diff --git a/abstraction/validatable.go b/abstraction/validatable.go index 4795f72..3f4f0de 100644 --- a/abstraction/validatable.go +++ b/abstraction/validatable.go @@ -1,8 +1,9 @@ package abstraction +import "github.com/sirupsen/logrus" + type ( - ValidatableStr string - Validatable interface { - Validate() error + Validatable interface { + Validate(log *logrus.Entry) error } ) diff --git a/cmd/root.go b/cmd/root.go index 3e2ccbc..27c858a 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -6,6 +6,7 @@ import ( "runtime" "github.com/joho/godotenv" + "github.com/sirupsen/logrus" "github.com/spf13/cobra" "github.com/spf13/viper" @@ -139,7 +140,7 @@ func initConfig() { "Cannot unmarshal the config file: %s", ) panicOnErr( - CFG.Validate(), + CFG.Validate(logrus.WithField("section", "config.validation")), "Failed to initialize config file: %s", ) } diff --git a/config.example.yaml b/config.example.yaml index 67791eb..3d3bfb9 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -11,7 +11,7 @@ jobs: # disabled: true tasks: # This line specifies the actual command to be executed. - - command: echo 15 + - command: echo $(whoami) # This setting determines the number of times the task will be retried if it fails # A failure is defined by either a non-zero exit code for commands or a status code of 400 or higher for HTTP requests. retries: 3 @@ -20,12 +20,13 @@ jobs: # This sets a maximum time limit for the task to complete. # If the task exceeds 15 seconds, it will be considered failed and stopped (command) or canceled (http requests) timeout: 15s - # This defines the working directory for the command. - working-dir: C:// + user: root + # # This defines the working directory for the command. + # working-dir: C:// # This section allows you to set environment variables that will be available to the command during execution. # `SHELL` and `SHELL_ARGS` can be used to change this commands shell environment - env: - DB_HOST: 10.0.0.5 + # env: + # DB_HOST: 10.0.0.5 # # A simple get request # - get: https://example.com/get diff --git a/config/config.go b/config/config.go index 414bf4c..ab12a48 100644 --- a/config/config.go +++ b/config/config.go @@ -50,6 +50,9 @@ type ( Headers map[string]string `mapstructure:"headers" json:"headers,omitempty"` Data any `mapstructure:"data" json:"data,omitempty"` + UserName string `mapstructure:"user" json:"user,omitempty"` + GroupName string `mapstructure:"group" json:"group,omitempty"` + Retries uint `mapstructure:"retries" json:"retries,omitempty"` RetryDelay time.Duration `mapstructure:"retry-delay" json:"retry_delay,omitempty"` Timeout time.Duration `mapstructure:"timeout" json:"timeout,omitempty"` diff --git a/config/validators.go b/config/validators.go index 714b74f..9e44dc5 100644 --- a/config/validators.go +++ b/config/validators.go @@ -4,10 +4,13 @@ import ( "encoding/json" "fmt" + "github.com/sirupsen/logrus" + + credential "github.com/FMotalleb/crontab-go/core/os_credential" "github.com/FMotalleb/crontab-go/core/schedule" ) -func (cfg *Config) Validate() error { +func (cfg *Config) Validate(log *logrus.Entry) error { if err := cfg.LogFormat.Validate(); err != nil { return err } @@ -15,42 +18,42 @@ func (cfg *Config) Validate() error { return err } for _, job := range cfg.Jobs { - if err := job.Validate(); err != nil { + if err := job.Validate(log); err != nil { return err } } return nil } -func (c *JobConfig) Validate() error { +func (c *JobConfig) Validate(log *logrus.Entry) error { if c.Disabled == true { return nil } for _, s := range c.Schedulers { - if err := s.Validate(); err != nil { + if err := s.Validate(log); err != nil { return err } } for _, t := range c.Tasks { - if err := t.Validate(); err != nil { + if err := t.Validate(log); err != nil { return err } } for _, t := range c.Hooks.Done { - if err := t.Validate(); err != nil { + if err := t.Validate(log); err != nil { return err } } for _, t := range c.Hooks.Failed { - if err := t.Validate(); err != nil { + if err := t.Validate(log); err != nil { return err } } return nil } -func (t *Task) Validate() error { +func (t *Task) Validate(log *logrus.Entry) error { actions := []bool{ t.Get != "", t.Command != "", @@ -70,7 +73,9 @@ func (t *Task) Validate() error { t.Post, ) } - + if err := credential.Validate(log, t.UserName, t.GroupName); err != nil { + return err + } if t.Command != "" && (t.Data != nil || t.Headers != nil) { return fmt.Errorf("command cannot have data or headers field, violating command: `%s`", t.Command) } @@ -101,7 +106,7 @@ func (t *Task) Validate() error { return nil } -func (s *JobScheduler) Validate() error { +func (s *JobScheduler) Validate(log *logrus.Entry) error { if s.Interval < 0 { return fmt.Errorf("received a negative time in interval: `%v`", s.Interval) } else if _, err := schedule.CronParser.Parse(s.Cron); s.Cron != "" && err != nil { diff --git a/core/jobs/runner.go b/core/jobs/runner.go index 2e08b95..c7d704d 100644 --- a/core/jobs/runner.go +++ b/core/jobs/runner.go @@ -31,7 +31,7 @@ func InitializeJobs(log *logrus.Entry, cronInstance *cron.Cron) { logger := initLogger(c, log, job) logger = logger.WithField("concurrency", job.Concurrency) - if err := job.Validate(); err != nil { + if err := job.Validate(log); err != nil { log.Panicf("failed to validate job (%s): %v", job.Name, err) } diff --git a/core/os_credential/unix_credential.go b/core/os_credential/unix_credential.go new file mode 100644 index 0000000..10bf0a5 --- /dev/null +++ b/core/os_credential/unix_credential.go @@ -0,0 +1,95 @@ +//go:build !windows +// +build !windows + +package credential + +import ( + "errors" + "fmt" + "os/exec" + osUser "os/user" + "strconv" + "syscall" + + "github.com/sirupsen/logrus" +) + +func Validate(log *logrus.Entry, usr string, grp string) error { + cu, err := osUser.Current() + if err != nil { + return fmt.Errorf("cannot get current user error: %s", err) + } + if usr != "" && cu.Uid != "0" { + return errors.New("cannot switch user of tasks without root privilege, if you need to use user in tasks run crontab-go as user root") + } + _, _, err = lookupUIDAndGID(usr, log) + if err != nil { + return fmt.Errorf("cannot get uid and gid of user `%s` error: %s", usr, err) + } + _, err = lookupGID(grp, log) + if err != nil { + return fmt.Errorf("cannot get gid of group `%s` error: %s", grp, err) + } + + return nil +} + +func SetUser(log *logrus.Entry, proc *exec.Cmd, usr string, grp string) { + if usr == "" { + log.Trace("no username given, running as current user") + return + } + + uid, gid, err := lookupUIDAndGID(usr, log) + if err != nil { + log.Panicf("cannot get uid and gid of user %s, error: %s", usr, err) + } + if grp != "" { + gid, _ = lookupGID(grp, log) + } + + setUID(log, proc, uid, gid) +} + +func lookupGID(grp string, log *logrus.Entry) (gid uint32, err error) { + g, err := osUser.LookupGroup(grp) + if err != nil { + log.Panicf("cannot find group with name %s in the os: %s, you've changed os users during application runtime", grp, err) + } + gidU, err := strconv.ParseUint(g.Gid, 10, 32) + if err != nil { + return 0, err + } + return uint32(gidU), nil +} + +func lookupUIDAndGID(usr string, log *logrus.Entry) (uid uint32, gid uint32, err error) { + u, err := osUser.Lookup(usr) + if err != nil { + log.Panicf("cannot find user with name %s in the os: %s, you've changed os users during application runtime", usr, err) + } + uidU, err := strconv.ParseUint(u.Uid, 10, 32) + if err != nil { + return 0, 0, err + } + gidU, err := strconv.ParseUint(u.Gid, 10, 32) + if err != nil { + return 0, 0, err + } + return uint32(uidU), uint32(gidU), nil +} + +func setUID( + log *logrus.Entry, + proc *exec.Cmd, + uid uint32, + gid uint32, +) { + log.Tracef("Setting: uid(%d) and gid(%d)", uid, gid) + attrib := &syscall.SysProcAttr{} + proc.SysProcAttr = attrib + proc.SysProcAttr.Credential = &syscall.Credential{ + Uid: uid, + Gid: gid, + } +} diff --git a/core/os_credential/windows_credential.go b/core/os_credential/windows_credential.go new file mode 100644 index 0000000..77af59a --- /dev/null +++ b/core/os_credential/windows_credential.go @@ -0,0 +1,19 @@ +//go:build windows +// +build windows + +package credential + +import ( + "os/exec" + + "github.com/sirupsen/logrus" +) + +func Validate(log *logrus.Entry, usr string, grp string) error { + log.Warn("windows os does not have capability to set user dus validation will pass but will not work") + return nil +} + +func SetUser(log *logrus.Entry, _ *exec.Cmd, _ string, _ string) { + log.Warn("cannot set user in windows platform") +} diff --git a/core/task/command.go b/core/task/command.go index a73a092..2de1a57 100644 --- a/core/task/command.go +++ b/core/task/command.go @@ -14,6 +14,7 @@ import ( "github.com/FMotalleb/crontab-go/abstraction" "github.com/FMotalleb/crontab-go/cmd" "github.com/FMotalleb/crontab-go/config" + credential "github.com/FMotalleb/crontab-go/core/os_credential" ) type Command struct { @@ -23,6 +24,9 @@ type Command struct { log *logrus.Entry cancel context.CancelFunc + user string + group string + shell string shellArgs []string @@ -32,42 +36,43 @@ type Command struct { } // Cancel implements abstraction.Executable. -func (g *Command) Cancel() { - if g.cancel != nil { - g.log.Debugln("canceling executable") - g.cancel() +func (c *Command) Cancel() { + if c.cancel != nil { + c.log.Debugln("canceling executable") + c.cancel() } } // Execute implements abstraction.Executable. -func (cmmnd *Command) Execute(ctx context.Context) (e error) { +func (c *Command) Execute(ctx context.Context) (e error) { r := getRetry(ctx) - log := cmmnd.log.WithField("retry", r) - if getRetry(ctx) > cmmnd.retries { + log := c.log.WithField("retry", r) + if getRetry(ctx) > c.retries { log.Warn("maximum retry reached") return fmt.Errorf("maximum retries reached") } if r != 0 { - log.Debugln("waiting", cmmnd.retryDelay, "before executing the next iteration after last fail") - time.Sleep(cmmnd.retryDelay) + log.Debugln("waiting", c.retryDelay, "before executing the next iteration after last fail") + time.Sleep(c.retryDelay) } ctx = increaseRetry(ctx) var procCtx context.Context var cancel context.CancelFunc - if cmmnd.timeout != 0 { - procCtx, cancel = context.WithTimeout(ctx, cmmnd.timeout) + if c.timeout != 0 { + procCtx, cancel = context.WithTimeout(ctx, c.timeout) } else { procCtx, cancel = context.WithCancel(ctx) } - cmmnd.cancel = cancel + c.cancel = cancel proc := exec.CommandContext( procCtx, - cmmnd.shell, - append(cmmnd.shellArgs, *&cmmnd.exe)..., + c.shell, + append(c.shellArgs, *&c.exe)..., ) - proc.Env = *cmmnd.envVars - proc.Dir = cmmnd.workingDirectory + credential.SetUser(log, proc, c.user, c.group) + proc.Env = *c.envVars + proc.Dir = c.workingDirectory var res bytes.Buffer proc.Stdout = &res proc.Stderr = &res @@ -76,7 +81,7 @@ func (cmmnd *Command) Execute(ctx context.Context) (e error) { log.Infof("command finished with answer: `%s`", strings.TrimSpace(string(res.Bytes()))) if e != nil { log.Warn("failed to execute the command ", e) - return cmmnd.Execute(ctx) + return c.Execute(ctx) } return } @@ -119,5 +124,7 @@ func NewCommand( retries: task.Retries, retryDelay: task.RetryDelay, timeout: task.Timeout, + user: task.UserName, + group: task.GroupName, } } diff --git a/helpers/err_handlers.go b/helpers/err_handlers.go new file mode 100644 index 0000000..250eef3 --- /dev/null +++ b/helpers/err_handlers.go @@ -0,0 +1,23 @@ +package helpers + +import ( + "github.com/sirupsen/logrus" +) + +func PanicOnErr(log *logrus.Entry, err error, message string) { + if err != nil { + log.Panicf(message, err) + } +} + +func FatalOnErr(log *logrus.Entry, err error, message string) { + if err != nil { + log.Fatalf(message, err) + } +} + +func WarnOnErr(log *logrus.Entry, err error, message string) { + if err != nil { + log.Warnf(message, err) + } +} diff --git a/schema.json b/schema.json index dbae7b7..90f8c87 100644 --- a/schema.json +++ b/schema.json @@ -65,11 +65,6 @@ "schedulers", "tasks" ], - "optional": [ - "description", - "disabled", - "hooks" - ], "title": "Job" }, "Hooks": { @@ -91,10 +86,6 @@ "description": "An array of Task objects that define the tasks to be executed when the job fails." } }, - "required": [ - "done", - "failed" - ], "title": "Hooks" }, "Scheduler": { @@ -159,6 +150,14 @@ "type": "string", "description": "A string that represents the working directory for the task." }, + "user": { + "type": "string", + "description": "Username that this command must run as. (root privilege needed)" + }, + "group": { + "type": "string", + "description": "Groupname that this command must run as. (root privilege needed)" + }, "env": { "$ref": "#/definitions/Env", "description": "An Env object that defines the environment variables for the task."