diff --git a/.github/workflows/doc.yml b/.github/workflows/doc.yml index beec2b66..3c70dbea 100644 --- a/.github/workflows/doc.yml +++ b/.github/workflows/doc.yml @@ -55,7 +55,7 @@ jobs: with: url: "https://${{ env.BRANCH_NAME }}.resticprofile.pages.dev/" pages_path: ./public/ - cmd_params: '--exclude="(linux\.die\.net|stackoverflow\.com|scoop\.sh|0-18)" --buffer-size=8192 --max-connections=10 --color=always --skip-tls-verification --header="User-Agent:curl/7.54.0" --timeout=20' + cmd_params: '--exclude="(linux\.die\.net|scoop\.sh|0-18)" --buffer-size=8192 --max-connections-per-host=8 --color=always --skip-tls-verification --header="User-Agent: Muffet/2.10.8" --timeout=20' - name: Publish to pages.dev continue-on-error: true # secrets are not set for PRs from forks diff --git a/.github/workflows/release-doc.yml b/.github/workflows/release-doc.yml index 6e84bc97..461c430f 100644 --- a/.github/workflows/release-doc.yml +++ b/.github/workflows/release-doc.yml @@ -49,7 +49,7 @@ jobs: with: url: https://creativeprojects.github.io/resticprofile/ pages_path: ./www/ - cmd_params: '--exclude="(linux\.die\.net|stackoverflow\.com|scoop\.sh|0-18)" --buffer-size=8192 --max-connections-per-host=5 --rate-limit=5 --timeout=20 --header="User-Agent:curl/7.54.0" --skip-tls-verification' + cmd_params: '--exclude="(linux\.die\.net|scoop\.sh|0-18)" --buffer-size=8192 --max-connections-per-host=8 --timeout=20 --header="User-Agent: Muffet/2.10.8" --skip-tls-verification' - name: Deploy to GitHub Pages uses: peaceiris/actions-gh-pages@v4 diff --git a/Makefile b/Makefile index b956cbe9..8bbea645 100644 --- a/Makefile +++ b/Makefile @@ -298,7 +298,10 @@ checkdoc: .PHONY: checklinks checklinks: @echo "[*] $@" - muffet -b 8192 --max-connections=10 --exclude="(linux\.die\.net|stackoverflow\.com|scoop\.sh|0-18)" http://localhost:1313/resticprofile/ + muffet -b 8192 --max-connections-per-host=8 \ + --exclude="(linux\.die\.net|scoop\.sh|0-18)" \ + --header="User-Agent: Muffet/$$(muffet --version)" \ + http://localhost:1313/resticprofile/ .PHONY: lint lint: $(GOBIN)/golangci-lint diff --git a/examples/linux.yaml b/examples/linux.yaml index 925c7a4e..eaee74d5 100644 --- a/examples/linux.yaml +++ b/examples/linux.yaml @@ -98,12 +98,14 @@ longrun: self: inherit: default status-file: /tmp/status.json + # systemd-drop-in-files: [ drop-in-example.conf ] backup: extended-status: true source: ./ schedule: at: "*:15,20,25" permission: system + after-network-online: true check: schedule-permission: user schedule: diff --git a/schedule/handler_crond.go b/schedule/handler_crond.go index 13471091..f3ed138f 100644 --- a/schedule/handler_crond.go +++ b/schedule/handler_crond.go @@ -202,7 +202,7 @@ func (h *HandlerCrond) CheckPermission(user user.User, p Permission) bool { return true default: - if user.SudoRoot || user.Uid == 0 { + if user.IsRoot() { return true } // last case is system (or undefined) + no sudo diff --git a/schedule/handler_darwin.go b/schedule/handler_darwin.go index 6cee9221..716f5186 100644 --- a/schedule/handler_darwin.go +++ b/schedule/handler_darwin.go @@ -268,8 +268,7 @@ func (h *HandlerLaunchd) CheckPermission(user user.User, p Permission) bool { return true default: - if user.SudoRoot || user.Uid == 0 { - // user has sudoed + if user.IsRoot() { return true } // last case is system (or undefined) + no sudo diff --git a/schedule/handler_systemd.go b/schedule/handler_systemd.go index a5b86c9e..6906a86b 100644 --- a/schedule/handler_systemd.go +++ b/schedule/handler_systemd.go @@ -108,13 +108,33 @@ func (h *HandlerSystemd) DisplayStatus(profileName string) error { // CreateJob is creating the systemd unit and activating it func (h *HandlerSystemd) CreateJob(job *Config, schedules []*calendar.Event, permission Permission) error { - unitType, user := permissionToSystemd(user.Current(), permission) + u := user.Current() + unitType, user := permissionToSystemd(u, permission) if unitType == systemd.UserUnit && job.AfterNetworkOnline { return fmt.Errorf("after-network-online is not available for \"user_logged_on\" permission schedules") } - err := systemd.Generate(systemd.Config{ + // check the user hasn't changed the permission, which could duplicate the unit (system & user) + otherUnitType := systemd.SystemUnit + if unitType == systemd.SystemUnit { + otherUnitType = systemd.UserUnit + } + if cfgs, _ := getConfigs(job.ProfileName, otherUnitType); len(cfgs) > 0 { // ignore errors here + for _, cfg := range cfgs { + if cfg.CommandName == job.CommandName && cfg.ProfileName == job.ProfileName { + // we'd better remove this schedule first + clog.Infof("removing existing unit with different permission") + err := h.RemoveJob(&cfg, PermissionFromConfig(cfg.Permission)) + if err != nil { + return fmt.Errorf("cannot remove existing unit before scheduling with different permission. You might want to retry using sudo.") + } + } + } + } + + unit := systemd.NewUnit(u) + err := unit.Generate(systemd.Config{ CommandLine: job.Command + " --no-prio " + job.Arguments.String(), Environment: job.Environment, WorkingDirectory: job.WorkingDirectory, @@ -169,7 +189,9 @@ func (h *HandlerSystemd) CreateJob(job *Config, schedules []*calendar.Event, per // RemoveJob is disabling the systemd unit and deleting the timer and service files func (h *HandlerSystemd) RemoveJob(job *Config, permission Permission) error { var err error - unitType, _ := permissionToSystemd(user.Current(), permission) + unit := systemd.NewUnit(user.Current()) + u := user.Current() + unitType, _ := permissionToSystemd(u, permission) serviceFile := systemd.GetServiceFile(job.ProfileName, job.CommandName) unitLoaded, err := unitLoaded(serviceFile, unitType) if err != nil { @@ -195,7 +217,7 @@ func (h *HandlerSystemd) RemoveJob(job *Config, permission Permission) error { systemdPath := systemd.GetSystemDir() if unitType == systemd.UserUnit { - systemdPath, err = systemd.GetUserDir() + systemdPath, err = unit.GetUserDir() if err != nil { return nil } @@ -241,7 +263,12 @@ func (h *HandlerSystemd) DisplayJobStatus(job *Config) error { if !unitLoaded { return ErrScheduledJobNotFound } - _ = runJournalCtlCommand(timerName, systemdType) // ignore errors on journalctl + if systemdType == systemd.UserUnit && user.Current().IsRoot() { + // journalctl doesn't accept the parameter "-M user@" (yet?) + clog.Warning("cannot load the journal from a user service as root") + } else { + _ = runJournalCtlCommand(timerName, systemdType) // ignore errors on journalctl + } return runSystemctlCommand(timerName, systemctlStatus, systemdType, false) } @@ -294,7 +321,7 @@ func (h *HandlerSystemd) CheckPermission(user user.User, p Permission) bool { return true default: - if user.SudoRoot || user.Uid == 0 { + if user.IsRoot() { return true } // last case is system (or undefined) + no sudo @@ -476,7 +503,7 @@ func getConfigs(profileName string, unitType systemd.UnitType) ([]Config, error) if unit.Load == unitNotFound { continue } - cfg, err := systemd.Read(unit.Unit, unitType) + cfg, err := systemd.NewUnit(user.Current()).Read(unit.Unit, unitType) if err != nil { clog.Errorf("cannot read information from unit %q: %s", unit.Unit, err) continue diff --git a/systemd/drop_ins.go b/systemd/drop_ins.go index 589241b3..84b1ac38 100644 --- a/systemd/drop_ins.go +++ b/systemd/drop_ins.go @@ -23,8 +23,8 @@ func getOwnedName(basename string) string { return fmt.Sprintf("%s.resticprofile.conf", strings.TrimSuffix(basename, ext)) } -func IsTimerDropIn(file string) bool { - if f, err := fs.Open(file); err == nil { +func (u Unit) IsTimerDropIn(file string) bool { + if f, err := u.fs.Open(file); err == nil { defer func() { _ = f.Close() }() for reader := bufio.NewScanner(f); reader.Scan(); { if timerDropInRegex.Match(reader.Bytes()) { @@ -37,8 +37,8 @@ func IsTimerDropIn(file string) bool { return false } -func CreateDropIns(dir string, files []string) error { - if err := fs.MkdirAll(dir, 0o755); err != nil { +func (u Unit) createDropIns(dir string, files []string) error { + if err := u.fs.MkdirAll(dir, 0o755); err != nil { return err } @@ -47,7 +47,7 @@ func CreateDropIns(dir string, files []string) error { fileBasenamesOwned[getOwnedName(filepath.Base(file))] = struct{}{} } - d, err := fs.Open(dir) + d, err := u.fs.Open(dir) if err != nil { return err } @@ -66,7 +66,7 @@ func CreateDropIns(dir string, files []string) error { if createdByUs && !notOrphaned { orphanPath := filepath.Join(dir, f.Name()) clog.Debugf("deleting orphaned drop-in file %v", orphanPath) - if err := fs.Remove(orphanPath); err != nil { + if err := u.fs.Remove(orphanPath); err != nil { return err } } @@ -79,13 +79,13 @@ func CreateDropIns(dir string, files []string) error { dropInFileOwned := getOwnedName(dropInFileBase) dstPath := filepath.Join(dir, dropInFileOwned) clog.Debugf("writing %v", dstPath) - dst, err := fs.Create(dstPath) + dst, err := u.fs.Create(dstPath) if err != nil { return err } defer dst.Close() - src, err := fs.Open(dropInFilePath) + src, err := u.fs.Open(dropInFilePath) if err != nil { return err } diff --git a/systemd/generate.go b/systemd/generate.go index b22f19dd..7752840c 100644 --- a/systemd/generate.go +++ b/systemd/generate.go @@ -6,6 +6,7 @@ import ( "bytes" "fmt" "io" + "io/fs" "os" "path/filepath" "slices" @@ -70,8 +71,6 @@ const ( SystemUnit ) -var fs afero.Fs - // templateInfo to create systemd unit type templateInfo struct { templates.DefaultData @@ -113,36 +112,40 @@ type Config struct { User string } -func init() { - fs = afero.NewOsFs() +type Unit struct { + fs afero.Fs + user user.User +} + +func NewUnit(user user.User) Unit { + return Unit{ + fs: afero.NewOsFs(), + user: user, + } } // Generate systemd unit -func Generate(config Config) error { +func (u Unit) Generate(config Config) error { var err error systemdProfile := GetServiceFile(config.Title, config.SubTitle) timerProfile := GetTimerFile(config.Title, config.SubTitle) systemdUserDir := systemdSystemDir if config.UnitType == UserUnit { - systemdUserDir, err = GetUserDir() + systemdUserDir, err = u.GetUserDir() if err != nil { return err } } environment := slices.Clone(config.Environment) - if config.User != "" { - // resticprofile will start under config.User (user background mode) - u := user.Current() - if u.HomeDir != "" { - environment = append(environment, fmt.Sprintf("HOME=%s", u.HomeDir)) - } - } else { - // running resticprofile as root - if home, err := os.UserHomeDir(); err == nil { - environment = append(environment, fmt.Sprintf("HOME=%s", home)) - } + + if u.user.HomeDir != "" { + environment = append(environment, fmt.Sprintf("HOME=%s", u.user.HomeDir)) + } + + if config.UnitType == SystemUnit && config.User == "" { + // permission = system if sudoUser := os.Getenv("SUDO_USER"); sudoUser != "" { environment = append(environment, fmt.Sprintf("SUDO_USER=%s", sudoUser)) } @@ -172,7 +175,7 @@ func Generate(config Config) error { var data bytes.Buffer - systemdUnitTmpl, err := loadTemplate(config.UnitFile, systemdUnitDefaultTmpl) + systemdUnitTmpl, err := u.loadTemplate(config.UnitFile, systemdUnitDefaultTmpl) if err != nil { return err } @@ -185,12 +188,17 @@ func Generate(config Config) error { } filePathName := filepath.Join(systemdUserDir, systemdProfile) clog.Debugf("writing %v", filePathName) - if err = afero.WriteFile(fs, filePathName, data.Bytes(), defaultPermission); err != nil { + if err = afero.WriteFile(u.fs, filePathName, data.Bytes(), defaultPermission); err != nil { return err } data.Reset() - systemdTimerTmpl, err := loadTemplate(config.TimerFile, systemdTimerDefaultTmpl) + if config.UnitType == UserUnit && u.user.SudoRoot { + // we need to change the owner to the original account + _ = u.fs.Chown(filePathName, u.user.Uid, u.user.Gid) + } + + systemdTimerTmpl, err := u.loadTemplate(config.TimerFile, systemdTimerDefaultTmpl) if err != nil { return err } @@ -203,24 +211,53 @@ func Generate(config Config) error { } filePathName = filepath.Join(systemdUserDir, timerProfile) clog.Debugf("writing %v", filePathName) - if err = afero.WriteFile(fs, filePathName, data.Bytes(), defaultPermission); err != nil { + if err = afero.WriteFile(u.fs, filePathName, data.Bytes(), defaultPermission); err != nil { return err } + if config.UnitType == UserUnit && u.user.SudoRoot { + // we need to change the owner to the original account + _ = u.fs.Chown(filePathName, u.user.Uid, u.user.Gid) + } + dropIns := map[string][]string{ - GetTimerFileDropInDir(config.Title, config.SubTitle): collect.All(config.DropInFiles, IsTimerDropIn), - GetServiceFileDropInDir(config.Title, config.SubTitle): collect.All(config.DropInFiles, collect.Not(IsTimerDropIn)), + GetTimerFileDropInDir(config.Title, config.SubTitle): collect.All(config.DropInFiles, u.IsTimerDropIn), + GetServiceFileDropInDir(config.Title, config.SubTitle): collect.All(config.DropInFiles, collect.Not(u.IsTimerDropIn)), } for dropInDir, dropInFiles := range dropIns { dropInDir = filepath.Join(systemdUserDir, dropInDir) - if err = CreateDropIns(dropInDir, dropInFiles); err != nil { + if err = u.createDropIns(dropInDir, dropInFiles); err != nil { return err } + if config.UnitType == UserUnit && u.user.SudoRoot { + // we need to change the owner to the original account + _ = afero.Walk(u.fs, dropInDir, func(path string, info fs.FileInfo, err error) error { + if err != nil { + return err + } + _ = u.fs.Chown(path, u.user.Uid, u.user.Gid) + return nil + }) + } } return nil } +// GetUserDir returns the default directory where systemd stores user units +func (u Unit) GetUserDir() (string, error) { + systemdUserDir := filepath.Join(u.user.HomeDir, ".config", "systemd", "user") + if err := u.fs.MkdirAll(systemdUserDir, 0o700); err != nil { + return "", err + } + return systemdUserDir, nil +} + +// GetSystemDir returns the path where the local systemd units are stored +func GetSystemDir() string { + return systemdSystemDir +} + // GetServiceFile returns the service file name for the profile func GetServiceFile(profileName, commandName string) string { return fmt.Sprintf("resticprofile-%s@profile-%s.service", commandName, profileName) @@ -241,30 +278,14 @@ func GetTimerFile(profileName, commandName string) string { return fmt.Sprintf("resticprofile-%s@profile-%s.timer", commandName, profileName) } -// GetUserDir returns the default directory where systemd stores user units -func GetUserDir() (string, error) { - u := user.Current() - - systemdUserDir := filepath.Join(u.HomeDir, ".config", "systemd", "user") - if err := fs.MkdirAll(systemdUserDir, 0o700); err != nil { - return "", err - } - return systemdUserDir, nil -} - -// GetSystemDir returns the path where the local systemd units are stored -func GetSystemDir() string { - return systemdSystemDir -} - // loadTemplate loads the content of the filename if the parameter is not empty, // or returns the default template if the filename parameter is empty -func loadTemplate(filename, defaultTmpl string) (string, error) { +func (u Unit) loadTemplate(filename, defaultTmpl string) (string, error) { if filename == "" { return defaultTmpl, nil } clog.Debugf("using template file %q", filename) - file, err := fs.Open(filename) + file, err := u.fs.Open(filename) if err != nil { return "", err } diff --git a/systemd/generate_test.go b/systemd/generate_test.go index cd5ee21f..bc479860 100644 --- a/systemd/generate_test.go +++ b/systemd/generate_test.go @@ -4,27 +4,33 @@ package systemd import ( "fmt" - "os" - "os/user" "path/filepath" "testing" + "github.com/creativeprojects/resticprofile/user" "github.com/spf13/afero" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) +var ( + testStandardUser = user.User{Uid: 1001, Gid: 1001, Username: "testuser", HomeDir: "/home/testuser"} + testSudoUser = user.User{Uid: 1001, Gid: 1001, Username: "testuser", HomeDir: "/home/testuser", SudoRoot: true} + testRootUser = user.User{Uid: 0, Gid: 0, Username: "root", HomeDir: "/root"} +) + func TestGenerateSystemUnit(t *testing.T) { - fs = afero.NewMemMapFs() + t.Parallel() + fs := afero.NewMemMapFs() systemdDir := GetSystemDir() serviceFile := filepath.Join(systemdDir, "resticprofile-backup@profile-name.service") timerFile := filepath.Join(systemdDir, "resticprofile-backup@profile-name.timer") - assertNoFileExists(t, serviceFile) - assertNoFileExists(t, timerFile) + assertNoFileExists(t, fs, serviceFile) + assertNoFileExists(t, fs, timerFile) - err := Generate(Config{ + err := Unit{fs: fs}.Generate(Config{ CommandLine: "commandLine", WorkingDirectory: "workdir", Title: "name", @@ -36,8 +42,8 @@ func TestGenerateSystemUnit(t *testing.T) { Priority: "low", }) require.NoError(t, err) - requireFileExists(t, serviceFile) - requireFileExists(t, timerFile) + requireFileExists(t, fs, serviceFile) + requireFileExists(t, fs, timerFile) } func TestGenerateSystemUnitServiceAfterNetworkOnline(t *testing.T) { @@ -49,22 +55,20 @@ After=network-online.target Type=notify WorkingDirectory=workdir ExecStart=commandLine +User=testuser Environment="HOME=%s" ` - - fs = afero.NewMemMapFs() - - home, err := os.UserHomeDir() - require.NoError(t, err) + t.Parallel() + fs := afero.NewMemMapFs() systemdDir := GetSystemDir() serviceFile := filepath.Join(systemdDir, "resticprofile-backup@profile-name.service") timerFile := filepath.Join(systemdDir, "resticprofile-backup@profile-name.timer") - assertNoFileExists(t, serviceFile) - assertNoFileExists(t, timerFile) + assertNoFileExists(t, fs, serviceFile) + assertNoFileExists(t, fs, timerFile) - err = Generate(Config{ + err := Unit{fs: fs, user: testSudoUser}.Generate(Config{ CommandLine: "commandLine", WorkingDirectory: "workdir", Title: "name", @@ -75,14 +79,15 @@ Environment="HOME=%s" UnitType: SystemUnit, Priority: "low", AfterNetworkOnline: true, + User: testStandardUser.Username, }) require.NoError(t, err) - requireFileExists(t, serviceFile) - requireFileExists(t, timerFile) + requireFileExists(t, fs, serviceFile) + requireFileExists(t, fs, timerFile) service, err := afero.ReadFile(fs, serviceFile) require.NoError(t, err) - assert.Equal(t, fmt.Sprintf(expectedService, home), string(service)) + assert.Equal(t, fmt.Sprintf(expectedService, testSudoUser.HomeDir), string(service)) } func TestGenerateUserUnit(t *testing.T) { @@ -106,19 +111,17 @@ Persistent=true [Install] WantedBy=timers.target ` - fs = afero.NewMemMapFs() - u, err := user.Current() - require.NoError(t, err) - systemdUserDir := filepath.Join(u.HomeDir, ".config", "systemd", "user") + t.Parallel() + fs := afero.NewMemMapFs() + + systemdUserDir := filepath.Join(testStandardUser.HomeDir, ".config", "systemd", "user") serviceFile := filepath.Join(systemdUserDir, "resticprofile-backup@profile-name.service") timerFile := filepath.Join(systemdUserDir, "resticprofile-backup@profile-name.timer") - home, err := os.UserHomeDir() - require.NoError(t, err) - assertNoFileExists(t, serviceFile) - assertNoFileExists(t, timerFile) + assertNoFileExists(t, fs, serviceFile) + assertNoFileExists(t, fs, timerFile) - err = Generate(Config{ + err := Unit{fs: fs, user: testStandardUser}.Generate(Config{ CommandLine: "commandLine", WorkingDirectory: "workdir", Title: "name", @@ -130,12 +133,12 @@ WantedBy=timers.target Priority: "low", }) require.NoError(t, err) - requireFileExists(t, serviceFile) - requireFileExists(t, timerFile) + requireFileExists(t, fs, serviceFile) + requireFileExists(t, fs, timerFile) service, err := afero.ReadFile(fs, serviceFile) require.NoError(t, err) - assert.Equal(t, fmt.Sprintf(expectedService, home), string(service)) + assert.Equal(t, fmt.Sprintf(expectedService, testStandardUser.HomeDir), string(service)) timer, err := afero.ReadFile(fs, timerFile) require.NoError(t, err) @@ -143,9 +146,10 @@ WantedBy=timers.target } func TestGenerateUnitTemplateNotFound(t *testing.T) { - fs = afero.NewMemMapFs() + t.Parallel() + fs := afero.NewMemMapFs() - err := Generate(Config{ + err := Unit{fs: fs, user: testSudoUser}.Generate(Config{ CommandLine: "commandLine", WorkingDirectory: "workdir", Title: "name", @@ -161,9 +165,10 @@ func TestGenerateUnitTemplateNotFound(t *testing.T) { } func TestGenerateTimerTemplateNotFound(t *testing.T) { - fs = afero.NewMemMapFs() + t.Parallel() + fs := afero.NewMemMapFs() - err := Generate(Config{ + err := Unit{fs: fs, user: testSudoUser}.Generate(Config{ CommandLine: "commandLine", WorkingDirectory: "workdir", Title: "name", @@ -179,12 +184,13 @@ func TestGenerateTimerTemplateNotFound(t *testing.T) { } func TestGenerateUnitTemplateFailed(t *testing.T) { - fs = afero.NewMemMapFs() + t.Parallel() + fs := afero.NewMemMapFs() err := afero.WriteFile(fs, "unit", []byte("{{ ."), 0600) require.NoError(t, err) - err = Generate(Config{ + err = Unit{fs: fs, user: testSudoUser}.Generate(Config{ CommandLine: "commandLine", WorkingDirectory: "workdir", Title: "name", @@ -200,12 +206,13 @@ func TestGenerateUnitTemplateFailed(t *testing.T) { } func TestGenerateTimerTemplateFailed(t *testing.T) { - fs = afero.NewMemMapFs() + t.Parallel() + fs := afero.NewMemMapFs() err := afero.WriteFile(fs, "timer", []byte("{{ ."), 0600) require.NoError(t, err) - err = Generate(Config{ + err = Unit{fs: fs, user: testSudoUser}.Generate(Config{ CommandLine: "commandLine", WorkingDirectory: "workdir", Title: "name", @@ -221,12 +228,13 @@ func TestGenerateTimerTemplateFailed(t *testing.T) { } func TestGenerateUnitTemplateFailedToExecute(t *testing.T) { - fs = afero.NewMemMapFs() + t.Parallel() + fs := afero.NewMemMapFs() err := afero.WriteFile(fs, "unit", []byte("{{ .Toto }}"), 0600) require.NoError(t, err) - err = Generate(Config{ + err = Unit{fs: fs, user: testSudoUser}.Generate(Config{ CommandLine: "commandLine", WorkingDirectory: "workdir", Title: "name", @@ -242,12 +250,13 @@ func TestGenerateUnitTemplateFailedToExecute(t *testing.T) { } func TestGenerateTimerTemplateFailedToExecute(t *testing.T) { - fs = afero.NewMemMapFs() + t.Parallel() + fs := afero.NewMemMapFs() err := afero.WriteFile(fs, "timer", []byte("{{ .Toto }}"), 0600) require.NoError(t, err) - err = Generate(Config{ + err = Unit{fs: fs, user: testSudoUser}.Generate(Config{ CommandLine: "commandLine", WorkingDirectory: "workdir", Title: "name", @@ -263,21 +272,22 @@ func TestGenerateTimerTemplateFailedToExecute(t *testing.T) { } func TestGenerateFromUserDefinedTemplates(t *testing.T) { - fs = afero.NewMemMapFs() + t.Parallel() + fs := afero.NewMemMapFs() systemdDir := GetSystemDir() serviceFile := filepath.Join(systemdDir, "resticprofile-backup@profile-name.service") timerFile := filepath.Join(systemdDir, "resticprofile-backup@profile-name.timer") - assertNoFileExists(t, serviceFile) - assertNoFileExists(t, timerFile) + assertNoFileExists(t, fs, serviceFile) + assertNoFileExists(t, fs, timerFile) err := afero.WriteFile(fs, "unit", []byte("{{ .JobDescription }}"), 0600) require.NoError(t, err) err = afero.WriteFile(fs, "timer", []byte("{{ .TimerDescription }}"), 0600) require.NoError(t, err) - err = Generate(Config{ + err = Unit{fs: fs, user: testSudoUser}.Generate(Config{ CommandLine: "commandLine", WorkingDirectory: "workdir", Title: "name", @@ -291,12 +301,13 @@ func TestGenerateFromUserDefinedTemplates(t *testing.T) { TimerFile: "timer", }) require.NoError(t, err) - requireFileExists(t, serviceFile) - requireFileExists(t, timerFile) + requireFileExists(t, fs, serviceFile) + requireFileExists(t, fs, timerFile) } func TestGenerateWithDropInFile(t *testing.T) { - fs = afero.NewMemMapFs() + t.Parallel() + fs := afero.NewMemMapFs() dropInFileContents := []byte(` [Service] @@ -316,12 +327,12 @@ RandomizedDelaySec=5h dropInFile := filepath.Join(systemdDir, "resticprofile-backup@profile-name.service.d/99-example.resticprofile.conf") dropInTimerFile := filepath.Join(systemdDir, "resticprofile-backup@profile-name.timer.d/98-example.resticprofile.conf") - assertNoFileExists(t, serviceFile) - assertNoFileExists(t, timerFile) - assertNoFileExists(t, dropInFile) - assertNoFileExists(t, dropInTimerFile) + assertNoFileExists(t, fs, serviceFile) + assertNoFileExists(t, fs, timerFile) + assertNoFileExists(t, fs, dropInFile) + assertNoFileExists(t, fs, dropInTimerFile) - err := Generate(Config{ + err := Unit{fs: fs, user: testSudoUser}.Generate(Config{ CommandLine: "commandLine", WorkingDirectory: "workdir", Title: "name", @@ -334,10 +345,10 @@ RandomizedDelaySec=5h DropInFiles: []string{"98-example.conf", "99-example.conf"}, }) require.NoError(t, err) - requireFileExists(t, serviceFile) - requireFileExists(t, timerFile) - requireFileExists(t, dropInFile) - requireFileExists(t, dropInTimerFile) + requireFileExists(t, fs, serviceFile) + requireFileExists(t, fs, timerFile) + requireFileExists(t, fs, dropInFile) + requireFileExists(t, fs, dropInTimerFile) dropIn, err := afero.ReadFile(fs, dropInFile) require.NoError(t, err) @@ -349,7 +360,8 @@ RandomizedDelaySec=5h } func TestGenerateCleansUpOrphanDropIns(t *testing.T) { - fs = afero.NewMemMapFs() + t.Parallel() + fs := afero.NewMemMapFs() dropInFileContents := []byte(` [Service] @@ -380,12 +392,12 @@ RandomizedDelaySec=5h dropInFile := filepath.Join(systemdDir, "resticprofile-backup@profile-name.service.d/99-example.resticprofile.conf") dropInTimerFile := filepath.Join(systemdDir, "resticprofile-backup@profile-name.timer.d/98-example.resticprofile.conf") - assertNoFileExists(t, serviceFile) - assertNoFileExists(t, timerFile) - assertNoFileExists(t, dropInFile) - assertNoFileExists(t, dropInTimerFile) + assertNoFileExists(t, fs, serviceFile) + assertNoFileExists(t, fs, timerFile) + assertNoFileExists(t, fs, dropInFile) + assertNoFileExists(t, fs, dropInTimerFile) - err := Generate(Config{ + err := Unit{fs: fs, user: testSudoUser}.Generate(Config{ CommandLine: "commandLine", WorkingDirectory: "workdir", Title: "name", @@ -398,30 +410,34 @@ RandomizedDelaySec=5h DropInFiles: []string{"98-example.conf", "99-example.conf"}, }) require.NoError(t, err) - requireFileExists(t, serviceFile) - requireFileExists(t, timerFile) - requireFileExists(t, dropInFile) - requireFileExists(t, dropInTimerFile) - assertNoFileExists(t, orphanFile) - assertNoFileExists(t, orphanTimerFile) - requireFileExists(t, externallyCreatedFile) - requireFileExists(t, externallyCreatedTimerFile) + requireFileExists(t, fs, serviceFile) + requireFileExists(t, fs, timerFile) + requireFileExists(t, fs, dropInFile) + requireFileExists(t, fs, dropInTimerFile) + assertNoFileExists(t, fs, orphanFile) + assertNoFileExists(t, fs, orphanTimerFile) + requireFileExists(t, fs, externallyCreatedFile) + requireFileExists(t, fs, externallyCreatedTimerFile) } func TestGetUserDirOnReadOnlyFs(t *testing.T) { - fs = afero.NewReadOnlyFs(afero.NewMemMapFs()) - _, err := GetUserDir() + t.Parallel() + fs := afero.NewReadOnlyFs(afero.NewMemMapFs()) + _, err := Unit{fs: fs, user: testStandardUser}.GetUserDir() assert.Error(t, err) } func TestGenerateOnReadOnlyFs(t *testing.T) { - fs = afero.NewMemMapFs() - _, err := GetUserDir() + t.Parallel() + fs := afero.NewMemMapFs() + unit := Unit{fs: fs, user: testSudoUser} + _, err := unit.GetUserDir() assert.NoError(t, err) // now make the FS readonly fs = afero.NewReadOnlyFs(fs) + unit = Unit{fs: fs, user: testSudoUser} - err = Generate(Config{ + err = unit.Generate(Config{ CommandLine: "commandLine", WorkingDirectory: "workdir", Title: "name", @@ -436,6 +452,7 @@ func TestGenerateOnReadOnlyFs(t *testing.T) { } func TestGeneratePriorityFields(t *testing.T) { + t.Parallel() jobName := "name" jobCommand := "backup" testCases := []struct { @@ -534,14 +551,15 @@ func TestGeneratePriorityFields(t *testing.T) { for _, testCase := range testCases { t.Run("", func(t *testing.T) { - fs = afero.NewMemMapFs() + t.Parallel() + fs := afero.NewMemMapFs() - assertNoFileExists(t, serviceFile) + assertNoFileExists(t, fs, serviceFile) - err := Generate(testCase.config) + err := Unit{fs: fs, user: testSudoUser}.Generate(testCase.config) require.NoError(t, err) - requireFileExists(t, serviceFile) + requireFileExists(t, fs, serviceFile) contents, err := afero.ReadFile(fs, serviceFile) require.NoError(t, err) @@ -561,11 +579,12 @@ func TestGeneratePriorityFields(t *testing.T) { } func TestGenerateUserField(t *testing.T) { - fs = afero.NewMemMapFs() + t.Parallel() + fs := afero.NewMemMapFs() systemdDir := GetSystemDir() serviceFile := filepath.Join(systemdDir, "resticprofile-backup@profile-name.service") - err := Generate(Config{ + err := Unit{fs: fs, user: testSudoUser}.Generate(Config{ JobDescription: "Test", CommandLine: "resticprofile", WorkingDirectory: "/tmp", @@ -579,9 +598,6 @@ func TestGenerateUserField(t *testing.T) { contents, err := afero.ReadFile(fs, serviceFile) require.NoError(t, err) - homeDir, err := os.UserHomeDir() - require.NoError(t, err) - expected := `[Unit] Description=Test @@ -592,17 +608,17 @@ ExecStart=resticprofile User=user Environment="HOME=%s" ` - assert.Equal(t, fmt.Sprintf(expected, homeDir), string(contents)) + assert.Equal(t, fmt.Sprintf(expected, testSudoUser.HomeDir), string(contents)) } -func assertNoFileExists(t *testing.T, filename string) { +func assertNoFileExists(t *testing.T, fs afero.Fs, filename string) { t.Helper() exists, err := afero.Exists(fs, filename) require.NoError(t, err) assert.Falsef(t, exists, "file %q exists", filename) } -func requireFileExists(t *testing.T, filename string) { +func requireFileExists(t *testing.T, fs afero.Fs, filename string) { t.Helper() exists, err := afero.Exists(fs, filename) require.NoError(t, err) diff --git a/systemd/read.go b/systemd/read.go index 9dd057be..a30e2361 100644 --- a/systemd/read.go +++ b/systemd/read.go @@ -16,25 +16,25 @@ import ( // Read parses systemd service and timer unit files and returns their configuration. // The unit parameter should be in the format "resticprofile-backup@profile-name.service". // The unitType parameter determines whether to read from system or user unit directories. -func Read(unit string, unitType UnitType) (*Config, error) { +func (u Unit) Read(unit string, unitType UnitType) (*Config, error) { var err error if !strings.HasSuffix(unit, ".service") { return nil, fmt.Errorf("invalid unit name format: %s (must end with .service)", unit) } unitsDir := systemdSystemDir if unitType == UserUnit { - unitsDir, err = GetUserDir() + unitsDir, err = u.GetUserDir() if err != nil { return nil, err } } filename := path.Join(unitsDir, unit) - serviceSections, err := readSystemdUnit(filename) + serviceSections, err := u.readSystemdUnit(filename) if err != nil { return nil, err } filename = strings.Replace(filename, ".service", ".timer", 1) - timerSections, err := readSystemdUnit(filename) + timerSections, err := u.readSystemdUnit(filename) if err != nil { return nil, err } @@ -60,8 +60,8 @@ func Read(unit string, unitType UnitType) (*Config, error) { // readSystemdUnit returns a map of sections with key/values pair. // This implementation doesn't support multiline values (since these are not generated by resticprofile) -func readSystemdUnit(filename string) (map[string]map[string][]string, error) { - content, err := afero.ReadFile(fs, filename) +func (u Unit) readSystemdUnit(filename string) (map[string]map[string][]string, error) { + content, err := afero.ReadFile(u.fs, filename) if err != nil { return nil, err } diff --git a/systemd/read_test.go b/systemd/read_test.go index a9d6b977..102d9283 100644 --- a/systemd/read_test.go +++ b/systemd/read_test.go @@ -4,10 +4,10 @@ package systemd import ( "fmt" - "os" "path" "testing" + "github.com/creativeprojects/resticprofile/user" "github.com/spf13/afero" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -21,13 +21,14 @@ OnFailure=unit-status-mail@%n.service [Service] Type=notify -WorkingDirectory=/home/linux/go/src/github.com/creativeprojects/resticprofile +WorkingDirectory=/home/testuser/go/src/github.com/creativeprojects/resticprofile ExecStart=/tmp/go-build982790897/b001/exe/resticprofile --no-prio --no-ansi --config examples/linux.yaml run-schedule copy@self Nice=19 IOSchedulingClass=3 IOSchedulingPriority=7 +User=testuser Environment="RESTICPROFILE_SCHEDULE_ID=examples/linux.yaml:copy@self" -Environment="HOME=/home/linux" +Environment="HOME=/home/testuser" ` testTimerUnit = `[Unit] Description=copy timer for profile self in examples/linux.yaml @@ -42,20 +43,23 @@ WantedBy=timers.target` ) func TestReadUnitFile(t *testing.T) { - fs = afero.NewMemMapFs() + t.Parallel() + + fs := afero.NewMemMapFs() unitFile := "resticprofile-copy@profile-self.service" timerFile := "resticprofile-copy@profile-self.timer" require.NoError(t, afero.WriteFile(fs, path.Join(systemdSystemDir, unitFile), []byte(testServiceUnit), 0o600)) require.NoError(t, afero.WriteFile(fs, path.Join(systemdSystemDir, timerFile), []byte(testTimerUnit), 0o600)) - cfg, err := Read(unitFile, SystemUnit) + unit := Unit{fs: fs, user: testSudoUser} + cfg, err := unit.Read(unitFile, SystemUnit) require.NoError(t, err) assert.NotNil(t, cfg) expected := &Config{ CommandLine: "/tmp/go-build982790897/b001/exe/resticprofile --no-prio --no-ansi --config examples/linux.yaml run-schedule copy@self", - Environment: []string{"RESTICPROFILE_SCHEDULE_ID=examples/linux.yaml:copy@self", "HOME=/home/linux"}, - WorkingDirectory: "/home/linux/go/src/github.com/creativeprojects/resticprofile", + Environment: []string{"RESTICPROFILE_SCHEDULE_ID=examples/linux.yaml:copy@self", "HOME=/home/testuser"}, + WorkingDirectory: testSudoUser.HomeDir + "/go/src/github.com/creativeprojects/resticprofile", Title: "self", SubTitle: "copy", JobDescription: "resticprofile copy for profile self in examples/linux.yaml", @@ -71,15 +75,20 @@ func TestReadUnitFile(t *testing.T) { CPUSchedulingPolicy: "", IOSchedulingClass: 3, IOSchedulingPriority: 7, + User: testSudoUser.Username, } assert.Equal(t, expected, cfg) } func TestReadSystemUnit(t *testing.T) { + t.Parallel() + testCases := []struct { + user user.User config Config }{ { + user: testRootUser, config: Config{ CommandLine: "/bin/resticprofile --config profiles.yaml run-schedule backup@profile1", WorkingDirectory: "/workdir", @@ -93,6 +102,21 @@ func TestReadSystemUnit(t *testing.T) { }, }, { + user: testSudoUser, + config: Config{ + CommandLine: "/bin/resticprofile --config profiles.yaml run-schedule backup@profile1", + WorkingDirectory: "/workdir", + Title: "profile1", + SubTitle: "backup", + JobDescription: "job description", + TimerDescription: "timer description", + Schedules: []string{"daily"}, + UnitType: SystemUnit, + Priority: "background", + }, + }, + { + user: testStandardUser, config: Config{ CommandLine: "/bin/resticprofile --no-ansi --config profiles.yaml run-schedule check@profile2", WorkingDirectory: "/workdir", @@ -109,6 +133,7 @@ func TestReadSystemUnit(t *testing.T) { }, }, { + user: testSudoUser, config: Config{ CommandLine: "/bin/resticprofile --no-ansi --config profiles.yaml run-schedule check@profile3", WorkingDirectory: "/workdir", @@ -119,28 +144,26 @@ func TestReadSystemUnit(t *testing.T) { Schedules: []string{"monthly"}, UnitType: SystemUnit, Priority: "standard", - User: "me", + User: testSudoUser.Username, }, }, } - fs = afero.NewMemMapFs() - for _, tc := range testCases { t.Run("", func(t *testing.T) { + t.Parallel() + + unit := Unit{fs: afero.NewMemMapFs(), user: tc.user} baseUnit := fmt.Sprintf("resticprofile-%s@profile-%s", tc.config.SubTitle, tc.config.Title) serviceFile := baseUnit + ".service" - err := Generate(tc.config) + err := unit.Generate(tc.config) require.NoError(t, err) - readCfg, err := Read(serviceFile, tc.config.UnitType) + readCfg, err := unit.Read(serviceFile, tc.config.UnitType) require.NoError(t, err) assert.NotNil(t, readCfg) - home, err := os.UserHomeDir() - require.NoError(t, err) - expected := &Config{ Title: tc.config.Title, SubTitle: tc.config.SubTitle, @@ -148,7 +171,7 @@ func TestReadSystemUnit(t *testing.T) { WorkingDirectory: tc.config.WorkingDirectory, CommandLine: tc.config.CommandLine, UnitType: tc.config.UnitType, - Environment: append(tc.config.Environment, "HOME="+home), + Environment: append(tc.config.Environment, "HOME="+tc.user.HomeDir), Schedules: tc.config.Schedules, Priority: tc.config.Priority, User: tc.config.User, diff --git a/user/user.go b/user/user.go index 35b319a0..0ca8e582 100644 --- a/user/user.go +++ b/user/user.go @@ -7,3 +7,9 @@ type User struct { HomeDir string SudoRoot bool } + +// IsRoot returns true when the user has used sudo to run the command, +// or if the user was simply logged on a root +func (u User) IsRoot() bool { + return u.SudoRoot || u.Uid == 0 +}