Compare commits

..
158 changed files with 2840 additions and 9037 deletions
+16
View File
@@ -8,6 +8,7 @@ import (
"github.com/1Panel-dev/1Panel/agent/app/dto/request" "github.com/1Panel-dev/1Panel/agent/app/dto/request"
"github.com/1Panel-dev/1Panel/agent/i18n" "github.com/1Panel-dev/1Panel/agent/i18n"
"github.com/1Panel-dev/1Panel/agent/utils/appicon" "github.com/1Panel-dev/1Panel/agent/utils/appicon"
"github.com/1Panel-dev/1Panel/agent/utils/mcpapi"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
@@ -160,10 +161,25 @@ func (b *BaseApi) GetAppDetailByID(c *gin.Context) {
// @Router /apps/install [post] // @Router /apps/install [post]
// @x-panel-log {"bodyKeys":["name"],"paramKeys":[],"BeforeFunctions":[],"formatZH":"安装应用 [name]","formatEN":"Install app [name]"} // @x-panel-log {"bodyKeys":["name"],"paramKeys":[],"BeforeFunctions":[],"formatZH":"安装应用 [name]","formatEN":"Install app [name]"}
func (b *BaseApi) InstallApp(c *gin.Context) { func (b *BaseApi) InstallApp(c *gin.Context) {
mcpRequest, trustedMCP := mcpapi.Check(c.Request)
if mcpRequest && !trustedMCP {
c.AbortWithStatus(http.StatusForbidden)
return
}
var req request.AppInstallCreate var req request.AppInstallCreate
if err := helper.CheckBindAndValidate(&req, c); err != nil { if err := helper.CheckBindAndValidate(&req, c); err != nil {
return return
} }
if mcpRequest {
req.MCPPreserveWebsiteDir = true
if value, exists := req.Params["WEBSITE_DIR"]; exists {
directory, ok := value.(string)
if !ok || directory == "" || directory != settingService.GetWebsiteDir() {
c.AbortWithStatus(http.StatusForbidden)
return
}
}
}
install, err := appService.Install(req, true) install, err := appService.Install(req, true)
if err != nil { if err != nil {
helper.InternalServer(c, err) helper.InternalServer(c, err)
+18
View File
@@ -1,7 +1,11 @@
package v2 package v2
import ( import (
"net/http"
"github.com/1Panel-dev/1Panel/agent/app/dto/request" "github.com/1Panel-dev/1Panel/agent/app/dto/request"
"github.com/1Panel-dev/1Panel/agent/app/service"
"github.com/1Panel-dev/1Panel/agent/utils/mcpapi"
"github.com/1Panel-dev/1Panel/agent/app/api/v2/helper" "github.com/1Panel-dev/1Panel/agent/app/api/v2/helper"
"github.com/1Panel-dev/1Panel/agent/app/dto" "github.com/1Panel-dev/1Panel/agent/app/dto"
@@ -336,11 +340,25 @@ func (b *BaseApi) UpdateAppConfig(c *gin.Context) {
// @Security Timestamp // @Security Timestamp
// @Router /apps/installed/info/:appInstallId [get] // @Router /apps/installed/info/:appInstallId [get]
func (b *BaseApi) GetAppInstallInfo(c *gin.Context) { func (b *BaseApi) GetAppInstallInfo(c *gin.Context) {
mcpRequest, trustedMCP := mcpapi.Check(c.Request)
if mcpRequest && !trustedMCP {
c.AbortWithStatus(http.StatusForbidden)
return
}
appInstallId, err := helper.GetIntParamByKey(c, "appInstallId") appInstallId, err := helper.GetIntParamByKey(c, "appInstallId")
if err != nil { if err != nil {
helper.BadRequest(c, err) helper.BadRequest(c, err)
return return
} }
if mcpRequest {
info, err := (&service.AppInstallService{}).GetAppInstallInfoForMCP(c.Request.Context(), appInstallId)
if err != nil {
helper.InternalServer(c, err)
return
}
helper.SuccessWithData(c, info)
return
}
info, err := appInstallService.GetAppInstallInfo(appInstallId) info, err := appInstallService.GetAppInstallInfo(appInstallId)
if err != nil { if err != nil {
helper.InternalServer(c, err) helper.InternalServer(c, err)
@@ -0,0 +1,43 @@
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,9 +2,12 @@ package v2
import ( import (
"encoding/base64" "encoding/base64"
"net/http"
"github.com/1Panel-dev/1Panel/agent/app/api/v2/helper" "github.com/1Panel-dev/1Panel/agent/app/api/v2/helper"
"github.com/1Panel-dev/1Panel/agent/app/dto" "github.com/1Panel-dev/1Panel/agent/app/dto"
"github.com/1Panel-dev/1Panel/agent/app/service"
"github.com/1Panel-dev/1Panel/agent/utils/mcpapi"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
@@ -98,11 +101,25 @@ func (b *BaseApi) SearchDatabase(c *gin.Context) {
// @Param type path string true "type" // @Param type path string true "type"
// @Router /databases/db/list/:type [get] // @Router /databases/db/list/:type [get]
func (b *BaseApi) ListDatabase(c *gin.Context) { func (b *BaseApi) ListDatabase(c *gin.Context) {
mcpRequest, trustedMCP := mcpapi.Check(c.Request)
if mcpRequest && !trustedMCP {
c.AbortWithStatus(http.StatusForbidden)
return
}
dbType, err := helper.GetStrParamByKey(c, "type") dbType, err := helper.GetStrParamByKey(c, "type")
if err != nil { if err != nil {
helper.BadRequest(c, err) helper.BadRequest(c, err)
return return
} }
if mcpRequest {
list, err := (&service.DatabaseService{}).ListForMCP(c.Request.Context(), dbType)
if err != nil {
helper.BadRequest(c, err)
return
}
helper.SuccessWithData(c, list)
return
}
list, err := databaseService.List(dbType) list, err := databaseService.List(dbType)
if err != nil { if err != nil {
helper.InternalServer(c, err) helper.InternalServer(c, err)
+33
View File
@@ -3,9 +3,12 @@ package v2
import ( import (
"context" "context"
"encoding/base64" "encoding/base64"
"net/http"
"github.com/1Panel-dev/1Panel/agent/app/api/v2/helper" "github.com/1Panel-dev/1Panel/agent/app/api/v2/helper"
"github.com/1Panel-dev/1Panel/agent/app/dto" "github.com/1Panel-dev/1Panel/agent/app/dto"
"github.com/1Panel-dev/1Panel/agent/app/service"
"github.com/1Panel-dev/1Panel/agent/utils/mcpapi"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
@@ -19,6 +22,11 @@ import (
// @Router /databases [post] // @Router /databases [post]
// @x-panel-log {"bodyKeys":["name"],"paramKeys":[],"BeforeFunctions":[],"formatZH":"创建 mysql 数据库 [name]","formatEN":"create mysql database [name]"} // @x-panel-log {"bodyKeys":["name"],"paramKeys":[],"BeforeFunctions":[],"formatZH":"创建 mysql 数据库 [name]","formatEN":"create mysql database [name]"}
func (b *BaseApi) CreateMysql(c *gin.Context) { func (b *BaseApi) CreateMysql(c *gin.Context) {
mcpRequest, trustedMCP := mcpapi.Check(c.Request)
if mcpRequest && !trustedMCP {
c.AbortWithStatus(http.StatusForbidden)
return
}
var req dto.MysqlDBCreate var req dto.MysqlDBCreate
if err := helper.CheckBindAndValidate(&req, c); err != nil { if err := helper.CheckBindAndValidate(&req, c); err != nil {
return return
@@ -32,6 +40,17 @@ func (b *BaseApi) CreateMysql(c *gin.Context) {
} }
req.Password = string(password) req.Password = string(password)
} }
if mcpRequest {
created, err := (&service.MysqlService{}).CreateForMCP(c.Request.Context(), req)
if err != nil {
helper.InternalServer(c, err)
return
}
nodeID, _ := mcpapi.NodeID(c.Request)
helper.SuccessWithData(c, map[string]any{"id": created.ID, "database": created.MysqlName, "name": created.Name, "nodeId": nodeID})
return
}
if _, err := mysqlService.Create(context.Background(), req); err != nil { if _, err := mysqlService.Create(context.Background(), req); err != nil {
helper.InternalServer(c, err) helper.InternalServer(c, err)
return return
@@ -512,11 +531,25 @@ func (b *BaseApi) LoadRemoteAccess(c *gin.Context) {
// @Security Timestamp // @Security Timestamp
// @Router /databases/status [post] // @Router /databases/status [post]
func (b *BaseApi) LoadStatus(c *gin.Context) { func (b *BaseApi) LoadStatus(c *gin.Context) {
mcpRequest, trustedMCP := mcpapi.Check(c.Request)
if mcpRequest && !trustedMCP {
c.AbortWithStatus(http.StatusForbidden)
return
}
var req dto.OperationWithNameAndType var req dto.OperationWithNameAndType
if err := helper.CheckBindAndValidate(&req, c); err != nil { if err := helper.CheckBindAndValidate(&req, c); err != nil {
return return
} }
if mcpRequest {
data, err := (&service.MysqlService{}).LoadStatusForMCP(c.Request.Context(), req)
if err != nil {
helper.InternalServer(c, err)
return
}
helper.SuccessWithData(c, data)
return
}
data, err := mysqlService.LoadStatus(req) data, err := mysqlService.LoadStatus(req)
if err != nil { if err != nil {
helper.InternalServer(c, err) helper.InternalServer(c, err)
-137
View File
@@ -1,137 +0,0 @@
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
@@ -1,29 +0,0 @@
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"
}
+3 -12
View File
@@ -5,8 +5,6 @@ import (
"github.com/1Panel-dev/1Panel/agent/app/dto" "github.com/1Panel-dev/1Panel/agent/app/dto"
"github.com/1Panel-dev/1Panel/agent/app/dto/request" "github.com/1Panel-dev/1Panel/agent/app/dto/request"
"github.com/1Panel-dev/1Panel/agent/app/dto/response" "github.com/1Panel-dev/1Panel/agent/app/dto/response"
"github.com/1Panel-dev/1Panel/agent/app/service"
"github.com/1Panel-dev/1Panel/agent/utils/taskscope"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
@@ -23,7 +21,7 @@ func (b *BaseApi) PageTasks(c *gin.Context) {
if err := helper.CheckBindAndValidate(&req, c); err != nil { if err := helper.CheckBindAndValidate(&req, c); err != nil {
return return
} }
total, list, err := taskServiceForRequest(c).Page(req) total, list, err := taskService.Page(req)
if err != nil { if err != nil {
helper.InternalServer(c, err) helper.InternalServer(c, err)
return return
@@ -47,7 +45,7 @@ func (b *BaseApi) ReadTaskLogByLine(c *gin.Context) {
return return
} }
var res *response.FileLineContent var res *response.FileLineContent
res, err := taskServiceForRequest(c).ReadByLine(req) res, err := taskService.ReadByLine(req)
if err != nil { if err != nil {
helper.InternalServer(c, err) helper.InternalServer(c, err)
return return
@@ -66,17 +64,10 @@ func (b *BaseApi) ReadTaskLogByLine(c *gin.Context) {
// @Security Timestamp // @Security Timestamp
// @Router /logs/tasks/executing/count [get] // @Router /logs/tasks/executing/count [get]
func (b *BaseApi) CountExecutingTasks(c *gin.Context) { func (b *BaseApi) CountExecutingTasks(c *gin.Context) {
count, err := taskServiceForRequest(c).CountExecutingTask() count, err := taskService.CountExecutingTask()
if err != nil { if err != nil {
helper.InternalServer(c, err) helper.InternalServer(c, err)
return return
} }
helper.SuccessWithData(c, count) helper.SuccessWithData(c, count)
} }
func taskServiceForRequest(c *gin.Context) service.ITaskLogService {
if granted := taskscope.Grants(c.Request.Context()); len(granted) != 0 {
return service.NewITaskService(granted...)
}
return taskService
}
+10 -7
View File
@@ -21,13 +21,16 @@ type InspectReq struct {
} }
type ContainerInfo struct { type ContainerInfo struct {
ContainerID string `json:"containerID"` LatestVersion string `json:"latestVersion"`
Name string `json:"name"` TargetImage string `json:"targetImage"`
ImageId string `json:"imageID"` HasUpdate bool `json:"hasUpdate"`
ImageName string `json:"imageName"` ContainerID string `json:"containerID"`
CreateTime string `json:"createTime"` Name string `json:"name"`
State string `json:"state"` ImageId string `json:"imageID"`
RunTime string `json:"runTime"` ImageName string `json:"imageName"`
CreateTime string `json:"createTime"`
State string `json:"state"`
RunTime string `json:"runTime"`
Network []string `json:"network"` Network []string `json:"network"`
Ports []string `json:"ports"` Ports []string `json:"ports"`
+2
View File
@@ -27,6 +27,8 @@ type AppInstallCreate struct {
ModelAccountBaseURLType string `json:"modelAccountBaseURLType" validate:"omitempty,oneof=container localhost systemIP custom"` ModelAccountBaseURLType string `json:"modelAccountBaseURLType" validate:"omitempty,oneof=container localhost systemIP custom"`
ModelAccountBaseURL string `json:"modelAccountBaseURL"` ModelAccountBaseURL string `json:"modelAccountBaseURL"`
MCPPreserveWebsiteDir bool `json:"-"`
AppContainerConfig AppContainerConfig
NodePushConfig NodePushConfig
} }
-14
View File
@@ -1,14 +0,0 @@
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
@@ -1,17 +0,0 @@
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"`
}
@@ -0,0 +1,20 @@
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,7 +38,6 @@ type IAppInstallRepo interface {
LoadBaseInfo(key string, name string) (*RootInfo, error) LoadBaseInfo(key string, name string) (*RootInfo, error)
LoadInstallAppByKeyAndName(key string, name string) (*model.AppInstall, error) LoadInstallAppByKeyAndName(key string, name string) (*model.AppInstall, error)
GetFirstByCtx(ctx context.Context, opts ...DBOption) (model.AppInstall, error) GetFirstByCtx(ctx context.Context, opts ...DBOption) (model.AppInstall, error)
GetSnapshot(ctx context.Context, id uint) (model.AppInstall, error)
} }
func NewIAppInstallRepo() IAppInstallRepo { func NewIAppInstallRepo() IAppInstallRepo {
@@ -132,12 +131,6 @@ func (a *AppInstallRepo) GetFirstByCtx(ctx context.Context, opts ...DBOption) (m
return install, err return install, err
} }
func (a *AppInstallRepo) GetSnapshot(ctx context.Context, id uint) (model.AppInstall, error) {
var install model.AppInstall
err := getTx(ctx).WithContext(ctx).Preload("App").Where("id = ?", id).First(&install).Error
return install, err
}
func (a *AppInstallRepo) Create(ctx context.Context, install *model.AppInstall) error { func (a *AppInstallRepo) Create(ctx context.Context, install *model.AppInstall) error {
db := getTx(ctx).Model(&model.AppInstall{}) db := getTx(ctx).Model(&model.AppInstall{})
return db.Omit(clause.Associations).Create(&install).Error return db.Omit(clause.Associations).Create(&install).Error
-29
View File
@@ -6,7 +6,6 @@ import (
"strings" "strings"
"github.com/1Panel-dev/1Panel/agent/app/model" "github.com/1Panel-dev/1Panel/agent/app/model"
"github.com/1Panel-dev/1Panel/agent/constant"
"github.com/1Panel-dev/1Panel/agent/global" "github.com/1Panel-dev/1Panel/agent/global"
"github.com/1Panel-dev/1Panel/agent/utils/encrypt" "github.com/1Panel-dev/1Panel/agent/utils/encrypt"
"gorm.io/gorm" "gorm.io/gorm"
@@ -21,8 +20,6 @@ type IDatabaseRepo interface {
Update(id uint, vars map[string]interface{}) error Update(id uint, vars map[string]interface{}) error
Delete(ctx context.Context, opts ...DBOption) error Delete(ctx context.Context, opts ...DBOption) error
Get(opts ...DBOption) (model.Database, error) Get(opts ...DBOption) (model.Database, error)
ListInstalledLocalMysql(ctx context.Context) ([]InstalledLocalMysql, error)
GetInstalledLocalMysql(ctx context.Context, name string) (InstalledLocalMysql, error)
WithoutByFrom(from string) DBOption WithoutByFrom(from string) DBOption
WithAppInstallID(appInstallID uint) DBOption WithAppInstallID(appInstallID uint) DBOption
WithTypeList(dbType string) DBOption WithTypeList(dbType string) DBOption
@@ -32,32 +29,6 @@ func NewIDatabaseRepo() IDatabaseRepo {
return &DatabaseRepo{} return &DatabaseRepo{}
} }
type InstalledLocalMysql struct {
ID uint
Type, From, Database string
ContainerName, Version, Env string
}
func installedLocalMysqlQuery(ctx context.Context) *gorm.DB {
return getTx(ctx).WithContext(ctx).Table("databases AS d").
Joins("JOIN app_installs AS ai ON ai.id = d.app_install_id AND ai.name = d.name").
Joins("JOIN apps AS a ON a.id = ai.app_id").
Where("d.type = ? AND d.\"from\" = ? AND a.key = ?", constant.AppMysql, "local", constant.AppMysql).
Where("ai.status <> ? AND ai.container_name <> ?", constant.StatusDeleted, "")
}
func (d *DatabaseRepo) ListInstalledLocalMysql(ctx context.Context) ([]InstalledLocalMysql, error) {
result := make([]InstalledLocalMysql, 0)
err := installedLocalMysqlQuery(ctx).Select("d.id, d.type, d.\"from\", d.name AS database, ai.version").Order("d.id").Scan(&result).Error
return result, err
}
func (d *DatabaseRepo) GetInstalledLocalMysql(ctx context.Context, name string) (InstalledLocalMysql, error) {
var result InstalledLocalMysql
err := installedLocalMysqlQuery(ctx).Where("d.name = ?", name).Select("d.id, ai.container_name, ai.version, ai.env").Take(&result).Error
return result, err
}
func (d *DatabaseRepo) Get(opts ...DBOption) (model.Database, error) { func (d *DatabaseRepo) Get(opts ...DBOption) (model.Database, error) {
var database model.Database var database model.Database
db := global.DB db := global.DB
-5
View File
@@ -18,7 +18,6 @@ type IMysqlRepo interface {
List(opts ...DBOption) ([]model.DatabaseMysql, error) List(opts ...DBOption) ([]model.DatabaseMysql, error)
Page(limit, offset int, opts ...DBOption) (int64, []model.DatabaseMysql, error) Page(limit, offset int, opts ...DBOption) (int64, []model.DatabaseMysql, error)
Create(ctx context.Context, mysql *model.DatabaseMysql) error Create(ctx context.Context, mysql *model.DatabaseMysql) error
CreateMetadata(ctx context.Context, mysql *model.DatabaseMysql) error
Delete(ctx context.Context, opts ...DBOption) error Delete(ctx context.Context, opts ...DBOption) error
Update(id uint, vars map[string]interface{}) error Update(id uint, vars map[string]interface{}) error
DeleteLocal(ctx context.Context) error DeleteLocal(ctx context.Context) error
@@ -95,10 +94,6 @@ func (u *MysqlRepo) Create(ctx context.Context, mysql *model.DatabaseMysql) erro
return getTx(ctx).Create(mysql).Error return getTx(ctx).Create(mysql).Error
} }
func (u *MysqlRepo) CreateMetadata(ctx context.Context, mysql *model.DatabaseMysql) error {
return getTx(ctx).WithContext(ctx).Create(mysql).Error
}
func (u *MysqlRepo) Delete(ctx context.Context, opts ...DBOption) error { func (u *MysqlRepo) Delete(ctx context.Context, opts ...DBOption) error {
return getTx(ctx, opts...).Delete(&model.DatabaseMysql{}).Error return getTx(ctx, opts...).Delete(&model.DatabaseMysql{}).Error
} }
-13
View File
@@ -14,10 +14,8 @@ type DatabaseUserRepo struct{}
type IDatabaseUserRepo interface { type IDatabaseUserRepo interface {
Get(opts ...DBOption) (model.DatabaseUser, error) Get(opts ...DBOption) (model.DatabaseUser, error)
GetEncryptedWithContext(ctx context.Context, opts ...DBOption) (model.DatabaseUser, error)
List(opts ...DBOption) ([]model.DatabaseUser, error) List(opts ...DBOption) ([]model.DatabaseUser, error)
Save(user *model.DatabaseUser) error Save(user *model.DatabaseUser) error
SaveEncryptedWithContext(ctx context.Context, user *model.DatabaseUser) error
Delete(opts ...DBOption) error Delete(opts ...DBOption) error
DeleteBy(ctx context.Context, opts ...DBOption) error DeleteBy(ctx context.Context, opts ...DBOption) error
Update(vars map[string]interface{}, opts ...DBOption) error Update(vars map[string]interface{}, opts ...DBOption) error
@@ -47,13 +45,6 @@ func (u *DatabaseUserRepo) Get(opts ...DBOption) (model.DatabaseUser, error) {
return user, nil return user, nil
} }
// GetEncryptedWithContext leaves credential transformation to the caller.
func (u *DatabaseUserRepo) GetEncryptedWithContext(ctx context.Context, opts ...DBOption) (model.DatabaseUser, error) {
var user model.DatabaseUser
err := getTx(ctx, opts...).WithContext(ctx).First(&user).Error
return user, err
}
func (u *DatabaseUserRepo) List(opts ...DBOption) ([]model.DatabaseUser, error) { func (u *DatabaseUserRepo) List(opts ...DBOption) ([]model.DatabaseUser, error) {
var users []model.DatabaseUser var users []model.DatabaseUser
db := global.DB.Model(&model.DatabaseUser{}) db := global.DB.Model(&model.DatabaseUser{})
@@ -84,10 +75,6 @@ func (u *DatabaseUserRepo) Save(user *model.DatabaseUser) error {
return global.DB.Save(user).Error return global.DB.Save(user).Error
} }
func (u *DatabaseUserRepo) SaveEncryptedWithContext(ctx context.Context, user *model.DatabaseUser) error {
return getTx(ctx).WithContext(ctx).Save(user).Error
}
func (u *DatabaseUserRepo) Delete(opts ...DBOption) error { func (u *DatabaseUserRepo) Delete(opts ...DBOption) error {
db := global.DB db := global.DB
for _, opt := range opts { for _, opt := range opts {
-7
View File
@@ -14,7 +14,6 @@ type IDatabaseUserGrantRepo interface {
Get(opts ...DBOption) (model.DatabaseUserGrant, error) Get(opts ...DBOption) (model.DatabaseUserGrant, error)
List(opts ...DBOption) ([]model.DatabaseUserGrant, error) List(opts ...DBOption) ([]model.DatabaseUserGrant, error)
Save(grant *model.DatabaseUserGrant) error Save(grant *model.DatabaseUserGrant) error
CreateIfAbsent(ctx context.Context, grant *model.DatabaseUserGrant) error
Replace(dbType, database string, grants []model.DatabaseUserGrant) error Replace(dbType, database string, grants []model.DatabaseUserGrant) error
Delete(opts ...DBOption) error Delete(opts ...DBOption) error
DeleteBy(ctx context.Context, opts ...DBOption) error DeleteBy(ctx context.Context, opts ...DBOption) error
@@ -53,12 +52,6 @@ func (u *DatabaseUserGrantRepo) Save(grant *model.DatabaseUserGrant) error {
return global.DB.Save(grant).Error return global.DB.Save(grant).Error
} }
func (u *DatabaseUserGrantRepo) CreateIfAbsent(ctx context.Context, grant *model.DatabaseUserGrant) error {
return getTx(ctx).WithContext(ctx).
Where("type = ? AND database = ? AND db_name = ? AND username = ? AND host = ?", grant.Type, grant.Database, grant.DBName, grant.Username, grant.Host).
FirstOrCreate(grant).Error
}
func (u *DatabaseUserGrantRepo) Replace(dbType, database string, grants []model.DatabaseUserGrant) error { func (u *DatabaseUserGrantRepo) Replace(dbType, database string, grants []model.DatabaseUserGrant) error {
return global.DB.Transaction(func(tx *gorm.DB) error { return global.DB.Transaction(func(tx *gorm.DB) error {
if err := tx.Where("`type` = ? AND database = ?", dbType, database).Delete(&model.DatabaseUserGrant{}).Error; err != nil { if err := tx.Where("`type` = ? AND database = ?", dbType, database).Delete(&model.DatabaseUserGrant{}).Error; err != nil {
-19
View File
@@ -15,13 +15,10 @@ type SettingRepo struct{}
type ISettingRepo interface { type ISettingRepo interface {
GetList(opts ...DBOption) ([]model.Setting, error) GetList(opts ...DBOption) ([]model.Setting, error)
Get(opts ...DBOption) (model.Setting, error) Get(opts ...DBOption) (model.Setting, error)
GetWithContext(ctx context.Context, opts ...DBOption) (model.Setting, error)
GetValueByKey(key string) (string, error) GetValueByKey(key string) (string, error)
GetCoreValueByKey(key string) (string, error) GetCoreValueByKey(key string) (string, error)
Create(key, value string) error Create(key, value string) error
CreateWithContext(ctx context.Context, setting *model.Setting) error
Update(key, value string) error Update(key, value string) error
UpdateIfMatch(ctx context.Context, id uint, expectedValue, value string) (int64, error)
WithByKey(key string) DBOption WithByKey(key string) DBOption
UpdateOrCreate(key, value string) error UpdateOrCreate(key, value string) error
@@ -70,22 +67,6 @@ func (s *SettingRepo) Get(opts ...DBOption) (model.Setting, error) {
return settings, err return settings, err
} }
func (s *SettingRepo) GetWithContext(ctx context.Context, opts ...DBOption) (model.Setting, error) {
var setting model.Setting
err := getTx(ctx, opts...).WithContext(ctx).First(&setting).Error
return setting, err
}
func (s *SettingRepo) CreateWithContext(ctx context.Context, setting *model.Setting) error {
return getTx(ctx).WithContext(ctx).Create(setting).Error
}
func (s *SettingRepo) UpdateIfMatch(ctx context.Context, id uint, expectedValue, value string) (int64, error) {
result := getTx(ctx).WithContext(ctx).Model(&model.Setting{}).
Where("id = ? AND value = ?", id, expectedValue).Update("value", value)
return result.RowsAffected, result.Error
}
func (s *SettingRepo) GetValueByKey(key string) (string, error) { func (s *SettingRepo) GetValueByKey(key string) (string, error) {
var setting model.Setting var setting model.Setting
if err := global.DB.Model(&model.Setting{}).Where("key = ?", key).First(&setting).Error; err != nil { if err := global.DB.Model(&model.Setting{}).Where("key = ?", key).First(&setting).Error; err != nil {
+6 -19
View File
@@ -19,8 +19,8 @@ type ITaskRepo interface {
GetFirst(opts ...DBOption) (model.Task, error) GetFirst(opts ...DBOption) (model.Task, error)
Page(page, size int, opts ...DBOption) (int64, []model.Task, error) Page(page, size int, opts ...DBOption) (int64, []model.Task, error)
Update(ctx context.Context, task *model.Task) error Update(ctx context.Context, task *model.Task) error
UpdateRunningTaskToFailed(opts ...DBOption) error UpdateRunningTaskToFailed() error
CountExecutingTask(opts ...DBOption) (int64, error) CountExecutingTask() (int64, error)
Delete(opts ...DBOption) error Delete(opts ...DBOption) error
DeleteAll() error DeleteAll() error
@@ -29,7 +29,6 @@ type ITaskRepo interface {
WithResourceID(id uint) DBOption WithResourceID(id uint) DBOption
WithOperate(taskOperate string) DBOption WithOperate(taskOperate string) DBOption
WithByStatus(status string) DBOption WithByStatus(status string) DBOption
WithoutScopes(scopes ...string) DBOption
} }
func NewITaskRepo() ITaskRepo { func NewITaskRepo() ITaskRepo {
@@ -111,30 +110,18 @@ func (t TaskRepo) Update(ctx context.Context, task *model.Task) error {
return getTaskTx(ctx).Save(&task).Error return getTaskTx(ctx).Save(&task).Error
} }
func (t TaskRepo) UpdateRunningTaskToFailed(opts ...DBOption) error { func (t TaskRepo) UpdateRunningTaskToFailed() error {
opts = append(opts, t.WithByStatus(constant.StatusExecuting)) return getTaskDb(t.WithByStatus(constant.StatusExecuting)).Model(&model.Task{}).Updates(map[string]interface{}{
return getTaskDb(opts...).Model(&model.Task{}).Updates(map[string]interface{}{
"status": constant.StatusFailed, "error_msg": constant.InterruptedMsg, "end_at": time.Now(), "status": constant.StatusFailed, "error_msg": constant.InterruptedMsg, "end_at": time.Now(),
}).Error }).Error
} }
func (t TaskRepo) CountExecutingTask(opts ...DBOption) (int64, error) { func (t TaskRepo) CountExecutingTask() (int64, error) {
var count int64 var count int64
opts = append(opts, t.WithByStatus(constant.StatusExecuting)) err := getTaskDb(t.WithByStatus(constant.StatusExecuting)).Model(&model.Task{}).Count(&count).Error
err := getTaskDb(opts...).Model(&model.Task{}).Count(&count).Error
return count, err return count, err
} }
func (t TaskRepo) WithoutScopes(scopes ...string) DBOption {
excluded := append([]string(nil), scopes...)
return func(db *gorm.DB) *gorm.DB {
if len(excluded) == 0 {
return db
}
return db.Where("type IS NULL OR type NOT IN ?", excluded)
}
}
func (t TaskRepo) Delete(opts ...DBOption) error { func (t TaskRepo) Delete(opts ...DBOption) error {
db := global.TaskDB db := global.TaskDB
for _, opt := range opts { for _, opt := range opts {
+2 -3
View File
@@ -59,8 +59,7 @@ type IAppService interface {
} }
type appInstallHooks struct { type appInstallHooks struct {
AfterCopyData func(appInstall *model.AppInstall) error AfterCopyData func(appInstall *model.AppInstall) error
PrepareSiteDir func(model.App, model.AppDetail, request.AppInstallCreate, *task.Task) error
} }
func NewIAppService() IAppService { func NewIAppService() IAppService {
@@ -615,7 +614,7 @@ func (a AppService) installWithHooks(req request.AppInstallCreate, executeScript
} }
} }
if app.Key == "openresty" { if app.Key == "openresty" {
if err = prepareAppInstallSiteDirectory(app, appDetail, req, t, hooks); err != nil { if err = handleSiteDir(app, appDetail, req, t); err != nil {
return err return err
} }
if err = handleOpenrestyFile(appInstall); err != nil { if err = handleOpenrestyFile(appInstall); err != nil {
+44 -10
View File
@@ -1,19 +1,53 @@
package service package service
import ( import (
"errors"
"os"
"path"
"github.com/1Panel-dev/1Panel/agent/app/dto/request" "github.com/1Panel-dev/1Panel/agent/app/dto/request"
"github.com/1Panel-dev/1Panel/agent/app/model" "github.com/1Panel-dev/1Panel/agent/app/model"
"github.com/1Panel-dev/1Panel/agent/app/task" "github.com/1Panel-dev/1Panel/agent/constant"
"github.com/1Panel-dev/1Panel/agent/global"
"gorm.io/gorm"
) )
func (a AppService) InstallForMCP(req request.AppInstallCreate, executeScript bool) (*model.AppInstall, error) { func prepareMCPWebsiteDirectory(req request.AppInstallCreate) error {
return a.installWithHooks(req, executeScript, &appInstallHooks{PrepareSiteDir: prepareMCPInstallSiteDirectory}) 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 {
func prepareMCPInstallSiteDirectory(app model.App, detail model.AppDetail, req request.AppInstallCreate, _ *task.Task) error { return changed
if !managesOpenrestySiteDirectory(app, detail) {
return nil
} }
directory, _ := req.Params["WEBSITE_DIR"].(string) return global.DB.Transaction(func(tx *gorm.DB) error {
return ensureCurrentWebsiteDirectory(directory) 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
})
} }
-49
View File
@@ -1,49 +0,0 @@
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)
})
}
+4 -12
View File
@@ -2033,19 +2033,11 @@ func getAppTags(appID uint, lang string) ([]response.TagDTO, error) {
return res, nil return res, nil
} }
func prepareAppInstallSiteDirectory(app model.App, appDetail model.AppDetail, req request.AppInstallCreate, t *task.Task, hooks *appInstallHooks) error {
if hooks != nil && hooks.PrepareSiteDir != nil {
return hooks.PrepareSiteDir(app, appDetail, req, t)
}
return handleSiteDir(app, appDetail, req, t)
}
func managesOpenrestySiteDirectory(app model.App, appDetail model.AppDetail) bool {
return app.Key == "openresty" && (app.Resource == "remote" || app.Resource == "custom") && common.CompareVersion(appDetail.Version, "1.27")
}
func handleSiteDir(app model.App, appDetail model.AppDetail, req request.AppInstallCreate, t *task.Task) error { func handleSiteDir(app model.App, appDetail model.AppDetail, req request.AppInstallCreate, t *task.Task) error {
if managesOpenrestySiteDirectory(app, appDetail) { if app.Key == "openresty" && (app.Resource == "remote" || app.Resource == "custom") && common.CompareVersion(appDetail.Version, "1.27") {
if req.MCPPreserveWebsiteDir {
return prepareMCPWebsiteDirectory(req)
}
if dir, ok := req.Params["WEBSITE_DIR"]; ok { if dir, ok := req.Params["WEBSITE_DIR"]; ok {
siteDir := dir.(string) siteDir := dir.(string)
if siteDir == "" || !strings.HasPrefix(siteDir, "/") { if siteDir == "" || !strings.HasPrefix(siteDir, "/") {
+1 -1
View File
@@ -261,7 +261,7 @@ func doWebsiteBackup(website *model.Website, parentTask *task.Task, backupDir, f
return err return err
} }
nginxConfFile := GetWebsiteConfigPath(*website) nginxConfFile := GetWebsiteConfigPath(*website)
if err := exportWebsiteNginxConfig(*website, nginxConfFile, tmpDir); err != nil { if err := fileOp.CopyFile(nginxConfFile, tmpDir); err != nil {
return err return err
} }
parentTask.Log(i18n.GetMsgByKey("BackupNginxConfig")) parentTask.Log(i18n.GetMsgByKey("BackupNginxConfig"))
+26 -11
View File
@@ -140,6 +140,29 @@ func (u *ContainerService) Page(ctx context.Context, req dto.PageContainer) (int
backData = records[start:end] 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++ { for i := 0; i < len(backData); i++ {
install, _ := appInstallRepo.GetFirst(appInstallRepo.WithContainerName(backData[i].Name)) install, _ := appInstallRepo.GetFirst(appInstallRepo.WithContainerName(backData[i].Name))
if install.ID > 0 { if install.ID > 0 {
@@ -1660,10 +1683,6 @@ func calculateNetwork(network map[string]container.NetworkStats) (float64, float
} }
func checkImageExist(client *client.Client, imageItem string) bool { func checkImageExist(client *client.Client, imageItem string) bool {
return checkImageExistContext(context.Background(), client, imageItem)
}
func checkImageExistContext(ctx context.Context, client *client.Client, imageItem string) bool {
if client == nil { if client == nil {
var err error var err error
client, err = docker.NewDockerClient() client, err = docker.NewDockerClient()
@@ -1671,7 +1690,7 @@ func checkImageExistContext(ctx context.Context, client *client.Client, imageIte
return false return false
} }
} }
images, err := client.ImageList(ctx, image.ListOptions{}) images, err := client.ImageList(context.Background(), image.ListOptions{})
if err != nil { if err != nil {
return false return false
} }
@@ -1713,10 +1732,6 @@ func checkImageLike(client *client.Client, imageName string) bool {
} }
func pullImages(task *task.Task, client *client.Client, imageName string) error { func pullImages(task *task.Task, client *client.Client, imageName string) error {
return pullImagesContext(context.Background(), task, client, imageName)
}
func pullImagesContext(ctx context.Context, task *task.Task, client *client.Client, imageName string) error {
dockerCli := docker.NewClientWithExist(client) dockerCli := docker.NewClientWithExist(client)
repos, err := imageRepoRepo.List() repos, err := imageRepoRepo.List()
if err != nil { if err != nil {
@@ -1724,7 +1739,7 @@ func pullImagesContext(ctx context.Context, task *task.Task, client *client.Clie
} }
imageRepo := selectImageRepo(imageName, repos) imageRepo := selectImageRepo(imageName, repos)
if imageRepo == nil || !imageRepo.Auth { if imageRepo == nil || !imageRepo.Auth {
return dockerCli.PullImageWithProcessContext(ctx, task, imageName) return dockerCli.PullImageWithProcess(task, imageName)
} }
options := image.PullOptions{} options := image.PullOptions{}
@@ -1737,7 +1752,7 @@ func pullImagesContext(ctx context.Context, task *task.Task, client *client.Clie
return err return err
} }
options.RegistryAuth = base64.URLEncoding.EncodeToString(encodedJSON) options.RegistryAuth = base64.URLEncoding.EncodeToString(encodedJSON)
return dockerCli.PullImageWithProcessAndOptionsContext(ctx, task, imageName, options) return dockerCli.PullImageWithProcessAndOptions(task, imageName, options)
} }
func selectImageRepo(imageName string, repos []model.ImageRepo) *model.ImageRepo { func selectImageRepo(imageName string, repos []model.ImageRepo) *model.ImageRepo {
@@ -0,0 +1,379 @@
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)
}
+76 -270
View File
@@ -91,95 +91,86 @@ func (u *ContainerService) ContainerUpdate(req dto.ContainerOperate) error {
return nil return nil
} }
const containerUpgradeConcurrency = 2
// Shared by all upgrade requests handled by this agent.
var containerUpgradeSlots = make(chan struct{}, containerUpgradeConcurrency)
func (u *ContainerService) ContainerUpgrade(req dto.ContainerUpgrade) error { func (u *ContainerService) ContainerUpgrade(req dto.ContainerUpgrade) error {
client, err := docker.NewDockerClient() client, err := docker.NewDockerClient()
if err != nil { if err != nil {
return err return err
} }
ctx := context.Background()
taskItem, err := task.NewTaskWithOps(req.Image, task.TaskUpgrade, task.TaskScopeImage, req.TaskID, 1) taskItem, err := task.NewTaskWithOps(req.Image, task.TaskUpgrade, task.TaskScopeImage, req.TaskID, 1)
if err != nil { if err != nil {
_ = client.Close() _ = client.Close()
global.LOG.Errorf("new task for create container failed, err: %v", err) global.LOG.Errorf("new task for create container failed, err: %v", err)
return err return err
} }
taskItem.AddSubTask(i18n.GetWithName("ContainerImagePull", req.Image), func(t *task.Task) error { go func() {
ctx, cancel := context.WithTimeout(context.Background(), containerUpgradePullTimeout) defer client.Close()
defer cancel() taskItem.AddSubTask(i18n.GetWithName("ContainerImagePull", req.Image), func(t *task.Task) error {
taskItem.LogStart(i18n.GetWithName("ContainerImagePull", req.Image)) taskItem.LogStart(i18n.GetWithName("ContainerImagePull", req.Image))
if req.ForcePull || !checkImageExistContext(ctx, client, req.Image) { if !checkImageExist(client, req.Image) || req.ForcePull {
if err := pullImagesContext(ctx, taskItem, client, req.Image); err != nil { if err := pullImages(taskItem, client, req.Image); err != nil {
return fmt.Errorf("pull image %s failed: %w", req.Image, err) if !req.ForcePull {
} return err
} }
return nil return fmt.Errorf("pull image %s failed, err: %v", req.Image, err)
}, nil) }
var upgradeErrors []error
for _, item := range req.Names {
item := item
taskItem.AddSubTaskWithIgnoreErr(i18n.GetWithName("ContainerUpgradeItem", item), func(t *task.Task) error {
t.Logf("----------------- %s -----------------", item)
t.LogStart(i18n.GetWithName("ContainerAcquireLock", item))
ctx, cancel := context.WithTimeout(context.Background(), containerUpgradeReplaceTimeout)
defer cancel()
unlock, lockErr := containerOperationLock.lockContext(ctx, item)
if lockErr != nil {
upgradeErrors = append(upgradeErrors, fmt.Errorf("wait for container %s lock: %w", item, lockErr))
return lockErr
}
defer unlock()
t.LogWithStatus(i18n.GetWithName("ContainerAcquireLock", item), nil)
oldContainer, inspectErr := client.ContainerInspect(ctx, item)
t.LogWithStatus(i18n.GetWithName("ContainerLoadInfo", item), inspectErr)
if inspectErr != nil {
err := fmt.Errorf("reload container %s failed: %w", item, inspectErr)
upgradeErrors = append(upgradeErrors, err)
return err
}
config := cloneContainerConfig(oldContainer.Config)
config.Image = req.Image
if config.Labels == nil {
config.Labels = make(map[string]string)
}
config.Labels[containerUpgradeOwnerLabel] = oldContainer.ID
hostConf := cloneContainerHostConfig(oldContainer.HostConfig)
preserveContainerVolumeMounts(hostConf, oldContainer.Mounts)
networkConf, extraNetworks := buildContainerRecoverNetworkConfig(oldContainer.NetworkSettings, hostConf)
if err := normalizeContainerEndpointSettings(ctx, client, networkConf, extraNetworks); err != nil {
upgradeErr := fmt.Errorf("prepare networks for container %s failed: %w", item, err)
upgradeErrors = append(upgradeErrors, upgradeErr)
return upgradeErr
}
cleanupErr, err := switchContainerWithRecovery(ctx, client, item, oldContainer, func() (container.CreateResponse, error) {
return createContainerWithNetworks(ctx, client, config, hostConf, networkConf, extraNetworks, item)
}, config.Tty, t, true)
if err != nil {
upgradeErr := fmt.Errorf("upgrade container %s failed: %w", item, err)
upgradeErrors = append(upgradeErrors, upgradeErr)
return upgradeErr
}
if cleanupErr != nil {
t.Log(i18n.GetWithNameAndErr("ContainerCleanupWarning", containerSwitchBackupName(oldContainer.ID), cleanupErr))
} }
return nil return nil
}) }, nil)
} var upgradeErrors []error
taskItem.AddSubTask(i18n.GetMsgByKey("ContainerUpgradeSummary"), func(t *task.Task) error { for _, item := range req.Names {
return errors.Join(upgradeErrors...) item := item
}, nil) taskItem.AddSubTaskWithIgnoreErr(i18n.GetWithName("ContainerUpgradeItem", item), func(t *task.Task) error {
// Stage contexts own the deadlines, including a separate recovery budget. t.Logf("----------------- %s -----------------", item)
for _, subTask := range taskItem.SubTasks { t.LogStart(i18n.GetWithName("ContainerAcquireLock", item))
subTask.Timeout = 0 unlock := containerOperationLock.lock(item)
} defer unlock()
if err := taskItem.ExecuteQueued(containerUpgradeSlots, "ContainerUpgradeWaiting", func() { _ = client.Close() }); err != nil { t.LogWithStatus(i18n.GetWithName("ContainerAcquireLock", item), nil)
_ = client.Close()
return err 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())
}
}()
return nil return nil
} }
@@ -206,16 +197,11 @@ type containerOperationMutex struct {
var containerOperationLock containerOperationMutex var containerOperationLock containerOperationMutex
type containerOperationLockEntry struct { type containerOperationLockEntry struct {
token chan struct{} mutex sync.Mutex
references int references int
} }
func (l *containerOperationMutex) lock(names ...string) func() { func (l *containerOperationMutex) lock(names ...string) func() {
unlock, _ := l.lockContext(context.Background(), names...)
return unlock
}
func (l *containerOperationMutex) lockContext(ctx context.Context, names ...string) (func(), error) {
nameSet := make(map[string]struct{}, len(names)) nameSet := make(map[string]struct{}, len(names))
for _, name := range names { for _, name := range names {
if name != "" { if name != "" {
@@ -228,7 +214,7 @@ func (l *containerOperationMutex) lockContext(ctx context.Context, names ...stri
} }
sort.Strings(orderedNames) sort.Strings(orderedNames)
if len(orderedNames) == 0 { if len(orderedNames) == 0 {
return func() {}, nil return func() {}
} }
l.mutex.Lock() l.mutex.Lock()
@@ -239,7 +225,7 @@ func (l *containerOperationMutex) lockContext(ctx context.Context, names ...stri
for _, name := range orderedNames { for _, name := range orderedNames {
entry := l.locks[name] entry := l.locks[name]
if entry == nil { if entry == nil {
entry = &containerOperationLockEntry{token: make(chan struct{}, 1)} entry = &containerOperationLockEntry{}
l.locks[name] = entry l.locks[name] = entry
} }
entry.references++ entry.references++
@@ -247,10 +233,12 @@ func (l *containerOperationMutex) lockContext(ctx context.Context, names ...stri
} }
l.mutex.Unlock() l.mutex.Unlock()
acquired := 0 for _, entry := range entries {
release := func() { entry.mutex.Lock()
for index := acquired - 1; index >= 0; index-- { }
<-entries[index].token return func() {
for index := len(entries) - 1; index >= 0; index-- {
entries[index].mutex.Unlock()
} }
l.mutex.Lock() l.mutex.Lock()
defer l.mutex.Unlock() defer l.mutex.Unlock()
@@ -262,20 +250,6 @@ func (l *containerOperationMutex) lockContext(ctx context.Context, names ...stri
} }
} }
} }
for _, entry := range entries {
select {
case entry.token <- struct{}{}:
acquired++
case <-ctx.Done():
release()
return nil, ctx.Err()
}
}
if err := ctx.Err(); err != nil {
release()
return nil, err
}
return release, nil
} }
type containerNetworkAttachment struct { type containerNetworkAttachment struct {
@@ -303,131 +277,6 @@ func switchContainer(
createNew func() (container.CreateResponse, error), createNew func() (container.CreateResponse, error),
tty bool, tty bool,
logger containerSwitchLogger, logger containerSwitchLogger,
) (cleanupErr error, err error) {
return switchContainerWithRecovery(ctx, cli, name, oldContainer, createNew, tty, logger, false)
}
const (
containerUpgradePullTimeout = 30 * time.Minute
containerUpgradeReplaceTimeout = 15 * time.Minute
containerUpgradeRecoveryTimeout = 5 * time.Minute
containerUpgradeOwnerLabel = "io.1panel.upgrade.original-id"
)
// Reconcile against Docker's actual state: a failed request may already have
// changed names, created a container, or disconnected a network on the daemon.
func recoverContainerUpgrade(ctx context.Context, cli containerSwitchClient, name string, old container.InspectResponse, newID string, logger containerSwitchLogger) error {
original, err := cli.ContainerInspect(ctx, old.ID)
if err != nil {
return fmt.Errorf("inspect original %s: %w", old.ID, err)
}
if original.State == nil {
return fmt.Errorf("original container state unavailable")
}
replacement, inspectErr := cli.ContainerInspect(ctx, name)
if inspectErr != nil && !client.IsErrNotFound(inspectErr) {
return fmt.Errorf("inspect replacement by name: %w", inspectErr)
}
if inspectErr == nil && replacement.ID != old.ID {
if replacement.Config == nil || replacement.Config.Labels[containerUpgradeOwnerLabel] != old.ID {
return fmt.Errorf("container %s belongs to another operation; refusing to remove it", name)
}
if newID != "" && replacement.ID != newID {
return fmt.Errorf("replacement ID changed; refusing to remove it")
}
newID = replacement.ID
}
if newID != "" {
replacement, inspectErr = cli.ContainerInspect(ctx, newID)
if inspectErr != nil && !client.IsErrNotFound(inspectErr) {
return fmt.Errorf("inspect replacement %s: %w", newID, inspectErr)
}
if inspectErr == nil {
if replacement.ID == old.ID || replacement.Config == nil || replacement.Config.Labels[containerUpgradeOwnerLabel] != old.ID {
return fmt.Errorf("replacement identity could not be confirmed")
}
err = cli.ContainerRemove(ctx, newID, container.RemoveOptions{Force: true, RemoveVolumes: false})
logContainerSwitchStep(logger, "ContainerRollbackRemoveReplacement", name, err)
if err != nil && !client.IsErrNotFound(err) {
return fmt.Errorf("remove replacement: %w", err)
}
}
}
if strings.TrimPrefix(original.Name, "/") != name {
err = cli.ContainerRename(ctx, old.ID, name)
logContainerSwitchStep(logger, "ContainerRollbackRenameOld", name, err)
if err != nil {
return fmt.Errorf("restore original name: %w", err)
}
}
// Reload after mutations; connect only networks which are actually missing.
original, err = cli.ContainerInspect(ctx, old.ID)
if err != nil {
return err
}
primary, extras := buildContainerRecoverNetworkConfig(old.NetworkSettings, old.HostConfig)
wanted := make(map[string]*network.EndpointSettings)
if primary != nil {
for n, endpoint := range primary.EndpointsConfig {
if n != "bridge" {
wanted[n] = endpoint
}
}
}
for n, endpoint := range extras {
if n != "bridge" {
wanted[n] = endpoint
}
}
var reconnectErr error
for n, endpoint := range wanted {
if original.NetworkSettings != nil && original.NetworkSettings.Networks[n] != nil {
continue
}
err = cli.NetworkConnect(ctx, n, old.ID, endpoint)
logContainerSwitchStep(logger, "ContainerRollbackReconnectOld", n, err)
if err != nil {
reconnectErr = errors.Join(reconnectErr, fmt.Errorf("reconnect network %s: %w", n, err))
}
}
if reconnectErr != nil {
return reconnectErr
}
if old.State != nil && old.State.Running {
if original.State == nil {
return fmt.Errorf("original state unavailable after recovery")
}
if !original.State.Running {
err = restartOriginalContainer(ctx, cli, old.ID)
logContainerSwitchStep(logger, "ContainerRollbackRestartOld", name, err)
if err != nil {
return err
}
}
}
verified, err := cli.ContainerInspect(ctx, old.ID)
if err != nil {
return fmt.Errorf("verify recovery: %w", err)
}
if strings.TrimPrefix(verified.Name, "/") != name {
return fmt.Errorf("original name was not restored")
}
if old.State != nil && old.State.Running && (verified.State == nil || !verified.State.Running) {
return fmt.Errorf("original container is not running after recovery")
}
for n := range wanted {
if verified.NetworkSettings == nil || verified.NetworkSettings.Networks[n] == nil {
return fmt.Errorf("network %s was not restored", n)
}
}
return nil
}
func switchContainerWithRecovery(ctx context.Context, cli containerSwitchClient, name string,
oldContainer container.InspectResponse, createNew func() (container.CreateResponse, error),
tty bool, logger containerSwitchLogger, boundedRecovery bool,
) (cleanupErr error, err error) { ) (cleanupErr error, err error) {
if oldContainer.ID == "" { if oldContainer.ID == "" {
return nil, fmt.Errorf("original container ID is empty") return nil, fmt.Errorf("original container ID is empty")
@@ -437,32 +286,9 @@ func switchContainerWithRecovery(ctx context.Context, cli containerSwitchClient,
return nil, fmt.Errorf("cannot safely replace container %s with auto-remove enabled", name) return nil, fmt.Errorf("cannot safely replace container %s with auto-remove enabled", name)
} }
recoverUpgrade := func(newID string, cause error) error {
recoveryCtx, cancel := context.WithTimeout(context.Background(), containerUpgradeRecoveryTimeout)
defer cancel()
if newID != "" {
logContainerStartupLogs(recoveryCtx, cli, newID, name, tty, logger)
}
recoveryErr := recoverContainerUpgrade(recoveryCtx, cli, name, oldContainer, newID, logger)
if ctx.Err() != nil {
recoveryErr = errors.Join(recoveryErr, fmt.Errorf("Docker request timed out or was canceled; daemon-side completion cannot be confirmed: %w", ctx.Err()))
}
if recoveryErr != nil {
message := fmt.Sprintf("Recovery unconfirmed for container %s (original ID %s, backup %s): %v", name, oldContainer.ID, containerSwitchBackupName(oldContainer.ID), recoveryErr)
if logger != nil {
logger.Log(message)
}
return errors.Join(cause, errors.New(message))
}
return cause
}
if wasRunning { if wasRunning {
if err := cli.ContainerStop(ctx, oldContainer.ID, container.StopOptions{}); err != nil { if err := cli.ContainerStop(ctx, oldContainer.ID, container.StopOptions{}); err != nil {
logContainerSwitchStep(logger, "ContainerStopOld", name, err) logContainerSwitchStep(logger, "ContainerStopOld", name, err)
if boundedRecovery {
return nil, recoverUpgrade("", fmt.Errorf("stop original container failed: %w", err))
}
current, inspectErr := cli.ContainerInspect(ctx, oldContainer.ID) current, inspectErr := cli.ContainerInspect(ctx, oldContainer.ID)
if inspectErr == nil && current.State != nil && !current.State.Running { if inspectErr == nil && current.State != nil && !current.State.Running {
restartErr := restartOriginalContainer(ctx, cli, oldContainer.ID) restartErr := restartOriginalContainer(ctx, cli, oldContainer.ID)
@@ -477,9 +303,6 @@ func switchContainerWithRecovery(ctx context.Context, cli containerSwitchClient,
backupName := containerSwitchBackupName(oldContainer.ID) backupName := containerSwitchBackupName(oldContainer.ID)
if err := cli.ContainerRename(ctx, oldContainer.ID, backupName); err != nil { if err := cli.ContainerRename(ctx, oldContainer.ID, backupName); err != nil {
logContainerSwitchStep(logger, "ContainerRenameOld", name, err) logContainerSwitchStep(logger, "ContainerRenameOld", name, err)
if boundedRecovery {
return nil, recoverUpgrade("", fmt.Errorf("rename original container failed: %w", err))
}
if wasRunning { if wasRunning {
restartErr := restartOriginalContainer(ctx, cli, oldContainer.ID) restartErr := restartOriginalContainer(ctx, cli, oldContainer.ID)
logContainerSwitchStep(logger, "ContainerRollbackRestartOld", name, restartErr) logContainerSwitchStep(logger, "ContainerRollbackRestartOld", name, restartErr)
@@ -491,9 +314,6 @@ func switchContainerWithRecovery(ctx context.Context, cli containerSwitchClient,
disconnectedNetworks, disconnectErr := disconnectOriginalContainerNetworks(ctx, cli, oldContainer) disconnectedNetworks, disconnectErr := disconnectOriginalContainerNetworks(ctx, cli, oldContainer)
logContainerSwitchStep(logger, "ContainerDisconnectOld", backupName, disconnectErr) logContainerSwitchStep(logger, "ContainerDisconnectOld", backupName, disconnectErr)
if disconnectErr != nil { if disconnectErr != nil {
if boundedRecovery {
return nil, recoverUpgrade("", disconnectErr)
}
rollbackErr := restoreOriginalContainer(ctx, cli, oldContainer.ID, name, wasRunning, "", disconnectedNetworks, logger) rollbackErr := restoreOriginalContainer(ctx, cli, oldContainer.ID, name, wasRunning, "", disconnectedNetworks, logger)
return nil, errors.Join(disconnectErr, rollbackErr) return nil, errors.Join(disconnectErr, rollbackErr)
} }
@@ -501,17 +321,11 @@ func switchContainerWithRecovery(ctx context.Context, cli containerSwitchClient,
created, createErr := createNew() created, createErr := createNew()
logContainerSwitchStep(logger, "ContainerCreateReplacement", name, createErr) logContainerSwitchStep(logger, "ContainerCreateReplacement", name, createErr)
if createErr != nil { if createErr != nil {
if boundedRecovery {
return nil, recoverUpgrade(created.ID, createErr)
}
rollbackErr := restoreOriginalContainer(ctx, cli, oldContainer.ID, name, wasRunning, created.ID, disconnectedNetworks, logger) rollbackErr := restoreOriginalContainer(ctx, cli, oldContainer.ID, name, wasRunning, created.ID, disconnectedNetworks, logger)
return nil, errors.Join(createErr, rollbackErr) return nil, errors.Join(createErr, rollbackErr)
} }
if err := cli.ContainerStart(ctx, created.ID, container.StartOptions{}); err != nil { if err := cli.ContainerStart(ctx, created.ID, container.StartOptions{}); err != nil {
logContainerSwitchStep(logger, "ContainerStartReplacement", name, err) logContainerSwitchStep(logger, "ContainerStartReplacement", name, err)
if boundedRecovery {
return nil, recoverUpgrade(created.ID, fmt.Errorf("start new container failed: %w", err))
}
logContainerStartupLogs(ctx, cli, created.ID, name, tty, logger) logContainerStartupLogs(ctx, cli, created.ID, name, tty, logger)
rollbackErr := restoreOriginalContainer(ctx, cli, oldContainer.ID, name, wasRunning, created.ID, disconnectedNetworks, logger) rollbackErr := restoreOriginalContainer(ctx, cli, oldContainer.ID, name, wasRunning, created.ID, disconnectedNetworks, logger)
return nil, errors.Join(fmt.Errorf("start new container failed: %w", err), rollbackErr) return nil, errors.Join(fmt.Errorf("start new container failed: %w", err), rollbackErr)
@@ -520,9 +334,6 @@ func switchContainerWithRecovery(ctx context.Context, cli containerSwitchClient,
if wasRunning { if wasRunning {
if err := waitContainerReady(ctx, cli, created.ID); err != nil { if err := waitContainerReady(ctx, cli, created.ID); err != nil {
logContainerSwitchStep(logger, "ContainerWaitReplacement", name, err) logContainerSwitchStep(logger, "ContainerWaitReplacement", name, err)
if boundedRecovery {
return nil, recoverUpgrade(created.ID, fmt.Errorf("new container readiness check failed: %w", err))
}
logContainerStartupLogs(ctx, cli, created.ID, name, tty, logger) logContainerStartupLogs(ctx, cli, created.ID, name, tty, logger)
rollbackErr := restoreOriginalContainer(ctx, cli, oldContainer.ID, name, wasRunning, created.ID, disconnectedNetworks, logger) rollbackErr := restoreOriginalContainer(ctx, cli, oldContainer.ID, name, wasRunning, created.ID, disconnectedNetworks, logger)
return nil, errors.Join(fmt.Errorf("new container readiness check failed: %w", err), rollbackErr) return nil, errors.Join(fmt.Errorf("new container readiness check failed: %w", err), rollbackErr)
@@ -530,11 +341,6 @@ func switchContainerWithRecovery(ctx context.Context, cli containerSwitchClient,
logContainerSwitchStep(logger, "ContainerWaitReplacement", name, nil) logContainerSwitchStep(logger, "ContainerWaitReplacement", name, nil)
} }
if boundedRecovery {
cleanupCtx, cancel := context.WithTimeout(context.Background(), containerUpgradeRecoveryTimeout)
defer cancel()
ctx = cleanupCtx
}
cleanupErr = cli.ContainerRemove(ctx, oldContainer.ID, container.RemoveOptions{Force: true, RemoveVolumes: false}) cleanupErr = cli.ContainerRemove(ctx, oldContainer.ID, container.RemoveOptions{Force: true, RemoveVolumes: false})
logContainerSwitchStep(logger, "ContainerRemoveOld", backupName, cleanupErr) logContainerSwitchStep(logger, "ContainerRemoveOld", backupName, cleanupErr)
return cleanupErr, nil return cleanupErr, nil
+65 -77
View File
@@ -433,45 +433,10 @@ func (u *MysqlService) ListDBOption() ([]dto.MysqlOption, error) {
} }
func (u *MysqlService) Create(ctx context.Context, req dto.MysqlDBCreate) (*model.DatabaseMysql, error) { func (u *MysqlService) Create(ctx context.Context, req dto.MysqlDBCreate) (*model.DatabaseMysql, error) {
creation, err := prepareMysqlCreation(req) return u.create(ctx, req, false)
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
} }
type mysqlCreation struct { func (u *MysqlService) create(ctx context.Context, req dto.MysqlDBCreate, mcpBounded bool) (*model.DatabaseMysql, error) {
request dto.MysqlDBCreate
dbType string
permissionHosts []string
item model.DatabaseMysql
}
func prepareMysqlCreation(req dto.MysqlDBCreate) (*mysqlCreation, error) {
if cmd.CheckIllegal(req.Name, req.Username, req.Password, req.Format, req.Collation, req.Permission) || strings.Contains(req.Password, " ") { if cmd.CheckIllegal(req.Name, req.Username, req.Password, req.Format, req.Collation, req.Permission) || strings.Contains(req.Password, " ") {
return nil, buserr.New("ErrCmdIllegal") return nil, buserr.New("ErrCmdIllegal")
} }
@@ -509,13 +474,13 @@ func prepareMysqlCreation(req dto.MysqlDBCreate) (*mysqlCreation, error) {
return nil, errors.New("cannot set root as user name") return nil, errors.New("cannot set root as user name")
} }
cli, version, err := loadMysqlClientByFrom(req.Database, mcpBounded)
if err != nil {
return nil, err
}
createItem.MysqlName = req.Database createItem.MysqlName = req.Database
return &mysqlCreation{request: req, dbType: dbType, permissionHosts: permissionHosts, item: createItem}, nil defer cli.Close()
} createInfo := client.CreateInfo{
func (creation *mysqlCreation) createInfo(version string) client.CreateInfo {
req := creation.request
return client.CreateInfo{
Name: req.Name, Name: req.Name,
Format: req.Format, Format: req.Format,
Collation: req.Collation, Collation: req.Collation,
@@ -525,16 +490,55 @@ func (creation *mysqlCreation) createInfo(version string) client.CreateInfo {
Version: version, Version: version,
Timeout: 300, Timeout: 300,
} }
if mcpBounded {
local, ok := cli.(*client.Local)
if !ok {
return nil, errors.New("an installed local MySQL instance is required")
}
deadline := time.Now().Add(300 * time.Second)
if writeDeadline, ok := ctx.Deadline(); ok && writeDeadline.Add(-10*time.Second).Before(deadline) {
deadline = writeDeadline.Add(-10 * time.Second)
}
sqlContext, cancel := context.WithDeadline(ctx, deadline)
err = local.CreateWithContext(sqlContext, createInfo)
cancel()
} else {
err = cli.Create(createInfo)
}
if err != nil {
return nil, err
}
if mcpBounded {
if err := persistMCPMysqlMetadata(ctx, req, permissionHosts, &createItem); err != nil {
return nil, err
}
return &createItem, nil
}
if len(req.Username) != 0 {
if err := saveDatabaseUserCredentials(dbType, req.Database, req.Username, req.Permission, req.Password, req.Description); err != nil {
return nil, err
}
for _, host := range permissionHosts {
if err := saveDatabaseUserGrant(dbType, req.Database, req.Name, req.Username, host); err != nil {
return nil, err
}
}
}
global.LOG.Infof("create database %s successful!", req.Name)
if err := mysqlRepo.Create(ctx, &createItem); err != nil {
return nil, err
}
return &createItem, nil
} }
func saveMysqlCreationMetadata(ctx context.Context, req dto.MysqlDBCreate, hosts []string, item *model.DatabaseMysql) error { func persistMCPMysqlMetadata(ctx context.Context, req dto.MysqlDBCreate, hosts []string, item *model.DatabaseMysql) error {
return global.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { return global.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
txCtx := context.WithValue(ctx, constant.DB, tx)
if req.Username != "" { if req.Username != "" {
key := global.CONF.Base.EncryptKey key := global.CONF.Base.EncryptKey
if key == "" { if key == "" {
setting, err := settingRepo.GetWithContext(txCtx, repo.WithByKey("EncryptKey")) var setting model.Setting
if err != nil { if err := tx.Where("key = ?", "EncryptKey").First(&setting).Error; err != nil {
return err return err
} }
key = setting.Value key = setting.Value
@@ -544,7 +548,8 @@ func saveMysqlCreationMetadata(ctx context.Context, req dto.MysqlDBCreate, hosts
return err return err
} }
for _, host := range hosts { for _, host := range hosts {
user, err := databaseUserRepo.GetEncryptedWithContext(txCtx, repo.WithByType("mysql"), databaseUserRepo.WithByDatabase(req.Database), databaseUserRepo.WithByUser(req.Username, host)) var user model.DatabaseUser
err := tx.Where("type = ? AND database = ? AND username = ? AND host = ?", "mysql", req.Database, req.Username, host).First(&user).Error
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return err return err
} }
@@ -553,16 +558,16 @@ func saveMysqlCreationMetadata(ctx context.Context, req dto.MysqlDBCreate, hosts
if req.Description != "" { if req.Description != "" {
user.Description = req.Description user.Description = req.Description
} }
if err := databaseUserRepo.SaveEncryptedWithContext(txCtx, &user); err != nil { if err := tx.Save(&user).Error; err != nil {
return err return err
} }
grant := model.DatabaseUserGrant{Type: "mysql", Database: req.Database, DBName: req.Name, Username: req.Username, Host: host} grant := model.DatabaseUserGrant{Type: "mysql", Database: req.Database, DBName: req.Name, Username: req.Username, Host: host}
if err := databaseUserGrantRepo.CreateIfAbsent(txCtx, &grant); err != nil { 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 {
return err return err
} }
} }
} }
return mysqlRepo.CreateMetadata(txCtx, item) return tx.Create(item).Error
}) })
} }
@@ -1598,34 +1603,10 @@ func updateMyCnf(oldFiles []string, group string, param string, value interface{
} }
func LoadMysqlClientByFrom(database string) (mysql.MysqlClient, string, error) { func LoadMysqlClientByFrom(database string) (mysql.MysqlClient, string, error) {
databaseItem, err := databaseRepo.Get(repo.WithByName(database)) return loadMysqlClientByFrom(database, false)
if err != nil {
return nil, "", err
}
return newMysqlClientForDatabase(databaseItem, database)
} }
func loadInstalledLocalMysqlClient(database string) (*client.Local, string, error) { func loadMysqlClientByFrom(database string, mcpLocalOnly bool) (mysql.MysqlClient, string, error) {
databaseItem, err := databaseRepo.Get(repo.WithByName(database))
if err != nil {
return nil, "", err
}
if databaseItem.ID == 0 || databaseItem.From != "local" || databaseItem.Type != "mysql" || databaseItem.AppInstallID == 0 {
return nil, "", errors.New("an installed local MySQL instance is required")
}
cli, version, err := newMysqlClientForDatabase(databaseItem, database)
if err != nil {
return nil, "", err
}
local, ok := cli.(*client.Local)
if !ok {
cli.Close()
return nil, "", errors.New("an installed local MySQL instance is required")
}
return local, version, nil
}
func newMysqlClientForDatabase(databaseItem model.Database, database string) (mysql.MysqlClient, string, error) {
var ( var (
dbInfo client.DBInfo dbInfo client.DBInfo
version string version string
@@ -1633,6 +1614,13 @@ func newMysqlClientForDatabase(databaseItem model.Database, database string) (my
) )
dbInfo.Timeout = 300 dbInfo.Timeout = 300
databaseItem, err := databaseRepo.Get(repo.WithByName(database))
if err != nil {
return nil, "", err
}
if mcpLocalOnly && (databaseItem.ID == 0 || databaseItem.From != "local" || databaseItem.Type != "mysql" || databaseItem.AppInstallID == 0) {
return nil, "", errors.New("an installed local MySQL instance is required")
}
dbInfo.Type = databaseItem.Type dbInfo.Type = databaseItem.Type
dbInfo.From = databaseItem.From dbInfo.From = databaseItem.From
dbInfo.Database = database dbInfo.Database = database
+1 -27
View File
@@ -16,35 +16,9 @@ func (u *MysqlService) CreateForMCP(ctx context.Context, req dto.MysqlDBCreate)
} }
writeContext, cancel := context.WithTimeout(context.WithoutCancel(ctx), 310*time.Second) writeContext, cancel := context.WithTimeout(context.WithoutCancel(ctx), 310*time.Second)
defer cancel() defer cancel()
created, err := createMCPMysqlDatabase(writeContext, req) created, err := u.create(writeContext, req, true)
if err != nil { if err != nil {
return nil, mcpapi.SanitizeBusinessError(err, "database creation failed; inspect the invocation before retrying") return nil, mcpapi.SanitizeBusinessError(err, "database creation failed; inspect the invocation before retrying")
} }
return created, nil return created, nil
} }
func createMCPMysqlDatabase(ctx context.Context, req dto.MysqlDBCreate) (*model.DatabaseMysql, error) {
creation, err := prepareMysqlCreation(req)
if err != nil {
return nil, err
}
local, version, err := loadInstalledLocalMysqlClient(creation.request.Database)
if err != nil {
return nil, err
}
defer local.Close()
deadline := time.Now().Add(300 * time.Second)
if writeDeadline, ok := ctx.Deadline(); ok && writeDeadline.Add(-10*time.Second).Before(deadline) {
deadline = writeDeadline.Add(-10 * time.Second)
}
sqlContext, cancel := context.WithDeadline(ctx, deadline)
err = local.CreateWithContext(sqlContext, creation.createInfo(version))
cancel()
if err != nil {
return nil, err
}
if err := saveMysqlCreationMetadata(ctx, creation.request, creation.permissionHosts, &creation.item); err != nil {
return nil, err
}
return &creation.item, nil
}
-10
View File
@@ -1,10 +0,0 @@
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
@@ -1,821 +0,0 @@
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)
}
+6 -26
View File
@@ -8,12 +8,9 @@ import (
"github.com/1Panel-dev/1Panel/agent/app/dto/response" "github.com/1Panel-dev/1Panel/agent/app/dto/response"
"github.com/1Panel-dev/1Panel/agent/app/repo" "github.com/1Panel-dev/1Panel/agent/app/repo"
"github.com/1Panel-dev/1Panel/agent/utils/files" "github.com/1Panel-dev/1Panel/agent/utils/files"
"github.com/1Panel-dev/1Panel/agent/utils/taskscope"
) )
type TaskLogService struct { type TaskLogService struct{}
grantedScopes []string
}
type ITaskLogService interface { type ITaskLogService interface {
Page(req dto.SearchTaskLogReq) (int64, []dto.TaskDTO, error) Page(req dto.SearchTaskLogReq) (int64, []dto.TaskDTO, error)
@@ -22,18 +19,13 @@ type ITaskLogService interface {
CountExecutingTask() (int64, error) CountExecutingTask() (int64, error)
} }
func NewITaskService(grantedScopes ...string) ITaskLogService { func NewITaskService() ITaskLogService {
return &TaskLogService{grantedScopes: append([]string(nil), grantedScopes...)} return &TaskLogService{}
} }
func (u *TaskLogService) Page(req dto.SearchTaskLogReq) (int64, []dto.TaskDTO, error) { func (u *TaskLogService) Page(req dto.SearchTaskLogReq) (int64, []dto.TaskDTO, error) {
excluded, err := taskscope.ReadExclusions(u.grantedScopes...)
if err != nil {
return 0, nil, err
}
opts := []repo.DBOption{ opts := []repo.DBOption{
repo.WithOrderDesc("created_at"), repo.WithOrderDesc("created_at"),
taskRepo.WithoutScopes(excluded...),
} }
if req.TaskID != "" { if req.TaskID != "" {
opts = append(opts, taskRepo.WithByID(req.TaskID)) opts = append(opts, taskRepo.WithByID(req.TaskID))
@@ -61,11 +53,7 @@ func (u *TaskLogService) Page(req dto.SearchTaskLogReq) (int64, []dto.TaskDTO, e
} }
func (u *TaskLogService) ReadByLine(req request.TaskLogReadReq) (*response.FileLineContent, error) { func (u *TaskLogService) ReadByLine(req request.TaskLogReadReq) (*response.FileLineContent, error) {
excluded, err := taskscope.ReadExclusions(u.grantedScopes...) opts := []repo.DBOption{}
if err != nil {
return nil, err
}
opts := []repo.DBOption{taskRepo.WithoutScopes(excluded...)}
if req.TaskID != "" { if req.TaskID != "" {
opts = append(opts, taskRepo.WithByID(req.TaskID)) opts = append(opts, taskRepo.WithByID(req.TaskID))
} else { } else {
@@ -124,17 +112,9 @@ func (u *TaskLogService) ReadByLine(req request.TaskLogReadReq) (*response.FileL
} }
func (u *TaskLogService) SyncForRestart() error { func (u *TaskLogService) SyncForRestart() error {
excluded, err := taskscope.RecoveryExclusions() return taskRepo.UpdateRunningTaskToFailed()
if err != nil {
return err
}
return taskRepo.UpdateRunningTaskToFailed(taskRepo.WithoutScopes(excluded...))
} }
func (u *TaskLogService) CountExecutingTask() (int64, error) { func (u *TaskLogService) CountExecutingTask() (int64, error) {
excluded, err := taskscope.ReadExclusions(u.grantedScopes...) return taskRepo.CountExecutingTask()
if err != nil {
return 0, err
}
return taskRepo.CountExecutingTask(taskRepo.WithoutScopes(excluded...))
} }
-100
View File
@@ -1,100 +0,0 @@
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)
}
+3 -81
View File
@@ -4,9 +4,7 @@ import (
"bytes" "bytes"
"errors" "errors"
"fmt" "fmt"
"os"
"path" "path"
"path/filepath"
"strconv" "strconv"
"strings" "strings"
@@ -25,63 +23,6 @@ import (
"github.com/1Panel-dev/1Panel/agent/utils/re" "github.com/1Panel-dev/1Panel/agent/utils/re"
) )
func websiteProxyPathAllowed(directory, name string) bool {
if name == "" || name == "." || name == ".." || strings.ContainsAny(name, "/\\\x00") {
return false
}
resolvedDirectory, err := websiteProxyResolvePath(directory)
if err != nil || !websiteManagedConfigs.pathAllowed(resolvedDirectory) {
return false
}
for _, extension := range []string{".conf", ".bak"} {
filename := filepath.Join(directory, name+extension)
if info, statErr := os.Lstat(filename); statErr == nil && info.Mode()&os.ModeSymlink != 0 {
return false
}
resolved, resolveErr := websiteProxyResolvePath(filename)
if resolveErr != nil || !websiteProxyPathWithin(resolved, resolvedDirectory) || !websiteManagedConfigs.pathAllowed(resolved) {
return false
}
}
return true
}
func websiteProxyDirectoryAllowed(directory string) bool {
resolved, err := websiteProxyResolvePath(directory)
return err == nil && websiteManagedConfigs.pathAllowed(resolved)
}
func websiteProxyPathWithin(target, directory string) bool {
resolvedDirectory, err := websiteProxyResolvePath(directory)
if err != nil {
return false
}
relative, err := filepath.Rel(resolvedDirectory, target)
return err == nil && relative != ".." && !strings.HasPrefix(relative, ".."+string(filepath.Separator))
}
func websiteProxyResolvePath(target string) (string, error) {
absolute, err := filepath.Abs(target)
if err != nil {
return "", err
}
resolved, err := filepath.EvalSymlinks(absolute)
if err == nil {
return resolved, nil
}
if !os.IsNotExist(err) || filepath.Dir(absolute) == absolute {
return "", err
}
if info, statErr := os.Lstat(absolute); statErr == nil && info.Mode()&os.ModeSymlink != 0 {
return "", errors.New("invalid proxy symlink")
}
parent, err := websiteProxyResolvePath(filepath.Dir(absolute))
if err != nil {
return "", err
}
return filepath.Join(parent, filepath.Base(absolute)), nil
}
func (w WebsiteService) OperateProxy(req request.WebsiteProxyConfig) (err error) { func (w WebsiteService) OperateProxy(req request.WebsiteProxyConfig) (err error) {
switch req.Operate { switch req.Operate {
case "delete": case "delete":
@@ -110,9 +51,6 @@ func (w WebsiteService) OperateProxy(req request.WebsiteProxyConfig) (err error)
} }
fileOp := files.NewFileOp() fileOp := files.NewFileOp()
includeDir := GetSitePath(website, SiteProxyDir) includeDir := GetSitePath(website, SiteProxyDir)
if !websiteProxyPathAllowed(includeDir, req.Name) {
return errors.New("invalid proxy path")
}
if !fileOp.Stat(includeDir) { if !fileOp.Stat(includeDir) {
_ = fileOp.CreateDir(includeDir, constant.DirPerm) _ = fileOp.CreateDir(includeDir, constant.DirPerm)
} }
@@ -332,9 +270,6 @@ func (w WebsiteService) GetProxies(id uint) (res []request.WebsiteProxyConfig, e
} }
includeDir := GetSitePath(website, SiteProxyDir) includeDir := GetSitePath(website, SiteProxyDir)
fileOp := files.NewFileOp() fileOp := files.NewFileOp()
if !websiteProxyDirectoryAllowed(includeDir) {
return nil, errors.New("invalid proxy path")
}
if !fileOp.Stat(includeDir) { if !fileOp.Stat(includeDir) {
return return
} }
@@ -347,16 +282,12 @@ func (w WebsiteService) GetProxies(id uint) (res []request.WebsiteProxyConfig, e
config *components.Config config *components.Config
) )
for _, configFile := range fileList.Items { for _, configFile := range fileList.Items {
extension := filepath.Ext(configFile.Name)
name := strings.TrimSuffix(configFile.Name, extension)
if configFile.IsDir || (extension != ".conf" && extension != ".bak") || !websiteProxyPathAllowed(includeDir, name) {
continue
}
proxyConfig := request.WebsiteProxyConfig{ proxyConfig := request.WebsiteProxyConfig{
ID: website.ID, ID: website.ID,
} }
proxyConfig.Name = name parts := strings.Split(configFile.Name, ".")
if extension == ".conf" { proxyConfig.Name = parts[0]
if parts[1] == "conf" {
proxyConfig.Enable = true proxyConfig.Enable = true
} else { } else {
proxyConfig.Enable = false proxyConfig.Enable = false
@@ -429,9 +360,6 @@ func (w WebsiteService) UpdateProxyFile(req request.NginxProxyUpdate) (err error
if err != nil { if err != nil {
return err return err
} }
if !websiteProxyPathAllowed(GetSitePath(website, SiteProxyDir), req.Name) {
return errors.New("invalid proxy path")
}
absolutePath := fmt.Sprintf("%s/%s.conf", GetSitePath(website, SiteProxyDir), req.Name) absolutePath := fmt.Sprintf("%s/%s.conf", GetSitePath(website, SiteProxyDir), req.Name)
fileOp := files.NewFileOp() fileOp := files.NewFileOp()
oldRewriteContent, err = fileOp.GetContent(absolutePath) oldRewriteContent, err = fileOp.GetContent(absolutePath)
@@ -477,9 +405,6 @@ func (w WebsiteService) DeleteProxy(req request.WebsiteProxyDel) (err error) {
return return
} }
includeDir := GetSitePath(website, SiteProxyDir) includeDir := GetSitePath(website, SiteProxyDir)
if !websiteProxyPathAllowed(includeDir, req.Name) {
return errors.New("invalid proxy path")
}
fileOp := files.NewFileOp() fileOp := files.NewFileOp()
if !fileOp.Stat(includeDir) { if !fileOp.Stat(includeDir) {
return return
@@ -500,9 +425,6 @@ func (w WebsiteService) UpdateProxyStatus(req request.WebsiteProxyStatusUpdate)
return return
} }
includeDir := GetSitePath(website, SiteProxyDir) includeDir := GetSitePath(website, SiteProxyDir)
if !websiteProxyPathAllowed(includeDir, req.Name) {
return errors.New("invalid proxy path")
}
fileOp := files.NewFileOp() fileOp := files.NewFileOp()
if !fileOp.Stat(includeDir) { if !fileOp.Stat(includeDir) {
return return
-26
View File
@@ -292,36 +292,10 @@ func (t *Task) updateTask(task *model.Task) {
_ = t.taskRepo.Update(context.Background(), task) _ = t.taskRepo.Update(context.Background(), task)
} }
// ExecuteQueued registers the task before returning and limits concurrent execution.
// Subtasks must use Timeout = 0 and manage their own operation and recovery deadlines
// so that a slot is held until the action and its cleanup have actually returned.
func (t *Task) ExecuteQueued(slots chan struct{}, waitingStep string, onFinish func()) error {
t.Task.CurrentStep = waitingStep
if err := t.taskRepo.Save(context.Background(), t.Task); err != nil {
_ = t.logFile.Close()
global.RemoveTaskCancel(t.TaskID)
return err
}
t.Log(i18n.GetMsgByKey(waitingStep))
go func() {
slots <- struct{}{}
defer func() { <-slots }()
defer onFinish()
if err := t.executeRegistered(); err != nil {
global.LOG.Error(err.Error())
}
}()
return nil
}
func (t *Task) Execute() error { func (t *Task) Execute() error {
if err := t.taskRepo.Save(context.Background(), t.Task); err != nil { if err := t.taskRepo.Save(context.Background(), t.Task); err != nil {
return err return err
} }
return t.executeRegistered()
}
func (t *Task) executeRegistered() error {
var err error var err error
t.Log(i18n.GetWithName("TaskStart", t.Name)) t.Log(i18n.GetWithName("TaskStart", t.Name))
for i := 0; i < len(t.SubTasks); i++ { for i := 0; i < len(t.SubTasks); i++ {
-2
View File
@@ -4,5 +4,3 @@ const (
TaskInstall = "installApp" TaskInstall = "installApp"
TaskCreateWebsite = "createWebsite" TaskCreateWebsite = "createWebsite"
) )
const TaskScopePanelMCP = "PanelMCP"
+5 -1
View File
@@ -77,7 +77,11 @@ func Run() {
global.LOG.Errorf("generate random hour failed: %v", err) global.LOG.Errorf("generate random hour failed: %v", err)
hourRand = big.NewInt(0) hourRand = big.NewInt(0)
} }
if _, err := global.Cron.AddJob(fmt.Sprintf("%v %v * * *", minuteRand.Int64(), hourRand.Int64()), job.NewAppStoreJob()); err != nil { 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 {
global.LOG.Errorf("can not add appstore corn job: %s", err.Error()) global.LOG.Errorf("can not add appstore corn job: %s", err.Error())
} }
if _, err := global.Cron.AddJob("0 3 */31 * *", job.NewBackupJob()); err != nil { if _, err := global.Cron.AddJob("0 3 */31 * *", job.NewBackupJob()); err != nil {
@@ -0,0 +1,24 @@
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,7 +7,6 @@ replace github.com/go-acme/lego/v5 => github.com/1Panel-dev/lego/v5 v5.3.1
require ( require (
github.com/aliyun/aliyun-oss-go-sdk v3.0.2+incompatible github.com/aliyun/aliyun-oss-go-sdk v3.0.2+incompatible
github.com/compose-spec/compose-go/v2 v2.15.0 github.com/compose-spec/compose-go/v2 v2.15.0
github.com/containerd/errdefs v1.0.0
github.com/creack/pty v1.1.24 github.com/creack/pty v1.1.24
github.com/docker/cli v29.9.0+incompatible github.com/docker/cli v29.9.0+incompatible
github.com/docker/docker v28.5.2+incompatible github.com/docker/docker v28.5.2+incompatible
@@ -105,6 +104,7 @@ require (
github.com/clbanning/mxj v1.8.4 // indirect github.com/clbanning/mxj v1.8.4 // indirect
github.com/clbanning/mxj/v2 v2.7.0 // indirect github.com/clbanning/mxj/v2 v2.7.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect github.com/cloudwego/base64x v0.1.6 // indirect
github.com/containerd/errdefs v1.0.0 // indirect
github.com/containerd/errdefs/pkg v0.3.0 // indirect github.com/containerd/errdefs/pkg v0.3.0 // indirect
github.com/containerd/log v0.1.0 // indirect github.com/containerd/log v0.1.0 // indirect
github.com/distribution/reference v0.6.0 // indirect github.com/distribution/reference v0.6.0 // indirect
+10 -1
View File
@@ -801,4 +801,13 @@ ErrFeishuUpgradeMounts: "These mounts cannot be fully backed up or restored. Use
AgentFeishuRecoveryDisabled: "Gateway recovered with Feishu disabled; the original configuration backup is retained." AgentFeishuRecoveryDisabled: "Gateway recovered with Feishu disabled; the original configuration backup is retained."
ContainerUpgradeWaiting: "Waiting for a container upgrade execution slot" 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"
+10 -1
View File
@@ -801,4 +801,13 @@ 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." AgentFeishuRecoveryDisabled: "Puerta de enlace recuperada con Feishu desactivado; se conserva la copia de configuración original."
ContainerUpgradeWaiting: "Waiting for a container upgrade execution slot" 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"
+10 -1
View File
@@ -802,4 +802,13 @@ ErrFeishuUpgradeMounts: "این اتصال‌های ذخیره‌سازی کام
AgentFeishuRecoveryDisabled: "دروازه بازیابی شد و Feishu غیرفعال است؛ پشتیبان تنظیمات اصلی حفظ شد." AgentFeishuRecoveryDisabled: "دروازه بازیابی شد و Feishu غیرفعال است؛ پشتیبان تنظیمات اصلی حفظ شد."
ContainerUpgradeWaiting: "Waiting for a container upgrade execution slot" 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 }} کانتینر ناموفق بود؛ گزارش وظیفه را مشاهده کنید"
+10 -1
View File
@@ -801,4 +801,13 @@ ErrFeishuUpgradeMounts: "現在のマウントは完全なバックアップ・
AgentFeishuRecoveryDisabled: "ゲートウェイを復旧しました。Feishu は無効のままで、元の設定のバックアップは保持しています。" AgentFeishuRecoveryDisabled: "ゲートウェイを復旧しました。Feishu は無効のままで、元の設定のバックアップは保持しています。"
ContainerUpgradeWaiting: "Waiting for a container upgrade execution slot" 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 }} コンテナの確認に失敗しました。タスクログを確認してください"
+10 -1
View File
@@ -801,4 +801,13 @@ ErrFeishuUpgradeMounts: "현재 마운트는 전체 백업 및 복원을 지원
AgentFeishuRecoveryDisabled: "게이트웨이가 복구되었습니다. Feishu는 비활성 상태이며 원본 설정 백업은 유지됩니다." AgentFeishuRecoveryDisabled: "게이트웨이가 복구되었습니다. Feishu는 비활성 상태이며 원본 설정 백업은 유지됩니다."
ContainerUpgradeWaiting: "Waiting for a container upgrade execution slot" 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 }}개 확인에 실패했습니다. 작업 로그를 확인하세요"
+10 -1
View File
@@ -792,4 +792,13 @@ ErrFeishuUpgradeMounts: "ຈຸດເຊື່ອມຂໍ້ມູນນີ້
AgentFeishuRecoveryDisabled: "ເກດເວກູ້ຄືນແລ້ວ ແຕ່ Feishu ຍັງປິດ; ສຳຮອງການຕັ້ງຄ່າເດີມຍັງຖືກເກັບໄວ້." AgentFeishuRecoveryDisabled: "ເກດເວກູ້ຄືນແລ້ວ ແຕ່ Feishu ຍັງປິດ; ສຳຮອງການຕັ້ງຄ່າເດີມຍັງຖືກເກັບໄວ້."
ContainerUpgradeWaiting: "Waiting for a container upgrade execution slot" 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 }} ຄອນເທນເນີລົ້ມເຫຼວ; ກະລຸນາເບິ່ງບັນທຶກວຽກ"
+10 -1
View File
@@ -801,4 +801,13 @@ ErrFeishuUpgradeMounts: "Lekapan ini tidak boleh disandar dan dipulihkan sepenuh
AgentFeishuRecoveryDisabled: "Gerbang dipulihkan dengan Feishu dinyahaktifkan; sandaran konfigurasi asal dikekalkan." AgentFeishuRecoveryDisabled: "Gerbang dipulihkan dengan Feishu dinyahaktifkan; sandaran konfigurasi asal dikekalkan."
ContainerUpgradeWaiting: "Waiting for a container upgrade execution slot" 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"
+10 -1
View File
@@ -801,4 +801,13 @@ 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." AgentFeishuRecoveryDisabled: "Gateway recuperado com Feishu desativado; o backup da configuração original foi mantido."
ContainerUpgradeWaiting: "Waiting for a container upgrade execution slot" 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"
+10 -1
View File
@@ -801,4 +801,13 @@ ErrFeishuUpgradeMounts: "Эти монтирования не поддержив
AgentFeishuRecoveryDisabled: "Шлюз восстановлен, Feishu отключён; исходная резервная копия конфигурации сохранена." AgentFeishuRecoveryDisabled: "Шлюз восстановлен, Feishu отключён; исходная резервная копия конфигурации сохранена."
ContainerUpgradeWaiting: "Waiting for a container upgrade execution slot" 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 }}. Подробности в журнале задачи"
+10 -1
View File
@@ -801,4 +801,13 @@ 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." AgentFeishuRecoveryDisabled: "Ağ geçidi kurtarıldı, Feishu kapalı; özgün yapılandırma yedeği korundu."
ContainerUpgradeWaiting: "Waiting for a container upgrade execution slot" 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"
+10 -1
View File
@@ -801,4 +801,13 @@ ErrFeishuUpgradeMounts: "目前掛載無法完整備份或還原,請使用應
AgentFeishuRecoveryDisabled: "閘道已恢復,飛書保持停用;原設定備份已保留。" AgentFeishuRecoveryDisabled: "閘道已恢復,飛書保持停用;原設定備份已保留。"
ContainerUpgradeWaiting: "等待容器升級任務執行名額" 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 }} 個容器檢查失敗,請查看任務日誌"
+10 -1
View File
@@ -801,4 +801,13 @@ ErrFeishuUpgradeMounts: "当前挂载无法完整备份或恢复,请使用应
AgentFeishuRecoveryDisabled: "网关已恢复,飞书保持停用;原配置备份已保留。" AgentFeishuRecoveryDisabled: "网关已恢复,飞书保持停用;原配置备份已保留。"
ContainerUpgradeWaiting: "等待容器升级任务执行名额" 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 }} 个容器检查失败,请查看任务日志"
+1
View File
@@ -119,6 +119,7 @@ func agentDBMigrations() []*gormigrate.Migration {
migrations.AddQuickDirectory, migrations.AddQuickDirectory,
migrations.AddLocalModelAccountLink, migrations.AddLocalModelAccountLink,
migrations.AddOllamaModelAccountSync, migrations.AddOllamaModelAccountSync,
migrations.AddContainerImageCheckUpdates,
} }
} }
@@ -0,0 +1,17 @@
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
},
}
+1 -63
View File
@@ -2,15 +2,11 @@ package router
import ( import (
"bytes" "bytes"
"context"
"encoding/json" "encoding/json"
"io" "io"
"net" "net"
"net/http" "net/http"
"github.com/1Panel-dev/1Panel/agent/app/dto/request"
"github.com/1Panel-dev/1Panel/agent/app/dto/response"
"github.com/1Panel-dev/1Panel/agent/app/service"
"github.com/1Panel-dev/1Panel/agent/global" "github.com/1Panel-dev/1Panel/agent/global"
"github.com/1Panel-dev/1Panel/agent/middleware" "github.com/1Panel-dev/1Panel/agent/middleware"
"github.com/1Panel-dev/1Panel/agent/utils/mcpapi" "github.com/1Panel-dev/1Panel/agent/utils/mcpapi"
@@ -21,38 +17,9 @@ import (
func registerMCPRuntime(engine *gin.Engine) { func registerMCPRuntime(engine *gin.Engine) {
controller := mcpruntime.New() controller := mcpruntime.New()
engine.POST("/internal/mcp/docker", mcpDockerHandler(controller)) engine.POST("/internal/mcp/docker", mcpDockerHandler(controller))
engine.POST("/internal/mcp/website", mcpWebsiteHandler(service.NewMCPWebsiteBindingService()))
engine.GET(mcpapi.CapabilitiesPath, middleware.Certificate(), mcpCapabilitiesHandler()) engine.GET(mcpapi.CapabilitiesPath, middleware.Certificate(), mcpCapabilitiesHandler())
} }
type mcpWebsiteController interface {
Handle(context.Context, request.MCPWebsiteBinding) response.MCPWebsiteBinding
}
func mcpWebsiteHandler(controller mcpWebsiteController) gin.HandlerFunc {
return func(ctx *gin.Context) {
local, ok := ctx.Request.Context().Value(http.LocalAddrContextKey).(net.Addr)
if !global.IsMaster || !ok || local.Network() != "unix" {
ctx.AbortWithStatus(http.StatusForbidden)
return
}
data, err := io.ReadAll(io.LimitReader(ctx.Request.Body, 16385))
if err != nil || len(data) > 16384 {
ctx.AbortWithStatus(http.StatusBadRequest)
return
}
var req request.MCPWebsiteBinding
decoder := json.NewDecoder(bytes.NewReader(data))
decoder.DisallowUnknownFields()
if decoder.Decode(&req) != nil || decoder.Decode(new(any)) != io.EOF {
ctx.AbortWithStatus(http.StatusBadRequest)
return
}
ctx.Header("Cache-Control", "no-store")
ctx.JSON(http.StatusOK, controller.Handle(ctx.Request.Context(), req))
}
}
func mcpCapabilitiesHandler() gin.HandlerFunc { func mcpCapabilitiesHandler() gin.HandlerFunc {
return func(ctx *gin.Context) { return func(ctx *gin.Context) {
if !mcpapi.TrustedTransport(ctx.Request) { if !mcpapi.TrustedTransport(ctx.Request) {
@@ -65,12 +32,7 @@ func mcpCapabilitiesHandler() gin.HandlerFunc {
} }
} }
type mcpDockerController interface { func mcpDockerHandler(controller *mcpruntime.Controller) gin.HandlerFunc {
Handle(context.Context, mcpruntime.Request) (mcpruntime.Response, error)
HandleWithProgress(context.Context, mcpruntime.Request, func(mcpruntime.PullProgress) error) (mcpruntime.Response, error)
}
func mcpDockerHandler(controller mcpDockerController) gin.HandlerFunc {
return func(ctx *gin.Context) { return func(ctx *gin.Context) {
local, ok := ctx.Request.Context().Value(http.LocalAddrContextKey).(net.Addr) local, ok := ctx.Request.Context().Value(http.LocalAddrContextKey).(net.Addr)
if !global.IsMaster || !ok || local.Network() != "unix" { if !global.IsMaster || !ok || local.Network() != "unix" {
@@ -89,30 +51,6 @@ func mcpDockerHandler(controller mcpDockerController) gin.HandlerFunc {
ctx.AbortWithStatus(http.StatusBadRequest) ctx.AbortWithStatus(http.StatusBadRequest)
return return
} }
if req.StreamProgress {
if req.Operation != "pull" {
ctx.JSON(http.StatusBadRequest, mcpruntime.Response{Error: "MCP Docker progress is only available when pulling an image"})
return
}
ctx.Header("Content-Type", "application/x-ndjson")
ctx.Header("Cache-Control", "no-store")
ctx.Status(http.StatusOK)
encoder := json.NewEncoder(ctx.Writer)
_, err := controller.HandleWithProgress(ctx.Request.Context(), req, func(progress mcpruntime.PullProgress) error {
if err := encoder.Encode(mcpruntime.PullEvent{Type: "progress", PullProgress: progress}); err != nil {
return err
}
ctx.Writer.Flush()
return nil
})
result := mcpruntime.PullEvent{Type: "result"}
if err != nil {
result.Error = err.Error()
}
_ = encoder.Encode(result)
ctx.Writer.Flush()
return
}
response, err := controller.Handle(ctx.Request.Context(), req) response, err := controller.Handle(ctx.Request.Context(), req)
if err != nil { if err != nil {
ctx.JSON(http.StatusBadRequest, mcpruntime.Response{Error: err.Error()}) ctx.JSON(http.StatusBadRequest, mcpruntime.Response{Error: err.Error()})
-6
View File
@@ -2,7 +2,6 @@ package router
import ( import (
v2 "github.com/1Panel-dev/1Panel/agent/app/api/v2" v2 "github.com/1Panel-dev/1Panel/agent/app/api/v2"
"github.com/1Panel-dev/1Panel/agent/app/service"
"github.com/1Panel-dev/1Panel/agent/global" "github.com/1Panel-dev/1Panel/agent/global"
"github.com/1Panel-dev/1Panel/agent/i18n" "github.com/1Panel-dev/1Panel/agent/i18n"
"github.com/1Panel-dev/1Panel/agent/middleware" "github.com/1Panel-dev/1Panel/agent/middleware"
@@ -15,9 +14,6 @@ var (
) )
func Routers() *gin.Engine { func Routers() *gin.Engine {
if err := service.ConfigurePanelMCPTasks(); err != nil {
panic(err)
}
Router = gin.Default() Router = gin.Default()
Router.Use(i18n.UseI18n()) Router.Use(i18n.UseI18n())
registerMCPRuntime(Router) registerMCPRuntime(Router)
@@ -27,8 +23,6 @@ func Routers() *gin.Engine {
PrivateGroup.Use(middleware.Certificate()) PrivateGroup.Use(middleware.Certificate())
} }
PrivateGroup.Use(middleware.OperationResolveMeta()) PrivateGroup.Use(middleware.OperationResolveMeta())
PrivateGroup.Use(v2.PanelMCPTaskAccess())
PrivateGroup.Use(v2.MCPBusinessDispatch())
for _, router := range rou.RouterGroupApp { for _, router := range rou.RouterGroupApp {
router.InitRouter(PrivateGroup) router.InitRouter(PrivateGroup)
} }
+3
View File
@@ -16,6 +16,9 @@ func (s *ContainerRouter) InitRouter(Router *gin.RouterGroup) {
baRouter.POST("", baseApi.ContainerCreate) baRouter.POST("", baseApi.ContainerCreate)
baRouter.POST("/update", baseApi.ContainerUpdate) baRouter.POST("/update", baseApi.ContainerUpdate)
baRouter.POST("/upgrade", baseApi.ContainerUpgrade) 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("/info", baseApi.ContainerInfo)
baRouter.POST("/search", baseApi.SearchContainer) baRouter.POST("/search", baseApi.SearchContainer)
baRouter.POST("/list", baseApi.ListContainer) baRouter.POST("/list", baseApi.ListContainer)
+22 -28
View File
@@ -13,7 +13,6 @@ import (
"github.com/1Panel-dev/1Panel/agent/app/model" "github.com/1Panel-dev/1Panel/agent/app/model"
"github.com/1Panel-dev/1Panel/agent/app/task" "github.com/1Panel-dev/1Panel/agent/app/task"
"github.com/1Panel-dev/1Panel/agent/global" "github.com/1Panel-dev/1Panel/agent/global"
"github.com/distribution/reference"
"github.com/docker/cli/cli/config" "github.com/docker/cli/cli/config"
"github.com/docker/docker/api/types" "github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/container" "github.com/docker/docker/api/types/container"
@@ -198,22 +197,12 @@ func setLog(id, newLastLine string, task *task.Task) error {
} }
func (c Client) PullImageWithProcessAndOptions(task *task.Task, imageName string, options image.PullOptions) error { func (c Client) PullImageWithProcessAndOptions(task *task.Task, imageName string, options image.PullOptions) error {
return c.PullImageWithProcessAndOptionsContext(context.Background(), task, imageName, options) out, err := c.cli.ImagePull(context.Background(), imageName, options)
}
func (c Client) PullImageWithProcessAndOptionsContext(ctx context.Context, task *task.Task, imageName string, options image.PullOptions) error {
out, err := c.cli.ImagePull(ctx, imageName, options)
if err != nil { if err != nil {
return err return err
} }
defer out.Close() defer out.Close()
stopClose := context.AfterFunc(ctx, func() { _ = out.Close() }) return handlePullImageProcess(out, task)
defer stopClose()
err = handlePullImageProcess(out, task)
if ctx.Err() != nil {
return ctx.Err()
}
return err
} }
func handlePullImageProcess(out io.Reader, task *task.Task) error { func handlePullImageProcess(out io.Reader, task *task.Task) error {
@@ -330,15 +319,11 @@ func (c Client) BuildImageWithProcessAndOptions(task *task.Task, tar io.ReadClos
} }
func (c Client) PullImageWithProcess(task *task.Task, imageName string) error { func (c Client) PullImageWithProcess(task *task.Task, imageName string) error {
return c.PullImageWithProcessContext(context.Background(), task, imageName)
}
func (c Client) PullImageWithProcessContext(ctx context.Context, task *task.Task, imageName string) error {
options := image.PullOptions{} options := image.PullOptions{}
if authStr, ok := RegistryAuthForImage(imageName); ok { if authStr, ok := loadRegistryAuthFromDockerConfig(imageName); ok {
options.RegistryAuth = authStr options.RegistryAuth = authStr
} }
return c.PullImageWithProcessAndOptionsContext(ctx, task, imageName, options) return c.PullImageWithProcessAndOptions(task, imageName, options)
} }
func logProcess(progress map[string]interface{}, task *task.Task) { func logProcess(progress map[string]interface{}, task *task.Task) {
@@ -357,7 +342,7 @@ func PullImage(imageName string) error {
} }
defer cli.Close() defer cli.Close()
options := image.PullOptions{} options := image.PullOptions{}
if authStr, ok := RegistryAuthForImage(imageName); ok { if authStr, ok := loadRegistryAuthFromDockerConfig(imageName); ok {
options.RegistryAuth = authStr options.RegistryAuth = authStr
} }
out, err := cli.ImagePull(context.Background(), imageName, options) out, err := cli.ImagePull(context.Background(), imageName, options)
@@ -368,20 +353,17 @@ func PullImage(imageName string) error {
return handlePullImageProcess(out, nil) return handlePullImageProcess(out, nil)
} }
func RegistryAuthForImage(imageName string) (string, bool) { func loadRegistryAuthFromDockerConfig(imageName string) (string, bool) {
named, err := reference.ParseNormalizedNamed(imageName) registryHost, hasRegistry := extractRegistryHost(imageName)
if err != nil {
return "", false
}
registryHost := normalizeRegistryHost(reference.Domain(named))
cfg := config.LoadDefaultConfigFile(io.Discard) cfg := config.LoadDefaultConfigFile(io.Discard)
if cfg == nil { if cfg == nil {
return "", false return "", false
} }
candidates := make([]string, 0) candidates := make([]string, 0)
if !isDockerHubRegistry(registryHost) { if hasRegistry {
candidates = append(candidates, registryHost, "https://"+registryHost, "http://"+registryHost) candidates = append(candidates, registryHost, "https://"+registryHost, "http://"+registryHost)
} else { }
if !hasRegistry || isDockerHubRegistry(registryHost) {
candidates = append(candidates, candidates = append(candidates,
"https://index.docker.io/v1/", "https://index.docker.io/v1/",
"index.docker.io", "index.docker.io",
@@ -431,6 +413,18 @@ func isDockerHubRegistry(host string) bool {
} }
} }
func extractRegistryHost(imageName string) (string, bool) {
parts := strings.Split(imageName, "/")
if len(parts) < 2 {
return "", false
}
first := parts[0]
if strings.Contains(first, ".") || strings.Contains(first, ":") || first == "localhost" {
return normalizeRegistryHost(first), true
}
return "", false
}
func normalizeRegistryHost(registryKey string) string { func normalizeRegistryHost(registryKey string) string {
key := strings.TrimSpace(registryKey) key := strings.TrimSpace(registryKey)
if key == "" { if key == "" {
+266
View File
@@ -0,0 +1,266 @@
// 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
}
+34 -68
View File
@@ -3,7 +3,6 @@ package mcpruntime
import ( import (
"bytes" "bytes"
"context" "context"
_ "crypto/sha256"
"encoding/binary" "encoding/binary"
"encoding/json" "encoding/json"
"errors" "errors"
@@ -16,21 +15,19 @@ import (
"strings" "strings"
"sync" "sync"
"time" "time"
"unicode"
"github.com/1Panel-dev/1Panel/agent/utils/docker" "github.com/1Panel-dev/1Panel/agent/utils/docker"
"github.com/containerd/errdefs"
"github.com/distribution/reference"
"github.com/docker/docker/api/types/container" "github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/image" "github.com/docker/docker/api/types/image"
"github.com/docker/docker/api/types/network" "github.com/docker/docker/api/types/network"
"github.com/docker/docker/api/types/system" "github.com/docker/docker/api/types/system"
"github.com/docker/docker/client" "github.com/docker/docker/client"
"github.com/docker/docker/errdefs"
ocispec "github.com/opencontainers/image-spec/specs-go/v1" ocispec "github.com/opencontainers/image-spec/specs-go/v1"
) )
const ( const (
ImageRepository = "1panel/mcp-server" ImageRepository = "1panel/1panel-mcp-server"
ContainerName = "1panel-mcp" ContainerName = "1panel-mcp"
previousName = "1panel-mcp-previous" previousName = "1panel-mcp-previous"
SocketDirectory = "/etc/1panel/mcp-runtime" SocketDirectory = "/etc/1panel/mcp-runtime"
@@ -51,13 +48,12 @@ var digestPattern = regexp.MustCompile(`^sha256:[a-f0-9]{64}$`)
var ErrUnsupported = errors.New("MCP Docker runtime requires a local rootful Linux Docker daemon without user namespace remapping") var ErrUnsupported = errors.New("MCP Docker runtime requires a local rootful Linux Docker daemon without user namespace remapping")
type Request struct { type Request struct {
Operation string `json:"operation"` Operation string `json:"operation"`
Image string `json:"image"` Image string `json:"image"`
Port int `json:"port"` Port int `json:"port"`
Enabled bool `json:"enabled"` Enabled bool `json:"enabled"`
Restart bool `json:"restart"` Restart bool `json:"restart"`
ImageID string `json:"imageID,omitempty"` ImageID string `json:"imageID,omitempty"`
StreamProgress bool `json:"streamProgress,omitempty"`
} }
type State struct { type State struct {
@@ -110,7 +106,6 @@ type Controller struct {
platform string platform string
health func(context.Context, int) (metadata, error) health func(context.Context, int) (metadata, error)
checkDirectory func() error checkDirectory func() error
registryAuth func(string) (string, bool)
mutation chan struct{} mutation chan struct{}
probes chan struct{} probes chan struct{}
cacheMu sync.Mutex cacheMu sync.Mutex
@@ -123,40 +118,25 @@ func New() *Controller {
func newController(factory func() (DockerClient, error)) *Controller { func newController(factory func() (DockerClient, error)) *Controller {
return &Controller{newClient: factory, platform: runtime.GOOS, health: readHealth, return &Controller{newClient: factory, platform: runtime.GOOS, health: readHealth,
checkDirectory: checkSocketDirectory, registryAuth: docker.RegistryAuthForImage, checkDirectory: checkSocketDirectory, mutation: make(chan struct{}, 1), probes: make(chan struct{}, 8), cache: make(map[string]verifiedImage)}
mutation: make(chan struct{}, 1), probes: make(chan struct{}, 8), cache: make(map[string]verifiedImage)}
} }
func ValidateImage(value string) error { func ValidateImage(value string) error {
if value == "" || len(value) > 512 || strings.IndexFunc(value, func(ch rune) bool { return unicode.IsSpace(ch) || unicode.IsControl(ch) }) >= 0 { if len(value) > 180 {
return errors.New("invalid MCP Docker image") return errors.New("invalid MCP Docker image")
} }
parsed, err := reference.ParseNormalizedNamed(value) if strings.HasPrefix(value, ImageRepository+"@") && digestPattern.MatchString(strings.TrimPrefix(value, ImageRepository+"@")) {
if err != nil {
return errors.New("invalid MCP Docker image")
}
if digested, ok := parsed.(reference.Digested); ok {
if digestPattern.MatchString(digested.Digest().String()) {
return nil
}
return errors.New("MCP Docker image must use a SHA256 digest")
}
if _, ok := parsed.(reference.Tagged); ok {
return nil return nil
} }
return errors.New("MCP Docker image must include a tag or SHA256 digest") 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")
} }
func (c *Controller) Handle(ctx context.Context, req Request) (Response, error) { func (c *Controller) Handle(ctx context.Context, req Request) (Response, error) {
return c.HandleWithProgress(ctx, req, nil)
}
func (c *Controller) HandleWithProgress(ctx context.Context, req Request, progress func(PullProgress) error) (Response, error) {
if req.StreamProgress && req.Operation != "pull" {
return Response{}, errors.New("MCP Docker progress is only available when pulling an image")
}
switch req.Operation { switch req.Operation {
case "prepare", "catalog", "apply_catalog", "candidate_catalog", "status", "apply", "install", "rollback", "rollback_absent", "pull", "logs": case "prepare", "catalog", "candidate_catalog", "status", "apply", "install", "rollback", "rollback_absent", "pull", "logs":
default: default:
return Response{}, errors.New("invalid MCP Docker operation") return Response{}, errors.New("invalid MCP Docker operation")
} }
@@ -167,7 +147,7 @@ func (c *Controller) HandleWithProgress(ctx context.Context, req Request, progre
return Response{}, errors.New("MCP listen port must be between 1024 and 65535") return Response{}, errors.New("MCP listen port must be between 1024 and 65535")
} }
timeout := 60 * time.Second timeout := 60 * time.Second
if req.Operation == "catalog" || req.Operation == "apply_catalog" || req.Operation == "candidate_catalog" || req.Operation == "logs" || req.Operation == "prepare" { if req.Operation == "catalog" || req.Operation == "candidate_catalog" || req.Operation == "logs" || req.Operation == "prepare" {
timeout = 5 * time.Second timeout = 5 * time.Second
} }
if req.Operation == "status" { if req.Operation == "status" {
@@ -196,23 +176,19 @@ func (c *Controller) HandleWithProgress(ctx context.Context, req Request, progre
} }
switch req.Operation { switch req.Operation {
case "prepare": case "prepare":
case "catalog", "apply_catalog", "candidate_catalog": case "catalog", "candidate_catalog":
var img image.InspectResponse var img image.InspectResponse
if req.Operation != "candidate_catalog" { img, err = c.resolve(ctx, cli, req.Image)
if err == nil && req.Operation == "catalog" {
var current *container.InspectResponse var current *container.InspectResponse
current, err = c.owned(ctx, cli, ContainerName) current, err = c.owned(ctx, cli, ContainerName)
if err == nil && req.Operation == "catalog" && current != nil && current.Config.Labels[imageLabel] != req.Image { if err == nil && current != nil && isRunning(current) && current.Config.Labels[imageLabel] == req.Image && current.Image != img.ID {
err = errors.New("MCP installed runtime does not match the configured image") err = errors.New("MCP image reference changed; restart the adapter before reading its catalog")
} }
if err == nil {
img, err = c.resolveActiveImage(ctx, cli, req.Image, current)
}
} else {
img, err = c.resolve(ctx, cli, req.Image)
} }
if err == nil { if err == nil {
var verified verifiedImage var verified verifiedImage
verified, err = c.verify(ctx, cli, img.ID) verified, err = c.verify(ctx, cli, img.ID, req.Image)
if err == nil { if err == nil {
return Response{Catalog: append(json.RawMessage(nil), verified.catalog...), ImageID: img.ID}, nil return Response{Catalog: append(json.RawMessage(nil), verified.catalog...), ImageID: img.ID}, nil
} }
@@ -228,7 +204,7 @@ func (c *Controller) HandleWithProgress(ctx context.Context, req Request, progre
case "rollback_absent": case "rollback_absent":
err = c.rollbackAbsent(ctx, cli, req) err = c.rollbackAbsent(ctx, cli, req)
case "pull": case "pull":
err = c.pullImage(ctx, cli, req, progress) err = c.pullImage(ctx, cli, req)
case "logs": case "logs":
var current *container.InspectResponse var current *container.InspectResponse
current, err = c.owned(ctx, cli, ContainerName) current, err = c.owned(ctx, cli, ContainerName)
@@ -283,20 +259,6 @@ func (c *Controller) resolve(ctx context.Context, cli DockerClient, ref string)
return img, nil return img, nil
} }
func (c *Controller) resolveActiveImage(ctx context.Context, cli DockerClient, ref string, current *container.InspectResponse) (image.InspectResponse, error) {
if current == nil || current.Config.Labels[imageLabel] != ref {
return c.resolve(ctx, cli, ref)
}
if !digestPattern.MatchString(current.Image) {
return image.InspectResponse{}, errors.New("invalid MCP installed image identity")
}
img, err := c.resolve(ctx, cli, current.Image)
if err != nil || img.ID != current.Image {
return image.InspectResponse{}, errors.New("MCP installed image is unavailable or changed")
}
return img, nil
}
func (c *Controller) owned(ctx context.Context, cli DockerClient, name string) (*container.InspectResponse, error) { func (c *Controller) owned(ctx context.Context, cli DockerClient, name string) (*container.InspectResponse, error) {
info, err := cli.ContainerInspect(ctx, name) info, err := cli.ContainerInspect(ctx, name)
if errdefs.IsNotFound(err) { if errdefs.IsNotFound(err) {
@@ -317,6 +279,10 @@ func isRunning(info *container.InspectResponse) bool {
func (c *Controller) status(ctx context.Context, cli DockerClient, req Request) Response { func (c *Controller) status(ctx context.Context, cli DockerClient, req Request) Response {
state := &State{Status: "not_installed", Image: req.Image} state := &State{Status: "not_installed", Image: req.Image}
img, err := c.resolve(ctx, cli, req.Image)
if err == nil {
state.ImageID = img.ID
}
current, inspectErr := c.owned(ctx, cli, ContainerName) current, inspectErr := c.owned(ctx, cli, ContainerName)
if inspectErr != nil { if inspectErr != nil {
state.Status = "unhealthy" state.Status = "unhealthy"
@@ -324,11 +290,9 @@ func (c *Controller) status(ctx context.Context, cli DockerClient, req Request)
return Response{State: state} return Response{State: state}
} }
if current == nil { if current == nil {
img, err := c.resolve(ctx, cli, req.Image)
if err != nil { if err != nil {
state.Reason = err.Error() state.Reason = err.Error()
} else { } else {
state.ImageID = img.ID
state.Reason = "MCP image is loaded; install it to create the managed container" state.Reason = "MCP image is loaded; install it to create the managed container"
} }
return Response{State: state} return Response{State: state}
@@ -348,13 +312,12 @@ func (c *Controller) status(ctx context.Context, cli DockerClient, req Request)
state.Reason = "MCP access is disabled" state.Reason = "MCP access is disabled"
return Response{State: state} return Response{State: state}
} }
img, err := c.resolveActiveImage(ctx, cli, req.Image, current) if err != nil || current.Image != img.ID {
if err != nil || current.Config.Labels[imageLabel] != req.Image {
state.Status = "unhealthy" state.Status = "unhealthy"
state.Reason = "MCP container does not match the configured image or listen port" state.Reason = "MCP container does not match the configured image or listen port"
return Response{State: state} return Response{State: state}
} }
verified, verifyErr := c.verify(ctx, cli, current.Image) verified, verifyErr := c.verify(ctx, cli, current.Image, req.Image)
if verifyErr != nil || !matchesRuntimeWithCapabilities(ctx, cli, current, img.ID, req, verified.metadata.AdapterVersion) { if verifyErr != nil || !matchesRuntimeWithCapabilities(ctx, cli, current, img.ID, req, verified.metadata.AdapterVersion) {
state.Status = "unhealthy" state.Status = "unhealthy"
state.Reason = "MCP container configuration or metadata does not match the verified image" state.Reason = "MCP container configuration or metadata does not match the verified image"
@@ -371,7 +334,7 @@ func (c *Controller) status(ctx context.Context, cli DockerClient, req Request)
return Response{State: state} return Response{State: state}
} }
func (c *Controller) verify(ctx context.Context, cli DockerClient, id string) (verifiedImage, error) { func (c *Controller) verify(ctx context.Context, cli DockerClient, id, ref string) (verifiedImage, error) {
c.cacheMu.Lock() c.cacheMu.Lock()
cached, ok := c.cache[id] cached, ok := c.cache[id]
c.cacheMu.Unlock() c.cacheMu.Unlock()
@@ -403,6 +366,9 @@ func (c *Controller) verify(ctx context.Context, cli DockerClient, id string) (v
c.cache[id] = cached c.cache[id] = cached
c.cacheMu.Unlock() c.cacheMu.Unlock()
} }
if strings.HasPrefix(ref, ImageRepository+":") && strings.TrimPrefix(cached.metadata.AdapterVersion, "v") != strings.TrimPrefix(strings.TrimPrefix(ref, ImageRepository+":"), "v") {
return verifiedImage{}, errors.New("MCP image version does not match its selected tag")
}
return cached, nil return cached, nil
} }
+11 -57
View File
@@ -10,90 +10,49 @@ import (
"strings" "strings"
"time" "time"
"github.com/containerd/errdefs"
"github.com/docker/docker/api/types/container" "github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/image" "github.com/docker/docker/api/types/image"
"github.com/docker/docker/api/types/mount" "github.com/docker/docker/api/types/mount"
"github.com/docker/docker/errdefs"
) )
func (c *Controller) pullImage(ctx context.Context, cli DockerClient, req Request, progress func(PullProgress) error) error { func (c *Controller) pullImage(ctx context.Context, cli DockerClient, req Request) error {
if err := reportPullProgress(ctx, progress, PullProgress{Status: "checking_image"}); err != nil {
return err
}
_, err := cli.ImageInspect(ctx, req.Image) _, err := cli.ImageInspect(ctx, req.Image)
if err != nil && !errdefs.IsNotFound(err) { if err != nil && !errdefs.IsNotFound(err) {
return errors.New("MCP Docker image inspection failed") return errors.New("MCP Docker image inspection failed")
} }
if errdefs.IsNotFound(err) { if errdefs.IsNotFound(err) {
if err = reportPullProgress(ctx, progress, PullProgress{Status: "pulling"}); err != nil {
return err
}
pullCtx, cancel := context.WithTimeout(ctx, 3*time.Minute) pullCtx, cancel := context.WithTimeout(ctx, 3*time.Minute)
defer cancel() defer cancel()
options := image.PullOptions{} reader, pullErr := cli.ImagePull(pullCtx, req.Image, image.PullOptions{})
if auth, ok := c.registryAuth(req.Image); ok {
options.RegistryAuth = auth
}
reader, pullErr := cli.ImagePull(pullCtx, req.Image, options)
if pullErr != nil { if pullErr != nil {
if errdefs.IsUnauthorized(pullErr) || errdefs.IsPermissionDenied(pullErr) {
return errors.New("MCP Docker image registry authentication failed")
}
return errors.New("MCP Docker image download failed") return errors.New("MCP Docker image download failed")
} }
defer reader.Close() defer reader.Close()
limited := &io.LimitedReader{R: reader, N: (16 << 20) + 1} decoder := json.NewDecoder(io.LimitReader(reader, 16<<20))
decoder := json.NewDecoder(limited)
for { for {
var message struct { var message struct {
Status string `json:"status"`
ID string `json:"id"`
ProgressDetail struct {
Current int64 `json:"current"`
Total int64 `json:"total"`
} `json:"progressDetail"`
Error string `json:"error"` Error string `json:"error"`
ErrorDetail *struct { ErrorDetail *struct {
Code int `json:"code"`
Message string `json:"message"` Message string `json:"message"`
} `json:"errorDetail"` } `json:"errorDetail"`
} }
err = decoder.Decode(&message) if err = decoder.Decode(&message); err == io.EOF {
if limited.N == 0 {
return errors.New("MCP Docker image download progress exceeds permitted size")
}
if err == io.EOF {
break break
} else if err != nil { } else if err != nil {
return errors.New("MCP Docker image download was interrupted") return errors.New("MCP Docker image download was interrupted")
} }
if message.Error != "" || message.ErrorDetail != nil { if message.Error != "" || message.ErrorDetail != nil {
if message.ErrorDetail != nil && (message.ErrorDetail.Code == 401 || message.ErrorDetail.Code == 403) {
return errors.New("MCP Docker image registry authentication failed")
}
return errors.New("MCP Docker image download failed") return errors.New("MCP Docker image download failed")
} }
if normalized, ok := normalizePullProgress(message.Status, message.ID, message.ProgressDetail.Current, message.ProgressDetail.Total); ok {
if err = reportPullProgress(ctx, progress, normalized); err != nil {
return err
}
}
} }
} else if err = reportPullProgress(ctx, progress, PullProgress{Status: "local_image"}); err != nil {
return err
}
if err = reportPullProgress(ctx, progress, PullProgress{Status: "verifying_image"}); err != nil {
return err
} }
img, err := c.resolve(ctx, cli, req.Image) img, err := c.resolve(ctx, cli, req.Image)
if err != nil { if err != nil {
return err return err
} }
_, err = c.verify(ctx, cli, img.ID) _, err = c.verify(ctx, cli, img.ID, req.Image)
if err != nil { return err
return err
}
return reportPullProgress(ctx, progress, PullProgress{Status: "verified_image"})
} }
func (c *Controller) installRuntime(ctx context.Context, cli DockerClient, req Request) error { func (c *Controller) installRuntime(ctx context.Context, cli DockerClient, req Request) error {
@@ -217,19 +176,14 @@ func (c *Controller) apply(ctx context.Context, cli DockerClient, req Request) e
if err = c.checkDirectory(); err != nil { if err = c.checkDirectory(); err != nil {
return err return err
} }
var img image.InspectResponse img, err := c.resolve(ctx, cli, req.Image)
if req.Operation == "install" {
img, err = c.resolve(ctx, cli, req.Image)
} else {
img, err = c.resolveActiveImage(ctx, cli, req.Image, current)
}
if err != nil { if err != nil {
return err return err
} }
if !digestPattern.MatchString(req.ImageID) || img.ID != req.ImageID { if !digestPattern.MatchString(req.ImageID) || img.ID != req.ImageID {
return errors.New("MCP image identity changed after catalog validation; retry the operation") return errors.New("MCP image identity changed after catalog validation; retry the operation")
} }
verified, err := c.verify(ctx, cli, img.ID) verified, err := c.verify(ctx, cli, img.ID, req.Image)
if err != nil { if err != nil {
return err return err
} }
@@ -400,7 +354,7 @@ func (c *Controller) rollback(ctx context.Context, cli DockerClient, req Request
} }
return errors.New("the previous MCP runtime instance is unavailable for rollback") return errors.New("the previous MCP runtime instance is unavailable for rollback")
} }
verified, err := c.verify(ctx, cli, target.Image) verified, err := c.verify(ctx, cli, target.Image, req.Image)
if err != nil { if err != nil {
return err return err
} }
@@ -448,7 +402,7 @@ func (c *Controller) rollbackAbsent(ctx context.Context, cli DockerClient, req R
if _, err = c.resolve(ctx, cli, current.Image); err != nil { if _, err = c.resolve(ctx, cli, current.Image); err != nil {
return err return err
} }
verified, err := c.verify(ctx, cli, current.Image) verified, err := c.verify(ctx, cli, current.Image, req.Image)
if err != nil { if err != nil {
return err return err
} }
-63
View File
@@ -1,63 +0,0 @@
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
@@ -1,98 +0,0 @@
// 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...)
}
+2 -144
View File
@@ -15,7 +15,7 @@ import (
) )
func bindAPIKeyRequest(c *gin.Context, value interface{}) bool { func bindAPIKeyRequest(c *gin.Context, value interface{}) bool {
if _, err := auth.RequireAPIKeyManagement(c); err != nil { if _, err := auth.RequireAPIKeySession(c); err != nil {
helper.BadAuth(c, "ErrNotLogin", nil) helper.BadAuth(c, "ErrNotLogin", nil)
return false return false
} }
@@ -41,24 +41,6 @@ func apiKeyError(c *gin.Context, err error) {
helper.InternalServer(c, err) helper.InternalServer(c, err)
} }
// Swag's legacy || syntax combines schemes in one requirement (AND);
// separate @Security lines represent alternative authentication methods.
// @Tags API Key
// @Summary List the current account's API keys
// @Description Accepts a panel login session with CSRF protection, or a valid API Key signature. The authenticated account determines ownership; another account cannot be selected in the request.
// @Description Returns key metadata and masked hints, never plaintext secrets. The legacy key is included when present; deleted keys are excluded.
// @Description Authentication and business errors use HTTP 200 with envelope code 400, 401 or 500; check code rather than HTTP status alone.
// @Accept json
// @Produce json
// @Param request body dto.APIKeySearch true "Pagination and revoked-key filter"
// @Param 1Panel-Key-ID header string false "Optional ID of the authenticating independent key; omit for legacy keys"
// @Param 1Panel-Signature-Version header string false "Signature algorithm; omission accepts either supported algorithm" Enums(v1,md5,hmac-sha256)
// @Success 200 {object} dto.Response{data=dto.APIKeyPage} "code=200 on success; data is null on errors"
// @Failure 403 {object} dto.Response "Invalid or missing CSRF token for a session request"
// @Header 200 {string} Cache-Control "no-store"
// @Security ApiKeyAuth || Timestamp
// @Security PanelSession || CSRFToken
// @Router /core/auth/api/keys/search [post]
func (b *BaseApi) SearchAPIKeys(c *gin.Context) { func (b *BaseApi) SearchAPIKeys(c *gin.Context) {
var req dto.APIKeySearch var req dto.APIKeySearch
if !bindAPIKeyRequest(c, &req) { if !bindAPIKeyRequest(c, &req) {
@@ -73,25 +55,6 @@ func (b *BaseApi) SearchAPIKeys(c *gin.Context) {
helper.SuccessWithData(c, result) helper.SuccessWithData(c, result)
} }
// @Tags API Key
// @Summary Create an API key for the current account
// @Description Accepts a panel login session with CSRF protection, or a valid API Key signature. The new key belongs to the authenticated account.
// @Description requestID is optional. Omitted or empty values are generated by the server. For idempotent retries, supply a UUID and reuse it with the same payload.
// @Description A successful first request returns the plaintext secret. Repeating the same requestID and payload returns alreadyCreated=true and an empty apiKey; changing the payload for that requestID is rejected.
// @Description Omitted expiresAt defaults to 90 days; explicit null means no expiration. Omitted apiKeyValidityTime defaults to 120 minutes; zero disables timestamp validation.
// @Description API-created keys may have a longer lifetime or broader IP policy than the calling key. Revoking the calling key does not revoke keys it created.
// @Description Authentication and business errors use HTTP 200 with envelope code 400, 401 or 500; check code rather than HTTP status alone. Unknown fields and bodies larger than 32 KiB are rejected.
// @Accept json
// @Produce json
// @Param request body dto.APIKeyCreate true "Key policy and an optional UUID requestID for idempotency"
// @Param 1Panel-Key-ID header string false "Optional ID of the authenticating independent key; omit for legacy keys"
// @Param 1Panel-Signature-Version header string false "Signature algorithm; omission accepts either supported algorithm" Enums(v1,md5,hmac-sha256)
// @Success 200 {object} dto.Response{data=dto.APIKeyCreated} "code=200 on success; data is null on errors"
// @Failure 403 {object} dto.Response "Invalid or missing CSRF token for a session request"
// @Header 200 {string} Cache-Control "no-store"
// @Security ApiKeyAuth || Timestamp
// @Security PanelSession || CSRFToken
// @Router /core/auth/api/keys/create [post]
func (b *BaseApi) CreateAPIKey(c *gin.Context) { func (b *BaseApi) CreateAPIKey(c *gin.Context) {
req := dto.APIKeyCreate{APIKeyFields: dto.APIKeyFields{APIKeyValidityTime: 120}} req := dto.APIKeyCreate{APIKeyFields: dto.APIKeyFields{APIKeyValidityTime: 120}}
if !bindAPIKeyRequest(c, &req) { if !bindAPIKeyRequest(c, &req) {
@@ -106,22 +69,6 @@ func (b *BaseApi) CreateAPIKey(c *gin.Context) {
helper.SuccessWithData(c, result) helper.SuccessWithData(c, result)
} }
// @Tags API Key
// @Summary Update an API key's policy
// @Description Accepts a panel login session with CSRF protection, or a valid API Key signature. Only keys owned by the authenticated account can be changed.
// @Description Submit the current revision returned by search; stale revisions are rejected. This replaces editable policy fields without changing the secret. expiresAt must be supplied as an RFC 3339 timestamp or null.
// @Description Use id=legacy for the legacy key; its name is fixed and expiresAt must be null. Revoked or deleted independent keys cannot be updated.
// @Description Success increments the revision; search again before another mutation. Authentication and business errors use HTTP 200 with envelope code 400, 401 or 500.
// @Accept json
// @Produce json
// @Param request body dto.APIKeyUpdate true "Key ID, current revision and complete editable policy"
// @Param 1Panel-Key-ID header string false "Optional ID of the authenticating independent key; omit for legacy keys"
// @Param 1Panel-Signature-Version header string false "Signature algorithm; omission accepts either supported algorithm" Enums(v1,md5,hmac-sha256)
// @Success 200 {object} dto.Response{data=dto.APIKeyMutationResult} "code=200 on success; data is null on errors"
// @Failure 403 {object} dto.Response "Invalid or missing CSRF token for a session request"
// @Security ApiKeyAuth || Timestamp
// @Security PanelSession || CSRFToken
// @Router /core/auth/api/keys/update [post]
func (b *BaseApi) UpdateAPIKey(c *gin.Context) { func (b *BaseApi) UpdateAPIKey(c *gin.Context) {
var req dto.APIKeyUpdate var req dto.APIKeyUpdate
if !bindAPIKeyRequest(c, &req) { if !bindAPIKeyRequest(c, &req) {
@@ -134,21 +81,6 @@ func (b *BaseApi) UpdateAPIKey(c *gin.Context) {
apiKeyMutationSuccess(c) apiKeyMutationSuccess(c)
} }
// @Tags API Key
// @Summary Enable or disable an API key
// @Description Accepts a panel login session with CSRF protection, or a valid API Key signature. Only keys owned by the authenticated account can be changed; id=legacy is supported.
// @Description Submit the current revision returned by search. Revoked or deleted keys cannot be enabled; expired keys must first receive a future expiration time. Disabling a key also closes its active terminal sessions.
// @Description Success increments the revision; search again before another mutation. Authentication and business errors use HTTP 200 with envelope code 400, 401 or 500.
// @Accept json
// @Produce json
// @Param request body dto.APIKeyStatus true "Key ID, current revision and Enable or Disable status"
// @Param 1Panel-Key-ID header string false "Optional ID of the authenticating independent key; omit for legacy keys"
// @Param 1Panel-Signature-Version header string false "Signature algorithm; omission accepts either supported algorithm" Enums(v1,md5,hmac-sha256)
// @Success 200 {object} dto.Response{data=dto.APIKeyMutationResult} "code=200 on success; data is null on errors"
// @Failure 403 {object} dto.Response "Invalid or missing CSRF token for a session request"
// @Security ApiKeyAuth || Timestamp
// @Security PanelSession || CSRFToken
// @Router /core/auth/api/keys/status [post]
func (b *BaseApi) SetAPIKeyStatus(c *gin.Context) { func (b *BaseApi) SetAPIKeyStatus(c *gin.Context) {
var req dto.APIKeyStatus var req dto.APIKeyStatus
if !bindAPIKeyRequest(c, &req) { if !bindAPIKeyRequest(c, &req) {
@@ -161,22 +93,6 @@ func (b *BaseApi) SetAPIKeyStatus(c *gin.Context) {
apiKeyMutationSuccess(c) apiKeyMutationSuccess(c)
} }
// @Tags API Key
// @Summary Revoke an API key
// @Description Accepts a panel login session with CSRF protection, or a valid API Key signature. Only keys owned by the authenticated account can be revoked.
// @Description Submit the current revision returned by search. Revocation destroys the independent key's stored secret and cannot be undone; its metadata remains listed. For id=legacy, the legacy secret is cleared and API access is disabled.
// @Description A key can revoke itself; subsequent requests signed with it fail. Keys previously created by the revoked key remain independent. Success increments the revision and closes associated terminal sessions.
// @Description Authentication and business errors use HTTP 200 with envelope code 400, 401 or 500.
// @Accept json
// @Produce json
// @Param request body dto.APIKeyMutation true "Key ID and current revision"
// @Param 1Panel-Key-ID header string false "Optional ID of the authenticating independent key; omit for legacy keys"
// @Param 1Panel-Signature-Version header string false "Signature algorithm; omission accepts either supported algorithm" Enums(v1,md5,hmac-sha256)
// @Success 200 {object} dto.Response{data=dto.APIKeyMutationResult} "code=200 on success; data is null on errors"
// @Failure 403 {object} dto.Response "Invalid or missing CSRF token for a session request"
// @Security ApiKeyAuth || Timestamp
// @Security PanelSession || CSRFToken
// @Router /core/auth/api/keys/revoke [post]
func (b *BaseApi) RevokeAPIKey(c *gin.Context) { func (b *BaseApi) RevokeAPIKey(c *gin.Context) {
var req dto.APIKeyMutation var req dto.APIKeyMutation
if !bindAPIKeyRequest(c, &req) { if !bindAPIKeyRequest(c, &req) {
@@ -190,63 +106,5 @@ func (b *BaseApi) RevokeAPIKey(c *gin.Context) {
} }
func apiKeyMutationSuccess(c *gin.Context) { func apiKeyMutationSuccess(c *gin.Context) {
helper.SuccessWithData(c, dto.APIKeyMutationResult{TerminalClosePending: c.GetBool("API_KEY_TERMINAL_CLOSE_PENDING")}) helper.SuccessWithData(c, gin.H{"terminalClosePending": c.GetBool("API_KEY_TERMINAL_CLOSE_PENDING")})
}
// @Tags API Key
// @Summary Delete an independent API key
// @Description Accepts a panel login session with CSRF protection, or a valid API Key signature. Only keys owned by the authenticated account can be deleted; id=legacy is not supported.
// @Description Submit the current revision returned by search. Deletion immediately revokes the key, destroys its stored secret and removes it from lists. Audit and idempotency records are retained. Already-revoked keys can be deleted; deletion cannot be undone.
// @Description A key can delete itself; subsequent requests signed with it fail. Authentication and business errors use HTTP 200 with envelope code 400, 401 or 500.
// @Accept json
// @Produce json
// @Param request body dto.APIKeyMutation true "Independent key ID and current revision"
// @Param 1Panel-Key-ID header string false "Optional ID of the authenticating independent key; omit for legacy keys"
// @Param 1Panel-Signature-Version header string false "Signature algorithm; omission accepts either supported algorithm" Enums(v1,md5,hmac-sha256)
// @Success 200 {object} dto.Response{data=dto.APIKeyMutationResult} "code=200 on success; data is null on errors"
// @Failure 403 {object} dto.Response "Invalid or missing CSRF token for a session request"
// @Security ApiKeyAuth || Timestamp
// @Security PanelSession || CSRFToken
// @Router /core/auth/api/keys/delete [post]
func (b *BaseApi) DeleteAPIKey(c *gin.Context) {
var req dto.APIKeyMutation
if !bindAPIKeyRequest(c, &req) {
return
}
if err := service.NewAPIKeyService().Delete(c, req); err != nil {
apiKeyError(c, err)
return
}
apiKeyMutationSuccess(c)
}
// @Tags API Key
// @Summary Reveal an independent API key's plaintext secret
// @Description Requires a panel login session and CSRF token. API Key authentication is not supported, including requests that also contain a valid session cookie; omit all 1Panel authentication headers.
// @Description Only the current account's independent keys can be revealed. Submit the current revision returned by search. Legacy, revoked and deleted keys are rejected; disabled or expired keys can still be revealed.
// @Description Secret disclosure is audited. Authentication and business errors use HTTP 200 with envelope code 400, 401 or 500. The response must not be cached or logged.
// @Accept json
// @Produce json
// @Param request body dto.APIKeyMutation true "Independent key ID and current revision"
// @Success 200 {object} dto.Response{data=dto.APIKeyRevealed} "code=200 on success; data is null on errors"
// @Failure 403 {object} dto.Response "Invalid or missing CSRF token"
// @Header 200 {string} Cache-Control "no-store"
// @Security PanelSession || CSRFToken
// @Router /core/auth/api/keys/reveal [post]
func (b *BaseApi) RevealAPIKey(c *gin.Context) {
c.Header("Cache-Control", "no-store")
if _, err := auth.RequireAPIKeySession(c); err != nil {
helper.BadAuth(c, "ErrNotLogin", nil)
return
}
var req dto.APIKeyMutation
if !bindAPIKeyRequest(c, &req) {
return
}
result, err := service.NewAPIKeyService().Reveal(c, req)
if err != nil {
apiKeyError(c, err)
return
}
helper.SuccessWithData(c, result)
} }
+8 -11
View File
@@ -417,13 +417,11 @@ func (b *BaseApi) MFAClose(c *gin.Context) {
} }
// @Tags Auth // @Tags Auth
// @Summary Generate or reset the legacy API key // @Summary generate api key
// @Description Requires a panel login session and CSRF token. API Key authentication is not supported. This replaces the current account's legacy secret; existing clients using the previous secret must be updated. Independent API keys are unaffected.
// @Accept json // @Accept json
// @Produce json // @Success 200 {string} key
// @Success 200 {object} dto.Response{data=string} "code=200 with the new secret; authentication and business errors use envelope code 400, 401 or 500" // @Security ApiKeyAuth
// @Header 200 {string} Cache-Control "no-store" // @Security Timestamp
// @Security PanelSession || CSRFToken
// @Router /core/auth/api/generate [post] // @Router /core/auth/api/generate [post]
// @x-panel-log {"bodyKeys":[],"paramKeys":[],"BeforeFunctions":[],"formatZH":"生成 API 接口密钥","formatEN":"generate api key"} // @x-panel-log {"bodyKeys":[],"paramKeys":[],"BeforeFunctions":[],"formatZH":"生成 API 接口密钥","formatEN":"generate api key"}
func (b *BaseApi) GenerateApiKey(c *gin.Context) { func (b *BaseApi) GenerateApiKey(c *gin.Context) {
@@ -442,13 +440,12 @@ func (b *BaseApi) GenerateApiKey(c *gin.Context) {
} }
// @Tags Auth // @Tags Auth
// @Summary Update the legacy API key configuration // @Summary Update api config
// @Description Requires a panel login session and CSRF token. API Key authentication is not supported. This updates only the current account's legacy API configuration.
// @Accept json // @Accept json
// @Produce json
// @Param request body dto.ApiInterfaceConfig true "request" // @Param request body dto.ApiInterfaceConfig true "request"
// @Success 200 {object} dto.Response "code=200 on success; authentication and business errors use envelope code 400, 401 or 500" // @Success 200
// @Security PanelSession || CSRFToken // @Security ApiKeyAuth
// @Security Timestamp
// @Router /core/auth/api/update [post] // @Router /core/auth/api/update [post]
// @x-panel-log {"bodyKeys":["ipWhiteList","apiTrustedProxies"],"paramKeys":[],"BeforeFunctions":[],"formatZH":"更新 API 接口配置 => IP 白名单: [ipWhiteList], API 可信代理: [apiTrustedProxies]","formatEN":"update api config => IP Allowlist: [ipWhiteList], API Trusted Proxies: [apiTrustedProxies]"} // @x-panel-log {"bodyKeys":["ipWhiteList","apiTrustedProxies"],"paramKeys":[],"BeforeFunctions":[],"formatZH":"更新 API 接口配置 => IP 白名单: [ipWhiteList], API 可信代理: [apiTrustedProxies]","formatEN":"update api config => IP Allowlist: [ipWhiteList], API Trusted Proxies: [apiTrustedProxies]"}
func (b *BaseApi) UpdateApiConfig(c *gin.Context) { func (b *BaseApi) UpdateApiConfig(c *gin.Context) {
+3 -63
View File
@@ -1,7 +1,6 @@
package v2 package v2
import ( import (
"bytes"
"context" "context"
"encoding/json" "encoding/json"
"errors" "errors"
@@ -33,41 +32,6 @@ func mcpRead(c *gin.Context, value any) bool {
if json.Unmarshal(data, &fields) != nil || len(fields.ExpiresAt) == 0 { if json.Unmarshal(data, &fields) != nil || len(fields.ExpiresAt) == 0 {
err = contract.Invalid() err = contract.Invalid()
} }
case *dto.MCPWebsiteBind, *dto.MCPWebsiteUpdate:
var fields struct {
EnableSSL json.RawMessage `json:"enableSSL"`
SSLID json.RawMessage `json:"sslID"`
}
if json.Unmarshal(data, &fields) != nil || len(fields.EnableSSL) == 0 || len(fields.SSLID) == 0 ||
bytes.Equal(bytes.TrimSpace(fields.EnableSSL), []byte("null")) || bytes.Equal(bytes.TrimSpace(fields.SSLID), []byte("null")) {
err = contract.Invalid()
}
case *dto.MCPRuntimeInstall:
var fields struct {
AccessPolicy json.RawMessage `json:"accessPolicy"`
AllowedIPs json.RawMessage `json:"allowedIPs"`
Website *struct {
Domain json.RawMessage `json:"domain"`
EnableSSL json.RawMessage `json:"enableSSL"`
SSLID json.RawMessage `json:"sslID"`
} `json:"website"`
}
if json.Unmarshal(data, &fields) != nil {
err = contract.Invalid()
} else {
for _, field := range []json.RawMessage{fields.AccessPolicy, fields.AllowedIPs} {
if len(field) == 0 || bytes.Equal(bytes.TrimSpace(field), []byte("null")) {
err = contract.Invalid()
}
}
}
if fields.Website != nil {
for _, field := range []json.RawMessage{fields.Website.Domain, fields.Website.EnableSSL, fields.Website.SSLID} {
if len(field) == 0 || bytes.Equal(bytes.TrimSpace(field), []byte("null")) {
err = contract.Invalid()
}
}
}
} }
} }
if err != nil { if err != nil {
@@ -215,21 +179,6 @@ func (b *BaseApi) RevokeMCPCredential(c *gin.Context) {
} }
helper.Success(c) helper.Success(c)
} }
func (b *BaseApi) DeleteMCPCredential(c *gin.Context) {
s, owner, ok := mcpManagement(c, true, false)
if !ok {
return
}
var req dto.MCPCredentialMutation
if !mcpRead(c, &req) {
return
}
if err := s.Delete(owner, req); err != nil {
mcpManagementError(c, err)
return
}
helper.Success(c)
}
func (b *BaseApi) SearchMCPInvocations(c *gin.Context) { func (b *BaseApi) SearchMCPInvocations(c *gin.Context) {
s, owner, ok := mcpManagement(c, false, false) s, owner, ok := mcpManagement(c, false, false)
if !ok { if !ok {
@@ -258,17 +207,8 @@ func (b *BaseApi) MCPServiceStatus(c *gin.Context) {
mcpManagementError(c, err) mcpManagementError(c, err)
return return
} }
if result.Website == nil { result.Endpoint = mcpPublicEndpoint(c, result.Endpoint)
result.Endpoint = mcpPublicEndpoint(c, result.Endpoint)
}
result.CanManageService = owner.IsSuperAdmin result.CanManageService = owner.IsSuperAdmin
if owner.IsSuperAdmin {
result.InstallTaskID, result.Installing, err = service.NewMCPRuntimeService().InstallationTask(ctx)
if err != nil {
mcpManagementError(c, err)
return
}
}
helper.SuccessWithData(c, result) helper.SuccessWithData(c, result)
} }
func (b *BaseApi) MCPSettings(c *gin.Context) { func (b *BaseApi) MCPSettings(c *gin.Context) {
@@ -328,9 +268,9 @@ func (b *BaseApi) InstallMCPService(c *gin.Context) {
if !mcpRead(c, &req) { if !mcpRead(c, &req) {
return return
} }
ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second) ctx, cancel := context.WithTimeout(c.Request.Context(), 6*time.Minute)
defer cancel() defer cancel()
result, err := service.NewMCPRuntimeService().SubmitInstall(ctx, req) result, err := service.NewMCPRuntimeService().Install(ctx, req)
if err != nil { if err != nil {
mcpServiceError(c, err) mcpServiceError(c, err)
return return
+1 -10
View File
@@ -55,16 +55,7 @@ func (b *BaseApi) ExportMCPCredential(c *gin.Context) {
mcpManagementError(c, err) mcpManagementError(c, err)
return return
} }
endpoint := mcpPublicEndpoint(c, settings.ExternalURL) result, err := s.ExportMarkdown(owner, req, mcpPublicEndpoint(c, settings.ExternalURL), c.GetHeader("Accept-Language"))
website, err := service.NewMCPRuntimeService().WebsiteStatus(c.Request.Context())
if err != nil {
mcpManagementError(c, err)
return
}
if website.State != "unbound" {
endpoint = website.Endpoint
}
result, err := s.ExportMarkdown(owner, req, endpoint, c.GetHeader("Accept-Language"))
if err != nil { if err != nil {
mcpManagementError(c, err) mcpManagementError(c, err)
return return
-6
View File
@@ -31,12 +31,6 @@ func mcpManagementMessage(code string) string {
key = "ErrMCPAuditUnavailable" key = "ErrMCPAuditUnavailable"
case "MCP_ENDPOINT_UNAVAILABLE": case "MCP_ENDPOINT_UNAVAILABLE":
key = "ErrMCPEndpointUnavailable" key = "ErrMCPEndpointUnavailable"
case "MCP_WEBSITE_PENDING":
key = "ErrMCPWebsitePending"
case "MCP_WEBSITE_UNAVAILABLE":
key = "ErrMCPWebsiteUnavailable"
case "MCP_WEBSITE_SERVICE_REQUIRED":
key = "ErrMCPWebsiteServiceRequired"
} }
return i18n.GetMsgWithMap(key, nil) return i18n.GetMsgWithMap(key, nil)
} }
-84
View File
@@ -1,84 +0,0 @@
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)
}
+1 -16
View File
@@ -7,7 +7,6 @@ import (
"encoding/hex" "encoding/hex"
"errors" "errors"
"net" "net"
"net/http"
"strconv" "strconv"
"strings" "strings"
"time" "time"
@@ -43,7 +42,7 @@ func APIAuthMiddleware(loadConfig APIAuthConfigLoader, onSuccess APIAuthSuccessH
ownerProvider = ownerProviders[0] ownerProvider = ownerProviders[0]
} }
return func(c *gin.Context) { return func(c *gin.Context) {
if strings.HasPrefix(c.Request.URL.Path, "/api/v2/core/auth") && !isAPIKeyManagementRequest(c) { if strings.HasPrefix(c.Request.URL.Path, "/api/v2/core/auth") {
c.Next() c.Next()
return return
} }
@@ -98,20 +97,6 @@ func APIAuthMiddleware(loadConfig APIAuthConfigLoader, onSuccess APIAuthSuccessH
} }
} }
func isAPIKeyManagementRequest(c *gin.Context) bool {
if c.Request.Method != http.MethodPost {
return false
}
switch c.Request.URL.Path {
case "/api/v2/core/auth/api/keys/search", "/api/v2/core/auth/api/keys/create",
"/api/v2/core/auth/api/keys/update", "/api/v2/core/auth/api/keys/status",
"/api/v2/core/auth/api/keys/revoke", "/api/v2/core/auth/api/keys/delete":
return true
default:
return false
}
}
func LoadAPIAuthConfig(_ *gin.Context) (APIAuthConfig, error) { func LoadAPIAuthConfig(_ *gin.Context) (APIAuthConfig, error) {
settingRepo := repo.NewISettingRepo() settingRepo := repo.NewISettingRepo()
config := APIAuthConfig{} config := APIAuthConfig{}
+3 -23
View File
@@ -66,26 +66,6 @@ func RequireAPIKeySession(c *gin.Context) (psession.SessionUser, error) {
return u, nil return u, nil
} }
func RequireAPIKeyManagement(c *gin.Context) (psession.SessionUser, error) {
if c == nil || !c.GetBool("API_AUTH") {
return RequireAPIKeySession(c)
}
if c.GetBool("LOCAL_REQUEST") {
return psession.SessionUser{}, buserr.New("ErrNotLogin")
}
kind, keyID := c.GetString("API_AUTH_KEY_KIND"), c.GetString("API_AUTH_KEY_ID")
if (kind != "apiKey" && kind != "legacy") || keyID == "" || (kind == "legacy") != (keyID == "legacy") {
return psession.SessionUser{}, buserr.New("ErrNotLogin")
}
ownerType, ownerID := c.GetString("API_AUTH_OWNER_TYPE"), c.GetString("API_AUTH_OWNER_ID")
value, _ := c.Get(psession.GinContextSessionUserKey)
u, ok := value.(psession.SessionUser)
if (ownerType != APIKeyOwnerPanelAdmin && ownerType != APIKeyOwnerEnterpriseUser) || ownerID == "" || !ok || u.ID != ownerID || u.Name == "" {
return psession.SessionUser{}, buserr.New("ErrNotLogin")
}
return u, nil
}
func HasAPICredentials(c *gin.Context) bool { func HasAPICredentials(c *gin.Context) bool {
return c.GetHeader("1Panel-Token") != "" || c.GetHeader("1Panel-Timestamp") != "" || c.GetHeader("1Panel-Key-ID") != "" || c.GetHeader("1Panel-Signature-Version") != "" return c.GetHeader("1Panel-Token") != "" || c.GetHeader("1Panel-Timestamp") != "" || c.GetHeader("1Panel-Key-ID") != "" || c.GetHeader("1Panel-Signature-Version") != ""
} }
@@ -107,7 +87,7 @@ func LegacyAPIKeyFingerprint(owner APIKeyOwner, config APIAuthConfig, policy mod
} }
func APIKeySecret(key model.APIKey) (string, error) { func APIKeySecret(key model.APIKey) (string, error) {
if key.DeletedAt != nil || key.Status == "Revoked" || key.SecretVersion != 1 || key.SecretCiphertext == "" { if key.SecretVersion != 1 || key.SecretCiphertext == "" {
return "", buserr.New("ErrApiConfigKeyInvalid") return "", buserr.New("ErrApiConfigKeyInvalid")
} }
master, err := encrypt.APIKeyEncryptionKey() master, err := encrypt.APIKeyEncryptionKey()
@@ -118,7 +98,7 @@ func APIKeySecret(key model.APIKey) (string, error) {
} }
func ValidateAPIKeyState(key model.APIKey, now time.Time) error { func ValidateAPIKeyState(key model.APIKey, now time.Time) error {
if key.DeletedAt != nil || key.Status != constant.StatusEnable { if key.Status != constant.StatusEnable {
return buserr.New("ErrApiConfigStatusInvalid") return buserr.New("ErrApiConfigStatusInvalid")
} }
if key.ExpiresAt != nil && !now.Before(*key.ExpiresAt) { if key.ExpiresAt != nil && !now.Before(*key.ExpiresAt) {
@@ -133,7 +113,7 @@ func configForAPIKey(key model.APIKey, secret string, owner APIKeyOwner) APIAuth
func loadMultiAPIKeyConfig(c *gin.Context, legacy APIAuthConfigLoader, provider APIKeyOwnerProvider) (APIAuthConfig, error) { func loadMultiAPIKeyConfig(c *gin.Context, legacy APIAuthConfigLoader, provider APIKeyOwnerProvider) (APIAuthConfig, error) {
keyID := c.GetHeader("1Panel-Key-ID") keyID := c.GetHeader("1Panel-Key-ID")
q := global.DB.Model(&model.APIKey{}).Where("deleted_at IS NULL").Select("id", "secret_ciphertext", "secret_version", "revision") q := global.DB.Model(&model.APIKey{}).Select("id", "secret_ciphertext", "secret_version", "revision")
if keyID != "" { if keyID != "" {
q = q.Where("id = ?", keyID) q = q.Where("id = ?", keyID)
} else { } else {
+27 -35
View File
@@ -6,42 +6,41 @@ import (
) )
type APIKeyItem struct { type APIKeyItem struct {
ID string `json:"id" example:"8a8f0f77-1ec8-4d46-8f5a-0ea8807e2d8a"` ID string `json:"id"`
Kind string `json:"kind" enums:"apiKey,legacy" example:"apiKey"` Kind string `json:"kind"`
Name string `json:"name" example:"Deployment automation"` Name string `json:"name"`
Description string `json:"description" example:"Used by the deployment service"` Description string `json:"description"`
KeyHint string `json:"keyHint" example:"abcd••••1234"` KeyHint string `json:"keyHint"`
Status string `json:"status" enums:"Enable,Disable,Revoked,Expired" example:"Enable"` Status string `json:"status"`
IPWhiteList string `json:"ipWhiteList" example:"192.0.2.10"` IPWhiteList string `json:"ipWhiteList"`
APITrustedProxies string `json:"apiTrustedProxies" example:"192.0.2.1"` APITrustedProxies string `json:"apiTrustedProxies"`
APIKeyValidityTime int `json:"apiKeyValidityTime" example:"120"` APIKeyValidityTime int `json:"apiKeyValidityTime"`
ExpiresAt *time.Time `json:"expiresAt" format:"date-time" extensions:"x-nullable"` ExpiresAt *time.Time `json:"expiresAt"`
AllowAppBinding bool `json:"allowAppBinding" example:"false"` AllowAppBinding bool `json:"allowAppBinding"`
Revision uint64 `json:"revision" minimum:"1" example:"1"` Revision uint64 `json:"revision"`
CreatedAt *time.Time `json:"createdAt" format:"date-time" extensions:"x-nullable"` CreatedAt *time.Time `json:"createdAt"`
} }
type APIKeySearch struct { type APIKeySearch struct {
Page int `json:"page" minimum:"1" example:"1"` Page int `json:"page"`
PageSize int `json:"pageSize" minimum:"1" maximum:"100" example:"20"` PageSize int `json:"pageSize"`
ExcludeRevoked bool `json:"excludeRevoked" default:"false"` ExcludeRevoked bool `json:"excludeRevoked"`
} }
type APIKeyPage struct { type APIKeyPage struct {
Items []APIKeyItem `json:"items"` Items []APIKeyItem `json:"items"`
Total int `json:"total"` Total int `json:"total"`
} }
type APIKeyFields struct { type APIKeyFields struct {
Name string `json:"name" minLength:"1" maxLength:"64" example:"Deployment automation"` Name string `json:"name"`
Description string `json:"description" maxLength:"256" example:"Used by the deployment service"` Description string `json:"description"`
IPWhiteList string `json:"ipWhiteList" maxLength:"4096" example:"192.0.2.10"` IPWhiteList string `json:"ipWhiteList"`
APITrustedProxies string `json:"apiTrustedProxies" maxLength:"4096" example:"192.0.2.1"` APITrustedProxies string `json:"apiTrustedProxies"`
APIKeyValidityTime int `json:"apiKeyValidityTime" minimum:"0" maximum:"1440" example:"120"` APIKeyValidityTime int `json:"apiKeyValidityTime"`
ExpiresAt json.RawMessage `json:"expiresAt" swaggertype:"string" format:"date-time" extensions:"x-nullable" example:"2030-01-01T00:00:00Z"` ExpiresAt json.RawMessage `json:"expiresAt"`
AllowAppBinding bool `json:"allowAppBinding" default:"false"` AllowAppBinding bool `json:"allowAppBinding"`
} }
type APIKeyCreate struct { type APIKeyCreate struct {
// Optional client-generated UUID for idempotent retries. Omitted or empty values are generated by the server. RequestID string `json:"requestID"`
RequestID string `json:"requestID" format:"uuid" example:"df1a75a3-5f56-4fa3-9e7a-a573a853d894"`
APIKeyFields APIKeyFields
} }
type APIKeyCreated struct { type APIKeyCreated struct {
@@ -50,15 +49,8 @@ type APIKeyCreated struct {
AlreadyCreated bool `json:"alreadyCreated,omitempty"` AlreadyCreated bool `json:"alreadyCreated,omitempty"`
} }
type APIKeyMutation struct { type APIKeyMutation struct {
ID string `json:"id" example:"8a8f0f77-1ec8-4d46-8f5a-0ea8807e2d8a"` ID string `json:"id"`
Revision uint64 `json:"revision" minimum:"1" example:"1"` Revision uint64 `json:"revision"`
}
type APIKeyMutationResult struct {
TerminalClosePending bool `json:"terminalClosePending"`
}
type APIKeyRevealed struct {
APIKeyMutation
APIKey string `json:"apiKey"`
} }
type APIKeyUpdate struct { type APIKeyUpdate struct {
APIKeyMutation APIKeyMutation
@@ -66,5 +58,5 @@ type APIKeyUpdate struct {
} }
type APIKeyStatus struct { type APIKeyStatus struct {
APIKeyMutation APIKeyMutation
Status string `json:"status" enums:"Enable,Disable" example:"Disable"` Status string `json:"status"`
} }
+19 -35
View File
@@ -14,44 +14,28 @@ type MCPRuntimeSettings struct {
type MCPRuntimeSettingsUpdate = MCPRuntimeSettings type MCPRuntimeSettingsUpdate = MCPRuntimeSettings
type MCPRuntimeInstall struct { type MCPRuntimeInstall struct {
DockerImage string `json:"dockerImage"` DockerImage string `json:"dockerImage"`
ListenPort int `json:"listenPort"` ListenPort int `json:"listenPort"`
ExternalURL string `json:"externalUrl"` ExternalURL string `json:"externalUrl"`
Revision uint64 `json:"revision"` Revision uint64 `json:"revision"`
AccessPolicy string `json:"accessPolicy"`
AllowedIPs []string `json:"allowedIPs"`
Website *MCPRuntimeInstallWebsite `json:"website,omitempty"`
}
type MCPRuntimeInstallWebsite struct {
Domain string `json:"domain"`
EnableSSL bool `json:"enableSSL"`
SSLID uint `json:"sslID"`
}
type MCPRuntimeInstallTask struct {
TaskID string `json:"taskID"`
} }
type MCPRuntimeStatus struct { type MCPRuntimeStatus struct {
RuntimeType string `json:"runtimeType"` RuntimeType string `json:"runtimeType"`
DockerImage string `json:"dockerImage"` DockerImage string `json:"dockerImage"`
ContainerName string `json:"containerName"` ContainerName string `json:"containerName"`
ImageID string `json:"imageId"` ImageID string `json:"imageId"`
Enabled bool `json:"enabled"` Enabled bool `json:"enabled"`
Installed bool `json:"installed"` Installed bool `json:"installed"`
Status string `json:"status"` Status string `json:"status"`
Ready bool `json:"ready"` Ready bool `json:"ready"`
Reason string `json:"reason"` Reason string `json:"reason"`
AdapterVersion string `json:"adapterVersion"` AdapterVersion string `json:"adapterVersion"`
ContractVersion int `json:"contractVersion"` ContractVersion int `json:"contractVersion"`
Protocol string `json:"protocol"` Protocol string `json:"protocol"`
InternalEndpoint string `json:"internalEndpoint"` InternalEndpoint string `json:"internalEndpoint"`
Endpoint string `json:"endpoint"` Endpoint string `json:"endpoint"`
CanManageService bool `json:"canManageService"` CanManageService bool `json:"canManageService"`
InstallTaskID string `json:"installTaskID,omitempty"`
Installing bool `json:"installing"`
Website *MCPWebsiteStatus `json:"website,omitempty"`
} }
type MCPRuntimeLogs struct { type MCPRuntimeLogs struct {
-43
View File
@@ -1,43 +0,0 @@
package dto
type MCPWebsiteBind struct {
RequestID string `json:"requestId"`
Revision uint64 `json:"revision"`
Domain string `json:"domain"`
EnableSSL bool `json:"enableSSL"`
SSLID uint `json:"sslID"`
}
type MCPWebsiteUpdate struct {
RequestID string `json:"requestId"`
Revision uint64 `json:"revision"`
EnableSSL bool `json:"enableSSL"`
SSLID uint `json:"sslID"`
}
type MCPWebsiteUnbind struct {
RequestID string `json:"requestId"`
Revision uint64 `json:"revision"`
}
type MCPWebsitePendingRequest struct {
Operation string `json:"operation"`
RequestID string `json:"requestId"`
Domain string `json:"domain"`
EnableSSL bool `json:"enableSSL"`
SSLID uint `json:"sslID"`
}
type MCPWebsiteStatus struct {
State string `json:"state"`
WebsiteID uint `json:"websiteId"`
Domain string `json:"domain"`
Endpoint string `json:"endpoint"`
EnableSSL bool `json:"enableSSL"`
SSLID uint `json:"sslID"`
WebsiteStatus string `json:"websiteStatus"`
RequestID string `json:"requestId"`
Revision uint64 `json:"revision"`
Reason string `json:"reason,omitempty"`
PendingRequest *MCPWebsitePendingRequest `json:"pendingRequest,omitempty"`
}
-1
View File
@@ -24,7 +24,6 @@ type APIKey struct {
CreatedAt time.Time `json:"-"` CreatedAt time.Time `json:"-"`
UpdatedAt time.Time `json:"-"` UpdatedAt time.Time `json:"-"`
RevokedAt *time.Time `json:"-"` RevokedAt *time.Time `json:"-"`
DeletedAt *time.Time `json:"-"`
} }
type LegacyAPIKeyPolicy struct { type LegacyAPIKeyPolicy struct {
+9 -12
View File
@@ -52,16 +52,13 @@ type MCPInvocation struct {
} }
type MCPSetting struct { type MCPSetting struct {
RuntimeType string `gorm:"not null;default:docker"` RuntimeType string `gorm:"not null;default:docker"`
DockerImage string `gorm:"not null;default:1panel/mcp-server:v1.0.0"` DockerImage string `gorm:"not null;default:1panel/1panel-mcp-server:v0.0.1"`
ID uint `gorm:"primaryKey"` ID uint `gorm:"primaryKey"`
Enabled bool `gorm:"not null;default:false"` Enabled bool `gorm:"not null;default:false"`
ListenPort int `gorm:"not null;default:18081"` ListenPort int `gorm:"not null;default:18081"`
ExternalURL string ExternalURL string
AccessPolicy string `gorm:"not null;default:inherit"` AccessPolicy string `gorm:"not null;default:inherit"`
AllowedIPs string `gorm:"type:text;not null;default:'[]'"` AllowedIPs string `gorm:"type:text;not null;default:'[]'"`
Revision uint64 `gorm:"not null;default:1"` Revision uint64 `gorm:"not null;default:1"`
WebsiteID uint `gorm:"not null;default:0"`
WebsiteBindingID string `gorm:"not null;default:''"`
WebsiteOperation string `gorm:"type:text;not null;default:''"`
} }
+2 -2
View File
@@ -14,12 +14,12 @@ func APIKeyOwnerQuery(db *gorm.DB, ownerType, ownerID string) *gorm.DB {
} }
func (r *APIKeyRepo) Get(id string) (model.APIKey, error) { func (r *APIKeyRepo) Get(id string) (model.APIKey, error) {
var key model.APIKey var key model.APIKey
err := global.DB.Where("id = ? AND deleted_at IS NULL", id).First(&key).Error err := global.DB.Where("id = ?", id).First(&key).Error
return key, err return key, err
} }
func (r *APIKeyRepo) List(ownerType, ownerID string, excludeRevoked bool) ([]model.APIKey, error) { func (r *APIKeyRepo) List(ownerType, ownerID string, excludeRevoked bool) ([]model.APIKey, error) {
items := make([]model.APIKey, 0) items := make([]model.APIKey, 0)
q := APIKeyOwnerQuery(global.DB, ownerType, ownerID).Where("deleted_at IS NULL") q := APIKeyOwnerQuery(global.DB, ownerType, ownerID)
if excludeRevoked { if excludeRevoked {
q = q.Where("status <> ?", "Revoked") q = q.Where("status <> ?", "Revoked")
} }
+1 -169
View File
@@ -1,89 +1,12 @@
package repo package repo
import ( import (
"time"
"github.com/1Panel-dev/1Panel/core/app/model" "github.com/1Panel-dev/1Panel/core/app/model"
"gorm.io/gorm" "gorm.io/gorm"
"gorm.io/gorm/clause"
) )
const mcpInvocationSecond = "CAST(strftime('%s', substr(started_at, 1, 19) || substr(started_at, -6)) AS INTEGER)"
const mcpInvocationNanosecond = "CASE WHEN substr(started_at, 20, 1) = '.' THEN CAST(substr(substr(started_at, 21, length(started_at) - 26) || '000000000', 1, 9) AS INTEGER) ELSE 0 END"
type MCPRepo struct{ DB *gorm.DB } type MCPRepo struct{ DB *gorm.DB }
func mcpOwnerQuery(db *gorm.DB, ownerType, ownerID string) *gorm.DB {
return db.Where("owner_type = ? AND owner_id = ?", ownerType, ownerID)
}
func (r MCPRepo) Credential(ownerType, ownerID, id string) (model.MCPCredential, error) {
var key model.MCPCredential
err := mcpOwnerQuery(r.DB, ownerType, ownerID).Where("id = ?", id).First(&key).Error
return key, err
}
func (r MCPRepo) CredentialByRequest(ownerType, ownerID, requestID string) (model.MCPCredential, error) {
var key model.MCPCredential
err := mcpOwnerQuery(r.DB, ownerType, ownerID).Where("request_id = ?", requestID).First(&key).Error
return key, err
}
func (r MCPRepo) SearchCredentials(ownerType, ownerID string, page, pageSize int, excludeRevoked bool) ([]model.MCPCredential, int64, error) {
q := mcpOwnerQuery(r.DB.Model(&model.MCPCredential{}), ownerType, ownerID)
if excludeRevoked {
q = q.Where("status <> ?", "Revoked")
}
var total int64
if err := q.Count(&total).Error; err != nil {
return nil, 0, err
}
keys := []model.MCPCredential{}
err := q.Order("created_at DESC, id DESC").Offset((page - 1) * pageSize).Limit(pageSize).Find(&keys).Error
return keys, total, err
}
func (r MCPRepo) CountActiveName(ownerType, ownerID, name, excludeID string) (int64, error) {
q := mcpOwnerQuery(r.DB.Model(&model.MCPCredential{}), ownerType, ownerID)
if excludeID == "" {
q = q.Where("active_name = ?", name)
} else {
q = q.Where("active_name = ? AND id <> ?", name, excludeID)
}
var count int64
err := q.Count(&count).Error
return count, err
}
func (r MCPRepo) CreateCredential(key *model.MCPCredential) error {
return r.DB.Create(key).Error
}
func (r MCPRepo) UpdateCredential(key *model.MCPCredential, revision uint64) (int64, error) {
result := r.DB.Model(&model.MCPCredential{}).Where("id = ? AND revision = ?", key.ID, revision).
Select("name", "active_name", "description", "node_ids", "status", "expires_at", "revision", "revoked_at", "secret_ciphertext", "secret_version", "token_hash", "key_hint").Updates(key)
return result.RowsAffected, result.Error
}
func (r MCPRepo) DeleteCredential(ownerType, ownerID, id string, revision uint64) (int64, error) {
result := mcpOwnerQuery(r.DB, ownerType, ownerID).Where("id = ? AND revision = ?", id, revision).Delete(&model.MCPCredential{})
return result.RowsAffected, result.Error
}
func (r MCPRepo) AdmissionCredential(id string, revision uint64) (model.MCPCredential, error) {
var key model.MCPCredential
err := r.DB.Where("id = ? AND revision = ? AND status = ? AND revoked_at IS NULL", id, revision, "Enable").Take(&key).Error
return key, err
}
func (r MCPRepo) InvocationCredential(id string, revision uint64) (model.MCPCredential, error) {
var key model.MCPCredential
err := r.DB.Select("expires_at").
Where("id = ? AND revision = ? AND status = ? AND revoked_at IS NULL", id, revision, "Enable").
Where("EXISTS (SELECT 1 FROM mcp_settings WHERE id = ? AND enabled = ?)", 1, true).Take(&key).Error
return key, err
}
func (r MCPRepo) Token(hash string) (model.MCPCredential, error) { func (r MCPRepo) Token(hash string) (model.MCPCredential, error) {
var key model.MCPCredential var key model.MCPCredential
err := r.DB.Where("token_hash = ?", hash).First(&key).Error err := r.DB.Where("token_hash = ?", hash).First(&key).Error
@@ -94,13 +17,8 @@ func (r MCPRepo) Grants(id string) ([]model.MCPGrant, error) {
err := r.DB.Where("credential_id = ?", id).Order("tool_id").Find(&grants).Error err := r.DB.Where("credential_id = ?", id).Order("tool_id").Find(&grants).Error
return grants, err return grants, err
} }
func (r MCPRepo) DeleteGrants(id string) error {
return r.DB.Where("credential_id = ?", id).Delete(&model.MCPGrant{}).Error
}
func (r MCPRepo) ReplaceTools(id string, grants []model.MCPGrant) error { func (r MCPRepo) ReplaceTools(id string, grants []model.MCPGrant) error {
if err := r.DeleteGrants(id); err != nil { if err := r.DB.Where("credential_id = ?", id).Delete(&model.MCPGrant{}).Error; err != nil {
return err return err
} }
for _, grant := range grants { for _, grant := range grants {
@@ -111,89 +29,3 @@ func (r MCPRepo) ReplaceTools(id string, grants []model.MCPGrant) error {
} }
return nil return nil
} }
func (r MCPRepo) Setting() (model.MCPSetting, error) {
var setting model.MCPSetting
err := r.DB.First(&setting, 1).Error
return setting, err
}
func (r MCPRepo) Enabled() (bool, error) {
var setting struct{ Enabled bool }
err := r.DB.Table("mcp_settings").Where("id = ?", 1).Take(&setting).Error
return setting.Enabled, err
}
func (r MCPRepo) AdmissionEnabled() (bool, error) {
var setting model.MCPSetting
err := r.DB.Select("enabled").Take(&setting, 1).Error
return setting.Enabled, err
}
func (r MCPRepo) PanelSetting(key string) (model.Setting, error) {
var setting model.Setting
err := r.DB.Where("key = ?", key).First(&setting).Error
return setting, err
}
func (r MCPRepo) CreateSettingIfAbsent(setting *model.MCPSetting) error {
return r.DB.Clauses(clause.OnConflict{DoNothing: true}).Create(setting).Error
}
func (r MCPRepo) UpdateRuntimeSetting(setting model.MCPSetting, revision uint64) (int64, error) {
result := r.DB.Model(&model.MCPSetting{}).Where("id = ? AND revision = ?", 1, revision).Updates(map[string]interface{}{
"runtime_type": setting.RuntimeType, "docker_image": setting.DockerImage,
"enabled": setting.Enabled, "listen_port": setting.ListenPort, "external_url": setting.ExternalURL, "revision": setting.Revision,
"access_policy": setting.AccessPolicy, "allowed_ips": setting.AllowedIPs,
})
return result.RowsAffected, result.Error
}
func (r MCPRepo) UpdateWebsiteSetting(setting model.MCPSetting, revision uint64) (int64, error) {
result := r.DB.Model(&model.MCPSetting{}).Where("id = 1 AND revision = ?", revision).Updates(map[string]any{
"website_id": setting.WebsiteID, "website_binding_id": setting.WebsiteBindingID, "website_operation": setting.WebsiteOperation, "revision": setting.Revision,
})
return result.RowsAffected, result.Error
}
func (r MCPRepo) CreateEnrollmentAudit(record *model.OperationLog) error {
return r.DB.Create(record).Error
}
func (r MCPRepo) CreateInvocation(record *model.MCPInvocation) error {
return r.DB.Create(record).Error
}
func (r MCPRepo) SearchInvocations(ownerType, ownerID string, page, pageSize int, credentialID, toolID, status string) ([]model.MCPInvocation, int64, error) {
q := mcpOwnerQuery(r.DB.Model(&model.MCPInvocation{}), ownerType, ownerID)
if credentialID != "" {
q = q.Where("credential_id = ?", credentialID)
}
if toolID != "" {
q = q.Where("tool_id = ?", toolID)
}
if status != "" {
q = q.Where("status = ?", status)
}
var total int64
if err := q.Count(&total).Error; err != nil {
return nil, 0, err
}
items := []model.MCPInvocation{}
if err := q.Order(mcpInvocationSecond + " DESC, " + mcpInvocationNanosecond + " DESC, id DESC").Offset((page - 1) * pageSize).Limit(pageSize).Find(&items).Error; err != nil {
return nil, 0, err
}
return items, total, nil
}
func (r MCPRepo) UpdateRunningInvocation(record model.MCPInvocation) error {
return r.DB.Model(&model.MCPInvocation{}).Where("id = ? AND status = ?", record.ID, "running").
Updates(map[string]any{"status": record.Status, "code": record.Code, "finished_at": record.FinishedAt, "duration_ms": record.DurationMs}).Error
}
func (r MCPRepo) UpdateInterruptedInvocations(processStartedAt time.Time, status, code string, finishedAt time.Time) error {
return r.DB.Model(&model.MCPInvocation{}).
Where("status = ? AND finished_at IS NULL", "running").
Where("("+mcpInvocationSecond+" < ? OR ("+mcpInvocationSecond+" = ? AND "+mcpInvocationNanosecond+" < ?))", processStartedAt.Unix(), processStartedAt.Unix(), processStartedAt.Nanosecond()).
Updates(map[string]any{"status": status, "code": code, "finished_at": finishedAt}).Error
}
+9 -89
View File
@@ -52,27 +52,6 @@ func (s *APIKeyService) owner(c *gin.Context) (auth.APIKeyOwner, error) {
return s.Provider.CurrentAPIKeyOwner(c) return s.Provider.CurrentAPIKeyOwner(c)
} }
func (s *APIKeyService) managementOwner(c *gin.Context) (auth.APIKeyOwner, error) {
if _, err := auth.RequireAPIKeyManagement(c); err != nil {
return auth.APIKeyOwner{}, err
}
if s.Provider == nil {
return auth.APIKeyOwner{}, buserr.New("ErrApiConfigStatusInvalid")
}
if !c.GetBool("API_AUTH") {
return s.Provider.CurrentAPIKeyOwner(c)
}
ownerType, ownerID := c.GetString("API_AUTH_OWNER_TYPE"), c.GetString("API_AUTH_OWNER_ID")
owner, err := s.Provider.ResolveAPIKeyOwner(c, ownerType, ownerID)
if err != nil {
return auth.APIKeyOwner{}, err
}
if owner.Type != ownerType || owner.ID != ownerID {
return auth.APIKeyOwner{}, buserr.New("ErrNotLogin")
}
return owner, nil
}
func apiKeyItem(key model.APIKey) dto.APIKeyItem { func apiKeyItem(key model.APIKey) dto.APIKeyItem {
status := key.Status status := key.Status
if status != "Revoked" && key.ExpiresAt != nil && !time.Now().Before(*key.ExpiresAt) { if status != "Revoked" && key.ExpiresAt != nil && !time.Now().Before(*key.ExpiresAt) {
@@ -96,7 +75,7 @@ func (s *APIKeyService) legacy(owner auth.APIKeyOwner) (dto.APIKeyItem, auth.API
} }
func (s *APIKeyService) Search(c *gin.Context, req dto.APIKeySearch) (*dto.APIKeyPage, error) { func (s *APIKeyService) Search(c *gin.Context, req dto.APIKeySearch) (*dto.APIKeyPage, error) {
owner, err := s.managementOwner(c) owner, err := s.owner(c)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -167,13 +146,11 @@ func keyHint(secret string) string {
} }
func (s *APIKeyService) Create(c *gin.Context, req dto.APIKeyCreate) (*dto.APIKeyCreated, error) { func (s *APIKeyService) Create(c *gin.Context, req dto.APIKeyCreate) (*dto.APIKeyCreated, error) {
owner, err := s.managementOwner(c) owner, err := s.owner(c)
if err != nil { if err != nil {
return nil, err return nil, err
} }
if req.RequestID == "" { if _, err = uuid.Parse(req.RequestID); err != nil {
req.RequestID = uuid.NewString()
} else if _, err = uuid.Parse(req.RequestID); err != nil {
return nil, buserr.New("ErrInvalidParams") return nil, buserr.New("ErrInvalidParams")
} }
expires, err := validateAPIKeyFields(&req.APIKeyFields, true) expires, err := validateAPIKeyFields(&req.APIKeyFields, true)
@@ -195,9 +172,6 @@ func (s *APIKeyService) Create(c *gin.Context, req dto.APIKeyCreate) (*dto.APIKe
q := repo.APIKeyOwnerQuery(tx, owner.Type, owner.ID) q := repo.APIKeyOwnerQuery(tx, owner.Type, owner.ID)
err := q.Where("request_id = ?", req.RequestID).First(&key).Error err := q.Where("request_id = ?", req.RequestID).First(&key).Error
if err == nil { if err == nil {
if key.DeletedAt != nil {
return buserr.New("ErrAPIKeyNotFound")
}
if key.RequestHash != requestHash { if key.RequestHash != requestHash {
return buserr.New("ErrAPIKeyConflict") return buserr.New("ErrAPIKeyConflict")
} }
@@ -245,7 +219,7 @@ func ensureAPIKeyName(tx *gorm.DB, owner auth.APIKeyOwner, name, id string) erro
} }
func (s *APIKeyService) Update(c *gin.Context, req dto.APIKeyUpdate) error { func (s *APIKeyService) Update(c *gin.Context, req dto.APIKeyUpdate) error {
owner, err := s.managementOwner(c) owner, err := s.owner(c)
if err != nil { if err != nil {
return err return err
} }
@@ -273,7 +247,7 @@ func (s *APIKeyService) Update(c *gin.Context, req dto.APIKeyUpdate) error {
} }
func (s *APIKeyService) Status(c *gin.Context, req dto.APIKeyStatus) error { func (s *APIKeyService) Status(c *gin.Context, req dto.APIKeyStatus) error {
owner, err := s.managementOwner(c) owner, err := s.owner(c)
if err != nil { if err != nil {
return err return err
} }
@@ -297,7 +271,7 @@ func (s *APIKeyService) Status(c *gin.Context, req dto.APIKeyStatus) error {
} }
func (s *APIKeyService) Revoke(c *gin.Context, req dto.APIKeyMutation) error { func (s *APIKeyService) Revoke(c *gin.Context, req dto.APIKeyMutation) error {
owner, err := s.managementOwner(c) owner, err := s.owner(c)
if err != nil { if err != nil {
return err return err
} }
@@ -315,75 +289,21 @@ func (s *APIKeyService) Revoke(c *gin.Context, req dto.APIKeyMutation) error {
}) })
} }
func (s *APIKeyService) Delete(c *gin.Context, req dto.APIKeyMutation) error {
owner, err := s.managementOwner(c)
if err != nil {
return err
}
if req.ID == "legacy" {
return buserr.New("ErrInvalidParams")
}
return s.mutate(c, owner, req, "delete", func(_ *gorm.DB, key *model.APIKey) error {
now := time.Now()
key.Status = "Revoked"
if key.RevokedAt == nil {
key.RevokedAt = &now
}
key.DeletedAt = &now
key.ActiveName = nil
key.SecretCiphertext = ""
key.KeyHint = ""
key.AllowAppBinding = false
return nil
})
}
func (s *APIKeyService) Reveal(c *gin.Context, req dto.APIKeyMutation) (*dto.APIKeyRevealed, error) {
owner, err := s.owner(c)
if err != nil {
return nil, err
}
if req.ID == "" || req.ID == "legacy" || req.Revision == 0 {
return nil, buserr.New("ErrInvalidParams")
}
// Serialize the snapshot and disclosure audit with revocation/deletion.
apiKeyMutationMu.Lock()
defer apiKeyMutationMu.Unlock()
var key model.APIKey
if err = repo.APIKeyOwnerQuery(global.DB, owner.Type, owner.ID).Where("id = ? AND deleted_at IS NULL", req.ID).First(&key).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, buserr.New("ErrAPIKeyNotFound")
}
return nil, err
}
if key.Revision != req.Revision || key.Status == "Revoked" {
return nil, buserr.New("ErrAPIKeyConflict")
}
secret, err := auth.APIKeySecret(key)
if err != nil {
return nil, buserr.New("ErrApiConfigKeyInvalid")
}
if err = apikey_audit.Record(c, apikey_audit.Event{OwnerType: owner.Type, OwnerID: owner.ID, KeyID: key.ID, KeyName: key.Name, Action: "reveal", Status: constant.StatusSuccess}); err != nil {
return nil, err
}
return &dto.APIKeyRevealed{APIKeyMutation: req, APIKey: secret}, nil
}
func (s *APIKeyService) mutate(c *gin.Context, owner auth.APIKeyOwner, req dto.APIKeyMutation, action string, change func(*gorm.DB, *model.APIKey) error) error { func (s *APIKeyService) mutate(c *gin.Context, owner auth.APIKeyOwner, req dto.APIKeyMutation, action string, change func(*gorm.DB, *model.APIKey) error) error {
if req.ID == "" || req.Revision == 0 { if req.ID == "" || req.Revision == 0 {
return buserr.New("ErrInvalidParams") return buserr.New("ErrInvalidParams")
} }
apiKeyMutationMu.Lock() apiKeyMutationMu.Lock()
var key model.APIKey var key model.APIKey
closeTerminals := action == "disable" || action == "revoke" || action == "delete" closeTerminals := action == "disable" || action == "revoke"
err := global.DB.Transaction(func(tx *gorm.DB) error { err := global.DB.Transaction(func(tx *gorm.DB) error {
if err := repo.APIKeyOwnerQuery(tx, owner.Type, owner.ID).Where("id = ? AND deleted_at IS NULL", req.ID).First(&key).Error; err != nil { if err := repo.APIKeyOwnerQuery(tx, owner.Type, owner.ID).Where("id = ?", req.ID).First(&key).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
return buserr.New("ErrAPIKeyNotFound") return buserr.New("ErrAPIKeyNotFound")
} }
return err return err
} }
if key.Revision != req.Revision || (key.Status == "Revoked" && action != "delete") { if key.Revision != req.Revision || key.Status == "Revoked" {
return buserr.New("ErrAPIKeyConflict") return buserr.New("ErrAPIKeyConflict")
} }
before := key before := key
+48 -24
View File
@@ -96,11 +96,12 @@ var NewMCPService = func() *MCPService {
defer cancel() defer cancel()
return NewMCPRuntimeService().Catalog(ctx) return NewMCPRuntimeService().Catalog(ctx)
}, Enabled: func() (bool, error) { }, Enabled: func() (bool, error) {
enabled, err := (repo.MCPRepo{DB: global.DB}).Enabled() var setting struct{ Enabled bool }
err := global.DB.Table("mcp_settings").Where("id = ?", 1).Take(&setting).Error
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
return false, nil return false, nil
} }
return enabled, err return setting.Enabled, err
}} }}
} }
@@ -148,6 +149,9 @@ func (s *MCPService) ManagementOwner(c *gin.Context, write bool) (auth.APIKeyOwn
} }
return owner, nil return owner, nil
} }
func mcpOwnerQuery(db *gorm.DB, owner auth.APIKeyOwner) *gorm.DB {
return db.Where("owner_type = ? AND owner_id = ?", owner.Type, owner.ID)
}
func validMCPPage(page, size int) bool { return page > 0 && page <= 1000000 && size > 0 && size <= 100 } func validMCPPage(page, size int) bool { return page > 0 && page <= 1000000 && size > 0 && size <= 100 }
func (s *MCPService) catalog() ([]contract.Tool, error) { func (s *MCPService) catalog() ([]contract.Tool, error) {
unavailable := func() error { unavailable := func() error {
@@ -290,8 +294,16 @@ func (s *MCPService) Search(owner auth.APIKeyOwner, req dto.MCPCredentialSearch)
if !validMCPPage(req.Page, req.PageSize) { if !validMCPPage(req.Page, req.PageSize) {
return nil, contract.Invalid() return nil, contract.Invalid()
} }
keys, total, err := (repo.MCPRepo{DB: s.DB}).SearchCredentials(owner.Type, owner.ID, req.Page, req.PageSize, req.ExcludeRevoked) q := mcpOwnerQuery(s.DB.Model(&model.MCPCredential{}), owner)
if err != nil { 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 {
return nil, err return nil, err
} }
items := []dto.MCPCredentialItem{} items := []dto.MCPCredentialItem{}
@@ -332,9 +344,7 @@ func (s *MCPService) Create(owner auth.APIKeyOwner, req dto.MCPCredentialCreate)
repeated := false repeated := false
mcpMutationMu.Lock() mcpMutationMu.Lock()
err = s.DB.Transaction(func(tx *gorm.DB) error { err = s.DB.Transaction(func(tx *gorm.DB) error {
store := repo.MCPRepo{DB: tx} err := mcpOwnerQuery(tx, owner).Where("request_id = ?", req.RequestID).First(&key).Error
var err error
key, err = store.CredentialByRequest(owner.Type, owner.ID, req.RequestID)
if err == nil { if err == nil {
if key.RequestHash != requestHash { if key.RequestHash != requestHash {
return contract.Fail("MCP_CONFLICT", 409, "Creation request already used") return contract.Fail("MCP_CONFLICT", 409, "Creation request already used")
@@ -345,8 +355,8 @@ func (s *MCPService) Create(owner auth.APIKeyOwner, req dto.MCPCredentialCreate)
if !errors.Is(err, gorm.ErrRecordNotFound) { if !errors.Is(err, gorm.ErrRecordNotFound) {
return err return err
} }
count, err := store.CountActiveName(owner.Type, owner.ID, req.Name, "") var count int64
if err != nil { if err = mcpOwnerQuery(tx.Model(&model.MCPCredential{}), owner).Where("active_name = ?", req.Name).Count(&count).Error; err != nil {
return err return err
} }
if count > 0 { if count > 0 {
@@ -361,10 +371,10 @@ func (s *MCPService) Create(owner auth.APIKeyOwner, req dto.MCPCredentialCreate)
if err = s.sealCredentialToken(&key, token); err != nil { if err = s.sealCredentialToken(&key, token); err != nil {
return err return err
} }
if err = store.CreateCredential(&key); err != nil { if err = tx.Create(&key).Error; err != nil {
return err return err
} }
if err = store.ReplaceTools(key.ID, grants); err != nil { if err = (repo.MCPRepo{DB: tx}).ReplaceTools(key.ID, grants); err != nil {
return err return err
} }
return s.auditEnrollment(tx, owner, "create", dto.MCPCredentialMutation{ID: key.ID, Revision: key.Revision}, nil) return s.auditEnrollment(tx, owner, "create", dto.MCPCredentialMutation{ID: key.ID, Revision: key.Revision}, nil)
@@ -387,9 +397,8 @@ func (s *MCPService) mutate(owner auth.APIKeyOwner, req dto.MCPCredentialMutatio
mcpMutationMu.Lock() mcpMutationMu.Lock()
defer mcpMutationMu.Unlock() defer mcpMutationMu.Unlock()
return s.DB.Transaction(func(tx *gorm.DB) error { return s.DB.Transaction(func(tx *gorm.DB) error {
store := repo.MCPRepo{DB: tx} var key model.MCPCredential
key, err := store.Credential(owner.Type, owner.ID, req.ID) if err := mcpOwnerQuery(tx, owner).Where("id = ?", req.ID).First(&key).Error; err != nil {
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
return contract.Forbidden() return contract.Forbidden()
} }
@@ -406,11 +415,11 @@ func (s *MCPService) mutate(owner auth.APIKeyOwner, req dto.MCPCredentialMutatio
key.SecretVersion = 0 key.SecretVersion = 0
} }
key.Revision++ key.Revision++
affected, err := store.UpdateCredential(&key, req.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 err != nil { if result.Error != nil {
return err return result.Error
} }
if affected != 1 { if result.RowsAffected != 1 {
return contract.Fail("MCP_CONFLICT", 409, "Credential changed; refresh and retry") return contract.Fail("MCP_CONFLICT", 409, "Credential changed; refresh and retry")
} }
return nil return nil
@@ -425,8 +434,8 @@ func (s *MCPService) Update(owner auth.APIKeyOwner, req dto.MCPCredentialUpdate)
return err return err
} }
return s.mutate(owner, req.MCPCredentialMutation, func(tx *gorm.DB, key *model.MCPCredential) error { return s.mutate(owner, req.MCPCredentialMutation, func(tx *gorm.DB, key *model.MCPCredential) error {
count, err := (repo.MCPRepo{DB: tx}).CountActiveName(owner.Type, owner.ID, req.Name, key.ID) var count int64
if err != nil { if err := mcpOwnerQuery(tx.Model(&model.MCPCredential{}), owner).Where("active_name = ? AND id <> ?", req.Name, key.ID).Count(&count).Error; err != nil {
return err return err
} }
if count > 0 { if count > 0 {
@@ -619,7 +628,7 @@ func (s *MCPService) Invoke(c *gin.Context, token string, req contract.Invoke) (
record.Revision = key.Revision record.Revision = key.Revision
} }
record.Status = "running" record.Status = "running"
if err := (repo.MCPRepo{DB: s.DB}).CreateInvocation(&record); err != nil { if err := s.DB.Create(&record).Error; err != nil {
return nil, contract.Fail("MCP_EXECUTION_FAILED", 503, "Invocation audit is unavailable") return nil, contract.Fail("MCP_EXECUTION_FAILED", 503, "Invocation audit is unavailable")
} }
record.Status = "denied" record.Status = "denied"
@@ -698,7 +707,10 @@ func (s *MCPService) Invoke(c *gin.Context, token string, req contract.Invoke) (
if enabledErr != nil || !enabled { if enabledErr != nil || !enabled {
return finish(contract.Fail("MCP_DISABLED", 503, "MCP access is disabled"), nil) return finish(contract.Fail("MCP_DISABLED", 503, "MCP access is disabled"), nil)
} }
admitted, admissionErr := (repo.MCPRepo{DB: s.DB}).InvocationCredential(key.ID, key.Revision) 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
if errors.Is(admissionErr, gorm.ErrRecordNotFound) { if errors.Is(admissionErr, gorm.ErrRecordNotFound) {
return finish(contract.Forbidden(), nil) return finish(contract.Forbidden(), nil)
} }
@@ -741,13 +753,25 @@ func (s *MCPService) Invocations(owner auth.APIKeyOwner, req dto.MCPInvocationSe
if !validMCPPage(req.Page, req.PageSize) { if !validMCPPage(req.Page, req.PageSize) {
return nil, contract.Invalid() return nil, contract.Invalid()
} }
q := mcpOwnerQuery(s.DB.Model(&model.MCPInvocation{}), owner)
if req.CredentialID != "" {
q = q.Where("credential_id = ?", req.CredentialID)
}
if req.ToolID != "" {
q = q.Where("tool_id = ?", req.ToolID)
}
if req.Status != "" { if req.Status != "" {
if req.Status != "success" && req.Status != "denied" && req.Status != "failed" && req.Status != "running" { if req.Status != "success" && req.Status != "denied" && req.Status != "failed" && req.Status != "running" {
return nil, contract.Invalid() return nil, contract.Invalid()
} }
q = q.Where("status = ?", req.Status)
} }
items, total, err := (repo.MCPRepo{DB: s.DB}).SearchInvocations(owner.Type, owner.ID, req.Page, req.PageSize, req.CredentialID, req.ToolID, req.Status) var total int64
if err != nil { 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 {
return nil, err return nil, err
} }
return map[string]any{"items": items, "total": total}, nil return map[string]any{"items": items, "total": total}, nil
+6 -8
View File
@@ -8,7 +8,6 @@ import (
"time" "time"
"github.com/1Panel-dev/1Panel/core/app/model" "github.com/1Panel-dev/1Panel/core/app/model"
"github.com/1Panel-dev/1Panel/core/app/repo"
contract "github.com/1Panel-dev/1Panel/core/utils/mcp" contract "github.com/1Panel-dev/1Panel/core/utils/mcp"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/google/uuid" "github.com/google/uuid"
@@ -98,7 +97,7 @@ func (s *MCPService) Admit(c *gin.Context, token string, req contract.Admission)
if known, ok := err.(*contract.Error); ok { if known, ok := err.(*contract.Error); ok {
record.Code = known.Code record.Code = known.Code
} }
if auditErr := (repo.MCPRepo{DB: s.DB}).CreateInvocation(&record); auditErr != nil { if auditErr := s.DB.Create(&record).Error; auditErr != nil {
return nil, contract.Fail("MCP_EXECUTION_FAILED", 503, "Admission audit is unavailable") return nil, contract.Fail("MCP_EXECUTION_FAILED", 503, "Admission audit is unavailable")
} }
if known, ok := err.(*contract.Error); ok { if known, ok := err.(*contract.Error); ok {
@@ -165,16 +164,15 @@ func newMCPAdmissionID() string { return uuid.NewString() }
func (s *MCPService) validateMCPAdmission(key model.MCPCredential, now time.Time) error { func (s *MCPService) validateMCPAdmission(key model.MCPCredential, now time.Time) error {
return s.DB.Transaction(func(tx *gorm.DB) error { return s.DB.Transaction(func(tx *gorm.DB) error {
data := repo.MCPRepo{DB: tx} var latest model.MCPCredential
latest, err := data.AdmissionCredential(key.ID, key.Revision) if err := tx.Where("id = ? AND revision = ? AND status = ? AND revoked_at IS NULL", key.ID, key.Revision, "Enable").Take(&latest).Error; err != nil {
if err != nil {
return contract.Forbidden() return contract.Forbidden()
} }
if latest.ExpiresAt != nil && !now.Before(*latest.ExpiresAt) { if latest.ExpiresAt != nil && !now.Before(*latest.ExpiresAt) {
return contract.Forbidden() return contract.Forbidden()
} }
enabled, err := data.AdmissionEnabled() var setting model.MCPSetting
if err != nil || !enabled { if err := tx.Select("enabled").Take(&setting, 1).Error; err != nil || !setting.Enabled {
return contract.Fail("MCP_DISABLED", 503, "MCP access is disabled") return contract.Fail("MCP_DISABLED", 503, "MCP access is disabled")
} }
@@ -238,7 +236,7 @@ func (s *MCPService) CompleteWithOutcome(token string, req contract.Completion)
now := s.Now() now := s.Now()
outcome.Status, outcome.Code = "failed", mcpCompletionErrorCode(req.ErrorCode) outcome.Status, outcome.Code = "failed", mcpCompletionErrorCode(req.ErrorCode)
outcome.FinishedAt, outcome.DurationMs = &now, now.Sub(outcome.StartedAt).Milliseconds() outcome.FinishedAt, outcome.DurationMs = &now, now.Sub(outcome.StartedAt).Milliseconds()
if err := (repo.MCPRepo{DB: s.DB}).CreateInvocation(outcome); err != nil { if err := s.DB.Create(outcome).Error; err != nil {
return nil, contract.Fail("MCP_AUDIT_UNAVAILABLE", 503, "Invocation outcome could not be recorded; do not retry a write automatically") return nil, contract.Fail("MCP_AUDIT_UNAVAILABLE", 503, "Invocation outcome could not be recorded; do not retry a write automatically")
} }
result.InvocationID = outcome.ID result.InvocationID = outcome.ID
-57
View File
@@ -1,57 +0,0 @@
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)
})
}
+1 -4
View File
@@ -17,7 +17,6 @@ import (
"github.com/1Panel-dev/1Panel/core/app/auth" "github.com/1Panel-dev/1Panel/core/app/auth"
"github.com/1Panel-dev/1Panel/core/app/dto" "github.com/1Panel-dev/1Panel/core/app/dto"
"github.com/1Panel-dev/1Panel/core/app/model" "github.com/1Panel-dev/1Panel/core/app/model"
"github.com/1Panel-dev/1Panel/core/app/repo"
paneli18n "github.com/1Panel-dev/1Panel/core/i18n" paneli18n "github.com/1Panel-dev/1Panel/core/i18n"
contract "github.com/1Panel-dev/1Panel/core/utils/mcp" contract "github.com/1Panel-dev/1Panel/core/utils/mcp"
"github.com/google/uuid" "github.com/google/uuid"
@@ -93,9 +92,7 @@ func (s *MCPService) enrollmentKey(owner auth.APIKeyOwner, req dto.MCPCredential
if err := s.authorize(owner, "mcp_access_manage"); err != nil { if err := s.authorize(owner, "mcp_access_manage"); err != nil {
return key, err return key, err
} }
var err error if err := mcpOwnerQuery(s.DB, owner).Where("id = ?", req.ID).First(&key).Error; err != nil {
key, err = (repo.MCPRepo{DB: s.DB}).Credential(owner.Type, owner.ID, req.ID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
return key, contract.Forbidden() return key, contract.Forbidden()
} }
+2 -3
View File
@@ -7,7 +7,6 @@ import (
"github.com/1Panel-dev/1Panel/core/app/auth" "github.com/1Panel-dev/1Panel/core/app/auth"
"github.com/1Panel-dev/1Panel/core/app/dto" "github.com/1Panel-dev/1Panel/core/app/dto"
"github.com/1Panel-dev/1Panel/core/app/model" "github.com/1Panel-dev/1Panel/core/app/model"
"github.com/1Panel-dev/1Panel/core/app/repo"
"github.com/1Panel-dev/1Panel/core/constant" "github.com/1Panel-dev/1Panel/core/constant"
contract "github.com/1Panel-dev/1Panel/core/utils/mcp" contract "github.com/1Panel-dev/1Panel/core/utils/mcp"
"github.com/google/uuid" "github.com/google/uuid"
@@ -30,7 +29,7 @@ func mcpEnrollmentAuditCode(err error) string {
func (s *MCPService) auditEnrollment(db *gorm.DB, owner auth.APIKeyOwner, action string, req dto.MCPCredentialMutation, outcome error) error { func (s *MCPService) auditEnrollment(db *gorm.DB, owner auth.APIKeyOwner, action string, req dto.MCPCredentialMutation, outcome error) error {
failed := contract.Fail("MCP_AUDIT_UNAVAILABLE", 503, "Credential access could not be audited; no secret was returned") failed := contract.Fail("MCP_AUDIT_UNAVAILABLE", 503, "Credential access could not be audited; no secret was returned")
if action != "create" && action != "reveal" && action != "rotate" && action != "export" && action != "delete" { if action != "create" && action != "reveal" && action != "rotate" && action != "export" {
return failed return failed
} }
id := "invalid" id := "invalid"
@@ -59,7 +58,7 @@ func (s *MCPService) auditEnrollment(db *gorm.DB, owner auth.APIKeyOwner, action
if s.EnrollmentAudit != nil { if s.EnrollmentAudit != nil {
err = s.EnrollmentAudit(db, record) err = s.EnrollmentAudit(db, record)
} else { } else {
err = (repo.MCPRepo{DB: db}).CreateEnrollmentAudit(record) err = db.Create(record).Error
} }
if err != nil { if err != nil {
return failed return failed
-206
View File
@@ -1,206 +0,0 @@
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
}
+15 -3
View File
@@ -4,16 +4,23 @@ import (
"time" "time"
"github.com/1Panel-dev/1Panel/core/app/model" "github.com/1Panel-dev/1Panel/core/app/model"
"github.com/1Panel-dev/1Panel/core/app/repo"
) )
// SQLite stores time.Time with the server's offset. Compare absolute seconds
// and integer nanoseconds so offset changes and repeated local hours remain
// chronological without changing the stored timestamps or losing precision.
const mcpInvocationSecond = "CAST(strftime('%s', substr(started_at, 1, 19) || substr(started_at, -6)) AS INTEGER)"
const mcpInvocationNanosecond = "CASE WHEN substr(started_at, 20, 1) = '.' THEN CAST(substr(substr(started_at, 21, length(started_at) - 26) || '000000000', 1, 9) AS INTEGER) ELSE 0 END"
func (s *MCPService) finishInvocationAudit(record model.MCPInvocation) error { func (s *MCPService) finishInvocationAudit(record model.MCPInvocation) error {
var err error var err error
for attempt := 0; attempt < 3; attempt++ { for attempt := 0; attempt < 3; attempt++ {
if attempt > 0 { if attempt > 0 {
time.Sleep(time.Duration(attempt) * 50 * time.Millisecond) time.Sleep(time.Duration(attempt) * 50 * time.Millisecond)
} }
err = (repo.MCPRepo{DB: s.DB}).UpdateRunningInvocation(record) // 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
if err == nil { if err == nil {
return nil return nil
} }
@@ -21,7 +28,12 @@ func (s *MCPService) finishInvocationAudit(record model.MCPInvocation) error {
return err return err
} }
// RecoverInterruptedInvocations runs before any MCP ingress starts. The fixed
// process-start boundary prevents a later invocation from being marked interrupted.
func (s *MCPService) RecoverInterruptedInvocations(processStartedAt time.Time) error { func (s *MCPService) RecoverInterruptedInvocations(processStartedAt time.Time) error {
now := s.Now() now := s.Now()
return (repo.MCPRepo{DB: s.DB}).UpdateInterruptedInvocations(processStartedAt, "failed", "MCP_OUTCOME_UNKNOWN", 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
} }
+27 -134
View File
@@ -8,35 +8,20 @@ import (
"reflect" "reflect"
"strings" "strings"
"sync" "sync"
"sync/atomic"
"time" "time"
"github.com/1Panel-dev/1Panel/core/app/dto" "github.com/1Panel-dev/1Panel/core/app/dto"
"github.com/1Panel-dev/1Panel/core/app/model" "github.com/1Panel-dev/1Panel/core/app/model"
"github.com/1Panel-dev/1Panel/core/app/repo"
"github.com/1Panel-dev/1Panel/core/global" "github.com/1Panel-dev/1Panel/core/global"
contract "github.com/1Panel-dev/1Panel/core/utils/mcp" contract "github.com/1Panel-dev/1Panel/core/utils/mcp"
"github.com/1Panel-dev/1Panel/core/utils/mcpruntime" "github.com/1Panel-dev/1Panel/core/utils/mcpruntime"
"gorm.io/gorm" "gorm.io/gorm"
"gorm.io/gorm/clause"
) )
var mcpRuntimeMutation sync.Mutex var mcpRuntimeMutation sync.Mutex
var mcpInstalling atomic.Bool
var ErrMCPSettingsRevision = errors.New("MCP settings changed; reload the page before saving") var ErrMCPSettingsRevision = errors.New("MCP settings changed; reload the page before saving")
func lockMCPRuntimeMutation() error {
conflict := func() error { return contract.Fail("MCP_CONFLICT", 409, "An MCP service operation is already running") }
if mcpInstalling.Load() {
return conflict()
}
mcpRuntimeMutation.Lock()
if mcpInstalling.Load() {
mcpRuntimeMutation.Unlock()
return conflict()
}
return nil
}
type mcpRuntimeManager interface { type mcpRuntimeManager interface {
Prepare(context.Context) error Prepare(context.Context) error
Catalog(context.Context) ([]contract.Tool, error) Catalog(context.Context) ([]contract.Tool, error)
@@ -55,14 +40,6 @@ type mcpRuntimeSnapshot struct {
unavailable bool unavailable bool
} }
type mcpRuntimeRecoveryError struct{ cause error }
func (e *mcpRuntimeRecoveryError) Error() string {
return "MCP runtime recovery is incomplete; check the service state before retrying"
}
func (e *mcpRuntimeRecoveryError) Unwrap() error { return e.cause }
func (s *MCPRuntimeService) snapshot(ctx context.Context, settings dto.MCPRuntimeSettings) (mcpRuntimeSnapshot, error) { func (s *MCPRuntimeService) snapshot(ctx context.Context, settings dto.MCPRuntimeSettings) (mcpRuntimeSnapshot, error) {
snapshot := mcpRuntimeSnapshot{settings: settings} snapshot := mcpRuntimeSnapshot{settings: settings}
state := s.manager(settings).Status(ctx, settings.Enabled) state := s.manager(settings).Status(ctx, settings.Enabled)
@@ -74,7 +51,7 @@ func (s *MCPRuntimeService) snapshot(ctx context.Context, settings dto.MCPRuntim
} }
snapshot.installed = state.Installed snapshot.installed = state.Installed
if state.Installed { if state.Installed {
if state.AdapterVersion == "" || !mcpruntime.ValidDockerImageID(state.ImageID) { if state.AdapterVersion == "" || mcpruntime.ValidateDockerImage(mcpruntime.DockerImageRepository+"@"+state.ImageID) != nil {
snapshot.unavailable = true snapshot.unavailable = true
return snapshot, errors.New("the existing MCP Docker instance has invalid metadata or image identity") return snapshot, errors.New("the existing MCP Docker instance has invalid metadata or image identity")
} }
@@ -88,7 +65,6 @@ func (s *MCPRuntimeService) snapshot(ctx context.Context, settings dto.MCPRuntim
type MCPRuntimeService struct { type MCPRuntimeService struct {
newRuntimeManager func(dto.MCPRuntimeSettings) mcpRuntimeManager newRuntimeManager func(dto.MCPRuntimeSettings) mcpRuntimeManager
websiteTransport mcpWebsiteTransport
} }
func (s *MCPRuntimeService) manager(settings dto.MCPRuntimeSettings) mcpRuntimeManager { func (s *MCPRuntimeService) manager(settings dto.MCPRuntimeSettings) mcpRuntimeManager {
@@ -120,7 +96,8 @@ func (s *MCPRuntimeService) settings(ctx context.Context) (dto.MCPRuntimeSetting
ListenPort: mcpruntime.DefaultPort, Revision: 1, ListenPort: mcpruntime.DefaultPort, Revision: 1,
AccessPolicy: "inherit", AllowedIPs: []string{}, AccessPolicy: "inherit", AllowedIPs: []string{},
} }
saved, err := (repo.MCPRepo{DB: global.DB.WithContext(ctx)}).Setting() var saved model.MCPSetting
err := global.DB.WithContext(ctx).First(&saved, 1).Error
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
return settings, nil return settings, nil
} }
@@ -144,9 +121,7 @@ func (s *MCPRuntimeService) Catalog(ctx context.Context) ([]contract.Tool, error
} }
func (s *MCPRuntimeService) Update(req dto.MCPRuntimeSettingsUpdate) (dto.MCPRuntimeSettings, error) { func (s *MCPRuntimeService) Update(req dto.MCPRuntimeSettingsUpdate) (dto.MCPRuntimeSettings, error) {
if err := lockMCPRuntimeMutation(); err != nil { mcpRuntimeMutation.Lock()
return dto.MCPRuntimeSettings{}, err
}
defer mcpRuntimeMutation.Unlock() defer mcpRuntimeMutation.Unlock()
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 50*time.Second)
defer cancel() defer cancel()
@@ -161,13 +136,6 @@ func (s *MCPRuntimeService) update(ctx context.Context, req dto.MCPRuntimeSettin
if req.Revision != old.Revision { if req.Revision != old.Revision {
return old, ErrMCPSettingsRevision return old, ErrMCPSettingsRevision
} }
if req.ExternalURL != old.ExternalURL {
if row, loadErr := s.websiteSetting(ctx); loadErr != nil {
return old, loadErr
} else if row.WebsiteID != 0 || row.WebsiteBindingID != "" {
return old, contract.Fail("MCP_CONFLICT", 409, "The website controls the MCP endpoint")
}
}
if err := validateMCPRuntimeSettings(req); err != nil { if err := validateMCPRuntimeSettings(req); err != nil {
return old, contract.Fail("MCP_INVALID_ARGUMENTS", 400, err.Error()) return old, contract.Fail("MCP_INVALID_ARGUMENTS", 400, err.Error())
} }
@@ -198,20 +166,19 @@ func (s *MCPRuntimeService) update(ctx context.Context, req dto.MCPRuntimeSettin
func (s *MCPRuntimeService) persist(req dto.MCPRuntimeSettings, revision uint64) error { func (s *MCPRuntimeService) persist(req dto.MCPRuntimeSettings, revision uint64) error {
return global.DB.Transaction(func(tx *gorm.DB) error { return global.DB.Transaction(func(tx *gorm.DB) error {
store := repo.MCPRepo{DB: tx}
seed := model.MCPSetting{ID: 1, RuntimeType: "docker", DockerImage: mcpruntime.DefaultDockerImage, ListenPort: mcpruntime.DefaultPort, Revision: 1} seed := model.MCPSetting{ID: 1, RuntimeType: "docker", DockerImage: mcpruntime.DefaultDockerImage, ListenPort: mcpruntime.DefaultPort, Revision: 1}
if err := store.CreateSettingIfAbsent(&seed); err != nil { if err := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&seed).Error; err != nil {
return err return err
} }
rowsAffected, err := store.UpdateRuntimeSetting(model.MCPSetting{ result := tx.Model(&model.MCPSetting{}).Where("id = ? AND revision = ?", 1, revision).Updates(map[string]interface{}{
RuntimeType: "docker", DockerImage: req.DockerImage, "runtime_type": "docker", "docker_image": req.DockerImage,
Enabled: req.Enabled, ListenPort: req.ListenPort, ExternalURL: req.ExternalURL, Revision: revision + 1, "enabled": req.Enabled, "listen_port": req.ListenPort, "external_url": req.ExternalURL, "revision": revision + 1,
AccessPolicy: req.AccessPolicy, AllowedIPs: encodeMCPIPs(req.AllowedIPs), "access_policy": req.AccessPolicy, "allowed_ips": encodeMCPIPs(req.AllowedIPs),
}, revision) })
if err != nil { if result.Error != nil {
return err return result.Error
} }
if rowsAffected != 1 { if result.RowsAffected != 1 {
return ErrMCPSettingsRevision return ErrMCPSettingsRevision
} }
return nil return nil
@@ -245,27 +212,16 @@ func (s *MCPRuntimeService) Status(ctx context.Context) (dto.MCPRuntimeStatus, e
} }
state := s.manager(settings).Status(ctx, settings.Enabled) state := s.manager(settings).Status(ctx, settings.Enabled)
result := dto.MCPRuntimeStatus{ return dto.MCPRuntimeStatus{
RuntimeType: "docker", DockerImage: settings.DockerImage, ContainerName: mcpruntime.DockerContainerName, ImageID: state.ImageID, RuntimeType: "docker", DockerImage: settings.DockerImage, ContainerName: mcpruntime.DockerContainerName, ImageID: state.ImageID,
Enabled: settings.Enabled, Installed: state.Installed, Status: state.Status, Ready: state.Ready, Reason: state.Reason, Enabled: settings.Enabled, Installed: state.Installed, Status: state.Status, Ready: state.Ready, Reason: state.Reason,
AdapterVersion: state.AdapterVersion, ContractVersion: mcpruntime.ContractVersion, Protocol: mcpruntime.Protocol, Endpoint: settings.ExternalURL, AdapterVersion: state.AdapterVersion, ContractVersion: mcpruntime.ContractVersion, Protocol: mcpruntime.Protocol, Endpoint: settings.ExternalURL,
InternalEndpoint: fmt.Sprintf("http://127.0.0.1:%d/mcp", settings.ListenPort), InternalEndpoint: fmt.Sprintf("http://127.0.0.1:%d/mcp", settings.ListenPort),
} }, nil
website, websiteErr := s.WebsiteStatus(ctx)
if websiteErr != nil {
return result, websiteErr
}
if website.State != "unbound" {
result.Website = &website
result.Endpoint = website.Endpoint
}
return result, nil
} }
func (s *MCPRuntimeService) Operate(ctx context.Context, operation string) (dto.MCPRuntimeStatus, error) { func (s *MCPRuntimeService) Operate(ctx context.Context, operation string) (dto.MCPRuntimeStatus, error) {
if err := lockMCPRuntimeMutation(); err != nil { mcpRuntimeMutation.Lock()
return dto.MCPRuntimeStatus{}, err
}
defer mcpRuntimeMutation.Unlock() defer mcpRuntimeMutation.Unlock()
settings, err := s.Settings() settings, err := s.Settings()
if err != nil { if err != nil {
@@ -286,46 +242,20 @@ func (s *MCPRuntimeService) Operate(ctx context.Context, operation string) (dto.
} }
func (s *MCPRuntimeService) Install(ctx context.Context, req dto.MCPRuntimeInstall) (dto.MCPRuntimeStatus, error) { func (s *MCPRuntimeService) Install(ctx context.Context, req dto.MCPRuntimeInstall) (dto.MCPRuntimeStatus, error) {
if err := lockMCPRuntimeMutation(); err != nil { mcpRuntimeMutation.Lock()
return dto.MCPRuntimeStatus{}, err
}
defer mcpRuntimeMutation.Unlock() defer mcpRuntimeMutation.Unlock()
return s.install(ctx, req) old, err := s.settings(ctx)
}
func (s *MCPRuntimeService) installSettings(ctx context.Context, req dto.MCPRuntimeInstall) (old, next dto.MCPRuntimeSettings, err error) {
old, err = s.settings(ctx)
if err != nil { if err != nil {
return old, next, err return dto.MCPRuntimeStatus{}, err
} }
if req.Revision != old.Revision { if req.Revision != old.Revision {
return old, next, ErrMCPSettingsRevision return dto.MCPRuntimeStatus{}, ErrMCPSettingsRevision
} }
next = old next := old
next.RuntimeType, next.DockerImage, next.ListenPort, next.ExternalURL = "docker", req.DockerImage, req.ListenPort, req.ExternalURL 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 { if err := validateMCPRuntimeSettings(next); err != nil {
return old, next, contract.Fail("MCP_INVALID_ARGUMENTS", 400, err.Error()) return dto.MCPRuntimeStatus{}, 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
}
mcpruntime.ReportProgress(ctx, mcpruntime.Progress{Status: "snapshot"})
snapshot, err := s.snapshot(ctx, old) snapshot, err := s.snapshot(ctx, old)
if err != nil { if err != nil {
return dto.MCPRuntimeStatus{}, err return dto.MCPRuntimeStatus{}, err
@@ -333,15 +263,6 @@ func (s *MCPRuntimeService) install(ctx context.Context, req dto.MCPRuntimeInsta
if !snapshot.installed { if !snapshot.installed {
next.Enabled = true next.Enabled = true
} }
prepared := old
policyPrepared := !snapshot.installed || next.AccessPolicy != old.AccessPolicy || !reflect.DeepEqual(next.AllowedIPs, old.AllowedIPs)
if policyPrepared {
prepared.AccessPolicy, prepared.AllowedIPs = next.AccessPolicy, next.AllowedIPs
if err := s.persist(prepared, old.Revision); err != nil {
return dto.MCPRuntimeStatus{}, err
}
prepared.Revision++
}
manager := s.manager(next) manager := s.manager(next)
rollback := func(authoritative dto.MCPRuntimeSettings) error { rollback := func(authoritative dto.MCPRuntimeSettings) error {
if !snapshot.installed && reflect.DeepEqual(authoritative, old) { if !snapshot.installed && reflect.DeepEqual(authoritative, old) {
@@ -352,49 +273,21 @@ func (s *MCPRuntimeService) install(ctx context.Context, req dto.MCPRuntimeInsta
return s.restore(authoritative, snapshot) return s.restore(authoritative, snapshot)
} }
recoverFailure := func(operationErr error) error { recoverFailure := func(operationErr error) error {
mcpruntime.ReportProgress(ctx, mcpruntime.Progress{Status: "rollback"})
current, loadErr := s.Settings() current, loadErr := s.Settings()
if loadErr != nil { if loadErr != nil {
mcpruntime.ReportProgress(ctx, mcpruntime.Progress{Status: "recovery_failed"}) return errors.Join(operationErr, loadErr, errors.New("MCP settings are unavailable; runtime recovery requires retrying after the database is available"))
return &mcpRuntimeRecoveryError{cause: errors.Join(operationErr, loadErr)}
} }
if policyPrepared && current.Revision == prepared.Revision { return errors.Join(operationErr, rollback(current))
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 := installMCPRuntimeImage(ctx, manager, next.Enabled); err != nil { if err := manager.InstallImage(ctx, next.Enabled); err != nil {
return dto.MCPRuntimeStatus{}, recoverFailure(err) return dto.MCPRuntimeStatus{}, recoverFailure(err)
} }
mcpruntime.ReportProgress(ctx, mcpruntime.Progress{Status: "saving"}) if err := s.persist(next, old.Revision); err != nil {
if err := s.persist(next, prepared.Revision); err != nil {
return dto.MCPRuntimeStatus{}, recoverFailure(err) return dto.MCPRuntimeStatus{}, recoverFailure(err)
} }
mcpruntime.ReportProgress(ctx, mcpruntime.Progress{Status: "installed"})
return s.Status(ctx) return s.Status(ctx)
} }
func installMCPRuntimeImage(ctx context.Context, manager mcpRuntimeManager, enabled bool) (err error) {
defer func() {
if recover() != nil {
err = errors.New("MCP Docker image installation failed")
}
}()
return manager.InstallImage(ctx, enabled)
}
func (s *MCPRuntimeService) Logs(ctx context.Context) (dto.MCPRuntimeLogs, error) { func (s *MCPRuntimeService) Logs(ctx context.Context) (dto.MCPRuntimeLogs, error) {
settings, err := s.Settings() settings, err := s.Settings()
if err != nil { if err != nil {
-483
View File
@@ -1,483 +0,0 @@
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,7 +80,6 @@ const (
TaskScopeCluster = "Cluster" TaskScopeCluster = "Cluster"
TaskScopeAppInstall = "AppInstallTask" TaskScopeAppInstall = "AppInstallTask"
TaskScopeAI = "AI" TaskScopeAI = "AI"
TaskScopePanelMCP = "PanelMCP"
) )
func GetTaskName(resourceName, operate, scope string) string { func GetTaskName(resourceName, operate, scope string) string {
@@ -322,12 +321,6 @@ func (t *Task) DeleteLogFile() {
_ = os.Remove(t.Task.LogFile) _ = os.Remove(t.Task.LogFile)
} }
func (t *Task) Close() {
if t.logFile != nil {
_ = t.logFile.Close()
}
}
func (t *Task) LogWithStatus(msg string, err error) { func (t *Task) LogWithStatus(msg string, err error) {
if err != nil { if err != nil {
t.Logger.Print(i18n.GetWithNameAndErr("FailedStatus", msg, err)) t.Logger.Print(i18n.GetWithNameAndErr("FailedStatus", msg, err))
+263 -2139
View File
File diff suppressed because it is too large Load Diff
+2 -5
View File
@@ -18,10 +18,7 @@ import (
// @schemes http https // @schemes http https
// @securityDefinitions.apikey ApiKeyAuth // @securityDefinitions.apikey ApiKeyAuth
// @description Signed API Key authentication. Send the signature, never the plaintext API Key, in 1Panel-Token. // @description Custom Token Format, Format: md5('1panel' + API-Key + UnixTimestamp).
// @description Supported formats: hex(HMAC-SHA256(API-Key, '1panel:' + UnixTimestamp)) or md5('1panel' + API-Key + UnixTimestamp).
// @description Send the matching Unix timestamp in 1Panel-Timestamp. Optionally set 1Panel-Signature-Version to hmac-sha256, v1 or md5; omission accepts either algorithm.
// @description Independent keys may include their ID in 1Panel-Key-ID to select the credential; omit that header for a legacy key.
// @description ``` // @description ```
// @description eg: // @description eg:
// @description curl -X GET "http://{host}:{port}/api/v2/toolbox/device/base" \ // @description curl -X GET "http://{host}:{port}/api/v2/toolbox/device/base" \
@@ -30,7 +27,7 @@ import (
// @description ``` // @description ```
// @description - `1Panel-Token` is the key for the panel API Key. // @description - `1Panel-Token` is the key for the panel API Key.
// @type apiKey // @type apiKey
// @in header // @in Header
// @name 1Panel-Token // @name 1Panel-Token
// @securityDefinitions.apikey Timestamp // @securityDefinitions.apikey Timestamp
// @type apiKey // @type apiKey
-3
View File
@@ -73,14 +73,11 @@ var WebUrlMap = map[string]struct{}{
"/ai/benchmark": {}, "/ai/benchmark": {},
"/ai/model/account": {}, "/ai/model/account": {},
"/ai/model/local": {}, "/ai/model/local": {},
"/ai/model/local/templates": {},
"/ai/model/files": {}, "/ai/model/files": {},
"/ai/gpu": {}, "/ai/gpu": {},
"/ai/gpu/current": {}, "/ai/gpu/current": {},
"/ai/gpu/history": {}, "/ai/gpu/history": {},
"/ai/mcp": {}, "/ai/mcp": {},
"/ai/mcp/server": {},
"/ai/mcp/1panel": {},
"/ai/agents/agent": {}, "/ai/agents/agent": {},
"/containers": {}, "/containers": {},
-2
View File
@@ -7,7 +7,6 @@ require (
github.com/coreos/go-oidc/v3 v3.20.0 github.com/coreos/go-oidc/v3 v3.20.0
github.com/creack/pty v1.1.24 github.com/creack/pty v1.1.24
github.com/crewjam/saml v0.5.1 github.com/crewjam/saml v0.5.1
github.com/distribution/reference v0.6.0
github.com/fsnotify/fsnotify v1.10.1 github.com/fsnotify/fsnotify v1.10.1
github.com/gin-contrib/gzip v1.2.6 github.com/gin-contrib/gzip v1.2.6
github.com/gin-gonic/gin v1.12.0 github.com/gin-gonic/gin v1.12.0
@@ -107,7 +106,6 @@ require (
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/pdfcpu/pdfcpu v0.11.1 // indirect github.com/pdfcpu/pdfcpu v0.11.1 // indirect
github.com/pelletier/go-toml/v2 v2.3.0 // indirect github.com/pelletier/go-toml/v2 v2.3.0 // indirect
github.com/philhofer/fwd v1.2.0 // indirect github.com/philhofer/fwd v1.2.0 // indirect
-4
View File
@@ -31,8 +31,6 @@ github.com/crewjam/saml v0.5.1/go.mod h1:r0fDkmFe5URDgPrmtH0IYokva6fac3AUdstiPhy
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/ebitengine/purego v0.10.2 h1:W809HbnvzAxgdm+aOvlSekrM16wGCdT/e76+9tS7gzE= github.com/ebitengine/purego v0.10.2 h1:W809HbnvzAxgdm+aOvlSekrM16wGCdT/e76+9tS7gzE=
@@ -202,8 +200,6 @@ github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOF
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/nicksnyder/go-i18n/v2 v2.6.1 h1:JDEJraFsQE17Dut9HFDHzCoAWGEQJom5s0TRd17NIEQ= github.com/nicksnyder/go-i18n/v2 v2.6.1 h1:JDEJraFsQE17Dut9HFDHzCoAWGEQJom5s0TRd17NIEQ=
github.com/nicksnyder/go-i18n/v2 v2.6.1/go.mod h1:Vee0/9RD3Quc/NmwEjzzD7VTZ+Ir7QbXocrkhOzmUKA= github.com/nicksnyder/go-i18n/v2 v2.6.1/go.mod h1:Vee0/9RD3Quc/NmwEjzzD7VTZ+Ir7QbXocrkhOzmUKA=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/oschwald/maxminddb-golang v1.13.1 h1:G3wwjdN9JmIK2o/ermkHM+98oX5fS+k5MbwsmL4MRQE= github.com/oschwald/maxminddb-golang v1.13.1 h1:G3wwjdN9JmIK2o/ermkHM+98oX5fS+k5MbwsmL4MRQE=
github.com/oschwald/maxminddb-golang v1.13.1/go.mod h1:K4pgV9N/GcK694KSTmVSDTODk4IsCNThNdTmnaBZ/F8= github.com/oschwald/maxminddb-golang v1.13.1/go.mod h1:K4pgV9N/GcK694KSTmVSDTODk4IsCNThNdTmnaBZ/F8=
github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc= github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc=
-21
View File
@@ -542,24 +542,3 @@ ErrMCPSecretInvalid: "The stored MCP Token cannot be decrypted. Regenerate the T
ErrMCPCredentialInactive: "The MCP credential is disabled, expired, or revoked." ErrMCPCredentialInactive: "The MCP credential is disabled, expired, or revoked."
ErrMCPAuditUnavailable: "MCP operation auditing is unavailable. Verify the operation result before continuing." ErrMCPAuditUnavailable: "MCP operation auditing is unavailable. Verify the operation result before continuing."
ErrMCPEndpointUnavailable: "Configure a valid external MCP endpoint first." ErrMCPEndpointUnavailable: "Configure a valid external MCP endpoint first."
ErrMCPWebsitePending: "The website operation result is uncertain. Refresh the binding status before retrying the original operation."
ErrMCPWebsiteUnavailable: "The website binding is unavailable. Check the website status and OpenResty configuration."
ErrMCPWebsiteServiceRequired: "Install the 1Panel MCP service first."
# MCP installation tasks
PanelMCP: "1Panel MCP"
MCPInstallPrepare: "Check the current MCP service"
MCPInstallPullImage: "Check and pull the image"
MCPInstallCatalog: "Validate the tool contract"
MCPInstallStarting: "Start the container and check its health"
MCPInstallSaving: "Save the service configuration"
MCPInstallCompleted: "MCP service installation completed"
MCPInstallRollback: "Installation failed; restore the previous MCP service"
MCPInstallBindWebsite: "Create the bound website"
MCPInstallWebsiteFailed: "The MCP service is installed, but website binding failed. Resolve the website configuration in the service settings."
MCPInstallInterrupted: "Installation was interrupted by a panel restart. Check the service status before continuing."
MCPInstallFailed: "MCP installation failed. Check Docker, the image, and the panel service logs."
MCPInstallRecoveryFailed: "Installation failed and the previous service could not be fully restored. Check the MCP service status before continuing."
MCPInstallLocalImage: "The image exists locally; skip downloading"
MCPInstallVerifyImage: "Validate the image version and managed service contract"
MCPInstallVerifiedImage: "Image validation passed"
-21
View File
@@ -542,24 +542,3 @@ ErrMCPSecretInvalid: "No se puede descifrar el Token MCP almacenado. Genere un T
ErrMCPCredentialInactive: "La credencial MCP está deshabilitada, ha caducado o se ha revocado." ErrMCPCredentialInactive: "La credencial MCP está deshabilitada, ha caducado o se ha revocado."
ErrMCPAuditUnavailable: "La auditoría de operaciones MCP no está disponible. Verifique el resultado antes de continuar." ErrMCPAuditUnavailable: "La auditoría de operaciones MCP no está disponible. Verifique el resultado antes de continuar."
ErrMCPEndpointUnavailable: "Configure primero una dirección externa MCP válida." ErrMCPEndpointUnavailable: "Configure primero una dirección externa MCP válida."
ErrMCPWebsitePending: "El resultado de la operación del sitio es incierto. Actualice el estado de la vinculación antes de reintentar la operación original."
ErrMCPWebsiteUnavailable: "La vinculación del sitio no está disponible. Revise el estado del sitio y la configuración de OpenResty."
ErrMCPWebsiteServiceRequired: "Instale primero el servicio 1Panel MCP."
# MCP installation tasks
PanelMCP: "1Panel MCP"
MCPInstallPrepare: "Comprobar el servicio MCP actual"
MCPInstallPullImage: "Comprobar y descargar la imagen"
MCPInstallCatalog: "Validar el contrato de herramientas"
MCPInstallStarting: "Iniciar el contenedor y comprobar su estado"
MCPInstallSaving: "Guardar la configuración del servicio"
MCPInstallCompleted: "Instalación del servicio MCP completada"
MCPInstallRollback: "La instalación falló; restaurar el servicio MCP anterior"
MCPInstallBindWebsite: "Crear el sitio web vinculado"
MCPInstallWebsiteFailed: "El servicio MCP está instalado, pero la vinculación del sitio web falló. Revise la configuración del sitio web en los ajustes del servicio."
MCPInstallInterrupted: "Un reinicio del panel interrumpió la instalación. Compruebe el estado del servicio antes de continuar."
MCPInstallFailed: "La instalación de MCP falló. Revise Docker, la imagen y los registros del servicio del panel."
MCPInstallRecoveryFailed: "La instalación falló y el servicio anterior no se pudo restaurar por completo. Compruebe el estado del servicio MCP antes de continuar."
MCPInstallLocalImage: "La imagen ya existe localmente; omitir la descarga"
MCPInstallVerifyImage: "Validar la versión de la imagen y el contrato del servicio administrado"
MCPInstallVerifiedImage: "La imagen superó la validación"
-21
View File
@@ -542,24 +542,3 @@ ErrMCPSecretInvalid: "رمزگشایی Token ذخیره‌شده MCP ممکن ن
ErrMCPCredentialInactive: "اعتبار MCP غیرفعال، منقضی یا لغو شده است." ErrMCPCredentialInactive: "اعتبار MCP غیرفعال، منقضی یا لغو شده است."
ErrMCPAuditUnavailable: "ثبت حسابرسی عملیات MCP در دسترس نیست. پیش از ادامه، نتیجه عملیات را بررسی کنید." ErrMCPAuditUnavailable: "ثبت حسابرسی عملیات MCP در دسترس نیست. پیش از ادامه، نتیجه عملیات را بررسی کنید."
ErrMCPEndpointUnavailable: "ابتدا یک نشانی خارجی معتبر برای MCP تنظیم کنید." ErrMCPEndpointUnavailable: "ابتدا یک نشانی خارجی معتبر برای MCP تنظیم کنید."
ErrMCPWebsitePending: "نتیجه عملیات وب‌سایت تأیید نشده است. پیش از تلاش دوباره برای عملیات اصلی، وضعیت اتصال را تازه‌سازی کنید."
ErrMCPWebsiteUnavailable: "اتصال وب‌سایت در دسترس نیست. وضعیت وب‌سایت و تنظیمات OpenResty را بررسی کنید."
ErrMCPWebsiteServiceRequired: "ابتدا سرویس 1Panel MCP را نصب کنید."
# MCP installation tasks
PanelMCP: "1Panel MCP"
MCPInstallPrepare: "بررسی سرویس فعلی MCP"
MCPInstallPullImage: "بررسی و دریافت تصویر"
MCPInstallCatalog: "اعتبارسنجی قرارداد ابزارها"
MCPInstallStarting: "راه‌اندازی کانتینر و بررسی سلامت آن"
MCPInstallSaving: "ذخیره پیکربندی سرویس"
MCPInstallCompleted: "نصب سرویس MCP تکمیل شد"
MCPInstallRollback: "نصب ناموفق بود؛ بازیابی سرویس قبلی MCP"
MCPInstallBindWebsite: "ایجاد وب‌سایت متصل"
MCPInstallWebsiteFailed: "سرویس MCP نصب شده است، اما اتصال وب‌سایت ناموفق بود. پیکربندی وب‌سایت را در تنظیمات سرویس اصلاح کنید."
MCPInstallInterrupted: "راه‌اندازی مجدد پنل باعث قطع نصب شد. پیش از ادامه، وضعیت سرویس را بررسی کنید."
MCPInstallFailed: "نصب MCP ناموفق بود. Docker، تصویر و گزارش‌های سرویس پنل را بررسی کنید."
MCPInstallRecoveryFailed: "نصب ناموفق بود و سرویس قبلی به‌طور کامل بازیابی نشد. پیش از ادامه، وضعیت سرویس MCP را بررسی کنید."
MCPInstallLocalImage: "تصویر به‌صورت محلی موجود است؛ دریافت انجام نمی‌شود"
MCPInstallVerifyImage: "اعتبارسنجی نسخه تصویر و قرارداد سرویس مدیریت‌شده"
MCPInstallVerifiedImage: "اعتبارسنجی تصویر موفق بود"
-21
View File
@@ -542,24 +542,3 @@ ErrMCPSecretInvalid: "保存済みの MCP Token を復号できません。Token
ErrMCPCredentialInactive: "MCP 認証情報が無効、期限切れ、または取り消し済みです。" ErrMCPCredentialInactive: "MCP 認証情報が無効、期限切れ、または取り消し済みです。"
ErrMCPAuditUnavailable: "MCP 操作の監査記録を利用できません。操作結果を確認してから続行してください。" ErrMCPAuditUnavailable: "MCP 操作の監査記録を利用できません。操作結果を確認してから続行してください。"
ErrMCPEndpointUnavailable: "先に有効な MCP 外部接続先を設定してください。" ErrMCPEndpointUnavailable: "先に有効な MCP 外部接続先を設定してください。"
ErrMCPWebsitePending: "サイト操作の結果を確認できません。連携状態を更新してから元の操作を再試行してください。"
ErrMCPWebsiteUnavailable: "サイト連携を利用できません。サイトの状態と OpenResty の設定を確認してください。"
ErrMCPWebsiteServiceRequired: "先に 1Panel MCP サービスをインストールしてください。"
# MCP installation tasks
PanelMCP: "1Panel MCP"
MCPInstallPrepare: "現在の MCP サービスを確認"
MCPInstallPullImage: "イメージを確認して取得"
MCPInstallCatalog: "ツールの契約を検証"
MCPInstallStarting: "コンテナを起動して正常性を確認"
MCPInstallSaving: "サービス設定を保存"
MCPInstallCompleted: "MCP サービスのインストールが完了しました"
MCPInstallRollback: "インストールに失敗したため、以前の MCP サービスを復元"
MCPInstallBindWebsite: "連携する Web サイトを作成"
MCPInstallWebsiteFailed: "MCP サービスはインストールされましたが、Web サイトの連携に失敗しました。サービス設定で Web サイトの設定を確認してください。"
MCPInstallInterrupted: "パネルの再起動によりインストールが中断されました。操作を続ける前にサービスの状態を確認してください。"
MCPInstallFailed: "MCP のインストールに失敗しました。Docker、イメージ、パネルサービスのログを確認してください。"
MCPInstallRecoveryFailed: "インストールに失敗し、以前のサービスを完全に復元できませんでした。操作を続ける前に MCP サービスの状態を確認してください。"
MCPInstallLocalImage: "ローカルにイメージが存在するため、ダウンロードをスキップ"
MCPInstallVerifyImage: "イメージのバージョンと管理対象サービスの契約を検証"
MCPInstallVerifiedImage: "イメージの検証に成功しました"
-21
View File
@@ -542,24 +542,3 @@ ErrMCPSecretInvalid: "저장된 MCP Token을 복호화할 수 없습니다. Toke
ErrMCPCredentialInactive: "MCP 자격 증명이 비활성화되었거나 만료 또는 취소되었습니다." ErrMCPCredentialInactive: "MCP 자격 증명이 비활성화되었거나 만료 또는 취소되었습니다."
ErrMCPAuditUnavailable: "MCP 작업 감사 기록을 사용할 수 없습니다. 작업 결과를 확인한 후 계속하세요." ErrMCPAuditUnavailable: "MCP 작업 감사 기록을 사용할 수 없습니다. 작업 결과를 확인한 후 계속하세요."
ErrMCPEndpointUnavailable: "먼저 유효한 MCP 외부 접속 주소를 설정하세요." ErrMCPEndpointUnavailable: "먼저 유효한 MCP 외부 접속 주소를 설정하세요."
ErrMCPWebsitePending: "웹사이트 작업 결과를 확인할 수 없습니다. 연결 상태를 새로 고친 후 원래 작업을 다시 시도하세요."
ErrMCPWebsiteUnavailable: "웹사이트 연결을 사용할 수 없습니다. 웹사이트 상태와 OpenResty 설정을 확인하세요."
ErrMCPWebsiteServiceRequired: "먼저 1Panel MCP 서비스를 설치하세요."
# MCP installation tasks
PanelMCP: "1Panel MCP"
MCPInstallPrepare: "현재 MCP 서비스 확인"
MCPInstallPullImage: "이미지 확인 및 가져오기"
MCPInstallCatalog: "도구 계약 검증"
MCPInstallStarting: "컨테이너 시작 및 상태 확인"
MCPInstallSaving: "서비스 구성 저장"
MCPInstallCompleted: "MCP 서비스 설치 완료"
MCPInstallRollback: "설치 실패로 이전 MCP 서비스 복원"
MCPInstallBindWebsite: "연결할 웹사이트 생성"
MCPInstallWebsiteFailed: "MCP 서비스는 설치되었지만 웹사이트 연결에 실패했습니다. 서비스 설정에서 웹사이트 구성을 확인하세요."
MCPInstallInterrupted: "패널이 다시 시작되어 설치가 중단되었습니다. 계속하기 전에 서비스 상태를 확인하세요."
MCPInstallFailed: "MCP 설치에 실패했습니다. Docker, 이미지 및 패널 서비스 로그를 확인하세요."
MCPInstallRecoveryFailed: "설치에 실패했으며 이전 서비스를 완전히 복원하지 못했습니다. 계속하기 전에 MCP 서비스 상태를 확인하세요."
MCPInstallLocalImage: "로컬 이미지가 있어 다운로드를 건너뜁니다"
MCPInstallVerifyImage: "이미지 버전 및 관리형 서비스 계약 검증"
MCPInstallVerifiedImage: "이미지 검증 통과"
-21
View File
@@ -542,24 +542,3 @@ ErrMCPSecretInvalid: "ບໍ່ສາມາດຖອດລະຫັດ MCP Token
ErrMCPCredentialInactive: "ຂໍ້ມູນຢັ້ງຢືນ MCP ຖືກປິດໃຊ້ງານ, ໝົດອາຍຸ ຫຼື ຖືກຖອນ." ErrMCPCredentialInactive: "ຂໍ້ມູນຢັ້ງຢືນ MCP ຖືກປິດໃຊ້ງານ, ໝົດອາຍຸ ຫຼື ຖືກຖອນ."
ErrMCPAuditUnavailable: "ການບັນທຶກກວດສອບການດຳເນີນງານ MCP ບໍ່ພ້ອມໃຊ້ງານ. ກະລຸນາກວດສອບຜົນການດຳເນີນງານກ່ອນດຳເນີນການຕໍ່." ErrMCPAuditUnavailable: "ການບັນທຶກກວດສອບການດຳເນີນງານ MCP ບໍ່ພ້ອມໃຊ້ງານ. ກະລຸນາກວດສອບຜົນການດຳເນີນງານກ່ອນດຳເນີນການຕໍ່."
ErrMCPEndpointUnavailable: "ກະລຸນາຕັ້ງຄ່າທີ່ຢູ່ MCP ພາຍນອກທີ່ຖືກຕ້ອງກ່ອນ." ErrMCPEndpointUnavailable: "ກະລຸນາຕັ້ງຄ່າທີ່ຢູ່ MCP ພາຍນອກທີ່ຖືກຕ້ອງກ່ອນ."
ErrMCPWebsitePending: "ຍັງບໍ່ສາມາດຢືນຢັນຜົນການດຳເນີນການເວັບໄຊ. ໂຫຼດສະຖານະການຜູກມັດໃໝ່ກ່ອນລອງການດຳເນີນການເດີມອີກຄັ້ງ."
ErrMCPWebsiteUnavailable: "ການຜູກມັດເວັບໄຊບໍ່ພ້ອມໃຊ້ງານ. ກວດສອບສະຖານະເວັບໄຊ ແລະ ການຕັ້ງຄ່າ OpenResty."
ErrMCPWebsiteServiceRequired: "ກະລຸນາຕິດຕັ້ງບໍລິການ 1Panel MCP ກ່ອນ."
# MCP installation tasks
PanelMCP: "1Panel MCP"
MCPInstallPrepare: "ກວດສອບບໍລິການ MCP ປັດຈຸບັນ"
MCPInstallPullImage: "ກວດສອບ ແລະ ດຶງອິມເມດ"
MCPInstallCatalog: "ກວດສອບສັນຍາຂອງເຄື່ອງມື"
MCPInstallStarting: "ເລີ່ມຄອນເທນເນີ ແລະ ກວດສອບສຸຂະພາບ"
MCPInstallSaving: "ບັນທຶກການຕັ້ງຄ່າບໍລິການ"
MCPInstallCompleted: "ຕິດຕັ້ງບໍລິການ MCP ສຳເລັດແລ້ວ"
MCPInstallRollback: "ການຕິດຕັ້ງລົ້ມເຫຼວ; ກູ້ຄືນບໍລິການ MCP ກ່ອນໜ້າ"
MCPInstallBindWebsite: "ສ້າງເວັບໄຊທີ່ເຊື່ອມໂຍງ"
MCPInstallWebsiteFailed: "ຕິດຕັ້ງບໍລິການ MCP ແລ້ວ ແຕ່ການເຊື່ອມໂຍງເວັບໄຊລົ້ມເຫຼວ. ກະລຸນາແກ້ໄຂການຕັ້ງຄ່າເວັບໄຊໃນການຕັ້ງຄ່າບໍລິການ."
MCPInstallInterrupted: "ການເລີ່ມແຜງຄວບຄຸມໃໝ່ເຮັດໃຫ້ການຕິດຕັ້ງຢຸດຊະງັກ. ກະລຸນາກວດສອບສະຖານະບໍລິການກ່ອນດຳເນີນການຕໍ່."
MCPInstallFailed: "ການຕິດຕັ້ງ MCP ລົ້ມເຫຼວ. ກະລຸນາກວດສອບ Docker, ອິມເມດ ແລະ ບັນທຶກບໍລິການແຜງຄວບຄຸມ."
MCPInstallRecoveryFailed: "ການຕິດຕັ້ງລົ້ມເຫຼວ ແລະ ບໍລິການເດີມບໍ່ສາມາດກູ້ຄືນໄດ້ຄົບຖ້ວນ. ກະລຸນາກວດສອບສະຖານະບໍລິການ MCP ກ່ອນດຳເນີນການຕໍ່."
MCPInstallLocalImage: "ມີອິມເມດຢູ່ໃນເຄື່ອງແລ້ວ; ຂ້າມການດາວໂຫຼດ"
MCPInstallVerifyImage: "ກວດສອບເວີຊັນອິມເມດ ແລະ ສັນຍາບໍລິການທີ່ຈັດການ"
MCPInstallVerifiedImage: "ກວດສອບອິມເມດຜ່ານແລ້ວ"

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