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
CityFun f9f9169a4d fix(build): upgrade Go to 1.27.3 (#14161) 2026-10-22 15:41:34 +08:00
zhengkunwang223 1e01761ba3 fix(appstore): refresh cached resources and PHP versions
Version official app package and compose downloads using the synchronized store revision. Refresh outdated local resource caches and use synchronized database parameters for official PHP version options.
2026-10-22 11:53:36 +08:00
CityFun 15283fde34 feat: update deepseek default model (#14146) 2026-10-21 18:37:22 +08:00
Octopusandocto-patch 49f8e7f040 feat(ai-agents): refresh MiniMax Flash models and defaults (#14136)
Co-authored-by: octo-patch <octo-patch@users.noreply.github.com>
2026-10-21 18:23:53 +08:00
Yuki Guo 98b27af6a0 fix: container list search (#14134) 2026-10-21 18:22:02 +08:00
ssongliu a393224729 feat(ai): support benchmark node options and report integration (#14145) 2026-10-21 18:07:39 +08:00
Woolsen b851c84290 fix(container): reset sort to default when cleared (#14140)
The container list opens with name ascending but shows no sort indicator. Clearing the name sort kept the previous descending order, so two no-indicator states represented different orders. Reset the request to the default name ascending when the sort is cleared.
2026-10-21 18:01:18 +08:00
Woolsen 372855157d feat(container): support sorting by creation time (#14141)
Add a creation time column to the container list. The column uses the backend createdAt order field and displays the existing createTime value. It follows the standard three-state sort cycle, starting with newest first.
2026-10-21 18:01:07 +08:00
CityFunandssongliu 577d771495 feat(agent): add DeepSeek Harness and OctoP support (#14138)
* feat(agent): add DeepSeek Harness and OctoP support

* fix: restore default core edition configuration

---------

Co-authored-by: ssongliu <sloooop1x@gmail.com>
2026-10-21 17:25:38 +08:00
Yuki Guo ca3e6511e5 feat: ssl push multiple directories (#14137) 2026-10-21 17:23:56 +08:00
Yuki Guo 8af14eae25 feat: add container catalog batch upload (#14133) 2026-10-21 17:09:01 +08:00
ssongliu 175fbaf3f2 fix: correct terminal node routing for containers (#14142) 2026-10-21 16:40:42 +08:00
Yuki Guo 13199efd62 feat: add overview more quick menu (#14132) 2026-10-21 16:21:07 +08:00
379 changed files with 22886 additions and 3519 deletions
+12 -16
View File
@@ -72,22 +72,6 @@ func (b *BaseApi) CloseOllamaModel(c *gin.Context) {
helper.SuccessWithData(c, nil) helper.SuccessWithData(c, nil)
} }
// @Tags AI
// @Summary Sync Ollama model list
// @Success 200 {array} dto.OllamaModelDropList
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /ai/ollama/model/sync [post]
// @x-panel-log {"bodyKeys":[],"paramKeys":[],"BeforeFunctions":[],"formatZH":"同步 Ollama 模型列表","formatEN":"sync Ollama model list"}
func (b *BaseApi) SyncOllamaModel(c *gin.Context) {
list, err := aiToolService.Sync()
if err != nil {
helper.BadRequest(c, err)
return
}
helper.SuccessWithData(c, list)
}
// @Tags AI // @Tags AI
// @Summary Page Ollama models // @Summary Page Ollama models
// @Accept json // @Accept json
@@ -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 // @Tags AI
// @Summary Page Ollama models // @Summary Page Ollama models
// @Accept json // @Accept json
+16
View File
@@ -0,0 +1,16 @@
package v2
import (
"github.com/1Panel-dev/1Panel/agent/app/api/v2/helper"
"github.com/1Panel-dev/1Panel/agent/app/service"
"github.com/gin-gonic/gin"
)
func (b *BaseApi) AIBenchmarkSources(c *gin.Context) {
sources, err := service.AIBenchmarkSources(c.Query("kind"), c.Query("selected"))
if err != nil {
helper.BadRequest(c, err)
return
}
helper.SuccessWithData(c, sources)
}
+16
View File
@@ -8,6 +8,7 @@ import (
"github.com/1Panel-dev/1Panel/agent/app/dto/request" "github.com/1Panel-dev/1Panel/agent/app/dto/request"
"github.com/1Panel-dev/1Panel/agent/i18n" "github.com/1Panel-dev/1Panel/agent/i18n"
"github.com/1Panel-dev/1Panel/agent/utils/appicon" "github.com/1Panel-dev/1Panel/agent/utils/appicon"
"github.com/1Panel-dev/1Panel/agent/utils/mcpapi"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
@@ -160,10 +161,25 @@ func (b *BaseApi) GetAppDetailByID(c *gin.Context) {
// @Router /apps/install [post] // @Router /apps/install [post]
// @x-panel-log {"bodyKeys":["name"],"paramKeys":[],"BeforeFunctions":[],"formatZH":"安装应用 [name]","formatEN":"Install app [name]"} // @x-panel-log {"bodyKeys":["name"],"paramKeys":[],"BeforeFunctions":[],"formatZH":"安装应用 [name]","formatEN":"Install app [name]"}
func (b *BaseApi) InstallApp(c *gin.Context) { func (b *BaseApi) InstallApp(c *gin.Context) {
mcpRequest, trustedMCP := mcpapi.Check(c.Request)
if mcpRequest && !trustedMCP {
c.AbortWithStatus(http.StatusForbidden)
return
}
var req request.AppInstallCreate var req request.AppInstallCreate
if err := helper.CheckBindAndValidate(&req, c); err != nil { if err := helper.CheckBindAndValidate(&req, c); err != nil {
return return
} }
if mcpRequest {
req.MCPPreserveWebsiteDir = true
if value, exists := req.Params["WEBSITE_DIR"]; exists {
directory, ok := value.(string)
if !ok || directory == "" || directory != settingService.GetWebsiteDir() {
c.AbortWithStatus(http.StatusForbidden)
return
}
}
}
install, err := appService.Install(req, true) install, err := appService.Install(req, true)
if err != nil { if err != nil {
helper.InternalServer(c, err) helper.InternalServer(c, err)
+18
View File
@@ -1,7 +1,11 @@
package v2 package v2
import ( import (
"net/http"
"github.com/1Panel-dev/1Panel/agent/app/dto/request" "github.com/1Panel-dev/1Panel/agent/app/dto/request"
"github.com/1Panel-dev/1Panel/agent/app/service"
"github.com/1Panel-dev/1Panel/agent/utils/mcpapi"
"github.com/1Panel-dev/1Panel/agent/app/api/v2/helper" "github.com/1Panel-dev/1Panel/agent/app/api/v2/helper"
"github.com/1Panel-dev/1Panel/agent/app/dto" "github.com/1Panel-dev/1Panel/agent/app/dto"
@@ -336,11 +340,25 @@ func (b *BaseApi) UpdateAppConfig(c *gin.Context) {
// @Security Timestamp // @Security Timestamp
// @Router /apps/installed/info/:appInstallId [get] // @Router /apps/installed/info/:appInstallId [get]
func (b *BaseApi) GetAppInstallInfo(c *gin.Context) { func (b *BaseApi) GetAppInstallInfo(c *gin.Context) {
mcpRequest, trustedMCP := mcpapi.Check(c.Request)
if mcpRequest && !trustedMCP {
c.AbortWithStatus(http.StatusForbidden)
return
}
appInstallId, err := helper.GetIntParamByKey(c, "appInstallId") appInstallId, err := helper.GetIntParamByKey(c, "appInstallId")
if err != nil { if err != nil {
helper.BadRequest(c, err) helper.BadRequest(c, err)
return return
} }
if mcpRequest {
info, err := (&service.AppInstallService{}).GetAppInstallInfoForMCP(c.Request.Context(), appInstallId)
if err != nil {
helper.InternalServer(c, err)
return
}
helper.SuccessWithData(c, info)
return
}
info, err := appInstallService.GetAppInstallInfo(appInstallId) info, err := appInstallService.GetAppInstallInfo(appInstallId)
if err != nil { if err != nil {
helper.InternalServer(c, err) helper.InternalServer(c, err)
+9 -3
View File
@@ -28,7 +28,7 @@ func (b *BaseApi) SearchContainer(c *gin.Context) {
return return
} }
total, list, err := containerService.Page(req) total, list, err := containerService.Page(c.Request.Context(), req)
if err != nil { if err != nil {
helper.InternalServer(c, err) helper.InternalServer(c, err)
return return
@@ -249,9 +249,10 @@ func (b *BaseApi) ListContainerByImage(c *gin.Context) {
// @Success 200 {object} dto.ContainerStatus // @Success 200 {object} dto.ContainerStatus
// @Security ApiKeyAuth // @Security ApiKeyAuth
// @Security Timestamp // @Security Timestamp
// @Param containersOnly query boolean false "Only count containers"
// @Router /containers/status [get] // @Router /containers/status [get]
func (b *BaseApi) LoadContainerStatus(c *gin.Context) { 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 { if err != nil {
helper.InternalServer(c, err) helper.InternalServer(c, err)
return return
@@ -415,9 +416,14 @@ func (b *BaseApi) LoadResourceLimit(c *gin.Context) {
// @Success 200 {array} dto.ContainerListStats // @Success 200 {array} dto.ContainerListStats
// @Security ApiKeyAuth // @Security ApiKeyAuth
// @Security Timestamp // @Security Timestamp
// @Param ids query string false "Comma-separated container IDs; omitted selects all containers"
// @Router /containers/list/stats [get] // @Router /containers/list/stats [get]
func (b *BaseApi) ContainerListStats(c *gin.Context) { 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 { if err != nil {
helper.InternalServer(c, err) helper.InternalServer(c, err)
return return
+17
View File
@@ -2,9 +2,12 @@ package v2
import ( import (
"encoding/base64" "encoding/base64"
"net/http"
"github.com/1Panel-dev/1Panel/agent/app/api/v2/helper" "github.com/1Panel-dev/1Panel/agent/app/api/v2/helper"
"github.com/1Panel-dev/1Panel/agent/app/dto" "github.com/1Panel-dev/1Panel/agent/app/dto"
"github.com/1Panel-dev/1Panel/agent/app/service"
"github.com/1Panel-dev/1Panel/agent/utils/mcpapi"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
@@ -98,11 +101,25 @@ func (b *BaseApi) SearchDatabase(c *gin.Context) {
// @Param type path string true "type" // @Param type path string true "type"
// @Router /databases/db/list/:type [get] // @Router /databases/db/list/:type [get]
func (b *BaseApi) ListDatabase(c *gin.Context) { func (b *BaseApi) ListDatabase(c *gin.Context) {
mcpRequest, trustedMCP := mcpapi.Check(c.Request)
if mcpRequest && !trustedMCP {
c.AbortWithStatus(http.StatusForbidden)
return
}
dbType, err := helper.GetStrParamByKey(c, "type") dbType, err := helper.GetStrParamByKey(c, "type")
if err != nil { if err != nil {
helper.BadRequest(c, err) helper.BadRequest(c, err)
return return
} }
if mcpRequest {
list, err := (&service.DatabaseService{}).ListForMCP(c.Request.Context(), dbType)
if err != nil {
helper.BadRequest(c, err)
return
}
helper.SuccessWithData(c, list)
return
}
list, err := databaseService.List(dbType) list, err := databaseService.List(dbType)
if err != nil { if err != nil {
helper.InternalServer(c, err) helper.InternalServer(c, err)
+32
View File
@@ -3,9 +3,12 @@ package v2
import ( import (
"context" "context"
"encoding/base64" "encoding/base64"
"net/http"
"github.com/1Panel-dev/1Panel/agent/app/api/v2/helper" "github.com/1Panel-dev/1Panel/agent/app/api/v2/helper"
"github.com/1Panel-dev/1Panel/agent/app/dto" "github.com/1Panel-dev/1Panel/agent/app/dto"
"github.com/1Panel-dev/1Panel/agent/app/service"
"github.com/1Panel-dev/1Panel/agent/utils/mcpapi"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
@@ -19,6 +22,11 @@ import (
// @Router /databases [post] // @Router /databases [post]
// @x-panel-log {"bodyKeys":["name"],"paramKeys":[],"BeforeFunctions":[],"formatZH":"创建 mysql 数据库 [name]","formatEN":"create mysql database [name]"} // @x-panel-log {"bodyKeys":["name"],"paramKeys":[],"BeforeFunctions":[],"formatZH":"创建 mysql 数据库 [name]","formatEN":"create mysql database [name]"}
func (b *BaseApi) CreateMysql(c *gin.Context) { func (b *BaseApi) CreateMysql(c *gin.Context) {
mcpRequest, trustedMCP := mcpapi.Check(c.Request)
if mcpRequest && !trustedMCP {
c.AbortWithStatus(http.StatusForbidden)
return
}
var req dto.MysqlDBCreate var req dto.MysqlDBCreate
if err := helper.CheckBindAndValidate(&req, c); err != nil { if err := helper.CheckBindAndValidate(&req, c); err != nil {
return return
@@ -32,6 +40,16 @@ func (b *BaseApi) CreateMysql(c *gin.Context) {
} }
req.Password = string(password) req.Password = string(password)
} }
if mcpRequest {
created, err := (&service.MysqlService{}).CreateForMCP(c.Request.Context(), req)
if err != nil {
helper.InternalServer(c, err)
return
}
nodeID, _ := mcpapi.NodeID(c.Request)
helper.SuccessWithData(c, map[string]any{"id": created.ID, "database": created.MysqlName, "name": created.Name, "nodeId": nodeID})
return
}
if _, err := mysqlService.Create(context.Background(), req); err != nil { if _, err := mysqlService.Create(context.Background(), req); err != nil {
helper.InternalServer(c, err) helper.InternalServer(c, err)
@@ -513,11 +531,25 @@ func (b *BaseApi) LoadRemoteAccess(c *gin.Context) {
// @Security Timestamp // @Security Timestamp
// @Router /databases/status [post] // @Router /databases/status [post]
func (b *BaseApi) LoadStatus(c *gin.Context) { func (b *BaseApi) LoadStatus(c *gin.Context) {
mcpRequest, trustedMCP := mcpapi.Check(c.Request)
if mcpRequest && !trustedMCP {
c.AbortWithStatus(http.StatusForbidden)
return
}
var req dto.OperationWithNameAndType var req dto.OperationWithNameAndType
if err := helper.CheckBindAndValidate(&req, c); err != nil { if err := helper.CheckBindAndValidate(&req, c); err != nil {
return return
} }
if mcpRequest {
data, err := (&service.MysqlService{}).LoadStatusForMCP(c.Request.Context(), req)
if err != nil {
helper.InternalServer(c, err)
return
}
helper.SuccessWithData(c, data)
return
}
data, err := mysqlService.LoadStatus(req) data, err := mysqlService.LoadStatus(req)
if err != nil { if err != nil {
helper.InternalServer(c, err) helper.InternalServer(c, err)
+4
View File
@@ -12,6 +12,7 @@ import (
"sync" "sync"
"github.com/1Panel-dev/1Panel/agent/global" "github.com/1Panel-dev/1Panel/agent/global"
"github.com/1Panel-dev/1Panel/agent/utils/mcpapi"
"gorm.io/gorm" "gorm.io/gorm"
"github.com/1Panel-dev/1Panel/agent/app/dto" "github.com/1Panel-dev/1Panel/agent/app/dto"
@@ -22,6 +23,7 @@ import (
) )
func ErrorWithDetail(ctx *gin.Context, code int, msgKey string, err error) { func ErrorWithDetail(ctx *gin.Context, code int, msgKey string, err error) {
mcpapi.SetBusinessErrorHeader(ctx.Request, ctx.Writer.Header(), err)
res := dto.Response{ res := dto.Response{
Code: code, Code: code,
Message: "", 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) { func ErrorWithBusinessCode(ctx *gin.Context, code int, businessCode, msgKey string, err error) {
mcpapi.SetBusinessErrorHeader(ctx.Request, ctx.Writer.Header(), err)
res := dto.Response{ res := dto.Response{
Code: code, Code: code,
ErrorCode: businessCode, 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{}) { func ErrorWithDetailAndData(ctx *gin.Context, code int, msgKey string, err error, data interface{}) {
mcpapi.SetBusinessErrorHeader(ctx.Request, ctx.Writer.Header(), err)
res := dto.Response{ res := dto.Response{
Code: code, Code: code,
Data: data, Data: data,
+84
View File
@@ -0,0 +1,84 @@
package v2
import (
"github.com/1Panel-dev/1Panel/agent/app/api/v2/helper"
"github.com/1Panel-dev/1Panel/agent/app/dto"
"github.com/gin-gonic/gin"
)
// @Tags Dashboard
// @Summary List quick directories
// @Success 200 {array} model.QuickDirectory
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /dashboard/quick/directory [get]
func (b *BaseApi) ListQuickDirectories(c *gin.Context) {
items, err := dashboardService.ListQuickDirectories()
if err != nil {
helper.InternalServer(c, err)
return
}
helper.SuccessWithData(c, items)
}
// @Tags Dashboard
// @Summary Create quick directory
// @Accept json
// @Param request body dto.QuickDirectoryCreate true "request"
// @Success 200
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /dashboard/quick/directory [post]
func (b *BaseApi) CreateQuickDirectory(c *gin.Context) {
var req dto.QuickDirectoryCreate
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
item, err := dashboardService.CreateQuickDirectory(req)
if err != nil {
helper.InternalServer(c, err)
return
}
helper.SuccessWithData(c, item)
}
// @Tags Dashboard
// @Summary Update quick directory
// @Accept json
// @Param request body dto.QuickDirectoryUpdate true "request"
// @Success 200
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /dashboard/quick/directory/update [post]
func (b *BaseApi) UpdateQuickDirectory(c *gin.Context) {
var req dto.QuickDirectoryUpdate
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
item, err := dashboardService.UpdateQuickDirectory(req)
if err != nil {
helper.InternalServer(c, err)
return
}
helper.SuccessWithData(c, item)
}
// @Tags Dashboard
// @Summary Delete quick directory
// @Accept json
// @Param request body dto.QuickDirectoryDelete true "request"
// @Success 200
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /dashboard/quick/directory/del [post]
func (b *BaseApi) DeleteQuickDirectory(c *gin.Context) {
var req dto.QuickDirectoryDelete
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
if err := dashboardService.DeleteQuickDirectory(req); err != nil {
helper.InternalServer(c, err)
return
}
helper.Success(c)
}
+3 -2
View File
@@ -61,13 +61,14 @@ func (b *BaseApi) CreateRuntime(c *gin.Context) {
// @Security ApiKeyAuth // @Security ApiKeyAuth
// @Security Timestamp // @Security Timestamp
// @Router /runtimes/del [post] // @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) { func (b *BaseApi) DeleteRuntime(c *gin.Context) {
var req request.RuntimeDelete var req request.RuntimeDelete
if err := helper.CheckBindAndValidate(&req, c); err != nil { if err := helper.CheckBindAndValidate(&req, c); err != nil {
return return
} }
err := runtimeService.Delete(req) name, err := runtimeService.Delete(req)
helper.SetOperationLogValues(c, map[string]string{"name": name})
if err != nil { if err != nil {
helper.InternalServer(c, err) helper.InternalServer(c, err)
return return
+1 -1
View File
@@ -402,7 +402,7 @@ func (b *BaseApi) ChangeDefaultServer(c *gin.Context) {
// @Security ApiKeyAuth // @Security ApiKeyAuth
// @Security Timestamp // @Security Timestamp
// @Router /websites/php/version [post] // @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) { func (b *BaseApi) ChangePHPVersion(c *gin.Context) {
var req request.WebsitePHPVersionReq var req request.WebsitePHPVersionReq
if err := helper.CheckBindAndValidate(&req, c); err != nil { if err := helper.CheckBindAndValidate(&req, c); err != nil {
+64
View File
@@ -1,13 +1,16 @@
package v2 package v2
import ( import (
"io"
"net/http" "net/http"
"net/url" "net/url"
"strconv" "strconv"
"strings"
"github.com/1Panel-dev/1Panel/agent/app/api/v2/helper" "github.com/1Panel-dev/1Panel/agent/app/api/v2/helper"
"github.com/1Panel-dev/1Panel/agent/app/dto" "github.com/1Panel-dev/1Panel/agent/app/dto"
"github.com/1Panel-dev/1Panel/agent/app/dto/request" "github.com/1Panel-dev/1Panel/agent/app/dto/request"
"github.com/1Panel-dev/1Panel/agent/buserr"
"github.com/gin-gonic/gin" "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())) c.Header("Content-Disposition", "attachment; filename*=utf-8''"+url.PathEscape(info.Name()))
http.ServeContent(c.Writer, c.Request, info.Name(), info.ModTime(), file) 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)
}
+4 -1
View File
@@ -9,7 +9,8 @@ type AgentCreateReq struct {
WebUIPort int `json:"webUIPort" validate:"required,min=1,max=65535"` WebUIPort int `json:"webUIPort" validate:"required,min=1,max=65535"`
BridgePort int `json:"bridgePort"` BridgePort int `json:"bridgePort"`
AllowedOrigins []string `json:"allowedOrigins"` AllowedOrigins []string `json:"allowedOrigins"`
AgentType string `json:"agentType" validate:"required,oneof=openclaw copaw hermes-agent"` AgentType string `json:"agentType" validate:"required,oneof=openclaw copaw hermes-agent deepseek-harness octop"`
AccessHost string `json:"accessHost"`
Model string `json:"model"` Model string `json:"model"`
AccountID uint `json:"accountId"` AccountID uint `json:"accountId"`
Token string `json:"token"` Token string `json:"token"`
@@ -110,6 +111,7 @@ type AgentBatchOperateResult struct {
} }
type AgentItem struct { type AgentItem struct {
AccessHost string `json:"accessHost"`
ID uint `json:"id"` ID uint `json:"id"`
Name string `json:"name"` Name string `json:"name"`
Remark string `json:"remark"` Remark string `json:"remark"`
@@ -384,6 +386,7 @@ type AgentAccountProviderCountReq struct {
} }
type AgentAccountInfo struct { type AgentAccountInfo struct {
AppInstallID uint `json:"appInstallId"`
ID uint `json:"id"` ID uint `json:"id"`
MasterAccountID uint `json:"masterAccountId"` MasterAccountID uint `json:"masterAccountId"`
Provider string `json:"provider"` Provider string `json:"provider"`
+6 -5
View File
@@ -14,16 +14,17 @@ type OllamaModelInfo struct {
CreatedAt time.Time `json:"createdAt"` CreatedAt time.Time `json:"createdAt"`
} }
type OllamaModelDropList struct {
ID uint `json:"id"`
Name string `json:"name"`
}
type OllamaModelName struct { type OllamaModelName struct {
Name string `json:"name"` Name string `json:"name"`
TaskID string `json:"taskID"` TaskID string `json:"taskID"`
} }
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 { type OllamaBindDomain struct {
Domain string `json:"domain" validate:"required"` Domain string `json:"domain" validate:"required"`
AppInstallID uint `json:"appInstallID" validate:"required"` AppInstallID uint `json:"appInstallID" validate:"required"`
+13
View File
@@ -0,0 +1,13 @@
package dto
type AIBenchmarkSource struct {
ID string `json:"id"`
Kind string `json:"kind"`
Name string `json:"name"`
Status string `json:"status"`
BaseURL string `json:"baseUrl"`
Endpoint string `json:"endpoint"`
Models []string `json:"models"`
Tokenizer string `json:"tokenizer"`
Message string `json:"message"`
}
+1
View File
@@ -172,6 +172,7 @@ type ContainerItemStats struct {
} }
type ContainerListStats struct { type ContainerListStats struct {
ContainerID string `json:"containerID"` ContainerID string `json:"containerID"`
ContainerName string `json:"containerName"`
CPUTotalUsage uint64 `json:"cpuTotalUsage"` CPUTotalUsage uint64 `json:"cpuTotalUsage"`
SystemUsage uint64 `json:"systemUsage"` SystemUsage uint64 `json:"systemUsage"`
+21 -1
View File
@@ -31,7 +31,27 @@ type DashboardBase struct {
} }
type ChangeQuicks struct { type ChangeQuicks struct {
Quicks []QuickJump `json:"quicks"` Quicks []QuickVisibility `json:"quicks" validate:"required,dive"`
}
type QuickVisibility struct {
ID uint `json:"id" validate:"required"`
IsShow bool `json:"isShow"`
}
type QuickDirectoryCreate struct {
Path string `json:"path" validate:"required"`
Alias string `json:"alias"`
}
type QuickDirectoryDelete struct {
ID uint `json:"id" validate:"required"`
Legacy bool `json:"legacy"`
}
type QuickDirectoryUpdate struct {
QuickDirectoryDelete
QuickDirectoryCreate
} }
type QuickJump struct { type QuickJump struct {
+9
View File
@@ -23,6 +23,12 @@ type AppInstallCreate struct {
Services map[string]string `json:"services"` Services map[string]string `json:"services"`
TaskID string `json:"taskID"` 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 AppContainerConfig
NodePushConfig NodePushConfig
} }
@@ -122,6 +128,9 @@ type AppInstallDelete struct {
} }
type AppInstalledUpdate 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"` InstallId uint `json:"installId" validate:"required"`
Params map[string]interface{} `json:"params" validate:"required"` Params map[string]interface{} `json:"params" validate:"required"`
TaskID string `json:"-"` TaskID string `json:"-"`
+9
View File
@@ -182,3 +182,12 @@ type WebsiteSSLFileUpload struct {
PushNode bool `json:"pushNode"` PushNode bool `json:"pushNode"`
Nodes string `json:"nodes"` 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 { type AppInstalledCheck struct {
ModelAccountBaseURLType string `json:"modelAccountBaseURLType"`
ModelAccountBaseURL string `json:"modelAccountBaseURL"`
IsExist bool `json:"isExist"` IsExist bool `json:"isExist"`
Name string `json:"name"` Name string `json:"name"`
App string `json:"app"` App string `json:"app"`
@@ -184,6 +186,12 @@ type AppParam struct {
} }
type AppConfig 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"` Params []AppParam `json:"params"`
RawCompose string `json:"rawCompose"` RawCompose string `json:"rawCompose"`
request.AppContainerConfig request.AppContainerConfig
+1
View File
@@ -2,6 +2,7 @@ package model
type AgentAccount struct { type AgentAccount struct {
BaseModel BaseModel
AppInstallID uint `json:"appInstallId"`
Provider string `json:"provider"` Provider string `json:"provider"`
Name string `json:"name"` Name string `json:"name"`
APIKey string `json:"apiKey"` APIKey string `json:"apiKey"`
+4
View File
@@ -28,6 +28,10 @@ type AppInstall struct {
Favorite bool `json:"favorite"` Favorite bool `json:"favorite"`
SortOrder int `json:"sortOrder" gorm:"default:0"` 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"` App App `json:"app" gorm:"-:migration"`
} }
+7
View File
@@ -5,6 +5,13 @@ type AppLauncher struct {
Key string `json:"key"` Key string `json:"key"`
} }
type QuickDirectory struct {
BaseModel
Path string `json:"path"`
Alias string `json:"alias"`
Legacy bool `json:"legacy" gorm:"-"`
}
type QuickJump struct { type QuickJump struct {
BaseModel BaseModel
Name string `json:"name"` Name string `json:"name"`
+2 -3
View File
@@ -66,7 +66,7 @@ var catalog = map[string]Meta{
{APIType: "openai-responses", BaseURL: "https://api.deepseek.com"}, {APIType: "openai-responses", BaseURL: "https://api.deepseek.com"},
anthropicAPIConfig("https://api.deepseek.com/anthropic", AuthModeXAPIKey), anthropicAPIConfig("https://api.deepseek.com/anthropic", AuthModeXAPIKey),
}, },
Models: []Model{{ID: "deepseek-v4-flash", Name: "deepseek-v4-flash"}, {ID: "deepseek-v4-pro", Name: "deepseek-v4-pro"}}, Models: []Model{{ID: "deepseek-flash", Name: "deepseek-flash"}, {ID: "deepseek-v4-pro", Name: "deepseek-v4-pro"}},
}, },
"bailian-coding-plan": { "bailian-coding-plan": {
Key: "bailian-coding-plan", DisplayNameKey: "AIProviderBailianCodingPlan", Sort: 30, DefaultAPIType: "openai-completions", EnvKey: "QWEN_API_KEY", Key: "bailian-coding-plan", DisplayNameKey: "AIProviderBailianCodingPlan", Sort: 30, DefaultAPIType: "openai-completions", EnvKey: "QWEN_API_KEY",
@@ -82,7 +82,6 @@ var catalog = map[string]Meta{
{ID: "kimi-k2.5", Name: "Kimi K2.5"}, {ID: "kimi-k2.5", Name: "Kimi K2.5"},
{ID: "qwen3.5-plus", Name: "Qwen3.5-Plus"}, {ID: "qwen3.5-plus", Name: "Qwen3.5-Plus"},
{ID: "glm-5", Name: "GLM-5"}, {ID: "glm-5", Name: "GLM-5"},
{ID: "MiniMax-M2.5", Name: "MiniMax M2.5"},
{ID: "qwen3.6-plus", Name: "Qwen3.6-Plus"}, {ID: "qwen3.6-plus", Name: "Qwen3.6-Plus"},
{ID: "qwen3.7-plus", Name: "Qwen3.7-Plus"}, {ID: "qwen3.7-plus", Name: "Qwen3.7-Plus"},
}, },
@@ -114,7 +113,7 @@ var catalog = map[string]Meta{
{APIType: "openai-completions", BaseURL: "https://api.minimaxi.com/v1"}, {APIType: "openai-completions", BaseURL: "https://api.minimaxi.com/v1"},
{APIType: "minimax-images", BaseURL: "https://api.minimaxi.com"}, {APIType: "minimax-images", BaseURL: "https://api.minimaxi.com"},
}, },
Models: []Model{{ID: "MiniMax-M3", Name: "MiniMax M3"}, {ID: "MiniMax-M2.7", Name: "MiniMax M2.7"}, {ID: "MiniMax-M2.7-highspeed", Name: "MiniMax M2.7 highspeed"}}, Models: []Model{{ID: "MiniMax-M3.1-Flash", Name: "MiniMax M3.1 Flash"}, {ID: "MiniMax-M3", Name: "MiniMax M3"}},
}, },
"xiaomi": { "xiaomi": {
Key: "xiaomi", DisplayName: "Xiaomi", Sort: 46, DefaultAPIType: "openai-completions", EnvKey: "XIAOMI_API_KEY", Key: "xiaomi", DisplayName: "Xiaomi", Sort: 46, DefaultAPIType: "openai-completions", EnvKey: "XIAOMI_API_KEY",
+100
View File
@@ -0,0 +1,100 @@
package provider
import (
"encoding/json"
"net/http"
"net/http/httptest"
"reflect"
"strings"
"testing"
)
func TestMiniMaxCatalog(t *testing.T) {
meta, ok := Get("minimax")
if !ok || !reflect.DeepEqual(meta.Models, []Model{{ID: "MiniMax-M3.1-Flash", Name: "MiniMax M3.1 Flash"}, {ID: "MiniMax-M3", Name: "MiniMax M3"}}) {
t.Fatalf("unexpected MiniMax models: %+v", meta.Models)
}
if meta.DefaultAPIType != "anthropic-messages" {
t.Fatalf("changed default protocol: %s", meta.DefaultAPIType)
}
for _, provider := range All() {
for _, model := range provider.Models {
if strings.Contains(strings.ToLower(model.ID), "minimax") && model.ID != "MiniMax-M3.1-Flash" && model.ID != "MiniMax-M3" {
t.Errorf("legacy suggestion in %s: %s", provider.Key, model.ID)
}
}
}
}
func TestMiniMaxVerificationRequests(t *testing.T) {
for _, region := range []string{"https://api.minimaxi.com", "https://api.minimax.io"} {
for _, protocol := range []string{"anthropic-messages", "openai-completions"} {
for _, auth := range []string{AuthModeXAPIKey, AuthModeBearer} {
for _, model := range []string{"MiniMax-M3.1-Flash", "MiniMax-M3", "MiniMax-M2.7", "MiniMax-M2.7-highspeed", "custom-model"} {
t.Run(region+"/"+protocol+"/"+auth+"/"+model, func(t *testing.T) {
base, endpoint := region+"/v1", "/chat/completions"
if protocol == "anthropic-messages" {
base, endpoint = region+"/anthropic", "/v1/messages"
}
built := BuildVerifyRequest("minimax", protocol, auth, base, "test-key", model)
if built.URL != base+endpoint {
t.Fatalf("URL = %s", built.URL)
}
var captured map[string]interface{}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost || r.URL.Path != strings.TrimPrefix(base, region)+endpoint {
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
}
if protocol == "anthropic-messages" && auth == AuthModeXAPIKey {
if r.Header.Get("x-api-key") != "test-key" || r.Header.Get("Authorization") != "" {
t.Error("incorrect API key authentication")
}
} else if r.Header.Get("Authorization") != "Bearer test-key" {
t.Error("incorrect bearer authentication")
}
if err := json.NewDecoder(r.Body).Decode(&captured); err != nil {
t.Error(err)
}
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
if err := VerifyAccount("minimax", protocol, auth, server.URL+strings.TrimPrefix(base, region), "test-key", model); err != nil {
t.Fatal(err)
}
if captured["model"] != model || captured["stream"] != false || captured["max_tokens"] != float64(defaultVerifyMaxTokens) {
t.Fatalf("unexpected serialized body: %#v", captured)
}
if model == "MiniMax-M3.1-Flash" {
if protocol == "anthropic-messages" {
if !reflect.DeepEqual(captured["thinking"], map[string]interface{}{"type": "adaptive"}) || !reflect.DeepEqual(captured["output_config"], map[string]interface{}{"effort": "max"}) || captured["reasoning_effort"] != nil {
t.Fatalf("invalid Anthropic Flash reasoning: %#v", captured)
}
} else if captured["reasoning_effort"] != "max" || captured["thinking"] != nil || captured["output_config"] != nil {
t.Fatalf("invalid OpenAI Flash reasoning: %#v", captured)
}
} else if captured["reasoning_effort"] != nil || captured["thinking"] != nil || captured["output_config"] != nil {
t.Fatalf("changed legacy/custom reasoning: %#v", captured)
}
})
}
}
}
}
}
func TestMiniMaxExplicitGatewayAndCustomModels(t *testing.T) {
for _, provider := range []string{"bailian-coding-plan", "custom", "openrouter"} {
for _, protocol := range []string{"openai-completions", "anthropic-messages"} {
for _, model := range []string{"MiniMax-M2.5", "MiniMax-M3.1-Flash", "my-custom-model"} {
request := BuildVerifyRequest(provider, protocol, AuthModeBearer, "https://example.test/v1", "test-key", model)
var body map[string]interface{}
if err := json.Unmarshal(request.Body, &body); err != nil {
t.Fatal(err)
}
if body["model"] != model || body["reasoning_effort"] != nil || body["output_config"] != nil || body["thinking"] != nil {
t.Fatalf("changed explicit %s request: %#v", provider, body)
}
}
}
}
}
+13 -4
View File
@@ -118,10 +118,15 @@ func BuildVerifyRequest(provider, apiType, authMode, baseURL, apiKey, model stri
headers["x-api-key"] = apiKey headers["x-api-key"] = apiKey
} }
headers["anthropic-version"] = "2023-06-01" headers["anthropic-version"] = "2023-06-01"
request.Body = mustJSON(map[string]interface{}{ body := map[string]interface{}{
"model": model, "max_tokens": defaultVerifyMaxTokens, "stream": false, "model": model, "max_tokens": defaultVerifyMaxTokens, "stream": false,
"messages": []map[string]interface{}{{"role": "user", "content": []map[string]string{{"type": "text", "text": "test"}}}}, "messages": []map[string]interface{}{{"role": "user", "content": []map[string]string{{"type": "text", "text": "test"}}}},
}) }
if provider == "minimax" && model == "MiniMax-M3.1-Flash" {
body["thinking"] = map[string]string{"type": "adaptive"}
body["output_config"] = map[string]string{"effort": "max"}
}
request.Body = mustJSON(body)
case "openai-responses": case "openai-responses":
request.URL = baseURL + "/responses" request.URL = baseURL + "/responses"
headers["Authorization"] = "Bearer " + apiKey headers["Authorization"] = "Bearer " + apiKey
@@ -131,9 +136,13 @@ func BuildVerifyRequest(provider, apiType, authMode, baseURL, apiKey, model stri
if (provider != "ollama" && provider != "llmman") || strings.TrimSpace(apiKey) != "" { if (provider != "ollama" && provider != "llmman") || strings.TrimSpace(apiKey) != "" {
headers["Authorization"] = "Bearer " + apiKey headers["Authorization"] = "Bearer " + apiKey
} }
request.Body = mustJSON(map[string]interface{}{ body := map[string]interface{}{
"model": model, "messages": []map[string]string{{"role": "user", "content": "test"}}, "max_tokens": defaultVerifyMaxTokens, "stream": false, "model": model, "messages": []map[string]string{{"role": "user", "content": "test"}}, "max_tokens": defaultVerifyMaxTokens, "stream": false,
}) }
if provider == "minimax" && model == "MiniMax-M3.1-Flash" {
body["reasoning_effort"] = "max"
}
request.Body = mustJSON(body)
} }
return request return request
} }
+21
View File
@@ -4,6 +4,7 @@ import (
"strings" "strings"
"github.com/1Panel-dev/1Panel/agent/app/model" "github.com/1Panel-dev/1Panel/agent/app/model"
"github.com/1Panel-dev/1Panel/agent/global"
"gorm.io/gorm" "gorm.io/gorm"
) )
@@ -18,6 +19,7 @@ type IAgentAccountRepo interface {
DeleteByID(id uint) error DeleteByID(id uint) error
List(opts ...DBOption) ([]model.AgentAccount, error) List(opts ...DBOption) ([]model.AgentAccount, error)
CountTextByProviders(providers []string) (map[string]int64, error) CountTextByProviders(providers []string) (map[string]int64, error)
SaveLocal(account *model.AgentAccount, models []model.AgentAccountModel) error
} }
func NewIAgentAccountRepo() IAgentAccountRepo { func NewIAgentAccountRepo() IAgentAccountRepo {
@@ -97,6 +99,25 @@ func (a AgentAccountRepo) CountTextByProviders(providers []string) (map[string]i
return counts, nil 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 { func normalizeProviders(providers []string) []string {
seen := make(map[string]struct{}, len(providers)) seen := make(map[string]struct{}, len(providers))
result := make([]string, 0, len(providers)) result := make([]string, 0, len(providers))
+7
View File
@@ -1,6 +1,8 @@
package repo package repo
import ( import (
"context"
"github.com/1Panel-dev/1Panel/agent/app/model" "github.com/1Panel-dev/1Panel/agent/app/model"
"github.com/1Panel-dev/1Panel/agent/global" "github.com/1Panel-dev/1Panel/agent/global"
) )
@@ -14,6 +16,7 @@ type IAiRepo interface {
Create(cronjob *model.OllamaModel) error Create(cronjob *model.OllamaModel) error
Update(id uint, vars map[string]interface{}) error Update(id uint, vars map[string]interface{}) error
Delete(opts ...DBOption) error Delete(opts ...DBOption) error
DeleteAll(ctx context.Context) error
} }
func NewIAiRepo() IAiRepo { func NewIAiRepo() IAiRepo {
@@ -67,3 +70,7 @@ func (u *AiRepo) Delete(opts ...DBOption) error {
} }
return db.Delete(&model.OllamaModel{}).Error return db.Delete(&model.OllamaModel{}).Error
} }
func (u *AiRepo) DeleteAll(ctx context.Context) error {
return getTx(ctx).Where("1 = 1").Delete(&model.OllamaModel{}).Error
}
+69 -3
View File
@@ -3,6 +3,7 @@ package repo
import ( import (
"github.com/1Panel-dev/1Panel/agent/app/model" "github.com/1Panel-dev/1Panel/agent/app/model"
"github.com/1Panel-dev/1Panel/agent/global" "github.com/1Panel-dev/1Panel/agent/global"
"gorm.io/gorm"
) )
type LauncherRepo struct{} type LauncherRepo struct{}
@@ -17,6 +18,11 @@ type ILauncherRepo interface {
GetQuickJump(opts ...DBOption) (model.QuickJump, error) GetQuickJump(opts ...DBOption) (model.QuickJump, error)
ListQuickJump(withAll bool) []model.QuickJump ListQuickJump(withAll bool) []model.QuickJump
UpdateQuicks(quicks []model.QuickJump) error UpdateQuicks(quicks []model.QuickJump) error
ListQuickDirectories() ([]model.QuickDirectory, error)
CountQuickDirectories() (int64, error)
CreateQuickDirectory(item *model.QuickDirectory) error
UpdateQuickDirectory(item *model.QuickDirectory) error
DeleteQuickDirectory(id uint, legacy bool) error
} }
func NewILauncherRepo() ILauncherRepo { func NewILauncherRepo() ILauncherRepo {
@@ -95,13 +101,73 @@ func (u *LauncherRepo) UpdateQuicks(quicks []model.QuickJump) error {
for _, item := range quicks { for _, item := range quicks {
if err := tx.Model(&model.QuickJump{}).Where("id = ?", item.ID).Updates(map[string]interface{}{ if err := tx.Model(&model.QuickJump{}).Where("id = ?", item.ID).Updates(map[string]interface{}{
"is_show": item.IsShow, "is_show": item.IsShow,
"detail": item.Detail,
"alias": item.Alias,
}).Error; err != nil { }).Error; err != nil {
tx.Rollback() tx.Rollback()
return err return err
} }
} }
tx.Commit() return tx.Commit().Error
}
// Legacy entries remain in the overview configuration until explicitly deleted.
func legacyQuickDirectories(db *gorm.DB) *gorm.DB {
return db.Model(&model.QuickJump{}).Where("name = ? AND is_show = ? AND detail <> ?", "File", true, "")
}
func (u *LauncherRepo) ListQuickDirectories() ([]model.QuickDirectory, error) {
items := make([]model.QuickDirectory, 0)
if err := global.DB.Order("id ASC").Find(&items).Error; err != nil {
return nil, err
}
var quicks []model.QuickJump
if err := legacyQuickDirectories(global.DB).Order("id ASC").Find(&quicks).Error; err != nil {
return nil, err
}
for _, quick := range quicks {
items = append(items, model.QuickDirectory{BaseModel: quick.BaseModel, Path: quick.Detail, Alias: quick.Alias, Legacy: true})
}
return items, nil
}
func (u *LauncherRepo) CountQuickDirectories() (int64, error) {
var count, legacyCount int64
if err := global.DB.Model(&model.QuickDirectory{}).Count(&count).Error; err != nil {
return 0, err
}
if err := legacyQuickDirectories(global.DB).Count(&legacyCount).Error; err != nil {
return 0, err
}
return count + legacyCount, nil
}
func (u *LauncherRepo) CreateQuickDirectory(item *model.QuickDirectory) error {
return global.DB.Create(item).Error
}
func (u *LauncherRepo) UpdateQuickDirectory(item *model.QuickDirectory) error {
var result *gorm.DB
if item.Legacy {
result = legacyQuickDirectories(global.DB).Where("id = ?", item.ID).Updates(map[string]interface{}{"detail": item.Path, "alias": item.Alias})
} else {
result = global.DB.Model(&model.QuickDirectory{}).Where("id = ?", item.ID).Updates(map[string]interface{}{"path": item.Path, "alias": item.Alias})
}
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return gorm.ErrRecordNotFound
}
return nil
}
func (u *LauncherRepo) DeleteQuickDirectory(id uint, legacy bool) error {
var result *gorm.DB
if legacy {
result = legacyQuickDirectories(global.DB).Where("id = ?", id).Updates(map[string]interface{}{"detail": nil, "alias": nil})
} else {
result = global.DB.Delete(&model.QuickDirectory{}, id)
}
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return gorm.ErrRecordNotFound
}
return nil return nil
} }
+55 -6
View File
@@ -142,6 +142,9 @@ const (
func (a AgentService) Create(req dto.AgentCreateReq) (*dto.AgentItem, error) { func (a AgentService) Create(req dto.AgentCreateReq) (*dto.AgentItem, error) {
agentType := req.AgentType agentType := req.AgentType
if err := validateNativeAgentCreate(req); err != nil {
return nil, err
}
if err := checkPortExist(req.WebUIPort); err != nil { if err := checkPortExist(req.WebUIPort); err != nil {
return nil, err return nil, err
} }
@@ -237,6 +240,8 @@ func (a AgentService) Create(req dto.AgentCreateReq) (*dto.AgentItem, error) {
} }
} else if agentType == constant.AppCopaw { } else if agentType == constant.AppCopaw {
dashboardAuth = normalizeAgentDashboardAuth(req.DashboardUsername, req.DashboardPassword) dashboardAuth = normalizeAgentDashboardAuth(req.DashboardUsername, req.DashboardPassword)
} else if agentType == constant.AppDeepSeekHarness || agentType == constant.AppOctop {
dashboardAuth = agentDashboardAuth{Username: req.DashboardUsername, Password: req.DashboardPassword}
} }
params := map[string]interface{}{ params := map[string]interface{}{
@@ -245,6 +250,12 @@ func (a AgentService) Create(req dto.AgentCreateReq) (*dto.AgentItem, error) {
constant.HostIP: "", constant.HostIP: "",
} }
setAgentWebUIParams(params, agentType, detail.Version, req.WebUIPort) setAgentWebUIParams(params, agentType, detail.Version, req.WebUIPort)
if agentType == constant.AppDeepSeekHarness {
params["HTTPS_ACCESS_HOST"] = req.AccessHost
}
if agentType == constant.AppOctop {
params["OCTOP_LOG_LEVEL"] = "info"
}
if agentType == constant.AppOpenclaw { if agentType == constant.AppOpenclaw {
if allowedOrigin := firstAllowedOrigin(allowedOrigins); allowedOrigin != "" { if allowedOrigin := firstAllowedOrigin(allowedOrigins); allowedOrigin != "" {
params["ALLOWED_ORIGIN"] = allowedOrigin params["ALLOWED_ORIGIN"] = allowedOrigin
@@ -738,7 +749,7 @@ func requiresAgentAccount(agentType string) bool {
} }
func setAgentWebUIParams(params map[string]interface{}, agentType, appVersion string, webUIPort int) { func setAgentWebUIParams(params map[string]interface{}, agentType, appVersion string, webUIPort int) {
if agentType == constant.AppOpenclaw && isOpenclawHTTPSWindowVersion(appVersion) { if agentType == constant.AppDeepSeekHarness || agentType == constant.AppOpenclaw && isOpenclawHTTPSWindowVersion(appVersion) {
params["PANEL_APP_PORT_HTTPS"] = webUIPort params["PANEL_APP_PORT_HTTPS"] = webUIPort
return return
} }
@@ -1044,7 +1055,7 @@ func (a AgentService) CreateAccount(req dto.AgentAccountCreateReq) error {
return err return err
} }
validateAvailability := req.ValidateAvailability == nil || *req.ValidateAvailability 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 { if err != nil {
return err return err
} }
@@ -1097,12 +1108,15 @@ func (a AgentService) UpdateAccount(req dto.AgentAccountUpdateReq) error {
if strings.TrimSpace(requestedVerifyModel) == "" { if strings.TrimSpace(requestedVerifyModel) == "" {
requestedVerifyModel = account.VerifyModel 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 { if err != nil {
return err return err
} }
}
validateAvailability := req.ValidateAvailability == nil || *req.ValidateAvailability 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 { if err != nil {
return err return err
} }
@@ -1116,7 +1130,7 @@ func (a AgentService) UpdateAccount(req dto.AgentAccountUpdateReq) error {
account.Remark = req.Remark account.Remark = req.Remark
account.Verified = true account.Verified = true
if err := global.DB.Save(account).Error; err != nil { if err := agentAccountRepo.Save(account); err != nil {
return err return err
} }
terminalai.InvalidateTerminalRuntimeCache() terminalai.InvalidateTerminalRuntimeCache()
@@ -1147,13 +1161,34 @@ func (a AgentService) PageAccounts(req dto.AgentAccountSearch) (int64, []dto.Age
if err != nil { if err != nil {
return 0, nil, err 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)) items := make([]dto.AgentAccountInfo, 0, len(list))
for _, item := range list { for _, item := range list {
apiKey := "" apiKey := ""
if item.RememberAPIKey { if item.RememberAPIKey {
apiKey = item.APIKey apiKey = item.APIKey
} }
appInstallID := uint(0)
if localInstalls[item.AppInstallID] {
appInstallID = item.AppInstallID
}
items = append(items, dto.AgentAccountInfo{ items = append(items, dto.AgentAccountInfo{
AppInstallID: appInstallID,
ID: item.ID, ID: item.ID,
MasterAccountID: item.MasterAccountID, MasterAccountID: item.MasterAccountID,
Provider: item.Provider, Provider: item.Provider,
@@ -1351,13 +1386,27 @@ func (a AgentService) SyncAgentsByAccount(account *model.AgentAccount) error {
} }
func (a AgentService) VerifyAccount(req dto.AgentAccountVerifyReq) 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 return err
} }
func (a AgentService) DeleteAccount(req dto.AgentAccountDeleteReq) error { func (a AgentService) DeleteAccount(req dto.AgentAccountDeleteReq) error {
agentAccountMutationMu.Lock() agentAccountMutationMu.Lock()
defer agentAccountMutationMu.Unlock() 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 { if err := global.DB.Transaction(func(tx *gorm.DB) error {
var agentCount int64 var agentCount int64
if err := tx.Model(&model.Agent{}).Where("account_id = ?", req.ID).Count(&agentCount).Error; err != nil { if err := tx.Model(&model.Agent{}).Where("account_id = ?", req.ID).Count(&agentCount).Error; err != nil {
+6 -2
View File
@@ -8,11 +8,15 @@ import (
) )
const hermesWeixinLoginScript = `import asyncio const hermesWeixinLoginScript = `import asyncio
from gateway.platforms.weixin import qr_login 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 from hermes_cli.config import save_env_value
creds = asyncio.run(qr_login('/opt/data')) creds = asyncio.run(qr_login('/opt/data'))
print('RESULT =', creds)
if not creds: if not creds:
raise SystemExit(1) raise SystemExit(1)
+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
}
}
+115
View File
@@ -0,0 +1,115 @@
package service
import (
"encoding/json"
"path/filepath"
"reflect"
"testing"
"github.com/1Panel-dev/1Panel/agent/app/dto"
"github.com/1Panel-dev/1Panel/agent/app/model"
providercatalog "github.com/1Panel-dev/1Panel/agent/app/provider"
)
func TestMiniMaxOpenclawModelMetadata(t *testing.T) {
for _, provider := range []string{"minimax", "custom", "bailian-coding-plan"} {
for _, protocol := range []string{"anthropic-messages", "openai-completions"} {
for _, id := range []string{"MiniMax-M3.1-Flash", "MiniMax-M3", "MiniMax-M2.7", "MiniMax-M2.5", "saved-custom"} {
t.Run(provider+"/"+protocol+"/"+id, func(t *testing.T) {
account := &model.AgentAccount{Provider: provider, APIType: protocol, BaseURL: "https://example.test/v1", APIKey: "test-key"}
_, entry, _, _, err := buildOpenclawAccountModelConfig(account, dto.AgentAccountModel{ID: id, Name: "Saved name"})
if err != nil {
t.Fatal(err)
}
payload, err := json.Marshal(entry)
if err != nil {
t.Fatal(err)
}
var actual modelEntry
if err := json.Unmarshal(payload, &actual); err != nil {
t.Fatal(err)
}
if actual.ID != id || actual.Name != "Saved name" {
t.Fatalf("changed saved identity: %s", payload)
}
if provider == "minimax" && id == "MiniMax-M3.1-Flash" {
if actual.ContextWindow != 1000000 || actual.MaxTokens != 524288 || !reflect.DeepEqual(actual.Input, []string{"text", "image"}) {
t.Fatalf("incorrect direct Flash metadata: %s", payload)
}
} else if actual.ContextWindow != 0 || actual.MaxTokens != 0 {
t.Fatalf("changed existing/gateway limits: %s", payload)
}
})
}
}
}
}
func TestMiniMaxPreservesSavedOpenclawMetadata(t *testing.T) {
current := &modelsConfig{Mode: "merge", Providers: map[string]modelProvider{
"minimax": {Models: []modelEntry{{ID: "MiniMax-M3.1-Flash", Input: []string{"text"}, ContextWindow: 32000, MaxTokens: 4096}}},
}}
conf := map[string]interface{}{}
if err := applyOpenclawModelsConfig(conf, current); err != nil {
t.Fatal(err)
}
next := &modelsConfig{Mode: "merge", Providers: map[string]modelProvider{
"minimax": {Models: []modelEntry{{ID: "MiniMax-M3.1-Flash", Input: []string{"text", "image"}, ContextWindow: 1000000, MaxTokens: 524288}}},
}}
preserveOpenclawModelMetadata(conf, next)
if !reflect.DeepEqual(next.Providers["minimax"].Models, current.Providers["minimax"].Models) {
t.Fatalf("saved metadata changed: %+v", next)
}
}
func TestMiniMaxDefaultAccountRequest(t *testing.T) {
for _, protocol := range []string{"anthropic-messages", "openai-completions"} {
account := &model.AgentAccount{Provider: "minimax", APIType: protocol}
models, err := buildInitialAgentAccountModels(account, nil)
if err != nil {
t.Fatal(err)
}
selected, err := resolveAgentAccountVerifyModel("minimax", "", models)
if err != nil || selected != "MiniMax-M3.1-Flash" || len(models) != 2 || models[1].ID != "MiniMax-M3" {
t.Fatalf("unexpected default: %s, %+v, %v", selected, models, err)
}
request := providercatalog.BuildVerifyRequest("minimax", protocol, "", "https://example.test", "test-key", selected)
var body map[string]interface{}
if err := json.Unmarshal(request.Body, &body); err != nil || body["model"] != selected {
t.Fatalf("default request: %s, %v", request.Body, err)
}
}
}
func TestMiniMaxHermesConfiguration(t *testing.T) {
for _, protocol := range []string{"anthropic-messages", "openai-completions"} {
for _, id := range []string{"MiniMax-M3.1-Flash", "MiniMax-M3", "MiniMax-M2.7", "my-custom-model"} {
dir := t.TempDir()
account := &model.AgentAccount{Provider: "minimax", APIType: protocol, BaseURL: "https://api.minimaxi.com/anthropic", APIKey: "test-key"}
if err := writeHermesConfig(dir, account, id, ""); err != nil {
t.Fatal(err)
}
conf, err := readHermesConfig(filepath.Join(dir, "config.yaml"))
if err != nil {
t.Fatal(err)
}
if conf.Model.Default != id || conf.Model.Provider != "minimax-cn" || conf.Model.BaseURL != account.BaseURL {
t.Fatalf("changed Hermes selection: %+v", conf.Model)
}
}
}
}
func TestMiniMaxExplicitAccountModels(t *testing.T) {
for _, provider := range []string{"minimax", "bailian-coding-plan", "custom"} {
account := &model.AgentAccount{Provider: provider, APIType: "openai-completions"}
requested := []dto.AgentAccountModel{{ID: "MiniMax-M2.5", Name: "Saved legacy"}, {ID: "my-custom-model", Name: "Saved custom"}}
actual, err := buildInitialAgentAccountModels(account, requested)
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(actual, requested) {
t.Fatalf("changed explicit %s models: %+v", provider, actual)
}
}
}
+69
View File
@@ -0,0 +1,69 @@
package service
import (
"net"
"regexp"
"strings"
"unicode"
"unicode/utf8"
"github.com/1Panel-dev/1Panel/agent/app/dto"
"github.com/1Panel-dev/1Panel/agent/buserr"
"github.com/1Panel-dev/1Panel/agent/constant"
)
var deepSeekUsernamePattern = regexp.MustCompile(`^[A-Za-z0-9._-]+$`)
var deepSeekHostLabelPattern = regexp.MustCompile(`^[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?$`)
func isDeepSeekAccessHost(value string) bool {
if ip := net.ParseIP(value); ip != nil {
return ip.To4() != nil && !strings.Contains(value, ":")
}
if value == "" || len(value) > 253 || strings.Trim(value, "0123456789.") == "" {
return false
}
for _, label := range strings.Split(value, ".") {
if !deepSeekHostLabelPattern.MatchString(label) {
return false
}
}
return true
}
func validateNativeAgentCreate(req dto.AgentCreateReq) error {
if req.AgentType != constant.AppDeepSeekHarness && req.AgentType != constant.AppOctop {
return nil
}
if strings.TrimSpace(req.DashboardUsername) == "" || strings.ContainsAny(req.DashboardUsername, "\r\n\x00") {
return buserr.WithDetail("ErrInvalidParams", "dashboardUsername", nil)
}
if strings.ContainsAny(req.DashboardPassword, "\r\n\x00") || req.DashboardPassword != strings.TrimSpace(req.DashboardPassword) {
return buserr.WithDetail("ErrInvalidParams", "dashboardPassword", nil)
}
if req.AgentType == constant.AppDeepSeekHarness {
if !isDeepSeekAccessHost(req.AccessHost) {
return buserr.WithDetail("ErrInvalidParams", "accessHost", nil)
}
if !deepSeekUsernamePattern.MatchString(req.DashboardUsername) {
return buserr.WithDetail("ErrInvalidParams", "dashboardUsername", nil)
}
if utf8.RuneCountInString(req.DashboardPassword) < 12 {
return buserr.WithDetail("ErrInvalidParams", "dashboardPassword", nil)
}
return nil
}
hasLetter, hasDigit := false, false
for _, char := range req.DashboardPassword {
hasLetter = hasLetter || unicode.IsLetter(char)
hasDigit = hasDigit || unicode.IsDigit(char)
}
if utf8.RuneCountInString(req.DashboardPassword) < 8 || !hasLetter || !hasDigit {
return buserr.WithDetail("ErrInvalidParams", "dashboardPassword", nil)
}
switch strings.ToLower(req.DashboardPassword) {
case "password", "password1", "password12", "password123", "12345678", "123456789", "qwerty123",
"admin123", "welcome1", "letmein1", "changeme1", "octop123", "abc12345", "iloveyou1":
return buserr.WithDetail("ErrInvalidParams", "dashboardPassword", nil)
}
return nil
}
+23 -4
View File
@@ -98,7 +98,7 @@ func ensureContainerRunning(containerName string) error {
return nil 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) resolvedAPIKey := strings.TrimSpace(apiKey)
resolvedAPIType := strings.TrimSpace(apiType) resolvedAPIType := strings.TrimSpace(apiType)
resolvedAuthMode, err := providercatalog.ResolveAuthMode(provider, resolvedAPIType, authMode) resolvedAuthMode, err := providercatalog.ResolveAuthMode(provider, resolvedAPIType, authMode)
@@ -113,7 +113,7 @@ func resolveAgentAccountInput(provider, apiType, authMode, apiKey, baseURL, mode
return resolvedAgentAccountInput{}, err return resolvedAgentAccountInput{}, err
} }
modelID = strings.TrimSpace(modelID) modelID = strings.TrimSpace(modelID)
if modelID == "" { if modelID == "" && !allowEmptyModel {
return resolvedAgentAccountInput{}, buserr.New("ErrAgentAccountModelsRequired") return resolvedAgentAccountInput{}, buserr.New("ErrAgentAccountModelsRequired")
} }
imageAPI := providercatalog.IsImageAPIType(resolvedAPIType) imageAPI := providercatalog.IsImageAPIType(resolvedAPIType)
@@ -415,6 +415,15 @@ func buildAgentItem(agent *model.Agent, appInstall *model.AppInstall, envMap map
} else { } else {
item.WebUIPort = appInstall.HttpPort item.WebUIPort = appInstall.HttpPort
} }
if agentType == constant.AppDeepSeekHarness {
item.WebUIPort = appInstall.HttpsPort
if values, err := readAgentEnvMap(appInstall.GetEnvPath()); err == nil {
accessHost := values["HTTPS_ACCESS_HOST"]
if isDeepSeekAccessHost(accessHost) {
item.AccessHost = accessHost
}
}
}
item.Path = appInstall.GetPath() item.Path = appInstall.GetPath()
item.Status = appInstall.Status item.Status = appInstall.Status
item.Message = appInstall.Message item.Message = appInstall.Message
@@ -442,7 +451,8 @@ func localizedAgentProviderName(provider string) string {
} }
func isAgentAppKey(appKey string) bool { func isAgentAppKey(appKey string) bool {
return appKey == constant.AppOpenclaw || appKey == constant.AppCopaw || appKey == constant.AppHermesAgent return appKey == constant.AppOpenclaw || appKey == constant.AppCopaw || appKey == constant.AppHermesAgent ||
appKey == constant.AppDeepSeekHarness || appKey == constant.AppOctop
} }
func isOpenclawLegacyHTTPVersion(version string) bool { func isOpenclawLegacyHTTPVersion(version string) bool {
@@ -1253,7 +1263,12 @@ func buildOpenclawAccountModelConfig(account *model.AgentAccount, model dto.Agen
if err != nil { if err != nil {
return "", modelEntry{}, "", modelProvider{}, err return "", modelEntry{}, "", modelProvider{}, err
} }
return providerPatch.PrimaryModel, buildOpenclawModelEntry(providerPatch.ModelID, model), providerPatch.ProviderKey, modelProvider{ entry := buildOpenclawModelEntry(providerPatch.ModelID, model)
if account.Provider == "minimax" && providerPatch.ModelID == "MiniMax-M3.1-Flash" {
entry.ContextWindow = 1000000
entry.MaxTokens = 524288
}
return providerPatch.PrimaryModel, entry, providerPatch.ProviderKey, modelProvider{
ApiKey: providerPatch.APIKey, ApiKey: providerPatch.APIKey,
BaseUrl: providerPatch.BaseURL, BaseUrl: providerPatch.BaseURL,
Api: providerPatch.APIType, Api: providerPatch.APIType,
@@ -1641,6 +1656,10 @@ func agentDashboardAuthEnvKeys(agentType string) (string, string, bool) {
return hermesDashboardUsernameEnvKey, hermesDashboardPasswordEnvKey, true return hermesDashboardUsernameEnvKey, hermesDashboardPasswordEnvKey, true
case constant.AppCopaw: case constant.AppCopaw:
return qwenPawAuthUsernameEnvKey, qwenPawAuthPasswordEnvKey, true return qwenPawAuthUsernameEnvKey, qwenPawAuthPasswordEnvKey, true
case constant.AppDeepSeekHarness:
return "DSH_AUTH_USERNAME", "DSH_AUTH_PASSWORD", true
case constant.AppOctop:
return "OCTOP_ADMIN_USERNAME", "OCTOP_DEFAULT_PASSWORD", true
default: default:
return "", "", false return "", "", false
} }
+107 -47
View File
@@ -8,6 +8,7 @@ import (
"os/exec" "os/exec"
"path" "path"
"strings" "strings"
"sync"
"syscall" "syscall"
"time" "time"
@@ -28,13 +29,15 @@ import (
type AIToolService struct{} type AIToolService struct{}
var ollamaModelMu sync.Mutex
type IAIToolService interface { type IAIToolService interface {
Search(search dto.SearchWithPage) (int64, []dto.OllamaModelInfo, error) Search(search dto.SearchWithPage) (int64, []dto.OllamaModelInfo, error)
Create(req dto.OllamaModelName) error Create(req dto.OllamaModelName) error
Close(name string) error Close(name string) error
Recreate(req dto.OllamaModelName) error Recreate(req dto.OllamaModelName) error
Delete(req dto.ForceDelete) error Delete(req dto.ForceDelete) error
Sync() ([]dto.OllamaModelDropList, error) SyncAccount(req dto.OllamaModelAccountSync) error
LoadDetail(name string) (string, error) LoadDetail(name string) (string, error)
BindDomain(req dto.OllamaBindDomain) error BindDomain(req dto.OllamaBindDomain) error
GetBindDomain(req dto.OllamaBindDomainReq) (*dto.OllamaBindDomainRes, 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) { func (u *AIToolService) Search(req dto.SearchWithPage) (int64, []dto.OllamaModelInfo, error) {
syncErr := syncOllamaModels()
var options []repo.DBOption var options []repo.DBOption
if len(req.Info) != 0 { if len(req.Info) != 0 {
options = append(options, repo.WithByLikeName(req.Info)) options = append(options, repo.WithByLikeName(req.Info))
@@ -60,6 +64,11 @@ func (u *AIToolService) Search(req dto.SearchWithPage) (int64, []dto.OllamaModel
if err := copier.Copy(&item, &itemModel); err != nil { if err := copier.Copy(&item, &itemModel); err != nil {
return 0, nil, buserr.WithDetail("ErrStructTransform", err.Error(), nil) return 0, nil, buserr.WithDetail("ErrStructTransform", err.Error(), nil)
} }
if syncErr != nil && item.Status != constant.StatusWaiting {
item.Status = constant.StatusUnknown
item.Message = syncErr.Error()
item.Size = ""
}
taskModel, _ := taskRepo.GetFirst(taskRepo.WithResourceID(item.ID), repo.WithByType(task.TaskScopeAI)) taskModel, _ := taskRepo.GetFirst(taskRepo.WithResourceID(item.ID), repo.WithByType(task.TaskScopeAI))
if len(taskModel.ID) != 0 { if len(taskModel.ID) != 0 {
item.LogFileExist = true item.LogFileExist = true
@@ -85,6 +94,8 @@ func (u *AIToolService) LoadDetail(name string) (string, error) {
} }
func (u *AIToolService) Create(req dto.OllamaModelName) error { func (u *AIToolService) Create(req dto.OllamaModelName) error {
ollamaModelMu.Lock()
defer ollamaModelMu.Unlock()
if cmd.CheckIllegal(req.Name) { if cmd.CheckIllegal(req.Name) {
return buserr.New("ErrCmdIllegal") return buserr.New("ErrCmdIllegal")
} }
@@ -125,6 +136,9 @@ func (u *AIToolService) Create(req dto.OllamaModelName) error {
if err := taskItem.Execute(); err != nil { if err := taskItem.Execute(); err != nil {
_ = aiRepo.Update(info.ID, map[string]interface{}{"status": constant.StatusFailed, "message": err.Error()}) _ = 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 return nil
} }
@@ -144,6 +158,8 @@ func (u *AIToolService) Close(name string) error {
} }
func (u *AIToolService) Recreate(req dto.OllamaModelName) error { func (u *AIToolService) Recreate(req dto.OllamaModelName) error {
ollamaModelMu.Lock()
defer ollamaModelMu.Unlock()
if cmd.CheckIllegal(req.Name) { if cmd.CheckIllegal(req.Name) {
return buserr.New("ErrCmdIllegal") return buserr.New("ErrCmdIllegal")
} }
@@ -179,11 +195,16 @@ func (u *AIToolService) Recreate(req dto.OllamaModelName) error {
if err := taskItem.Execute(); err != nil { if err := taskItem.Execute(); err != nil {
_ = aiRepo.Update(modelInfo.ID, map[string]interface{}{"status": constant.StatusFailed, "message": err.Error()}) _ = 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 return nil
} }
func (u *AIToolService) Delete(req dto.ForceDelete) error { func (u *AIToolService) Delete(req dto.ForceDelete) error {
ollamaModelMu.Lock()
defer ollamaModelMu.Unlock()
ollamaList, _ := aiRepo.List(repo.WithByIDs(req.IDs)) ollamaList, _ := aiRepo.List(repo.WithByIDs(req.IDs))
if len(ollamaList) == 0 { if len(ollamaList) == 0 {
return buserr.New("ErrRecordNotFound") return buserr.New("ErrRecordNotFound")
@@ -202,55 +223,28 @@ func (u *AIToolService) Delete(req dto.ForceDelete) error {
logItem := path.Join(global.Dir.DataDir, "log", "AITools", item.Name) logItem := path.Join(global.Dir.DataDir, "log", "AITools", item.Name)
_ = os.Remove(logItem) _ = os.Remove(logItem)
} }
return nil return SyncOllamaModelAccount(false)
} }
func (u *AIToolService) Sync() ([]dto.OllamaModelDropList, error) { func (u *AIToolService) SyncAccount(req dto.OllamaModelAccountSync) error {
containerName, err := LoadContainerName() install, err := appInstallRepo.GetFirst(repo.WithByID(req.ID))
if err != nil { 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 { if err != nil {
return nil, err return err
} }
var list []model.OllamaModel if err := syncOllamaModelAccount(install, baseURL, true, true); err != nil {
lines := strings.Split(stdout, "\n") return err
for _, line := range lines {
parts := strings.Fields(line)
if len(parts) < 5 {
continue
} }
if parts[0] == "NAME" { return appInstallRepo.BatchUpdateBy(map[string]interface{}{
continue "model_account_base_url_type": req.ModelAccountBaseURLType,
} "model_account_base_url": baseURL,
list = append(list, model.OllamaModel{Name: parts[0], Size: parts[2] + " " + parts[3]}) }, repo.WithByID(install.ID))
}
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
} }
func (u *AIToolService) BindDomain(req dto.OllamaBindDomain) error { func (u *AIToolService) BindDomain(req dto.OllamaBindDomain) error {
@@ -370,6 +364,72 @@ func (u *AIToolService) UpdateBindDomain(req dto.OllamaBindDomain) error {
return nil return nil
} }
func syncOllamaModels() error {
ollamaModelMu.Lock()
defer ollamaModelMu.Unlock()
containerName, err := LoadContainerName()
if err != nil {
return err
}
stdout, err := cmd.RunDockerExecWithStdout(20*time.Second, containerName, "ollama", "list")
if err != nil {
return err
}
models := make(map[string]string)
lines := strings.Split(strings.TrimSpace(stdout), "\n")
if len(lines) == 0 || !strings.HasPrefix(strings.TrimSpace(lines[0]), "NAME") {
return fmt.Errorf("invalid ollama list output: %s", stdout)
}
for _, line := range lines[1:] {
if strings.TrimSpace(line) == "" {
continue
}
parts := strings.Fields(line)
if len(parts) < 5 {
return fmt.Errorf("invalid ollama model entry: %s", line)
}
models[parts[0]] = parts[2] + " " + parts[3]
}
list, err := aiRepo.List()
if err != nil {
return err
}
known := make(map[string]bool, len(list))
for _, item := range list {
name := item.Name
if !strings.Contains(path.Base(name), ":") {
name += ":latest"
}
size, exists := models[name]
known[name] = true
if item.Status == constant.StatusWaiting {
continue
}
status, message := constant.StatusSuccess, ""
if !exists {
if item.Status == constant.StatusFailed || item.Status == constant.StatusCanceled {
continue
}
status, message = constant.StatusDeleted, "not exist"
}
if item.Status != status || item.Size != size || item.Message != message {
if err := aiRepo.Update(item.ID, map[string]interface{}{"status": status, "size": size, "message": message}); err != nil {
return err
}
}
}
for name, size := range models {
if known[name] {
continue
}
item := model.OllamaModel{Name: name, Size: size, From: "remote", Status: constant.StatusSuccess}
if err := aiRepo.Create(&item); err != nil {
return err
}
}
return nil
}
func LoadContainerName() (string, error) { func LoadContainerName() (string, error) {
ollamaBaseInfo, err := appInstallRepo.LoadBaseInfo("ollama", "") ollamaBaseInfo, err := appInstallRepo.LoadBaseInfo("ollama", "")
if err != nil { if err != nil {
@@ -387,12 +447,12 @@ func loadModelSize(name string, containerName string) (string, error) {
return "", err return "", err
} }
lines := strings.Split(stdout, "\n") lines := strings.Split(stdout, "\n")
for _, line := range lines { if !strings.Contains(path.Base(name), ":") {
if !strings.Contains(line, name) { name += ":latest"
continue
} }
for _, line := range lines {
parts := strings.Fields(line) parts := strings.Fields(line)
if len(parts) < 5 { if len(parts) < 5 || parts[0] != name {
continue continue
} }
return parts[2] + " " + parts[3], nil return parts[2] + " " + parts[3], nil
+209
View File
@@ -0,0 +1,209 @@
package service
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"time"
"github.com/1Panel-dev/1Panel/agent/app/dto"
"github.com/1Panel-dev/1Panel/agent/utils/docker"
"github.com/mattn/go-shellwords"
)
func AIBenchmarkSources(kind, selected string) ([]dto.AIBenchmarkSource, error) {
if kind != "vllm" && kind != "ollama" {
return nil, errors.New("unsupported benchmark source")
}
selectedID := strings.SplitN(selected, ":", 2)[0]
type instance struct {
id uint
name, container string
port int
}
instances := []instance{}
apps, err := appRepo.GetBy(appRepo.WithKey(kind))
if err != nil {
return nil, err
}
for _, app := range apps {
installs, err := appInstallRepo.ListBy(context.Background(), appInstallRepo.WithAppId(app.ID))
if err != nil {
return nil, err
}
for _, install := range installs {
if selected != "" && strconv.FormatUint(uint64(install.ID), 10) != selectedID {
continue
}
instances = append(instances, instance{id: install.ID, name: install.Name, container: install.ContainerName, port: install.HttpPort})
}
}
results := []dto.AIBenchmarkSource{}
if len(instances) == 0 {
return results, nil
}
client, err := docker.NewDockerClient()
if err != nil {
return nil, err
}
defer client.Close()
if !strings.HasPrefix(client.DaemonHost(), "unix://") {
return nil, errors.New("benchmark shortcuts require a Docker daemon on the main node; enter the remote service address manually")
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
for _, instance := range instances {
base := dto.AIBenchmarkSource{Kind: kind, Name: instance.name, Endpoint: "/v1/chat/completions", Models: []string{}, Status: "unknown"}
inspected, err := client.ContainerInspect(ctx, instance.container)
if err != nil {
base.ID = strconv.FormatUint(uint64(instance.id), 10)
base.Message = "container inspection unavailable"
results = append(results, base)
continue
}
if inspected.State != nil {
base.Status = inspected.State.Status
}
args := []string{}
if inspected.Config != nil {
args = append(args, inspected.Config.Entrypoint...)
args = append(args, inspected.Config.Cmd...)
}
if len(args) >= 3 && (args[0] == "sh" || args[0] == "bash" || strings.HasSuffix(args[0], "/sh") || strings.HasSuffix(args[0], "/bash")) && args[1] == "-c" {
parser := shellwords.NewParser()
parser.ParseEnv = false
parser.ParseBacktick = false
if parsed, err := parser.Parse(args[2]); err == nil {
args = parsed
}
}
flags := map[string]string{}
for i, arg := range args {
if key, value, ok := strings.Cut(arg, "="); ok && strings.HasPrefix(key, "--") {
flags[key] = value
} else if strings.HasPrefix(arg, "--") && i+1 < len(args) && !strings.HasPrefix(args[i+1], "--") {
flags[arg] = args[i+1]
}
}
if name := flags["--served-model-name"]; name != "" {
base.Models = []string{name}
}
tokenizer := flags["--tokenizer"]
if tokenizer == "" {
tokenizer = flags["--model"]
for i, arg := range args {
if tokenizer == "" && arg == "serve" && i+1 < len(args) && !strings.HasPrefix(args[i+1], "-") {
tokenizer = args[i+1]
}
}
}
if kind != "ollama" && tokenizer != "" {
if !filepath.IsAbs(tokenizer) {
base.Tokenizer = tokenizer
} else {
longest := 0
for _, mount := range inspected.Mounts {
relative, err := filepath.Rel(mount.Destination, tokenizer)
if err == nil && relative != ".." && !strings.HasPrefix(relative, ".."+string(filepath.Separator)) && len(mount.Destination) > longest {
candidate := filepath.Join(mount.Source, relative)
if info, err := os.Stat(candidate); err == nil && info.IsDir() {
base.Tokenizer = candidate
longest = len(mount.Destination)
}
}
}
}
}
ports := map[int]string{}
if instance.port > 0 {
ports[instance.port] = "127.0.0.1"
}
if inspected.NetworkSettings != nil {
for _, bindings := range inspected.NetworkSettings.Ports {
for _, binding := range bindings {
port, _ := strconv.Atoi(binding.HostPort)
if port > 0 {
host := binding.HostIP
if host == "" || host == "0.0.0.0" || host == "::" {
host = "127.0.0.1"
}
ports[port] = host
}
}
}
}
if len(ports) == 0 && inspected.HostConfig != nil && inspected.HostConfig.NetworkMode == "host" {
port, _ := strconv.Atoi(flags["--port"])
if port > 0 {
ports[port] = "127.0.0.1"
}
}
ordered := []int{}
for port := range ports {
ordered = append(ordered, port)
}
sort.Ints(ordered)
if len(ordered) == 0 {
ordered = append(ordered, 0)
}
for _, port := range ordered {
source := base
source.ID = fmt.Sprintf("%d:%d", instance.id, port)
if selected != "" && source.ID != selected {
continue
}
if port > 0 {
source.BaseURL = "http://" + net.JoinHostPort(ports[port], strconv.Itoa(port))
}
if selected == source.ID && source.BaseURL != "" && source.Status == "running" {
endpoint := "/v1/models"
if kind == "ollama" {
endpoint = "/api/tags"
}
request, err := http.NewRequestWithContext(ctx, http.MethodGet, source.BaseURL+endpoint, nil)
if err != nil {
return nil, err
}
response, err := (&http.Client{Timeout: 3 * time.Second, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}).Do(request)
if err != nil {
source.Message = "model list unavailable; enter the served model name"
} else {
raw, readErr := io.ReadAll(io.LimitReader(response.Body, 2*1024*1024))
response.Body.Close()
if response.StatusCode == http.StatusOK && readErr == nil {
var data struct {
Data []struct {
ID string `json:"id"`
} `json:"data"`
Models []struct {
Name string `json:"name"`
} `json:"models"`
}
if err := json.Unmarshal(raw, &data); err == nil {
source.Models = []string{}
for _, model := range data.Data {
source.Models = append(source.Models, model.ID)
}
for _, model := range data.Models {
source.Models = append(source.Models, model.Name)
}
}
} else {
source.Message = fmt.Sprintf("model list HTTP %d; provide model name and credentials if required", response.StatusCode)
}
}
}
results = append(results, source)
}
}
return results, nil
}
+43 -2
View File
@@ -258,13 +258,22 @@ func (a AppService) GetAppDetail(appID uint, version, appType string) (response.
fileOp := files.NewFileOp() fileOp := files.NewFileOp()
versionPath := filepath.Join(app.GetAppResourcePath(), detail.Version) versionPath := filepath.Join(app.GetAppResourcePath(), detail.Version)
if !fileOp.Stat(versionPath) || detail.Update {
if err = downloadApp(app, detail, nil, nil); err != nil && !fileOp.Stat(versionPath) { if err = downloadApp(app, detail, nil, nil); err != nil && !fileOp.Stat(versionPath) {
return appDetailDTO, err return appDetailDTO, err
} }
if err == nil && app.Resource == constant.AppResourceRemote {
detail.Update = false
appDetailDTO.Update = false
} }
switch app.Type { switch app.Type {
case constant.RuntimePHP: case constant.RuntimePHP:
paramMap := make(map[string]interface{})
if app.Resource == constant.AppResourceRemote {
if err = json.Unmarshal([]byte(detail.Params), &paramMap); err != nil {
return appDetailDTO, err
}
appDetailDTO.Params = paramMap
} else {
paramsPath := filepath.Join(versionPath, "data.yml") paramsPath := filepath.Join(versionPath, "data.yml")
if !fileOp.Stat(paramsPath) { if !fileOp.Stat(paramsPath) {
return appDetailDTO, buserr.WithDetail("ErrFileNotExist", paramsPath, nil) return appDetailDTO, buserr.WithDetail("ErrFileNotExist", paramsPath, nil)
@@ -273,11 +282,11 @@ func (a AppService) GetAppDetail(appID uint, version, appType string) (response.
if err != nil { if err != nil {
return appDetailDTO, err return appDetailDTO, err
} }
paramMap := make(map[string]interface{})
if err = yaml.Unmarshal(param, &paramMap); err != nil { if err = yaml.Unmarshal(param, &paramMap); err != nil {
return appDetailDTO, err return appDetailDTO, err
} }
appDetailDTO.Params = paramMap["additionalProperties"] appDetailDTO.Params = paramMap["additionalProperties"]
}
composePath := filepath.Join(versionPath, "docker-compose.yml") composePath := filepath.Join(versionPath, "docker-compose.yml")
if !fileOp.Stat(composePath) { if !fileOp.Stat(composePath) {
return appDetailDTO, buserr.WithDetail("ErrFileNotExist", composePath, nil) return appDetailDTO, buserr.WithDetail("ErrFileNotExist", composePath, nil)
@@ -308,6 +317,12 @@ func (a AppService) GetAppDetail(appID uint, version, appType string) (response.
if appDetailDTO.DockerCompose == "" { if appDetailDTO.DockerCompose == "" {
filename := filepath.Base(appDetailDTO.DownloadUrl) filename := filepath.Base(appDetailDTO.DownloadUrl)
dockerComposeUrl := fmt.Sprintf("%s%s", strings.TrimSuffix(appDetailDTO.DownloadUrl, filename), "docker-compose.yml") dockerComposeUrl := fmt.Sprintf("%s%s", strings.TrimSuffix(appDetailDTO.DownloadUrl, filename), "docker-compose.yml")
if app.Resource == constant.AppResourceRemote {
dockerComposeUrl, err = currentAppDownloadURL(dockerComposeUrl)
if err != nil {
return appDetailDTO, err
}
}
statusCode, composeRes, err := req_helper.HandleRequest(dockerComposeUrl, http.MethodGet, constant.TimeOut20s) statusCode, composeRes, err := req_helper.HandleRequest(dockerComposeUrl, http.MethodGet, constant.TimeOut20s)
if statusCode == http.StatusNotFound { if statusCode == http.StatusNotFound {
return appDetailDTO, buserr.New("ErrAppVersionUnavailable") return appDetailDTO, buserr.New("ErrAppVersionUnavailable")
@@ -434,6 +449,12 @@ func (a AppService) installWithHooks(req request.AppInstallCreate, executeScript
} else { } else {
if appDetail.DockerCompose == "" { if appDetail.DockerCompose == "" {
dockerComposeUrl := fmt.Sprintf("%s/%s/1panel/%s/%s/docker-compose.yml", global.AppRepoURL(), global.CONF.Base.Mode, app.Key, appDetail.Version) dockerComposeUrl := fmt.Sprintf("%s/%s/1panel/%s/%s/docker-compose.yml", global.AppRepoURL(), global.CONF.Base.Mode, app.Key, appDetail.Version)
if app.Resource == constant.AppResourceRemote {
dockerComposeUrl, err = currentAppDownloadURL(dockerComposeUrl)
if err != nil {
return
}
}
var statusCode int var statusCode int
statusCode, composeRes, err = req_helper.HandleRequest(dockerComposeUrl, http.MethodGet, constant.TimeOut20s) statusCode, composeRes, err = req_helper.HandleRequest(dockerComposeUrl, http.MethodGet, constant.TimeOut20s)
if statusCode == http.StatusNotFound { if statusCode == http.StatusNotFound {
@@ -478,6 +499,22 @@ func (a AppService) installWithHooks(req request.AppInstallCreate, executeScript
req.Params[constant.ContainerName] = containerName req.Params[constant.ContainerName] = containerName
appInstall.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 index := 0
serviceName := "" serviceName := ""
for k := range servicesMap { for k := range servicesMap {
@@ -588,6 +625,10 @@ func (a AppService) installWithHooks(req request.AppInstallCreate, executeScript
return err return err
} }
updateToolApp(appInstall) updateToolApp(appInstall)
if app.Key == "ollama" && appInstall.SyncModelAccount {
_, err := SyncLocalModelAccount(*appInstall, 0, modelAccountBaseURL, nil, false, true)
return err
}
return nil return nil
} }
+55
View File
@@ -159,6 +159,14 @@ func (a *AppInstallService) CheckExist(req request.AppInstalledInfo) (*response.
} }
res.ContainerName = appInstall.ContainerName 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.Name = appInstall.Name
res.Version = appInstall.Version res.Version = appInstall.Version
res.CreatedAt = appInstall.CreatedAt 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 backupDockerCompose := installed.DockerCompose
if req.Advanced { if req.Advanced {
composeMap := make(map[string]interface{}) composeMap := make(map[string]interface{})
@@ -563,6 +597,9 @@ func (a *AppInstallService) Update(req request.AppInstalledUpdate) error {
if proxyChanged && website.ID != 0 && currentProxyErr != nil { if proxyChanged && website.ID != 0 && currentProxyErr != nil {
global.LOG.Error(buserr.WithErr("ErrUpdateBuWebsite", currentProxyErr).Error()) global.LOG.Error(buserr.WithErr("ErrUpdateBuWebsite", currentProxyErr).Error())
} }
if installed.App.Key == "ollama" && installed.SyncModelAccount {
return syncOllamaModelAccount(installed, modelAccountBaseURL, true, true)
}
return nil return nil
} }
@@ -707,6 +744,12 @@ func (a *AppInstallService) GetUpdateVersions(req request.AppUpdateVersion) ([]d
if req.UpdateVersion != "" && req.UpdateVersion == detail.Version && detail.DockerCompose == "" && !app.IsLocalApp() { if req.UpdateVersion != "" && req.UpdateVersion == detail.Version && detail.DockerCompose == "" && !app.IsLocalApp() {
filename := filepath.Base(detail.DownloadUrl) filename := filepath.Base(detail.DownloadUrl)
dockerComposeUrl := fmt.Sprintf("%s%s", strings.TrimSuffix(detail.DownloadUrl, filename), "docker-compose.yml") dockerComposeUrl := fmt.Sprintf("%s%s", strings.TrimSuffix(detail.DownloadUrl, filename), "docker-compose.yml")
if app.Resource == constant.AppResourceRemote {
dockerComposeUrl, err = currentAppDownloadURL(dockerComposeUrl)
if err != nil {
return versions, err
}
}
statusCode, composeRes, err := req_helper.HandleRequest(dockerComposeUrl, http.MethodGet, constant.TimeOut20s) statusCode, composeRes, err := req_helper.HandleRequest(dockerComposeUrl, http.MethodGet, constant.TimeOut20s)
if statusCode == http.StatusNotFound { if statusCode == http.StatusNotFound {
return versions, buserr.New("ErrAppVersionUnavailable") return versions, buserr.New("ErrAppVersionUnavailable")
@@ -928,6 +971,18 @@ func (a *AppInstallService) GetParams(id uint) (*response.AppConfig, error) {
config := getAppCommonConfig(envs) config := getAppCommonConfig(envs)
config.DockerCompose = install.DockerCompose config.DockerCompose = install.DockerCompose
res.Params = params 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 == "" { if config.ContainerName == "" {
config.ContainerName = install.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
})
}
+5 -1
View File
@@ -191,7 +191,11 @@ func (c *appSyncContext) processOneApp(item appWorkItem) appWorkResult {
if _, ok := InitTypes[app.Type]; ok { if _, ok := InitTypes[app.Type]; ok {
dockerComposeUrl := fmt.Sprintf("%s/%s", versionUrl, "docker-compose.yml") dockerComposeUrl := fmt.Sprintf("%s/%s", versionUrl, "docker-compose.yml")
_, composeRes, err := req_helper.HandleRequestWithClient(&c.httpClient, dockerComposeUrl, http.MethodGet, constant.TimeOut20s) dockerComposeUrl, err := appDownloadURL(dockerComposeUrl, strconv.Itoa(c.list.LastModified))
var composeRes []byte
if err == nil {
_, composeRes, err = req_helper.HandleRequestWithClient(&c.httpClient, dockerComposeUrl, http.MethodGet, constant.TimeOut20s)
}
if err == nil { if err == nil {
detail.DockerCompose = string(composeRes) detail.DockerCompose = string(composeRes)
} else { } else {
+68 -14
View File
@@ -8,6 +8,7 @@ import (
"maps" "maps"
"math" "math"
"net/http" "net/http"
"net/url"
"os" "os"
"path" "path"
"path/filepath" "path/filepath"
@@ -390,12 +391,27 @@ func deleteAppInstall(deleteReq request.AppInstallDelete) error {
} }
} }
} }
if install.App.Key == "ollama" {
ollamaModelMu.Lock()
defer ollamaModelMu.Unlock()
}
tx, ctx := helper.GetTxAndContext() tx, ctx := helper.GetTxAndContext()
defer tx.Rollback() defer tx.Rollback()
if err = appInstallRepo.Delete(ctx, install); err != nil { if err = appInstallRepo.Delete(ctx, install); err != nil {
return err return err
} }
appKey := install.App.Key appKey := install.App.Key
if appKey == "ollama" {
remaining, listErr := appInstallRepo.ListBy(ctx, appInstallRepo.WithAppId(install.AppId))
if listErr != nil {
return listErr
}
if len(remaining) == 0 {
if err = aiRepo.DeleteAll(ctx); err != nil {
return err
}
}
}
if isAgentAppKey(appKey) { if isAgentAppKey(appKey) {
_ = agentRepo.DeleteByAppInstallIDWithCtx(ctx, install.ID) _ = agentRepo.DeleteByAppInstallIDWithCtx(ctx, install.ID)
} }
@@ -468,7 +484,12 @@ func deleteAppInstall(deleteReq request.AppInstallDelete) error {
if err == nil && len(entries) == 0 { if err == nil && len(entries) == 0 {
_ = op.DeleteDir(parentDir) _ = 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)) existApps, _ := appInstallRepo.ListBy(context.Background(), appInstallRepo.WithAppId(install.AppId))
if len(existApps) == 0 { if len(existApps) == 0 {
@@ -891,16 +912,52 @@ func handleMap(params map[string]interface{}, envParams map[string]string) {
} }
} }
func appDownloadURL(rawURL, version string) (string, error) {
parsedURL, err := url.Parse(rawURL)
if err != nil {
return "", err
}
query := parsedURL.Query()
query.Set("v", version)
parsedURL.RawQuery = query.Encode()
return parsedURL.String(), nil
}
func currentAppDownloadURL(rawURL string) (string, error) {
storeVersion, err := settingRepo.GetValueByKey("AppStoreLastModified")
if err != nil {
return "", err
}
return appDownloadURL(rawURL, storeVersion)
}
func downloadApp(app model.App, appDetail model.AppDetail, appInstall *model.AppInstall, logger *logrus.Logger) (err error) { func downloadApp(app model.App, appDetail model.AppDetail, appInstall *model.AppInstall, logger *logrus.Logger) (err error) {
if app.IsLocalApp() || app.IsCustomApp() { if app.IsLocalApp() || app.IsCustomApp() {
return nil return nil
} }
defer func() {
if err != nil && appInstall != nil {
appInstall.Status = constant.StatusDownloadErr
appInstall.Message = err.Error()
}
}()
appResourceDir := path.Join(global.Dir.AppResourceDir, app.Resource) appResourceDir := path.Join(global.Dir.AppResourceDir, app.Resource)
appDownloadDir := app.GetAppResourcePath() appDownloadDir := app.GetAppResourcePath()
appVersionDir := path.Join(appDownloadDir, appDetail.Version) appVersionDir := path.Join(appDownloadDir, appDetail.Version)
fileOp := files.NewFileOp() fileOp := files.NewFileOp()
storeVersion, err := settingRepo.GetValueByKey("AppStoreLastModified")
if err != nil {
return err
}
cacheVersionPath := path.Join(appVersionDir, ".1panel-appstore-version")
if !appDetail.Update && fileOp.Stat(appVersionDir) { if !appDetail.Update && fileOp.Stat(appVersionDir) {
return if cachedVersion, readErr := os.ReadFile(cacheVersionPath); readErr == nil && string(cachedVersion) == storeVersion {
return nil
}
}
downloadURL, err := appDownloadURL(appDetail.DownloadUrl, storeVersion)
if err != nil {
return err
} }
if !fileOp.Stat(appDownloadDir) { if !fileOp.Stat(appDownloadDir) {
_ = fileOp.CreateDir(appDownloadDir, constant.DirPerm) _ = fileOp.CreateDir(appDownloadDir, constant.DirPerm)
@@ -909,23 +966,14 @@ func downloadApp(app model.App, appDetail model.AppDetail, appInstall *model.App
_ = fileOp.CreateDir(appVersionDir, constant.DirPerm) _ = fileOp.CreateDir(appVersionDir, constant.DirPerm)
} }
if logger == nil { if logger == nil {
global.LOG.Infof("download app [%s] from %s", app.Name, appDetail.DownloadUrl) global.LOG.Infof("download app [%s] from %s", app.Name, downloadURL)
} else { } else {
logger.Printf("download app [%s] from %s", app.Name, appDetail.DownloadUrl) logger.Printf("download app [%s] from %s", app.Name, downloadURL)
} }
filePath := path.Join(appVersionDir, app.Key+"-"+appDetail.Version+".tar.gz") filePath := path.Join(appVersionDir, app.Key+"-"+appDetail.Version+".tar.gz")
defer func() { if err = files.DownloadFileWithProxy(downloadURL, filePath); err != nil {
if err != nil {
if appInstall != nil {
appInstall.Status = constant.StatusDownloadErr
appInstall.Message = err.Error()
}
}
}()
if err = files.DownloadFileWithProxy(appDetail.DownloadUrl, filePath); err != nil {
if logger == nil { if logger == nil {
global.LOG.Errorf("download app [%s] error %v", app.Name, err) global.LOG.Errorf("download app [%s] error %v", app.Name, err)
} else { } else {
@@ -942,6 +990,9 @@ func downloadApp(app model.App, appDetail model.AppDetail, appInstall *model.App
return return
} }
_ = fileOp.DeleteFile(filePath) _ = fileOp.DeleteFile(filePath)
if err = os.WriteFile(cacheVersionPath, []byte(storeVersion), constant.FilePerm); err != nil {
return err
}
appDetail.Update = false appDetail.Update = false
_ = appDetailRepo.Update(context.Background(), appDetail) _ = appDetailRepo.Update(context.Background(), appDetail)
return return
@@ -1984,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 { 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 app.Key == "openresty" && (app.Resource == "remote" || app.Resource == "custom") && common.CompareVersion(appDetail.Version, "1.27") {
if req.MCPPreserveWebsiteDir {
return prepareMCPWebsiteDirectory(req)
}
if dir, ok := req.Params["WEBSITE_DIR"]; ok { if dir, ok := req.Params["WEBSITE_DIR"]; ok {
siteDir := dir.(string) siteDir := dir.(string)
if siteDir == "" || !strings.HasPrefix(siteDir, "/") { if siteDir == "" || !strings.HasPrefix(siteDir, "/") {
+50 -22
View File
@@ -58,10 +58,10 @@ type ContainerService struct{}
var containerLogAnsiRegex = regexp.MustCompile("\x1b\\[[0-9;?]*[A-Za-z]|\x1b=|\x1b>") var containerLogAnsiRegex = regexp.MustCompile("\x1b\\[[0-9;?]*[A-Za-z]|\x1b=|\x1b>")
type IContainerService interface { type IContainerService interface {
Page(req dto.PageContainer) (int64, interface{}, error) Page(ctx context.Context, req dto.PageContainer) (int64, interface{}, error)
List() []dto.ContainerOptions List() []dto.ContainerOptions
ListByImage(imageName string) []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) PageNetwork(req dto.SearchWithPage) (int64, interface{}, error)
ListNetwork() ([]dto.Options, error) ListNetwork() ([]dto.Options, error)
PageVolume(req dto.SearchWithPage) (int64, interface{}, error) PageVolume(req dto.SearchWithPage) (int64, interface{}, error)
@@ -80,7 +80,7 @@ type IContainerService interface {
ContainerUpdate(req dto.ContainerOperate) error ContainerUpdate(req dto.ContainerOperate) error
ContainerUpgrade(req dto.ContainerUpgrade) error ContainerUpgrade(req dto.ContainerUpgrade) error
ContainerInfo(req dto.OperationWithName) (*dto.ContainerOperate, 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) ContainerItemStats(ctx context.Context, req dto.OperationWithName) (dto.ContainerItemStats, error)
LoadResourceLimit() (*dto.ResourceLimit, error) LoadResourceLimit() (*dto.ResourceLimit, error)
ContainerRename(req dto.ContainerRename) error ContainerRename(req dto.ContainerRename) error
@@ -112,7 +112,7 @@ func NewIContainerService() IContainerService {
return &ContainerService{} 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() client, err := docker.NewDockerClient()
if err != nil { if err != nil {
return 0, nil, err return 0, nil, err
@@ -123,7 +123,7 @@ func (u *ContainerService) Page(req dto.PageContainer) (int64, interface{}, erro
options.Filters = filters.NewArgs() options.Filters = filters.NewArgs()
options.Filters.Add("label", req.Filters) options.Filters.Add("label", req.Filters)
} }
containers, err := client.ContainerList(context.Background(), options) containers, err := client.ContainerList(ctx, options)
if err != nil { if err != nil {
return 0, nil, err return 0, nil, err
} }
@@ -206,27 +206,30 @@ func (u *ContainerService) ListByImage(imageName string) []dto.ContainerOptions
return options 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 var data dto.ContainerStatus
client, err := docker.NewDockerClient() client, err := docker.NewDockerClient()
if err != nil { if err != nil {
return data, err return data, err
} }
defer client.Close() defer client.Close()
c := context.Background() if !containersOnly {
images, _ := client.ImageList(ctx, image.ListOptions{All: true})
images, _ := client.ImageList(c, image.ListOptions{All: true})
data.ImageCount = len(images) data.ImageCount = len(images)
repo, _ := imageRepoRepo.List() repo, _ := imageRepoRepo.List()
data.RepoCount = len(repo) data.RepoCount = len(repo)
templates, _ := composeRepo.List() templates, _ := composeRepo.List()
data.ComposeTemplateCount = len(templates) data.ComposeTemplateCount = len(templates)
networks, _ := client.NetworkList(c, network.ListOptions{}) networks, _ := client.NetworkList(ctx, network.ListOptions{})
data.NetworkCount = len(networks) data.NetworkCount = len(networks)
volumes, _ := client.VolumeList(c, volume.ListOptions{}) volumes, _ := client.VolumeList(ctx, volume.ListOptions{})
data.VolumeCount = len(volumes.Volumes) data.VolumeCount = len(volumes.Volumes)
data.ComposeCount = loadComposeCount(client) 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) data.ContainerCount = len(containers)
for _, item := range containers { for _, item := range containers {
switch item.State { switch item.State {
@@ -292,25 +295,48 @@ func (u *ContainerService) ContainerItemStats(ctx context.Context, req dto.Opera
} }
return data, nil 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() client, err := docker.NewDockerClient()
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer client.Close() 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 { if err != nil {
return nil, err return nil, err
} }
datas := make([]dto.ContainerListStats, len(list)) datas := make([]dto.ContainerListStats, len(list))
jobs := make(chan int)
var wg sync.WaitGroup var wg sync.WaitGroup
wg.Add(len(list)) for worker := 0; worker < min(8, len(list)); worker++ {
for i := 0; i < len(list); i++ { wg.Add(1)
go func(index int, item container.Summary) { go func() {
datas[index] = loadCpuAndMem(client, item.ID) defer wg.Done()
wg.Done() for index := range jobs {
}(i, list[i]) 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() wg.Wait()
return datas, nil return datas, nil
} }
@@ -1722,11 +1748,13 @@ func selectImageRepo(imageName string, repos []model.ImageRepo) *model.ImageRepo
return selected 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{ data := dto.ContainerListStats{
ContainerID: containerItem, ContainerID: containerItem,
} }
res, err := client.ContainerStats(context.Background(), containerItem, false) res, err := client.ContainerStats(ctx, containerItem, false)
if err != nil { if err != nil {
return data return data
} }
+16 -6
View File
@@ -43,6 +43,10 @@ type IDashboardService interface {
LoadQuickOptions() []dto.QuickJump LoadQuickOptions() []dto.QuickJump
ChangeQuick(req dto.ChangeQuicks) error ChangeQuick(req dto.ChangeQuicks) error
ListQuickDirectories() ([]model.QuickDirectory, error)
CreateQuickDirectory(req dto.QuickDirectoryCreate) (*model.QuickDirectory, error)
UpdateQuickDirectory(req dto.QuickDirectoryUpdate) (*model.QuickDirectory, error)
DeleteQuickDirectory(req dto.QuickDirectoryDelete) error
LoadAppLauncher(ctx *gin.Context) ([]dto.AppLauncher, error) LoadAppLauncher(ctx *gin.Context) ([]dto.AppLauncher, error)
ChangeShow(req dto.SettingUpdate) error ChangeShow(req dto.SettingUpdate) error
@@ -397,16 +401,15 @@ func (u *DashboardService) LoadQuickOptions() []dto.QuickJump {
} }
func (u *DashboardService) ChangeQuick(req dto.ChangeQuicks) error { func (u *DashboardService) ChangeQuick(req dto.ChangeQuicks) error {
showCount := 0 showCount := 0
var quicks []model.QuickJump quicks := make([]model.QuickJump, 0, len(req.Quicks))
for _, item := range req.Quicks { for _, item := range req.Quicks {
var quick model.QuickJump
if item.IsShow { if item.IsShow {
showCount++ showCount++
} }
if err := copier.Copy(&quick, &item); err != nil { quicks = append(quicks, model.QuickJump{
return err BaseModel: model.BaseModel{ID: item.ID},
} IsShow: item.IsShow,
quicks = append(quicks, quick) })
} }
if showCount == 0 { if showCount == 0 {
return buserr.New("ErrMinQuickJump") return buserr.New("ErrMinQuickJump")
@@ -706,6 +709,13 @@ func loadQuickJump(base *dto.DashboardBase) {
quicks := launcherRepo.ListQuickJump(false) quicks := launcherRepo.ListQuickJump(false)
for i := 0; i < len(quicks); i++ { for i := 0; i < len(quicks); i++ {
switch quicks[i].Name { switch quicks[i].Name {
case "File":
count, err := launcherRepo.CountQuickDirectories()
if err != nil {
global.LOG.Errorf("load quick directory count: %v", err)
}
quicks[i].Detail = fmt.Sprintf("%d", count)
quicks[i].Alias = ""
case "Agent": case "Agent":
quicks[i].Detail = fmt.Sprintf("%d", base.AgentNumber) quicks[i].Detail = fmt.Sprintf("%d", base.AgentNumber)
case "Website": case "Website":
+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) { 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) { if cmd.CheckIllegal(req.Name, req.Username, req.Password, req.Format, req.Collation, req.Permission) {
return nil, buserr.New("ErrCmdIllegal") 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") 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 { if err != nil {
return nil, err return nil, err
} }
createItem.MysqlName = req.Database createItem.MysqlName = req.Database
defer cli.Close() defer cli.Close()
if err := cli.Create(client.CreateInfo{ createInfo := client.CreateInfo{
Name: req.Name, Name: req.Name,
Format: req.Format, Format: req.Format,
Collation: req.Collation, Collation: req.Collation,
@@ -485,9 +489,31 @@ func (u *MysqlService) Create(ctx context.Context, req dto.MysqlDBCreate) (*mode
Permission: req.Permission, Permission: req.Permission,
Version: version, Version: version,
Timeout: 300, 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 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 len(req.Username) != 0 {
if err := saveDatabaseUserCredentials(dbType, req.Database, req.Username, req.Permission, req.Password, req.Description); err != nil { if err := saveDatabaseUserCredentials(dbType, req.Database, req.Username, req.Permission, req.Password, req.Description); err != nil {
return nil, err return nil, err
@@ -506,6 +532,45 @@ func (u *MysqlService) Create(ctx context.Context, req dto.MysqlDBCreate) (*mode
return &createItem, nil 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) { func (u *MysqlService) ListUsers(req dto.MysqlUserSearch) ([]dto.MysqlUser, error) {
dbType, err := resolveDatabaseUserType(req.Database) dbType, err := resolveDatabaseUserType(req.Database)
if err != nil { if err != nil {
@@ -1398,7 +1463,14 @@ func (u *MysqlService) LoadStatus(req dto.OperationWithNameAndType) (*dto.MysqlS
return nil, err 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 { if err != nil {
return nil, err 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) { if common.CompareAppVersion(app.Version, "8.4.0") && (req.Type == constant.AppMysql || req.Type == constant.AppMysqlCluster) {
masterStatus = "show binary log status;" 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 { if err != nil {
return nil, err 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) { func LoadMysqlClientByFrom(database string) (mysql.MysqlClient, string, error) {
return loadMysqlClientByFrom(database, false)
}
func loadMysqlClientByFrom(database string, mcpLocalOnly bool) (mysql.MysqlClient, string, error) {
var ( var (
dbInfo client.DBInfo dbInfo client.DBInfo
version string version string
@@ -1542,6 +1618,9 @@ func LoadMysqlClientByFrom(database string) (mysql.MysqlClient, string, error) {
if err != nil { if err != nil {
return nil, "", err return nil, "", err
} }
if mcpLocalOnly && (databaseItem.ID == 0 || databaseItem.From != "local" || databaseItem.Type != "mysql" || databaseItem.AppInstallID == 0) {
return nil, "", errors.New("an installed local MySQL instance is required")
}
dbInfo.Type = databaseItem.Type dbInfo.Type = databaseItem.Type
dbInfo.From = databaseItem.From dbInfo.From = databaseItem.From
dbInfo.Database = database dbInfo.Database = database
+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) { if hasInvalidFileName(req.NewName) {
return buserr.New("ErrInvalidChar") 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() fo := files.NewFileOp()
content, mode, shouldRecordHistory := readEditableFileHistoryContent(req.OldName) content, mode, shouldRecordHistory := readEditableFileHistoryContent(req.OldName)
if err := fo.Rename(req.OldName, req.NewName); err != nil { 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 res []response.NginxParam
var block components.IBlock var block components.IBlock
if scope == constant.NginxScopeHttp { 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 { } 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 { for _, key := range keys {
dirs := block.FindDirectives(key) dirs := block.FindDirectives(key)
+49
View File
@@ -0,0 +1,49 @@
package service
import (
"strings"
"github.com/1Panel-dev/1Panel/agent/app/dto"
"github.com/1Panel-dev/1Panel/agent/app/model"
"github.com/1Panel-dev/1Panel/agent/buserr"
)
func (u *DashboardService) ListQuickDirectories() ([]model.QuickDirectory, error) {
return launcherRepo.ListQuickDirectories()
}
func quickDirectoryValues(req dto.QuickDirectoryCreate) (model.QuickDirectory, error) {
item := model.QuickDirectory{Path: req.Path, Alias: strings.TrimSpace(req.Alias)}
if strings.TrimSpace(item.Path) == "" {
return item, buserr.WithDetail("ErrInvalidParams", "path is required", nil)
}
return item, nil
}
func (u *DashboardService) CreateQuickDirectory(req dto.QuickDirectoryCreate) (*model.QuickDirectory, error) {
item, err := quickDirectoryValues(req)
if err != nil {
return nil, err
}
if err := launcherRepo.CreateQuickDirectory(&item); err != nil {
return nil, err
}
return &item, nil
}
func (u *DashboardService) UpdateQuickDirectory(req dto.QuickDirectoryUpdate) (*model.QuickDirectory, error) {
item, err := quickDirectoryValues(req.QuickDirectoryCreate)
if err != nil {
return nil, err
}
item.ID = req.ID
item.Legacy = req.Legacy
if err := launcherRepo.UpdateQuickDirectory(&item); err != nil {
return nil, err
}
return &item, nil
}
func (u *DashboardService) DeleteQuickDirectory(req dto.QuickDirectoryDelete) error {
return launcherRepo.DeleteQuickDirectory(req.ID, req.Legacy)
}
+21 -5
View File
@@ -10,6 +10,7 @@ import (
"os/exec" "os/exec"
"path" "path"
"path/filepath" "path/filepath"
"slices"
"sort" "sort"
"strconv" "strconv"
"strings" "strings"
@@ -46,7 +47,7 @@ type RuntimeService struct {
type IRuntimeService interface { type IRuntimeService interface {
Page(req request.RuntimeSearch) (int64, []response.RuntimeDTO, error) Page(req request.RuntimeSearch) (int64, []response.RuntimeDTO, error)
Create(create request.RuntimeCreate) (*model.Runtime, error) Create(create request.RuntimeCreate) (*model.Runtime, error)
Delete(delete request.RuntimeDelete) error Delete(delete request.RuntimeDelete) (string, error)
Update(req request.RuntimeUpdate) error Update(req request.RuntimeUpdate) error
Get(id uint) (res *response.RuntimeDTO, err error) Get(id uint) (res *response.RuntimeDTO, err error)
GetNodePackageRunScript(req request.NodePackageReq) ([]response.PackageScripts, error) GetNodePackageRunScript(req request.NodePackageReq) ([]response.PackageScripts, error)
@@ -376,16 +377,16 @@ func (r *RuntimeService) DeleteCheck(runTimeId uint) ([]dto.AppResource, error)
return res, nil 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)) runtime, err := runtimeRepo.GetFirst(context.Background(), repo.WithByID(runtimeDelete.ID))
if err != nil { if err != nil {
return err return "", err
} }
website, _ := websiteRepo.GetFirst(websiteRepo.WithRuntimeID(runtimeDelete.ID)) website, _ := websiteRepo.GetFirst(websiteRepo.WithRuntimeID(runtimeDelete.ID))
if website.ID > 0 { 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 { 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 { for _, form := range appForm.FormFields {
if v, ok := envs[form.EnvKey]; ok { 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{ appParam := response.AppParam{
Edit: false, Edit: false,
Key: form.EnvKey, Key: form.EnvKey,
+116 -37
View File
@@ -3,6 +3,7 @@ package service
import ( import (
"bytes" "bytes"
"context" "context"
"database/sql"
"encoding/base64" "encoding/base64"
"errors" "errors"
"fmt" "fmt"
@@ -973,11 +974,16 @@ func (w WebsiteService) GetWebsiteHTTPS(websiteId uint) (response.WebsiteHTTPS,
if err != nil { if err != nil {
return res, err return res, err
} }
applyWebsiteHTTPSParams(&res, params)
return res, nil
}
func applyWebsiteHTTPSParams(res *response.WebsiteHTTPS, params []response.NginxParam) {
for _, p := range params { for _, p := range params {
if p.Name == "ssl_protocols" { if p.Name == "ssl_protocols" {
res.SSLProtocol = p.Params res.SSLProtocol = p.Params
} }
if p.Name == "ssl_ciphers" { if p.Name == "ssl_ciphers" && len(p.Params) > 0 {
res.Algorithm = p.Params[0] res.Algorithm = p.Params[0]
} }
if p.Name == "add_header" && len(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) { 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) 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)) website, err := websiteRepo.GetFirst(repo.WithByID(req.WebsiteID))
if err != nil { if err != nil {
return err return err
} }
wasPHP := website.Type == constant.Runtime
if website.Type == constant.Runtime { if website.Type == constant.Runtime {
oldRuntime, err := runtimeRepo.GetFirst(context.Background(), repo.WithByID(website.RuntimeID)) oldRuntime, err := runtimeRepo.GetFirst(context.Background(), repo.WithByID(website.RuntimeID))
if err != nil { if err != nil {
@@ -1347,12 +1353,45 @@ func (w WebsiteService) ChangePHPVersion(req request.WebsitePHPVersionReq) error
return buserr.WithName("ErrImageNotExist", oldRuntime.Name) 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 { if err != nil {
return err 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 { if err != nil {
return err return err
} }
@@ -1361,51 +1400,91 @@ func (w WebsiteService) ChangePHPVersion(req request.WebsitePHPVersionReq) error
return errors.New("nginx config is not valid") return errors.New("nginx config is not valid")
} }
server := servers[0] server := servers[0]
fileOp := files.NewFileOp() if i == 0 || wasPHP != (req.RuntimeID > 0) {
indexPHPPath := path.Join(GetSitePath(website, SiteIndexDir), "index.php")
indexHtmlPath := path.Join(GetSitePath(website, SiteIndexDir), "index.html")
if req.RuntimeID > 0 { if req.RuntimeID > 0 {
server.UpdateDirective("index", []string{"index.php index.html index.htm default.php default.htm default.html"}) 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 { } else {
server.UpdateDirective("index", []string{"index.html index.php index.htm default.php default.htm default.html"}) 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 indexPath := path.Join(GetSitePath(website, SiteIndexDir), "index.html")
if err = nginx.WriteConfig(config, nginx.IndentedStyle); err != nil { indexContent := nginx_conf.Index
return err 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) nginxInstall, err := getAppInstallByKey(constant.AppOpenresty)
if err != nil { if err != nil {
return err 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 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 { func (w WebsiteService) UpdateSiteDir(req request.WebsiteUpdateDir) error {
+169 -7
View File
@@ -1,15 +1,19 @@
package service package service
import ( import (
"archive/zip"
"bytes" "bytes"
"context" "context"
"crypto/ecdsa" "crypto/ecdsa"
"crypto/rand" "crypto/rand"
"crypto/rsa" "crypto/rsa"
"crypto/tls"
"crypto/x509" "crypto/x509"
"crypto/x509/pkix" "crypto/x509/pkix"
"encoding/pem" "encoding/pem"
"errors"
"fmt" "fmt"
"io"
"log" "log"
"math/big" "math/big"
"net" "net"
@@ -18,25 +22,27 @@ import (
"strings" "strings"
"time" "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/request"
"github.com/1Panel-dev/1Panel/agent/app/dto/response" "github.com/1Panel-dev/1Panel/agent/app/dto/response"
"github.com/1Panel-dev/1Panel/agent/app/model" "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/buserr"
"github.com/1Panel-dev/1Panel/agent/constant" "github.com/1Panel-dev/1Panel/agent/constant"
"github.com/1Panel-dev/1Panel/agent/global" "github.com/1Panel-dev/1Panel/agent/global"
"github.com/1Panel-dev/1Panel/agent/i18n" "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/files"
"github.com/1Panel-dev/1Panel/agent/utils/ssl" "github.com/1Panel-dev/1Panel/agent/utils/ssl"
"github.com/go-acme/lego/v5/certcrypto" "github.com/go-acme/lego/v5/certcrypto"
"gorm.io/gorm"
) )
type WebsiteCAService struct { type WebsiteCAService struct {
} }
type IWebsiteCAService interface { type IWebsiteCAService interface {
Import(req request.WebsiteCAImport) error
Upload(name string, archive []byte) error
Page(search request.WebsiteCASearch) (int64, []response.WebsiteCADTO, error) Page(search request.WebsiteCASearch) (int64, []response.WebsiteCADTO, error)
Create(create request.WebsiteCACreate) (*request.WebsiteCACreate, error) Create(create request.WebsiteCACreate) (*request.WebsiteCACreate, error)
GetCA(id uint) (*response.WebsiteCADTO, error) GetCA(id uint) (*response.WebsiteCADTO, error)
@@ -212,10 +218,17 @@ func (w WebsiteCAService) ObtainSSL(req request.WebsiteCAObtain) (*model.Website
websiteSSL.Shell = req.Shell websiteSSL.Shell = req.Shell
} }
if req.PushDir { if req.PushDir {
if !files.NewFileOp().Stat(req.Dir) { dirs := certificateDirectories(req.Dir)
if len(dirs) == 0 {
return nil, buserr.New("ErrLinkPathNotFound") return nil, buserr.New("ErrLinkPathNotFound")
} }
websiteSSL.Dir = req.Dir for _, dir := range dirs {
info, err := os.Stat(dir)
if err != nil || !info.IsDir() {
return nil, buserr.New("ErrLinkPathNotFound")
}
}
websiteSSL.Dir = strings.Join(dirs, "\n")
} }
if req.Domains != "" { if req.Domains != "" {
domainArray := strings.Split(req.Domains, "\n") domainArray := strings.Split(req.Domains, "\n")
@@ -327,7 +340,7 @@ func (w WebsiteCAService) ObtainSSL(req request.WebsiteCAObtain) (*model.Website
websiteSSL.ExpireDate = cert.NotAfter websiteSSL.ExpireDate = cert.NotAfter
websiteSSL.StartDate = cert.NotBefore websiteSSL.StartDate = cert.NotBefore
websiteSSL.Type = cert.Issuer.CommonName websiteSSL.Type = cert.Issuer.CommonName
websiteSSL.Organization = rootCsr.Subject.Organization[0] websiteSSL.Organization = strings.Join(rootCsr.Subject.Organization, ",")
websiteSSL.Status = constant.SSLReady websiteSSL.Status = constant.SSLReady
if req.Renew { if req.Renew {
@@ -348,7 +361,9 @@ func (w WebsiteCAService) ObtainSSL(req request.WebsiteCAObtain) (*model.Website
if websiteSSL.ExecShell { if websiteSSL.ExecShell {
workDir := global.Dir.DataDir workDir := global.Dir.DataDir
if websiteSSL.PushDir { if websiteSSL.PushDir {
workDir = websiteSSL.Dir if dirs := certificateDirectories(websiteSSL.Dir); len(dirs) > 0 {
workDir = dirs[0]
}
} }
logger.Println(i18n.GetMsgByKey("ExecShellStart")) logger.Println(i18n.GetMsgByKey("ExecShellStart"))
if err = runShellScriptFile(workDir, websiteSSL.Shell, logger); err != nil { if err = runShellScriptFile(workDir, websiteSSL.Shell, logger); err != nil {
@@ -433,3 +448,150 @@ func (w WebsiteCAService) DownloadFile(id uint) (*os.File, error) {
} }
return os.Open(path.Join(dir, fileName)) 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
}
+21 -8
View File
@@ -209,11 +209,17 @@ func (w WebsiteSSLService) Create(create request.WebsiteSSLCreate) (request.Webs
websiteSSL.Shell = create.Shell websiteSSL.Shell = create.Shell
} }
if create.PushDir { if create.PushDir {
fileOP := files.NewFileOp() dirs := certificateDirectories(create.Dir)
if !fileOP.Stat(create.Dir) { if len(dirs) == 0 {
return res, buserr.New("ErrLinkPathNotFound") return res, buserr.New("ErrLinkPathNotFound")
} }
websiteSSL.Dir = create.Dir for _, dir := range dirs {
info, err := os.Stat(dir)
if err != nil || !info.IsDir() {
return res, buserr.New("ErrLinkPathNotFound")
}
}
websiteSSL.Dir = strings.Join(dirs, "\n")
} }
setSSLPushConfig(&websiteSSL, create.PushNode, create.Nodes) setSSLPushConfig(&websiteSSL, create.PushNode, create.Nodes)
@@ -573,7 +579,9 @@ func (w WebsiteSSLService) obtainSSL(id uint, autoRenew bool) error {
if websiteSSL.ExecShell { if websiteSSL.ExecShell {
workDir := global.Dir.DataDir workDir := global.Dir.DataDir
if websiteSSL.PushDir { if websiteSSL.PushDir {
workDir = websiteSSL.Dir if dirs := certificateDirectories(websiteSSL.Dir); len(dirs) > 0 {
workDir = dirs[0]
}
} }
printSSLLog(logger, "ExecShellStart", nil) printSSLLog(logger, "ExecShellStart", nil)
if err = runShellScriptFile(workDir, websiteSSL.Shell, logger); err != nil { if err = runShellScriptFile(workDir, websiteSSL.Shell, logger); err != nil {
@@ -793,11 +801,16 @@ func (w WebsiteSSLService) Update(update request.WebsiteSSLUpdate) error {
} }
if update.PushDir { if update.PushDir {
fileOP := files.NewFileOp() dirs := certificateDirectories(update.Dir)
if !fileOP.Stat(update.Dir) { if len(dirs) == 0 {
_ = fileOP.CreateDir(update.Dir, constant.DirPerm) return buserr.New("ErrLinkPathNotFound")
} }
updateParams["dir"] = update.Dir for _, dir := range dirs {
if err := os.MkdirAll(dir, constant.DirPerm); err != nil {
return err
}
}
updateParams["dir"] = strings.Join(dirs, "\n")
} }
var domains []string var domains []string
if update.OtherDomains != "" { if update.OtherDomains != "" {
+32 -17
View File
@@ -1454,10 +1454,10 @@ func getWebsiteDomains(domains []request.WebsiteDomain, defaultHTTPPort, default
return return
} }
domainModel.Domain = strings.ToLower(domainModel.Domain) domainModel.Domain = strings.ToLower(domainModel.Domain)
domainModel.Port = domain.Port
if domain.Port == 0 { if domain.Port == 0 {
domain.Port = defaultHTTPPort domain.Port = defaultHTTPPort
} }
domainModel.Port = domain.Port
domainModel.SSL = domain.SSL domainModel.SSL = domain.SSL
domainModel.WebsiteID = websiteID domainModel.WebsiteID = websiteID
domainModels = append(domainModels, domainModel) domainModels = append(domainModels, domainModel)
@@ -1529,26 +1529,41 @@ func checkWebsitePort(defaultHTTPsPort, port int, websiteType string) error {
return nil return nil
} }
// certificateDirectories accepts both legacy single paths and newline-separated paths.
func certificateDirectories(value string) []string {
var dirs []string
seen := make(map[string]bool)
for _, dir := range strings.Split(value, "\n") {
dir = strings.TrimSpace(dir)
if dir == "" {
continue
}
dir = filepath.Clean(dir)
if !seen[dir] {
seen[dir] = true
dirs = append(dirs, dir)
}
}
return dirs
}
func saveCertificateFile(websiteSSL *model.WebsiteSSL, logger *log.Logger) { func saveCertificateFile(websiteSSL *model.WebsiteSSL, logger *log.Logger) {
if websiteSSL.PushDir { if !websiteSSL.PushDir {
return
}
fileOp := files.NewFileOp() fileOp := files.NewFileOp()
var ( for _, dir := range certificateDirectories(websiteSSL.Dir) {
pushErr error status := i18n.GetMsgByKey("Success")
MsgMap = map[string]interface{}{"path": websiteSSL.Dir, "status": i18n.GetMsgByKey("Success")} for _, file := range []struct{ name, content string }{
) {"privkey.pem", websiteSSL.PrivateKey},
if pushErr = fileOp.SaveFile(path.Join(websiteSSL.Dir, "privkey.pem"), websiteSSL.PrivateKey, constant.FilePerm); pushErr != nil { {"fullchain.pem", websiteSSL.Pem},
MsgMap["status"] = i18n.GetMsgByKey("Failed") } {
logger.Println(i18n.GetMsgWithMap("PushDirLog", MsgMap)) if err := fileOp.SaveFile(path.Join(dir, file.name), file.content, constant.FilePerm); err != nil {
logger.Println("Push dir failed:" + pushErr.Error()) status = i18n.GetMsgByKey("Failed")
logger.Printf("Push dir %s failed: %s", dir, err)
} }
if pushErr = fileOp.SaveFile(path.Join(websiteSSL.Dir, "fullchain.pem"), websiteSSL.Pem, constant.FilePerm); pushErr != nil {
MsgMap["status"] = i18n.GetMsgByKey("Failed")
logger.Println(i18n.GetMsgWithMap("PushDirLog", MsgMap))
logger.Println("Push dir failed:" + pushErr.Error())
}
if pushErr == nil {
logger.Println(i18n.GetMsgWithMap("PushDirLog", MsgMap))
} }
logger.Println(i18n.GetMsgWithMap("PushDirLog", map[string]interface{}{"path": dir, "status": status}))
} }
} }
+5 -14
View File
@@ -53,13 +53,6 @@
"formatZH": "添加 Ollama 模型重试 [name]", "formatZH": "添加 Ollama 模型重试 [name]",
"formatEN": "re-add Ollama model [name]" "formatEN": "re-add Ollama model [name]"
}, },
"/ai/ollama/model/sync": {
"bodyKeys": [],
"paramKeys": [],
"beforeFunctions": [],
"formatZH": "同步 Ollama 模型列表",
"formatEN": "sync Ollama model list"
},
"/ai/tensorrt/create": { "/ai/tensorrt/create": {
"bodyKeys": [ "bodyKeys": [
"name" "name"
@@ -3740,13 +3733,11 @@
"formatEN": "Create runtime [name]" "formatEN": "Create runtime [name]"
}, },
"/runtimes/del": { "/runtimes/del": {
"bodyKeys": [ "bodyKeys": [],
"id"
],
"paramKeys": [], "paramKeys": [],
"beforeFunctions": [], "beforeFunctions": [],
"formatZH": "删除运行环境 [id]", "formatZH": "删除运行环境 [name]",
"formatEN": "Delete runtime [id]" "formatEN": "Delete runtime [name]"
}, },
"/runtimes/operate": { "/runtimes/operate": {
"bodyKeys": [ "bodyKeys": [
@@ -4582,13 +4573,13 @@
}, },
"/websites/php/version": { "/websites/php/version": {
"bodyKeys": [ "bodyKeys": [
"websiteId" "websiteID"
], ],
"paramKeys": [], "paramKeys": [],
"beforeFunctions": [ "beforeFunctions": [
{ {
"input_column": "id", "input_column": "id",
"input_value": "websiteId", "input_value": "websiteID",
"isList": false, "isList": false,
"db": "websites", "db": "websites",
"output_column": "primary_domain", "output_column": "primary_domain",
+2
View File
@@ -10,6 +10,8 @@ const (
AppOpenclaw = "openclaw" AppOpenclaw = "openclaw"
AppCopaw = "copaw" AppCopaw = "copaw"
AppHermesAgent = "hermes-agent" AppHermesAgent = "hermes-agent"
AppDeepSeekHarness = "deepseek-harness"
AppOctop = "octop"
AppMysql = "mysql" AppMysql = "mysql"
AppMariaDB = "mariadb" AppMariaDB = "mariadb"
AppPostgresql = "postgresql" AppPostgresql = "postgresql"
+1 -1
View File
@@ -1,6 +1,6 @@
module github.com/1Panel-dev/1Panel/agent module github.com/1Panel-dev/1Panel/agent
go 1.26.6 go 1.27.3
replace github.com/go-acme/lego/v5 => github.com/1Panel-dev/lego/v5 v5.3.1 replace github.com/go-acme/lego/v5 => github.com/1Panel-dev/lego/v5 v5.3.1
+4
View File
@@ -773,3 +773,7 @@ VMSnapshotRecoverTask: "Recover snapshot [{{ .snapshot }}] for virtual machine [
VMSnapshotRecoverStep: "Recover virtual machine snapshot [{{ .snapshot }}]" VMSnapshotRecoverStep: "Recover virtual machine snapshot [{{ .snapshot }}]"
VMSnapshotDeleteTask: "Delete snapshot [{{ .snapshot }}] for virtual machine [{{ .vm }}]" VMSnapshotDeleteTask: "Delete snapshot [{{ .snapshot }}] for virtual machine [{{ .vm }}]"
VMSnapshotDeleteStep: "Delete virtual machine snapshot [{{ .snapshot }}]" 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 }}]" VMSnapshotRecoverStep: "Restaurar instantánea de máquina virtual [{{ .snapshot }}]"
VMSnapshotDeleteTask: "Eliminar instantánea [{{ .snapshot }}] de la máquina virtual [{{ .vm }}]" VMSnapshotDeleteTask: "Eliminar instantánea [{{ .snapshot }}] de la máquina virtual [{{ .vm }}]"
VMSnapshotDeleteStep: "Eliminar instantánea de máquina virtual [{{ .snapshot }}]" 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 }}]" VMSnapshotRecoverStep: "بازیابی اسنپ‌شات ماشین مجازی [{{ .snapshot }}]"
VMSnapshotDeleteTask: "حذف اسنپ‌شات [{{ .snapshot }}] برای ماشین مجازی [{{ .vm }}]" VMSnapshotDeleteTask: "حذف اسنپ‌شات [{{ .snapshot }}] برای ماشین مجازی [{{ .vm }}]"
VMSnapshotDeleteStep: "حذف اسنپ‌شات ماشین مجازی [{{ .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 }}] のスナップショット
VMSnapshotRecoverStep: "仮想マシンのスナップショット [{{ .snapshot }}] を復元" VMSnapshotRecoverStep: "仮想マシンのスナップショット [{{ .snapshot }}] を復元"
VMSnapshotDeleteTask: "仮想マシン [{{ .vm }}] のスナップショット [{{ .snapshot }}] を削除" VMSnapshotDeleteTask: "仮想マシン [{{ .vm }}] のスナップショット [{{ .snapshot }}] を削除"
VMSnapshotDeleteStep: "仮想マシンのスナップショット [{{ .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 }}] 복구" VMSnapshotRecoverStep: "가상 머신 스냅샷 [{{ .snapshot }}] 복구"
VMSnapshotDeleteTask: "가상 머신 [{{ .vm }}] 스냅샷 [{{ .snapshot }}] 삭제" VMSnapshotDeleteTask: "가상 머신 [{{ .vm }}] 스냅샷 [{{ .snapshot }}] 삭제"
VMSnapshotDeleteStep: "가상 머신 스냅샷 [{{ .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 }}]" VMSnapshotRecoverStep: "ກູ້ຄືນສະແນັບຊັອດເຄື່ອງສະເໝືອນ [{{ .snapshot }}]"
VMSnapshotDeleteTask: "ລຶບສະແນັບຊັອດ [{{ .snapshot }}] ຂອງເຄື່ອງສະເໝືອນ [{{ .vm }}]" VMSnapshotDeleteTask: "ລຶບສະແນັບຊັອດ [{{ .snapshot }}] ຂອງເຄື່ອງສະເໝືອນ [{{ .vm }}]"
VMSnapshotDeleteStep: "ລຶບສະແນັບຊັອດເຄື່ອງສະເໝືອນ [{{ .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: "Pulihkan syot kilat [{{ .snapshot }}] untuk mesin maya [
VMSnapshotRecoverStep: "Pulihkan syot kilat mesin maya [{{ .snapshot }}]" VMSnapshotRecoverStep: "Pulihkan syot kilat mesin maya [{{ .snapshot }}]"
VMSnapshotDeleteTask: "Padam syot kilat [{{ .snapshot }}] untuk mesin maya [{{ .vm }}]" VMSnapshotDeleteTask: "Padam syot kilat [{{ .snapshot }}] untuk mesin maya [{{ .vm }}]"
VMSnapshotDeleteStep: "Padam syot kilat mesin maya [{{ .snapshot }}]" 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 }}]" VMSnapshotRecoverStep: "Restaurar snapshot da máquina virtual [{{ .snapshot }}]"
VMSnapshotDeleteTask: "Excluir snapshot [{{ .snapshot }}] da máquina virtual [{{ .vm }}]" VMSnapshotDeleteTask: "Excluir snapshot [{{ .snapshot }}] da máquina virtual [{{ .vm }}]"
VMSnapshotDeleteStep: "Excluir snapshot da máquina virtual [{{ .snapshot }}]" 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 }}]" VMSnapshotRecoverStep: "Восстановить снимок виртуальной машины [{{ .snapshot }}]"
VMSnapshotDeleteTask: "Удалить снимок [{{ .snapshot }}] виртуальной машины [{{ .vm }}]" VMSnapshotDeleteTask: "Удалить снимок [{{ .snapshot }}] виртуальной машины [{{ .vm }}]"
VMSnapshotDeleteStep: "Удалить снимок виртуальной машины [{{ .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: "Sanal makine [{{ .vm }}] anlık görüntüsü [{{ .snaps
VMSnapshotRecoverStep: "Sanal makine anlık görüntüsü [{{ .snapshot }}] geri yükle" 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" VMSnapshotDeleteTask: "Sanal makine [{{ .vm }}] anlık görüntüsü [{{ .snapshot }}] sil"
VMSnapshotDeleteStep: "Sanal makine 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 }}]" VMSnapshotRecoverStep: "復原虛擬機快照 [{{ .snapshot }}]"
VMSnapshotDeleteTask: "刪除虛擬機 [{{ .vm }}] 快照 [{{ .snapshot }}]" VMSnapshotDeleteTask: "刪除虛擬機 [{{ .vm }}] 快照 [{{ .snapshot }}]"
VMSnapshotDeleteStep: "刪除虛擬機快照 [{{ .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 }}]" VMSnapshotRecoverStep: "恢复虚拟机快照 [{{ .snapshot }}]"
VMSnapshotDeleteTask: "删除虚拟机 [{{ .vm }}] 快照 [{{ .snapshot }}]" VMSnapshotDeleteTask: "删除虚拟机 [{{ .vm }}] 快照 [{{ .snapshot }}]"
VMSnapshotDeleteStep: "删除虚拟机快照 [{{ .snapshot }}]" VMSnapshotDeleteStep: "删除虚拟机快照 [{{ .snapshot }}]"
ErrCAUpload: "请上传包含有效自签根证书 ca.crt 及匹配私钥 ca.key 的 ZIP 文件(最大 10 MB),密钥算法须为支持的 RSA 或 EC。"
ErrCAImport: "请提供有效的自签根证书及匹配私钥(每个最大 1 MB),密钥算法须为支持的 RSA 或 EC。"
+4
View File
@@ -115,6 +115,10 @@ func agentDBMigrations() []*gormigrate.Migration {
migrations.AddMonitorSettings, migrations.AddMonitorSettings,
migrations.AddVLLMRequestMetrics, migrations.AddVLLMRequestMetrics,
migrations.AddVLLMSampleInterval, migrations.AddVLLMSampleInterval,
migrations.AddNativeAgentInstalls,
migrations.AddQuickDirectory,
migrations.AddLocalModelAccountLink,
migrations.AddOllamaModelAccountSync,
} }
} }
@@ -0,0 +1,47 @@
package migrations
import (
"github.com/1Panel-dev/1Panel/agent/app/model"
"github.com/1Panel-dev/1Panel/agent/constant"
"github.com/1Panel-dev/1Panel/agent/global"
"github.com/go-gormigrate/gormigrate/v2"
"gorm.io/gorm"
)
var AddNativeAgentInstalls = &gormigrate.Migration{
ID: "20261020-add-deepseek-harness-octop-agent-installs",
Migrate: func(tx *gorm.DB) error {
var installs []struct {
model.AppInstall
AgentType string
}
if err := tx.Table("app_installs").
Select("app_installs.*, apps.key AS agent_type").
Joins("JOIN apps ON apps.id = app_installs.app_id").
Where("apps.key IN ?", []string{constant.AppDeepSeekHarness, constant.AppOctop}).
Where("NOT EXISTS (SELECT 1 FROM agents WHERE agents.app_install_id = app_installs.id)").
Order("app_installs.id").
Scan(&installs).Error; err != nil {
return err
}
for _, install := range installs {
var count int64
if err := tx.Model(&model.Agent{}).Where("LOWER(name) = LOWER(?)", install.Name).Count(&count).Error; err != nil {
return err
}
if count > 0 {
global.LOG.Warnf("skip agent registration for app install %d: name already exists", install.ID)
continue
}
agent := model.Agent{
BaseModel: model.BaseModel{CreatedAt: install.CreatedAt, UpdatedAt: install.UpdatedAt},
Name: install.Name, AgentType: install.AgentType, AppInstallID: install.ID,
Status: install.Status, Message: install.Message,
}
if err := tx.Create(&agent).Error; err != nil {
return err
}
}
return nil
},
}
+51
View File
@@ -1888,3 +1888,54 @@ var AddVLLMSampleInterval = &gormigrate.Migration{
return global.VLLMMonitorDB.AutoMigrate(&model.MonitorVLLM{}) return global.VLLMMonitorDB.AutoMigrate(&model.MonitorVLLM{})
}, },
} }
var AddQuickDirectory = &gormigrate.Migration{
ID: "20261016-add-quick-directory",
Migrate: func(tx *gorm.DB) error {
return tx.AutoMigrate(&model.QuickDirectory{})
},
}
var AddLocalModelAccountLink = &gormigrate.Migration{
ID: "20261023-add-local-model-account-link",
Migrate: func(tx *gorm.DB) error {
return tx.AutoMigrate(&model.AgentAccount{})
},
}
var AddOllamaModelAccountSync = &gormigrate.Migration{
ID: "20261026-add-ollama-model-account-sync",
Migrate: func(tx *gorm.DB) error {
if err := tx.AutoMigrate(&model.AppInstall{}); err != nil {
return err
}
var accounts []model.AgentAccount
if err := tx.Where("provider = ? AND app_install_id > 0", "ollama").Find(&accounts).Error; err != nil {
return err
}
for _, account := range accounts {
var install model.AppInstall
if err := tx.Preload("App").First(&install, account.AppInstallID).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
continue
}
return err
}
if install.App.Key != "ollama" {
continue
}
addressType := "custom"
if strings.TrimRight(account.BaseURL, "/") == fmt.Sprintf("http://%s:11434/v1", install.ContainerName) {
addressType = "container"
}
if err := tx.Model(&model.AppInstall{}).Where("id = ?", account.AppInstallID).Updates(map[string]interface{}{
"sync_model_account": true,
"model_account_base_url_type": addressType,
"model_account_base_url": account.BaseURL,
}).Error; err != nil {
return err
}
}
return nil
},
}
+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 { func Routers() *gin.Engine {
Router = gin.Default() Router = gin.Default()
Router.Use(i18n.UseI18n()) Router.Use(i18n.UseI18n())
registerMCPRuntime(Router)
PrivateGroup := Router.Group("/api/v2") PrivateGroup := Router.Group("/api/v2")
if !global.IsMaster { 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/app/api/v2/helper"
"github.com/1Panel-dev/1Panel/agent/global" "github.com/1Panel-dev/1Panel/agent/global"
"github.com/1Panel-dev/1Panel/agent/utils/cmd" "github.com/1Panel-dev/1Panel/agent/utils/cmd"
"github.com/1Panel-dev/1Panel/agent/utils/mcpapi"
"github.com/1Panel-dev/1Panel/agent/utils/xpack" "github.com/1Panel-dev/1Panel/agent/utils/xpack"
"github.com/gin-gonic/gin" "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 { func Certificate() gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {
if global.IsMaster { if global.IsMaster {
@@ -25,16 +30,23 @@ func Certificate() gin.HandlerFunc {
return return
} }
conn := c.Request.Header.Get("Connection") 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() c.Next()
return return
} }
masterProxyID := c.Request.Header.Get("Proxy-Id") 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) { if err == nil && len(proxyID) != 0 && strings.TrimSpace(proxyID) != strings.TrimSpace(masterProxyID) {
helper.InternalServer(c, fmt.Errorf("err proxy id")) helper.InternalServer(c, fmt.Errorf("err proxy id"))
return return
} }
c.Request = mcpapi.BindVerifiedNodeTransport(c.Request)
c.Next() c.Next()
} }
} }
+2 -1
View File
@@ -13,11 +13,12 @@ func (a *AIToolsRouter) InitRouter(Router *gin.RouterGroup) {
baseApi := v1.ApiGroupApp.BaseApi baseApi := v1.ApiGroupApp.BaseApi
{ {
aiToolsRouter.GET("/benchmark/sources", baseApi.AIBenchmarkSources)
aiToolsRouter.POST("/ollama/close", baseApi.CloseOllamaModel) aiToolsRouter.POST("/ollama/close", baseApi.CloseOllamaModel)
aiToolsRouter.POST("/ollama/model", baseApi.CreateOllamaModel) aiToolsRouter.POST("/ollama/model", baseApi.CreateOllamaModel)
aiToolsRouter.POST("/ollama/model/recreate", baseApi.RecreateOllamaModel) aiToolsRouter.POST("/ollama/model/recreate", baseApi.RecreateOllamaModel)
aiToolsRouter.POST("/ollama/model/search", baseApi.SearchOllamaModel) aiToolsRouter.POST("/ollama/model/search", baseApi.SearchOllamaModel)
aiToolsRouter.POST("/ollama/model/sync", baseApi.SyncOllamaModel) aiToolsRouter.POST("/ollama/account/sync", baseApi.SyncOllamaModelAccount)
aiToolsRouter.POST("/ollama/model/load", baseApi.LoadOllamaModelDetail) aiToolsRouter.POST("/ollama/model/load", baseApi.LoadOllamaModelDetail)
aiToolsRouter.POST("/ollama/model/del", baseApi.DeleteOllamaModel) aiToolsRouter.POST("/ollama/model/del", baseApi.DeleteOllamaModel)
aiToolsRouter.POST("/domain/bind", baseApi.BindDomain) aiToolsRouter.POST("/domain/bind", baseApi.BindDomain)
+4
View File
@@ -14,6 +14,10 @@ func (s *DashboardRouter) InitRouter(Router *gin.RouterGroup) {
cmdRouter.GET("/base/os", baseApi.LoadDashboardOsInfo) cmdRouter.GET("/base/os", baseApi.LoadDashboardOsInfo)
cmdRouter.GET("/quick/option", baseApi.LoadQuickOption) cmdRouter.GET("/quick/option", baseApi.LoadQuickOption)
cmdRouter.POST("/quick/change", baseApi.UpdateQuickJump) cmdRouter.POST("/quick/change", baseApi.UpdateQuickJump)
cmdRouter.GET("/quick/directory", baseApi.ListQuickDirectories)
cmdRouter.POST("/quick/directory", baseApi.CreateQuickDirectory)
cmdRouter.POST("/quick/directory/update", baseApi.UpdateQuickDirectory)
cmdRouter.POST("/quick/directory/del", baseApi.DeleteQuickDirectory)
cmdRouter.GET("/app/launcher", baseApi.LoadAppLauncher) cmdRouter.GET("/app/launcher", baseApi.LoadAppLauncher)
cmdRouter.POST("/app/launcher/show", baseApi.UpdateAppLauncher) cmdRouter.POST("/app/launcher/show", baseApi.UpdateAppLauncher)
cmdRouter.POST("/app/launcher/option", baseApi.LoadAppLauncherOption) cmdRouter.POST("/app/launcher/option", baseApi.LoadAppLauncherOption)
+2
View File
@@ -13,6 +13,8 @@ func (a *WebsiteCARouter) InitRouter(Router *gin.RouterGroup) {
baseApi := v2.ApiGroupApp.BaseApi baseApi := v2.ApiGroupApp.BaseApi
{ {
groupRouter.POST("/import", baseApi.ImportWebsiteCA)
groupRouter.POST("/upload", baseApi.UploadWebsiteCA)
groupRouter.POST("/search", baseApi.PageWebsiteCA) groupRouter.POST("/search", baseApi.PageWebsiteCA)
groupRouter.POST("", baseApi.CreateWebsiteCA) groupRouter.POST("", baseApi.CreateWebsiteCA)
groupRouter.POST("/del", baseApi.DeleteWebsiteCA) 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) { func Marshal(envMap map[string]string) (string, error) {
lines := make([]string, 0, len(envMap)) lines := make([]string, 0, len(envMap))
for k, v := range envMap { for k, v := range envMap {
if d, err := strconv.Atoi(v); err == nil && !isStartWithZero(v) { if d, ok := canonicalInt(v); ok {
lines = append(lines, fmt.Sprintf(`%s=%d`, k, d)) lines = append(lines, fmt.Sprintf(`%s=%d`, k, d))
} else if shouldUseSingleQuotes(v) { } else if shouldUseSingleQuotes(v) {
lines = append(lines, fmt.Sprintf(`%s='%s'`, k, v)) lines = append(lines, fmt.Sprintf(`%s='%s'`, k, v))
@@ -86,7 +86,7 @@ func MarshalWithOrder(envMap map[string]string, orders []string) (string, error)
} }
func formatEnvLine(k, v string) string { func formatEnvLine(k, v string) string {
if d, err := strconv.Atoi(v); err == nil && !isStartWithZero(v) { if d, ok := canonicalInt(v); ok {
return fmt.Sprintf(`%s=%d`, k, d) return fmt.Sprintf(`%s=%d`, k, d)
} else if shouldUseSingleQuotes(v) { } else if shouldUseSingleQuotes(v) {
return fmt.Sprintf(`%s='%s'`, k, v) return fmt.Sprintf(`%s='%s'`, k, v)
@@ -107,11 +107,14 @@ func GetEnvValueByKey(envPath, key string) (string, error) {
return value, nil return value, nil
} }
func isStartWithZero(value string) bool { // canonicalInt reports whether value is an integer that formats back to the
if strings.HasPrefix(value, "0") && len(value) > 1 { // same string, so values such as "007", "+1" or "-01" keep their exact text.
return true 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 { 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 Database string
Password string Password string
ContainerName 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 { func NewLocal(command []string, dbType, containerName, password, database string) *Local {
@@ -47,6 +49,26 @@ func (r *Local) Create(info CreateInfo) error {
return nil 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 { 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) createSql := fmt.Sprintf("create database `%s` default character set %s collate %s", info.Name, info.Format, info.Collation)
if len(info.Collation) == 0 { if len(info.Collation) == 0 {
@@ -396,15 +418,18 @@ func (r *Local) SyncDB(version string) ([]SyncDBInfo, error) {
func (r *Local) Close() {} func (r *Local) Close() {}
func (r *Local) ExecSQL(command string, timeout uint) error { func (r *Local) ExecSQL(command string, timeout uint) error {
itemCommand := r.PrefixCommand[:] itemCommand := append([]string(nil), r.PrefixCommand...)
itemCommand = append(itemCommand, command) itemCommand = append(itemCommand, command)
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeout)*time.Second) ctx, cancel := r.commandContext(timeout)
defer cancel() defer cancel()
cmd := exec.CommandContext(ctx, "docker", itemCommand...) cmd := exec.CommandContext(ctx, "docker", itemCommand...)
stdout, err := cmd.CombinedOutput() stdout, err := cmd.CombinedOutput()
if errors.Is(ctx.Err(), context.DeadlineExceeded) { if errors.Is(ctx.Err(), context.DeadlineExceeded) {
return buserr.New("ErrExecTimeOut") 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", "") 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 ") { if err != nil || strings.HasPrefix(string(stdStr), "ERROR ") {
return errors.New(stdStr) 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) { func (r *Local) ExecSQLForRows(command string, timeout uint) ([]string, error) {
itemCommand := r.PrefixCommand[:] itemCommand := append([]string(nil), r.PrefixCommand...)
itemCommand = append(itemCommand, command) itemCommand = append(itemCommand, command)
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeout)*time.Second) ctx, cancel := r.commandContext(timeout)
defer cancel() defer cancel()
cmd := exec.CommandContext(ctx, "docker", itemCommand...) cmd := exec.CommandContext(ctx, "docker", itemCommand...)
stdout, err := cmd.CombinedOutput() stdout, err := cmd.CombinedOutput()
if errors.Is(ctx.Err(), context.DeadlineExceeded) { if errors.Is(ctx.Err(), context.DeadlineExceeded) {
return nil, buserr.New("ErrExecTimeOut") 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", "") 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 ") { if err != nil || strings.HasPrefix(string(stdStr), "ERROR ") {
return nil, errors.New(stdStr) return nil, errors.New(stdStr)
+4
View File
@@ -6,6 +6,8 @@ import (
) )
const ( 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}$` VMNameValidationPattern = `^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$`
VMCommonPattern = `^[a-zA-Z0-9][a-zA-Z0-9._:-]{0,63}$` VMCommonPattern = `^[a-zA-Z0-9][a-zA-Z0-9._:-]{0,63}$`
@@ -66,6 +68,8 @@ var regexMap = make(map[string]*regexp.Regexp)
func Init() { func Init() {
patterns := []string{ patterns := []string{
TokenizerDownloadNamePattern,
ModelDownloaderRepoIDPattern,
VMNameValidationPattern, VMNameValidationPattern,
VMCommonPattern, VMCommonPattern,
+1
View File
@@ -266,6 +266,7 @@ func (b *BaseApi) GetLoginSetting(c *gin.Context) {
Language: settingInfo.Language, Language: settingInfo.Language,
MenuTabs: settingInfo.MenuTabs, MenuTabs: settingInfo.MenuTabs,
MenuAccordion: settingInfo.MenuAccordion, MenuAccordion: settingInfo.MenuAccordion,
PanelCache: settingInfo.PanelCache,
PanelName: settingInfo.PanelName, PanelName: settingInfo.PanelName,
Theme: settingInfo.Theme, Theme: settingInfo.Theme,
NeedCaptcha: needCaptcha, NeedCaptcha: needCaptcha,
+4
View File
@@ -10,10 +10,14 @@ import (
"github.com/1Panel-dev/1Panel/core/app/dto" "github.com/1Panel-dev/1Panel/core/app/dto"
"github.com/1Panel-dev/1Panel/core/global" "github.com/1Panel-dev/1Panel/core/global"
"github.com/1Panel-dev/1Panel/core/i18n" "github.com/1Panel-dev/1Panel/core/i18n"
mcpmeta "github.com/1Panel-dev/1Panel/core/utils/mcpapi/metadata"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
func ErrorWithDetail(ctx *gin.Context, code int, msgKey string, err error) { 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{ res := dto.Response{
Code: code, Code: code,
Message: "", 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"`
}
+13
View File
@@ -4,6 +4,16 @@ import (
"time" "time"
) )
const DefaultPanelCache = `{"tabs":true,"pagination":true,"refresh":true,"search":false,"duration":"24h"}`
type PanelCache struct {
Tabs *bool `json:"tabs"`
Pagination *bool `json:"pagination"`
Refresh *bool `json:"refresh"`
Search *bool `json:"search"`
Duration string `json:"duration"`
}
type SettingInfo struct { type SettingInfo struct {
SystemVersion string `json:"systemVersion"` SystemVersion string `json:"systemVersion"`
DeveloperMode string `json:"developerMode"` DeveloperMode string `json:"developerMode"`
@@ -18,6 +28,7 @@ type SettingInfo struct {
Theme string `json:"theme"` Theme string `json:"theme"`
MenuTabs string `json:"menuTabs"` MenuTabs string `json:"menuTabs"`
MenuAccordion string `json:"menuAccordion"` MenuAccordion string `json:"menuAccordion"`
PanelCache string `json:"panelCache"`
Language string `json:"language"` Language string `json:"language"`
DocSource string `json:"docSource"` DocSource string `json:"docSource"`
@@ -62,6 +73,7 @@ type SettingBaseInfo struct {
Theme string `json:"theme"` Theme string `json:"theme"`
MenuTabs string `json:"menuTabs"` MenuTabs string `json:"menuTabs"`
MenuAccordion string `json:"menuAccordion"` MenuAccordion string `json:"menuAccordion"`
PanelCache string `json:"panelCache"`
Language string `json:"language"` Language string `json:"language"`
HideMenu string `json:"hideMenu"` HideMenu string `json:"hideMenu"`
DocSource string `json:"docSource"` DocSource string `json:"docSource"`
@@ -288,6 +300,7 @@ type LoginSetting struct {
Language string `json:"language"` Language string `json:"language"`
MenuTabs string `json:"menuTabs"` MenuTabs string `json:"menuTabs"`
MenuAccordion string `json:"menuAccordion"` MenuAccordion string `json:"menuAccordion"`
PanelCache string `json:"panelCache"`
PanelName string `json:"panelName"` PanelName string `json:"panelName"`
Theme string `json:"theme"` Theme string `json:"theme"`
NeedCaptcha bool `json:"needCaptcha"` NeedCaptcha bool `json:"needCaptcha"`
+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"`
}

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