Compare commits

...
Author SHA1 Message Date
guoyuqi 2a480c2135 feat(container): add image update checks with scheduling and task logs 2026-10-28 17:53:22 +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
蘭 d7355f3ccc feat(mcp): add 1Panel MCP and enhance MCP credential management (#14189)
* feat(mcp): add 1Panel MCP and enhance MCP credential management

* feat(i18n): implement MCP document localization and remove legacy code
2026-10-26 13:59:49 +08:00
CityFun 93ca724985 fix(appstore): refresh cached app resources and PHP runtime version options (#14185) 2026-10-26 11:29:29 +08:00
ssongliu 9481a7535d fix: align AI menu order and labels (#14183) 2026-10-26 11:29:20 +08:00
ssongliu 14be5b7870 feat(ai): translate benchmark comparison metrics (#14182) 2026-10-25 14:05:09 +08:00
ssongliu c4b76a8e89 feat(ai): unify model services and inference engine management (#14175) 2026-10-23 18:39:22 +08:00
Woolsenandssongliu 9295f4ef8a fix: correct 1pctl password complexity validation (#14169)
* fix: correct 1pctl password complexity validation

`isValidPassword("newPassword")` passed a literal string and the condition was inverted, so the complexity check never ran. Extract the validation into a testable helper and cover both the enabled and disabled paths.

Refs #12640

* Delete core/cmd/server/cmd/update_test.go

---------

Co-authored-by: ssongliu <sloooop1x@gmail.com>
2026-10-23 18:26:25 +08:00
Yuki Guo c925184bf6 fix: container list (#14156)
* fix: container list

* fix: container list
2026-10-23 18:19:52 +08:00
CityFun 87828b03ae fix(website): sync PHP runtime changes to subsites (#14166)
Update subsite upstreams and runtime references together with the parent site. Preserve stopped sites and custom PHP settings, and restore configurations and database records when applying the change fails.
2026-10-23 18:19:44 +08:00
Yuki Guo e7b4146e1b fix: operation log text (#14173) 2026-10-23 18:19:30 +08:00
Yuki Guo ec998552b9 feat: ca website upload (#14148) 2026-10-23 18:12:57 +08:00
Woolsen 1af237944d fix(file): reject rename when the target already exists (#13946) (#14168) 2026-10-23 14:05:48 +08:00
258 changed files with 21857 additions and 2606 deletions
+20
View File
@@ -826,6 +826,26 @@ func (b *BaseApi) UpdateAgentFeishuConfig(c *gin.Context) {
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
// @Summary Get Agent Telegram channel config
// @Accept json
+12 -16
View File
@@ -72,22 +72,6 @@ func (b *BaseApi) CloseOllamaModel(c *gin.Context) {
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
// @Summary Page Ollama models
// @Accept json
@@ -114,6 +98,18 @@ func (b *BaseApi) SearchOllamaModel(c *gin.Context) {
})
}
func (b *BaseApi) SyncOllamaModelAccount(c *gin.Context) {
var req dto.OllamaModelAccountSync
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
if err := aiToolService.SyncAccount(req); err != nil {
helper.BadRequest(c, err)
return
}
helper.Success(c)
}
// @Tags AI
// @Summary Page Ollama models
// @Accept json
+16
View File
@@ -8,6 +8,7 @@ import (
"github.com/1Panel-dev/1Panel/agent/app/dto/request"
"github.com/1Panel-dev/1Panel/agent/i18n"
"github.com/1Panel-dev/1Panel/agent/utils/appicon"
"github.com/1Panel-dev/1Panel/agent/utils/mcpapi"
"github.com/gin-gonic/gin"
)
@@ -160,10 +161,25 @@ func (b *BaseApi) GetAppDetailByID(c *gin.Context) {
// @Router /apps/install [post]
// @x-panel-log {"bodyKeys":["name"],"paramKeys":[],"BeforeFunctions":[],"formatZH":"安装应用 [name]","formatEN":"Install app [name]"}
func (b *BaseApi) InstallApp(c *gin.Context) {
mcpRequest, trustedMCP := mcpapi.Check(c.Request)
if mcpRequest && !trustedMCP {
c.AbortWithStatus(http.StatusForbidden)
return
}
var req request.AppInstallCreate
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
if mcpRequest {
req.MCPPreserveWebsiteDir = true
if value, exists := req.Params["WEBSITE_DIR"]; exists {
directory, ok := value.(string)
if !ok || directory == "" || directory != settingService.GetWebsiteDir() {
c.AbortWithStatus(http.StatusForbidden)
return
}
}
}
install, err := appService.Install(req, true)
if err != nil {
helper.InternalServer(c, err)
+18
View File
@@ -1,7 +1,11 @@
package v2
import (
"net/http"
"github.com/1Panel-dev/1Panel/agent/app/dto/request"
"github.com/1Panel-dev/1Panel/agent/app/service"
"github.com/1Panel-dev/1Panel/agent/utils/mcpapi"
"github.com/1Panel-dev/1Panel/agent/app/api/v2/helper"
"github.com/1Panel-dev/1Panel/agent/app/dto"
@@ -336,11 +340,25 @@ func (b *BaseApi) UpdateAppConfig(c *gin.Context) {
// @Security Timestamp
// @Router /apps/installed/info/:appInstallId [get]
func (b *BaseApi) GetAppInstallInfo(c *gin.Context) {
mcpRequest, trustedMCP := mcpapi.Check(c.Request)
if mcpRequest && !trustedMCP {
c.AbortWithStatus(http.StatusForbidden)
return
}
appInstallId, err := helper.GetIntParamByKey(c, "appInstallId")
if err != nil {
helper.BadRequest(c, err)
return
}
if mcpRequest {
info, err := (&service.AppInstallService{}).GetAppInstallInfoForMCP(c.Request.Context(), appInstallId)
if err != nil {
helper.InternalServer(c, err)
return
}
helper.SuccessWithData(c, info)
return
}
info, err := appInstallService.GetAppInstallInfo(appInstallId)
if err != nil {
helper.InternalServer(c, err)
+9 -3
View File
@@ -28,7 +28,7 @@ func (b *BaseApi) SearchContainer(c *gin.Context) {
return
}
total, list, err := containerService.Page(req)
total, list, err := containerService.Page(c.Request.Context(), req)
if err != nil {
helper.InternalServer(c, err)
return
@@ -249,9 +249,10 @@ func (b *BaseApi) ListContainerByImage(c *gin.Context) {
// @Success 200 {object} dto.ContainerStatus
// @Security ApiKeyAuth
// @Security Timestamp
// @Param containersOnly query boolean false "Only count containers"
// @Router /containers/status [get]
func (b *BaseApi) LoadContainerStatus(c *gin.Context) {
data, err := containerService.LoadStatus()
data, err := containerService.LoadStatus(c.Request.Context(), c.Query("containersOnly") == "true")
if err != nil {
helper.InternalServer(c, err)
return
@@ -415,9 +416,14 @@ func (b *BaseApi) LoadResourceLimit(c *gin.Context) {
// @Success 200 {array} dto.ContainerListStats
// @Security ApiKeyAuth
// @Security Timestamp
// @Param ids query string false "Comma-separated container IDs; omitted selects all containers"
// @Router /containers/list/stats [get]
func (b *BaseApi) ContainerListStats(c *gin.Context) {
data, err := containerService.ContainerListStats()
var ids []string
if _, supplied := c.Request.URL.Query()["ids"]; supplied {
ids = strings.FieldsFunc(c.Query("ids"), func(r rune) bool { return r == ',' })
}
data, err := containerService.ContainerListStats(c.Request.Context(), ids)
if err != nil {
helper.InternalServer(c, err)
return
@@ -0,0 +1,43 @@
package v2
import (
"github.com/1Panel-dev/1Panel/agent/app/api/v2/helper"
"github.com/1Panel-dev/1Panel/agent/app/service"
"github.com/gin-gonic/gin"
)
func (b *BaseApi) GetContainerImageCheckUpdateStatus(c *gin.Context) {
status, err := service.GetContainerImageCheckUpdateStatus()
if err != nil {
helper.InternalServer(c, err)
return
}
helper.SuccessWithData(c, status)
}
func (b *BaseApi) ContainerImageCheckUpdate(c *gin.Context) {
taskID, err := service.StartContainerImageCheckUpdate()
if err != nil {
helper.InternalServer(c, err)
return
}
status, err := service.GetContainerImageCheckUpdateStatus()
if err != nil {
helper.InternalServer(c, err)
return
}
status.TaskID = taskID
helper.SuccessWithData(c, status)
}
func (b *BaseApi) SetContainerImageCheckUpdateSchedule(c *gin.Context) {
var req struct {
Enabled *bool `json:"enabled" validate:"required"`
}
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
if err := service.SetContainerImageCheckUpdateSchedule(*req.Enabled); err != nil {
helper.InternalServer(c, err)
return
}
b.GetContainerImageCheckUpdateStatus(c)
}
+17
View File
@@ -2,9 +2,12 @@ package v2
import (
"encoding/base64"
"net/http"
"github.com/1Panel-dev/1Panel/agent/app/api/v2/helper"
"github.com/1Panel-dev/1Panel/agent/app/dto"
"github.com/1Panel-dev/1Panel/agent/app/service"
"github.com/1Panel-dev/1Panel/agent/utils/mcpapi"
"github.com/gin-gonic/gin"
)
@@ -98,11 +101,25 @@ func (b *BaseApi) SearchDatabase(c *gin.Context) {
// @Param type path string true "type"
// @Router /databases/db/list/:type [get]
func (b *BaseApi) ListDatabase(c *gin.Context) {
mcpRequest, trustedMCP := mcpapi.Check(c.Request)
if mcpRequest && !trustedMCP {
c.AbortWithStatus(http.StatusForbidden)
return
}
dbType, err := helper.GetStrParamByKey(c, "type")
if err != nil {
helper.BadRequest(c, err)
return
}
if mcpRequest {
list, err := (&service.DatabaseService{}).ListForMCP(c.Request.Context(), dbType)
if err != nil {
helper.BadRequest(c, err)
return
}
helper.SuccessWithData(c, list)
return
}
list, err := databaseService.List(dbType)
if err != nil {
helper.InternalServer(c, err)
+32
View File
@@ -3,9 +3,12 @@ package v2
import (
"context"
"encoding/base64"
"net/http"
"github.com/1Panel-dev/1Panel/agent/app/api/v2/helper"
"github.com/1Panel-dev/1Panel/agent/app/dto"
"github.com/1Panel-dev/1Panel/agent/app/service"
"github.com/1Panel-dev/1Panel/agent/utils/mcpapi"
"github.com/gin-gonic/gin"
)
@@ -19,6 +22,11 @@ import (
// @Router /databases [post]
// @x-panel-log {"bodyKeys":["name"],"paramKeys":[],"BeforeFunctions":[],"formatZH":"创建 mysql 数据库 [name]","formatEN":"create mysql database [name]"}
func (b *BaseApi) CreateMysql(c *gin.Context) {
mcpRequest, trustedMCP := mcpapi.Check(c.Request)
if mcpRequest && !trustedMCP {
c.AbortWithStatus(http.StatusForbidden)
return
}
var req dto.MysqlDBCreate
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
@@ -32,6 +40,16 @@ func (b *BaseApi) CreateMysql(c *gin.Context) {
}
req.Password = string(password)
}
if mcpRequest {
created, err := (&service.MysqlService{}).CreateForMCP(c.Request.Context(), req)
if err != nil {
helper.InternalServer(c, err)
return
}
nodeID, _ := mcpapi.NodeID(c.Request)
helper.SuccessWithData(c, map[string]any{"id": created.ID, "database": created.MysqlName, "name": created.Name, "nodeId": nodeID})
return
}
if _, err := mysqlService.Create(context.Background(), req); err != nil {
helper.InternalServer(c, err)
@@ -513,11 +531,25 @@ func (b *BaseApi) LoadRemoteAccess(c *gin.Context) {
// @Security Timestamp
// @Router /databases/status [post]
func (b *BaseApi) LoadStatus(c *gin.Context) {
mcpRequest, trustedMCP := mcpapi.Check(c.Request)
if mcpRequest && !trustedMCP {
c.AbortWithStatus(http.StatusForbidden)
return
}
var req dto.OperationWithNameAndType
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
if mcpRequest {
data, err := (&service.MysqlService{}).LoadStatusForMCP(c.Request.Context(), req)
if err != nil {
helper.InternalServer(c, err)
return
}
helper.SuccessWithData(c, data)
return
}
data, err := mysqlService.LoadStatus(req)
if err != nil {
helper.InternalServer(c, err)
+4
View File
@@ -12,6 +12,7 @@ import (
"sync"
"github.com/1Panel-dev/1Panel/agent/global"
"github.com/1Panel-dev/1Panel/agent/utils/mcpapi"
"gorm.io/gorm"
"github.com/1Panel-dev/1Panel/agent/app/dto"
@@ -22,6 +23,7 @@ import (
)
func ErrorWithDetail(ctx *gin.Context, code int, msgKey string, err error) {
mcpapi.SetBusinessErrorHeader(ctx.Request, ctx.Writer.Header(), err)
res := dto.Response{
Code: code,
Message: "",
@@ -32,6 +34,7 @@ func ErrorWithDetail(ctx *gin.Context, code int, msgKey string, err error) {
}
func ErrorWithBusinessCode(ctx *gin.Context, code int, businessCode, msgKey string, err error) {
mcpapi.SetBusinessErrorHeader(ctx.Request, ctx.Writer.Header(), err)
res := dto.Response{
Code: code,
ErrorCode: businessCode,
@@ -42,6 +45,7 @@ func ErrorWithBusinessCode(ctx *gin.Context, code int, businessCode, msgKey stri
}
func ErrorWithDetailAndData(ctx *gin.Context, code int, msgKey string, err error, data interface{}) {
mcpapi.SetBusinessErrorHeader(ctx.Request, ctx.Writer.Header(), err)
res := dto.Response{
Code: code,
Data: data,
+3 -2
View File
@@ -61,13 +61,14 @@ func (b *BaseApi) CreateRuntime(c *gin.Context) {
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /runtimes/del [post]
// @x-panel-log {"bodyKeys":["id"],"paramKeys":[],"BeforeFunctions":[],"formatZH":"删除运行环境 [id]","formatEN":"Delete runtime [id]"}
// @x-panel-log {"bodyKeys":[],"paramKeys":[],"BeforeFunctions":[],"formatZH":"删除运行环境 [name]","formatEN":"Delete runtime [name]"}
func (b *BaseApi) DeleteRuntime(c *gin.Context) {
var req request.RuntimeDelete
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
err := runtimeService.Delete(req)
name, err := runtimeService.Delete(req)
helper.SetOperationLogValues(c, map[string]string{"name": name})
if err != nil {
helper.InternalServer(c, err)
return
+1 -1
View File
@@ -402,7 +402,7 @@ func (b *BaseApi) ChangeDefaultServer(c *gin.Context) {
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /websites/php/version [post]
// @x-panel-log {"bodyKeys":["websiteId"],"paramKeys":[],"BeforeFunctions":[{"input_column":"id","input_value":"websiteId","isList":false,"db":"websites","output_column":"primary_domain","output_value":"domain"}],"formatZH":"php 版本变更 [domain]","formatEN":"php version update [domain]"}
// @x-panel-log {"bodyKeys":["websiteID"],"paramKeys":[],"BeforeFunctions":[{"input_column":"id","input_value":"websiteID","isList":false,"db":"websites","output_column":"primary_domain","output_value":"domain"}],"formatZH":"php 版本变更 [domain]","formatEN":"php version update [domain]"}
func (b *BaseApi) ChangePHPVersion(c *gin.Context) {
var req request.WebsitePHPVersionReq
if err := helper.CheckBindAndValidate(&req, c); err != nil {
+64
View File
@@ -1,13 +1,16 @@
package v2
import (
"io"
"net/http"
"net/url"
"strconv"
"strings"
"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/buserr"
"github.com/gin-gonic/gin"
)
@@ -176,3 +179,64 @@ func (b *BaseApi) DownloadCAFile(c *gin.Context) {
c.Header("Content-Disposition", "attachment; filename*=utf-8''"+url.PathEscape(info.Name()))
http.ServeContent(c.Writer, c.Request, info.Name(), info.ModTime(), file)
}
// @Tags Website CA
// @Summary Upload website CA archive
// @Accept multipart/form-data
// @Param file formData file true "ZIP containing ca.crt and ca.key"
// @Success 200
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /websites/ca/upload [post]
func (b *BaseApi) UploadWebsiteCA(c *gin.Context) {
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 11<<20)
if err := c.Request.ParseMultipartForm(11 << 20); err != nil {
helper.InternalServer(c, buserr.New("ErrCAUpload"))
return
}
if c.Request.MultipartForm != nil {
defer c.Request.MultipartForm.RemoveAll()
}
file, err := c.FormFile("file")
if err != nil || file.Size > 10<<20 || !strings.HasSuffix(strings.ToLower(file.Filename), ".zip") {
helper.InternalServer(c, buserr.New("ErrCAUpload"))
return
}
stream, err := file.Open()
if err != nil {
helper.InternalServer(c, err)
return
}
defer stream.Close()
data, err := io.ReadAll(io.LimitReader(stream, (10<<20)+1))
if err != nil {
helper.InternalServer(c, err)
return
}
if err = websiteCAService.Upload(file.Filename[:len(file.Filename)-len(".zip")], data); err != nil {
helper.InternalServer(c, err)
return
}
helper.Success(c)
}
// @Tags Website CA
// @Summary Import website CA from PEM or server files
// @Accept json
// @Param request body request.WebsiteCAImport true "request"
// @Success 200
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /websites/ca/import [post]
func (b *BaseApi) ImportWebsiteCA(c *gin.Context) {
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 3<<20)
var req request.WebsiteCAImport
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
if err := websiteCAService.Import(req); err != nil {
helper.InternalServer(c, err)
return
}
helper.Success(c)
}
+22
View File
@@ -386,6 +386,7 @@ type AgentAccountProviderCountReq struct {
}
type AgentAccountInfo struct {
AppInstallID uint `json:"appInstallId"`
ID uint `json:"id"`
MasterAccountID uint `json:"masterAccountId"`
Provider string `json:"provider"`
@@ -432,7 +433,23 @@ type AgentFeishuConfigReq struct {
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 {
GroupSessionScope string `json:"groupSessionScope" validate:"omitempty,oneof=group group_sender group_topic group_topic_sender"`
AgentID uint `json:"agentId" validate:"required"`
Enabled bool `json:"enabled"`
ThreadSession bool `json:"threadSession"`
@@ -447,6 +464,11 @@ type AgentFeishuConfigUpdateReq struct {
}
type AgentFeishuConfig struct {
BuiltinManaged bool `json:"builtinManaged"`
LegacyConfigured bool `json:"legacyConfigured"`
GroupSessionScope string `json:"groupSessionScope"`
BuiltinAvailable bool `json:"builtinAvailable"`
Legacy bool `json:"legacy"`
Enabled bool `json:"enabled"`
ThreadSession bool `json:"threadSession"`
ReplyMode string `json:"replyMode"`
+6 -5
View File
@@ -14,16 +14,17 @@ type OllamaModelInfo struct {
CreatedAt time.Time `json:"createdAt"`
}
type OllamaModelDropList struct {
ID uint `json:"id"`
Name string `json:"name"`
}
type OllamaModelName struct {
Name string `json:"name"`
TaskID string `json:"taskID"`
}
type OllamaModelAccountSync struct {
ID uint `json:"id" validate:"required"`
ModelAccountBaseURLType string `json:"modelAccountBaseURLType" validate:"required,oneof=container localhost systemIP custom"`
ModelAccountBaseURL string `json:"modelAccountBaseURL"`
}
type OllamaBindDomain struct {
Domain string `json:"domain" validate:"required"`
AppInstallID uint `json:"appInstallID" validate:"required"`
+2
View File
@@ -43,6 +43,8 @@ type AppOssConfig struct {
}
type AppVersion struct {
FeishuLegacy bool `json:"feishuLegacy,omitempty"`
FeishuConfigured bool `json:"feishuConfigured,omitempty"`
Version string `json:"version"`
DetailId uint `json:"detailId"`
DockerCompose string `json:"dockerCompose"`
+4
View File
@@ -21,6 +21,9 @@ type InspectReq struct {
}
type ContainerInfo struct {
LatestVersion string `json:"latestVersion"`
TargetImage string `json:"targetImage"`
HasUpdate bool `json:"hasUpdate"`
ContainerID string `json:"containerID"`
Name string `json:"name"`
ImageId string `json:"imageID"`
@@ -172,6 +175,7 @@ type ContainerItemStats struct {
}
type ContainerListStats struct {
ContainerID string `json:"containerID"`
ContainerName string `json:"containerName"`
CPUTotalUsage uint64 `json:"cpuTotalUsage"`
SystemUsage uint64 `json:"systemUsage"`
+11
View File
@@ -23,6 +23,12 @@ type AppInstallCreate struct {
Services map[string]string `json:"services"`
TaskID string `json:"taskID"`
SyncModelAccount *bool `json:"syncModelAccount"`
ModelAccountBaseURLType string `json:"modelAccountBaseURLType" validate:"omitempty,oneof=container localhost systemIP custom"`
ModelAccountBaseURL string `json:"modelAccountBaseURL"`
MCPPreserveWebsiteDir bool `json:"-"`
AppContainerConfig
NodePushConfig
}
@@ -83,6 +89,7 @@ type AppBackupDelete struct {
}
type AppInstalledOperate struct {
FeishuTransition *dto.AgentFeishuTransition `json:"feishuTransition,omitempty"`
InstallId uint `json:"installId" validate:"required"`
BackupId uint `json:"backupId"`
DetailId uint `json:"detailId"`
@@ -101,6 +108,7 @@ type AppInstalledOperate struct {
}
type AppInstallUpgrade struct {
FeishuTransition *dto.AgentFeishuTransition `json:"feishuTransition,omitempty"`
InstallID uint `json:"installId"`
DetailID uint `json:"detailId"`
Backup bool `json:"backup"`
@@ -122,6 +130,9 @@ type AppInstallDelete struct {
}
type AppInstalledUpdate struct {
SyncModelAccount *bool `json:"syncModelAccount"`
ModelAccountBaseURLType string `json:"modelAccountBaseURLType" validate:"omitempty,oneof=container localhost systemIP custom"`
ModelAccountBaseURL string `json:"modelAccountBaseURL"`
InstallId uint `json:"installId" validate:"required"`
Params map[string]interface{} `json:"params" validate:"required"`
TaskID string `json:"-"`
+9
View File
@@ -182,3 +182,12 @@ type WebsiteSSLFileUpload struct {
PushNode bool `json:"pushNode"`
Nodes string `json:"nodes"`
}
type WebsiteCAImport struct {
Name string `json:"name" validate:"required,max=128"`
Type string `json:"type" validate:"required,oneof=paste local"`
Certificate string `json:"certificate" validate:"required_if=Type paste"`
PrivateKey string `json:"privateKey" validate:"required_if=Type paste"`
CertificatePath string `json:"certificatePath" validate:"required_if=Type local"`
PrivateKeyPath string `json:"privateKeyPath" validate:"required_if=Type local"`
}
+8
View File
@@ -50,6 +50,8 @@ type TagDTO struct {
}
type AppInstalledCheck struct {
ModelAccountBaseURLType string `json:"modelAccountBaseURLType"`
ModelAccountBaseURL string `json:"modelAccountBaseURL"`
IsExist bool `json:"isExist"`
Name string `json:"name"`
App string `json:"app"`
@@ -184,6 +186,12 @@ type AppParam struct {
}
type AppConfig struct {
AppKey string `json:"appKey"`
Name string `json:"name"`
HttpPort int `json:"httpPort"`
SyncModelAccount bool `json:"syncModelAccount"`
ModelAccountBaseURLType string `json:"modelAccountBaseURLType"`
ModelAccountBaseURL string `json:"modelAccountBaseURL"`
Params []AppParam `json:"params"`
RawCompose string `json:"rawCompose"`
request.AppContainerConfig
+1
View File
@@ -2,6 +2,7 @@ package model
type AgentAccount struct {
BaseModel
AppInstallID uint `json:"appInstallId"`
Provider string `json:"provider"`
Name string `json:"name"`
APIKey string `json:"apiKey"`
+4
View File
@@ -28,6 +28,10 @@ type AppInstall struct {
Favorite bool `json:"favorite"`
SortOrder int `json:"sortOrder" gorm:"default:0"`
SyncModelAccount bool `json:"syncModelAccount"`
ModelAccountBaseURLType string `json:"modelAccountBaseURLType"`
ModelAccountBaseURL string `json:"modelAccountBaseURL"`
App App `json:"app" gorm:"-:migration"`
}
@@ -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" }
+21
View File
@@ -4,6 +4,7 @@ import (
"strings"
"github.com/1Panel-dev/1Panel/agent/app/model"
"github.com/1Panel-dev/1Panel/agent/global"
"gorm.io/gorm"
)
@@ -18,6 +19,7 @@ type IAgentAccountRepo interface {
DeleteByID(id uint) error
List(opts ...DBOption) ([]model.AgentAccount, error)
CountTextByProviders(providers []string) (map[string]int64, error)
SaveLocal(account *model.AgentAccount, models []model.AgentAccountModel) error
}
func NewIAgentAccountRepo() IAgentAccountRepo {
@@ -97,6 +99,25 @@ func (a AgentAccountRepo) CountTextByProviders(providers []string) (map[string]i
return counts, nil
}
func (a AgentAccountRepo) SaveLocal(account *model.AgentAccount, models []model.AgentAccountModel) error {
return global.DB.Transaction(func(tx *gorm.DB) error {
if err := tx.Save(account).Error; err != nil {
return err
}
if err := tx.Where("account_id = ?", account.ID).Delete(&model.AgentAccountModel{}).Error; err != nil {
return err
}
if len(models) == 0 {
return nil
}
for i := range models {
models[i].AccountID = account.ID
models[i].SortOrder = i + 1
}
return tx.Create(&models).Error
})
}
func normalizeProviders(providers []string) []string {
seen := make(map[string]struct{}, len(providers))
result := make([]string, 0, len(providers))
+7
View File
@@ -1,6 +1,8 @@
package repo
import (
"context"
"github.com/1Panel-dev/1Panel/agent/app/model"
"github.com/1Panel-dev/1Panel/agent/global"
)
@@ -14,6 +16,7 @@ type IAiRepo interface {
Create(cronjob *model.OllamaModel) error
Update(id uint, vars map[string]interface{}) error
Delete(opts ...DBOption) error
DeleteAll(ctx context.Context) error
}
func NewIAiRepo() IAiRepo {
@@ -67,3 +70,7 @@ func (u *AiRepo) Delete(opts ...DBOption) 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
}
+48 -5
View File
@@ -88,6 +88,7 @@ type IAgentService interface {
GetFeishuConfig(req dto.AgentFeishuConfigReq) (*dto.AgentFeishuConfig, error)
UpdateFeishuConfig(req dto.AgentFeishuConfigUpdateReq) error
SwitchFeishuPlugin(req dto.AgentFeishuSwitchReq) error
GetTelegramConfig(req dto.AgentTelegramConfigReq) (*dto.AgentTelegramConfig, error)
UpdateTelegramConfig(req dto.AgentTelegramConfigUpdateReq) error
GetDiscordConfig(req dto.AgentIDReq) (*dto.AgentDiscordConfig, error)
@@ -1055,7 +1056,7 @@ func (a AgentService) CreateAccount(req dto.AgentAccountCreateReq) error {
return err
}
validateAvailability := req.ValidateAvailability == nil || *req.ValidateAvailability
resolvedInput, err := resolveAgentAccountInput(provider, req.APIType, req.AuthMode, req.APIKey, req.BaseURL, verifyModel, validateAvailability)
resolvedInput, err := resolveAgentAccountInput(provider, req.APIType, req.AuthMode, req.APIKey, req.BaseURL, verifyModel, validateAvailability, false)
if err != nil {
return err
}
@@ -1108,12 +1109,15 @@ func (a AgentService) UpdateAccount(req dto.AgentAccountUpdateReq) error {
if strings.TrimSpace(requestedVerifyModel) == "" {
requestedVerifyModel = account.VerifyModel
}
verifyModel, err := resolveAgentAccountVerifyModel(provider, requestedVerifyModel, models)
verifyModel := ""
if account.AppInstallID == 0 || len(models) > 0 {
verifyModel, err = resolveAgentAccountVerifyModel(provider, requestedVerifyModel, models)
if err != nil {
return err
}
}
validateAvailability := req.ValidateAvailability == nil || *req.ValidateAvailability
resolvedInput, err := resolveAgentAccountInput(provider, req.APIType, req.AuthMode, req.APIKey, req.BaseURL, verifyModel, validateAvailability)
resolvedInput, err := resolveAgentAccountInput(provider, req.APIType, req.AuthMode, req.APIKey, req.BaseURL, verifyModel, validateAvailability, account.AppInstallID != 0 && len(models) == 0)
if err != nil {
return err
}
@@ -1127,7 +1131,7 @@ func (a AgentService) UpdateAccount(req dto.AgentAccountUpdateReq) error {
account.Remark = req.Remark
account.Verified = true
if err := global.DB.Save(account).Error; err != nil {
if err := agentAccountRepo.Save(account); err != nil {
return err
}
terminalai.InvalidateTerminalRuntimeCache()
@@ -1158,13 +1162,34 @@ func (a AgentService) PageAccounts(req dto.AgentAccountSearch) (int64, []dto.Age
if err != nil {
return 0, nil, err
}
installIDs := make([]uint, 0, len(list))
for _, account := range list {
if account.AppInstallID != 0 {
installIDs = append(installIDs, account.AppInstallID)
}
}
localInstalls := make(map[uint]bool, len(installIDs))
if len(installIDs) > 0 {
installs, err := appInstallRepo.ListBy(context.Background(), repo.WithByIDs(installIDs))
if err != nil {
return 0, nil, err
}
for _, install := range installs {
localInstalls[install.ID] = true
}
}
items := make([]dto.AgentAccountInfo, 0, len(list))
for _, item := range list {
apiKey := ""
if item.RememberAPIKey {
apiKey = item.APIKey
}
appInstallID := uint(0)
if localInstalls[item.AppInstallID] {
appInstallID = item.AppInstallID
}
items = append(items, dto.AgentAccountInfo{
AppInstallID: appInstallID,
ID: item.ID,
MasterAccountID: item.MasterAccountID,
Provider: item.Provider,
@@ -1362,13 +1387,27 @@ func (a AgentService) SyncAgentsByAccount(account *model.AgentAccount) error {
}
func (a AgentService) VerifyAccount(req dto.AgentAccountVerifyReq) error {
_, err := resolveAgentAccountInput(req.Provider, req.APIType, req.AuthMode, req.APIKey, req.BaseURL, req.Model, true)
_, err := resolveAgentAccountInput(req.Provider, req.APIType, req.AuthMode, req.APIKey, req.BaseURL, req.Model, true, false)
return err
}
func (a AgentService) DeleteAccount(req dto.AgentAccountDeleteReq) error {
agentAccountMutationMu.Lock()
defer agentAccountMutationMu.Unlock()
account, err := agentAccountRepo.GetFirst(repo.WithByID(req.ID))
if err != nil {
return err
}
if account.AppInstallID != 0 && account.Provider != "ollama" {
_, err := appInstallRepo.GetFirst(repo.WithByID(account.AppInstallID))
if err == nil {
return buserr.WithDetail("ErrInvalidParams", "Delete the local inference engine instead", nil)
}
if !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
}
if err := global.DB.Transaction(func(tx *gorm.DB) error {
var agentCount int64
if err := tx.Model(&model.Agent{}).Where("account_id = ?", req.ID).Count(&agentCount).Error; err != nil {
@@ -1553,6 +1592,10 @@ func (a AgentService) UpdateConfigFile(req dto.AgentConfigFileUpdateReq) error {
if err != nil {
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 {
return err
}
+38 -3
View File
@@ -45,9 +45,29 @@ func (a AgentService) GetFeishuConfig(req dto.AgentFeishuConfigReq) (*dto.AgentF
if err != nil {
return nil, err
}
if agent.AgentType != constant.AppOpenclaw || !isOpenclawBundledFeishuVersion(install.Version) {
result := extractFeishuConfig(conf)
installed, _ := checkPluginInstalled(install.GetPath(), "feishu")
result.Installed = installed
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)
if !legacy {
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
}
@@ -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 {
config := dto.AgentFeishuConfig{
Enabled: req.Enabled,
@@ -358,6 +381,9 @@ func (a AgentService) InstallPlugin(req dto.AgentPluginInstallReq) error {
if err != nil {
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 {
return err
}
@@ -403,6 +429,9 @@ func (a AgentService) UpgradePlugin(req dto.AgentPluginUpgradeReq) error {
if err != nil {
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 {
return err
}
@@ -443,6 +472,9 @@ func (a AgentService) UninstallPlugin(req dto.AgentPluginUninstallReq) error {
if err != nil {
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 {
return err
}
@@ -538,10 +570,13 @@ func (a AgentService) DeleteChannelConfig(req dto.AgentChannelDeleteReq) 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 {
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)
if err != nil {
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
}
+5 -1
View File
@@ -8,11 +8,15 @@ import (
)
const hermesWeixinLoginScript = `import asyncio
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
creds = asyncio.run(qr_login('/opt/data'))
print('RESULT =', creds)
if not creds:
raise SystemExit(1)
+202
View File
@@ -0,0 +1,202 @@
package service
import (
"errors"
"fmt"
"strings"
"github.com/1Panel-dev/1Panel/agent/app/dto"
"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/global"
terminalai "github.com/1Panel-dev/1Panel/agent/utils/terminal/ai"
"gorm.io/gorm"
)
func SyncLocalModelAccount(install model.AppInstall, accountID uint, baseURL string, models []dto.AgentAccountModel, updateBaseURL, createIfMissing bool) (*model.AgentAccount, error) {
if install.ID == 0 || (install.App.Key != "ollama" && install.App.Key != "vllm") {
return nil, fmt.Errorf("invalid local model installation")
}
agentAccountMutationMu.Lock()
defer agentAccountMutationMu.Unlock()
account, err := agentAccountRepo.GetFirst(repo.WithByAppInstallID(install.ID))
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, err
}
if errors.Is(err, gorm.ErrRecordNotFound) && accountID != 0 {
account, err = agentAccountRepo.GetFirst(repo.WithByID(accountID), repo.WithByProvider(install.App.Key))
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, err
}
if err == nil && account.AppInstallID != 0 && account.AppInstallID != install.ID {
return nil, fmt.Errorf("model account belongs to another installation")
}
}
if errors.Is(err, gorm.ErrRecordNotFound) {
if !createIfMissing {
return nil, nil
}
name := install.Name
for suffix := 1; ; suffix++ {
existing, err := agentAccountRepo.List(repo.WithByProvider(install.App.Key), repo.WithByName(name))
if err != nil {
return nil, err
}
if len(existing) == 0 {
break
}
name = fmt.Sprintf("%s-%d", install.Name, suffix)
}
apiType, apiKey := "openai-completions", "vllm-local"
if install.App.Key == "ollama" {
apiType, apiKey = "openai-responses", "ollama-local"
}
account = &model.AgentAccount{Name: name, Provider: install.App.Key, BaseURL: baseURL, APIType: apiType, APIKey: apiKey, RememberAPIKey: true, Verified: true}
}
accountChanged := account.ID == 0 || account.AppInstallID != install.ID || (updateBaseURL && account.BaseURL != baseURL)
account.AppInstallID = install.ID
if updateBaseURL {
account.BaseURL = baseURL
}
rows := make([]model.AgentAccountModel, 0, len(models))
verifyModelExists := account.VerifyModel == ""
for _, item := range models {
rows = append(rows, model.AgentAccountModel{Model: item.ID, Name: item.Name})
if item.ID == account.VerifyModel {
verifyModelExists = true
}
}
if !verifyModelExists {
account.VerifyModel = ""
accountChanged = true
}
var previous []model.AgentAccountModel
if account.ID != 0 {
previous, err = agentAccountModelRepo.List(repo.WithByAccountID(account.ID), repo.WithOrderAsc("sort_order"), repo.WithOrderAsc("id"))
if err != nil {
return nil, err
}
}
modelsChanged := len(previous) != len(rows)
if !modelsChanged {
for i := range rows {
if previous[i].Model != rows[i].Model || previous[i].Name != rows[i].Name {
modelsChanged = true
break
}
}
}
if modelsChanged {
err = agentAccountRepo.SaveLocal(account, rows)
} else if accountChanged {
err = agentAccountRepo.Save(account)
} else {
return account, nil
}
if err != nil {
return nil, err
}
terminalai.InvalidateTerminalRuntimeCache()
terminalai.InvalidateFileAIRuntimeCache()
return account, nil
}
func SyncOllamaModelAccount(createIfMissing bool) error {
app, err := appRepo.GetFirst(appRepo.WithKey("ollama"))
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil
}
if err != nil {
return err
}
install, err := appInstallRepo.GetFirst(appInstallRepo.WithAppId(app.ID))
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil
}
if err != nil {
return err
}
if !install.SyncModelAccount {
return nil
}
return syncOllamaModelAccount(install, fmt.Sprintf("http://%s:11434/v1", install.ContainerName), false, createIfMissing)
}
func syncOllamaModelAccount(install model.AppInstall, baseURL string, updateBaseURL, createIfMissing bool) error {
models, err := aiRepo.List(repo.WithByStatus(constant.StatusSuccess), repo.WithOrderAsc("name"))
if err != nil {
return err
}
items := make([]dto.AgentAccountModel, 0, len(models))
for _, item := range models {
items = append(items, dto.AgentAccountModel{ID: item.Name, Name: item.Name})
}
_, err = SyncLocalModelAccount(install, 0, baseURL, items, updateBaseURL, createIfMissing)
return err
}
func loadOllamaModelAccountAddress(install model.AppInstall) (string, string, error) {
addressType := install.ModelAccountBaseURLType
if addressType == "" {
addressType = "container"
}
account, err := agentAccountRepo.GetFirst(repo.WithByAppInstallID(install.ID), repo.WithByProvider("ollama"))
if errors.Is(err, gorm.ErrRecordNotFound) {
return addressType, install.ModelAccountBaseURL, nil
}
if err != nil {
return "", "", err
}
if addressType != "custom" {
expectedURL, err := ResolveLocalModelAccountBaseURL(addressType, "", install.ContainerName, "11434", install.HttpPort)
if err != nil {
return "", "", err
}
if strings.TrimRight(account.BaseURL, "/") != expectedURL {
addressType = "custom"
}
}
return addressType, account.BaseURL, nil
}
func CleanupLocalModelAccount(installID uint) {
if installID == 0 {
return
}
accounts, err := agentAccountRepo.List(repo.WithByAppInstallID(installID))
if err != nil {
global.LOG.Errorf("load local model accounts: %v", err)
return
}
for _, account := range accounts {
if err := (AgentService{}).DeleteAccount(dto.AgentAccountDeleteReq{ID: account.ID}); err != nil {
global.LOG.Warnf("keep local model account %d after uninstall: %v", account.ID, err)
}
}
}
func ResolveLocalModelAccountBaseURL(addressType, customURL, containerName, containerPort string, hostPort int) (string, error) {
switch addressType {
case "custom":
baseURL := strings.TrimRight(strings.TrimSpace(customURL), "/")
if !strings.HasPrefix(baseURL, "http://") && !strings.HasPrefix(baseURL, "https://") {
return "", fmt.Errorf("model account base URL must start with http:// or https://")
}
return baseURL, nil
case "localhost":
return fmt.Sprintf("http://127.0.0.1:%d/v1", hostPort), nil
case "systemIP":
systemIP, err := settingRepo.GetValueByKey("SystemIP")
if err != nil {
return "", err
}
systemIP = strings.TrimSpace(systemIP)
if systemIP == "" {
systemIP = "127.0.0.1"
}
return fmt.Sprintf("http://%s:%d/v1", systemIP, hostPort), nil
default:
return fmt.Sprintf("http://%s:%s/v1", containerName, containerPort), nil
}
}
+6
View File
@@ -107,6 +107,9 @@ func (a AgentService) InstallMarketPlugin(req dto.AgentPluginMarketInstallReq) e
if err != nil {
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 {
return err
}
@@ -132,6 +135,9 @@ func (a AgentService) OperatePlugin(req dto.AgentPluginOperateReq) error {
if err != nil {
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 {
return err
}
+6 -2
View File
@@ -98,7 +98,7 @@ func ensureContainerRunning(containerName string) error {
return nil
}
func resolveAgentAccountInput(provider, apiType, authMode, apiKey, baseURL, modelID string, validateAvailability bool) (resolvedAgentAccountInput, error) {
func resolveAgentAccountInput(provider, apiType, authMode, apiKey, baseURL, modelID string, validateAvailability, allowEmptyModel bool) (resolvedAgentAccountInput, error) {
resolvedAPIKey := strings.TrimSpace(apiKey)
resolvedAPIType := strings.TrimSpace(apiType)
resolvedAuthMode, err := providercatalog.ResolveAuthMode(provider, resolvedAPIType, authMode)
@@ -113,7 +113,7 @@ func resolveAgentAccountInput(provider, apiType, authMode, apiKey, baseURL, mode
return resolvedAgentAccountInput{}, err
}
modelID = strings.TrimSpace(modelID)
if modelID == "" {
if modelID == "" && !allowEmptyModel {
return resolvedAgentAccountInput{}, buserr.New("ErrAgentAccountModelsRequired")
}
imageAPI := providercatalog.IsImageAPIType(resolvedAPIType)
@@ -148,6 +148,10 @@ func readOpenclawConfig(configPath string) (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)
ensureOpenclawUpdateDefaults(conf)
payload, err := json.MarshalIndent(conf, "", " ")
+107 -47
View File
@@ -8,6 +8,7 @@ import (
"os/exec"
"path"
"strings"
"sync"
"syscall"
"time"
@@ -28,13 +29,15 @@ import (
type AIToolService struct{}
var ollamaModelMu sync.Mutex
type IAIToolService interface {
Search(search dto.SearchWithPage) (int64, []dto.OllamaModelInfo, error)
Create(req dto.OllamaModelName) error
Close(name string) error
Recreate(req dto.OllamaModelName) error
Delete(req dto.ForceDelete) error
Sync() ([]dto.OllamaModelDropList, error)
SyncAccount(req dto.OllamaModelAccountSync) error
LoadDetail(name string) (string, error)
BindDomain(req dto.OllamaBindDomain) error
GetBindDomain(req dto.OllamaBindDomainReq) (*dto.OllamaBindDomainRes, error)
@@ -46,6 +49,7 @@ func NewIAIToolService() IAIToolService {
}
func (u *AIToolService) Search(req dto.SearchWithPage) (int64, []dto.OllamaModelInfo, error) {
syncErr := syncOllamaModels()
var options []repo.DBOption
if len(req.Info) != 0 {
options = append(options, repo.WithByLikeName(req.Info))
@@ -60,6 +64,11 @@ func (u *AIToolService) Search(req dto.SearchWithPage) (int64, []dto.OllamaModel
if err := copier.Copy(&item, &itemModel); err != 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))
if len(taskModel.ID) != 0 {
item.LogFileExist = true
@@ -85,6 +94,8 @@ func (u *AIToolService) LoadDetail(name string) (string, error) {
}
func (u *AIToolService) Create(req dto.OllamaModelName) error {
ollamaModelMu.Lock()
defer ollamaModelMu.Unlock()
if cmd.CheckIllegal(req.Name) {
return buserr.New("ErrCmdIllegal")
}
@@ -125,6 +136,9 @@ func (u *AIToolService) Create(req dto.OllamaModelName) error {
if err := taskItem.Execute(); err != nil {
_ = aiRepo.Update(info.ID, map[string]interface{}{"status": constant.StatusFailed, "message": err.Error()})
}
if err := SyncOllamaModelAccount(false); err != nil {
global.LOG.Errorf("sync Ollama model account after pull: %v", err)
}
}()
return nil
}
@@ -144,6 +158,8 @@ func (u *AIToolService) Close(name string) error {
}
func (u *AIToolService) Recreate(req dto.OllamaModelName) error {
ollamaModelMu.Lock()
defer ollamaModelMu.Unlock()
if cmd.CheckIllegal(req.Name) {
return buserr.New("ErrCmdIllegal")
}
@@ -179,11 +195,16 @@ func (u *AIToolService) Recreate(req dto.OllamaModelName) error {
if err := taskItem.Execute(); err != nil {
_ = aiRepo.Update(modelInfo.ID, map[string]interface{}{"status": constant.StatusFailed, "message": err.Error()})
}
if err := SyncOllamaModelAccount(false); err != nil {
global.LOG.Errorf("sync Ollama model account after pull: %v", err)
}
}()
return nil
}
func (u *AIToolService) Delete(req dto.ForceDelete) error {
ollamaModelMu.Lock()
defer ollamaModelMu.Unlock()
ollamaList, _ := aiRepo.List(repo.WithByIDs(req.IDs))
if len(ollamaList) == 0 {
return buserr.New("ErrRecordNotFound")
@@ -202,55 +223,28 @@ func (u *AIToolService) Delete(req dto.ForceDelete) error {
logItem := path.Join(global.Dir.DataDir, "log", "AITools", item.Name)
_ = os.Remove(logItem)
}
return nil
return SyncOllamaModelAccount(false)
}
func (u *AIToolService) Sync() ([]dto.OllamaModelDropList, error) {
containerName, err := LoadContainerName()
func (u *AIToolService) SyncAccount(req dto.OllamaModelAccountSync) error {
install, err := appInstallRepo.GetFirst(repo.WithByID(req.ID))
if err != nil {
return nil, err
return err
}
stdout, err := cmd.RunDockerExecWithStdout(20*time.Second, containerName, "ollama", "list")
if install.App.Key != "ollama" {
return buserr.New("ErrInvalidParams")
}
baseURL, err := ResolveLocalModelAccountBaseURL(req.ModelAccountBaseURLType, req.ModelAccountBaseURL, install.ContainerName, "11434", install.HttpPort)
if err != nil {
return nil, err
return err
}
var list []model.OllamaModel
lines := strings.Split(stdout, "\n")
for _, line := range lines {
parts := strings.Fields(line)
if len(parts) < 5 {
continue
if err := syncOllamaModelAccount(install, baseURL, true, true); err != nil {
return err
}
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, nil
return appInstallRepo.BatchUpdateBy(map[string]interface{}{
"model_account_base_url_type": req.ModelAccountBaseURLType,
"model_account_base_url": baseURL,
}, repo.WithByID(install.ID))
}
func (u *AIToolService) BindDomain(req dto.OllamaBindDomain) error {
@@ -370,6 +364,72 @@ func (u *AIToolService) UpdateBindDomain(req dto.OllamaBindDomain) error {
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) {
ollamaBaseInfo, err := appInstallRepo.LoadBaseInfo("ollama", "")
if err != nil {
@@ -387,12 +447,12 @@ func loadModelSize(name string, containerName string) (string, error) {
return "", err
}
lines := strings.Split(stdout, "\n")
for _, line := range lines {
if !strings.Contains(line, name) {
continue
if !strings.Contains(path.Base(name), ":") {
name += ":latest"
}
for _, line := range lines {
parts := strings.Fields(line)
if len(parts) < 5 {
if len(parts) < 5 || parts[0] != name {
continue
}
return parts[2] + " " + parts[3], nil
+20
View File
@@ -499,6 +499,22 @@ func (a AppService) installWithHooks(req request.AppInstallCreate, executeScript
req.Params[constant.ContainerName] = containerName
appInstall.ContainerName = containerName
modelAccountBaseURL := ""
if app.Key == "ollama" {
appInstall.SyncModelAccount = req.SyncModelAccount == nil || *req.SyncModelAccount
appInstall.ModelAccountBaseURLType = req.ModelAccountBaseURLType
if appInstall.ModelAccountBaseURLType == "" {
appInstall.ModelAccountBaseURLType = "container"
}
appInstall.ModelAccountBaseURL = req.ModelAccountBaseURL
}
if app.Key == "ollama" && appInstall.SyncModelAccount {
modelAccountBaseURL, err = ResolveLocalModelAccountBaseURL(req.ModelAccountBaseURLType, req.ModelAccountBaseURL, containerName, "11434", httpPort)
if err != nil {
return
}
}
index := 0
serviceName := ""
for k := range servicesMap {
@@ -609,6 +625,10 @@ func (a AppService) installWithHooks(req request.AppInstallCreate, executeScript
return err
}
updateToolApp(appInstall)
if app.Key == "ollama" && appInstall.SyncModelAccount {
_, err := SyncLocalModelAccount(*appInstall, 0, modelAccountBaseURL, nil, false, true)
return err
}
return nil
}
+65
View File
@@ -159,6 +159,14 @@ func (a *AppInstallService) CheckExist(req request.AppInstalledInfo) (*response.
}
res.ContainerName = appInstall.ContainerName
res.ModelAccountBaseURLType = appInstall.ModelAccountBaseURLType
res.ModelAccountBaseURL = appInstall.ModelAccountBaseURL
if app.Key == "ollama" {
res.ModelAccountBaseURLType, res.ModelAccountBaseURL, err = loadOllamaModelAccountAddress(appInstall)
if err != nil {
return nil, err
}
}
res.Name = appInstall.Name
res.Version = appInstall.Version
res.CreatedAt = appInstall.CreatedAt
@@ -297,6 +305,7 @@ func (a *AppInstallService) Operate(req request.AppInstalledOperate) error {
return syncAppInstallStatus(&install, true)
case constant.Upgrade:
upgradeReq := request.AppInstallUpgrade{
FeishuTransition: req.FeishuTransition,
InstallID: install.ID,
DetailID: req.DetailId,
Backup: req.Backup,
@@ -438,6 +447,32 @@ func (a *AppInstallService) Update(req request.AppInstalledUpdate) error {
}
}
modelAccountBaseURL := ""
if installed.App.Key == "ollama" {
if req.SyncModelAccount != nil {
installed.SyncModelAccount = *req.SyncModelAccount
}
if req.ModelAccountBaseURLType != "" {
installed.ModelAccountBaseURLType = req.ModelAccountBaseURLType
installed.ModelAccountBaseURL = req.ModelAccountBaseURL
} else if installed.SyncModelAccount {
installed.ModelAccountBaseURLType, installed.ModelAccountBaseURL, err = loadOllamaModelAccountAddress(oldInstalled)
if err != nil {
return err
}
}
if installed.SyncModelAccount {
containerName := installed.ContainerName
if req.Advanced && req.ContainerName != "" {
containerName = req.ContainerName
}
modelAccountBaseURL, err = ResolveLocalModelAccountBaseURL(installed.ModelAccountBaseURLType, installed.ModelAccountBaseURL, containerName, "11434", installed.HttpPort)
if err != nil {
return err
}
}
}
backupDockerCompose := installed.DockerCompose
if req.Advanced {
composeMap := make(map[string]interface{})
@@ -563,6 +598,9 @@ func (a *AppInstallService) Update(req request.AppInstalledUpdate) error {
if proxyChanged && website.ID != 0 && currentProxyErr != nil {
global.LOG.Error(buserr.WithErr("ErrUpdateBuWebsite", currentProxyErr).Error())
}
if installed.App.Key == "ollama" && installed.SyncModelAccount {
return syncOllamaModelAccount(installed, modelAccountBaseURL, true, true)
}
return nil
}
@@ -679,6 +717,7 @@ func (a *AppInstallService) GetUpdateVersions(req request.AppUpdateVersion) ([]d
if err != nil {
return versions, err
}
var feishuLegacy, feishuConfigured, feishuChecked bool
details, err := appDetailRepo.GetBy(appDetailRepo.WithAppId(app.ID))
if err != nil {
return versions, err
@@ -703,6 +742,18 @@ func (a *AppInstallService) GetUpdateVersions(req request.AppUpdateVersion) ([]d
canUpgrade = isVllmUpgradeCandidate(install.Version, detail.Version, currentImage)
}
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
if req.UpdateVersion != "" && req.UpdateVersion == detail.Version && detail.DockerCompose == "" && !app.IsLocalApp() {
filename := filepath.Base(detail.DownloadUrl)
@@ -737,6 +788,8 @@ func (a *AppInstallService) GetUpdateVersions(req request.AppUpdateVersion) ([]d
}
}
versions = append(versions, dto.AppVersion{
FeishuLegacy: feishuTarget && feishuLegacy,
FeishuConfigured: feishuTarget && feishuConfigured,
Version: detail.Version,
DetailId: detail.ID,
DockerCompose: newCompose,
@@ -934,6 +987,18 @@ func (a *AppInstallService) GetParams(id uint) (*response.AppConfig, error) {
config := getAppCommonConfig(envs)
config.DockerCompose = install.DockerCompose
res.Params = params
res.AppKey = install.App.Key
res.Name = install.Name
res.HttpPort = install.HttpPort
res.SyncModelAccount = install.SyncModelAccount
res.ModelAccountBaseURLType = install.ModelAccountBaseURLType
res.ModelAccountBaseURL = install.ModelAccountBaseURL
if install.App.Key == "ollama" {
res.ModelAccountBaseURLType, res.ModelAccountBaseURL, err = loadOllamaModelAccountAddress(install)
if err != nil {
return nil, err
}
}
if config.ContainerName == "" {
config.ContainerName = install.ContainerName
}
+53
View File
@@ -0,0 +1,53 @@
package service
import (
"errors"
"os"
"path"
"github.com/1Panel-dev/1Panel/agent/app/dto/request"
"github.com/1Panel-dev/1Panel/agent/app/model"
"github.com/1Panel-dev/1Panel/agent/constant"
"github.com/1Panel-dev/1Panel/agent/global"
"gorm.io/gorm"
)
func prepareMCPWebsiteDirectory(req request.AppInstallCreate) error {
changed := errors.New("MCP website directory changed; inspect the installation before retrying")
directory, ok := req.Params["WEBSITE_DIR"].(string)
if !ok || directory == "" || global.DB == nil {
return changed
}
return global.DB.Transaction(func(tx *gorm.DB) error {
var setting model.Setting
err := tx.Where("key = ?", "WEBSITE_DIR").First(&setting).Error
missing := errors.Is(err, gorm.ErrRecordNotFound)
if err != nil && !missing {
return err
}
current := setting.Value
if current == "" {
current = path.Join(global.Dir.BaseDir, "1panel", "www")
}
if directory != current {
return changed
}
if missing {
if err := tx.Create(&model.Setting{Key: "WEBSITE_DIR", Value: directory}).Error; err != nil {
return err
}
} else if setting.Value == "" {
result := tx.Model(&model.Setting{}).Where("id = ? AND value = ?", setting.ID, "").Update("value", directory)
if result.Error != nil {
return result.Error
}
if result.RowsAffected != 1 {
return changed
}
}
if err := os.MkdirAll(path.Join(directory, "conf.d"), constant.DirPerm); err != nil {
return err
}
return nil
})
}
+70
View File
@@ -60,6 +60,12 @@ type upgradeFileSnapshot struct {
}
type appUpgradeContext struct {
openclawOriginal []byte
feishuOriginal []byte
feishuImage string
feishuOldImage string
feishuEnabled bool
feishuLockPath string
req request.AppInstallUpgrade
original model.AppInstall
candidate model.AppInstall
@@ -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))
if err != nil {
return err
@@ -129,6 +148,7 @@ func upgradeInstall(req request.AppInstallUpgrade) error {
return err
}
ctx := &appUpgradeContext{
feishuLockPath: feishuLockPath,
req: req,
original: 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 {
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 {
for _, relativePath := range []string{
@@ -330,6 +354,9 @@ func (u *appUpgradeContext) prepare(t *task.Task) error {
if u.original.App.Resource == constant.AppResourceRemote {
go RequestDownloadCallBack(u.detail.DownloadCallBackUrl)
}
if err = u.prepareFeishuUpgrade(); err != nil {
return err
}
u.phase = appUpgradePrepared
return nil
}
@@ -387,6 +414,9 @@ func (u *appUpgradeContext) cutover(t *task.Task) error {
}
t.LogSuccess(i18n.GetMsgByKey("UpgradeStop"))
u.phase = appUpgradeStopped
if err := u.checkFeishuUpgradeSource(); err != nil {
return err
}
var err error
if u.original.App.Key == constant.AppOpenresty {
@@ -454,6 +484,9 @@ func (u *appUpgradeContext) cutover(t *task.Task) error {
} else {
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.Message = ""
@@ -579,8 +612,34 @@ func (u *appUpgradeContext) rollback(t *task.Task) (rollbackErr error) {
if out != "" {
err = fmt.Errorf("%s: %w", out, err)
}
if u.feishuOriginal != nil {
return 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 != "" {
_ = u.restoreManagedFiles()
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 {
if u.feishuOriginal != nil {
if err := waitOpenclawReady(u.original.ContainerName); err != nil {
return err
}
}
restored := u.original
if err := appInstallRepo.Save(context.Background(), &restored); err != nil {
return err
@@ -643,6 +707,12 @@ func (u *appUpgradeContext) deleteOldImages(t *task.Task) {
}
func (u *appUpgradeContext) cleanup() {
if u.feishuLockPath != "" {
feishuSwitches.Delete(u.feishuLockPath)
}
if u.feishuOriginal != nil && u.rollbackErr != nil {
return
}
if u.snapshot != nil {
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)
}
+24 -1
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()
defer tx.Rollback()
if err = appInstallRepo.Delete(ctx, install); err != nil {
return err
}
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) {
_ = agentRepo.DeleteByAppInstallIDWithCtx(ctx, install.ID)
}
@@ -469,7 +484,12 @@ func deleteAppInstall(deleteReq request.AppInstallDelete) error {
if err == nil && len(entries) == 0 {
_ = op.DeleteDir(parentDir)
}
tx.Commit()
if err := tx.Commit().Error; err != nil {
return err
}
if install.App.Key == "ollama" || install.App.Key == "vllm" {
CleanupLocalModelAccount(install.ID)
}
existApps, _ := appInstallRepo.ListBy(context.Background(), appInstallRepo.WithAppId(install.AppId))
if len(existApps) == 0 {
@@ -2015,6 +2035,9 @@ func getAppTags(appID uint, lang string) ([]response.TagDTO, 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 req.MCPPreserveWebsiteDir {
return prepareMCPWebsiteDirectory(req)
}
if dir, ok := req.Params["WEBSITE_DIR"]; ok {
siteDir := dir.(string)
if siteDir == "" || !strings.HasPrefix(siteDir, "/") {
+16 -4
View File
@@ -197,7 +197,8 @@ func handleAppBackup(install *model.AppInstall, parentTask *task.Task, recordID
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
// backups, or touching its files/database. Keep the validated extraction for
// 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
}
defer func() {
defer os.RemoveAll(tmpPath)
if !startAfter {
return
}
if isRollback {
_, _ = compose.UpWithoutBuild(install.GetComposePath())
} else {
_, _ = compose.Up(install.GetComposePath())
}
_ = os.RemoveAll(tmpPath)
}()
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()
backPath := fmt.Sprintf("%s_bak", appDir)
_ = fileOp.Rename(appDir, backPath)
_ = fileOp.CreateDir(appDir, constant.DirPerm)
if err := fileOp.Rename(appDir, backPath); err != nil && !startAfter {
return err
}
if err := fileOp.CreateDir(appDir, constant.DirPerm); err != nil && !startAfter {
return err
}
deCompressName := i18n.GetWithName("DeCompressFile", "app.tar.gz")
t.LogStart(deCompressName)
@@ -376,6 +384,10 @@ func handleAppRecover(install *model.AppInstall, parentTask *task.Task, recoverF
_, _ = file.WriteString(mergedEnvContent)
}
// The upgrade transaction verifies readiness before saving its restored install.
if !startAfter {
return nil
}
backupInstall.ID = install.ID
backupInstall.Status = constant.StatusRunning
backupInstall.AppId = install.AppId
+73 -22
View File
@@ -58,10 +58,10 @@ type ContainerService struct{}
var containerLogAnsiRegex = regexp.MustCompile("\x1b\\[[0-9;?]*[A-Za-z]|\x1b=|\x1b>")
type IContainerService interface {
Page(req dto.PageContainer) (int64, interface{}, error)
Page(ctx context.Context, req dto.PageContainer) (int64, interface{}, error)
List() []dto.ContainerOptions
ListByImage(imageName string) []dto.ContainerOptions
LoadStatus() (dto.ContainerStatus, error)
LoadStatus(ctx context.Context, containersOnly bool) (dto.ContainerStatus, error)
PageNetwork(req dto.SearchWithPage) (int64, interface{}, error)
ListNetwork() ([]dto.Options, error)
PageVolume(req dto.SearchWithPage) (int64, interface{}, error)
@@ -80,7 +80,7 @@ type IContainerService interface {
ContainerUpdate(req dto.ContainerOperate) error
ContainerUpgrade(req dto.ContainerUpgrade) error
ContainerInfo(req dto.OperationWithName) (*dto.ContainerOperate, error)
ContainerListStats() ([]dto.ContainerListStats, error)
ContainerListStats(ctx context.Context, ids []string) ([]dto.ContainerListStats, error)
ContainerItemStats(ctx context.Context, req dto.OperationWithName) (dto.ContainerItemStats, error)
LoadResourceLimit() (*dto.ResourceLimit, error)
ContainerRename(req dto.ContainerRename) error
@@ -112,7 +112,7 @@ func NewIContainerService() IContainerService {
return &ContainerService{}
}
func (u *ContainerService) Page(req dto.PageContainer) (int64, interface{}, error) {
func (u *ContainerService) Page(ctx context.Context, req dto.PageContainer) (int64, interface{}, error) {
client, err := docker.NewDockerClient()
if err != nil {
return 0, nil, err
@@ -123,7 +123,7 @@ func (u *ContainerService) Page(req dto.PageContainer) (int64, interface{}, erro
options.Filters = filters.NewArgs()
options.Filters.Add("label", req.Filters)
}
containers, err := client.ContainerList(context.Background(), options)
containers, err := client.ContainerList(ctx, options)
if err != nil {
return 0, nil, err
}
@@ -140,6 +140,29 @@ func (u *ContainerService) Page(req dto.PageContainer) (int64, interface{}, erro
backData = records[start:end]
}
// Batch-load persisted checks; never contact Docker Hub while rendering the list.
ids := make([]string, 0, len(backData))
for _, row := range backData {
ids = append(ids, row.ContainerID)
}
if len(ids) > 0 {
var checks []model.ContainerImageCheckUpdate
if err := global.DB.Where("container_id IN ?", ids).Find(&checks).Error; err != nil {
return 0, nil, err
}
byID := make(map[string]model.ContainerImageCheckUpdate, len(checks))
for _, check := range checks {
byID[check.ContainerID] = check
}
for i := range backData {
check, ok := byID[backData[i].ContainerID]
if ok && sameContainerImage(check.ImageID, backData[i].ImageId) && check.ImageName == backData[i].ImageName {
backData[i].LatestVersion = check.LatestVersion
backData[i].TargetImage = check.TargetImage
backData[i].HasUpdate = check.HasUpdate
}
}
}
for i := 0; i < len(backData); i++ {
install, _ := appInstallRepo.GetFirst(appInstallRepo.WithContainerName(backData[i].Name))
if install.ID > 0 {
@@ -206,27 +229,30 @@ func (u *ContainerService) ListByImage(imageName string) []dto.ContainerOptions
return options
}
func (u *ContainerService) LoadStatus() (dto.ContainerStatus, error) {
func (u *ContainerService) LoadStatus(ctx context.Context, containersOnly bool) (dto.ContainerStatus, error) {
var data dto.ContainerStatus
client, err := docker.NewDockerClient()
if err != nil {
return data, err
}
defer client.Close()
c := context.Background()
images, _ := client.ImageList(c, image.ListOptions{All: true})
if !containersOnly {
images, _ := client.ImageList(ctx, image.ListOptions{All: true})
data.ImageCount = len(images)
repo, _ := imageRepoRepo.List()
data.RepoCount = len(repo)
templates, _ := composeRepo.List()
data.ComposeTemplateCount = len(templates)
networks, _ := client.NetworkList(c, network.ListOptions{})
networks, _ := client.NetworkList(ctx, network.ListOptions{})
data.NetworkCount = len(networks)
volumes, _ := client.VolumeList(c, volume.ListOptions{})
volumes, _ := client.VolumeList(ctx, volume.ListOptions{})
data.VolumeCount = len(volumes.Volumes)
data.ComposeCount = loadComposeCount(client)
containers, _ := client.ContainerList(c, container.ListOptions{All: true})
}
containers, err := client.ContainerList(ctx, container.ListOptions{All: true})
if err != nil {
return data, err
}
data.ContainerCount = len(containers)
for _, item := range containers {
switch item.State {
@@ -292,25 +318,48 @@ func (u *ContainerService) ContainerItemStats(ctx context.Context, req dto.Opera
}
return data, nil
}
func (u *ContainerService) ContainerListStats() ([]dto.ContainerListStats, error) {
func (u *ContainerService) ContainerListStats(ctx context.Context, ids []string) ([]dto.ContainerListStats, error) {
client, err := docker.NewDockerClient()
if err != nil {
return nil, err
}
defer client.Close()
list, err := client.ContainerList(context.Background(), container.ListOptions{All: true})
options := container.ListOptions{All: true}
if ids != nil {
if len(ids) == 0 {
return []dto.ContainerListStats{}, nil
}
options.Filters = filters.NewArgs()
for _, id := range ids {
options.Filters.Add("id", id)
}
}
list, err := client.ContainerList(ctx, options)
if err != nil {
return nil, err
}
datas := make([]dto.ContainerListStats, len(list))
jobs := make(chan int)
var wg sync.WaitGroup
wg.Add(len(list))
for i := 0; i < len(list); i++ {
go func(index int, item container.Summary) {
datas[index] = loadCpuAndMem(client, item.ID)
wg.Done()
}(i, list[i])
for worker := 0; worker < min(8, len(list)); worker++ {
wg.Add(1)
go func() {
defer wg.Done()
for index := range jobs {
datas[index].ContainerID = list[index].ID
if ctx.Err() == nil && list[index].State == "running" {
datas[index] = loadCpuAndMem(ctx, client, list[index].ID)
}
if len(list[index].Names) > 0 {
datas[index].ContainerName = strings.TrimPrefix(list[index].Names[0], "/")
}
}
}()
}
for index := range list {
jobs <- index
}
close(jobs)
wg.Wait()
return datas, nil
}
@@ -1722,11 +1771,13 @@ func selectImageRepo(imageName string, repos []model.ImageRepo) *model.ImageRepo
return selected
}
func loadCpuAndMem(client *client.Client, containerItem string) dto.ContainerListStats {
func loadCpuAndMem(ctx context.Context, client *client.Client, containerItem string) dto.ContainerListStats {
ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
defer cancel()
data := dto.ContainerListStats{
ContainerID: containerItem,
}
res, err := client.ContainerStats(context.Background(), containerItem, false)
res, err := client.ContainerStats(ctx, containerItem, false)
if err != nil {
return data
}
@@ -0,0 +1,379 @@
package service
import (
"context"
"fmt"
"io"
"strings"
"sync"
"time"
"github.com/1Panel-dev/1Panel/agent/app/model"
"github.com/1Panel-dev/1Panel/agent/app/task"
"github.com/1Panel-dev/1Panel/agent/global"
"github.com/1Panel-dev/1Panel/agent/i18n"
"github.com/1Panel-dev/1Panel/agent/utils/docker"
"github.com/1Panel-dev/1Panel/agent/utils/dockerhub"
"github.com/docker/cli/cli/config"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/image"
"github.com/docker/docker/client"
"github.com/docker/docker/errdefs"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
const imageCheckUpdateSetting = "ContainerImageUpdateEnabled"
type ImageCheckUpdateStatus struct {
TaskID string `json:"taskID"`
Enabled bool `json:"enabled"`
Running bool `json:"running"`
Checked int `json:"checked"`
Updates int `json:"updates"`
Failed int `json:"failed"`
Skipped int `json:"skipped"`
Error string `json:"error"`
FinishedAt int64 `json:"finishedAt"`
}
var imageCheckUpdateState struct {
sync.Mutex
status ImageCheckUpdateStatus
}
func GetContainerImageCheckUpdateStatus() (ImageCheckUpdateStatus, error) {
var setting model.Setting
if err := global.DB.Where("key = ?", imageCheckUpdateSetting).First(&setting).Error; err != nil {
return ImageCheckUpdateStatus{}, err
}
imageCheckUpdateState.Lock()
defer imageCheckUpdateState.Unlock()
status := imageCheckUpdateState.status
status.Enabled = setting.Value == "Enable"
return status, nil
}
func SetContainerImageCheckUpdateSchedule(enabled bool) error {
value := "Disable"
if enabled {
value = "Enable"
}
return global.DB.Model(&model.Setting{}).Where("key = ?", imageCheckUpdateSetting).Update("value", value).Error
}
// StartContainerImageCheckUpdate coalesces manual and scheduled runs on this node.
func StartContainerImageCheckUpdate() (string, error) {
imageCheckUpdateState.Lock()
defer imageCheckUpdateState.Unlock()
if imageCheckUpdateState.status.Running {
return imageCheckUpdateState.status.TaskID, nil
}
taskItem, err := task.NewTask(i18n.GetMsgByKey("ContainerImageCheckUpdate"), task.TaskScan, task.TaskScopeImage, "", 0)
if err != nil {
return "", err
}
// Persist before returning the ID so the log drawer can immediately find it.
if err := global.TaskDB.Save(taskItem.Task).Error; err != nil {
_ = taskItem.Logger.Out.(io.Closer).Close()
taskItem.DeleteLogFile()
if cancel, ok := global.LoadTaskCancel(taskItem.TaskID); ok {
cancel()
}
global.RemoveTaskCancel(taskItem.TaskID)
return "", err
}
imageCheckUpdateState.status = ImageCheckUpdateStatus{Running: true, TaskID: taskItem.TaskID}
go func() {
result := ImageCheckUpdateStatus{TaskID: taskItem.TaskID}
defer func() {
result.FinishedAt = time.Now().UnixMilli()
imageCheckUpdateState.Lock()
imageCheckUpdateState.status = result
imageCheckUpdateState.Unlock()
}()
// The action owns its deadline so the task runner waits for cleanup and logs.
taskItem.AddSubTaskWithOps(i18n.GetMsgByKey("ContainerImageCheckUpdate"), func(t *task.Task) (err error) {
defer func() {
if recovered := recover(); recovered != nil {
err = fmt.Errorf("image update check failed: %v", recovered)
}
t.Log(i18n.GetMsgWithMap("ContainerImageCheckSummary", map[string]interface{}{
"checked": result.Checked, "updates": result.Updates, "failed": result.Failed, "skipped": result.Skipped,
}))
}()
ctx, cancel := context.WithTimeout(t.TaskCtx, 30*time.Minute)
defer cancel()
if err = runContainerImageCheckUpdate(ctx, &result, t.Log); err != nil {
return err
}
if result.Failed > 0 {
return fmt.Errorf("%s", i18n.GetMsgWithMap("ContainerImageCheckFailures", map[string]interface{}{"count": result.Failed}))
}
return nil
}, nil, 0, 0)
if err := taskItem.Execute(); err != nil {
result.Error = err.Error()
global.LOG.Errorf("container image check: %s", err)
}
}()
return taskItem.TaskID, nil
}
// These caches live only for one check. Remote results do not depend on a
// container's local image ID, which is compared separately for every baseline.
type imageCheckUpdateRepository struct {
client imageCheckUpdateHubClient
err error
tags []string
tagsLoaded bool
tagsErr error
}
type imageCheckUpdateTargetKey struct {
repository, tag string
platform dockerhub.Platform
}
type imageCheckUpdateTargetResult struct {
target dockerhub.Target
compatible bool
err error
}
type imageCheckUpdateResult struct {
record model.ContainerImageCheckUpdate
err error
}
type imageCheckUpdateCache struct {
username, password string
repositories map[string]*imageCheckUpdateRepository
targets map[imageCheckUpdateTargetKey]imageCheckUpdateTargetResult
}
func runContainerImageCheckUpdate(ctx context.Context, result *ImageCheckUpdateStatus, logger func(string)) error {
log := func(key string, values map[string]interface{}) {
if logger != nil {
logger(i18n.GetMsgWithMap(key, values))
}
}
cli, err := newImageCheckUpdateDockerClient()
if err != nil {
return err
}
defer cli.Close()
containers, err := cli.ContainerList(ctx, container.ListOptions{All: true})
if err != nil {
return err
}
log("ContainerImageCheckScope", map[string]interface{}{"count": len(containers)})
// Load old results once, rather than issuing one SELECT for every failed check.
var previousRows []model.ContainerImageCheckUpdate
if err := global.DB.WithContext(ctx).Find(&previousRows).Error; err != nil {
return err
}
previous := make(map[string]model.ContainerImageCheckUpdate, len(previousRows))
for _, row := range previousRows {
previous[row.ContainerID] = row
}
cache := imageCheckUpdateCache{repositories: map[string]*imageCheckUpdateRepository{}, targets: map[imageCheckUpdateTargetKey]imageCheckUpdateTargetResult{}}
cfg := config.LoadDefaultConfigFile(io.Discard)
if cfg != nil {
for _, host := range []string{"https://index.docker.io/v1/", "docker.io", "registry-1.docker.io"} {
auth, e := cfg.GetAuthConfig(host)
if e == nil && auth.Username != "" {
cache.username, cache.password = auth.Username, auth.Password
break
}
}
}
checks := map[string]imageCheckUpdateResult{}
ids := make([]string, 0, len(containers))
names := make(map[string]string, len(containers))
pending := make([]model.ContainerImageCheckUpdate, 0, 100)
for _, item := range containers {
if err := ctx.Err(); err != nil {
return err
}
ids = append(ids, item.ID)
name := item.ID
if len(item.Names) > 0 {
name = strings.TrimPrefix(item.Names[0], "/")
}
names[item.ID] = name
log("ContainerImageCheckItem", map[string]interface{}{"name": name, "image": item.Image})
repository, tag, supported := dockerhub.Reference(item.Image)
key := repository + ":" + tag + "@" + item.ImageID
entry, found := checks[key]
if !found {
entry.record, entry.err = checkContainerImageUpdate(ctx, cli, item, repository, tag, supported, &cache)
checks[key] = entry
}
record := entry.record
record.ContainerID, record.ImageName = item.ID, item.Image
if entry.err != nil {
record.CheckStatus, record.CheckError = "failed", entry.err.Error()
old := previous[item.ID]
if old.ImageID == item.ImageID && old.ImageName == item.Image {
record.LatestVersion, record.TargetImage, record.TargetImageID, record.HasUpdate = old.LatestVersion, old.TargetImage, old.TargetImageID, old.HasUpdate
}
}
pending = append(pending, record)
if len(pending) == 100 {
if err := saveContainerImageCheckUpdates(ctx, cli, pending, names, result, log); err != nil {
return err
}
pending = pending[:0]
}
}
if err := saveContainerImageCheckUpdates(ctx, cli, pending, names, result, log); err != nil {
return err
}
query := global.DB.WithContext(ctx).Where("1 = 1")
if len(ids) > 0 {
query = query.Where("container_id NOT IN ?", ids)
}
return query.Delete(&model.ContainerImageCheckUpdate{}).Error
}
func checkContainerImageUpdate(ctx context.Context, cli imageCheckUpdateDockerClient, item container.Summary, repository, tag string, supported bool, cache *imageCheckUpdateCache) (model.ContainerImageCheckUpdate, error) {
record := model.ContainerImageCheckUpdate{ImageID: item.ImageID, ImageName: item.Image, CheckStatus: "success", CheckedAt: time.Now()}
if !supported {
record.CheckStatus = "skipped"
return record, nil
}
inspected, err := cli.ImageInspect(ctx, item.ImageID)
if err != nil {
return record, err
}
repo, loaded := cache.repositories[repository]
if !loaded {
repo = &imageCheckUpdateRepository{}
repo.client, repo.err = newImageCheckUpdateHubClient(ctx, repository, cache.username, cache.password)
cache.repositories[repository] = repo
}
if repo.err != nil {
return record, repo.err
}
if dockerhub.NeedsTags(tag) {
if !repo.tagsLoaded {
repo.tags, repo.tagsErr = repo.client.Tags(ctx)
repo.tagsLoaded = true
}
if repo.tagsErr != nil {
return record, repo.tagsErr
}
}
platform := dockerhub.Platform{OS: inspected.Os, Architecture: inspected.Architecture, Variant: inspected.Variant}
for _, candidate := range dockerhub.Candidates(tag, repo.tags) {
key := imageCheckUpdateTargetKey{repository: repository, tag: candidate, platform: platform}
remote, loaded := cache.targets[key]
if !loaded {
remote.target, remote.compatible, remote.err = repo.client.Resolve(ctx, candidate, platform)
cache.targets[key] = remote
}
if remote.err != nil {
return record, remote.err
}
if !remote.compatible {
continue
}
record.LatestVersion = remote.target.Tag
record.TargetImage = "docker.io/" + repository + ":" + remote.target.Tag
record.TargetImageID = remote.target.ConfigDigest
record.HasUpdate = remote.target.Tag != tag || remote.target.ConfigDigest != inspected.ID
return record, nil
}
return record, fmt.Errorf("no compatible image for %s/%s", platform.OS, platform.Architecture)
}
// Revalidate immediately before each small write batch. Never hold a database
// transaction open while waiting for Docker or Docker Hub.
func saveContainerImageCheckUpdates(ctx context.Context, cli imageCheckUpdateDockerClient, pending []model.ContainerImageCheckUpdate, names map[string]string, result *ImageCheckUpdateStatus, log func(string, map[string]interface{})) error {
records := make([]model.ContainerImageCheckUpdate, 0, len(pending))
for _, record := range pending {
current, err := cli.ContainerInspect(ctx, record.ContainerID)
if err != nil && !errdefs.IsNotFound(err) {
return err
}
if err != nil || current.Image != record.ImageID {
result.Skipped++
log("ContainerImageCheckChanged", map[string]interface{}{"name": names[record.ContainerID]})
continue
}
records = append(records, record)
}
if len(records) == 0 {
return nil
}
if err := global.DB.WithContext(ctx).Clauses(clause.OnConflict{UpdateAll: true}).CreateInBatches(&records, 100).Error; err != nil {
return err
}
// Count and announce only results that were successfully persisted.
for _, record := range records {
values := map[string]interface{}{"name": names[record.ContainerID], "image": record.ImageName}
switch record.CheckStatus {
case "failed":
result.Failed++
values["error"] = record.CheckError
log("ContainerImageCheckFailed", values)
case "skipped":
result.Skipped++
log("ContainerImageCheckSkipped", values)
default:
result.Checked++
if record.HasUpdate {
result.Updates++
values["target"] = record.TargetImage
log("ContainerImageCheckAvailable", values)
} else {
log("ContainerImageCheckCurrent", values)
}
}
}
return nil
}
// sameContainerImage guards cached results after external image changes.
func sameContainerImage(stored, displayed string) bool {
return strings.TrimPrefix(stored, "sha256:") == strings.TrimPrefix(displayed, "sha256:")
}
// Reconcile only after Docker confirms the replacement container uses the target.
func reconcileContainerImageCheckUpdate(oldID, newID, imageID, imageName string) error {
return global.DB.Transaction(func(tx *gorm.DB) error {
var previous model.ContainerImageCheckUpdate
if err := tx.Where("container_id = ?", oldID).Find(&previous).Error; err != nil {
return err
}
if previous.ContainerID == "" {
return nil
}
if err := tx.Where("container_id = ?", oldID).Delete(&model.ContainerImageCheckUpdate{}).Error; err != nil {
return err
}
if previous.TargetImageID != imageID {
return nil
}
previous.ContainerID = newID
previous.ImageID = imageID
previous.ImageName = imageName
previous.HasUpdate = false
previous.CheckStatus = "success"
previous.CheckError = ""
previous.CheckedAt = time.Now()
return tx.Clauses(clause.OnConflict{UpdateAll: true}).Create(&previous).Error
})
}
type imageCheckUpdateDockerClient interface {
ContainerList(context.Context, container.ListOptions) ([]container.Summary, error)
ContainerInspect(context.Context, string) (container.InspectResponse, error)
ImageInspect(context.Context, string, ...client.ImageInspectOption) (image.InspectResponse, error)
Close() error
}
type imageCheckUpdateHubClient interface {
Tags(context.Context) ([]string, error)
Resolve(context.Context, string, dockerhub.Platform) (dockerhub.Target, bool, error)
}
var newImageCheckUpdateDockerClient = func() (imageCheckUpdateDockerClient, error) { return docker.NewDockerClient() }
var newImageCheckUpdateHubClient = func(ctx context.Context, repository, username, password string) (imageCheckUpdateHubClient, error) {
return dockerhub.New(ctx, repository, username, password)
}
+5
View File
@@ -152,6 +152,11 @@ func (u *ContainerService) ContainerUpgrade(req dto.ContainerUpgrade) error {
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))
}
+11
View File
@@ -5,12 +5,14 @@ import (
"fmt"
"os"
"path"
"strings"
"github.com/1Panel-dev/1Panel/agent/app/dto"
"github.com/1Panel-dev/1Panel/agent/app/repo"
"github.com/1Panel-dev/1Panel/agent/buserr"
"github.com/1Panel-dev/1Panel/agent/constant"
"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/mysql"
"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 {
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 {
req.Timeout = 30
}
@@ -180,6 +185,9 @@ func (u *DatabaseService) CheckDatabase(req dto.DatabaseCreate) bool {
}
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 {
req.Timeout = 30
}
@@ -303,6 +311,9 @@ func (u *DatabaseService) Delete(req dto.DatabaseDelete) 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 {
case constant.AppPostgresql:
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) {
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")
}
if !isSupportedMongodbPrivilege(req.Permission) {
@@ -146,7 +146,7 @@ func (u *MongodbService) LoadFromRemote(req dto.MongodbLoadDB) 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")
}
@@ -168,7 +168,7 @@ func (u *MongodbService) BindUser(req dto.MongodbBind) 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")
}
@@ -190,7 +190,7 @@ func (u *MongodbService) ChangePassword(req dto.MongodbPassword) 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")
}
if req.From != constant.AppResourceLocal {
+89 -10
View File
@@ -433,7 +433,11 @@ func (u *MysqlService) ListDBOption() ([]dto.MysqlOption, error) {
}
func (u *MysqlService) Create(ctx context.Context, req dto.MysqlDBCreate) (*model.DatabaseMysql, error) {
if cmd.CheckIllegal(req.Name, req.Username, req.Password, req.Format, req.Collation, req.Permission) {
return u.create(ctx, req, false)
}
func (u *MysqlService) create(ctx context.Context, req dto.MysqlDBCreate, mcpBounded bool) (*model.DatabaseMysql, 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")
}
if len(req.Username) != 0 && len(req.Password) == 0 {
@@ -470,13 +474,13 @@ func (u *MysqlService) Create(ctx context.Context, req dto.MysqlDBCreate) (*mode
return nil, errors.New("cannot set root as user name")
}
cli, version, err := LoadMysqlClientByFrom(req.Database)
cli, version, err := loadMysqlClientByFrom(req.Database, mcpBounded)
if err != nil {
return nil, err
}
createItem.MysqlName = req.Database
defer cli.Close()
if err := cli.Create(client.CreateInfo{
createInfo := client.CreateInfo{
Name: req.Name,
Format: req.Format,
Collation: req.Collation,
@@ -485,9 +489,31 @@ func (u *MysqlService) Create(ctx context.Context, req dto.MysqlDBCreate) (*mode
Permission: req.Permission,
Version: version,
Timeout: 300,
}); err != nil {
}
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
@@ -506,6 +532,45 @@ func (u *MysqlService) Create(ctx context.Context, req dto.MysqlDBCreate) (*mode
return &createItem, nil
}
func persistMCPMysqlMetadata(ctx context.Context, req dto.MysqlDBCreate, hosts []string, item *model.DatabaseMysql) error {
return global.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if req.Username != "" {
key := global.CONF.Base.EncryptKey
if key == "" {
var setting model.Setting
if err := tx.Where("key = ?", "EncryptKey").First(&setting).Error; err != nil {
return err
}
key = setting.Value
}
password, err := encrypt.StringEncryptWithKey(req.Password, key)
if err != nil {
return err
}
for _, host := range hosts {
var user model.DatabaseUser
err := tx.Where("type = ? AND database = ? AND username = ? AND host = ?", "mysql", req.Database, req.Username, host).First(&user).Error
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
user.Type, user.Database, user.Username, user.Host = "mysql", req.Database, req.Username, host
user.Password, user.IsDelete = password, false
if req.Description != "" {
user.Description = req.Description
}
if err := tx.Save(&user).Error; err != nil {
return err
}
grant := model.DatabaseUserGrant{Type: "mysql", Database: req.Database, DBName: req.Name, Username: req.Username, Host: host}
if err := tx.Where("type = ? AND database = ? AND db_name = ? AND username = ? AND host = ?", grant.Type, grant.Database, grant.DBName, grant.Username, grant.Host).FirstOrCreate(&grant).Error; err != nil {
return err
}
}
}
return tx.Create(item).Error
})
}
func (u *MysqlService) ListUsers(req dto.MysqlUserSearch) ([]dto.MysqlUser, error) {
dbType, err := resolveDatabaseUserType(req.Database)
if err != nil {
@@ -615,7 +680,7 @@ func (u *MysqlService) ListGrantSummary(req dto.MysqlGrantSummarySearch) (map[st
}
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")
}
dbs := make([]string, 0, len(req.DBs))
@@ -818,7 +883,7 @@ func (u *MysqlService) UpdateUser(req dto.MysqlUserUpdate) 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")
}
if err := checkMysqlNormalUser(req.Username); err != nil {
@@ -871,7 +936,7 @@ func (u *MysqlService) ChangeUserPassword(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")
}
if err := checkMysqlNormalUser(req.Username); err != nil {
@@ -1237,7 +1302,7 @@ func isMysqlDatabaseResourceInUseError(err error) bool {
}
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")
}
cli, version, err := LoadMysqlClientByFrom(req.Database)
@@ -1398,7 +1463,14 @@ func (u *MysqlService) LoadStatus(req dto.OperationWithNameAndType) (*dto.MysqlS
return nil, err
}
statusMap, err := executeSqlForMaps(app.ContainerName, app.Key, app.Password, "show global status;")
return loadMysqlStatus(req, app, executeSqlForMaps, executeSqlForRows)
}
func loadMysqlStatus(req dto.OperationWithNameAndType, app *repo.RootInfo,
maps func(string, string, string, string) (map[string]string, error),
rowsQuery func(string, string, string, string) ([]string, error),
) (*dto.MysqlStatus, error) {
statusMap, err := maps(app.ContainerName, app.Key, app.Password, "show global status;")
if err != nil {
return nil, err
}
@@ -1426,7 +1498,7 @@ func (u *MysqlService) LoadStatus(req dto.OperationWithNameAndType) (*dto.MysqlS
if common.CompareAppVersion(app.Version, "8.4.0") && (req.Type == constant.AppMysql || req.Type == constant.AppMysqlCluster) {
masterStatus = "show binary log status;"
}
rows, err := executeSqlForRows(app.ContainerName, app.Key, app.Password, masterStatus)
rows, err := rowsQuery(app.ContainerName, app.Key, app.Password, masterStatus)
if err != nil {
return nil, err
}
@@ -1531,6 +1603,10 @@ func updateMyCnf(oldFiles []string, group string, param string, value interface{
}
func LoadMysqlClientByFrom(database string) (mysql.MysqlClient, string, error) {
return loadMysqlClientByFrom(database, false)
}
func loadMysqlClientByFrom(database string, mcpLocalOnly bool) (mysql.MysqlClient, string, error) {
var (
dbInfo client.DBInfo
version string
@@ -1542,6 +1618,9 @@ func LoadMysqlClientByFrom(database string) (mysql.MysqlClient, string, error) {
if err != nil {
return nil, "", err
}
if mcpLocalOnly && (databaseItem.ID == 0 || databaseItem.From != "local" || databaseItem.Type != "mysql" || databaseItem.AppInstallID == 0) {
return nil, "", errors.New("an installed local MySQL instance is required")
}
dbInfo.Type = databaseItem.Type
dbInfo.From = databaseItem.From
dbInfo.Database = database
+24
View File
@@ -0,0 +1,24 @@
package service
import (
"context"
"errors"
"time"
"github.com/1Panel-dev/1Panel/agent/app/dto"
"github.com/1Panel-dev/1Panel/agent/app/model"
"github.com/1Panel-dev/1Panel/agent/utils/mcpapi"
)
func (u *MysqlService) CreateForMCP(ctx context.Context, req dto.MysqlDBCreate) (*model.DatabaseMysql, error) {
if req.From != "local" || ctx.Err() != nil {
return nil, errors.New("invalid MCP database target or request")
}
writeContext, cancel := context.WithTimeout(context.WithoutCancel(ctx), 310*time.Second)
defer cancel()
created, err := u.create(writeContext, req, true)
if err != nil {
return nil, mcpapi.SanitizeBusinessError(err, "database creation failed; inspect the invocation before retrying")
}
return created, nil
}
+3 -3
View File
@@ -87,7 +87,7 @@ func (u *PostgresqlService) ListDBOption() ([]dto.PostgresqlOption, 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")
}
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) {
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")
}
@@ -366,7 +366,7 @@ func (u *PostgresqlService) ChangePrivileges(req dto.PostgresqlPrivileges) 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")
}
cli, err := LoadPostgresqlClientByFrom(req.Database)
+10
View File
@@ -882,6 +882,16 @@ func (f *FileService) ChangeName(req request.FileRename) error {
if hasInvalidFileName(req.NewName) {
return buserr.New("ErrInvalidChar")
}
if req.OldName == req.NewName {
return nil
}
// os.Rename silently replaces an existing target on Linux, so reject the
// rename here to avoid losing the target file without confirmation.
if _, err := os.Lstat(req.NewName); err == nil {
return buserr.New("ErrFileIsExist")
} else if !os.IsNotExist(err) {
return err
}
fo := files.NewFileOp()
content, mode, shouldRecordHistory := readEditableFileHistoryContent(req.OldName)
if err := fo.Rename(req.OldName, req.NewName); err != nil {
+63
View File
@@ -0,0 +1,63 @@
package service
import (
"errors"
"os"
"path/filepath"
"testing"
"github.com/1Panel-dev/1Panel/agent/app/dto/request"
"github.com/1Panel-dev/1Panel/agent/buserr"
)
func TestChangeNameRejectsExistingTarget(t *testing.T) {
dir := t.TempDir()
oldPath := filepath.Join(dir, "old.txt")
targetPath := filepath.Join(dir, "target.txt")
// Binary source content keeps this test independent of the file-history DB.
if err := os.WriteFile(oldPath, []byte("old\x00"), 0644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(targetPath, []byte("target"), 0644); err != nil {
t.Fatal(err)
}
err := (&FileService{}).ChangeName(request.FileRename{OldName: oldPath, NewName: targetPath})
if err == nil {
t.Fatal("expected an error when the target name already exists")
}
var bizErr buserr.BusinessError
if !errors.As(err, &bizErr) || bizErr.Msg != "ErrFileIsExist" {
t.Fatalf("expected ErrFileIsExist, got %v", err)
}
content, err := os.ReadFile(targetPath)
if err != nil {
t.Fatal(err)
}
if string(content) != "target" {
t.Fatalf("target file was overwritten, content = %q", content)
}
if _, err := os.Stat(oldPath); err != nil {
t.Fatalf("source file should still exist after a rejected rename: %v", err)
}
}
func TestChangeNameRenamesToNewName(t *testing.T) {
dir := t.TempDir()
oldPath := filepath.Join(dir, "old")
newPath := filepath.Join(dir, "new")
if err := os.Mkdir(oldPath, 0755); err != nil {
t.Fatal(err)
}
if err := (&FileService{}).ChangeName(request.FileRename{OldName: oldPath, NewName: newPath}); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(newPath); err != nil {
t.Fatalf("renamed path does not exist: %v", err)
}
if _, err := os.Stat(oldPath); !os.IsNotExist(err) {
t.Fatalf("old path should be gone, stat err = %v", err)
}
}
+50
View File
@@ -0,0 +1,50 @@
package service
import (
"errors"
"regexp"
"strings"
"unicode"
"unicode/utf8"
"github.com/1Panel-dev/1Panel/agent/utils/mcpapi"
)
const mcpMysqlStoppedMessage = "The selected MySQL instance is not running. Start the database service and try the query again."
const mcpMysqlConnectionFailedMessage = "Cannot connect to the selected MySQL instance. Check the database service, connection settings and panel logs."
var errMCPMysqlStopped = mcpapi.NewBusinessError(mcpapi.CodeMySQLStopped, mcpMysqlStoppedMessage)
var errMCPMysqlConnectionFailed = mcpapi.NewBusinessError(mcpapi.CodeMySQLConnectionFailed, mcpMysqlConnectionFailedMessage)
var mcpMysqlStoppedPattern = regexp.MustCompile(`^Error response from daemon: [Cc]ontainer [A-Za-z0-9][A-Za-z0-9_.-]{0,127} is not running$`)
var mcpMysqlConnectionPatterns = []*regexp.Regexp{
regexp.MustCompile(`^ERROR 2002 \(HY000\): Can't connect to local MySQL server through socket '[^'\r\n]{1,1000}' \([0-9]{1,10}\)$`),
regexp.MustCompile(`^ERROR 2003 \(HY000\): Can't connect to MySQL server on '[^'\r\n]{1,1000}' \([0-9]{1,10}\)$`),
regexp.MustCompile(`^ERROR 1045 \(28000\): Access denied for user '[^'\r\n]{1,128}'@'[^'\r\n]{1,255}' \(using password: (YES|NO)\)$`),
}
func classifyMCPMysqlReadFailure(stderr string) error {
generic := errors.New("local MySQL status query failed")
if len(stderr) == 0 || len(stderr) > 2048 || !utf8.ValidString(stderr) {
return generic
}
warning := "mysql: [Warning] Using a password on the command line interface can be insecure."
stderr = strings.TrimPrefix(stderr, warning+"\n")
if strings.HasSuffix(stderr, "\r\n") {
stderr = strings.TrimSuffix(stderr, "\r\n")
} else {
stderr = strings.TrimSuffix(stderr, "\n")
}
if strings.IndexFunc(stderr, unicode.IsControl) >= 0 {
return generic
}
if mcpMysqlStoppedPattern.MatchString(stderr) {
return errMCPMysqlStopped
}
for _, pattern := range mcpMysqlConnectionPatterns {
if pattern.MatchString(stderr) {
return errMCPMysqlConnectionFailed
}
}
return generic
}
+189
View File
@@ -0,0 +1,189 @@
package service
import (
"bytes"
"context"
"encoding/json"
"errors"
"io"
"os/exec"
"sort"
"strconv"
"strings"
"sync"
"time"
"github.com/1Panel-dev/1Panel/agent/app/dto"
"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/global"
"gorm.io/gorm"
)
const mcpMysqlQueryTimeout = 30 * time.Second
const mcpMysqlQueryOutputLimit = 1 << 20
func mcpLocalMysqlQuery(ctx context.Context) *gorm.DB {
return global.DB.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 (u *DatabaseService) ListForMCP(ctx context.Context, dbType string) ([]dto.DatabaseOption, error) {
if dbType != constant.AppMysql {
return nil, errors.New("only installed local MySQL instances are supported")
}
result := make([]dto.DatabaseOption, 0)
err := mcpLocalMysqlQuery(ctx).Select("d.id, d.type, d.\"from\", d.name AS database, ai.version").Order("d.id").Scan(&result).Error
if err != nil {
return nil, errors.New("cannot read local MySQL instances")
}
return result, nil
}
type mcpMysqlReadRunner func(context.Context, string, string, string) (string, error)
func (u *MysqlService) LoadStatusForMCP(ctx context.Context, req dto.OperationWithNameAndType) (*dto.MysqlStatus, error) {
return u.loadStatusForMCP(ctx, req, runMCPMysqlRead)
}
func (u *MysqlService) loadStatusForMCP(ctx context.Context, req dto.OperationWithNameAndType, run mcpMysqlReadRunner) (*dto.MysqlStatus, error) {
if req.Type != constant.AppMysql || req.Name == "" || len(req.Name) > 128 || strings.TrimSpace(req.Name) != req.Name || strings.ContainsAny(req.Name, "\x00\r\n/\\") || ctx.Err() != nil {
return nil, errors.New("an explicitly named installed local MySQL instance is required")
}
ctx, cancel := context.WithTimeout(ctx, mcpMysqlQueryTimeout)
defer cancel()
var target struct {
ID uint
ContainerName, Version, Env string
}
if err := mcpLocalMysqlQuery(ctx).Where("d.name = ?", req.Name).Select("d.id, ai.container_name, ai.version, ai.env").Take(&target).Error; err != nil || target.ID == 0 {
return nil, errors.New("the installed local MySQL instance is unavailable")
}
var env map[string]any
if json.Unmarshal([]byte(target.Env), &env) != nil {
return nil, errors.New("local MySQL configuration is unavailable")
}
password, ok := env["PANEL_DB_ROOT_PASSWORD"].(string)
if !ok || password == "" {
return nil, errors.New("local MySQL configuration is unavailable")
}
app := &repo.RootInfo{ContainerName: target.ContainerName, Version: target.Version, Key: constant.AppMysql, Password: password}
rows := func(container, _, password, sql string) ([]string, error) {
if ctx.Err() != nil {
return nil, errors.New("local MySQL status query failed")
}
output, err := run(ctx, container, password, sql)
if err != nil {
if err == errMCPMysqlStopped || err == errMCPMysqlConnectionFailed {
return nil, err
}
return nil, errors.New("local MySQL status query failed")
}
return strings.Split(output, "\n"), nil
}
maps := func(container, dbType, password, sql string) (map[string]string, error) {
lines, err := rows(container, dbType, password, sql)
if err != nil {
return nil, err
}
values := map[string]string{}
for _, line := range lines {
if pair := strings.Split(line, "\t"); len(pair) == 2 {
values[pair[0]] = pair[1]
}
}
return values, nil
}
return loadMysqlStatus(req, app, maps, rows)
}
type mcpQueryOutput struct {
buffer bytes.Buffer
mu sync.Mutex
exceeded bool
}
func (b *mcpQueryOutput) Write(data []byte) (int, error) {
b.mu.Lock()
defer b.mu.Unlock()
if len(data) > mcpMysqlQueryOutputLimit-b.buffer.Len() {
b.exceeded = true
return 0, errors.New("query output limit exceeded")
}
return b.buffer.Write(data)
}
func (b *mcpQueryOutput) Len() int {
b.mu.Lock()
defer b.mu.Unlock()
return b.buffer.Len()
}
func (b *mcpQueryOutput) String() string {
b.mu.Lock()
defer b.mu.Unlock()
return b.buffer.String()
}
func runMCPMysqlRead(ctx context.Context, container, password, sql string) (string, error) {
switch sql {
case "show global status;", "show master status;", "show binary log status;":
default:
return "", errors.New("unsupported MySQL monitoring query")
}
command := exec.CommandContext(ctx, "docker", "exec", container, "mysql", "-uroot", "-p"+password, "-e", sql)
output := &mcpQueryOutput{}
var stderr bytes.Buffer
command.Stdout, command.Stderr = output, io.MultiWriter(output, &stderr)
runErr := command.Run()
if ctx.Err() != nil || output.exceeded {
return "", errors.New("local MySQL status query failed")
}
if runErr != nil {
return "", classifyMCPMysqlReadFailure(stderr.String())
}
value := strings.ReplaceAll(output.String(), "mysql: [Warning] Using a password on the command line interface can be insecure.\n", "")
if strings.HasPrefix(value, "ERROR ") {
return "", errors.New("local MySQL status query failed")
}
return value, nil
}
func (a *AppInstallService) GetAppInstallInfoForMCP(ctx context.Context, id uint) (*response.AppInstallInfo, error) {
var installed model.AppInstall
if id == 0 {
return nil, errors.New("invalid installed application")
}
err := global.DB.WithContext(ctx).Preload("App").Where("id = ?", id).First(&installed).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return &response.AppInstallInfo{Status: constant.StatusDeleted}, nil
}
if err != nil {
return nil, errors.New("installed application metadata is unavailable")
}
var env map[string]any
if json.Unmarshal([]byte(installed.Env), &env) != nil {
return nil, errors.New("installed application metadata is unavailable")
}
result := &response.AppInstallInfo{ID: installed.ID, Name: installed.Name, Version: installed.Version, Status: installed.Status, HttpPort: installed.HttpPort, AppKey: installed.App.Key, AppPorts: []int{}}
for key, value := range env {
if !strings.Contains(strings.ToUpper(key), "PANEL_APP_PORT") {
continue
}
var port int
switch item := value.(type) {
case string:
port, _ = strconv.Atoi(item)
case float64:
if item == float64(int(item)) {
port = int(item)
}
}
if port > 0 && port <= 65535 {
result.AppPorts = append(result.AppPorts, port)
}
}
sort.Ints(result.AppPorts)
return result, nil
}
+10 -2
View File
@@ -78,9 +78,17 @@ func getNginxParamsByKeys(scope string, keys []string, website *model.Website) (
var res []response.NginxParam
var block components.IBlock
if scope == constant.NginxScopeHttp {
block = nginxFull.RootConfig.Config.FindHttp()
httpBlock := nginxFull.RootConfig.Config.FindHttp()
if httpBlock == nil {
return nil, fmt.Errorf("nginx HTTP configuration block is missing")
}
block = httpBlock
} else {
block = nginxFull.SiteConfig.Config.FindServers()[0]
servers := nginxFull.SiteConfig.Config.FindServers()
if len(servers) == 0 {
return nil, fmt.Errorf("website nginx server block is missing")
}
block = servers[0]
}
for _, key := range keys {
dirs := block.FindDirectives(key)
+44 -7
View File
@@ -10,6 +10,7 @@ import (
"os/exec"
"path"
"path/filepath"
"slices"
"sort"
"strconv"
"strings"
@@ -46,7 +47,7 @@ type RuntimeService struct {
type IRuntimeService interface {
Page(req request.RuntimeSearch) (int64, []response.RuntimeDTO, error)
Create(create request.RuntimeCreate) (*model.Runtime, error)
Delete(delete request.RuntimeDelete) error
Delete(delete request.RuntimeDelete) (string, error)
Update(req request.RuntimeUpdate) error
Get(id uint) (res *response.RuntimeDTO, err error)
GetNodePackageRunScript(req request.NodePackageReq) ([]response.PackageScripts, error)
@@ -376,16 +377,16 @@ func (r *RuntimeService) DeleteCheck(runTimeId uint) ([]dto.AppResource, error)
return res, nil
}
func (r *RuntimeService) Delete(runtimeDelete request.RuntimeDelete) error {
func (r *RuntimeService) Delete(runtimeDelete request.RuntimeDelete) (string, error) {
runtime, err := runtimeRepo.GetFirst(context.Background(), repo.WithByID(runtimeDelete.ID))
if err != nil {
return err
return "", err
}
website, _ := websiteRepo.GetFirst(websiteRepo.WithRuntimeID(runtimeDelete.ID))
if website.ID > 0 {
return buserr.New("ErrDelWithWebsite")
return runtime.Name, buserr.New("ErrDelWithWebsite")
}
return startRuntimeDeleteTask(runtimeDelete, runtime)
return runtime.Name, startRuntimeDeleteTask(runtimeDelete, runtime)
}
func startRuntimeDeleteTask(runtimeDelete request.RuntimeDelete, runtime *model.Runtime) error {
@@ -584,6 +585,21 @@ func (r *RuntimeService) Get(id uint) (*response.RuntimeDTO, error) {
for _, form := range appForm.FormFields {
if v, ok := envs[form.EnvKey]; ok {
if form.EnvKey == "PHP_VERSION" {
// Refresh only version choices; keep the saved form if synchronized metadata is unavailable.
var latestForm dto.AppForm
if json.Unmarshal([]byte(appDetail.Params), &latestForm) == nil {
for _, latest := range latestForm.FormFields {
if latest.EnvKey == form.EnvKey && len(latest.Values) > 0 {
form.Values = latest.Values
break
}
}
}
if v != "" && !slices.ContainsFunc(form.Values, func(value dto.AppFormValue) bool { return value.Value == v }) {
form.Values = append(form.Values, dto.AppFormValue{Label: v, Value: v})
}
}
appParam := response.AppParam{
Edit: false,
Key: form.EnvKey,
@@ -631,7 +647,7 @@ func (r *RuntimeService) Get(id uint) (*response.RuntimeDTO, error) {
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))
if err != nil {
return err
@@ -640,6 +656,7 @@ func (r *RuntimeService) Update(req request.RuntimeUpdate) error {
runtime.Version = req.Version
return runtimeRepo.Save(runtime)
}
oldRuntime := *runtime
appDetail, err := getRuntimeUpdateAppDetail(runtime, req)
if err != nil {
return err
@@ -684,6 +701,20 @@ func (r *RuntimeService) Update(req request.RuntimeUpdate) error {
if err = downloadApp(app, appDetail, nil, nil); err != nil {
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 err = updateRuntimeVersionFiles(runtime, appVersionDir); err != nil {
return err
@@ -757,7 +788,13 @@ func (r *RuntimeService) Update(req request.RuntimeUpdate) error {
if err = runtimeRepo.Save(runtime); err != nil {
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
}
+86
View File
@@ -1,6 +1,9 @@
package service
import (
"errors"
"fmt"
"os"
"path/filepath"
"github.com/1Panel-dev/1Panel/agent/app/dto/request"
@@ -12,6 +15,89 @@ import (
"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) {
current, err := appDetailRepo.GetFirst(repo.WithByID(runtime.AppDetailID))
if err != nil {
+116 -37
View File
@@ -3,6 +3,7 @@ package service
import (
"bytes"
"context"
"database/sql"
"encoding/base64"
"errors"
"fmt"
@@ -973,11 +974,16 @@ func (w WebsiteService) GetWebsiteHTTPS(websiteId uint) (response.WebsiteHTTPS,
if err != nil {
return res, err
}
applyWebsiteHTTPSParams(&res, params)
return res, nil
}
func applyWebsiteHTTPSParams(res *response.WebsiteHTTPS, params []response.NginxParam) {
for _, p := range params {
if p.Name == "ssl_protocols" {
res.SSLProtocol = p.Params
}
if p.Name == "ssl_ciphers" {
if p.Name == "ssl_ciphers" && len(p.Params) > 0 {
res.Algorithm = p.Params[0]
}
if p.Name == "add_header" && len(p.Params) > 0 {
@@ -995,7 +1001,6 @@ func (w WebsiteService) GetWebsiteHTTPS(websiteId uint) (response.WebsiteHTTPS,
}
}
}
return res, nil
}
func (w WebsiteService) OpWebsiteHTTPS(ctx context.Context, req request.WebsiteHTTPSOp) (*response.WebsiteHTTPS, error) {
@@ -1325,11 +1330,12 @@ func (w WebsiteService) ChangeDefaultServer(id uint) error {
return websiteRepo.Save(context.Background(), &website)
}
func (w WebsiteService) ChangePHPVersion(req request.WebsitePHPVersionReq) error {
func (w WebsiteService) ChangePHPVersion(req request.WebsitePHPVersionReq) (err error) {
website, err := websiteRepo.GetFirst(repo.WithByID(req.WebsiteID))
if err != nil {
return err
}
wasPHP := website.Type == constant.Runtime
if website.Type == constant.Runtime {
oldRuntime, err := runtimeRepo.GetFirst(context.Background(), repo.WithByID(website.RuntimeID))
if err != nil {
@@ -1347,12 +1353,45 @@ func (w WebsiteService) ChangePHPVersion(req request.WebsitePHPVersionReq) error
return buserr.WithName("ErrImageNotExist", oldRuntime.Name)
}
}
configPath := GetSitePath(website, SiteConf)
nginxContent, err := files.NewFileOp().GetContent(configPath)
if req.RuntimeID > 0 {
runtime, err := runtimeRepo.GetFirst(context.Background(), repo.WithByID(req.RuntimeID))
if err != nil {
return err
}
config, err := parser.NewStringParser(string(nginxContent)).Parse()
if runtime.Resource == constant.ResourceLocal {
return buserr.New("ErrPHPResource")
}
website.RuntimeID = runtime.ID
website.AppInstallID = 0
website.Proxy = fmt.Sprintf("127.0.0.1:%s", runtime.Port)
website.Type = constant.Runtime
} else {
website.RuntimeID = 0
website.Type = constant.Static
website.Proxy = ""
}
subsites, err := websiteRepo.List(websiteRepo.WithParentID(website.ID), websiteRepo.WithType(constant.Subsite))
if err != nil {
return err
}
websites := append([]model.Website{website}, subsites...)
configs := make([]*components.Config, 0, len(websites))
oldContents := make(map[string][]byte, len(websites))
fileOp := files.NewFileOp()
for i := range websites {
site := &websites[i]
if i > 0 {
site.RuntimeID = website.RuntimeID
site.Proxy = website.Proxy
}
configPath := GetSitePath(*site, SiteConf)
content, err := fileOp.GetContent(configPath)
if err != nil {
return err
}
config, err := parser.NewStringParser(string(content)).Parse()
if err != nil {
return err
}
@@ -1361,51 +1400,91 @@ func (w WebsiteService) ChangePHPVersion(req request.WebsitePHPVersionReq) error
return errors.New("nginx config is not valid")
}
server := servers[0]
fileOp := files.NewFileOp()
indexPHPPath := path.Join(GetSitePath(website, SiteIndexDir), "index.php")
indexHtmlPath := path.Join(GetSitePath(website, SiteIndexDir), "index.html")
if i == 0 || wasPHP != (req.RuntimeID > 0) {
if req.RuntimeID > 0 {
server.UpdateDirective("index", []string{"index.php index.html index.htm default.php default.htm default.html"})
server.RemoveDirective("location", []string{"~", "[^/]\\.php(/|$)"})
runtime, err := runtimeRepo.GetFirst(context.Background(), repo.WithByID(req.RuntimeID))
if err != nil {
return err
}
if runtime.Resource == constant.ResourceLocal {
return buserr.New("ErrPHPResource")
}
website.RuntimeID = req.RuntimeID
website.AppInstallID = 0
phpProxy := fmt.Sprintf("127.0.0.1:%s", runtime.Port)
website.Proxy = phpProxy
server.UpdatePHPProxy([]string{website.Proxy}, "")
website.Type = constant.Runtime
if !fileOp.Stat(indexPHPPath) {
_ = fileOp.WriteFile(indexPHPPath, strings.NewReader(string(nginx_conf.IndexPHP)), constant.FilePerm)
}
} else {
server.UpdateDirective("index", []string{"index.html index.php index.htm default.php default.htm default.html"})
website.RuntimeID = 0
website.Type = constant.Static
website.Proxy = ""
server.RemoveDirective("location", []string{"~", "[^/]\\.php(/|$)"})
if !fileOp.Stat(indexHtmlPath) {
_ = fileOp.WriteFile(indexHtmlPath, strings.NewReader(string(nginx_conf.Index)), constant.FilePerm)
}
}
if req.RuntimeID > 0 && site.Status != constant.WebStopped {
updated := false
for _, location := range server.FindDirectives("location") {
if strings.Join(location.GetParameters(), " ") == "~ [^/]\\.php(/|$)" && location.GetBlock() != nil {
location.GetBlock().UpdateDirective("fastcgi_pass", []string{site.Proxy})
updated = true
}
}
if !updated {
server.UpdatePHPProxy([]string{site.Proxy}, "")
}
} else {
server.RemoveDirective("location", []string{"~", "[^/]\\.php(/|$)"})
}
config.FilePath = configPath
configs = append(configs, config)
oldContents[configPath] = content
}
config.FilePath = configPath
if err = nginx.WriteConfig(config, nginx.IndentedStyle); err != nil {
return err
indexPath := path.Join(GetSitePath(website, SiteIndexDir), "index.html")
indexContent := nginx_conf.Index
if req.RuntimeID > 0 {
indexPath = path.Join(GetSitePath(website, SiteIndexDir), "index.php")
indexContent = nginx_conf.IndexPHP
}
if !fileOp.Stat(indexPath) {
_ = fileOp.WriteFile(indexPath, strings.NewReader(string(indexContent)), constant.FilePerm)
}
nginxInstall, err := getAppInstallByKey(constant.AppOpenresty)
if err != nil {
return err
}
if err = nginxCheckAndReload(string(nginxContent), configPath, nginxInstall.ContainerName); err != nil {
tx, ctx := helper.GetTxAndContext()
if err = tx.Error; err != nil {
return err
}
return websiteRepo.Save(context.Background(), &website)
written := 0
reloadAttempted := false
defer func() {
if err == nil {
return
}
if rollbackErr := tx.Rollback().Error; rollbackErr != nil && !errors.Is(rollbackErr, sql.ErrTxDone) {
err = errors.Join(err, rollbackErr)
}
for _, config := range configs[:written] {
if restoreErr := fileOp.WriteFile(config.FilePath, strings.NewReader(string(oldContents[config.FilePath])), constant.FilePerm); restoreErr != nil {
err = errors.Join(err, fmt.Errorf("restore %s: %w", config.FilePath, restoreErr))
}
}
if reloadAttempted {
if checkErr := opNginx(nginxInstall.ContainerName, constant.NginxCheck); checkErr != nil {
err = errors.Join(err, fmt.Errorf("check restored nginx configuration: %w", checkErr))
} else if reloadErr := opNginx(nginxInstall.ContainerName, constant.NginxReload); reloadErr != nil {
err = errors.Join(err, fmt.Errorf("reload restored nginx configuration: %w", reloadErr))
}
}
}()
for i := range websites {
if err = websiteRepo.Save(ctx, &websites[i]); err != nil {
return err
}
}
for _, config := range configs {
written++
if err = nginx.WriteConfig(config, nginx.IndentedStyle); err != nil {
return err
}
}
if err = opNginx(nginxInstall.ContainerName, constant.NginxCheck); err != nil {
return err
}
reloadAttempted = true
if err = opNginx(nginxInstall.ContainerName, constant.NginxReload); err != nil {
return err
}
return tx.Commit().Error
}
func (w WebsiteService) UpdateSiteDir(req request.WebsiteUpdateDir) error {
+157 -4
View File
@@ -1,15 +1,19 @@
package service
import (
"archive/zip"
"bytes"
"context"
"crypto/ecdsa"
"crypto/rand"
"crypto/rsa"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"errors"
"fmt"
"io"
"log"
"math/big"
"net"
@@ -18,25 +22,27 @@ import (
"strings"
"time"
"github.com/1Panel-dev/1Panel/agent/app/repo"
"github.com/1Panel-dev/1Panel/agent/utils/common"
"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/buserr"
"github.com/1Panel-dev/1Panel/agent/constant"
"github.com/1Panel-dev/1Panel/agent/global"
"github.com/1Panel-dev/1Panel/agent/i18n"
"github.com/1Panel-dev/1Panel/agent/utils/common"
"github.com/1Panel-dev/1Panel/agent/utils/files"
"github.com/1Panel-dev/1Panel/agent/utils/ssl"
"github.com/go-acme/lego/v5/certcrypto"
"gorm.io/gorm"
)
type WebsiteCAService struct {
}
type IWebsiteCAService interface {
Import(req request.WebsiteCAImport) error
Upload(name string, archive []byte) error
Page(search request.WebsiteCASearch) (int64, []response.WebsiteCADTO, error)
Create(create request.WebsiteCACreate) (*request.WebsiteCACreate, error)
GetCA(id uint) (*response.WebsiteCADTO, error)
@@ -334,7 +340,7 @@ func (w WebsiteCAService) ObtainSSL(req request.WebsiteCAObtain) (*model.Website
websiteSSL.ExpireDate = cert.NotAfter
websiteSSL.StartDate = cert.NotBefore
websiteSSL.Type = cert.Issuer.CommonName
websiteSSL.Organization = rootCsr.Subject.Organization[0]
websiteSSL.Organization = strings.Join(rootCsr.Subject.Organization, ",")
websiteSSL.Status = constant.SSLReady
if req.Renew {
@@ -442,3 +448,150 @@ func (w WebsiteCAService) DownloadFile(id uint) (*os.File, error) {
}
return os.Open(path.Join(dir, fileName))
}
func (w WebsiteCAService) Upload(name string, archive []byte) error {
ca, err := parseCAArchive(archive)
if err != nil {
return err
}
return saveImportedCA(name, ca)
}
func saveImportedCA(name string, ca *model.WebsiteCA) error {
name = strings.TrimSpace(name)
if name == "" || len(name) > 128 || name == "." || name == ".." || strings.ContainsAny(name, "/\\\x00") {
return buserr.WithDetail("ErrInvalidParams", "name", nil)
}
exist, err := websiteCARepo.GetFirst(repo.WithByName(name))
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
if exist.ID > 0 {
return buserr.New("ErrNameIsExist")
}
ca.Name = name
return websiteCARepo.Create(context.Background(), ca)
}
// Read only the certificate and key in memory; never extract user-supplied paths.
func parseCAArchive(archive []byte) (*model.WebsiteCA, error) {
invalid := func() (*model.WebsiteCA, error) { return nil, buserr.New("ErrCAUpload") }
if len(archive) > 10<<20 {
return invalid()
}
reader, err := zip.NewReader(bytes.NewReader(archive), int64(len(archive)))
if err != nil || len(reader.File) > 16 {
return invalid()
}
contents := make(map[string][]byte)
for _, file := range reader.File {
if file.FileInfo().IsDir() {
continue
}
name := path.Base(file.Name)
if (name != "ca.crt" && name != "ca.key") || contents[name] != nil || file.UncompressedSize64 > 1<<20 {
return invalid()
}
stream, err := file.Open()
if err != nil {
return invalid()
}
data, err := io.ReadAll(io.LimitReader(stream, (1<<20)+1))
stream.Close()
if err != nil || len(data) > 1<<20 {
return invalid()
}
contents[name] = data
}
return parseAndValidateRootCA(contents["ca.crt"], contents["ca.key"])
}
func parseAndValidateRootCA(certificate, privateKey []byte) (*model.WebsiteCA, error) {
invalid := func() (*model.WebsiteCA, error) { return nil, buserr.New("ErrCAImport") }
if len(certificate) > 1<<20 || len(privateKey) > 1<<20 {
return invalid()
}
pair, err := tls.X509KeyPair(certificate, privateKey)
if err != nil || len(pair.Certificate) != 1 {
return invalid()
}
cert := pair.Leaf
now := time.Now()
if cert == nil || !cert.IsCA || !cert.BasicConstraintsValid || cert.KeyUsage&x509.KeyUsageCertSign == 0 ||
!bytes.Equal(cert.RawIssuer, cert.RawSubject) || cert.CheckSignatureFrom(cert) != nil ||
now.Before(cert.NotBefore) || !now.Before(cert.NotAfter) {
return invalid()
}
ca := &model.WebsiteCA{CSR: string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: cert.Raw}))}
// Normalize keys to the formats used by ObtainSSL, including PKCS#8 uploads.
switch key := pair.PrivateKey.(type) {
case *ecdsa.PrivateKey:
bits := key.Curve.Params().BitSize
if bits != 256 && bits != 384 {
return invalid()
}
der, err := x509.MarshalECPrivateKey(key)
if err != nil {
return invalid()
}
ca.KeyType = fmt.Sprintf("EC%d", bits)
ca.PrivateKey = string(pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: der}))
case *rsa.PrivateKey:
bits := key.N.BitLen()
if bits != 2048 && bits != 3072 && bits != 4096 && bits != 8192 {
return invalid()
}
ca.KeyType = fmt.Sprintf("RSA%d", bits)
ca.PrivateKey = string(pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)}))
default:
return invalid()
}
return ca, nil
}
func (w WebsiteCAService) Import(req request.WebsiteCAImport) error {
certificate, privateKey := []byte(req.Certificate), []byte(req.PrivateKey)
switch req.Type {
case "local":
var err error
certificate, err = readCAImportFile(req.CertificatePath)
if err != nil {
return err
}
privateKey, err = readCAImportFile(req.PrivateKeyPath)
if err != nil {
return err
}
case "paste":
default:
return buserr.New("ErrCAImport")
}
ca, err := parseAndValidateRootCA(certificate, privateKey)
if err != nil {
return err
}
return saveImportedCA(req.Name, ca)
}
func readCAImportFile(name string) ([]byte, error) {
info, err := os.Stat(name)
if err != nil {
return nil, err
}
if !info.Mode().IsRegular() || info.Size() > 1<<20 {
return nil, buserr.New("ErrCAImport")
}
file, err := os.Open(name)
if err != nil {
return nil, err
}
defer file.Close()
content, err := io.ReadAll(io.LimitReader(file, (1<<20)+1))
if err != nil {
return nil, err
}
if len(content) > 1<<20 {
return nil, buserr.New("ErrCAImport")
}
return content, nil
}
+1 -1
View File
@@ -1454,10 +1454,10 @@ func getWebsiteDomains(domains []request.WebsiteDomain, defaultHTTPPort, default
return
}
domainModel.Domain = strings.ToLower(domainModel.Domain)
domainModel.Port = domain.Port
if domain.Port == 0 {
domain.Port = defaultHTTPPort
}
domainModel.Port = domain.Port
domainModel.SSL = domain.SSL
domainModel.WebsiteID = websiteID
domainModels = append(domainModels, domainModel)
+5 -14
View File
@@ -53,13 +53,6 @@
"formatZH": "添加 Ollama 模型重试 [name]",
"formatEN": "re-add Ollama model [name]"
},
"/ai/ollama/model/sync": {
"bodyKeys": [],
"paramKeys": [],
"beforeFunctions": [],
"formatZH": "同步 Ollama 模型列表",
"formatEN": "sync Ollama model list"
},
"/ai/tensorrt/create": {
"bodyKeys": [
"name"
@@ -3740,13 +3733,11 @@
"formatEN": "Create runtime [name]"
},
"/runtimes/del": {
"bodyKeys": [
"id"
],
"bodyKeys": [],
"paramKeys": [],
"beforeFunctions": [],
"formatZH": "删除运行环境 [id]",
"formatEN": "Delete runtime [id]"
"formatZH": "删除运行环境 [name]",
"formatEN": "Delete runtime [name]"
},
"/runtimes/operate": {
"bodyKeys": [
@@ -4582,13 +4573,13 @@
},
"/websites/php/version": {
"bodyKeys": [
"websiteId"
"websiteID"
],
"paramKeys": [],
"beforeFunctions": [
{
"input_column": "id",
"input_value": "websiteId",
"input_value": "websiteID",
"isList": false,
"db": "websites",
"output_column": "primary_domain",
+5 -1
View File
@@ -77,7 +77,11 @@ func Run() {
global.LOG.Errorf("generate random hour failed: %v", err)
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())
}
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)
}
}
}
+38
View File
@@ -773,3 +773,41 @@ VMSnapshotRecoverTask: "Recover snapshot [{{ .snapshot }}] for virtual machine [
VMSnapshotRecoverStep: "Recover virtual machine snapshot [{{ .snapshot }}]"
VMSnapshotDeleteTask: "Delete snapshot [{{ .snapshot }}] for virtual machine [{{ .vm }}]"
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."
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."
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"
+38
View File
@@ -773,3 +773,41 @@ VMSnapshotRecoverTask: "Restaurar instantánea [{{ .snapshot }}] para la máquin
VMSnapshotRecoverStep: "Restaurar instantánea de máquina virtual [{{ .snapshot }}]"
VMSnapshotDeleteTask: "Eliminar instantánea [{{ .snapshot }}] de la máquina virtual [{{ .vm }}]"
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."
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."
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"
+38
View File
@@ -774,3 +774,41 @@ VMSnapshotRecoverTask: "بازیابی اسنپ‌شات [{{ .snapshot }}] بر
VMSnapshotRecoverStep: "بازیابی اسنپ‌شات ماشین مجازی [{{ .snapshot }}]"
VMSnapshotDeleteTask: "حذف اسنپ‌شات [{{ .snapshot }}] برای ماشین مجازی [{{ .vm }}]"
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."
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 غیرفعال است؛ پشتیبان تنظیمات اصلی حفظ شد."
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 }} کانتینر ناموفق بود؛ گزارش وظیفه را مشاهده کنید"
+38
View File
@@ -773,3 +773,41 @@ VMSnapshotRecoverTask: "仮想マシン [{{ .vm }}] のスナップショット
VMSnapshotRecoverStep: "仮想マシンのスナップショット [{{ .snapshot }}] を復元"
VMSnapshotDeleteTask: "仮想マシン [{{ .vm }}] のスナップショット [{{ .snapshot }}] を削除"
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."
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 は無効のままで、元の設定のバックアップは保持しています。"
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 }} コンテナの確認に失敗しました。タスクログを確認してください"
+38
View File
@@ -773,3 +773,41 @@ VMSnapshotRecoverTask: "가상 머신 [{{ .vm }}] 스냅샷 [{{ .snapshot }}]
VMSnapshotRecoverStep: "가상 머신 스냅샷 [{{ .snapshot }}] 복구"
VMSnapshotDeleteTask: "가상 머신 [{{ .vm }}] 스냅샷 [{{ .snapshot }}] 삭제"
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."
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는 비활성 상태이며 원본 설정 백업은 유지됩니다."
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 }}개 확인에 실패했습니다. 작업 로그를 확인하세요"
+38
View File
@@ -764,3 +764,41 @@ VMSnapshotRecoverTask: "ກູ້ຄືນສະແນັບຊັອດ [{{ .sn
VMSnapshotRecoverStep: "ກູ້ຄືນສະແນັບຊັອດເຄື່ອງສະເໝືອນ [{{ .snapshot }}]"
VMSnapshotDeleteTask: "ລຶບສະແນັບຊັອດ [{{ .snapshot }}] ຂອງເຄື່ອງສະເໝືອນ [{{ .vm }}]"
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."
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 ຍັງປິດ; ສຳຮອງການຕັ້ງຄ່າເດີມຍັງຖືກເກັບໄວ້."
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 }} ຄອນເທນເນີລົ້ມເຫຼວ; ກະລຸນາເບິ່ງບັນທຶກວຽກ"
+38
View File
@@ -773,3 +773,41 @@ VMSnapshotRecoverTask: "Pulihkan syot kilat [{{ .snapshot }}] untuk mesin maya [
VMSnapshotRecoverStep: "Pulihkan syot kilat mesin maya [{{ .snapshot }}]"
VMSnapshotDeleteTask: "Padam syot kilat [{{ .snapshot }}] untuk mesin maya [{{ .vm }}]"
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."
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."
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"
+38
View File
@@ -773,3 +773,41 @@ VMSnapshotRecoverTask: "Restaurar snapshot [{{ .snapshot }}] da máquina virtual
VMSnapshotRecoverStep: "Restaurar snapshot da máquina virtual [{{ .snapshot }}]"
VMSnapshotDeleteTask: "Excluir snapshot [{{ .snapshot }}] da máquina virtual [{{ .vm }}]"
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."
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."
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"
+38
View File
@@ -773,3 +773,41 @@ VMSnapshotRecoverTask: "Восстановить снимок [{{ .snapshot }}]
VMSnapshotRecoverStep: "Восстановить снимок виртуальной машины [{{ .snapshot }}]"
VMSnapshotDeleteTask: "Удалить снимок [{{ .snapshot }}] виртуальной машины [{{ .vm }}]"
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."
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 отключён; исходная резервная копия конфигурации сохранена."
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 }}. Подробности в журнале задачи"
+38
View File
@@ -773,3 +773,41 @@ VMSnapshotRecoverTask: "Sanal makine [{{ .vm }}] anlık görüntüsü [{{ .snaps
VMSnapshotRecoverStep: "Sanal makine anlık görüntüsü [{{ .snapshot }}] geri yükle"
VMSnapshotDeleteTask: "Sanal makine [{{ .vm }}] anlık görüntüsü [{{ .snapshot }}] sil"
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."
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."
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"
+38
View File
@@ -773,3 +773,41 @@ VMSnapshotRecoverTask: "復原虛擬機 [{{ .vm }}] 快照 [{{ .snapshot }}]"
VMSnapshotRecoverStep: "復原虛擬機快照 [{{ .snapshot }}]"
VMSnapshotDeleteTask: "刪除虛擬機 [{{ .vm }}] 快照 [{{ .snapshot }}]"
VMSnapshotDeleteStep: "刪除虛擬機快照 [{{ .snapshot }}]"
ErrCAUpload: "請上傳包含有效自簽根憑證 ca.crt 及匹配私鑰 ca.key 的 ZIP 檔案(最大 10 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: "閘道已恢復,飛書保持停用;原設定備份已保留。"
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 }} 個容器檢查失敗,請查看任務日誌"
+38
View File
@@ -773,3 +773,41 @@ VMSnapshotRecoverTask: "恢复虚拟机 [{{ .vm }}] 快照 [{{ .snapshot }}]"
VMSnapshotRecoverStep: "恢复虚拟机快照 [{{ .snapshot }}]"
VMSnapshotDeleteTask: "删除虚拟机 [{{ .vm }}] 快照 [{{ .snapshot }}]"
VMSnapshotDeleteStep: "删除虚拟机快照 [{{ .snapshot }}]"
ErrCAUpload: "请上传包含有效自签根证书 ca.crt 及匹配私钥 ca.key 的 ZIP 文件(最大 10 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: "网关已恢复,飞书保持停用;原配置备份已保留。"
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 }} 个容器检查失败,请查看任务日志"
+3
View File
@@ -117,6 +117,9 @@ func agentDBMigrations() []*gormigrate.Migration {
migrations.AddVLLMSampleInterval,
migrations.AddNativeAgentInstalls,
migrations.AddQuickDirectory,
migrations.AddLocalModelAccountLink,
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
},
}
+44
View File
@@ -1895,3 +1895,47 @@ var AddQuickDirectory = &gormigrate.Migration{
return tx.AutoMigrate(&model.QuickDirectory{})
},
}
var AddLocalModelAccountLink = &gormigrate.Migration{
ID: "20261023-add-local-model-account-link",
Migrate: func(tx *gorm.DB) error {
return tx.AutoMigrate(&model.AgentAccount{})
},
}
var AddOllamaModelAccountSync = &gormigrate.Migration{
ID: "20261026-add-ollama-model-account-sync",
Migrate: func(tx *gorm.DB) error {
if err := tx.AutoMigrate(&model.AppInstall{}); err != nil {
return err
}
var accounts []model.AgentAccount
if err := tx.Where("provider = ? AND app_install_id > 0", "ollama").Find(&accounts).Error; err != nil {
return err
}
for _, account := range accounts {
var install model.AppInstall
if err := tx.Preload("App").First(&install, account.AppInstallID).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
continue
}
return err
}
if install.App.Key != "ollama" {
continue
}
addressType := "custom"
if strings.TrimRight(account.BaseURL, "/") == fmt.Sprintf("http://%s:11434/v1", install.ContainerName) {
addressType = "container"
}
if err := tx.Model(&model.AppInstall{}).Where("id = ?", account.AppInstallID).Updates(map[string]interface{}{
"sync_model_account": true,
"model_account_base_url_type": addressType,
"model_account_base_url": account.BaseURL,
}).Error; err != nil {
return err
}
}
return nil
},
}
+61
View File
@@ -0,0 +1,61 @@
package router
import (
"bytes"
"encoding/json"
"io"
"net"
"net/http"
"github.com/1Panel-dev/1Panel/agent/global"
"github.com/1Panel-dev/1Panel/agent/middleware"
"github.com/1Panel-dev/1Panel/agent/utils/mcpapi"
"github.com/1Panel-dev/1Panel/agent/utils/mcpruntime"
"github.com/gin-gonic/gin"
)
func registerMCPRuntime(engine *gin.Engine) {
controller := mcpruntime.New()
engine.POST("/internal/mcp/docker", mcpDockerHandler(controller))
engine.GET(mcpapi.CapabilitiesPath, middleware.Certificate(), mcpCapabilitiesHandler())
}
func mcpCapabilitiesHandler() gin.HandlerFunc {
return func(ctx *gin.Context) {
if !mcpapi.TrustedTransport(ctx.Request) {
ctx.AbortWithStatus(http.StatusForbidden)
return
}
ctx.JSON(http.StatusOK, gin.H{"contractVersion": mcpapi.ContractVersion, "features": []string{
"operation-bound-identity", "stable-node-target", "resource-guards",
}})
}
}
func mcpDockerHandler(controller *mcpruntime.Controller) 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, 8193))
if err != nil || len(data) > 8192 {
ctx.AbortWithStatus(http.StatusBadRequest)
return
}
var req mcpruntime.Request
decoder := json.NewDecoder(bytes.NewReader(data))
decoder.DisallowUnknownFields()
if decoder.Decode(&req) != nil || decoder.Decode(new(any)) != io.EOF {
ctx.AbortWithStatus(http.StatusBadRequest)
return
}
response, err := controller.Handle(ctx.Request.Context(), req)
if err != nil {
ctx.JSON(http.StatusBadRequest, mcpruntime.Response{Error: err.Error()})
return
}
ctx.JSON(http.StatusOK, response)
}
}
+1
View File
@@ -16,6 +16,7 @@ var (
func Routers() *gin.Engine {
Router = gin.Default()
Router.Use(i18n.UseI18n())
registerMCPRuntime(Router)
PrivateGroup := Router.Group("/api/v2")
if !global.IsMaster {
+14 -2
View File
@@ -10,10 +10,15 @@ import (
"github.com/1Panel-dev/1Panel/agent/app/api/v2/helper"
"github.com/1Panel-dev/1Panel/agent/global"
"github.com/1Panel-dev/1Panel/agent/utils/cmd"
"github.com/1Panel-dev/1Panel/agent/utils/mcpapi"
"github.com/1Panel-dev/1Panel/agent/utils/xpack"
"github.com/gin-gonic/gin"
)
var loadNodeProxyID = func() (string, error) {
return cmd.NewCommandMgr(cmd.WithTimeout(20*time.Second)).RunWithStdout("cat", "/etc/1panel/.nodeProxyID")
}
func Certificate() gin.HandlerFunc {
return func(c *gin.Context) {
if global.IsMaster {
@@ -25,16 +30,23 @@ func Certificate() gin.HandlerFunc {
return
}
conn := c.Request.Header.Get("Connection")
if conn == "Upgrade" {
mcpRequest, _ := mcpapi.Check(c.Request)
mcpTransport := mcpRequest || c.Request.URL.Path == mcpapi.CapabilitiesPath
if conn == "Upgrade" && !mcpTransport {
c.Next()
return
}
masterProxyID := c.Request.Header.Get("Proxy-Id")
proxyID, err := cmd.NewCommandMgr(cmd.WithTimeout(20*time.Second)).RunWithStdout("cat", "/etc/1panel/.nodeProxyID")
proxyID, err := loadNodeProxyID()
if mcpTransport && (err != nil || strings.TrimSpace(proxyID) == "" || strings.TrimSpace(proxyID) != strings.TrimSpace(masterProxyID)) {
c.AbortWithStatus(http.StatusForbidden)
return
}
if err == nil && len(proxyID) != 0 && strings.TrimSpace(proxyID) != strings.TrimSpace(masterProxyID) {
helper.InternalServer(c, fmt.Errorf("err proxy id"))
return
}
c.Request = mcpapi.BindVerifiedNodeTransport(c.Request)
c.Next()
}
}
+2 -1
View File
@@ -18,7 +18,7 @@ func (a *AIToolsRouter) InitRouter(Router *gin.RouterGroup) {
aiToolsRouter.POST("/ollama/model", baseApi.CreateOllamaModel)
aiToolsRouter.POST("/ollama/model/recreate", baseApi.RecreateOllamaModel)
aiToolsRouter.POST("/ollama/model/search", baseApi.SearchOllamaModel)
aiToolsRouter.POST("/ollama/model/sync", baseApi.SyncOllamaModel)
aiToolsRouter.POST("/ollama/account/sync", baseApi.SyncOllamaModelAccount)
aiToolsRouter.POST("/ollama/model/load", baseApi.LoadOllamaModelDetail)
aiToolsRouter.POST("/ollama/model/del", baseApi.DeleteOllamaModel)
aiToolsRouter.POST("/domain/bind", baseApi.BindDomain)
@@ -91,6 +91,7 @@ func (a *AIToolsRouter) InitRouter(Router *gin.RouterGroup) {
aiToolsRouter.POST("/agents/agent/md/update", baseApi.UpdateAgentRoleMarkdownFile)
aiToolsRouter.POST("/agents/channel/feishu/get", baseApi.GetAgentFeishuConfig)
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/update", baseApi.UpdateAgentTelegramConfig)
aiToolsRouter.POST("/agents/channel/discord/get", baseApi.GetAgentDiscordConfig)
+3
View File
@@ -16,6 +16,9 @@ func (s *ContainerRouter) InitRouter(Router *gin.RouterGroup) {
baRouter.POST("", baseApi.ContainerCreate)
baRouter.POST("/update", baseApi.ContainerUpdate)
baRouter.POST("/upgrade", baseApi.ContainerUpgrade)
baRouter.GET("/image-updates", baseApi.GetContainerImageCheckUpdateStatus)
baRouter.POST("/image-updates/check", baseApi.ContainerImageCheckUpdate)
baRouter.POST("/image-updates/schedule", baseApi.SetContainerImageCheckUpdateSchedule)
baRouter.POST("/info", baseApi.ContainerInfo)
baRouter.POST("/search", baseApi.SearchContainer)
baRouter.POST("/list", baseApi.ListContainer)
+2
View File
@@ -13,6 +13,8 @@ func (a *WebsiteCARouter) InitRouter(Router *gin.RouterGroup) {
baseApi := v2.ApiGroupApp.BaseApi
{
groupRouter.POST("/import", baseApi.ImportWebsiteCA)
groupRouter.POST("/upload", baseApi.UploadWebsiteCA)
groupRouter.POST("/search", baseApi.PageWebsiteCA)
groupRouter.POST("", baseApi.CreateWebsiteCA)
groupRouter.POST("/del", baseApi.DeleteWebsiteCA)
+1
View File
@@ -104,6 +104,7 @@ func pullComposeImages(filePath string, forcePull bool, task *task.Task, project
if err != nil {
return err
}
defer dockerCLi.Close()
for _, image := range images {
if !forcePull {
if exist, _ := dockerCLi.ImageExists(image); exist {
+4 -2
View File
@@ -345,10 +345,12 @@ func PullImage(imageName string) error {
if authStr, ok := loadRegistryAuthFromDockerConfig(imageName); ok {
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 nil
defer out.Close()
return handlePullImageProcess(out, nil)
}
func loadRegistryAuthFromDockerConfig(imageName string) (string, bool) {
+266
View File
@@ -0,0 +1,266 @@
// Package dockerhub checks Docker Hub metadata without downloading image layers.
package dockerhub
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"regexp"
"sort"
"strconv"
"strings"
"time"
"github.com/distribution/reference"
)
const registryURL = "https://registry-1.docker.io"
// Reference only accepts tagged Docker Hub references. Digest pins are intentional.
func Reference(raw string) (string, string, bool) {
if strings.HasPrefix(raw, "sha256:") {
return "", "", false
}
ref, err := reference.ParseNormalizedNamed(raw)
if err != nil || reference.Domain(ref) != "docker.io" {
return "", "", false
}
if _, pinned := ref.(reference.Digested); pinned {
return "", "", false
}
tagged := reference.TagNameOnly(ref).(reference.NamedTagged)
return reference.Path(ref), tagged.Tag(), true
}
type Client struct {
HTTP *http.Client
repository string
token string
}
type Platform struct{ OS, Architecture, Variant string }
type Target struct{ Tag, Digest, ConfigDigest string }
func New(ctx context.Context, repository, username, password string) (*Client, error) {
c := &Client{HTTP: &http.Client{Timeout: 30 * time.Second}, repository: repository}
endpoint := "https://auth.docker.io/token?service=registry.docker.io&scope=" + url.QueryEscape("repository:"+repository+":pull")
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return nil, err
}
if username != "" {
req.SetBasicAuth(username, password)
}
resp, err := c.HTTP.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("Docker Hub authentication: HTTP %d", resp.StatusCode)
}
var body struct {
Token string `json:"token"`
AccessToken string `json:"access_token"`
}
if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&body); err != nil {
return nil, err
}
c.token = body.Token
if c.token == "" {
c.token = body.AccessToken
}
if c.token == "" {
return nil, fmt.Errorf("Docker Hub returned an empty token")
}
return c, nil
}
func (c *Client) get(ctx context.Context, endpoint string, result interface{}) (http.Header, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+c.token)
req.Header.Set("Accept", "application/vnd.oci.image.index.v1+json, application/vnd.docker.distribution.manifest.list.v2+json, application/vnd.oci.image.manifest.v1+json, application/vnd.docker.distribution.manifest.v2+json")
resp, err := c.HTTP.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("Docker Hub metadata: HTTP %d", resp.StatusCode)
}
err = json.NewDecoder(io.LimitReader(resp.Body, 16<<20)).Decode(result)
return resp.Header, err
}
func (c *Client) Tags(ctx context.Context) ([]string, error) {
endpoint := registryURL + "/v2/" + c.repository + "/tags/list?n=1000"
var tags []string
for page := 0; page < 100; page++ {
var body struct {
Tags []string `json:"tags"`
}
headers, err := c.get(ctx, endpoint, &body)
if err != nil {
return nil, err
}
tags = append(tags, body.Tags...)
link := headers.Get("Link")
if link == "" {
return tags, nil
}
start, end := strings.Index(link, "<"), strings.Index(link, ">")
if start < 0 || end <= start {
return nil, fmt.Errorf("invalid Docker Hub pagination")
}
base, _ := url.Parse(registryURL)
next, err := base.Parse(link[start+1 : end])
if err != nil || next.Scheme != "https" || next.Host != base.Host || next.Path != "/v2/"+c.repository+"/tags/list" {
return nil, fmt.Errorf("invalid Docker Hub pagination URL")
}
endpoint = next.String()
}
return nil, fmt.Errorf("Docker Hub tag pagination limit exceeded")
}
type manifest struct {
Config struct {
Digest string `json:"digest"`
} `json:"config"`
Manifests []struct {
Digest string `json:"digest"`
Platform struct {
OS string `json:"os"`
Architecture string `json:"architecture"`
Variant string `json:"variant"`
} `json:"platform"`
} `json:"manifests"`
}
func (c *Client) Resolve(ctx context.Context, tag string, platform Platform) (Target, bool, error) {
var m manifest
headers, err := c.get(ctx, registryURL+"/v2/"+c.repository+"/manifests/"+url.PathEscape(tag), &m)
if err != nil {
return Target{}, false, err
}
digest := headers.Get("Docker-Content-Digest")
if len(m.Manifests) > 0 {
digest = ""
for _, item := range m.Manifests {
if item.Platform.OS == platform.OS && item.Platform.Architecture == platform.Architecture && normalizedVariant(platform.Architecture, item.Platform.Variant) == normalizedVariant(platform.Architecture, platform.Variant) {
digest = item.Digest
break
}
}
if digest == "" {
return Target{}, false, nil
}
m = manifest{}
if _, err := c.get(ctx, registryURL+"/v2/"+c.repository+"/manifests/"+url.PathEscape(digest), &m); err != nil {
return Target{}, false, err
}
}
if m.Config.Digest == "" {
return Target{}, false, fmt.Errorf("unsupported Docker Hub manifest")
}
// Validate single-platform manifests too; the config digest is the Docker Image ID.
var config struct {
OS string `json:"os"`
Architecture string `json:"architecture"`
Variant string `json:"variant"`
}
if _, err := c.get(ctx, registryURL+"/v2/"+c.repository+"/blobs/"+url.PathEscape(m.Config.Digest), &config); err != nil {
return Target{}, false, err
}
if config.OS != platform.OS || config.Architecture != platform.Architecture || (platform.Variant != "" && config.Variant != "" && config.Variant != platform.Variant) {
return Target{}, false, nil
}
return Target{Tag: tag, Digest: digest, ConfigDigest: m.Config.Digest}, true, nil
}
var versionPattern = regexp.MustCompile(`^v?(\d+)\.(\d+)(?:\.(\d+))?(-[a-zA-Z][a-zA-Z0-9_.-]*)?$`)
var prereleasePattern = regexp.MustCompile(`(?i)(^|[-_.])(alpha|beta|rc|dev|nightly|edge|test|unstable|preview)([0-9_.-]|$)`)
type version struct {
numbers [3]uint64
parts int
variant string
}
func parseVersion(tag string) (version, bool) {
m := versionPattern.FindStringSubmatch(tag)
if m == nil || prereleasePattern.MatchString(m[4]) {
return version{}, false
}
v := version{parts: 2, variant: m[4]}
for i := 0; i < 3; i++ {
if m[i+1] != "" {
n, err := strconv.ParseUint(m[i+1], 10, 64)
if err != nil {
return version{}, false
}
v.numbers[i] = n
}
}
if m[3] != "" {
v.parts = 3
}
return v, true
}
func greater(a, b version) bool {
for i := 0; i < 3; i++ {
if a.numbers[i] != b.numbers[i] {
return a.numbers[i] > b.numbers[i]
}
}
return false
}
// Candidates preserves the current tag's variant and precision. Unknown/floating
// tags are checked only for content changes, never guessed from upload dates.
func Candidates(current string, tags []string) []string {
baseline, ok := parseVersion(current)
if !ok {
return []string{current}
}
type candidate struct {
tag string
version version
}
candidates := []candidate{}
seen := map[string]bool{}
for _, tag := range tags {
v, valid := parseVersion(tag)
if valid && v.parts == baseline.parts && v.variant == baseline.variant && greater(v, baseline) && !seen[tag] {
candidates = append(candidates, candidate{tag: tag, version: v})
seen[tag] = true
}
}
sort.Slice(candidates, func(i, j int) bool {
a, b := candidates[i], candidates[j]
if a.version.numbers == b.version.numbers {
return a.tag < b.tag
}
return greater(a.version, b.version)
})
result := make([]string, 0, len(candidates)+1)
for _, candidate := range candidates {
result = append(result, candidate.tag)
}
return append(result, current)
}
func NeedsTags(tag string) bool { _, ok := parseVersion(tag); return ok }
func normalizedVariant(architecture, variant string) string {
if architecture == "arm64" && variant == "" {
return "v8"
}
return variant
}
+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) {
lines := make([]string, 0, len(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))
} else if shouldUseSingleQuotes(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 {
if d, err := strconv.Atoi(v); err == nil && !isStartWithZero(v) {
if d, ok := canonicalInt(v); ok {
return fmt.Sprintf(`%s=%d`, k, d)
} else if shouldUseSingleQuotes(v) {
return fmt.Sprintf(`%s='%s'`, k, v)
@@ -107,11 +107,14 @@ func GetEnvValueByKey(envPath, key string) (string, error) {
return value, nil
}
func isStartWithZero(value string) bool {
if strings.HasPrefix(value, "0") && len(value) > 1 {
return true
// canonicalInt reports whether value is an integer that formats back to the
// same string, so values such as "007", "+1" or "-01" keep their exact text.
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 {
+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")
}
}
+67
View File
@@ -0,0 +1,67 @@
package mcpapi
import (
"errors"
"net/http"
"github.com/1Panel-dev/1Panel/agent/buserr"
)
const (
BusinessCodeHeader = "X-Panel-MCP-Business-Code"
CodeMySQLStopped = "MCP_MYSQL_STOPPED"
CodeMySQLConnectionFailed = "MCP_MYSQL_CONNECTION_FAILED"
)
type codedError struct {
code string
message string
}
func (e *codedError) Error() string { return e.message }
func allowedBusinessCode(code string) string {
switch code {
case "ErrAliasIsExist", "ErrDomainIsUsed", "ErrPortInUsed", "ErrPortInOtherApp", "ErrPortExist", "ErrRecordExist", "ErrDatabaseIsExist", CodeMySQLStopped, CodeMySQLConnectionFailed:
return code
default:
return ""
}
}
func BusinessErrorCode(err error) string {
var coded *codedError
if errors.As(err, &coded) && coded != nil {
return allowedBusinessCode(coded.code)
}
var business buserr.BusinessError
if errors.As(err, &business) {
return allowedBusinessCode(business.Msg)
}
var businessPointer *buserr.BusinessError
if errors.As(err, &businessPointer) && businessPointer != nil {
return allowedBusinessCode(businessPointer.Msg)
}
return ""
}
func NewBusinessError(code, message string) error {
return &codedError{code: allowedBusinessCode(code), message: message}
}
func SanitizeBusinessError(err error, safeMessage string) error {
return NewBusinessError(BusinessErrorCode(err), safeMessage)
}
func SetBusinessErrorHeader(request *http.Request, header http.Header, err error) {
if request == nil || header == nil {
return
}
if present, trusted := Check(request); !present || !trusted {
return
}
header.Del(BusinessCodeHeader)
if code := BusinessErrorCode(err); code != "" {
header.Set(BusinessCodeHeader, code)
}
}
+79
View File
@@ -0,0 +1,79 @@
package mcpapi
import (
"context"
"net"
"net/http"
"strconv"
"github.com/1Panel-dev/1Panel/agent/global"
"github.com/google/uuid"
)
const (
InvocationHeader = "X-Panel-MCP-Invocation"
NodeHeader = "X-Panel-MCP-Node"
MethodHeader = "X-Panel-MCP-Method"
PathHeader = "X-Panel-MCP-Path"
CapabilitiesPath = "/api/v2/internal/mcp/capabilities"
ContractVersion = 4
)
type verifiedNodeTransportKey struct{}
func BindVerifiedNodeTransport(request *http.Request) *http.Request {
if request.TLS == nil || !request.TLS.HandshakeComplete || len(request.TLS.VerifiedChains) == 0 {
return request
}
return request.WithContext(context.WithValue(request.Context(), verifiedNodeTransportKey{}, request.TLS))
}
func validNodeID(id string) bool {
if id == "local" {
return true
}
value, err := strconv.ParseUint(id, 10, 64)
return err == nil && value != 0 && strconv.FormatUint(value, 10) == id
}
func TrustedTransport(request *http.Request) bool {
if global.IsMaster {
local, ok := request.Context().Value(http.LocalAddrContextKey).(net.Addr)
return ok && local.Network() == "unix"
}
verified := request.Context().Value(verifiedNodeTransportKey{})
return request.TLS != nil && verified == request.TLS
}
func Check(request *http.Request) (present, trusted bool) {
present = false
for _, name := range []string{InvocationHeader, NodeHeader, MethodHeader, PathHeader} {
if len(request.Header.Values(name)) != 0 {
present = true
}
}
if !present {
return false, false
}
for _, name := range []string{InvocationHeader, NodeHeader, MethodHeader, PathHeader} {
if len(request.Header.Values(name)) != 1 {
return true, false
}
}
if _, err := uuid.Parse(request.Header.Get(InvocationHeader)); err != nil {
return true, false
}
nodeID := request.Header.Get(NodeHeader)
if !validNodeID(nodeID) || request.Header.Get(MethodHeader) != request.Method || request.Header.Get(PathHeader) != request.URL.Path || request.URL.RawQuery != "" {
return true, false
}
return true, TrustedTransport(request) && ((nodeID == "local") == global.IsMaster)
}
func NodeID(request *http.Request) (string, bool) {
_, trusted := Check(request)
if !trusted {
return "", false
}
return request.Header.Get(NodeHeader), true
}
+530
View File
@@ -0,0 +1,530 @@
package mcpruntime
import (
"bytes"
"context"
"encoding/binary"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
"regexp"
"runtime"
"strings"
"sync"
"time"
"github.com/1Panel-dev/1Panel/agent/utils/docker"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/image"
"github.com/docker/docker/api/types/network"
"github.com/docker/docker/api/types/system"
"github.com/docker/docker/client"
"github.com/docker/docker/errdefs"
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
)
const (
ImageRepository = "1panel/1panel-mcp-server"
ContainerName = "1panel-mcp"
previousName = "1panel-mcp-previous"
SocketDirectory = "/etc/1panel/mcp-runtime"
CoreSocket = SocketDirectory + "/core.sock"
adapterBinary = "/usr/local/bin/mcp-1panel"
ownerLabel = "io.1panel.mcp.managed"
imageLabel = "io.1panel.mcp.image"
portLabel = "io.1panel.mcp.port"
versionLabel = "io.1panel.mcp.version"
maxCatalogBytes = 2 << 20
maxLogBytes = 64 << 10
contractVersion = 5
protocol = "streamable-http"
)
var semanticVersion = regexp.MustCompile(`^v?(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(?:-[0-9A-Za-z]+(?:[.-][0-9A-Za-z]+)*)?$`)
var digestPattern = regexp.MustCompile(`^sha256:[a-f0-9]{64}$`)
var ErrUnsupported = errors.New("MCP Docker runtime requires a local rootful Linux Docker daemon without user namespace remapping")
type Request struct {
Operation string `json:"operation"`
Image string `json:"image"`
Port int `json:"port"`
Enabled bool `json:"enabled"`
Restart bool `json:"restart"`
ImageID string `json:"imageID,omitempty"`
}
type State struct {
Installed bool `json:"installed"`
Status string `json:"status"`
Ready bool `json:"ready"`
Reason string `json:"reason,omitempty"`
AdapterVersion string `json:"adapterVersion,omitempty"`
Image string `json:"image,omitempty"`
ImageID string `json:"imageID,omitempty"`
}
type Response struct {
State *State `json:"state,omitempty"`
Catalog json.RawMessage `json:"catalog,omitempty"`
Logs string `json:"logs,omitempty"`
Error string `json:"error,omitempty"`
ImageID string `json:"imageID,omitempty"`
}
type DockerClient interface {
Close() error
DaemonHost() string
Info(context.Context) (system.Info, error)
ImageInspect(context.Context, string, ...client.ImageInspectOption) (image.InspectResponse, error)
ImagePull(context.Context, string, image.PullOptions) (io.ReadCloser, error)
ContainerInspect(context.Context, string) (container.InspectResponse, error)
ContainerCreate(context.Context, *container.Config, *container.HostConfig, *network.NetworkingConfig, *ocispec.Platform, string) (container.CreateResponse, error)
ContainerStart(context.Context, string, container.StartOptions) error
ContainerStop(context.Context, string, container.StopOptions) error
ContainerRemove(context.Context, string, container.RemoveOptions) error
ContainerRename(context.Context, string, string) error
ContainerLogs(context.Context, string, container.LogsOptions) (io.ReadCloser, error)
}
type metadata struct {
Status string `json:"status"`
AdapterVersion string `json:"adapterVersion"`
ContractVersion int `json:"contractVersion"`
Protocol string `json:"protocol"`
}
type verifiedImage struct {
metadata metadata
catalog []byte
}
type Controller struct {
newClient func() (DockerClient, error)
platform string
health func(context.Context, int) (metadata, error)
checkDirectory func() error
mutation chan struct{}
probes chan struct{}
cacheMu sync.Mutex
cache map[string]verifiedImage
}
func New() *Controller {
return newController(func() (DockerClient, error) { return docker.NewDockerClient() })
}
func newController(factory func() (DockerClient, error)) *Controller {
return &Controller{newClient: factory, platform: runtime.GOOS, health: readHealth,
checkDirectory: checkSocketDirectory, mutation: make(chan struct{}, 1), probes: make(chan struct{}, 8), cache: make(map[string]verifiedImage)}
}
func ValidateImage(value string) error {
if len(value) > 180 {
return errors.New("invalid MCP Docker image")
}
if strings.HasPrefix(value, ImageRepository+"@") && digestPattern.MatchString(strings.TrimPrefix(value, ImageRepository+"@")) {
return nil
}
if strings.HasPrefix(value, ImageRepository+":") && semanticVersion.MatchString(strings.TrimPrefix(value, ImageRepository+":")) {
return nil
}
return errors.New("MCP Docker image must use the official repository with a fixed semantic version or SHA256 digest")
}
func (c *Controller) Handle(ctx context.Context, req Request) (Response, error) {
switch req.Operation {
case "prepare", "catalog", "candidate_catalog", "status", "apply", "install", "rollback", "rollback_absent", "pull", "logs":
default:
return Response{}, errors.New("invalid MCP Docker operation")
}
if err := ValidateImage(req.Image); err != nil {
return Response{}, err
}
if req.Port < 1024 || req.Port > 65535 {
return Response{}, errors.New("MCP listen port must be between 1024 and 65535")
}
timeout := 60 * time.Second
if req.Operation == "catalog" || req.Operation == "candidate_catalog" || req.Operation == "logs" || req.Operation == "prepare" {
timeout = 5 * time.Second
}
if req.Operation == "status" {
timeout = 8 * time.Second
}
if req.Operation == "pull" {
timeout = 4 * time.Minute
}
ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
if req.Operation == "apply" || req.Operation == "install" || req.Operation == "rollback" || req.Operation == "rollback_absent" || req.Operation == "pull" {
select {
case c.mutation <- struct{}{}:
defer func() { <-c.mutation }()
case <-ctx.Done():
return Response{}, ctx.Err()
}
}
cli, err := c.newClient()
if err != nil {
return unavailable(req, "Docker daemon is unavailable")
}
defer cli.Close()
if err = c.supported(ctx, cli); err != nil {
return unavailable(req, err.Error())
}
switch req.Operation {
case "prepare":
case "catalog", "candidate_catalog":
var img image.InspectResponse
img, err = c.resolve(ctx, cli, req.Image)
if err == nil && req.Operation == "catalog" {
var current *container.InspectResponse
current, err = c.owned(ctx, cli, ContainerName)
if err == nil && current != nil && isRunning(current) && current.Config.Labels[imageLabel] == req.Image && current.Image != img.ID {
err = errors.New("MCP image reference changed; restart the adapter before reading its catalog")
}
}
if err == nil {
var verified verifiedImage
verified, err = c.verify(ctx, cli, img.ID, req.Image)
if err == nil {
return Response{Catalog: append(json.RawMessage(nil), verified.catalog...), ImageID: img.ID}, nil
}
}
case "status":
return c.status(ctx, cli, req), nil
case "apply":
err = c.apply(ctx, cli, req)
case "install":
err = c.installRuntime(ctx, cli, req)
case "rollback":
err = c.rollback(ctx, cli, req)
case "rollback_absent":
err = c.rollbackAbsent(ctx, cli, req)
case "pull":
err = c.pullImage(ctx, cli, req)
case "logs":
var current *container.InspectResponse
current, err = c.owned(ctx, cli, ContainerName)
if err == nil && current == nil {
err = errors.New("MCP container is not installed")
}
if err == nil {
var data []byte
data, err = readLogs(ctx, cli, current.ID, maxLogBytes, "100")
if err == nil {
return Response{Logs: redactLog(string(data))}, nil
}
}
}
return Response{}, err
}
func unavailable(req Request, reason string) (Response, error) {
if req.Operation == "status" {
return Response{State: &State{Status: "unsupported", Image: req.Image, Reason: reason}}, nil
}
return Response{}, errors.New(reason)
}
func (c *Controller) supported(ctx context.Context, cli DockerClient) error {
if c.platform != "linux" || !strings.HasPrefix(cli.DaemonHost(), "unix:///") {
return ErrUnsupported
}
info, err := cli.Info(ctx)
if err != nil {
return errors.New("Docker daemon is unavailable")
}
if info.OSType != "linux" {
return ErrUnsupported
}
for _, option := range info.SecurityOptions {
if strings.Contains(option, "rootless") || strings.Contains(option, "userns") {
return ErrUnsupported
}
}
return nil
}
func (c *Controller) resolve(ctx context.Context, cli DockerClient, ref string) (image.InspectResponse, error) {
img, err := cli.ImageInspect(ctx, ref)
if err != nil {
return image.InspectResponse{}, errors.New("MCP Docker image is unavailable locally; load it or install the selected version")
}
if !digestPattern.MatchString(img.ID) || img.Os != "linux" || img.Config == nil || len(img.Config.Volumes) > 0 {
return image.InspectResponse{}, errors.New("invalid MCP Docker image identity or platform")
}
return img, nil
}
func (c *Controller) owned(ctx context.Context, cli DockerClient, name string) (*container.InspectResponse, error) {
info, err := cli.ContainerInspect(ctx, name)
if errdefs.IsNotFound(err) {
return nil, nil
}
if err != nil {
return nil, errors.New("MCP container state is unavailable")
}
if info.ContainerJSONBase == nil || info.Config == nil || info.Config.Labels[ownerLabel] != "true" {
return nil, errors.New("MCP container name is occupied by an unmanaged container")
}
return &info, nil
}
func isRunning(info *container.InspectResponse) bool {
return info != nil && info.State != nil && info.State.Running
}
func (c *Controller) status(ctx context.Context, cli DockerClient, req Request) Response {
state := &State{Status: "not_installed", Image: req.Image}
img, err := c.resolve(ctx, cli, req.Image)
if err == nil {
state.ImageID = img.ID
}
current, inspectErr := c.owned(ctx, cli, ContainerName)
if inspectErr != nil {
state.Status = "unhealthy"
state.Reason = inspectErr.Error()
return Response{State: state}
}
if current == nil {
if err != nil {
state.Reason = err.Error()
} else {
state.Reason = "MCP image is loaded; install it to create the managed container"
}
return Response{State: state}
}
state.Installed = true
state.ImageID = current.Image
state.AdapterVersion = current.Config.Labels[versionLabel]
state.Status = "stopped"
if !isRunning(current) {
if req.Enabled {
state.Reason = "MCP container is not running"
}
return Response{State: state}
}
state.Status = "running"
if !req.Enabled {
state.Reason = "MCP access is disabled"
return Response{State: state}
}
if err != nil || current.Image != img.ID {
state.Status = "unhealthy"
state.Reason = "MCP container does not match the configured image or listen port"
return Response{State: state}
}
verified, verifyErr := c.verify(ctx, cli, current.Image, req.Image)
if verifyErr != nil || !matchesRuntimeWithCapabilities(ctx, cli, current, img.ID, req, verified.metadata.AdapterVersion) {
state.Status = "unhealthy"
state.Reason = "MCP container configuration or metadata does not match the verified image"
return Response{State: state}
}
state.AdapterVersion = verified.metadata.AdapterVersion
health, healthErr := c.health(ctx, req.Port)
if healthErr != nil || health.Status != "ok" || !validMetadata(health) || health.AdapterVersion != state.AdapterVersion {
state.Status = "unhealthy"
state.Reason = "MCP adapter health or contract is invalid"
return Response{State: state}
}
state.Ready = true
return Response{State: state}
}
func (c *Controller) verify(ctx context.Context, cli DockerClient, id, ref string) (verifiedImage, error) {
c.cacheMu.Lock()
cached, ok := c.cache[id]
c.cacheMu.Unlock()
if !ok {
select {
case c.probes <- struct{}{}:
defer func() { <-c.probes }()
default:
return verifiedImage{}, errors.New("MCP image catalog is busy; retry shortly")
}
probeCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
version, err := c.probe(probeCtx, cli, id, "--version", 4096)
if err != nil || json.Unmarshal(version, &cached.metadata) != nil || !validMetadata(cached.metadata) {
return verifiedImage{}, errors.New("MCP image version or contract is incompatible with this panel")
}
cached.catalog, err = c.probe(probeCtx, cli, id, "--catalog", maxCatalogBytes)
var manifest struct {
ContractVersion int `json:"contractVersion"`
Tools []json.RawMessage `json:"tools"`
}
if err != nil || json.Unmarshal(cached.catalog, &manifest) != nil || manifest.ContractVersion != contractVersion || len(manifest.Tools) > 256 {
return verifiedImage{}, errors.New("MCP image catalog is invalid or incompatible")
}
c.cacheMu.Lock()
if len(c.cache) >= 32 {
c.cache = make(map[string]verifiedImage)
}
c.cache[id] = cached
c.cacheMu.Unlock()
}
if strings.HasPrefix(ref, ImageRepository+":") && strings.TrimPrefix(cached.metadata.AdapterVersion, "v") != strings.TrimPrefix(strings.TrimPrefix(ref, ImageRepository+":"), "v") {
return verifiedImage{}, errors.New("MCP image version does not match its selected tag")
}
return cached, nil
}
func validMetadata(value metadata) bool {
return semanticVersion.MatchString(value.AdapterVersion) && value.ContractVersion == contractVersion && value.Protocol == protocol
}
func safeConfig(id string, args []string) (*container.Config, *container.HostConfig) {
pids := int64(128)
config := &container.Config{Image: id, Entrypoint: []string{adapterBinary}, Cmd: args, User: "0:0", Env: []string{}, Labels: map[string]string{ownerLabel: "true"}, NetworkDisabled: true, Healthcheck: &container.HealthConfig{Test: []string{"NONE"}}}
host := &container.HostConfig{NetworkMode: "none", ReadonlyRootfs: true, CapDrop: []string{"ALL"}, SecurityOpt: []string{"no-new-privileges:true"}, IpcMode: "private",
Resources: container.Resources{Memory: 256 << 20, MemorySwap: 256 << 20, NanoCPUs: 1_000_000_000, PidsLimit: &pids},
LogConfig: container.LogConfig{Type: "json-file", Config: map[string]string{"max-size": "3m", "max-file": "1"}}}
return config, host
}
func (c *Controller) probe(ctx context.Context, cli DockerClient, id, flag string, limit int) (data []byte, err error) {
config, host := safeConfig(id, []string{flag})
created, err := cli.ContainerCreate(ctx, config, host, nil, nil, "")
if err != nil {
return nil, errors.New("MCP image probe could not be created")
}
defer func() {
cleanupCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
cleanupErr := cli.ContainerRemove(cleanupCtx, created.ID, container.RemoveOptions{Force: true})
if cleanupErr != nil && !errdefs.IsNotFound(cleanupErr) {
err = errors.Join(err, errors.New("MCP image probe cleanup failed"))
}
}()
if err = cli.ContainerStart(ctx, created.ID, container.StartOptions{}); err != nil {
return nil, errors.New("MCP image probe failed to start")
}
for {
info, inspectErr := cli.ContainerInspect(ctx, created.ID)
if inspectErr != nil || info.State == nil {
return nil, errors.New("MCP image probe state is unavailable")
}
if info.State.Status == "exited" {
if info.State.ExitCode != 0 {
return nil, errors.New("MCP image probe exited unsuccessfully")
}
break
}
if info.State.Dead {
return nil, errors.New("MCP image probe exited unsuccessfully")
}
select {
case <-ctx.Done():
return nil, errors.New("MCP image probe timed out")
case <-time.After(25 * time.Millisecond):
}
}
return readLogs(ctx, cli, created.ID, limit, "all")
}
type boundedWriter struct {
bytes.Buffer
limit int
}
func (b *boundedWriter) Write(value []byte) (int, error) {
if b.Len()+len(value) > b.limit {
return 0, errors.New("MCP output exceeds permitted size")
}
return b.Buffer.Write(value)
}
func readLogs(ctx context.Context, cli DockerClient, id string, limit int, tail string) ([]byte, error) {
reader, err := cli.ContainerLogs(ctx, id, container.LogsOptions{ShowStdout: true, ShowStderr: true, Tail: tail})
if err != nil {
return nil, errors.New("MCP container logs are unavailable")
}
defer reader.Close()
stdout, stderr := &boundedWriter{limit: limit}, &boundedWriter{limit: limit}
if err = readDockerStreams(reader, stdout, stderr, 2*limit+64<<10); err != nil {
return nil, errors.New("MCP output exceeds permitted size or is invalid")
}
if tail == "all" {
return stdout.Bytes(), nil
}
combined := append(append([]byte(nil), stdout.Bytes()...), stderr.Bytes()...)
if len(combined) > limit {
combined = combined[:limit]
}
return combined, nil
}
func readDockerStreams(reader io.Reader, stdout, stderr *boundedWriter, maxRaw int) error {
total := 0
for {
var header [8]byte
_, err := io.ReadFull(reader, header[:])
if err == io.EOF {
return nil
}
if err != nil {
return err
}
size := int64(binary.BigEndian.Uint32(header[4:]))
total += len(header)
if size > int64(maxRaw-total) || header[1] != 0 || header[2] != 0 || header[3] != 0 {
return errors.New("invalid Docker log frame")
}
var output *boundedWriter
switch header[0] {
case 1:
output = stdout
case 2:
output = stderr
default:
return errors.New("invalid Docker log stream")
}
if size > int64(output.limit-output.Len()) {
return errors.New("MCP output exceeds permitted size")
}
if _, err = io.CopyN(output, reader, size); err != nil {
return err
}
total += int(size)
}
}
func checkSocketDirectory() error {
info, err := os.Lstat(SocketDirectory)
if err != nil || !info.IsDir() || info.Mode()&os.ModeSymlink != 0 || info.Mode().Perm()&0022 != 0 {
return errors.New("MCP runtime socket directory is missing or unsafe")
}
return nil
}
func readHealth(ctx context.Context, port int) (metadata, error) {
ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, fmt.Sprintf("http://127.0.0.1:%d/healthz", port), nil)
if err != nil {
return metadata{}, err
}
client := &http.Client{Transport: &http.Transport{Proxy: nil}, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
defer client.CloseIdleConnections()
resp, err := client.Do(req)
if err != nil {
return metadata{}, errors.New("MCP health endpoint is unavailable")
}
defer resp.Body.Close()
var result metadata
data, readErr := io.ReadAll(io.LimitReader(resp.Body, 4097))
if resp.StatusCode != 200 || readErr != nil || len(data) > 4096 || json.Unmarshal(data, &result) != nil || result.Status != "ok" || !validMetadata(result) {
return metadata{}, errors.New("MCP health response is incompatible")
}
return result, nil
}
var bearerPattern = regexp.MustCompile(`(?i)bearer\s+[^\s"']+`)
var sensitivePattern = regexp.MustCompile(`(?i)("?(?:password|token|secret|authorization)"?\s*[:=]\s*)("[^"]*"|[^\s,}]+)`)
func redactLog(value string) string {
return sensitivePattern.ReplaceAllString(bearerPattern.ReplaceAllString(value, "Bearer [REDACTED]"), "${1}[REDACTED]")
}
+441
View File
@@ -0,0 +1,441 @@
package mcpruntime
import (
"context"
"encoding/json"
"errors"
"io"
"reflect"
"strconv"
"strings"
"time"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/image"
"github.com/docker/docker/api/types/mount"
"github.com/docker/docker/errdefs"
)
func (c *Controller) pullImage(ctx context.Context, cli DockerClient, req Request) error {
_, err := cli.ImageInspect(ctx, req.Image)
if err != nil && !errdefs.IsNotFound(err) {
return errors.New("MCP Docker image inspection failed")
}
if errdefs.IsNotFound(err) {
pullCtx, cancel := context.WithTimeout(ctx, 3*time.Minute)
defer cancel()
reader, pullErr := cli.ImagePull(pullCtx, req.Image, image.PullOptions{})
if pullErr != nil {
return errors.New("MCP Docker image download failed")
}
defer reader.Close()
decoder := json.NewDecoder(io.LimitReader(reader, 16<<20))
for {
var message struct {
Error string `json:"error"`
ErrorDetail *struct {
Message string `json:"message"`
} `json:"errorDetail"`
}
if err = decoder.Decode(&message); err == io.EOF {
break
} else if err != nil {
return errors.New("MCP Docker image download was interrupted")
}
if message.Error != "" || message.ErrorDetail != nil {
return errors.New("MCP Docker image download failed")
}
}
}
img, err := c.resolve(ctx, cli, req.Image)
if err != nil {
return err
}
_, err = c.verify(ctx, cli, img.ID, req.Image)
return err
}
func (c *Controller) installRuntime(ctx context.Context, cli DockerClient, req Request) error {
before, err := c.owned(ctx, cli, ContainerName)
if err != nil {
return err
}
wasRunning := isRunning(before)
activate := req
activate.Enabled = true
activate.Restart = true
if err = c.apply(ctx, cli, activate); err != nil {
return err
}
if req.Enabled {
return nil
}
current, err := c.owned(ctx, cli, ContainerName)
if err == nil && current == nil {
err = errors.New("installed MCP container is unavailable")
}
if err == nil && current != nil {
err = stopContainer(ctx, cli, current.ID)
}
if err == nil {
return nil
}
rollbackCtx, cancel := context.WithTimeout(context.Background(), 40*time.Second)
defer cancel()
var restoreErr error
if before != nil {
port, parseErr := strconv.Atoi(before.Config.Labels[portLabel])
old := Request{Operation: "rollback", Image: before.Config.Labels[imageLabel], ImageID: before.Image, Port: port, Enabled: wasRunning}
if parseErr != nil || ValidateImage(old.Image) != nil {
restoreErr = errors.New("old MCP runtime configuration is invalid")
}
if restoreErr == nil {
restoreErr = c.rollback(rollbackCtx, cli, old)
}
} else if current != nil {
restoreErr = cli.ContainerRemove(rollbackCtx, current.ID, container.RemoveOptions{Force: true})
}
return errors.Join(errors.New("installed MCP container could not be left stopped"), restoreErr)
}
func runtimeConfig(id string, req Request, version string) (*container.Config, *container.HostConfig) {
config, host := safeConfig(id, []string{"--mode=panel", "--listen=127.0.0.1:" + strconv.Itoa(req.Port), "--panel-socket=" + CoreSocket})
config.NetworkDisabled = false
config.Labels[imageLabel] = req.Image
config.Labels[portLabel] = strconv.Itoa(req.Port)
config.Labels[versionLabel] = version
host.NetworkMode = "host"
host.RestartPolicy = container.RestartPolicy{Name: container.RestartPolicyUnlessStopped}
host.Mounts = []mount.Mount{{Type: mount.TypeBind, Source: SocketDirectory, Target: SocketDirectory, ReadOnly: true, BindOptions: &mount.BindOptions{Propagation: mount.PropagationRPrivate}}}
host.LogConfig.Config = map[string]string{"max-size": "1m", "max-file": "3"}
return config, host
}
func matchesRuntime(info *container.InspectResponse, id string, req Request, version string) bool {
if info == nil || info.ContainerJSONBase == nil || info.Config == nil || info.HostConfig == nil || info.Image != id {
return false
}
wantConfig, wantHost := runtimeConfig(id, req, version)
cfg, host := info.Config, info.HostConfig
if cfg.Labels[versionLabel] != version || cfg.Labels[imageLabel] != req.Image || cfg.Labels[portLabel] != strconv.Itoa(req.Port) || cfg.User != wantConfig.User || cfg.Tty || !reflect.DeepEqual(cfg.Entrypoint, wantConfig.Entrypoint) || !reflect.DeepEqual(cfg.Cmd, wantConfig.Cmd) {
return false
}
if host.NetworkMode != "host" || host.Privileged || !host.ReadonlyRootfs || !reflect.DeepEqual(host.CapDrop, wantHost.CapDrop) || len(host.CapAdd) != 0 || !reflect.DeepEqual(host.SecurityOpt, wantHost.SecurityOpt) || len(host.Binds) != 0 || len(host.VolumesFrom) != 0 || len(host.Devices) != 0 || len(host.DeviceRequests) != 0 || len(host.Mounts) != 1 {
return false
}
m := host.Mounts[0]
if m.Type != mount.TypeBind || m.Source != SocketDirectory || m.Target != SocketDirectory || !m.ReadOnly {
return false
}
if host.PidMode != "" || host.UsernsMode != "" || host.UTSMode != "" || (host.IpcMode != "" && host.IpcMode != "private") || host.PublishAllPorts || len(host.PortBindings) != 0 || host.Resources.Memory != wantHost.Memory || host.Resources.MemorySwap != wantHost.MemorySwap || host.Resources.NanoCPUs != wantHost.NanoCPUs || host.Resources.PidsLimit == nil || *host.Resources.PidsLimit != *wantHost.PidsLimit {
return false
}
return host.RestartPolicy.Name == container.RestartPolicyUnlessStopped
}
func matchesRuntimeWithCapabilities(ctx context.Context, cli DockerClient, info *container.InspectResponse, id string, req Request, version string) bool {
if matchesRuntime(info, id, req, version) {
return true
}
if info == nil || info.ContainerJSONBase == nil || info.HostConfig == nil || info.HostConfig.MemorySwap != -1 {
return false
}
adjusted := *info
base := *info.ContainerJSONBase
host := *info.HostConfig
_, wantHost := runtimeConfig(id, req, version)
host.MemorySwap = wantHost.MemorySwap
base.HostConfig = &host
adjusted.ContainerJSONBase = &base
if !matchesRuntime(&adjusted, id, req, version) {
return false
}
daemon, err := cli.Info(ctx)
return err == nil && !daemon.SwapLimit
}
func stopContainer(ctx context.Context, cli DockerClient, id string) error {
seconds := 10
if err := cli.ContainerStop(ctx, id, container.StopOptions{Timeout: &seconds}); err != nil && !errdefs.IsNotModified(err) {
return errors.New("MCP container could not be stopped")
}
return nil
}
func (c *Controller) apply(ctx context.Context, cli DockerClient, req Request) error {
current, err := c.owned(ctx, cli, ContainerName)
if err != nil {
return err
}
if !req.Enabled {
if current == nil || !isRunning(current) {
return nil
}
return stopContainer(ctx, cli, current.ID)
}
if err = c.checkDirectory(); err != nil {
return err
}
img, err := c.resolve(ctx, cli, req.Image)
if err != nil {
return err
}
if !digestPattern.MatchString(req.ImageID) || img.ID != req.ImageID {
return errors.New("MCP image identity changed after catalog validation; retry the operation")
}
verified, err := c.verify(ctx, cli, img.ID, req.Image)
if err != nil {
return err
}
if matchesRuntimeWithCapabilities(ctx, cli, current, img.ID, req, verified.metadata.AdapterVersion) {
wasRunning := isRunning(current)
if wasRunning && !req.Restart {
return c.waitHealthy(ctx, cli, current.ID, req, verified.metadata.AdapterVersion)
}
if wasRunning {
if err = stopContainer(ctx, cli, current.ID); err != nil {
return err
}
}
if err = cli.ContainerStart(ctx, current.ID, container.StartOptions{}); err == nil {
err = c.waitHealthy(ctx, cli, current.ID, req, verified.metadata.AdapterVersion)
}
if err == nil {
return nil
}
rollbackCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
stopErr := stopContainer(rollbackCtx, cli, current.ID)
if wasRunning {
stopErr = errors.Join(stopErr, cli.ContainerStart(rollbackCtx, current.ID, container.StartOptions{}))
}
return errors.Join(errors.New("MCP container failed to start or become healthy"), stopErr)
}
previous, err := c.owned(ctx, cli, previousName)
if err != nil {
return err
}
if isRunning(previous) {
return errors.New("MCP previous container is unexpectedly running; resolve its state before upgrading")
}
if matchesRuntimeWithCapabilities(ctx, cli, previous, img.ID, req, verified.metadata.AdapterVersion) {
return c.restorePrevious(ctx, cli, current, previous, req, verified.metadata.AdapterVersion)
}
if previous != nil {
if err = cli.ContainerRemove(ctx, previous.ID, container.RemoveOptions{}); err != nil {
return errors.New("MCP previous container could not be removed")
}
}
wasRunning := isRunning(current)
if current != nil {
if wasRunning {
if err = stopContainer(ctx, cli, current.ID); err != nil {
return err
}
}
if err = cli.ContainerRename(ctx, current.ID, previousName); err != nil {
if wasRunning {
rollbackCtx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
err = errors.Join(err, cli.ContainerStart(rollbackCtx, current.ID, container.StartOptions{}))
}
return errors.Join(errors.New("MCP old container could not be retained for rollback"), err)
}
}
config, host := runtimeConfig(img.ID, req, verified.metadata.AdapterVersion)
created, err := cli.ContainerCreate(ctx, config, host, nil, nil, ContainerName)
if err == nil {
err = cli.ContainerStart(ctx, created.ID, container.StartOptions{})
}
if err == nil {
err = c.waitHealthy(ctx, cli, created.ID, req, verified.metadata.AdapterVersion)
}
if err == nil {
return nil
}
rollbackCtx, cancel := context.WithTimeout(context.Background(), 40*time.Second)
defer cancel()
var rollbackErr error
if created.ID != "" {
rollbackErr = cli.ContainerRemove(rollbackCtx, created.ID, container.RemoveOptions{Force: true})
}
if current != nil && rollbackErr == nil {
rollbackErr = cli.ContainerRename(rollbackCtx, current.ID, ContainerName)
if rollbackErr == nil && wasRunning {
rollbackErr = cli.ContainerStart(rollbackCtx, current.ID, container.StartOptions{})
}
}
return errors.Join(errors.New("MCP replacement failed to start or become healthy; previous container restored where possible"), rollbackErr)
}
func (c *Controller) restorePrevious(ctx context.Context, cli DockerClient, current, previous *container.InspectResponse, req Request, version string) error {
wasRunning := isRunning(current)
if wasRunning {
if err := stopContainer(ctx, cli, current.ID); err != nil {
return err
}
}
temporary := ContainerName + "-rollback-" + strings.TrimPrefix(previous.ID, "sha256:")[:min(12, len(strings.TrimPrefix(previous.ID, "sha256:")))]
if current != nil {
if err := cli.ContainerRename(ctx, current.ID, temporary); err != nil {
rollbackCtx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
if wasRunning {
err = errors.Join(err, cli.ContainerStart(rollbackCtx, current.ID, container.StartOptions{}))
}
return errors.Join(errors.New("MCP current container could not be retained for rollback"), err)
}
}
if err := cli.ContainerRename(ctx, previous.ID, ContainerName); err != nil {
rollbackCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
var restoreErr error
if current != nil {
restoreErr = cli.ContainerRename(rollbackCtx, current.ID, ContainerName)
if restoreErr == nil && wasRunning {
restoreErr = cli.ContainerStart(rollbackCtx, current.ID, container.StartOptions{})
}
}
return errors.Join(errors.New("MCP previous container could not be restored"), restoreErr)
}
var err error
if req.Enabled {
err = cli.ContainerStart(ctx, previous.ID, container.StartOptions{})
if err == nil {
err = c.waitHealthy(ctx, cli, previous.ID, req, version)
}
}
if err == nil {
if current != nil {
err = cli.ContainerRename(ctx, current.ID, previousName)
}
if err == nil {
return nil
}
}
rollbackCtx, cancel := context.WithTimeout(context.Background(), 40*time.Second)
defer cancel()
rollbackErr := stopContainer(rollbackCtx, cli, previous.ID)
rollbackErr = errors.Join(rollbackErr, cli.ContainerRename(rollbackCtx, previous.ID, previousName))
if current != nil && rollbackErr == nil {
rollbackErr = cli.ContainerRename(rollbackCtx, current.ID, ContainerName)
if rollbackErr == nil && wasRunning {
rollbackErr = cli.ContainerStart(rollbackCtx, current.ID, container.StartOptions{})
}
}
return errors.Join(errors.New("MCP previous container failed its health check"), rollbackErr)
}
func (c *Controller) rollback(ctx context.Context, cli DockerClient, req Request) error {
if (req.ImageID != "" && !digestPattern.MatchString(req.ImageID)) || (req.Enabled && req.ImageID == "") {
return errors.New("invalid MCP rollback image identity")
}
current, err := c.owned(ctx, cli, ContainerName)
if err != nil {
return err
}
previous, err := c.owned(ctx, cli, previousName)
if err != nil {
return err
}
var target *container.InspectResponse
for _, candidate := range []*container.InspectResponse{current, previous} {
if candidate != nil && candidate.Image == req.ImageID && candidate.Config.Labels[imageLabel] == req.Image && candidate.Config.Labels[portLabel] == strconv.Itoa(req.Port) {
target = candidate
break
}
}
if target == nil {
if !req.Enabled {
if isRunning(current) {
return stopContainer(ctx, cli, current.ID)
}
return nil
}
return errors.New("the previous MCP runtime instance is unavailable for rollback")
}
verified, err := c.verify(ctx, cli, target.Image, req.Image)
if err != nil {
return err
}
if !matchesRuntimeWithCapabilities(ctx, cli, target, req.ImageID, req, verified.metadata.AdapterVersion) {
return errors.New("the previous MCP runtime configuration is unsafe or incompatible")
}
if req.Enabled {
if err = c.checkDirectory(); err != nil {
return err
}
}
if target == previous {
if isRunning(previous) {
return errors.New("MCP previous container is unexpectedly running")
}
return c.restorePrevious(ctx, cli, current, previous, req, verified.metadata.AdapterVersion)
}
if !req.Enabled {
if isRunning(current) {
return stopContainer(ctx, cli, current.ID)
}
return nil
}
if !isRunning(current) {
if err = cli.ContainerStart(ctx, current.ID, container.StartOptions{}); err != nil {
return errors.New("MCP rollback instance failed to start")
}
}
return c.waitHealthy(ctx, cli, current.ID, req, verified.metadata.AdapterVersion)
}
// rollbackAbsent removes only the fixed owned instance created by a failed
// first installation. It never resolves a tag, pulls, or creates a runtime.
func (c *Controller) rollbackAbsent(ctx context.Context, cli DockerClient, req Request) error {
if req.Enabled || !digestPattern.MatchString(req.ImageID) {
return errors.New("invalid MCP absent-runtime rollback identity")
}
current, err := c.owned(ctx, cli, ContainerName)
if err != nil || current == nil {
return err
}
if current.Image != req.ImageID || current.Config.Labels[imageLabel] != req.Image || current.Config.Labels[portLabel] != strconv.Itoa(req.Port) {
return errors.New("MCP runtime changed after installation; absent-runtime rollback was rejected")
}
if _, err = c.resolve(ctx, cli, current.Image); err != nil {
return err
}
verified, err := c.verify(ctx, cli, current.Image, req.Image)
if err != nil {
return err
}
if !matchesRuntimeWithCapabilities(ctx, cli, current, req.ImageID, req, verified.metadata.AdapterVersion) {
return errors.New("MCP installed runtime configuration is unsafe or incompatible")
}
if err = cli.ContainerRemove(ctx, current.ID, container.RemoveOptions{Force: true}); err != nil && !errdefs.IsNotFound(err) {
return errors.New("MCP failed installation could not be removed")
}
return nil
}
func (c *Controller) waitHealthy(ctx context.Context, cli DockerClient, id string, req Request, version string) error {
ctx, cancel := context.WithTimeout(ctx, 15*time.Second)
defer cancel()
for {
if ctx.Err() != nil {
return errors.New("MCP container did not become healthy before the timeout")
}
info, err := cli.ContainerInspect(ctx, id)
if err == nil && isRunning(&info) {
if info.Config == nil || info.Config.Labels[ownerLabel] != "true" || !matchesRuntimeWithCapabilities(ctx, cli, &info, req.ImageID, req, version) {
return errors.New("MCP container configuration or metadata does not match the verified image")
}
health, healthErr := c.health(ctx, req.Port)
if healthErr == nil && ctx.Err() == nil && validMetadata(health) && health.Status == "ok" && health.AdapterVersion == version {
return nil
}
}
select {
case <-ctx.Done():
return errors.New("MCP container did not become healthy before the timeout")
case <-time.After(250 * time.Millisecond):
}
}
}
+32 -4
View File
@@ -26,6 +26,8 @@ type Local struct {
Database string
Password string
ContainerName string
// sqlContext belongs to a request-scoped copy, never the reusable client.
sqlContext context.Context
}
func NewLocal(command []string, dbType, containerName, password, database string) *Local {
@@ -47,6 +49,26 @@ func (r *Local) Create(info CreateInfo) error {
return nil
}
// CreateWithContext shares one deadline across all creation and rollback SQL.
// Copying the client keeps concurrent or subsequent ordinary calls independent.
func (r *Local) CreateWithContext(ctx context.Context, info CreateInfo) error {
if ctx == nil {
return errors.New("mysql creation context is required")
}
scoped := *r
scoped.PrefixCommand = append([]string(nil), r.PrefixCommand...)
scoped.sqlContext = ctx
return scoped.Create(info)
}
func (r *Local) commandContext(timeout uint) (context.Context, context.CancelFunc) {
parent := r.sqlContext
if parent == nil {
parent = context.Background()
}
return context.WithTimeout(parent, time.Duration(timeout)*time.Second)
}
func (r *Local) CreateDatabase(info CreateInfo) error {
createSql := fmt.Sprintf("create database `%s` default character set %s collate %s", info.Name, info.Format, info.Collation)
if len(info.Collation) == 0 {
@@ -396,15 +418,18 @@ func (r *Local) SyncDB(version string) ([]SyncDBInfo, error) {
func (r *Local) Close() {}
func (r *Local) ExecSQL(command string, timeout uint) error {
itemCommand := r.PrefixCommand[:]
itemCommand := append([]string(nil), r.PrefixCommand...)
itemCommand = append(itemCommand, command)
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeout)*time.Second)
ctx, cancel := r.commandContext(timeout)
defer cancel()
cmd := exec.CommandContext(ctx, "docker", itemCommand...)
stdout, err := cmd.CombinedOutput()
if errors.Is(ctx.Err(), context.DeadlineExceeded) {
return buserr.New("ErrExecTimeOut")
}
if ctx.Err() != nil {
return ctx.Err()
}
stdStr := strings.ReplaceAll(string(stdout), "mysql: [Warning] Using a password on the command line interface can be insecure.\n", "")
if err != nil || strings.HasPrefix(string(stdStr), "ERROR ") {
return errors.New(stdStr)
@@ -413,15 +438,18 @@ func (r *Local) ExecSQL(command string, timeout uint) error {
}
func (r *Local) ExecSQLForRows(command string, timeout uint) ([]string, error) {
itemCommand := r.PrefixCommand[:]
itemCommand := append([]string(nil), r.PrefixCommand...)
itemCommand = append(itemCommand, command)
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeout)*time.Second)
ctx, cancel := r.commandContext(timeout)
defer cancel()
cmd := exec.CommandContext(ctx, "docker", itemCommand...)
stdout, err := cmd.CombinedOutput()
if errors.Is(ctx.Err(), context.DeadlineExceeded) {
return nil, buserr.New("ErrExecTimeOut")
}
if ctx.Err() != nil {
return nil, ctx.Err()
}
stdStr := strings.ReplaceAll(string(stdout), "mysql: [Warning] Using a password on the command line interface can be insecure.\n", "")
if err != nil || strings.HasPrefix(string(stdStr), "ERROR ") {
return nil, errors.New(stdStr)
+2
View File
@@ -6,6 +6,7 @@ import (
)
const (
ModelDownloaderRepoIDPattern = `^[A-Za-z0-9][A-Za-z0-9._-]*/[A-Za-z0-9][A-Za-z0-9._-]*$`
VMNameValidationPattern = `^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$`
VMCommonPattern = `^[a-zA-Z0-9][a-zA-Z0-9._:-]{0,63}$`
@@ -66,6 +67,7 @@ var regexMap = make(map[string]*regexp.Regexp)
func Init() {
patterns := []string{
ModelDownloaderRepoIDPattern,
VMNameValidationPattern,
VMCommonPattern,
+4
View File
@@ -10,10 +10,14 @@ import (
"github.com/1Panel-dev/1Panel/core/app/dto"
"github.com/1Panel-dev/1Panel/core/global"
"github.com/1Panel-dev/1Panel/core/i18n"
mcpmeta "github.com/1Panel-dev/1Panel/core/utils/mcpapi/metadata"
"github.com/gin-gonic/gin"
)
func ErrorWithDetail(ctx *gin.Context, code int, msgKey string, err error) {
if msgKey == "ErrProxy" && ctx.GetBool("MCP_API_AUTH") && ctx.Request != nil && mcpmeta.TrustedRequest(ctx.Request) {
ctx.Header(mcpmeta.BusinessCodeHeader, "ErrProxy")
}
res := dto.Response{
Code: code,
Message: "",
+374
View File
@@ -0,0 +1,374 @@
package v2
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"net/url"
"strconv"
"time"
"github.com/1Panel-dev/1Panel/core/app/api/v2/helper"
"github.com/1Panel-dev/1Panel/core/app/auth"
"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 mcpRead(c *gin.Context, value any) bool {
data, err := io.ReadAll(http.MaxBytesReader(c.Writer, c.Request.Body, 64<<10))
if err == nil {
err = contract.DecodeObject(data, value)
}
if err == nil {
switch value.(type) {
case *dto.MCPCredentialCreate, *dto.MCPCredentialUpdate:
var fields struct {
ExpiresAt json.RawMessage `json:"expiresAt"`
}
if json.Unmarshal(data, &fields) != nil || len(fields.ExpiresAt) == 0 {
err = contract.Invalid()
}
}
}
if err != nil {
mcpManagementError(c, contract.Invalid())
return false
}
return true
}
func mcpManagementError(c *gin.Context, err error) {
code := http.StatusInternalServerError
errorCode := "MCP_OPERATION_FAILED"
invocationID := ""
var known *contract.Error
if errors.As(err, &known) {
code = known.Status
errorCode = known.Code
invocationID = known.InvocationID
} else if errors.Is(err, service.ErrMCPSettingsRevision) {
code = 409
errorCode = "MCP_CONFLICT"
}
c.Header("Cache-Control", "no-store")
c.JSON(200, struct {
dto.Response
ErrorCode string `json:"errorCode"`
InvocationID string `json:"invocationId,omitempty"`
}{Response: dto.Response{Code: code, Message: mcpManagementMessage(errorCode)}, ErrorCode: errorCode, InvocationID: invocationID})
c.Abort()
}
func mcpManagement(c *gin.Context, write bool, admin bool) (*service.MCPService, auth.APIKeyOwner, bool) {
s := service.NewMCPService()
owner, err := s.ManagementOwner(c, write)
if err != nil {
mcpManagementError(c, err)
return nil, owner, false
}
if admin && !owner.IsSuperAdmin {
mcpManagementError(c, contract.Forbidden())
return nil, owner, false
}
c.Header("Cache-Control", "no-store")
return s, owner, true
}
func (b *BaseApi) MCPCatalog(c *gin.Context) {
s, owner, ok := mcpManagement(c, false, false)
if ok {
catalog, err := s.Catalog(owner)
if err != nil {
mcpManagementError(c, err)
return
}
helper.SuccessWithData(c, catalog)
}
}
func (b *BaseApi) MCPNodes(c *gin.Context) {
s, owner, ok := mcpManagement(c, false, false)
if !ok {
return
}
if c.Request.URL.RawQuery != "" {
mcpManagementError(c, contract.Invalid())
return
}
nodes, err := s.Nodes(owner)
if err != nil {
mcpManagementError(c, err)
return
}
helper.SuccessWithData(c, nodes)
}
func (b *BaseApi) SearchMCPCredentials(c *gin.Context) {
s, owner, ok := mcpManagement(c, false, false)
if !ok {
return
}
var req dto.MCPCredentialSearch
if !mcpRead(c, &req) {
return
}
result, err := s.Search(owner, req)
if err != nil {
mcpManagementError(c, err)
return
}
helper.SuccessWithData(c, result)
}
func (b *BaseApi) CreateMCPCredential(c *gin.Context) {
s, owner, ok := mcpManagement(c, true, false)
if !ok {
return
}
var req dto.MCPCredentialCreate
if !mcpRead(c, &req) {
return
}
result, err := s.Create(owner, req)
if err != nil {
mcpManagementError(c, err)
return
}
helper.SuccessWithData(c, result)
}
func (b *BaseApi) UpdateMCPCredential(c *gin.Context) {
s, owner, ok := mcpManagement(c, true, false)
if !ok {
return
}
var req dto.MCPCredentialUpdate
if !mcpRead(c, &req) {
return
}
if err := s.Update(owner, req); err != nil {
mcpManagementError(c, err)
return
}
helper.Success(c)
}
func (b *BaseApi) StatusMCPCredential(c *gin.Context) {
s, owner, ok := mcpManagement(c, true, false)
if !ok {
return
}
var req dto.MCPCredentialStatus
if !mcpRead(c, &req) {
return
}
if err := s.Status(owner, req); err != nil {
mcpManagementError(c, err)
return
}
helper.Success(c)
}
func (b *BaseApi) RevokeMCPCredential(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.Revoke(owner, req); err != nil {
mcpManagementError(c, err)
return
}
helper.Success(c)
}
func (b *BaseApi) SearchMCPInvocations(c *gin.Context) {
s, owner, ok := mcpManagement(c, false, false)
if !ok {
return
}
var req dto.MCPInvocationSearch
if !mcpRead(c, &req) {
return
}
result, err := s.Invocations(owner, req)
if err != nil {
mcpManagementError(c, err)
return
}
helper.SuccessWithData(c, result)
}
func (b *BaseApi) MCPServiceStatus(c *gin.Context) {
_, owner, ok := mcpManagement(c, false, false)
if !ok {
return
}
ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second)
defer cancel()
result, err := service.NewMCPRuntimeService().Status(ctx)
if err != nil {
mcpManagementError(c, err)
return
}
result.Endpoint = mcpPublicEndpoint(c, result.Endpoint)
result.CanManageService = owner.IsSuperAdmin
helper.SuccessWithData(c, result)
}
func (b *BaseApi) MCPSettings(c *gin.Context) {
_, _, ok := mcpManagement(c, false, false)
if !ok {
return
}
result, err := service.NewMCPRuntimeService().Settings()
if err != nil {
mcpManagementError(c, err)
return
}
helper.SuccessWithData(c, result)
}
func (b *BaseApi) UpdateMCPSettings(c *gin.Context) {
_, _, ok := mcpManagement(c, true, true)
if !ok {
return
}
var req dto.MCPRuntimeSettingsUpdate
if !mcpRead(c, &req) {
return
}
result, err := service.NewMCPRuntimeService().Update(req)
if err != nil {
mcpServiceError(c, err)
return
}
helper.SuccessWithData(c, result)
}
func (b *BaseApi) OperateMCPService(c *gin.Context) {
_, _, ok := mcpManagement(c, true, true)
if !ok {
return
}
var req struct {
Operation string `json:"operation"`
}
if !mcpRead(c, &req) {
return
}
ctx, cancel := context.WithTimeout(c.Request.Context(), 55*time.Second)
defer cancel()
result, err := service.NewMCPRuntimeService().Operate(ctx, req.Operation)
if err != nil {
mcpServiceError(c, err)
return
}
helper.SuccessWithData(c, result)
}
func (b *BaseApi) InstallMCPService(c *gin.Context) {
_, _, ok := mcpManagement(c, true, true)
if !ok {
return
}
var req dto.MCPRuntimeInstall
if !mcpRead(c, &req) {
return
}
ctx, cancel := context.WithTimeout(c.Request.Context(), 6*time.Minute)
defer cancel()
result, err := service.NewMCPRuntimeService().Install(ctx, req)
if err != nil {
mcpServiceError(c, err)
return
}
helper.SuccessWithData(c, result)
}
func (b *BaseApi) MCPServiceLogs(c *gin.Context) {
_, _, ok := mcpManagement(c, false, true)
if !ok {
return
}
ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second)
defer cancel()
result, err := service.NewMCPRuntimeService().Logs(ctx)
if err != nil {
mcpServiceError(c, err)
return
}
helper.SuccessWithData(c, result)
}
func mcpServiceError(c *gin.Context, err error) {
var known *contract.Error
if errors.Is(err, service.ErrMCPSettingsRevision) || errors.As(err, &known) {
mcpManagementError(c, err)
return
}
helper.InternalServer(c, err)
}
func MCPRuntimeTools(c *gin.Context) {
c.Header("Cache-Control", "no-store")
token, err := service.MCPBearer(c)
if err != nil {
MCPRuntimeError(c, err)
return
}
if !mcpRuntimeProof(c, token) {
return
}
result, err := service.NewMCPService().RuntimeTools(c, token)
if err != nil {
MCPRuntimeError(c, err)
return
}
c.JSON(200, result)
}
func MCPRuntimeInvoke(c *gin.Context) {
c.Header("Cache-Control", "no-store")
token, err := service.MCPBearer(c)
if err != nil {
MCPRuntimeError(c, err)
return
}
if !mcpRuntimeProof(c, token) {
return
}
data, err := io.ReadAll(http.MaxBytesReader(c.Writer, c.Request.Body, 64<<10))
var req contract.Invoke
if err == nil {
err = contract.DecodeObject(data, &req)
}
if err == nil {
req.NodeID, err = contract.NormalizeNodeID(req.NodeID)
}
if err != nil || req.AdmissionID == "" {
MCPRuntimeError(c, contract.Invalid())
return
}
result, err := service.NewMCPService().Invoke(c, token, req)
if err != nil {
MCPRuntimeError(c, err)
return
}
c.JSON(200, result)
}
func MCPRuntimeError(c *gin.Context, err error) {
var known *contract.Error
if !errors.As(err, &known) {
known = contract.Fail("MCP_EXECUTION_FAILED", 503, "MCP service is unavailable")
}
c.Header("Cache-Control", "no-store")
if known.RetryAfterSeconds > 0 {
c.Header("Retry-After", strconv.Itoa(known.RetryAfterSeconds))
}
c.JSON(known.Status, known)
c.Abort()
}
func mcpPublicEndpoint(c *gin.Context, configured string) string {
if configured != "" {
return configured
}
scheme := "http"
if c.Request.TLS != nil {
scheme = "https"
}
if c.Request.Host == "" {
return ""
}
return (&url.URL{Scheme: scheme, Host: c.Request.Host, Path: "/mcp"}).String()
}
+70
View File
@@ -0,0 +1,70 @@
package v2
import (
"io"
"time"
"github.com/1Panel-dev/1Panel/core/app/service"
contract "github.com/1Panel-dev/1Panel/core/utils/mcp"
"github.com/gin-gonic/gin"
"net/http"
)
func mcpRuntimeProof(c *gin.Context, token string) bool {
source, err := contract.VerifyAccessProof(c.GetHeader(contract.AccessProofHeader), token, time.Now())
if err != nil {
MCPRuntimeError(c, err)
return false
}
c.Set("MCP_CLIENT_IP", source)
return true
}
func MCPRuntimeAdmit(c *gin.Context) {
c.Header("Cache-Control", "no-store")
token, err := service.MCPBearer(c)
if err != nil {
MCPRuntimeError(c, err)
return
}
if !mcpRuntimeProof(c, token) {
return
}
data, err := io.ReadAll(http.MaxBytesReader(c.Writer, c.Request.Body, 64<<10))
var req contract.Admission
if err != nil || contract.DecodeObject(data, &req) != nil {
MCPRuntimeError(c, contract.Invalid())
return
}
result, err := service.NewMCPService().Admit(c, token, req)
if err != nil {
MCPRuntimeError(c, err)
return
}
c.JSON(200, result)
}
func MCPRuntimeComplete(c *gin.Context) {
c.Header("Cache-Control", "no-store")
token, err := service.MCPBearer(c)
if err != nil {
MCPRuntimeError(c, err)
return
}
data, err := io.ReadAll(http.MaxBytesReader(c.Writer, c.Request.Body, 1024))
var req contract.Completion
if err != nil || contract.DecodeObject(data, &req) != nil {
MCPRuntimeError(c, contract.Invalid())
return
}
result, err := service.NewMCPService().CompleteWithOutcome(token, req)
if err != nil {
MCPRuntimeError(c, err)
return
}
if req.ErrorCode != "" {
c.JSON(http.StatusOK, result)
return
}
c.Status(204)
}
+64
View File
@@ -0,0 +1,64 @@
package v2
import (
"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"
"github.com/gin-gonic/gin"
)
func (b *BaseApi) RevealMCPCredential(c *gin.Context) {
s, owner, ok := mcpManagement(c, true, false)
if !ok {
return
}
var req dto.MCPCredentialMutation
if !mcpRead(c, &req) {
return
}
result, err := s.Reveal(owner, req)
if err != nil {
mcpManagementError(c, err)
return
}
helper.SuccessWithData(c, result)
}
func (b *BaseApi) RotateMCPCredential(c *gin.Context) {
s, owner, ok := mcpManagement(c, true, false)
if !ok {
return
}
var req dto.MCPCredentialMutation
if !mcpRead(c, &req) {
return
}
result, err := s.Rotate(owner, req)
if err != nil {
mcpManagementError(c, err)
return
}
helper.SuccessWithData(c, result)
}
func (b *BaseApi) ExportMCPCredential(c *gin.Context) {
s, owner, ok := mcpManagement(c, true, false)
if !ok {
return
}
var req dto.MCPEnrollmentExport
if !mcpRead(c, &req) {
return
}
settings, err := service.NewMCPRuntimeService().Settings()
if err != nil {
mcpManagementError(c, err)
return
}
result, err := s.ExportMarkdown(owner, req, mcpPublicEndpoint(c, settings.ExternalURL), c.GetHeader("Accept-Language"))
if err != nil {
mcpManagementError(c, err)
return
}
helper.SuccessWithData(c, result)
}
+36
View File
@@ -0,0 +1,36 @@
package v2
import "github.com/1Panel-dev/1Panel/core/i18n"
// Management responses use panel localization while runtime errors retain their
// stable machine contract. Never render raw errors or credential data here.
func mcpManagementMessage(code string) string {
key := "ErrInternalServer"
switch code {
case "MCP_INVALID_ARGUMENTS":
key = "ErrInvalidParams"
case "MCP_UNAUTHENTICATED":
key = "ErrNotLogin"
case "MCP_FORBIDDEN":
key = "ErrRBAC"
case "MCP_CONFLICT":
key = "ErrMCPConflict"
case "MCP_POLICY_CHANGED", "MCP_SCHEMA_MISMATCH":
key = "ErrMCPPolicyChanged"
case "MCP_CATALOG_UNAVAILABLE":
key = "ErrMCPCatalogUnavailable"
case "MCP_DISABLED":
key = "ErrMCPDisabled"
case "MCP_SECRET_KEY_UNAVAILABLE":
key = "ErrMCPSecretKeyUnavailable"
case "MCP_SECRET_INVALID":
key = "ErrMCPSecretInvalid"
case "MCP_CREDENTIAL_INACTIVE":
key = "ErrMCPCredentialInactive"
case "MCP_AUDIT_UNAVAILABLE":
key = "ErrMCPAuditUnavailable"
case "MCP_ENDPOINT_UNAVAILABLE":
key = "ErrMCPEndpointUnavailable"
}
return i18n.GetMsgWithMap(key, nil)
}
+62
View File
@@ -0,0 +1,62 @@
package dto
import "time"
type MCPCredentialFields struct {
Name string `json:"name"`
Description string `json:"description"`
ExpiresAt *time.Time `json:"expiresAt"`
Tools []string `json:"tools"`
ToolPolicies map[string]string `json:"toolPolicies"`
ToolReadOnly map[string]bool `json:"toolReadOnly"`
NodeIDs []string `json:"nodeIds"`
}
type MCPNodeItem struct {
ID string `json:"id"`
Name string `json:"name"`
Status string `json:"status"`
IsLocal bool `json:"isLocal"`
}
type MCPCredentialCreate struct {
RequestID string `json:"requestID"`
MCPCredentialFields
}
type MCPCredentialMutation struct {
ID string `json:"id"`
Revision uint64 `json:"revision"`
}
type MCPCredentialUpdate struct {
MCPCredentialMutation
MCPCredentialFields
}
type MCPCredentialStatus struct {
MCPCredentialMutation
Status string `json:"status"`
}
type MCPCredentialItem struct {
ID string `json:"id"`
MCPCredentialFields
KeyHint string `json:"keyHint"`
Status string `json:"status"`
Revision uint64 `json:"revision"`
CreatedAt time.Time `json:"createdAt"`
OwnerName string `json:"ownerName"`
StaleTools []string `json:"staleTools"`
}
type MCPCredentialCreated struct {
Item MCPCredentialItem `json:"item"`
Token string `json:"token"`
AlreadyCreated bool `json:"alreadyCreated,omitempty"`
}
type MCPCredentialSearch struct {
Page int `json:"page"`
PageSize int `json:"pageSize"`
ExcludeRevoked bool `json:"excludeRevoked"`
}
type MCPInvocationSearch struct {
Page int `json:"page"`
PageSize int `json:"pageSize"`
CredentialID string `json:"credentialId"`
ToolID string `json:"toolId"`
Status string `json:"status"`
}
+16
View File
@@ -0,0 +1,16 @@
package dto
type MCPCredentialSecret struct {
ID string `json:"id"`
Token string `json:"token"`
}
type MCPEnrollmentExport struct {
MCPCredentialMutation
Client string `json:"client"`
}
type MCPEnrollmentDocument struct {
Filename string `json:"filename"`
Content string `json:"content"`
}

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