mirror of
https://github.com/1Panel-dev/1Panel.git
synced 2026-10-10 08:00:32 +00:00
Compare commits
49
Commits
v2.2.4
...
release-2.2.5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e7ef35740c | ||
|
|
b0d561e33b | ||
|
|
75b362fa9b | ||
|
|
466f373ef6 | ||
|
|
4489641b54 | ||
|
|
1971d9dec2 | ||
|
|
c38d741770 | ||
|
|
122a474032 | ||
|
|
54854e99e7 | ||
|
|
e01fb7c905 | ||
|
|
83a3675a0c | ||
|
|
9dfa451fae | ||
|
|
9204a287fd | ||
|
|
f027507f9a | ||
|
|
01aee89f3b | ||
|
|
17285d4397 | ||
|
|
91ae846418 | ||
|
|
1eb429631d | ||
|
|
6584e3b868 | ||
|
|
14e2294db9 | ||
|
|
26b69bc208 | ||
|
|
2111d4c16b | ||
|
|
b682835b4e | ||
|
|
c34ac2f31e | ||
|
|
c28047374a | ||
|
|
60d16609f7 | ||
|
|
02ca9347dc | ||
|
|
d0187994ee | ||
|
|
be672d604f | ||
|
|
16e3d496eb | ||
|
|
9159ab842d | ||
|
|
0d8835d494 | ||
|
|
9f9e3aacfc | ||
|
|
7bd11fe73e | ||
|
|
e11dc5fadd | ||
|
|
f35b0deb29 | ||
|
|
33b3eecb95 | ||
|
|
6ac7a5f167 | ||
|
|
a5fbbfc460 | ||
|
|
563df3da71 | ||
|
|
13e6bc4fac | ||
|
|
357d77856a | ||
|
|
4279339189 | ||
|
|
380033dfe0 | ||
|
|
97d383ed12 | ||
|
|
52e6a63ebc | ||
|
|
7506e709e2 | ||
|
|
cf37de66fc | ||
|
|
e2754b447d |
@@ -1378,6 +1378,88 @@ func (b *BaseApi) UninstallAgentSkill(c *gin.Context) {
|
||||
helper.Success(c)
|
||||
}
|
||||
|
||||
// @Tags AI
|
||||
// @Summary List OpenClaw plugins
|
||||
// @Accept json
|
||||
// @Param request body dto.AgentPluginsReq true "request"
|
||||
// @Success 200 {array} dto.AgentPluginItem
|
||||
// @Security ApiKeyAuth
|
||||
// @Security Timestamp
|
||||
// @Router /ai/agents/plugins/list [post]
|
||||
func (b *BaseApi) ListAgentPlugins(c *gin.Context) {
|
||||
var req dto.AgentPluginsReq
|
||||
if err := helper.CheckBindAndValidate(&req, c); err != nil {
|
||||
return
|
||||
}
|
||||
data, err := agentService.ListPlugins(req)
|
||||
if err != nil {
|
||||
helper.BadRequest(c, err)
|
||||
return
|
||||
}
|
||||
helper.SuccessWithData(c, data)
|
||||
}
|
||||
|
||||
// @Tags AI
|
||||
// @Summary Search OpenClaw plugins
|
||||
// @Accept json
|
||||
// @Param request body dto.AgentPluginSearchReq true "request"
|
||||
// @Success 200 {array} dto.AgentPluginSearchItem
|
||||
// @Security ApiKeyAuth
|
||||
// @Security Timestamp
|
||||
// @Router /ai/agents/plugins/search [post]
|
||||
func (b *BaseApi) SearchAgentPlugins(c *gin.Context) {
|
||||
var req dto.AgentPluginSearchReq
|
||||
if err := helper.CheckBindAndValidate(&req, c); err != nil {
|
||||
return
|
||||
}
|
||||
data, err := agentService.SearchPlugins(req)
|
||||
if err != nil {
|
||||
helper.BadRequest(c, err)
|
||||
return
|
||||
}
|
||||
helper.SuccessWithData(c, data)
|
||||
}
|
||||
|
||||
// @Tags AI
|
||||
// @Summary Install an OpenClaw marketplace plugin
|
||||
// @Accept json
|
||||
// @Param request body dto.AgentPluginMarketInstallReq true "request"
|
||||
// @Success 200
|
||||
// @Security ApiKeyAuth
|
||||
// @Security Timestamp
|
||||
// @Router /ai/agents/plugins/install [post]
|
||||
func (b *BaseApi) InstallAgentMarketPlugin(c *gin.Context) {
|
||||
var req dto.AgentPluginMarketInstallReq
|
||||
if err := helper.CheckBindAndValidate(&req, c); err != nil {
|
||||
return
|
||||
}
|
||||
if err := agentService.InstallMarketPlugin(req); err != nil {
|
||||
helper.BadRequest(c, err)
|
||||
return
|
||||
}
|
||||
helper.Success(c)
|
||||
}
|
||||
|
||||
// @Tags AI
|
||||
// @Summary Operate an OpenClaw plugin
|
||||
// @Accept json
|
||||
// @Param request body dto.AgentPluginOperateReq true "request"
|
||||
// @Success 200
|
||||
// @Security ApiKeyAuth
|
||||
// @Security Timestamp
|
||||
// @Router /ai/agents/plugins/operate [post]
|
||||
func (b *BaseApi) OperateAgentPlugin(c *gin.Context) {
|
||||
var req dto.AgentPluginOperateReq
|
||||
if err := helper.CheckBindAndValidate(&req, c); err != nil {
|
||||
return
|
||||
}
|
||||
if err := agentService.OperatePlugin(req); err != nil {
|
||||
helper.BadRequest(c, err)
|
||||
return
|
||||
}
|
||||
helper.Success(c)
|
||||
}
|
||||
|
||||
// @Tags AI
|
||||
// @Summary Login Agent Weixin channel
|
||||
// @Accept json
|
||||
|
||||
@@ -881,6 +881,26 @@ func (b *BaseApi) ComposeUpdate(c *gin.Context) {
|
||||
helper.Success(c)
|
||||
}
|
||||
|
||||
// @Tags Container Compose
|
||||
// @Summary Pin compose
|
||||
// @Accept json
|
||||
// @Param request body dto.ComposePin true "request"
|
||||
// @Success 200
|
||||
// @Security ApiKeyAuth
|
||||
// @Security Timestamp
|
||||
// @Router /containers/compose/pin [post]
|
||||
func (b *BaseApi) ComposePin(c *gin.Context) {
|
||||
var req dto.ComposePin
|
||||
if err := helper.CheckBindAndValidate(&req, c); err != nil {
|
||||
return
|
||||
}
|
||||
if err := containerService.ComposePin(req); err != nil {
|
||||
helper.InternalServer(c, err)
|
||||
return
|
||||
}
|
||||
helper.Success(c)
|
||||
}
|
||||
|
||||
// @Tags Container Compose
|
||||
// @Summary Load compose environment variables
|
||||
// @Accept json
|
||||
|
||||
@@ -42,6 +42,7 @@ var (
|
||||
fileShareService = service.NewIFileShareService()
|
||||
sshService = service.NewISSHService()
|
||||
firewallService = service.NewIFirewallService()
|
||||
forwardingService = service.NewIForwardingService()
|
||||
iptablesService = service.NewIIptablesService()
|
||||
monitorService = service.NewIMonitorService()
|
||||
systemService = service.NewISystemService()
|
||||
@@ -60,6 +61,7 @@ var (
|
||||
websiteDnsAccountService = service.NewIWebsiteDnsAccountService()
|
||||
websiteSSLService = service.NewIWebsiteSSLService()
|
||||
websiteAcmeAccountService = service.NewIWebsiteAcmeAccountService()
|
||||
websiteTemplateService = service.NewIWebsiteTemplateService()
|
||||
|
||||
nginxService = service.NewINginxService()
|
||||
|
||||
|
||||
@@ -20,7 +20,15 @@ func (b *BaseApi) LoadFirewallBaseInfo(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
data, err := firewallService.LoadBaseInfo(req.Name)
|
||||
var (
|
||||
data dto.FirewallBaseInfo
|
||||
err error
|
||||
)
|
||||
if req.Name == "forward" {
|
||||
data, err = forwardingService.LoadBaseInfo()
|
||||
} else {
|
||||
data, err = firewallService.LoadBaseInfo(req.Name)
|
||||
}
|
||||
if err != nil {
|
||||
helper.InternalServer(c, err)
|
||||
return
|
||||
@@ -43,7 +51,21 @@ func (b *BaseApi) SearchFirewallRule(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
total, list, err := firewallService.SearchWithPage(req)
|
||||
var (
|
||||
total int64
|
||||
list interface{}
|
||||
err error
|
||||
)
|
||||
if req.Type == "forward" {
|
||||
total, list, err = forwardingService.SearchWithPage(dto.ForwardRuleSearch{
|
||||
PageInfo: req.PageInfo,
|
||||
Info: req.Info,
|
||||
Status: req.Status,
|
||||
Strategy: req.Strategy,
|
||||
})
|
||||
} else {
|
||||
total, list, err = firewallService.SearchWithPage(req)
|
||||
}
|
||||
if err != nil {
|
||||
helper.InternalServer(c, err)
|
||||
return
|
||||
@@ -116,7 +138,7 @@ func (b *BaseApi) OperateForwardRule(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if err := firewallService.OperateForwardRule(req); err != nil {
|
||||
if err := forwardingService.Operate(req); err != nil {
|
||||
helper.InternalServer(c, err)
|
||||
return
|
||||
}
|
||||
@@ -313,7 +335,13 @@ func (b *BaseApi) OperateFilterChain(c *gin.Context) {
|
||||
if err := helper.CheckBindAndValidate(&req, c); err != nil {
|
||||
return
|
||||
}
|
||||
if err := iptablesService.Operate(req); err != nil {
|
||||
var err error
|
||||
if req.Operate == "init-forward" {
|
||||
err = forwardingService.Enable()
|
||||
} else {
|
||||
err = iptablesService.Operate(req)
|
||||
}
|
||||
if err != nil {
|
||||
helper.InternalServer(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
package v2
|
||||
|
||||
import (
|
||||
"io"
|
||||
|
||||
"github.com/1Panel-dev/1Panel/agent/app/api/v2/helper"
|
||||
"github.com/1Panel-dev/1Panel/agent/app/dto"
|
||||
"github.com/1Panel-dev/1Panel/agent/app/dto/request"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// @Tags Website Template
|
||||
// @Summary Page website templates
|
||||
// @Accept json
|
||||
// @Param request body request.WebsiteTemplateSearch true "request"
|
||||
// @Success 200 {object} dto.PageResult
|
||||
// @Security ApiKeyAuth
|
||||
// @Security Timestamp
|
||||
// @Router /websites/templates/search [post]
|
||||
func (b *BaseApi) PageWebsiteTemplate(c *gin.Context) {
|
||||
var req request.WebsiteTemplateSearch
|
||||
if err := helper.CheckBindAndValidate(&req, c); err != nil {
|
||||
return
|
||||
}
|
||||
total, templates, err := websiteTemplateService.PageTemplate(req)
|
||||
if err != nil {
|
||||
helper.InternalServer(c, err)
|
||||
return
|
||||
}
|
||||
helper.SuccessWithData(c, dto.PageResult{
|
||||
Total: total,
|
||||
Items: templates,
|
||||
})
|
||||
}
|
||||
|
||||
// @Tags Website Template
|
||||
// @Summary Create website template
|
||||
// @Accept json
|
||||
// @Param request body request.WebsiteTemplateCreate true "request"
|
||||
// @Success 200
|
||||
// @Security ApiKeyAuth
|
||||
// @Security Timestamp
|
||||
// @Router /websites/templates [post]
|
||||
// @x-panel-log {"bodyKeys":["name"],"paramKeys":[],"BeforeFunctions":[],"formatZH":"创建网站模板 [name]","formatEN":"Create website template [name]"}
|
||||
func (b *BaseApi) CreateWebsiteTemplate(c *gin.Context) {
|
||||
var req request.WebsiteTemplateCreate
|
||||
if err := helper.CheckBindAndValidate(&req, c); err != nil {
|
||||
return
|
||||
}
|
||||
if err := websiteTemplateService.CreateTemplate(req); err != nil {
|
||||
helper.InternalServer(c, err)
|
||||
return
|
||||
}
|
||||
helper.Success(c)
|
||||
}
|
||||
|
||||
// @Tags Website Template
|
||||
// @Summary Update website template
|
||||
// @Accept json
|
||||
// @Param request body request.WebsiteTemplateUpdate true "request"
|
||||
// @Success 200
|
||||
// @Security ApiKeyAuth
|
||||
// @Security Timestamp
|
||||
// @Router /websites/templates/update [post]
|
||||
// @x-panel-log {"bodyKeys":["name"],"paramKeys":[],"BeforeFunctions":[],"formatZH":"更新网站模板 [name]","formatEN":"Update website template [name]"}
|
||||
func (b *BaseApi) UpdateWebsiteTemplate(c *gin.Context) {
|
||||
var req request.WebsiteTemplateUpdate
|
||||
if err := helper.CheckBindAndValidate(&req, c); err != nil {
|
||||
return
|
||||
}
|
||||
if err := websiteTemplateService.UpdateTemplate(req); err != nil {
|
||||
helper.InternalServer(c, err)
|
||||
return
|
||||
}
|
||||
helper.Success(c)
|
||||
}
|
||||
|
||||
// @Tags Website Template
|
||||
// @Summary Delete website template
|
||||
// @Accept json
|
||||
// @Param request body dto.OperateByID true "request"
|
||||
// @Success 200
|
||||
// @Security ApiKeyAuth
|
||||
// @Security Timestamp
|
||||
// @Router /websites/templates/del [post]
|
||||
// @x-panel-log {"bodyKeys":["id"],"paramKeys":[],"BeforeFunctions":[{"input_column":"id","input_value":"id","isList":false,"db":"website_templates","output_column":"name","output_value":"name"}],"formatZH":"删除网站模板 [name]","formatEN":"Delete website template [name]"}
|
||||
func (b *BaseApi) DeleteWebsiteTemplate(c *gin.Context) {
|
||||
var req dto.OperateByID
|
||||
if err := helper.CheckBindAndValidate(&req, c); err != nil {
|
||||
return
|
||||
}
|
||||
if err := websiteTemplateService.DeleteTemplate(req.ID); err != nil {
|
||||
helper.InternalServer(c, err)
|
||||
return
|
||||
}
|
||||
helper.Success(c)
|
||||
}
|
||||
|
||||
// @Tags Website Template
|
||||
// @Summary Get website template
|
||||
// @Accept json
|
||||
// @Param request body dto.OperateByID true "request"
|
||||
// @Success 200 {object} response.WebsiteTemplateDTO
|
||||
// @Security ApiKeyAuth
|
||||
// @Security Timestamp
|
||||
// @Router /websites/templates/get [post]
|
||||
func (b *BaseApi) GetWebsiteTemplate(c *gin.Context) {
|
||||
var req dto.OperateByID
|
||||
if err := helper.CheckBindAndValidate(&req, c); err != nil {
|
||||
return
|
||||
}
|
||||
template, err := websiteTemplateService.GetTemplate(req.ID)
|
||||
if err != nil {
|
||||
helper.InternalServer(c, err)
|
||||
return
|
||||
}
|
||||
helper.SuccessWithData(c, template)
|
||||
}
|
||||
|
||||
// @Tags Website Template
|
||||
// @Summary Upload website template zip
|
||||
// @Accept multipart/form-data
|
||||
// @Param file formData file true "file"
|
||||
// @Success 200
|
||||
// @Security ApiKeyAuth
|
||||
// @Security Timestamp
|
||||
// @Router /websites/templates/upload [post]
|
||||
func (b *BaseApi) UploadTemplateZip(c *gin.Context) {
|
||||
fileHeader, err := c.FormFile("file")
|
||||
if err != nil {
|
||||
helper.BadRequest(c, err)
|
||||
return
|
||||
}
|
||||
file, err := fileHeader.Open()
|
||||
if err != nil {
|
||||
helper.InternalServer(c, err)
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
content, err := io.ReadAll(file)
|
||||
if err != nil {
|
||||
helper.InternalServer(c, err)
|
||||
return
|
||||
}
|
||||
filePath, variables, err := websiteTemplateService.SaveUploadZip(fileHeader.Filename, content)
|
||||
if err != nil {
|
||||
helper.InternalServer(c, err)
|
||||
return
|
||||
}
|
||||
helper.SuccessWithData(c, gin.H{"filePath": filePath, "variables": variables})
|
||||
}
|
||||
|
||||
// @Tags Website Template
|
||||
// @Summary Preview website template
|
||||
// @Accept json
|
||||
// @Param request body request.WebsitePreviewReq true "request"
|
||||
// @Success 200 {object} response.WebsitePreviewDTO
|
||||
// @Security ApiKeyAuth
|
||||
// @Security Timestamp
|
||||
// @Router /websites/templates/preview [post]
|
||||
func (b *BaseApi) PreviewWebsiteTemplate(c *gin.Context) {
|
||||
var req request.WebsitePreviewReq
|
||||
if err := helper.CheckBindAndValidate(&req, c); err != nil {
|
||||
return
|
||||
}
|
||||
preview, err := websiteTemplateService.Preview(req)
|
||||
if err != nil {
|
||||
helper.InternalServer(c, err)
|
||||
return
|
||||
}
|
||||
helper.SuccessWithData(c, preview)
|
||||
}
|
||||
|
||||
// @Tags Website Template
|
||||
// @Summary Page website template outputs
|
||||
// @Accept json
|
||||
// @Param request body request.WebsiteTemplateOutputSearch true "request"
|
||||
// @Success 200 {object} dto.PageResult
|
||||
// @Security ApiKeyAuth
|
||||
// @Security Timestamp
|
||||
// @Router /websites/templates/outputs/search [post]
|
||||
func (b *BaseApi) PageWebsiteTemplateOutput(c *gin.Context) {
|
||||
var req request.WebsiteTemplateOutputSearch
|
||||
if err := helper.CheckBindAndValidate(&req, c); err != nil {
|
||||
return
|
||||
}
|
||||
total, outputs, err := websiteTemplateService.PageOutput(req)
|
||||
if err != nil {
|
||||
helper.InternalServer(c, err)
|
||||
return
|
||||
}
|
||||
helper.SuccessWithData(c, dto.PageResult{
|
||||
Total: total,
|
||||
Items: outputs,
|
||||
})
|
||||
}
|
||||
|
||||
// @Tags Website Template
|
||||
// @Summary Create website template output
|
||||
// @Accept json
|
||||
// @Param request body request.WebsiteTemplateOutputCreate true "request"
|
||||
// @Success 200
|
||||
// @Security ApiKeyAuth
|
||||
// @Security Timestamp
|
||||
// @Router /websites/templates/outputs [post]
|
||||
// @x-panel-log {"bodyKeys":["name"],"paramKeys":[],"BeforeFunctions":[],"formatZH":"生成模板产物 [name]","formatEN":"Generate template output [name]"}
|
||||
func (b *BaseApi) CreateWebsiteTemplateOutput(c *gin.Context) {
|
||||
var req request.WebsiteTemplateOutputCreate
|
||||
if err := helper.CheckBindAndValidate(&req, c); err != nil {
|
||||
return
|
||||
}
|
||||
if err := websiteTemplateService.CreateOutput(req); err != nil {
|
||||
helper.InternalServer(c, err)
|
||||
return
|
||||
}
|
||||
helper.Success(c)
|
||||
}
|
||||
|
||||
// @Tags Website Template
|
||||
// @Summary Delete website template output
|
||||
// @Accept json
|
||||
// @Param request body dto.OperateByID true "request"
|
||||
// @Success 200
|
||||
// @Security ApiKeyAuth
|
||||
// @Security Timestamp
|
||||
// @Router /websites/templates/outputs/del [post]
|
||||
// @x-panel-log {"bodyKeys":["id"],"paramKeys":[],"BeforeFunctions":[{"input_column":"id","input_value":"id","isList":false,"db":"website_template_outputs","output_column":"name","output_value":"name"}],"formatZH":"删除模板产物 [name]","formatEN":"Delete template output [name]"}
|
||||
func (b *BaseApi) DeleteWebsiteTemplateOutput(c *gin.Context) {
|
||||
var req dto.OperateByID
|
||||
if err := helper.CheckBindAndValidate(&req, c); err != nil {
|
||||
return
|
||||
}
|
||||
if err := websiteTemplateService.DeleteOutput(req.ID); err != nil {
|
||||
helper.InternalServer(c, err)
|
||||
return
|
||||
}
|
||||
helper.Success(c)
|
||||
}
|
||||
|
||||
// @Tags Website Template
|
||||
// @Summary Get website template output
|
||||
// @Accept json
|
||||
// @Param request body dto.OperateByID true "request"
|
||||
// @Success 200 {object} response.WebsiteTemplateOutputDTO
|
||||
// @Security ApiKeyAuth
|
||||
// @Security Timestamp
|
||||
// @Router /websites/templates/outputs/get [post]
|
||||
func (b *BaseApi) GetWebsiteTemplateOutput(c *gin.Context) {
|
||||
var req dto.OperateByID
|
||||
if err := helper.CheckBindAndValidate(&req, c); err != nil {
|
||||
return
|
||||
}
|
||||
output, err := websiteTemplateService.GetOutput(req.ID)
|
||||
if err != nil {
|
||||
helper.InternalServer(c, err)
|
||||
return
|
||||
}
|
||||
helper.SuccessWithData(c, output)
|
||||
}
|
||||
+79
-27
@@ -322,29 +322,31 @@ type AgentAccountModelDeleteReq struct {
|
||||
}
|
||||
|
||||
type AgentAccountCreateReq struct {
|
||||
Provider string `json:"provider" validate:"required"`
|
||||
Name string `json:"name" validate:"required"`
|
||||
APIKey string `json:"apiKey" validate:"required"`
|
||||
RememberAPIKey bool `json:"rememberApiKey"`
|
||||
BaseURL string `json:"baseURL"`
|
||||
Models []AgentAccountModel `json:"models"`
|
||||
APIType string `json:"apiType" validate:"required"`
|
||||
AuthMode string `json:"authMode"`
|
||||
VerifyModel string `json:"verifyModel"`
|
||||
Remark string `json:"remark"`
|
||||
Provider string `json:"provider" validate:"required"`
|
||||
Name string `json:"name" validate:"required"`
|
||||
APIKey string `json:"apiKey" validate:"required"`
|
||||
RememberAPIKey bool `json:"rememberApiKey"`
|
||||
BaseURL string `json:"baseURL"`
|
||||
Models []AgentAccountModel `json:"models"`
|
||||
APIType string `json:"apiType" validate:"required"`
|
||||
AuthMode string `json:"authMode"`
|
||||
VerifyModel string `json:"verifyModel"`
|
||||
ValidateAvailability *bool `json:"validateAvailability"`
|
||||
Remark string `json:"remark"`
|
||||
}
|
||||
|
||||
type AgentAccountUpdateReq struct {
|
||||
ID uint `json:"id" validate:"required"`
|
||||
Name string `json:"name" validate:"required"`
|
||||
APIKey string `json:"apiKey" validate:"required"`
|
||||
RememberAPIKey bool `json:"rememberApiKey"`
|
||||
BaseURL string `json:"baseURL"`
|
||||
APIType string `json:"apiType" validate:"required"`
|
||||
AuthMode string `json:"authMode"`
|
||||
VerifyModel string `json:"verifyModel"`
|
||||
Remark string `json:"remark"`
|
||||
SyncAgents bool `json:"syncAgents"`
|
||||
ID uint `json:"id" validate:"required"`
|
||||
Name string `json:"name" validate:"required"`
|
||||
APIKey string `json:"apiKey" validate:"required"`
|
||||
RememberAPIKey bool `json:"rememberApiKey"`
|
||||
BaseURL string `json:"baseURL"`
|
||||
APIType string `json:"apiType" validate:"required"`
|
||||
AuthMode string `json:"authMode"`
|
||||
VerifyModel string `json:"verifyModel"`
|
||||
ValidateAvailability *bool `json:"validateAvailability"`
|
||||
Remark string `json:"remark"`
|
||||
SyncAgents bool `json:"syncAgents"`
|
||||
}
|
||||
|
||||
type AgentAccountVerifyReq struct {
|
||||
@@ -363,6 +365,8 @@ type AgentAccountDeleteReq struct {
|
||||
type AgentAccountSearch struct {
|
||||
PageInfo
|
||||
Provider string `json:"provider"`
|
||||
APIType string `json:"apiType"`
|
||||
TextOnly bool `json:"textOnly"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
@@ -394,11 +398,13 @@ type ProviderModelInfo struct {
|
||||
}
|
||||
|
||||
type ProviderAPIInfo struct {
|
||||
APIType string `json:"apiType"`
|
||||
BaseURL string `json:"baseUrl"`
|
||||
EditableBaseURL bool `json:"editableBaseUrl"`
|
||||
DefaultAuthMode string `json:"defaultAuthMode"`
|
||||
AuthModes []string `json:"authModes"`
|
||||
APIType string `json:"apiType"`
|
||||
BaseURL string `json:"baseUrl"`
|
||||
EditableBaseURL bool `json:"editableBaseUrl"`
|
||||
SupportsModelDiscovery bool `json:"supportsModelDiscovery"`
|
||||
DefaultAuthMode string `json:"defaultAuthMode"`
|
||||
AuthModes []string `json:"authModes"`
|
||||
Models []ProviderModelInfo `json:"models"`
|
||||
}
|
||||
|
||||
type ProviderInfo struct {
|
||||
@@ -598,6 +604,52 @@ type AgentPluginStatus struct {
|
||||
Upgradable bool `json:"upgradable"`
|
||||
}
|
||||
|
||||
type AgentPluginsReq struct {
|
||||
AgentID uint `json:"agentId" validate:"required"`
|
||||
}
|
||||
|
||||
type AgentPluginSearchReq struct {
|
||||
AgentID uint `json:"agentId" validate:"required"`
|
||||
Keyword string `json:"keyword" validate:"required,max=100"`
|
||||
Limit int `json:"limit" validate:"omitempty,min=1,max=100"`
|
||||
}
|
||||
|
||||
type AgentPluginMarketInstallReq struct {
|
||||
AgentID uint `json:"agentId" validate:"required"`
|
||||
Package string `json:"package" validate:"required,max=200"`
|
||||
Version string `json:"version" validate:"required,max=100"`
|
||||
TaskID string `json:"taskID" validate:"required"`
|
||||
}
|
||||
|
||||
type AgentPluginOperateReq struct {
|
||||
AgentID uint `json:"agentId" validate:"required"`
|
||||
PluginID string `json:"pluginId" validate:"required,max=200"`
|
||||
Operate string `json:"operate" validate:"required,oneof=enable disable update uninstall"`
|
||||
TaskID string `json:"taskID" validate:"required"`
|
||||
}
|
||||
|
||||
type AgentPluginItem struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Version string `json:"version"`
|
||||
Origin string `json:"origin"`
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
type AgentPluginSearchItem struct {
|
||||
Package string `json:"package"`
|
||||
PluginID string `json:"pluginId"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Version string `json:"version"`
|
||||
Channel string `json:"channel"`
|
||||
VerificationTier string `json:"verificationTier"`
|
||||
Categories []string `json:"categories"`
|
||||
Official bool `json:"official"`
|
||||
Downloads int64 `json:"downloads"`
|
||||
Score float64 `json:"score"`
|
||||
}
|
||||
|
||||
type AgentDiscordConfigUpdateReq struct {
|
||||
AgentID uint `json:"agentId" validate:"required"`
|
||||
Enabled bool `json:"enabled"`
|
||||
@@ -678,9 +730,9 @@ type AgentSecurityConfig struct {
|
||||
|
||||
type AgentOtherConfigUpdateReq struct {
|
||||
AgentID uint `json:"agentId" validate:"required"`
|
||||
UserTimezone string `json:"userTimezone" validate:"required"`
|
||||
UserTimezone string `json:"userTimezone"`
|
||||
BrowserEnabled bool `json:"browserEnabled"`
|
||||
NPMRegistry string `json:"npmRegistry" validate:"required"`
|
||||
NPMRegistry string `json:"npmRegistry"`
|
||||
DashboardUsername string `json:"dashboardUsername"`
|
||||
DashboardPassword string `json:"dashboardPassword"`
|
||||
}
|
||||
|
||||
@@ -113,8 +113,10 @@ type DiskDTO struct {
|
||||
|
||||
type AlertLogSearch struct {
|
||||
PageInfo
|
||||
Count uint `json:"count"`
|
||||
Status string `json:"status"`
|
||||
Count uint `json:"count"`
|
||||
Status string `json:"status"`
|
||||
StartTime time.Time `json:"startTime"`
|
||||
EndTime time.Time `json:"endTime"`
|
||||
}
|
||||
|
||||
type AlertLogDTO struct {
|
||||
|
||||
@@ -2,7 +2,8 @@ package dto
|
||||
|
||||
type SearchWithPage struct {
|
||||
PageInfo
|
||||
Info string `json:"info"`
|
||||
Info string `json:"info"`
|
||||
ExcludeAppStore bool `json:"excludeAppStore"`
|
||||
}
|
||||
|
||||
type SearchPageWithType struct {
|
||||
|
||||
@@ -300,6 +300,7 @@ type ComposeInfo struct {
|
||||
ConfigFile string `json:"configFile"`
|
||||
Workdir string `json:"workdir"`
|
||||
ComposeFileExists bool `json:"composeFileExists"`
|
||||
IsPinned bool `json:"isPinned"`
|
||||
Path string `json:"path"`
|
||||
Containers []ComposeContainer `json:"containers"`
|
||||
Env string `json:"env"`
|
||||
@@ -314,6 +315,7 @@ type ComposeContainer struct {
|
||||
type ComposeCreate struct {
|
||||
TaskID string `json:"taskID"`
|
||||
Name string `json:"name"`
|
||||
DirName string `json:"dirName"`
|
||||
From string `json:"from" validate:"required,oneof=edit path template"`
|
||||
File string `json:"file"`
|
||||
Path string `json:"path"`
|
||||
@@ -337,6 +339,10 @@ type ComposeUpdate struct {
|
||||
Env string `json:"env"`
|
||||
ForcePull bool `json:"forcePull"`
|
||||
}
|
||||
type ComposePin struct {
|
||||
Name string `json:"name" validate:"required"`
|
||||
IsPinned bool `json:"isPinned"`
|
||||
}
|
||||
type ComposeLogClean struct {
|
||||
Name string `json:"name" validate:"required"`
|
||||
Path string `json:"path" validate:"required"`
|
||||
|
||||
@@ -197,6 +197,7 @@ type SearchRecord struct {
|
||||
|
||||
type Record struct {
|
||||
ID uint `json:"id"`
|
||||
CronjobID uint `json:"cronjobID"`
|
||||
TaskID string `json:"taskID"`
|
||||
StartTime string `json:"startTime"`
|
||||
Records string `json:"records"`
|
||||
|
||||
@@ -35,19 +35,6 @@ type PortRuleOperate struct {
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
type ForwardRuleOperate struct {
|
||||
ForceDelete bool `json:"forceDelete"`
|
||||
Rules []struct {
|
||||
Operation string `json:"operation" validate:"required,oneof=add remove"`
|
||||
Num string `json:"num"`
|
||||
Protocol string `json:"protocol" validate:"required,oneof=tcp udp tcp/udp"`
|
||||
Interface string `json:"interface"`
|
||||
Port string `json:"port" validate:"required"`
|
||||
TargetIP string `json:"targetIP"`
|
||||
TargetPort string `json:"targetPort" validate:"required"`
|
||||
} `json:"rules"`
|
||||
}
|
||||
|
||||
type UpdateFirewallDescription struct {
|
||||
Type string `json:"type"`
|
||||
Chain string `json:"chain"`
|
||||
@@ -86,7 +73,7 @@ type BatchRuleOperate struct {
|
||||
}
|
||||
|
||||
type IptablesOp struct {
|
||||
Name string `json:"name" validate:"required,oneof=1PANEL_INPUT 1PANEL_OUTPUT 1PANEL_BASIC"`
|
||||
Name string `json:"name" validate:"required,oneof=1PANEL_INPUT 1PANEL_OUTPUT 1PANEL_BASIC 1PANEL_FORWARD"`
|
||||
Operate string `json:"operate" validate:"required,oneof=init-base init-forward init-advance bind-base unbind-base bind unbind"`
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
package dto
|
||||
|
||||
type ForwardRuleSearch struct {
|
||||
PageInfo
|
||||
Info string `json:"info"`
|
||||
Status string `json:"status"`
|
||||
Strategy string `json:"strategy"`
|
||||
}
|
||||
|
||||
// ForwardRule preserves the existing firewall search response shape while
|
||||
// keeping forwarding data separate from the filter client model.
|
||||
type ForwardRule struct {
|
||||
ID uint `json:"id"`
|
||||
Chain string `json:"chain"`
|
||||
Family string `json:"family"`
|
||||
Address string `json:"address"`
|
||||
Port string `json:"port"`
|
||||
Protocol string `json:"protocol"`
|
||||
Strategy string `json:"strategy"`
|
||||
|
||||
Num string `json:"num"`
|
||||
TargetIP string `json:"targetIP"`
|
||||
TargetPort string `json:"targetPort"`
|
||||
Interface string `json:"interface"`
|
||||
|
||||
UsedStatus string `json:"usedStatus"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
type ForwardRuleOperate struct {
|
||||
ForceDelete bool `json:"forceDelete"`
|
||||
Rules []ForwardRuleOperation `json:"rules"`
|
||||
}
|
||||
|
||||
type ForwardRuleOperation struct {
|
||||
Operation string `json:"operation" validate:"required,oneof=add remove"`
|
||||
Num string `json:"num"`
|
||||
Protocol string `json:"protocol" validate:"required,oneof=tcp udp tcp/udp"`
|
||||
Interface string `json:"interface"`
|
||||
Port string `json:"port" validate:"required"`
|
||||
TargetIP string `json:"targetIP"`
|
||||
TargetPort string `json:"targetPort" validate:"required"`
|
||||
}
|
||||
@@ -34,6 +34,8 @@ type WebsiteCreate struct {
|
||||
|
||||
SiteDir string `json:"siteDir"`
|
||||
|
||||
TemplateOutputID uint `json:"templateOutputID"`
|
||||
|
||||
RuntimeConfig
|
||||
FtpConfig
|
||||
DataBaseConfig
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
package request
|
||||
|
||||
import (
|
||||
"github.com/1Panel-dev/1Panel/agent/app/dto"
|
||||
)
|
||||
|
||||
type WebsiteTemplateSearch struct {
|
||||
dto.PageInfo
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
}
|
||||
|
||||
type WebsiteTemplateCreate struct {
|
||||
Name string `json:"name" validate:"required"`
|
||||
Type string `json:"type" validate:"required,oneof=single multi"`
|
||||
Content string `json:"content"`
|
||||
FilePath string `json:"filePath"`
|
||||
Variables string `json:"variables"`
|
||||
Remark string `json:"remark"`
|
||||
}
|
||||
|
||||
type WebsiteTemplateUpdate struct {
|
||||
ID uint `json:"id" validate:"required"`
|
||||
Name string `json:"name" validate:"required"`
|
||||
Type string `json:"type" validate:"required,oneof=single multi"`
|
||||
Content string `json:"content"`
|
||||
FilePath string `json:"filePath"`
|
||||
Variables string `json:"variables"`
|
||||
Remark string `json:"remark"`
|
||||
}
|
||||
|
||||
type WebsiteTemplateOutputSearch struct {
|
||||
dto.PageInfo
|
||||
TemplateID uint `json:"templateID"`
|
||||
}
|
||||
|
||||
type WebsiteTemplateOutputCreate struct {
|
||||
TemplateID uint `json:"templateID" validate:"required"`
|
||||
Name string `json:"name" validate:"required"`
|
||||
VariableValues map[string]string `json:"variableValues"`
|
||||
}
|
||||
|
||||
type WebsitePreviewReq struct {
|
||||
TemplateID uint `json:"templateID" validate:"required"`
|
||||
VariableValues map[string]string `json:"variableValues"`
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package response
|
||||
|
||||
import (
|
||||
"github.com/1Panel-dev/1Panel/agent/app/model"
|
||||
)
|
||||
|
||||
type WebsiteTemplateDTO struct {
|
||||
model.WebsiteTemplate
|
||||
}
|
||||
|
||||
type WebsiteTemplateOutputDTO struct {
|
||||
model.WebsiteTemplateOutput
|
||||
TemplateName string `json:"templateName"`
|
||||
}
|
||||
|
||||
type WebsitePreviewDTO struct {
|
||||
HTML string `json:"html"`
|
||||
}
|
||||
@@ -31,4 +31,5 @@ type BackupRecord struct {
|
||||
Status string `json:"status"`
|
||||
Message string `json:"message"`
|
||||
Description string `json:"description"`
|
||||
Args string `gorm:"not null;default:''" json:"args"`
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ type ComposeTemplate struct {
|
||||
type Compose struct {
|
||||
BaseModel
|
||||
|
||||
Name string `json:"name"`
|
||||
Path string `json:"path"`
|
||||
Name string `json:"name"`
|
||||
Path string `json:"path"`
|
||||
IsPinned bool `json:"isPinned"`
|
||||
}
|
||||
|
||||
@@ -8,4 +8,6 @@ type Ftp struct {
|
||||
Status string `gorm:"not null" json:"status"`
|
||||
Path string `gorm:"not null" json:"path"`
|
||||
Description string `gorm:"not null" json:"description"`
|
||||
UID uint `gorm:"column:uid;not null;default:1000" json:"-"`
|
||||
GID uint `gorm:"column:gid;not null;default:1000" json:"-"`
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
package model
|
||||
|
||||
type WebsiteTemplate struct {
|
||||
BaseModel
|
||||
Name string `gorm:"not null" json:"name"`
|
||||
Type string `gorm:"not null" json:"type"` // single | multi
|
||||
Content string `gorm:"type:longtext" json:"content"`
|
||||
FilePath string `json:"filePath"`
|
||||
Variables string `gorm:"type:text" json:"variables"`
|
||||
Remark string `json:"remark"`
|
||||
}
|
||||
|
||||
func (w WebsiteTemplate) TableName() string {
|
||||
return "website_templates"
|
||||
}
|
||||
|
||||
type WebsiteTemplateOutput struct {
|
||||
BaseModel
|
||||
Name string `gorm:"not null" json:"name"`
|
||||
TemplateID uint `gorm:"not null" json:"templateID"`
|
||||
TemplateType string `json:"templateType"`
|
||||
VariableValues string `gorm:"type:text" json:"variableValues"`
|
||||
OutputPath string `json:"outputPath"`
|
||||
}
|
||||
|
||||
func (w WebsiteTemplateOutput) TableName() string {
|
||||
return "website_template_outputs"
|
||||
}
|
||||
+132
-13
@@ -10,8 +10,10 @@ type APIConfig struct {
|
||||
APIType string
|
||||
BaseURL string
|
||||
EditableBaseURL bool
|
||||
DiscoverModels bool
|
||||
DefaultAuthMode string
|
||||
AuthModes []string
|
||||
Models []Model
|
||||
}
|
||||
|
||||
const (
|
||||
@@ -27,6 +29,7 @@ type Model struct {
|
||||
type Meta struct {
|
||||
Key string
|
||||
DisplayName string
|
||||
DisplayNameKey string
|
||||
Sort uint
|
||||
DefaultAPIType string
|
||||
APIConfigs []APIConfig
|
||||
@@ -37,26 +40,26 @@ type Meta struct {
|
||||
var catalog = map[string]Meta{
|
||||
"custom": {
|
||||
Key: "custom", DisplayName: "Custom", Sort: 10, DefaultAPIType: "openai-completions", EnvKey: "CUSTOM_API_KEY",
|
||||
APIConfigs: editableAPIConfigs("openai-completions", "openai-responses", "anthropic-messages"),
|
||||
APIConfigs: editableAPIConfigs(true, "openai-completions", "openai-responses", "anthropic-messages", "openai-images"),
|
||||
},
|
||||
"ollama": {
|
||||
Key: "ollama", DisplayName: "Ollama", Sort: 15, DefaultAPIType: "openai-responses",
|
||||
APIConfigs: editableAPIConfigs("openai-responses", "openai-completions"),
|
||||
APIConfigs: editableAPIConfigs(false, "openai-responses", "openai-completions"),
|
||||
},
|
||||
"vllm": {
|
||||
Key: "vllm", DisplayName: "vLLM", Sort: 20, DefaultAPIType: "openai-completions", EnvKey: "VLLM_API_KEY",
|
||||
APIConfigs: editableAPIConfigs("openai-completions", "openai-responses", "anthropic-messages"),
|
||||
APIConfigs: editableAPIConfigs(false, "openai-completions", "openai-responses", "anthropic-messages", "openai-images"),
|
||||
},
|
||||
"deepseek": {
|
||||
Key: "deepseek", DisplayName: "DeepSeek", Sort: 25, DefaultAPIType: "openai-completions", EnvKey: "DEEPSEEK_API_KEY",
|
||||
APIConfigs: []APIConfig{
|
||||
{APIType: "openai-completions", BaseURL: "https://api.deepseek.com/v1"},
|
||||
{APIType: "openai-completions", BaseURL: "https://api.deepseek.com"},
|
||||
anthropicAPIConfig("https://api.deepseek.com/anthropic", AuthModeXAPIKey),
|
||||
},
|
||||
Models: []Model{{ID: "deepseek-v4-flash", Name: "deepseek-v4-flash"}, {ID: "deepseek-v4-pro", Name: "deepseek-v4-pro"}},
|
||||
},
|
||||
"bailian-coding-plan": {
|
||||
Key: "bailian-coding-plan", DisplayName: "阿里云百炼 Coding Plan", Sort: 30, DefaultAPIType: "openai-completions", EnvKey: "QWEN_API_KEY",
|
||||
Key: "bailian-coding-plan", DisplayNameKey: "AIProviderBailianCodingPlan", Sort: 30, DefaultAPIType: "openai-completions", EnvKey: "QWEN_API_KEY",
|
||||
APIConfigs: []APIConfig{
|
||||
{APIType: "openai-completions", BaseURL: "https://coding.dashscope.aliyuncs.com/v1"},
|
||||
anthropicAPIConfig("https://coding.dashscope.aliyuncs.com/apps/anthropic", AuthModeBearer),
|
||||
@@ -75,7 +78,7 @@ var catalog = map[string]Meta{
|
||||
},
|
||||
},
|
||||
"ark-coding-plan": {
|
||||
Key: "ark-coding-plan", DisplayName: "方舟 Coding Plan", Sort: 35, DefaultAPIType: "openai-completions", EnvKey: "ARK_API_KEY",
|
||||
Key: "ark-coding-plan", DisplayNameKey: "AIProviderArkCodingPlan", Sort: 35, DefaultAPIType: "openai-completions", EnvKey: "ARK_API_KEY",
|
||||
APIConfigs: []APIConfig{
|
||||
{APIType: "openai-completions", BaseURL: "https://ark.cn-beijing.volces.com/api/coding/v3"},
|
||||
anthropicAPIConfig("https://ark.cn-beijing.volces.com/api/coding", AuthModeBearer),
|
||||
@@ -88,14 +91,18 @@ var catalog = map[string]Meta{
|
||||
},
|
||||
"zai": {
|
||||
Key: "zai", DisplayName: "Z.ai", Sort: 40, DefaultAPIType: "openai-completions", EnvKey: "ZAI_API_KEY",
|
||||
APIConfigs: []APIConfig{{APIType: "openai-completions", BaseURL: "https://open.bigmodel.cn/api/paas/v4", EditableBaseURL: true}},
|
||||
Models: []Model{{ID: "glm-5", Name: "GLM-5"}, {ID: "glm-4.7", Name: "GLM-4.7"}, {ID: "glm-4.7-flash", Name: "GLM-4.7-Flash"}, {ID: "glm-4.7-flashx", Name: "GLM-4.7-FlashX"}},
|
||||
APIConfigs: []APIConfig{
|
||||
{APIType: "openai-completions", BaseURL: "https://open.bigmodel.cn/api/paas/v4", EditableBaseURL: true},
|
||||
{APIType: "openai-images", BaseURL: "https://open.bigmodel.cn/api/paas/v4", EditableBaseURL: true},
|
||||
},
|
||||
Models: []Model{{ID: "glm-5", Name: "GLM-5"}, {ID: "glm-4.7", Name: "GLM-4.7"}, {ID: "glm-4.7-flash", Name: "GLM-4.7-Flash"}, {ID: "glm-4.7-flashx", Name: "GLM-4.7-FlashX"}},
|
||||
},
|
||||
"minimax": {
|
||||
Key: "minimax", DisplayName: "MiniMax (CN)", Sort: 45, DefaultAPIType: "anthropic-messages", EnvKey: "MINIMAX_API_KEY",
|
||||
APIConfigs: []APIConfig{
|
||||
anthropicAPIConfig("https://api.minimaxi.com/anthropic", AuthModeXAPIKey, AuthModeBearer),
|
||||
{APIType: "openai-completions", BaseURL: "https://api.minimaxi.com/v1"},
|
||||
{APIType: "minimax-images", BaseURL: "https://api.minimaxi.com"},
|
||||
},
|
||||
Models: []Model{{ID: "MiniMax-M3", Name: "MiniMax M3"}, {ID: "MiniMax-M2.7", Name: "MiniMax M2.7"}, {ID: "MiniMax-M2.7-highspeed", Name: "MiniMax M2.7 highspeed"}},
|
||||
},
|
||||
@@ -103,9 +110,10 @@ var catalog = map[string]Meta{
|
||||
Key: "xiaomi", DisplayName: "Xiaomi", Sort: 46, DefaultAPIType: "openai-completions", EnvKey: "XIAOMI_API_KEY",
|
||||
APIConfigs: []APIConfig{
|
||||
{APIType: "openai-completions", BaseURL: "https://api.xiaomimimo.com/v1"},
|
||||
{APIType: "openai-responses", BaseURL: "https://api.xiaomimimo.com/v1"},
|
||||
anthropicAPIConfig("https://api.xiaomimimo.com/anthropic", AuthModeBearer),
|
||||
},
|
||||
Models: []Model{{ID: "mimo-v2-flash", Name: "Xiaomi MiMo V2 Flash"}, {ID: "mimo-v2-pro", Name: "Xiaomi MiMo V2 Pro"}, {ID: "mimo-v2-omni", Name: "Xiaomi MiMo V2 Omni"}},
|
||||
Models: []Model{{ID: "mimo-v2.5", Name: "Xiaomi MiMo V2.5"}, {ID: "mimo-v2.5-pro", Name: "Xiaomi MiMo V2.5 Pro"}},
|
||||
},
|
||||
"kimi": {
|
||||
Key: "kimi", DisplayName: "Kimi (CN)", Sort: 50, DefaultAPIType: "openai-completions", EnvKey: "KIMI_API_KEY",
|
||||
@@ -122,13 +130,17 @@ var catalog = map[string]Meta{
|
||||
APIConfigs: []APIConfig{
|
||||
{APIType: "openai-responses", BaseURL: "https://api.openai.com/v1"},
|
||||
{APIType: "openai-completions", BaseURL: "https://api.openai.com/v1"},
|
||||
{APIType: "openai-images", BaseURL: "https://api.openai.com/v1"},
|
||||
},
|
||||
Models: []Model{{ID: "gpt-5.4", Name: "gpt-5.4"}, {ID: "gpt-5.4-pro", Name: "gpt-5.4-pro"}, {ID: "gpt-5.4-mini", Name: "gpt-5.4-mini"}, {ID: "gpt-5.4-nano", Name: "gpt-5.4-nano"}},
|
||||
},
|
||||
"openrouter": {
|
||||
Key: "openrouter", DisplayName: "OpenRouter", Sort: 56, DefaultAPIType: "openai-completions", EnvKey: "OPENROUTER_API_KEY",
|
||||
APIConfigs: []APIConfig{{APIType: "openai-completions", BaseURL: "https://openrouter.ai/api/v1"}},
|
||||
Models: []Model{{ID: "openrouter/free", Name: "openrouter/free"}, {ID: "openrouter/auto", Name: "openrouter/auto"}},
|
||||
APIConfigs: []APIConfig{
|
||||
{APIType: "openai-completions", BaseURL: "https://openrouter.ai/api/v1"},
|
||||
{APIType: "openrouter-images", BaseURL: "https://openrouter.ai"},
|
||||
},
|
||||
Models: []Model{{ID: "openrouter/free", Name: "openrouter/free"}, {ID: "openrouter/auto", Name: "openrouter/auto"}},
|
||||
},
|
||||
"anthropic": {
|
||||
Key: "anthropic", DisplayName: "Anthropic", Sort: 60, DefaultAPIType: "anthropic-messages", EnvKey: "ANTHROPIC_API_KEY",
|
||||
@@ -145,9 +157,62 @@ var catalog = map[string]Meta{
|
||||
APIConfigs: []APIConfig{{APIType: "openai-completions", BaseURL: "https://api.moonshot.ai/v1"}},
|
||||
Models: []Model{{ID: "kimi-k2.5", Name: "Kimi K2.5"}, {ID: "kimi-k2-0905-preview", Name: "Kimi K2 0905 Preview"}, {ID: "kimi-k2-thinking", Name: "Kimi K2 Thinking"}},
|
||||
},
|
||||
"bailian": {
|
||||
Key: "bailian", DisplayNameKey: "AIProviderBailian", Sort: 31, DefaultAPIType: "openai-completions", EnvKey: "DASHSCOPE_API_KEY",
|
||||
APIConfigs: []APIConfig{
|
||||
{
|
||||
APIType: "openai-completions", BaseURL: "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||
DiscoverModels: true,
|
||||
Models: []Model{{ID: "qwen3.7-plus", Name: "qwen3.7-plus"}, {ID: "qwen3.6-plus", Name: "qwen3.6-plus"}, {ID: "qwen3.6-flash", Name: "qwen3.6-flash"}},
|
||||
},
|
||||
{
|
||||
APIType: "openai-responses", BaseURL: "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||
DiscoverModels: true,
|
||||
Models: []Model{{ID: "qwen3.7-plus", Name: "qwen3.7-plus"}, {ID: "qwen3.6-plus", Name: "qwen3.6-plus"}, {ID: "qwen3.6-flash", Name: "qwen3.6-flash"}},
|
||||
},
|
||||
{
|
||||
APIType: "anthropic-messages", BaseURL: "https://dashscope.aliyuncs.com/apps/anthropic",
|
||||
DefaultAuthMode: AuthModeBearer,
|
||||
AuthModes: []string{AuthModeBearer},
|
||||
Models: []Model{{ID: "qwen3.7-plus", Name: "qwen3.7-plus"}, {ID: "qwen3.6-plus", Name: "qwen3.6-plus"}, {ID: "qwen3.6-flash", Name: "qwen3.6-flash"}},
|
||||
},
|
||||
{
|
||||
APIType: "dashscope-images", BaseURL: "https://dashscope.aliyuncs.com",
|
||||
Models: []Model{
|
||||
{ID: "qwen-image-2.0-pro", Name: "qwen-image-2.0-pro"},
|
||||
{ID: "qwen-image-2.0", Name: "qwen-image-2.0"},
|
||||
{ID: "wan2.7-image-pro", Name: "wan2.7-image-pro"},
|
||||
{ID: "wan2.7-image", Name: "wan2.7-image"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"ark": {
|
||||
Key: "ark", DisplayNameKey: "AIProviderArk", Sort: 36, DefaultAPIType: "openai-completions", EnvKey: "ARK_API_KEY",
|
||||
APIConfigs: []APIConfig{
|
||||
{
|
||||
APIType: "openai-completions", BaseURL: "https://ark.cn-beijing.volces.com/api/v3",
|
||||
DiscoverModels: true,
|
||||
Models: []Model{{ID: "doubao-seed-2-0-pro-260215", Name: "doubao-seed-2-0-pro-260215"}, {ID: "doubao-seed-2-0-lite-260215", Name: "doubao-seed-2-0-lite-260215"}},
|
||||
},
|
||||
{
|
||||
APIType: "openai-responses", BaseURL: "https://ark.cn-beijing.volces.com/api/v3",
|
||||
DiscoverModels: true,
|
||||
Models: []Model{{ID: "doubao-seed-2-0-pro-260215", Name: "doubao-seed-2-0-pro-260215"}, {ID: "doubao-seed-2-0-lite-260215", Name: "doubao-seed-2-0-lite-260215"}},
|
||||
},
|
||||
{
|
||||
APIType: "openai-images", BaseURL: "https://ark.cn-beijing.volces.com/api/v3",
|
||||
Models: []Model{
|
||||
{ID: "doubao-seedream-5-0-260128", Name: "doubao-seedream-5-0-260128"},
|
||||
{ID: "doubao-seedream-5-0-lite-260128", Name: "doubao-seedream-5-0-lite-260128"},
|
||||
{ID: "doubao-seedream-4-5-251128", Name: "doubao-seedream-4-5-251128"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
func editableAPIConfigs(apiTypes ...string) []APIConfig {
|
||||
func editableAPIConfigs(discoverModels bool, apiTypes ...string) []APIConfig {
|
||||
configs := make([]APIConfig, 0, len(apiTypes))
|
||||
for _, apiType := range apiTypes {
|
||||
if apiType == "anthropic-messages" {
|
||||
@@ -156,7 +221,11 @@ func editableAPIConfigs(apiTypes ...string) []APIConfig {
|
||||
configs = append(configs, config)
|
||||
continue
|
||||
}
|
||||
configs = append(configs, APIConfig{APIType: apiType, EditableBaseURL: true})
|
||||
configs = append(configs, APIConfig{
|
||||
APIType: apiType,
|
||||
EditableBaseURL: true,
|
||||
DiscoverModels: discoverModels && (apiType == "openai-completions" || apiType == "openai-responses"),
|
||||
})
|
||||
}
|
||||
return configs
|
||||
}
|
||||
@@ -203,12 +272,38 @@ func FindAPIConfig(key, apiType string) (APIConfig, bool) {
|
||||
}
|
||||
for _, config := range meta.APIConfigs {
|
||||
if config.APIType == target {
|
||||
config.AuthModes = append([]string(nil), config.AuthModes...)
|
||||
config.Models = append([]Model(nil), config.Models...)
|
||||
return config, true
|
||||
}
|
||||
}
|
||||
return APIConfig{}, false
|
||||
}
|
||||
|
||||
func DefaultModels(key, apiType string) []Model {
|
||||
meta, ok := catalog[key]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
target := strings.TrimSpace(apiType)
|
||||
if target == "" {
|
||||
target = meta.DefaultAPIType
|
||||
}
|
||||
for _, config := range meta.APIConfigs {
|
||||
if config.APIType != target {
|
||||
continue
|
||||
}
|
||||
if len(config.Models) > 0 {
|
||||
return append([]Model(nil), config.Models...)
|
||||
}
|
||||
if IsImageAPIType(config.APIType) {
|
||||
return nil
|
||||
}
|
||||
break
|
||||
}
|
||||
return append([]Model(nil), meta.Models...)
|
||||
}
|
||||
|
||||
func ResolveAuthMode(provider, apiType, requested string) (string, error) {
|
||||
config, ok := FindAPIConfig(provider, apiType)
|
||||
if !ok {
|
||||
@@ -264,6 +359,9 @@ func ResolveBaseURL(key, apiType, requested string) (string, error) {
|
||||
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
|
||||
return "", fmt.Errorf("invalid base url")
|
||||
}
|
||||
if key == "custom" && IsImageAPIType(config.APIType) {
|
||||
return baseURL, nil
|
||||
}
|
||||
parsed.Path = normalizeEndpointPath(config.APIType, parsed.Path)
|
||||
parsed.RawQuery = ""
|
||||
parsed.Fragment = ""
|
||||
@@ -271,6 +369,9 @@ func ResolveBaseURL(key, apiType, requested string) (string, error) {
|
||||
}
|
||||
|
||||
func normalizeEndpointPath(apiType, value string) string {
|
||||
if IsImageAPIType(apiType) {
|
||||
return strings.TrimRight(value, "/")
|
||||
}
|
||||
path := strings.TrimRight(value, "/")
|
||||
suffixes := []string{}
|
||||
switch apiType {
|
||||
@@ -289,6 +390,15 @@ func normalizeEndpointPath(apiType, value string) string {
|
||||
return path
|
||||
}
|
||||
|
||||
func IsImageAPIType(apiType string) bool {
|
||||
switch apiType {
|
||||
case "openai-images", "dashscope-images", "minimax-images", "openrouter-images":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func EnvKey(key string) string {
|
||||
meta, ok := catalog[key]
|
||||
if !ok {
|
||||
@@ -305,6 +415,14 @@ func DisplayName(key string) string {
|
||||
return meta.DisplayName
|
||||
}
|
||||
|
||||
func DisplayNameKey(key string) string {
|
||||
meta, ok := catalog[key]
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return meta.DisplayNameKey
|
||||
}
|
||||
|
||||
func NormalizeModelID(provider, modelID string) string {
|
||||
target := strings.TrimLeft(strings.TrimSpace(modelID), "/")
|
||||
for _, prefix := range legacyModelPrefixes[provider] {
|
||||
@@ -344,6 +462,7 @@ func cloneMeta(meta Meta) Meta {
|
||||
for index, config := range meta.APIConfigs {
|
||||
clone.APIConfigs[index] = config
|
||||
clone.APIConfigs[index].AuthModes = append([]string(nil), config.AuthModes...)
|
||||
clone.APIConfigs[index].Models = append([]Model(nil), config.Models...)
|
||||
}
|
||||
clone.Models = append([]Model(nil), meta.Models...)
|
||||
return clone
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
@@ -54,11 +55,20 @@ func buildModelDiscoveryURL(baseURL string) string {
|
||||
base = normalizeEndpointPath(apiType, base)
|
||||
}
|
||||
switch {
|
||||
case strings.HasSuffix(base, "/v1/models"):
|
||||
case strings.HasSuffix(base, "/models"):
|
||||
return base
|
||||
case strings.HasSuffix(base, "/v1"):
|
||||
case hasAPIVersionSuffix(base):
|
||||
return base + "/models"
|
||||
default:
|
||||
return base + "/v1/models"
|
||||
}
|
||||
}
|
||||
|
||||
func hasAPIVersionSuffix(value string) bool {
|
||||
segment := value[strings.LastIndex(value, "/")+1:]
|
||||
if len(segment) < 2 || segment[0] != 'v' {
|
||||
return false
|
||||
}
|
||||
_, err := strconv.Atoi(segment[1:])
|
||||
return err == nil
|
||||
}
|
||||
|
||||
@@ -23,6 +23,9 @@ func BuildOpenClawProviderPatch(provider, modelName, apiType, authMode, baseURL,
|
||||
if _, ok := FindAPIConfig(provider, resolvedAPIType); !ok {
|
||||
resolvedAPIType = DefaultAPIType(provider)
|
||||
}
|
||||
if IsImageAPIType(resolvedAPIType) {
|
||||
return nil, fmt.Errorf("api type %s does not support text generation", resolvedAPIType)
|
||||
}
|
||||
resolvedAuthMode, err := ResolveAuthMode(provider, resolvedAPIType, authMode)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -64,7 +64,10 @@ func VerifyAccount(provider, apiType, authMode, baseURL, apiKey, model string) e
|
||||
}
|
||||
|
||||
func BuildVerifyRequest(provider, apiType, authMode, baseURL, apiKey, model string) VerifyRequest {
|
||||
baseURL = strings.TrimRight(strings.TrimSpace(baseURL), "/")
|
||||
baseURL = strings.TrimSpace(baseURL)
|
||||
if provider != "custom" || !IsImageAPIType(apiType) {
|
||||
baseURL = strings.TrimRight(baseURL, "/")
|
||||
}
|
||||
headers := map[string]string{"Content-Type": "application/json"}
|
||||
request := VerifyRequest{Method: http.MethodPost, Headers: headers}
|
||||
|
||||
@@ -78,6 +81,28 @@ func BuildVerifyRequest(provider, apiType, authMode, baseURL, apiKey, model stri
|
||||
}
|
||||
|
||||
switch apiType {
|
||||
case "openai-images":
|
||||
request.URL = imageVerifyURL(provider, baseURL, "/images/generations")
|
||||
headers["Authorization"] = "Bearer " + apiKey
|
||||
request.Body = mustJSON(map[string]interface{}{"model": model, "prompt": "test", "n": 1, "response_format": "url"})
|
||||
case "dashscope-images":
|
||||
request.URL = imageVerifyURL(provider, baseURL, "/api/v1/services/aigc/multimodal-generation/generation")
|
||||
headers["Authorization"] = "Bearer " + apiKey
|
||||
request.Body = mustJSON(map[string]interface{}{
|
||||
"model": model,
|
||||
"input": map[string]interface{}{"messages": []map[string]interface{}{
|
||||
{"role": "user", "content": []map[string]string{{"text": "test"}}},
|
||||
}},
|
||||
"parameters": map[string]interface{}{"n": 1},
|
||||
})
|
||||
case "minimax-images":
|
||||
request.URL = imageVerifyURL(provider, baseURL, "/v1/image_generation")
|
||||
headers["Authorization"] = "Bearer " + apiKey
|
||||
request.Body = mustJSON(map[string]interface{}{"model": model, "prompt": "test", "n": 1, "response_format": "url"})
|
||||
case "openrouter-images":
|
||||
request.URL = imageVerifyURL(provider, baseURL, "/api/v1/images")
|
||||
headers["Authorization"] = "Bearer " + apiKey
|
||||
request.Body = mustJSON(map[string]interface{}{"model": model, "prompt": "test", "n": 1, "response_format": "url"})
|
||||
case "anthropic-messages":
|
||||
request.URL = baseURL + "/v1/messages"
|
||||
if authMode == AuthModeBearer {
|
||||
@@ -106,6 +131,13 @@ func BuildVerifyRequest(provider, apiType, authMode, baseURL, apiKey, model stri
|
||||
return request
|
||||
}
|
||||
|
||||
func imageVerifyURL(provider, baseURL, endpoint string) string {
|
||||
if provider == "custom" || strings.HasSuffix(strings.ToLower(baseURL), endpoint) {
|
||||
return baseURL
|
||||
}
|
||||
return baseURL + endpoint
|
||||
}
|
||||
|
||||
func verifyHTTPError(statusCode int, body []byte) string {
|
||||
message := strings.TrimSpace(string(body))
|
||||
var payload verifyErrorResponse
|
||||
|
||||
@@ -17,7 +17,7 @@ type IAgentAccountRepo interface {
|
||||
Save(account *model.AgentAccount) error
|
||||
DeleteByID(id uint) error
|
||||
List(opts ...DBOption) ([]model.AgentAccount, error)
|
||||
CountByProviders(providers []string) (map[string]int64, error)
|
||||
CountTextByProviders(providers []string) (map[string]int64, error)
|
||||
}
|
||||
|
||||
func NewIAgentAccountRepo() IAgentAccountRepo {
|
||||
@@ -67,7 +67,7 @@ func (a AgentAccountRepo) List(opts ...DBOption) ([]model.AgentAccount, error) {
|
||||
return accounts, nil
|
||||
}
|
||||
|
||||
func (a AgentAccountRepo) CountByProviders(providers []string) (map[string]int64, error) {
|
||||
func (a AgentAccountRepo) CountTextByProviders(providers []string) (map[string]int64, error) {
|
||||
normalizedProviders := normalizeProviders(providers)
|
||||
counts := make(map[string]int64, len(normalizedProviders))
|
||||
for _, provider := range normalizedProviders {
|
||||
@@ -86,6 +86,7 @@ func (a AgentAccountRepo) CountByProviders(providers []string) (map[string]int64
|
||||
Model(&model.AgentAccount{}).
|
||||
Select("provider, COUNT(*) as count").
|
||||
Where("provider IN ?", normalizedProviders).
|
||||
Where("api_type NOT LIKE ?", "%-images").
|
||||
Group("provider").
|
||||
Scan(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -49,6 +49,12 @@ func WithByName(name string) DBOption {
|
||||
}
|
||||
}
|
||||
|
||||
func WithByPath(path string) DBOption {
|
||||
return func(g *gorm.DB) *gorm.DB {
|
||||
return g.Where("path = ?", path)
|
||||
}
|
||||
}
|
||||
|
||||
func WithByAddr(addr string) DBOption {
|
||||
return func(g *gorm.DB) *gorm.DB {
|
||||
return g.Where("addr = ?", addr)
|
||||
@@ -94,6 +100,18 @@ func WithByProvider(provider string) DBOption {
|
||||
}
|
||||
}
|
||||
|
||||
func WithByAPIType(apiType string) DBOption {
|
||||
return func(g *gorm.DB) *gorm.DB {
|
||||
return g.Where("api_type = ?", apiType)
|
||||
}
|
||||
}
|
||||
|
||||
func WithTextAPIType() DBOption {
|
||||
return func(g *gorm.DB) *gorm.DB {
|
||||
return g.Where("api_type NOT LIKE ?", "%-images")
|
||||
}
|
||||
}
|
||||
|
||||
func WithByModel(model string) DBOption {
|
||||
return func(g *gorm.DB) *gorm.DB {
|
||||
if len(model) == 0 {
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"github.com/1Panel-dev/1Panel/agent/app/model"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type IWebsiteTemplateRepo interface {
|
||||
Page(page, size int, opts ...DBOption) (int64, []model.WebsiteTemplate, error)
|
||||
GetFirst(opts ...DBOption) (*model.WebsiteTemplate, error)
|
||||
List(opts ...DBOption) ([]model.WebsiteTemplate, error)
|
||||
Create(template *model.WebsiteTemplate) error
|
||||
Save(template *model.WebsiteTemplate) error
|
||||
DeleteBy(opts ...DBOption) error
|
||||
WithName(name string) DBOption
|
||||
WithType(templateType string) DBOption
|
||||
}
|
||||
|
||||
func NewIWebsiteTemplateRepo() IWebsiteTemplateRepo {
|
||||
return &WebsiteTemplateRepo{}
|
||||
}
|
||||
|
||||
type WebsiteTemplateRepo struct {
|
||||
}
|
||||
|
||||
func (w *WebsiteTemplateRepo) WithName(name string) DBOption {
|
||||
return func(db *gorm.DB) *gorm.DB {
|
||||
return db.Where("name like ?", "%"+name+"%")
|
||||
}
|
||||
}
|
||||
|
||||
func (w *WebsiteTemplateRepo) WithType(templateType string) DBOption {
|
||||
return func(db *gorm.DB) *gorm.DB {
|
||||
return db.Where("type = ?", templateType)
|
||||
}
|
||||
}
|
||||
|
||||
func (w *WebsiteTemplateRepo) Page(page, size int, opts ...DBOption) (int64, []model.WebsiteTemplate, error) {
|
||||
var templates []model.WebsiteTemplate
|
||||
db := getDb(opts...).Model(&model.WebsiteTemplate{})
|
||||
count := int64(0)
|
||||
db = db.Count(&count)
|
||||
err := db.Limit(size).Offset(size * (page - 1)).Find(&templates).Error
|
||||
return count, templates, err
|
||||
}
|
||||
|
||||
func (w *WebsiteTemplateRepo) GetFirst(opts ...DBOption) (*model.WebsiteTemplate, error) {
|
||||
var template model.WebsiteTemplate
|
||||
db := getDb(opts...).Model(&model.WebsiteTemplate{})
|
||||
if err := db.First(&template).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &template, nil
|
||||
}
|
||||
|
||||
func (w *WebsiteTemplateRepo) List(opts ...DBOption) ([]model.WebsiteTemplate, error) {
|
||||
var templates []model.WebsiteTemplate
|
||||
err := getDb(opts...).Model(&model.WebsiteTemplate{}).Find(&templates).Error
|
||||
return templates, err
|
||||
}
|
||||
|
||||
func (w *WebsiteTemplateRepo) Create(template *model.WebsiteTemplate) error {
|
||||
return getDb().Create(template).Error
|
||||
}
|
||||
|
||||
func (w *WebsiteTemplateRepo) Save(template *model.WebsiteTemplate) error {
|
||||
return getDb().Save(template).Error
|
||||
}
|
||||
|
||||
func (w *WebsiteTemplateRepo) DeleteBy(opts ...DBOption) error {
|
||||
return getDb(opts...).Delete(&model.WebsiteTemplate{}).Error
|
||||
}
|
||||
|
||||
type IWebsiteTemplateOutputRepo interface {
|
||||
Page(page, size int, opts ...DBOption) (int64, []model.WebsiteTemplateOutput, error)
|
||||
GetFirst(opts ...DBOption) (*model.WebsiteTemplateOutput, error)
|
||||
List(opts ...DBOption) ([]model.WebsiteTemplateOutput, error)
|
||||
Create(output *model.WebsiteTemplateOutput) error
|
||||
Save(output *model.WebsiteTemplateOutput) error
|
||||
DeleteBy(opts ...DBOption) error
|
||||
WithByTemplateID(templateID uint) DBOption
|
||||
}
|
||||
|
||||
func NewIWebsiteTemplateOutputRepo() IWebsiteTemplateOutputRepo {
|
||||
return &WebsiteTemplateOutputRepo{}
|
||||
}
|
||||
|
||||
type WebsiteTemplateOutputRepo struct {
|
||||
}
|
||||
|
||||
func (w *WebsiteTemplateOutputRepo) WithByTemplateID(templateID uint) DBOption {
|
||||
return func(db *gorm.DB) *gorm.DB {
|
||||
return db.Where("template_id = ?", templateID)
|
||||
}
|
||||
}
|
||||
|
||||
func (w *WebsiteTemplateOutputRepo) Page(page, size int, opts ...DBOption) (int64, []model.WebsiteTemplateOutput, error) {
|
||||
var outputs []model.WebsiteTemplateOutput
|
||||
db := getDb(opts...).Model(&model.WebsiteTemplateOutput{})
|
||||
count := int64(0)
|
||||
db = db.Count(&count)
|
||||
err := db.Limit(size).Offset(size * (page - 1)).Find(&outputs).Error
|
||||
return count, outputs, err
|
||||
}
|
||||
|
||||
func (w *WebsiteTemplateOutputRepo) GetFirst(opts ...DBOption) (*model.WebsiteTemplateOutput, error) {
|
||||
var output model.WebsiteTemplateOutput
|
||||
db := getDb(opts...).Model(&model.WebsiteTemplateOutput{})
|
||||
if err := db.First(&output).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &output, nil
|
||||
}
|
||||
|
||||
func (w *WebsiteTemplateOutputRepo) List(opts ...DBOption) ([]model.WebsiteTemplateOutput, error) {
|
||||
var outputs []model.WebsiteTemplateOutput
|
||||
err := getDb(opts...).Model(&model.WebsiteTemplateOutput{}).Find(&outputs).Error
|
||||
return outputs, err
|
||||
}
|
||||
|
||||
func (w *WebsiteTemplateOutputRepo) Create(output *model.WebsiteTemplateOutput) error {
|
||||
return getDb().Create(output).Error
|
||||
}
|
||||
|
||||
func (w *WebsiteTemplateOutputRepo) Save(output *model.WebsiteTemplateOutput) error {
|
||||
return getDb().Save(output).Error
|
||||
}
|
||||
|
||||
func (w *WebsiteTemplateOutputRepo) DeleteBy(opts ...DBOption) error {
|
||||
return getDb(opts...).Delete(&model.WebsiteTemplateOutput{}).Error
|
||||
}
|
||||
+69
-23
@@ -105,6 +105,10 @@ type IAgentService interface {
|
||||
UpgradePlugin(req dto.AgentPluginUpgradeReq) error
|
||||
UninstallPlugin(req dto.AgentPluginUninstallReq) error
|
||||
CheckPlugin(req dto.AgentPluginCheckReq) (*dto.AgentPluginStatus, error)
|
||||
ListPlugins(req dto.AgentPluginsReq) ([]dto.AgentPluginItem, error)
|
||||
SearchPlugins(req dto.AgentPluginSearchReq) ([]dto.AgentPluginSearchItem, error)
|
||||
InstallMarketPlugin(req dto.AgentPluginMarketInstallReq) error
|
||||
OperatePlugin(req dto.AgentPluginOperateReq) error
|
||||
ApproveChannelPairing(req dto.AgentChannelPairingApproveReq) error
|
||||
}
|
||||
|
||||
@@ -177,7 +181,7 @@ func (a AgentService) Create(req dto.AgentCreateReq) (*dto.AgentItem, error) {
|
||||
var allowedOrigins []string
|
||||
var account *model.AgentAccount
|
||||
var installHooks *appInstallHooks
|
||||
var hermesAuth hermesDashboardAuth
|
||||
var dashboardAuth agentDashboardAuth
|
||||
|
||||
if agentType == constant.AppOpenclaw || agentType == constant.AppHermesAgent {
|
||||
if req.AccountID == 0 {
|
||||
@@ -222,15 +226,17 @@ func (a AgentService) Create(req dto.AgentCreateReq) (*dto.AgentItem, error) {
|
||||
},
|
||||
}
|
||||
} else if agentType == constant.AppHermesAgent {
|
||||
hermesAuth = normalizeHermesDashboardAuth(req.DashboardUsername, req.DashboardPassword)
|
||||
dashboardAuth = normalizeAgentDashboardAuth(req.DashboardUsername, req.DashboardPassword)
|
||||
installHooks = &appInstallHooks{
|
||||
AfterCopyData: func(appInstall *model.AppInstall) error {
|
||||
if err := prepareHermesInstallFiles(appInstall, account, storedModel); err != nil {
|
||||
return err
|
||||
}
|
||||
return writeHermesDashboardAuthEnv(path.Join(appInstall.GetPath(), ".env"), hermesAuth, false)
|
||||
return writeAgentDashboardAuthEnv(appInstall.GetEnvPath(), agentType, dashboardAuth, false)
|
||||
},
|
||||
}
|
||||
} else if agentType == constant.AppCopaw {
|
||||
dashboardAuth = normalizeAgentDashboardAuth(req.DashboardUsername, req.DashboardPassword)
|
||||
}
|
||||
|
||||
params := map[string]interface{}{
|
||||
@@ -250,9 +256,12 @@ func (a AgentService) Create(req dto.AgentCreateReq) (*dto.AgentItem, error) {
|
||||
params["API_KEY"] = apiKey
|
||||
params["OPENCLAW_GATEWAY_TOKEN"] = token
|
||||
}
|
||||
if agentType == constant.AppHermesAgent {
|
||||
params[hermesDashboardUsernameEnvKey] = hermesAuth.Username
|
||||
params[hermesDashboardPasswordEnvKey] = hermesAuth.Password
|
||||
if usernameKey, passwordKey, ok := agentDashboardAuthEnvKeys(agentType); ok {
|
||||
params[usernameKey] = dashboardAuth.Username
|
||||
params[passwordKey] = dashboardAuth.Password
|
||||
if agentType == constant.AppCopaw {
|
||||
params[qwenPawAuthEnabledEnvKey] = "true"
|
||||
}
|
||||
}
|
||||
|
||||
if req.EditCompose && strings.TrimSpace(req.DockerCompose) == "" {
|
||||
@@ -984,19 +993,28 @@ func (a AgentService) GetProviders() ([]dto.ProviderInfo, error) {
|
||||
}
|
||||
apiTypes := make([]dto.ProviderAPIInfo, 0, len(def.APIConfigs))
|
||||
for _, item := range def.APIConfigs {
|
||||
apiModels := make([]dto.ProviderModelInfo, 0, len(item.Models))
|
||||
for _, model := range item.Models {
|
||||
apiModels = append(apiModels, dto.ProviderModelInfo{
|
||||
ID: model.ID,
|
||||
Name: model.Name,
|
||||
})
|
||||
}
|
||||
apiTypes = append(apiTypes, dto.ProviderAPIInfo{
|
||||
APIType: item.APIType,
|
||||
BaseURL: item.BaseURL,
|
||||
EditableBaseURL: item.EditableBaseURL,
|
||||
DefaultAuthMode: item.DefaultAuthMode,
|
||||
AuthModes: item.AuthModes,
|
||||
APIType: item.APIType,
|
||||
BaseURL: item.BaseURL,
|
||||
EditableBaseURL: item.EditableBaseURL,
|
||||
SupportsModelDiscovery: item.DiscoverModels,
|
||||
DefaultAuthMode: item.DefaultAuthMode,
|
||||
AuthModes: item.AuthModes,
|
||||
Models: apiModels,
|
||||
})
|
||||
}
|
||||
baseURL, _ := providercatalog.DefaultBaseURL(key)
|
||||
providers = append(providers, dto.ProviderInfo{
|
||||
Sort: def.Sort,
|
||||
Provider: key,
|
||||
DisplayName: def.DisplayName,
|
||||
DisplayName: localizedAgentProviderName(key),
|
||||
BaseURL: baseURL,
|
||||
DefaultAPIType: def.DefaultAPIType,
|
||||
APITypes: apiTypes,
|
||||
@@ -1016,7 +1034,7 @@ func (a AgentService) CreateAccount(req dto.AgentAccountCreateReq) error {
|
||||
if err := ensureAgentAccountNameAvailable(provider, req.Name, 0); err != nil {
|
||||
return err
|
||||
}
|
||||
initialModels, err := buildInitialAgentAccountModels(&model.AgentAccount{Provider: provider}, req.Models)
|
||||
initialModels, err := buildInitialAgentAccountModels(&model.AgentAccount{Provider: provider, APIType: req.APIType}, req.Models)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -1024,7 +1042,8 @@ func (a AgentService) CreateAccount(req dto.AgentAccountCreateReq) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resolvedInput, err := resolveAgentAccountInput(provider, req.APIType, req.AuthMode, req.APIKey, req.BaseURL, verifyModel)
|
||||
validateAvailability := req.ValidateAvailability == nil || *req.ValidateAvailability
|
||||
resolvedInput, err := resolveAgentAccountInput(provider, req.APIType, req.AuthMode, req.APIKey, req.BaseURL, verifyModel, validateAvailability)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -1062,6 +1081,9 @@ func (a AgentService) UpdateAccount(req dto.AgentAccountUpdateReq) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if req.APIType != account.APIType {
|
||||
return buserr.WithDetail("ErrInvalidParams", "API type cannot be changed", nil)
|
||||
}
|
||||
provider := account.Provider
|
||||
if err := ensureAgentAccountNameAvailable(provider, req.Name, account.ID); err != nil {
|
||||
return err
|
||||
@@ -1078,7 +1100,8 @@ func (a AgentService) UpdateAccount(req dto.AgentAccountUpdateReq) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resolvedInput, err := resolveAgentAccountInput(provider, req.APIType, req.AuthMode, req.APIKey, req.BaseURL, verifyModel)
|
||||
validateAvailability := req.ValidateAvailability == nil || *req.ValidateAvailability
|
||||
resolvedInput, err := resolveAgentAccountInput(provider, req.APIType, req.AuthMode, req.APIKey, req.BaseURL, verifyModel, validateAvailability)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -1110,6 +1133,12 @@ func (a AgentService) PageAccounts(req dto.AgentAccountSearch) (int64, []dto.Age
|
||||
if strings.TrimSpace(req.Provider) != "" {
|
||||
opts = append(opts, repo.WithByProvider(req.Provider))
|
||||
}
|
||||
if apiType := strings.TrimSpace(req.APIType); apiType != "" {
|
||||
opts = append(opts, repo.WithByAPIType(apiType))
|
||||
}
|
||||
if req.TextOnly {
|
||||
opts = append(opts, repo.WithTextAPIType())
|
||||
}
|
||||
if strings.TrimSpace(req.Name) != "" {
|
||||
opts = append(opts, repo.WithByLikeName(req.Name))
|
||||
}
|
||||
@@ -1127,7 +1156,7 @@ func (a AgentService) PageAccounts(req dto.AgentAccountSearch) (int64, []dto.Age
|
||||
ID: item.ID,
|
||||
MasterAccountID: item.MasterAccountID,
|
||||
Provider: item.Provider,
|
||||
ProviderName: providercatalog.DisplayName(item.Provider),
|
||||
ProviderName: localizedAgentProviderName(item.Provider),
|
||||
Name: item.Name,
|
||||
APIKey: apiKey,
|
||||
RememberAPIKey: item.RememberAPIKey,
|
||||
@@ -1166,7 +1195,7 @@ func (a AgentService) PageAccounts(req dto.AgentAccountSearch) (int64, []dto.Age
|
||||
}
|
||||
|
||||
func (a AgentService) CountAccountsByProviders(req dto.AgentAccountProviderCountReq) (map[string]int64, error) {
|
||||
return agentAccountRepo.CountByProviders(req.Providers)
|
||||
return agentAccountRepo.CountTextByProviders(req.Providers)
|
||||
}
|
||||
|
||||
func (a AgentService) GetAccountModels(req dto.AgentAccountModelReq) ([]dto.AgentAccountModel, error) {
|
||||
@@ -1178,7 +1207,8 @@ func (a AgentService) GetAccountModels(req dto.AgentAccountModelReq) ([]dto.Agen
|
||||
}
|
||||
|
||||
func (a AgentService) DiscoverAccountModels(req dto.AgentAccountModelDiscoverReq) ([]dto.AgentAccountModel, error) {
|
||||
if req.APIType != "openai-completions" && req.APIType != "openai-responses" {
|
||||
config, ok := providercatalog.FindAPIConfig(req.Provider, req.APIType)
|
||||
if !ok || !config.DiscoverModels {
|
||||
return nil, buserr.New("ErrAgentAccountModelsRequired")
|
||||
}
|
||||
baseURL, err := providercatalog.ResolveBaseURL(req.Provider, req.APIType, req.BaseURL)
|
||||
@@ -1320,7 +1350,7 @@ func (a AgentService) SyncAgentsByAccount(account *model.AgentAccount) error {
|
||||
}
|
||||
|
||||
func (a AgentService) VerifyAccount(req dto.AgentAccountVerifyReq) error {
|
||||
_, err := resolveAgentAccountInput(req.Provider, req.APIType, req.AuthMode, req.APIKey, req.BaseURL, req.Model)
|
||||
_, err := resolveAgentAccountInput(req.Provider, req.APIType, req.AuthMode, req.APIKey, req.BaseURL, req.Model, true)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -1410,7 +1440,7 @@ func (a AgentService) GetOtherConfig(req dto.AgentIDReq) (*dto.AgentOtherConfig,
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
auth := readHermesDashboardAuthFromInstall(install)
|
||||
auth := readAgentDashboardAuthFromInstall(install, agent.AgentType)
|
||||
return &dto.AgentOtherConfig{
|
||||
UserTimezone: cfg.Timezone,
|
||||
BrowserEnabled: true,
|
||||
@@ -1419,6 +1449,13 @@ func (a AgentService) GetOtherConfig(req dto.AgentIDReq) (*dto.AgentOtherConfig,
|
||||
DashboardPassword: auth.Password,
|
||||
}, nil
|
||||
}
|
||||
if agent.AgentType == constant.AppCopaw {
|
||||
auth := readAgentDashboardAuthFromInstall(install, agent.AgentType)
|
||||
return &dto.AgentOtherConfig{
|
||||
DashboardUsername: auth.Username,
|
||||
DashboardPassword: auth.Password,
|
||||
}, nil
|
||||
}
|
||||
conf, err := readOpenclawConfig(agent.ConfigPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -1437,16 +1474,19 @@ func (a AgentService) UpdateOtherConfig(req dto.AgentOtherConfigUpdateReq) error
|
||||
return err
|
||||
}
|
||||
if agent.AgentType == constant.AppHermesAgent {
|
||||
if strings.TrimSpace(req.UserTimezone) == "" {
|
||||
return buserr.New("ErrInvalidParams")
|
||||
}
|
||||
account, err := agentAccountRepo.GetFirst(repo.WithByID(agent.AccountID))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
previousAuth := readHermesDashboardAuthFromInstall(install)
|
||||
nextAuth := normalizeHermesDashboardAuth(req.DashboardUsername, req.DashboardPassword)
|
||||
previousAuth := readAgentDashboardAuthFromInstall(install, agent.AgentType)
|
||||
nextAuth := normalizeAgentDashboardAuth(req.DashboardUsername, req.DashboardPassword)
|
||||
if err := writeHermesConfig(path.Dir(agent.ConfigPath), account, agent.Model, strings.TrimSpace(req.UserTimezone)); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writeHermesDashboardAuthEnv(path.Join(install.GetPath(), ".env"), nextAuth, true); err != nil {
|
||||
if err := writeAgentDashboardAuthEnv(install.GetEnvPath(), agent.AgentType, nextAuth, true); err != nil {
|
||||
return err
|
||||
}
|
||||
operate := constant.Restart
|
||||
@@ -1458,6 +1498,12 @@ func (a AgentService) UpdateOtherConfig(req dto.AgentOtherConfigUpdateReq) error
|
||||
Operate: operate,
|
||||
})
|
||||
}
|
||||
if agent.AgentType == constant.AppCopaw {
|
||||
return updateQwenPawDashboardAuth(install, normalizeAgentDashboardAuth(req.DashboardUsername, req.DashboardPassword))
|
||||
}
|
||||
if strings.TrimSpace(req.UserTimezone) == "" || strings.TrimSpace(req.NPMRegistry) == "" {
|
||||
return buserr.New("ErrInvalidParams")
|
||||
}
|
||||
if err := ensureContainerRunning(install.ContainerName); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/1Panel-dev/1Panel/agent/app/model"
|
||||
"github.com/1Panel-dev/1Panel/agent/buserr"
|
||||
"github.com/1Panel-dev/1Panel/agent/constant"
|
||||
)
|
||||
|
||||
type qwenPawAuthStatus struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
HasUsers bool `json:"has_users"`
|
||||
}
|
||||
|
||||
type qwenPawLoginResponse struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
|
||||
func updateQwenPawDashboardAuth(install *model.AppInstall, next agentDashboardAuth) error {
|
||||
if install == nil || install.ID == 0 {
|
||||
return buserr.New("ErrRecordNotFound")
|
||||
}
|
||||
current, err := readAgentDashboardAuthEnv(install.GetEnvPath(), constant.AppCopaw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if current == next {
|
||||
return writeAgentDashboardAuthEnv(install.GetEnvPath(), constant.AppCopaw, next, true)
|
||||
}
|
||||
if err := ensureContainerRunning(install.ContainerName); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
baseURL := fmt.Sprintf("http://127.0.0.1:%d/api/auth", install.HttpPort)
|
||||
var status qwenPawAuthStatus
|
||||
if _, err := requestQwenPawAuth(http.MethodGet, baseURL+"/status", nil, "", &status); err != nil {
|
||||
return buserr.WithMap("ErrQwenPawAuthRequest", map[string]interface{}{"err": err.Error()}, err)
|
||||
}
|
||||
if !status.Enabled {
|
||||
return buserr.New("ErrQwenPawAuthDisabled")
|
||||
}
|
||||
|
||||
if !status.HasUsers {
|
||||
payload := map[string]string{"username": next.Username, "password": next.Password}
|
||||
if _, err := requestQwenPawAuth(http.MethodPost, baseURL+"/register", payload, "", nil); err != nil {
|
||||
return buserr.WithMap("ErrQwenPawAuthRequest", map[string]interface{}{"err": err.Error()}, err)
|
||||
}
|
||||
} else {
|
||||
var login qwenPawLoginResponse
|
||||
payload := map[string]string{"username": current.Username, "password": current.Password}
|
||||
statusCode, err := requestQwenPawAuth(http.MethodPost, baseURL+"/login", payload, "", &login)
|
||||
if statusCode == http.StatusUnauthorized {
|
||||
return buserr.New("ErrQwenPawAuthOutOfSync")
|
||||
}
|
||||
if err != nil {
|
||||
return buserr.WithMap("ErrQwenPawAuthRequest", map[string]interface{}{"err": err.Error()}, err)
|
||||
}
|
||||
payload = map[string]string{"current_password": current.Password}
|
||||
if current.Username != next.Username {
|
||||
payload["new_username"] = next.Username
|
||||
}
|
||||
if current.Password != next.Password {
|
||||
payload["new_password"] = next.Password
|
||||
}
|
||||
if _, err := requestQwenPawAuth(http.MethodPost, baseURL+"/update-profile", payload, login.Token, nil); err != nil {
|
||||
return buserr.WithMap("ErrQwenPawAuthRequest", map[string]interface{}{"err": err.Error()}, err)
|
||||
}
|
||||
}
|
||||
return writeAgentDashboardAuthEnv(install.GetEnvPath(), constant.AppCopaw, next, true)
|
||||
}
|
||||
|
||||
func requestQwenPawAuth(method, reqURL string, payload interface{}, token string, result interface{}) (int, error) {
|
||||
var body io.Reader
|
||||
if payload != nil {
|
||||
data, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
body = bytes.NewReader(data)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
req, err := http.NewRequestWithContext(ctx, method, reqURL, body)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
resp, err := (&http.Client{Timeout: 10 * time.Second}).Do(req)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
data, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
if err != nil {
|
||||
return resp.StatusCode, err
|
||||
}
|
||||
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
|
||||
detail := strings.TrimSpace(string(data))
|
||||
var errorResponse struct {
|
||||
Detail string `json:"detail"`
|
||||
}
|
||||
if json.Unmarshal(data, &errorResponse) == nil && strings.TrimSpace(errorResponse.Detail) != "" {
|
||||
detail = strings.TrimSpace(errorResponse.Detail)
|
||||
}
|
||||
if detail == "" {
|
||||
detail = resp.Status
|
||||
}
|
||||
return resp.StatusCode, errors.New(detail)
|
||||
}
|
||||
if result != nil && len(data) > 0 {
|
||||
if err := json.Unmarshal(data, result); err != nil {
|
||||
return resp.StatusCode, err
|
||||
}
|
||||
}
|
||||
return resp.StatusCode, nil
|
||||
}
|
||||
@@ -19,13 +19,6 @@ import (
|
||||
|
||||
const hermesWorkspaceDir = "/opt/data/workspace"
|
||||
const hermesExecutablePath = "/opt/hermes/.venv/bin/hermes"
|
||||
const hermesDashboardUsernameEnvKey = "HERMES_DASHBOARD_USERNAME"
|
||||
const hermesDashboardPasswordEnvKey = "HERMES_DASHBOARD_PASSWORD"
|
||||
|
||||
type hermesDashboardAuth struct {
|
||||
Username string
|
||||
Password string
|
||||
}
|
||||
|
||||
type hermesConfig struct {
|
||||
Model hermesModelConfig `yaml:"model"`
|
||||
@@ -117,52 +110,6 @@ func prepareHermesInstallFiles(appInstall *model.AppInstall, account *model.Agen
|
||||
return files.NewFileOp().ChownR(dataDir, "1000", "1000", true)
|
||||
}
|
||||
|
||||
func normalizeHermesDashboardAuth(username, password string) hermesDashboardAuth {
|
||||
auth := hermesDashboardAuth{
|
||||
Username: strings.TrimSpace(username),
|
||||
Password: strings.TrimSpace(password),
|
||||
}
|
||||
if auth.Username == "" {
|
||||
auth.Username = "admin"
|
||||
}
|
||||
if auth.Password == "" {
|
||||
auth.Password = common.RandStr(8)
|
||||
}
|
||||
return auth
|
||||
}
|
||||
|
||||
func writeHermesDashboardAuthEnv(envPath string, auth hermesDashboardAuth, overwrite bool) error {
|
||||
return upsertAgentEnv(envPath, map[string]string{
|
||||
hermesDashboardUsernameEnvKey: auth.Username,
|
||||
hermesDashboardPasswordEnvKey: auth.Password,
|
||||
}, []string{
|
||||
hermesDashboardUsernameEnvKey,
|
||||
hermesDashboardPasswordEnvKey,
|
||||
}, overwrite)
|
||||
}
|
||||
|
||||
func readHermesDashboardAuthEnv(envPath string) (hermesDashboardAuth, error) {
|
||||
envMap, err := readAgentEnvMap(envPath)
|
||||
if err != nil {
|
||||
return hermesDashboardAuth{}, err
|
||||
}
|
||||
return hermesDashboardAuth{
|
||||
Username: strings.TrimSpace(envMap[hermesDashboardUsernameEnvKey]),
|
||||
Password: strings.TrimSpace(envMap[hermesDashboardPasswordEnvKey]),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func readHermesDashboardAuthFromInstall(appInstall *model.AppInstall) hermesDashboardAuth {
|
||||
if appInstall == nil || appInstall.ID == 0 {
|
||||
return hermesDashboardAuth{}
|
||||
}
|
||||
auth, err := readHermesDashboardAuthEnv(path.Join(appInstall.GetPath(), ".env"))
|
||||
if err != nil {
|
||||
return hermesDashboardAuth{}
|
||||
}
|
||||
return auth
|
||||
}
|
||||
|
||||
func readHermesConfig(configPath string) (*hermesConfig, error) {
|
||||
content, err := files.NewFileOp().GetContent(configPath)
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,315 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/1Panel-dev/1Panel/agent/app/dto"
|
||||
"github.com/1Panel-dev/1Panel/agent/app/model"
|
||||
"github.com/1Panel-dev/1Panel/agent/app/task"
|
||||
"github.com/1Panel-dev/1Panel/agent/buserr"
|
||||
"github.com/1Panel-dev/1Panel/agent/global"
|
||||
"github.com/1Panel-dev/1Panel/agent/i18n"
|
||||
"github.com/1Panel-dev/1Panel/agent/utils/cmd"
|
||||
"github.com/1Panel-dev/1Panel/agent/utils/compose"
|
||||
)
|
||||
|
||||
var (
|
||||
openclawPluginPackagePattern = regexp.MustCompile(`^(@[a-z0-9][a-z0-9._-]*/)?[a-z0-9][a-z0-9._-]*$`)
|
||||
openclawPluginVersionPattern = regexp.MustCompile(`^[0-9A-Za-z][0-9A-Za-z._-]*$`)
|
||||
openclawPluginIDPattern = regexp.MustCompile(`^(@[A-Za-z0-9][A-Za-z0-9._-]*/)?[A-Za-z0-9][A-Za-z0-9._-]*$`)
|
||||
)
|
||||
|
||||
type openclawPluginListOutput struct {
|
||||
Plugins []struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Version string `json:"version"`
|
||||
Origin string `json:"origin"`
|
||||
Enabled bool `json:"enabled"`
|
||||
} `json:"plugins"`
|
||||
}
|
||||
|
||||
type openclawPluginIndexItem struct {
|
||||
PluginID string `json:"pluginId"`
|
||||
PackageName string `json:"packageName"`
|
||||
PackageVersion string `json:"packageVersion"`
|
||||
Origin string `json:"origin"`
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
type openclawPluginSearchOutput struct {
|
||||
Results []struct {
|
||||
Score float64 `json:"score"`
|
||||
Package struct {
|
||||
Name string `json:"name"`
|
||||
RuntimeID string `json:"runtimeId"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Summary string `json:"summary"`
|
||||
LatestVersion string `json:"latestVersion"`
|
||||
Categories []string `json:"categories"`
|
||||
Channel string `json:"channel"`
|
||||
IsOfficial bool `json:"isOfficial"`
|
||||
VerificationTier string `json:"verificationTier"`
|
||||
Stats struct {
|
||||
Downloads int64 `json:"downloads"`
|
||||
} `json:"stats"`
|
||||
} `json:"package"`
|
||||
} `json:"results"`
|
||||
}
|
||||
|
||||
func (a AgentService) ListPlugins(req dto.AgentPluginsReq) ([]dto.AgentPluginItem, error) {
|
||||
agent, install, err := a.loadOpenclawAgentAndInstall(req.AgentID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if plugins, err := readOpenclawPluginIndex(filepath.Join(filepath.Dir(agent.ConfigPath), "state", "openclaw.sqlite")); err == nil {
|
||||
return plugins, nil
|
||||
}
|
||||
output, err := cmd.RunDockerExecWithStdout(2*time.Minute, install.ContainerName, "openclaw", "plugins", "list", "--json")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return parseOpenclawPluginList([]byte(output))
|
||||
}
|
||||
|
||||
func (a AgentService) SearchPlugins(req dto.AgentPluginSearchReq) ([]dto.AgentPluginSearchItem, error) {
|
||||
_, install, err := a.loadOpenclawAgentAndInstall(req.AgentID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
limit := req.Limit
|
||||
if limit == 0 {
|
||||
limit = 20
|
||||
}
|
||||
output, err := cmd.RunDockerExecWithStdout(
|
||||
2*time.Minute,
|
||||
install.ContainerName,
|
||||
"openclaw", "plugins", "search", strings.TrimSpace(req.Keyword), "--limit", fmt.Sprint(limit), "--json",
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return parseOpenclawPluginSearch([]byte(output))
|
||||
}
|
||||
|
||||
func (a AgentService) InstallMarketPlugin(req dto.AgentPluginMarketInstallReq) error {
|
||||
spec, err := buildOpenclawPluginInstallSpec(req.Package, req.Version)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, install, err := a.loadOpenclawAgentAndInstall(req.AgentID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := task.CheckScopeTaskIsExecuting(task.TaskScopeAI, req.AgentID); err != nil {
|
||||
return err
|
||||
}
|
||||
taskName := fmt.Sprintf("%s [%s]", i18n.GetMsgByKey("AgentPluginInstall"), req.Package)
|
||||
installTask, err := task.NewTask(taskName, task.TaskInstall, task.TaskScopeAI, req.TaskID, req.AgentID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
installTask.AddSubTask(taskName, func(t *task.Task) error {
|
||||
mgr := cmd.NewCommandMgr(cmd.WithTask(*t), cmd.WithContext(t.TaskCtx), cmd.WithTimeout(10*time.Minute))
|
||||
return mgr.Run("docker", "exec", install.ContainerName, "openclaw", "plugins", "install", spec)
|
||||
}, nil)
|
||||
addOpenclawPluginRestartTask(installTask, install)
|
||||
go executeAgentPluginTask(installTask)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a AgentService) OperatePlugin(req dto.AgentPluginOperateReq) error {
|
||||
if !openclawPluginIDPattern.MatchString(req.PluginID) {
|
||||
return buserr.New("ErrInvalidChar")
|
||||
}
|
||||
agent, install, err := a.loadOpenclawAgentAndInstall(req.AgentID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := task.CheckScopeTaskIsExecuting(task.TaskScopeAI, req.AgentID); err != nil {
|
||||
return err
|
||||
}
|
||||
if req.Operate == "update" || req.Operate == "uninstall" {
|
||||
plugins, err := a.ListPlugins(dto.AgentPluginsReq{AgentID: req.AgentID})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, plugin := range plugins {
|
||||
if plugin.ID == req.PluginID && plugin.Origin == "bundled" {
|
||||
return buserr.WithName("ErrNotSupportType", req.Operate)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
taskType := map[string]string{
|
||||
"enable": task.TaskUpdate,
|
||||
"disable": task.TaskUpdate,
|
||||
"update": task.TaskUpgrade,
|
||||
"uninstall": task.TaskUninstall,
|
||||
}[req.Operate]
|
||||
taskName := fmt.Sprintf("%s [%s]", i18n.GetMsgByKey(map[string]string{
|
||||
"enable": "AgentPluginEnable",
|
||||
"disable": "AgentPluginDisable",
|
||||
"update": "AgentPluginUpdate",
|
||||
"uninstall": "AgentPluginUninstall",
|
||||
}[req.Operate]), req.PluginID)
|
||||
operateTask, err := task.NewTask(taskName, taskType, task.TaskScopeAI, req.TaskID, req.AgentID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
operateTask.AddSubTask(taskName, func(t *task.Task) error {
|
||||
mgr := cmd.NewCommandMgr(cmd.WithTask(*t), cmd.WithContext(t.TaskCtx), cmd.WithTimeout(10*time.Minute))
|
||||
if req.Operate == "uninstall" {
|
||||
if err := uninstallOpenclawPlugin(mgr, install.ContainerName, req.PluginID); err != nil {
|
||||
return err
|
||||
}
|
||||
return cleanupManagedOpenclawPlugin(agent, req.PluginID)
|
||||
}
|
||||
return mgr.Run("docker", "exec", install.ContainerName, "openclaw", "plugins", req.Operate, req.PluginID)
|
||||
}, nil)
|
||||
addOpenclawPluginRestartTask(operateTask, install)
|
||||
go executeAgentPluginTask(operateTask)
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseOpenclawPluginList(raw []byte) ([]dto.AgentPluginItem, error) {
|
||||
payload, err := extractEmbeddedJSON(string(raw))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(payload) == 0 {
|
||||
return []dto.AgentPluginItem{}, nil
|
||||
}
|
||||
var output openclawPluginListOutput
|
||||
if err := json.Unmarshal(payload, &output); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items := make([]dto.AgentPluginItem, 0, len(output.Plugins))
|
||||
for _, plugin := range output.Plugins {
|
||||
items = append(items, dto.AgentPluginItem{
|
||||
ID: plugin.ID,
|
||||
Name: plugin.Name,
|
||||
Version: plugin.Version,
|
||||
Origin: plugin.Origin,
|
||||
Enabled: plugin.Enabled,
|
||||
})
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func readOpenclawPluginIndex(dbPath string) ([]dto.AgentPluginItem, error) {
|
||||
db, err := sql.Open("sqlite", "file:"+filepath.ToSlash(dbPath)+"?mode=ro")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
var raw []byte
|
||||
if err := db.QueryRow(
|
||||
"SELECT plugins_json FROM installed_plugin_index WHERE index_key = ?",
|
||||
"installed-plugin-index",
|
||||
).Scan(&raw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var plugins []openclawPluginIndexItem
|
||||
if err := json.Unmarshal(raw, &plugins); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items := make([]dto.AgentPluginItem, 0, len(plugins))
|
||||
for _, plugin := range plugins {
|
||||
name := plugin.PackageName
|
||||
if name == "" {
|
||||
name = plugin.PluginID
|
||||
}
|
||||
items = append(items, dto.AgentPluginItem{
|
||||
ID: plugin.PluginID,
|
||||
Name: name,
|
||||
Version: plugin.PackageVersion,
|
||||
Origin: plugin.Origin,
|
||||
Enabled: plugin.Enabled,
|
||||
})
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func parseOpenclawPluginSearch(raw []byte) ([]dto.AgentPluginSearchItem, error) {
|
||||
payload, err := extractEmbeddedJSON(string(raw))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(payload) == 0 {
|
||||
return []dto.AgentPluginSearchItem{}, nil
|
||||
}
|
||||
var output openclawPluginSearchOutput
|
||||
if err := json.Unmarshal(payload, &output); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items := make([]dto.AgentPluginSearchItem, 0, len(output.Results))
|
||||
for _, result := range output.Results {
|
||||
items = append(items, dto.AgentPluginSearchItem{
|
||||
Package: result.Package.Name,
|
||||
PluginID: result.Package.RuntimeID,
|
||||
Name: result.Package.DisplayName,
|
||||
Description: result.Package.Summary,
|
||||
Version: result.Package.LatestVersion,
|
||||
Channel: result.Package.Channel,
|
||||
VerificationTier: result.Package.VerificationTier,
|
||||
Categories: append([]string{}, result.Package.Categories...),
|
||||
Official: result.Package.IsOfficial,
|
||||
Downloads: result.Package.Stats.Downloads,
|
||||
Score: result.Score,
|
||||
})
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func buildOpenclawPluginInstallSpec(packageName, version string) (string, error) {
|
||||
packageName = strings.TrimSpace(packageName)
|
||||
version = strings.TrimSpace(version)
|
||||
if !openclawPluginPackagePattern.MatchString(packageName) || !openclawPluginVersionPattern.MatchString(version) {
|
||||
return "", buserr.New("ErrInvalidChar")
|
||||
}
|
||||
return "clawhub:" + packageName + "@" + version, nil
|
||||
}
|
||||
|
||||
func cleanupManagedOpenclawPlugin(agent *model.Agent, pluginID string) error {
|
||||
pluginType := map[string]string{
|
||||
"openclaw-lark": "feishu",
|
||||
"openclaw-qqbot": "qqbot",
|
||||
"wecom-openclaw-plugin": "wecom",
|
||||
"dingtalk-connector": "dingtalk",
|
||||
"openclaw-weixin": "weixin",
|
||||
}[pluginID]
|
||||
if pluginType == "" {
|
||||
return nil
|
||||
}
|
||||
conf, err := readOpenclawConfig(agent.ConfigPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cleanupOpenclawPluginConfig(conf, pluginType)
|
||||
return writeOpenclawConfigRaw(agent.ConfigPath, conf)
|
||||
}
|
||||
|
||||
func addOpenclawPluginRestartTask(t *task.Task, install *model.AppInstall) {
|
||||
t.AddSubTask(task.GetTaskName("OpenClaw", task.TaskRestart, task.TaskScopeAI), func(t *task.Task) error {
|
||||
output, err := compose.Restart(install.GetComposePath())
|
||||
if output != "" {
|
||||
t.Log(output)
|
||||
}
|
||||
return err
|
||||
}, nil)
|
||||
}
|
||||
|
||||
func executeAgentPluginTask(t *task.Task) {
|
||||
if err := t.Execute(); err != nil {
|
||||
global.LOG.Errorf("operate openclaw plugin failed: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,7 @@ import (
|
||||
"github.com/1Panel-dev/1Panel/agent/buserr"
|
||||
"github.com/1Panel-dev/1Panel/agent/constant"
|
||||
"github.com/1Panel-dev/1Panel/agent/global"
|
||||
"github.com/1Panel-dev/1Panel/agent/i18n"
|
||||
"github.com/1Panel-dev/1Panel/agent/utils/common"
|
||||
agentenv "github.com/1Panel-dev/1Panel/agent/utils/env"
|
||||
"github.com/1Panel-dev/1Panel/agent/utils/files"
|
||||
@@ -96,7 +97,7 @@ func ensureContainerRunning(containerName string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func resolveAgentAccountInput(provider, apiType, authMode, apiKey, baseURL, modelID string) (resolvedAgentAccountInput, error) {
|
||||
func resolveAgentAccountInput(provider, apiType, authMode, apiKey, baseURL, modelID string, validateAvailability bool) (resolvedAgentAccountInput, error) {
|
||||
resolvedAPIKey := strings.TrimSpace(apiKey)
|
||||
resolvedAPIType := strings.TrimSpace(apiType)
|
||||
resolvedAuthMode, err := providercatalog.ResolveAuthMode(provider, resolvedAPIType, authMode)
|
||||
@@ -114,7 +115,8 @@ func resolveAgentAccountInput(provider, apiType, authMode, apiKey, baseURL, mode
|
||||
if modelID == "" {
|
||||
return resolvedAgentAccountInput{}, buserr.New("ErrAgentAccountModelsRequired")
|
||||
}
|
||||
if !providercatalog.SkipVerification(provider) {
|
||||
imageAPI := providercatalog.IsImageAPIType(resolvedAPIType)
|
||||
if validateAvailability && (imageAPI || !providercatalog.SkipVerification(provider)) {
|
||||
if err := providercatalog.VerifyAccount(provider, resolvedAPIType, resolvedAuthMode, resolvedBaseURL, resolvedAPIKey, modelID); err != nil {
|
||||
return resolvedAgentAccountInput{}, err
|
||||
}
|
||||
@@ -386,7 +388,7 @@ func buildAgentItem(agent *model.Agent, appInstall *model.AppInstall, envMap map
|
||||
Remark: agent.Remark,
|
||||
AgentType: agentType,
|
||||
Provider: agent.Provider,
|
||||
ProviderName: providercatalog.DisplayName(agent.Provider),
|
||||
ProviderName: localizedAgentProviderName(agent.Provider),
|
||||
Model: agent.Model,
|
||||
APIType: agent.APIType,
|
||||
BaseURL: agent.BaseURL,
|
||||
@@ -420,8 +422,8 @@ func buildAgentItem(agent *model.Agent, appInstall *model.AppInstall, envMap map
|
||||
item.BridgePort = toInt(bridge)
|
||||
}
|
||||
}
|
||||
if agentType == constant.AppHermesAgent {
|
||||
auth := readHermesDashboardAuthFromInstall(appInstall)
|
||||
if _, _, ok := agentDashboardAuthEnvKeys(agentType); ok {
|
||||
auth := readAgentDashboardAuthFromInstall(appInstall, agentType)
|
||||
item.DashboardUsername = auth.Username
|
||||
item.DashboardPassword = auth.Password
|
||||
}
|
||||
@@ -429,6 +431,15 @@ func buildAgentItem(agent *model.Agent, appInstall *model.AppInstall, envMap map
|
||||
return item
|
||||
}
|
||||
|
||||
func localizedAgentProviderName(provider string) string {
|
||||
if key := providercatalog.DisplayNameKey(provider); key != "" {
|
||||
if name := strings.TrimSpace(i18n.GetMsgByKey(key)); name != "" {
|
||||
return name
|
||||
}
|
||||
}
|
||||
return providercatalog.DisplayName(provider)
|
||||
}
|
||||
|
||||
func isAgentAppKey(appKey string) bool {
|
||||
return appKey == constant.AppOpenclaw || appKey == constant.AppCopaw || appKey == constant.AppHermesAgent
|
||||
}
|
||||
@@ -1159,15 +1170,15 @@ func buildInitialAgentAccountModels(account *model.AgentAccount, requested []dto
|
||||
if len(requested) > 0 {
|
||||
return normalizeAgentAccountModels(account, requested)
|
||||
}
|
||||
meta, ok := providercatalog.Get(account.Provider)
|
||||
if !ok || len(meta.Models) == 0 {
|
||||
defaultModels := providercatalog.DefaultModels(account.Provider, account.APIType)
|
||||
if len(defaultModels) == 0 {
|
||||
if requiresInitialAgentAccountModels(account.Provider) {
|
||||
return nil, buserr.New("ErrAgentAccountModelsRequired")
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
requested = make([]dto.AgentAccountModel, 0, len(meta.Models))
|
||||
for _, item := range meta.Models {
|
||||
requested = make([]dto.AgentAccountModel, 0, len(defaultModels))
|
||||
for _, item := range defaultModels {
|
||||
requested = append(requested, dto.AgentAccountModel{
|
||||
ID: item.ID,
|
||||
Name: item.Name,
|
||||
@@ -1452,6 +1463,87 @@ func readInstallEnv(envStr string) map[string]interface{} {
|
||||
return data
|
||||
}
|
||||
|
||||
const (
|
||||
hermesDashboardUsernameEnvKey = "HERMES_DASHBOARD_USERNAME"
|
||||
hermesDashboardPasswordEnvKey = "HERMES_DASHBOARD_PASSWORD"
|
||||
qwenPawAuthEnabledEnvKey = "QWENPAW_AUTH_ENABLED"
|
||||
qwenPawAuthUsernameEnvKey = "QWENPAW_AUTH_USERNAME"
|
||||
qwenPawAuthPasswordEnvKey = "QWENPAW_AUTH_PASSWORD"
|
||||
)
|
||||
|
||||
type agentDashboardAuth struct {
|
||||
Username string
|
||||
Password string
|
||||
}
|
||||
|
||||
func normalizeAgentDashboardAuth(username, password string) agentDashboardAuth {
|
||||
auth := agentDashboardAuth{
|
||||
Username: strings.TrimSpace(username),
|
||||
Password: strings.TrimSpace(password),
|
||||
}
|
||||
if auth.Username == "" {
|
||||
auth.Username = "admin"
|
||||
}
|
||||
if auth.Password == "" {
|
||||
auth.Password = common.RandStr(8)
|
||||
}
|
||||
return auth
|
||||
}
|
||||
|
||||
func agentDashboardAuthEnvKeys(agentType string) (string, string, bool) {
|
||||
switch agentType {
|
||||
case constant.AppHermesAgent:
|
||||
return hermesDashboardUsernameEnvKey, hermesDashboardPasswordEnvKey, true
|
||||
case constant.AppCopaw:
|
||||
return qwenPawAuthUsernameEnvKey, qwenPawAuthPasswordEnvKey, true
|
||||
default:
|
||||
return "", "", false
|
||||
}
|
||||
}
|
||||
|
||||
func writeAgentDashboardAuthEnv(envPath, agentType string, auth agentDashboardAuth, overwrite bool) error {
|
||||
usernameKey, passwordKey, ok := agentDashboardAuthEnvKeys(agentType)
|
||||
if !ok {
|
||||
return fmt.Errorf("dashboard auth is not supported for %s", agentType)
|
||||
}
|
||||
values := map[string]string{
|
||||
usernameKey: auth.Username,
|
||||
passwordKey: auth.Password,
|
||||
}
|
||||
order := []string{usernameKey, passwordKey}
|
||||
if agentType == constant.AppCopaw {
|
||||
values[qwenPawAuthEnabledEnvKey] = "true"
|
||||
order = append([]string{qwenPawAuthEnabledEnvKey}, order...)
|
||||
}
|
||||
return upsertAgentEnv(envPath, values, order, overwrite)
|
||||
}
|
||||
|
||||
func readAgentDashboardAuthEnv(envPath, agentType string) (agentDashboardAuth, error) {
|
||||
usernameKey, passwordKey, ok := agentDashboardAuthEnvKeys(agentType)
|
||||
if !ok {
|
||||
return agentDashboardAuth{}, fmt.Errorf("dashboard auth is not supported for %s", agentType)
|
||||
}
|
||||
envMap, err := readAgentEnvMap(envPath)
|
||||
if err != nil {
|
||||
return agentDashboardAuth{}, err
|
||||
}
|
||||
return agentDashboardAuth{
|
||||
Username: strings.TrimSpace(envMap[usernameKey]),
|
||||
Password: strings.TrimSpace(envMap[passwordKey]),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func readAgentDashboardAuthFromInstall(appInstall *model.AppInstall, agentType string) agentDashboardAuth {
|
||||
if appInstall == nil || appInstall.ID == 0 {
|
||||
return agentDashboardAuth{}
|
||||
}
|
||||
auth, err := readAgentDashboardAuthEnv(appInstall.GetEnvPath(), agentType)
|
||||
if err != nil {
|
||||
return agentDashboardAuth{}
|
||||
}
|
||||
return auth
|
||||
}
|
||||
|
||||
func readAgentEnvMap(envPath string) (map[string]string, error) {
|
||||
fileOp := files.NewFileOp()
|
||||
if !fileOp.Stat(envPath) {
|
||||
|
||||
@@ -384,6 +384,9 @@ func (a AlertService) PageAlertLogs(search dto.AlertLogSearch) (int64, []dto.Ale
|
||||
if search.Count != 0 {
|
||||
opts = append(opts, alertRepo.WithByCount(search.Count))
|
||||
}
|
||||
if !search.StartTime.IsZero() && !search.EndTime.IsZero() {
|
||||
opts = append(opts, repo.WithByCreatedAt(search.StartTime, search.EndTime))
|
||||
}
|
||||
opts = append(opts, repo.WithOrderDesc("created_at"))
|
||||
|
||||
total, alerts, err := alertRepo.PageLog(search.Page, search.PageSize, opts...)
|
||||
|
||||
@@ -0,0 +1,928 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"maps"
|
||||
"os"
|
||||
"path"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/1Panel-dev/1Panel/agent/app/dto/request"
|
||||
"github.com/1Panel-dev/1Panel/agent/app/model"
|
||||
"github.com/1Panel-dev/1Panel/agent/app/repo"
|
||||
"github.com/1Panel-dev/1Panel/agent/app/task"
|
||||
"github.com/1Panel-dev/1Panel/agent/buserr"
|
||||
"github.com/1Panel-dev/1Panel/agent/constant"
|
||||
"github.com/1Panel-dev/1Panel/agent/global"
|
||||
"github.com/1Panel-dev/1Panel/agent/i18n"
|
||||
"github.com/1Panel-dev/1Panel/agent/utils/common"
|
||||
"github.com/1Panel-dev/1Panel/agent/utils/compose"
|
||||
"github.com/1Panel-dev/1Panel/agent/utils/docker"
|
||||
"github.com/1Panel-dev/1Panel/agent/utils/files"
|
||||
"github.com/docker/docker/api/types/container"
|
||||
"github.com/docker/docker/api/types/filters"
|
||||
"github.com/joho/godotenv"
|
||||
)
|
||||
|
||||
type appUpgradePhase int
|
||||
|
||||
const (
|
||||
appUpgradePreparing appUpgradePhase = iota
|
||||
appUpgradePrepared
|
||||
appUpgradeStopped
|
||||
appUpgradeBackedUp
|
||||
appUpgradeDown
|
||||
appUpgradeMutated
|
||||
appUpgradeStarted
|
||||
appUpgradeReady
|
||||
appUpgradeCommitted
|
||||
)
|
||||
|
||||
const composeServiceLabel = "com.docker.compose.service"
|
||||
|
||||
var appUpgradeLocks sync.Map
|
||||
|
||||
type appUpgradeSnapshot interface {
|
||||
Restore() error
|
||||
Cleanup()
|
||||
}
|
||||
|
||||
type upgradeFileSnapshot struct {
|
||||
installPath string
|
||||
backupPath string
|
||||
paths []string
|
||||
existing map[string]bool
|
||||
}
|
||||
|
||||
type appUpgradeContext struct {
|
||||
req request.AppInstallUpgrade
|
||||
original model.AppInstall
|
||||
candidate model.AppInstall
|
||||
detail model.AppDetail
|
||||
|
||||
phase appUpgradePhase
|
||||
stopAttempted bool
|
||||
downAttempted bool
|
||||
rollbackErr error
|
||||
|
||||
detailDir string
|
||||
stageDir string
|
||||
envContent []byte
|
||||
oldEnvContent []byte
|
||||
oldDockerCompose string
|
||||
oldImageIDs []appImageID
|
||||
backupFile string
|
||||
snapshot appUpgradeSnapshot
|
||||
createdPaths []string
|
||||
}
|
||||
|
||||
func upgradeInstall(req request.AppInstallUpgrade) error {
|
||||
install, err := appInstallRepo.GetFirst(repo.WithByID(req.InstallID))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if install.Status == constant.StatusUpgrading {
|
||||
return buserr.New("TaskIsExecuting")
|
||||
}
|
||||
if err = task.CheckScopeTaskIsExecuting(task.TaskScopeApp, install.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, loaded := appUpgradeLocks.LoadOrStore(install.ID, struct{}{}); loaded {
|
||||
return buserr.New("TaskIsExecuting")
|
||||
}
|
||||
releaseLock := true
|
||||
defer func() {
|
||||
if releaseLock {
|
||||
appUpgradeLocks.Delete(install.ID)
|
||||
}
|
||||
}()
|
||||
|
||||
detail, err := appDetailRepo.GetFirst(repo.WithByID(req.DetailID))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if install.App.Key == vllmAppKeyForUpgrade && !isVllmUpgradeVersionAllowed(install.Version, detail.Version, loadVllmImageFromEnv(install.Env)) {
|
||||
return errors.New("vLLM can only upgrade within the same image type")
|
||||
}
|
||||
if install.Version == detail.Version {
|
||||
return errors.New("two version is same")
|
||||
}
|
||||
|
||||
upgradeTask, err := task.NewTaskWithOps(install.Name, task.TaskUpgrade, task.TaskScopeApp, req.TaskID, install.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ctx := &appUpgradeContext{
|
||||
req: req,
|
||||
original: install,
|
||||
candidate: install,
|
||||
detail: detail,
|
||||
phase: appUpgradePreparing,
|
||||
oldDockerCompose: install.DockerCompose,
|
||||
}
|
||||
upgradeTask.AddSubTaskWithOps(i18n.GetMsgByKey("UpgradePrepare"), ctx.prepare, nil, 0, 0)
|
||||
upgradeTask.AddSubTaskWithOps(
|
||||
task.GetTaskName(install.Name, task.TaskUpgrade, task.TaskScopeApp),
|
||||
ctx.cutover,
|
||||
func(t *task.Task) {
|
||||
ctx.rollbackErr = ctx.rollback(t)
|
||||
},
|
||||
0,
|
||||
0,
|
||||
)
|
||||
|
||||
upgradingInstall := install
|
||||
upgradingInstall.Status = constant.StatusUpgrading
|
||||
upgradingInstall.Message = ""
|
||||
if err = appInstallRepo.Save(context.Background(), &upgradingInstall); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
releaseLock = false
|
||||
go func() {
|
||||
defer appUpgradeLocks.Delete(install.ID)
|
||||
defer ctx.cleanup()
|
||||
taskErr := upgradeTask.Execute()
|
||||
if taskErr == nil {
|
||||
return
|
||||
}
|
||||
if ctx.rollbackErr != nil {
|
||||
taskErr = fmt.Errorf("%w; %s: %v", taskErr, i18n.GetMsgByKey("UpgradeRollbackFailed"), ctx.rollbackErr)
|
||||
upgradeTask.Task.ErrorMsg = taskErr.Error()
|
||||
_ = repo.NewITaskRepo().Update(context.Background(), upgradeTask.Task)
|
||||
}
|
||||
if !ctx.stopAttempted || ctx.rollbackErr == nil {
|
||||
restored := ctx.original
|
||||
_ = appInstallRepo.Save(context.Background(), &restored)
|
||||
return
|
||||
}
|
||||
failed := ctx.original
|
||||
failed.Status = constant.StatusUpgradeErr
|
||||
failed.Message = taskErr.Error()
|
||||
_ = appInstallRepo.Save(context.Background(), &failed)
|
||||
}()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (u *appUpgradeContext) prepare(t *task.Task) error {
|
||||
fileOp := files.NewFileOp()
|
||||
u.detailDir = path.Join(u.original.App.GetAppResourcePath(), u.detail.Version)
|
||||
if u.original.App.Resource == constant.AppResourceRemote {
|
||||
if err := downloadApp(u.original.App, u.detail, nil, t.Logger); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if !fileOp.Stat(u.detailDir) {
|
||||
return buserr.WithName("ErrFileNotFound", u.detailDir)
|
||||
}
|
||||
if u.detail.DockerCompose == "" {
|
||||
composeContent, err := fileOp.GetContent(path.Join(u.detailDir, "docker-compose.yml"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
u.detail.DockerCompose = string(composeContent)
|
||||
_ = appDetailRepo.Update(context.Background(), u.detail)
|
||||
}
|
||||
if strings.TrimSpace(u.detail.DockerCompose) == "" && strings.TrimSpace(u.req.DockerCompose) == "" {
|
||||
return buserr.WithName("ErrFileNotFound", "docker-compose.yml")
|
||||
}
|
||||
|
||||
var err error
|
||||
u.oldEnvContent, err = fileOp.GetContent(u.original.GetEnvPath())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
u.stageDir, err = os.MkdirTemp(u.original.GetAppPath(), "."+u.original.Name+"-upgrade-")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err = fileOp.CopyDirWithNewName(u.detailDir, u.stageDir, "."); err != nil {
|
||||
return err
|
||||
}
|
||||
if err = copyUpgradeStageFile(u.original.GetPath(), u.stageDir, ".env"); err != nil {
|
||||
return err
|
||||
}
|
||||
if u.original.App.Key == constant.AppOpenclaw {
|
||||
if err = copyUpgradeStageFile(u.original.GetPath(), u.stageDir, path.Join("data", "conf", "openclaw.json")); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if u.original.App.Key == constant.AppOpenresty {
|
||||
for _, relativePath := range []string{
|
||||
nginxModuleBuildDir,
|
||||
nginxModuleModulesDir,
|
||||
path.Join(nginxModuleConfDir, nginxModuleEnabledConfDir),
|
||||
} {
|
||||
if err = copyUpgradeStageFile(u.original.GetPath(), u.stageDir, relativePath); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stagedInstall := u.original
|
||||
stagedInstall.Name = path.Base(u.stageDir)
|
||||
stagedInstall.Version = u.detail.Version
|
||||
stagedInstall.AppDetailId = u.req.DetailID
|
||||
if stagedInstall.App.Key == vllmAppKeyForUpgrade {
|
||||
envs := make(map[string]interface{})
|
||||
if err = json.Unmarshal([]byte(stagedInstall.Env), &envs); err != nil {
|
||||
return err
|
||||
}
|
||||
image := buildVllmUpgradeImage(loadVllmImageFromEnv(stagedInstall.Env), u.original.Version, u.detail.Version)
|
||||
envs[vllmImageEnvKey] = image
|
||||
paramBytes, marshalErr := json.Marshal(envs)
|
||||
if marshalErr != nil {
|
||||
return marshalErr
|
||||
}
|
||||
stagedInstall.Env = string(paramBytes)
|
||||
}
|
||||
if err = migrateOpenclawProtocolUpgrade(&stagedInstall, u.original.Version, u.detail.Version); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
u.candidate = stagedInstall
|
||||
u.candidate.Name = u.original.Name
|
||||
u.candidate.DockerCompose, err = renderUpgradeCompose(u.candidate, u.detail, u.req.DockerCompose)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(u.candidate.DockerCompose) == "" {
|
||||
return buserr.WithName("ErrFileNotFound", "docker-compose.yml")
|
||||
}
|
||||
|
||||
u.envContent, err = renderUpgradeEnv(&u.candidate, u.oldEnvContent)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err = writeUpgradeFile(path.Join(u.stageDir, ".env"), u.envContent, constant.FilePerm); err != nil {
|
||||
return err
|
||||
}
|
||||
if err = writeUpgradeFile(path.Join(u.stageDir, "docker-compose.yml"), []byte(u.candidate.DockerCompose), constant.FilePerm); err != nil {
|
||||
return err
|
||||
}
|
||||
project, err := docker.GetComposeProject(u.original.Name, u.stageDir, []byte(u.candidate.DockerCompose), u.envContent, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
hasBuild := false
|
||||
for _, service := range project.Services {
|
||||
if service.Image == "" && service.Build == nil {
|
||||
return fmt.Errorf("compose service %s has neither image nor build configuration", service.Name)
|
||||
}
|
||||
hasBuild = hasBuild || service.Build != nil
|
||||
}
|
||||
if u.req.DeleteImage {
|
||||
dockerClient, clientErr := docker.NewClient()
|
||||
if clientErr != nil {
|
||||
return clientErr
|
||||
}
|
||||
u.oldImageIDs, err = getAppImageIDsByCompose(dockerClient, u.oldEnvContent, []byte(u.oldDockerCompose))
|
||||
dockerClient.Close()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
images := make([]string, 0, len(project.Services))
|
||||
for _, service := range project.Services {
|
||||
if service.Image != "" {
|
||||
images = append(images, service.Image)
|
||||
}
|
||||
}
|
||||
if err = prepareUpgradeImages(t, images, u.req.PullImage); err != nil {
|
||||
return err
|
||||
}
|
||||
if u.candidate.App.Key == constant.AppOpenresty {
|
||||
if err = u.prepareOpenresty(t, stagedInstall); err != nil {
|
||||
return err
|
||||
}
|
||||
if err = verifyUpgradeImages(images); err != nil {
|
||||
return err
|
||||
}
|
||||
} else if hasBuild {
|
||||
logStr := fmt.Sprintf("%s %s", i18n.GetMsgByKey("TaskBuild"), i18n.GetMsgByKey("Image"))
|
||||
t.LogStart(logStr)
|
||||
if err = compose.BuildWithTask(path.Join(u.stageDir, "docker-compose.yml"), project.Name, t); err != nil {
|
||||
t.LogFailedWithErr(logStr, err)
|
||||
return err
|
||||
}
|
||||
t.LogSuccess(logStr)
|
||||
if err = verifyUpgradeImages(images); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if u.original.App.Resource == constant.AppResourceRemote {
|
||||
go RequestDownloadCallBack(u.detail.DownloadCallBackUrl)
|
||||
}
|
||||
u.phase = appUpgradePrepared
|
||||
return nil
|
||||
}
|
||||
|
||||
func (u *appUpgradeContext) prepareOpenresty(t *task.Task, stagedInstall model.AppInstall) error {
|
||||
fileOp := files.NewFileOp()
|
||||
detailBuildDir := path.Join(u.detailDir, nginxModuleBuildDir)
|
||||
installBuildDir := path.Join(u.stageDir, nginxModuleBuildDir)
|
||||
if !fileOp.Stat(installBuildDir) {
|
||||
if err := fileOp.CreateDir(installBuildDir, constant.DirPerm); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := copyAppDetailMissing(fileOp, detailBuildDir, installBuildDir); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := fileOp.DeleteDir(path.Join(installBuildDir, nginxModuleTmpDir)); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := fileOp.CopyDir(path.Join(detailBuildDir, nginxModuleTmpDir), installBuildDir); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, fileName := range []string{"Dockerfile", "nginx.conf", "nginx.vh.default.conf"} {
|
||||
if err := fileOp.CopyFile(path.Join(detailBuildDir, fileName), installBuildDir); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := syncNginxModuleBuilder(detailBuildDir, installBuildDir); err != nil {
|
||||
return err
|
||||
}
|
||||
targetCatalogSource := path.Join(detailBuildDir, nginxModuleCatalogFile)
|
||||
if !fileOp.Stat(targetCatalogSource) {
|
||||
return fmt.Errorf("target OpenResty module catalog not found: %s", targetCatalogSource)
|
||||
}
|
||||
targetCatalogPath := path.Join(installBuildDir, nginxModuleCatalogPendingFile)
|
||||
if err := stageNginxModuleCatalog(targetCatalogSource, targetCatalogPath); err != nil {
|
||||
return err
|
||||
}
|
||||
stagedInstall.Name = path.Base(u.stageDir)
|
||||
stagedInstall.Version = u.candidate.Version
|
||||
stagedInstall.Env = u.candidate.Env
|
||||
stagedInstall.DockerCompose = u.candidate.DockerCompose
|
||||
return buildNginx(t, stagedInstall, targetCatalogPath)
|
||||
}
|
||||
|
||||
func (u *appUpgradeContext) cutover(t *task.Task) error {
|
||||
u.stopAttempted = true
|
||||
t.LogStart(i18n.GetMsgByKey("UpgradeStop"))
|
||||
if out, err := compose.Stop(u.original.GetComposePath()); err != nil {
|
||||
if out != "" {
|
||||
err = fmt.Errorf("%s: %w", out, err)
|
||||
}
|
||||
t.LogFailedWithErr(i18n.GetMsgByKey("UpgradeStop"), err)
|
||||
return err
|
||||
}
|
||||
t.LogSuccess(i18n.GetMsgByKey("UpgradeStop"))
|
||||
u.phase = appUpgradeStopped
|
||||
|
||||
var err error
|
||||
if u.original.App.Key == constant.AppOpenresty {
|
||||
u.snapshot, err = createOpenrestyUpgradeSnapshot(u.original.GetPath())
|
||||
} else {
|
||||
snapshotPaths := []string{".env", "docker-compose.yml", "scripts"}
|
||||
if u.original.App.Key == constant.AppOpenclaw {
|
||||
snapshotPaths = append(snapshotPaths, path.Join("data", "conf", "openclaw.json"))
|
||||
}
|
||||
u.snapshot, err = createUpgradeFileSnapshot(u.original.GetPath(), snapshotPaths)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if u.req.Backup {
|
||||
if err = u.backup(t); err != nil {
|
||||
return err
|
||||
}
|
||||
u.phase = appUpgradeBackedUp
|
||||
} else {
|
||||
t.Log(i18n.GetMsgByKey("UpgradeBackupDisabled"))
|
||||
}
|
||||
|
||||
u.downAttempted = true
|
||||
if out, downErr := compose.Down(u.original.GetComposePath()); downErr != nil {
|
||||
if out != "" {
|
||||
downErr = fmt.Errorf("%s: %w", out, downErr)
|
||||
}
|
||||
return downErr
|
||||
}
|
||||
u.phase = appUpgradeDown
|
||||
|
||||
u.phase = appUpgradeMutated
|
||||
if err = u.applyStagedFiles(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err = writeUpgradeFile(u.original.GetEnvPath(), u.envContent, constant.FilePerm); err != nil {
|
||||
return err
|
||||
}
|
||||
if err = runScript(t, &u.candidate, "upgrade"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err = writeUpgradeFile(u.original.GetComposePath(), []byte(u.candidate.DockerCompose), constant.FilePerm); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
logStr := fmt.Sprintf("%s %s", i18n.GetMsgByKey("Run"), i18n.GetMsgByKey("App"))
|
||||
t.LogStart(logStr)
|
||||
if out, upErr := compose.UpWithoutPull(u.original.GetComposePath()); upErr != nil {
|
||||
if out != "" {
|
||||
upErr = fmt.Errorf("%s: %w", out, upErr)
|
||||
}
|
||||
t.LogFailedWithErr(logStr, upErr)
|
||||
return upErr
|
||||
}
|
||||
t.LogSuccess(logStr)
|
||||
u.phase = appUpgradeStarted
|
||||
|
||||
t.LogStart(i18n.GetMsgByKey("UpgradeWaitReady"))
|
||||
containerNames, err := waitAppContainersReady(context.Background(), u.candidate)
|
||||
if err != nil {
|
||||
t.LogFailedWithErr(i18n.GetMsgByKey("UpgradeWaitReady"), err)
|
||||
return err
|
||||
}
|
||||
t.LogSuccess(i18n.GetMsgByKey("UpgradeWaitReady"))
|
||||
u.phase = appUpgradeReady
|
||||
u.candidate.ContainerName = strings.Join(containerNames, ",")
|
||||
u.candidate.Status = constant.StatusRunning
|
||||
u.candidate.Message = ""
|
||||
|
||||
if u.candidate.App.Key == constant.AppOpenresty {
|
||||
liveCatalogPath := path.Join(u.candidate.GetPath(), nginxModuleBuildDir, nginxModuleCatalogPendingFile)
|
||||
if err = commitStaticNginxModuleBuilds(u.candidate, liveCatalogPath, t); err != nil {
|
||||
return err
|
||||
}
|
||||
activeCatalogPath := path.Join(u.candidate.GetPath(), nginxModuleBuildDir, nginxModuleCatalogFile)
|
||||
if err = activateNginxModuleCatalogAndCommit(liveCatalogPath, activeCatalogPath, func() error {
|
||||
return appInstallRepo.Save(context.Background(), &u.candidate)
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
} else if err = appInstallRepo.Save(context.Background(), &u.candidate); err != nil {
|
||||
return err
|
||||
}
|
||||
u.phase = appUpgradeCommitted
|
||||
u.deleteOldImages(t)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (u *appUpgradeContext) backup(t *task.Task) error {
|
||||
fileName := fmt.Sprintf("upgrade_backup_%s_%s.tar.gz", u.original.Name, time.Now().Format(constant.DateTimeSlimLayout)+common.RandStrAndNum(5))
|
||||
record, err := backupAppWithParentTask(&u.original, t, fileName)
|
||||
if err != nil {
|
||||
return buserr.WithNameAndErr("ErrAppBackup", u.original.Name, err)
|
||||
}
|
||||
u.backupFile = path.Join(global.Dir.LocalBackupDir, record.FileDir, record.FileName)
|
||||
info, err := os.Stat(u.backupFile)
|
||||
if err != nil || info.Size() == 0 || record.Status != constant.StatusSuccess {
|
||||
if err == nil {
|
||||
err = errors.New("backup archive is empty or incomplete")
|
||||
}
|
||||
markBackupFailed(record.ID, err)
|
||||
return buserr.WithNameAndErr("ErrAppBackup", u.original.Name, err)
|
||||
}
|
||||
|
||||
backupRecordService := NewIBackupRecordService()
|
||||
backups, _ := backupRecordService.ListAppRecords(u.original.App.Key, u.original.Name, "upgrade_backup")
|
||||
if len(backups) > 3 {
|
||||
deleteIDs := make([]uint, 0, len(backups)-3)
|
||||
for _, backup := range backups[:len(backups)-3] {
|
||||
deleteIDs = append(deleteIDs, backup.ID)
|
||||
}
|
||||
_ = backupRecordService.BatchDeleteRecord(deleteIDs)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (u *appUpgradeContext) applyStagedFiles() error {
|
||||
fileOp := files.NewFileOp()
|
||||
if err := copyAppDetailMissingTracked(fileOp, u.detailDir, u.original.GetPath(), &u.createdPaths); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := replaceUpgradePath(u.stageDir, u.original.GetPath(), "scripts"); err != nil {
|
||||
return err
|
||||
}
|
||||
if u.original.App.Key == constant.AppOpenclaw {
|
||||
if err := replaceUpgradePath(u.stageDir, u.original.GetPath(), path.Join("data", "conf", "openclaw.json")); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if u.original.App.Key == constant.AppOpenresty {
|
||||
for _, relativePath := range []string{
|
||||
nginxModuleBuildDir,
|
||||
nginxModuleModulesDir,
|
||||
path.Join(nginxModuleConfDir, nginxModuleEnabledConfDir),
|
||||
path.Join(nginxModuleConfDir, "nginx.conf"),
|
||||
} {
|
||||
if err := replaceUpgradePath(u.stageDir, u.original.GetPath(), relativePath); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (u *appUpgradeContext) rollback(t *task.Task) (rollbackErr error) {
|
||||
if !u.stopAttempted {
|
||||
return nil
|
||||
}
|
||||
logStr := i18n.GetWithName("AppRecover", u.original.Name)
|
||||
t.LogStart(logStr)
|
||||
defer func() {
|
||||
if rollbackErr != nil {
|
||||
t.LogFailedWithErr(logStr, rollbackErr)
|
||||
} else {
|
||||
t.LogSuccess(logStr)
|
||||
}
|
||||
}()
|
||||
if !u.downAttempted {
|
||||
if out, err := compose.Operate(u.original.GetComposePath(), "start"); err != nil {
|
||||
if out != "" {
|
||||
err = fmt.Errorf("%s: %w", out, err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
return u.finishRollback()
|
||||
}
|
||||
if u.phase < appUpgradeMutated {
|
||||
if out, err := compose.UpWithoutPull(u.original.GetComposePath()); err != nil {
|
||||
if out != "" {
|
||||
err = fmt.Errorf("%s: %w", out, err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
return u.finishRollback()
|
||||
}
|
||||
|
||||
if out, err := compose.Down(u.original.GetComposePath()); err != nil {
|
||||
if out != "" {
|
||||
err = fmt.Errorf("%s: %w", out, err)
|
||||
}
|
||||
rollbackErr = err
|
||||
}
|
||||
if u.backupFile != "" {
|
||||
_ = u.restoreManagedFiles()
|
||||
if err := handleAppRecover(&u.original, t, u.backupFile, true, "", ""); err != nil {
|
||||
_, _ = compose.UpWithoutPull(u.original.GetComposePath())
|
||||
return errors.Join(rollbackErr, err)
|
||||
}
|
||||
} else {
|
||||
if err := u.restoreManagedFiles(); err != nil {
|
||||
return errors.Join(rollbackErr, err)
|
||||
}
|
||||
if out, err := compose.UpWithoutPull(u.original.GetComposePath()); err != nil {
|
||||
if out != "" {
|
||||
err = fmt.Errorf("%s: %w", out, err)
|
||||
}
|
||||
return errors.Join(rollbackErr, err)
|
||||
}
|
||||
}
|
||||
return errors.Join(rollbackErr, u.finishRollback())
|
||||
}
|
||||
|
||||
func (u *appUpgradeContext) finishRollback() error {
|
||||
if _, err := waitAppContainersReady(context.Background(), u.original); err != nil {
|
||||
return err
|
||||
}
|
||||
restored := u.original
|
||||
if err := appInstallRepo.Save(context.Background(), &restored); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (u *appUpgradeContext) restoreManagedFiles() error {
|
||||
var restoreErr error
|
||||
if u.snapshot != nil {
|
||||
restoreErr = u.snapshot.Restore()
|
||||
}
|
||||
for index := len(u.createdPaths) - 1; index >= 0; index-- {
|
||||
if err := os.RemoveAll(u.createdPaths[index]); err != nil {
|
||||
restoreErr = errors.Join(restoreErr, err)
|
||||
}
|
||||
}
|
||||
return restoreErr
|
||||
}
|
||||
|
||||
func (u *appUpgradeContext) deleteOldImages(t *task.Task) {
|
||||
if !u.req.DeleteImage {
|
||||
return
|
||||
}
|
||||
excludeImages, err := docker.GetImagesFromDockerCompose(u.envContent, []byte(u.candidate.DockerCompose))
|
||||
if err != nil {
|
||||
t.LogFailedWithErr(i18n.GetMsgByKey("TaskDelete")+i18n.GetMsgByKey("Image"), err)
|
||||
return
|
||||
}
|
||||
dockerClient, err := docker.NewClient()
|
||||
if err != nil {
|
||||
t.LogFailedWithErr(i18n.GetMsgByKey("TaskDelete")+i18n.GetMsgByKey("Image"), err)
|
||||
return
|
||||
}
|
||||
defer dockerClient.Close()
|
||||
if err = deleteAppImagesByIDs(t, dockerClient, u.oldImageIDs, excludeImages); err != nil {
|
||||
t.LogFailedWithErr(i18n.GetMsgByKey("TaskDelete")+i18n.GetMsgByKey("Image"), err)
|
||||
}
|
||||
}
|
||||
|
||||
func (u *appUpgradeContext) cleanup() {
|
||||
if u.snapshot != nil {
|
||||
u.snapshot.Cleanup()
|
||||
}
|
||||
if u.stageDir != "" {
|
||||
_ = os.RemoveAll(u.stageDir)
|
||||
}
|
||||
}
|
||||
|
||||
type upgradeImageClient interface {
|
||||
PullImageWithProcess(*task.Task, string) error
|
||||
ImageExists(string) (bool, error)
|
||||
Close()
|
||||
}
|
||||
|
||||
func prepareUpgradeImages(t *task.Task, images []string, pull bool) error {
|
||||
dockerClient, err := docker.NewClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return prepareUpgradeImagesWithClient(t, dockerClient, images, pull)
|
||||
}
|
||||
|
||||
func prepareUpgradeImagesWithClient(t *task.Task, dockerClient upgradeImageClient, images []string, pull bool) error {
|
||||
defer dockerClient.Close()
|
||||
seen := make(map[string]struct{}, len(images))
|
||||
for _, image := range images {
|
||||
image = strings.TrimSpace(image)
|
||||
if image == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[image]; ok {
|
||||
continue
|
||||
}
|
||||
seen[image] = struct{}{}
|
||||
if pull {
|
||||
if t != nil {
|
||||
t.Log(i18n.GetWithName("PullImageStart", image))
|
||||
}
|
||||
if pullErr := dockerClient.PullImageWithProcess(t, image); pullErr != nil {
|
||||
if exists, _ := dockerClient.ImageExists(image); exists {
|
||||
if t != nil {
|
||||
t.Log(i18n.GetMsgByKey("UseExistImage"))
|
||||
}
|
||||
continue
|
||||
}
|
||||
return buserr.WithNameAndErr("ErrDockerPullImage", "", pullErr)
|
||||
}
|
||||
}
|
||||
exists, inspectErr := dockerClient.ImageExists(image)
|
||||
if inspectErr != nil || !exists {
|
||||
return buserr.WithNameAndErr("ErrDockerPullImage", "", fmt.Errorf("image %s is not available locally: %v", image, inspectErr))
|
||||
}
|
||||
if pull && t != nil {
|
||||
t.LogSuccess(i18n.GetMsgByKey("PullImage"))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func verifyUpgradeImages(images []string) error {
|
||||
dockerClient, err := docker.NewClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer dockerClient.Close()
|
||||
for _, image := range images {
|
||||
exists, inspectErr := dockerClient.ImageExists(image)
|
||||
if inspectErr != nil || !exists {
|
||||
return buserr.WithNameAndErr("ErrDockerPullImage", "", fmt.Errorf("image %s is not available locally: %v", image, inspectErr))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func renderUpgradeEnv(install *model.AppInstall, original []byte) ([]byte, error) {
|
||||
originalEnv := make(map[string]string)
|
||||
if len(original) > 0 {
|
||||
var err error
|
||||
originalEnv, err = godotenv.UnmarshalBytes(original)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
params := make(map[string]string, len(originalEnv))
|
||||
maps.Copy(params, originalEnv)
|
||||
envs := make(map[string]interface{})
|
||||
if err := json.Unmarshal([]byte(install.Env), &envs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
handleMap(envs, params)
|
||||
if install.App.Key == constant.AppOpenresty {
|
||||
for _, key := range []string{"CONTAINER_PACKAGE_URL", "RESTY_ADD_PACKAGE_BUILDDEPS", "RESTY_CONFIG_OPTIONS_MORE"} {
|
||||
if value, ok := originalEnv[key]; ok {
|
||||
params[key] = value
|
||||
}
|
||||
}
|
||||
if websiteDir := strings.TrimSpace(originalEnv["WEBSITE_DIR"]); websiteDir != "" {
|
||||
params["WEBSITE_DIR"] = websiteDir
|
||||
}
|
||||
websiteDir := strings.TrimSpace(params["WEBSITE_DIR"])
|
||||
if websiteDir == "" {
|
||||
websiteDir = NewISettingService().GetWebsiteDir()
|
||||
}
|
||||
if !path.IsAbs(websiteDir) {
|
||||
websiteDir = path.Join(global.Dir.DataDir, websiteDir)
|
||||
}
|
||||
params["WEBSITE_DIR"] = websiteDir
|
||||
envs["WEBSITE_DIR"] = websiteDir
|
||||
content, marshalErr := json.Marshal(envs)
|
||||
if marshalErr != nil {
|
||||
return nil, marshalErr
|
||||
}
|
||||
install.Env = string(content)
|
||||
}
|
||||
content, err := godotenv.Marshal(params)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return []byte(content), nil
|
||||
}
|
||||
|
||||
func renderUpgradeCompose(install model.AppInstall, detail model.AppDetail, customCompose string) (string, error) {
|
||||
if customCompose != "" {
|
||||
return customCompose, nil
|
||||
}
|
||||
if install.App.Key == vllmAppKeyForUpgrade {
|
||||
return install.DockerCompose, nil
|
||||
}
|
||||
return getUpgradeCompose(install, detail)
|
||||
}
|
||||
|
||||
func writeUpgradeFile(filePath string, content []byte, mode os.FileMode) error {
|
||||
tmp, err := os.CreateTemp(path.Dir(filePath), "."+path.Base(filePath)+".*")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmpPath := tmp.Name()
|
||||
defer os.Remove(tmpPath)
|
||||
if err = tmp.Chmod(mode); err == nil {
|
||||
_, err = tmp.Write(content)
|
||||
}
|
||||
if err == nil {
|
||||
err = tmp.Sync()
|
||||
}
|
||||
if closeErr := tmp.Close(); err == nil {
|
||||
err = closeErr
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmpPath, filePath)
|
||||
}
|
||||
|
||||
func copyUpgradeStageFile(sourceRoot, targetRoot, relativePath string) error {
|
||||
source := path.Join(sourceRoot, relativePath)
|
||||
if _, err := os.Stat(source); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
target := path.Join(targetRoot, relativePath)
|
||||
_ = os.RemoveAll(target)
|
||||
return copyOpenrestyUpgradeSnapshotEntry(source, target)
|
||||
}
|
||||
|
||||
func replaceUpgradePath(sourceRoot, targetRoot, relativePath string) error {
|
||||
source := path.Join(sourceRoot, relativePath)
|
||||
if _, err := os.Stat(source); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
target := path.Join(targetRoot, relativePath)
|
||||
if err := os.RemoveAll(target); err != nil {
|
||||
return err
|
||||
}
|
||||
return copyOpenrestyUpgradeSnapshotEntry(source, target)
|
||||
}
|
||||
|
||||
func createUpgradeFileSnapshot(installPath string, paths []string) (*upgradeFileSnapshot, error) {
|
||||
backupPath, err := os.MkdirTemp("", "1panel-app-upgrade-*")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
snapshot := &upgradeFileSnapshot{
|
||||
installPath: installPath,
|
||||
backupPath: backupPath,
|
||||
paths: paths,
|
||||
existing: make(map[string]bool, len(paths)),
|
||||
}
|
||||
for _, relativePath := range paths {
|
||||
source := path.Join(installPath, relativePath)
|
||||
if _, err = os.Stat(source); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
continue
|
||||
}
|
||||
snapshot.Cleanup()
|
||||
return nil, err
|
||||
}
|
||||
snapshot.existing[relativePath] = true
|
||||
if err = copyOpenrestyUpgradeSnapshotEntry(source, path.Join(backupPath, relativePath)); err != nil {
|
||||
snapshot.Cleanup()
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return snapshot, nil
|
||||
}
|
||||
|
||||
func (s *upgradeFileSnapshot) Restore() error {
|
||||
for _, relativePath := range s.paths {
|
||||
target := path.Join(s.installPath, relativePath)
|
||||
if err := os.RemoveAll(target); err != nil {
|
||||
return err
|
||||
}
|
||||
if !s.existing[relativePath] {
|
||||
continue
|
||||
}
|
||||
if err := copyOpenrestyUpgradeSnapshotEntry(path.Join(s.backupPath, relativePath), target); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *upgradeFileSnapshot) Cleanup() {
|
||||
if s != nil && s.backupPath != "" {
|
||||
_ = os.RemoveAll(s.backupPath)
|
||||
}
|
||||
}
|
||||
|
||||
type appContainerReadinessClient interface {
|
||||
ContainerList(context.Context, container.ListOptions) ([]container.Summary, error)
|
||||
ContainerInspect(context.Context, string) (container.InspectResponse, error)
|
||||
}
|
||||
|
||||
func waitAppContainersReady(ctx context.Context, install model.AppInstall) ([]string, error) {
|
||||
client, err := docker.NewDockerClient()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer client.Close()
|
||||
return waitAppContainersReadyWithClient(ctx, client, install)
|
||||
}
|
||||
|
||||
func waitAppContainersReadyWithClient(ctx context.Context, client appContainerReadinessClient, install model.AppInstall) ([]string, error) {
|
||||
envContent, err := os.ReadFile(install.GetEnvPath())
|
||||
if err != nil {
|
||||
envContent, err = renderUpgradeEnv(&install, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
project, err := docker.GetComposeProject(install.Name, install.GetPath(), []byte(install.DockerCompose), envContent, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
expectedServices := make(map[string]struct{})
|
||||
for _, service := range project.Services {
|
||||
if !skipCheckStatus(service) {
|
||||
expectedServices[service.Name] = struct{}{}
|
||||
}
|
||||
}
|
||||
if len(expectedServices) == 0 {
|
||||
return strings.Split(install.ContainerName, ","), nil
|
||||
}
|
||||
options := container.ListOptions{
|
||||
All: true,
|
||||
Filters: filters.NewArgs(
|
||||
filters.Arg("label", composeWorkdirLabel+"="+install.GetPath()),
|
||||
),
|
||||
}
|
||||
containers, err := client.ContainerList(ctx, options)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
foundServices := make(map[string]bool, len(expectedServices))
|
||||
containerNames := make([]string, 0, len(containers))
|
||||
for _, item := range containers {
|
||||
serviceName := item.Labels[composeServiceLabel]
|
||||
if _, ok := expectedServices[serviceName]; !ok {
|
||||
continue
|
||||
}
|
||||
if err = waitContainerReady(ctx, client, item.ID); err != nil {
|
||||
return nil, fmt.Errorf("container %s is not ready: %w", serviceName, err)
|
||||
}
|
||||
foundServices[serviceName] = true
|
||||
if len(item.Names) > 0 {
|
||||
containerNames = append(containerNames, strings.TrimPrefix(item.Names[0], "/"))
|
||||
}
|
||||
}
|
||||
for serviceName := range expectedServices {
|
||||
if !foundServices[serviceName] {
|
||||
return nil, fmt.Errorf("container for service %s was not created", serviceName)
|
||||
}
|
||||
}
|
||||
sort.Strings(containerNames)
|
||||
return containerNames, nil
|
||||
}
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
"math"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
@@ -783,416 +782,6 @@ func buildNginx(parentTask *task.Task, nginxInstall model.AppInstall, catalogPat
|
||||
return commitNginxModuleBuilds(nginxInstall, previousModules, modules, false, catalogPath)
|
||||
}
|
||||
|
||||
func upgradeInstall(req request.AppInstallUpgrade) error {
|
||||
install, err := appInstallRepo.GetFirst(repo.WithByID(req.InstallID))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
originalInstall := install
|
||||
oldVersion := install.Version
|
||||
detail, err := appDetailRepo.GetFirst(repo.WithByID(req.DetailID))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if install.App.Key == vllmAppKeyForUpgrade && !isVllmUpgradeVersionAllowed(install.Version, detail.Version, loadVllmImageFromEnv(install.Env)) {
|
||||
return errors.New("vLLM can only upgrade within the same image type")
|
||||
}
|
||||
if install.Version == detail.Version {
|
||||
return errors.New("two version is same")
|
||||
}
|
||||
|
||||
upgradeTask, err := task.NewTaskWithOps(install.Name, task.TaskUpgrade, task.TaskScopeApp, req.TaskID, install.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
install.Status = constant.StatusUpgrading
|
||||
|
||||
var (
|
||||
upErr error
|
||||
backupFile string
|
||||
nginxUpgradeSnapshot *openrestyUpgradeSnapshot
|
||||
)
|
||||
backUpApp := func(t *task.Task) error {
|
||||
backupService := NewIBackupService()
|
||||
backupRecordService := NewIBackupRecordService()
|
||||
fileName := fmt.Sprintf("upgrade_backup_%s_%s.tar.gz", install.Name, time.Now().Format(constant.DateTimeSlimLayout)+common.RandStrAndNum(5))
|
||||
backupRecord, err := backupService.AppBackup(dto.CommonBackup{Name: install.App.Key, DetailName: install.Name, FileName: fileName})
|
||||
if err == nil {
|
||||
backups, _ := backupRecordService.ListAppRecords(install.App.Key, install.Name, "upgrade_backup")
|
||||
if len(backups) > 3 {
|
||||
backupsToDelete := backups[:len(backups)-3]
|
||||
var deleteIDs []uint
|
||||
for _, backup := range backupsToDelete {
|
||||
deleteIDs = append(deleteIDs, backup.ID)
|
||||
}
|
||||
_ = backupRecordService.BatchDeleteRecord(deleteIDs)
|
||||
}
|
||||
backupFile = path.Join(global.Dir.LocalBackupDir, backupRecord.FileDir, backupRecord.FileName)
|
||||
} else {
|
||||
return buserr.WithNameAndErr("ErrAppBackup", install.Name, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if req.Backup {
|
||||
upgradeTask.AddSubTask(task.GetTaskName(install.Name, task.TaskBackup, task.TaskScopeApp), backUpApp, nil)
|
||||
}
|
||||
|
||||
upgradeApp := func(t *task.Task) error {
|
||||
fileOp := files.NewFileOp()
|
||||
detailDir := path.Join(global.Dir.ResourceDir, "apps", install.App.Resource, install.App.Key, detail.Version)
|
||||
if install.App.Resource == constant.AppResourceRemote {
|
||||
if err = downloadApp(install.App, detail, &install, t.Logger); err != nil {
|
||||
return err
|
||||
}
|
||||
if detail.DockerCompose == "" {
|
||||
composeDetail, err := fileOp.GetContent(path.Join(detailDir, "docker-compose.yml"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
detail.DockerCompose = string(composeDetail)
|
||||
_ = appDetailRepo.Update(context.Background(), detail)
|
||||
}
|
||||
go func() {
|
||||
RequestDownloadCallBack(detail.DownloadCallBackUrl)
|
||||
}()
|
||||
}
|
||||
if install.App.Resource == constant.AppResourceLocal {
|
||||
detailDir = path.Join(global.Dir.ResourceDir, "apps", "local", strings.TrimPrefix(install.App.Key, "local"), detail.Version)
|
||||
}
|
||||
|
||||
content, err := fileOp.GetContent(install.GetEnvPath())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
oldEnvContent := append([]byte(nil), content...)
|
||||
oldDockerCompose := install.DockerCompose
|
||||
targetNginxCatalogPath := ""
|
||||
if install.App.Key == constant.AppOpenresty {
|
||||
nginxUpgradeSnapshot, err = createOpenrestyUpgradeSnapshot(install.GetPath())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if install.App.Key == vllmAppKeyForUpgrade {
|
||||
envs := make(map[string]interface{})
|
||||
if err = json.Unmarshal([]byte(install.Env), &envs); err != nil {
|
||||
return err
|
||||
}
|
||||
image := buildVllmUpgradeImage(loadVllmImageFromEnv(install.Env), oldVersion, detail.Version)
|
||||
envs[vllmImageEnvKey] = image
|
||||
paramByte, err := json.Marshal(envs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
install.Env = string(paramByte)
|
||||
content = setVllmImageInEnvContent(content, image)
|
||||
}
|
||||
_ = copyAppDetailMissing(fileOp, detailDir, install.GetPath())
|
||||
if install.App.Key == constant.AppOpenresty {
|
||||
installBuildDir := path.Join(install.GetPath(), nginxModuleBuildDir)
|
||||
detailBuildDir := path.Join(detailDir, nginxModuleBuildDir)
|
||||
if !fileOp.Stat(installBuildDir) {
|
||||
if err := fileOp.CreateDir(installBuildDir, constant.DirPerm); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := fileOp.DeleteDir(path.Join(installBuildDir, nginxModuleTmpDir)); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := fileOp.CopyDir(path.Join(detailBuildDir, nginxModuleTmpDir), installBuildDir); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := fileOp.CopyFile(path.Join(detailBuildDir, "Dockerfile"), installBuildDir); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := syncNginxModuleBuilder(detailBuildDir, installBuildDir); err != nil {
|
||||
return err
|
||||
}
|
||||
targetCatalogSource := path.Join(detailBuildDir, nginxModuleCatalogFile)
|
||||
if !fileOp.Stat(targetCatalogSource) {
|
||||
return fmt.Errorf("target OpenResty module catalog not found: %s", targetCatalogSource)
|
||||
}
|
||||
targetNginxCatalogPath = path.Join(installBuildDir, nginxModuleCatalogPendingFile)
|
||||
if err := stageNginxModuleCatalog(targetCatalogSource, targetNginxCatalogPath); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := fileOp.CopyFile(path.Join(detailBuildDir, "nginx.conf"), installBuildDir); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := fileOp.CopyFile(path.Join(detailBuildDir, "nginx.vh.default.conf"), installBuildDir); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
sourceScripts := path.Join(detailDir, "scripts")
|
||||
if fileOp.Stat(sourceScripts) {
|
||||
dstScripts := path.Join(install.GetPath(), "scripts")
|
||||
_ = fileOp.DeleteDir(dstScripts)
|
||||
_ = fileOp.CreateDir(dstScripts, constant.DirPerm)
|
||||
scriptCmd := exec.Command("cp", "-rf", sourceScripts+"/.", dstScripts+"/")
|
||||
_, _ = scriptCmd.CombinedOutput()
|
||||
}
|
||||
|
||||
var newCompose string
|
||||
if err = migrateOpenclawProtocolUpgrade(&install, oldVersion, detail.Version); err != nil {
|
||||
return err
|
||||
}
|
||||
if req.DockerCompose == "" {
|
||||
if install.App.Key == vllmAppKeyForUpgrade {
|
||||
newCompose = install.DockerCompose
|
||||
} else {
|
||||
newCompose, err = getUpgradeCompose(install, detail)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
} else {
|
||||
newCompose = req.DockerCompose
|
||||
}
|
||||
|
||||
install.DockerCompose = newCompose
|
||||
install.Version = detail.Version
|
||||
install.AppDetailId = req.DetailID
|
||||
|
||||
var oldImageIDs []appImageID
|
||||
if req.DeleteImage {
|
||||
dockerCLi, err := docker.NewClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
oldImageIDs, err = getAppImageIDsByCompose(dockerCLi, oldEnvContent, []byte(oldDockerCompose))
|
||||
dockerCLi.Close()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if req.PullImage {
|
||||
images, err := docker.GetImagesFromDockerCompose(content, []byte(install.DockerCompose))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
dockerCLi, err := docker.NewClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer dockerCLi.Close()
|
||||
for _, image := range images {
|
||||
t.Log(i18n.GetWithName("PullImageStart", image))
|
||||
if pullErr := dockerCLi.PullImageWithProcess(t, image); pullErr != nil {
|
||||
if exist, _ := dockerCLi.ImageExists(image); exist {
|
||||
t.Log(i18n.GetMsgByKey("UseExistImage"))
|
||||
continue
|
||||
}
|
||||
return buserr.WithNameAndErr("ErrDockerPullImage", "", pullErr)
|
||||
}
|
||||
exist, err := dockerCLi.ImageExists(image)
|
||||
if err != nil || !exist {
|
||||
return buserr.WithNameAndErr("ErrDockerPullImage", "", fmt.Errorf("image %s does not exist after pull: %v", image, err))
|
||||
}
|
||||
t.LogSuccess(i18n.GetMsgByKey("PullImage"))
|
||||
}
|
||||
}
|
||||
|
||||
if install.App.Key == constant.AppOpenresty {
|
||||
modules, moduleErr := loadNginxModulesWithCatalog(install, targetNginxCatalogPath)
|
||||
if moduleErr != nil {
|
||||
return moduleErr
|
||||
}
|
||||
// Build dynamic modules for the target version before stopping the
|
||||
// current container. Static modules retain the full rebuild path.
|
||||
if !hasEnabledStaticNginxModules(modules) {
|
||||
previousModules := cloneNginxModules(modules)
|
||||
modules, moduleErr = buildDynamicNginxModules(install, modules, nil, false, "", targetNginxCatalogPath, t)
|
||||
if moduleErr != nil {
|
||||
return moduleErr
|
||||
}
|
||||
if moduleErr = saveNginxModulesWithCatalog(install, modules, targetNginxCatalogPath); moduleErr != nil {
|
||||
removeNginxModuleOutputsNotReferenced(install, modules, previousModules)
|
||||
return moduleErr
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if out, err := compose.Down(install.GetComposePath()); err != nil {
|
||||
if out != "" {
|
||||
upErr = errors.New(out)
|
||||
return upErr
|
||||
}
|
||||
return err
|
||||
}
|
||||
envs := make(map[string]interface{})
|
||||
if err = json.Unmarshal([]byte(install.Env), &envs); err != nil {
|
||||
return err
|
||||
}
|
||||
envParams := make(map[string]string, len(envs))
|
||||
if install.App.Key == constant.AppOpenresty {
|
||||
packageUrl, _ := env.GetEnvValueByKey(install.GetEnvPath(), "CONTAINER_PACKAGE_URL")
|
||||
addPackage, _ := env.GetEnvValueByKey(install.GetEnvPath(), "RESTY_ADD_PACKAGE_BUILDDEPS")
|
||||
options, _ := env.GetEnvValueByKey(install.GetEnvPath(), "RESTY_CONFIG_OPTIONS_MORE")
|
||||
envParams["CONTAINER_PACKAGE_URL"] = packageUrl
|
||||
envParams["RESTY_ADD_PACKAGE_BUILDDEPS"] = addPackage
|
||||
envParams["RESTY_CONFIG_OPTIONS_MORE"] = options
|
||||
}
|
||||
handleMap(envs, envParams)
|
||||
if err = env.Write(envParams, install.GetEnvPath()); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err = runScript(t, &install, "upgrade"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err = fileOp.WriteFile(install.GetComposePath(), strings.NewReader(install.DockerCompose), constant.FilePerm); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if install.App.Key == constant.AppOpenresty {
|
||||
if err = buildNginx(t, install, targetNginxCatalogPath); err != nil {
|
||||
t.Log(err.Error())
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
logStr := fmt.Sprintf("%s %s", i18n.GetMsgByKey("Run"), i18n.GetMsgByKey("App"))
|
||||
t.Log(logStr)
|
||||
if out, err := compose.Up(install.GetComposePath()); err != nil {
|
||||
if out != "" {
|
||||
return errors.New(out)
|
||||
}
|
||||
return err
|
||||
}
|
||||
t.LogSuccess(logStr)
|
||||
install.Status = constant.StatusRunning
|
||||
if install.App.Key == constant.AppOpenresty {
|
||||
if err = commitStaticNginxModuleBuilds(install, targetNginxCatalogPath, t); err != nil {
|
||||
return err
|
||||
}
|
||||
activeCatalogPath := path.Join(install.GetPath(), nginxModuleBuildDir, nginxModuleCatalogFile)
|
||||
if err = activateNginxModuleCatalogAndCommit(targetNginxCatalogPath, activeCatalogPath, func() error {
|
||||
return appInstallRepo.Save(context.Background(), &install)
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
if err = appInstallRepo.Save(context.Background(), &install); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if nginxUpgradeSnapshot != nil {
|
||||
nginxUpgradeSnapshot.Cleanup()
|
||||
nginxUpgradeSnapshot = nil
|
||||
}
|
||||
if req.DeleteImage {
|
||||
newEnvContent, err := fileOp.GetContent(install.GetEnvPath())
|
||||
if err != nil {
|
||||
t.LogFailedWithErr(i18n.GetMsgByKey("TaskDelete")+i18n.GetMsgByKey("Image"), err)
|
||||
return nil
|
||||
}
|
||||
excludeImages, err := docker.GetImagesFromDockerCompose(newEnvContent, []byte(install.DockerCompose))
|
||||
if err != nil {
|
||||
t.LogFailedWithErr(i18n.GetMsgByKey("TaskDelete")+i18n.GetMsgByKey("Image"), err)
|
||||
return nil
|
||||
}
|
||||
dockerCLi, err := docker.NewClient()
|
||||
if err != nil {
|
||||
t.LogFailedWithErr(i18n.GetMsgByKey("TaskDelete")+i18n.GetMsgByKey("Image"), err)
|
||||
return nil
|
||||
}
|
||||
defer dockerCLi.Close()
|
||||
if err = deleteAppImagesByIDs(t, dockerCLi, oldImageIDs, excludeImages); err != nil {
|
||||
t.LogFailedWithErr(i18n.GetMsgByKey("TaskDelete")+i18n.GetMsgByKey("Image"), err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
rollBackApp := func(t *task.Task) {
|
||||
if req.Backup {
|
||||
t.Log(i18n.GetWithName("AppRecover", install.Name))
|
||||
recoverErr := NewIBackupService().AppRecover(dto.CommonRecover{
|
||||
Name: install.App.Key, DetailName: install.Name, Type: "app", DownloadAccountID: 1, File: backupFile,
|
||||
})
|
||||
if recoverErr == nil {
|
||||
if nginxUpgradeSnapshot != nil {
|
||||
nginxUpgradeSnapshot.Cleanup()
|
||||
nginxUpgradeSnapshot = nil
|
||||
}
|
||||
t.LogSuccess(i18n.GetWithName("AppRecover", install.Name))
|
||||
return
|
||||
}
|
||||
t.LogFailedWithErr(i18n.GetWithName("AppRecover", install.Name), recoverErr)
|
||||
if install.App.Key != constant.AppOpenresty {
|
||||
return
|
||||
}
|
||||
}
|
||||
if install.App.Key == constant.AppOpenresty && nginxUpgradeSnapshot != nil {
|
||||
if out, rollbackErr := compose.Down(install.GetComposePath()); rollbackErr != nil {
|
||||
if out != "" {
|
||||
rollbackErr = fmt.Errorf("%s: %w", out, rollbackErr)
|
||||
}
|
||||
t.LogFailedWithErr(i18n.GetWithName("AppRecover", install.Name), rollbackErr)
|
||||
}
|
||||
if rollbackErr := nginxUpgradeSnapshot.Restore(); rollbackErr != nil {
|
||||
t.LogFailedWithErr(i18n.GetWithName("AppRecover", install.Name), rollbackErr)
|
||||
return
|
||||
}
|
||||
nginxUpgradeSnapshot.Cleanup()
|
||||
nginxUpgradeSnapshot = nil
|
||||
if out, rollbackErr := compose.Up(originalInstall.GetComposePath()); rollbackErr != nil {
|
||||
if out != "" {
|
||||
rollbackErr = fmt.Errorf("%s: %w", out, rollbackErr)
|
||||
}
|
||||
t.LogFailedWithErr(i18n.GetWithName("AppRecover", install.Name), rollbackErr)
|
||||
return
|
||||
}
|
||||
originalInstall.Status = constant.StatusRunning
|
||||
originalInstall.Message = ""
|
||||
if rollbackErr := appInstallRepo.Save(context.Background(), &originalInstall); rollbackErr != nil {
|
||||
t.LogFailedWithErr(i18n.GetWithName("AppRecover", install.Name), rollbackErr)
|
||||
return
|
||||
}
|
||||
install = originalInstall
|
||||
t.LogSuccess(i18n.GetWithName("AppRecover", install.Name))
|
||||
return
|
||||
}
|
||||
if install.App.Key == constant.AppOpenresty {
|
||||
if rollbackErr := appInstallRepo.Save(context.Background(), &originalInstall); rollbackErr != nil {
|
||||
t.LogFailedWithErr(i18n.GetWithName("AppRecover", install.Name), rollbackErr)
|
||||
return
|
||||
}
|
||||
install = originalInstall
|
||||
t.LogSuccess(i18n.GetWithName("AppRecover", install.Name))
|
||||
}
|
||||
}
|
||||
|
||||
upgradeTimeout := 1 * time.Hour
|
||||
if install.App.Key == constant.AppOpenresty {
|
||||
// Dynamic modules are built serially and each Docker build has its own
|
||||
// timeout. An outer deadline would start rollback while upgradeApp is
|
||||
// still mutating the installation because SubTask does not stop its
|
||||
// action goroutine on timeout.
|
||||
upgradeTimeout = 0
|
||||
}
|
||||
upgradeTask.AddSubTaskWithOps(task.GetTaskName(install.Name, task.TaskUpgrade, task.TaskScopeApp), upgradeApp, rollBackApp, 0, upgradeTimeout)
|
||||
|
||||
upgradingInstall := install
|
||||
if err = appInstallRepo.Save(context.Background(), &upgradingInstall); err != nil {
|
||||
return err
|
||||
}
|
||||
go func() {
|
||||
if taskErr := upgradeTask.Execute(); taskErr != nil {
|
||||
existInstall, _ := appInstallRepo.GetFirst(repo.WithByID(req.InstallID))
|
||||
if existInstall.ID > 0 && existInstall.Status != constant.StatusRunning {
|
||||
existInstall.Status = constant.StatusUpgradeErr
|
||||
existInstall.Message = taskErr.Error()
|
||||
_ = appInstallRepo.Save(context.Background(), &existInstall)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func skipCheckStatus(service types.ServiceConfig) bool {
|
||||
for key := range service.Labels {
|
||||
if key == "skipStatusCheck" {
|
||||
@@ -2233,6 +1822,10 @@ func isHostModel(dockerCompose string) bool {
|
||||
}
|
||||
|
||||
func copyAppDetailMissing(fileOp files.FileOp, srcDir, dstDir string) error {
|
||||
return copyAppDetailMissingTracked(fileOp, srcDir, dstDir, nil)
|
||||
}
|
||||
|
||||
func copyAppDetailMissingTracked(fileOp files.FileOp, srcDir, dstDir string, createdPaths *[]string) error {
|
||||
entries, err := os.ReadDir(srcDir)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -2244,6 +1837,9 @@ func copyAppDetailMissing(fileOp files.FileOp, srcDir, dstDir string) error {
|
||||
srcPath := path.Join(srcDir, entry.Name())
|
||||
dstPath := path.Join(dstDir, entry.Name())
|
||||
if !fileOp.Stat(dstPath) {
|
||||
if createdPaths != nil {
|
||||
*createdPaths = append(*createdPaths, dstPath)
|
||||
}
|
||||
if entry.IsDir() {
|
||||
if err := fileOp.CopyDir(srcPath, dstDir); err != nil {
|
||||
return err
|
||||
@@ -2258,7 +1854,7 @@ func copyAppDetailMissing(fileOp files.FileOp, srcDir, dstDir string) error {
|
||||
if !entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
if err := copyAppDetailMissing(fileOp, srcPath, dstPath); err != nil {
|
||||
if err := copyAppDetailMissingTracked(fileOp, srcPath, dstPath, createdPaths); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,6 +90,34 @@ func (u *BackupService) AppBackup(req dto.CommonBackup) (*model.BackupRecord, er
|
||||
return record, nil
|
||||
}
|
||||
|
||||
func backupAppWithParentTask(install *model.AppInstall, parentTask *task.Task, fileName string) (*model.BackupRecord, error) {
|
||||
itemDir := fmt.Sprintf("app/%s/%s", install.App.Key, install.Name)
|
||||
backupDir := path.Join(global.Dir.LocalBackupDir, itemDir)
|
||||
record := &model.BackupRecord{
|
||||
Type: "app",
|
||||
Name: install.App.Key,
|
||||
DetailName: install.Name,
|
||||
SourceAccountIDs: "1",
|
||||
DownloadAccountID: 1,
|
||||
FileDir: itemDir,
|
||||
FileName: fileName,
|
||||
TaskID: parentTask.TaskID,
|
||||
Status: constant.StatusWaiting,
|
||||
}
|
||||
if err := backupRepo.CreateRecord(record); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := handleAppBackup(install, parentTask, record.ID, backupDir, fileName, "", "", parentTask.TaskID); err != nil {
|
||||
markBackupFailed(record.ID, err)
|
||||
record.Status = constant.StatusFailed
|
||||
record.Message = err.Error()
|
||||
return record, err
|
||||
}
|
||||
backupRepo.UpdateRecordByMap(record.ID, map[string]interface{}{"status": constant.StatusSuccess})
|
||||
record.Status = constant.StatusSuccess
|
||||
return record, nil
|
||||
}
|
||||
|
||||
func (u *BackupService) AppRecover(req dto.CommonRecover) error {
|
||||
app, err := appRepo.GetFirst(appRepo.WithKey(req.Name))
|
||||
if err != nil {
|
||||
@@ -203,7 +231,11 @@ func handleAppRecover(install *model.AppInstall, parentTask *task.Task, recoverF
|
||||
return err
|
||||
}
|
||||
defer func() {
|
||||
_, _ = compose.Up(install.GetComposePath())
|
||||
if isRollback {
|
||||
_, _ = compose.UpWithoutPull(install.GetComposePath())
|
||||
} else {
|
||||
_, _ = compose.Up(install.GetComposePath())
|
||||
}
|
||||
_ = os.RemoveAll(strings.ReplaceAll(recoverFile, ".tar.gz", ""))
|
||||
}()
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path"
|
||||
@@ -39,6 +40,7 @@ func (u *BackupService) MysqlBackup(req dto.CommonBackup) error {
|
||||
TaskID: req.TaskID,
|
||||
Status: constant.StatusWaiting,
|
||||
Description: req.Description,
|
||||
Args: encodeBackupArgs(req.Args),
|
||||
}
|
||||
if err := backupRepo.CreateRecord(record); err != nil {
|
||||
global.LOG.Errorf("save backup record failed, err: %v", err)
|
||||
@@ -143,6 +145,14 @@ func handleMysqlRecover(req dto.CommonRecover, parentTask *task.Task, isRollback
|
||||
|
||||
if !isRollback {
|
||||
rollbackFile := path.Join(global.Dir.TmpDir, fmt.Sprintf("database/%s/%s_%s.sql.gz", req.Type, req.DetailName, time.Now().Format(constant.DateTimeSlimLayout)))
|
||||
var rollbackArgs []string
|
||||
if req.BackupRecordID != 0 {
|
||||
record, err := backupRepo.GetRecord(repo.WithByID(req.BackupRecordID))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rollbackArgs = decodeBackupArgs(record.Args)
|
||||
}
|
||||
if err := cli.Backup(client.BackupInfo{
|
||||
Name: req.DetailName,
|
||||
Type: req.Type,
|
||||
@@ -150,6 +160,7 @@ func handleMysqlRecover(req dto.CommonRecover, parentTask *task.Task, isRollback
|
||||
Format: dbInfo.Format,
|
||||
TargetDir: path.Dir(rollbackFile),
|
||||
FileName: path.Base(rollbackFile),
|
||||
Args: rollbackArgs,
|
||||
}); err != nil {
|
||||
return fmt.Errorf("backup mysql db %s for rollback before recover failed, err: %v", req.DetailName, err)
|
||||
}
|
||||
@@ -242,6 +253,36 @@ func doMysqlBackup(db DatabaseHelper, targetDir, fileName, secret string) error
|
||||
return nil
|
||||
}
|
||||
|
||||
func encodeBackupArgs(args []string) string {
|
||||
var items []string
|
||||
for _, arg := range args {
|
||||
if len(arg) != 0 {
|
||||
items = append(items, arg)
|
||||
}
|
||||
}
|
||||
if len(items) == 0 {
|
||||
return ""
|
||||
}
|
||||
data, err := json.Marshal(items)
|
||||
if err != nil {
|
||||
global.LOG.Warnf("marshal backup args failed: %v", err)
|
||||
return ""
|
||||
}
|
||||
return string(data)
|
||||
}
|
||||
|
||||
func decodeBackupArgs(value string) []string {
|
||||
if len(value) == 0 {
|
||||
return nil
|
||||
}
|
||||
var args []string
|
||||
if err := json.Unmarshal([]byte(value), &args); err != nil {
|
||||
global.LOG.Warnf("unmarshal backup args failed: %v", err)
|
||||
return nil
|
||||
}
|
||||
return args
|
||||
}
|
||||
|
||||
func loadSqlFile(file string) (string, error) {
|
||||
if !strings.HasSuffix(file, ".tar.gz") && !strings.HasSuffix(file, ".zip") {
|
||||
return file, nil
|
||||
|
||||
@@ -71,6 +71,7 @@ type IContainerService interface {
|
||||
ComposeOperation(req dto.ComposeOperation) error
|
||||
TestCompose(req dto.ComposeCreate) (bool, error)
|
||||
ComposeUpdate(req dto.ComposeUpdate) error
|
||||
ComposePin(req dto.ComposePin) error
|
||||
ComposeLogClean(req dto.ComposeLogClean) error
|
||||
|
||||
ContainerCreate(req dto.ContainerOperate, inThread bool) error
|
||||
@@ -2025,6 +2026,9 @@ func loadComposeCount(client *client.Client) int {
|
||||
}
|
||||
}
|
||||
for _, compose := range composeCreatedByLocal {
|
||||
if len(compose.Path) == 0 {
|
||||
continue
|
||||
}
|
||||
if _, has := composeMap[compose.Name]; !has {
|
||||
composeMap[compose.Name] = struct{}{}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
@@ -23,8 +24,10 @@ import (
|
||||
"github.com/1Panel-dev/1Panel/agent/utils/cmd"
|
||||
"github.com/1Panel-dev/1Panel/agent/utils/compose"
|
||||
"github.com/1Panel-dev/1Panel/agent/utils/docker"
|
||||
"github.com/1Panel-dev/1Panel/agent/utils/re"
|
||||
"github.com/docker/docker/api/types/container"
|
||||
"github.com/docker/docker/api/types/filters"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
const composeProjectLabel = "com.docker.compose.project"
|
||||
@@ -52,7 +55,15 @@ func (u *ContainerService) PageCompose(req dto.SearchWithPage) (int64, interface
|
||||
return 0, nil, err
|
||||
}
|
||||
|
||||
composeCreatedByLocal, _ := composeRepo.ListRecord()
|
||||
composeRecords, _ := composeRepo.ListRecord()
|
||||
pinnedByName := make(map[string]bool, len(composeRecords))
|
||||
composeCreatedByLocal := make([]model.Compose, 0, len(composeRecords))
|
||||
for _, record := range composeRecords {
|
||||
pinnedByName[record.Name] = record.IsPinned
|
||||
if len(record.Path) != 0 {
|
||||
composeCreatedByLocal = append(composeCreatedByLocal, record)
|
||||
}
|
||||
}
|
||||
composeLocalMap := make(map[string]dto.ComposeInfo)
|
||||
for _, localItem := range composeCreatedByLocal {
|
||||
composeItemLocal := dto.ComposeInfo{
|
||||
@@ -136,6 +147,7 @@ func (u *ContainerService) PageCompose(req dto.SearchWithPage) (int64, interface
|
||||
for key, value := range mergedMap {
|
||||
value.Name = key
|
||||
value.ComposeFileExists = composeFileExists(value.Workdir, value.ConfigFile)
|
||||
value.IsPinned = pinnedByName[key]
|
||||
records = append(records, value)
|
||||
}
|
||||
if len(req.Info) != 0 {
|
||||
@@ -149,7 +161,21 @@ func (u *ContainerService) PageCompose(req dto.SearchWithPage) (int64, interface
|
||||
}
|
||||
}
|
||||
}
|
||||
if req.ExcludeAppStore {
|
||||
length, count := len(records), 0
|
||||
for count < length {
|
||||
if records[count].CreatedBy == "Apps" {
|
||||
records = append(records[:count], records[(count+1):]...)
|
||||
length--
|
||||
} else {
|
||||
count++
|
||||
}
|
||||
}
|
||||
}
|
||||
sort.Slice(records, func(i, j int) bool {
|
||||
if records[i].IsPinned != records[j].IsPinned {
|
||||
return records[i].IsPinned
|
||||
}
|
||||
return records[i].CreatedAt > records[j].CreatedAt
|
||||
})
|
||||
total, start, end := len(records), (req.Page-1)*req.PageSize, req.Page*req.PageSize
|
||||
@@ -189,53 +215,62 @@ func composeFileExists(workdir, configFile string) bool {
|
||||
}
|
||||
|
||||
func (u *ContainerService) TestCompose(req dto.ComposeCreate) (bool, error) {
|
||||
if cmd.CheckIllegal(req.Path) {
|
||||
if err := validateComposeCreateName(req); err != nil {
|
||||
return false, err
|
||||
}
|
||||
if hasIllegalComposeCreateInput(req) {
|
||||
return false, buserr.New("ErrCmdIllegal")
|
||||
}
|
||||
composeItem, _ := composeRepo.GetRecord(repo.WithByName(req.Name))
|
||||
if composeItem.ID != 0 {
|
||||
return false, buserr.New("ErrRecordExist")
|
||||
}
|
||||
if err := u.loadPath(&req); err != nil {
|
||||
return false, err
|
||||
}
|
||||
if err := newComposeEnv(req.Path, req.Env); err != nil {
|
||||
return false, err
|
||||
}
|
||||
cmd := getComposeCmd(req.Path, "config")
|
||||
stdout, err := cmd.CombinedOutput()
|
||||
projectName, err := resolveComposeCreateProjectName(req)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("docker-compose config failed, std: %s, err: %v", string(stdout), err)
|
||||
return false, err
|
||||
}
|
||||
if err := checkComposeCreateDuplicate(req, projectName); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (u *ContainerService) CreateCompose(req dto.ComposeCreate) error {
|
||||
if cmd.CheckIllegal(req.Name, req.Path) {
|
||||
if err := validateComposeCreateName(req); err != nil {
|
||||
return err
|
||||
}
|
||||
if hasIllegalComposeCreateInput(req) {
|
||||
return buserr.New("ErrCmdIllegal")
|
||||
}
|
||||
projectName, err := resolveComposeCreateProjectName(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := checkComposeCreateDuplicate(req, projectName); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := u.loadPath(&req); err != nil {
|
||||
return err
|
||||
}
|
||||
if req.From == "path" {
|
||||
req.Name = path.Base(path.Dir(req.Path))
|
||||
if err := newComposeEnv(req.Path, req.Env); err != nil {
|
||||
return err
|
||||
}
|
||||
req.Name = projectName
|
||||
taskItem, err := task.NewTaskWithOps(req.Name, task.TaskCreate, task.TaskScopeCompose, req.TaskID, 1)
|
||||
if err != nil {
|
||||
return fmt.Errorf("new task for image build failed, err: %v", err)
|
||||
}
|
||||
if err := newComposeEnv(req.Path, req.Env); err != nil {
|
||||
return err
|
||||
}
|
||||
go func() {
|
||||
taskItem.AddSubTask(i18n.GetMsgByKey("ComposeCreate"), func(t *task.Task) error {
|
||||
err := compose.UpWithTask(req.Path, t, req.ForcePull)
|
||||
err := compose.UpWithTask(req.Path, t, req.ForcePull, req.Name)
|
||||
t.LogWithStatus(i18n.GetMsgByKey("ComposeCreate"), err)
|
||||
if err != nil {
|
||||
_, _ = compose.Down(req.Path)
|
||||
_, _ = compose.Down(req.Path, req.Name)
|
||||
return err
|
||||
}
|
||||
_ = composeRepo.CreateRecord(&model.Compose{Name: strings.ToLower(req.Name), Path: req.Path})
|
||||
recordName := strings.ToLower(req.Name)
|
||||
record, _ := composeRepo.GetRecord(repo.WithByName(recordName))
|
||||
if record.ID == 0 {
|
||||
_ = composeRepo.CreateRecord(&model.Compose{Name: recordName, Path: req.Path})
|
||||
} else {
|
||||
_ = composeRepo.UpdateRecord(recordName, map[string]interface{}{"path": req.Path})
|
||||
}
|
||||
return nil
|
||||
}, nil)
|
||||
_ = taskItem.Execute()
|
||||
@@ -244,6 +279,243 @@ func (u *ContainerService) CreateCompose(req dto.ComposeCreate) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func checkComposeRecordName(name string) error {
|
||||
composeItem, _ := composeRepo.GetRecord(repo.WithByName(name))
|
||||
if composeItem.ID != 0 && len(composeItem.Path) != 0 {
|
||||
return buserr.New("ErrRecordExist")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func checkComposeCreateDuplicate(req dto.ComposeCreate, projectName string) error {
|
||||
if err := checkComposeRecordName(projectName); err != nil {
|
||||
return err
|
||||
}
|
||||
if req.From == "path" {
|
||||
return nil
|
||||
}
|
||||
composeItem, _ := composeRepo.GetRecord(repo.WithByPath(composeCreatePath(req)))
|
||||
if composeItem.ID != 0 && composeItem.Path != "" {
|
||||
return buserr.New("ErrRecordExist")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateComposeCreateName(req dto.ComposeCreate) error {
|
||||
if req.From == "path" {
|
||||
name := strings.TrimSpace(req.Name)
|
||||
if name != "" && !re.GetRegex(re.ComposeNamePattern).MatchString(name) {
|
||||
return buserr.New("ErrComposeNameInvalid")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if !re.GetRegex(re.ComposeNamePattern).MatchString(composeCreateDirName(req)) {
|
||||
return buserr.New("ErrComposeNameInvalid")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func hasIllegalComposeCreateInput(req dto.ComposeCreate) bool {
|
||||
if req.From == "path" {
|
||||
return cmd.CheckIllegal(req.Name, req.Path)
|
||||
}
|
||||
return cmd.CheckIllegal(composeCreateDirName(req))
|
||||
}
|
||||
|
||||
func composeCreateDirName(req dto.ComposeCreate) string {
|
||||
dirName := strings.TrimSpace(req.DirName)
|
||||
if dirName == "" {
|
||||
// Keep compatibility with callers that used name as both the directory and
|
||||
// Compose project name before dirName was introduced.
|
||||
return strings.TrimSpace(req.Name)
|
||||
}
|
||||
return dirName
|
||||
}
|
||||
|
||||
func composeCreatePath(req dto.ComposeCreate) string {
|
||||
return filepath.Join(global.Dir.DataDir, "docker", "compose", composeCreateDirName(req), "docker-compose.yml")
|
||||
}
|
||||
|
||||
func resolveComposeCreateProjectName(req dto.ComposeCreate) (string, error) {
|
||||
if req.From == "path" {
|
||||
envPath, err := createComposeTempFile(
|
||||
filepath.Dir(primaryComposePath(req.Path)),
|
||||
".1panel-compose-*.env",
|
||||
req.Env,
|
||||
)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer os.Remove(envPath)
|
||||
return resolveComposeProjectName(req.Path, req.Name, envPath)
|
||||
}
|
||||
|
||||
dir := filepath.Dir(composeCreatePath(req))
|
||||
cleanupDir, err := prepareComposeStagingDir(dir)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer cleanupDir()
|
||||
|
||||
composePath, err := createComposeTempFile(dir, ".1panel-compose-*.yml", req.File)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer os.Remove(composePath)
|
||||
|
||||
envPath, err := createComposeTempFile(dir, ".1panel-compose-*.env", req.Env)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer os.Remove(envPath)
|
||||
|
||||
return resolveComposeProjectName(composePath, "", envPath)
|
||||
}
|
||||
|
||||
func createComposeTempFile(dir, pattern, content string) (string, error) {
|
||||
file, err := os.CreateTemp(dir, pattern)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
filePath := file.Name()
|
||||
if _, err := file.WriteString(content); err != nil {
|
||||
_ = file.Close()
|
||||
_ = os.Remove(filePath)
|
||||
return "", err
|
||||
}
|
||||
if err := file.Close(); err != nil {
|
||||
_ = os.Remove(filePath)
|
||||
return "", err
|
||||
}
|
||||
return filePath, nil
|
||||
}
|
||||
|
||||
func prepareComposeStagingDir(dir string) (func(), error) {
|
||||
if err := os.MkdirAll(filepath.Dir(dir), os.ModePerm); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
created := false
|
||||
if err := os.Mkdir(dir, os.ModePerm); err != nil {
|
||||
if !errors.Is(err, os.ErrExist) {
|
||||
return nil, err
|
||||
}
|
||||
} else {
|
||||
created = true
|
||||
}
|
||||
return func() {
|
||||
if created {
|
||||
_ = os.Remove(dir)
|
||||
}
|
||||
}, nil
|
||||
}
|
||||
|
||||
func resolveComposeProjectName(composePath, fallbackName, envFile string) (string, error) {
|
||||
// Preserve the name resolved by Compose (including a top-level name) so the
|
||||
// container label and the local record always use the same project identity.
|
||||
parentName := normalizeComposeProjectName(path.Base(path.Dir(primaryComposePath(composePath))))
|
||||
fallbackName = strings.TrimSpace(fallbackName)
|
||||
stdout, err := runComposeConfig(composePath, "", envFile)
|
||||
if err == nil {
|
||||
projectName, parseErr := loadComposeProjectName(stdout)
|
||||
if parseErr != nil {
|
||||
return "", parseErr
|
||||
}
|
||||
if projectName != "" {
|
||||
if !re.GetRegex(re.ComposeNamePattern).MatchString(projectName) {
|
||||
return "", buserr.New("ErrComposeNameInvalid")
|
||||
}
|
||||
return projectName, nil
|
||||
}
|
||||
if parentName != "" {
|
||||
return parentName, nil
|
||||
}
|
||||
if fallbackName != "" {
|
||||
if _, fallbackErr := runComposeConfig(composePath, fallbackName, envFile); fallbackErr != nil {
|
||||
return "", fallbackErr
|
||||
}
|
||||
return fallbackName, nil
|
||||
}
|
||||
return "", buserr.New("ErrComposeProjectNameEmpty")
|
||||
}
|
||||
if !isComposeProjectNameEmptyError(err) {
|
||||
return "", err
|
||||
}
|
||||
|
||||
resolveErr := err
|
||||
if parentName != "" {
|
||||
if _, parentErr := runComposeConfig(composePath, parentName, envFile); parentErr == nil {
|
||||
return parentName, nil
|
||||
} else {
|
||||
resolveErr = parentErr
|
||||
}
|
||||
}
|
||||
|
||||
if fallbackName != "" && fallbackName != parentName {
|
||||
if _, fallbackErr := runComposeConfig(composePath, fallbackName, envFile); fallbackErr == nil {
|
||||
return fallbackName, nil
|
||||
} else {
|
||||
return "", fallbackErr
|
||||
}
|
||||
}
|
||||
if parentName == "" && fallbackName == "" {
|
||||
return "", buserr.New("ErrComposeProjectNameEmpty")
|
||||
}
|
||||
return "", resolveErr
|
||||
}
|
||||
|
||||
func runComposeConfig(composePath, projectName, envFile string) ([]byte, error) {
|
||||
configCmd := getComposeCmdWithEnv(composePath, "config", envFile, projectName)
|
||||
stdout, err := configCmd.Output()
|
||||
if err != nil {
|
||||
var stderr []byte
|
||||
if exitErr, ok := err.(*exec.ExitError); ok {
|
||||
stderr = exitErr.Stderr
|
||||
}
|
||||
return nil, fmt.Errorf("docker-compose config failed, std: %s, err: %v", mergeComposeOutput(stdout, stderr), err)
|
||||
}
|
||||
return stdout, nil
|
||||
}
|
||||
|
||||
func mergeComposeOutput(stdout, stderr []byte) string {
|
||||
outputs := make([]string, 0, 2)
|
||||
if output := strings.TrimSpace(string(stdout)); output != "" {
|
||||
outputs = append(outputs, output)
|
||||
}
|
||||
if output := strings.TrimSpace(string(stderr)); output != "" {
|
||||
outputs = append(outputs, output)
|
||||
}
|
||||
return strings.Join(outputs, "\n")
|
||||
}
|
||||
|
||||
func loadComposeProjectName(config []byte) (string, error) {
|
||||
var project struct {
|
||||
Name string `yaml:"name"`
|
||||
}
|
||||
if err := yaml.Unmarshal(config, &project); err != nil {
|
||||
return "", buserr.WithDetail("ErrComposeProjectNameParse", err.Error(), err)
|
||||
}
|
||||
return strings.TrimSpace(project.Name), nil
|
||||
}
|
||||
|
||||
func primaryComposePath(composePath string) string {
|
||||
if index := strings.Index(composePath, ","); index >= 0 {
|
||||
return composePath[:index]
|
||||
}
|
||||
return composePath
|
||||
}
|
||||
|
||||
func normalizeComposeProjectName(name string) string {
|
||||
name = re.GetRegex(re.ComposeDisallowedCharsPattern).
|
||||
ReplaceAllString(strings.ToLower(strings.TrimSpace(name)), "")
|
||||
return strings.TrimLeft(name, "_-")
|
||||
}
|
||||
|
||||
func isComposeProjectNameEmptyError(err error) bool {
|
||||
message := strings.ToLower(err.Error())
|
||||
return strings.Contains(message, "project name must not be empty") ||
|
||||
strings.Contains(message, "project name can't be empty")
|
||||
}
|
||||
|
||||
func (u *ContainerService) ComposeOperation(req dto.ComposeOperation) error {
|
||||
if len(req.Path) == 0 && req.Operation == "delete" {
|
||||
_ = composeRepo.DeleteRecord(repo.WithByName(req.Name))
|
||||
@@ -267,15 +539,15 @@ func (u *ContainerService) ComposeOperation(req dto.ComposeOperation) error {
|
||||
return nil
|
||||
}
|
||||
if req.Operation == "up" {
|
||||
if stdout, err := compose.Up(req.Path); err != nil {
|
||||
if stdout, err := compose.Up(req.Path, req.Name); err != nil {
|
||||
return fmt.Errorf("docker-compose up failed, std: %s, err: %v", stdout, err)
|
||||
}
|
||||
} else if req.Operation == "rebuild" {
|
||||
if stdout, err := compose.DownAndUp(req.Path); err != nil {
|
||||
if stdout, err := compose.DownAndUp(req.Path, req.Name); err != nil {
|
||||
return fmt.Errorf("docker-compose rebuild failed, std: %s, err: %v", stdout, err)
|
||||
}
|
||||
} else {
|
||||
if stdout, err := compose.Operate(req.Path, req.Operation); err != nil {
|
||||
if stdout, err := compose.Operate(req.Path, req.Operation, req.Name); err != nil {
|
||||
return fmt.Errorf("docker-compose %s failed, std: %s, err: %v", req.Operation, stdout, err)
|
||||
}
|
||||
}
|
||||
@@ -311,9 +583,9 @@ func (u *ContainerService) ComposeUpdate(req dto.ComposeUpdate) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := compose.UpWithTask(req.Path, t, req.ForcePull); err != nil {
|
||||
if err := compose.UpWithTask(req.Path, t, req.ForcePull, req.Name); err != nil {
|
||||
global.LOG.Errorf("update failed when handle compose up, err: %s, now try to recreate the old compose file", err)
|
||||
if err := recreateCompose(string(oldFile), req.Path); err != nil {
|
||||
if err := recreateCompose(string(oldFile), req.Path, req.Name); err != nil {
|
||||
return fmt.Errorf("update failed and recreate old compose file also failed, err: %v", err)
|
||||
}
|
||||
return fmt.Errorf("update failed when handle compose up, err: %s", err)
|
||||
@@ -327,6 +599,20 @@ func (u *ContainerService) ComposeUpdate(req dto.ComposeUpdate) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (u *ContainerService) ComposePin(req dto.ComposePin) error {
|
||||
record, _ := composeRepo.GetRecord(repo.WithByName(req.Name))
|
||||
if record.ID == 0 {
|
||||
if !req.IsPinned {
|
||||
return nil
|
||||
}
|
||||
return composeRepo.CreateRecord(&model.Compose{Name: req.Name, IsPinned: true})
|
||||
}
|
||||
if !req.IsPinned && len(record.Path) == 0 {
|
||||
return composeRepo.DeleteRecord(repo.WithByName(req.Name))
|
||||
}
|
||||
return composeRepo.UpdateRecord(req.Name, map[string]interface{}{"is_pinned": req.IsPinned})
|
||||
}
|
||||
|
||||
func (u *ContainerService) ComposeLogClean(req dto.ComposeLogClean) error {
|
||||
client, err := docker.NewDockerClient()
|
||||
if err != nil {
|
||||
@@ -389,15 +675,15 @@ func (u *ContainerService) LoadComposeEnv(name string) (string, error) {
|
||||
|
||||
func (u *ContainerService) loadPath(req *dto.ComposeCreate) error {
|
||||
if req.From == "template" || req.From == "edit" {
|
||||
dir := fmt.Sprintf("%s/docker/compose/%s", global.Dir.DataDir, req.Name)
|
||||
composePath := composeCreatePath(*req)
|
||||
dir := filepath.Dir(composePath)
|
||||
if _, err := os.Stat(dir); err != nil && os.IsNotExist(err) {
|
||||
if err = os.MkdirAll(dir, os.ModePerm); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
path := fmt.Sprintf("%s/docker-compose.yml", dir)
|
||||
file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, constant.FilePerm)
|
||||
file, err := os.OpenFile(composePath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, constant.FilePerm)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -405,14 +691,14 @@ func (u *ContainerService) loadPath(req *dto.ComposeCreate) error {
|
||||
write := bufio.NewWriter(file)
|
||||
_, _ = write.WriteString(string(req.File))
|
||||
write.Flush()
|
||||
req.Path = path
|
||||
req.Path = composePath
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func removeContainerForCompose(composeName, composePath string) error {
|
||||
if _, err := os.Stat(composePath); err == nil {
|
||||
if stdout, err := compose.Operate(composePath, "down"); err != nil {
|
||||
if stdout, err := compose.Operate(composePath, "down", composeName); err != nil {
|
||||
return errors.New(stdout)
|
||||
}
|
||||
return nil
|
||||
@@ -437,7 +723,7 @@ func removeContainerForCompose(composeName, composePath string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func recreateCompose(content, path string) error {
|
||||
func recreateCompose(content, path, projectName string) error {
|
||||
file, err := os.OpenFile(path, os.O_WRONLY|os.O_TRUNC, 0640)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -447,7 +733,7 @@ func recreateCompose(content, path string) error {
|
||||
_, _ = write.WriteString(content)
|
||||
write.Flush()
|
||||
|
||||
if stdout, err := compose.Up(path); err != nil {
|
||||
if stdout, err := compose.Up(path, projectName); err != nil {
|
||||
return errors.New(string(stdout))
|
||||
}
|
||||
return nil
|
||||
|
||||
@@ -170,6 +170,10 @@ type containerSwitchClient interface {
|
||||
NetworkDisconnect(context.Context, string, string, bool) error
|
||||
}
|
||||
|
||||
type containerInspectClient interface {
|
||||
ContainerInspect(context.Context, string) (container.InspectResponse, error)
|
||||
}
|
||||
|
||||
type containerOperationMutex struct {
|
||||
mutex sync.Mutex
|
||||
locks map[string]*containerOperationLockEntry
|
||||
@@ -335,7 +339,7 @@ const (
|
||||
containerHealthCheckMaxWait = 10 * time.Minute
|
||||
)
|
||||
|
||||
func waitContainerReady(ctx context.Context, cli containerSwitchClient, containerID string) error {
|
||||
func waitContainerReady(ctx context.Context, cli containerInspectClient, containerID string) error {
|
||||
info, err := cli.ContainerInspect(ctx, containerID)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -347,14 +351,15 @@ func waitContainerReady(ctx context.Context, cli containerSwitchClient, containe
|
||||
return waitContainerStable(ctx, cli, containerID, info)
|
||||
}
|
||||
|
||||
initialRestartCount := info.RestartCount
|
||||
timeout := containerHealthCheckTimeout(info.Config)
|
||||
deadline := time.NewTimer(timeout)
|
||||
ticker := time.NewTicker(time.Second)
|
||||
defer deadline.Stop()
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
if info.State.Restarting || info.RestartCount != 0 {
|
||||
return fmt.Errorf("container restarted %d times during startup", info.RestartCount)
|
||||
if info.State.Restarting || info.RestartCount != initialRestartCount {
|
||||
return fmt.Errorf("container restart count changed from %d to %d during startup", initialRestartCount, info.RestartCount)
|
||||
}
|
||||
if info.State.Health == nil {
|
||||
return fmt.Errorf("container health status is unavailable")
|
||||
@@ -382,9 +387,10 @@ func waitContainerReady(ctx context.Context, cli containerSwitchClient, containe
|
||||
}
|
||||
}
|
||||
|
||||
func waitContainerStable(ctx context.Context, cli containerSwitchClient, containerID string, initial container.InspectResponse) error {
|
||||
func waitContainerStable(ctx context.Context, cli containerInspectClient, containerID string, initial container.InspectResponse) error {
|
||||
startedAt := initial.State.StartedAt
|
||||
if err := checkContainerStableState(initial, startedAt); err != nil {
|
||||
restartCount := initial.RestartCount
|
||||
if err := checkContainerStableState(initial, startedAt, restartCount); err != nil {
|
||||
return err
|
||||
}
|
||||
deadline := time.NewTimer(containerStartStabilization)
|
||||
@@ -400,25 +406,25 @@ func waitContainerStable(ctx context.Context, cli containerSwitchClient, contain
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return checkContainerStableState(info, startedAt)
|
||||
return checkContainerStableState(info, startedAt, restartCount)
|
||||
case <-ticker.C:
|
||||
info, err := cli.ContainerInspect(ctx, containerID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := checkContainerStableState(info, startedAt); err != nil {
|
||||
if err := checkContainerStableState(info, startedAt, restartCount); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func checkContainerStableState(info container.InspectResponse, startedAt string) error {
|
||||
func checkContainerStableState(info container.InspectResponse, startedAt string, restartCount int) error {
|
||||
if err := checkContainerRunningState(info); err != nil {
|
||||
return err
|
||||
}
|
||||
if info.State.Restarting || info.RestartCount != 0 {
|
||||
return fmt.Errorf("container restarted %d times during startup", info.RestartCount)
|
||||
if info.State.Restarting || info.RestartCount != restartCount {
|
||||
return fmt.Errorf("container restart count changed from %d to %d during startup", restartCount, info.RestartCount)
|
||||
}
|
||||
if startedAt != "" && info.State.StartedAt != startedAt {
|
||||
return fmt.Errorf("container start time changed during startup")
|
||||
|
||||
@@ -538,11 +538,14 @@ func (u *CronjobService) CleanRecord(req dto.CronjobClean) error {
|
||||
return err
|
||||
}
|
||||
for _, del := range delRecords {
|
||||
if del.Status == constant.StatusWaiting || del.Status == constant.StatusRunning {
|
||||
continue
|
||||
}
|
||||
if err := cronjobRepo.DeleteRecord(repo.WithByID(del.ID)); err != nil {
|
||||
return err
|
||||
}
|
||||
_ = os.RemoveAll(del.Records)
|
||||
}
|
||||
if err := cronjobRepo.DeleteRecord(cronjobRepo.WithByJobID(int(req.CronjobID))); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -163,6 +163,7 @@ func (u *CronjobService) handleDatabase(cronjob model.Cronjob, startTime time.Ti
|
||||
record.Name = dbInfo.Database
|
||||
record.DetailName = dbInfo.Name
|
||||
record.DownloadAccountID, record.SourceAccountIDs = cronjob.DownloadAccountID, cronjob.SourceAccountIDs
|
||||
record.Args = encodeBackupArgs(dbInfo.Args)
|
||||
|
||||
backupDir := path.Join(global.Dir.LocalBackupDir, fmt.Sprintf("tmp/database/%s/%s/%s", dbInfo.DBType, record.Name, dbInfo.Name))
|
||||
switch dbInfo.DBType {
|
||||
|
||||
@@ -184,10 +184,30 @@ func (u *DashboardService) LoadBaseInfo(ioOption string, netOption string) (*dto
|
||||
|
||||
func (u *DashboardService) LoadCurrentInfo(ioOption string, netOption string) *dto.DashboardCurrent {
|
||||
var currentInfo dto.DashboardCurrent
|
||||
hostInfo, _ := psutil.HOST.GetHostInfo(false)
|
||||
currentInfo.Uptime = hostInfo.Uptime
|
||||
currentInfo.TimeSinceUptime = time.Unix(int64(hostInfo.BootTime), 0).Format(constant.DateTimeLayout)
|
||||
currentInfo.RunningTime = loadRunningTime(hostInfo.Uptime)
|
||||
shotTime := time.Now()
|
||||
hostInfo, err := psutil.HOST.GetHostInfo(false)
|
||||
if err != nil {
|
||||
global.LOG.Errorf("load host info failed: %v", err)
|
||||
currentInfo.ShotTime = shotTime
|
||||
return ¤tInfo
|
||||
}
|
||||
|
||||
uptime := hostInfo.Uptime
|
||||
var bootTime uint64
|
||||
if now := shotTime.Unix(); now > 0 {
|
||||
nowUnix := uint64(now)
|
||||
if hostInfo.BootTime > 0 && hostInfo.BootTime <= nowUnix {
|
||||
bootTime = hostInfo.BootTime
|
||||
uptime = nowUnix - bootTime
|
||||
} else if uptime <= nowUnix {
|
||||
bootTime = nowUnix - uptime
|
||||
}
|
||||
}
|
||||
currentInfo.Uptime = uptime
|
||||
currentInfo.RunningTime = loadRunningTime(uptime)
|
||||
if bootTime > 0 {
|
||||
currentInfo.TimeSinceUptime = time.Unix(int64(bootTime), 0).Format(constant.DateTimeLayout)
|
||||
}
|
||||
currentInfo.Procs = hostInfo.Procs
|
||||
currentInfo.CPUTotal, _ = psutil.CPUInfo.GetLogicalCores(false)
|
||||
|
||||
@@ -263,7 +283,7 @@ func (u *DashboardService) LoadCurrentInfo(ioOption string, netOption string) *d
|
||||
}
|
||||
}
|
||||
|
||||
currentInfo.ShotTime = time.Now()
|
||||
currentInfo.ShotTime = shotTime
|
||||
return ¤tInfo
|
||||
}
|
||||
|
||||
|
||||
@@ -28,10 +28,11 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
rollbackPath = "1panel/tmp"
|
||||
upgradePath = "1panel/tmp/upgrade"
|
||||
uploadPath = "1panel/uploads"
|
||||
downloadPath = "1panel/download"
|
||||
rollbackPath = "1panel/tmp"
|
||||
communityRestorePath = "1panel/tmp/community-restore"
|
||||
upgradePath = "1panel/tmp/upgrade"
|
||||
uploadPath = "1panel/uploads"
|
||||
downloadPath = "1panel/download"
|
||||
)
|
||||
|
||||
func (u *DeviceService) Scan() dto.CleanData {
|
||||
@@ -58,7 +59,7 @@ func (u *DeviceService) Scan() dto.CleanData {
|
||||
|
||||
SystemClean.BackupClean = loadBackupTree(fileOp)
|
||||
|
||||
rollBackTree := loadRollBackTree(fileOp)
|
||||
rollBackTree := loadRollBackTree()
|
||||
rollbackSize := uint64(0)
|
||||
for _, rollback := range rollBackTree {
|
||||
rollbackSize += rollback.Size
|
||||
@@ -113,12 +114,15 @@ func (u *DeviceService) Clean(req []dto.Clean) {
|
||||
dropFileOrDir(path.Join(global.Dir.BaseDir, rollbackPath, "app"))
|
||||
dropFileOrDir(path.Join(global.Dir.BaseDir, rollbackPath, "database"))
|
||||
dropFileOrDir(path.Join(global.Dir.BaseDir, rollbackPath, "website"))
|
||||
dropFileOrDir(path.Join(global.Dir.BaseDir, communityRestorePath))
|
||||
case "rollback_app":
|
||||
dropFileOrDir(path.Join(global.Dir.BaseDir, rollbackPath, "app", item.Name))
|
||||
case "rollback_database":
|
||||
dropFileOrDir(path.Join(global.Dir.BaseDir, rollbackPath, "database", item.Name))
|
||||
case "rollback_website":
|
||||
dropFileOrDir(path.Join(global.Dir.BaseDir, rollbackPath, "website", item.Name))
|
||||
case "rollback_community_restore":
|
||||
dropFileOrDir(path.Join(global.Dir.BaseDir, communityRestorePath, item.Name))
|
||||
|
||||
case "upload":
|
||||
dropFileOrDir(path.Join(global.Dir.BaseDir, uploadPath, item.Name))
|
||||
@@ -214,6 +218,7 @@ func doSystemClean(taskItem *task.Task) func(t *task.Task) error {
|
||||
dropWithTask(path.Join(global.Dir.BaseDir, rollbackPath, "app"), taskItem, &size, &fileCount)
|
||||
dropWithTask(path.Join(global.Dir.BaseDir, rollbackPath, "website"), taskItem, &size, &fileCount)
|
||||
dropWithTask(path.Join(global.Dir.BaseDir, rollbackPath, "database"), taskItem, &size, &fileCount)
|
||||
dropWithTask(path.Join(global.Dir.BaseDir, communityRestorePath), taskItem, &size, &fileCount)
|
||||
|
||||
upgrades := path.Join(global.Dir.BaseDir, upgradePath)
|
||||
oldUpgradeFiles, _ := os.ReadDir(upgrades)
|
||||
@@ -606,20 +611,21 @@ func isExactPathMatch(path string, excludePaths []string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func loadRollBackTree(fileOp fileUtils.FileOp) []dto.CleanTree {
|
||||
func loadRollBackTree() []dto.CleanTree {
|
||||
var treeData []dto.CleanTree
|
||||
treeData = loadTreeWithCheck(treeData, path.Join(global.Dir.BaseDir, rollbackPath, "app"), "rollback_app", fileOp)
|
||||
treeData = loadTreeWithCheck(treeData, path.Join(global.Dir.BaseDir, rollbackPath, "website"), "rollback_website", fileOp)
|
||||
treeData = loadTreeWithCheck(treeData, path.Join(global.Dir.BaseDir, rollbackPath, "database"), "rollback_database", fileOp)
|
||||
treeData = loadTreeWithCheck(treeData, path.Join(global.Dir.BaseDir, rollbackPath, "app"), "rollback_app")
|
||||
treeData = loadTreeWithCheck(treeData, path.Join(global.Dir.BaseDir, rollbackPath, "website"), "rollback_website")
|
||||
treeData = loadTreeWithCheck(treeData, path.Join(global.Dir.BaseDir, rollbackPath, "database"), "rollback_database")
|
||||
treeData = loadTreeWithCheck(treeData, path.Join(global.Dir.BaseDir, communityRestorePath), "rollback_community_restore")
|
||||
|
||||
return treeData
|
||||
}
|
||||
|
||||
func loadUploadTree(fileOp fileUtils.FileOp) []dto.CleanTree {
|
||||
var treeData []dto.CleanTree
|
||||
treeData = loadTreeWithCheck(treeData, path.Join(global.Dir.BaseDir, uploadPath, "app"), "upload_app", fileOp)
|
||||
treeData = loadTreeWithCheck(treeData, path.Join(global.Dir.BaseDir, uploadPath, "website"), "upload_website", fileOp)
|
||||
treeData = loadTreeWithCheck(treeData, path.Join(global.Dir.BaseDir, uploadPath, "database"), "upload_database", fileOp)
|
||||
treeData = loadTreeWithCheck(treeData, path.Join(global.Dir.BaseDir, uploadPath, "app"), "upload_app")
|
||||
treeData = loadTreeWithCheck(treeData, path.Join(global.Dir.BaseDir, uploadPath, "website"), "upload_website")
|
||||
treeData = loadTreeWithCheck(treeData, path.Join(global.Dir.BaseDir, uploadPath, "database"), "upload_database")
|
||||
|
||||
path5 := path.Join(global.Dir.BaseDir, uploadPath)
|
||||
uploadTreeData := loadTreeWithAllFile(true, path5, "upload", path5, fileOp)
|
||||
@@ -630,9 +636,9 @@ func loadUploadTree(fileOp fileUtils.FileOp) []dto.CleanTree {
|
||||
|
||||
func loadDownloadTree(fileOp fileUtils.FileOp) []dto.CleanTree {
|
||||
var treeData []dto.CleanTree
|
||||
treeData = loadTreeWithCheck(treeData, path.Join(global.Dir.BaseDir, downloadPath, "app"), "download_app", fileOp)
|
||||
treeData = loadTreeWithCheck(treeData, path.Join(global.Dir.BaseDir, downloadPath, "website"), "download_website", fileOp)
|
||||
treeData = loadTreeWithCheck(treeData, path.Join(global.Dir.BaseDir, downloadPath, "database"), "download_database", fileOp)
|
||||
treeData = loadTreeWithCheck(treeData, path.Join(global.Dir.BaseDir, downloadPath, "app"), "download_app")
|
||||
treeData = loadTreeWithCheck(treeData, path.Join(global.Dir.BaseDir, downloadPath, "website"), "download_website")
|
||||
treeData = loadTreeWithCheck(treeData, path.Join(global.Dir.BaseDir, downloadPath, "database"), "download_database")
|
||||
|
||||
path5 := path.Join(global.Dir.BaseDir, downloadPath)
|
||||
uploadTreeData := loadTreeWithAllFile(true, path5, "download", path5, fileOp)
|
||||
@@ -814,16 +820,59 @@ func loadContainerTree() []dto.CleanTree {
|
||||
return treeData
|
||||
}
|
||||
|
||||
func loadTreeWithCheck(treeData []dto.CleanTree, pathItem, treeType string, fileOp fileUtils.FileOp) []dto.CleanTree {
|
||||
size, _ := fileOp.GetDirSize(pathItem)
|
||||
if size == 0 {
|
||||
func loadTreeWithCheck(treeData []dto.CleanTree, pathItem, treeType string) []dto.CleanTree {
|
||||
list, size := loadTreeWithFileSize(true, pathItem, treeType, pathItem)
|
||||
if len(list) == 0 || size == 0 {
|
||||
return treeData
|
||||
}
|
||||
list := loadTreeWithAllFile(true, pathItem, treeType, pathItem, fileOp)
|
||||
treeData = append(treeData, dto.CleanTree{ID: uuid.NewString(), Label: treeType, Size: uint64(size), IsCheck: size > 0, Children: list, Type: treeType, IsRecommend: true, CanDelete: false})
|
||||
treeData = append(treeData, dto.CleanTree{ID: uuid.NewString(), Label: treeType, Size: size, IsCheck: size > 0, Children: list, Type: treeType, IsRecommend: true, CanDelete: false})
|
||||
return treeData
|
||||
}
|
||||
|
||||
func loadTreeWithFileSize(isCheck bool, originalPath, treeType, pathItem string) ([]dto.CleanTree, uint64) {
|
||||
var (
|
||||
lists []dto.CleanTree
|
||||
total uint64
|
||||
)
|
||||
|
||||
entries, err := os.ReadDir(pathItem)
|
||||
if err != nil {
|
||||
return lists, total
|
||||
}
|
||||
for _, entry := range entries {
|
||||
item := dto.CleanTree{
|
||||
ID: uuid.NewString(),
|
||||
Label: entry.Name(),
|
||||
Type: treeType,
|
||||
Name: strings.TrimPrefix(path.Join(pathItem, entry.Name()), originalPath+"/"),
|
||||
IsCheck: isCheck,
|
||||
IsRecommend: isCheck,
|
||||
CanDelete: true,
|
||||
}
|
||||
entryPath := path.Join(pathItem, entry.Name())
|
||||
if entry.IsDir() {
|
||||
children, size := loadTreeWithFileSize(isCheck, originalPath, treeType, entryPath)
|
||||
if len(children) == 0 {
|
||||
continue
|
||||
}
|
||||
item.Children = children
|
||||
item.Size = size
|
||||
} else {
|
||||
info, err := entry.Info()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
item.Size = uint64(info.Size())
|
||||
}
|
||||
if item.Size == 0 {
|
||||
continue
|
||||
}
|
||||
total += item.Size
|
||||
lists = append(lists, item)
|
||||
}
|
||||
return lists, total
|
||||
}
|
||||
|
||||
func loadTreeWithDir(isCheck bool, treeType, pathItem string, fileOp fileUtils.FileOp) []dto.CleanTree {
|
||||
var lists []dto.CleanTree
|
||||
files, err := os.ReadDir(pathItem)
|
||||
|
||||
@@ -40,12 +40,14 @@ var (
|
||||
settingRepo = repo.NewISettingRepo()
|
||||
backupRepo = repo.NewIBackupRepo()
|
||||
|
||||
websiteRepo = repo.NewIWebsiteRepo()
|
||||
websiteDomainRepo = repo.NewIWebsiteDomainRepo()
|
||||
websiteDnsRepo = repo.NewIWebsiteDnsAccountRepo()
|
||||
websiteSSLRepo = repo.NewISSLRepo()
|
||||
websiteAcmeRepo = repo.NewIAcmeAccountRepo()
|
||||
websiteCARepo = repo.NewIWebsiteCARepo()
|
||||
websiteRepo = repo.NewIWebsiteRepo()
|
||||
websiteDomainRepo = repo.NewIWebsiteDomainRepo()
|
||||
websiteDnsRepo = repo.NewIWebsiteDnsAccountRepo()
|
||||
websiteSSLRepo = repo.NewISSLRepo()
|
||||
websiteAcmeRepo = repo.NewIAcmeAccountRepo()
|
||||
websiteCARepo = repo.NewIWebsiteCARepo()
|
||||
websiteTemplateRepo = repo.NewIWebsiteTemplateRepo()
|
||||
websiteTemplateOutputRepo = repo.NewIWebsiteTemplateOutputRepo()
|
||||
|
||||
snapshotRepo = repo.NewISnapshotRepo()
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
@@ -466,7 +467,7 @@ func (f *FileService) Compress(c request.FileCompress) error {
|
||||
|
||||
func preflightCompressTool(compressType files.CompressType) error {
|
||||
switch compressType {
|
||||
case files.TarGz, files.Rar, files.X7z:
|
||||
case files.Tar, files.Gz, files.Bz2, files.TarBz2, files.Tgz, files.TarGz, files.Xz, files.TarXz, files.Rar, files.X7z:
|
||||
_, err := files.NewShellArchiver(compressType)
|
||||
return err
|
||||
default:
|
||||
@@ -476,7 +477,7 @@ func preflightCompressTool(compressType files.CompressType) error {
|
||||
|
||||
func preflightDecompressTool(decompressType files.CompressType) error {
|
||||
switch decompressType {
|
||||
case files.Rar, files.X7z:
|
||||
case files.Rar:
|
||||
_, err := files.NewExtractShellArchiver(decompressType)
|
||||
return err
|
||||
default:
|
||||
@@ -533,7 +534,10 @@ func (f *FileService) DeCompress(c request.FileDeCompress) error {
|
||||
_ = os.RemoveAll(c.Dst)
|
||||
}
|
||||
}()
|
||||
if err := fo.Decompress(t.TaskCtx, c.Path, tempDst, files.CompressType(c.Type), c.Secret); err != nil {
|
||||
if err := fo.DecompressWithOptions(t.TaskCtx, c.Path, tempDst, files.CompressType(c.Type), c.Secret, files.DecompressOptions{
|
||||
PreserveOwner: true,
|
||||
AllowCLIReextract: true,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := fo.CreateDir(c.Dst, constant.DirPerm); err != nil {
|
||||
@@ -551,19 +555,42 @@ func (f *FileService) DeCompress(c request.FileDeCompress) error {
|
||||
}
|
||||
|
||||
func copyDecompressTree(ctx context.Context, srcDir, dstDir string) error {
|
||||
state := decompressCopyState{hardlinks: make(map[decompressFileIdentity]string)}
|
||||
entries, err := os.ReadDir(srcDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if err := copyDecompressEntry(ctx, filepath.Join(srcDir, entry.Name()), filepath.Join(dstDir, entry.Name())); err != nil {
|
||||
if err := copyDecompressEntryWithState(ctx, filepath.Join(srcDir, entry.Name()), filepath.Join(dstDir, entry.Name()), &state); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type decompressFileIdentity struct {
|
||||
device uint64
|
||||
inode uint64
|
||||
}
|
||||
|
||||
type decompressCopyState struct {
|
||||
hardlinks map[decompressFileIdentity]string
|
||||
}
|
||||
|
||||
func decompressHardlinkIdentity(info os.FileInfo) (decompressFileIdentity, bool) {
|
||||
stat, ok := info.Sys().(*syscall.Stat_t)
|
||||
if !ok || stat.Nlink < 2 {
|
||||
return decompressFileIdentity{}, false
|
||||
}
|
||||
return decompressFileIdentity{device: uint64(stat.Dev), inode: uint64(stat.Ino)}, true
|
||||
}
|
||||
|
||||
func copyDecompressEntry(ctx context.Context, srcPath, dstPath string) (retErr error) {
|
||||
state := decompressCopyState{hardlinks: make(map[decompressFileIdentity]string)}
|
||||
return copyDecompressEntryWithState(ctx, srcPath, dstPath, &state)
|
||||
}
|
||||
|
||||
func copyDecompressEntryWithState(ctx context.Context, srcPath, dstPath string, state *decompressCopyState) (retErr error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -605,13 +632,16 @@ func copyDecompressEntry(ctx context.Context, srcPath, dstPath string) (retErr e
|
||||
if err := applyDecompressOwnership(srcPath, dstPath); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Chmod(dstPath, info.Mode().Perm()); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
entries, err := os.ReadDir(srcPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if err := copyDecompressEntry(ctx, filepath.Join(srcPath, entry.Name()), filepath.Join(dstPath, entry.Name())); err != nil {
|
||||
if err := copyDecompressEntryWithState(ctx, filepath.Join(srcPath, entry.Name()), filepath.Join(dstPath, entry.Name()), state); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -634,6 +664,15 @@ func copyDecompressEntry(ctx context.Context, srcPath, dstPath string) (retErr e
|
||||
if err := os.MkdirAll(filepath.Dir(dstPath), constant.DirPerm); err != nil {
|
||||
return err
|
||||
}
|
||||
identity, isHardlink := decompressHardlinkIdentity(info)
|
||||
if !keepExistingFile && isHardlink {
|
||||
if existingPath, ok := state.hardlinks[identity]; ok {
|
||||
if err := os.Link(existingPath, dstPath); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Chtimes(dstPath, info.ModTime(), info.ModTime())
|
||||
}
|
||||
}
|
||||
|
||||
srcFile, err := os.Open(srcPath)
|
||||
if err != nil {
|
||||
@@ -658,6 +697,12 @@ func copyDecompressEntry(ctx context.Context, srcPath, dstPath string) (retErr e
|
||||
if err := applyDecompressOwnership(srcPath, dstPath); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Chmod(dstPath, info.Mode().Perm()); err != nil {
|
||||
return err
|
||||
}
|
||||
if isHardlink {
|
||||
state.hardlinks[identity] = dstPath
|
||||
}
|
||||
}
|
||||
return os.Chtimes(dstPath, info.ModTime(), info.ModTime())
|
||||
}
|
||||
@@ -667,7 +712,7 @@ func applyDecompressOwnership(srcPath, dstPath string) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
stat, ok := info.Sys().(*unix.Stat_t)
|
||||
stat, ok := info.Sys().(*syscall.Stat_t)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -3,14 +3,12 @@ package service
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/1Panel-dev/1Panel/agent/app/dto"
|
||||
"github.com/1Panel-dev/1Panel/agent/app/model"
|
||||
"github.com/1Panel-dev/1Panel/agent/buserr"
|
||||
"github.com/1Panel-dev/1Panel/agent/constant"
|
||||
"github.com/1Panel-dev/1Panel/agent/global"
|
||||
"github.com/1Panel-dev/1Panel/agent/utils/common"
|
||||
@@ -28,7 +26,6 @@ type IFirewallService interface {
|
||||
SearchWithPage(search dto.RuleSearch) (int64, interface{}, error)
|
||||
OperateFirewall(req dto.FirewallOperation) error
|
||||
OperatePortRule(req dto.PortRuleOperate, reload bool) error
|
||||
OperateForwardRule(req dto.ForwardRuleOperate) error
|
||||
OperateAddressRule(req dto.AddrRuleOperate, reload bool) error
|
||||
UpdatePortRule(req dto.PortRuleUpdate) error
|
||||
UpdateAddrRule(req dto.AddrRuleUpdate) error
|
||||
@@ -84,8 +81,6 @@ func (u *FirewallService) SearchWithPage(req dto.RuleSearch) (int64, interface{}
|
||||
switch req.Type {
|
||||
case "port":
|
||||
rules, err = client.ListPort()
|
||||
case "forward":
|
||||
rules, err = client.ListForward()
|
||||
case "address":
|
||||
rules, err = client.ListAddress()
|
||||
}
|
||||
@@ -317,96 +312,6 @@ func (u *FirewallService) OperatePortRule(req dto.PortRuleOperate, reload bool)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (u *FirewallService) OperateForwardRule(req dto.ForwardRuleOperate) error {
|
||||
client, err := firewall.NewFirewallClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
rules, _ := client.ListForward()
|
||||
i := 0
|
||||
for _, rule := range rules {
|
||||
shouldKeep := true
|
||||
for i := range req.Rules {
|
||||
reqRule := &req.Rules[i]
|
||||
if reqRule.TargetIP == "" {
|
||||
reqRule.TargetIP = "127.0.0.1"
|
||||
}
|
||||
|
||||
if reqRule.Operation == "remove" {
|
||||
for _, proto := range strings.Split(reqRule.Protocol, "/") {
|
||||
if reqRule.Port == rule.Port &&
|
||||
reqRule.TargetPort == rule.TargetPort &&
|
||||
reqRule.TargetIP == rule.TargetIP &&
|
||||
proto == rule.Protocol &&
|
||||
reqRule.Interface == rule.Interface {
|
||||
shouldKeep = false
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if shouldKeep {
|
||||
rules[i] = rule
|
||||
i++
|
||||
}
|
||||
}
|
||||
rules = rules[:i]
|
||||
|
||||
for _, rule := range rules {
|
||||
for _, reqRule := range req.Rules {
|
||||
if reqRule.Operation == "remove" {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, proto := range strings.Split(reqRule.Protocol, "/") {
|
||||
if reqRule.Port == rule.Port &&
|
||||
reqRule.TargetPort == rule.TargetPort &&
|
||||
reqRule.TargetIP == rule.TargetIP &&
|
||||
proto == rule.Protocol &&
|
||||
reqRule.Interface == rule.Interface {
|
||||
return buserr.New("ErrRecordExist")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sort.SliceStable(req.Rules, func(i, j int) bool {
|
||||
if req.Rules[i].Operation == "remove" && req.Rules[j].Operation != "remove" {
|
||||
return true
|
||||
}
|
||||
if req.Rules[i].Operation != "remove" && req.Rules[j].Operation == "remove" {
|
||||
return false
|
||||
}
|
||||
n1, _ := strconv.Atoi(req.Rules[i].Num)
|
||||
n2, _ := strconv.Atoi(req.Rules[j].Num)
|
||||
return n1 > n2
|
||||
})
|
||||
|
||||
for _, r := range req.Rules {
|
||||
for _, p := range strings.Split(r.Protocol, "/") {
|
||||
if r.TargetIP == "" {
|
||||
r.TargetIP = "127.0.0.1"
|
||||
}
|
||||
if err = client.PortForward(fireClient.Forward{
|
||||
Num: r.Num,
|
||||
Protocol: p,
|
||||
Port: r.Port,
|
||||
TargetIP: r.TargetIP,
|
||||
TargetPort: r.TargetPort,
|
||||
Interface: r.Interface,
|
||||
}, r.Operation); err != nil {
|
||||
if req.ForceDelete {
|
||||
global.LOG.Error(err)
|
||||
continue
|
||||
}
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (u *FirewallService) OperateAddressRule(req dto.AddrRuleOperate, reload bool) error {
|
||||
client, err := firewall.NewFirewallClient()
|
||||
if err != nil {
|
||||
@@ -521,7 +426,7 @@ func OperateFirewallPort(oldPorts, newPorts []int) error {
|
||||
return client.Reload()
|
||||
}
|
||||
|
||||
func (u *FirewallService) operatePort(client firewall.FirewallClient, req dto.PortRuleOperate) error {
|
||||
func (u *FirewallService) operatePort(client firewall.FilterClient, req dto.PortRuleOperate) error {
|
||||
var fireInfo fireClient.FireInfo
|
||||
if err := copier.Copy(&fireInfo, &req); err != nil {
|
||||
return err
|
||||
@@ -589,7 +494,7 @@ func (u *FirewallService) loadPortByApp() []portOfApp {
|
||||
return datas
|
||||
}
|
||||
|
||||
func (u *FirewallService) cleanUnUsedData(client firewall.FirewallClient) {
|
||||
func (u *FirewallService) cleanUnUsedData(client firewall.FilterClient) {
|
||||
list, _ := client.ListPort()
|
||||
addressList, _ := client.ListAddress()
|
||||
list = append(list, addressList...)
|
||||
@@ -613,7 +518,7 @@ func (u *FirewallService) cleanUnUsedData(client firewall.FirewallClient) {
|
||||
}
|
||||
}
|
||||
|
||||
func (u *FirewallService) addPortsBeforeStart(client firewall.FirewallClient) error {
|
||||
func (u *FirewallService) addPortsBeforeStart(client firewall.FilterClient) error {
|
||||
if client.Name() == "iptables" {
|
||||
isInit, _ := iptables.LoadInitStatus("iptables", "base")
|
||||
if !isInit {
|
||||
|
||||
@@ -138,7 +138,7 @@ func syncFirewallPortWhiteListAfterUpdate(oldValue string) error {
|
||||
return syncFirewallClientPortWhiteList(client, oldPortWhiteList, portWhiteList)
|
||||
}
|
||||
|
||||
func syncFirewallClientPortWhiteList(client firewall.FirewallClient, oldPortWhiteList, portWhiteList []firewallPortWhitelist) error {
|
||||
func syncFirewallClientPortWhiteList(client firewall.FilterClient, oldPortWhiteList, portWhiteList []firewallPortWhitelist) error {
|
||||
oldPorts := firewallPortWhiteListMap(oldPortWhiteList)
|
||||
newPorts := firewallPortWhiteListMap(portWhiteList)
|
||||
for _, item := range oldPortWhiteList {
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/1Panel-dev/1Panel/agent/app/dto"
|
||||
"github.com/1Panel-dev/1Panel/agent/buserr"
|
||||
"github.com/1Panel-dev/1Panel/agent/constant"
|
||||
"github.com/1Panel-dev/1Panel/agent/global"
|
||||
"github.com/1Panel-dev/1Panel/agent/utils/firewall"
|
||||
forwardClient "github.com/1Panel-dev/1Panel/agent/utils/firewall/forwarding"
|
||||
)
|
||||
|
||||
type IForwardingService interface {
|
||||
LoadBaseInfo() (dto.FirewallBaseInfo, error)
|
||||
SearchWithPage(search dto.ForwardRuleSearch) (int64, interface{}, error)
|
||||
Operate(req dto.ForwardRuleOperate) error
|
||||
Enable() error
|
||||
Replay() error
|
||||
}
|
||||
|
||||
type ForwardingService struct {
|
||||
adapterFactory func() (forwardClient.Adapter, error)
|
||||
filterFactory func() (firewall.FilterClient, error)
|
||||
}
|
||||
|
||||
func NewIForwardingService() IForwardingService {
|
||||
return &ForwardingService{
|
||||
adapterFactory: newForwardingAdapter,
|
||||
filterFactory: firewall.NewFirewallClient,
|
||||
}
|
||||
}
|
||||
|
||||
func newForwardingAdapter() (forwardClient.Adapter, error) {
|
||||
client, err := firewall.NewFirewallClient()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return forwardClient.NewAdapter(client.Name())
|
||||
}
|
||||
|
||||
func (s *ForwardingService) LoadBaseInfo() (dto.FirewallBaseInfo, error) {
|
||||
baseInfo := dto.FirewallBaseInfo{Version: "-", Name: "-"}
|
||||
adapter, err := s.adapterFactory()
|
||||
if err != nil {
|
||||
global.LOG.Errorf("load forwarding failed, err: %v", err)
|
||||
return baseInfo, nil
|
||||
}
|
||||
filter, err := s.filterFactory()
|
||||
if err != nil {
|
||||
global.LOG.Errorf("load firewall status failed, err: %v", err)
|
||||
return baseInfo, nil
|
||||
}
|
||||
baseInfo.IsExist = true
|
||||
baseInfo.Name = adapter.Name()
|
||||
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(2)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
baseInfo.PingStatus = firewall.LoadPingStatus()
|
||||
baseInfo.Version, _ = filter.Version()
|
||||
}()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
baseInfo.IsActive, _ = filter.Status()
|
||||
baseInfo.IsInit, baseInfo.IsBind = adapter.InitStatus()
|
||||
}()
|
||||
wg.Wait()
|
||||
return baseInfo, nil
|
||||
}
|
||||
|
||||
func (s *ForwardingService) SearchWithPage(req dto.ForwardRuleSearch) (int64, interface{}, error) {
|
||||
adapter, err := s.adapterFactory()
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
rules, err := adapter.List()
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
if req.Strategy != "" {
|
||||
return 0, nil, nil
|
||||
}
|
||||
|
||||
var filtered []forwardClient.Rule
|
||||
for _, rule := range rules {
|
||||
if req.Info != "" && !strings.Contains(rule.Port, req.Info) &&
|
||||
!strings.Contains(rule.TargetPort, req.Info) && !strings.Contains(rule.TargetIP, req.Info) {
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, rule)
|
||||
}
|
||||
total := len(filtered)
|
||||
start, end := (req.Page-1)*req.PageSize, req.Page*req.PageSize
|
||||
if start > total {
|
||||
return int64(total), make([]dto.ForwardRule, 0), nil
|
||||
}
|
||||
if end > total {
|
||||
end = total
|
||||
}
|
||||
pageRules := filtered[start:end]
|
||||
var items []dto.ForwardRule
|
||||
if pageRules != nil {
|
||||
items = make([]dto.ForwardRule, 0, len(pageRules))
|
||||
}
|
||||
for _, rule := range pageRules {
|
||||
items = append(items, dto.ForwardRule{
|
||||
Num: rule.Num,
|
||||
Protocol: rule.Protocol,
|
||||
Port: rule.Port,
|
||||
TargetIP: rule.TargetIP,
|
||||
TargetPort: rule.TargetPort,
|
||||
Interface: rule.Interface,
|
||||
})
|
||||
}
|
||||
return int64(total), items, nil
|
||||
}
|
||||
|
||||
func (s *ForwardingService) Operate(req dto.ForwardRuleOperate) error {
|
||||
adapter, err := s.adapterFactory()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
rules, _ := adapter.List()
|
||||
kept := rules[:0]
|
||||
for _, rule := range rules {
|
||||
shouldKeep := true
|
||||
for i := range req.Rules {
|
||||
reqRule := &req.Rules[i]
|
||||
if reqRule.TargetIP == "" {
|
||||
reqRule.TargetIP = "127.0.0.1"
|
||||
}
|
||||
if reqRule.Operation == "remove" && requestMatchesForwardRule(*reqRule, rule) {
|
||||
shouldKeep = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if shouldKeep {
|
||||
kept = append(kept, rule)
|
||||
}
|
||||
}
|
||||
|
||||
for _, rule := range kept {
|
||||
for _, reqRule := range req.Rules {
|
||||
if reqRule.Operation != "remove" && requestMatchesForwardRule(reqRule, rule) {
|
||||
return buserr.New("ErrRecordExist")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sort.SliceStable(req.Rules, func(i, j int) bool {
|
||||
if req.Rules[i].Operation == "remove" && req.Rules[j].Operation != "remove" {
|
||||
return true
|
||||
}
|
||||
if req.Rules[i].Operation != "remove" && req.Rules[j].Operation == "remove" {
|
||||
return false
|
||||
}
|
||||
n1, _ := strconv.Atoi(req.Rules[i].Num)
|
||||
n2, _ := strconv.Atoi(req.Rules[j].Num)
|
||||
return n1 > n2
|
||||
})
|
||||
|
||||
for _, rule := range req.Rules {
|
||||
for _, protocol := range strings.Split(rule.Protocol, "/") {
|
||||
targetIP := rule.TargetIP
|
||||
if targetIP == "" {
|
||||
targetIP = "127.0.0.1"
|
||||
}
|
||||
err := adapter.Operate(forwardClient.Rule{
|
||||
Num: rule.Num,
|
||||
Protocol: protocol,
|
||||
Port: rule.Port,
|
||||
TargetIP: targetIP,
|
||||
TargetPort: rule.TargetPort,
|
||||
Interface: rule.Interface,
|
||||
}, rule.Operation)
|
||||
if err == nil {
|
||||
continue
|
||||
}
|
||||
if req.ForceDelete {
|
||||
global.LOG.Error(err)
|
||||
continue
|
||||
}
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func requestMatchesForwardRule(req dto.ForwardRuleOperation, rule forwardClient.Rule) bool {
|
||||
for _, protocol := range strings.Split(req.Protocol, "/") {
|
||||
if req.Port == rule.Port && req.TargetPort == rule.TargetPort && req.TargetIP == rule.TargetIP &&
|
||||
protocol == rule.Protocol && req.Interface == rule.Interface {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (s *ForwardingService) Enable() error {
|
||||
adapter, err := s.adapterFactory()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := adapter.Enable(); err != nil {
|
||||
return err
|
||||
}
|
||||
if adapter.Name() != "firewalld" {
|
||||
_ = settingRepo.Update("IptablesForwardStatus", constant.StatusEnable)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *ForwardingService) Replay() error {
|
||||
adapter, err := s.adapterFactory()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := adapter.Replay(); err != nil {
|
||||
return err
|
||||
}
|
||||
if adapter.Name() == "firewalld" {
|
||||
return nil
|
||||
}
|
||||
status, _ := settingRepo.GetValueByKey("IptablesForwardStatus")
|
||||
if status == constant.StatusEnable {
|
||||
return adapter.Enable()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/1Panel-dev/1Panel/agent/app/dto"
|
||||
"github.com/1Panel-dev/1Panel/agent/utils/firewall"
|
||||
forwardClient "github.com/1Panel-dev/1Panel/agent/utils/firewall/forwarding"
|
||||
"github.com/go-playground/validator/v10"
|
||||
)
|
||||
|
||||
type forwardingCall struct {
|
||||
rule forwardClient.Rule
|
||||
operation string
|
||||
}
|
||||
|
||||
type fakeForwardingAdapter struct {
|
||||
name string
|
||||
rules []forwardClient.Rule
|
||||
listErr error
|
||||
operateErr error
|
||||
calls []forwardingCall
|
||||
}
|
||||
|
||||
func (f *fakeForwardingAdapter) Name() string { return f.name }
|
||||
|
||||
func (f *fakeForwardingAdapter) List() ([]forwardClient.Rule, error) {
|
||||
return append([]forwardClient.Rule(nil), f.rules...), f.listErr
|
||||
}
|
||||
|
||||
func (f *fakeForwardingAdapter) Operate(rule forwardClient.Rule, operation string) error {
|
||||
f.calls = append(f.calls, forwardingCall{rule: rule, operation: operation})
|
||||
return f.operateErr
|
||||
}
|
||||
|
||||
func (f *fakeForwardingAdapter) Enable() error { return nil }
|
||||
func (f *fakeForwardingAdapter) InitStatus() (bool, bool) { return true, true }
|
||||
func (f *fakeForwardingAdapter) Replay() error { return nil }
|
||||
|
||||
func forwardingServiceWithAdapter(adapter forwardClient.Adapter) *ForwardingService {
|
||||
return &ForwardingService{
|
||||
adapterFactory: func() (forwardClient.Adapter, error) { return adapter, nil },
|
||||
filterFactory: firewall.NewFirewallClient,
|
||||
}
|
||||
}
|
||||
|
||||
func TestForwardingAndFilterInterfacesAreSeparated(t *testing.T) {
|
||||
filterType := reflect.TypeOf((*firewall.FilterClient)(nil)).Elem()
|
||||
for _, method := range []string{"ListForward", "PortForward", "EnableForward"} {
|
||||
if _, ok := filterType.MethodByName(method); ok {
|
||||
t.Fatalf("filter interface still exposes %s", method)
|
||||
}
|
||||
}
|
||||
firewallServiceType := reflect.TypeOf((*IFirewallService)(nil)).Elem()
|
||||
if _, ok := firewallServiceType.MethodByName("OperateForwardRule"); ok {
|
||||
t.Fatal("firewall service still owns forwarding writes")
|
||||
}
|
||||
forwardingServiceType := reflect.TypeOf((*IForwardingService)(nil)).Elem()
|
||||
for _, method := range []string{"LoadBaseInfo", "SearchWithPage", "Operate", "Enable", "Replay"} {
|
||||
if _, ok := forwardingServiceType.MethodByName(method); !ok {
|
||||
t.Fatalf("forwarding service missing %s", method)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestForwardingInitRequestContract(t *testing.T) {
|
||||
req := dto.IptablesOp{Name: "1PANEL_FORWARD", Operate: "init-forward"}
|
||||
if err := validator.New().Struct(req); err != nil {
|
||||
t.Fatalf("frontend forwarding initialization request must remain valid: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestForwardingSearchPreservesAPIShapeAndPagination(t *testing.T) {
|
||||
adapter := &fakeForwardingAdapter{name: "iptables", rules: []forwardClient.Rule{
|
||||
{Num: "1", Protocol: "tcp", Port: "8080", TargetIP: "10.0.0.2", TargetPort: "80", Interface: "eth0"},
|
||||
{Num: "2", Protocol: "udp", Port: "5353", TargetIP: "127.0.0.1", TargetPort: "53"},
|
||||
}}
|
||||
service := forwardingServiceWithAdapter(adapter)
|
||||
total, value, err := service.SearchWithPage(dto.ForwardRuleSearch{PageInfo: dto.PageInfo{Page: 1, PageSize: 10}, Info: "10.0.0.2"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if total != 1 {
|
||||
t.Fatalf("got total %d want 1", total)
|
||||
}
|
||||
items, ok := value.([]dto.ForwardRule)
|
||||
if !ok || len(items) != 1 || items[0].Port != "8080" {
|
||||
t.Fatalf("unexpected items: %#v", value)
|
||||
}
|
||||
data, err := json.Marshal(items[0])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var fields map[string]interface{}
|
||||
if err := json.Unmarshal(data, &fields); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wantFields := []string{"id", "chain", "family", "address", "port", "protocol", "strategy", "num", "targetIP", "targetPort", "interface", "usedStatus", "description"}
|
||||
for _, field := range wantFields {
|
||||
if _, ok := fields[field]; !ok {
|
||||
t.Fatalf("forward response dropped compatibility field %q: %s", field, data)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestForwardingOperatePreservesDuplicateAndOrderingContracts(t *testing.T) {
|
||||
existing := &fakeForwardingAdapter{name: "ufw", rules: []forwardClient.Rule{
|
||||
{Protocol: "tcp", Port: "8080", TargetIP: "127.0.0.1", TargetPort: "80"},
|
||||
}}
|
||||
service := forwardingServiceWithAdapter(existing)
|
||||
err := service.Operate(dto.ForwardRuleOperate{Rules: []dto.ForwardRuleOperation{{
|
||||
Operation: "add", Protocol: "tcp", Port: "8080", TargetPort: "80",
|
||||
}}})
|
||||
if err == nil {
|
||||
t.Fatal("duplicate forwarding rule must be rejected")
|
||||
}
|
||||
if len(existing.calls) != 0 {
|
||||
t.Fatalf("duplicate check wrote forwarding state: %#v", existing.calls)
|
||||
}
|
||||
|
||||
adapter := &fakeForwardingAdapter{name: "iptables"}
|
||||
service = forwardingServiceWithAdapter(adapter)
|
||||
err = service.Operate(dto.ForwardRuleOperate{Rules: []dto.ForwardRuleOperation{
|
||||
{Operation: "add", Protocol: "tcp/udp", Port: "9000", TargetIP: "10.0.0.2", TargetPort: "90"},
|
||||
{Operation: "remove", Num: "1", Protocol: "tcp", Port: "8001", TargetIP: "10.0.0.2", TargetPort: "81"},
|
||||
{Operation: "remove", Num: "3", Protocol: "tcp", Port: "8003", TargetIP: "10.0.0.2", TargetPort: "83"},
|
||||
}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := []forwardingCall{
|
||||
{operation: "remove", rule: forwardClient.Rule{Num: "3", Protocol: "tcp", Port: "8003", TargetIP: "10.0.0.2", TargetPort: "83"}},
|
||||
{operation: "remove", rule: forwardClient.Rule{Num: "1", Protocol: "tcp", Port: "8001", TargetIP: "10.0.0.2", TargetPort: "81"}},
|
||||
{operation: "add", rule: forwardClient.Rule{Protocol: "tcp", Port: "9000", TargetIP: "10.0.0.2", TargetPort: "90"}},
|
||||
{operation: "add", rule: forwardClient.Rule{Protocol: "udp", Port: "9000", TargetIP: "10.0.0.2", TargetPort: "90"}},
|
||||
}
|
||||
if !reflect.DeepEqual(adapter.calls, want) {
|
||||
t.Fatalf("operation order changed\ngot %#v\nwant %#v", adapter.calls, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestForwardingSearchReturnsAdapterError(t *testing.T) {
|
||||
wantErr := errors.New("list failed")
|
||||
service := forwardingServiceWithAdapter(&fakeForwardingAdapter{name: "firewalld", listErr: wantErr})
|
||||
_, _, err := service.SearchWithPage(dto.ForwardRuleSearch{PageInfo: dto.PageInfo{Page: 1, PageSize: 20}})
|
||||
if !errors.Is(err, wantErr) {
|
||||
t.Fatalf("got %v want %v", err, wantErr)
|
||||
}
|
||||
}
|
||||
+61
-21
@@ -1,6 +1,8 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"sort"
|
||||
|
||||
@@ -22,6 +24,7 @@ type IFtpService interface {
|
||||
SearchWithPage(search dto.SearchWithPage) (int64, interface{}, error)
|
||||
Operate(operation string) error
|
||||
Create(req dto.FtpCreate) (uint, error)
|
||||
CreateWebsite(req dto.FtpCreate) (uint, error)
|
||||
Delete(req dto.BatchDeleteReq) error
|
||||
Update(req dto.FtpUpdate) error
|
||||
Sync() error
|
||||
@@ -34,11 +37,7 @@ func NewIFtpService() IFtpService {
|
||||
|
||||
func (f *FtpService) LoadBaseInfo() (dto.FtpBaseInfo, error) {
|
||||
var baseInfo dto.FtpBaseInfo
|
||||
client, err := toolbox.NewFtpClient()
|
||||
if err != nil {
|
||||
return baseInfo, err
|
||||
}
|
||||
baseInfo.IsActive, baseInfo.IsExist = client.Status()
|
||||
baseInfo.IsActive, baseInfo.IsExist = toolbox.FtpStatus()
|
||||
return baseInfo, nil
|
||||
}
|
||||
|
||||
@@ -99,7 +98,7 @@ func (f *FtpService) Sync() error {
|
||||
}
|
||||
lists, err := client.LoadList()
|
||||
if err != nil {
|
||||
return nil
|
||||
return err
|
||||
}
|
||||
listsInDB, err := ftpRepo.GetList()
|
||||
if err != nil {
|
||||
@@ -113,13 +112,24 @@ func (f *FtpService) Sync() error {
|
||||
for _, item := range lists {
|
||||
if itemInDB, ok := currentData[item.User]; ok {
|
||||
sameData[item.User] = struct{}{}
|
||||
if item.Path != itemInDB.Path || item.Status != itemInDB.Status {
|
||||
if err := ftpRepo.Update(itemInDB.ID, map[string]interface{}{"path": item.Path, "status": item.Status}); err != nil {
|
||||
if item.Path != itemInDB.Path || item.Status != itemInDB.Status || item.UID != itemInDB.UID || item.GID != itemInDB.GID {
|
||||
if err := ftpRepo.Update(itemInDB.ID, map[string]interface{}{
|
||||
"path": item.Path,
|
||||
"status": item.Status,
|
||||
"uid": item.UID,
|
||||
"gid": item.GID,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if err := ftpRepo.Create(&model.Ftp{User: item.User, Path: item.Path, Status: item.Status}); err != nil {
|
||||
if err := ftpRepo.Create(&model.Ftp{
|
||||
User: item.User,
|
||||
Path: item.Path,
|
||||
Status: item.Status,
|
||||
UID: item.UID,
|
||||
GID: item.GID,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -133,6 +143,21 @@ func (f *FtpService) Sync() error {
|
||||
}
|
||||
|
||||
func (f *FtpService) Create(req dto.FtpCreate) (uint, error) {
|
||||
return f.create(req, false)
|
||||
}
|
||||
|
||||
func (f *FtpService) CreateWebsite(req dto.FtpCreate) (uint, error) {
|
||||
return f.create(req, true)
|
||||
}
|
||||
|
||||
func (f *FtpService) create(req dto.FtpCreate, website bool) (uint, error) {
|
||||
if err := toolbox.ValidateFtpRootPath(req.Path); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
client, err := toolbox.NewFtpClient()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if _, err := os.Stat(req.Path); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
if err := os.MkdirAll(req.Path, os.ModePerm); err != nil {
|
||||
@@ -150,20 +175,28 @@ func (f *FtpService) Create(req dto.FtpCreate) (uint, error) {
|
||||
if userInDB.ID != 0 {
|
||||
return 0, buserr.New("ErrRecordExist")
|
||||
}
|
||||
client, err := toolbox.NewFtpClient()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if err := client.UserAdd(req.User, req.Password, req.Path); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
var ftp model.Ftp
|
||||
if err := copier.Copy(&ftp, &req); err != nil {
|
||||
return 0, buserr.WithDetail("ErrStructTransform", err.Error(), nil)
|
||||
}
|
||||
uid, gid := uint(constant.WebsiteUID), uint(constant.WebsiteGID)
|
||||
if !website {
|
||||
uid, gid, err = toolbox.EnsureStandaloneFtpIdentity()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
}
|
||||
if err := client.UserAdd(req.User, req.Password, req.Path, uid, gid); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
ftp.Status = constant.StatusEnable
|
||||
ftp.Password = pass
|
||||
ftp.UID = uid
|
||||
ftp.GID = gid
|
||||
if err := ftpRepo.Create(&ftp); err != nil {
|
||||
if rollbackErr := client.UserDel(req.User); rollbackErr != nil {
|
||||
return 0, errors.Join(err, fmt.Errorf("rollback FTP user %s failed: %w", req.User, rollbackErr))
|
||||
}
|
||||
return 0, err
|
||||
}
|
||||
return ftp.ID, nil
|
||||
@@ -186,6 +219,13 @@ func (f *FtpService) Delete(req dto.BatchDeleteReq) error {
|
||||
}
|
||||
|
||||
func (f *FtpService) Update(req dto.FtpUpdate) error {
|
||||
if err := toolbox.ValidateFtpRootPath(req.Path); err != nil {
|
||||
return err
|
||||
}
|
||||
client, err := toolbox.NewFtpClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := os.Stat(req.Path); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
if err := os.MkdirAll(req.Path, os.ModePerm); err != nil {
|
||||
@@ -209,10 +249,6 @@ func (f *FtpService) Update(req dto.FtpUpdate) error {
|
||||
return err
|
||||
}
|
||||
|
||||
client, err := toolbox.NewFtpClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
needReload := false
|
||||
updates := make(map[string]interface{})
|
||||
if req.Password != passItem {
|
||||
@@ -230,7 +266,11 @@ func (f *FtpService) Update(req dto.FtpUpdate) error {
|
||||
needReload = true
|
||||
}
|
||||
if req.Path != ftpItem.Path {
|
||||
if err := client.SetPath(ftpItem.User, req.Path); err != nil {
|
||||
uid, gid := ftpItem.UID, ftpItem.GID
|
||||
if uid == 0 || gid == 0 {
|
||||
uid, gid = uint(constant.WebsiteUID), uint(constant.WebsiteGID)
|
||||
}
|
||||
if err := client.SetPath(ftpItem.User, req.Path, uid, gid); err != nil {
|
||||
return err
|
||||
}
|
||||
updates["path"] = req.Path
|
||||
|
||||
@@ -11,7 +11,6 @@ import (
|
||||
"github.com/1Panel-dev/1Panel/agent/constant"
|
||||
"github.com/1Panel-dev/1Panel/agent/global"
|
||||
"github.com/1Panel-dev/1Panel/agent/utils/cmd"
|
||||
"github.com/1Panel-dev/1Panel/agent/utils/firewall/client"
|
||||
"github.com/1Panel-dev/1Panel/agent/utils/firewall/client/iptables"
|
||||
)
|
||||
|
||||
@@ -195,12 +194,6 @@ func (s *IptablesService) Operate(req dto.IptablesOp) error {
|
||||
}
|
||||
_ = settingRepo.Update("IptablesStatus", constant.StatusEnable)
|
||||
return nil
|
||||
case "init-forward":
|
||||
if err := client.EnableIptablesForward(); err != nil {
|
||||
return err
|
||||
}
|
||||
_ = settingRepo.Update("IptablesForwardStatus", constant.StatusEnable)
|
||||
return nil
|
||||
case "init-advance":
|
||||
if err := iptables.AddChain(iptables.FilterTab, iptables.Chain1PanelInput); err != nil {
|
||||
return err
|
||||
|
||||
+59
-18
@@ -7,13 +7,13 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/1Panel-dev/1Panel/agent/app/dto"
|
||||
"github.com/1Panel-dev/1Panel/agent/buserr"
|
||||
"github.com/1Panel-dev/1Panel/agent/global"
|
||||
"github.com/1Panel-dev/1Panel/agent/i18n"
|
||||
"github.com/1Panel-dev/1Panel/agent/utils/re"
|
||||
@@ -73,20 +73,22 @@ func (u *LogService) ReadSystemLog(req dto.SystemLogReq) (dto.SystemLogRes, erro
|
||||
return u.readFileSystemLog(req, startTime, endTime, pageSize, cursor)
|
||||
}
|
||||
queryArgs := buildJournalQueryArgs(req, startTime, endTime, pageSize, cursor)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
output, err := exec.CommandContext(ctx, journalctl, queryArgs...).CombinedOutput()
|
||||
cancel()
|
||||
output, err := executeJournalQuery(journalctl, queryArgs)
|
||||
if err != nil {
|
||||
if journalctlGrepUnsupported(req.Keyword, string(output)) {
|
||||
return dto.SystemLogRes{}, buserr.New("ErrSystemLogKeywordFilterUnsupported")
|
||||
}
|
||||
return dto.SystemLogRes{}, fmt.Errorf("read host system logs failed: %s", strings.TrimSpace(string(output)))
|
||||
return handleJournalQueryError(req, output, err, func() ([]byte, error) {
|
||||
probeReq := req
|
||||
probeReq.Keyword = ""
|
||||
probeReq.Priority = ""
|
||||
probeReq.Service = ""
|
||||
probeArgs := buildJournalQueryArgs(probeReq, startTime, endTime, 1, cursor)
|
||||
return executeJournalQuery(journalctl, probeArgs)
|
||||
})
|
||||
}
|
||||
content := strings.TrimSpace(string(output))
|
||||
if content == "" || strings.HasPrefix(content, "-- No entries --") {
|
||||
return dto.SystemLogRes{Source: "journalctl", Items: []dto.SystemLogItem{}}, nil
|
||||
}
|
||||
items := parseJournalLogItems(content)
|
||||
items := trimJournalLogItemsToStartTime(parseJournalLogItems(content), startTime)
|
||||
if cursor != nil && cursor.JournalCursor == "" {
|
||||
items, err = skipSystemLogCursorItems(items, *cursor)
|
||||
if err != nil {
|
||||
@@ -96,6 +98,12 @@ func (u *LogService) ReadSystemLog(req dto.SystemLogReq) (dto.SystemLogRes, erro
|
||||
return buildSystemLogResponse("journalctl", items, pageSize, cursor)
|
||||
}
|
||||
|
||||
func executeJournalQuery(journalctl string, queryArgs []string) ([]byte, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
return exec.CommandContext(ctx, journalctl, queryArgs...).CombinedOutput()
|
||||
}
|
||||
|
||||
func (u *LogService) GetSystemLogStatus() (dto.SystemLogStatus, error) {
|
||||
journalctl, err := exec.LookPath("journalctl")
|
||||
if err != nil {
|
||||
@@ -134,13 +142,44 @@ func journalctlHelpSupportsGrep(help string) bool {
|
||||
return strings.Contains(help, "--grep=")
|
||||
}
|
||||
|
||||
func journalctlGrepUnsupported(keyword, output string) bool {
|
||||
if strings.TrimSpace(keyword) == "" {
|
||||
return false
|
||||
func handleJournalQueryError(
|
||||
req dto.SystemLogReq,
|
||||
output []byte,
|
||||
queryErr error,
|
||||
probe func() ([]byte, error),
|
||||
) (dto.SystemLogRes, error) {
|
||||
if hasSystemLogFilter(req) && probe != nil {
|
||||
probeOutput, probeErr := probe()
|
||||
if probeErr == nil {
|
||||
return dto.SystemLogRes{Source: "journalctl", Items: []dto.SystemLogItem{}}, nil
|
||||
}
|
||||
return dto.SystemLogRes{}, newJournalQueryError(probeOutput, probeErr)
|
||||
}
|
||||
output = strings.ToLower(output)
|
||||
return strings.Contains(output, "grep") &&
|
||||
(strings.Contains(output, "unrecognized option") || strings.Contains(output, "unknown option"))
|
||||
return dto.SystemLogRes{}, newJournalQueryError(output, queryErr)
|
||||
}
|
||||
|
||||
func newJournalQueryError(output []byte, queryErr error) error {
|
||||
message := strings.TrimSpace(string(output))
|
||||
if message == "" && queryErr != nil {
|
||||
message = queryErr.Error()
|
||||
}
|
||||
return fmt.Errorf("read host system logs failed: %s", message)
|
||||
}
|
||||
|
||||
func trimJournalLogItemsToStartTime(items []dto.SystemLogItem, startTime time.Time) []dto.SystemLogItem {
|
||||
startTimestamp := startTime.UnixMicro()
|
||||
for i, item := range items {
|
||||
if item.Timestamp < startTimestamp {
|
||||
return items[:i]
|
||||
}
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func hasSystemLogFilter(req dto.SystemLogReq) bool {
|
||||
return strings.TrimSpace(req.Keyword) != "" ||
|
||||
strings.TrimSpace(req.Priority) != "" ||
|
||||
strings.TrimSpace(req.Service) != ""
|
||||
}
|
||||
|
||||
func firstOutputLine(output string) string {
|
||||
@@ -155,12 +194,14 @@ func firstOutputLine(output string) string {
|
||||
func buildJournalQueryArgs(req dto.SystemLogReq, startTime, endTime time.Time, pageSize int, cursor *systemLogCursor) []string {
|
||||
args := []string{
|
||||
"--no-pager", "--reverse", "--output=json",
|
||||
"--since", formatJournalQueryTime(startTime),
|
||||
}
|
||||
if cursor != nil && cursor.JournalCursor != "" {
|
||||
args = append(args, "--after-cursor="+cursor.JournalCursor)
|
||||
} else {
|
||||
args = append(args, "--until", formatJournalQueryTime(endTime))
|
||||
args = append(args,
|
||||
"--since", formatJournalQueryTime(startTime),
|
||||
"--until", formatJournalQueryTime(endTime),
|
||||
)
|
||||
}
|
||||
if service := strings.TrimSpace(req.Service); service != "" {
|
||||
args = append(args, "-u", service)
|
||||
@@ -169,7 +210,7 @@ func buildJournalQueryArgs(req dto.SystemLogReq, startTime, endTime time.Time, p
|
||||
args = append(args, "--priority", priority+".."+priority)
|
||||
}
|
||||
if keyword := strings.TrimSpace(req.Keyword); keyword != "" {
|
||||
args = append(args, "--grep", keyword)
|
||||
args = append(args, "--grep", regexp.QuoteMeta(keyword), "--case-sensitive=no")
|
||||
}
|
||||
queryLines := pageSize + 1
|
||||
if cursor != nil && cursor.JournalCursor == "" {
|
||||
|
||||
@@ -515,6 +515,7 @@ type openrestyUpgradeSnapshot struct {
|
||||
|
||||
var openrestyUpgradeSnapshotPaths = []string{
|
||||
nginxModuleBuildDir,
|
||||
nginxModuleModulesDir,
|
||||
"scripts",
|
||||
path.Join(nginxModuleConfDir, nginxModuleEnabledConfDir),
|
||||
path.Join(nginxModuleConfDir, "nginx.conf"),
|
||||
|
||||
@@ -1,676 +0,0 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/1Panel-dev/1Panel/agent/app/dto"
|
||||
"github.com/1Panel-dev/1Panel/agent/app/dto/request"
|
||||
"github.com/1Panel-dev/1Panel/agent/app/model"
|
||||
"github.com/1Panel-dev/1Panel/agent/constant"
|
||||
"github.com/1Panel-dev/1Panel/agent/global"
|
||||
)
|
||||
|
||||
func TestNormalizeNginxModuleDoesNotInferBuildMode(t *testing.T) {
|
||||
module := dto.NginxModule{
|
||||
Name: "legacy",
|
||||
Packages: []string{"git", "", "git", " curl "},
|
||||
}
|
||||
|
||||
normalizeNginxModule(&module)
|
||||
|
||||
if module.BuildMode != "" {
|
||||
t.Fatalf("build mode must be explicit, got %s", module.BuildMode)
|
||||
}
|
||||
if module.Provider != nginxModuleProviderLocal {
|
||||
t.Fatalf("expected local provider, got %s", module.Provider)
|
||||
}
|
||||
if len(module.Packages) != 2 || module.Packages[0] != "git" || module.Packages[1] != "curl" {
|
||||
t.Fatalf("unexpected normalized packages: %#v", module.Packages)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeDynamicModuleParams(t *testing.T) {
|
||||
params, err := normalizeDynamicModuleParams("--with-http_dav_module --add-module=/tmp/nginx-dav-ext-module")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
expected := "--with-http_dav_module --add-dynamic-module=/tmp/nginx-dav-ext-module"
|
||||
if params != expected {
|
||||
t.Fatalf("expected %q, got %q", expected, params)
|
||||
}
|
||||
|
||||
if _, err = normalizeDynamicModuleParams("--add-module=/tmp/module;touch /tmp/unsafe"); err == nil {
|
||||
t.Fatal("expected shell metacharacters to be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindCurrentAndLatestNginxModuleBuild(t *testing.T) {
|
||||
target := dto.NginxModuleTarget{Key: "target"}
|
||||
module := dto.NginxModule{
|
||||
Name: "example",
|
||||
Params: "--add-module=/tmp/example",
|
||||
BuildMode: nginxModuleBuildDynamic,
|
||||
Provider: nginxModuleProviderLocal,
|
||||
}
|
||||
params, err := normalizeDynamicModuleParams(module.Params)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
currentHash, err := nginxModuleBuildHash(module, target, params)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
oldTime := time.Now().Add(-time.Hour)
|
||||
newTime := time.Now()
|
||||
module.Builds = []dto.NginxModuleBuild{
|
||||
{Hash: "old", Status: nginxModuleStatusReady, Target: target, BuiltAt: oldTime},
|
||||
{Hash: currentHash, Status: nginxModuleStatusReady, Target: target, BuiltAt: newTime},
|
||||
}
|
||||
|
||||
if build := findCurrentNginxModuleBuild(module, target); build == nil || build.Hash != currentHash {
|
||||
t.Fatalf("current build was not selected: %#v", build)
|
||||
}
|
||||
if build := findLatestNginxModuleBuild(module, target); build == nil || build.Hash != currentHash {
|
||||
t.Fatalf("latest build was not selected: %#v", build)
|
||||
}
|
||||
|
||||
module.Params = "--add-module=/tmp/example-v2"
|
||||
if build := findCurrentNginxModuleBuild(module, target); build != nil {
|
||||
t.Fatalf("changed module input should be stale, got %#v", build)
|
||||
}
|
||||
if build := findLatestNginxModuleBuild(module, target); build == nil || build.Hash != currentHash {
|
||||
t.Fatal("the previous ready build should remain available until replacement")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNginxModulePathNameAvoidsSanitizedNameCollisions(t *testing.T) {
|
||||
first := nginxModulePathName("example/module")
|
||||
second := nginxModulePathName("example-module")
|
||||
if first == second {
|
||||
t.Fatalf("module path names collided: %s", first)
|
||||
}
|
||||
if len(nginxModulePathName(string(make([]byte, 256)))) > 57 {
|
||||
t.Fatal("module path name should remain safe for Docker resource names")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordNginxModuleBuildFailureKeepsPreviousReadyBuild(t *testing.T) {
|
||||
target := dto.NginxModuleTarget{Key: "target"}
|
||||
ready := dto.NginxModuleBuild{
|
||||
Hash: "ready", Status: nginxModuleStatusReady, Target: target, BuiltAt: time.Now().Add(-time.Hour),
|
||||
}
|
||||
original := []dto.NginxModule{{
|
||||
Name: "example", BuildMode: nginxModuleBuildDynamic,
|
||||
Builds: []dto.NginxModuleBuild{ready},
|
||||
}}
|
||||
failed := dto.NginxModuleBuild{
|
||||
Hash: "candidate", Status: nginxModuleStatusFailed, Target: target, Error: "load failed", BuiltAt: time.Now(),
|
||||
}
|
||||
|
||||
result := recordNginxModuleBuildFailure(original, "example", failed, &ready)
|
||||
|
||||
if len(result[0].Builds) != 1 || result[0].Builds[0].Hash != "ready" {
|
||||
t.Fatalf("previous ready build was replaced: %#v", result[0].Builds)
|
||||
}
|
||||
if result[0].LastError != failed.Error {
|
||||
t.Fatalf("failure metadata was not retained: %#v", result[0])
|
||||
}
|
||||
result[0].Builds[0].Hash = "mutated"
|
||||
if original[0].Builds[0].Hash != "ready" {
|
||||
t.Fatal("module clone shares build state with the original")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHasDynamicNginxModuleBuildTask(t *testing.T) {
|
||||
dynamicEnabled := dto.NginxModule{Name: "brotli", Enable: true, BuildMode: nginxModuleBuildDynamic}
|
||||
staticEnabled := dto.NginxModule{Name: "pagespeed", Enable: true, BuildMode: nginxModuleBuildStatic}
|
||||
disabledDynamic := dto.NginxModule{Name: "waf", Enable: false, BuildMode: nginxModuleBuildDynamic}
|
||||
|
||||
if hasDynamicNginxModuleBuildTask(nil, nil) {
|
||||
t.Fatal("empty module list should not require a dynamic build")
|
||||
}
|
||||
if hasDynamicNginxModuleBuildTask([]dto.NginxModule{staticEnabled}, nil) {
|
||||
t.Fatal("static-only modules should not require a dynamic build")
|
||||
}
|
||||
if hasDynamicNginxModuleBuildTask([]dto.NginxModule{dynamicEnabled}, []string{"other"}) {
|
||||
t.Fatal("enabled module outside the selection should not require a dynamic build")
|
||||
}
|
||||
if !hasDynamicNginxModuleBuildTask([]dto.NginxModule{disabledDynamic}, []string{"waf"}) {
|
||||
t.Fatal("selected module should require a dynamic build even when disabled")
|
||||
}
|
||||
if !hasDynamicNginxModuleBuildTask([]dto.NginxModule{dynamicEnabled, staticEnabled}, nil) {
|
||||
t.Fatal("enabled dynamic module should require a dynamic build")
|
||||
}
|
||||
|
||||
legacy := dto.NginxModule{Name: "legacy", Enable: true}
|
||||
if hasDynamicNginxModuleBuildTask([]dto.NginxModule{legacy}, nil) {
|
||||
t.Fatal("legacy module without a build mode stays static and should not require a dynamic build")
|
||||
}
|
||||
if legacy.BuildMode != "" {
|
||||
t.Fatalf("prescan must normalize a copy, got mutated BuildMode %q", legacy.BuildMode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNginxModuleStaticBuildErrorHintOnlyForCustomModules(t *testing.T) {
|
||||
if hint := nginxModuleStaticBuildErrorHint(dto.NginxModule{Name: "builtin"}); hint != "" {
|
||||
t.Fatalf("built-in module cannot switch build mode, got hint %q", hint)
|
||||
}
|
||||
if hint := nginxModuleStaticBuildErrorHint(dto.NginxModule{Name: "custom", Custom: true}); hint == "" {
|
||||
t.Fatal("custom module should receive the static-build alternative")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveNginxModuleTargetWithoutBuilder(t *testing.T) {
|
||||
oldDir := global.Dir.AppInstallDir
|
||||
global.Dir.AppInstallDir = t.TempDir()
|
||||
t.Cleanup(func() { global.Dir.AppInstallDir = oldDir })
|
||||
install := model.AppInstall{Name: "openresty", Version: "1.27.1.2"}
|
||||
install.App.Key = constant.AppOpenresty
|
||||
|
||||
_, _, err := resolveNginxModuleTarget(install)
|
||||
if !errors.Is(err, errNginxModuleBuilderMissing) {
|
||||
t.Fatalf("expected builder-missing sentinel, got %v", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "dynamic module builder not found") {
|
||||
t.Fatalf("unexpected error message: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildDynamicNginxModulesFailsWithoutBuilder(t *testing.T) {
|
||||
oldDir := global.Dir.AppInstallDir
|
||||
global.Dir.AppInstallDir = t.TempDir()
|
||||
t.Cleanup(func() { global.Dir.AppInstallDir = oldDir })
|
||||
install := model.AppInstall{Name: "openresty", Version: "1.31.1.1"}
|
||||
install.App.Key = constant.AppOpenresty
|
||||
modules := []dto.NginxModule{{
|
||||
Name: "rtmp", Enable: true, BuildMode: nginxModuleBuildDynamic,
|
||||
}}
|
||||
|
||||
_, err := buildDynamicNginxModules(install, modules, nil, false, "", "", nil)
|
||||
if !errors.Is(err, errNginxModuleBuilderMissing) {
|
||||
t.Fatalf("missing target builder must fail the dynamic build, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeOpenrestyModuleVolumes(t *testing.T) {
|
||||
newService := map[string]interface{}{}
|
||||
oldService := map[string]interface{}{
|
||||
"volumes": []interface{}{
|
||||
"./conf:/etc/nginx/conf.d:ro",
|
||||
"./modules:/usr/local/openresty/nginx/modules/1panel:ro",
|
||||
"./conf/modules-enabled:/usr/local/openresty/nginx/conf/modules-enabled:ro",
|
||||
12345,
|
||||
},
|
||||
}
|
||||
|
||||
mergeOpenrestyModuleVolumes(newService, oldService)
|
||||
|
||||
merged, ok := newService["volumes"].([]interface{})
|
||||
if !ok || len(merged) != 2 {
|
||||
t.Fatalf("expected two module mounts to be merged, got %#v", newService["volumes"])
|
||||
}
|
||||
for _, volume := range merged {
|
||||
if volume == "./conf:/etc/nginx/conf.d:ro" {
|
||||
t.Fatal("unrelated mount should not be merged")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeOpenrestyModuleVolumesKeepsExisting(t *testing.T) {
|
||||
existing := "./modules:/usr/local/openresty/nginx/modules/1panel:ro"
|
||||
newService := map[string]interface{}{
|
||||
"volumes": []interface{}{existing, map[string]interface{}{"type": "bind"}},
|
||||
}
|
||||
oldService := map[string]interface{}{
|
||||
"volumes": []interface{}{
|
||||
"./modules:/usr/local/openresty/nginx/modules/1panel:ro",
|
||||
"./conf/modules-enabled:/usr/local/openresty/nginx/conf/modules-enabled:ro",
|
||||
},
|
||||
}
|
||||
|
||||
mergeOpenrestyModuleVolumes(newService, oldService)
|
||||
|
||||
merged, ok := newService["volumes"].([]interface{})
|
||||
if !ok || len(merged) != 3 {
|
||||
t.Fatalf("expected only the missing mount to be appended, got %#v", newService["volumes"])
|
||||
}
|
||||
if merged[0] != existing {
|
||||
t.Fatalf("existing mounts must keep their order, got %#v", merged)
|
||||
}
|
||||
if merged[2] != "./conf/modules-enabled:/usr/local/openresty/nginx/conf/modules-enabled:ro" {
|
||||
t.Fatalf("missing module mount was not appended, got %#v", merged)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveNginxModuleBuildMirror(t *testing.T) {
|
||||
oldDir := global.Dir.AppInstallDir
|
||||
global.Dir.AppInstallDir = t.TempDir()
|
||||
t.Cleanup(func() { global.Dir.AppInstallDir = oldDir })
|
||||
install := model.AppInstall{Name: "openresty", Version: "1.27.1.2"}
|
||||
install.App.Key = constant.AppOpenresty
|
||||
if err := os.MkdirAll(install.GetPath(), constant.DirPerm); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if got := resolveNginxModuleBuildMirror(install, "https://mirror.example.com"); got != "https://mirror.example.com" {
|
||||
t.Fatalf("request mirror should win, got %q", got)
|
||||
}
|
||||
if got := resolveNginxModuleBuildMirror(install, ""); got != "" {
|
||||
t.Fatalf("missing env file should yield an empty mirror, got %q", got)
|
||||
}
|
||||
|
||||
envContent := "CONTAINER_PACKAGE_URL=https://apt.example.com\nOTHER=value\n"
|
||||
if err := os.WriteFile(install.GetEnvPath(), []byte(envContent), constant.FilePerm); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := resolveNginxModuleBuildMirror(install, ""); got != "https://apt.example.com" {
|
||||
t.Fatalf("env CONTAINER_PACKAGE_URL should be the fallback, got %q", got)
|
||||
}
|
||||
if got := resolveNginxModuleBuildMirror(install, "https://mirror.example.com"); got != "https://mirror.example.com" {
|
||||
t.Fatalf("request mirror should still win over the env value, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func writeNginxModuleCatalogStateFixture(t *testing.T, install model.AppInstall, catalog []dto.NginxModule, state string) {
|
||||
t.Helper()
|
||||
buildDir := path.Join(install.GetPath(), nginxModuleBuildDir)
|
||||
if err := os.MkdirAll(buildDir, constant.DirPerm); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
catalogContent, err := json.Marshal(catalog)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = os.WriteFile(path.Join(buildDir, nginxModuleCatalogFile), catalogContent, constant.FilePerm); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if state != "" {
|
||||
if err = os.WriteFile(path.Join(buildDir, nginxModuleStoreFile), []byte(state), constant.FilePerm); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadNginxModulesBuiltinStateCannotOverrideCatalogDefinition(t *testing.T) {
|
||||
oldDir := global.Dir.AppInstallDir
|
||||
global.Dir.AppInstallDir = t.TempDir()
|
||||
t.Cleanup(func() { global.Dir.AppInstallDir = oldDir })
|
||||
install := model.AppInstall{Name: "openresty", Version: "1.31.1.1"}
|
||||
install.App.Key = constant.AppOpenresty
|
||||
|
||||
catalog := []dto.NginxModule{{
|
||||
Name: "rtmp", Script: "catalog-script", Packages: []string{"unzip"}, Params: "--add-module=/tmp/rtmp",
|
||||
BuildMode: nginxModuleBuildDynamic, Provider: nginxModuleProviderLocal, LoadOrder: 20,
|
||||
}}
|
||||
state := `[{"name":"rtmp","enable":true,"script":"user-script","params":"--with-user","buildMode":"static","loadOrder":99,"lastError":"failed"}]`
|
||||
writeNginxModuleCatalogStateFixture(t, install, catalog, state)
|
||||
|
||||
modules, err := loadNginxModules(install)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(modules) != 1 {
|
||||
t.Fatalf("expected one merged module, got %#v", modules)
|
||||
}
|
||||
module := modules[0]
|
||||
if !module.Enable || module.LastError != "failed" {
|
||||
t.Fatalf("builtin state was not applied: %#v", module)
|
||||
}
|
||||
if module.Script != "catalog-script" || module.Params != "--add-module=/tmp/rtmp" ||
|
||||
module.BuildMode != nginxModuleBuildDynamic || module.LoadOrder != 20 {
|
||||
t.Fatalf("builtin definition must come from catalog: %#v", module)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadNginxModulesWithoutStateShowsDisabledCatalogModules(t *testing.T) {
|
||||
oldDir := global.Dir.AppInstallDir
|
||||
global.Dir.AppInstallDir = t.TempDir()
|
||||
t.Cleanup(func() { global.Dir.AppInstallDir = oldDir })
|
||||
install := model.AppInstall{Name: "openresty", Version: "1.31.1.1"}
|
||||
install.App.Key = constant.AppOpenresty
|
||||
|
||||
catalog := []dto.NginxModule{
|
||||
{Name: "rtmp", Enable: true, BuildMode: nginxModuleBuildDynamic},
|
||||
{Name: "geoip2", BuildMode: nginxModuleBuildDynamic},
|
||||
}
|
||||
writeNginxModuleCatalogStateFixture(t, install, catalog, "")
|
||||
|
||||
modules, err := loadNginxModules(install)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(modules) != len(catalog) {
|
||||
t.Fatalf("expected all catalog modules, got %#v", modules)
|
||||
}
|
||||
for _, module := range modules {
|
||||
if module.Enable || module.Custom {
|
||||
t.Fatalf("catalog modules must default to disabled built-ins: %#v", module)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveNginxModulesPersistsOnlyBuiltinState(t *testing.T) {
|
||||
oldDir := global.Dir.AppInstallDir
|
||||
global.Dir.AppInstallDir = t.TempDir()
|
||||
t.Cleanup(func() { global.Dir.AppInstallDir = oldDir })
|
||||
install := model.AppInstall{Name: "openresty", Version: "1.31.1.1"}
|
||||
install.App.Key = constant.AppOpenresty
|
||||
|
||||
catalog := []dto.NginxModule{{
|
||||
Name: "rtmp", Script: "catalog-script", Params: "--add-module=/tmp/rtmp",
|
||||
BuildMode: nginxModuleBuildDynamic, Provider: nginxModuleProviderLocal, LoadOrder: 20,
|
||||
}}
|
||||
writeNginxModuleCatalogStateFixture(t, install, catalog, "")
|
||||
modules, err := loadNginxModules(install)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
modules[0].Enable = true
|
||||
modules[0].LastError = "failed"
|
||||
|
||||
if err = saveNginxModules(install, modules); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
content, err := os.ReadFile(path.Join(install.GetPath(), nginxModuleBuildDir, nginxModuleStoreFile))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var states []map[string]any
|
||||
if err = json.Unmarshal(content, &states); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(states) != 1 || states[0]["name"] != "rtmp" || states[0]["enable"] != true || states[0]["lastError"] != "failed" {
|
||||
t.Fatalf("unexpected builtin state: %s", content)
|
||||
}
|
||||
for _, key := range []string{"script", "packages", "params", "buildMode", "provider", "loadOrder"} {
|
||||
if _, exists := states[0][key]; exists {
|
||||
t.Fatalf("builtin definition field %q leaked into module state: %s", key, content)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveNginxModulesOmitsPristineBuiltinState(t *testing.T) {
|
||||
oldDir := global.Dir.AppInstallDir
|
||||
global.Dir.AppInstallDir = t.TempDir()
|
||||
t.Cleanup(func() { global.Dir.AppInstallDir = oldDir })
|
||||
install := model.AppInstall{Name: "openresty", Version: "1.31.1.1"}
|
||||
install.App.Key = constant.AppOpenresty
|
||||
catalog := []dto.NginxModule{{Name: "rtmp", BuildMode: nginxModuleBuildDynamic}}
|
||||
writeNginxModuleCatalogStateFixture(t, install, catalog, "")
|
||||
|
||||
modules, err := loadNginxModules(install)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = saveNginxModules(install, modules); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
content, err := os.ReadFile(path.Join(install.GetPath(), nginxModuleBuildDir, nginxModuleStoreFile))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.TrimSpace(string(content)) != "[]" {
|
||||
t.Fatalf("pristine built-in state should not be persisted: %s", content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadAndSaveNginxCustomModule(t *testing.T) {
|
||||
oldDir := global.Dir.AppInstallDir
|
||||
global.Dir.AppInstallDir = t.TempDir()
|
||||
t.Cleanup(func() { global.Dir.AppInstallDir = oldDir })
|
||||
install := model.AppInstall{Name: "openresty", Version: "1.31.1.1"}
|
||||
install.App.Key = constant.AppOpenresty
|
||||
|
||||
state := `[{"name":"custom","custom":true,"script":"prepare","packages":["git"],"params":"--add-module=/tmp/custom","enable":true,"buildMode":"static","provider":"local","loadOrder":100}]`
|
||||
writeNginxModuleCatalogStateFixture(t, install, nil, state)
|
||||
|
||||
modules, err := loadNginxModules(install)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(modules) != 1 || !modules[0].Custom {
|
||||
t.Fatalf("custom module source was not restored: %#v", modules)
|
||||
}
|
||||
if modules[0].Script != "prepare" || modules[0].BuildMode != nginxModuleBuildStatic || modules[0].LoadOrder != 100 {
|
||||
t.Fatalf("custom module definition was not restored: %#v", modules[0])
|
||||
}
|
||||
|
||||
if err = saveNginxModules(install, modules); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
content, err := os.ReadFile(path.Join(install.GetPath(), nginxModuleBuildDir, nginxModuleStoreFile))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(content), `"custom": true`) || !strings.Contains(string(content), `"buildMode": "static"`) {
|
||||
t.Fatalf("custom module definition was not persisted: %s", content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestActivateNginxModuleCatalogReplacesCatalogAtomically(t *testing.T) {
|
||||
buildDir := t.TempDir()
|
||||
activePath := path.Join(buildDir, nginxModuleCatalogFile)
|
||||
pendingPath := path.Join(buildDir, nginxModuleCatalogPendingFile)
|
||||
sourcePath := path.Join(buildDir, "target.catalog.json")
|
||||
if err := os.WriteFile(activePath, []byte(`[{"name":"old"}]`), constant.FilePerm); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(sourcePath, []byte(`[{"name":"new"}]`), constant.FilePerm); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := stageNginxModuleCatalog(sourcePath, pendingPath); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
content, err := os.ReadFile(activePath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(content) != `[{"name":"old"}]` {
|
||||
t.Fatalf("staging target catalog changed the active catalog: %s", content)
|
||||
}
|
||||
if err := activateNginxModuleCatalog(pendingPath, activePath); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
content, err = os.ReadFile(activePath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(content) != `[{"name":"new"}]` {
|
||||
t.Fatalf("active catalog was not replaced: %s", content)
|
||||
}
|
||||
if _, err = os.Stat(pendingPath); !os.IsNotExist(err) {
|
||||
t.Fatalf("pending catalog should be consumed, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestActivateNginxModuleCatalogRestoresCatalogWhenCommitFails(t *testing.T) {
|
||||
buildDir := t.TempDir()
|
||||
activePath := path.Join(buildDir, nginxModuleCatalogFile)
|
||||
pendingPath := path.Join(buildDir, nginxModuleCatalogPendingFile)
|
||||
if err := os.WriteFile(activePath, []byte(`[{"name":"old"}]`), constant.FilePerm); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(pendingPath, []byte(`[{"name":"new"}]`), constant.FilePerm); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
commitErr := errors.New("save install failed")
|
||||
|
||||
err := activateNginxModuleCatalogAndCommit(pendingPath, activePath, func() error {
|
||||
return commitErr
|
||||
})
|
||||
if !errors.Is(err, commitErr) {
|
||||
t.Fatalf("expected commit error, got %v", err)
|
||||
}
|
||||
content, readErr := os.ReadFile(activePath)
|
||||
if readErr != nil {
|
||||
t.Fatal(readErr)
|
||||
}
|
||||
if string(content) != `[{"name":"old"}]` {
|
||||
t.Fatalf("failed upgrade must restore the old catalog: %s", content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadNginxModulesRejectsDuplicateCatalogNames(t *testing.T) {
|
||||
oldDir := global.Dir.AppInstallDir
|
||||
global.Dir.AppInstallDir = t.TempDir()
|
||||
t.Cleanup(func() { global.Dir.AppInstallDir = oldDir })
|
||||
install := model.AppInstall{Name: "openresty", Version: "1.31.1.1"}
|
||||
install.App.Key = constant.AppOpenresty
|
||||
|
||||
catalog := []dto.NginxModule{
|
||||
{Name: "rtmp", Params: "--add-module=/tmp/one", BuildMode: nginxModuleBuildDynamic},
|
||||
{Name: "rtmp", Params: "--add-module=/tmp/two", BuildMode: nginxModuleBuildDynamic},
|
||||
}
|
||||
writeNginxModuleCatalogStateFixture(t, install, catalog, "")
|
||||
|
||||
if _, err := loadNginxModules(install); err == nil || !strings.Contains(err.Error(), "duplicate") {
|
||||
t.Fatalf("expected duplicate catalog name error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadNginxModulesRejectsDuplicateStateNames(t *testing.T) {
|
||||
oldDir := global.Dir.AppInstallDir
|
||||
global.Dir.AppInstallDir = t.TempDir()
|
||||
t.Cleanup(func() { global.Dir.AppInstallDir = oldDir })
|
||||
install := model.AppInstall{Name: "openresty", Version: "1.31.1.1"}
|
||||
install.App.Key = constant.AppOpenresty
|
||||
|
||||
catalog := []dto.NginxModule{{Name: "rtmp", Params: "--add-module=/tmp/rtmp", BuildMode: nginxModuleBuildDynamic}}
|
||||
writeNginxModuleCatalogStateFixture(t, install, catalog, `[{"name":"rtmp","enable":true},{"name":"rtmp","enable":false}]`)
|
||||
|
||||
if _, err := loadNginxModules(install); err == nil || !strings.Contains(err.Error(), "duplicate") {
|
||||
t.Fatalf("expected duplicate state name error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadNginxModulesRejectsOrphanBuiltinState(t *testing.T) {
|
||||
oldDir := global.Dir.AppInstallDir
|
||||
global.Dir.AppInstallDir = t.TempDir()
|
||||
t.Cleanup(func() { global.Dir.AppInstallDir = oldDir })
|
||||
install := model.AppInstall{Name: "openresty", Version: "1.31.1.1"}
|
||||
install.App.Key = constant.AppOpenresty
|
||||
|
||||
writeNginxModuleCatalogStateFixture(t, install, nil, `[{"name":"removed","enable":true}]`)
|
||||
|
||||
if _, err := loadNginxModules(install); err == nil || !strings.Contains(err.Error(), "missing from the module catalog") {
|
||||
t.Fatalf("expected orphan builtin state error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadNginxModulesRejectsCustomCatalogNameConflict(t *testing.T) {
|
||||
oldDir := global.Dir.AppInstallDir
|
||||
global.Dir.AppInstallDir = t.TempDir()
|
||||
t.Cleanup(func() { global.Dir.AppInstallDir = oldDir })
|
||||
install := model.AppInstall{Name: "openresty", Version: "1.31.1.1"}
|
||||
install.App.Key = constant.AppOpenresty
|
||||
|
||||
catalog := []dto.NginxModule{{Name: "rtmp", BuildMode: nginxModuleBuildDynamic}}
|
||||
writeNginxModuleCatalogStateFixture(t, install, catalog, `[{"name":"rtmp","custom":true,"enable":true}]`)
|
||||
|
||||
if _, err := loadNginxModules(install); err == nil || !strings.Contains(err.Error(), "conflicts") {
|
||||
t.Fatalf("expected custom/catalog conflict error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadNginxModulesRejectsInvalidBuildModes(t *testing.T) {
|
||||
oldDir := global.Dir.AppInstallDir
|
||||
global.Dir.AppInstallDir = t.TempDir()
|
||||
t.Cleanup(func() { global.Dir.AppInstallDir = oldDir })
|
||||
install := model.AppInstall{Name: "openresty", Version: "1.31.1.1"}
|
||||
install.App.Key = constant.AppOpenresty
|
||||
|
||||
writeNginxModuleCatalogStateFixture(t, install, []dto.NginxModule{{
|
||||
Name: "rtmp", BuildMode: "auto",
|
||||
}}, "")
|
||||
if _, err := loadNginxModules(install); err == nil || !strings.Contains(err.Error(), "invalid build mode") {
|
||||
t.Fatalf("expected invalid catalog build mode error, got %v", err)
|
||||
}
|
||||
|
||||
writeNginxModuleCatalogStateFixture(t, install, nil, `[{"name":"custom","custom":true,"buildMode":"auto"}]`)
|
||||
if _, err := loadNginxModules(install); err == nil || !strings.Contains(err.Error(), "invalid build mode") {
|
||||
t.Fatalf("expected invalid custom build mode error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyNginxModuleUpdateKeepsBuiltinDefinitionImmutable(t *testing.T) {
|
||||
modules := []dto.NginxModule{{
|
||||
Name: "rtmp", Script: "catalog-script", Params: "--add-module=/tmp/rtmp",
|
||||
BuildMode: nginxModuleBuildDynamic, Provider: nginxModuleProviderLocal, LoadOrder: 20,
|
||||
}}
|
||||
req := request.NginxModuleUpdate{
|
||||
Operate: nginxModuleOperateUpdate, Name: "rtmp", Enable: true, Script: "user-script",
|
||||
Params: "--with-user", BuildMode: nginxModuleBuildStatic, Provider: "prebuilt", LoadOrder: 99,
|
||||
}
|
||||
|
||||
updated, _, err := applyNginxModuleUpdate(modules, req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !updated[0].Enable {
|
||||
t.Fatal("builtin enable state was not updated")
|
||||
}
|
||||
if updated[0].Script != "catalog-script" || updated[0].Params != "--add-module=/tmp/rtmp" ||
|
||||
updated[0].BuildMode != nginxModuleBuildDynamic || updated[0].Provider != nginxModuleProviderLocal ||
|
||||
updated[0].LoadOrder != 20 {
|
||||
t.Fatalf("builtin definition was modified: %#v", updated[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyNginxModuleUpdateRejectsBuiltinDelete(t *testing.T) {
|
||||
modules := []dto.NginxModule{{Name: "rtmp", BuildMode: nginxModuleBuildDynamic}}
|
||||
|
||||
_, _, err := applyNginxModuleUpdate(modules, request.NginxModuleUpdate{
|
||||
Operate: nginxModuleOperateDelete, Name: "rtmp",
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "cannot be deleted") {
|
||||
t.Fatalf("expected builtin delete error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyNginxModuleUpdateCreatesAndDeletesCustomModule(t *testing.T) {
|
||||
if _, _, err := applyNginxModuleUpdate(nil, request.NginxModuleUpdate{
|
||||
Operate: nginxModuleOperateCreate, Name: "invalid", BuildMode: "auto",
|
||||
}); err == nil || !strings.Contains(err.Error(), "invalid build mode") {
|
||||
t.Fatalf("expected invalid custom build mode error, got %v", err)
|
||||
}
|
||||
|
||||
created, _, err := applyNginxModuleUpdate(nil, request.NginxModuleUpdate{
|
||||
Operate: nginxModuleOperateCreate, Name: "custom", Script: "prepare", Packages: "git,curl",
|
||||
Params: "--add-module=/tmp/custom", Enable: true, BuildMode: nginxModuleBuildStatic,
|
||||
Provider: nginxModuleProviderLocal, LoadOrder: 100,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(created) != 1 || !created[0].Custom || created[0].BuildMode != nginxModuleBuildStatic {
|
||||
t.Fatalf("custom module was not created correctly: %#v", created)
|
||||
}
|
||||
updated, _, err := applyNginxModuleUpdate(created, request.NginxModuleUpdate{
|
||||
Operate: nginxModuleOperateUpdate, Name: "custom", Script: "updated",
|
||||
Params: "--add-module=/tmp/custom-v2", Enable: false, BuildMode: nginxModuleBuildDynamic,
|
||||
Provider: nginxModuleProviderLocal, LoadOrder: 75,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if updated[0].Script != "updated" || updated[0].BuildMode != nginxModuleBuildDynamic || updated[0].LoadOrder != 75 {
|
||||
t.Fatalf("custom module was not updated correctly: %#v", updated[0])
|
||||
}
|
||||
|
||||
remaining, deleted, err := applyNginxModuleUpdate(updated, request.NginxModuleUpdate{
|
||||
Operate: nginxModuleOperateDelete, Name: "custom",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(remaining) != 0 || deleted == nil || deleted.Name != "custom" {
|
||||
t.Fatalf("custom module was not removed: remaining=%#v deleted=%#v", remaining, deleted)
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
|
||||
"github.com/1Panel-dev/1Panel/agent/app/dto/request"
|
||||
"github.com/1Panel-dev/1Panel/agent/utils/common"
|
||||
agentPsutil "github.com/1Panel-dev/1Panel/agent/utils/psutil"
|
||||
"github.com/1Panel-dev/1Panel/agent/utils/websocket"
|
||||
"github.com/shirou/gopsutil/v4/net"
|
||||
"github.com/shirou/gopsutil/v4/process"
|
||||
@@ -128,7 +129,7 @@ func (ps *ProcessService) GetProcessInfoByPID(pid int32) (*websocket.PsProcessDa
|
||||
}
|
||||
}
|
||||
|
||||
if createTime, err := p.CreateTime(); err == nil {
|
||||
if createTime, err := agentPsutil.NewProcessCreateTimeResolver().CreateTime(p); err == nil {
|
||||
data.StartTime = time.Unix(createTime/1000, 0).Format("2006-01-02 15:04:05")
|
||||
}
|
||||
|
||||
|
||||
@@ -166,21 +166,28 @@ func reCreateRuntime(runtime *model.Runtime) {
|
||||
}
|
||||
}
|
||||
|
||||
func getComposeCmd(composePath, operate string) *exec.Cmd {
|
||||
func getComposeCmd(composePath, operate string, projectName ...string) *exec.Cmd {
|
||||
return getComposeCmdWithEnv(composePath, operate, "", projectName...)
|
||||
}
|
||||
|
||||
func getComposeCmdWithEnv(composePath, operate, envFile string, projectName ...string) *exec.Cmd {
|
||||
dockerCommand := global.CONF.DockerConfig.Command
|
||||
args := make([]string, 0, 9)
|
||||
if envFile != "" {
|
||||
args = append(args, "--env-file", envFile)
|
||||
}
|
||||
if len(projectName) > 0 && strings.TrimSpace(projectName[0]) != "" {
|
||||
args = append(args, "--project-name", projectName[0])
|
||||
}
|
||||
args = append(args, "-f", composePath, operate)
|
||||
if operate == "up" {
|
||||
args = append(args, "-d")
|
||||
}
|
||||
var cmd *exec.Cmd
|
||||
if dockerCommand == "docker-compose" {
|
||||
if operate == "up" {
|
||||
cmd = exec.Command("docker-compose", "-f", composePath, operate, "-d")
|
||||
} else {
|
||||
cmd = exec.Command("docker-compose", "-f", composePath, operate)
|
||||
}
|
||||
cmd = exec.Command("docker-compose", args...)
|
||||
} else {
|
||||
if operate == "up" {
|
||||
cmd = exec.Command("docker", "compose", "-f", composePath, operate, "-d")
|
||||
} else {
|
||||
cmd = exec.Command("docker", "compose", "-f", composePath, operate)
|
||||
}
|
||||
cmd = exec.Command("docker", append([]string{"compose"}, args...)...)
|
||||
}
|
||||
return cmd
|
||||
}
|
||||
|
||||
@@ -250,7 +250,11 @@ func handleDownloadSnapshot(itemHelper *snapRecoverHelper, snap model.Snapshot,
|
||||
itemHelper.Task.LogStart(i18n.GetMsgByKey("RecoverDownload"))
|
||||
|
||||
account, client, err := NewBackupClientWithID(snap.DownloadAccountID)
|
||||
itemHelper.Task.LogWithStatus(i18n.GetWithName("RecoverDownloadAccount", fmt.Sprintf("%s - %s", account.Type, account.Name)), err)
|
||||
if err != nil {
|
||||
itemHelper.Task.LogWithStatus(i18n.GetWithName("RecoverDownloadAccount", "-"), err)
|
||||
return err
|
||||
}
|
||||
itemHelper.Task.LogWithStatus(i18n.GetWithName("RecoverDownloadAccount", fmt.Sprintf("%s - %s", account.Type, account.Name)), nil)
|
||||
targetPath := ""
|
||||
if len(account.BackupPath) != 0 {
|
||||
targetPath = path.Join(account.BackupPath, fmt.Sprintf("system_snapshot/%s.tar.gz", snap.Name))
|
||||
@@ -259,11 +263,23 @@ func handleDownloadSnapshot(itemHelper *snapRecoverHelper, snap model.Snapshot,
|
||||
}
|
||||
filePath := fmt.Sprintf("%s/%s.tar.gz", targetDir, snap.Name)
|
||||
_ = os.RemoveAll(filePath)
|
||||
_, err = client.Download(targetPath, filePath)
|
||||
err = prepareSnapshotRecoverFile(account.Type, targetPath, filePath, func() error {
|
||||
_, downloadErr := client.Download(targetPath, filePath)
|
||||
return downloadErr
|
||||
})
|
||||
itemHelper.Task.LogWithStatus(i18n.GetMsgByKey("Download"), err)
|
||||
return err
|
||||
}
|
||||
|
||||
func prepareSnapshotRecoverFile(accountType, sourcePath, targetPath string, download func() error) error {
|
||||
if accountType == constant.Local {
|
||||
if err := os.Link(sourcePath, targetPath); err == nil {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return download()
|
||||
}
|
||||
|
||||
func backupBeforeRecover(name string, itemHelper *snapRecoverHelper) error {
|
||||
itemHelper.Task.Log("---------------------- 3 / 11 ----------------------")
|
||||
itemHelper.Task.LogStart(i18n.GetMsgByKey("BackupBeforeRecover"))
|
||||
|
||||
@@ -165,7 +165,12 @@ func (w WebsiteService) PageWebsite(req request.WebsiteSearch) (int64, []respons
|
||||
websiteDTOs []response.WebsiteRes
|
||||
opts []repo.DBOption
|
||||
)
|
||||
opts = append(opts, repo.WithOrderRuleBy(req.OrderBy, req.Order), repo.WithOrderRuleBy("updated_at", "descending"))
|
||||
opts = append(
|
||||
opts,
|
||||
repo.WithOrderRuleBy(req.OrderBy, req.Order),
|
||||
repo.WithOrderRuleBy("created_at", "descending"),
|
||||
repo.WithOrderRuleBy("id", "descending"),
|
||||
)
|
||||
if req.Name != "" {
|
||||
domains, _ := websiteDomainRepo.GetBy(websiteDomainRepo.WithDomainLike(req.Name))
|
||||
var websiteIds []uint
|
||||
@@ -488,6 +493,21 @@ func (w WebsiteService) CreateWebsite(create request.WebsiteCreate) (err error)
|
||||
if err = configDefaultNginx(website, domains, appInstall, runtime, create.StreamConfig); err != nil {
|
||||
return err
|
||||
}
|
||||
if create.Type == constant.Static && create.TemplateOutputID > 0 {
|
||||
templateOutput, err := websiteTemplateOutputRepo.GetFirst(repo.WithByID(create.TemplateOutputID))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if templateOutput.OutputPath == "" {
|
||||
return buserr.New("ErrFileNotFound")
|
||||
}
|
||||
if _, err := os.Stat(templateOutput.OutputPath); err != nil {
|
||||
return buserr.New("ErrFileNotFound")
|
||||
}
|
||||
if err := copyDir(templateOutput.OutputPath, GetSitePath(*website, SiteIndexDir)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if website.Type != constant.Stream {
|
||||
if err = createWafConfig(website, domains); err != nil {
|
||||
return err
|
||||
@@ -561,7 +581,7 @@ func (w WebsiteService) CreateWebsite(create request.WebsiteCreate) (err error)
|
||||
if len(create.FtpUser) != 0 && len(create.FtpPassword) != 0 {
|
||||
createFtpUser := func(t *task.Task) error {
|
||||
indexDir := GetSitePath(*website, SiteIndexDir)
|
||||
itemID, err := NewIFtpService().Create(dto.FtpCreate{User: create.FtpUser, Password: create.FtpPassword, Path: indexDir})
|
||||
itemID, err := NewIFtpService().CreateWebsite(dto.FtpCreate{User: create.FtpUser, Password: create.FtpPassword, Path: indexDir})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,440 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/1Panel-dev/1Panel/agent/app/dto/request"
|
||||
"github.com/1Panel-dev/1Panel/agent/app/dto/response"
|
||||
"github.com/1Panel-dev/1Panel/agent/app/model"
|
||||
"github.com/1Panel-dev/1Panel/agent/app/repo"
|
||||
"github.com/1Panel-dev/1Panel/agent/buserr"
|
||||
"github.com/1Panel-dev/1Panel/agent/constant"
|
||||
"github.com/1Panel-dev/1Panel/agent/global"
|
||||
)
|
||||
|
||||
type WebsiteTemplateService struct {
|
||||
}
|
||||
|
||||
type IWebsiteTemplateService interface {
|
||||
PageTemplate(req request.WebsiteTemplateSearch) (int64, []response.WebsiteTemplateDTO, error)
|
||||
CreateTemplate(req request.WebsiteTemplateCreate) error
|
||||
UpdateTemplate(req request.WebsiteTemplateUpdate) error
|
||||
DeleteTemplate(id uint) error
|
||||
GetTemplate(id uint) (*response.WebsiteTemplateDTO, error)
|
||||
SaveUploadZip(fileName string, content []byte) (string, []string, error)
|
||||
PageOutput(req request.WebsiteTemplateOutputSearch) (int64, []response.WebsiteTemplateOutputDTO, error)
|
||||
CreateOutput(req request.WebsiteTemplateOutputCreate) error
|
||||
DeleteOutput(id uint) error
|
||||
GetOutput(id uint) (*response.WebsiteTemplateOutputDTO, error)
|
||||
Preview(req request.WebsitePreviewReq) (*response.WebsitePreviewDTO, error)
|
||||
}
|
||||
|
||||
func NewIWebsiteTemplateService() IWebsiteTemplateService {
|
||||
return &WebsiteTemplateService{}
|
||||
}
|
||||
|
||||
func templateBaseDir() string {
|
||||
return path.Join(global.Dir.DataDir, "templates")
|
||||
}
|
||||
|
||||
func templateFileDir() string {
|
||||
return path.Join(templateBaseDir(), "files")
|
||||
}
|
||||
|
||||
func templateOutputDir(id uint) string {
|
||||
return path.Join(templateBaseDir(), "outputs", fmt.Sprintf("%d", id))
|
||||
}
|
||||
|
||||
func (w WebsiteTemplateService) PageTemplate(req request.WebsiteTemplateSearch) (int64, []response.WebsiteTemplateDTO, error) {
|
||||
var opts []repo.DBOption
|
||||
if req.Name != "" {
|
||||
opts = append(opts, websiteTemplateRepo.WithName(req.Name))
|
||||
}
|
||||
if req.Type != "" {
|
||||
opts = append(opts, websiteTemplateRepo.WithType(req.Type))
|
||||
}
|
||||
opts = append(opts, repo.WithOrderDesc("created_at"))
|
||||
total, templates, err := websiteTemplateRepo.Page(req.Page, req.PageSize, opts...)
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
var dtos []response.WebsiteTemplateDTO
|
||||
for _, t := range templates {
|
||||
dtos = append(dtos, response.WebsiteTemplateDTO{WebsiteTemplate: t})
|
||||
}
|
||||
return total, dtos, nil
|
||||
}
|
||||
|
||||
func (w WebsiteTemplateService) CreateTemplate(req request.WebsiteTemplateCreate) error {
|
||||
if err := validateWebsiteTemplateFile(req.Type, req.FilePath); err != nil {
|
||||
return err
|
||||
}
|
||||
if exist, _ := websiteTemplateRepo.GetFirst(repo.WithByName(req.Name)); exist != nil {
|
||||
return buserr.New("ErrNameIsExist")
|
||||
}
|
||||
template := &model.WebsiteTemplate{
|
||||
Name: req.Name,
|
||||
Type: req.Type,
|
||||
Content: req.Content,
|
||||
FilePath: req.FilePath,
|
||||
Variables: req.Variables,
|
||||
Remark: req.Remark,
|
||||
}
|
||||
return websiteTemplateRepo.Create(template)
|
||||
}
|
||||
|
||||
func (w WebsiteTemplateService) UpdateTemplate(req request.WebsiteTemplateUpdate) error {
|
||||
template, err := websiteTemplateRepo.GetFirst(repo.WithByID(req.ID))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateWebsiteTemplateFile(req.Type, req.FilePath); err != nil {
|
||||
return err
|
||||
}
|
||||
if exist, _ := websiteTemplateRepo.GetFirst(repo.WithByName(req.Name), repo.WithByNOTID(req.ID)); exist != nil {
|
||||
return buserr.New("ErrNameIsExist")
|
||||
}
|
||||
template.Name = req.Name
|
||||
template.Type = req.Type
|
||||
template.Content = req.Content
|
||||
if req.FilePath != template.FilePath && template.FilePath != "" && strings.HasPrefix(template.FilePath, templateBaseDir()) {
|
||||
_ = os.Remove(template.FilePath)
|
||||
}
|
||||
template.FilePath = req.FilePath
|
||||
template.Variables = req.Variables
|
||||
template.Remark = req.Remark
|
||||
return websiteTemplateRepo.Save(template)
|
||||
}
|
||||
|
||||
func validateWebsiteTemplateFile(templateType, filePath string) error {
|
||||
if templateType != "multi" {
|
||||
return nil
|
||||
}
|
||||
if filePath == "" {
|
||||
return buserr.WithName("ErrFileNotFound", "ZIP")
|
||||
}
|
||||
if _, err := os.Stat(filePath); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return buserr.WithName("ErrFileNotFound", "ZIP")
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w WebsiteTemplateService) DeleteTemplate(id uint) error {
|
||||
template, err := websiteTemplateRepo.GetFirst(repo.WithByID(id))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
outputs, _ := websiteTemplateOutputRepo.List(websiteTemplateOutputRepo.WithByTemplateID(id))
|
||||
for _, output := range outputs {
|
||||
if output.OutputPath != "" && strings.HasPrefix(output.OutputPath, templateBaseDir()) {
|
||||
_ = os.RemoveAll(output.OutputPath)
|
||||
}
|
||||
}
|
||||
if template.FilePath != "" && strings.HasPrefix(template.FilePath, templateBaseDir()) {
|
||||
_ = os.Remove(template.FilePath)
|
||||
}
|
||||
if err := websiteTemplateOutputRepo.DeleteBy(websiteTemplateOutputRepo.WithByTemplateID(id)); err != nil {
|
||||
return err
|
||||
}
|
||||
return websiteTemplateRepo.DeleteBy(repo.WithByID(id))
|
||||
}
|
||||
|
||||
func (w WebsiteTemplateService) GetTemplate(id uint) (*response.WebsiteTemplateDTO, error) {
|
||||
template, err := websiteTemplateRepo.GetFirst(repo.WithByID(id))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &response.WebsiteTemplateDTO{WebsiteTemplate: *template}, nil
|
||||
}
|
||||
|
||||
func (w WebsiteTemplateService) SaveUploadZip(fileName string, content []byte) (string, []string, error) {
|
||||
if !strings.HasSuffix(strings.ToLower(fileName), ".zip") {
|
||||
return "", nil, buserr.WithName("ErrNotSupportType", fileName)
|
||||
}
|
||||
dir := templateFileDir()
|
||||
if err := os.MkdirAll(dir, constant.DirPerm); err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
filePath := path.Join(dir, fmt.Sprintf("%d_%s", time.Now().Unix(), filepath.Base(fileName)))
|
||||
if err := os.WriteFile(filePath, content, constant.FilePerm); err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
return filePath, scanZipVariables(filePath), nil
|
||||
}
|
||||
|
||||
func scanZipVariables(zipPath string) []string {
|
||||
variables := []string{}
|
||||
reader, err := zip.OpenReader(zipPath)
|
||||
if err != nil {
|
||||
return variables
|
||||
}
|
||||
defer reader.Close()
|
||||
seen := make(map[string]struct{})
|
||||
for _, file := range reader.File {
|
||||
if file.FileInfo().IsDir() || !isTextFile(file.Name) {
|
||||
continue
|
||||
}
|
||||
rc, err := file.Open()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
content, err := io.ReadAll(rc)
|
||||
_ = rc.Close()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, match := range templateVarRegex.FindAllStringSubmatch(string(content), -1) {
|
||||
if _, ok := seen[match[1]]; !ok {
|
||||
seen[match[1]] = struct{}{}
|
||||
variables = append(variables, match[1])
|
||||
}
|
||||
}
|
||||
}
|
||||
return variables
|
||||
}
|
||||
|
||||
func (w WebsiteTemplateService) PageOutput(req request.WebsiteTemplateOutputSearch) (int64, []response.WebsiteTemplateOutputDTO, error) {
|
||||
var opts []repo.DBOption
|
||||
if req.TemplateID > 0 {
|
||||
opts = append(opts, websiteTemplateOutputRepo.WithByTemplateID(req.TemplateID))
|
||||
}
|
||||
opts = append(opts, repo.WithOrderDesc("created_at"))
|
||||
total, outputs, err := websiteTemplateOutputRepo.Page(req.Page, req.PageSize, opts...)
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
var dtos []response.WebsiteTemplateOutputDTO
|
||||
for _, output := range outputs {
|
||||
item := response.WebsiteTemplateOutputDTO{WebsiteTemplateOutput: output}
|
||||
if template, err := websiteTemplateRepo.GetFirst(repo.WithByID(output.TemplateID)); err == nil {
|
||||
item.TemplateName = template.Name
|
||||
}
|
||||
dtos = append(dtos, item)
|
||||
}
|
||||
return total, dtos, nil
|
||||
}
|
||||
|
||||
func (w WebsiteTemplateService) CreateOutput(req request.WebsiteTemplateOutputCreate) error {
|
||||
template, err := websiteTemplateRepo.GetFirst(repo.WithByID(req.TemplateID))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
valuesJSON, err := json.Marshal(req.VariableValues)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
output := &model.WebsiteTemplateOutput{
|
||||
Name: req.Name,
|
||||
TemplateID: template.ID,
|
||||
TemplateType: template.Type,
|
||||
VariableValues: string(valuesJSON),
|
||||
}
|
||||
if err := websiteTemplateOutputRepo.Create(output); err != nil {
|
||||
return err
|
||||
}
|
||||
outputDir := templateOutputDir(output.ID)
|
||||
if err := renderToDir(template, req.VariableValues, outputDir); err != nil {
|
||||
_ = websiteTemplateOutputRepo.DeleteBy(repo.WithByID(output.ID))
|
||||
return err
|
||||
}
|
||||
output.OutputPath = outputDir
|
||||
return websiteTemplateOutputRepo.Save(output)
|
||||
}
|
||||
|
||||
func (w WebsiteTemplateService) DeleteOutput(id uint) error {
|
||||
output, err := websiteTemplateOutputRepo.GetFirst(repo.WithByID(id))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if output.OutputPath != "" && strings.HasPrefix(output.OutputPath, templateBaseDir()) {
|
||||
_ = os.RemoveAll(output.OutputPath)
|
||||
}
|
||||
return websiteTemplateOutputRepo.DeleteBy(repo.WithByID(id))
|
||||
}
|
||||
|
||||
func (w WebsiteTemplateService) GetOutput(id uint) (*response.WebsiteTemplateOutputDTO, error) {
|
||||
output, err := websiteTemplateOutputRepo.GetFirst(repo.WithByID(id))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
item := &response.WebsiteTemplateOutputDTO{WebsiteTemplateOutput: *output}
|
||||
if template, err := websiteTemplateRepo.GetFirst(repo.WithByID(output.TemplateID)); err == nil {
|
||||
item.TemplateName = template.Name
|
||||
}
|
||||
return item, nil
|
||||
}
|
||||
|
||||
func (w WebsiteTemplateService) Preview(req request.WebsitePreviewReq) (*response.WebsitePreviewDTO, error) {
|
||||
template, err := websiteTemplateRepo.GetFirst(repo.WithByID(req.TemplateID))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var html string
|
||||
if template.Type == "single" {
|
||||
html = renderContent(template.Content, req.VariableValues)
|
||||
} else {
|
||||
html, err = renderMainHTMLFromZip(template.FilePath, req.VariableValues)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return &response.WebsitePreviewDTO{HTML: html}, nil
|
||||
}
|
||||
|
||||
var templateVarRegex = regexp.MustCompile(`\{\{(\w+)\}\}`)
|
||||
|
||||
func renderContent(content string, values map[string]string) string {
|
||||
rendered := templateVarRegex.ReplaceAllStringFunc(content, func(match string) string {
|
||||
key := templateVarRegex.FindStringSubmatch(match)[1]
|
||||
if val, ok := values[key]; ok {
|
||||
return val
|
||||
}
|
||||
return ""
|
||||
})
|
||||
return rendered
|
||||
}
|
||||
|
||||
func renderToDir(template *model.WebsiteTemplate, values map[string]string, outputDir string) error {
|
||||
if err := os.MkdirAll(outputDir, constant.DirPerm); err != nil {
|
||||
return err
|
||||
}
|
||||
if template.Type == "single" {
|
||||
html := renderContent(template.Content, values)
|
||||
return os.WriteFile(path.Join(outputDir, "index.html"), []byte(html), constant.FilePerm)
|
||||
}
|
||||
return unzipAndRender(template.FilePath, outputDir, values)
|
||||
}
|
||||
|
||||
func unzipAndRender(zipPath, outputDir string, values map[string]string) error {
|
||||
if zipPath == "" {
|
||||
return buserr.WithName("ErrFileNotFound", "ZIP")
|
||||
}
|
||||
reader, err := zip.OpenReader(zipPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer reader.Close()
|
||||
for _, file := range reader.File {
|
||||
if err := extractAndRenderFile(file, outputDir, values); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func extractAndRenderFile(file *zip.File, outputDir string, values map[string]string) error {
|
||||
targetPath := filepath.Join(outputDir, file.Name)
|
||||
if !strings.HasPrefix(targetPath, filepath.Clean(outputDir)+string(os.PathSeparator)) {
|
||||
return fmt.Errorf("invalid file path in zip: %s", file.Name)
|
||||
}
|
||||
if file.FileInfo().IsDir() {
|
||||
return os.MkdirAll(targetPath, constant.DirPerm)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(targetPath), constant.DirPerm); err != nil {
|
||||
return err
|
||||
}
|
||||
rc, err := file.Open()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rc.Close()
|
||||
content, err := io.ReadAll(rc)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if isTextFile(file.Name) {
|
||||
content = []byte(renderContent(string(content), values))
|
||||
}
|
||||
return os.WriteFile(targetPath, content, constant.FilePerm)
|
||||
}
|
||||
|
||||
func isTextFile(name string) bool {
|
||||
switch strings.ToLower(filepath.Ext(name)) {
|
||||
case ".html", ".htm", ".css", ".js", ".json", ".xml", ".txt", ".svg", ".md":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func renderMainHTMLFromZip(zipPath string, values map[string]string) (string, error) {
|
||||
if zipPath == "" {
|
||||
return "", buserr.WithName("ErrFileNotFound", "ZIP")
|
||||
}
|
||||
reader, err := zip.OpenReader(zipPath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer reader.Close()
|
||||
var mainFile *zip.File
|
||||
for _, file := range reader.File {
|
||||
if file.FileInfo().IsDir() {
|
||||
continue
|
||||
}
|
||||
name := strings.ToLower(filepath.Base(file.Name))
|
||||
if name == "index.html" || name == "index.htm" {
|
||||
if mainFile == nil || len(file.Name) < len(mainFile.Name) {
|
||||
mainFile = file
|
||||
}
|
||||
}
|
||||
}
|
||||
if mainFile == nil {
|
||||
return "", buserr.WithName("ErrFileNotFound", "index.html")
|
||||
}
|
||||
rc, err := mainFile.Open()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer rc.Close()
|
||||
content, err := io.ReadAll(rc)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return renderContent(string(content), values), nil
|
||||
}
|
||||
|
||||
func copyDir(src, dst string) error {
|
||||
return filepath.Walk(src, func(p string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
relPath, err := filepath.Rel(src, p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
targetPath := filepath.Join(dst, relPath)
|
||||
if info.IsDir() {
|
||||
return os.MkdirAll(targetPath, constant.DirPerm)
|
||||
}
|
||||
return copyFile(p, targetPath)
|
||||
})
|
||||
}
|
||||
|
||||
func copyFile(src, dst string) error {
|
||||
srcFile, err := os.Open(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer srcFile.Close()
|
||||
if err := os.MkdirAll(filepath.Dir(dst), constant.DirPerm); err != nil {
|
||||
return err
|
||||
}
|
||||
dstFile, err := os.Create(dst)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer dstFile.Close()
|
||||
_, err = io.Copy(dstFile, srcFile)
|
||||
return err
|
||||
}
|
||||
@@ -613,10 +613,6 @@ func delWafConfig(website model.Website, force bool) error {
|
||||
if !fileOp.Stat(wafDataPath) {
|
||||
return nil
|
||||
}
|
||||
monitorDir := path.Join(wafDataPath, "db", "sites", website.Alias)
|
||||
if fileOp.Stat(monitorDir) {
|
||||
_ = fileOp.DeleteDir(monitorDir)
|
||||
}
|
||||
websitesConfigPath := path.Join(wafDataPath, "conf", "sites.json")
|
||||
content, err := fileOp.GetContent(websitesConfigPath)
|
||||
if err != nil {
|
||||
@@ -642,7 +638,13 @@ func delWafConfig(website model.Website, force bool) error {
|
||||
return err
|
||||
}
|
||||
|
||||
_ = fileOp.DeleteDir(path.Join(wafDataPath, "sites", website.Alias))
|
||||
for _, websiteDataDir := range []string{
|
||||
path.Join(wafDataPath, "sites", website.Alias),
|
||||
path.Join(wafDataPath, "db", "monitor", website.Alias),
|
||||
path.Join(wafDataPath, "db", "sites", website.Alias),
|
||||
} {
|
||||
_ = fileOp.DeleteDir(websiteDataDir)
|
||||
}
|
||||
|
||||
if err := opNginx(nginxInstall.ContainerName, constant.NginxReload); err != nil {
|
||||
if force {
|
||||
@@ -1221,7 +1223,8 @@ func checkIsLinkApp(website model.Website) bool {
|
||||
|
||||
func chownRootDir(path string) error {
|
||||
cmdMgr := cmd.NewCommandMgr(cmd.WithTimeout(1 * time.Second))
|
||||
if err := cmdMgr.Run("chown", "-R", "1000:1000", path); err != nil {
|
||||
owner := fmt.Sprintf("%d:%d", constant.WebsiteUID, constant.WebsiteGID)
|
||||
if err := cmdMgr.Run("chown", "-R", owner, path); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
@@ -1559,7 +1562,7 @@ func GetSitePath(website model.Website, confType string) string {
|
||||
|
||||
func GetConfDir(website model.Website) string {
|
||||
if website.Type != constant.Stream {
|
||||
return GetOpenrestyDir(SiteConf)
|
||||
return GetOpenrestyDir(SiteConfDir)
|
||||
}
|
||||
return GetOpenrestyDir(StreamDir)
|
||||
}
|
||||
|
||||
@@ -816,12 +816,13 @@
|
||||
},
|
||||
"/core/auth/api/update": {
|
||||
"bodyKeys": [
|
||||
"ipWhiteList"
|
||||
"ipWhiteList",
|
||||
"apiTrustedProxies"
|
||||
],
|
||||
"paramKeys": [],
|
||||
"beforeFunctions": [],
|
||||
"formatZH": "更新 API 接口配置 =\u003e IP 白名单: [ipWhiteList]",
|
||||
"formatEN": "update api config =\u003e IP White List: [ipWhiteList]"
|
||||
"formatZH": "更新 API 接口配置 =\u003e IP 白名单: [ipWhiteList], API 可信代理: [apiTrustedProxies]",
|
||||
"formatEN": "update api config =\u003e IP Allowlist: [ipWhiteList], API Trusted Proxies: [apiTrustedProxies]"
|
||||
},
|
||||
"/core/auth/expired/reset": {
|
||||
"bodyKeys": [],
|
||||
@@ -1023,6 +1024,13 @@
|
||||
"formatZH": "删除 AI 网关 API Key",
|
||||
"formatEN": "delete AI proxy API key"
|
||||
},
|
||||
"/core/enterprise/ai-proxy/api-keys/reactivate": {
|
||||
"bodyKeys": [],
|
||||
"paramKeys": [],
|
||||
"beforeFunctions": [],
|
||||
"formatZH": "重新激活 AI 网关 API Key",
|
||||
"formatEN": "reactivate AI proxy API key"
|
||||
},
|
||||
"/core/enterprise/ai-proxy/api-keys/reveal": {
|
||||
"bodyKeys": [],
|
||||
"paramKeys": [],
|
||||
@@ -1341,6 +1349,99 @@
|
||||
"formatZH": "更新 AI 网关智能路由样本 [id] [type] [label]",
|
||||
"formatEN": "update AI proxy smart route sample [id] [type] [label]"
|
||||
},
|
||||
"/core/enterprise/auth-sources/ldap/setting": {
|
||||
"bodyKeys": [],
|
||||
"paramKeys": [],
|
||||
"beforeFunctions": [],
|
||||
"formatZH": "更新 LDAP 认证设置",
|
||||
"formatEN": "update LDAP authentication settings"
|
||||
},
|
||||
"/core/enterprise/auth-sources/ldap/sync/run": {
|
||||
"bodyKeys": [],
|
||||
"paramKeys": [],
|
||||
"beforeFunctions": [],
|
||||
"formatZH": "手动同步 LDAP 用户",
|
||||
"formatEN": "manually synchronize LDAP users"
|
||||
},
|
||||
"/core/enterprise/auth-sources/ldap/sync/setting": {
|
||||
"bodyKeys": [],
|
||||
"paramKeys": [],
|
||||
"beforeFunctions": [],
|
||||
"formatZH": "更新 LDAP 同步设置",
|
||||
"formatEN": "update LDAP synchronization settings"
|
||||
},
|
||||
"/core/enterprise/auth-sources/ldap/test-connection": {
|
||||
"bodyKeys": [],
|
||||
"paramKeys": [],
|
||||
"beforeFunctions": [],
|
||||
"formatZH": "测试 LDAP 连接",
|
||||
"formatEN": "test LDAP connection"
|
||||
},
|
||||
"/core/enterprise/auth-sources/ldap/test-login": {
|
||||
"bodyKeys": [],
|
||||
"paramKeys": [],
|
||||
"beforeFunctions": [],
|
||||
"formatZH": "测试 LDAP 用户登录",
|
||||
"formatEN": "test LDAP user login"
|
||||
},
|
||||
"/core/enterprise/auth-sources/ldap/users/import": {
|
||||
"bodyKeys": [],
|
||||
"paramKeys": [],
|
||||
"beforeFunctions": [],
|
||||
"formatZH": "导入 LDAP 用户",
|
||||
"formatEN": "import LDAP users"
|
||||
},
|
||||
"/core/enterprise/auth-sources/oidc/setting": {
|
||||
"bodyKeys": [],
|
||||
"paramKeys": [],
|
||||
"beforeFunctions": [],
|
||||
"formatZH": "更新 OIDC 认证设置",
|
||||
"formatEN": "update OIDC authentication settings"
|
||||
},
|
||||
"/core/enterprise/auth-sources/oidc/test-configuration": {
|
||||
"bodyKeys": [],
|
||||
"paramKeys": [],
|
||||
"beforeFunctions": [],
|
||||
"formatZH": "测试 OIDC 连接",
|
||||
"formatEN": "test OIDC connection"
|
||||
},
|
||||
"/core/enterprise/auth-sources/oidc/test-login/begin": {
|
||||
"bodyKeys": [],
|
||||
"paramKeys": [],
|
||||
"beforeFunctions": [],
|
||||
"formatZH": "发起 OIDC 测试登录",
|
||||
"formatEN": "start OIDC test login"
|
||||
},
|
||||
"/core/enterprise/auth-sources/saml2/setting": {
|
||||
"bodyKeys": [],
|
||||
"paramKeys": [],
|
||||
"beforeFunctions": [],
|
||||
"formatZH": "更新 SAML2 认证设置",
|
||||
"formatEN": "update SAML2 authentication settings"
|
||||
},
|
||||
"/core/enterprise/auth-sources/saml2/test-configuration": {
|
||||
"bodyKeys": [],
|
||||
"paramKeys": [],
|
||||
"beforeFunctions": [],
|
||||
"formatZH": "测试 SAML2 连接",
|
||||
"formatEN": "test SAML2 connection"
|
||||
},
|
||||
"/core/enterprise/auth-sources/saml2/test-login/begin": {
|
||||
"bodyKeys": [],
|
||||
"paramKeys": [],
|
||||
"beforeFunctions": [],
|
||||
"formatZH": "发起 SAML2 测试登录",
|
||||
"formatEN": "start SAML2 test login"
|
||||
},
|
||||
"/core/enterprise/licenses/community-restore": {
|
||||
"bodyKeys": [
|
||||
"mode"
|
||||
],
|
||||
"paramKeys": [],
|
||||
"beforeFunctions": [],
|
||||
"formatZH": "恢复为社区版",
|
||||
"formatEN": "restore Community Edition"
|
||||
},
|
||||
"/core/enterprise/licenses/upload": {
|
||||
"bodyKeys": [],
|
||||
"paramKeys": [],
|
||||
@@ -1577,7 +1678,8 @@
|
||||
"/core/enterprise/users/api/update": {
|
||||
"bodyKeys": [
|
||||
"id",
|
||||
"ipWhiteList"
|
||||
"ipWhiteList",
|
||||
"apiTrustedProxies"
|
||||
],
|
||||
"paramKeys": [],
|
||||
"beforeFunctions": [
|
||||
@@ -1590,8 +1692,8 @@
|
||||
"output_value": "name"
|
||||
}
|
||||
],
|
||||
"formatZH": "更新用户 [name] API 接口配置 =\u003e IP 白名单: [ipWhiteList]",
|
||||
"formatEN": "update user [name] api config =\u003e IP White List: [ipWhiteList]"
|
||||
"formatZH": "更新用户 [name] API 接口配置 =\u003e IP 白名单: [ipWhiteList], API 可信代理: [apiTrustedProxies]",
|
||||
"formatEN": "update user [name] api config =\u003e IP Allowlist: [ipWhiteList], API Trusted Proxies: [apiTrustedProxies]"
|
||||
},
|
||||
"/core/enterprise/users/del": {
|
||||
"bodyKeys": [
|
||||
@@ -2314,6 +2416,7 @@
|
||||
"/core/xpack/nodes/health/setting": {
|
||||
"bodyKeys": [
|
||||
"interval",
|
||||
"autoCheckFailedNodes",
|
||||
"loadFailedResources"
|
||||
],
|
||||
"paramKeys": [],
|
||||
@@ -4900,6 +5003,69 @@
|
||||
"formatZH": "上传 ssl 文件 [type]",
|
||||
"formatEN": "Upload ssl file [type]"
|
||||
},
|
||||
"/websites/templates": {
|
||||
"bodyKeys": [
|
||||
"name"
|
||||
],
|
||||
"paramKeys": [],
|
||||
"beforeFunctions": [],
|
||||
"formatZH": "创建网站模板 [name]",
|
||||
"formatEN": "Create website template [name]"
|
||||
},
|
||||
"/websites/templates/del": {
|
||||
"bodyKeys": [
|
||||
"id"
|
||||
],
|
||||
"paramKeys": [],
|
||||
"beforeFunctions": [
|
||||
{
|
||||
"input_column": "id",
|
||||
"input_value": "id",
|
||||
"isList": false,
|
||||
"db": "website_templates",
|
||||
"output_column": "name",
|
||||
"output_value": "name"
|
||||
}
|
||||
],
|
||||
"formatZH": "删除网站模板 [name]",
|
||||
"formatEN": "Delete website template [name]"
|
||||
},
|
||||
"/websites/templates/outputs": {
|
||||
"bodyKeys": [
|
||||
"name"
|
||||
],
|
||||
"paramKeys": [],
|
||||
"beforeFunctions": [],
|
||||
"formatZH": "生成模板产物 [name]",
|
||||
"formatEN": "Generate template output [name]"
|
||||
},
|
||||
"/websites/templates/outputs/del": {
|
||||
"bodyKeys": [
|
||||
"id"
|
||||
],
|
||||
"paramKeys": [],
|
||||
"beforeFunctions": [
|
||||
{
|
||||
"input_column": "id",
|
||||
"input_value": "id",
|
||||
"isList": false,
|
||||
"db": "website_template_outputs",
|
||||
"output_column": "name",
|
||||
"output_value": "name"
|
||||
}
|
||||
],
|
||||
"formatZH": "删除模板产物 [name]",
|
||||
"formatEN": "Delete template output [name]"
|
||||
},
|
||||
"/websites/templates/update": {
|
||||
"bodyKeys": [
|
||||
"name"
|
||||
],
|
||||
"paramKeys": [],
|
||||
"beforeFunctions": [],
|
||||
"formatZH": "更新网站模板 [name]",
|
||||
"formatEN": "Update website template [name]"
|
||||
},
|
||||
"/websites/update": {
|
||||
"bodyKeys": [
|
||||
"primaryDomain"
|
||||
|
||||
@@ -5,4 +5,9 @@ const (
|
||||
Supervisor = "supervisor"
|
||||
SupervisorConfigPath = "SupervisorConfigPath"
|
||||
SupervisorServiceName = "SupervisorServiceName"
|
||||
|
||||
WebsiteUID = 1000
|
||||
WebsiteGID = 1000
|
||||
|
||||
FTPUser = "1panel-ftp"
|
||||
)
|
||||
|
||||
+9
-9
@@ -8,11 +8,11 @@ replace github.com/go-acme/lego/v5 => github.com/1Panel-dev/lego/v5 v5.3.1
|
||||
|
||||
require (
|
||||
github.com/aliyun/aliyun-oss-go-sdk v3.0.2+incompatible
|
||||
github.com/compose-spec/compose-go/v2 v2.13.0
|
||||
github.com/compose-spec/compose-go/v2 v2.14.0
|
||||
github.com/creack/pty v1.1.24
|
||||
github.com/docker/cli v29.6.2+incompatible
|
||||
github.com/docker/cli v29.7.1+incompatible
|
||||
github.com/docker/docker v28.5.2+incompatible
|
||||
github.com/docker/go-connections v0.7.0
|
||||
github.com/docker/go-connections v0.8.1
|
||||
github.com/fsnotify/fsnotify v1.10.1
|
||||
github.com/gin-gonic/gin v1.12.0
|
||||
github.com/glebarez/sqlite v1.11.0
|
||||
@@ -39,16 +39,17 @@ require (
|
||||
github.com/patrickmn/go-cache v2.1.0+incompatible
|
||||
github.com/pkg/errors v0.9.1
|
||||
github.com/pkg/sftp v1.13.11
|
||||
github.com/qiniu/go-sdk/v7 v7.26.17
|
||||
github.com/qiniu/go-sdk/v7 v7.27.0
|
||||
github.com/robfig/cron/v3 v3.0.1
|
||||
github.com/shirou/gopsutil/v4 v4.26.6
|
||||
github.com/shirou/gopsutil/v4 v4.26.7
|
||||
github.com/sirupsen/logrus v1.9.4
|
||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
|
||||
github.com/spf13/afero v1.15.0
|
||||
github.com/spf13/cobra v1.10.2
|
||||
github.com/spf13/viper v1.21.0
|
||||
github.com/subosito/gotenv v1.6.0
|
||||
github.com/tencentyun/cos-go-sdk-v5 v0.7.74
|
||||
github.com/tencentyun/cos-go-sdk-v5 v0.7.75
|
||||
github.com/tklauser/go-sysconf v0.4.0
|
||||
github.com/tomasen/fcgi_client v0.0.0-20180423082037-2bb3d819fd19
|
||||
github.com/upyun/go-sdk v2.1.0+incompatible
|
||||
go.mongodb.org/mongo-driver/v2 v2.8.0
|
||||
@@ -112,7 +113,7 @@ require (
|
||||
github.com/docker/go-units v0.5.0 // indirect
|
||||
github.com/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/ebitengine/purego v0.10.0 // indirect
|
||||
github.com/ebitengine/purego v0.10.2 // indirect
|
||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.13 // indirect
|
||||
github.com/gin-contrib/sse v1.1.1 // indirect
|
||||
@@ -193,8 +194,7 @@ require (
|
||||
github.com/therootcompany/xz v1.0.1 // indirect
|
||||
github.com/tinylib/msgp v1.6.4 // indirect
|
||||
github.com/tjfoc/gmsm v1.4.1 // indirect
|
||||
github.com/tklauser/go-sysconf v0.3.16 // indirect
|
||||
github.com/tklauser/numcpus v0.11.0 // indirect
|
||||
github.com/tklauser/numcpus v0.12.0 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.3.1 // indirect
|
||||
github.com/ulikunitz/xz v0.5.15 // indirect
|
||||
|
||||
+18
-18
@@ -205,8 +205,8 @@ github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gE
|
||||
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
|
||||
github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk=
|
||||
github.com/cncf/xds/go v0.0.0-20210312221358-fbca930ec8ed/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
|
||||
github.com/compose-spec/compose-go/v2 v2.13.0 h1:2+2oS3v4SrtAOBdZRAZYBsBy47D571p5EXMSCppmTtE=
|
||||
github.com/compose-spec/compose-go/v2 v2.13.0/go.mod h1:ZU6zlcweCZKyiB7BVfCizQT9XmkEIMFE+PRZydVcsZg=
|
||||
github.com/compose-spec/compose-go/v2 v2.14.0 h1:uaJeo5B3+OVlu+Rx2qLBcAdXPEUUzm5nQrRiGJafRAQ=
|
||||
github.com/compose-spec/compose-go/v2 v2.14.0/go.mod h1:ZU6zlcweCZKyiB7BVfCizQT9XmkEIMFE+PRZydVcsZg=
|
||||
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
|
||||
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
|
||||
github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
|
||||
@@ -228,14 +228,14 @@ github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5Qvfr
|
||||
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
|
||||
github.com/dlclark/regexp2 v1.12.0 h1:0j4c5qQmnC6XOWNjP3PIXURXN2gWx76rd3KvgdPkCz8=
|
||||
github.com/dlclark/regexp2 v1.12.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||
github.com/docker/cli v29.6.2+incompatible h1:/bjePvcbbFTnRrMfWJBY7AjfICdsiLVgHn6LwTVOcqw=
|
||||
github.com/docker/cli v29.6.2+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
|
||||
github.com/docker/cli v29.7.1+incompatible h1:ILZpP6B7fedIr6ANy824QkDp1WMJuouIq0O2SrBkB2w=
|
||||
github.com/docker/cli v29.7.1+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
|
||||
github.com/docker/docker v28.5.2+incompatible h1:DBX0Y0zAjZbSrm1uzOkdr1onVghKaftjlSWt4AFexzM=
|
||||
github.com/docker/docker v28.5.2+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
|
||||
github.com/docker/docker-credential-helpers v0.9.5 h1:EFNN8DHvaiK8zVqFA2DT6BjXE0GzfLOZ38ggPTKePkY=
|
||||
github.com/docker/docker-credential-helpers v0.9.5/go.mod h1:v1S+hepowrQXITkEfw6o4+BMbGot02wiKpzWhGUZK6c=
|
||||
github.com/docker/go-connections v0.7.0 h1:6SsRfJddP22WMrCkj19x9WKjEDTB+ahsdiGYf0mN39c=
|
||||
github.com/docker/go-connections v0.7.0/go.mod h1:no1qkHdjq7kLMGUXYAduOhYPSJxxvgWBh7ogVvptn3Q=
|
||||
github.com/docker/go-connections v0.8.1 h1:JibmG5hULs5qXSr/cp/w3Pw5fZuStt4MOHMUExb29/M=
|
||||
github.com/docker/go-connections v0.8.1/go.mod h1:no1qkHdjq7kLMGUXYAduOhYPSJxxvgWBh7ogVvptn3Q=
|
||||
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
|
||||
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
|
||||
github.com/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707 h1:2tV76y6Q9BB+NEBasnqvs7e49aEBFI8ejC89PSnWH+4=
|
||||
@@ -248,8 +248,8 @@ github.com/eapache/go-resiliency v1.1.0/go.mod h1:kFI+JgMyC7bLPUVY133qvEBtVayf5m
|
||||
github.com/eapache/go-resiliency v1.2.0/go.mod h1:kFI+JgMyC7bLPUVY133qvEBtVayf5mFgVsvEsIPBvNs=
|
||||
github.com/eapache/go-xerial-snappy v0.0.0-20180814174437-776d5712da21/go.mod h1:+020luEh2TKB4/GOp8oxxtq0Daoen/Cii55CzbTV6DU=
|
||||
github.com/eapache/queue v1.1.0/go.mod h1:6eCeP0CKFpHLu8blIFXhExK/dRa7WDZfr6jVFPTqq+I=
|
||||
github.com/ebitengine/purego v0.10.0 h1:QIw4xfpWT6GWTzaW5XEKy3HXoqrJGx1ijYHzTF0/ISU=
|
||||
github.com/ebitengine/purego v0.10.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
|
||||
github.com/ebitengine/purego v0.10.2 h1:W809HbnvzAxgdm+aOvlSekrM16wGCdT/e76+9tS7gzE=
|
||||
github.com/ebitengine/purego v0.10.2/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
|
||||
github.com/edsrzf/mmap-go v1.0.0/go.mod h1:YO35OhQPt3KJa3ryjFM5Bs14WD66h8eGKpfaBNrHW5M=
|
||||
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
||||
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
||||
@@ -724,8 +724,8 @@ github.com/prometheus/procfs v0.0.8/go.mod h1:7Qr8sr6344vo1JqZ6HhLceV9o3AJ1Ff+Gx
|
||||
github.com/prometheus/procfs v0.1.3/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4OA4YeYWdaU=
|
||||
github.com/prometheus/procfs v0.6.0/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA=
|
||||
github.com/prometheus/procfs v0.7.3/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA=
|
||||
github.com/qiniu/go-sdk/v7 v7.26.17 h1:SMjjdKak2j1eedAHreMiS6sObDOLuUrvRqPtlV4KPQo=
|
||||
github.com/qiniu/go-sdk/v7 v7.26.17/go.mod h1:pTwVR1B+8SXcPLhDzBUasiKFTD9F7jRglRDR553BW3k=
|
||||
github.com/qiniu/go-sdk/v7 v7.27.0 h1:n+2U0S5fhbmG/lN/agO8KcYJaQDqROUVShtnp56Mkw8=
|
||||
github.com/qiniu/go-sdk/v7 v7.27.0/go.mod h1:pTwVR1B+8SXcPLhDzBUasiKFTD9F7jRglRDR553BW3k=
|
||||
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
|
||||
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
|
||||
github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
|
||||
@@ -751,8 +751,8 @@ github.com/sagikazarmark/locafero v0.12.0/go.mod h1:sZh36u/YSZ918v0Io+U9ogLYQJ9t
|
||||
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ=
|
||||
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU=
|
||||
github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc=
|
||||
github.com/shirou/gopsutil/v4 v4.26.6 h1:Mzr/npDtQC/xpeEuQKHZt8Zo9CmPvhTj8nkR8w5TLDs=
|
||||
github.com/shirou/gopsutil/v4 v4.26.6/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ=
|
||||
github.com/shirou/gopsutil/v4 v4.26.7 h1:IXzpHz/dkMRYAhKkOXr1HB6SuzWU3eoyyeWe7g3bNZc=
|
||||
github.com/shirou/gopsutil/v4 v4.26.7/go.mod h1:5O9FjBiXoTDFatIWjZZosqj4pV0DRtLx598xGbBehzM=
|
||||
github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
|
||||
github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
|
||||
github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
|
||||
@@ -813,8 +813,8 @@ github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.3.133/go.mod
|
||||
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/dnspod v1.3.131 h1:YfzhVkH30GipMEOGIx6nkaJv2E/mWN+G6Vvg+M7SqGg=
|
||||
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/dnspod v1.3.131/go.mod h1:Eejvn572HmciFhWFMg6a+eZvkegnEzJrPSnJYKYZX2s=
|
||||
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/kms v1.0.563/go.mod h1:uom4Nvi9W+Qkom0exYiJ9VWJjXwyxtPYTkKkaLMlfE0=
|
||||
github.com/tencentyun/cos-go-sdk-v5 v0.7.74 h1:YCsGhIK+Q5vMcmAiQqPuMyhSQ3zN1p7U+dhlHQD++6c=
|
||||
github.com/tencentyun/cos-go-sdk-v5 v0.7.74/go.mod h1:STbTNaNKq03u+gscPEGOahKzLcGSYOj6Dzc5zNay7Pg=
|
||||
github.com/tencentyun/cos-go-sdk-v5 v0.7.75 h1:eRCGP5chujSYGFnsCPxlhQcN9wtlSO/4eXrxKtLVAIw=
|
||||
github.com/tencentyun/cos-go-sdk-v5 v0.7.75/go.mod h1:STbTNaNKq03u+gscPEGOahKzLcGSYOj6Dzc5zNay7Pg=
|
||||
github.com/tencentyun/qcloud-cos-sts-sdk v0.0.0-20250515025012-e0eec8a5d123/go.mod h1:b18KQa4IxHbxeseW1GcZox53d7J0z39VNONTxvvlkXw=
|
||||
github.com/therootcompany/xz v1.0.1 h1:CmOtsn1CbtmyYiusbfmhmkpAAETj0wBIH6kCYaX+xzw=
|
||||
github.com/therootcompany/xz v1.0.1/go.mod h1:3K3UH1yCKgBneZYhuQUvJ9HPD19UEXEI0BWbMn8qNMY=
|
||||
@@ -823,10 +823,10 @@ github.com/tinylib/msgp v1.6.4/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77ro
|
||||
github.com/tjfoc/gmsm v1.3.2/go.mod h1:HaUcFuY0auTiaHB9MHFGCPx5IaLhTUd2atbCFBQXn9w=
|
||||
github.com/tjfoc/gmsm v1.4.1 h1:aMe1GlZb+0bLjn+cKTPEvvn9oUEBlJitaZiiBwsbgho=
|
||||
github.com/tjfoc/gmsm v1.4.1/go.mod h1:j4INPkHWMrhJb38G+J6W4Tw0AbuN8Thu3PbdVYhVcTE=
|
||||
github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA=
|
||||
github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI=
|
||||
github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw=
|
||||
github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ=
|
||||
github.com/tklauser/go-sysconf v0.4.0 h1:7H0uAN+7RkwWRaxhYXDLqa5V3LPrJeV8wmD9dRUgPQU=
|
||||
github.com/tklauser/go-sysconf v0.4.0/go.mod h1:8mTNWyog7H+MpKijp4VmKJAd2bbYQ2zuUwkYRbUArPI=
|
||||
github.com/tklauser/numcpus v0.12.0 h1:NR85qdvHA9pFse3x3weVZ0r0ST8R6l5RHbZrlRaqob4=
|
||||
github.com/tklauser/numcpus v0.12.0/go.mod h1:ABHeXzJnr/qqwguhClkZKT1/8VABcYrsyUiUGobwWJg=
|
||||
github.com/tomasen/fcgi_client v0.0.0-20180423082037-2bb3d819fd19 h1:ZCmSnT6CLGhfoQ2lPEhL4nsJstKDCw1F1RfN8/smTCU=
|
||||
github.com/tomasen/fcgi_client v0.0.0-20180423082037-2bb3d819fd19/go.mod h1:SXTY+QvI+KTTKXQdg0zZ7nx0u94QWh8ZAwBQYsW9cqk=
|
||||
github.com/tv42/httpunix v0.0.0-20150427012821-b75d8614f926/go.mod h1:9ESjWnEqriFuLhtthL60Sar/7RFoluCcXsuvEwTV5KM=
|
||||
|
||||
@@ -93,6 +93,9 @@ ErrAgentWebsiteBound: 'This agent is already bound to a website'
|
||||
ErrAgentWebsiteTypeUnsupported: 'Only proxy or static websites can be bound'
|
||||
ErrAgentWebsiteInUse: 'This website is already bound to another agent'
|
||||
ErrAgentWebsiteUnbindUnsupported: 'Deployment websites cannot be unbound manually'
|
||||
ErrQwenPawAuthRequest: 'Failed to update QwenPaw credentials: {{ .err }}'
|
||||
ErrQwenPawAuthOutOfSync: 'The current QwenPaw credentials do not match the 1Panel record. Reset the QwenPaw password or restore the credentials in the application .env file'
|
||||
ErrQwenPawAuthDisabled: 'Login authentication is not enabled in QwenPaw'
|
||||
ErrHermesPairingCodeUnavailable: 'The pairing code is temporarily unavailable in Hermes, possibly due to network issues. Please try again later.'
|
||||
ErrHermesFeishuGroupAllowlistRequiresAllowlist: 'When the Feishu group policy is Allowlist, the DM policy cannot be Pairing Code.'
|
||||
|
||||
@@ -132,6 +135,9 @@ ErrAppNameExist: 'The application name already exists'
|
||||
ErrAppInstallNameExist: 'App install name {{ .name }} already exists'
|
||||
AppStoreIsSyncing: 'App Store is syncing; try again later'
|
||||
ErrGetCompose: 'Failed to read docker-compose.yml: {{ .detail }}'
|
||||
ErrComposeProjectNameParse: 'Failed to parse the project name from Docker Compose config output: {{ .detail }}'
|
||||
ErrComposeProjectNameEmpty: 'Unable to derive a project name from Docker Compose config or the file parent directory. Enter a name and try again.'
|
||||
ErrComposeNameInvalid: 'Invalid Compose project name: it must start with a lowercase letter or number, contain only lowercase letters, numbers, hyphens, and underscores, and be 1-256 characters long.'
|
||||
ErrAppVersionUnavailable: 'This application version has been removed from the remote service. Please select another version and try again.'
|
||||
ErrAppWarn: 'App status abnormal; check logs'
|
||||
ErrAppParamKey: 'Invalid app parameter: {{ .name }}'
|
||||
@@ -154,6 +160,11 @@ ErrAppVersionDeprecated: "The {{ .name }} application is not compatible with the
|
||||
ErrDockerFailed: 'Docker is abnormal; check service status'
|
||||
ErrDockerComposeCmdNotFound: 'Docker Compose not found on host'
|
||||
UseExistImage: 'Image exists; using existing image'
|
||||
UpgradePrepare: 'Prepare application upgrade'
|
||||
UpgradeStop: 'Stop original application'
|
||||
UpgradeWaitReady: 'Wait for application readiness'
|
||||
UpgradeBackupDisabled: 'Upgrade backup is disabled; data changes made by upgrade scripts cannot be fully rolled back'
|
||||
UpgradeRollbackFailed: 'Rollback failed'
|
||||
ErrDatabaseNotFound: 'Database not found: {{ .name }}'
|
||||
|
||||
# SSH
|
||||
@@ -449,6 +460,11 @@ TaskCreate: 'Create'
|
||||
TaskDelete: 'Delete'
|
||||
TaskUpgrade: 'Upgrade'
|
||||
TaskUpdate: 'Update'
|
||||
AgentPluginInstall: 'Install plugin'
|
||||
AgentPluginEnable: 'Enable plugin'
|
||||
AgentPluginDisable: 'Disable plugin'
|
||||
AgentPluginUpdate: 'Update plugin'
|
||||
AgentPluginUninstall: 'Uninstall plugin'
|
||||
TaskRestart: 'Restart'
|
||||
TaskProtect: "Protect"
|
||||
TaskBackup: 'Backup'
|
||||
@@ -649,3 +665,7 @@ ErrAIProxyModelMapEmpty: 'Model mapping cannot be empty'
|
||||
ErrAIProxyModelMapEmptyExternalModel: 'Model mapping contains an empty request model'
|
||||
ErrAIProxyModelMapEmptyUpstreamModel: 'Upstream model for {{ .model }} cannot be empty'
|
||||
AIProxyUserFallback: 'Deleted user (ID: {{ .id }})'
|
||||
AIProviderBailian: 'Alibaba Cloud Model Studio'
|
||||
AIProviderBailianCodingPlan: 'Alibaba Cloud Model Studio Coding Plan'
|
||||
AIProviderArk: 'Volcengine Ark'
|
||||
AIProviderArkCodingPlan: 'Volcengine Ark Coding Plan'
|
||||
|
||||
@@ -93,6 +93,9 @@ ErrAgentWebsiteBound: 'Este agente ya está vinculado a un sitio web'
|
||||
ErrAgentWebsiteTypeUnsupported: 'Solo se pueden vincular sitios proxy o estáticos'
|
||||
ErrAgentWebsiteInUse: 'Este sitio web ya está vinculado a otro agente'
|
||||
ErrAgentWebsiteUnbindUnsupported: 'Los sitios web de despliegue no se pueden desvincular manualmente'
|
||||
ErrQwenPawAuthRequest: 'No se pudieron actualizar las credenciales de QwenPaw: {{ .err }}'
|
||||
ErrQwenPawAuthOutOfSync: 'Las credenciales actuales de QwenPaw no coinciden con el registro de 1Panel. Restablezca la contraseña de QwenPaw o restaure las credenciales en el archivo .env de la aplicación'
|
||||
ErrQwenPawAuthDisabled: 'La autenticación de inicio de sesión no está habilitada en QwenPaw'
|
||||
ErrHermesPairingCodeUnavailable: 'El código de emparejamiento no está disponible temporalmente en Hermes, posiblemente por un problema de red. Inténtalo de nuevo más tarde.'
|
||||
ErrHermesFeishuGroupAllowlistRequiresAllowlist: 'Cuando la política de grupo de Feishu es Lista permitida, la política de MD no puede ser Código de emparejamiento.'
|
||||
|
||||
@@ -132,6 +135,9 @@ ErrAppNameExist: 'El nombre de la aplicación ya existe'
|
||||
ErrAppInstallNameExist: 'El nombre de instalación de la aplicación {{ .name }} ya existe'
|
||||
AppStoreIsSyncing: 'La tienda de aplicaciones está sincronizando, inténtelo más tarde'
|
||||
ErrGetCompose: 'Fallo al obtener el archivo docker-compose.yml {{ .detail }}'
|
||||
ErrComposeProjectNameParse: 'No se pudo analizar el nombre del proyecto desde la salida de configuración de Docker Compose: {{ .detail }}'
|
||||
ErrComposeProjectNameEmpty: 'No se pudo derivar un nombre de proyecto desde la configuración de Docker Compose ni desde el directorio padre del archivo. Introduzca un nombre e inténtelo de nuevo.'
|
||||
ErrComposeNameInvalid: 'Nombre de proyecto Compose no válido: debe comenzar con una letra minúscula o un número, contener solo letras minúsculas, números, guiones y guiones bajos, y tener entre 1 y 256 caracteres.'
|
||||
ErrAppVersionUnavailable: 'Esta versión de la aplicación se ha eliminado del servicio remoto. Seleccione otra versión e inténtelo de nuevo.'
|
||||
ErrAppWarn: 'Estado anómalo, revise el log'
|
||||
ErrAppParamKey: 'El campo de parámetro {{ .name }} es anómalo'
|
||||
@@ -142,7 +148,7 @@ PullImageSuccess: 'Descarga de imagen exitosa'
|
||||
AppStoreSyncSuccess: 'Sincronización de la tienda de aplicaciones exitosa'
|
||||
SyncAppDetail: 'Sincronizar configuración de la aplicación'
|
||||
AppVersionNotMatch: 'La aplicación {{ .name }} requiere una versión superior de 1Panel, se omite la sincronización'
|
||||
MoveSiteDir: 'La actualización actual requiere migrar el directorio del sitio web OpenResty'
|
||||
MoveSiteDir: 'Se detectó un cambio en el directorio del sitio web. Es necesario migrar el directorio de OpenResty a {{ .name }}'
|
||||
MoveSiteDirSuccess: 'Migración del directorio del sitio exitosa'
|
||||
DeleteRuntimePHP: 'Eliminar entorno de ejecución PHP'
|
||||
CustomAppStoreFileValid: 'Los paquetes de la tienda de aplicaciones deben estar en formato.tar.gz'
|
||||
@@ -154,6 +160,11 @@ ErrAppVersionDeprecated: 'La aplicación {{ .name }} no es compatible con la ver
|
||||
ErrDockerFailed: 'El estado de Docker es anómalo, revise el servicio'
|
||||
ErrDockerComposeCmdNotFound: 'El comando Docker Compose no existe instálelo primero en el host'
|
||||
UseExistImage: 'La imagen ya existe, usando imagen existente'
|
||||
UpgradePrepare: 'Preparar actualización de la aplicación'
|
||||
UpgradeStop: 'Detener la aplicación original'
|
||||
UpgradeWaitReady: 'Esperar a que la aplicación esté lista'
|
||||
UpgradeBackupDisabled: 'La copia de actualización está desactivada; los cambios de datos de los scripts no se pueden revertir por completo'
|
||||
UpgradeRollbackFailed: 'Error al revertir'
|
||||
ErrDatabaseNotFound: 'La base de datos {{ .name }} no existe'
|
||||
|
||||
# SSH
|
||||
@@ -187,7 +198,7 @@ ErrSourcePathNotFound: 'El directorio fuente no existe'
|
||||
ErrFavoriteExist: 'Esta ruta ya ha sido marcada como favorita'
|
||||
ErrInvalidChar: 'No se permiten caracteres ilegales'
|
||||
ErrPathNotDelete: 'No se puede eliminar el directorio seleccionado'
|
||||
ErrLogFileToLarge: 'Log file exceeds 500 MB'
|
||||
ErrLogFileToLarge: 'El archivo de registro supera los 500 MB y no se puede abrir'
|
||||
FileDropFailed: 'Error al limpiar el archivo {{ .name }}: {{ .err }}'
|
||||
FileDropSuccess: 'Archivo {{ .name }} limpiado correctamente, {{ .count }} archivos eliminados, {{ .size }} de espacio liberado'
|
||||
FileDropSum: 'Limpieza de archivos completada, total {{ .count }} archivos eliminados, {{ .size }} de espacio liberado'
|
||||
@@ -237,7 +248,7 @@ ErrEabKidOrEabHmacKeyCannotBlank: 'EabKid o EabHmacKey no pueden estar en blanco
|
||||
ErrOpenrestyNotFound: 'El modo HTTP requiere OpenResty'
|
||||
ApplySSLStart: 'Iniciando solicitud de certificado, dominio [{{ .domain }}] método de solicitud [{{ .type }}]'
|
||||
dnsAccount: 'DNS Automático'
|
||||
dnsManual: 'DNS Manual'
|
||||
dnsManual: 'DNS manual'
|
||||
http: 'HTTP'
|
||||
ApplySSLFailed: 'Solicitud de certificado para [{{ .domain }}] fallida, {{ .detail }}'
|
||||
ApplySSLSuccess: 'Solicitud de certificado para [{{ .domain }}] exitosa'
|
||||
@@ -341,7 +352,7 @@ ErrUserFindErr: 'Búsqueda del usuario {{ .name }} fallida {{ .err }}'
|
||||
# tarea programada
|
||||
CutWebsiteLogSuccess: 'Log del sitio web {{ .name }} cortado con éxito, ruta de respaldo {{ .path }}'
|
||||
HandleShell: 'Ejecutar script {{ .name }}'
|
||||
HandleCurl: 'URL {{ .name }} にアクセス'
|
||||
HandleCurl: 'Acceder a la URL {{ .name }}'
|
||||
HandleNtpSync: 'Sincronizar hora del sistema'
|
||||
HandleSystemClean: 'Limpiar caché del sistema'
|
||||
SystemLog: 'Log del sistema'
|
||||
@@ -449,13 +460,18 @@ TaskCreate: 'Crear'
|
||||
TaskDelete: 'Eliminar'
|
||||
TaskUpgrade: 'Actualizar'
|
||||
TaskUpdate: 'Actualizar'
|
||||
AgentPluginInstall: 'Instalar complemento'
|
||||
AgentPluginEnable: 'Activar complemento'
|
||||
AgentPluginDisable: 'Desactivar complemento'
|
||||
AgentPluginUpdate: 'Actualizar complemento'
|
||||
AgentPluginUninstall: 'Desinstalar complemento'
|
||||
TaskRestart: 'Reiniciar'
|
||||
TaskProtect: 'Proteger'
|
||||
TaskBackup: 'Respaldar'
|
||||
TaskRecover: 'Recuperar'
|
||||
TaskRollback: 'Revertir'
|
||||
TaskPull: 'Descargar'
|
||||
TaskCommit: 'Commit'
|
||||
TaskCommit: 'Crear imagen'
|
||||
TaskBuild: 'Construir'
|
||||
TaskPush: 'Enviar'
|
||||
TaskClean: 'Limpieza'
|
||||
@@ -649,3 +665,7 @@ ErrAIProxyModelMapEmpty: 'El mapeo de modelos no puede estar vacío'
|
||||
ErrAIProxyModelMapEmptyExternalModel: 'El mapeo de modelos contiene un modelo solicitado vacío'
|
||||
ErrAIProxyModelMapEmptyUpstreamModel: 'El modelo upstream de {{ .model }} no puede estar vacío'
|
||||
AIProxyUserFallback: 'Usuario eliminado (ID: {{ .id }})'
|
||||
AIProviderBailian: 'Alibaba Cloud Model Studio'
|
||||
AIProviderBailianCodingPlan: 'Alibaba Cloud Model Studio Coding Plan'
|
||||
AIProviderArk: 'Volcengine Ark'
|
||||
AIProviderArkCodingPlan: 'Volcengine Ark Coding Plan'
|
||||
|
||||
+21
-1
@@ -93,6 +93,9 @@ ErrAgentWebsiteBound: 'این عامل قبلاً به یک وبسایت مت
|
||||
ErrAgentWebsiteTypeUnsupported: 'فقط وبسایتهای پراکسی یا استاتیک قابل اتصال هستند'
|
||||
ErrAgentWebsiteInUse: 'این وبسایت قبلاً به عامل دیگری متصل است'
|
||||
ErrAgentWebsiteUnbindUnsupported: 'وبسایتهای استقرار یافته را نمیتوان به صورت دستی قطع اتصال کرد'
|
||||
ErrQwenPawAuthRequest: 'بهروزرسانی اطلاعات ورود QwenPaw ناموفق بود: {{ .err }}'
|
||||
ErrQwenPawAuthOutOfSync: 'اطلاعات ورود فعلی QwenPaw با رکورد 1Panel مطابقت ندارد. گذرواژه QwenPaw را بازنشانی کنید یا اطلاعات ورود فایل .env برنامه را بازیابی کنید'
|
||||
ErrQwenPawAuthDisabled: 'احراز هویت ورود در QwenPaw فعال نشده است'
|
||||
ErrHermesPairingCodeUnavailable: 'کد جفتسازی به طور موقت در هرمس در دسترس نیست، احتمالاً به دلیل مشکلات شبکه. لطفاً بعداً دوباره تلاش کنید.'
|
||||
ErrHermesFeishuGroupAllowlistRequiresAllowlist: 'وقتی خط مشی گروه فیشو لیست سفید باشد، خط مشی DM نمیتواند کد جفتسازی باشد.'
|
||||
|
||||
@@ -132,6 +135,9 @@ ErrAppNameExist: 'نام برنامه از قبل وجود دارد'
|
||||
ErrAppInstallNameExist: 'نام نصب برنامه {{ .name }} از قبل وجود دارد'
|
||||
AppStoreIsSyncing: 'فروشگاه برنامه در حال همگامسازی است؛ بعداً دوباره تلاش کنید'
|
||||
ErrGetCompose: 'خواندن docker-compose.yml ناموفق بود: {{ .detail }}'
|
||||
ErrComposeProjectNameParse: 'تجزیه نام پروژه از خروجی پیکربندی Docker Compose ناموفق بود: {{ .detail }}'
|
||||
ErrComposeProjectNameEmpty: 'نام پروژه از پیکربندی Docker Compose یا پوشه والد فایل قابل استخراج نیست. یک نام وارد کرده و دوباره تلاش کنید.'
|
||||
ErrComposeNameInvalid: 'نام پروژه Compose نامعتبر است: باید با حرف کوچک یا عدد شروع شود، فقط شامل حروف کوچک، اعداد، خط تیره و زیرخط باشد و ۱ تا ۲۵۶ نویسه داشته باشد.'
|
||||
ErrAppVersionUnavailable: 'این نسخه برنامه از سرویس راه دور حذف شده است. لطفاً نسخه دیگری را انتخاب کرده و دوباره تلاش کنید.'
|
||||
ErrAppWarn: 'وضعیت برنامه غیرعادی است؛ لاگها را بررسی کنید'
|
||||
ErrAppParamKey: 'پارامتر برنامه نامعتبر است: {{ .name }}'
|
||||
@@ -154,6 +160,11 @@ ErrAppVersionDeprecated: "برنامه {{ .name }} با نسخه فعلی 1Panel
|
||||
ErrDockerFailed: 'Docker غیرعادی است؛ وضعیت سرویس را بررسی کنید'
|
||||
ErrDockerComposeCmdNotFound: 'Docker Compose روی میزبان یافت نشد'
|
||||
UseExistImage: 'تصویر وجود دارد؛ استفاده از تصویر موجود'
|
||||
UpgradePrepare: 'آمادهسازی ارتقاء برنامه'
|
||||
UpgradeStop: 'توقف برنامه اصلی'
|
||||
UpgradeWaitReady: 'انتظار برای آماده شدن برنامه'
|
||||
UpgradeBackupDisabled: 'پشتیبان ارتقاء غیرفعال است؛ تغییرات داده اسکریپتهای ارتقاء کاملاً قابل بازگشت نیست'
|
||||
UpgradeRollbackFailed: 'بازگشت ناموفق بود'
|
||||
ErrDatabaseNotFound: 'پایگاه داده یافت نشد: {{ .name }}'
|
||||
|
||||
# SSH
|
||||
@@ -431,7 +442,7 @@ allow: 'مجاز'
|
||||
deny: 'ممنوع'
|
||||
OpenrestyNotFound: 'OpenResty نصب نشده است'
|
||||
remoteIpIsNull: "لیست IP خالی است"
|
||||
OpenrestyVersionErr: 'نسخه OpenResty بسیار پایین است؛ به ۱.۲۷.۱.۲-۲-۲-focal ارتقاء دهید'
|
||||
OpenrestyVersionErr: 'نسخه OpenResty بسیار پایین است؛ به 1.27.1.2-2-2-focal ارتقاء دهید'
|
||||
ErrFileTooLarge: 'فایل بزرگتر از ۱ مگابایت است و قابل بارگذاری نیست'
|
||||
|
||||
# وظیفه
|
||||
@@ -449,6 +460,11 @@ TaskCreate: 'ایجاد'
|
||||
TaskDelete: 'حذف'
|
||||
TaskUpgrade: 'ارتقاء'
|
||||
TaskUpdate: 'بهروزرسانی'
|
||||
AgentPluginInstall: 'نصب افزونه'
|
||||
AgentPluginEnable: 'فعالکردن افزونه'
|
||||
AgentPluginDisable: 'غیرفعالکردن افزونه'
|
||||
AgentPluginUpdate: 'بهروزرسانی افزونه'
|
||||
AgentPluginUninstall: 'حذف افزونه'
|
||||
TaskRestart: 'راهاندازی مجدد'
|
||||
TaskProtect: "محافظت"
|
||||
TaskBackup: 'پشتیبانگیری'
|
||||
@@ -649,3 +665,7 @@ ErrAIProxyModelMapEmpty: 'نگاشت مدل نمیتواند خالی باش
|
||||
ErrAIProxyModelMapEmptyExternalModel: 'نگاشت مدل شامل یک مدل درخواست خالی است'
|
||||
ErrAIProxyModelMapEmptyUpstreamModel: 'مدل بالادست برای {{ .model }} نمیتواند خالی باشد'
|
||||
AIProxyUserFallback: 'کاربر حذف شده (شناسه: {{ .id }})'
|
||||
AIProviderBailian: 'Alibaba Cloud Model Studio'
|
||||
AIProviderBailianCodingPlan: 'Alibaba Cloud Model Studio Coding Plan'
|
||||
AIProviderArk: 'Volcengine Ark'
|
||||
AIProviderArkCodingPlan: 'Volcengine Ark Coding Plan'
|
||||
|
||||
@@ -93,6 +93,9 @@ ErrAgentWebsiteBound: 'このエージェントはすでにサイトに関連付
|
||||
ErrAgentWebsiteTypeUnsupported: '関連付けできるのはプロキシサイトまたは静的サイトのみです'
|
||||
ErrAgentWebsiteInUse: 'このサイトはすでに別のエージェントに関連付けられています'
|
||||
ErrAgentWebsiteUnbindUnsupported: 'ワンクリックデプロイのサイトは手動で関連解除できません'
|
||||
ErrQwenPawAuthRequest: 'QwenPaw 認証情報の更新に失敗しました: {{ .err }}'
|
||||
ErrQwenPawAuthOutOfSync: '現在の QwenPaw 認証情報が 1Panel の記録と一致しません。QwenPaw のパスワードをリセットするか、アプリケーションの .env にある認証情報を復元してください'
|
||||
ErrQwenPawAuthDisabled: 'QwenPaw でログイン認証が有効になっていません'
|
||||
ErrHermesPairingCodeUnavailable: 'Hermes でペアリングコードが一時的に見つかりません。ネットワーク要因の可能性があるため、しばらくしてから再試行してください。'
|
||||
ErrHermesFeishuGroupAllowlistRequiresAllowlist: 'Feishu のグループポリシーが許可リストの場合、DM ポリシーをペアリングコードにはできません。'
|
||||
|
||||
@@ -132,6 +135,9 @@ ErrAppNameExist: 'アプリケーション名がすでに存在します'
|
||||
ErrAppInstallNameExist: 'アプリケーションインストール名 {{ .name }} はすでに存在します'
|
||||
AppStoreIsSyncing: 'App Store が同期中です。しばらくしてからもう一度お試しください'
|
||||
ErrGetCompose: 'docker-compose.yml ファイルの取得に失敗しました {{ .detail }}'
|
||||
ErrComposeProjectNameParse: 'Docker Compose 設定出力からプロジェクト名を解析できませんでした:{{ .detail }}'
|
||||
ErrComposeProjectNameEmpty: 'Docker Compose 設定またはファイルの親ディレクトリからプロジェクト名を推定できません。名前を入力して再試行してください。'
|
||||
ErrComposeNameInvalid: 'Compose プロジェクト名の形式が正しくありません。小文字または数字で始まり、小文字、数字、ハイフン、アンダースコアのみを使用した 1~256 文字にしてください。'
|
||||
ErrAppVersionUnavailable: '現在のアプリケーションバージョンはリモートサービスから削除されています。別のバージョンを選択して再試行してください。'
|
||||
ErrAppWarn: '異常な状態です。ログを確認してください'
|
||||
ErrAppParamKey: 'パラメータ {{ .name }} フィールドが異常です'
|
||||
@@ -154,6 +160,11 @@ ErrAppVersionDeprecated: '{{ .name }} アプリケーションは現在の 1Pane
|
||||
ErrDockerFailed: 'Docker の状態が異常です。サービス状態を確認してください'
|
||||
ErrDockerComposeCmdNotFound: 'Docker Compose コマンドは存在しません。ホストマシンにこのコマンドを先にインストールしてください'
|
||||
UseExistImage: 'イメージは既に存在します。既存のイメージを使用します'
|
||||
UpgradePrepare: 'アプリケーションのアップグレードを準備'
|
||||
UpgradeStop: '元のアプリケーションを停止'
|
||||
UpgradeWaitReady: 'アプリケーションの準備完了を待機'
|
||||
UpgradeBackupDisabled: 'アップグレードバックアップが無効なため、アップグレードスクリプトによるデータ変更を完全にはロールバックできません'
|
||||
UpgradeRollbackFailed: 'ロールバックに失敗しました'
|
||||
ErrDatabaseNotFound: 'データベース {{ .name }} は存在しません'
|
||||
|
||||
# SSH
|
||||
@@ -449,6 +460,11 @@ TaskCreate: '作成'
|
||||
TaskDelete: '削除'
|
||||
TaskUpgrade: 'アップグレード'
|
||||
TaskUpdate: '更新'
|
||||
AgentPluginInstall: 'プラグインをインストール'
|
||||
AgentPluginEnable: 'プラグインを有効化'
|
||||
AgentPluginDisable: 'プラグインを無効化'
|
||||
AgentPluginUpdate: 'プラグインを更新'
|
||||
AgentPluginUninstall: 'プラグインをアンインストール'
|
||||
TaskRestart: '再起動'
|
||||
TaskProtect: '保護'
|
||||
TaskBackup: 'バックアップ'
|
||||
@@ -649,3 +665,7 @@ ErrAIProxyModelMapEmpty: 'モデルマッピングは空にできません'
|
||||
ErrAIProxyModelMapEmptyExternalModel: 'モデルマッピングに空のリクエストモデルがあります'
|
||||
ErrAIProxyModelMapEmptyUpstreamModel: '{{ .model }} の上流モデルは空にできません'
|
||||
AIProxyUserFallback: '削除済みユーザー (ID: {{ .id }})'
|
||||
AIProviderBailian: 'Alibaba Cloud Model Studio'
|
||||
AIProviderBailianCodingPlan: 'Alibaba Cloud Model Studio Coding Plan'
|
||||
AIProviderArk: 'Volcengine Ark'
|
||||
AIProviderArkCodingPlan: 'Volcengine Ark Coding Plan'
|
||||
|
||||
+21
-1
@@ -93,6 +93,9 @@ ErrAgentWebsiteBound: '이 에이전트는 이미 웹사이트에 연결되어
|
||||
ErrAgentWebsiteTypeUnsupported: '프록시 또는 정적 웹사이트만 연결할 수 있습니다'
|
||||
ErrAgentWebsiteInUse: '이 웹사이트는 이미 다른 에이전트에 연결되어 있습니다'
|
||||
ErrAgentWebsiteUnbindUnsupported: '원클릭 배포 웹사이트는 수동으로 연결 해제할 수 없습니다'
|
||||
ErrQwenPawAuthRequest: 'QwenPaw 인증 정보 업데이트에 실패했습니다: {{ .err }}'
|
||||
ErrQwenPawAuthOutOfSync: '현재 QwenPaw 자격 증명이 1Panel 기록과 일치하지 않습니다. QwenPaw 비밀번호를 재설정하거나 애플리케이션 .env의 자격 증명을 복원하세요'
|
||||
ErrQwenPawAuthDisabled: 'QwenPaw에서 로그인 인증이 활성화되어 있지 않습니다'
|
||||
ErrHermesPairingCodeUnavailable: 'Hermes에서 페어링 코드가 일시적으로 존재하지 않습니다. 네트워크 문제일 수 있으니 잠시 후 다시 시도해 주세요.'
|
||||
ErrHermesFeishuGroupAllowlistRequiresAllowlist: 'Feishu 그룹 정책이 허용 목록이면 DM 정책을 페어링 코드로 설정할 수 없습니다.'
|
||||
|
||||
@@ -113,7 +116,7 @@ ErrPortInOtherApp: '{{ .port }} 포트는 이미 {{ .apps }} 애플리케이션
|
||||
ErrDbUserNotValid: '기존 데이터베이스, 사용자 이름 및 비밀번호가 일치하지 않습니다'
|
||||
ErrMysqlUserUsedByApps: '이 데이터베이스 사용자는 [{{ .detail }}]에서 사용 중이므로 호스트 변경, 삭제 또는 권한 해제를 할 수 없습니다'
|
||||
ErrUpdateBuWebsite: '응용 프로그램이 성공적으로 업데이트되었지만, 웹사이트 구성 파일 수정에 실패했습니다. 구성을 확인하세요'
|
||||
Err1PanelNetworkFailed: '기본 컨테이너 네트워크 생성에 실패했습니다 {{ .세부 사항 }}'
|
||||
Err1PanelNetworkFailed: '기본 컨테이너 네트워크 생성에 실패했습니다 {{ .detail }}'
|
||||
ErrFileParse: '응용 프로그램 docker-compose 파일 구문 분석에 실패했습니다'
|
||||
ErrInstallDirNotFound: '설치 디렉토리가 존재하지 않습니다. 제거를 원하실 경우 강제 제거 를 선택해주세요.'
|
||||
AppStoreIsUpToDate: '앱 스토어가 이미 최신 버전입니다'
|
||||
@@ -132,6 +135,9 @@ ErrAppNameExist: '응용 프로그램 이름이 이미 존재합니다'
|
||||
ErrAppInstallNameExist: '앱 설치 이름 {{ .name }} 이(가) 이미 존재합니다'
|
||||
AppStoreIsSyncing: 'App Store가 동기화 중입니다. 나중에 다시 시도하세요.'
|
||||
ErrGetCompose: 'docker-compose.yml 파일을 가져오지 못했습니다 {{ .detail }}'
|
||||
ErrComposeProjectNameParse: 'Docker Compose 구성 출력에서 프로젝트 이름을 구문 분석하지 못했습니다: {{ .detail }}'
|
||||
ErrComposeProjectNameEmpty: 'Docker Compose 구성 또는 파일의 상위 디렉터리에서 프로젝트 이름을 확인할 수 없습니다. 이름을 입력한 후 다시 시도하세요.'
|
||||
ErrComposeNameInvalid: 'Compose 프로젝트 이름 형식이 잘못되었습니다. 소문자 또는 숫자로 시작하고 소문자, 숫자, 하이픈, 밑줄만 사용하여 1~256자로 입력하세요.'
|
||||
ErrAppVersionUnavailable: '현재 애플리케이션 버전이 원격 서비스에서 제거되었습니다. 다른 버전을 선택한 후 다시 시도해 주세요.'
|
||||
ErrAppWarn: '비정상적인 상태입니다. 로그를 확인해 주세요.'
|
||||
ErrAppParamKey: '매개변수 {{ .name }} 필드가 비정상입니다.'
|
||||
@@ -154,6 +160,11 @@ ErrAppVersionDeprecated: '{{ .name }} 응용 프로그램은 현재 1Panel 버
|
||||
ErrDockerFailed: 'Docker의 상태가 비정상입니다. 서비스 상태를 확인하세요'
|
||||
ErrDockerComposeCmdNotFound: 'Docker Compose 명령이 없습니다. 호스트 머신에 먼저 이 명령을 설치하세요'
|
||||
UseExistImage: '이미지가 이미 존재하여 기존 이미지를 사용합니다'
|
||||
UpgradePrepare: '애플리케이션 업그레이드 준비'
|
||||
UpgradeStop: '기존 애플리케이션 중지'
|
||||
UpgradeWaitReady: '애플리케이션 준비 상태 대기'
|
||||
UpgradeBackupDisabled: '업그레이드 백업이 비활성화되어 업그레이드 스크립트의 데이터 변경을 완전히 롤백할 수 없습니다'
|
||||
UpgradeRollbackFailed: '롤백 실패'
|
||||
ErrDatabaseNotFound: '데이터베이스 {{ .name }} 이(가) 존재하지 않습니다'
|
||||
|
||||
# SSH
|
||||
@@ -449,6 +460,11 @@ TaskCreate: '생성'
|
||||
TaskDelete: '삭제'
|
||||
TaskUpgrade: '업그레이드'
|
||||
TaskUpdate: '업데이트'
|
||||
AgentPluginInstall: '플러그인 설치'
|
||||
AgentPluginEnable: '플러그인 활성화'
|
||||
AgentPluginDisable: '플러그인 비활성화'
|
||||
AgentPluginUpdate: '플러그인 업데이트'
|
||||
AgentPluginUninstall: '플러그인 제거'
|
||||
TaskRestart: '다시 시작'
|
||||
TaskProtect: '보호'
|
||||
TaskBackup: '백업'
|
||||
@@ -649,3 +665,7 @@ ErrAIProxyModelMapEmpty: '모델 매핑은 비워 둘 수 없습니다'
|
||||
ErrAIProxyModelMapEmptyExternalModel: '모델 매핑에 빈 요청 모델이 있습니다'
|
||||
ErrAIProxyModelMapEmptyUpstreamModel: '{{ .model }}의 업스트림 모델은 비워 둘 수 없습니다'
|
||||
AIProxyUserFallback: '삭제된 사용자 (ID: {{ .id }})'
|
||||
AIProviderBailian: 'Alibaba Cloud Model Studio'
|
||||
AIProviderBailianCodingPlan: 'Alibaba Cloud Model Studio Coding Plan'
|
||||
AIProviderArk: 'Volcengine Ark'
|
||||
AIProviderArkCodingPlan: 'Volcengine Ark Coding Plan'
|
||||
|
||||
+25
-5
@@ -78,6 +78,9 @@ ErrAgentWebsiteBound: 'ຕົວແທນນີ້ຖືກຜູກມັດກ
|
||||
ErrAgentWebsiteTypeUnsupported: 'ສາມາດຜູກມັດໄດ້ສະເພາະເວັບໄຊປະເພດ Proxy ຫຼື Static ເທົ່ານັ້ນ'
|
||||
ErrAgentWebsiteInUse: 'ເວັບໄຊນີ້ຖືກຜູກມັດກັບຕົວແທນອື່ນແລ້ວ'
|
||||
ErrAgentWebsiteUnbindUnsupported: 'ເວັບໄຊທີ່ຕິດຕັ້ງແລ້ວບໍ່ສາມາດຍົກເລີກການຜູກມັດດ້ວຍຕົນເອງໄດ້'
|
||||
ErrQwenPawAuthRequest: 'ອັບເດດຂໍ້ມູນຢືນຢັນ QwenPaw ບໍ່ສຳເລັດ: {{ .err }}'
|
||||
ErrQwenPawAuthOutOfSync: 'ຂໍ້ມູນຢືນຢັນ QwenPaw ປັດຈຸບັນບໍ່ກົງກັບບັນທຶກຂອງ 1Panel. ກະລຸນາຣີເຊັດລະຫັດຜ່ານ QwenPaw ຫຼືກູ້ຄືນຂໍ້ມູນໃນໄຟລ໌ .env ຂອງແອັບ'
|
||||
ErrQwenPawAuthDisabled: 'QwenPaw ຍັງບໍ່ໄດ້ເປີດໃຊ້ການຢືນຢັນການເຂົ້າລະບົບ'
|
||||
ErrHermesPairingCodeUnavailable: 'ລະຫັດການຈັບຄູ່ໃນ Hermes ບໍ່ສາມາດໃຊ້ງານໄດ້ຊົ່ວຄາວ, ອາດຈະເກີດຈາກບັນຫາເຄືອຂ່າຍ. ກະລຸນາລອງໃໝ່ພາຍຫຼັງ.'
|
||||
ErrHermesFeishuGroupAllowlistRequiresAllowlist: 'ເມື່ອນະໂຍບາຍກຸ່ມ Feishu ແມ່ນ Allowlist, ນະໂຍບາຍ DM ບໍ່ສາມາດເປັນ Pairing Code ໄດ້.'
|
||||
|
||||
@@ -122,6 +125,9 @@ ErrAppNameExist: 'ຊື່ແອັບພລິເຄຊັນມີຢູ່
|
||||
ErrAppInstallNameExist: 'ຊື່ການຕິດຕັ້ງແອັບ {{ .name }} ມີຢູ່ໃນລະບົບແລ້ວ'
|
||||
AppStoreIsSyncing: 'App Store ກຳລັງຊິງຄ໌ຂໍ້ມູນ; ກະລຸນາລອງໃໝ່ພາຍຫຼັງ'
|
||||
ErrGetCompose: 'ອ່ານໄຟລ໌ docker-compose.yml ລົ້ມເຫຼວ: {{ .detail }}'
|
||||
ErrComposeProjectNameParse: 'ບໍ່ສາມາດວິເຄາະຊື່ໂຄງການຈາກຜົນລັບການກຳນົດຄ່າ Docker Compose: {{ .detail }}'
|
||||
ErrComposeProjectNameEmpty: 'ບໍ່ສາມາດກຳນົດຊື່ໂຄງການຈາກການຕັ້ງຄ່າ Docker Compose ຫຼື ໂຟນເດີແມ່ຂອງໄຟລ໌ໄດ້. ກະລຸນາປ້ອນຊື່ແລ້ວລອງໃໝ່.'
|
||||
ErrComposeNameInvalid: 'ຊື່ໂຄງການ Compose ບໍ່ຖືກຕ້ອງ: ຕ້ອງເລີ່ມດ້ວຍຕົວພິມນ້ອຍ ຫຼື ຕົວເລກ, ໃຊ້ໄດ້ສະເພາະຕົວພິມນ້ອຍ, ຕົວເລກ, ຂີດກາງ ແລະ ຂີດລຸ່ມ, ຄວາມຍາວ 1-256 ຕົວອັກສອນ.'
|
||||
ErrAppVersionUnavailable: 'ເວີຊັນແອັບນີ້ຖືກລຶບອອກຈາກບໍລິການທາງໄກແລ້ວ ກະລຸນາເລືອກເວີຊັນອື່ນແລ້ວລອງໃໝ່'
|
||||
ErrAppWarn: 'ສະຖານະແອັບຜິດປົກກະຕິ; ກະລຸນາກວດສອບບັນທຶກ'
|
||||
ErrAppParamKey: 'ພາຣາມິເຕີຂອງແອັບບໍ່ຖືກຕ້ອງ: {{ .name }}'
|
||||
@@ -144,6 +150,11 @@ ErrAppVersionDeprecated: "ແອັບພລິເຄຊັນ {{ .name }} ບ
|
||||
ErrDockerFailed: 'Docker ຜິດປົກກະຕິ; ກະລຸນາກວດສອບສະຖານະການບໍລິການ'
|
||||
ErrDockerComposeCmdNotFound: 'ບໍ່ພົບຄຳສັ່ງ Docker Compose ໃນເຄື່ອງ'
|
||||
UseExistImage: 'ມີຮູບພາບຢູ່ແລ້ວ; ກຳລັງໃຊ້ຮູບພາບທີ່ມີຢູ່'
|
||||
UpgradePrepare: 'ກະກຽມອັບເກຣດແອັບ'
|
||||
UpgradeStop: 'ຢຸດແອັບເດີມ'
|
||||
UpgradeWaitReady: 'ລໍຖ້າແອັບພ້ອມ'
|
||||
UpgradeBackupDisabled: 'ປິດການສຳຮອງກ່ອນອັບເກຣດ; ການປ່ຽນແປງຂໍ້ມູນຈາກສະຄຣິບບໍ່ສາມາດຍ້ອນກັບໄດ້ທັງໝົດ'
|
||||
UpgradeRollbackFailed: 'ຍ້ອນກັບລົ້ມເຫຼວ'
|
||||
ErrDatabaseNotFound: 'ບໍ່ພົບຖານຂໍ້ມູນ: {{ .name }}'
|
||||
|
||||
#ssh
|
||||
@@ -271,7 +282,7 @@ ErrPortRules: 'ຈຳນວນພອດບໍ່ກົງກັນ'
|
||||
ErrPgImagePull: 'ດຶງຮູບພາບເກີນກຳນົດເວລາ; ກະລຸນາກຳນົດຄ່າການເລັ່ງ (Acceleration) ຫຼື ດຶງ {{ .name }} ດ້ວຍຕົນເອງ'
|
||||
PruneHelper: "ທຳຄວາມສະອາດ {{ .name }} ຄືນພື້ນທີ່ດິສກ໌ {{ .size }}"
|
||||
ImageRemoveHelper: "ລຶບຮູບພາບ {{ .name }} ແລ້ວ, ຄືນພື້ນທີ່ດິສກ໌ {{ .size }}"
|
||||
BuildCache: 'Build Cache'
|
||||
BuildCache: 'ແຄດການສ້າງ'
|
||||
Volume: 'ວໍລຸມ (Volume)'
|
||||
Network: "ເຄືອຂ່າຍ"
|
||||
PruneStart: 'ກຳລັງດຳເນີນການທຳຄວາມສະອາດ'
|
||||
@@ -412,10 +423,10 @@ captcha: 'ການຢືນຢັນຕົວຕົນ (CAPTCHA)'
|
||||
fiveSeconds: 'ການຢືນຢັນ 5 ວິນາທີ'
|
||||
vulnCheck: 'ກົດລະບຽບເພີ່ມເຕີມ'
|
||||
acl: 'ກົດລະບຽບປັບແຕ່ງເອງ'
|
||||
sql: 'SQL injection'
|
||||
sql: 'ການໂຈມຕີແບບສອດແຊກ SQL'
|
||||
cc: 'ຂີດຈຳກັດຄວາມຖີ່ຂອງການເຂົ້າເຖິງ'
|
||||
defaultUrlBlack: 'ກົດລະບຽບ URL'
|
||||
sqlInject: 'SQL injection'
|
||||
sqlInject: 'ການໂຈມຕີແບບສອດແຊກ SQL'
|
||||
ErrDBNotExist: 'ຖານຂໍ້ມູນບໍ່ມີຢູ່'
|
||||
allow: 'ອະນຸຍາດ'
|
||||
deny: 'ປະຕິເສດ'
|
||||
@@ -439,6 +450,11 @@ TaskCreate: 'ສ້າງ'
|
||||
TaskDelete: 'ລຶບ'
|
||||
TaskUpgrade: 'ອັບເກຣດ'
|
||||
TaskUpdate: 'ອັບເດດ'
|
||||
AgentPluginInstall: 'ຕິດຕັ້ງປລັກອິນ'
|
||||
AgentPluginEnable: 'ເປີດໃຊ້ປລັກອິນ'
|
||||
AgentPluginDisable: 'ປິດໃຊ້ປລັກອິນ'
|
||||
AgentPluginUpdate: 'ອັບເດດປລັກອິນ'
|
||||
AgentPluginUninstall: 'ຖອນການຕິດຕັ້ງປລັກອິນ'
|
||||
TaskRestart: 'ເລີ່ມໃໝ່'
|
||||
TaskProtect: "ປ້ອງກັນ"
|
||||
TaskBackup: 'ສຳຮອງຂໍ້ມູນ'
|
||||
@@ -454,7 +470,7 @@ TaskImport: "ນຳເຂົ້າ"
|
||||
TaskExport: "ສົ່ງອອກ"
|
||||
Website: 'ເວັບໄຊ'
|
||||
App: 'ແອັບພລິເຄຊັນ'
|
||||
Runtime: 'Runtime'
|
||||
Runtime: 'ສະພາບແວດລ້ອມຣັນທາມ'
|
||||
Database: 'ຖານຂໍ້ມູນ'
|
||||
ConfigFTP: 'ສ້າງຜູ້ໃຊ້ FTP {{ .name }}'
|
||||
ConfigOpenresty: 'ສ້າງໄຟລ໌ການຕັ້ງຄ່າ OpenResty'
|
||||
@@ -475,7 +491,7 @@ Compose: 'Compose'
|
||||
Container: 'ຄອນເທນເນີ'
|
||||
AppLink: 'ແອັບພລິເຄຊັນທີ່ເຊື່ອມໂຍງ'
|
||||
EnableSSL: 'ເປີດໃຊ້ HTTPS'
|
||||
AppStore: 'App Store'
|
||||
AppStore: 'ຮ້ານແອັບ'
|
||||
TaskSync: 'ຊິງຄ໌'
|
||||
LocalApp: 'ແອັບພລິເຄຊັນພາຍໃນເຄື່ອງ'
|
||||
SubTask: 'ວຽກຍ່ອຍ'
|
||||
@@ -640,3 +656,7 @@ ErrAIProxyModelMapEmpty: 'ການຈັບຄູ່ໂມເດລບໍ່ສ
|
||||
ErrAIProxyModelMapEmptyExternalModel: 'ການຈັບຄູ່ໂມເດລມີໂມເດລຄຳຮ້ອງຂໍທີ່ຫວ່າງເປົ່າ'
|
||||
ErrAIProxyModelMapEmptyUpstreamModel: 'ໂມເດລ Upstream ສຳລັບ {{ .model }} ບໍ່ສາມາດຫວ່າງເປົ່າໄດ້'
|
||||
AIProxyUserFallback: 'ຜູ້ໃຊ້ທີ່ຖືກລຶບ (ID: {{ .id }})'
|
||||
AIProviderBailian: 'Alibaba Cloud Model Studio'
|
||||
AIProviderBailianCodingPlan: 'Alibaba Cloud Model Studio Coding Plan'
|
||||
AIProviderArk: 'Volcengine Ark'
|
||||
AIProviderArkCodingPlan: 'Volcengine Ark Coding Plan'
|
||||
|
||||
+29
-9
@@ -93,6 +93,9 @@ ErrAgentWebsiteBound: 'Ejen ini sudah dipautkan ke laman web'
|
||||
ErrAgentWebsiteTypeUnsupported: 'Hanya laman web proxy atau statik boleh dipautkan'
|
||||
ErrAgentWebsiteInUse: 'Laman web ini sudah dipautkan ke ejen lain'
|
||||
ErrAgentWebsiteUnbindUnsupported: 'Laman web one-click deployment tidak menyokong nyahikat manual'
|
||||
ErrQwenPawAuthRequest: 'Gagal mengemas kini kelayakan QwenPaw: {{ .err }}'
|
||||
ErrQwenPawAuthOutOfSync: 'Kelayakan QwenPaw semasa tidak sepadan dengan rekod 1Panel. Tetapkan semula kata laluan QwenPaw atau pulihkan kelayakan dalam fail .env aplikasi'
|
||||
ErrQwenPawAuthDisabled: 'Pengesahan log masuk belum didayakan dalam QwenPaw'
|
||||
ErrHermesPairingCodeUnavailable: 'Kod pasangan buat sementara waktu tidak wujud dalam Hermes, mungkin disebabkan masalah rangkaian. Sila cuba lagi sebentar nanti.'
|
||||
ErrHermesFeishuGroupAllowlistRequiresAllowlist: 'Apabila dasar kumpulan Feishu ialah senarai benarkan, dasar DM tidak boleh menggunakan kod pasangan.'
|
||||
|
||||
@@ -132,6 +135,9 @@ ErrAppNameExist: 'Nama aplikasi sudah wujud'
|
||||
ErrAppInstallNameExist: 'Nama pemasangan aplikasi {{ .name }} sudah wujud'
|
||||
AppStoreIsSyncing: 'App Store sedang menyegerak, sila cuba sebentar lagi'
|
||||
ErrGetCompose: 'Gagal mendapatkan fail docker-compose.yml {{ .detail }}'
|
||||
ErrComposeProjectNameParse: 'Gagal menghurai nama projek daripada output konfigurasi Docker Compose: {{ .detail }}'
|
||||
ErrComposeProjectNameEmpty: 'Nama projek tidak dapat diperoleh daripada konfigurasi Docker Compose atau direktori induk fail. Masukkan nama dan cuba lagi.'
|
||||
ErrComposeNameInvalid: 'Nama projek Compose tidak sah: mesti bermula dengan huruf kecil atau nombor, hanya mengandungi huruf kecil, nombor, tanda sempang dan garis bawah, serta sepanjang 1-256 aksara.'
|
||||
ErrAppVersionUnavailable: 'Versi aplikasi ini telah dialih keluar daripada perkhidmatan jauh. Sila pilih versi lain dan cuba lagi.'
|
||||
ErrAppWarn: 'Status tidak normal, sila semak log'
|
||||
ErrAppParamKey: 'Parameter {{ .name }} medan tidak normal'
|
||||
@@ -154,12 +160,17 @@ ErrAppVersionDeprecated: 'Aplikasi {{ .name }} tidak sesuai dengan versi 1Panel
|
||||
ErrDockerFailed: 'Keadaan Docker tidak normal, sila periksa status perkhidmatan'
|
||||
ErrDockerComposeCmdNotFound: 'Perintah Docker Compose tidak wujud, sila pasang perintah ini di mesin tuan terlebih dahulu'
|
||||
UseExistImage: 'Imej sudah wujud, menggunakan imej sedia ada'
|
||||
UpgradePrepare: 'Sediakan naik taraf aplikasi'
|
||||
UpgradeStop: 'Hentikan aplikasi asal'
|
||||
UpgradeWaitReady: 'Tunggu aplikasi sedia'
|
||||
UpgradeBackupDisabled: 'Sandaran naik taraf dilumpuhkan; perubahan data oleh skrip naik taraf tidak dapat diundur sepenuhnya'
|
||||
UpgradeRollbackFailed: 'Undur gagal'
|
||||
ErrDatabaseNotFound: 'Pangkalan data {{ .name }} tidak wujud'
|
||||
|
||||
# SSH
|
||||
ExportIP: 'IP Log Masuk'
|
||||
ExportArea: 'Lokasi'
|
||||
ExportPort: 'Port'
|
||||
ExportPort: 'Nombor port'
|
||||
ExportAuthMode: 'Kaedah Log Masuk'
|
||||
ExportUser: 'Pengguna'
|
||||
ExportStatus: 'Status Log Masuk'
|
||||
@@ -211,7 +222,7 @@ ErrWebsiteDir: 'Sila pilih direktori dalam direktori laman web.'
|
||||
ErrComposerFileNotFound: 'Fail composer.json tidak wujud'
|
||||
ErrRuntimeNoPort: 'Persekitaran runtime tidak diset dengan port, sila edit persekitaran runtime terlebih dahulu.'
|
||||
ErrRuntimeProjectDirContainsCodeDir: 'Direktori runtime mengandungi direktori projek yang dipilih. Sila pilih direktori projek lain dan cipta semula.'
|
||||
Status: 'Status'
|
||||
Status: 'Keadaan'
|
||||
start: 'Mulakan'
|
||||
stop: 'Berhenti'
|
||||
restart: 'Mulakan Semula'
|
||||
@@ -236,7 +247,7 @@ ErrSSLCertificateFormat: 'Format fail sijil tidak betul, sila gunakan format pem
|
||||
ErrEabKidOrEabHmacKeyCannotBlank: 'EabKid atau EabHmacKey tidak boleh kosong'
|
||||
ErrOpenrestyNotFound: 'Mod HTTP memerlukan OpenResty dipasang terlebih dahulu'
|
||||
ApplySSLStart: 'Mula memohon sijil, nama domain [{{ .domain }}] kaedah permohonan [{{ .type }}]'
|
||||
dnsAccount: 'DNS Auto'
|
||||
dnsAccount: 'DNS automatik'
|
||||
dnsManual: 'Manual DNS'
|
||||
http: 'HTTP'
|
||||
ApplySSLFailed: 'Permohonan untuk sijil [{{ .domain }}] gagal, {{ .detail }}'
|
||||
@@ -395,7 +406,7 @@ urlHelper: 'URL Terlarang'
|
||||
dirFilter: 'Penapis direktori'
|
||||
xss: 'XSS'
|
||||
phpExec: 'Pelaksanaan skrip PHP'
|
||||
oneWordTrojan: 'One Word Trojan'
|
||||
oneWordTrojan: 'Trojan satu baris'
|
||||
appFilter: 'Gunakan penapisan direktori berbahaya'
|
||||
webShell: 'Webshell'
|
||||
args: 'Peraturan parameter'
|
||||
@@ -449,18 +460,23 @@ TaskCreate: 'Buat'
|
||||
TaskDelete: 'Padam'
|
||||
TaskUpgrade: 'Naik taraf'
|
||||
TaskUpdate: 'Kemas kini'
|
||||
AgentPluginInstall: 'Pasang pemalam'
|
||||
AgentPluginEnable: 'Aktifkan pemalam'
|
||||
AgentPluginDisable: 'Nyahaktifkan pemalam'
|
||||
AgentPluginUpdate: 'Kemas kini pemalam'
|
||||
AgentPluginUninstall: 'Nyahpasang pemalam'
|
||||
TaskRestart: 'Mulakan semula'
|
||||
TaskProtect: 'Lindungi'
|
||||
TaskBackup: 'Sandaran'
|
||||
TaskRecover: 'Pulihkan'
|
||||
TaskRollback: 'Rollback'
|
||||
TaskRollback: 'Pulangkan semula'
|
||||
TaskPull: 'Tarik'
|
||||
TaskCommit: 'Komit'
|
||||
TaskBuild: 'Bina'
|
||||
TaskPush: 'Hantar'
|
||||
TaskClean: 'Pembersihan'
|
||||
TaskHandle: 'Laksanakan'
|
||||
TaskImport: 'Import'
|
||||
TaskImport: 'Import data'
|
||||
TaskExport: 'Eksport'
|
||||
Website: 'Laman web'
|
||||
App: 'Aplikasi'
|
||||
@@ -485,7 +501,7 @@ Compose: 'Orkestrasi'
|
||||
Container: 'Bekas'
|
||||
AppLink: 'Aplikasi Terpaut'
|
||||
EnableSSL: 'Dayakan HTTPS'
|
||||
AppStore: 'App Store'
|
||||
AppStore: 'Kedai aplikasi'
|
||||
TaskSync: 'Segerakkan'
|
||||
LocalApp: 'Aplikasi Tempatan'
|
||||
SubTask: 'Subtugas'
|
||||
@@ -506,7 +522,7 @@ OllamaModelSize: 'Dapatkan saiz model Ollama {{ .name }}'
|
||||
AIBenchmarkRun: "Jalankan penanda aras AI"
|
||||
|
||||
# tugas - syot kilat
|
||||
Snapshot: 'Snapshot'
|
||||
Snapshot: 'Syot kilat'
|
||||
SnapDBInfo: 'Tulis maklumat pangkalan data 1Panel'
|
||||
SnapCopy: 'Salin fail & direktori {{ .name }}'
|
||||
SnapNewDB: 'Mulakan sambungan {{ .name }} pangkalan data'
|
||||
@@ -547,7 +563,7 @@ RecoverWebsite: 'Pulihkan direktori tapak web'
|
||||
RecoverAppImage: 'Pulihkan sandaran imej syot kilat'
|
||||
RecoverCompose: 'Pulihkan kandungan komposer lain'
|
||||
RecoverComposeList: 'Dapatkan semua komposer untuk dipulihkan'
|
||||
RecoverComposeItem: 'Recover compose {{ .name }}'
|
||||
RecoverComposeItem: 'Pulihkan orkestrasi {{ .name }}'
|
||||
RecoverAppEmpty: 'Tiada sandaran imej aplikasi ditemui dalam fail syot kilat'
|
||||
RecoverBaseData: 'Pulihkan data asas dan fail'
|
||||
RecoverDaemonJsonEmpty: 'Kedua-dua fail syot kilat dan mesin semasa tidak mempunyai fail daemon.json konfigurasi bekas'
|
||||
@@ -649,3 +665,7 @@ ErrAIProxyModelMapEmpty: 'Pemetaan model tidak boleh kosong'
|
||||
ErrAIProxyModelMapEmptyExternalModel: 'Pemetaan model mengandungi model permintaan kosong'
|
||||
ErrAIProxyModelMapEmptyUpstreamModel: 'Model upstream untuk {{ .model }} tidak boleh kosong'
|
||||
AIProxyUserFallback: 'Pengguna dipadam (ID: {{ .id }})'
|
||||
AIProviderBailian: 'Alibaba Cloud Model Studio'
|
||||
AIProviderBailianCodingPlan: 'Alibaba Cloud Model Studio Coding Plan'
|
||||
AIProviderArk: 'Volcengine Ark'
|
||||
AIProviderArkCodingPlan: 'Volcengine Ark Coding Plan'
|
||||
|
||||
@@ -93,6 +93,9 @@ ErrAgentWebsiteBound: 'Este agente já está vinculado a um site'
|
||||
ErrAgentWebsiteTypeUnsupported: 'Somente sites proxy ou estáticos podem ser vinculados'
|
||||
ErrAgentWebsiteInUse: 'Este site já está vinculado a outro agente'
|
||||
ErrAgentWebsiteUnbindUnsupported: 'Sites implantados em um clique não podem ser desvinculados manualmente'
|
||||
ErrQwenPawAuthRequest: 'Falha ao atualizar as credenciais do QwenPaw: {{ .err }}'
|
||||
ErrQwenPawAuthOutOfSync: 'As credenciais atuais do QwenPaw não correspondem ao registro do 1Panel. Redefina a senha do QwenPaw ou restaure as credenciais no arquivo .env do aplicativo'
|
||||
ErrQwenPawAuthDisabled: 'A autenticação de login não está ativada no QwenPaw'
|
||||
ErrHermesPairingCodeUnavailable: 'O código de pareamento está temporariamente indisponível no Hermes, possivelmente por causa de rede. Tente novamente mais tarde.'
|
||||
ErrHermesFeishuGroupAllowlistRequiresAllowlist: 'Quando a política de grupo do Feishu é Lista de permissões, a política de DM não pode ser Código de pareamento.'
|
||||
|
||||
@@ -113,7 +116,7 @@ ErrPortInOtherApp: 'A porta {{ .port }} já está ocupada pelo aplicativo {{ .ap
|
||||
ErrDbUserNotValid: 'Banco de dados existente, nome de usuário e senha não correspondem'
|
||||
ErrMysqlUserUsedByApps: 'Este usuário do banco de dados está sendo usado por [{{ .detail }}]; seu host não pode ser alterado, e ele não pode ser excluído nem ter sua autorização revogada'
|
||||
ErrUpdateBuWebsite: 'O aplicativo foi atualizado com sucesso, mas a modificação do arquivo de configuração do site falhou., verifique a configuração'
|
||||
Err1PanelNetworkFailed: 'Falha na criação da rede de contêiner padrão {{ .detalhe }}'
|
||||
Err1PanelNetworkFailed: 'Falha na criação da rede de contêiner padrão {{ .detail }}'
|
||||
ErrFileParse: 'Falha na análise do arquivo docker-compose do aplicativo'
|
||||
ErrInstallDirNotFound: 'O diretório de instalação não existe. Se precisar desinstalar, selecione Forçar desinstalação'
|
||||
AppStoreIsUpToDate: 'A loja de aplicativos já é a versão mais recente'
|
||||
@@ -132,6 +135,9 @@ ErrAppNameExist: 'O nome do aplicativo já existe'
|
||||
ErrAppInstallNameExist: 'O nome de instalação do aplicativo {{ .name }} já existe'
|
||||
AppStoreIsSyncing: 'A App Store está sincronizando mais tarde'
|
||||
ErrGetCompose: 'Falha ao obter o arquivo docker-compose.yml {{ .detail }}'
|
||||
ErrComposeProjectNameParse: 'Falha ao analisar o nome do projeto na saída de configuração do Docker Compose: {{ .detail }}'
|
||||
ErrComposeProjectNameEmpty: 'Não foi possível derivar um nome de projeto da configuração do Docker Compose nem do diretório pai do arquivo. Informe um nome e tente novamente.'
|
||||
ErrComposeNameInvalid: 'Nome de projeto Compose inválido: deve começar com uma letra minúscula ou número, conter apenas letras minúsculas, números, hífens e sublinhados e ter de 1 a 256 caracteres.'
|
||||
ErrAppVersionUnavailable: 'Esta versão do aplicativo foi removida do serviço remoto. Selecione outra versão e tente novamente.'
|
||||
ErrAppWarn: 'Status anormal, verifique o log'
|
||||
ErrAppParamKey: 'O campo de parâmetro {{ .name }} está anormal'
|
||||
@@ -154,6 +160,11 @@ ErrAppVersionDeprecated: 'O aplicativo {{ .name }} não é compatível com a ver
|
||||
ErrDockerFailed: 'O estado do Docker está anormal, verifique o status do serviço'
|
||||
ErrDockerComposeCmdNotFound: 'O comando Docker Compose não existe, instale este comando na máquina host primeiro'
|
||||
UseExistImage: 'A imagem já existe, usando imagem existente'
|
||||
UpgradePrepare: 'Preparar atualização do aplicativo'
|
||||
UpgradeStop: 'Parar aplicativo original'
|
||||
UpgradeWaitReady: 'Aguardar o aplicativo ficar pronto'
|
||||
UpgradeBackupDisabled: 'O backup de atualização está desativado; alterações de dados feitas pelos scripts não podem ser totalmente revertidas'
|
||||
UpgradeRollbackFailed: 'Falha na reversão'
|
||||
ErrDatabaseNotFound: 'O banco de dados {{ .name }} não existe'
|
||||
|
||||
# SSH
|
||||
@@ -187,7 +198,7 @@ ErrSourcePathNotFound: 'O diretório de origem não existe'
|
||||
ErrFavoriteExist: 'Este caminho já foi favorito'
|
||||
ErrInvalidChar: 'Caracteres ilegais não são permitidos'
|
||||
ErrPathNotDelete: 'O diretório selecionado não pode ser excluído'
|
||||
ErrLogFileToLarge: 'Fail log melebihi 500MB, tidak boleh dibuka'
|
||||
ErrLogFileToLarge: 'O arquivo de log excede 500 MB e não pode ser aberto'
|
||||
FileDropFailed: 'Falha ao limpar arquivo {{ .name }}: {{ .err }}'
|
||||
FileDropSuccess: 'Arquivo {{ .name }} limpo com sucesso, {{ .count }} arquivos removidos, {{ .size }} de espaço em disco liberado'
|
||||
FileDropSum: 'Limpeza de arquivos concluída com sucesso, total de {{ .count }} arquivos removidos, total de {{ .size }} de espaço em disco liberado'
|
||||
@@ -211,7 +222,7 @@ ErrWebsiteDir: ', selecione um diretório dentro do diretório do site.'
|
||||
ErrComposerFileNotFound: 'O arquivo composer.json não existe'
|
||||
ErrRuntimeNoPort: 'O ambiente de tempo de execução não está configurado com uma porta, edite o ambiente de tempo de execução primeiro.'
|
||||
ErrRuntimeProjectDirContainsCodeDir: 'O diretório de execução inclui o diretório de projeto selecionado. Escolha outro diretório de projeto e crie novamente.'
|
||||
Status: 'Status'
|
||||
Status: 'Estado'
|
||||
start: 'Iniciar'
|
||||
stop: 'Parar'
|
||||
restart: 'Reiniciar'
|
||||
@@ -449,13 +460,18 @@ TaskCreate: 'Criar'
|
||||
TaskDelete: 'Excluir'
|
||||
TaskUpgrade: 'Atualizar'
|
||||
TaskUpdate: 'Atualizar'
|
||||
AgentPluginInstall: 'Instalar plugin'
|
||||
AgentPluginEnable: 'Ativar plugin'
|
||||
AgentPluginDisable: 'Desativar plugin'
|
||||
AgentPluginUpdate: 'Atualizar plugin'
|
||||
AgentPluginUninstall: 'Desinstalar plugin'
|
||||
TaskRestart: 'Reiniciar'
|
||||
TaskProtect: 'Proteger'
|
||||
TaskBackup: 'Backup'
|
||||
TaskBackup: 'Fazer cópia de segurança'
|
||||
TaskRecover: 'Recuperar'
|
||||
TaskRollback: 'Reverter'
|
||||
TaskPull: 'Puxar'
|
||||
TaskCommit: 'Commit'
|
||||
TaskCommit: 'Criar imagem'
|
||||
TaskBuild: 'Construir'
|
||||
TaskPush: 'Enviar'
|
||||
TaskClean: 'Limpeza'
|
||||
@@ -649,3 +665,7 @@ ErrAIProxyModelMapEmpty: 'O mapeamento de modelos não pode estar vazio'
|
||||
ErrAIProxyModelMapEmptyExternalModel: 'O mapeamento de modelos contém um modelo solicitado vazio'
|
||||
ErrAIProxyModelMapEmptyUpstreamModel: 'O modelo upstream de {{ .model }} não pode estar vazio'
|
||||
AIProxyUserFallback: 'Usuário excluído (ID: {{ .id }})'
|
||||
AIProviderBailian: 'Alibaba Cloud Model Studio'
|
||||
AIProviderBailianCodingPlan: 'Alibaba Cloud Model Studio Coding Plan'
|
||||
AIProviderArk: 'Volcengine Ark'
|
||||
AIProviderArkCodingPlan: 'Volcengine Ark Coding Plan'
|
||||
|
||||
+22
-2
@@ -93,6 +93,9 @@ ErrAgentWebsiteBound: 'Этот агент уже связан с сайтом'
|
||||
ErrAgentWebsiteTypeUnsupported: 'Можно связывать только proxy- или static-сайты'
|
||||
ErrAgentWebsiteInUse: 'Этот сайт уже связан с другим агентом'
|
||||
ErrAgentWebsiteUnbindUnsupported: 'Сайты one-click deployment нельзя отвязать вручную'
|
||||
ErrQwenPawAuthRequest: 'Не удалось обновить учетные данные QwenPaw: {{ .err }}'
|
||||
ErrQwenPawAuthOutOfSync: 'Текущие учетные данные QwenPaw не совпадают с записью 1Panel. Сбросьте пароль QwenPaw или восстановите учетные данные в файле .env приложения'
|
||||
ErrQwenPawAuthDisabled: 'В QwenPaw не включена аутентификация при входе'
|
||||
ErrHermesPairingCodeUnavailable: 'Код сопряжения временно недоступен в Hermes, возможно из-за проблем с сетью. Повторите попытку позже.'
|
||||
ErrHermesFeishuGroupAllowlistRequiresAllowlist: 'Когда групповая политика Feishu — белый список, политика личных сообщений не может быть кодом сопряжения.'
|
||||
|
||||
@@ -132,6 +135,9 @@ ErrAppNameExist: 'Имя приложения уже существует'
|
||||
ErrAppInstallNameExist: 'Имя установки приложения {{ .name }} уже существует'
|
||||
AppStoreIsSyncing: 'App Store синхронизируется, повторите попытку позже'
|
||||
ErrGetCompose: 'Не удалось получить файл docker-compose.yml {{ .detail }}'
|
||||
ErrComposeProjectNameParse: 'Не удалось определить имя проекта из вывода конфигурации Docker Compose: {{ .detail }}'
|
||||
ErrComposeProjectNameEmpty: 'Не удалось получить имя проекта из конфигурации Docker Compose или родительского каталога файла. Введите имя и повторите попытку.'
|
||||
ErrComposeNameInvalid: 'Недопустимое имя проекта Compose: оно должно начинаться со строчной буквы или цифры, содержать только строчные буквы, цифры, дефисы и подчёркивания и иметь длину от 1 до 256 символов.'
|
||||
ErrAppVersionUnavailable: 'Эта версия приложения удалена из удаленного сервиса. Выберите другую версию и повторите попытку.'
|
||||
ErrAppWarn: 'Ненормальное состояние, проверьте журнал'
|
||||
ErrAppParamKey: 'Поле параметра {{ .name }} ненормально'
|
||||
@@ -154,6 +160,11 @@ ErrAppVersionDeprecated: 'Приложение {{ .name }} несовмести
|
||||
ErrDockerFailed: 'Состояние Docker аномально, проверьте состояние сервиса'
|
||||
ErrDockerComposeCmdNotFound: 'Команда Docker Compose отсутствует, пожалуйста, установите эту команду на хост-машине сначала'
|
||||
UseExistImage: 'Образ уже существует, используется существующий образ'
|
||||
UpgradePrepare: 'Подготовка обновления приложения'
|
||||
UpgradeStop: 'Остановка исходного приложения'
|
||||
UpgradeWaitReady: 'Ожидание готовности приложения'
|
||||
UpgradeBackupDisabled: 'Резервная копия обновления отключена; изменения данных из сценариев обновления нельзя полностью откатить'
|
||||
UpgradeRollbackFailed: 'Ошибка отката'
|
||||
ErrDatabaseNotFound: 'База данных {{ .name }} не существует'
|
||||
|
||||
# SSH
|
||||
@@ -397,7 +408,7 @@ xss: 'XSS'
|
||||
phpExec: 'Выполнение PHP-скрипта'
|
||||
oneWordTrojan: 'Троян из одного слова'
|
||||
appFilter: 'Применить фильтрацию опасных каталогов'
|
||||
webShell: 'Be6-оболочка'
|
||||
webShell: 'Веб-оболочка'
|
||||
args: 'Правила параметров'
|
||||
protocolFilter: 'Фильтрация протоколов'
|
||||
javaFilter: 'Фильтр опасных файлов Java'
|
||||
@@ -449,6 +460,11 @@ TaskCreate: 'Создать'
|
||||
TaskDelete: 'Удалить'
|
||||
TaskUpgrade: 'Обновить'
|
||||
TaskUpdate: 'Обновить'
|
||||
AgentPluginInstall: 'Установить плагин'
|
||||
AgentPluginEnable: 'Включить плагин'
|
||||
AgentPluginDisable: 'Отключить плагин'
|
||||
AgentPluginUpdate: 'Обновить плагин'
|
||||
AgentPluginUninstall: 'Удалить плагин'
|
||||
TaskRestart: 'Перезапуск'
|
||||
TaskProtect: 'Защита'
|
||||
TaskBackup: 'Резервное копирование'
|
||||
@@ -462,7 +478,7 @@ TaskClean: 'Очистка'
|
||||
TaskHandle: 'Выполнить'
|
||||
TaskImport: 'Импорт'
|
||||
TaskExport: 'Экспорт'
|
||||
Website: 'Be6-сайт'
|
||||
Website: 'Веб-сайт'
|
||||
App: 'Приложение'
|
||||
Runtime: 'Среда выполнения'
|
||||
Database: 'База данных'
|
||||
@@ -649,3 +665,7 @@ ErrAIProxyModelMapEmpty: 'Сопоставление моделей не мож
|
||||
ErrAIProxyModelMapEmptyExternalModel: 'Сопоставление моделей содержит пустую запрошенную модель'
|
||||
ErrAIProxyModelMapEmptyUpstreamModel: 'Upstream-модель для {{ .model }} не может быть пустой'
|
||||
AIProxyUserFallback: 'Удаленный пользователь (ID: {{ .id }})'
|
||||
AIProviderBailian: 'Alibaba Cloud Model Studio'
|
||||
AIProviderBailianCodingPlan: 'Alibaba Cloud Model Studio Coding Plan'
|
||||
AIProviderArk: 'Volcengine Ark'
|
||||
AIProviderArkCodingPlan: 'Volcengine Ark Coding Plan'
|
||||
|
||||
+21
-1
@@ -93,6 +93,9 @@ ErrAgentWebsiteBound: 'Bu ajan zaten bir web sitesine bağlı'
|
||||
ErrAgentWebsiteTypeUnsupported: 'Yalnızca proxy veya statik web siteleri bağlanabilir'
|
||||
ErrAgentWebsiteInUse: 'Bu web sitesi zaten başka bir ajana bağlı'
|
||||
ErrAgentWebsiteUnbindUnsupported: 'Tek tıkla dağıtılan web sitelerinin bağlantısı manuel olarak kaldırılamaz'
|
||||
ErrQwenPawAuthRequest: 'QwenPaw kimlik bilgileri güncellenemedi: {{ .err }}'
|
||||
ErrQwenPawAuthOutOfSync: 'Geçerli QwenPaw kimlik bilgileri 1Panel kaydıyla eşleşmiyor. QwenPaw parolasını sıfırlayın veya uygulamanın .env dosyasındaki kimlik bilgilerini geri yükleyin'
|
||||
ErrQwenPawAuthDisabled: 'QwenPaw oturum açma kimlik doğrulaması etkin değil'
|
||||
ErrHermesPairingCodeUnavailable: 'Eşleştirme kodu Hermes içinde geçici olarak bulunamıyor; bu durum ağ kaynaklı olabilir. Lütfen daha sonra tekrar deneyin.'
|
||||
ErrHermesFeishuGroupAllowlistRequiresAllowlist: 'Feishu grup ilkesi izin listesi olduğunda, DM ilkesi eşleştirme kodu olamaz.'
|
||||
|
||||
@@ -132,6 +135,9 @@ ErrAppNameExist: 'Uygulama adı zaten mevcut'
|
||||
ErrAppInstallNameExist: '{{ .name }} uygulama kurulum adı zaten mevcut'
|
||||
AppStoreIsSyncing: 'Uygulama Mağazası senkronize ediliyor, lütfen daha sonra tekrar deneyin'
|
||||
ErrGetCompose: 'docker-compose.yml dosyası alınamadı {{ .detail }}'
|
||||
ErrComposeProjectNameParse: 'Docker Compose yapılandırma çıktısından proje adı ayrıştırılamadı: {{ .detail }}'
|
||||
ErrComposeProjectNameEmpty: 'Docker Compose yapılandırmasından veya dosyanın üst dizininden proje adı türetilemedi. Bir ad girip yeniden deneyin.'
|
||||
ErrComposeNameInvalid: 'Geçersiz Compose proje adı: küçük harf veya rakamla başlamalı, yalnızca küçük harf, rakam, kısa çizgi ve alt çizgi içermeli ve 1-256 karakter uzunluğunda olmalıdır.'
|
||||
ErrAppVersionUnavailable: 'Bu uygulama sürümü uzak hizmetten kaldırıldı. Lütfen başka bir sürüm seçip tekrar deneyin.'
|
||||
ErrAppWarn: 'Anormal durum, lütfen günlüğü kontrol edin'
|
||||
ErrAppParamKey: 'Parametre {{ .name }} alanı anormal'
|
||||
@@ -154,12 +160,17 @@ ErrAppVersionDeprecated: '{{ .name }} uygulaması mevcut 1Panel sürümü ile uy
|
||||
ErrDockerFailed: 'Docker durumu anormal, lütfen servis durumunu kontrol edin'
|
||||
ErrDockerComposeCmdNotFound: 'Docker Compose komutu mevcut değil, lütfen önce bu komutu host makinesine yükleyin'
|
||||
UseExistImage: 'Görüntü zaten mevcut, mevcut görüntü kullanılıyor'
|
||||
UpgradePrepare: 'Uygulama yükseltmesini hazırla'
|
||||
UpgradeStop: 'Özgün uygulamayı durdur'
|
||||
UpgradeWaitReady: 'Uygulamanın hazır olmasını bekle'
|
||||
UpgradeBackupDisabled: 'Yükseltme yedeği devre dışı; yükseltme betiklerinin veri değişiklikleri tamamen geri alınamaz'
|
||||
UpgradeRollbackFailed: 'Geri alma başarısız'
|
||||
ErrDatabaseNotFound: '{{ .name }} veritabanı mevcut değil'
|
||||
|
||||
# SSH
|
||||
ExportIP: 'Giriş IP'
|
||||
ExportArea: 'Konum'
|
||||
ExportPort: 'Port'
|
||||
ExportPort: 'Bağlantı noktası'
|
||||
ExportAuthMode: 'Giriş Yöntemi'
|
||||
ExportUser: 'Kullanıcı'
|
||||
ExportStatus: 'Giriş Durumu'
|
||||
@@ -449,6 +460,11 @@ TaskCreate: 'Oluştur'
|
||||
TaskDelete: 'Sil'
|
||||
TaskUpgrade: 'Yükselt'
|
||||
TaskUpdate: 'Güncelle'
|
||||
AgentPluginInstall: 'Eklenti yükle'
|
||||
AgentPluginEnable: 'Eklentiyi etkinleştir'
|
||||
AgentPluginDisable: 'Eklentiyi devre dışı bırak'
|
||||
AgentPluginUpdate: 'Eklentiyi güncelle'
|
||||
AgentPluginUninstall: 'Eklentiyi kaldır'
|
||||
TaskRestart: 'Yeniden Başlat'
|
||||
TaskProtect: 'Koru'
|
||||
TaskBackup: 'Yedekle'
|
||||
@@ -649,3 +665,7 @@ ErrAIProxyModelMapEmpty: 'Model eşlemesi boş olamaz'
|
||||
ErrAIProxyModelMapEmptyExternalModel: 'Model eşlemesi boş bir istek modeli içeriyor'
|
||||
ErrAIProxyModelMapEmptyUpstreamModel: '{{ .model }} için upstream model boş olamaz'
|
||||
AIProxyUserFallback: 'Silinen kullanıcı (ID: {{ .id }})'
|
||||
AIProviderBailian: 'Alibaba Cloud Model Studio'
|
||||
AIProviderBailianCodingPlan: 'Alibaba Cloud Model Studio Coding Plan'
|
||||
AIProviderArk: 'Volcengine Ark'
|
||||
AIProviderArkCodingPlan: 'Volcengine Ark Coding Plan'
|
||||
|
||||
@@ -93,6 +93,9 @@ ErrAgentWebsiteBound: '該智能體已關聯網站'
|
||||
ErrAgentWebsiteTypeUnsupported: '只能關聯反向代理或靜態網站'
|
||||
ErrAgentWebsiteInUse: '該網站已被其他智能體關聯'
|
||||
ErrAgentWebsiteUnbindUnsupported: '一鍵部署網站不支援手動解綁'
|
||||
ErrQwenPawAuthRequest: 'QwenPaw 驗證資訊更新失敗: {{ .err }}'
|
||||
ErrQwenPawAuthOutOfSync: 'QwenPaw 目前憑據與 1Panel 記錄不一致,請先重設 QwenPaw 密碼或還原應用程式 .env 中的憑據'
|
||||
ErrQwenPawAuthDisabled: 'QwenPaw 尚未啟用登入驗證'
|
||||
ErrHermesPairingCodeUnavailable: '配對碼在 Hermes 中暫時不存在,可能是由於網路原因,請稍後再試'
|
||||
ErrHermesFeishuGroupAllowlistRequiresAllowlist: '飛書群組策略為白名單時,私聊策略不能為配對碼'
|
||||
|
||||
@@ -132,6 +135,9 @@ ErrAppNameExist: '應用程式名稱已存在'
|
||||
ErrAppInstallNameExist: '應用程式安裝名稱 {{ .name }} 已存在'
|
||||
AppStoreIsSyncing: '應用程式商店正在同步中,請稍後重試'
|
||||
ErrGetCompose: 'docker-compose.yml 檔案取得失敗!{{ .detail }}'
|
||||
ErrComposeProjectNameParse: '解析 Docker Compose 設定輸出中的專案名稱失敗:{{ .detail }}'
|
||||
ErrComposeProjectNameEmpty: '無法從 Docker Compose 設定或檔案父目錄中推導出專案名稱,請填寫名稱後重試'
|
||||
ErrComposeNameInvalid: 'Compose 專案名稱格式錯誤:必須以小寫字母或數字開頭,僅支援小寫字母、數字、- 和 _,長度為 1-256 個字元'
|
||||
ErrAppVersionUnavailable: '目前應用程式版本已從遠端服務下架,請選擇其他版本後重試'
|
||||
ErrAppWarn: '狀態異常,請檢視日誌'
|
||||
ErrAppParamKey: '參數{{ .name }} 欄位異常'
|
||||
@@ -154,6 +160,11 @@ ErrAppVersionDeprecated: '{{ .name }} 應用不適配目前 1Panel 版本,跳
|
||||
ErrDockerFailed: 'Docker 狀態異常,請檢查服務狀態'
|
||||
ErrDockerComposeCmdNotFound: 'Docker Compose 指令不存在,請先在宿主機安裝此指令'
|
||||
UseExistImage: '映像已存在,使用現有映像'
|
||||
UpgradePrepare: '準備應用程式升級'
|
||||
UpgradeStop: '停止原應用程式'
|
||||
UpgradeWaitReady: '等待應用程式就緒'
|
||||
UpgradeBackupDisabled: '未啟用升級備份,升級腳本產生的資料變更無法完整回滾'
|
||||
UpgradeRollbackFailed: '回滾失敗'
|
||||
ErrDatabaseNotFound: '資料庫 {{ .name }} 不存在'
|
||||
|
||||
# SSH
|
||||
@@ -449,6 +460,11 @@ TaskCreate: '建立'
|
||||
TaskDelete: '刪除'
|
||||
TaskUpgrade: '升級'
|
||||
TaskUpdate: '更新'
|
||||
AgentPluginInstall: '安裝外掛程式'
|
||||
AgentPluginEnable: '啟用外掛程式'
|
||||
AgentPluginDisable: '停用外掛程式'
|
||||
AgentPluginUpdate: '更新外掛程式'
|
||||
AgentPluginUninstall: '解除安裝外掛程式'
|
||||
TaskRestart: '重新啟動'
|
||||
TaskProtect: '防護'
|
||||
TaskBackup: '備份'
|
||||
@@ -649,3 +665,7 @@ ErrAIProxyModelMapEmpty: '模型映射不能為空'
|
||||
ErrAIProxyModelMapEmptyExternalModel: '模型映射中存在空的請求模型'
|
||||
ErrAIProxyModelMapEmptyUpstreamModel: '模型 {{ .model }} 的上游模型不能為空'
|
||||
AIProxyUserFallback: '已刪除使用者 (ID: {{ .id }})'
|
||||
AIProviderBailian: '阿里雲百煉'
|
||||
AIProviderBailianCodingPlan: '阿里雲百煉 Coding Plan'
|
||||
AIProviderArk: '火山方舟'
|
||||
AIProviderArkCodingPlan: '火山方舟 Coding Plan'
|
||||
|
||||
@@ -93,6 +93,9 @@ ErrAgentWebsiteBound: "该智能体已关联网站"
|
||||
ErrAgentWebsiteTypeUnsupported: "只能关联反向代理或静态网站"
|
||||
ErrAgentWebsiteInUse: "该网站已被其他智能体关联"
|
||||
ErrAgentWebsiteUnbindUnsupported: "一键部署网站不支持手动解绑"
|
||||
ErrQwenPawAuthRequest: "QwenPaw 认证信息更新失败: {{ .err }}"
|
||||
ErrQwenPawAuthOutOfSync: "QwenPaw 当前凭据与 1Panel 记录不一致,请先重置 QwenPaw 密码或恢复应用 .env 中的凭据"
|
||||
ErrQwenPawAuthDisabled: "QwenPaw 尚未启用登录认证"
|
||||
ErrHermesPairingCodeUnavailable: "配对码在 hermes 中暂时不存在,可能是由于网络原因,请稍后尝试"
|
||||
ErrHermesFeishuGroupAllowlistRequiresAllowlist: "飞书群组策略为白名单时,私聊策略不能为配对码"
|
||||
|
||||
@@ -132,6 +135,9 @@ ErrAppNameExist: '应用名称已存在'
|
||||
ErrAppInstallNameExist: '应用安装名称 {{ .name }} 已存在'
|
||||
AppStoreIsSyncing: '应用商店同步中,请稍后重试'
|
||||
ErrGetCompose: "docker-compose.yml 文件获取失败!{{ .detail }}"
|
||||
ErrComposeProjectNameParse: "解析 Docker Compose 配置输出中的项目名称失败:{{ .detail }}"
|
||||
ErrComposeProjectNameEmpty: "无法从 Docker Compose 配置或文件父目录中推导出项目名称,请填写名称后重试"
|
||||
ErrComposeNameInvalid: "Compose 项目名称格式错误:必须以小写字母或数字开头,仅支持小写字母、数字、- 和 _,长度为 1-256 个字符"
|
||||
ErrAppVersionUnavailable: "当前应用版本已从远程服务下架,请选择其他版本后重试"
|
||||
ErrAppWarn: "状态异常,请查看日志"
|
||||
ErrAppParamKey: "参数 {{ .name }} 字段异常"
|
||||
@@ -154,6 +160,11 @@ ErrAppVersionDeprecated: "{{ .name }} 应用不适配当前 1Panel 版本,跳
|
||||
ErrDockerFailed: "Docker 状态异常,请检查服务状态"
|
||||
ErrDockerComposeCmdNotFound: "Docker Compose 命令不存在,请先在宿主机安装"
|
||||
UseExistImage: "镜像已存在,使用存量镜像"
|
||||
UpgradePrepare: "准备应用升级"
|
||||
UpgradeStop: "停止原应用"
|
||||
UpgradeWaitReady: "等待应用就绪"
|
||||
UpgradeBackupDisabled: "未启用升级备份,升级脚本产生的数据变更无法完整回滚"
|
||||
UpgradeRollbackFailed: "回滚失败"
|
||||
ErrDatabaseNotFound: "数据库 {{ .name }} 不存在"
|
||||
|
||||
# SSH
|
||||
@@ -449,6 +460,11 @@ TaskCreate: "创建"
|
||||
TaskDelete: "删除"
|
||||
TaskUpgrade: "升级"
|
||||
TaskUpdate: "更新"
|
||||
AgentPluginInstall: "安装插件"
|
||||
AgentPluginEnable: "启用插件"
|
||||
AgentPluginDisable: "禁用插件"
|
||||
AgentPluginUpdate: "更新插件"
|
||||
AgentPluginUninstall: "卸载插件"
|
||||
TaskRestart: "重启"
|
||||
TaskProtect: "防护"
|
||||
TaskBackup: "备份"
|
||||
@@ -649,3 +665,7 @@ ErrAIProxyModelMapEmpty: "模型映射不能为空"
|
||||
ErrAIProxyModelMapEmptyExternalModel: "模型映射中存在空的请求模型"
|
||||
ErrAIProxyModelMapEmptyUpstreamModel: "模型 {{ .model }} 的上游模型不能为空"
|
||||
AIProxyUserFallback: "已删除用户 (ID: {{ .id }})"
|
||||
AIProviderBailian: "阿里云百炼"
|
||||
AIProviderBailianCodingPlan: "阿里云百炼 Coding Plan"
|
||||
AIProviderArk: "火山方舟"
|
||||
AIProviderArkCodingPlan: "火山方舟 Coding Plan"
|
||||
|
||||
@@ -10,7 +10,6 @@ import (
|
||||
"github.com/1Panel-dev/1Panel/agent/constant"
|
||||
"github.com/1Panel-dev/1Panel/agent/global"
|
||||
"github.com/1Panel-dev/1Panel/agent/utils/firewall"
|
||||
firewallClient "github.com/1Panel-dev/1Panel/agent/utils/firewall/client"
|
||||
"github.com/1Panel-dev/1Panel/agent/utils/firewall/client/iptables"
|
||||
)
|
||||
|
||||
@@ -25,35 +24,15 @@ func Init() {
|
||||
return
|
||||
}
|
||||
clientName := client.Name()
|
||||
|
||||
settingRepo := repo.NewISettingRepo()
|
||||
if clientName == "ufw" || clientName == "iptables" {
|
||||
if err := iptables.LoadRulesFromFile(iptables.FilterTab, iptables.Chain1PanelForward, iptables.ForwardFileName); err != nil {
|
||||
global.LOG.Errorf("load forward rules from file failed, err: %v", err)
|
||||
return
|
||||
}
|
||||
if err := iptables.LoadRulesFromFile(iptables.NatTab, iptables.Chain1PanelPreRouting, iptables.ForwardFileName1); err != nil {
|
||||
global.LOG.Errorf("load prerouting rules from file failed, err: %v", err)
|
||||
return
|
||||
}
|
||||
if err := iptables.LoadRulesFromFile(iptables.NatTab, iptables.Chain1PanelPostRouting, iptables.ForwardFileName2); err != nil {
|
||||
global.LOG.Errorf("load postrouting rules from file failed, err: %v", err)
|
||||
return
|
||||
}
|
||||
global.LOG.Infof("loaded iptables rules for forward from file successfully")
|
||||
|
||||
iptablesForwardStatus, _ := settingRepo.GetValueByKey("IptablesForwardStatus")
|
||||
if iptablesForwardStatus == constant.StatusEnable {
|
||||
if err := firewallClient.EnableIptablesForward(); err != nil {
|
||||
global.LOG.Errorf("enable iptables forward failed, err: %v", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := service.NewIForwardingService().Replay(); err != nil {
|
||||
global.LOG.Errorf("replay forwarding rules failed, err: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if clientName != "iptables" {
|
||||
return
|
||||
}
|
||||
settingRepo := repo.NewISettingRepo()
|
||||
if err := iptables.LoadRulesFromFile(iptables.FilterTab, iptables.Chain1PanelBasicBefore, iptables.BasicBeforeFileName); err != nil {
|
||||
global.LOG.Errorf("load basic before rules from file failed, err: %v", err)
|
||||
return
|
||||
|
||||
@@ -93,6 +93,10 @@ func InitAgentDB() {
|
||||
migrations.AddMcpServerGatewayArgs,
|
||||
migrations.InitFirewallPortWhiteList,
|
||||
migrations.AddDatabaseUserTable,
|
||||
migrations.AddBackupRecordArgs,
|
||||
migrations.AddFtpIdentity,
|
||||
migrations.AddWebsiteTemplateTable,
|
||||
migrations.AddComposePinned,
|
||||
})
|
||||
if err := m.Migrate(); err != nil {
|
||||
global.LOG.Error(err)
|
||||
|
||||
@@ -73,6 +73,8 @@ var AddTable = &gormigrate.Migration{
|
||||
&model.Website{},
|
||||
&model.WebsiteAcmeAccount{},
|
||||
&model.WebsiteCA{},
|
||||
&model.WebsiteTemplate{},
|
||||
&model.WebsiteTemplateOutput{},
|
||||
&model.WebsiteDnsAccount{},
|
||||
&model.WebsiteDomain{},
|
||||
&model.WebsiteSSL{},
|
||||
@@ -1676,3 +1678,42 @@ var AddMcpServerGatewayArgs = &gormigrate.Migration{
|
||||
return tx.AutoMigrate(&model.McpServer{})
|
||||
},
|
||||
}
|
||||
|
||||
var AddBackupRecordArgs = &gormigrate.Migration{
|
||||
ID: "20260729-add-backup-record-args",
|
||||
Migrate: func(tx *gorm.DB) error {
|
||||
return tx.AutoMigrate(&model.BackupRecord{})
|
||||
},
|
||||
}
|
||||
|
||||
var AddFtpIdentity = &gormigrate.Migration{
|
||||
ID: "20260729-add-ftp-identity",
|
||||
Migrate: func(tx *gorm.DB) error {
|
||||
if err := tx.AutoMigrate(&model.Ftp{}); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Model(&model.Ftp{}).
|
||||
Where("1 = 1").
|
||||
Updates(map[string]interface{}{
|
||||
"uid": constant.WebsiteUID,
|
||||
"gid": constant.WebsiteGID,
|
||||
}).Error
|
||||
},
|
||||
}
|
||||
|
||||
var AddWebsiteTemplateTable = &gormigrate.Migration{
|
||||
ID: "20260728-add-website-template-table",
|
||||
Migrate: func(tx *gorm.DB) error {
|
||||
return tx.AutoMigrate(
|
||||
&model.WebsiteTemplate{},
|
||||
&model.WebsiteTemplateOutput{},
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
var AddComposePinned = &gormigrate.Migration{
|
||||
ID: "20260729-add-compose-pinned",
|
||||
Migrate: func(tx *gorm.DB) error {
|
||||
return tx.AutoMigrate(&model.Compose{})
|
||||
},
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ func commonGroups() []CommonRouter {
|
||||
&WebsiteDnsAccountRouter{},
|
||||
&WebsiteAcmeAccountRouter{},
|
||||
&WebsiteSSLRouter{},
|
||||
&WebsiteTemplateRouter{},
|
||||
&DatabaseRouter{},
|
||||
&NginxRouter{},
|
||||
&RuntimeRouter{},
|
||||
|
||||
@@ -104,6 +104,10 @@ func (a *AIToolsRouter) InitRouter(Router *gin.RouterGroup) {
|
||||
aiToolsRouter.POST("/agents/plugin/upgrade", baseApi.UpgradeAgentPlugin)
|
||||
aiToolsRouter.POST("/agents/plugin/uninstall", baseApi.UninstallAgentPlugin)
|
||||
aiToolsRouter.POST("/agents/plugin/check", baseApi.CheckAgentPlugin)
|
||||
aiToolsRouter.POST("/agents/plugins/list", baseApi.ListAgentPlugins)
|
||||
aiToolsRouter.POST("/agents/plugins/search", baseApi.SearchAgentPlugins)
|
||||
aiToolsRouter.POST("/agents/plugins/install", baseApi.InstallAgentMarketPlugin)
|
||||
aiToolsRouter.POST("/agents/plugins/operate", baseApi.OperateAgentPlugin)
|
||||
aiToolsRouter.POST("/agents/security/get", baseApi.GetAgentSecurityConfig)
|
||||
aiToolsRouter.POST("/agents/security/update", baseApi.UpdateAgentSecurityConfig)
|
||||
aiToolsRouter.POST("/agents/other/get", baseApi.GetAgentOtherConfig)
|
||||
|
||||
@@ -55,6 +55,7 @@ func (s *ContainerRouter) InitRouter(Router *gin.RouterGroup) {
|
||||
baRouter.POST("/compose/operate", baseApi.OperatorCompose)
|
||||
baRouter.POST("/compose/clean/log", baseApi.CleanComposeLog)
|
||||
baRouter.POST("/compose/update", baseApi.ComposeUpdate)
|
||||
baRouter.POST("/compose/pin", baseApi.ComposePin)
|
||||
|
||||
baRouter.GET("/template", baseApi.ListComposeTemplate)
|
||||
baRouter.POST("/template/search", baseApi.SearchComposeTemplate)
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
v2 "github.com/1Panel-dev/1Panel/agent/app/api/v2"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type WebsiteTemplateRouter struct {
|
||||
}
|
||||
|
||||
func (a *WebsiteTemplateRouter) InitRouter(Router *gin.RouterGroup) {
|
||||
groupRouter := Router.Group("websites/templates")
|
||||
|
||||
baseApi := v2.ApiGroupApp.BaseApi
|
||||
{
|
||||
groupRouter.POST("/search", baseApi.PageWebsiteTemplate)
|
||||
groupRouter.POST("", baseApi.CreateWebsiteTemplate)
|
||||
groupRouter.POST("/update", baseApi.UpdateWebsiteTemplate)
|
||||
groupRouter.POST("/del", baseApi.DeleteWebsiteTemplate)
|
||||
groupRouter.POST("/get", baseApi.GetWebsiteTemplate)
|
||||
groupRouter.POST("/upload", baseApi.UploadTemplateZip)
|
||||
groupRouter.POST("/preview", baseApi.PreviewWebsiteTemplate)
|
||||
groupRouter.POST("/outputs/search", baseApi.PageWebsiteTemplateOutput)
|
||||
groupRouter.POST("/outputs", baseApi.CreateWebsiteTemplateOutput)
|
||||
groupRouter.POST("/outputs/del", baseApi.DeleteWebsiteTemplateOutput)
|
||||
groupRouter.POST("/outputs/get", baseApi.GetWebsiteTemplateOutput)
|
||||
}
|
||||
}
|
||||
@@ -38,32 +38,65 @@ func getComposeBaseCmd() (string, []string) {
|
||||
return parts[0], parts[1:]
|
||||
}
|
||||
|
||||
func Up(filePath string) (string, error) {
|
||||
func Up(filePath string, projectName ...string) (string, error) {
|
||||
if err := checkCmd(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
base, extra := getComposeBaseCmd()
|
||||
args := append(extra, loadFiles(filePath)...)
|
||||
args = append(args, "up", "-d")
|
||||
args := appendProjectName(append([]string(nil), extra...), projectName)
|
||||
args = append(args, upArgs(filePath, false)...)
|
||||
return cmd.NewCommandMgr(cmd.WithTimeout(20*time.Minute)).RunWithStdout(base, args...)
|
||||
}
|
||||
|
||||
func UpWithTask(filePath string, task *task.Task, forcePull bool) error {
|
||||
if err := PullComposeImages(filePath, forcePull, task); err != nil {
|
||||
func UpWithoutPull(filePath string, projectName ...string) (string, error) {
|
||||
if err := checkCmd(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
base, extra := getComposeBaseCmd()
|
||||
args := appendProjectName(append([]string(nil), extra...), projectName)
|
||||
args = append(args, upArgs(filePath, true)...)
|
||||
return cmd.NewCommandMgr(cmd.WithTimeout(20*time.Minute)).RunWithStdout(base, args...)
|
||||
}
|
||||
|
||||
func upArgs(filePath string, withoutPull bool) []string {
|
||||
args := loadFiles(filePath)
|
||||
args = append(args, "up", "-d")
|
||||
if withoutPull {
|
||||
args = append(args, "--pull", "never", "--no-build")
|
||||
}
|
||||
return args
|
||||
}
|
||||
|
||||
func UpWithTask(filePath string, task *task.Task, forcePull bool, projectName ...string) error {
|
||||
if err := PullComposeImages(filePath, forcePull, task, projectName...); err != nil {
|
||||
return err
|
||||
}
|
||||
base, extra := getComposeBaseCmd()
|
||||
args := append(extra, loadFiles(filePath)...)
|
||||
args = append(args, "up", "-d")
|
||||
args := appendProjectName(append([]string(nil), extra...), projectName)
|
||||
args = append(args, upArgs(filePath, false)...)
|
||||
return cmd.NewCommandMgr(cmd.WithTask(*task), cmd.WithTimeout(20*time.Minute)).Run(base, args...)
|
||||
}
|
||||
|
||||
func PullComposeImages(filePath string, forcePull bool, task *task.Task) error {
|
||||
return pullComposeImages(filePath, forcePull, task)
|
||||
func BuildWithTask(filePath, projectName string, task *task.Task) error {
|
||||
if err := checkCmd(); err != nil {
|
||||
return err
|
||||
}
|
||||
base, extra := getComposeBaseCmd()
|
||||
args := append([]string(nil), extra...)
|
||||
if projectName != "" {
|
||||
args = append(args, "--project-name", projectName)
|
||||
}
|
||||
args = append(args, loadFiles(filePath)...)
|
||||
args = append(args, "build")
|
||||
return cmd.NewCommandMgr(cmd.WithTask(*task), cmd.WithTimeout(120*time.Minute)).Run(base, args...)
|
||||
}
|
||||
|
||||
func pullComposeImages(filePath string, forcePull bool, task *task.Task) error {
|
||||
images, err := GetComposeImages(filePath)
|
||||
func PullComposeImages(filePath string, forcePull bool, task *task.Task, projectName ...string) error {
|
||||
return pullComposeImages(filePath, forcePull, task, projectName...)
|
||||
}
|
||||
|
||||
func pullComposeImages(filePath string, forcePull bool, task *task.Task, projectName ...string) error {
|
||||
images, err := GetComposeImages(filePath, projectName...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -120,8 +153,8 @@ func pullComposeImages(filePath string, forcePull bool, task *task.Task) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func GetComposeImages(filePath string) ([]string, error) {
|
||||
images, err := getComposeImagesByCommand(filePath)
|
||||
func GetComposeImages(filePath string, projectName ...string) ([]string, error) {
|
||||
images, err := getComposeImagesByCommand(filePath, projectName...)
|
||||
if err == nil {
|
||||
return images, nil
|
||||
}
|
||||
@@ -138,12 +171,13 @@ func GetComposeImages(filePath string) ([]string, error) {
|
||||
return images, nil
|
||||
}
|
||||
|
||||
func getComposeImagesByCommand(filePath string) ([]string, error) {
|
||||
func getComposeImagesByCommand(filePath string, projectName ...string) ([]string, error) {
|
||||
if err := checkCmd(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
base, extra := getComposeBaseCmd()
|
||||
args := append(extra, loadFiles(filePath)...)
|
||||
args := appendProjectName(append([]string(nil), extra...), projectName)
|
||||
args = append(args, loadFiles(filePath)...)
|
||||
args = append(args, "config", "--format", "json", "--no-normalize")
|
||||
stdout, err := cmd.NewCommandMgr(cmd.WithTimeout(5*time.Minute)).
|
||||
RunWithStdout(base, args...)
|
||||
@@ -179,64 +213,77 @@ func getComposeImagesByCommand(filePath string) ([]string, error) {
|
||||
return images, nil
|
||||
}
|
||||
|
||||
func Down(filePath string) (string, error) {
|
||||
func Down(filePath string, projectName ...string) (string, error) {
|
||||
if err := checkCmd(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
base, extra := getComposeBaseCmd()
|
||||
args := append(extra, loadFiles(filePath)...)
|
||||
args := appendProjectName(append([]string(nil), extra...), projectName)
|
||||
args = append(args, loadFiles(filePath)...)
|
||||
args = append(args, "down", "--remove-orphans")
|
||||
return cmd.NewCommandMgr(cmd.WithTimeout(20*time.Minute)).RunWithStdout(base, args...)
|
||||
}
|
||||
|
||||
func Stop(filePath string) (string, error) {
|
||||
func Stop(filePath string, projectName ...string) (string, error) {
|
||||
if err := checkCmd(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
base, extra := getComposeBaseCmd()
|
||||
args := append(extra, loadFiles(filePath)...)
|
||||
args := appendProjectName(append([]string(nil), extra...), projectName)
|
||||
args = append(args, loadFiles(filePath)...)
|
||||
args = append(args, "stop")
|
||||
return cmd.NewCommandMgr(cmd.WithTimeout(20*time.Minute)).RunWithStdout(base, args...)
|
||||
}
|
||||
|
||||
func Restart(filePath string) (string, error) {
|
||||
func Restart(filePath string, projectName ...string) (string, error) {
|
||||
if err := checkCmd(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
base, extra := getComposeBaseCmd()
|
||||
args := append(extra, loadFiles(filePath)...)
|
||||
args := appendProjectName(append([]string(nil), extra...), projectName)
|
||||
args = append(args, loadFiles(filePath)...)
|
||||
args = append(args, "restart")
|
||||
return cmd.NewCommandMgr(cmd.WithTimeout(20*time.Minute)).RunWithStdout(base, args...)
|
||||
}
|
||||
|
||||
func Operate(filePath, operation string) (string, error) {
|
||||
func Operate(filePath, operation string, projectName ...string) (string, error) {
|
||||
if err := checkCmd(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
base, extra := getComposeBaseCmd()
|
||||
args := append(extra, loadFiles(filePath)...)
|
||||
args := appendProjectName(append([]string(nil), extra...), projectName)
|
||||
args = append(args, loadFiles(filePath)...)
|
||||
args = append(args, operation)
|
||||
return cmd.NewCommandMgr(cmd.WithTimeout(20*time.Minute)).RunWithStdout(base, args...)
|
||||
}
|
||||
|
||||
func DownAndUp(filePath string) (string, error) {
|
||||
func DownAndUp(filePath string, projectName ...string) (string, error) {
|
||||
if err := checkCmd(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
cmdMgr := cmd.NewCommandMgr(cmd.WithTimeout(20 * time.Minute))
|
||||
base, extra := getComposeBaseCmd()
|
||||
argsDown := append(extra, loadFiles(filePath)...)
|
||||
argsDown := appendProjectName(append([]string(nil), extra...), projectName)
|
||||
argsDown = append(argsDown, loadFiles(filePath)...)
|
||||
argsDown = append(argsDown, "down")
|
||||
stdout, err := cmdMgr.RunWithStdout(base, argsDown...)
|
||||
if err != nil {
|
||||
return stdout, err
|
||||
}
|
||||
argsUp := append(extra, loadFiles(filePath)...)
|
||||
argsUp := appendProjectName(append([]string(nil), extra...), projectName)
|
||||
argsUp = append(argsUp, loadFiles(filePath)...)
|
||||
argsUp = append(argsUp, "up", "-d")
|
||||
stdout, err = cmdMgr.RunWithStdout(base, argsUp...)
|
||||
return stdout, err
|
||||
}
|
||||
|
||||
func appendProjectName(args []string, projectName []string) []string {
|
||||
if len(projectName) > 0 && strings.TrimSpace(projectName[0]) != "" {
|
||||
args = append(args, "--project-name", projectName[0])
|
||||
}
|
||||
return args
|
||||
}
|
||||
|
||||
func loadFiles(filePath string) []string {
|
||||
var fileItem []string
|
||||
for _, item := range strings.Split(filePath, ",") {
|
||||
|
||||
@@ -14,7 +14,7 @@ type ShellArchiver interface {
|
||||
|
||||
func NewShellArchiver(compressType CompressType) (ShellArchiver, error) {
|
||||
switch compressType {
|
||||
case Tar:
|
||||
case Tar, Gz, Bz2, TarBz2, Tgz, Xz, TarXz:
|
||||
if err := checkCmdAvailability("tar"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -46,7 +46,7 @@ func NewShellArchiver(compressType CompressType) (ShellArchiver, error) {
|
||||
|
||||
func NewExtractShellArchiver(compressType CompressType) (ShellArchiver, error) {
|
||||
switch compressType {
|
||||
case Tar:
|
||||
case Tar, Gz, Bz2, TarBz2, Tgz, Xz, TarXz:
|
||||
if err := checkCmdAvailability("tar"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
+503
-95
@@ -1,11 +1,13 @@
|
||||
package files
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"archive/zip"
|
||||
"bufio"
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -20,6 +22,7 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/1Panel-dev/1Panel/agent/buserr"
|
||||
@@ -38,8 +41,9 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
cmdDefaultTimeout = 10 * time.Second
|
||||
cmdRecursiveTimeout = 5 * time.Minute
|
||||
cmdDefaultTimeout = 10 * time.Second
|
||||
cmdRecursiveTimeout = 5 * time.Minute
|
||||
maxArchiveSymlinkTargetSize = 4 * 1024
|
||||
)
|
||||
|
||||
var protectedPaths = []string{
|
||||
@@ -953,6 +957,12 @@ func (f FileOp) Compress(ctx context.Context, srcRiles []string, dst string, nam
|
||||
}
|
||||
_ = f.DeleteFile(dstFile)
|
||||
return NewZipArchiver().Compress(ctx, srcRiles, dstFile, "")
|
||||
case Tar, Gz, Bz2, TarBz2, Tgz, Xz, TarXz:
|
||||
err = NewTarArchiver(cType).Compress(ctx, srcRiles, dstFile, secret)
|
||||
if err != nil {
|
||||
_ = f.DeleteFile(dstFile)
|
||||
return err
|
||||
}
|
||||
case TarGz:
|
||||
err = NewTarGzArchiver().Compress(ctx, srcRiles, dstFile, secret)
|
||||
if err != nil {
|
||||
@@ -1031,20 +1041,217 @@ func decodeGBK(input string) (string, error) {
|
||||
return decoded, nil
|
||||
}
|
||||
|
||||
func (f FileOp) decompressWithSDK(ctx context.Context, srcFile string, dst string, cType CompressType) error {
|
||||
format := getFormat(cType)
|
||||
if cType == Gz {
|
||||
if err := f.tryDecompressTarGz(ctx, srcFile, dst, format); err == nil {
|
||||
return nil
|
||||
}
|
||||
return f.DecompressGzFile(ctx, srcFile, dst)
|
||||
}
|
||||
type DecompressOptions struct {
|
||||
PreserveOwner bool
|
||||
AllowCLIReextract bool
|
||||
}
|
||||
|
||||
type archiveOwnership struct {
|
||||
uid int
|
||||
gid int
|
||||
}
|
||||
|
||||
func getArchiveOwnership(file archiver.File) (archiveOwnership, bool) {
|
||||
header, ok := getArchiveTarHeader(file)
|
||||
if ok && header.Uid >= 0 && header.Gid >= 0 {
|
||||
return archiveOwnership{uid: header.Uid, gid: header.Gid}, true
|
||||
}
|
||||
return getArchiveZipOwnership(file)
|
||||
}
|
||||
|
||||
const zipUnixOwnershipExtraID = 0x7875
|
||||
|
||||
func getArchiveZipOwnership(file archiver.File) (archiveOwnership, bool) {
|
||||
var extra []byte
|
||||
switch header := file.Header.(type) {
|
||||
case *zip.FileHeader:
|
||||
if header != nil {
|
||||
extra = header.Extra
|
||||
}
|
||||
case zip.FileHeader:
|
||||
extra = header.Extra
|
||||
case *cZip.FileHeader:
|
||||
if header != nil {
|
||||
extra = header.Extra
|
||||
}
|
||||
case cZip.FileHeader:
|
||||
extra = header.Extra
|
||||
default:
|
||||
return archiveOwnership{}, false
|
||||
}
|
||||
for len(extra) >= 4 {
|
||||
fieldID := binary.LittleEndian.Uint16(extra[:2])
|
||||
fieldSize := int(binary.LittleEndian.Uint16(extra[2:4]))
|
||||
extra = extra[4:]
|
||||
if fieldSize > len(extra) {
|
||||
return archiveOwnership{}, false
|
||||
}
|
||||
field := extra[:fieldSize]
|
||||
extra = extra[fieldSize:]
|
||||
if fieldID != zipUnixOwnershipExtraID || len(field) < 4 || field[0] != 1 {
|
||||
continue
|
||||
}
|
||||
uidSize := int(field[1])
|
||||
if uidSize == 0 || uidSize > 4 || len(field) < 2+uidSize+1 {
|
||||
return archiveOwnership{}, false
|
||||
}
|
||||
uid := decodeZipOwnershipID(field[2 : 2+uidSize])
|
||||
gidSizeOffset := 2 + uidSize
|
||||
gidSize := int(field[gidSizeOffset])
|
||||
if gidSize == 0 || gidSize > 4 || len(field) < gidSizeOffset+1+gidSize {
|
||||
return archiveOwnership{}, false
|
||||
}
|
||||
gid := decodeZipOwnershipID(field[gidSizeOffset+1 : gidSizeOffset+1+gidSize])
|
||||
return archiveOwnership{uid: int(uid), gid: int(gid)}, true
|
||||
}
|
||||
return archiveOwnership{}, false
|
||||
}
|
||||
|
||||
func decodeZipOwnershipID(value []byte) uint32 {
|
||||
var result uint32
|
||||
for i := len(value) - 1; i >= 0; i-- {
|
||||
result = result<<8 | uint32(value[i])
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func appendZipOwnershipExtra(extra []byte, ownership archiveOwnership) []byte {
|
||||
const fieldSize = 11
|
||||
field := make([]byte, 4+fieldSize)
|
||||
binary.LittleEndian.PutUint16(field[:2], zipUnixOwnershipExtraID)
|
||||
binary.LittleEndian.PutUint16(field[2:4], fieldSize)
|
||||
field[4] = 1
|
||||
field[5] = 4
|
||||
binary.LittleEndian.PutUint32(field[6:10], uint32(ownership.uid))
|
||||
field[10] = 4
|
||||
binary.LittleEndian.PutUint32(field[11:15], uint32(ownership.gid))
|
||||
return append(extra, field...)
|
||||
}
|
||||
|
||||
func getFileOwnership(info fs.FileInfo) (archiveOwnership, bool) {
|
||||
stat, ok := info.Sys().(*syscall.Stat_t)
|
||||
if !ok {
|
||||
return archiveOwnership{}, false
|
||||
}
|
||||
return archiveOwnership{uid: int(stat.Uid), gid: int(stat.Gid)}, true
|
||||
}
|
||||
|
||||
func getArchiveTarHeader(file archiver.File) (tar.Header, bool) {
|
||||
switch header := file.Header.(type) {
|
||||
case *tar.Header:
|
||||
if header == nil {
|
||||
return tar.Header{}, false
|
||||
}
|
||||
return *header, true
|
||||
case tar.Header:
|
||||
return header, true
|
||||
default:
|
||||
return tar.Header{}, false
|
||||
}
|
||||
}
|
||||
|
||||
func applyArchiveOwnership(filePath string, mode fs.FileMode, ownership archiveOwnership) error {
|
||||
if mode&fs.ModeSymlink != 0 {
|
||||
return os.Lchown(filePath, ownership.uid, ownership.gid)
|
||||
}
|
||||
return os.Chown(filePath, ownership.uid, ownership.gid)
|
||||
}
|
||||
|
||||
func archiveDestinationPath(dst, name string) (string, error) {
|
||||
cleaned := filepath.Clean(filepath.FromSlash(name))
|
||||
if cleaned == "." {
|
||||
return dst, nil
|
||||
}
|
||||
if filepath.IsAbs(cleaned) || cleaned == ".." || strings.HasPrefix(cleaned, ".."+string(os.PathSeparator)) {
|
||||
return "", fmt.Errorf("invalid archive path: %s", name)
|
||||
}
|
||||
return filepath.Join(dst, cleaned), nil
|
||||
}
|
||||
|
||||
func ensureArchiveDirectory(dirPath string, mode fs.FileMode) error {
|
||||
info, err := os.Lstat(dirPath)
|
||||
if err == nil {
|
||||
if info.Mode()&fs.ModeSymlink != 0 || !info.IsDir() {
|
||||
return fmt.Errorf("archive directory path is not a directory: %s", dirPath)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
return os.Mkdir(dirPath, mode.Perm())
|
||||
}
|
||||
|
||||
func ensureArchiveParent(dst, filePath string) error {
|
||||
root := filepath.Clean(dst)
|
||||
parent := filepath.Dir(filePath)
|
||||
rel, err := filepath.Rel(root, parent)
|
||||
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) {
|
||||
return fmt.Errorf("archive parent escapes destination: %s", parent)
|
||||
}
|
||||
if err := ensureArchiveDirectory(root, constant.DirPerm); err != nil {
|
||||
return err
|
||||
}
|
||||
if rel == "." {
|
||||
return nil
|
||||
}
|
||||
current := root
|
||||
for _, part := range strings.Split(rel, string(os.PathSeparator)) {
|
||||
current = filepath.Join(current, part)
|
||||
if err := ensureArchiveDirectory(current, constant.DirPerm); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateArchiveHardlinkTarget(dst, targetPath string) error {
|
||||
root := filepath.Clean(dst)
|
||||
rel, err := filepath.Rel(root, targetPath)
|
||||
if err != nil || rel == "." || rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) {
|
||||
return fmt.Errorf("archive hardlink target escapes destination: %s", targetPath)
|
||||
}
|
||||
current := root
|
||||
parts := strings.Split(rel, string(os.PathSeparator))
|
||||
for i, part := range parts {
|
||||
current = filepath.Join(current, part)
|
||||
info, err := os.Lstat(current)
|
||||
if err != nil {
|
||||
return fmt.Errorf("archive hardlink target is unavailable: %s: %w", targetPath, err)
|
||||
}
|
||||
if info.Mode()&fs.ModeSymlink != 0 {
|
||||
return fmt.Errorf("archive hardlink target contains a symlink: %s", current)
|
||||
}
|
||||
if i < len(parts)-1 && !info.IsDir() {
|
||||
return fmt.Errorf("archive hardlink target parent is not a directory: %s", current)
|
||||
}
|
||||
if i == len(parts)-1 && !info.Mode().IsRegular() {
|
||||
return fmt.Errorf("archive hardlink target is not a regular file: %s", current)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f FileOp) extractArchiveWithSDK(ctx context.Context, input io.Reader, dst string, extractor archiver.Extractor, options DecompressOptions) (bool, error) {
|
||||
type dirEntry struct {
|
||||
path string
|
||||
modTime time.Time
|
||||
path string
|
||||
mode fs.FileMode
|
||||
modTime time.Time
|
||||
ownership archiveOwnership
|
||||
hasOwnership bool
|
||||
}
|
||||
type hardlinkEntry struct {
|
||||
path string
|
||||
target string
|
||||
mode fs.FileMode
|
||||
modTime time.Time
|
||||
ownership archiveOwnership
|
||||
hasOwnership bool
|
||||
}
|
||||
var dirs []dirEntry
|
||||
var hardlinks []hardlinkEntry
|
||||
extractionStarted := false
|
||||
root := filepath.Clean(dst)
|
||||
|
||||
handler := func(ctx context.Context, archFile archiver.File) error {
|
||||
info := archFile.FileInfo
|
||||
@@ -1061,67 +1268,320 @@ func (f FileOp) decompressWithSDK(ctx context.Context, srcFile string, dst strin
|
||||
}
|
||||
}
|
||||
}
|
||||
filePath := filepath.Join(dst, fileName)
|
||||
header, hasTarHeader := getArchiveTarHeader(archFile)
|
||||
ownership, hasOwnership := getArchiveOwnership(archFile)
|
||||
filePath, err := archiveDestinationPath(dst, fileName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
extractionStarted = true
|
||||
if archFile.FileInfo.IsDir() {
|
||||
if err := f.Fs.MkdirAll(filePath, info.Mode()); err != nil {
|
||||
if filePath == root {
|
||||
return ensureArchiveDirectory(root, constant.DirPerm)
|
||||
}
|
||||
if err := ensureArchiveParent(dst, filePath); err != nil {
|
||||
return err
|
||||
}
|
||||
dirs = append(dirs, dirEntry{path: filePath, modTime: info.ModTime()})
|
||||
if err := ensureArchiveDirectory(filePath, info.Mode()); err != nil {
|
||||
return err
|
||||
}
|
||||
dirs = append(dirs, dirEntry{
|
||||
path: filePath,
|
||||
mode: info.Mode(),
|
||||
modTime: info.ModTime(),
|
||||
ownership: ownership,
|
||||
hasOwnership: hasOwnership,
|
||||
})
|
||||
return nil
|
||||
} else {
|
||||
parentDir := path.Dir(filePath)
|
||||
if !f.Stat(parentDir) {
|
||||
if err := f.Fs.MkdirAll(parentDir, constant.DirPerm); err != nil {
|
||||
}
|
||||
|
||||
if hasTarHeader && header.Typeflag == tar.TypeLink {
|
||||
target, err := archiveDestinationPath(dst, header.Linkname)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
hardlinks = append(hardlinks, hardlinkEntry{
|
||||
path: filePath,
|
||||
target: target,
|
||||
mode: info.Mode(),
|
||||
modTime: info.ModTime(),
|
||||
ownership: ownership,
|
||||
hasOwnership: hasOwnership,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := ensureArchiveParent(dst, filePath); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if info.Mode()&fs.ModeSymlink != 0 || hasTarHeader && header.Typeflag == tar.TypeSymlink {
|
||||
target := archFile.LinkTarget
|
||||
if target == "" && hasTarHeader {
|
||||
target = header.Linkname
|
||||
}
|
||||
if target == "" && archFile.Open != nil {
|
||||
fr, err := archFile.Open()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, readErr := io.ReadAll(io.LimitReader(fr, maxArchiveSymlinkTargetSize+1))
|
||||
closeErr := fr.Close()
|
||||
if readErr != nil {
|
||||
return readErr
|
||||
}
|
||||
if closeErr != nil {
|
||||
return closeErr
|
||||
}
|
||||
target = string(data)
|
||||
}
|
||||
if len(target) > maxArchiveSymlinkTargetSize {
|
||||
return fmt.Errorf("archive symlink target for %s exceeds %d bytes", fileName, maxArchiveSymlinkTargetSize)
|
||||
}
|
||||
if target == "" {
|
||||
return fmt.Errorf("archive symlink %s has no target", fileName)
|
||||
}
|
||||
if err := os.RemoveAll(filePath); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Symlink(target, filePath); err != nil {
|
||||
return err
|
||||
}
|
||||
if options.PreserveOwner && hasOwnership {
|
||||
if err := applyArchiveOwnership(filePath, info.Mode(), ownership); err != nil {
|
||||
return fmt.Errorf("restore archive ownership for %s: %w", fileName, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if existing, err := os.Lstat(filePath); err == nil {
|
||||
if existing.IsDir() {
|
||||
return fmt.Errorf("archive file path is a directory: %s", fileName)
|
||||
}
|
||||
if err := os.Remove(filePath); err != nil {
|
||||
return err
|
||||
}
|
||||
} else if !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
|
||||
fr, err := archFile.Open()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer fr.Close()
|
||||
fw, err := f.Fs.OpenFile(filePath, os.O_CREATE|os.O_RDWR|os.O_TRUNC, info.Mode())
|
||||
fw, err := f.Fs.OpenFile(filePath, os.O_CREATE|os.O_RDWR|os.O_TRUNC, info.Mode().Perm())
|
||||
if err != nil {
|
||||
_ = fr.Close()
|
||||
return err
|
||||
}
|
||||
defer fw.Close()
|
||||
if _, err := io.Copy(fw, fr); err != nil {
|
||||
return err
|
||||
_, copyErr := io.Copy(fw, fr)
|
||||
closeReadErr := fr.Close()
|
||||
closeWriteErr := fw.Close()
|
||||
if copyErr != nil {
|
||||
return copyErr
|
||||
}
|
||||
if closeReadErr != nil {
|
||||
return closeReadErr
|
||||
}
|
||||
if closeWriteErr != nil {
|
||||
return closeWriteErr
|
||||
}
|
||||
if options.PreserveOwner && hasOwnership {
|
||||
if err := applyArchiveOwnership(filePath, info.Mode(), ownership); err != nil {
|
||||
return fmt.Errorf("restore archive ownership for %s: %w", fileName, err)
|
||||
}
|
||||
}
|
||||
if err := f.Fs.Chmod(filePath, info.Mode().Perm()); err != nil {
|
||||
return fmt.Errorf("restore archive mode for %s: %w", fileName, err)
|
||||
}
|
||||
_ = os.Chtimes(filePath, info.ModTime(), info.ModTime())
|
||||
return nil
|
||||
}
|
||||
if err := extractor.Extract(ctx, input, nil, handler); err != nil {
|
||||
return extractionStarted, err
|
||||
}
|
||||
for _, link := range hardlinks {
|
||||
if err := ensureArchiveParent(dst, link.path); err != nil {
|
||||
return extractionStarted, err
|
||||
}
|
||||
if err := validateArchiveHardlinkTarget(dst, link.target); err != nil {
|
||||
return extractionStarted, err
|
||||
}
|
||||
if err := os.RemoveAll(link.path); err != nil {
|
||||
return extractionStarted, err
|
||||
}
|
||||
if err := os.Link(link.target, link.path); err != nil {
|
||||
return extractionStarted, fmt.Errorf("restore archive hardlink %s: %w", link.path, err)
|
||||
}
|
||||
if options.PreserveOwner && link.hasOwnership {
|
||||
if err := applyArchiveOwnership(link.path, link.mode, link.ownership); err != nil {
|
||||
return extractionStarted, fmt.Errorf("restore archive ownership for %s: %w", link.path, err)
|
||||
}
|
||||
}
|
||||
if err := f.Fs.Chmod(link.path, link.mode.Perm()); err != nil {
|
||||
return extractionStarted, fmt.Errorf("restore archive mode for %s: %w", link.path, err)
|
||||
}
|
||||
_ = os.Chtimes(link.path, link.modTime, link.modTime)
|
||||
}
|
||||
for i := len(dirs) - 1; i >= 0; i-- {
|
||||
if options.PreserveOwner && dirs[i].hasOwnership {
|
||||
if err := applyArchiveOwnership(dirs[i].path, dirs[i].mode, dirs[i].ownership); err != nil {
|
||||
return extractionStarted, fmt.Errorf("restore archive ownership for %s: %w", dirs[i].path, err)
|
||||
}
|
||||
}
|
||||
if err := f.Fs.Chmod(dirs[i].path, dirs[i].mode.Perm()); err != nil {
|
||||
return extractionStarted, fmt.Errorf("restore archive mode for %s: %w", dirs[i].path, err)
|
||||
}
|
||||
_ = os.Chtimes(dirs[i].path, dirs[i].modTime, dirs[i].modTime)
|
||||
}
|
||||
return extractionStarted, nil
|
||||
}
|
||||
|
||||
func (f FileOp) decompressWithSDKState(ctx context.Context, srcFile string, dst string, cType CompressType, secret string, options DecompressOptions) (bool, error) {
|
||||
input, err := f.Fs.Open(srcFile)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
var extractor archiver.Extractor = getFormat(cType)
|
||||
if cType == X7z {
|
||||
extractor = archiver.SevenZip{Password: secret}
|
||||
}
|
||||
extractionStarted, extractErr := f.extractArchiveWithSDK(ctx, input, dst, extractor, options)
|
||||
closeErr := input.Close()
|
||||
if cType == Gz {
|
||||
if extractErr == nil && extractionStarted {
|
||||
return true, closeErr
|
||||
}
|
||||
if extractionStarted {
|
||||
return true, extractErr
|
||||
}
|
||||
return false, f.DecompressGzFile(ctx, srcFile, dst)
|
||||
}
|
||||
if extractErr != nil {
|
||||
return extractionStarted, extractErr
|
||||
}
|
||||
if closeErr != nil {
|
||||
return extractionStarted, closeErr
|
||||
}
|
||||
return extractionStarted, nil
|
||||
}
|
||||
|
||||
func (f FileOp) decompressWithSDK(ctx context.Context, srcFile string, dst string, cType CompressType, secret string, options DecompressOptions) error {
|
||||
_, err := f.decompressWithSDKState(ctx, srcFile, dst, cType, secret, options)
|
||||
return err
|
||||
}
|
||||
|
||||
func resetArchiveFallbackDestination(dst string) error {
|
||||
info, err := os.Lstat(dst)
|
||||
if os.IsNotExist(err) {
|
||||
return os.MkdirAll(dst, constant.DirPerm)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer input.Close()
|
||||
if err := format.Extract(ctx, input, nil, handler); err != nil {
|
||||
if info.Mode()&fs.ModeSymlink != 0 || !info.IsDir() {
|
||||
return fmt.Errorf("archive fallback destination is not a directory: %s", dst)
|
||||
}
|
||||
mode := info.Mode().Perm()
|
||||
ownership, hasOwnership := getFileOwnership(info)
|
||||
if err := os.RemoveAll(dst); err != nil {
|
||||
return err
|
||||
}
|
||||
for i := len(dirs) - 1; i >= 0; i-- {
|
||||
_ = os.Chtimes(dirs[i].path, dirs[i].modTime, dirs[i].modTime)
|
||||
if err := os.MkdirAll(dst, mode); err != nil {
|
||||
return err
|
||||
}
|
||||
if hasOwnership {
|
||||
if err := os.Chown(dst, ownership.uid, ownership.gid); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return os.Chmod(dst, mode)
|
||||
}
|
||||
|
||||
func (f FileOp) decompressSevenZipWithFallback(ctx context.Context, srcFile, dst, secret string, options DecompressOptions) error {
|
||||
return f.decompressSevenZipWithFallbackUsing(ctx, srcFile, dst, secret, options, f.decompressWithSDKState)
|
||||
}
|
||||
|
||||
func (f FileOp) decompressSevenZipWithFallbackUsing(
|
||||
ctx context.Context,
|
||||
srcFile, dst, secret string,
|
||||
options DecompressOptions,
|
||||
sdkExtract func(context.Context, string, string, CompressType, string, DecompressOptions) (bool, error),
|
||||
) error {
|
||||
extractionStarted, sdkErr := sdkExtract(ctx, srcFile, dst, X7z, secret, options)
|
||||
if sdkErr == nil {
|
||||
return nil
|
||||
}
|
||||
if secret != "" {
|
||||
return sdkErr
|
||||
}
|
||||
if extractionStarted && !options.AllowCLIReextract {
|
||||
return sdkErr
|
||||
}
|
||||
if extractionStarted {
|
||||
if err := resetArchiveFallbackDestination(dst); err != nil {
|
||||
return fmt.Errorf("reset 7z CLI fallback destination: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
shellArchiver, err := NewExtractShellArchiver(X7z)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if global.LOG != nil {
|
||||
global.LOG.Warnf("7z SDK decompression failed, falling back to CLI: %v", sdkErr)
|
||||
}
|
||||
if err := shellArchiver.Extract(ctx, srcFile, dst, secret); err != nil {
|
||||
return fmt.Errorf("7z SDK decompression failed: %v; CLI fallback failed: %w", sdkErr, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type ownershipPreservingShellExtractor interface {
|
||||
ExtractWithOptions(ctx context.Context, filePath, dstDir, secret string, preserveOwner bool) error
|
||||
}
|
||||
|
||||
func extractWithShellOptions(ctx context.Context, shellArchiver ShellArchiver, srcFile, dst, secret string, options DecompressOptions) error {
|
||||
if options.PreserveOwner {
|
||||
if extractor, ok := shellArchiver.(ownershipPreservingShellExtractor); ok {
|
||||
return extractor.ExtractWithOptions(ctx, srcFile, dst, secret, true)
|
||||
}
|
||||
}
|
||||
return shellArchiver.Extract(ctx, srcFile, dst, secret)
|
||||
}
|
||||
|
||||
func (f FileOp) Decompress(ctx context.Context, srcFile string, dst string, cType CompressType, secret string) error {
|
||||
if cType == Tar || cType == Zip || cType == TarGz || cType == Rar || cType == X7z {
|
||||
return f.DecompressWithOptions(ctx, srcFile, dst, cType, secret, DecompressOptions{})
|
||||
}
|
||||
|
||||
func (f FileOp) DecompressWithOptions(ctx context.Context, srcFile string, dst string, cType CompressType, secret string, options DecompressOptions) error {
|
||||
if cType == X7z && options.PreserveOwner {
|
||||
return f.decompressSevenZipWithFallback(ctx, srcFile, dst, secret, options)
|
||||
}
|
||||
|
||||
var shellErr error
|
||||
useShell := cType == Rar || cType == Zip || cType == Tar || cType == TarGz ||
|
||||
!options.PreserveOwner && cType == X7z
|
||||
if useShell {
|
||||
shellArchiver, err := NewExtractShellArchiver(cType)
|
||||
if !f.Stat(dst) {
|
||||
_ = f.CreateDir(dst, 0755)
|
||||
}
|
||||
if err == nil {
|
||||
if err = shellArchiver.Extract(ctx, srcFile, dst, secret); err == nil {
|
||||
if err = extractWithShellOptions(ctx, shellArchiver, srcFile, dst, secret, options); err == nil {
|
||||
return nil
|
||||
}
|
||||
shellErr = err
|
||||
if cType == TarGz {
|
||||
if strings.Contains(err.Error(), "bad decrypt") {
|
||||
return buserr.New("ErrBadDecrypt")
|
||||
}
|
||||
if err := shellArchiver.Extract(ctx, srcFile, dst, "-"); strings.Contains(err.Error(), "bad decrypt") {
|
||||
if retryErr := extractWithShellOptions(ctx, shellArchiver, srcFile, dst, "-", options); retryErr == nil {
|
||||
return nil
|
||||
} else if strings.Contains(retryErr.Error(), "bad decrypt") {
|
||||
return buserr.New("ErrBadDecrypt")
|
||||
} else {
|
||||
shellErr = retryErr
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -1130,7 +1590,15 @@ func (f FileOp) Decompress(ctx context.Context, srcFile string, dst string, cTyp
|
||||
}
|
||||
}
|
||||
}
|
||||
return f.decompressWithSDK(ctx, srcFile, dst, cType)
|
||||
if shellErr != nil && global.LOG != nil {
|
||||
global.LOG.Warnf("shell decompression for %s failed, falling back to SDK: %v", cType, shellErr)
|
||||
}
|
||||
if shellErr != nil && options.AllowCLIReextract {
|
||||
if err := resetArchiveFallbackDestination(dst); err != nil {
|
||||
return fmt.Errorf("reset %s SDK fallback destination: %w", cType, err)
|
||||
}
|
||||
}
|
||||
return f.decompressWithSDK(ctx, srcFile, dst, cType, secret, options)
|
||||
}
|
||||
|
||||
func ZipFile(ctx context.Context, files []archiver.File, dst afero.File, progress func(current, total int, message string)) error {
|
||||
@@ -1152,6 +1620,9 @@ func ZipFile(ctx context.Context, files []archiver.File, dst afero.File, progres
|
||||
}
|
||||
hdr.Method = zip.Deflate
|
||||
hdr.Name = file.NameInArchive
|
||||
if ownership, ok := getFileOwnership(file.FileInfo); ok {
|
||||
hdr.Extra = appendZipOwnershipExtra(hdr.Extra, ownership)
|
||||
}
|
||||
if file.IsDir() {
|
||||
if !strings.HasSuffix(hdr.Name, "/") {
|
||||
hdr.Name += "/"
|
||||
@@ -1209,69 +1680,6 @@ func (r *contextReader) Read(p []byte) (int, error) {
|
||||
}
|
||||
}
|
||||
|
||||
func (f FileOp) tryDecompressTarGz(ctx context.Context, srcFile string, dst string, format archiver.CompressedArchive) error {
|
||||
input, err := f.Fs.Open(srcFile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer input.Close()
|
||||
|
||||
type dirEntry struct {
|
||||
path string
|
||||
modTime time.Time
|
||||
}
|
||||
var dirs []dirEntry
|
||||
extracted := false
|
||||
|
||||
handler := func(ctx context.Context, archFile archiver.File) error {
|
||||
info := archFile.FileInfo
|
||||
if isIgnoreFile(archFile.Name()) {
|
||||
return nil
|
||||
}
|
||||
filePath := filepath.Join(dst, archFile.NameInArchive)
|
||||
if info.IsDir() {
|
||||
if err := f.Fs.MkdirAll(filePath, info.Mode()); err != nil {
|
||||
return err
|
||||
}
|
||||
dirs = append(dirs, dirEntry{path: filePath, modTime: info.ModTime()})
|
||||
} else {
|
||||
parentDir := filepath.Dir(filePath)
|
||||
if !f.Stat(parentDir) {
|
||||
if err := f.Fs.MkdirAll(parentDir, constant.DirPerm); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
fr, err := archFile.Open()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer fr.Close()
|
||||
fw, err := f.Fs.OpenFile(filePath, os.O_CREATE|os.O_RDWR|os.O_TRUNC, info.Mode())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer fw.Close()
|
||||
if _, err := io.Copy(fw, fr); err != nil {
|
||||
return err
|
||||
}
|
||||
_ = os.Chtimes(filePath, info.ModTime(), info.ModTime())
|
||||
}
|
||||
extracted = true
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := format.Extract(ctx, input, nil, handler); err != nil {
|
||||
return err
|
||||
}
|
||||
if !extracted {
|
||||
return fmt.Errorf("no files extracted as tar.gz")
|
||||
}
|
||||
for i := len(dirs) - 1; i >= 0; i-- {
|
||||
_ = os.Chtimes(dirs[i].path, dirs[i].modTime, dirs[i].modTime)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f FileOp) DecompressGzFile(ctx context.Context, srcFile, dst string) error {
|
||||
var archiveModTime time.Time
|
||||
if st, err := f.Fs.Stat(srcFile); err == nil {
|
||||
|
||||
@@ -20,10 +20,19 @@ func NewRarArchiver() ShellArchiver {
|
||||
}
|
||||
|
||||
func (z RarArchiver) Extract(ctx context.Context, filePath, dstDir string, _ string) error {
|
||||
return z.ExtractWithOptions(ctx, filePath, dstDir, "", false)
|
||||
}
|
||||
|
||||
func (z RarArchiver) ExtractWithOptions(ctx context.Context, filePath, dstDir, _ string, preserveOwner bool) error {
|
||||
if err := checkCmdAvailability("unrar"); err != nil {
|
||||
return err
|
||||
}
|
||||
return cmd.NewCommandMgr(cmd.WithContext(ctx)).Run("unrar", "x", "-y", "-o+", filePath, dstDir)
|
||||
args := []string{"x", "-y", "-o+"}
|
||||
if preserveOwner {
|
||||
args = append(args, "-oh", "-ol", "-ow")
|
||||
}
|
||||
args = append(args, filePath, dstDir)
|
||||
return cmd.NewCommandMgr(cmd.WithContext(ctx)).Run("unrar", args...)
|
||||
}
|
||||
|
||||
func (z RarArchiver) Compress(ctx context.Context, sourcePaths []string, dstFile string, _ string) (err error) {
|
||||
@@ -45,7 +54,7 @@ func (z RarArchiver) Compress(ctx context.Context, sourcePaths []string, dstFile
|
||||
relativePaths[i] = path.Base(sp)
|
||||
}
|
||||
|
||||
cmdArgs := append([]string{"a", "-r", tmpFile}, relativePaths...)
|
||||
cmdArgs := append([]string{"a", "-r", "-oh", "-ol", "-ow", tmpFile}, relativePaths...)
|
||||
cmdMgr := cmd.NewCommandMgr(cmd.WithWorkDir(baseDir), cmd.WithContext(ctx))
|
||||
if err = cmdMgr.Run("rar", cmdArgs...); err != nil {
|
||||
return err
|
||||
|
||||
@@ -2,6 +2,8 @@ package files
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/1Panel-dev/1Panel/agent/utils/cmd"
|
||||
)
|
||||
@@ -19,21 +21,57 @@ func NewTarArchiver(compressType CompressType) ShellArchiver {
|
||||
}
|
||||
|
||||
func (t TarArchiver) Extract(ctx context.Context, FilePath string, dstDir string, secret string) error {
|
||||
return cmd.NewCommandMgr(cmd.WithContext(ctx)).Run(t.Cmd, t.getOptionStr("extract"), FilePath, "-C", dstDir)
|
||||
return t.ExtractWithOptions(ctx, FilePath, dstDir, secret, false)
|
||||
}
|
||||
|
||||
func (t TarArchiver) Compress(ctx context.Context, sourcePaths []string, dstFile string, secret string) error {
|
||||
return nil
|
||||
func (t TarArchiver) ExtractWithOptions(ctx context.Context, filePath, dstDir, _ string, preserveOwner bool) error {
|
||||
if err := checkCmdAvailability(t.Cmd); err != nil {
|
||||
return err
|
||||
}
|
||||
args := []string{t.getOptionStr("extract"), filePath, "-C", dstDir}
|
||||
if preserveOwner {
|
||||
args = append([]string{"--same-owner", "--same-permissions"}, args...)
|
||||
}
|
||||
return cmd.NewCommandMgr(cmd.WithContext(ctx)).Run(t.Cmd, args...)
|
||||
}
|
||||
|
||||
func (t TarArchiver) getOptionStr(Option string) string {
|
||||
func (t TarArchiver) Compress(ctx context.Context, sourcePaths []string, dstFile string, _ string) error {
|
||||
if len(sourcePaths) == 0 {
|
||||
return fmt.Errorf("source paths cannot be empty")
|
||||
}
|
||||
if err := checkCmdAvailability(t.Cmd); err != nil {
|
||||
return err
|
||||
}
|
||||
baseDir := filepath.Dir(sourcePaths[0])
|
||||
args := []string{t.getOptionStr("compress"), dstFile, "-C", baseDir}
|
||||
for _, sourcePath := range sourcePaths {
|
||||
args = append(args, filepath.Base(sourcePath))
|
||||
}
|
||||
return cmd.NewCommandMgr(cmd.WithContext(ctx)).Run(t.Cmd, args...)
|
||||
}
|
||||
|
||||
func (t TarArchiver) getOptionStr(option string) string {
|
||||
switch t.CompressType {
|
||||
case Tar:
|
||||
if Option == "compress" {
|
||||
return "cvf"
|
||||
} else {
|
||||
return "xf"
|
||||
if option == "compress" {
|
||||
return "-cf"
|
||||
}
|
||||
return "-xf"
|
||||
case Gz, Tgz, TarGz:
|
||||
if option == "compress" {
|
||||
return "-zcf"
|
||||
}
|
||||
return "-zxf"
|
||||
case Bz2, TarBz2:
|
||||
if option == "compress" {
|
||||
return "-jcf"
|
||||
}
|
||||
return "-jxf"
|
||||
case Xz, TarXz:
|
||||
if option == "compress" {
|
||||
return "-Jcf"
|
||||
}
|
||||
return "-Jxf"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -21,14 +21,18 @@ func NewTarGzArchiver() ShellArchiver {
|
||||
}
|
||||
|
||||
func (t TarGzArchiver) Extract(ctx context.Context, filePath, dstDir string, secret string) error {
|
||||
return t.ExtractWithOptions(ctx, filePath, dstDir, secret, false)
|
||||
}
|
||||
|
||||
func (t TarGzArchiver) ExtractWithOptions(ctx context.Context, filePath, dstDir, secret string, preserveOwner bool) error {
|
||||
if err := os.MkdirAll(dstDir, 0755); err != nil {
|
||||
return fmt.Errorf("failed to create destination dir: %w", err)
|
||||
}
|
||||
cmdMgr := cmd.NewCommandMgr(cmd.WithContext(ctx), cmd.WithIgnoreExist1())
|
||||
cmdMgr := cmd.NewCommandMgr(cmd.WithContext(ctx))
|
||||
if len(secret) != 0 {
|
||||
return runTarGzDecryptToDir(cmdMgr, filePath, dstDir, secret, false)
|
||||
return runTarGzDecryptToDirWithOptions(cmdMgr, filePath, dstDir, secret, false, preserveOwner)
|
||||
}
|
||||
return runTarGzExtractToDir(cmdMgr, filePath, dstDir)
|
||||
return runTarGzExtractToDirWithOptions(cmdMgr, filePath, dstDir, preserveOwner)
|
||||
}
|
||||
|
||||
func (t TarGzArchiver) Compress(ctx context.Context, sourcePaths []string, dstFile string, secret string) error {
|
||||
@@ -79,18 +83,34 @@ func runTarGzEncryptToFile(cmdMgr *cmd.CommandHelper, dst, secret string, tarArg
|
||||
}
|
||||
|
||||
func runTarGzExtractToDir(cmdMgr *cmd.CommandHelper, src, dst string) error {
|
||||
return cmdMgr.Run("tar", "-zxvf", src, "-C", dst)
|
||||
return runTarGzExtractToDirWithOptions(cmdMgr, src, dst, false)
|
||||
}
|
||||
|
||||
func runTarGzExtractToDirWithOptions(cmdMgr *cmd.CommandHelper, src, dst string, preserveOwner bool) error {
|
||||
args := []string{"-zxvf", src, "-C", dst}
|
||||
if preserveOwner {
|
||||
args = append([]string{"--same-owner", "--same-permissions"}, args...)
|
||||
}
|
||||
return cmdMgr.Run("tar", args...)
|
||||
}
|
||||
|
||||
func runTarGzDecryptToDir(cmdMgr *cmd.CommandHelper, src, dst, secret string, withSalt bool) error {
|
||||
return runTarGzDecryptToDirWithOptions(cmdMgr, src, dst, secret, withSalt, false)
|
||||
}
|
||||
|
||||
func runTarGzDecryptToDirWithOptions(cmdMgr *cmd.CommandHelper, src, dst, secret string, withSalt, preserveOwner bool) error {
|
||||
opensslArgs := []string{"enc", "-d", "-aes-256-cbc"}
|
||||
if withSalt {
|
||||
opensslArgs = append(opensslArgs, "-salt")
|
||||
}
|
||||
opensslArgs = append(opensslArgs, "-pass", "env:BACKUP_SECRET", "-in", src)
|
||||
tarArgs := []string{"-zxf", "-", "-C", dst}
|
||||
if preserveOwner {
|
||||
tarArgs = append([]string{"--same-owner", "--same-permissions"}, tarArgs...)
|
||||
}
|
||||
_, err := cmdMgr.RunPipe(
|
||||
cmd.PipeCommand{Name: "openssl", Args: opensslArgs, Env: []string{"BACKUP_SECRET=" + secret}},
|
||||
cmd.PipeCommand{Name: "tar", Args: []string{"-zxf", "-", "-C", dst}},
|
||||
cmd.PipeCommand{Name: "tar", Args: tarArgs},
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -45,7 +45,7 @@ func (z X7zArchiver) Compress(ctx context.Context, sourcePaths []string, dstFile
|
||||
relativePaths[i] = path.Base(sp)
|
||||
}
|
||||
|
||||
cmdArgs := append([]string{"a", "-r", tmpFile}, relativePaths...)
|
||||
cmdArgs := append([]string{"a", "-snh", "-snl", tmpFile}, relativePaths...)
|
||||
cmdMgr := cmd.NewCommandMgr(cmd.WithWorkDir(baseDir), cmd.WithContext(ctx))
|
||||
if err = cmdMgr.Run("7z", cmdArgs...); err != nil {
|
||||
return err
|
||||
|
||||
@@ -20,10 +20,19 @@ func NewZipArchiver() ShellArchiver {
|
||||
}
|
||||
|
||||
func (z ZipArchiver) Extract(ctx context.Context, filePath, dstDir string, secret string) error {
|
||||
return z.ExtractWithOptions(ctx, filePath, dstDir, secret, false)
|
||||
}
|
||||
|
||||
func (z ZipArchiver) ExtractWithOptions(ctx context.Context, filePath, dstDir, _ string, preserveOwner bool) error {
|
||||
if err := checkCmdAvailability("unzip"); err != nil {
|
||||
return err
|
||||
}
|
||||
return cmd.NewCommandMgr(cmd.WithContext(ctx)).Run("unzip", "-qo", filePath, "-d", dstDir)
|
||||
args := []string{"-qo"}
|
||||
if preserveOwner {
|
||||
args = append(args, "-X")
|
||||
}
|
||||
args = append(args, filePath, "-d", dstDir)
|
||||
return cmd.NewCommandMgr(cmd.WithContext(ctx)).Run("unzip", args...)
|
||||
}
|
||||
|
||||
func (z ZipArchiver) Compress(ctx context.Context, sourcePaths []string, dstFile string, _ string) error {
|
||||
|
||||
@@ -12,8 +12,10 @@ import (
|
||||
"github.com/1Panel-dev/1Panel/agent/utils/firewall/client"
|
||||
)
|
||||
|
||||
type FirewallClient interface {
|
||||
Name() string // ufw firewalld
|
||||
// FilterClient is the filter capability surface; port forwarding lives in its
|
||||
// own adapter and is no longer reachable from here.
|
||||
type FilterClient interface {
|
||||
Name() string // ufw firewalld iptables
|
||||
Start() error
|
||||
Stop() error
|
||||
Restart() error
|
||||
@@ -22,17 +24,13 @@ type FirewallClient interface {
|
||||
Version() (string, error)
|
||||
|
||||
ListPort() ([]client.FireInfo, error)
|
||||
ListForward() ([]client.FireInfo, error)
|
||||
ListAddress() ([]client.FireInfo, error)
|
||||
|
||||
Port(port client.FireInfo, operation string) error
|
||||
RichRules(rule client.FireInfo, operation string) error
|
||||
PortForward(info client.Forward, operation string) error
|
||||
|
||||
EnableForward() error
|
||||
}
|
||||
|
||||
func NewFirewallClient() (FirewallClient, error) {
|
||||
func NewFirewallClient() (FilterClient, error) {
|
||||
firewalld := cmd.Which("firewalld")
|
||||
ufw := cmd.Which("ufw")
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"sync"
|
||||
|
||||
"github.com/1Panel-dev/1Panel/agent/buserr"
|
||||
"github.com/1Panel-dev/1Panel/agent/global"
|
||||
"github.com/1Panel-dev/1Panel/agent/utils/cmd"
|
||||
"github.com/1Panel-dev/1Panel/agent/utils/controller"
|
||||
)
|
||||
@@ -108,34 +107,6 @@ func (f *Firewall) ListPort() ([]FireInfo, error) {
|
||||
return datas, nil
|
||||
}
|
||||
|
||||
func (f *Firewall) ListForward() ([]FireInfo, error) {
|
||||
if err := f.EnableForward(); err != nil {
|
||||
global.LOG.Errorf("init port forward failed, err: %v", err)
|
||||
}
|
||||
stdout, err := cmd.NewCommandMgr().RunWithStdout("firewall-cmd", "--zone=public", "--list-forward-ports")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var datas []FireInfo
|
||||
for _, line := range strings.Split(stdout, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
parts := strings.Split(line, ":")
|
||||
if len(parts) < 4 {
|
||||
continue
|
||||
}
|
||||
if parts[3] == "toaddr=" {
|
||||
parts[3] = "127.0.0.1"
|
||||
}
|
||||
datas = append(datas, FireInfo{
|
||||
Port: strings.TrimPrefix(parts[0], "port="),
|
||||
Protocol: strings.TrimPrefix(parts[1], "proto="),
|
||||
TargetIP: strings.TrimPrefix(parts[3], "toaddr="),
|
||||
TargetPort: strings.TrimPrefix(parts[2], "toport="),
|
||||
})
|
||||
}
|
||||
return datas, nil
|
||||
}
|
||||
|
||||
func (f *Firewall) ListAddress() ([]FireInfo, error) {
|
||||
stdout, err := cmd.NewCommandMgr().RunWithStdout("firewall-cmd", "--zone=public", "--list-rich-rules")
|
||||
if err != nil {
|
||||
@@ -196,24 +167,6 @@ func (f *Firewall) RichRules(rule FireInfo, operation string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *Firewall) PortForward(info Forward, operation string) error {
|
||||
if cmd.CheckIllegal(operation, info.Port, info.Protocol, info.TargetIP, info.TargetPort) {
|
||||
return buserr.New("ErrCmdIllegal")
|
||||
}
|
||||
forwardRule := fmt.Sprintf("--%s-forward-port=port=%s:proto=%s:toport=%s", operation, info.Port, info.Protocol, info.TargetPort)
|
||||
if info.TargetIP != "" && info.TargetIP != "127.0.0.1" && info.TargetIP != "localhost" {
|
||||
forwardRule = fmt.Sprintf("--%s-forward-port=port=%s:proto=%s:toaddr=%s:toport=%s", operation, info.Port, info.Protocol, info.TargetIP, info.TargetPort)
|
||||
}
|
||||
|
||||
if err := cmd.NewCommandMgr().Run("firewall-cmd", "--zone=public", forwardRule, "--permanent"); err != nil {
|
||||
return fmt.Errorf("%s port forward failed, %s", operation, err)
|
||||
}
|
||||
if err := f.Reload(); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *Firewall) loadInfo(line string) FireInfo {
|
||||
var itemRule FireInfo
|
||||
ruleInfo := strings.Split(strings.ReplaceAll(line, "\"", ""), " ")
|
||||
@@ -235,18 +188,3 @@ func (f *Firewall) loadInfo(line string) FireInfo {
|
||||
}
|
||||
return itemRule
|
||||
}
|
||||
|
||||
func (f *Firewall) EnableForward() error {
|
||||
stdout, err := cmd.NewCommandMgr().RunWithStdout("firewall-cmd", "--zone=public", "--query-masquerade")
|
||||
if err != nil {
|
||||
if strings.HasSuffix(strings.TrimSpace(stdout), "no") {
|
||||
if err := cmd.NewCommandMgr().Run("firewall-cmd", "--zone=public", "--add-masquerade", "--permanent"); err != nil {
|
||||
return err
|
||||
}
|
||||
return f.Reload()
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -17,12 +17,3 @@ type FireInfo struct {
|
||||
UsedStatus string `json:"usedStatus"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
type Forward struct {
|
||||
Num string `json:"num"`
|
||||
Protocol string `json:"protocol"`
|
||||
Port string `json:"port"`
|
||||
TargetIP string `json:"targetIP"`
|
||||
TargetPort string `json:"targetPort"`
|
||||
Interface string `json:"interface"`
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ package client
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -219,96 +218,6 @@ func (i *Iptables) RichRules(rule FireInfo, operation string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (i *Iptables) PortForward(info Forward, operation string) error {
|
||||
return iptablesPortForward(info, operation)
|
||||
}
|
||||
|
||||
func (i *Iptables) EnableForward() error {
|
||||
return EnableIptablesForward()
|
||||
}
|
||||
|
||||
func (i *Iptables) ListForward() ([]FireInfo, error) {
|
||||
return iptablesListForward()
|
||||
}
|
||||
|
||||
func EnableIptablesForward() error {
|
||||
if err := cmd.WriteFileWithOptionalSudo("/proc/sys/net/ipv4/ip_forward", []byte("1"), 0644); err != nil {
|
||||
return fmt.Errorf("failed to enable IP forwarding: %w", err)
|
||||
}
|
||||
if data, err := os.ReadFile("/etc/sysctl.conf"); err == nil {
|
||||
if !strings.Contains(string(data), "net.ipv4.ip_forward") {
|
||||
content := strings.TrimRight(string(data), "\n") + "\nnet.ipv4.ip_forward = 1\n"
|
||||
_ = cmd.WriteFileWithOptionalSudo("/etc/sysctl.conf", []byte(content), 0644)
|
||||
}
|
||||
}
|
||||
_ = cmd.NewCommandMgr().RunWithOptionalSudo("sysctl", "-p")
|
||||
|
||||
if err := iptables.AddChainWithAppend(iptables.NatTab, "PREROUTING", iptables.Chain1PanelPreRouting); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := iptables.AddChainWithAppend(iptables.NatTab, "POSTROUTING", iptables.Chain1PanelPostRouting); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := iptables.AddChainWithAppend(iptables.FilterTab, "FORWARD", iptables.Chain1PanelForward); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func iptablesPortForward(info Forward, operation string) error {
|
||||
if operation != "add" && operation != "remove" {
|
||||
return buserr.New("ErrCmdIllegal")
|
||||
}
|
||||
if info.Protocol == "" || info.Port == "" || info.TargetPort == "" {
|
||||
return fmt.Errorf("protocol, port, and target port are required")
|
||||
}
|
||||
if operation == "add" {
|
||||
if err := iptables.AddForward(info.Protocol, info.Port, info.TargetIP, info.TargetPort, info.Interface, true); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
if err := iptables.DeleteForward(info.Num, info.Protocol, info.Port, info.TargetIP, info.TargetPort, info.Interface); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
forwardPersistence()
|
||||
return nil
|
||||
}
|
||||
|
||||
func forwardPersistence() {
|
||||
if err := iptables.SaveRulesToFile(iptables.FilterTab, iptables.Chain1PanelForward, iptables.ForwardFileName); err != nil {
|
||||
global.LOG.Errorf("persistence for %s failed, err: %v", iptables.Chain1PanelForward, err)
|
||||
}
|
||||
if err := iptables.SaveRulesToFile(iptables.NatTab, iptables.Chain1PanelPreRouting, iptables.ForwardFileName1); err != nil {
|
||||
global.LOG.Errorf("persistence for %s failed, err: %v", iptables.Chain1PanelPreRouting, err)
|
||||
}
|
||||
if err := iptables.SaveRulesToFile(iptables.NatTab, iptables.Chain1PanelPostRouting, iptables.ForwardFileName2); err != nil {
|
||||
global.LOG.Errorf("persistence for %s failed, err: %v", iptables.Chain1PanelPostRouting, err)
|
||||
}
|
||||
}
|
||||
|
||||
func iptablesListForward() ([]FireInfo, error) {
|
||||
natList, err := iptables.ListForward(iptables.Chain1PanelPreRouting)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list NAT rules: %w", err)
|
||||
}
|
||||
|
||||
var datas []FireInfo
|
||||
for _, nat := range natList {
|
||||
datas = append(datas, FireInfo{
|
||||
Num: nat.Num,
|
||||
Protocol: nat.Protocol,
|
||||
Port: strings.TrimPrefix(nat.SrcPort, ":"),
|
||||
TargetIP: nat.Destination,
|
||||
TargetPort: strings.TrimPrefix(nat.DestPort, ":"),
|
||||
Interface: nat.InIface,
|
||||
})
|
||||
}
|
||||
|
||||
return datas, nil
|
||||
}
|
||||
|
||||
func parsePort(portStr string) (int, error) {
|
||||
port, err := strconv.Atoi(portStr)
|
||||
if err != nil {
|
||||
|
||||
@@ -11,9 +11,6 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
Chain1PanelPreRouting = "1PANEL_PREROUTING"
|
||||
Chain1PanelPostRouting = "1PANEL_POSTROUTING"
|
||||
Chain1PanelForward = "1PANEL_FORWARD"
|
||||
ChainInput = "INPUT"
|
||||
ChainOutput = "OUTPUT"
|
||||
Chain1PanelInput = "1PANEL_INPUT"
|
||||
|
||||
@@ -2,7 +2,6 @@ package iptables
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/1Panel-dev/1Panel/agent/buserr"
|
||||
@@ -127,7 +126,7 @@ func LoadInitStatus(clientName, tab string) (bool, bool) {
|
||||
if clientName == "firewalld" {
|
||||
return true, true
|
||||
}
|
||||
if clientName == "ufw" && tab != "forward" {
|
||||
if clientName == "ufw" {
|
||||
return true, true
|
||||
}
|
||||
switch tab {
|
||||
@@ -167,41 +166,6 @@ func LoadInitStatus(clientName, tab string) (bool, bool) {
|
||||
fmt.Sprintf("-A %s -j %s", ChainOutput, Chain1PanelOutput),
|
||||
}
|
||||
return checkWithInitAndBind(initRules, bindRules, lines)
|
||||
case "forward":
|
||||
data, err := os.ReadFile("/proc/sys/net/ipv4/ip_forward")
|
||||
if err != nil {
|
||||
global.LOG.Errorf("check /proc/sys/net/ipv4/ip_forward failed, err: %v", err)
|
||||
return false, false
|
||||
}
|
||||
if strings.TrimSpace(string(data)) == "0" {
|
||||
return false, false
|
||||
}
|
||||
natRules, err := RunWithStd(NatTab, "-S")
|
||||
if err != nil {
|
||||
return false, false
|
||||
}
|
||||
lines := strings.Split(natRules, "\n")
|
||||
initRules := []string{
|
||||
"-N " + Chain1PanelPreRouting,
|
||||
"-N " + Chain1PanelPostRouting,
|
||||
}
|
||||
bindRules := []string{
|
||||
fmt.Sprintf("-A PREROUTING -j %s", Chain1PanelPreRouting),
|
||||
fmt.Sprintf("-A POSTROUTING -j %s", Chain1PanelPostRouting),
|
||||
}
|
||||
isNatInit, isNatBind := checkWithInitAndBind(initRules, bindRules, lines)
|
||||
if !isNatInit {
|
||||
return false, false
|
||||
}
|
||||
filterRules, err := RunWithStd(FilterTab, "-S")
|
||||
if err != nil {
|
||||
return false, false
|
||||
}
|
||||
filterLines := strings.Split(filterRules, "\n")
|
||||
filterInitRules := []string{"-N " + Chain1PanelForward}
|
||||
filterBindRules := []string{fmt.Sprintf("-A FORWARD -j %s", Chain1PanelForward)}
|
||||
isFilterInit, isFilterBind := checkWithInitAndBind(filterInitRules, filterBindRules, filterLines)
|
||||
return isNatInit && isFilterInit, isNatBind && isFilterBind
|
||||
default:
|
||||
return false, false
|
||||
}
|
||||
|
||||
@@ -1,129 +0,0 @@
|
||||
package iptables
|
||||
|
||||
import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
func AddForward(protocol, srcPort, dest, destPort, iface string, save bool) error {
|
||||
srcPort = strings.ReplaceAll(srcPort, "-", ":")
|
||||
itemDstPort := strings.ReplaceAll(destPort, "-", ":")
|
||||
if dest != "" && dest != "127.0.0.1" && dest != "localhost" {
|
||||
args := []string{"-A", Chain1PanelPreRouting}
|
||||
if iface != "" {
|
||||
args = append(args, "-i", iface)
|
||||
}
|
||||
args = append(args, "-p", protocol, "--dport", srcPort, "-j", "DNAT", "--to-destination", dest+":"+destPort)
|
||||
if err := Run(NatTab, args...); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := Run(NatTab, "-A", Chain1PanelPostRouting, "-d", dest, "-p", protocol, "--dport", itemDstPort, "-j", "MASQUERADE"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := Run(FilterTab, "-A", Chain1PanelForward, "-d", dest, "-p", protocol, "--dport", itemDstPort, "-j", "ACCEPT"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := Run(FilterTab, "-A", Chain1PanelForward, "-s", dest, "-p", protocol, "--sport", itemDstPort, "-j", "ACCEPT"); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
args := []string{"-A", Chain1PanelPreRouting}
|
||||
if iface != "" {
|
||||
args = append(args, "-i", iface)
|
||||
}
|
||||
args = append(args, "-p", protocol, "--dport", srcPort, "-j", "REDIRECT", "--to-port", destPort)
|
||||
if err := Run(NatTab, args...); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func DeleteForward(num string, protocol, srcPort, dest, destPort, iface string) error {
|
||||
itemDstPort := strings.ReplaceAll(destPort, "-", ":")
|
||||
if err := Run(NatTab, "-D", Chain1PanelPreRouting, num); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if dest != "" && dest != "127.0.0.1" && dest != "localhost" {
|
||||
if err := Run(NatTab, "-D", Chain1PanelPostRouting, "-d", dest, "-p", protocol, "--dport", itemDstPort, "-j", "MASQUERADE"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := Run(FilterTab, "-D", Chain1PanelForward, "-d", dest, "-p", protocol, "--dport", itemDstPort, "-j", "ACCEPT"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := Run(FilterTab, "-D", Chain1PanelForward, "-s", dest, "-p", protocol, "--sport", itemDstPort, "-j", "ACCEPT"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ListForward(chain ...string) ([]IptablesNatInfo, error) {
|
||||
if len(chain) == 0 {
|
||||
chain = append(chain, Chain1PanelPreRouting)
|
||||
}
|
||||
stdout, err := RunWithStd(NatTab, "-nvL", chain[0], "--line-numbers")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var forwardList []IptablesNatInfo
|
||||
lines := strings.Split(stdout, "\n")
|
||||
for i := 0; i < len(lines); i++ {
|
||||
fields := strings.Fields(lines[i])
|
||||
if len(fields) < 13 {
|
||||
continue
|
||||
}
|
||||
item := IptablesNatInfo{
|
||||
Num: fields[0],
|
||||
Protocol: loadProtocol(fields[4]),
|
||||
InIface: fields[6],
|
||||
OutIface: fields[7],
|
||||
Source: fields[8],
|
||||
SrcPort: loadNatSrcPort(fields[11]),
|
||||
}
|
||||
if len(fields) == 15 && fields[13] == "ports" {
|
||||
item.DestPort = fields[14]
|
||||
}
|
||||
if len(fields) == 13 && strings.HasPrefix(fields[12], "to:") {
|
||||
parts := strings.Split(fields[12], ":")
|
||||
if len(parts) > 2 {
|
||||
item.DestPort = parts[2]
|
||||
item.Destination = parts[1]
|
||||
}
|
||||
}
|
||||
if len(item.Destination) == 0 {
|
||||
item.Destination = "127.0.0.1"
|
||||
}
|
||||
forwardList = append(forwardList, item)
|
||||
}
|
||||
|
||||
return forwardList, nil
|
||||
}
|
||||
|
||||
func loadNatSrcPort(portStr string) string {
|
||||
var portItem string
|
||||
if strings.Contains(portStr, "dpt:") {
|
||||
portItem = strings.ReplaceAll(portStr, "dpt:", "")
|
||||
}
|
||||
if strings.Contains(portStr, "dpts:") {
|
||||
portItem = strings.ReplaceAll(portStr, "dpts:", "")
|
||||
}
|
||||
portItem = strings.ReplaceAll(portItem, ":", "-")
|
||||
return portItem
|
||||
}
|
||||
|
||||
type IptablesNatInfo struct {
|
||||
Num string `json:"num"`
|
||||
Protocol string `json:"protocol"`
|
||||
InIface string `json:"inIface"`
|
||||
OutIface string `json:"outIface"`
|
||||
Source string `json:"source"`
|
||||
Destination string `json:"destination"`
|
||||
SrcPort string `json:"srcPort"`
|
||||
DestPort string `json:"destPort"`
|
||||
}
|
||||
@@ -18,9 +18,6 @@ const (
|
||||
BasicAfterFileName = "1panel_basic_after.rules"
|
||||
InputFileName = "1panel_input.rules"
|
||||
OutputFileName = "1panel_out.rules"
|
||||
ForwardFileName = "1panel_forward.rules"
|
||||
ForwardFileName1 = "1panel_forward_pre.rules"
|
||||
ForwardFileName2 = "1panel_forward_post.rules"
|
||||
)
|
||||
|
||||
func SaveRulesToFile(tab, chain, fileName string) error {
|
||||
|
||||
@@ -203,18 +203,6 @@ func (f *Ufw) RichRules(rule FireInfo, operation string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *Ufw) PortForward(info Forward, operation string) error {
|
||||
return iptablesPortForward(info, operation)
|
||||
}
|
||||
|
||||
func (f *Ufw) EnableForward() error {
|
||||
return EnableIptablesForward()
|
||||
}
|
||||
|
||||
func (f *Ufw) ListForward() ([]FireInfo, error) {
|
||||
return iptablesListForward()
|
||||
}
|
||||
|
||||
func (f *Ufw) loadInfo(line string, fireType string) FireInfo {
|
||||
fields := strings.Fields(line)
|
||||
var itemInfo FireInfo
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user