mirror of
https://github.com/1Panel-dev/1Panel.git
synced 2026-10-28 16:00:18 +00:00
Compare commits
27
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
50955e20b7 | ||
|
|
bd67614a53 | ||
|
|
d7355f3ccc | ||
|
|
14dd3135ec | ||
|
|
93ca724985 | ||
|
|
9481a7535d | ||
|
|
14be5b7870 | ||
|
|
c4b76a8e89 | ||
|
|
9295f4ef8a | ||
|
|
c925184bf6 | ||
|
|
87828b03ae | ||
|
|
e7b4146e1b | ||
|
|
ec998552b9 | ||
|
|
1af237944d | ||
|
|
f9f9169a4d | ||
|
|
1e01761ba3 | ||
|
|
15283fde34 | ||
|
|
49f8e7f040 | ||
|
|
98b27af6a0 | ||
|
|
a393224729 | ||
|
|
b851c84290 | ||
|
|
372855157d | ||
|
|
577d771495 | ||
|
|
ca3e6511e5 | ||
|
|
8af14eae25 | ||
|
|
175fbaf3f2 | ||
|
|
13199efd62 |
@@ -114,6 +114,18 @@ func (b *BaseApi) SearchOllamaModel(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
func (b *BaseApi) SyncOllamaModelAccount(c *gin.Context) {
|
||||
var req dto.OllamaModelAccountSync
|
||||
if err := helper.CheckBindAndValidate(&req, c); err != nil {
|
||||
return
|
||||
}
|
||||
if err := aiToolService.SyncAccount(req); err != nil {
|
||||
helper.BadRequest(c, err)
|
||||
return
|
||||
}
|
||||
helper.Success(c)
|
||||
}
|
||||
|
||||
// @Tags AI
|
||||
// @Summary Page Ollama models
|
||||
// @Accept json
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
package v2
|
||||
|
||||
import (
|
||||
"github.com/1Panel-dev/1Panel/agent/app/api/v2/helper"
|
||||
"github.com/1Panel-dev/1Panel/agent/app/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func (b *BaseApi) AIBenchmarkSources(c *gin.Context) {
|
||||
sources, err := service.AIBenchmarkSources(c.Query("kind"), c.Query("selected"))
|
||||
if err != nil {
|
||||
helper.BadRequest(c, err)
|
||||
return
|
||||
}
|
||||
helper.SuccessWithData(c, sources)
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"github.com/1Panel-dev/1Panel/agent/app/dto/request"
|
||||
"github.com/1Panel-dev/1Panel/agent/i18n"
|
||||
"github.com/1Panel-dev/1Panel/agent/utils/appicon"
|
||||
"github.com/1Panel-dev/1Panel/agent/utils/mcpapi"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
@@ -160,10 +161,25 @@ func (b *BaseApi) GetAppDetailByID(c *gin.Context) {
|
||||
// @Router /apps/install [post]
|
||||
// @x-panel-log {"bodyKeys":["name"],"paramKeys":[],"BeforeFunctions":[],"formatZH":"安装应用 [name]","formatEN":"Install app [name]"}
|
||||
func (b *BaseApi) InstallApp(c *gin.Context) {
|
||||
mcpRequest, trustedMCP := mcpapi.Check(c.Request)
|
||||
if mcpRequest && !trustedMCP {
|
||||
c.AbortWithStatus(http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
var req request.AppInstallCreate
|
||||
if err := helper.CheckBindAndValidate(&req, c); err != nil {
|
||||
return
|
||||
}
|
||||
if mcpRequest {
|
||||
req.MCPPreserveWebsiteDir = true
|
||||
if value, exists := req.Params["WEBSITE_DIR"]; exists {
|
||||
directory, ok := value.(string)
|
||||
if !ok || directory == "" || directory != settingService.GetWebsiteDir() {
|
||||
c.AbortWithStatus(http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
install, err := appService.Install(req, true)
|
||||
if err != nil {
|
||||
helper.InternalServer(c, err)
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
package v2
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/1Panel-dev/1Panel/agent/app/dto/request"
|
||||
"github.com/1Panel-dev/1Panel/agent/app/service"
|
||||
"github.com/1Panel-dev/1Panel/agent/utils/mcpapi"
|
||||
|
||||
"github.com/1Panel-dev/1Panel/agent/app/api/v2/helper"
|
||||
"github.com/1Panel-dev/1Panel/agent/app/dto"
|
||||
@@ -336,11 +340,25 @@ func (b *BaseApi) UpdateAppConfig(c *gin.Context) {
|
||||
// @Security Timestamp
|
||||
// @Router /apps/installed/info/:appInstallId [get]
|
||||
func (b *BaseApi) GetAppInstallInfo(c *gin.Context) {
|
||||
mcpRequest, trustedMCP := mcpapi.Check(c.Request)
|
||||
if mcpRequest && !trustedMCP {
|
||||
c.AbortWithStatus(http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
appInstallId, err := helper.GetIntParamByKey(c, "appInstallId")
|
||||
if err != nil {
|
||||
helper.BadRequest(c, err)
|
||||
return
|
||||
}
|
||||
if mcpRequest {
|
||||
info, err := (&service.AppInstallService{}).GetAppInstallInfoForMCP(c.Request.Context(), appInstallId)
|
||||
if err != nil {
|
||||
helper.InternalServer(c, err)
|
||||
return
|
||||
}
|
||||
helper.SuccessWithData(c, info)
|
||||
return
|
||||
}
|
||||
info, err := appInstallService.GetAppInstallInfo(appInstallId)
|
||||
if err != nil {
|
||||
helper.InternalServer(c, err)
|
||||
|
||||
@@ -28,7 +28,7 @@ func (b *BaseApi) SearchContainer(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
total, list, err := containerService.Page(req)
|
||||
total, list, err := containerService.Page(c.Request.Context(), req)
|
||||
if err != nil {
|
||||
helper.InternalServer(c, err)
|
||||
return
|
||||
@@ -249,9 +249,10 @@ func (b *BaseApi) ListContainerByImage(c *gin.Context) {
|
||||
// @Success 200 {object} dto.ContainerStatus
|
||||
// @Security ApiKeyAuth
|
||||
// @Security Timestamp
|
||||
// @Param containersOnly query boolean false "Only count containers"
|
||||
// @Router /containers/status [get]
|
||||
func (b *BaseApi) LoadContainerStatus(c *gin.Context) {
|
||||
data, err := containerService.LoadStatus()
|
||||
data, err := containerService.LoadStatus(c.Request.Context(), c.Query("containersOnly") == "true")
|
||||
if err != nil {
|
||||
helper.InternalServer(c, err)
|
||||
return
|
||||
@@ -415,9 +416,14 @@ func (b *BaseApi) LoadResourceLimit(c *gin.Context) {
|
||||
// @Success 200 {array} dto.ContainerListStats
|
||||
// @Security ApiKeyAuth
|
||||
// @Security Timestamp
|
||||
// @Param ids query string false "Comma-separated container IDs; omitted selects all containers"
|
||||
// @Router /containers/list/stats [get]
|
||||
func (b *BaseApi) ContainerListStats(c *gin.Context) {
|
||||
data, err := containerService.ContainerListStats()
|
||||
var ids []string
|
||||
if _, supplied := c.Request.URL.Query()["ids"]; supplied {
|
||||
ids = strings.FieldsFunc(c.Query("ids"), func(r rune) bool { return r == ',' })
|
||||
}
|
||||
data, err := containerService.ContainerListStats(c.Request.Context(), ids)
|
||||
if err != nil {
|
||||
helper.InternalServer(c, err)
|
||||
return
|
||||
|
||||
@@ -2,9 +2,12 @@ package v2
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"net/http"
|
||||
|
||||
"github.com/1Panel-dev/1Panel/agent/app/api/v2/helper"
|
||||
"github.com/1Panel-dev/1Panel/agent/app/dto"
|
||||
"github.com/1Panel-dev/1Panel/agent/app/service"
|
||||
"github.com/1Panel-dev/1Panel/agent/utils/mcpapi"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
@@ -98,11 +101,25 @@ func (b *BaseApi) SearchDatabase(c *gin.Context) {
|
||||
// @Param type path string true "type"
|
||||
// @Router /databases/db/list/:type [get]
|
||||
func (b *BaseApi) ListDatabase(c *gin.Context) {
|
||||
mcpRequest, trustedMCP := mcpapi.Check(c.Request)
|
||||
if mcpRequest && !trustedMCP {
|
||||
c.AbortWithStatus(http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
dbType, err := helper.GetStrParamByKey(c, "type")
|
||||
if err != nil {
|
||||
helper.BadRequest(c, err)
|
||||
return
|
||||
}
|
||||
if mcpRequest {
|
||||
list, err := (&service.DatabaseService{}).ListForMCP(c.Request.Context(), dbType)
|
||||
if err != nil {
|
||||
helper.BadRequest(c, err)
|
||||
return
|
||||
}
|
||||
helper.SuccessWithData(c, list)
|
||||
return
|
||||
}
|
||||
list, err := databaseService.List(dbType)
|
||||
if err != nil {
|
||||
helper.InternalServer(c, err)
|
||||
|
||||
@@ -3,9 +3,12 @@ package v2
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"net/http"
|
||||
|
||||
"github.com/1Panel-dev/1Panel/agent/app/api/v2/helper"
|
||||
"github.com/1Panel-dev/1Panel/agent/app/dto"
|
||||
"github.com/1Panel-dev/1Panel/agent/app/service"
|
||||
"github.com/1Panel-dev/1Panel/agent/utils/mcpapi"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
@@ -19,6 +22,11 @@ import (
|
||||
// @Router /databases [post]
|
||||
// @x-panel-log {"bodyKeys":["name"],"paramKeys":[],"BeforeFunctions":[],"formatZH":"创建 mysql 数据库 [name]","formatEN":"create mysql database [name]"}
|
||||
func (b *BaseApi) CreateMysql(c *gin.Context) {
|
||||
mcpRequest, trustedMCP := mcpapi.Check(c.Request)
|
||||
if mcpRequest && !trustedMCP {
|
||||
c.AbortWithStatus(http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
var req dto.MysqlDBCreate
|
||||
if err := helper.CheckBindAndValidate(&req, c); err != nil {
|
||||
return
|
||||
@@ -32,6 +40,16 @@ func (b *BaseApi) CreateMysql(c *gin.Context) {
|
||||
}
|
||||
req.Password = string(password)
|
||||
}
|
||||
if mcpRequest {
|
||||
created, err := (&service.MysqlService{}).CreateForMCP(c.Request.Context(), req)
|
||||
if err != nil {
|
||||
helper.InternalServer(c, err)
|
||||
return
|
||||
}
|
||||
nodeID, _ := mcpapi.NodeID(c.Request)
|
||||
helper.SuccessWithData(c, map[string]any{"id": created.ID, "database": created.MysqlName, "name": created.Name, "nodeId": nodeID})
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := mysqlService.Create(context.Background(), req); err != nil {
|
||||
helper.InternalServer(c, err)
|
||||
@@ -513,11 +531,25 @@ func (b *BaseApi) LoadRemoteAccess(c *gin.Context) {
|
||||
// @Security Timestamp
|
||||
// @Router /databases/status [post]
|
||||
func (b *BaseApi) LoadStatus(c *gin.Context) {
|
||||
mcpRequest, trustedMCP := mcpapi.Check(c.Request)
|
||||
if mcpRequest && !trustedMCP {
|
||||
c.AbortWithStatus(http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
var req dto.OperationWithNameAndType
|
||||
if err := helper.CheckBindAndValidate(&req, c); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if mcpRequest {
|
||||
data, err := (&service.MysqlService{}).LoadStatusForMCP(c.Request.Context(), req)
|
||||
if err != nil {
|
||||
helper.InternalServer(c, err)
|
||||
return
|
||||
}
|
||||
helper.SuccessWithData(c, data)
|
||||
return
|
||||
}
|
||||
data, err := mysqlService.LoadStatus(req)
|
||||
if err != nil {
|
||||
helper.InternalServer(c, err)
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"sync"
|
||||
|
||||
"github.com/1Panel-dev/1Panel/agent/global"
|
||||
"github.com/1Panel-dev/1Panel/agent/utils/mcpapi"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/1Panel-dev/1Panel/agent/app/dto"
|
||||
@@ -22,6 +23,7 @@ import (
|
||||
)
|
||||
|
||||
func ErrorWithDetail(ctx *gin.Context, code int, msgKey string, err error) {
|
||||
mcpapi.SetBusinessErrorHeader(ctx.Request, ctx.Writer.Header(), err)
|
||||
res := dto.Response{
|
||||
Code: code,
|
||||
Message: "",
|
||||
@@ -32,6 +34,7 @@ func ErrorWithDetail(ctx *gin.Context, code int, msgKey string, err error) {
|
||||
}
|
||||
|
||||
func ErrorWithBusinessCode(ctx *gin.Context, code int, businessCode, msgKey string, err error) {
|
||||
mcpapi.SetBusinessErrorHeader(ctx.Request, ctx.Writer.Header(), err)
|
||||
res := dto.Response{
|
||||
Code: code,
|
||||
ErrorCode: businessCode,
|
||||
@@ -42,6 +45,7 @@ func ErrorWithBusinessCode(ctx *gin.Context, code int, businessCode, msgKey stri
|
||||
}
|
||||
|
||||
func ErrorWithDetailAndData(ctx *gin.Context, code int, msgKey string, err error, data interface{}) {
|
||||
mcpapi.SetBusinessErrorHeader(ctx.Request, ctx.Writer.Header(), err)
|
||||
res := dto.Response{
|
||||
Code: code,
|
||||
Data: data,
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
package v2
|
||||
|
||||
import (
|
||||
"github.com/1Panel-dev/1Panel/agent/app/api/v2/helper"
|
||||
"github.com/1Panel-dev/1Panel/agent/app/dto"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// @Tags Dashboard
|
||||
// @Summary List quick directories
|
||||
// @Success 200 {array} model.QuickDirectory
|
||||
// @Security ApiKeyAuth
|
||||
// @Security Timestamp
|
||||
// @Router /dashboard/quick/directory [get]
|
||||
func (b *BaseApi) ListQuickDirectories(c *gin.Context) {
|
||||
items, err := dashboardService.ListQuickDirectories()
|
||||
if err != nil {
|
||||
helper.InternalServer(c, err)
|
||||
return
|
||||
}
|
||||
helper.SuccessWithData(c, items)
|
||||
}
|
||||
|
||||
// @Tags Dashboard
|
||||
// @Summary Create quick directory
|
||||
// @Accept json
|
||||
// @Param request body dto.QuickDirectoryCreate true "request"
|
||||
// @Success 200
|
||||
// @Security ApiKeyAuth
|
||||
// @Security Timestamp
|
||||
// @Router /dashboard/quick/directory [post]
|
||||
func (b *BaseApi) CreateQuickDirectory(c *gin.Context) {
|
||||
var req dto.QuickDirectoryCreate
|
||||
if err := helper.CheckBindAndValidate(&req, c); err != nil {
|
||||
return
|
||||
}
|
||||
item, err := dashboardService.CreateQuickDirectory(req)
|
||||
if err != nil {
|
||||
helper.InternalServer(c, err)
|
||||
return
|
||||
}
|
||||
helper.SuccessWithData(c, item)
|
||||
}
|
||||
|
||||
// @Tags Dashboard
|
||||
// @Summary Update quick directory
|
||||
// @Accept json
|
||||
// @Param request body dto.QuickDirectoryUpdate true "request"
|
||||
// @Success 200
|
||||
// @Security ApiKeyAuth
|
||||
// @Security Timestamp
|
||||
// @Router /dashboard/quick/directory/update [post]
|
||||
func (b *BaseApi) UpdateQuickDirectory(c *gin.Context) {
|
||||
var req dto.QuickDirectoryUpdate
|
||||
if err := helper.CheckBindAndValidate(&req, c); err != nil {
|
||||
return
|
||||
}
|
||||
item, err := dashboardService.UpdateQuickDirectory(req)
|
||||
if err != nil {
|
||||
helper.InternalServer(c, err)
|
||||
return
|
||||
}
|
||||
helper.SuccessWithData(c, item)
|
||||
}
|
||||
|
||||
// @Tags Dashboard
|
||||
// @Summary Delete quick directory
|
||||
// @Accept json
|
||||
// @Param request body dto.QuickDirectoryDelete true "request"
|
||||
// @Success 200
|
||||
// @Security ApiKeyAuth
|
||||
// @Security Timestamp
|
||||
// @Router /dashboard/quick/directory/del [post]
|
||||
func (b *BaseApi) DeleteQuickDirectory(c *gin.Context) {
|
||||
var req dto.QuickDirectoryDelete
|
||||
if err := helper.CheckBindAndValidate(&req, c); err != nil {
|
||||
return
|
||||
}
|
||||
if err := dashboardService.DeleteQuickDirectory(req); err != nil {
|
||||
helper.InternalServer(c, err)
|
||||
return
|
||||
}
|
||||
helper.Success(c)
|
||||
}
|
||||
@@ -61,13 +61,14 @@ func (b *BaseApi) CreateRuntime(c *gin.Context) {
|
||||
// @Security ApiKeyAuth
|
||||
// @Security Timestamp
|
||||
// @Router /runtimes/del [post]
|
||||
// @x-panel-log {"bodyKeys":["id"],"paramKeys":[],"BeforeFunctions":[],"formatZH":"删除运行环境 [id]","formatEN":"Delete runtime [id]"}
|
||||
// @x-panel-log {"bodyKeys":[],"paramKeys":[],"BeforeFunctions":[],"formatZH":"删除运行环境 [name]","formatEN":"Delete runtime [name]"}
|
||||
func (b *BaseApi) DeleteRuntime(c *gin.Context) {
|
||||
var req request.RuntimeDelete
|
||||
if err := helper.CheckBindAndValidate(&req, c); err != nil {
|
||||
return
|
||||
}
|
||||
err := runtimeService.Delete(req)
|
||||
name, err := runtimeService.Delete(req)
|
||||
helper.SetOperationLogValues(c, map[string]string{"name": name})
|
||||
if err != nil {
|
||||
helper.InternalServer(c, err)
|
||||
return
|
||||
|
||||
@@ -402,7 +402,7 @@ func (b *BaseApi) ChangeDefaultServer(c *gin.Context) {
|
||||
// @Security ApiKeyAuth
|
||||
// @Security Timestamp
|
||||
// @Router /websites/php/version [post]
|
||||
// @x-panel-log {"bodyKeys":["websiteId"],"paramKeys":[],"BeforeFunctions":[{"input_column":"id","input_value":"websiteId","isList":false,"db":"websites","output_column":"primary_domain","output_value":"domain"}],"formatZH":"php 版本变更 [domain]","formatEN":"php version update [domain]"}
|
||||
// @x-panel-log {"bodyKeys":["websiteID"],"paramKeys":[],"BeforeFunctions":[{"input_column":"id","input_value":"websiteID","isList":false,"db":"websites","output_column":"primary_domain","output_value":"domain"}],"formatZH":"php 版本变更 [domain]","formatEN":"php version update [domain]"}
|
||||
func (b *BaseApi) ChangePHPVersion(c *gin.Context) {
|
||||
var req request.WebsitePHPVersionReq
|
||||
if err := helper.CheckBindAndValidate(&req, c); err != nil {
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
package v2
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/1Panel-dev/1Panel/agent/app/api/v2/helper"
|
||||
"github.com/1Panel-dev/1Panel/agent/app/dto"
|
||||
"github.com/1Panel-dev/1Panel/agent/app/dto/request"
|
||||
"github.com/1Panel-dev/1Panel/agent/buserr"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
@@ -176,3 +179,64 @@ func (b *BaseApi) DownloadCAFile(c *gin.Context) {
|
||||
c.Header("Content-Disposition", "attachment; filename*=utf-8''"+url.PathEscape(info.Name()))
|
||||
http.ServeContent(c.Writer, c.Request, info.Name(), info.ModTime(), file)
|
||||
}
|
||||
|
||||
// @Tags Website CA
|
||||
// @Summary Upload website CA archive
|
||||
// @Accept multipart/form-data
|
||||
// @Param file formData file true "ZIP containing ca.crt and ca.key"
|
||||
// @Success 200
|
||||
// @Security ApiKeyAuth
|
||||
// @Security Timestamp
|
||||
// @Router /websites/ca/upload [post]
|
||||
func (b *BaseApi) UploadWebsiteCA(c *gin.Context) {
|
||||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 11<<20)
|
||||
if err := c.Request.ParseMultipartForm(11 << 20); err != nil {
|
||||
helper.InternalServer(c, buserr.New("ErrCAUpload"))
|
||||
return
|
||||
}
|
||||
if c.Request.MultipartForm != nil {
|
||||
defer c.Request.MultipartForm.RemoveAll()
|
||||
}
|
||||
file, err := c.FormFile("file")
|
||||
if err != nil || file.Size > 10<<20 || !strings.HasSuffix(strings.ToLower(file.Filename), ".zip") {
|
||||
helper.InternalServer(c, buserr.New("ErrCAUpload"))
|
||||
return
|
||||
}
|
||||
stream, err := file.Open()
|
||||
if err != nil {
|
||||
helper.InternalServer(c, err)
|
||||
return
|
||||
}
|
||||
defer stream.Close()
|
||||
data, err := io.ReadAll(io.LimitReader(stream, (10<<20)+1))
|
||||
if err != nil {
|
||||
helper.InternalServer(c, err)
|
||||
return
|
||||
}
|
||||
if err = websiteCAService.Upload(file.Filename[:len(file.Filename)-len(".zip")], data); err != nil {
|
||||
helper.InternalServer(c, err)
|
||||
return
|
||||
}
|
||||
helper.Success(c)
|
||||
}
|
||||
|
||||
// @Tags Website CA
|
||||
// @Summary Import website CA from PEM or server files
|
||||
// @Accept json
|
||||
// @Param request body request.WebsiteCAImport true "request"
|
||||
// @Success 200
|
||||
// @Security ApiKeyAuth
|
||||
// @Security Timestamp
|
||||
// @Router /websites/ca/import [post]
|
||||
func (b *BaseApi) ImportWebsiteCA(c *gin.Context) {
|
||||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 3<<20)
|
||||
var req request.WebsiteCAImport
|
||||
if err := helper.CheckBindAndValidate(&req, c); err != nil {
|
||||
return
|
||||
}
|
||||
if err := websiteCAService.Import(req); err != nil {
|
||||
helper.InternalServer(c, err)
|
||||
return
|
||||
}
|
||||
helper.Success(c)
|
||||
}
|
||||
|
||||
@@ -9,7 +9,8 @@ type AgentCreateReq struct {
|
||||
WebUIPort int `json:"webUIPort" validate:"required,min=1,max=65535"`
|
||||
BridgePort int `json:"bridgePort"`
|
||||
AllowedOrigins []string `json:"allowedOrigins"`
|
||||
AgentType string `json:"agentType" validate:"required,oneof=openclaw copaw hermes-agent"`
|
||||
AgentType string `json:"agentType" validate:"required,oneof=openclaw copaw hermes-agent deepseek-harness octop"`
|
||||
AccessHost string `json:"accessHost"`
|
||||
Model string `json:"model"`
|
||||
AccountID uint `json:"accountId"`
|
||||
Token string `json:"token"`
|
||||
@@ -110,6 +111,7 @@ type AgentBatchOperateResult struct {
|
||||
}
|
||||
|
||||
type AgentItem struct {
|
||||
AccessHost string `json:"accessHost"`
|
||||
ID uint `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Remark string `json:"remark"`
|
||||
@@ -384,6 +386,7 @@ type AgentAccountProviderCountReq struct {
|
||||
}
|
||||
|
||||
type AgentAccountInfo struct {
|
||||
AppInstallID uint `json:"appInstallId"`
|
||||
ID uint `json:"id"`
|
||||
MasterAccountID uint `json:"masterAccountId"`
|
||||
Provider string `json:"provider"`
|
||||
|
||||
@@ -24,6 +24,12 @@ type OllamaModelName struct {
|
||||
TaskID string `json:"taskID"`
|
||||
}
|
||||
|
||||
type OllamaModelAccountSync struct {
|
||||
ID uint `json:"id" validate:"required"`
|
||||
ModelAccountBaseURLType string `json:"modelAccountBaseURLType" validate:"required,oneof=container localhost systemIP custom"`
|
||||
ModelAccountBaseURL string `json:"modelAccountBaseURL"`
|
||||
}
|
||||
|
||||
type OllamaBindDomain struct {
|
||||
Domain string `json:"domain" validate:"required"`
|
||||
AppInstallID uint `json:"appInstallID" validate:"required"`
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
package dto
|
||||
|
||||
type AIBenchmarkSource struct {
|
||||
ID string `json:"id"`
|
||||
Kind string `json:"kind"`
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status"`
|
||||
BaseURL string `json:"baseUrl"`
|
||||
Endpoint string `json:"endpoint"`
|
||||
Models []string `json:"models"`
|
||||
Tokenizer string `json:"tokenizer"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
@@ -171,7 +171,8 @@ type ContainerItemStats struct {
|
||||
BuildCacheReclaimable int64 `json:"buildCacheReclaimable"`
|
||||
}
|
||||
type ContainerListStats struct {
|
||||
ContainerID string `json:"containerID"`
|
||||
ContainerID string `json:"containerID"`
|
||||
ContainerName string `json:"containerName"`
|
||||
|
||||
CPUTotalUsage uint64 `json:"cpuTotalUsage"`
|
||||
SystemUsage uint64 `json:"systemUsage"`
|
||||
|
||||
@@ -31,7 +31,27 @@ type DashboardBase struct {
|
||||
}
|
||||
|
||||
type ChangeQuicks struct {
|
||||
Quicks []QuickJump `json:"quicks"`
|
||||
Quicks []QuickVisibility `json:"quicks" validate:"required,dive"`
|
||||
}
|
||||
|
||||
type QuickVisibility struct {
|
||||
ID uint `json:"id" validate:"required"`
|
||||
IsShow bool `json:"isShow"`
|
||||
}
|
||||
|
||||
type QuickDirectoryCreate struct {
|
||||
Path string `json:"path" validate:"required"`
|
||||
Alias string `json:"alias"`
|
||||
}
|
||||
|
||||
type QuickDirectoryDelete struct {
|
||||
ID uint `json:"id" validate:"required"`
|
||||
Legacy bool `json:"legacy"`
|
||||
}
|
||||
|
||||
type QuickDirectoryUpdate struct {
|
||||
QuickDirectoryDelete
|
||||
QuickDirectoryCreate
|
||||
}
|
||||
|
||||
type QuickJump struct {
|
||||
|
||||
@@ -23,6 +23,12 @@ type AppInstallCreate struct {
|
||||
Services map[string]string `json:"services"`
|
||||
TaskID string `json:"taskID"`
|
||||
|
||||
SyncModelAccount *bool `json:"syncModelAccount"`
|
||||
ModelAccountBaseURLType string `json:"modelAccountBaseURLType" validate:"omitempty,oneof=container localhost systemIP custom"`
|
||||
ModelAccountBaseURL string `json:"modelAccountBaseURL"`
|
||||
|
||||
MCPPreserveWebsiteDir bool `json:"-"`
|
||||
|
||||
AppContainerConfig
|
||||
NodePushConfig
|
||||
}
|
||||
@@ -122,9 +128,12 @@ type AppInstallDelete struct {
|
||||
}
|
||||
|
||||
type AppInstalledUpdate struct {
|
||||
InstallId uint `json:"installId" validate:"required"`
|
||||
Params map[string]interface{} `json:"params" validate:"required"`
|
||||
TaskID string `json:"-"`
|
||||
SyncModelAccount *bool `json:"syncModelAccount"`
|
||||
ModelAccountBaseURLType string `json:"modelAccountBaseURLType" validate:"omitempty,oneof=container localhost systemIP custom"`
|
||||
ModelAccountBaseURL string `json:"modelAccountBaseURL"`
|
||||
InstallId uint `json:"installId" validate:"required"`
|
||||
Params map[string]interface{} `json:"params" validate:"required"`
|
||||
TaskID string `json:"-"`
|
||||
AppContainerConfig
|
||||
}
|
||||
|
||||
|
||||
@@ -182,3 +182,12 @@ type WebsiteSSLFileUpload struct {
|
||||
PushNode bool `json:"pushNode"`
|
||||
Nodes string `json:"nodes"`
|
||||
}
|
||||
|
||||
type WebsiteCAImport struct {
|
||||
Name string `json:"name" validate:"required,max=128"`
|
||||
Type string `json:"type" validate:"required,oneof=paste local"`
|
||||
Certificate string `json:"certificate" validate:"required_if=Type paste"`
|
||||
PrivateKey string `json:"privateKey" validate:"required_if=Type paste"`
|
||||
CertificatePath string `json:"certificatePath" validate:"required_if=Type local"`
|
||||
PrivateKeyPath string `json:"privateKeyPath" validate:"required_if=Type local"`
|
||||
}
|
||||
|
||||
@@ -50,19 +50,21 @@ type TagDTO struct {
|
||||
}
|
||||
|
||||
type AppInstalledCheck struct {
|
||||
IsExist bool `json:"isExist"`
|
||||
Name string `json:"name"`
|
||||
App string `json:"app"`
|
||||
Version string `json:"version"`
|
||||
Status string `json:"status"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
LastBackupAt string `json:"lastBackupAt"`
|
||||
AppInstallID uint `json:"appInstallId"`
|
||||
ContainerName string `json:"containerName"`
|
||||
InstallPath string `json:"installPath"`
|
||||
HttpPort int `json:"httpPort"`
|
||||
HttpsPort int `json:"httpsPort"`
|
||||
WebsiteDir string `json:"websiteDir"`
|
||||
ModelAccountBaseURLType string `json:"modelAccountBaseURLType"`
|
||||
ModelAccountBaseURL string `json:"modelAccountBaseURL"`
|
||||
IsExist bool `json:"isExist"`
|
||||
Name string `json:"name"`
|
||||
App string `json:"app"`
|
||||
Version string `json:"version"`
|
||||
Status string `json:"status"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
LastBackupAt string `json:"lastBackupAt"`
|
||||
AppInstallID uint `json:"appInstallId"`
|
||||
ContainerName string `json:"containerName"`
|
||||
InstallPath string `json:"installPath"`
|
||||
HttpPort int `json:"httpPort"`
|
||||
HttpsPort int `json:"httpsPort"`
|
||||
WebsiteDir string `json:"websiteDir"`
|
||||
}
|
||||
|
||||
type AppDetailDTO struct {
|
||||
@@ -184,7 +186,13 @@ type AppParam struct {
|
||||
}
|
||||
|
||||
type AppConfig struct {
|
||||
Params []AppParam `json:"params"`
|
||||
RawCompose string `json:"rawCompose"`
|
||||
AppKey string `json:"appKey"`
|
||||
Name string `json:"name"`
|
||||
HttpPort int `json:"httpPort"`
|
||||
SyncModelAccount bool `json:"syncModelAccount"`
|
||||
ModelAccountBaseURLType string `json:"modelAccountBaseURLType"`
|
||||
ModelAccountBaseURL string `json:"modelAccountBaseURL"`
|
||||
Params []AppParam `json:"params"`
|
||||
RawCompose string `json:"rawCompose"`
|
||||
request.AppContainerConfig
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package model
|
||||
|
||||
type AgentAccount struct {
|
||||
BaseModel
|
||||
AppInstallID uint `json:"appInstallId"`
|
||||
Provider string `json:"provider"`
|
||||
Name string `json:"name"`
|
||||
APIKey string `json:"apiKey"`
|
||||
|
||||
@@ -28,6 +28,10 @@ type AppInstall struct {
|
||||
Favorite bool `json:"favorite"`
|
||||
SortOrder int `json:"sortOrder" gorm:"default:0"`
|
||||
|
||||
SyncModelAccount bool `json:"syncModelAccount"`
|
||||
ModelAccountBaseURLType string `json:"modelAccountBaseURLType"`
|
||||
ModelAccountBaseURL string `json:"modelAccountBaseURL"`
|
||||
|
||||
App App `json:"app" gorm:"-:migration"`
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,13 @@ type AppLauncher struct {
|
||||
Key string `json:"key"`
|
||||
}
|
||||
|
||||
type QuickDirectory struct {
|
||||
BaseModel
|
||||
Path string `json:"path"`
|
||||
Alias string `json:"alias"`
|
||||
Legacy bool `json:"legacy" gorm:"-"`
|
||||
}
|
||||
|
||||
type QuickJump struct {
|
||||
BaseModel
|
||||
Name string `json:"name"`
|
||||
|
||||
@@ -66,7 +66,7 @@ var catalog = map[string]Meta{
|
||||
{APIType: "openai-responses", BaseURL: "https://api.deepseek.com"},
|
||||
anthropicAPIConfig("https://api.deepseek.com/anthropic", AuthModeXAPIKey),
|
||||
},
|
||||
Models: []Model{{ID: "deepseek-v4-flash", Name: "deepseek-v4-flash"}, {ID: "deepseek-v4-pro", Name: "deepseek-v4-pro"}},
|
||||
Models: []Model{{ID: "deepseek-flash", Name: "deepseek-flash"}, {ID: "deepseek-v4-pro", Name: "deepseek-v4-pro"}},
|
||||
},
|
||||
"bailian-coding-plan": {
|
||||
Key: "bailian-coding-plan", DisplayNameKey: "AIProviderBailianCodingPlan", Sort: 30, DefaultAPIType: "openai-completions", EnvKey: "QWEN_API_KEY",
|
||||
@@ -82,7 +82,6 @@ var catalog = map[string]Meta{
|
||||
{ID: "kimi-k2.5", Name: "Kimi K2.5"},
|
||||
{ID: "qwen3.5-plus", Name: "Qwen3.5-Plus"},
|
||||
{ID: "glm-5", Name: "GLM-5"},
|
||||
{ID: "MiniMax-M2.5", Name: "MiniMax M2.5"},
|
||||
{ID: "qwen3.6-plus", Name: "Qwen3.6-Plus"},
|
||||
{ID: "qwen3.7-plus", Name: "Qwen3.7-Plus"},
|
||||
},
|
||||
@@ -114,7 +113,7 @@ var catalog = map[string]Meta{
|
||||
{APIType: "openai-completions", BaseURL: "https://api.minimaxi.com/v1"},
|
||||
{APIType: "minimax-images", BaseURL: "https://api.minimaxi.com"},
|
||||
},
|
||||
Models: []Model{{ID: "MiniMax-M3", Name: "MiniMax M3"}, {ID: "MiniMax-M2.7", Name: "MiniMax M2.7"}, {ID: "MiniMax-M2.7-highspeed", Name: "MiniMax M2.7 highspeed"}},
|
||||
Models: []Model{{ID: "MiniMax-M3.1-Flash", Name: "MiniMax M3.1 Flash"}, {ID: "MiniMax-M3", Name: "MiniMax M3"}},
|
||||
},
|
||||
"xiaomi": {
|
||||
Key: "xiaomi", DisplayName: "Xiaomi", Sort: 46, DefaultAPIType: "openai-completions", EnvKey: "XIAOMI_API_KEY",
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
package provider
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMiniMaxCatalog(t *testing.T) {
|
||||
meta, ok := Get("minimax")
|
||||
if !ok || !reflect.DeepEqual(meta.Models, []Model{{ID: "MiniMax-M3.1-Flash", Name: "MiniMax M3.1 Flash"}, {ID: "MiniMax-M3", Name: "MiniMax M3"}}) {
|
||||
t.Fatalf("unexpected MiniMax models: %+v", meta.Models)
|
||||
}
|
||||
if meta.DefaultAPIType != "anthropic-messages" {
|
||||
t.Fatalf("changed default protocol: %s", meta.DefaultAPIType)
|
||||
}
|
||||
for _, provider := range All() {
|
||||
for _, model := range provider.Models {
|
||||
if strings.Contains(strings.ToLower(model.ID), "minimax") && model.ID != "MiniMax-M3.1-Flash" && model.ID != "MiniMax-M3" {
|
||||
t.Errorf("legacy suggestion in %s: %s", provider.Key, model.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMiniMaxVerificationRequests(t *testing.T) {
|
||||
for _, region := range []string{"https://api.minimaxi.com", "https://api.minimax.io"} {
|
||||
for _, protocol := range []string{"anthropic-messages", "openai-completions"} {
|
||||
for _, auth := range []string{AuthModeXAPIKey, AuthModeBearer} {
|
||||
for _, model := range []string{"MiniMax-M3.1-Flash", "MiniMax-M3", "MiniMax-M2.7", "MiniMax-M2.7-highspeed", "custom-model"} {
|
||||
t.Run(region+"/"+protocol+"/"+auth+"/"+model, func(t *testing.T) {
|
||||
base, endpoint := region+"/v1", "/chat/completions"
|
||||
if protocol == "anthropic-messages" {
|
||||
base, endpoint = region+"/anthropic", "/v1/messages"
|
||||
}
|
||||
built := BuildVerifyRequest("minimax", protocol, auth, base, "test-key", model)
|
||||
if built.URL != base+endpoint {
|
||||
t.Fatalf("URL = %s", built.URL)
|
||||
}
|
||||
var captured map[string]interface{}
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost || r.URL.Path != strings.TrimPrefix(base, region)+endpoint {
|
||||
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
if protocol == "anthropic-messages" && auth == AuthModeXAPIKey {
|
||||
if r.Header.Get("x-api-key") != "test-key" || r.Header.Get("Authorization") != "" {
|
||||
t.Error("incorrect API key authentication")
|
||||
}
|
||||
} else if r.Header.Get("Authorization") != "Bearer test-key" {
|
||||
t.Error("incorrect bearer authentication")
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&captured); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer server.Close()
|
||||
if err := VerifyAccount("minimax", protocol, auth, server.URL+strings.TrimPrefix(base, region), "test-key", model); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if captured["model"] != model || captured["stream"] != false || captured["max_tokens"] != float64(defaultVerifyMaxTokens) {
|
||||
t.Fatalf("unexpected serialized body: %#v", captured)
|
||||
}
|
||||
if model == "MiniMax-M3.1-Flash" {
|
||||
if protocol == "anthropic-messages" {
|
||||
if !reflect.DeepEqual(captured["thinking"], map[string]interface{}{"type": "adaptive"}) || !reflect.DeepEqual(captured["output_config"], map[string]interface{}{"effort": "max"}) || captured["reasoning_effort"] != nil {
|
||||
t.Fatalf("invalid Anthropic Flash reasoning: %#v", captured)
|
||||
}
|
||||
} else if captured["reasoning_effort"] != "max" || captured["thinking"] != nil || captured["output_config"] != nil {
|
||||
t.Fatalf("invalid OpenAI Flash reasoning: %#v", captured)
|
||||
}
|
||||
} else if captured["reasoning_effort"] != nil || captured["thinking"] != nil || captured["output_config"] != nil {
|
||||
t.Fatalf("changed legacy/custom reasoning: %#v", captured)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMiniMaxExplicitGatewayAndCustomModels(t *testing.T) {
|
||||
for _, provider := range []string{"bailian-coding-plan", "custom", "openrouter"} {
|
||||
for _, protocol := range []string{"openai-completions", "anthropic-messages"} {
|
||||
for _, model := range []string{"MiniMax-M2.5", "MiniMax-M3.1-Flash", "my-custom-model"} {
|
||||
request := BuildVerifyRequest(provider, protocol, AuthModeBearer, "https://example.test/v1", "test-key", model)
|
||||
var body map[string]interface{}
|
||||
if err := json.Unmarshal(request.Body, &body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if body["model"] != model || body["reasoning_effort"] != nil || body["output_config"] != nil || body["thinking"] != nil {
|
||||
t.Fatalf("changed explicit %s request: %#v", provider, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -118,10 +118,15 @@ func BuildVerifyRequest(provider, apiType, authMode, baseURL, apiKey, model stri
|
||||
headers["x-api-key"] = apiKey
|
||||
}
|
||||
headers["anthropic-version"] = "2023-06-01"
|
||||
request.Body = mustJSON(map[string]interface{}{
|
||||
body := map[string]interface{}{
|
||||
"model": model, "max_tokens": defaultVerifyMaxTokens, "stream": false,
|
||||
"messages": []map[string]interface{}{{"role": "user", "content": []map[string]string{{"type": "text", "text": "test"}}}},
|
||||
})
|
||||
}
|
||||
if provider == "minimax" && model == "MiniMax-M3.1-Flash" {
|
||||
body["thinking"] = map[string]string{"type": "adaptive"}
|
||||
body["output_config"] = map[string]string{"effort": "max"}
|
||||
}
|
||||
request.Body = mustJSON(body)
|
||||
case "openai-responses":
|
||||
request.URL = baseURL + "/responses"
|
||||
headers["Authorization"] = "Bearer " + apiKey
|
||||
@@ -131,9 +136,13 @@ func BuildVerifyRequest(provider, apiType, authMode, baseURL, apiKey, model stri
|
||||
if (provider != "ollama" && provider != "llmman") || strings.TrimSpace(apiKey) != "" {
|
||||
headers["Authorization"] = "Bearer " + apiKey
|
||||
}
|
||||
request.Body = mustJSON(map[string]interface{}{
|
||||
body := map[string]interface{}{
|
||||
"model": model, "messages": []map[string]string{{"role": "user", "content": "test"}}, "max_tokens": defaultVerifyMaxTokens, "stream": false,
|
||||
})
|
||||
}
|
||||
if provider == "minimax" && model == "MiniMax-M3.1-Flash" {
|
||||
body["reasoning_effort"] = "max"
|
||||
}
|
||||
request.Body = mustJSON(body)
|
||||
}
|
||||
return request
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/1Panel-dev/1Panel/agent/app/model"
|
||||
"github.com/1Panel-dev/1Panel/agent/global"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
@@ -18,6 +19,7 @@ type IAgentAccountRepo interface {
|
||||
DeleteByID(id uint) error
|
||||
List(opts ...DBOption) ([]model.AgentAccount, error)
|
||||
CountTextByProviders(providers []string) (map[string]int64, error)
|
||||
SaveLocal(account *model.AgentAccount, models []model.AgentAccountModel) error
|
||||
}
|
||||
|
||||
func NewIAgentAccountRepo() IAgentAccountRepo {
|
||||
@@ -97,6 +99,25 @@ func (a AgentAccountRepo) CountTextByProviders(providers []string) (map[string]i
|
||||
return counts, nil
|
||||
}
|
||||
|
||||
func (a AgentAccountRepo) SaveLocal(account *model.AgentAccount, models []model.AgentAccountModel) error {
|
||||
return global.DB.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Save(account).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Where("account_id = ?", account.ID).Delete(&model.AgentAccountModel{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if len(models) == 0 {
|
||||
return nil
|
||||
}
|
||||
for i := range models {
|
||||
models[i].AccountID = account.ID
|
||||
models[i].SortOrder = i + 1
|
||||
}
|
||||
return tx.Create(&models).Error
|
||||
})
|
||||
}
|
||||
|
||||
func normalizeProviders(providers []string) []string {
|
||||
seen := make(map[string]struct{}, len(providers))
|
||||
result := make([]string, 0, len(providers))
|
||||
|
||||
@@ -3,6 +3,7 @@ package repo
|
||||
import (
|
||||
"github.com/1Panel-dev/1Panel/agent/app/model"
|
||||
"github.com/1Panel-dev/1Panel/agent/global"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type LauncherRepo struct{}
|
||||
@@ -17,6 +18,11 @@ type ILauncherRepo interface {
|
||||
GetQuickJump(opts ...DBOption) (model.QuickJump, error)
|
||||
ListQuickJump(withAll bool) []model.QuickJump
|
||||
UpdateQuicks(quicks []model.QuickJump) error
|
||||
ListQuickDirectories() ([]model.QuickDirectory, error)
|
||||
CountQuickDirectories() (int64, error)
|
||||
CreateQuickDirectory(item *model.QuickDirectory) error
|
||||
UpdateQuickDirectory(item *model.QuickDirectory) error
|
||||
DeleteQuickDirectory(id uint, legacy bool) error
|
||||
}
|
||||
|
||||
func NewILauncherRepo() ILauncherRepo {
|
||||
@@ -95,13 +101,73 @@ func (u *LauncherRepo) UpdateQuicks(quicks []model.QuickJump) error {
|
||||
for _, item := range quicks {
|
||||
if err := tx.Model(&model.QuickJump{}).Where("id = ?", item.ID).Updates(map[string]interface{}{
|
||||
"is_show": item.IsShow,
|
||||
"detail": item.Detail,
|
||||
"alias": item.Alias,
|
||||
}).Error; err != nil {
|
||||
tx.Rollback()
|
||||
return err
|
||||
}
|
||||
}
|
||||
tx.Commit()
|
||||
return tx.Commit().Error
|
||||
}
|
||||
|
||||
// Legacy entries remain in the overview configuration until explicitly deleted.
|
||||
func legacyQuickDirectories(db *gorm.DB) *gorm.DB {
|
||||
return db.Model(&model.QuickJump{}).Where("name = ? AND is_show = ? AND detail <> ?", "File", true, "")
|
||||
}
|
||||
|
||||
func (u *LauncherRepo) ListQuickDirectories() ([]model.QuickDirectory, error) {
|
||||
items := make([]model.QuickDirectory, 0)
|
||||
if err := global.DB.Order("id ASC").Find(&items).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var quicks []model.QuickJump
|
||||
if err := legacyQuickDirectories(global.DB).Order("id ASC").Find(&quicks).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, quick := range quicks {
|
||||
items = append(items, model.QuickDirectory{BaseModel: quick.BaseModel, Path: quick.Detail, Alias: quick.Alias, Legacy: true})
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
func (u *LauncherRepo) CountQuickDirectories() (int64, error) {
|
||||
var count, legacyCount int64
|
||||
if err := global.DB.Model(&model.QuickDirectory{}).Count(&count).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if err := legacyQuickDirectories(global.DB).Count(&legacyCount).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return count + legacyCount, nil
|
||||
}
|
||||
func (u *LauncherRepo) CreateQuickDirectory(item *model.QuickDirectory) error {
|
||||
return global.DB.Create(item).Error
|
||||
}
|
||||
func (u *LauncherRepo) UpdateQuickDirectory(item *model.QuickDirectory) error {
|
||||
var result *gorm.DB
|
||||
if item.Legacy {
|
||||
result = legacyQuickDirectories(global.DB).Where("id = ?", item.ID).Updates(map[string]interface{}{"detail": item.Path, "alias": item.Alias})
|
||||
} else {
|
||||
result = global.DB.Model(&model.QuickDirectory{}).Where("id = ?", item.ID).Updates(map[string]interface{}{"path": item.Path, "alias": item.Alias})
|
||||
}
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return gorm.ErrRecordNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (u *LauncherRepo) DeleteQuickDirectory(id uint, legacy bool) error {
|
||||
var result *gorm.DB
|
||||
if legacy {
|
||||
result = legacyQuickDirectories(global.DB).Where("id = ?", id).Updates(map[string]interface{}{"detail": nil, "alias": nil})
|
||||
} else {
|
||||
result = global.DB.Delete(&model.QuickDirectory{}, id)
|
||||
}
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return gorm.ErrRecordNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -142,6 +142,9 @@ const (
|
||||
|
||||
func (a AgentService) Create(req dto.AgentCreateReq) (*dto.AgentItem, error) {
|
||||
agentType := req.AgentType
|
||||
if err := validateNativeAgentCreate(req); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := checkPortExist(req.WebUIPort); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -237,6 +240,8 @@ func (a AgentService) Create(req dto.AgentCreateReq) (*dto.AgentItem, error) {
|
||||
}
|
||||
} else if agentType == constant.AppCopaw {
|
||||
dashboardAuth = normalizeAgentDashboardAuth(req.DashboardUsername, req.DashboardPassword)
|
||||
} else if agentType == constant.AppDeepSeekHarness || agentType == constant.AppOctop {
|
||||
dashboardAuth = agentDashboardAuth{Username: req.DashboardUsername, Password: req.DashboardPassword}
|
||||
}
|
||||
|
||||
params := map[string]interface{}{
|
||||
@@ -245,6 +250,12 @@ func (a AgentService) Create(req dto.AgentCreateReq) (*dto.AgentItem, error) {
|
||||
constant.HostIP: "",
|
||||
}
|
||||
setAgentWebUIParams(params, agentType, detail.Version, req.WebUIPort)
|
||||
if agentType == constant.AppDeepSeekHarness {
|
||||
params["HTTPS_ACCESS_HOST"] = req.AccessHost
|
||||
}
|
||||
if agentType == constant.AppOctop {
|
||||
params["OCTOP_LOG_LEVEL"] = "info"
|
||||
}
|
||||
if agentType == constant.AppOpenclaw {
|
||||
if allowedOrigin := firstAllowedOrigin(allowedOrigins); allowedOrigin != "" {
|
||||
params["ALLOWED_ORIGIN"] = allowedOrigin
|
||||
@@ -738,7 +749,7 @@ func requiresAgentAccount(agentType string) bool {
|
||||
}
|
||||
|
||||
func setAgentWebUIParams(params map[string]interface{}, agentType, appVersion string, webUIPort int) {
|
||||
if agentType == constant.AppOpenclaw && isOpenclawHTTPSWindowVersion(appVersion) {
|
||||
if agentType == constant.AppDeepSeekHarness || agentType == constant.AppOpenclaw && isOpenclawHTTPSWindowVersion(appVersion) {
|
||||
params["PANEL_APP_PORT_HTTPS"] = webUIPort
|
||||
return
|
||||
}
|
||||
@@ -1044,7 +1055,7 @@ func (a AgentService) CreateAccount(req dto.AgentAccountCreateReq) error {
|
||||
return err
|
||||
}
|
||||
validateAvailability := req.ValidateAvailability == nil || *req.ValidateAvailability
|
||||
resolvedInput, err := resolveAgentAccountInput(provider, req.APIType, req.AuthMode, req.APIKey, req.BaseURL, verifyModel, validateAvailability)
|
||||
resolvedInput, err := resolveAgentAccountInput(provider, req.APIType, req.AuthMode, req.APIKey, req.BaseURL, verifyModel, validateAvailability, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -1097,12 +1108,15 @@ func (a AgentService) UpdateAccount(req dto.AgentAccountUpdateReq) error {
|
||||
if strings.TrimSpace(requestedVerifyModel) == "" {
|
||||
requestedVerifyModel = account.VerifyModel
|
||||
}
|
||||
verifyModel, err := resolveAgentAccountVerifyModel(provider, requestedVerifyModel, models)
|
||||
if err != nil {
|
||||
return err
|
||||
verifyModel := ""
|
||||
if account.AppInstallID == 0 || len(models) > 0 {
|
||||
verifyModel, err = resolveAgentAccountVerifyModel(provider, requestedVerifyModel, models)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
validateAvailability := req.ValidateAvailability == nil || *req.ValidateAvailability
|
||||
resolvedInput, err := resolveAgentAccountInput(provider, req.APIType, req.AuthMode, req.APIKey, req.BaseURL, verifyModel, validateAvailability)
|
||||
resolvedInput, err := resolveAgentAccountInput(provider, req.APIType, req.AuthMode, req.APIKey, req.BaseURL, verifyModel, validateAvailability, account.AppInstallID != 0 && len(models) == 0)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -1116,7 +1130,7 @@ func (a AgentService) UpdateAccount(req dto.AgentAccountUpdateReq) error {
|
||||
account.Remark = req.Remark
|
||||
account.Verified = true
|
||||
|
||||
if err := global.DB.Save(account).Error; err != nil {
|
||||
if err := agentAccountRepo.Save(account); err != nil {
|
||||
return err
|
||||
}
|
||||
terminalai.InvalidateTerminalRuntimeCache()
|
||||
@@ -1147,13 +1161,34 @@ func (a AgentService) PageAccounts(req dto.AgentAccountSearch) (int64, []dto.Age
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
installIDs := make([]uint, 0, len(list))
|
||||
for _, account := range list {
|
||||
if account.AppInstallID != 0 {
|
||||
installIDs = append(installIDs, account.AppInstallID)
|
||||
}
|
||||
}
|
||||
localInstalls := make(map[uint]bool, len(installIDs))
|
||||
if len(installIDs) > 0 {
|
||||
installs, err := appInstallRepo.ListBy(context.Background(), repo.WithByIDs(installIDs))
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
for _, install := range installs {
|
||||
localInstalls[install.ID] = true
|
||||
}
|
||||
}
|
||||
items := make([]dto.AgentAccountInfo, 0, len(list))
|
||||
for _, item := range list {
|
||||
apiKey := ""
|
||||
if item.RememberAPIKey {
|
||||
apiKey = item.APIKey
|
||||
}
|
||||
appInstallID := uint(0)
|
||||
if localInstalls[item.AppInstallID] {
|
||||
appInstallID = item.AppInstallID
|
||||
}
|
||||
items = append(items, dto.AgentAccountInfo{
|
||||
AppInstallID: appInstallID,
|
||||
ID: item.ID,
|
||||
MasterAccountID: item.MasterAccountID,
|
||||
Provider: item.Provider,
|
||||
@@ -1351,13 +1386,27 @@ func (a AgentService) SyncAgentsByAccount(account *model.AgentAccount) error {
|
||||
}
|
||||
|
||||
func (a AgentService) VerifyAccount(req dto.AgentAccountVerifyReq) error {
|
||||
_, err := resolveAgentAccountInput(req.Provider, req.APIType, req.AuthMode, req.APIKey, req.BaseURL, req.Model, true)
|
||||
_, err := resolveAgentAccountInput(req.Provider, req.APIType, req.AuthMode, req.APIKey, req.BaseURL, req.Model, true, false)
|
||||
return err
|
||||
}
|
||||
|
||||
func (a AgentService) DeleteAccount(req dto.AgentAccountDeleteReq) error {
|
||||
agentAccountMutationMu.Lock()
|
||||
defer agentAccountMutationMu.Unlock()
|
||||
account, err := agentAccountRepo.GetFirst(repo.WithByID(req.ID))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if account.AppInstallID != 0 && account.Provider != "ollama" {
|
||||
_, err := appInstallRepo.GetFirst(repo.WithByID(account.AppInstallID))
|
||||
if err == nil {
|
||||
return buserr.WithDetail("ErrInvalidParams", "Delete the local inference engine instead", nil)
|
||||
}
|
||||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if err := global.DB.Transaction(func(tx *gorm.DB) error {
|
||||
var agentCount int64
|
||||
if err := tx.Model(&model.Agent{}).Where("account_id = ?", req.ID).Count(&agentCount).Error; err != nil {
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/1Panel-dev/1Panel/agent/app/dto"
|
||||
"github.com/1Panel-dev/1Panel/agent/app/model"
|
||||
"github.com/1Panel-dev/1Panel/agent/app/repo"
|
||||
"github.com/1Panel-dev/1Panel/agent/constant"
|
||||
"github.com/1Panel-dev/1Panel/agent/global"
|
||||
terminalai "github.com/1Panel-dev/1Panel/agent/utils/terminal/ai"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func SyncLocalModelAccount(install model.AppInstall, accountID uint, baseURL string, models []dto.AgentAccountModel, updateBaseURL, createIfMissing bool) (*model.AgentAccount, error) {
|
||||
if install.ID == 0 || (install.App.Key != "ollama" && install.App.Key != "vllm") {
|
||||
return nil, fmt.Errorf("invalid local model installation")
|
||||
}
|
||||
agentAccountMutationMu.Lock()
|
||||
defer agentAccountMutationMu.Unlock()
|
||||
account, err := agentAccountRepo.GetFirst(repo.WithByAppInstallID(install.ID))
|
||||
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) && accountID != 0 {
|
||||
account, err = agentAccountRepo.GetFirst(repo.WithByID(accountID), repo.WithByProvider(install.App.Key))
|
||||
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
if err == nil && account.AppInstallID != 0 && account.AppInstallID != install.ID {
|
||||
return nil, fmt.Errorf("model account belongs to another installation")
|
||||
}
|
||||
}
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
if !createIfMissing {
|
||||
return nil, nil
|
||||
}
|
||||
name := install.Name
|
||||
for suffix := 1; ; suffix++ {
|
||||
existing, err := agentAccountRepo.List(repo.WithByProvider(install.App.Key), repo.WithByName(name))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(existing) == 0 {
|
||||
break
|
||||
}
|
||||
name = fmt.Sprintf("%s-%d", install.Name, suffix)
|
||||
}
|
||||
apiType, apiKey := "openai-completions", "vllm-local"
|
||||
if install.App.Key == "ollama" {
|
||||
apiType, apiKey = "openai-responses", "ollama-local"
|
||||
}
|
||||
account = &model.AgentAccount{Name: name, Provider: install.App.Key, BaseURL: baseURL, APIType: apiType, APIKey: apiKey, RememberAPIKey: true, Verified: true}
|
||||
}
|
||||
accountChanged := account.ID == 0 || account.AppInstallID != install.ID || (updateBaseURL && account.BaseURL != baseURL)
|
||||
account.AppInstallID = install.ID
|
||||
if updateBaseURL {
|
||||
account.BaseURL = baseURL
|
||||
}
|
||||
rows := make([]model.AgentAccountModel, 0, len(models))
|
||||
verifyModelExists := account.VerifyModel == ""
|
||||
for _, item := range models {
|
||||
rows = append(rows, model.AgentAccountModel{Model: item.ID, Name: item.Name})
|
||||
if item.ID == account.VerifyModel {
|
||||
verifyModelExists = true
|
||||
}
|
||||
}
|
||||
if !verifyModelExists {
|
||||
account.VerifyModel = ""
|
||||
accountChanged = true
|
||||
}
|
||||
var previous []model.AgentAccountModel
|
||||
if account.ID != 0 {
|
||||
previous, err = agentAccountModelRepo.List(repo.WithByAccountID(account.ID), repo.WithOrderAsc("sort_order"), repo.WithOrderAsc("id"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
modelsChanged := len(previous) != len(rows)
|
||||
if !modelsChanged {
|
||||
for i := range rows {
|
||||
if previous[i].Model != rows[i].Model || previous[i].Name != rows[i].Name {
|
||||
modelsChanged = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if modelsChanged {
|
||||
err = agentAccountRepo.SaveLocal(account, rows)
|
||||
} else if accountChanged {
|
||||
err = agentAccountRepo.Save(account)
|
||||
} else {
|
||||
return account, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
terminalai.InvalidateTerminalRuntimeCache()
|
||||
terminalai.InvalidateFileAIRuntimeCache()
|
||||
return account, nil
|
||||
}
|
||||
|
||||
func SyncOllamaModelAccount(createIfMissing bool) error {
|
||||
app, err := appRepo.GetFirst(appRepo.WithKey("ollama"))
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
install, err := appInstallRepo.GetFirst(appInstallRepo.WithAppId(app.ID))
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !install.SyncModelAccount {
|
||||
return nil
|
||||
}
|
||||
return syncOllamaModelAccount(install, fmt.Sprintf("http://%s:11434/v1", install.ContainerName), false, createIfMissing)
|
||||
}
|
||||
|
||||
func syncOllamaModelAccount(install model.AppInstall, baseURL string, updateBaseURL, createIfMissing bool) error {
|
||||
models, err := aiRepo.List(repo.WithByStatus(constant.StatusSuccess), repo.WithOrderAsc("name"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
items := make([]dto.AgentAccountModel, 0, len(models))
|
||||
for _, item := range models {
|
||||
items = append(items, dto.AgentAccountModel{ID: item.Name, Name: item.Name})
|
||||
}
|
||||
_, err = SyncLocalModelAccount(install, 0, baseURL, items, updateBaseURL, createIfMissing)
|
||||
return err
|
||||
}
|
||||
|
||||
func loadOllamaModelAccountAddress(install model.AppInstall) (string, string, error) {
|
||||
addressType := install.ModelAccountBaseURLType
|
||||
if addressType == "" {
|
||||
addressType = "container"
|
||||
}
|
||||
account, err := agentAccountRepo.GetFirst(repo.WithByAppInstallID(install.ID), repo.WithByProvider("ollama"))
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return addressType, install.ModelAccountBaseURL, nil
|
||||
}
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
if addressType != "custom" {
|
||||
expectedURL, err := ResolveLocalModelAccountBaseURL(addressType, "", install.ContainerName, "11434", install.HttpPort)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
if strings.TrimRight(account.BaseURL, "/") != expectedURL {
|
||||
addressType = "custom"
|
||||
}
|
||||
}
|
||||
return addressType, account.BaseURL, nil
|
||||
}
|
||||
|
||||
func CleanupLocalModelAccount(installID uint) {
|
||||
if installID == 0 {
|
||||
return
|
||||
}
|
||||
accounts, err := agentAccountRepo.List(repo.WithByAppInstallID(installID))
|
||||
if err != nil {
|
||||
global.LOG.Errorf("load local model accounts: %v", err)
|
||||
return
|
||||
}
|
||||
for _, account := range accounts {
|
||||
if err := (AgentService{}).DeleteAccount(dto.AgentAccountDeleteReq{ID: account.ID}); err != nil {
|
||||
global.LOG.Warnf("keep local model account %d after uninstall: %v", account.ID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func ResolveLocalModelAccountBaseURL(addressType, customURL, containerName, containerPort string, hostPort int) (string, error) {
|
||||
switch addressType {
|
||||
case "custom":
|
||||
baseURL := strings.TrimRight(strings.TrimSpace(customURL), "/")
|
||||
if !strings.HasPrefix(baseURL, "http://") && !strings.HasPrefix(baseURL, "https://") {
|
||||
return "", fmt.Errorf("model account base URL must start with http:// or https://")
|
||||
}
|
||||
return baseURL, nil
|
||||
case "localhost":
|
||||
return fmt.Sprintf("http://127.0.0.1:%d/v1", hostPort), nil
|
||||
case "systemIP":
|
||||
systemIP, err := settingRepo.GetValueByKey("SystemIP")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
systemIP = strings.TrimSpace(systemIP)
|
||||
if systemIP == "" {
|
||||
systemIP = "127.0.0.1"
|
||||
}
|
||||
return fmt.Sprintf("http://%s:%d/v1", systemIP, hostPort), nil
|
||||
default:
|
||||
return fmt.Sprintf("http://%s:%s/v1", containerName, containerPort), nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/1Panel-dev/1Panel/agent/app/dto"
|
||||
"github.com/1Panel-dev/1Panel/agent/app/model"
|
||||
providercatalog "github.com/1Panel-dev/1Panel/agent/app/provider"
|
||||
)
|
||||
|
||||
func TestMiniMaxOpenclawModelMetadata(t *testing.T) {
|
||||
for _, provider := range []string{"minimax", "custom", "bailian-coding-plan"} {
|
||||
for _, protocol := range []string{"anthropic-messages", "openai-completions"} {
|
||||
for _, id := range []string{"MiniMax-M3.1-Flash", "MiniMax-M3", "MiniMax-M2.7", "MiniMax-M2.5", "saved-custom"} {
|
||||
t.Run(provider+"/"+protocol+"/"+id, func(t *testing.T) {
|
||||
account := &model.AgentAccount{Provider: provider, APIType: protocol, BaseURL: "https://example.test/v1", APIKey: "test-key"}
|
||||
_, entry, _, _, err := buildOpenclawAccountModelConfig(account, dto.AgentAccountModel{ID: id, Name: "Saved name"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
payload, err := json.Marshal(entry)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var actual modelEntry
|
||||
if err := json.Unmarshal(payload, &actual); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if actual.ID != id || actual.Name != "Saved name" {
|
||||
t.Fatalf("changed saved identity: %s", payload)
|
||||
}
|
||||
if provider == "minimax" && id == "MiniMax-M3.1-Flash" {
|
||||
if actual.ContextWindow != 1000000 || actual.MaxTokens != 524288 || !reflect.DeepEqual(actual.Input, []string{"text", "image"}) {
|
||||
t.Fatalf("incorrect direct Flash metadata: %s", payload)
|
||||
}
|
||||
} else if actual.ContextWindow != 0 || actual.MaxTokens != 0 {
|
||||
t.Fatalf("changed existing/gateway limits: %s", payload)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMiniMaxPreservesSavedOpenclawMetadata(t *testing.T) {
|
||||
current := &modelsConfig{Mode: "merge", Providers: map[string]modelProvider{
|
||||
"minimax": {Models: []modelEntry{{ID: "MiniMax-M3.1-Flash", Input: []string{"text"}, ContextWindow: 32000, MaxTokens: 4096}}},
|
||||
}}
|
||||
conf := map[string]interface{}{}
|
||||
if err := applyOpenclawModelsConfig(conf, current); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
next := &modelsConfig{Mode: "merge", Providers: map[string]modelProvider{
|
||||
"minimax": {Models: []modelEntry{{ID: "MiniMax-M3.1-Flash", Input: []string{"text", "image"}, ContextWindow: 1000000, MaxTokens: 524288}}},
|
||||
}}
|
||||
preserveOpenclawModelMetadata(conf, next)
|
||||
if !reflect.DeepEqual(next.Providers["minimax"].Models, current.Providers["minimax"].Models) {
|
||||
t.Fatalf("saved metadata changed: %+v", next)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMiniMaxDefaultAccountRequest(t *testing.T) {
|
||||
for _, protocol := range []string{"anthropic-messages", "openai-completions"} {
|
||||
account := &model.AgentAccount{Provider: "minimax", APIType: protocol}
|
||||
models, err := buildInitialAgentAccountModels(account, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
selected, err := resolveAgentAccountVerifyModel("minimax", "", models)
|
||||
if err != nil || selected != "MiniMax-M3.1-Flash" || len(models) != 2 || models[1].ID != "MiniMax-M3" {
|
||||
t.Fatalf("unexpected default: %s, %+v, %v", selected, models, err)
|
||||
}
|
||||
request := providercatalog.BuildVerifyRequest("minimax", protocol, "", "https://example.test", "test-key", selected)
|
||||
var body map[string]interface{}
|
||||
if err := json.Unmarshal(request.Body, &body); err != nil || body["model"] != selected {
|
||||
t.Fatalf("default request: %s, %v", request.Body, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMiniMaxHermesConfiguration(t *testing.T) {
|
||||
for _, protocol := range []string{"anthropic-messages", "openai-completions"} {
|
||||
for _, id := range []string{"MiniMax-M3.1-Flash", "MiniMax-M3", "MiniMax-M2.7", "my-custom-model"} {
|
||||
dir := t.TempDir()
|
||||
account := &model.AgentAccount{Provider: "minimax", APIType: protocol, BaseURL: "https://api.minimaxi.com/anthropic", APIKey: "test-key"}
|
||||
if err := writeHermesConfig(dir, account, id, ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
conf, err := readHermesConfig(filepath.Join(dir, "config.yaml"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if conf.Model.Default != id || conf.Model.Provider != "minimax-cn" || conf.Model.BaseURL != account.BaseURL {
|
||||
t.Fatalf("changed Hermes selection: %+v", conf.Model)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMiniMaxExplicitAccountModels(t *testing.T) {
|
||||
for _, provider := range []string{"minimax", "bailian-coding-plan", "custom"} {
|
||||
account := &model.AgentAccount{Provider: provider, APIType: "openai-completions"}
|
||||
requested := []dto.AgentAccountModel{{ID: "MiniMax-M2.5", Name: "Saved legacy"}, {ID: "my-custom-model", Name: "Saved custom"}}
|
||||
actual, err := buildInitialAgentAccountModels(account, requested)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !reflect.DeepEqual(actual, requested) {
|
||||
t.Fatalf("changed explicit %s models: %+v", provider, actual)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"net"
|
||||
"regexp"
|
||||
"strings"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/1Panel-dev/1Panel/agent/app/dto"
|
||||
"github.com/1Panel-dev/1Panel/agent/buserr"
|
||||
"github.com/1Panel-dev/1Panel/agent/constant"
|
||||
)
|
||||
|
||||
var deepSeekUsernamePattern = regexp.MustCompile(`^[A-Za-z0-9._-]+$`)
|
||||
var deepSeekHostLabelPattern = regexp.MustCompile(`^[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?$`)
|
||||
|
||||
func isDeepSeekAccessHost(value string) bool {
|
||||
if ip := net.ParseIP(value); ip != nil {
|
||||
return ip.To4() != nil && !strings.Contains(value, ":")
|
||||
}
|
||||
if value == "" || len(value) > 253 || strings.Trim(value, "0123456789.") == "" {
|
||||
return false
|
||||
}
|
||||
for _, label := range strings.Split(value, ".") {
|
||||
if !deepSeekHostLabelPattern.MatchString(label) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func validateNativeAgentCreate(req dto.AgentCreateReq) error {
|
||||
if req.AgentType != constant.AppDeepSeekHarness && req.AgentType != constant.AppOctop {
|
||||
return nil
|
||||
}
|
||||
if strings.TrimSpace(req.DashboardUsername) == "" || strings.ContainsAny(req.DashboardUsername, "\r\n\x00") {
|
||||
return buserr.WithDetail("ErrInvalidParams", "dashboardUsername", nil)
|
||||
}
|
||||
if strings.ContainsAny(req.DashboardPassword, "\r\n\x00") || req.DashboardPassword != strings.TrimSpace(req.DashboardPassword) {
|
||||
return buserr.WithDetail("ErrInvalidParams", "dashboardPassword", nil)
|
||||
}
|
||||
if req.AgentType == constant.AppDeepSeekHarness {
|
||||
if !isDeepSeekAccessHost(req.AccessHost) {
|
||||
return buserr.WithDetail("ErrInvalidParams", "accessHost", nil)
|
||||
}
|
||||
if !deepSeekUsernamePattern.MatchString(req.DashboardUsername) {
|
||||
return buserr.WithDetail("ErrInvalidParams", "dashboardUsername", nil)
|
||||
}
|
||||
if utf8.RuneCountInString(req.DashboardPassword) < 12 {
|
||||
return buserr.WithDetail("ErrInvalidParams", "dashboardPassword", nil)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
hasLetter, hasDigit := false, false
|
||||
for _, char := range req.DashboardPassword {
|
||||
hasLetter = hasLetter || unicode.IsLetter(char)
|
||||
hasDigit = hasDigit || unicode.IsDigit(char)
|
||||
}
|
||||
if utf8.RuneCountInString(req.DashboardPassword) < 8 || !hasLetter || !hasDigit {
|
||||
return buserr.WithDetail("ErrInvalidParams", "dashboardPassword", nil)
|
||||
}
|
||||
switch strings.ToLower(req.DashboardPassword) {
|
||||
case "password", "password1", "password12", "password123", "12345678", "123456789", "qwerty123",
|
||||
"admin123", "welcome1", "letmein1", "changeme1", "octop123", "abc12345", "iloveyou1":
|
||||
return buserr.WithDetail("ErrInvalidParams", "dashboardPassword", nil)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -98,7 +98,7 @@ func ensureContainerRunning(containerName string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func resolveAgentAccountInput(provider, apiType, authMode, apiKey, baseURL, modelID string, validateAvailability bool) (resolvedAgentAccountInput, error) {
|
||||
func resolveAgentAccountInput(provider, apiType, authMode, apiKey, baseURL, modelID string, validateAvailability, allowEmptyModel bool) (resolvedAgentAccountInput, error) {
|
||||
resolvedAPIKey := strings.TrimSpace(apiKey)
|
||||
resolvedAPIType := strings.TrimSpace(apiType)
|
||||
resolvedAuthMode, err := providercatalog.ResolveAuthMode(provider, resolvedAPIType, authMode)
|
||||
@@ -113,7 +113,7 @@ func resolveAgentAccountInput(provider, apiType, authMode, apiKey, baseURL, mode
|
||||
return resolvedAgentAccountInput{}, err
|
||||
}
|
||||
modelID = strings.TrimSpace(modelID)
|
||||
if modelID == "" {
|
||||
if modelID == "" && !allowEmptyModel {
|
||||
return resolvedAgentAccountInput{}, buserr.New("ErrAgentAccountModelsRequired")
|
||||
}
|
||||
imageAPI := providercatalog.IsImageAPIType(resolvedAPIType)
|
||||
@@ -415,6 +415,15 @@ func buildAgentItem(agent *model.Agent, appInstall *model.AppInstall, envMap map
|
||||
} else {
|
||||
item.WebUIPort = appInstall.HttpPort
|
||||
}
|
||||
if agentType == constant.AppDeepSeekHarness {
|
||||
item.WebUIPort = appInstall.HttpsPort
|
||||
if values, err := readAgentEnvMap(appInstall.GetEnvPath()); err == nil {
|
||||
accessHost := values["HTTPS_ACCESS_HOST"]
|
||||
if isDeepSeekAccessHost(accessHost) {
|
||||
item.AccessHost = accessHost
|
||||
}
|
||||
}
|
||||
}
|
||||
item.Path = appInstall.GetPath()
|
||||
item.Status = appInstall.Status
|
||||
item.Message = appInstall.Message
|
||||
@@ -442,7 +451,8 @@ func localizedAgentProviderName(provider string) string {
|
||||
}
|
||||
|
||||
func isAgentAppKey(appKey string) bool {
|
||||
return appKey == constant.AppOpenclaw || appKey == constant.AppCopaw || appKey == constant.AppHermesAgent
|
||||
return appKey == constant.AppOpenclaw || appKey == constant.AppCopaw || appKey == constant.AppHermesAgent ||
|
||||
appKey == constant.AppDeepSeekHarness || appKey == constant.AppOctop
|
||||
}
|
||||
|
||||
func isOpenclawLegacyHTTPVersion(version string) bool {
|
||||
@@ -1253,7 +1263,12 @@ func buildOpenclawAccountModelConfig(account *model.AgentAccount, model dto.Agen
|
||||
if err != nil {
|
||||
return "", modelEntry{}, "", modelProvider{}, err
|
||||
}
|
||||
return providerPatch.PrimaryModel, buildOpenclawModelEntry(providerPatch.ModelID, model), providerPatch.ProviderKey, modelProvider{
|
||||
entry := buildOpenclawModelEntry(providerPatch.ModelID, model)
|
||||
if account.Provider == "minimax" && providerPatch.ModelID == "MiniMax-M3.1-Flash" {
|
||||
entry.ContextWindow = 1000000
|
||||
entry.MaxTokens = 524288
|
||||
}
|
||||
return providerPatch.PrimaryModel, entry, providerPatch.ProviderKey, modelProvider{
|
||||
ApiKey: providerPatch.APIKey,
|
||||
BaseUrl: providerPatch.BaseURL,
|
||||
Api: providerPatch.APIType,
|
||||
@@ -1641,6 +1656,10 @@ func agentDashboardAuthEnvKeys(agentType string) (string, string, bool) {
|
||||
return hermesDashboardUsernameEnvKey, hermesDashboardPasswordEnvKey, true
|
||||
case constant.AppCopaw:
|
||||
return qwenPawAuthUsernameEnvKey, qwenPawAuthPasswordEnvKey, true
|
||||
case constant.AppDeepSeekHarness:
|
||||
return "DSH_AUTH_USERNAME", "DSH_AUTH_PASSWORD", true
|
||||
case constant.AppOctop:
|
||||
return "OCTOP_ADMIN_USERNAME", "OCTOP_DEFAULT_PASSWORD", true
|
||||
default:
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
+30
-2
@@ -35,6 +35,7 @@ type IAIToolService interface {
|
||||
Recreate(req dto.OllamaModelName) error
|
||||
Delete(req dto.ForceDelete) error
|
||||
Sync() ([]dto.OllamaModelDropList, error)
|
||||
SyncAccount(req dto.OllamaModelAccountSync) error
|
||||
LoadDetail(name string) (string, error)
|
||||
BindDomain(req dto.OllamaBindDomain) error
|
||||
GetBindDomain(req dto.OllamaBindDomainReq) (*dto.OllamaBindDomainRes, error)
|
||||
@@ -125,6 +126,9 @@ func (u *AIToolService) Create(req dto.OllamaModelName) error {
|
||||
if err := taskItem.Execute(); err != nil {
|
||||
_ = aiRepo.Update(info.ID, map[string]interface{}{"status": constant.StatusFailed, "message": err.Error()})
|
||||
}
|
||||
if err := SyncOllamaModelAccount(false); err != nil {
|
||||
global.LOG.Errorf("sync Ollama model account after pull: %v", err)
|
||||
}
|
||||
}()
|
||||
return nil
|
||||
}
|
||||
@@ -179,6 +183,9 @@ func (u *AIToolService) Recreate(req dto.OllamaModelName) error {
|
||||
if err := taskItem.Execute(); err != nil {
|
||||
_ = aiRepo.Update(modelInfo.ID, map[string]interface{}{"status": constant.StatusFailed, "message": err.Error()})
|
||||
}
|
||||
if err := SyncOllamaModelAccount(false); err != nil {
|
||||
global.LOG.Errorf("sync Ollama model account after pull: %v", err)
|
||||
}
|
||||
}()
|
||||
return nil
|
||||
}
|
||||
@@ -202,7 +209,7 @@ func (u *AIToolService) Delete(req dto.ForceDelete) error {
|
||||
logItem := path.Join(global.Dir.DataDir, "log", "AITools", item.Name)
|
||||
_ = os.Remove(logItem)
|
||||
}
|
||||
return nil
|
||||
return SyncOllamaModelAccount(false)
|
||||
}
|
||||
|
||||
func (u *AIToolService) Sync() ([]dto.OllamaModelDropList, error) {
|
||||
@@ -250,7 +257,28 @@ func (u *AIToolService) Sync() ([]dto.OllamaModelDropList, error) {
|
||||
_ = aiRepo.Create(&item)
|
||||
}
|
||||
|
||||
return dropList, nil
|
||||
return dropList, SyncOllamaModelAccount(false)
|
||||
}
|
||||
|
||||
func (u *AIToolService) SyncAccount(req dto.OllamaModelAccountSync) error {
|
||||
install, err := appInstallRepo.GetFirst(repo.WithByID(req.ID))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if install.App.Key != "ollama" {
|
||||
return buserr.New("ErrInvalidParams")
|
||||
}
|
||||
baseURL, err := ResolveLocalModelAccountBaseURL(req.ModelAccountBaseURLType, req.ModelAccountBaseURL, install.ContainerName, "11434", install.HttpPort)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := syncOllamaModelAccount(install, baseURL, true, true); err != nil {
|
||||
return err
|
||||
}
|
||||
return appInstallRepo.BatchUpdateBy(map[string]interface{}{
|
||||
"model_account_base_url_type": req.ModelAccountBaseURLType,
|
||||
"model_account_base_url": baseURL,
|
||||
}, repo.WithByID(install.ID))
|
||||
}
|
||||
|
||||
func (u *AIToolService) BindDomain(req dto.OllamaBindDomain) error {
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/1Panel-dev/1Panel/agent/app/dto"
|
||||
"github.com/1Panel-dev/1Panel/agent/utils/docker"
|
||||
"github.com/mattn/go-shellwords"
|
||||
)
|
||||
|
||||
func AIBenchmarkSources(kind, selected string) ([]dto.AIBenchmarkSource, error) {
|
||||
if kind != "vllm" && kind != "ollama" {
|
||||
return nil, errors.New("unsupported benchmark source")
|
||||
}
|
||||
selectedID := strings.SplitN(selected, ":", 2)[0]
|
||||
type instance struct {
|
||||
id uint
|
||||
name, container string
|
||||
port int
|
||||
}
|
||||
instances := []instance{}
|
||||
apps, err := appRepo.GetBy(appRepo.WithKey(kind))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, app := range apps {
|
||||
installs, err := appInstallRepo.ListBy(context.Background(), appInstallRepo.WithAppId(app.ID))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, install := range installs {
|
||||
if selected != "" && strconv.FormatUint(uint64(install.ID), 10) != selectedID {
|
||||
continue
|
||||
}
|
||||
instances = append(instances, instance{id: install.ID, name: install.Name, container: install.ContainerName, port: install.HttpPort})
|
||||
}
|
||||
}
|
||||
results := []dto.AIBenchmarkSource{}
|
||||
if len(instances) == 0 {
|
||||
return results, nil
|
||||
}
|
||||
client, err := docker.NewDockerClient()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer client.Close()
|
||||
if !strings.HasPrefix(client.DaemonHost(), "unix://") {
|
||||
return nil, errors.New("benchmark shortcuts require a Docker daemon on the main node; enter the remote service address manually")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
for _, instance := range instances {
|
||||
base := dto.AIBenchmarkSource{Kind: kind, Name: instance.name, Endpoint: "/v1/chat/completions", Models: []string{}, Status: "unknown"}
|
||||
inspected, err := client.ContainerInspect(ctx, instance.container)
|
||||
if err != nil {
|
||||
base.ID = strconv.FormatUint(uint64(instance.id), 10)
|
||||
base.Message = "container inspection unavailable"
|
||||
results = append(results, base)
|
||||
continue
|
||||
}
|
||||
if inspected.State != nil {
|
||||
base.Status = inspected.State.Status
|
||||
}
|
||||
args := []string{}
|
||||
if inspected.Config != nil {
|
||||
args = append(args, inspected.Config.Entrypoint...)
|
||||
args = append(args, inspected.Config.Cmd...)
|
||||
}
|
||||
if len(args) >= 3 && (args[0] == "sh" || args[0] == "bash" || strings.HasSuffix(args[0], "/sh") || strings.HasSuffix(args[0], "/bash")) && args[1] == "-c" {
|
||||
parser := shellwords.NewParser()
|
||||
parser.ParseEnv = false
|
||||
parser.ParseBacktick = false
|
||||
if parsed, err := parser.Parse(args[2]); err == nil {
|
||||
args = parsed
|
||||
}
|
||||
}
|
||||
flags := map[string]string{}
|
||||
for i, arg := range args {
|
||||
if key, value, ok := strings.Cut(arg, "="); ok && strings.HasPrefix(key, "--") {
|
||||
flags[key] = value
|
||||
} else if strings.HasPrefix(arg, "--") && i+1 < len(args) && !strings.HasPrefix(args[i+1], "--") {
|
||||
flags[arg] = args[i+1]
|
||||
}
|
||||
}
|
||||
if name := flags["--served-model-name"]; name != "" {
|
||||
base.Models = []string{name}
|
||||
}
|
||||
tokenizer := flags["--tokenizer"]
|
||||
if tokenizer == "" {
|
||||
tokenizer = flags["--model"]
|
||||
for i, arg := range args {
|
||||
if tokenizer == "" && arg == "serve" && i+1 < len(args) && !strings.HasPrefix(args[i+1], "-") {
|
||||
tokenizer = args[i+1]
|
||||
}
|
||||
}
|
||||
}
|
||||
if kind != "ollama" && tokenizer != "" {
|
||||
if !filepath.IsAbs(tokenizer) {
|
||||
base.Tokenizer = tokenizer
|
||||
} else {
|
||||
longest := 0
|
||||
for _, mount := range inspected.Mounts {
|
||||
relative, err := filepath.Rel(mount.Destination, tokenizer)
|
||||
if err == nil && relative != ".." && !strings.HasPrefix(relative, ".."+string(filepath.Separator)) && len(mount.Destination) > longest {
|
||||
candidate := filepath.Join(mount.Source, relative)
|
||||
if info, err := os.Stat(candidate); err == nil && info.IsDir() {
|
||||
base.Tokenizer = candidate
|
||||
longest = len(mount.Destination)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
ports := map[int]string{}
|
||||
if instance.port > 0 {
|
||||
ports[instance.port] = "127.0.0.1"
|
||||
}
|
||||
if inspected.NetworkSettings != nil {
|
||||
for _, bindings := range inspected.NetworkSettings.Ports {
|
||||
for _, binding := range bindings {
|
||||
port, _ := strconv.Atoi(binding.HostPort)
|
||||
if port > 0 {
|
||||
host := binding.HostIP
|
||||
if host == "" || host == "0.0.0.0" || host == "::" {
|
||||
host = "127.0.0.1"
|
||||
}
|
||||
ports[port] = host
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(ports) == 0 && inspected.HostConfig != nil && inspected.HostConfig.NetworkMode == "host" {
|
||||
port, _ := strconv.Atoi(flags["--port"])
|
||||
if port > 0 {
|
||||
ports[port] = "127.0.0.1"
|
||||
}
|
||||
}
|
||||
ordered := []int{}
|
||||
for port := range ports {
|
||||
ordered = append(ordered, port)
|
||||
}
|
||||
sort.Ints(ordered)
|
||||
if len(ordered) == 0 {
|
||||
ordered = append(ordered, 0)
|
||||
}
|
||||
for _, port := range ordered {
|
||||
source := base
|
||||
source.ID = fmt.Sprintf("%d:%d", instance.id, port)
|
||||
if selected != "" && source.ID != selected {
|
||||
continue
|
||||
}
|
||||
if port > 0 {
|
||||
source.BaseURL = "http://" + net.JoinHostPort(ports[port], strconv.Itoa(port))
|
||||
}
|
||||
if selected == source.ID && source.BaseURL != "" && source.Status == "running" {
|
||||
endpoint := "/v1/models"
|
||||
if kind == "ollama" {
|
||||
endpoint = "/api/tags"
|
||||
}
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodGet, source.BaseURL+endpoint, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
response, err := (&http.Client{Timeout: 3 * time.Second, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}).Do(request)
|
||||
if err != nil {
|
||||
source.Message = "model list unavailable; enter the served model name"
|
||||
} else {
|
||||
raw, readErr := io.ReadAll(io.LimitReader(response.Body, 2*1024*1024))
|
||||
response.Body.Close()
|
||||
if response.StatusCode == http.StatusOK && readErr == nil {
|
||||
var data struct {
|
||||
Data []struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"data"`
|
||||
Models []struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"models"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &data); err == nil {
|
||||
source.Models = []string{}
|
||||
for _, model := range data.Data {
|
||||
source.Models = append(source.Models, model.ID)
|
||||
}
|
||||
for _, model := range data.Models {
|
||||
source.Models = append(source.Models, model.Name)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
source.Message = fmt.Sprintf("model list HTTP %d; provide model name and credentials if required", response.StatusCode)
|
||||
}
|
||||
}
|
||||
}
|
||||
results = append(results, source)
|
||||
}
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
+56
-15
@@ -258,26 +258,35 @@ func (a AppService) GetAppDetail(appID uint, version, appType string) (response.
|
||||
fileOp := files.NewFileOp()
|
||||
|
||||
versionPath := filepath.Join(app.GetAppResourcePath(), detail.Version)
|
||||
if !fileOp.Stat(versionPath) || detail.Update {
|
||||
if err = downloadApp(app, detail, nil, nil); err != nil && !fileOp.Stat(versionPath) {
|
||||
return appDetailDTO, err
|
||||
}
|
||||
if err = downloadApp(app, detail, nil, nil); err != nil && !fileOp.Stat(versionPath) {
|
||||
return appDetailDTO, err
|
||||
}
|
||||
if err == nil && app.Resource == constant.AppResourceRemote {
|
||||
detail.Update = false
|
||||
appDetailDTO.Update = false
|
||||
}
|
||||
switch app.Type {
|
||||
case constant.RuntimePHP:
|
||||
paramsPath := filepath.Join(versionPath, "data.yml")
|
||||
if !fileOp.Stat(paramsPath) {
|
||||
return appDetailDTO, buserr.WithDetail("ErrFileNotExist", paramsPath, nil)
|
||||
}
|
||||
param, err := fileOp.GetContent(paramsPath)
|
||||
if err != nil {
|
||||
return appDetailDTO, err
|
||||
}
|
||||
paramMap := make(map[string]interface{})
|
||||
if err = yaml.Unmarshal(param, ¶mMap); err != nil {
|
||||
return appDetailDTO, err
|
||||
if app.Resource == constant.AppResourceRemote {
|
||||
if err = json.Unmarshal([]byte(detail.Params), ¶mMap); err != nil {
|
||||
return appDetailDTO, err
|
||||
}
|
||||
appDetailDTO.Params = paramMap
|
||||
} else {
|
||||
paramsPath := filepath.Join(versionPath, "data.yml")
|
||||
if !fileOp.Stat(paramsPath) {
|
||||
return appDetailDTO, buserr.WithDetail("ErrFileNotExist", paramsPath, nil)
|
||||
}
|
||||
param, err := fileOp.GetContent(paramsPath)
|
||||
if err != nil {
|
||||
return appDetailDTO, err
|
||||
}
|
||||
if err = yaml.Unmarshal(param, ¶mMap); err != nil {
|
||||
return appDetailDTO, err
|
||||
}
|
||||
appDetailDTO.Params = paramMap["additionalProperties"]
|
||||
}
|
||||
appDetailDTO.Params = paramMap["additionalProperties"]
|
||||
composePath := filepath.Join(versionPath, "docker-compose.yml")
|
||||
if !fileOp.Stat(composePath) {
|
||||
return appDetailDTO, buserr.WithDetail("ErrFileNotExist", composePath, nil)
|
||||
@@ -308,6 +317,12 @@ func (a AppService) GetAppDetail(appID uint, version, appType string) (response.
|
||||
if appDetailDTO.DockerCompose == "" {
|
||||
filename := filepath.Base(appDetailDTO.DownloadUrl)
|
||||
dockerComposeUrl := fmt.Sprintf("%s%s", strings.TrimSuffix(appDetailDTO.DownloadUrl, filename), "docker-compose.yml")
|
||||
if app.Resource == constant.AppResourceRemote {
|
||||
dockerComposeUrl, err = currentAppDownloadURL(dockerComposeUrl)
|
||||
if err != nil {
|
||||
return appDetailDTO, err
|
||||
}
|
||||
}
|
||||
statusCode, composeRes, err := req_helper.HandleRequest(dockerComposeUrl, http.MethodGet, constant.TimeOut20s)
|
||||
if statusCode == http.StatusNotFound {
|
||||
return appDetailDTO, buserr.New("ErrAppVersionUnavailable")
|
||||
@@ -434,6 +449,12 @@ func (a AppService) installWithHooks(req request.AppInstallCreate, executeScript
|
||||
} else {
|
||||
if appDetail.DockerCompose == "" {
|
||||
dockerComposeUrl := fmt.Sprintf("%s/%s/1panel/%s/%s/docker-compose.yml", global.AppRepoURL(), global.CONF.Base.Mode, app.Key, appDetail.Version)
|
||||
if app.Resource == constant.AppResourceRemote {
|
||||
dockerComposeUrl, err = currentAppDownloadURL(dockerComposeUrl)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
var statusCode int
|
||||
statusCode, composeRes, err = req_helper.HandleRequest(dockerComposeUrl, http.MethodGet, constant.TimeOut20s)
|
||||
if statusCode == http.StatusNotFound {
|
||||
@@ -478,6 +499,22 @@ func (a AppService) installWithHooks(req request.AppInstallCreate, executeScript
|
||||
req.Params[constant.ContainerName] = containerName
|
||||
appInstall.ContainerName = containerName
|
||||
|
||||
modelAccountBaseURL := ""
|
||||
if app.Key == "ollama" {
|
||||
appInstall.SyncModelAccount = req.SyncModelAccount == nil || *req.SyncModelAccount
|
||||
appInstall.ModelAccountBaseURLType = req.ModelAccountBaseURLType
|
||||
if appInstall.ModelAccountBaseURLType == "" {
|
||||
appInstall.ModelAccountBaseURLType = "container"
|
||||
}
|
||||
appInstall.ModelAccountBaseURL = req.ModelAccountBaseURL
|
||||
}
|
||||
if app.Key == "ollama" && appInstall.SyncModelAccount {
|
||||
modelAccountBaseURL, err = ResolveLocalModelAccountBaseURL(req.ModelAccountBaseURLType, req.ModelAccountBaseURL, containerName, "11434", httpPort)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
index := 0
|
||||
serviceName := ""
|
||||
for k := range servicesMap {
|
||||
@@ -588,6 +625,10 @@ func (a AppService) installWithHooks(req request.AppInstallCreate, executeScript
|
||||
return err
|
||||
}
|
||||
updateToolApp(appInstall)
|
||||
if app.Key == "ollama" && appInstall.SyncModelAccount {
|
||||
_, err := SyncLocalModelAccount(*appInstall, 0, modelAccountBaseURL, nil, false, true)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -159,6 +159,14 @@ func (a *AppInstallService) CheckExist(req request.AppInstalledInfo) (*response.
|
||||
}
|
||||
|
||||
res.ContainerName = appInstall.ContainerName
|
||||
res.ModelAccountBaseURLType = appInstall.ModelAccountBaseURLType
|
||||
res.ModelAccountBaseURL = appInstall.ModelAccountBaseURL
|
||||
if app.Key == "ollama" {
|
||||
res.ModelAccountBaseURLType, res.ModelAccountBaseURL, err = loadOllamaModelAccountAddress(appInstall)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
res.Name = appInstall.Name
|
||||
res.Version = appInstall.Version
|
||||
res.CreatedAt = appInstall.CreatedAt
|
||||
@@ -438,6 +446,32 @@ func (a *AppInstallService) Update(req request.AppInstalledUpdate) error {
|
||||
}
|
||||
}
|
||||
|
||||
modelAccountBaseURL := ""
|
||||
if installed.App.Key == "ollama" {
|
||||
if req.SyncModelAccount != nil {
|
||||
installed.SyncModelAccount = *req.SyncModelAccount
|
||||
}
|
||||
if req.ModelAccountBaseURLType != "" {
|
||||
installed.ModelAccountBaseURLType = req.ModelAccountBaseURLType
|
||||
installed.ModelAccountBaseURL = req.ModelAccountBaseURL
|
||||
} else if installed.SyncModelAccount {
|
||||
installed.ModelAccountBaseURLType, installed.ModelAccountBaseURL, err = loadOllamaModelAccountAddress(oldInstalled)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if installed.SyncModelAccount {
|
||||
containerName := installed.ContainerName
|
||||
if req.Advanced && req.ContainerName != "" {
|
||||
containerName = req.ContainerName
|
||||
}
|
||||
modelAccountBaseURL, err = ResolveLocalModelAccountBaseURL(installed.ModelAccountBaseURLType, installed.ModelAccountBaseURL, containerName, "11434", installed.HttpPort)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
backupDockerCompose := installed.DockerCompose
|
||||
if req.Advanced {
|
||||
composeMap := make(map[string]interface{})
|
||||
@@ -563,6 +597,9 @@ func (a *AppInstallService) Update(req request.AppInstalledUpdate) error {
|
||||
if proxyChanged && website.ID != 0 && currentProxyErr != nil {
|
||||
global.LOG.Error(buserr.WithErr("ErrUpdateBuWebsite", currentProxyErr).Error())
|
||||
}
|
||||
if installed.App.Key == "ollama" && installed.SyncModelAccount {
|
||||
return syncOllamaModelAccount(installed, modelAccountBaseURL, true, true)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -707,6 +744,12 @@ func (a *AppInstallService) GetUpdateVersions(req request.AppUpdateVersion) ([]d
|
||||
if req.UpdateVersion != "" && req.UpdateVersion == detail.Version && detail.DockerCompose == "" && !app.IsLocalApp() {
|
||||
filename := filepath.Base(detail.DownloadUrl)
|
||||
dockerComposeUrl := fmt.Sprintf("%s%s", strings.TrimSuffix(detail.DownloadUrl, filename), "docker-compose.yml")
|
||||
if app.Resource == constant.AppResourceRemote {
|
||||
dockerComposeUrl, err = currentAppDownloadURL(dockerComposeUrl)
|
||||
if err != nil {
|
||||
return versions, err
|
||||
}
|
||||
}
|
||||
statusCode, composeRes, err := req_helper.HandleRequest(dockerComposeUrl, http.MethodGet, constant.TimeOut20s)
|
||||
if statusCode == http.StatusNotFound {
|
||||
return versions, buserr.New("ErrAppVersionUnavailable")
|
||||
@@ -928,6 +971,18 @@ func (a *AppInstallService) GetParams(id uint) (*response.AppConfig, error) {
|
||||
config := getAppCommonConfig(envs)
|
||||
config.DockerCompose = install.DockerCompose
|
||||
res.Params = params
|
||||
res.AppKey = install.App.Key
|
||||
res.Name = install.Name
|
||||
res.HttpPort = install.HttpPort
|
||||
res.SyncModelAccount = install.SyncModelAccount
|
||||
res.ModelAccountBaseURLType = install.ModelAccountBaseURLType
|
||||
res.ModelAccountBaseURL = install.ModelAccountBaseURL
|
||||
if install.App.Key == "ollama" {
|
||||
res.ModelAccountBaseURLType, res.ModelAccountBaseURL, err = loadOllamaModelAccountAddress(install)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if config.ContainerName == "" {
|
||||
config.ContainerName = install.ContainerName
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path"
|
||||
|
||||
"github.com/1Panel-dev/1Panel/agent/app/dto/request"
|
||||
"github.com/1Panel-dev/1Panel/agent/app/model"
|
||||
"github.com/1Panel-dev/1Panel/agent/constant"
|
||||
"github.com/1Panel-dev/1Panel/agent/global"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func prepareMCPWebsiteDirectory(req request.AppInstallCreate) error {
|
||||
changed := errors.New("MCP website directory changed; inspect the installation before retrying")
|
||||
directory, ok := req.Params["WEBSITE_DIR"].(string)
|
||||
if !ok || directory == "" || global.DB == nil {
|
||||
return changed
|
||||
}
|
||||
return global.DB.Transaction(func(tx *gorm.DB) error {
|
||||
var setting model.Setting
|
||||
err := tx.Where("key = ?", "WEBSITE_DIR").First(&setting).Error
|
||||
missing := errors.Is(err, gorm.ErrRecordNotFound)
|
||||
if err != nil && !missing {
|
||||
return err
|
||||
}
|
||||
current := setting.Value
|
||||
if current == "" {
|
||||
current = path.Join(global.Dir.BaseDir, "1panel", "www")
|
||||
}
|
||||
if directory != current {
|
||||
return changed
|
||||
}
|
||||
if missing {
|
||||
if err := tx.Create(&model.Setting{Key: "WEBSITE_DIR", Value: directory}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
} else if setting.Value == "" {
|
||||
result := tx.Model(&model.Setting{}).Where("id = ? AND value = ?", setting.ID, "").Update("value", directory)
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected != 1 {
|
||||
return changed
|
||||
}
|
||||
}
|
||||
if err := os.MkdirAll(path.Join(directory, "conf.d"), constant.DirPerm); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
@@ -191,7 +191,11 @@ func (c *appSyncContext) processOneApp(item appWorkItem) appWorkResult {
|
||||
|
||||
if _, ok := InitTypes[app.Type]; ok {
|
||||
dockerComposeUrl := fmt.Sprintf("%s/%s", versionUrl, "docker-compose.yml")
|
||||
_, composeRes, err := req_helper.HandleRequestWithClient(&c.httpClient, dockerComposeUrl, http.MethodGet, constant.TimeOut20s)
|
||||
dockerComposeUrl, err := appDownloadURL(dockerComposeUrl, strconv.Itoa(c.list.LastModified))
|
||||
var composeRes []byte
|
||||
if err == nil {
|
||||
_, composeRes, err = req_helper.HandleRequestWithClient(&c.httpClient, dockerComposeUrl, http.MethodGet, constant.TimeOut20s)
|
||||
}
|
||||
if err == nil {
|
||||
detail.DockerCompose = string(composeRes)
|
||||
} else {
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"maps"
|
||||
"math"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
@@ -468,7 +469,12 @@ func deleteAppInstall(deleteReq request.AppInstallDelete) error {
|
||||
if err == nil && len(entries) == 0 {
|
||||
_ = op.DeleteDir(parentDir)
|
||||
}
|
||||
tx.Commit()
|
||||
if err := tx.Commit().Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if install.App.Key == "ollama" || install.App.Key == "vllm" {
|
||||
CleanupLocalModelAccount(install.ID)
|
||||
}
|
||||
|
||||
existApps, _ := appInstallRepo.ListBy(context.Background(), appInstallRepo.WithAppId(install.AppId))
|
||||
if len(existApps) == 0 {
|
||||
@@ -891,16 +897,52 @@ func handleMap(params map[string]interface{}, envParams map[string]string) {
|
||||
}
|
||||
}
|
||||
|
||||
func appDownloadURL(rawURL, version string) (string, error) {
|
||||
parsedURL, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
query := parsedURL.Query()
|
||||
query.Set("v", version)
|
||||
parsedURL.RawQuery = query.Encode()
|
||||
return parsedURL.String(), nil
|
||||
}
|
||||
|
||||
func currentAppDownloadURL(rawURL string) (string, error) {
|
||||
storeVersion, err := settingRepo.GetValueByKey("AppStoreLastModified")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return appDownloadURL(rawURL, storeVersion)
|
||||
}
|
||||
|
||||
func downloadApp(app model.App, appDetail model.AppDetail, appInstall *model.AppInstall, logger *logrus.Logger) (err error) {
|
||||
if app.IsLocalApp() || app.IsCustomApp() {
|
||||
return nil
|
||||
}
|
||||
defer func() {
|
||||
if err != nil && appInstall != nil {
|
||||
appInstall.Status = constant.StatusDownloadErr
|
||||
appInstall.Message = err.Error()
|
||||
}
|
||||
}()
|
||||
appResourceDir := path.Join(global.Dir.AppResourceDir, app.Resource)
|
||||
appDownloadDir := app.GetAppResourcePath()
|
||||
appVersionDir := path.Join(appDownloadDir, appDetail.Version)
|
||||
fileOp := files.NewFileOp()
|
||||
storeVersion, err := settingRepo.GetValueByKey("AppStoreLastModified")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cacheVersionPath := path.Join(appVersionDir, ".1panel-appstore-version")
|
||||
if !appDetail.Update && fileOp.Stat(appVersionDir) {
|
||||
return
|
||||
if cachedVersion, readErr := os.ReadFile(cacheVersionPath); readErr == nil && string(cachedVersion) == storeVersion {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
downloadURL, err := appDownloadURL(appDetail.DownloadUrl, storeVersion)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !fileOp.Stat(appDownloadDir) {
|
||||
_ = fileOp.CreateDir(appDownloadDir, constant.DirPerm)
|
||||
@@ -909,23 +951,14 @@ func downloadApp(app model.App, appDetail model.AppDetail, appInstall *model.App
|
||||
_ = fileOp.CreateDir(appVersionDir, constant.DirPerm)
|
||||
}
|
||||
if logger == nil {
|
||||
global.LOG.Infof("download app [%s] from %s", app.Name, appDetail.DownloadUrl)
|
||||
global.LOG.Infof("download app [%s] from %s", app.Name, downloadURL)
|
||||
} else {
|
||||
logger.Printf("download app [%s] from %s", app.Name, appDetail.DownloadUrl)
|
||||
logger.Printf("download app [%s] from %s", app.Name, downloadURL)
|
||||
}
|
||||
|
||||
filePath := path.Join(appVersionDir, app.Key+"-"+appDetail.Version+".tar.gz")
|
||||
|
||||
defer func() {
|
||||
if err != nil {
|
||||
if appInstall != nil {
|
||||
appInstall.Status = constant.StatusDownloadErr
|
||||
appInstall.Message = err.Error()
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
if err = files.DownloadFileWithProxy(appDetail.DownloadUrl, filePath); err != nil {
|
||||
if err = files.DownloadFileWithProxy(downloadURL, filePath); err != nil {
|
||||
if logger == nil {
|
||||
global.LOG.Errorf("download app [%s] error %v", app.Name, err)
|
||||
} else {
|
||||
@@ -942,6 +975,9 @@ func downloadApp(app model.App, appDetail model.AppDetail, appInstall *model.App
|
||||
return
|
||||
}
|
||||
_ = fileOp.DeleteFile(filePath)
|
||||
if err = os.WriteFile(cacheVersionPath, []byte(storeVersion), constant.FilePerm); err != nil {
|
||||
return err
|
||||
}
|
||||
appDetail.Update = false
|
||||
_ = appDetailRepo.Update(context.Background(), appDetail)
|
||||
return
|
||||
@@ -1984,6 +2020,9 @@ func getAppTags(appID uint, lang string) ([]response.TagDTO, error) {
|
||||
|
||||
func handleSiteDir(app model.App, appDetail model.AppDetail, req request.AppInstallCreate, t *task.Task) error {
|
||||
if app.Key == "openresty" && (app.Resource == "remote" || app.Resource == "custom") && common.CompareVersion(appDetail.Version, "1.27") {
|
||||
if req.MCPPreserveWebsiteDir {
|
||||
return prepareMCPWebsiteDirectory(req)
|
||||
}
|
||||
if dir, ok := req.Params["WEBSITE_DIR"]; ok {
|
||||
siteDir := dir.(string)
|
||||
if siteDir == "" || !strings.HasPrefix(siteDir, "/") {
|
||||
|
||||
@@ -58,10 +58,10 @@ type ContainerService struct{}
|
||||
var containerLogAnsiRegex = regexp.MustCompile("\x1b\\[[0-9;?]*[A-Za-z]|\x1b=|\x1b>")
|
||||
|
||||
type IContainerService interface {
|
||||
Page(req dto.PageContainer) (int64, interface{}, error)
|
||||
Page(ctx context.Context, req dto.PageContainer) (int64, interface{}, error)
|
||||
List() []dto.ContainerOptions
|
||||
ListByImage(imageName string) []dto.ContainerOptions
|
||||
LoadStatus() (dto.ContainerStatus, error)
|
||||
LoadStatus(ctx context.Context, containersOnly bool) (dto.ContainerStatus, error)
|
||||
PageNetwork(req dto.SearchWithPage) (int64, interface{}, error)
|
||||
ListNetwork() ([]dto.Options, error)
|
||||
PageVolume(req dto.SearchWithPage) (int64, interface{}, error)
|
||||
@@ -80,7 +80,7 @@ type IContainerService interface {
|
||||
ContainerUpdate(req dto.ContainerOperate) error
|
||||
ContainerUpgrade(req dto.ContainerUpgrade) error
|
||||
ContainerInfo(req dto.OperationWithName) (*dto.ContainerOperate, error)
|
||||
ContainerListStats() ([]dto.ContainerListStats, error)
|
||||
ContainerListStats(ctx context.Context, ids []string) ([]dto.ContainerListStats, error)
|
||||
ContainerItemStats(ctx context.Context, req dto.OperationWithName) (dto.ContainerItemStats, error)
|
||||
LoadResourceLimit() (*dto.ResourceLimit, error)
|
||||
ContainerRename(req dto.ContainerRename) error
|
||||
@@ -112,7 +112,7 @@ func NewIContainerService() IContainerService {
|
||||
return &ContainerService{}
|
||||
}
|
||||
|
||||
func (u *ContainerService) Page(req dto.PageContainer) (int64, interface{}, error) {
|
||||
func (u *ContainerService) Page(ctx context.Context, req dto.PageContainer) (int64, interface{}, error) {
|
||||
client, err := docker.NewDockerClient()
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
@@ -123,7 +123,7 @@ func (u *ContainerService) Page(req dto.PageContainer) (int64, interface{}, erro
|
||||
options.Filters = filters.NewArgs()
|
||||
options.Filters.Add("label", req.Filters)
|
||||
}
|
||||
containers, err := client.ContainerList(context.Background(), options)
|
||||
containers, err := client.ContainerList(ctx, options)
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
@@ -206,27 +206,30 @@ func (u *ContainerService) ListByImage(imageName string) []dto.ContainerOptions
|
||||
return options
|
||||
}
|
||||
|
||||
func (u *ContainerService) LoadStatus() (dto.ContainerStatus, error) {
|
||||
func (u *ContainerService) LoadStatus(ctx context.Context, containersOnly bool) (dto.ContainerStatus, error) {
|
||||
var data dto.ContainerStatus
|
||||
client, err := docker.NewDockerClient()
|
||||
if err != nil {
|
||||
return data, err
|
||||
}
|
||||
defer client.Close()
|
||||
c := context.Background()
|
||||
|
||||
images, _ := client.ImageList(c, image.ListOptions{All: true})
|
||||
data.ImageCount = len(images)
|
||||
repo, _ := imageRepoRepo.List()
|
||||
data.RepoCount = len(repo)
|
||||
templates, _ := composeRepo.List()
|
||||
data.ComposeTemplateCount = len(templates)
|
||||
networks, _ := client.NetworkList(c, network.ListOptions{})
|
||||
data.NetworkCount = len(networks)
|
||||
volumes, _ := client.VolumeList(c, volume.ListOptions{})
|
||||
data.VolumeCount = len(volumes.Volumes)
|
||||
data.ComposeCount = loadComposeCount(client)
|
||||
containers, _ := client.ContainerList(c, container.ListOptions{All: true})
|
||||
if !containersOnly {
|
||||
images, _ := client.ImageList(ctx, image.ListOptions{All: true})
|
||||
data.ImageCount = len(images)
|
||||
repo, _ := imageRepoRepo.List()
|
||||
data.RepoCount = len(repo)
|
||||
templates, _ := composeRepo.List()
|
||||
data.ComposeTemplateCount = len(templates)
|
||||
networks, _ := client.NetworkList(ctx, network.ListOptions{})
|
||||
data.NetworkCount = len(networks)
|
||||
volumes, _ := client.VolumeList(ctx, volume.ListOptions{})
|
||||
data.VolumeCount = len(volumes.Volumes)
|
||||
data.ComposeCount = loadComposeCount(client)
|
||||
}
|
||||
containers, err := client.ContainerList(ctx, container.ListOptions{All: true})
|
||||
if err != nil {
|
||||
return data, err
|
||||
}
|
||||
data.ContainerCount = len(containers)
|
||||
for _, item := range containers {
|
||||
switch item.State {
|
||||
@@ -292,25 +295,48 @@ func (u *ContainerService) ContainerItemStats(ctx context.Context, req dto.Opera
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
func (u *ContainerService) ContainerListStats() ([]dto.ContainerListStats, error) {
|
||||
func (u *ContainerService) ContainerListStats(ctx context.Context, ids []string) ([]dto.ContainerListStats, error) {
|
||||
client, err := docker.NewDockerClient()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer client.Close()
|
||||
list, err := client.ContainerList(context.Background(), container.ListOptions{All: true})
|
||||
options := container.ListOptions{All: true}
|
||||
if ids != nil {
|
||||
if len(ids) == 0 {
|
||||
return []dto.ContainerListStats{}, nil
|
||||
}
|
||||
options.Filters = filters.NewArgs()
|
||||
for _, id := range ids {
|
||||
options.Filters.Add("id", id)
|
||||
}
|
||||
}
|
||||
list, err := client.ContainerList(ctx, options)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
datas := make([]dto.ContainerListStats, len(list))
|
||||
jobs := make(chan int)
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(len(list))
|
||||
for i := 0; i < len(list); i++ {
|
||||
go func(index int, item container.Summary) {
|
||||
datas[index] = loadCpuAndMem(client, item.ID)
|
||||
wg.Done()
|
||||
}(i, list[i])
|
||||
for worker := 0; worker < min(8, len(list)); worker++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for index := range jobs {
|
||||
datas[index].ContainerID = list[index].ID
|
||||
if ctx.Err() == nil && list[index].State == "running" {
|
||||
datas[index] = loadCpuAndMem(ctx, client, list[index].ID)
|
||||
}
|
||||
if len(list[index].Names) > 0 {
|
||||
datas[index].ContainerName = strings.TrimPrefix(list[index].Names[0], "/")
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
for index := range list {
|
||||
jobs <- index
|
||||
}
|
||||
close(jobs)
|
||||
wg.Wait()
|
||||
return datas, nil
|
||||
}
|
||||
@@ -1722,11 +1748,13 @@ func selectImageRepo(imageName string, repos []model.ImageRepo) *model.ImageRepo
|
||||
return selected
|
||||
}
|
||||
|
||||
func loadCpuAndMem(client *client.Client, containerItem string) dto.ContainerListStats {
|
||||
func loadCpuAndMem(ctx context.Context, client *client.Client, containerItem string) dto.ContainerListStats {
|
||||
ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
|
||||
defer cancel()
|
||||
data := dto.ContainerListStats{
|
||||
ContainerID: containerItem,
|
||||
}
|
||||
res, err := client.ContainerStats(context.Background(), containerItem, false)
|
||||
res, err := client.ContainerStats(ctx, containerItem, false)
|
||||
if err != nil {
|
||||
return data
|
||||
}
|
||||
|
||||
@@ -43,6 +43,10 @@ type IDashboardService interface {
|
||||
|
||||
LoadQuickOptions() []dto.QuickJump
|
||||
ChangeQuick(req dto.ChangeQuicks) error
|
||||
ListQuickDirectories() ([]model.QuickDirectory, error)
|
||||
CreateQuickDirectory(req dto.QuickDirectoryCreate) (*model.QuickDirectory, error)
|
||||
UpdateQuickDirectory(req dto.QuickDirectoryUpdate) (*model.QuickDirectory, error)
|
||||
DeleteQuickDirectory(req dto.QuickDirectoryDelete) error
|
||||
|
||||
LoadAppLauncher(ctx *gin.Context) ([]dto.AppLauncher, error)
|
||||
ChangeShow(req dto.SettingUpdate) error
|
||||
@@ -397,16 +401,15 @@ func (u *DashboardService) LoadQuickOptions() []dto.QuickJump {
|
||||
}
|
||||
func (u *DashboardService) ChangeQuick(req dto.ChangeQuicks) error {
|
||||
showCount := 0
|
||||
var quicks []model.QuickJump
|
||||
quicks := make([]model.QuickJump, 0, len(req.Quicks))
|
||||
for _, item := range req.Quicks {
|
||||
var quick model.QuickJump
|
||||
if item.IsShow {
|
||||
showCount++
|
||||
}
|
||||
if err := copier.Copy(&quick, &item); err != nil {
|
||||
return err
|
||||
}
|
||||
quicks = append(quicks, quick)
|
||||
quicks = append(quicks, model.QuickJump{
|
||||
BaseModel: model.BaseModel{ID: item.ID},
|
||||
IsShow: item.IsShow,
|
||||
})
|
||||
}
|
||||
if showCount == 0 {
|
||||
return buserr.New("ErrMinQuickJump")
|
||||
@@ -706,6 +709,13 @@ func loadQuickJump(base *dto.DashboardBase) {
|
||||
quicks := launcherRepo.ListQuickJump(false)
|
||||
for i := 0; i < len(quicks); i++ {
|
||||
switch quicks[i].Name {
|
||||
case "File":
|
||||
count, err := launcherRepo.CountQuickDirectories()
|
||||
if err != nil {
|
||||
global.LOG.Errorf("load quick directory count: %v", err)
|
||||
}
|
||||
quicks[i].Detail = fmt.Sprintf("%d", count)
|
||||
quicks[i].Alias = ""
|
||||
case "Agent":
|
||||
quicks[i].Detail = fmt.Sprintf("%d", base.AgentNumber)
|
||||
case "Website":
|
||||
|
||||
@@ -433,6 +433,10 @@ func (u *MysqlService) ListDBOption() ([]dto.MysqlOption, error) {
|
||||
}
|
||||
|
||||
func (u *MysqlService) Create(ctx context.Context, req dto.MysqlDBCreate) (*model.DatabaseMysql, error) {
|
||||
return u.create(ctx, req, false)
|
||||
}
|
||||
|
||||
func (u *MysqlService) create(ctx context.Context, req dto.MysqlDBCreate, mcpBounded bool) (*model.DatabaseMysql, error) {
|
||||
if cmd.CheckIllegal(req.Name, req.Username, req.Password, req.Format, req.Collation, req.Permission) {
|
||||
return nil, buserr.New("ErrCmdIllegal")
|
||||
}
|
||||
@@ -470,13 +474,13 @@ func (u *MysqlService) Create(ctx context.Context, req dto.MysqlDBCreate) (*mode
|
||||
return nil, errors.New("cannot set root as user name")
|
||||
}
|
||||
|
||||
cli, version, err := LoadMysqlClientByFrom(req.Database)
|
||||
cli, version, err := loadMysqlClientByFrom(req.Database, mcpBounded)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
createItem.MysqlName = req.Database
|
||||
defer cli.Close()
|
||||
if err := cli.Create(client.CreateInfo{
|
||||
createInfo := client.CreateInfo{
|
||||
Name: req.Name,
|
||||
Format: req.Format,
|
||||
Collation: req.Collation,
|
||||
@@ -485,9 +489,31 @@ func (u *MysqlService) Create(ctx context.Context, req dto.MysqlDBCreate) (*mode
|
||||
Permission: req.Permission,
|
||||
Version: version,
|
||||
Timeout: 300,
|
||||
}); err != nil {
|
||||
}
|
||||
if mcpBounded {
|
||||
local, ok := cli.(*client.Local)
|
||||
if !ok {
|
||||
return nil, errors.New("an installed local MySQL instance is required")
|
||||
}
|
||||
deadline := time.Now().Add(300 * time.Second)
|
||||
if writeDeadline, ok := ctx.Deadline(); ok && writeDeadline.Add(-10*time.Second).Before(deadline) {
|
||||
deadline = writeDeadline.Add(-10 * time.Second)
|
||||
}
|
||||
sqlContext, cancel := context.WithDeadline(ctx, deadline)
|
||||
err = local.CreateWithContext(sqlContext, createInfo)
|
||||
cancel()
|
||||
} else {
|
||||
err = cli.Create(createInfo)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if mcpBounded {
|
||||
if err := persistMCPMysqlMetadata(ctx, req, permissionHosts, &createItem); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &createItem, nil
|
||||
}
|
||||
if len(req.Username) != 0 {
|
||||
if err := saveDatabaseUserCredentials(dbType, req.Database, req.Username, req.Permission, req.Password, req.Description); err != nil {
|
||||
return nil, err
|
||||
@@ -506,6 +532,45 @@ func (u *MysqlService) Create(ctx context.Context, req dto.MysqlDBCreate) (*mode
|
||||
return &createItem, nil
|
||||
}
|
||||
|
||||
func persistMCPMysqlMetadata(ctx context.Context, req dto.MysqlDBCreate, hosts []string, item *model.DatabaseMysql) error {
|
||||
return global.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if req.Username != "" {
|
||||
key := global.CONF.Base.EncryptKey
|
||||
if key == "" {
|
||||
var setting model.Setting
|
||||
if err := tx.Where("key = ?", "EncryptKey").First(&setting).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
key = setting.Value
|
||||
}
|
||||
password, err := encrypt.StringEncryptWithKey(req.Password, key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, host := range hosts {
|
||||
var user model.DatabaseUser
|
||||
err := tx.Where("type = ? AND database = ? AND username = ? AND host = ?", "mysql", req.Database, req.Username, host).First(&user).Error
|
||||
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return err
|
||||
}
|
||||
user.Type, user.Database, user.Username, user.Host = "mysql", req.Database, req.Username, host
|
||||
user.Password, user.IsDelete = password, false
|
||||
if req.Description != "" {
|
||||
user.Description = req.Description
|
||||
}
|
||||
if err := tx.Save(&user).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
grant := model.DatabaseUserGrant{Type: "mysql", Database: req.Database, DBName: req.Name, Username: req.Username, Host: host}
|
||||
if err := tx.Where("type = ? AND database = ? AND db_name = ? AND username = ? AND host = ?", grant.Type, grant.Database, grant.DBName, grant.Username, grant.Host).FirstOrCreate(&grant).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return tx.Create(item).Error
|
||||
})
|
||||
}
|
||||
|
||||
func (u *MysqlService) ListUsers(req dto.MysqlUserSearch) ([]dto.MysqlUser, error) {
|
||||
dbType, err := resolveDatabaseUserType(req.Database)
|
||||
if err != nil {
|
||||
@@ -1398,7 +1463,14 @@ func (u *MysqlService) LoadStatus(req dto.OperationWithNameAndType) (*dto.MysqlS
|
||||
return nil, err
|
||||
}
|
||||
|
||||
statusMap, err := executeSqlForMaps(app.ContainerName, app.Key, app.Password, "show global status;")
|
||||
return loadMysqlStatus(req, app, executeSqlForMaps, executeSqlForRows)
|
||||
}
|
||||
|
||||
func loadMysqlStatus(req dto.OperationWithNameAndType, app *repo.RootInfo,
|
||||
maps func(string, string, string, string) (map[string]string, error),
|
||||
rowsQuery func(string, string, string, string) ([]string, error),
|
||||
) (*dto.MysqlStatus, error) {
|
||||
statusMap, err := maps(app.ContainerName, app.Key, app.Password, "show global status;")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -1426,7 +1498,7 @@ func (u *MysqlService) LoadStatus(req dto.OperationWithNameAndType) (*dto.MysqlS
|
||||
if common.CompareAppVersion(app.Version, "8.4.0") && (req.Type == constant.AppMysql || req.Type == constant.AppMysqlCluster) {
|
||||
masterStatus = "show binary log status;"
|
||||
}
|
||||
rows, err := executeSqlForRows(app.ContainerName, app.Key, app.Password, masterStatus)
|
||||
rows, err := rowsQuery(app.ContainerName, app.Key, app.Password, masterStatus)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -1531,6 +1603,10 @@ func updateMyCnf(oldFiles []string, group string, param string, value interface{
|
||||
}
|
||||
|
||||
func LoadMysqlClientByFrom(database string) (mysql.MysqlClient, string, error) {
|
||||
return loadMysqlClientByFrom(database, false)
|
||||
}
|
||||
|
||||
func loadMysqlClientByFrom(database string, mcpLocalOnly bool) (mysql.MysqlClient, string, error) {
|
||||
var (
|
||||
dbInfo client.DBInfo
|
||||
version string
|
||||
@@ -1542,6 +1618,9 @@ func LoadMysqlClientByFrom(database string) (mysql.MysqlClient, string, error) {
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
if mcpLocalOnly && (databaseItem.ID == 0 || databaseItem.From != "local" || databaseItem.Type != "mysql" || databaseItem.AppInstallID == 0) {
|
||||
return nil, "", errors.New("an installed local MySQL instance is required")
|
||||
}
|
||||
dbInfo.Type = databaseItem.Type
|
||||
dbInfo.From = databaseItem.From
|
||||
dbInfo.Database = database
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/1Panel-dev/1Panel/agent/app/dto"
|
||||
"github.com/1Panel-dev/1Panel/agent/app/model"
|
||||
"github.com/1Panel-dev/1Panel/agent/utils/mcpapi"
|
||||
)
|
||||
|
||||
func (u *MysqlService) CreateForMCP(ctx context.Context, req dto.MysqlDBCreate) (*model.DatabaseMysql, error) {
|
||||
if req.From != "local" || ctx.Err() != nil {
|
||||
return nil, errors.New("invalid MCP database target or request")
|
||||
}
|
||||
writeContext, cancel := context.WithTimeout(context.WithoutCancel(ctx), 310*time.Second)
|
||||
defer cancel()
|
||||
created, err := u.create(writeContext, req, true)
|
||||
if err != nil {
|
||||
return nil, mcpapi.SanitizeBusinessError(err, "database creation failed; inspect the invocation before retrying")
|
||||
}
|
||||
return created, nil
|
||||
}
|
||||
@@ -882,6 +882,16 @@ func (f *FileService) ChangeName(req request.FileRename) error {
|
||||
if hasInvalidFileName(req.NewName) {
|
||||
return buserr.New("ErrInvalidChar")
|
||||
}
|
||||
if req.OldName == req.NewName {
|
||||
return nil
|
||||
}
|
||||
// os.Rename silently replaces an existing target on Linux, so reject the
|
||||
// rename here to avoid losing the target file without confirmation.
|
||||
if _, err := os.Lstat(req.NewName); err == nil {
|
||||
return buserr.New("ErrFileIsExist")
|
||||
} else if !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
fo := files.NewFileOp()
|
||||
content, mode, shouldRecordHistory := readEditableFileHistoryContent(req.OldName)
|
||||
if err := fo.Rename(req.OldName, req.NewName); err != nil {
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/1Panel-dev/1Panel/agent/app/dto/request"
|
||||
"github.com/1Panel-dev/1Panel/agent/buserr"
|
||||
)
|
||||
|
||||
func TestChangeNameRejectsExistingTarget(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
oldPath := filepath.Join(dir, "old.txt")
|
||||
targetPath := filepath.Join(dir, "target.txt")
|
||||
// Binary source content keeps this test independent of the file-history DB.
|
||||
if err := os.WriteFile(oldPath, []byte("old\x00"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(targetPath, []byte("target"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
err := (&FileService{}).ChangeName(request.FileRename{OldName: oldPath, NewName: targetPath})
|
||||
if err == nil {
|
||||
t.Fatal("expected an error when the target name already exists")
|
||||
}
|
||||
var bizErr buserr.BusinessError
|
||||
if !errors.As(err, &bizErr) || bizErr.Msg != "ErrFileIsExist" {
|
||||
t.Fatalf("expected ErrFileIsExist, got %v", err)
|
||||
}
|
||||
|
||||
content, err := os.ReadFile(targetPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(content) != "target" {
|
||||
t.Fatalf("target file was overwritten, content = %q", content)
|
||||
}
|
||||
if _, err := os.Stat(oldPath); err != nil {
|
||||
t.Fatalf("source file should still exist after a rejected rename: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChangeNameRenamesToNewName(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
oldPath := filepath.Join(dir, "old")
|
||||
newPath := filepath.Join(dir, "new")
|
||||
if err := os.Mkdir(oldPath, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := (&FileService{}).ChangeName(request.FileRename{OldName: oldPath, NewName: newPath}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Stat(newPath); err != nil {
|
||||
t.Fatalf("renamed path does not exist: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(oldPath); !os.IsNotExist(err) {
|
||||
t.Fatalf("old path should be gone, stat err = %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"regexp"
|
||||
"strings"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/1Panel-dev/1Panel/agent/utils/mcpapi"
|
||||
)
|
||||
|
||||
const mcpMysqlStoppedMessage = "The selected MySQL instance is not running. Start the database service and try the query again."
|
||||
const mcpMysqlConnectionFailedMessage = "Cannot connect to the selected MySQL instance. Check the database service, connection settings and panel logs."
|
||||
|
||||
var errMCPMysqlStopped = mcpapi.NewBusinessError(mcpapi.CodeMySQLStopped, mcpMysqlStoppedMessage)
|
||||
var errMCPMysqlConnectionFailed = mcpapi.NewBusinessError(mcpapi.CodeMySQLConnectionFailed, mcpMysqlConnectionFailedMessage)
|
||||
|
||||
var mcpMysqlStoppedPattern = regexp.MustCompile(`^Error response from daemon: [Cc]ontainer [A-Za-z0-9][A-Za-z0-9_.-]{0,127} is not running$`)
|
||||
var mcpMysqlConnectionPatterns = []*regexp.Regexp{
|
||||
regexp.MustCompile(`^ERROR 2002 \(HY000\): Can't connect to local MySQL server through socket '[^'\r\n]{1,1000}' \([0-9]{1,10}\)$`),
|
||||
regexp.MustCompile(`^ERROR 2003 \(HY000\): Can't connect to MySQL server on '[^'\r\n]{1,1000}' \([0-9]{1,10}\)$`),
|
||||
regexp.MustCompile(`^ERROR 1045 \(28000\): Access denied for user '[^'\r\n]{1,128}'@'[^'\r\n]{1,255}' \(using password: (YES|NO)\)$`),
|
||||
}
|
||||
|
||||
func classifyMCPMysqlReadFailure(stderr string) error {
|
||||
generic := errors.New("local MySQL status query failed")
|
||||
if len(stderr) == 0 || len(stderr) > 2048 || !utf8.ValidString(stderr) {
|
||||
return generic
|
||||
}
|
||||
warning := "mysql: [Warning] Using a password on the command line interface can be insecure."
|
||||
stderr = strings.TrimPrefix(stderr, warning+"\n")
|
||||
if strings.HasSuffix(stderr, "\r\n") {
|
||||
stderr = strings.TrimSuffix(stderr, "\r\n")
|
||||
} else {
|
||||
stderr = strings.TrimSuffix(stderr, "\n")
|
||||
}
|
||||
if strings.IndexFunc(stderr, unicode.IsControl) >= 0 {
|
||||
return generic
|
||||
}
|
||||
if mcpMysqlStoppedPattern.MatchString(stderr) {
|
||||
return errMCPMysqlStopped
|
||||
}
|
||||
for _, pattern := range mcpMysqlConnectionPatterns {
|
||||
if pattern.MatchString(stderr) {
|
||||
return errMCPMysqlConnectionFailed
|
||||
}
|
||||
}
|
||||
return generic
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"os/exec"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/1Panel-dev/1Panel/agent/app/dto"
|
||||
"github.com/1Panel-dev/1Panel/agent/app/dto/response"
|
||||
"github.com/1Panel-dev/1Panel/agent/app/model"
|
||||
"github.com/1Panel-dev/1Panel/agent/app/repo"
|
||||
"github.com/1Panel-dev/1Panel/agent/constant"
|
||||
"github.com/1Panel-dev/1Panel/agent/global"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const mcpMysqlQueryTimeout = 30 * time.Second
|
||||
const mcpMysqlQueryOutputLimit = 1 << 20
|
||||
|
||||
func mcpLocalMysqlQuery(ctx context.Context) *gorm.DB {
|
||||
return global.DB.WithContext(ctx).Table("databases AS d").
|
||||
Joins("JOIN app_installs AS ai ON ai.id = d.app_install_id AND ai.name = d.name").
|
||||
Joins("JOIN apps AS a ON a.id = ai.app_id").
|
||||
Where("d.type = ? AND d.\"from\" = ? AND a.key = ?", constant.AppMysql, "local", constant.AppMysql).
|
||||
Where("ai.status <> ? AND ai.container_name <> ?", constant.StatusDeleted, "")
|
||||
}
|
||||
|
||||
func (u *DatabaseService) ListForMCP(ctx context.Context, dbType string) ([]dto.DatabaseOption, error) {
|
||||
if dbType != constant.AppMysql {
|
||||
return nil, errors.New("only installed local MySQL instances are supported")
|
||||
}
|
||||
result := make([]dto.DatabaseOption, 0)
|
||||
err := mcpLocalMysqlQuery(ctx).Select("d.id, d.type, d.\"from\", d.name AS database, ai.version").Order("d.id").Scan(&result).Error
|
||||
if err != nil {
|
||||
return nil, errors.New("cannot read local MySQL instances")
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
type mcpMysqlReadRunner func(context.Context, string, string, string) (string, error)
|
||||
|
||||
func (u *MysqlService) LoadStatusForMCP(ctx context.Context, req dto.OperationWithNameAndType) (*dto.MysqlStatus, error) {
|
||||
return u.loadStatusForMCP(ctx, req, runMCPMysqlRead)
|
||||
}
|
||||
func (u *MysqlService) loadStatusForMCP(ctx context.Context, req dto.OperationWithNameAndType, run mcpMysqlReadRunner) (*dto.MysqlStatus, error) {
|
||||
if req.Type != constant.AppMysql || req.Name == "" || len(req.Name) > 128 || strings.TrimSpace(req.Name) != req.Name || strings.ContainsAny(req.Name, "\x00\r\n/\\") || ctx.Err() != nil {
|
||||
return nil, errors.New("an explicitly named installed local MySQL instance is required")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(ctx, mcpMysqlQueryTimeout)
|
||||
defer cancel()
|
||||
var target struct {
|
||||
ID uint
|
||||
ContainerName, Version, Env string
|
||||
}
|
||||
if err := mcpLocalMysqlQuery(ctx).Where("d.name = ?", req.Name).Select("d.id, ai.container_name, ai.version, ai.env").Take(&target).Error; err != nil || target.ID == 0 {
|
||||
return nil, errors.New("the installed local MySQL instance is unavailable")
|
||||
}
|
||||
var env map[string]any
|
||||
if json.Unmarshal([]byte(target.Env), &env) != nil {
|
||||
return nil, errors.New("local MySQL configuration is unavailable")
|
||||
}
|
||||
password, ok := env["PANEL_DB_ROOT_PASSWORD"].(string)
|
||||
if !ok || password == "" {
|
||||
return nil, errors.New("local MySQL configuration is unavailable")
|
||||
}
|
||||
app := &repo.RootInfo{ContainerName: target.ContainerName, Version: target.Version, Key: constant.AppMysql, Password: password}
|
||||
rows := func(container, _, password, sql string) ([]string, error) {
|
||||
if ctx.Err() != nil {
|
||||
return nil, errors.New("local MySQL status query failed")
|
||||
}
|
||||
output, err := run(ctx, container, password, sql)
|
||||
if err != nil {
|
||||
if err == errMCPMysqlStopped || err == errMCPMysqlConnectionFailed {
|
||||
return nil, err
|
||||
}
|
||||
return nil, errors.New("local MySQL status query failed")
|
||||
}
|
||||
return strings.Split(output, "\n"), nil
|
||||
}
|
||||
maps := func(container, dbType, password, sql string) (map[string]string, error) {
|
||||
lines, err := rows(container, dbType, password, sql)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
values := map[string]string{}
|
||||
for _, line := range lines {
|
||||
if pair := strings.Split(line, "\t"); len(pair) == 2 {
|
||||
values[pair[0]] = pair[1]
|
||||
}
|
||||
}
|
||||
return values, nil
|
||||
}
|
||||
return loadMysqlStatus(req, app, maps, rows)
|
||||
}
|
||||
|
||||
type mcpQueryOutput struct {
|
||||
buffer bytes.Buffer
|
||||
mu sync.Mutex
|
||||
exceeded bool
|
||||
}
|
||||
|
||||
func (b *mcpQueryOutput) Write(data []byte) (int, error) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
if len(data) > mcpMysqlQueryOutputLimit-b.buffer.Len() {
|
||||
b.exceeded = true
|
||||
return 0, errors.New("query output limit exceeded")
|
||||
}
|
||||
return b.buffer.Write(data)
|
||||
}
|
||||
func (b *mcpQueryOutput) Len() int {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
return b.buffer.Len()
|
||||
}
|
||||
func (b *mcpQueryOutput) String() string {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
return b.buffer.String()
|
||||
}
|
||||
func runMCPMysqlRead(ctx context.Context, container, password, sql string) (string, error) {
|
||||
switch sql {
|
||||
case "show global status;", "show master status;", "show binary log status;":
|
||||
default:
|
||||
return "", errors.New("unsupported MySQL monitoring query")
|
||||
}
|
||||
command := exec.CommandContext(ctx, "docker", "exec", container, "mysql", "-uroot", "-p"+password, "-e", sql)
|
||||
output := &mcpQueryOutput{}
|
||||
var stderr bytes.Buffer
|
||||
command.Stdout, command.Stderr = output, io.MultiWriter(output, &stderr)
|
||||
runErr := command.Run()
|
||||
if ctx.Err() != nil || output.exceeded {
|
||||
return "", errors.New("local MySQL status query failed")
|
||||
}
|
||||
if runErr != nil {
|
||||
return "", classifyMCPMysqlReadFailure(stderr.String())
|
||||
}
|
||||
value := strings.ReplaceAll(output.String(), "mysql: [Warning] Using a password on the command line interface can be insecure.\n", "")
|
||||
if strings.HasPrefix(value, "ERROR ") {
|
||||
return "", errors.New("local MySQL status query failed")
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func (a *AppInstallService) GetAppInstallInfoForMCP(ctx context.Context, id uint) (*response.AppInstallInfo, error) {
|
||||
var installed model.AppInstall
|
||||
if id == 0 {
|
||||
return nil, errors.New("invalid installed application")
|
||||
}
|
||||
err := global.DB.WithContext(ctx).Preload("App").Where("id = ?", id).First(&installed).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return &response.AppInstallInfo{Status: constant.StatusDeleted}, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, errors.New("installed application metadata is unavailable")
|
||||
}
|
||||
var env map[string]any
|
||||
if json.Unmarshal([]byte(installed.Env), &env) != nil {
|
||||
return nil, errors.New("installed application metadata is unavailable")
|
||||
}
|
||||
result := &response.AppInstallInfo{ID: installed.ID, Name: installed.Name, Version: installed.Version, Status: installed.Status, HttpPort: installed.HttpPort, AppKey: installed.App.Key, AppPorts: []int{}}
|
||||
for key, value := range env {
|
||||
if !strings.Contains(strings.ToUpper(key), "PANEL_APP_PORT") {
|
||||
continue
|
||||
}
|
||||
var port int
|
||||
switch item := value.(type) {
|
||||
case string:
|
||||
port, _ = strconv.Atoi(item)
|
||||
case float64:
|
||||
if item == float64(int(item)) {
|
||||
port = int(item)
|
||||
}
|
||||
}
|
||||
if port > 0 && port <= 65535 {
|
||||
result.AppPorts = append(result.AppPorts, port)
|
||||
}
|
||||
}
|
||||
sort.Ints(result.AppPorts)
|
||||
return result, nil
|
||||
}
|
||||
@@ -78,9 +78,17 @@ func getNginxParamsByKeys(scope string, keys []string, website *model.Website) (
|
||||
var res []response.NginxParam
|
||||
var block components.IBlock
|
||||
if scope == constant.NginxScopeHttp {
|
||||
block = nginxFull.RootConfig.Config.FindHttp()
|
||||
httpBlock := nginxFull.RootConfig.Config.FindHttp()
|
||||
if httpBlock == nil {
|
||||
return nil, fmt.Errorf("nginx HTTP configuration block is missing")
|
||||
}
|
||||
block = httpBlock
|
||||
} else {
|
||||
block = nginxFull.SiteConfig.Config.FindServers()[0]
|
||||
servers := nginxFull.SiteConfig.Config.FindServers()
|
||||
if len(servers) == 0 {
|
||||
return nil, fmt.Errorf("website nginx server block is missing")
|
||||
}
|
||||
block = servers[0]
|
||||
}
|
||||
for _, key := range keys {
|
||||
dirs := block.FindDirectives(key)
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/1Panel-dev/1Panel/agent/app/dto"
|
||||
"github.com/1Panel-dev/1Panel/agent/app/model"
|
||||
"github.com/1Panel-dev/1Panel/agent/buserr"
|
||||
)
|
||||
|
||||
func (u *DashboardService) ListQuickDirectories() ([]model.QuickDirectory, error) {
|
||||
return launcherRepo.ListQuickDirectories()
|
||||
}
|
||||
|
||||
func quickDirectoryValues(req dto.QuickDirectoryCreate) (model.QuickDirectory, error) {
|
||||
item := model.QuickDirectory{Path: req.Path, Alias: strings.TrimSpace(req.Alias)}
|
||||
if strings.TrimSpace(item.Path) == "" {
|
||||
return item, buserr.WithDetail("ErrInvalidParams", "path is required", nil)
|
||||
}
|
||||
return item, nil
|
||||
}
|
||||
|
||||
func (u *DashboardService) CreateQuickDirectory(req dto.QuickDirectoryCreate) (*model.QuickDirectory, error) {
|
||||
item, err := quickDirectoryValues(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := launcherRepo.CreateQuickDirectory(&item); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &item, nil
|
||||
}
|
||||
|
||||
func (u *DashboardService) UpdateQuickDirectory(req dto.QuickDirectoryUpdate) (*model.QuickDirectory, error) {
|
||||
item, err := quickDirectoryValues(req.QuickDirectoryCreate)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
item.ID = req.ID
|
||||
item.Legacy = req.Legacy
|
||||
if err := launcherRepo.UpdateQuickDirectory(&item); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &item, nil
|
||||
}
|
||||
|
||||
func (u *DashboardService) DeleteQuickDirectory(req dto.QuickDirectoryDelete) error {
|
||||
return launcherRepo.DeleteQuickDirectory(req.ID, req.Legacy)
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"os/exec"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -46,7 +47,7 @@ type RuntimeService struct {
|
||||
type IRuntimeService interface {
|
||||
Page(req request.RuntimeSearch) (int64, []response.RuntimeDTO, error)
|
||||
Create(create request.RuntimeCreate) (*model.Runtime, error)
|
||||
Delete(delete request.RuntimeDelete) error
|
||||
Delete(delete request.RuntimeDelete) (string, error)
|
||||
Update(req request.RuntimeUpdate) error
|
||||
Get(id uint) (res *response.RuntimeDTO, err error)
|
||||
GetNodePackageRunScript(req request.NodePackageReq) ([]response.PackageScripts, error)
|
||||
@@ -376,16 +377,16 @@ func (r *RuntimeService) DeleteCheck(runTimeId uint) ([]dto.AppResource, error)
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func (r *RuntimeService) Delete(runtimeDelete request.RuntimeDelete) error {
|
||||
func (r *RuntimeService) Delete(runtimeDelete request.RuntimeDelete) (string, error) {
|
||||
runtime, err := runtimeRepo.GetFirst(context.Background(), repo.WithByID(runtimeDelete.ID))
|
||||
if err != nil {
|
||||
return err
|
||||
return "", err
|
||||
}
|
||||
website, _ := websiteRepo.GetFirst(websiteRepo.WithRuntimeID(runtimeDelete.ID))
|
||||
if website.ID > 0 {
|
||||
return buserr.New("ErrDelWithWebsite")
|
||||
return runtime.Name, buserr.New("ErrDelWithWebsite")
|
||||
}
|
||||
return startRuntimeDeleteTask(runtimeDelete, runtime)
|
||||
return runtime.Name, startRuntimeDeleteTask(runtimeDelete, runtime)
|
||||
}
|
||||
|
||||
func startRuntimeDeleteTask(runtimeDelete request.RuntimeDelete, runtime *model.Runtime) error {
|
||||
@@ -584,6 +585,21 @@ func (r *RuntimeService) Get(id uint) (*response.RuntimeDTO, error) {
|
||||
|
||||
for _, form := range appForm.FormFields {
|
||||
if v, ok := envs[form.EnvKey]; ok {
|
||||
if form.EnvKey == "PHP_VERSION" {
|
||||
// Refresh only version choices; keep the saved form if synchronized metadata is unavailable.
|
||||
var latestForm dto.AppForm
|
||||
if json.Unmarshal([]byte(appDetail.Params), &latestForm) == nil {
|
||||
for _, latest := range latestForm.FormFields {
|
||||
if latest.EnvKey == form.EnvKey && len(latest.Values) > 0 {
|
||||
form.Values = latest.Values
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if v != "" && !slices.ContainsFunc(form.Values, func(value dto.AppFormValue) bool { return value.Value == v }) {
|
||||
form.Values = append(form.Values, dto.AppFormValue{Label: v, Value: v})
|
||||
}
|
||||
}
|
||||
appParam := response.AppParam{
|
||||
Edit: false,
|
||||
Key: form.EnvKey,
|
||||
|
||||
+117
-38
@@ -3,6 +3,7 @@ package service
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -973,11 +974,16 @@ func (w WebsiteService) GetWebsiteHTTPS(websiteId uint) (response.WebsiteHTTPS,
|
||||
if err != nil {
|
||||
return res, err
|
||||
}
|
||||
applyWebsiteHTTPSParams(&res, params)
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func applyWebsiteHTTPSParams(res *response.WebsiteHTTPS, params []response.NginxParam) {
|
||||
for _, p := range params {
|
||||
if p.Name == "ssl_protocols" {
|
||||
res.SSLProtocol = p.Params
|
||||
}
|
||||
if p.Name == "ssl_ciphers" {
|
||||
if p.Name == "ssl_ciphers" && len(p.Params) > 0 {
|
||||
res.Algorithm = p.Params[0]
|
||||
}
|
||||
if p.Name == "add_header" && len(p.Params) > 0 {
|
||||
@@ -995,7 +1001,6 @@ func (w WebsiteService) GetWebsiteHTTPS(websiteId uint) (response.WebsiteHTTPS,
|
||||
}
|
||||
}
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func (w WebsiteService) OpWebsiteHTTPS(ctx context.Context, req request.WebsiteHTTPSOp) (*response.WebsiteHTTPS, error) {
|
||||
@@ -1325,11 +1330,12 @@ func (w WebsiteService) ChangeDefaultServer(id uint) error {
|
||||
return websiteRepo.Save(context.Background(), &website)
|
||||
}
|
||||
|
||||
func (w WebsiteService) ChangePHPVersion(req request.WebsitePHPVersionReq) error {
|
||||
func (w WebsiteService) ChangePHPVersion(req request.WebsitePHPVersionReq) (err error) {
|
||||
website, err := websiteRepo.GetFirst(repo.WithByID(req.WebsiteID))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
wasPHP := website.Type == constant.Runtime
|
||||
if website.Type == constant.Runtime {
|
||||
oldRuntime, err := runtimeRepo.GetFirst(context.Background(), repo.WithByID(website.RuntimeID))
|
||||
if err != nil {
|
||||
@@ -1347,26 +1353,8 @@ func (w WebsiteService) ChangePHPVersion(req request.WebsitePHPVersionReq) error
|
||||
return buserr.WithName("ErrImageNotExist", oldRuntime.Name)
|
||||
}
|
||||
}
|
||||
configPath := GetSitePath(website, SiteConf)
|
||||
nginxContent, err := files.NewFileOp().GetContent(configPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
config, err := parser.NewStringParser(string(nginxContent)).Parse()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
servers := config.FindServers()
|
||||
if len(servers) == 0 {
|
||||
return errors.New("nginx config is not valid")
|
||||
}
|
||||
server := servers[0]
|
||||
fileOp := files.NewFileOp()
|
||||
indexPHPPath := path.Join(GetSitePath(website, SiteIndexDir), "index.php")
|
||||
indexHtmlPath := path.Join(GetSitePath(website, SiteIndexDir), "index.html")
|
||||
|
||||
if req.RuntimeID > 0 {
|
||||
server.UpdateDirective("index", []string{"index.php index.html index.htm default.php default.htm default.html"})
|
||||
server.RemoveDirective("location", []string{"~", "[^/]\\.php(/|$)"})
|
||||
runtime, err := runtimeRepo.GetFirst(context.Background(), repo.WithByID(req.RuntimeID))
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -1374,38 +1362,129 @@ func (w WebsiteService) ChangePHPVersion(req request.WebsitePHPVersionReq) error
|
||||
if runtime.Resource == constant.ResourceLocal {
|
||||
return buserr.New("ErrPHPResource")
|
||||
}
|
||||
website.RuntimeID = req.RuntimeID
|
||||
website.RuntimeID = runtime.ID
|
||||
website.AppInstallID = 0
|
||||
phpProxy := fmt.Sprintf("127.0.0.1:%s", runtime.Port)
|
||||
website.Proxy = phpProxy
|
||||
server.UpdatePHPProxy([]string{website.Proxy}, "")
|
||||
website.Proxy = fmt.Sprintf("127.0.0.1:%s", runtime.Port)
|
||||
website.Type = constant.Runtime
|
||||
if !fileOp.Stat(indexPHPPath) {
|
||||
_ = fileOp.WriteFile(indexPHPPath, strings.NewReader(string(nginx_conf.IndexPHP)), constant.FilePerm)
|
||||
}
|
||||
} else {
|
||||
server.UpdateDirective("index", []string{"index.html index.php index.htm default.php default.htm default.html"})
|
||||
website.RuntimeID = 0
|
||||
website.Type = constant.Static
|
||||
website.Proxy = ""
|
||||
server.RemoveDirective("location", []string{"~", "[^/]\\.php(/|$)"})
|
||||
if !fileOp.Stat(indexHtmlPath) {
|
||||
_ = fileOp.WriteFile(indexHtmlPath, strings.NewReader(string(nginx_conf.Index)), constant.FilePerm)
|
||||
}
|
||||
}
|
||||
|
||||
config.FilePath = configPath
|
||||
if err = nginx.WriteConfig(config, nginx.IndentedStyle); err != nil {
|
||||
subsites, err := websiteRepo.List(websiteRepo.WithParentID(website.ID), websiteRepo.WithType(constant.Subsite))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
websites := append([]model.Website{website}, subsites...)
|
||||
configs := make([]*components.Config, 0, len(websites))
|
||||
oldContents := make(map[string][]byte, len(websites))
|
||||
fileOp := files.NewFileOp()
|
||||
for i := range websites {
|
||||
site := &websites[i]
|
||||
if i > 0 {
|
||||
site.RuntimeID = website.RuntimeID
|
||||
site.Proxy = website.Proxy
|
||||
}
|
||||
configPath := GetSitePath(*site, SiteConf)
|
||||
content, err := fileOp.GetContent(configPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
config, err := parser.NewStringParser(string(content)).Parse()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
servers := config.FindServers()
|
||||
if len(servers) == 0 {
|
||||
return errors.New("nginx config is not valid")
|
||||
}
|
||||
server := servers[0]
|
||||
if i == 0 || wasPHP != (req.RuntimeID > 0) {
|
||||
if req.RuntimeID > 0 {
|
||||
server.UpdateDirective("index", []string{"index.php index.html index.htm default.php default.htm default.html"})
|
||||
} else {
|
||||
server.UpdateDirective("index", []string{"index.html index.php index.htm default.php default.htm default.html"})
|
||||
}
|
||||
}
|
||||
if req.RuntimeID > 0 && site.Status != constant.WebStopped {
|
||||
updated := false
|
||||
for _, location := range server.FindDirectives("location") {
|
||||
if strings.Join(location.GetParameters(), " ") == "~ [^/]\\.php(/|$)" && location.GetBlock() != nil {
|
||||
location.GetBlock().UpdateDirective("fastcgi_pass", []string{site.Proxy})
|
||||
updated = true
|
||||
}
|
||||
}
|
||||
if !updated {
|
||||
server.UpdatePHPProxy([]string{site.Proxy}, "")
|
||||
}
|
||||
} else {
|
||||
server.RemoveDirective("location", []string{"~", "[^/]\\.php(/|$)"})
|
||||
}
|
||||
config.FilePath = configPath
|
||||
configs = append(configs, config)
|
||||
oldContents[configPath] = content
|
||||
}
|
||||
|
||||
indexPath := path.Join(GetSitePath(website, SiteIndexDir), "index.html")
|
||||
indexContent := nginx_conf.Index
|
||||
if req.RuntimeID > 0 {
|
||||
indexPath = path.Join(GetSitePath(website, SiteIndexDir), "index.php")
|
||||
indexContent = nginx_conf.IndexPHP
|
||||
}
|
||||
if !fileOp.Stat(indexPath) {
|
||||
_ = fileOp.WriteFile(indexPath, strings.NewReader(string(indexContent)), constant.FilePerm)
|
||||
}
|
||||
|
||||
nginxInstall, err := getAppInstallByKey(constant.AppOpenresty)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err = nginxCheckAndReload(string(nginxContent), configPath, nginxInstall.ContainerName); err != nil {
|
||||
tx, ctx := helper.GetTxAndContext()
|
||||
if err = tx.Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return websiteRepo.Save(context.Background(), &website)
|
||||
written := 0
|
||||
reloadAttempted := false
|
||||
defer func() {
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
if rollbackErr := tx.Rollback().Error; rollbackErr != nil && !errors.Is(rollbackErr, sql.ErrTxDone) {
|
||||
err = errors.Join(err, rollbackErr)
|
||||
}
|
||||
for _, config := range configs[:written] {
|
||||
if restoreErr := fileOp.WriteFile(config.FilePath, strings.NewReader(string(oldContents[config.FilePath])), constant.FilePerm); restoreErr != nil {
|
||||
err = errors.Join(err, fmt.Errorf("restore %s: %w", config.FilePath, restoreErr))
|
||||
}
|
||||
}
|
||||
if reloadAttempted {
|
||||
if checkErr := opNginx(nginxInstall.ContainerName, constant.NginxCheck); checkErr != nil {
|
||||
err = errors.Join(err, fmt.Errorf("check restored nginx configuration: %w", checkErr))
|
||||
} else if reloadErr := opNginx(nginxInstall.ContainerName, constant.NginxReload); reloadErr != nil {
|
||||
err = errors.Join(err, fmt.Errorf("reload restored nginx configuration: %w", reloadErr))
|
||||
}
|
||||
}
|
||||
}()
|
||||
for i := range websites {
|
||||
if err = websiteRepo.Save(ctx, &websites[i]); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for _, config := range configs {
|
||||
written++
|
||||
if err = nginx.WriteConfig(config, nginx.IndentedStyle); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err = opNginx(nginxInstall.ContainerName, constant.NginxCheck); err != nil {
|
||||
return err
|
||||
}
|
||||
reloadAttempted = true
|
||||
if err = opNginx(nginxInstall.ContainerName, constant.NginxReload); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit().Error
|
||||
}
|
||||
|
||||
func (w WebsiteService) UpdateSiteDir(req request.WebsiteUpdateDir) error {
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/ecdsa"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"math/big"
|
||||
"net"
|
||||
@@ -18,25 +22,27 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/1Panel-dev/1Panel/agent/app/repo"
|
||||
"github.com/1Panel-dev/1Panel/agent/utils/common"
|
||||
|
||||
"github.com/1Panel-dev/1Panel/agent/app/dto/request"
|
||||
"github.com/1Panel-dev/1Panel/agent/app/dto/response"
|
||||
"github.com/1Panel-dev/1Panel/agent/app/model"
|
||||
"github.com/1Panel-dev/1Panel/agent/app/repo"
|
||||
"github.com/1Panel-dev/1Panel/agent/buserr"
|
||||
"github.com/1Panel-dev/1Panel/agent/constant"
|
||||
"github.com/1Panel-dev/1Panel/agent/global"
|
||||
"github.com/1Panel-dev/1Panel/agent/i18n"
|
||||
"github.com/1Panel-dev/1Panel/agent/utils/common"
|
||||
"github.com/1Panel-dev/1Panel/agent/utils/files"
|
||||
"github.com/1Panel-dev/1Panel/agent/utils/ssl"
|
||||
"github.com/go-acme/lego/v5/certcrypto"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type WebsiteCAService struct {
|
||||
}
|
||||
|
||||
type IWebsiteCAService interface {
|
||||
Import(req request.WebsiteCAImport) error
|
||||
Upload(name string, archive []byte) error
|
||||
Page(search request.WebsiteCASearch) (int64, []response.WebsiteCADTO, error)
|
||||
Create(create request.WebsiteCACreate) (*request.WebsiteCACreate, error)
|
||||
GetCA(id uint) (*response.WebsiteCADTO, error)
|
||||
@@ -212,10 +218,17 @@ func (w WebsiteCAService) ObtainSSL(req request.WebsiteCAObtain) (*model.Website
|
||||
websiteSSL.Shell = req.Shell
|
||||
}
|
||||
if req.PushDir {
|
||||
if !files.NewFileOp().Stat(req.Dir) {
|
||||
dirs := certificateDirectories(req.Dir)
|
||||
if len(dirs) == 0 {
|
||||
return nil, buserr.New("ErrLinkPathNotFound")
|
||||
}
|
||||
websiteSSL.Dir = req.Dir
|
||||
for _, dir := range dirs {
|
||||
info, err := os.Stat(dir)
|
||||
if err != nil || !info.IsDir() {
|
||||
return nil, buserr.New("ErrLinkPathNotFound")
|
||||
}
|
||||
}
|
||||
websiteSSL.Dir = strings.Join(dirs, "\n")
|
||||
}
|
||||
if req.Domains != "" {
|
||||
domainArray := strings.Split(req.Domains, "\n")
|
||||
@@ -327,7 +340,7 @@ func (w WebsiteCAService) ObtainSSL(req request.WebsiteCAObtain) (*model.Website
|
||||
websiteSSL.ExpireDate = cert.NotAfter
|
||||
websiteSSL.StartDate = cert.NotBefore
|
||||
websiteSSL.Type = cert.Issuer.CommonName
|
||||
websiteSSL.Organization = rootCsr.Subject.Organization[0]
|
||||
websiteSSL.Organization = strings.Join(rootCsr.Subject.Organization, ",")
|
||||
websiteSSL.Status = constant.SSLReady
|
||||
|
||||
if req.Renew {
|
||||
@@ -348,7 +361,9 @@ func (w WebsiteCAService) ObtainSSL(req request.WebsiteCAObtain) (*model.Website
|
||||
if websiteSSL.ExecShell {
|
||||
workDir := global.Dir.DataDir
|
||||
if websiteSSL.PushDir {
|
||||
workDir = websiteSSL.Dir
|
||||
if dirs := certificateDirectories(websiteSSL.Dir); len(dirs) > 0 {
|
||||
workDir = dirs[0]
|
||||
}
|
||||
}
|
||||
logger.Println(i18n.GetMsgByKey("ExecShellStart"))
|
||||
if err = runShellScriptFile(workDir, websiteSSL.Shell, logger); err != nil {
|
||||
@@ -433,3 +448,150 @@ func (w WebsiteCAService) DownloadFile(id uint) (*os.File, error) {
|
||||
}
|
||||
return os.Open(path.Join(dir, fileName))
|
||||
}
|
||||
|
||||
func (w WebsiteCAService) Upload(name string, archive []byte) error {
|
||||
ca, err := parseCAArchive(archive)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return saveImportedCA(name, ca)
|
||||
}
|
||||
|
||||
func saveImportedCA(name string, ca *model.WebsiteCA) error {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" || len(name) > 128 || name == "." || name == ".." || strings.ContainsAny(name, "/\\\x00") {
|
||||
return buserr.WithDetail("ErrInvalidParams", "name", nil)
|
||||
}
|
||||
exist, err := websiteCARepo.GetFirst(repo.WithByName(name))
|
||||
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return err
|
||||
}
|
||||
if exist.ID > 0 {
|
||||
return buserr.New("ErrNameIsExist")
|
||||
}
|
||||
ca.Name = name
|
||||
return websiteCARepo.Create(context.Background(), ca)
|
||||
}
|
||||
|
||||
// Read only the certificate and key in memory; never extract user-supplied paths.
|
||||
func parseCAArchive(archive []byte) (*model.WebsiteCA, error) {
|
||||
invalid := func() (*model.WebsiteCA, error) { return nil, buserr.New("ErrCAUpload") }
|
||||
if len(archive) > 10<<20 {
|
||||
return invalid()
|
||||
}
|
||||
reader, err := zip.NewReader(bytes.NewReader(archive), int64(len(archive)))
|
||||
if err != nil || len(reader.File) > 16 {
|
||||
return invalid()
|
||||
}
|
||||
contents := make(map[string][]byte)
|
||||
for _, file := range reader.File {
|
||||
if file.FileInfo().IsDir() {
|
||||
continue
|
||||
}
|
||||
name := path.Base(file.Name)
|
||||
if (name != "ca.crt" && name != "ca.key") || contents[name] != nil || file.UncompressedSize64 > 1<<20 {
|
||||
return invalid()
|
||||
}
|
||||
stream, err := file.Open()
|
||||
if err != nil {
|
||||
return invalid()
|
||||
}
|
||||
data, err := io.ReadAll(io.LimitReader(stream, (1<<20)+1))
|
||||
stream.Close()
|
||||
if err != nil || len(data) > 1<<20 {
|
||||
return invalid()
|
||||
}
|
||||
contents[name] = data
|
||||
}
|
||||
return parseAndValidateRootCA(contents["ca.crt"], contents["ca.key"])
|
||||
}
|
||||
|
||||
func parseAndValidateRootCA(certificate, privateKey []byte) (*model.WebsiteCA, error) {
|
||||
invalid := func() (*model.WebsiteCA, error) { return nil, buserr.New("ErrCAImport") }
|
||||
if len(certificate) > 1<<20 || len(privateKey) > 1<<20 {
|
||||
return invalid()
|
||||
}
|
||||
pair, err := tls.X509KeyPair(certificate, privateKey)
|
||||
if err != nil || len(pair.Certificate) != 1 {
|
||||
return invalid()
|
||||
}
|
||||
cert := pair.Leaf
|
||||
now := time.Now()
|
||||
if cert == nil || !cert.IsCA || !cert.BasicConstraintsValid || cert.KeyUsage&x509.KeyUsageCertSign == 0 ||
|
||||
!bytes.Equal(cert.RawIssuer, cert.RawSubject) || cert.CheckSignatureFrom(cert) != nil ||
|
||||
now.Before(cert.NotBefore) || !now.Before(cert.NotAfter) {
|
||||
return invalid()
|
||||
}
|
||||
ca := &model.WebsiteCA{CSR: string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: cert.Raw}))}
|
||||
// Normalize keys to the formats used by ObtainSSL, including PKCS#8 uploads.
|
||||
switch key := pair.PrivateKey.(type) {
|
||||
case *ecdsa.PrivateKey:
|
||||
bits := key.Curve.Params().BitSize
|
||||
if bits != 256 && bits != 384 {
|
||||
return invalid()
|
||||
}
|
||||
der, err := x509.MarshalECPrivateKey(key)
|
||||
if err != nil {
|
||||
return invalid()
|
||||
}
|
||||
ca.KeyType = fmt.Sprintf("EC%d", bits)
|
||||
ca.PrivateKey = string(pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: der}))
|
||||
case *rsa.PrivateKey:
|
||||
bits := key.N.BitLen()
|
||||
if bits != 2048 && bits != 3072 && bits != 4096 && bits != 8192 {
|
||||
return invalid()
|
||||
}
|
||||
ca.KeyType = fmt.Sprintf("RSA%d", bits)
|
||||
ca.PrivateKey = string(pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)}))
|
||||
default:
|
||||
return invalid()
|
||||
}
|
||||
return ca, nil
|
||||
}
|
||||
|
||||
func (w WebsiteCAService) Import(req request.WebsiteCAImport) error {
|
||||
certificate, privateKey := []byte(req.Certificate), []byte(req.PrivateKey)
|
||||
switch req.Type {
|
||||
case "local":
|
||||
var err error
|
||||
certificate, err = readCAImportFile(req.CertificatePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
privateKey, err = readCAImportFile(req.PrivateKeyPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
case "paste":
|
||||
default:
|
||||
return buserr.New("ErrCAImport")
|
||||
}
|
||||
ca, err := parseAndValidateRootCA(certificate, privateKey)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return saveImportedCA(req.Name, ca)
|
||||
}
|
||||
|
||||
func readCAImportFile(name string) ([]byte, error) {
|
||||
info, err := os.Stat(name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !info.Mode().IsRegular() || info.Size() > 1<<20 {
|
||||
return nil, buserr.New("ErrCAImport")
|
||||
}
|
||||
file, err := os.Open(name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer file.Close()
|
||||
content, err := io.ReadAll(io.LimitReader(file, (1<<20)+1))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(content) > 1<<20 {
|
||||
return nil, buserr.New("ErrCAImport")
|
||||
}
|
||||
return content, nil
|
||||
}
|
||||
|
||||
@@ -209,11 +209,17 @@ func (w WebsiteSSLService) Create(create request.WebsiteSSLCreate) (request.Webs
|
||||
websiteSSL.Shell = create.Shell
|
||||
}
|
||||
if create.PushDir {
|
||||
fileOP := files.NewFileOp()
|
||||
if !fileOP.Stat(create.Dir) {
|
||||
dirs := certificateDirectories(create.Dir)
|
||||
if len(dirs) == 0 {
|
||||
return res, buserr.New("ErrLinkPathNotFound")
|
||||
}
|
||||
websiteSSL.Dir = create.Dir
|
||||
for _, dir := range dirs {
|
||||
info, err := os.Stat(dir)
|
||||
if err != nil || !info.IsDir() {
|
||||
return res, buserr.New("ErrLinkPathNotFound")
|
||||
}
|
||||
}
|
||||
websiteSSL.Dir = strings.Join(dirs, "\n")
|
||||
}
|
||||
setSSLPushConfig(&websiteSSL, create.PushNode, create.Nodes)
|
||||
|
||||
@@ -573,7 +579,9 @@ func (w WebsiteSSLService) obtainSSL(id uint, autoRenew bool) error {
|
||||
if websiteSSL.ExecShell {
|
||||
workDir := global.Dir.DataDir
|
||||
if websiteSSL.PushDir {
|
||||
workDir = websiteSSL.Dir
|
||||
if dirs := certificateDirectories(websiteSSL.Dir); len(dirs) > 0 {
|
||||
workDir = dirs[0]
|
||||
}
|
||||
}
|
||||
printSSLLog(logger, "ExecShellStart", nil)
|
||||
if err = runShellScriptFile(workDir, websiteSSL.Shell, logger); err != nil {
|
||||
@@ -793,11 +801,16 @@ func (w WebsiteSSLService) Update(update request.WebsiteSSLUpdate) error {
|
||||
}
|
||||
|
||||
if update.PushDir {
|
||||
fileOP := files.NewFileOp()
|
||||
if !fileOP.Stat(update.Dir) {
|
||||
_ = fileOP.CreateDir(update.Dir, constant.DirPerm)
|
||||
dirs := certificateDirectories(update.Dir)
|
||||
if len(dirs) == 0 {
|
||||
return buserr.New("ErrLinkPathNotFound")
|
||||
}
|
||||
updateParams["dir"] = update.Dir
|
||||
for _, dir := range dirs {
|
||||
if err := os.MkdirAll(dir, constant.DirPerm); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
updateParams["dir"] = strings.Join(dirs, "\n")
|
||||
}
|
||||
var domains []string
|
||||
if update.OtherDomains != "" {
|
||||
|
||||
@@ -1454,10 +1454,10 @@ func getWebsiteDomains(domains []request.WebsiteDomain, defaultHTTPPort, default
|
||||
return
|
||||
}
|
||||
domainModel.Domain = strings.ToLower(domainModel.Domain)
|
||||
domainModel.Port = domain.Port
|
||||
if domain.Port == 0 {
|
||||
domain.Port = defaultHTTPPort
|
||||
}
|
||||
domainModel.Port = domain.Port
|
||||
domainModel.SSL = domain.SSL
|
||||
domainModel.WebsiteID = websiteID
|
||||
domainModels = append(domainModels, domainModel)
|
||||
@@ -1529,26 +1529,41 @@ func checkWebsitePort(defaultHTTPsPort, port int, websiteType string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// certificateDirectories accepts both legacy single paths and newline-separated paths.
|
||||
func certificateDirectories(value string) []string {
|
||||
var dirs []string
|
||||
seen := make(map[string]bool)
|
||||
for _, dir := range strings.Split(value, "\n") {
|
||||
dir = strings.TrimSpace(dir)
|
||||
if dir == "" {
|
||||
continue
|
||||
}
|
||||
dir = filepath.Clean(dir)
|
||||
if !seen[dir] {
|
||||
seen[dir] = true
|
||||
dirs = append(dirs, dir)
|
||||
}
|
||||
}
|
||||
return dirs
|
||||
}
|
||||
|
||||
func saveCertificateFile(websiteSSL *model.WebsiteSSL, logger *log.Logger) {
|
||||
if websiteSSL.PushDir {
|
||||
fileOp := files.NewFileOp()
|
||||
var (
|
||||
pushErr error
|
||||
MsgMap = map[string]interface{}{"path": websiteSSL.Dir, "status": i18n.GetMsgByKey("Success")}
|
||||
)
|
||||
if pushErr = fileOp.SaveFile(path.Join(websiteSSL.Dir, "privkey.pem"), websiteSSL.PrivateKey, constant.FilePerm); pushErr != nil {
|
||||
MsgMap["status"] = i18n.GetMsgByKey("Failed")
|
||||
logger.Println(i18n.GetMsgWithMap("PushDirLog", MsgMap))
|
||||
logger.Println("Push dir failed:" + pushErr.Error())
|
||||
}
|
||||
if pushErr = fileOp.SaveFile(path.Join(websiteSSL.Dir, "fullchain.pem"), websiteSSL.Pem, constant.FilePerm); pushErr != nil {
|
||||
MsgMap["status"] = i18n.GetMsgByKey("Failed")
|
||||
logger.Println(i18n.GetMsgWithMap("PushDirLog", MsgMap))
|
||||
logger.Println("Push dir failed:" + pushErr.Error())
|
||||
}
|
||||
if pushErr == nil {
|
||||
logger.Println(i18n.GetMsgWithMap("PushDirLog", MsgMap))
|
||||
if !websiteSSL.PushDir {
|
||||
return
|
||||
}
|
||||
fileOp := files.NewFileOp()
|
||||
for _, dir := range certificateDirectories(websiteSSL.Dir) {
|
||||
status := i18n.GetMsgByKey("Success")
|
||||
for _, file := range []struct{ name, content string }{
|
||||
{"privkey.pem", websiteSSL.PrivateKey},
|
||||
{"fullchain.pem", websiteSSL.Pem},
|
||||
} {
|
||||
if err := fileOp.SaveFile(path.Join(dir, file.name), file.content, constant.FilePerm); err != nil {
|
||||
status = i18n.GetMsgByKey("Failed")
|
||||
logger.Printf("Push dir %s failed: %s", dir, err)
|
||||
}
|
||||
}
|
||||
logger.Println(i18n.GetMsgWithMap("PushDirLog", map[string]interface{}{"path": dir, "status": status}))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3740,13 +3740,11 @@
|
||||
"formatEN": "Create runtime [name]"
|
||||
},
|
||||
"/runtimes/del": {
|
||||
"bodyKeys": [
|
||||
"id"
|
||||
],
|
||||
"bodyKeys": [],
|
||||
"paramKeys": [],
|
||||
"beforeFunctions": [],
|
||||
"formatZH": "删除运行环境 [id]",
|
||||
"formatEN": "Delete runtime [id]"
|
||||
"formatZH": "删除运行环境 [name]",
|
||||
"formatEN": "Delete runtime [name]"
|
||||
},
|
||||
"/runtimes/operate": {
|
||||
"bodyKeys": [
|
||||
@@ -4582,13 +4580,13 @@
|
||||
},
|
||||
"/websites/php/version": {
|
||||
"bodyKeys": [
|
||||
"websiteId"
|
||||
"websiteID"
|
||||
],
|
||||
"paramKeys": [],
|
||||
"beforeFunctions": [
|
||||
{
|
||||
"input_column": "id",
|
||||
"input_value": "websiteId",
|
||||
"input_value": "websiteID",
|
||||
"isList": false,
|
||||
"db": "websites",
|
||||
"output_column": "primary_domain",
|
||||
|
||||
@@ -10,6 +10,8 @@ const (
|
||||
AppOpenclaw = "openclaw"
|
||||
AppCopaw = "copaw"
|
||||
AppHermesAgent = "hermes-agent"
|
||||
AppDeepSeekHarness = "deepseek-harness"
|
||||
AppOctop = "octop"
|
||||
AppMysql = "mysql"
|
||||
AppMariaDB = "mariadb"
|
||||
AppPostgresql = "postgresql"
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
module github.com/1Panel-dev/1Panel/agent
|
||||
|
||||
go 1.26.6
|
||||
go 1.27.3
|
||||
|
||||
replace github.com/go-acme/lego/v5 => github.com/1Panel-dev/lego/v5 v5.3.1
|
||||
|
||||
|
||||
@@ -773,3 +773,7 @@ VMSnapshotRecoverTask: "Recover snapshot [{{ .snapshot }}] for virtual machine [
|
||||
VMSnapshotRecoverStep: "Recover virtual machine snapshot [{{ .snapshot }}]"
|
||||
VMSnapshotDeleteTask: "Delete snapshot [{{ .snapshot }}] for virtual machine [{{ .vm }}]"
|
||||
VMSnapshotDeleteStep: "Delete virtual machine snapshot [{{ .snapshot }}]"
|
||||
|
||||
ErrCAUpload: "Upload a ZIP (up to 10 MB) containing a valid self-signed CA certificate ca.crt and its matching private key ca.key, with a supported RSA or EC algorithm."
|
||||
|
||||
ErrCAImport: "Provide a valid self-signed root CA certificate and matching private key (up to 1 MB each), using a supported RSA or EC algorithm."
|
||||
|
||||
@@ -773,3 +773,7 @@ VMSnapshotRecoverTask: "Restaurar instantánea [{{ .snapshot }}] para la máquin
|
||||
VMSnapshotRecoverStep: "Restaurar instantánea de máquina virtual [{{ .snapshot }}]"
|
||||
VMSnapshotDeleteTask: "Eliminar instantánea [{{ .snapshot }}] de la máquina virtual [{{ .vm }}]"
|
||||
VMSnapshotDeleteStep: "Eliminar instantánea de máquina virtual [{{ .snapshot }}]"
|
||||
|
||||
ErrCAUpload: "Upload a ZIP (up to 10 MB) containing a valid self-signed CA certificate ca.crt and its matching private key ca.key, with a supported RSA or EC algorithm."
|
||||
|
||||
ErrCAImport: "Provide a valid self-signed root CA certificate and matching private key (up to 1 MB each), using a supported RSA or EC algorithm."
|
||||
|
||||
@@ -774,3 +774,7 @@ VMSnapshotRecoverTask: "بازیابی اسنپشات [{{ .snapshot }}] بر
|
||||
VMSnapshotRecoverStep: "بازیابی اسنپشات ماشین مجازی [{{ .snapshot }}]"
|
||||
VMSnapshotDeleteTask: "حذف اسنپشات [{{ .snapshot }}] برای ماشین مجازی [{{ .vm }}]"
|
||||
VMSnapshotDeleteStep: "حذف اسنپشات ماشین مجازی [{{ .snapshot }}]"
|
||||
|
||||
ErrCAUpload: "Upload a ZIP (up to 10 MB) containing a valid self-signed CA certificate ca.crt and its matching private key ca.key, with a supported RSA or EC algorithm."
|
||||
|
||||
ErrCAImport: "Provide a valid self-signed root CA certificate and matching private key (up to 1 MB each), using a supported RSA or EC algorithm."
|
||||
|
||||
@@ -773,3 +773,7 @@ VMSnapshotRecoverTask: "仮想マシン [{{ .vm }}] のスナップショット
|
||||
VMSnapshotRecoverStep: "仮想マシンのスナップショット [{{ .snapshot }}] を復元"
|
||||
VMSnapshotDeleteTask: "仮想マシン [{{ .vm }}] のスナップショット [{{ .snapshot }}] を削除"
|
||||
VMSnapshotDeleteStep: "仮想マシンのスナップショット [{{ .snapshot }}] を削除"
|
||||
|
||||
ErrCAUpload: "Upload a ZIP (up to 10 MB) containing a valid self-signed CA certificate ca.crt and its matching private key ca.key, with a supported RSA or EC algorithm."
|
||||
|
||||
ErrCAImport: "Provide a valid self-signed root CA certificate and matching private key (up to 1 MB each), using a supported RSA or EC algorithm."
|
||||
|
||||
@@ -773,3 +773,7 @@ VMSnapshotRecoverTask: "가상 머신 [{{ .vm }}] 스냅샷 [{{ .snapshot }}]
|
||||
VMSnapshotRecoverStep: "가상 머신 스냅샷 [{{ .snapshot }}] 복구"
|
||||
VMSnapshotDeleteTask: "가상 머신 [{{ .vm }}] 스냅샷 [{{ .snapshot }}] 삭제"
|
||||
VMSnapshotDeleteStep: "가상 머신 스냅샷 [{{ .snapshot }}] 삭제"
|
||||
|
||||
ErrCAUpload: "Upload a ZIP (up to 10 MB) containing a valid self-signed CA certificate ca.crt and its matching private key ca.key, with a supported RSA or EC algorithm."
|
||||
|
||||
ErrCAImport: "Provide a valid self-signed root CA certificate and matching private key (up to 1 MB each), using a supported RSA or EC algorithm."
|
||||
|
||||
@@ -764,3 +764,7 @@ VMSnapshotRecoverTask: "ກູ້ຄືນສະແນັບຊັອດ [{{ .sn
|
||||
VMSnapshotRecoverStep: "ກູ້ຄືນສະແນັບຊັອດເຄື່ອງສະເໝືອນ [{{ .snapshot }}]"
|
||||
VMSnapshotDeleteTask: "ລຶບສະແນັບຊັອດ [{{ .snapshot }}] ຂອງເຄື່ອງສະເໝືອນ [{{ .vm }}]"
|
||||
VMSnapshotDeleteStep: "ລຶບສະແນັບຊັອດເຄື່ອງສະເໝືອນ [{{ .snapshot }}]"
|
||||
|
||||
ErrCAUpload: "Upload a ZIP (up to 10 MB) containing a valid self-signed CA certificate ca.crt and its matching private key ca.key, with a supported RSA or EC algorithm."
|
||||
|
||||
ErrCAImport: "Provide a valid self-signed root CA certificate and matching private key (up to 1 MB each), using a supported RSA or EC algorithm."
|
||||
|
||||
@@ -773,3 +773,7 @@ VMSnapshotRecoverTask: "Pulihkan syot kilat [{{ .snapshot }}] untuk mesin maya [
|
||||
VMSnapshotRecoverStep: "Pulihkan syot kilat mesin maya [{{ .snapshot }}]"
|
||||
VMSnapshotDeleteTask: "Padam syot kilat [{{ .snapshot }}] untuk mesin maya [{{ .vm }}]"
|
||||
VMSnapshotDeleteStep: "Padam syot kilat mesin maya [{{ .snapshot }}]"
|
||||
|
||||
ErrCAUpload: "Upload a ZIP (up to 10 MB) containing a valid self-signed CA certificate ca.crt and its matching private key ca.key, with a supported RSA or EC algorithm."
|
||||
|
||||
ErrCAImport: "Provide a valid self-signed root CA certificate and matching private key (up to 1 MB each), using a supported RSA or EC algorithm."
|
||||
|
||||
@@ -773,3 +773,7 @@ VMSnapshotRecoverTask: "Restaurar snapshot [{{ .snapshot }}] da máquina virtual
|
||||
VMSnapshotRecoverStep: "Restaurar snapshot da máquina virtual [{{ .snapshot }}]"
|
||||
VMSnapshotDeleteTask: "Excluir snapshot [{{ .snapshot }}] da máquina virtual [{{ .vm }}]"
|
||||
VMSnapshotDeleteStep: "Excluir snapshot da máquina virtual [{{ .snapshot }}]"
|
||||
|
||||
ErrCAUpload: "Upload a ZIP (up to 10 MB) containing a valid self-signed CA certificate ca.crt and its matching private key ca.key, with a supported RSA or EC algorithm."
|
||||
|
||||
ErrCAImport: "Provide a valid self-signed root CA certificate and matching private key (up to 1 MB each), using a supported RSA or EC algorithm."
|
||||
|
||||
@@ -773,3 +773,7 @@ VMSnapshotRecoverTask: "Восстановить снимок [{{ .snapshot }}]
|
||||
VMSnapshotRecoverStep: "Восстановить снимок виртуальной машины [{{ .snapshot }}]"
|
||||
VMSnapshotDeleteTask: "Удалить снимок [{{ .snapshot }}] виртуальной машины [{{ .vm }}]"
|
||||
VMSnapshotDeleteStep: "Удалить снимок виртуальной машины [{{ .snapshot }}]"
|
||||
|
||||
ErrCAUpload: "Upload a ZIP (up to 10 MB) containing a valid self-signed CA certificate ca.crt and its matching private key ca.key, with a supported RSA or EC algorithm."
|
||||
|
||||
ErrCAImport: "Provide a valid self-signed root CA certificate and matching private key (up to 1 MB each), using a supported RSA or EC algorithm."
|
||||
|
||||
@@ -773,3 +773,7 @@ VMSnapshotRecoverTask: "Sanal makine [{{ .vm }}] anlık görüntüsü [{{ .snaps
|
||||
VMSnapshotRecoverStep: "Sanal makine anlık görüntüsü [{{ .snapshot }}] geri yükle"
|
||||
VMSnapshotDeleteTask: "Sanal makine [{{ .vm }}] anlık görüntüsü [{{ .snapshot }}] sil"
|
||||
VMSnapshotDeleteStep: "Sanal makine anlık görüntüsü [{{ .snapshot }}] sil"
|
||||
|
||||
ErrCAUpload: "Upload a ZIP (up to 10 MB) containing a valid self-signed CA certificate ca.crt and its matching private key ca.key, with a supported RSA or EC algorithm."
|
||||
|
||||
ErrCAImport: "Provide a valid self-signed root CA certificate and matching private key (up to 1 MB each), using a supported RSA or EC algorithm."
|
||||
|
||||
@@ -773,3 +773,7 @@ VMSnapshotRecoverTask: "復原虛擬機 [{{ .vm }}] 快照 [{{ .snapshot }}]"
|
||||
VMSnapshotRecoverStep: "復原虛擬機快照 [{{ .snapshot }}]"
|
||||
VMSnapshotDeleteTask: "刪除虛擬機 [{{ .vm }}] 快照 [{{ .snapshot }}]"
|
||||
VMSnapshotDeleteStep: "刪除虛擬機快照 [{{ .snapshot }}]"
|
||||
|
||||
ErrCAUpload: "請上傳包含有效自簽根憑證 ca.crt 及匹配私鑰 ca.key 的 ZIP 檔案(最大 10 MB),金鑰演算法須為支援的 RSA 或 EC。"
|
||||
|
||||
ErrCAImport: "請提供有效的自簽根憑證及匹配私鑰(每個最大 1 MB),金鑰演算法須為支援的 RSA 或 EC。"
|
||||
|
||||
@@ -773,3 +773,7 @@ VMSnapshotRecoverTask: "恢复虚拟机 [{{ .vm }}] 快照 [{{ .snapshot }}]"
|
||||
VMSnapshotRecoverStep: "恢复虚拟机快照 [{{ .snapshot }}]"
|
||||
VMSnapshotDeleteTask: "删除虚拟机 [{{ .vm }}] 快照 [{{ .snapshot }}]"
|
||||
VMSnapshotDeleteStep: "删除虚拟机快照 [{{ .snapshot }}]"
|
||||
|
||||
ErrCAUpload: "请上传包含有效自签根证书 ca.crt 及匹配私钥 ca.key 的 ZIP 文件(最大 10 MB),密钥算法须为支持的 RSA 或 EC。"
|
||||
|
||||
ErrCAImport: "请提供有效的自签根证书及匹配私钥(每个最大 1 MB),密钥算法须为支持的 RSA 或 EC。"
|
||||
|
||||
@@ -115,6 +115,10 @@ func agentDBMigrations() []*gormigrate.Migration {
|
||||
migrations.AddMonitorSettings,
|
||||
migrations.AddVLLMRequestMetrics,
|
||||
migrations.AddVLLMSampleInterval,
|
||||
migrations.AddNativeAgentInstalls,
|
||||
migrations.AddQuickDirectory,
|
||||
migrations.AddLocalModelAccountLink,
|
||||
migrations.AddOllamaModelAccountSync,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"github.com/1Panel-dev/1Panel/agent/app/model"
|
||||
"github.com/1Panel-dev/1Panel/agent/constant"
|
||||
"github.com/1Panel-dev/1Panel/agent/global"
|
||||
"github.com/go-gormigrate/gormigrate/v2"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
var AddNativeAgentInstalls = &gormigrate.Migration{
|
||||
ID: "20261020-add-deepseek-harness-octop-agent-installs",
|
||||
Migrate: func(tx *gorm.DB) error {
|
||||
var installs []struct {
|
||||
model.AppInstall
|
||||
AgentType string
|
||||
}
|
||||
if err := tx.Table("app_installs").
|
||||
Select("app_installs.*, apps.key AS agent_type").
|
||||
Joins("JOIN apps ON apps.id = app_installs.app_id").
|
||||
Where("apps.key IN ?", []string{constant.AppDeepSeekHarness, constant.AppOctop}).
|
||||
Where("NOT EXISTS (SELECT 1 FROM agents WHERE agents.app_install_id = app_installs.id)").
|
||||
Order("app_installs.id").
|
||||
Scan(&installs).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
for _, install := range installs {
|
||||
var count int64
|
||||
if err := tx.Model(&model.Agent{}).Where("LOWER(name) = LOWER(?)", install.Name).Count(&count).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if count > 0 {
|
||||
global.LOG.Warnf("skip agent registration for app install %d: name already exists", install.ID)
|
||||
continue
|
||||
}
|
||||
agent := model.Agent{
|
||||
BaseModel: model.BaseModel{CreatedAt: install.CreatedAt, UpdatedAt: install.UpdatedAt},
|
||||
Name: install.Name, AgentType: install.AgentType, AppInstallID: install.ID,
|
||||
Status: install.Status, Message: install.Message,
|
||||
}
|
||||
if err := tx.Create(&agent).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
@@ -1888,3 +1888,54 @@ var AddVLLMSampleInterval = &gormigrate.Migration{
|
||||
return global.VLLMMonitorDB.AutoMigrate(&model.MonitorVLLM{})
|
||||
},
|
||||
}
|
||||
|
||||
var AddQuickDirectory = &gormigrate.Migration{
|
||||
ID: "20261016-add-quick-directory",
|
||||
Migrate: func(tx *gorm.DB) error {
|
||||
return tx.AutoMigrate(&model.QuickDirectory{})
|
||||
},
|
||||
}
|
||||
|
||||
var AddLocalModelAccountLink = &gormigrate.Migration{
|
||||
ID: "20261023-add-local-model-account-link",
|
||||
Migrate: func(tx *gorm.DB) error {
|
||||
return tx.AutoMigrate(&model.AgentAccount{})
|
||||
},
|
||||
}
|
||||
|
||||
var AddOllamaModelAccountSync = &gormigrate.Migration{
|
||||
ID: "20261026-add-ollama-model-account-sync",
|
||||
Migrate: func(tx *gorm.DB) error {
|
||||
if err := tx.AutoMigrate(&model.AppInstall{}); err != nil {
|
||||
return err
|
||||
}
|
||||
var accounts []model.AgentAccount
|
||||
if err := tx.Where("provider = ? AND app_install_id > 0", "ollama").Find(&accounts).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
for _, account := range accounts {
|
||||
var install model.AppInstall
|
||||
if err := tx.Preload("App").First(&install, account.AppInstallID).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
continue
|
||||
}
|
||||
return err
|
||||
}
|
||||
if install.App.Key != "ollama" {
|
||||
continue
|
||||
}
|
||||
addressType := "custom"
|
||||
if strings.TrimRight(account.BaseURL, "/") == fmt.Sprintf("http://%s:11434/v1", install.ContainerName) {
|
||||
addressType = "container"
|
||||
}
|
||||
if err := tx.Model(&model.AppInstall{}).Where("id = ?", account.AppInstallID).Updates(map[string]interface{}{
|
||||
"sync_model_account": true,
|
||||
"model_account_base_url_type": addressType,
|
||||
"model_account_base_url": account.BaseURL,
|
||||
}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
|
||||
"github.com/1Panel-dev/1Panel/agent/global"
|
||||
"github.com/1Panel-dev/1Panel/agent/middleware"
|
||||
"github.com/1Panel-dev/1Panel/agent/utils/mcpapi"
|
||||
"github.com/1Panel-dev/1Panel/agent/utils/mcpruntime"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func registerMCPRuntime(engine *gin.Engine) {
|
||||
controller := mcpruntime.New()
|
||||
engine.POST("/internal/mcp/docker", mcpDockerHandler(controller))
|
||||
engine.GET(mcpapi.CapabilitiesPath, middleware.Certificate(), mcpCapabilitiesHandler())
|
||||
}
|
||||
|
||||
func mcpCapabilitiesHandler() gin.HandlerFunc {
|
||||
return func(ctx *gin.Context) {
|
||||
if !mcpapi.TrustedTransport(ctx.Request) {
|
||||
ctx.AbortWithStatus(http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
ctx.JSON(http.StatusOK, gin.H{"contractVersion": mcpapi.ContractVersion, "features": []string{
|
||||
"operation-bound-identity", "stable-node-target", "resource-guards",
|
||||
}})
|
||||
}
|
||||
}
|
||||
|
||||
func mcpDockerHandler(controller *mcpruntime.Controller) gin.HandlerFunc {
|
||||
return func(ctx *gin.Context) {
|
||||
local, ok := ctx.Request.Context().Value(http.LocalAddrContextKey).(net.Addr)
|
||||
if !global.IsMaster || !ok || local.Network() != "unix" {
|
||||
ctx.AbortWithStatus(http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
data, err := io.ReadAll(io.LimitReader(ctx.Request.Body, 8193))
|
||||
if err != nil || len(data) > 8192 {
|
||||
ctx.AbortWithStatus(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
var req mcpruntime.Request
|
||||
decoder := json.NewDecoder(bytes.NewReader(data))
|
||||
decoder.DisallowUnknownFields()
|
||||
if decoder.Decode(&req) != nil || decoder.Decode(new(any)) != io.EOF {
|
||||
ctx.AbortWithStatus(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
response, err := controller.Handle(ctx.Request.Context(), req)
|
||||
if err != nil {
|
||||
ctx.JSON(http.StatusBadRequest, mcpruntime.Response{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
ctx.JSON(http.StatusOK, response)
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ var (
|
||||
func Routers() *gin.Engine {
|
||||
Router = gin.Default()
|
||||
Router.Use(i18n.UseI18n())
|
||||
registerMCPRuntime(Router)
|
||||
|
||||
PrivateGroup := Router.Group("/api/v2")
|
||||
if !global.IsMaster {
|
||||
|
||||
@@ -10,10 +10,15 @@ import (
|
||||
"github.com/1Panel-dev/1Panel/agent/app/api/v2/helper"
|
||||
"github.com/1Panel-dev/1Panel/agent/global"
|
||||
"github.com/1Panel-dev/1Panel/agent/utils/cmd"
|
||||
"github.com/1Panel-dev/1Panel/agent/utils/mcpapi"
|
||||
"github.com/1Panel-dev/1Panel/agent/utils/xpack"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
var loadNodeProxyID = func() (string, error) {
|
||||
return cmd.NewCommandMgr(cmd.WithTimeout(20*time.Second)).RunWithStdout("cat", "/etc/1panel/.nodeProxyID")
|
||||
}
|
||||
|
||||
func Certificate() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if global.IsMaster {
|
||||
@@ -25,16 +30,23 @@ func Certificate() gin.HandlerFunc {
|
||||
return
|
||||
}
|
||||
conn := c.Request.Header.Get("Connection")
|
||||
if conn == "Upgrade" {
|
||||
mcpRequest, _ := mcpapi.Check(c.Request)
|
||||
mcpTransport := mcpRequest || c.Request.URL.Path == mcpapi.CapabilitiesPath
|
||||
if conn == "Upgrade" && !mcpTransport {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
masterProxyID := c.Request.Header.Get("Proxy-Id")
|
||||
proxyID, err := cmd.NewCommandMgr(cmd.WithTimeout(20*time.Second)).RunWithStdout("cat", "/etc/1panel/.nodeProxyID")
|
||||
proxyID, err := loadNodeProxyID()
|
||||
if mcpTransport && (err != nil || strings.TrimSpace(proxyID) == "" || strings.TrimSpace(proxyID) != strings.TrimSpace(masterProxyID)) {
|
||||
c.AbortWithStatus(http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
if err == nil && len(proxyID) != 0 && strings.TrimSpace(proxyID) != strings.TrimSpace(masterProxyID) {
|
||||
helper.InternalServer(c, fmt.Errorf("err proxy id"))
|
||||
return
|
||||
}
|
||||
c.Request = mcpapi.BindVerifiedNodeTransport(c.Request)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,11 +13,13 @@ func (a *AIToolsRouter) InitRouter(Router *gin.RouterGroup) {
|
||||
|
||||
baseApi := v1.ApiGroupApp.BaseApi
|
||||
{
|
||||
aiToolsRouter.GET("/benchmark/sources", baseApi.AIBenchmarkSources)
|
||||
aiToolsRouter.POST("/ollama/close", baseApi.CloseOllamaModel)
|
||||
aiToolsRouter.POST("/ollama/model", baseApi.CreateOllamaModel)
|
||||
aiToolsRouter.POST("/ollama/model/recreate", baseApi.RecreateOllamaModel)
|
||||
aiToolsRouter.POST("/ollama/model/search", baseApi.SearchOllamaModel)
|
||||
aiToolsRouter.POST("/ollama/model/sync", baseApi.SyncOllamaModel)
|
||||
aiToolsRouter.POST("/ollama/account/sync", baseApi.SyncOllamaModelAccount)
|
||||
aiToolsRouter.POST("/ollama/model/load", baseApi.LoadOllamaModelDetail)
|
||||
aiToolsRouter.POST("/ollama/model/del", baseApi.DeleteOllamaModel)
|
||||
aiToolsRouter.POST("/domain/bind", baseApi.BindDomain)
|
||||
|
||||
@@ -14,6 +14,10 @@ func (s *DashboardRouter) InitRouter(Router *gin.RouterGroup) {
|
||||
cmdRouter.GET("/base/os", baseApi.LoadDashboardOsInfo)
|
||||
cmdRouter.GET("/quick/option", baseApi.LoadQuickOption)
|
||||
cmdRouter.POST("/quick/change", baseApi.UpdateQuickJump)
|
||||
cmdRouter.GET("/quick/directory", baseApi.ListQuickDirectories)
|
||||
cmdRouter.POST("/quick/directory", baseApi.CreateQuickDirectory)
|
||||
cmdRouter.POST("/quick/directory/update", baseApi.UpdateQuickDirectory)
|
||||
cmdRouter.POST("/quick/directory/del", baseApi.DeleteQuickDirectory)
|
||||
cmdRouter.GET("/app/launcher", baseApi.LoadAppLauncher)
|
||||
cmdRouter.POST("/app/launcher/show", baseApi.UpdateAppLauncher)
|
||||
cmdRouter.POST("/app/launcher/option", baseApi.LoadAppLauncherOption)
|
||||
|
||||
@@ -13,6 +13,8 @@ func (a *WebsiteCARouter) InitRouter(Router *gin.RouterGroup) {
|
||||
|
||||
baseApi := v2.ApiGroupApp.BaseApi
|
||||
{
|
||||
groupRouter.POST("/import", baseApi.ImportWebsiteCA)
|
||||
groupRouter.POST("/upload", baseApi.UploadWebsiteCA)
|
||||
groupRouter.POST("/search", baseApi.PageWebsiteCA)
|
||||
groupRouter.POST("", baseApi.CreateWebsiteCA)
|
||||
groupRouter.POST("/del", baseApi.DeleteWebsiteCA)
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
package mcpapi
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/1Panel-dev/1Panel/agent/buserr"
|
||||
)
|
||||
|
||||
const (
|
||||
BusinessCodeHeader = "X-Panel-MCP-Business-Code"
|
||||
CodeMySQLStopped = "MCP_MYSQL_STOPPED"
|
||||
CodeMySQLConnectionFailed = "MCP_MYSQL_CONNECTION_FAILED"
|
||||
)
|
||||
|
||||
type codedError struct {
|
||||
code string
|
||||
message string
|
||||
}
|
||||
|
||||
func (e *codedError) Error() string { return e.message }
|
||||
|
||||
func allowedBusinessCode(code string) string {
|
||||
switch code {
|
||||
case "ErrAliasIsExist", "ErrDomainIsUsed", "ErrPortInUsed", "ErrPortInOtherApp", "ErrPortExist", "ErrRecordExist", "ErrDatabaseIsExist", CodeMySQLStopped, CodeMySQLConnectionFailed:
|
||||
return code
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func BusinessErrorCode(err error) string {
|
||||
var coded *codedError
|
||||
if errors.As(err, &coded) && coded != nil {
|
||||
return allowedBusinessCode(coded.code)
|
||||
}
|
||||
var business buserr.BusinessError
|
||||
if errors.As(err, &business) {
|
||||
return allowedBusinessCode(business.Msg)
|
||||
}
|
||||
var businessPointer *buserr.BusinessError
|
||||
if errors.As(err, &businessPointer) && businessPointer != nil {
|
||||
return allowedBusinessCode(businessPointer.Msg)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func NewBusinessError(code, message string) error {
|
||||
return &codedError{code: allowedBusinessCode(code), message: message}
|
||||
}
|
||||
|
||||
func SanitizeBusinessError(err error, safeMessage string) error {
|
||||
return NewBusinessError(BusinessErrorCode(err), safeMessage)
|
||||
}
|
||||
|
||||
func SetBusinessErrorHeader(request *http.Request, header http.Header, err error) {
|
||||
if request == nil || header == nil {
|
||||
return
|
||||
}
|
||||
if present, trusted := Check(request); !present || !trusted {
|
||||
return
|
||||
}
|
||||
header.Del(BusinessCodeHeader)
|
||||
if code := BusinessErrorCode(err); code != "" {
|
||||
header.Set(BusinessCodeHeader, code)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package mcpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/1Panel-dev/1Panel/agent/global"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
const (
|
||||
InvocationHeader = "X-Panel-MCP-Invocation"
|
||||
NodeHeader = "X-Panel-MCP-Node"
|
||||
MethodHeader = "X-Panel-MCP-Method"
|
||||
PathHeader = "X-Panel-MCP-Path"
|
||||
CapabilitiesPath = "/api/v2/internal/mcp/capabilities"
|
||||
ContractVersion = 4
|
||||
)
|
||||
|
||||
type verifiedNodeTransportKey struct{}
|
||||
|
||||
func BindVerifiedNodeTransport(request *http.Request) *http.Request {
|
||||
if request.TLS == nil || !request.TLS.HandshakeComplete || len(request.TLS.VerifiedChains) == 0 {
|
||||
return request
|
||||
}
|
||||
return request.WithContext(context.WithValue(request.Context(), verifiedNodeTransportKey{}, request.TLS))
|
||||
}
|
||||
|
||||
func validNodeID(id string) bool {
|
||||
if id == "local" {
|
||||
return true
|
||||
}
|
||||
value, err := strconv.ParseUint(id, 10, 64)
|
||||
return err == nil && value != 0 && strconv.FormatUint(value, 10) == id
|
||||
}
|
||||
|
||||
func TrustedTransport(request *http.Request) bool {
|
||||
if global.IsMaster {
|
||||
local, ok := request.Context().Value(http.LocalAddrContextKey).(net.Addr)
|
||||
return ok && local.Network() == "unix"
|
||||
}
|
||||
verified := request.Context().Value(verifiedNodeTransportKey{})
|
||||
return request.TLS != nil && verified == request.TLS
|
||||
}
|
||||
|
||||
func Check(request *http.Request) (present, trusted bool) {
|
||||
present = false
|
||||
for _, name := range []string{InvocationHeader, NodeHeader, MethodHeader, PathHeader} {
|
||||
if len(request.Header.Values(name)) != 0 {
|
||||
present = true
|
||||
}
|
||||
}
|
||||
if !present {
|
||||
return false, false
|
||||
}
|
||||
for _, name := range []string{InvocationHeader, NodeHeader, MethodHeader, PathHeader} {
|
||||
if len(request.Header.Values(name)) != 1 {
|
||||
return true, false
|
||||
}
|
||||
}
|
||||
if _, err := uuid.Parse(request.Header.Get(InvocationHeader)); err != nil {
|
||||
return true, false
|
||||
}
|
||||
nodeID := request.Header.Get(NodeHeader)
|
||||
if !validNodeID(nodeID) || request.Header.Get(MethodHeader) != request.Method || request.Header.Get(PathHeader) != request.URL.Path || request.URL.RawQuery != "" {
|
||||
return true, false
|
||||
}
|
||||
return true, TrustedTransport(request) && ((nodeID == "local") == global.IsMaster)
|
||||
}
|
||||
|
||||
func NodeID(request *http.Request) (string, bool) {
|
||||
_, trusted := Check(request)
|
||||
if !trusted {
|
||||
return "", false
|
||||
}
|
||||
return request.Header.Get(NodeHeader), true
|
||||
}
|
||||
@@ -0,0 +1,530 @@
|
||||
package mcpruntime
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"regexp"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/1Panel-dev/1Panel/agent/utils/docker"
|
||||
"github.com/docker/docker/api/types/container"
|
||||
"github.com/docker/docker/api/types/image"
|
||||
"github.com/docker/docker/api/types/network"
|
||||
"github.com/docker/docker/api/types/system"
|
||||
"github.com/docker/docker/client"
|
||||
"github.com/docker/docker/errdefs"
|
||||
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
|
||||
)
|
||||
|
||||
const (
|
||||
ImageRepository = "1panel/1panel-mcp-server"
|
||||
ContainerName = "1panel-mcp"
|
||||
previousName = "1panel-mcp-previous"
|
||||
SocketDirectory = "/etc/1panel/mcp-runtime"
|
||||
CoreSocket = SocketDirectory + "/core.sock"
|
||||
adapterBinary = "/usr/local/bin/mcp-1panel"
|
||||
ownerLabel = "io.1panel.mcp.managed"
|
||||
imageLabel = "io.1panel.mcp.image"
|
||||
portLabel = "io.1panel.mcp.port"
|
||||
versionLabel = "io.1panel.mcp.version"
|
||||
maxCatalogBytes = 2 << 20
|
||||
maxLogBytes = 64 << 10
|
||||
contractVersion = 5
|
||||
protocol = "streamable-http"
|
||||
)
|
||||
|
||||
var semanticVersion = regexp.MustCompile(`^v?(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(?:-[0-9A-Za-z]+(?:[.-][0-9A-Za-z]+)*)?$`)
|
||||
var digestPattern = regexp.MustCompile(`^sha256:[a-f0-9]{64}$`)
|
||||
var ErrUnsupported = errors.New("MCP Docker runtime requires a local rootful Linux Docker daemon without user namespace remapping")
|
||||
|
||||
type Request struct {
|
||||
Operation string `json:"operation"`
|
||||
Image string `json:"image"`
|
||||
Port int `json:"port"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Restart bool `json:"restart"`
|
||||
ImageID string `json:"imageID,omitempty"`
|
||||
}
|
||||
|
||||
type State struct {
|
||||
Installed bool `json:"installed"`
|
||||
Status string `json:"status"`
|
||||
Ready bool `json:"ready"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
AdapterVersion string `json:"adapterVersion,omitempty"`
|
||||
Image string `json:"image,omitempty"`
|
||||
ImageID string `json:"imageID,omitempty"`
|
||||
}
|
||||
|
||||
type Response struct {
|
||||
State *State `json:"state,omitempty"`
|
||||
Catalog json.RawMessage `json:"catalog,omitempty"`
|
||||
Logs string `json:"logs,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
ImageID string `json:"imageID,omitempty"`
|
||||
}
|
||||
|
||||
type DockerClient interface {
|
||||
Close() error
|
||||
DaemonHost() string
|
||||
Info(context.Context) (system.Info, error)
|
||||
ImageInspect(context.Context, string, ...client.ImageInspectOption) (image.InspectResponse, error)
|
||||
ImagePull(context.Context, string, image.PullOptions) (io.ReadCloser, error)
|
||||
ContainerInspect(context.Context, string) (container.InspectResponse, error)
|
||||
ContainerCreate(context.Context, *container.Config, *container.HostConfig, *network.NetworkingConfig, *ocispec.Platform, string) (container.CreateResponse, error)
|
||||
ContainerStart(context.Context, string, container.StartOptions) error
|
||||
ContainerStop(context.Context, string, container.StopOptions) error
|
||||
ContainerRemove(context.Context, string, container.RemoveOptions) error
|
||||
ContainerRename(context.Context, string, string) error
|
||||
ContainerLogs(context.Context, string, container.LogsOptions) (io.ReadCloser, error)
|
||||
}
|
||||
|
||||
type metadata struct {
|
||||
Status string `json:"status"`
|
||||
AdapterVersion string `json:"adapterVersion"`
|
||||
ContractVersion int `json:"contractVersion"`
|
||||
Protocol string `json:"protocol"`
|
||||
}
|
||||
|
||||
type verifiedImage struct {
|
||||
metadata metadata
|
||||
catalog []byte
|
||||
}
|
||||
|
||||
type Controller struct {
|
||||
newClient func() (DockerClient, error)
|
||||
platform string
|
||||
health func(context.Context, int) (metadata, error)
|
||||
checkDirectory func() error
|
||||
mutation chan struct{}
|
||||
probes chan struct{}
|
||||
cacheMu sync.Mutex
|
||||
cache map[string]verifiedImage
|
||||
}
|
||||
|
||||
func New() *Controller {
|
||||
return newController(func() (DockerClient, error) { return docker.NewDockerClient() })
|
||||
}
|
||||
|
||||
func newController(factory func() (DockerClient, error)) *Controller {
|
||||
return &Controller{newClient: factory, platform: runtime.GOOS, health: readHealth,
|
||||
checkDirectory: checkSocketDirectory, mutation: make(chan struct{}, 1), probes: make(chan struct{}, 8), cache: make(map[string]verifiedImage)}
|
||||
}
|
||||
|
||||
func ValidateImage(value string) error {
|
||||
if len(value) > 180 {
|
||||
return errors.New("invalid MCP Docker image")
|
||||
}
|
||||
if strings.HasPrefix(value, ImageRepository+"@") && digestPattern.MatchString(strings.TrimPrefix(value, ImageRepository+"@")) {
|
||||
return nil
|
||||
}
|
||||
if strings.HasPrefix(value, ImageRepository+":") && semanticVersion.MatchString(strings.TrimPrefix(value, ImageRepository+":")) {
|
||||
return nil
|
||||
}
|
||||
return errors.New("MCP Docker image must use the official repository with a fixed semantic version or SHA256 digest")
|
||||
}
|
||||
|
||||
func (c *Controller) Handle(ctx context.Context, req Request) (Response, error) {
|
||||
switch req.Operation {
|
||||
case "prepare", "catalog", "candidate_catalog", "status", "apply", "install", "rollback", "rollback_absent", "pull", "logs":
|
||||
default:
|
||||
return Response{}, errors.New("invalid MCP Docker operation")
|
||||
}
|
||||
if err := ValidateImage(req.Image); err != nil {
|
||||
return Response{}, err
|
||||
}
|
||||
if req.Port < 1024 || req.Port > 65535 {
|
||||
return Response{}, errors.New("MCP listen port must be between 1024 and 65535")
|
||||
}
|
||||
timeout := 60 * time.Second
|
||||
if req.Operation == "catalog" || req.Operation == "candidate_catalog" || req.Operation == "logs" || req.Operation == "prepare" {
|
||||
timeout = 5 * time.Second
|
||||
}
|
||||
if req.Operation == "status" {
|
||||
timeout = 8 * time.Second
|
||||
}
|
||||
if req.Operation == "pull" {
|
||||
timeout = 4 * time.Minute
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(ctx, timeout)
|
||||
defer cancel()
|
||||
if req.Operation == "apply" || req.Operation == "install" || req.Operation == "rollback" || req.Operation == "rollback_absent" || req.Operation == "pull" {
|
||||
select {
|
||||
case c.mutation <- struct{}{}:
|
||||
defer func() { <-c.mutation }()
|
||||
case <-ctx.Done():
|
||||
return Response{}, ctx.Err()
|
||||
}
|
||||
}
|
||||
cli, err := c.newClient()
|
||||
if err != nil {
|
||||
return unavailable(req, "Docker daemon is unavailable")
|
||||
}
|
||||
defer cli.Close()
|
||||
if err = c.supported(ctx, cli); err != nil {
|
||||
return unavailable(req, err.Error())
|
||||
}
|
||||
switch req.Operation {
|
||||
case "prepare":
|
||||
case "catalog", "candidate_catalog":
|
||||
var img image.InspectResponse
|
||||
img, err = c.resolve(ctx, cli, req.Image)
|
||||
if err == nil && req.Operation == "catalog" {
|
||||
var current *container.InspectResponse
|
||||
current, err = c.owned(ctx, cli, ContainerName)
|
||||
if err == nil && current != nil && isRunning(current) && current.Config.Labels[imageLabel] == req.Image && current.Image != img.ID {
|
||||
err = errors.New("MCP image reference changed; restart the adapter before reading its catalog")
|
||||
}
|
||||
}
|
||||
if err == nil {
|
||||
var verified verifiedImage
|
||||
verified, err = c.verify(ctx, cli, img.ID, req.Image)
|
||||
if err == nil {
|
||||
return Response{Catalog: append(json.RawMessage(nil), verified.catalog...), ImageID: img.ID}, nil
|
||||
}
|
||||
}
|
||||
case "status":
|
||||
return c.status(ctx, cli, req), nil
|
||||
case "apply":
|
||||
err = c.apply(ctx, cli, req)
|
||||
case "install":
|
||||
err = c.installRuntime(ctx, cli, req)
|
||||
case "rollback":
|
||||
err = c.rollback(ctx, cli, req)
|
||||
case "rollback_absent":
|
||||
err = c.rollbackAbsent(ctx, cli, req)
|
||||
case "pull":
|
||||
err = c.pullImage(ctx, cli, req)
|
||||
case "logs":
|
||||
var current *container.InspectResponse
|
||||
current, err = c.owned(ctx, cli, ContainerName)
|
||||
if err == nil && current == nil {
|
||||
err = errors.New("MCP container is not installed")
|
||||
}
|
||||
if err == nil {
|
||||
var data []byte
|
||||
data, err = readLogs(ctx, cli, current.ID, maxLogBytes, "100")
|
||||
if err == nil {
|
||||
return Response{Logs: redactLog(string(data))}, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return Response{}, err
|
||||
}
|
||||
|
||||
func unavailable(req Request, reason string) (Response, error) {
|
||||
if req.Operation == "status" {
|
||||
return Response{State: &State{Status: "unsupported", Image: req.Image, Reason: reason}}, nil
|
||||
}
|
||||
return Response{}, errors.New(reason)
|
||||
}
|
||||
|
||||
func (c *Controller) supported(ctx context.Context, cli DockerClient) error {
|
||||
if c.platform != "linux" || !strings.HasPrefix(cli.DaemonHost(), "unix:///") {
|
||||
return ErrUnsupported
|
||||
}
|
||||
info, err := cli.Info(ctx)
|
||||
if err != nil {
|
||||
return errors.New("Docker daemon is unavailable")
|
||||
}
|
||||
if info.OSType != "linux" {
|
||||
return ErrUnsupported
|
||||
}
|
||||
for _, option := range info.SecurityOptions {
|
||||
if strings.Contains(option, "rootless") || strings.Contains(option, "userns") {
|
||||
return ErrUnsupported
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Controller) resolve(ctx context.Context, cli DockerClient, ref string) (image.InspectResponse, error) {
|
||||
img, err := cli.ImageInspect(ctx, ref)
|
||||
if err != nil {
|
||||
return image.InspectResponse{}, errors.New("MCP Docker image is unavailable locally; load it or install the selected version")
|
||||
}
|
||||
if !digestPattern.MatchString(img.ID) || img.Os != "linux" || img.Config == nil || len(img.Config.Volumes) > 0 {
|
||||
return image.InspectResponse{}, errors.New("invalid MCP Docker image identity or platform")
|
||||
}
|
||||
return img, nil
|
||||
}
|
||||
|
||||
func (c *Controller) owned(ctx context.Context, cli DockerClient, name string) (*container.InspectResponse, error) {
|
||||
info, err := cli.ContainerInspect(ctx, name)
|
||||
if errdefs.IsNotFound(err) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, errors.New("MCP container state is unavailable")
|
||||
}
|
||||
if info.ContainerJSONBase == nil || info.Config == nil || info.Config.Labels[ownerLabel] != "true" {
|
||||
return nil, errors.New("MCP container name is occupied by an unmanaged container")
|
||||
}
|
||||
return &info, nil
|
||||
}
|
||||
|
||||
func isRunning(info *container.InspectResponse) bool {
|
||||
return info != nil && info.State != nil && info.State.Running
|
||||
}
|
||||
|
||||
func (c *Controller) status(ctx context.Context, cli DockerClient, req Request) Response {
|
||||
state := &State{Status: "not_installed", Image: req.Image}
|
||||
img, err := c.resolve(ctx, cli, req.Image)
|
||||
if err == nil {
|
||||
state.ImageID = img.ID
|
||||
}
|
||||
current, inspectErr := c.owned(ctx, cli, ContainerName)
|
||||
if inspectErr != nil {
|
||||
state.Status = "unhealthy"
|
||||
state.Reason = inspectErr.Error()
|
||||
return Response{State: state}
|
||||
}
|
||||
if current == nil {
|
||||
if err != nil {
|
||||
state.Reason = err.Error()
|
||||
} else {
|
||||
state.Reason = "MCP image is loaded; install it to create the managed container"
|
||||
}
|
||||
return Response{State: state}
|
||||
}
|
||||
state.Installed = true
|
||||
state.ImageID = current.Image
|
||||
state.AdapterVersion = current.Config.Labels[versionLabel]
|
||||
state.Status = "stopped"
|
||||
if !isRunning(current) {
|
||||
if req.Enabled {
|
||||
state.Reason = "MCP container is not running"
|
||||
}
|
||||
return Response{State: state}
|
||||
}
|
||||
state.Status = "running"
|
||||
if !req.Enabled {
|
||||
state.Reason = "MCP access is disabled"
|
||||
return Response{State: state}
|
||||
}
|
||||
if err != nil || current.Image != img.ID {
|
||||
state.Status = "unhealthy"
|
||||
state.Reason = "MCP container does not match the configured image or listen port"
|
||||
return Response{State: state}
|
||||
}
|
||||
verified, verifyErr := c.verify(ctx, cli, current.Image, req.Image)
|
||||
if verifyErr != nil || !matchesRuntimeWithCapabilities(ctx, cli, current, img.ID, req, verified.metadata.AdapterVersion) {
|
||||
state.Status = "unhealthy"
|
||||
state.Reason = "MCP container configuration or metadata does not match the verified image"
|
||||
return Response{State: state}
|
||||
}
|
||||
state.AdapterVersion = verified.metadata.AdapterVersion
|
||||
health, healthErr := c.health(ctx, req.Port)
|
||||
if healthErr != nil || health.Status != "ok" || !validMetadata(health) || health.AdapterVersion != state.AdapterVersion {
|
||||
state.Status = "unhealthy"
|
||||
state.Reason = "MCP adapter health or contract is invalid"
|
||||
return Response{State: state}
|
||||
}
|
||||
state.Ready = true
|
||||
return Response{State: state}
|
||||
}
|
||||
|
||||
func (c *Controller) verify(ctx context.Context, cli DockerClient, id, ref string) (verifiedImage, error) {
|
||||
c.cacheMu.Lock()
|
||||
cached, ok := c.cache[id]
|
||||
c.cacheMu.Unlock()
|
||||
if !ok {
|
||||
select {
|
||||
case c.probes <- struct{}{}:
|
||||
defer func() { <-c.probes }()
|
||||
default:
|
||||
return verifiedImage{}, errors.New("MCP image catalog is busy; retry shortly")
|
||||
}
|
||||
probeCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
defer cancel()
|
||||
version, err := c.probe(probeCtx, cli, id, "--version", 4096)
|
||||
if err != nil || json.Unmarshal(version, &cached.metadata) != nil || !validMetadata(cached.metadata) {
|
||||
return verifiedImage{}, errors.New("MCP image version or contract is incompatible with this panel")
|
||||
}
|
||||
cached.catalog, err = c.probe(probeCtx, cli, id, "--catalog", maxCatalogBytes)
|
||||
var manifest struct {
|
||||
ContractVersion int `json:"contractVersion"`
|
||||
Tools []json.RawMessage `json:"tools"`
|
||||
}
|
||||
if err != nil || json.Unmarshal(cached.catalog, &manifest) != nil || manifest.ContractVersion != contractVersion || len(manifest.Tools) > 256 {
|
||||
return verifiedImage{}, errors.New("MCP image catalog is invalid or incompatible")
|
||||
}
|
||||
c.cacheMu.Lock()
|
||||
if len(c.cache) >= 32 {
|
||||
c.cache = make(map[string]verifiedImage)
|
||||
}
|
||||
c.cache[id] = cached
|
||||
c.cacheMu.Unlock()
|
||||
}
|
||||
if strings.HasPrefix(ref, ImageRepository+":") && strings.TrimPrefix(cached.metadata.AdapterVersion, "v") != strings.TrimPrefix(strings.TrimPrefix(ref, ImageRepository+":"), "v") {
|
||||
return verifiedImage{}, errors.New("MCP image version does not match its selected tag")
|
||||
}
|
||||
return cached, nil
|
||||
}
|
||||
|
||||
func validMetadata(value metadata) bool {
|
||||
return semanticVersion.MatchString(value.AdapterVersion) && value.ContractVersion == contractVersion && value.Protocol == protocol
|
||||
}
|
||||
|
||||
func safeConfig(id string, args []string) (*container.Config, *container.HostConfig) {
|
||||
pids := int64(128)
|
||||
config := &container.Config{Image: id, Entrypoint: []string{adapterBinary}, Cmd: args, User: "0:0", Env: []string{}, Labels: map[string]string{ownerLabel: "true"}, NetworkDisabled: true, Healthcheck: &container.HealthConfig{Test: []string{"NONE"}}}
|
||||
host := &container.HostConfig{NetworkMode: "none", ReadonlyRootfs: true, CapDrop: []string{"ALL"}, SecurityOpt: []string{"no-new-privileges:true"}, IpcMode: "private",
|
||||
Resources: container.Resources{Memory: 256 << 20, MemorySwap: 256 << 20, NanoCPUs: 1_000_000_000, PidsLimit: &pids},
|
||||
LogConfig: container.LogConfig{Type: "json-file", Config: map[string]string{"max-size": "3m", "max-file": "1"}}}
|
||||
return config, host
|
||||
}
|
||||
|
||||
func (c *Controller) probe(ctx context.Context, cli DockerClient, id, flag string, limit int) (data []byte, err error) {
|
||||
config, host := safeConfig(id, []string{flag})
|
||||
created, err := cli.ContainerCreate(ctx, config, host, nil, nil, "")
|
||||
if err != nil {
|
||||
return nil, errors.New("MCP image probe could not be created")
|
||||
}
|
||||
defer func() {
|
||||
cleanupCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
cleanupErr := cli.ContainerRemove(cleanupCtx, created.ID, container.RemoveOptions{Force: true})
|
||||
if cleanupErr != nil && !errdefs.IsNotFound(cleanupErr) {
|
||||
err = errors.Join(err, errors.New("MCP image probe cleanup failed"))
|
||||
}
|
||||
}()
|
||||
if err = cli.ContainerStart(ctx, created.ID, container.StartOptions{}); err != nil {
|
||||
return nil, errors.New("MCP image probe failed to start")
|
||||
}
|
||||
for {
|
||||
info, inspectErr := cli.ContainerInspect(ctx, created.ID)
|
||||
if inspectErr != nil || info.State == nil {
|
||||
return nil, errors.New("MCP image probe state is unavailable")
|
||||
}
|
||||
if info.State.Status == "exited" {
|
||||
if info.State.ExitCode != 0 {
|
||||
return nil, errors.New("MCP image probe exited unsuccessfully")
|
||||
}
|
||||
break
|
||||
}
|
||||
if info.State.Dead {
|
||||
return nil, errors.New("MCP image probe exited unsuccessfully")
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, errors.New("MCP image probe timed out")
|
||||
case <-time.After(25 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
return readLogs(ctx, cli, created.ID, limit, "all")
|
||||
}
|
||||
|
||||
type boundedWriter struct {
|
||||
bytes.Buffer
|
||||
limit int
|
||||
}
|
||||
|
||||
func (b *boundedWriter) Write(value []byte) (int, error) {
|
||||
if b.Len()+len(value) > b.limit {
|
||||
return 0, errors.New("MCP output exceeds permitted size")
|
||||
}
|
||||
return b.Buffer.Write(value)
|
||||
}
|
||||
|
||||
func readLogs(ctx context.Context, cli DockerClient, id string, limit int, tail string) ([]byte, error) {
|
||||
reader, err := cli.ContainerLogs(ctx, id, container.LogsOptions{ShowStdout: true, ShowStderr: true, Tail: tail})
|
||||
if err != nil {
|
||||
return nil, errors.New("MCP container logs are unavailable")
|
||||
}
|
||||
defer reader.Close()
|
||||
stdout, stderr := &boundedWriter{limit: limit}, &boundedWriter{limit: limit}
|
||||
if err = readDockerStreams(reader, stdout, stderr, 2*limit+64<<10); err != nil {
|
||||
return nil, errors.New("MCP output exceeds permitted size or is invalid")
|
||||
}
|
||||
if tail == "all" {
|
||||
return stdout.Bytes(), nil
|
||||
}
|
||||
combined := append(append([]byte(nil), stdout.Bytes()...), stderr.Bytes()...)
|
||||
if len(combined) > limit {
|
||||
combined = combined[:limit]
|
||||
}
|
||||
return combined, nil
|
||||
}
|
||||
|
||||
func readDockerStreams(reader io.Reader, stdout, stderr *boundedWriter, maxRaw int) error {
|
||||
total := 0
|
||||
for {
|
||||
var header [8]byte
|
||||
_, err := io.ReadFull(reader, header[:])
|
||||
if err == io.EOF {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
size := int64(binary.BigEndian.Uint32(header[4:]))
|
||||
total += len(header)
|
||||
if size > int64(maxRaw-total) || header[1] != 0 || header[2] != 0 || header[3] != 0 {
|
||||
return errors.New("invalid Docker log frame")
|
||||
}
|
||||
var output *boundedWriter
|
||||
switch header[0] {
|
||||
case 1:
|
||||
output = stdout
|
||||
case 2:
|
||||
output = stderr
|
||||
default:
|
||||
return errors.New("invalid Docker log stream")
|
||||
}
|
||||
if size > int64(output.limit-output.Len()) {
|
||||
return errors.New("MCP output exceeds permitted size")
|
||||
}
|
||||
if _, err = io.CopyN(output, reader, size); err != nil {
|
||||
return err
|
||||
}
|
||||
total += int(size)
|
||||
}
|
||||
}
|
||||
|
||||
func checkSocketDirectory() error {
|
||||
info, err := os.Lstat(SocketDirectory)
|
||||
if err != nil || !info.IsDir() || info.Mode()&os.ModeSymlink != 0 || info.Mode().Perm()&0022 != 0 {
|
||||
return errors.New("MCP runtime socket directory is missing or unsafe")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func readHealth(ctx context.Context, port int) (metadata, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
|
||||
defer cancel()
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, fmt.Sprintf("http://127.0.0.1:%d/healthz", port), nil)
|
||||
if err != nil {
|
||||
return metadata{}, err
|
||||
}
|
||||
client := &http.Client{Transport: &http.Transport{Proxy: nil}, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
|
||||
defer client.CloseIdleConnections()
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return metadata{}, errors.New("MCP health endpoint is unavailable")
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
var result metadata
|
||||
data, readErr := io.ReadAll(io.LimitReader(resp.Body, 4097))
|
||||
if resp.StatusCode != 200 || readErr != nil || len(data) > 4096 || json.Unmarshal(data, &result) != nil || result.Status != "ok" || !validMetadata(result) {
|
||||
return metadata{}, errors.New("MCP health response is incompatible")
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
var bearerPattern = regexp.MustCompile(`(?i)bearer\s+[^\s"']+`)
|
||||
var sensitivePattern = regexp.MustCompile(`(?i)("?(?:password|token|secret|authorization)"?\s*[:=]\s*)("[^"]*"|[^\s,}]+)`)
|
||||
|
||||
func redactLog(value string) string {
|
||||
return sensitivePattern.ReplaceAllString(bearerPattern.ReplaceAllString(value, "Bearer [REDACTED]"), "${1}[REDACTED]")
|
||||
}
|
||||
@@ -0,0 +1,441 @@
|
||||
package mcpruntime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/docker/docker/api/types/container"
|
||||
"github.com/docker/docker/api/types/image"
|
||||
"github.com/docker/docker/api/types/mount"
|
||||
"github.com/docker/docker/errdefs"
|
||||
)
|
||||
|
||||
func (c *Controller) pullImage(ctx context.Context, cli DockerClient, req Request) error {
|
||||
_, err := cli.ImageInspect(ctx, req.Image)
|
||||
if err != nil && !errdefs.IsNotFound(err) {
|
||||
return errors.New("MCP Docker image inspection failed")
|
||||
}
|
||||
if errdefs.IsNotFound(err) {
|
||||
pullCtx, cancel := context.WithTimeout(ctx, 3*time.Minute)
|
||||
defer cancel()
|
||||
reader, pullErr := cli.ImagePull(pullCtx, req.Image, image.PullOptions{})
|
||||
if pullErr != nil {
|
||||
return errors.New("MCP Docker image download failed")
|
||||
}
|
||||
defer reader.Close()
|
||||
decoder := json.NewDecoder(io.LimitReader(reader, 16<<20))
|
||||
for {
|
||||
var message struct {
|
||||
Error string `json:"error"`
|
||||
ErrorDetail *struct {
|
||||
Message string `json:"message"`
|
||||
} `json:"errorDetail"`
|
||||
}
|
||||
if err = decoder.Decode(&message); err == io.EOF {
|
||||
break
|
||||
} else if err != nil {
|
||||
return errors.New("MCP Docker image download was interrupted")
|
||||
}
|
||||
if message.Error != "" || message.ErrorDetail != nil {
|
||||
return errors.New("MCP Docker image download failed")
|
||||
}
|
||||
}
|
||||
}
|
||||
img, err := c.resolve(ctx, cli, req.Image)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = c.verify(ctx, cli, img.ID, req.Image)
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *Controller) installRuntime(ctx context.Context, cli DockerClient, req Request) error {
|
||||
before, err := c.owned(ctx, cli, ContainerName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
wasRunning := isRunning(before)
|
||||
activate := req
|
||||
activate.Enabled = true
|
||||
activate.Restart = true
|
||||
if err = c.apply(ctx, cli, activate); err != nil {
|
||||
return err
|
||||
}
|
||||
if req.Enabled {
|
||||
return nil
|
||||
}
|
||||
current, err := c.owned(ctx, cli, ContainerName)
|
||||
if err == nil && current == nil {
|
||||
err = errors.New("installed MCP container is unavailable")
|
||||
}
|
||||
if err == nil && current != nil {
|
||||
err = stopContainer(ctx, cli, current.ID)
|
||||
}
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
rollbackCtx, cancel := context.WithTimeout(context.Background(), 40*time.Second)
|
||||
defer cancel()
|
||||
var restoreErr error
|
||||
if before != nil {
|
||||
port, parseErr := strconv.Atoi(before.Config.Labels[portLabel])
|
||||
old := Request{Operation: "rollback", Image: before.Config.Labels[imageLabel], ImageID: before.Image, Port: port, Enabled: wasRunning}
|
||||
if parseErr != nil || ValidateImage(old.Image) != nil {
|
||||
restoreErr = errors.New("old MCP runtime configuration is invalid")
|
||||
}
|
||||
if restoreErr == nil {
|
||||
restoreErr = c.rollback(rollbackCtx, cli, old)
|
||||
}
|
||||
} else if current != nil {
|
||||
restoreErr = cli.ContainerRemove(rollbackCtx, current.ID, container.RemoveOptions{Force: true})
|
||||
}
|
||||
return errors.Join(errors.New("installed MCP container could not be left stopped"), restoreErr)
|
||||
}
|
||||
|
||||
func runtimeConfig(id string, req Request, version string) (*container.Config, *container.HostConfig) {
|
||||
config, host := safeConfig(id, []string{"--mode=panel", "--listen=127.0.0.1:" + strconv.Itoa(req.Port), "--panel-socket=" + CoreSocket})
|
||||
config.NetworkDisabled = false
|
||||
config.Labels[imageLabel] = req.Image
|
||||
config.Labels[portLabel] = strconv.Itoa(req.Port)
|
||||
config.Labels[versionLabel] = version
|
||||
host.NetworkMode = "host"
|
||||
host.RestartPolicy = container.RestartPolicy{Name: container.RestartPolicyUnlessStopped}
|
||||
host.Mounts = []mount.Mount{{Type: mount.TypeBind, Source: SocketDirectory, Target: SocketDirectory, ReadOnly: true, BindOptions: &mount.BindOptions{Propagation: mount.PropagationRPrivate}}}
|
||||
host.LogConfig.Config = map[string]string{"max-size": "1m", "max-file": "3"}
|
||||
return config, host
|
||||
}
|
||||
|
||||
func matchesRuntime(info *container.InspectResponse, id string, req Request, version string) bool {
|
||||
if info == nil || info.ContainerJSONBase == nil || info.Config == nil || info.HostConfig == nil || info.Image != id {
|
||||
return false
|
||||
}
|
||||
wantConfig, wantHost := runtimeConfig(id, req, version)
|
||||
cfg, host := info.Config, info.HostConfig
|
||||
if cfg.Labels[versionLabel] != version || cfg.Labels[imageLabel] != req.Image || cfg.Labels[portLabel] != strconv.Itoa(req.Port) || cfg.User != wantConfig.User || cfg.Tty || !reflect.DeepEqual(cfg.Entrypoint, wantConfig.Entrypoint) || !reflect.DeepEqual(cfg.Cmd, wantConfig.Cmd) {
|
||||
return false
|
||||
}
|
||||
if host.NetworkMode != "host" || host.Privileged || !host.ReadonlyRootfs || !reflect.DeepEqual(host.CapDrop, wantHost.CapDrop) || len(host.CapAdd) != 0 || !reflect.DeepEqual(host.SecurityOpt, wantHost.SecurityOpt) || len(host.Binds) != 0 || len(host.VolumesFrom) != 0 || len(host.Devices) != 0 || len(host.DeviceRequests) != 0 || len(host.Mounts) != 1 {
|
||||
return false
|
||||
}
|
||||
m := host.Mounts[0]
|
||||
if m.Type != mount.TypeBind || m.Source != SocketDirectory || m.Target != SocketDirectory || !m.ReadOnly {
|
||||
return false
|
||||
}
|
||||
if host.PidMode != "" || host.UsernsMode != "" || host.UTSMode != "" || (host.IpcMode != "" && host.IpcMode != "private") || host.PublishAllPorts || len(host.PortBindings) != 0 || host.Resources.Memory != wantHost.Memory || host.Resources.MemorySwap != wantHost.MemorySwap || host.Resources.NanoCPUs != wantHost.NanoCPUs || host.Resources.PidsLimit == nil || *host.Resources.PidsLimit != *wantHost.PidsLimit {
|
||||
return false
|
||||
}
|
||||
return host.RestartPolicy.Name == container.RestartPolicyUnlessStopped
|
||||
}
|
||||
|
||||
func matchesRuntimeWithCapabilities(ctx context.Context, cli DockerClient, info *container.InspectResponse, id string, req Request, version string) bool {
|
||||
if matchesRuntime(info, id, req, version) {
|
||||
return true
|
||||
}
|
||||
if info == nil || info.ContainerJSONBase == nil || info.HostConfig == nil || info.HostConfig.MemorySwap != -1 {
|
||||
return false
|
||||
}
|
||||
adjusted := *info
|
||||
base := *info.ContainerJSONBase
|
||||
host := *info.HostConfig
|
||||
_, wantHost := runtimeConfig(id, req, version)
|
||||
host.MemorySwap = wantHost.MemorySwap
|
||||
base.HostConfig = &host
|
||||
adjusted.ContainerJSONBase = &base
|
||||
if !matchesRuntime(&adjusted, id, req, version) {
|
||||
return false
|
||||
}
|
||||
daemon, err := cli.Info(ctx)
|
||||
return err == nil && !daemon.SwapLimit
|
||||
}
|
||||
|
||||
func stopContainer(ctx context.Context, cli DockerClient, id string) error {
|
||||
seconds := 10
|
||||
if err := cli.ContainerStop(ctx, id, container.StopOptions{Timeout: &seconds}); err != nil && !errdefs.IsNotModified(err) {
|
||||
return errors.New("MCP container could not be stopped")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Controller) apply(ctx context.Context, cli DockerClient, req Request) error {
|
||||
current, err := c.owned(ctx, cli, ContainerName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !req.Enabled {
|
||||
if current == nil || !isRunning(current) {
|
||||
return nil
|
||||
}
|
||||
return stopContainer(ctx, cli, current.ID)
|
||||
}
|
||||
if err = c.checkDirectory(); err != nil {
|
||||
return err
|
||||
}
|
||||
img, err := c.resolve(ctx, cli, req.Image)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !digestPattern.MatchString(req.ImageID) || img.ID != req.ImageID {
|
||||
return errors.New("MCP image identity changed after catalog validation; retry the operation")
|
||||
}
|
||||
verified, err := c.verify(ctx, cli, img.ID, req.Image)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if matchesRuntimeWithCapabilities(ctx, cli, current, img.ID, req, verified.metadata.AdapterVersion) {
|
||||
wasRunning := isRunning(current)
|
||||
if wasRunning && !req.Restart {
|
||||
return c.waitHealthy(ctx, cli, current.ID, req, verified.metadata.AdapterVersion)
|
||||
}
|
||||
if wasRunning {
|
||||
if err = stopContainer(ctx, cli, current.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err = cli.ContainerStart(ctx, current.ID, container.StartOptions{}); err == nil {
|
||||
err = c.waitHealthy(ctx, cli, current.ID, req, verified.metadata.AdapterVersion)
|
||||
}
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
rollbackCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
stopErr := stopContainer(rollbackCtx, cli, current.ID)
|
||||
if wasRunning {
|
||||
stopErr = errors.Join(stopErr, cli.ContainerStart(rollbackCtx, current.ID, container.StartOptions{}))
|
||||
}
|
||||
return errors.Join(errors.New("MCP container failed to start or become healthy"), stopErr)
|
||||
}
|
||||
previous, err := c.owned(ctx, cli, previousName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if isRunning(previous) {
|
||||
return errors.New("MCP previous container is unexpectedly running; resolve its state before upgrading")
|
||||
}
|
||||
if matchesRuntimeWithCapabilities(ctx, cli, previous, img.ID, req, verified.metadata.AdapterVersion) {
|
||||
return c.restorePrevious(ctx, cli, current, previous, req, verified.metadata.AdapterVersion)
|
||||
}
|
||||
if previous != nil {
|
||||
if err = cli.ContainerRemove(ctx, previous.ID, container.RemoveOptions{}); err != nil {
|
||||
return errors.New("MCP previous container could not be removed")
|
||||
}
|
||||
}
|
||||
wasRunning := isRunning(current)
|
||||
if current != nil {
|
||||
if wasRunning {
|
||||
if err = stopContainer(ctx, cli, current.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err = cli.ContainerRename(ctx, current.ID, previousName); err != nil {
|
||||
if wasRunning {
|
||||
rollbackCtx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
||||
defer cancel()
|
||||
err = errors.Join(err, cli.ContainerStart(rollbackCtx, current.ID, container.StartOptions{}))
|
||||
}
|
||||
return errors.Join(errors.New("MCP old container could not be retained for rollback"), err)
|
||||
}
|
||||
}
|
||||
config, host := runtimeConfig(img.ID, req, verified.metadata.AdapterVersion)
|
||||
created, err := cli.ContainerCreate(ctx, config, host, nil, nil, ContainerName)
|
||||
if err == nil {
|
||||
err = cli.ContainerStart(ctx, created.ID, container.StartOptions{})
|
||||
}
|
||||
if err == nil {
|
||||
err = c.waitHealthy(ctx, cli, created.ID, req, verified.metadata.AdapterVersion)
|
||||
}
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
rollbackCtx, cancel := context.WithTimeout(context.Background(), 40*time.Second)
|
||||
defer cancel()
|
||||
var rollbackErr error
|
||||
if created.ID != "" {
|
||||
rollbackErr = cli.ContainerRemove(rollbackCtx, created.ID, container.RemoveOptions{Force: true})
|
||||
}
|
||||
if current != nil && rollbackErr == nil {
|
||||
rollbackErr = cli.ContainerRename(rollbackCtx, current.ID, ContainerName)
|
||||
if rollbackErr == nil && wasRunning {
|
||||
rollbackErr = cli.ContainerStart(rollbackCtx, current.ID, container.StartOptions{})
|
||||
}
|
||||
}
|
||||
return errors.Join(errors.New("MCP replacement failed to start or become healthy; previous container restored where possible"), rollbackErr)
|
||||
}
|
||||
|
||||
func (c *Controller) restorePrevious(ctx context.Context, cli DockerClient, current, previous *container.InspectResponse, req Request, version string) error {
|
||||
wasRunning := isRunning(current)
|
||||
if wasRunning {
|
||||
if err := stopContainer(ctx, cli, current.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
temporary := ContainerName + "-rollback-" + strings.TrimPrefix(previous.ID, "sha256:")[:min(12, len(strings.TrimPrefix(previous.ID, "sha256:")))]
|
||||
if current != nil {
|
||||
if err := cli.ContainerRename(ctx, current.ID, temporary); err != nil {
|
||||
rollbackCtx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
||||
defer cancel()
|
||||
if wasRunning {
|
||||
err = errors.Join(err, cli.ContainerStart(rollbackCtx, current.ID, container.StartOptions{}))
|
||||
}
|
||||
return errors.Join(errors.New("MCP current container could not be retained for rollback"), err)
|
||||
}
|
||||
}
|
||||
if err := cli.ContainerRename(ctx, previous.ID, ContainerName); err != nil {
|
||||
rollbackCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
var restoreErr error
|
||||
if current != nil {
|
||||
restoreErr = cli.ContainerRename(rollbackCtx, current.ID, ContainerName)
|
||||
if restoreErr == nil && wasRunning {
|
||||
restoreErr = cli.ContainerStart(rollbackCtx, current.ID, container.StartOptions{})
|
||||
}
|
||||
}
|
||||
return errors.Join(errors.New("MCP previous container could not be restored"), restoreErr)
|
||||
}
|
||||
var err error
|
||||
if req.Enabled {
|
||||
err = cli.ContainerStart(ctx, previous.ID, container.StartOptions{})
|
||||
if err == nil {
|
||||
err = c.waitHealthy(ctx, cli, previous.ID, req, version)
|
||||
}
|
||||
}
|
||||
if err == nil {
|
||||
if current != nil {
|
||||
err = cli.ContainerRename(ctx, current.ID, previousName)
|
||||
}
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
rollbackCtx, cancel := context.WithTimeout(context.Background(), 40*time.Second)
|
||||
defer cancel()
|
||||
rollbackErr := stopContainer(rollbackCtx, cli, previous.ID)
|
||||
rollbackErr = errors.Join(rollbackErr, cli.ContainerRename(rollbackCtx, previous.ID, previousName))
|
||||
if current != nil && rollbackErr == nil {
|
||||
rollbackErr = cli.ContainerRename(rollbackCtx, current.ID, ContainerName)
|
||||
if rollbackErr == nil && wasRunning {
|
||||
rollbackErr = cli.ContainerStart(rollbackCtx, current.ID, container.StartOptions{})
|
||||
}
|
||||
}
|
||||
return errors.Join(errors.New("MCP previous container failed its health check"), rollbackErr)
|
||||
}
|
||||
|
||||
func (c *Controller) rollback(ctx context.Context, cli DockerClient, req Request) error {
|
||||
if (req.ImageID != "" && !digestPattern.MatchString(req.ImageID)) || (req.Enabled && req.ImageID == "") {
|
||||
return errors.New("invalid MCP rollback image identity")
|
||||
}
|
||||
current, err := c.owned(ctx, cli, ContainerName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
previous, err := c.owned(ctx, cli, previousName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var target *container.InspectResponse
|
||||
for _, candidate := range []*container.InspectResponse{current, previous} {
|
||||
if candidate != nil && candidate.Image == req.ImageID && candidate.Config.Labels[imageLabel] == req.Image && candidate.Config.Labels[portLabel] == strconv.Itoa(req.Port) {
|
||||
target = candidate
|
||||
break
|
||||
}
|
||||
}
|
||||
if target == nil {
|
||||
if !req.Enabled {
|
||||
if isRunning(current) {
|
||||
return stopContainer(ctx, cli, current.ID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return errors.New("the previous MCP runtime instance is unavailable for rollback")
|
||||
}
|
||||
verified, err := c.verify(ctx, cli, target.Image, req.Image)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !matchesRuntimeWithCapabilities(ctx, cli, target, req.ImageID, req, verified.metadata.AdapterVersion) {
|
||||
return errors.New("the previous MCP runtime configuration is unsafe or incompatible")
|
||||
}
|
||||
if req.Enabled {
|
||||
if err = c.checkDirectory(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if target == previous {
|
||||
if isRunning(previous) {
|
||||
return errors.New("MCP previous container is unexpectedly running")
|
||||
}
|
||||
return c.restorePrevious(ctx, cli, current, previous, req, verified.metadata.AdapterVersion)
|
||||
}
|
||||
if !req.Enabled {
|
||||
if isRunning(current) {
|
||||
return stopContainer(ctx, cli, current.ID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if !isRunning(current) {
|
||||
if err = cli.ContainerStart(ctx, current.ID, container.StartOptions{}); err != nil {
|
||||
return errors.New("MCP rollback instance failed to start")
|
||||
}
|
||||
}
|
||||
return c.waitHealthy(ctx, cli, current.ID, req, verified.metadata.AdapterVersion)
|
||||
}
|
||||
|
||||
// rollbackAbsent removes only the fixed owned instance created by a failed
|
||||
// first installation. It never resolves a tag, pulls, or creates a runtime.
|
||||
func (c *Controller) rollbackAbsent(ctx context.Context, cli DockerClient, req Request) error {
|
||||
if req.Enabled || !digestPattern.MatchString(req.ImageID) {
|
||||
return errors.New("invalid MCP absent-runtime rollback identity")
|
||||
}
|
||||
current, err := c.owned(ctx, cli, ContainerName)
|
||||
if err != nil || current == nil {
|
||||
return err
|
||||
}
|
||||
if current.Image != req.ImageID || current.Config.Labels[imageLabel] != req.Image || current.Config.Labels[portLabel] != strconv.Itoa(req.Port) {
|
||||
return errors.New("MCP runtime changed after installation; absent-runtime rollback was rejected")
|
||||
}
|
||||
if _, err = c.resolve(ctx, cli, current.Image); err != nil {
|
||||
return err
|
||||
}
|
||||
verified, err := c.verify(ctx, cli, current.Image, req.Image)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !matchesRuntimeWithCapabilities(ctx, cli, current, req.ImageID, req, verified.metadata.AdapterVersion) {
|
||||
return errors.New("MCP installed runtime configuration is unsafe or incompatible")
|
||||
}
|
||||
if err = cli.ContainerRemove(ctx, current.ID, container.RemoveOptions{Force: true}); err != nil && !errdefs.IsNotFound(err) {
|
||||
return errors.New("MCP failed installation could not be removed")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Controller) waitHealthy(ctx context.Context, cli DockerClient, id string, req Request, version string) error {
|
||||
ctx, cancel := context.WithTimeout(ctx, 15*time.Second)
|
||||
defer cancel()
|
||||
for {
|
||||
if ctx.Err() != nil {
|
||||
return errors.New("MCP container did not become healthy before the timeout")
|
||||
}
|
||||
info, err := cli.ContainerInspect(ctx, id)
|
||||
if err == nil && isRunning(&info) {
|
||||
if info.Config == nil || info.Config.Labels[ownerLabel] != "true" || !matchesRuntimeWithCapabilities(ctx, cli, &info, req.ImageID, req, version) {
|
||||
return errors.New("MCP container configuration or metadata does not match the verified image")
|
||||
}
|
||||
health, healthErr := c.health(ctx, req.Port)
|
||||
if healthErr == nil && ctx.Err() == nil && validMetadata(health) && health.Status == "ok" && health.AdapterVersion == version {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return errors.New("MCP container did not become healthy before the timeout")
|
||||
case <-time.After(250 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,8 @@ type Local struct {
|
||||
Database string
|
||||
Password string
|
||||
ContainerName string
|
||||
// sqlContext belongs to a request-scoped copy, never the reusable client.
|
||||
sqlContext context.Context
|
||||
}
|
||||
|
||||
func NewLocal(command []string, dbType, containerName, password, database string) *Local {
|
||||
@@ -47,6 +49,26 @@ func (r *Local) Create(info CreateInfo) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// CreateWithContext shares one deadline across all creation and rollback SQL.
|
||||
// Copying the client keeps concurrent or subsequent ordinary calls independent.
|
||||
func (r *Local) CreateWithContext(ctx context.Context, info CreateInfo) error {
|
||||
if ctx == nil {
|
||||
return errors.New("mysql creation context is required")
|
||||
}
|
||||
scoped := *r
|
||||
scoped.PrefixCommand = append([]string(nil), r.PrefixCommand...)
|
||||
scoped.sqlContext = ctx
|
||||
return scoped.Create(info)
|
||||
}
|
||||
|
||||
func (r *Local) commandContext(timeout uint) (context.Context, context.CancelFunc) {
|
||||
parent := r.sqlContext
|
||||
if parent == nil {
|
||||
parent = context.Background()
|
||||
}
|
||||
return context.WithTimeout(parent, time.Duration(timeout)*time.Second)
|
||||
}
|
||||
|
||||
func (r *Local) CreateDatabase(info CreateInfo) error {
|
||||
createSql := fmt.Sprintf("create database `%s` default character set %s collate %s", info.Name, info.Format, info.Collation)
|
||||
if len(info.Collation) == 0 {
|
||||
@@ -396,15 +418,18 @@ func (r *Local) SyncDB(version string) ([]SyncDBInfo, error) {
|
||||
func (r *Local) Close() {}
|
||||
|
||||
func (r *Local) ExecSQL(command string, timeout uint) error {
|
||||
itemCommand := r.PrefixCommand[:]
|
||||
itemCommand := append([]string(nil), r.PrefixCommand...)
|
||||
itemCommand = append(itemCommand, command)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeout)*time.Second)
|
||||
ctx, cancel := r.commandContext(timeout)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(ctx, "docker", itemCommand...)
|
||||
stdout, err := cmd.CombinedOutput()
|
||||
if errors.Is(ctx.Err(), context.DeadlineExceeded) {
|
||||
return buserr.New("ErrExecTimeOut")
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
stdStr := strings.ReplaceAll(string(stdout), "mysql: [Warning] Using a password on the command line interface can be insecure.\n", "")
|
||||
if err != nil || strings.HasPrefix(string(stdStr), "ERROR ") {
|
||||
return errors.New(stdStr)
|
||||
@@ -413,15 +438,18 @@ func (r *Local) ExecSQL(command string, timeout uint) error {
|
||||
}
|
||||
|
||||
func (r *Local) ExecSQLForRows(command string, timeout uint) ([]string, error) {
|
||||
itemCommand := r.PrefixCommand[:]
|
||||
itemCommand := append([]string(nil), r.PrefixCommand...)
|
||||
itemCommand = append(itemCommand, command)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeout)*time.Second)
|
||||
ctx, cancel := r.commandContext(timeout)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(ctx, "docker", itemCommand...)
|
||||
stdout, err := cmd.CombinedOutput()
|
||||
if errors.Is(ctx.Err(), context.DeadlineExceeded) {
|
||||
return nil, buserr.New("ErrExecTimeOut")
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
stdStr := strings.ReplaceAll(string(stdout), "mysql: [Warning] Using a password on the command line interface can be insecure.\n", "")
|
||||
if err != nil || strings.HasPrefix(string(stdStr), "ERROR ") {
|
||||
return nil, errors.New(stdStr)
|
||||
|
||||
@@ -266,6 +266,7 @@ func (b *BaseApi) GetLoginSetting(c *gin.Context) {
|
||||
Language: settingInfo.Language,
|
||||
MenuTabs: settingInfo.MenuTabs,
|
||||
MenuAccordion: settingInfo.MenuAccordion,
|
||||
PanelCache: settingInfo.PanelCache,
|
||||
PanelName: settingInfo.PanelName,
|
||||
Theme: settingInfo.Theme,
|
||||
NeedCaptcha: needCaptcha,
|
||||
|
||||
@@ -10,10 +10,14 @@ import (
|
||||
"github.com/1Panel-dev/1Panel/core/app/dto"
|
||||
"github.com/1Panel-dev/1Panel/core/global"
|
||||
"github.com/1Panel-dev/1Panel/core/i18n"
|
||||
mcpmeta "github.com/1Panel-dev/1Panel/core/utils/mcpapi/metadata"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func ErrorWithDetail(ctx *gin.Context, code int, msgKey string, err error) {
|
||||
if msgKey == "ErrProxy" && ctx.GetBool("MCP_API_AUTH") && ctx.Request != nil && mcpmeta.TrustedRequest(ctx.Request) {
|
||||
ctx.Header(mcpmeta.BusinessCodeHeader, "ErrProxy")
|
||||
}
|
||||
res := dto.Response{
|
||||
Code: code,
|
||||
Message: "",
|
||||
|
||||
@@ -0,0 +1,374 @@
|
||||
package v2
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/1Panel-dev/1Panel/core/app/api/v2/helper"
|
||||
"github.com/1Panel-dev/1Panel/core/app/auth"
|
||||
"github.com/1Panel-dev/1Panel/core/app/dto"
|
||||
"github.com/1Panel-dev/1Panel/core/app/service"
|
||||
contract "github.com/1Panel-dev/1Panel/core/utils/mcp"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func mcpRead(c *gin.Context, value any) bool {
|
||||
data, err := io.ReadAll(http.MaxBytesReader(c.Writer, c.Request.Body, 64<<10))
|
||||
if err == nil {
|
||||
err = contract.DecodeObject(data, value)
|
||||
}
|
||||
if err == nil {
|
||||
switch value.(type) {
|
||||
case *dto.MCPCredentialCreate, *dto.MCPCredentialUpdate:
|
||||
var fields struct {
|
||||
ExpiresAt json.RawMessage `json:"expiresAt"`
|
||||
}
|
||||
if json.Unmarshal(data, &fields) != nil || len(fields.ExpiresAt) == 0 {
|
||||
err = contract.Invalid()
|
||||
}
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
mcpManagementError(c, contract.Invalid())
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
func mcpManagementError(c *gin.Context, err error) {
|
||||
code := http.StatusInternalServerError
|
||||
errorCode := "MCP_OPERATION_FAILED"
|
||||
invocationID := ""
|
||||
var known *contract.Error
|
||||
if errors.As(err, &known) {
|
||||
code = known.Status
|
||||
errorCode = known.Code
|
||||
invocationID = known.InvocationID
|
||||
} else if errors.Is(err, service.ErrMCPSettingsRevision) {
|
||||
code = 409
|
||||
errorCode = "MCP_CONFLICT"
|
||||
}
|
||||
c.Header("Cache-Control", "no-store")
|
||||
c.JSON(200, struct {
|
||||
dto.Response
|
||||
ErrorCode string `json:"errorCode"`
|
||||
InvocationID string `json:"invocationId,omitempty"`
|
||||
}{Response: dto.Response{Code: code, Message: mcpManagementMessage(errorCode)}, ErrorCode: errorCode, InvocationID: invocationID})
|
||||
c.Abort()
|
||||
}
|
||||
func mcpManagement(c *gin.Context, write bool, admin bool) (*service.MCPService, auth.APIKeyOwner, bool) {
|
||||
s := service.NewMCPService()
|
||||
owner, err := s.ManagementOwner(c, write)
|
||||
if err != nil {
|
||||
mcpManagementError(c, err)
|
||||
return nil, owner, false
|
||||
}
|
||||
if admin && !owner.IsSuperAdmin {
|
||||
mcpManagementError(c, contract.Forbidden())
|
||||
return nil, owner, false
|
||||
}
|
||||
c.Header("Cache-Control", "no-store")
|
||||
return s, owner, true
|
||||
}
|
||||
func (b *BaseApi) MCPCatalog(c *gin.Context) {
|
||||
s, owner, ok := mcpManagement(c, false, false)
|
||||
if ok {
|
||||
catalog, err := s.Catalog(owner)
|
||||
if err != nil {
|
||||
mcpManagementError(c, err)
|
||||
return
|
||||
}
|
||||
helper.SuccessWithData(c, catalog)
|
||||
}
|
||||
}
|
||||
func (b *BaseApi) MCPNodes(c *gin.Context) {
|
||||
s, owner, ok := mcpManagement(c, false, false)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if c.Request.URL.RawQuery != "" {
|
||||
mcpManagementError(c, contract.Invalid())
|
||||
return
|
||||
}
|
||||
nodes, err := s.Nodes(owner)
|
||||
if err != nil {
|
||||
mcpManagementError(c, err)
|
||||
return
|
||||
}
|
||||
helper.SuccessWithData(c, nodes)
|
||||
}
|
||||
func (b *BaseApi) SearchMCPCredentials(c *gin.Context) {
|
||||
s, owner, ok := mcpManagement(c, false, false)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req dto.MCPCredentialSearch
|
||||
if !mcpRead(c, &req) {
|
||||
return
|
||||
}
|
||||
result, err := s.Search(owner, req)
|
||||
if err != nil {
|
||||
mcpManagementError(c, err)
|
||||
return
|
||||
}
|
||||
helper.SuccessWithData(c, result)
|
||||
}
|
||||
func (b *BaseApi) CreateMCPCredential(c *gin.Context) {
|
||||
s, owner, ok := mcpManagement(c, true, false)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req dto.MCPCredentialCreate
|
||||
if !mcpRead(c, &req) {
|
||||
return
|
||||
}
|
||||
result, err := s.Create(owner, req)
|
||||
if err != nil {
|
||||
mcpManagementError(c, err)
|
||||
return
|
||||
}
|
||||
helper.SuccessWithData(c, result)
|
||||
}
|
||||
func (b *BaseApi) UpdateMCPCredential(c *gin.Context) {
|
||||
s, owner, ok := mcpManagement(c, true, false)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req dto.MCPCredentialUpdate
|
||||
if !mcpRead(c, &req) {
|
||||
return
|
||||
}
|
||||
if err := s.Update(owner, req); err != nil {
|
||||
mcpManagementError(c, err)
|
||||
return
|
||||
}
|
||||
helper.Success(c)
|
||||
}
|
||||
func (b *BaseApi) StatusMCPCredential(c *gin.Context) {
|
||||
s, owner, ok := mcpManagement(c, true, false)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req dto.MCPCredentialStatus
|
||||
if !mcpRead(c, &req) {
|
||||
return
|
||||
}
|
||||
if err := s.Status(owner, req); err != nil {
|
||||
mcpManagementError(c, err)
|
||||
return
|
||||
}
|
||||
helper.Success(c)
|
||||
}
|
||||
func (b *BaseApi) RevokeMCPCredential(c *gin.Context) {
|
||||
s, owner, ok := mcpManagement(c, true, false)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req dto.MCPCredentialMutation
|
||||
if !mcpRead(c, &req) {
|
||||
return
|
||||
}
|
||||
if err := s.Revoke(owner, req); err != nil {
|
||||
mcpManagementError(c, err)
|
||||
return
|
||||
}
|
||||
helper.Success(c)
|
||||
}
|
||||
func (b *BaseApi) SearchMCPInvocations(c *gin.Context) {
|
||||
s, owner, ok := mcpManagement(c, false, false)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req dto.MCPInvocationSearch
|
||||
if !mcpRead(c, &req) {
|
||||
return
|
||||
}
|
||||
result, err := s.Invocations(owner, req)
|
||||
if err != nil {
|
||||
mcpManagementError(c, err)
|
||||
return
|
||||
}
|
||||
helper.SuccessWithData(c, result)
|
||||
}
|
||||
func (b *BaseApi) MCPServiceStatus(c *gin.Context) {
|
||||
_, owner, ok := mcpManagement(c, false, false)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
result, err := service.NewMCPRuntimeService().Status(ctx)
|
||||
if err != nil {
|
||||
mcpManagementError(c, err)
|
||||
return
|
||||
}
|
||||
result.Endpoint = mcpPublicEndpoint(c, result.Endpoint)
|
||||
result.CanManageService = owner.IsSuperAdmin
|
||||
helper.SuccessWithData(c, result)
|
||||
}
|
||||
func (b *BaseApi) MCPSettings(c *gin.Context) {
|
||||
_, _, ok := mcpManagement(c, false, false)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
result, err := service.NewMCPRuntimeService().Settings()
|
||||
if err != nil {
|
||||
mcpManagementError(c, err)
|
||||
return
|
||||
}
|
||||
helper.SuccessWithData(c, result)
|
||||
}
|
||||
func (b *BaseApi) UpdateMCPSettings(c *gin.Context) {
|
||||
_, _, ok := mcpManagement(c, true, true)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req dto.MCPRuntimeSettingsUpdate
|
||||
if !mcpRead(c, &req) {
|
||||
return
|
||||
}
|
||||
result, err := service.NewMCPRuntimeService().Update(req)
|
||||
if err != nil {
|
||||
mcpServiceError(c, err)
|
||||
return
|
||||
}
|
||||
helper.SuccessWithData(c, result)
|
||||
}
|
||||
func (b *BaseApi) OperateMCPService(c *gin.Context) {
|
||||
_, _, ok := mcpManagement(c, true, true)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Operation string `json:"operation"`
|
||||
}
|
||||
if !mcpRead(c, &req) {
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(c.Request.Context(), 55*time.Second)
|
||||
defer cancel()
|
||||
result, err := service.NewMCPRuntimeService().Operate(ctx, req.Operation)
|
||||
if err != nil {
|
||||
mcpServiceError(c, err)
|
||||
return
|
||||
}
|
||||
helper.SuccessWithData(c, result)
|
||||
}
|
||||
func (b *BaseApi) InstallMCPService(c *gin.Context) {
|
||||
_, _, ok := mcpManagement(c, true, true)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req dto.MCPRuntimeInstall
|
||||
if !mcpRead(c, &req) {
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(c.Request.Context(), 6*time.Minute)
|
||||
defer cancel()
|
||||
result, err := service.NewMCPRuntimeService().Install(ctx, req)
|
||||
if err != nil {
|
||||
mcpServiceError(c, err)
|
||||
return
|
||||
}
|
||||
helper.SuccessWithData(c, result)
|
||||
}
|
||||
func (b *BaseApi) MCPServiceLogs(c *gin.Context) {
|
||||
_, _, ok := mcpManagement(c, false, true)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
result, err := service.NewMCPRuntimeService().Logs(ctx)
|
||||
if err != nil {
|
||||
mcpServiceError(c, err)
|
||||
return
|
||||
}
|
||||
helper.SuccessWithData(c, result)
|
||||
}
|
||||
func mcpServiceError(c *gin.Context, err error) {
|
||||
var known *contract.Error
|
||||
if errors.Is(err, service.ErrMCPSettingsRevision) || errors.As(err, &known) {
|
||||
mcpManagementError(c, err)
|
||||
return
|
||||
}
|
||||
helper.InternalServer(c, err)
|
||||
}
|
||||
|
||||
func MCPRuntimeTools(c *gin.Context) {
|
||||
c.Header("Cache-Control", "no-store")
|
||||
token, err := service.MCPBearer(c)
|
||||
if err != nil {
|
||||
MCPRuntimeError(c, err)
|
||||
return
|
||||
}
|
||||
if !mcpRuntimeProof(c, token) {
|
||||
return
|
||||
}
|
||||
result, err := service.NewMCPService().RuntimeTools(c, token)
|
||||
if err != nil {
|
||||
MCPRuntimeError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(200, result)
|
||||
}
|
||||
func MCPRuntimeInvoke(c *gin.Context) {
|
||||
c.Header("Cache-Control", "no-store")
|
||||
token, err := service.MCPBearer(c)
|
||||
if err != nil {
|
||||
MCPRuntimeError(c, err)
|
||||
return
|
||||
}
|
||||
if !mcpRuntimeProof(c, token) {
|
||||
return
|
||||
}
|
||||
data, err := io.ReadAll(http.MaxBytesReader(c.Writer, c.Request.Body, 64<<10))
|
||||
var req contract.Invoke
|
||||
if err == nil {
|
||||
err = contract.DecodeObject(data, &req)
|
||||
}
|
||||
if err == nil {
|
||||
req.NodeID, err = contract.NormalizeNodeID(req.NodeID)
|
||||
}
|
||||
if err != nil || req.AdmissionID == "" {
|
||||
MCPRuntimeError(c, contract.Invalid())
|
||||
return
|
||||
}
|
||||
result, err := service.NewMCPService().Invoke(c, token, req)
|
||||
if err != nil {
|
||||
MCPRuntimeError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(200, result)
|
||||
}
|
||||
func MCPRuntimeError(c *gin.Context, err error) {
|
||||
var known *contract.Error
|
||||
if !errors.As(err, &known) {
|
||||
known = contract.Fail("MCP_EXECUTION_FAILED", 503, "MCP service is unavailable")
|
||||
}
|
||||
c.Header("Cache-Control", "no-store")
|
||||
if known.RetryAfterSeconds > 0 {
|
||||
c.Header("Retry-After", strconv.Itoa(known.RetryAfterSeconds))
|
||||
}
|
||||
c.JSON(known.Status, known)
|
||||
c.Abort()
|
||||
}
|
||||
|
||||
func mcpPublicEndpoint(c *gin.Context, configured string) string {
|
||||
if configured != "" {
|
||||
return configured
|
||||
}
|
||||
scheme := "http"
|
||||
if c.Request.TLS != nil {
|
||||
scheme = "https"
|
||||
}
|
||||
if c.Request.Host == "" {
|
||||
return ""
|
||||
}
|
||||
return (&url.URL{Scheme: scheme, Host: c.Request.Host, Path: "/mcp"}).String()
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package v2
|
||||
|
||||
import (
|
||||
"io"
|
||||
"time"
|
||||
|
||||
"github.com/1Panel-dev/1Panel/core/app/service"
|
||||
contract "github.com/1Panel-dev/1Panel/core/utils/mcp"
|
||||
"github.com/gin-gonic/gin"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func mcpRuntimeProof(c *gin.Context, token string) bool {
|
||||
source, err := contract.VerifyAccessProof(c.GetHeader(contract.AccessProofHeader), token, time.Now())
|
||||
if err != nil {
|
||||
MCPRuntimeError(c, err)
|
||||
return false
|
||||
}
|
||||
c.Set("MCP_CLIENT_IP", source)
|
||||
return true
|
||||
}
|
||||
|
||||
func MCPRuntimeAdmit(c *gin.Context) {
|
||||
c.Header("Cache-Control", "no-store")
|
||||
token, err := service.MCPBearer(c)
|
||||
if err != nil {
|
||||
MCPRuntimeError(c, err)
|
||||
return
|
||||
}
|
||||
if !mcpRuntimeProof(c, token) {
|
||||
return
|
||||
}
|
||||
data, err := io.ReadAll(http.MaxBytesReader(c.Writer, c.Request.Body, 64<<10))
|
||||
var req contract.Admission
|
||||
if err != nil || contract.DecodeObject(data, &req) != nil {
|
||||
MCPRuntimeError(c, contract.Invalid())
|
||||
return
|
||||
}
|
||||
result, err := service.NewMCPService().Admit(c, token, req)
|
||||
if err != nil {
|
||||
MCPRuntimeError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(200, result)
|
||||
}
|
||||
|
||||
func MCPRuntimeComplete(c *gin.Context) {
|
||||
c.Header("Cache-Control", "no-store")
|
||||
token, err := service.MCPBearer(c)
|
||||
if err != nil {
|
||||
MCPRuntimeError(c, err)
|
||||
return
|
||||
}
|
||||
data, err := io.ReadAll(http.MaxBytesReader(c.Writer, c.Request.Body, 1024))
|
||||
var req contract.Completion
|
||||
if err != nil || contract.DecodeObject(data, &req) != nil {
|
||||
MCPRuntimeError(c, contract.Invalid())
|
||||
return
|
||||
}
|
||||
result, err := service.NewMCPService().CompleteWithOutcome(token, req)
|
||||
if err != nil {
|
||||
MCPRuntimeError(c, err)
|
||||
return
|
||||
}
|
||||
if req.ErrorCode != "" {
|
||||
c.JSON(http.StatusOK, result)
|
||||
return
|
||||
}
|
||||
c.Status(204)
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package v2
|
||||
|
||||
import (
|
||||
"github.com/1Panel-dev/1Panel/core/app/api/v2/helper"
|
||||
"github.com/1Panel-dev/1Panel/core/app/dto"
|
||||
"github.com/1Panel-dev/1Panel/core/app/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func (b *BaseApi) RevealMCPCredential(c *gin.Context) {
|
||||
s, owner, ok := mcpManagement(c, true, false)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req dto.MCPCredentialMutation
|
||||
if !mcpRead(c, &req) {
|
||||
return
|
||||
}
|
||||
result, err := s.Reveal(owner, req)
|
||||
if err != nil {
|
||||
mcpManagementError(c, err)
|
||||
return
|
||||
}
|
||||
helper.SuccessWithData(c, result)
|
||||
}
|
||||
|
||||
func (b *BaseApi) RotateMCPCredential(c *gin.Context) {
|
||||
s, owner, ok := mcpManagement(c, true, false)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req dto.MCPCredentialMutation
|
||||
if !mcpRead(c, &req) {
|
||||
return
|
||||
}
|
||||
result, err := s.Rotate(owner, req)
|
||||
if err != nil {
|
||||
mcpManagementError(c, err)
|
||||
return
|
||||
}
|
||||
helper.SuccessWithData(c, result)
|
||||
}
|
||||
|
||||
func (b *BaseApi) ExportMCPCredential(c *gin.Context) {
|
||||
s, owner, ok := mcpManagement(c, true, false)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req dto.MCPEnrollmentExport
|
||||
if !mcpRead(c, &req) {
|
||||
return
|
||||
}
|
||||
settings, err := service.NewMCPRuntimeService().Settings()
|
||||
if err != nil {
|
||||
mcpManagementError(c, err)
|
||||
return
|
||||
}
|
||||
result, err := s.ExportMarkdown(owner, req, mcpPublicEndpoint(c, settings.ExternalURL), c.GetHeader("Accept-Language"))
|
||||
if err != nil {
|
||||
mcpManagementError(c, err)
|
||||
return
|
||||
}
|
||||
helper.SuccessWithData(c, result)
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package v2
|
||||
|
||||
import "github.com/1Panel-dev/1Panel/core/i18n"
|
||||
|
||||
// Management responses use panel localization while runtime errors retain their
|
||||
// stable machine contract. Never render raw errors or credential data here.
|
||||
func mcpManagementMessage(code string) string {
|
||||
key := "ErrInternalServer"
|
||||
switch code {
|
||||
case "MCP_INVALID_ARGUMENTS":
|
||||
key = "ErrInvalidParams"
|
||||
case "MCP_UNAUTHENTICATED":
|
||||
key = "ErrNotLogin"
|
||||
case "MCP_FORBIDDEN":
|
||||
key = "ErrRBAC"
|
||||
case "MCP_CONFLICT":
|
||||
key = "ErrMCPConflict"
|
||||
case "MCP_POLICY_CHANGED", "MCP_SCHEMA_MISMATCH":
|
||||
key = "ErrMCPPolicyChanged"
|
||||
case "MCP_CATALOG_UNAVAILABLE":
|
||||
key = "ErrMCPCatalogUnavailable"
|
||||
case "MCP_DISABLED":
|
||||
key = "ErrMCPDisabled"
|
||||
case "MCP_SECRET_KEY_UNAVAILABLE":
|
||||
key = "ErrMCPSecretKeyUnavailable"
|
||||
case "MCP_SECRET_INVALID":
|
||||
key = "ErrMCPSecretInvalid"
|
||||
case "MCP_CREDENTIAL_INACTIVE":
|
||||
key = "ErrMCPCredentialInactive"
|
||||
case "MCP_AUDIT_UNAVAILABLE":
|
||||
key = "ErrMCPAuditUnavailable"
|
||||
case "MCP_ENDPOINT_UNAVAILABLE":
|
||||
key = "ErrMCPEndpointUnavailable"
|
||||
}
|
||||
return i18n.GetMsgWithMap(key, nil)
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package dto
|
||||
|
||||
import "time"
|
||||
|
||||
type MCPCredentialFields struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
ExpiresAt *time.Time `json:"expiresAt"`
|
||||
Tools []string `json:"tools"`
|
||||
ToolPolicies map[string]string `json:"toolPolicies"`
|
||||
ToolReadOnly map[string]bool `json:"toolReadOnly"`
|
||||
NodeIDs []string `json:"nodeIds"`
|
||||
}
|
||||
type MCPNodeItem struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status"`
|
||||
IsLocal bool `json:"isLocal"`
|
||||
}
|
||||
type MCPCredentialCreate struct {
|
||||
RequestID string `json:"requestID"`
|
||||
MCPCredentialFields
|
||||
}
|
||||
type MCPCredentialMutation struct {
|
||||
ID string `json:"id"`
|
||||
Revision uint64 `json:"revision"`
|
||||
}
|
||||
type MCPCredentialUpdate struct {
|
||||
MCPCredentialMutation
|
||||
MCPCredentialFields
|
||||
}
|
||||
type MCPCredentialStatus struct {
|
||||
MCPCredentialMutation
|
||||
Status string `json:"status"`
|
||||
}
|
||||
type MCPCredentialItem struct {
|
||||
ID string `json:"id"`
|
||||
MCPCredentialFields
|
||||
KeyHint string `json:"keyHint"`
|
||||
Status string `json:"status"`
|
||||
Revision uint64 `json:"revision"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
OwnerName string `json:"ownerName"`
|
||||
StaleTools []string `json:"staleTools"`
|
||||
}
|
||||
type MCPCredentialCreated struct {
|
||||
Item MCPCredentialItem `json:"item"`
|
||||
Token string `json:"token"`
|
||||
AlreadyCreated bool `json:"alreadyCreated,omitempty"`
|
||||
}
|
||||
type MCPCredentialSearch struct {
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"pageSize"`
|
||||
ExcludeRevoked bool `json:"excludeRevoked"`
|
||||
}
|
||||
type MCPInvocationSearch struct {
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"pageSize"`
|
||||
CredentialID string `json:"credentialId"`
|
||||
ToolID string `json:"toolId"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package dto
|
||||
|
||||
type MCPCredentialSecret struct {
|
||||
ID string `json:"id"`
|
||||
Token string `json:"token"`
|
||||
}
|
||||
|
||||
type MCPEnrollmentExport struct {
|
||||
MCPCredentialMutation
|
||||
Client string `json:"client"`
|
||||
}
|
||||
|
||||
type MCPEnrollmentDocument struct {
|
||||
Filename string `json:"filename"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package dto
|
||||
|
||||
type MCPRuntimeSettings struct {
|
||||
RuntimeType string `json:"runtimeType"`
|
||||
DockerImage string `json:"dockerImage"`
|
||||
Enabled bool `json:"enabled"`
|
||||
ListenPort int `json:"listenPort"`
|
||||
ExternalURL string `json:"externalUrl"`
|
||||
Revision uint64 `json:"revision"`
|
||||
AccessPolicy string `json:"accessPolicy"`
|
||||
AllowedIPs []string `json:"allowedIPs"`
|
||||
}
|
||||
|
||||
type MCPRuntimeSettingsUpdate = MCPRuntimeSettings
|
||||
|
||||
type MCPRuntimeInstall struct {
|
||||
DockerImage string `json:"dockerImage"`
|
||||
ListenPort int `json:"listenPort"`
|
||||
ExternalURL string `json:"externalUrl"`
|
||||
Revision uint64 `json:"revision"`
|
||||
}
|
||||
|
||||
type MCPRuntimeStatus struct {
|
||||
RuntimeType string `json:"runtimeType"`
|
||||
DockerImage string `json:"dockerImage"`
|
||||
ContainerName string `json:"containerName"`
|
||||
ImageID string `json:"imageId"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Installed bool `json:"installed"`
|
||||
Status string `json:"status"`
|
||||
Ready bool `json:"ready"`
|
||||
Reason string `json:"reason"`
|
||||
AdapterVersion string `json:"adapterVersion"`
|
||||
ContractVersion int `json:"contractVersion"`
|
||||
Protocol string `json:"protocol"`
|
||||
InternalEndpoint string `json:"internalEndpoint"`
|
||||
Endpoint string `json:"endpoint"`
|
||||
CanManageService bool `json:"canManageService"`
|
||||
}
|
||||
|
||||
type MCPRuntimeLogs struct {
|
||||
Content string `json:"content"`
|
||||
}
|
||||
@@ -4,6 +4,16 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
const DefaultPanelCache = `{"tabs":true,"pagination":true,"refresh":true,"search":false,"duration":"24h"}`
|
||||
|
||||
type PanelCache struct {
|
||||
Tabs *bool `json:"tabs"`
|
||||
Pagination *bool `json:"pagination"`
|
||||
Refresh *bool `json:"refresh"`
|
||||
Search *bool `json:"search"`
|
||||
Duration string `json:"duration"`
|
||||
}
|
||||
|
||||
type SettingInfo struct {
|
||||
SystemVersion string `json:"systemVersion"`
|
||||
DeveloperMode string `json:"developerMode"`
|
||||
@@ -18,6 +28,7 @@ type SettingInfo struct {
|
||||
Theme string `json:"theme"`
|
||||
MenuTabs string `json:"menuTabs"`
|
||||
MenuAccordion string `json:"menuAccordion"`
|
||||
PanelCache string `json:"panelCache"`
|
||||
Language string `json:"language"`
|
||||
DocSource string `json:"docSource"`
|
||||
|
||||
@@ -62,6 +73,7 @@ type SettingBaseInfo struct {
|
||||
Theme string `json:"theme"`
|
||||
MenuTabs string `json:"menuTabs"`
|
||||
MenuAccordion string `json:"menuAccordion"`
|
||||
PanelCache string `json:"panelCache"`
|
||||
Language string `json:"language"`
|
||||
HideMenu string `json:"hideMenu"`
|
||||
DocSource string `json:"docSource"`
|
||||
@@ -288,6 +300,7 @@ type LoginSetting struct {
|
||||
Language string `json:"language"`
|
||||
MenuTabs string `json:"menuTabs"`
|
||||
MenuAccordion string `json:"menuAccordion"`
|
||||
PanelCache string `json:"panelCache"`
|
||||
PanelName string `json:"panelName"`
|
||||
Theme string `json:"theme"`
|
||||
NeedCaptcha bool `json:"needCaptcha"`
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
type MCPCredential struct {
|
||||
ID string `gorm:"type:varchar(36);primaryKey" json:"-"`
|
||||
OwnerType string `gorm:"not null;uniqueIndex:idx_mcp_request;uniqueIndex:idx_mcp_name;index:idx_mcp_owner" json:"-"`
|
||||
OwnerID string `gorm:"not null;uniqueIndex:idx_mcp_request;uniqueIndex:idx_mcp_name;index:idx_mcp_owner" json:"-"`
|
||||
OwnerName string `json:"-"`
|
||||
Name string `gorm:"not null" json:"-"`
|
||||
ActiveName *string `gorm:"uniqueIndex:idx_mcp_name" json:"-"`
|
||||
Description string `json:"-"`
|
||||
NodeIDs string `gorm:"type:text;not null" json:"-"`
|
||||
RequestID string `gorm:"not null;uniqueIndex:idx_mcp_request" json:"-"`
|
||||
RequestHash string `json:"-"`
|
||||
TokenHash string `gorm:"uniqueIndex;not null" json:"-"`
|
||||
SecretCiphertext string `gorm:"type:text" json:"-"`
|
||||
SecretVersion int `gorm:"not null;default:0" json:"-"`
|
||||
KeyHint string `json:"-"`
|
||||
Status string `gorm:"not null" json:"-"`
|
||||
ExpiresAt *time.Time `json:"-"`
|
||||
Revision uint64 `gorm:"not null" json:"-"`
|
||||
CreatedAt time.Time `json:"-"`
|
||||
UpdatedAt time.Time `json:"-"`
|
||||
RevokedAt *time.Time `json:"-"`
|
||||
}
|
||||
type MCPGrant struct {
|
||||
CredentialID string `gorm:"primaryKey"`
|
||||
ToolID string `gorm:"primaryKey"`
|
||||
PolicyHash string `gorm:"not null"`
|
||||
AuthorizationSnapshot string `gorm:"type:text;not null" json:"-"`
|
||||
}
|
||||
|
||||
type MCPInvocation struct {
|
||||
ID string `gorm:"type:varchar(36);primaryKey" json:"id"`
|
||||
RequestID string `json:"requestId"`
|
||||
CredentialID string `gorm:"index" json:"credentialId"`
|
||||
CredentialName string `json:"credentialName"`
|
||||
OwnerType string `gorm:"index:idx_mcp_invocation_owner" json:"-"`
|
||||
OwnerID string `gorm:"index:idx_mcp_invocation_owner" json:"-"`
|
||||
OwnerName string `json:"ownerName"`
|
||||
ToolID string `gorm:"index" json:"toolId"`
|
||||
OperationID string `json:"operationId"`
|
||||
PolicyHash string `json:"policyHash"`
|
||||
NodeID string `json:"nodeId"`
|
||||
Revision uint64 `json:"revision"`
|
||||
Status string `gorm:"index" json:"status"`
|
||||
Code string `json:"code"`
|
||||
StartedAt time.Time `gorm:"index" json:"startedAt"`
|
||||
FinishedAt *time.Time `json:"finishedAt"`
|
||||
DurationMs int64 `json:"durationMs"`
|
||||
}
|
||||
|
||||
type MCPSetting struct {
|
||||
RuntimeType string `gorm:"not null;default:docker"`
|
||||
DockerImage string `gorm:"not null;default:1panel/1panel-mcp-server:v0.0.1"`
|
||||
ID uint `gorm:"primaryKey"`
|
||||
Enabled bool `gorm:"not null;default:false"`
|
||||
ListenPort int `gorm:"not null;default:18081"`
|
||||
ExternalURL string
|
||||
AccessPolicy string `gorm:"not null;default:inherit"`
|
||||
AllowedIPs string `gorm:"type:text;not null;default:'[]'"`
|
||||
Revision uint64 `gorm:"not null;default:1"`
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"github.com/1Panel-dev/1Panel/core/app/model"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type MCPRepo struct{ DB *gorm.DB }
|
||||
|
||||
func (r MCPRepo) Token(hash string) (model.MCPCredential, error) {
|
||||
var key model.MCPCredential
|
||||
err := r.DB.Where("token_hash = ?", hash).First(&key).Error
|
||||
return key, err
|
||||
}
|
||||
func (r MCPRepo) Grants(id string) ([]model.MCPGrant, error) {
|
||||
grants := []model.MCPGrant{}
|
||||
err := r.DB.Where("credential_id = ?", id).Order("tool_id").Find(&grants).Error
|
||||
return grants, err
|
||||
}
|
||||
func (r MCPRepo) ReplaceTools(id string, grants []model.MCPGrant) error {
|
||||
if err := r.DB.Where("credential_id = ?", id).Delete(&model.MCPGrant{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
for _, grant := range grants {
|
||||
grant.CredentialID = id
|
||||
if err := r.DB.Create(&grant).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,793 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/1Panel-dev/1Panel/core/app/auth"
|
||||
"github.com/1Panel-dev/1Panel/core/app/dto"
|
||||
"github.com/1Panel-dev/1Panel/core/app/model"
|
||||
"github.com/1Panel-dev/1Panel/core/app/repo"
|
||||
"github.com/1Panel-dev/1Panel/core/global"
|
||||
"github.com/1Panel-dev/1Panel/core/utils/encrypt"
|
||||
contract "github.com/1Panel-dev/1Panel/core/utils/mcp"
|
||||
"github.com/1Panel-dev/1Panel/core/utils/xpack"
|
||||
"github.com/1Panel-dev/1Panel/core/utils/xpack/providers"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
var mcpMutationMu sync.Mutex
|
||||
var mcpExecutionSlots = make(chan struct{}, 8)
|
||||
var mcpCallWindow = struct {
|
||||
sync.Mutex
|
||||
entries map[string]mcpWindow
|
||||
}{entries: make(map[string]mcpWindow)}
|
||||
|
||||
type mcpWindow struct {
|
||||
start time.Time
|
||||
calls int
|
||||
}
|
||||
|
||||
func admitMCPCall(id string, now time.Time) bool {
|
||||
mcpCallWindow.Lock()
|
||||
defer mcpCallWindow.Unlock()
|
||||
window, exists := mcpCallWindow.entries[id]
|
||||
if !exists || now.Sub(window.start) >= time.Minute {
|
||||
if len(mcpCallWindow.entries) >= 4096 {
|
||||
for key, value := range mcpCallWindow.entries {
|
||||
if now.Sub(value.start) >= time.Minute {
|
||||
delete(mcpCallWindow.entries, key)
|
||||
}
|
||||
}
|
||||
if len(mcpCallWindow.entries) >= 4096 && !exists {
|
||||
return false
|
||||
}
|
||||
}
|
||||
window = mcpWindow{start: now}
|
||||
}
|
||||
if window.calls >= 120 {
|
||||
return false
|
||||
}
|
||||
window.calls++
|
||||
mcpCallWindow.entries[id] = window
|
||||
return true
|
||||
}
|
||||
|
||||
type MCPManagementAuthorizer interface {
|
||||
AuthorizeMCPManagement(auth.APIKeyOwner, string, []string) error
|
||||
}
|
||||
type MCPAPIAuthorizer interface {
|
||||
AuthorizeMCPAPI(auth.APIKeyOwner, string, string, string) error
|
||||
}
|
||||
type MCPExecutor func(context.Context, contract.APIRequest) (any, error)
|
||||
type MCPService struct {
|
||||
DB *gorm.DB
|
||||
Provider auth.APIKeyOwnerProvider
|
||||
Authorizer MCPManagementAuthorizer
|
||||
APIAuthorizer MCPAPIAuthorizer
|
||||
NodeProvider providers.MCPNodeProvider
|
||||
Execute MCPExecutor
|
||||
Enabled func() (bool, error)
|
||||
Now func() time.Time
|
||||
LoadCatalog func() ([]contract.Tool, error)
|
||||
SecretKey func() (string, error)
|
||||
EnrollmentAudit func(*gorm.DB, *model.OperationLog) error
|
||||
}
|
||||
|
||||
var NewMCPService = func() *MCPService {
|
||||
owner, _ := xpack.AuthProvider.(auth.APIKeyOwnerProvider)
|
||||
authorizer, _ := xpack.AuthProvider.(MCPManagementAuthorizer)
|
||||
apiAuthorizer, _ := xpack.AuthProvider.(MCPAPIAuthorizer)
|
||||
nodeProvider, _ := xpack.AuthProvider.(providers.MCPNodeProvider)
|
||||
return &MCPService{DB: global.DB, Provider: owner, Authorizer: authorizer, APIAuthorizer: apiAuthorizer, NodeProvider: nodeProvider, Execute: executeMCPAPI, Now: time.Now, SecretKey: encrypt.APIKeyEncryptionKey, LoadCatalog: func() ([]contract.Tool, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
return NewMCPRuntimeService().Catalog(ctx)
|
||||
}, Enabled: func() (bool, error) {
|
||||
var setting struct{ Enabled bool }
|
||||
err := global.DB.Table("mcp_settings").Where("id = ?", 1).Take(&setting).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return false, nil
|
||||
}
|
||||
return setting.Enabled, err
|
||||
}}
|
||||
}
|
||||
|
||||
func (s *MCPService) authorize(owner auth.APIKeyOwner, codes ...string) error {
|
||||
if s.Authorizer == nil || s.Authorizer.AuthorizeMCPManagement(owner, "local", codes) != nil {
|
||||
return contract.Forbidden()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *MCPService) authorizeAPI(owner auth.APIKeyOwner, nodeID, method, path string) error {
|
||||
if s.APIAuthorizer == nil || s.APIAuthorizer.AuthorizeMCPAPI(owner, nodeID, method, path) != nil {
|
||||
return contract.Forbidden()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (s *MCPService) authorizeToolOnNode(owner auth.APIKeyOwner, nodeID string, tool contract.Tool) error {
|
||||
for _, op := range tool.Operations {
|
||||
if err := s.authorizeAPI(owner, nodeID, op.Method, contract.PermissionPath(op)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (s *MCPService) ManagementOwner(c *gin.Context, write bool) (auth.APIKeyOwner, error) {
|
||||
if c.GetHeader("Authorization") != "" {
|
||||
return auth.APIKeyOwner{}, contract.Forbidden()
|
||||
}
|
||||
if _, err := auth.RequireAPIKeySession(c); err != nil {
|
||||
return auth.APIKeyOwner{}, contract.Fail("MCP_UNAUTHENTICATED", 401, "A panel session is required")
|
||||
}
|
||||
if s.Provider == nil {
|
||||
return auth.APIKeyOwner{}, contract.Forbidden()
|
||||
}
|
||||
owner, err := s.Provider.CurrentAPIKeyOwner(c)
|
||||
if err != nil {
|
||||
return auth.APIKeyOwner{}, contract.Forbidden()
|
||||
}
|
||||
code := "mcp_access_view"
|
||||
if write {
|
||||
code = "mcp_access_manage"
|
||||
}
|
||||
if err = s.authorize(owner, code); err != nil {
|
||||
return auth.APIKeyOwner{}, err
|
||||
}
|
||||
return owner, nil
|
||||
}
|
||||
func mcpOwnerQuery(db *gorm.DB, owner auth.APIKeyOwner) *gorm.DB {
|
||||
return db.Where("owner_type = ? AND owner_id = ?", owner.Type, owner.ID)
|
||||
}
|
||||
func validMCPPage(page, size int) bool { return page > 0 && page <= 1000000 && size > 0 && size <= 100 }
|
||||
func (s *MCPService) catalog() ([]contract.Tool, error) {
|
||||
unavailable := func() error {
|
||||
return contract.Fail("MCP_CATALOG_UNAVAILABLE", 503, "The installed MCP tool catalog is unavailable or incompatible")
|
||||
}
|
||||
if s.LoadCatalog == nil {
|
||||
return nil, unavailable()
|
||||
}
|
||||
tools, err := s.LoadCatalog()
|
||||
if err != nil {
|
||||
return nil, unavailable()
|
||||
}
|
||||
tools, err = contract.ValidateCatalog(tools)
|
||||
if err != nil {
|
||||
return nil, unavailable()
|
||||
}
|
||||
return tools, nil
|
||||
}
|
||||
func catalogIndex(tools []contract.Tool) map[string]contract.Tool {
|
||||
index := make(map[string]contract.Tool, len(tools))
|
||||
for _, tool := range tools {
|
||||
index[tool.ToolID] = tool
|
||||
}
|
||||
return index
|
||||
}
|
||||
func mcpGrantAllows(grant model.MCPGrant, tool contract.Tool) bool {
|
||||
return grant.ToolID == tool.ToolID && grant.PolicyHash != "" &&
|
||||
contract.ToolAuthorizationAllows(grant.AuthorizationSnapshot, grant.PolicyHash, tool)
|
||||
}
|
||||
func mcpPolicyChanged() error {
|
||||
return contract.Fail("MCP_POLICY_CHANGED", 409, "Tool API policy changed; reload the catalog and explicitly save the credential authorization again")
|
||||
}
|
||||
func (s *MCPService) Catalog(owner auth.APIKeyOwner) (map[string]any, error) {
|
||||
catalog, err := s.catalog()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
nodes, err := s.Nodes(owner)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tools := []contract.Tool{}
|
||||
toolNodeIDs := make(map[string][]string)
|
||||
nodeIDs := mcpNodeItemIDs(nodes)
|
||||
for _, tool := range catalog {
|
||||
allowed := []string{}
|
||||
for _, nodeID := range nodeIDs {
|
||||
if mcpIsNodeDiscovery(tool) || s.authorizeToolOnNode(owner, nodeID, tool) == nil {
|
||||
allowed = append(allowed, nodeID)
|
||||
}
|
||||
}
|
||||
if len(allowed) > 0 || mcpIsNodeDiscovery(tool) {
|
||||
tools = append(tools, tool)
|
||||
toolNodeIDs[tool.ToolID] = allowed
|
||||
}
|
||||
}
|
||||
return map[string]any{"contractVersion": contract.ContractVersion, "scope": "nodes", "tools": tools, "toolNodeIds": toolNodeIDs}, nil
|
||||
}
|
||||
func (s *MCPService) validateFields(owner auth.APIKeyOwner, fields *dto.MCPCredentialFields, checkExpiry bool) ([]model.MCPGrant, error) {
|
||||
fields.Name = strings.TrimSpace(fields.Name)
|
||||
fields.Description = strings.TrimSpace(fields.Description)
|
||||
if fields.Name == "" || utf8.RuneCountInString(fields.Name) > 64 || utf8.RuneCountInString(fields.Description) > 256 || strings.ContainsAny(fields.Name, "\x00\r\n") || len(fields.Tools) > 256 {
|
||||
return nil, contract.Invalid()
|
||||
}
|
||||
if checkExpiry && fields.ExpiresAt != nil && !fields.ExpiresAt.After(s.Now()) {
|
||||
return nil, contract.Invalid()
|
||||
}
|
||||
if err := s.validateNodeIDs(owner, &fields.NodeIDs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
catalog, err := s.catalog()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
index := catalogIndex(catalog)
|
||||
slices.Sort(fields.Tools)
|
||||
fields.Tools = slices.Compact(fields.Tools)
|
||||
if len(fields.ToolPolicies) != len(fields.Tools) {
|
||||
return nil, mcpPolicyChanged()
|
||||
}
|
||||
if len(fields.ToolReadOnly) != len(fields.Tools) {
|
||||
return nil, mcpPolicyChanged()
|
||||
}
|
||||
grants := make([]model.MCPGrant, 0, len(fields.Tools))
|
||||
for _, id := range fields.Tools {
|
||||
tool, ok := index[id]
|
||||
if !ok || fields.ToolPolicies[id] == "" || fields.ToolPolicies[id] != tool.PolicyHash {
|
||||
return nil, mcpPolicyChanged()
|
||||
}
|
||||
readOnly, reviewed := fields.ToolReadOnly[id]
|
||||
if !reviewed || readOnly != tool.ReadOnly {
|
||||
return nil, mcpPolicyChanged()
|
||||
}
|
||||
if err := s.authorizeToolOnNodes(owner, fields.NodeIDs, tool); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
snapshot, err := contract.ToolAuthorizationSnapshot(tool)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
grants = append(grants, model.MCPGrant{ToolID: id, PolicyHash: tool.PolicyHash, AuthorizationSnapshot: snapshot})
|
||||
}
|
||||
return grants, nil
|
||||
}
|
||||
func (s *MCPService) credentialItem(key model.MCPCredential, catalog map[string]contract.Tool) (dto.MCPCredentialItem, error) {
|
||||
nodeIDs, err := mcpCredentialNodeIDs(key)
|
||||
if err != nil {
|
||||
return dto.MCPCredentialItem{}, err
|
||||
}
|
||||
grants, err := (repo.MCPRepo{DB: s.DB}).Grants(key.ID)
|
||||
if err != nil {
|
||||
return dto.MCPCredentialItem{}, err
|
||||
}
|
||||
tools := []string{}
|
||||
policies := map[string]string{}
|
||||
readOnly := map[string]bool{}
|
||||
stale := []string{}
|
||||
for _, grant := range grants {
|
||||
tools = append(tools, grant.ToolID)
|
||||
policies[grant.ToolID] = grant.PolicyHash
|
||||
tool, exists := catalog[grant.ToolID]
|
||||
if exists {
|
||||
readOnly[grant.ToolID] = tool.ReadOnly
|
||||
}
|
||||
if exists && mcpGrantAllows(grant, tool) {
|
||||
// Bind this compatibility decision to the catalog used to calculate
|
||||
// it, while leaving the stored consent baseline unchanged.
|
||||
policies[grant.ToolID] = tool.PolicyHash
|
||||
} else {
|
||||
stale = append(stale, grant.ToolID)
|
||||
}
|
||||
}
|
||||
status := key.Status
|
||||
if status != "Revoked" && key.ExpiresAt != nil && !s.Now().Before(*key.ExpiresAt) {
|
||||
status = "Expired"
|
||||
}
|
||||
return dto.MCPCredentialItem{ID: key.ID, MCPCredentialFields: dto.MCPCredentialFields{Name: key.Name, Description: key.Description, ExpiresAt: key.ExpiresAt, Tools: tools, ToolPolicies: policies, ToolReadOnly: readOnly, NodeIDs: nodeIDs}, StaleTools: stale, KeyHint: key.KeyHint, Status: status, Revision: key.Revision, CreatedAt: key.CreatedAt, OwnerName: key.OwnerName}, nil
|
||||
}
|
||||
func (s *MCPService) Search(owner auth.APIKeyOwner, req dto.MCPCredentialSearch) (map[string]any, error) {
|
||||
if !validMCPPage(req.Page, req.PageSize) {
|
||||
return nil, contract.Invalid()
|
||||
}
|
||||
q := mcpOwnerQuery(s.DB.Model(&model.MCPCredential{}), owner)
|
||||
if req.ExcludeRevoked {
|
||||
q = q.Where("status <> ?", "Revoked")
|
||||
}
|
||||
var total int64
|
||||
if err := q.Count(&total).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
keys := []model.MCPCredential{}
|
||||
if err := q.Order("created_at DESC, id DESC").Offset((req.Page - 1) * req.PageSize).Limit(req.PageSize).Find(&keys).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items := []dto.MCPCredentialItem{}
|
||||
catalog, _ := s.catalog()
|
||||
index := catalogIndex(catalog)
|
||||
for _, key := range keys {
|
||||
item, err := s.credentialItem(key, index)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
return map[string]any{"items": items, "total": total}, nil
|
||||
}
|
||||
func tokenDigest(token string) string {
|
||||
sum := sha256.Sum256([]byte(token))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
func (s *MCPService) Create(owner auth.APIKeyOwner, req dto.MCPCredentialCreate) (result *dto.MCPCredentialCreated, resultErr error) {
|
||||
defer func() {
|
||||
if resultErr != nil {
|
||||
if err := s.auditEnrollment(s.DB, owner, "create", dto.MCPCredentialMutation{}, resultErr); err != nil {
|
||||
result, resultErr = nil, err
|
||||
}
|
||||
}
|
||||
}()
|
||||
if _, err := uuid.Parse(req.RequestID); err != nil {
|
||||
return nil, contract.Invalid()
|
||||
}
|
||||
grants, err := s.validateFields(owner, &req.MCPCredentialFields, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
fields, _ := json.Marshal(req.MCPCredentialFields)
|
||||
requestHash := tokenDigest(string(fields))
|
||||
var key model.MCPCredential
|
||||
var token string
|
||||
repeated := false
|
||||
mcpMutationMu.Lock()
|
||||
err = s.DB.Transaction(func(tx *gorm.DB) error {
|
||||
err := mcpOwnerQuery(tx, owner).Where("request_id = ?", req.RequestID).First(&key).Error
|
||||
if err == nil {
|
||||
if key.RequestHash != requestHash {
|
||||
return contract.Fail("MCP_CONFLICT", 409, "Creation request already used")
|
||||
}
|
||||
repeated = true
|
||||
return nil
|
||||
}
|
||||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return err
|
||||
}
|
||||
var count int64
|
||||
if err = mcpOwnerQuery(tx.Model(&model.MCPCredential{}), owner).Where("active_name = ?", req.Name).Count(&count).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if count > 0 {
|
||||
return contract.Fail("MCP_CONFLICT", 409, "Credential name already exists")
|
||||
}
|
||||
secret := make([]byte, 32)
|
||||
if _, err = rand.Read(secret); err != nil {
|
||||
return err
|
||||
}
|
||||
token = "mcp_" + hex.EncodeToString(secret)
|
||||
key = model.MCPCredential{ID: uuid.NewString(), OwnerType: owner.Type, OwnerID: owner.ID, OwnerName: owner.Name, Name: req.Name, ActiveName: &req.Name, Description: req.Description, NodeIDs: mcpEncodeNodeIDs(req.NodeIDs), RequestID: req.RequestID, RequestHash: requestHash, TokenHash: tokenDigest(token), KeyHint: token[:8] + "…" + token[len(token)-4:], Status: "Enable", ExpiresAt: req.ExpiresAt, Revision: 1}
|
||||
if err = s.sealCredentialToken(&key, token); err != nil {
|
||||
return err
|
||||
}
|
||||
if err = tx.Create(&key).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err = (repo.MCPRepo{DB: tx}).ReplaceTools(key.ID, grants); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.auditEnrollment(tx, owner, "create", dto.MCPCredentialMutation{ID: key.ID, Revision: key.Revision}, nil)
|
||||
})
|
||||
mcpMutationMu.Unlock()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
catalog, _ := s.catalog()
|
||||
item, err := s.credentialItem(key, catalogIndex(catalog))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &dto.MCPCredentialCreated{Item: item, Token: token, AlreadyCreated: repeated}, nil
|
||||
}
|
||||
func (s *MCPService) mutate(owner auth.APIKeyOwner, req dto.MCPCredentialMutation, change func(*gorm.DB, *model.MCPCredential) error) error {
|
||||
if _, err := uuid.Parse(req.ID); err != nil || req.Revision == 0 {
|
||||
return contract.Invalid()
|
||||
}
|
||||
mcpMutationMu.Lock()
|
||||
defer mcpMutationMu.Unlock()
|
||||
return s.DB.Transaction(func(tx *gorm.DB) error {
|
||||
var key model.MCPCredential
|
||||
if err := mcpOwnerQuery(tx, owner).Where("id = ?", req.ID).First(&key).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return contract.Forbidden()
|
||||
}
|
||||
return err
|
||||
}
|
||||
if key.Revision != req.Revision || key.Status == "Revoked" {
|
||||
return contract.Fail("MCP_CONFLICT", 409, "Credential changed; refresh and retry")
|
||||
}
|
||||
if err := change(tx, &key); err != nil {
|
||||
return err
|
||||
}
|
||||
if key.Status == "Revoked" {
|
||||
key.SecretCiphertext = ""
|
||||
key.SecretVersion = 0
|
||||
}
|
||||
key.Revision++
|
||||
result := tx.Model(&model.MCPCredential{}).Where("id = ? AND revision = ?", key.ID, req.Revision).Select("name", "active_name", "description", "node_ids", "status", "expires_at", "revision", "revoked_at", "secret_ciphertext", "secret_version", "token_hash", "key_hint").Updates(&key)
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected != 1 {
|
||||
return contract.Fail("MCP_CONFLICT", 409, "Credential changed; refresh and retry")
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
func (s *MCPService) Update(owner auth.APIKeyOwner, req dto.MCPCredentialUpdate) error {
|
||||
if _, err := uuid.Parse(req.ID); err != nil || req.Revision == 0 {
|
||||
return contract.Invalid()
|
||||
}
|
||||
grants, err := s.validateFields(owner, &req.MCPCredentialFields, true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.mutate(owner, req.MCPCredentialMutation, func(tx *gorm.DB, key *model.MCPCredential) error {
|
||||
var count int64
|
||||
if err := mcpOwnerQuery(tx.Model(&model.MCPCredential{}), owner).Where("active_name = ? AND id <> ?", req.Name, key.ID).Count(&count).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if count > 0 {
|
||||
return contract.Fail("MCP_CONFLICT", 409, "Credential name already exists")
|
||||
}
|
||||
key.Name = req.Name
|
||||
key.ActiveName = &req.Name
|
||||
key.Description = req.Description
|
||||
key.NodeIDs = mcpEncodeNodeIDs(req.NodeIDs)
|
||||
key.ExpiresAt = req.ExpiresAt
|
||||
return (repo.MCPRepo{DB: tx}).ReplaceTools(key.ID, grants)
|
||||
})
|
||||
}
|
||||
func (s *MCPService) Status(owner auth.APIKeyOwner, req dto.MCPCredentialStatus) error {
|
||||
if req.Status != "Enable" && req.Status != "Disable" {
|
||||
return contract.Invalid()
|
||||
}
|
||||
return s.mutate(owner, req.MCPCredentialMutation, func(tx *gorm.DB, key *model.MCPCredential) error {
|
||||
if req.Status == "Enable" {
|
||||
catalog, err := s.catalog()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
index := catalogIndex(catalog)
|
||||
grants, err := (repo.MCPRepo{DB: tx}).Grants(key.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
nodeIDs, err := mcpCredentialNodeIDs(*key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fields := dto.MCPCredentialFields{Name: key.Name, Description: key.Description, ExpiresAt: key.ExpiresAt, ToolPolicies: map[string]string{}, ToolReadOnly: map[string]bool{}, NodeIDs: nodeIDs}
|
||||
for _, grant := range grants {
|
||||
tool, exists := index[grant.ToolID]
|
||||
if !exists || !mcpGrantAllows(grant, tool) {
|
||||
return mcpPolicyChanged()
|
||||
}
|
||||
fields.Tools = append(fields.Tools, grant.ToolID)
|
||||
fields.ToolPolicies[grant.ToolID] = tool.PolicyHash
|
||||
fields.ToolReadOnly[grant.ToolID] = tool.ReadOnly
|
||||
}
|
||||
if _, err = s.validateFields(owner, &fields, true); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
key.Status = req.Status
|
||||
return nil
|
||||
})
|
||||
}
|
||||
func (s *MCPService) Revoke(owner auth.APIKeyOwner, req dto.MCPCredentialMutation) error {
|
||||
return s.mutate(owner, req, func(tx *gorm.DB, key *model.MCPCredential) error {
|
||||
now := s.Now()
|
||||
key.Status = "Revoked"
|
||||
key.RevokedAt = &now
|
||||
key.ActiveName = nil
|
||||
return (repo.MCPRepo{DB: tx}).ReplaceTools(key.ID, nil)
|
||||
})
|
||||
}
|
||||
func (s *MCPService) Authenticate(c *gin.Context, token string) (model.MCPCredential, auth.APIKeyOwner, error) {
|
||||
var key model.MCPCredential
|
||||
var owner auth.APIKeyOwner
|
||||
if len(token) != 68 || !strings.HasPrefix(token, "mcp_") {
|
||||
return key, owner, contract.Fail("MCP_UNAUTHENTICATED", 401, "Invalid MCP credential")
|
||||
}
|
||||
if _, err := hex.DecodeString(token[4:]); err != nil {
|
||||
return key, owner, contract.Fail("MCP_UNAUTHENTICATED", 401, "Invalid MCP credential")
|
||||
}
|
||||
var err error
|
||||
key, err = (repo.MCPRepo{DB: s.DB}).Token(tokenDigest(token))
|
||||
if err != nil {
|
||||
return key, owner, contract.Fail("MCP_UNAUTHENTICATED", 401, "Invalid MCP credential")
|
||||
}
|
||||
if key.Status != "Enable" || key.ExpiresAt != nil && !s.Now().Before(*key.ExpiresAt) {
|
||||
return key, owner, contract.Fail("MCP_UNAUTHENTICATED", 401, "MCP credential is inactive or expired")
|
||||
}
|
||||
if s.Provider == nil {
|
||||
return key, owner, contract.Forbidden()
|
||||
}
|
||||
owner, err = s.Provider.ResolveAPIKeyOwner(c, key.OwnerType, key.OwnerID)
|
||||
if err != nil {
|
||||
return key, owner, contract.Forbidden()
|
||||
}
|
||||
if s.Enabled == nil {
|
||||
return key, owner, contract.Fail("MCP_DISABLED", 503, "MCP access is disabled")
|
||||
}
|
||||
enabled, err := s.Enabled()
|
||||
if err != nil || !enabled {
|
||||
return key, owner, contract.Fail("MCP_DISABLED", 503, "MCP access is disabled")
|
||||
}
|
||||
return key, owner, nil
|
||||
}
|
||||
func (s *MCPService) RuntimeTools(c *gin.Context, token string) (map[string]any, error) {
|
||||
key, _, err := s.Authenticate(c, token)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
catalog, err := s.catalog()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
grants, err := (repo.MCPRepo{DB: s.DB}).Grants(key.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
policies := make(map[string]model.MCPGrant, len(grants))
|
||||
for _, grant := range grants {
|
||||
policies[grant.ToolID] = grant
|
||||
}
|
||||
latest, owner, err := s.Authenticate(c, token)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if latest.Revision != key.Revision {
|
||||
return nil, contract.Forbidden()
|
||||
}
|
||||
nodes, err := s.credentialNodes(latest, owner)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tools := []contract.Tool{}
|
||||
for _, tool := range catalog {
|
||||
if grant, exists := policies[tool.ToolID]; exists && mcpGrantAllows(grant, tool) && s.authorizeToolOnNodes(owner, mcpNodeItemIDs(nodes), tool) == nil {
|
||||
tools = append(tools, tool)
|
||||
}
|
||||
}
|
||||
return map[string]any{"contractVersion": contract.ContractVersion, "tools": tools}, nil
|
||||
}
|
||||
|
||||
func (s *MCPService) authorizeInvocation(key model.MCPCredential, owner auth.APIKeyOwner, req contract.Invoke) (contract.Tool, error) {
|
||||
catalog, err := s.catalog()
|
||||
if err != nil {
|
||||
return contract.Tool{}, err
|
||||
}
|
||||
tool, ok := catalogIndex(catalog)[req.ToolID]
|
||||
if !ok {
|
||||
return contract.Tool{}, contract.Fail("MCP_UNKNOWN_TOOL", 400, "Unknown tool")
|
||||
}
|
||||
if req.SchemaVersion != tool.SchemaVersion || req.PolicyHash == "" || req.PolicyHash != tool.PolicyHash {
|
||||
return contract.Tool{}, mcpPolicyChanged()
|
||||
}
|
||||
if !slices.ContainsFunc(tool.Operations, func(op contract.Operation) bool { return op.ID == req.OperationID }) {
|
||||
return contract.Tool{}, contract.Forbidden()
|
||||
}
|
||||
grants, err := (repo.MCPRepo{DB: s.DB}).Grants(key.ID)
|
||||
if err != nil {
|
||||
return contract.Tool{}, err
|
||||
}
|
||||
granted := false
|
||||
for _, grant := range grants {
|
||||
if grant.ToolID != tool.ToolID {
|
||||
continue
|
||||
}
|
||||
if !mcpGrantAllows(grant, tool) {
|
||||
return contract.Tool{}, mcpPolicyChanged()
|
||||
}
|
||||
granted = true
|
||||
break
|
||||
}
|
||||
if !granted {
|
||||
return contract.Tool{}, contract.Forbidden()
|
||||
}
|
||||
if err := s.authorizeInvocationNode(key, owner, req.NodeID, tool); err != nil {
|
||||
return contract.Tool{}, err
|
||||
}
|
||||
return tool, nil
|
||||
}
|
||||
func (s *MCPService) Invoke(c *gin.Context, token string, req contract.Invoke) (*contract.InvocationResult, error) {
|
||||
started := s.Now()
|
||||
nodeID, nodeErr := contract.NormalizeNodeID(req.NodeID)
|
||||
if nodeErr == nil {
|
||||
req.NodeID = nodeID
|
||||
}
|
||||
done, err := useMCPAdmission(token, req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer done()
|
||||
record := model.MCPInvocation{ID: uuid.NewString(), ToolID: safeMCPAuditID(req.ToolID), RequestID: safeMCPAuditID(req.RequestID), OperationID: safeMCPAuditID(req.OperationID), PolicyHash: safeMCPPolicyHash(req.PolicyHash), NodeID: safeMCPAuditID(req.NodeID), StartedAt: started, Status: "denied"}
|
||||
key, owner, callErr := s.Authenticate(c, token)
|
||||
if key.ID == "" {
|
||||
return nil, callErr
|
||||
}
|
||||
if key.ID != "" {
|
||||
record.CredentialID = key.ID
|
||||
record.CredentialName = key.Name
|
||||
record.OwnerType = key.OwnerType
|
||||
record.OwnerID = key.OwnerID
|
||||
record.OwnerName = key.OwnerName
|
||||
record.Revision = key.Revision
|
||||
}
|
||||
record.Status = "running"
|
||||
if err := s.DB.Create(&record).Error; err != nil {
|
||||
return nil, contract.Fail("MCP_EXECUTION_FAILED", 503, "Invocation audit is unavailable")
|
||||
}
|
||||
record.Status = "denied"
|
||||
finish := func(err error, result any) (*contract.InvocationResult, error) {
|
||||
now := s.Now()
|
||||
record.FinishedAt = &now
|
||||
record.DurationMs = now.Sub(started).Milliseconds()
|
||||
if err == nil {
|
||||
record.Status = "success"
|
||||
} else {
|
||||
var e *contract.Error
|
||||
if !errors.As(err, &e) {
|
||||
e = contract.Fail("MCP_EXECUTION_FAILED", 502, "Tool execution failed; inspect the panel invocation record before retrying")
|
||||
}
|
||||
record.Code = e.Code
|
||||
e.InvocationID = record.ID
|
||||
err = e
|
||||
}
|
||||
if auditErr := s.finishInvocationAudit(record); auditErr != nil {
|
||||
return nil, &contract.Error{Code: "MCP_EXECUTION_FAILED", Status: 503, Message: "Invocation outcome could not be recorded; do not retry a write automatically", InvocationID: record.ID}
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &contract.InvocationResult{InvocationID: record.ID, NodeID: req.NodeID, Result: result}, nil
|
||||
}
|
||||
if callErr != nil {
|
||||
return finish(callErr, nil)
|
||||
}
|
||||
if nodeErr != nil {
|
||||
return finish(contract.Invalid(), nil)
|
||||
}
|
||||
tool, err := s.authorizeInvocation(key, owner, req)
|
||||
if err != nil {
|
||||
return finish(err, nil)
|
||||
}
|
||||
operation, apiPath, arguments, err := contract.NormalizeOperation(tool, req)
|
||||
if err != nil {
|
||||
return finish(err, nil)
|
||||
}
|
||||
latest, latestOwner, err := s.Authenticate(c, token)
|
||||
if err != nil {
|
||||
return finish(err, nil)
|
||||
}
|
||||
if latest.Revision != key.Revision {
|
||||
return finish(contract.Forbidden(), nil)
|
||||
}
|
||||
currentTool, err := s.authorizeInvocation(latest, latestOwner, req)
|
||||
if err != nil {
|
||||
return finish(err, nil)
|
||||
}
|
||||
latest, latestOwner, err = s.Authenticate(c, token)
|
||||
if err != nil {
|
||||
return finish(err, nil)
|
||||
}
|
||||
if latest.Revision != key.Revision || s.authorizeInvocationNode(latest, latestOwner, req.NodeID, currentTool) != nil {
|
||||
return finish(contract.Forbidden(), nil)
|
||||
}
|
||||
discovery := mcpIsNodeDiscovery(currentTool)
|
||||
if !discovery && s.authorizeAPI(latestOwner, req.NodeID, operation.Method, apiPath) != nil {
|
||||
return finish(contract.Forbidden(), nil)
|
||||
}
|
||||
if !discovery && s.Execute == nil {
|
||||
return finish(contract.Fail("MCP_NODE_UNAVAILABLE", 503, "Selected node is unavailable"), nil)
|
||||
}
|
||||
requestContext := c.Request.Context()
|
||||
if requestContext.Err() != nil {
|
||||
return finish(contract.Fail("MCP_EXECUTION_FAILED", 499, "Request ended before execution"), nil)
|
||||
}
|
||||
if operation.Method != http.MethodGet {
|
||||
requestContext = context.WithoutCancel(requestContext)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(requestContext, 330*time.Second)
|
||||
defer cancel()
|
||||
enabled, enabledErr := s.Enabled()
|
||||
if enabledErr != nil || !enabled {
|
||||
return finish(contract.Fail("MCP_DISABLED", 503, "MCP access is disabled"), nil)
|
||||
}
|
||||
var admitted model.MCPCredential
|
||||
admissionErr := s.DB.Select("expires_at").
|
||||
Where("id = ? AND revision = ? AND status = ? AND revoked_at IS NULL", key.ID, key.Revision, "Enable").
|
||||
Where("EXISTS (SELECT 1 FROM mcp_settings WHERE id = ? AND enabled = ?)", 1, true).Take(&admitted).Error
|
||||
if errors.Is(admissionErr, gorm.ErrRecordNotFound) {
|
||||
return finish(contract.Forbidden(), nil)
|
||||
}
|
||||
if admissionErr != nil {
|
||||
return finish(contract.Fail("MCP_EXECUTION_FAILED", 503, "Credential admission is unavailable"), nil)
|
||||
}
|
||||
if admitted.ExpiresAt != nil && !s.Now().Before(*admitted.ExpiresAt) {
|
||||
return finish(contract.Forbidden(), nil)
|
||||
}
|
||||
record.Status = "failed"
|
||||
if discovery {
|
||||
nodes, err := s.credentialNodes(latest, latestOwner)
|
||||
if err != nil {
|
||||
return finish(err, nil)
|
||||
}
|
||||
return finish(nil, map[string]any{"items": nodes})
|
||||
}
|
||||
result, err := s.Execute(ctx, contract.APIRequest{InvocationID: record.ID, NodeID: req.NodeID, Owner: latestOwner, Method: operation.Method, Path: apiPath, Arguments: arguments, ResponseFields: operation.ResponseFields, ReadOnly: operation.Method == http.MethodGet})
|
||||
return finish(err, result)
|
||||
}
|
||||
func safeMCPPolicyHash(value string) string {
|
||||
data, err := hex.DecodeString(value)
|
||||
if err != nil || len(data) != sha256.Size {
|
||||
return ""
|
||||
}
|
||||
return strings.ToLower(value)
|
||||
}
|
||||
func safeMCPAuditID(value string) string {
|
||||
if len(value) > 128 {
|
||||
return "invalid"
|
||||
}
|
||||
for _, r := range value {
|
||||
if !(r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || strings.ContainsRune("_.:-", r)) {
|
||||
return "invalid"
|
||||
}
|
||||
}
|
||||
return value
|
||||
}
|
||||
func (s *MCPService) Invocations(owner auth.APIKeyOwner, req dto.MCPInvocationSearch) (map[string]any, error) {
|
||||
if !validMCPPage(req.Page, req.PageSize) {
|
||||
return nil, contract.Invalid()
|
||||
}
|
||||
q := mcpOwnerQuery(s.DB.Model(&model.MCPInvocation{}), owner)
|
||||
if req.CredentialID != "" {
|
||||
q = q.Where("credential_id = ?", req.CredentialID)
|
||||
}
|
||||
if req.ToolID != "" {
|
||||
q = q.Where("tool_id = ?", req.ToolID)
|
||||
}
|
||||
if req.Status != "" {
|
||||
if req.Status != "success" && req.Status != "denied" && req.Status != "failed" && req.Status != "running" {
|
||||
return nil, contract.Invalid()
|
||||
}
|
||||
q = q.Where("status = ?", req.Status)
|
||||
}
|
||||
var total int64
|
||||
if err := q.Count(&total).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items := []model.MCPInvocation{}
|
||||
if err := q.Order(mcpInvocationSecond + " DESC, " + mcpInvocationNanosecond + " DESC, id DESC").Offset((req.Page - 1) * req.PageSize).Limit(req.PageSize).Find(&items).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]any{"items": items, "total": total}, nil
|
||||
}
|
||||
|
||||
func MCPBearer(c *gin.Context) (string, error) {
|
||||
if auth.HasAPICredentials(c) || c.GetHeader("CurrentNode") != "" || c.GetHeader("X-Panel-User") != "" || c.Request.URL.RawQuery != "" {
|
||||
return "", contract.Invalid()
|
||||
}
|
||||
values := c.Request.Header.Values("Authorization")
|
||||
if len(values) != 1 {
|
||||
return "", contract.Fail("MCP_UNAUTHENTICATED", http.StatusUnauthorized, "MCP Bearer credential required")
|
||||
}
|
||||
parts := strings.Fields(values[0])
|
||||
if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") {
|
||||
return "", contract.Fail("MCP_UNAUTHENTICATED", 401, "MCP Bearer credential required")
|
||||
}
|
||||
return parts[1], nil
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
|
||||
"github.com/1Panel-dev/1Panel/core/app/dto"
|
||||
contract "github.com/1Panel-dev/1Panel/core/utils/mcp"
|
||||
)
|
||||
|
||||
func encodeMCPIPs(entries []string) string {
|
||||
data, _ := json.Marshal(entries)
|
||||
if entries == nil {
|
||||
return "[]"
|
||||
}
|
||||
return string(data)
|
||||
}
|
||||
func normalizeMCPAccessSettings(req *dto.MCPRuntimeSettings) error {
|
||||
entries, err := contract.NormalizeAllowedIPs(req.AllowedIPs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
switch req.AccessPolicy {
|
||||
case "inherit", "all":
|
||||
if len(entries) != 0 {
|
||||
return errors.New("independent MCP IP entries require restricted policy")
|
||||
}
|
||||
case "restricted":
|
||||
if len(entries) == 0 {
|
||||
return errors.New("MCP restricted policy requires at least one IP or CIDR")
|
||||
}
|
||||
default:
|
||||
return errors.New("invalid MCP access policy")
|
||||
}
|
||||
req.AllowedIPs = entries
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/1Panel-dev/1Panel/core/app/model"
|
||||
contract "github.com/1Panel-dev/1Panel/core/utils/mcp"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const mcpServiceCallsPerMinute = 240
|
||||
const mcpAdmissionLifetime = 350 * time.Second
|
||||
const mcpAdmissionMaxOperations = 64
|
||||
|
||||
type mcpAdmittedCall struct {
|
||||
credentialID, tokenHash, toolID, nodeID, policyHash, requestID string
|
||||
schemaVersion int
|
||||
expires time.Time
|
||||
operations int
|
||||
active, closed bool
|
||||
timer *time.Timer
|
||||
// Used only when the adapter fails after successful API sub-operations.
|
||||
outcome model.MCPInvocation
|
||||
}
|
||||
|
||||
var mcpAdmissions = struct {
|
||||
sync.Mutex
|
||||
calls map[string]*mcpAdmittedCall
|
||||
}{calls: map[string]*mcpAdmittedCall{}}
|
||||
var mcpGlobalCalls = struct {
|
||||
sync.Mutex
|
||||
start int64
|
||||
calls int
|
||||
}{}
|
||||
|
||||
func mcpRateError(message string, retry int) *contract.Error {
|
||||
err := contract.Fail("MCP_RATE_LIMITED", 429, message)
|
||||
if retry < 1 {
|
||||
retry = 1
|
||||
}
|
||||
err.RetryAfterSeconds = retry
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *MCPService) Admit(c *gin.Context, token string, req contract.Admission) (*contract.Admitted, error) {
|
||||
now := s.Now()
|
||||
nodeID, err := contract.NormalizeNodeID(req.NodeID)
|
||||
if err != nil || len(req.RequestID) > 128 || len(req.Arguments) > 64<<10 {
|
||||
return nil, contract.Invalid()
|
||||
}
|
||||
key, owner, err := s.Authenticate(c, token)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !admitMCPCall("admit:"+key.ID, now) {
|
||||
return nil, mcpRateError("Too many MCP admission attempts", 60)
|
||||
}
|
||||
_, err = contract.VerifyAccessProof(c.GetHeader(contract.AccessProofHeader), token, time.Now())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
catalog, err := s.catalog()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tool, found := catalogIndex(catalog)[req.ToolID]
|
||||
if !found {
|
||||
return nil, contract.Fail("MCP_UNKNOWN_TOOL", 400, "Unknown managed tool")
|
||||
}
|
||||
var args map[string]any
|
||||
if json.Unmarshal(req.Arguments, &args) != nil || args == nil || !contract.ValidateInput(tool.InputSchema, args) {
|
||||
return nil, contract.Invalid()
|
||||
}
|
||||
requestedNode := "local"
|
||||
if value, exists := args["nodeId"]; exists {
|
||||
text, ok := value.(string)
|
||||
if !ok {
|
||||
return nil, contract.Invalid()
|
||||
}
|
||||
requestedNode, err = contract.NormalizeNodeID(text)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if requestedNode != nodeID {
|
||||
return nil, contract.Forbidden()
|
||||
}
|
||||
check := contract.Invoke{ToolID: req.ToolID, NodeID: nodeID, SchemaVersion: req.SchemaVersion, PolicyHash: req.PolicyHash, OperationID: tool.Operations[0].ID}
|
||||
if _, err := s.authorizeInvocation(key, owner, check); err != nil {
|
||||
record := model.MCPInvocation{ID: newMCPAdmissionID(), CredentialID: key.ID, CredentialName: key.Name, OwnerType: key.OwnerType, OwnerID: key.OwnerID, OwnerName: key.OwnerName, ToolID: safeMCPAuditID(req.ToolID), NodeID: nodeID, RequestID: safeMCPAuditID(req.RequestID), PolicyHash: safeMCPPolicyHash(req.PolicyHash), Revision: key.Revision, Status: "denied", Code: "MCP_FORBIDDEN", StartedAt: now, FinishedAt: &now}
|
||||
if known, ok := err.(*contract.Error); ok {
|
||||
record.Code = known.Code
|
||||
}
|
||||
if auditErr := s.DB.Create(&record).Error; auditErr != nil {
|
||||
return nil, contract.Fail("MCP_EXECUTION_FAILED", 503, "Admission audit is unavailable")
|
||||
}
|
||||
if known, ok := err.(*contract.Error); ok {
|
||||
copy := *known
|
||||
copy.InvocationID = record.ID
|
||||
return nil, ©
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if !mcpIsNodeDiscovery(tool) {
|
||||
if err := s.authorizeToolOnNode(owner, nodeID, tool); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
select {
|
||||
case mcpExecutionSlots <- struct{}{}:
|
||||
default:
|
||||
return nil, mcpRateError("Too many concurrent MCP calls", 1)
|
||||
}
|
||||
release := true
|
||||
defer func() {
|
||||
if release {
|
||||
<-mcpExecutionSlots
|
||||
}
|
||||
}()
|
||||
mcpMutationMu.Lock()
|
||||
defer mcpMutationMu.Unlock()
|
||||
mcpGlobalCalls.Lock()
|
||||
defer mcpGlobalCalls.Unlock()
|
||||
now = s.Now()
|
||||
if now.Unix() >= mcpGlobalCalls.start+60 {
|
||||
mcpGlobalCalls.start = now.Unix()
|
||||
mcpGlobalCalls.calls = 0
|
||||
}
|
||||
if mcpGlobalCalls.calls >= mcpServiceCallsPerMinute {
|
||||
return nil, mcpRateError("MCP global call limit exceeded", int(mcpGlobalCalls.start+60-now.Unix()))
|
||||
}
|
||||
id, err := randomMCPAdmissionID()
|
||||
if err != nil {
|
||||
return nil, contract.Fail("MCP_EXECUTION_FAILED", 503, "Admission could not be created")
|
||||
}
|
||||
if err := s.validateMCPAdmission(key, now); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
entry := &mcpAdmittedCall{credentialID: key.ID, tokenHash: tokenDigest(token), toolID: req.ToolID, nodeID: nodeID, policyHash: req.PolicyHash, requestID: req.RequestID, schemaVersion: req.SchemaVersion, expires: time.Now().Add(mcpAdmissionLifetime)}
|
||||
entry.outcome = model.MCPInvocation{ID: newMCPAdmissionID(), CredentialID: key.ID, CredentialName: key.Name, OwnerType: key.OwnerType, OwnerID: key.OwnerID, OwnerName: key.OwnerName, ToolID: safeMCPAuditID(req.ToolID), NodeID: nodeID, RequestID: safeMCPAuditID(req.RequestID), OperationID: "tool.result", PolicyHash: safeMCPPolicyHash(req.PolicyHash), Revision: key.Revision, StartedAt: now}
|
||||
mcpAdmissions.Lock()
|
||||
mcpAdmissions.calls[id] = entry
|
||||
entry.timer = time.AfterFunc(mcpAdmissionLifetime, func() { closeMCPAdmission(id) })
|
||||
mcpAdmissions.Unlock()
|
||||
mcpGlobalCalls.calls++
|
||||
release = false
|
||||
return &contract.Admitted{AdmissionID: id, ExpiresAt: entry.expires}, nil
|
||||
}
|
||||
|
||||
func randomMCPAdmissionID() (string, error) {
|
||||
data := make([]byte, 32)
|
||||
if _, err := rand.Read(data); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(data), nil
|
||||
}
|
||||
func newMCPAdmissionID() string { return uuid.NewString() }
|
||||
|
||||
func (s *MCPService) validateMCPAdmission(key model.MCPCredential, now time.Time) error {
|
||||
return s.DB.Transaction(func(tx *gorm.DB) error {
|
||||
var latest model.MCPCredential
|
||||
if err := tx.Where("id = ? AND revision = ? AND status = ? AND revoked_at IS NULL", key.ID, key.Revision, "Enable").Take(&latest).Error; err != nil {
|
||||
return contract.Forbidden()
|
||||
}
|
||||
if latest.ExpiresAt != nil && !now.Before(*latest.ExpiresAt) {
|
||||
return contract.Forbidden()
|
||||
}
|
||||
var setting model.MCPSetting
|
||||
if err := tx.Select("enabled").Take(&setting, 1).Error; err != nil || !setting.Enabled {
|
||||
return contract.Fail("MCP_DISABLED", 503, "MCP access is disabled")
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func closeMCPAdmission(id string) {
|
||||
mcpAdmissions.Lock()
|
||||
defer mcpAdmissions.Unlock()
|
||||
entry := mcpAdmissions.calls[id]
|
||||
if entry == nil {
|
||||
return
|
||||
}
|
||||
closeMCPAdmissionLocked(id, entry)
|
||||
}
|
||||
|
||||
func closeMCPAdmissionLocked(id string, entry *mcpAdmittedCall) {
|
||||
entry.closed = true
|
||||
if entry.active {
|
||||
return
|
||||
}
|
||||
if entry.timer != nil {
|
||||
entry.timer.Stop()
|
||||
}
|
||||
delete(mcpAdmissions.calls, id)
|
||||
<-mcpExecutionSlots
|
||||
}
|
||||
|
||||
func (s *MCPService) Complete(token string, req contract.Completion) error {
|
||||
_, err := s.CompleteWithOutcome(token, req)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *MCPService) CompleteWithOutcome(token string, req contract.Completion) (*contract.Completed, error) {
|
||||
if len(req.AdmissionID) != 64 || len(req.ErrorCode) > 64 {
|
||||
return nil, contract.Invalid()
|
||||
}
|
||||
mcpAdmissions.Lock()
|
||||
entry := mcpAdmissions.calls[req.AdmissionID]
|
||||
if entry != nil && entry.tokenHash != tokenDigest(token) {
|
||||
mcpAdmissions.Unlock()
|
||||
return nil, contract.Forbidden()
|
||||
}
|
||||
var outcome *model.MCPInvocation
|
||||
if entry != nil && req.ErrorCode != "" {
|
||||
if entry.active {
|
||||
closeMCPAdmissionLocked(req.AdmissionID, entry)
|
||||
mcpAdmissions.Unlock()
|
||||
return nil, contract.Fail("MCP_CONFLICT", 409, "An API operation is still active")
|
||||
}
|
||||
record := entry.outcome
|
||||
outcome = &record
|
||||
}
|
||||
if entry != nil {
|
||||
closeMCPAdmissionLocked(req.AdmissionID, entry)
|
||||
}
|
||||
mcpAdmissions.Unlock()
|
||||
result := &contract.Completed{}
|
||||
if outcome != nil {
|
||||
now := s.Now()
|
||||
outcome.Status, outcome.Code = "failed", mcpCompletionErrorCode(req.ErrorCode)
|
||||
outcome.FinishedAt, outcome.DurationMs = &now, now.Sub(outcome.StartedAt).Milliseconds()
|
||||
if err := s.DB.Create(outcome).Error; err != nil {
|
||||
return nil, contract.Fail("MCP_AUDIT_UNAVAILABLE", 503, "Invocation outcome could not be recorded; do not retry a write automatically")
|
||||
}
|
||||
result.InvocationID = outcome.ID
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func mcpCompletionErrorCode(code string) string {
|
||||
switch code {
|
||||
case "MCP_CONFLICT", "MCP_INVALID_ARGUMENTS", "MCP_SCHEMA_MISMATCH", "MCP_FORBIDDEN", "MCP_EXECUTION_FAILED", "MCP_NODE_UNAVAILABLE", "MCP_DISABLED", "MCP_POLICY_CHANGED", "MCP_UNKNOWN_TOOL", "MCP_RATE_LIMITED", "MCP_CATALOG_UNAVAILABLE", "MCP_UNAUTHENTICATED":
|
||||
return code
|
||||
default:
|
||||
return "MCP_EXECUTION_FAILED"
|
||||
}
|
||||
}
|
||||
|
||||
func useMCPAdmission(token string, req contract.Invoke) (func(), error) {
|
||||
mcpAdmissions.Lock()
|
||||
entry := mcpAdmissions.calls[req.AdmissionID]
|
||||
if entry == nil || entry.closed || !time.Now().Before(entry.expires) || entry.active || entry.operations >= mcpAdmissionMaxOperations || entry.tokenHash != tokenDigest(token) || entry.toolID != req.ToolID || entry.nodeID != req.NodeID || entry.policyHash != req.PolicyHash || entry.schemaVersion != req.SchemaVersion || entry.requestID != req.RequestID {
|
||||
mcpAdmissions.Unlock()
|
||||
return nil, contract.Forbidden()
|
||||
}
|
||||
entry.active = true
|
||||
entry.operations++
|
||||
mcpAdmissions.Unlock()
|
||||
return func() {
|
||||
mcpAdmissions.Lock()
|
||||
entry.active = false
|
||||
if entry.closed {
|
||||
closeMCPAdmissionLocked(req.AdmissionID, entry)
|
||||
}
|
||||
mcpAdmissions.Unlock()
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/1Panel-dev/1Panel/core/app/auth"
|
||||
"github.com/1Panel-dev/1Panel/core/app/dto"
|
||||
"github.com/1Panel-dev/1Panel/core/app/model"
|
||||
paneli18n "github.com/1Panel-dev/1Panel/core/i18n"
|
||||
contract "github.com/1Panel-dev/1Panel/core/utils/mcp"
|
||||
"github.com/google/uuid"
|
||||
goi18n "github.com/nicksnyder/go-i18n/v2/i18n"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const mcpSecretVersion = 1
|
||||
|
||||
func (s *MCPService) secretAEAD() (cipher.AEAD, error) {
|
||||
unavailable := contract.Fail("MCP_SECRET_KEY_UNAVAILABLE", 503, "MCP credential encryption is unavailable")
|
||||
if s.SecretKey == nil {
|
||||
return nil, unavailable
|
||||
}
|
||||
master, err := s.SecretKey()
|
||||
if err != nil || master == "" {
|
||||
return nil, unavailable
|
||||
}
|
||||
derived := sha256.Sum256([]byte("1panel:mcp-token:v1:" + master))
|
||||
block, err := aes.NewCipher(derived[:])
|
||||
if err != nil {
|
||||
return nil, unavailable
|
||||
}
|
||||
return cipher.NewGCM(block)
|
||||
}
|
||||
|
||||
func (s *MCPService) sealCredentialToken(key *model.MCPCredential, token string) error {
|
||||
aead, err := s.secretAEAD()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
nonce := make([]byte, aead.NonceSize())
|
||||
if _, err = rand.Read(nonce); err != nil {
|
||||
return contract.Fail("MCP_SECRET_KEY_UNAVAILABLE", 503, "MCP credential encryption is unavailable")
|
||||
}
|
||||
key.SecretCiphertext = base64.RawStdEncoding.EncodeToString(aead.Seal(nonce, nonce, []byte(token), []byte("mcp-token:v1:"+key.ID)))
|
||||
key.SecretVersion = mcpSecretVersion
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *MCPService) openCredentialToken(key model.MCPCredential) (string, error) {
|
||||
invalid := contract.Fail("MCP_SECRET_INVALID", 409, "The stored MCP token cannot be decrypted; rotate the credential")
|
||||
if key.SecretVersion != mcpSecretVersion {
|
||||
return "", invalid
|
||||
}
|
||||
aead, err := s.secretAEAD()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
data, err := base64.RawStdEncoding.DecodeString(key.SecretCiphertext)
|
||||
if err != nil || len(data) < aead.NonceSize()+aead.Overhead() || len(data) > 256 {
|
||||
return "", invalid
|
||||
}
|
||||
plain, err := aead.Open(nil, data[:aead.NonceSize()], data[aead.NonceSize():], []byte("mcp-token:v1:"+key.ID))
|
||||
if err != nil {
|
||||
return "", invalid
|
||||
}
|
||||
token := string(plain)
|
||||
if len(token) != 68 || !strings.HasPrefix(token, "mcp_") || subtle.ConstantTimeCompare([]byte(tokenDigest(token)), []byte(key.TokenHash)) != 1 {
|
||||
return "", invalid
|
||||
}
|
||||
if _, err := hex.DecodeString(token[4:]); err != nil {
|
||||
return "", invalid
|
||||
}
|
||||
return token, nil
|
||||
}
|
||||
|
||||
func (s *MCPService) enrollmentKey(owner auth.APIKeyOwner, req dto.MCPCredentialMutation) (model.MCPCredential, error) {
|
||||
var key model.MCPCredential
|
||||
if _, err := uuid.Parse(req.ID); err != nil || req.Revision == 0 {
|
||||
return key, contract.Invalid()
|
||||
}
|
||||
if err := s.authorize(owner, "mcp_access_manage"); err != nil {
|
||||
return key, err
|
||||
}
|
||||
if err := mcpOwnerQuery(s.DB, owner).Where("id = ?", req.ID).First(&key).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return key, contract.Forbidden()
|
||||
}
|
||||
return key, err
|
||||
}
|
||||
if key.Revision != req.Revision {
|
||||
return key, contract.Fail("MCP_CONFLICT", 409, "Credential changed; refresh and retry")
|
||||
}
|
||||
if err := s.enrollmentActive(key, false); err != nil {
|
||||
return key, err
|
||||
}
|
||||
return key, nil
|
||||
}
|
||||
|
||||
func (s *MCPService) enrollmentActive(key model.MCPCredential, allowDisabled bool) error {
|
||||
active := key.Status == "Enable" || allowDisabled && key.Status == "Disable"
|
||||
if !active || key.RevokedAt != nil || key.ExpiresAt != nil && !s.Now().Before(*key.ExpiresAt) {
|
||||
return contract.Fail("MCP_CREDENTIAL_INACTIVE", 409, "The credential is inactive or expired")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *MCPService) Reveal(owner auth.APIKeyOwner, req dto.MCPCredentialMutation) (result *dto.MCPCredentialSecret, resultErr error) {
|
||||
defer func() {
|
||||
if err := s.auditEnrollment(s.DB, owner, "reveal", req, resultErr); err != nil {
|
||||
result, resultErr = nil, err
|
||||
}
|
||||
}()
|
||||
mcpMutationMu.Lock()
|
||||
defer mcpMutationMu.Unlock()
|
||||
key, err := s.enrollmentKey(owner, req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
token, err := s.openCredentialToken(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &dto.MCPCredentialSecret{ID: key.ID, Token: token}, nil
|
||||
}
|
||||
|
||||
func (s *MCPService) Rotate(owner auth.APIKeyOwner, req dto.MCPCredentialMutation) (result *dto.MCPCredentialCreated, resultErr error) {
|
||||
defer func() {
|
||||
if resultErr != nil {
|
||||
if err := s.auditEnrollment(s.DB, owner, "rotate", req, resultErr); err != nil {
|
||||
result, resultErr = nil, err
|
||||
}
|
||||
}
|
||||
}()
|
||||
if err := s.authorize(owner, "mcp_access_manage"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var token string
|
||||
var item dto.MCPCredentialItem
|
||||
catalog, _ := s.catalog()
|
||||
err := s.mutate(owner, req, func(tx *gorm.DB, key *model.MCPCredential) error {
|
||||
if err := s.authorize(owner, "mcp_access_manage"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.enrollmentActive(*key, true); err != nil {
|
||||
return err
|
||||
}
|
||||
secret := make([]byte, 32)
|
||||
if _, err := rand.Read(secret); err != nil {
|
||||
return contract.Fail("MCP_SECRET_KEY_UNAVAILABLE", 503, "MCP credential encryption is unavailable")
|
||||
}
|
||||
token = "mcp_" + hex.EncodeToString(secret)
|
||||
key.TokenHash = tokenDigest(token)
|
||||
key.KeyHint = token[:8] + "…" + token[len(token)-4:]
|
||||
if err := s.sealCredentialToken(key, token); err != nil {
|
||||
return err
|
||||
}
|
||||
view := *s
|
||||
view.DB = tx
|
||||
var err error
|
||||
item, err = view.credentialItem(*key, catalogIndex(catalog))
|
||||
item.Revision = key.Revision + 1
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.auditEnrollment(tx, owner, "rotate", dto.MCPCredentialMutation{ID: key.ID, Revision: item.Revision}, nil)
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &dto.MCPCredentialCreated{Item: item, Token: token}, nil
|
||||
}
|
||||
|
||||
func mcpDocumentEndpoint(endpoint string) bool {
|
||||
u, err := url.Parse(endpoint)
|
||||
return err == nil && len(endpoint) <= 2048 && !strings.ContainsAny(endpoint, "\r\n\x00") &&
|
||||
(u.Scheme == "https" || u.Scheme == "http") && u.Host != "" && u.User == nil && u.Path == "/mcp" &&
|
||||
u.RawQuery == "" && u.Fragment == "" && u.Opaque == ""
|
||||
}
|
||||
|
||||
func mcpDocumentJSON(value any) string {
|
||||
data, _ := json.MarshalIndent(value, "", " ")
|
||||
return strings.ReplaceAll(string(data), "`", "\\u0060")
|
||||
}
|
||||
|
||||
func mcpEnrollmentConfig(client, id, endpoint, token string) (string, string) {
|
||||
name := "1panel_" + strings.ReplaceAll(id, "-", "")
|
||||
if client == "codex" {
|
||||
urlJSON := mcpDocumentJSON(endpoint)
|
||||
authJSON := mcpDocumentJSON("Bearer " + token)
|
||||
return "toml", fmt.Sprintf("[mcp_servers.%s]\nurl = %s\nhttp_headers = { Authorization = %s }", name, urlJSON, authJSON)
|
||||
}
|
||||
return "json", mcpDocumentJSON(map[string]any{"mcpServers": map[string]any{name: map[string]any{
|
||||
"type": "http", "url": endpoint, "headers": map[string]string{"Authorization": "Bearer " + token},
|
||||
}}})
|
||||
}
|
||||
|
||||
func mcpEnrollmentDocument(locale, client, syntax, config, endpoint string) (string, error) {
|
||||
localizer, err := paneli18n.NewMCPDocumentLocalizer(locale)
|
||||
if err != nil {
|
||||
return "", contract.Fail("MCP_DOCUMENT_UNAVAILABLE", 500, "MCP connection document is unavailable")
|
||||
}
|
||||
clientKey := map[string]string{
|
||||
"codex": "MCPDocumentClientCodex", "claude": "MCPDocumentClientClaude", "generic": "MCPDocumentClientGeneric",
|
||||
}[client]
|
||||
keys := []string{
|
||||
"MCPDocumentTitle", "MCPDocumentIntro", "MCPDocumentTaskTitle", "MCPDocumentTask", clientKey,
|
||||
"MCPDocumentSteps", "MCPDocumentConfigTitle", "MCPDocumentNotes",
|
||||
}
|
||||
if strings.HasPrefix(endpoint, "http://") {
|
||||
keys = append(keys, "MCPDocumentHTTPWarning")
|
||||
}
|
||||
texts := make([]string, len(keys))
|
||||
for index, key := range keys {
|
||||
texts[index], err = localizer.Localize(&goi18n.LocalizeConfig{MessageID: key})
|
||||
if err != nil || texts[index] == "" {
|
||||
return "", contract.Fail("MCP_DOCUMENT_UNAVAILABLE", 500, "MCP connection document is unavailable")
|
||||
}
|
||||
}
|
||||
content := fmt.Sprintf("# %s\n\n%s\n\n## %s\n\n%s\n\n%s\n\n%s\n\n## %s\n\n```%s\n%s\n```\n\n%s\n",
|
||||
texts[0], texts[1], texts[2], texts[3], texts[4], texts[5], texts[6], syntax, config, texts[7])
|
||||
if len(texts) > 8 {
|
||||
content += "\n" + texts[8] + "\n"
|
||||
}
|
||||
return content, nil
|
||||
}
|
||||
|
||||
func (s *MCPService) ExportMarkdown(owner auth.APIKeyOwner, req dto.MCPEnrollmentExport, endpoint, locale string) (result *dto.MCPEnrollmentDocument, resultErr error) {
|
||||
defer func() {
|
||||
if err := s.auditEnrollment(s.DB, owner, "export", req.MCPCredentialMutation, resultErr); err != nil {
|
||||
result, resultErr = nil, err
|
||||
}
|
||||
}()
|
||||
if req.Client != "generic" && req.Client != "codex" && req.Client != "claude" {
|
||||
return nil, contract.Invalid()
|
||||
}
|
||||
if !mcpDocumentEndpoint(endpoint) {
|
||||
return nil, contract.Fail("MCP_ENDPOINT_UNAVAILABLE", 409, "Configure an MCP external endpoint before exporting")
|
||||
}
|
||||
mcpMutationMu.Lock()
|
||||
defer mcpMutationMu.Unlock()
|
||||
key, err := s.enrollmentKey(owner, req.MCPCredentialMutation)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
token, err := s.openCredentialToken(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
language, config := mcpEnrollmentConfig(req.Client, key.ID, endpoint, token)
|
||||
content, err := mcpEnrollmentDocument(locale, req.Client, language, config, endpoint)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &dto.MCPEnrollmentDocument{Filename: "1panel-mcp-" + key.ID + "-" + req.Client + ".md", Content: content}, nil
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user