mirror of
https://github.com/1Panel-dev/1Panel.git
synced 2026-10-28 16:00:18 +00:00
Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
50955e20b7 | ||
|
|
bd67614a53 | ||
|
|
d7355f3ccc | ||
|
|
14dd3135ec | ||
|
|
93ca724985 | ||
|
|
9481a7535d | ||
|
|
14be5b7870 | ||
|
|
c4b76a8e89 | ||
|
|
9295f4ef8a | ||
|
|
c925184bf6 | ||
|
|
87828b03ae | ||
|
|
e7b4146e1b | ||
|
|
ec998552b9 | ||
|
|
1af237944d |
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -386,6 +386,7 @@ type AgentAccountProviderCountReq struct {
|
||||
}
|
||||
|
||||
type AgentAccountInfo struct {
|
||||
AppInstallID uint `json:"appInstallId"`
|
||||
ID uint `json:"id"`
|
||||
MasterAccountID uint `json:"masterAccountId"`
|
||||
Provider string `json:"provider"`
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -1055,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
|
||||
}
|
||||
@@ -1108,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
|
||||
}
|
||||
@@ -1127,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()
|
||||
@@ -1158,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,
|
||||
@@ -1362,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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
+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 {
|
||||
|
||||
@@ -499,6 +499,22 @@ func (a AppService) installWithHooks(req request.AppInstallCreate, executeScript
|
||||
req.Params[constant.ContainerName] = containerName
|
||||
appInstall.ContainerName = containerName
|
||||
|
||||
modelAccountBaseURL := ""
|
||||
if app.Key == "ollama" {
|
||||
appInstall.SyncModelAccount = req.SyncModelAccount == nil || *req.SyncModelAccount
|
||||
appInstall.ModelAccountBaseURLType = req.ModelAccountBaseURLType
|
||||
if appInstall.ModelAccountBaseURLType == "" {
|
||||
appInstall.ModelAccountBaseURLType = "container"
|
||||
}
|
||||
appInstall.ModelAccountBaseURL = req.ModelAccountBaseURL
|
||||
}
|
||||
if app.Key == "ollama" && appInstall.SyncModelAccount {
|
||||
modelAccountBaseURL, err = ResolveLocalModelAccountBaseURL(req.ModelAccountBaseURLType, req.ModelAccountBaseURL, containerName, "11434", httpPort)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
index := 0
|
||||
serviceName := ""
|
||||
for k := range servicesMap {
|
||||
@@ -609,6 +625,10 @@ func (a AppService) installWithHooks(req request.AppInstallCreate, executeScript
|
||||
return err
|
||||
}
|
||||
updateToolApp(appInstall)
|
||||
if app.Key == "ollama" && appInstall.SyncModelAccount {
|
||||
_, err := SyncLocalModelAccount(*appInstall, 0, modelAccountBaseURL, nil, false, true)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -934,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
|
||||
})
|
||||
}
|
||||
@@ -469,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 {
|
||||
@@ -2015,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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
@@ -334,7 +340,7 @@ func (w WebsiteCAService) ObtainSSL(req request.WebsiteCAObtain) (*model.Website
|
||||
websiteSSL.ExpireDate = cert.NotAfter
|
||||
websiteSSL.StartDate = cert.NotBefore
|
||||
websiteSSL.Type = cert.Issuer.CommonName
|
||||
websiteSSL.Organization = rootCsr.Subject.Organization[0]
|
||||
websiteSSL.Organization = strings.Join(rootCsr.Subject.Organization, ",")
|
||||
websiteSSL.Status = constant.SSLReady
|
||||
|
||||
if req.Renew {
|
||||
@@ -442,3 +448,150 @@ func (w WebsiteCAService) DownloadFile(id uint) (*os.File, error) {
|
||||
}
|
||||
return os.Open(path.Join(dir, fileName))
|
||||
}
|
||||
|
||||
func (w WebsiteCAService) Upload(name string, archive []byte) error {
|
||||
ca, err := parseCAArchive(archive)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return saveImportedCA(name, ca)
|
||||
}
|
||||
|
||||
func saveImportedCA(name string, ca *model.WebsiteCA) error {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" || len(name) > 128 || name == "." || name == ".." || strings.ContainsAny(name, "/\\\x00") {
|
||||
return buserr.WithDetail("ErrInvalidParams", "name", nil)
|
||||
}
|
||||
exist, err := websiteCARepo.GetFirst(repo.WithByName(name))
|
||||
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return err
|
||||
}
|
||||
if exist.ID > 0 {
|
||||
return buserr.New("ErrNameIsExist")
|
||||
}
|
||||
ca.Name = name
|
||||
return websiteCARepo.Create(context.Background(), ca)
|
||||
}
|
||||
|
||||
// Read only the certificate and key in memory; never extract user-supplied paths.
|
||||
func parseCAArchive(archive []byte) (*model.WebsiteCA, error) {
|
||||
invalid := func() (*model.WebsiteCA, error) { return nil, buserr.New("ErrCAUpload") }
|
||||
if len(archive) > 10<<20 {
|
||||
return invalid()
|
||||
}
|
||||
reader, err := zip.NewReader(bytes.NewReader(archive), int64(len(archive)))
|
||||
if err != nil || len(reader.File) > 16 {
|
||||
return invalid()
|
||||
}
|
||||
contents := make(map[string][]byte)
|
||||
for _, file := range reader.File {
|
||||
if file.FileInfo().IsDir() {
|
||||
continue
|
||||
}
|
||||
name := path.Base(file.Name)
|
||||
if (name != "ca.crt" && name != "ca.key") || contents[name] != nil || file.UncompressedSize64 > 1<<20 {
|
||||
return invalid()
|
||||
}
|
||||
stream, err := file.Open()
|
||||
if err != nil {
|
||||
return invalid()
|
||||
}
|
||||
data, err := io.ReadAll(io.LimitReader(stream, (1<<20)+1))
|
||||
stream.Close()
|
||||
if err != nil || len(data) > 1<<20 {
|
||||
return invalid()
|
||||
}
|
||||
contents[name] = data
|
||||
}
|
||||
return parseAndValidateRootCA(contents["ca.crt"], contents["ca.key"])
|
||||
}
|
||||
|
||||
func parseAndValidateRootCA(certificate, privateKey []byte) (*model.WebsiteCA, error) {
|
||||
invalid := func() (*model.WebsiteCA, error) { return nil, buserr.New("ErrCAImport") }
|
||||
if len(certificate) > 1<<20 || len(privateKey) > 1<<20 {
|
||||
return invalid()
|
||||
}
|
||||
pair, err := tls.X509KeyPair(certificate, privateKey)
|
||||
if err != nil || len(pair.Certificate) != 1 {
|
||||
return invalid()
|
||||
}
|
||||
cert := pair.Leaf
|
||||
now := time.Now()
|
||||
if cert == nil || !cert.IsCA || !cert.BasicConstraintsValid || cert.KeyUsage&x509.KeyUsageCertSign == 0 ||
|
||||
!bytes.Equal(cert.RawIssuer, cert.RawSubject) || cert.CheckSignatureFrom(cert) != nil ||
|
||||
now.Before(cert.NotBefore) || !now.Before(cert.NotAfter) {
|
||||
return invalid()
|
||||
}
|
||||
ca := &model.WebsiteCA{CSR: string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: cert.Raw}))}
|
||||
// Normalize keys to the formats used by ObtainSSL, including PKCS#8 uploads.
|
||||
switch key := pair.PrivateKey.(type) {
|
||||
case *ecdsa.PrivateKey:
|
||||
bits := key.Curve.Params().BitSize
|
||||
if bits != 256 && bits != 384 {
|
||||
return invalid()
|
||||
}
|
||||
der, err := x509.MarshalECPrivateKey(key)
|
||||
if err != nil {
|
||||
return invalid()
|
||||
}
|
||||
ca.KeyType = fmt.Sprintf("EC%d", bits)
|
||||
ca.PrivateKey = string(pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: der}))
|
||||
case *rsa.PrivateKey:
|
||||
bits := key.N.BitLen()
|
||||
if bits != 2048 && bits != 3072 && bits != 4096 && bits != 8192 {
|
||||
return invalid()
|
||||
}
|
||||
ca.KeyType = fmt.Sprintf("RSA%d", bits)
|
||||
ca.PrivateKey = string(pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)}))
|
||||
default:
|
||||
return invalid()
|
||||
}
|
||||
return ca, nil
|
||||
}
|
||||
|
||||
func (w WebsiteCAService) Import(req request.WebsiteCAImport) error {
|
||||
certificate, privateKey := []byte(req.Certificate), []byte(req.PrivateKey)
|
||||
switch req.Type {
|
||||
case "local":
|
||||
var err error
|
||||
certificate, err = readCAImportFile(req.CertificatePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
privateKey, err = readCAImportFile(req.PrivateKeyPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
case "paste":
|
||||
default:
|
||||
return buserr.New("ErrCAImport")
|
||||
}
|
||||
ca, err := parseAndValidateRootCA(certificate, privateKey)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return saveImportedCA(req.Name, ca)
|
||||
}
|
||||
|
||||
func readCAImportFile(name string) ([]byte, error) {
|
||||
info, err := os.Stat(name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !info.Mode().IsRegular() || info.Size() > 1<<20 {
|
||||
return nil, buserr.New("ErrCAImport")
|
||||
}
|
||||
file, err := os.Open(name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer file.Close()
|
||||
content, err := io.ReadAll(io.LimitReader(file, (1<<20)+1))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(content) > 1<<20 {
|
||||
return nil, buserr.New("ErrCAImport")
|
||||
}
|
||||
return content, nil
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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。"
|
||||
|
||||
@@ -117,6 +117,8 @@ func agentDBMigrations() []*gormigrate.Migration {
|
||||
migrations.AddVLLMSampleInterval,
|
||||
migrations.AddNativeAgentInstalls,
|
||||
migrations.AddQuickDirectory,
|
||||
migrations.AddLocalModelAccountLink,
|
||||
migrations.AddOllamaModelAccountSync,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1895,3 +1895,47 @@ var AddQuickDirectory = &gormigrate.Migration{
|
||||
return tx.AutoMigrate(&model.QuickDirectory{})
|
||||
},
|
||||
}
|
||||
|
||||
var AddLocalModelAccountLink = &gormigrate.Migration{
|
||||
ID: "20261023-add-local-model-account-link",
|
||||
Migrate: func(tx *gorm.DB) error {
|
||||
return tx.AutoMigrate(&model.AgentAccount{})
|
||||
},
|
||||
}
|
||||
|
||||
var AddOllamaModelAccountSync = &gormigrate.Migration{
|
||||
ID: "20261026-add-ollama-model-account-sync",
|
||||
Migrate: func(tx *gorm.DB) error {
|
||||
if err := tx.AutoMigrate(&model.AppInstall{}); err != nil {
|
||||
return err
|
||||
}
|
||||
var accounts []model.AgentAccount
|
||||
if err := tx.Where("provider = ? AND app_install_id > 0", "ollama").Find(&accounts).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
for _, account := range accounts {
|
||||
var install model.AppInstall
|
||||
if err := tx.Preload("App").First(&install, account.AppInstallID).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
continue
|
||||
}
|
||||
return err
|
||||
}
|
||||
if install.App.Key != "ollama" {
|
||||
continue
|
||||
}
|
||||
addressType := "custom"
|
||||
if strings.TrimRight(account.BaseURL, "/") == fmt.Sprintf("http://%s:11434/v1", install.ContainerName) {
|
||||
addressType = "container"
|
||||
}
|
||||
if err := tx.Model(&model.AppInstall{}).Where("id = ?", account.AppInstallID).Updates(map[string]interface{}{
|
||||
"sync_model_account": true,
|
||||
"model_account_base_url_type": addressType,
|
||||
"model_account_base_url": account.BaseURL,
|
||||
}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ func (a *AIToolsRouter) InitRouter(Router *gin.RouterGroup) {
|
||||
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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
|
||||
"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/constant"
|
||||
contract "github.com/1Panel-dev/1Panel/core/utils/mcp"
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func mcpEnrollmentAuditCode(err error) string {
|
||||
if err == nil {
|
||||
return "MCP_OK"
|
||||
}
|
||||
var known *contract.Error
|
||||
if errors.As(err, &known) {
|
||||
switch known.Code {
|
||||
case "MCP_FORBIDDEN", "MCP_CONFLICT", "MCP_INVALID_ARGUMENTS", "MCP_SECRET_INVALID", "MCP_SECRET_KEY_UNAVAILABLE", "MCP_CREDENTIAL_INACTIVE", "MCP_ENDPOINT_UNAVAILABLE", "MCP_POLICY_CHANGED", "MCP_CATALOG_UNAVAILABLE", "MCP_AUDIT_UNAVAILABLE":
|
||||
return known.Code
|
||||
}
|
||||
}
|
||||
return "MCP_ENROLLMENT_FAILED"
|
||||
}
|
||||
|
||||
func (s *MCPService) auditEnrollment(db *gorm.DB, owner auth.APIKeyOwner, action string, req dto.MCPCredentialMutation, outcome error) error {
|
||||
failed := contract.Fail("MCP_AUDIT_UNAVAILABLE", 503, "Credential access could not be audited; no secret was returned")
|
||||
if action != "create" && action != "reveal" && action != "rotate" && action != "export" {
|
||||
return failed
|
||||
}
|
||||
id := "invalid"
|
||||
if parsed, err := uuid.Parse(req.ID); err == nil {
|
||||
id = parsed.String()
|
||||
}
|
||||
status := constant.StatusSuccess
|
||||
if outcome != nil {
|
||||
status = constant.StatusFailed
|
||||
}
|
||||
metadata := struct {
|
||||
OwnerType string `json:"ownerType"`
|
||||
OwnerID string `json:"ownerId"`
|
||||
Action string `json:"action"`
|
||||
CredentialID string `json:"credentialId"`
|
||||
Revision uint64 `json:"revision"`
|
||||
Outcome string `json:"outcome"`
|
||||
Code string `json:"code"`
|
||||
}{owner.Type, owner.ID, action, id, req.Revision, status, mcpEnrollmentAuditCode(outcome)}
|
||||
detail, _ := json.Marshal(metadata)
|
||||
record := &model.OperationLog{Source: "mcp", User: owner.Name, AuthMethod: "session", Node: "local", Path: "/core/mcp/credentials/" + action, Method: "post", Status: status, Message: metadata.Code, DetailEN: string(detail), DetailZH: string(detail)}
|
||||
if db == nil {
|
||||
return failed
|
||||
}
|
||||
var err error
|
||||
if s.EnrollmentAudit != nil {
|
||||
err = s.EnrollmentAudit(db, record)
|
||||
} else {
|
||||
err = db.Create(record).Error
|
||||
}
|
||||
if err != nil {
|
||||
return failed
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
contract "github.com/1Panel-dev/1Panel/core/utils/mcp"
|
||||
"github.com/1Panel-dev/1Panel/core/utils/mcpapi"
|
||||
"github.com/1Panel-dev/1Panel/core/utils/xpack"
|
||||
"github.com/1Panel-dev/1Panel/core/utils/xpack/providers"
|
||||
)
|
||||
|
||||
func executeMCPAPI(ctx context.Context, req contract.APIRequest) (any, error) {
|
||||
result, err := mcpapi.Dispatch(ctx, mcpapi.Request{InvocationID: req.InvocationID, NodeID: req.NodeID, Owner: req.Owner, Method: req.Method, Path: req.Path, Arguments: req.Arguments})
|
||||
if err != nil {
|
||||
if message := mcpapi.SafeReadErrorMessage(err); message != "" {
|
||||
if mcpapi.IsSystemReadTransportFailure(err) && ctx.Err() == nil {
|
||||
if provider, ok := xpack.AuthProvider.(providers.MCPNodeProvider); ok {
|
||||
node, nodeErr := provider.ResolveMCPNode(req.Owner, req.NodeID)
|
||||
if nodeErr == nil && node.ID == req.NodeID && node.Name != "" && node.IsLocal == (req.NodeID == "local") && node.IsLocal == (node.Name == "local") {
|
||||
switch strings.ToLower(node.Status) {
|
||||
case "unhealthy", "lost", "failed":
|
||||
message = mcpapi.NodeAbnormalMessage
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil, contract.Fail("MCP_EXECUTION_FAILED", 502, message)
|
||||
}
|
||||
if message := mcpapi.SafeBusinessErrorMessage(err); message != "" {
|
||||
return nil, contract.Fail("MCP_EXECUTION_FAILED", 502, message)
|
||||
}
|
||||
return nil, contract.Fail("MCP_EXECUTION_FAILED", 502, "Panel API execution failed; inspect the invocation before retrying")
|
||||
}
|
||||
projected, err := projectMCPAPIResult(req, result)
|
||||
if err != nil {
|
||||
return nil, contract.Fail("MCP_EXECUTION_FAILED", 502, "Panel API returned an incompatible result")
|
||||
}
|
||||
return projected, nil
|
||||
}
|
||||
|
||||
func projectMCPAPIResult(req contract.APIRequest, result any) (any, error) {
|
||||
if req.Method == http.MethodGet && req.Path == "/api/v2/settings/website/dir" {
|
||||
directory, ok := result.(string)
|
||||
if !ok || !path.IsAbs(directory) || len(directory) > 4096 || strings.ContainsAny(directory, "\x00\r\n") {
|
||||
return nil, errors.New("unexpected website directory result")
|
||||
}
|
||||
result = map[string]any{"websiteDir": directory}
|
||||
}
|
||||
return contract.ProjectResult(result, req.ResponseFields)
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/1Panel-dev/1Panel/core/app/model"
|
||||
)
|
||||
|
||||
// SQLite stores time.Time with the server's offset. Compare absolute seconds
|
||||
// and integer nanoseconds so offset changes and repeated local hours remain
|
||||
// chronological without changing the stored timestamps or losing precision.
|
||||
const mcpInvocationSecond = "CAST(strftime('%s', substr(started_at, 1, 19) || substr(started_at, -6)) AS INTEGER)"
|
||||
const mcpInvocationNanosecond = "CASE WHEN substr(started_at, 20, 1) = '.' THEN CAST(substr(substr(started_at, 21, length(started_at) - 26) || '000000000', 1, 9) AS INTEGER) ELSE 0 END"
|
||||
|
||||
func (s *MCPService) finishInvocationAudit(record model.MCPInvocation) error {
|
||||
var err error
|
||||
for attempt := 0; attempt < 3; attempt++ {
|
||||
if attempt > 0 {
|
||||
time.Sleep(time.Duration(attempt) * 50 * time.Millisecond)
|
||||
}
|
||||
// Only compensate the audit write. The business operation must never be replayed.
|
||||
err = s.DB.Model(&model.MCPInvocation{}).Where("id = ? AND status = ?", record.ID, "running").
|
||||
Updates(map[string]any{"status": record.Status, "code": record.Code, "finished_at": record.FinishedAt, "duration_ms": record.DurationMs}).Error
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// RecoverInterruptedInvocations runs before any MCP ingress starts. The fixed
|
||||
// process-start boundary prevents a later invocation from being marked interrupted.
|
||||
func (s *MCPService) RecoverInterruptedInvocations(processStartedAt time.Time) error {
|
||||
now := s.Now()
|
||||
return s.DB.Model(&model.MCPInvocation{}).
|
||||
Where("status = ? AND finished_at IS NULL", "running").
|
||||
Where("("+mcpInvocationSecond+" < ? OR ("+mcpInvocationSecond+" = ? AND "+mcpInvocationNanosecond+" < ?))", processStartedAt.Unix(), processStartedAt.Unix(), processStartedAt.Nanosecond()).
|
||||
Updates(map[string]any{"status": "failed", "code": "MCP_OUTCOME_UNKNOWN", "finished_at": now}).Error
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"slices"
|
||||
|
||||
"github.com/1Panel-dev/1Panel/core/app/auth"
|
||||
"github.com/1Panel-dev/1Panel/core/app/dto"
|
||||
"github.com/1Panel-dev/1Panel/core/app/model"
|
||||
contract "github.com/1Panel-dev/1Panel/core/utils/mcp"
|
||||
)
|
||||
|
||||
func (s *MCPService) Nodes(owner auth.APIKeyOwner) ([]dto.MCPNodeItem, error) {
|
||||
if s.NodeProvider == nil {
|
||||
return nil, contract.Forbidden()
|
||||
}
|
||||
nodes, err := s.NodeProvider.ListMCPNodes(owner)
|
||||
if err != nil {
|
||||
return nil, contract.Forbidden()
|
||||
}
|
||||
items := make([]dto.MCPNodeItem, 0, len(nodes))
|
||||
seen := make(map[string]bool, len(nodes))
|
||||
for _, node := range nodes {
|
||||
if !contract.ValidateNodeID(node.ID) || node.IsLocal != (node.ID == "local") || seen[node.ID] {
|
||||
return nil, contract.Forbidden()
|
||||
}
|
||||
seen[node.ID] = true
|
||||
items = append(items, dto.MCPNodeItem{ID: node.ID, Name: node.Name, Status: node.Status, IsLocal: node.IsLocal})
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func mcpNodeItemIDs(nodes []dto.MCPNodeItem) []string {
|
||||
ids := make([]string, 0, len(nodes))
|
||||
for _, node := range nodes {
|
||||
ids = append(ids, node.ID)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
func mcpEncodeNodeIDs(ids []string) string {
|
||||
data, _ := json.Marshal(ids)
|
||||
return string(data)
|
||||
}
|
||||
|
||||
func mcpCredentialNodeIDs(key model.MCPCredential) ([]string, error) {
|
||||
var ids []string
|
||||
if json.Unmarshal([]byte(key.NodeIDs), &ids) != nil || len(ids) == 0 || len(ids) > 256 {
|
||||
return nil, contract.Forbidden()
|
||||
}
|
||||
for _, id := range ids {
|
||||
if !contract.ValidateNodeID(id) {
|
||||
return nil, contract.Forbidden()
|
||||
}
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
func (s *MCPService) validateNodeIDs(owner auth.APIKeyOwner, ids *[]string) error {
|
||||
if len(*ids) == 0 || len(*ids) > 256 {
|
||||
return contract.Invalid()
|
||||
}
|
||||
for _, id := range *ids {
|
||||
if !contract.ValidateNodeID(id) {
|
||||
return contract.Invalid()
|
||||
}
|
||||
}
|
||||
slices.Sort(*ids)
|
||||
*ids = slices.Compact(*ids)
|
||||
nodes, err := s.Nodes(owner)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
available := mcpNodeItemIDs(nodes)
|
||||
for _, id := range *ids {
|
||||
if !slices.Contains(available, id) {
|
||||
return contract.Forbidden()
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *MCPService) credentialNodes(key model.MCPCredential, owner auth.APIKeyOwner) ([]dto.MCPNodeItem, error) {
|
||||
ids, err := mcpCredentialNodeIDs(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
nodes, err := s.Nodes(owner)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
allowed := make([]dto.MCPNodeItem, 0, len(nodes))
|
||||
for _, node := range nodes {
|
||||
if slices.Contains(ids, node.ID) {
|
||||
allowed = append(allowed, node)
|
||||
}
|
||||
}
|
||||
return allowed, nil
|
||||
}
|
||||
|
||||
func mcpIsNodeDiscovery(tool contract.Tool) bool {
|
||||
return tool.ToolID == "list_nodes" && tool.ReadOnly && len(tool.Operations) == 1 &&
|
||||
tool.Operations[0].ID == "nodes" && tool.Operations[0].Method == http.MethodGet &&
|
||||
tool.Operations[0].Path == contract.NodesPath
|
||||
}
|
||||
|
||||
func (s *MCPService) authorizeToolOnNodes(owner auth.APIKeyOwner, nodeIDs []string, tool contract.Tool) error {
|
||||
if mcpIsNodeDiscovery(tool) {
|
||||
return nil
|
||||
}
|
||||
for _, nodeID := range nodeIDs {
|
||||
if s.authorizeToolOnNode(owner, nodeID, tool) == nil {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return contract.Forbidden()
|
||||
}
|
||||
|
||||
func (s *MCPService) authorizeInvocationNode(key model.MCPCredential, owner auth.APIKeyOwner, nodeID string, tool contract.Tool) error {
|
||||
if mcpIsNodeDiscovery(tool) {
|
||||
if nodeID != "local" {
|
||||
return contract.Forbidden()
|
||||
}
|
||||
_, err := s.credentialNodes(key, owner)
|
||||
return err
|
||||
}
|
||||
nodes, err := s.credentialNodes(key, owner)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !slices.Contains(mcpNodeItemIDs(nodes), nodeID) {
|
||||
return contract.Forbidden()
|
||||
}
|
||||
return s.authorizeToolOnNode(owner, nodeID, tool)
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"reflect"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/1Panel-dev/1Panel/core/app/dto"
|
||||
"github.com/1Panel-dev/1Panel/core/app/model"
|
||||
"github.com/1Panel-dev/1Panel/core/global"
|
||||
contract "github.com/1Panel-dev/1Panel/core/utils/mcp"
|
||||
"github.com/1Panel-dev/1Panel/core/utils/mcpruntime"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
var mcpRuntimeMutation sync.Mutex
|
||||
var ErrMCPSettingsRevision = errors.New("MCP settings changed; reload the page before saving")
|
||||
|
||||
type mcpRuntimeManager interface {
|
||||
Prepare(context.Context) error
|
||||
Catalog(context.Context) ([]contract.Tool, error)
|
||||
Status(context.Context, bool) mcpruntime.State
|
||||
Apply(context.Context, bool, bool) error
|
||||
InstallImage(context.Context, bool) error
|
||||
Restore(context.Context, string, bool) error
|
||||
RestoreAbsent(context.Context) error
|
||||
Logs(context.Context) (string, error)
|
||||
}
|
||||
|
||||
type mcpRuntimeSnapshot struct {
|
||||
settings dto.MCPRuntimeSettings
|
||||
imageID string
|
||||
installed bool
|
||||
unavailable bool
|
||||
}
|
||||
|
||||
func (s *MCPRuntimeService) snapshot(ctx context.Context, settings dto.MCPRuntimeSettings) (mcpRuntimeSnapshot, error) {
|
||||
snapshot := mcpRuntimeSnapshot{settings: settings}
|
||||
state := s.manager(settings).Status(ctx, settings.Enabled)
|
||||
switch state.Status {
|
||||
case "not_installed", "stopped", "running", "unhealthy":
|
||||
default:
|
||||
snapshot.unavailable = true
|
||||
return snapshot, errors.New("cannot determine the existing MCP Docker instance; retry before changing it")
|
||||
}
|
||||
snapshot.installed = state.Installed
|
||||
if state.Installed {
|
||||
if state.AdapterVersion == "" || mcpruntime.ValidateDockerImage(mcpruntime.DockerImageRepository+"@"+state.ImageID) != nil {
|
||||
snapshot.unavailable = true
|
||||
return snapshot, errors.New("the existing MCP Docker instance has invalid metadata or image identity")
|
||||
}
|
||||
snapshot.imageID = state.ImageID
|
||||
} else if state.Status != "not_installed" {
|
||||
snapshot.unavailable = true
|
||||
return snapshot, errors.New("cannot determine the existing MCP Docker installation")
|
||||
}
|
||||
return snapshot, nil
|
||||
}
|
||||
|
||||
type MCPRuntimeService struct {
|
||||
newRuntimeManager func(dto.MCPRuntimeSettings) mcpRuntimeManager
|
||||
}
|
||||
|
||||
func (s *MCPRuntimeService) manager(settings dto.MCPRuntimeSettings) mcpRuntimeManager {
|
||||
if s.newRuntimeManager != nil {
|
||||
return s.newRuntimeManager(settings)
|
||||
}
|
||||
return mcpruntime.NewDocker(settings.ListenPort, settings.DockerImage)
|
||||
}
|
||||
|
||||
func NewMCPRuntimeService() *MCPRuntimeService { return &MCPRuntimeService{} }
|
||||
|
||||
func (s *MCPRuntimeService) PrepareOnStartup(ctx context.Context) error {
|
||||
mcpRuntimeMutation.Lock()
|
||||
defer mcpRuntimeMutation.Unlock()
|
||||
settings, err := s.Settings()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.manager(settings).Prepare(ctx)
|
||||
}
|
||||
|
||||
func (s *MCPRuntimeService) Settings() (dto.MCPRuntimeSettings, error) {
|
||||
return s.settings(context.Background())
|
||||
}
|
||||
|
||||
func (s *MCPRuntimeService) settings(ctx context.Context) (dto.MCPRuntimeSettings, error) {
|
||||
settings := dto.MCPRuntimeSettings{
|
||||
RuntimeType: "docker", DockerImage: mcpruntime.DefaultDockerImage,
|
||||
ListenPort: mcpruntime.DefaultPort, Revision: 1,
|
||||
AccessPolicy: "inherit", AllowedIPs: []string{},
|
||||
}
|
||||
var saved model.MCPSetting
|
||||
err := global.DB.WithContext(ctx).First(&saved, 1).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return settings, nil
|
||||
}
|
||||
if err != nil {
|
||||
return settings, err
|
||||
}
|
||||
entries, err := contract.DecodeAllowedIPs(saved.AllowedIPs)
|
||||
if err != nil {
|
||||
return settings, err
|
||||
}
|
||||
settings = dto.MCPRuntimeSettings{RuntimeType: saved.RuntimeType, DockerImage: saved.DockerImage, Enabled: saved.Enabled, ListenPort: saved.ListenPort, ExternalURL: saved.ExternalURL, Revision: saved.Revision, AccessPolicy: saved.AccessPolicy, AllowedIPs: entries}
|
||||
return settings, validateMCPRuntimeSettings(settings)
|
||||
}
|
||||
|
||||
func (s *MCPRuntimeService) Catalog(ctx context.Context) ([]contract.Tool, error) {
|
||||
settings, err := s.settings(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.manager(settings).Catalog(ctx)
|
||||
}
|
||||
|
||||
func (s *MCPRuntimeService) Update(req dto.MCPRuntimeSettingsUpdate) (dto.MCPRuntimeSettings, error) {
|
||||
mcpRuntimeMutation.Lock()
|
||||
defer mcpRuntimeMutation.Unlock()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Second)
|
||||
defer cancel()
|
||||
return s.update(ctx, req, false)
|
||||
}
|
||||
|
||||
func (s *MCPRuntimeService) update(ctx context.Context, req dto.MCPRuntimeSettingsUpdate, restart bool) (dto.MCPRuntimeSettings, error) {
|
||||
old, err := s.Settings()
|
||||
if err != nil {
|
||||
return old, err
|
||||
}
|
||||
if req.Revision != old.Revision {
|
||||
return old, ErrMCPSettingsRevision
|
||||
}
|
||||
if err := validateMCPRuntimeSettings(req); err != nil {
|
||||
return old, contract.Fail("MCP_INVALID_ARGUMENTS", 400, err.Error())
|
||||
}
|
||||
if err := normalizeMCPAccessSettings(&req); err != nil {
|
||||
return old, contract.Fail("MCP_INVALID_ARGUMENTS", 400, err.Error())
|
||||
}
|
||||
snapshot, snapshotErr := s.snapshot(ctx, old)
|
||||
if snapshotErr != nil && req.Enabled {
|
||||
return old, snapshotErr
|
||||
}
|
||||
manager := s.manager(req)
|
||||
recoverFailure := func(operationErr error) (dto.MCPRuntimeSettings, error) {
|
||||
current, loadErr := s.Settings()
|
||||
if loadErr != nil {
|
||||
return old, errors.Join(operationErr, loadErr, errors.New("MCP settings are unavailable; runtime recovery requires retrying after the database is available"))
|
||||
}
|
||||
return current, errors.Join(operationErr, s.restore(current, snapshot))
|
||||
}
|
||||
if err := manager.Apply(ctx, req.Enabled, restart || (!old.Enabled && req.Enabled) || (old.Enabled && old.ListenPort != req.ListenPort)); err != nil {
|
||||
return recoverFailure(err)
|
||||
}
|
||||
if err := s.persist(req, old.Revision); err != nil {
|
||||
return recoverFailure(err)
|
||||
}
|
||||
req.Revision++
|
||||
return req, nil
|
||||
}
|
||||
|
||||
func (s *MCPRuntimeService) persist(req dto.MCPRuntimeSettings, revision uint64) error {
|
||||
return global.DB.Transaction(func(tx *gorm.DB) error {
|
||||
seed := model.MCPSetting{ID: 1, RuntimeType: "docker", DockerImage: mcpruntime.DefaultDockerImage, ListenPort: mcpruntime.DefaultPort, Revision: 1}
|
||||
if err := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&seed).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
result := tx.Model(&model.MCPSetting{}).Where("id = ? AND revision = ?", 1, revision).Updates(map[string]interface{}{
|
||||
"runtime_type": "docker", "docker_image": req.DockerImage,
|
||||
"enabled": req.Enabled, "listen_port": req.ListenPort, "external_url": req.ExternalURL, "revision": revision + 1,
|
||||
"access_policy": req.AccessPolicy, "allowed_ips": encodeMCPIPs(req.AllowedIPs),
|
||||
})
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected != 1 {
|
||||
return ErrMCPSettingsRevision
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (s *MCPRuntimeService) restore(settings dto.MCPRuntimeSettings, snapshot mcpRuntimeSnapshot) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 40*time.Second)
|
||||
defer cancel()
|
||||
manager := s.manager(settings)
|
||||
old := snapshot.settings
|
||||
if sameMCPRuntimeConfiguration(settings, old) {
|
||||
if snapshot.unavailable {
|
||||
return errors.New("the previous MCP Docker identity was unavailable; the runtime could not be restored automatically")
|
||||
}
|
||||
if snapshot.imageID != "" {
|
||||
return manager.Restore(ctx, snapshot.imageID, settings.Enabled)
|
||||
}
|
||||
}
|
||||
return manager.Apply(ctx, settings.Enabled, true)
|
||||
}
|
||||
|
||||
func sameMCPRuntimeConfiguration(a, b dto.MCPRuntimeSettings) bool {
|
||||
return a.DockerImage == b.DockerImage && a.ListenPort == b.ListenPort && a.Enabled == b.Enabled
|
||||
}
|
||||
|
||||
func (s *MCPRuntimeService) Status(ctx context.Context) (dto.MCPRuntimeStatus, error) {
|
||||
settings, err := s.Settings()
|
||||
if err != nil {
|
||||
return dto.MCPRuntimeStatus{}, err
|
||||
}
|
||||
state := s.manager(settings).Status(ctx, settings.Enabled)
|
||||
|
||||
return dto.MCPRuntimeStatus{
|
||||
RuntimeType: "docker", DockerImage: settings.DockerImage, ContainerName: mcpruntime.DockerContainerName, ImageID: state.ImageID,
|
||||
Enabled: settings.Enabled, Installed: state.Installed, Status: state.Status, Ready: state.Ready, Reason: state.Reason,
|
||||
AdapterVersion: state.AdapterVersion, ContractVersion: mcpruntime.ContractVersion, Protocol: mcpruntime.Protocol, Endpoint: settings.ExternalURL,
|
||||
InternalEndpoint: fmt.Sprintf("http://127.0.0.1:%d/mcp", settings.ListenPort),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *MCPRuntimeService) Operate(ctx context.Context, operation string) (dto.MCPRuntimeStatus, error) {
|
||||
mcpRuntimeMutation.Lock()
|
||||
defer mcpRuntimeMutation.Unlock()
|
||||
settings, err := s.Settings()
|
||||
if err != nil {
|
||||
return dto.MCPRuntimeStatus{}, err
|
||||
}
|
||||
switch operation {
|
||||
case "start", "restart":
|
||||
settings.Enabled = true
|
||||
case "stop":
|
||||
settings.Enabled = false
|
||||
default:
|
||||
return dto.MCPRuntimeStatus{}, contract.Invalid()
|
||||
}
|
||||
if _, err := s.update(ctx, settings, operation == "restart"); err != nil {
|
||||
return dto.MCPRuntimeStatus{}, err
|
||||
}
|
||||
return s.Status(ctx)
|
||||
}
|
||||
|
||||
func (s *MCPRuntimeService) Install(ctx context.Context, req dto.MCPRuntimeInstall) (dto.MCPRuntimeStatus, error) {
|
||||
mcpRuntimeMutation.Lock()
|
||||
defer mcpRuntimeMutation.Unlock()
|
||||
old, err := s.settings(ctx)
|
||||
if err != nil {
|
||||
return dto.MCPRuntimeStatus{}, err
|
||||
}
|
||||
if req.Revision != old.Revision {
|
||||
return dto.MCPRuntimeStatus{}, ErrMCPSettingsRevision
|
||||
}
|
||||
next := old
|
||||
next.RuntimeType, next.DockerImage, next.ListenPort, next.ExternalURL = "docker", req.DockerImage, req.ListenPort, req.ExternalURL
|
||||
if err := validateMCPRuntimeSettings(next); err != nil {
|
||||
return dto.MCPRuntimeStatus{}, contract.Fail("MCP_INVALID_ARGUMENTS", 400, err.Error())
|
||||
}
|
||||
snapshot, err := s.snapshot(ctx, old)
|
||||
if err != nil {
|
||||
return dto.MCPRuntimeStatus{}, err
|
||||
}
|
||||
if !snapshot.installed {
|
||||
next.Enabled = true
|
||||
}
|
||||
manager := s.manager(next)
|
||||
rollback := func(authoritative dto.MCPRuntimeSettings) error {
|
||||
if !snapshot.installed && reflect.DeepEqual(authoritative, old) {
|
||||
restoreCtx, cancel := context.WithTimeout(context.Background(), 40*time.Second)
|
||||
defer cancel()
|
||||
return manager.RestoreAbsent(restoreCtx)
|
||||
}
|
||||
return s.restore(authoritative, snapshot)
|
||||
}
|
||||
recoverFailure := func(operationErr error) error {
|
||||
current, loadErr := s.Settings()
|
||||
if loadErr != nil {
|
||||
return errors.Join(operationErr, loadErr, errors.New("MCP settings are unavailable; runtime recovery requires retrying after the database is available"))
|
||||
}
|
||||
return errors.Join(operationErr, rollback(current))
|
||||
}
|
||||
if err := manager.InstallImage(ctx, next.Enabled); err != nil {
|
||||
return dto.MCPRuntimeStatus{}, recoverFailure(err)
|
||||
}
|
||||
if err := s.persist(next, old.Revision); err != nil {
|
||||
return dto.MCPRuntimeStatus{}, recoverFailure(err)
|
||||
}
|
||||
return s.Status(ctx)
|
||||
}
|
||||
|
||||
func (s *MCPRuntimeService) Logs(ctx context.Context) (dto.MCPRuntimeLogs, error) {
|
||||
settings, err := s.Settings()
|
||||
if err != nil {
|
||||
return dto.MCPRuntimeLogs{}, err
|
||||
}
|
||||
content, err := s.manager(settings).Logs(ctx)
|
||||
return dto.MCPRuntimeLogs{Content: content}, err
|
||||
}
|
||||
|
||||
func validateMCPRuntimeSettings(req dto.MCPRuntimeSettings) error {
|
||||
if req.AccessPolicy == "" {
|
||||
return errors.New("MCP source policy is required")
|
||||
}
|
||||
if err := normalizeMCPAccessSettings(&req); err != nil {
|
||||
return err
|
||||
}
|
||||
if req.RuntimeType != "docker" {
|
||||
return errors.New("1Panel MCP supports only the Docker runtime")
|
||||
}
|
||||
if err := mcpruntime.ValidateDockerImage(req.DockerImage); err != nil {
|
||||
return err
|
||||
}
|
||||
if req.ListenPort < 1024 || req.ListenPort > 65535 {
|
||||
return errors.New("MCP listen port must be between 1024 and 65535")
|
||||
}
|
||||
if req.ExternalURL == "" {
|
||||
return nil
|
||||
}
|
||||
if len(req.ExternalURL) > 2048 || strings.ContainsAny(req.ExternalURL, "\r\n") {
|
||||
return errors.New("invalid MCP external URL")
|
||||
}
|
||||
u, err := url.Parse(req.ExternalURL)
|
||||
if err != nil || (u.Scheme != "https" && u.Scheme != "http") || u.Host == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" || u.Path != "/mcp" {
|
||||
return errors.New("MCP external URL must be an HTTP or HTTPS URL ending in /mcp without credentials or query parameters")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -272,13 +272,11 @@ func password() {
|
||||
return
|
||||
}
|
||||
complexSetting := getSettingByKey(db, "ComplexityVerification")
|
||||
if complexSetting == constant.StatusEnable {
|
||||
if isValidPassword("newPassword") {
|
||||
fmt.Println("\n" + i18n.GetMsgByKeyForCmd("UpdatePasswordFormat"))
|
||||
return
|
||||
}
|
||||
}
|
||||
if len(newPassword) < 6 {
|
||||
switch validatePassword(newPassword, complexSetting) {
|
||||
case passwordTooWeak:
|
||||
fmt.Println("\n" + i18n.GetMsgByKeyForCmd("UpdatePasswordFormat"))
|
||||
return
|
||||
case passwordTooShort:
|
||||
fmt.Println(i18n.GetMsgByKeyForCmd("UpdatePasswordLen"))
|
||||
return
|
||||
}
|
||||
@@ -377,6 +375,25 @@ func port() {
|
||||
fmt.Println(std)
|
||||
}
|
||||
}
|
||||
|
||||
type passwordValidation int
|
||||
|
||||
const (
|
||||
passwordValid passwordValidation = iota
|
||||
passwordTooShort
|
||||
passwordTooWeak
|
||||
)
|
||||
|
||||
func validatePassword(password, complexitySetting string) passwordValidation {
|
||||
if complexitySetting == constant.StatusEnable && !isValidPassword(password) {
|
||||
return passwordTooWeak
|
||||
}
|
||||
if len(password) < 6 {
|
||||
return passwordTooShort
|
||||
}
|
||||
return passwordValid
|
||||
}
|
||||
|
||||
func isValidPassword(password string) bool {
|
||||
numCount := 0
|
||||
alphaCount := 0
|
||||
|
||||
@@ -21983,11 +21983,9 @@ const docTemplate = `{
|
||||
],
|
||||
"x-panel-log": {
|
||||
"BeforeFunctions": [],
|
||||
"bodyKeys": [
|
||||
"id"
|
||||
],
|
||||
"formatEN": "Delete runtime [id]",
|
||||
"formatZH": "删除运行环境 [id]",
|
||||
"bodyKeys": [],
|
||||
"formatEN": "Delete runtime [name]",
|
||||
"formatZH": "删除运行环境 [name]",
|
||||
"paramKeys": []
|
||||
}
|
||||
}
|
||||
@@ -28072,14 +28070,14 @@ const docTemplate = `{
|
||||
{
|
||||
"db": "websites",
|
||||
"input_column": "id",
|
||||
"input_value": "websiteId",
|
||||
"input_value": "websiteID",
|
||||
"isList": false,
|
||||
"output_column": "primary_domain",
|
||||
"output_value": "domain"
|
||||
}
|
||||
],
|
||||
"bodyKeys": [
|
||||
"websiteId"
|
||||
"websiteID"
|
||||
],
|
||||
"formatEN": "php version update [domain]",
|
||||
"formatZH": "php 版本变更 [domain]",
|
||||
|
||||
@@ -21979,11 +21979,9 @@
|
||||
],
|
||||
"x-panel-log": {
|
||||
"BeforeFunctions": [],
|
||||
"bodyKeys": [
|
||||
"id"
|
||||
],
|
||||
"formatEN": "Delete runtime [id]",
|
||||
"formatZH": "删除运行环境 [id]",
|
||||
"bodyKeys": [],
|
||||
"formatEN": "Delete runtime [name]",
|
||||
"formatZH": "删除运行环境 [name]",
|
||||
"paramKeys": []
|
||||
}
|
||||
}
|
||||
@@ -28068,14 +28066,14 @@
|
||||
{
|
||||
"db": "websites",
|
||||
"input_column": "id",
|
||||
"input_value": "websiteId",
|
||||
"input_value": "websiteID",
|
||||
"isList": false,
|
||||
"output_column": "primary_domain",
|
||||
"output_value": "domain"
|
||||
}
|
||||
],
|
||||
"bodyKeys": [
|
||||
"websiteId"
|
||||
"websiteID"
|
||||
],
|
||||
"formatEN": "php version update [domain]",
|
||||
"formatZH": "php 版本变更 [domain]",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -73,6 +73,7 @@ var WebUrlMap = map[string]struct{}{
|
||||
"/ai/benchmark": {},
|
||||
"/ai/model/account": {},
|
||||
"/ai/model/local": {},
|
||||
"/ai/model/files": {},
|
||||
"/ai/gpu": {},
|
||||
"/ai/gpu/current": {},
|
||||
"/ai/gpu/history": {},
|
||||
@@ -235,6 +236,7 @@ var WebUrlMap = map[string]struct{}{
|
||||
}
|
||||
|
||||
var DynamicRoutes = []string{
|
||||
`^/ai/benchmark/[0-9]+$`,
|
||||
`^/containers/composeDetail/[^/]+$`,
|
||||
`^/databases/mysql/setting/[^/]+/[^/]+$`,
|
||||
`^/databases/postgresql/setting/[^/]+/[^/]+$`,
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"embed"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/1Panel-dev/1Panel/core/app/repo"
|
||||
@@ -253,3 +254,30 @@ func initBundle() {
|
||||
panic("[i18n] failed to init language files, See log above for details")
|
||||
}
|
||||
}
|
||||
|
||||
var (
|
||||
mcpDocumentBundleOnce sync.Once
|
||||
mcpDocumentBundle *i18n.Bundle
|
||||
mcpDocumentBundleErr error
|
||||
)
|
||||
|
||||
func NewMCPDocumentLocalizer(lang string) (*i18n.Localizer, error) {
|
||||
mcpDocumentBundleOnce.Do(func() {
|
||||
documentBundle := i18n.NewBundle(language.English)
|
||||
documentBundle.RegisterUnmarshalFunc("yaml", yaml.Unmarshal)
|
||||
for _, file := range langFiles {
|
||||
if _, err := documentBundle.LoadMessageFileFS(fs, file); err != nil {
|
||||
mcpDocumentBundleErr = fmt.Errorf("load MCP document language %s: %w", file, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
mcpDocumentBundle = documentBundle
|
||||
})
|
||||
if mcpDocumentBundleErr != nil {
|
||||
return nil, mcpDocumentBundleErr
|
||||
}
|
||||
if strings.TrimSpace(lang) == "" {
|
||||
lang = GetLanguage()
|
||||
}
|
||||
return i18n.NewLocalizer(mcpDocumentBundle, lang, language.English.String()), nil
|
||||
}
|
||||
|
||||
@@ -502,3 +502,33 @@ ErrAPIKeyTerminalUpgradeRequired: "Unable to verify API key terminal authorizati
|
||||
LocalAppSyncDispatch: "Dispatch local app sync"
|
||||
LocalAppSyncDispatchNode: "Dispatch local app sync to {{.node}} (node task: {{.taskID}})"
|
||||
ErrLocalAppSyncDisabled: "Local app sync across nodes is disabled"
|
||||
|
||||
# MCP connection document
|
||||
MCPDocumentTitle: "1Panel MCP connection setup prompt"
|
||||
MCPDocumentIntro: "Give this file to an AI Agent and ask it to complete the setup described here. This file contains the full Token; keep it private."
|
||||
MCPDocumentTaskTitle: "Configuration task for the AI Agent"
|
||||
MCPDocumentTask: "Configure 1Panel MCP for the client specified below in the current environment, then verify the connection and tool discovery. Use the connection parameters in the configuration block below."
|
||||
MCPDocumentClientCodex: "Target client: Codex. First confirm the configuration scope and the actual CODEX_HOME. Use the active config.toml (default: ~/.codex/config.toml) and merge the TOML below into the corresponding mcp_servers entry."
|
||||
MCPDocumentClientClaude: "Target client: Claude. First confirm the actual client version. The JSON below is intended for clients that support HTTP MCP configuration. Use a configuration location or management method supported by that version; do not put remote HTTP configuration into an entry that only supports stdio."
|
||||
MCPDocumentClientGeneric: "Target client: a generic MCP client. First identify the client in use and the configuration scope. Ask the user if uncertain; do not guess the configuration file location. Merge the connection parameters below using the client’s syntax."
|
||||
MCPDocumentSteps: |-
|
||||
1. Confirm that the target client supports Streamable HTTP and Bearer request headers. Explain the limitation if its version does not support them.
|
||||
2. Read and back up the existing configuration. Merge the single server entry below, preserving all other MCP and client settings. If the name already exists, update only that entry.
|
||||
3. Write the URL and Authorization values exactly as provided. Do not regenerate the Token or change panel permissions or service state. Do not put the Token in command history, logs, replies or Git.
|
||||
4. Keep certificate verification enabled for HTTPS. For a private CA, ask the user to provide a trusted CA; do not automatically trust the server certificate or disable TLS verification. Use HTTP only on trusted networks; configure HTTPS before crossing an untrusted network.
|
||||
5. Reload through the client, then verify MCP initialization and tools/list. Do not execute tools/call or write operations. If reloading is unavailable, report that a new session or restart is required. Saving a file does not prove a successful connection.
|
||||
6. Report the client, configuration location, server name, connection and tool discovery results, and any remaining issues. Hide the Token.
|
||||
MCPDocumentConfigTitle: "Connection configuration"
|
||||
MCPDocumentNotes: "The panel server checks tool, node and current account permissions on every request. Update the client configuration after regenerating the Token. Access is rejected when the credential is disabled, revoked or expired."
|
||||
MCPDocumentHTTPWarning: "This address uses unencrypted HTTP. Configure HTTPS before using it on an untrusted network."
|
||||
|
||||
# MCP management
|
||||
ErrMCPConflict: "MCP data changed or conflicts with existing data. Refresh and review it."
|
||||
ErrMCPPolicyChanged: "The MCP tool catalog or authorization changed. Refresh, review, and save the authorization."
|
||||
ErrMCPCatalogUnavailable: "The MCP tool catalog is unavailable. Check the service status."
|
||||
ErrMCPDisabled: "The MCP service is not enabled."
|
||||
ErrMCPSecretKeyUnavailable: "MCP credential encryption is unavailable. Try again later."
|
||||
ErrMCPSecretInvalid: "The stored MCP Token cannot be decrypted. Regenerate the Token."
|
||||
ErrMCPCredentialInactive: "The MCP credential is disabled, expired, or revoked."
|
||||
ErrMCPAuditUnavailable: "MCP operation auditing is unavailable. Verify the operation result before continuing."
|
||||
ErrMCPEndpointUnavailable: "Configure a valid external MCP endpoint first."
|
||||
|
||||
@@ -502,3 +502,33 @@ ErrAPIKeyTerminalUpgradeRequired: "No se pudo verificar la autorización de term
|
||||
LocalAppSyncDispatch: "Enviar sincronización de apps locales"
|
||||
LocalAppSyncDispatchNode: "Enviar sincronización de apps locales a {{.node}} (tarea del nodo: {{.taskID}})"
|
||||
ErrLocalAppSyncDisabled: "La sincronización de apps locales entre nodos está desactivada"
|
||||
|
||||
# MCP connection document
|
||||
MCPDocumentTitle: "Instrucciones de configuración de la conexión MCP de 1Panel"
|
||||
MCPDocumentIntro: "Entregue este archivo a un agente de IA y pídale que realice la configuración descrita. El archivo contiene el Token completo; manténgalo privado."
|
||||
MCPDocumentTaskTitle: "Tarea de configuración para el agente de IA"
|
||||
MCPDocumentTask: "Configure el MCP de 1Panel en el entorno actual para el cliente indicado a continuación y verifique la conexión y el descubrimiento de herramientas. Use los parámetros del bloque de configuración siguiente."
|
||||
MCPDocumentClientCodex: "Cliente de destino: Codex. Confirme primero el ámbito de configuración y el CODEX_HOME real. Use el config.toml activo (por defecto: ~/.codex/config.toml) e integre el TOML siguiente en la entrada mcp_servers correspondiente."
|
||||
MCPDocumentClientClaude: "Cliente de destino: Claude. Confirme primero la versión real del cliente. El JSON siguiente está destinado a clientes que admitan la configuración MCP por HTTP. Use una ubicación o un método de gestión admitidos por esa versión; no escriba la configuración HTTP remota en una entrada que solo admita stdio."
|
||||
MCPDocumentClientGeneric: "Cliente de destino: cliente MCP genérico. Identifique primero el cliente utilizado y el ámbito de configuración. Si no puede determinarlo, pregunte al usuario; no adivine la ubicación del archivo. Integre los parámetros siguientes con la sintaxis del cliente."
|
||||
MCPDocumentSteps: |-
|
||||
1. Confirme que el cliente admite Streamable HTTP y cabeceras de solicitud Bearer. Si la versión no los admite, explique el motivo.
|
||||
2. Lea la configuración existente y cree una copia de seguridad. Integre la única entrada de servidor siguiente y conserve el resto de los ajustes MCP y del cliente. Si el nombre ya existe, actualice solo esa entrada.
|
||||
3. Escriba los valores de URL y Authorization exactamente como se proporcionan. No regenere el Token ni cambie los permisos del panel o el estado del servicio. No incluya el Token en el historial de comandos, registros, respuestas ni Git.
|
||||
4. Mantenga la validación de certificados HTTPS. Para una CA privada, pida al usuario una CA de confianza; no confíe automáticamente en el certificado del servidor ni desactive la validación TLS. Use HTTP solo en redes de confianza; configure HTTPS antes de atravesar una red no fiable.
|
||||
5. Recargue mediante el cliente y verifique la inicialización MCP y tools/list. No ejecute tools/call ni operaciones de escritura. Si no se puede recargar, indique que hace falta una nueva sesión o un reinicio. Guardar el archivo no demuestra que la conexión funcione.
|
||||
6. Informe del cliente, la ubicación de configuración, el nombre del servidor, los resultados de conexión y descubrimiento de herramientas y los problemas pendientes. Oculte el Token.
|
||||
MCPDocumentConfigTitle: "Configuración de la conexión"
|
||||
MCPDocumentNotes: "El servidor del panel comprueba los permisos de herramientas, nodos y de la cuenta actual en cada solicitud. Tras regenerar el Token, actualice la configuración del cliente. Se deniega el acceso si la credencial se desactiva, revoca o caduca."
|
||||
MCPDocumentHTTPWarning: "Esta dirección utiliza HTTP sin cifrar. Configure HTTPS antes de usarla en una red no fiable."
|
||||
|
||||
# MCP management
|
||||
ErrMCPConflict: "Los datos MCP han cambiado o entran en conflicto. Actualice la página y revíselos."
|
||||
ErrMCPPolicyChanged: "El catálogo de herramientas MCP o la autorización han cambiado. Actualice, revise y guarde la autorización."
|
||||
ErrMCPCatalogUnavailable: "El catálogo de herramientas MCP no está disponible. Compruebe el estado del servicio."
|
||||
ErrMCPDisabled: "El servicio MCP no está habilitado."
|
||||
ErrMCPSecretKeyUnavailable: "El cifrado de credenciales MCP no está disponible. Inténtelo más tarde."
|
||||
ErrMCPSecretInvalid: "No se puede descifrar el Token MCP almacenado. Genere un Token nuevo."
|
||||
ErrMCPCredentialInactive: "La credencial MCP está deshabilitada, ha caducado o se ha revocado."
|
||||
ErrMCPAuditUnavailable: "La auditoría de operaciones MCP no está disponible. Verifique el resultado antes de continuar."
|
||||
ErrMCPEndpointUnavailable: "Configure primero una dirección externa MCP válida."
|
||||
|
||||
@@ -502,3 +502,33 @@ ErrAPIKeyTerminalUpgradeRequired: "امکان بررسی پشتیبانی از
|
||||
LocalAppSyncDispatch: "ارسال درخواست همگامسازی برنامههای محلی"
|
||||
LocalAppSyncDispatchNode: "ارسال درخواست همگامسازی به {{.node}} (وظیفه گره: {{.taskID}})"
|
||||
ErrLocalAppSyncDisabled: "همگامسازی برنامههای محلی بین گرهها غیرفعال است"
|
||||
|
||||
# MCP connection document
|
||||
MCPDocumentTitle: "دستور راهاندازی اتصال MCP در 1Panel"
|
||||
MCPDocumentIntro: "این فایل را به یک عامل هوش مصنوعی بدهید و از آن بخواهید تنظیمات را طبق سند انجام دهد. فایل شامل Token کامل است؛ آن را محرمانه نگه دارید."
|
||||
MCPDocumentTaskTitle: "وظیفه پیکربندی برای عامل هوش مصنوعی"
|
||||
MCPDocumentTask: "در محیط فعلی، MCP مربوط به 1Panel را برای کلاینت مشخصشده در پایین پیکربندی کنید؛ سپس اتصال و کشف ابزارها را بررسی کنید. پارامترهای اتصال را از بخش پیکربندی زیر بردارید."
|
||||
MCPDocumentClientCodex: "کلاینت هدف: Codex. ابتدا محدوده پیکربندی و CODEX_HOME واقعی را مشخص کنید. از config.toml فعال (پیشفرض: ~/.codex/config.toml) استفاده کنید و TOML زیر را با ورودی مربوط در mcp_servers ادغام کنید."
|
||||
MCPDocumentClientClaude: "کلاینت هدف: Claude. ابتدا نسخه واقعی کلاینت را مشخص کنید. JSON زیر برای کلاینتهایی است که از پیکربندی MCP روی HTTP پشتیبانی میکنند. از محل پیکربندی یا روش مدیریتی پشتیبانیشده در همان نسخه استفاده کنید؛ تنظیمات HTTP راه دور را در ورودی مختص stdio ننویسید."
|
||||
MCPDocumentClientGeneric: "کلاینت هدف: یک کلاینت عمومی MCP. ابتدا کلاینت مورد استفاده و محدوده پیکربندی را مشخص کنید. اگر مطمئن نیستید، از کاربر بپرسید و محل فایل پیکربندی را حدس نزنید. پارامترهای اتصال زیر را طبق نحو کلاینت ادغام کنید."
|
||||
MCPDocumentSteps: |-
|
||||
1. مطمئن شوید کلاینت هدف از Streamable HTTP و سرآیندهای درخواست Bearer پشتیبانی میکند. اگر نسخه آن پشتیبانی نمیکند، دلیل را توضیح دهید.
|
||||
2. پیکربندی موجود را بخوانید و از آن نسخه پشتیبان بگیرید. تنها ورودی سرور زیر را ادغام کنید و سایر تنظیمات MCP و کلاینت را حفظ کنید. اگر نام از قبل وجود دارد، فقط همان ورودی را بهروز کنید.
|
||||
3. مقادیر URL و Authorization را دقیقاً همانطور که ارائه شدهاند بنویسید. Token را دوباره تولید نکنید و مجوزهای پنل یا وضعیت سرویس را تغییر ندهید. Token را در تاریخچه فرمانها، گزارشها، پاسخها یا Git قرار ندهید.
|
||||
4. اعتبارسنجی گواهی HTTPS را فعال نگه دارید. برای CA خصوصی، از کاربر بخواهید CA مورد اعتماد ارائه کند؛ گواهی سرور را خودکار معتبر ندانید و اعتبارسنجی TLS را غیرفعال نکنید. HTTP فقط در شبکههای مورد اعتماد استفاده شود؛ پیش از عبور از شبکه نامطمئن، HTTPS را پیکربندی کنید.
|
||||
5. تنظیمات را از طریق کلاینت دوباره بارگذاری کنید و راهاندازی MCP و tools/list را بررسی کنید. tools/call یا عملیات نوشتن را اجرا نکنید. اگر بارگذاری دوباره ممکن نیست، نیاز به نشست جدید یا راهاندازی مجدد را گزارش کنید. ذخیره فایل بهتنهایی به معنی موفقیت اتصال نیست.
|
||||
6. کلاینت، محل پیکربندی، نام سرور، نتیجه اتصال و کشف ابزارها و مشکلات باقیمانده را گزارش کنید. Token را پنهان نگه دارید.
|
||||
MCPDocumentConfigTitle: "پیکربندی اتصال"
|
||||
MCPDocumentNotes: "سرور پنل در هر درخواست مجوزهای ابزار، گره و حساب مالک را بررسی میکند. پس از تولید دوباره Token، پیکربندی کلاینت را بهروز کنید. اگر اعتبارنامه غیرفعال، لغو یا منقضی شود، دسترسی رد میشود."
|
||||
MCPDocumentHTTPWarning: "این نشانی از HTTP بدون رمزنگاری استفاده میکند. پیش از استفاده در شبکه نامطمئن، HTTPS را پیکربندی کنید."
|
||||
|
||||
# MCP management
|
||||
ErrMCPConflict: "دادههای MCP تغییر کردهاند یا با دادههای موجود تداخل دارند. صفحه را تازهسازی و دادهها را بررسی کنید."
|
||||
ErrMCPPolicyChanged: "فهرست ابزارهای MCP یا مجوزها تغییر کرده است. صفحه را تازهسازی کنید، مجوزها را بررسی و ذخیره کنید."
|
||||
ErrMCPCatalogUnavailable: "فهرست ابزارهای MCP در دسترس نیست. وضعیت سرویس را بررسی کنید."
|
||||
ErrMCPDisabled: "سرویس MCP فعال نیست."
|
||||
ErrMCPSecretKeyUnavailable: "رمزنگاری اطلاعات اعتبار MCP در دسترس نیست. بعداً دوباره تلاش کنید."
|
||||
ErrMCPSecretInvalid: "رمزگشایی Token ذخیرهشده MCP ممکن نیست. Token را دوباره ایجاد کنید."
|
||||
ErrMCPCredentialInactive: "اعتبار MCP غیرفعال، منقضی یا لغو شده است."
|
||||
ErrMCPAuditUnavailable: "ثبت حسابرسی عملیات MCP در دسترس نیست. پیش از ادامه، نتیجه عملیات را بررسی کنید."
|
||||
ErrMCPEndpointUnavailable: "ابتدا یک نشانی خارجی معتبر برای MCP تنظیم کنید."
|
||||
|
||||
@@ -502,3 +502,33 @@ ErrAPIKeyTerminalUpgradeRequired: "API キーによるターミナル認証へ
|
||||
LocalAppSyncDispatch: "ローカルアプリ同期要求の送信"
|
||||
LocalAppSyncDispatchNode: "{{.node}} にローカルアプリ同期要求を送信(ノードタスク: {{.taskID}})"
|
||||
ErrLocalAppSyncDisabled: "ノード間のローカルアプリ同期は無効です"
|
||||
|
||||
# MCP connection document
|
||||
MCPDocumentTitle: "1Panel MCP 接続設定用プロンプト"
|
||||
MCPDocumentIntro: "このファイルを AI エージェントに渡し、記載された手順で設定するよう依頼してください。完全な Token が含まれるため、非公開で保管してください。"
|
||||
MCPDocumentTaskTitle: "AI エージェントへの設定タスク"
|
||||
MCPDocumentTask: "現在の環境で、以下に指定されたクライアントに 1Panel MCP を設定し、接続とツールの検出を検証してください。接続パラメーターには以下の設定ブロックを使用してください。"
|
||||
MCPDocumentClientCodex: "対象クライアント:Codex。まず設定の適用範囲と実際の CODEX_HOME を確認してください。有効な config.toml(既定:~/.codex/config.toml)を使用し、以下の TOML を対応する mcp_servers エントリーに統合してください。"
|
||||
MCPDocumentClientClaude: "対象クライアント:Claude。まず実際のクライアントのバージョンを確認してください。以下の JSON は HTTP MCP 設定に対応したクライアント向けです。そのバージョンが対応する設定場所または管理方法を使用し、stdio 専用の設定先にリモート HTTP 設定を書き込まないでください。"
|
||||
MCPDocumentClientGeneric: "対象クライアント:汎用 MCP クライアント。まず使用中のクライアントと設定の適用範囲を特定してください。不明な場合はユーザーに確認し、設定ファイルの場所を推測しないでください。クライアントの構文に従って、以下の接続パラメーターを統合してください。"
|
||||
MCPDocumentSteps: |-
|
||||
1. 対象クライアントが Streamable HTTP と Bearer リクエストヘッダーに対応していることを確認してください。対応していないバージョンの場合は理由を説明してください。
|
||||
2. 既存の設定を読み取り、バックアップしてください。以下の単一サーバーエントリーを統合し、ほかの MCP 設定とクライアント設定を保持してください。同名のエントリーがある場合は、その項目だけを更新してください。
|
||||
3. URL と Authorization は提示された値をそのまま書き込んでください。Token の再生成、パネル権限やサービスの起動・停止状態の変更は行わないでください。Token をコマンド履歴、ログ、返信、Git に含めないでください。
|
||||
4. HTTPS の証明書検証は有効のままにしてください。プライベート CA の場合はユーザーに信頼できる CA の提供を求め、サーバー証明書を自動的に信頼したり、TLS 検証を無効にしたりしないでください。HTTP は信頼できるネットワーク内に限定し、信頼できないネットワークを経由する前に HTTPS を設定してください。
|
||||
5. クライアントで設定を再読み込みし、MCP 初期化と tools/list を検証してください。tools/call や書き込み操作は実行しないでください。再読み込みできない場合は、新しいセッションまたは再起動が必要であることを報告してください。ファイルの保存だけで接続成功と判断しないでください。
|
||||
6. クライアント、設定場所、サーバー名、接続とツール検出の結果、未解決の問題を報告してください。Token は表示しないでください。
|
||||
MCPDocumentConfigTitle: "接続設定"
|
||||
MCPDocumentNotes: "パネルのサーバーは、ツール、ノード、所属アカウントの現在の権限をリクエストごとに確認します。Token を再生成した後はクライアント設定を更新してください。認証情報が無効化、取り消し、期限切れになった場合、接続は拒否されます。"
|
||||
MCPDocumentHTTPWarning: "現在のアドレスは暗号化されていない HTTP を使用しています。信頼できないネットワークで使用する前に HTTPS を設定してください。"
|
||||
|
||||
# MCP management
|
||||
ErrMCPConflict: "MCP データが変更されたか、競合しています。再読み込みして確認してください。"
|
||||
ErrMCPPolicyChanged: "MCP ツールカタログまたは認可設定が変更されました。再読み込みして確認し、認可設定を保存してください。"
|
||||
ErrMCPCatalogUnavailable: "MCP ツールカタログを利用できません。サービスの状態を確認してください。"
|
||||
ErrMCPDisabled: "MCP サービスが有効になっていません。"
|
||||
ErrMCPSecretKeyUnavailable: "MCP 認証情報を暗号化できません。しばらくしてから再試行してください。"
|
||||
ErrMCPSecretInvalid: "保存済みの MCP Token を復号できません。Token を再生成してください。"
|
||||
ErrMCPCredentialInactive: "MCP 認証情報が無効、期限切れ、または取り消し済みです。"
|
||||
ErrMCPAuditUnavailable: "MCP 操作の監査記録を利用できません。操作結果を確認してから続行してください。"
|
||||
ErrMCPEndpointUnavailable: "先に有効な MCP 外部接続先を設定してください。"
|
||||
|
||||
@@ -502,3 +502,33 @@ ErrAPIKeyTerminalUpgradeRequired: "API Key 터미널 인증 지원 여부를 확
|
||||
LocalAppSyncDispatch: "로컬 앱 동기화 요청 전송"
|
||||
LocalAppSyncDispatchNode: "{{.node}}에 로컬 앱 동기화 요청 전송 (노드 작업: {{.taskID}})"
|
||||
ErrLocalAppSyncDisabled: "노드 간 로컬 앱 동기화가 비활성화되어 있습니다"
|
||||
|
||||
# MCP connection document
|
||||
MCPDocumentTitle: "1Panel MCP 연결 설정 프롬프트"
|
||||
MCPDocumentIntro: "이 파일을 AI 에이전트에 전달하고 문서에 따라 설정하도록 요청하세요. 전체 Token이 포함되어 있으므로 비공개로 보관하세요."
|
||||
MCPDocumentTaskTitle: "AI 에이전트의 설정 작업"
|
||||
MCPDocumentTask: "현재 환경에서 아래에 지정된 클라이언트에 1Panel MCP를 설정하고 연결 및 도구 검색을 검증하세요. 연결 매개변수는 아래 설정 블록을 기준으로 사용하세요."
|
||||
MCPDocumentClientCodex: "대상 클라이언트: Codex. 먼저 설정 범위와 실제 CODEX_HOME을 확인하세요. 적용 중인 config.toml(기본값: ~/.codex/config.toml)을 사용하고 아래 TOML을 해당 mcp_servers 항목에 병합하세요."
|
||||
MCPDocumentClientClaude: "대상 클라이언트: Claude. 먼저 실제 클라이언트 버전을 확인하세요. 아래 JSON은 HTTP MCP 설정을 지원하는 클라이언트용입니다. 해당 버전에서 지원하는 설정 위치나 관리 방법을 사용하고, stdio만 지원하는 항목에 원격 HTTP 설정을 넣지 마세요."
|
||||
MCPDocumentClientGeneric: "대상 클라이언트: 범용 MCP 클라이언트. 먼저 사용 중인 클라이언트와 설정 범위를 확인하세요. 확실하지 않으면 사용자에게 질문하고 설정 파일 위치를 추측하지 마세요. 클라이언트 구문에 따라 아래 연결 매개변수를 병합하세요."
|
||||
MCPDocumentSteps: |-
|
||||
1. 대상 클라이언트가 Streamable HTTP와 Bearer 요청 헤더를 지원하는지 확인하세요. 해당 버전이 지원하지 않으면 이유를 설명하세요.
|
||||
2. 기존 설정을 읽고 백업하세요. 아래의 단일 서버 항목을 병합하면서 다른 MCP 및 클라이언트 설정은 유지하세요. 같은 이름이 있으면 해당 항목만 업데이트하세요.
|
||||
3. URL과 Authorization 값을 제공된 그대로 기록하세요. Token을 다시 생성하거나 패널 권한 또는 서비스 실행 상태를 변경하지 마세요. Token을 명령 기록, 로그, 답변 또는 Git에 포함하지 마세요.
|
||||
4. HTTPS 인증서 검증을 유지하세요. 사설 CA를 사용하는 경우 사용자에게 신뢰할 수 있는 CA를 요청하고, 서버 인증서를 자동으로 신뢰하거나 TLS 검증을 끄지 마세요. HTTP는 신뢰할 수 있는 네트워크에서만 사용하고, 신뢰할 수 없는 네트워크를 통과하기 전에 HTTPS를 설정하세요.
|
||||
5. 클라이언트에서 다시 로드한 후 MCP 초기화와 tools/list를 검증하세요. tools/call이나 쓰기 작업은 실행하지 마세요. 다시 로드할 수 없으면 새 세션 또는 재시작이 필요하다고 보고하세요. 파일 저장만으로 연결 성공을 판단하지 마세요.
|
||||
6. 클라이언트, 설정 위치, 서버 이름, 연결 및 도구 검색 결과와 남은 문제를 보고하세요. Token은 숨기세요.
|
||||
MCPDocumentConfigTitle: "연결 설정"
|
||||
MCPDocumentNotes: "패널 서버는 요청마다 도구, 노드 및 소유 계정의 현재 권한을 검사합니다. Token을 다시 생성하면 클라이언트 설정을 업데이트해야 합니다. 자격 증명이 비활성화, 취소 또는 만료되면 접근이 거부됩니다."
|
||||
MCPDocumentHTTPWarning: "현재 주소는 암호화되지 않은 HTTP를 사용합니다. 신뢰할 수 없는 네트워크에서 사용하기 전에 HTTPS를 설정하세요."
|
||||
|
||||
# MCP management
|
||||
ErrMCPConflict: "MCP 데이터가 변경되었거나 충돌합니다. 새로 고친 후 확인하세요."
|
||||
ErrMCPPolicyChanged: "MCP 도구 카탈로그 또는 권한이 변경되었습니다. 새로 고친 후 권한을 확인하고 저장하세요."
|
||||
ErrMCPCatalogUnavailable: "MCP 도구 카탈로그를 사용할 수 없습니다. 서비스 상태를 확인하세요."
|
||||
ErrMCPDisabled: "MCP 서비스가 활성화되지 않았습니다."
|
||||
ErrMCPSecretKeyUnavailable: "MCP 자격 증명을 암호화할 수 없습니다. 나중에 다시 시도하세요."
|
||||
ErrMCPSecretInvalid: "저장된 MCP Token을 복호화할 수 없습니다. Token을 다시 생성하세요."
|
||||
ErrMCPCredentialInactive: "MCP 자격 증명이 비활성화되었거나 만료 또는 취소되었습니다."
|
||||
ErrMCPAuditUnavailable: "MCP 작업 감사 기록을 사용할 수 없습니다. 작업 결과를 확인한 후 계속하세요."
|
||||
ErrMCPEndpointUnavailable: "먼저 유효한 MCP 외부 접속 주소를 설정하세요."
|
||||
|
||||
@@ -502,3 +502,33 @@ ErrAPIKeyTerminalUpgradeRequired: "ບໍ່ສາມາດຢືນຢັນກ
|
||||
LocalAppSyncDispatch: "ສົ່ງຄຳຂໍຊິງຄ໌ແອັບໃນເຄື່ອງ"
|
||||
LocalAppSyncDispatchNode: "ສົ່ງຄຳຂໍຊິງຄ໌ໄປຫາ {{.node}} (ວຽກຂອງໂນດ: {{.taskID}})"
|
||||
ErrLocalAppSyncDisabled: "ການຊິງຄ໌ແອັບລະຫວ່າງໂນດຍັງບໍ່ໄດ້ເປີດ"
|
||||
|
||||
# MCP connection document
|
||||
MCPDocumentTitle: "ຄຳສັ່ງຕັ້ງຄ່າການເຊື່ອມຕໍ່ 1Panel MCP"
|
||||
MCPDocumentIntro: "ສົ່ງໄຟລ໌ນີ້ໃຫ້ຕົວແທນ AI ແລະ ຂໍໃຫ້ຕັ້ງຄ່າຕາມເອກະສານ. ໄຟລ໌ນີ້ມີ Token ຄົບຖ້ວນ; ກະລຸນາເກັບເປັນຄວາມລັບ."
|
||||
MCPDocumentTaskTitle: "ໜ້າວຽກຕັ້ງຄ່າສຳລັບຕົວແທນ AI"
|
||||
MCPDocumentTask: "ກະລຸນາຕັ້ງຄ່າ 1Panel MCP ໃນສະພາບແວດລ້ອມປັດຈຸບັນສຳລັບໄຄລເອນທີ່ລະບຸຂ້າງລຸ່ມ ແລ້ວກວດສອບການເຊື່ອມຕໍ່ ແລະ ການຄົ້ນພົບເຄື່ອງມື. ໃຊ້ພາລາມິເຕີການເຊື່ອມຕໍ່ຈາກສ່ວນຕັ້ງຄ່າຂ້າງລຸ່ມ."
|
||||
MCPDocumentClientCodex: "ໄຄລເອນເປົ້າໝາຍ: Codex. ກ່ອນອື່ນໃຫ້ຢືນຢັນຂອບເຂດການຕັ້ງຄ່າ ແລະ CODEX_HOME ທີ່ໃຊ້ຈິງ. ໃຊ້ config.toml ທີ່ມີຜົນ (ຄ່າເລີ່ມຕົ້ນ: ~/.codex/config.toml) ແລະ ລວມ TOML ຂ້າງລຸ່ມເຂົ້າໃນລາຍການ mcp_servers ທີ່ກົງກັນ."
|
||||
MCPDocumentClientClaude: "ໄຄລເອນເປົ້າໝາຍ: Claude. ກ່ອນອື່ນໃຫ້ຢືນຢັນເວີຊັນໄຄລເອນທີ່ໃຊ້ຈິງ. JSON ຂ້າງລຸ່ມເໝາະສຳລັບໄຄລເອນທີ່ຮອງຮັບການຕັ້ງຄ່າ HTTP MCP. ໃຊ້ຕຳແໜ່ງຕັ້ງຄ່າ ຫຼື ວິທີຈັດການທີ່ເວີຊັນນັ້ນຮອງຮັບ; ຢ່າຂຽນການຕັ້ງຄ່າ HTTP ທາງໄກໃສ່ຊ່ອງທີ່ຮອງຮັບພຽງ stdio."
|
||||
MCPDocumentClientGeneric: "ໄຄລເອນເປົ້າໝາຍ: ໄຄລເອນ MCP ທົ່ວໄປ. ກ່ອນອື່ນໃຫ້ລະບຸໄຄລເອນທີ່ໃຊ້ ແລະ ຂອບເຂດການຕັ້ງຄ່າ. ຖ້າບໍ່ແນ່ໃຈໃຫ້ຖາມຜູ້ໃຊ້, ຢ່າເດົາຕຳແໜ່ງໄຟລ໌. ລວມພາລາມິເຕີຂ້າງລຸ່ມຕາມຮູບແບບຂອງໄຄລເອນ."
|
||||
MCPDocumentSteps: |-
|
||||
1. ຢືນຢັນວ່າໄຄລເອນເປົ້າໝາຍຮອງຮັບ Streamable HTTP ແລະ ສ່ວນຫົວຄຳຮ້ອງຂໍ Bearer. ຖ້າເວີຊັນບໍ່ຮອງຮັບ ໃຫ້ອະທິບາຍເຫດຜົນ.
|
||||
2. ອ່ານ ແລະ ສຳຮອງການຕັ້ງຄ່າທີ່ມີຢູ່. ລວມລາຍການເຊີບເວີດຽວຂ້າງລຸ່ມ ໂດຍຮັກສາການຕັ້ງຄ່າ MCP ແລະ ໄຄລເອນອື່ນໄວ້. ຖ້າຊື່ຊ້ຳກັນ ໃຫ້ປັບປຸງສະເພາະລາຍການນັ້ນ.
|
||||
3. ຂຽນ URL ແລະ Authorization ຕາມຄ່າເດີມທີ່ໃຫ້. ຢ່າສ້າງ Token ໃໝ່ ຫຼື ປ່ຽນສິດແຜງຄວບຄຸມ ຫຼື ສະຖານະເປີດປິດບໍລິການ. ຢ່າຂຽນ Token ໃສ່ປະຫວັດຄຳສັ່ງ, ບັນທຶກເຫດການ, ຄຳຕອບ ຫຼື Git.
|
||||
4. ຮັກສາການກວດສອບໃບຢັ້ງຢືນ HTTPS ໄວ້. ສຳລັບ CA ສ່ວນຕົວ ໃຫ້ຂໍ CA ທີ່ເຊື່ອຖືໄດ້ຈາກຜູ້ໃຊ້; ຢ່າເຊື່ອຖືໃບຢັ້ງຢືນເຊີບເວີອັດຕະໂນມັດ ຫຼື ປິດການກວດສອບ TLS. ໃຊ້ HTTP ສະເພາະເຄືອຂ່າຍທີ່ເຊື່ອຖືໄດ້; ຕັ້ງຄ່າ HTTPS ກ່ອນຜ່ານເຄືອຂ່າຍທີ່ບໍ່ເຊື່ອຖື.
|
||||
5. ໂຫຼດຄືນຜ່ານໄຄລເອນ ແລ້ວກວດສອບການເລີ່ມຕົ້ນ MCP ແລະ tools/list. ຢ່າເອີ້ນ tools/call ຫຼື ດຳເນີນການຂຽນ. ຖ້າໂຫຼດຄືນບໍ່ໄດ້ ໃຫ້ລາຍງານວ່າຕ້ອງເປີດເຊດຊັນໃໝ່ ຫຼື ເລີ່ມໃໝ່. ຢ່າຖືວ່າການບັນທຶກໄຟລ໌ໝາຍເຖິງເຊື່ອມຕໍ່ສຳເລັດ.
|
||||
6. ລາຍງານໄຄລເອນ, ຕຳແໜ່ງຕັ້ງຄ່າ, ຊື່ເຊີບເວີ, ຜົນການເຊື່ອມຕໍ່ ແລະ ການຄົ້ນພົບເຄື່ອງມື ພ້ອມບັນຫາທີ່ຍັງເຫຼືອ. ເຊື່ອງ Token ໄວ້.
|
||||
MCPDocumentConfigTitle: "ການຕັ້ງຄ່າເຊື່ອມຕໍ່"
|
||||
MCPDocumentNotes: "ເຊີບເວີແຜງຄວບຄຸມກວດສອບສິດເຄື່ອງມື, ໂນດ ແລະ ສິດປັດຈຸບັນຂອງບັນຊີເຈົ້າຂອງທຸກຄຳຮ້ອງຂໍ. ຫຼັງສ້າງ Token ໃໝ່ ຕ້ອງປັບປຸງການຕັ້ງຄ່າໄຄລເອນ. ການເຂົ້າເຖິງຈະຖືກປະຕິເສດເມື່ອຂໍ້ມູນຢືນຢັນຖືກປິດ, ຖອນ ຫຼື ໝົດອາຍຸ."
|
||||
MCPDocumentHTTPWarning: "ທີ່ຢູ່ນີ້ໃຊ້ HTTP ທີ່ບໍ່ເຂົ້າລະຫັດ. ກະລຸນາຕັ້ງຄ່າ HTTPS ກ່ອນໃຊ້ໃນເຄືອຂ່າຍທີ່ບໍ່ເຊື່ອຖື."
|
||||
|
||||
# MCP management
|
||||
ErrMCPConflict: "ຂໍ້ມູນ MCP ຖືກປ່ຽນແປງ ຫຼື ຂັດແຍ່ງກັບຂໍ້ມູນເດີມ. ກະລຸນາໂຫຼດໜ້າໃໝ່ແລະກວດສອບ."
|
||||
ErrMCPPolicyChanged: "ລາຍການເຄື່ອງມື MCP ຫຼື ການອະນຸຍາດຖືກປ່ຽນແປງ. ກະລຸນາໂຫຼດໜ້າໃໝ່, ກວດສອບ ແລະ ບັນທຶກການອະນຸຍາດ."
|
||||
ErrMCPCatalogUnavailable: "ລາຍການເຄື່ອງມື MCP ບໍ່ພ້ອມໃຊ້ງານ. ກະລຸນາກວດສອບສະຖານະບໍລິການ."
|
||||
ErrMCPDisabled: "ບໍລິການ MCP ຍັງບໍ່ຖືກເປີດໃຊ້ງານ."
|
||||
ErrMCPSecretKeyUnavailable: "ການເຂົ້າລະຫັດຂໍ້ມູນຢັ້ງຢືນ MCP ບໍ່ພ້ອມໃຊ້ງານ. ກະລຸນາລອງໃໝ່ພາຍຫຼັງ."
|
||||
ErrMCPSecretInvalid: "ບໍ່ສາມາດຖອດລະຫັດ MCP Token ທີ່ບັນທຶກໄວ້. ກະລຸນາສ້າງ Token ໃໝ່."
|
||||
ErrMCPCredentialInactive: "ຂໍ້ມູນຢັ້ງຢືນ MCP ຖືກປິດໃຊ້ງານ, ໝົດອາຍຸ ຫຼື ຖືກຖອນ."
|
||||
ErrMCPAuditUnavailable: "ການບັນທຶກກວດສອບການດຳເນີນງານ MCP ບໍ່ພ້ອມໃຊ້ງານ. ກະລຸນາກວດສອບຜົນການດຳເນີນງານກ່ອນດຳເນີນການຕໍ່."
|
||||
ErrMCPEndpointUnavailable: "ກະລຸນາຕັ້ງຄ່າທີ່ຢູ່ MCP ພາຍນອກທີ່ຖືກຕ້ອງກ່ອນ."
|
||||
|
||||
@@ -502,3 +502,33 @@ ErrAPIKeyTerminalUpgradeRequired: "Sokongan pengesahan terminal melalui kunci AP
|
||||
LocalAppSyncDispatch: "Hantar penyegerakan aplikasi tempatan"
|
||||
LocalAppSyncDispatchNode: "Hantar penyegerakan aplikasi tempatan ke {{.node}} (tugas nod: {{.taskID}})"
|
||||
ErrLocalAppSyncDisabled: "Penyegerakan aplikasi tempatan antara nod dinyahdayakan"
|
||||
|
||||
# MCP connection document
|
||||
MCPDocumentTitle: "Arahan persediaan sambungan MCP 1Panel"
|
||||
MCPDocumentIntro: "Berikan fail ini kepada ejen AI dan minta ia melengkapkan persediaan mengikut dokumen. Fail ini mengandungi Token penuh; simpan secara peribadi."
|
||||
MCPDocumentTaskTitle: "Tugas konfigurasi untuk ejen AI"
|
||||
MCPDocumentTask: "Konfigurasikan MCP 1Panel dalam persekitaran semasa untuk klien yang dinyatakan di bawah, kemudian sahkan sambungan dan penemuan alat. Gunakan parameter sambungan dalam blok konfigurasi di bawah."
|
||||
MCPDocumentClientCodex: "Klien sasaran: Codex. Sahkan dahulu skop konfigurasi dan CODEX_HOME sebenar. Gunakan config.toml yang berkuat kuasa (lalai: ~/.codex/config.toml) dan gabungkan TOML di bawah ke dalam entri mcp_servers yang sepadan."
|
||||
MCPDocumentClientClaude: "Klien sasaran: Claude. Sahkan dahulu versi klien sebenar. JSON di bawah adalah untuk klien yang menyokong konfigurasi MCP melalui HTTP. Gunakan lokasi konfigurasi atau kaedah pengurusan yang disokong versi tersebut; jangan masukkan konfigurasi HTTP jauh ke dalam entri yang hanya menyokong stdio."
|
||||
MCPDocumentClientGeneric: "Klien sasaran: klien MCP umum. Kenal pasti dahulu klien yang digunakan dan skop konfigurasi. Tanya pengguna jika tidak pasti; jangan meneka lokasi fail konfigurasi. Gabungkan parameter sambungan di bawah mengikut sintaks klien."
|
||||
MCPDocumentSteps: |-
|
||||
1. Sahkan bahawa klien sasaran menyokong Streamable HTTP dan pengepala permintaan Bearer. Jika versinya tidak menyokongnya, jelaskan sebabnya.
|
||||
2. Baca dan sandarkan konfigurasi sedia ada. Gabungkan satu entri pelayan di bawah sambil mengekalkan tetapan MCP dan klien yang lain. Jika nama itu sudah wujud, kemas kini entri itu sahaja.
|
||||
3. Tulis nilai URL dan Authorization tepat seperti yang diberikan. Jangan jana semula Token atau ubah kebenaran panel atau keadaan perkhidmatan. Jangan masukkan Token dalam sejarah perintah, log, balasan atau Git.
|
||||
4. Kekalkan pengesahan sijil untuk HTTPS. Bagi CA peribadi, minta pengguna memberikan CA yang dipercayai; jangan mempercayai sijil pelayan secara automatik atau mematikan pengesahan TLS. Gunakan HTTP hanya pada rangkaian dipercayai; konfigurasikan HTTPS sebelum melalui rangkaian yang tidak dipercayai.
|
||||
5. Muat semula melalui klien, kemudian sahkan permulaan MCP dan tools/list. Jangan jalankan tools/call atau operasi tulis. Jika muat semula tidak tersedia, laporkan bahawa sesi baharu atau mula semula diperlukan. Menyimpan fail bukan bukti sambungan berjaya.
|
||||
6. Laporkan klien, lokasi konfigurasi, nama pelayan, hasil sambungan dan penemuan alat, serta masalah yang masih belum selesai. Sembunyikan Token.
|
||||
MCPDocumentConfigTitle: "Konfigurasi sambungan"
|
||||
MCPDocumentNotes: "Pelayan panel menyemak kebenaran alat, nod dan akaun pemilik semasa bagi setiap permintaan. Kemas kini konfigurasi klien selepas menjana semula Token. Akses ditolak apabila kelayakan dinyahdayakan, dibatalkan atau tamat tempoh."
|
||||
MCPDocumentHTTPWarning: "Alamat ini menggunakan HTTP tanpa penyulitan. Konfigurasikan HTTPS sebelum menggunakannya pada rangkaian yang tidak dipercayai."
|
||||
|
||||
# MCP management
|
||||
ErrMCPConflict: "Data MCP telah berubah atau bercanggah dengan data sedia ada. Muat semula dan semak data tersebut."
|
||||
ErrMCPPolicyChanged: "Katalog alat MCP atau kebenaran telah berubah. Muat semula, semak dan simpan kebenaran."
|
||||
ErrMCPCatalogUnavailable: "Katalog alat MCP tidak tersedia. Semak status perkhidmatan."
|
||||
ErrMCPDisabled: "Perkhidmatan MCP tidak diaktifkan."
|
||||
ErrMCPSecretKeyUnavailable: "Penyulitan kelayakan MCP tidak tersedia. Cuba lagi kemudian."
|
||||
ErrMCPSecretInvalid: "Token MCP yang disimpan tidak dapat dinyahsulit. Jana semula Token."
|
||||
ErrMCPCredentialInactive: "Kelayakan MCP telah dinyahaktifkan, tamat tempoh atau dibatalkan."
|
||||
ErrMCPAuditUnavailable: "Audit operasi MCP tidak tersedia. Sahkan hasil operasi sebelum meneruskan."
|
||||
ErrMCPEndpointUnavailable: "Konfigurasikan alamat luaran MCP yang sah terlebih dahulu."
|
||||
|
||||
@@ -502,3 +502,33 @@ ErrAPIKeyTerminalUpgradeRequired: "Não foi possível verificar o suporte à aut
|
||||
LocalAppSyncDispatch: "Enviar sincronização de apps locais"
|
||||
LocalAppSyncDispatchNode: "Enviar sincronização de apps locais para {{.node}} (tarefa do nó: {{.taskID}})"
|
||||
ErrLocalAppSyncDisabled: "A sincronização de apps locais entre nós está desativada"
|
||||
|
||||
# MCP connection document
|
||||
MCPDocumentTitle: "Instruções de configuração da conexão MCP do 1Panel"
|
||||
MCPDocumentIntro: "Entregue este arquivo a um agente de IA e peça que realize a configuração descrita. Este arquivo contém o Token completo; mantenha-o privado."
|
||||
MCPDocumentTaskTitle: "Tarefa de configuração para o agente de IA"
|
||||
MCPDocumentTask: "Configure o MCP do 1Panel no ambiente atual para o cliente indicado abaixo e verifique a conexão e a descoberta de ferramentas. Use os parâmetros do bloco de configuração abaixo."
|
||||
MCPDocumentClientCodex: "Cliente de destino: Codex. Primeiro confirme o escopo da configuração e o CODEX_HOME efetivo. Use o config.toml em uso (padrão: ~/.codex/config.toml) e mescle o TOML abaixo na entrada mcp_servers correspondente."
|
||||
MCPDocumentClientClaude: "Cliente de destino: Claude. Primeiro confirme a versão real do cliente. O JSON abaixo se destina a clientes compatíveis com a configuração MCP por HTTP. Use um local de configuração ou método de gerenciamento compatível com essa versão; não insira a configuração HTTP remota em uma entrada que aceite apenas stdio."
|
||||
MCPDocumentClientGeneric: "Cliente de destino: cliente MCP genérico. Primeiro identifique o cliente em uso e o escopo da configuração. Se houver dúvida, pergunte ao usuário; não adivinhe o local do arquivo. Mescle os parâmetros de conexão abaixo conforme a sintaxe do cliente."
|
||||
MCPDocumentSteps: |-
|
||||
1. Confirme que o cliente de destino aceita Streamable HTTP e cabeçalhos de requisição Bearer. Se a versão não oferecer suporte, explique o motivo.
|
||||
2. Leia a configuração existente e faça uma cópia de segurança. Mescle a única entrada de servidor abaixo, preservando as demais configurações MCP e do cliente. Se o nome já existir, atualize somente essa entrada.
|
||||
3. Grave os valores de URL e Authorization exatamente como fornecidos. Não gere outro Token nem altere as permissões do painel ou o estado do serviço. Não inclua o Token no histórico de comandos, em logs, respostas ou no Git.
|
||||
4. Mantenha a verificação de certificados habilitada para HTTPS. Para uma CA privada, solicite ao usuário uma CA confiável; não confie automaticamente no certificado do servidor nem desative a verificação TLS. Use HTTP apenas em redes confiáveis; configure HTTPS antes de passar por uma rede não confiável.
|
||||
5. Recarregue pelo cliente e verifique a inicialização MCP e tools/list. Não execute tools/call nem operações de escrita. Se não for possível recarregar, informe que é necessária uma nova sessão ou reinicialização. Salvar o arquivo não comprova que a conexão funcionou.
|
||||
6. Informe o cliente, o local da configuração, o nome do servidor, os resultados da conexão e da descoberta de ferramentas e as pendências. Oculte o Token.
|
||||
MCPDocumentConfigTitle: "Configuração da conexão"
|
||||
MCPDocumentNotes: "O servidor do painel verifica as permissões de ferramentas, nós e da conta atual em cada requisição. Após gerar um novo Token, atualize a configuração do cliente. O acesso será negado se a credencial for desativada, revogada ou expirar."
|
||||
MCPDocumentHTTPWarning: "Este endereço usa HTTP sem criptografia. Configure HTTPS antes de utilizá-lo em uma rede não confiável."
|
||||
|
||||
# MCP management
|
||||
ErrMCPConflict: "Os dados MCP foram alterados ou estão em conflito. Atualize a página e revise-os."
|
||||
ErrMCPPolicyChanged: "O catálogo de ferramentas MCP ou a autorização foi alterado. Atualize, revise e salve a autorização."
|
||||
ErrMCPCatalogUnavailable: "O catálogo de ferramentas MCP está indisponível. Verifique o estado do serviço."
|
||||
ErrMCPDisabled: "O serviço MCP não está habilitado."
|
||||
ErrMCPSecretKeyUnavailable: "A criptografia de credenciais MCP está indisponível. Tente novamente mais tarde."
|
||||
ErrMCPSecretInvalid: "Não foi possível descriptografar o Token MCP armazenado. Gere um novo Token."
|
||||
ErrMCPCredentialInactive: "A credencial MCP está desabilitada, expirada ou revogada."
|
||||
ErrMCPAuditUnavailable: "A auditoria de operações MCP está indisponível. Verifique o resultado antes de continuar."
|
||||
ErrMCPEndpointUnavailable: "Configure primeiro um endereço externo MCP válido."
|
||||
|
||||
@@ -502,3 +502,33 @@ ErrAPIKeyTerminalUpgradeRequired: "Не удалось проверить под
|
||||
LocalAppSyncDispatch: "Отправка запросов синхронизации локальных приложений"
|
||||
LocalAppSyncDispatchNode: "Отправка запроса синхронизации узлу {{.node}} (задача узла: {{.taskID}})"
|
||||
ErrLocalAppSyncDisabled: "Синхронизация локальных приложений между узлами отключена"
|
||||
|
||||
# MCP connection document
|
||||
MCPDocumentTitle: "Инструкция для настройки подключения 1Panel MCP"
|
||||
MCPDocumentIntro: "Передайте этот файл ИИ-агенту и попросите выполнить настройку по инструкции. Файл содержит полный Token; храните его конфиденциально."
|
||||
MCPDocumentTaskTitle: "Задача настройки для ИИ-агента"
|
||||
MCPDocumentTask: "Настройте 1Panel MCP в текущей среде для указанного ниже клиента, затем проверьте подключение и обнаружение инструментов. Используйте параметры подключения из приведённого ниже блока конфигурации."
|
||||
MCPDocumentClientCodex: "Целевой клиент: Codex. Сначала уточните область действия конфигурации и фактический CODEX_HOME. Используйте действующий config.toml (по умолчанию ~/.codex/config.toml) и объедините приведённый ниже TOML с соответствующей записью mcp_servers."
|
||||
MCPDocumentClientClaude: "Целевой клиент: Claude. Сначала уточните фактическую версию клиента. Приведённый ниже JSON предназначен для клиентов с поддержкой настройки MCP по HTTP. Используйте место хранения или способ управления конфигурацией, поддерживаемые этой версией; не добавляйте удалённую конфигурацию HTTP в раздел, предназначенный только для stdio."
|
||||
MCPDocumentClientGeneric: "Целевой клиент: универсальный MCP-клиент. Сначала определите используемый клиент и область действия конфигурации. Если это неясно, спросите пользователя; не угадывайте расположение файла. Объедините параметры подключения ниже согласно синтаксису клиента."
|
||||
MCPDocumentSteps: |-
|
||||
1. Убедитесь, что целевой клиент поддерживает Streamable HTTP и заголовки запросов Bearer. Если версия их не поддерживает, объясните причину.
|
||||
2. Прочитайте существующую конфигурацию и создайте резервную копию. Добавьте единственную запись сервера ниже, сохранив остальные настройки MCP и клиента. Если такое имя уже существует, обновите только эту запись.
|
||||
3. Запишите URL и Authorization без изменения значений. Не создавайте новый Token и не меняйте права панели или состояние службы. Не помещайте Token в историю команд, журналы, ответы или Git.
|
||||
4. Не отключайте проверку сертификатов HTTPS. Для частного CA запросите у пользователя доверенный CA; не доверяйте сертификату сервера автоматически и не отключайте проверку TLS. Используйте HTTP только в доверенных сетях; перед передачей через недоверенную сеть настройте HTTPS.
|
||||
5. Перезагрузите конфигурацию средствами клиента и проверьте инициализацию MCP и tools/list. Не выполняйте tools/call или операции записи. Если перезагрузка недоступна, сообщите о необходимости нового сеанса или перезапуска. Сохранение файла само по себе не означает успешного подключения.
|
||||
6. Укажите клиент, расположение конфигурации, имя сервера, результаты подключения и обнаружения инструментов, а также нерешённые вопросы. Скройте Token.
|
||||
MCPDocumentConfigTitle: "Конфигурация подключения"
|
||||
MCPDocumentNotes: "Сервер панели проверяет текущие права на инструменты, узлы и права владельца учётных данных при каждом запросе. После создания нового Token обновите конфигурацию клиента. Доступ отклоняется, если учётные данные отключены, отозваны или просрочены."
|
||||
MCPDocumentHTTPWarning: "Этот адрес использует незашифрованный HTTP. Перед использованием в недоверенной сети настройте HTTPS."
|
||||
|
||||
# MCP management
|
||||
ErrMCPConflict: "Данные MCP изменились или конфликтуют с существующими. Обновите страницу и проверьте их."
|
||||
ErrMCPPolicyChanged: "Каталог инструментов MCP или разрешения изменились. Обновите страницу, проверьте и сохраните разрешения."
|
||||
ErrMCPCatalogUnavailable: "Каталог инструментов MCP недоступен. Проверьте состояние службы."
|
||||
ErrMCPDisabled: "Служба MCP не включена."
|
||||
ErrMCPSecretKeyUnavailable: "Шифрование учётных данных MCP недоступно. Повторите попытку позже."
|
||||
ErrMCPSecretInvalid: "Не удалось расшифровать сохранённый MCP Token. Создайте новый Token."
|
||||
ErrMCPCredentialInactive: "Учётные данные MCP отключены, просрочены или отозваны."
|
||||
ErrMCPAuditUnavailable: "Аудит операций MCP недоступен. Проверьте результат операции, прежде чем продолжить."
|
||||
ErrMCPEndpointUnavailable: "Сначала настройте корректный внешний адрес MCP."
|
||||
|
||||
@@ -502,3 +502,33 @@ ErrAPIKeyTerminalUpgradeRequired: "API anahtarıyla terminal yetkilendirme deste
|
||||
LocalAppSyncDispatch: "Yerel uygulama eşitleme isteği gönder"
|
||||
LocalAppSyncDispatchNode: "{{.node}} düğümüne yerel uygulama eşitleme isteği gönder (düğüm görevi: {{.taskID}})"
|
||||
ErrLocalAppSyncDisabled: "Düğümler arası yerel uygulama eşitleme devre dışı"
|
||||
|
||||
# MCP connection document
|
||||
MCPDocumentTitle: "1Panel MCP bağlantı kurulum yönergesi"
|
||||
MCPDocumentIntro: "Bu dosyayı bir yapay zekâ ajanına verin ve belgede açıklanan kurulumu tamamlamasını isteyin. Dosya Token değerinin tamamını içerir; gizli tutun."
|
||||
MCPDocumentTaskTitle: "Yapay zekâ ajanı için yapılandırma görevi"
|
||||
MCPDocumentTask: "Mevcut ortamda aşağıda belirtilen istemci için 1Panel MCP bağlantısını yapılandırın; ardından bağlantıyı ve araç keşfini doğrulayın. Aşağıdaki yapılandırma bloğundaki bağlantı parametrelerini kullanın."
|
||||
MCPDocumentClientCodex: "Hedef istemci: Codex. Önce yapılandırmanın kapsamını ve gerçek CODEX_HOME konumunu doğrulayın. Etkin config.toml dosyasını (varsayılan: ~/.codex/config.toml) kullanın ve aşağıdaki TOML içeriğini ilgili mcp_servers girdisine birleştirin."
|
||||
MCPDocumentClientClaude: "Hedef istemci: Claude. Önce gerçek istemci sürümünü doğrulayın. Aşağıdaki JSON, HTTP MCP yapılandırmasını destekleyen istemciler içindir. Bu sürümün desteklediği yapılandırma konumunu veya yönetim yöntemini kullanın; uzak HTTP yapılandırmasını yalnızca stdio destekleyen bir girdiye yazmayın."
|
||||
MCPDocumentClientGeneric: "Hedef istemci: genel bir MCP istemcisi. Önce kullanılan istemciyi ve yapılandırmanın kapsamını belirleyin. Emin değilseniz kullanıcıya sorun; yapılandırma dosyasının konumunu tahmin etmeyin. Aşağıdaki bağlantı parametrelerini istemcinin sözdizimine göre birleştirin."
|
||||
MCPDocumentSteps: |-
|
||||
1. Hedef istemcinin Streamable HTTP ve Bearer istek başlıklarını desteklediğini doğrulayın. Sürüm bunları desteklemiyorsa nedenini açıklayın.
|
||||
2. Mevcut yapılandırmayı okuyun ve yedekleyin. Aşağıdaki tek sunucu girdisini birleştirirken diğer MCP ve istemci ayarlarını koruyun. Aynı ad varsa yalnızca o girdiyi güncelleyin.
|
||||
3. URL ve Authorization değerlerini verildiği gibi yazın. Token değerini yeniden oluşturmayın; panel izinlerini veya hizmet durumunu değiştirmeyin. Token değerini komut geçmişine, günlüklere, yanıtlara veya Git içine yazmayın.
|
||||
4. HTTPS için sertifika doğrulamasını açık tutun. Özel CA kullanılıyorsa kullanıcıdan güvenilir bir CA isteyin; sunucu sertifikasına otomatik olarak güvenmeyin veya TLS doğrulamasını kapatmayın. HTTP yalnızca güvenilir ağlarda kullanılmalıdır; güvenilmeyen bir ağdan geçmeden önce HTTPS yapılandırın.
|
||||
5. İstemci üzerinden yeniden yükleyin; ardından MCP başlatma ve tools/list işlemlerini doğrulayın. tools/call veya yazma işlemleri çalıştırmayın. Yeniden yükleme mümkün değilse yeni oturum ya da yeniden başlatma gerektiğini bildirin. Dosyanın kaydedilmesi, bağlantının başarılı olduğunu kanıtlamaz.
|
||||
6. İstemciyi, yapılandırma konumunu, sunucu adını, bağlantı ve araç keşfi sonuçlarını ve kalan sorunları bildirin. Token değerini gizleyin.
|
||||
MCPDocumentConfigTitle: "Bağlantı yapılandırması"
|
||||
MCPDocumentNotes: "Panel sunucusu her istekte araç, düğüm ve ilgili hesabın güncel izinlerini denetler. Token yeniden oluşturulduğunda istemci yapılandırmasını güncelleyin. Kimlik bilgisi devre dışı bırakılırsa, iptal edilirse veya süresi dolarsa erişim reddedilir."
|
||||
MCPDocumentHTTPWarning: "Bu adres şifrelenmemiş HTTP kullanıyor. Güvenilmeyen bir ağda kullanmadan önce HTTPS yapılandırın."
|
||||
|
||||
# MCP management
|
||||
ErrMCPConflict: "MCP verileri değişti veya mevcut verilerle çakışıyor. Sayfayı yenileyip kontrol edin."
|
||||
ErrMCPPolicyChanged: "MCP araç kataloğu veya yetkilendirme değişti. Yenileyin, kontrol edin ve yetkilendirmeyi kaydedin."
|
||||
ErrMCPCatalogUnavailable: "MCP araç kataloğu kullanılamıyor. Hizmet durumunu kontrol edin."
|
||||
ErrMCPDisabled: "MCP hizmeti etkin değil."
|
||||
ErrMCPSecretKeyUnavailable: "MCP kimlik bilgilerinin şifrelenmesi kullanılamıyor. Daha sonra tekrar deneyin."
|
||||
ErrMCPSecretInvalid: "Kaydedilen MCP Token çözülemiyor. Token'ı yeniden oluşturun."
|
||||
ErrMCPCredentialInactive: "MCP kimlik bilgileri devre dışı, süresi dolmuş veya iptal edilmiş."
|
||||
ErrMCPAuditUnavailable: "MCP işlem denetimi kullanılamıyor. Devam etmeden önce işlem sonucunu doğrulayın."
|
||||
ErrMCPEndpointUnavailable: "Önce geçerli bir harici MCP adresi yapılandırın."
|
||||
|
||||
@@ -502,3 +502,33 @@ ErrAPIKeyTerminalUpgradeRequired: "無法確認節點支援 API Key 終端授權
|
||||
LocalAppSyncDispatch: "下發本機應用同步"
|
||||
LocalAppSyncDispatchNode: "向節點 {{.node}} 下發本機應用同步(節點任務:{{.taskID}})"
|
||||
ErrLocalAppSyncDisabled: "尚未啟用本機應用跨節點同步"
|
||||
|
||||
# MCP connection document
|
||||
MCPDocumentTitle: "1Panel MCP 連線設定提示詞"
|
||||
MCPDocumentIntro: "將本檔案交給 AI Agent,要求其依照文件完成設定。本檔案包含完整 Token,請妥善保密。"
|
||||
MCPDocumentTaskTitle: "給 AI Agent 的設定任務"
|
||||
MCPDocumentTask: "請在目前環境中為下方指定的用戶端設定 1Panel MCP,並驗證連線及工具探索。連線參數以下方設定片段為準。"
|
||||
MCPDocumentClientCodex: "目標用戶端:Codex。先確認設定範圍及實際 CODEX_HOME;使用生效的 config.toml(預設 ~/.codex/config.toml),將下方 TOML 合併至對應的 mcp_servers 項目。"
|
||||
MCPDocumentClientClaude: "目標用戶端:Claude。先確認實際用戶端版本;下方 JSON 適用於支援 HTTP MCP 設定的用戶端。使用該版本支援的設定位置或管理方式,不將遠端 HTTP 設定寫入僅支援 stdio 的入口。"
|
||||
MCPDocumentClientGeneric: "目標用戶端:通用 MCP 用戶端。先識別目前使用的用戶端及設定範圍;無法確定時詢問使用者,不猜測設定檔位置。依照用戶端語法合併下方連線參數。"
|
||||
MCPDocumentSteps: |-
|
||||
1. 確認目標用戶端支援 Streamable HTTP 與 Bearer 請求標頭;版本不支援時說明原因。
|
||||
2. 讀取並備份現有設定,合併下方唯一的伺服器項目,保留其他 MCP 和用戶端設定;同名時只更新該項目。
|
||||
3. 按原值寫入 URL 與 Authorization,不重設 Token、不變更面板權限或服務啟停狀態,不將 Token 寫入命令列歷程、日誌、回覆或 Git。
|
||||
4. HTTPS 保持憑證驗證;私有 CA 須由使用者提供可信任的 CA,不自動信任伺服器憑證或停用 TLS 驗證。HTTP 僅限可信任網路,跨不可信任網路前先設定 HTTPS。
|
||||
5. 使用用戶端重新載入,執行 MCP 初始化及 tools/list 驗證;不執行 tools/call 或寫入操作。無法重新載入時回報需要新工作階段或重新啟動,勿將儲存檔案視為連線成功。
|
||||
6. 回報用戶端、設定位置、伺服器名稱、連線與工具探索結果及尚待處理的問題,隱藏 Token。
|
||||
MCPDocumentConfigTitle: "連線設定"
|
||||
MCPDocumentNotes: "工具、節點及所屬帳號目前的權限由面板伺服器每次驗證。重新產生 Token 後須更新用戶端設定,停用、撤銷或過期後將拒絕連線。"
|
||||
MCPDocumentHTTPWarning: "目前位址使用未加密的 HTTP;在不可信任的網路使用前,請先設定 HTTPS。"
|
||||
|
||||
# MCP management
|
||||
ErrMCPConflict: "MCP 資料已變更或存在衝突,請重新整理後再次核對。"
|
||||
ErrMCPPolicyChanged: "MCP 工具目錄或授權已變更,請重新整理後核對並儲存授權。"
|
||||
ErrMCPCatalogUnavailable: "MCP 工具目錄暫時無法使用,請檢查服務狀態。"
|
||||
ErrMCPDisabled: "MCP 服務尚未啟用。"
|
||||
ErrMCPSecretKeyUnavailable: "MCP 存取憑證加密暫時無法使用,請稍後重試。"
|
||||
ErrMCPSecretInvalid: "儲存的 MCP Token 無法解密,請重新產生 Token。"
|
||||
ErrMCPCredentialInactive: "MCP 存取憑證已停用、過期或撤銷。"
|
||||
ErrMCPAuditUnavailable: "MCP 操作稽核暫時無法使用,請核對操作結果後再繼續。"
|
||||
ErrMCPEndpointUnavailable: "請先設定有效的 MCP 外部連線位址。"
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user