mirror of
https://github.com/1Panel-dev/1Panel.git
synced 2026-10-29 16:05:34 +00:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
50955e20b7 | ||
|
|
bd67614a53 | ||
|
|
14dd3135ec |
@@ -826,26 +826,6 @@ func (b *BaseApi) UpdateAgentFeishuConfig(c *gin.Context) {
|
|||||||
helper.Success(c)
|
helper.Success(c)
|
||||||
}
|
}
|
||||||
|
|
||||||
// @Tags AI
|
|
||||||
// @Summary Switch to the bundled OpenClaw Feishu plugin
|
|
||||||
// @Accept json
|
|
||||||
// @Param request body dto.AgentFeishuSwitchReq true "request"
|
|
||||||
// @Success 200
|
|
||||||
// @Security ApiKeyAuth
|
|
||||||
// @Security Timestamp
|
|
||||||
// @Router /ai/agents/channel/feishu/switch [post]
|
|
||||||
func (b *BaseApi) SwitchAgentFeishuPlugin(c *gin.Context) {
|
|
||||||
var req dto.AgentFeishuSwitchReq
|
|
||||||
if err := helper.CheckBindAndValidate(&req, c); err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := agentService.SwitchFeishuPlugin(req); err != nil {
|
|
||||||
helper.BadRequest(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
helper.Success(c)
|
|
||||||
}
|
|
||||||
|
|
||||||
// @Tags AI
|
// @Tags AI
|
||||||
// @Summary Get Agent Telegram channel config
|
// @Summary Get Agent Telegram channel config
|
||||||
// @Accept json
|
// @Accept json
|
||||||
|
|||||||
@@ -72,6 +72,22 @@ func (b *BaseApi) CloseOllamaModel(c *gin.Context) {
|
|||||||
helper.SuccessWithData(c, nil)
|
helper.SuccessWithData(c, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// @Tags AI
|
||||||
|
// @Summary Sync Ollama model list
|
||||||
|
// @Success 200 {array} dto.OllamaModelDropList
|
||||||
|
// @Security ApiKeyAuth
|
||||||
|
// @Security Timestamp
|
||||||
|
// @Router /ai/ollama/model/sync [post]
|
||||||
|
// @x-panel-log {"bodyKeys":[],"paramKeys":[],"BeforeFunctions":[],"formatZH":"同步 Ollama 模型列表","formatEN":"sync Ollama model list"}
|
||||||
|
func (b *BaseApi) SyncOllamaModel(c *gin.Context) {
|
||||||
|
list, err := aiToolService.Sync()
|
||||||
|
if err != nil {
|
||||||
|
helper.BadRequest(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
helper.SuccessWithData(c, list)
|
||||||
|
}
|
||||||
|
|
||||||
// @Tags AI
|
// @Tags AI
|
||||||
// @Summary Page Ollama models
|
// @Summary Page Ollama models
|
||||||
// @Accept json
|
// @Accept json
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"github.com/1Panel-dev/1Panel/agent/app/dto/request"
|
"github.com/1Panel-dev/1Panel/agent/app/dto/request"
|
||||||
"github.com/1Panel-dev/1Panel/agent/i18n"
|
"github.com/1Panel-dev/1Panel/agent/i18n"
|
||||||
"github.com/1Panel-dev/1Panel/agent/utils/appicon"
|
"github.com/1Panel-dev/1Panel/agent/utils/appicon"
|
||||||
|
"github.com/1Panel-dev/1Panel/agent/utils/mcpapi"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -160,10 +161,25 @@ func (b *BaseApi) GetAppDetailByID(c *gin.Context) {
|
|||||||
// @Router /apps/install [post]
|
// @Router /apps/install [post]
|
||||||
// @x-panel-log {"bodyKeys":["name"],"paramKeys":[],"BeforeFunctions":[],"formatZH":"安装应用 [name]","formatEN":"Install app [name]"}
|
// @x-panel-log {"bodyKeys":["name"],"paramKeys":[],"BeforeFunctions":[],"formatZH":"安装应用 [name]","formatEN":"Install app [name]"}
|
||||||
func (b *BaseApi) InstallApp(c *gin.Context) {
|
func (b *BaseApi) InstallApp(c *gin.Context) {
|
||||||
|
mcpRequest, trustedMCP := mcpapi.Check(c.Request)
|
||||||
|
if mcpRequest && !trustedMCP {
|
||||||
|
c.AbortWithStatus(http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
var req request.AppInstallCreate
|
var req request.AppInstallCreate
|
||||||
if err := helper.CheckBindAndValidate(&req, c); err != nil {
|
if err := helper.CheckBindAndValidate(&req, c); err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if mcpRequest {
|
||||||
|
req.MCPPreserveWebsiteDir = true
|
||||||
|
if value, exists := req.Params["WEBSITE_DIR"]; exists {
|
||||||
|
directory, ok := value.(string)
|
||||||
|
if !ok || directory == "" || directory != settingService.GetWebsiteDir() {
|
||||||
|
c.AbortWithStatus(http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
install, err := appService.Install(req, true)
|
install, err := appService.Install(req, true)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
helper.InternalServer(c, err)
|
helper.InternalServer(c, err)
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
package v2
|
package v2
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
"github.com/1Panel-dev/1Panel/agent/app/dto/request"
|
"github.com/1Panel-dev/1Panel/agent/app/dto/request"
|
||||||
|
"github.com/1Panel-dev/1Panel/agent/app/service"
|
||||||
|
"github.com/1Panel-dev/1Panel/agent/utils/mcpapi"
|
||||||
|
|
||||||
"github.com/1Panel-dev/1Panel/agent/app/api/v2/helper"
|
"github.com/1Panel-dev/1Panel/agent/app/api/v2/helper"
|
||||||
"github.com/1Panel-dev/1Panel/agent/app/dto"
|
"github.com/1Panel-dev/1Panel/agent/app/dto"
|
||||||
@@ -336,11 +340,25 @@ func (b *BaseApi) UpdateAppConfig(c *gin.Context) {
|
|||||||
// @Security Timestamp
|
// @Security Timestamp
|
||||||
// @Router /apps/installed/info/:appInstallId [get]
|
// @Router /apps/installed/info/:appInstallId [get]
|
||||||
func (b *BaseApi) GetAppInstallInfo(c *gin.Context) {
|
func (b *BaseApi) GetAppInstallInfo(c *gin.Context) {
|
||||||
|
mcpRequest, trustedMCP := mcpapi.Check(c.Request)
|
||||||
|
if mcpRequest && !trustedMCP {
|
||||||
|
c.AbortWithStatus(http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
appInstallId, err := helper.GetIntParamByKey(c, "appInstallId")
|
appInstallId, err := helper.GetIntParamByKey(c, "appInstallId")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
helper.BadRequest(c, err)
|
helper.BadRequest(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if mcpRequest {
|
||||||
|
info, err := (&service.AppInstallService{}).GetAppInstallInfoForMCP(c.Request.Context(), appInstallId)
|
||||||
|
if err != nil {
|
||||||
|
helper.InternalServer(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
helper.SuccessWithData(c, info)
|
||||||
|
return
|
||||||
|
}
|
||||||
info, err := appInstallService.GetAppInstallInfo(appInstallId)
|
info, err := appInstallService.GetAppInstallInfo(appInstallId)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
helper.InternalServer(c, err)
|
helper.InternalServer(c, err)
|
||||||
|
|||||||
@@ -2,9 +2,12 @@ package v2
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
"github.com/1Panel-dev/1Panel/agent/app/api/v2/helper"
|
"github.com/1Panel-dev/1Panel/agent/app/api/v2/helper"
|
||||||
"github.com/1Panel-dev/1Panel/agent/app/dto"
|
"github.com/1Panel-dev/1Panel/agent/app/dto"
|
||||||
|
"github.com/1Panel-dev/1Panel/agent/app/service"
|
||||||
|
"github.com/1Panel-dev/1Panel/agent/utils/mcpapi"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -98,11 +101,25 @@ func (b *BaseApi) SearchDatabase(c *gin.Context) {
|
|||||||
// @Param type path string true "type"
|
// @Param type path string true "type"
|
||||||
// @Router /databases/db/list/:type [get]
|
// @Router /databases/db/list/:type [get]
|
||||||
func (b *BaseApi) ListDatabase(c *gin.Context) {
|
func (b *BaseApi) ListDatabase(c *gin.Context) {
|
||||||
|
mcpRequest, trustedMCP := mcpapi.Check(c.Request)
|
||||||
|
if mcpRequest && !trustedMCP {
|
||||||
|
c.AbortWithStatus(http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
dbType, err := helper.GetStrParamByKey(c, "type")
|
dbType, err := helper.GetStrParamByKey(c, "type")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
helper.BadRequest(c, err)
|
helper.BadRequest(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if mcpRequest {
|
||||||
|
list, err := (&service.DatabaseService{}).ListForMCP(c.Request.Context(), dbType)
|
||||||
|
if err != nil {
|
||||||
|
helper.BadRequest(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
helper.SuccessWithData(c, list)
|
||||||
|
return
|
||||||
|
}
|
||||||
list, err := databaseService.List(dbType)
|
list, err := databaseService.List(dbType)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
helper.InternalServer(c, err)
|
helper.InternalServer(c, err)
|
||||||
|
|||||||
@@ -3,9 +3,12 @@ package v2
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
"github.com/1Panel-dev/1Panel/agent/app/api/v2/helper"
|
"github.com/1Panel-dev/1Panel/agent/app/api/v2/helper"
|
||||||
"github.com/1Panel-dev/1Panel/agent/app/dto"
|
"github.com/1Panel-dev/1Panel/agent/app/dto"
|
||||||
|
"github.com/1Panel-dev/1Panel/agent/app/service"
|
||||||
|
"github.com/1Panel-dev/1Panel/agent/utils/mcpapi"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -19,6 +22,11 @@ import (
|
|||||||
// @Router /databases [post]
|
// @Router /databases [post]
|
||||||
// @x-panel-log {"bodyKeys":["name"],"paramKeys":[],"BeforeFunctions":[],"formatZH":"创建 mysql 数据库 [name]","formatEN":"create mysql database [name]"}
|
// @x-panel-log {"bodyKeys":["name"],"paramKeys":[],"BeforeFunctions":[],"formatZH":"创建 mysql 数据库 [name]","formatEN":"create mysql database [name]"}
|
||||||
func (b *BaseApi) CreateMysql(c *gin.Context) {
|
func (b *BaseApi) CreateMysql(c *gin.Context) {
|
||||||
|
mcpRequest, trustedMCP := mcpapi.Check(c.Request)
|
||||||
|
if mcpRequest && !trustedMCP {
|
||||||
|
c.AbortWithStatus(http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
var req dto.MysqlDBCreate
|
var req dto.MysqlDBCreate
|
||||||
if err := helper.CheckBindAndValidate(&req, c); err != nil {
|
if err := helper.CheckBindAndValidate(&req, c); err != nil {
|
||||||
return
|
return
|
||||||
@@ -32,6 +40,17 @@ func (b *BaseApi) CreateMysql(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
req.Password = string(password)
|
req.Password = string(password)
|
||||||
}
|
}
|
||||||
|
if mcpRequest {
|
||||||
|
created, err := (&service.MysqlService{}).CreateForMCP(c.Request.Context(), req)
|
||||||
|
if err != nil {
|
||||||
|
helper.InternalServer(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
nodeID, _ := mcpapi.NodeID(c.Request)
|
||||||
|
helper.SuccessWithData(c, map[string]any{"id": created.ID, "database": created.MysqlName, "name": created.Name, "nodeId": nodeID})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if _, err := mysqlService.Create(context.Background(), req); err != nil {
|
if _, err := mysqlService.Create(context.Background(), req); err != nil {
|
||||||
helper.InternalServer(c, err)
|
helper.InternalServer(c, err)
|
||||||
return
|
return
|
||||||
@@ -512,11 +531,25 @@ func (b *BaseApi) LoadRemoteAccess(c *gin.Context) {
|
|||||||
// @Security Timestamp
|
// @Security Timestamp
|
||||||
// @Router /databases/status [post]
|
// @Router /databases/status [post]
|
||||||
func (b *BaseApi) LoadStatus(c *gin.Context) {
|
func (b *BaseApi) LoadStatus(c *gin.Context) {
|
||||||
|
mcpRequest, trustedMCP := mcpapi.Check(c.Request)
|
||||||
|
if mcpRequest && !trustedMCP {
|
||||||
|
c.AbortWithStatus(http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
var req dto.OperationWithNameAndType
|
var req dto.OperationWithNameAndType
|
||||||
if err := helper.CheckBindAndValidate(&req, c); err != nil {
|
if err := helper.CheckBindAndValidate(&req, c); err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if mcpRequest {
|
||||||
|
data, err := (&service.MysqlService{}).LoadStatusForMCP(c.Request.Context(), req)
|
||||||
|
if err != nil {
|
||||||
|
helper.InternalServer(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
helper.SuccessWithData(c, data)
|
||||||
|
return
|
||||||
|
}
|
||||||
data, err := mysqlService.LoadStatus(req)
|
data, err := mysqlService.LoadStatus(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
helper.InternalServer(c, err)
|
helper.InternalServer(c, err)
|
||||||
|
|||||||
@@ -1,137 +0,0 @@
|
|||||||
package v2
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/base64"
|
|
||||||
"net/http"
|
|
||||||
|
|
||||||
"github.com/1Panel-dev/1Panel/agent/app/api/v2/helper"
|
|
||||||
"github.com/1Panel-dev/1Panel/agent/app/dto"
|
|
||||||
"github.com/1Panel-dev/1Panel/agent/app/dto/request"
|
|
||||||
"github.com/1Panel-dev/1Panel/agent/app/model"
|
|
||||||
"github.com/1Panel-dev/1Panel/agent/app/service"
|
|
||||||
"github.com/1Panel-dev/1Panel/agent/utils/mcpapi"
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
)
|
|
||||||
|
|
||||||
type mcpBusinessAPI struct {
|
|
||||||
installApp func(request.AppInstallCreate, bool) (*model.AppInstall, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
func MCPBusinessDispatch() gin.HandlerFunc {
|
|
||||||
return (&mcpBusinessAPI{installApp: (&service.AppService{}).InstallForMCP}).dispatch()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (api *mcpBusinessAPI) dispatch() gin.HandlerFunc {
|
|
||||||
return func(c *gin.Context) {
|
|
||||||
var handler gin.HandlerFunc
|
|
||||||
switch c.Request.Method + " " + c.FullPath() {
|
|
||||||
case "POST /api/v2/databases":
|
|
||||||
handler = api.createMysql
|
|
||||||
case "POST /api/v2/databases/status":
|
|
||||||
handler = api.mysqlStatus
|
|
||||||
case "GET /api/v2/databases/db/list/:type":
|
|
||||||
handler = api.databases
|
|
||||||
case "POST /api/v2/apps/install":
|
|
||||||
handler = api.install
|
|
||||||
case "GET /api/v2/apps/installed/info/:appInstallId":
|
|
||||||
handler = api.appInfo
|
|
||||||
default:
|
|
||||||
c.Next()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
present, trusted := mcpapi.Check(c.Request)
|
|
||||||
if !present {
|
|
||||||
c.Next()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if !trusted {
|
|
||||||
c.AbortWithStatus(http.StatusForbidden)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
handler(c)
|
|
||||||
c.Abort()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (api *mcpBusinessAPI) createMysql(c *gin.Context) {
|
|
||||||
var req dto.MysqlDBCreate
|
|
||||||
if err := helper.CheckBindAndValidate(&req, c); err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if len(req.Password) != 0 {
|
|
||||||
password, err := base64.StdEncoding.DecodeString(req.Password)
|
|
||||||
if err != nil {
|
|
||||||
helper.BadRequest(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
req.Password = string(password)
|
|
||||||
}
|
|
||||||
created, err := (&service.MysqlService{}).CreateForMCP(c.Request.Context(), req)
|
|
||||||
if err != nil {
|
|
||||||
helper.InternalServer(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
nodeID, _ := mcpapi.NodeID(c.Request)
|
|
||||||
helper.SuccessWithData(c, map[string]any{"id": created.ID, "database": created.MysqlName, "name": created.Name, "nodeId": nodeID})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (api *mcpBusinessAPI) mysqlStatus(c *gin.Context) {
|
|
||||||
var req dto.OperationWithNameAndType
|
|
||||||
if err := helper.CheckBindAndValidate(&req, c); err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
data, err := (&service.MysqlService{}).LoadStatusForMCP(c.Request.Context(), req)
|
|
||||||
if err != nil {
|
|
||||||
helper.InternalServer(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
helper.SuccessWithData(c, data)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (api *mcpBusinessAPI) databases(c *gin.Context) {
|
|
||||||
dbType, err := helper.GetStrParamByKey(c, "type")
|
|
||||||
if err != nil {
|
|
||||||
helper.BadRequest(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
list, err := (&service.DatabaseService{}).ListForMCP(c.Request.Context(), dbType)
|
|
||||||
if err != nil {
|
|
||||||
helper.BadRequest(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
helper.SuccessWithData(c, list)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (api *mcpBusinessAPI) install(c *gin.Context) {
|
|
||||||
var req request.AppInstallCreate
|
|
||||||
if err := helper.CheckBindAndValidate(&req, c); err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if value, exists := req.Params["WEBSITE_DIR"]; exists {
|
|
||||||
directory, ok := value.(string)
|
|
||||||
if !ok || directory == "" || directory != settingService.GetWebsiteDir() {
|
|
||||||
c.AbortWithStatus(http.StatusForbidden)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
installed, err := api.installApp(req, true)
|
|
||||||
if err != nil {
|
|
||||||
helper.InternalServer(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
helper.SuccessWithData(c, installed)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (api *mcpBusinessAPI) appInfo(c *gin.Context) {
|
|
||||||
id, err := helper.GetIntParamByKey(c, "appInstallId")
|
|
||||||
if err != nil {
|
|
||||||
helper.BadRequest(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
info, err := (&service.AppInstallService{}).GetAppInstallInfoForMCP(c.Request.Context(), id)
|
|
||||||
if err != nil {
|
|
||||||
helper.InternalServer(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
helper.SuccessWithData(c, info)
|
|
||||||
}
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
package v2
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net"
|
|
||||||
"net/http"
|
|
||||||
|
|
||||||
"github.com/1Panel-dev/1Panel/agent/constant"
|
|
||||||
"github.com/1Panel-dev/1Panel/agent/global"
|
|
||||||
"github.com/1Panel-dev/1Panel/agent/utils/taskscope"
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
)
|
|
||||||
|
|
||||||
func PanelMCPTaskAccess() gin.HandlerFunc {
|
|
||||||
return func(c *gin.Context) {
|
|
||||||
if hasPanelMCPTaskAccess(c.Request) {
|
|
||||||
c.Request = c.Request.WithContext(taskscope.WithGrants(c.Request.Context(), constant.TaskScopePanelMCP))
|
|
||||||
}
|
|
||||||
c.Next()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func hasPanelMCPTaskAccess(req *http.Request) bool {
|
|
||||||
if !global.IsMaster {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
local, ok := req.Context().Value(http.LocalAddrContextKey).(net.Addr)
|
|
||||||
values := req.Header.Values("X-Panel-MCP-Task-Access")
|
|
||||||
return ok && local.Network() == "unix" && len(values) == 1 && values[0] == "1"
|
|
||||||
}
|
|
||||||
@@ -5,8 +5,6 @@ import (
|
|||||||
"github.com/1Panel-dev/1Panel/agent/app/dto"
|
"github.com/1Panel-dev/1Panel/agent/app/dto"
|
||||||
"github.com/1Panel-dev/1Panel/agent/app/dto/request"
|
"github.com/1Panel-dev/1Panel/agent/app/dto/request"
|
||||||
"github.com/1Panel-dev/1Panel/agent/app/dto/response"
|
"github.com/1Panel-dev/1Panel/agent/app/dto/response"
|
||||||
"github.com/1Panel-dev/1Panel/agent/app/service"
|
|
||||||
"github.com/1Panel-dev/1Panel/agent/utils/taskscope"
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -23,7 +21,7 @@ func (b *BaseApi) PageTasks(c *gin.Context) {
|
|||||||
if err := helper.CheckBindAndValidate(&req, c); err != nil {
|
if err := helper.CheckBindAndValidate(&req, c); err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
total, list, err := taskServiceForRequest(c).Page(req)
|
total, list, err := taskService.Page(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
helper.InternalServer(c, err)
|
helper.InternalServer(c, err)
|
||||||
return
|
return
|
||||||
@@ -47,7 +45,7 @@ func (b *BaseApi) ReadTaskLogByLine(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
var res *response.FileLineContent
|
var res *response.FileLineContent
|
||||||
res, err := taskServiceForRequest(c).ReadByLine(req)
|
res, err := taskService.ReadByLine(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
helper.InternalServer(c, err)
|
helper.InternalServer(c, err)
|
||||||
return
|
return
|
||||||
@@ -66,17 +64,10 @@ func (b *BaseApi) ReadTaskLogByLine(c *gin.Context) {
|
|||||||
// @Security Timestamp
|
// @Security Timestamp
|
||||||
// @Router /logs/tasks/executing/count [get]
|
// @Router /logs/tasks/executing/count [get]
|
||||||
func (b *BaseApi) CountExecutingTasks(c *gin.Context) {
|
func (b *BaseApi) CountExecutingTasks(c *gin.Context) {
|
||||||
count, err := taskServiceForRequest(c).CountExecutingTask()
|
count, err := taskService.CountExecutingTask()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
helper.InternalServer(c, err)
|
helper.InternalServer(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
helper.SuccessWithData(c, count)
|
helper.SuccessWithData(c, count)
|
||||||
}
|
}
|
||||||
|
|
||||||
func taskServiceForRequest(c *gin.Context) service.ITaskLogService {
|
|
||||||
if granted := taskscope.Grants(c.Request.Context()); len(granted) != 0 {
|
|
||||||
return service.NewITaskService(granted...)
|
|
||||||
}
|
|
||||||
return taskService
|
|
||||||
}
|
|
||||||
|
|||||||
+22
-43
@@ -433,53 +433,32 @@ type AgentFeishuConfigReq struct {
|
|||||||
AgentID uint `json:"agentId" validate:"required"`
|
AgentID uint `json:"agentId" validate:"required"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type AgentFeishuTransition struct {
|
|
||||||
Confirmed bool `json:"confirmed"`
|
|
||||||
Streaming bool `json:"streaming"`
|
|
||||||
GroupSessionScope string `json:"groupSessionScope"`
|
|
||||||
RequireMention string `json:"requireMention"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type AgentFeishuSwitchReq struct {
|
|
||||||
AgentID uint `json:"agentId" validate:"required"`
|
|
||||||
TaskID string `json:"taskID" validate:"required"`
|
|
||||||
Streaming bool `json:"streaming"`
|
|
||||||
GroupSessionScope string `json:"groupSessionScope" validate:"required,oneof=group group_sender group_topic group_topic_sender"`
|
|
||||||
RequireMention string `json:"requireMention" validate:"required,oneof=true false"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type AgentFeishuConfigUpdateReq struct {
|
type AgentFeishuConfigUpdateReq struct {
|
||||||
GroupSessionScope string `json:"groupSessionScope" validate:"omitempty,oneof=group group_sender group_topic group_topic_sender"`
|
AgentID uint `json:"agentId" validate:"required"`
|
||||||
AgentID uint `json:"agentId" validate:"required"`
|
Enabled bool `json:"enabled"`
|
||||||
Enabled bool `json:"enabled"`
|
ThreadSession bool `json:"threadSession"`
|
||||||
ThreadSession bool `json:"threadSession"`
|
ReplyMode string `json:"replyMode" validate:"required"`
|
||||||
ReplyMode string `json:"replyMode" validate:"required"`
|
Streaming bool `json:"streaming"`
|
||||||
Streaming bool `json:"streaming"`
|
RequireMention string `json:"requireMention" validate:"required,oneof=true false open"`
|
||||||
RequireMention string `json:"requireMention" validate:"required,oneof=true false open"`
|
GroupPolicy string `json:"groupPolicy" validate:"required,oneof=open allowlist disabled"`
|
||||||
GroupPolicy string `json:"groupPolicy" validate:"required,oneof=open allowlist disabled"`
|
GroupAllowFrom []string `json:"groupAllowFrom"`
|
||||||
GroupAllowFrom []string `json:"groupAllowFrom"`
|
Domain string `json:"domain"`
|
||||||
Domain string `json:"domain"`
|
ConnectionMode string `json:"connectionMode"`
|
||||||
ConnectionMode string `json:"connectionMode"`
|
Bots []AgentFeishuBot `json:"bots" validate:"required,min=1"`
|
||||||
Bots []AgentFeishuBot `json:"bots" validate:"required,min=1"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type AgentFeishuConfig struct {
|
type AgentFeishuConfig struct {
|
||||||
BuiltinManaged bool `json:"builtinManaged"`
|
Enabled bool `json:"enabled"`
|
||||||
LegacyConfigured bool `json:"legacyConfigured"`
|
ThreadSession bool `json:"threadSession"`
|
||||||
GroupSessionScope string `json:"groupSessionScope"`
|
ReplyMode string `json:"replyMode"`
|
||||||
BuiltinAvailable bool `json:"builtinAvailable"`
|
Streaming bool `json:"streaming"`
|
||||||
Legacy bool `json:"legacy"`
|
RequireMention string `json:"requireMention"`
|
||||||
Enabled bool `json:"enabled"`
|
GroupPolicy string `json:"groupPolicy"`
|
||||||
ThreadSession bool `json:"threadSession"`
|
GroupAllowFrom []string `json:"groupAllowFrom"`
|
||||||
ReplyMode string `json:"replyMode"`
|
Domain string `json:"domain"`
|
||||||
Streaming bool `json:"streaming"`
|
ConnectionMode string `json:"connectionMode"`
|
||||||
RequireMention string `json:"requireMention"`
|
Bots []AgentFeishuBot `json:"bots"`
|
||||||
GroupPolicy string `json:"groupPolicy"`
|
Installed bool `json:"installed"`
|
||||||
GroupAllowFrom []string `json:"groupAllowFrom"`
|
|
||||||
Domain string `json:"domain"`
|
|
||||||
ConnectionMode string `json:"connectionMode"`
|
|
||||||
Bots []AgentFeishuBot `json:"bots"`
|
|
||||||
Installed bool `json:"installed"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type AgentTelegramConfigReq struct {
|
type AgentTelegramConfigReq struct {
|
||||||
|
|||||||
@@ -14,6 +14,11 @@ type OllamaModelInfo struct {
|
|||||||
CreatedAt time.Time `json:"createdAt"`
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type OllamaModelDropList struct {
|
||||||
|
ID uint `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
|
||||||
type OllamaModelName struct {
|
type OllamaModelName struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
TaskID string `json:"taskID"`
|
TaskID string `json:"taskID"`
|
||||||
|
|||||||
@@ -43,11 +43,9 @@ type AppOssConfig struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type AppVersion struct {
|
type AppVersion struct {
|
||||||
FeishuLegacy bool `json:"feishuLegacy,omitempty"`
|
Version string `json:"version"`
|
||||||
FeishuConfigured bool `json:"feishuConfigured,omitempty"`
|
DetailId uint `json:"detailId"`
|
||||||
Version string `json:"version"`
|
DockerCompose string `json:"dockerCompose"`
|
||||||
DetailId uint `json:"detailId"`
|
|
||||||
DockerCompose string `json:"dockerCompose"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type AppList struct {
|
type AppList struct {
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ type AppInstallCreate struct {
|
|||||||
ModelAccountBaseURLType string `json:"modelAccountBaseURLType" validate:"omitempty,oneof=container localhost systemIP custom"`
|
ModelAccountBaseURLType string `json:"modelAccountBaseURLType" validate:"omitempty,oneof=container localhost systemIP custom"`
|
||||||
ModelAccountBaseURL string `json:"modelAccountBaseURL"`
|
ModelAccountBaseURL string `json:"modelAccountBaseURL"`
|
||||||
|
|
||||||
|
MCPPreserveWebsiteDir bool `json:"-"`
|
||||||
|
|
||||||
AppContainerConfig
|
AppContainerConfig
|
||||||
NodePushConfig
|
NodePushConfig
|
||||||
}
|
}
|
||||||
@@ -87,33 +89,31 @@ type AppBackupDelete struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type AppInstalledOperate struct {
|
type AppInstalledOperate struct {
|
||||||
FeishuTransition *dto.AgentFeishuTransition `json:"feishuTransition,omitempty"`
|
InstallId uint `json:"installId" validate:"required"`
|
||||||
InstallId uint `json:"installId" validate:"required"`
|
BackupId uint `json:"backupId"`
|
||||||
BackupId uint `json:"backupId"`
|
DetailId uint `json:"detailId"`
|
||||||
DetailId uint `json:"detailId"`
|
Operate constant.AppOperate `json:"operate" validate:"required"`
|
||||||
Operate constant.AppOperate `json:"operate" validate:"required"`
|
ForceDelete bool `json:"forceDelete"`
|
||||||
ForceDelete bool `json:"forceDelete"`
|
DeleteBackup bool `json:"deleteBackup"`
|
||||||
DeleteBackup bool `json:"deleteBackup"`
|
DeleteDB bool `json:"deleteDB"`
|
||||||
DeleteDB bool `json:"deleteDB"`
|
Backup bool `json:"backup"`
|
||||||
Backup bool `json:"backup"`
|
PullImage bool `json:"pullImage"`
|
||||||
PullImage bool `json:"pullImage"`
|
DockerCompose string `json:"dockerCompose"`
|
||||||
DockerCompose string `json:"dockerCompose"`
|
TaskID string `json:"taskID"`
|
||||||
TaskID string `json:"taskID"`
|
DeleteImage bool `json:"deleteImage"`
|
||||||
DeleteImage bool `json:"deleteImage"`
|
Favorite bool `json:"favorite"`
|
||||||
Favorite bool `json:"favorite"`
|
|
||||||
|
|
||||||
UseLifecycleScripts bool `json:"-"`
|
UseLifecycleScripts bool `json:"-"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type AppInstallUpgrade struct {
|
type AppInstallUpgrade struct {
|
||||||
FeishuTransition *dto.AgentFeishuTransition `json:"feishuTransition,omitempty"`
|
InstallID uint `json:"installId"`
|
||||||
InstallID uint `json:"installId"`
|
DetailID uint `json:"detailId"`
|
||||||
DetailID uint `json:"detailId"`
|
Backup bool `json:"backup"`
|
||||||
Backup bool `json:"backup"`
|
PullImage bool `json:"pullImage"`
|
||||||
PullImage bool `json:"pullImage"`
|
DeleteImage bool `json:"deleteImage"`
|
||||||
DeleteImage bool `json:"deleteImage"`
|
DockerCompose string `json:"dockerCompose"`
|
||||||
DockerCompose string `json:"dockerCompose"`
|
TaskID string `json:"taskID"`
|
||||||
TaskID string `json:"taskID"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type AppInstallDelete struct {
|
type AppInstallDelete struct {
|
||||||
|
|||||||
@@ -1,14 +0,0 @@
|
|||||||
package request
|
|
||||||
|
|
||||||
type MCPWebsiteBinding struct {
|
|
||||||
Operation string `json:"operation"`
|
|
||||||
RequestID string `json:"requestId"`
|
|
||||||
BindingID string `json:"bindingId"`
|
|
||||||
WebsiteID uint `json:"websiteId"`
|
|
||||||
Domain string `json:"domain"`
|
|
||||||
EnableSSL bool `json:"enableSSL"`
|
|
||||||
SSLID uint `json:"sslID"`
|
|
||||||
Upstream string `json:"upstream"`
|
|
||||||
CoreHost string `json:"coreHost"`
|
|
||||||
ProxyKey string `json:"proxyKey"`
|
|
||||||
}
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
package response
|
|
||||||
|
|
||||||
type MCPWebsiteBinding struct {
|
|
||||||
State string `json:"state"`
|
|
||||||
WebsiteID uint `json:"websiteId"`
|
|
||||||
BindingID string `json:"bindingId"`
|
|
||||||
RequestID string `json:"requestId"`
|
|
||||||
Domain string `json:"domain"`
|
|
||||||
Endpoint string `json:"endpoint"`
|
|
||||||
EnableSSL bool `json:"enableSSL"`
|
|
||||||
SSLID uint `json:"sslID"`
|
|
||||||
WebsiteStatus string `json:"websiteStatus"`
|
|
||||||
Reason string `json:"reason"`
|
|
||||||
Code string `json:"code,omitempty"`
|
|
||||||
Error string `json:"error,omitempty"`
|
|
||||||
Definitive bool `json:"definitive,omitempty"`
|
|
||||||
}
|
|
||||||
@@ -1,8 +1,6 @@
|
|||||||
package repo
|
package repo
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
|
|
||||||
"github.com/1Panel-dev/1Panel/agent/app/model"
|
"github.com/1Panel-dev/1Panel/agent/app/model"
|
||||||
"github.com/1Panel-dev/1Panel/agent/global"
|
"github.com/1Panel-dev/1Panel/agent/global"
|
||||||
)
|
)
|
||||||
@@ -16,7 +14,6 @@ type IAiRepo interface {
|
|||||||
Create(cronjob *model.OllamaModel) error
|
Create(cronjob *model.OllamaModel) error
|
||||||
Update(id uint, vars map[string]interface{}) error
|
Update(id uint, vars map[string]interface{}) error
|
||||||
Delete(opts ...DBOption) error
|
Delete(opts ...DBOption) error
|
||||||
DeleteAll(ctx context.Context) error
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewIAiRepo() IAiRepo {
|
func NewIAiRepo() IAiRepo {
|
||||||
@@ -70,7 +67,3 @@ func (u *AiRepo) Delete(opts ...DBOption) error {
|
|||||||
}
|
}
|
||||||
return db.Delete(&model.OllamaModel{}).Error
|
return db.Delete(&model.OllamaModel{}).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (u *AiRepo) DeleteAll(ctx context.Context) error {
|
|
||||||
return getTx(ctx).Where("1 = 1").Delete(&model.OllamaModel{}).Error
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -38,7 +38,6 @@ type IAppInstallRepo interface {
|
|||||||
LoadBaseInfo(key string, name string) (*RootInfo, error)
|
LoadBaseInfo(key string, name string) (*RootInfo, error)
|
||||||
LoadInstallAppByKeyAndName(key string, name string) (*model.AppInstall, error)
|
LoadInstallAppByKeyAndName(key string, name string) (*model.AppInstall, error)
|
||||||
GetFirstByCtx(ctx context.Context, opts ...DBOption) (model.AppInstall, error)
|
GetFirstByCtx(ctx context.Context, opts ...DBOption) (model.AppInstall, error)
|
||||||
GetSnapshot(ctx context.Context, id uint) (model.AppInstall, error)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewIAppInstallRepo() IAppInstallRepo {
|
func NewIAppInstallRepo() IAppInstallRepo {
|
||||||
@@ -132,12 +131,6 @@ func (a *AppInstallRepo) GetFirstByCtx(ctx context.Context, opts ...DBOption) (m
|
|||||||
return install, err
|
return install, err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *AppInstallRepo) GetSnapshot(ctx context.Context, id uint) (model.AppInstall, error) {
|
|
||||||
var install model.AppInstall
|
|
||||||
err := getTx(ctx).WithContext(ctx).Preload("App").Where("id = ?", id).First(&install).Error
|
|
||||||
return install, err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *AppInstallRepo) Create(ctx context.Context, install *model.AppInstall) error {
|
func (a *AppInstallRepo) Create(ctx context.Context, install *model.AppInstall) error {
|
||||||
db := getTx(ctx).Model(&model.AppInstall{})
|
db := getTx(ctx).Model(&model.AppInstall{})
|
||||||
return db.Omit(clause.Associations).Create(&install).Error
|
return db.Omit(clause.Associations).Create(&install).Error
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/1Panel-dev/1Panel/agent/app/model"
|
"github.com/1Panel-dev/1Panel/agent/app/model"
|
||||||
"github.com/1Panel-dev/1Panel/agent/constant"
|
|
||||||
"github.com/1Panel-dev/1Panel/agent/global"
|
"github.com/1Panel-dev/1Panel/agent/global"
|
||||||
"github.com/1Panel-dev/1Panel/agent/utils/encrypt"
|
"github.com/1Panel-dev/1Panel/agent/utils/encrypt"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
@@ -21,8 +20,6 @@ type IDatabaseRepo interface {
|
|||||||
Update(id uint, vars map[string]interface{}) error
|
Update(id uint, vars map[string]interface{}) error
|
||||||
Delete(ctx context.Context, opts ...DBOption) error
|
Delete(ctx context.Context, opts ...DBOption) error
|
||||||
Get(opts ...DBOption) (model.Database, error)
|
Get(opts ...DBOption) (model.Database, error)
|
||||||
ListInstalledLocalMysql(ctx context.Context) ([]InstalledLocalMysql, error)
|
|
||||||
GetInstalledLocalMysql(ctx context.Context, name string) (InstalledLocalMysql, error)
|
|
||||||
WithoutByFrom(from string) DBOption
|
WithoutByFrom(from string) DBOption
|
||||||
WithAppInstallID(appInstallID uint) DBOption
|
WithAppInstallID(appInstallID uint) DBOption
|
||||||
WithTypeList(dbType string) DBOption
|
WithTypeList(dbType string) DBOption
|
||||||
@@ -32,32 +29,6 @@ func NewIDatabaseRepo() IDatabaseRepo {
|
|||||||
return &DatabaseRepo{}
|
return &DatabaseRepo{}
|
||||||
}
|
}
|
||||||
|
|
||||||
type InstalledLocalMysql struct {
|
|
||||||
ID uint
|
|
||||||
Type, From, Database string
|
|
||||||
ContainerName, Version, Env string
|
|
||||||
}
|
|
||||||
|
|
||||||
func installedLocalMysqlQuery(ctx context.Context) *gorm.DB {
|
|
||||||
return getTx(ctx).WithContext(ctx).Table("databases AS d").
|
|
||||||
Joins("JOIN app_installs AS ai ON ai.id = d.app_install_id AND ai.name = d.name").
|
|
||||||
Joins("JOIN apps AS a ON a.id = ai.app_id").
|
|
||||||
Where("d.type = ? AND d.\"from\" = ? AND a.key = ?", constant.AppMysql, "local", constant.AppMysql).
|
|
||||||
Where("ai.status <> ? AND ai.container_name <> ?", constant.StatusDeleted, "")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (d *DatabaseRepo) ListInstalledLocalMysql(ctx context.Context) ([]InstalledLocalMysql, error) {
|
|
||||||
result := make([]InstalledLocalMysql, 0)
|
|
||||||
err := installedLocalMysqlQuery(ctx).Select("d.id, d.type, d.\"from\", d.name AS database, ai.version").Order("d.id").Scan(&result).Error
|
|
||||||
return result, err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (d *DatabaseRepo) GetInstalledLocalMysql(ctx context.Context, name string) (InstalledLocalMysql, error) {
|
|
||||||
var result InstalledLocalMysql
|
|
||||||
err := installedLocalMysqlQuery(ctx).Where("d.name = ?", name).Select("d.id, ai.container_name, ai.version, ai.env").Take(&result).Error
|
|
||||||
return result, err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (d *DatabaseRepo) Get(opts ...DBOption) (model.Database, error) {
|
func (d *DatabaseRepo) Get(opts ...DBOption) (model.Database, error) {
|
||||||
var database model.Database
|
var database model.Database
|
||||||
db := global.DB
|
db := global.DB
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ type IMysqlRepo interface {
|
|||||||
List(opts ...DBOption) ([]model.DatabaseMysql, error)
|
List(opts ...DBOption) ([]model.DatabaseMysql, error)
|
||||||
Page(limit, offset int, opts ...DBOption) (int64, []model.DatabaseMysql, error)
|
Page(limit, offset int, opts ...DBOption) (int64, []model.DatabaseMysql, error)
|
||||||
Create(ctx context.Context, mysql *model.DatabaseMysql) error
|
Create(ctx context.Context, mysql *model.DatabaseMysql) error
|
||||||
CreateMetadata(ctx context.Context, mysql *model.DatabaseMysql) error
|
|
||||||
Delete(ctx context.Context, opts ...DBOption) error
|
Delete(ctx context.Context, opts ...DBOption) error
|
||||||
Update(id uint, vars map[string]interface{}) error
|
Update(id uint, vars map[string]interface{}) error
|
||||||
DeleteLocal(ctx context.Context) error
|
DeleteLocal(ctx context.Context) error
|
||||||
@@ -95,10 +94,6 @@ func (u *MysqlRepo) Create(ctx context.Context, mysql *model.DatabaseMysql) erro
|
|||||||
return getTx(ctx).Create(mysql).Error
|
return getTx(ctx).Create(mysql).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (u *MysqlRepo) CreateMetadata(ctx context.Context, mysql *model.DatabaseMysql) error {
|
|
||||||
return getTx(ctx).WithContext(ctx).Create(mysql).Error
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *MysqlRepo) Delete(ctx context.Context, opts ...DBOption) error {
|
func (u *MysqlRepo) Delete(ctx context.Context, opts ...DBOption) error {
|
||||||
return getTx(ctx, opts...).Delete(&model.DatabaseMysql{}).Error
|
return getTx(ctx, opts...).Delete(&model.DatabaseMysql{}).Error
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,10 +14,8 @@ type DatabaseUserRepo struct{}
|
|||||||
|
|
||||||
type IDatabaseUserRepo interface {
|
type IDatabaseUserRepo interface {
|
||||||
Get(opts ...DBOption) (model.DatabaseUser, error)
|
Get(opts ...DBOption) (model.DatabaseUser, error)
|
||||||
GetEncryptedWithContext(ctx context.Context, opts ...DBOption) (model.DatabaseUser, error)
|
|
||||||
List(opts ...DBOption) ([]model.DatabaseUser, error)
|
List(opts ...DBOption) ([]model.DatabaseUser, error)
|
||||||
Save(user *model.DatabaseUser) error
|
Save(user *model.DatabaseUser) error
|
||||||
SaveEncryptedWithContext(ctx context.Context, user *model.DatabaseUser) error
|
|
||||||
Delete(opts ...DBOption) error
|
Delete(opts ...DBOption) error
|
||||||
DeleteBy(ctx context.Context, opts ...DBOption) error
|
DeleteBy(ctx context.Context, opts ...DBOption) error
|
||||||
Update(vars map[string]interface{}, opts ...DBOption) error
|
Update(vars map[string]interface{}, opts ...DBOption) error
|
||||||
@@ -47,13 +45,6 @@ func (u *DatabaseUserRepo) Get(opts ...DBOption) (model.DatabaseUser, error) {
|
|||||||
return user, nil
|
return user, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetEncryptedWithContext leaves credential transformation to the caller.
|
|
||||||
func (u *DatabaseUserRepo) GetEncryptedWithContext(ctx context.Context, opts ...DBOption) (model.DatabaseUser, error) {
|
|
||||||
var user model.DatabaseUser
|
|
||||||
err := getTx(ctx, opts...).WithContext(ctx).First(&user).Error
|
|
||||||
return user, err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *DatabaseUserRepo) List(opts ...DBOption) ([]model.DatabaseUser, error) {
|
func (u *DatabaseUserRepo) List(opts ...DBOption) ([]model.DatabaseUser, error) {
|
||||||
var users []model.DatabaseUser
|
var users []model.DatabaseUser
|
||||||
db := global.DB.Model(&model.DatabaseUser{})
|
db := global.DB.Model(&model.DatabaseUser{})
|
||||||
@@ -84,10 +75,6 @@ func (u *DatabaseUserRepo) Save(user *model.DatabaseUser) error {
|
|||||||
return global.DB.Save(user).Error
|
return global.DB.Save(user).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (u *DatabaseUserRepo) SaveEncryptedWithContext(ctx context.Context, user *model.DatabaseUser) error {
|
|
||||||
return getTx(ctx).WithContext(ctx).Save(user).Error
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *DatabaseUserRepo) Delete(opts ...DBOption) error {
|
func (u *DatabaseUserRepo) Delete(opts ...DBOption) error {
|
||||||
db := global.DB
|
db := global.DB
|
||||||
for _, opt := range opts {
|
for _, opt := range opts {
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ type IDatabaseUserGrantRepo interface {
|
|||||||
Get(opts ...DBOption) (model.DatabaseUserGrant, error)
|
Get(opts ...DBOption) (model.DatabaseUserGrant, error)
|
||||||
List(opts ...DBOption) ([]model.DatabaseUserGrant, error)
|
List(opts ...DBOption) ([]model.DatabaseUserGrant, error)
|
||||||
Save(grant *model.DatabaseUserGrant) error
|
Save(grant *model.DatabaseUserGrant) error
|
||||||
CreateIfAbsent(ctx context.Context, grant *model.DatabaseUserGrant) error
|
|
||||||
Replace(dbType, database string, grants []model.DatabaseUserGrant) error
|
Replace(dbType, database string, grants []model.DatabaseUserGrant) error
|
||||||
Delete(opts ...DBOption) error
|
Delete(opts ...DBOption) error
|
||||||
DeleteBy(ctx context.Context, opts ...DBOption) error
|
DeleteBy(ctx context.Context, opts ...DBOption) error
|
||||||
@@ -53,12 +52,6 @@ func (u *DatabaseUserGrantRepo) Save(grant *model.DatabaseUserGrant) error {
|
|||||||
return global.DB.Save(grant).Error
|
return global.DB.Save(grant).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (u *DatabaseUserGrantRepo) CreateIfAbsent(ctx context.Context, grant *model.DatabaseUserGrant) error {
|
|
||||||
return getTx(ctx).WithContext(ctx).
|
|
||||||
Where("type = ? AND database = ? AND db_name = ? AND username = ? AND host = ?", grant.Type, grant.Database, grant.DBName, grant.Username, grant.Host).
|
|
||||||
FirstOrCreate(grant).Error
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *DatabaseUserGrantRepo) Replace(dbType, database string, grants []model.DatabaseUserGrant) error {
|
func (u *DatabaseUserGrantRepo) Replace(dbType, database string, grants []model.DatabaseUserGrant) error {
|
||||||
return global.DB.Transaction(func(tx *gorm.DB) error {
|
return global.DB.Transaction(func(tx *gorm.DB) error {
|
||||||
if err := tx.Where("`type` = ? AND database = ?", dbType, database).Delete(&model.DatabaseUserGrant{}).Error; err != nil {
|
if err := tx.Where("`type` = ? AND database = ?", dbType, database).Delete(&model.DatabaseUserGrant{}).Error; err != nil {
|
||||||
|
|||||||
@@ -15,13 +15,10 @@ type SettingRepo struct{}
|
|||||||
type ISettingRepo interface {
|
type ISettingRepo interface {
|
||||||
GetList(opts ...DBOption) ([]model.Setting, error)
|
GetList(opts ...DBOption) ([]model.Setting, error)
|
||||||
Get(opts ...DBOption) (model.Setting, error)
|
Get(opts ...DBOption) (model.Setting, error)
|
||||||
GetWithContext(ctx context.Context, opts ...DBOption) (model.Setting, error)
|
|
||||||
GetValueByKey(key string) (string, error)
|
GetValueByKey(key string) (string, error)
|
||||||
GetCoreValueByKey(key string) (string, error)
|
GetCoreValueByKey(key string) (string, error)
|
||||||
Create(key, value string) error
|
Create(key, value string) error
|
||||||
CreateWithContext(ctx context.Context, setting *model.Setting) error
|
|
||||||
Update(key, value string) error
|
Update(key, value string) error
|
||||||
UpdateIfMatch(ctx context.Context, id uint, expectedValue, value string) (int64, error)
|
|
||||||
WithByKey(key string) DBOption
|
WithByKey(key string) DBOption
|
||||||
|
|
||||||
UpdateOrCreate(key, value string) error
|
UpdateOrCreate(key, value string) error
|
||||||
@@ -70,22 +67,6 @@ func (s *SettingRepo) Get(opts ...DBOption) (model.Setting, error) {
|
|||||||
return settings, err
|
return settings, err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *SettingRepo) GetWithContext(ctx context.Context, opts ...DBOption) (model.Setting, error) {
|
|
||||||
var setting model.Setting
|
|
||||||
err := getTx(ctx, opts...).WithContext(ctx).First(&setting).Error
|
|
||||||
return setting, err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *SettingRepo) CreateWithContext(ctx context.Context, setting *model.Setting) error {
|
|
||||||
return getTx(ctx).WithContext(ctx).Create(setting).Error
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *SettingRepo) UpdateIfMatch(ctx context.Context, id uint, expectedValue, value string) (int64, error) {
|
|
||||||
result := getTx(ctx).WithContext(ctx).Model(&model.Setting{}).
|
|
||||||
Where("id = ? AND value = ?", id, expectedValue).Update("value", value)
|
|
||||||
return result.RowsAffected, result.Error
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *SettingRepo) GetValueByKey(key string) (string, error) {
|
func (s *SettingRepo) GetValueByKey(key string) (string, error) {
|
||||||
var setting model.Setting
|
var setting model.Setting
|
||||||
if err := global.DB.Model(&model.Setting{}).Where("key = ?", key).First(&setting).Error; err != nil {
|
if err := global.DB.Model(&model.Setting{}).Where("key = ?", key).First(&setting).Error; err != nil {
|
||||||
|
|||||||
+6
-19
@@ -19,8 +19,8 @@ type ITaskRepo interface {
|
|||||||
GetFirst(opts ...DBOption) (model.Task, error)
|
GetFirst(opts ...DBOption) (model.Task, error)
|
||||||
Page(page, size int, opts ...DBOption) (int64, []model.Task, error)
|
Page(page, size int, opts ...DBOption) (int64, []model.Task, error)
|
||||||
Update(ctx context.Context, task *model.Task) error
|
Update(ctx context.Context, task *model.Task) error
|
||||||
UpdateRunningTaskToFailed(opts ...DBOption) error
|
UpdateRunningTaskToFailed() error
|
||||||
CountExecutingTask(opts ...DBOption) (int64, error)
|
CountExecutingTask() (int64, error)
|
||||||
Delete(opts ...DBOption) error
|
Delete(opts ...DBOption) error
|
||||||
DeleteAll() error
|
DeleteAll() error
|
||||||
|
|
||||||
@@ -29,7 +29,6 @@ type ITaskRepo interface {
|
|||||||
WithResourceID(id uint) DBOption
|
WithResourceID(id uint) DBOption
|
||||||
WithOperate(taskOperate string) DBOption
|
WithOperate(taskOperate string) DBOption
|
||||||
WithByStatus(status string) DBOption
|
WithByStatus(status string) DBOption
|
||||||
WithoutScopes(scopes ...string) DBOption
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewITaskRepo() ITaskRepo {
|
func NewITaskRepo() ITaskRepo {
|
||||||
@@ -111,30 +110,18 @@ func (t TaskRepo) Update(ctx context.Context, task *model.Task) error {
|
|||||||
return getTaskTx(ctx).Save(&task).Error
|
return getTaskTx(ctx).Save(&task).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t TaskRepo) UpdateRunningTaskToFailed(opts ...DBOption) error {
|
func (t TaskRepo) UpdateRunningTaskToFailed() error {
|
||||||
opts = append(opts, t.WithByStatus(constant.StatusExecuting))
|
return getTaskDb(t.WithByStatus(constant.StatusExecuting)).Model(&model.Task{}).Updates(map[string]interface{}{
|
||||||
return getTaskDb(opts...).Model(&model.Task{}).Updates(map[string]interface{}{
|
|
||||||
"status": constant.StatusFailed, "error_msg": constant.InterruptedMsg, "end_at": time.Now(),
|
"status": constant.StatusFailed, "error_msg": constant.InterruptedMsg, "end_at": time.Now(),
|
||||||
}).Error
|
}).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t TaskRepo) CountExecutingTask(opts ...DBOption) (int64, error) {
|
func (t TaskRepo) CountExecutingTask() (int64, error) {
|
||||||
var count int64
|
var count int64
|
||||||
opts = append(opts, t.WithByStatus(constant.StatusExecuting))
|
err := getTaskDb(t.WithByStatus(constant.StatusExecuting)).Model(&model.Task{}).Count(&count).Error
|
||||||
err := getTaskDb(opts...).Model(&model.Task{}).Count(&count).Error
|
|
||||||
return count, err
|
return count, err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t TaskRepo) WithoutScopes(scopes ...string) DBOption {
|
|
||||||
excluded := append([]string(nil), scopes...)
|
|
||||||
return func(db *gorm.DB) *gorm.DB {
|
|
||||||
if len(excluded) == 0 {
|
|
||||||
return db
|
|
||||||
}
|
|
||||||
return db.Where("type IS NULL OR type NOT IN ?", excluded)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (t TaskRepo) Delete(opts ...DBOption) error {
|
func (t TaskRepo) Delete(opts ...DBOption) error {
|
||||||
db := global.TaskDB
|
db := global.TaskDB
|
||||||
for _, opt := range opts {
|
for _, opt := range opts {
|
||||||
|
|||||||
@@ -88,7 +88,6 @@ type IAgentService interface {
|
|||||||
|
|
||||||
GetFeishuConfig(req dto.AgentFeishuConfigReq) (*dto.AgentFeishuConfig, error)
|
GetFeishuConfig(req dto.AgentFeishuConfigReq) (*dto.AgentFeishuConfig, error)
|
||||||
UpdateFeishuConfig(req dto.AgentFeishuConfigUpdateReq) error
|
UpdateFeishuConfig(req dto.AgentFeishuConfigUpdateReq) error
|
||||||
SwitchFeishuPlugin(req dto.AgentFeishuSwitchReq) error
|
|
||||||
GetTelegramConfig(req dto.AgentTelegramConfigReq) (*dto.AgentTelegramConfig, error)
|
GetTelegramConfig(req dto.AgentTelegramConfigReq) (*dto.AgentTelegramConfig, error)
|
||||||
UpdateTelegramConfig(req dto.AgentTelegramConfigUpdateReq) error
|
UpdateTelegramConfig(req dto.AgentTelegramConfigUpdateReq) error
|
||||||
GetDiscordConfig(req dto.AgentIDReq) (*dto.AgentDiscordConfig, error)
|
GetDiscordConfig(req dto.AgentIDReq) (*dto.AgentDiscordConfig, error)
|
||||||
@@ -1592,10 +1591,6 @@ func (a AgentService) UpdateConfigFile(req dto.AgentConfigFileUpdateReq) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if _, busy := feishuSwitches.LoadOrStore(agent.ConfigPath, true); busy {
|
|
||||||
return buserr.New("TaskIsExecuting")
|
|
||||||
}
|
|
||||||
defer feishuSwitches.Delete(agent.ConfigPath)
|
|
||||||
if err := validateAgentConfigFileContent(agent.AgentType, req.Content); err != nil {
|
if err := validateAgentConfigFileContent(agent.AgentType, req.Content); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,29 +45,9 @@ func (a AgentService) GetFeishuConfig(req dto.AgentFeishuConfigReq) (*dto.AgentF
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if agent.AgentType != constant.AppOpenclaw || !isOpenclawBundledFeishuVersion(install.Version) {
|
|
||||||
result := extractFeishuConfig(conf)
|
|
||||||
result.Installed, _ = checkPluginInstalled(install.GetPath(), "feishu")
|
|
||||||
return &result, nil
|
|
||||||
}
|
|
||||||
legacy, configured, err := classifyLegacyFeishu(conf, install.GetPath())
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
result := extractFeishuConfig(conf)
|
result := extractFeishuConfig(conf)
|
||||||
if !legacy {
|
installed, _ := checkPluginInstalled(install.GetPath(), "feishu")
|
||||||
result = extractBundledFeishuConfig(conf)
|
result.Installed = installed
|
||||||
}
|
|
||||||
result.BuiltinManaged = true
|
|
||||||
result.BuiltinAvailable = hasBundledFeishu(install.ContainerName)
|
|
||||||
result.Installed = result.BuiltinAvailable
|
|
||||||
result.Legacy, result.LegacyConfigured = legacy, configured
|
|
||||||
if legacy {
|
|
||||||
result.GroupSessionScope = "group"
|
|
||||||
if result.ThreadSession {
|
|
||||||
result.GroupSessionScope = "group_topic"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return &result, nil
|
return &result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,9 +72,6 @@ func (a AgentService) UpdateFeishuConfig(req dto.AgentFeishuConfigUpdateReq) err
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if agent.AgentType == constant.AppOpenclaw && isOpenclawBundledFeishuVersion(install.Version) {
|
|
||||||
return saveBundledFeishuConfig(agent, install, req)
|
|
||||||
}
|
|
||||||
return a.mutateAgentConfig(req.AgentID, func(_ *model.Agent, _ *model.AppInstall, conf map[string]interface{}) error {
|
return a.mutateAgentConfig(req.AgentID, func(_ *model.Agent, _ *model.AppInstall, conf map[string]interface{}) error {
|
||||||
config := dto.AgentFeishuConfig{
|
config := dto.AgentFeishuConfig{
|
||||||
Enabled: req.Enabled,
|
Enabled: req.Enabled,
|
||||||
@@ -381,9 +358,6 @@ func (a AgentService) InstallPlugin(req dto.AgentPluginInstallReq) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if req.Type == "feishu" && agent.AgentType == constant.AppOpenclaw && isOpenclawBundledFeishuVersion(install.Version) {
|
|
||||||
return buserr.New("ErrFeishuManaged")
|
|
||||||
}
|
|
||||||
if err := task.CheckScopeTaskIsExecuting(task.TaskScopeAI, req.AgentID); err != nil {
|
if err := task.CheckScopeTaskIsExecuting(task.TaskScopeAI, req.AgentID); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -429,9 +403,6 @@ func (a AgentService) UpgradePlugin(req dto.AgentPluginUpgradeReq) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if req.Type == "feishu" && agent.AgentType == constant.AppOpenclaw && isOpenclawBundledFeishuVersion(install.Version) {
|
|
||||||
return buserr.New("ErrFeishuManaged")
|
|
||||||
}
|
|
||||||
if err := task.CheckScopeTaskIsExecuting(task.TaskScopeAI, req.AgentID); err != nil {
|
if err := task.CheckScopeTaskIsExecuting(task.TaskScopeAI, req.AgentID); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -472,9 +443,6 @@ func (a AgentService) UninstallPlugin(req dto.AgentPluginUninstallReq) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if req.Type == "feishu" && agent.AgentType == constant.AppOpenclaw && isOpenclawBundledFeishuVersion(install.Version) {
|
|
||||||
return buserr.New("ErrFeishuManaged")
|
|
||||||
}
|
|
||||||
if err := task.CheckScopeTaskIsExecuting(task.TaskScopeAI, req.AgentID); err != nil {
|
if err := task.CheckScopeTaskIsExecuting(task.TaskScopeAI, req.AgentID); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -570,13 +538,10 @@ func (a AgentService) DeleteChannelConfig(req dto.AgentChannelDeleteReq) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a AgentService) CheckPlugin(req dto.AgentPluginCheckReq) (*dto.AgentPluginStatus, error) {
|
func (a AgentService) CheckPlugin(req dto.AgentPluginCheckReq) (*dto.AgentPluginStatus, error) {
|
||||||
agent, install, err := a.loadAgentAndInstall(req.AgentID)
|
_, install, err := a.loadAgentAndInstall(req.AgentID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if req.Type == "feishu" && agent.AgentType == constant.AppOpenclaw && isOpenclawBundledFeishuVersion(install.Version) {
|
|
||||||
return &dto.AgentPluginStatus{Installed: hasBundledFeishu(install.ContainerName)}, nil
|
|
||||||
}
|
|
||||||
installed, err := checkPluginInstalled(install.GetPath(), req.Type)
|
installed, err := checkPluginInstalled(install.GetPath(), req.Type)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -1,677 +0,0 @@
|
|||||||
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"))
|
|
||||||
}
|
|
||||||
@@ -1,201 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@@ -8,15 +8,11 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const hermesWeixinLoginScript = `import asyncio
|
const hermesWeixinLoginScript = `import asyncio
|
||||||
try:
|
from gateway.platforms.weixin import qr_login
|
||||||
from plugins.platforms.weixin.adapter import qr_login
|
|
||||||
except ModuleNotFoundError as exc:
|
|
||||||
if exc.name not in ('plugins', 'plugins.platforms', 'plugins.platforms.weixin', 'plugins.platforms.weixin.adapter'):
|
|
||||||
raise
|
|
||||||
from gateway.platforms.weixin import qr_login
|
|
||||||
from hermes_cli.config import save_env_value
|
from hermes_cli.config import save_env_value
|
||||||
|
|
||||||
creds = asyncio.run(qr_login('/opt/data'))
|
creds = asyncio.run(qr_login('/opt/data'))
|
||||||
|
print('RESULT =', creds)
|
||||||
|
|
||||||
if not creds:
|
if not creds:
|
||||||
raise SystemExit(1)
|
raise SystemExit(1)
|
||||||
|
|||||||
@@ -107,9 +107,6 @@ func (a AgentService) InstallMarketPlugin(req dto.AgentPluginMarketInstallReq) e
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if isOpenclawBundledFeishuVersion(install.Version) && (strings.TrimSpace(req.Package) == "@larksuite/openclaw-lark" || strings.TrimSpace(req.Package) == "@openclaw/feishu") {
|
|
||||||
return buserr.New("ErrFeishuManaged")
|
|
||||||
}
|
|
||||||
if err := task.CheckScopeTaskIsExecuting(task.TaskScopeAI, req.AgentID); err != nil {
|
if err := task.CheckScopeTaskIsExecuting(task.TaskScopeAI, req.AgentID); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -135,9 +132,6 @@ func (a AgentService) OperatePlugin(req dto.AgentPluginOperateReq) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if isOpenclawBundledFeishuVersion(install.Version) && (req.PluginID == "openclaw-lark" || req.PluginID == "feishu") {
|
|
||||||
return buserr.New("ErrFeishuManaged")
|
|
||||||
}
|
|
||||||
if err := task.CheckScopeTaskIsExecuting(task.TaskScopeAI, req.AgentID); err != nil {
|
if err := task.CheckScopeTaskIsExecuting(task.TaskScopeAI, req.AgentID); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -148,10 +148,6 @@ func readOpenclawConfig(configPath string) (map[string]interface{}, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func writeOpenclawConfigRaw(configPath string, conf map[string]interface{}) error {
|
func writeOpenclawConfigRaw(configPath string, conf map[string]interface{}) error {
|
||||||
if _, busy := feishuSwitches.LoadOrStore(configPath, true); busy {
|
|
||||||
return buserr.New("TaskIsExecuting")
|
|
||||||
}
|
|
||||||
defer feishuSwitches.Delete(configPath)
|
|
||||||
ensureGatewaySecurityDefaults(conf)
|
ensureGatewaySecurityDefaults(conf)
|
||||||
ensureOpenclawUpdateDefaults(conf)
|
ensureOpenclawUpdateDefaults(conf)
|
||||||
payload, err := json.MarshalIndent(conf, "", " ")
|
payload, err := json.MarshalIndent(conf, "", " ")
|
||||||
|
|||||||
+53
-85
@@ -8,7 +8,6 @@ import (
|
|||||||
"os/exec"
|
"os/exec"
|
||||||
"path"
|
"path"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -29,14 +28,13 @@ import (
|
|||||||
|
|
||||||
type AIToolService struct{}
|
type AIToolService struct{}
|
||||||
|
|
||||||
var ollamaModelMu sync.Mutex
|
|
||||||
|
|
||||||
type IAIToolService interface {
|
type IAIToolService interface {
|
||||||
Search(search dto.SearchWithPage) (int64, []dto.OllamaModelInfo, error)
|
Search(search dto.SearchWithPage) (int64, []dto.OllamaModelInfo, error)
|
||||||
Create(req dto.OllamaModelName) error
|
Create(req dto.OllamaModelName) error
|
||||||
Close(name string) error
|
Close(name string) error
|
||||||
Recreate(req dto.OllamaModelName) error
|
Recreate(req dto.OllamaModelName) error
|
||||||
Delete(req dto.ForceDelete) error
|
Delete(req dto.ForceDelete) error
|
||||||
|
Sync() ([]dto.OllamaModelDropList, error)
|
||||||
SyncAccount(req dto.OllamaModelAccountSync) error
|
SyncAccount(req dto.OllamaModelAccountSync) error
|
||||||
LoadDetail(name string) (string, error)
|
LoadDetail(name string) (string, error)
|
||||||
BindDomain(req dto.OllamaBindDomain) error
|
BindDomain(req dto.OllamaBindDomain) error
|
||||||
@@ -49,7 +47,6 @@ func NewIAIToolService() IAIToolService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (u *AIToolService) Search(req dto.SearchWithPage) (int64, []dto.OllamaModelInfo, error) {
|
func (u *AIToolService) Search(req dto.SearchWithPage) (int64, []dto.OllamaModelInfo, error) {
|
||||||
syncErr := syncOllamaModels()
|
|
||||||
var options []repo.DBOption
|
var options []repo.DBOption
|
||||||
if len(req.Info) != 0 {
|
if len(req.Info) != 0 {
|
||||||
options = append(options, repo.WithByLikeName(req.Info))
|
options = append(options, repo.WithByLikeName(req.Info))
|
||||||
@@ -64,11 +61,6 @@ func (u *AIToolService) Search(req dto.SearchWithPage) (int64, []dto.OllamaModel
|
|||||||
if err := copier.Copy(&item, &itemModel); err != nil {
|
if err := copier.Copy(&item, &itemModel); err != nil {
|
||||||
return 0, nil, buserr.WithDetail("ErrStructTransform", err.Error(), nil)
|
return 0, nil, buserr.WithDetail("ErrStructTransform", err.Error(), nil)
|
||||||
}
|
}
|
||||||
if syncErr != nil && item.Status != constant.StatusWaiting {
|
|
||||||
item.Status = constant.StatusUnknown
|
|
||||||
item.Message = syncErr.Error()
|
|
||||||
item.Size = ""
|
|
||||||
}
|
|
||||||
taskModel, _ := taskRepo.GetFirst(taskRepo.WithResourceID(item.ID), repo.WithByType(task.TaskScopeAI))
|
taskModel, _ := taskRepo.GetFirst(taskRepo.WithResourceID(item.ID), repo.WithByType(task.TaskScopeAI))
|
||||||
if len(taskModel.ID) != 0 {
|
if len(taskModel.ID) != 0 {
|
||||||
item.LogFileExist = true
|
item.LogFileExist = true
|
||||||
@@ -94,8 +86,6 @@ func (u *AIToolService) LoadDetail(name string) (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (u *AIToolService) Create(req dto.OllamaModelName) error {
|
func (u *AIToolService) Create(req dto.OllamaModelName) error {
|
||||||
ollamaModelMu.Lock()
|
|
||||||
defer ollamaModelMu.Unlock()
|
|
||||||
if cmd.CheckIllegal(req.Name) {
|
if cmd.CheckIllegal(req.Name) {
|
||||||
return buserr.New("ErrCmdIllegal")
|
return buserr.New("ErrCmdIllegal")
|
||||||
}
|
}
|
||||||
@@ -158,8 +148,6 @@ func (u *AIToolService) Close(name string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (u *AIToolService) Recreate(req dto.OllamaModelName) error {
|
func (u *AIToolService) Recreate(req dto.OllamaModelName) error {
|
||||||
ollamaModelMu.Lock()
|
|
||||||
defer ollamaModelMu.Unlock()
|
|
||||||
if cmd.CheckIllegal(req.Name) {
|
if cmd.CheckIllegal(req.Name) {
|
||||||
return buserr.New("ErrCmdIllegal")
|
return buserr.New("ErrCmdIllegal")
|
||||||
}
|
}
|
||||||
@@ -203,8 +191,6 @@ func (u *AIToolService) Recreate(req dto.OllamaModelName) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (u *AIToolService) Delete(req dto.ForceDelete) error {
|
func (u *AIToolService) Delete(req dto.ForceDelete) error {
|
||||||
ollamaModelMu.Lock()
|
|
||||||
defer ollamaModelMu.Unlock()
|
|
||||||
ollamaList, _ := aiRepo.List(repo.WithByIDs(req.IDs))
|
ollamaList, _ := aiRepo.List(repo.WithByIDs(req.IDs))
|
||||||
if len(ollamaList) == 0 {
|
if len(ollamaList) == 0 {
|
||||||
return buserr.New("ErrRecordNotFound")
|
return buserr.New("ErrRecordNotFound")
|
||||||
@@ -226,6 +212,54 @@ func (u *AIToolService) Delete(req dto.ForceDelete) error {
|
|||||||
return SyncOllamaModelAccount(false)
|
return SyncOllamaModelAccount(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (u *AIToolService) Sync() ([]dto.OllamaModelDropList, error) {
|
||||||
|
containerName, err := LoadContainerName()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
stdout, err := cmd.RunDockerExecWithStdout(20*time.Second, containerName, "ollama", "list")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var list []model.OllamaModel
|
||||||
|
lines := strings.Split(stdout, "\n")
|
||||||
|
for _, line := range lines {
|
||||||
|
parts := strings.Fields(line)
|
||||||
|
if len(parts) < 5 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if parts[0] == "NAME" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
list = append(list, model.OllamaModel{Name: parts[0], Size: parts[2] + " " + parts[3]})
|
||||||
|
}
|
||||||
|
listInDB, _ := aiRepo.List()
|
||||||
|
var dropList []dto.OllamaModelDropList
|
||||||
|
for _, itemModel := range listInDB {
|
||||||
|
isExit := false
|
||||||
|
for i := 0; i < len(list); i++ {
|
||||||
|
if list[i].Name == itemModel.Name {
|
||||||
|
_ = aiRepo.Update(itemModel.ID, map[string]interface{}{"status": constant.StatusSuccess, "message": "", "size": list[i].Size})
|
||||||
|
list = append(list[:i], list[(i+1):]...)
|
||||||
|
isExit = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !isExit && itemModel.Status != constant.StatusWaiting {
|
||||||
|
_ = aiRepo.Update(itemModel.ID, map[string]interface{}{"status": constant.StatusDeleted, "message": "not exist", "size": ""})
|
||||||
|
dropList = append(dropList, dto.OllamaModelDropList{ID: itemModel.ID, Name: itemModel.Name})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, item := range list {
|
||||||
|
item.Status = constant.StatusSuccess
|
||||||
|
item.From = "remote"
|
||||||
|
_ = aiRepo.Create(&item)
|
||||||
|
}
|
||||||
|
|
||||||
|
return dropList, SyncOllamaModelAccount(false)
|
||||||
|
}
|
||||||
|
|
||||||
func (u *AIToolService) SyncAccount(req dto.OllamaModelAccountSync) error {
|
func (u *AIToolService) SyncAccount(req dto.OllamaModelAccountSync) error {
|
||||||
install, err := appInstallRepo.GetFirst(repo.WithByID(req.ID))
|
install, err := appInstallRepo.GetFirst(repo.WithByID(req.ID))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -364,72 +398,6 @@ func (u *AIToolService) UpdateBindDomain(req dto.OllamaBindDomain) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func syncOllamaModels() error {
|
|
||||||
ollamaModelMu.Lock()
|
|
||||||
defer ollamaModelMu.Unlock()
|
|
||||||
containerName, err := LoadContainerName()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
stdout, err := cmd.RunDockerExecWithStdout(20*time.Second, containerName, "ollama", "list")
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
models := make(map[string]string)
|
|
||||||
lines := strings.Split(strings.TrimSpace(stdout), "\n")
|
|
||||||
if len(lines) == 0 || !strings.HasPrefix(strings.TrimSpace(lines[0]), "NAME") {
|
|
||||||
return fmt.Errorf("invalid ollama list output: %s", stdout)
|
|
||||||
}
|
|
||||||
for _, line := range lines[1:] {
|
|
||||||
if strings.TrimSpace(line) == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
parts := strings.Fields(line)
|
|
||||||
if len(parts) < 5 {
|
|
||||||
return fmt.Errorf("invalid ollama model entry: %s", line)
|
|
||||||
}
|
|
||||||
models[parts[0]] = parts[2] + " " + parts[3]
|
|
||||||
}
|
|
||||||
list, err := aiRepo.List()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
known := make(map[string]bool, len(list))
|
|
||||||
for _, item := range list {
|
|
||||||
name := item.Name
|
|
||||||
if !strings.Contains(path.Base(name), ":") {
|
|
||||||
name += ":latest"
|
|
||||||
}
|
|
||||||
size, exists := models[name]
|
|
||||||
known[name] = true
|
|
||||||
if item.Status == constant.StatusWaiting {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
status, message := constant.StatusSuccess, ""
|
|
||||||
if !exists {
|
|
||||||
if item.Status == constant.StatusFailed || item.Status == constant.StatusCanceled {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
status, message = constant.StatusDeleted, "not exist"
|
|
||||||
}
|
|
||||||
if item.Status != status || item.Size != size || item.Message != message {
|
|
||||||
if err := aiRepo.Update(item.ID, map[string]interface{}{"status": status, "size": size, "message": message}); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for name, size := range models {
|
|
||||||
if known[name] {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
item := model.OllamaModel{Name: name, Size: size, From: "remote", Status: constant.StatusSuccess}
|
|
||||||
if err := aiRepo.Create(&item); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func LoadContainerName() (string, error) {
|
func LoadContainerName() (string, error) {
|
||||||
ollamaBaseInfo, err := appInstallRepo.LoadBaseInfo("ollama", "")
|
ollamaBaseInfo, err := appInstallRepo.LoadBaseInfo("ollama", "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -447,12 +415,12 @@ func loadModelSize(name string, containerName string) (string, error) {
|
|||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
lines := strings.Split(stdout, "\n")
|
lines := strings.Split(stdout, "\n")
|
||||||
if !strings.Contains(path.Base(name), ":") {
|
|
||||||
name += ":latest"
|
|
||||||
}
|
|
||||||
for _, line := range lines {
|
for _, line := range lines {
|
||||||
|
if !strings.Contains(line, name) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
parts := strings.Fields(line)
|
parts := strings.Fields(line)
|
||||||
if len(parts) < 5 || parts[0] != name {
|
if len(parts) < 5 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
return parts[2] + " " + parts[3], nil
|
return parts[2] + " " + parts[3], nil
|
||||||
|
|||||||
@@ -59,8 +59,7 @@ type IAppService interface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type appInstallHooks struct {
|
type appInstallHooks struct {
|
||||||
AfterCopyData func(appInstall *model.AppInstall) error
|
AfterCopyData func(appInstall *model.AppInstall) error
|
||||||
PrepareSiteDir func(model.App, model.AppDetail, request.AppInstallCreate, *task.Task) error
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewIAppService() IAppService {
|
func NewIAppService() IAppService {
|
||||||
@@ -615,7 +614,7 @@ func (a AppService) installWithHooks(req request.AppInstallCreate, executeScript
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if app.Key == "openresty" {
|
if app.Key == "openresty" {
|
||||||
if err = prepareAppInstallSiteDirectory(app, appDetail, req, t, hooks); err != nil {
|
if err = handleSiteDir(app, appDetail, req, t); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err = handleOpenrestyFile(appInstall); err != nil {
|
if err = handleOpenrestyFile(appInstall); err != nil {
|
||||||
|
|||||||
@@ -305,14 +305,13 @@ func (a *AppInstallService) Operate(req request.AppInstalledOperate) error {
|
|||||||
return syncAppInstallStatus(&install, true)
|
return syncAppInstallStatus(&install, true)
|
||||||
case constant.Upgrade:
|
case constant.Upgrade:
|
||||||
upgradeReq := request.AppInstallUpgrade{
|
upgradeReq := request.AppInstallUpgrade{
|
||||||
FeishuTransition: req.FeishuTransition,
|
InstallID: install.ID,
|
||||||
InstallID: install.ID,
|
DetailID: req.DetailId,
|
||||||
DetailID: req.DetailId,
|
Backup: req.Backup,
|
||||||
Backup: req.Backup,
|
PullImage: req.PullImage,
|
||||||
PullImage: req.PullImage,
|
DeleteImage: req.DeleteImage,
|
||||||
DeleteImage: req.DeleteImage,
|
DockerCompose: req.DockerCompose,
|
||||||
DockerCompose: req.DockerCompose,
|
TaskID: req.TaskID,
|
||||||
TaskID: req.TaskID,
|
|
||||||
}
|
}
|
||||||
return upgradeInstall(upgradeReq)
|
return upgradeInstall(upgradeReq)
|
||||||
case constant.Reload:
|
case constant.Reload:
|
||||||
@@ -717,7 +716,6 @@ func (a *AppInstallService) GetUpdateVersions(req request.AppUpdateVersion) ([]d
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return versions, err
|
return versions, err
|
||||||
}
|
}
|
||||||
var feishuLegacy, feishuConfigured, feishuChecked bool
|
|
||||||
details, err := appDetailRepo.GetBy(appDetailRepo.WithAppId(app.ID))
|
details, err := appDetailRepo.GetBy(appDetailRepo.WithAppId(app.ID))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return versions, err
|
return versions, err
|
||||||
@@ -742,18 +740,6 @@ func (a *AppInstallService) GetUpdateVersions(req request.AppUpdateVersion) ([]d
|
|||||||
canUpgrade = isVllmUpgradeCandidate(install.Version, detail.Version, currentImage)
|
canUpgrade = isVllmUpgradeCandidate(install.Version, detail.Version, currentImage)
|
||||||
}
|
}
|
||||||
if canUpgrade {
|
if canUpgrade {
|
||||||
feishuTarget := app.Key == constant.AppOpenclaw && isOpenclawBundledFeishuVersion(detail.Version)
|
|
||||||
if feishuTarget && !feishuChecked {
|
|
||||||
conf, readErr := readOpenclawConfig(path.Join(install.GetPath(), "data/conf/openclaw.json"))
|
|
||||||
if readErr != nil {
|
|
||||||
return versions, readErr
|
|
||||||
}
|
|
||||||
feishuLegacy, feishuConfigured, err = classifyLegacyFeishu(conf, install.GetPath())
|
|
||||||
if err != nil {
|
|
||||||
return versions, err
|
|
||||||
}
|
|
||||||
feishuChecked = true
|
|
||||||
}
|
|
||||||
var newCompose string
|
var newCompose string
|
||||||
if req.UpdateVersion != "" && req.UpdateVersion == detail.Version && detail.DockerCompose == "" && !app.IsLocalApp() {
|
if req.UpdateVersion != "" && req.UpdateVersion == detail.Version && detail.DockerCompose == "" && !app.IsLocalApp() {
|
||||||
filename := filepath.Base(detail.DownloadUrl)
|
filename := filepath.Base(detail.DownloadUrl)
|
||||||
@@ -788,11 +774,9 @@ func (a *AppInstallService) GetUpdateVersions(req request.AppUpdateVersion) ([]d
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
versions = append(versions, dto.AppVersion{
|
versions = append(versions, dto.AppVersion{
|
||||||
FeishuLegacy: feishuTarget && feishuLegacy,
|
Version: detail.Version,
|
||||||
FeishuConfigured: feishuTarget && feishuConfigured,
|
DetailId: detail.ID,
|
||||||
Version: detail.Version,
|
DockerCompose: newCompose,
|
||||||
DetailId: detail.ID,
|
|
||||||
DockerCompose: newCompose,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +1,53 @@
|
|||||||
package service
|
package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"path"
|
||||||
|
|
||||||
"github.com/1Panel-dev/1Panel/agent/app/dto/request"
|
"github.com/1Panel-dev/1Panel/agent/app/dto/request"
|
||||||
"github.com/1Panel-dev/1Panel/agent/app/model"
|
"github.com/1Panel-dev/1Panel/agent/app/model"
|
||||||
"github.com/1Panel-dev/1Panel/agent/app/task"
|
"github.com/1Panel-dev/1Panel/agent/constant"
|
||||||
|
"github.com/1Panel-dev/1Panel/agent/global"
|
||||||
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (a AppService) InstallForMCP(req request.AppInstallCreate, executeScript bool) (*model.AppInstall, error) {
|
func prepareMCPWebsiteDirectory(req request.AppInstallCreate) error {
|
||||||
return a.installWithHooks(req, executeScript, &appInstallHooks{PrepareSiteDir: prepareMCPInstallSiteDirectory})
|
changed := errors.New("MCP website directory changed; inspect the installation before retrying")
|
||||||
}
|
directory, ok := req.Params["WEBSITE_DIR"].(string)
|
||||||
|
if !ok || directory == "" || global.DB == nil {
|
||||||
func prepareMCPInstallSiteDirectory(app model.App, detail model.AppDetail, req request.AppInstallCreate, _ *task.Task) error {
|
return changed
|
||||||
if !managesOpenrestySiteDirectory(app, detail) {
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
directory, _ := req.Params["WEBSITE_DIR"].(string)
|
return global.DB.Transaction(func(tx *gorm.DB) error {
|
||||||
return ensureCurrentWebsiteDirectory(directory)
|
var setting model.Setting
|
||||||
|
err := tx.Where("key = ?", "WEBSITE_DIR").First(&setting).Error
|
||||||
|
missing := errors.Is(err, gorm.ErrRecordNotFound)
|
||||||
|
if err != nil && !missing {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
current := setting.Value
|
||||||
|
if current == "" {
|
||||||
|
current = path.Join(global.Dir.BaseDir, "1panel", "www")
|
||||||
|
}
|
||||||
|
if directory != current {
|
||||||
|
return changed
|
||||||
|
}
|
||||||
|
if missing {
|
||||||
|
if err := tx.Create(&model.Setting{Key: "WEBSITE_DIR", Value: directory}).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
} else if setting.Value == "" {
|
||||||
|
result := tx.Model(&model.Setting{}).Where("id = ? AND value = ?", setting.ID, "").Update("value", directory)
|
||||||
|
if result.Error != nil {
|
||||||
|
return result.Error
|
||||||
|
}
|
||||||
|
if result.RowsAffected != 1 {
|
||||||
|
return changed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(path.Join(directory, "conf.d"), constant.DirPerm); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,49 +0,0 @@
|
|||||||
package service
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"os"
|
|
||||||
"path"
|
|
||||||
|
|
||||||
"github.com/1Panel-dev/1Panel/agent/app/model"
|
|
||||||
"github.com/1Panel-dev/1Panel/agent/constant"
|
|
||||||
"github.com/1Panel-dev/1Panel/agent/global"
|
|
||||||
"gorm.io/gorm"
|
|
||||||
)
|
|
||||||
|
|
||||||
func ensureCurrentWebsiteDirectory(directory string) error {
|
|
||||||
changed := errors.New("website directory changed; inspect the installation before retrying")
|
|
||||||
if directory == "" || global.DB == nil {
|
|
||||||
return changed
|
|
||||||
}
|
|
||||||
return global.DB.Transaction(func(tx *gorm.DB) error {
|
|
||||||
ctx := context.WithValue(tx.Statement.Context, constant.DB, tx)
|
|
||||||
setting, err := settingRepo.GetWithContext(ctx, settingRepo.WithByKey("WEBSITE_DIR"))
|
|
||||||
missing := errors.Is(err, gorm.ErrRecordNotFound)
|
|
||||||
if err != nil && !missing {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
current := setting.Value
|
|
||||||
if current == "" {
|
|
||||||
current = path.Join(global.Dir.BaseDir, "1panel", "www")
|
|
||||||
}
|
|
||||||
if directory != current {
|
|
||||||
return changed
|
|
||||||
}
|
|
||||||
if missing {
|
|
||||||
if err := settingRepo.CreateWithContext(ctx, &model.Setting{Key: "WEBSITE_DIR", Value: directory}); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
} else if setting.Value == "" {
|
|
||||||
updated, err := settingRepo.UpdateIfMatch(ctx, setting.ID, "", directory)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if updated != 1 {
|
|
||||||
return changed
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return os.MkdirAll(path.Join(directory, "conf.d"), constant.DirPerm)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -60,16 +60,10 @@ type upgradeFileSnapshot struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type appUpgradeContext struct {
|
type appUpgradeContext struct {
|
||||||
openclawOriginal []byte
|
req request.AppInstallUpgrade
|
||||||
feishuOriginal []byte
|
original model.AppInstall
|
||||||
feishuImage string
|
candidate model.AppInstall
|
||||||
feishuOldImage string
|
detail model.AppDetail
|
||||||
feishuEnabled bool
|
|
||||||
feishuLockPath string
|
|
||||||
req request.AppInstallUpgrade
|
|
||||||
original model.AppInstall
|
|
||||||
candidate model.AppInstall
|
|
||||||
detail model.AppDetail
|
|
||||||
|
|
||||||
phase appUpgradePhase
|
phase appUpgradePhase
|
||||||
stopAttempted bool
|
stopAttempted bool
|
||||||
@@ -108,19 +102,6 @@ func upgradeInstall(req request.AppInstallUpgrade) error {
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
feishuLockPath := ""
|
|
||||||
if install.App.Key == constant.AppOpenclaw {
|
|
||||||
feishuLockPath = path.Join(install.GetPath(), "data/conf/openclaw.json")
|
|
||||||
if _, busy := feishuSwitches.LoadOrStore(feishuLockPath, true); busy {
|
|
||||||
return buserr.New("TaskIsExecuting")
|
|
||||||
}
|
|
||||||
defer func() {
|
|
||||||
if releaseLock {
|
|
||||||
feishuSwitches.Delete(feishuLockPath)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
|
|
||||||
detail, err := appDetailRepo.GetFirst(repo.WithByID(req.DetailID))
|
detail, err := appDetailRepo.GetFirst(repo.WithByID(req.DetailID))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -148,7 +129,6 @@ func upgradeInstall(req request.AppInstallUpgrade) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
ctx := &appUpgradeContext{
|
ctx := &appUpgradeContext{
|
||||||
feishuLockPath: feishuLockPath,
|
|
||||||
req: req,
|
req: req,
|
||||||
original: install,
|
original: install,
|
||||||
candidate: install,
|
candidate: install,
|
||||||
@@ -242,10 +222,6 @@ func (u *appUpgradeContext) prepare(t *task.Task) error {
|
|||||||
if err = copyUpgradeStageFile(u.original.GetPath(), u.stageDir, path.Join("data", "conf", "openclaw.json")); err != nil {
|
if err = copyUpgradeStageFile(u.original.GetPath(), u.stageDir, path.Join("data", "conf", "openclaw.json")); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
u.openclawOriginal, err = os.ReadFile(path.Join(u.stageDir, "data/conf/openclaw.json"))
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if u.original.App.Key == constant.AppOpenresty {
|
if u.original.App.Key == constant.AppOpenresty {
|
||||||
for _, relativePath := range []string{
|
for _, relativePath := range []string{
|
||||||
@@ -354,9 +330,6 @@ func (u *appUpgradeContext) prepare(t *task.Task) error {
|
|||||||
if u.original.App.Resource == constant.AppResourceRemote {
|
if u.original.App.Resource == constant.AppResourceRemote {
|
||||||
go RequestDownloadCallBack(u.detail.DownloadCallBackUrl)
|
go RequestDownloadCallBack(u.detail.DownloadCallBackUrl)
|
||||||
}
|
}
|
||||||
if err = u.prepareFeishuUpgrade(); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
u.phase = appUpgradePrepared
|
u.phase = appUpgradePrepared
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -414,9 +387,6 @@ func (u *appUpgradeContext) cutover(t *task.Task) error {
|
|||||||
}
|
}
|
||||||
t.LogSuccess(i18n.GetMsgByKey("UpgradeStop"))
|
t.LogSuccess(i18n.GetMsgByKey("UpgradeStop"))
|
||||||
u.phase = appUpgradeStopped
|
u.phase = appUpgradeStopped
|
||||||
if err := u.checkFeishuUpgradeSource(); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
var err error
|
var err error
|
||||||
if u.original.App.Key == constant.AppOpenresty {
|
if u.original.App.Key == constant.AppOpenresty {
|
||||||
@@ -484,9 +454,6 @@ func (u *appUpgradeContext) cutover(t *task.Task) error {
|
|||||||
} else {
|
} else {
|
||||||
t.Log("WARNING: no containers found for the upgraded application")
|
t.Log("WARNING: no containers found for the upgraded application")
|
||||||
}
|
}
|
||||||
if err = u.verifyFeishuUpgrade(t); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
u.candidate.Status = constant.StatusRunning
|
u.candidate.Status = constant.StatusRunning
|
||||||
u.candidate.Message = ""
|
u.candidate.Message = ""
|
||||||
|
|
||||||
@@ -612,34 +579,8 @@ func (u *appUpgradeContext) rollback(t *task.Task) (rollbackErr error) {
|
|||||||
if out != "" {
|
if out != "" {
|
||||||
err = fmt.Errorf("%s: %w", out, err)
|
err = fmt.Errorf("%s: %w", out, err)
|
||||||
}
|
}
|
||||||
if u.feishuOriginal != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
rollbackErr = err
|
rollbackErr = err
|
||||||
}
|
}
|
||||||
if u.feishuOriginal != nil {
|
|
||||||
if u.backupFile == "" {
|
|
||||||
return buserr.New("ErrFeishuUpgradeBackup")
|
|
||||||
}
|
|
||||||
if err := u.restoreManagedFiles(); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := handleAppRecover(&u.original, t, u.backupFile, true, "", "", false); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := pinFeishuRollbackImage(u.original.GetComposePath(), u.feishuOldImage); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
restoredCompose, err := os.ReadFile(u.original.GetComposePath())
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
u.original.DockerCompose = string(restoredCompose)
|
|
||||||
if _, err := compose.UpWithoutBuild(u.original.GetComposePath()); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return u.finishRollback()
|
|
||||||
}
|
|
||||||
if u.backupFile != "" {
|
if u.backupFile != "" {
|
||||||
_ = u.restoreManagedFiles()
|
_ = u.restoreManagedFiles()
|
||||||
if err := handleAppRecover(&u.original, t, u.backupFile, true, "", ""); err != nil {
|
if err := handleAppRecover(&u.original, t, u.backupFile, true, "", ""); err != nil {
|
||||||
@@ -661,11 +602,6 @@ func (u *appUpgradeContext) rollback(t *task.Task) (rollbackErr error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (u *appUpgradeContext) finishRollback() error {
|
func (u *appUpgradeContext) finishRollback() error {
|
||||||
if u.feishuOriginal != nil {
|
|
||||||
if err := waitOpenclawReady(u.original.ContainerName); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
restored := u.original
|
restored := u.original
|
||||||
if err := appInstallRepo.Save(context.Background(), &restored); err != nil {
|
if err := appInstallRepo.Save(context.Background(), &restored); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -707,12 +643,6 @@ func (u *appUpgradeContext) deleteOldImages(t *task.Task) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (u *appUpgradeContext) cleanup() {
|
func (u *appUpgradeContext) cleanup() {
|
||||||
if u.feishuLockPath != "" {
|
|
||||||
feishuSwitches.Delete(u.feishuLockPath)
|
|
||||||
}
|
|
||||||
if u.feishuOriginal != nil && u.rollbackErr != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if u.snapshot != nil {
|
if u.snapshot != nil {
|
||||||
u.snapshot.Cleanup()
|
u.snapshot.Cleanup()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,336 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
@@ -391,27 +391,12 @@ func deleteAppInstall(deleteReq request.AppInstallDelete) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if install.App.Key == "ollama" {
|
|
||||||
ollamaModelMu.Lock()
|
|
||||||
defer ollamaModelMu.Unlock()
|
|
||||||
}
|
|
||||||
tx, ctx := helper.GetTxAndContext()
|
tx, ctx := helper.GetTxAndContext()
|
||||||
defer tx.Rollback()
|
defer tx.Rollback()
|
||||||
if err = appInstallRepo.Delete(ctx, install); err != nil {
|
if err = appInstallRepo.Delete(ctx, install); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
appKey := install.App.Key
|
appKey := install.App.Key
|
||||||
if appKey == "ollama" {
|
|
||||||
remaining, listErr := appInstallRepo.ListBy(ctx, appInstallRepo.WithAppId(install.AppId))
|
|
||||||
if listErr != nil {
|
|
||||||
return listErr
|
|
||||||
}
|
|
||||||
if len(remaining) == 0 {
|
|
||||||
if err = aiRepo.DeleteAll(ctx); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if isAgentAppKey(appKey) {
|
if isAgentAppKey(appKey) {
|
||||||
_ = agentRepo.DeleteByAppInstallIDWithCtx(ctx, install.ID)
|
_ = agentRepo.DeleteByAppInstallIDWithCtx(ctx, install.ID)
|
||||||
}
|
}
|
||||||
@@ -2033,19 +2018,11 @@ func getAppTags(appID uint, lang string) ([]response.TagDTO, error) {
|
|||||||
return res, nil
|
return res, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func prepareAppInstallSiteDirectory(app model.App, appDetail model.AppDetail, req request.AppInstallCreate, t *task.Task, hooks *appInstallHooks) error {
|
|
||||||
if hooks != nil && hooks.PrepareSiteDir != nil {
|
|
||||||
return hooks.PrepareSiteDir(app, appDetail, req, t)
|
|
||||||
}
|
|
||||||
return handleSiteDir(app, appDetail, req, t)
|
|
||||||
}
|
|
||||||
|
|
||||||
func managesOpenrestySiteDirectory(app model.App, appDetail model.AppDetail) bool {
|
|
||||||
return app.Key == "openresty" && (app.Resource == "remote" || app.Resource == "custom") && common.CompareVersion(appDetail.Version, "1.27")
|
|
||||||
}
|
|
||||||
|
|
||||||
func handleSiteDir(app model.App, appDetail model.AppDetail, req request.AppInstallCreate, t *task.Task) error {
|
func handleSiteDir(app model.App, appDetail model.AppDetail, req request.AppInstallCreate, t *task.Task) error {
|
||||||
if managesOpenrestySiteDirectory(app, appDetail) {
|
if app.Key == "openresty" && (app.Resource == "remote" || app.Resource == "custom") && common.CompareVersion(appDetail.Version, "1.27") {
|
||||||
|
if req.MCPPreserveWebsiteDir {
|
||||||
|
return prepareMCPWebsiteDirectory(req)
|
||||||
|
}
|
||||||
if dir, ok := req.Params["WEBSITE_DIR"]; ok {
|
if dir, ok := req.Params["WEBSITE_DIR"]; ok {
|
||||||
siteDir := dir.(string)
|
siteDir := dir.(string)
|
||||||
if siteDir == "" || !strings.HasPrefix(siteDir, "/") {
|
if siteDir == "" || !strings.HasPrefix(siteDir, "/") {
|
||||||
|
|||||||
@@ -197,8 +197,7 @@ func handleAppBackup(install *model.AppInstall, parentTask *task.Task, recordID
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleAppRecover(install *model.AppInstall, parentTask *task.Task, recoverFile string, isRollback bool, secret, taskID string, startAfterRecover ...bool) error {
|
func handleAppRecover(install *model.AppInstall, parentTask *task.Task, recoverFile string, isRollback bool, secret, taskID string) error {
|
||||||
startAfter := len(startAfterRecover) == 0 || startAfterRecover[0]
|
|
||||||
// Validate AI Gateway backups before stopping the app, creating rollback
|
// Validate AI Gateway backups before stopping the app, creating rollback
|
||||||
// backups, or touching its files/database. Keep the validated extraction for
|
// backups, or touching its files/database. Keep the validated extraction for
|
||||||
// the restore so uploaded/encrypted archives are not unpacked twice.
|
// the restore so uploaded/encrypted archives are not unpacked twice.
|
||||||
@@ -247,15 +246,12 @@ func handleAppRecover(install *model.AppInstall, parentTask *task.Task, recoverF
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer func() {
|
defer func() {
|
||||||
defer os.RemoveAll(tmpPath)
|
|
||||||
if !startAfter {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if isRollback {
|
if isRollback {
|
||||||
_, _ = compose.UpWithoutBuild(install.GetComposePath())
|
_, _ = compose.UpWithoutBuild(install.GetComposePath())
|
||||||
} else {
|
} else {
|
||||||
_, _ = compose.Up(install.GetComposePath())
|
_, _ = compose.Up(install.GetComposePath())
|
||||||
}
|
}
|
||||||
|
_ = os.RemoveAll(tmpPath)
|
||||||
}()
|
}()
|
||||||
|
|
||||||
if !fileOp.Stat(tmpPath+"/app.json") || !fileOp.Stat(tmpPath+"/app.tar.gz") {
|
if !fileOp.Stat(tmpPath+"/app.json") || !fileOp.Stat(tmpPath+"/app.tar.gz") {
|
||||||
@@ -356,12 +352,8 @@ func handleAppRecover(install *model.AppInstall, parentTask *task.Task, recoverF
|
|||||||
|
|
||||||
appDir := install.GetPath()
|
appDir := install.GetPath()
|
||||||
backPath := fmt.Sprintf("%s_bak", appDir)
|
backPath := fmt.Sprintf("%s_bak", appDir)
|
||||||
if err := fileOp.Rename(appDir, backPath); err != nil && !startAfter {
|
_ = fileOp.Rename(appDir, backPath)
|
||||||
return err
|
_ = fileOp.CreateDir(appDir, constant.DirPerm)
|
||||||
}
|
|
||||||
if err := fileOp.CreateDir(appDir, constant.DirPerm); err != nil && !startAfter {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
deCompressName := i18n.GetWithName("DeCompressFile", "app.tar.gz")
|
deCompressName := i18n.GetWithName("DeCompressFile", "app.tar.gz")
|
||||||
t.LogStart(deCompressName)
|
t.LogStart(deCompressName)
|
||||||
@@ -384,10 +376,6 @@ func handleAppRecover(install *model.AppInstall, parentTask *task.Task, recoverF
|
|||||||
_, _ = file.WriteString(mergedEnvContent)
|
_, _ = file.WriteString(mergedEnvContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
// The upgrade transaction verifies readiness before saving its restored install.
|
|
||||||
if !startAfter {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
backupInstall.ID = install.ID
|
backupInstall.ID = install.ID
|
||||||
backupInstall.Status = constant.StatusRunning
|
backupInstall.Status = constant.StatusRunning
|
||||||
backupInstall.AppId = install.AppId
|
backupInstall.AppId = install.AppId
|
||||||
|
|||||||
@@ -261,7 +261,7 @@ func doWebsiteBackup(website *model.Website, parentTask *task.Task, backupDir, f
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
nginxConfFile := GetWebsiteConfigPath(*website)
|
nginxConfFile := GetWebsiteConfigPath(*website)
|
||||||
if err := exportWebsiteNginxConfig(*website, nginxConfFile, tmpDir); err != nil {
|
if err := fileOp.CopyFile(nginxConfFile, tmpDir); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
parentTask.Log(i18n.GetMsgByKey("BackupNginxConfig"))
|
parentTask.Log(i18n.GetMsgByKey("BackupNginxConfig"))
|
||||||
|
|||||||
@@ -1660,10 +1660,6 @@ func calculateNetwork(network map[string]container.NetworkStats) (float64, float
|
|||||||
}
|
}
|
||||||
|
|
||||||
func checkImageExist(client *client.Client, imageItem string) bool {
|
func checkImageExist(client *client.Client, imageItem string) bool {
|
||||||
return checkImageExistContext(context.Background(), client, imageItem)
|
|
||||||
}
|
|
||||||
|
|
||||||
func checkImageExistContext(ctx context.Context, client *client.Client, imageItem string) bool {
|
|
||||||
if client == nil {
|
if client == nil {
|
||||||
var err error
|
var err error
|
||||||
client, err = docker.NewDockerClient()
|
client, err = docker.NewDockerClient()
|
||||||
@@ -1671,7 +1667,7 @@ func checkImageExistContext(ctx context.Context, client *client.Client, imageIte
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
images, err := client.ImageList(ctx, image.ListOptions{})
|
images, err := client.ImageList(context.Background(), image.ListOptions{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -1713,10 +1709,6 @@ func checkImageLike(client *client.Client, imageName string) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func pullImages(task *task.Task, client *client.Client, imageName string) error {
|
func pullImages(task *task.Task, client *client.Client, imageName string) error {
|
||||||
return pullImagesContext(context.Background(), task, client, imageName)
|
|
||||||
}
|
|
||||||
|
|
||||||
func pullImagesContext(ctx context.Context, task *task.Task, client *client.Client, imageName string) error {
|
|
||||||
dockerCli := docker.NewClientWithExist(client)
|
dockerCli := docker.NewClientWithExist(client)
|
||||||
repos, err := imageRepoRepo.List()
|
repos, err := imageRepoRepo.List()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -1724,7 +1716,7 @@ func pullImagesContext(ctx context.Context, task *task.Task, client *client.Clie
|
|||||||
}
|
}
|
||||||
imageRepo := selectImageRepo(imageName, repos)
|
imageRepo := selectImageRepo(imageName, repos)
|
||||||
if imageRepo == nil || !imageRepo.Auth {
|
if imageRepo == nil || !imageRepo.Auth {
|
||||||
return dockerCli.PullImageWithProcessContext(ctx, task, imageName)
|
return dockerCli.PullImageWithProcess(task, imageName)
|
||||||
}
|
}
|
||||||
|
|
||||||
options := image.PullOptions{}
|
options := image.PullOptions{}
|
||||||
@@ -1737,7 +1729,7 @@ func pullImagesContext(ctx context.Context, task *task.Task, client *client.Clie
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
options.RegistryAuth = base64.URLEncoding.EncodeToString(encodedJSON)
|
options.RegistryAuth = base64.URLEncoding.EncodeToString(encodedJSON)
|
||||||
return dockerCli.PullImageWithProcessAndOptionsContext(ctx, task, imageName, options)
|
return dockerCli.PullImageWithProcessAndOptions(task, imageName, options)
|
||||||
}
|
}
|
||||||
|
|
||||||
func selectImageRepo(imageName string, repos []model.ImageRepo) *model.ImageRepo {
|
func selectImageRepo(imageName string, repos []model.ImageRepo) *model.ImageRepo {
|
||||||
|
|||||||
@@ -91,95 +91,81 @@ func (u *ContainerService) ContainerUpdate(req dto.ContainerOperate) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
const containerUpgradeConcurrency = 2
|
|
||||||
|
|
||||||
// Shared by all upgrade requests handled by this agent.
|
|
||||||
var containerUpgradeSlots = make(chan struct{}, containerUpgradeConcurrency)
|
|
||||||
|
|
||||||
func (u *ContainerService) ContainerUpgrade(req dto.ContainerUpgrade) error {
|
func (u *ContainerService) ContainerUpgrade(req dto.ContainerUpgrade) error {
|
||||||
client, err := docker.NewDockerClient()
|
client, err := docker.NewDockerClient()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
ctx := context.Background()
|
||||||
taskItem, err := task.NewTaskWithOps(req.Image, task.TaskUpgrade, task.TaskScopeImage, req.TaskID, 1)
|
taskItem, err := task.NewTaskWithOps(req.Image, task.TaskUpgrade, task.TaskScopeImage, req.TaskID, 1)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_ = client.Close()
|
_ = client.Close()
|
||||||
global.LOG.Errorf("new task for create container failed, err: %v", err)
|
global.LOG.Errorf("new task for create container failed, err: %v", err)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
taskItem.AddSubTask(i18n.GetWithName("ContainerImagePull", req.Image), func(t *task.Task) error {
|
go func() {
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), containerUpgradePullTimeout)
|
defer client.Close()
|
||||||
defer cancel()
|
taskItem.AddSubTask(i18n.GetWithName("ContainerImagePull", req.Image), func(t *task.Task) error {
|
||||||
taskItem.LogStart(i18n.GetWithName("ContainerImagePull", req.Image))
|
taskItem.LogStart(i18n.GetWithName("ContainerImagePull", req.Image))
|
||||||
if req.ForcePull || !checkImageExistContext(ctx, client, req.Image) {
|
if !checkImageExist(client, req.Image) || req.ForcePull {
|
||||||
if err := pullImagesContext(ctx, taskItem, client, req.Image); err != nil {
|
if err := pullImages(taskItem, client, req.Image); err != nil {
|
||||||
return fmt.Errorf("pull image %s failed: %w", req.Image, err)
|
if !req.ForcePull {
|
||||||
}
|
return err
|
||||||
}
|
}
|
||||||
return nil
|
return fmt.Errorf("pull image %s failed, err: %v", req.Image, err)
|
||||||
}, nil)
|
}
|
||||||
var upgradeErrors []error
|
|
||||||
for _, item := range req.Names {
|
|
||||||
item := item
|
|
||||||
taskItem.AddSubTaskWithIgnoreErr(i18n.GetWithName("ContainerUpgradeItem", item), func(t *task.Task) error {
|
|
||||||
t.Logf("----------------- %s -----------------", item)
|
|
||||||
t.LogStart(i18n.GetWithName("ContainerAcquireLock", item))
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), containerUpgradeReplaceTimeout)
|
|
||||||
defer cancel()
|
|
||||||
unlock, lockErr := containerOperationLock.lockContext(ctx, item)
|
|
||||||
if lockErr != nil {
|
|
||||||
upgradeErrors = append(upgradeErrors, fmt.Errorf("wait for container %s lock: %w", item, lockErr))
|
|
||||||
return lockErr
|
|
||||||
}
|
|
||||||
defer unlock()
|
|
||||||
t.LogWithStatus(i18n.GetWithName("ContainerAcquireLock", item), nil)
|
|
||||||
|
|
||||||
oldContainer, inspectErr := client.ContainerInspect(ctx, item)
|
|
||||||
t.LogWithStatus(i18n.GetWithName("ContainerLoadInfo", item), inspectErr)
|
|
||||||
if inspectErr != nil {
|
|
||||||
err := fmt.Errorf("reload container %s failed: %w", item, inspectErr)
|
|
||||||
upgradeErrors = append(upgradeErrors, err)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
config := cloneContainerConfig(oldContainer.Config)
|
|
||||||
config.Image = req.Image
|
|
||||||
if config.Labels == nil {
|
|
||||||
config.Labels = make(map[string]string)
|
|
||||||
}
|
|
||||||
config.Labels[containerUpgradeOwnerLabel] = oldContainer.ID
|
|
||||||
hostConf := cloneContainerHostConfig(oldContainer.HostConfig)
|
|
||||||
preserveContainerVolumeMounts(hostConf, oldContainer.Mounts)
|
|
||||||
networkConf, extraNetworks := buildContainerRecoverNetworkConfig(oldContainer.NetworkSettings, hostConf)
|
|
||||||
if err := normalizeContainerEndpointSettings(ctx, client, networkConf, extraNetworks); err != nil {
|
|
||||||
upgradeErr := fmt.Errorf("prepare networks for container %s failed: %w", item, err)
|
|
||||||
upgradeErrors = append(upgradeErrors, upgradeErr)
|
|
||||||
return upgradeErr
|
|
||||||
}
|
|
||||||
cleanupErr, err := switchContainerWithRecovery(ctx, client, item, oldContainer, func() (container.CreateResponse, error) {
|
|
||||||
return createContainerWithNetworks(ctx, client, config, hostConf, networkConf, extraNetworks, item)
|
|
||||||
}, config.Tty, t, true)
|
|
||||||
if err != nil {
|
|
||||||
upgradeErr := fmt.Errorf("upgrade container %s failed: %w", item, err)
|
|
||||||
upgradeErrors = append(upgradeErrors, upgradeErr)
|
|
||||||
return upgradeErr
|
|
||||||
}
|
|
||||||
if cleanupErr != nil {
|
|
||||||
t.Log(i18n.GetWithNameAndErr("ContainerCleanupWarning", containerSwitchBackupName(oldContainer.ID), cleanupErr))
|
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
})
|
}, nil)
|
||||||
}
|
var upgradeErrors []error
|
||||||
taskItem.AddSubTask(i18n.GetMsgByKey("ContainerUpgradeSummary"), func(t *task.Task) error {
|
for _, item := range req.Names {
|
||||||
return errors.Join(upgradeErrors...)
|
item := item
|
||||||
}, nil)
|
taskItem.AddSubTaskWithIgnoreErr(i18n.GetWithName("ContainerUpgradeItem", item), func(t *task.Task) error {
|
||||||
// Stage contexts own the deadlines, including a separate recovery budget.
|
t.Logf("----------------- %s -----------------", item)
|
||||||
for _, subTask := range taskItem.SubTasks {
|
t.LogStart(i18n.GetWithName("ContainerAcquireLock", item))
|
||||||
subTask.Timeout = 0
|
unlock := containerOperationLock.lock(item)
|
||||||
}
|
defer unlock()
|
||||||
if err := taskItem.ExecuteQueued(containerUpgradeSlots, "ContainerUpgradeWaiting", func() { _ = client.Close() }); err != nil {
|
t.LogWithStatus(i18n.GetWithName("ContainerAcquireLock", item), nil)
|
||||||
_ = client.Close()
|
|
||||||
return err
|
oldContainer, inspectErr := client.ContainerInspect(ctx, item)
|
||||||
}
|
t.LogWithStatus(i18n.GetWithName("ContainerLoadInfo", item), inspectErr)
|
||||||
|
if inspectErr != nil {
|
||||||
|
err := fmt.Errorf("reload container %s failed: %w", item, inspectErr)
|
||||||
|
upgradeErrors = append(upgradeErrors, err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
config := cloneContainerConfig(oldContainer.Config)
|
||||||
|
config.Image = req.Image
|
||||||
|
hostConf := cloneContainerHostConfig(oldContainer.HostConfig)
|
||||||
|
preserveContainerVolumeMounts(hostConf, oldContainer.Mounts)
|
||||||
|
networkConf, extraNetworks := buildContainerRecoverNetworkConfig(oldContainer.NetworkSettings, hostConf)
|
||||||
|
if err := normalizeContainerEndpointSettings(ctx, client, networkConf, extraNetworks); err != nil {
|
||||||
|
upgradeErr := fmt.Errorf("prepare networks for container %s failed: %w", item, err)
|
||||||
|
upgradeErrors = append(upgradeErrors, upgradeErr)
|
||||||
|
return upgradeErr
|
||||||
|
}
|
||||||
|
cleanupErr, err := switchContainer(ctx, client, item, oldContainer, func() (container.CreateResponse, error) {
|
||||||
|
return createContainerWithNetworks(ctx, client, config, hostConf, networkConf, extraNetworks, item)
|
||||||
|
}, config.Tty, t)
|
||||||
|
if err != nil {
|
||||||
|
upgradeErr := fmt.Errorf("upgrade container %s failed: %w", item, err)
|
||||||
|
upgradeErrors = append(upgradeErrors, upgradeErr)
|
||||||
|
return upgradeErr
|
||||||
|
}
|
||||||
|
if cleanupErr != nil {
|
||||||
|
t.Log(i18n.GetWithNameAndErr("ContainerCleanupWarning", containerSwitchBackupName(oldContainer.ID), cleanupErr))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
taskItem.AddSubTask(i18n.GetMsgByKey("ContainerUpgradeSummary"), func(t *task.Task) error {
|
||||||
|
return errors.Join(upgradeErrors...)
|
||||||
|
}, nil)
|
||||||
|
if err := taskItem.Execute(); err != nil {
|
||||||
|
global.LOG.Error(err.Error())
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -206,16 +192,11 @@ type containerOperationMutex struct {
|
|||||||
var containerOperationLock containerOperationMutex
|
var containerOperationLock containerOperationMutex
|
||||||
|
|
||||||
type containerOperationLockEntry struct {
|
type containerOperationLockEntry struct {
|
||||||
token chan struct{}
|
mutex sync.Mutex
|
||||||
references int
|
references int
|
||||||
}
|
}
|
||||||
|
|
||||||
func (l *containerOperationMutex) lock(names ...string) func() {
|
func (l *containerOperationMutex) lock(names ...string) func() {
|
||||||
unlock, _ := l.lockContext(context.Background(), names...)
|
|
||||||
return unlock
|
|
||||||
}
|
|
||||||
|
|
||||||
func (l *containerOperationMutex) lockContext(ctx context.Context, names ...string) (func(), error) {
|
|
||||||
nameSet := make(map[string]struct{}, len(names))
|
nameSet := make(map[string]struct{}, len(names))
|
||||||
for _, name := range names {
|
for _, name := range names {
|
||||||
if name != "" {
|
if name != "" {
|
||||||
@@ -228,7 +209,7 @@ func (l *containerOperationMutex) lockContext(ctx context.Context, names ...stri
|
|||||||
}
|
}
|
||||||
sort.Strings(orderedNames)
|
sort.Strings(orderedNames)
|
||||||
if len(orderedNames) == 0 {
|
if len(orderedNames) == 0 {
|
||||||
return func() {}, nil
|
return func() {}
|
||||||
}
|
}
|
||||||
|
|
||||||
l.mutex.Lock()
|
l.mutex.Lock()
|
||||||
@@ -239,7 +220,7 @@ func (l *containerOperationMutex) lockContext(ctx context.Context, names ...stri
|
|||||||
for _, name := range orderedNames {
|
for _, name := range orderedNames {
|
||||||
entry := l.locks[name]
|
entry := l.locks[name]
|
||||||
if entry == nil {
|
if entry == nil {
|
||||||
entry = &containerOperationLockEntry{token: make(chan struct{}, 1)}
|
entry = &containerOperationLockEntry{}
|
||||||
l.locks[name] = entry
|
l.locks[name] = entry
|
||||||
}
|
}
|
||||||
entry.references++
|
entry.references++
|
||||||
@@ -247,10 +228,12 @@ func (l *containerOperationMutex) lockContext(ctx context.Context, names ...stri
|
|||||||
}
|
}
|
||||||
l.mutex.Unlock()
|
l.mutex.Unlock()
|
||||||
|
|
||||||
acquired := 0
|
for _, entry := range entries {
|
||||||
release := func() {
|
entry.mutex.Lock()
|
||||||
for index := acquired - 1; index >= 0; index-- {
|
}
|
||||||
<-entries[index].token
|
return func() {
|
||||||
|
for index := len(entries) - 1; index >= 0; index-- {
|
||||||
|
entries[index].mutex.Unlock()
|
||||||
}
|
}
|
||||||
l.mutex.Lock()
|
l.mutex.Lock()
|
||||||
defer l.mutex.Unlock()
|
defer l.mutex.Unlock()
|
||||||
@@ -262,20 +245,6 @@ func (l *containerOperationMutex) lockContext(ctx context.Context, names ...stri
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for _, entry := range entries {
|
|
||||||
select {
|
|
||||||
case entry.token <- struct{}{}:
|
|
||||||
acquired++
|
|
||||||
case <-ctx.Done():
|
|
||||||
release()
|
|
||||||
return nil, ctx.Err()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if err := ctx.Err(); err != nil {
|
|
||||||
release()
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return release, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type containerNetworkAttachment struct {
|
type containerNetworkAttachment struct {
|
||||||
@@ -303,131 +272,6 @@ func switchContainer(
|
|||||||
createNew func() (container.CreateResponse, error),
|
createNew func() (container.CreateResponse, error),
|
||||||
tty bool,
|
tty bool,
|
||||||
logger containerSwitchLogger,
|
logger containerSwitchLogger,
|
||||||
) (cleanupErr error, err error) {
|
|
||||||
return switchContainerWithRecovery(ctx, cli, name, oldContainer, createNew, tty, logger, false)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
const (
|
|
||||||
containerUpgradePullTimeout = 30 * time.Minute
|
|
||||||
containerUpgradeReplaceTimeout = 15 * time.Minute
|
|
||||||
containerUpgradeRecoveryTimeout = 5 * time.Minute
|
|
||||||
containerUpgradeOwnerLabel = "io.1panel.upgrade.original-id"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Reconcile against Docker's actual state: a failed request may already have
|
|
||||||
// changed names, created a container, or disconnected a network on the daemon.
|
|
||||||
func recoverContainerUpgrade(ctx context.Context, cli containerSwitchClient, name string, old container.InspectResponse, newID string, logger containerSwitchLogger) error {
|
|
||||||
original, err := cli.ContainerInspect(ctx, old.ID)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("inspect original %s: %w", old.ID, err)
|
|
||||||
}
|
|
||||||
if original.State == nil {
|
|
||||||
return fmt.Errorf("original container state unavailable")
|
|
||||||
}
|
|
||||||
|
|
||||||
replacement, inspectErr := cli.ContainerInspect(ctx, name)
|
|
||||||
if inspectErr != nil && !client.IsErrNotFound(inspectErr) {
|
|
||||||
return fmt.Errorf("inspect replacement by name: %w", inspectErr)
|
|
||||||
}
|
|
||||||
if inspectErr == nil && replacement.ID != old.ID {
|
|
||||||
if replacement.Config == nil || replacement.Config.Labels[containerUpgradeOwnerLabel] != old.ID {
|
|
||||||
return fmt.Errorf("container %s belongs to another operation; refusing to remove it", name)
|
|
||||||
}
|
|
||||||
if newID != "" && replacement.ID != newID {
|
|
||||||
return fmt.Errorf("replacement ID changed; refusing to remove it")
|
|
||||||
}
|
|
||||||
newID = replacement.ID
|
|
||||||
}
|
|
||||||
if newID != "" {
|
|
||||||
replacement, inspectErr = cli.ContainerInspect(ctx, newID)
|
|
||||||
if inspectErr != nil && !client.IsErrNotFound(inspectErr) {
|
|
||||||
return fmt.Errorf("inspect replacement %s: %w", newID, inspectErr)
|
|
||||||
}
|
|
||||||
if inspectErr == nil {
|
|
||||||
if replacement.ID == old.ID || replacement.Config == nil || replacement.Config.Labels[containerUpgradeOwnerLabel] != old.ID {
|
|
||||||
return fmt.Errorf("replacement identity could not be confirmed")
|
|
||||||
}
|
|
||||||
err = cli.ContainerRemove(ctx, newID, container.RemoveOptions{Force: true, RemoveVolumes: false})
|
|
||||||
logContainerSwitchStep(logger, "ContainerRollbackRemoveReplacement", name, err)
|
|
||||||
if err != nil && !client.IsErrNotFound(err) {
|
|
||||||
return fmt.Errorf("remove replacement: %w", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if strings.TrimPrefix(original.Name, "/") != name {
|
|
||||||
err = cli.ContainerRename(ctx, old.ID, name)
|
|
||||||
logContainerSwitchStep(logger, "ContainerRollbackRenameOld", name, err)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("restore original name: %w", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Reload after mutations; connect only networks which are actually missing.
|
|
||||||
original, err = cli.ContainerInspect(ctx, old.ID)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
primary, extras := buildContainerRecoverNetworkConfig(old.NetworkSettings, old.HostConfig)
|
|
||||||
wanted := make(map[string]*network.EndpointSettings)
|
|
||||||
if primary != nil {
|
|
||||||
for n, endpoint := range primary.EndpointsConfig {
|
|
||||||
if n != "bridge" {
|
|
||||||
wanted[n] = endpoint
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for n, endpoint := range extras {
|
|
||||||
if n != "bridge" {
|
|
||||||
wanted[n] = endpoint
|
|
||||||
}
|
|
||||||
}
|
|
||||||
var reconnectErr error
|
|
||||||
for n, endpoint := range wanted {
|
|
||||||
if original.NetworkSettings != nil && original.NetworkSettings.Networks[n] != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
err = cli.NetworkConnect(ctx, n, old.ID, endpoint)
|
|
||||||
logContainerSwitchStep(logger, "ContainerRollbackReconnectOld", n, err)
|
|
||||||
if err != nil {
|
|
||||||
reconnectErr = errors.Join(reconnectErr, fmt.Errorf("reconnect network %s: %w", n, err))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if reconnectErr != nil {
|
|
||||||
return reconnectErr
|
|
||||||
}
|
|
||||||
if old.State != nil && old.State.Running {
|
|
||||||
if original.State == nil {
|
|
||||||
return fmt.Errorf("original state unavailable after recovery")
|
|
||||||
}
|
|
||||||
if !original.State.Running {
|
|
||||||
err = restartOriginalContainer(ctx, cli, old.ID)
|
|
||||||
logContainerSwitchStep(logger, "ContainerRollbackRestartOld", name, err)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
verified, err := cli.ContainerInspect(ctx, old.ID)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("verify recovery: %w", err)
|
|
||||||
}
|
|
||||||
if strings.TrimPrefix(verified.Name, "/") != name {
|
|
||||||
return fmt.Errorf("original name was not restored")
|
|
||||||
}
|
|
||||||
if old.State != nil && old.State.Running && (verified.State == nil || !verified.State.Running) {
|
|
||||||
return fmt.Errorf("original container is not running after recovery")
|
|
||||||
}
|
|
||||||
for n := range wanted {
|
|
||||||
if verified.NetworkSettings == nil || verified.NetworkSettings.Networks[n] == nil {
|
|
||||||
return fmt.Errorf("network %s was not restored", n)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func switchContainerWithRecovery(ctx context.Context, cli containerSwitchClient, name string,
|
|
||||||
oldContainer container.InspectResponse, createNew func() (container.CreateResponse, error),
|
|
||||||
tty bool, logger containerSwitchLogger, boundedRecovery bool,
|
|
||||||
) (cleanupErr error, err error) {
|
) (cleanupErr error, err error) {
|
||||||
if oldContainer.ID == "" {
|
if oldContainer.ID == "" {
|
||||||
return nil, fmt.Errorf("original container ID is empty")
|
return nil, fmt.Errorf("original container ID is empty")
|
||||||
@@ -437,32 +281,9 @@ func switchContainerWithRecovery(ctx context.Context, cli containerSwitchClient,
|
|||||||
return nil, fmt.Errorf("cannot safely replace container %s with auto-remove enabled", name)
|
return nil, fmt.Errorf("cannot safely replace container %s with auto-remove enabled", name)
|
||||||
}
|
}
|
||||||
|
|
||||||
recoverUpgrade := func(newID string, cause error) error {
|
|
||||||
recoveryCtx, cancel := context.WithTimeout(context.Background(), containerUpgradeRecoveryTimeout)
|
|
||||||
defer cancel()
|
|
||||||
if newID != "" {
|
|
||||||
logContainerStartupLogs(recoveryCtx, cli, newID, name, tty, logger)
|
|
||||||
}
|
|
||||||
recoveryErr := recoverContainerUpgrade(recoveryCtx, cli, name, oldContainer, newID, logger)
|
|
||||||
if ctx.Err() != nil {
|
|
||||||
recoveryErr = errors.Join(recoveryErr, fmt.Errorf("Docker request timed out or was canceled; daemon-side completion cannot be confirmed: %w", ctx.Err()))
|
|
||||||
}
|
|
||||||
if recoveryErr != nil {
|
|
||||||
message := fmt.Sprintf("Recovery unconfirmed for container %s (original ID %s, backup %s): %v", name, oldContainer.ID, containerSwitchBackupName(oldContainer.ID), recoveryErr)
|
|
||||||
if logger != nil {
|
|
||||||
logger.Log(message)
|
|
||||||
}
|
|
||||||
return errors.Join(cause, errors.New(message))
|
|
||||||
}
|
|
||||||
return cause
|
|
||||||
}
|
|
||||||
|
|
||||||
if wasRunning {
|
if wasRunning {
|
||||||
if err := cli.ContainerStop(ctx, oldContainer.ID, container.StopOptions{}); err != nil {
|
if err := cli.ContainerStop(ctx, oldContainer.ID, container.StopOptions{}); err != nil {
|
||||||
logContainerSwitchStep(logger, "ContainerStopOld", name, err)
|
logContainerSwitchStep(logger, "ContainerStopOld", name, err)
|
||||||
if boundedRecovery {
|
|
||||||
return nil, recoverUpgrade("", fmt.Errorf("stop original container failed: %w", err))
|
|
||||||
}
|
|
||||||
current, inspectErr := cli.ContainerInspect(ctx, oldContainer.ID)
|
current, inspectErr := cli.ContainerInspect(ctx, oldContainer.ID)
|
||||||
if inspectErr == nil && current.State != nil && !current.State.Running {
|
if inspectErr == nil && current.State != nil && !current.State.Running {
|
||||||
restartErr := restartOriginalContainer(ctx, cli, oldContainer.ID)
|
restartErr := restartOriginalContainer(ctx, cli, oldContainer.ID)
|
||||||
@@ -477,9 +298,6 @@ func switchContainerWithRecovery(ctx context.Context, cli containerSwitchClient,
|
|||||||
backupName := containerSwitchBackupName(oldContainer.ID)
|
backupName := containerSwitchBackupName(oldContainer.ID)
|
||||||
if err := cli.ContainerRename(ctx, oldContainer.ID, backupName); err != nil {
|
if err := cli.ContainerRename(ctx, oldContainer.ID, backupName); err != nil {
|
||||||
logContainerSwitchStep(logger, "ContainerRenameOld", name, err)
|
logContainerSwitchStep(logger, "ContainerRenameOld", name, err)
|
||||||
if boundedRecovery {
|
|
||||||
return nil, recoverUpgrade("", fmt.Errorf("rename original container failed: %w", err))
|
|
||||||
}
|
|
||||||
if wasRunning {
|
if wasRunning {
|
||||||
restartErr := restartOriginalContainer(ctx, cli, oldContainer.ID)
|
restartErr := restartOriginalContainer(ctx, cli, oldContainer.ID)
|
||||||
logContainerSwitchStep(logger, "ContainerRollbackRestartOld", name, restartErr)
|
logContainerSwitchStep(logger, "ContainerRollbackRestartOld", name, restartErr)
|
||||||
@@ -491,9 +309,6 @@ func switchContainerWithRecovery(ctx context.Context, cli containerSwitchClient,
|
|||||||
disconnectedNetworks, disconnectErr := disconnectOriginalContainerNetworks(ctx, cli, oldContainer)
|
disconnectedNetworks, disconnectErr := disconnectOriginalContainerNetworks(ctx, cli, oldContainer)
|
||||||
logContainerSwitchStep(logger, "ContainerDisconnectOld", backupName, disconnectErr)
|
logContainerSwitchStep(logger, "ContainerDisconnectOld", backupName, disconnectErr)
|
||||||
if disconnectErr != nil {
|
if disconnectErr != nil {
|
||||||
if boundedRecovery {
|
|
||||||
return nil, recoverUpgrade("", disconnectErr)
|
|
||||||
}
|
|
||||||
rollbackErr := restoreOriginalContainer(ctx, cli, oldContainer.ID, name, wasRunning, "", disconnectedNetworks, logger)
|
rollbackErr := restoreOriginalContainer(ctx, cli, oldContainer.ID, name, wasRunning, "", disconnectedNetworks, logger)
|
||||||
return nil, errors.Join(disconnectErr, rollbackErr)
|
return nil, errors.Join(disconnectErr, rollbackErr)
|
||||||
}
|
}
|
||||||
@@ -501,17 +316,11 @@ func switchContainerWithRecovery(ctx context.Context, cli containerSwitchClient,
|
|||||||
created, createErr := createNew()
|
created, createErr := createNew()
|
||||||
logContainerSwitchStep(logger, "ContainerCreateReplacement", name, createErr)
|
logContainerSwitchStep(logger, "ContainerCreateReplacement", name, createErr)
|
||||||
if createErr != nil {
|
if createErr != nil {
|
||||||
if boundedRecovery {
|
|
||||||
return nil, recoverUpgrade(created.ID, createErr)
|
|
||||||
}
|
|
||||||
rollbackErr := restoreOriginalContainer(ctx, cli, oldContainer.ID, name, wasRunning, created.ID, disconnectedNetworks, logger)
|
rollbackErr := restoreOriginalContainer(ctx, cli, oldContainer.ID, name, wasRunning, created.ID, disconnectedNetworks, logger)
|
||||||
return nil, errors.Join(createErr, rollbackErr)
|
return nil, errors.Join(createErr, rollbackErr)
|
||||||
}
|
}
|
||||||
if err := cli.ContainerStart(ctx, created.ID, container.StartOptions{}); err != nil {
|
if err := cli.ContainerStart(ctx, created.ID, container.StartOptions{}); err != nil {
|
||||||
logContainerSwitchStep(logger, "ContainerStartReplacement", name, err)
|
logContainerSwitchStep(logger, "ContainerStartReplacement", name, err)
|
||||||
if boundedRecovery {
|
|
||||||
return nil, recoverUpgrade(created.ID, fmt.Errorf("start new container failed: %w", err))
|
|
||||||
}
|
|
||||||
logContainerStartupLogs(ctx, cli, created.ID, name, tty, logger)
|
logContainerStartupLogs(ctx, cli, created.ID, name, tty, logger)
|
||||||
rollbackErr := restoreOriginalContainer(ctx, cli, oldContainer.ID, name, wasRunning, created.ID, disconnectedNetworks, logger)
|
rollbackErr := restoreOriginalContainer(ctx, cli, oldContainer.ID, name, wasRunning, created.ID, disconnectedNetworks, logger)
|
||||||
return nil, errors.Join(fmt.Errorf("start new container failed: %w", err), rollbackErr)
|
return nil, errors.Join(fmt.Errorf("start new container failed: %w", err), rollbackErr)
|
||||||
@@ -520,9 +329,6 @@ func switchContainerWithRecovery(ctx context.Context, cli containerSwitchClient,
|
|||||||
if wasRunning {
|
if wasRunning {
|
||||||
if err := waitContainerReady(ctx, cli, created.ID); err != nil {
|
if err := waitContainerReady(ctx, cli, created.ID); err != nil {
|
||||||
logContainerSwitchStep(logger, "ContainerWaitReplacement", name, err)
|
logContainerSwitchStep(logger, "ContainerWaitReplacement", name, err)
|
||||||
if boundedRecovery {
|
|
||||||
return nil, recoverUpgrade(created.ID, fmt.Errorf("new container readiness check failed: %w", err))
|
|
||||||
}
|
|
||||||
logContainerStartupLogs(ctx, cli, created.ID, name, tty, logger)
|
logContainerStartupLogs(ctx, cli, created.ID, name, tty, logger)
|
||||||
rollbackErr := restoreOriginalContainer(ctx, cli, oldContainer.ID, name, wasRunning, created.ID, disconnectedNetworks, logger)
|
rollbackErr := restoreOriginalContainer(ctx, cli, oldContainer.ID, name, wasRunning, created.ID, disconnectedNetworks, logger)
|
||||||
return nil, errors.Join(fmt.Errorf("new container readiness check failed: %w", err), rollbackErr)
|
return nil, errors.Join(fmt.Errorf("new container readiness check failed: %w", err), rollbackErr)
|
||||||
@@ -530,11 +336,6 @@ func switchContainerWithRecovery(ctx context.Context, cli containerSwitchClient,
|
|||||||
logContainerSwitchStep(logger, "ContainerWaitReplacement", name, nil)
|
logContainerSwitchStep(logger, "ContainerWaitReplacement", name, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
if boundedRecovery {
|
|
||||||
cleanupCtx, cancel := context.WithTimeout(context.Background(), containerUpgradeRecoveryTimeout)
|
|
||||||
defer cancel()
|
|
||||||
ctx = cleanupCtx
|
|
||||||
}
|
|
||||||
cleanupErr = cli.ContainerRemove(ctx, oldContainer.ID, container.RemoveOptions{Force: true, RemoveVolumes: false})
|
cleanupErr = cli.ContainerRemove(ctx, oldContainer.ID, container.RemoveOptions{Force: true, RemoveVolumes: false})
|
||||||
logContainerSwitchStep(logger, "ContainerRemoveOld", backupName, cleanupErr)
|
logContainerSwitchStep(logger, "ContainerRemoveOld", backupName, cleanupErr)
|
||||||
return cleanupErr, nil
|
return cleanupErr, nil
|
||||||
|
|||||||
@@ -5,14 +5,12 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path"
|
"path"
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/1Panel-dev/1Panel/agent/app/dto"
|
"github.com/1Panel-dev/1Panel/agent/app/dto"
|
||||||
"github.com/1Panel-dev/1Panel/agent/app/repo"
|
"github.com/1Panel-dev/1Panel/agent/app/repo"
|
||||||
"github.com/1Panel-dev/1Panel/agent/buserr"
|
"github.com/1Panel-dev/1Panel/agent/buserr"
|
||||||
"github.com/1Panel-dev/1Panel/agent/constant"
|
"github.com/1Panel-dev/1Panel/agent/constant"
|
||||||
"github.com/1Panel-dev/1Panel/agent/global"
|
"github.com/1Panel-dev/1Panel/agent/global"
|
||||||
"github.com/1Panel-dev/1Panel/agent/utils/cmd"
|
|
||||||
"github.com/1Panel-dev/1Panel/agent/utils/encrypt"
|
"github.com/1Panel-dev/1Panel/agent/utils/encrypt"
|
||||||
"github.com/1Panel-dev/1Panel/agent/utils/mysql"
|
"github.com/1Panel-dev/1Panel/agent/utils/mysql"
|
||||||
"github.com/1Panel-dev/1Panel/agent/utils/mysql/client"
|
"github.com/1Panel-dev/1Panel/agent/utils/mysql/client"
|
||||||
@@ -128,9 +126,6 @@ func (u *DatabaseService) LoadItems(dbType string) ([]dto.DatabaseItem, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (u *DatabaseService) CheckDatabase(req dto.DatabaseCreate) bool {
|
func (u *DatabaseService) CheckDatabase(req dto.DatabaseCreate) bool {
|
||||||
if (req.Type == constant.AppMysql || req.Type == constant.AppMariaDB || req.Type == constant.AppPostgresql || req.Type == constant.AppMongodb) && (cmd.CheckIllegal(req.Username, req.Password) || strings.Contains(req.Password, " ")) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if req.Timeout == 0 {
|
if req.Timeout == 0 {
|
||||||
req.Timeout = 30
|
req.Timeout = 30
|
||||||
}
|
}
|
||||||
@@ -185,9 +180,6 @@ func (u *DatabaseService) CheckDatabase(req dto.DatabaseCreate) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (u *DatabaseService) Create(req dto.DatabaseCreate) error {
|
func (u *DatabaseService) Create(req dto.DatabaseCreate) error {
|
||||||
if (req.Type == constant.AppMysql || req.Type == constant.AppMariaDB || req.Type == constant.AppPostgresql || req.Type == constant.AppMongodb) && (cmd.CheckIllegal(req.Username, req.Password) || strings.Contains(req.Password, " ")) {
|
|
||||||
return buserr.New("ErrCmdIllegal")
|
|
||||||
}
|
|
||||||
if req.Timeout == 0 {
|
if req.Timeout == 0 {
|
||||||
req.Timeout = 30
|
req.Timeout = 30
|
||||||
}
|
}
|
||||||
@@ -311,9 +303,6 @@ func (u *DatabaseService) Delete(req dto.DatabaseDelete) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (u *DatabaseService) Update(req dto.DatabaseUpdate) error {
|
func (u *DatabaseService) Update(req dto.DatabaseUpdate) error {
|
||||||
if (req.Type == constant.AppMysql || req.Type == constant.AppMariaDB || req.Type == constant.AppPostgresql || req.Type == constant.AppMongodb) && (cmd.CheckIllegal(req.Username, req.Password) || strings.Contains(req.Password, " ")) {
|
|
||||||
return buserr.New("ErrCmdIllegal")
|
|
||||||
}
|
|
||||||
switch req.Type {
|
switch req.Type {
|
||||||
case constant.AppPostgresql:
|
case constant.AppPostgresql:
|
||||||
if _, err := postgresql.NewPostgresqlClient(pgclient.DBInfo{
|
if _, err := postgresql.NewPostgresqlClient(pgclient.DBInfo{
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ func (u *MongodbService) UpdateDescription(req dto.UpdateDescription) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (u *MongodbService) Create(ctx context.Context, req dto.MongodbDBCreate) (*model.DatabaseMongodb, error) {
|
func (u *MongodbService) Create(ctx context.Context, req dto.MongodbDBCreate) (*model.DatabaseMongodb, error) {
|
||||||
if cmd.CheckIllegal(req.Name, req.Username, req.Password) || strings.Contains(req.Password, " ") {
|
if cmd.CheckIllegal(req.Name, req.Username, req.Password) {
|
||||||
return nil, buserr.New("ErrCmdIllegal")
|
return nil, buserr.New("ErrCmdIllegal")
|
||||||
}
|
}
|
||||||
if !isSupportedMongodbPrivilege(req.Permission) {
|
if !isSupportedMongodbPrivilege(req.Permission) {
|
||||||
@@ -146,7 +146,7 @@ func (u *MongodbService) LoadFromRemote(req dto.MongodbLoadDB) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (u *MongodbService) BindUser(req dto.MongodbBind) error {
|
func (u *MongodbService) BindUser(req dto.MongodbBind) error {
|
||||||
if cmd.CheckIllegal(req.Database, req.Name, req.Username, req.Password) || strings.Contains(req.Password, " ") {
|
if cmd.CheckIllegal(req.Database, req.Name, req.Username, req.Password) {
|
||||||
return buserr.New("ErrCmdIllegal")
|
return buserr.New("ErrCmdIllegal")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -168,7 +168,7 @@ func (u *MongodbService) BindUser(req dto.MongodbBind) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (u *MongodbService) ChangePassword(req dto.MongodbPassword) error {
|
func (u *MongodbService) ChangePassword(req dto.MongodbPassword) error {
|
||||||
if cmd.CheckIllegal(req.Database, req.Name, req.Password) || strings.Contains(req.Password, " ") {
|
if cmd.CheckIllegal(req.Database, req.Name, req.Password) {
|
||||||
return buserr.New("ErrCmdIllegal")
|
return buserr.New("ErrCmdIllegal")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -190,7 +190,7 @@ func (u *MongodbService) ChangePassword(req dto.MongodbPassword) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (u *MongodbService) ChangeRootPassword(req dto.ChangeDBInfo) error {
|
func (u *MongodbService) ChangeRootPassword(req dto.ChangeDBInfo) error {
|
||||||
if cmd.CheckIllegal(req.Value) || strings.Contains(req.Value, " ") {
|
if cmd.CheckIllegal(req.Value) {
|
||||||
return buserr.New("ErrCmdIllegal")
|
return buserr.New("ErrCmdIllegal")
|
||||||
}
|
}
|
||||||
if req.From != constant.AppResourceLocal {
|
if req.From != constant.AppResourceLocal {
|
||||||
|
|||||||
@@ -433,46 +433,11 @@ func (u *MysqlService) ListDBOption() ([]dto.MysqlOption, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (u *MysqlService) Create(ctx context.Context, req dto.MysqlDBCreate) (*model.DatabaseMysql, error) {
|
func (u *MysqlService) Create(ctx context.Context, req dto.MysqlDBCreate) (*model.DatabaseMysql, error) {
|
||||||
creation, err := prepareMysqlCreation(req)
|
return u.create(ctx, req, false)
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
req = creation.request
|
|
||||||
cli, version, err := LoadMysqlClientByFrom(req.Database)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer cli.Close()
|
|
||||||
if err := cli.Create(creation.createInfo(version)); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if len(req.Username) != 0 {
|
|
||||||
if err := saveDatabaseUserCredentials(creation.dbType, req.Database, req.Username, req.Permission, req.Password, req.Description); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
for _, host := range creation.permissionHosts {
|
|
||||||
if err := saveDatabaseUserGrant(creation.dbType, req.Database, req.Name, req.Username, host); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
global.LOG.Infof("create database %s successful!", req.Name)
|
|
||||||
if err := mysqlRepo.Create(ctx, &creation.item); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return &creation.item, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type mysqlCreation struct {
|
func (u *MysqlService) create(ctx context.Context, req dto.MysqlDBCreate, mcpBounded bool) (*model.DatabaseMysql, error) {
|
||||||
request dto.MysqlDBCreate
|
if cmd.CheckIllegal(req.Name, req.Username, req.Password, req.Format, req.Collation, req.Permission) {
|
||||||
dbType string
|
|
||||||
permissionHosts []string
|
|
||||||
item model.DatabaseMysql
|
|
||||||
}
|
|
||||||
|
|
||||||
func prepareMysqlCreation(req dto.MysqlDBCreate) (*mysqlCreation, error) {
|
|
||||||
if cmd.CheckIllegal(req.Name, req.Username, req.Password, req.Format, req.Collation, req.Permission) || strings.Contains(req.Password, " ") {
|
|
||||||
return nil, buserr.New("ErrCmdIllegal")
|
return nil, buserr.New("ErrCmdIllegal")
|
||||||
}
|
}
|
||||||
if len(req.Username) != 0 && len(req.Password) == 0 {
|
if len(req.Username) != 0 && len(req.Password) == 0 {
|
||||||
@@ -509,13 +474,13 @@ func prepareMysqlCreation(req dto.MysqlDBCreate) (*mysqlCreation, error) {
|
|||||||
return nil, errors.New("cannot set root as user name")
|
return nil, errors.New("cannot set root as user name")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
cli, version, err := loadMysqlClientByFrom(req.Database, mcpBounded)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
createItem.MysqlName = req.Database
|
createItem.MysqlName = req.Database
|
||||||
return &mysqlCreation{request: req, dbType: dbType, permissionHosts: permissionHosts, item: createItem}, nil
|
defer cli.Close()
|
||||||
}
|
createInfo := client.CreateInfo{
|
||||||
|
|
||||||
func (creation *mysqlCreation) createInfo(version string) client.CreateInfo {
|
|
||||||
req := creation.request
|
|
||||||
return client.CreateInfo{
|
|
||||||
Name: req.Name,
|
Name: req.Name,
|
||||||
Format: req.Format,
|
Format: req.Format,
|
||||||
Collation: req.Collation,
|
Collation: req.Collation,
|
||||||
@@ -525,16 +490,55 @@ func (creation *mysqlCreation) createInfo(version string) client.CreateInfo {
|
|||||||
Version: version,
|
Version: version,
|
||||||
Timeout: 300,
|
Timeout: 300,
|
||||||
}
|
}
|
||||||
|
if mcpBounded {
|
||||||
|
local, ok := cli.(*client.Local)
|
||||||
|
if !ok {
|
||||||
|
return nil, errors.New("an installed local MySQL instance is required")
|
||||||
|
}
|
||||||
|
deadline := time.Now().Add(300 * time.Second)
|
||||||
|
if writeDeadline, ok := ctx.Deadline(); ok && writeDeadline.Add(-10*time.Second).Before(deadline) {
|
||||||
|
deadline = writeDeadline.Add(-10 * time.Second)
|
||||||
|
}
|
||||||
|
sqlContext, cancel := context.WithDeadline(ctx, deadline)
|
||||||
|
err = local.CreateWithContext(sqlContext, createInfo)
|
||||||
|
cancel()
|
||||||
|
} else {
|
||||||
|
err = cli.Create(createInfo)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if mcpBounded {
|
||||||
|
if err := persistMCPMysqlMetadata(ctx, req, permissionHosts, &createItem); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &createItem, nil
|
||||||
|
}
|
||||||
|
if len(req.Username) != 0 {
|
||||||
|
if err := saveDatabaseUserCredentials(dbType, req.Database, req.Username, req.Permission, req.Password, req.Description); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, host := range permissionHosts {
|
||||||
|
if err := saveDatabaseUserGrant(dbType, req.Database, req.Name, req.Username, host); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
global.LOG.Infof("create database %s successful!", req.Name)
|
||||||
|
if err := mysqlRepo.Create(ctx, &createItem); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &createItem, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func saveMysqlCreationMetadata(ctx context.Context, req dto.MysqlDBCreate, hosts []string, item *model.DatabaseMysql) error {
|
func persistMCPMysqlMetadata(ctx context.Context, req dto.MysqlDBCreate, hosts []string, item *model.DatabaseMysql) error {
|
||||||
return global.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
return global.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
txCtx := context.WithValue(ctx, constant.DB, tx)
|
|
||||||
if req.Username != "" {
|
if req.Username != "" {
|
||||||
key := global.CONF.Base.EncryptKey
|
key := global.CONF.Base.EncryptKey
|
||||||
if key == "" {
|
if key == "" {
|
||||||
setting, err := settingRepo.GetWithContext(txCtx, repo.WithByKey("EncryptKey"))
|
var setting model.Setting
|
||||||
if err != nil {
|
if err := tx.Where("key = ?", "EncryptKey").First(&setting).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
key = setting.Value
|
key = setting.Value
|
||||||
@@ -544,7 +548,8 @@ func saveMysqlCreationMetadata(ctx context.Context, req dto.MysqlDBCreate, hosts
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
for _, host := range hosts {
|
for _, host := range hosts {
|
||||||
user, err := databaseUserRepo.GetEncryptedWithContext(txCtx, repo.WithByType("mysql"), databaseUserRepo.WithByDatabase(req.Database), databaseUserRepo.WithByUser(req.Username, host))
|
var user model.DatabaseUser
|
||||||
|
err := tx.Where("type = ? AND database = ? AND username = ? AND host = ?", "mysql", req.Database, req.Username, host).First(&user).Error
|
||||||
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -553,16 +558,16 @@ func saveMysqlCreationMetadata(ctx context.Context, req dto.MysqlDBCreate, hosts
|
|||||||
if req.Description != "" {
|
if req.Description != "" {
|
||||||
user.Description = req.Description
|
user.Description = req.Description
|
||||||
}
|
}
|
||||||
if err := databaseUserRepo.SaveEncryptedWithContext(txCtx, &user); err != nil {
|
if err := tx.Save(&user).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
grant := model.DatabaseUserGrant{Type: "mysql", Database: req.Database, DBName: req.Name, Username: req.Username, Host: host}
|
grant := model.DatabaseUserGrant{Type: "mysql", Database: req.Database, DBName: req.Name, Username: req.Username, Host: host}
|
||||||
if err := databaseUserGrantRepo.CreateIfAbsent(txCtx, &grant); err != nil {
|
if err := tx.Where("type = ? AND database = ? AND db_name = ? AND username = ? AND host = ?", grant.Type, grant.Database, grant.DBName, grant.Username, grant.Host).FirstOrCreate(&grant).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return mysqlRepo.CreateMetadata(txCtx, item)
|
return tx.Create(item).Error
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -675,7 +680,7 @@ func (u *MysqlService) ListGrantSummary(req dto.MysqlGrantSummarySearch) (map[st
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (u *MysqlService) CreateUser(req dto.MysqlUserCreate) error {
|
func (u *MysqlService) CreateUser(req dto.MysqlUserCreate) error {
|
||||||
if cmd.CheckIllegal(req.Username, req.Password, req.Host) || strings.Contains(req.Password, " ") {
|
if cmd.CheckIllegal(req.Username, req.Password, req.Host) {
|
||||||
return buserr.New("ErrCmdIllegal")
|
return buserr.New("ErrCmdIllegal")
|
||||||
}
|
}
|
||||||
dbs := make([]string, 0, len(req.DBs))
|
dbs := make([]string, 0, len(req.DBs))
|
||||||
@@ -878,7 +883,7 @@ func (u *MysqlService) UpdateUser(req dto.MysqlUserUpdate) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (u *MysqlService) ChangeUserPassword(req dto.MysqlUserPassword) error {
|
func (u *MysqlService) ChangeUserPassword(req dto.MysqlUserPassword) error {
|
||||||
if cmd.CheckIllegal(req.Username, req.Host, req.Password) || strings.Contains(req.Password, " ") {
|
if cmd.CheckIllegal(req.Username, req.Host, req.Password) {
|
||||||
return buserr.New("ErrCmdIllegal")
|
return buserr.New("ErrCmdIllegal")
|
||||||
}
|
}
|
||||||
if err := checkMysqlNormalUser(req.Username); err != nil {
|
if err := checkMysqlNormalUser(req.Username); err != nil {
|
||||||
@@ -931,7 +936,7 @@ func (u *MysqlService) ChangeUserPassword(req dto.MysqlUserPassword) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (u *MysqlService) SaveUserPassword(req dto.MysqlUserPassword) error {
|
func (u *MysqlService) SaveUserPassword(req dto.MysqlUserPassword) error {
|
||||||
if cmd.CheckIllegal(req.Username, req.Host, req.Password) || strings.Contains(req.Password, " ") {
|
if cmd.CheckIllegal(req.Username, req.Host, req.Password) {
|
||||||
return buserr.New("ErrCmdIllegal")
|
return buserr.New("ErrCmdIllegal")
|
||||||
}
|
}
|
||||||
if err := checkMysqlNormalUser(req.Username); err != nil {
|
if err := checkMysqlNormalUser(req.Username); err != nil {
|
||||||
@@ -1297,7 +1302,7 @@ func isMysqlDatabaseResourceInUseError(err error) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (u *MysqlService) ChangePassword(req dto.ChangeDBInfo) error {
|
func (u *MysqlService) ChangePassword(req dto.ChangeDBInfo) error {
|
||||||
if cmd.CheckIllegal(req.Value) || strings.Contains(req.Value, " ") {
|
if cmd.CheckIllegal(req.Value) {
|
||||||
return buserr.New("ErrCmdIllegal")
|
return buserr.New("ErrCmdIllegal")
|
||||||
}
|
}
|
||||||
cli, version, err := LoadMysqlClientByFrom(req.Database)
|
cli, version, err := LoadMysqlClientByFrom(req.Database)
|
||||||
@@ -1598,34 +1603,10 @@ func updateMyCnf(oldFiles []string, group string, param string, value interface{
|
|||||||
}
|
}
|
||||||
|
|
||||||
func LoadMysqlClientByFrom(database string) (mysql.MysqlClient, string, error) {
|
func LoadMysqlClientByFrom(database string) (mysql.MysqlClient, string, error) {
|
||||||
databaseItem, err := databaseRepo.Get(repo.WithByName(database))
|
return loadMysqlClientByFrom(database, false)
|
||||||
if err != nil {
|
|
||||||
return nil, "", err
|
|
||||||
}
|
|
||||||
return newMysqlClientForDatabase(databaseItem, database)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadInstalledLocalMysqlClient(database string) (*client.Local, string, error) {
|
func loadMysqlClientByFrom(database string, mcpLocalOnly bool) (mysql.MysqlClient, string, error) {
|
||||||
databaseItem, err := databaseRepo.Get(repo.WithByName(database))
|
|
||||||
if err != nil {
|
|
||||||
return nil, "", err
|
|
||||||
}
|
|
||||||
if databaseItem.ID == 0 || databaseItem.From != "local" || databaseItem.Type != "mysql" || databaseItem.AppInstallID == 0 {
|
|
||||||
return nil, "", errors.New("an installed local MySQL instance is required")
|
|
||||||
}
|
|
||||||
cli, version, err := newMysqlClientForDatabase(databaseItem, database)
|
|
||||||
if err != nil {
|
|
||||||
return nil, "", err
|
|
||||||
}
|
|
||||||
local, ok := cli.(*client.Local)
|
|
||||||
if !ok {
|
|
||||||
cli.Close()
|
|
||||||
return nil, "", errors.New("an installed local MySQL instance is required")
|
|
||||||
}
|
|
||||||
return local, version, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func newMysqlClientForDatabase(databaseItem model.Database, database string) (mysql.MysqlClient, string, error) {
|
|
||||||
var (
|
var (
|
||||||
dbInfo client.DBInfo
|
dbInfo client.DBInfo
|
||||||
version string
|
version string
|
||||||
@@ -1633,6 +1614,13 @@ func newMysqlClientForDatabase(databaseItem model.Database, database string) (my
|
|||||||
)
|
)
|
||||||
|
|
||||||
dbInfo.Timeout = 300
|
dbInfo.Timeout = 300
|
||||||
|
databaseItem, err := databaseRepo.Get(repo.WithByName(database))
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
if mcpLocalOnly && (databaseItem.ID == 0 || databaseItem.From != "local" || databaseItem.Type != "mysql" || databaseItem.AppInstallID == 0) {
|
||||||
|
return nil, "", errors.New("an installed local MySQL instance is required")
|
||||||
|
}
|
||||||
dbInfo.Type = databaseItem.Type
|
dbInfo.Type = databaseItem.Type
|
||||||
dbInfo.From = databaseItem.From
|
dbInfo.From = databaseItem.From
|
||||||
dbInfo.Database = database
|
dbInfo.Database = database
|
||||||
|
|||||||
@@ -16,35 +16,9 @@ func (u *MysqlService) CreateForMCP(ctx context.Context, req dto.MysqlDBCreate)
|
|||||||
}
|
}
|
||||||
writeContext, cancel := context.WithTimeout(context.WithoutCancel(ctx), 310*time.Second)
|
writeContext, cancel := context.WithTimeout(context.WithoutCancel(ctx), 310*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
created, err := createMCPMysqlDatabase(writeContext, req)
|
created, err := u.create(writeContext, req, true)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, mcpapi.SanitizeBusinessError(err, "database creation failed; inspect the invocation before retrying")
|
return nil, mcpapi.SanitizeBusinessError(err, "database creation failed; inspect the invocation before retrying")
|
||||||
}
|
}
|
||||||
return created, nil
|
return created, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func createMCPMysqlDatabase(ctx context.Context, req dto.MysqlDBCreate) (*model.DatabaseMysql, error) {
|
|
||||||
creation, err := prepareMysqlCreation(req)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
local, version, err := loadInstalledLocalMysqlClient(creation.request.Database)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer local.Close()
|
|
||||||
deadline := time.Now().Add(300 * time.Second)
|
|
||||||
if writeDeadline, ok := ctx.Deadline(); ok && writeDeadline.Add(-10*time.Second).Before(deadline) {
|
|
||||||
deadline = writeDeadline.Add(-10 * time.Second)
|
|
||||||
}
|
|
||||||
sqlContext, cancel := context.WithDeadline(ctx, deadline)
|
|
||||||
err = local.CreateWithContext(sqlContext, creation.createInfo(version))
|
|
||||||
cancel()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if err := saveMysqlCreationMetadata(ctx, creation.request, creation.permissionHosts, &creation.item); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return &creation.item, nil
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ func (u *PostgresqlService) ListDBOption() ([]dto.PostgresqlOption, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (u *PostgresqlService) BindUser(req dto.PostgresqlBindUser) error {
|
func (u *PostgresqlService) BindUser(req dto.PostgresqlBindUser) error {
|
||||||
if cmd.CheckIllegal(req.Name, req.Username, req.Password) || strings.Contains(req.Password, " ") {
|
if cmd.CheckIllegal(req.Name, req.Username, req.Password) {
|
||||||
return buserr.New("ErrCmdIllegal")
|
return buserr.New("ErrCmdIllegal")
|
||||||
}
|
}
|
||||||
dbItem, err := postgresqlRepo.Get(postgresqlRepo.WithByPostgresqlName(req.Database), repo.WithByName(req.Name))
|
dbItem, err := postgresqlRepo.Get(postgresqlRepo.WithByPostgresqlName(req.Database), repo.WithByName(req.Name))
|
||||||
@@ -123,7 +123,7 @@ func (u *PostgresqlService) BindUser(req dto.PostgresqlBindUser) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (u *PostgresqlService) Create(ctx context.Context, req dto.PostgresqlDBCreate) (*model.DatabasePostgresql, error) {
|
func (u *PostgresqlService) Create(ctx context.Context, req dto.PostgresqlDBCreate) (*model.DatabasePostgresql, error) {
|
||||||
if cmd.CheckIllegal(req.Name, req.Username, req.Password, req.Format) || strings.Contains(req.Password, " ") {
|
if cmd.CheckIllegal(req.Name, req.Username, req.Password, req.Format) {
|
||||||
return nil, buserr.New("ErrCmdIllegal")
|
return nil, buserr.New("ErrCmdIllegal")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -366,7 +366,7 @@ func (u *PostgresqlService) ChangePrivileges(req dto.PostgresqlPrivileges) error
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (u *PostgresqlService) ChangePassword(req dto.ChangeDBInfo) error {
|
func (u *PostgresqlService) ChangePassword(req dto.ChangeDBInfo) error {
|
||||||
if cmd.CheckIllegal(req.Value) || strings.Contains(req.Value, " ") {
|
if cmd.CheckIllegal(req.Value) {
|
||||||
return buserr.New("ErrCmdIllegal")
|
return buserr.New("ErrCmdIllegal")
|
||||||
}
|
}
|
||||||
cli, err := LoadPostgresqlClientByFrom(req.Database)
|
cli, err := LoadPostgresqlClientByFrom(req.Database)
|
||||||
|
|||||||
@@ -1,10 +0,0 @@
|
|||||||
package service
|
|
||||||
|
|
||||||
import (
|
|
||||||
"github.com/1Panel-dev/1Panel/agent/constant"
|
|
||||||
"github.com/1Panel-dev/1Panel/agent/utils/taskscope"
|
|
||||||
)
|
|
||||||
|
|
||||||
func ConfigurePanelMCPTasks() error {
|
|
||||||
return taskscope.Configure(taskscope.Rule{Scope: constant.TaskScopePanelMCP, Restricted: true, ExternalExecutor: true})
|
|
||||||
}
|
|
||||||
@@ -1,821 +0,0 @@
|
|||||||
package service
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"crypto/sha256"
|
|
||||||
"crypto/tls"
|
|
||||||
"crypto/x509"
|
|
||||||
"encoding/hex"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"net"
|
|
||||||
"net/url"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"slices"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/1Panel-dev/1Panel/agent/app/dto/request"
|
|
||||||
"github.com/1Panel-dev/1Panel/agent/app/dto/response"
|
|
||||||
"github.com/1Panel-dev/1Panel/agent/app/model"
|
|
||||||
"github.com/1Panel-dev/1Panel/agent/app/repo"
|
|
||||||
"github.com/1Panel-dev/1Panel/agent/constant"
|
|
||||||
"github.com/1Panel-dev/1Panel/agent/utils/docker"
|
|
||||||
"github.com/1Panel-dev/1Panel/agent/utils/nginx"
|
|
||||||
"github.com/1Panel-dev/1Panel/agent/utils/nginx/parser"
|
|
||||||
"github.com/docker/docker/api/types/container"
|
|
||||||
"github.com/google/uuid"
|
|
||||||
"golang.org/x/net/idna"
|
|
||||||
"gorm.io/gorm"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
mcpWebsitePrivateDir = ".1panel-mcp"
|
|
||||||
mcpWebsiteProxyName = "proxy.conf"
|
|
||||||
mcpWebsiteOwnerName = "binding.json"
|
|
||||||
)
|
|
||||||
|
|
||||||
var mcpWebsiteMutation sync.Mutex
|
|
||||||
|
|
||||||
func init() {
|
|
||||||
configureWebsiteManagedConfig(websiteManagedConfigPolicy{
|
|
||||||
protectedDirectories: func() []string {
|
|
||||||
return []string{filepath.Join(GetWebSiteRootDir(), mcpWebsitePrivateDir)}
|
|
||||||
},
|
|
||||||
backupIncludes: mcpWebsiteBackupIncludes,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
type MCPWebsiteBindingService struct {
|
|
||||||
createWebsite func(request.WebsiteCreate) error
|
|
||||||
updateHTTPS func(context.Context, request.WebsiteHTTPSOp) error
|
|
||||||
applyInclude func(model.Website) error
|
|
||||||
removeInclude func(model.Website) error
|
|
||||||
checkReload func() error
|
|
||||||
openresty func() (model.AppInstall, error)
|
|
||||||
now func() time.Time
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewMCPWebsiteBindingService() *MCPWebsiteBindingService {
|
|
||||||
s := &MCPWebsiteBindingService{
|
|
||||||
createWebsite: func(req request.WebsiteCreate) error { return NewIWebsiteService().CreateWebsite(req) },
|
|
||||||
updateHTTPS: func(ctx context.Context, req request.WebsiteHTTPSOp) error {
|
|
||||||
if req.Enable {
|
|
||||||
current, err := NewIWebsiteService().GetWebsiteHTTPS(req.WebsiteID)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defaults := newWebsiteCreateHTTPSOp(req.WebsiteSSLID)
|
|
||||||
if current.Enable {
|
|
||||||
req.SSLProtocol, req.Algorithm = current.SSLProtocol, current.Algorithm
|
|
||||||
req.Hsts, req.HstsIncludeSubDomains, req.Http3 = current.Hsts, current.HstsIncludeSubDomains, current.Http3
|
|
||||||
req.HttpConfig = current.HttpConfig
|
|
||||||
} else {
|
|
||||||
defaults.WebsiteID = req.WebsiteID
|
|
||||||
req = defaults
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_, err := NewIWebsiteService().OpWebsiteHTTPS(ctx, req)
|
|
||||||
return err
|
|
||||||
},
|
|
||||||
checkReload: func() error {
|
|
||||||
installed, err := getAppInstallByKey(constant.AppOpenresty)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if installed.Status != constant.StatusRunning {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if err = opNginx(installed.ContainerName, constant.NginxCheck); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return opNginx(installed.ContainerName, constant.NginxReload)
|
|
||||||
},
|
|
||||||
openresty: mcpWebsiteOpenresty,
|
|
||||||
now: time.Now,
|
|
||||||
}
|
|
||||||
s.applyInclude = func(site model.Website) error { return s.setInclude(site, true) }
|
|
||||||
s.removeInclude = func(site model.Website) error { return s.setInclude(site, false) }
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
|
|
||||||
func mcpWebsiteOpenresty() (model.AppInstall, error) {
|
|
||||||
installed, err := getAppInstallByKey(constant.AppOpenresty)
|
|
||||||
if err != nil || installed.ID == 0 || installed.Status != constant.StatusRunning {
|
|
||||||
return installed, err
|
|
||||||
}
|
|
||||||
client, err := docker.NewDockerClient()
|
|
||||||
if err != nil {
|
|
||||||
return model.AppInstall{}, errors.New("OpenResty mount is unavailable")
|
|
||||||
}
|
|
||||||
defer client.Close()
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
inspected, err := client.ContainerInspect(ctx, installed.ContainerName)
|
|
||||||
if err != nil || !mcpWebsiteHasWWWMount(inspected.Mounts, GetWebSiteRootDir()) {
|
|
||||||
return model.AppInstall{}, errors.New("OpenResty mount is unavailable")
|
|
||||||
}
|
|
||||||
return installed, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func mcpWebsiteHasWWWMount(mounts []container.MountPoint, root string) bool {
|
|
||||||
resolvedRoot, err := filepath.EvalSymlinks(root)
|
|
||||||
if err != nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
for _, mount := range mounts {
|
|
||||||
if filepath.Clean(mount.Destination) != "/www" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
resolvedSource, sourceErr := filepath.EvalSymlinks(mount.Source)
|
|
||||||
if sourceErr == nil && resolvedSource == resolvedRoot {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
type mcpWebsiteOwner struct {
|
|
||||||
BindingID string `json:"bindingId"`
|
|
||||||
WebsiteID uint `json:"websiteId"`
|
|
||||||
KeyHash string `json:"keyHash"`
|
|
||||||
BindHash string `json:"bindHash"`
|
|
||||||
ProxyHash string `json:"proxyHash"`
|
|
||||||
RequestID string `json:"requestId"`
|
|
||||||
RequestHash string `json:"requestHash"`
|
|
||||||
Operation string `json:"operation"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func mcpWebsiteHash(value string) string {
|
|
||||||
digest := sha256.Sum256([]byte(value))
|
|
||||||
return hex.EncodeToString(digest[:])
|
|
||||||
}
|
|
||||||
|
|
||||||
func mcpWebsiteAlias(bindingID string) string { return "panel-mcp-" + bindingID }
|
|
||||||
func mcpWebsitePrivatePath(site model.Website, name string) string {
|
|
||||||
return filepath.Join(GetWebSiteRootDir(), mcpWebsitePrivateDir, strings.TrimPrefix(site.Alias, "panel-mcp-"), name)
|
|
||||||
}
|
|
||||||
|
|
||||||
func mcpWebsiteInclude(site model.Website) string {
|
|
||||||
return "/www/" + mcpWebsitePrivateDir + "/" + strings.TrimPrefix(site.Alias, "panel-mcp-") + "/" + mcpWebsiteProxyName
|
|
||||||
}
|
|
||||||
|
|
||||||
func mcpWebsiteBackupIncludes(site model.Website) []string {
|
|
||||||
bindingID := strings.TrimPrefix(site.Alias, "panel-mcp-")
|
|
||||||
if site.Alias != mcpWebsiteAlias(bindingID) || !validMCPWebsiteID(bindingID) {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return []string{mcpWebsiteInclude(site)}
|
|
||||||
}
|
|
||||||
|
|
||||||
func mcpWebsitePrivateDirectory(site model.Website, create bool) error {
|
|
||||||
for _, directory := range []string{filepath.Join(GetWebSiteRootDir(), mcpWebsitePrivateDir), filepath.Dir(mcpWebsitePrivatePath(site, mcpWebsiteProxyName))} {
|
|
||||||
info, err := os.Lstat(directory)
|
|
||||||
if os.IsNotExist(err) && create {
|
|
||||||
if err = os.Mkdir(directory, 0700); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
info, err = os.Lstat(directory)
|
|
||||||
}
|
|
||||||
if err != nil || !info.IsDir() || info.Mode()&os.ModeSymlink != 0 || info.Mode().Perm() != 0700 {
|
|
||||||
return errors.New("invalid MCP website private directory")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *MCPWebsiteBindingService) setInclude(site model.Website, enabled bool) error {
|
|
||||||
configPath := GetSitePath(site, SiteConf)
|
|
||||||
old, err := os.ReadFile(configPath)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
config, err := parser.NewStringParser(string(old)).Parse()
|
|
||||||
if err != nil || len(config.FindServers()) == 0 {
|
|
||||||
return errors.New("invalid website configuration")
|
|
||||||
}
|
|
||||||
server := config.FindServers()[0]
|
|
||||||
include := mcpWebsiteInclude(site)
|
|
||||||
for _, directive := range server.FindDirectives("include") {
|
|
||||||
parameters := directive.GetParameters()
|
|
||||||
if len(parameters) == 1 && strings.Trim(parameters[0], "\"'") == include {
|
|
||||||
server.RemoveDirective("include", parameters)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if enabled {
|
|
||||||
server.UpdateDirective("include", []string{include})
|
|
||||||
}
|
|
||||||
updated := []byte(nginx.DumpConfig(config, nginx.IndentedStyle))
|
|
||||||
if string(updated) == string(old) {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if err = mcpWebsiteAtomicWrite(configPath, updated); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err = s.checkReload(); err != nil {
|
|
||||||
_ = mcpWebsiteAtomicWrite(configPath, old)
|
|
||||||
_ = s.checkReload()
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func mcpWebsiteRemark(req request.MCPWebsiteBinding) string {
|
|
||||||
return "1Panel MCP " + req.BindingID + " " + mcpWebsiteHash(req.ProxyKey)
|
|
||||||
}
|
|
||||||
|
|
||||||
func mcpWebsiteFailure(reason string, definitive, conflict bool) response.MCPWebsiteBinding {
|
|
||||||
code, message := "MCP_WEBSITE_UNAVAILABLE", "MCP website operation is unavailable"
|
|
||||||
if definitive {
|
|
||||||
code, message = "MCP_WEBSITE_INVALID_ARGUMENTS", "Invalid MCP website configuration"
|
|
||||||
}
|
|
||||||
if conflict {
|
|
||||||
code, message = "MCP_WEBSITE_CONFLICT", "MCP website configuration changed"
|
|
||||||
}
|
|
||||||
return response.MCPWebsiteBinding{State: "unavailable", Reason: reason, Code: code, Error: message, Definitive: definitive}
|
|
||||||
}
|
|
||||||
|
|
||||||
func validMCPWebsiteID(value string) bool {
|
|
||||||
parsed, err := uuid.Parse(value)
|
|
||||||
return err == nil && parsed != uuid.Nil && parsed.String() == value
|
|
||||||
}
|
|
||||||
|
|
||||||
func mcpWebsiteDomain(value string) (string, int, error) {
|
|
||||||
if value == "" || len(value) > 320 || strings.TrimSpace(value) != value || strings.ContainsAny(value, "/\\?#@\r\n\t ;{}\"'$\x00") {
|
|
||||||
return "", 0, errors.New("invalid domain")
|
|
||||||
}
|
|
||||||
host, port := value, 0
|
|
||||||
if parsedHost, parsedPort, err := net.SplitHostPort(value); err == nil {
|
|
||||||
host = parsedHost
|
|
||||||
port, err = strconv.Atoi(parsedPort)
|
|
||||||
if err != nil || port < 1 || port > 65535 {
|
|
||||||
return "", 0, errors.New("invalid port")
|
|
||||||
}
|
|
||||||
} else if strings.ContainsAny(value, ":[]") {
|
|
||||||
return "", 0, errors.New("invalid domain")
|
|
||||||
}
|
|
||||||
host, err := idna.Lookup.ToASCII(strings.ToLower(host))
|
|
||||||
if err != nil || len(host) > 253 || net.ParseIP(host) != nil {
|
|
||||||
return "", 0, errors.New("invalid domain")
|
|
||||||
}
|
|
||||||
for _, label := range strings.Split(host, ".") {
|
|
||||||
if label == "" || len(label) > 63 || label[0] == '-' || label[len(label)-1] == '-' {
|
|
||||||
return "", 0, errors.New("invalid domain")
|
|
||||||
}
|
|
||||||
for _, char := range label {
|
|
||||||
if char != '-' && (char < 'a' || char > 'z') && (char < '0' || char > '9') {
|
|
||||||
return "", 0, errors.New("invalid domain")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return host, port, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Core can select a locally bound panel IP, but an external upstream is never
|
|
||||||
// accepted even on this private API. Host/SNI is a literal hostname, not Nginx code.
|
|
||||||
func validateMCPWebsiteUpstream(req request.MCPWebsiteBinding) bool {
|
|
||||||
if strings.ContainsAny(req.Upstream, "\r\n\t ;{}\"'$#?") {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
u, err := url.Parse(req.Upstream)
|
|
||||||
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Path != "/mcp" || u.RawPath != "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" || u.Port() == "" {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
ip := net.ParseIP(u.Hostname())
|
|
||||||
if ip == nil || ip.IsUnspecified() {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
port, err := strconv.Atoi(u.Port())
|
|
||||||
if err != nil || port < 1 || port > 65535 {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
local := ip.IsLoopback()
|
|
||||||
if !local {
|
|
||||||
addresses, err := net.InterfaceAddrs()
|
|
||||||
if err != nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
for _, address := range addresses {
|
|
||||||
if network, ok := address.(*net.IPNet); ok && network.IP.Equal(ip) {
|
|
||||||
local = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !local || len(req.ProxyKey) != 64 {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if _, err = hex.DecodeString(req.ProxyKey); err != nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if req.CoreHost == "" || len(req.CoreHost) > 320 || strings.ContainsAny(req.CoreHost, "/\\?#@\r\n\t ;{}\"'$\x00") {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
host := req.CoreHost
|
|
||||||
if parsed, _, splitErr := net.SplitHostPort(host); splitErr == nil {
|
|
||||||
host = parsed
|
|
||||||
}
|
|
||||||
if strings.HasPrefix(host, "[") && strings.HasSuffix(host, "]") {
|
|
||||||
host = strings.Trim(host, "[]")
|
|
||||||
}
|
|
||||||
if net.ParseIP(host) == nil {
|
|
||||||
if _, _, domainErr := mcpWebsiteDomain(host); domainErr != nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
func mcpWebsiteProxy(req request.MCPWebsiteBinding) []byte {
|
|
||||||
host := req.CoreHost
|
|
||||||
if parsed, _, err := net.SplitHostPort(host); err == nil {
|
|
||||||
host = parsed
|
|
||||||
}
|
|
||||||
host = strings.Trim(host, "[]")
|
|
||||||
var tlsDirectives string
|
|
||||||
if strings.HasPrefix(req.Upstream, "https://") {
|
|
||||||
tlsDirectives = " proxy_ssl_server_name on;\n proxy_ssl_name " + host + ";\n"
|
|
||||||
}
|
|
||||||
return []byte("# Managed by 1Panel MCP: " + req.BindingID + "\n" +
|
|
||||||
"location = /mcp {\n" +
|
|
||||||
" proxy_pass " + req.Upstream + ";\n" +
|
|
||||||
" proxy_http_version 1.1;\n" +
|
|
||||||
" proxy_set_header Host " + req.CoreHost + ";\n" +
|
|
||||||
" proxy_set_header Connection \"\";\n" +
|
|
||||||
" proxy_set_header X-Panel-MCP-Website-Key " + req.ProxyKey + ";\n" +
|
|
||||||
" proxy_set_header X-Panel-MCP-Website-Peer $realip_remote_addr;\n" +
|
|
||||||
" proxy_set_header X-Forwarded-For $http_x_forwarded_for;\n" +
|
|
||||||
" proxy_set_header X-Real-IP $http_x_real_ip;\n" +
|
|
||||||
" proxy_set_header Forwarded $http_forwarded;\n" +
|
|
||||||
" proxy_set_header Authorization $http_authorization;\n" +
|
|
||||||
" proxy_set_header Origin $http_origin;\n" +
|
|
||||||
" proxy_set_header Accept $http_accept;\n" +
|
|
||||||
" proxy_set_header Content-Type $http_content_type;\n" +
|
|
||||||
" proxy_set_header MCP-Protocol-Version $http_mcp_protocol_version;\n" +
|
|
||||||
" proxy_buffering off;\n proxy_request_buffering off;\n proxy_cache off;\n" +
|
|
||||||
" proxy_read_timeout 360s;\n proxy_send_timeout 360s;\n proxy_connect_timeout 5s;\n" +
|
|
||||||
" proxy_next_upstream off;\n client_max_body_size 1m;\n" + tlsDirectives + "}\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *MCPWebsiteBindingService) validCertificate(id uint, domain string) bool {
|
|
||||||
certificate, err := websiteSSLRepo.GetFirst(repo.WithByID(id))
|
|
||||||
if err != nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
pair, err := tls.X509KeyPair([]byte(certificate.Pem), []byte(certificate.PrivateKey))
|
|
||||||
if err != nil || len(pair.Certificate) == 0 {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
leaf, err := x509.ParseCertificate(pair.Certificate[0])
|
|
||||||
if err != nil || s.now().Before(leaf.NotBefore) || !s.now().Before(leaf.NotAfter) || leaf.VerifyHostname(domain) != nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if len(leaf.ExtKeyUsage) == 0 {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
for _, usage := range leaf.ExtKeyUsage {
|
|
||||||
if usage == x509.ExtKeyUsageServerAuth || usage == x509.ExtKeyUsageAny {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
func mcpWebsiteReadOwner(site model.Website) (mcpWebsiteOwner, error) {
|
|
||||||
var owner mcpWebsiteOwner
|
|
||||||
if err := mcpWebsitePrivateDirectory(site, false); err != nil {
|
|
||||||
return owner, err
|
|
||||||
}
|
|
||||||
path := mcpWebsitePrivatePath(site, mcpWebsiteOwnerName)
|
|
||||||
info, err := os.Lstat(path)
|
|
||||||
if err != nil || !info.Mode().IsRegular() || info.Size() > 8192 {
|
|
||||||
return owner, errors.New("invalid MCP website ownership")
|
|
||||||
}
|
|
||||||
data, err := os.ReadFile(path)
|
|
||||||
if err != nil || json.Unmarshal(data, &owner) != nil {
|
|
||||||
return owner, errors.New("invalid MCP website ownership")
|
|
||||||
}
|
|
||||||
return owner, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func mcpWebsiteWriteOwner(site model.Website, owner mcpWebsiteOwner) error {
|
|
||||||
data, err := json.Marshal(owner)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return mcpWebsiteAtomicWrite(mcpWebsitePrivatePath(site, mcpWebsiteOwnerName), data)
|
|
||||||
}
|
|
||||||
|
|
||||||
func mcpWebsiteAtomicWrite(path string, data []byte) error {
|
|
||||||
if info, err := os.Lstat(path); err == nil && !info.Mode().IsRegular() {
|
|
||||||
return errors.New("managed file is not regular")
|
|
||||||
} else if err != nil && !os.IsNotExist(err) {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
temp, err := os.CreateTemp(filepath.Dir(path), ".panel-mcp-*")
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer os.Remove(temp.Name())
|
|
||||||
if err = temp.Chmod(0600); err == nil {
|
|
||||||
_, err = temp.Write(data)
|
|
||||||
}
|
|
||||||
if closeErr := temp.Close(); err == nil {
|
|
||||||
err = closeErr
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return os.Rename(temp.Name(), path)
|
|
||||||
}
|
|
||||||
|
|
||||||
func mcpWebsiteCurrentProxy(site model.Website, owner mcpWebsiteOwner) ([]byte, error) {
|
|
||||||
if err := mcpWebsitePrivateDirectory(site, false); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
path := mcpWebsitePrivatePath(site, mcpWebsiteProxyName)
|
|
||||||
info, err := os.Lstat(path)
|
|
||||||
if err != nil || !info.Mode().IsRegular() || info.Size() > 16<<10 {
|
|
||||||
return nil, errors.New("managed proxy is missing or invalid")
|
|
||||||
}
|
|
||||||
data, err := os.ReadFile(path)
|
|
||||||
if err != nil || mcpWebsiteHash(string(data)) != owner.ProxyHash {
|
|
||||||
return nil, errors.New("managed proxy was modified")
|
|
||||||
}
|
|
||||||
return data, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func mcpWebsiteReadProxy(path string) ([]byte, error) {
|
|
||||||
info, err := os.Lstat(path)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if !info.Mode().IsRegular() || info.Size() > 16<<10 {
|
|
||||||
return nil, errors.New("managed proxy is not a regular file")
|
|
||||||
}
|
|
||||||
return os.ReadFile(path)
|
|
||||||
}
|
|
||||||
|
|
||||||
func mcpWebsitePayloadHash(req request.MCPWebsiteBinding) string {
|
|
||||||
return mcpWebsiteHash(fmt.Sprintf("%s\n%s\n%t\n%d", req.Operation, req.Domain, req.EnableSSL, req.SSLID))
|
|
||||||
}
|
|
||||||
|
|
||||||
func mcpWebsiteBindHash(req request.MCPWebsiteBinding) string {
|
|
||||||
return mcpWebsiteHash(fmt.Sprintf("%s\n%t\n%d", req.Domain, req.EnableSSL, req.SSLID))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *MCPWebsiteBindingService) matchesInitialSite(req request.MCPWebsiteBinding, site model.Website) bool {
|
|
||||||
host, port, err := mcpWebsiteDomain(req.Domain)
|
|
||||||
if err != nil || (site.Protocol == constant.ProtocolHTTPS) != req.EnableSSL || site.WebsiteSSLID != req.SSLID {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if port == 0 {
|
|
||||||
installed, installErr := s.openresty()
|
|
||||||
if installErr != nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
port = installed.HttpPort
|
|
||||||
if req.EnableSSL {
|
|
||||||
port = installed.HttpsPort
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, domain := range site.Domains {
|
|
||||||
if domain.Domain == host && domain.Port == port {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *MCPWebsiteBindingService) load(req request.MCPWebsiteBinding) (model.Website, error) {
|
|
||||||
if req.WebsiteID > 0 {
|
|
||||||
return websiteRepo.GetFirst(repo.WithByID(req.WebsiteID))
|
|
||||||
}
|
|
||||||
return websiteRepo.GetFirst(websiteRepo.WithAlias(mcpWebsiteAlias(req.BindingID)))
|
|
||||||
}
|
|
||||||
|
|
||||||
func mcpWebsiteProxyIncluded(site model.Website) bool {
|
|
||||||
server, err := getServer(site)
|
|
||||||
if err != nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
include := mcpWebsiteInclude(site)
|
|
||||||
for _, directive := range server.FindDirectives("include") {
|
|
||||||
parameters := directive.GetParameters()
|
|
||||||
if len(parameters) == 1 && strings.Trim(parameters[0], "\"'") == include {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *MCPWebsiteBindingService) status(req request.MCPWebsiteBinding, site model.Website, owner mcpWebsiteOwner) response.MCPWebsiteBinding {
|
|
||||||
result := response.MCPWebsiteBinding{State: "bound", WebsiteID: site.ID, BindingID: req.BindingID, RequestID: owner.RequestID,
|
|
||||||
Domain: site.PrimaryDomain, EnableSSL: site.Protocol == constant.ProtocolHTTPS, SSLID: site.WebsiteSSLID, WebsiteStatus: site.Status}
|
|
||||||
if !result.EnableSSL {
|
|
||||||
result.SSLID = 0
|
|
||||||
}
|
|
||||||
if owner.Operation == "unbind" {
|
|
||||||
result.State = "unbound"
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
if _, err := mcpWebsiteCurrentProxy(site, owner); err != nil {
|
|
||||||
result.State, result.Reason = "unavailable", "proxy_modified"
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
if mcpWebsiteHash(string(mcpWebsiteProxy(req))) != owner.ProxyHash {
|
|
||||||
result.State, result.Reason = "unavailable", "upstream_changed"
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
if site.Status != constant.WebRunning {
|
|
||||||
result.State, result.Reason = "unavailable", "website_stopped"
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
if !mcpWebsiteProxyIncluded(site) {
|
|
||||||
result.State, result.Reason = "unavailable", "proxy_not_loaded"
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
host, _, err := mcpWebsiteDomain(site.PrimaryDomain)
|
|
||||||
if err != nil {
|
|
||||||
result.State, result.Reason = "unavailable", "website_modified"
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
if result.EnableSSL && !s.validCertificate(site.WebsiteSSLID, host) {
|
|
||||||
result.State, result.Reason = "unavailable", "certificate_invalid"
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
installed, err := s.openresty()
|
|
||||||
if err != nil || installed.ID == 0 || installed.Status != constant.StatusRunning {
|
|
||||||
result.State, result.Reason = "unavailable", "openresty_unavailable"
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
matching, matchingSSL := 0, 0
|
|
||||||
for _, domain := range site.Domains {
|
|
||||||
if domain.Domain != host || domain.Port < 1 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if matching == 0 || !domain.SSL {
|
|
||||||
matching = domain.Port
|
|
||||||
}
|
|
||||||
if domain.SSL {
|
|
||||||
matchingSSL = domain.Port
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if matching == 0 {
|
|
||||||
result.State, result.Reason = "unavailable", "website_modified"
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
port := matching
|
|
||||||
if result.EnableSSL {
|
|
||||||
port = matchingSSL
|
|
||||||
if port == 0 {
|
|
||||||
plan := buildWebsiteTLSPlan(site.Domains, installed.HttpPort, installed.HttpsPort)
|
|
||||||
if slices.Contains(plan.httpsPorts, installed.HttpsPort) {
|
|
||||||
port = installed.HttpsPort
|
|
||||||
} else if len(plan.httpsPorts) > 0 {
|
|
||||||
port = plan.httpsPorts[0]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if port == 0 {
|
|
||||||
result.State, result.Reason = "unavailable", "website_modified"
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
scheme := "http"
|
|
||||||
if result.EnableSSL {
|
|
||||||
scheme = "https"
|
|
||||||
}
|
|
||||||
address := host
|
|
||||||
if result.EnableSSL && port != 443 || !result.EnableSSL && port != 80 {
|
|
||||||
address = net.JoinHostPort(host, strconv.Itoa(port))
|
|
||||||
}
|
|
||||||
result.Endpoint = (&url.URL{Scheme: scheme, Host: address, Path: "/mcp"}).String()
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *MCPWebsiteBindingService) Handle(ctx context.Context, req request.MCPWebsiteBinding) (result response.MCPWebsiteBinding) {
|
|
||||||
mcpWebsiteMutation.Lock()
|
|
||||||
defer mcpWebsiteMutation.Unlock()
|
|
||||||
mutationStarted := false
|
|
||||||
defer func() {
|
|
||||||
if mutationStarted && result.Error != "" {
|
|
||||||
result.Definitive = false
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
if req.Operation == "bind" && validMCPWebsiteID(req.BindingID) {
|
|
||||||
_, existingErr := websiteRepo.GetFirst(websiteRepo.WithAlias(mcpWebsiteAlias(req.BindingID)))
|
|
||||||
_, privateErr := os.Lstat(filepath.Join(GetWebSiteRootDir(), mcpWebsitePrivateDir, req.BindingID))
|
|
||||||
mutationStarted = !errors.Is(existingErr, gorm.ErrRecordNotFound) || !os.IsNotExist(privateErr)
|
|
||||||
}
|
|
||||||
if ctx.Err() != nil {
|
|
||||||
return mcpWebsiteFailure("request_cancelled", true, false)
|
|
||||||
}
|
|
||||||
if req.Operation != "status" && req.Operation != "bind" && req.Operation != "update" && req.Operation != "unbind" && req.Operation != "sync" {
|
|
||||||
return mcpWebsiteFailure("invalid_operation", true, false)
|
|
||||||
}
|
|
||||||
if req.BindingID == "" && req.WebsiteID == 0 && req.Operation == "status" {
|
|
||||||
return response.MCPWebsiteBinding{State: "unbound"}
|
|
||||||
}
|
|
||||||
if !validMCPWebsiteID(req.BindingID) || !validateMCPWebsiteUpstream(req) {
|
|
||||||
return mcpWebsiteFailure("invalid_configuration", true, false)
|
|
||||||
}
|
|
||||||
if req.Operation != "status" && req.Operation != "sync" && !validMCPWebsiteID(req.RequestID) {
|
|
||||||
return mcpWebsiteFailure("invalid_request", true, false)
|
|
||||||
}
|
|
||||||
if req.Operation == "bind" {
|
|
||||||
host, port, err := mcpWebsiteDomain(req.Domain)
|
|
||||||
if err != nil || (!req.EnableSSL && req.SSLID != 0) || (req.EnableSSL && (req.SSLID == 0 || !s.validCertificate(req.SSLID, host))) {
|
|
||||||
return mcpWebsiteFailure("invalid_domain_or_certificate", true, false)
|
|
||||||
}
|
|
||||||
req.Domain = host
|
|
||||||
if port != 0 {
|
|
||||||
req.Domain = net.JoinHostPort(host, strconv.Itoa(port))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
site, err := s.load(req)
|
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
||||||
if req.Operation == "status" || req.Operation == "unbind" {
|
|
||||||
if req.WebsiteID == 0 || req.Operation == "unbind" {
|
|
||||||
return response.MCPWebsiteBinding{State: "unbound", BindingID: req.BindingID, RequestID: req.RequestID}
|
|
||||||
}
|
|
||||||
return response.MCPWebsiteBinding{State: "unavailable", WebsiteID: req.WebsiteID, BindingID: req.BindingID, Reason: "website_missing"}
|
|
||||||
}
|
|
||||||
if req.Operation != "bind" {
|
|
||||||
return mcpWebsiteFailure("website_missing", true, true)
|
|
||||||
}
|
|
||||||
installed, installErr := s.openresty()
|
|
||||||
if installErr != nil || installed.ID == 0 || installed.Status != constant.StatusRunning {
|
|
||||||
return mcpWebsiteFailure("openresty_unavailable", true, false)
|
|
||||||
}
|
|
||||||
group, groupErr := groupRepo.Get(groupRepo.WithByWebsiteDefault())
|
|
||||||
if groupErr != nil || group.ID == 0 {
|
|
||||||
return mcpWebsiteFailure("website_group_unavailable", true, false)
|
|
||||||
}
|
|
||||||
domain, port, _ := mcpWebsiteDomain(req.Domain)
|
|
||||||
if port == 0 {
|
|
||||||
port = installed.HttpPort
|
|
||||||
if req.EnableSSL {
|
|
||||||
port = installed.HttpsPort
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if existing, conflictErr := websiteDomainRepo.GetFirst(websiteDomainRepo.WithDomain(domain), websiteDomainRepo.WithPort(port)); conflictErr == nil && existing.ID > 0 {
|
|
||||||
return mcpWebsiteFailure("domain_conflict", true, true)
|
|
||||||
} else if conflictErr != nil && !errors.Is(conflictErr, gorm.ErrRecordNotFound) {
|
|
||||||
return mcpWebsiteFailure("website_unavailable", false, false)
|
|
||||||
}
|
|
||||||
create := request.WebsiteCreate{Alias: mcpWebsiteAlias(req.BindingID), Type: constant.Static, Remark: mcpWebsiteRemark(req), WebsiteGroupID: group.ID,
|
|
||||||
Domains: []request.WebsiteDomain{{Domain: domain, Port: port, SSL: req.EnableSSL}}, SSLConfig: request.SSLConfig{EnableSSL: req.EnableSSL, WebsiteSSLID: req.SSLID}}
|
|
||||||
mutationStarted = true
|
|
||||||
if createErr := s.createWebsite(create); createErr != nil {
|
|
||||||
return mcpWebsiteFailure("website_create_failed", false, false)
|
|
||||||
}
|
|
||||||
site, err = s.load(req)
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return mcpWebsiteFailure("website_unavailable", false, false)
|
|
||||||
}
|
|
||||||
if site.Alias != mcpWebsiteAlias(req.BindingID) {
|
|
||||||
return mcpWebsiteFailure("website_ownership_changed", true, true)
|
|
||||||
}
|
|
||||||
owner, ownerErr := mcpWebsiteReadOwner(site)
|
|
||||||
if ownerErr == nil && owner.RequestID == req.RequestID && req.RequestID != "" {
|
|
||||||
mutationStarted = true
|
|
||||||
}
|
|
||||||
if req.Operation == "update" {
|
|
||||||
if req.Domain != "" && req.Domain != site.PrimaryDomain {
|
|
||||||
return mcpWebsiteFailure("domain_changed", true, true)
|
|
||||||
}
|
|
||||||
req.Domain = site.PrimaryDomain
|
|
||||||
host, _, domainErr := mcpWebsiteDomain(req.Domain)
|
|
||||||
if domainErr != nil || (!req.EnableSSL && req.SSLID != 0) || (req.EnableSSL && (req.SSLID == 0 || !s.validCertificate(req.SSLID, host))) {
|
|
||||||
return mcpWebsiteFailure("invalid_domain_or_certificate", true, false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if info, dirErr := os.Lstat(GetSitePath(site, SiteDir)); dirErr == nil && (!info.IsDir() || info.Mode()&os.ModeSymlink != 0) {
|
|
||||||
return mcpWebsiteFailure("website_directory_changed", true, true)
|
|
||||||
} else if dirErr != nil && !os.IsNotExist(dirErr) {
|
|
||||||
return mcpWebsiteFailure("website_unavailable", false, false)
|
|
||||||
}
|
|
||||||
if ownerErr != nil {
|
|
||||||
if (req.Operation != "bind" && req.Operation != "unbind") || site.Remark != mcpWebsiteRemark(req) {
|
|
||||||
return mcpWebsiteFailure("website_ownership_changed", true, true)
|
|
||||||
}
|
|
||||||
if req.Operation == "bind" && !s.matchesInitialSite(req, site) {
|
|
||||||
return mcpWebsiteFailure("binding_request_changed", true, true)
|
|
||||||
}
|
|
||||||
owner = mcpWebsiteOwner{BindingID: req.BindingID, WebsiteID: site.ID, KeyHash: mcpWebsiteHash(req.ProxyKey), BindHash: mcpWebsiteBindHash(req)}
|
|
||||||
} else if owner.BindingID != req.BindingID || owner.WebsiteID != site.ID || owner.KeyHash != mcpWebsiteHash(req.ProxyKey) {
|
|
||||||
return mcpWebsiteFailure("website_ownership_changed", true, true)
|
|
||||||
}
|
|
||||||
if req.Operation == "status" {
|
|
||||||
return s.status(req, site, owner)
|
|
||||||
}
|
|
||||||
operationHash := mcpWebsitePayloadHash(req)
|
|
||||||
if owner.RequestID == req.RequestID && req.RequestID != "" {
|
|
||||||
if owner.RequestHash != operationHash {
|
|
||||||
return mcpWebsiteFailure("operation_request_changed", true, true)
|
|
||||||
}
|
|
||||||
if owner.Operation == "unbind" || owner.ProxyHash == mcpWebsiteHash(string(mcpWebsiteProxy(req))) {
|
|
||||||
return s.status(req, site, owner)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if req.Operation == "bind" && owner.BindHash != mcpWebsiteBindHash(req) {
|
|
||||||
return mcpWebsiteFailure("binding_request_changed", true, true)
|
|
||||||
}
|
|
||||||
if owner.ProxyHash != "" && owner.Operation != "unbind" && req.Operation != "unbind" && !mcpWebsiteProxyIncluded(site) {
|
|
||||||
return mcpWebsiteFailure("proxy_not_loaded", true, true)
|
|
||||||
}
|
|
||||||
proxyPath := mcpWebsitePrivatePath(site, mcpWebsiteProxyName)
|
|
||||||
if err = mcpWebsitePrivateDirectory(site, true); err != nil {
|
|
||||||
return mcpWebsiteFailure("proxy_directory_changed", true, true)
|
|
||||||
}
|
|
||||||
if ownerErr != nil {
|
|
||||||
mutationStarted = true
|
|
||||||
if err = mcpWebsiteWriteOwner(site, owner); err != nil {
|
|
||||||
return mcpWebsiteFailure("ownership_write_failed", false, false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
var oldProxy []byte
|
|
||||||
if owner.ProxyHash != "" && owner.Operation != "unbind" {
|
|
||||||
oldProxy, err = mcpWebsiteCurrentProxy(site, owner)
|
|
||||||
if err != nil {
|
|
||||||
actual, readErr := mcpWebsiteReadProxy(proxyPath)
|
|
||||||
if req.Operation == "unbind" && os.IsNotExist(readErr) {
|
|
||||||
oldProxy = nil
|
|
||||||
} else if readErr == nil && string(actual) == string(mcpWebsiteProxy(req)) {
|
|
||||||
oldProxy = actual
|
|
||||||
} else {
|
|
||||||
return mcpWebsiteFailure("proxy_modified", true, true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if _, statErr := os.Lstat(proxyPath); statErr == nil {
|
|
||||||
actual, readErr := mcpWebsiteReadProxy(proxyPath)
|
|
||||||
if readErr != nil || string(actual) != string(mcpWebsiteProxy(req)) {
|
|
||||||
return mcpWebsiteFailure("proxy_already_exists", true, true)
|
|
||||||
}
|
|
||||||
oldProxy = actual
|
|
||||||
} else if !os.IsNotExist(statErr) {
|
|
||||||
return mcpWebsiteFailure("proxy_unavailable", false, false)
|
|
||||||
}
|
|
||||||
if req.Operation == "unbind" {
|
|
||||||
if mcpWebsiteProxyIncluded(site) {
|
|
||||||
mutationStarted = true
|
|
||||||
if err = s.removeInclude(site); err != nil {
|
|
||||||
return mcpWebsiteFailure("proxy_reload_failed", false, false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(oldProxy) > 0 {
|
|
||||||
mutationStarted = true
|
|
||||||
if err = os.Remove(proxyPath); err != nil {
|
|
||||||
return mcpWebsiteFailure("proxy_remove_failed", false, false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
owner.Operation, owner.RequestID, owner.RequestHash = "unbind", req.RequestID, operationHash
|
|
||||||
mutationStarted = true
|
|
||||||
if err = mcpWebsiteWriteOwner(site, owner); err != nil {
|
|
||||||
return mcpWebsiteFailure("ownership_write_failed", false, false)
|
|
||||||
}
|
|
||||||
return s.status(req, site, owner)
|
|
||||||
}
|
|
||||||
if req.Operation == "update" {
|
|
||||||
if req.Domain != site.PrimaryDomain {
|
|
||||||
return mcpWebsiteFailure("domain_changed", true, true)
|
|
||||||
}
|
|
||||||
if site.Protocol == constant.ProtocolHTTPS && !req.EnableSSL || site.Protocol != constant.ProtocolHTTPS && req.EnableSSL || req.EnableSSL && site.WebsiteSSLID != req.SSLID {
|
|
||||||
mutationStarted = true
|
|
||||||
if err = s.updateHTTPS(context.WithoutCancel(ctx), request.WebsiteHTTPSOp{WebsiteID: site.ID, Enable: req.EnableSSL, Type: constant.SSLExisted, WebsiteSSLID: req.SSLID, HttpConfig: constant.HTTPSOnly}); err != nil {
|
|
||||||
return mcpWebsiteFailure("certificate_update_failed", false, false)
|
|
||||||
}
|
|
||||||
site, err = s.load(req)
|
|
||||||
if err != nil {
|
|
||||||
return mcpWebsiteFailure("website_unavailable", false, false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
newProxy := mcpWebsiteProxy(req)
|
|
||||||
mutationStarted = true
|
|
||||||
if err = mcpWebsiteAtomicWrite(proxyPath, newProxy); err != nil {
|
|
||||||
return mcpWebsiteFailure("proxy_write_failed", false, false)
|
|
||||||
}
|
|
||||||
if err = s.applyInclude(site); err != nil {
|
|
||||||
if len(oldProxy) == 0 {
|
|
||||||
_ = os.Remove(proxyPath)
|
|
||||||
} else {
|
|
||||||
_ = mcpWebsiteAtomicWrite(proxyPath, oldProxy)
|
|
||||||
}
|
|
||||||
_ = s.checkReload()
|
|
||||||
return mcpWebsiteFailure("proxy_reload_failed", false, false)
|
|
||||||
}
|
|
||||||
owner.ProxyHash = mcpWebsiteHash(string(newProxy))
|
|
||||||
if req.Operation != "sync" || req.RequestID != "" {
|
|
||||||
owner.RequestID, owner.RequestHash, owner.Operation = req.RequestID, operationHash, req.Operation
|
|
||||||
}
|
|
||||||
if err = mcpWebsiteWriteOwner(site, owner); err != nil {
|
|
||||||
return mcpWebsiteFailure("ownership_write_failed", false, false)
|
|
||||||
}
|
|
||||||
return s.status(req, site, owner)
|
|
||||||
}
|
|
||||||
@@ -647,7 +647,7 @@ func (r *RuntimeService) Get(id uint) (*response.RuntimeDTO, error) {
|
|||||||
return &res, nil
|
return &res, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *RuntimeService) Update(req request.RuntimeUpdate) (err error) {
|
func (r *RuntimeService) Update(req request.RuntimeUpdate) error {
|
||||||
runtime, err := runtimeRepo.GetFirst(context.Background(), repo.WithByID(req.ID))
|
runtime, err := runtimeRepo.GetFirst(context.Background(), repo.WithByID(req.ID))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -656,7 +656,6 @@ func (r *RuntimeService) Update(req request.RuntimeUpdate) (err error) {
|
|||||||
runtime.Version = req.Version
|
runtime.Version = req.Version
|
||||||
return runtimeRepo.Save(runtime)
|
return runtimeRepo.Save(runtime)
|
||||||
}
|
}
|
||||||
oldRuntime := *runtime
|
|
||||||
appDetail, err := getRuntimeUpdateAppDetail(runtime, req)
|
appDetail, err := getRuntimeUpdateAppDetail(runtime, req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -701,20 +700,6 @@ func (r *RuntimeService) Update(req request.RuntimeUpdate) (err error) {
|
|||||||
if err = downloadApp(app, appDetail, nil, nil); err != nil {
|
if err = downloadApp(app, appDetail, nil, nil); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
var snapshot *runtimeUpdateSnapshot
|
|
||||||
if runtime.Type != constant.RuntimePHP {
|
|
||||||
snapshot, err = snapshotRuntimeUpdate(&oldRuntime)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer func() {
|
|
||||||
if err != nil {
|
|
||||||
if restoreErr := snapshot.restoreFiles(); restoreErr != nil {
|
|
||||||
err = fmt.Errorf("%w; restore runtime configuration: %v", err, restoreErr)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
if versionChanged {
|
if versionChanged {
|
||||||
if err = updateRuntimeVersionFiles(runtime, appVersionDir); err != nil {
|
if err = updateRuntimeVersionFiles(runtime, appVersionDir); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -788,13 +773,7 @@ func (r *RuntimeService) Update(req request.RuntimeUpdate) (err error) {
|
|||||||
if err = runtimeRepo.Save(runtime); err != nil {
|
if err = runtimeRepo.Save(runtime); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
go func() {
|
go reCreateRuntime(runtime)
|
||||||
if err := prepareRuntimeUpdate(runtime, snapshot); err != nil {
|
|
||||||
global.LOG.Errorf("prepare runtime [%s] update failed: %v", runtime.Name, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
reCreateRuntime(runtime)
|
|
||||||
}()
|
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,6 @@
|
|||||||
package service
|
package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
|
||||||
"github.com/1Panel-dev/1Panel/agent/app/dto/request"
|
"github.com/1Panel-dev/1Panel/agent/app/dto/request"
|
||||||
@@ -15,89 +12,6 @@ import (
|
|||||||
"gopkg.in/yaml.v3"
|
"gopkg.in/yaml.v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Keep the on-disk configuration as well as the model: files may contain edits
|
|
||||||
// that have not been reflected in the database.
|
|
||||||
type runtimeUpdateSnapshot struct {
|
|
||||||
runtime model.Runtime
|
|
||||||
files []runtimeUpdateFile
|
|
||||||
}
|
|
||||||
|
|
||||||
type runtimeUpdateFile struct {
|
|
||||||
path string
|
|
||||||
content []byte
|
|
||||||
mode os.FileMode
|
|
||||||
exists bool
|
|
||||||
}
|
|
||||||
|
|
||||||
func snapshotRuntimeUpdate(runtime *model.Runtime) (*runtimeUpdateSnapshot, error) {
|
|
||||||
snapshot := &runtimeUpdateSnapshot{runtime: *runtime}
|
|
||||||
for _, name := range []string{"docker-compose.yml", ".env", "run.sh"} {
|
|
||||||
file := runtimeUpdateFile{path: filepath.Join(runtime.GetPath(), name)}
|
|
||||||
info, err := os.Stat(file.path)
|
|
||||||
if errors.Is(err, os.ErrNotExist) {
|
|
||||||
snapshot.files = append(snapshot.files, file)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
file.content, err = os.ReadFile(file.path)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
file.exists, file.mode = true, info.Mode().Perm()
|
|
||||||
snapshot.files = append(snapshot.files, file)
|
|
||||||
}
|
|
||||||
return snapshot, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *runtimeUpdateSnapshot) restoreFiles() error {
|
|
||||||
var result error
|
|
||||||
for _, file := range s.files {
|
|
||||||
var err error
|
|
||||||
if file.exists {
|
|
||||||
err = os.WriteFile(file.path, file.content, file.mode)
|
|
||||||
if err == nil {
|
|
||||||
err = os.Chmod(file.path, file.mode)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
err = os.Remove(file.path)
|
|
||||||
if errors.Is(err, os.ErrNotExist) {
|
|
||||||
err = nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
result = errors.Join(result, fmt.Errorf("restore %s: %w", file.path, err))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
// This must finish before reCreateRuntime stops the existing container. A
|
|
||||||
// failed preparation restores the previous version without touching Docker.
|
|
||||||
func prepareRuntimeUpdate(runtime *model.Runtime, snapshot *runtimeUpdateSnapshot) error {
|
|
||||||
if err := pullRuntimeComposeImages(runtime.GetComposePath(), false, nil); err != nil {
|
|
||||||
result := fmt.Errorf("prepare runtime images: %w", err)
|
|
||||||
restoreErr := snapshot.restoreFiles()
|
|
||||||
*runtime = snapshot.runtime
|
|
||||||
if restoreErr != nil {
|
|
||||||
result = errors.Join(result, restoreErr)
|
|
||||||
runtime.Status = constant.StatusError
|
|
||||||
}
|
|
||||||
runtime.Message = result.Error()
|
|
||||||
if saveErr := runtimeRepo.Save(runtime); saveErr != nil {
|
|
||||||
result = errors.Join(result, fmt.Errorf("restore runtime record: %w", saveErr))
|
|
||||||
}
|
|
||||||
logFile, logErr := os.OpenFile(runtime.GetLogPath(), os.O_CREATE|os.O_WRONLY|os.O_APPEND, constant.FilePerm)
|
|
||||||
if logErr == nil {
|
|
||||||
_, logErr = fmt.Fprintln(logFile, result.Error())
|
|
||||||
logErr = errors.Join(logErr, logFile.Close())
|
|
||||||
}
|
|
||||||
return errors.Join(result, logErr)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func getRuntimeUpdateAppDetail(runtime *model.Runtime, req request.RuntimeUpdate) (model.AppDetail, error) {
|
func getRuntimeUpdateAppDetail(runtime *model.Runtime, req request.RuntimeUpdate) (model.AppDetail, error) {
|
||||||
current, err := appDetailRepo.GetFirst(repo.WithByID(runtime.AppDetailID))
|
current, err := appDetailRepo.GetFirst(repo.WithByID(runtime.AppDetailID))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -8,12 +8,9 @@ import (
|
|||||||
"github.com/1Panel-dev/1Panel/agent/app/dto/response"
|
"github.com/1Panel-dev/1Panel/agent/app/dto/response"
|
||||||
"github.com/1Panel-dev/1Panel/agent/app/repo"
|
"github.com/1Panel-dev/1Panel/agent/app/repo"
|
||||||
"github.com/1Panel-dev/1Panel/agent/utils/files"
|
"github.com/1Panel-dev/1Panel/agent/utils/files"
|
||||||
"github.com/1Panel-dev/1Panel/agent/utils/taskscope"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type TaskLogService struct {
|
type TaskLogService struct{}
|
||||||
grantedScopes []string
|
|
||||||
}
|
|
||||||
|
|
||||||
type ITaskLogService interface {
|
type ITaskLogService interface {
|
||||||
Page(req dto.SearchTaskLogReq) (int64, []dto.TaskDTO, error)
|
Page(req dto.SearchTaskLogReq) (int64, []dto.TaskDTO, error)
|
||||||
@@ -22,18 +19,13 @@ type ITaskLogService interface {
|
|||||||
CountExecutingTask() (int64, error)
|
CountExecutingTask() (int64, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewITaskService(grantedScopes ...string) ITaskLogService {
|
func NewITaskService() ITaskLogService {
|
||||||
return &TaskLogService{grantedScopes: append([]string(nil), grantedScopes...)}
|
return &TaskLogService{}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (u *TaskLogService) Page(req dto.SearchTaskLogReq) (int64, []dto.TaskDTO, error) {
|
func (u *TaskLogService) Page(req dto.SearchTaskLogReq) (int64, []dto.TaskDTO, error) {
|
||||||
excluded, err := taskscope.ReadExclusions(u.grantedScopes...)
|
|
||||||
if err != nil {
|
|
||||||
return 0, nil, err
|
|
||||||
}
|
|
||||||
opts := []repo.DBOption{
|
opts := []repo.DBOption{
|
||||||
repo.WithOrderDesc("created_at"),
|
repo.WithOrderDesc("created_at"),
|
||||||
taskRepo.WithoutScopes(excluded...),
|
|
||||||
}
|
}
|
||||||
if req.TaskID != "" {
|
if req.TaskID != "" {
|
||||||
opts = append(opts, taskRepo.WithByID(req.TaskID))
|
opts = append(opts, taskRepo.WithByID(req.TaskID))
|
||||||
@@ -61,11 +53,7 @@ func (u *TaskLogService) Page(req dto.SearchTaskLogReq) (int64, []dto.TaskDTO, e
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (u *TaskLogService) ReadByLine(req request.TaskLogReadReq) (*response.FileLineContent, error) {
|
func (u *TaskLogService) ReadByLine(req request.TaskLogReadReq) (*response.FileLineContent, error) {
|
||||||
excluded, err := taskscope.ReadExclusions(u.grantedScopes...)
|
opts := []repo.DBOption{}
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
opts := []repo.DBOption{taskRepo.WithoutScopes(excluded...)}
|
|
||||||
if req.TaskID != "" {
|
if req.TaskID != "" {
|
||||||
opts = append(opts, taskRepo.WithByID(req.TaskID))
|
opts = append(opts, taskRepo.WithByID(req.TaskID))
|
||||||
} else {
|
} else {
|
||||||
@@ -124,17 +112,9 @@ func (u *TaskLogService) ReadByLine(req request.TaskLogReadReq) (*response.FileL
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (u *TaskLogService) SyncForRestart() error {
|
func (u *TaskLogService) SyncForRestart() error {
|
||||||
excluded, err := taskscope.RecoveryExclusions()
|
return taskRepo.UpdateRunningTaskToFailed()
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return taskRepo.UpdateRunningTaskToFailed(taskRepo.WithoutScopes(excluded...))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (u *TaskLogService) CountExecutingTask() (int64, error) {
|
func (u *TaskLogService) CountExecutingTask() (int64, error) {
|
||||||
excluded, err := taskscope.ReadExclusions(u.grantedScopes...)
|
return taskRepo.CountExecutingTask()
|
||||||
if err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
return taskRepo.CountExecutingTask(taskRepo.WithoutScopes(excluded...))
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,100 +0,0 @@
|
|||||||
package service
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/1Panel-dev/1Panel/agent/app/model"
|
|
||||||
"github.com/1Panel-dev/1Panel/agent/utils/files"
|
|
||||||
"github.com/1Panel-dev/1Panel/agent/utils/nginx"
|
|
||||||
"github.com/1Panel-dev/1Panel/agent/utils/nginx/parser"
|
|
||||||
)
|
|
||||||
|
|
||||||
type websiteManagedConfigPolicy struct {
|
|
||||||
protectedDirectories func() []string
|
|
||||||
backupIncludes func(model.Website) []string
|
|
||||||
}
|
|
||||||
|
|
||||||
var websiteManagedConfigs websiteManagedConfigPolicy
|
|
||||||
|
|
||||||
func configureWebsiteManagedConfig(policy websiteManagedConfigPolicy) {
|
|
||||||
if !policy.configured() || websiteManagedConfigs.configured() {
|
|
||||||
panic("invalid website managed configuration policy")
|
|
||||||
}
|
|
||||||
websiteManagedConfigs = policy
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p websiteManagedConfigPolicy) configured() bool {
|
|
||||||
return p.protectedDirectories != nil && p.backupIncludes != nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p websiteManagedConfigPolicy) pathAllowed(target string) bool {
|
|
||||||
if !p.configured() {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
resolved, err := websiteProxyResolvePath(target)
|
|
||||||
if err != nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
for _, directory := range p.protectedDirectories() {
|
|
||||||
if directory == "" {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
root, err := websiteProxyResolvePath(directory)
|
|
||||||
if err != nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if relative, err := filepath.Rel(root, resolved); err != nil || relative != ".." && !strings.HasPrefix(relative, ".."+string(filepath.Separator)) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
func exportWebsiteNginxConfig(site model.Website, source, destination string) error {
|
|
||||||
return websiteManagedConfigs.exportNginxConfig(site, source, destination)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p websiteManagedConfigPolicy) exportNginxConfig(site model.Website, source, destination string) error {
|
|
||||||
if !p.configured() {
|
|
||||||
return errors.New("website managed configuration policy is unavailable")
|
|
||||||
}
|
|
||||||
includes := p.backupIncludes(site)
|
|
||||||
if len(includes) == 0 {
|
|
||||||
return files.NewFileOp().CopyFile(source, destination)
|
|
||||||
}
|
|
||||||
excluded := make(map[string]struct{}, len(includes))
|
|
||||||
for _, include := range includes {
|
|
||||||
if include == "" {
|
|
||||||
return errors.New("invalid managed website backup include")
|
|
||||||
}
|
|
||||||
excluded[include] = struct{}{}
|
|
||||||
}
|
|
||||||
content, err := os.ReadFile(source)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
config, err := parser.NewStringParser(string(content)).Parse()
|
|
||||||
if err != nil {
|
|
||||||
return errors.New("invalid managed website backup configuration")
|
|
||||||
}
|
|
||||||
changed := false
|
|
||||||
for _, server := range config.FindServers() {
|
|
||||||
for _, directive := range server.FindDirectives("include") {
|
|
||||||
parameters := directive.GetParameters()
|
|
||||||
if len(parameters) != 1 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if _, found := excluded[strings.Trim(parameters[0], "\"'")]; found {
|
|
||||||
server.RemoveDirective("include", parameters)
|
|
||||||
changed = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if changed {
|
|
||||||
content = []byte(nginx.DumpConfig(config, nginx.IndentedStyle))
|
|
||||||
}
|
|
||||||
return os.WriteFile(filepath.Join(destination, filepath.Base(source)), content, 0600)
|
|
||||||
}
|
|
||||||
@@ -4,9 +4,7 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
|
||||||
"path"
|
"path"
|
||||||
"path/filepath"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@@ -25,63 +23,6 @@ import (
|
|||||||
"github.com/1Panel-dev/1Panel/agent/utils/re"
|
"github.com/1Panel-dev/1Panel/agent/utils/re"
|
||||||
)
|
)
|
||||||
|
|
||||||
func websiteProxyPathAllowed(directory, name string) bool {
|
|
||||||
if name == "" || name == "." || name == ".." || strings.ContainsAny(name, "/\\\x00") {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
resolvedDirectory, err := websiteProxyResolvePath(directory)
|
|
||||||
if err != nil || !websiteManagedConfigs.pathAllowed(resolvedDirectory) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
for _, extension := range []string{".conf", ".bak"} {
|
|
||||||
filename := filepath.Join(directory, name+extension)
|
|
||||||
if info, statErr := os.Lstat(filename); statErr == nil && info.Mode()&os.ModeSymlink != 0 {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
resolved, resolveErr := websiteProxyResolvePath(filename)
|
|
||||||
if resolveErr != nil || !websiteProxyPathWithin(resolved, resolvedDirectory) || !websiteManagedConfigs.pathAllowed(resolved) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
func websiteProxyDirectoryAllowed(directory string) bool {
|
|
||||||
resolved, err := websiteProxyResolvePath(directory)
|
|
||||||
return err == nil && websiteManagedConfigs.pathAllowed(resolved)
|
|
||||||
}
|
|
||||||
|
|
||||||
func websiteProxyPathWithin(target, directory string) bool {
|
|
||||||
resolvedDirectory, err := websiteProxyResolvePath(directory)
|
|
||||||
if err != nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
relative, err := filepath.Rel(resolvedDirectory, target)
|
|
||||||
return err == nil && relative != ".." && !strings.HasPrefix(relative, ".."+string(filepath.Separator))
|
|
||||||
}
|
|
||||||
|
|
||||||
func websiteProxyResolvePath(target string) (string, error) {
|
|
||||||
absolute, err := filepath.Abs(target)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
resolved, err := filepath.EvalSymlinks(absolute)
|
|
||||||
if err == nil {
|
|
||||||
return resolved, nil
|
|
||||||
}
|
|
||||||
if !os.IsNotExist(err) || filepath.Dir(absolute) == absolute {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
if info, statErr := os.Lstat(absolute); statErr == nil && info.Mode()&os.ModeSymlink != 0 {
|
|
||||||
return "", errors.New("invalid proxy symlink")
|
|
||||||
}
|
|
||||||
parent, err := websiteProxyResolvePath(filepath.Dir(absolute))
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
return filepath.Join(parent, filepath.Base(absolute)), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (w WebsiteService) OperateProxy(req request.WebsiteProxyConfig) (err error) {
|
func (w WebsiteService) OperateProxy(req request.WebsiteProxyConfig) (err error) {
|
||||||
switch req.Operate {
|
switch req.Operate {
|
||||||
case "delete":
|
case "delete":
|
||||||
@@ -110,9 +51,6 @@ func (w WebsiteService) OperateProxy(req request.WebsiteProxyConfig) (err error)
|
|||||||
}
|
}
|
||||||
fileOp := files.NewFileOp()
|
fileOp := files.NewFileOp()
|
||||||
includeDir := GetSitePath(website, SiteProxyDir)
|
includeDir := GetSitePath(website, SiteProxyDir)
|
||||||
if !websiteProxyPathAllowed(includeDir, req.Name) {
|
|
||||||
return errors.New("invalid proxy path")
|
|
||||||
}
|
|
||||||
if !fileOp.Stat(includeDir) {
|
if !fileOp.Stat(includeDir) {
|
||||||
_ = fileOp.CreateDir(includeDir, constant.DirPerm)
|
_ = fileOp.CreateDir(includeDir, constant.DirPerm)
|
||||||
}
|
}
|
||||||
@@ -332,9 +270,6 @@ func (w WebsiteService) GetProxies(id uint) (res []request.WebsiteProxyConfig, e
|
|||||||
}
|
}
|
||||||
includeDir := GetSitePath(website, SiteProxyDir)
|
includeDir := GetSitePath(website, SiteProxyDir)
|
||||||
fileOp := files.NewFileOp()
|
fileOp := files.NewFileOp()
|
||||||
if !websiteProxyDirectoryAllowed(includeDir) {
|
|
||||||
return nil, errors.New("invalid proxy path")
|
|
||||||
}
|
|
||||||
if !fileOp.Stat(includeDir) {
|
if !fileOp.Stat(includeDir) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -347,16 +282,12 @@ func (w WebsiteService) GetProxies(id uint) (res []request.WebsiteProxyConfig, e
|
|||||||
config *components.Config
|
config *components.Config
|
||||||
)
|
)
|
||||||
for _, configFile := range fileList.Items {
|
for _, configFile := range fileList.Items {
|
||||||
extension := filepath.Ext(configFile.Name)
|
|
||||||
name := strings.TrimSuffix(configFile.Name, extension)
|
|
||||||
if configFile.IsDir || (extension != ".conf" && extension != ".bak") || !websiteProxyPathAllowed(includeDir, name) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
proxyConfig := request.WebsiteProxyConfig{
|
proxyConfig := request.WebsiteProxyConfig{
|
||||||
ID: website.ID,
|
ID: website.ID,
|
||||||
}
|
}
|
||||||
proxyConfig.Name = name
|
parts := strings.Split(configFile.Name, ".")
|
||||||
if extension == ".conf" {
|
proxyConfig.Name = parts[0]
|
||||||
|
if parts[1] == "conf" {
|
||||||
proxyConfig.Enable = true
|
proxyConfig.Enable = true
|
||||||
} else {
|
} else {
|
||||||
proxyConfig.Enable = false
|
proxyConfig.Enable = false
|
||||||
@@ -429,9 +360,6 @@ func (w WebsiteService) UpdateProxyFile(req request.NginxProxyUpdate) (err error
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if !websiteProxyPathAllowed(GetSitePath(website, SiteProxyDir), req.Name) {
|
|
||||||
return errors.New("invalid proxy path")
|
|
||||||
}
|
|
||||||
absolutePath := fmt.Sprintf("%s/%s.conf", GetSitePath(website, SiteProxyDir), req.Name)
|
absolutePath := fmt.Sprintf("%s/%s.conf", GetSitePath(website, SiteProxyDir), req.Name)
|
||||||
fileOp := files.NewFileOp()
|
fileOp := files.NewFileOp()
|
||||||
oldRewriteContent, err = fileOp.GetContent(absolutePath)
|
oldRewriteContent, err = fileOp.GetContent(absolutePath)
|
||||||
@@ -477,9 +405,6 @@ func (w WebsiteService) DeleteProxy(req request.WebsiteProxyDel) (err error) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
includeDir := GetSitePath(website, SiteProxyDir)
|
includeDir := GetSitePath(website, SiteProxyDir)
|
||||||
if !websiteProxyPathAllowed(includeDir, req.Name) {
|
|
||||||
return errors.New("invalid proxy path")
|
|
||||||
}
|
|
||||||
fileOp := files.NewFileOp()
|
fileOp := files.NewFileOp()
|
||||||
if !fileOp.Stat(includeDir) {
|
if !fileOp.Stat(includeDir) {
|
||||||
return
|
return
|
||||||
@@ -500,9 +425,6 @@ func (w WebsiteService) UpdateProxyStatus(req request.WebsiteProxyStatusUpdate)
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
includeDir := GetSitePath(website, SiteProxyDir)
|
includeDir := GetSitePath(website, SiteProxyDir)
|
||||||
if !websiteProxyPathAllowed(includeDir, req.Name) {
|
|
||||||
return errors.New("invalid proxy path")
|
|
||||||
}
|
|
||||||
fileOp := files.NewFileOp()
|
fileOp := files.NewFileOp()
|
||||||
if !fileOp.Stat(includeDir) {
|
if !fileOp.Stat(includeDir) {
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -292,36 +292,10 @@ func (t *Task) updateTask(task *model.Task) {
|
|||||||
_ = t.taskRepo.Update(context.Background(), task)
|
_ = t.taskRepo.Update(context.Background(), task)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ExecuteQueued registers the task before returning and limits concurrent execution.
|
|
||||||
// Subtasks must use Timeout = 0 and manage their own operation and recovery deadlines
|
|
||||||
// so that a slot is held until the action and its cleanup have actually returned.
|
|
||||||
func (t *Task) ExecuteQueued(slots chan struct{}, waitingStep string, onFinish func()) error {
|
|
||||||
t.Task.CurrentStep = waitingStep
|
|
||||||
if err := t.taskRepo.Save(context.Background(), t.Task); err != nil {
|
|
||||||
_ = t.logFile.Close()
|
|
||||||
global.RemoveTaskCancel(t.TaskID)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
t.Log(i18n.GetMsgByKey(waitingStep))
|
|
||||||
go func() {
|
|
||||||
slots <- struct{}{}
|
|
||||||
defer func() { <-slots }()
|
|
||||||
defer onFinish()
|
|
||||||
if err := t.executeRegistered(); err != nil {
|
|
||||||
global.LOG.Error(err.Error())
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (t *Task) Execute() error {
|
func (t *Task) Execute() error {
|
||||||
if err := t.taskRepo.Save(context.Background(), t.Task); err != nil {
|
if err := t.taskRepo.Save(context.Background(), t.Task); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return t.executeRegistered()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (t *Task) executeRegistered() error {
|
|
||||||
var err error
|
var err error
|
||||||
t.Log(i18n.GetWithName("TaskStart", t.Name))
|
t.Log(i18n.GetWithName("TaskStart", t.Name))
|
||||||
for i := 0; i < len(t.SubTasks); i++ {
|
for i := 0; i < len(t.SubTasks); i++ {
|
||||||
|
|||||||
@@ -53,6 +53,13 @@
|
|||||||
"formatZH": "添加 Ollama 模型重试 [name]",
|
"formatZH": "添加 Ollama 模型重试 [name]",
|
||||||
"formatEN": "re-add Ollama model [name]"
|
"formatEN": "re-add Ollama model [name]"
|
||||||
},
|
},
|
||||||
|
"/ai/ollama/model/sync": {
|
||||||
|
"bodyKeys": [],
|
||||||
|
"paramKeys": [],
|
||||||
|
"beforeFunctions": [],
|
||||||
|
"formatZH": "同步 Ollama 模型列表",
|
||||||
|
"formatEN": "sync Ollama model list"
|
||||||
|
},
|
||||||
"/ai/tensorrt/create": {
|
"/ai/tensorrt/create": {
|
||||||
"bodyKeys": [
|
"bodyKeys": [
|
||||||
"name"
|
"name"
|
||||||
|
|||||||
@@ -4,5 +4,3 @@ const (
|
|||||||
TaskInstall = "installApp"
|
TaskInstall = "installApp"
|
||||||
TaskCreateWebsite = "createWebsite"
|
TaskCreateWebsite = "createWebsite"
|
||||||
)
|
)
|
||||||
|
|
||||||
const TaskScopePanelMCP = "PanelMCP"
|
|
||||||
|
|||||||
+1
-1
@@ -7,7 +7,6 @@ replace github.com/go-acme/lego/v5 => github.com/1Panel-dev/lego/v5 v5.3.1
|
|||||||
require (
|
require (
|
||||||
github.com/aliyun/aliyun-oss-go-sdk v3.0.2+incompatible
|
github.com/aliyun/aliyun-oss-go-sdk v3.0.2+incompatible
|
||||||
github.com/compose-spec/compose-go/v2 v2.15.0
|
github.com/compose-spec/compose-go/v2 v2.15.0
|
||||||
github.com/containerd/errdefs v1.0.0
|
|
||||||
github.com/creack/pty v1.1.24
|
github.com/creack/pty v1.1.24
|
||||||
github.com/docker/cli v29.9.0+incompatible
|
github.com/docker/cli v29.9.0+incompatible
|
||||||
github.com/docker/docker v28.5.2+incompatible
|
github.com/docker/docker v28.5.2+incompatible
|
||||||
@@ -105,6 +104,7 @@ require (
|
|||||||
github.com/clbanning/mxj v1.8.4 // indirect
|
github.com/clbanning/mxj v1.8.4 // indirect
|
||||||
github.com/clbanning/mxj/v2 v2.7.0 // indirect
|
github.com/clbanning/mxj/v2 v2.7.0 // indirect
|
||||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||||
|
github.com/containerd/errdefs v1.0.0 // indirect
|
||||||
github.com/containerd/errdefs/pkg v0.3.0 // indirect
|
github.com/containerd/errdefs/pkg v0.3.0 // indirect
|
||||||
github.com/containerd/log v0.1.0 // indirect
|
github.com/containerd/log v0.1.0 // indirect
|
||||||
github.com/distribution/reference v0.6.0 // indirect
|
github.com/distribution/reference v0.6.0 // indirect
|
||||||
|
|||||||
@@ -777,28 +777,3 @@ VMSnapshotDeleteStep: "Delete virtual machine snapshot [{{ .snapshot }}]"
|
|||||||
ErrCAUpload: "Upload a ZIP (up to 10 MB) containing a valid self-signed CA certificate ca.crt and its matching private key ca.key, with a supported RSA or EC algorithm."
|
ErrCAUpload: "Upload a ZIP (up to 10 MB) containing a valid self-signed CA certificate ca.crt and its matching private key ca.key, with a supported RSA or EC algorithm."
|
||||||
|
|
||||||
ErrCAImport: "Provide a valid self-signed root CA certificate and matching private key (up to 1 MB each), using a supported RSA or EC algorithm."
|
ErrCAImport: "Provide a valid self-signed root CA certificate and matching private key (up to 1 MB each), using a supported RSA or EC algorithm."
|
||||||
|
|
||||||
# Built-in Feishu
|
|
||||||
ErrFeishuConfig: "Resolve incompatible configuration before continuing: {{ .detail }}"
|
|
||||||
ErrFeishuConfigChanged: "Configuration changed during switching. Retry after reloading."
|
|
||||||
ErrFeishuImageRequired: "Use an image containing bundled Feishu."
|
|
||||||
ErrFeishuAlreadySwitched: "Feishu has already switched to the built-in plugin."
|
|
||||||
ErrFeishuSwitchRequired: "Switch to the built-in plugin from the Feishu channel page first."
|
|
||||||
ErrFeishuManaged: "Manage Feishu from its channel page; the plugin is updated with the image."
|
|
||||||
ErrFeishuRestore: "Failed to restore Feishu configuration: {{ .err }}"
|
|
||||||
ErrFeishuActivation: "The built-in Feishu plugin or gateway failed to start."
|
|
||||||
AgentFeishuSwitch: "Switch Feishu plugin"
|
|
||||||
AgentFeishuBackup: "Configuration backup: {{ .name }}"
|
|
||||||
AgentFeishuRestored: "Previous configuration restored."
|
|
||||||
AgentFeishuProbeWarning: "Feishu connection check failed. Check the network and credentials; the switch has been retained."
|
|
||||||
AgentFeishuVerifyMessages: "Local activation completed. Verify pairing, DMs, groups, bot routing and streaming in Feishu."
|
|
||||||
|
|
||||||
ErrFeishuUpgradeConfirm: "Confirm the Feishu transition options on the upgrade page before retrying."
|
|
||||||
|
|
||||||
ErrFeishuUpgradeBackup: "A full application backup is required for the Feishu compatibility upgrade."
|
|
||||||
|
|
||||||
ErrFeishuUpgradeMounts: "These mounts cannot be fully backed up or restored. Use standard data mounts inside the application directory."
|
|
||||||
|
|
||||||
AgentFeishuRecoveryDisabled: "Gateway recovered with Feishu disabled; the original configuration backup is retained."
|
|
||||||
|
|
||||||
ContainerUpgradeWaiting: "Waiting for a container upgrade execution slot"
|
|
||||||
|
|||||||
@@ -777,28 +777,3 @@ VMSnapshotDeleteStep: "Eliminar instantánea de máquina virtual [{{ .snapshot }
|
|||||||
ErrCAUpload: "Upload a ZIP (up to 10 MB) containing a valid self-signed CA certificate ca.crt and its matching private key ca.key, with a supported RSA or EC algorithm."
|
ErrCAUpload: "Upload a ZIP (up to 10 MB) containing a valid self-signed CA certificate ca.crt and its matching private key ca.key, with a supported RSA or EC algorithm."
|
||||||
|
|
||||||
ErrCAImport: "Provide a valid self-signed root CA certificate and matching private key (up to 1 MB each), using a supported RSA or EC algorithm."
|
ErrCAImport: "Provide a valid self-signed root CA certificate and matching private key (up to 1 MB each), using a supported RSA or EC algorithm."
|
||||||
|
|
||||||
# Built-in Feishu
|
|
||||||
ErrFeishuConfig: "Corrija la configuración incompatible antes de continuar: {{ .detail }}"
|
|
||||||
ErrFeishuConfigChanged: "La configuración cambió durante la transición. Recargue y reintente."
|
|
||||||
ErrFeishuImageRequired: "Use una imagen que incluya Feishu."
|
|
||||||
ErrFeishuAlreadySwitched: "Feishu ya utiliza el complemento integrado."
|
|
||||||
ErrFeishuSwitchRequired: "Cambie primero al complemento integrado desde la página del canal Feishu."
|
|
||||||
ErrFeishuManaged: "Gestione Feishu desde su canal; el complemento se actualiza con la imagen."
|
|
||||||
ErrFeishuRestore: "Error al restaurar la configuración de Feishu: {{ .err }}"
|
|
||||||
ErrFeishuActivation: "No se pudo iniciar el complemento Feishu integrado o la puerta de enlace."
|
|
||||||
AgentFeishuSwitch: "Cambiar el complemento Feishu"
|
|
||||||
AgentFeishuBackup: "Copia de configuración: {{ .name }}"
|
|
||||||
AgentFeishuRestored: "Se restauró la configuración anterior."
|
|
||||||
AgentFeishuProbeWarning: "Falló la comprobación de conexión de Feishu. Revise la red y las credenciales; se conservó el cambio."
|
|
||||||
AgentFeishuVerifyMessages: "Activación local completada. Verifique el emparejamiento, chats, grupos, rutas de bots y transmisión en Feishu."
|
|
||||||
|
|
||||||
ErrFeishuUpgradeConfirm: "Confirme las opciones de transición de Feishu en la página de actualización."
|
|
||||||
|
|
||||||
ErrFeishuUpgradeBackup: "La actualización de compatibilidad de Feishu requiere una copia completa de la aplicación."
|
|
||||||
|
|
||||||
ErrFeishuUpgradeMounts: "Estos montajes no permiten una copia y restauración completas. Use los montajes de datos estándar dentro del directorio de la aplicación."
|
|
||||||
|
|
||||||
AgentFeishuRecoveryDisabled: "Puerta de enlace recuperada con Feishu desactivado; se conserva la copia de configuración original."
|
|
||||||
|
|
||||||
ContainerUpgradeWaiting: "Waiting for a container upgrade execution slot"
|
|
||||||
|
|||||||
@@ -778,28 +778,3 @@ VMSnapshotDeleteStep: "حذف اسنپشات ماشین مجازی [{{ .snaps
|
|||||||
ErrCAUpload: "Upload a ZIP (up to 10 MB) containing a valid self-signed CA certificate ca.crt and its matching private key ca.key, with a supported RSA or EC algorithm."
|
ErrCAUpload: "Upload a ZIP (up to 10 MB) containing a valid self-signed CA certificate ca.crt and its matching private key ca.key, with a supported RSA or EC algorithm."
|
||||||
|
|
||||||
ErrCAImport: "Provide a valid self-signed root CA certificate and matching private key (up to 1 MB each), using a supported RSA or EC algorithm."
|
ErrCAImport: "Provide a valid self-signed root CA certificate and matching private key (up to 1 MB each), using a supported RSA or EC algorithm."
|
||||||
|
|
||||||
# Built-in Feishu
|
|
||||||
ErrFeishuConfig: "پیش از ادامه تنظیمات ناسازگار را اصلاح کنید: {{ .detail }}"
|
|
||||||
ErrFeishuConfigChanged: "تنظیمات هنگام تغییر عوض شد. صفحه را تازه و دوباره تلاش کنید."
|
|
||||||
ErrFeishuImageRequired: "از تصویری با Feishu داخلی استفاده کنید."
|
|
||||||
ErrFeishuAlreadySwitched: "Feishu قبلاً به افزونه داخلی تغییر کرده است."
|
|
||||||
ErrFeishuSwitchRequired: "ابتدا در صفحه کانال Feishu به افزونه داخلی تغییر دهید."
|
|
||||||
ErrFeishuManaged: "Feishu را از صفحه کانال مدیریت کنید؛ افزونه همراه تصویر بهروز میشود."
|
|
||||||
ErrFeishuRestore: "بازیابی تنظیمات Feishu ناموفق بود: {{ .err }}"
|
|
||||||
ErrFeishuActivation: "راهاندازی افزونه داخلی Feishu یا دروازه ناموفق بود."
|
|
||||||
AgentFeishuSwitch: "تغییر افزونه Feishu"
|
|
||||||
AgentFeishuBackup: "پشتیبان تنظیمات: {{ .name }}"
|
|
||||||
AgentFeishuRestored: "تنظیمات قبلی بازیابی شد."
|
|
||||||
AgentFeishuProbeWarning: "بررسی اتصال Feishu ناموفق بود. شبکه و اطلاعات ورود را بررسی کنید؛ تغییر حفظ شد."
|
|
||||||
AgentFeishuVerifyMessages: "فعالسازی محلی انجام شد. جفتسازی، پیام خصوصی و گروهی، مسیریابی ربات و پاسخ جریانی را در Feishu بررسی کنید."
|
|
||||||
|
|
||||||
ErrFeishuUpgradeConfirm: "گزینههای انتقال Feishu را در صفحه ارتقا تأیید کنید."
|
|
||||||
|
|
||||||
ErrFeishuUpgradeBackup: "ارتقای سازگاری Feishu به پشتیبان کامل برنامه نیاز دارد."
|
|
||||||
|
|
||||||
ErrFeishuUpgradeMounts: "این اتصالهای ذخیرهسازی کاملاً قابل پشتیبانگیری و بازیابی نیستند. از مسیرهای استاندارد داده در پوشه برنامه استفاده کنید."
|
|
||||||
|
|
||||||
AgentFeishuRecoveryDisabled: "دروازه بازیابی شد و Feishu غیرفعال است؛ پشتیبان تنظیمات اصلی حفظ شد."
|
|
||||||
|
|
||||||
ContainerUpgradeWaiting: "Waiting for a container upgrade execution slot"
|
|
||||||
|
|||||||
@@ -777,28 +777,3 @@ VMSnapshotDeleteStep: "仮想マシンのスナップショット [{{ .snapshot
|
|||||||
ErrCAUpload: "Upload a ZIP (up to 10 MB) containing a valid self-signed CA certificate ca.crt and its matching private key ca.key, with a supported RSA or EC algorithm."
|
ErrCAUpload: "Upload a ZIP (up to 10 MB) containing a valid self-signed CA certificate ca.crt and its matching private key ca.key, with a supported RSA or EC algorithm."
|
||||||
|
|
||||||
ErrCAImport: "Provide a valid self-signed root CA certificate and matching private key (up to 1 MB each), using a supported RSA or EC algorithm."
|
ErrCAImport: "Provide a valid self-signed root CA certificate and matching private key (up to 1 MB each), using a supported RSA or EC algorithm."
|
||||||
|
|
||||||
# Built-in Feishu
|
|
||||||
ErrFeishuConfig: "続行前に互換性のない設定を修正してください:{{ .detail }}"
|
|
||||||
ErrFeishuConfigChanged: "切り替え中に設定が変更されました。再読み込みして再試行してください。"
|
|
||||||
ErrFeishuImageRequired: "Feishu を内蔵したイメージを使用してください。"
|
|
||||||
ErrFeishuAlreadySwitched: "Feishu は組み込みプラグインに切り替え済みです。"
|
|
||||||
ErrFeishuSwitchRequired: "先に Feishu チャネル画面で組み込みプラグインに切り替えてください。"
|
|
||||||
ErrFeishuManaged: "Feishu はチャネル画面で管理してください。プラグインはイメージとともに更新されます。"
|
|
||||||
ErrFeishuRestore: "Feishu 設定の復元に失敗しました:{{ .err }}"
|
|
||||||
ErrFeishuActivation: "組み込み Feishu プラグインまたはゲートウェイを起動できませんでした。"
|
|
||||||
AgentFeishuSwitch: "Feishu プラグインの切り替え"
|
|
||||||
AgentFeishuBackup: "設定のバックアップ:{{ .name }}"
|
|
||||||
AgentFeishuRestored: "以前の設定を復元しました。"
|
|
||||||
AgentFeishuProbeWarning: "Feishu の接続確認に失敗しました。ネットワークと認証情報を確認してください。切り替え結果は保持しています。"
|
|
||||||
AgentFeishuVerifyMessages: "ローカルの有効化が完了しました。Feishu でペアリング、個別・グループチャット、ボットルーティング、ストリーミングを確認してください。"
|
|
||||||
|
|
||||||
ErrFeishuUpgradeConfirm: "更新画面で Feishu の切り替え設定を確認してください。"
|
|
||||||
|
|
||||||
ErrFeishuUpgradeBackup: "Feishu 互換更新にはアプリ全体のバックアップが必要です。"
|
|
||||||
|
|
||||||
ErrFeishuUpgradeMounts: "現在のマウントは完全なバックアップ・復元に対応しません。アプリディレクトリ内の標準データマウントを使用してください。"
|
|
||||||
|
|
||||||
AgentFeishuRecoveryDisabled: "ゲートウェイを復旧しました。Feishu は無効のままで、元の設定のバックアップは保持しています。"
|
|
||||||
|
|
||||||
ContainerUpgradeWaiting: "Waiting for a container upgrade execution slot"
|
|
||||||
|
|||||||
@@ -777,28 +777,3 @@ VMSnapshotDeleteStep: "가상 머신 스냅샷 [{{ .snapshot }}] 삭제"
|
|||||||
ErrCAUpload: "Upload a ZIP (up to 10 MB) containing a valid self-signed CA certificate ca.crt and its matching private key ca.key, with a supported RSA or EC algorithm."
|
ErrCAUpload: "Upload a ZIP (up to 10 MB) containing a valid self-signed CA certificate ca.crt and its matching private key ca.key, with a supported RSA or EC algorithm."
|
||||||
|
|
||||||
ErrCAImport: "Provide a valid self-signed root CA certificate and matching private key (up to 1 MB each), using a supported RSA or EC algorithm."
|
ErrCAImport: "Provide a valid self-signed root CA certificate and matching private key (up to 1 MB each), using a supported RSA or EC algorithm."
|
||||||
|
|
||||||
# Built-in Feishu
|
|
||||||
ErrFeishuConfig: "계속하기 전에 호환되지 않는 설정을 수정하세요: {{ .detail }}"
|
|
||||||
ErrFeishuConfigChanged: "전환 중 설정이 변경되었습니다. 새로고침 후 다시 시도하세요."
|
|
||||||
ErrFeishuImageRequired: "Feishu가 내장된 이미지를 사용하세요."
|
|
||||||
ErrFeishuAlreadySwitched: "이미 내장 Feishu 플러그인으로 전환했습니다."
|
|
||||||
ErrFeishuSwitchRequired: "먼저 Feishu 채널 페이지에서 내장 플러그인으로 전환하세요."
|
|
||||||
ErrFeishuManaged: "Feishu 채널 페이지에서 관리하세요. 플러그인은 이미지와 함께 업데이트됩니다."
|
|
||||||
ErrFeishuRestore: "Feishu 설정 복구 실패: {{ .err }}"
|
|
||||||
ErrFeishuActivation: "내장 Feishu 플러그인 또는 게이트웨이 시작에 실패했습니다."
|
|
||||||
AgentFeishuSwitch: "Feishu 플러그인 전환"
|
|
||||||
AgentFeishuBackup: "설정 백업: {{ .name }}"
|
|
||||||
AgentFeishuRestored: "이전 설정을 복구했습니다."
|
|
||||||
AgentFeishuProbeWarning: "Feishu 연결 확인에 실패했습니다. 네트워크와 자격 증명을 확인하세요. 전환 결과는 유지됩니다."
|
|
||||||
AgentFeishuVerifyMessages: "로컬 활성화가 완료되었습니다. Feishu에서 페어링, 개인 및 그룹 채팅, 봇 라우팅과 스트리밍을 확인하세요."
|
|
||||||
|
|
||||||
ErrFeishuUpgradeConfirm: "업그레이드 화면에서 Feishu 전환 옵션을 확인하세요."
|
|
||||||
|
|
||||||
ErrFeishuUpgradeBackup: "Feishu 호환 업그레이드에는 전체 앱 백업이 필요합니다."
|
|
||||||
|
|
||||||
ErrFeishuUpgradeMounts: "현재 마운트는 전체 백업 및 복원을 지원하지 않습니다. 앱 디렉터리 내부의 표준 데이터 마운트를 사용하세요."
|
|
||||||
|
|
||||||
AgentFeishuRecoveryDisabled: "게이트웨이가 복구되었습니다. Feishu는 비활성 상태이며 원본 설정 백업은 유지됩니다."
|
|
||||||
|
|
||||||
ContainerUpgradeWaiting: "Waiting for a container upgrade execution slot"
|
|
||||||
|
|||||||
@@ -768,28 +768,3 @@ VMSnapshotDeleteStep: "ລຶບສະແນັບຊັອດເຄື່ອງ
|
|||||||
ErrCAUpload: "Upload a ZIP (up to 10 MB) containing a valid self-signed CA certificate ca.crt and its matching private key ca.key, with a supported RSA or EC algorithm."
|
ErrCAUpload: "Upload a ZIP (up to 10 MB) containing a valid self-signed CA certificate ca.crt and its matching private key ca.key, with a supported RSA or EC algorithm."
|
||||||
|
|
||||||
ErrCAImport: "Provide a valid self-signed root CA certificate and matching private key (up to 1 MB each), using a supported RSA or EC algorithm."
|
ErrCAImport: "Provide a valid self-signed root CA certificate and matching private key (up to 1 MB each), using a supported RSA or EC algorithm."
|
||||||
|
|
||||||
# Built-in Feishu
|
|
||||||
ErrFeishuConfig: "ກະລຸນາແກ້ໄຂການຕັ້ງຄ່າທີ່ບໍ່ເຂົ້າກັນກ່ອນ: {{ .detail }}"
|
|
||||||
ErrFeishuConfigChanged: "ການຕັ້ງຄ່າປ່ຽນໃນລະຫວ່າງການສະຫຼັບ. ໂຫຼດໃໝ່ແລ້ວລອງອີກ."
|
|
||||||
ErrFeishuImageRequired: "ໃຊ້ອິມເມຈທີ່ມີ Feishu ໃນຕົວ."
|
|
||||||
ErrFeishuAlreadySwitched: "Feishu ໄດ້ສະຫຼັບເປັນປລັກອິນໃນຕົວແລ້ວ."
|
|
||||||
ErrFeishuSwitchRequired: "ກະລຸນາສະຫຼັບໃນໜ້າຊ່ອງ Feishu ກ່ອນ."
|
|
||||||
ErrFeishuManaged: "ຈັດການ Feishu ໃນໜ້າຊ່ອງ; ປລັກອິນອັບເດດພ້ອມອິມເມຈ."
|
|
||||||
ErrFeishuRestore: "ກູ້ຄືນການຕັ້ງຄ່າ Feishu ບໍ່ສຳເລັດ: {{ .err }}"
|
|
||||||
ErrFeishuActivation: "ປລັກອິນ Feishu ໃນຕົວ ຫຼື ເກດເວເລີ່ມບໍ່ສຳເລັດ."
|
|
||||||
AgentFeishuSwitch: "ສະຫຼັບປລັກອິນ Feishu"
|
|
||||||
AgentFeishuBackup: "ສຳຮອງການຕັ້ງຄ່າ: {{ .name }}"
|
|
||||||
AgentFeishuRestored: "ກູ້ຄືນການຕັ້ງຄ່າເດີມແລ້ວ."
|
|
||||||
AgentFeishuProbeWarning: "ກວດການເຊື່ອມຕໍ່ Feishu ບໍ່ຜ່ານ. ກວດເຄືອຂ່າຍ ແລະ ຂໍ້ມູນເຂົ້າໃຊ້; ຮັກສາຜົນການສະຫຼັບໄວ້."
|
|
||||||
AgentFeishuVerifyMessages: "ເປີດໃຊ້ພາຍໃນສຳເລັດ. ກວດການຈັບຄູ່, ແຊັດສ່ວນຕົວ, ກຸ່ມ, ເສັ້ນທາງບອດ ແລະ ສະຕຣີມໃນ Feishu."
|
|
||||||
|
|
||||||
ErrFeishuUpgradeConfirm: "ຢືນຢັນຕົວເລືອກການປ່ຽນ Feishu ໃນໜ້າອັບເກຣດ."
|
|
||||||
|
|
||||||
ErrFeishuUpgradeBackup: "ການອັບເກຣດ Feishu ຕ້ອງສຳຮອງແອັບທັງໝົດ."
|
|
||||||
|
|
||||||
ErrFeishuUpgradeMounts: "ຈຸດເຊື່ອມຂໍ້ມູນນີ້ບໍ່ສາມາດສຳຮອງແລະກູ້ຄືນທັງໝົດໄດ້. ໃຊ້ຈຸດເຊື່ອມມາດຕະຖານພາຍໃນໂຟນເດີແອັບ."
|
|
||||||
|
|
||||||
AgentFeishuRecoveryDisabled: "ເກດເວກູ້ຄືນແລ້ວ ແຕ່ Feishu ຍັງປິດ; ສຳຮອງການຕັ້ງຄ່າເດີມຍັງຖືກເກັບໄວ້."
|
|
||||||
|
|
||||||
ContainerUpgradeWaiting: "Waiting for a container upgrade execution slot"
|
|
||||||
|
|||||||
@@ -777,28 +777,3 @@ VMSnapshotDeleteStep: "Padam syot kilat mesin maya [{{ .snapshot }}]"
|
|||||||
ErrCAUpload: "Upload a ZIP (up to 10 MB) containing a valid self-signed CA certificate ca.crt and its matching private key ca.key, with a supported RSA or EC algorithm."
|
ErrCAUpload: "Upload a ZIP (up to 10 MB) containing a valid self-signed CA certificate ca.crt and its matching private key ca.key, with a supported RSA or EC algorithm."
|
||||||
|
|
||||||
ErrCAImport: "Provide a valid self-signed root CA certificate and matching private key (up to 1 MB each), using a supported RSA or EC algorithm."
|
ErrCAImport: "Provide a valid self-signed root CA certificate and matching private key (up to 1 MB each), using a supported RSA or EC algorithm."
|
||||||
|
|
||||||
# Built-in Feishu
|
|
||||||
ErrFeishuConfig: "Betulkan konfigurasi tidak serasi sebelum meneruskan: {{ .detail }}"
|
|
||||||
ErrFeishuConfigChanged: "Konfigurasi berubah semasa pertukaran. Muat semula dan cuba lagi."
|
|
||||||
ErrFeishuImageRequired: "Gunakan imej yang mengandungi Feishu terbina dalam."
|
|
||||||
ErrFeishuAlreadySwitched: "Feishu sudah menggunakan pemalam terbina dalam."
|
|
||||||
ErrFeishuSwitchRequired: "Tukar dahulu kepada pemalam terbina dalam pada halaman saluran Feishu."
|
|
||||||
ErrFeishuManaged: "Urus Feishu pada halaman salurannya; pemalam dikemas kini bersama imej."
|
|
||||||
ErrFeishuRestore: "Gagal memulihkan konfigurasi Feishu: {{ .err }}"
|
|
||||||
ErrFeishuActivation: "Pemalam Feishu terbina dalam atau gerbang gagal dimulakan."
|
|
||||||
AgentFeishuSwitch: "Tukar pemalam Feishu"
|
|
||||||
AgentFeishuBackup: "Sandaran konfigurasi: {{ .name }}"
|
|
||||||
AgentFeishuRestored: "Konfigurasi sebelumnya dipulihkan."
|
|
||||||
AgentFeishuProbeWarning: "Semakan sambungan Feishu gagal. Semak rangkaian dan kelayakan; pertukaran dikekalkan."
|
|
||||||
AgentFeishuVerifyMessages: "Pengaktifan setempat selesai. Sahkan pasangan, mesej peribadi, kumpulan, penghalaan bot dan penstriman dalam Feishu."
|
|
||||||
|
|
||||||
ErrFeishuUpgradeConfirm: "Sahkan pilihan peralihan Feishu pada halaman naik taraf."
|
|
||||||
|
|
||||||
ErrFeishuUpgradeBackup: "Naik taraf keserasian Feishu memerlukan sandaran aplikasi penuh."
|
|
||||||
|
|
||||||
ErrFeishuUpgradeMounts: "Lekapan ini tidak boleh disandar dan dipulihkan sepenuhnya. Gunakan lekapan data standard dalam direktori aplikasi."
|
|
||||||
|
|
||||||
AgentFeishuRecoveryDisabled: "Gerbang dipulihkan dengan Feishu dinyahaktifkan; sandaran konfigurasi asal dikekalkan."
|
|
||||||
|
|
||||||
ContainerUpgradeWaiting: "Waiting for a container upgrade execution slot"
|
|
||||||
|
|||||||
@@ -777,28 +777,3 @@ VMSnapshotDeleteStep: "Excluir snapshot da máquina virtual [{{ .snapshot }}]"
|
|||||||
ErrCAUpload: "Upload a ZIP (up to 10 MB) containing a valid self-signed CA certificate ca.crt and its matching private key ca.key, with a supported RSA or EC algorithm."
|
ErrCAUpload: "Upload a ZIP (up to 10 MB) containing a valid self-signed CA certificate ca.crt and its matching private key ca.key, with a supported RSA or EC algorithm."
|
||||||
|
|
||||||
ErrCAImport: "Provide a valid self-signed root CA certificate and matching private key (up to 1 MB each), using a supported RSA or EC algorithm."
|
ErrCAImport: "Provide a valid self-signed root CA certificate and matching private key (up to 1 MB each), using a supported RSA or EC algorithm."
|
||||||
|
|
||||||
# Built-in Feishu
|
|
||||||
ErrFeishuConfig: "Corrija a configuração incompatível antes de continuar: {{ .detail }}"
|
|
||||||
ErrFeishuConfigChanged: "A configuração mudou durante a troca. Recarregue e tente novamente."
|
|
||||||
ErrFeishuImageRequired: "Use uma imagem com Feishu integrado."
|
|
||||||
ErrFeishuAlreadySwitched: "O Feishu já usa o plugin integrado."
|
|
||||||
ErrFeishuSwitchRequired: "Primeiro troque para o plugin integrado na página do canal Feishu."
|
|
||||||
ErrFeishuManaged: "Gerencie o Feishu pela página do canal; o plugin é atualizado com a imagem."
|
|
||||||
ErrFeishuRestore: "Falha ao restaurar a configuração do Feishu: {{ .err }}"
|
|
||||||
ErrFeishuActivation: "Falha ao iniciar o plugin Feishu integrado ou o gateway."
|
|
||||||
AgentFeishuSwitch: "Trocar plugin Feishu"
|
|
||||||
AgentFeishuBackup: "Backup da configuração: {{ .name }}"
|
|
||||||
AgentFeishuRestored: "Configuração anterior restaurada."
|
|
||||||
AgentFeishuProbeWarning: "Falha na verificação de conexão do Feishu. Verifique a rede e as credenciais; a troca foi mantida."
|
|
||||||
AgentFeishuVerifyMessages: "Ativação local concluída. Verifique pareamento, conversas, grupos, rotas de bots e streaming no Feishu."
|
|
||||||
|
|
||||||
ErrFeishuUpgradeConfirm: "Confirme as opções de transição do Feishu na página de atualização."
|
|
||||||
|
|
||||||
ErrFeishuUpgradeBackup: "A atualização de compatibilidade do Feishu exige backup completo do aplicativo."
|
|
||||||
|
|
||||||
ErrFeishuUpgradeMounts: "Estas montagens não permitem backup e restauração completos. Use montagens de dados padrão dentro do diretório do aplicativo."
|
|
||||||
|
|
||||||
AgentFeishuRecoveryDisabled: "Gateway recuperado com Feishu desativado; o backup da configuração original foi mantido."
|
|
||||||
|
|
||||||
ContainerUpgradeWaiting: "Waiting for a container upgrade execution slot"
|
|
||||||
|
|||||||
@@ -777,28 +777,3 @@ VMSnapshotDeleteStep: "Удалить снимок виртуальной маш
|
|||||||
ErrCAUpload: "Upload a ZIP (up to 10 MB) containing a valid self-signed CA certificate ca.crt and its matching private key ca.key, with a supported RSA or EC algorithm."
|
ErrCAUpload: "Upload a ZIP (up to 10 MB) containing a valid self-signed CA certificate ca.crt and its matching private key ca.key, with a supported RSA or EC algorithm."
|
||||||
|
|
||||||
ErrCAImport: "Provide a valid self-signed root CA certificate and matching private key (up to 1 MB each), using a supported RSA or EC algorithm."
|
ErrCAImport: "Provide a valid self-signed root CA certificate and matching private key (up to 1 MB each), using a supported RSA or EC algorithm."
|
||||||
|
|
||||||
# Built-in Feishu
|
|
||||||
ErrFeishuConfig: "Исправьте несовместимые настройки перед продолжением: {{ .detail }}"
|
|
||||||
ErrFeishuConfigChanged: "Настройки изменились во время переключения. Обновите страницу и повторите."
|
|
||||||
ErrFeishuImageRequired: "Используйте образ со встроенным Feishu."
|
|
||||||
ErrFeishuAlreadySwitched: "Feishu уже переключён на встроенный плагин."
|
|
||||||
ErrFeishuSwitchRequired: "Сначала перейдите на встроенный плагин на странице канала Feishu."
|
|
||||||
ErrFeishuManaged: "Управляйте Feishu на странице канала; плагин обновляется с образом."
|
|
||||||
ErrFeishuRestore: "Не удалось восстановить настройки Feishu: {{ .err }}"
|
|
||||||
ErrFeishuActivation: "Не удалось запустить встроенный плагин Feishu или шлюз."
|
|
||||||
AgentFeishuSwitch: "Переключить плагин Feishu"
|
|
||||||
AgentFeishuBackup: "Резервная копия настроек: {{ .name }}"
|
|
||||||
AgentFeishuRestored: "Прежние настройки восстановлены."
|
|
||||||
AgentFeishuProbeWarning: "Проверка подключения Feishu не пройдена. Проверьте сеть и учётные данные; переключение сохранено."
|
|
||||||
AgentFeishuVerifyMessages: "Локальная активация завершена. Проверьте сопряжение, личные и групповые сообщения, маршрутизацию ботов и потоковые ответы в Feishu."
|
|
||||||
|
|
||||||
ErrFeishuUpgradeConfirm: "Подтвердите параметры перехода Feishu на странице обновления."
|
|
||||||
|
|
||||||
ErrFeishuUpgradeBackup: "Для обновления совместимости Feishu нужна полная резервная копия приложения."
|
|
||||||
|
|
||||||
ErrFeishuUpgradeMounts: "Эти монтирования не поддерживают полное резервное копирование и восстановление. Используйте стандартные данные внутри каталога приложения."
|
|
||||||
|
|
||||||
AgentFeishuRecoveryDisabled: "Шлюз восстановлен, Feishu отключён; исходная резервная копия конфигурации сохранена."
|
|
||||||
|
|
||||||
ContainerUpgradeWaiting: "Waiting for a container upgrade execution slot"
|
|
||||||
|
|||||||
@@ -777,28 +777,3 @@ VMSnapshotDeleteStep: "Sanal makine anlık görüntüsü [{{ .snapshot }}] sil"
|
|||||||
ErrCAUpload: "Upload a ZIP (up to 10 MB) containing a valid self-signed CA certificate ca.crt and its matching private key ca.key, with a supported RSA or EC algorithm."
|
ErrCAUpload: "Upload a ZIP (up to 10 MB) containing a valid self-signed CA certificate ca.crt and its matching private key ca.key, with a supported RSA or EC algorithm."
|
||||||
|
|
||||||
ErrCAImport: "Provide a valid self-signed root CA certificate and matching private key (up to 1 MB each), using a supported RSA or EC algorithm."
|
ErrCAImport: "Provide a valid self-signed root CA certificate and matching private key (up to 1 MB each), using a supported RSA or EC algorithm."
|
||||||
|
|
||||||
# Built-in Feishu
|
|
||||||
ErrFeishuConfig: "Devam etmeden önce uyumsuz ayarları düzeltin: {{ .detail }}"
|
|
||||||
ErrFeishuConfigChanged: "Geçiş sırasında ayarlar değişti. Yenileyip tekrar deneyin."
|
|
||||||
ErrFeishuImageRequired: "Yerleşik Feishu içeren bir imaj kullanın."
|
|
||||||
ErrFeishuAlreadySwitched: "Feishu zaten yerleşik eklentiye geçti."
|
|
||||||
ErrFeishuSwitchRequired: "Önce Feishu kanal sayfasından yerleşik eklentiye geçin."
|
|
||||||
ErrFeishuManaged: "Feishu kanal sayfasından yönetin; eklenti imajla güncellenir."
|
|
||||||
ErrFeishuRestore: "Feishu ayarları geri yüklenemedi: {{ .err }}"
|
|
||||||
ErrFeishuActivation: "Yerleşik Feishu eklentisi veya ağ geçidi başlatılamadı."
|
|
||||||
AgentFeishuSwitch: "Feishu eklentisini değiştir"
|
|
||||||
AgentFeishuBackup: "Ayar yedeği: {{ .name }}"
|
|
||||||
AgentFeishuRestored: "Önceki ayarlar geri yüklendi."
|
|
||||||
AgentFeishuProbeWarning: "Feishu bağlantı kontrolü başarısız. Ağı ve kimlik bilgilerini kontrol edin; geçiş korundu."
|
|
||||||
AgentFeishuVerifyMessages: "Yerel etkinleştirme tamamlandı. Feishu üzerinden eşleştirmeyi, özel ve grup mesajlarını, bot yönlendirmesini ve akışı doğrulayın."
|
|
||||||
|
|
||||||
ErrFeishuUpgradeConfirm: "Yükseltme sayfasında Feishu geçiş seçeneklerini onaylayın."
|
|
||||||
|
|
||||||
ErrFeishuUpgradeBackup: "Feishu uyumluluk yükseltmesi tam uygulama yedeği gerektirir."
|
|
||||||
|
|
||||||
ErrFeishuUpgradeMounts: "Bu bağlamalar tam yedeklenip geri yüklenemiyor. Uygulama dizinindeki standart veri bağlamalarını kullanın."
|
|
||||||
|
|
||||||
AgentFeishuRecoveryDisabled: "Ağ geçidi kurtarıldı, Feishu kapalı; özgün yapılandırma yedeği korundu."
|
|
||||||
|
|
||||||
ContainerUpgradeWaiting: "Waiting for a container upgrade execution slot"
|
|
||||||
|
|||||||
@@ -777,28 +777,3 @@ VMSnapshotDeleteStep: "刪除虛擬機快照 [{{ .snapshot }}]"
|
|||||||
ErrCAUpload: "請上傳包含有效自簽根憑證 ca.crt 及匹配私鑰 ca.key 的 ZIP 檔案(最大 10 MB),金鑰演算法須為支援的 RSA 或 EC。"
|
ErrCAUpload: "請上傳包含有效自簽根憑證 ca.crt 及匹配私鑰 ca.key 的 ZIP 檔案(最大 10 MB),金鑰演算法須為支援的 RSA 或 EC。"
|
||||||
|
|
||||||
ErrCAImport: "請提供有效的自簽根憑證及匹配私鑰(每個最大 1 MB),金鑰演算法須為支援的 RSA 或 EC。"
|
ErrCAImport: "請提供有效的自簽根憑證及匹配私鑰(每個最大 1 MB),金鑰演算法須為支援的 RSA 或 EC。"
|
||||||
|
|
||||||
# Built-in Feishu
|
|
||||||
ErrFeishuConfig: "請先處理不相容的設定:{{ .detail }}"
|
|
||||||
ErrFeishuConfigChanged: "切換期間設定已變更,請重新整理後重試。"
|
|
||||||
ErrFeishuImageRequired: "請使用包含內建飛書外掛的映像。"
|
|
||||||
ErrFeishuAlreadySwitched: "飛書已切換至內建外掛。"
|
|
||||||
ErrFeishuSwitchRequired: "請先在飛書管道頁面切換至內建外掛。"
|
|
||||||
ErrFeishuManaged: "請在飛書管道頁面管理,外掛隨映像更新。"
|
|
||||||
ErrFeishuRestore: "還原飛書設定失敗:{{ .err }}"
|
|
||||||
ErrFeishuActivation: "內建飛書外掛或閘道啟動失敗。"
|
|
||||||
AgentFeishuSwitch: "切換飛書外掛"
|
|
||||||
AgentFeishuBackup: "設定備份:{{ .name }}"
|
|
||||||
AgentFeishuRestored: "已還原切換前的設定。"
|
|
||||||
AgentFeishuProbeWarning: "飛書連線檢查未通過,請檢查網路和憑證;已保留切換結果。"
|
|
||||||
AgentFeishuVerifyMessages: "本機啟用完成,請在飛書驗證配對、私聊、群聊、機器人路由和串流回覆。"
|
|
||||||
|
|
||||||
ErrFeishuUpgradeConfirm: "請在升級頁面確認飛書切換選項後重試。"
|
|
||||||
|
|
||||||
ErrFeishuUpgradeBackup: "飛書相容升級必須開啟完整應用程式備份。"
|
|
||||||
|
|
||||||
ErrFeishuUpgradeMounts: "目前掛載無法完整備份或還原,請使用應用程式目錄內的標準資料掛載。"
|
|
||||||
|
|
||||||
AgentFeishuRecoveryDisabled: "閘道已恢復,飛書保持停用;原設定備份已保留。"
|
|
||||||
|
|
||||||
ContainerUpgradeWaiting: "等待容器升級任務執行名額"
|
|
||||||
|
|||||||
@@ -777,28 +777,3 @@ VMSnapshotDeleteStep: "删除虚拟机快照 [{{ .snapshot }}]"
|
|||||||
ErrCAUpload: "请上传包含有效自签根证书 ca.crt 及匹配私钥 ca.key 的 ZIP 文件(最大 10 MB),密钥算法须为支持的 RSA 或 EC。"
|
ErrCAUpload: "请上传包含有效自签根证书 ca.crt 及匹配私钥 ca.key 的 ZIP 文件(最大 10 MB),密钥算法须为支持的 RSA 或 EC。"
|
||||||
|
|
||||||
ErrCAImport: "请提供有效的自签根证书及匹配私钥(每个最大 1 MB),密钥算法须为支持的 RSA 或 EC。"
|
ErrCAImport: "请提供有效的自签根证书及匹配私钥(每个最大 1 MB),密钥算法须为支持的 RSA 或 EC。"
|
||||||
|
|
||||||
# Built-in Feishu
|
|
||||||
ErrFeishuConfig: "请先处理不兼容的配置:{{ .detail }}"
|
|
||||||
ErrFeishuConfigChanged: "切换期间配置已改变,请刷新后重试。"
|
|
||||||
ErrFeishuImageRequired: "请使用包含内置飞书插件的镜像。"
|
|
||||||
ErrFeishuAlreadySwitched: "飞书已切换到内置插件。"
|
|
||||||
ErrFeishuSwitchRequired: "请先在飞书渠道页面切换到内置插件。"
|
|
||||||
ErrFeishuManaged: "请在飞书渠道页面管理,插件随镜像更新。"
|
|
||||||
ErrFeishuRestore: "恢复飞书配置失败:{{ .err }}"
|
|
||||||
ErrFeishuActivation: "内置飞书插件或网关启动失败。"
|
|
||||||
AgentFeishuSwitch: "切换飞书插件"
|
|
||||||
AgentFeishuBackup: "配置备份:{{ .name }}"
|
|
||||||
AgentFeishuRestored: "已恢复切换前的配置。"
|
|
||||||
AgentFeishuProbeWarning: "飞书连接检查未通过,请检查网络和凭据;已保留切换结果。"
|
|
||||||
AgentFeishuVerifyMessages: "本地启用完成,请在飞书中验证配对、私聊、群聊、机器人路由和流式回复。"
|
|
||||||
|
|
||||||
ErrFeishuUpgradeConfirm: "请在升级页面确认飞书切换选项后重试。"
|
|
||||||
|
|
||||||
ErrFeishuUpgradeBackup: "飞书兼容升级必须开启完整应用备份。"
|
|
||||||
|
|
||||||
ErrFeishuUpgradeMounts: "当前挂载无法完整备份或恢复,请使用应用目录内的标准数据挂载后重试。"
|
|
||||||
|
|
||||||
AgentFeishuRecoveryDisabled: "网关已恢复,飞书保持停用;原配置备份已保留。"
|
|
||||||
|
|
||||||
ContainerUpgradeWaiting: "等待容器升级任务执行名额"
|
|
||||||
|
|||||||
@@ -2,15 +2,11 @@ package router
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"io"
|
"io"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/1Panel-dev/1Panel/agent/app/dto/request"
|
|
||||||
"github.com/1Panel-dev/1Panel/agent/app/dto/response"
|
|
||||||
"github.com/1Panel-dev/1Panel/agent/app/service"
|
|
||||||
"github.com/1Panel-dev/1Panel/agent/global"
|
"github.com/1Panel-dev/1Panel/agent/global"
|
||||||
"github.com/1Panel-dev/1Panel/agent/middleware"
|
"github.com/1Panel-dev/1Panel/agent/middleware"
|
||||||
"github.com/1Panel-dev/1Panel/agent/utils/mcpapi"
|
"github.com/1Panel-dev/1Panel/agent/utils/mcpapi"
|
||||||
@@ -21,38 +17,9 @@ import (
|
|||||||
func registerMCPRuntime(engine *gin.Engine) {
|
func registerMCPRuntime(engine *gin.Engine) {
|
||||||
controller := mcpruntime.New()
|
controller := mcpruntime.New()
|
||||||
engine.POST("/internal/mcp/docker", mcpDockerHandler(controller))
|
engine.POST("/internal/mcp/docker", mcpDockerHandler(controller))
|
||||||
engine.POST("/internal/mcp/website", mcpWebsiteHandler(service.NewMCPWebsiteBindingService()))
|
|
||||||
engine.GET(mcpapi.CapabilitiesPath, middleware.Certificate(), mcpCapabilitiesHandler())
|
engine.GET(mcpapi.CapabilitiesPath, middleware.Certificate(), mcpCapabilitiesHandler())
|
||||||
}
|
}
|
||||||
|
|
||||||
type mcpWebsiteController interface {
|
|
||||||
Handle(context.Context, request.MCPWebsiteBinding) response.MCPWebsiteBinding
|
|
||||||
}
|
|
||||||
|
|
||||||
func mcpWebsiteHandler(controller mcpWebsiteController) gin.HandlerFunc {
|
|
||||||
return func(ctx *gin.Context) {
|
|
||||||
local, ok := ctx.Request.Context().Value(http.LocalAddrContextKey).(net.Addr)
|
|
||||||
if !global.IsMaster || !ok || local.Network() != "unix" {
|
|
||||||
ctx.AbortWithStatus(http.StatusForbidden)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
data, err := io.ReadAll(io.LimitReader(ctx.Request.Body, 16385))
|
|
||||||
if err != nil || len(data) > 16384 {
|
|
||||||
ctx.AbortWithStatus(http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var req request.MCPWebsiteBinding
|
|
||||||
decoder := json.NewDecoder(bytes.NewReader(data))
|
|
||||||
decoder.DisallowUnknownFields()
|
|
||||||
if decoder.Decode(&req) != nil || decoder.Decode(new(any)) != io.EOF {
|
|
||||||
ctx.AbortWithStatus(http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
ctx.Header("Cache-Control", "no-store")
|
|
||||||
ctx.JSON(http.StatusOK, controller.Handle(ctx.Request.Context(), req))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func mcpCapabilitiesHandler() gin.HandlerFunc {
|
func mcpCapabilitiesHandler() gin.HandlerFunc {
|
||||||
return func(ctx *gin.Context) {
|
return func(ctx *gin.Context) {
|
||||||
if !mcpapi.TrustedTransport(ctx.Request) {
|
if !mcpapi.TrustedTransport(ctx.Request) {
|
||||||
@@ -65,12 +32,7 @@ func mcpCapabilitiesHandler() gin.HandlerFunc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type mcpDockerController interface {
|
func mcpDockerHandler(controller *mcpruntime.Controller) gin.HandlerFunc {
|
||||||
Handle(context.Context, mcpruntime.Request) (mcpruntime.Response, error)
|
|
||||||
HandleWithProgress(context.Context, mcpruntime.Request, func(mcpruntime.PullProgress) error) (mcpruntime.Response, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
func mcpDockerHandler(controller mcpDockerController) gin.HandlerFunc {
|
|
||||||
return func(ctx *gin.Context) {
|
return func(ctx *gin.Context) {
|
||||||
local, ok := ctx.Request.Context().Value(http.LocalAddrContextKey).(net.Addr)
|
local, ok := ctx.Request.Context().Value(http.LocalAddrContextKey).(net.Addr)
|
||||||
if !global.IsMaster || !ok || local.Network() != "unix" {
|
if !global.IsMaster || !ok || local.Network() != "unix" {
|
||||||
@@ -89,30 +51,6 @@ func mcpDockerHandler(controller mcpDockerController) gin.HandlerFunc {
|
|||||||
ctx.AbortWithStatus(http.StatusBadRequest)
|
ctx.AbortWithStatus(http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if req.StreamProgress {
|
|
||||||
if req.Operation != "pull" {
|
|
||||||
ctx.JSON(http.StatusBadRequest, mcpruntime.Response{Error: "MCP Docker progress is only available when pulling an image"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
ctx.Header("Content-Type", "application/x-ndjson")
|
|
||||||
ctx.Header("Cache-Control", "no-store")
|
|
||||||
ctx.Status(http.StatusOK)
|
|
||||||
encoder := json.NewEncoder(ctx.Writer)
|
|
||||||
_, err := controller.HandleWithProgress(ctx.Request.Context(), req, func(progress mcpruntime.PullProgress) error {
|
|
||||||
if err := encoder.Encode(mcpruntime.PullEvent{Type: "progress", PullProgress: progress}); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
ctx.Writer.Flush()
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
result := mcpruntime.PullEvent{Type: "result"}
|
|
||||||
if err != nil {
|
|
||||||
result.Error = err.Error()
|
|
||||||
}
|
|
||||||
_ = encoder.Encode(result)
|
|
||||||
ctx.Writer.Flush()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
response, err := controller.Handle(ctx.Request.Context(), req)
|
response, err := controller.Handle(ctx.Request.Context(), req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ctx.JSON(http.StatusBadRequest, mcpruntime.Response{Error: err.Error()})
|
ctx.JSON(http.StatusBadRequest, mcpruntime.Response{Error: err.Error()})
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ package router
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
v2 "github.com/1Panel-dev/1Panel/agent/app/api/v2"
|
v2 "github.com/1Panel-dev/1Panel/agent/app/api/v2"
|
||||||
"github.com/1Panel-dev/1Panel/agent/app/service"
|
|
||||||
"github.com/1Panel-dev/1Panel/agent/global"
|
"github.com/1Panel-dev/1Panel/agent/global"
|
||||||
"github.com/1Panel-dev/1Panel/agent/i18n"
|
"github.com/1Panel-dev/1Panel/agent/i18n"
|
||||||
"github.com/1Panel-dev/1Panel/agent/middleware"
|
"github.com/1Panel-dev/1Panel/agent/middleware"
|
||||||
@@ -15,9 +14,6 @@ var (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func Routers() *gin.Engine {
|
func Routers() *gin.Engine {
|
||||||
if err := service.ConfigurePanelMCPTasks(); err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
Router = gin.Default()
|
Router = gin.Default()
|
||||||
Router.Use(i18n.UseI18n())
|
Router.Use(i18n.UseI18n())
|
||||||
registerMCPRuntime(Router)
|
registerMCPRuntime(Router)
|
||||||
@@ -27,8 +23,6 @@ func Routers() *gin.Engine {
|
|||||||
PrivateGroup.Use(middleware.Certificate())
|
PrivateGroup.Use(middleware.Certificate())
|
||||||
}
|
}
|
||||||
PrivateGroup.Use(middleware.OperationResolveMeta())
|
PrivateGroup.Use(middleware.OperationResolveMeta())
|
||||||
PrivateGroup.Use(v2.PanelMCPTaskAccess())
|
|
||||||
PrivateGroup.Use(v2.MCPBusinessDispatch())
|
|
||||||
for _, router := range rou.RouterGroupApp {
|
for _, router := range rou.RouterGroupApp {
|
||||||
router.InitRouter(PrivateGroup)
|
router.InitRouter(PrivateGroup)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ func (a *AIToolsRouter) InitRouter(Router *gin.RouterGroup) {
|
|||||||
aiToolsRouter.POST("/ollama/model", baseApi.CreateOllamaModel)
|
aiToolsRouter.POST("/ollama/model", baseApi.CreateOllamaModel)
|
||||||
aiToolsRouter.POST("/ollama/model/recreate", baseApi.RecreateOllamaModel)
|
aiToolsRouter.POST("/ollama/model/recreate", baseApi.RecreateOllamaModel)
|
||||||
aiToolsRouter.POST("/ollama/model/search", baseApi.SearchOllamaModel)
|
aiToolsRouter.POST("/ollama/model/search", baseApi.SearchOllamaModel)
|
||||||
|
aiToolsRouter.POST("/ollama/model/sync", baseApi.SyncOllamaModel)
|
||||||
aiToolsRouter.POST("/ollama/account/sync", baseApi.SyncOllamaModelAccount)
|
aiToolsRouter.POST("/ollama/account/sync", baseApi.SyncOllamaModelAccount)
|
||||||
aiToolsRouter.POST("/ollama/model/load", baseApi.LoadOllamaModelDetail)
|
aiToolsRouter.POST("/ollama/model/load", baseApi.LoadOllamaModelDetail)
|
||||||
aiToolsRouter.POST("/ollama/model/del", baseApi.DeleteOllamaModel)
|
aiToolsRouter.POST("/ollama/model/del", baseApi.DeleteOllamaModel)
|
||||||
@@ -91,7 +92,6 @@ func (a *AIToolsRouter) InitRouter(Router *gin.RouterGroup) {
|
|||||||
aiToolsRouter.POST("/agents/agent/md/update", baseApi.UpdateAgentRoleMarkdownFile)
|
aiToolsRouter.POST("/agents/agent/md/update", baseApi.UpdateAgentRoleMarkdownFile)
|
||||||
aiToolsRouter.POST("/agents/channel/feishu/get", baseApi.GetAgentFeishuConfig)
|
aiToolsRouter.POST("/agents/channel/feishu/get", baseApi.GetAgentFeishuConfig)
|
||||||
aiToolsRouter.POST("/agents/channel/feishu/update", baseApi.UpdateAgentFeishuConfig)
|
aiToolsRouter.POST("/agents/channel/feishu/update", baseApi.UpdateAgentFeishuConfig)
|
||||||
aiToolsRouter.POST("/agents/channel/feishu/switch", baseApi.SwitchAgentFeishuPlugin)
|
|
||||||
aiToolsRouter.POST("/agents/channel/telegram/get", baseApi.GetAgentTelegramConfig)
|
aiToolsRouter.POST("/agents/channel/telegram/get", baseApi.GetAgentTelegramConfig)
|
||||||
aiToolsRouter.POST("/agents/channel/telegram/update", baseApi.UpdateAgentTelegramConfig)
|
aiToolsRouter.POST("/agents/channel/telegram/update", baseApi.UpdateAgentTelegramConfig)
|
||||||
aiToolsRouter.POST("/agents/channel/discord/get", baseApi.GetAgentDiscordConfig)
|
aiToolsRouter.POST("/agents/channel/discord/get", baseApi.GetAgentDiscordConfig)
|
||||||
|
|||||||
@@ -104,7 +104,6 @@ func pullComposeImages(filePath string, forcePull bool, task *task.Task, project
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer dockerCLi.Close()
|
|
||||||
for _, image := range images {
|
for _, image := range images {
|
||||||
if !forcePull {
|
if !forcePull {
|
||||||
if exist, _ := dockerCLi.ImageExists(image); exist {
|
if exist, _ := dockerCLi.ImageExists(image); exist {
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import (
|
|||||||
"github.com/1Panel-dev/1Panel/agent/app/model"
|
"github.com/1Panel-dev/1Panel/agent/app/model"
|
||||||
"github.com/1Panel-dev/1Panel/agent/app/task"
|
"github.com/1Panel-dev/1Panel/agent/app/task"
|
||||||
"github.com/1Panel-dev/1Panel/agent/global"
|
"github.com/1Panel-dev/1Panel/agent/global"
|
||||||
"github.com/distribution/reference"
|
|
||||||
"github.com/docker/cli/cli/config"
|
"github.com/docker/cli/cli/config"
|
||||||
"github.com/docker/docker/api/types"
|
"github.com/docker/docker/api/types"
|
||||||
"github.com/docker/docker/api/types/container"
|
"github.com/docker/docker/api/types/container"
|
||||||
@@ -198,22 +197,12 @@ func setLog(id, newLastLine string, task *task.Task) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (c Client) PullImageWithProcessAndOptions(task *task.Task, imageName string, options image.PullOptions) error {
|
func (c Client) PullImageWithProcessAndOptions(task *task.Task, imageName string, options image.PullOptions) error {
|
||||||
return c.PullImageWithProcessAndOptionsContext(context.Background(), task, imageName, options)
|
out, err := c.cli.ImagePull(context.Background(), imageName, options)
|
||||||
}
|
|
||||||
|
|
||||||
func (c Client) PullImageWithProcessAndOptionsContext(ctx context.Context, task *task.Task, imageName string, options image.PullOptions) error {
|
|
||||||
out, err := c.cli.ImagePull(ctx, imageName, options)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer out.Close()
|
defer out.Close()
|
||||||
stopClose := context.AfterFunc(ctx, func() { _ = out.Close() })
|
return handlePullImageProcess(out, task)
|
||||||
defer stopClose()
|
|
||||||
err = handlePullImageProcess(out, task)
|
|
||||||
if ctx.Err() != nil {
|
|
||||||
return ctx.Err()
|
|
||||||
}
|
|
||||||
return err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func handlePullImageProcess(out io.Reader, task *task.Task) error {
|
func handlePullImageProcess(out io.Reader, task *task.Task) error {
|
||||||
@@ -330,15 +319,11 @@ func (c Client) BuildImageWithProcessAndOptions(task *task.Task, tar io.ReadClos
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (c Client) PullImageWithProcess(task *task.Task, imageName string) error {
|
func (c Client) PullImageWithProcess(task *task.Task, imageName string) error {
|
||||||
return c.PullImageWithProcessContext(context.Background(), task, imageName)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c Client) PullImageWithProcessContext(ctx context.Context, task *task.Task, imageName string) error {
|
|
||||||
options := image.PullOptions{}
|
options := image.PullOptions{}
|
||||||
if authStr, ok := RegistryAuthForImage(imageName); ok {
|
if authStr, ok := loadRegistryAuthFromDockerConfig(imageName); ok {
|
||||||
options.RegistryAuth = authStr
|
options.RegistryAuth = authStr
|
||||||
}
|
}
|
||||||
return c.PullImageWithProcessAndOptionsContext(ctx, task, imageName, options)
|
return c.PullImageWithProcessAndOptions(task, imageName, options)
|
||||||
}
|
}
|
||||||
|
|
||||||
func logProcess(progress map[string]interface{}, task *task.Task) {
|
func logProcess(progress map[string]interface{}, task *task.Task) {
|
||||||
@@ -357,31 +342,26 @@ func PullImage(imageName string) error {
|
|||||||
}
|
}
|
||||||
defer cli.Close()
|
defer cli.Close()
|
||||||
options := image.PullOptions{}
|
options := image.PullOptions{}
|
||||||
if authStr, ok := RegistryAuthForImage(imageName); ok {
|
if authStr, ok := loadRegistryAuthFromDockerConfig(imageName); ok {
|
||||||
options.RegistryAuth = authStr
|
options.RegistryAuth = authStr
|
||||||
}
|
}
|
||||||
out, err := cli.ImagePull(context.Background(), imageName, options)
|
if _, err := cli.ImagePull(context.Background(), imageName, options); err != nil {
|
||||||
if err != nil {
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer out.Close()
|
return nil
|
||||||
return handlePullImageProcess(out, nil)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func RegistryAuthForImage(imageName string) (string, bool) {
|
func loadRegistryAuthFromDockerConfig(imageName string) (string, bool) {
|
||||||
named, err := reference.ParseNormalizedNamed(imageName)
|
registryHost, hasRegistry := extractRegistryHost(imageName)
|
||||||
if err != nil {
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
registryHost := normalizeRegistryHost(reference.Domain(named))
|
|
||||||
cfg := config.LoadDefaultConfigFile(io.Discard)
|
cfg := config.LoadDefaultConfigFile(io.Discard)
|
||||||
if cfg == nil {
|
if cfg == nil {
|
||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
candidates := make([]string, 0)
|
candidates := make([]string, 0)
|
||||||
if !isDockerHubRegistry(registryHost) {
|
if hasRegistry {
|
||||||
candidates = append(candidates, registryHost, "https://"+registryHost, "http://"+registryHost)
|
candidates = append(candidates, registryHost, "https://"+registryHost, "http://"+registryHost)
|
||||||
} else {
|
}
|
||||||
|
if !hasRegistry || isDockerHubRegistry(registryHost) {
|
||||||
candidates = append(candidates,
|
candidates = append(candidates,
|
||||||
"https://index.docker.io/v1/",
|
"https://index.docker.io/v1/",
|
||||||
"index.docker.io",
|
"index.docker.io",
|
||||||
@@ -431,6 +411,18 @@ func isDockerHubRegistry(host string) bool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func extractRegistryHost(imageName string) (string, bool) {
|
||||||
|
parts := strings.Split(imageName, "/")
|
||||||
|
if len(parts) < 2 {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
first := parts[0]
|
||||||
|
if strings.Contains(first, ".") || strings.Contains(first, ":") || first == "localhost" {
|
||||||
|
return normalizeRegistryHost(first), true
|
||||||
|
}
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
func normalizeRegistryHost(registryKey string) string {
|
func normalizeRegistryHost(registryKey string) string {
|
||||||
key := strings.TrimSpace(registryKey)
|
key := strings.TrimSpace(registryKey)
|
||||||
if key == "" {
|
if key == "" {
|
||||||
|
|||||||
Vendored
+6
-9
@@ -29,7 +29,7 @@ func Write(envMap map[string]string, filename string) error {
|
|||||||
func Marshal(envMap map[string]string) (string, error) {
|
func Marshal(envMap map[string]string) (string, error) {
|
||||||
lines := make([]string, 0, len(envMap))
|
lines := make([]string, 0, len(envMap))
|
||||||
for k, v := range envMap {
|
for k, v := range envMap {
|
||||||
if d, ok := canonicalInt(v); ok {
|
if d, err := strconv.Atoi(v); err == nil && !isStartWithZero(v) {
|
||||||
lines = append(lines, fmt.Sprintf(`%s=%d`, k, d))
|
lines = append(lines, fmt.Sprintf(`%s=%d`, k, d))
|
||||||
} else if shouldUseSingleQuotes(v) {
|
} else if shouldUseSingleQuotes(v) {
|
||||||
lines = append(lines, fmt.Sprintf(`%s='%s'`, k, v))
|
lines = append(lines, fmt.Sprintf(`%s='%s'`, k, v))
|
||||||
@@ -86,7 +86,7 @@ func MarshalWithOrder(envMap map[string]string, orders []string) (string, error)
|
|||||||
}
|
}
|
||||||
|
|
||||||
func formatEnvLine(k, v string) string {
|
func formatEnvLine(k, v string) string {
|
||||||
if d, ok := canonicalInt(v); ok {
|
if d, err := strconv.Atoi(v); err == nil && !isStartWithZero(v) {
|
||||||
return fmt.Sprintf(`%s=%d`, k, d)
|
return fmt.Sprintf(`%s=%d`, k, d)
|
||||||
} else if shouldUseSingleQuotes(v) {
|
} else if shouldUseSingleQuotes(v) {
|
||||||
return fmt.Sprintf(`%s='%s'`, k, v)
|
return fmt.Sprintf(`%s='%s'`, k, v)
|
||||||
@@ -107,14 +107,11 @@ func GetEnvValueByKey(envPath, key string) (string, error) {
|
|||||||
return value, nil
|
return value, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// canonicalInt reports whether value is an integer that formats back to the
|
func isStartWithZero(value string) bool {
|
||||||
// same string, so values such as "007", "+1" or "-01" keep their exact text.
|
if strings.HasPrefix(value, "0") && len(value) > 1 {
|
||||||
func canonicalInt(value string) (int, bool) {
|
return true
|
||||||
d, err := strconv.Atoi(value)
|
|
||||||
if err != nil || strconv.Itoa(d) != value {
|
|
||||||
return 0, false
|
|
||||||
}
|
}
|
||||||
return d, true
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
func hasEvenDoubleQuotes(s string) bool {
|
func hasEvenDoubleQuotes(s string) bool {
|
||||||
|
|||||||
Vendored
-42
@@ -1,42 +0,0 @@
|
|||||||
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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -3,7 +3,6 @@ package mcpruntime
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
_ "crypto/sha256"
|
|
||||||
"encoding/binary"
|
"encoding/binary"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
@@ -16,21 +15,19 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
"unicode"
|
|
||||||
|
|
||||||
"github.com/1Panel-dev/1Panel/agent/utils/docker"
|
"github.com/1Panel-dev/1Panel/agent/utils/docker"
|
||||||
"github.com/containerd/errdefs"
|
|
||||||
"github.com/distribution/reference"
|
|
||||||
"github.com/docker/docker/api/types/container"
|
"github.com/docker/docker/api/types/container"
|
||||||
"github.com/docker/docker/api/types/image"
|
"github.com/docker/docker/api/types/image"
|
||||||
"github.com/docker/docker/api/types/network"
|
"github.com/docker/docker/api/types/network"
|
||||||
"github.com/docker/docker/api/types/system"
|
"github.com/docker/docker/api/types/system"
|
||||||
"github.com/docker/docker/client"
|
"github.com/docker/docker/client"
|
||||||
|
"github.com/docker/docker/errdefs"
|
||||||
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
|
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
ImageRepository = "1panel/mcp-server"
|
ImageRepository = "1panel/1panel-mcp-server"
|
||||||
ContainerName = "1panel-mcp"
|
ContainerName = "1panel-mcp"
|
||||||
previousName = "1panel-mcp-previous"
|
previousName = "1panel-mcp-previous"
|
||||||
SocketDirectory = "/etc/1panel/mcp-runtime"
|
SocketDirectory = "/etc/1panel/mcp-runtime"
|
||||||
@@ -51,13 +48,12 @@ var digestPattern = regexp.MustCompile(`^sha256:[a-f0-9]{64}$`)
|
|||||||
var ErrUnsupported = errors.New("MCP Docker runtime requires a local rootful Linux Docker daemon without user namespace remapping")
|
var ErrUnsupported = errors.New("MCP Docker runtime requires a local rootful Linux Docker daemon without user namespace remapping")
|
||||||
|
|
||||||
type Request struct {
|
type Request struct {
|
||||||
Operation string `json:"operation"`
|
Operation string `json:"operation"`
|
||||||
Image string `json:"image"`
|
Image string `json:"image"`
|
||||||
Port int `json:"port"`
|
Port int `json:"port"`
|
||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
Restart bool `json:"restart"`
|
Restart bool `json:"restart"`
|
||||||
ImageID string `json:"imageID,omitempty"`
|
ImageID string `json:"imageID,omitempty"`
|
||||||
StreamProgress bool `json:"streamProgress,omitempty"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type State struct {
|
type State struct {
|
||||||
@@ -110,7 +106,6 @@ type Controller struct {
|
|||||||
platform string
|
platform string
|
||||||
health func(context.Context, int) (metadata, error)
|
health func(context.Context, int) (metadata, error)
|
||||||
checkDirectory func() error
|
checkDirectory func() error
|
||||||
registryAuth func(string) (string, bool)
|
|
||||||
mutation chan struct{}
|
mutation chan struct{}
|
||||||
probes chan struct{}
|
probes chan struct{}
|
||||||
cacheMu sync.Mutex
|
cacheMu sync.Mutex
|
||||||
@@ -123,40 +118,25 @@ func New() *Controller {
|
|||||||
|
|
||||||
func newController(factory func() (DockerClient, error)) *Controller {
|
func newController(factory func() (DockerClient, error)) *Controller {
|
||||||
return &Controller{newClient: factory, platform: runtime.GOOS, health: readHealth,
|
return &Controller{newClient: factory, platform: runtime.GOOS, health: readHealth,
|
||||||
checkDirectory: checkSocketDirectory, registryAuth: docker.RegistryAuthForImage,
|
checkDirectory: checkSocketDirectory, mutation: make(chan struct{}, 1), probes: make(chan struct{}, 8), cache: make(map[string]verifiedImage)}
|
||||||
mutation: make(chan struct{}, 1), probes: make(chan struct{}, 8), cache: make(map[string]verifiedImage)}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func ValidateImage(value string) error {
|
func ValidateImage(value string) error {
|
||||||
if value == "" || len(value) > 512 || strings.IndexFunc(value, func(ch rune) bool { return unicode.IsSpace(ch) || unicode.IsControl(ch) }) >= 0 {
|
if len(value) > 180 {
|
||||||
return errors.New("invalid MCP Docker image")
|
return errors.New("invalid MCP Docker image")
|
||||||
}
|
}
|
||||||
parsed, err := reference.ParseNormalizedNamed(value)
|
if strings.HasPrefix(value, ImageRepository+"@") && digestPattern.MatchString(strings.TrimPrefix(value, ImageRepository+"@")) {
|
||||||
if err != nil {
|
|
||||||
return errors.New("invalid MCP Docker image")
|
|
||||||
}
|
|
||||||
if digested, ok := parsed.(reference.Digested); ok {
|
|
||||||
if digestPattern.MatchString(digested.Digest().String()) {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return errors.New("MCP Docker image must use a SHA256 digest")
|
|
||||||
}
|
|
||||||
if _, ok := parsed.(reference.Tagged); ok {
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return errors.New("MCP Docker image must include a tag or SHA256 digest")
|
if strings.HasPrefix(value, ImageRepository+":") && semanticVersion.MatchString(strings.TrimPrefix(value, ImageRepository+":")) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return errors.New("MCP Docker image must use the official repository with a fixed semantic version or SHA256 digest")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Controller) Handle(ctx context.Context, req Request) (Response, error) {
|
func (c *Controller) Handle(ctx context.Context, req Request) (Response, error) {
|
||||||
return c.HandleWithProgress(ctx, req, nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Controller) HandleWithProgress(ctx context.Context, req Request, progress func(PullProgress) error) (Response, error) {
|
|
||||||
if req.StreamProgress && req.Operation != "pull" {
|
|
||||||
return Response{}, errors.New("MCP Docker progress is only available when pulling an image")
|
|
||||||
}
|
|
||||||
switch req.Operation {
|
switch req.Operation {
|
||||||
case "prepare", "catalog", "apply_catalog", "candidate_catalog", "status", "apply", "install", "rollback", "rollback_absent", "pull", "logs":
|
case "prepare", "catalog", "candidate_catalog", "status", "apply", "install", "rollback", "rollback_absent", "pull", "logs":
|
||||||
default:
|
default:
|
||||||
return Response{}, errors.New("invalid MCP Docker operation")
|
return Response{}, errors.New("invalid MCP Docker operation")
|
||||||
}
|
}
|
||||||
@@ -167,7 +147,7 @@ func (c *Controller) HandleWithProgress(ctx context.Context, req Request, progre
|
|||||||
return Response{}, errors.New("MCP listen port must be between 1024 and 65535")
|
return Response{}, errors.New("MCP listen port must be between 1024 and 65535")
|
||||||
}
|
}
|
||||||
timeout := 60 * time.Second
|
timeout := 60 * time.Second
|
||||||
if req.Operation == "catalog" || req.Operation == "apply_catalog" || req.Operation == "candidate_catalog" || req.Operation == "logs" || req.Operation == "prepare" {
|
if req.Operation == "catalog" || req.Operation == "candidate_catalog" || req.Operation == "logs" || req.Operation == "prepare" {
|
||||||
timeout = 5 * time.Second
|
timeout = 5 * time.Second
|
||||||
}
|
}
|
||||||
if req.Operation == "status" {
|
if req.Operation == "status" {
|
||||||
@@ -196,23 +176,19 @@ func (c *Controller) HandleWithProgress(ctx context.Context, req Request, progre
|
|||||||
}
|
}
|
||||||
switch req.Operation {
|
switch req.Operation {
|
||||||
case "prepare":
|
case "prepare":
|
||||||
case "catalog", "apply_catalog", "candidate_catalog":
|
case "catalog", "candidate_catalog":
|
||||||
var img image.InspectResponse
|
var img image.InspectResponse
|
||||||
if req.Operation != "candidate_catalog" {
|
img, err = c.resolve(ctx, cli, req.Image)
|
||||||
|
if err == nil && req.Operation == "catalog" {
|
||||||
var current *container.InspectResponse
|
var current *container.InspectResponse
|
||||||
current, err = c.owned(ctx, cli, ContainerName)
|
current, err = c.owned(ctx, cli, ContainerName)
|
||||||
if err == nil && req.Operation == "catalog" && current != nil && current.Config.Labels[imageLabel] != req.Image {
|
if err == nil && current != nil && isRunning(current) && current.Config.Labels[imageLabel] == req.Image && current.Image != img.ID {
|
||||||
err = errors.New("MCP installed runtime does not match the configured image")
|
err = errors.New("MCP image reference changed; restart the adapter before reading its catalog")
|
||||||
}
|
}
|
||||||
if err == nil {
|
|
||||||
img, err = c.resolveActiveImage(ctx, cli, req.Image, current)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
img, err = c.resolve(ctx, cli, req.Image)
|
|
||||||
}
|
}
|
||||||
if err == nil {
|
if err == nil {
|
||||||
var verified verifiedImage
|
var verified verifiedImage
|
||||||
verified, err = c.verify(ctx, cli, img.ID)
|
verified, err = c.verify(ctx, cli, img.ID, req.Image)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
return Response{Catalog: append(json.RawMessage(nil), verified.catalog...), ImageID: img.ID}, nil
|
return Response{Catalog: append(json.RawMessage(nil), verified.catalog...), ImageID: img.ID}, nil
|
||||||
}
|
}
|
||||||
@@ -228,7 +204,7 @@ func (c *Controller) HandleWithProgress(ctx context.Context, req Request, progre
|
|||||||
case "rollback_absent":
|
case "rollback_absent":
|
||||||
err = c.rollbackAbsent(ctx, cli, req)
|
err = c.rollbackAbsent(ctx, cli, req)
|
||||||
case "pull":
|
case "pull":
|
||||||
err = c.pullImage(ctx, cli, req, progress)
|
err = c.pullImage(ctx, cli, req)
|
||||||
case "logs":
|
case "logs":
|
||||||
var current *container.InspectResponse
|
var current *container.InspectResponse
|
||||||
current, err = c.owned(ctx, cli, ContainerName)
|
current, err = c.owned(ctx, cli, ContainerName)
|
||||||
@@ -283,20 +259,6 @@ func (c *Controller) resolve(ctx context.Context, cli DockerClient, ref string)
|
|||||||
return img, nil
|
return img, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Controller) resolveActiveImage(ctx context.Context, cli DockerClient, ref string, current *container.InspectResponse) (image.InspectResponse, error) {
|
|
||||||
if current == nil || current.Config.Labels[imageLabel] != ref {
|
|
||||||
return c.resolve(ctx, cli, ref)
|
|
||||||
}
|
|
||||||
if !digestPattern.MatchString(current.Image) {
|
|
||||||
return image.InspectResponse{}, errors.New("invalid MCP installed image identity")
|
|
||||||
}
|
|
||||||
img, err := c.resolve(ctx, cli, current.Image)
|
|
||||||
if err != nil || img.ID != current.Image {
|
|
||||||
return image.InspectResponse{}, errors.New("MCP installed image is unavailable or changed")
|
|
||||||
}
|
|
||||||
return img, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Controller) owned(ctx context.Context, cli DockerClient, name string) (*container.InspectResponse, error) {
|
func (c *Controller) owned(ctx context.Context, cli DockerClient, name string) (*container.InspectResponse, error) {
|
||||||
info, err := cli.ContainerInspect(ctx, name)
|
info, err := cli.ContainerInspect(ctx, name)
|
||||||
if errdefs.IsNotFound(err) {
|
if errdefs.IsNotFound(err) {
|
||||||
@@ -317,6 +279,10 @@ func isRunning(info *container.InspectResponse) bool {
|
|||||||
|
|
||||||
func (c *Controller) status(ctx context.Context, cli DockerClient, req Request) Response {
|
func (c *Controller) status(ctx context.Context, cli DockerClient, req Request) Response {
|
||||||
state := &State{Status: "not_installed", Image: req.Image}
|
state := &State{Status: "not_installed", Image: req.Image}
|
||||||
|
img, err := c.resolve(ctx, cli, req.Image)
|
||||||
|
if err == nil {
|
||||||
|
state.ImageID = img.ID
|
||||||
|
}
|
||||||
current, inspectErr := c.owned(ctx, cli, ContainerName)
|
current, inspectErr := c.owned(ctx, cli, ContainerName)
|
||||||
if inspectErr != nil {
|
if inspectErr != nil {
|
||||||
state.Status = "unhealthy"
|
state.Status = "unhealthy"
|
||||||
@@ -324,11 +290,9 @@ func (c *Controller) status(ctx context.Context, cli DockerClient, req Request)
|
|||||||
return Response{State: state}
|
return Response{State: state}
|
||||||
}
|
}
|
||||||
if current == nil {
|
if current == nil {
|
||||||
img, err := c.resolve(ctx, cli, req.Image)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
state.Reason = err.Error()
|
state.Reason = err.Error()
|
||||||
} else {
|
} else {
|
||||||
state.ImageID = img.ID
|
|
||||||
state.Reason = "MCP image is loaded; install it to create the managed container"
|
state.Reason = "MCP image is loaded; install it to create the managed container"
|
||||||
}
|
}
|
||||||
return Response{State: state}
|
return Response{State: state}
|
||||||
@@ -348,13 +312,12 @@ func (c *Controller) status(ctx context.Context, cli DockerClient, req Request)
|
|||||||
state.Reason = "MCP access is disabled"
|
state.Reason = "MCP access is disabled"
|
||||||
return Response{State: state}
|
return Response{State: state}
|
||||||
}
|
}
|
||||||
img, err := c.resolveActiveImage(ctx, cli, req.Image, current)
|
if err != nil || current.Image != img.ID {
|
||||||
if err != nil || current.Config.Labels[imageLabel] != req.Image {
|
|
||||||
state.Status = "unhealthy"
|
state.Status = "unhealthy"
|
||||||
state.Reason = "MCP container does not match the configured image or listen port"
|
state.Reason = "MCP container does not match the configured image or listen port"
|
||||||
return Response{State: state}
|
return Response{State: state}
|
||||||
}
|
}
|
||||||
verified, verifyErr := c.verify(ctx, cli, current.Image)
|
verified, verifyErr := c.verify(ctx, cli, current.Image, req.Image)
|
||||||
if verifyErr != nil || !matchesRuntimeWithCapabilities(ctx, cli, current, img.ID, req, verified.metadata.AdapterVersion) {
|
if verifyErr != nil || !matchesRuntimeWithCapabilities(ctx, cli, current, img.ID, req, verified.metadata.AdapterVersion) {
|
||||||
state.Status = "unhealthy"
|
state.Status = "unhealthy"
|
||||||
state.Reason = "MCP container configuration or metadata does not match the verified image"
|
state.Reason = "MCP container configuration or metadata does not match the verified image"
|
||||||
@@ -371,7 +334,7 @@ func (c *Controller) status(ctx context.Context, cli DockerClient, req Request)
|
|||||||
return Response{State: state}
|
return Response{State: state}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Controller) verify(ctx context.Context, cli DockerClient, id string) (verifiedImage, error) {
|
func (c *Controller) verify(ctx context.Context, cli DockerClient, id, ref string) (verifiedImage, error) {
|
||||||
c.cacheMu.Lock()
|
c.cacheMu.Lock()
|
||||||
cached, ok := c.cache[id]
|
cached, ok := c.cache[id]
|
||||||
c.cacheMu.Unlock()
|
c.cacheMu.Unlock()
|
||||||
@@ -403,6 +366,9 @@ func (c *Controller) verify(ctx context.Context, cli DockerClient, id string) (v
|
|||||||
c.cache[id] = cached
|
c.cache[id] = cached
|
||||||
c.cacheMu.Unlock()
|
c.cacheMu.Unlock()
|
||||||
}
|
}
|
||||||
|
if strings.HasPrefix(ref, ImageRepository+":") && strings.TrimPrefix(cached.metadata.AdapterVersion, "v") != strings.TrimPrefix(strings.TrimPrefix(ref, ImageRepository+":"), "v") {
|
||||||
|
return verifiedImage{}, errors.New("MCP image version does not match its selected tag")
|
||||||
|
}
|
||||||
return cached, nil
|
return cached, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,90 +10,49 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/containerd/errdefs"
|
|
||||||
"github.com/docker/docker/api/types/container"
|
"github.com/docker/docker/api/types/container"
|
||||||
"github.com/docker/docker/api/types/image"
|
"github.com/docker/docker/api/types/image"
|
||||||
"github.com/docker/docker/api/types/mount"
|
"github.com/docker/docker/api/types/mount"
|
||||||
|
"github.com/docker/docker/errdefs"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (c *Controller) pullImage(ctx context.Context, cli DockerClient, req Request, progress func(PullProgress) error) error {
|
func (c *Controller) pullImage(ctx context.Context, cli DockerClient, req Request) error {
|
||||||
if err := reportPullProgress(ctx, progress, PullProgress{Status: "checking_image"}); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
_, err := cli.ImageInspect(ctx, req.Image)
|
_, err := cli.ImageInspect(ctx, req.Image)
|
||||||
if err != nil && !errdefs.IsNotFound(err) {
|
if err != nil && !errdefs.IsNotFound(err) {
|
||||||
return errors.New("MCP Docker image inspection failed")
|
return errors.New("MCP Docker image inspection failed")
|
||||||
}
|
}
|
||||||
if errdefs.IsNotFound(err) {
|
if errdefs.IsNotFound(err) {
|
||||||
if err = reportPullProgress(ctx, progress, PullProgress{Status: "pulling"}); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
pullCtx, cancel := context.WithTimeout(ctx, 3*time.Minute)
|
pullCtx, cancel := context.WithTimeout(ctx, 3*time.Minute)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
options := image.PullOptions{}
|
reader, pullErr := cli.ImagePull(pullCtx, req.Image, image.PullOptions{})
|
||||||
if auth, ok := c.registryAuth(req.Image); ok {
|
|
||||||
options.RegistryAuth = auth
|
|
||||||
}
|
|
||||||
reader, pullErr := cli.ImagePull(pullCtx, req.Image, options)
|
|
||||||
if pullErr != nil {
|
if pullErr != nil {
|
||||||
if errdefs.IsUnauthorized(pullErr) || errdefs.IsPermissionDenied(pullErr) {
|
|
||||||
return errors.New("MCP Docker image registry authentication failed")
|
|
||||||
}
|
|
||||||
return errors.New("MCP Docker image download failed")
|
return errors.New("MCP Docker image download failed")
|
||||||
}
|
}
|
||||||
defer reader.Close()
|
defer reader.Close()
|
||||||
limited := &io.LimitedReader{R: reader, N: (16 << 20) + 1}
|
decoder := json.NewDecoder(io.LimitReader(reader, 16<<20))
|
||||||
decoder := json.NewDecoder(limited)
|
|
||||||
for {
|
for {
|
||||||
var message struct {
|
var message struct {
|
||||||
Status string `json:"status"`
|
|
||||||
ID string `json:"id"`
|
|
||||||
ProgressDetail struct {
|
|
||||||
Current int64 `json:"current"`
|
|
||||||
Total int64 `json:"total"`
|
|
||||||
} `json:"progressDetail"`
|
|
||||||
Error string `json:"error"`
|
Error string `json:"error"`
|
||||||
ErrorDetail *struct {
|
ErrorDetail *struct {
|
||||||
Code int `json:"code"`
|
|
||||||
Message string `json:"message"`
|
Message string `json:"message"`
|
||||||
} `json:"errorDetail"`
|
} `json:"errorDetail"`
|
||||||
}
|
}
|
||||||
err = decoder.Decode(&message)
|
if err = decoder.Decode(&message); err == io.EOF {
|
||||||
if limited.N == 0 {
|
|
||||||
return errors.New("MCP Docker image download progress exceeds permitted size")
|
|
||||||
}
|
|
||||||
if err == io.EOF {
|
|
||||||
break
|
break
|
||||||
} else if err != nil {
|
} else if err != nil {
|
||||||
return errors.New("MCP Docker image download was interrupted")
|
return errors.New("MCP Docker image download was interrupted")
|
||||||
}
|
}
|
||||||
if message.Error != "" || message.ErrorDetail != nil {
|
if message.Error != "" || message.ErrorDetail != nil {
|
||||||
if message.ErrorDetail != nil && (message.ErrorDetail.Code == 401 || message.ErrorDetail.Code == 403) {
|
|
||||||
return errors.New("MCP Docker image registry authentication failed")
|
|
||||||
}
|
|
||||||
return errors.New("MCP Docker image download failed")
|
return errors.New("MCP Docker image download failed")
|
||||||
}
|
}
|
||||||
if normalized, ok := normalizePullProgress(message.Status, message.ID, message.ProgressDetail.Current, message.ProgressDetail.Total); ok {
|
|
||||||
if err = reportPullProgress(ctx, progress, normalized); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} else if err = reportPullProgress(ctx, progress, PullProgress{Status: "local_image"}); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err = reportPullProgress(ctx, progress, PullProgress{Status: "verifying_image"}); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
}
|
||||||
img, err := c.resolve(ctx, cli, req.Image)
|
img, err := c.resolve(ctx, cli, req.Image)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
_, err = c.verify(ctx, cli, img.ID)
|
_, err = c.verify(ctx, cli, img.ID, req.Image)
|
||||||
if err != nil {
|
return err
|
||||||
return err
|
|
||||||
}
|
|
||||||
return reportPullProgress(ctx, progress, PullProgress{Status: "verified_image"})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Controller) installRuntime(ctx context.Context, cli DockerClient, req Request) error {
|
func (c *Controller) installRuntime(ctx context.Context, cli DockerClient, req Request) error {
|
||||||
@@ -217,19 +176,14 @@ func (c *Controller) apply(ctx context.Context, cli DockerClient, req Request) e
|
|||||||
if err = c.checkDirectory(); err != nil {
|
if err = c.checkDirectory(); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
var img image.InspectResponse
|
img, err := c.resolve(ctx, cli, req.Image)
|
||||||
if req.Operation == "install" {
|
|
||||||
img, err = c.resolve(ctx, cli, req.Image)
|
|
||||||
} else {
|
|
||||||
img, err = c.resolveActiveImage(ctx, cli, req.Image, current)
|
|
||||||
}
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if !digestPattern.MatchString(req.ImageID) || img.ID != req.ImageID {
|
if !digestPattern.MatchString(req.ImageID) || img.ID != req.ImageID {
|
||||||
return errors.New("MCP image identity changed after catalog validation; retry the operation")
|
return errors.New("MCP image identity changed after catalog validation; retry the operation")
|
||||||
}
|
}
|
||||||
verified, err := c.verify(ctx, cli, img.ID)
|
verified, err := c.verify(ctx, cli, img.ID, req.Image)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -400,7 +354,7 @@ func (c *Controller) rollback(ctx context.Context, cli DockerClient, req Request
|
|||||||
}
|
}
|
||||||
return errors.New("the previous MCP runtime instance is unavailable for rollback")
|
return errors.New("the previous MCP runtime instance is unavailable for rollback")
|
||||||
}
|
}
|
||||||
verified, err := c.verify(ctx, cli, target.Image)
|
verified, err := c.verify(ctx, cli, target.Image, req.Image)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -448,7 +402,7 @@ func (c *Controller) rollbackAbsent(ctx context.Context, cli DockerClient, req R
|
|||||||
if _, err = c.resolve(ctx, cli, current.Image); err != nil {
|
if _, err = c.resolve(ctx, cli, current.Image); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
verified, err := c.verify(ctx, cli, current.Image)
|
verified, err := c.verify(ctx, cli, current.Image, req.Image)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,63 +0,0 @@
|
|||||||
package mcpruntime
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"regexp"
|
|
||||||
)
|
|
||||||
|
|
||||||
type PullProgress struct {
|
|
||||||
Status string `json:"status,omitempty"`
|
|
||||||
ID string `json:"id,omitempty"`
|
|
||||||
Current int64 `json:"current,omitempty"`
|
|
||||||
Total int64 `json:"total,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type PullEvent struct {
|
|
||||||
Type string `json:"type"`
|
|
||||||
PullProgress
|
|
||||||
Error string `json:"error,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
var pullLayerID = regexp.MustCompile(`^[a-f0-9]{12,64}$`)
|
|
||||||
|
|
||||||
func reportPullProgress(ctx context.Context, report func(PullProgress) error, progress PullProgress) error {
|
|
||||||
if err := ctx.Err(); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if report != nil && report(progress) != nil {
|
|
||||||
return errors.New("MCP Docker progress connection closed")
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func normalizePullProgress(status, id string, current, total int64) (PullProgress, bool) {
|
|
||||||
var normalized string
|
|
||||||
switch status {
|
|
||||||
case "Pulling fs layer":
|
|
||||||
normalized = "pulling"
|
|
||||||
case "Waiting":
|
|
||||||
normalized = "waiting"
|
|
||||||
case "Downloading":
|
|
||||||
normalized = "downloading"
|
|
||||||
case "Verifying Checksum":
|
|
||||||
normalized = "verifying_checksum"
|
|
||||||
case "Download complete":
|
|
||||||
normalized = "download_complete"
|
|
||||||
case "Extracting":
|
|
||||||
normalized = "extracting"
|
|
||||||
case "Pull complete":
|
|
||||||
normalized = "pull_complete"
|
|
||||||
case "Already exists":
|
|
||||||
normalized = "already_exists"
|
|
||||||
default:
|
|
||||||
return PullProgress{}, false
|
|
||||||
}
|
|
||||||
if !pullLayerID.MatchString(id) {
|
|
||||||
id = ""
|
|
||||||
}
|
|
||||||
if current < 0 || total < 0 || (total > 0 && current > total) {
|
|
||||||
current, total = 0, 0
|
|
||||||
}
|
|
||||||
return PullProgress{Status: normalized, ID: id, Current: current, Total: total}, true
|
|
||||||
}
|
|
||||||
@@ -6,9 +6,8 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
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}$`
|
||||||
VMNameValidationPattern = `^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$`
|
VMCommonPattern = `^[a-zA-Z0-9][a-zA-Z0-9._:-]{0,63}$`
|
||||||
VMCommonPattern = `^[a-zA-Z0-9][a-zA-Z0-9._:-]{0,63}$`
|
|
||||||
|
|
||||||
VLLMMetricSamplePattern = `^([a-zA-Z_:][a-zA-Z0-9_:]*)(\{(?:[^"\\]|"(?:\\.|[^"\\])*")*\})?\s+(\S+)(?:\s+.*)?$`
|
VLLMMetricSamplePattern = `^([a-zA-Z_:][a-zA-Z0-9_:]*)(\{(?:[^"\\]|"(?:\\.|[^"\\])*")*\})?\s+(\S+)(?:\s+.*)?$`
|
||||||
VLLMMetricLabelPattern = `([a-zA-Z_][a-zA-Z0-9_]*)\s*=\s*("(?:[^"\\]|\\.)*")`
|
VLLMMetricLabelPattern = `([a-zA-Z_][a-zA-Z0-9_]*)\s*=\s*("(?:[^"\\]|\\.)*")`
|
||||||
@@ -67,7 +66,6 @@ var regexMap = make(map[string]*regexp.Regexp)
|
|||||||
|
|
||||||
func Init() {
|
func Init() {
|
||||||
patterns := []string{
|
patterns := []string{
|
||||||
ModelDownloaderRepoIDPattern,
|
|
||||||
VMNameValidationPattern,
|
VMNameValidationPattern,
|
||||||
VMCommonPattern,
|
VMCommonPattern,
|
||||||
|
|
||||||
|
|||||||
@@ -1,98 +0,0 @@
|
|||||||
// Package taskscope separates task visibility and executor ownership from task storage.
|
|
||||||
package taskscope
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"slices"
|
|
||||||
"sort"
|
|
||||||
"strings"
|
|
||||||
"sync/atomic"
|
|
||||||
)
|
|
||||||
|
|
||||||
type Rule struct {
|
|
||||||
Scope string
|
|
||||||
Restricted bool
|
|
||||||
ExternalExecutor bool
|
|
||||||
}
|
|
||||||
|
|
||||||
type policy struct{ rules []Rule }
|
|
||||||
|
|
||||||
var configured atomic.Pointer[policy]
|
|
||||||
|
|
||||||
var ErrUnavailable = errors.New("task scope policy is unavailable")
|
|
||||||
|
|
||||||
func Configure(rules ...Rule) error {
|
|
||||||
copyRules := append([]Rule(nil), rules...)
|
|
||||||
if len(copyRules) == 0 {
|
|
||||||
return ErrUnavailable
|
|
||||||
}
|
|
||||||
sort.Slice(copyRules, func(i, j int) bool { return copyRules[i].Scope < copyRules[j].Scope })
|
|
||||||
for i, rule := range copyRules {
|
|
||||||
if rule.Scope == "" || strings.TrimSpace(rule.Scope) != rule.Scope || strings.ContainsAny(rule.Scope, "\x00\r\n") ||
|
|
||||||
i > 0 && copyRules[i-1].Scope == rule.Scope {
|
|
||||||
return errors.New("invalid task scope policy")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
next := &policy{rules: copyRules}
|
|
||||||
if configured.CompareAndSwap(nil, next) {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if !slices.Equal(configured.Load().rules, next.rules) {
|
|
||||||
return errors.New("task scope policy is already configured")
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func ReadExclusions(granted ...string) ([]string, error) {
|
|
||||||
p := configured.Load()
|
|
||||||
if p == nil {
|
|
||||||
return nil, ErrUnavailable
|
|
||||||
}
|
|
||||||
allowed := make(map[string]bool, len(granted))
|
|
||||||
for _, scope := range granted {
|
|
||||||
found := false
|
|
||||||
for _, rule := range p.rules {
|
|
||||||
if rule.Scope == scope && rule.Restricted {
|
|
||||||
found = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !found || allowed[scope] {
|
|
||||||
return nil, errors.New("invalid task scope grant")
|
|
||||||
}
|
|
||||||
allowed[scope] = true
|
|
||||||
}
|
|
||||||
var excluded []string
|
|
||||||
for _, rule := range p.rules {
|
|
||||||
if rule.Restricted && !allowed[rule.Scope] {
|
|
||||||
excluded = append(excluded, rule.Scope)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return excluded, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func RecoveryExclusions() ([]string, error) {
|
|
||||||
p := configured.Load()
|
|
||||||
if p == nil {
|
|
||||||
return nil, ErrUnavailable
|
|
||||||
}
|
|
||||||
var excluded []string
|
|
||||||
for _, rule := range p.rules {
|
|
||||||
if rule.ExternalExecutor {
|
|
||||||
excluded = append(excluded, rule.Scope)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return excluded, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
type grantKey struct{}
|
|
||||||
|
|
||||||
func WithGrants(ctx context.Context, granted ...string) context.Context {
|
|
||||||
return context.WithValue(ctx, grantKey{}, append([]string(nil), granted...))
|
|
||||||
}
|
|
||||||
|
|
||||||
func Grants(ctx context.Context) []string {
|
|
||||||
granted, _ := ctx.Value(grantKey{}).([]string)
|
|
||||||
return append([]string(nil), granted...)
|
|
||||||
}
|
|
||||||
+2
-144
@@ -15,7 +15,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func bindAPIKeyRequest(c *gin.Context, value interface{}) bool {
|
func bindAPIKeyRequest(c *gin.Context, value interface{}) bool {
|
||||||
if _, err := auth.RequireAPIKeyManagement(c); err != nil {
|
if _, err := auth.RequireAPIKeySession(c); err != nil {
|
||||||
helper.BadAuth(c, "ErrNotLogin", nil)
|
helper.BadAuth(c, "ErrNotLogin", nil)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -41,24 +41,6 @@ func apiKeyError(c *gin.Context, err error) {
|
|||||||
helper.InternalServer(c, err)
|
helper.InternalServer(c, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Swag's legacy || syntax combines schemes in one requirement (AND);
|
|
||||||
// separate @Security lines represent alternative authentication methods.
|
|
||||||
// @Tags API Key
|
|
||||||
// @Summary List the current account's API keys
|
|
||||||
// @Description Accepts a panel login session with CSRF protection, or a valid API Key signature. The authenticated account determines ownership; another account cannot be selected in the request.
|
|
||||||
// @Description Returns key metadata and masked hints, never plaintext secrets. The legacy key is included when present; deleted keys are excluded.
|
|
||||||
// @Description Authentication and business errors use HTTP 200 with envelope code 400, 401 or 500; check code rather than HTTP status alone.
|
|
||||||
// @Accept json
|
|
||||||
// @Produce json
|
|
||||||
// @Param request body dto.APIKeySearch true "Pagination and revoked-key filter"
|
|
||||||
// @Param 1Panel-Key-ID header string false "Optional ID of the authenticating independent key; omit for legacy keys"
|
|
||||||
// @Param 1Panel-Signature-Version header string false "Signature algorithm; omission accepts either supported algorithm" Enums(v1,md5,hmac-sha256)
|
|
||||||
// @Success 200 {object} dto.Response{data=dto.APIKeyPage} "code=200 on success; data is null on errors"
|
|
||||||
// @Failure 403 {object} dto.Response "Invalid or missing CSRF token for a session request"
|
|
||||||
// @Header 200 {string} Cache-Control "no-store"
|
|
||||||
// @Security ApiKeyAuth || Timestamp
|
|
||||||
// @Security PanelSession || CSRFToken
|
|
||||||
// @Router /core/auth/api/keys/search [post]
|
|
||||||
func (b *BaseApi) SearchAPIKeys(c *gin.Context) {
|
func (b *BaseApi) SearchAPIKeys(c *gin.Context) {
|
||||||
var req dto.APIKeySearch
|
var req dto.APIKeySearch
|
||||||
if !bindAPIKeyRequest(c, &req) {
|
if !bindAPIKeyRequest(c, &req) {
|
||||||
@@ -73,25 +55,6 @@ func (b *BaseApi) SearchAPIKeys(c *gin.Context) {
|
|||||||
helper.SuccessWithData(c, result)
|
helper.SuccessWithData(c, result)
|
||||||
}
|
}
|
||||||
|
|
||||||
// @Tags API Key
|
|
||||||
// @Summary Create an API key for the current account
|
|
||||||
// @Description Accepts a panel login session with CSRF protection, or a valid API Key signature. The new key belongs to the authenticated account.
|
|
||||||
// @Description requestID is optional. Omitted or empty values are generated by the server. For idempotent retries, supply a UUID and reuse it with the same payload.
|
|
||||||
// @Description A successful first request returns the plaintext secret. Repeating the same requestID and payload returns alreadyCreated=true and an empty apiKey; changing the payload for that requestID is rejected.
|
|
||||||
// @Description Omitted expiresAt defaults to 90 days; explicit null means no expiration. Omitted apiKeyValidityTime defaults to 120 minutes; zero disables timestamp validation.
|
|
||||||
// @Description API-created keys may have a longer lifetime or broader IP policy than the calling key. Revoking the calling key does not revoke keys it created.
|
|
||||||
// @Description Authentication and business errors use HTTP 200 with envelope code 400, 401 or 500; check code rather than HTTP status alone. Unknown fields and bodies larger than 32 KiB are rejected.
|
|
||||||
// @Accept json
|
|
||||||
// @Produce json
|
|
||||||
// @Param request body dto.APIKeyCreate true "Key policy and an optional UUID requestID for idempotency"
|
|
||||||
// @Param 1Panel-Key-ID header string false "Optional ID of the authenticating independent key; omit for legacy keys"
|
|
||||||
// @Param 1Panel-Signature-Version header string false "Signature algorithm; omission accepts either supported algorithm" Enums(v1,md5,hmac-sha256)
|
|
||||||
// @Success 200 {object} dto.Response{data=dto.APIKeyCreated} "code=200 on success; data is null on errors"
|
|
||||||
// @Failure 403 {object} dto.Response "Invalid or missing CSRF token for a session request"
|
|
||||||
// @Header 200 {string} Cache-Control "no-store"
|
|
||||||
// @Security ApiKeyAuth || Timestamp
|
|
||||||
// @Security PanelSession || CSRFToken
|
|
||||||
// @Router /core/auth/api/keys/create [post]
|
|
||||||
func (b *BaseApi) CreateAPIKey(c *gin.Context) {
|
func (b *BaseApi) CreateAPIKey(c *gin.Context) {
|
||||||
req := dto.APIKeyCreate{APIKeyFields: dto.APIKeyFields{APIKeyValidityTime: 120}}
|
req := dto.APIKeyCreate{APIKeyFields: dto.APIKeyFields{APIKeyValidityTime: 120}}
|
||||||
if !bindAPIKeyRequest(c, &req) {
|
if !bindAPIKeyRequest(c, &req) {
|
||||||
@@ -106,22 +69,6 @@ func (b *BaseApi) CreateAPIKey(c *gin.Context) {
|
|||||||
helper.SuccessWithData(c, result)
|
helper.SuccessWithData(c, result)
|
||||||
}
|
}
|
||||||
|
|
||||||
// @Tags API Key
|
|
||||||
// @Summary Update an API key's policy
|
|
||||||
// @Description Accepts a panel login session with CSRF protection, or a valid API Key signature. Only keys owned by the authenticated account can be changed.
|
|
||||||
// @Description Submit the current revision returned by search; stale revisions are rejected. This replaces editable policy fields without changing the secret. expiresAt must be supplied as an RFC 3339 timestamp or null.
|
|
||||||
// @Description Use id=legacy for the legacy key; its name is fixed and expiresAt must be null. Revoked or deleted independent keys cannot be updated.
|
|
||||||
// @Description Success increments the revision; search again before another mutation. Authentication and business errors use HTTP 200 with envelope code 400, 401 or 500.
|
|
||||||
// @Accept json
|
|
||||||
// @Produce json
|
|
||||||
// @Param request body dto.APIKeyUpdate true "Key ID, current revision and complete editable policy"
|
|
||||||
// @Param 1Panel-Key-ID header string false "Optional ID of the authenticating independent key; omit for legacy keys"
|
|
||||||
// @Param 1Panel-Signature-Version header string false "Signature algorithm; omission accepts either supported algorithm" Enums(v1,md5,hmac-sha256)
|
|
||||||
// @Success 200 {object} dto.Response{data=dto.APIKeyMutationResult} "code=200 on success; data is null on errors"
|
|
||||||
// @Failure 403 {object} dto.Response "Invalid or missing CSRF token for a session request"
|
|
||||||
// @Security ApiKeyAuth || Timestamp
|
|
||||||
// @Security PanelSession || CSRFToken
|
|
||||||
// @Router /core/auth/api/keys/update [post]
|
|
||||||
func (b *BaseApi) UpdateAPIKey(c *gin.Context) {
|
func (b *BaseApi) UpdateAPIKey(c *gin.Context) {
|
||||||
var req dto.APIKeyUpdate
|
var req dto.APIKeyUpdate
|
||||||
if !bindAPIKeyRequest(c, &req) {
|
if !bindAPIKeyRequest(c, &req) {
|
||||||
@@ -134,21 +81,6 @@ func (b *BaseApi) UpdateAPIKey(c *gin.Context) {
|
|||||||
apiKeyMutationSuccess(c)
|
apiKeyMutationSuccess(c)
|
||||||
}
|
}
|
||||||
|
|
||||||
// @Tags API Key
|
|
||||||
// @Summary Enable or disable an API key
|
|
||||||
// @Description Accepts a panel login session with CSRF protection, or a valid API Key signature. Only keys owned by the authenticated account can be changed; id=legacy is supported.
|
|
||||||
// @Description Submit the current revision returned by search. Revoked or deleted keys cannot be enabled; expired keys must first receive a future expiration time. Disabling a key also closes its active terminal sessions.
|
|
||||||
// @Description Success increments the revision; search again before another mutation. Authentication and business errors use HTTP 200 with envelope code 400, 401 or 500.
|
|
||||||
// @Accept json
|
|
||||||
// @Produce json
|
|
||||||
// @Param request body dto.APIKeyStatus true "Key ID, current revision and Enable or Disable status"
|
|
||||||
// @Param 1Panel-Key-ID header string false "Optional ID of the authenticating independent key; omit for legacy keys"
|
|
||||||
// @Param 1Panel-Signature-Version header string false "Signature algorithm; omission accepts either supported algorithm" Enums(v1,md5,hmac-sha256)
|
|
||||||
// @Success 200 {object} dto.Response{data=dto.APIKeyMutationResult} "code=200 on success; data is null on errors"
|
|
||||||
// @Failure 403 {object} dto.Response "Invalid or missing CSRF token for a session request"
|
|
||||||
// @Security ApiKeyAuth || Timestamp
|
|
||||||
// @Security PanelSession || CSRFToken
|
|
||||||
// @Router /core/auth/api/keys/status [post]
|
|
||||||
func (b *BaseApi) SetAPIKeyStatus(c *gin.Context) {
|
func (b *BaseApi) SetAPIKeyStatus(c *gin.Context) {
|
||||||
var req dto.APIKeyStatus
|
var req dto.APIKeyStatus
|
||||||
if !bindAPIKeyRequest(c, &req) {
|
if !bindAPIKeyRequest(c, &req) {
|
||||||
@@ -161,22 +93,6 @@ func (b *BaseApi) SetAPIKeyStatus(c *gin.Context) {
|
|||||||
apiKeyMutationSuccess(c)
|
apiKeyMutationSuccess(c)
|
||||||
}
|
}
|
||||||
|
|
||||||
// @Tags API Key
|
|
||||||
// @Summary Revoke an API key
|
|
||||||
// @Description Accepts a panel login session with CSRF protection, or a valid API Key signature. Only keys owned by the authenticated account can be revoked.
|
|
||||||
// @Description Submit the current revision returned by search. Revocation destroys the independent key's stored secret and cannot be undone; its metadata remains listed. For id=legacy, the legacy secret is cleared and API access is disabled.
|
|
||||||
// @Description A key can revoke itself; subsequent requests signed with it fail. Keys previously created by the revoked key remain independent. Success increments the revision and closes associated terminal sessions.
|
|
||||||
// @Description Authentication and business errors use HTTP 200 with envelope code 400, 401 or 500.
|
|
||||||
// @Accept json
|
|
||||||
// @Produce json
|
|
||||||
// @Param request body dto.APIKeyMutation true "Key ID and current revision"
|
|
||||||
// @Param 1Panel-Key-ID header string false "Optional ID of the authenticating independent key; omit for legacy keys"
|
|
||||||
// @Param 1Panel-Signature-Version header string false "Signature algorithm; omission accepts either supported algorithm" Enums(v1,md5,hmac-sha256)
|
|
||||||
// @Success 200 {object} dto.Response{data=dto.APIKeyMutationResult} "code=200 on success; data is null on errors"
|
|
||||||
// @Failure 403 {object} dto.Response "Invalid or missing CSRF token for a session request"
|
|
||||||
// @Security ApiKeyAuth || Timestamp
|
|
||||||
// @Security PanelSession || CSRFToken
|
|
||||||
// @Router /core/auth/api/keys/revoke [post]
|
|
||||||
func (b *BaseApi) RevokeAPIKey(c *gin.Context) {
|
func (b *BaseApi) RevokeAPIKey(c *gin.Context) {
|
||||||
var req dto.APIKeyMutation
|
var req dto.APIKeyMutation
|
||||||
if !bindAPIKeyRequest(c, &req) {
|
if !bindAPIKeyRequest(c, &req) {
|
||||||
@@ -190,63 +106,5 @@ func (b *BaseApi) RevokeAPIKey(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func apiKeyMutationSuccess(c *gin.Context) {
|
func apiKeyMutationSuccess(c *gin.Context) {
|
||||||
helper.SuccessWithData(c, dto.APIKeyMutationResult{TerminalClosePending: c.GetBool("API_KEY_TERMINAL_CLOSE_PENDING")})
|
helper.SuccessWithData(c, gin.H{"terminalClosePending": c.GetBool("API_KEY_TERMINAL_CLOSE_PENDING")})
|
||||||
}
|
|
||||||
|
|
||||||
// @Tags API Key
|
|
||||||
// @Summary Delete an independent API key
|
|
||||||
// @Description Accepts a panel login session with CSRF protection, or a valid API Key signature. Only keys owned by the authenticated account can be deleted; id=legacy is not supported.
|
|
||||||
// @Description Submit the current revision returned by search. Deletion immediately revokes the key, destroys its stored secret and removes it from lists. Audit and idempotency records are retained. Already-revoked keys can be deleted; deletion cannot be undone.
|
|
||||||
// @Description A key can delete itself; subsequent requests signed with it fail. Authentication and business errors use HTTP 200 with envelope code 400, 401 or 500.
|
|
||||||
// @Accept json
|
|
||||||
// @Produce json
|
|
||||||
// @Param request body dto.APIKeyMutation true "Independent key ID and current revision"
|
|
||||||
// @Param 1Panel-Key-ID header string false "Optional ID of the authenticating independent key; omit for legacy keys"
|
|
||||||
// @Param 1Panel-Signature-Version header string false "Signature algorithm; omission accepts either supported algorithm" Enums(v1,md5,hmac-sha256)
|
|
||||||
// @Success 200 {object} dto.Response{data=dto.APIKeyMutationResult} "code=200 on success; data is null on errors"
|
|
||||||
// @Failure 403 {object} dto.Response "Invalid or missing CSRF token for a session request"
|
|
||||||
// @Security ApiKeyAuth || Timestamp
|
|
||||||
// @Security PanelSession || CSRFToken
|
|
||||||
// @Router /core/auth/api/keys/delete [post]
|
|
||||||
func (b *BaseApi) DeleteAPIKey(c *gin.Context) {
|
|
||||||
var req dto.APIKeyMutation
|
|
||||||
if !bindAPIKeyRequest(c, &req) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := service.NewAPIKeyService().Delete(c, req); err != nil {
|
|
||||||
apiKeyError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
apiKeyMutationSuccess(c)
|
|
||||||
}
|
|
||||||
|
|
||||||
// @Tags API Key
|
|
||||||
// @Summary Reveal an independent API key's plaintext secret
|
|
||||||
// @Description Requires a panel login session and CSRF token. API Key authentication is not supported, including requests that also contain a valid session cookie; omit all 1Panel authentication headers.
|
|
||||||
// @Description Only the current account's independent keys can be revealed. Submit the current revision returned by search. Legacy, revoked and deleted keys are rejected; disabled or expired keys can still be revealed.
|
|
||||||
// @Description Secret disclosure is audited. Authentication and business errors use HTTP 200 with envelope code 400, 401 or 500. The response must not be cached or logged.
|
|
||||||
// @Accept json
|
|
||||||
// @Produce json
|
|
||||||
// @Param request body dto.APIKeyMutation true "Independent key ID and current revision"
|
|
||||||
// @Success 200 {object} dto.Response{data=dto.APIKeyRevealed} "code=200 on success; data is null on errors"
|
|
||||||
// @Failure 403 {object} dto.Response "Invalid or missing CSRF token"
|
|
||||||
// @Header 200 {string} Cache-Control "no-store"
|
|
||||||
// @Security PanelSession || CSRFToken
|
|
||||||
// @Router /core/auth/api/keys/reveal [post]
|
|
||||||
func (b *BaseApi) RevealAPIKey(c *gin.Context) {
|
|
||||||
c.Header("Cache-Control", "no-store")
|
|
||||||
if _, err := auth.RequireAPIKeySession(c); err != nil {
|
|
||||||
helper.BadAuth(c, "ErrNotLogin", nil)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var req dto.APIKeyMutation
|
|
||||||
if !bindAPIKeyRequest(c, &req) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
result, err := service.NewAPIKeyService().Reveal(c, req)
|
|
||||||
if err != nil {
|
|
||||||
apiKeyError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
helper.SuccessWithData(c, result)
|
|
||||||
}
|
}
|
||||||
|
|||||||
+8
-11
@@ -417,13 +417,11 @@ func (b *BaseApi) MFAClose(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// @Tags Auth
|
// @Tags Auth
|
||||||
// @Summary Generate or reset the legacy API key
|
// @Summary generate api key
|
||||||
// @Description Requires a panel login session and CSRF token. API Key authentication is not supported. This replaces the current account's legacy secret; existing clients using the previous secret must be updated. Independent API keys are unaffected.
|
|
||||||
// @Accept json
|
// @Accept json
|
||||||
// @Produce json
|
// @Success 200 {string} key
|
||||||
// @Success 200 {object} dto.Response{data=string} "code=200 with the new secret; authentication and business errors use envelope code 400, 401 or 500"
|
// @Security ApiKeyAuth
|
||||||
// @Header 200 {string} Cache-Control "no-store"
|
// @Security Timestamp
|
||||||
// @Security PanelSession || CSRFToken
|
|
||||||
// @Router /core/auth/api/generate [post]
|
// @Router /core/auth/api/generate [post]
|
||||||
// @x-panel-log {"bodyKeys":[],"paramKeys":[],"BeforeFunctions":[],"formatZH":"生成 API 接口密钥","formatEN":"generate api key"}
|
// @x-panel-log {"bodyKeys":[],"paramKeys":[],"BeforeFunctions":[],"formatZH":"生成 API 接口密钥","formatEN":"generate api key"}
|
||||||
func (b *BaseApi) GenerateApiKey(c *gin.Context) {
|
func (b *BaseApi) GenerateApiKey(c *gin.Context) {
|
||||||
@@ -442,13 +440,12 @@ func (b *BaseApi) GenerateApiKey(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// @Tags Auth
|
// @Tags Auth
|
||||||
// @Summary Update the legacy API key configuration
|
// @Summary Update api config
|
||||||
// @Description Requires a panel login session and CSRF token. API Key authentication is not supported. This updates only the current account's legacy API configuration.
|
|
||||||
// @Accept json
|
// @Accept json
|
||||||
// @Produce json
|
|
||||||
// @Param request body dto.ApiInterfaceConfig true "request"
|
// @Param request body dto.ApiInterfaceConfig true "request"
|
||||||
// @Success 200 {object} dto.Response "code=200 on success; authentication and business errors use envelope code 400, 401 or 500"
|
// @Success 200
|
||||||
// @Security PanelSession || CSRFToken
|
// @Security ApiKeyAuth
|
||||||
|
// @Security Timestamp
|
||||||
// @Router /core/auth/api/update [post]
|
// @Router /core/auth/api/update [post]
|
||||||
// @x-panel-log {"bodyKeys":["ipWhiteList","apiTrustedProxies"],"paramKeys":[],"BeforeFunctions":[],"formatZH":"更新 API 接口配置 => IP 白名单: [ipWhiteList], API 可信代理: [apiTrustedProxies]","formatEN":"update api config => IP Allowlist: [ipWhiteList], API Trusted Proxies: [apiTrustedProxies]"}
|
// @x-panel-log {"bodyKeys":["ipWhiteList","apiTrustedProxies"],"paramKeys":[],"BeforeFunctions":[],"formatZH":"更新 API 接口配置 => IP 白名单: [ipWhiteList], API 可信代理: [apiTrustedProxies]","formatEN":"update api config => IP Allowlist: [ipWhiteList], API Trusted Proxies: [apiTrustedProxies]"}
|
||||||
func (b *BaseApi) UpdateApiConfig(c *gin.Context) {
|
func (b *BaseApi) UpdateApiConfig(c *gin.Context) {
|
||||||
|
|||||||
+3
-63
@@ -1,7 +1,6 @@
|
|||||||
package v2
|
package v2
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
@@ -33,41 +32,6 @@ func mcpRead(c *gin.Context, value any) bool {
|
|||||||
if json.Unmarshal(data, &fields) != nil || len(fields.ExpiresAt) == 0 {
|
if json.Unmarshal(data, &fields) != nil || len(fields.ExpiresAt) == 0 {
|
||||||
err = contract.Invalid()
|
err = contract.Invalid()
|
||||||
}
|
}
|
||||||
case *dto.MCPWebsiteBind, *dto.MCPWebsiteUpdate:
|
|
||||||
var fields struct {
|
|
||||||
EnableSSL json.RawMessage `json:"enableSSL"`
|
|
||||||
SSLID json.RawMessage `json:"sslID"`
|
|
||||||
}
|
|
||||||
if json.Unmarshal(data, &fields) != nil || len(fields.EnableSSL) == 0 || len(fields.SSLID) == 0 ||
|
|
||||||
bytes.Equal(bytes.TrimSpace(fields.EnableSSL), []byte("null")) || bytes.Equal(bytes.TrimSpace(fields.SSLID), []byte("null")) {
|
|
||||||
err = contract.Invalid()
|
|
||||||
}
|
|
||||||
case *dto.MCPRuntimeInstall:
|
|
||||||
var fields struct {
|
|
||||||
AccessPolicy json.RawMessage `json:"accessPolicy"`
|
|
||||||
AllowedIPs json.RawMessage `json:"allowedIPs"`
|
|
||||||
Website *struct {
|
|
||||||
Domain json.RawMessage `json:"domain"`
|
|
||||||
EnableSSL json.RawMessage `json:"enableSSL"`
|
|
||||||
SSLID json.RawMessage `json:"sslID"`
|
|
||||||
} `json:"website"`
|
|
||||||
}
|
|
||||||
if json.Unmarshal(data, &fields) != nil {
|
|
||||||
err = contract.Invalid()
|
|
||||||
} else {
|
|
||||||
for _, field := range []json.RawMessage{fields.AccessPolicy, fields.AllowedIPs} {
|
|
||||||
if len(field) == 0 || bytes.Equal(bytes.TrimSpace(field), []byte("null")) {
|
|
||||||
err = contract.Invalid()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if fields.Website != nil {
|
|
||||||
for _, field := range []json.RawMessage{fields.Website.Domain, fields.Website.EnableSSL, fields.Website.SSLID} {
|
|
||||||
if len(field) == 0 || bytes.Equal(bytes.TrimSpace(field), []byte("null")) {
|
|
||||||
err = contract.Invalid()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -215,21 +179,6 @@ func (b *BaseApi) RevokeMCPCredential(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
helper.Success(c)
|
helper.Success(c)
|
||||||
}
|
}
|
||||||
func (b *BaseApi) DeleteMCPCredential(c *gin.Context) {
|
|
||||||
s, owner, ok := mcpManagement(c, true, false)
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var req dto.MCPCredentialMutation
|
|
||||||
if !mcpRead(c, &req) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := s.Delete(owner, req); err != nil {
|
|
||||||
mcpManagementError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
helper.Success(c)
|
|
||||||
}
|
|
||||||
func (b *BaseApi) SearchMCPInvocations(c *gin.Context) {
|
func (b *BaseApi) SearchMCPInvocations(c *gin.Context) {
|
||||||
s, owner, ok := mcpManagement(c, false, false)
|
s, owner, ok := mcpManagement(c, false, false)
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -258,17 +207,8 @@ func (b *BaseApi) MCPServiceStatus(c *gin.Context) {
|
|||||||
mcpManagementError(c, err)
|
mcpManagementError(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if result.Website == nil {
|
result.Endpoint = mcpPublicEndpoint(c, result.Endpoint)
|
||||||
result.Endpoint = mcpPublicEndpoint(c, result.Endpoint)
|
|
||||||
}
|
|
||||||
result.CanManageService = owner.IsSuperAdmin
|
result.CanManageService = owner.IsSuperAdmin
|
||||||
if owner.IsSuperAdmin {
|
|
||||||
result.InstallTaskID, result.Installing, err = service.NewMCPRuntimeService().InstallationTask(ctx)
|
|
||||||
if err != nil {
|
|
||||||
mcpManagementError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
helper.SuccessWithData(c, result)
|
helper.SuccessWithData(c, result)
|
||||||
}
|
}
|
||||||
func (b *BaseApi) MCPSettings(c *gin.Context) {
|
func (b *BaseApi) MCPSettings(c *gin.Context) {
|
||||||
@@ -328,9 +268,9 @@ func (b *BaseApi) InstallMCPService(c *gin.Context) {
|
|||||||
if !mcpRead(c, &req) {
|
if !mcpRead(c, &req) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second)
|
ctx, cancel := context.WithTimeout(c.Request.Context(), 6*time.Minute)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
result, err := service.NewMCPRuntimeService().SubmitInstall(ctx, req)
|
result, err := service.NewMCPRuntimeService().Install(ctx, req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
mcpServiceError(c, err)
|
mcpServiceError(c, err)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -55,16 +55,7 @@ func (b *BaseApi) ExportMCPCredential(c *gin.Context) {
|
|||||||
mcpManagementError(c, err)
|
mcpManagementError(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
endpoint := mcpPublicEndpoint(c, settings.ExternalURL)
|
result, err := s.ExportMarkdown(owner, req, mcpPublicEndpoint(c, settings.ExternalURL), c.GetHeader("Accept-Language"))
|
||||||
website, err := service.NewMCPRuntimeService().WebsiteStatus(c.Request.Context())
|
|
||||||
if err != nil {
|
|
||||||
mcpManagementError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if website.State != "unbound" {
|
|
||||||
endpoint = website.Endpoint
|
|
||||||
}
|
|
||||||
result, err := s.ExportMarkdown(owner, req, endpoint, c.GetHeader("Accept-Language"))
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
mcpManagementError(c, err)
|
mcpManagementError(c, err)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -31,12 +31,6 @@ func mcpManagementMessage(code string) string {
|
|||||||
key = "ErrMCPAuditUnavailable"
|
key = "ErrMCPAuditUnavailable"
|
||||||
case "MCP_ENDPOINT_UNAVAILABLE":
|
case "MCP_ENDPOINT_UNAVAILABLE":
|
||||||
key = "ErrMCPEndpointUnavailable"
|
key = "ErrMCPEndpointUnavailable"
|
||||||
case "MCP_WEBSITE_PENDING":
|
|
||||||
key = "ErrMCPWebsitePending"
|
|
||||||
case "MCP_WEBSITE_UNAVAILABLE":
|
|
||||||
key = "ErrMCPWebsiteUnavailable"
|
|
||||||
case "MCP_WEBSITE_SERVICE_REQUIRED":
|
|
||||||
key = "ErrMCPWebsiteServiceRequired"
|
|
||||||
}
|
}
|
||||||
return i18n.GetMsgWithMap(key, nil)
|
return i18n.GetMsgWithMap(key, nil)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,84 +0,0 @@
|
|||||||
package v2
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/1Panel-dev/1Panel/core/app/api/v2/helper"
|
|
||||||
"github.com/1Panel-dev/1Panel/core/app/dto"
|
|
||||||
"github.com/1Panel-dev/1Panel/core/app/service"
|
|
||||||
contract "github.com/1Panel-dev/1Panel/core/utils/mcp"
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
)
|
|
||||||
|
|
||||||
func (b *BaseApi) MCPWebsiteStatus(c *gin.Context) {
|
|
||||||
if _, _, ok := mcpManagement(c, false, true); !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if c.Request.URL.RawQuery != "" {
|
|
||||||
mcpManagementError(c, contract.Invalid())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
result, err := service.NewMCPRuntimeService().WebsiteStatus(ctx)
|
|
||||||
if err != nil {
|
|
||||||
mcpManagementError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
helper.SuccessWithData(c, result)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (b *BaseApi) BindMCPWebsite(c *gin.Context) {
|
|
||||||
if _, _, ok := mcpManagement(c, true, true); !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var req dto.MCPWebsiteBind
|
|
||||||
if !mcpRead(c, &req) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
ctx, cancel := context.WithTimeout(context.WithoutCancel(c.Request.Context()), 90*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
result, err := service.NewMCPRuntimeService().BindWebsite(ctx, req)
|
|
||||||
if err != nil {
|
|
||||||
mcpManagementError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
helper.SuccessWithData(c, result)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (b *BaseApi) UpdateMCPWebsite(c *gin.Context) {
|
|
||||||
if _, _, ok := mcpManagement(c, true, true); !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var req dto.MCPWebsiteUpdate
|
|
||||||
if !mcpRead(c, &req) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
ctx, cancel := context.WithTimeout(context.WithoutCancel(c.Request.Context()), 90*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
result, err := service.NewMCPRuntimeService().UpdateWebsite(ctx, req)
|
|
||||||
if err != nil {
|
|
||||||
mcpManagementError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
helper.SuccessWithData(c, result)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (b *BaseApi) UnbindMCPWebsite(c *gin.Context) {
|
|
||||||
if _, _, ok := mcpManagement(c, true, true); !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var req dto.MCPWebsiteUnbind
|
|
||||||
if !mcpRead(c, &req) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
ctx, cancel := context.WithTimeout(context.WithoutCancel(c.Request.Context()), 90*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
result, err := service.NewMCPRuntimeService().UnbindWebsite(ctx, req)
|
|
||||||
if err != nil {
|
|
||||||
mcpManagementError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
helper.SuccessWithData(c, result)
|
|
||||||
}
|
|
||||||
@@ -7,7 +7,6 @@ import (
|
|||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"errors"
|
"errors"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -43,7 +42,7 @@ func APIAuthMiddleware(loadConfig APIAuthConfigLoader, onSuccess APIAuthSuccessH
|
|||||||
ownerProvider = ownerProviders[0]
|
ownerProvider = ownerProviders[0]
|
||||||
}
|
}
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
if strings.HasPrefix(c.Request.URL.Path, "/api/v2/core/auth") && !isAPIKeyManagementRequest(c) {
|
if strings.HasPrefix(c.Request.URL.Path, "/api/v2/core/auth") {
|
||||||
c.Next()
|
c.Next()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -98,20 +97,6 @@ func APIAuthMiddleware(loadConfig APIAuthConfigLoader, onSuccess APIAuthSuccessH
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func isAPIKeyManagementRequest(c *gin.Context) bool {
|
|
||||||
if c.Request.Method != http.MethodPost {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
switch c.Request.URL.Path {
|
|
||||||
case "/api/v2/core/auth/api/keys/search", "/api/v2/core/auth/api/keys/create",
|
|
||||||
"/api/v2/core/auth/api/keys/update", "/api/v2/core/auth/api/keys/status",
|
|
||||||
"/api/v2/core/auth/api/keys/revoke", "/api/v2/core/auth/api/keys/delete":
|
|
||||||
return true
|
|
||||||
default:
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func LoadAPIAuthConfig(_ *gin.Context) (APIAuthConfig, error) {
|
func LoadAPIAuthConfig(_ *gin.Context) (APIAuthConfig, error) {
|
||||||
settingRepo := repo.NewISettingRepo()
|
settingRepo := repo.NewISettingRepo()
|
||||||
config := APIAuthConfig{}
|
config := APIAuthConfig{}
|
||||||
|
|||||||
@@ -66,26 +66,6 @@ func RequireAPIKeySession(c *gin.Context) (psession.SessionUser, error) {
|
|||||||
return u, nil
|
return u, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func RequireAPIKeyManagement(c *gin.Context) (psession.SessionUser, error) {
|
|
||||||
if c == nil || !c.GetBool("API_AUTH") {
|
|
||||||
return RequireAPIKeySession(c)
|
|
||||||
}
|
|
||||||
if c.GetBool("LOCAL_REQUEST") {
|
|
||||||
return psession.SessionUser{}, buserr.New("ErrNotLogin")
|
|
||||||
}
|
|
||||||
kind, keyID := c.GetString("API_AUTH_KEY_KIND"), c.GetString("API_AUTH_KEY_ID")
|
|
||||||
if (kind != "apiKey" && kind != "legacy") || keyID == "" || (kind == "legacy") != (keyID == "legacy") {
|
|
||||||
return psession.SessionUser{}, buserr.New("ErrNotLogin")
|
|
||||||
}
|
|
||||||
ownerType, ownerID := c.GetString("API_AUTH_OWNER_TYPE"), c.GetString("API_AUTH_OWNER_ID")
|
|
||||||
value, _ := c.Get(psession.GinContextSessionUserKey)
|
|
||||||
u, ok := value.(psession.SessionUser)
|
|
||||||
if (ownerType != APIKeyOwnerPanelAdmin && ownerType != APIKeyOwnerEnterpriseUser) || ownerID == "" || !ok || u.ID != ownerID || u.Name == "" {
|
|
||||||
return psession.SessionUser{}, buserr.New("ErrNotLogin")
|
|
||||||
}
|
|
||||||
return u, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func HasAPICredentials(c *gin.Context) bool {
|
func HasAPICredentials(c *gin.Context) bool {
|
||||||
return c.GetHeader("1Panel-Token") != "" || c.GetHeader("1Panel-Timestamp") != "" || c.GetHeader("1Panel-Key-ID") != "" || c.GetHeader("1Panel-Signature-Version") != ""
|
return c.GetHeader("1Panel-Token") != "" || c.GetHeader("1Panel-Timestamp") != "" || c.GetHeader("1Panel-Key-ID") != "" || c.GetHeader("1Panel-Signature-Version") != ""
|
||||||
}
|
}
|
||||||
@@ -107,7 +87,7 @@ func LegacyAPIKeyFingerprint(owner APIKeyOwner, config APIAuthConfig, policy mod
|
|||||||
}
|
}
|
||||||
|
|
||||||
func APIKeySecret(key model.APIKey) (string, error) {
|
func APIKeySecret(key model.APIKey) (string, error) {
|
||||||
if key.DeletedAt != nil || key.Status == "Revoked" || key.SecretVersion != 1 || key.SecretCiphertext == "" {
|
if key.SecretVersion != 1 || key.SecretCiphertext == "" {
|
||||||
return "", buserr.New("ErrApiConfigKeyInvalid")
|
return "", buserr.New("ErrApiConfigKeyInvalid")
|
||||||
}
|
}
|
||||||
master, err := encrypt.APIKeyEncryptionKey()
|
master, err := encrypt.APIKeyEncryptionKey()
|
||||||
@@ -118,7 +98,7 @@ func APIKeySecret(key model.APIKey) (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func ValidateAPIKeyState(key model.APIKey, now time.Time) error {
|
func ValidateAPIKeyState(key model.APIKey, now time.Time) error {
|
||||||
if key.DeletedAt != nil || key.Status != constant.StatusEnable {
|
if key.Status != constant.StatusEnable {
|
||||||
return buserr.New("ErrApiConfigStatusInvalid")
|
return buserr.New("ErrApiConfigStatusInvalid")
|
||||||
}
|
}
|
||||||
if key.ExpiresAt != nil && !now.Before(*key.ExpiresAt) {
|
if key.ExpiresAt != nil && !now.Before(*key.ExpiresAt) {
|
||||||
@@ -133,7 +113,7 @@ func configForAPIKey(key model.APIKey, secret string, owner APIKeyOwner) APIAuth
|
|||||||
|
|
||||||
func loadMultiAPIKeyConfig(c *gin.Context, legacy APIAuthConfigLoader, provider APIKeyOwnerProvider) (APIAuthConfig, error) {
|
func loadMultiAPIKeyConfig(c *gin.Context, legacy APIAuthConfigLoader, provider APIKeyOwnerProvider) (APIAuthConfig, error) {
|
||||||
keyID := c.GetHeader("1Panel-Key-ID")
|
keyID := c.GetHeader("1Panel-Key-ID")
|
||||||
q := global.DB.Model(&model.APIKey{}).Where("deleted_at IS NULL").Select("id", "secret_ciphertext", "secret_version", "revision")
|
q := global.DB.Model(&model.APIKey{}).Select("id", "secret_ciphertext", "secret_version", "revision")
|
||||||
if keyID != "" {
|
if keyID != "" {
|
||||||
q = q.Where("id = ?", keyID)
|
q = q.Where("id = ?", keyID)
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
+27
-35
@@ -6,42 +6,41 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type APIKeyItem struct {
|
type APIKeyItem struct {
|
||||||
ID string `json:"id" example:"8a8f0f77-1ec8-4d46-8f5a-0ea8807e2d8a"`
|
ID string `json:"id"`
|
||||||
Kind string `json:"kind" enums:"apiKey,legacy" example:"apiKey"`
|
Kind string `json:"kind"`
|
||||||
Name string `json:"name" example:"Deployment automation"`
|
Name string `json:"name"`
|
||||||
Description string `json:"description" example:"Used by the deployment service"`
|
Description string `json:"description"`
|
||||||
KeyHint string `json:"keyHint" example:"abcd••••1234"`
|
KeyHint string `json:"keyHint"`
|
||||||
Status string `json:"status" enums:"Enable,Disable,Revoked,Expired" example:"Enable"`
|
Status string `json:"status"`
|
||||||
IPWhiteList string `json:"ipWhiteList" example:"192.0.2.10"`
|
IPWhiteList string `json:"ipWhiteList"`
|
||||||
APITrustedProxies string `json:"apiTrustedProxies" example:"192.0.2.1"`
|
APITrustedProxies string `json:"apiTrustedProxies"`
|
||||||
APIKeyValidityTime int `json:"apiKeyValidityTime" example:"120"`
|
APIKeyValidityTime int `json:"apiKeyValidityTime"`
|
||||||
ExpiresAt *time.Time `json:"expiresAt" format:"date-time" extensions:"x-nullable"`
|
ExpiresAt *time.Time `json:"expiresAt"`
|
||||||
AllowAppBinding bool `json:"allowAppBinding" example:"false"`
|
AllowAppBinding bool `json:"allowAppBinding"`
|
||||||
Revision uint64 `json:"revision" minimum:"1" example:"1"`
|
Revision uint64 `json:"revision"`
|
||||||
CreatedAt *time.Time `json:"createdAt" format:"date-time" extensions:"x-nullable"`
|
CreatedAt *time.Time `json:"createdAt"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type APIKeySearch struct {
|
type APIKeySearch struct {
|
||||||
Page int `json:"page" minimum:"1" example:"1"`
|
Page int `json:"page"`
|
||||||
PageSize int `json:"pageSize" minimum:"1" maximum:"100" example:"20"`
|
PageSize int `json:"pageSize"`
|
||||||
ExcludeRevoked bool `json:"excludeRevoked" default:"false"`
|
ExcludeRevoked bool `json:"excludeRevoked"`
|
||||||
}
|
}
|
||||||
type APIKeyPage struct {
|
type APIKeyPage struct {
|
||||||
Items []APIKeyItem `json:"items"`
|
Items []APIKeyItem `json:"items"`
|
||||||
Total int `json:"total"`
|
Total int `json:"total"`
|
||||||
}
|
}
|
||||||
type APIKeyFields struct {
|
type APIKeyFields struct {
|
||||||
Name string `json:"name" minLength:"1" maxLength:"64" example:"Deployment automation"`
|
Name string `json:"name"`
|
||||||
Description string `json:"description" maxLength:"256" example:"Used by the deployment service"`
|
Description string `json:"description"`
|
||||||
IPWhiteList string `json:"ipWhiteList" maxLength:"4096" example:"192.0.2.10"`
|
IPWhiteList string `json:"ipWhiteList"`
|
||||||
APITrustedProxies string `json:"apiTrustedProxies" maxLength:"4096" example:"192.0.2.1"`
|
APITrustedProxies string `json:"apiTrustedProxies"`
|
||||||
APIKeyValidityTime int `json:"apiKeyValidityTime" minimum:"0" maximum:"1440" example:"120"`
|
APIKeyValidityTime int `json:"apiKeyValidityTime"`
|
||||||
ExpiresAt json.RawMessage `json:"expiresAt" swaggertype:"string" format:"date-time" extensions:"x-nullable" example:"2030-01-01T00:00:00Z"`
|
ExpiresAt json.RawMessage `json:"expiresAt"`
|
||||||
AllowAppBinding bool `json:"allowAppBinding" default:"false"`
|
AllowAppBinding bool `json:"allowAppBinding"`
|
||||||
}
|
}
|
||||||
type APIKeyCreate struct {
|
type APIKeyCreate struct {
|
||||||
// Optional client-generated UUID for idempotent retries. Omitted or empty values are generated by the server.
|
RequestID string `json:"requestID"`
|
||||||
RequestID string `json:"requestID" format:"uuid" example:"df1a75a3-5f56-4fa3-9e7a-a573a853d894"`
|
|
||||||
APIKeyFields
|
APIKeyFields
|
||||||
}
|
}
|
||||||
type APIKeyCreated struct {
|
type APIKeyCreated struct {
|
||||||
@@ -50,15 +49,8 @@ type APIKeyCreated struct {
|
|||||||
AlreadyCreated bool `json:"alreadyCreated,omitempty"`
|
AlreadyCreated bool `json:"alreadyCreated,omitempty"`
|
||||||
}
|
}
|
||||||
type APIKeyMutation struct {
|
type APIKeyMutation struct {
|
||||||
ID string `json:"id" example:"8a8f0f77-1ec8-4d46-8f5a-0ea8807e2d8a"`
|
ID string `json:"id"`
|
||||||
Revision uint64 `json:"revision" minimum:"1" example:"1"`
|
Revision uint64 `json:"revision"`
|
||||||
}
|
|
||||||
type APIKeyMutationResult struct {
|
|
||||||
TerminalClosePending bool `json:"terminalClosePending"`
|
|
||||||
}
|
|
||||||
type APIKeyRevealed struct {
|
|
||||||
APIKeyMutation
|
|
||||||
APIKey string `json:"apiKey"`
|
|
||||||
}
|
}
|
||||||
type APIKeyUpdate struct {
|
type APIKeyUpdate struct {
|
||||||
APIKeyMutation
|
APIKeyMutation
|
||||||
@@ -66,5 +58,5 @@ type APIKeyUpdate struct {
|
|||||||
}
|
}
|
||||||
type APIKeyStatus struct {
|
type APIKeyStatus struct {
|
||||||
APIKeyMutation
|
APIKeyMutation
|
||||||
Status string `json:"status" enums:"Enable,Disable" example:"Disable"`
|
Status string `json:"status"`
|
||||||
}
|
}
|
||||||
|
|||||||
+19
-35
@@ -14,44 +14,28 @@ type MCPRuntimeSettings struct {
|
|||||||
type MCPRuntimeSettingsUpdate = MCPRuntimeSettings
|
type MCPRuntimeSettingsUpdate = MCPRuntimeSettings
|
||||||
|
|
||||||
type MCPRuntimeInstall struct {
|
type MCPRuntimeInstall struct {
|
||||||
DockerImage string `json:"dockerImage"`
|
DockerImage string `json:"dockerImage"`
|
||||||
ListenPort int `json:"listenPort"`
|
ListenPort int `json:"listenPort"`
|
||||||
ExternalURL string `json:"externalUrl"`
|
ExternalURL string `json:"externalUrl"`
|
||||||
Revision uint64 `json:"revision"`
|
Revision uint64 `json:"revision"`
|
||||||
AccessPolicy string `json:"accessPolicy"`
|
|
||||||
AllowedIPs []string `json:"allowedIPs"`
|
|
||||||
Website *MCPRuntimeInstallWebsite `json:"website,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type MCPRuntimeInstallWebsite struct {
|
|
||||||
Domain string `json:"domain"`
|
|
||||||
EnableSSL bool `json:"enableSSL"`
|
|
||||||
SSLID uint `json:"sslID"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type MCPRuntimeInstallTask struct {
|
|
||||||
TaskID string `json:"taskID"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type MCPRuntimeStatus struct {
|
type MCPRuntimeStatus struct {
|
||||||
RuntimeType string `json:"runtimeType"`
|
RuntimeType string `json:"runtimeType"`
|
||||||
DockerImage string `json:"dockerImage"`
|
DockerImage string `json:"dockerImage"`
|
||||||
ContainerName string `json:"containerName"`
|
ContainerName string `json:"containerName"`
|
||||||
ImageID string `json:"imageId"`
|
ImageID string `json:"imageId"`
|
||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
Installed bool `json:"installed"`
|
Installed bool `json:"installed"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
Ready bool `json:"ready"`
|
Ready bool `json:"ready"`
|
||||||
Reason string `json:"reason"`
|
Reason string `json:"reason"`
|
||||||
AdapterVersion string `json:"adapterVersion"`
|
AdapterVersion string `json:"adapterVersion"`
|
||||||
ContractVersion int `json:"contractVersion"`
|
ContractVersion int `json:"contractVersion"`
|
||||||
Protocol string `json:"protocol"`
|
Protocol string `json:"protocol"`
|
||||||
InternalEndpoint string `json:"internalEndpoint"`
|
InternalEndpoint string `json:"internalEndpoint"`
|
||||||
Endpoint string `json:"endpoint"`
|
Endpoint string `json:"endpoint"`
|
||||||
CanManageService bool `json:"canManageService"`
|
CanManageService bool `json:"canManageService"`
|
||||||
InstallTaskID string `json:"installTaskID,omitempty"`
|
|
||||||
Installing bool `json:"installing"`
|
|
||||||
Website *MCPWebsiteStatus `json:"website,omitempty"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type MCPRuntimeLogs struct {
|
type MCPRuntimeLogs struct {
|
||||||
|
|||||||
@@ -1,43 +0,0 @@
|
|||||||
package dto
|
|
||||||
|
|
||||||
type MCPWebsiteBind struct {
|
|
||||||
RequestID string `json:"requestId"`
|
|
||||||
Revision uint64 `json:"revision"`
|
|
||||||
Domain string `json:"domain"`
|
|
||||||
EnableSSL bool `json:"enableSSL"`
|
|
||||||
SSLID uint `json:"sslID"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type MCPWebsiteUpdate struct {
|
|
||||||
RequestID string `json:"requestId"`
|
|
||||||
Revision uint64 `json:"revision"`
|
|
||||||
EnableSSL bool `json:"enableSSL"`
|
|
||||||
SSLID uint `json:"sslID"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type MCPWebsiteUnbind struct {
|
|
||||||
RequestID string `json:"requestId"`
|
|
||||||
Revision uint64 `json:"revision"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type MCPWebsitePendingRequest struct {
|
|
||||||
Operation string `json:"operation"`
|
|
||||||
RequestID string `json:"requestId"`
|
|
||||||
Domain string `json:"domain"`
|
|
||||||
EnableSSL bool `json:"enableSSL"`
|
|
||||||
SSLID uint `json:"sslID"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type MCPWebsiteStatus struct {
|
|
||||||
State string `json:"state"`
|
|
||||||
WebsiteID uint `json:"websiteId"`
|
|
||||||
Domain string `json:"domain"`
|
|
||||||
Endpoint string `json:"endpoint"`
|
|
||||||
EnableSSL bool `json:"enableSSL"`
|
|
||||||
SSLID uint `json:"sslID"`
|
|
||||||
WebsiteStatus string `json:"websiteStatus"`
|
|
||||||
RequestID string `json:"requestId"`
|
|
||||||
Revision uint64 `json:"revision"`
|
|
||||||
Reason string `json:"reason,omitempty"`
|
|
||||||
PendingRequest *MCPWebsitePendingRequest `json:"pendingRequest,omitempty"`
|
|
||||||
}
|
|
||||||
@@ -24,7 +24,6 @@ type APIKey struct {
|
|||||||
CreatedAt time.Time `json:"-"`
|
CreatedAt time.Time `json:"-"`
|
||||||
UpdatedAt time.Time `json:"-"`
|
UpdatedAt time.Time `json:"-"`
|
||||||
RevokedAt *time.Time `json:"-"`
|
RevokedAt *time.Time `json:"-"`
|
||||||
DeletedAt *time.Time `json:"-"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type LegacyAPIKeyPolicy struct {
|
type LegacyAPIKeyPolicy struct {
|
||||||
|
|||||||
+9
-12
@@ -52,16 +52,13 @@ type MCPInvocation struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type MCPSetting struct {
|
type MCPSetting struct {
|
||||||
RuntimeType string `gorm:"not null;default:docker"`
|
RuntimeType string `gorm:"not null;default:docker"`
|
||||||
DockerImage string `gorm:"not null;default:1panel/mcp-server:v1.0.0"`
|
DockerImage string `gorm:"not null;default:1panel/1panel-mcp-server:v0.0.1"`
|
||||||
ID uint `gorm:"primaryKey"`
|
ID uint `gorm:"primaryKey"`
|
||||||
Enabled bool `gorm:"not null;default:false"`
|
Enabled bool `gorm:"not null;default:false"`
|
||||||
ListenPort int `gorm:"not null;default:18081"`
|
ListenPort int `gorm:"not null;default:18081"`
|
||||||
ExternalURL string
|
ExternalURL string
|
||||||
AccessPolicy string `gorm:"not null;default:inherit"`
|
AccessPolicy string `gorm:"not null;default:inherit"`
|
||||||
AllowedIPs string `gorm:"type:text;not null;default:'[]'"`
|
AllowedIPs string `gorm:"type:text;not null;default:'[]'"`
|
||||||
Revision uint64 `gorm:"not null;default:1"`
|
Revision uint64 `gorm:"not null;default:1"`
|
||||||
WebsiteID uint `gorm:"not null;default:0"`
|
|
||||||
WebsiteBindingID string `gorm:"not null;default:''"`
|
|
||||||
WebsiteOperation string `gorm:"type:text;not null;default:''"`
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,12 +14,12 @@ func APIKeyOwnerQuery(db *gorm.DB, ownerType, ownerID string) *gorm.DB {
|
|||||||
}
|
}
|
||||||
func (r *APIKeyRepo) Get(id string) (model.APIKey, error) {
|
func (r *APIKeyRepo) Get(id string) (model.APIKey, error) {
|
||||||
var key model.APIKey
|
var key model.APIKey
|
||||||
err := global.DB.Where("id = ? AND deleted_at IS NULL", id).First(&key).Error
|
err := global.DB.Where("id = ?", id).First(&key).Error
|
||||||
return key, err
|
return key, err
|
||||||
}
|
}
|
||||||
func (r *APIKeyRepo) List(ownerType, ownerID string, excludeRevoked bool) ([]model.APIKey, error) {
|
func (r *APIKeyRepo) List(ownerType, ownerID string, excludeRevoked bool) ([]model.APIKey, error) {
|
||||||
items := make([]model.APIKey, 0)
|
items := make([]model.APIKey, 0)
|
||||||
q := APIKeyOwnerQuery(global.DB, ownerType, ownerID).Where("deleted_at IS NULL")
|
q := APIKeyOwnerQuery(global.DB, ownerType, ownerID)
|
||||||
if excludeRevoked {
|
if excludeRevoked {
|
||||||
q = q.Where("status <> ?", "Revoked")
|
q = q.Where("status <> ?", "Revoked")
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-169
@@ -1,89 +1,12 @@
|
|||||||
package repo
|
package repo
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/1Panel-dev/1Panel/core/app/model"
|
"github.com/1Panel-dev/1Panel/core/app/model"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
"gorm.io/gorm/clause"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const mcpInvocationSecond = "CAST(strftime('%s', substr(started_at, 1, 19) || substr(started_at, -6)) AS INTEGER)"
|
|
||||||
const mcpInvocationNanosecond = "CASE WHEN substr(started_at, 20, 1) = '.' THEN CAST(substr(substr(started_at, 21, length(started_at) - 26) || '000000000', 1, 9) AS INTEGER) ELSE 0 END"
|
|
||||||
|
|
||||||
type MCPRepo struct{ DB *gorm.DB }
|
type MCPRepo struct{ DB *gorm.DB }
|
||||||
|
|
||||||
func mcpOwnerQuery(db *gorm.DB, ownerType, ownerID string) *gorm.DB {
|
|
||||||
return db.Where("owner_type = ? AND owner_id = ?", ownerType, ownerID)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r MCPRepo) Credential(ownerType, ownerID, id string) (model.MCPCredential, error) {
|
|
||||||
var key model.MCPCredential
|
|
||||||
err := mcpOwnerQuery(r.DB, ownerType, ownerID).Where("id = ?", id).First(&key).Error
|
|
||||||
return key, err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r MCPRepo) CredentialByRequest(ownerType, ownerID, requestID string) (model.MCPCredential, error) {
|
|
||||||
var key model.MCPCredential
|
|
||||||
err := mcpOwnerQuery(r.DB, ownerType, ownerID).Where("request_id = ?", requestID).First(&key).Error
|
|
||||||
return key, err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r MCPRepo) SearchCredentials(ownerType, ownerID string, page, pageSize int, excludeRevoked bool) ([]model.MCPCredential, int64, error) {
|
|
||||||
q := mcpOwnerQuery(r.DB.Model(&model.MCPCredential{}), ownerType, ownerID)
|
|
||||||
if excludeRevoked {
|
|
||||||
q = q.Where("status <> ?", "Revoked")
|
|
||||||
}
|
|
||||||
var total int64
|
|
||||||
if err := q.Count(&total).Error; err != nil {
|
|
||||||
return nil, 0, err
|
|
||||||
}
|
|
||||||
keys := []model.MCPCredential{}
|
|
||||||
err := q.Order("created_at DESC, id DESC").Offset((page - 1) * pageSize).Limit(pageSize).Find(&keys).Error
|
|
||||||
return keys, total, err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r MCPRepo) CountActiveName(ownerType, ownerID, name, excludeID string) (int64, error) {
|
|
||||||
q := mcpOwnerQuery(r.DB.Model(&model.MCPCredential{}), ownerType, ownerID)
|
|
||||||
if excludeID == "" {
|
|
||||||
q = q.Where("active_name = ?", name)
|
|
||||||
} else {
|
|
||||||
q = q.Where("active_name = ? AND id <> ?", name, excludeID)
|
|
||||||
}
|
|
||||||
var count int64
|
|
||||||
err := q.Count(&count).Error
|
|
||||||
return count, err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r MCPRepo) CreateCredential(key *model.MCPCredential) error {
|
|
||||||
return r.DB.Create(key).Error
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r MCPRepo) UpdateCredential(key *model.MCPCredential, revision uint64) (int64, error) {
|
|
||||||
result := r.DB.Model(&model.MCPCredential{}).Where("id = ? AND revision = ?", key.ID, revision).
|
|
||||||
Select("name", "active_name", "description", "node_ids", "status", "expires_at", "revision", "revoked_at", "secret_ciphertext", "secret_version", "token_hash", "key_hint").Updates(key)
|
|
||||||
return result.RowsAffected, result.Error
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r MCPRepo) DeleteCredential(ownerType, ownerID, id string, revision uint64) (int64, error) {
|
|
||||||
result := mcpOwnerQuery(r.DB, ownerType, ownerID).Where("id = ? AND revision = ?", id, revision).Delete(&model.MCPCredential{})
|
|
||||||
return result.RowsAffected, result.Error
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r MCPRepo) AdmissionCredential(id string, revision uint64) (model.MCPCredential, error) {
|
|
||||||
var key model.MCPCredential
|
|
||||||
err := r.DB.Where("id = ? AND revision = ? AND status = ? AND revoked_at IS NULL", id, revision, "Enable").Take(&key).Error
|
|
||||||
return key, err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r MCPRepo) InvocationCredential(id string, revision uint64) (model.MCPCredential, error) {
|
|
||||||
var key model.MCPCredential
|
|
||||||
err := r.DB.Select("expires_at").
|
|
||||||
Where("id = ? AND revision = ? AND status = ? AND revoked_at IS NULL", id, revision, "Enable").
|
|
||||||
Where("EXISTS (SELECT 1 FROM mcp_settings WHERE id = ? AND enabled = ?)", 1, true).Take(&key).Error
|
|
||||||
return key, err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r MCPRepo) Token(hash string) (model.MCPCredential, error) {
|
func (r MCPRepo) Token(hash string) (model.MCPCredential, error) {
|
||||||
var key model.MCPCredential
|
var key model.MCPCredential
|
||||||
err := r.DB.Where("token_hash = ?", hash).First(&key).Error
|
err := r.DB.Where("token_hash = ?", hash).First(&key).Error
|
||||||
@@ -94,13 +17,8 @@ func (r MCPRepo) Grants(id string) ([]model.MCPGrant, error) {
|
|||||||
err := r.DB.Where("credential_id = ?", id).Order("tool_id").Find(&grants).Error
|
err := r.DB.Where("credential_id = ?", id).Order("tool_id").Find(&grants).Error
|
||||||
return grants, err
|
return grants, err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r MCPRepo) DeleteGrants(id string) error {
|
|
||||||
return r.DB.Where("credential_id = ?", id).Delete(&model.MCPGrant{}).Error
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r MCPRepo) ReplaceTools(id string, grants []model.MCPGrant) error {
|
func (r MCPRepo) ReplaceTools(id string, grants []model.MCPGrant) error {
|
||||||
if err := r.DeleteGrants(id); err != nil {
|
if err := r.DB.Where("credential_id = ?", id).Delete(&model.MCPGrant{}).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
for _, grant := range grants {
|
for _, grant := range grants {
|
||||||
@@ -111,89 +29,3 @@ func (r MCPRepo) ReplaceTools(id string, grants []model.MCPGrant) error {
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r MCPRepo) Setting() (model.MCPSetting, error) {
|
|
||||||
var setting model.MCPSetting
|
|
||||||
err := r.DB.First(&setting, 1).Error
|
|
||||||
return setting, err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r MCPRepo) Enabled() (bool, error) {
|
|
||||||
var setting struct{ Enabled bool }
|
|
||||||
err := r.DB.Table("mcp_settings").Where("id = ?", 1).Take(&setting).Error
|
|
||||||
return setting.Enabled, err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r MCPRepo) AdmissionEnabled() (bool, error) {
|
|
||||||
var setting model.MCPSetting
|
|
||||||
err := r.DB.Select("enabled").Take(&setting, 1).Error
|
|
||||||
return setting.Enabled, err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r MCPRepo) PanelSetting(key string) (model.Setting, error) {
|
|
||||||
var setting model.Setting
|
|
||||||
err := r.DB.Where("key = ?", key).First(&setting).Error
|
|
||||||
return setting, err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r MCPRepo) CreateSettingIfAbsent(setting *model.MCPSetting) error {
|
|
||||||
return r.DB.Clauses(clause.OnConflict{DoNothing: true}).Create(setting).Error
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r MCPRepo) UpdateRuntimeSetting(setting model.MCPSetting, revision uint64) (int64, error) {
|
|
||||||
result := r.DB.Model(&model.MCPSetting{}).Where("id = ? AND revision = ?", 1, revision).Updates(map[string]interface{}{
|
|
||||||
"runtime_type": setting.RuntimeType, "docker_image": setting.DockerImage,
|
|
||||||
"enabled": setting.Enabled, "listen_port": setting.ListenPort, "external_url": setting.ExternalURL, "revision": setting.Revision,
|
|
||||||
"access_policy": setting.AccessPolicy, "allowed_ips": setting.AllowedIPs,
|
|
||||||
})
|
|
||||||
return result.RowsAffected, result.Error
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r MCPRepo) UpdateWebsiteSetting(setting model.MCPSetting, revision uint64) (int64, error) {
|
|
||||||
result := r.DB.Model(&model.MCPSetting{}).Where("id = 1 AND revision = ?", revision).Updates(map[string]any{
|
|
||||||
"website_id": setting.WebsiteID, "website_binding_id": setting.WebsiteBindingID, "website_operation": setting.WebsiteOperation, "revision": setting.Revision,
|
|
||||||
})
|
|
||||||
return result.RowsAffected, result.Error
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r MCPRepo) CreateEnrollmentAudit(record *model.OperationLog) error {
|
|
||||||
return r.DB.Create(record).Error
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r MCPRepo) CreateInvocation(record *model.MCPInvocation) error {
|
|
||||||
return r.DB.Create(record).Error
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r MCPRepo) SearchInvocations(ownerType, ownerID string, page, pageSize int, credentialID, toolID, status string) ([]model.MCPInvocation, int64, error) {
|
|
||||||
q := mcpOwnerQuery(r.DB.Model(&model.MCPInvocation{}), ownerType, ownerID)
|
|
||||||
if credentialID != "" {
|
|
||||||
q = q.Where("credential_id = ?", credentialID)
|
|
||||||
}
|
|
||||||
if toolID != "" {
|
|
||||||
q = q.Where("tool_id = ?", toolID)
|
|
||||||
}
|
|
||||||
if status != "" {
|
|
||||||
q = q.Where("status = ?", status)
|
|
||||||
}
|
|
||||||
var total int64
|
|
||||||
if err := q.Count(&total).Error; err != nil {
|
|
||||||
return nil, 0, err
|
|
||||||
}
|
|
||||||
items := []model.MCPInvocation{}
|
|
||||||
if err := q.Order(mcpInvocationSecond + " DESC, " + mcpInvocationNanosecond + " DESC, id DESC").Offset((page - 1) * pageSize).Limit(pageSize).Find(&items).Error; err != nil {
|
|
||||||
return nil, 0, err
|
|
||||||
}
|
|
||||||
return items, total, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r MCPRepo) UpdateRunningInvocation(record model.MCPInvocation) error {
|
|
||||||
return r.DB.Model(&model.MCPInvocation{}).Where("id = ? AND status = ?", record.ID, "running").
|
|
||||||
Updates(map[string]any{"status": record.Status, "code": record.Code, "finished_at": record.FinishedAt, "duration_ms": record.DurationMs}).Error
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r MCPRepo) UpdateInterruptedInvocations(processStartedAt time.Time, status, code string, finishedAt time.Time) error {
|
|
||||||
return r.DB.Model(&model.MCPInvocation{}).
|
|
||||||
Where("status = ? AND finished_at IS NULL", "running").
|
|
||||||
Where("("+mcpInvocationSecond+" < ? OR ("+mcpInvocationSecond+" = ? AND "+mcpInvocationNanosecond+" < ?))", processStartedAt.Unix(), processStartedAt.Unix(), processStartedAt.Nanosecond()).
|
|
||||||
Updates(map[string]any{"status": status, "code": code, "finished_at": finishedAt}).Error
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -52,27 +52,6 @@ func (s *APIKeyService) owner(c *gin.Context) (auth.APIKeyOwner, error) {
|
|||||||
return s.Provider.CurrentAPIKeyOwner(c)
|
return s.Provider.CurrentAPIKeyOwner(c)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *APIKeyService) managementOwner(c *gin.Context) (auth.APIKeyOwner, error) {
|
|
||||||
if _, err := auth.RequireAPIKeyManagement(c); err != nil {
|
|
||||||
return auth.APIKeyOwner{}, err
|
|
||||||
}
|
|
||||||
if s.Provider == nil {
|
|
||||||
return auth.APIKeyOwner{}, buserr.New("ErrApiConfigStatusInvalid")
|
|
||||||
}
|
|
||||||
if !c.GetBool("API_AUTH") {
|
|
||||||
return s.Provider.CurrentAPIKeyOwner(c)
|
|
||||||
}
|
|
||||||
ownerType, ownerID := c.GetString("API_AUTH_OWNER_TYPE"), c.GetString("API_AUTH_OWNER_ID")
|
|
||||||
owner, err := s.Provider.ResolveAPIKeyOwner(c, ownerType, ownerID)
|
|
||||||
if err != nil {
|
|
||||||
return auth.APIKeyOwner{}, err
|
|
||||||
}
|
|
||||||
if owner.Type != ownerType || owner.ID != ownerID {
|
|
||||||
return auth.APIKeyOwner{}, buserr.New("ErrNotLogin")
|
|
||||||
}
|
|
||||||
return owner, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func apiKeyItem(key model.APIKey) dto.APIKeyItem {
|
func apiKeyItem(key model.APIKey) dto.APIKeyItem {
|
||||||
status := key.Status
|
status := key.Status
|
||||||
if status != "Revoked" && key.ExpiresAt != nil && !time.Now().Before(*key.ExpiresAt) {
|
if status != "Revoked" && key.ExpiresAt != nil && !time.Now().Before(*key.ExpiresAt) {
|
||||||
@@ -96,7 +75,7 @@ func (s *APIKeyService) legacy(owner auth.APIKeyOwner) (dto.APIKeyItem, auth.API
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *APIKeyService) Search(c *gin.Context, req dto.APIKeySearch) (*dto.APIKeyPage, error) {
|
func (s *APIKeyService) Search(c *gin.Context, req dto.APIKeySearch) (*dto.APIKeyPage, error) {
|
||||||
owner, err := s.managementOwner(c)
|
owner, err := s.owner(c)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -167,13 +146,11 @@ func keyHint(secret string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *APIKeyService) Create(c *gin.Context, req dto.APIKeyCreate) (*dto.APIKeyCreated, error) {
|
func (s *APIKeyService) Create(c *gin.Context, req dto.APIKeyCreate) (*dto.APIKeyCreated, error) {
|
||||||
owner, err := s.managementOwner(c)
|
owner, err := s.owner(c)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if req.RequestID == "" {
|
if _, err = uuid.Parse(req.RequestID); err != nil {
|
||||||
req.RequestID = uuid.NewString()
|
|
||||||
} else if _, err = uuid.Parse(req.RequestID); err != nil {
|
|
||||||
return nil, buserr.New("ErrInvalidParams")
|
return nil, buserr.New("ErrInvalidParams")
|
||||||
}
|
}
|
||||||
expires, err := validateAPIKeyFields(&req.APIKeyFields, true)
|
expires, err := validateAPIKeyFields(&req.APIKeyFields, true)
|
||||||
@@ -195,9 +172,6 @@ func (s *APIKeyService) Create(c *gin.Context, req dto.APIKeyCreate) (*dto.APIKe
|
|||||||
q := repo.APIKeyOwnerQuery(tx, owner.Type, owner.ID)
|
q := repo.APIKeyOwnerQuery(tx, owner.Type, owner.ID)
|
||||||
err := q.Where("request_id = ?", req.RequestID).First(&key).Error
|
err := q.Where("request_id = ?", req.RequestID).First(&key).Error
|
||||||
if err == nil {
|
if err == nil {
|
||||||
if key.DeletedAt != nil {
|
|
||||||
return buserr.New("ErrAPIKeyNotFound")
|
|
||||||
}
|
|
||||||
if key.RequestHash != requestHash {
|
if key.RequestHash != requestHash {
|
||||||
return buserr.New("ErrAPIKeyConflict")
|
return buserr.New("ErrAPIKeyConflict")
|
||||||
}
|
}
|
||||||
@@ -245,7 +219,7 @@ func ensureAPIKeyName(tx *gorm.DB, owner auth.APIKeyOwner, name, id string) erro
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *APIKeyService) Update(c *gin.Context, req dto.APIKeyUpdate) error {
|
func (s *APIKeyService) Update(c *gin.Context, req dto.APIKeyUpdate) error {
|
||||||
owner, err := s.managementOwner(c)
|
owner, err := s.owner(c)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -273,7 +247,7 @@ func (s *APIKeyService) Update(c *gin.Context, req dto.APIKeyUpdate) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *APIKeyService) Status(c *gin.Context, req dto.APIKeyStatus) error {
|
func (s *APIKeyService) Status(c *gin.Context, req dto.APIKeyStatus) error {
|
||||||
owner, err := s.managementOwner(c)
|
owner, err := s.owner(c)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -297,7 +271,7 @@ func (s *APIKeyService) Status(c *gin.Context, req dto.APIKeyStatus) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *APIKeyService) Revoke(c *gin.Context, req dto.APIKeyMutation) error {
|
func (s *APIKeyService) Revoke(c *gin.Context, req dto.APIKeyMutation) error {
|
||||||
owner, err := s.managementOwner(c)
|
owner, err := s.owner(c)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -315,75 +289,21 @@ func (s *APIKeyService) Revoke(c *gin.Context, req dto.APIKeyMutation) error {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *APIKeyService) Delete(c *gin.Context, req dto.APIKeyMutation) error {
|
|
||||||
owner, err := s.managementOwner(c)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if req.ID == "legacy" {
|
|
||||||
return buserr.New("ErrInvalidParams")
|
|
||||||
}
|
|
||||||
return s.mutate(c, owner, req, "delete", func(_ *gorm.DB, key *model.APIKey) error {
|
|
||||||
now := time.Now()
|
|
||||||
key.Status = "Revoked"
|
|
||||||
if key.RevokedAt == nil {
|
|
||||||
key.RevokedAt = &now
|
|
||||||
}
|
|
||||||
key.DeletedAt = &now
|
|
||||||
key.ActiveName = nil
|
|
||||||
key.SecretCiphertext = ""
|
|
||||||
key.KeyHint = ""
|
|
||||||
key.AllowAppBinding = false
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *APIKeyService) Reveal(c *gin.Context, req dto.APIKeyMutation) (*dto.APIKeyRevealed, error) {
|
|
||||||
owner, err := s.owner(c)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if req.ID == "" || req.ID == "legacy" || req.Revision == 0 {
|
|
||||||
return nil, buserr.New("ErrInvalidParams")
|
|
||||||
}
|
|
||||||
// Serialize the snapshot and disclosure audit with revocation/deletion.
|
|
||||||
apiKeyMutationMu.Lock()
|
|
||||||
defer apiKeyMutationMu.Unlock()
|
|
||||||
var key model.APIKey
|
|
||||||
if err = repo.APIKeyOwnerQuery(global.DB, owner.Type, owner.ID).Where("id = ? AND deleted_at IS NULL", req.ID).First(&key).Error; err != nil {
|
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
||||||
return nil, buserr.New("ErrAPIKeyNotFound")
|
|
||||||
}
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if key.Revision != req.Revision || key.Status == "Revoked" {
|
|
||||||
return nil, buserr.New("ErrAPIKeyConflict")
|
|
||||||
}
|
|
||||||
secret, err := auth.APIKeySecret(key)
|
|
||||||
if err != nil {
|
|
||||||
return nil, buserr.New("ErrApiConfigKeyInvalid")
|
|
||||||
}
|
|
||||||
if err = apikey_audit.Record(c, apikey_audit.Event{OwnerType: owner.Type, OwnerID: owner.ID, KeyID: key.ID, KeyName: key.Name, Action: "reveal", Status: constant.StatusSuccess}); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return &dto.APIKeyRevealed{APIKeyMutation: req, APIKey: secret}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *APIKeyService) mutate(c *gin.Context, owner auth.APIKeyOwner, req dto.APIKeyMutation, action string, change func(*gorm.DB, *model.APIKey) error) error {
|
func (s *APIKeyService) mutate(c *gin.Context, owner auth.APIKeyOwner, req dto.APIKeyMutation, action string, change func(*gorm.DB, *model.APIKey) error) error {
|
||||||
if req.ID == "" || req.Revision == 0 {
|
if req.ID == "" || req.Revision == 0 {
|
||||||
return buserr.New("ErrInvalidParams")
|
return buserr.New("ErrInvalidParams")
|
||||||
}
|
}
|
||||||
apiKeyMutationMu.Lock()
|
apiKeyMutationMu.Lock()
|
||||||
var key model.APIKey
|
var key model.APIKey
|
||||||
closeTerminals := action == "disable" || action == "revoke" || action == "delete"
|
closeTerminals := action == "disable" || action == "revoke"
|
||||||
err := global.DB.Transaction(func(tx *gorm.DB) error {
|
err := global.DB.Transaction(func(tx *gorm.DB) error {
|
||||||
if err := repo.APIKeyOwnerQuery(tx, owner.Type, owner.ID).Where("id = ? AND deleted_at IS NULL", req.ID).First(&key).Error; err != nil {
|
if err := repo.APIKeyOwnerQuery(tx, owner.Type, owner.ID).Where("id = ?", req.ID).First(&key).Error; err != nil {
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
return buserr.New("ErrAPIKeyNotFound")
|
return buserr.New("ErrAPIKeyNotFound")
|
||||||
}
|
}
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if key.Revision != req.Revision || (key.Status == "Revoked" && action != "delete") {
|
if key.Revision != req.Revision || key.Status == "Revoked" {
|
||||||
return buserr.New("ErrAPIKeyConflict")
|
return buserr.New("ErrAPIKeyConflict")
|
||||||
}
|
}
|
||||||
before := key
|
before := key
|
||||||
|
|||||||
+48
-24
@@ -96,11 +96,12 @@ var NewMCPService = func() *MCPService {
|
|||||||
defer cancel()
|
defer cancel()
|
||||||
return NewMCPRuntimeService().Catalog(ctx)
|
return NewMCPRuntimeService().Catalog(ctx)
|
||||||
}, Enabled: func() (bool, error) {
|
}, Enabled: func() (bool, error) {
|
||||||
enabled, err := (repo.MCPRepo{DB: global.DB}).Enabled()
|
var setting struct{ Enabled bool }
|
||||||
|
err := global.DB.Table("mcp_settings").Where("id = ?", 1).Take(&setting).Error
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
return false, nil
|
return false, nil
|
||||||
}
|
}
|
||||||
return enabled, err
|
return setting.Enabled, err
|
||||||
}}
|
}}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -148,6 +149,9 @@ func (s *MCPService) ManagementOwner(c *gin.Context, write bool) (auth.APIKeyOwn
|
|||||||
}
|
}
|
||||||
return owner, nil
|
return owner, nil
|
||||||
}
|
}
|
||||||
|
func mcpOwnerQuery(db *gorm.DB, owner auth.APIKeyOwner) *gorm.DB {
|
||||||
|
return db.Where("owner_type = ? AND owner_id = ?", owner.Type, owner.ID)
|
||||||
|
}
|
||||||
func validMCPPage(page, size int) bool { return page > 0 && page <= 1000000 && size > 0 && size <= 100 }
|
func validMCPPage(page, size int) bool { return page > 0 && page <= 1000000 && size > 0 && size <= 100 }
|
||||||
func (s *MCPService) catalog() ([]contract.Tool, error) {
|
func (s *MCPService) catalog() ([]contract.Tool, error) {
|
||||||
unavailable := func() error {
|
unavailable := func() error {
|
||||||
@@ -290,8 +294,16 @@ func (s *MCPService) Search(owner auth.APIKeyOwner, req dto.MCPCredentialSearch)
|
|||||||
if !validMCPPage(req.Page, req.PageSize) {
|
if !validMCPPage(req.Page, req.PageSize) {
|
||||||
return nil, contract.Invalid()
|
return nil, contract.Invalid()
|
||||||
}
|
}
|
||||||
keys, total, err := (repo.MCPRepo{DB: s.DB}).SearchCredentials(owner.Type, owner.ID, req.Page, req.PageSize, req.ExcludeRevoked)
|
q := mcpOwnerQuery(s.DB.Model(&model.MCPCredential{}), owner)
|
||||||
if err != nil {
|
if req.ExcludeRevoked {
|
||||||
|
q = q.Where("status <> ?", "Revoked")
|
||||||
|
}
|
||||||
|
var total int64
|
||||||
|
if err := q.Count(&total).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
keys := []model.MCPCredential{}
|
||||||
|
if err := q.Order("created_at DESC, id DESC").Offset((req.Page - 1) * req.PageSize).Limit(req.PageSize).Find(&keys).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
items := []dto.MCPCredentialItem{}
|
items := []dto.MCPCredentialItem{}
|
||||||
@@ -332,9 +344,7 @@ func (s *MCPService) Create(owner auth.APIKeyOwner, req dto.MCPCredentialCreate)
|
|||||||
repeated := false
|
repeated := false
|
||||||
mcpMutationMu.Lock()
|
mcpMutationMu.Lock()
|
||||||
err = s.DB.Transaction(func(tx *gorm.DB) error {
|
err = s.DB.Transaction(func(tx *gorm.DB) error {
|
||||||
store := repo.MCPRepo{DB: tx}
|
err := mcpOwnerQuery(tx, owner).Where("request_id = ?", req.RequestID).First(&key).Error
|
||||||
var err error
|
|
||||||
key, err = store.CredentialByRequest(owner.Type, owner.ID, req.RequestID)
|
|
||||||
if err == nil {
|
if err == nil {
|
||||||
if key.RequestHash != requestHash {
|
if key.RequestHash != requestHash {
|
||||||
return contract.Fail("MCP_CONFLICT", 409, "Creation request already used")
|
return contract.Fail("MCP_CONFLICT", 409, "Creation request already used")
|
||||||
@@ -345,8 +355,8 @@ func (s *MCPService) Create(owner auth.APIKeyOwner, req dto.MCPCredentialCreate)
|
|||||||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
count, err := store.CountActiveName(owner.Type, owner.ID, req.Name, "")
|
var count int64
|
||||||
if err != nil {
|
if err = mcpOwnerQuery(tx.Model(&model.MCPCredential{}), owner).Where("active_name = ?", req.Name).Count(&count).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if count > 0 {
|
if count > 0 {
|
||||||
@@ -361,10 +371,10 @@ func (s *MCPService) Create(owner auth.APIKeyOwner, req dto.MCPCredentialCreate)
|
|||||||
if err = s.sealCredentialToken(&key, token); err != nil {
|
if err = s.sealCredentialToken(&key, token); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err = store.CreateCredential(&key); err != nil {
|
if err = tx.Create(&key).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err = store.ReplaceTools(key.ID, grants); err != nil {
|
if err = (repo.MCPRepo{DB: tx}).ReplaceTools(key.ID, grants); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return s.auditEnrollment(tx, owner, "create", dto.MCPCredentialMutation{ID: key.ID, Revision: key.Revision}, nil)
|
return s.auditEnrollment(tx, owner, "create", dto.MCPCredentialMutation{ID: key.ID, Revision: key.Revision}, nil)
|
||||||
@@ -387,9 +397,8 @@ func (s *MCPService) mutate(owner auth.APIKeyOwner, req dto.MCPCredentialMutatio
|
|||||||
mcpMutationMu.Lock()
|
mcpMutationMu.Lock()
|
||||||
defer mcpMutationMu.Unlock()
|
defer mcpMutationMu.Unlock()
|
||||||
return s.DB.Transaction(func(tx *gorm.DB) error {
|
return s.DB.Transaction(func(tx *gorm.DB) error {
|
||||||
store := repo.MCPRepo{DB: tx}
|
var key model.MCPCredential
|
||||||
key, err := store.Credential(owner.Type, owner.ID, req.ID)
|
if err := mcpOwnerQuery(tx, owner).Where("id = ?", req.ID).First(&key).Error; err != nil {
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
return contract.Forbidden()
|
return contract.Forbidden()
|
||||||
}
|
}
|
||||||
@@ -406,11 +415,11 @@ func (s *MCPService) mutate(owner auth.APIKeyOwner, req dto.MCPCredentialMutatio
|
|||||||
key.SecretVersion = 0
|
key.SecretVersion = 0
|
||||||
}
|
}
|
||||||
key.Revision++
|
key.Revision++
|
||||||
affected, err := store.UpdateCredential(&key, req.Revision)
|
result := tx.Model(&model.MCPCredential{}).Where("id = ? AND revision = ?", key.ID, req.Revision).Select("name", "active_name", "description", "node_ids", "status", "expires_at", "revision", "revoked_at", "secret_ciphertext", "secret_version", "token_hash", "key_hint").Updates(&key)
|
||||||
if err != nil {
|
if result.Error != nil {
|
||||||
return err
|
return result.Error
|
||||||
}
|
}
|
||||||
if affected != 1 {
|
if result.RowsAffected != 1 {
|
||||||
return contract.Fail("MCP_CONFLICT", 409, "Credential changed; refresh and retry")
|
return contract.Fail("MCP_CONFLICT", 409, "Credential changed; refresh and retry")
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
@@ -425,8 +434,8 @@ func (s *MCPService) Update(owner auth.APIKeyOwner, req dto.MCPCredentialUpdate)
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return s.mutate(owner, req.MCPCredentialMutation, func(tx *gorm.DB, key *model.MCPCredential) error {
|
return s.mutate(owner, req.MCPCredentialMutation, func(tx *gorm.DB, key *model.MCPCredential) error {
|
||||||
count, err := (repo.MCPRepo{DB: tx}).CountActiveName(owner.Type, owner.ID, req.Name, key.ID)
|
var count int64
|
||||||
if err != nil {
|
if err := mcpOwnerQuery(tx.Model(&model.MCPCredential{}), owner).Where("active_name = ? AND id <> ?", req.Name, key.ID).Count(&count).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if count > 0 {
|
if count > 0 {
|
||||||
@@ -619,7 +628,7 @@ func (s *MCPService) Invoke(c *gin.Context, token string, req contract.Invoke) (
|
|||||||
record.Revision = key.Revision
|
record.Revision = key.Revision
|
||||||
}
|
}
|
||||||
record.Status = "running"
|
record.Status = "running"
|
||||||
if err := (repo.MCPRepo{DB: s.DB}).CreateInvocation(&record); err != nil {
|
if err := s.DB.Create(&record).Error; err != nil {
|
||||||
return nil, contract.Fail("MCP_EXECUTION_FAILED", 503, "Invocation audit is unavailable")
|
return nil, contract.Fail("MCP_EXECUTION_FAILED", 503, "Invocation audit is unavailable")
|
||||||
}
|
}
|
||||||
record.Status = "denied"
|
record.Status = "denied"
|
||||||
@@ -698,7 +707,10 @@ func (s *MCPService) Invoke(c *gin.Context, token string, req contract.Invoke) (
|
|||||||
if enabledErr != nil || !enabled {
|
if enabledErr != nil || !enabled {
|
||||||
return finish(contract.Fail("MCP_DISABLED", 503, "MCP access is disabled"), nil)
|
return finish(contract.Fail("MCP_DISABLED", 503, "MCP access is disabled"), nil)
|
||||||
}
|
}
|
||||||
admitted, admissionErr := (repo.MCPRepo{DB: s.DB}).InvocationCredential(key.ID, key.Revision)
|
var admitted model.MCPCredential
|
||||||
|
admissionErr := s.DB.Select("expires_at").
|
||||||
|
Where("id = ? AND revision = ? AND status = ? AND revoked_at IS NULL", key.ID, key.Revision, "Enable").
|
||||||
|
Where("EXISTS (SELECT 1 FROM mcp_settings WHERE id = ? AND enabled = ?)", 1, true).Take(&admitted).Error
|
||||||
if errors.Is(admissionErr, gorm.ErrRecordNotFound) {
|
if errors.Is(admissionErr, gorm.ErrRecordNotFound) {
|
||||||
return finish(contract.Forbidden(), nil)
|
return finish(contract.Forbidden(), nil)
|
||||||
}
|
}
|
||||||
@@ -741,13 +753,25 @@ func (s *MCPService) Invocations(owner auth.APIKeyOwner, req dto.MCPInvocationSe
|
|||||||
if !validMCPPage(req.Page, req.PageSize) {
|
if !validMCPPage(req.Page, req.PageSize) {
|
||||||
return nil, contract.Invalid()
|
return nil, contract.Invalid()
|
||||||
}
|
}
|
||||||
|
q := mcpOwnerQuery(s.DB.Model(&model.MCPInvocation{}), owner)
|
||||||
|
if req.CredentialID != "" {
|
||||||
|
q = q.Where("credential_id = ?", req.CredentialID)
|
||||||
|
}
|
||||||
|
if req.ToolID != "" {
|
||||||
|
q = q.Where("tool_id = ?", req.ToolID)
|
||||||
|
}
|
||||||
if req.Status != "" {
|
if req.Status != "" {
|
||||||
if req.Status != "success" && req.Status != "denied" && req.Status != "failed" && req.Status != "running" {
|
if req.Status != "success" && req.Status != "denied" && req.Status != "failed" && req.Status != "running" {
|
||||||
return nil, contract.Invalid()
|
return nil, contract.Invalid()
|
||||||
}
|
}
|
||||||
|
q = q.Where("status = ?", req.Status)
|
||||||
}
|
}
|
||||||
items, total, err := (repo.MCPRepo{DB: s.DB}).SearchInvocations(owner.Type, owner.ID, req.Page, req.PageSize, req.CredentialID, req.ToolID, req.Status)
|
var total int64
|
||||||
if err != nil {
|
if err := q.Count(&total).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items := []model.MCPInvocation{}
|
||||||
|
if err := q.Order(mcpInvocationSecond + " DESC, " + mcpInvocationNanosecond + " DESC, id DESC").Offset((req.Page - 1) * req.PageSize).Limit(req.PageSize).Find(&items).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return map[string]any{"items": items, "total": total}, nil
|
return map[string]any{"items": items, "total": total}, nil
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/1Panel-dev/1Panel/core/app/model"
|
"github.com/1Panel-dev/1Panel/core/app/model"
|
||||||
"github.com/1Panel-dev/1Panel/core/app/repo"
|
|
||||||
contract "github.com/1Panel-dev/1Panel/core/utils/mcp"
|
contract "github.com/1Panel-dev/1Panel/core/utils/mcp"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
@@ -98,7 +97,7 @@ func (s *MCPService) Admit(c *gin.Context, token string, req contract.Admission)
|
|||||||
if known, ok := err.(*contract.Error); ok {
|
if known, ok := err.(*contract.Error); ok {
|
||||||
record.Code = known.Code
|
record.Code = known.Code
|
||||||
}
|
}
|
||||||
if auditErr := (repo.MCPRepo{DB: s.DB}).CreateInvocation(&record); auditErr != nil {
|
if auditErr := s.DB.Create(&record).Error; auditErr != nil {
|
||||||
return nil, contract.Fail("MCP_EXECUTION_FAILED", 503, "Admission audit is unavailable")
|
return nil, contract.Fail("MCP_EXECUTION_FAILED", 503, "Admission audit is unavailable")
|
||||||
}
|
}
|
||||||
if known, ok := err.(*contract.Error); ok {
|
if known, ok := err.(*contract.Error); ok {
|
||||||
@@ -165,16 +164,15 @@ func newMCPAdmissionID() string { return uuid.NewString() }
|
|||||||
|
|
||||||
func (s *MCPService) validateMCPAdmission(key model.MCPCredential, now time.Time) error {
|
func (s *MCPService) validateMCPAdmission(key model.MCPCredential, now time.Time) error {
|
||||||
return s.DB.Transaction(func(tx *gorm.DB) error {
|
return s.DB.Transaction(func(tx *gorm.DB) error {
|
||||||
data := repo.MCPRepo{DB: tx}
|
var latest model.MCPCredential
|
||||||
latest, err := data.AdmissionCredential(key.ID, key.Revision)
|
if err := tx.Where("id = ? AND revision = ? AND status = ? AND revoked_at IS NULL", key.ID, key.Revision, "Enable").Take(&latest).Error; err != nil {
|
||||||
if err != nil {
|
|
||||||
return contract.Forbidden()
|
return contract.Forbidden()
|
||||||
}
|
}
|
||||||
if latest.ExpiresAt != nil && !now.Before(*latest.ExpiresAt) {
|
if latest.ExpiresAt != nil && !now.Before(*latest.ExpiresAt) {
|
||||||
return contract.Forbidden()
|
return contract.Forbidden()
|
||||||
}
|
}
|
||||||
enabled, err := data.AdmissionEnabled()
|
var setting model.MCPSetting
|
||||||
if err != nil || !enabled {
|
if err := tx.Select("enabled").Take(&setting, 1).Error; err != nil || !setting.Enabled {
|
||||||
return contract.Fail("MCP_DISABLED", 503, "MCP access is disabled")
|
return contract.Fail("MCP_DISABLED", 503, "MCP access is disabled")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -238,7 +236,7 @@ func (s *MCPService) CompleteWithOutcome(token string, req contract.Completion)
|
|||||||
now := s.Now()
|
now := s.Now()
|
||||||
outcome.Status, outcome.Code = "failed", mcpCompletionErrorCode(req.ErrorCode)
|
outcome.Status, outcome.Code = "failed", mcpCompletionErrorCode(req.ErrorCode)
|
||||||
outcome.FinishedAt, outcome.DurationMs = &now, now.Sub(outcome.StartedAt).Milliseconds()
|
outcome.FinishedAt, outcome.DurationMs = &now, now.Sub(outcome.StartedAt).Milliseconds()
|
||||||
if err := (repo.MCPRepo{DB: s.DB}).CreateInvocation(outcome); err != nil {
|
if err := s.DB.Create(outcome).Error; err != nil {
|
||||||
return nil, contract.Fail("MCP_AUDIT_UNAVAILABLE", 503, "Invocation outcome could not be recorded; do not retry a write automatically")
|
return nil, contract.Fail("MCP_AUDIT_UNAVAILABLE", 503, "Invocation outcome could not be recorded; do not retry a write automatically")
|
||||||
}
|
}
|
||||||
result.InvocationID = outcome.ID
|
result.InvocationID = outcome.ID
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user