Compare commits

...
Author SHA1 Message Date
guoyuqi f91db55daa refactor(frontend): unify dialogs and improve deletion flows 2026-10-27 14:52:05 +08:00
ssongliu 5346d5f58a fix(ai): reconcile Ollama models and improve engine management (#14211) 2026-10-27 11:06:32 +08:00
Raphael Fakhri ac09426f10 fix(app): keep signed numeric env values unchanged (#14180) 2026-10-27 09:58:53 +08:00
CityFun 19707a68dd fix(agent): support new Hermes Weixin module path (#14197) 2026-10-26 23:34:56 +08:00
ssongliu 7c7fd492c4 feat(ai): support benchmark report routes and parameter translations (#14210) 2026-10-26 23:33:17 +08:00
ssongliu fbf66766c2 fix: align AI menu order and labels (#14192) 2026-10-26 14:03:40 +08:00
蘭 d7355f3ccc feat(mcp): add 1Panel MCP and enhance MCP credential management (#14189)
* feat(mcp): add 1Panel MCP and enhance MCP credential management

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

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

Refs #12640

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

---------

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

* fix: container list
2026-10-23 18:19:52 +08:00
CityFun 87828b03ae fix(website): sync PHP runtime changes to subsites (#14166)
Update subsite upstreams and runtime references together with the parent site. Preserve stopped sites and custom PHP settings, and restore configurations and database records when applying the change fails.
2026-10-23 18:19:44 +08:00
Yuki Guo e7b4146e1b fix: operation log text (#14173) 2026-10-23 18:19:30 +08:00
Yuki Guo ec998552b9 feat: ca website upload (#14148) 2026-10-23 18:12:57 +08:00
Woolsen 1af237944d fix(file): reject rename when the target already exists (#13946) (#14168) 2026-10-23 14:05:48 +08:00
267 changed files with 17937 additions and 2905 deletions
+12 -16
View File
@@ -72,22 +72,6 @@ func (b *BaseApi) CloseOllamaModel(c *gin.Context) {
helper.SuccessWithData(c, nil)
}
// @Tags AI
// @Summary Sync Ollama model list
// @Success 200 {array} dto.OllamaModelDropList
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /ai/ollama/model/sync [post]
// @x-panel-log {"bodyKeys":[],"paramKeys":[],"BeforeFunctions":[],"formatZH":"同步 Ollama 模型列表","formatEN":"sync Ollama model list"}
func (b *BaseApi) SyncOllamaModel(c *gin.Context) {
list, err := aiToolService.Sync()
if err != nil {
helper.BadRequest(c, err)
return
}
helper.SuccessWithData(c, list)
}
// @Tags AI
// @Summary Page Ollama models
// @Accept json
@@ -114,6 +98,18 @@ func (b *BaseApi) SearchOllamaModel(c *gin.Context) {
})
}
func (b *BaseApi) SyncOllamaModelAccount(c *gin.Context) {
var req dto.OllamaModelAccountSync
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
if err := aiToolService.SyncAccount(req); err != nil {
helper.BadRequest(c, err)
return
}
helper.Success(c)
}
// @Tags AI
// @Summary Page Ollama models
// @Accept json
+16
View File
@@ -8,6 +8,7 @@ import (
"github.com/1Panel-dev/1Panel/agent/app/dto/request"
"github.com/1Panel-dev/1Panel/agent/i18n"
"github.com/1Panel-dev/1Panel/agent/utils/appicon"
"github.com/1Panel-dev/1Panel/agent/utils/mcpapi"
"github.com/gin-gonic/gin"
)
@@ -160,10 +161,25 @@ func (b *BaseApi) GetAppDetailByID(c *gin.Context) {
// @Router /apps/install [post]
// @x-panel-log {"bodyKeys":["name"],"paramKeys":[],"BeforeFunctions":[],"formatZH":"安装应用 [name]","formatEN":"Install app [name]"}
func (b *BaseApi) InstallApp(c *gin.Context) {
mcpRequest, trustedMCP := mcpapi.Check(c.Request)
if mcpRequest && !trustedMCP {
c.AbortWithStatus(http.StatusForbidden)
return
}
var req request.AppInstallCreate
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
if mcpRequest {
req.MCPPreserveWebsiteDir = true
if value, exists := req.Params["WEBSITE_DIR"]; exists {
directory, ok := value.(string)
if !ok || directory == "" || directory != settingService.GetWebsiteDir() {
c.AbortWithStatus(http.StatusForbidden)
return
}
}
}
install, err := appService.Install(req, true)
if err != nil {
helper.InternalServer(c, err)
+18
View File
@@ -1,7 +1,11 @@
package v2
import (
"net/http"
"github.com/1Panel-dev/1Panel/agent/app/dto/request"
"github.com/1Panel-dev/1Panel/agent/app/service"
"github.com/1Panel-dev/1Panel/agent/utils/mcpapi"
"github.com/1Panel-dev/1Panel/agent/app/api/v2/helper"
"github.com/1Panel-dev/1Panel/agent/app/dto"
@@ -336,11 +340,25 @@ func (b *BaseApi) UpdateAppConfig(c *gin.Context) {
// @Security Timestamp
// @Router /apps/installed/info/:appInstallId [get]
func (b *BaseApi) GetAppInstallInfo(c *gin.Context) {
mcpRequest, trustedMCP := mcpapi.Check(c.Request)
if mcpRequest && !trustedMCP {
c.AbortWithStatus(http.StatusForbidden)
return
}
appInstallId, err := helper.GetIntParamByKey(c, "appInstallId")
if err != nil {
helper.BadRequest(c, err)
return
}
if mcpRequest {
info, err := (&service.AppInstallService{}).GetAppInstallInfoForMCP(c.Request.Context(), appInstallId)
if err != nil {
helper.InternalServer(c, err)
return
}
helper.SuccessWithData(c, info)
return
}
info, err := appInstallService.GetAppInstallInfo(appInstallId)
if err != nil {
helper.InternalServer(c, err)
+9 -3
View File
@@ -28,7 +28,7 @@ func (b *BaseApi) SearchContainer(c *gin.Context) {
return
}
total, list, err := containerService.Page(req)
total, list, err := containerService.Page(c.Request.Context(), req)
if err != nil {
helper.InternalServer(c, err)
return
@@ -249,9 +249,10 @@ func (b *BaseApi) ListContainerByImage(c *gin.Context) {
// @Success 200 {object} dto.ContainerStatus
// @Security ApiKeyAuth
// @Security Timestamp
// @Param containersOnly query boolean false "Only count containers"
// @Router /containers/status [get]
func (b *BaseApi) LoadContainerStatus(c *gin.Context) {
data, err := containerService.LoadStatus()
data, err := containerService.LoadStatus(c.Request.Context(), c.Query("containersOnly") == "true")
if err != nil {
helper.InternalServer(c, err)
return
@@ -415,9 +416,14 @@ func (b *BaseApi) LoadResourceLimit(c *gin.Context) {
// @Success 200 {array} dto.ContainerListStats
// @Security ApiKeyAuth
// @Security Timestamp
// @Param ids query string false "Comma-separated container IDs; omitted selects all containers"
// @Router /containers/list/stats [get]
func (b *BaseApi) ContainerListStats(c *gin.Context) {
data, err := containerService.ContainerListStats()
var ids []string
if _, supplied := c.Request.URL.Query()["ids"]; supplied {
ids = strings.FieldsFunc(c.Query("ids"), func(r rune) bool { return r == ',' })
}
data, err := containerService.ContainerListStats(c.Request.Context(), ids)
if err != nil {
helper.InternalServer(c, err)
return
+17
View File
@@ -2,9 +2,12 @@ package v2
import (
"encoding/base64"
"net/http"
"github.com/1Panel-dev/1Panel/agent/app/api/v2/helper"
"github.com/1Panel-dev/1Panel/agent/app/dto"
"github.com/1Panel-dev/1Panel/agent/app/service"
"github.com/1Panel-dev/1Panel/agent/utils/mcpapi"
"github.com/gin-gonic/gin"
)
@@ -98,11 +101,25 @@ func (b *BaseApi) SearchDatabase(c *gin.Context) {
// @Param type path string true "type"
// @Router /databases/db/list/:type [get]
func (b *BaseApi) ListDatabase(c *gin.Context) {
mcpRequest, trustedMCP := mcpapi.Check(c.Request)
if mcpRequest && !trustedMCP {
c.AbortWithStatus(http.StatusForbidden)
return
}
dbType, err := helper.GetStrParamByKey(c, "type")
if err != nil {
helper.BadRequest(c, err)
return
}
if mcpRequest {
list, err := (&service.DatabaseService{}).ListForMCP(c.Request.Context(), dbType)
if err != nil {
helper.BadRequest(c, err)
return
}
helper.SuccessWithData(c, list)
return
}
list, err := databaseService.List(dbType)
if err != nil {
helper.InternalServer(c, err)
+32
View File
@@ -3,9 +3,12 @@ package v2
import (
"context"
"encoding/base64"
"net/http"
"github.com/1Panel-dev/1Panel/agent/app/api/v2/helper"
"github.com/1Panel-dev/1Panel/agent/app/dto"
"github.com/1Panel-dev/1Panel/agent/app/service"
"github.com/1Panel-dev/1Panel/agent/utils/mcpapi"
"github.com/gin-gonic/gin"
)
@@ -19,6 +22,11 @@ import (
// @Router /databases [post]
// @x-panel-log {"bodyKeys":["name"],"paramKeys":[],"BeforeFunctions":[],"formatZH":"创建 mysql 数据库 [name]","formatEN":"create mysql database [name]"}
func (b *BaseApi) CreateMysql(c *gin.Context) {
mcpRequest, trustedMCP := mcpapi.Check(c.Request)
if mcpRequest && !trustedMCP {
c.AbortWithStatus(http.StatusForbidden)
return
}
var req dto.MysqlDBCreate
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
@@ -32,6 +40,16 @@ func (b *BaseApi) CreateMysql(c *gin.Context) {
}
req.Password = string(password)
}
if mcpRequest {
created, err := (&service.MysqlService{}).CreateForMCP(c.Request.Context(), req)
if err != nil {
helper.InternalServer(c, err)
return
}
nodeID, _ := mcpapi.NodeID(c.Request)
helper.SuccessWithData(c, map[string]any{"id": created.ID, "database": created.MysqlName, "name": created.Name, "nodeId": nodeID})
return
}
if _, err := mysqlService.Create(context.Background(), req); err != nil {
helper.InternalServer(c, err)
@@ -513,11 +531,25 @@ func (b *BaseApi) LoadRemoteAccess(c *gin.Context) {
// @Security Timestamp
// @Router /databases/status [post]
func (b *BaseApi) LoadStatus(c *gin.Context) {
mcpRequest, trustedMCP := mcpapi.Check(c.Request)
if mcpRequest && !trustedMCP {
c.AbortWithStatus(http.StatusForbidden)
return
}
var req dto.OperationWithNameAndType
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
if mcpRequest {
data, err := (&service.MysqlService{}).LoadStatusForMCP(c.Request.Context(), req)
if err != nil {
helper.InternalServer(c, err)
return
}
helper.SuccessWithData(c, data)
return
}
data, err := mysqlService.LoadStatus(req)
if err != nil {
helper.InternalServer(c, err)
+4
View File
@@ -12,6 +12,7 @@ import (
"sync"
"github.com/1Panel-dev/1Panel/agent/global"
"github.com/1Panel-dev/1Panel/agent/utils/mcpapi"
"gorm.io/gorm"
"github.com/1Panel-dev/1Panel/agent/app/dto"
@@ -22,6 +23,7 @@ import (
)
func ErrorWithDetail(ctx *gin.Context, code int, msgKey string, err error) {
mcpapi.SetBusinessErrorHeader(ctx.Request, ctx.Writer.Header(), err)
res := dto.Response{
Code: code,
Message: "",
@@ -32,6 +34,7 @@ func ErrorWithDetail(ctx *gin.Context, code int, msgKey string, err error) {
}
func ErrorWithBusinessCode(ctx *gin.Context, code int, businessCode, msgKey string, err error) {
mcpapi.SetBusinessErrorHeader(ctx.Request, ctx.Writer.Header(), err)
res := dto.Response{
Code: code,
ErrorCode: businessCode,
@@ -42,6 +45,7 @@ func ErrorWithBusinessCode(ctx *gin.Context, code int, businessCode, msgKey stri
}
func ErrorWithDetailAndData(ctx *gin.Context, code int, msgKey string, err error, data interface{}) {
mcpapi.SetBusinessErrorHeader(ctx.Request, ctx.Writer.Header(), err)
res := dto.Response{
Code: code,
Data: data,
+3 -2
View File
@@ -61,13 +61,14 @@ func (b *BaseApi) CreateRuntime(c *gin.Context) {
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /runtimes/del [post]
// @x-panel-log {"bodyKeys":["id"],"paramKeys":[],"BeforeFunctions":[],"formatZH":"删除运行环境 [id]","formatEN":"Delete runtime [id]"}
// @x-panel-log {"bodyKeys":[],"paramKeys":[],"BeforeFunctions":[],"formatZH":"删除运行环境 [name]","formatEN":"Delete runtime [name]"}
func (b *BaseApi) DeleteRuntime(c *gin.Context) {
var req request.RuntimeDelete
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
err := runtimeService.Delete(req)
name, err := runtimeService.Delete(req)
helper.SetOperationLogValues(c, map[string]string{"name": name})
if err != nil {
helper.InternalServer(c, err)
return
+1 -1
View File
@@ -402,7 +402,7 @@ func (b *BaseApi) ChangeDefaultServer(c *gin.Context) {
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /websites/php/version [post]
// @x-panel-log {"bodyKeys":["websiteId"],"paramKeys":[],"BeforeFunctions":[{"input_column":"id","input_value":"websiteId","isList":false,"db":"websites","output_column":"primary_domain","output_value":"domain"}],"formatZH":"php 版本变更 [domain]","formatEN":"php version update [domain]"}
// @x-panel-log {"bodyKeys":["websiteID"],"paramKeys":[],"BeforeFunctions":[{"input_column":"id","input_value":"websiteID","isList":false,"db":"websites","output_column":"primary_domain","output_value":"domain"}],"formatZH":"php 版本变更 [domain]","formatEN":"php version update [domain]"}
func (b *BaseApi) ChangePHPVersion(c *gin.Context) {
var req request.WebsitePHPVersionReq
if err := helper.CheckBindAndValidate(&req, c); err != nil {
+64
View File
@@ -1,13 +1,16 @@
package v2
import (
"io"
"net/http"
"net/url"
"strconv"
"strings"
"github.com/1Panel-dev/1Panel/agent/app/api/v2/helper"
"github.com/1Panel-dev/1Panel/agent/app/dto"
"github.com/1Panel-dev/1Panel/agent/app/dto/request"
"github.com/1Panel-dev/1Panel/agent/buserr"
"github.com/gin-gonic/gin"
)
@@ -176,3 +179,64 @@ func (b *BaseApi) DownloadCAFile(c *gin.Context) {
c.Header("Content-Disposition", "attachment; filename*=utf-8''"+url.PathEscape(info.Name()))
http.ServeContent(c.Writer, c.Request, info.Name(), info.ModTime(), file)
}
// @Tags Website CA
// @Summary Upload website CA archive
// @Accept multipart/form-data
// @Param file formData file true "ZIP containing ca.crt and ca.key"
// @Success 200
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /websites/ca/upload [post]
func (b *BaseApi) UploadWebsiteCA(c *gin.Context) {
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 11<<20)
if err := c.Request.ParseMultipartForm(11 << 20); err != nil {
helper.InternalServer(c, buserr.New("ErrCAUpload"))
return
}
if c.Request.MultipartForm != nil {
defer c.Request.MultipartForm.RemoveAll()
}
file, err := c.FormFile("file")
if err != nil || file.Size > 10<<20 || !strings.HasSuffix(strings.ToLower(file.Filename), ".zip") {
helper.InternalServer(c, buserr.New("ErrCAUpload"))
return
}
stream, err := file.Open()
if err != nil {
helper.InternalServer(c, err)
return
}
defer stream.Close()
data, err := io.ReadAll(io.LimitReader(stream, (10<<20)+1))
if err != nil {
helper.InternalServer(c, err)
return
}
if err = websiteCAService.Upload(file.Filename[:len(file.Filename)-len(".zip")], data); err != nil {
helper.InternalServer(c, err)
return
}
helper.Success(c)
}
// @Tags Website CA
// @Summary Import website CA from PEM or server files
// @Accept json
// @Param request body request.WebsiteCAImport true "request"
// @Success 200
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /websites/ca/import [post]
func (b *BaseApi) ImportWebsiteCA(c *gin.Context) {
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 3<<20)
var req request.WebsiteCAImport
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
if err := websiteCAService.Import(req); err != nil {
helper.InternalServer(c, err)
return
}
helper.Success(c)
}
+1
View File
@@ -386,6 +386,7 @@ type AgentAccountProviderCountReq struct {
}
type AgentAccountInfo struct {
AppInstallID uint `json:"appInstallId"`
ID uint `json:"id"`
MasterAccountID uint `json:"masterAccountId"`
Provider string `json:"provider"`
+6 -5
View File
@@ -14,16 +14,17 @@ type OllamaModelInfo struct {
CreatedAt time.Time `json:"createdAt"`
}
type OllamaModelDropList struct {
ID uint `json:"id"`
Name string `json:"name"`
}
type OllamaModelName struct {
Name string `json:"name"`
TaskID string `json:"taskID"`
}
type OllamaModelAccountSync struct {
ID uint `json:"id" validate:"required"`
ModelAccountBaseURLType string `json:"modelAccountBaseURLType" validate:"required,oneof=container localhost systemIP custom"`
ModelAccountBaseURL string `json:"modelAccountBaseURL"`
}
type OllamaBindDomain struct {
Domain string `json:"domain" validate:"required"`
AppInstallID uint `json:"appInstallID" validate:"required"`
+1
View File
@@ -172,6 +172,7 @@ type ContainerItemStats struct {
}
type ContainerListStats struct {
ContainerID string `json:"containerID"`
ContainerName string `json:"containerName"`
CPUTotalUsage uint64 `json:"cpuTotalUsage"`
SystemUsage uint64 `json:"systemUsage"`
+9
View File
@@ -23,6 +23,12 @@ type AppInstallCreate struct {
Services map[string]string `json:"services"`
TaskID string `json:"taskID"`
SyncModelAccount *bool `json:"syncModelAccount"`
ModelAccountBaseURLType string `json:"modelAccountBaseURLType" validate:"omitempty,oneof=container localhost systemIP custom"`
ModelAccountBaseURL string `json:"modelAccountBaseURL"`
MCPPreserveWebsiteDir bool `json:"-"`
AppContainerConfig
NodePushConfig
}
@@ -122,6 +128,9 @@ type AppInstallDelete struct {
}
type AppInstalledUpdate struct {
SyncModelAccount *bool `json:"syncModelAccount"`
ModelAccountBaseURLType string `json:"modelAccountBaseURLType" validate:"omitempty,oneof=container localhost systemIP custom"`
ModelAccountBaseURL string `json:"modelAccountBaseURL"`
InstallId uint `json:"installId" validate:"required"`
Params map[string]interface{} `json:"params" validate:"required"`
TaskID string `json:"-"`
+9
View File
@@ -182,3 +182,12 @@ type WebsiteSSLFileUpload struct {
PushNode bool `json:"pushNode"`
Nodes string `json:"nodes"`
}
type WebsiteCAImport struct {
Name string `json:"name" validate:"required,max=128"`
Type string `json:"type" validate:"required,oneof=paste local"`
Certificate string `json:"certificate" validate:"required_if=Type paste"`
PrivateKey string `json:"privateKey" validate:"required_if=Type paste"`
CertificatePath string `json:"certificatePath" validate:"required_if=Type local"`
PrivateKeyPath string `json:"privateKeyPath" validate:"required_if=Type local"`
}
+8
View File
@@ -50,6 +50,8 @@ type TagDTO struct {
}
type AppInstalledCheck struct {
ModelAccountBaseURLType string `json:"modelAccountBaseURLType"`
ModelAccountBaseURL string `json:"modelAccountBaseURL"`
IsExist bool `json:"isExist"`
Name string `json:"name"`
App string `json:"app"`
@@ -184,6 +186,12 @@ type AppParam struct {
}
type AppConfig struct {
AppKey string `json:"appKey"`
Name string `json:"name"`
HttpPort int `json:"httpPort"`
SyncModelAccount bool `json:"syncModelAccount"`
ModelAccountBaseURLType string `json:"modelAccountBaseURLType"`
ModelAccountBaseURL string `json:"modelAccountBaseURL"`
Params []AppParam `json:"params"`
RawCompose string `json:"rawCompose"`
request.AppContainerConfig
+1
View File
@@ -2,6 +2,7 @@ package model
type AgentAccount struct {
BaseModel
AppInstallID uint `json:"appInstallId"`
Provider string `json:"provider"`
Name string `json:"name"`
APIKey string `json:"apiKey"`
+4
View File
@@ -28,6 +28,10 @@ type AppInstall struct {
Favorite bool `json:"favorite"`
SortOrder int `json:"sortOrder" gorm:"default:0"`
SyncModelAccount bool `json:"syncModelAccount"`
ModelAccountBaseURLType string `json:"modelAccountBaseURLType"`
ModelAccountBaseURL string `json:"modelAccountBaseURL"`
App App `json:"app" gorm:"-:migration"`
}
+21
View File
@@ -4,6 +4,7 @@ import (
"strings"
"github.com/1Panel-dev/1Panel/agent/app/model"
"github.com/1Panel-dev/1Panel/agent/global"
"gorm.io/gorm"
)
@@ -18,6 +19,7 @@ type IAgentAccountRepo interface {
DeleteByID(id uint) error
List(opts ...DBOption) ([]model.AgentAccount, error)
CountTextByProviders(providers []string) (map[string]int64, error)
SaveLocal(account *model.AgentAccount, models []model.AgentAccountModel) error
}
func NewIAgentAccountRepo() IAgentAccountRepo {
@@ -97,6 +99,25 @@ func (a AgentAccountRepo) CountTextByProviders(providers []string) (map[string]i
return counts, nil
}
func (a AgentAccountRepo) SaveLocal(account *model.AgentAccount, models []model.AgentAccountModel) error {
return global.DB.Transaction(func(tx *gorm.DB) error {
if err := tx.Save(account).Error; err != nil {
return err
}
if err := tx.Where("account_id = ?", account.ID).Delete(&model.AgentAccountModel{}).Error; err != nil {
return err
}
if len(models) == 0 {
return nil
}
for i := range models {
models[i].AccountID = account.ID
models[i].SortOrder = i + 1
}
return tx.Create(&models).Error
})
}
func normalizeProviders(providers []string) []string {
seen := make(map[string]struct{}, len(providers))
result := make([]string, 0, len(providers))
+7
View File
@@ -1,6 +1,8 @@
package repo
import (
"context"
"github.com/1Panel-dev/1Panel/agent/app/model"
"github.com/1Panel-dev/1Panel/agent/global"
)
@@ -14,6 +16,7 @@ type IAiRepo interface {
Create(cronjob *model.OllamaModel) error
Update(id uint, vars map[string]interface{}) error
Delete(opts ...DBOption) error
DeleteAll(ctx context.Context) error
}
func NewIAiRepo() IAiRepo {
@@ -67,3 +70,7 @@ func (u *AiRepo) Delete(opts ...DBOption) error {
}
return db.Delete(&model.OllamaModel{}).Error
}
func (u *AiRepo) DeleteAll(ctx context.Context) error {
return getTx(ctx).Where("1 = 1").Delete(&model.OllamaModel{}).Error
}
+43 -5
View File
@@ -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)
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 {
+5 -1
View File
@@ -8,11 +8,15 @@ import (
)
const hermesWeixinLoginScript = `import asyncio
try:
from plugins.platforms.weixin.adapter import qr_login
except ModuleNotFoundError as exc:
if exc.name not in ('plugins', 'plugins.platforms', 'plugins.platforms.weixin', 'plugins.platforms.weixin.adapter'):
raise
from gateway.platforms.weixin import qr_login
from hermes_cli.config import save_env_value
creds = asyncio.run(qr_login('/opt/data'))
print('RESULT =', creds)
if not creds:
raise SystemExit(1)
+202
View File
@@ -0,0 +1,202 @@
package service
import (
"errors"
"fmt"
"strings"
"github.com/1Panel-dev/1Panel/agent/app/dto"
"github.com/1Panel-dev/1Panel/agent/app/model"
"github.com/1Panel-dev/1Panel/agent/app/repo"
"github.com/1Panel-dev/1Panel/agent/constant"
"github.com/1Panel-dev/1Panel/agent/global"
terminalai "github.com/1Panel-dev/1Panel/agent/utils/terminal/ai"
"gorm.io/gorm"
)
func SyncLocalModelAccount(install model.AppInstall, accountID uint, baseURL string, models []dto.AgentAccountModel, updateBaseURL, createIfMissing bool) (*model.AgentAccount, error) {
if install.ID == 0 || (install.App.Key != "ollama" && install.App.Key != "vllm") {
return nil, fmt.Errorf("invalid local model installation")
}
agentAccountMutationMu.Lock()
defer agentAccountMutationMu.Unlock()
account, err := agentAccountRepo.GetFirst(repo.WithByAppInstallID(install.ID))
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, err
}
if errors.Is(err, gorm.ErrRecordNotFound) && accountID != 0 {
account, err = agentAccountRepo.GetFirst(repo.WithByID(accountID), repo.WithByProvider(install.App.Key))
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, err
}
if err == nil && account.AppInstallID != 0 && account.AppInstallID != install.ID {
return nil, fmt.Errorf("model account belongs to another installation")
}
}
if errors.Is(err, gorm.ErrRecordNotFound) {
if !createIfMissing {
return nil, nil
}
name := install.Name
for suffix := 1; ; suffix++ {
existing, err := agentAccountRepo.List(repo.WithByProvider(install.App.Key), repo.WithByName(name))
if err != nil {
return nil, err
}
if len(existing) == 0 {
break
}
name = fmt.Sprintf("%s-%d", install.Name, suffix)
}
apiType, apiKey := "openai-completions", "vllm-local"
if install.App.Key == "ollama" {
apiType, apiKey = "openai-responses", "ollama-local"
}
account = &model.AgentAccount{Name: name, Provider: install.App.Key, BaseURL: baseURL, APIType: apiType, APIKey: apiKey, RememberAPIKey: true, Verified: true}
}
accountChanged := account.ID == 0 || account.AppInstallID != install.ID || (updateBaseURL && account.BaseURL != baseURL)
account.AppInstallID = install.ID
if updateBaseURL {
account.BaseURL = baseURL
}
rows := make([]model.AgentAccountModel, 0, len(models))
verifyModelExists := account.VerifyModel == ""
for _, item := range models {
rows = append(rows, model.AgentAccountModel{Model: item.ID, Name: item.Name})
if item.ID == account.VerifyModel {
verifyModelExists = true
}
}
if !verifyModelExists {
account.VerifyModel = ""
accountChanged = true
}
var previous []model.AgentAccountModel
if account.ID != 0 {
previous, err = agentAccountModelRepo.List(repo.WithByAccountID(account.ID), repo.WithOrderAsc("sort_order"), repo.WithOrderAsc("id"))
if err != nil {
return nil, err
}
}
modelsChanged := len(previous) != len(rows)
if !modelsChanged {
for i := range rows {
if previous[i].Model != rows[i].Model || previous[i].Name != rows[i].Name {
modelsChanged = true
break
}
}
}
if modelsChanged {
err = agentAccountRepo.SaveLocal(account, rows)
} else if accountChanged {
err = agentAccountRepo.Save(account)
} else {
return account, nil
}
if err != nil {
return nil, err
}
terminalai.InvalidateTerminalRuntimeCache()
terminalai.InvalidateFileAIRuntimeCache()
return account, nil
}
func SyncOllamaModelAccount(createIfMissing bool) error {
app, err := appRepo.GetFirst(appRepo.WithKey("ollama"))
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil
}
if err != nil {
return err
}
install, err := appInstallRepo.GetFirst(appInstallRepo.WithAppId(app.ID))
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil
}
if err != nil {
return err
}
if !install.SyncModelAccount {
return nil
}
return syncOllamaModelAccount(install, fmt.Sprintf("http://%s:11434/v1", install.ContainerName), false, createIfMissing)
}
func syncOllamaModelAccount(install model.AppInstall, baseURL string, updateBaseURL, createIfMissing bool) error {
models, err := aiRepo.List(repo.WithByStatus(constant.StatusSuccess), repo.WithOrderAsc("name"))
if err != nil {
return err
}
items := make([]dto.AgentAccountModel, 0, len(models))
for _, item := range models {
items = append(items, dto.AgentAccountModel{ID: item.Name, Name: item.Name})
}
_, err = SyncLocalModelAccount(install, 0, baseURL, items, updateBaseURL, createIfMissing)
return err
}
func loadOllamaModelAccountAddress(install model.AppInstall) (string, string, error) {
addressType := install.ModelAccountBaseURLType
if addressType == "" {
addressType = "container"
}
account, err := agentAccountRepo.GetFirst(repo.WithByAppInstallID(install.ID), repo.WithByProvider("ollama"))
if errors.Is(err, gorm.ErrRecordNotFound) {
return addressType, install.ModelAccountBaseURL, nil
}
if err != nil {
return "", "", err
}
if addressType != "custom" {
expectedURL, err := ResolveLocalModelAccountBaseURL(addressType, "", install.ContainerName, "11434", install.HttpPort)
if err != nil {
return "", "", err
}
if strings.TrimRight(account.BaseURL, "/") != expectedURL {
addressType = "custom"
}
}
return addressType, account.BaseURL, nil
}
func CleanupLocalModelAccount(installID uint) {
if installID == 0 {
return
}
accounts, err := agentAccountRepo.List(repo.WithByAppInstallID(installID))
if err != nil {
global.LOG.Errorf("load local model accounts: %v", err)
return
}
for _, account := range accounts {
if err := (AgentService{}).DeleteAccount(dto.AgentAccountDeleteReq{ID: account.ID}); err != nil {
global.LOG.Warnf("keep local model account %d after uninstall: %v", account.ID, err)
}
}
}
func ResolveLocalModelAccountBaseURL(addressType, customURL, containerName, containerPort string, hostPort int) (string, error) {
switch addressType {
case "custom":
baseURL := strings.TrimRight(strings.TrimSpace(customURL), "/")
if !strings.HasPrefix(baseURL, "http://") && !strings.HasPrefix(baseURL, "https://") {
return "", fmt.Errorf("model account base URL must start with http:// or https://")
}
return baseURL, nil
case "localhost":
return fmt.Sprintf("http://127.0.0.1:%d/v1", hostPort), nil
case "systemIP":
systemIP, err := settingRepo.GetValueByKey("SystemIP")
if err != nil {
return "", err
}
systemIP = strings.TrimSpace(systemIP)
if systemIP == "" {
systemIP = "127.0.0.1"
}
return fmt.Sprintf("http://%s:%d/v1", systemIP, hostPort), nil
default:
return fmt.Sprintf("http://%s:%s/v1", containerName, containerPort), nil
}
}
+2 -2
View File
@@ -98,7 +98,7 @@ func ensureContainerRunning(containerName string) error {
return nil
}
func resolveAgentAccountInput(provider, apiType, authMode, apiKey, baseURL, modelID string, validateAvailability bool) (resolvedAgentAccountInput, error) {
func resolveAgentAccountInput(provider, apiType, authMode, apiKey, baseURL, modelID string, validateAvailability, allowEmptyModel bool) (resolvedAgentAccountInput, error) {
resolvedAPIKey := strings.TrimSpace(apiKey)
resolvedAPIType := strings.TrimSpace(apiType)
resolvedAuthMode, err := providercatalog.ResolveAuthMode(provider, resolvedAPIType, authMode)
@@ -113,7 +113,7 @@ func resolveAgentAccountInput(provider, apiType, authMode, apiKey, baseURL, mode
return resolvedAgentAccountInput{}, err
}
modelID = strings.TrimSpace(modelID)
if modelID == "" {
if modelID == "" && !allowEmptyModel {
return resolvedAgentAccountInput{}, buserr.New("ErrAgentAccountModelsRequired")
}
imageAPI := providercatalog.IsImageAPIType(resolvedAPIType)
+107 -47
View File
@@ -8,6 +8,7 @@ import (
"os/exec"
"path"
"strings"
"sync"
"syscall"
"time"
@@ -28,13 +29,15 @@ import (
type AIToolService struct{}
var ollamaModelMu sync.Mutex
type IAIToolService interface {
Search(search dto.SearchWithPage) (int64, []dto.OllamaModelInfo, error)
Create(req dto.OllamaModelName) error
Close(name string) error
Recreate(req dto.OllamaModelName) error
Delete(req dto.ForceDelete) error
Sync() ([]dto.OllamaModelDropList, error)
SyncAccount(req dto.OllamaModelAccountSync) error
LoadDetail(name string) (string, error)
BindDomain(req dto.OllamaBindDomain) error
GetBindDomain(req dto.OllamaBindDomainReq) (*dto.OllamaBindDomainRes, error)
@@ -46,6 +49,7 @@ func NewIAIToolService() IAIToolService {
}
func (u *AIToolService) Search(req dto.SearchWithPage) (int64, []dto.OllamaModelInfo, error) {
syncErr := syncOllamaModels()
var options []repo.DBOption
if len(req.Info) != 0 {
options = append(options, repo.WithByLikeName(req.Info))
@@ -60,6 +64,11 @@ func (u *AIToolService) Search(req dto.SearchWithPage) (int64, []dto.OllamaModel
if err := copier.Copy(&item, &itemModel); err != nil {
return 0, nil, buserr.WithDetail("ErrStructTransform", err.Error(), nil)
}
if syncErr != nil && item.Status != constant.StatusWaiting {
item.Status = constant.StatusUnknown
item.Message = syncErr.Error()
item.Size = ""
}
taskModel, _ := taskRepo.GetFirst(taskRepo.WithResourceID(item.ID), repo.WithByType(task.TaskScopeAI))
if len(taskModel.ID) != 0 {
item.LogFileExist = true
@@ -85,6 +94,8 @@ func (u *AIToolService) LoadDetail(name string) (string, error) {
}
func (u *AIToolService) Create(req dto.OllamaModelName) error {
ollamaModelMu.Lock()
defer ollamaModelMu.Unlock()
if cmd.CheckIllegal(req.Name) {
return buserr.New("ErrCmdIllegal")
}
@@ -125,6 +136,9 @@ func (u *AIToolService) Create(req dto.OllamaModelName) error {
if err := taskItem.Execute(); err != nil {
_ = aiRepo.Update(info.ID, map[string]interface{}{"status": constant.StatusFailed, "message": err.Error()})
}
if err := SyncOllamaModelAccount(false); err != nil {
global.LOG.Errorf("sync Ollama model account after pull: %v", err)
}
}()
return nil
}
@@ -144,6 +158,8 @@ func (u *AIToolService) Close(name string) error {
}
func (u *AIToolService) Recreate(req dto.OllamaModelName) error {
ollamaModelMu.Lock()
defer ollamaModelMu.Unlock()
if cmd.CheckIllegal(req.Name) {
return buserr.New("ErrCmdIllegal")
}
@@ -179,11 +195,16 @@ func (u *AIToolService) Recreate(req dto.OllamaModelName) error {
if err := taskItem.Execute(); err != nil {
_ = aiRepo.Update(modelInfo.ID, map[string]interface{}{"status": constant.StatusFailed, "message": err.Error()})
}
if err := SyncOllamaModelAccount(false); err != nil {
global.LOG.Errorf("sync Ollama model account after pull: %v", err)
}
}()
return nil
}
func (u *AIToolService) Delete(req dto.ForceDelete) error {
ollamaModelMu.Lock()
defer ollamaModelMu.Unlock()
ollamaList, _ := aiRepo.List(repo.WithByIDs(req.IDs))
if len(ollamaList) == 0 {
return buserr.New("ErrRecordNotFound")
@@ -202,55 +223,28 @@ func (u *AIToolService) Delete(req dto.ForceDelete) error {
logItem := path.Join(global.Dir.DataDir, "log", "AITools", item.Name)
_ = os.Remove(logItem)
}
return nil
return SyncOllamaModelAccount(false)
}
func (u *AIToolService) Sync() ([]dto.OllamaModelDropList, error) {
containerName, err := LoadContainerName()
func (u *AIToolService) SyncAccount(req dto.OllamaModelAccountSync) error {
install, err := appInstallRepo.GetFirst(repo.WithByID(req.ID))
if err != nil {
return nil, err
return err
}
stdout, err := cmd.RunDockerExecWithStdout(20*time.Second, containerName, "ollama", "list")
if install.App.Key != "ollama" {
return buserr.New("ErrInvalidParams")
}
baseURL, err := ResolveLocalModelAccountBaseURL(req.ModelAccountBaseURLType, req.ModelAccountBaseURL, install.ContainerName, "11434", install.HttpPort)
if err != nil {
return nil, err
return err
}
var list []model.OllamaModel
lines := strings.Split(stdout, "\n")
for _, line := range lines {
parts := strings.Fields(line)
if len(parts) < 5 {
continue
if err := syncOllamaModelAccount(install, baseURL, true, true); err != nil {
return err
}
if parts[0] == "NAME" {
continue
}
list = append(list, model.OllamaModel{Name: parts[0], Size: parts[2] + " " + parts[3]})
}
listInDB, _ := aiRepo.List()
var dropList []dto.OllamaModelDropList
for _, itemModel := range listInDB {
isExit := false
for i := 0; i < len(list); i++ {
if list[i].Name == itemModel.Name {
_ = aiRepo.Update(itemModel.ID, map[string]interface{}{"status": constant.StatusSuccess, "message": "", "size": list[i].Size})
list = append(list[:i], list[(i+1):]...)
isExit = true
break
}
}
if !isExit && itemModel.Status != constant.StatusWaiting {
_ = aiRepo.Update(itemModel.ID, map[string]interface{}{"status": constant.StatusDeleted, "message": "not exist", "size": ""})
dropList = append(dropList, dto.OllamaModelDropList{ID: itemModel.ID, Name: itemModel.Name})
continue
}
}
for _, item := range list {
item.Status = constant.StatusSuccess
item.From = "remote"
_ = aiRepo.Create(&item)
}
return dropList, nil
return appInstallRepo.BatchUpdateBy(map[string]interface{}{
"model_account_base_url_type": req.ModelAccountBaseURLType,
"model_account_base_url": baseURL,
}, repo.WithByID(install.ID))
}
func (u *AIToolService) BindDomain(req dto.OllamaBindDomain) error {
@@ -370,6 +364,72 @@ func (u *AIToolService) UpdateBindDomain(req dto.OllamaBindDomain) error {
return nil
}
func syncOllamaModels() error {
ollamaModelMu.Lock()
defer ollamaModelMu.Unlock()
containerName, err := LoadContainerName()
if err != nil {
return err
}
stdout, err := cmd.RunDockerExecWithStdout(20*time.Second, containerName, "ollama", "list")
if err != nil {
return err
}
models := make(map[string]string)
lines := strings.Split(strings.TrimSpace(stdout), "\n")
if len(lines) == 0 || !strings.HasPrefix(strings.TrimSpace(lines[0]), "NAME") {
return fmt.Errorf("invalid ollama list output: %s", stdout)
}
for _, line := range lines[1:] {
if strings.TrimSpace(line) == "" {
continue
}
parts := strings.Fields(line)
if len(parts) < 5 {
return fmt.Errorf("invalid ollama model entry: %s", line)
}
models[parts[0]] = parts[2] + " " + parts[3]
}
list, err := aiRepo.List()
if err != nil {
return err
}
known := make(map[string]bool, len(list))
for _, item := range list {
name := item.Name
if !strings.Contains(path.Base(name), ":") {
name += ":latest"
}
size, exists := models[name]
known[name] = true
if item.Status == constant.StatusWaiting {
continue
}
status, message := constant.StatusSuccess, ""
if !exists {
if item.Status == constant.StatusFailed || item.Status == constant.StatusCanceled {
continue
}
status, message = constant.StatusDeleted, "not exist"
}
if item.Status != status || item.Size != size || item.Message != message {
if err := aiRepo.Update(item.ID, map[string]interface{}{"status": status, "size": size, "message": message}); err != nil {
return err
}
}
}
for name, size := range models {
if known[name] {
continue
}
item := model.OllamaModel{Name: name, Size: size, From: "remote", Status: constant.StatusSuccess}
if err := aiRepo.Create(&item); err != nil {
return err
}
}
return nil
}
func LoadContainerName() (string, error) {
ollamaBaseInfo, err := appInstallRepo.LoadBaseInfo("ollama", "")
if err != nil {
@@ -387,12 +447,12 @@ func loadModelSize(name string, containerName string) (string, error) {
return "", err
}
lines := strings.Split(stdout, "\n")
for _, line := range lines {
if !strings.Contains(line, name) {
continue
if !strings.Contains(path.Base(name), ":") {
name += ":latest"
}
for _, line := range lines {
parts := strings.Fields(line)
if len(parts) < 5 {
if len(parts) < 5 || parts[0] != name {
continue
}
return parts[2] + " " + parts[3], nil
+20
View File
@@ -499,6 +499,22 @@ func (a AppService) installWithHooks(req request.AppInstallCreate, executeScript
req.Params[constant.ContainerName] = containerName
appInstall.ContainerName = containerName
modelAccountBaseURL := ""
if app.Key == "ollama" {
appInstall.SyncModelAccount = req.SyncModelAccount == nil || *req.SyncModelAccount
appInstall.ModelAccountBaseURLType = req.ModelAccountBaseURLType
if appInstall.ModelAccountBaseURLType == "" {
appInstall.ModelAccountBaseURLType = "container"
}
appInstall.ModelAccountBaseURL = req.ModelAccountBaseURL
}
if app.Key == "ollama" && appInstall.SyncModelAccount {
modelAccountBaseURL, err = ResolveLocalModelAccountBaseURL(req.ModelAccountBaseURLType, req.ModelAccountBaseURL, containerName, "11434", httpPort)
if err != nil {
return
}
}
index := 0
serviceName := ""
for k := range servicesMap {
@@ -609,6 +625,10 @@ func (a AppService) installWithHooks(req request.AppInstallCreate, executeScript
return err
}
updateToolApp(appInstall)
if app.Key == "ollama" && appInstall.SyncModelAccount {
_, err := SyncLocalModelAccount(*appInstall, 0, modelAccountBaseURL, nil, false, true)
return err
}
return nil
}
+49
View File
@@ -159,6 +159,14 @@ func (a *AppInstallService) CheckExist(req request.AppInstalledInfo) (*response.
}
res.ContainerName = appInstall.ContainerName
res.ModelAccountBaseURLType = appInstall.ModelAccountBaseURLType
res.ModelAccountBaseURL = appInstall.ModelAccountBaseURL
if app.Key == "ollama" {
res.ModelAccountBaseURLType, res.ModelAccountBaseURL, err = loadOllamaModelAccountAddress(appInstall)
if err != nil {
return nil, err
}
}
res.Name = appInstall.Name
res.Version = appInstall.Version
res.CreatedAt = appInstall.CreatedAt
@@ -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
}
+53
View File
@@ -0,0 +1,53 @@
package service
import (
"errors"
"os"
"path"
"github.com/1Panel-dev/1Panel/agent/app/dto/request"
"github.com/1Panel-dev/1Panel/agent/app/model"
"github.com/1Panel-dev/1Panel/agent/constant"
"github.com/1Panel-dev/1Panel/agent/global"
"gorm.io/gorm"
)
func prepareMCPWebsiteDirectory(req request.AppInstallCreate) error {
changed := errors.New("MCP website directory changed; inspect the installation before retrying")
directory, ok := req.Params["WEBSITE_DIR"].(string)
if !ok || directory == "" || global.DB == nil {
return changed
}
return global.DB.Transaction(func(tx *gorm.DB) error {
var setting model.Setting
err := tx.Where("key = ?", "WEBSITE_DIR").First(&setting).Error
missing := errors.Is(err, gorm.ErrRecordNotFound)
if err != nil && !missing {
return err
}
current := setting.Value
if current == "" {
current = path.Join(global.Dir.BaseDir, "1panel", "www")
}
if directory != current {
return changed
}
if missing {
if err := tx.Create(&model.Setting{Key: "WEBSITE_DIR", Value: directory}).Error; err != nil {
return err
}
} else if setting.Value == "" {
result := tx.Model(&model.Setting{}).Where("id = ? AND value = ?", setting.ID, "").Update("value", directory)
if result.Error != nil {
return result.Error
}
if result.RowsAffected != 1 {
return changed
}
}
if err := os.MkdirAll(path.Join(directory, "conf.d"), constant.DirPerm); err != nil {
return err
}
return nil
})
}
+24 -1
View File
@@ -391,12 +391,27 @@ func deleteAppInstall(deleteReq request.AppInstallDelete) error {
}
}
}
if install.App.Key == "ollama" {
ollamaModelMu.Lock()
defer ollamaModelMu.Unlock()
}
tx, ctx := helper.GetTxAndContext()
defer tx.Rollback()
if err = appInstallRepo.Delete(ctx, install); err != nil {
return err
}
appKey := install.App.Key
if appKey == "ollama" {
remaining, listErr := appInstallRepo.ListBy(ctx, appInstallRepo.WithAppId(install.AppId))
if listErr != nil {
return listErr
}
if len(remaining) == 0 {
if err = aiRepo.DeleteAll(ctx); err != nil {
return err
}
}
}
if isAgentAppKey(appKey) {
_ = agentRepo.DeleteByAppInstallIDWithCtx(ctx, install.ID)
}
@@ -469,7 +484,12 @@ func deleteAppInstall(deleteReq request.AppInstallDelete) error {
if err == nil && len(entries) == 0 {
_ = op.DeleteDir(parentDir)
}
tx.Commit()
if err := tx.Commit().Error; err != nil {
return err
}
if install.App.Key == "ollama" || install.App.Key == "vllm" {
CleanupLocalModelAccount(install.ID)
}
existApps, _ := appInstallRepo.ListBy(context.Background(), appInstallRepo.WithAppId(install.AppId))
if len(existApps) == 0 {
@@ -2015,6 +2035,9 @@ func getAppTags(appID uint, lang string) ([]response.TagDTO, error) {
func handleSiteDir(app model.App, appDetail model.AppDetail, req request.AppInstallCreate, t *task.Task) error {
if app.Key == "openresty" && (app.Resource == "remote" || app.Resource == "custom") && common.CompareVersion(appDetail.Version, "1.27") {
if req.MCPPreserveWebsiteDir {
return prepareMCPWebsiteDirectory(req)
}
if dir, ok := req.Params["WEBSITE_DIR"]; ok {
siteDir := dir.(string)
if siteDir == "" || !strings.HasPrefix(siteDir, "/") {
+50 -22
View File
@@ -58,10 +58,10 @@ type ContainerService struct{}
var containerLogAnsiRegex = regexp.MustCompile("\x1b\\[[0-9;?]*[A-Za-z]|\x1b=|\x1b>")
type IContainerService interface {
Page(req dto.PageContainer) (int64, interface{}, error)
Page(ctx context.Context, req dto.PageContainer) (int64, interface{}, error)
List() []dto.ContainerOptions
ListByImage(imageName string) []dto.ContainerOptions
LoadStatus() (dto.ContainerStatus, error)
LoadStatus(ctx context.Context, containersOnly bool) (dto.ContainerStatus, error)
PageNetwork(req dto.SearchWithPage) (int64, interface{}, error)
ListNetwork() ([]dto.Options, error)
PageVolume(req dto.SearchWithPage) (int64, interface{}, error)
@@ -80,7 +80,7 @@ type IContainerService interface {
ContainerUpdate(req dto.ContainerOperate) error
ContainerUpgrade(req dto.ContainerUpgrade) error
ContainerInfo(req dto.OperationWithName) (*dto.ContainerOperate, error)
ContainerListStats() ([]dto.ContainerListStats, error)
ContainerListStats(ctx context.Context, ids []string) ([]dto.ContainerListStats, error)
ContainerItemStats(ctx context.Context, req dto.OperationWithName) (dto.ContainerItemStats, error)
LoadResourceLimit() (*dto.ResourceLimit, error)
ContainerRename(req dto.ContainerRename) error
@@ -112,7 +112,7 @@ func NewIContainerService() IContainerService {
return &ContainerService{}
}
func (u *ContainerService) Page(req dto.PageContainer) (int64, interface{}, error) {
func (u *ContainerService) Page(ctx context.Context, req dto.PageContainer) (int64, interface{}, error) {
client, err := docker.NewDockerClient()
if err != nil {
return 0, nil, err
@@ -123,7 +123,7 @@ func (u *ContainerService) Page(req dto.PageContainer) (int64, interface{}, erro
options.Filters = filters.NewArgs()
options.Filters.Add("label", req.Filters)
}
containers, err := client.ContainerList(context.Background(), options)
containers, err := client.ContainerList(ctx, options)
if err != nil {
return 0, nil, err
}
@@ -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})
if !containersOnly {
images, _ := client.ImageList(ctx, image.ListOptions{All: true})
data.ImageCount = len(images)
repo, _ := imageRepoRepo.List()
data.RepoCount = len(repo)
templates, _ := composeRepo.List()
data.ComposeTemplateCount = len(templates)
networks, _ := client.NetworkList(c, network.ListOptions{})
networks, _ := client.NetworkList(ctx, network.ListOptions{})
data.NetworkCount = len(networks)
volumes, _ := client.VolumeList(c, volume.ListOptions{})
volumes, _ := client.VolumeList(ctx, volume.ListOptions{})
data.VolumeCount = len(volumes.Volumes)
data.ComposeCount = loadComposeCount(client)
containers, _ := client.ContainerList(c, container.ListOptions{All: true})
}
containers, err := client.ContainerList(ctx, container.ListOptions{All: true})
if err != nil {
return data, err
}
data.ContainerCount = len(containers)
for _, item := range containers {
switch item.State {
@@ -292,25 +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
}
+84 -5
View File
@@ -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
+24
View File
@@ -0,0 +1,24 @@
package service
import (
"context"
"errors"
"time"
"github.com/1Panel-dev/1Panel/agent/app/dto"
"github.com/1Panel-dev/1Panel/agent/app/model"
"github.com/1Panel-dev/1Panel/agent/utils/mcpapi"
)
func (u *MysqlService) CreateForMCP(ctx context.Context, req dto.MysqlDBCreate) (*model.DatabaseMysql, error) {
if req.From != "local" || ctx.Err() != nil {
return nil, errors.New("invalid MCP database target or request")
}
writeContext, cancel := context.WithTimeout(context.WithoutCancel(ctx), 310*time.Second)
defer cancel()
created, err := u.create(writeContext, req, true)
if err != nil {
return nil, mcpapi.SanitizeBusinessError(err, "database creation failed; inspect the invocation before retrying")
}
return created, nil
}
+10
View File
@@ -882,6 +882,16 @@ func (f *FileService) ChangeName(req request.FileRename) error {
if hasInvalidFileName(req.NewName) {
return buserr.New("ErrInvalidChar")
}
if req.OldName == req.NewName {
return nil
}
// os.Rename silently replaces an existing target on Linux, so reject the
// rename here to avoid losing the target file without confirmation.
if _, err := os.Lstat(req.NewName); err == nil {
return buserr.New("ErrFileIsExist")
} else if !os.IsNotExist(err) {
return err
}
fo := files.NewFileOp()
content, mode, shouldRecordHistory := readEditableFileHistoryContent(req.OldName)
if err := fo.Rename(req.OldName, req.NewName); err != nil {
+63
View File
@@ -0,0 +1,63 @@
package service
import (
"errors"
"os"
"path/filepath"
"testing"
"github.com/1Panel-dev/1Panel/agent/app/dto/request"
"github.com/1Panel-dev/1Panel/agent/buserr"
)
func TestChangeNameRejectsExistingTarget(t *testing.T) {
dir := t.TempDir()
oldPath := filepath.Join(dir, "old.txt")
targetPath := filepath.Join(dir, "target.txt")
// Binary source content keeps this test independent of the file-history DB.
if err := os.WriteFile(oldPath, []byte("old\x00"), 0644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(targetPath, []byte("target"), 0644); err != nil {
t.Fatal(err)
}
err := (&FileService{}).ChangeName(request.FileRename{OldName: oldPath, NewName: targetPath})
if err == nil {
t.Fatal("expected an error when the target name already exists")
}
var bizErr buserr.BusinessError
if !errors.As(err, &bizErr) || bizErr.Msg != "ErrFileIsExist" {
t.Fatalf("expected ErrFileIsExist, got %v", err)
}
content, err := os.ReadFile(targetPath)
if err != nil {
t.Fatal(err)
}
if string(content) != "target" {
t.Fatalf("target file was overwritten, content = %q", content)
}
if _, err := os.Stat(oldPath); err != nil {
t.Fatalf("source file should still exist after a rejected rename: %v", err)
}
}
func TestChangeNameRenamesToNewName(t *testing.T) {
dir := t.TempDir()
oldPath := filepath.Join(dir, "old")
newPath := filepath.Join(dir, "new")
if err := os.Mkdir(oldPath, 0755); err != nil {
t.Fatal(err)
}
if err := (&FileService{}).ChangeName(request.FileRename{OldName: oldPath, NewName: newPath}); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(newPath); err != nil {
t.Fatalf("renamed path does not exist: %v", err)
}
if _, err := os.Stat(oldPath); !os.IsNotExist(err) {
t.Fatalf("old path should be gone, stat err = %v", err)
}
}
+50
View File
@@ -0,0 +1,50 @@
package service
import (
"errors"
"regexp"
"strings"
"unicode"
"unicode/utf8"
"github.com/1Panel-dev/1Panel/agent/utils/mcpapi"
)
const mcpMysqlStoppedMessage = "The selected MySQL instance is not running. Start the database service and try the query again."
const mcpMysqlConnectionFailedMessage = "Cannot connect to the selected MySQL instance. Check the database service, connection settings and panel logs."
var errMCPMysqlStopped = mcpapi.NewBusinessError(mcpapi.CodeMySQLStopped, mcpMysqlStoppedMessage)
var errMCPMysqlConnectionFailed = mcpapi.NewBusinessError(mcpapi.CodeMySQLConnectionFailed, mcpMysqlConnectionFailedMessage)
var mcpMysqlStoppedPattern = regexp.MustCompile(`^Error response from daemon: [Cc]ontainer [A-Za-z0-9][A-Za-z0-9_.-]{0,127} is not running$`)
var mcpMysqlConnectionPatterns = []*regexp.Regexp{
regexp.MustCompile(`^ERROR 2002 \(HY000\): Can't connect to local MySQL server through socket '[^'\r\n]{1,1000}' \([0-9]{1,10}\)$`),
regexp.MustCompile(`^ERROR 2003 \(HY000\): Can't connect to MySQL server on '[^'\r\n]{1,1000}' \([0-9]{1,10}\)$`),
regexp.MustCompile(`^ERROR 1045 \(28000\): Access denied for user '[^'\r\n]{1,128}'@'[^'\r\n]{1,255}' \(using password: (YES|NO)\)$`),
}
func classifyMCPMysqlReadFailure(stderr string) error {
generic := errors.New("local MySQL status query failed")
if len(stderr) == 0 || len(stderr) > 2048 || !utf8.ValidString(stderr) {
return generic
}
warning := "mysql: [Warning] Using a password on the command line interface can be insecure."
stderr = strings.TrimPrefix(stderr, warning+"\n")
if strings.HasSuffix(stderr, "\r\n") {
stderr = strings.TrimSuffix(stderr, "\r\n")
} else {
stderr = strings.TrimSuffix(stderr, "\n")
}
if strings.IndexFunc(stderr, unicode.IsControl) >= 0 {
return generic
}
if mcpMysqlStoppedPattern.MatchString(stderr) {
return errMCPMysqlStopped
}
for _, pattern := range mcpMysqlConnectionPatterns {
if pattern.MatchString(stderr) {
return errMCPMysqlConnectionFailed
}
}
return generic
}
+189
View File
@@ -0,0 +1,189 @@
package service
import (
"bytes"
"context"
"encoding/json"
"errors"
"io"
"os/exec"
"sort"
"strconv"
"strings"
"sync"
"time"
"github.com/1Panel-dev/1Panel/agent/app/dto"
"github.com/1Panel-dev/1Panel/agent/app/dto/response"
"github.com/1Panel-dev/1Panel/agent/app/model"
"github.com/1Panel-dev/1Panel/agent/app/repo"
"github.com/1Panel-dev/1Panel/agent/constant"
"github.com/1Panel-dev/1Panel/agent/global"
"gorm.io/gorm"
)
const mcpMysqlQueryTimeout = 30 * time.Second
const mcpMysqlQueryOutputLimit = 1 << 20
func mcpLocalMysqlQuery(ctx context.Context) *gorm.DB {
return global.DB.WithContext(ctx).Table("databases AS d").
Joins("JOIN app_installs AS ai ON ai.id = d.app_install_id AND ai.name = d.name").
Joins("JOIN apps AS a ON a.id = ai.app_id").
Where("d.type = ? AND d.\"from\" = ? AND a.key = ?", constant.AppMysql, "local", constant.AppMysql).
Where("ai.status <> ? AND ai.container_name <> ?", constant.StatusDeleted, "")
}
func (u *DatabaseService) ListForMCP(ctx context.Context, dbType string) ([]dto.DatabaseOption, error) {
if dbType != constant.AppMysql {
return nil, errors.New("only installed local MySQL instances are supported")
}
result := make([]dto.DatabaseOption, 0)
err := mcpLocalMysqlQuery(ctx).Select("d.id, d.type, d.\"from\", d.name AS database, ai.version").Order("d.id").Scan(&result).Error
if err != nil {
return nil, errors.New("cannot read local MySQL instances")
}
return result, nil
}
type mcpMysqlReadRunner func(context.Context, string, string, string) (string, error)
func (u *MysqlService) LoadStatusForMCP(ctx context.Context, req dto.OperationWithNameAndType) (*dto.MysqlStatus, error) {
return u.loadStatusForMCP(ctx, req, runMCPMysqlRead)
}
func (u *MysqlService) loadStatusForMCP(ctx context.Context, req dto.OperationWithNameAndType, run mcpMysqlReadRunner) (*dto.MysqlStatus, error) {
if req.Type != constant.AppMysql || req.Name == "" || len(req.Name) > 128 || strings.TrimSpace(req.Name) != req.Name || strings.ContainsAny(req.Name, "\x00\r\n/\\") || ctx.Err() != nil {
return nil, errors.New("an explicitly named installed local MySQL instance is required")
}
ctx, cancel := context.WithTimeout(ctx, mcpMysqlQueryTimeout)
defer cancel()
var target struct {
ID uint
ContainerName, Version, Env string
}
if err := mcpLocalMysqlQuery(ctx).Where("d.name = ?", req.Name).Select("d.id, ai.container_name, ai.version, ai.env").Take(&target).Error; err != nil || target.ID == 0 {
return nil, errors.New("the installed local MySQL instance is unavailable")
}
var env map[string]any
if json.Unmarshal([]byte(target.Env), &env) != nil {
return nil, errors.New("local MySQL configuration is unavailable")
}
password, ok := env["PANEL_DB_ROOT_PASSWORD"].(string)
if !ok || password == "" {
return nil, errors.New("local MySQL configuration is unavailable")
}
app := &repo.RootInfo{ContainerName: target.ContainerName, Version: target.Version, Key: constant.AppMysql, Password: password}
rows := func(container, _, password, sql string) ([]string, error) {
if ctx.Err() != nil {
return nil, errors.New("local MySQL status query failed")
}
output, err := run(ctx, container, password, sql)
if err != nil {
if err == errMCPMysqlStopped || err == errMCPMysqlConnectionFailed {
return nil, err
}
return nil, errors.New("local MySQL status query failed")
}
return strings.Split(output, "\n"), nil
}
maps := func(container, dbType, password, sql string) (map[string]string, error) {
lines, err := rows(container, dbType, password, sql)
if err != nil {
return nil, err
}
values := map[string]string{}
for _, line := range lines {
if pair := strings.Split(line, "\t"); len(pair) == 2 {
values[pair[0]] = pair[1]
}
}
return values, nil
}
return loadMysqlStatus(req, app, maps, rows)
}
type mcpQueryOutput struct {
buffer bytes.Buffer
mu sync.Mutex
exceeded bool
}
func (b *mcpQueryOutput) Write(data []byte) (int, error) {
b.mu.Lock()
defer b.mu.Unlock()
if len(data) > mcpMysqlQueryOutputLimit-b.buffer.Len() {
b.exceeded = true
return 0, errors.New("query output limit exceeded")
}
return b.buffer.Write(data)
}
func (b *mcpQueryOutput) Len() int {
b.mu.Lock()
defer b.mu.Unlock()
return b.buffer.Len()
}
func (b *mcpQueryOutput) String() string {
b.mu.Lock()
defer b.mu.Unlock()
return b.buffer.String()
}
func runMCPMysqlRead(ctx context.Context, container, password, sql string) (string, error) {
switch sql {
case "show global status;", "show master status;", "show binary log status;":
default:
return "", errors.New("unsupported MySQL monitoring query")
}
command := exec.CommandContext(ctx, "docker", "exec", container, "mysql", "-uroot", "-p"+password, "-e", sql)
output := &mcpQueryOutput{}
var stderr bytes.Buffer
command.Stdout, command.Stderr = output, io.MultiWriter(output, &stderr)
runErr := command.Run()
if ctx.Err() != nil || output.exceeded {
return "", errors.New("local MySQL status query failed")
}
if runErr != nil {
return "", classifyMCPMysqlReadFailure(stderr.String())
}
value := strings.ReplaceAll(output.String(), "mysql: [Warning] Using a password on the command line interface can be insecure.\n", "")
if strings.HasPrefix(value, "ERROR ") {
return "", errors.New("local MySQL status query failed")
}
return value, nil
}
func (a *AppInstallService) GetAppInstallInfoForMCP(ctx context.Context, id uint) (*response.AppInstallInfo, error) {
var installed model.AppInstall
if id == 0 {
return nil, errors.New("invalid installed application")
}
err := global.DB.WithContext(ctx).Preload("App").Where("id = ?", id).First(&installed).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return &response.AppInstallInfo{Status: constant.StatusDeleted}, nil
}
if err != nil {
return nil, errors.New("installed application metadata is unavailable")
}
var env map[string]any
if json.Unmarshal([]byte(installed.Env), &env) != nil {
return nil, errors.New("installed application metadata is unavailable")
}
result := &response.AppInstallInfo{ID: installed.ID, Name: installed.Name, Version: installed.Version, Status: installed.Status, HttpPort: installed.HttpPort, AppKey: installed.App.Key, AppPorts: []int{}}
for key, value := range env {
if !strings.Contains(strings.ToUpper(key), "PANEL_APP_PORT") {
continue
}
var port int
switch item := value.(type) {
case string:
port, _ = strconv.Atoi(item)
case float64:
if item == float64(int(item)) {
port = int(item)
}
}
if port > 0 && port <= 65535 {
result.AppPorts = append(result.AppPorts, port)
}
}
sort.Ints(result.AppPorts)
return result, nil
}
+10 -2
View File
@@ -78,9 +78,17 @@ func getNginxParamsByKeys(scope string, keys []string, website *model.Website) (
var res []response.NginxParam
var block components.IBlock
if scope == constant.NginxScopeHttp {
block = nginxFull.RootConfig.Config.FindHttp()
httpBlock := nginxFull.RootConfig.Config.FindHttp()
if httpBlock == nil {
return nil, fmt.Errorf("nginx HTTP configuration block is missing")
}
block = httpBlock
} else {
block = nginxFull.SiteConfig.Config.FindServers()[0]
servers := nginxFull.SiteConfig.Config.FindServers()
if len(servers) == 0 {
return nil, fmt.Errorf("website nginx server block is missing")
}
block = servers[0]
}
for _, key := range keys {
dirs := block.FindDirectives(key)
+21 -5
View File
@@ -10,6 +10,7 @@ import (
"os/exec"
"path"
"path/filepath"
"slices"
"sort"
"strconv"
"strings"
@@ -46,7 +47,7 @@ type RuntimeService struct {
type IRuntimeService interface {
Page(req request.RuntimeSearch) (int64, []response.RuntimeDTO, error)
Create(create request.RuntimeCreate) (*model.Runtime, error)
Delete(delete request.RuntimeDelete) error
Delete(delete request.RuntimeDelete) (string, error)
Update(req request.RuntimeUpdate) error
Get(id uint) (res *response.RuntimeDTO, err error)
GetNodePackageRunScript(req request.NodePackageReq) ([]response.PackageScripts, error)
@@ -376,16 +377,16 @@ func (r *RuntimeService) DeleteCheck(runTimeId uint) ([]dto.AppResource, error)
return res, nil
}
func (r *RuntimeService) Delete(runtimeDelete request.RuntimeDelete) error {
func (r *RuntimeService) Delete(runtimeDelete request.RuntimeDelete) (string, error) {
runtime, err := runtimeRepo.GetFirst(context.Background(), repo.WithByID(runtimeDelete.ID))
if err != nil {
return err
return "", err
}
website, _ := websiteRepo.GetFirst(websiteRepo.WithRuntimeID(runtimeDelete.ID))
if website.ID > 0 {
return buserr.New("ErrDelWithWebsite")
return runtime.Name, buserr.New("ErrDelWithWebsite")
}
return startRuntimeDeleteTask(runtimeDelete, runtime)
return runtime.Name, startRuntimeDeleteTask(runtimeDelete, runtime)
}
func startRuntimeDeleteTask(runtimeDelete request.RuntimeDelete, runtime *model.Runtime) error {
@@ -584,6 +585,21 @@ func (r *RuntimeService) Get(id uint) (*response.RuntimeDTO, error) {
for _, form := range appForm.FormFields {
if v, ok := envs[form.EnvKey]; ok {
if form.EnvKey == "PHP_VERSION" {
// Refresh only version choices; keep the saved form if synchronized metadata is unavailable.
var latestForm dto.AppForm
if json.Unmarshal([]byte(appDetail.Params), &latestForm) == nil {
for _, latest := range latestForm.FormFields {
if latest.EnvKey == form.EnvKey && len(latest.Values) > 0 {
form.Values = latest.Values
break
}
}
}
if v != "" && !slices.ContainsFunc(form.Values, func(value dto.AppFormValue) bool { return value.Value == v }) {
form.Values = append(form.Values, dto.AppFormValue{Label: v, Value: v})
}
}
appParam := response.AppParam{
Edit: false,
Key: form.EnvKey,
+116 -37
View File
@@ -3,6 +3,7 @@ package service
import (
"bytes"
"context"
"database/sql"
"encoding/base64"
"errors"
"fmt"
@@ -973,11 +974,16 @@ func (w WebsiteService) GetWebsiteHTTPS(websiteId uint) (response.WebsiteHTTPS,
if err != nil {
return res, err
}
applyWebsiteHTTPSParams(&res, params)
return res, nil
}
func applyWebsiteHTTPSParams(res *response.WebsiteHTTPS, params []response.NginxParam) {
for _, p := range params {
if p.Name == "ssl_protocols" {
res.SSLProtocol = p.Params
}
if p.Name == "ssl_ciphers" {
if p.Name == "ssl_ciphers" && len(p.Params) > 0 {
res.Algorithm = p.Params[0]
}
if p.Name == "add_header" && len(p.Params) > 0 {
@@ -995,7 +1001,6 @@ func (w WebsiteService) GetWebsiteHTTPS(websiteId uint) (response.WebsiteHTTPS,
}
}
}
return res, nil
}
func (w WebsiteService) OpWebsiteHTTPS(ctx context.Context, req request.WebsiteHTTPSOp) (*response.WebsiteHTTPS, error) {
@@ -1325,11 +1330,12 @@ func (w WebsiteService) ChangeDefaultServer(id uint) error {
return websiteRepo.Save(context.Background(), &website)
}
func (w WebsiteService) ChangePHPVersion(req request.WebsitePHPVersionReq) error {
func (w WebsiteService) ChangePHPVersion(req request.WebsitePHPVersionReq) (err error) {
website, err := websiteRepo.GetFirst(repo.WithByID(req.WebsiteID))
if err != nil {
return err
}
wasPHP := website.Type == constant.Runtime
if website.Type == constant.Runtime {
oldRuntime, err := runtimeRepo.GetFirst(context.Background(), repo.WithByID(website.RuntimeID))
if err != nil {
@@ -1347,12 +1353,45 @@ func (w WebsiteService) ChangePHPVersion(req request.WebsitePHPVersionReq) error
return buserr.WithName("ErrImageNotExist", oldRuntime.Name)
}
}
configPath := GetSitePath(website, SiteConf)
nginxContent, err := files.NewFileOp().GetContent(configPath)
if req.RuntimeID > 0 {
runtime, err := runtimeRepo.GetFirst(context.Background(), repo.WithByID(req.RuntimeID))
if err != nil {
return err
}
config, err := parser.NewStringParser(string(nginxContent)).Parse()
if runtime.Resource == constant.ResourceLocal {
return buserr.New("ErrPHPResource")
}
website.RuntimeID = runtime.ID
website.AppInstallID = 0
website.Proxy = fmt.Sprintf("127.0.0.1:%s", runtime.Port)
website.Type = constant.Runtime
} else {
website.RuntimeID = 0
website.Type = constant.Static
website.Proxy = ""
}
subsites, err := websiteRepo.List(websiteRepo.WithParentID(website.ID), websiteRepo.WithType(constant.Subsite))
if err != nil {
return err
}
websites := append([]model.Website{website}, subsites...)
configs := make([]*components.Config, 0, len(websites))
oldContents := make(map[string][]byte, len(websites))
fileOp := files.NewFileOp()
for i := range websites {
site := &websites[i]
if i > 0 {
site.RuntimeID = website.RuntimeID
site.Proxy = website.Proxy
}
configPath := GetSitePath(*site, SiteConf)
content, err := fileOp.GetContent(configPath)
if err != nil {
return err
}
config, err := parser.NewStringParser(string(content)).Parse()
if err != nil {
return err
}
@@ -1361,51 +1400,91 @@ func (w WebsiteService) ChangePHPVersion(req request.WebsitePHPVersionReq) error
return errors.New("nginx config is not valid")
}
server := servers[0]
fileOp := files.NewFileOp()
indexPHPPath := path.Join(GetSitePath(website, SiteIndexDir), "index.php")
indexHtmlPath := path.Join(GetSitePath(website, SiteIndexDir), "index.html")
if i == 0 || wasPHP != (req.RuntimeID > 0) {
if req.RuntimeID > 0 {
server.UpdateDirective("index", []string{"index.php index.html index.htm default.php default.htm default.html"})
server.RemoveDirective("location", []string{"~", "[^/]\\.php(/|$)"})
runtime, err := runtimeRepo.GetFirst(context.Background(), repo.WithByID(req.RuntimeID))
if err != nil {
return err
}
if runtime.Resource == constant.ResourceLocal {
return buserr.New("ErrPHPResource")
}
website.RuntimeID = req.RuntimeID
website.AppInstallID = 0
phpProxy := fmt.Sprintf("127.0.0.1:%s", runtime.Port)
website.Proxy = phpProxy
server.UpdatePHPProxy([]string{website.Proxy}, "")
website.Type = constant.Runtime
if !fileOp.Stat(indexPHPPath) {
_ = fileOp.WriteFile(indexPHPPath, strings.NewReader(string(nginx_conf.IndexPHP)), constant.FilePerm)
}
} else {
server.UpdateDirective("index", []string{"index.html index.php index.htm default.php default.htm default.html"})
website.RuntimeID = 0
website.Type = constant.Static
website.Proxy = ""
server.RemoveDirective("location", []string{"~", "[^/]\\.php(/|$)"})
if !fileOp.Stat(indexHtmlPath) {
_ = fileOp.WriteFile(indexHtmlPath, strings.NewReader(string(nginx_conf.Index)), constant.FilePerm)
}
}
if req.RuntimeID > 0 && site.Status != constant.WebStopped {
updated := false
for _, location := range server.FindDirectives("location") {
if strings.Join(location.GetParameters(), " ") == "~ [^/]\\.php(/|$)" && location.GetBlock() != nil {
location.GetBlock().UpdateDirective("fastcgi_pass", []string{site.Proxy})
updated = true
}
}
if !updated {
server.UpdatePHPProxy([]string{site.Proxy}, "")
}
} else {
server.RemoveDirective("location", []string{"~", "[^/]\\.php(/|$)"})
}
config.FilePath = configPath
configs = append(configs, config)
oldContents[configPath] = content
}
config.FilePath = configPath
if err = nginx.WriteConfig(config, nginx.IndentedStyle); err != nil {
return err
indexPath := path.Join(GetSitePath(website, SiteIndexDir), "index.html")
indexContent := nginx_conf.Index
if req.RuntimeID > 0 {
indexPath = path.Join(GetSitePath(website, SiteIndexDir), "index.php")
indexContent = nginx_conf.IndexPHP
}
if !fileOp.Stat(indexPath) {
_ = fileOp.WriteFile(indexPath, strings.NewReader(string(indexContent)), constant.FilePerm)
}
nginxInstall, err := getAppInstallByKey(constant.AppOpenresty)
if err != nil {
return err
}
if err = nginxCheckAndReload(string(nginxContent), configPath, nginxInstall.ContainerName); err != nil {
tx, ctx := helper.GetTxAndContext()
if err = tx.Error; err != nil {
return err
}
return websiteRepo.Save(context.Background(), &website)
written := 0
reloadAttempted := false
defer func() {
if err == nil {
return
}
if rollbackErr := tx.Rollback().Error; rollbackErr != nil && !errors.Is(rollbackErr, sql.ErrTxDone) {
err = errors.Join(err, rollbackErr)
}
for _, config := range configs[:written] {
if restoreErr := fileOp.WriteFile(config.FilePath, strings.NewReader(string(oldContents[config.FilePath])), constant.FilePerm); restoreErr != nil {
err = errors.Join(err, fmt.Errorf("restore %s: %w", config.FilePath, restoreErr))
}
}
if reloadAttempted {
if checkErr := opNginx(nginxInstall.ContainerName, constant.NginxCheck); checkErr != nil {
err = errors.Join(err, fmt.Errorf("check restored nginx configuration: %w", checkErr))
} else if reloadErr := opNginx(nginxInstall.ContainerName, constant.NginxReload); reloadErr != nil {
err = errors.Join(err, fmt.Errorf("reload restored nginx configuration: %w", reloadErr))
}
}
}()
for i := range websites {
if err = websiteRepo.Save(ctx, &websites[i]); err != nil {
return err
}
}
for _, config := range configs {
written++
if err = nginx.WriteConfig(config, nginx.IndentedStyle); err != nil {
return err
}
}
if err = opNginx(nginxInstall.ContainerName, constant.NginxCheck); err != nil {
return err
}
reloadAttempted = true
if err = opNginx(nginxInstall.ContainerName, constant.NginxReload); err != nil {
return err
}
return tx.Commit().Error
}
func (w WebsiteService) UpdateSiteDir(req request.WebsiteUpdateDir) error {
+157 -4
View File
@@ -1,15 +1,19 @@
package service
import (
"archive/zip"
"bytes"
"context"
"crypto/ecdsa"
"crypto/rand"
"crypto/rsa"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"errors"
"fmt"
"io"
"log"
"math/big"
"net"
@@ -18,25 +22,27 @@ import (
"strings"
"time"
"github.com/1Panel-dev/1Panel/agent/app/repo"
"github.com/1Panel-dev/1Panel/agent/utils/common"
"github.com/1Panel-dev/1Panel/agent/app/dto/request"
"github.com/1Panel-dev/1Panel/agent/app/dto/response"
"github.com/1Panel-dev/1Panel/agent/app/model"
"github.com/1Panel-dev/1Panel/agent/app/repo"
"github.com/1Panel-dev/1Panel/agent/buserr"
"github.com/1Panel-dev/1Panel/agent/constant"
"github.com/1Panel-dev/1Panel/agent/global"
"github.com/1Panel-dev/1Panel/agent/i18n"
"github.com/1Panel-dev/1Panel/agent/utils/common"
"github.com/1Panel-dev/1Panel/agent/utils/files"
"github.com/1Panel-dev/1Panel/agent/utils/ssl"
"github.com/go-acme/lego/v5/certcrypto"
"gorm.io/gorm"
)
type WebsiteCAService struct {
}
type IWebsiteCAService interface {
Import(req request.WebsiteCAImport) error
Upload(name string, archive []byte) error
Page(search request.WebsiteCASearch) (int64, []response.WebsiteCADTO, error)
Create(create request.WebsiteCACreate) (*request.WebsiteCACreate, error)
GetCA(id uint) (*response.WebsiteCADTO, error)
@@ -334,7 +340,7 @@ func (w WebsiteCAService) ObtainSSL(req request.WebsiteCAObtain) (*model.Website
websiteSSL.ExpireDate = cert.NotAfter
websiteSSL.StartDate = cert.NotBefore
websiteSSL.Type = cert.Issuer.CommonName
websiteSSL.Organization = rootCsr.Subject.Organization[0]
websiteSSL.Organization = strings.Join(rootCsr.Subject.Organization, ",")
websiteSSL.Status = constant.SSLReady
if req.Renew {
@@ -442,3 +448,150 @@ func (w WebsiteCAService) DownloadFile(id uint) (*os.File, error) {
}
return os.Open(path.Join(dir, fileName))
}
func (w WebsiteCAService) Upload(name string, archive []byte) error {
ca, err := parseCAArchive(archive)
if err != nil {
return err
}
return saveImportedCA(name, ca)
}
func saveImportedCA(name string, ca *model.WebsiteCA) error {
name = strings.TrimSpace(name)
if name == "" || len(name) > 128 || name == "." || name == ".." || strings.ContainsAny(name, "/\\\x00") {
return buserr.WithDetail("ErrInvalidParams", "name", nil)
}
exist, err := websiteCARepo.GetFirst(repo.WithByName(name))
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
if exist.ID > 0 {
return buserr.New("ErrNameIsExist")
}
ca.Name = name
return websiteCARepo.Create(context.Background(), ca)
}
// Read only the certificate and key in memory; never extract user-supplied paths.
func parseCAArchive(archive []byte) (*model.WebsiteCA, error) {
invalid := func() (*model.WebsiteCA, error) { return nil, buserr.New("ErrCAUpload") }
if len(archive) > 10<<20 {
return invalid()
}
reader, err := zip.NewReader(bytes.NewReader(archive), int64(len(archive)))
if err != nil || len(reader.File) > 16 {
return invalid()
}
contents := make(map[string][]byte)
for _, file := range reader.File {
if file.FileInfo().IsDir() {
continue
}
name := path.Base(file.Name)
if (name != "ca.crt" && name != "ca.key") || contents[name] != nil || file.UncompressedSize64 > 1<<20 {
return invalid()
}
stream, err := file.Open()
if err != nil {
return invalid()
}
data, err := io.ReadAll(io.LimitReader(stream, (1<<20)+1))
stream.Close()
if err != nil || len(data) > 1<<20 {
return invalid()
}
contents[name] = data
}
return parseAndValidateRootCA(contents["ca.crt"], contents["ca.key"])
}
func parseAndValidateRootCA(certificate, privateKey []byte) (*model.WebsiteCA, error) {
invalid := func() (*model.WebsiteCA, error) { return nil, buserr.New("ErrCAImport") }
if len(certificate) > 1<<20 || len(privateKey) > 1<<20 {
return invalid()
}
pair, err := tls.X509KeyPair(certificate, privateKey)
if err != nil || len(pair.Certificate) != 1 {
return invalid()
}
cert := pair.Leaf
now := time.Now()
if cert == nil || !cert.IsCA || !cert.BasicConstraintsValid || cert.KeyUsage&x509.KeyUsageCertSign == 0 ||
!bytes.Equal(cert.RawIssuer, cert.RawSubject) || cert.CheckSignatureFrom(cert) != nil ||
now.Before(cert.NotBefore) || !now.Before(cert.NotAfter) {
return invalid()
}
ca := &model.WebsiteCA{CSR: string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: cert.Raw}))}
// Normalize keys to the formats used by ObtainSSL, including PKCS#8 uploads.
switch key := pair.PrivateKey.(type) {
case *ecdsa.PrivateKey:
bits := key.Curve.Params().BitSize
if bits != 256 && bits != 384 {
return invalid()
}
der, err := x509.MarshalECPrivateKey(key)
if err != nil {
return invalid()
}
ca.KeyType = fmt.Sprintf("EC%d", bits)
ca.PrivateKey = string(pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: der}))
case *rsa.PrivateKey:
bits := key.N.BitLen()
if bits != 2048 && bits != 3072 && bits != 4096 && bits != 8192 {
return invalid()
}
ca.KeyType = fmt.Sprintf("RSA%d", bits)
ca.PrivateKey = string(pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)}))
default:
return invalid()
}
return ca, nil
}
func (w WebsiteCAService) Import(req request.WebsiteCAImport) error {
certificate, privateKey := []byte(req.Certificate), []byte(req.PrivateKey)
switch req.Type {
case "local":
var err error
certificate, err = readCAImportFile(req.CertificatePath)
if err != nil {
return err
}
privateKey, err = readCAImportFile(req.PrivateKeyPath)
if err != nil {
return err
}
case "paste":
default:
return buserr.New("ErrCAImport")
}
ca, err := parseAndValidateRootCA(certificate, privateKey)
if err != nil {
return err
}
return saveImportedCA(req.Name, ca)
}
func readCAImportFile(name string) ([]byte, error) {
info, err := os.Stat(name)
if err != nil {
return nil, err
}
if !info.Mode().IsRegular() || info.Size() > 1<<20 {
return nil, buserr.New("ErrCAImport")
}
file, err := os.Open(name)
if err != nil {
return nil, err
}
defer file.Close()
content, err := io.ReadAll(io.LimitReader(file, (1<<20)+1))
if err != nil {
return nil, err
}
if len(content) > 1<<20 {
return nil, buserr.New("ErrCAImport")
}
return content, nil
}
+1 -1
View File
@@ -1454,10 +1454,10 @@ func getWebsiteDomains(domains []request.WebsiteDomain, defaultHTTPPort, default
return
}
domainModel.Domain = strings.ToLower(domainModel.Domain)
domainModel.Port = domain.Port
if domain.Port == 0 {
domain.Port = defaultHTTPPort
}
domainModel.Port = domain.Port
domainModel.SSL = domain.SSL
domainModel.WebsiteID = websiteID
domainModels = append(domainModels, domainModel)
+5 -14
View File
@@ -53,13 +53,6 @@
"formatZH": "添加 Ollama 模型重试 [name]",
"formatEN": "re-add Ollama model [name]"
},
"/ai/ollama/model/sync": {
"bodyKeys": [],
"paramKeys": [],
"beforeFunctions": [],
"formatZH": "同步 Ollama 模型列表",
"formatEN": "sync Ollama model list"
},
"/ai/tensorrt/create": {
"bodyKeys": [
"name"
@@ -3740,13 +3733,11 @@
"formatEN": "Create runtime [name]"
},
"/runtimes/del": {
"bodyKeys": [
"id"
],
"bodyKeys": [],
"paramKeys": [],
"beforeFunctions": [],
"formatZH": "删除运行环境 [id]",
"formatEN": "Delete runtime [id]"
"formatZH": "删除运行环境 [name]",
"formatEN": "Delete runtime [name]"
},
"/runtimes/operate": {
"bodyKeys": [
@@ -4582,13 +4573,13 @@
},
"/websites/php/version": {
"bodyKeys": [
"websiteId"
"websiteID"
],
"paramKeys": [],
"beforeFunctions": [
{
"input_column": "id",
"input_value": "websiteId",
"input_value": "websiteID",
"isList": false,
"db": "websites",
"output_column": "primary_domain",
+4
View File
@@ -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."
+4
View File
@@ -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."
+4
View File
@@ -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."
+4
View File
@@ -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."
+4
View File
@@ -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."
+4
View File
@@ -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."
+4
View File
@@ -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."
+4
View File
@@ -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."
+4
View File
@@ -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."
+4
View File
@@ -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."
+4
View File
@@ -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。"
+4
View File
@@ -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。"
+2
View File
@@ -117,6 +117,8 @@ func agentDBMigrations() []*gormigrate.Migration {
migrations.AddVLLMSampleInterval,
migrations.AddNativeAgentInstalls,
migrations.AddQuickDirectory,
migrations.AddLocalModelAccountLink,
migrations.AddOllamaModelAccountSync,
}
}
+44
View File
@@ -1895,3 +1895,47 @@ var AddQuickDirectory = &gormigrate.Migration{
return tx.AutoMigrate(&model.QuickDirectory{})
},
}
var AddLocalModelAccountLink = &gormigrate.Migration{
ID: "20261023-add-local-model-account-link",
Migrate: func(tx *gorm.DB) error {
return tx.AutoMigrate(&model.AgentAccount{})
},
}
var AddOllamaModelAccountSync = &gormigrate.Migration{
ID: "20261026-add-ollama-model-account-sync",
Migrate: func(tx *gorm.DB) error {
if err := tx.AutoMigrate(&model.AppInstall{}); err != nil {
return err
}
var accounts []model.AgentAccount
if err := tx.Where("provider = ? AND app_install_id > 0", "ollama").Find(&accounts).Error; err != nil {
return err
}
for _, account := range accounts {
var install model.AppInstall
if err := tx.Preload("App").First(&install, account.AppInstallID).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
continue
}
return err
}
if install.App.Key != "ollama" {
continue
}
addressType := "custom"
if strings.TrimRight(account.BaseURL, "/") == fmt.Sprintf("http://%s:11434/v1", install.ContainerName) {
addressType = "container"
}
if err := tx.Model(&model.AppInstall{}).Where("id = ?", account.AppInstallID).Updates(map[string]interface{}{
"sync_model_account": true,
"model_account_base_url_type": addressType,
"model_account_base_url": account.BaseURL,
}).Error; err != nil {
return err
}
}
return nil
},
}
+61
View File
@@ -0,0 +1,61 @@
package router
import (
"bytes"
"encoding/json"
"io"
"net"
"net/http"
"github.com/1Panel-dev/1Panel/agent/global"
"github.com/1Panel-dev/1Panel/agent/middleware"
"github.com/1Panel-dev/1Panel/agent/utils/mcpapi"
"github.com/1Panel-dev/1Panel/agent/utils/mcpruntime"
"github.com/gin-gonic/gin"
)
func registerMCPRuntime(engine *gin.Engine) {
controller := mcpruntime.New()
engine.POST("/internal/mcp/docker", mcpDockerHandler(controller))
engine.GET(mcpapi.CapabilitiesPath, middleware.Certificate(), mcpCapabilitiesHandler())
}
func mcpCapabilitiesHandler() gin.HandlerFunc {
return func(ctx *gin.Context) {
if !mcpapi.TrustedTransport(ctx.Request) {
ctx.AbortWithStatus(http.StatusForbidden)
return
}
ctx.JSON(http.StatusOK, gin.H{"contractVersion": mcpapi.ContractVersion, "features": []string{
"operation-bound-identity", "stable-node-target", "resource-guards",
}})
}
}
func mcpDockerHandler(controller *mcpruntime.Controller) gin.HandlerFunc {
return func(ctx *gin.Context) {
local, ok := ctx.Request.Context().Value(http.LocalAddrContextKey).(net.Addr)
if !global.IsMaster || !ok || local.Network() != "unix" {
ctx.AbortWithStatus(http.StatusForbidden)
return
}
data, err := io.ReadAll(io.LimitReader(ctx.Request.Body, 8193))
if err != nil || len(data) > 8192 {
ctx.AbortWithStatus(http.StatusBadRequest)
return
}
var req mcpruntime.Request
decoder := json.NewDecoder(bytes.NewReader(data))
decoder.DisallowUnknownFields()
if decoder.Decode(&req) != nil || decoder.Decode(new(any)) != io.EOF {
ctx.AbortWithStatus(http.StatusBadRequest)
return
}
response, err := controller.Handle(ctx.Request.Context(), req)
if err != nil {
ctx.JSON(http.StatusBadRequest, mcpruntime.Response{Error: err.Error()})
return
}
ctx.JSON(http.StatusOK, response)
}
}
+1
View File
@@ -16,6 +16,7 @@ var (
func Routers() *gin.Engine {
Router = gin.Default()
Router.Use(i18n.UseI18n())
registerMCPRuntime(Router)
PrivateGroup := Router.Group("/api/v2")
if !global.IsMaster {
+14 -2
View File
@@ -10,10 +10,15 @@ import (
"github.com/1Panel-dev/1Panel/agent/app/api/v2/helper"
"github.com/1Panel-dev/1Panel/agent/global"
"github.com/1Panel-dev/1Panel/agent/utils/cmd"
"github.com/1Panel-dev/1Panel/agent/utils/mcpapi"
"github.com/1Panel-dev/1Panel/agent/utils/xpack"
"github.com/gin-gonic/gin"
)
var loadNodeProxyID = func() (string, error) {
return cmd.NewCommandMgr(cmd.WithTimeout(20*time.Second)).RunWithStdout("cat", "/etc/1panel/.nodeProxyID")
}
func Certificate() gin.HandlerFunc {
return func(c *gin.Context) {
if global.IsMaster {
@@ -25,16 +30,23 @@ func Certificate() gin.HandlerFunc {
return
}
conn := c.Request.Header.Get("Connection")
if conn == "Upgrade" {
mcpRequest, _ := mcpapi.Check(c.Request)
mcpTransport := mcpRequest || c.Request.URL.Path == mcpapi.CapabilitiesPath
if conn == "Upgrade" && !mcpTransport {
c.Next()
return
}
masterProxyID := c.Request.Header.Get("Proxy-Id")
proxyID, err := cmd.NewCommandMgr(cmd.WithTimeout(20*time.Second)).RunWithStdout("cat", "/etc/1panel/.nodeProxyID")
proxyID, err := loadNodeProxyID()
if mcpTransport && (err != nil || strings.TrimSpace(proxyID) == "" || strings.TrimSpace(proxyID) != strings.TrimSpace(masterProxyID)) {
c.AbortWithStatus(http.StatusForbidden)
return
}
if err == nil && len(proxyID) != 0 && strings.TrimSpace(proxyID) != strings.TrimSpace(masterProxyID) {
helper.InternalServer(c, fmt.Errorf("err proxy id"))
return
}
c.Request = mcpapi.BindVerifiedNodeTransport(c.Request)
c.Next()
}
}
+1 -1
View File
@@ -18,7 +18,7 @@ func (a *AIToolsRouter) InitRouter(Router *gin.RouterGroup) {
aiToolsRouter.POST("/ollama/model", baseApi.CreateOllamaModel)
aiToolsRouter.POST("/ollama/model/recreate", baseApi.RecreateOllamaModel)
aiToolsRouter.POST("/ollama/model/search", baseApi.SearchOllamaModel)
aiToolsRouter.POST("/ollama/model/sync", baseApi.SyncOllamaModel)
aiToolsRouter.POST("/ollama/account/sync", baseApi.SyncOllamaModelAccount)
aiToolsRouter.POST("/ollama/model/load", baseApi.LoadOllamaModelDetail)
aiToolsRouter.POST("/ollama/model/del", baseApi.DeleteOllamaModel)
aiToolsRouter.POST("/domain/bind", baseApi.BindDomain)
+2
View File
@@ -13,6 +13,8 @@ func (a *WebsiteCARouter) InitRouter(Router *gin.RouterGroup) {
baseApi := v2.ApiGroupApp.BaseApi
{
groupRouter.POST("/import", baseApi.ImportWebsiteCA)
groupRouter.POST("/upload", baseApi.UploadWebsiteCA)
groupRouter.POST("/search", baseApi.PageWebsiteCA)
groupRouter.POST("", baseApi.CreateWebsiteCA)
groupRouter.POST("/del", baseApi.DeleteWebsiteCA)
+9 -6
View File
@@ -29,7 +29,7 @@ func Write(envMap map[string]string, filename string) error {
func Marshal(envMap map[string]string) (string, error) {
lines := make([]string, 0, len(envMap))
for k, v := range envMap {
if d, err := strconv.Atoi(v); err == nil && !isStartWithZero(v) {
if d, ok := canonicalInt(v); ok {
lines = append(lines, fmt.Sprintf(`%s=%d`, k, d))
} else if shouldUseSingleQuotes(v) {
lines = append(lines, fmt.Sprintf(`%s='%s'`, k, v))
@@ -86,7 +86,7 @@ func MarshalWithOrder(envMap map[string]string, orders []string) (string, error)
}
func formatEnvLine(k, v string) string {
if d, err := strconv.Atoi(v); err == nil && !isStartWithZero(v) {
if d, ok := canonicalInt(v); ok {
return fmt.Sprintf(`%s=%d`, k, d)
} else if shouldUseSingleQuotes(v) {
return fmt.Sprintf(`%s='%s'`, k, v)
@@ -107,11 +107,14 @@ func GetEnvValueByKey(envPath, key string) (string, error) {
return value, nil
}
func isStartWithZero(value string) bool {
if strings.HasPrefix(value, "0") && len(value) > 1 {
return true
// canonicalInt reports whether value is an integer that formats back to the
// same string, so values such as "007", "+1" or "-01" keep their exact text.
func canonicalInt(value string) (int, bool) {
d, err := strconv.Atoi(value)
if err != nil || strconv.Itoa(d) != value {
return 0, false
}
return false
return d, true
}
func hasEvenDoubleQuotes(s string) bool {
+42
View File
@@ -0,0 +1,42 @@
package env
import (
"testing"
"github.com/joho/godotenv"
)
func TestMarshalKeepsValuesExactly(t *testing.T) {
values := []string{"3306", "0", "-1", "007", "+123456", "-0123", "+0", "-0", "a'b", `a"b`, `a\b`}
for _, v := range values {
envMap := map[string]string{"PANEL_DB_ROOT_PASSWORD": v}
for name, marshal := range map[string]func(map[string]string) (string, error){
"Marshal": Marshal,
"MarshalWithOrder": func(m map[string]string) (string, error) {
return MarshalWithOrder(m, []string{"PANEL_DB_ROOT_PASSWORD"})
},
} {
content, err := marshal(envMap)
if err != nil {
t.Fatalf("%s(%q) returned error: %v", name, v, err)
}
parsed, err := godotenv.Unmarshal(content)
if err != nil {
t.Fatalf("%s(%q) produced unparsable content %q: %v", name, v, content, err)
}
if got := parsed["PANEL_DB_ROOT_PASSWORD"]; got != v {
t.Errorf("%s(%q) wrote %q, which reads back as %q", name, v, content, got)
}
}
}
}
func TestMarshalWritesCanonicalIntegersUnquoted(t *testing.T) {
content, err := Marshal(map[string]string{"PANEL_APP_PORT_HTTP": "8080"})
if err != nil {
t.Fatal(err)
}
if content != "PANEL_APP_PORT_HTTP=8080" {
t.Fatalf("Marshal() = %q, want %q", content, "PANEL_APP_PORT_HTTP=8080")
}
}
+67
View File
@@ -0,0 +1,67 @@
package mcpapi
import (
"errors"
"net/http"
"github.com/1Panel-dev/1Panel/agent/buserr"
)
const (
BusinessCodeHeader = "X-Panel-MCP-Business-Code"
CodeMySQLStopped = "MCP_MYSQL_STOPPED"
CodeMySQLConnectionFailed = "MCP_MYSQL_CONNECTION_FAILED"
)
type codedError struct {
code string
message string
}
func (e *codedError) Error() string { return e.message }
func allowedBusinessCode(code string) string {
switch code {
case "ErrAliasIsExist", "ErrDomainIsUsed", "ErrPortInUsed", "ErrPortInOtherApp", "ErrPortExist", "ErrRecordExist", "ErrDatabaseIsExist", CodeMySQLStopped, CodeMySQLConnectionFailed:
return code
default:
return ""
}
}
func BusinessErrorCode(err error) string {
var coded *codedError
if errors.As(err, &coded) && coded != nil {
return allowedBusinessCode(coded.code)
}
var business buserr.BusinessError
if errors.As(err, &business) {
return allowedBusinessCode(business.Msg)
}
var businessPointer *buserr.BusinessError
if errors.As(err, &businessPointer) && businessPointer != nil {
return allowedBusinessCode(businessPointer.Msg)
}
return ""
}
func NewBusinessError(code, message string) error {
return &codedError{code: allowedBusinessCode(code), message: message}
}
func SanitizeBusinessError(err error, safeMessage string) error {
return NewBusinessError(BusinessErrorCode(err), safeMessage)
}
func SetBusinessErrorHeader(request *http.Request, header http.Header, err error) {
if request == nil || header == nil {
return
}
if present, trusted := Check(request); !present || !trusted {
return
}
header.Del(BusinessCodeHeader)
if code := BusinessErrorCode(err); code != "" {
header.Set(BusinessCodeHeader, code)
}
}
+79
View File
@@ -0,0 +1,79 @@
package mcpapi
import (
"context"
"net"
"net/http"
"strconv"
"github.com/1Panel-dev/1Panel/agent/global"
"github.com/google/uuid"
)
const (
InvocationHeader = "X-Panel-MCP-Invocation"
NodeHeader = "X-Panel-MCP-Node"
MethodHeader = "X-Panel-MCP-Method"
PathHeader = "X-Panel-MCP-Path"
CapabilitiesPath = "/api/v2/internal/mcp/capabilities"
ContractVersion = 4
)
type verifiedNodeTransportKey struct{}
func BindVerifiedNodeTransport(request *http.Request) *http.Request {
if request.TLS == nil || !request.TLS.HandshakeComplete || len(request.TLS.VerifiedChains) == 0 {
return request
}
return request.WithContext(context.WithValue(request.Context(), verifiedNodeTransportKey{}, request.TLS))
}
func validNodeID(id string) bool {
if id == "local" {
return true
}
value, err := strconv.ParseUint(id, 10, 64)
return err == nil && value != 0 && strconv.FormatUint(value, 10) == id
}
func TrustedTransport(request *http.Request) bool {
if global.IsMaster {
local, ok := request.Context().Value(http.LocalAddrContextKey).(net.Addr)
return ok && local.Network() == "unix"
}
verified := request.Context().Value(verifiedNodeTransportKey{})
return request.TLS != nil && verified == request.TLS
}
func Check(request *http.Request) (present, trusted bool) {
present = false
for _, name := range []string{InvocationHeader, NodeHeader, MethodHeader, PathHeader} {
if len(request.Header.Values(name)) != 0 {
present = true
}
}
if !present {
return false, false
}
for _, name := range []string{InvocationHeader, NodeHeader, MethodHeader, PathHeader} {
if len(request.Header.Values(name)) != 1 {
return true, false
}
}
if _, err := uuid.Parse(request.Header.Get(InvocationHeader)); err != nil {
return true, false
}
nodeID := request.Header.Get(NodeHeader)
if !validNodeID(nodeID) || request.Header.Get(MethodHeader) != request.Method || request.Header.Get(PathHeader) != request.URL.Path || request.URL.RawQuery != "" {
return true, false
}
return true, TrustedTransport(request) && ((nodeID == "local") == global.IsMaster)
}
func NodeID(request *http.Request) (string, bool) {
_, trusted := Check(request)
if !trusted {
return "", false
}
return request.Header.Get(NodeHeader), true
}
+530
View File
@@ -0,0 +1,530 @@
package mcpruntime
import (
"bytes"
"context"
"encoding/binary"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
"regexp"
"runtime"
"strings"
"sync"
"time"
"github.com/1Panel-dev/1Panel/agent/utils/docker"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/image"
"github.com/docker/docker/api/types/network"
"github.com/docker/docker/api/types/system"
"github.com/docker/docker/client"
"github.com/docker/docker/errdefs"
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
)
const (
ImageRepository = "1panel/1panel-mcp-server"
ContainerName = "1panel-mcp"
previousName = "1panel-mcp-previous"
SocketDirectory = "/etc/1panel/mcp-runtime"
CoreSocket = SocketDirectory + "/core.sock"
adapterBinary = "/usr/local/bin/mcp-1panel"
ownerLabel = "io.1panel.mcp.managed"
imageLabel = "io.1panel.mcp.image"
portLabel = "io.1panel.mcp.port"
versionLabel = "io.1panel.mcp.version"
maxCatalogBytes = 2 << 20
maxLogBytes = 64 << 10
contractVersion = 5
protocol = "streamable-http"
)
var semanticVersion = regexp.MustCompile(`^v?(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(?:-[0-9A-Za-z]+(?:[.-][0-9A-Za-z]+)*)?$`)
var digestPattern = regexp.MustCompile(`^sha256:[a-f0-9]{64}$`)
var ErrUnsupported = errors.New("MCP Docker runtime requires a local rootful Linux Docker daemon without user namespace remapping")
type Request struct {
Operation string `json:"operation"`
Image string `json:"image"`
Port int `json:"port"`
Enabled bool `json:"enabled"`
Restart bool `json:"restart"`
ImageID string `json:"imageID,omitempty"`
}
type State struct {
Installed bool `json:"installed"`
Status string `json:"status"`
Ready bool `json:"ready"`
Reason string `json:"reason,omitempty"`
AdapterVersion string `json:"adapterVersion,omitempty"`
Image string `json:"image,omitempty"`
ImageID string `json:"imageID,omitempty"`
}
type Response struct {
State *State `json:"state,omitempty"`
Catalog json.RawMessage `json:"catalog,omitempty"`
Logs string `json:"logs,omitempty"`
Error string `json:"error,omitempty"`
ImageID string `json:"imageID,omitempty"`
}
type DockerClient interface {
Close() error
DaemonHost() string
Info(context.Context) (system.Info, error)
ImageInspect(context.Context, string, ...client.ImageInspectOption) (image.InspectResponse, error)
ImagePull(context.Context, string, image.PullOptions) (io.ReadCloser, error)
ContainerInspect(context.Context, string) (container.InspectResponse, error)
ContainerCreate(context.Context, *container.Config, *container.HostConfig, *network.NetworkingConfig, *ocispec.Platform, string) (container.CreateResponse, error)
ContainerStart(context.Context, string, container.StartOptions) error
ContainerStop(context.Context, string, container.StopOptions) error
ContainerRemove(context.Context, string, container.RemoveOptions) error
ContainerRename(context.Context, string, string) error
ContainerLogs(context.Context, string, container.LogsOptions) (io.ReadCloser, error)
}
type metadata struct {
Status string `json:"status"`
AdapterVersion string `json:"adapterVersion"`
ContractVersion int `json:"contractVersion"`
Protocol string `json:"protocol"`
}
type verifiedImage struct {
metadata metadata
catalog []byte
}
type Controller struct {
newClient func() (DockerClient, error)
platform string
health func(context.Context, int) (metadata, error)
checkDirectory func() error
mutation chan struct{}
probes chan struct{}
cacheMu sync.Mutex
cache map[string]verifiedImage
}
func New() *Controller {
return newController(func() (DockerClient, error) { return docker.NewDockerClient() })
}
func newController(factory func() (DockerClient, error)) *Controller {
return &Controller{newClient: factory, platform: runtime.GOOS, health: readHealth,
checkDirectory: checkSocketDirectory, mutation: make(chan struct{}, 1), probes: make(chan struct{}, 8), cache: make(map[string]verifiedImage)}
}
func ValidateImage(value string) error {
if len(value) > 180 {
return errors.New("invalid MCP Docker image")
}
if strings.HasPrefix(value, ImageRepository+"@") && digestPattern.MatchString(strings.TrimPrefix(value, ImageRepository+"@")) {
return nil
}
if strings.HasPrefix(value, ImageRepository+":") && semanticVersion.MatchString(strings.TrimPrefix(value, ImageRepository+":")) {
return nil
}
return errors.New("MCP Docker image must use the official repository with a fixed semantic version or SHA256 digest")
}
func (c *Controller) Handle(ctx context.Context, req Request) (Response, error) {
switch req.Operation {
case "prepare", "catalog", "candidate_catalog", "status", "apply", "install", "rollback", "rollback_absent", "pull", "logs":
default:
return Response{}, errors.New("invalid MCP Docker operation")
}
if err := ValidateImage(req.Image); err != nil {
return Response{}, err
}
if req.Port < 1024 || req.Port > 65535 {
return Response{}, errors.New("MCP listen port must be between 1024 and 65535")
}
timeout := 60 * time.Second
if req.Operation == "catalog" || req.Operation == "candidate_catalog" || req.Operation == "logs" || req.Operation == "prepare" {
timeout = 5 * time.Second
}
if req.Operation == "status" {
timeout = 8 * time.Second
}
if req.Operation == "pull" {
timeout = 4 * time.Minute
}
ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
if req.Operation == "apply" || req.Operation == "install" || req.Operation == "rollback" || req.Operation == "rollback_absent" || req.Operation == "pull" {
select {
case c.mutation <- struct{}{}:
defer func() { <-c.mutation }()
case <-ctx.Done():
return Response{}, ctx.Err()
}
}
cli, err := c.newClient()
if err != nil {
return unavailable(req, "Docker daemon is unavailable")
}
defer cli.Close()
if err = c.supported(ctx, cli); err != nil {
return unavailable(req, err.Error())
}
switch req.Operation {
case "prepare":
case "catalog", "candidate_catalog":
var img image.InspectResponse
img, err = c.resolve(ctx, cli, req.Image)
if err == nil && req.Operation == "catalog" {
var current *container.InspectResponse
current, err = c.owned(ctx, cli, ContainerName)
if err == nil && current != nil && isRunning(current) && current.Config.Labels[imageLabel] == req.Image && current.Image != img.ID {
err = errors.New("MCP image reference changed; restart the adapter before reading its catalog")
}
}
if err == nil {
var verified verifiedImage
verified, err = c.verify(ctx, cli, img.ID, req.Image)
if err == nil {
return Response{Catalog: append(json.RawMessage(nil), verified.catalog...), ImageID: img.ID}, nil
}
}
case "status":
return c.status(ctx, cli, req), nil
case "apply":
err = c.apply(ctx, cli, req)
case "install":
err = c.installRuntime(ctx, cli, req)
case "rollback":
err = c.rollback(ctx, cli, req)
case "rollback_absent":
err = c.rollbackAbsent(ctx, cli, req)
case "pull":
err = c.pullImage(ctx, cli, req)
case "logs":
var current *container.InspectResponse
current, err = c.owned(ctx, cli, ContainerName)
if err == nil && current == nil {
err = errors.New("MCP container is not installed")
}
if err == nil {
var data []byte
data, err = readLogs(ctx, cli, current.ID, maxLogBytes, "100")
if err == nil {
return Response{Logs: redactLog(string(data))}, nil
}
}
}
return Response{}, err
}
func unavailable(req Request, reason string) (Response, error) {
if req.Operation == "status" {
return Response{State: &State{Status: "unsupported", Image: req.Image, Reason: reason}}, nil
}
return Response{}, errors.New(reason)
}
func (c *Controller) supported(ctx context.Context, cli DockerClient) error {
if c.platform != "linux" || !strings.HasPrefix(cli.DaemonHost(), "unix:///") {
return ErrUnsupported
}
info, err := cli.Info(ctx)
if err != nil {
return errors.New("Docker daemon is unavailable")
}
if info.OSType != "linux" {
return ErrUnsupported
}
for _, option := range info.SecurityOptions {
if strings.Contains(option, "rootless") || strings.Contains(option, "userns") {
return ErrUnsupported
}
}
return nil
}
func (c *Controller) resolve(ctx context.Context, cli DockerClient, ref string) (image.InspectResponse, error) {
img, err := cli.ImageInspect(ctx, ref)
if err != nil {
return image.InspectResponse{}, errors.New("MCP Docker image is unavailable locally; load it or install the selected version")
}
if !digestPattern.MatchString(img.ID) || img.Os != "linux" || img.Config == nil || len(img.Config.Volumes) > 0 {
return image.InspectResponse{}, errors.New("invalid MCP Docker image identity or platform")
}
return img, nil
}
func (c *Controller) owned(ctx context.Context, cli DockerClient, name string) (*container.InspectResponse, error) {
info, err := cli.ContainerInspect(ctx, name)
if errdefs.IsNotFound(err) {
return nil, nil
}
if err != nil {
return nil, errors.New("MCP container state is unavailable")
}
if info.ContainerJSONBase == nil || info.Config == nil || info.Config.Labels[ownerLabel] != "true" {
return nil, errors.New("MCP container name is occupied by an unmanaged container")
}
return &info, nil
}
func isRunning(info *container.InspectResponse) bool {
return info != nil && info.State != nil && info.State.Running
}
func (c *Controller) status(ctx context.Context, cli DockerClient, req Request) Response {
state := &State{Status: "not_installed", Image: req.Image}
img, err := c.resolve(ctx, cli, req.Image)
if err == nil {
state.ImageID = img.ID
}
current, inspectErr := c.owned(ctx, cli, ContainerName)
if inspectErr != nil {
state.Status = "unhealthy"
state.Reason = inspectErr.Error()
return Response{State: state}
}
if current == nil {
if err != nil {
state.Reason = err.Error()
} else {
state.Reason = "MCP image is loaded; install it to create the managed container"
}
return Response{State: state}
}
state.Installed = true
state.ImageID = current.Image
state.AdapterVersion = current.Config.Labels[versionLabel]
state.Status = "stopped"
if !isRunning(current) {
if req.Enabled {
state.Reason = "MCP container is not running"
}
return Response{State: state}
}
state.Status = "running"
if !req.Enabled {
state.Reason = "MCP access is disabled"
return Response{State: state}
}
if err != nil || current.Image != img.ID {
state.Status = "unhealthy"
state.Reason = "MCP container does not match the configured image or listen port"
return Response{State: state}
}
verified, verifyErr := c.verify(ctx, cli, current.Image, req.Image)
if verifyErr != nil || !matchesRuntimeWithCapabilities(ctx, cli, current, img.ID, req, verified.metadata.AdapterVersion) {
state.Status = "unhealthy"
state.Reason = "MCP container configuration or metadata does not match the verified image"
return Response{State: state}
}
state.AdapterVersion = verified.metadata.AdapterVersion
health, healthErr := c.health(ctx, req.Port)
if healthErr != nil || health.Status != "ok" || !validMetadata(health) || health.AdapterVersion != state.AdapterVersion {
state.Status = "unhealthy"
state.Reason = "MCP adapter health or contract is invalid"
return Response{State: state}
}
state.Ready = true
return Response{State: state}
}
func (c *Controller) verify(ctx context.Context, cli DockerClient, id, ref string) (verifiedImage, error) {
c.cacheMu.Lock()
cached, ok := c.cache[id]
c.cacheMu.Unlock()
if !ok {
select {
case c.probes <- struct{}{}:
defer func() { <-c.probes }()
default:
return verifiedImage{}, errors.New("MCP image catalog is busy; retry shortly")
}
probeCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
version, err := c.probe(probeCtx, cli, id, "--version", 4096)
if err != nil || json.Unmarshal(version, &cached.metadata) != nil || !validMetadata(cached.metadata) {
return verifiedImage{}, errors.New("MCP image version or contract is incompatible with this panel")
}
cached.catalog, err = c.probe(probeCtx, cli, id, "--catalog", maxCatalogBytes)
var manifest struct {
ContractVersion int `json:"contractVersion"`
Tools []json.RawMessage `json:"tools"`
}
if err != nil || json.Unmarshal(cached.catalog, &manifest) != nil || manifest.ContractVersion != contractVersion || len(manifest.Tools) > 256 {
return verifiedImage{}, errors.New("MCP image catalog is invalid or incompatible")
}
c.cacheMu.Lock()
if len(c.cache) >= 32 {
c.cache = make(map[string]verifiedImage)
}
c.cache[id] = cached
c.cacheMu.Unlock()
}
if strings.HasPrefix(ref, ImageRepository+":") && strings.TrimPrefix(cached.metadata.AdapterVersion, "v") != strings.TrimPrefix(strings.TrimPrefix(ref, ImageRepository+":"), "v") {
return verifiedImage{}, errors.New("MCP image version does not match its selected tag")
}
return cached, nil
}
func validMetadata(value metadata) bool {
return semanticVersion.MatchString(value.AdapterVersion) && value.ContractVersion == contractVersion && value.Protocol == protocol
}
func safeConfig(id string, args []string) (*container.Config, *container.HostConfig) {
pids := int64(128)
config := &container.Config{Image: id, Entrypoint: []string{adapterBinary}, Cmd: args, User: "0:0", Env: []string{}, Labels: map[string]string{ownerLabel: "true"}, NetworkDisabled: true, Healthcheck: &container.HealthConfig{Test: []string{"NONE"}}}
host := &container.HostConfig{NetworkMode: "none", ReadonlyRootfs: true, CapDrop: []string{"ALL"}, SecurityOpt: []string{"no-new-privileges:true"}, IpcMode: "private",
Resources: container.Resources{Memory: 256 << 20, MemorySwap: 256 << 20, NanoCPUs: 1_000_000_000, PidsLimit: &pids},
LogConfig: container.LogConfig{Type: "json-file", Config: map[string]string{"max-size": "3m", "max-file": "1"}}}
return config, host
}
func (c *Controller) probe(ctx context.Context, cli DockerClient, id, flag string, limit int) (data []byte, err error) {
config, host := safeConfig(id, []string{flag})
created, err := cli.ContainerCreate(ctx, config, host, nil, nil, "")
if err != nil {
return nil, errors.New("MCP image probe could not be created")
}
defer func() {
cleanupCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
cleanupErr := cli.ContainerRemove(cleanupCtx, created.ID, container.RemoveOptions{Force: true})
if cleanupErr != nil && !errdefs.IsNotFound(cleanupErr) {
err = errors.Join(err, errors.New("MCP image probe cleanup failed"))
}
}()
if err = cli.ContainerStart(ctx, created.ID, container.StartOptions{}); err != nil {
return nil, errors.New("MCP image probe failed to start")
}
for {
info, inspectErr := cli.ContainerInspect(ctx, created.ID)
if inspectErr != nil || info.State == nil {
return nil, errors.New("MCP image probe state is unavailable")
}
if info.State.Status == "exited" {
if info.State.ExitCode != 0 {
return nil, errors.New("MCP image probe exited unsuccessfully")
}
break
}
if info.State.Dead {
return nil, errors.New("MCP image probe exited unsuccessfully")
}
select {
case <-ctx.Done():
return nil, errors.New("MCP image probe timed out")
case <-time.After(25 * time.Millisecond):
}
}
return readLogs(ctx, cli, created.ID, limit, "all")
}
type boundedWriter struct {
bytes.Buffer
limit int
}
func (b *boundedWriter) Write(value []byte) (int, error) {
if b.Len()+len(value) > b.limit {
return 0, errors.New("MCP output exceeds permitted size")
}
return b.Buffer.Write(value)
}
func readLogs(ctx context.Context, cli DockerClient, id string, limit int, tail string) ([]byte, error) {
reader, err := cli.ContainerLogs(ctx, id, container.LogsOptions{ShowStdout: true, ShowStderr: true, Tail: tail})
if err != nil {
return nil, errors.New("MCP container logs are unavailable")
}
defer reader.Close()
stdout, stderr := &boundedWriter{limit: limit}, &boundedWriter{limit: limit}
if err = readDockerStreams(reader, stdout, stderr, 2*limit+64<<10); err != nil {
return nil, errors.New("MCP output exceeds permitted size or is invalid")
}
if tail == "all" {
return stdout.Bytes(), nil
}
combined := append(append([]byte(nil), stdout.Bytes()...), stderr.Bytes()...)
if len(combined) > limit {
combined = combined[:limit]
}
return combined, nil
}
func readDockerStreams(reader io.Reader, stdout, stderr *boundedWriter, maxRaw int) error {
total := 0
for {
var header [8]byte
_, err := io.ReadFull(reader, header[:])
if err == io.EOF {
return nil
}
if err != nil {
return err
}
size := int64(binary.BigEndian.Uint32(header[4:]))
total += len(header)
if size > int64(maxRaw-total) || header[1] != 0 || header[2] != 0 || header[3] != 0 {
return errors.New("invalid Docker log frame")
}
var output *boundedWriter
switch header[0] {
case 1:
output = stdout
case 2:
output = stderr
default:
return errors.New("invalid Docker log stream")
}
if size > int64(output.limit-output.Len()) {
return errors.New("MCP output exceeds permitted size")
}
if _, err = io.CopyN(output, reader, size); err != nil {
return err
}
total += int(size)
}
}
func checkSocketDirectory() error {
info, err := os.Lstat(SocketDirectory)
if err != nil || !info.IsDir() || info.Mode()&os.ModeSymlink != 0 || info.Mode().Perm()&0022 != 0 {
return errors.New("MCP runtime socket directory is missing or unsafe")
}
return nil
}
func readHealth(ctx context.Context, port int) (metadata, error) {
ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, fmt.Sprintf("http://127.0.0.1:%d/healthz", port), nil)
if err != nil {
return metadata{}, err
}
client := &http.Client{Transport: &http.Transport{Proxy: nil}, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
defer client.CloseIdleConnections()
resp, err := client.Do(req)
if err != nil {
return metadata{}, errors.New("MCP health endpoint is unavailable")
}
defer resp.Body.Close()
var result metadata
data, readErr := io.ReadAll(io.LimitReader(resp.Body, 4097))
if resp.StatusCode != 200 || readErr != nil || len(data) > 4096 || json.Unmarshal(data, &result) != nil || result.Status != "ok" || !validMetadata(result) {
return metadata{}, errors.New("MCP health response is incompatible")
}
return result, nil
}
var bearerPattern = regexp.MustCompile(`(?i)bearer\s+[^\s"']+`)
var sensitivePattern = regexp.MustCompile(`(?i)("?(?:password|token|secret|authorization)"?\s*[:=]\s*)("[^"]*"|[^\s,}]+)`)
func redactLog(value string) string {
return sensitivePattern.ReplaceAllString(bearerPattern.ReplaceAllString(value, "Bearer [REDACTED]"), "${1}[REDACTED]")
}
+441
View File
@@ -0,0 +1,441 @@
package mcpruntime
import (
"context"
"encoding/json"
"errors"
"io"
"reflect"
"strconv"
"strings"
"time"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/image"
"github.com/docker/docker/api/types/mount"
"github.com/docker/docker/errdefs"
)
func (c *Controller) pullImage(ctx context.Context, cli DockerClient, req Request) error {
_, err := cli.ImageInspect(ctx, req.Image)
if err != nil && !errdefs.IsNotFound(err) {
return errors.New("MCP Docker image inspection failed")
}
if errdefs.IsNotFound(err) {
pullCtx, cancel := context.WithTimeout(ctx, 3*time.Minute)
defer cancel()
reader, pullErr := cli.ImagePull(pullCtx, req.Image, image.PullOptions{})
if pullErr != nil {
return errors.New("MCP Docker image download failed")
}
defer reader.Close()
decoder := json.NewDecoder(io.LimitReader(reader, 16<<20))
for {
var message struct {
Error string `json:"error"`
ErrorDetail *struct {
Message string `json:"message"`
} `json:"errorDetail"`
}
if err = decoder.Decode(&message); err == io.EOF {
break
} else if err != nil {
return errors.New("MCP Docker image download was interrupted")
}
if message.Error != "" || message.ErrorDetail != nil {
return errors.New("MCP Docker image download failed")
}
}
}
img, err := c.resolve(ctx, cli, req.Image)
if err != nil {
return err
}
_, err = c.verify(ctx, cli, img.ID, req.Image)
return err
}
func (c *Controller) installRuntime(ctx context.Context, cli DockerClient, req Request) error {
before, err := c.owned(ctx, cli, ContainerName)
if err != nil {
return err
}
wasRunning := isRunning(before)
activate := req
activate.Enabled = true
activate.Restart = true
if err = c.apply(ctx, cli, activate); err != nil {
return err
}
if req.Enabled {
return nil
}
current, err := c.owned(ctx, cli, ContainerName)
if err == nil && current == nil {
err = errors.New("installed MCP container is unavailable")
}
if err == nil && current != nil {
err = stopContainer(ctx, cli, current.ID)
}
if err == nil {
return nil
}
rollbackCtx, cancel := context.WithTimeout(context.Background(), 40*time.Second)
defer cancel()
var restoreErr error
if before != nil {
port, parseErr := strconv.Atoi(before.Config.Labels[portLabel])
old := Request{Operation: "rollback", Image: before.Config.Labels[imageLabel], ImageID: before.Image, Port: port, Enabled: wasRunning}
if parseErr != nil || ValidateImage(old.Image) != nil {
restoreErr = errors.New("old MCP runtime configuration is invalid")
}
if restoreErr == nil {
restoreErr = c.rollback(rollbackCtx, cli, old)
}
} else if current != nil {
restoreErr = cli.ContainerRemove(rollbackCtx, current.ID, container.RemoveOptions{Force: true})
}
return errors.Join(errors.New("installed MCP container could not be left stopped"), restoreErr)
}
func runtimeConfig(id string, req Request, version string) (*container.Config, *container.HostConfig) {
config, host := safeConfig(id, []string{"--mode=panel", "--listen=127.0.0.1:" + strconv.Itoa(req.Port), "--panel-socket=" + CoreSocket})
config.NetworkDisabled = false
config.Labels[imageLabel] = req.Image
config.Labels[portLabel] = strconv.Itoa(req.Port)
config.Labels[versionLabel] = version
host.NetworkMode = "host"
host.RestartPolicy = container.RestartPolicy{Name: container.RestartPolicyUnlessStopped}
host.Mounts = []mount.Mount{{Type: mount.TypeBind, Source: SocketDirectory, Target: SocketDirectory, ReadOnly: true, BindOptions: &mount.BindOptions{Propagation: mount.PropagationRPrivate}}}
host.LogConfig.Config = map[string]string{"max-size": "1m", "max-file": "3"}
return config, host
}
func matchesRuntime(info *container.InspectResponse, id string, req Request, version string) bool {
if info == nil || info.ContainerJSONBase == nil || info.Config == nil || info.HostConfig == nil || info.Image != id {
return false
}
wantConfig, wantHost := runtimeConfig(id, req, version)
cfg, host := info.Config, info.HostConfig
if cfg.Labels[versionLabel] != version || cfg.Labels[imageLabel] != req.Image || cfg.Labels[portLabel] != strconv.Itoa(req.Port) || cfg.User != wantConfig.User || cfg.Tty || !reflect.DeepEqual(cfg.Entrypoint, wantConfig.Entrypoint) || !reflect.DeepEqual(cfg.Cmd, wantConfig.Cmd) {
return false
}
if host.NetworkMode != "host" || host.Privileged || !host.ReadonlyRootfs || !reflect.DeepEqual(host.CapDrop, wantHost.CapDrop) || len(host.CapAdd) != 0 || !reflect.DeepEqual(host.SecurityOpt, wantHost.SecurityOpt) || len(host.Binds) != 0 || len(host.VolumesFrom) != 0 || len(host.Devices) != 0 || len(host.DeviceRequests) != 0 || len(host.Mounts) != 1 {
return false
}
m := host.Mounts[0]
if m.Type != mount.TypeBind || m.Source != SocketDirectory || m.Target != SocketDirectory || !m.ReadOnly {
return false
}
if host.PidMode != "" || host.UsernsMode != "" || host.UTSMode != "" || (host.IpcMode != "" && host.IpcMode != "private") || host.PublishAllPorts || len(host.PortBindings) != 0 || host.Resources.Memory != wantHost.Memory || host.Resources.MemorySwap != wantHost.MemorySwap || host.Resources.NanoCPUs != wantHost.NanoCPUs || host.Resources.PidsLimit == nil || *host.Resources.PidsLimit != *wantHost.PidsLimit {
return false
}
return host.RestartPolicy.Name == container.RestartPolicyUnlessStopped
}
func matchesRuntimeWithCapabilities(ctx context.Context, cli DockerClient, info *container.InspectResponse, id string, req Request, version string) bool {
if matchesRuntime(info, id, req, version) {
return true
}
if info == nil || info.ContainerJSONBase == nil || info.HostConfig == nil || info.HostConfig.MemorySwap != -1 {
return false
}
adjusted := *info
base := *info.ContainerJSONBase
host := *info.HostConfig
_, wantHost := runtimeConfig(id, req, version)
host.MemorySwap = wantHost.MemorySwap
base.HostConfig = &host
adjusted.ContainerJSONBase = &base
if !matchesRuntime(&adjusted, id, req, version) {
return false
}
daemon, err := cli.Info(ctx)
return err == nil && !daemon.SwapLimit
}
func stopContainer(ctx context.Context, cli DockerClient, id string) error {
seconds := 10
if err := cli.ContainerStop(ctx, id, container.StopOptions{Timeout: &seconds}); err != nil && !errdefs.IsNotModified(err) {
return errors.New("MCP container could not be stopped")
}
return nil
}
func (c *Controller) apply(ctx context.Context, cli DockerClient, req Request) error {
current, err := c.owned(ctx, cli, ContainerName)
if err != nil {
return err
}
if !req.Enabled {
if current == nil || !isRunning(current) {
return nil
}
return stopContainer(ctx, cli, current.ID)
}
if err = c.checkDirectory(); err != nil {
return err
}
img, err := c.resolve(ctx, cli, req.Image)
if err != nil {
return err
}
if !digestPattern.MatchString(req.ImageID) || img.ID != req.ImageID {
return errors.New("MCP image identity changed after catalog validation; retry the operation")
}
verified, err := c.verify(ctx, cli, img.ID, req.Image)
if err != nil {
return err
}
if matchesRuntimeWithCapabilities(ctx, cli, current, img.ID, req, verified.metadata.AdapterVersion) {
wasRunning := isRunning(current)
if wasRunning && !req.Restart {
return c.waitHealthy(ctx, cli, current.ID, req, verified.metadata.AdapterVersion)
}
if wasRunning {
if err = stopContainer(ctx, cli, current.ID); err != nil {
return err
}
}
if err = cli.ContainerStart(ctx, current.ID, container.StartOptions{}); err == nil {
err = c.waitHealthy(ctx, cli, current.ID, req, verified.metadata.AdapterVersion)
}
if err == nil {
return nil
}
rollbackCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
stopErr := stopContainer(rollbackCtx, cli, current.ID)
if wasRunning {
stopErr = errors.Join(stopErr, cli.ContainerStart(rollbackCtx, current.ID, container.StartOptions{}))
}
return errors.Join(errors.New("MCP container failed to start or become healthy"), stopErr)
}
previous, err := c.owned(ctx, cli, previousName)
if err != nil {
return err
}
if isRunning(previous) {
return errors.New("MCP previous container is unexpectedly running; resolve its state before upgrading")
}
if matchesRuntimeWithCapabilities(ctx, cli, previous, img.ID, req, verified.metadata.AdapterVersion) {
return c.restorePrevious(ctx, cli, current, previous, req, verified.metadata.AdapterVersion)
}
if previous != nil {
if err = cli.ContainerRemove(ctx, previous.ID, container.RemoveOptions{}); err != nil {
return errors.New("MCP previous container could not be removed")
}
}
wasRunning := isRunning(current)
if current != nil {
if wasRunning {
if err = stopContainer(ctx, cli, current.ID); err != nil {
return err
}
}
if err = cli.ContainerRename(ctx, current.ID, previousName); err != nil {
if wasRunning {
rollbackCtx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
err = errors.Join(err, cli.ContainerStart(rollbackCtx, current.ID, container.StartOptions{}))
}
return errors.Join(errors.New("MCP old container could not be retained for rollback"), err)
}
}
config, host := runtimeConfig(img.ID, req, verified.metadata.AdapterVersion)
created, err := cli.ContainerCreate(ctx, config, host, nil, nil, ContainerName)
if err == nil {
err = cli.ContainerStart(ctx, created.ID, container.StartOptions{})
}
if err == nil {
err = c.waitHealthy(ctx, cli, created.ID, req, verified.metadata.AdapterVersion)
}
if err == nil {
return nil
}
rollbackCtx, cancel := context.WithTimeout(context.Background(), 40*time.Second)
defer cancel()
var rollbackErr error
if created.ID != "" {
rollbackErr = cli.ContainerRemove(rollbackCtx, created.ID, container.RemoveOptions{Force: true})
}
if current != nil && rollbackErr == nil {
rollbackErr = cli.ContainerRename(rollbackCtx, current.ID, ContainerName)
if rollbackErr == nil && wasRunning {
rollbackErr = cli.ContainerStart(rollbackCtx, current.ID, container.StartOptions{})
}
}
return errors.Join(errors.New("MCP replacement failed to start or become healthy; previous container restored where possible"), rollbackErr)
}
func (c *Controller) restorePrevious(ctx context.Context, cli DockerClient, current, previous *container.InspectResponse, req Request, version string) error {
wasRunning := isRunning(current)
if wasRunning {
if err := stopContainer(ctx, cli, current.ID); err != nil {
return err
}
}
temporary := ContainerName + "-rollback-" + strings.TrimPrefix(previous.ID, "sha256:")[:min(12, len(strings.TrimPrefix(previous.ID, "sha256:")))]
if current != nil {
if err := cli.ContainerRename(ctx, current.ID, temporary); err != nil {
rollbackCtx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
if wasRunning {
err = errors.Join(err, cli.ContainerStart(rollbackCtx, current.ID, container.StartOptions{}))
}
return errors.Join(errors.New("MCP current container could not be retained for rollback"), err)
}
}
if err := cli.ContainerRename(ctx, previous.ID, ContainerName); err != nil {
rollbackCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
var restoreErr error
if current != nil {
restoreErr = cli.ContainerRename(rollbackCtx, current.ID, ContainerName)
if restoreErr == nil && wasRunning {
restoreErr = cli.ContainerStart(rollbackCtx, current.ID, container.StartOptions{})
}
}
return errors.Join(errors.New("MCP previous container could not be restored"), restoreErr)
}
var err error
if req.Enabled {
err = cli.ContainerStart(ctx, previous.ID, container.StartOptions{})
if err == nil {
err = c.waitHealthy(ctx, cli, previous.ID, req, version)
}
}
if err == nil {
if current != nil {
err = cli.ContainerRename(ctx, current.ID, previousName)
}
if err == nil {
return nil
}
}
rollbackCtx, cancel := context.WithTimeout(context.Background(), 40*time.Second)
defer cancel()
rollbackErr := stopContainer(rollbackCtx, cli, previous.ID)
rollbackErr = errors.Join(rollbackErr, cli.ContainerRename(rollbackCtx, previous.ID, previousName))
if current != nil && rollbackErr == nil {
rollbackErr = cli.ContainerRename(rollbackCtx, current.ID, ContainerName)
if rollbackErr == nil && wasRunning {
rollbackErr = cli.ContainerStart(rollbackCtx, current.ID, container.StartOptions{})
}
}
return errors.Join(errors.New("MCP previous container failed its health check"), rollbackErr)
}
func (c *Controller) rollback(ctx context.Context, cli DockerClient, req Request) error {
if (req.ImageID != "" && !digestPattern.MatchString(req.ImageID)) || (req.Enabled && req.ImageID == "") {
return errors.New("invalid MCP rollback image identity")
}
current, err := c.owned(ctx, cli, ContainerName)
if err != nil {
return err
}
previous, err := c.owned(ctx, cli, previousName)
if err != nil {
return err
}
var target *container.InspectResponse
for _, candidate := range []*container.InspectResponse{current, previous} {
if candidate != nil && candidate.Image == req.ImageID && candidate.Config.Labels[imageLabel] == req.Image && candidate.Config.Labels[portLabel] == strconv.Itoa(req.Port) {
target = candidate
break
}
}
if target == nil {
if !req.Enabled {
if isRunning(current) {
return stopContainer(ctx, cli, current.ID)
}
return nil
}
return errors.New("the previous MCP runtime instance is unavailable for rollback")
}
verified, err := c.verify(ctx, cli, target.Image, req.Image)
if err != nil {
return err
}
if !matchesRuntimeWithCapabilities(ctx, cli, target, req.ImageID, req, verified.metadata.AdapterVersion) {
return errors.New("the previous MCP runtime configuration is unsafe or incompatible")
}
if req.Enabled {
if err = c.checkDirectory(); err != nil {
return err
}
}
if target == previous {
if isRunning(previous) {
return errors.New("MCP previous container is unexpectedly running")
}
return c.restorePrevious(ctx, cli, current, previous, req, verified.metadata.AdapterVersion)
}
if !req.Enabled {
if isRunning(current) {
return stopContainer(ctx, cli, current.ID)
}
return nil
}
if !isRunning(current) {
if err = cli.ContainerStart(ctx, current.ID, container.StartOptions{}); err != nil {
return errors.New("MCP rollback instance failed to start")
}
}
return c.waitHealthy(ctx, cli, current.ID, req, verified.metadata.AdapterVersion)
}
// rollbackAbsent removes only the fixed owned instance created by a failed
// first installation. It never resolves a tag, pulls, or creates a runtime.
func (c *Controller) rollbackAbsent(ctx context.Context, cli DockerClient, req Request) error {
if req.Enabled || !digestPattern.MatchString(req.ImageID) {
return errors.New("invalid MCP absent-runtime rollback identity")
}
current, err := c.owned(ctx, cli, ContainerName)
if err != nil || current == nil {
return err
}
if current.Image != req.ImageID || current.Config.Labels[imageLabel] != req.Image || current.Config.Labels[portLabel] != strconv.Itoa(req.Port) {
return errors.New("MCP runtime changed after installation; absent-runtime rollback was rejected")
}
if _, err = c.resolve(ctx, cli, current.Image); err != nil {
return err
}
verified, err := c.verify(ctx, cli, current.Image, req.Image)
if err != nil {
return err
}
if !matchesRuntimeWithCapabilities(ctx, cli, current, req.ImageID, req, verified.metadata.AdapterVersion) {
return errors.New("MCP installed runtime configuration is unsafe or incompatible")
}
if err = cli.ContainerRemove(ctx, current.ID, container.RemoveOptions{Force: true}); err != nil && !errdefs.IsNotFound(err) {
return errors.New("MCP failed installation could not be removed")
}
return nil
}
func (c *Controller) waitHealthy(ctx context.Context, cli DockerClient, id string, req Request, version string) error {
ctx, cancel := context.WithTimeout(ctx, 15*time.Second)
defer cancel()
for {
if ctx.Err() != nil {
return errors.New("MCP container did not become healthy before the timeout")
}
info, err := cli.ContainerInspect(ctx, id)
if err == nil && isRunning(&info) {
if info.Config == nil || info.Config.Labels[ownerLabel] != "true" || !matchesRuntimeWithCapabilities(ctx, cli, &info, req.ImageID, req, version) {
return errors.New("MCP container configuration or metadata does not match the verified image")
}
health, healthErr := c.health(ctx, req.Port)
if healthErr == nil && ctx.Err() == nil && validMetadata(health) && health.Status == "ok" && health.AdapterVersion == version {
return nil
}
}
select {
case <-ctx.Done():
return errors.New("MCP container did not become healthy before the timeout")
case <-time.After(250 * time.Millisecond):
}
}
}
+32 -4
View File
@@ -26,6 +26,8 @@ type Local struct {
Database string
Password string
ContainerName string
// sqlContext belongs to a request-scoped copy, never the reusable client.
sqlContext context.Context
}
func NewLocal(command []string, dbType, containerName, password, database string) *Local {
@@ -47,6 +49,26 @@ func (r *Local) Create(info CreateInfo) error {
return nil
}
// CreateWithContext shares one deadline across all creation and rollback SQL.
// Copying the client keeps concurrent or subsequent ordinary calls independent.
func (r *Local) CreateWithContext(ctx context.Context, info CreateInfo) error {
if ctx == nil {
return errors.New("mysql creation context is required")
}
scoped := *r
scoped.PrefixCommand = append([]string(nil), r.PrefixCommand...)
scoped.sqlContext = ctx
return scoped.Create(info)
}
func (r *Local) commandContext(timeout uint) (context.Context, context.CancelFunc) {
parent := r.sqlContext
if parent == nil {
parent = context.Background()
}
return context.WithTimeout(parent, time.Duration(timeout)*time.Second)
}
func (r *Local) CreateDatabase(info CreateInfo) error {
createSql := fmt.Sprintf("create database `%s` default character set %s collate %s", info.Name, info.Format, info.Collation)
if len(info.Collation) == 0 {
@@ -396,15 +418,18 @@ func (r *Local) SyncDB(version string) ([]SyncDBInfo, error) {
func (r *Local) Close() {}
func (r *Local) ExecSQL(command string, timeout uint) error {
itemCommand := r.PrefixCommand[:]
itemCommand := append([]string(nil), r.PrefixCommand...)
itemCommand = append(itemCommand, command)
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeout)*time.Second)
ctx, cancel := r.commandContext(timeout)
defer cancel()
cmd := exec.CommandContext(ctx, "docker", itemCommand...)
stdout, err := cmd.CombinedOutput()
if errors.Is(ctx.Err(), context.DeadlineExceeded) {
return buserr.New("ErrExecTimeOut")
}
if ctx.Err() != nil {
return ctx.Err()
}
stdStr := strings.ReplaceAll(string(stdout), "mysql: [Warning] Using a password on the command line interface can be insecure.\n", "")
if err != nil || strings.HasPrefix(string(stdStr), "ERROR ") {
return errors.New(stdStr)
@@ -413,15 +438,18 @@ func (r *Local) ExecSQL(command string, timeout uint) error {
}
func (r *Local) ExecSQLForRows(command string, timeout uint) ([]string, error) {
itemCommand := r.PrefixCommand[:]
itemCommand := append([]string(nil), r.PrefixCommand...)
itemCommand = append(itemCommand, command)
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeout)*time.Second)
ctx, cancel := r.commandContext(timeout)
defer cancel()
cmd := exec.CommandContext(ctx, "docker", itemCommand...)
stdout, err := cmd.CombinedOutput()
if errors.Is(ctx.Err(), context.DeadlineExceeded) {
return nil, buserr.New("ErrExecTimeOut")
}
if ctx.Err() != nil {
return nil, ctx.Err()
}
stdStr := strings.ReplaceAll(string(stdout), "mysql: [Warning] Using a password on the command line interface can be insecure.\n", "")
if err != nil || strings.HasPrefix(string(stdStr), "ERROR ") {
return nil, errors.New(stdStr)
+4
View File
@@ -6,6 +6,8 @@ import (
)
const (
TokenizerDownloadNamePattern = `^[A-Za-z0-9][A-Za-z0-9._-]{0,63}-[0-9a-f]{12}$`
ModelDownloaderRepoIDPattern = `^[A-Za-z0-9][A-Za-z0-9._-]*/[A-Za-z0-9][A-Za-z0-9._-]*$`
VMNameValidationPattern = `^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$`
VMCommonPattern = `^[a-zA-Z0-9][a-zA-Z0-9._:-]{0,63}$`
@@ -66,6 +68,8 @@ var regexMap = make(map[string]*regexp.Regexp)
func Init() {
patterns := []string{
TokenizerDownloadNamePattern,
ModelDownloaderRepoIDPattern,
VMNameValidationPattern,
VMCommonPattern,
+4
View File
@@ -10,10 +10,14 @@ import (
"github.com/1Panel-dev/1Panel/core/app/dto"
"github.com/1Panel-dev/1Panel/core/global"
"github.com/1Panel-dev/1Panel/core/i18n"
mcpmeta "github.com/1Panel-dev/1Panel/core/utils/mcpapi/metadata"
"github.com/gin-gonic/gin"
)
func ErrorWithDetail(ctx *gin.Context, code int, msgKey string, err error) {
if msgKey == "ErrProxy" && ctx.GetBool("MCP_API_AUTH") && ctx.Request != nil && mcpmeta.TrustedRequest(ctx.Request) {
ctx.Header(mcpmeta.BusinessCodeHeader, "ErrProxy")
}
res := dto.Response{
Code: code,
Message: "",
+374
View File
@@ -0,0 +1,374 @@
package v2
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"net/url"
"strconv"
"time"
"github.com/1Panel-dev/1Panel/core/app/api/v2/helper"
"github.com/1Panel-dev/1Panel/core/app/auth"
"github.com/1Panel-dev/1Panel/core/app/dto"
"github.com/1Panel-dev/1Panel/core/app/service"
contract "github.com/1Panel-dev/1Panel/core/utils/mcp"
"github.com/gin-gonic/gin"
)
func mcpRead(c *gin.Context, value any) bool {
data, err := io.ReadAll(http.MaxBytesReader(c.Writer, c.Request.Body, 64<<10))
if err == nil {
err = contract.DecodeObject(data, value)
}
if err == nil {
switch value.(type) {
case *dto.MCPCredentialCreate, *dto.MCPCredentialUpdate:
var fields struct {
ExpiresAt json.RawMessage `json:"expiresAt"`
}
if json.Unmarshal(data, &fields) != nil || len(fields.ExpiresAt) == 0 {
err = contract.Invalid()
}
}
}
if err != nil {
mcpManagementError(c, contract.Invalid())
return false
}
return true
}
func mcpManagementError(c *gin.Context, err error) {
code := http.StatusInternalServerError
errorCode := "MCP_OPERATION_FAILED"
invocationID := ""
var known *contract.Error
if errors.As(err, &known) {
code = known.Status
errorCode = known.Code
invocationID = known.InvocationID
} else if errors.Is(err, service.ErrMCPSettingsRevision) {
code = 409
errorCode = "MCP_CONFLICT"
}
c.Header("Cache-Control", "no-store")
c.JSON(200, struct {
dto.Response
ErrorCode string `json:"errorCode"`
InvocationID string `json:"invocationId,omitempty"`
}{Response: dto.Response{Code: code, Message: mcpManagementMessage(errorCode)}, ErrorCode: errorCode, InvocationID: invocationID})
c.Abort()
}
func mcpManagement(c *gin.Context, write bool, admin bool) (*service.MCPService, auth.APIKeyOwner, bool) {
s := service.NewMCPService()
owner, err := s.ManagementOwner(c, write)
if err != nil {
mcpManagementError(c, err)
return nil, owner, false
}
if admin && !owner.IsSuperAdmin {
mcpManagementError(c, contract.Forbidden())
return nil, owner, false
}
c.Header("Cache-Control", "no-store")
return s, owner, true
}
func (b *BaseApi) MCPCatalog(c *gin.Context) {
s, owner, ok := mcpManagement(c, false, false)
if ok {
catalog, err := s.Catalog(owner)
if err != nil {
mcpManagementError(c, err)
return
}
helper.SuccessWithData(c, catalog)
}
}
func (b *BaseApi) MCPNodes(c *gin.Context) {
s, owner, ok := mcpManagement(c, false, false)
if !ok {
return
}
if c.Request.URL.RawQuery != "" {
mcpManagementError(c, contract.Invalid())
return
}
nodes, err := s.Nodes(owner)
if err != nil {
mcpManagementError(c, err)
return
}
helper.SuccessWithData(c, nodes)
}
func (b *BaseApi) SearchMCPCredentials(c *gin.Context) {
s, owner, ok := mcpManagement(c, false, false)
if !ok {
return
}
var req dto.MCPCredentialSearch
if !mcpRead(c, &req) {
return
}
result, err := s.Search(owner, req)
if err != nil {
mcpManagementError(c, err)
return
}
helper.SuccessWithData(c, result)
}
func (b *BaseApi) CreateMCPCredential(c *gin.Context) {
s, owner, ok := mcpManagement(c, true, false)
if !ok {
return
}
var req dto.MCPCredentialCreate
if !mcpRead(c, &req) {
return
}
result, err := s.Create(owner, req)
if err != nil {
mcpManagementError(c, err)
return
}
helper.SuccessWithData(c, result)
}
func (b *BaseApi) UpdateMCPCredential(c *gin.Context) {
s, owner, ok := mcpManagement(c, true, false)
if !ok {
return
}
var req dto.MCPCredentialUpdate
if !mcpRead(c, &req) {
return
}
if err := s.Update(owner, req); err != nil {
mcpManagementError(c, err)
return
}
helper.Success(c)
}
func (b *BaseApi) StatusMCPCredential(c *gin.Context) {
s, owner, ok := mcpManagement(c, true, false)
if !ok {
return
}
var req dto.MCPCredentialStatus
if !mcpRead(c, &req) {
return
}
if err := s.Status(owner, req); err != nil {
mcpManagementError(c, err)
return
}
helper.Success(c)
}
func (b *BaseApi) RevokeMCPCredential(c *gin.Context) {
s, owner, ok := mcpManagement(c, true, false)
if !ok {
return
}
var req dto.MCPCredentialMutation
if !mcpRead(c, &req) {
return
}
if err := s.Revoke(owner, req); err != nil {
mcpManagementError(c, err)
return
}
helper.Success(c)
}
func (b *BaseApi) SearchMCPInvocations(c *gin.Context) {
s, owner, ok := mcpManagement(c, false, false)
if !ok {
return
}
var req dto.MCPInvocationSearch
if !mcpRead(c, &req) {
return
}
result, err := s.Invocations(owner, req)
if err != nil {
mcpManagementError(c, err)
return
}
helper.SuccessWithData(c, result)
}
func (b *BaseApi) MCPServiceStatus(c *gin.Context) {
_, owner, ok := mcpManagement(c, false, false)
if !ok {
return
}
ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second)
defer cancel()
result, err := service.NewMCPRuntimeService().Status(ctx)
if err != nil {
mcpManagementError(c, err)
return
}
result.Endpoint = mcpPublicEndpoint(c, result.Endpoint)
result.CanManageService = owner.IsSuperAdmin
helper.SuccessWithData(c, result)
}
func (b *BaseApi) MCPSettings(c *gin.Context) {
_, _, ok := mcpManagement(c, false, false)
if !ok {
return
}
result, err := service.NewMCPRuntimeService().Settings()
if err != nil {
mcpManagementError(c, err)
return
}
helper.SuccessWithData(c, result)
}
func (b *BaseApi) UpdateMCPSettings(c *gin.Context) {
_, _, ok := mcpManagement(c, true, true)
if !ok {
return
}
var req dto.MCPRuntimeSettingsUpdate
if !mcpRead(c, &req) {
return
}
result, err := service.NewMCPRuntimeService().Update(req)
if err != nil {
mcpServiceError(c, err)
return
}
helper.SuccessWithData(c, result)
}
func (b *BaseApi) OperateMCPService(c *gin.Context) {
_, _, ok := mcpManagement(c, true, true)
if !ok {
return
}
var req struct {
Operation string `json:"operation"`
}
if !mcpRead(c, &req) {
return
}
ctx, cancel := context.WithTimeout(c.Request.Context(), 55*time.Second)
defer cancel()
result, err := service.NewMCPRuntimeService().Operate(ctx, req.Operation)
if err != nil {
mcpServiceError(c, err)
return
}
helper.SuccessWithData(c, result)
}
func (b *BaseApi) InstallMCPService(c *gin.Context) {
_, _, ok := mcpManagement(c, true, true)
if !ok {
return
}
var req dto.MCPRuntimeInstall
if !mcpRead(c, &req) {
return
}
ctx, cancel := context.WithTimeout(c.Request.Context(), 6*time.Minute)
defer cancel()
result, err := service.NewMCPRuntimeService().Install(ctx, req)
if err != nil {
mcpServiceError(c, err)
return
}
helper.SuccessWithData(c, result)
}
func (b *BaseApi) MCPServiceLogs(c *gin.Context) {
_, _, ok := mcpManagement(c, false, true)
if !ok {
return
}
ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second)
defer cancel()
result, err := service.NewMCPRuntimeService().Logs(ctx)
if err != nil {
mcpServiceError(c, err)
return
}
helper.SuccessWithData(c, result)
}
func mcpServiceError(c *gin.Context, err error) {
var known *contract.Error
if errors.Is(err, service.ErrMCPSettingsRevision) || errors.As(err, &known) {
mcpManagementError(c, err)
return
}
helper.InternalServer(c, err)
}
func MCPRuntimeTools(c *gin.Context) {
c.Header("Cache-Control", "no-store")
token, err := service.MCPBearer(c)
if err != nil {
MCPRuntimeError(c, err)
return
}
if !mcpRuntimeProof(c, token) {
return
}
result, err := service.NewMCPService().RuntimeTools(c, token)
if err != nil {
MCPRuntimeError(c, err)
return
}
c.JSON(200, result)
}
func MCPRuntimeInvoke(c *gin.Context) {
c.Header("Cache-Control", "no-store")
token, err := service.MCPBearer(c)
if err != nil {
MCPRuntimeError(c, err)
return
}
if !mcpRuntimeProof(c, token) {
return
}
data, err := io.ReadAll(http.MaxBytesReader(c.Writer, c.Request.Body, 64<<10))
var req contract.Invoke
if err == nil {
err = contract.DecodeObject(data, &req)
}
if err == nil {
req.NodeID, err = contract.NormalizeNodeID(req.NodeID)
}
if err != nil || req.AdmissionID == "" {
MCPRuntimeError(c, contract.Invalid())
return
}
result, err := service.NewMCPService().Invoke(c, token, req)
if err != nil {
MCPRuntimeError(c, err)
return
}
c.JSON(200, result)
}
func MCPRuntimeError(c *gin.Context, err error) {
var known *contract.Error
if !errors.As(err, &known) {
known = contract.Fail("MCP_EXECUTION_FAILED", 503, "MCP service is unavailable")
}
c.Header("Cache-Control", "no-store")
if known.RetryAfterSeconds > 0 {
c.Header("Retry-After", strconv.Itoa(known.RetryAfterSeconds))
}
c.JSON(known.Status, known)
c.Abort()
}
func mcpPublicEndpoint(c *gin.Context, configured string) string {
if configured != "" {
return configured
}
scheme := "http"
if c.Request.TLS != nil {
scheme = "https"
}
if c.Request.Host == "" {
return ""
}
return (&url.URL{Scheme: scheme, Host: c.Request.Host, Path: "/mcp"}).String()
}
+70
View File
@@ -0,0 +1,70 @@
package v2
import (
"io"
"time"
"github.com/1Panel-dev/1Panel/core/app/service"
contract "github.com/1Panel-dev/1Panel/core/utils/mcp"
"github.com/gin-gonic/gin"
"net/http"
)
func mcpRuntimeProof(c *gin.Context, token string) bool {
source, err := contract.VerifyAccessProof(c.GetHeader(contract.AccessProofHeader), token, time.Now())
if err != nil {
MCPRuntimeError(c, err)
return false
}
c.Set("MCP_CLIENT_IP", source)
return true
}
func MCPRuntimeAdmit(c *gin.Context) {
c.Header("Cache-Control", "no-store")
token, err := service.MCPBearer(c)
if err != nil {
MCPRuntimeError(c, err)
return
}
if !mcpRuntimeProof(c, token) {
return
}
data, err := io.ReadAll(http.MaxBytesReader(c.Writer, c.Request.Body, 64<<10))
var req contract.Admission
if err != nil || contract.DecodeObject(data, &req) != nil {
MCPRuntimeError(c, contract.Invalid())
return
}
result, err := service.NewMCPService().Admit(c, token, req)
if err != nil {
MCPRuntimeError(c, err)
return
}
c.JSON(200, result)
}
func MCPRuntimeComplete(c *gin.Context) {
c.Header("Cache-Control", "no-store")
token, err := service.MCPBearer(c)
if err != nil {
MCPRuntimeError(c, err)
return
}
data, err := io.ReadAll(http.MaxBytesReader(c.Writer, c.Request.Body, 1024))
var req contract.Completion
if err != nil || contract.DecodeObject(data, &req) != nil {
MCPRuntimeError(c, contract.Invalid())
return
}
result, err := service.NewMCPService().CompleteWithOutcome(token, req)
if err != nil {
MCPRuntimeError(c, err)
return
}
if req.ErrorCode != "" {
c.JSON(http.StatusOK, result)
return
}
c.Status(204)
}
+64
View File
@@ -0,0 +1,64 @@
package v2
import (
"github.com/1Panel-dev/1Panel/core/app/api/v2/helper"
"github.com/1Panel-dev/1Panel/core/app/dto"
"github.com/1Panel-dev/1Panel/core/app/service"
"github.com/gin-gonic/gin"
)
func (b *BaseApi) RevealMCPCredential(c *gin.Context) {
s, owner, ok := mcpManagement(c, true, false)
if !ok {
return
}
var req dto.MCPCredentialMutation
if !mcpRead(c, &req) {
return
}
result, err := s.Reveal(owner, req)
if err != nil {
mcpManagementError(c, err)
return
}
helper.SuccessWithData(c, result)
}
func (b *BaseApi) RotateMCPCredential(c *gin.Context) {
s, owner, ok := mcpManagement(c, true, false)
if !ok {
return
}
var req dto.MCPCredentialMutation
if !mcpRead(c, &req) {
return
}
result, err := s.Rotate(owner, req)
if err != nil {
mcpManagementError(c, err)
return
}
helper.SuccessWithData(c, result)
}
func (b *BaseApi) ExportMCPCredential(c *gin.Context) {
s, owner, ok := mcpManagement(c, true, false)
if !ok {
return
}
var req dto.MCPEnrollmentExport
if !mcpRead(c, &req) {
return
}
settings, err := service.NewMCPRuntimeService().Settings()
if err != nil {
mcpManagementError(c, err)
return
}
result, err := s.ExportMarkdown(owner, req, mcpPublicEndpoint(c, settings.ExternalURL), c.GetHeader("Accept-Language"))
if err != nil {
mcpManagementError(c, err)
return
}
helper.SuccessWithData(c, result)
}
+36
View File
@@ -0,0 +1,36 @@
package v2
import "github.com/1Panel-dev/1Panel/core/i18n"
// Management responses use panel localization while runtime errors retain their
// stable machine contract. Never render raw errors or credential data here.
func mcpManagementMessage(code string) string {
key := "ErrInternalServer"
switch code {
case "MCP_INVALID_ARGUMENTS":
key = "ErrInvalidParams"
case "MCP_UNAUTHENTICATED":
key = "ErrNotLogin"
case "MCP_FORBIDDEN":
key = "ErrRBAC"
case "MCP_CONFLICT":
key = "ErrMCPConflict"
case "MCP_POLICY_CHANGED", "MCP_SCHEMA_MISMATCH":
key = "ErrMCPPolicyChanged"
case "MCP_CATALOG_UNAVAILABLE":
key = "ErrMCPCatalogUnavailable"
case "MCP_DISABLED":
key = "ErrMCPDisabled"
case "MCP_SECRET_KEY_UNAVAILABLE":
key = "ErrMCPSecretKeyUnavailable"
case "MCP_SECRET_INVALID":
key = "ErrMCPSecretInvalid"
case "MCP_CREDENTIAL_INACTIVE":
key = "ErrMCPCredentialInactive"
case "MCP_AUDIT_UNAVAILABLE":
key = "ErrMCPAuditUnavailable"
case "MCP_ENDPOINT_UNAVAILABLE":
key = "ErrMCPEndpointUnavailable"
}
return i18n.GetMsgWithMap(key, nil)
}
+62
View File
@@ -0,0 +1,62 @@
package dto
import "time"
type MCPCredentialFields struct {
Name string `json:"name"`
Description string `json:"description"`
ExpiresAt *time.Time `json:"expiresAt"`
Tools []string `json:"tools"`
ToolPolicies map[string]string `json:"toolPolicies"`
ToolReadOnly map[string]bool `json:"toolReadOnly"`
NodeIDs []string `json:"nodeIds"`
}
type MCPNodeItem struct {
ID string `json:"id"`
Name string `json:"name"`
Status string `json:"status"`
IsLocal bool `json:"isLocal"`
}
type MCPCredentialCreate struct {
RequestID string `json:"requestID"`
MCPCredentialFields
}
type MCPCredentialMutation struct {
ID string `json:"id"`
Revision uint64 `json:"revision"`
}
type MCPCredentialUpdate struct {
MCPCredentialMutation
MCPCredentialFields
}
type MCPCredentialStatus struct {
MCPCredentialMutation
Status string `json:"status"`
}
type MCPCredentialItem struct {
ID string `json:"id"`
MCPCredentialFields
KeyHint string `json:"keyHint"`
Status string `json:"status"`
Revision uint64 `json:"revision"`
CreatedAt time.Time `json:"createdAt"`
OwnerName string `json:"ownerName"`
StaleTools []string `json:"staleTools"`
}
type MCPCredentialCreated struct {
Item MCPCredentialItem `json:"item"`
Token string `json:"token"`
AlreadyCreated bool `json:"alreadyCreated,omitempty"`
}
type MCPCredentialSearch struct {
Page int `json:"page"`
PageSize int `json:"pageSize"`
ExcludeRevoked bool `json:"excludeRevoked"`
}
type MCPInvocationSearch struct {
Page int `json:"page"`
PageSize int `json:"pageSize"`
CredentialID string `json:"credentialId"`
ToolID string `json:"toolId"`
Status string `json:"status"`
}
+16
View File
@@ -0,0 +1,16 @@
package dto
type MCPCredentialSecret struct {
ID string `json:"id"`
Token string `json:"token"`
}
type MCPEnrollmentExport struct {
MCPCredentialMutation
Client string `json:"client"`
}
type MCPEnrollmentDocument struct {
Filename string `json:"filename"`
Content string `json:"content"`
}
+43
View File
@@ -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"`
}
+64
View File
@@ -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"`
}
+31
View File
@@ -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
}
+793
View File
@@ -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
}
+37
View File
@@ -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
}
+274
View File
@@ -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, &copy
}
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
}
+266
View File
@@ -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
}
+67
View File
@@ -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
}
+54
View File
@@ -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)
}
+39
View File
@@ -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
}
+136
View File
@@ -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)
}
+327
View File
@@ -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
}
+22 -5
View File
@@ -272,13 +272,11 @@ func password() {
return
}
complexSetting := getSettingByKey(db, "ComplexityVerification")
if complexSetting == constant.StatusEnable {
if isValidPassword("newPassword") {
switch validatePassword(newPassword, complexSetting) {
case passwordTooWeak:
fmt.Println("\n" + i18n.GetMsgByKeyForCmd("UpdatePasswordFormat"))
return
}
}
if len(newPassword) < 6 {
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
+5 -52
View File
@@ -3560,40 +3560,6 @@ const docTemplate = `{
]
}
},
"/ai/ollama/model/sync": {
"post": {
"responses": {
"200": {
"description": "OK",
"schema": {
"items": {
"$ref": "#/definitions/dto.OllamaModelDropList"
},
"type": "array"
}
}
},
"security": [
{
"ApiKeyAuth": []
},
{
"Timestamp": []
}
],
"summary": "Sync Ollama model list",
"tags": [
"AI"
],
"x-panel-log": {
"BeforeFunctions": [],
"bodyKeys": [],
"formatEN": "sync Ollama model list",
"formatZH": "同步 Ollama 模型列表",
"paramKeys": []
}
}
},
"/ai/tensorrt/create": {
"post": {
"consumes": [
@@ -21983,11 +21949,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 +28036,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]",
@@ -39348,17 +39312,6 @@ const docTemplate = `{
},
"type": "object"
},
"dto.OllamaModelDropList": {
"properties": {
"id": {
"type": "integer"
},
"name": {
"type": "string"
}
},
"type": "object"
},
"dto.OllamaModelName": {
"properties": {
"name": {
+5 -52
View File
@@ -3556,40 +3556,6 @@
]
}
},
"/ai/ollama/model/sync": {
"post": {
"responses": {
"200": {
"description": "OK",
"schema": {
"items": {
"$ref": "#/definitions/dto.OllamaModelDropList"
},
"type": "array"
}
}
},
"security": [
{
"ApiKeyAuth": []
},
{
"Timestamp": []
}
],
"summary": "Sync Ollama model list",
"tags": [
"AI"
],
"x-panel-log": {
"BeforeFunctions": [],
"bodyKeys": [],
"formatEN": "sync Ollama model list",
"formatZH": "同步 Ollama 模型列表",
"paramKeys": []
}
}
},
"/ai/tensorrt/create": {
"post": {
"consumes": [
@@ -21979,11 +21945,9 @@
],
"x-panel-log": {
"BeforeFunctions": [],
"bodyKeys": [
"id"
],
"formatEN": "Delete runtime [id]",
"formatZH": "删除运行环境 [id]",
"bodyKeys": [],
"formatEN": "Delete runtime [name]",
"formatZH": "删除运行环境 [name]",
"paramKeys": []
}
}
@@ -28068,14 +28032,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]",
@@ -39344,17 +39308,6 @@
},
"type": "object"
},
"dto.OllamaModelDropList": {
"properties": {
"id": {
"type": "integer"
},
"name": {
"type": "string"
}
},
"type": "object"
},
"dto.OllamaModelName": {
"properties": {
"name": {
+5 -14
View File
@@ -53,13 +53,6 @@
"formatZH": "添加 Ollama 模型重试 [name]",
"formatEN": "re-add Ollama model [name]"
},
"/ai/ollama/model/sync": {
"bodyKeys": [],
"paramKeys": [],
"beforeFunctions": [],
"formatZH": "同步 Ollama 模型列表",
"formatEN": "sync Ollama model list"
},
"/ai/tensorrt/create": {
"bodyKeys": [
"name"
@@ -3740,13 +3733,11 @@
"formatEN": "Create runtime [name]"
},
"/runtimes/del": {
"bodyKeys": [
"id"
],
"bodyKeys": [],
"paramKeys": [],
"beforeFunctions": [],
"formatZH": "删除运行环境 [id]",
"formatEN": "Delete runtime [id]"
"formatZH": "删除运行环境 [name]",
"formatEN": "Delete runtime [name]"
},
"/runtimes/operate": {
"bodyKeys": [
@@ -4582,13 +4573,13 @@
},
"/websites/php/version": {
"bodyKeys": [
"websiteId"
"websiteID"
],
"paramKeys": [],
"beforeFunctions": [
{
"input_column": "id",
"input_value": "websiteId",
"input_value": "websiteID",
"isList": false,
"db": "websites",
"output_column": "primary_domain",
+2
View File
@@ -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/[^/]+/[^/]+$`,
+28
View File
@@ -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
}
+30
View File
@@ -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."
+30
View File
@@ -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."
+30
View File
@@ -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 تنظیم کنید."
+30
View File
@@ -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 外部接続先を設定してください。"
+30
View File
@@ -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 외부 접속 주소를 설정하세요."
+30
View File
@@ -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 ພາຍນອກທີ່ຖືກຕ້ອງກ່ອນ."

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