Compare commits

..
Author SHA1 Message Date
guoyuqi 8b6328fd04 feat(container): add image update checks with scheduling and task logs 2026-10-29 19:02:44 +08:00
guoyuqi e9c2162921 feat(container): add image update checks with scheduling and task logs 2026-10-29 18:25:32 +08:00
ssongliu 87ab399bcc feat(ai): add download source icons and localize downloads labels (#14240) 2026-10-29 11:23:09 +08:00
Yuki Guo ecedaf8c21 feat(container): support batch upgrades with bounded concurrency and recovery (#14231) 2026-10-28 18:45:13 +08:00
ssongliu d7c42e3e8b fix(ai): unify model deletion prompts and register template route (#14233) 2026-10-28 18:20:06 +08:00
蘭 d32cbb40f6 feat(mcp): add Panel MCP task configuration and website binding structures (#14229)
* feat(mcp): add Panel MCP task configuration and website binding structures

* fix(mcp): correct variable assignment in mcpWebsiteDomain function
2026-10-28 14:04:38 +08:00
ssongliu 8684a26459 feat(ai): refine model navigation and downloader translations (#14228) 2026-10-28 12:11:41 +08:00
CityFun 1798f9768e fix(ai): preserve legacy Feishu and safely migrate OpenClaw 2026.10.7 upgrades (#14214)
* fix(ai): preserve legacy Feishu and migrate bundled plugin upgrades

* fix(ai): override inherited Feishu allowlists when cleared
2026-10-28 11:24:03 +08:00
Yuki Guo eb4beb50c4 fix: docker image pull and stop (#14202) 2026-10-27 23:07:21 +08:00
ssongliu 3a7cd211af fix: unify database credential validation (#14216) 2026-10-27 23:06:13 +08:00
ssongliu bd0bc0079e refactor(ai): remove obsolete tokenizer directory patterns (#14225) 2026-10-27 23:05:54 +08:00
ssongliu 93ed58d784 fix(terminal): improve dock button border in dark mode (#14224) 2026-10-27 17:22:51 +08:00
ssongliu 8d320ef2ca feat(node): add translations for existing node packages (#14222) 2026-10-27 17:12:14 +08:00
ssongliu b3c1e0df42 refactor(frontend): use DialogPro for task logs (#14212) 2026-10-27 14:34:20 +08:00
ssongliu 5346d5f58a fix(ai): reconcile Ollama models and improve engine management (#14211) 2026-10-27 11:06:32 +08:00
Raphael Fakhri ac09426f10 fix(app): keep signed numeric env values unchanged (#14180) 2026-10-27 09:58:53 +08:00
CityFun 19707a68dd fix(agent): support new Hermes Weixin module path (#14197) 2026-10-26 23:34:56 +08:00
ssongliu 7c7fd492c4 feat(ai): support benchmark report routes and parameter translations (#14210) 2026-10-26 23:33:17 +08:00
ssongliu fbf66766c2 fix: align AI menu order and labels (#14192) 2026-10-26 14:03:40 +08:00
208 changed files with 11125 additions and 1780 deletions
+20
View File
@@ -826,6 +826,26 @@ func (b *BaseApi) UpdateAgentFeishuConfig(c *gin.Context) {
helper.Success(c) helper.Success(c)
} }
// @Tags AI
// @Summary Switch to the bundled OpenClaw Feishu plugin
// @Accept json
// @Param request body dto.AgentFeishuSwitchReq true "request"
// @Success 200
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /ai/agents/channel/feishu/switch [post]
func (b *BaseApi) SwitchAgentFeishuPlugin(c *gin.Context) {
var req dto.AgentFeishuSwitchReq
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
if err := agentService.SwitchFeishuPlugin(req); err != nil {
helper.BadRequest(c, err)
return
}
helper.Success(c)
}
// @Tags AI // @Tags AI
// @Summary Get Agent Telegram channel config // @Summary Get Agent Telegram channel config
// @Accept json // @Accept json
-16
View File
@@ -72,22 +72,6 @@ func (b *BaseApi) CloseOllamaModel(c *gin.Context) {
helper.SuccessWithData(c, nil) helper.SuccessWithData(c, nil)
} }
// @Tags AI
// @Summary Sync Ollama model list
// @Success 200 {array} dto.OllamaModelDropList
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /ai/ollama/model/sync [post]
// @x-panel-log {"bodyKeys":[],"paramKeys":[],"BeforeFunctions":[],"formatZH":"同步 Ollama 模型列表","formatEN":"sync Ollama model list"}
func (b *BaseApi) SyncOllamaModel(c *gin.Context) {
list, err := aiToolService.Sync()
if err != nil {
helper.BadRequest(c, err)
return
}
helper.SuccessWithData(c, list)
}
// @Tags AI // @Tags AI
// @Summary Page Ollama models // @Summary Page Ollama models
// @Accept json // @Accept json
-16
View File
@@ -8,7 +8,6 @@ import (
"github.com/1Panel-dev/1Panel/agent/app/dto/request" "github.com/1Panel-dev/1Panel/agent/app/dto/request"
"github.com/1Panel-dev/1Panel/agent/i18n" "github.com/1Panel-dev/1Panel/agent/i18n"
"github.com/1Panel-dev/1Panel/agent/utils/appicon" "github.com/1Panel-dev/1Panel/agent/utils/appicon"
"github.com/1Panel-dev/1Panel/agent/utils/mcpapi"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
@@ -161,25 +160,10 @@ func (b *BaseApi) GetAppDetailByID(c *gin.Context) {
// @Router /apps/install [post] // @Router /apps/install [post]
// @x-panel-log {"bodyKeys":["name"],"paramKeys":[],"BeforeFunctions":[],"formatZH":"安装应用 [name]","formatEN":"Install app [name]"} // @x-panel-log {"bodyKeys":["name"],"paramKeys":[],"BeforeFunctions":[],"formatZH":"安装应用 [name]","formatEN":"Install app [name]"}
func (b *BaseApi) InstallApp(c *gin.Context) { func (b *BaseApi) InstallApp(c *gin.Context) {
mcpRequest, trustedMCP := mcpapi.Check(c.Request)
if mcpRequest && !trustedMCP {
c.AbortWithStatus(http.StatusForbidden)
return
}
var req request.AppInstallCreate var req request.AppInstallCreate
if err := helper.CheckBindAndValidate(&req, c); err != nil { if err := helper.CheckBindAndValidate(&req, c); err != nil {
return return
} }
if mcpRequest {
req.MCPPreserveWebsiteDir = true
if value, exists := req.Params["WEBSITE_DIR"]; exists {
directory, ok := value.(string)
if !ok || directory == "" || directory != settingService.GetWebsiteDir() {
c.AbortWithStatus(http.StatusForbidden)
return
}
}
}
install, err := appService.Install(req, true) install, err := appService.Install(req, true)
if err != nil { if err != nil {
helper.InternalServer(c, err) helper.InternalServer(c, err)
-18
View File
@@ -1,11 +1,7 @@
package v2 package v2
import ( import (
"net/http"
"github.com/1Panel-dev/1Panel/agent/app/dto/request" "github.com/1Panel-dev/1Panel/agent/app/dto/request"
"github.com/1Panel-dev/1Panel/agent/app/service"
"github.com/1Panel-dev/1Panel/agent/utils/mcpapi"
"github.com/1Panel-dev/1Panel/agent/app/api/v2/helper" "github.com/1Panel-dev/1Panel/agent/app/api/v2/helper"
"github.com/1Panel-dev/1Panel/agent/app/dto" "github.com/1Panel-dev/1Panel/agent/app/dto"
@@ -340,25 +336,11 @@ func (b *BaseApi) UpdateAppConfig(c *gin.Context) {
// @Security Timestamp // @Security Timestamp
// @Router /apps/installed/info/:appInstallId [get] // @Router /apps/installed/info/:appInstallId [get]
func (b *BaseApi) GetAppInstallInfo(c *gin.Context) { func (b *BaseApi) GetAppInstallInfo(c *gin.Context) {
mcpRequest, trustedMCP := mcpapi.Check(c.Request)
if mcpRequest && !trustedMCP {
c.AbortWithStatus(http.StatusForbidden)
return
}
appInstallId, err := helper.GetIntParamByKey(c, "appInstallId") appInstallId, err := helper.GetIntParamByKey(c, "appInstallId")
if err != nil { if err != nil {
helper.BadRequest(c, err) helper.BadRequest(c, err)
return return
} }
if mcpRequest {
info, err := (&service.AppInstallService{}).GetAppInstallInfoForMCP(c.Request.Context(), appInstallId)
if err != nil {
helper.InternalServer(c, err)
return
}
helper.SuccessWithData(c, info)
return
}
info, err := appInstallService.GetAppInstallInfo(appInstallId) info, err := appInstallService.GetAppInstallInfo(appInstallId)
if err != nil { if err != nil {
helper.InternalServer(c, err) helper.InternalServer(c, err)
@@ -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,12 +2,9 @@ package v2
import ( import (
"encoding/base64" "encoding/base64"
"net/http"
"github.com/1Panel-dev/1Panel/agent/app/api/v2/helper" "github.com/1Panel-dev/1Panel/agent/app/api/v2/helper"
"github.com/1Panel-dev/1Panel/agent/app/dto" "github.com/1Panel-dev/1Panel/agent/app/dto"
"github.com/1Panel-dev/1Panel/agent/app/service"
"github.com/1Panel-dev/1Panel/agent/utils/mcpapi"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
@@ -101,25 +98,11 @@ func (b *BaseApi) SearchDatabase(c *gin.Context) {
// @Param type path string true "type" // @Param type path string true "type"
// @Router /databases/db/list/:type [get] // @Router /databases/db/list/:type [get]
func (b *BaseApi) ListDatabase(c *gin.Context) { func (b *BaseApi) ListDatabase(c *gin.Context) {
mcpRequest, trustedMCP := mcpapi.Check(c.Request)
if mcpRequest && !trustedMCP {
c.AbortWithStatus(http.StatusForbidden)
return
}
dbType, err := helper.GetStrParamByKey(c, "type") dbType, err := helper.GetStrParamByKey(c, "type")
if err != nil { if err != nil {
helper.BadRequest(c, err) helper.BadRequest(c, err)
return return
} }
if mcpRequest {
list, err := (&service.DatabaseService{}).ListForMCP(c.Request.Context(), dbType)
if err != nil {
helper.BadRequest(c, err)
return
}
helper.SuccessWithData(c, list)
return
}
list, err := databaseService.List(dbType) list, err := databaseService.List(dbType)
if err != nil { if err != nil {
helper.InternalServer(c, err) helper.InternalServer(c, err)
-33
View File
@@ -3,12 +3,9 @@ package v2
import ( import (
"context" "context"
"encoding/base64" "encoding/base64"
"net/http"
"github.com/1Panel-dev/1Panel/agent/app/api/v2/helper" "github.com/1Panel-dev/1Panel/agent/app/api/v2/helper"
"github.com/1Panel-dev/1Panel/agent/app/dto" "github.com/1Panel-dev/1Panel/agent/app/dto"
"github.com/1Panel-dev/1Panel/agent/app/service"
"github.com/1Panel-dev/1Panel/agent/utils/mcpapi"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
@@ -22,11 +19,6 @@ import (
// @Router /databases [post] // @Router /databases [post]
// @x-panel-log {"bodyKeys":["name"],"paramKeys":[],"BeforeFunctions":[],"formatZH":"创建 mysql 数据库 [name]","formatEN":"create mysql database [name]"} // @x-panel-log {"bodyKeys":["name"],"paramKeys":[],"BeforeFunctions":[],"formatZH":"创建 mysql 数据库 [name]","formatEN":"create mysql database [name]"}
func (b *BaseApi) CreateMysql(c *gin.Context) { func (b *BaseApi) CreateMysql(c *gin.Context) {
mcpRequest, trustedMCP := mcpapi.Check(c.Request)
if mcpRequest && !trustedMCP {
c.AbortWithStatus(http.StatusForbidden)
return
}
var req dto.MysqlDBCreate var req dto.MysqlDBCreate
if err := helper.CheckBindAndValidate(&req, c); err != nil { if err := helper.CheckBindAndValidate(&req, c); err != nil {
return return
@@ -40,17 +32,6 @@ func (b *BaseApi) CreateMysql(c *gin.Context) {
} }
req.Password = string(password) req.Password = string(password)
} }
if mcpRequest {
created, err := (&service.MysqlService{}).CreateForMCP(c.Request.Context(), req)
if err != nil {
helper.InternalServer(c, err)
return
}
nodeID, _ := mcpapi.NodeID(c.Request)
helper.SuccessWithData(c, map[string]any{"id": created.ID, "database": created.MysqlName, "name": created.Name, "nodeId": nodeID})
return
}
if _, err := mysqlService.Create(context.Background(), req); err != nil { if _, err := mysqlService.Create(context.Background(), req); err != nil {
helper.InternalServer(c, err) helper.InternalServer(c, err)
return return
@@ -531,25 +512,11 @@ func (b *BaseApi) LoadRemoteAccess(c *gin.Context) {
// @Security Timestamp // @Security Timestamp
// @Router /databases/status [post] // @Router /databases/status [post]
func (b *BaseApi) LoadStatus(c *gin.Context) { func (b *BaseApi) LoadStatus(c *gin.Context) {
mcpRequest, trustedMCP := mcpapi.Check(c.Request)
if mcpRequest && !trustedMCP {
c.AbortWithStatus(http.StatusForbidden)
return
}
var req dto.OperationWithNameAndType var req dto.OperationWithNameAndType
if err := helper.CheckBindAndValidate(&req, c); err != nil { if err := helper.CheckBindAndValidate(&req, c); err != nil {
return return
} }
if mcpRequest {
data, err := (&service.MysqlService{}).LoadStatusForMCP(c.Request.Context(), req)
if err != nil {
helper.InternalServer(c, err)
return
}
helper.SuccessWithData(c, data)
return
}
data, err := mysqlService.LoadStatus(req) data, err := mysqlService.LoadStatus(req)
if err != nil { if err != nil {
helper.InternalServer(c, err) helper.InternalServer(c, err)
+16
View File
@@ -0,0 +1,16 @@
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) GetImageRemoteCheckStatus(c *gin.Context) {
status, err := service.GetImageRemoteCheckStatus()
if err != nil {
helper.InternalServer(c, err)
return
}
helper.SuccessWithData(c, status)
}
+137
View File
@@ -0,0 +1,137 @@
package v2
import (
"encoding/base64"
"net/http"
"github.com/1Panel-dev/1Panel/agent/app/api/v2/helper"
"github.com/1Panel-dev/1Panel/agent/app/dto"
"github.com/1Panel-dev/1Panel/agent/app/dto/request"
"github.com/1Panel-dev/1Panel/agent/app/model"
"github.com/1Panel-dev/1Panel/agent/app/service"
"github.com/1Panel-dev/1Panel/agent/utils/mcpapi"
"github.com/gin-gonic/gin"
)
type mcpBusinessAPI struct {
installApp func(request.AppInstallCreate, bool) (*model.AppInstall, error)
}
func MCPBusinessDispatch() gin.HandlerFunc {
return (&mcpBusinessAPI{installApp: (&service.AppService{}).InstallForMCP}).dispatch()
}
func (api *mcpBusinessAPI) dispatch() gin.HandlerFunc {
return func(c *gin.Context) {
var handler gin.HandlerFunc
switch c.Request.Method + " " + c.FullPath() {
case "POST /api/v2/databases":
handler = api.createMysql
case "POST /api/v2/databases/status":
handler = api.mysqlStatus
case "GET /api/v2/databases/db/list/:type":
handler = api.databases
case "POST /api/v2/apps/install":
handler = api.install
case "GET /api/v2/apps/installed/info/:appInstallId":
handler = api.appInfo
default:
c.Next()
return
}
present, trusted := mcpapi.Check(c.Request)
if !present {
c.Next()
return
}
if !trusted {
c.AbortWithStatus(http.StatusForbidden)
return
}
handler(c)
c.Abort()
}
}
func (api *mcpBusinessAPI) createMysql(c *gin.Context) {
var req dto.MysqlDBCreate
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
if len(req.Password) != 0 {
password, err := base64.StdEncoding.DecodeString(req.Password)
if err != nil {
helper.BadRequest(c, err)
return
}
req.Password = string(password)
}
created, err := (&service.MysqlService{}).CreateForMCP(c.Request.Context(), req)
if err != nil {
helper.InternalServer(c, err)
return
}
nodeID, _ := mcpapi.NodeID(c.Request)
helper.SuccessWithData(c, map[string]any{"id": created.ID, "database": created.MysqlName, "name": created.Name, "nodeId": nodeID})
}
func (api *mcpBusinessAPI) mysqlStatus(c *gin.Context) {
var req dto.OperationWithNameAndType
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
data, err := (&service.MysqlService{}).LoadStatusForMCP(c.Request.Context(), req)
if err != nil {
helper.InternalServer(c, err)
return
}
helper.SuccessWithData(c, data)
}
func (api *mcpBusinessAPI) databases(c *gin.Context) {
dbType, err := helper.GetStrParamByKey(c, "type")
if err != nil {
helper.BadRequest(c, err)
return
}
list, err := (&service.DatabaseService{}).ListForMCP(c.Request.Context(), dbType)
if err != nil {
helper.BadRequest(c, err)
return
}
helper.SuccessWithData(c, list)
}
func (api *mcpBusinessAPI) install(c *gin.Context) {
var req request.AppInstallCreate
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
if value, exists := req.Params["WEBSITE_DIR"]; exists {
directory, ok := value.(string)
if !ok || directory == "" || directory != settingService.GetWebsiteDir() {
c.AbortWithStatus(http.StatusForbidden)
return
}
}
installed, err := api.installApp(req, true)
if err != nil {
helper.InternalServer(c, err)
return
}
helper.SuccessWithData(c, installed)
}
func (api *mcpBusinessAPI) appInfo(c *gin.Context) {
id, err := helper.GetIntParamByKey(c, "appInstallId")
if err != nil {
helper.BadRequest(c, err)
return
}
info, err := (&service.AppInstallService{}).GetAppInstallInfoForMCP(c.Request.Context(), id)
if err != nil {
helper.InternalServer(c, err)
return
}
helper.SuccessWithData(c, info)
}
+29
View File
@@ -0,0 +1,29 @@
package v2
import (
"net"
"net/http"
"github.com/1Panel-dev/1Panel/agent/constant"
"github.com/1Panel-dev/1Panel/agent/global"
"github.com/1Panel-dev/1Panel/agent/utils/taskscope"
"github.com/gin-gonic/gin"
)
func PanelMCPTaskAccess() gin.HandlerFunc {
return func(c *gin.Context) {
if hasPanelMCPTaskAccess(c.Request) {
c.Request = c.Request.WithContext(taskscope.WithGrants(c.Request.Context(), constant.TaskScopePanelMCP))
}
c.Next()
}
}
func hasPanelMCPTaskAccess(req *http.Request) bool {
if !global.IsMaster {
return false
}
local, ok := req.Context().Value(http.LocalAddrContextKey).(net.Addr)
values := req.Header.Values("X-Panel-MCP-Task-Access")
return ok && local.Network() == "unix" && len(values) == 1 && values[0] == "1"
}
+12 -3
View File
@@ -5,6 +5,8 @@ import (
"github.com/1Panel-dev/1Panel/agent/app/dto" "github.com/1Panel-dev/1Panel/agent/app/dto"
"github.com/1Panel-dev/1Panel/agent/app/dto/request" "github.com/1Panel-dev/1Panel/agent/app/dto/request"
"github.com/1Panel-dev/1Panel/agent/app/dto/response" "github.com/1Panel-dev/1Panel/agent/app/dto/response"
"github.com/1Panel-dev/1Panel/agent/app/service"
"github.com/1Panel-dev/1Panel/agent/utils/taskscope"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
@@ -21,7 +23,7 @@ func (b *BaseApi) PageTasks(c *gin.Context) {
if err := helper.CheckBindAndValidate(&req, c); err != nil { if err := helper.CheckBindAndValidate(&req, c); err != nil {
return return
} }
total, list, err := taskService.Page(req) total, list, err := taskServiceForRequest(c).Page(req)
if err != nil { if err != nil {
helper.InternalServer(c, err) helper.InternalServer(c, err)
return return
@@ -45,7 +47,7 @@ func (b *BaseApi) ReadTaskLogByLine(c *gin.Context) {
return return
} }
var res *response.FileLineContent var res *response.FileLineContent
res, err := taskService.ReadByLine(req) res, err := taskServiceForRequest(c).ReadByLine(req)
if err != nil { if err != nil {
helper.InternalServer(c, err) helper.InternalServer(c, err)
return return
@@ -64,10 +66,17 @@ func (b *BaseApi) ReadTaskLogByLine(c *gin.Context) {
// @Security Timestamp // @Security Timestamp
// @Router /logs/tasks/executing/count [get] // @Router /logs/tasks/executing/count [get]
func (b *BaseApi) CountExecutingTasks(c *gin.Context) { func (b *BaseApi) CountExecutingTasks(c *gin.Context) {
count, err := taskService.CountExecutingTask() count, err := taskServiceForRequest(c).CountExecutingTask()
if err != nil { if err != nil {
helper.InternalServer(c, err) helper.InternalServer(c, err)
return return
} }
helper.SuccessWithData(c, count) helper.SuccessWithData(c, count)
} }
func taskServiceForRequest(c *gin.Context) service.ITaskLogService {
if granted := taskscope.Grants(c.Request.Context()); len(granted) != 0 {
return service.NewITaskService(granted...)
}
return taskService
}
+43 -22
View File
@@ -433,32 +433,53 @@ type AgentFeishuConfigReq struct {
AgentID uint `json:"agentId" validate:"required"` AgentID uint `json:"agentId" validate:"required"`
} }
type AgentFeishuTransition struct {
Confirmed bool `json:"confirmed"`
Streaming bool `json:"streaming"`
GroupSessionScope string `json:"groupSessionScope"`
RequireMention string `json:"requireMention"`
}
type AgentFeishuSwitchReq struct {
AgentID uint `json:"agentId" validate:"required"`
TaskID string `json:"taskID" validate:"required"`
Streaming bool `json:"streaming"`
GroupSessionScope string `json:"groupSessionScope" validate:"required,oneof=group group_sender group_topic group_topic_sender"`
RequireMention string `json:"requireMention" validate:"required,oneof=true false"`
}
type AgentFeishuConfigUpdateReq struct { type AgentFeishuConfigUpdateReq struct {
AgentID uint `json:"agentId" validate:"required"` GroupSessionScope string `json:"groupSessionScope" validate:"omitempty,oneof=group group_sender group_topic group_topic_sender"`
Enabled bool `json:"enabled"` AgentID uint `json:"agentId" validate:"required"`
ThreadSession bool `json:"threadSession"` Enabled bool `json:"enabled"`
ReplyMode string `json:"replyMode" validate:"required"` ThreadSession bool `json:"threadSession"`
Streaming bool `json:"streaming"` ReplyMode string `json:"replyMode" validate:"required"`
RequireMention string `json:"requireMention" validate:"required,oneof=true false open"` Streaming bool `json:"streaming"`
GroupPolicy string `json:"groupPolicy" validate:"required,oneof=open allowlist disabled"` RequireMention string `json:"requireMention" validate:"required,oneof=true false open"`
GroupAllowFrom []string `json:"groupAllowFrom"` GroupPolicy string `json:"groupPolicy" validate:"required,oneof=open allowlist disabled"`
Domain string `json:"domain"` GroupAllowFrom []string `json:"groupAllowFrom"`
ConnectionMode string `json:"connectionMode"` Domain string `json:"domain"`
Bots []AgentFeishuBot `json:"bots" validate:"required,min=1"` ConnectionMode string `json:"connectionMode"`
Bots []AgentFeishuBot `json:"bots" validate:"required,min=1"`
} }
type AgentFeishuConfig struct { type AgentFeishuConfig struct {
Enabled bool `json:"enabled"` BuiltinManaged bool `json:"builtinManaged"`
ThreadSession bool `json:"threadSession"` LegacyConfigured bool `json:"legacyConfigured"`
ReplyMode string `json:"replyMode"` GroupSessionScope string `json:"groupSessionScope"`
Streaming bool `json:"streaming"` BuiltinAvailable bool `json:"builtinAvailable"`
RequireMention string `json:"requireMention"` Legacy bool `json:"legacy"`
GroupPolicy string `json:"groupPolicy"` Enabled bool `json:"enabled"`
GroupAllowFrom []string `json:"groupAllowFrom"` ThreadSession bool `json:"threadSession"`
Domain string `json:"domain"` ReplyMode string `json:"replyMode"`
ConnectionMode string `json:"connectionMode"` Streaming bool `json:"streaming"`
Bots []AgentFeishuBot `json:"bots"` RequireMention string `json:"requireMention"`
Installed bool `json:"installed"` GroupPolicy string `json:"groupPolicy"`
GroupAllowFrom []string `json:"groupAllowFrom"`
Domain string `json:"domain"`
ConnectionMode string `json:"connectionMode"`
Bots []AgentFeishuBot `json:"bots"`
Installed bool `json:"installed"`
} }
type AgentTelegramConfigReq struct { type AgentTelegramConfigReq struct {
-5
View File
@@ -14,11 +14,6 @@ type OllamaModelInfo struct {
CreatedAt time.Time `json:"createdAt"` CreatedAt time.Time `json:"createdAt"`
} }
type OllamaModelDropList struct {
ID uint `json:"id"`
Name string `json:"name"`
}
type OllamaModelName struct { type OllamaModelName struct {
Name string `json:"name"` Name string `json:"name"`
TaskID string `json:"taskID"` TaskID string `json:"taskID"`
+5 -3
View File
@@ -43,9 +43,11 @@ type AppOssConfig struct {
} }
type AppVersion struct { type AppVersion struct {
Version string `json:"version"` FeishuLegacy bool `json:"feishuLegacy,omitempty"`
DetailId uint `json:"detailId"` FeishuConfigured bool `json:"feishuConfigured,omitempty"`
DockerCompose string `json:"dockerCompose"` Version string `json:"version"`
DetailId uint `json:"detailId"`
DockerCompose string `json:"dockerCompose"`
} }
type AppList struct { type AppList struct {
+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"`
+22 -22
View File
@@ -27,8 +27,6 @@ type AppInstallCreate struct {
ModelAccountBaseURLType string `json:"modelAccountBaseURLType" validate:"omitempty,oneof=container localhost systemIP custom"` ModelAccountBaseURLType string `json:"modelAccountBaseURLType" validate:"omitempty,oneof=container localhost systemIP custom"`
ModelAccountBaseURL string `json:"modelAccountBaseURL"` ModelAccountBaseURL string `json:"modelAccountBaseURL"`
MCPPreserveWebsiteDir bool `json:"-"`
AppContainerConfig AppContainerConfig
NodePushConfig NodePushConfig
} }
@@ -89,31 +87,33 @@ type AppBackupDelete struct {
} }
type AppInstalledOperate struct { type AppInstalledOperate struct {
InstallId uint `json:"installId" validate:"required"` FeishuTransition *dto.AgentFeishuTransition `json:"feishuTransition,omitempty"`
BackupId uint `json:"backupId"` InstallId uint `json:"installId" validate:"required"`
DetailId uint `json:"detailId"` BackupId uint `json:"backupId"`
Operate constant.AppOperate `json:"operate" validate:"required"` DetailId uint `json:"detailId"`
ForceDelete bool `json:"forceDelete"` Operate constant.AppOperate `json:"operate" validate:"required"`
DeleteBackup bool `json:"deleteBackup"` ForceDelete bool `json:"forceDelete"`
DeleteDB bool `json:"deleteDB"` DeleteBackup bool `json:"deleteBackup"`
Backup bool `json:"backup"` DeleteDB bool `json:"deleteDB"`
PullImage bool `json:"pullImage"` Backup bool `json:"backup"`
DockerCompose string `json:"dockerCompose"` PullImage bool `json:"pullImage"`
TaskID string `json:"taskID"` DockerCompose string `json:"dockerCompose"`
DeleteImage bool `json:"deleteImage"` TaskID string `json:"taskID"`
Favorite bool `json:"favorite"` DeleteImage bool `json:"deleteImage"`
Favorite bool `json:"favorite"`
UseLifecycleScripts bool `json:"-"` UseLifecycleScripts bool `json:"-"`
} }
type AppInstallUpgrade struct { type AppInstallUpgrade struct {
InstallID uint `json:"installId"` FeishuTransition *dto.AgentFeishuTransition `json:"feishuTransition,omitempty"`
DetailID uint `json:"detailId"` InstallID uint `json:"installId"`
Backup bool `json:"backup"` DetailID uint `json:"detailId"`
PullImage bool `json:"pullImage"` Backup bool `json:"backup"`
DeleteImage bool `json:"deleteImage"` PullImage bool `json:"pullImage"`
DockerCompose string `json:"dockerCompose"` DeleteImage bool `json:"deleteImage"`
TaskID string `json:"taskID"` DockerCompose string `json:"dockerCompose"`
TaskID string `json:"taskID"`
} }
type AppInstallDelete struct { type AppInstallDelete struct {
+14
View File
@@ -0,0 +1,14 @@
package request
type MCPWebsiteBinding struct {
Operation string `json:"operation"`
RequestID string `json:"requestId"`
BindingID string `json:"bindingId"`
WebsiteID uint `json:"websiteId"`
Domain string `json:"domain"`
EnableSSL bool `json:"enableSSL"`
SSLID uint `json:"sslID"`
Upstream string `json:"upstream"`
CoreHost string `json:"coreHost"`
ProxyKey string `json:"proxyKey"`
}
+17
View File
@@ -0,0 +1,17 @@
package response
type MCPWebsiteBinding struct {
State string `json:"state"`
WebsiteID uint `json:"websiteId"`
BindingID string `json:"bindingId"`
RequestID string `json:"requestId"`
Domain string `json:"domain"`
Endpoint string `json:"endpoint"`
EnableSSL bool `json:"enableSSL"`
SSLID uint `json:"sslID"`
WebsiteStatus string `json:"websiteStatus"`
Reason string `json:"reason"`
Code string `json:"code,omitempty"`
Error string `json:"error,omitempty"`
Definitive bool `json:"definitive,omitempty"`
}
@@ -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
@@ -1,6 +1,8 @@
package repo package repo
import ( import (
"context"
"github.com/1Panel-dev/1Panel/agent/app/model" "github.com/1Panel-dev/1Panel/agent/app/model"
"github.com/1Panel-dev/1Panel/agent/global" "github.com/1Panel-dev/1Panel/agent/global"
) )
@@ -14,6 +16,7 @@ type IAiRepo interface {
Create(cronjob *model.OllamaModel) error Create(cronjob *model.OllamaModel) error
Update(id uint, vars map[string]interface{}) error Update(id uint, vars map[string]interface{}) error
Delete(opts ...DBOption) error Delete(opts ...DBOption) error
DeleteAll(ctx context.Context) error
} }
func NewIAiRepo() IAiRepo { func NewIAiRepo() IAiRepo {
@@ -67,3 +70,7 @@ func (u *AiRepo) Delete(opts ...DBOption) error {
} }
return db.Delete(&model.OllamaModel{}).Error return db.Delete(&model.OllamaModel{}).Error
} }
func (u *AiRepo) DeleteAll(ctx context.Context) error {
return getTx(ctx).Where("1 = 1").Delete(&model.OllamaModel{}).Error
}
+7
View File
@@ -38,6 +38,7 @@ type IAppInstallRepo interface {
LoadBaseInfo(key string, name string) (*RootInfo, error) LoadBaseInfo(key string, name string) (*RootInfo, error)
LoadInstallAppByKeyAndName(key string, name string) (*model.AppInstall, error) LoadInstallAppByKeyAndName(key string, name string) (*model.AppInstall, error)
GetFirstByCtx(ctx context.Context, opts ...DBOption) (model.AppInstall, error) GetFirstByCtx(ctx context.Context, opts ...DBOption) (model.AppInstall, error)
GetSnapshot(ctx context.Context, id uint) (model.AppInstall, error)
} }
func NewIAppInstallRepo() IAppInstallRepo { func NewIAppInstallRepo() IAppInstallRepo {
@@ -131,6 +132,12 @@ func (a *AppInstallRepo) GetFirstByCtx(ctx context.Context, opts ...DBOption) (m
return install, err return install, err
} }
func (a *AppInstallRepo) GetSnapshot(ctx context.Context, id uint) (model.AppInstall, error) {
var install model.AppInstall
err := getTx(ctx).WithContext(ctx).Preload("App").Where("id = ?", id).First(&install).Error
return install, err
}
func (a *AppInstallRepo) Create(ctx context.Context, install *model.AppInstall) error { func (a *AppInstallRepo) Create(ctx context.Context, install *model.AppInstall) error {
db := getTx(ctx).Model(&model.AppInstall{}) db := getTx(ctx).Model(&model.AppInstall{})
return db.Omit(clause.Associations).Create(&install).Error return db.Omit(clause.Associations).Create(&install).Error
+29
View File
@@ -6,6 +6,7 @@ import (
"strings" "strings"
"github.com/1Panel-dev/1Panel/agent/app/model" "github.com/1Panel-dev/1Panel/agent/app/model"
"github.com/1Panel-dev/1Panel/agent/constant"
"github.com/1Panel-dev/1Panel/agent/global" "github.com/1Panel-dev/1Panel/agent/global"
"github.com/1Panel-dev/1Panel/agent/utils/encrypt" "github.com/1Panel-dev/1Panel/agent/utils/encrypt"
"gorm.io/gorm" "gorm.io/gorm"
@@ -20,6 +21,8 @@ type IDatabaseRepo interface {
Update(id uint, vars map[string]interface{}) error Update(id uint, vars map[string]interface{}) error
Delete(ctx context.Context, opts ...DBOption) error Delete(ctx context.Context, opts ...DBOption) error
Get(opts ...DBOption) (model.Database, error) Get(opts ...DBOption) (model.Database, error)
ListInstalledLocalMysql(ctx context.Context) ([]InstalledLocalMysql, error)
GetInstalledLocalMysql(ctx context.Context, name string) (InstalledLocalMysql, error)
WithoutByFrom(from string) DBOption WithoutByFrom(from string) DBOption
WithAppInstallID(appInstallID uint) DBOption WithAppInstallID(appInstallID uint) DBOption
WithTypeList(dbType string) DBOption WithTypeList(dbType string) DBOption
@@ -29,6 +32,32 @@ func NewIDatabaseRepo() IDatabaseRepo {
return &DatabaseRepo{} return &DatabaseRepo{}
} }
type InstalledLocalMysql struct {
ID uint
Type, From, Database string
ContainerName, Version, Env string
}
func installedLocalMysqlQuery(ctx context.Context) *gorm.DB {
return getTx(ctx).WithContext(ctx).Table("databases AS d").
Joins("JOIN app_installs AS ai ON ai.id = d.app_install_id AND ai.name = d.name").
Joins("JOIN apps AS a ON a.id = ai.app_id").
Where("d.type = ? AND d.\"from\" = ? AND a.key = ?", constant.AppMysql, "local", constant.AppMysql).
Where("ai.status <> ? AND ai.container_name <> ?", constant.StatusDeleted, "")
}
func (d *DatabaseRepo) ListInstalledLocalMysql(ctx context.Context) ([]InstalledLocalMysql, error) {
result := make([]InstalledLocalMysql, 0)
err := installedLocalMysqlQuery(ctx).Select("d.id, d.type, d.\"from\", d.name AS database, ai.version").Order("d.id").Scan(&result).Error
return result, err
}
func (d *DatabaseRepo) GetInstalledLocalMysql(ctx context.Context, name string) (InstalledLocalMysql, error) {
var result InstalledLocalMysql
err := installedLocalMysqlQuery(ctx).Where("d.name = ?", name).Select("d.id, ai.container_name, ai.version, ai.env").Take(&result).Error
return result, err
}
func (d *DatabaseRepo) Get(opts ...DBOption) (model.Database, error) { func (d *DatabaseRepo) Get(opts ...DBOption) (model.Database, error) {
var database model.Database var database model.Database
db := global.DB db := global.DB
+5
View File
@@ -18,6 +18,7 @@ type IMysqlRepo interface {
List(opts ...DBOption) ([]model.DatabaseMysql, error) List(opts ...DBOption) ([]model.DatabaseMysql, error)
Page(limit, offset int, opts ...DBOption) (int64, []model.DatabaseMysql, error) Page(limit, offset int, opts ...DBOption) (int64, []model.DatabaseMysql, error)
Create(ctx context.Context, mysql *model.DatabaseMysql) error Create(ctx context.Context, mysql *model.DatabaseMysql) error
CreateMetadata(ctx context.Context, mysql *model.DatabaseMysql) error
Delete(ctx context.Context, opts ...DBOption) error Delete(ctx context.Context, opts ...DBOption) error
Update(id uint, vars map[string]interface{}) error Update(id uint, vars map[string]interface{}) error
DeleteLocal(ctx context.Context) error DeleteLocal(ctx context.Context) error
@@ -94,6 +95,10 @@ func (u *MysqlRepo) Create(ctx context.Context, mysql *model.DatabaseMysql) erro
return getTx(ctx).Create(mysql).Error return getTx(ctx).Create(mysql).Error
} }
func (u *MysqlRepo) CreateMetadata(ctx context.Context, mysql *model.DatabaseMysql) error {
return getTx(ctx).WithContext(ctx).Create(mysql).Error
}
func (u *MysqlRepo) Delete(ctx context.Context, opts ...DBOption) error { func (u *MysqlRepo) Delete(ctx context.Context, opts ...DBOption) error {
return getTx(ctx, opts...).Delete(&model.DatabaseMysql{}).Error return getTx(ctx, opts...).Delete(&model.DatabaseMysql{}).Error
} }
+13
View File
@@ -14,8 +14,10 @@ type DatabaseUserRepo struct{}
type IDatabaseUserRepo interface { type IDatabaseUserRepo interface {
Get(opts ...DBOption) (model.DatabaseUser, error) Get(opts ...DBOption) (model.DatabaseUser, error)
GetEncryptedWithContext(ctx context.Context, opts ...DBOption) (model.DatabaseUser, error)
List(opts ...DBOption) ([]model.DatabaseUser, error) List(opts ...DBOption) ([]model.DatabaseUser, error)
Save(user *model.DatabaseUser) error Save(user *model.DatabaseUser) error
SaveEncryptedWithContext(ctx context.Context, user *model.DatabaseUser) error
Delete(opts ...DBOption) error Delete(opts ...DBOption) error
DeleteBy(ctx context.Context, opts ...DBOption) error DeleteBy(ctx context.Context, opts ...DBOption) error
Update(vars map[string]interface{}, opts ...DBOption) error Update(vars map[string]interface{}, opts ...DBOption) error
@@ -45,6 +47,13 @@ func (u *DatabaseUserRepo) Get(opts ...DBOption) (model.DatabaseUser, error) {
return user, nil return user, nil
} }
// GetEncryptedWithContext leaves credential transformation to the caller.
func (u *DatabaseUserRepo) GetEncryptedWithContext(ctx context.Context, opts ...DBOption) (model.DatabaseUser, error) {
var user model.DatabaseUser
err := getTx(ctx, opts...).WithContext(ctx).First(&user).Error
return user, err
}
func (u *DatabaseUserRepo) List(opts ...DBOption) ([]model.DatabaseUser, error) { func (u *DatabaseUserRepo) List(opts ...DBOption) ([]model.DatabaseUser, error) {
var users []model.DatabaseUser var users []model.DatabaseUser
db := global.DB.Model(&model.DatabaseUser{}) db := global.DB.Model(&model.DatabaseUser{})
@@ -75,6 +84,10 @@ func (u *DatabaseUserRepo) Save(user *model.DatabaseUser) error {
return global.DB.Save(user).Error return global.DB.Save(user).Error
} }
func (u *DatabaseUserRepo) SaveEncryptedWithContext(ctx context.Context, user *model.DatabaseUser) error {
return getTx(ctx).WithContext(ctx).Save(user).Error
}
func (u *DatabaseUserRepo) Delete(opts ...DBOption) error { func (u *DatabaseUserRepo) Delete(opts ...DBOption) error {
db := global.DB db := global.DB
for _, opt := range opts { for _, opt := range opts {
+7
View File
@@ -14,6 +14,7 @@ type IDatabaseUserGrantRepo interface {
Get(opts ...DBOption) (model.DatabaseUserGrant, error) Get(opts ...DBOption) (model.DatabaseUserGrant, error)
List(opts ...DBOption) ([]model.DatabaseUserGrant, error) List(opts ...DBOption) ([]model.DatabaseUserGrant, error)
Save(grant *model.DatabaseUserGrant) error Save(grant *model.DatabaseUserGrant) error
CreateIfAbsent(ctx context.Context, grant *model.DatabaseUserGrant) error
Replace(dbType, database string, grants []model.DatabaseUserGrant) error Replace(dbType, database string, grants []model.DatabaseUserGrant) error
Delete(opts ...DBOption) error Delete(opts ...DBOption) error
DeleteBy(ctx context.Context, opts ...DBOption) error DeleteBy(ctx context.Context, opts ...DBOption) error
@@ -52,6 +53,12 @@ func (u *DatabaseUserGrantRepo) Save(grant *model.DatabaseUserGrant) error {
return global.DB.Save(grant).Error return global.DB.Save(grant).Error
} }
func (u *DatabaseUserGrantRepo) CreateIfAbsent(ctx context.Context, grant *model.DatabaseUserGrant) error {
return getTx(ctx).WithContext(ctx).
Where("type = ? AND database = ? AND db_name = ? AND username = ? AND host = ?", grant.Type, grant.Database, grant.DBName, grant.Username, grant.Host).
FirstOrCreate(grant).Error
}
func (u *DatabaseUserGrantRepo) Replace(dbType, database string, grants []model.DatabaseUserGrant) error { func (u *DatabaseUserGrantRepo) Replace(dbType, database string, grants []model.DatabaseUserGrant) error {
return global.DB.Transaction(func(tx *gorm.DB) error { return global.DB.Transaction(func(tx *gorm.DB) error {
if err := tx.Where("`type` = ? AND database = ?", dbType, database).Delete(&model.DatabaseUserGrant{}).Error; err != nil { if err := tx.Where("`type` = ? AND database = ?", dbType, database).Delete(&model.DatabaseUserGrant{}).Error; err != nil {
+19
View File
@@ -15,10 +15,13 @@ type SettingRepo struct{}
type ISettingRepo interface { type ISettingRepo interface {
GetList(opts ...DBOption) ([]model.Setting, error) GetList(opts ...DBOption) ([]model.Setting, error)
Get(opts ...DBOption) (model.Setting, error) Get(opts ...DBOption) (model.Setting, error)
GetWithContext(ctx context.Context, opts ...DBOption) (model.Setting, error)
GetValueByKey(key string) (string, error) GetValueByKey(key string) (string, error)
GetCoreValueByKey(key string) (string, error) GetCoreValueByKey(key string) (string, error)
Create(key, value string) error Create(key, value string) error
CreateWithContext(ctx context.Context, setting *model.Setting) error
Update(key, value string) error Update(key, value string) error
UpdateIfMatch(ctx context.Context, id uint, expectedValue, value string) (int64, error)
WithByKey(key string) DBOption WithByKey(key string) DBOption
UpdateOrCreate(key, value string) error UpdateOrCreate(key, value string) error
@@ -67,6 +70,22 @@ func (s *SettingRepo) Get(opts ...DBOption) (model.Setting, error) {
return settings, err return settings, err
} }
func (s *SettingRepo) GetWithContext(ctx context.Context, opts ...DBOption) (model.Setting, error) {
var setting model.Setting
err := getTx(ctx, opts...).WithContext(ctx).First(&setting).Error
return setting, err
}
func (s *SettingRepo) CreateWithContext(ctx context.Context, setting *model.Setting) error {
return getTx(ctx).WithContext(ctx).Create(setting).Error
}
func (s *SettingRepo) UpdateIfMatch(ctx context.Context, id uint, expectedValue, value string) (int64, error) {
result := getTx(ctx).WithContext(ctx).Model(&model.Setting{}).
Where("id = ? AND value = ?", id, expectedValue).Update("value", value)
return result.RowsAffected, result.Error
}
func (s *SettingRepo) GetValueByKey(key string) (string, error) { func (s *SettingRepo) GetValueByKey(key string) (string, error) {
var setting model.Setting var setting model.Setting
if err := global.DB.Model(&model.Setting{}).Where("key = ?", key).First(&setting).Error; err != nil { if err := global.DB.Model(&model.Setting{}).Where("key = ?", key).First(&setting).Error; err != nil {
+19 -6
View File
@@ -19,8 +19,8 @@ type ITaskRepo interface {
GetFirst(opts ...DBOption) (model.Task, error) GetFirst(opts ...DBOption) (model.Task, error)
Page(page, size int, opts ...DBOption) (int64, []model.Task, error) Page(page, size int, opts ...DBOption) (int64, []model.Task, error)
Update(ctx context.Context, task *model.Task) error Update(ctx context.Context, task *model.Task) error
UpdateRunningTaskToFailed() error UpdateRunningTaskToFailed(opts ...DBOption) error
CountExecutingTask() (int64, error) CountExecutingTask(opts ...DBOption) (int64, error)
Delete(opts ...DBOption) error Delete(opts ...DBOption) error
DeleteAll() error DeleteAll() error
@@ -29,6 +29,7 @@ type ITaskRepo interface {
WithResourceID(id uint) DBOption WithResourceID(id uint) DBOption
WithOperate(taskOperate string) DBOption WithOperate(taskOperate string) DBOption
WithByStatus(status string) DBOption WithByStatus(status string) DBOption
WithoutScopes(scopes ...string) DBOption
} }
func NewITaskRepo() ITaskRepo { func NewITaskRepo() ITaskRepo {
@@ -110,18 +111,30 @@ func (t TaskRepo) Update(ctx context.Context, task *model.Task) error {
return getTaskTx(ctx).Save(&task).Error return getTaskTx(ctx).Save(&task).Error
} }
func (t TaskRepo) UpdateRunningTaskToFailed() error { func (t TaskRepo) UpdateRunningTaskToFailed(opts ...DBOption) error {
return getTaskDb(t.WithByStatus(constant.StatusExecuting)).Model(&model.Task{}).Updates(map[string]interface{}{ opts = append(opts, t.WithByStatus(constant.StatusExecuting))
return getTaskDb(opts...).Model(&model.Task{}).Updates(map[string]interface{}{
"status": constant.StatusFailed, "error_msg": constant.InterruptedMsg, "end_at": time.Now(), "status": constant.StatusFailed, "error_msg": constant.InterruptedMsg, "end_at": time.Now(),
}).Error }).Error
} }
func (t TaskRepo) CountExecutingTask() (int64, error) { func (t TaskRepo) CountExecutingTask(opts ...DBOption) (int64, error) {
var count int64 var count int64
err := getTaskDb(t.WithByStatus(constant.StatusExecuting)).Model(&model.Task{}).Count(&count).Error opts = append(opts, t.WithByStatus(constant.StatusExecuting))
err := getTaskDb(opts...).Model(&model.Task{}).Count(&count).Error
return count, err return count, err
} }
func (t TaskRepo) WithoutScopes(scopes ...string) DBOption {
excluded := append([]string(nil), scopes...)
return func(db *gorm.DB) *gorm.DB {
if len(excluded) == 0 {
return db
}
return db.Where("type IS NULL OR type NOT IN ?", excluded)
}
}
func (t TaskRepo) Delete(opts ...DBOption) error { func (t TaskRepo) Delete(opts ...DBOption) error {
db := global.TaskDB db := global.TaskDB
for _, opt := range opts { for _, opt := range opts {
+5
View File
@@ -88,6 +88,7 @@ type IAgentService interface {
GetFeishuConfig(req dto.AgentFeishuConfigReq) (*dto.AgentFeishuConfig, error) GetFeishuConfig(req dto.AgentFeishuConfigReq) (*dto.AgentFeishuConfig, error)
UpdateFeishuConfig(req dto.AgentFeishuConfigUpdateReq) error UpdateFeishuConfig(req dto.AgentFeishuConfigUpdateReq) error
SwitchFeishuPlugin(req dto.AgentFeishuSwitchReq) error
GetTelegramConfig(req dto.AgentTelegramConfigReq) (*dto.AgentTelegramConfig, error) GetTelegramConfig(req dto.AgentTelegramConfigReq) (*dto.AgentTelegramConfig, error)
UpdateTelegramConfig(req dto.AgentTelegramConfigUpdateReq) error UpdateTelegramConfig(req dto.AgentTelegramConfigUpdateReq) error
GetDiscordConfig(req dto.AgentIDReq) (*dto.AgentDiscordConfig, error) GetDiscordConfig(req dto.AgentIDReq) (*dto.AgentDiscordConfig, error)
@@ -1591,6 +1592,10 @@ func (a AgentService) UpdateConfigFile(req dto.AgentConfigFileUpdateReq) error {
if err != nil { if err != nil {
return err return err
} }
if _, busy := feishuSwitches.LoadOrStore(agent.ConfigPath, true); busy {
return buserr.New("TaskIsExecuting")
}
defer feishuSwitches.Delete(agent.ConfigPath)
if err := validateAgentConfigFileContent(agent.AgentType, req.Content); err != nil { if err := validateAgentConfigFileContent(agent.AgentType, req.Content); err != nil {
return err return err
} }
+38 -3
View File
@@ -45,9 +45,29 @@ func (a AgentService) GetFeishuConfig(req dto.AgentFeishuConfigReq) (*dto.AgentF
if err != nil { if err != nil {
return nil, err return nil, err
} }
if agent.AgentType != constant.AppOpenclaw || !isOpenclawBundledFeishuVersion(install.Version) {
result := extractFeishuConfig(conf)
result.Installed, _ = checkPluginInstalled(install.GetPath(), "feishu")
return &result, nil
}
legacy, configured, err := classifyLegacyFeishu(conf, install.GetPath())
if err != nil {
return nil, err
}
result := extractFeishuConfig(conf) result := extractFeishuConfig(conf)
installed, _ := checkPluginInstalled(install.GetPath(), "feishu") if !legacy {
result.Installed = installed result = extractBundledFeishuConfig(conf)
}
result.BuiltinManaged = true
result.BuiltinAvailable = hasBundledFeishu(install.ContainerName)
result.Installed = result.BuiltinAvailable
result.Legacy, result.LegacyConfigured = legacy, configured
if legacy {
result.GroupSessionScope = "group"
if result.ThreadSession {
result.GroupSessionScope = "group_topic"
}
}
return &result, nil return &result, nil
} }
@@ -72,6 +92,9 @@ func (a AgentService) UpdateFeishuConfig(req dto.AgentFeishuConfigUpdateReq) err
}) })
}) })
} }
if agent.AgentType == constant.AppOpenclaw && isOpenclawBundledFeishuVersion(install.Version) {
return saveBundledFeishuConfig(agent, install, req)
}
return a.mutateAgentConfig(req.AgentID, func(_ *model.Agent, _ *model.AppInstall, conf map[string]interface{}) error { return a.mutateAgentConfig(req.AgentID, func(_ *model.Agent, _ *model.AppInstall, conf map[string]interface{}) error {
config := dto.AgentFeishuConfig{ config := dto.AgentFeishuConfig{
Enabled: req.Enabled, Enabled: req.Enabled,
@@ -358,6 +381,9 @@ func (a AgentService) InstallPlugin(req dto.AgentPluginInstallReq) error {
if err != nil { if err != nil {
return err return err
} }
if req.Type == "feishu" && agent.AgentType == constant.AppOpenclaw && isOpenclawBundledFeishuVersion(install.Version) {
return buserr.New("ErrFeishuManaged")
}
if err := task.CheckScopeTaskIsExecuting(task.TaskScopeAI, req.AgentID); err != nil { if err := task.CheckScopeTaskIsExecuting(task.TaskScopeAI, req.AgentID); err != nil {
return err return err
} }
@@ -403,6 +429,9 @@ func (a AgentService) UpgradePlugin(req dto.AgentPluginUpgradeReq) error {
if err != nil { if err != nil {
return err return err
} }
if req.Type == "feishu" && agent.AgentType == constant.AppOpenclaw && isOpenclawBundledFeishuVersion(install.Version) {
return buserr.New("ErrFeishuManaged")
}
if err := task.CheckScopeTaskIsExecuting(task.TaskScopeAI, req.AgentID); err != nil { if err := task.CheckScopeTaskIsExecuting(task.TaskScopeAI, req.AgentID); err != nil {
return err return err
} }
@@ -443,6 +472,9 @@ func (a AgentService) UninstallPlugin(req dto.AgentPluginUninstallReq) error {
if err != nil { if err != nil {
return err return err
} }
if req.Type == "feishu" && agent.AgentType == constant.AppOpenclaw && isOpenclawBundledFeishuVersion(install.Version) {
return buserr.New("ErrFeishuManaged")
}
if err := task.CheckScopeTaskIsExecuting(task.TaskScopeAI, req.AgentID); err != nil { if err := task.CheckScopeTaskIsExecuting(task.TaskScopeAI, req.AgentID); err != nil {
return err return err
} }
@@ -538,10 +570,13 @@ func (a AgentService) DeleteChannelConfig(req dto.AgentChannelDeleteReq) error {
} }
func (a AgentService) CheckPlugin(req dto.AgentPluginCheckReq) (*dto.AgentPluginStatus, error) { func (a AgentService) CheckPlugin(req dto.AgentPluginCheckReq) (*dto.AgentPluginStatus, error) {
_, install, err := a.loadAgentAndInstall(req.AgentID) agent, install, err := a.loadAgentAndInstall(req.AgentID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
if req.Type == "feishu" && agent.AgentType == constant.AppOpenclaw && isOpenclawBundledFeishuVersion(install.Version) {
return &dto.AgentPluginStatus{Installed: hasBundledFeishu(install.ContainerName)}, nil
}
installed, err := checkPluginInstalled(install.GetPath(), req.Type) installed, err := checkPluginInstalled(install.GetPath(), req.Type)
if err != nil { if err != nil {
return nil, err return nil, err
+677
View File
@@ -0,0 +1,677 @@
package service
import (
"bytes"
"encoding/json"
"errors"
"os"
"path/filepath"
"slices"
"strings"
"sync"
"syscall"
"time"
"github.com/1Panel-dev/1Panel/agent/app/dto"
"github.com/1Panel-dev/1Panel/agent/app/model"
"github.com/1Panel-dev/1Panel/agent/app/task"
"github.com/1Panel-dev/1Panel/agent/buserr"
"github.com/1Panel-dev/1Panel/agent/i18n"
"github.com/1Panel-dev/1Panel/agent/utils/cmd"
"github.com/1Panel-dev/1Panel/agent/utils/common"
"github.com/1Panel-dev/1Panel/agent/utils/compose"
)
// Keep panel configuration writes out of the backup/activation/recovery window.
var feishuSwitches sync.Map
func isOpenclawBundledFeishuVersion(version string) bool {
return common.CompareAppVersion(version, "2026.10.7")
}
func hasBundledFeishu(container string) bool {
image, err := feishuContainerImage(container)
if err != nil {
return false
}
_, err = bundledFeishuImage(image)
return err == nil
}
func usesBundledFeishu(conf map[string]interface{}) bool {
f := getChannelConfig(conf, "feishu")
for _, key := range []string{"threadSession", "replyMode"} {
if _, old := f[key]; old {
return false
}
}
if _, old := f["streaming"].(bool); old {
return false
}
entries := childMap(childMap(conf, "plugins"), "entries")
if extractBoolValue(childMap(entries, "openclaw-lark")["enabled"], false) {
return false
}
_, streaming := f["streaming"].(map[string]interface{})
_, selected := entries["feishu"]
return selected && (extractBoolValue(childMap(entries, "feishu")["enabled"], false) || f["groupSessionScope"] != nil || streaming)
}
func hasLegacyFeishu(conf map[string]interface{}, installPath string) bool {
legacy, _, err := classifyLegacyFeishu(conf, installPath)
return err != nil || legacy
}
func selectBundledFeishu(conf map[string]interface{}, enabled bool) {
plugins := ensureChildMap(conf, "plugins")
entries := ensureChildMap(plugins, "entries")
if legacy, ok := entries["openclaw-lark"]; ok {
if entry, ok := legacy.(map[string]interface{}); ok {
entry["enabled"] = false
}
}
ensureChildMap(entries, "feishu")["enabled"] = enabled
// Preserve an absent allowlist; creating one would disable unrelated plugins.
if allow, ok := plugins["allow"]; ok && len(extractStringList(allow)) > 0 {
ids := slices.DeleteFunc(extractStringList(allow), func(id string) bool { return id == "openclaw-lark" })
if !slices.Contains(ids, "feishu") {
ids = append(ids, "feishu")
}
plugins["allow"] = ids
}
if deny, ok := plugins["deny"]; ok {
plugins["deny"] = slices.DeleteFunc(extractStringList(deny), func(id string) bool { return id == "feishu" })
}
}
func feishuConfigError(field string) error { return buserr.WithDetail("ErrFeishuConfig", field, nil) }
func convertLegacyFeishu(conf map[string]interface{}, req dto.AgentFeishuSwitchReq) error {
f := getChannelConfig(conf, "feishu")
if len(f) == 0 {
if raw, exists := childMap(conf, "channels")["feishu"]; exists && raw != nil {
if _, ok := raw.(map[string]interface{}); !ok {
return feishuConfigError("channels.feishu")
}
}
f = ensureChildMap(ensureChildMap(conf, "channels"), "feishu")
f["enabled"], f["dmPolicy"], f["groupPolicy"] = false, "pairing", "allowlist"
}
if err := checkLegacyFeishuFields(f, "channels.feishu", false); err != nil {
return err
}
if err := convertFeishuAccess(f, "channels.feishu"); err != nil {
return err
}
delete(f, "threadSession")
delete(f, "replyMode")
f["streaming"] = map[string]interface{}{"mode": "off"}
if req.Streaming {
f["streaming"] = map[string]interface{}{"mode": "partial"}
}
f["groupSessionScope"] = req.GroupSessionScope
f["requireMention"] = req.RequireMention == "true"
delete(f, "topicSessionMode")
// Preserve legacy defaults explicitly; bundled defaults are more restrictive.
if _, ok := f["groupPolicy"]; !ok {
f["groupPolicy"] = "open"
}
entries := ensureChildMap(ensureChildMap(conf, "plugins"), "entries")
ensureChildMap(entries, "openclaw-lark")["enabled"] = false
selectBundledFeishu(conf, extractBoolValue(f["enabled"], true))
return nil
}
// Limit automatic conversion to the shared channel fields; custom plugin options require review.
func checkLegacyFeishuFields(f map[string]interface{}, field string, group bool) error {
allowed := strings.Fields("enabled name appId appSecret encryptKey verificationToken domain connectionMode webhookPath defaultAccount dmPolicy allowFrom groupPolicy groupAllowFrom groupSenderAllowFrom requireMention accounts groups streaming threadSession replyMode groupSessionScope topicSessionMode replyInThread")
if group {
allowed = strings.Fields("enabled requireMention allowFrom groupSessionScope topicSessionMode replyInThread")
}
for _, key := range sortedChildKeys(f) {
if !slices.Contains(allowed, key) {
return feishuConfigError(field + "." + key)
}
}
if enabled, exists := f["enabled"]; exists {
if _, ok := enabled.(bool); !ok {
return feishuConfigError(field + ".enabled")
}
}
for _, key := range []string{"dmPolicy", "groupPolicy"} {
if raw, exists := f[key]; exists {
value, ok := raw.(string)
options := []string{"open", "allowlist", "disabled"}
if key == "dmPolicy" {
options = append(options, "pairing")
}
if !ok || !slices.Contains(options, value) {
return feishuConfigError(field + "." + key)
}
}
}
for _, key := range []string{"allowFrom", "groupSenderAllowFrom"} {
if raw, exists := f[key]; exists {
values, ok := raw.([]interface{})
if !ok {
return feishuConfigError(field + "." + key)
}
for _, value := range values {
if _, ok := value.(string); !ok {
return feishuConfigError(field + "." + key)
}
}
}
}
for _, section := range []string{"accounts", "groups"} {
raw, exists := f[section]
if !exists {
continue
}
entries, ok := raw.(map[string]interface{})
if !ok {
return feishuConfigError(field + "." + section)
}
for _, id := range sortedChildKeys(entries) {
entry, ok := entries[id].(map[string]interface{})
name := field + "." + section + "." + id
if !ok {
return feishuConfigError(name)
}
if err := checkLegacyFeishuFields(entry, name, section == "groups"); err != nil {
return err
}
}
}
return nil
}
func convertFeishuAccess(f map[string]interface{}, field string) error {
if raw, ok := f["groupAllowFrom"]; ok {
values, ok := raw.([]interface{})
if !ok {
return feishuConfigError(field + ".groupAllowFrom")
}
groups, senders := []string{}, []string{}
for _, value := range values {
id, ok := value.(string)
if !ok {
return feishuConfigError(field + ".groupAllowFrom")
}
switch {
case strings.HasPrefix(id, "oc_"):
groups = append(groups, id)
case strings.HasPrefix(id, "ou_"):
senders = append(senders, id)
default:
return feishuConfigError(field + ".groupAllowFrom")
}
}
// A sender-only legacy allowlist has different group admission semantics.
if len(senders) > 0 && (len(groups) == 0 || f["groupSenderAllowFrom"] != nil) {
return feishuConfigError(field + ".groupAllowFrom")
}
f["groupAllowFrom"] = groups
if len(senders) > 0 {
f["groupSenderAllowFrom"] = senders
}
}
for _, section := range []string{"accounts", "groups"} {
for id, value := range childMap(f, section) {
entry, ok := value.(map[string]interface{})
name := field + "." + section + "." + id
if !ok {
return feishuConfigError(name)
}
// Account/group overrides must be resolved explicitly, not flattened.
for _, key := range []string{"threadSession", "replyMode"} {
if _, ok := entry[key]; ok {
return feishuConfigError(name + "." + key)
}
}
if mention, ok := entry["requireMention"]; ok {
if _, ok := mention.(bool); !ok {
return feishuConfigError(name + ".requireMention")
}
}
if raw, exists := entry["streaming"]; exists {
streaming, ok := raw.(bool)
if !ok || streaming {
return feishuConfigError(name + ".streaming")
}
entry["streaming"] = map[string]interface{}{"mode": "off"}
}
if err := convertFeishuAccess(entry, name); err != nil {
return err
}
}
}
return nil
}
func extractBundledFeishuConfig(conf map[string]interface{}) dto.AgentFeishuConfig {
result := extractFeishuConfig(conf)
f := getChannelConfig(conf, "feishu")
result.Enabled = extractBoolValue(f["enabled"], true) && extractBoolValue(childMap(childMap(childMap(conf, "plugins"), "entries"), "feishu")["enabled"], true)
result.GroupSessionScope = extractStringValue(f["groupSessionScope"])
if result.GroupSessionScope == "" {
result.GroupSessionScope = "group"
}
result.Streaming = extractStringValue(childMap(f, "streaming")["mode"]) != "off"
result.GroupPolicy = extractStringValue(f["groupPolicy"])
if result.GroupPolicy == "" {
result.GroupPolicy = "allowlist"
}
if len(f) == 0 {
result.Streaming = false
result.RequireMention = "true"
} else if _, ok := f["requireMention"]; !ok {
result.RequireMention = "open"
}
accounts := childMap(f, "accounts")
ids := sortedChildKeys(accounts)
if (f["appId"] != nil || f["appSecret"] != nil) && !slices.Contains(ids, "default") {
ids = append([]string{"default"}, ids...)
}
result.Bots = []dto.AgentFeishuBot{}
for _, id := range ids {
effective := make(map[string]interface{}, len(f))
for k, v := range f {
effective[k] = v
}
for k, v := range childMap(accounts, id) {
effective[k] = v
}
bot := defaultFeishuBot()
bot.AccountID, bot.IsDefault = id, id == "default"
bot.Name = extractDisplayName(childMap(accounts, id), "", id)
bot.Enabled = extractBoolValue(childMap(accounts, id)["enabled"], true)
bot.AppID, bot.AppSecret = extractStringValue(effective["appId"]), extractStringValue(effective["appSecret"])
bot.DmPolicy = extractStringValue(effective["dmPolicy"])
if bot.DmPolicy == "" {
bot.DmPolicy = "pairing"
}
bot.AllowFrom = extractStringList(effective["allowFrom"])
result.Bots = append(result.Bots, bot)
}
return result
}
func setBundledFeishuConfig(conf map[string]interface{}, req dto.AgentFeishuConfigUpdateReq) error {
if !slices.Contains([]string{"group", "group_sender", "group_topic", "group_topic_sender"}, req.GroupSessionScope) {
return feishuConfigError("groupSessionScope")
}
f := ensureChildMap(ensureChildMap(conf, "channels"), "feishu")
accounts := childMap(f, "accounts")
seen := map[string]bool{}
appIDs := map[string]bool{}
for _, bot := range req.Bots {
if bot.AccountID == "" || bot.AccountID != strings.ToLower(strings.TrimSpace(bot.AccountID)) || seen[bot.AccountID] || appIDs[bot.AppID] || bot.AppID == "" || !slices.Contains([]string{"pairing", "open", "allowlist", "disabled"}, bot.DmPolicy) || (bot.DmPolicy == "allowlist" && len(bot.AllowFrom) == 0) {
return feishuConfigError("accounts")
}
seen[bot.AccountID] = true
appIDs[bot.AppID] = true
entry := childMap(accounts, bot.AccountID)
if _, exists := entry["appSecret"]; !exists && bot.AppSecret == "" {
entry["appSecret"] = f["appSecret"]
}
// SecretRef credentials cannot be represented by the form: keep them intact.
if _, secretRef := entry["appSecret"].(map[string]interface{}); !secretRef && bot.AppSecret == "" {
return feishuConfigError("accounts." + bot.AccountID + ".appSecret")
}
entry["appId"], entry["name"], entry["enabled"], entry["dmPolicy"] = bot.AppID, bot.Name, bot.Enabled, bot.DmPolicy
if bot.AppSecret != "" {
entry["appSecret"] = bot.AppSecret
}
switch bot.DmPolicy {
case "open":
entry["allowFrom"] = []string{"*"}
case "allowlist":
entry["allowFrom"] = bot.AllowFrom
default:
if len(bot.AllowFrom) > 0 {
entry["allowFrom"] = bot.AllowFrom
} else {
entry["allowFrom"] = []string{}
}
}
accounts[bot.AccountID] = entry
}
for id := range accounts {
if !seen[id] {
delete(accounts, id)
}
}
if !seen["default"] {
delete(f, "appId")
delete(f, "appSecret")
}
if id := extractStringValue(f["defaultAccount"]); id != "" && !seen[id] {
return feishuConfigError("defaultAccount")
}
if len(accounts) > 0 {
f["accounts"] = accounts
} else {
delete(f, "accounts")
}
f["enabled"], f["groupSessionScope"], f["groupPolicy"] = req.Enabled, req.GroupSessionScope, req.GroupPolicy
if req.RequireMention == "open" {
delete(f, "requireMention")
} else {
f["requireMention"] = req.RequireMention == "true"
}
if req.GroupPolicy == "allowlist" {
f["groupAllowFrom"] = req.GroupAllowFrom
} else {
delete(f, "groupAllowFrom")
}
streaming := ensureChildMap(f, "streaming")
streaming["mode"] = "off"
if req.Streaming {
streaming["mode"] = "partial"
}
selectBundledFeishu(conf, req.Enabled && hasEnabledBots(req.Bots))
return nil
}
// Write beside the original so validation and rename preserve the mounted filesystem and owner.
func stageFeishuConfig(configPath string, payload []byte) (string, error) {
info, err := os.Lstat(configPath)
if err != nil {
return "", err
}
if !info.Mode().IsRegular() {
return "", feishuConfigError("openclaw.json")
}
f, err := os.CreateTemp(filepath.Dir(configPath), ".feishu-*.json")
if err != nil {
return "", err
}
name := f.Name()
defer func() {
f.Close()
if err != nil {
os.Remove(name)
}
}()
if stat, ok := info.Sys().(*syscall.Stat_t); ok {
err = f.Chown(int(stat.Uid), int(stat.Gid))
}
if err == nil {
err = f.Chmod(info.Mode().Perm())
}
if err == nil {
_, err = f.Write(payload)
}
if err == nil {
err = f.Sync()
}
if err == nil {
err = f.Close()
}
return name, err
}
func validateFeishuConfig(container, configPath string) error {
out, err := cmd.RunDockerExecWithStdout(time.Minute, container, "env", "OPENCLAW_CONFIG_PATH=/home/node/.openclaw/"+filepath.Base(configPath), "openclaw", "config", "validate", "--json")
return feishuValidationResult(out, err)
}
func replaceFeishuConfig(configPath string, expected, payload []byte) error {
tmp, err := stageFeishuConfig(configPath, payload)
if err != nil {
return err
}
defer os.Remove(tmp)
current, err := os.ReadFile(configPath)
if err != nil {
return err
}
if !bytes.Equal(current, expected) {
return buserr.New("ErrFeishuConfigChanged")
}
return os.Rename(tmp, configPath)
}
func (a AgentService) SwitchFeishuPlugin(req dto.AgentFeishuSwitchReq) error {
agent, install, err := a.loadOpenclawAgentAndInstall(req.AgentID)
if err != nil {
return err
}
if !isOpenclawBundledFeishuVersion(install.Version) {
return buserr.New("ErrFeishuImageRequired")
}
if _, busy := feishuSwitches.LoadOrStore(agent.ConfigPath, true); busy {
return buserr.New("TaskIsExecuting")
}
started := false
defer func() {
if !started {
feishuSwitches.Delete(agent.ConfigPath)
}
}()
if err := task.CheckScopeTaskIsExecuting(task.TaskScopeAI, req.AgentID); err != nil {
return err
}
if !hasBundledFeishu(install.ContainerName) {
return buserr.New("ErrFeishuImageRequired")
}
conf, err := readOpenclawConfig(agent.ConfigPath)
if err != nil {
return err
}
legacy, configured, err := classifyLegacyFeishu(conf, install.GetPath())
if err != nil {
return err
}
if !legacy {
return buserr.New("ErrFeishuAlreadySwitched")
}
if err := prepareLegacyFeishu(conf, configured, &dto.AgentFeishuTransition{Confirmed: true, Streaming: req.Streaming, GroupSessionScope: req.GroupSessionScope, RequireMention: req.RequireMention}); err != nil {
return err
}
switchTask, err := task.NewTaskWithOps("Feishu", task.TaskUpdate, task.TaskScopeAI, req.TaskID, req.AgentID)
if err != nil {
return err
}
switchTask.AddSubTaskWithAlias("AgentFeishuSwitch", func(t *task.Task) error { return a.runFeishuSwitch(agent, install, req, t) }, nil)
started = true
go func() { defer feishuSwitches.Delete(agent.ConfigPath); executeAgentPluginTask(switchTask) }()
return nil
}
func (a AgentService) runFeishuSwitch(agent *model.Agent, install *model.AppInstall, req dto.AgentFeishuSwitchReq, t *task.Task) (result error) {
original, conf, err := readFeishuConfigBytes(agent.ConfigPath)
if err != nil {
return err
}
legacy, configured, err := classifyLegacyFeishu(conf, install.GetPath())
if err != nil {
return err
}
if !legacy {
return buserr.New("ErrFeishuAlreadySwitched")
}
image, err := feishuContainerImage(install.ContainerName)
if err != nil {
return err
}
image, err = bundledFeishuImage(image)
if err != nil {
return err
}
if err = prepareLegacyFeishu(conf, configured, &dto.AgentFeishuTransition{Confirmed: true, Streaming: req.Streaming, GroupSessionScope: req.GroupSessionScope, RequireMention: req.RequireMention}); err != nil {
return err
}
payload, err := json.MarshalIndent(conf, "", " ")
if err != nil {
return err
}
recovery, err := feishuRecoveryPayload(payload, configured)
if err != nil {
return err
}
// Validate both candidates without invoking Doctor or mutating retained state.
for _, candidate := range [][]byte{payload, recovery} {
staged, stageErr := stageFeishuConfig(agent.ConfigPath, candidate)
if stageErr != nil {
return stageErr
}
validateErr := validateFeishuImageConfig(image, staged)
os.Remove(staged)
if validateErr != nil {
return validateErr
}
}
if _, err = compose.Stop(install.GetComposePath()); err != nil {
return err
}
replaced := false
defer func() {
if !replaced {
if _, startErr := compose.Operate(install.GetComposePath(), "start"); startErr != nil {
result = errors.Join(result, buserr.WithErr("ErrFeishuRestore", startErr))
}
return
}
if result == nil {
return
}
if _, stopErr := compose.Stop(install.GetComposePath()); stopErr != nil {
result = errors.Join(result, buserr.WithErr("ErrFeishuRestore", stopErr))
return
}
if restoreErr := replaceFeishuConfig(agent.ConfigPath, payload, recovery); restoreErr != nil {
result = errors.Join(result, buserr.WithErr("ErrFeishuRestore", restoreErr))
return
}
if _, startErr := compose.Operate(install.GetComposePath(), "start"); startErr != nil {
result = errors.Join(result, buserr.WithErr("ErrFeishuRestore", startErr))
return
}
if healthErr := verifyFeishuActivation(install.ContainerName, false); healthErr != nil {
result = errors.Join(result, buserr.WithErr("ErrFeishuRestore", healthErr))
return
}
t.Log(i18n.GetMsgByKey("AgentFeishuRecoveryDisabled"))
}()
current, err := os.ReadFile(agent.ConfigPath)
if err != nil {
return err
}
if !bytes.Equal(current, original) {
return buserr.New("ErrFeishuConfigChanged")
}
backup, err := backupFeishuConfig(agent.ConfigPath, original)
if err != nil {
return err
}
t.Log(i18n.GetWithName("AgentFeishuBackup", filepath.Base(backup)))
if err = replaceFeishuConfig(agent.ConfigPath, original, payload); err != nil {
return err
}
replaced = true
if _, err = compose.Operate(install.GetComposePath(), "start"); err != nil {
return err
}
enabled := configured && extractBoolValue(getChannelConfig(conf, "feishu")["enabled"], true)
if err = verifyFeishuActivation(install.ContainerName, enabled); err != nil {
return err
}
if enabled {
probeFeishuConnection(install.ContainerName, t)
}
if configured {
t.Log(i18n.GetMsgByKey("AgentFeishuVerifyMessages"))
} else {
t.Log(i18n.GetMsgByKey("AgentFeishuRecoveryDisabled"))
}
return nil
}
func saveBundledFeishuConfig(agent *model.Agent, install *model.AppInstall, req dto.AgentFeishuConfigUpdateReq) error {
if !isOpenclawBundledFeishuVersion(install.Version) {
return buserr.New("ErrFeishuImageRequired")
}
if _, busy := feishuSwitches.LoadOrStore(agent.ConfigPath, true); busy {
return buserr.New("TaskIsExecuting")
}
defer feishuSwitches.Delete(agent.ConfigPath)
if err := task.CheckScopeTaskIsExecuting(task.TaskScopeAI, req.AgentID); err != nil {
return err
}
if !hasBundledFeishu(install.ContainerName) {
return buserr.New("ErrFeishuImageRequired")
}
original, err := os.ReadFile(agent.ConfigPath)
if err != nil {
return err
}
var conf map[string]interface{}
if err = json.Unmarshal(original, &conf); err != nil {
return err
}
if hasLegacyFeishu(conf, install.GetPath()) {
return buserr.New("ErrFeishuSwitchRequired")
}
if err = setBundledFeishuConfig(conf, req); err != nil {
return err
}
payload, err := json.MarshalIndent(conf, "", " ")
if err != nil {
return err
}
staged, err := stageFeishuConfig(agent.ConfigPath, payload)
if err != nil {
return err
}
defer os.Remove(staged)
if err = validateFeishuConfig(install.ContainerName, staged); err != nil {
return err
}
return replaceFeishuConfig(agent.ConfigPath, original, payload)
}
func feishuProbeOK(output string) bool {
raw, err := extractEmbeddedJSON(output)
if err != nil {
return false
}
var status struct {
ChannelAccounts map[string][]struct {
Enabled bool `json:"enabled"`
Configured bool `json:"configured"`
Running bool `json:"running"`
Probe struct {
OK bool `json:"ok"`
} `json:"probe"`
} `json:"channelAccounts"`
}
if json.Unmarshal(raw, &status) != nil {
return false
}
accounts, ok := status.ChannelAccounts["feishu"]
if !ok {
return false
}
for _, account := range accounts {
if account.Enabled && (!account.Configured || !account.Running || !account.Probe.OK) {
return false
}
}
return true
}
func feishuGatewayReady(output string, enabled bool) bool {
raw, err := extractEmbeddedJSON(output)
if err != nil {
return false
}
var health struct {
OK bool `json:"ok"`
Plugins struct {
Loaded []string `json:"loaded"`
} `json:"plugins"`
}
return json.Unmarshal(raw, &health) == nil && health.OK && !slices.Contains(health.Plugins.Loaded, "openclaw-lark") && (!enabled || slices.Contains(health.Plugins.Loaded, "feishu"))
}
+201
View File
@@ -0,0 +1,201 @@
package service
import (
"context"
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"syscall"
"time"
"github.com/1Panel-dev/1Panel/agent/buserr"
"github.com/1Panel-dev/1Panel/agent/utils/cmd"
"github.com/1Panel-dev/1Panel/agent/utils/docker"
"github.com/docker/docker/api/types/container"
)
// Inspect a stopped disposable container; its entrypoint never touches retained state.
func bundledFeishuImage(image string) (string, error) {
cli, err := docker.NewDockerClient()
if err != nil {
return "", err
}
defer cli.Close()
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
info, _, err := cli.ImageInspectWithRaw(ctx, image)
if err != nil {
return "", buserr.New("ErrFeishuImageRequired")
}
created, err := cli.ContainerCreate(ctx, &container.Config{Image: info.ID, Entrypoint: []string{"/bin/true"}}, &container.HostConfig{NetworkMode: "none"}, nil, nil, "")
if err != nil {
return "", err
}
defer func() {
cleanup, done := context.WithTimeout(context.Background(), 10*time.Second)
defer done()
_ = cli.ContainerRemove(cleanup, created.ID, container.RemoveOptions{Force: true, RemoveVolumes: true})
}()
if _, err = cli.ContainerStatPath(ctx, created.ID, "/app/.1panel-feishu-bundled-v1"); err != nil {
return "", buserr.New("ErrFeishuImageRequired")
}
return info.ID, nil
}
func feishuContainerImage(name string) (string, error) {
cli, err := docker.NewDockerClient()
if err != nil {
return "", err
}
defer cli.Close()
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
info, err := cli.ContainerInspect(ctx, name)
if err != nil {
return "", err
}
return info.Image, nil
}
// The original state is read-only and the normal Doctor entrypoint is bypassed.
func validateFeishuImageConfig(image, configPath string) error {
info, err := os.Stat(configPath)
if err != nil {
return err
}
uid, gid := uint32(1000), uint32(1000)
if stat, ok := info.Sys().(*syscall.Stat_t); ok {
uid, gid = stat.Uid, stat.Gid
}
user := fmt.Sprintf("%d:%d", uid, gid)
tmpfs := fmt.Sprintf("/tmp:rw,mode=0700,uid=%d,gid=%d", uid, gid)
name := "1panel-feishu-" + strings.TrimSuffix(filepath.Base(configPath), ".json")
mgr := cmd.NewCommandMgr(cmd.WithTimeout(2 * time.Minute))
defer func() { _ = cmd.NewCommandMgr(cmd.WithTimeout(15*time.Second)).Run("docker", "rm", "-f", "-v", name) }()
out, runErr := mgr.RunWithStdout("docker", "run", "--name", name, "--rm", "--network", "none", "--read-only", "--user", user,
"--tmpfs", tmpfs,
"--mount", "type=bind,src="+filepath.Dir(configPath)+",dst=/home/node/.openclaw,readonly",
"--env", "HOME=/home/node", "--env", "TMPDIR=/tmp", "--env", "OPENCLAW_STATE_DIR=/tmp/state",
"--env", "OPENCLAW_CONFIG_PATH=/home/node/.openclaw/"+filepath.Base(configPath),
"--entrypoint", "sh", image, "-c", "mkdir -m 700 /tmp/state && ln -s /home/node/.openclaw/extensions /tmp/state/extensions && exec node /app/openclaw.mjs config validate --json")
return feishuValidationResult(out, runErr)
}
func feishuValidationResult(out string, runErr error) error {
raw, _ := extractEmbeddedJSON(out)
var result struct {
Valid bool `json:"valid"`
Issues []struct {
Path string `json:"path"`
} `json:"issues"`
}
if json.Unmarshal(raw, &result) == nil && result.Valid && runErr == nil {
return nil
}
fields := []string{}
for _, issue := range result.Issues {
if issue.Path != "" {
fields = append(fields, issue.Path)
}
}
if len(fields) == 0 {
fields = append(fields, "openclaw.json")
}
return feishuConfigError(strings.Join(fields, ", "))
}
func backupFeishuConfig(configPath string, original []byte) (string, error) {
f, err := os.CreateTemp(filepath.Dir(configPath), ".feishu-backup-*.json")
if err != nil {
return "", err
}
if _, err = f.Write(original); err == nil {
err = f.Sync()
}
closeErr := f.Close()
if err != nil {
return "", err
}
if closeErr != nil {
return "", closeErr
}
return f.Name(), nil
}
// Doctor can spend 94 seconds waiting for the previous container's owner lease.
func waitOpenclawReady(name string) error {
script := `const fs=require('fs');const c=JSON.parse(fs.readFileSync('/home/node/.openclaw/openclaw.json','utf8'));const tls=c.gateway?.tls?.enabled===true;const h=require(tls?'https':'http');function check(p){const r=h.get({hostname:'127.0.0.1',port:c.gateway?.port||18789,path:p,rejectUnauthorized:false,timeout:3000},s=>{s.resume();if(s.statusCode===404&&p==='/readyz')return check('/healthz');process.exitCode=s.statusCode===200?0:1});r.on('timeout',()=>r.destroy());r.on('error',()=>process.exitCode=1)}check('/readyz')`
deadline := time.Now().Add(4 * time.Minute)
for time.Now().Before(deadline) {
if cmd.RunDockerExec(8*time.Second, name, "node", "-e", script) == nil {
return nil
}
time.Sleep(2 * time.Second)
}
return buserr.New("ErrFeishuActivation")
}
func verifyFeishuActivation(name string, enabled bool) error {
if err := waitOpenclawReady(name); err != nil {
return err
}
output, err := cmd.RunDockerExecWithStdout(time.Minute, name, "openclaw", "plugins", "list", "--json")
if err != nil {
return buserr.New("ErrFeishuActivation")
}
raw, _ := extractEmbeddedJSON(output)
var result struct {
Plugins []struct {
ID string `json:"id"`
Status string `json:"status"`
Origin string `json:"origin"`
} `json:"plugins"`
}
if json.Unmarshal(raw, &result) != nil {
return buserr.New("ErrFeishuActivation")
}
found := false
for _, p := range result.Plugins {
if p.ID == "openclaw-lark" && p.Status != "disabled" {
return buserr.New("ErrFeishuActivation")
}
if p.ID == "feishu" && p.Origin == "bundled" && (p.Status == "loaded" || (!enabled && p.Status == "disabled")) {
found = true
}
}
if !found {
return buserr.New("ErrFeishuActivation")
}
health, err := cmd.RunDockerExecWithStdout(20*time.Second, name, "openclaw", "gateway", "health", "--json")
if err != nil || !feishuGatewayReady(health, enabled) {
return buserr.New("ErrFeishuActivation")
}
return nil
}
func readFeishuConfigBytes(file string) ([]byte, map[string]interface{}, error) {
info, err := os.Lstat(file)
if err != nil {
return nil, nil, err
}
if !info.Mode().IsRegular() {
return nil, nil, feishuConfigError("openclaw.json")
}
f, err := os.Open(file)
if err != nil {
return nil, nil, err
}
defer f.Close()
raw, err := io.ReadAll(f)
if err != nil {
return nil, nil, err
}
var conf map[string]interface{}
if err = json.Unmarshal(raw, &conf); err != nil || conf == nil {
return nil, nil, feishuConfigError("openclaw.json")
}
return raw, conf, nil
}
+6 -2
View File
@@ -8,11 +8,15 @@ import (
) )
const hermesWeixinLoginScript = `import asyncio const hermesWeixinLoginScript = `import asyncio
from gateway.platforms.weixin import qr_login try:
from plugins.platforms.weixin.adapter import qr_login
except ModuleNotFoundError as exc:
if exc.name not in ('plugins', 'plugins.platforms', 'plugins.platforms.weixin', 'plugins.platforms.weixin.adapter'):
raise
from gateway.platforms.weixin import qr_login
from hermes_cli.config import save_env_value from hermes_cli.config import save_env_value
creds = asyncio.run(qr_login('/opt/data')) creds = asyncio.run(qr_login('/opt/data'))
print('RESULT =', creds)
if not creds: if not creds:
raise SystemExit(1) raise SystemExit(1)
+6
View File
@@ -107,6 +107,9 @@ func (a AgentService) InstallMarketPlugin(req dto.AgentPluginMarketInstallReq) e
if err != nil { if err != nil {
return err return err
} }
if isOpenclawBundledFeishuVersion(install.Version) && (strings.TrimSpace(req.Package) == "@larksuite/openclaw-lark" || strings.TrimSpace(req.Package) == "@openclaw/feishu") {
return buserr.New("ErrFeishuManaged")
}
if err := task.CheckScopeTaskIsExecuting(task.TaskScopeAI, req.AgentID); err != nil { if err := task.CheckScopeTaskIsExecuting(task.TaskScopeAI, req.AgentID); err != nil {
return err return err
} }
@@ -132,6 +135,9 @@ func (a AgentService) OperatePlugin(req dto.AgentPluginOperateReq) error {
if err != nil { if err != nil {
return err return err
} }
if isOpenclawBundledFeishuVersion(install.Version) && (req.PluginID == "openclaw-lark" || req.PluginID == "feishu") {
return buserr.New("ErrFeishuManaged")
}
if err := task.CheckScopeTaskIsExecuting(task.TaskScopeAI, req.AgentID); err != nil { if err := task.CheckScopeTaskIsExecuting(task.TaskScopeAI, req.AgentID); err != nil {
return err return err
} }
+4
View File
@@ -148,6 +148,10 @@ func readOpenclawConfig(configPath string) (map[string]interface{}, error) {
} }
func writeOpenclawConfigRaw(configPath string, conf map[string]interface{}) error { func writeOpenclawConfigRaw(configPath string, conf map[string]interface{}) error {
if _, busy := feishuSwitches.LoadOrStore(configPath, true); busy {
return buserr.New("TaskIsExecuting")
}
defer feishuSwitches.Delete(configPath)
ensureGatewaySecurityDefaults(conf) ensureGatewaySecurityDefaults(conf)
ensureOpenclawUpdateDefaults(conf) ensureOpenclawUpdateDefaults(conf)
payload, err := json.MarshalIndent(conf, "", " ") payload, err := json.MarshalIndent(conf, "", " ")
+85 -53
View File
@@ -8,6 +8,7 @@ import (
"os/exec" "os/exec"
"path" "path"
"strings" "strings"
"sync"
"syscall" "syscall"
"time" "time"
@@ -28,13 +29,14 @@ import (
type AIToolService struct{} type AIToolService struct{}
var ollamaModelMu sync.Mutex
type IAIToolService interface { type IAIToolService interface {
Search(search dto.SearchWithPage) (int64, []dto.OllamaModelInfo, error) Search(search dto.SearchWithPage) (int64, []dto.OllamaModelInfo, error)
Create(req dto.OllamaModelName) error Create(req dto.OllamaModelName) error
Close(name string) error Close(name string) error
Recreate(req dto.OllamaModelName) error Recreate(req dto.OllamaModelName) error
Delete(req dto.ForceDelete) error Delete(req dto.ForceDelete) error
Sync() ([]dto.OllamaModelDropList, error)
SyncAccount(req dto.OllamaModelAccountSync) error SyncAccount(req dto.OllamaModelAccountSync) error
LoadDetail(name string) (string, error) LoadDetail(name string) (string, error)
BindDomain(req dto.OllamaBindDomain) error BindDomain(req dto.OllamaBindDomain) error
@@ -47,6 +49,7 @@ func NewIAIToolService() IAIToolService {
} }
func (u *AIToolService) Search(req dto.SearchWithPage) (int64, []dto.OllamaModelInfo, error) { func (u *AIToolService) Search(req dto.SearchWithPage) (int64, []dto.OllamaModelInfo, error) {
syncErr := syncOllamaModels()
var options []repo.DBOption var options []repo.DBOption
if len(req.Info) != 0 { if len(req.Info) != 0 {
options = append(options, repo.WithByLikeName(req.Info)) options = append(options, repo.WithByLikeName(req.Info))
@@ -61,6 +64,11 @@ func (u *AIToolService) Search(req dto.SearchWithPage) (int64, []dto.OllamaModel
if err := copier.Copy(&item, &itemModel); err != nil { if err := copier.Copy(&item, &itemModel); err != nil {
return 0, nil, buserr.WithDetail("ErrStructTransform", err.Error(), nil) return 0, nil, buserr.WithDetail("ErrStructTransform", err.Error(), nil)
} }
if syncErr != nil && item.Status != constant.StatusWaiting {
item.Status = constant.StatusUnknown
item.Message = syncErr.Error()
item.Size = ""
}
taskModel, _ := taskRepo.GetFirst(taskRepo.WithResourceID(item.ID), repo.WithByType(task.TaskScopeAI)) taskModel, _ := taskRepo.GetFirst(taskRepo.WithResourceID(item.ID), repo.WithByType(task.TaskScopeAI))
if len(taskModel.ID) != 0 { if len(taskModel.ID) != 0 {
item.LogFileExist = true item.LogFileExist = true
@@ -86,6 +94,8 @@ func (u *AIToolService) LoadDetail(name string) (string, error) {
} }
func (u *AIToolService) Create(req dto.OllamaModelName) error { func (u *AIToolService) Create(req dto.OllamaModelName) error {
ollamaModelMu.Lock()
defer ollamaModelMu.Unlock()
if cmd.CheckIllegal(req.Name) { if cmd.CheckIllegal(req.Name) {
return buserr.New("ErrCmdIllegal") return buserr.New("ErrCmdIllegal")
} }
@@ -148,6 +158,8 @@ func (u *AIToolService) Close(name string) error {
} }
func (u *AIToolService) Recreate(req dto.OllamaModelName) error { func (u *AIToolService) Recreate(req dto.OllamaModelName) error {
ollamaModelMu.Lock()
defer ollamaModelMu.Unlock()
if cmd.CheckIllegal(req.Name) { if cmd.CheckIllegal(req.Name) {
return buserr.New("ErrCmdIllegal") return buserr.New("ErrCmdIllegal")
} }
@@ -191,6 +203,8 @@ func (u *AIToolService) Recreate(req dto.OllamaModelName) error {
} }
func (u *AIToolService) Delete(req dto.ForceDelete) error { func (u *AIToolService) Delete(req dto.ForceDelete) error {
ollamaModelMu.Lock()
defer ollamaModelMu.Unlock()
ollamaList, _ := aiRepo.List(repo.WithByIDs(req.IDs)) ollamaList, _ := aiRepo.List(repo.WithByIDs(req.IDs))
if len(ollamaList) == 0 { if len(ollamaList) == 0 {
return buserr.New("ErrRecordNotFound") return buserr.New("ErrRecordNotFound")
@@ -212,54 +226,6 @@ func (u *AIToolService) Delete(req dto.ForceDelete) error {
return SyncOllamaModelAccount(false) return SyncOllamaModelAccount(false)
} }
func (u *AIToolService) Sync() ([]dto.OllamaModelDropList, error) {
containerName, err := LoadContainerName()
if err != nil {
return nil, err
}
stdout, err := cmd.RunDockerExecWithStdout(20*time.Second, containerName, "ollama", "list")
if err != nil {
return nil, err
}
var list []model.OllamaModel
lines := strings.Split(stdout, "\n")
for _, line := range lines {
parts := strings.Fields(line)
if len(parts) < 5 {
continue
}
if parts[0] == "NAME" {
continue
}
list = append(list, model.OllamaModel{Name: parts[0], Size: parts[2] + " " + parts[3]})
}
listInDB, _ := aiRepo.List()
var dropList []dto.OllamaModelDropList
for _, itemModel := range listInDB {
isExit := false
for i := 0; i < len(list); i++ {
if list[i].Name == itemModel.Name {
_ = aiRepo.Update(itemModel.ID, map[string]interface{}{"status": constant.StatusSuccess, "message": "", "size": list[i].Size})
list = append(list[:i], list[(i+1):]...)
isExit = true
break
}
}
if !isExit && itemModel.Status != constant.StatusWaiting {
_ = aiRepo.Update(itemModel.ID, map[string]interface{}{"status": constant.StatusDeleted, "message": "not exist", "size": ""})
dropList = append(dropList, dto.OllamaModelDropList{ID: itemModel.ID, Name: itemModel.Name})
continue
}
}
for _, item := range list {
item.Status = constant.StatusSuccess
item.From = "remote"
_ = aiRepo.Create(&item)
}
return dropList, SyncOllamaModelAccount(false)
}
func (u *AIToolService) SyncAccount(req dto.OllamaModelAccountSync) error { func (u *AIToolService) SyncAccount(req dto.OllamaModelAccountSync) error {
install, err := appInstallRepo.GetFirst(repo.WithByID(req.ID)) install, err := appInstallRepo.GetFirst(repo.WithByID(req.ID))
if err != nil { if err != nil {
@@ -398,6 +364,72 @@ func (u *AIToolService) UpdateBindDomain(req dto.OllamaBindDomain) error {
return nil return nil
} }
func syncOllamaModels() error {
ollamaModelMu.Lock()
defer ollamaModelMu.Unlock()
containerName, err := LoadContainerName()
if err != nil {
return err
}
stdout, err := cmd.RunDockerExecWithStdout(20*time.Second, containerName, "ollama", "list")
if err != nil {
return err
}
models := make(map[string]string)
lines := strings.Split(strings.TrimSpace(stdout), "\n")
if len(lines) == 0 || !strings.HasPrefix(strings.TrimSpace(lines[0]), "NAME") {
return fmt.Errorf("invalid ollama list output: %s", stdout)
}
for _, line := range lines[1:] {
if strings.TrimSpace(line) == "" {
continue
}
parts := strings.Fields(line)
if len(parts) < 5 {
return fmt.Errorf("invalid ollama model entry: %s", line)
}
models[parts[0]] = parts[2] + " " + parts[3]
}
list, err := aiRepo.List()
if err != nil {
return err
}
known := make(map[string]bool, len(list))
for _, item := range list {
name := item.Name
if !strings.Contains(path.Base(name), ":") {
name += ":latest"
}
size, exists := models[name]
known[name] = true
if item.Status == constant.StatusWaiting {
continue
}
status, message := constant.StatusSuccess, ""
if !exists {
if item.Status == constant.StatusFailed || item.Status == constant.StatusCanceled {
continue
}
status, message = constant.StatusDeleted, "not exist"
}
if item.Status != status || item.Size != size || item.Message != message {
if err := aiRepo.Update(item.ID, map[string]interface{}{"status": status, "size": size, "message": message}); err != nil {
return err
}
}
}
for name, size := range models {
if known[name] {
continue
}
item := model.OllamaModel{Name: name, Size: size, From: "remote", Status: constant.StatusSuccess}
if err := aiRepo.Create(&item); err != nil {
return err
}
}
return nil
}
func LoadContainerName() (string, error) { func LoadContainerName() (string, error) {
ollamaBaseInfo, err := appInstallRepo.LoadBaseInfo("ollama", "") ollamaBaseInfo, err := appInstallRepo.LoadBaseInfo("ollama", "")
if err != nil { if err != nil {
@@ -415,12 +447,12 @@ func loadModelSize(name string, containerName string) (string, error) {
return "", err return "", err
} }
lines := strings.Split(stdout, "\n") lines := strings.Split(stdout, "\n")
if !strings.Contains(path.Base(name), ":") {
name += ":latest"
}
for _, line := range lines { for _, line := range lines {
if !strings.Contains(line, name) {
continue
}
parts := strings.Fields(line) parts := strings.Fields(line)
if len(parts) < 5 { if len(parts) < 5 || parts[0] != name {
continue continue
} }
return parts[2] + " " + parts[3], nil return parts[2] + " " + parts[3], nil
+3 -2
View File
@@ -59,7 +59,8 @@ type IAppService interface {
} }
type appInstallHooks struct { type appInstallHooks struct {
AfterCopyData func(appInstall *model.AppInstall) error AfterCopyData func(appInstall *model.AppInstall) error
PrepareSiteDir func(model.App, model.AppDetail, request.AppInstallCreate, *task.Task) error
} }
func NewIAppService() IAppService { func NewIAppService() IAppService {
@@ -614,7 +615,7 @@ func (a AppService) installWithHooks(req request.AppInstallCreate, executeScript
} }
} }
if app.Key == "openresty" { if app.Key == "openresty" {
if err = handleSiteDir(app, appDetail, req, t); err != nil { if err = prepareAppInstallSiteDirectory(app, appDetail, req, t, hooks); err != nil {
return err return err
} }
if err = handleOpenrestyFile(appInstall); err != nil { if err = handleOpenrestyFile(appInstall); err != nil {
+26 -10
View File
@@ -305,13 +305,14 @@ func (a *AppInstallService) Operate(req request.AppInstalledOperate) error {
return syncAppInstallStatus(&install, true) return syncAppInstallStatus(&install, true)
case constant.Upgrade: case constant.Upgrade:
upgradeReq := request.AppInstallUpgrade{ upgradeReq := request.AppInstallUpgrade{
InstallID: install.ID, FeishuTransition: req.FeishuTransition,
DetailID: req.DetailId, InstallID: install.ID,
Backup: req.Backup, DetailID: req.DetailId,
PullImage: req.PullImage, Backup: req.Backup,
DeleteImage: req.DeleteImage, PullImage: req.PullImage,
DockerCompose: req.DockerCompose, DeleteImage: req.DeleteImage,
TaskID: req.TaskID, DockerCompose: req.DockerCompose,
TaskID: req.TaskID,
} }
return upgradeInstall(upgradeReq) return upgradeInstall(upgradeReq)
case constant.Reload: case constant.Reload:
@@ -716,6 +717,7 @@ func (a *AppInstallService) GetUpdateVersions(req request.AppUpdateVersion) ([]d
if err != nil { if err != nil {
return versions, err return versions, err
} }
var feishuLegacy, feishuConfigured, feishuChecked bool
details, err := appDetailRepo.GetBy(appDetailRepo.WithAppId(app.ID)) details, err := appDetailRepo.GetBy(appDetailRepo.WithAppId(app.ID))
if err != nil { if err != nil {
return versions, err return versions, err
@@ -740,6 +742,18 @@ func (a *AppInstallService) GetUpdateVersions(req request.AppUpdateVersion) ([]d
canUpgrade = isVllmUpgradeCandidate(install.Version, detail.Version, currentImage) canUpgrade = isVllmUpgradeCandidate(install.Version, detail.Version, currentImage)
} }
if canUpgrade { if canUpgrade {
feishuTarget := app.Key == constant.AppOpenclaw && isOpenclawBundledFeishuVersion(detail.Version)
if feishuTarget && !feishuChecked {
conf, readErr := readOpenclawConfig(path.Join(install.GetPath(), "data/conf/openclaw.json"))
if readErr != nil {
return versions, readErr
}
feishuLegacy, feishuConfigured, err = classifyLegacyFeishu(conf, install.GetPath())
if err != nil {
return versions, err
}
feishuChecked = true
}
var newCompose string var newCompose string
if req.UpdateVersion != "" && req.UpdateVersion == detail.Version && detail.DockerCompose == "" && !app.IsLocalApp() { if req.UpdateVersion != "" && req.UpdateVersion == detail.Version && detail.DockerCompose == "" && !app.IsLocalApp() {
filename := filepath.Base(detail.DownloadUrl) filename := filepath.Base(detail.DownloadUrl)
@@ -774,9 +788,11 @@ func (a *AppInstallService) GetUpdateVersions(req request.AppUpdateVersion) ([]d
} }
} }
versions = append(versions, dto.AppVersion{ versions = append(versions, dto.AppVersion{
Version: detail.Version, FeishuLegacy: feishuTarget && feishuLegacy,
DetailId: detail.ID, FeishuConfigured: feishuTarget && feishuConfigured,
DockerCompose: newCompose, Version: detail.Version,
DetailId: detail.ID,
DockerCompose: newCompose,
}) })
} }
} }
+10 -44
View File
@@ -1,53 +1,19 @@
package service package service
import ( import (
"errors"
"os"
"path"
"github.com/1Panel-dev/1Panel/agent/app/dto/request" "github.com/1Panel-dev/1Panel/agent/app/dto/request"
"github.com/1Panel-dev/1Panel/agent/app/model" "github.com/1Panel-dev/1Panel/agent/app/model"
"github.com/1Panel-dev/1Panel/agent/constant" "github.com/1Panel-dev/1Panel/agent/app/task"
"github.com/1Panel-dev/1Panel/agent/global"
"gorm.io/gorm"
) )
func prepareMCPWebsiteDirectory(req request.AppInstallCreate) error { func (a AppService) InstallForMCP(req request.AppInstallCreate, executeScript bool) (*model.AppInstall, error) {
changed := errors.New("MCP website directory changed; inspect the installation before retrying") return a.installWithHooks(req, executeScript, &appInstallHooks{PrepareSiteDir: prepareMCPInstallSiteDirectory})
directory, ok := req.Params["WEBSITE_DIR"].(string) }
if !ok || directory == "" || global.DB == nil {
return changed func prepareMCPInstallSiteDirectory(app model.App, detail model.AppDetail, req request.AppInstallCreate, _ *task.Task) error {
if !managesOpenrestySiteDirectory(app, detail) {
return nil
} }
return global.DB.Transaction(func(tx *gorm.DB) error { directory, _ := req.Params["WEBSITE_DIR"].(string)
var setting model.Setting return ensureCurrentWebsiteDirectory(directory)
err := tx.Where("key = ?", "WEBSITE_DIR").First(&setting).Error
missing := errors.Is(err, gorm.ErrRecordNotFound)
if err != nil && !missing {
return err
}
current := setting.Value
if current == "" {
current = path.Join(global.Dir.BaseDir, "1panel", "www")
}
if directory != current {
return changed
}
if missing {
if err := tx.Create(&model.Setting{Key: "WEBSITE_DIR", Value: directory}).Error; err != nil {
return err
}
} else if setting.Value == "" {
result := tx.Model(&model.Setting{}).Where("id = ? AND value = ?", setting.ID, "").Update("value", directory)
if result.Error != nil {
return result.Error
}
if result.RowsAffected != 1 {
return changed
}
}
if err := os.MkdirAll(path.Join(directory, "conf.d"), constant.DirPerm); err != nil {
return err
}
return nil
})
} }
+49
View File
@@ -0,0 +1,49 @@
package service
import (
"context"
"errors"
"os"
"path"
"github.com/1Panel-dev/1Panel/agent/app/model"
"github.com/1Panel-dev/1Panel/agent/constant"
"github.com/1Panel-dev/1Panel/agent/global"
"gorm.io/gorm"
)
func ensureCurrentWebsiteDirectory(directory string) error {
changed := errors.New("website directory changed; inspect the installation before retrying")
if directory == "" || global.DB == nil {
return changed
}
return global.DB.Transaction(func(tx *gorm.DB) error {
ctx := context.WithValue(tx.Statement.Context, constant.DB, tx)
setting, err := settingRepo.GetWithContext(ctx, settingRepo.WithByKey("WEBSITE_DIR"))
missing := errors.Is(err, gorm.ErrRecordNotFound)
if err != nil && !missing {
return err
}
current := setting.Value
if current == "" {
current = path.Join(global.Dir.BaseDir, "1panel", "www")
}
if directory != current {
return changed
}
if missing {
if err := settingRepo.CreateWithContext(ctx, &model.Setting{Key: "WEBSITE_DIR", Value: directory}); err != nil {
return err
}
} else if setting.Value == "" {
updated, err := settingRepo.UpdateIfMatch(ctx, setting.ID, "", directory)
if err != nil {
return err
}
if updated != 1 {
return changed
}
}
return os.MkdirAll(path.Join(directory, "conf.d"), constant.DirPerm)
})
}
+74 -4
View File
@@ -60,10 +60,16 @@ type upgradeFileSnapshot struct {
} }
type appUpgradeContext struct { type appUpgradeContext struct {
req request.AppInstallUpgrade openclawOriginal []byte
original model.AppInstall feishuOriginal []byte
candidate model.AppInstall feishuImage string
detail model.AppDetail feishuOldImage string
feishuEnabled bool
feishuLockPath string
req request.AppInstallUpgrade
original model.AppInstall
candidate model.AppInstall
detail model.AppDetail
phase appUpgradePhase phase appUpgradePhase
stopAttempted bool stopAttempted bool
@@ -102,6 +108,19 @@ func upgradeInstall(req request.AppInstallUpgrade) error {
} }
}() }()
feishuLockPath := ""
if install.App.Key == constant.AppOpenclaw {
feishuLockPath = path.Join(install.GetPath(), "data/conf/openclaw.json")
if _, busy := feishuSwitches.LoadOrStore(feishuLockPath, true); busy {
return buserr.New("TaskIsExecuting")
}
defer func() {
if releaseLock {
feishuSwitches.Delete(feishuLockPath)
}
}()
}
detail, err := appDetailRepo.GetFirst(repo.WithByID(req.DetailID)) detail, err := appDetailRepo.GetFirst(repo.WithByID(req.DetailID))
if err != nil { if err != nil {
return err return err
@@ -129,6 +148,7 @@ func upgradeInstall(req request.AppInstallUpgrade) error {
return err return err
} }
ctx := &appUpgradeContext{ ctx := &appUpgradeContext{
feishuLockPath: feishuLockPath,
req: req, req: req,
original: install, original: install,
candidate: install, candidate: install,
@@ -222,6 +242,10 @@ func (u *appUpgradeContext) prepare(t *task.Task) error {
if err = copyUpgradeStageFile(u.original.GetPath(), u.stageDir, path.Join("data", "conf", "openclaw.json")); err != nil { if err = copyUpgradeStageFile(u.original.GetPath(), u.stageDir, path.Join("data", "conf", "openclaw.json")); err != nil {
return err return err
} }
u.openclawOriginal, err = os.ReadFile(path.Join(u.stageDir, "data/conf/openclaw.json"))
if err != nil {
return err
}
} }
if u.original.App.Key == constant.AppOpenresty { if u.original.App.Key == constant.AppOpenresty {
for _, relativePath := range []string{ for _, relativePath := range []string{
@@ -330,6 +354,9 @@ func (u *appUpgradeContext) prepare(t *task.Task) error {
if u.original.App.Resource == constant.AppResourceRemote { if u.original.App.Resource == constant.AppResourceRemote {
go RequestDownloadCallBack(u.detail.DownloadCallBackUrl) go RequestDownloadCallBack(u.detail.DownloadCallBackUrl)
} }
if err = u.prepareFeishuUpgrade(); err != nil {
return err
}
u.phase = appUpgradePrepared u.phase = appUpgradePrepared
return nil return nil
} }
@@ -387,6 +414,9 @@ func (u *appUpgradeContext) cutover(t *task.Task) error {
} }
t.LogSuccess(i18n.GetMsgByKey("UpgradeStop")) t.LogSuccess(i18n.GetMsgByKey("UpgradeStop"))
u.phase = appUpgradeStopped u.phase = appUpgradeStopped
if err := u.checkFeishuUpgradeSource(); err != nil {
return err
}
var err error var err error
if u.original.App.Key == constant.AppOpenresty { if u.original.App.Key == constant.AppOpenresty {
@@ -454,6 +484,9 @@ func (u *appUpgradeContext) cutover(t *task.Task) error {
} else { } else {
t.Log("WARNING: no containers found for the upgraded application") t.Log("WARNING: no containers found for the upgraded application")
} }
if err = u.verifyFeishuUpgrade(t); err != nil {
return err
}
u.candidate.Status = constant.StatusRunning u.candidate.Status = constant.StatusRunning
u.candidate.Message = "" u.candidate.Message = ""
@@ -579,8 +612,34 @@ func (u *appUpgradeContext) rollback(t *task.Task) (rollbackErr error) {
if out != "" { if out != "" {
err = fmt.Errorf("%s: %w", out, err) err = fmt.Errorf("%s: %w", out, err)
} }
if u.feishuOriginal != nil {
return err
}
rollbackErr = err rollbackErr = err
} }
if u.feishuOriginal != nil {
if u.backupFile == "" {
return buserr.New("ErrFeishuUpgradeBackup")
}
if err := u.restoreManagedFiles(); err != nil {
return err
}
if err := handleAppRecover(&u.original, t, u.backupFile, true, "", "", false); err != nil {
return err
}
if err := pinFeishuRollbackImage(u.original.GetComposePath(), u.feishuOldImage); err != nil {
return err
}
restoredCompose, err := os.ReadFile(u.original.GetComposePath())
if err != nil {
return err
}
u.original.DockerCompose = string(restoredCompose)
if _, err := compose.UpWithoutBuild(u.original.GetComposePath()); err != nil {
return err
}
return u.finishRollback()
}
if u.backupFile != "" { if u.backupFile != "" {
_ = u.restoreManagedFiles() _ = u.restoreManagedFiles()
if err := handleAppRecover(&u.original, t, u.backupFile, true, "", ""); err != nil { if err := handleAppRecover(&u.original, t, u.backupFile, true, "", ""); err != nil {
@@ -602,6 +661,11 @@ func (u *appUpgradeContext) rollback(t *task.Task) (rollbackErr error) {
} }
func (u *appUpgradeContext) finishRollback() error { func (u *appUpgradeContext) finishRollback() error {
if u.feishuOriginal != nil {
if err := waitOpenclawReady(u.original.ContainerName); err != nil {
return err
}
}
restored := u.original restored := u.original
if err := appInstallRepo.Save(context.Background(), &restored); err != nil { if err := appInstallRepo.Save(context.Background(), &restored); err != nil {
return err return err
@@ -643,6 +707,12 @@ func (u *appUpgradeContext) deleteOldImages(t *task.Task) {
} }
func (u *appUpgradeContext) cleanup() { func (u *appUpgradeContext) cleanup() {
if u.feishuLockPath != "" {
feishuSwitches.Delete(u.feishuLockPath)
}
if u.feishuOriginal != nil && u.rollbackErr != nil {
return
}
if u.snapshot != nil { if u.snapshot != nil {
u.snapshot.Cleanup() u.snapshot.Cleanup()
} }
+336
View File
@@ -0,0 +1,336 @@
package service
import (
"bytes"
"context"
"encoding/json"
"os"
"path/filepath"
"strings"
"time"
"github.com/1Panel-dev/1Panel/agent/app/dto"
"github.com/1Panel-dev/1Panel/agent/app/task"
"github.com/1Panel-dev/1Panel/agent/buserr"
"github.com/1Panel-dev/1Panel/agent/constant"
"github.com/1Panel-dev/1Panel/agent/i18n"
"github.com/1Panel-dev/1Panel/agent/utils/cmd"
"github.com/1Panel-dev/1Panel/agent/utils/docker"
"gopkg.in/yaml.v3"
)
func classifyLegacyFeishu(conf map[string]interface{}, installPath string) (legacy, configured bool, err error) {
for _, field := range []string{"channels", "channels.feishu", "plugins", "plugins.entries", "plugins.entries.openclaw-lark", "plugins.entries.feishu"} {
parts := strings.Split(field, ".")
parent := conf
for _, part := range parts[:len(parts)-1] {
parent = childMap(parent, part)
}
if raw, exists := parent[parts[len(parts)-1]]; exists {
if _, ok := raw.(map[string]interface{}); !ok {
return false, false, feishuConfigError(field)
}
}
}
plugins := childMap(conf, "plugins")
for _, key := range []string{"allow", "deny"} {
if raw, exists := plugins[key]; exists {
if _, ok := raw.([]string); ok {
continue
}
ids, ok := raw.([]interface{})
if !ok {
return false, false, feishuConfigError("plugins." + key)
}
for _, id := range ids {
if _, ok := id.(string); !ok {
return false, false, feishuConfigError("plugins." + key)
}
}
}
}
for _, id := range []string{"openclaw-lark", "feishu"} {
if raw, exists := childMap(childMap(plugins, "entries"), id)["enabled"]; exists {
if _, ok := raw.(bool); !ok {
return false, false, feishuConfigError("plugins.entries." + id + ".enabled")
}
}
}
if usesBundledFeishu(conf) {
return false, false, nil
}
f := getChannelConfig(conf, "feishu")
configured = len(f) > 0
if raw, exists := conf["bindings"]; exists {
bindings, ok := raw.([]interface{})
if !ok {
return false, false, feishuConfigError("bindings")
}
for _, raw := range bindings {
binding, ok := raw.(map[string]interface{})
if !ok {
return false, false, feishuConfigError("bindings")
}
if match, exists := binding["match"]; exists {
if _, ok := match.(map[string]interface{}); !ok {
return false, false, feishuConfigError("bindings.match")
}
}
if extractStringValue(childMap(binding, "match")["channel"]) == "feishu" {
configured = true
}
}
}
entries := childMap(childMap(conf, "plugins"), "entries")
entry, selected := entries["openclaw-lark"]
if selected {
old, ok := entry.(map[string]interface{})
if !ok {
return false, configured, feishuConfigError("plugins.entries.openclaw-lark")
}
// A completed disable-only repair must not repeat on every upgrade.
if enabled, ok := old["enabled"].(bool); ok && !enabled && !configured {
return false, false, nil
}
}
installed, err := checkPluginInstalled(installPath, "feishu")
if err != nil {
return false, configured, err
}
_, oldThread := f["threadSession"]
_, oldReply := f["replyMode"]
_, oldStreaming := f["streaming"].(bool)
return selected || installed || oldThread || oldReply || oldStreaming, configured, nil
}
func prepareLegacyFeishu(conf map[string]interface{}, configured bool, transition *dto.AgentFeishuTransition) error {
if !configured {
ensureChildMap(ensureChildMap(ensureChildMap(conf, "plugins"), "entries"), "openclaw-lark")["enabled"] = false
return nil
}
if transition == nil || !transition.Confirmed {
return buserr.New("ErrFeishuUpgradeConfirm")
}
if !validFeishuTransition(transition.GroupSessionScope, transition.RequireMention) {
return feishuConfigError("feishuTransition")
}
return convertLegacyFeishu(conf, dto.AgentFeishuSwitchReq{Streaming: transition.Streaming, GroupSessionScope: transition.GroupSessionScope, RequireMention: transition.RequireMention})
}
func validFeishuTransition(scope, mention string) bool {
return (scope == "group" || scope == "group_sender" || scope == "group_topic" || scope == "group_topic_sender") && (mention == "true" || mention == "false")
}
func feishuRecoveryPayload(candidate []byte, configured bool) ([]byte, error) {
var conf map[string]interface{}
if err := json.Unmarshal(candidate, &conf); err != nil {
return nil, err
}
if configured {
ensureChildMap(ensureChildMap(conf, "channels"), "feishu")["enabled"] = false
selectBundledFeishu(conf, false)
}
ensureChildMap(ensureChildMap(ensureChildMap(conf, "plugins"), "entries"), "openclaw-lark")["enabled"] = false
return json.MarshalIndent(conf, "", " ")
}
func (u *appUpgradeContext) prepareFeishuUpgrade() error {
if u.original.App.Key != constant.AppOpenclaw || !isOpenclawBundledFeishuVersion(u.candidate.Version) {
return nil
}
source := filepath.Join(u.original.GetPath(), "data/conf/openclaw.json")
staged := filepath.Join(u.stageDir, "data/conf/openclaw.json")
// The staged copy predates protocol migration and is the exact cutover comparison source.
original := u.openclawOriginal
current, err := os.ReadFile(source)
if err != nil {
return err
}
if !bytes.Equal(original, current) {
return buserr.New("ErrFeishuConfigChanged")
}
_, conf, err := readFeishuConfigBytes(staged)
if err != nil {
return err
}
legacy, configured, err := classifyLegacyFeishu(conf, u.original.GetPath())
if err != nil || !legacy {
return err
}
if !u.req.Backup {
return buserr.New("ErrFeishuUpgradeBackup")
}
if err = prepareLegacyFeishu(conf, configured, u.req.FeishuTransition); err != nil {
return err
}
project, err := docker.GetComposeProject(u.original.Name, u.original.GetPath(), []byte(u.candidate.DockerCompose), u.envContent, false)
if err != nil {
return err
}
if len(project.Services) != 1 {
return buserr.New("ErrFeishuUpgradeMounts")
}
for _, service := range project.Services {
if service.Build != nil {
return buserr.New("ErrFeishuImageRequired")
}
u.feishuImage, err = bundledFeishuImage(service.Image)
if err != nil {
return err
}
configMounted := false
for _, v := range service.Volumes {
if v.Target == "/etc/localtime" {
continue
}
if v.Type != "bind" {
return buserr.New("ErrFeishuUpgradeMounts")
}
if err = feishuBackupPath(u.original.GetPath(), v.Source); err != nil {
return err
}
if v.Target == "/home/node/.openclaw" {
configMounted = filepath.Clean(v.Source) == filepath.Dir(source)
}
}
if !configMounted {
return buserr.New("ErrFeishuUpgradeMounts")
}
}
cli, err := docker.NewDockerClient()
if err != nil {
return err
}
defer cli.Close()
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
info, err := cli.ContainerInspect(ctx, u.original.ContainerName)
if err != nil {
return err
}
u.feishuOldImage = info.Image
for _, m := range info.Mounts {
if m.Destination == "/etc/localtime" {
continue
}
if string(m.Type) != "bind" {
return buserr.New("ErrFeishuUpgradeMounts")
}
if err = feishuBackupPath(u.original.GetPath(), m.Source); err != nil {
return err
}
}
// Tar preserves symlinks: an external target would not be in the application backup.
if err = filepath.WalkDir(filepath.Join(u.original.GetPath(), "data"), func(file string, d os.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
if d.Type()&os.ModeSymlink != 0 {
return feishuBackupPath(u.original.GetPath(), file)
}
return nil
}); err != nil {
return err
}
u.feishuOriginal = original
u.feishuEnabled = configured && extractBoolValue(getChannelConfig(conf, "feishu")["enabled"], true)
payload, err := json.MarshalIndent(conf, "", " ")
if err != nil {
return err
}
before, err := os.ReadFile(staged)
if err != nil {
return err
}
if err = replaceFeishuConfig(staged, before, payload); err != nil {
return err
}
targetCompose := filepath.Join(u.stageDir, "docker-compose.yml")
if err = pinFeishuRollbackImage(targetCompose, u.feishuImage); err != nil {
return err
}
pinned, err := os.ReadFile(targetCompose)
if err != nil {
return err
}
u.candidate.DockerCompose = string(pinned)
return nil
}
func feishuBackupPath(root, source string) error {
root, err := filepath.EvalSymlinks(root)
if err != nil {
return err
}
resolved, err := filepath.EvalSymlinks(source)
if err != nil {
return buserr.New("ErrFeishuUpgradeMounts")
}
rel, err := filepath.Rel(root, resolved)
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
return buserr.New("ErrFeishuUpgradeMounts")
}
return nil
}
func (u *appUpgradeContext) checkFeishuUpgradeSource() error {
if u.feishuOriginal == nil {
return nil
}
current, err := os.ReadFile(filepath.Join(u.original.GetPath(), "data/conf/openclaw.json"))
if err != nil {
return err
}
if !bytes.Equal(current, u.feishuOriginal) {
return buserr.New("ErrFeishuConfigChanged")
}
return nil
}
func (u *appUpgradeContext) verifyFeishuUpgrade(t *task.Task) error {
if u.feishuOriginal == nil {
return nil
}
if err := verifyFeishuActivation(u.candidate.ContainerName, u.feishuEnabled); err != nil {
return err
}
if u.feishuEnabled {
probeFeishuConnection(u.candidate.ContainerName, t)
}
return nil
}
func probeFeishuConnection(name string, t *task.Task) {
output, err := cmd.RunDockerExecWithStdout(30*time.Second, name, "openclaw", "channels", "status", "--channel", "feishu", "--probe", "--json")
if err != nil || !feishuProbeOK(output) {
t.Log(i18n.GetMsgByKey("AgentFeishuProbeWarning"))
}
}
// Pin the restored service to the actual old image, even if its registry tag moved.
func pinFeishuRollbackImage(composeFile string, image string) error {
raw, err := os.ReadFile(composeFile)
if err != nil {
return err
}
var conf map[string]interface{}
if err = yaml.Unmarshal(raw, &conf); err != nil {
return err
}
services := childMap(conf, "services")
if len(services) != 1 {
return buserr.New("ErrFeishuUpgradeMounts")
}
for _, raw := range services {
service, ok := raw.(map[string]interface{})
if !ok {
return buserr.New("ErrFeishuUpgradeMounts")
}
service["image"] = image
}
payload, err := yaml.Marshal(conf)
if err != nil {
return err
}
return writeUpgradeFile(composeFile, payload, constant.FilePerm)
}
+27 -4
View File
@@ -391,12 +391,27 @@ func deleteAppInstall(deleteReq request.AppInstallDelete) error {
} }
} }
} }
if install.App.Key == "ollama" {
ollamaModelMu.Lock()
defer ollamaModelMu.Unlock()
}
tx, ctx := helper.GetTxAndContext() tx, ctx := helper.GetTxAndContext()
defer tx.Rollback() defer tx.Rollback()
if err = appInstallRepo.Delete(ctx, install); err != nil { if err = appInstallRepo.Delete(ctx, install); err != nil {
return err return err
} }
appKey := install.App.Key appKey := install.App.Key
if appKey == "ollama" {
remaining, listErr := appInstallRepo.ListBy(ctx, appInstallRepo.WithAppId(install.AppId))
if listErr != nil {
return listErr
}
if len(remaining) == 0 {
if err = aiRepo.DeleteAll(ctx); err != nil {
return err
}
}
}
if isAgentAppKey(appKey) { if isAgentAppKey(appKey) {
_ = agentRepo.DeleteByAppInstallIDWithCtx(ctx, install.ID) _ = agentRepo.DeleteByAppInstallIDWithCtx(ctx, install.ID)
} }
@@ -2018,11 +2033,19 @@ func getAppTags(appID uint, lang string) ([]response.TagDTO, error) {
return res, nil return res, nil
} }
func prepareAppInstallSiteDirectory(app model.App, appDetail model.AppDetail, req request.AppInstallCreate, t *task.Task, hooks *appInstallHooks) error {
if hooks != nil && hooks.PrepareSiteDir != nil {
return hooks.PrepareSiteDir(app, appDetail, req, t)
}
return handleSiteDir(app, appDetail, req, t)
}
func managesOpenrestySiteDirectory(app model.App, appDetail model.AppDetail) bool {
return app.Key == "openresty" && (app.Resource == "remote" || app.Resource == "custom") && common.CompareVersion(appDetail.Version, "1.27")
}
func handleSiteDir(app model.App, appDetail model.AppDetail, req request.AppInstallCreate, t *task.Task) error { func handleSiteDir(app model.App, appDetail model.AppDetail, req request.AppInstallCreate, t *task.Task) error {
if app.Key == "openresty" && (app.Resource == "remote" || app.Resource == "custom") && common.CompareVersion(appDetail.Version, "1.27") { if managesOpenrestySiteDirectory(app, appDetail) {
if req.MCPPreserveWebsiteDir {
return prepareMCPWebsiteDirectory(req)
}
if dir, ok := req.Params["WEBSITE_DIR"]; ok { if dir, ok := req.Params["WEBSITE_DIR"]; ok {
siteDir := dir.(string) siteDir := dir.(string)
if siteDir == "" || !strings.HasPrefix(siteDir, "/") { if siteDir == "" || !strings.HasPrefix(siteDir, "/") {
+16 -4
View File
@@ -197,7 +197,8 @@ func handleAppBackup(install *model.AppInstall, parentTask *task.Task, recordID
return nil return nil
} }
func handleAppRecover(install *model.AppInstall, parentTask *task.Task, recoverFile string, isRollback bool, secret, taskID string) error { func handleAppRecover(install *model.AppInstall, parentTask *task.Task, recoverFile string, isRollback bool, secret, taskID string, startAfterRecover ...bool) error {
startAfter := len(startAfterRecover) == 0 || startAfterRecover[0]
// Validate AI Gateway backups before stopping the app, creating rollback // Validate AI Gateway backups before stopping the app, creating rollback
// backups, or touching its files/database. Keep the validated extraction for // backups, or touching its files/database. Keep the validated extraction for
// the restore so uploaded/encrypted archives are not unpacked twice. // the restore so uploaded/encrypted archives are not unpacked twice.
@@ -246,12 +247,15 @@ func handleAppRecover(install *model.AppInstall, parentTask *task.Task, recoverF
return err return err
} }
defer func() { defer func() {
defer os.RemoveAll(tmpPath)
if !startAfter {
return
}
if isRollback { if isRollback {
_, _ = compose.UpWithoutBuild(install.GetComposePath()) _, _ = compose.UpWithoutBuild(install.GetComposePath())
} else { } else {
_, _ = compose.Up(install.GetComposePath()) _, _ = compose.Up(install.GetComposePath())
} }
_ = os.RemoveAll(tmpPath)
}() }()
if !fileOp.Stat(tmpPath+"/app.json") || !fileOp.Stat(tmpPath+"/app.tar.gz") { if !fileOp.Stat(tmpPath+"/app.json") || !fileOp.Stat(tmpPath+"/app.tar.gz") {
@@ -352,8 +356,12 @@ func handleAppRecover(install *model.AppInstall, parentTask *task.Task, recoverF
appDir := install.GetPath() appDir := install.GetPath()
backPath := fmt.Sprintf("%s_bak", appDir) backPath := fmt.Sprintf("%s_bak", appDir)
_ = fileOp.Rename(appDir, backPath) if err := fileOp.Rename(appDir, backPath); err != nil && !startAfter {
_ = fileOp.CreateDir(appDir, constant.DirPerm) return err
}
if err := fileOp.CreateDir(appDir, constant.DirPerm); err != nil && !startAfter {
return err
}
deCompressName := i18n.GetWithName("DeCompressFile", "app.tar.gz") deCompressName := i18n.GetWithName("DeCompressFile", "app.tar.gz")
t.LogStart(deCompressName) t.LogStart(deCompressName)
@@ -376,6 +384,10 @@ func handleAppRecover(install *model.AppInstall, parentTask *task.Task, recoverF
_, _ = file.WriteString(mergedEnvContent) _, _ = file.WriteString(mergedEnvContent)
} }
// The upgrade transaction verifies readiness before saving its restored install.
if !startAfter {
return nil
}
backupInstall.ID = install.ID backupInstall.ID = install.ID
backupInstall.Status = constant.StatusRunning backupInstall.Status = constant.StatusRunning
backupInstall.AppId = install.AppId backupInstall.AppId = install.AppId
+1 -1
View File
@@ -261,7 +261,7 @@ func doWebsiteBackup(website *model.Website, parentTask *task.Task, backupDir, f
return err return err
} }
nginxConfFile := GetWebsiteConfigPath(*website) nginxConfFile := GetWebsiteConfigPath(*website)
if err := fileOp.CopyFile(nginxConfFile, tmpDir); err != nil { if err := exportWebsiteNginxConfig(*website, nginxConfFile, tmpDir); err != nil {
return err return err
} }
parentTask.Log(i18n.GetMsgByKey("BackupNginxConfig")) parentTask.Log(i18n.GetMsgByKey("BackupNginxConfig"))
+34 -3
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,6 +1683,10 @@ func calculateNetwork(network map[string]container.NetworkStats) (float64, float
} }
func checkImageExist(client *client.Client, imageItem string) bool { func checkImageExist(client *client.Client, imageItem string) bool {
return checkImageExistContext(context.Background(), client, imageItem)
}
func checkImageExistContext(ctx context.Context, client *client.Client, imageItem string) bool {
if client == nil { if client == nil {
var err error var err error
client, err = docker.NewDockerClient() client, err = docker.NewDockerClient()
@@ -1667,7 +1694,7 @@ func checkImageExist(client *client.Client, imageItem string) bool {
return false return false
} }
} }
images, err := client.ImageList(context.Background(), image.ListOptions{}) images, err := client.ImageList(ctx, image.ListOptions{})
if err != nil { if err != nil {
return false return false
} }
@@ -1709,6 +1736,10 @@ func checkImageLike(client *client.Client, imageName string) bool {
} }
func pullImages(task *task.Task, client *client.Client, imageName string) error { func pullImages(task *task.Task, client *client.Client, imageName string) error {
return pullImagesContext(context.Background(), task, client, imageName)
}
func pullImagesContext(ctx context.Context, task *task.Task, client *client.Client, imageName string) error {
dockerCli := docker.NewClientWithExist(client) dockerCli := docker.NewClientWithExist(client)
repos, err := imageRepoRepo.List() repos, err := imageRepoRepo.List()
if err != nil { if err != nil {
@@ -1716,7 +1747,7 @@ func pullImages(task *task.Task, client *client.Client, imageName string) error
} }
imageRepo := selectImageRepo(imageName, repos) imageRepo := selectImageRepo(imageName, repos)
if imageRepo == nil || !imageRepo.Auth { if imageRepo == nil || !imageRepo.Auth {
return dockerCli.PullImageWithProcess(task, imageName) return dockerCli.PullImageWithProcessContext(ctx, task, imageName)
} }
options := image.PullOptions{} options := image.PullOptions{}
@@ -1729,7 +1760,7 @@ func pullImages(task *task.Task, client *client.Client, imageName string) error
return err return err
} }
options.RegistryAuth = base64.URLEncoding.EncodeToString(encodedJSON) options.RegistryAuth = base64.URLEncoding.EncodeToString(encodedJSON)
return dockerCli.PullImageWithProcessAndOptions(task, imageName, options) return dockerCli.PullImageWithProcessAndOptionsContext(ctx, task, imageName, options)
} }
func selectImageRepo(imageName string, repos []model.ImageRepo) *model.ImageRepo { func selectImageRepo(imageName string, repos []model.ImageRepo) *model.ImageRepo {
@@ -0,0 +1,396 @@
package service
import (
"context"
"fmt"
"io"
"net/http"
"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 {
httpClient, err := dockerhub.NewHTTPClient()
if err != nil {
return err
}
if httpClient.Transport != nil {
httpClient.CloseIdleConnections()
}
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
httpClient *http.Client
repositories map[string]*imageCheckUpdateRepository
targets map[imageCheckUpdateTargetKey]imageCheckUpdateTargetResult
}
func runContainerImageCheckUpdate(ctx context.Context, result *ImageCheckUpdateStatus, logger func(string)) error {
httpClient, err := dockerhub.NewHTTPClient()
if err != nil {
return err
}
if httpClient.Transport != nil {
defer httpClient.CloseIdleConnections()
}
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{httpClient: httpClient, 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.httpClient)
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
// Both versioned and floating tags can be republished with new image content.
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, httpClient *http.Client) (imageCheckUpdateHubClient, error) {
return dockerhub.New(ctx, repository, username, password, httpClient)
}
+273 -70
View File
@@ -91,81 +91,100 @@ func (u *ContainerService) ContainerUpdate(req dto.ContainerOperate) error {
return nil return nil
} }
const containerUpgradeConcurrency = 2
// Shared by all upgrade requests handled by this agent.
var containerUpgradeSlots = make(chan struct{}, containerUpgradeConcurrency)
func (u *ContainerService) ContainerUpgrade(req dto.ContainerUpgrade) error { func (u *ContainerService) ContainerUpgrade(req dto.ContainerUpgrade) error {
client, err := docker.NewDockerClient() client, err := docker.NewDockerClient()
if err != nil { if err != nil {
return err return err
} }
ctx := context.Background()
taskItem, err := task.NewTaskWithOps(req.Image, task.TaskUpgrade, task.TaskScopeImage, req.TaskID, 1) taskItem, err := task.NewTaskWithOps(req.Image, task.TaskUpgrade, task.TaskScopeImage, req.TaskID, 1)
if err != nil { if err != nil {
_ = client.Close() _ = client.Close()
global.LOG.Errorf("new task for create container failed, err: %v", err) global.LOG.Errorf("new task for create container failed, err: %v", err)
return err return err
} }
go func() { taskItem.AddSubTask(i18n.GetWithName("ContainerImagePull", req.Image), func(t *task.Task) error {
defer client.Close() ctx, cancel := context.WithTimeout(context.Background(), containerUpgradePullTimeout)
taskItem.AddSubTask(i18n.GetWithName("ContainerImagePull", req.Image), func(t *task.Task) error { defer cancel()
taskItem.LogStart(i18n.GetWithName("ContainerImagePull", req.Image)) taskItem.LogStart(i18n.GetWithName("ContainerImagePull", req.Image))
if !checkImageExist(client, req.Image) || req.ForcePull { if req.ForcePull || !checkImageExistContext(ctx, client, req.Image) {
if err := pullImages(taskItem, client, req.Image); err != nil { if err := pullImagesContext(ctx, taskItem, client, req.Image); err != nil {
if !req.ForcePull { return fmt.Errorf("pull image %s failed: %w", req.Image, err)
return err }
} }
return fmt.Errorf("pull image %s failed, err: %v", req.Image, err) return nil
}, nil)
var upgradeErrors []error
for _, item := range req.Names {
item := item
taskItem.AddSubTaskWithIgnoreErr(i18n.GetWithName("ContainerUpgradeItem", item), func(t *task.Task) error {
t.Logf("----------------- %s -----------------", item)
t.LogStart(i18n.GetWithName("ContainerAcquireLock", item))
ctx, cancel := context.WithTimeout(context.Background(), containerUpgradeReplaceTimeout)
defer cancel()
unlock, lockErr := containerOperationLock.lockContext(ctx, item)
if lockErr != nil {
upgradeErrors = append(upgradeErrors, fmt.Errorf("wait for container %s lock: %w", item, lockErr))
return lockErr
}
defer unlock()
t.LogWithStatus(i18n.GetWithName("ContainerAcquireLock", item), nil)
oldContainer, inspectErr := client.ContainerInspect(ctx, item)
t.LogWithStatus(i18n.GetWithName("ContainerLoadInfo", item), inspectErr)
if inspectErr != nil {
err := fmt.Errorf("reload container %s failed: %w", item, inspectErr)
upgradeErrors = append(upgradeErrors, err)
return err
}
config := cloneContainerConfig(oldContainer.Config)
config.Image = req.Image
if config.Labels == nil {
config.Labels = make(map[string]string)
}
config.Labels[containerUpgradeOwnerLabel] = oldContainer.ID
hostConf := cloneContainerHostConfig(oldContainer.HostConfig)
preserveContainerVolumeMounts(hostConf, oldContainer.Mounts)
networkConf, extraNetworks := buildContainerRecoverNetworkConfig(oldContainer.NetworkSettings, hostConf)
if err := normalizeContainerEndpointSettings(ctx, client, networkConf, extraNetworks); err != nil {
upgradeErr := fmt.Errorf("prepare networks for container %s failed: %w", item, err)
upgradeErrors = append(upgradeErrors, upgradeErr)
return upgradeErr
}
cleanupErr, err := switchContainerWithRecovery(ctx, client, item, oldContainer, func() (container.CreateResponse, error) {
return createContainerWithNetworks(ctx, client, config, hostConf, networkConf, extraNetworks, item)
}, config.Tty, t, true)
if err != nil {
upgradeErr := fmt.Errorf("upgrade container %s failed: %w", item, err)
upgradeErrors = append(upgradeErrors, upgradeErr)
return upgradeErr
}
if 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 return nil
}, nil) })
var upgradeErrors []error }
for _, item := range req.Names { taskItem.AddSubTask(i18n.GetMsgByKey("ContainerUpgradeSummary"), func(t *task.Task) error {
item := item return errors.Join(upgradeErrors...)
taskItem.AddSubTaskWithIgnoreErr(i18n.GetWithName("ContainerUpgradeItem", item), func(t *task.Task) error { }, nil)
t.Logf("----------------- %s -----------------", item) // Stage contexts own the deadlines, including a separate recovery budget.
t.LogStart(i18n.GetWithName("ContainerAcquireLock", item)) for _, subTask := range taskItem.SubTasks {
unlock := containerOperationLock.lock(item) subTask.Timeout = 0
defer unlock() }
t.LogWithStatus(i18n.GetWithName("ContainerAcquireLock", item), nil) if err := taskItem.ExecuteQueued(containerUpgradeSlots, "ContainerUpgradeWaiting", func() { _ = client.Close() }); err != nil {
_ = client.Close()
oldContainer, inspectErr := client.ContainerInspect(ctx, item) return err
t.LogWithStatus(i18n.GetWithName("ContainerLoadInfo", item), inspectErr) }
if inspectErr != nil {
err := fmt.Errorf("reload container %s failed: %w", item, inspectErr)
upgradeErrors = append(upgradeErrors, err)
return err
}
config := cloneContainerConfig(oldContainer.Config)
config.Image = req.Image
hostConf := cloneContainerHostConfig(oldContainer.HostConfig)
preserveContainerVolumeMounts(hostConf, oldContainer.Mounts)
networkConf, extraNetworks := buildContainerRecoverNetworkConfig(oldContainer.NetworkSettings, hostConf)
if err := normalizeContainerEndpointSettings(ctx, client, networkConf, extraNetworks); err != nil {
upgradeErr := fmt.Errorf("prepare networks for container %s failed: %w", item, err)
upgradeErrors = append(upgradeErrors, upgradeErr)
return upgradeErr
}
cleanupErr, err := switchContainer(ctx, client, item, oldContainer, func() (container.CreateResponse, error) {
return createContainerWithNetworks(ctx, client, config, hostConf, networkConf, extraNetworks, item)
}, config.Tty, t)
if err != nil {
upgradeErr := fmt.Errorf("upgrade container %s failed: %w", item, err)
upgradeErrors = append(upgradeErrors, upgradeErr)
return upgradeErr
}
if cleanupErr != nil {
t.Log(i18n.GetWithNameAndErr("ContainerCleanupWarning", containerSwitchBackupName(oldContainer.ID), cleanupErr))
}
return nil
})
}
taskItem.AddSubTask(i18n.GetMsgByKey("ContainerUpgradeSummary"), func(t *task.Task) error {
return errors.Join(upgradeErrors...)
}, nil)
if err := taskItem.Execute(); err != nil {
global.LOG.Error(err.Error())
}
}()
return nil return nil
} }
@@ -192,11 +211,16 @@ type containerOperationMutex struct {
var containerOperationLock containerOperationMutex var containerOperationLock containerOperationMutex
type containerOperationLockEntry struct { type containerOperationLockEntry struct {
mutex sync.Mutex token chan struct{}
references int references int
} }
func (l *containerOperationMutex) lock(names ...string) func() { func (l *containerOperationMutex) lock(names ...string) func() {
unlock, _ := l.lockContext(context.Background(), names...)
return unlock
}
func (l *containerOperationMutex) lockContext(ctx context.Context, names ...string) (func(), error) {
nameSet := make(map[string]struct{}, len(names)) nameSet := make(map[string]struct{}, len(names))
for _, name := range names { for _, name := range names {
if name != "" { if name != "" {
@@ -209,7 +233,7 @@ func (l *containerOperationMutex) lock(names ...string) func() {
} }
sort.Strings(orderedNames) sort.Strings(orderedNames)
if len(orderedNames) == 0 { if len(orderedNames) == 0 {
return func() {} return func() {}, nil
} }
l.mutex.Lock() l.mutex.Lock()
@@ -220,7 +244,7 @@ func (l *containerOperationMutex) lock(names ...string) func() {
for _, name := range orderedNames { for _, name := range orderedNames {
entry := l.locks[name] entry := l.locks[name]
if entry == nil { if entry == nil {
entry = &containerOperationLockEntry{} entry = &containerOperationLockEntry{token: make(chan struct{}, 1)}
l.locks[name] = entry l.locks[name] = entry
} }
entry.references++ entry.references++
@@ -228,12 +252,10 @@ func (l *containerOperationMutex) lock(names ...string) func() {
} }
l.mutex.Unlock() l.mutex.Unlock()
for _, entry := range entries { acquired := 0
entry.mutex.Lock() release := func() {
} for index := acquired - 1; index >= 0; index-- {
return func() { <-entries[index].token
for index := len(entries) - 1; index >= 0; index-- {
entries[index].mutex.Unlock()
} }
l.mutex.Lock() l.mutex.Lock()
defer l.mutex.Unlock() defer l.mutex.Unlock()
@@ -245,6 +267,20 @@ func (l *containerOperationMutex) lock(names ...string) func() {
} }
} }
} }
for _, entry := range entries {
select {
case entry.token <- struct{}{}:
acquired++
case <-ctx.Done():
release()
return nil, ctx.Err()
}
}
if err := ctx.Err(); err != nil {
release()
return nil, err
}
return release, nil
} }
type containerNetworkAttachment struct { type containerNetworkAttachment struct {
@@ -272,6 +308,130 @@ func switchContainer(
createNew func() (container.CreateResponse, error), createNew func() (container.CreateResponse, error),
tty bool, tty bool,
logger containerSwitchLogger, logger containerSwitchLogger,
) (cleanupErr error, err error) {
return switchContainerWithRecovery(ctx, cli, name, oldContainer, createNew, tty, logger, false)
}
const (
containerUpgradePullTimeout = 30 * time.Minute
containerUpgradeReplaceTimeout = 15 * time.Minute
containerUpgradeRecoveryTimeout = 5 * time.Minute
containerUpgradeOwnerLabel = "io.1panel.upgrade.original-id"
)
// Reconcile against Docker's actual state: a failed request may already have
// changed names, created a container, or disconnected a network on the daemon.
func recoverContainerUpgrade(ctx context.Context, cli containerSwitchClient, name string, old container.InspectResponse, newID string, logger containerSwitchLogger) error {
original, err := cli.ContainerInspect(ctx, old.ID)
if err != nil {
return fmt.Errorf("inspect original %s: %w", old.ID, err)
}
if original.State == nil {
return fmt.Errorf("original container state unavailable")
}
replacement, inspectErr := cli.ContainerInspect(ctx, name)
if inspectErr != nil && !client.IsErrNotFound(inspectErr) {
return fmt.Errorf("inspect replacement by name: %w", inspectErr)
}
if inspectErr == nil && replacement.ID != old.ID {
if replacement.Config == nil || replacement.Config.Labels[containerUpgradeOwnerLabel] != old.ID {
return fmt.Errorf("container %s belongs to another operation; refusing to remove it", name)
}
if newID != "" && replacement.ID != newID {
return fmt.Errorf("replacement ID changed; refusing to remove it")
}
newID = replacement.ID
}
if newID != "" {
replacement, inspectErr = cli.ContainerInspect(ctx, newID)
if inspectErr != nil && !client.IsErrNotFound(inspectErr) {
return fmt.Errorf("inspect replacement %s: %w", newID, inspectErr)
}
if inspectErr == nil {
if replacement.ID == old.ID || replacement.Config == nil || replacement.Config.Labels[containerUpgradeOwnerLabel] != old.ID {
return fmt.Errorf("replacement identity could not be confirmed")
}
err = cli.ContainerRemove(ctx, newID, container.RemoveOptions{Force: true, RemoveVolumes: false})
logContainerSwitchStep(logger, "ContainerRollbackRemoveReplacement", name, err)
if err != nil && !client.IsErrNotFound(err) {
return fmt.Errorf("remove replacement: %w", err)
}
}
}
if strings.TrimPrefix(original.Name, "/") != name {
err = cli.ContainerRename(ctx, old.ID, name)
logContainerSwitchStep(logger, "ContainerRollbackRenameOld", name, err)
if err != nil {
return fmt.Errorf("restore original name: %w", err)
}
}
// Reload after mutations; connect only networks which are actually missing.
original, err = cli.ContainerInspect(ctx, old.ID)
if err != nil {
return err
}
primary, extras := buildContainerRecoverNetworkConfig(old.NetworkSettings, old.HostConfig)
wanted := make(map[string]*network.EndpointSettings)
if primary != nil {
for n, endpoint := range primary.EndpointsConfig {
if n != "bridge" {
wanted[n] = endpoint
}
}
}
for n, endpoint := range extras {
if n != "bridge" {
wanted[n] = endpoint
}
}
var reconnectErr error
for n, endpoint := range wanted {
if original.NetworkSettings != nil && original.NetworkSettings.Networks[n] != nil {
continue
}
err = cli.NetworkConnect(ctx, n, old.ID, endpoint)
logContainerSwitchStep(logger, "ContainerRollbackReconnectOld", n, err)
if err != nil {
reconnectErr = errors.Join(reconnectErr, fmt.Errorf("reconnect network %s: %w", n, err))
}
}
if reconnectErr != nil {
return reconnectErr
}
if old.State != nil && old.State.Running {
if original.State == nil {
return fmt.Errorf("original state unavailable after recovery")
}
if !original.State.Running {
err = restartOriginalContainer(ctx, cli, old.ID)
logContainerSwitchStep(logger, "ContainerRollbackRestartOld", name, err)
if err != nil {
return err
}
}
}
verified, err := cli.ContainerInspect(ctx, old.ID)
if err != nil {
return fmt.Errorf("verify recovery: %w", err)
}
if strings.TrimPrefix(verified.Name, "/") != name {
return fmt.Errorf("original name was not restored")
}
if old.State != nil && old.State.Running && (verified.State == nil || !verified.State.Running) {
return fmt.Errorf("original container is not running after recovery")
}
for n := range wanted {
if verified.NetworkSettings == nil || verified.NetworkSettings.Networks[n] == nil {
return fmt.Errorf("network %s was not restored", n)
}
}
return nil
}
func switchContainerWithRecovery(ctx context.Context, cli containerSwitchClient, name string,
oldContainer container.InspectResponse, createNew func() (container.CreateResponse, error),
tty bool, logger containerSwitchLogger, boundedRecovery bool,
) (cleanupErr error, err error) { ) (cleanupErr error, err error) {
if oldContainer.ID == "" { if oldContainer.ID == "" {
return nil, fmt.Errorf("original container ID is empty") return nil, fmt.Errorf("original container ID is empty")
@@ -281,9 +441,32 @@ func switchContainer(
return nil, fmt.Errorf("cannot safely replace container %s with auto-remove enabled", name) return nil, fmt.Errorf("cannot safely replace container %s with auto-remove enabled", name)
} }
recoverUpgrade := func(newID string, cause error) error {
recoveryCtx, cancel := context.WithTimeout(context.Background(), containerUpgradeRecoveryTimeout)
defer cancel()
if newID != "" {
logContainerStartupLogs(recoveryCtx, cli, newID, name, tty, logger)
}
recoveryErr := recoverContainerUpgrade(recoveryCtx, cli, name, oldContainer, newID, logger)
if ctx.Err() != nil {
recoveryErr = errors.Join(recoveryErr, fmt.Errorf("Docker request timed out or was canceled; daemon-side completion cannot be confirmed: %w", ctx.Err()))
}
if recoveryErr != nil {
message := fmt.Sprintf("Recovery unconfirmed for container %s (original ID %s, backup %s): %v", name, oldContainer.ID, containerSwitchBackupName(oldContainer.ID), recoveryErr)
if logger != nil {
logger.Log(message)
}
return errors.Join(cause, errors.New(message))
}
return cause
}
if wasRunning { if wasRunning {
if err := cli.ContainerStop(ctx, oldContainer.ID, container.StopOptions{}); err != nil { if err := cli.ContainerStop(ctx, oldContainer.ID, container.StopOptions{}); err != nil {
logContainerSwitchStep(logger, "ContainerStopOld", name, err) logContainerSwitchStep(logger, "ContainerStopOld", name, err)
if boundedRecovery {
return nil, recoverUpgrade("", fmt.Errorf("stop original container failed: %w", err))
}
current, inspectErr := cli.ContainerInspect(ctx, oldContainer.ID) current, inspectErr := cli.ContainerInspect(ctx, oldContainer.ID)
if inspectErr == nil && current.State != nil && !current.State.Running { if inspectErr == nil && current.State != nil && !current.State.Running {
restartErr := restartOriginalContainer(ctx, cli, oldContainer.ID) restartErr := restartOriginalContainer(ctx, cli, oldContainer.ID)
@@ -298,6 +481,9 @@ func switchContainer(
backupName := containerSwitchBackupName(oldContainer.ID) backupName := containerSwitchBackupName(oldContainer.ID)
if err := cli.ContainerRename(ctx, oldContainer.ID, backupName); err != nil { if err := cli.ContainerRename(ctx, oldContainer.ID, backupName); err != nil {
logContainerSwitchStep(logger, "ContainerRenameOld", name, err) logContainerSwitchStep(logger, "ContainerRenameOld", name, err)
if boundedRecovery {
return nil, recoverUpgrade("", fmt.Errorf("rename original container failed: %w", err))
}
if wasRunning { if wasRunning {
restartErr := restartOriginalContainer(ctx, cli, oldContainer.ID) restartErr := restartOriginalContainer(ctx, cli, oldContainer.ID)
logContainerSwitchStep(logger, "ContainerRollbackRestartOld", name, restartErr) logContainerSwitchStep(logger, "ContainerRollbackRestartOld", name, restartErr)
@@ -309,6 +495,9 @@ func switchContainer(
disconnectedNetworks, disconnectErr := disconnectOriginalContainerNetworks(ctx, cli, oldContainer) disconnectedNetworks, disconnectErr := disconnectOriginalContainerNetworks(ctx, cli, oldContainer)
logContainerSwitchStep(logger, "ContainerDisconnectOld", backupName, disconnectErr) logContainerSwitchStep(logger, "ContainerDisconnectOld", backupName, disconnectErr)
if disconnectErr != nil { if disconnectErr != nil {
if boundedRecovery {
return nil, recoverUpgrade("", disconnectErr)
}
rollbackErr := restoreOriginalContainer(ctx, cli, oldContainer.ID, name, wasRunning, "", disconnectedNetworks, logger) rollbackErr := restoreOriginalContainer(ctx, cli, oldContainer.ID, name, wasRunning, "", disconnectedNetworks, logger)
return nil, errors.Join(disconnectErr, rollbackErr) return nil, errors.Join(disconnectErr, rollbackErr)
} }
@@ -316,11 +505,17 @@ func switchContainer(
created, createErr := createNew() created, createErr := createNew()
logContainerSwitchStep(logger, "ContainerCreateReplacement", name, createErr) logContainerSwitchStep(logger, "ContainerCreateReplacement", name, createErr)
if createErr != nil { if createErr != nil {
if boundedRecovery {
return nil, recoverUpgrade(created.ID, createErr)
}
rollbackErr := restoreOriginalContainer(ctx, cli, oldContainer.ID, name, wasRunning, created.ID, disconnectedNetworks, logger) rollbackErr := restoreOriginalContainer(ctx, cli, oldContainer.ID, name, wasRunning, created.ID, disconnectedNetworks, logger)
return nil, errors.Join(createErr, rollbackErr) return nil, errors.Join(createErr, rollbackErr)
} }
if err := cli.ContainerStart(ctx, created.ID, container.StartOptions{}); err != nil { if err := cli.ContainerStart(ctx, created.ID, container.StartOptions{}); err != nil {
logContainerSwitchStep(logger, "ContainerStartReplacement", name, err) logContainerSwitchStep(logger, "ContainerStartReplacement", name, err)
if boundedRecovery {
return nil, recoverUpgrade(created.ID, fmt.Errorf("start new container failed: %w", err))
}
logContainerStartupLogs(ctx, cli, created.ID, name, tty, logger) logContainerStartupLogs(ctx, cli, created.ID, name, tty, logger)
rollbackErr := restoreOriginalContainer(ctx, cli, oldContainer.ID, name, wasRunning, created.ID, disconnectedNetworks, logger) rollbackErr := restoreOriginalContainer(ctx, cli, oldContainer.ID, name, wasRunning, created.ID, disconnectedNetworks, logger)
return nil, errors.Join(fmt.Errorf("start new container failed: %w", err), rollbackErr) return nil, errors.Join(fmt.Errorf("start new container failed: %w", err), rollbackErr)
@@ -329,6 +524,9 @@ func switchContainer(
if wasRunning { if wasRunning {
if err := waitContainerReady(ctx, cli, created.ID); err != nil { if err := waitContainerReady(ctx, cli, created.ID); err != nil {
logContainerSwitchStep(logger, "ContainerWaitReplacement", name, err) logContainerSwitchStep(logger, "ContainerWaitReplacement", name, err)
if boundedRecovery {
return nil, recoverUpgrade(created.ID, fmt.Errorf("new container readiness check failed: %w", err))
}
logContainerStartupLogs(ctx, cli, created.ID, name, tty, logger) logContainerStartupLogs(ctx, cli, created.ID, name, tty, logger)
rollbackErr := restoreOriginalContainer(ctx, cli, oldContainer.ID, name, wasRunning, created.ID, disconnectedNetworks, logger) rollbackErr := restoreOriginalContainer(ctx, cli, oldContainer.ID, name, wasRunning, created.ID, disconnectedNetworks, logger)
return nil, errors.Join(fmt.Errorf("new container readiness check failed: %w", err), rollbackErr) return nil, errors.Join(fmt.Errorf("new container readiness check failed: %w", err), rollbackErr)
@@ -336,6 +534,11 @@ func switchContainer(
logContainerSwitchStep(logger, "ContainerWaitReplacement", name, nil) logContainerSwitchStep(logger, "ContainerWaitReplacement", name, nil)
} }
if boundedRecovery {
cleanupCtx, cancel := context.WithTimeout(context.Background(), containerUpgradeRecoveryTimeout)
defer cancel()
ctx = cleanupCtx
}
cleanupErr = cli.ContainerRemove(ctx, oldContainer.ID, container.RemoveOptions{Force: true, RemoveVolumes: false}) cleanupErr = cli.ContainerRemove(ctx, oldContainer.ID, container.RemoveOptions{Force: true, RemoveVolumes: false})
logContainerSwitchStep(logger, "ContainerRemoveOld", backupName, cleanupErr) logContainerSwitchStep(logger, "ContainerRemoveOld", backupName, cleanupErr)
return cleanupErr, nil return cleanupErr, nil
+11
View File
@@ -5,12 +5,14 @@ import (
"fmt" "fmt"
"os" "os"
"path" "path"
"strings"
"github.com/1Panel-dev/1Panel/agent/app/dto" "github.com/1Panel-dev/1Panel/agent/app/dto"
"github.com/1Panel-dev/1Panel/agent/app/repo" "github.com/1Panel-dev/1Panel/agent/app/repo"
"github.com/1Panel-dev/1Panel/agent/buserr" "github.com/1Panel-dev/1Panel/agent/buserr"
"github.com/1Panel-dev/1Panel/agent/constant" "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/cmd"
"github.com/1Panel-dev/1Panel/agent/utils/encrypt" "github.com/1Panel-dev/1Panel/agent/utils/encrypt"
"github.com/1Panel-dev/1Panel/agent/utils/mysql" "github.com/1Panel-dev/1Panel/agent/utils/mysql"
"github.com/1Panel-dev/1Panel/agent/utils/mysql/client" "github.com/1Panel-dev/1Panel/agent/utils/mysql/client"
@@ -126,6 +128,9 @@ func (u *DatabaseService) LoadItems(dbType string) ([]dto.DatabaseItem, error) {
} }
func (u *DatabaseService) CheckDatabase(req dto.DatabaseCreate) bool { func (u *DatabaseService) CheckDatabase(req dto.DatabaseCreate) bool {
if (req.Type == constant.AppMysql || req.Type == constant.AppMariaDB || req.Type == constant.AppPostgresql || req.Type == constant.AppMongodb) && (cmd.CheckIllegal(req.Username, req.Password) || strings.Contains(req.Password, " ")) {
return false
}
if req.Timeout == 0 { if req.Timeout == 0 {
req.Timeout = 30 req.Timeout = 30
} }
@@ -180,6 +185,9 @@ func (u *DatabaseService) CheckDatabase(req dto.DatabaseCreate) bool {
} }
func (u *DatabaseService) Create(req dto.DatabaseCreate) error { func (u *DatabaseService) Create(req dto.DatabaseCreate) error {
if (req.Type == constant.AppMysql || req.Type == constant.AppMariaDB || req.Type == constant.AppPostgresql || req.Type == constant.AppMongodb) && (cmd.CheckIllegal(req.Username, req.Password) || strings.Contains(req.Password, " ")) {
return buserr.New("ErrCmdIllegal")
}
if req.Timeout == 0 { if req.Timeout == 0 {
req.Timeout = 30 req.Timeout = 30
} }
@@ -303,6 +311,9 @@ func (u *DatabaseService) Delete(req dto.DatabaseDelete) error {
} }
func (u *DatabaseService) Update(req dto.DatabaseUpdate) error { func (u *DatabaseService) Update(req dto.DatabaseUpdate) error {
if (req.Type == constant.AppMysql || req.Type == constant.AppMariaDB || req.Type == constant.AppPostgresql || req.Type == constant.AppMongodb) && (cmd.CheckIllegal(req.Username, req.Password) || strings.Contains(req.Password, " ")) {
return buserr.New("ErrCmdIllegal")
}
switch req.Type { switch req.Type {
case constant.AppPostgresql: case constant.AppPostgresql:
if _, err := postgresql.NewPostgresqlClient(pgclient.DBInfo{ if _, err := postgresql.NewPostgresqlClient(pgclient.DBInfo{
+4 -4
View File
@@ -64,7 +64,7 @@ func (u *MongodbService) UpdateDescription(req dto.UpdateDescription) error {
} }
func (u *MongodbService) Create(ctx context.Context, req dto.MongodbDBCreate) (*model.DatabaseMongodb, error) { func (u *MongodbService) Create(ctx context.Context, req dto.MongodbDBCreate) (*model.DatabaseMongodb, error) {
if cmd.CheckIllegal(req.Name, req.Username, req.Password) { if cmd.CheckIllegal(req.Name, req.Username, req.Password) || strings.Contains(req.Password, " ") {
return nil, buserr.New("ErrCmdIllegal") return nil, buserr.New("ErrCmdIllegal")
} }
if !isSupportedMongodbPrivilege(req.Permission) { if !isSupportedMongodbPrivilege(req.Permission) {
@@ -146,7 +146,7 @@ func (u *MongodbService) LoadFromRemote(req dto.MongodbLoadDB) error {
} }
func (u *MongodbService) BindUser(req dto.MongodbBind) error { func (u *MongodbService) BindUser(req dto.MongodbBind) error {
if cmd.CheckIllegal(req.Database, req.Name, req.Username, req.Password) { if cmd.CheckIllegal(req.Database, req.Name, req.Username, req.Password) || strings.Contains(req.Password, " ") {
return buserr.New("ErrCmdIllegal") return buserr.New("ErrCmdIllegal")
} }
@@ -168,7 +168,7 @@ func (u *MongodbService) BindUser(req dto.MongodbBind) error {
} }
func (u *MongodbService) ChangePassword(req dto.MongodbPassword) error { func (u *MongodbService) ChangePassword(req dto.MongodbPassword) error {
if cmd.CheckIllegal(req.Database, req.Name, req.Password) { if cmd.CheckIllegal(req.Database, req.Name, req.Password) || strings.Contains(req.Password, " ") {
return buserr.New("ErrCmdIllegal") return buserr.New("ErrCmdIllegal")
} }
@@ -190,7 +190,7 @@ func (u *MongodbService) ChangePassword(req dto.MongodbPassword) error {
} }
func (u *MongodbService) ChangeRootPassword(req dto.ChangeDBInfo) error { func (u *MongodbService) ChangeRootPassword(req dto.ChangeDBInfo) error {
if cmd.CheckIllegal(req.Value) { if cmd.CheckIllegal(req.Value) || strings.Contains(req.Value, " ") {
return buserr.New("ErrCmdIllegal") return buserr.New("ErrCmdIllegal")
} }
if req.From != constant.AppResourceLocal { if req.From != constant.AppResourceLocal {
+82 -70
View File
@@ -433,11 +433,46 @@ func (u *MysqlService) ListDBOption() ([]dto.MysqlOption, error) {
} }
func (u *MysqlService) Create(ctx context.Context, req dto.MysqlDBCreate) (*model.DatabaseMysql, error) { func (u *MysqlService) Create(ctx context.Context, req dto.MysqlDBCreate) (*model.DatabaseMysql, error) {
return u.create(ctx, req, false) creation, err := prepareMysqlCreation(req)
if err != nil {
return nil, err
}
req = creation.request
cli, version, err := LoadMysqlClientByFrom(req.Database)
if err != nil {
return nil, err
}
defer cli.Close()
if err := cli.Create(creation.createInfo(version)); err != nil {
return nil, err
}
if len(req.Username) != 0 {
if err := saveDatabaseUserCredentials(creation.dbType, req.Database, req.Username, req.Permission, req.Password, req.Description); err != nil {
return nil, err
}
for _, host := range creation.permissionHosts {
if err := saveDatabaseUserGrant(creation.dbType, req.Database, req.Name, req.Username, host); err != nil {
return nil, err
}
}
}
global.LOG.Infof("create database %s successful!", req.Name)
if err := mysqlRepo.Create(ctx, &creation.item); err != nil {
return nil, err
}
return &creation.item, nil
} }
func (u *MysqlService) create(ctx context.Context, req dto.MysqlDBCreate, mcpBounded bool) (*model.DatabaseMysql, error) { type mysqlCreation struct {
if cmd.CheckIllegal(req.Name, req.Username, req.Password, req.Format, req.Collation, req.Permission) { request dto.MysqlDBCreate
dbType string
permissionHosts []string
item model.DatabaseMysql
}
func prepareMysqlCreation(req dto.MysqlDBCreate) (*mysqlCreation, error) {
if cmd.CheckIllegal(req.Name, req.Username, req.Password, req.Format, req.Collation, req.Permission) || strings.Contains(req.Password, " ") {
return nil, buserr.New("ErrCmdIllegal") return nil, buserr.New("ErrCmdIllegal")
} }
if len(req.Username) != 0 && len(req.Password) == 0 { if len(req.Username) != 0 && len(req.Password) == 0 {
@@ -474,13 +509,13 @@ func (u *MysqlService) create(ctx context.Context, req dto.MysqlDBCreate, mcpBou
return nil, errors.New("cannot set root as user name") return nil, errors.New("cannot set root as user name")
} }
cli, version, err := loadMysqlClientByFrom(req.Database, mcpBounded)
if err != nil {
return nil, err
}
createItem.MysqlName = req.Database createItem.MysqlName = req.Database
defer cli.Close() return &mysqlCreation{request: req, dbType: dbType, permissionHosts: permissionHosts, item: createItem}, nil
createInfo := client.CreateInfo{ }
func (creation *mysqlCreation) createInfo(version string) client.CreateInfo {
req := creation.request
return client.CreateInfo{
Name: req.Name, Name: req.Name,
Format: req.Format, Format: req.Format,
Collation: req.Collation, Collation: req.Collation,
@@ -490,55 +525,16 @@ func (u *MysqlService) create(ctx context.Context, req dto.MysqlDBCreate, mcpBou
Version: version, Version: version,
Timeout: 300, Timeout: 300,
} }
if mcpBounded {
local, ok := cli.(*client.Local)
if !ok {
return nil, errors.New("an installed local MySQL instance is required")
}
deadline := time.Now().Add(300 * time.Second)
if writeDeadline, ok := ctx.Deadline(); ok && writeDeadline.Add(-10*time.Second).Before(deadline) {
deadline = writeDeadline.Add(-10 * time.Second)
}
sqlContext, cancel := context.WithDeadline(ctx, deadline)
err = local.CreateWithContext(sqlContext, createInfo)
cancel()
} else {
err = cli.Create(createInfo)
}
if err != nil {
return nil, err
}
if mcpBounded {
if err := persistMCPMysqlMetadata(ctx, req, permissionHosts, &createItem); err != nil {
return nil, err
}
return &createItem, nil
}
if len(req.Username) != 0 {
if err := saveDatabaseUserCredentials(dbType, req.Database, req.Username, req.Permission, req.Password, req.Description); err != nil {
return nil, err
}
for _, host := range permissionHosts {
if err := saveDatabaseUserGrant(dbType, req.Database, req.Name, req.Username, host); err != nil {
return nil, err
}
}
}
global.LOG.Infof("create database %s successful!", req.Name)
if err := mysqlRepo.Create(ctx, &createItem); err != nil {
return nil, err
}
return &createItem, nil
} }
func persistMCPMysqlMetadata(ctx context.Context, req dto.MysqlDBCreate, hosts []string, item *model.DatabaseMysql) error { func saveMysqlCreationMetadata(ctx context.Context, req dto.MysqlDBCreate, hosts []string, item *model.DatabaseMysql) error {
return global.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { return global.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
txCtx := context.WithValue(ctx, constant.DB, tx)
if req.Username != "" { if req.Username != "" {
key := global.CONF.Base.EncryptKey key := global.CONF.Base.EncryptKey
if key == "" { if key == "" {
var setting model.Setting setting, err := settingRepo.GetWithContext(txCtx, repo.WithByKey("EncryptKey"))
if err := tx.Where("key = ?", "EncryptKey").First(&setting).Error; err != nil { if err != nil {
return err return err
} }
key = setting.Value key = setting.Value
@@ -548,8 +544,7 @@ func persistMCPMysqlMetadata(ctx context.Context, req dto.MysqlDBCreate, hosts [
return err return err
} }
for _, host := range hosts { for _, host := range hosts {
var user model.DatabaseUser user, err := databaseUserRepo.GetEncryptedWithContext(txCtx, repo.WithByType("mysql"), databaseUserRepo.WithByDatabase(req.Database), databaseUserRepo.WithByUser(req.Username, host))
err := tx.Where("type = ? AND database = ? AND username = ? AND host = ?", "mysql", req.Database, req.Username, host).First(&user).Error
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return err return err
} }
@@ -558,16 +553,16 @@ func persistMCPMysqlMetadata(ctx context.Context, req dto.MysqlDBCreate, hosts [
if req.Description != "" { if req.Description != "" {
user.Description = req.Description user.Description = req.Description
} }
if err := tx.Save(&user).Error; err != nil { if err := databaseUserRepo.SaveEncryptedWithContext(txCtx, &user); err != nil {
return err return err
} }
grant := model.DatabaseUserGrant{Type: "mysql", Database: req.Database, DBName: req.Name, Username: req.Username, Host: host} grant := model.DatabaseUserGrant{Type: "mysql", Database: req.Database, DBName: req.Name, Username: req.Username, Host: host}
if err := tx.Where("type = ? AND database = ? AND db_name = ? AND username = ? AND host = ?", grant.Type, grant.Database, grant.DBName, grant.Username, grant.Host).FirstOrCreate(&grant).Error; err != nil { if err := databaseUserGrantRepo.CreateIfAbsent(txCtx, &grant); err != nil {
return err return err
} }
} }
} }
return tx.Create(item).Error return mysqlRepo.CreateMetadata(txCtx, item)
}) })
} }
@@ -680,7 +675,7 @@ func (u *MysqlService) ListGrantSummary(req dto.MysqlGrantSummarySearch) (map[st
} }
func (u *MysqlService) CreateUser(req dto.MysqlUserCreate) error { func (u *MysqlService) CreateUser(req dto.MysqlUserCreate) error {
if cmd.CheckIllegal(req.Username, req.Password, req.Host) { if cmd.CheckIllegal(req.Username, req.Password, req.Host) || strings.Contains(req.Password, " ") {
return buserr.New("ErrCmdIllegal") return buserr.New("ErrCmdIllegal")
} }
dbs := make([]string, 0, len(req.DBs)) dbs := make([]string, 0, len(req.DBs))
@@ -883,7 +878,7 @@ func (u *MysqlService) UpdateUser(req dto.MysqlUserUpdate) error {
} }
func (u *MysqlService) ChangeUserPassword(req dto.MysqlUserPassword) error { func (u *MysqlService) ChangeUserPassword(req dto.MysqlUserPassword) error {
if cmd.CheckIllegal(req.Username, req.Host, req.Password) { if cmd.CheckIllegal(req.Username, req.Host, req.Password) || strings.Contains(req.Password, " ") {
return buserr.New("ErrCmdIllegal") return buserr.New("ErrCmdIllegal")
} }
if err := checkMysqlNormalUser(req.Username); err != nil { if err := checkMysqlNormalUser(req.Username); err != nil {
@@ -936,7 +931,7 @@ func (u *MysqlService) ChangeUserPassword(req dto.MysqlUserPassword) error {
} }
func (u *MysqlService) SaveUserPassword(req dto.MysqlUserPassword) error { func (u *MysqlService) SaveUserPassword(req dto.MysqlUserPassword) error {
if cmd.CheckIllegal(req.Username, req.Host, req.Password) { if cmd.CheckIllegal(req.Username, req.Host, req.Password) || strings.Contains(req.Password, " ") {
return buserr.New("ErrCmdIllegal") return buserr.New("ErrCmdIllegal")
} }
if err := checkMysqlNormalUser(req.Username); err != nil { if err := checkMysqlNormalUser(req.Username); err != nil {
@@ -1302,7 +1297,7 @@ func isMysqlDatabaseResourceInUseError(err error) bool {
} }
func (u *MysqlService) ChangePassword(req dto.ChangeDBInfo) error { func (u *MysqlService) ChangePassword(req dto.ChangeDBInfo) error {
if cmd.CheckIllegal(req.Value) { if cmd.CheckIllegal(req.Value) || strings.Contains(req.Value, " ") {
return buserr.New("ErrCmdIllegal") return buserr.New("ErrCmdIllegal")
} }
cli, version, err := LoadMysqlClientByFrom(req.Database) cli, version, err := LoadMysqlClientByFrom(req.Database)
@@ -1603,10 +1598,34 @@ func updateMyCnf(oldFiles []string, group string, param string, value interface{
} }
func LoadMysqlClientByFrom(database string) (mysql.MysqlClient, string, error) { func LoadMysqlClientByFrom(database string) (mysql.MysqlClient, string, error) {
return loadMysqlClientByFrom(database, false) databaseItem, err := databaseRepo.Get(repo.WithByName(database))
if err != nil {
return nil, "", err
}
return newMysqlClientForDatabase(databaseItem, database)
} }
func loadMysqlClientByFrom(database string, mcpLocalOnly bool) (mysql.MysqlClient, string, error) { func loadInstalledLocalMysqlClient(database string) (*client.Local, string, error) {
databaseItem, err := databaseRepo.Get(repo.WithByName(database))
if err != nil {
return nil, "", err
}
if databaseItem.ID == 0 || databaseItem.From != "local" || databaseItem.Type != "mysql" || databaseItem.AppInstallID == 0 {
return nil, "", errors.New("an installed local MySQL instance is required")
}
cli, version, err := newMysqlClientForDatabase(databaseItem, database)
if err != nil {
return nil, "", err
}
local, ok := cli.(*client.Local)
if !ok {
cli.Close()
return nil, "", errors.New("an installed local MySQL instance is required")
}
return local, version, nil
}
func newMysqlClientForDatabase(databaseItem model.Database, database string) (mysql.MysqlClient, string, error) {
var ( var (
dbInfo client.DBInfo dbInfo client.DBInfo
version string version string
@@ -1614,13 +1633,6 @@ func loadMysqlClientByFrom(database string, mcpLocalOnly bool) (mysql.MysqlClien
) )
dbInfo.Timeout = 300 dbInfo.Timeout = 300
databaseItem, err := databaseRepo.Get(repo.WithByName(database))
if err != nil {
return nil, "", err
}
if mcpLocalOnly && (databaseItem.ID == 0 || databaseItem.From != "local" || databaseItem.Type != "mysql" || databaseItem.AppInstallID == 0) {
return nil, "", errors.New("an installed local MySQL instance is required")
}
dbInfo.Type = databaseItem.Type dbInfo.Type = databaseItem.Type
dbInfo.From = databaseItem.From dbInfo.From = databaseItem.From
dbInfo.Database = database dbInfo.Database = database
+27 -1
View File
@@ -16,9 +16,35 @@ func (u *MysqlService) CreateForMCP(ctx context.Context, req dto.MysqlDBCreate)
} }
writeContext, cancel := context.WithTimeout(context.WithoutCancel(ctx), 310*time.Second) writeContext, cancel := context.WithTimeout(context.WithoutCancel(ctx), 310*time.Second)
defer cancel() defer cancel()
created, err := u.create(writeContext, req, true) created, err := createMCPMysqlDatabase(writeContext, req)
if err != nil { if err != nil {
return nil, mcpapi.SanitizeBusinessError(err, "database creation failed; inspect the invocation before retrying") return nil, mcpapi.SanitizeBusinessError(err, "database creation failed; inspect the invocation before retrying")
} }
return created, nil return created, nil
} }
func createMCPMysqlDatabase(ctx context.Context, req dto.MysqlDBCreate) (*model.DatabaseMysql, error) {
creation, err := prepareMysqlCreation(req)
if err != nil {
return nil, err
}
local, version, err := loadInstalledLocalMysqlClient(creation.request.Database)
if err != nil {
return nil, err
}
defer local.Close()
deadline := time.Now().Add(300 * time.Second)
if writeDeadline, ok := ctx.Deadline(); ok && writeDeadline.Add(-10*time.Second).Before(deadline) {
deadline = writeDeadline.Add(-10 * time.Second)
}
sqlContext, cancel := context.WithDeadline(ctx, deadline)
err = local.CreateWithContext(sqlContext, creation.createInfo(version))
cancel()
if err != nil {
return nil, err
}
if err := saveMysqlCreationMetadata(ctx, creation.request, creation.permissionHosts, &creation.item); err != nil {
return nil, err
}
return &creation.item, nil
}
+3 -3
View File
@@ -87,7 +87,7 @@ func (u *PostgresqlService) ListDBOption() ([]dto.PostgresqlOption, error) {
} }
func (u *PostgresqlService) BindUser(req dto.PostgresqlBindUser) error { func (u *PostgresqlService) BindUser(req dto.PostgresqlBindUser) error {
if cmd.CheckIllegal(req.Name, req.Username, req.Password) { if cmd.CheckIllegal(req.Name, req.Username, req.Password) || strings.Contains(req.Password, " ") {
return buserr.New("ErrCmdIllegal") return buserr.New("ErrCmdIllegal")
} }
dbItem, err := postgresqlRepo.Get(postgresqlRepo.WithByPostgresqlName(req.Database), repo.WithByName(req.Name)) dbItem, err := postgresqlRepo.Get(postgresqlRepo.WithByPostgresqlName(req.Database), repo.WithByName(req.Name))
@@ -123,7 +123,7 @@ func (u *PostgresqlService) BindUser(req dto.PostgresqlBindUser) error {
} }
func (u *PostgresqlService) Create(ctx context.Context, req dto.PostgresqlDBCreate) (*model.DatabasePostgresql, error) { func (u *PostgresqlService) Create(ctx context.Context, req dto.PostgresqlDBCreate) (*model.DatabasePostgresql, error) {
if cmd.CheckIllegal(req.Name, req.Username, req.Password, req.Format) { if cmd.CheckIllegal(req.Name, req.Username, req.Password, req.Format) || strings.Contains(req.Password, " ") {
return nil, buserr.New("ErrCmdIllegal") return nil, buserr.New("ErrCmdIllegal")
} }
@@ -366,7 +366,7 @@ func (u *PostgresqlService) ChangePrivileges(req dto.PostgresqlPrivileges) error
} }
func (u *PostgresqlService) ChangePassword(req dto.ChangeDBInfo) error { func (u *PostgresqlService) ChangePassword(req dto.ChangeDBInfo) error {
if cmd.CheckIllegal(req.Value) { if cmd.CheckIllegal(req.Value) || strings.Contains(req.Value, " ") {
return buserr.New("ErrCmdIllegal") return buserr.New("ErrCmdIllegal")
} }
cli, err := LoadPostgresqlClientByFrom(req.Database) cli, err := LoadPostgresqlClientByFrom(req.Database)
+29
View File
@@ -0,0 +1,29 @@
package service
import (
"errors"
"github.com/1Panel-dev/1Panel/agent/buserr"
"github.com/1Panel-dev/1Panel/agent/utils/dockerhub"
)
type ImageRemoteCheckStatus struct {
ProxyRequired bool `json:"proxyRequired"`
}
// GetImageRemoteCheckStatus validates local settings without contacting Docker Hub.
// Both image searches and update checks use the same proxy policy.
func GetImageRemoteCheckStatus() (ImageRemoteCheckStatus, error) {
client, err := dockerhub.NewHTTPClient()
if err != nil {
var business buserr.BusinessError
if errors.As(err, &business) && business.Msg == "ErrImageCheckProxyRequired" {
return ImageRemoteCheckStatus{ProxyRequired: true}, nil
}
return ImageRemoteCheckStatus{}, err
}
if client.Transport != nil {
client.CloseIdleConnections()
}
return ImageRemoteCheckStatus{}, nil
}
+10
View File
@@ -0,0 +1,10 @@
package service
import (
"github.com/1Panel-dev/1Panel/agent/constant"
"github.com/1Panel-dev/1Panel/agent/utils/taskscope"
)
func ConfigurePanelMCPTasks() error {
return taskscope.Configure(taskscope.Rule{Scope: constant.TaskScopePanelMCP, Restricted: true, ExternalExecutor: true})
}
+821
View File
@@ -0,0 +1,821 @@
package service
import (
"context"
"crypto/sha256"
"crypto/tls"
"crypto/x509"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"net"
"net/url"
"os"
"path/filepath"
"slices"
"strconv"
"strings"
"sync"
"time"
"github.com/1Panel-dev/1Panel/agent/app/dto/request"
"github.com/1Panel-dev/1Panel/agent/app/dto/response"
"github.com/1Panel-dev/1Panel/agent/app/model"
"github.com/1Panel-dev/1Panel/agent/app/repo"
"github.com/1Panel-dev/1Panel/agent/constant"
"github.com/1Panel-dev/1Panel/agent/utils/docker"
"github.com/1Panel-dev/1Panel/agent/utils/nginx"
"github.com/1Panel-dev/1Panel/agent/utils/nginx/parser"
"github.com/docker/docker/api/types/container"
"github.com/google/uuid"
"golang.org/x/net/idna"
"gorm.io/gorm"
)
const (
mcpWebsitePrivateDir = ".1panel-mcp"
mcpWebsiteProxyName = "proxy.conf"
mcpWebsiteOwnerName = "binding.json"
)
var mcpWebsiteMutation sync.Mutex
func init() {
configureWebsiteManagedConfig(websiteManagedConfigPolicy{
protectedDirectories: func() []string {
return []string{filepath.Join(GetWebSiteRootDir(), mcpWebsitePrivateDir)}
},
backupIncludes: mcpWebsiteBackupIncludes,
})
}
type MCPWebsiteBindingService struct {
createWebsite func(request.WebsiteCreate) error
updateHTTPS func(context.Context, request.WebsiteHTTPSOp) error
applyInclude func(model.Website) error
removeInclude func(model.Website) error
checkReload func() error
openresty func() (model.AppInstall, error)
now func() time.Time
}
func NewMCPWebsiteBindingService() *MCPWebsiteBindingService {
s := &MCPWebsiteBindingService{
createWebsite: func(req request.WebsiteCreate) error { return NewIWebsiteService().CreateWebsite(req) },
updateHTTPS: func(ctx context.Context, req request.WebsiteHTTPSOp) error {
if req.Enable {
current, err := NewIWebsiteService().GetWebsiteHTTPS(req.WebsiteID)
if err != nil {
return err
}
defaults := newWebsiteCreateHTTPSOp(req.WebsiteSSLID)
if current.Enable {
req.SSLProtocol, req.Algorithm = current.SSLProtocol, current.Algorithm
req.Hsts, req.HstsIncludeSubDomains, req.Http3 = current.Hsts, current.HstsIncludeSubDomains, current.Http3
req.HttpConfig = current.HttpConfig
} else {
defaults.WebsiteID = req.WebsiteID
req = defaults
}
}
_, err := NewIWebsiteService().OpWebsiteHTTPS(ctx, req)
return err
},
checkReload: func() error {
installed, err := getAppInstallByKey(constant.AppOpenresty)
if err != nil {
return err
}
if installed.Status != constant.StatusRunning {
return nil
}
if err = opNginx(installed.ContainerName, constant.NginxCheck); err != nil {
return err
}
return opNginx(installed.ContainerName, constant.NginxReload)
},
openresty: mcpWebsiteOpenresty,
now: time.Now,
}
s.applyInclude = func(site model.Website) error { return s.setInclude(site, true) }
s.removeInclude = func(site model.Website) error { return s.setInclude(site, false) }
return s
}
func mcpWebsiteOpenresty() (model.AppInstall, error) {
installed, err := getAppInstallByKey(constant.AppOpenresty)
if err != nil || installed.ID == 0 || installed.Status != constant.StatusRunning {
return installed, err
}
client, err := docker.NewDockerClient()
if err != nil {
return model.AppInstall{}, errors.New("OpenResty mount is unavailable")
}
defer client.Close()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
inspected, err := client.ContainerInspect(ctx, installed.ContainerName)
if err != nil || !mcpWebsiteHasWWWMount(inspected.Mounts, GetWebSiteRootDir()) {
return model.AppInstall{}, errors.New("OpenResty mount is unavailable")
}
return installed, nil
}
func mcpWebsiteHasWWWMount(mounts []container.MountPoint, root string) bool {
resolvedRoot, err := filepath.EvalSymlinks(root)
if err != nil {
return false
}
for _, mount := range mounts {
if filepath.Clean(mount.Destination) != "/www" {
continue
}
resolvedSource, sourceErr := filepath.EvalSymlinks(mount.Source)
if sourceErr == nil && resolvedSource == resolvedRoot {
return true
}
}
return false
}
type mcpWebsiteOwner struct {
BindingID string `json:"bindingId"`
WebsiteID uint `json:"websiteId"`
KeyHash string `json:"keyHash"`
BindHash string `json:"bindHash"`
ProxyHash string `json:"proxyHash"`
RequestID string `json:"requestId"`
RequestHash string `json:"requestHash"`
Operation string `json:"operation"`
}
func mcpWebsiteHash(value string) string {
digest := sha256.Sum256([]byte(value))
return hex.EncodeToString(digest[:])
}
func mcpWebsiteAlias(bindingID string) string { return "panel-mcp-" + bindingID }
func mcpWebsitePrivatePath(site model.Website, name string) string {
return filepath.Join(GetWebSiteRootDir(), mcpWebsitePrivateDir, strings.TrimPrefix(site.Alias, "panel-mcp-"), name)
}
func mcpWebsiteInclude(site model.Website) string {
return "/www/" + mcpWebsitePrivateDir + "/" + strings.TrimPrefix(site.Alias, "panel-mcp-") + "/" + mcpWebsiteProxyName
}
func mcpWebsiteBackupIncludes(site model.Website) []string {
bindingID := strings.TrimPrefix(site.Alias, "panel-mcp-")
if site.Alias != mcpWebsiteAlias(bindingID) || !validMCPWebsiteID(bindingID) {
return nil
}
return []string{mcpWebsiteInclude(site)}
}
func mcpWebsitePrivateDirectory(site model.Website, create bool) error {
for _, directory := range []string{filepath.Join(GetWebSiteRootDir(), mcpWebsitePrivateDir), filepath.Dir(mcpWebsitePrivatePath(site, mcpWebsiteProxyName))} {
info, err := os.Lstat(directory)
if os.IsNotExist(err) && create {
if err = os.Mkdir(directory, 0700); err != nil {
return err
}
info, err = os.Lstat(directory)
}
if err != nil || !info.IsDir() || info.Mode()&os.ModeSymlink != 0 || info.Mode().Perm() != 0700 {
return errors.New("invalid MCP website private directory")
}
}
return nil
}
func (s *MCPWebsiteBindingService) setInclude(site model.Website, enabled bool) error {
configPath := GetSitePath(site, SiteConf)
old, err := os.ReadFile(configPath)
if err != nil {
return err
}
config, err := parser.NewStringParser(string(old)).Parse()
if err != nil || len(config.FindServers()) == 0 {
return errors.New("invalid website configuration")
}
server := config.FindServers()[0]
include := mcpWebsiteInclude(site)
for _, directive := range server.FindDirectives("include") {
parameters := directive.GetParameters()
if len(parameters) == 1 && strings.Trim(parameters[0], "\"'") == include {
server.RemoveDirective("include", parameters)
}
}
if enabled {
server.UpdateDirective("include", []string{include})
}
updated := []byte(nginx.DumpConfig(config, nginx.IndentedStyle))
if string(updated) == string(old) {
return nil
}
if err = mcpWebsiteAtomicWrite(configPath, updated); err != nil {
return err
}
if err = s.checkReload(); err != nil {
_ = mcpWebsiteAtomicWrite(configPath, old)
_ = s.checkReload()
return err
}
return nil
}
func mcpWebsiteRemark(req request.MCPWebsiteBinding) string {
return "1Panel MCP " + req.BindingID + " " + mcpWebsiteHash(req.ProxyKey)
}
func mcpWebsiteFailure(reason string, definitive, conflict bool) response.MCPWebsiteBinding {
code, message := "MCP_WEBSITE_UNAVAILABLE", "MCP website operation is unavailable"
if definitive {
code, message = "MCP_WEBSITE_INVALID_ARGUMENTS", "Invalid MCP website configuration"
}
if conflict {
code, message = "MCP_WEBSITE_CONFLICT", "MCP website configuration changed"
}
return response.MCPWebsiteBinding{State: "unavailable", Reason: reason, Code: code, Error: message, Definitive: definitive}
}
func validMCPWebsiteID(value string) bool {
parsed, err := uuid.Parse(value)
return err == nil && parsed != uuid.Nil && parsed.String() == value
}
func mcpWebsiteDomain(value string) (string, int, error) {
if value == "" || len(value) > 320 || strings.TrimSpace(value) != value || strings.ContainsAny(value, "/\\?#@\r\n\t ;{}\"'$\x00") {
return "", 0, errors.New("invalid domain")
}
host, port := value, 0
if parsedHost, parsedPort, err := net.SplitHostPort(value); err == nil {
host = parsedHost
port, err = strconv.Atoi(parsedPort)
if err != nil || port < 1 || port > 65535 {
return "", 0, errors.New("invalid port")
}
} else if strings.ContainsAny(value, ":[]") {
return "", 0, errors.New("invalid domain")
}
host, err := idna.Lookup.ToASCII(strings.ToLower(host))
if err != nil || len(host) > 253 || net.ParseIP(host) != nil {
return "", 0, errors.New("invalid domain")
}
for _, label := range strings.Split(host, ".") {
if label == "" || len(label) > 63 || label[0] == '-' || label[len(label)-1] == '-' {
return "", 0, errors.New("invalid domain")
}
for _, char := range label {
if char != '-' && (char < 'a' || char > 'z') && (char < '0' || char > '9') {
return "", 0, errors.New("invalid domain")
}
}
}
return host, port, nil
}
// Core can select a locally bound panel IP, but an external upstream is never
// accepted even on this private API. Host/SNI is a literal hostname, not Nginx code.
func validateMCPWebsiteUpstream(req request.MCPWebsiteBinding) bool {
if strings.ContainsAny(req.Upstream, "\r\n\t ;{}\"'$#?") {
return false
}
u, err := url.Parse(req.Upstream)
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Path != "/mcp" || u.RawPath != "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" || u.Port() == "" {
return false
}
ip := net.ParseIP(u.Hostname())
if ip == nil || ip.IsUnspecified() {
return false
}
port, err := strconv.Atoi(u.Port())
if err != nil || port < 1 || port > 65535 {
return false
}
local := ip.IsLoopback()
if !local {
addresses, err := net.InterfaceAddrs()
if err != nil {
return false
}
for _, address := range addresses {
if network, ok := address.(*net.IPNet); ok && network.IP.Equal(ip) {
local = true
break
}
}
}
if !local || len(req.ProxyKey) != 64 {
return false
}
if _, err = hex.DecodeString(req.ProxyKey); err != nil {
return false
}
if req.CoreHost == "" || len(req.CoreHost) > 320 || strings.ContainsAny(req.CoreHost, "/\\?#@\r\n\t ;{}\"'$\x00") {
return false
}
host := req.CoreHost
if parsed, _, splitErr := net.SplitHostPort(host); splitErr == nil {
host = parsed
}
if strings.HasPrefix(host, "[") && strings.HasSuffix(host, "]") {
host = strings.Trim(host, "[]")
}
if net.ParseIP(host) == nil {
if _, _, domainErr := mcpWebsiteDomain(host); domainErr != nil {
return false
}
}
return true
}
func mcpWebsiteProxy(req request.MCPWebsiteBinding) []byte {
host := req.CoreHost
if parsed, _, err := net.SplitHostPort(host); err == nil {
host = parsed
}
host = strings.Trim(host, "[]")
var tlsDirectives string
if strings.HasPrefix(req.Upstream, "https://") {
tlsDirectives = " proxy_ssl_server_name on;\n proxy_ssl_name " + host + ";\n"
}
return []byte("# Managed by 1Panel MCP: " + req.BindingID + "\n" +
"location = /mcp {\n" +
" proxy_pass " + req.Upstream + ";\n" +
" proxy_http_version 1.1;\n" +
" proxy_set_header Host " + req.CoreHost + ";\n" +
" proxy_set_header Connection \"\";\n" +
" proxy_set_header X-Panel-MCP-Website-Key " + req.ProxyKey + ";\n" +
" proxy_set_header X-Panel-MCP-Website-Peer $realip_remote_addr;\n" +
" proxy_set_header X-Forwarded-For $http_x_forwarded_for;\n" +
" proxy_set_header X-Real-IP $http_x_real_ip;\n" +
" proxy_set_header Forwarded $http_forwarded;\n" +
" proxy_set_header Authorization $http_authorization;\n" +
" proxy_set_header Origin $http_origin;\n" +
" proxy_set_header Accept $http_accept;\n" +
" proxy_set_header Content-Type $http_content_type;\n" +
" proxy_set_header MCP-Protocol-Version $http_mcp_protocol_version;\n" +
" proxy_buffering off;\n proxy_request_buffering off;\n proxy_cache off;\n" +
" proxy_read_timeout 360s;\n proxy_send_timeout 360s;\n proxy_connect_timeout 5s;\n" +
" proxy_next_upstream off;\n client_max_body_size 1m;\n" + tlsDirectives + "}\n")
}
func (s *MCPWebsiteBindingService) validCertificate(id uint, domain string) bool {
certificate, err := websiteSSLRepo.GetFirst(repo.WithByID(id))
if err != nil {
return false
}
pair, err := tls.X509KeyPair([]byte(certificate.Pem), []byte(certificate.PrivateKey))
if err != nil || len(pair.Certificate) == 0 {
return false
}
leaf, err := x509.ParseCertificate(pair.Certificate[0])
if err != nil || s.now().Before(leaf.NotBefore) || !s.now().Before(leaf.NotAfter) || leaf.VerifyHostname(domain) != nil {
return false
}
if len(leaf.ExtKeyUsage) == 0 {
return true
}
for _, usage := range leaf.ExtKeyUsage {
if usage == x509.ExtKeyUsageServerAuth || usage == x509.ExtKeyUsageAny {
return true
}
}
return false
}
func mcpWebsiteReadOwner(site model.Website) (mcpWebsiteOwner, error) {
var owner mcpWebsiteOwner
if err := mcpWebsitePrivateDirectory(site, false); err != nil {
return owner, err
}
path := mcpWebsitePrivatePath(site, mcpWebsiteOwnerName)
info, err := os.Lstat(path)
if err != nil || !info.Mode().IsRegular() || info.Size() > 8192 {
return owner, errors.New("invalid MCP website ownership")
}
data, err := os.ReadFile(path)
if err != nil || json.Unmarshal(data, &owner) != nil {
return owner, errors.New("invalid MCP website ownership")
}
return owner, nil
}
func mcpWebsiteWriteOwner(site model.Website, owner mcpWebsiteOwner) error {
data, err := json.Marshal(owner)
if err != nil {
return err
}
return mcpWebsiteAtomicWrite(mcpWebsitePrivatePath(site, mcpWebsiteOwnerName), data)
}
func mcpWebsiteAtomicWrite(path string, data []byte) error {
if info, err := os.Lstat(path); err == nil && !info.Mode().IsRegular() {
return errors.New("managed file is not regular")
} else if err != nil && !os.IsNotExist(err) {
return err
}
temp, err := os.CreateTemp(filepath.Dir(path), ".panel-mcp-*")
if err != nil {
return err
}
defer os.Remove(temp.Name())
if err = temp.Chmod(0600); err == nil {
_, err = temp.Write(data)
}
if closeErr := temp.Close(); err == nil {
err = closeErr
}
if err != nil {
return err
}
return os.Rename(temp.Name(), path)
}
func mcpWebsiteCurrentProxy(site model.Website, owner mcpWebsiteOwner) ([]byte, error) {
if err := mcpWebsitePrivateDirectory(site, false); err != nil {
return nil, err
}
path := mcpWebsitePrivatePath(site, mcpWebsiteProxyName)
info, err := os.Lstat(path)
if err != nil || !info.Mode().IsRegular() || info.Size() > 16<<10 {
return nil, errors.New("managed proxy is missing or invalid")
}
data, err := os.ReadFile(path)
if err != nil || mcpWebsiteHash(string(data)) != owner.ProxyHash {
return nil, errors.New("managed proxy was modified")
}
return data, nil
}
func mcpWebsiteReadProxy(path string) ([]byte, error) {
info, err := os.Lstat(path)
if err != nil {
return nil, err
}
if !info.Mode().IsRegular() || info.Size() > 16<<10 {
return nil, errors.New("managed proxy is not a regular file")
}
return os.ReadFile(path)
}
func mcpWebsitePayloadHash(req request.MCPWebsiteBinding) string {
return mcpWebsiteHash(fmt.Sprintf("%s\n%s\n%t\n%d", req.Operation, req.Domain, req.EnableSSL, req.SSLID))
}
func mcpWebsiteBindHash(req request.MCPWebsiteBinding) string {
return mcpWebsiteHash(fmt.Sprintf("%s\n%t\n%d", req.Domain, req.EnableSSL, req.SSLID))
}
func (s *MCPWebsiteBindingService) matchesInitialSite(req request.MCPWebsiteBinding, site model.Website) bool {
host, port, err := mcpWebsiteDomain(req.Domain)
if err != nil || (site.Protocol == constant.ProtocolHTTPS) != req.EnableSSL || site.WebsiteSSLID != req.SSLID {
return false
}
if port == 0 {
installed, installErr := s.openresty()
if installErr != nil {
return false
}
port = installed.HttpPort
if req.EnableSSL {
port = installed.HttpsPort
}
}
for _, domain := range site.Domains {
if domain.Domain == host && domain.Port == port {
return true
}
}
return false
}
func (s *MCPWebsiteBindingService) load(req request.MCPWebsiteBinding) (model.Website, error) {
if req.WebsiteID > 0 {
return websiteRepo.GetFirst(repo.WithByID(req.WebsiteID))
}
return websiteRepo.GetFirst(websiteRepo.WithAlias(mcpWebsiteAlias(req.BindingID)))
}
func mcpWebsiteProxyIncluded(site model.Website) bool {
server, err := getServer(site)
if err != nil {
return false
}
include := mcpWebsiteInclude(site)
for _, directive := range server.FindDirectives("include") {
parameters := directive.GetParameters()
if len(parameters) == 1 && strings.Trim(parameters[0], "\"'") == include {
return true
}
}
return false
}
func (s *MCPWebsiteBindingService) status(req request.MCPWebsiteBinding, site model.Website, owner mcpWebsiteOwner) response.MCPWebsiteBinding {
result := response.MCPWebsiteBinding{State: "bound", WebsiteID: site.ID, BindingID: req.BindingID, RequestID: owner.RequestID,
Domain: site.PrimaryDomain, EnableSSL: site.Protocol == constant.ProtocolHTTPS, SSLID: site.WebsiteSSLID, WebsiteStatus: site.Status}
if !result.EnableSSL {
result.SSLID = 0
}
if owner.Operation == "unbind" {
result.State = "unbound"
return result
}
if _, err := mcpWebsiteCurrentProxy(site, owner); err != nil {
result.State, result.Reason = "unavailable", "proxy_modified"
return result
}
if mcpWebsiteHash(string(mcpWebsiteProxy(req))) != owner.ProxyHash {
result.State, result.Reason = "unavailable", "upstream_changed"
return result
}
if site.Status != constant.WebRunning {
result.State, result.Reason = "unavailable", "website_stopped"
return result
}
if !mcpWebsiteProxyIncluded(site) {
result.State, result.Reason = "unavailable", "proxy_not_loaded"
return result
}
host, _, err := mcpWebsiteDomain(site.PrimaryDomain)
if err != nil {
result.State, result.Reason = "unavailable", "website_modified"
return result
}
if result.EnableSSL && !s.validCertificate(site.WebsiteSSLID, host) {
result.State, result.Reason = "unavailable", "certificate_invalid"
return result
}
installed, err := s.openresty()
if err != nil || installed.ID == 0 || installed.Status != constant.StatusRunning {
result.State, result.Reason = "unavailable", "openresty_unavailable"
return result
}
matching, matchingSSL := 0, 0
for _, domain := range site.Domains {
if domain.Domain != host || domain.Port < 1 {
continue
}
if matching == 0 || !domain.SSL {
matching = domain.Port
}
if domain.SSL {
matchingSSL = domain.Port
}
}
if matching == 0 {
result.State, result.Reason = "unavailable", "website_modified"
return result
}
port := matching
if result.EnableSSL {
port = matchingSSL
if port == 0 {
plan := buildWebsiteTLSPlan(site.Domains, installed.HttpPort, installed.HttpsPort)
if slices.Contains(plan.httpsPorts, installed.HttpsPort) {
port = installed.HttpsPort
} else if len(plan.httpsPorts) > 0 {
port = plan.httpsPorts[0]
}
}
}
if port == 0 {
result.State, result.Reason = "unavailable", "website_modified"
return result
}
scheme := "http"
if result.EnableSSL {
scheme = "https"
}
address := host
if result.EnableSSL && port != 443 || !result.EnableSSL && port != 80 {
address = net.JoinHostPort(host, strconv.Itoa(port))
}
result.Endpoint = (&url.URL{Scheme: scheme, Host: address, Path: "/mcp"}).String()
return result
}
func (s *MCPWebsiteBindingService) Handle(ctx context.Context, req request.MCPWebsiteBinding) (result response.MCPWebsiteBinding) {
mcpWebsiteMutation.Lock()
defer mcpWebsiteMutation.Unlock()
mutationStarted := false
defer func() {
if mutationStarted && result.Error != "" {
result.Definitive = false
}
}()
if req.Operation == "bind" && validMCPWebsiteID(req.BindingID) {
_, existingErr := websiteRepo.GetFirst(websiteRepo.WithAlias(mcpWebsiteAlias(req.BindingID)))
_, privateErr := os.Lstat(filepath.Join(GetWebSiteRootDir(), mcpWebsitePrivateDir, req.BindingID))
mutationStarted = !errors.Is(existingErr, gorm.ErrRecordNotFound) || !os.IsNotExist(privateErr)
}
if ctx.Err() != nil {
return mcpWebsiteFailure("request_cancelled", true, false)
}
if req.Operation != "status" && req.Operation != "bind" && req.Operation != "update" && req.Operation != "unbind" && req.Operation != "sync" {
return mcpWebsiteFailure("invalid_operation", true, false)
}
if req.BindingID == "" && req.WebsiteID == 0 && req.Operation == "status" {
return response.MCPWebsiteBinding{State: "unbound"}
}
if !validMCPWebsiteID(req.BindingID) || !validateMCPWebsiteUpstream(req) {
return mcpWebsiteFailure("invalid_configuration", true, false)
}
if req.Operation != "status" && req.Operation != "sync" && !validMCPWebsiteID(req.RequestID) {
return mcpWebsiteFailure("invalid_request", true, false)
}
if req.Operation == "bind" {
host, port, err := mcpWebsiteDomain(req.Domain)
if err != nil || (!req.EnableSSL && req.SSLID != 0) || (req.EnableSSL && (req.SSLID == 0 || !s.validCertificate(req.SSLID, host))) {
return mcpWebsiteFailure("invalid_domain_or_certificate", true, false)
}
req.Domain = host
if port != 0 {
req.Domain = net.JoinHostPort(host, strconv.Itoa(port))
}
}
site, err := s.load(req)
if errors.Is(err, gorm.ErrRecordNotFound) {
if req.Operation == "status" || req.Operation == "unbind" {
if req.WebsiteID == 0 || req.Operation == "unbind" {
return response.MCPWebsiteBinding{State: "unbound", BindingID: req.BindingID, RequestID: req.RequestID}
}
return response.MCPWebsiteBinding{State: "unavailable", WebsiteID: req.WebsiteID, BindingID: req.BindingID, Reason: "website_missing"}
}
if req.Operation != "bind" {
return mcpWebsiteFailure("website_missing", true, true)
}
installed, installErr := s.openresty()
if installErr != nil || installed.ID == 0 || installed.Status != constant.StatusRunning {
return mcpWebsiteFailure("openresty_unavailable", true, false)
}
group, groupErr := groupRepo.Get(groupRepo.WithByWebsiteDefault())
if groupErr != nil || group.ID == 0 {
return mcpWebsiteFailure("website_group_unavailable", true, false)
}
domain, port, _ := mcpWebsiteDomain(req.Domain)
if port == 0 {
port = installed.HttpPort
if req.EnableSSL {
port = installed.HttpsPort
}
}
if existing, conflictErr := websiteDomainRepo.GetFirst(websiteDomainRepo.WithDomain(domain), websiteDomainRepo.WithPort(port)); conflictErr == nil && existing.ID > 0 {
return mcpWebsiteFailure("domain_conflict", true, true)
} else if conflictErr != nil && !errors.Is(conflictErr, gorm.ErrRecordNotFound) {
return mcpWebsiteFailure("website_unavailable", false, false)
}
create := request.WebsiteCreate{Alias: mcpWebsiteAlias(req.BindingID), Type: constant.Static, Remark: mcpWebsiteRemark(req), WebsiteGroupID: group.ID,
Domains: []request.WebsiteDomain{{Domain: domain, Port: port, SSL: req.EnableSSL}}, SSLConfig: request.SSLConfig{EnableSSL: req.EnableSSL, WebsiteSSLID: req.SSLID}}
mutationStarted = true
if createErr := s.createWebsite(create); createErr != nil {
return mcpWebsiteFailure("website_create_failed", false, false)
}
site, err = s.load(req)
}
if err != nil {
return mcpWebsiteFailure("website_unavailable", false, false)
}
if site.Alias != mcpWebsiteAlias(req.BindingID) {
return mcpWebsiteFailure("website_ownership_changed", true, true)
}
owner, ownerErr := mcpWebsiteReadOwner(site)
if ownerErr == nil && owner.RequestID == req.RequestID && req.RequestID != "" {
mutationStarted = true
}
if req.Operation == "update" {
if req.Domain != "" && req.Domain != site.PrimaryDomain {
return mcpWebsiteFailure("domain_changed", true, true)
}
req.Domain = site.PrimaryDomain
host, _, domainErr := mcpWebsiteDomain(req.Domain)
if domainErr != nil || (!req.EnableSSL && req.SSLID != 0) || (req.EnableSSL && (req.SSLID == 0 || !s.validCertificate(req.SSLID, host))) {
return mcpWebsiteFailure("invalid_domain_or_certificate", true, false)
}
}
if info, dirErr := os.Lstat(GetSitePath(site, SiteDir)); dirErr == nil && (!info.IsDir() || info.Mode()&os.ModeSymlink != 0) {
return mcpWebsiteFailure("website_directory_changed", true, true)
} else if dirErr != nil && !os.IsNotExist(dirErr) {
return mcpWebsiteFailure("website_unavailable", false, false)
}
if ownerErr != nil {
if (req.Operation != "bind" && req.Operation != "unbind") || site.Remark != mcpWebsiteRemark(req) {
return mcpWebsiteFailure("website_ownership_changed", true, true)
}
if req.Operation == "bind" && !s.matchesInitialSite(req, site) {
return mcpWebsiteFailure("binding_request_changed", true, true)
}
owner = mcpWebsiteOwner{BindingID: req.BindingID, WebsiteID: site.ID, KeyHash: mcpWebsiteHash(req.ProxyKey), BindHash: mcpWebsiteBindHash(req)}
} else if owner.BindingID != req.BindingID || owner.WebsiteID != site.ID || owner.KeyHash != mcpWebsiteHash(req.ProxyKey) {
return mcpWebsiteFailure("website_ownership_changed", true, true)
}
if req.Operation == "status" {
return s.status(req, site, owner)
}
operationHash := mcpWebsitePayloadHash(req)
if owner.RequestID == req.RequestID && req.RequestID != "" {
if owner.RequestHash != operationHash {
return mcpWebsiteFailure("operation_request_changed", true, true)
}
if owner.Operation == "unbind" || owner.ProxyHash == mcpWebsiteHash(string(mcpWebsiteProxy(req))) {
return s.status(req, site, owner)
}
}
if req.Operation == "bind" && owner.BindHash != mcpWebsiteBindHash(req) {
return mcpWebsiteFailure("binding_request_changed", true, true)
}
if owner.ProxyHash != "" && owner.Operation != "unbind" && req.Operation != "unbind" && !mcpWebsiteProxyIncluded(site) {
return mcpWebsiteFailure("proxy_not_loaded", true, true)
}
proxyPath := mcpWebsitePrivatePath(site, mcpWebsiteProxyName)
if err = mcpWebsitePrivateDirectory(site, true); err != nil {
return mcpWebsiteFailure("proxy_directory_changed", true, true)
}
if ownerErr != nil {
mutationStarted = true
if err = mcpWebsiteWriteOwner(site, owner); err != nil {
return mcpWebsiteFailure("ownership_write_failed", false, false)
}
}
var oldProxy []byte
if owner.ProxyHash != "" && owner.Operation != "unbind" {
oldProxy, err = mcpWebsiteCurrentProxy(site, owner)
if err != nil {
actual, readErr := mcpWebsiteReadProxy(proxyPath)
if req.Operation == "unbind" && os.IsNotExist(readErr) {
oldProxy = nil
} else if readErr == nil && string(actual) == string(mcpWebsiteProxy(req)) {
oldProxy = actual
} else {
return mcpWebsiteFailure("proxy_modified", true, true)
}
}
} else if _, statErr := os.Lstat(proxyPath); statErr == nil {
actual, readErr := mcpWebsiteReadProxy(proxyPath)
if readErr != nil || string(actual) != string(mcpWebsiteProxy(req)) {
return mcpWebsiteFailure("proxy_already_exists", true, true)
}
oldProxy = actual
} else if !os.IsNotExist(statErr) {
return mcpWebsiteFailure("proxy_unavailable", false, false)
}
if req.Operation == "unbind" {
if mcpWebsiteProxyIncluded(site) {
mutationStarted = true
if err = s.removeInclude(site); err != nil {
return mcpWebsiteFailure("proxy_reload_failed", false, false)
}
}
if len(oldProxy) > 0 {
mutationStarted = true
if err = os.Remove(proxyPath); err != nil {
return mcpWebsiteFailure("proxy_remove_failed", false, false)
}
}
owner.Operation, owner.RequestID, owner.RequestHash = "unbind", req.RequestID, operationHash
mutationStarted = true
if err = mcpWebsiteWriteOwner(site, owner); err != nil {
return mcpWebsiteFailure("ownership_write_failed", false, false)
}
return s.status(req, site, owner)
}
if req.Operation == "update" {
if req.Domain != site.PrimaryDomain {
return mcpWebsiteFailure("domain_changed", true, true)
}
if site.Protocol == constant.ProtocolHTTPS && !req.EnableSSL || site.Protocol != constant.ProtocolHTTPS && req.EnableSSL || req.EnableSSL && site.WebsiteSSLID != req.SSLID {
mutationStarted = true
if err = s.updateHTTPS(context.WithoutCancel(ctx), request.WebsiteHTTPSOp{WebsiteID: site.ID, Enable: req.EnableSSL, Type: constant.SSLExisted, WebsiteSSLID: req.SSLID, HttpConfig: constant.HTTPSOnly}); err != nil {
return mcpWebsiteFailure("certificate_update_failed", false, false)
}
site, err = s.load(req)
if err != nil {
return mcpWebsiteFailure("website_unavailable", false, false)
}
}
}
newProxy := mcpWebsiteProxy(req)
mutationStarted = true
if err = mcpWebsiteAtomicWrite(proxyPath, newProxy); err != nil {
return mcpWebsiteFailure("proxy_write_failed", false, false)
}
if err = s.applyInclude(site); err != nil {
if len(oldProxy) == 0 {
_ = os.Remove(proxyPath)
} else {
_ = mcpWebsiteAtomicWrite(proxyPath, oldProxy)
}
_ = s.checkReload()
return mcpWebsiteFailure("proxy_reload_failed", false, false)
}
owner.ProxyHash = mcpWebsiteHash(string(newProxy))
if req.Operation != "sync" || req.RequestID != "" {
owner.RequestID, owner.RequestHash, owner.Operation = req.RequestID, operationHash, req.Operation
}
if err = mcpWebsiteWriteOwner(site, owner); err != nil {
return mcpWebsiteFailure("ownership_write_failed", false, false)
}
return s.status(req, site, owner)
}
+23 -2
View File
@@ -647,7 +647,7 @@ func (r *RuntimeService) Get(id uint) (*response.RuntimeDTO, error) {
return &res, nil return &res, nil
} }
func (r *RuntimeService) Update(req request.RuntimeUpdate) error { func (r *RuntimeService) Update(req request.RuntimeUpdate) (err error) {
runtime, err := runtimeRepo.GetFirst(context.Background(), repo.WithByID(req.ID)) runtime, err := runtimeRepo.GetFirst(context.Background(), repo.WithByID(req.ID))
if err != nil { if err != nil {
return err return err
@@ -656,6 +656,7 @@ func (r *RuntimeService) Update(req request.RuntimeUpdate) error {
runtime.Version = req.Version runtime.Version = req.Version
return runtimeRepo.Save(runtime) return runtimeRepo.Save(runtime)
} }
oldRuntime := *runtime
appDetail, err := getRuntimeUpdateAppDetail(runtime, req) appDetail, err := getRuntimeUpdateAppDetail(runtime, req)
if err != nil { if err != nil {
return err return err
@@ -700,6 +701,20 @@ func (r *RuntimeService) Update(req request.RuntimeUpdate) error {
if err = downloadApp(app, appDetail, nil, nil); err != nil { if err = downloadApp(app, appDetail, nil, nil); err != nil {
return err return err
} }
var snapshot *runtimeUpdateSnapshot
if runtime.Type != constant.RuntimePHP {
snapshot, err = snapshotRuntimeUpdate(&oldRuntime)
if err != nil {
return err
}
defer func() {
if err != nil {
if restoreErr := snapshot.restoreFiles(); restoreErr != nil {
err = fmt.Errorf("%w; restore runtime configuration: %v", err, restoreErr)
}
}
}()
}
if versionChanged { if versionChanged {
if err = updateRuntimeVersionFiles(runtime, appVersionDir); err != nil { if err = updateRuntimeVersionFiles(runtime, appVersionDir); err != nil {
return err return err
@@ -773,7 +788,13 @@ func (r *RuntimeService) Update(req request.RuntimeUpdate) error {
if err = runtimeRepo.Save(runtime); err != nil { if err = runtimeRepo.Save(runtime); err != nil {
return err return err
} }
go reCreateRuntime(runtime) go func() {
if err := prepareRuntimeUpdate(runtime, snapshot); err != nil {
global.LOG.Errorf("prepare runtime [%s] update failed: %v", runtime.Name, err)
return
}
reCreateRuntime(runtime)
}()
} }
return nil return nil
} }
+86
View File
@@ -1,6 +1,9 @@
package service package service
import ( import (
"errors"
"fmt"
"os"
"path/filepath" "path/filepath"
"github.com/1Panel-dev/1Panel/agent/app/dto/request" "github.com/1Panel-dev/1Panel/agent/app/dto/request"
@@ -12,6 +15,89 @@ import (
"gopkg.in/yaml.v3" "gopkg.in/yaml.v3"
) )
// Keep the on-disk configuration as well as the model: files may contain edits
// that have not been reflected in the database.
type runtimeUpdateSnapshot struct {
runtime model.Runtime
files []runtimeUpdateFile
}
type runtimeUpdateFile struct {
path string
content []byte
mode os.FileMode
exists bool
}
func snapshotRuntimeUpdate(runtime *model.Runtime) (*runtimeUpdateSnapshot, error) {
snapshot := &runtimeUpdateSnapshot{runtime: *runtime}
for _, name := range []string{"docker-compose.yml", ".env", "run.sh"} {
file := runtimeUpdateFile{path: filepath.Join(runtime.GetPath(), name)}
info, err := os.Stat(file.path)
if errors.Is(err, os.ErrNotExist) {
snapshot.files = append(snapshot.files, file)
continue
}
if err != nil {
return nil, err
}
file.content, err = os.ReadFile(file.path)
if err != nil {
return nil, err
}
file.exists, file.mode = true, info.Mode().Perm()
snapshot.files = append(snapshot.files, file)
}
return snapshot, nil
}
func (s *runtimeUpdateSnapshot) restoreFiles() error {
var result error
for _, file := range s.files {
var err error
if file.exists {
err = os.WriteFile(file.path, file.content, file.mode)
if err == nil {
err = os.Chmod(file.path, file.mode)
}
} else {
err = os.Remove(file.path)
if errors.Is(err, os.ErrNotExist) {
err = nil
}
}
if err != nil {
result = errors.Join(result, fmt.Errorf("restore %s: %w", file.path, err))
}
}
return result
}
// This must finish before reCreateRuntime stops the existing container. A
// failed preparation restores the previous version without touching Docker.
func prepareRuntimeUpdate(runtime *model.Runtime, snapshot *runtimeUpdateSnapshot) error {
if err := pullRuntimeComposeImages(runtime.GetComposePath(), false, nil); err != nil {
result := fmt.Errorf("prepare runtime images: %w", err)
restoreErr := snapshot.restoreFiles()
*runtime = snapshot.runtime
if restoreErr != nil {
result = errors.Join(result, restoreErr)
runtime.Status = constant.StatusError
}
runtime.Message = result.Error()
if saveErr := runtimeRepo.Save(runtime); saveErr != nil {
result = errors.Join(result, fmt.Errorf("restore runtime record: %w", saveErr))
}
logFile, logErr := os.OpenFile(runtime.GetLogPath(), os.O_CREATE|os.O_WRONLY|os.O_APPEND, constant.FilePerm)
if logErr == nil {
_, logErr = fmt.Fprintln(logFile, result.Error())
logErr = errors.Join(logErr, logFile.Close())
}
return errors.Join(result, logErr)
}
return nil
}
func getRuntimeUpdateAppDetail(runtime *model.Runtime, req request.RuntimeUpdate) (model.AppDetail, error) { func getRuntimeUpdateAppDetail(runtime *model.Runtime, req request.RuntimeUpdate) (model.AppDetail, error) {
current, err := appDetailRepo.GetFirst(repo.WithByID(runtime.AppDetailID)) current, err := appDetailRepo.GetFirst(repo.WithByID(runtime.AppDetailID))
if err != nil { if err != nil {
+26 -6
View File
@@ -8,9 +8,12 @@ import (
"github.com/1Panel-dev/1Panel/agent/app/dto/response" "github.com/1Panel-dev/1Panel/agent/app/dto/response"
"github.com/1Panel-dev/1Panel/agent/app/repo" "github.com/1Panel-dev/1Panel/agent/app/repo"
"github.com/1Panel-dev/1Panel/agent/utils/files" "github.com/1Panel-dev/1Panel/agent/utils/files"
"github.com/1Panel-dev/1Panel/agent/utils/taskscope"
) )
type TaskLogService struct{} type TaskLogService struct {
grantedScopes []string
}
type ITaskLogService interface { type ITaskLogService interface {
Page(req dto.SearchTaskLogReq) (int64, []dto.TaskDTO, error) Page(req dto.SearchTaskLogReq) (int64, []dto.TaskDTO, error)
@@ -19,13 +22,18 @@ type ITaskLogService interface {
CountExecutingTask() (int64, error) CountExecutingTask() (int64, error)
} }
func NewITaskService() ITaskLogService { func NewITaskService(grantedScopes ...string) ITaskLogService {
return &TaskLogService{} return &TaskLogService{grantedScopes: append([]string(nil), grantedScopes...)}
} }
func (u *TaskLogService) Page(req dto.SearchTaskLogReq) (int64, []dto.TaskDTO, error) { func (u *TaskLogService) Page(req dto.SearchTaskLogReq) (int64, []dto.TaskDTO, error) {
excluded, err := taskscope.ReadExclusions(u.grantedScopes...)
if err != nil {
return 0, nil, err
}
opts := []repo.DBOption{ opts := []repo.DBOption{
repo.WithOrderDesc("created_at"), repo.WithOrderDesc("created_at"),
taskRepo.WithoutScopes(excluded...),
} }
if req.TaskID != "" { if req.TaskID != "" {
opts = append(opts, taskRepo.WithByID(req.TaskID)) opts = append(opts, taskRepo.WithByID(req.TaskID))
@@ -53,7 +61,11 @@ func (u *TaskLogService) Page(req dto.SearchTaskLogReq) (int64, []dto.TaskDTO, e
} }
func (u *TaskLogService) ReadByLine(req request.TaskLogReadReq) (*response.FileLineContent, error) { func (u *TaskLogService) ReadByLine(req request.TaskLogReadReq) (*response.FileLineContent, error) {
opts := []repo.DBOption{} excluded, err := taskscope.ReadExclusions(u.grantedScopes...)
if err != nil {
return nil, err
}
opts := []repo.DBOption{taskRepo.WithoutScopes(excluded...)}
if req.TaskID != "" { if req.TaskID != "" {
opts = append(opts, taskRepo.WithByID(req.TaskID)) opts = append(opts, taskRepo.WithByID(req.TaskID))
} else { } else {
@@ -112,9 +124,17 @@ func (u *TaskLogService) ReadByLine(req request.TaskLogReadReq) (*response.FileL
} }
func (u *TaskLogService) SyncForRestart() error { func (u *TaskLogService) SyncForRestart() error {
return taskRepo.UpdateRunningTaskToFailed() excluded, err := taskscope.RecoveryExclusions()
if err != nil {
return err
}
return taskRepo.UpdateRunningTaskToFailed(taskRepo.WithoutScopes(excluded...))
} }
func (u *TaskLogService) CountExecutingTask() (int64, error) { func (u *TaskLogService) CountExecutingTask() (int64, error) {
return taskRepo.CountExecutingTask() excluded, err := taskscope.ReadExclusions(u.grantedScopes...)
if err != nil {
return 0, err
}
return taskRepo.CountExecutingTask(taskRepo.WithoutScopes(excluded...))
} }
+100
View File
@@ -0,0 +1,100 @@
package service
import (
"errors"
"os"
"path/filepath"
"strings"
"github.com/1Panel-dev/1Panel/agent/app/model"
"github.com/1Panel-dev/1Panel/agent/utils/files"
"github.com/1Panel-dev/1Panel/agent/utils/nginx"
"github.com/1Panel-dev/1Panel/agent/utils/nginx/parser"
)
type websiteManagedConfigPolicy struct {
protectedDirectories func() []string
backupIncludes func(model.Website) []string
}
var websiteManagedConfigs websiteManagedConfigPolicy
func configureWebsiteManagedConfig(policy websiteManagedConfigPolicy) {
if !policy.configured() || websiteManagedConfigs.configured() {
panic("invalid website managed configuration policy")
}
websiteManagedConfigs = policy
}
func (p websiteManagedConfigPolicy) configured() bool {
return p.protectedDirectories != nil && p.backupIncludes != nil
}
func (p websiteManagedConfigPolicy) pathAllowed(target string) bool {
if !p.configured() {
return false
}
resolved, err := websiteProxyResolvePath(target)
if err != nil {
return false
}
for _, directory := range p.protectedDirectories() {
if directory == "" {
return false
}
root, err := websiteProxyResolvePath(directory)
if err != nil {
return false
}
if relative, err := filepath.Rel(root, resolved); err != nil || relative != ".." && !strings.HasPrefix(relative, ".."+string(filepath.Separator)) {
return false
}
}
return true
}
func exportWebsiteNginxConfig(site model.Website, source, destination string) error {
return websiteManagedConfigs.exportNginxConfig(site, source, destination)
}
func (p websiteManagedConfigPolicy) exportNginxConfig(site model.Website, source, destination string) error {
if !p.configured() {
return errors.New("website managed configuration policy is unavailable")
}
includes := p.backupIncludes(site)
if len(includes) == 0 {
return files.NewFileOp().CopyFile(source, destination)
}
excluded := make(map[string]struct{}, len(includes))
for _, include := range includes {
if include == "" {
return errors.New("invalid managed website backup include")
}
excluded[include] = struct{}{}
}
content, err := os.ReadFile(source)
if err != nil {
return err
}
config, err := parser.NewStringParser(string(content)).Parse()
if err != nil {
return errors.New("invalid managed website backup configuration")
}
changed := false
for _, server := range config.FindServers() {
for _, directive := range server.FindDirectives("include") {
parameters := directive.GetParameters()
if len(parameters) != 1 {
continue
}
if _, found := excluded[strings.Trim(parameters[0], "\"'")]; found {
server.RemoveDirective("include", parameters)
changed = true
}
}
}
if changed {
content = []byte(nginx.DumpConfig(config, nginx.IndentedStyle))
}
return os.WriteFile(filepath.Join(destination, filepath.Base(source)), content, 0600)
}
+81 -3
View File
@@ -4,7 +4,9 @@ import (
"bytes" "bytes"
"errors" "errors"
"fmt" "fmt"
"os"
"path" "path"
"path/filepath"
"strconv" "strconv"
"strings" "strings"
@@ -23,6 +25,63 @@ import (
"github.com/1Panel-dev/1Panel/agent/utils/re" "github.com/1Panel-dev/1Panel/agent/utils/re"
) )
func websiteProxyPathAllowed(directory, name string) bool {
if name == "" || name == "." || name == ".." || strings.ContainsAny(name, "/\\\x00") {
return false
}
resolvedDirectory, err := websiteProxyResolvePath(directory)
if err != nil || !websiteManagedConfigs.pathAllowed(resolvedDirectory) {
return false
}
for _, extension := range []string{".conf", ".bak"} {
filename := filepath.Join(directory, name+extension)
if info, statErr := os.Lstat(filename); statErr == nil && info.Mode()&os.ModeSymlink != 0 {
return false
}
resolved, resolveErr := websiteProxyResolvePath(filename)
if resolveErr != nil || !websiteProxyPathWithin(resolved, resolvedDirectory) || !websiteManagedConfigs.pathAllowed(resolved) {
return false
}
}
return true
}
func websiteProxyDirectoryAllowed(directory string) bool {
resolved, err := websiteProxyResolvePath(directory)
return err == nil && websiteManagedConfigs.pathAllowed(resolved)
}
func websiteProxyPathWithin(target, directory string) bool {
resolvedDirectory, err := websiteProxyResolvePath(directory)
if err != nil {
return false
}
relative, err := filepath.Rel(resolvedDirectory, target)
return err == nil && relative != ".." && !strings.HasPrefix(relative, ".."+string(filepath.Separator))
}
func websiteProxyResolvePath(target string) (string, error) {
absolute, err := filepath.Abs(target)
if err != nil {
return "", err
}
resolved, err := filepath.EvalSymlinks(absolute)
if err == nil {
return resolved, nil
}
if !os.IsNotExist(err) || filepath.Dir(absolute) == absolute {
return "", err
}
if info, statErr := os.Lstat(absolute); statErr == nil && info.Mode()&os.ModeSymlink != 0 {
return "", errors.New("invalid proxy symlink")
}
parent, err := websiteProxyResolvePath(filepath.Dir(absolute))
if err != nil {
return "", err
}
return filepath.Join(parent, filepath.Base(absolute)), nil
}
func (w WebsiteService) OperateProxy(req request.WebsiteProxyConfig) (err error) { func (w WebsiteService) OperateProxy(req request.WebsiteProxyConfig) (err error) {
switch req.Operate { switch req.Operate {
case "delete": case "delete":
@@ -51,6 +110,9 @@ func (w WebsiteService) OperateProxy(req request.WebsiteProxyConfig) (err error)
} }
fileOp := files.NewFileOp() fileOp := files.NewFileOp()
includeDir := GetSitePath(website, SiteProxyDir) includeDir := GetSitePath(website, SiteProxyDir)
if !websiteProxyPathAllowed(includeDir, req.Name) {
return errors.New("invalid proxy path")
}
if !fileOp.Stat(includeDir) { if !fileOp.Stat(includeDir) {
_ = fileOp.CreateDir(includeDir, constant.DirPerm) _ = fileOp.CreateDir(includeDir, constant.DirPerm)
} }
@@ -270,6 +332,9 @@ func (w WebsiteService) GetProxies(id uint) (res []request.WebsiteProxyConfig, e
} }
includeDir := GetSitePath(website, SiteProxyDir) includeDir := GetSitePath(website, SiteProxyDir)
fileOp := files.NewFileOp() fileOp := files.NewFileOp()
if !websiteProxyDirectoryAllowed(includeDir) {
return nil, errors.New("invalid proxy path")
}
if !fileOp.Stat(includeDir) { if !fileOp.Stat(includeDir) {
return return
} }
@@ -282,12 +347,16 @@ func (w WebsiteService) GetProxies(id uint) (res []request.WebsiteProxyConfig, e
config *components.Config config *components.Config
) )
for _, configFile := range fileList.Items { for _, configFile := range fileList.Items {
extension := filepath.Ext(configFile.Name)
name := strings.TrimSuffix(configFile.Name, extension)
if configFile.IsDir || (extension != ".conf" && extension != ".bak") || !websiteProxyPathAllowed(includeDir, name) {
continue
}
proxyConfig := request.WebsiteProxyConfig{ proxyConfig := request.WebsiteProxyConfig{
ID: website.ID, ID: website.ID,
} }
parts := strings.Split(configFile.Name, ".") proxyConfig.Name = name
proxyConfig.Name = parts[0] if extension == ".conf" {
if parts[1] == "conf" {
proxyConfig.Enable = true proxyConfig.Enable = true
} else { } else {
proxyConfig.Enable = false proxyConfig.Enable = false
@@ -360,6 +429,9 @@ func (w WebsiteService) UpdateProxyFile(req request.NginxProxyUpdate) (err error
if err != nil { if err != nil {
return err return err
} }
if !websiteProxyPathAllowed(GetSitePath(website, SiteProxyDir), req.Name) {
return errors.New("invalid proxy path")
}
absolutePath := fmt.Sprintf("%s/%s.conf", GetSitePath(website, SiteProxyDir), req.Name) absolutePath := fmt.Sprintf("%s/%s.conf", GetSitePath(website, SiteProxyDir), req.Name)
fileOp := files.NewFileOp() fileOp := files.NewFileOp()
oldRewriteContent, err = fileOp.GetContent(absolutePath) oldRewriteContent, err = fileOp.GetContent(absolutePath)
@@ -405,6 +477,9 @@ func (w WebsiteService) DeleteProxy(req request.WebsiteProxyDel) (err error) {
return return
} }
includeDir := GetSitePath(website, SiteProxyDir) includeDir := GetSitePath(website, SiteProxyDir)
if !websiteProxyPathAllowed(includeDir, req.Name) {
return errors.New("invalid proxy path")
}
fileOp := files.NewFileOp() fileOp := files.NewFileOp()
if !fileOp.Stat(includeDir) { if !fileOp.Stat(includeDir) {
return return
@@ -425,6 +500,9 @@ func (w WebsiteService) UpdateProxyStatus(req request.WebsiteProxyStatusUpdate)
return return
} }
includeDir := GetSitePath(website, SiteProxyDir) includeDir := GetSitePath(website, SiteProxyDir)
if !websiteProxyPathAllowed(includeDir, req.Name) {
return errors.New("invalid proxy path")
}
fileOp := files.NewFileOp() fileOp := files.NewFileOp()
if !fileOp.Stat(includeDir) { if !fileOp.Stat(includeDir) {
return return
+26
View File
@@ -292,10 +292,36 @@ func (t *Task) updateTask(task *model.Task) {
_ = t.taskRepo.Update(context.Background(), task) _ = t.taskRepo.Update(context.Background(), task)
} }
// ExecuteQueued registers the task before returning and limits concurrent execution.
// Subtasks must use Timeout = 0 and manage their own operation and recovery deadlines
// so that a slot is held until the action and its cleanup have actually returned.
func (t *Task) ExecuteQueued(slots chan struct{}, waitingStep string, onFinish func()) error {
t.Task.CurrentStep = waitingStep
if err := t.taskRepo.Save(context.Background(), t.Task); err != nil {
_ = t.logFile.Close()
global.RemoveTaskCancel(t.TaskID)
return err
}
t.Log(i18n.GetMsgByKey(waitingStep))
go func() {
slots <- struct{}{}
defer func() { <-slots }()
defer onFinish()
if err := t.executeRegistered(); err != nil {
global.LOG.Error(err.Error())
}
}()
return nil
}
func (t *Task) Execute() error { func (t *Task) Execute() error {
if err := t.taskRepo.Save(context.Background(), t.Task); err != nil { if err := t.taskRepo.Save(context.Background(), t.Task); err != nil {
return err return err
} }
return t.executeRegistered()
}
func (t *Task) executeRegistered() error {
var err error var err error
t.Log(i18n.GetWithName("TaskStart", t.Name)) t.Log(i18n.GetWithName("TaskStart", t.Name))
for i := 0; i < len(t.SubTasks); i++ { for i := 0; i < len(t.SubTasks); i++ {
-7
View File
@@ -53,13 +53,6 @@
"formatZH": "添加 Ollama 模型重试 [name]", "formatZH": "添加 Ollama 模型重试 [name]",
"formatEN": "re-add Ollama model [name]" "formatEN": "re-add Ollama model [name]"
}, },
"/ai/ollama/model/sync": {
"bodyKeys": [],
"paramKeys": [],
"beforeFunctions": [],
"formatZH": "同步 Ollama 模型列表",
"formatEN": "sync Ollama model list"
},
"/ai/tensorrt/create": { "/ai/tensorrt/create": {
"bodyKeys": [ "bodyKeys": [
"name" "name"
+2
View File
@@ -4,3 +4,5 @@ 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,6 +7,7 @@ replace github.com/go-acme/lego/v5 => github.com/1Panel-dev/lego/v5 v5.3.1
require ( require (
github.com/aliyun/aliyun-oss-go-sdk v3.0.2+incompatible github.com/aliyun/aliyun-oss-go-sdk v3.0.2+incompatible
github.com/compose-spec/compose-go/v2 v2.15.0 github.com/compose-spec/compose-go/v2 v2.15.0
github.com/containerd/errdefs v1.0.0
github.com/creack/pty v1.1.24 github.com/creack/pty v1.1.24
github.com/docker/cli v29.9.0+incompatible github.com/docker/cli v29.9.0+incompatible
github.com/docker/docker v28.5.2+incompatible github.com/docker/docker v28.5.2+incompatible
@@ -104,7 +105,6 @@ require (
github.com/clbanning/mxj v1.8.4 // indirect github.com/clbanning/mxj v1.8.4 // indirect
github.com/clbanning/mxj/v2 v2.7.0 // indirect github.com/clbanning/mxj/v2 v2.7.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect github.com/cloudwego/base64x v0.1.6 // indirect
github.com/containerd/errdefs v1.0.0 // indirect
github.com/containerd/errdefs/pkg v0.3.0 // indirect github.com/containerd/errdefs/pkg v0.3.0 // indirect
github.com/containerd/log v0.1.0 // indirect github.com/containerd/log v0.1.0 // indirect
github.com/distribution/reference v0.6.0 // indirect github.com/distribution/reference v0.6.0 // indirect
+37
View File
@@ -777,3 +777,40 @@ VMSnapshotDeleteStep: "Delete virtual machine snapshot [{{ .snapshot }}]"
ErrCAUpload: "Upload a ZIP (up to 10 MB) containing a valid self-signed CA certificate ca.crt and its matching private key ca.key, with a supported RSA or EC algorithm." ErrCAUpload: "Upload a ZIP (up to 10 MB) containing a valid self-signed CA certificate ca.crt and its matching private key ca.key, with a supported RSA or EC algorithm."
ErrCAImport: "Provide a valid self-signed root CA certificate and matching private key (up to 1 MB each), using a supported RSA or EC algorithm." ErrCAImport: "Provide a valid self-signed root CA certificate and matching private key (up to 1 MB each), using a supported RSA or EC algorithm."
# Built-in Feishu
ErrFeishuConfig: "Resolve incompatible configuration before continuing: {{ .detail }}"
ErrFeishuConfigChanged: "Configuration changed during switching. Retry after reloading."
ErrFeishuImageRequired: "Use an image containing bundled Feishu."
ErrFeishuAlreadySwitched: "Feishu has already switched to the built-in plugin."
ErrFeishuSwitchRequired: "Switch to the built-in plugin from the Feishu channel page first."
ErrFeishuManaged: "Manage Feishu from its channel page; the plugin is updated with the image."
ErrFeishuRestore: "Failed to restore Feishu configuration: {{ .err }}"
ErrFeishuActivation: "The built-in Feishu plugin or gateway failed to start."
AgentFeishuSwitch: "Switch Feishu plugin"
AgentFeishuBackup: "Configuration backup: {{ .name }}"
AgentFeishuRestored: "Previous configuration restored."
AgentFeishuProbeWarning: "Feishu connection check failed. Check the network and credentials; the switch has been retained."
AgentFeishuVerifyMessages: "Local activation completed. Verify pairing, DMs, groups, bot routing and streaming in Feishu."
ErrFeishuUpgradeConfirm: "Confirm the Feishu transition options on the upgrade page before retrying."
ErrFeishuUpgradeBackup: "A full application backup is required for the Feishu compatibility upgrade."
ErrFeishuUpgradeMounts: "These mounts cannot be fully backed up or restored. Use standard data mounts inside the application directory."
AgentFeishuRecoveryDisabled: "Gateway recovered with Feishu disabled; the original configuration backup is retained."
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"
ErrImageCheckProxyRequired: "To check for image updates in the current runtime environment, please configure a proxy server first."
+37
View File
@@ -777,3 +777,40 @@ VMSnapshotDeleteStep: "Eliminar instantánea de máquina virtual [{{ .snapshot }
ErrCAUpload: "Upload a ZIP (up to 10 MB) containing a valid self-signed CA certificate ca.crt and its matching private key ca.key, with a supported RSA or EC algorithm." ErrCAUpload: "Upload a ZIP (up to 10 MB) containing a valid self-signed CA certificate ca.crt and its matching private key ca.key, with a supported RSA or EC algorithm."
ErrCAImport: "Provide a valid self-signed root CA certificate and matching private key (up to 1 MB each), using a supported RSA or EC algorithm." ErrCAImport: "Provide a valid self-signed root CA certificate and matching private key (up to 1 MB each), using a supported RSA or EC algorithm."
# Built-in Feishu
ErrFeishuConfig: "Corrija la configuración incompatible antes de continuar: {{ .detail }}"
ErrFeishuConfigChanged: "La configuración cambió durante la transición. Recargue y reintente."
ErrFeishuImageRequired: "Use una imagen que incluya Feishu."
ErrFeishuAlreadySwitched: "Feishu ya utiliza el complemento integrado."
ErrFeishuSwitchRequired: "Cambie primero al complemento integrado desde la página del canal Feishu."
ErrFeishuManaged: "Gestione Feishu desde su canal; el complemento se actualiza con la imagen."
ErrFeishuRestore: "Error al restaurar la configuración de Feishu: {{ .err }}"
ErrFeishuActivation: "No se pudo iniciar el complemento Feishu integrado o la puerta de enlace."
AgentFeishuSwitch: "Cambiar el complemento Feishu"
AgentFeishuBackup: "Copia de configuración: {{ .name }}"
AgentFeishuRestored: "Se restauró la configuración anterior."
AgentFeishuProbeWarning: "Falló la comprobación de conexión de Feishu. Revise la red y las credenciales; se conservó el cambio."
AgentFeishuVerifyMessages: "Activación local completada. Verifique el emparejamiento, chats, grupos, rutas de bots y transmisión en Feishu."
ErrFeishuUpgradeConfirm: "Confirme las opciones de transición de Feishu en la página de actualización."
ErrFeishuUpgradeBackup: "La actualización de compatibilidad de Feishu requiere una copia completa de la aplicación."
ErrFeishuUpgradeMounts: "Estos montajes no permiten una copia y restauración completas. Use los montajes de datos estándar dentro del directorio de la aplicación."
AgentFeishuRecoveryDisabled: "Puerta de enlace recuperada con Feishu desactivado; se conserva la copia de configuración original."
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"
ErrImageCheckProxyRequired: "Para comprobar actualizaciones de imágenes en el entorno actual, configura primero un servidor proxy."
+37
View File
@@ -778,3 +778,40 @@ VMSnapshotDeleteStep: "حذف اسنپ‌شات ماشین مجازی [{{ .snaps
ErrCAUpload: "Upload a ZIP (up to 10 MB) containing a valid self-signed CA certificate ca.crt and its matching private key ca.key, with a supported RSA or EC algorithm." ErrCAUpload: "Upload a ZIP (up to 10 MB) containing a valid self-signed CA certificate ca.crt and its matching private key ca.key, with a supported RSA or EC algorithm."
ErrCAImport: "Provide a valid self-signed root CA certificate and matching private key (up to 1 MB each), using a supported RSA or EC algorithm." ErrCAImport: "Provide a valid self-signed root CA certificate and matching private key (up to 1 MB each), using a supported RSA or EC algorithm."
# Built-in Feishu
ErrFeishuConfig: "پیش از ادامه تنظیمات ناسازگار را اصلاح کنید: {{ .detail }}"
ErrFeishuConfigChanged: "تنظیمات هنگام تغییر عوض شد. صفحه را تازه و دوباره تلاش کنید."
ErrFeishuImageRequired: "از تصویری با Feishu داخلی استفاده کنید."
ErrFeishuAlreadySwitched: "Feishu قبلاً به افزونه داخلی تغییر کرده است."
ErrFeishuSwitchRequired: "ابتدا در صفحه کانال Feishu به افزونه داخلی تغییر دهید."
ErrFeishuManaged: "Feishu را از صفحه کانال مدیریت کنید؛ افزونه همراه تصویر به‌روز می‌شود."
ErrFeishuRestore: "بازیابی تنظیمات Feishu ناموفق بود: {{ .err }}"
ErrFeishuActivation: "راه‌اندازی افزونه داخلی Feishu یا دروازه ناموفق بود."
AgentFeishuSwitch: "تغییر افزونه Feishu"
AgentFeishuBackup: "پشتیبان تنظیمات: {{ .name }}"
AgentFeishuRestored: "تنظیمات قبلی بازیابی شد."
AgentFeishuProbeWarning: "بررسی اتصال Feishu ناموفق بود. شبکه و اطلاعات ورود را بررسی کنید؛ تغییر حفظ شد."
AgentFeishuVerifyMessages: "فعال‌سازی محلی انجام شد. جفت‌سازی، پیام خصوصی و گروهی، مسیریابی ربات و پاسخ جریانی را در Feishu بررسی کنید."
ErrFeishuUpgradeConfirm: "گزینه‌های انتقال Feishu را در صفحه ارتقا تأیید کنید."
ErrFeishuUpgradeBackup: "ارتقای سازگاری Feishu به پشتیبان کامل برنامه نیاز دارد."
ErrFeishuUpgradeMounts: "این اتصال‌های ذخیره‌سازی کاملاً قابل پشتیبان‌گیری و بازیابی نیستند. از مسیرهای استاندارد داده در پوشه برنامه استفاده کنید."
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 }} کانتینر ناموفق بود؛ گزارش وظیفه را مشاهده کنید"
ErrImageCheckProxyRequired: "برای بررسی به‌روزرسانی تصاویر در محیط فعلی، ابتدا یک سرور پروکسی پیکربندی کنید."
+37
View File
@@ -777,3 +777,40 @@ VMSnapshotDeleteStep: "仮想マシンのスナップショット [{{ .snapshot
ErrCAUpload: "Upload a ZIP (up to 10 MB) containing a valid self-signed CA certificate ca.crt and its matching private key ca.key, with a supported RSA or EC algorithm." ErrCAUpload: "Upload a ZIP (up to 10 MB) containing a valid self-signed CA certificate ca.crt and its matching private key ca.key, with a supported RSA or EC algorithm."
ErrCAImport: "Provide a valid self-signed root CA certificate and matching private key (up to 1 MB each), using a supported RSA or EC algorithm." ErrCAImport: "Provide a valid self-signed root CA certificate and matching private key (up to 1 MB each), using a supported RSA or EC algorithm."
# Built-in Feishu
ErrFeishuConfig: "続行前に互換性のない設定を修正してください:{{ .detail }}"
ErrFeishuConfigChanged: "切り替え中に設定が変更されました。再読み込みして再試行してください。"
ErrFeishuImageRequired: "Feishu を内蔵したイメージを使用してください。"
ErrFeishuAlreadySwitched: "Feishu は組み込みプラグインに切り替え済みです。"
ErrFeishuSwitchRequired: "先に Feishu チャネル画面で組み込みプラグインに切り替えてください。"
ErrFeishuManaged: "Feishu はチャネル画面で管理してください。プラグインはイメージとともに更新されます。"
ErrFeishuRestore: "Feishu 設定の復元に失敗しました:{{ .err }}"
ErrFeishuActivation: "組み込み Feishu プラグインまたはゲートウェイを起動できませんでした。"
AgentFeishuSwitch: "Feishu プラグインの切り替え"
AgentFeishuBackup: "設定のバックアップ:{{ .name }}"
AgentFeishuRestored: "以前の設定を復元しました。"
AgentFeishuProbeWarning: "Feishu の接続確認に失敗しました。ネットワークと認証情報を確認してください。切り替え結果は保持しています。"
AgentFeishuVerifyMessages: "ローカルの有効化が完了しました。Feishu でペアリング、個別・グループチャット、ボットルーティング、ストリーミングを確認してください。"
ErrFeishuUpgradeConfirm: "更新画面で Feishu の切り替え設定を確認してください。"
ErrFeishuUpgradeBackup: "Feishu 互換更新にはアプリ全体のバックアップが必要です。"
ErrFeishuUpgradeMounts: "現在のマウントは完全なバックアップ・復元に対応しません。アプリディレクトリ内の標準データマウントを使用してください。"
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 }} コンテナの確認に失敗しました。タスクログを確認してください"
ErrImageCheckProxyRequired: "現在の実行環境でイメージの更新を確認するには、先にプロキシサーバーを設定してください。"
+37
View File
@@ -777,3 +777,40 @@ VMSnapshotDeleteStep: "가상 머신 스냅샷 [{{ .snapshot }}] 삭제"
ErrCAUpload: "Upload a ZIP (up to 10 MB) containing a valid self-signed CA certificate ca.crt and its matching private key ca.key, with a supported RSA or EC algorithm." ErrCAUpload: "Upload a ZIP (up to 10 MB) containing a valid self-signed CA certificate ca.crt and its matching private key ca.key, with a supported RSA or EC algorithm."
ErrCAImport: "Provide a valid self-signed root CA certificate and matching private key (up to 1 MB each), using a supported RSA or EC algorithm." ErrCAImport: "Provide a valid self-signed root CA certificate and matching private key (up to 1 MB each), using a supported RSA or EC algorithm."
# Built-in Feishu
ErrFeishuConfig: "계속하기 전에 호환되지 않는 설정을 수정하세요: {{ .detail }}"
ErrFeishuConfigChanged: "전환 중 설정이 변경되었습니다. 새로고침 후 다시 시도하세요."
ErrFeishuImageRequired: "Feishu가 내장된 이미지를 사용하세요."
ErrFeishuAlreadySwitched: "이미 내장 Feishu 플러그인으로 전환했습니다."
ErrFeishuSwitchRequired: "먼저 Feishu 채널 페이지에서 내장 플러그인으로 전환하세요."
ErrFeishuManaged: "Feishu 채널 페이지에서 관리하세요. 플러그인은 이미지와 함께 업데이트됩니다."
ErrFeishuRestore: "Feishu 설정 복구 실패: {{ .err }}"
ErrFeishuActivation: "내장 Feishu 플러그인 또는 게이트웨이 시작에 실패했습니다."
AgentFeishuSwitch: "Feishu 플러그인 전환"
AgentFeishuBackup: "설정 백업: {{ .name }}"
AgentFeishuRestored: "이전 설정을 복구했습니다."
AgentFeishuProbeWarning: "Feishu 연결 확인에 실패했습니다. 네트워크와 자격 증명을 확인하세요. 전환 결과는 유지됩니다."
AgentFeishuVerifyMessages: "로컬 활성화가 완료되었습니다. Feishu에서 페어링, 개인 및 그룹 채팅, 봇 라우팅과 스트리밍을 확인하세요."
ErrFeishuUpgradeConfirm: "업그레이드 화면에서 Feishu 전환 옵션을 확인하세요."
ErrFeishuUpgradeBackup: "Feishu 호환 업그레이드에는 전체 앱 백업이 필요합니다."
ErrFeishuUpgradeMounts: "현재 마운트는 전체 백업 및 복원을 지원하지 않습니다. 앱 디렉터리 내부의 표준 데이터 마운트를 사용하세요."
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 }}개 확인에 실패했습니다. 작업 로그를 확인하세요"
ErrImageCheckProxyRequired: "현재 실행 환경에서 이미지 업데이트를 확인하려면 먼저 프록시 서버를 설정하세요."
+37
View File
@@ -768,3 +768,40 @@ VMSnapshotDeleteStep: "ລຶບສະແນັບຊັອດເຄື່ອງ
ErrCAUpload: "Upload a ZIP (up to 10 MB) containing a valid self-signed CA certificate ca.crt and its matching private key ca.key, with a supported RSA or EC algorithm." ErrCAUpload: "Upload a ZIP (up to 10 MB) containing a valid self-signed CA certificate ca.crt and its matching private key ca.key, with a supported RSA or EC algorithm."
ErrCAImport: "Provide a valid self-signed root CA certificate and matching private key (up to 1 MB each), using a supported RSA or EC algorithm." ErrCAImport: "Provide a valid self-signed root CA certificate and matching private key (up to 1 MB each), using a supported RSA or EC algorithm."
# Built-in Feishu
ErrFeishuConfig: "ກະລຸນາແກ້ໄຂການຕັ້ງຄ່າທີ່ບໍ່ເຂົ້າກັນກ່ອນ: {{ .detail }}"
ErrFeishuConfigChanged: "ການຕັ້ງຄ່າປ່ຽນໃນລະຫວ່າງການສະຫຼັບ. ໂຫຼດໃໝ່ແລ້ວລອງອີກ."
ErrFeishuImageRequired: "ໃຊ້ອິມເມຈທີ່ມີ Feishu ໃນຕົວ."
ErrFeishuAlreadySwitched: "Feishu ໄດ້ສະຫຼັບເປັນປລັກອິນໃນຕົວແລ້ວ."
ErrFeishuSwitchRequired: "ກະລຸນາສະຫຼັບໃນໜ້າຊ່ອງ Feishu ກ່ອນ."
ErrFeishuManaged: "ຈັດການ Feishu ໃນໜ້າຊ່ອງ; ປລັກອິນອັບເດດພ້ອມອິມເມຈ."
ErrFeishuRestore: "ກູ້ຄືນການຕັ້ງຄ່າ Feishu ບໍ່ສຳເລັດ: {{ .err }}"
ErrFeishuActivation: "ປລັກອິນ Feishu ໃນຕົວ ຫຼື ເກດເວເລີ່ມບໍ່ສຳເລັດ."
AgentFeishuSwitch: "ສະຫຼັບປລັກອິນ Feishu"
AgentFeishuBackup: "ສຳຮອງການຕັ້ງຄ່າ: {{ .name }}"
AgentFeishuRestored: "ກູ້ຄືນການຕັ້ງຄ່າເດີມແລ້ວ."
AgentFeishuProbeWarning: "ກວດການເຊື່ອມຕໍ່ Feishu ບໍ່ຜ່ານ. ກວດເຄືອຂ່າຍ ແລະ ຂໍ້ມູນເຂົ້າໃຊ້; ຮັກສາຜົນການສະຫຼັບໄວ້."
AgentFeishuVerifyMessages: "ເປີດໃຊ້ພາຍໃນສຳເລັດ. ກວດການຈັບຄູ່, ແຊັດສ່ວນຕົວ, ກຸ່ມ, ເສັ້ນທາງບອດ ແລະ ສະຕຣີມໃນ Feishu."
ErrFeishuUpgradeConfirm: "ຢືນຢັນຕົວເລືອກການປ່ຽນ Feishu ໃນໜ້າອັບເກຣດ."
ErrFeishuUpgradeBackup: "ການອັບເກຣດ Feishu ຕ້ອງສຳຮອງແອັບທັງໝົດ."
ErrFeishuUpgradeMounts: "ຈຸດເຊື່ອມຂໍ້ມູນນີ້ບໍ່ສາມາດສຳຮອງແລະກູ້ຄືນທັງໝົດໄດ້. ໃຊ້ຈຸດເຊື່ອມມາດຕະຖານພາຍໃນໂຟນເດີແອັບ."
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 }} ຄອນເທນເນີລົ້ມເຫຼວ; ກະລຸນາເບິ່ງບັນທຶກວຽກ"
ErrImageCheckProxyRequired: "ເພື່ອກວດສອບການອັບເດດອິມເມຈໃນສະພາບແວດລ້ອມປັດຈຸບັນ ກະລຸນາຕັ້ງຄ່າພຣັອກຊີເຊີບເວີກ່ອນ."
+37
View File
@@ -777,3 +777,40 @@ VMSnapshotDeleteStep: "Padam syot kilat mesin maya [{{ .snapshot }}]"
ErrCAUpload: "Upload a ZIP (up to 10 MB) containing a valid self-signed CA certificate ca.crt and its matching private key ca.key, with a supported RSA or EC algorithm." ErrCAUpload: "Upload a ZIP (up to 10 MB) containing a valid self-signed CA certificate ca.crt and its matching private key ca.key, with a supported RSA or EC algorithm."
ErrCAImport: "Provide a valid self-signed root CA certificate and matching private key (up to 1 MB each), using a supported RSA or EC algorithm." ErrCAImport: "Provide a valid self-signed root CA certificate and matching private key (up to 1 MB each), using a supported RSA or EC algorithm."
# Built-in Feishu
ErrFeishuConfig: "Betulkan konfigurasi tidak serasi sebelum meneruskan: {{ .detail }}"
ErrFeishuConfigChanged: "Konfigurasi berubah semasa pertukaran. Muat semula dan cuba lagi."
ErrFeishuImageRequired: "Gunakan imej yang mengandungi Feishu terbina dalam."
ErrFeishuAlreadySwitched: "Feishu sudah menggunakan pemalam terbina dalam."
ErrFeishuSwitchRequired: "Tukar dahulu kepada pemalam terbina dalam pada halaman saluran Feishu."
ErrFeishuManaged: "Urus Feishu pada halaman salurannya; pemalam dikemas kini bersama imej."
ErrFeishuRestore: "Gagal memulihkan konfigurasi Feishu: {{ .err }}"
ErrFeishuActivation: "Pemalam Feishu terbina dalam atau gerbang gagal dimulakan."
AgentFeishuSwitch: "Tukar pemalam Feishu"
AgentFeishuBackup: "Sandaran konfigurasi: {{ .name }}"
AgentFeishuRestored: "Konfigurasi sebelumnya dipulihkan."
AgentFeishuProbeWarning: "Semakan sambungan Feishu gagal. Semak rangkaian dan kelayakan; pertukaran dikekalkan."
AgentFeishuVerifyMessages: "Pengaktifan setempat selesai. Sahkan pasangan, mesej peribadi, kumpulan, penghalaan bot dan penstriman dalam Feishu."
ErrFeishuUpgradeConfirm: "Sahkan pilihan peralihan Feishu pada halaman naik taraf."
ErrFeishuUpgradeBackup: "Naik taraf keserasian Feishu memerlukan sandaran aplikasi penuh."
ErrFeishuUpgradeMounts: "Lekapan ini tidak boleh disandar dan dipulihkan sepenuhnya. Gunakan lekapan data standard dalam direktori aplikasi."
AgentFeishuRecoveryDisabled: "Gerbang dipulihkan dengan Feishu dinyahaktifkan; sandaran konfigurasi asal dikekalkan."
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"
ErrImageCheckProxyRequired: "Untuk menyemak kemas kini imej dalam persekitaran semasa, sila konfigurasikan pelayan proksi terlebih dahulu."
+37
View File
@@ -777,3 +777,40 @@ VMSnapshotDeleteStep: "Excluir snapshot da máquina virtual [{{ .snapshot }}]"
ErrCAUpload: "Upload a ZIP (up to 10 MB) containing a valid self-signed CA certificate ca.crt and its matching private key ca.key, with a supported RSA or EC algorithm." ErrCAUpload: "Upload a ZIP (up to 10 MB) containing a valid self-signed CA certificate ca.crt and its matching private key ca.key, with a supported RSA or EC algorithm."
ErrCAImport: "Provide a valid self-signed root CA certificate and matching private key (up to 1 MB each), using a supported RSA or EC algorithm." ErrCAImport: "Provide a valid self-signed root CA certificate and matching private key (up to 1 MB each), using a supported RSA or EC algorithm."
# Built-in Feishu
ErrFeishuConfig: "Corrija a configuração incompatível antes de continuar: {{ .detail }}"
ErrFeishuConfigChanged: "A configuração mudou durante a troca. Recarregue e tente novamente."
ErrFeishuImageRequired: "Use uma imagem com Feishu integrado."
ErrFeishuAlreadySwitched: "O Feishu já usa o plugin integrado."
ErrFeishuSwitchRequired: "Primeiro troque para o plugin integrado na página do canal Feishu."
ErrFeishuManaged: "Gerencie o Feishu pela página do canal; o plugin é atualizado com a imagem."
ErrFeishuRestore: "Falha ao restaurar a configuração do Feishu: {{ .err }}"
ErrFeishuActivation: "Falha ao iniciar o plugin Feishu integrado ou o gateway."
AgentFeishuSwitch: "Trocar plugin Feishu"
AgentFeishuBackup: "Backup da configuração: {{ .name }}"
AgentFeishuRestored: "Configuração anterior restaurada."
AgentFeishuProbeWarning: "Falha na verificação de conexão do Feishu. Verifique a rede e as credenciais; a troca foi mantida."
AgentFeishuVerifyMessages: "Ativação local concluída. Verifique pareamento, conversas, grupos, rotas de bots e streaming no Feishu."
ErrFeishuUpgradeConfirm: "Confirme as opções de transição do Feishu na página de atualização."
ErrFeishuUpgradeBackup: "A atualização de compatibilidade do Feishu exige backup completo do aplicativo."
ErrFeishuUpgradeMounts: "Estas montagens não permitem backup e restauração completos. Use montagens de dados padrão dentro do diretório do aplicativo."
AgentFeishuRecoveryDisabled: "Gateway recuperado com Feishu desativado; o backup da configuração original foi mantido."
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"
ErrImageCheckProxyRequired: "Para verificar atualizações de imagens no ambiente atual, configure primeiro um servidor proxy."
+37
View File
@@ -777,3 +777,40 @@ VMSnapshotDeleteStep: "Удалить снимок виртуальной маш
ErrCAUpload: "Upload a ZIP (up to 10 MB) containing a valid self-signed CA certificate ca.crt and its matching private key ca.key, with a supported RSA or EC algorithm." ErrCAUpload: "Upload a ZIP (up to 10 MB) containing a valid self-signed CA certificate ca.crt and its matching private key ca.key, with a supported RSA or EC algorithm."
ErrCAImport: "Provide a valid self-signed root CA certificate and matching private key (up to 1 MB each), using a supported RSA or EC algorithm." ErrCAImport: "Provide a valid self-signed root CA certificate and matching private key (up to 1 MB each), using a supported RSA or EC algorithm."
# Built-in Feishu
ErrFeishuConfig: "Исправьте несовместимые настройки перед продолжением: {{ .detail }}"
ErrFeishuConfigChanged: "Настройки изменились во время переключения. Обновите страницу и повторите."
ErrFeishuImageRequired: "Используйте образ со встроенным Feishu."
ErrFeishuAlreadySwitched: "Feishu уже переключён на встроенный плагин."
ErrFeishuSwitchRequired: "Сначала перейдите на встроенный плагин на странице канала Feishu."
ErrFeishuManaged: "Управляйте Feishu на странице канала; плагин обновляется с образом."
ErrFeishuRestore: "Не удалось восстановить настройки Feishu: {{ .err }}"
ErrFeishuActivation: "Не удалось запустить встроенный плагин Feishu или шлюз."
AgentFeishuSwitch: "Переключить плагин Feishu"
AgentFeishuBackup: "Резервная копия настроек: {{ .name }}"
AgentFeishuRestored: "Прежние настройки восстановлены."
AgentFeishuProbeWarning: "Проверка подключения Feishu не пройдена. Проверьте сеть и учётные данные; переключение сохранено."
AgentFeishuVerifyMessages: "Локальная активация завершена. Проверьте сопряжение, личные и групповые сообщения, маршрутизацию ботов и потоковые ответы в Feishu."
ErrFeishuUpgradeConfirm: "Подтвердите параметры перехода Feishu на странице обновления."
ErrFeishuUpgradeBackup: "Для обновления совместимости Feishu нужна полная резервная копия приложения."
ErrFeishuUpgradeMounts: "Эти монтирования не поддерживают полное резервное копирование и восстановление. Используйте стандартные данные внутри каталога приложения."
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 }}. Подробности в журнале задачи"
ErrImageCheckProxyRequired: "Для проверки обновлений образов в текущей среде сначала настройте прокси-сервер."
+37
View File
@@ -777,3 +777,40 @@ VMSnapshotDeleteStep: "Sanal makine anlık görüntüsü [{{ .snapshot }}] sil"
ErrCAUpload: "Upload a ZIP (up to 10 MB) containing a valid self-signed CA certificate ca.crt and its matching private key ca.key, with a supported RSA or EC algorithm." ErrCAUpload: "Upload a ZIP (up to 10 MB) containing a valid self-signed CA certificate ca.crt and its matching private key ca.key, with a supported RSA or EC algorithm."
ErrCAImport: "Provide a valid self-signed root CA certificate and matching private key (up to 1 MB each), using a supported RSA or EC algorithm." ErrCAImport: "Provide a valid self-signed root CA certificate and matching private key (up to 1 MB each), using a supported RSA or EC algorithm."
# Built-in Feishu
ErrFeishuConfig: "Devam etmeden önce uyumsuz ayarları düzeltin: {{ .detail }}"
ErrFeishuConfigChanged: "Geçiş sırasında ayarlar değişti. Yenileyip tekrar deneyin."
ErrFeishuImageRequired: "Yerleşik Feishu içeren bir imaj kullanın."
ErrFeishuAlreadySwitched: "Feishu zaten yerleşik eklentiye geçti."
ErrFeishuSwitchRequired: "Önce Feishu kanal sayfasından yerleşik eklentiye geçin."
ErrFeishuManaged: "Feishu kanal sayfasından yönetin; eklenti imajla güncellenir."
ErrFeishuRestore: "Feishu ayarları geri yüklenemedi: {{ .err }}"
ErrFeishuActivation: "Yerleşik Feishu eklentisi veya ağ geçidi başlatılamadı."
AgentFeishuSwitch: "Feishu eklentisini değiştir"
AgentFeishuBackup: "Ayar yedeği: {{ .name }}"
AgentFeishuRestored: "Önceki ayarlar geri yüklendi."
AgentFeishuProbeWarning: "Feishu bağlantı kontrolü başarısız. Ağı ve kimlik bilgilerini kontrol edin; geçiş korundu."
AgentFeishuVerifyMessages: "Yerel etkinleştirme tamamlandı. Feishu üzerinden eşleştirmeyi, özel ve grup mesajlarını, bot yönlendirmesini ve akışı doğrulayın."
ErrFeishuUpgradeConfirm: "Yükseltme sayfasında Feishu geçiş seçeneklerini onaylayın."
ErrFeishuUpgradeBackup: "Feishu uyumluluk yükseltmesi tam uygulama yedeği gerektirir."
ErrFeishuUpgradeMounts: "Bu bağlamalar tam yedeklenip geri yüklenemiyor. Uygulama dizinindeki standart veri bağlamalarını kullanın."
AgentFeishuRecoveryDisabled: "Ağ geçidi kurtarıldı, Feishu kapalı; özgün yapılandırma yedeği korundu."
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"
ErrImageCheckProxyRequired: "Geçerli çalışma ortamında imaj güncellemelerini kontrol etmek için önce bir proxy sunucusu yapılandırın."
+37
View File
@@ -777,3 +777,40 @@ VMSnapshotDeleteStep: "刪除虛擬機快照 [{{ .snapshot }}]"
ErrCAUpload: "請上傳包含有效自簽根憑證 ca.crt 及匹配私鑰 ca.key 的 ZIP 檔案(最大 10 MB),金鑰演算法須為支援的 RSA 或 EC。" ErrCAUpload: "請上傳包含有效自簽根憑證 ca.crt 及匹配私鑰 ca.key 的 ZIP 檔案(最大 10 MB),金鑰演算法須為支援的 RSA 或 EC。"
ErrCAImport: "請提供有效的自簽根憑證及匹配私鑰(每個最大 1 MB),金鑰演算法須為支援的 RSA 或 EC。" ErrCAImport: "請提供有效的自簽根憑證及匹配私鑰(每個最大 1 MB),金鑰演算法須為支援的 RSA 或 EC。"
# Built-in Feishu
ErrFeishuConfig: "請先處理不相容的設定:{{ .detail }}"
ErrFeishuConfigChanged: "切換期間設定已變更,請重新整理後重試。"
ErrFeishuImageRequired: "請使用包含內建飛書外掛的映像。"
ErrFeishuAlreadySwitched: "飛書已切換至內建外掛。"
ErrFeishuSwitchRequired: "請先在飛書管道頁面切換至內建外掛。"
ErrFeishuManaged: "請在飛書管道頁面管理,外掛隨映像更新。"
ErrFeishuRestore: "還原飛書設定失敗:{{ .err }}"
ErrFeishuActivation: "內建飛書外掛或閘道啟動失敗。"
AgentFeishuSwitch: "切換飛書外掛"
AgentFeishuBackup: "設定備份:{{ .name }}"
AgentFeishuRestored: "已還原切換前的設定。"
AgentFeishuProbeWarning: "飛書連線檢查未通過,請檢查網路和憑證;已保留切換結果。"
AgentFeishuVerifyMessages: "本機啟用完成,請在飛書驗證配對、私聊、群聊、機器人路由和串流回覆。"
ErrFeishuUpgradeConfirm: "請在升級頁面確認飛書切換選項後重試。"
ErrFeishuUpgradeBackup: "飛書相容升級必須開啟完整應用程式備份。"
ErrFeishuUpgradeMounts: "目前掛載無法完整備份或還原,請使用應用程式目錄內的標準資料掛載。"
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 }} 個容器檢查失敗,請查看任務日誌"
ErrImageCheckProxyRequired: "目前運行環境下,如需檢查映像是否更新,請先設定代理伺服器"
+37
View File
@@ -777,3 +777,40 @@ VMSnapshotDeleteStep: "删除虚拟机快照 [{{ .snapshot }}]"
ErrCAUpload: "请上传包含有效自签根证书 ca.crt 及匹配私钥 ca.key 的 ZIP 文件(最大 10 MB),密钥算法须为支持的 RSA 或 EC。" ErrCAUpload: "请上传包含有效自签根证书 ca.crt 及匹配私钥 ca.key 的 ZIP 文件(最大 10 MB),密钥算法须为支持的 RSA 或 EC。"
ErrCAImport: "请提供有效的自签根证书及匹配私钥(每个最大 1 MB),密钥算法须为支持的 RSA 或 EC。" ErrCAImport: "请提供有效的自签根证书及匹配私钥(每个最大 1 MB),密钥算法须为支持的 RSA 或 EC。"
# Built-in Feishu
ErrFeishuConfig: "请先处理不兼容的配置:{{ .detail }}"
ErrFeishuConfigChanged: "切换期间配置已改变,请刷新后重试。"
ErrFeishuImageRequired: "请使用包含内置飞书插件的镜像。"
ErrFeishuAlreadySwitched: "飞书已切换到内置插件。"
ErrFeishuSwitchRequired: "请先在飞书渠道页面切换到内置插件。"
ErrFeishuManaged: "请在飞书渠道页面管理,插件随镜像更新。"
ErrFeishuRestore: "恢复飞书配置失败:{{ .err }}"
ErrFeishuActivation: "内置飞书插件或网关启动失败。"
AgentFeishuSwitch: "切换飞书插件"
AgentFeishuBackup: "配置备份:{{ .name }}"
AgentFeishuRestored: "已恢复切换前的配置。"
AgentFeishuProbeWarning: "飞书连接检查未通过,请检查网络和凭据;已保留切换结果。"
AgentFeishuVerifyMessages: "本地启用完成,请在飞书中验证配对、私聊、群聊、机器人路由和流式回复。"
ErrFeishuUpgradeConfirm: "请在升级页面确认飞书切换选项后重试。"
ErrFeishuUpgradeBackup: "飞书兼容升级必须开启完整应用备份。"
ErrFeishuUpgradeMounts: "当前挂载无法完整备份或恢复,请使用应用目录内的标准数据挂载后重试。"
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 }} 个容器检查失败,请查看任务日志"
ErrImageCheckProxyRequired: "当前运行环境下,如需检查镜像是否更新,请优先配置代理服务器"
+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
},
}
+63 -1
View File
@@ -2,11 +2,15 @@ package router
import ( import (
"bytes" "bytes"
"context"
"encoding/json" "encoding/json"
"io" "io"
"net" "net"
"net/http" "net/http"
"github.com/1Panel-dev/1Panel/agent/app/dto/request"
"github.com/1Panel-dev/1Panel/agent/app/dto/response"
"github.com/1Panel-dev/1Panel/agent/app/service"
"github.com/1Panel-dev/1Panel/agent/global" "github.com/1Panel-dev/1Panel/agent/global"
"github.com/1Panel-dev/1Panel/agent/middleware" "github.com/1Panel-dev/1Panel/agent/middleware"
"github.com/1Panel-dev/1Panel/agent/utils/mcpapi" "github.com/1Panel-dev/1Panel/agent/utils/mcpapi"
@@ -17,9 +21,38 @@ import (
func registerMCPRuntime(engine *gin.Engine) { func registerMCPRuntime(engine *gin.Engine) {
controller := mcpruntime.New() controller := mcpruntime.New()
engine.POST("/internal/mcp/docker", mcpDockerHandler(controller)) engine.POST("/internal/mcp/docker", mcpDockerHandler(controller))
engine.POST("/internal/mcp/website", mcpWebsiteHandler(service.NewMCPWebsiteBindingService()))
engine.GET(mcpapi.CapabilitiesPath, middleware.Certificate(), mcpCapabilitiesHandler()) engine.GET(mcpapi.CapabilitiesPath, middleware.Certificate(), mcpCapabilitiesHandler())
} }
type mcpWebsiteController interface {
Handle(context.Context, request.MCPWebsiteBinding) response.MCPWebsiteBinding
}
func mcpWebsiteHandler(controller mcpWebsiteController) gin.HandlerFunc {
return func(ctx *gin.Context) {
local, ok := ctx.Request.Context().Value(http.LocalAddrContextKey).(net.Addr)
if !global.IsMaster || !ok || local.Network() != "unix" {
ctx.AbortWithStatus(http.StatusForbidden)
return
}
data, err := io.ReadAll(io.LimitReader(ctx.Request.Body, 16385))
if err != nil || len(data) > 16384 {
ctx.AbortWithStatus(http.StatusBadRequest)
return
}
var req request.MCPWebsiteBinding
decoder := json.NewDecoder(bytes.NewReader(data))
decoder.DisallowUnknownFields()
if decoder.Decode(&req) != nil || decoder.Decode(new(any)) != io.EOF {
ctx.AbortWithStatus(http.StatusBadRequest)
return
}
ctx.Header("Cache-Control", "no-store")
ctx.JSON(http.StatusOK, controller.Handle(ctx.Request.Context(), req))
}
}
func mcpCapabilitiesHandler() gin.HandlerFunc { func mcpCapabilitiesHandler() gin.HandlerFunc {
return func(ctx *gin.Context) { return func(ctx *gin.Context) {
if !mcpapi.TrustedTransport(ctx.Request) { if !mcpapi.TrustedTransport(ctx.Request) {
@@ -32,7 +65,12 @@ func mcpCapabilitiesHandler() gin.HandlerFunc {
} }
} }
func mcpDockerHandler(controller *mcpruntime.Controller) gin.HandlerFunc { type mcpDockerController interface {
Handle(context.Context, mcpruntime.Request) (mcpruntime.Response, error)
HandleWithProgress(context.Context, mcpruntime.Request, func(mcpruntime.PullProgress) error) (mcpruntime.Response, error)
}
func mcpDockerHandler(controller mcpDockerController) gin.HandlerFunc {
return func(ctx *gin.Context) { return func(ctx *gin.Context) {
local, ok := ctx.Request.Context().Value(http.LocalAddrContextKey).(net.Addr) local, ok := ctx.Request.Context().Value(http.LocalAddrContextKey).(net.Addr)
if !global.IsMaster || !ok || local.Network() != "unix" { if !global.IsMaster || !ok || local.Network() != "unix" {
@@ -51,6 +89,30 @@ func mcpDockerHandler(controller *mcpruntime.Controller) gin.HandlerFunc {
ctx.AbortWithStatus(http.StatusBadRequest) ctx.AbortWithStatus(http.StatusBadRequest)
return return
} }
if req.StreamProgress {
if req.Operation != "pull" {
ctx.JSON(http.StatusBadRequest, mcpruntime.Response{Error: "MCP Docker progress is only available when pulling an image"})
return
}
ctx.Header("Content-Type", "application/x-ndjson")
ctx.Header("Cache-Control", "no-store")
ctx.Status(http.StatusOK)
encoder := json.NewEncoder(ctx.Writer)
_, err := controller.HandleWithProgress(ctx.Request.Context(), req, func(progress mcpruntime.PullProgress) error {
if err := encoder.Encode(mcpruntime.PullEvent{Type: "progress", PullProgress: progress}); err != nil {
return err
}
ctx.Writer.Flush()
return nil
})
result := mcpruntime.PullEvent{Type: "result"}
if err != nil {
result.Error = err.Error()
}
_ = encoder.Encode(result)
ctx.Writer.Flush()
return
}
response, err := controller.Handle(ctx.Request.Context(), req) response, err := controller.Handle(ctx.Request.Context(), req)
if err != nil { if err != nil {
ctx.JSON(http.StatusBadRequest, mcpruntime.Response{Error: err.Error()}) ctx.JSON(http.StatusBadRequest, mcpruntime.Response{Error: err.Error()})
+6
View File
@@ -2,6 +2,7 @@ package router
import ( import (
v2 "github.com/1Panel-dev/1Panel/agent/app/api/v2" v2 "github.com/1Panel-dev/1Panel/agent/app/api/v2"
"github.com/1Panel-dev/1Panel/agent/app/service"
"github.com/1Panel-dev/1Panel/agent/global" "github.com/1Panel-dev/1Panel/agent/global"
"github.com/1Panel-dev/1Panel/agent/i18n" "github.com/1Panel-dev/1Panel/agent/i18n"
"github.com/1Panel-dev/1Panel/agent/middleware" "github.com/1Panel-dev/1Panel/agent/middleware"
@@ -14,6 +15,9 @@ var (
) )
func Routers() *gin.Engine { func Routers() *gin.Engine {
if err := service.ConfigurePanelMCPTasks(); err != nil {
panic(err)
}
Router = gin.Default() Router = gin.Default()
Router.Use(i18n.UseI18n()) Router.Use(i18n.UseI18n())
registerMCPRuntime(Router) registerMCPRuntime(Router)
@@ -23,6 +27,8 @@ func Routers() *gin.Engine {
PrivateGroup.Use(middleware.Certificate()) PrivateGroup.Use(middleware.Certificate())
} }
PrivateGroup.Use(middleware.OperationResolveMeta()) PrivateGroup.Use(middleware.OperationResolveMeta())
PrivateGroup.Use(v2.PanelMCPTaskAccess())
PrivateGroup.Use(v2.MCPBusinessDispatch())
for _, router := range rou.RouterGroupApp { for _, router := range rou.RouterGroupApp {
router.InitRouter(PrivateGroup) router.InitRouter(PrivateGroup)
} }
+1 -1
View File
@@ -18,7 +18,6 @@ func (a *AIToolsRouter) InitRouter(Router *gin.RouterGroup) {
aiToolsRouter.POST("/ollama/model", baseApi.CreateOllamaModel) aiToolsRouter.POST("/ollama/model", baseApi.CreateOllamaModel)
aiToolsRouter.POST("/ollama/model/recreate", baseApi.RecreateOllamaModel) aiToolsRouter.POST("/ollama/model/recreate", baseApi.RecreateOllamaModel)
aiToolsRouter.POST("/ollama/model/search", baseApi.SearchOllamaModel) aiToolsRouter.POST("/ollama/model/search", baseApi.SearchOllamaModel)
aiToolsRouter.POST("/ollama/model/sync", baseApi.SyncOllamaModel)
aiToolsRouter.POST("/ollama/account/sync", baseApi.SyncOllamaModelAccount) aiToolsRouter.POST("/ollama/account/sync", baseApi.SyncOllamaModelAccount)
aiToolsRouter.POST("/ollama/model/load", baseApi.LoadOllamaModelDetail) aiToolsRouter.POST("/ollama/model/load", baseApi.LoadOllamaModelDetail)
aiToolsRouter.POST("/ollama/model/del", baseApi.DeleteOllamaModel) aiToolsRouter.POST("/ollama/model/del", baseApi.DeleteOllamaModel)
@@ -92,6 +91,7 @@ func (a *AIToolsRouter) InitRouter(Router *gin.RouterGroup) {
aiToolsRouter.POST("/agents/agent/md/update", baseApi.UpdateAgentRoleMarkdownFile) aiToolsRouter.POST("/agents/agent/md/update", baseApi.UpdateAgentRoleMarkdownFile)
aiToolsRouter.POST("/agents/channel/feishu/get", baseApi.GetAgentFeishuConfig) aiToolsRouter.POST("/agents/channel/feishu/get", baseApi.GetAgentFeishuConfig)
aiToolsRouter.POST("/agents/channel/feishu/update", baseApi.UpdateAgentFeishuConfig) aiToolsRouter.POST("/agents/channel/feishu/update", baseApi.UpdateAgentFeishuConfig)
aiToolsRouter.POST("/agents/channel/feishu/switch", baseApi.SwitchAgentFeishuPlugin)
aiToolsRouter.POST("/agents/channel/telegram/get", baseApi.GetAgentTelegramConfig) aiToolsRouter.POST("/agents/channel/telegram/get", baseApi.GetAgentTelegramConfig)
aiToolsRouter.POST("/agents/channel/telegram/update", baseApi.UpdateAgentTelegramConfig) aiToolsRouter.POST("/agents/channel/telegram/update", baseApi.UpdateAgentTelegramConfig)
aiToolsRouter.POST("/agents/channel/discord/get", baseApi.GetAgentDiscordConfig) aiToolsRouter.POST("/agents/channel/discord/get", baseApi.GetAgentDiscordConfig)
+4
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)
@@ -64,6 +67,7 @@ func (s *ContainerRouter) InitRouter(Router *gin.RouterGroup) {
baRouter.POST("/template", baseApi.CreateComposeTemplate) baRouter.POST("/template", baseApi.CreateComposeTemplate)
baRouter.POST("/template/del", baseApi.DeleteComposeTemplate) baRouter.POST("/template/del", baseApi.DeleteComposeTemplate)
baRouter.GET("/image/remote/check", baseApi.GetImageRemoteCheckStatus)
baRouter.GET("/image", baseApi.ListImage) baRouter.GET("/image", baseApi.ListImage)
baRouter.GET("/image/all", baseApi.ListAllImage) baRouter.GET("/image/all", baseApi.ListAllImage)
baRouter.POST("/image/search", baseApi.SearchImage) baRouter.POST("/image/search", baseApi.SearchImage)
+1
View File
@@ -104,6 +104,7 @@ func pullComposeImages(filePath string, forcePull bool, task *task.Task, project
if err != nil { if err != nil {
return err return err
} }
defer dockerCLi.Close()
for _, image := range images { for _, image := range images {
if !forcePull { if !forcePull {
if exist, _ := dockerCLi.ImageExists(image); exist { if exist, _ := dockerCLi.ImageExists(image); exist {
+32 -24
View File
@@ -13,6 +13,7 @@ import (
"github.com/1Panel-dev/1Panel/agent/app/model" "github.com/1Panel-dev/1Panel/agent/app/model"
"github.com/1Panel-dev/1Panel/agent/app/task" "github.com/1Panel-dev/1Panel/agent/app/task"
"github.com/1Panel-dev/1Panel/agent/global" "github.com/1Panel-dev/1Panel/agent/global"
"github.com/distribution/reference"
"github.com/docker/cli/cli/config" "github.com/docker/cli/cli/config"
"github.com/docker/docker/api/types" "github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/container" "github.com/docker/docker/api/types/container"
@@ -197,12 +198,22 @@ func setLog(id, newLastLine string, task *task.Task) error {
} }
func (c Client) PullImageWithProcessAndOptions(task *task.Task, imageName string, options image.PullOptions) error { func (c Client) PullImageWithProcessAndOptions(task *task.Task, imageName string, options image.PullOptions) error {
out, err := c.cli.ImagePull(context.Background(), imageName, options) return c.PullImageWithProcessAndOptionsContext(context.Background(), task, imageName, options)
}
func (c Client) PullImageWithProcessAndOptionsContext(ctx context.Context, task *task.Task, imageName string, options image.PullOptions) error {
out, err := c.cli.ImagePull(ctx, imageName, options)
if err != nil { if err != nil {
return err return err
} }
defer out.Close() defer out.Close()
return handlePullImageProcess(out, task) stopClose := context.AfterFunc(ctx, func() { _ = out.Close() })
defer stopClose()
err = handlePullImageProcess(out, task)
if ctx.Err() != nil {
return ctx.Err()
}
return err
} }
func handlePullImageProcess(out io.Reader, task *task.Task) error { func handlePullImageProcess(out io.Reader, task *task.Task) error {
@@ -319,11 +330,15 @@ func (c Client) BuildImageWithProcessAndOptions(task *task.Task, tar io.ReadClos
} }
func (c Client) PullImageWithProcess(task *task.Task, imageName string) error { func (c Client) PullImageWithProcess(task *task.Task, imageName string) error {
return c.PullImageWithProcessContext(context.Background(), task, imageName)
}
func (c Client) PullImageWithProcessContext(ctx context.Context, task *task.Task, imageName string) error {
options := image.PullOptions{} options := image.PullOptions{}
if authStr, ok := loadRegistryAuthFromDockerConfig(imageName); ok { if authStr, ok := RegistryAuthForImage(imageName); ok {
options.RegistryAuth = authStr options.RegistryAuth = authStr
} }
return c.PullImageWithProcessAndOptions(task, imageName, options) return c.PullImageWithProcessAndOptionsContext(ctx, task, imageName, options)
} }
func logProcess(progress map[string]interface{}, task *task.Task) { func logProcess(progress map[string]interface{}, task *task.Task) {
@@ -342,26 +357,31 @@ func PullImage(imageName string) error {
} }
defer cli.Close() defer cli.Close()
options := image.PullOptions{} options := image.PullOptions{}
if authStr, ok := loadRegistryAuthFromDockerConfig(imageName); ok { if authStr, ok := RegistryAuthForImage(imageName); ok {
options.RegistryAuth = authStr options.RegistryAuth = authStr
} }
if _, err := cli.ImagePull(context.Background(), imageName, options); err != nil { out, err := cli.ImagePull(context.Background(), imageName, options)
if err != nil {
return err return err
} }
return nil defer out.Close()
return handlePullImageProcess(out, nil)
} }
func loadRegistryAuthFromDockerConfig(imageName string) (string, bool) { func RegistryAuthForImage(imageName string) (string, bool) {
registryHost, hasRegistry := extractRegistryHost(imageName) named, err := reference.ParseNormalizedNamed(imageName)
if err != nil {
return "", false
}
registryHost := normalizeRegistryHost(reference.Domain(named))
cfg := config.LoadDefaultConfigFile(io.Discard) cfg := config.LoadDefaultConfigFile(io.Discard)
if cfg == nil { if cfg == nil {
return "", false return "", false
} }
candidates := make([]string, 0) candidates := make([]string, 0)
if hasRegistry { if !isDockerHubRegistry(registryHost) {
candidates = append(candidates, registryHost, "https://"+registryHost, "http://"+registryHost) candidates = append(candidates, registryHost, "https://"+registryHost, "http://"+registryHost)
} } else {
if !hasRegistry || isDockerHubRegistry(registryHost) {
candidates = append(candidates, candidates = append(candidates,
"https://index.docker.io/v1/", "https://index.docker.io/v1/",
"index.docker.io", "index.docker.io",
@@ -411,18 +431,6 @@ func isDockerHubRegistry(host string) bool {
} }
} }
func extractRegistryHost(imageName string) (string, bool) {
parts := strings.Split(imageName, "/")
if len(parts) < 2 {
return "", false
}
first := parts[0]
if strings.Contains(first, ".") || strings.Contains(first, ":") || first == "localhost" {
return normalizeRegistryHost(first), true
}
return "", false
}
func normalizeRegistryHost(registryKey string) string { func normalizeRegistryHost(registryKey string) string {
key := strings.TrimSpace(registryKey) key := strings.TrimSpace(registryKey)
if key == "" { if key == "" {
+323
View File
@@ -0,0 +1,323 @@
// Package dockerhub checks Docker Hub metadata without downloading image layers.
package dockerhub
import (
"context"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"net/url"
"regexp"
"sort"
"strconv"
"strings"
"time"
"github.com/1Panel-dev/1Panel/agent/app/model"
"github.com/1Panel-dev/1Panel/agent/buserr"
"github.com/1Panel-dev/1Panel/agent/global"
"github.com/1Panel-dev/1Panel/agent/utils/encrypt"
"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, httpClient *http.Client) (*Client, error) {
if httpClient == nil {
httpClient = &http.Client{Timeout: 30 * time.Second}
}
c := &Client{HTTP: httpClient, 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 major version, 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.numbers[0] == baseline.numbers[0] && 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
}
// NewHTTPClient reads the synchronized panel settings for each check, including cron.
func NewHTTPClient() (*http.Client, error) {
var rows []model.Setting
if err := global.DB.Where("key IN ?", []string{"Edition", "ProxyType", "ProxyUrl", "ProxyPort", "ProxyUser", "ProxyPasswd"}).Find(&rows).Error; err != nil {
return nil, err
}
settings := make(map[string]string, len(rows))
for _, row := range rows {
settings[row.Key] = row.Value
}
client := &http.Client{Timeout: 30 * time.Second}
if settings["Edition"] == "intl" {
return client, nil
}
kind, host, port := settings["ProxyType"], strings.TrimSpace(settings["ProxyUrl"]), settings["ProxyPort"]
if kind == "" || kind == "close" || host == "" || port == "" {
return nil, buserr.New("ErrImageCheckProxyRequired")
}
if kind != "http" && kind != "https" && kind != "socks5" {
return nil, buserr.New("ErrImageCheckProxyRequired")
}
number, err := strconv.Atoi(port)
if err != nil || number < 1 || number > 65535 {
return nil, buserr.New("ErrImageCheckProxyRequired")
}
if !strings.Contains(host, "://") {
host = kind + "://" + host
}
address, err := url.Parse(host)
if err != nil || address.Scheme != kind || address.Hostname() == "" || address.User != nil || address.RawQuery != "" || address.Fragment != "" || (address.Path != "" && address.Path != "/") {
return nil, buserr.New("ErrImageCheckProxyRequired")
}
address.Host = net.JoinHostPort(address.Hostname(), port)
address.Path = ""
if settings["ProxyUser"] != "" {
password, err := encrypt.StringDecrypt(settings["ProxyPasswd"])
if err != nil {
return nil, buserr.New("ErrImageCheckProxyRequired")
}
address.User = url.UserPassword(settings["ProxyUser"], password)
}
transport := http.DefaultTransport.(*http.Transport).Clone()
// Explicit proxy selection prevents NO_PROXY from bypassing the panel proxy.
// net/http also supports SOCKS5 URLs and proxy authentication.
transport.Proxy = http.ProxyURL(address)
client.Transport = transport
return client, nil
}
+9 -6
View File
@@ -29,7 +29,7 @@ func Write(envMap map[string]string, filename string) error {
func Marshal(envMap map[string]string) (string, error) { func Marshal(envMap map[string]string) (string, error) {
lines := make([]string, 0, len(envMap)) lines := make([]string, 0, len(envMap))
for k, v := range envMap { for k, v := range envMap {
if d, err := strconv.Atoi(v); err == nil && !isStartWithZero(v) { if d, ok := canonicalInt(v); ok {
lines = append(lines, fmt.Sprintf(`%s=%d`, k, d)) lines = append(lines, fmt.Sprintf(`%s=%d`, k, d))
} else if shouldUseSingleQuotes(v) { } else if shouldUseSingleQuotes(v) {
lines = append(lines, fmt.Sprintf(`%s='%s'`, k, v)) lines = append(lines, fmt.Sprintf(`%s='%s'`, k, v))
@@ -86,7 +86,7 @@ func MarshalWithOrder(envMap map[string]string, orders []string) (string, error)
} }
func formatEnvLine(k, v string) string { func formatEnvLine(k, v string) string {
if d, err := strconv.Atoi(v); err == nil && !isStartWithZero(v) { if d, ok := canonicalInt(v); ok {
return fmt.Sprintf(`%s=%d`, k, d) return fmt.Sprintf(`%s=%d`, k, d)
} else if shouldUseSingleQuotes(v) { } else if shouldUseSingleQuotes(v) {
return fmt.Sprintf(`%s='%s'`, k, v) return fmt.Sprintf(`%s='%s'`, k, v)
@@ -107,11 +107,14 @@ func GetEnvValueByKey(envPath, key string) (string, error) {
return value, nil return value, nil
} }
func isStartWithZero(value string) bool { // canonicalInt reports whether value is an integer that formats back to the
if strings.HasPrefix(value, "0") && len(value) > 1 { // same string, so values such as "007", "+1" or "-01" keep their exact text.
return true func canonicalInt(value string) (int, bool) {
d, err := strconv.Atoi(value)
if err != nil || strconv.Itoa(d) != value {
return 0, false
} }
return false return d, true
} }
func hasEvenDoubleQuotes(s string) bool { func hasEvenDoubleQuotes(s string) bool {
+42
View File
@@ -0,0 +1,42 @@
package env
import (
"testing"
"github.com/joho/godotenv"
)
func TestMarshalKeepsValuesExactly(t *testing.T) {
values := []string{"3306", "0", "-1", "007", "+123456", "-0123", "+0", "-0", "a'b", `a"b`, `a\b`}
for _, v := range values {
envMap := map[string]string{"PANEL_DB_ROOT_PASSWORD": v}
for name, marshal := range map[string]func(map[string]string) (string, error){
"Marshal": Marshal,
"MarshalWithOrder": func(m map[string]string) (string, error) {
return MarshalWithOrder(m, []string{"PANEL_DB_ROOT_PASSWORD"})
},
} {
content, err := marshal(envMap)
if err != nil {
t.Fatalf("%s(%q) returned error: %v", name, v, err)
}
parsed, err := godotenv.Unmarshal(content)
if err != nil {
t.Fatalf("%s(%q) produced unparsable content %q: %v", name, v, content, err)
}
if got := parsed["PANEL_DB_ROOT_PASSWORD"]; got != v {
t.Errorf("%s(%q) wrote %q, which reads back as %q", name, v, content, got)
}
}
}
}
func TestMarshalWritesCanonicalIntegersUnquoted(t *testing.T) {
content, err := Marshal(map[string]string{"PANEL_APP_PORT_HTTP": "8080"})
if err != nil {
t.Fatal(err)
}
if content != "PANEL_APP_PORT_HTTP=8080" {
t.Fatalf("Marshal() = %q, want %q", content, "PANEL_APP_PORT_HTTP=8080")
}
}
+68 -34
View File
@@ -3,6 +3,7 @@ package mcpruntime
import ( import (
"bytes" "bytes"
"context" "context"
_ "crypto/sha256"
"encoding/binary" "encoding/binary"
"encoding/json" "encoding/json"
"errors" "errors"
@@ -15,19 +16,21 @@ import (
"strings" "strings"
"sync" "sync"
"time" "time"
"unicode"
"github.com/1Panel-dev/1Panel/agent/utils/docker" "github.com/1Panel-dev/1Panel/agent/utils/docker"
"github.com/containerd/errdefs"
"github.com/distribution/reference"
"github.com/docker/docker/api/types/container" "github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/image" "github.com/docker/docker/api/types/image"
"github.com/docker/docker/api/types/network" "github.com/docker/docker/api/types/network"
"github.com/docker/docker/api/types/system" "github.com/docker/docker/api/types/system"
"github.com/docker/docker/client" "github.com/docker/docker/client"
"github.com/docker/docker/errdefs"
ocispec "github.com/opencontainers/image-spec/specs-go/v1" ocispec "github.com/opencontainers/image-spec/specs-go/v1"
) )
const ( const (
ImageRepository = "1panel/1panel-mcp-server" ImageRepository = "1panel/mcp-server"
ContainerName = "1panel-mcp" ContainerName = "1panel-mcp"
previousName = "1panel-mcp-previous" previousName = "1panel-mcp-previous"
SocketDirectory = "/etc/1panel/mcp-runtime" SocketDirectory = "/etc/1panel/mcp-runtime"
@@ -48,12 +51,13 @@ var digestPattern = regexp.MustCompile(`^sha256:[a-f0-9]{64}$`)
var ErrUnsupported = errors.New("MCP Docker runtime requires a local rootful Linux Docker daemon without user namespace remapping") var ErrUnsupported = errors.New("MCP Docker runtime requires a local rootful Linux Docker daemon without user namespace remapping")
type Request struct { type Request struct {
Operation string `json:"operation"` Operation string `json:"operation"`
Image string `json:"image"` Image string `json:"image"`
Port int `json:"port"` Port int `json:"port"`
Enabled bool `json:"enabled"` Enabled bool `json:"enabled"`
Restart bool `json:"restart"` Restart bool `json:"restart"`
ImageID string `json:"imageID,omitempty"` ImageID string `json:"imageID,omitempty"`
StreamProgress bool `json:"streamProgress,omitempty"`
} }
type State struct { type State struct {
@@ -106,6 +110,7 @@ type Controller struct {
platform string platform string
health func(context.Context, int) (metadata, error) health func(context.Context, int) (metadata, error)
checkDirectory func() error checkDirectory func() error
registryAuth func(string) (string, bool)
mutation chan struct{} mutation chan struct{}
probes chan struct{} probes chan struct{}
cacheMu sync.Mutex cacheMu sync.Mutex
@@ -118,25 +123,40 @@ func New() *Controller {
func newController(factory func() (DockerClient, error)) *Controller { func newController(factory func() (DockerClient, error)) *Controller {
return &Controller{newClient: factory, platform: runtime.GOOS, health: readHealth, return &Controller{newClient: factory, platform: runtime.GOOS, health: readHealth,
checkDirectory: checkSocketDirectory, mutation: make(chan struct{}, 1), probes: make(chan struct{}, 8), cache: make(map[string]verifiedImage)} checkDirectory: checkSocketDirectory, registryAuth: docker.RegistryAuthForImage,
mutation: make(chan struct{}, 1), probes: make(chan struct{}, 8), cache: make(map[string]verifiedImage)}
} }
func ValidateImage(value string) error { func ValidateImage(value string) error {
if len(value) > 180 { if value == "" || len(value) > 512 || strings.IndexFunc(value, func(ch rune) bool { return unicode.IsSpace(ch) || unicode.IsControl(ch) }) >= 0 {
return errors.New("invalid MCP Docker image") return errors.New("invalid MCP Docker image")
} }
if strings.HasPrefix(value, ImageRepository+"@") && digestPattern.MatchString(strings.TrimPrefix(value, ImageRepository+"@")) { parsed, err := reference.ParseNormalizedNamed(value)
if err != nil {
return errors.New("invalid MCP Docker image")
}
if digested, ok := parsed.(reference.Digested); ok {
if digestPattern.MatchString(digested.Digest().String()) {
return nil
}
return errors.New("MCP Docker image must use a SHA256 digest")
}
if _, ok := parsed.(reference.Tagged); ok {
return nil return nil
} }
if strings.HasPrefix(value, ImageRepository+":") && semanticVersion.MatchString(strings.TrimPrefix(value, ImageRepository+":")) { return errors.New("MCP Docker image must include a tag or SHA256 digest")
return nil
}
return errors.New("MCP Docker image must use the official repository with a fixed semantic version or SHA256 digest")
} }
func (c *Controller) Handle(ctx context.Context, req Request) (Response, error) { func (c *Controller) Handle(ctx context.Context, req Request) (Response, error) {
return c.HandleWithProgress(ctx, req, nil)
}
func (c *Controller) HandleWithProgress(ctx context.Context, req Request, progress func(PullProgress) error) (Response, error) {
if req.StreamProgress && req.Operation != "pull" {
return Response{}, errors.New("MCP Docker progress is only available when pulling an image")
}
switch req.Operation { switch req.Operation {
case "prepare", "catalog", "candidate_catalog", "status", "apply", "install", "rollback", "rollback_absent", "pull", "logs": case "prepare", "catalog", "apply_catalog", "candidate_catalog", "status", "apply", "install", "rollback", "rollback_absent", "pull", "logs":
default: default:
return Response{}, errors.New("invalid MCP Docker operation") return Response{}, errors.New("invalid MCP Docker operation")
} }
@@ -147,7 +167,7 @@ func (c *Controller) Handle(ctx context.Context, req Request) (Response, error)
return Response{}, errors.New("MCP listen port must be between 1024 and 65535") return Response{}, errors.New("MCP listen port must be between 1024 and 65535")
} }
timeout := 60 * time.Second timeout := 60 * time.Second
if req.Operation == "catalog" || req.Operation == "candidate_catalog" || req.Operation == "logs" || req.Operation == "prepare" { if req.Operation == "catalog" || req.Operation == "apply_catalog" || req.Operation == "candidate_catalog" || req.Operation == "logs" || req.Operation == "prepare" {
timeout = 5 * time.Second timeout = 5 * time.Second
} }
if req.Operation == "status" { if req.Operation == "status" {
@@ -176,19 +196,23 @@ func (c *Controller) Handle(ctx context.Context, req Request) (Response, error)
} }
switch req.Operation { switch req.Operation {
case "prepare": case "prepare":
case "catalog", "candidate_catalog": case "catalog", "apply_catalog", "candidate_catalog":
var img image.InspectResponse var img image.InspectResponse
img, err = c.resolve(ctx, cli, req.Image) if req.Operation != "candidate_catalog" {
if err == nil && req.Operation == "catalog" {
var current *container.InspectResponse var current *container.InspectResponse
current, err = c.owned(ctx, cli, ContainerName) current, err = c.owned(ctx, cli, ContainerName)
if err == nil && current != nil && isRunning(current) && current.Config.Labels[imageLabel] == req.Image && current.Image != img.ID { if err == nil && req.Operation == "catalog" && current != nil && current.Config.Labels[imageLabel] != req.Image {
err = errors.New("MCP image reference changed; restart the adapter before reading its catalog") err = errors.New("MCP installed runtime does not match the configured image")
} }
if err == nil {
img, err = c.resolveActiveImage(ctx, cli, req.Image, current)
}
} else {
img, err = c.resolve(ctx, cli, req.Image)
} }
if err == nil { if err == nil {
var verified verifiedImage var verified verifiedImage
verified, err = c.verify(ctx, cli, img.ID, req.Image) verified, err = c.verify(ctx, cli, img.ID)
if err == nil { if err == nil {
return Response{Catalog: append(json.RawMessage(nil), verified.catalog...), ImageID: img.ID}, nil return Response{Catalog: append(json.RawMessage(nil), verified.catalog...), ImageID: img.ID}, nil
} }
@@ -204,7 +228,7 @@ func (c *Controller) Handle(ctx context.Context, req Request) (Response, error)
case "rollback_absent": case "rollback_absent":
err = c.rollbackAbsent(ctx, cli, req) err = c.rollbackAbsent(ctx, cli, req)
case "pull": case "pull":
err = c.pullImage(ctx, cli, req) err = c.pullImage(ctx, cli, req, progress)
case "logs": case "logs":
var current *container.InspectResponse var current *container.InspectResponse
current, err = c.owned(ctx, cli, ContainerName) current, err = c.owned(ctx, cli, ContainerName)
@@ -259,6 +283,20 @@ func (c *Controller) resolve(ctx context.Context, cli DockerClient, ref string)
return img, nil return img, nil
} }
func (c *Controller) resolveActiveImage(ctx context.Context, cli DockerClient, ref string, current *container.InspectResponse) (image.InspectResponse, error) {
if current == nil || current.Config.Labels[imageLabel] != ref {
return c.resolve(ctx, cli, ref)
}
if !digestPattern.MatchString(current.Image) {
return image.InspectResponse{}, errors.New("invalid MCP installed image identity")
}
img, err := c.resolve(ctx, cli, current.Image)
if err != nil || img.ID != current.Image {
return image.InspectResponse{}, errors.New("MCP installed image is unavailable or changed")
}
return img, nil
}
func (c *Controller) owned(ctx context.Context, cli DockerClient, name string) (*container.InspectResponse, error) { func (c *Controller) owned(ctx context.Context, cli DockerClient, name string) (*container.InspectResponse, error) {
info, err := cli.ContainerInspect(ctx, name) info, err := cli.ContainerInspect(ctx, name)
if errdefs.IsNotFound(err) { if errdefs.IsNotFound(err) {
@@ -279,10 +317,6 @@ func isRunning(info *container.InspectResponse) bool {
func (c *Controller) status(ctx context.Context, cli DockerClient, req Request) Response { func (c *Controller) status(ctx context.Context, cli DockerClient, req Request) Response {
state := &State{Status: "not_installed", Image: req.Image} state := &State{Status: "not_installed", Image: req.Image}
img, err := c.resolve(ctx, cli, req.Image)
if err == nil {
state.ImageID = img.ID
}
current, inspectErr := c.owned(ctx, cli, ContainerName) current, inspectErr := c.owned(ctx, cli, ContainerName)
if inspectErr != nil { if inspectErr != nil {
state.Status = "unhealthy" state.Status = "unhealthy"
@@ -290,9 +324,11 @@ func (c *Controller) status(ctx context.Context, cli DockerClient, req Request)
return Response{State: state} return Response{State: state}
} }
if current == nil { if current == nil {
img, err := c.resolve(ctx, cli, req.Image)
if err != nil { if err != nil {
state.Reason = err.Error() state.Reason = err.Error()
} else { } else {
state.ImageID = img.ID
state.Reason = "MCP image is loaded; install it to create the managed container" state.Reason = "MCP image is loaded; install it to create the managed container"
} }
return Response{State: state} return Response{State: state}
@@ -312,12 +348,13 @@ func (c *Controller) status(ctx context.Context, cli DockerClient, req Request)
state.Reason = "MCP access is disabled" state.Reason = "MCP access is disabled"
return Response{State: state} return Response{State: state}
} }
if err != nil || current.Image != img.ID { img, err := c.resolveActiveImage(ctx, cli, req.Image, current)
if err != nil || current.Config.Labels[imageLabel] != req.Image {
state.Status = "unhealthy" state.Status = "unhealthy"
state.Reason = "MCP container does not match the configured image or listen port" state.Reason = "MCP container does not match the configured image or listen port"
return Response{State: state} return Response{State: state}
} }
verified, verifyErr := c.verify(ctx, cli, current.Image, req.Image) verified, verifyErr := c.verify(ctx, cli, current.Image)
if verifyErr != nil || !matchesRuntimeWithCapabilities(ctx, cli, current, img.ID, req, verified.metadata.AdapterVersion) { if verifyErr != nil || !matchesRuntimeWithCapabilities(ctx, cli, current, img.ID, req, verified.metadata.AdapterVersion) {
state.Status = "unhealthy" state.Status = "unhealthy"
state.Reason = "MCP container configuration or metadata does not match the verified image" state.Reason = "MCP container configuration or metadata does not match the verified image"
@@ -334,7 +371,7 @@ func (c *Controller) status(ctx context.Context, cli DockerClient, req Request)
return Response{State: state} return Response{State: state}
} }
func (c *Controller) verify(ctx context.Context, cli DockerClient, id, ref string) (verifiedImage, error) { func (c *Controller) verify(ctx context.Context, cli DockerClient, id string) (verifiedImage, error) {
c.cacheMu.Lock() c.cacheMu.Lock()
cached, ok := c.cache[id] cached, ok := c.cache[id]
c.cacheMu.Unlock() c.cacheMu.Unlock()
@@ -366,9 +403,6 @@ func (c *Controller) verify(ctx context.Context, cli DockerClient, id, ref strin
c.cache[id] = cached c.cache[id] = cached
c.cacheMu.Unlock() c.cacheMu.Unlock()
} }
if strings.HasPrefix(ref, ImageRepository+":") && strings.TrimPrefix(cached.metadata.AdapterVersion, "v") != strings.TrimPrefix(strings.TrimPrefix(ref, ImageRepository+":"), "v") {
return verifiedImage{}, errors.New("MCP image version does not match its selected tag")
}
return cached, nil return cached, nil
} }
+57 -11
View File
@@ -10,49 +10,90 @@ import (
"strings" "strings"
"time" "time"
"github.com/containerd/errdefs"
"github.com/docker/docker/api/types/container" "github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/image" "github.com/docker/docker/api/types/image"
"github.com/docker/docker/api/types/mount" "github.com/docker/docker/api/types/mount"
"github.com/docker/docker/errdefs"
) )
func (c *Controller) pullImage(ctx context.Context, cli DockerClient, req Request) error { func (c *Controller) pullImage(ctx context.Context, cli DockerClient, req Request, progress func(PullProgress) error) error {
if err := reportPullProgress(ctx, progress, PullProgress{Status: "checking_image"}); err != nil {
return err
}
_, err := cli.ImageInspect(ctx, req.Image) _, err := cli.ImageInspect(ctx, req.Image)
if err != nil && !errdefs.IsNotFound(err) { if err != nil && !errdefs.IsNotFound(err) {
return errors.New("MCP Docker image inspection failed") return errors.New("MCP Docker image inspection failed")
} }
if errdefs.IsNotFound(err) { if errdefs.IsNotFound(err) {
if err = reportPullProgress(ctx, progress, PullProgress{Status: "pulling"}); err != nil {
return err
}
pullCtx, cancel := context.WithTimeout(ctx, 3*time.Minute) pullCtx, cancel := context.WithTimeout(ctx, 3*time.Minute)
defer cancel() defer cancel()
reader, pullErr := cli.ImagePull(pullCtx, req.Image, image.PullOptions{}) options := image.PullOptions{}
if auth, ok := c.registryAuth(req.Image); ok {
options.RegistryAuth = auth
}
reader, pullErr := cli.ImagePull(pullCtx, req.Image, options)
if pullErr != nil { if pullErr != nil {
if errdefs.IsUnauthorized(pullErr) || errdefs.IsPermissionDenied(pullErr) {
return errors.New("MCP Docker image registry authentication failed")
}
return errors.New("MCP Docker image download failed") return errors.New("MCP Docker image download failed")
} }
defer reader.Close() defer reader.Close()
decoder := json.NewDecoder(io.LimitReader(reader, 16<<20)) limited := &io.LimitedReader{R: reader, N: (16 << 20) + 1}
decoder := json.NewDecoder(limited)
for { for {
var message struct { var message struct {
Status string `json:"status"`
ID string `json:"id"`
ProgressDetail struct {
Current int64 `json:"current"`
Total int64 `json:"total"`
} `json:"progressDetail"`
Error string `json:"error"` Error string `json:"error"`
ErrorDetail *struct { ErrorDetail *struct {
Code int `json:"code"`
Message string `json:"message"` Message string `json:"message"`
} `json:"errorDetail"` } `json:"errorDetail"`
} }
if err = decoder.Decode(&message); err == io.EOF { err = decoder.Decode(&message)
if limited.N == 0 {
return errors.New("MCP Docker image download progress exceeds permitted size")
}
if err == io.EOF {
break break
} else if err != nil { } else if err != nil {
return errors.New("MCP Docker image download was interrupted") return errors.New("MCP Docker image download was interrupted")
} }
if message.Error != "" || message.ErrorDetail != nil { if message.Error != "" || message.ErrorDetail != nil {
if message.ErrorDetail != nil && (message.ErrorDetail.Code == 401 || message.ErrorDetail.Code == 403) {
return errors.New("MCP Docker image registry authentication failed")
}
return errors.New("MCP Docker image download failed") return errors.New("MCP Docker image download failed")
} }
if normalized, ok := normalizePullProgress(message.Status, message.ID, message.ProgressDetail.Current, message.ProgressDetail.Total); ok {
if err = reportPullProgress(ctx, progress, normalized); err != nil {
return err
}
}
} }
} else if err = reportPullProgress(ctx, progress, PullProgress{Status: "local_image"}); err != nil {
return err
}
if err = reportPullProgress(ctx, progress, PullProgress{Status: "verifying_image"}); err != nil {
return err
} }
img, err := c.resolve(ctx, cli, req.Image) img, err := c.resolve(ctx, cli, req.Image)
if err != nil { if err != nil {
return err return err
} }
_, err = c.verify(ctx, cli, img.ID, req.Image) _, err = c.verify(ctx, cli, img.ID)
return err if err != nil {
return err
}
return reportPullProgress(ctx, progress, PullProgress{Status: "verified_image"})
} }
func (c *Controller) installRuntime(ctx context.Context, cli DockerClient, req Request) error { func (c *Controller) installRuntime(ctx context.Context, cli DockerClient, req Request) error {
@@ -176,14 +217,19 @@ func (c *Controller) apply(ctx context.Context, cli DockerClient, req Request) e
if err = c.checkDirectory(); err != nil { if err = c.checkDirectory(); err != nil {
return err return err
} }
img, err := c.resolve(ctx, cli, req.Image) var img image.InspectResponse
if req.Operation == "install" {
img, err = c.resolve(ctx, cli, req.Image)
} else {
img, err = c.resolveActiveImage(ctx, cli, req.Image, current)
}
if err != nil { if err != nil {
return err return err
} }
if !digestPattern.MatchString(req.ImageID) || img.ID != req.ImageID { if !digestPattern.MatchString(req.ImageID) || img.ID != req.ImageID {
return errors.New("MCP image identity changed after catalog validation; retry the operation") return errors.New("MCP image identity changed after catalog validation; retry the operation")
} }
verified, err := c.verify(ctx, cli, img.ID, req.Image) verified, err := c.verify(ctx, cli, img.ID)
if err != nil { if err != nil {
return err return err
} }
@@ -354,7 +400,7 @@ func (c *Controller) rollback(ctx context.Context, cli DockerClient, req Request
} }
return errors.New("the previous MCP runtime instance is unavailable for rollback") return errors.New("the previous MCP runtime instance is unavailable for rollback")
} }
verified, err := c.verify(ctx, cli, target.Image, req.Image) verified, err := c.verify(ctx, cli, target.Image)
if err != nil { if err != nil {
return err return err
} }
@@ -402,7 +448,7 @@ func (c *Controller) rollbackAbsent(ctx context.Context, cli DockerClient, req R
if _, err = c.resolve(ctx, cli, current.Image); err != nil { if _, err = c.resolve(ctx, cli, current.Image); err != nil {
return err return err
} }
verified, err := c.verify(ctx, cli, current.Image, req.Image) verified, err := c.verify(ctx, cli, current.Image)
if err != nil { if err != nil {
return err return err
} }
+63
View File
@@ -0,0 +1,63 @@
package mcpruntime
import (
"context"
"errors"
"regexp"
)
type PullProgress struct {
Status string `json:"status,omitempty"`
ID string `json:"id,omitempty"`
Current int64 `json:"current,omitempty"`
Total int64 `json:"total,omitempty"`
}
type PullEvent struct {
Type string `json:"type"`
PullProgress
Error string `json:"error,omitempty"`
}
var pullLayerID = regexp.MustCompile(`^[a-f0-9]{12,64}$`)
func reportPullProgress(ctx context.Context, report func(PullProgress) error, progress PullProgress) error {
if err := ctx.Err(); err != nil {
return err
}
if report != nil && report(progress) != nil {
return errors.New("MCP Docker progress connection closed")
}
return nil
}
func normalizePullProgress(status, id string, current, total int64) (PullProgress, bool) {
var normalized string
switch status {
case "Pulling fs layer":
normalized = "pulling"
case "Waiting":
normalized = "waiting"
case "Downloading":
normalized = "downloading"
case "Verifying Checksum":
normalized = "verifying_checksum"
case "Download complete":
normalized = "download_complete"
case "Extracting":
normalized = "extracting"
case "Pull complete":
normalized = "pull_complete"
case "Already exists":
normalized = "already_exists"
default:
return PullProgress{}, false
}
if !pullLayerID.MatchString(id) {
id = ""
}
if current < 0 || total < 0 || (total > 0 && current > total) {
current, total = 0, 0
}
return PullProgress{Status: normalized, ID: id, Current: current, Total: total}, true
}
+4 -2
View File
@@ -6,8 +6,9 @@ import (
) )
const ( const (
VMNameValidationPattern = `^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$` ModelDownloaderRepoIDPattern = `^[A-Za-z0-9][A-Za-z0-9._-]*/[A-Za-z0-9][A-Za-z0-9._-]*$`
VMCommonPattern = `^[a-zA-Z0-9][a-zA-Z0-9._:-]{0,63}$` VMNameValidationPattern = `^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$`
VMCommonPattern = `^[a-zA-Z0-9][a-zA-Z0-9._:-]{0,63}$`
VLLMMetricSamplePattern = `^([a-zA-Z_:][a-zA-Z0-9_:]*)(\{(?:[^"\\]|"(?:\\.|[^"\\])*")*\})?\s+(\S+)(?:\s+.*)?$` VLLMMetricSamplePattern = `^([a-zA-Z_:][a-zA-Z0-9_:]*)(\{(?:[^"\\]|"(?:\\.|[^"\\])*")*\})?\s+(\S+)(?:\s+.*)?$`
VLLMMetricLabelPattern = `([a-zA-Z_][a-zA-Z0-9_]*)\s*=\s*("(?:[^"\\]|\\.)*")` VLLMMetricLabelPattern = `([a-zA-Z_][a-zA-Z0-9_]*)\s*=\s*("(?:[^"\\]|\\.)*")`
@@ -66,6 +67,7 @@ var regexMap = make(map[string]*regexp.Regexp)
func Init() { func Init() {
patterns := []string{ patterns := []string{
ModelDownloaderRepoIDPattern,
VMNameValidationPattern, VMNameValidationPattern,
VMCommonPattern, VMCommonPattern,
+98
View File
@@ -0,0 +1,98 @@
// Package taskscope separates task visibility and executor ownership from task storage.
package taskscope
import (
"context"
"errors"
"slices"
"sort"
"strings"
"sync/atomic"
)
type Rule struct {
Scope string
Restricted bool
ExternalExecutor bool
}
type policy struct{ rules []Rule }
var configured atomic.Pointer[policy]
var ErrUnavailable = errors.New("task scope policy is unavailable")
func Configure(rules ...Rule) error {
copyRules := append([]Rule(nil), rules...)
if len(copyRules) == 0 {
return ErrUnavailable
}
sort.Slice(copyRules, func(i, j int) bool { return copyRules[i].Scope < copyRules[j].Scope })
for i, rule := range copyRules {
if rule.Scope == "" || strings.TrimSpace(rule.Scope) != rule.Scope || strings.ContainsAny(rule.Scope, "\x00\r\n") ||
i > 0 && copyRules[i-1].Scope == rule.Scope {
return errors.New("invalid task scope policy")
}
}
next := &policy{rules: copyRules}
if configured.CompareAndSwap(nil, next) {
return nil
}
if !slices.Equal(configured.Load().rules, next.rules) {
return errors.New("task scope policy is already configured")
}
return nil
}
func ReadExclusions(granted ...string) ([]string, error) {
p := configured.Load()
if p == nil {
return nil, ErrUnavailable
}
allowed := make(map[string]bool, len(granted))
for _, scope := range granted {
found := false
for _, rule := range p.rules {
if rule.Scope == scope && rule.Restricted {
found = true
break
}
}
if !found || allowed[scope] {
return nil, errors.New("invalid task scope grant")
}
allowed[scope] = true
}
var excluded []string
for _, rule := range p.rules {
if rule.Restricted && !allowed[rule.Scope] {
excluded = append(excluded, rule.Scope)
}
}
return excluded, nil
}
func RecoveryExclusions() ([]string, error) {
p := configured.Load()
if p == nil {
return nil, ErrUnavailable
}
var excluded []string
for _, rule := range p.rules {
if rule.ExternalExecutor {
excluded = append(excluded, rule.Scope)
}
}
return excluded, nil
}
type grantKey struct{}
func WithGrants(ctx context.Context, granted ...string) context.Context {
return context.WithValue(ctx, grantKey{}, append([]string(nil), granted...))
}
func Grants(ctx context.Context) []string {
granted, _ := ctx.Value(grantKey{}).([]string)
return append([]string(nil), granted...)
}
+63 -3
View File
@@ -1,6 +1,7 @@
package v2 package v2
import ( import (
"bytes"
"context" "context"
"encoding/json" "encoding/json"
"errors" "errors"
@@ -32,6 +33,41 @@ func mcpRead(c *gin.Context, value any) bool {
if json.Unmarshal(data, &fields) != nil || len(fields.ExpiresAt) == 0 { if json.Unmarshal(data, &fields) != nil || len(fields.ExpiresAt) == 0 {
err = contract.Invalid() err = contract.Invalid()
} }
case *dto.MCPWebsiteBind, *dto.MCPWebsiteUpdate:
var fields struct {
EnableSSL json.RawMessage `json:"enableSSL"`
SSLID json.RawMessage `json:"sslID"`
}
if json.Unmarshal(data, &fields) != nil || len(fields.EnableSSL) == 0 || len(fields.SSLID) == 0 ||
bytes.Equal(bytes.TrimSpace(fields.EnableSSL), []byte("null")) || bytes.Equal(bytes.TrimSpace(fields.SSLID), []byte("null")) {
err = contract.Invalid()
}
case *dto.MCPRuntimeInstall:
var fields struct {
AccessPolicy json.RawMessage `json:"accessPolicy"`
AllowedIPs json.RawMessage `json:"allowedIPs"`
Website *struct {
Domain json.RawMessage `json:"domain"`
EnableSSL json.RawMessage `json:"enableSSL"`
SSLID json.RawMessage `json:"sslID"`
} `json:"website"`
}
if json.Unmarshal(data, &fields) != nil {
err = contract.Invalid()
} else {
for _, field := range []json.RawMessage{fields.AccessPolicy, fields.AllowedIPs} {
if len(field) == 0 || bytes.Equal(bytes.TrimSpace(field), []byte("null")) {
err = contract.Invalid()
}
}
}
if fields.Website != nil {
for _, field := range []json.RawMessage{fields.Website.Domain, fields.Website.EnableSSL, fields.Website.SSLID} {
if len(field) == 0 || bytes.Equal(bytes.TrimSpace(field), []byte("null")) {
err = contract.Invalid()
}
}
}
} }
} }
if err != nil { if err != nil {
@@ -179,6 +215,21 @@ func (b *BaseApi) RevokeMCPCredential(c *gin.Context) {
} }
helper.Success(c) helper.Success(c)
} }
func (b *BaseApi) DeleteMCPCredential(c *gin.Context) {
s, owner, ok := mcpManagement(c, true, false)
if !ok {
return
}
var req dto.MCPCredentialMutation
if !mcpRead(c, &req) {
return
}
if err := s.Delete(owner, req); err != nil {
mcpManagementError(c, err)
return
}
helper.Success(c)
}
func (b *BaseApi) SearchMCPInvocations(c *gin.Context) { func (b *BaseApi) SearchMCPInvocations(c *gin.Context) {
s, owner, ok := mcpManagement(c, false, false) s, owner, ok := mcpManagement(c, false, false)
if !ok { if !ok {
@@ -207,8 +258,17 @@ func (b *BaseApi) MCPServiceStatus(c *gin.Context) {
mcpManagementError(c, err) mcpManagementError(c, err)
return return
} }
result.Endpoint = mcpPublicEndpoint(c, result.Endpoint) if result.Website == nil {
result.Endpoint = mcpPublicEndpoint(c, result.Endpoint)
}
result.CanManageService = owner.IsSuperAdmin result.CanManageService = owner.IsSuperAdmin
if owner.IsSuperAdmin {
result.InstallTaskID, result.Installing, err = service.NewMCPRuntimeService().InstallationTask(ctx)
if err != nil {
mcpManagementError(c, err)
return
}
}
helper.SuccessWithData(c, result) helper.SuccessWithData(c, result)
} }
func (b *BaseApi) MCPSettings(c *gin.Context) { func (b *BaseApi) MCPSettings(c *gin.Context) {
@@ -268,9 +328,9 @@ func (b *BaseApi) InstallMCPService(c *gin.Context) {
if !mcpRead(c, &req) { if !mcpRead(c, &req) {
return return
} }
ctx, cancel := context.WithTimeout(c.Request.Context(), 6*time.Minute) ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second)
defer cancel() defer cancel()
result, err := service.NewMCPRuntimeService().Install(ctx, req) result, err := service.NewMCPRuntimeService().SubmitInstall(ctx, req)
if err != nil { if err != nil {
mcpServiceError(c, err) mcpServiceError(c, err)
return return
+10 -1
View File
@@ -55,7 +55,16 @@ func (b *BaseApi) ExportMCPCredential(c *gin.Context) {
mcpManagementError(c, err) mcpManagementError(c, err)
return return
} }
result, err := s.ExportMarkdown(owner, req, mcpPublicEndpoint(c, settings.ExternalURL), c.GetHeader("Accept-Language")) endpoint := mcpPublicEndpoint(c, settings.ExternalURL)
website, err := service.NewMCPRuntimeService().WebsiteStatus(c.Request.Context())
if err != nil {
mcpManagementError(c, err)
return
}
if website.State != "unbound" {
endpoint = website.Endpoint
}
result, err := s.ExportMarkdown(owner, req, endpoint, c.GetHeader("Accept-Language"))
if err != nil { if err != nil {
mcpManagementError(c, err) mcpManagementError(c, err)
return return
+6
View File
@@ -31,6 +31,12 @@ func mcpManagementMessage(code string) string {
key = "ErrMCPAuditUnavailable" key = "ErrMCPAuditUnavailable"
case "MCP_ENDPOINT_UNAVAILABLE": case "MCP_ENDPOINT_UNAVAILABLE":
key = "ErrMCPEndpointUnavailable" key = "ErrMCPEndpointUnavailable"
case "MCP_WEBSITE_PENDING":
key = "ErrMCPWebsitePending"
case "MCP_WEBSITE_UNAVAILABLE":
key = "ErrMCPWebsiteUnavailable"
case "MCP_WEBSITE_SERVICE_REQUIRED":
key = "ErrMCPWebsiteServiceRequired"
} }
return i18n.GetMsgWithMap(key, nil) return i18n.GetMsgWithMap(key, nil)
} }
+84
View File
@@ -0,0 +1,84 @@
package v2
import (
"context"
"time"
"github.com/1Panel-dev/1Panel/core/app/api/v2/helper"
"github.com/1Panel-dev/1Panel/core/app/dto"
"github.com/1Panel-dev/1Panel/core/app/service"
contract "github.com/1Panel-dev/1Panel/core/utils/mcp"
"github.com/gin-gonic/gin"
)
func (b *BaseApi) MCPWebsiteStatus(c *gin.Context) {
if _, _, ok := mcpManagement(c, false, true); !ok {
return
}
if c.Request.URL.RawQuery != "" {
mcpManagementError(c, contract.Invalid())
return
}
ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second)
defer cancel()
result, err := service.NewMCPRuntimeService().WebsiteStatus(ctx)
if err != nil {
mcpManagementError(c, err)
return
}
helper.SuccessWithData(c, result)
}
func (b *BaseApi) BindMCPWebsite(c *gin.Context) {
if _, _, ok := mcpManagement(c, true, true); !ok {
return
}
var req dto.MCPWebsiteBind
if !mcpRead(c, &req) {
return
}
ctx, cancel := context.WithTimeout(context.WithoutCancel(c.Request.Context()), 90*time.Second)
defer cancel()
result, err := service.NewMCPRuntimeService().BindWebsite(ctx, req)
if err != nil {
mcpManagementError(c, err)
return
}
helper.SuccessWithData(c, result)
}
func (b *BaseApi) UpdateMCPWebsite(c *gin.Context) {
if _, _, ok := mcpManagement(c, true, true); !ok {
return
}
var req dto.MCPWebsiteUpdate
if !mcpRead(c, &req) {
return
}
ctx, cancel := context.WithTimeout(context.WithoutCancel(c.Request.Context()), 90*time.Second)
defer cancel()
result, err := service.NewMCPRuntimeService().UpdateWebsite(ctx, req)
if err != nil {
mcpManagementError(c, err)
return
}
helper.SuccessWithData(c, result)
}
func (b *BaseApi) UnbindMCPWebsite(c *gin.Context) {
if _, _, ok := mcpManagement(c, true, true); !ok {
return
}
var req dto.MCPWebsiteUnbind
if !mcpRead(c, &req) {
return
}
ctx, cancel := context.WithTimeout(context.WithoutCancel(c.Request.Context()), 90*time.Second)
defer cancel()
result, err := service.NewMCPRuntimeService().UnbindWebsite(ctx, req)
if err != nil {
mcpManagementError(c, err)
return
}
helper.SuccessWithData(c, result)
}
+35 -19
View File
@@ -14,28 +14,44 @@ type MCPRuntimeSettings struct {
type MCPRuntimeSettingsUpdate = MCPRuntimeSettings type MCPRuntimeSettingsUpdate = MCPRuntimeSettings
type MCPRuntimeInstall struct { type MCPRuntimeInstall struct {
DockerImage string `json:"dockerImage"` DockerImage string `json:"dockerImage"`
ListenPort int `json:"listenPort"` ListenPort int `json:"listenPort"`
ExternalURL string `json:"externalUrl"` ExternalURL string `json:"externalUrl"`
Revision uint64 `json:"revision"` Revision uint64 `json:"revision"`
AccessPolicy string `json:"accessPolicy"`
AllowedIPs []string `json:"allowedIPs"`
Website *MCPRuntimeInstallWebsite `json:"website,omitempty"`
}
type MCPRuntimeInstallWebsite struct {
Domain string `json:"domain"`
EnableSSL bool `json:"enableSSL"`
SSLID uint `json:"sslID"`
}
type MCPRuntimeInstallTask struct {
TaskID string `json:"taskID"`
} }
type MCPRuntimeStatus struct { type MCPRuntimeStatus struct {
RuntimeType string `json:"runtimeType"` RuntimeType string `json:"runtimeType"`
DockerImage string `json:"dockerImage"` DockerImage string `json:"dockerImage"`
ContainerName string `json:"containerName"` ContainerName string `json:"containerName"`
ImageID string `json:"imageId"` ImageID string `json:"imageId"`
Enabled bool `json:"enabled"` Enabled bool `json:"enabled"`
Installed bool `json:"installed"` Installed bool `json:"installed"`
Status string `json:"status"` Status string `json:"status"`
Ready bool `json:"ready"` Ready bool `json:"ready"`
Reason string `json:"reason"` Reason string `json:"reason"`
AdapterVersion string `json:"adapterVersion"` AdapterVersion string `json:"adapterVersion"`
ContractVersion int `json:"contractVersion"` ContractVersion int `json:"contractVersion"`
Protocol string `json:"protocol"` Protocol string `json:"protocol"`
InternalEndpoint string `json:"internalEndpoint"` InternalEndpoint string `json:"internalEndpoint"`
Endpoint string `json:"endpoint"` Endpoint string `json:"endpoint"`
CanManageService bool `json:"canManageService"` CanManageService bool `json:"canManageService"`
InstallTaskID string `json:"installTaskID,omitempty"`
Installing bool `json:"installing"`
Website *MCPWebsiteStatus `json:"website,omitempty"`
} }
type MCPRuntimeLogs struct { type MCPRuntimeLogs struct {
+43
View File
@@ -0,0 +1,43 @@
package dto
type MCPWebsiteBind struct {
RequestID string `json:"requestId"`
Revision uint64 `json:"revision"`
Domain string `json:"domain"`
EnableSSL bool `json:"enableSSL"`
SSLID uint `json:"sslID"`
}
type MCPWebsiteUpdate struct {
RequestID string `json:"requestId"`
Revision uint64 `json:"revision"`
EnableSSL bool `json:"enableSSL"`
SSLID uint `json:"sslID"`
}
type MCPWebsiteUnbind struct {
RequestID string `json:"requestId"`
Revision uint64 `json:"revision"`
}
type MCPWebsitePendingRequest struct {
Operation string `json:"operation"`
RequestID string `json:"requestId"`
Domain string `json:"domain"`
EnableSSL bool `json:"enableSSL"`
SSLID uint `json:"sslID"`
}
type MCPWebsiteStatus struct {
State string `json:"state"`
WebsiteID uint `json:"websiteId"`
Domain string `json:"domain"`
Endpoint string `json:"endpoint"`
EnableSSL bool `json:"enableSSL"`
SSLID uint `json:"sslID"`
WebsiteStatus string `json:"websiteStatus"`
RequestID string `json:"requestId"`
Revision uint64 `json:"revision"`
Reason string `json:"reason,omitempty"`
PendingRequest *MCPWebsitePendingRequest `json:"pendingRequest,omitempty"`
}

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