mirror of
https://github.com/drakkan/sftpgo.git
synced 2024-11-25 00:50:31 +00:00
EventManager: allow to define the allowed system commands
Some checks failed
CI / Test and deploy (push) Has been cancelled
Code scanning - action / CodeQL-Build (push) Has been cancelled
CI / Test build flags (push) Has been cancelled
CI / Test with PgSQL/MySQL/Cockroach (push) Has been cancelled
CI / Build Linux packages (push) Has been cancelled
CI / golangci-lint (push) Has been cancelled
Docker / Build (push) Has been cancelled
Some checks failed
CI / Test and deploy (push) Has been cancelled
Code scanning - action / CodeQL-Build (push) Has been cancelled
CI / Test build flags (push) Has been cancelled
CI / Test with PgSQL/MySQL/Cockroach (push) Has been cancelled
CI / Build Linux packages (push) Has been cancelled
CI / golangci-lint (push) Has been cancelled
Docker / Build (push) Has been cancelled
Signed-off-by: Nicola Murino <nicola.murino@gmail.com>
This commit is contained in:
parent
1df1b8e4b5
commit
5c163ed592
10 changed files with 260 additions and 17 deletions
|
@ -228,6 +228,9 @@ func Initialize(c Configuration, isShared int) error {
|
||||||
if err := c.initializeProxyProtocol(); err != nil {
|
if err := c.initializeProxyProtocol(); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if err := c.EventManager.validate(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
vfs.SetTempPath(c.TempPath)
|
vfs.SetTempPath(c.TempPath)
|
||||||
dataprovider.SetTempPath(c.TempPath)
|
dataprovider.SetTempPath(c.TempPath)
|
||||||
vfs.SetAllowSelfConnections(c.AllowSelfConnections)
|
vfs.SetAllowSelfConnections(c.AllowSelfConnections)
|
||||||
|
@ -236,6 +239,7 @@ func Initialize(c Configuration, isShared int) error {
|
||||||
vfs.SetResumeMaxSize(c.ResumeMaxSize)
|
vfs.SetResumeMaxSize(c.ResumeMaxSize)
|
||||||
vfs.SetUploadMode(c.UploadMode)
|
vfs.SetUploadMode(c.UploadMode)
|
||||||
dataprovider.SetAllowSelfConnections(c.AllowSelfConnections)
|
dataprovider.SetAllowSelfConnections(c.AllowSelfConnections)
|
||||||
|
dataprovider.EnabledActionCommands = c.EventManager.EnabledCommands
|
||||||
transfersChecker = getTransfersChecker(isShared)
|
transfersChecker = getTransfersChecker(isShared)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
@ -484,6 +488,23 @@ type ConnectionTransfer struct {
|
||||||
DLSize int64 `json:"-"`
|
DLSize int64 `json:"-"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// EventManagerConfig defines the configuration for the EventManager
|
||||||
|
type EventManagerConfig struct {
|
||||||
|
// EnabledCommands defines the system commands that can be executed via EventManager,
|
||||||
|
// an empty list means that any command is allowed to be executed.
|
||||||
|
// Commands must be set as an absolute path
|
||||||
|
EnabledCommands []string `json:"enabled_commands" mapstructure:"enabled_commands"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *EventManagerConfig) validate() error {
|
||||||
|
for _, c := range c.EnabledCommands {
|
||||||
|
if !filepath.IsAbs(c) {
|
||||||
|
return fmt.Errorf("invalid command %q: it must be an absolute path", c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// MetadataConfig defines how to handle metadata for cloud storage backends
|
// MetadataConfig defines how to handle metadata for cloud storage backends
|
||||||
type MetadataConfig struct {
|
type MetadataConfig struct {
|
||||||
// If not zero the metadata will be read before downloads and will be
|
// If not zero the metadata will be read before downloads and will be
|
||||||
|
@ -589,7 +610,9 @@ type Configuration struct {
|
||||||
// Defines the server version
|
// Defines the server version
|
||||||
ServerVersion string `json:"server_version" mapstructure:"server_version"`
|
ServerVersion string `json:"server_version" mapstructure:"server_version"`
|
||||||
// Metadata configuration
|
// Metadata configuration
|
||||||
Metadata MetadataConfig `json:"metadata" mapstructure:"metadata"`
|
Metadata MetadataConfig `json:"metadata" mapstructure:"metadata"`
|
||||||
|
// EventManager configuration
|
||||||
|
EventManager EventManagerConfig `json:"event_manager" mapstructure:"event_manager"`
|
||||||
idleTimeoutAsDuration time.Duration
|
idleTimeoutAsDuration time.Duration
|
||||||
idleLoginTimeout time.Duration
|
idleLoginTimeout time.Duration
|
||||||
defender Defender
|
defender Defender
|
||||||
|
|
|
@ -216,6 +216,33 @@ func TestConnections(t *testing.T) {
|
||||||
Connections.RUnlock()
|
Connections.RUnlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestEventManagerCommandsInitialization(t *testing.T) {
|
||||||
|
configCopy := Config
|
||||||
|
|
||||||
|
c := Configuration{
|
||||||
|
EventManager: EventManagerConfig{
|
||||||
|
EnabledCommands: []string{"ls"}, // not an absolute path
|
||||||
|
},
|
||||||
|
}
|
||||||
|
err := Initialize(c, 0)
|
||||||
|
assert.ErrorContains(t, err, "invalid command")
|
||||||
|
|
||||||
|
var commands []string
|
||||||
|
if runtime.GOOS == osWindows {
|
||||||
|
commands = []string{"C:\\command"}
|
||||||
|
} else {
|
||||||
|
commands = []string{"/bin/ls"}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.EventManager.EnabledCommands = commands
|
||||||
|
err = Initialize(c, 0)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, commands, dataprovider.EnabledActionCommands)
|
||||||
|
|
||||||
|
dataprovider.EnabledActionCommands = configCopy.EventManager.EnabledCommands
|
||||||
|
Config = configCopy
|
||||||
|
}
|
||||||
|
|
||||||
func TestInitializationProxyErrors(t *testing.T) {
|
func TestInitializationProxyErrors(t *testing.T) {
|
||||||
configCopy := Config
|
configCopy := Config
|
||||||
|
|
||||||
|
|
|
@ -1480,6 +1480,9 @@ func executeHTTPRuleAction(c dataprovider.EventActionHTTPConfig, params *EventPa
|
||||||
}
|
}
|
||||||
|
|
||||||
func executeCommandRuleAction(c dataprovider.EventActionCommandConfig, params *EventParams) error {
|
func executeCommandRuleAction(c dataprovider.EventActionCommandConfig, params *EventParams) error {
|
||||||
|
if !dataprovider.IsActionCommandAllowed(c.Cmd) {
|
||||||
|
return fmt.Errorf("command %q is not allowed", c.Cmd)
|
||||||
|
}
|
||||||
addObjectData := false
|
addObjectData := false
|
||||||
if params.Object != nil {
|
if params.Object != nil {
|
||||||
for _, k := range c.EnvVars {
|
for _, k := range c.EnvVars {
|
||||||
|
|
|
@ -4208,6 +4208,148 @@ func TestEventRuleStatues(t *testing.T) {
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestEventRuleDisabledCommand(t *testing.T) {
|
||||||
|
if runtime.GOOS == osWindows {
|
||||||
|
t.Skip("this test is not available on Windows")
|
||||||
|
}
|
||||||
|
smtpCfg := smtp.Config{
|
||||||
|
Host: "127.0.0.1",
|
||||||
|
Port: 2525,
|
||||||
|
From: "notification@example.com",
|
||||||
|
TemplatesPath: "templates",
|
||||||
|
}
|
||||||
|
err := smtpCfg.Initialize(configDir, true)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
saveObjectScriptPath := filepath.Join(os.TempDir(), "provider.sh")
|
||||||
|
outPath := filepath.Join(os.TempDir(), "provider_out.json")
|
||||||
|
err = os.WriteFile(saveObjectScriptPath, getSaveProviderObjectScriptContent(outPath, 0), 0755)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
a1 := dataprovider.BaseEventAction{
|
||||||
|
Name: "a1",
|
||||||
|
Type: dataprovider.ActionTypeCommand,
|
||||||
|
Options: dataprovider.BaseEventActionOptions{
|
||||||
|
CmdConfig: dataprovider.EventActionCommandConfig{
|
||||||
|
Cmd: saveObjectScriptPath,
|
||||||
|
Timeout: 10,
|
||||||
|
EnvVars: []dataprovider.KeyValue{
|
||||||
|
{
|
||||||
|
Key: "SFTPGO_OBJECT_DATA",
|
||||||
|
Value: "{{ObjectData}}",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
a2 := dataprovider.BaseEventAction{
|
||||||
|
Name: "a2",
|
||||||
|
Type: dataprovider.ActionTypeEmail,
|
||||||
|
Options: dataprovider.BaseEventActionOptions{
|
||||||
|
EmailConfig: dataprovider.EventActionEmailConfig{
|
||||||
|
Recipients: []string{"test3@example.com"},
|
||||||
|
Subject: `New "{{Event}}" from "{{Name}}"`,
|
||||||
|
Body: "Object name: {{ObjectName}} object type: {{ObjectType}} Data: {{ObjectData}}",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
a3 := dataprovider.BaseEventAction{
|
||||||
|
Name: "a3",
|
||||||
|
Type: dataprovider.ActionTypeEmail,
|
||||||
|
Options: dataprovider.BaseEventActionOptions{
|
||||||
|
EmailConfig: dataprovider.EventActionEmailConfig{
|
||||||
|
Recipients: []string{"failure@example.com"},
|
||||||
|
Subject: `Failed "{{Event}}" from "{{Name}}"`,
|
||||||
|
Body: "Object name: {{ObjectName}} object type: {{ObjectType}}, IP: {{IP}}",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
action1, _, err := httpdtest.AddEventAction(a1, http.StatusCreated)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
action2, _, err := httpdtest.AddEventAction(a2, http.StatusCreated)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
action3, _, err := httpdtest.AddEventAction(a3, http.StatusCreated)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
r := dataprovider.EventRule{
|
||||||
|
Name: "rule",
|
||||||
|
Status: 1,
|
||||||
|
Trigger: dataprovider.EventTriggerProviderEvent,
|
||||||
|
Conditions: dataprovider.EventConditions{
|
||||||
|
ProviderEvents: []string{"add"},
|
||||||
|
Options: dataprovider.ConditionOptions{
|
||||||
|
ProviderObjects: []string{"folder"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Actions: []dataprovider.EventAction{
|
||||||
|
{
|
||||||
|
BaseEventAction: dataprovider.BaseEventAction{
|
||||||
|
Name: action1.Name,
|
||||||
|
},
|
||||||
|
Order: 1,
|
||||||
|
Options: dataprovider.EventActionOptions{
|
||||||
|
StopOnFailure: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
BaseEventAction: dataprovider.BaseEventAction{
|
||||||
|
Name: action2.Name,
|
||||||
|
},
|
||||||
|
Order: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
BaseEventAction: dataprovider.BaseEventAction{
|
||||||
|
Name: action3.Name,
|
||||||
|
},
|
||||||
|
Order: 3,
|
||||||
|
Options: dataprovider.EventActionOptions{
|
||||||
|
IsFailureAction: true,
|
||||||
|
StopOnFailure: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
rule, _, err := httpdtest.AddEventRule(r, http.StatusCreated)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
// restrit command execution
|
||||||
|
dataprovider.EnabledActionCommands = []string{"/bin/ls"}
|
||||||
|
|
||||||
|
lastReceivedEmail.reset()
|
||||||
|
// create a folder to trigger the rule
|
||||||
|
folder := vfs.BaseVirtualFolder{
|
||||||
|
Name: "ftest failed command",
|
||||||
|
MappedPath: filepath.Join(os.TempDir(), "p"),
|
||||||
|
}
|
||||||
|
folder, _, err = httpdtest.AddFolder(folder, http.StatusCreated)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
assert.NoFileExists(t, outPath)
|
||||||
|
assert.Eventually(t, func() bool {
|
||||||
|
return lastReceivedEmail.get().From != ""
|
||||||
|
}, 3000*time.Millisecond, 100*time.Millisecond)
|
||||||
|
email := lastReceivedEmail.get()
|
||||||
|
assert.Len(t, email.To, 1)
|
||||||
|
assert.True(t, slices.Contains(email.To, "failure@example.com"))
|
||||||
|
assert.Contains(t, email.Data, `Subject: Failed "add" from "admin"`)
|
||||||
|
assert.Contains(t, email.Data, fmt.Sprintf("Object name: %s object type: folder", folder.Name))
|
||||||
|
lastReceivedEmail.reset()
|
||||||
|
|
||||||
|
dataprovider.EnabledActionCommands = nil
|
||||||
|
|
||||||
|
_, err = httpdtest.RemoveFolder(folder, http.StatusOK)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
_, err = httpdtest.RemoveEventRule(rule, http.StatusOK)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
_, err = httpdtest.RemoveEventAction(action1, http.StatusOK)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
_, err = httpdtest.RemoveEventAction(action2, http.StatusOK)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
_, err = httpdtest.RemoveEventAction(action3, http.StatusOK)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
func TestEventRuleProviderEvents(t *testing.T) {
|
func TestEventRuleProviderEvents(t *testing.T) {
|
||||||
if runtime.GOOS == osWindows {
|
if runtime.GOOS == osWindows {
|
||||||
t.Skip("this test is not available on Windows")
|
t.Skip("this test is not available on Windows")
|
||||||
|
|
|
@ -238,6 +238,9 @@ func Init() {
|
||||||
Metadata: common.MetadataConfig{
|
Metadata: common.MetadataConfig{
|
||||||
Read: 0,
|
Read: 0,
|
||||||
},
|
},
|
||||||
|
EventManager: common.EventManagerConfig{
|
||||||
|
EnabledCommands: []string{},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
ACME: acme.Configuration{
|
ACME: acme.Configuration{
|
||||||
Email: "",
|
Email: "",
|
||||||
|
@ -2015,6 +2018,7 @@ func setViperDefaults() {
|
||||||
viper.SetDefault("common.umask", globalConf.Common.Umask)
|
viper.SetDefault("common.umask", globalConf.Common.Umask)
|
||||||
viper.SetDefault("common.server_version", globalConf.Common.ServerVersion)
|
viper.SetDefault("common.server_version", globalConf.Common.ServerVersion)
|
||||||
viper.SetDefault("common.metadata.read", globalConf.Common.Metadata.Read)
|
viper.SetDefault("common.metadata.read", globalConf.Common.Metadata.Read)
|
||||||
|
viper.SetDefault("common.event_manager.enabled_commands", globalConf.Common.EventManager.EnabledCommands)
|
||||||
viper.SetDefault("acme.email", globalConf.ACME.Email)
|
viper.SetDefault("acme.email", globalConf.ACME.Email)
|
||||||
viper.SetDefault("acme.key_type", globalConf.ACME.KeyType)
|
viper.SetDefault("acme.key_type", globalConf.ACME.KeyType)
|
||||||
viper.SetDefault("acme.certs_path", globalConf.ACME.CertsPath)
|
viper.SetDefault("acme.certs_path", globalConf.ACME.CertsPath)
|
||||||
|
|
|
@ -23,6 +23,7 @@ import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"path"
|
"path"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
@ -57,6 +58,9 @@ var (
|
||||||
ActionTypeBackup, ActionTypeUserQuotaReset, ActionTypeFolderQuotaReset, ActionTypeTransferQuotaReset,
|
ActionTypeBackup, ActionTypeUserQuotaReset, ActionTypeFolderQuotaReset, ActionTypeTransferQuotaReset,
|
||||||
ActionTypeDataRetentionCheck, ActionTypePasswordExpirationCheck, ActionTypeUserExpirationCheck,
|
ActionTypeDataRetentionCheck, ActionTypePasswordExpirationCheck, ActionTypeUserExpirationCheck,
|
||||||
ActionTypeUserInactivityCheck, ActionTypeIDPAccountCheck, ActionTypeRotateLogs}
|
ActionTypeUserInactivityCheck, ActionTypeIDPAccountCheck, ActionTypeRotateLogs}
|
||||||
|
// EnabledActionCommands defines the system commands that can be executed via EventManager,
|
||||||
|
// an empty list means that any command is allowed to be executed.
|
||||||
|
EnabledActionCommands []string
|
||||||
)
|
)
|
||||||
|
|
||||||
func isActionTypeValid(action int) bool {
|
func isActionTypeValid(action int) bool {
|
||||||
|
@ -449,6 +453,14 @@ func (c *EventActionHTTPConfig) GetHTTPClient() *http.Client {
|
||||||
return client
|
return client
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// IsActionCommandAllowed returns true if the specified command is allowed
|
||||||
|
func IsActionCommandAllowed(cmd string) bool {
|
||||||
|
if len(EnabledActionCommands) == 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return slices.Contains(EnabledActionCommands, cmd)
|
||||||
|
}
|
||||||
|
|
||||||
// EventActionCommandConfig defines the configuration for a command event target
|
// EventActionCommandConfig defines the configuration for a command event target
|
||||||
type EventActionCommandConfig struct {
|
type EventActionCommandConfig struct {
|
||||||
Cmd string `json:"cmd,omitempty"`
|
Cmd string `json:"cmd,omitempty"`
|
||||||
|
@ -461,6 +473,9 @@ func (c *EventActionCommandConfig) validate() error {
|
||||||
if c.Cmd == "" {
|
if c.Cmd == "" {
|
||||||
return util.NewI18nError(util.NewValidationError("command is required"), util.I18nErrorCommandRequired)
|
return util.NewI18nError(util.NewValidationError("command is required"), util.I18nErrorCommandRequired)
|
||||||
}
|
}
|
||||||
|
if !IsActionCommandAllowed(c.Cmd) {
|
||||||
|
return util.NewValidationError(fmt.Sprintf("command %q is not allowed", c.Cmd))
|
||||||
|
}
|
||||||
if !filepath.IsAbs(c.Cmd) {
|
if !filepath.IsAbs(c.Cmd) {
|
||||||
return util.NewI18nError(
|
return util.NewI18nError(
|
||||||
util.NewValidationError("invalid command, it must be an absolute path"),
|
util.NewValidationError("invalid command, it must be an absolute path"),
|
||||||
|
|
|
@ -2383,6 +2383,17 @@ func TestEventActionValidation(t *testing.T) {
|
||||||
_, resp, err = httpdtest.AddEventAction(action, http.StatusBadRequest)
|
_, resp, err = httpdtest.AddEventAction(action, http.StatusBadRequest)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.Contains(t, string(resp), "invalid command args")
|
assert.Contains(t, string(resp), "invalid command args")
|
||||||
|
action.Options.CmdConfig.Args = nil
|
||||||
|
// restrict commands
|
||||||
|
if runtime.GOOS == osWindows {
|
||||||
|
dataprovider.EnabledActionCommands = []string{"C:\\cmd.exe"}
|
||||||
|
} else {
|
||||||
|
dataprovider.EnabledActionCommands = []string{"/bin/sh"}
|
||||||
|
}
|
||||||
|
_, resp, err = httpdtest.AddEventAction(action, http.StatusBadRequest)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Contains(t, string(resp), "is not allowed")
|
||||||
|
dataprovider.EnabledActionCommands = nil
|
||||||
|
|
||||||
action.Type = dataprovider.ActionTypeEmail
|
action.Type = dataprovider.ActionTypeEmail
|
||||||
_, resp, err = httpdtest.AddEventAction(action, http.StatusBadRequest)
|
_, resp, err = httpdtest.AddEventAction(action, http.StatusBadRequest)
|
||||||
|
|
|
@ -291,13 +291,14 @@ type rolePage struct {
|
||||||
|
|
||||||
type eventActionPage struct {
|
type eventActionPage struct {
|
||||||
basePage
|
basePage
|
||||||
Action dataprovider.BaseEventAction
|
Action dataprovider.BaseEventAction
|
||||||
ActionTypes []dataprovider.EnumMapping
|
ActionTypes []dataprovider.EnumMapping
|
||||||
FsActions []dataprovider.EnumMapping
|
FsActions []dataprovider.EnumMapping
|
||||||
HTTPMethods []string
|
HTTPMethods []string
|
||||||
RedactedSecret string
|
EnabledCommands []string
|
||||||
Error *util.I18nError
|
RedactedSecret string
|
||||||
Mode genericPageMode
|
Error *util.I18nError
|
||||||
|
Mode genericPageMode
|
||||||
}
|
}
|
||||||
|
|
||||||
type eventRulePage struct {
|
type eventRulePage struct {
|
||||||
|
@ -1079,14 +1080,15 @@ func (s *httpdServer) renderEventActionPage(w http.ResponseWriter, r *http.Reque
|
||||||
}
|
}
|
||||||
|
|
||||||
data := eventActionPage{
|
data := eventActionPage{
|
||||||
basePage: s.getBasePageData(title, currentURL, r),
|
basePage: s.getBasePageData(title, currentURL, r),
|
||||||
Action: action,
|
Action: action,
|
||||||
ActionTypes: dataprovider.EventActionTypes,
|
ActionTypes: dataprovider.EventActionTypes,
|
||||||
FsActions: dataprovider.FsActionTypes,
|
FsActions: dataprovider.FsActionTypes,
|
||||||
HTTPMethods: dataprovider.SupportedHTTPActionMethods,
|
HTTPMethods: dataprovider.SupportedHTTPActionMethods,
|
||||||
RedactedSecret: redactedSecret,
|
EnabledCommands: dataprovider.EnabledActionCommands,
|
||||||
Error: getI18nError(err),
|
RedactedSecret: redactedSecret,
|
||||||
Mode: mode,
|
Error: getI18nError(err),
|
||||||
|
Mode: mode,
|
||||||
}
|
}
|
||||||
renderAdminTemplate(w, templateEventAction, data)
|
renderAdminTemplate(w, templateEventAction, data)
|
||||||
}
|
}
|
||||||
|
|
|
@ -61,7 +61,10 @@
|
||||||
"entries_soft_limit": 100,
|
"entries_soft_limit": 100,
|
||||||
"entries_hard_limit": 150
|
"entries_hard_limit": 150
|
||||||
}
|
}
|
||||||
]
|
],
|
||||||
|
"event_manager": {
|
||||||
|
"enabled_commands": []
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"acme": {
|
"acme": {
|
||||||
"domains": [],
|
"domains": [],
|
||||||
|
|
|
@ -396,6 +396,18 @@ explicit grant from the SFTPGo Team (support@sftpgo.com).
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{{ if .EnabledCommands}}
|
||||||
|
<div class="form-group row action-type action-cmd mt-10">
|
||||||
|
<label for="idCmdPath" data-i18n="actions.types.command" class="col-md-3 col-form-label">Command</label>
|
||||||
|
<div class="col-md-9">
|
||||||
|
<select id="idCmdPath" name="cmd_path" class="form-select" data-control="i18n-select2" data-hide-search="true">
|
||||||
|
{{- range .EnabledCommands}}
|
||||||
|
<option value="{{.}}" {{if eq $.Action.Options.CmdConfig.Cmd . }}selected{{end}}>{{.}}</option>
|
||||||
|
{{- end}}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{- else}}
|
||||||
<div class="form-group row action-type action-cmd mt-10">
|
<div class="form-group row action-type action-cmd mt-10">
|
||||||
<label for="idCmdPath" data-i18n="actions.types.command" class="col-md-3 col-form-label">Command</label>
|
<label for="idCmdPath" data-i18n="actions.types.command" class="col-md-3 col-form-label">Command</label>
|
||||||
<div class="col-md-9">
|
<div class="col-md-9">
|
||||||
|
@ -403,6 +415,7 @@ explicit grant from the SFTPGo Team (support@sftpgo.com).
|
||||||
<div id="idCmdPathHelp" class="form-text" data-i18n="actions.command_help"></div>
|
<div id="idCmdPathHelp" class="form-text" data-i18n="actions.command_help"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{{- end}}
|
||||||
|
|
||||||
<div class="form-group row action-type action-cmd mt-10">
|
<div class="form-group row action-type action-cmd mt-10">
|
||||||
<label for="idCommandArgs" data-i18n="actions.command_args" class="col-md-3 col-form-label">Arguments</label>
|
<label for="idCommandArgs" data-i18n="actions.command_args" class="col-md-3 col-form-label">Arguments</label>
|
||||||
|
|
Loading…
Reference in a new issue