]> ruderich.org/simon Gitweb - safcm/safcm.git/commitdiff
First working version
authorSimon Ruderich <simon@ruderich.org>
Sat, 3 Apr 2021 13:02:39 +0000 (15:02 +0200)
committerSimon Ruderich <simon@ruderich.org>
Sat, 3 Apr 2021 13:02:39 +0000 (15:02 +0200)
136 files changed:
.gitignore [new file with mode: 0644]
Makefile [new file with mode: 0644]
cmd/safcm-remote/build.sh [new file with mode: 0755]
cmd/safcm-remote/info/info.go [new file with mode: 0644]
cmd/safcm-remote/log/logger.go [new file with mode: 0644]
cmd/safcm-remote/main.go [new file with mode: 0644]
cmd/safcm-remote/run/cmd.go [new file with mode: 0644]
cmd/safcm-remote/run/runner.go [new file with mode: 0644]
cmd/safcm-remote/sync/commands.go [new file with mode: 0644]
cmd/safcm-remote/sync/commands_test.go [new file with mode: 0644]
cmd/safcm-remote/sync/files.go [new file with mode: 0644]
cmd/safcm-remote/sync/files_test.go [new file with mode: 0644]
cmd/safcm-remote/sync/packages.go [new file with mode: 0644]
cmd/safcm-remote/sync/packages_debian.go [new file with mode: 0644]
cmd/safcm-remote/sync/packages_debian_test.go [new file with mode: 0644]
cmd/safcm-remote/sync/services.go [new file with mode: 0644]
cmd/safcm-remote/sync/services_systemd.go [new file with mode: 0644]
cmd/safcm-remote/sync/services_systemd_test.go [new file with mode: 0644]
cmd/safcm-remote/sync/sync.go [new file with mode: 0644]
cmd/safcm-remote/sync/sync_test.go [new file with mode: 0644]
cmd/safcm-remote/sync/triggers.go [new file with mode: 0644]
cmd/safcm-remote/sync/triggers_test.go [new file with mode: 0644]
cmd/safcm/config/commands.go [new file with mode: 0644]
cmd/safcm/config/config.go [new file with mode: 0644]
cmd/safcm/config/files.go [new file with mode: 0644]
cmd/safcm/config/files_test.go [new file with mode: 0644]
cmd/safcm/config/groups.go [new file with mode: 0644]
cmd/safcm/config/groups_test.go [new file with mode: 0644]
cmd/safcm/config/hosts.go [new file with mode: 0644]
cmd/safcm/config/packages.go [new file with mode: 0644]
cmd/safcm/config/permissions.go [new file with mode: 0644]
cmd/safcm/config/permissions_test.go [new file with mode: 0644]
cmd/safcm/config/services.go [new file with mode: 0644]
cmd/safcm/config/templates.go [new file with mode: 0644]
cmd/safcm/config/templates_test.go [new file with mode: 0644]
cmd/safcm/config/triggers.go [new file with mode: 0644]
cmd/safcm/config/triggers_test.go [new file with mode: 0644]
cmd/safcm/fixperms.go [new file with mode: 0644]
cmd/safcm/main.go [new file with mode: 0644]
cmd/safcm/sync.go [new file with mode: 0644]
cmd/safcm/sync_changes.go [new file with mode: 0644]
cmd/safcm/sync_changes_test.go [new file with mode: 0644]
cmd/safcm/sync_info.go [new file with mode: 0644]
cmd/safcm/sync_info_test.go [new file with mode: 0644]
cmd/safcm/sync_sync.go [new file with mode: 0644]
cmd/safcm/sync_sync_test.go [new file with mode: 0644]
cmd/safcm/sync_test.go [new file with mode: 0644]
cmd/safcm/term.go [new file with mode: 0644]
cmd/safcm/term_test.go [new file with mode: 0644]
cmd/safcm/testdata/group-invalid-all-remove/groups.yaml [new file with mode: 0644]
cmd/safcm/testdata/group-invalid-all/groups.yaml [new file with mode: 0644]
cmd/safcm/testdata/group-invalid-conflict/groups.yaml [new file with mode: 0644]
cmd/safcm/testdata/group-invalid-detected/groups.yaml [new file with mode: 0644]
cmd/safcm/testdata/group-invalid-member/groups.yaml [new file with mode: 0644]
cmd/safcm/testdata/group-invalid-missing/groups.yaml [new file with mode: 0644]
cmd/safcm/testdata/group-invalid-name/groups.yaml [new file with mode: 0644]
cmd/safcm/testdata/project-conflict-dir/all/files/etc/motd [new file with mode: 0644]
cmd/safcm/testdata/project-conflict-dir/dns/files/etc/resolv.conf [new file with mode: 0644]
cmd/safcm/testdata/project-conflict-dir/dns/permissions.yaml [new file with mode: 0644]
cmd/safcm/testdata/project-conflict-dir/groups.yaml [new file with mode: 0644]
cmd/safcm/testdata/project-conflict-dir/hosts.yaml [new file with mode: 0644]
cmd/safcm/testdata/project-conflict-dir/other/files/etc/resolv.conf [new file with mode: 0644]
cmd/safcm/testdata/project-conflict-dir/other/triggers.yaml [new file with mode: 0644]
cmd/safcm/testdata/project-conflict-file/all/files/etc/resolv.conf [new file with mode: 0644]
cmd/safcm/testdata/project-conflict-file/dns/files/etc/resolv.conf [new file with mode: 0644]
cmd/safcm/testdata/project-conflict-file/groups.yaml [new file with mode: 0644]
cmd/safcm/testdata/project-conflict-file/hosts.yaml [new file with mode: 0644]
cmd/safcm/testdata/project-conflict-file/other/files/etc/resolv.conf [new file with mode: 0644]
cmd/safcm/testdata/project-group-cycle/groups.yaml [new file with mode: 0644]
cmd/safcm/testdata/project-group-cycle/hosts.yaml [new file with mode: 0644]
cmd/safcm/testdata/project-group_order/config.yaml [new file with mode: 0644]
cmd/safcm/testdata/project-group_order/group-a/files/etc/dir-to-file [new file with mode: 0644]
cmd/safcm/testdata/project-group_order/group-a/files/etc/dir-to-link [new symlink]
cmd/safcm/testdata/project-group_order/group-a/files/etc/file-to-dir/dir/file2 [new file with mode: 0644]
cmd/safcm/testdata/project-group_order/group-a/files/etc/file-to-dir/file [new file with mode: 0644]
cmd/safcm/testdata/project-group_order/group-a/files/etc/motd [new file with mode: 0644]
cmd/safcm/testdata/project-group_order/group-a/triggers.yaml [new file with mode: 0644]
cmd/safcm/testdata/project-group_order/group-b/files/etc/dir-to-file/dir/file2 [new file with mode: 0644]
cmd/safcm/testdata/project-group_order/group-b/files/etc/dir-to-file/file [new file with mode: 0644]
cmd/safcm/testdata/project-group_order/group-b/files/etc/dir-to-filex [new file with mode: 0644]
cmd/safcm/testdata/project-group_order/group-b/files/etc/dir-to-link/dir-to-file/dir/file2 [new file with mode: 0644]
cmd/safcm/testdata/project-group_order/group-b/files/etc/dir-to-link/dir-to-file/file [new file with mode: 0644]
cmd/safcm/testdata/project-group_order/group-b/files/etc/dir-to-linkx [new file with mode: 0644]
cmd/safcm/testdata/project-group_order/group-b/files/etc/file-to-dir [new file with mode: 0644]
cmd/safcm/testdata/project-group_order/group-b/files/etc/motd [new file with mode: 0644]
cmd/safcm/testdata/project-group_order/group-b/triggers.yaml [new file with mode: 0644]
cmd/safcm/testdata/project-group_order/groups.yaml [new file with mode: 0644]
cmd/safcm/testdata/project-group_order/host1.example.org/files/etc/motd [new file with mode: 0644]
cmd/safcm/testdata/project-group_order/hosts.yaml [new file with mode: 0644]
cmd/safcm/testdata/project/empty/.gitignore [new file with mode: 0644]
cmd/safcm/testdata/project/files-invalid-perm-dir-setgid/files/etc/resolv.conf [new file with mode: 0644]
cmd/safcm/testdata/project/files-invalid-perm-dir/files/etc/resolv.conf [new file with mode: 0644]
cmd/safcm/testdata/project/files-invalid-perm-file-executable/files/etc/rc.local [new file with mode: 0755]
cmd/safcm/testdata/project/files-invalid-perm-file-sticky/files/etc/resolv.conf [new file with mode: 0644]
cmd/safcm/testdata/project/files-invalid-perm-file/files/etc/resolv.conf [new file with mode: 0644]
cmd/safcm/testdata/project/files-invalid-type/files/.gitignore [new file with mode: 0644]
cmd/safcm/testdata/project/group/commands.yaml [new file with mode: 0644]
cmd/safcm/testdata/project/group/files/etc/.hidden [new file with mode: 0644]
cmd/safcm/testdata/project/group/files/etc/motd [new file with mode: 0644]
cmd/safcm/testdata/project/group/files/etc/rc.local [new file with mode: 0755]
cmd/safcm/testdata/project/group/files/etc/resolv.conf [new file with mode: 0644]
cmd/safcm/testdata/project/group/files/etc/test [new symlink]
cmd/safcm/testdata/project/group/packages.yaml [new file with mode: 0644]
cmd/safcm/testdata/project/group/permissions.yaml [new file with mode: 0644]
cmd/safcm/testdata/project/group/services.yaml [new file with mode: 0644]
cmd/safcm/testdata/project/group/templates.yaml [new file with mode: 0644]
cmd/safcm/testdata/project/group/triggers.yaml [new file with mode: 0644]
cmd/safcm/testdata/project/groups.yaml [new file with mode: 0644]
cmd/safcm/testdata/project/hosts.yaml [new file with mode: 0644]
cmd/safcm/testdata/project/permissions-invalid-execute/files/etc/rc.local [new file with mode: 0755]
cmd/safcm/testdata/project/permissions-invalid-execute/permissions.yaml [new file with mode: 0644]
cmd/safcm/testdata/project/permissions-invalid-line/files/etc/resolv.conf [new file with mode: 0644]
cmd/safcm/testdata/project/permissions-invalid-line/permissions.yaml [new file with mode: 0644]
cmd/safcm/testdata/project/permissions-invalid-path/permissions.yaml [new file with mode: 0644]
cmd/safcm/testdata/project/permissions-invalid-permission-int/files/etc/resolv.conf [new file with mode: 0644]
cmd/safcm/testdata/project/permissions-invalid-permission-int/permissions.yaml [new file with mode: 0644]
cmd/safcm/testdata/project/permissions-invalid-permission/files/etc/resolv.conf [new file with mode: 0644]
cmd/safcm/testdata/project/permissions-invalid-permission/permissions.yaml [new file with mode: 0644]
cmd/safcm/testdata/project/templates-invalid-group/files/etc/motd [new file with mode: 0644]
cmd/safcm/testdata/project/templates-invalid-group/templates.yaml [new file with mode: 0644]
cmd/safcm/testdata/project/templates-invalid-host/files/etc/motd [new file with mode: 0644]
cmd/safcm/testdata/project/templates-invalid-host/templates.yaml [new file with mode: 0644]
cmd/safcm/testdata/project/templates-invalid-path/templates.yaml [new file with mode: 0644]
cmd/safcm/testdata/project/templates-invalid-template/files/etc/motd [new file with mode: 0644]
cmd/safcm/testdata/project/templates-invalid-template/templates.yaml [new file with mode: 0644]
cmd/safcm/testdata/project/templates-invalid-type/files/etc/motd [new file with mode: 0644]
cmd/safcm/testdata/project/templates-invalid-type/templates.yaml [new file with mode: 0644]
cmd/safcm/testdata/project/triggers-invalid-path/triggers.yaml [new file with mode: 0644]
go.mod [new file with mode: 0644]
go.sum [new file with mode: 0644]
gob.go [new file with mode: 0644]
log.go [new file with mode: 0644]
remote/remote.go [new file with mode: 0644]
rpc/conn.go [new file with mode: 0644]
rpc/dial.go [new file with mode: 0644]
types.go [new file with mode: 0644]

diff --git a/.gitignore b/.gitignore
new file mode 100644 (file)
index 0000000..0b4325c
--- /dev/null
@@ -0,0 +1,3 @@
+/remote/helpers/
+/safcm
+/tags
diff --git a/Makefile b/Makefile
new file mode 100644 (file)
index 0000000..8ccec9d
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,14 @@
+all: safcm
+
+safcm:
+       go fmt ./...
+       cd cmd/safcm-remote && ./build.sh
+       go build -race ruderich.org/simon/safcm/cmd/safcm
+       @# For proper permissions after initial clone with a strict umask
+       cd cmd/safcm/testdata/project && ../../../../safcm fixperms 2> /dev/null
+
+clean:
+       rm -rf remote/helpers/
+       rm -f safcm
+
+.PHONY: all clean safcm
diff --git a/cmd/safcm-remote/build.sh b/cmd/safcm-remote/build.sh
new file mode 100755 (executable)
index 0000000..404f48f
--- /dev/null
@@ -0,0 +1,34 @@
+#!/bin/sh
+
+# Build remote helpers for all operating systems and architectures which are
+# supported as target hosts
+
+# Copyright (C) 2021  Simon Ruderich
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+set -eu
+
+
+build() {
+    GOOS="$1" GOARCH="$2" go build -o "$dest/$1-$2"
+}
+
+
+dest=../../remote/helpers
+
+mkdir -p "$dest"
+
+build linux amd64
+# TODO: support more operating systems and architectures
diff --git a/cmd/safcm-remote/info/info.go b/cmd/safcm-remote/info/info.go
new file mode 100644 (file)
index 0000000..c3853dd
--- /dev/null
@@ -0,0 +1,69 @@
+// MsgInfoReq: collect information about the remote host
+
+// Copyright (C) 2021  Simon Ruderich
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+package info
+
+import (
+       "fmt"
+       "runtime"
+
+       "ruderich.org/simon/safcm"
+       "ruderich.org/simon/safcm/cmd/safcm-remote/log"
+       "ruderich.org/simon/safcm/cmd/safcm-remote/run"
+)
+
+type Info struct {
+       req  safcm.MsgInfoReq
+       resp safcm.MsgInfoResp
+
+       cmd    *run.Cmd
+       logger *log.PrefixLogger
+}
+
+const logPrefix = "info remote:"
+
+func Handle(req safcm.MsgInfoReq,
+       runner run.Runner, fun log.LogFunc) safcm.MsgInfoResp {
+
+       i := Info{
+               req:    req,
+               logger: log.NewLogger(logPrefix, fun),
+       }
+       i.cmd = run.NewCmd(runner, i.logger)
+
+       err := i.handle()
+       if err != nil {
+               i.resp.Error = fmt.Sprintf("%s %v", logPrefix, err)
+       }
+       return i.resp
+}
+
+func (i *Info) handle() error {
+       i.resp.Goos = runtime.GOOS
+       i.resp.Goarch = runtime.GOARCH
+
+       for _, x := range i.req.DetectGroups {
+               stdout, _, err := i.cmd.Run("detect group",
+                       "/bin/sh", "-c", x)
+               if err != nil {
+                       return err
+               }
+               i.resp.Output = append(i.resp.Output, string(stdout))
+       }
+
+       return nil
+}
diff --git a/cmd/safcm-remote/log/logger.go b/cmd/safcm-remote/log/logger.go
new file mode 100644 (file)
index 0000000..159e097
--- /dev/null
@@ -0,0 +1,60 @@
+// Logging helpers
+
+// Copyright (C) 2021  Simon Ruderich
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+package log
+
+import (
+       "fmt"
+
+       "ruderich.org/simon/safcm"
+)
+
+// LogFunc is a helper type to reduce typing.
+type LogFunc func(level safcm.LogLevel, format string, a ...interface{})
+
+type Logger interface {
+       Verbosef(format string, a ...interface{})
+       Debugf(format string, a ...interface{})
+       Debug2f(format string, a ...interface{})
+}
+
+type PrefixLogger struct {
+       fun    LogFunc
+       prefix string
+}
+
+func NewLogger(prefix string, fun LogFunc) *PrefixLogger {
+       return &PrefixLogger{
+               fun:    fun,
+               prefix: prefix,
+       }
+}
+
+func (l *PrefixLogger) Verbosef(format string, a ...interface{}) {
+       l.log(safcm.LogVerbose, format, a...)
+}
+func (l *PrefixLogger) Debugf(format string, a ...interface{}) {
+       l.log(safcm.LogDebug, format, a...)
+}
+func (l *PrefixLogger) Debug2f(format string, a ...interface{}) {
+       l.log(safcm.LogDebug2, format, a...)
+}
+
+func (l *PrefixLogger) log(level safcm.LogLevel,
+       format string, a ...interface{}) {
+       l.fun(level, "%s %s", l.prefix, fmt.Sprintf(format, a...))
+}
diff --git a/cmd/safcm-remote/main.go b/cmd/safcm-remote/main.go
new file mode 100644 (file)
index 0000000..ea81daf
--- /dev/null
@@ -0,0 +1,86 @@
+// Helper copied to the remote hosts to run commands and deploy configuration
+
+// Copyright (C) 2021  Simon Ruderich
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+package main
+
+import (
+       "fmt"
+       "log"
+       "os"
+
+       "ruderich.org/simon/safcm"
+       "ruderich.org/simon/safcm/cmd/safcm-remote/info"
+       "ruderich.org/simon/safcm/cmd/safcm-remote/run"
+       "ruderich.org/simon/safcm/cmd/safcm-remote/sync"
+)
+
+func main() {
+       if len(os.Args) != 1 {
+               log.SetFlags(0)
+               log.Fatalf("usage: %s", os.Args[0])
+       }
+
+       log.SetFlags(0)
+       err := mainLoop()
+       if err != nil {
+               log.Fatalf("%s: %v", os.Args[0], err)
+       }
+}
+
+func mainLoop() error {
+       conn := safcm.NewGobConn(os.Stdin, os.Stdout)
+
+       var logLevel safcm.LogLevel
+       logFunc := func(level safcm.LogLevel, format string, a ...interface{}) {
+               if logLevel >= level {
+                       conn.Send(safcm.MsgLog{
+                               Level: level,
+                               Text:  fmt.Sprintf(format, a...),
+                       })
+               }
+       }
+
+       var quitResp safcm.MsgQuitResp
+       for {
+               x, err := conn.Recv()
+               if err != nil {
+                       return err
+               }
+
+               var resp safcm.Msg
+               switch x := x.(type) {
+               case safcm.MsgInfoReq:
+                       logLevel = x.LogLevel // set log level globally
+                       resp = info.Handle(x, run.ExecRunner{}, logFunc)
+               case safcm.MsgSyncReq:
+                       resp = sync.Handle(x, run.ExecRunner{}, logFunc)
+               case safcm.MsgQuitReq:
+                       resp = quitResp
+               default:
+                       return fmt.Errorf("unsupported message %#v", x)
+               }
+
+               err = conn.Send(resp)
+               if err != nil {
+                       return err
+               }
+               if resp == quitResp {
+                       break
+               }
+       }
+       return nil
+}
diff --git a/cmd/safcm-remote/run/cmd.go b/cmd/safcm-remote/run/cmd.go
new file mode 100644 (file)
index 0000000..4700ea3
--- /dev/null
@@ -0,0 +1,94 @@
+// Helper type to run and log commands
+
+// Copyright (C) 2021  Simon Ruderich
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+package run
+
+import (
+       "bytes"
+       "fmt"
+       "os/exec"
+       "strings"
+
+       "ruderich.org/simon/safcm/cmd/safcm-remote/log"
+)
+
+type Cmd struct {
+       Runner Runner
+       logger log.Logger
+}
+
+func NewCmd(runner Runner, logger log.Logger) *Cmd {
+       return &Cmd{
+               Runner: runner,
+               logger: logger,
+       }
+}
+
+// Run runs a command and return stdout and stderr.
+//
+// Use this only for commands running on our behalf where we need to parse the
+// output. Use CombinedOutput() for user commands because these should show
+// the complete output and have stdout and stderr in the proper order and not
+// split.
+func (c *Cmd) Run(module string, args ...string) ([]byte, []byte, error) {
+       var stdout, stderr bytes.Buffer
+       cmd := exec.Command(args[0], args[1:]...)
+       cmd.Stdout = &stdout
+       cmd.Stderr = &stderr
+       quoted := QuoteForDebug(cmd)
+       c.logger.Debugf("%s: running %s", module, quoted)
+       err := c.Runner.Run(cmd)
+       if stdout.Len() > 0 {
+               c.logger.Debug2f("%s: command stdout:\n%s",
+                       module, stdout.Bytes())
+       }
+       if stderr.Len() > 0 {
+               c.logger.Debug2f("%s: command stderr:\n%s",
+                       module, stderr.Bytes())
+       }
+       if err != nil {
+               return nil, nil, fmt.Errorf(
+                       "%s failed: %s; stdout: %q, stderr: %q",
+                       quoted, err, stdout.String(), stderr.String())
+       }
+       return stdout.Bytes(), stderr.Bytes(), nil
+}
+
+// CombinedOutputCmd runs the command and returns its combined stdout and
+// stderr.
+func (c *Cmd) CombinedOutputCmd(module string, cmd *exec.Cmd) ([]byte, error) {
+       quoted := QuoteForDebug(cmd)
+       c.logger.Debugf("%s: running %s", module, quoted)
+       out, err := c.Runner.CombinedOutput(cmd)
+       if len(out) > 0 {
+               c.logger.Debug2f("%s: command output:\n%s", module, out)
+       }
+       if err != nil {
+               return nil, fmt.Errorf("%s failed: %s; output: %q",
+                       quoted, err, out)
+       }
+       return out, nil
+}
+
+func QuoteForDebug(cmd *exec.Cmd) string {
+       // TODO: proper shell escaping, remove quotes when not necessary
+       var quoted []string
+       for _, x := range append([]string{cmd.Path}, cmd.Args[1:]...) {
+               quoted = append(quoted, fmt.Sprintf("%q", x))
+       }
+       return strings.Join(quoted, " ")
+}
diff --git a/cmd/safcm-remote/run/runner.go b/cmd/safcm-remote/run/runner.go
new file mode 100644 (file)
index 0000000..b5ea690
--- /dev/null
@@ -0,0 +1,40 @@
+// Interface to run commands
+
+// Copyright (C) 2021  Simon Ruderich
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+package run
+
+import (
+       "os/exec"
+)
+
+// Runner abstracts running commands to permit testing.
+type Runner interface {
+       Run(*exec.Cmd) error
+       CombinedOutput(*exec.Cmd) ([]byte, error)
+}
+
+// ExecRunner implements Runner by calling the corresponding function from
+// exec.Cmd.
+type ExecRunner struct {
+}
+
+func (r ExecRunner) Run(cmd *exec.Cmd) error {
+       return cmd.Run()
+}
+func (r ExecRunner) CombinedOutput(cmd *exec.Cmd) ([]byte, error) {
+       return cmd.CombinedOutput()
+}
diff --git a/cmd/safcm-remote/sync/commands.go b/cmd/safcm-remote/sync/commands.go
new file mode 100644 (file)
index 0000000..abc23bc
--- /dev/null
@@ -0,0 +1,91 @@
+// MsgSyncReq: run commands on the remote host
+
+// Copyright (C) 2021  Simon Ruderich
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+package sync
+
+import (
+       "fmt"
+       "os"
+       "os/exec"
+       "strings"
+
+       "ruderich.org/simon/safcm"
+       "ruderich.org/simon/safcm/cmd/safcm-remote/run"
+)
+
+func (s *Sync) syncCommands() error {
+       // Run triggered commands first
+       for _, path := range s.triggers {
+               for _, x := range s.req.Files[path].TriggerCommands {
+                       err := s.syncCommand(x, path)
+                       if err != nil {
+                               return err
+                       }
+               }
+       }
+       // Regular commands afterwards so they can react on triggers if
+       // necessary
+       for _, x := range s.req.Commands {
+               err := s.syncCommand(x, "")
+               if err != nil {
+                       return err
+               }
+       }
+       return nil
+}
+
+func (s *Sync) syncCommand(command string, trigger string) error {
+       s.resp.CommandChanges = append(s.resp.CommandChanges,
+               safcm.CommandChange{
+                       Command: command,
+                       Trigger: trigger,
+               })
+       if s.req.DryRun {
+               return nil
+       }
+       change := &s.resp.CommandChanges[len(s.resp.CommandChanges)-1]
+
+       cmd := exec.Command("/bin/sh", "-c", command)
+       cmd.Env = safcmEnviroment(s.req.Groups)
+       // Cannot use cmd.CombinedOutputCmd() because we need another log
+       // level (here the command is the actual change and not a side effect)
+       // and different error handling.
+       s.log.Verbosef("commands: running %s", run.QuoteForDebug(cmd))
+       out, err := s.cmd.Runner.CombinedOutput(cmd)
+       if len(out) > 0 {
+               s.log.Debug2f("commands: command output:\n%s", out)
+       }
+       change.Output = string(out)
+       if err != nil {
+               change.Error = err.Error()
+               return fmt.Errorf("%q failed: %v", command, err)
+       }
+
+       return nil
+}
+
+func safcmEnviroment(groups []string) []string {
+       env := os.Environ()
+       // Provide additional environment variables so commands can check
+       // group membership
+       env = append(env,
+               fmt.Sprintf("SAFCM_GROUPS=%s", strings.Join(groups, " ")))
+       for _, x := range groups {
+               env = append(env, fmt.Sprintf("SAFCM_GROUP_%s=%s", x, x))
+       }
+       return env
+}
diff --git a/cmd/safcm-remote/sync/commands_test.go b/cmd/safcm-remote/sync/commands_test.go
new file mode 100644 (file)
index 0000000..aeeea32
--- /dev/null
@@ -0,0 +1,522 @@
+// Copyright (C) 2021  Simon Ruderich
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+package sync
+
+import (
+       "fmt"
+       "io/fs"
+       "os"
+       "os/exec"
+       "reflect"
+       "testing"
+
+       "github.com/google/go-cmp/cmp"
+
+       "ruderich.org/simon/safcm"
+)
+
+func TestSyncCommands(t *testing.T) {
+       env := append(os.Environ(),
+               "SAFCM_GROUPS=all group1 group2 host.example.org",
+               "SAFCM_GROUP_all=all",
+               "SAFCM_GROUP_group1=group1",
+               "SAFCM_GROUP_group2=group2",
+               "SAFCM_GROUP_host.example.org=host.example.org",
+       )
+
+       tests := []struct {
+               name     string
+               req      safcm.MsgSyncReq
+               triggers []string
+               stdout   [][]byte
+               stderr   [][]byte
+               errors   []error
+               expCmds  []*exec.Cmd
+               expDbg   []string
+               expResp  safcm.MsgSyncResp
+               expErr   error
+       }{
+
+               // NOTE: Also update MsgSyncResp in safcm test cases when
+               // changing anything here!
+
+               {
+                       "successful command",
+                       safcm.MsgSyncReq{
+                               Groups: []string{
+                                       "all",
+                                       "group1",
+                                       "group2",
+                                       "host.example.org",
+                               },
+                               Commands: []string{
+                                       "echo; env | grep SAFCM_",
+                               },
+                       },
+                       nil,
+                       [][]byte{[]byte("fake stdout/stderr")},
+                       [][]byte{nil},
+                       []error{nil},
+                       []*exec.Cmd{{
+                               Path: "/bin/sh",
+                               Args: []string{
+                                       "/bin/sh", "-c",
+                                       "echo; env | grep SAFCM_",
+                               },
+                               Env: env,
+                       }},
+                       []string{
+                               `3: sync remote: commands: running "/bin/sh" "-c" "echo; env | grep SAFCM_"`,
+                               "5: sync remote: commands: command output:\nfake stdout/stderr",
+                       },
+                       safcm.MsgSyncResp{
+                               CommandChanges: []safcm.CommandChange{
+                                       {
+                                               Command: "echo; env | grep SAFCM_",
+                                               Output:  "fake stdout/stderr",
+                                       },
+                               },
+                       },
+                       nil,
+               },
+               {
+                       "successful command (dry-run)",
+                       safcm.MsgSyncReq{
+                               DryRun: true,
+                               Groups: []string{
+                                       "all",
+                                       "group1",
+                                       "group2",
+                                       "host.example.org",
+                               },
+                               Commands: []string{
+                                       "echo; env | grep SAFCM_",
+                               },
+                       },
+                       nil,
+                       nil,
+                       nil,
+                       nil,
+                       nil,
+                       nil,
+                       safcm.MsgSyncResp{
+                               CommandChanges: []safcm.CommandChange{
+                                       {
+                                               Command: "echo; env | grep SAFCM_",
+                                       },
+                               },
+                       },
+                       nil,
+               },
+
+               {
+                       "failed command",
+                       safcm.MsgSyncReq{
+                               Groups: []string{
+                                       "all",
+                                       "group1",
+                                       "group2",
+                                       "host.example.org",
+                               },
+                               Commands: []string{
+                                       "echo hi; false",
+                               },
+                       },
+                       nil,
+                       [][]byte{[]byte("fake stdout/stderr")},
+                       [][]byte{nil},
+                       []error{fmt.Errorf("fake error")},
+                       []*exec.Cmd{{
+                               Path: "/bin/sh",
+                               Args: []string{
+                                       "/bin/sh", "-c",
+                                       "echo hi; false",
+                               },
+                               Env: env,
+                       }},
+                       []string{
+                               `3: sync remote: commands: running "/bin/sh" "-c" "echo hi; false"`,
+                               "5: sync remote: commands: command output:\nfake stdout/stderr",
+                       },
+                       safcm.MsgSyncResp{
+                               CommandChanges: []safcm.CommandChange{
+                                       {
+                                               Command: "echo hi; false",
+                                               Output:  "fake stdout/stderr",
+                                               Error:   "fake error",
+                                       },
+                               },
+                       },
+                       fmt.Errorf("\"echo hi; false\" failed: fake error"),
+               },
+               {
+                       "failed command (dry-run)",
+                       safcm.MsgSyncReq{
+                               DryRun: true,
+                               Groups: []string{
+                                       "all",
+                                       "group1",
+                                       "group2",
+                                       "host.example.org",
+                               },
+                               Commands: []string{
+                                       "echo hi; false",
+                               },
+                       },
+                       nil,
+                       nil,
+                       nil,
+                       nil,
+                       nil,
+                       nil,
+                       safcm.MsgSyncResp{
+                               CommandChanges: []safcm.CommandChange{
+                                       {
+                                               Command: "echo hi; false",
+                                       },
+                               },
+                       },
+                       nil,
+               },
+
+               {
+                       "multiple commands, abort on first failed",
+                       safcm.MsgSyncReq{
+                               Groups: []string{
+                                       "all",
+                                       "group1",
+                                       "group2",
+                                       "host.example.org",
+                               },
+                               Commands: []string{
+                                       "echo first",
+                                       "echo second",
+                                       "false",
+                                       "echo third",
+                               },
+                       },
+                       nil,
+                       [][]byte{
+                               []byte("fake stdout/stderr first"),
+                               []byte("fake stdout/stderr second"),
+                               nil,
+                       },
+                       [][]byte{
+                               nil,
+                               nil,
+                               nil,
+                       },
+                       []error{
+                               nil,
+                               nil,
+                               fmt.Errorf("fake error"),
+                       },
+                       []*exec.Cmd{{
+                               Path: "/bin/sh",
+                               Args: []string{
+                                       "/bin/sh", "-c",
+                                       "echo first",
+                               },
+                               Env: env,
+                       }, {
+                               Path: "/bin/sh",
+                               Args: []string{
+                                       "/bin/sh", "-c",
+                                       "echo second",
+                               },
+                               Env: env,
+                       }, {
+                               Path: "/bin/sh",
+                               Args: []string{
+                                       "/bin/sh", "-c",
+                                       "false",
+                               },
+                               Env: env,
+                       }},
+                       []string{
+                               `3: sync remote: commands: running "/bin/sh" "-c" "echo first"`,
+                               "5: sync remote: commands: command output:\nfake stdout/stderr first",
+                               `3: sync remote: commands: running "/bin/sh" "-c" "echo second"`,
+                               "5: sync remote: commands: command output:\nfake stdout/stderr second",
+                               `3: sync remote: commands: running "/bin/sh" "-c" "false"`,
+                       },
+                       safcm.MsgSyncResp{
+                               CommandChanges: []safcm.CommandChange{
+                                       {
+                                               Command: "echo first",
+                                               Output:  "fake stdout/stderr first",
+                                       },
+                                       {
+                                               Command: "echo second",
+                                               Output:  "fake stdout/stderr second",
+                                       },
+                                       {
+                                               Command: "false",
+                                               Output:  "",
+                                               Error:   "fake error",
+                                       },
+                               },
+                       },
+                       fmt.Errorf("\"false\" failed: fake error"),
+               },
+
+               {
+                       "triggers",
+                       safcm.MsgSyncReq{
+                               Groups: []string{
+                                       "all",
+                                       "group1",
+                                       "group2",
+                                       "host.example.org",
+                               },
+                               Files: map[string]*safcm.File{
+                                       ".": {
+                                               Path:      ".",
+                                               Mode:      fs.ModeDir | 0700,
+                                               Uid:       -1,
+                                               Gid:       -1,
+                                               OrigGroup: "group",
+                                               TriggerCommands: []string{
+                                                       "echo trigger .",
+                                               },
+                                       },
+                                       "dir": {
+                                               Path:      "dir",
+                                               Mode:      fs.ModeDir | 0755,
+                                               Uid:       -1,
+                                               Gid:       -1,
+                                               OrigGroup: "group",
+                                               TriggerCommands: []string{
+                                                       "echo trigger dir",
+                                               },
+                                       },
+                                       "dir/file": {
+                                               Path:      "dir/file",
+                                               Mode:      0644,
+                                               Uid:       -1,
+                                               Gid:       -1,
+                                               Data:      []byte("content\n"),
+                                               OrigGroup: "group",
+                                               TriggerCommands: []string{
+                                                       "echo trigger dir/file",
+                                               },
+                                       },
+                               },
+                               Commands: []string{
+                                       "echo; env | grep SAFCM_",
+                               },
+                       },
+                       []string{
+                               ".",
+                               "dir",
+                       },
+                       [][]byte{
+                               []byte("fake stdout/stderr ."),
+                               []byte("fake stdout/stderr dir"),
+                               []byte("fake stdout/stderr"),
+                       },
+                       [][]byte{
+                               nil,
+                               nil,
+                               nil,
+                       },
+                       []error{
+                               nil,
+                               nil,
+                               nil,
+                       },
+                       []*exec.Cmd{{
+                               Path: "/bin/sh",
+                               Args: []string{
+                                       "/bin/sh", "-c",
+                                       "echo trigger .",
+                               },
+                               Env: env,
+                       }, {
+                               Path: "/bin/sh",
+                               Args: []string{
+                                       "/bin/sh", "-c",
+                                       "echo trigger dir",
+                               },
+                               Env: env,
+                       }, {
+                               Path: "/bin/sh",
+                               Args: []string{
+                                       "/bin/sh", "-c",
+                                       "echo; env | grep SAFCM_",
+                               },
+                               Env: env,
+                       }},
+                       []string{
+                               `3: sync remote: commands: running "/bin/sh" "-c" "echo trigger ."`,
+                               "5: sync remote: commands: command output:\nfake stdout/stderr .",
+                               `3: sync remote: commands: running "/bin/sh" "-c" "echo trigger dir"`,
+                               "5: sync remote: commands: command output:\nfake stdout/stderr dir",
+                               `3: sync remote: commands: running "/bin/sh" "-c" "echo; env | grep SAFCM_"`,
+                               "5: sync remote: commands: command output:\nfake stdout/stderr",
+                       },
+                       safcm.MsgSyncResp{
+                               CommandChanges: []safcm.CommandChange{
+                                       {
+                                               Command: "echo trigger .",
+                                               Trigger: ".",
+                                               Output:  "fake stdout/stderr .",
+                                       },
+                                       {
+                                               Command: "echo trigger dir",
+                                               Trigger: "dir",
+                                               Output:  "fake stdout/stderr dir",
+                                       },
+                                       {
+                                               Command: "echo; env | grep SAFCM_",
+                                               Output:  "fake stdout/stderr",
+                                       },
+                               },
+                       },
+                       nil,
+               },
+
+               {
+                       "failed trigger",
+                       safcm.MsgSyncReq{
+                               Groups: []string{
+                                       "all",
+                                       "group1",
+                                       "group2",
+                                       "host.example.org",
+                               },
+                               Files: map[string]*safcm.File{
+                                       ".": {
+                                               Path:      ".",
+                                               Mode:      fs.ModeDir | 0700,
+                                               Uid:       -1,
+                                               Gid:       -1,
+                                               OrigGroup: "group",
+                                               TriggerCommands: []string{
+                                                       "echo trigger .",
+                                               },
+                                       },
+                                       "dir": {
+                                               Path:      "dir",
+                                               Mode:      fs.ModeDir | 0755,
+                                               Uid:       -1,
+                                               Gid:       -1,
+                                               OrigGroup: "group",
+                                               TriggerCommands: []string{
+                                                       "false",
+                                               },
+                                       },
+                                       "dir/file": {
+                                               Path:      "dir/file",
+                                               Mode:      0644,
+                                               Uid:       -1,
+                                               Gid:       -1,
+                                               Data:      []byte("content\n"),
+                                               OrigGroup: "group",
+                                               TriggerCommands: []string{
+                                                       "echo trigger dir/file",
+                                               },
+                                       },
+                               },
+                               Commands: []string{
+                                       "echo; env | grep SAFCM_",
+                               },
+                       },
+                       []string{
+                               ".",
+                               "dir",
+                       },
+                       [][]byte{
+                               []byte("fake stdout/stderr ."),
+                               []byte("fake stdout/stderr dir"),
+                       },
+                       [][]byte{
+                               nil,
+                               nil,
+                       },
+                       []error{
+                               nil,
+                               fmt.Errorf("fake error"),
+                       },
+                       []*exec.Cmd{{
+                               Path: "/bin/sh",
+                               Args: []string{
+                                       "/bin/sh", "-c",
+                                       "echo trigger .",
+                               },
+                               Env: env,
+                       }, {
+                               Path: "/bin/sh",
+                               Args: []string{
+                                       "/bin/sh", "-c",
+                                       "false",
+                               },
+                               Env: env,
+                       }},
+                       []string{
+                               `3: sync remote: commands: running "/bin/sh" "-c" "echo trigger ."`,
+                               "5: sync remote: commands: command output:\nfake stdout/stderr .",
+                               `3: sync remote: commands: running "/bin/sh" "-c" "false"`,
+                               "5: sync remote: commands: command output:\nfake stdout/stderr dir",
+                       },
+                       safcm.MsgSyncResp{
+                               CommandChanges: []safcm.CommandChange{
+                                       {
+                                               Command: "echo trigger .",
+                                               Trigger: ".",
+                                               Output:  "fake stdout/stderr .",
+                                       },
+                                       {
+                                               Command: "false",
+                                               Trigger: "dir",
+                                               Output:  "fake stdout/stderr dir",
+                                               Error:   "fake error",
+                                       },
+                               },
+                       },
+                       fmt.Errorf("\"false\" failed: fake error"),
+               },
+       }
+
+       for _, tc := range tests {
+               s, res := prepareSync(tc.req, &testRunner{
+                       t:         t,
+                       name:      tc.name,
+                       expCmds:   tc.expCmds,
+                       resStdout: tc.stdout,
+                       resStderr: tc.stderr,
+                       resError:  tc.errors,
+               })
+               s.triggers = tc.triggers
+
+               err := s.syncCommands()
+               // Ugly but the simplest way to compare errors (including nil)
+               if fmt.Sprintf("%s", err) != fmt.Sprintf("%s", tc.expErr) {
+                       t.Errorf("%s: err = %#v, want %#v",
+                               tc.name, err, tc.expErr)
+               }
+               dbg := res.Wait()
+
+               if !reflect.DeepEqual(tc.expResp, s.resp) {
+                       t.Errorf("%s: resp: %s", tc.name,
+                               cmp.Diff(tc.expResp, s.resp))
+               }
+               if !reflect.DeepEqual(tc.expDbg, dbg) {
+                       t.Errorf("%s: dbg: %s", tc.name,
+                               cmp.Diff(tc.expDbg, dbg))
+               }
+       }
+}
diff --git a/cmd/safcm-remote/sync/files.go b/cmd/safcm-remote/sync/files.go
new file mode 100644 (file)
index 0000000..06bc406
--- /dev/null
@@ -0,0 +1,524 @@
+// MsgSyncReq: copy files to the remote host
+
+// Copyright (C) 2021  Simon Ruderich
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+package sync
+
+import (
+       "bytes"
+       "fmt"
+       "io"
+       "io/fs"
+       "math/rand"
+       "net/http"
+       "os"
+       "os/user"
+       "path/filepath"
+       "sort"
+       "strconv"
+       "strings"
+       "syscall"
+       "time"
+
+       "github.com/ianbruene/go-difflib/difflib"
+
+       "ruderich.org/simon/safcm"
+)
+
+func (s *Sync) syncFiles() error {
+       // To create random file names for symlinks
+       rand.Seed(time.Now().UnixNano())
+
+       // Sort for deterministic order and so parent directories are present
+       // when files in them are created
+       var files []*safcm.File
+       for _, x := range s.req.Files {
+               files = append(files, x)
+       }
+       sort.Slice(files, func(i, j int) bool {
+               return files[i].Path < files[j].Path
+       })
+
+       for _, x := range files {
+               var changed bool
+               err := s.syncFile(x, &changed)
+               if err != nil {
+                       return fmt.Errorf("%q: %v", x.Path, err)
+               }
+               if changed {
+                       s.queueTriggers(x)
+               }
+       }
+
+       return nil
+}
+
+func (s *Sync) syncFile(file *safcm.File, changed *bool) error {
+       // The general strategy is "update by rename": If any property of a
+       // file changes it will be written to a temporary file and then
+       // renamed "over" the original file. This is simple and prevents race
+       // conditions where the file is partially readable while changes to
+       // permissions or owner/group are applied. However, this strategy does
+       // not work for directories which must be removed first (was
+       // directory), must remove the existing file (will be directory) or
+       // must be directly modified (changed permissions or owner). In the
+       // first two cases the old path is removed. In the last the directory
+       // is modified (carefully) in place.
+       //
+       // The implementation is careful not to follow any symlinks to prevent
+       // possible race conditions which can be exploited and are especially
+       // dangerous when running with elevated privileges (which will most
+       // likely be the case).
+
+       err := s.fileResolveIds(file)
+       if err != nil {
+               return err
+       }
+
+       change := safcm.FileChange{
+               Path: file.Path,
+               New: safcm.FileChangeInfo{
+                       Mode:  file.Mode,
+                       User:  file.User,
+                       Uid:   file.Uid,
+                       Group: file.Group,
+                       Gid:   file.Gid,
+               },
+       }
+
+       debugf := func(format string, a ...interface{}) {
+               s.log.Debugf("files: %q (%s): %s",
+                       file.Path, file.OrigGroup, fmt.Sprintf(format, a...))
+       }
+       verbosef := func(format string, a ...interface{}) {
+               s.log.Verbosef("files: %q (%s): %s",
+                       file.Path, file.OrigGroup, fmt.Sprintf(format, a...))
+       }
+
+       var oldStat fs.FileInfo
+reopen:
+       oldFh, err := os.OpenFile(file.Path,
+               // O_NOFOLLOW prevents symlink attacks
+               // O_NONBLOCK is necessary to prevent blocking on FIFOs
+               os.O_RDONLY|syscall.O_NOFOLLOW|syscall.O_NONBLOCK, 0)
+       if err != nil {
+               err := err.(*fs.PathError)
+               if err.Err == syscall.ELOOP {
+                       // Check if ELOOP was caused not by O_NOFOLLOW but by
+                       // too many nested symlinks before the final path
+                       // component.
+                       x, err := os.Lstat(file.Path)
+                       if err != nil {
+                               return err
+                       }
+                       if x.Mode().Type() != fs.ModeSymlink {
+                               debugf("type changed from symlink to %s, retry",
+                                       x.Mode().Type())
+                               goto reopen
+                       }
+                       // ELOOP from symbolic link, this is fine
+                       oldStat = x
+               } else if os.IsNotExist(err) {
+                       change.Created = true
+                       debugf("will create")
+               } else {
+                       return err
+               }
+       } else {
+               defer oldFh.Close()
+
+               x, err := oldFh.Stat()
+               if err != nil {
+                       return err
+               }
+               oldStat = x
+       }
+
+       var oldData []byte
+       var changeType, changePerm, changeUserOrGroup, changeData bool
+       if !change.Created {
+               // Compare permissions
+               change.Old.Mode = oldStat.Mode()
+               if change.Old.Mode != file.Mode {
+                       if change.Old.Mode.Type() != file.Mode.Type() {
+                               changeType = true
+                               debugf("type differs %s -> %s",
+                                       change.Old.Mode.Type(),
+                                       file.Mode.Type())
+                       } else {
+                               // Be careful with .Perm() which does not
+                               // contain the setuid/setgid/sticky bits!
+                               changePerm = true
+                               debugf("permission differs %s -> %s",
+                                       change.Old.Mode, file.Mode)
+                       }
+               }
+
+               // Compare user/group
+               x, ok := oldStat.Sys().(*syscall.Stat_t)
+               if !ok {
+                       return fmt.Errorf("unsupported Stat().Sys()")
+               }
+               change.Old.Uid = int(x.Uid)
+               change.Old.Gid = int(x.Gid)
+               if change.Old.Uid != file.Uid || change.Old.Gid != file.Gid {
+                       changeUserOrGroup = true
+                       debugf("uid/gid differs %d/%d -> %d/%d",
+                               change.Old.Uid, change.Old.Gid,
+                               file.Uid, file.Gid)
+               }
+               u, g, err := resolveIdsToNames(change.Old.Uid, change.Old.Gid)
+               // Errors are not relevant as this is only used to report the
+               // change. If the user/group no longer exits only the ids will
+               // be reported.
+               if err == nil {
+                       change.Old.User = u
+                       change.Old.Group = g
+               }
+
+               // Compare file content (if possible)
+               switch change.Old.Mode.Type() {
+               case 0: // regular file
+                       x, err := io.ReadAll(oldFh)
+                       if err != nil {
+                               return fmt.Errorf("reading old content: %v",
+                                       err)
+                       }
+                       oldData = x
+               case fs.ModeSymlink:
+                       x, err := os.Readlink(file.Path)
+                       if err != nil {
+                               return fmt.Errorf("reading old content: %v",
+                                       err)
+                       }
+                       oldData = []byte(x)
+               }
+               if !changeType && file.Mode.Type() != fs.ModeDir {
+                       if !bytes.Equal(oldData, file.Data) {
+                               changeData = true
+                               debugf("content differs")
+                       }
+               }
+       }
+       oldStat = nil // prevent accidental use
+
+       // No changes
+       if !change.Created && !changeType &&
+               !changePerm && !changeUserOrGroup &&
+               !changeData {
+               debugf("unchanged")
+               return nil
+       }
+       *changed = true
+
+       // Don't show a diff with the full content for newly created files or
+       // on type changes. This is just noise for the user as the new file
+       // content is obvious. But we always want to see a diff when files are
+       // replaced because this destroys data.
+       if !change.Created &&
+               (change.Old.Mode.Type() == 0 ||
+                       change.Old.Mode.Type() == fs.ModeSymlink) {
+               change.DataDiff, err = diffData(oldData, file.Data)
+               if err != nil {
+                       return err
+               }
+       }
+
+       // Add change here so it is stored even when applying it fails. This
+       // way the user knows exactly what was attempted.
+       s.resp.FileChanges = append(s.resp.FileChanges, change)
+
+       if change.Created {
+               verbosef("creating")
+       } else {
+               verbosef("updating")
+       }
+
+       if s.req.DryRun {
+               debugf("dry-run, skipping changes")
+               return nil
+       }
+
+       // We cannot rename over directories and vice versa
+       if changeType && (change.Old.Mode.IsDir() || file.Mode.IsDir()) {
+               debugf("removing (due to type change)")
+               err := os.RemoveAll(file.Path)
+               if err != nil {
+                       return err
+               }
+       }
+
+       // Directory: create new directory (also type change to directory)
+       if file.Mode.IsDir() && (change.Created || changeType) {
+               debugf("creating directory")
+               err := os.Mkdir(file.Path, 0700)
+               if err != nil {
+                       return err
+               }
+               // We must be careful not to chmod arbitrary files. If the
+               // target directory is writable then it might have changed to
+               // a symlink at this point. There's no lchmod so open the
+               // directory.
+               debugf("chmodding %s", file.Mode)
+               dh, err := os.OpenFile(file.Path,
+                       os.O_RDONLY|syscall.O_NOFOLLOW|syscall.O_NONBLOCK, 0)
+               if err != nil {
+                       return err
+               }
+               err = dh.Chmod(file.Mode)
+               if err != nil {
+                       dh.Close()
+                       return err
+               }
+               // Less restrictive access is not relevant here because there
+               // are no files present yet.
+               debugf("chowning %d/%d", file.Uid, file.Gid)
+               err = dh.Chown(file.Uid, file.Gid)
+               if err != nil {
+                       dh.Close()
+                       return err
+               }
+               dh.Close()
+               return nil
+       }
+       // Directory: changed permission or user/group
+       if file.Mode.IsDir() {
+               // We don't know if the new permission or if the new
+               // user/group is more restrictive (e.g. root:root 0750 ->
+               // user:group 0700; applying group first gives group
+               // unexpected access). To prevent a short window where the
+               // access might be too lax we temporarily deny all access.
+               if changePerm && changeUserOrGroup {
+                       // Only drop group and other permission because user
+                       // has access anyway (either before or after the
+                       // change). This also prevents temporary errors during
+                       // the error when the user tries to access this
+                       // directory (access for the group will fail though).
+                       mode := change.Old.Mode & fs.ModePerm & 0700
+                       debugf("chmodding %#o (temporary)", mode)
+                       err := oldFh.Chmod(mode)
+                       if err != nil {
+                               return err
+                       }
+               }
+               if changeUserOrGroup {
+                       debugf("chowning %d/%d", file.Uid, file.Gid)
+                       err := oldFh.Chown(file.Uid, file.Gid)
+                       if err != nil {
+                               return err
+                       }
+               }
+               if changePerm {
+                       debugf("chmodding %s", file.Mode)
+                       err := oldFh.Chmod(file.Mode)
+                       if err != nil {
+                               return err
+                       }
+               }
+               return nil
+       }
+
+       dir := filepath.Dir(file.Path)
+       base := filepath.Base(file.Path)
+
+       var tmpPath string
+       switch file.Mode.Type() {
+       case 0: // regular file
+               debugf("creating temporary file %q",
+                       filepath.Join(dir, "."+base+"*"))
+               // Create hidden file which should be ignored by most other
+               // tools and thus not affect anything during creation
+               newFh, err := os.CreateTemp(dir, "."+base)
+               if err != nil {
+                       return err
+               }
+               tmpPath = newFh.Name()
+
+               _, err = newFh.Write(file.Data)
+               if err != nil {
+                       newFh.Close()
+                       os.Remove(tmpPath)
+                       return err
+               }
+               // CreateTemp() creates the file with 0600
+               err = newFh.Chown(file.Uid, file.Gid)
+               if err != nil {
+                       newFh.Close()
+                       os.Remove(tmpPath)
+                       return err
+               }
+               err = newFh.Chmod(file.Mode)
+               if err != nil {
+                       newFh.Close()
+                       os.Remove(tmpPath)
+                       return err
+               }
+               err = newFh.Sync()
+               if err != nil {
+                       newFh.Close()
+                       os.Remove(tmpPath)
+                       return err
+               }
+               err = newFh.Close()
+               if err != nil {
+                       newFh.Close()
+                       os.Remove(tmpPath)
+                       return err
+               }
+
+       case fs.ModeSymlink:
+               i := 0
+       retry:
+               // Similar to os.CreateTemp() but for symlinks which we cannot
+               // open as file
+               tmpPath = filepath.Join(dir,
+                       "."+base+strconv.Itoa(rand.Int()))
+               debugf("creating temporary symlink %q", tmpPath)
+               err := os.Symlink(string(file.Data), tmpPath)
+               if err != nil {
+                       if os.IsExist(err) && i < 10000 {
+                               i++
+                               goto retry
+                       }
+                       return err
+               }
+               err = os.Lchown(tmpPath, file.Uid, file.Gid)
+               if err != nil {
+                       os.Remove(tmpPath)
+                       return err
+               }
+               // Permissions are irrelevant for symlinks
+
+       default:
+               panic(fmt.Sprintf("invalid file type %s", file.Mode))
+       }
+
+       debugf("renaming %q", tmpPath)
+       err = os.Rename(tmpPath, file.Path)
+       if err != nil {
+               os.Remove(tmpPath)
+               return err
+       }
+       err = syncPath(dir)
+       if err != nil {
+               return err
+       }
+
+       return nil
+}
+
+func (s *Sync) fileResolveIds(file *safcm.File) error {
+       if file.User != "" && file.Uid != -1 {
+               return fmt.Errorf("cannot set both User (%q) and Uid (%d)",
+                       file.User, file.Uid)
+       }
+       if file.Group != "" && file.Gid != -1 {
+               return fmt.Errorf("cannot set both Group (%q) and Gid (%d)",
+                       file.Group, file.Gid)
+       }
+
+       if file.User == "" && file.Uid == -1 {
+               file.User = s.defaultUser
+       }
+       if file.User != "" {
+               x, err := user.Lookup(file.User)
+               if err != nil {
+                       return err
+               }
+               id, err := strconv.Atoi(x.Uid)
+               if err != nil {
+                       return err
+               }
+               file.Uid = id
+       }
+
+       if file.Group == "" && file.Gid == -1 {
+               file.Group = s.defaultGroup
+       }
+       if file.Group != "" {
+               x, err := user.LookupGroup(file.Group)
+               if err != nil {
+                       return err
+               }
+               id, err := strconv.Atoi(x.Gid)
+               if err != nil {
+                       return err
+               }
+               file.Gid = id
+       }
+
+       return nil
+}
+
+func resolveIdsToNames(uid, gid int) (string, string, error) {
+       u, err := user.LookupId(strconv.Itoa(uid))
+       if err != nil {
+               return "", "", err
+       }
+       g, err := user.LookupGroupId(strconv.Itoa(gid))
+       if err != nil {
+               return "", "", err
+       }
+       return u.Username, g.Name, nil
+}
+
+func diffData(oldData []byte, newData []byte) (string, error) {
+       oldBin := !strings.HasPrefix(http.DetectContentType(oldData), "text/")
+       newBin := !strings.HasPrefix(http.DetectContentType(newData), "text/")
+       if oldBin && newBin {
+               return "Binary files differ, cannot show diff", nil
+       }
+       if oldBin {
+               oldData = []byte("<binary content>\n")
+       }
+       if newBin {
+               newData = []byte("<binary content>\n")
+       }
+
+       // TODO: difflib shows empty context lines at the end of the file
+       // which should not be there
+       // TODO: difflib has issues with missing newlines in either side
+       result, err := difflib.GetUnifiedDiffString(difflib.LineDiffParams{
+               A:       difflib.SplitLines(string(oldData)),
+               B:       difflib.SplitLines(string(newData)),
+               Context: 3,
+       })
+       if err != nil {
+               return "", err
+       }
+       return result, nil
+}
+
+// syncPath syncs path, which should be a directory. To guarantee durability
+// it must be called on a parent directory after adding, renaming or removing
+// files therein.
+//
+// Calling sync on the files itself is not enough according to POSIX; man 2
+// fsync: "Calling fsync() does not necessarily ensure that the entry in the
+// directory containing the file has also reached disk. For that an explicit
+// fsync() on a file descriptor for the directory is also needed."
+func syncPath(path string) error {
+       x, err := os.Open(path)
+       if err != nil {
+               return err
+       }
+       err = x.Sync()
+       closeErr := x.Close()
+       if err != nil {
+               return err
+       }
+       return closeErr
+}
diff --git a/cmd/safcm-remote/sync/files_test.go b/cmd/safcm-remote/sync/files_test.go
new file mode 100644 (file)
index 0000000..22daa63
--- /dev/null
@@ -0,0 +1,2572 @@
+// Copyright (C) 2021  Simon Ruderich
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+package sync
+
+import (
+       "fmt"
+       "io/fs"
+       "math/rand"
+       "os"
+       "os/user"
+       "path/filepath"
+       "reflect"
+       "regexp"
+       "strconv"
+       "syscall"
+       "testing"
+
+       "github.com/google/go-cmp/cmp"
+
+       "ruderich.org/simon/safcm"
+)
+
+type File struct {
+       Path string
+       Mode fs.FileMode
+       Data []byte
+}
+
+func walkDir(basePath string) ([]File, error) {
+       var res []File
+       err := filepath.WalkDir(basePath, func(path string, d fs.DirEntry, err error) error {
+               if err != nil {
+                       return err
+               }
+               info, err := d.Info()
+               if err != nil {
+                       return err
+               }
+               rel, err := filepath.Rel(basePath, path)
+               if err != nil {
+                       return err
+               }
+
+               f := File{
+                       Path: rel,
+                       Mode: info.Mode(),
+               }
+               if f.Mode.Type() == 0 {
+                       x, err := os.ReadFile(path)
+                       if err != nil {
+                               return err
+                       }
+                       f.Data = x
+               } else if f.Mode.Type() == fs.ModeSymlink {
+                       x, err := os.Readlink(path)
+                       if err != nil {
+                               return err
+                       }
+                       f.Data = []byte(x)
+               }
+               res = append(res, f)
+               return nil
+       })
+       if err != nil {
+               return nil, err
+       }
+       return res, nil
+}
+
+var randFilesRegexp = regexp.MustCompile(`\d+"$`)
+
+func TestSyncFiles(t *testing.T) {
+       cwd, err := os.Getwd()
+       if err != nil {
+               t.Fatal(err)
+       }
+       defer os.Chdir(cwd)
+
+       err = os.RemoveAll("testdata")
+       if err != nil {
+               t.Fatal(err)
+       }
+       err = os.Mkdir("testdata", 0700)
+       if err != nil {
+               t.Fatal(err)
+       }
+
+       root := File{
+               Path: ".",
+               Mode: fs.ModeDir | 0700,
+       }
+       user, uid, group, gid := currentUserAndGroup()
+
+       tmpTestFilePath := "/tmp/safcm-sync-files-test-file"
+
+       tests := []struct {
+               name     string
+               req      safcm.MsgSyncReq
+               prepare  func()
+               triggers []string
+               expFiles []File
+               expResp  safcm.MsgSyncResp
+               expDbg   []string
+               expErr   error
+       }{
+
+               // NOTE: Also update MsgSyncResp in safcm test cases when
+               // changing anything here!
+
+               // See TestSyncFile() for most file related tests. This
+               // function only tests the overall results and triggers.
+
+               {
+                       "basic: create",
+                       safcm.MsgSyncReq{
+                               Files: map[string]*safcm.File{
+                                       ".": {
+                                               Path:      ".",
+                                               Mode:      fs.ModeDir | 0700,
+                                               Uid:       -1,
+                                               Gid:       -1,
+                                               OrigGroup: "group",
+                                       },
+                                       "dir": {
+                                               Path:      "dir",
+                                               Mode:      fs.ModeDir | 0755,
+                                               Uid:       -1,
+                                               Gid:       -1,
+                                               OrigGroup: "group",
+                                       },
+                                       "dir/file": {
+                                               Path:      "dir/file",
+                                               Mode:      0644,
+                                               Uid:       -1,
+                                               Gid:       -1,
+                                               Data:      []byte("content\n"),
+                                               OrigGroup: "group",
+                                       },
+                               },
+                       },
+                       nil,
+                       nil,
+                       []File{
+                               root,
+                               {
+                                       Path: "dir",
+                                       Mode: fs.ModeDir | 0755,
+                               },
+                               {
+                                       Path: "dir/file",
+                                       Mode: 0644,
+                                       Data: []byte("content\n"),
+                               },
+                       },
+                       safcm.MsgSyncResp{
+                               FileChanges: []safcm.FileChange{
+                                       {
+                                               Path:    "dir",
+                                               Created: true,
+                                               New: safcm.FileChangeInfo{
+                                                       Mode:  fs.ModeDir | 0755,
+                                                       User:  user,
+                                                       Uid:   uid,
+                                                       Group: group,
+                                                       Gid:   gid,
+                                               },
+                                       },
+                                       {
+                                               Path:    "dir/file",
+                                               Created: true,
+                                               New: safcm.FileChangeInfo{
+                                                       Mode:  0644,
+                                                       User:  user,
+                                                       Uid:   uid,
+                                                       Group: group,
+                                                       Gid:   gid,
+                                               },
+                                       },
+                               },
+                       },
+                       []string{
+                               `4: sync remote: files: "." (group): unchanged`,
+                               `4: sync remote: files: "dir" (group): will create`,
+                               `3: sync remote: files: "dir" (group): creating`,
+                               `4: sync remote: files: "dir" (group): creating directory`,
+                               `4: sync remote: files: "dir" (group): chmodding drwxr-xr-x`,
+                               fmt.Sprintf(`4: sync remote: files: "dir" (group): chowning %d/%d`, uid, gid),
+                               `4: sync remote: files: "dir/file" (group): will create`,
+                               `3: sync remote: files: "dir/file" (group): creating`,
+                               `4: sync remote: files: "dir/file" (group): creating temporary file "dir/.file*"`,
+                               `4: sync remote: files: "dir/file" (group): renaming "dir/.fileRND"`,
+                       },
+                       nil,
+               },
+
+               {
+                       "basic: no change",
+                       safcm.MsgSyncReq{
+                               Files: map[string]*safcm.File{
+                                       ".": {
+                                               Path:      ".",
+                                               Mode:      fs.ModeDir | 0700,
+                                               Uid:       -1,
+                                               Gid:       -1,
+                                               OrigGroup: "group",
+                                       },
+                                       "dir": {
+                                               Path:      "dir",
+                                               Mode:      fs.ModeDir | 0755,
+                                               Uid:       -1,
+                                               Gid:       -1,
+                                               OrigGroup: "group",
+                                       },
+                                       "dir/file": {
+                                               Path:      "dir/file",
+                                               Mode:      0644,
+                                               Uid:       -1,
+                                               Gid:       -1,
+                                               Data:      []byte("content\n"),
+                                               OrigGroup: "group",
+                                       },
+                               },
+                       },
+                       func() {
+                               createDirectory("dir", 0755)
+                               createFile("dir/file", "content\n", 0644)
+                       },
+                       nil,
+                       []File{
+                               root,
+                               {
+                                       Path: "dir",
+                                       Mode: fs.ModeDir | 0755,
+                               },
+                               {
+                                       Path: "dir/file",
+                                       Mode: 0644,
+                                       Data: []byte("content\n"),
+                               },
+                       },
+                       safcm.MsgSyncResp{},
+                       []string{
+                               `4: sync remote: files: "." (group): unchanged`,
+                               `4: sync remote: files: "dir" (group): unchanged`,
+                               `4: sync remote: files: "dir/file" (group): unchanged`,
+                       },
+                       nil,
+               },
+
+               {
+                       "invalid File: user",
+                       safcm.MsgSyncReq{
+                               Files: map[string]*safcm.File{
+                                       ".": {
+                                               Path:      ".",
+                                               Mode:      fs.ModeDir | 0700,
+                                               User:      "user",
+                                               Uid:       1,
+                                               Gid:       -1,
+                                               OrigGroup: "group",
+                                       },
+                               },
+                       },
+                       nil,
+                       nil,
+                       []File{
+                               root,
+                       },
+                       safcm.MsgSyncResp{},
+                       nil,
+                       fmt.Errorf("\".\": cannot set both User (\"user\") and Uid (1)"),
+               },
+               {
+                       "invalid File: group",
+                       safcm.MsgSyncReq{
+                               Files: map[string]*safcm.File{
+                                       ".": {
+                                               Path:      ".",
+                                               Mode:      fs.ModeDir | 0700,
+                                               Uid:       -1,
+                                               Group:     "group",
+                                               Gid:       1,
+                                               OrigGroup: "group",
+                                       },
+                               },
+                       },
+                       nil,
+                       nil,
+                       []File{
+                               root,
+                       },
+                       safcm.MsgSyncResp{},
+                       nil,
+                       fmt.Errorf("\".\": cannot set both Group (\"group\") and Gid (1)"),
+               },
+
+               {
+                       // We use relative paths for most tests because we
+                       // don't want to modify the running system. Use this
+                       // test (and the one below for triggers) as a basic
+                       // check that absolute paths work.
+                       "absolute paths: no change",
+                       safcm.MsgSyncReq{
+                               Files: map[string]*safcm.File{
+                                       "/": {
+                                               Path:      "/",
+                                               Mode:      fs.ModeDir | 0755,
+                                               User:      "root",
+                                               Uid:       -1,
+                                               Group:     "root",
+                                               Gid:       -1,
+                                               OrigGroup: "group",
+                                       },
+                                       "/etc": {
+                                               Path:      "/etc",
+                                               Mode:      fs.ModeDir | 0755,
+                                               User:      "root",
+                                               Uid:       -1,
+                                               Group:     "root",
+                                               Gid:       -1,
+                                               OrigGroup: "group",
+                                       },
+                                       "/tmp": {
+                                               Path:      "/tmp",
+                                               Mode:      fs.ModeDir | 0777 | fs.ModeSticky,
+                                               User:      "root",
+                                               Uid:       -1,
+                                               Group:     "root",
+                                               Gid:       -1,
+                                               OrigGroup: "group",
+                                       },
+                                       "/var/tmp": {
+                                               Path:      "/var/tmp",
+                                               Mode:      fs.ModeDir | 0777 | fs.ModeSticky,
+                                               User:      "root",
+                                               Uid:       -1,
+                                               Group:     "root",
+                                               Gid:       -1,
+                                               OrigGroup: "group",
+                                       },
+                               },
+                       },
+                       nil,
+                       nil,
+                       []File{
+                               root,
+                       },
+                       safcm.MsgSyncResp{},
+                       []string{
+                               `4: sync remote: files: "/" (group): unchanged`,
+                               `4: sync remote: files: "/etc" (group): unchanged`,
+                               `4: sync remote: files: "/tmp" (group): unchanged`,
+                               `4: sync remote: files: "/var/tmp" (group): unchanged`,
+                       },
+                       nil,
+               },
+
+               {
+                       "triggers: no change",
+                       safcm.MsgSyncReq{
+                               Files: map[string]*safcm.File{
+                                       ".": {
+                                               Path:      ".",
+                                               Mode:      fs.ModeDir | 0700,
+                                               Uid:       -1,
+                                               Gid:       -1,
+                                               OrigGroup: "group",
+                                               TriggerCommands: []string{
+                                                       "echo trigger .",
+                                               },
+                                       },
+                                       "dir": {
+                                               Path:      "dir",
+                                               Mode:      fs.ModeDir | 0755,
+                                               Uid:       -1,
+                                               Gid:       -1,
+                                               OrigGroup: "group",
+                                               TriggerCommands: []string{
+                                                       "echo trigger dir",
+                                               },
+                                       },
+                                       "dir/file": {
+                                               Path:      "dir/file",
+                                               Mode:      0644,
+                                               Uid:       -1,
+                                               Gid:       -1,
+                                               Data:      []byte("content\n"),
+                                               OrigGroup: "group",
+                                               TriggerCommands: []string{
+                                                       "echo trigger dir/file",
+                                               },
+                                       },
+                               },
+                       },
+                       func() {
+                               createDirectory("dir", 0755)
+                               createFile("dir/file", "content\n", 0644)
+                       },
+                       nil,
+                       []File{
+                               root,
+                               {
+                                       Path: "dir",
+                                       Mode: fs.ModeDir | 0755,
+                               },
+                               {
+                                       Path: "dir/file",
+                                       Mode: 0644,
+                                       Data: []byte("content\n"),
+                               },
+                       },
+                       safcm.MsgSyncResp{},
+                       []string{
+                               `4: sync remote: files: "." (group): unchanged`,
+                               `4: sync remote: files: "dir" (group): unchanged`,
+                               `4: sync remote: files: "dir/file" (group): unchanged`,
+                       },
+                       nil,
+               },
+
+               {
+                       "triggers: change root",
+                       safcm.MsgSyncReq{
+                               Files: map[string]*safcm.File{
+                                       ".": {
+                                               Path:      ".",
+                                               Mode:      fs.ModeDir | 0700,
+                                               Uid:       -1,
+                                               Gid:       -1,
+                                               OrigGroup: "group",
+                                               TriggerCommands: []string{
+                                                       "echo trigger .",
+                                               },
+                                       },
+                                       "dir": {
+                                               Path:      "dir",
+                                               Mode:      fs.ModeDir | 0755,
+                                               Uid:       -1,
+                                               Gid:       -1,
+                                               OrigGroup: "group",
+                                               TriggerCommands: []string{
+                                                       "echo trigger dir",
+                                               },
+                                       },
+                                       "dir/file": {
+                                               Path:      "dir/file",
+                                               Mode:      0644,
+                                               Uid:       -1,
+                                               Gid:       -1,
+                                               Data:      []byte("content\n"),
+                                               OrigGroup: "group",
+                                               TriggerCommands: []string{
+                                                       "echo trigger dir/file",
+                                               },
+                                       },
+                               },
+                       },
+                       func() {
+                               err = os.Chmod(".", 0750)
+                               if err != nil {
+                                       panic(err)
+                               }
+                               createDirectory("dir", 0755)
+                               createFile("dir/file", "content\n", 0644)
+                       },
+                       []string{
+                               ".",
+                       },
+                       []File{
+                               root,
+                               {
+                                       Path: "dir",
+                                       Mode: fs.ModeDir | 0755,
+                               },
+                               {
+                                       Path: "dir/file",
+                                       Mode: 0644,
+                                       Data: []byte("content\n"),
+                               },
+                       },
+                       safcm.MsgSyncResp{
+                               FileChanges: []safcm.FileChange{
+                                       {
+                                               Path: ".",
+                                               Old: safcm.FileChangeInfo{
+                                                       Mode:  fs.ModeDir | 0750,
+                                                       User:  user,
+                                                       Uid:   uid,
+                                                       Group: group,
+                                                       Gid:   gid,
+                                               },
+                                               New: safcm.FileChangeInfo{
+                                                       Mode:  fs.ModeDir | 0700,
+                                                       User:  user,
+                                                       Uid:   uid,
+                                                       Group: group,
+                                                       Gid:   gid,
+                                               },
+                                       },
+                               },
+                       },
+                       []string{
+                               `4: sync remote: files: "." (group): permission differs drwxr-x--- -> drwx------`,
+                               `3: sync remote: files: "." (group): updating`,
+                               `4: sync remote: files: "." (group): chmodding drwx------`,
+                               `3: sync remote: files: ".": queuing trigger on "."`,
+                               `4: sync remote: files: "dir" (group): unchanged`,
+                               `4: sync remote: files: "dir/file" (group): unchanged`,
+                       },
+                       nil,
+               },
+
+               {
+                       "triggers: change middle",
+                       safcm.MsgSyncReq{
+                               Files: map[string]*safcm.File{
+                                       ".": {
+                                               Path:      ".",
+                                               Mode:      fs.ModeDir | 0700,
+                                               Uid:       -1,
+                                               Gid:       -1,
+                                               OrigGroup: "group",
+                                               TriggerCommands: []string{
+                                                       "echo trigger .",
+                                               },
+                                       },
+                                       "dir": {
+                                               Path:      "dir",
+                                               Mode:      fs.ModeDir | 0755,
+                                               Uid:       -1,
+                                               Gid:       -1,
+                                               OrigGroup: "group",
+                                               TriggerCommands: []string{
+                                                       "echo trigger dir",
+                                               },
+                                       },
+                                       "dir/file": {
+                                               Path:      "dir/file",
+                                               Mode:      0644,
+                                               Uid:       -1,
+                                               Gid:       -1,
+                                               Data:      []byte("content\n"),
+                                               OrigGroup: "group",
+                                               TriggerCommands: []string{
+                                                       "echo trigger dir/file",
+                                               },
+                                       },
+                               },
+                       },
+                       func() {
+                               createDirectory("dir", 0750)
+                               createFile("dir/file", "content\n", 0644)
+                       },
+                       []string{
+                               ".",
+                               "dir",
+                       },
+                       []File{
+                               root,
+                               {
+                                       Path: "dir",
+                                       Mode: fs.ModeDir | 0755,
+                               },
+                               {
+                                       Path: "dir/file",
+                                       Mode: 0644,
+                                       Data: []byte("content\n"),
+                               },
+                       },
+                       safcm.MsgSyncResp{
+                               FileChanges: []safcm.FileChange{
+                                       {
+                                               Path: "dir",
+                                               Old: safcm.FileChangeInfo{
+                                                       Mode:  fs.ModeDir | 0750,
+                                                       User:  user,
+                                                       Uid:   uid,
+                                                       Group: group,
+                                                       Gid:   gid,
+                                               },
+                                               New: safcm.FileChangeInfo{
+                                                       Mode:  fs.ModeDir | 0755,
+                                                       User:  user,
+                                                       Uid:   uid,
+                                                       Group: group,
+                                                       Gid:   gid,
+                                               },
+                                       },
+                               },
+                       },
+                       []string{
+                               `4: sync remote: files: "." (group): unchanged`,
+                               `4: sync remote: files: "dir" (group): permission differs drwxr-x--- -> drwxr-xr-x`,
+                               `3: sync remote: files: "dir" (group): updating`,
+                               `4: sync remote: files: "dir" (group): chmodding drwxr-xr-x`,
+                               `3: sync remote: files: "dir": queuing trigger on "."`,
+                               `3: sync remote: files: "dir": queuing trigger on "dir"`,
+                               `4: sync remote: files: "dir/file" (group): unchanged`,
+                       },
+                       nil,
+               },
+
+               {
+                       "triggers: change leaf",
+                       safcm.MsgSyncReq{
+                               Files: map[string]*safcm.File{
+                                       ".": {
+                                               Path:      ".",
+                                               Mode:      fs.ModeDir | 0700,
+                                               Uid:       -1,
+                                               Gid:       -1,
+                                               OrigGroup: "group",
+                                               TriggerCommands: []string{
+                                                       "echo trigger .",
+                                               },
+                                       },
+                                       "dir": {
+                                               Path:      "dir",
+                                               Mode:      fs.ModeDir | 0755,
+                                               Uid:       -1,
+                                               Gid:       -1,
+                                               OrigGroup: "group",
+                                               TriggerCommands: []string{
+                                                       "echo trigger dir",
+                                               },
+                                       },
+                                       "dir/file": {
+                                               Path:      "dir/file",
+                                               Mode:      0644,
+                                               Uid:       -1,
+                                               Gid:       -1,
+                                               Data:      []byte("content\n"),
+                                               OrigGroup: "group",
+                                               TriggerCommands: []string{
+                                                       "echo trigger dir/file",
+                                               },
+                                       },
+                               },
+                       },
+                       func() {
+                               createDirectory("dir", 0755)
+                       },
+                       []string{
+                               ".",
+                               "dir",
+                               "dir/file",
+                       },
+                       []File{
+                               root,
+                               {
+                                       Path: "dir",
+                                       Mode: fs.ModeDir | 0755,
+                               },
+                               {
+                                       Path: "dir/file",
+                                       Mode: 0644,
+                                       Data: []byte("content\n"),
+                               },
+                       },
+                       safcm.MsgSyncResp{
+                               FileChanges: []safcm.FileChange{
+                                       {
+                                               Path:    "dir/file",
+                                               Created: true,
+                                               New: safcm.FileChangeInfo{
+                                                       Mode:  0644,
+                                                       User:  user,
+                                                       Uid:   uid,
+                                                       Group: group,
+                                                       Gid:   gid,
+                                               },
+                                       },
+                               },
+                       },
+                       []string{
+                               `4: sync remote: files: "." (group): unchanged`,
+                               `4: sync remote: files: "dir" (group): unchanged`,
+                               `4: sync remote: files: "dir/file" (group): will create`,
+                               `3: sync remote: files: "dir/file" (group): creating`,
+                               `4: sync remote: files: "dir/file" (group): creating temporary file "dir/.file*"`,
+                               `4: sync remote: files: "dir/file" (group): renaming "dir/.fileRND"`,
+                               `3: sync remote: files: "dir/file": queuing trigger on "."`,
+                               `3: sync remote: files: "dir/file": queuing trigger on "dir"`,
+                               `3: sync remote: files: "dir/file": queuing trigger on "dir/file"`,
+                       },
+                       nil,
+               },
+
+               {
+                       "triggers: multiple changes",
+                       safcm.MsgSyncReq{
+                               Files: map[string]*safcm.File{
+                                       ".": {
+                                               Path:      ".",
+                                               Mode:      fs.ModeDir | 0700,
+                                               Uid:       -1,
+                                               Gid:       -1,
+                                               OrigGroup: "group",
+                                               TriggerCommands: []string{
+                                                       "echo trigger .",
+                                               },
+                                       },
+                                       "dir": {
+                                               Path:      "dir",
+                                               Mode:      fs.ModeDir | 0755,
+                                               Uid:       -1,
+                                               Gid:       -1,
+                                               OrigGroup: "group",
+                                               TriggerCommands: []string{
+                                                       "echo trigger dir",
+                                               },
+                                       },
+                                       "dir/file": {
+                                               Path:      "dir/file",
+                                               Mode:      0644,
+                                               Uid:       -1,
+                                               Gid:       -1,
+                                               Data:      []byte("content\n"),
+                                               OrigGroup: "group",
+                                               TriggerCommands: []string{
+                                                       "echo trigger dir/file",
+                                               },
+                                       },
+                               },
+                       },
+                       nil,
+                       []string{
+                               ".",
+                               "dir",
+                               "dir/file",
+                       },
+                       []File{
+                               root,
+                               {
+                                       Path: "dir",
+                                       Mode: fs.ModeDir | 0755,
+                               },
+                               {
+                                       Path: "dir/file",
+                                       Mode: 0644,
+                                       Data: []byte("content\n"),
+                               },
+                       },
+                       safcm.MsgSyncResp{
+                               FileChanges: []safcm.FileChange{
+                                       {
+                                               Path:    "dir",
+                                               Created: true,
+                                               New: safcm.FileChangeInfo{
+                                                       Mode:  fs.ModeDir | 0755,
+                                                       User:  user,
+                                                       Uid:   uid,
+                                                       Group: group,
+                                                       Gid:   gid,
+                                               },
+                                       },
+                                       {
+                                               Path:    "dir/file",
+                                               Created: true,
+                                               New: safcm.FileChangeInfo{
+                                                       Mode:  0644,
+                                                       User:  user,
+                                                       Uid:   uid,
+                                                       Group: group,
+                                                       Gid:   gid,
+                                               },
+                                       },
+                               },
+                       },
+                       []string{
+                               `4: sync remote: files: "." (group): unchanged`,
+                               `4: sync remote: files: "dir" (group): will create`,
+                               `3: sync remote: files: "dir" (group): creating`,
+                               `4: sync remote: files: "dir" (group): creating directory`,
+                               `4: sync remote: files: "dir" (group): chmodding drwxr-xr-x`,
+                               fmt.Sprintf(`4: sync remote: files: "dir" (group): chowning %d/%d`, uid, gid),
+                               `3: sync remote: files: "dir": queuing trigger on "."`,
+                               `3: sync remote: files: "dir": queuing trigger on "dir"`,
+                               `4: sync remote: files: "dir/file" (group): will create`,
+                               `3: sync remote: files: "dir/file" (group): creating`,
+                               `4: sync remote: files: "dir/file" (group): creating temporary file "dir/.file*"`,
+                               `4: sync remote: files: "dir/file" (group): renaming "dir/.fileRND"`,
+                               `4: sync remote: files: "dir/file": skipping trigger on ".", already active`,
+                               `4: sync remote: files: "dir/file": skipping trigger on "dir", already active`,
+                               `3: sync remote: files: "dir/file": queuing trigger on "dir/file"`,
+                       },
+                       nil,
+               },
+
+               {
+                       "triggers: absolute paths",
+                       safcm.MsgSyncReq{
+                               Files: map[string]*safcm.File{
+                                       "/": {
+                                               Path:      "/",
+                                               Mode:      fs.ModeDir | 0755,
+                                               User:      "root",
+                                               Uid:       -1,
+                                               Group:     "root",
+                                               Gid:       -1,
+                                               OrigGroup: "group",
+                                               TriggerCommands: []string{
+                                                       "echo trigger /",
+                                               },
+                                       },
+                                       "/tmp": {
+                                               Path:      "/tmp",
+                                               Mode:      fs.ModeDir | 0777 | fs.ModeSticky,
+                                               User:      "root",
+                                               Uid:       -1,
+                                               Group:     "root",
+                                               Gid:       -1,
+                                               OrigGroup: "group",
+                                               TriggerCommands: []string{
+                                                       "echo trigger /tmp",
+                                               },
+                                       },
+                                       tmpTestFilePath: {
+                                               Path:      tmpTestFilePath,
+                                               Mode:      0600,
+                                               Uid:       -1,
+                                               Gid:       -1,
+                                               OrigGroup: "group",
+                                               TriggerCommands: []string{
+                                                       "echo trigger /tmp/file",
+                                               },
+                                       },
+                               },
+                       },
+                       func() {
+                               // This is slightly racy but the file name
+                               // should be rare enough that this isn't an
+                               // issue
+                               _, err := os.Stat(tmpTestFilePath)
+                               if err == nil {
+                                       t.Fatalf("%q exists, aborting",
+                                               tmpTestFilePath)
+                               }
+                       },
+                       []string{
+                               "/",
+                               "/tmp",
+                               // Don't use variable for more robust test
+                               "/tmp/safcm-sync-files-test-file",
+                       },
+                       []File{
+                               root,
+                       },
+                       safcm.MsgSyncResp{
+                               FileChanges: []safcm.FileChange{
+                                       {
+                                               Path:    "/tmp/safcm-sync-files-test-file",
+                                               Created: true,
+                                               New: safcm.FileChangeInfo{
+                                                       Mode:  0600,
+                                                       User:  user,
+                                                       Uid:   uid,
+                                                       Group: group,
+                                                       Gid:   gid,
+                                               },
+                                       },
+                               },
+                       },
+                       []string{
+                               `4: sync remote: files: "/" (group): unchanged`,
+                               `4: sync remote: files: "/tmp" (group): unchanged`,
+                               `4: sync remote: files: "/tmp/safcm-sync-files-test-file" (group): will create`,
+                               `3: sync remote: files: "/tmp/safcm-sync-files-test-file" (group): creating`,
+                               `4: sync remote: files: "/tmp/safcm-sync-files-test-file" (group): creating temporary file "/tmp/.safcm-sync-files-test-file*"`,
+                               `4: sync remote: files: "/tmp/safcm-sync-files-test-file" (group): renaming "/tmp/.safcm-sync-files-test-fileRND"`,
+                               `3: sync remote: files: "/tmp/safcm-sync-files-test-file": queuing trigger on "/"`,
+                               `3: sync remote: files: "/tmp/safcm-sync-files-test-file": queuing trigger on "/tmp"`,
+                               `3: sync remote: files: "/tmp/safcm-sync-files-test-file": queuing trigger on "/tmp/safcm-sync-files-test-file"`,
+                       },
+                       nil,
+               },
+       }
+
+       for _, tc := range tests {
+               // Create separate test directory for each test case
+               path := filepath.Join(cwd, "testdata", "files-"+tc.name)
+               err = os.Mkdir(path, 0700)
+               if err != nil {
+                       t.Fatal(err)
+               }
+               err = os.Chdir(path)
+               if err != nil {
+                       t.Fatal(err)
+               }
+
+               if tc.prepare != nil {
+                       tc.prepare()
+               }
+
+               s, res := prepareSync(tc.req, &testRunner{
+                       t:    t,
+                       name: tc.name,
+               })
+               s.setDefaults()
+
+               err := s.syncFiles()
+               // Ugly but the simplest way to compare errors (including nil)
+               if fmt.Sprintf("%s", err) != fmt.Sprintf("%s", tc.expErr) {
+                       t.Errorf("%s: err = %#v, want %#v",
+                               tc.name, err, tc.expErr)
+               }
+               dbg := res.Wait()
+               // Remove random file names from result
+               for i, x := range dbg {
+                       dbg[i] = randFilesRegexp.ReplaceAllString(x, `RND"`)
+               }
+               if !reflect.DeepEqual(tc.expDbg, dbg) {
+                       t.Errorf("%s: dbg: %s", tc.name,
+                               cmp.Diff(tc.expDbg, dbg))
+               }
+
+               files, err := walkDir(path)
+               if err != nil {
+                       t.Fatal(err)
+               }
+               if !reflect.DeepEqual(tc.expFiles, files) {
+                       t.Errorf("%s: files: %s", tc.name,
+                               cmp.Diff(tc.expFiles, files))
+               }
+
+               if !reflect.DeepEqual(tc.expResp, s.resp) {
+                       t.Errorf("%s: resp: %s", tc.name,
+                               cmp.Diff(tc.expResp, s.resp))
+               }
+               if !reflect.DeepEqual(tc.triggers, s.triggers) {
+                       t.Errorf("%s: triggers: %s", tc.name,
+                               cmp.Diff(tc.triggers, s.triggers))
+               }
+       }
+
+       os.Remove(tmpTestFilePath)
+       if !t.Failed() {
+               err = os.RemoveAll(filepath.Join(cwd, "testdata"))
+               if err != nil {
+                       t.Fatal(err)
+               }
+       }
+}
+
+func TestSyncFile(t *testing.T) {
+       cwd, err := os.Getwd()
+       if err != nil {
+               t.Fatal(err)
+       }
+       defer os.Chdir(cwd)
+
+       err = os.RemoveAll("testdata")
+       if err != nil {
+               t.Fatal(err)
+       }
+       err = os.Mkdir("testdata", 0700)
+       if err != nil {
+               t.Fatal(err)
+       }
+
+       root := File{
+               Path: ".",
+               Mode: fs.ModeDir | 0700,
+       }
+       user, uid, group, gid := currentUserAndGroup()
+
+       tests := []struct {
+               name       string
+               req        safcm.MsgSyncReq
+               file       *safcm.File
+               prepare    func()
+               expChanged bool
+               expFiles   []File
+               expResp    safcm.MsgSyncResp
+               expDbg     []string
+               expErr     error
+       }{
+
+               // NOTE: Also update MsgSyncResp in safcm test cases when
+               // changing anything here!
+
+               // TODO: Add tests for chown and run them only as root
+
+               // Regular file
+
+               {
+                       "file: create",
+                       safcm.MsgSyncReq{},
+                       &safcm.File{
+                               Path:      "file",
+                               Mode:      0644,
+                               Uid:       -1,
+                               Gid:       -1,
+                               Data:      []byte("content\n"),
+                               OrigGroup: "group",
+                       },
+                       nil,
+                       true,
+                       []File{
+                               root,
+                               {
+                                       Path: "file",
+                                       Mode: 0644,
+                                       Data: []byte("content\n"),
+                               },
+                       },
+                       safcm.MsgSyncResp{
+                               FileChanges: []safcm.FileChange{
+                                       {
+                                               Path:    "file",
+                                               Created: true,
+                                               New: safcm.FileChangeInfo{
+                                                       Mode:  0644,
+                                                       User:  user,
+                                                       Uid:   uid,
+                                                       Group: group,
+                                                       Gid:   gid,
+                                               },
+                                       },
+                               },
+                       },
+                       []string{
+                               `4: sync remote: files: "file" (group): will create`,
+                               `3: sync remote: files: "file" (group): creating`,
+                               `4: sync remote: files: "file" (group): creating temporary file ".file*"`,
+                               `4: sync remote: files: "file" (group): renaming "./.fileRND"`,
+                       },
+                       nil,
+               },
+               {
+                       "file: create (dry-run)",
+                       safcm.MsgSyncReq{
+                               DryRun: true,
+                       },
+                       &safcm.File{
+                               Path:      "file",
+                               Mode:      0644,
+                               Uid:       -1,
+                               Gid:       -1,
+                               Data:      []byte("content\n"),
+                               OrigGroup: "group",
+                       },
+                       nil,
+                       true,
+                       []File{root},
+                       safcm.MsgSyncResp{
+                               FileChanges: []safcm.FileChange{
+                                       {
+                                               Path:    "file",
+                                               Created: true,
+                                               New: safcm.FileChangeInfo{
+                                                       Mode:  0644,
+                                                       User:  user,
+                                                       Uid:   uid,
+                                                       Group: group,
+                                                       Gid:   gid,
+                                               },
+                                       },
+                               },
+                       },
+                       []string{
+                               `4: sync remote: files: "file" (group): will create`,
+                               `3: sync remote: files: "file" (group): creating`,
+                               `4: sync remote: files: "file" (group): dry-run, skipping changes`,
+                       },
+                       nil,
+               },
+
+               {
+                       "file: unchanged",
+                       safcm.MsgSyncReq{},
+                       &safcm.File{
+                               Path:      "file",
+                               Mode:      0644,
+                               Uid:       -1,
+                               Gid:       -1,
+                               Data:      []byte("content\n"),
+                               OrigGroup: "group",
+                       },
+                       func() {
+                               createFile("file", "content\n", 0644)
+                       },
+                       false,
+                       []File{
+                               root,
+                               {
+                                       Path: "file",
+                                       Mode: 0644,
+                                       Data: []byte("content\n"),
+                               },
+                       },
+                       safcm.MsgSyncResp{},
+                       []string{
+                               `4: sync remote: files: "file" (group): unchanged`,
+                       },
+                       nil,
+               },
+
+               {
+                       "file: unchanged (non-default user-group)",
+                       safcm.MsgSyncReq{},
+                       &safcm.File{
+                               Path:      "file",
+                               Mode:      0644,
+                               User:      user,
+                               Uid:       -1,
+                               Group:     group,
+                               Gid:       -1,
+                               Data:      []byte("content\n"),
+                               OrigGroup: "group",
+                       },
+                       func() {
+                               createFile("file", "content\n", 0644)
+                       },
+                       false,
+                       []File{
+                               root,
+                               {
+                                       Path: "file",
+                                       Mode: 0644,
+                                       Data: []byte("content\n"),
+                               },
+                       },
+                       safcm.MsgSyncResp{},
+                       []string{
+                               `4: sync remote: files: "file" (group): unchanged`,
+                       },
+                       nil,
+               },
+
+               {
+                       "file: permission",
+                       safcm.MsgSyncReq{},
+                       &safcm.File{
+                               Path:      "file",
+                               Mode:      0755 | fs.ModeSetuid,
+                               Uid:       -1,
+                               Gid:       -1,
+                               Data:      []byte("content\n"),
+                               OrigGroup: "group",
+                       },
+                       func() {
+                               createFile("file", "content\n", 0755)
+                       },
+                       true,
+                       []File{
+                               root,
+                               {
+                                       Path: "file",
+                                       Mode: 0755 | fs.ModeSetuid,
+                                       Data: []byte("content\n"),
+                               },
+                       },
+                       safcm.MsgSyncResp{
+                               FileChanges: []safcm.FileChange{
+                                       {
+                                               Path: "file",
+                                               Old: safcm.FileChangeInfo{
+                                                       Mode:  0755,
+                                                       User:  user,
+                                                       Uid:   uid,
+                                                       Group: group,
+                                                       Gid:   gid,
+                                               },
+                                               New: safcm.FileChangeInfo{
+                                                       Mode:  0755 | fs.ModeSetuid,
+                                                       User:  user,
+                                                       Uid:   uid,
+                                                       Group: group,
+                                                       Gid:   gid,
+                                               },
+                                       },
+                               },
+                       },
+                       []string{
+                               `4: sync remote: files: "file" (group): permission differs -rwxr-xr-x -> urwxr-xr-x`,
+                               `3: sync remote: files: "file" (group): updating`,
+                               `4: sync remote: files: "file" (group): creating temporary file ".file*"`,
+                               `4: sync remote: files: "file" (group): renaming "./.fileRND"`,
+                       },
+                       nil,
+               },
+
+               {
+                       "file: content",
+                       safcm.MsgSyncReq{},
+                       &safcm.File{
+                               Path:      "file",
+                               Mode:      0644,
+                               Uid:       -1,
+                               Gid:       -1,
+                               Data:      []byte("content\n"),
+                               OrigGroup: "group",
+                       },
+                       func() {
+                               createFile("file", "old content\n", 0644)
+                       },
+                       true,
+                       []File{
+                               root,
+                               {
+                                       Path: "file",
+                                       Mode: 0644,
+                                       Data: []byte("content\n"),
+                               },
+                       },
+                       safcm.MsgSyncResp{
+                               FileChanges: []safcm.FileChange{
+                                       {
+                                               Path: "file",
+                                               Old: safcm.FileChangeInfo{
+                                                       Mode:  0644,
+                                                       User:  user,
+                                                       Uid:   uid,
+                                                       Group: group,
+                                                       Gid:   gid,
+                                               },
+                                               New: safcm.FileChangeInfo{
+                                                       Mode:  0644,
+                                                       User:  user,
+                                                       Uid:   uid,
+                                                       Group: group,
+                                                       Gid:   gid,
+                                               },
+                                               DataDiff: `@@ -1,2 +1,2 @@
+-old content
++content
+`,
+                                       },
+                               },
+                       },
+                       []string{
+                               `4: sync remote: files: "file" (group): content differs`,
+                               `3: sync remote: files: "file" (group): updating`,
+                               `4: sync remote: files: "file" (group): creating temporary file ".file*"`,
+                               `4: sync remote: files: "file" (group): renaming "./.fileRND"`,
+                       },
+                       nil,
+               },
+
+               // Symbolic link
+
+               {
+                       "symlink: create",
+                       safcm.MsgSyncReq{},
+                       &safcm.File{
+                               Path:      "link",
+                               Mode:      fs.ModeSymlink | 0777,
+                               Uid:       -1,
+                               Gid:       -1,
+                               Data:      []byte("target"),
+                               OrigGroup: "group",
+                       },
+                       nil,
+                       true,
+                       []File{
+                               root,
+                               {
+                                       Path: "link",
+                                       Mode: fs.ModeSymlink | 0777,
+                                       Data: []byte("target"),
+                               },
+                       },
+                       safcm.MsgSyncResp{
+                               FileChanges: []safcm.FileChange{
+                                       {
+                                               Path:    "link",
+                                               Created: true,
+                                               New: safcm.FileChangeInfo{
+                                                       Mode:  fs.ModeSymlink | 0777,
+                                                       User:  user,
+                                                       Uid:   uid,
+                                                       Group: group,
+                                                       Gid:   gid,
+                                               },
+                                       },
+                               },
+                       },
+                       []string{
+                               `4: sync remote: files: "link" (group): will create`,
+                               `3: sync remote: files: "link" (group): creating`,
+                               `4: sync remote: files: "link" (group): creating temporary symlink ".linkRND"`,
+                               `4: sync remote: files: "link" (group): renaming ".linkRND"`,
+                       },
+                       nil,
+               },
+               {
+                       "symlink: create (conflict)",
+                       safcm.MsgSyncReq{},
+                       &safcm.File{
+                               Path:      "link",
+                               Mode:      fs.ModeSymlink | 0777,
+                               Uid:       -1,
+                               Gid:       -1,
+                               Data:      []byte("target"),
+                               OrigGroup: "group",
+                       },
+                       func() {
+                               createFile(".link8717895732742165505", "", 0600)
+                       },
+                       true,
+                       []File{
+                               root,
+                               {
+                                       Path: ".link8717895732742165505",
+                                       Mode: 0600,
+                                       Data: []byte(""),
+                               },
+                               {
+                                       Path: "link",
+                                       Mode: fs.ModeSymlink | 0777,
+                                       Data: []byte("target"),
+                               },
+                       },
+                       safcm.MsgSyncResp{
+                               FileChanges: []safcm.FileChange{
+                                       {
+                                               Path:    "link",
+                                               Created: true,
+                                               New: safcm.FileChangeInfo{
+                                                       Mode:  fs.ModeSymlink | 0777,
+                                                       User:  user,
+                                                       Uid:   uid,
+                                                       Group: group,
+                                                       Gid:   gid,
+                                               },
+                                       },
+                               },
+                       },
+                       []string{
+                               `4: sync remote: files: "link" (group): will create`,
+                               `3: sync remote: files: "link" (group): creating`,
+                               `4: sync remote: files: "link" (group): creating temporary symlink ".linkRND"`,
+                               `4: sync remote: files: "link" (group): creating temporary symlink ".linkRND"`,
+                               `4: sync remote: files: "link" (group): renaming ".linkRND"`,
+                       },
+                       nil,
+               },
+               {
+                       "symlink: create (dry-run)",
+                       safcm.MsgSyncReq{
+                               DryRun: true,
+                       },
+                       &safcm.File{
+                               Path:      "link",
+                               Mode:      fs.ModeSymlink | 0777,
+                               Uid:       -1,
+                               Gid:       -1,
+                               Data:      []byte("target"),
+                               OrigGroup: "group",
+                       },
+                       nil,
+                       true,
+                       []File{root},
+                       safcm.MsgSyncResp{
+                               FileChanges: []safcm.FileChange{
+                                       {
+                                               Path:    "link",
+                                               Created: true,
+                                               New: safcm.FileChangeInfo{
+                                                       Mode:  fs.ModeSymlink | 0777,
+                                                       User:  user,
+                                                       Uid:   uid,
+                                                       Group: group,
+                                                       Gid:   gid,
+                                               },
+                                       },
+                               },
+                       },
+                       []string{
+                               `4: sync remote: files: "link" (group): will create`,
+                               `3: sync remote: files: "link" (group): creating`,
+                               `4: sync remote: files: "link" (group): dry-run, skipping changes`,
+                       },
+                       nil,
+               },
+
+               {
+                       "symlink: unchanged",
+                       safcm.MsgSyncReq{},
+                       &safcm.File{
+                               Path:      "link",
+                               Mode:      fs.ModeSymlink | 0777,
+                               Uid:       -1,
+                               Gid:       -1,
+                               Data:      []byte("target"),
+                               OrigGroup: "group",
+                       },
+                       func() {
+                               createSymlink("link", "target")
+                       },
+                       false,
+                       []File{
+                               root,
+                               {
+                                       Path: "link",
+                                       Mode: fs.ModeSymlink | 0777,
+                                       Data: []byte("target"),
+                               },
+                       },
+                       safcm.MsgSyncResp{},
+                       []string{
+                               `4: sync remote: files: "link" (group): unchanged`,
+                       },
+                       nil,
+               },
+
+               {
+                       "symlink: content",
+                       safcm.MsgSyncReq{},
+                       &safcm.File{
+                               Path:      "link",
+                               Mode:      fs.ModeSymlink | 0777,
+                               Uid:       -1,
+                               Gid:       -1,
+                               Data:      []byte("target"),
+                               OrigGroup: "group",
+                       },
+                       func() {
+                               createSymlink("link", "old-target")
+                       },
+                       true,
+                       []File{
+                               root,
+                               {
+                                       Path: "link",
+                                       Mode: fs.ModeSymlink | 0777,
+                                       Data: []byte("target"),
+                               },
+                       },
+                       safcm.MsgSyncResp{
+                               FileChanges: []safcm.FileChange{
+                                       {
+                                               Path: "link",
+                                               Old: safcm.FileChangeInfo{
+                                                       Mode:  fs.ModeSymlink | 0777,
+                                                       User:  user,
+                                                       Uid:   uid,
+                                                       Group: group,
+                                                       Gid:   gid,
+                                               },
+                                               New: safcm.FileChangeInfo{
+                                                       Mode:  fs.ModeSymlink | 0777,
+                                                       User:  user,
+                                                       Uid:   uid,
+                                                       Group: group,
+                                                       Gid:   gid,
+                                               },
+                                               DataDiff: `@@ -1 +1 @@
+-old-target
++target
+`,
+                                       },
+                               },
+                       },
+                       []string{
+                               `4: sync remote: files: "link" (group): content differs`,
+                               `3: sync remote: files: "link" (group): updating`,
+                               `4: sync remote: files: "link" (group): creating temporary symlink ".linkRND"`,
+                               `4: sync remote: files: "link" (group): renaming ".linkRND"`,
+                       },
+                       nil,
+               },
+
+               // Directory
+
+               {
+                       "directory: create",
+                       safcm.MsgSyncReq{},
+                       &safcm.File{
+                               Path:      "dir",
+                               Mode:      fs.ModeDir | 0705,
+                               Uid:       -1,
+                               Gid:       -1,
+                               OrigGroup: "group",
+                       },
+                       nil,
+                       true,
+                       []File{
+                               root,
+                               {
+                                       Path: "dir",
+                                       Mode: fs.ModeDir | 0705,
+                               },
+                       },
+                       safcm.MsgSyncResp{
+                               FileChanges: []safcm.FileChange{
+                                       {
+                                               Path:    "dir",
+                                               Created: true,
+                                               New: safcm.FileChangeInfo{
+                                                       Mode:  fs.ModeDir | 0705,
+                                                       User:  user,
+                                                       Uid:   uid,
+                                                       Group: group,
+                                                       Gid:   gid,
+                                               },
+                                       },
+                               },
+                       },
+                       []string{
+                               `4: sync remote: files: "dir" (group): will create`,
+                               `3: sync remote: files: "dir" (group): creating`,
+                               `4: sync remote: files: "dir" (group): creating directory`,
+                               `4: sync remote: files: "dir" (group): chmodding drwx---r-x`,
+                               fmt.Sprintf(`4: sync remote: files: "dir" (group): chowning %d/%d`, uid, gid),
+                       },
+                       nil,
+               },
+               {
+                       "directory: create (dry-run)",
+                       safcm.MsgSyncReq{
+                               DryRun: true,
+                       },
+                       &safcm.File{
+                               Path:      "dir",
+                               Mode:      fs.ModeDir | 0644,
+                               Uid:       -1,
+                               Gid:       -1,
+                               OrigGroup: "group",
+                       },
+                       nil,
+                       true,
+                       []File{root},
+                       safcm.MsgSyncResp{
+                               FileChanges: []safcm.FileChange{
+                                       {
+                                               Path:    "dir",
+                                               Created: true,
+                                               New: safcm.FileChangeInfo{
+                                                       Mode:  fs.ModeDir | 0644,
+                                                       User:  user,
+                                                       Uid:   uid,
+                                                       Group: group,
+                                                       Gid:   gid,
+                                               },
+                                       },
+                               },
+                       },
+                       []string{
+                               `4: sync remote: files: "dir" (group): will create`,
+                               `3: sync remote: files: "dir" (group): creating`,
+                               `4: sync remote: files: "dir" (group): dry-run, skipping changes`,
+                       },
+                       nil,
+               },
+
+               {
+                       "directory: unchanged",
+                       safcm.MsgSyncReq{},
+                       &safcm.File{
+                               Path:      "dir",
+                               Mode:      fs.ModeDir | 0755,
+                               Uid:       -1,
+                               Gid:       -1,
+                               OrigGroup: "group",
+                       },
+                       func() {
+                               createDirectory("dir", 0755)
+                       },
+                       false,
+                       []File{
+                               root,
+                               {
+                                       Path: "dir",
+                                       Mode: fs.ModeDir | 0755,
+                               },
+                       },
+                       safcm.MsgSyncResp{},
+                       []string{
+                               `4: sync remote: files: "dir" (group): unchanged`,
+                       },
+                       nil,
+               },
+
+               {
+                       "directory: permission",
+                       safcm.MsgSyncReq{},
+                       &safcm.File{
+                               Path:      "dir",
+                               Mode:      fs.ModeDir | 0755 | fs.ModeSetgid,
+                               Uid:       -1,
+                               Gid:       -1,
+                               OrigGroup: "group",
+                       },
+                       func() {
+                               createDirectory("dir", 0500|fs.ModeSticky)
+                       },
+                       true,
+                       []File{
+                               root,
+                               {
+                                       Path: "dir",
+                                       Mode: fs.ModeDir | 0755 | fs.ModeSetgid,
+                               },
+                       },
+                       safcm.MsgSyncResp{
+                               FileChanges: []safcm.FileChange{
+                                       {
+                                               Path: "dir",
+                                               Old: safcm.FileChangeInfo{
+                                                       Mode:  fs.ModeDir | 0500 | fs.ModeSticky,
+                                                       User:  user,
+                                                       Uid:   uid,
+                                                       Group: group,
+                                                       Gid:   gid,
+                                               },
+                                               New: safcm.FileChangeInfo{
+                                                       Mode:  fs.ModeDir | 0755 | fs.ModeSetgid,
+                                                       User:  user,
+                                                       Uid:   uid,
+                                                       Group: group,
+                                                       Gid:   gid,
+                                               },
+                                       },
+                               },
+                       },
+                       []string{
+                               `4: sync remote: files: "dir" (group): permission differs dtr-x------ -> dgrwxr-xr-x`,
+                               `3: sync remote: files: "dir" (group): updating`,
+                               `4: sync remote: files: "dir" (group): chmodding dgrwxr-xr-x`,
+                       },
+                       nil,
+               },
+
+               // Type changes
+
+               {
+                       "change: file to directory",
+                       safcm.MsgSyncReq{},
+                       &safcm.File{
+                               Path:      "path",
+                               Mode:      fs.ModeDir | 0751,
+                               Uid:       -1,
+                               Gid:       -1,
+                               OrigGroup: "group",
+                       },
+                       func() {
+                               createFile("path", "content\n", 0644)
+                       },
+                       true,
+                       []File{
+                               root,
+                               {
+                                       Path: "path",
+                                       Mode: fs.ModeDir | 0751,
+                               },
+                       },
+                       safcm.MsgSyncResp{
+                               FileChanges: []safcm.FileChange{
+                                       {
+                                               Path: "path",
+                                               Old: safcm.FileChangeInfo{
+                                                       Mode:  0644,
+                                                       User:  user,
+                                                       Uid:   uid,
+                                                       Group: group,
+                                                       Gid:   gid,
+                                               },
+                                               New: safcm.FileChangeInfo{
+                                                       Mode:  fs.ModeDir | 0751,
+                                                       User:  user,
+                                                       Uid:   uid,
+                                                       Group: group,
+                                                       Gid:   gid,
+                                               },
+                                               DataDiff: `@@ -1,2 +1 @@
+-content
+`,
+                                       },
+                               },
+                       },
+                       []string{
+                               `4: sync remote: files: "path" (group): type differs ---------- -> d---------`,
+                               `3: sync remote: files: "path" (group): updating`,
+                               `4: sync remote: files: "path" (group): removing (due to type change)`,
+                               `4: sync remote: files: "path" (group): creating directory`,
+                               `4: sync remote: files: "path" (group): chmodding drwxr-x--x`,
+                               fmt.Sprintf(`4: sync remote: files: "path" (group): chowning %d/%d`, uid, gid),
+                       },
+                       nil,
+               },
+
+               {
+                       "change: file to symlink",
+                       safcm.MsgSyncReq{},
+                       &safcm.File{
+                               Path:      "path",
+                               Mode:      fs.ModeSymlink | 0777,
+                               Uid:       -1,
+                               Gid:       -1,
+                               OrigGroup: "group",
+                               Data:      []byte("target"),
+                       },
+                       func() {
+                               createFile("path", "content\n", 0644)
+                       },
+                       true,
+                       []File{
+                               root,
+                               {
+                                       Path: "path",
+                                       Mode: fs.ModeSymlink | 0777,
+                                       Data: []byte("target"),
+                               },
+                       },
+                       safcm.MsgSyncResp{
+                               FileChanges: []safcm.FileChange{
+                                       {
+                                               Path: "path",
+                                               Old: safcm.FileChangeInfo{
+                                                       Mode:  0644,
+                                                       User:  user,
+                                                       Uid:   uid,
+                                                       Group: group,
+                                                       Gid:   gid,
+                                               },
+                                               New: safcm.FileChangeInfo{
+                                                       Mode:  fs.ModeSymlink | 0777,
+                                                       User:  user,
+                                                       Uid:   uid,
+                                                       Group: group,
+                                                       Gid:   gid,
+                                               },
+                                               DataDiff: `@@ -1,2 +1 @@
+-content
+-
++target
+`,
+                                       },
+                               },
+                       },
+                       []string{
+                               `4: sync remote: files: "path" (group): type differs ---------- -> L---------`,
+                               `3: sync remote: files: "path" (group): updating`,
+                               `4: sync remote: files: "path" (group): creating temporary symlink ".pathRND"`,
+                               `4: sync remote: files: "path" (group): renaming ".pathRND"`,
+                       },
+                       nil,
+               },
+
+               {
+                       "change: symlink to file",
+                       safcm.MsgSyncReq{},
+                       &safcm.File{
+                               Path:      "path",
+                               Mode:      0640,
+                               Uid:       -1,
+                               Gid:       -1,
+                               OrigGroup: "group",
+                               Data:      []byte("content\n"),
+                       },
+                       func() {
+                               createSymlink("path", "target")
+                       },
+                       true,
+                       []File{
+                               root,
+                               {
+                                       Path: "path",
+                                       Mode: 0640,
+                                       Data: []byte("content\n"),
+                               },
+                       },
+                       safcm.MsgSyncResp{
+                               FileChanges: []safcm.FileChange{
+                                       {
+                                               Path: "path",
+                                               Old: safcm.FileChangeInfo{
+                                                       Mode:  fs.ModeSymlink | 0777,
+                                                       User:  user,
+                                                       Uid:   uid,
+                                                       Group: group,
+                                                       Gid:   gid,
+                                               },
+                                               New: safcm.FileChangeInfo{
+                                                       Mode:  0640,
+                                                       User:  user,
+                                                       Uid:   uid,
+                                                       Group: group,
+                                                       Gid:   gid,
+                                               },
+                                               DataDiff: `@@ -1 +1,2 @@
+-target
++content
++
+`,
+                                       },
+                               },
+                       },
+                       []string{
+                               `4: sync remote: files: "path" (group): type differs L--------- -> ----------`,
+                               `3: sync remote: files: "path" (group): updating`,
+                               `4: sync remote: files: "path" (group): creating temporary file ".path*"`,
+                               `4: sync remote: files: "path" (group): renaming "./.pathRND"`,
+                       },
+                       nil,
+               },
+
+               {
+                       "change: symlink to directory",
+                       safcm.MsgSyncReq{},
+                       &safcm.File{
+                               Path:      "path",
+                               Mode:      fs.ModeDir | 0751,
+                               Uid:       -1,
+                               Gid:       -1,
+                               OrigGroup: "group",
+                       },
+                       func() {
+                               createSymlink("path", "target")
+                       },
+                       true,
+                       []File{
+                               root,
+                               {
+                                       Path: "path",
+                                       Mode: fs.ModeDir | 0751,
+                               },
+                       },
+                       safcm.MsgSyncResp{
+                               FileChanges: []safcm.FileChange{
+                                       {
+                                               Path: "path",
+                                               Old: safcm.FileChangeInfo{
+                                                       Mode:  fs.ModeSymlink | 0777,
+                                                       User:  user,
+                                                       Uid:   uid,
+                                                       Group: group,
+                                                       Gid:   gid,
+                                               },
+                                               New: safcm.FileChangeInfo{
+                                                       Mode:  fs.ModeDir | 0751,
+                                                       User:  user,
+                                                       Uid:   uid,
+                                                       Group: group,
+                                                       Gid:   gid,
+                                               },
+                                               DataDiff: `@@ -1 +1 @@
+-target
++
+`,
+                                       },
+                               },
+                       },
+                       []string{
+                               `4: sync remote: files: "path" (group): type differs L--------- -> d---------`,
+                               `3: sync remote: files: "path" (group): updating`,
+                               `4: sync remote: files: "path" (group): removing (due to type change)`,
+                               `4: sync remote: files: "path" (group): creating directory`,
+                               `4: sync remote: files: "path" (group): chmodding drwxr-x--x`,
+                               fmt.Sprintf(`4: sync remote: files: "path" (group): chowning %d/%d`, uid, gid),
+                       },
+                       nil,
+               },
+
+               {
+                       "change: directory to file",
+                       safcm.MsgSyncReq{},
+                       &safcm.File{
+                               Path:      "path",
+                               Mode:      0666,
+                               Uid:       -1,
+                               Gid:       -1,
+                               OrigGroup: "group",
+                               Data:      []byte("content\n"),
+                       },
+                       func() {
+                               createDirectory("path", 0777)
+                       },
+                       true,
+                       []File{
+                               root,
+                               {
+                                       Path: "path",
+                                       Mode: 0666,
+                                       Data: []byte("content\n"),
+                               },
+                       },
+                       safcm.MsgSyncResp{
+                               FileChanges: []safcm.FileChange{
+                                       {
+                                               Path: "path",
+                                               Old: safcm.FileChangeInfo{
+                                                       Mode:  fs.ModeDir | 0777,
+                                                       User:  user,
+                                                       Uid:   uid,
+                                                       Group: group,
+                                                       Gid:   gid,
+                                               },
+                                               New: safcm.FileChangeInfo{
+                                                       Mode:  0666,
+                                                       User:  user,
+                                                       Uid:   uid,
+                                                       Group: group,
+                                                       Gid:   gid,
+                                               },
+                                       },
+                               },
+                       },
+                       []string{
+                               `4: sync remote: files: "path" (group): type differs d--------- -> ----------`,
+                               `3: sync remote: files: "path" (group): updating`,
+                               `4: sync remote: files: "path" (group): removing (due to type change)`,
+                               `4: sync remote: files: "path" (group): creating temporary file ".path*"`,
+                               `4: sync remote: files: "path" (group): renaming "./.pathRND"`,
+                       },
+                       nil,
+               },
+
+               {
+                       "change: directory to symlink",
+                       safcm.MsgSyncReq{},
+                       &safcm.File{
+                               Path:      "path",
+                               Mode:      fs.ModeSymlink | 0777,
+                               Uid:       -1,
+                               Gid:       -1,
+                               OrigGroup: "group",
+                               Data:      []byte("target"),
+                       },
+                       func() {
+                               createDirectory("path", 0777)
+                       },
+                       true,
+                       []File{
+                               root,
+                               {
+                                       Path: "path",
+                                       Mode: fs.ModeSymlink | 0777,
+                                       Data: []byte("target"),
+                               },
+                       },
+                       safcm.MsgSyncResp{
+                               FileChanges: []safcm.FileChange{
+                                       {
+                                               Path: "path",
+                                               Old: safcm.FileChangeInfo{
+                                                       Mode:  fs.ModeDir | 0777,
+                                                       User:  user,
+                                                       Uid:   uid,
+                                                       Group: group,
+                                                       Gid:   gid,
+                                               },
+                                               New: safcm.FileChangeInfo{
+                                                       Mode:  fs.ModeSymlink | 0777,
+                                                       User:  user,
+                                                       Uid:   uid,
+                                                       Group: group,
+                                                       Gid:   gid,
+                                               },
+                                       },
+                               },
+                       },
+                       []string{
+                               `4: sync remote: files: "path" (group): type differs d--------- -> L---------`,
+                               `3: sync remote: files: "path" (group): updating`,
+                               `4: sync remote: files: "path" (group): removing (due to type change)`,
+                               `4: sync remote: files: "path" (group): creating temporary symlink ".pathRND"`,
+                               `4: sync remote: files: "path" (group): renaming ".pathRND"`,
+                       },
+                       nil,
+               },
+
+               {
+                       "change: other to file",
+                       safcm.MsgSyncReq{},
+                       &safcm.File{
+                               Path:      "path",
+                               Mode:      0640,
+                               Uid:       -1,
+                               Gid:       -1,
+                               OrigGroup: "group",
+                               Data:      []byte("content\n"),
+                       },
+                       func() {
+                               createFifo("path", 0666)
+                       },
+                       true,
+                       []File{
+                               root,
+                               {
+                                       Path: "path",
+                                       Mode: 0640,
+                                       Data: []byte("content\n"),
+                               },
+                       },
+                       safcm.MsgSyncResp{
+                               FileChanges: []safcm.FileChange{
+                                       {
+                                               Path: "path",
+                                               Old: safcm.FileChangeInfo{
+                                                       Mode:  fs.ModeNamedPipe | 0666,
+                                                       User:  user,
+                                                       Uid:   uid,
+                                                       Group: group,
+                                                       Gid:   gid,
+                                               },
+                                               New: safcm.FileChangeInfo{
+                                                       Mode:  0640,
+                                                       User:  user,
+                                                       Uid:   uid,
+                                                       Group: group,
+                                                       Gid:   gid,
+                                               },
+                                       },
+                               },
+                       },
+                       []string{
+                               `4: sync remote: files: "path" (group): type differs p--------- -> ----------`,
+                               `3: sync remote: files: "path" (group): updating`,
+                               `4: sync remote: files: "path" (group): creating temporary file ".path*"`,
+                               `4: sync remote: files: "path" (group): renaming "./.pathRND"`,
+                       },
+                       nil,
+               },
+
+               {
+                       "change: other to symlink",
+                       safcm.MsgSyncReq{},
+                       &safcm.File{
+                               Path:      "path",
+                               Mode:      fs.ModeSymlink | 0777,
+                               Uid:       -1,
+                               Gid:       -1,
+                               OrigGroup: "group",
+                               Data:      []byte("target"),
+                       },
+                       func() {
+                               createFifo("path", 0666)
+                       },
+                       true,
+                       []File{
+                               root,
+                               {
+                                       Path: "path",
+                                       Mode: fs.ModeSymlink | 0777,
+                                       Data: []byte("target"),
+                               },
+                       },
+                       safcm.MsgSyncResp{
+                               FileChanges: []safcm.FileChange{
+                                       {
+                                               Path: "path",
+                                               Old: safcm.FileChangeInfo{
+                                                       Mode:  fs.ModeNamedPipe | 0666,
+                                                       User:  user,
+                                                       Uid:   uid,
+                                                       Group: group,
+                                                       Gid:   gid,
+                                               },
+                                               New: safcm.FileChangeInfo{
+                                                       Mode:  fs.ModeSymlink | 0777,
+                                                       User:  user,
+                                                       Uid:   uid,
+                                                       Group: group,
+                                                       Gid:   gid,
+                                               },
+                                       },
+                               },
+                       },
+                       []string{
+                               `4: sync remote: files: "path" (group): type differs p--------- -> L---------`,
+                               `3: sync remote: files: "path" (group): updating`,
+                               `4: sync remote: files: "path" (group): creating temporary symlink ".pathRND"`,
+                               `4: sync remote: files: "path" (group): renaming ".pathRND"`,
+                       },
+                       nil,
+               },
+
+               {
+                       "change: other to directory",
+                       safcm.MsgSyncReq{},
+                       &safcm.File{
+                               Path:      "path",
+                               Mode:      fs.ModeDir | 0751,
+                               Uid:       -1,
+                               Gid:       -1,
+                               OrigGroup: "group",
+                       },
+                       func() {
+                               createFifo("path", 0666)
+                       },
+                       true,
+                       []File{
+                               root,
+                               {
+                                       Path: "path",
+                                       Mode: fs.ModeDir | 0751,
+                               },
+                       },
+                       safcm.MsgSyncResp{
+                               FileChanges: []safcm.FileChange{
+                                       {
+                                               Path: "path",
+                                               Old: safcm.FileChangeInfo{
+                                                       Mode:  fs.ModeNamedPipe | 0666,
+                                                       User:  user,
+                                                       Uid:   uid,
+                                                       Group: group,
+                                                       Gid:   gid,
+                                               },
+                                               New: safcm.FileChangeInfo{
+                                                       Mode:  fs.ModeDir | 0751,
+                                                       User:  user,
+                                                       Uid:   uid,
+                                                       Group: group,
+                                                       Gid:   gid,
+                                               },
+                                       },
+                               },
+                       },
+                       []string{
+                               `4: sync remote: files: "path" (group): type differs p--------- -> d---------`,
+                               `3: sync remote: files: "path" (group): updating`,
+                               `4: sync remote: files: "path" (group): removing (due to type change)`,
+                               `4: sync remote: files: "path" (group): creating directory`,
+                               `4: sync remote: files: "path" (group): chmodding drwxr-x--x`,
+                               fmt.Sprintf(`4: sync remote: files: "path" (group): chowning %d/%d`, uid, gid),
+                       },
+                       nil,
+               },
+
+               {
+                       "change: file to symlink (same content)",
+                       safcm.MsgSyncReq{},
+                       &safcm.File{
+                               Path:      "path",
+                               Mode:      fs.ModeSymlink | 0777,
+                               Uid:       -1,
+                               Gid:       -1,
+                               OrigGroup: "group",
+                               Data:      []byte("target"),
+                       },
+                       func() {
+                               createFile("path", "target", 0644)
+                       },
+                       true,
+                       []File{
+                               root,
+                               {
+                                       Path: "path",
+                                       Mode: fs.ModeSymlink | 0777,
+                                       Data: []byte("target"),
+                               },
+                       },
+                       safcm.MsgSyncResp{
+                               FileChanges: []safcm.FileChange{
+                                       {
+                                               Path: "path",
+                                               Old: safcm.FileChangeInfo{
+                                                       Mode:  0644,
+                                                       User:  user,
+                                                       Uid:   uid,
+                                                       Group: group,
+                                                       Gid:   gid,
+                                               },
+                                               New: safcm.FileChangeInfo{
+                                                       Mode:  fs.ModeSymlink | 0777,
+                                                       User:  user,
+                                                       Uid:   uid,
+                                                       Group: group,
+                                                       Gid:   gid,
+                                               },
+                                       },
+                               },
+                       },
+                       []string{
+                               `4: sync remote: files: "path" (group): type differs ---------- -> L---------`,
+                               `3: sync remote: files: "path" (group): updating`,
+                               `4: sync remote: files: "path" (group): creating temporary symlink ".pathRND"`,
+                               `4: sync remote: files: "path" (group): renaming ".pathRND"`,
+                       },
+                       nil,
+               },
+
+               // Diffs
+
+               {
+                       "diff: textual",
+                       safcm.MsgSyncReq{
+                               DryRun: true,
+                       },
+                       &safcm.File{
+                               Path: "file",
+                               Mode: 0644,
+                               Uid:  -1,
+                               Gid:  -1,
+                               Data: []byte(`
+this
+is
+a
+simple
+file
+`),
+                               OrigGroup: "group",
+                       },
+                       func() {
+                               createFile("file", `this
+is
+file
+!
+`, 0644)
+                       },
+                       true,
+                       []File{
+                               root,
+                               {
+                                       Path: "file",
+                                       Mode: 0644,
+                                       Data: []byte(`this
+is
+file
+!
+`),
+                               },
+                       },
+                       safcm.MsgSyncResp{
+                               FileChanges: []safcm.FileChange{
+                                       {
+                                               Path: "file",
+                                               Old: safcm.FileChangeInfo{
+                                                       Mode:  0644,
+                                                       User:  user,
+                                                       Uid:   uid,
+                                                       Group: group,
+                                                       Gid:   gid,
+                                               },
+                                               New: safcm.FileChangeInfo{
+                                                       Mode:  0644,
+                                                       User:  user,
+                                                       Uid:   uid,
+                                                       Group: group,
+                                                       Gid:   gid,
+                                               },
+                                               DataDiff: `@@ -1,5 +1,7 @@
++
+ this
+ is
++a
++simple
+ file
+-!
+`,
+                                       },
+                               },
+                       },
+                       []string{
+                               `4: sync remote: files: "file" (group): content differs`,
+                               `3: sync remote: files: "file" (group): updating`,
+                               `4: sync remote: files: "file" (group): dry-run, skipping changes`,
+                       },
+                       nil,
+               },
+
+               {
+                       "diff: binary both",
+                       safcm.MsgSyncReq{
+                               DryRun: true,
+                       },
+                       &safcm.File{
+                               Path:      "file",
+                               Mode:      0644,
+                               Uid:       -1,
+                               Gid:       -1,
+                               Data:      []byte("\x00\x01\x02\x03"),
+                               OrigGroup: "group",
+                       },
+                       func() {
+                               createFile("file", "\x00\x01\x02", 0644)
+                       },
+                       true,
+                       []File{
+                               root,
+                               {
+                                       Path: "file",
+                                       Mode: 0644,
+                                       Data: []byte("\x00\x01\x02"),
+                               },
+                       },
+                       safcm.MsgSyncResp{
+                               FileChanges: []safcm.FileChange{
+                                       {
+                                               Path: "file",
+                                               Old: safcm.FileChangeInfo{
+                                                       Mode:  0644,
+                                                       User:  user,
+                                                       Uid:   uid,
+                                                       Group: group,
+                                                       Gid:   gid,
+                                               },
+                                               New: safcm.FileChangeInfo{
+                                                       Mode:  0644,
+                                                       User:  user,
+                                                       Uid:   uid,
+                                                       Group: group,
+                                                       Gid:   gid,
+                                               },
+                                               DataDiff: "Binary files differ, cannot show diff",
+                                       },
+                               },
+                       },
+                       []string{
+                               `4: sync remote: files: "file" (group): content differs`,
+                               `3: sync remote: files: "file" (group): updating`,
+                               `4: sync remote: files: "file" (group): dry-run, skipping changes`,
+                       },
+                       nil,
+               },
+
+               {
+                       "diff: binary old",
+                       safcm.MsgSyncReq{
+                               DryRun: true,
+                       },
+                       &safcm.File{
+                               Path:      "file",
+                               Mode:      0644,
+                               Uid:       -1,
+                               Gid:       -1,
+                               Data:      []byte("content\n"),
+                               OrigGroup: "group",
+                       },
+                       func() {
+                               createFile("file", "\x00\x01\x02", 0644)
+                       },
+                       true,
+                       []File{
+                               root,
+                               {
+                                       Path: "file",
+                                       Mode: 0644,
+                                       Data: []byte("\x00\x01\x02"),
+                               },
+                       },
+                       safcm.MsgSyncResp{
+                               FileChanges: []safcm.FileChange{
+                                       {
+                                               Path: "file",
+                                               Old: safcm.FileChangeInfo{
+                                                       Mode:  0644,
+                                                       User:  user,
+                                                       Uid:   uid,
+                                                       Group: group,
+                                                       Gid:   gid,
+                                               },
+                                               New: safcm.FileChangeInfo{
+                                                       Mode:  0644,
+                                                       User:  user,
+                                                       Uid:   uid,
+                                                       Group: group,
+                                                       Gid:   gid,
+                                               },
+                                               DataDiff: `@@ -1,2 +1,2 @@
+-<binary content>
++content
+`,
+                                       },
+                               },
+                       },
+                       []string{
+                               `4: sync remote: files: "file" (group): content differs`,
+                               `3: sync remote: files: "file" (group): updating`,
+                               `4: sync remote: files: "file" (group): dry-run, skipping changes`,
+                       },
+                       nil,
+               },
+
+               {
+                       "diff: binary new",
+                       safcm.MsgSyncReq{
+                               DryRun: true,
+                       },
+                       &safcm.File{
+                               Path:      "file",
+                               Mode:      0644,
+                               Uid:       -1,
+                               Gid:       -1,
+                               Data:      []byte("\x00\x01\x02\x03"),
+                               OrigGroup: "group",
+                       },
+                       func() {
+                               createFile("file", "content\n", 0644)
+                       },
+                       true,
+                       []File{
+                               root,
+                               {
+                                       Path: "file",
+                                       Mode: 0644,
+                                       Data: []byte("content\n"),
+                               },
+                       },
+                       safcm.MsgSyncResp{
+                               FileChanges: []safcm.FileChange{
+                                       {
+                                               Path: "file",
+                                               Old: safcm.FileChangeInfo{
+                                                       Mode:  0644,
+                                                       User:  user,
+                                                       Uid:   uid,
+                                                       Group: group,
+                                                       Gid:   gid,
+                                               },
+                                               New: safcm.FileChangeInfo{
+                                                       Mode:  0644,
+                                                       User:  user,
+                                                       Uid:   uid,
+                                                       Group: group,
+                                                       Gid:   gid,
+                                               },
+                                               DataDiff: `@@ -1,2 +1,2 @@
+-content
++<binary content>
+`,
+                                       },
+                               },
+                       },
+                       []string{
+                               `4: sync remote: files: "file" (group): content differs`,
+                               `3: sync remote: files: "file" (group): updating`,
+                               `4: sync remote: files: "file" (group): dry-run, skipping changes`,
+                       },
+                       nil,
+               },
+       }
+
+       for _, tc := range tests {
+               // Create separate test directory for each test case
+               path := filepath.Join(cwd, "testdata", "file-"+tc.name)
+               err = os.Mkdir(path, 0700)
+               if err != nil {
+                       t.Fatal(err)
+               }
+               err = os.Chdir(path)
+               if err != nil {
+                       t.Fatal(err)
+               }
+
+               if tc.prepare != nil {
+                       tc.prepare()
+               }
+
+               s, res := prepareSync(tc.req, &testRunner{
+                       t:    t,
+                       name: tc.name,
+               })
+               s.setDefaults()
+
+               // Deterministic temporary symlink names
+               rand.Seed(0)
+
+               var changed bool
+               err := s.syncFile(tc.file, &changed)
+               // Ugly but the simplest way to compare errors (including nil)
+               if fmt.Sprintf("%s", err) != fmt.Sprintf("%s", tc.expErr) {
+                       t.Errorf("%s: err = %#v, want %#v",
+                               tc.name, err, tc.expErr)
+               }
+               dbg := res.Wait()
+               // Remove random file names from result
+               for i, x := range dbg {
+                       dbg[i] = randFilesRegexp.ReplaceAllString(x, `RND"`)
+               }
+               if !reflect.DeepEqual(tc.expDbg, dbg) {
+                       t.Errorf("%s: dbg: %s", tc.name,
+                               cmp.Diff(tc.expDbg, dbg))
+               }
+
+               files, err := walkDir(path)
+               if err != nil {
+                       t.Fatal(err)
+               }
+               if !reflect.DeepEqual(tc.expFiles, files) {
+                       t.Errorf("%s: files: %s", tc.name,
+                               cmp.Diff(tc.expFiles, files))
+               }
+
+               if tc.expChanged != changed {
+                       t.Errorf("%s: changed = %#v, want %#v",
+                               tc.name, changed, tc.expChanged)
+               }
+               if !reflect.DeepEqual(tc.expResp, s.resp) {
+                       t.Errorf("%s: resp: %s", tc.name,
+                               cmp.Diff(tc.expResp, s.resp))
+               }
+       }
+
+       if !t.Failed() {
+               err = os.RemoveAll(filepath.Join(cwd, "testdata"))
+               if err != nil {
+                       t.Fatal(err)
+               }
+       }
+}
+
+// Helper functions
+
+func createFile(path string, data string, mode fs.FileMode) {
+       err := os.WriteFile(path, []byte(data), 0644)
+       if err != nil {
+               panic(err)
+       }
+       err = os.Chmod(path, mode)
+       if err != nil {
+               panic(err)
+       }
+}
+func createSymlink(path string, data string) {
+       err := os.Symlink(data, path)
+       if err != nil {
+               panic(err)
+       }
+}
+func createDirectory(path string, mode fs.FileMode) {
+       err := os.Mkdir(path, 0700)
+       if err != nil {
+               panic(err)
+       }
+       err = os.Chmod(path, mode)
+       if err != nil {
+               panic(err)
+       }
+}
+func createFifo(path string, mode fs.FileMode) {
+       err := syscall.Mkfifo(path, 0600)
+       if err != nil {
+               panic(err)
+       }
+       err = os.Chmod(path, mode)
+       if err != nil {
+               panic(err)
+       }
+}
+
+func currentUserAndGroup() (string, int, string, int) {
+       u, err := user.Current()
+       if err != nil {
+               panic(err)
+       }
+       g, err := user.LookupGroupId(u.Gid)
+       if err != nil {
+               panic(err)
+       }
+       uid, err := strconv.Atoi(u.Uid)
+       if err != nil {
+               panic(err)
+       }
+       gid, err := strconv.Atoi(g.Gid)
+       if err != nil {
+               panic(err)
+       }
+       return u.Username, uid, g.Name, gid
+}
diff --git a/cmd/safcm-remote/sync/packages.go b/cmd/safcm-remote/sync/packages.go
new file mode 100644 (file)
index 0000000..f0b4309
--- /dev/null
@@ -0,0 +1,36 @@
+// MsgSyncReq: install packages on the remote host
+
+// Copyright (C) 2021  Simon Ruderich
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+package sync
+
+import (
+       "fmt"
+       "os"
+)
+
+func (s *Sync) syncPackages() error {
+       if len(s.req.Packages) == 0 {
+               return nil
+       }
+
+       _, err := os.Stat("/etc/debian_version")
+       if err == nil {
+               return s.syncPackagesDebian()
+       }
+       // TODO: support more distributions
+       return fmt.Errorf("not yet supported on this distribution")
+}
diff --git a/cmd/safcm-remote/sync/packages_debian.go b/cmd/safcm-remote/sync/packages_debian.go
new file mode 100644 (file)
index 0000000..0dcbc4f
--- /dev/null
@@ -0,0 +1,111 @@
+// MsgSyncReq: install packages on the remote host (Debian)
+
+// Copyright (C) 2021  Simon Ruderich
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+package sync
+
+import (
+       "fmt"
+       "os"
+       "os/exec"
+       "strings"
+
+       "ruderich.org/simon/safcm"
+)
+
+func (s *Sync) syncPackagesDebian() error {
+       s.log.Debugf("packages: detected debian")
+
+       installed, err := s.debianInstalledPackages()
+       if err != nil {
+               return err
+       }
+
+       s.log.Debugf("packages: checking %s",
+               strings.Join(s.req.Packages, " "))
+       var install []string
+       for _, x := range s.req.Packages {
+               if !installed[x] {
+                       install = append(install, x)
+               }
+       }
+       if len(install) == 0 {
+               return nil
+       }
+
+       for _, x := range install {
+               s.resp.PackageChanges = append(s.resp.PackageChanges,
+                       safcm.PackageChange{
+                               Name: x,
+                       })
+       }
+
+       if s.req.DryRun {
+               return nil
+       }
+
+       s.log.Verbosef("packages: installing %s", strings.Join(install, " "))
+       cmd := exec.Command("/usr/bin/apt-get", append([]string{"install",
+               // Don't require further acknowledgment; this won't perform
+               // dangerous actions
+               "--assume-yes",
+               // Don't perform upgrades
+               "--no-upgrade",
+               // Nobody needs those
+               "--no-install-recommends",
+               // Don't overwrite existing config files
+               "-o", "Dpkg::Options::=--force-confdef",
+               "-o", "Dpkg::Options::=--force-confold",
+       }, install...)...)
+       cmd.Env = append(os.Environ(),
+               // Don't ask questions during installation
+               "DEBIAN_FRONTEND=noninteractive",
+       )
+       _, err = s.cmd.CombinedOutputCmd("packages", cmd)
+       if err != nil {
+               return err
+       }
+
+       return nil
+}
+
+func (s *Sync) debianInstalledPackages() (map[string]bool, error) {
+       out, _, err := s.cmd.Run("packages",
+               "/usr/bin/dpkg-query",
+               "--show",
+               `--showformat=${Status}\t${Package}\n`,
+       )
+       if err != nil {
+               return nil, err
+       }
+       lines := strings.Split(strings.TrimSpace(string(out)), "\n")
+
+       res := make(map[string]bool)
+       for _, line := range lines {
+               xs := strings.Split(line, "\t")
+               if len(xs) != 2 {
+                       return nil, fmt.Errorf("invalid dpkg-query line %q",
+                               line)
+               }
+               // We only care if the package is currently successfully
+               // installed (last two fields). If a package is on hold (first
+               // field) this is fine as well.
+               if strings.HasSuffix(xs[0], " ok installed") {
+                       res[xs[1]] = true
+               }
+       }
+       return res, nil
+}
diff --git a/cmd/safcm-remote/sync/packages_debian_test.go b/cmd/safcm-remote/sync/packages_debian_test.go
new file mode 100644 (file)
index 0000000..f67dda9
--- /dev/null
@@ -0,0 +1,329 @@
+// Copyright (C) 2021  Simon Ruderich
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+package sync
+
+import (
+       "bytes"
+       "fmt"
+       "os"
+       "os/exec"
+       "reflect"
+       "testing"
+
+       "github.com/google/go-cmp/cmp"
+
+       "ruderich.org/simon/safcm"
+)
+
+func TestSyncPackagesDebian(t *testing.T) {
+       tests := []struct {
+               name    string
+               req     safcm.MsgSyncReq
+               stdout  [][]byte
+               stderr  [][]byte
+               errors  []error
+               expCmds []*exec.Cmd
+               expDbg  []string
+               expResp safcm.MsgSyncResp
+               expErr  error
+       }{
+
+               // NOTE: Also update MsgSyncResp in safcm test cases when
+               // changing anything here!
+
+               {
+                       "packages already installed",
+                       safcm.MsgSyncReq{
+                               Packages: []string{
+                                       "package-one",
+                                       "package-two",
+                               },
+                       },
+                       [][]byte{
+                               []byte(`install ok installed    golang
+install ok installed   golang-1.16
+install ok installed   golang-1.16-doc
+install ok installed   golang-1.16-go
+install ok installed   golang-1.16-src
+hold ok installed      package-one
+install ok installed   package-two
+`),
+                       },
+                       [][]byte{nil},
+                       []error{nil},
+                       []*exec.Cmd{&exec.Cmd{
+                               Path: "/usr/bin/dpkg-query",
+                               Args: []string{
+                                       "/usr/bin/dpkg-query",
+                                       "--show",
+                                       `--showformat=${Status}\t${Package}\n`,
+                               },
+                               Stdout: &bytes.Buffer{},
+                               Stderr: &bytes.Buffer{},
+                       }},
+                       []string{
+                               "4: sync remote: packages: detected debian",
+                               `4: sync remote: packages: running "/usr/bin/dpkg-query" "--show" "--showformat=${Status}\\t${Package}\\n"`,
+                               `5: sync remote: packages: command stdout:
+install ok installed   golang
+install ok installed   golang-1.16
+install ok installed   golang-1.16-doc
+install ok installed   golang-1.16-go
+install ok installed   golang-1.16-src
+hold ok installed      package-one
+install ok installed   package-two
+`,
+                               "4: sync remote: packages: checking package-one package-two",
+                       },
+                       safcm.MsgSyncResp{},
+                       nil,
+               },
+
+               {
+                       "packages not yet installed",
+                       safcm.MsgSyncReq{
+                               Packages: []string{
+                                       "package-one",
+                                       "package-two",
+                                       "package-three",
+                               },
+                       },
+                       [][]byte{
+                               []byte(`install ok installed    golang
+install ok installed   golang-1.16
+install ok installed   golang-1.16-doc
+install ok installed   golang-1.16-go
+install ok installed   golang-1.16-src
+install ok installed   package-two
+`),
+                               []byte("fake stdout/stderr"),
+                       },
+                       [][]byte{nil, nil},
+                       []error{nil, nil},
+                       []*exec.Cmd{&exec.Cmd{
+                               Path: "/usr/bin/dpkg-query",
+                               Args: []string{
+                                       "/usr/bin/dpkg-query",
+                                       "--show",
+                                       `--showformat=${Status}\t${Package}\n`,
+                               },
+                               Stdout: &bytes.Buffer{},
+                               Stderr: &bytes.Buffer{},
+                       }, &exec.Cmd{
+                               Path: "/usr/bin/apt-get",
+                               Args: []string{
+                                       "/usr/bin/apt-get",
+                                       "install",
+                                       "--assume-yes",
+                                       "--no-upgrade",
+                                       "--no-install-recommends",
+                                       "-o", "Dpkg::Options::=--force-confdef",
+                                       "-o", "Dpkg::Options::=--force-confold",
+                                       "package-one",
+                                       "package-three",
+                               },
+                               Env: append(os.Environ(),
+                                       "DEBIAN_FRONTEND=noninteractive",
+                               ),
+                       }},
+                       []string{
+                               "4: sync remote: packages: detected debian",
+                               `4: sync remote: packages: running "/usr/bin/dpkg-query" "--show" "--showformat=${Status}\\t${Package}\\n"`,
+                               `5: sync remote: packages: command stdout:
+install ok installed   golang
+install ok installed   golang-1.16
+install ok installed   golang-1.16-doc
+install ok installed   golang-1.16-go
+install ok installed   golang-1.16-src
+install ok installed   package-two
+`,
+                               "4: sync remote: packages: checking package-one package-two package-three",
+                               "3: sync remote: packages: installing package-one package-three",
+                               `4: sync remote: packages: running "/usr/bin/apt-get" "install" "--assume-yes" "--no-upgrade" "--no-install-recommends" "-o" "Dpkg::Options::=--force-confdef" "-o" "Dpkg::Options::=--force-confold" "package-one" "package-three"`,
+                               "5: sync remote: packages: command output:\nfake stdout/stderr",
+                       },
+                       safcm.MsgSyncResp{
+                               PackageChanges: []safcm.PackageChange{
+                                       {
+                                               Name: "package-one",
+                                       },
+                                       {
+                                               Name: "package-three",
+                                       },
+                               },
+                       },
+                       nil,
+               },
+
+               {
+                       "packages not yet installed (error)",
+                       safcm.MsgSyncReq{
+                               Packages: []string{
+                                       "package-one",
+                                       "package-two",
+                               },
+                       },
+                       [][]byte{
+                               []byte(`install ok installed    golang
+install ok installed   golang-1.16
+install ok installed   golang-1.16-doc
+install ok installed   golang-1.16-go
+install ok installed   golang-1.16-src
+`),
+                               []byte("fake stdout/stderr"),
+                       },
+                       [][]byte{nil, nil},
+                       []error{
+                               nil,
+                               fmt.Errorf("fake error"),
+                       },
+                       []*exec.Cmd{&exec.Cmd{
+                               Path: "/usr/bin/dpkg-query",
+                               Args: []string{
+                                       "/usr/bin/dpkg-query",
+                                       "--show",
+                                       `--showformat=${Status}\t${Package}\n`,
+                               },
+                               Stdout: &bytes.Buffer{},
+                               Stderr: &bytes.Buffer{},
+                       }, &exec.Cmd{
+                               Path: "/usr/bin/apt-get",
+                               Args: []string{
+                                       "/usr/bin/apt-get",
+                                       "install",
+                                       "--assume-yes",
+                                       "--no-upgrade",
+                                       "--no-install-recommends",
+                                       "-o", "Dpkg::Options::=--force-confdef",
+                                       "-o", "Dpkg::Options::=--force-confold",
+                                       "package-one",
+                                       "package-two",
+                               },
+                               Env: append(os.Environ(),
+                                       "DEBIAN_FRONTEND=noninteractive",
+                               ),
+                       }},
+                       []string{
+                               "4: sync remote: packages: detected debian",
+                               `4: sync remote: packages: running "/usr/bin/dpkg-query" "--show" "--showformat=${Status}\\t${Package}\\n"`,
+                               `5: sync remote: packages: command stdout:
+install ok installed   golang
+install ok installed   golang-1.16
+install ok installed   golang-1.16-doc
+install ok installed   golang-1.16-go
+install ok installed   golang-1.16-src
+`,
+                               "4: sync remote: packages: checking package-one package-two",
+                               "3: sync remote: packages: installing package-one package-two",
+                               `4: sync remote: packages: running "/usr/bin/apt-get" "install" "--assume-yes" "--no-upgrade" "--no-install-recommends" "-o" "Dpkg::Options::=--force-confdef" "-o" "Dpkg::Options::=--force-confold" "package-one" "package-two"`,
+                               "5: sync remote: packages: command output:\nfake stdout/stderr",
+                       },
+                       safcm.MsgSyncResp{
+                               PackageChanges: []safcm.PackageChange{
+                                       {
+                                               Name: "package-one",
+                                       },
+                                       {
+                                               Name: "package-two",
+                                       },
+                               },
+                       },
+                       fmt.Errorf(`"/usr/bin/apt-get" "install" "--assume-yes" "--no-upgrade" "--no-install-recommends" "-o" "Dpkg::Options::=--force-confdef" "-o" "Dpkg::Options::=--force-confold" "package-one" "package-two" failed: fake error; output: "fake stdout/stderr"`),
+               },
+
+               {
+                       "packages not yet installed (dry-run)",
+                       safcm.MsgSyncReq{
+                               DryRun: true,
+                               Packages: []string{
+                                       "package-one",
+                                       "package-two",
+                               },
+                       },
+                       [][]byte{
+                               []byte(`install ok installed    golang
+install ok installed   golang-1.16
+install ok installed   golang-1.16-doc
+install ok installed   golang-1.16-go
+install ok installed   golang-1.16-src
+`),
+                       },
+                       [][]byte{nil},
+                       []error{nil},
+                       []*exec.Cmd{&exec.Cmd{
+                               Path: "/usr/bin/dpkg-query",
+                               Args: []string{
+                                       "/usr/bin/dpkg-query",
+                                       "--show",
+                                       `--showformat=${Status}\t${Package}\n`,
+                               },
+                               Stdout: &bytes.Buffer{},
+                               Stderr: &bytes.Buffer{},
+                       }},
+                       []string{
+                               "4: sync remote: packages: detected debian",
+                               `4: sync remote: packages: running "/usr/bin/dpkg-query" "--show" "--showformat=${Status}\\t${Package}\\n"`,
+                               `5: sync remote: packages: command stdout:
+install ok installed   golang
+install ok installed   golang-1.16
+install ok installed   golang-1.16-doc
+install ok installed   golang-1.16-go
+install ok installed   golang-1.16-src
+`,
+                               "4: sync remote: packages: checking package-one package-two",
+                       },
+                       safcm.MsgSyncResp{
+                               PackageChanges: []safcm.PackageChange{
+                                       {
+                                               Name: "package-one",
+                                       },
+                                       {
+                                               Name: "package-two",
+                                       },
+                               },
+                       },
+                       nil,
+               },
+       }
+
+       for _, tc := range tests {
+               s, res := prepareSync(tc.req, &testRunner{
+                       t:         t,
+                       name:      tc.name,
+                       expCmds:   tc.expCmds,
+                       resStdout: tc.stdout,
+                       resStderr: tc.stderr,
+                       resError:  tc.errors,
+               })
+
+               err := s.syncPackagesDebian()
+               // Ugly but the simplest way to compare errors (including nil)
+               if fmt.Sprintf("%s", err) != fmt.Sprintf("%s", tc.expErr) {
+                       t.Errorf("%s: err = %#v, want %#v",
+                               tc.name, err, tc.expErr)
+               }
+               dbg := res.Wait()
+
+               if !reflect.DeepEqual(tc.expResp, s.resp) {
+                       t.Errorf("%s: resp: %s", tc.name,
+                               cmp.Diff(tc.expResp, s.resp))
+               }
+               if !reflect.DeepEqual(tc.expDbg, dbg) {
+                       t.Errorf("%s: dbg: %s", tc.name,
+                               cmp.Diff(tc.expDbg, dbg))
+               }
+       }
+}
diff --git a/cmd/safcm-remote/sync/services.go b/cmd/safcm-remote/sync/services.go
new file mode 100644 (file)
index 0000000..2e1ba6c
--- /dev/null
@@ -0,0 +1,39 @@
+// MsgSyncReq: enable and start services on the remote host
+
+// Copyright (C) 2021  Simon Ruderich
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+package sync
+
+import (
+       "fmt"
+       "path/filepath"
+)
+
+func (s *Sync) syncServices() error {
+       if len(s.req.Services) == 0 {
+               return nil
+       }
+
+       x, err := filepath.EvalSymlinks("/sbin/init")
+       if err != nil {
+               return err
+       }
+       if filepath.Base(x) == "systemd" {
+               return s.syncServicesSystemd()
+       }
+       // TODO: support more distributions
+       return fmt.Errorf("not yet supported on this distribution")
+}
diff --git a/cmd/safcm-remote/sync/services_systemd.go b/cmd/safcm-remote/sync/services_systemd.go
new file mode 100644 (file)
index 0000000..68bbc7d
--- /dev/null
@@ -0,0 +1,157 @@
+// MsgSyncReq: enable and start services on the remote host (systemd)
+
+// Copyright (C) 2021  Simon Ruderich
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+package sync
+
+import (
+       "fmt"
+       "strings"
+
+       "ruderich.org/simon/safcm"
+)
+
+func (s *Sync) syncServicesSystemd() error {
+       s.log.Debugf("services: detected systemd")
+
+       s.log.Debugf("services: checking %s",
+               strings.Join(s.req.Services, " "))
+       services, err := s.systemdServiceState(s.req.Services)
+       if err != nil {
+               return err
+       }
+
+       var start, enable []string
+       for _, name := range s.req.Services {
+               var change safcm.ServiceChange
+
+               x := services[name]
+               if x.ActiveState != "active" {
+                       start = append(start, name)
+                       change.Started = true
+               }
+               if x.UnitFileState != "enabled" {
+                       enable = append(enable, name)
+                       change.Enabled = true
+               }
+
+               if change.Started || change.Enabled {
+                       change.Name = name
+                       s.resp.ServiceChanges = append(s.resp.ServiceChanges,
+                               change)
+               }
+       }
+       if len(start) == 0 && len(enable) == 0 {
+               return nil
+       }
+
+       if s.req.DryRun {
+               return nil
+       }
+
+       // Reload service files which were possibly changed during file sync
+       // or package installation
+       _, _, err = s.cmd.Run("services", "/bin/systemctl", "daemon-reload")
+       if err != nil {
+               return err
+       }
+       if len(start) != 0 {
+               s.log.Verbosef("services: starting %s",
+                       strings.Join(start, " "))
+               _, _, err := s.cmd.Run("services", append([]string{
+                       "/bin/systemctl", "start", "--",
+               }, start...)...)
+               if err != nil {
+                       return err
+               }
+       }
+       if len(enable) != 0 {
+               s.log.Verbosef("services: enabling %s",
+                       strings.Join(enable, " "))
+               _, _, err := s.cmd.Run("services", append([]string{
+                       "/bin/systemctl", "enable", "--",
+               }, enable...)...)
+               if err != nil {
+                       return err
+               }
+       }
+
+       return nil
+}
+
+type SystemdService struct {
+       ActiveState   string
+       UnitFileState string
+}
+
+func (s *Sync) systemdServiceState(services []string) (
+       map[string]SystemdService, error) {
+
+       out, _, err := s.cmd.Run("services", append([]string{
+               "/bin/systemctl",
+               "show",
+               "--property=ActiveState,UnitFileState,LoadError",
+               "--",
+       }, services...)...)
+       if err != nil {
+               return nil, err
+       }
+
+       i := 0
+
+       res := make(map[string]SystemdService)
+       for _, block := range strings.Split(string(out), "\n\n") {
+               lines := strings.Split(strings.TrimSpace(block), "\n")
+               if len(lines) != 3 {
+                       return nil, fmt.Errorf("invalid systemctl output: %q",
+                               block)
+               }
+
+               var service SystemdService
+               for _, x := range lines {
+                       const (
+                               activePrefix = "ActiveState="
+                               unitPrefix   = "UnitFileState="
+                               errorPrefix  = "LoadError="
+                       )
+
+                       if strings.HasPrefix(x, activePrefix) {
+                               service.ActiveState = strings.TrimPrefix(x,
+                                       activePrefix)
+                       } else if strings.HasPrefix(x, unitPrefix) {
+                               service.UnitFileState = strings.TrimPrefix(x,
+                                       unitPrefix)
+                       } else if strings.HasPrefix(x, errorPrefix) {
+                               x := strings.TrimPrefix(x, errorPrefix)
+                               // Older systemd versions (e.g. 237) add empty
+                               // quotes even if there is no error
+                               if x != "" && x != ` ""` {
+                                       return nil, fmt.Errorf(
+                                               "systemd unit %q not found",
+                                               services[i])
+                               }
+                       } else {
+                               return nil, fmt.Errorf(
+                                       "invalid systemctl show line %q", x)
+                       }
+               }
+               res[services[i]] = service
+
+               i++
+       }
+
+       return res, nil
+}
diff --git a/cmd/safcm-remote/sync/services_systemd_test.go b/cmd/safcm-remote/sync/services_systemd_test.go
new file mode 100644 (file)
index 0000000..8ba0bfa
--- /dev/null
@@ -0,0 +1,536 @@
+// Copyright (C) 2021  Simon Ruderich
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+package sync
+
+import (
+       "bytes"
+       "fmt"
+       "os/exec"
+       "reflect"
+       "testing"
+
+       "github.com/google/go-cmp/cmp"
+
+       "ruderich.org/simon/safcm"
+)
+
+func TestSyncServicesSystemd(t *testing.T) {
+       tests := []struct {
+               name    string
+               req     safcm.MsgSyncReq
+               stdout  [][]byte
+               stderr  [][]byte
+               errors  []error
+               expCmds []*exec.Cmd
+               expDbg  []string
+               expResp safcm.MsgSyncResp
+               expErr  error
+       }{
+
+               // NOTE: Also update MsgSyncResp in safcm test cases when
+               // changing anything here!
+
+               {
+                       "no service change necessary",
+                       safcm.MsgSyncReq{
+                               Services: []string{
+                                       "service-one",
+                                       "service-two",
+                               },
+                       },
+                       [][]byte{
+                               []byte(`ActiveState=active
+UnitFileState=enabled
+LoadError=
+
+ActiveState=active
+UnitFileState=enabled
+LoadError=
+`),
+                       },
+                       [][]byte{nil},
+                       []error{nil},
+                       []*exec.Cmd{&exec.Cmd{
+                               Path: "/bin/systemctl",
+                               Args: []string{
+                                       "/bin/systemctl",
+                                       "show",
+                                       "--property=ActiveState,UnitFileState,LoadError",
+                                       "--",
+                                       "service-one",
+                                       "service-two",
+                               },
+                               Stdout: &bytes.Buffer{},
+                               Stderr: &bytes.Buffer{},
+                       }},
+                       []string{
+                               "4: sync remote: services: detected systemd",
+                               "4: sync remote: services: checking service-one service-two",
+                               `4: sync remote: services: running "/bin/systemctl" "show" "--property=ActiveState,UnitFileState,LoadError" "--" "service-one" "service-two"`,
+                               `5: sync remote: services: command stdout:
+ActiveState=active
+UnitFileState=enabled
+LoadError=
+
+ActiveState=active
+UnitFileState=enabled
+LoadError=
+`,
+                       },
+                       safcm.MsgSyncResp{},
+                       nil,
+               },
+
+               {
+                       "no service change necessary (older systemd)",
+                       safcm.MsgSyncReq{
+                               Services: []string{
+                                       "service-one",
+                                       "service-two",
+                               },
+                       },
+                       [][]byte{
+                               []byte(`ActiveState=active
+UnitFileState=enabled
+LoadError= ""
+
+ActiveState=active
+UnitFileState=enabled
+LoadError= ""
+`),
+                       },
+                       [][]byte{nil},
+                       []error{nil},
+                       []*exec.Cmd{&exec.Cmd{
+                               Path: "/bin/systemctl",
+                               Args: []string{
+                                       "/bin/systemctl",
+                                       "show",
+                                       "--property=ActiveState,UnitFileState,LoadError",
+                                       "--",
+                                       "service-one",
+                                       "service-two",
+                               },
+                               Stdout: &bytes.Buffer{},
+                               Stderr: &bytes.Buffer{},
+                       }},
+                       []string{
+                               "4: sync remote: services: detected systemd",
+                               "4: sync remote: services: checking service-one service-two",
+                               `4: sync remote: services: running "/bin/systemctl" "show" "--property=ActiveState,UnitFileState,LoadError" "--" "service-one" "service-two"`,
+                               `5: sync remote: services: command stdout:
+ActiveState=active
+UnitFileState=enabled
+LoadError= ""
+
+ActiveState=active
+UnitFileState=enabled
+LoadError= ""
+`,
+                       },
+                       safcm.MsgSyncResp{},
+                       nil,
+               },
+
+               {
+                       "invalid service",
+                       safcm.MsgSyncReq{
+                               Services: []string{
+                                       "service-does-not-exist",
+                                       "service-two",
+                               },
+                       },
+                       [][]byte{
+                               []byte(`ActiveState=inactive
+UnitFileState=
+LoadError=org.freedesktop.systemd1.NoSuchUnit "Unit service-does-not-exist.service not found."
+
+ActiveState=active
+UnitFileState=enabled
+LoadError=
+`),
+                       },
+                       [][]byte{nil},
+                       []error{nil},
+                       []*exec.Cmd{&exec.Cmd{
+                               Path: "/bin/systemctl",
+                               Args: []string{
+                                       "/bin/systemctl",
+                                       "show",
+                                       "--property=ActiveState,UnitFileState,LoadError",
+                                       "--",
+                                       "service-does-not-exist",
+                                       "service-two",
+                               },
+                               Stdout: &bytes.Buffer{},
+                               Stderr: &bytes.Buffer{},
+                       }},
+                       []string{
+                               "4: sync remote: services: detected systemd",
+                               "4: sync remote: services: checking service-does-not-exist service-two",
+                               `4: sync remote: services: running "/bin/systemctl" "show" "--property=ActiveState,UnitFileState,LoadError" "--" "service-does-not-exist" "service-two"`,
+                               `5: sync remote: services: command stdout:
+ActiveState=inactive
+UnitFileState=
+LoadError=org.freedesktop.systemd1.NoSuchUnit "Unit service-does-not-exist.service not found."
+
+ActiveState=active
+UnitFileState=enabled
+LoadError=
+`,
+                       },
+                       safcm.MsgSyncResp{},
+                       fmt.Errorf("systemd unit \"service-does-not-exist\" not found"),
+               },
+
+               {
+                       "start/enable service",
+                       safcm.MsgSyncReq{
+                               Services: []string{
+                                       "service-one",
+                                       "service-two",
+                                       "service-three",
+                               },
+                       },
+                       [][]byte{
+                               []byte(`ActiveState=inactive
+UnitFileState=enabled
+LoadError=
+
+ActiveState=active
+UnitFileState=disabled
+LoadError=
+
+ActiveState=failed
+UnitFileState=disabled
+LoadError=
+`),
+                               nil,
+                               nil,
+                               nil,
+                       },
+                       [][]byte{
+                               nil,
+                               nil,
+                               nil,
+                               []byte(`fake stderr`),
+                       },
+                       []error{nil, nil, nil, nil},
+                       []*exec.Cmd{&exec.Cmd{
+                               Path: "/bin/systemctl",
+                               Args: []string{
+                                       "/bin/systemctl",
+                                       "show",
+                                       "--property=ActiveState,UnitFileState,LoadError",
+                                       "--",
+                                       "service-one",
+                                       "service-two",
+                                       "service-three",
+                               },
+                               Stdout: &bytes.Buffer{},
+                               Stderr: &bytes.Buffer{},
+                       }, &exec.Cmd{
+                               Path: "/bin/systemctl",
+                               Args: []string{
+                                       "/bin/systemctl",
+                                       "daemon-reload",
+                               },
+                               Stdout: &bytes.Buffer{},
+                               Stderr: &bytes.Buffer{},
+                       }, &exec.Cmd{
+                               Path: "/bin/systemctl",
+                               Args: []string{
+                                       "/bin/systemctl",
+                                       "start",
+                                       "--",
+                                       "service-one",
+                                       "service-three",
+                               },
+                               Stdout: &bytes.Buffer{},
+                               Stderr: &bytes.Buffer{},
+                       }, &exec.Cmd{
+                               Path: "/bin/systemctl",
+                               Args: []string{
+                                       "/bin/systemctl",
+                                       "enable",
+                                       "--",
+                                       "service-two",
+                                       "service-three",
+                               },
+                               Stdout: &bytes.Buffer{},
+                               Stderr: &bytes.Buffer{},
+                       }},
+                       []string{
+                               "4: sync remote: services: detected systemd",
+                               "4: sync remote: services: checking service-one service-two service-three",
+                               `4: sync remote: services: running "/bin/systemctl" "show" "--property=ActiveState,UnitFileState,LoadError" "--" "service-one" "service-two" "service-three"`,
+                               `5: sync remote: services: command stdout:
+ActiveState=inactive
+UnitFileState=enabled
+LoadError=
+
+ActiveState=active
+UnitFileState=disabled
+LoadError=
+
+ActiveState=failed
+UnitFileState=disabled
+LoadError=
+`,
+                               `4: sync remote: services: running "/bin/systemctl" "daemon-reload"`,
+                               "3: sync remote: services: starting service-one service-three",
+                               `4: sync remote: services: running "/bin/systemctl" "start" "--" "service-one" "service-three"`,
+                               "3: sync remote: services: enabling service-two service-three",
+                               `4: sync remote: services: running "/bin/systemctl" "enable" "--" "service-two" "service-three"`,
+                               "5: sync remote: services: command stderr:\nfake stderr",
+                       },
+                       safcm.MsgSyncResp{
+                               ServiceChanges: []safcm.ServiceChange{
+                                       {
+                                               Name:    "service-one",
+                                               Started: true,
+                                       },
+                                       {
+                                               Name:    "service-two",
+                                               Enabled: true,
+                                       },
+                                       {
+                                               Name:    "service-three",
+                                               Started: true,
+                                               Enabled: true,
+                                       },
+                               },
+                       },
+                       nil,
+               },
+
+               {
+                       "start/enable service (dry-run)",
+                       safcm.MsgSyncReq{
+                               DryRun: true,
+                               Services: []string{
+                                       "service-one",
+                                       "service-two",
+                                       "service-three",
+                               },
+                       },
+                       [][]byte{
+                               []byte(`ActiveState=inactive
+UnitFileState=enabled
+LoadError=
+
+ActiveState=active
+UnitFileState=disabled
+LoadError=
+
+ActiveState=failed
+UnitFileState=disabled
+LoadError=
+`),
+                       },
+                       [][]byte{nil},
+                       []error{nil},
+                       []*exec.Cmd{&exec.Cmd{
+                               Path: "/bin/systemctl",
+                               Args: []string{
+                                       "/bin/systemctl",
+                                       "show",
+                                       "--property=ActiveState,UnitFileState,LoadError",
+                                       "--",
+                                       "service-one",
+                                       "service-two",
+                                       "service-three",
+                               },
+                               Stdout: &bytes.Buffer{},
+                               Stderr: &bytes.Buffer{},
+                       }},
+                       []string{
+                               "4: sync remote: services: detected systemd",
+                               "4: sync remote: services: checking service-one service-two service-three",
+                               `4: sync remote: services: running "/bin/systemctl" "show" "--property=ActiveState,UnitFileState,LoadError" "--" "service-one" "service-two" "service-three"`,
+                               `5: sync remote: services: command stdout:
+ActiveState=inactive
+UnitFileState=enabled
+LoadError=
+
+ActiveState=active
+UnitFileState=disabled
+LoadError=
+
+ActiveState=failed
+UnitFileState=disabled
+LoadError=
+`,
+                       },
+                       safcm.MsgSyncResp{
+                               ServiceChanges: []safcm.ServiceChange{
+                                       {
+                                               Name:    "service-one",
+                                               Started: true,
+                                       },
+                                       {
+                                               Name:    "service-two",
+                                               Enabled: true,
+                                       },
+                                       {
+                                               Name:    "service-three",
+                                               Started: true,
+                                               Enabled: true,
+                                       },
+                               },
+                       },
+                       nil,
+               },
+
+               {
+                       "start/enable service (error)",
+                       safcm.MsgSyncReq{
+                               Services: []string{
+                                       "service-one",
+                                       "service-two",
+                                       "service-three",
+                               },
+                       },
+                       [][]byte{
+                               []byte(`ActiveState=inactive
+UnitFileState=enabled
+LoadError=
+
+ActiveState=active
+UnitFileState=disabled
+LoadError=
+
+ActiveState=failed
+UnitFileState=disabled
+LoadError=
+`),
+                               nil,
+                               nil,
+                       },
+                       [][]byte{
+                               nil,
+                               nil,
+                               []byte(`fake stderr`),
+                       },
+                       []error{
+                               nil,
+                               nil,
+                               fmt.Errorf("fake error"),
+                       },
+                       []*exec.Cmd{&exec.Cmd{
+                               Path: "/bin/systemctl",
+                               Args: []string{
+                                       "/bin/systemctl",
+                                       "show",
+                                       "--property=ActiveState,UnitFileState,LoadError",
+                                       "--",
+                                       "service-one",
+                                       "service-two",
+                                       "service-three",
+                               },
+                               Stdout: &bytes.Buffer{},
+                               Stderr: &bytes.Buffer{},
+                       }, &exec.Cmd{
+                               Path: "/bin/systemctl",
+                               Args: []string{
+                                       "/bin/systemctl",
+                                       "daemon-reload",
+                               },
+                               Stdout: &bytes.Buffer{},
+                               Stderr: &bytes.Buffer{},
+                       }, &exec.Cmd{
+                               Path: "/bin/systemctl",
+                               Args: []string{
+                                       "/bin/systemctl",
+                                       "start",
+                                       "--",
+                                       "service-one",
+                                       "service-three",
+                               },
+                               Stdout: &bytes.Buffer{},
+                               Stderr: &bytes.Buffer{},
+                       }},
+                       []string{
+                               "4: sync remote: services: detected systemd",
+                               "4: sync remote: services: checking service-one service-two service-three",
+                               `4: sync remote: services: running "/bin/systemctl" "show" "--property=ActiveState,UnitFileState,LoadError" "--" "service-one" "service-two" "service-three"`,
+                               `5: sync remote: services: command stdout:
+ActiveState=inactive
+UnitFileState=enabled
+LoadError=
+
+ActiveState=active
+UnitFileState=disabled
+LoadError=
+
+ActiveState=failed
+UnitFileState=disabled
+LoadError=
+`,
+                               `4: sync remote: services: running "/bin/systemctl" "daemon-reload"`,
+                               "3: sync remote: services: starting service-one service-three",
+                               `4: sync remote: services: running "/bin/systemctl" "start" "--" "service-one" "service-three"`,
+                               "5: sync remote: services: command stderr:\nfake stderr",
+                       },
+                       safcm.MsgSyncResp{
+                               ServiceChanges: []safcm.ServiceChange{
+                                       {
+                                               Name:    "service-one",
+                                               Started: true,
+                                       },
+                                       {
+                                               Name:    "service-two",
+                                               Enabled: true,
+                                       },
+                                       {
+                                               Name:    "service-three",
+                                               Started: true,
+                                               Enabled: true,
+                                       },
+                               },
+                       },
+                       fmt.Errorf(`"/bin/systemctl" "start" "--" "service-one" "service-three" failed: fake error; stdout: "", stderr: "fake stderr"`),
+               },
+       }
+
+       for _, tc := range tests {
+               s, res := prepareSync(tc.req, &testRunner{
+                       t:         t,
+                       name:      tc.name,
+                       expCmds:   tc.expCmds,
+                       resStdout: tc.stdout,
+                       resStderr: tc.stderr,
+                       resError:  tc.errors,
+               })
+
+               err := s.syncServicesSystemd()
+               // Ugly but the simplest way to compare errors (including nil)
+               if fmt.Sprintf("%s", err) != fmt.Sprintf("%s", tc.expErr) {
+                       t.Errorf("%s: err = %#v, want %#v",
+                               tc.name, err, tc.expErr)
+               }
+               dbg := res.Wait()
+
+               if !reflect.DeepEqual(tc.expResp, s.resp) {
+                       t.Errorf("%s: resp: %s", tc.name,
+                               cmp.Diff(tc.expResp, s.resp))
+               }
+               if !reflect.DeepEqual(tc.expDbg, dbg) {
+                       t.Errorf("%s: dbg: %s", tc.name,
+                               cmp.Diff(tc.expDbg, dbg))
+               }
+       }
+}
diff --git a/cmd/safcm-remote/sync/sync.go b/cmd/safcm-remote/sync/sync.go
new file mode 100644 (file)
index 0000000..6f1cb78
--- /dev/null
@@ -0,0 +1,98 @@
+// MsgSyncReq: sync data on the remote host
+
+// Copyright (C) 2021  Simon Ruderich
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+package sync
+
+import (
+       "fmt"
+       "os/user"
+
+       "ruderich.org/simon/safcm"
+       "ruderich.org/simon/safcm/cmd/safcm-remote/log"
+       "ruderich.org/simon/safcm/cmd/safcm-remote/run"
+)
+
+type Sync struct {
+       req  safcm.MsgSyncReq
+       resp safcm.MsgSyncResp
+
+       defaultUser  string
+       defaultGroup string
+
+       triggers       []string
+       triggersActive map[string]bool
+
+       cmd *run.Cmd
+       log *log.PrefixLogger
+}
+
+const logPrefix = "sync remote:"
+
+func Handle(req safcm.MsgSyncReq,
+       runner run.Runner, fun log.LogFunc) safcm.MsgSyncResp {
+
+       s := &Sync{
+               req: req,
+               log: log.NewLogger(logPrefix, fun),
+       }
+       s.cmd = run.NewCmd(runner, s.log)
+
+       err := s.setDefaults()
+       if err != nil {
+               s.resp.Error = fmt.Sprintf("%s %s", logPrefix, err)
+               return s.resp
+       }
+
+       err = s.syncFiles()
+       if err != nil {
+               s.resp.Error = fmt.Sprintf("%s files: %s", logPrefix, err)
+               return s.resp
+       }
+       err = s.syncPackages()
+       if err != nil {
+               s.resp.Error = fmt.Sprintf("%s packages: %s", logPrefix, err)
+               return s.resp
+       }
+       err = s.syncServices()
+       if err != nil {
+               s.resp.Error = fmt.Sprintf("%s services: %s", logPrefix, err)
+               return s.resp
+       }
+       err = s.syncCommands()
+       if err != nil {
+               s.resp.Error = fmt.Sprintf("%s commands: %s", logPrefix, err)
+               return s.resp
+       }
+       return s.resp
+}
+
+func (s *Sync) setDefaults() error {
+       u, err := user.Current()
+       if err != nil {
+               return err
+       }
+       s.defaultUser = u.Username
+       g, err := user.LookupGroupId(u.Gid)
+       if err != nil {
+               return err
+       }
+       s.defaultGroup = g.Name
+
+       s.triggersActive = make(map[string]bool)
+
+       return nil
+}
diff --git a/cmd/safcm-remote/sync/sync_test.go b/cmd/safcm-remote/sync/sync_test.go
new file mode 100644 (file)
index 0000000..44e2be2
--- /dev/null
@@ -0,0 +1,160 @@
+// Copyright (C) 2021  Simon Ruderich
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+package sync
+
+import (
+       "bytes"
+       "fmt"
+       "os/exec"
+       "reflect"
+       "sync"
+       "testing"
+
+       "github.com/google/go-cmp/cmp"
+       "github.com/google/go-cmp/cmp/cmpopts"
+
+       "ruderich.org/simon/safcm"
+       "ruderich.org/simon/safcm/cmd/safcm-remote/log"
+       "ruderich.org/simon/safcm/cmd/safcm-remote/run"
+)
+
+type testRunner struct {
+       t         *testing.T
+       name      string
+       expCmds   []*exec.Cmd
+       resStdout [][]byte
+       resStderr [][]byte
+       resError  []error
+}
+
+func (r *testRunner) Run(cmd *exec.Cmd) error {
+       stdout, stderr, resErr := r.check("run", cmd)
+       _, err := cmd.Stdout.Write(stdout)
+       if err != nil {
+               panic(err)
+       }
+       _, err = cmd.Stderr.Write(stderr)
+       if err != nil {
+               panic(err)
+       }
+       return resErr
+}
+func (r *testRunner) CombinedOutput(cmd *exec.Cmd) ([]byte, error) {
+       stdout, stderr, err := r.check("combinedOutput", cmd)
+       if stderr != nil {
+               // stdout also contains stderr
+               r.t.Fatalf("%s: CombinedOutput: stderr != nil, but %v",
+                       r.name, stderr)
+       }
+       return stdout, err
+}
+func (r *testRunner) check(method string, cmd *exec.Cmd) (
+       []byte, []byte, error) {
+
+       if len(r.expCmds) == 0 {
+               r.t.Fatalf("%s: %s: empty expCmds", r.name, method)
+       }
+       if len(r.resStdout) == 0 {
+               r.t.Fatalf("%s: %s: empty resStdout", r.name, method)
+       }
+       if len(r.resStderr) == 0 {
+               r.t.Fatalf("%s: %s: empty resStderr", r.name, method)
+       }
+       if len(r.resError) == 0 {
+               r.t.Fatalf("%s: %s: empty resError", r.name, method)
+       }
+
+       exp := r.expCmds[0]
+       r.expCmds = r.expCmds[1:]
+       if !reflect.DeepEqual(exp, cmd) {
+               r.t.Errorf("%s: %s: %s", r.name, method,
+                       cmp.Diff(exp, cmd, cmpopts.IgnoreUnexported(
+                               exec.Cmd{},
+                               bytes.Buffer{})))
+       }
+
+       var stdout, stderr []byte
+       var err error
+
+       stdout, r.resStdout = r.resStdout[0], r.resStdout[1:]
+       stderr, r.resStderr = r.resStderr[0], r.resStderr[1:]
+       err, r.resError = r.resError[0], r.resError[1:]
+
+       return stdout, stderr, err
+}
+
+type syncTestResult struct {
+       ch     chan string
+       wg     sync.WaitGroup
+       dbg    []string
+       runner *testRunner
+}
+
+func prepareSync(req safcm.MsgSyncReq, runner *testRunner) (
+       *Sync, *syncTestResult) {
+
+       res := &syncTestResult{
+               ch:     make(chan string),
+               runner: runner,
+       }
+       res.wg.Add(1)
+       go func() {
+               for {
+                       x, ok := <-res.ch
+                       if !ok {
+                               break
+                       }
+                       res.dbg = append(res.dbg, x)
+               }
+               res.wg.Done()
+       }()
+
+       logger := log.NewLogger(logPrefix,
+               func(level safcm.LogLevel, format string, a ...interface{}) {
+                       res.ch <- fmt.Sprintf("%d: %s", level,
+                               fmt.Sprintf(format, a...))
+               })
+       return &Sync{
+               req: req,
+               cmd: run.NewCmd(runner, logger),
+               log: logger,
+       }, res
+}
+
+func (s *syncTestResult) Wait() []string {
+       close(s.ch)
+       s.wg.Wait()
+
+       // All expected commands must have been executed
+       if len(s.runner.expCmds) != 0 {
+               s.runner.t.Errorf("%s: expCmds left: %v",
+                       s.runner.name, s.runner.expCmds)
+       }
+       if len(s.runner.resStdout) != 0 {
+               s.runner.t.Errorf("%s: resStdout left: %v",
+                       s.runner.name, s.runner.resStdout)
+       }
+       if len(s.runner.resStderr) != 0 {
+               s.runner.t.Errorf("%s: resStderr left: %v",
+                       s.runner.name, s.runner.resStderr)
+       }
+       if len(s.runner.resError) != 0 {
+               s.runner.t.Errorf("%s: resError left: %v",
+                       s.runner.name, s.runner.resError)
+       }
+
+       return s.dbg
+}
diff --git a/cmd/safcm-remote/sync/triggers.go b/cmd/safcm-remote/sync/triggers.go
new file mode 100644 (file)
index 0000000..34aae7d
--- /dev/null
@@ -0,0 +1,73 @@
+// MsgSyncReq: run triggers for changed files
+
+// Copyright (C) 2021  Simon Ruderich
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+package sync
+
+import (
+       "path/filepath"
+       "strings"
+
+       "ruderich.org/simon/safcm"
+)
+
+// queueTriggers queues all triggers applying to file.
+func (s *Sync) queueTriggers(file *safcm.File) {
+       for _, path := range triggerPaths(file.Path) {
+               if s.req.Files[path].TriggerCommands == nil {
+                       continue
+               }
+               // Queue each trigger only once
+               if s.triggersActive[path] {
+                       s.log.Debugf(
+                               "files: %q: skipping trigger on %q, already active",
+                               file.Path, path)
+                       continue
+               }
+
+               s.log.Verbosef("files: %q: queuing trigger on %q",
+                       file.Path, path)
+               s.triggers = append(s.triggers, path)
+               s.triggersActive[path] = true
+       }
+}
+
+// triggerPaths returns all possible trigger paths for path, that is the path
+// itself and all parent paths. The paths are returned in reverse order so
+// more specific triggers can override effects of less specific ones (first
+// "/" or ".", then the parents and finally path itself).
+func triggerPaths(path string) []string {
+       sep := string(filepath.Separator)
+       if path == sep {
+               return []string{path}
+       } else if path == "." {
+               return []string{path}
+       }
+       parts := strings.Split(path, sep)
+       if strings.HasPrefix(path, sep) {
+               // Absolute path
+               parts[0] = sep
+       } else {
+               // Relative path
+               parts = append([]string{"."}, parts...)
+       }
+
+       var res []string
+       for i := 0; i < len(parts); i++ {
+               res = append(res, filepath.Join(parts[:i+1]...))
+       }
+       return res
+}
diff --git a/cmd/safcm-remote/sync/triggers_test.go b/cmd/safcm-remote/sync/triggers_test.go
new file mode 100644 (file)
index 0000000..27a0d02
--- /dev/null
@@ -0,0 +1,64 @@
+// Copyright (C) 2021  Simon Ruderich
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+package sync
+
+import (
+       "reflect"
+       "testing"
+
+       "github.com/google/go-cmp/cmp"
+)
+
+func TestTriggerPaths(t *testing.T) {
+       tests := []struct {
+               name string
+               path string
+               exp  []string
+       }{
+
+               {
+                       "root",
+                       "/",
+                       []string{"/"},
+               },
+
+               {
+                       "absolute path",
+                       "/foo/bar/baz",
+                       []string{"/", "/foo", "/foo/bar", "/foo/bar/baz"},
+               },
+
+               {
+                       "dot",
+                       ".",
+                       []string{"."},
+               },
+
+               {
+                       "relative path (for tests)",
+                       "foo/bar/baz",
+                       []string{".", "foo", "foo/bar", "foo/bar/baz"},
+               },
+       }
+
+       for _, tc := range tests {
+               res := triggerPaths(tc.path)
+               if !reflect.DeepEqual(tc.exp, res) {
+                       t.Errorf("%s: res: %s", tc.name,
+                               cmp.Diff(tc.exp, res))
+               }
+       }
+}
diff --git a/cmd/safcm/config/commands.go b/cmd/safcm/config/commands.go
new file mode 100644 (file)
index 0000000..8a6f240
--- /dev/null
@@ -0,0 +1,44 @@
+// Config: parse commands.yaml
+
+// Copyright (C) 2021  Simon Ruderich
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+package config
+
+import (
+       "fmt"
+       "os"
+       "path/filepath"
+
+       "gopkg.in/yaml.v2"
+)
+
+func LoadCommands(group string) ([]string, error) {
+       path := filepath.Join(group, "commands.yaml")
+
+       var res []string
+       x, err := os.ReadFile(path)
+       if err != nil {
+               if os.IsNotExist(err) {
+                       return nil, nil
+               }
+               return nil, err
+       }
+       err = yaml.UnmarshalStrict(x, &res)
+       if err != nil {
+               return nil, fmt.Errorf("%s: failed to load: %v", path, err)
+       }
+       return res, nil
+}
diff --git a/cmd/safcm/config/config.go b/cmd/safcm/config/config.go
new file mode 100644 (file)
index 0000000..6dae4a1
--- /dev/null
@@ -0,0 +1,54 @@
+// Config: parse config.yaml
+
+// Copyright (C) 2021  Simon Ruderich
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+package config
+
+import (
+       "fmt"
+       "os"
+
+       "gopkg.in/yaml.v2"
+
+       "ruderich.org/simon/safcm"
+)
+
+type Config struct {
+       DryRun   bool           `yaml:"-"` // set via command line
+       LogLevel safcm.LogLevel `yaml:"-"` // set via command line
+
+       DetectGroups []string `yaml:"detect_groups"`
+       GroupOrder   []string `yaml:"group_order"`
+}
+
+func LoadConfig() (*Config, error) {
+       const path = "config.yaml"
+
+       var cfg Config
+       x, err := os.ReadFile(path)
+       if err != nil {
+               // This file is optional
+               if os.IsNotExist(err) {
+                       return &cfg, nil
+               }
+               return nil, err
+       }
+       err = yaml.UnmarshalStrict(x, &cfg)
+       if err != nil {
+               return nil, fmt.Errorf("%s: failed to load: %v", path, err)
+       }
+       return &cfg, nil
+}
diff --git a/cmd/safcm/config/files.go b/cmd/safcm/config/files.go
new file mode 100644 (file)
index 0000000..08b2dbf
--- /dev/null
@@ -0,0 +1,108 @@
+// Config: load files/ directory tree
+
+// Copyright (C) 2021  Simon Ruderich
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+package config
+
+import (
+       "fmt"
+       "io/fs"
+       "os"
+       "path/filepath"
+
+       "ruderich.org/simon/safcm"
+)
+
+func LoadFiles(group string) (map[string]*safcm.File, error) {
+       basePath := filepath.Join(group, "files")
+
+       const errMsg = `
+The actual permissions and user/group of files and directories are not used
+(except for +x on files). 0644/0755 and current remote user/group is used per
+default. Apply different file permissions via permissions.yaml. To prevent
+confusion files must be manually chmodded 0644/0755 and directories 0755 or
+via "safcm fixperms".
+`
+
+       files := make(map[string]*safcm.File)
+       err := filepath.WalkDir(basePath, func(path string, d fs.DirEntry,
+               err error) error {
+
+               if err != nil {
+                       return err
+               }
+
+               info, err := d.Info()
+               if err != nil {
+                       return err
+               }
+               typ := info.Mode().Type()
+               perm := FileModeToFullPerm(info.Mode())
+
+               var data []byte
+               // See errMsg above. If a user stores a file with stricter
+               // permissions they could assume that these permissions are
+               // respected. This is not the case.
+               if typ == 0 /* regular file */ {
+                       if perm != 0644 && perm != 0755 {
+                               return fmt.Errorf(
+                                       "%q: invalid permissions %#o%s",
+                                       path, perm, errMsg)
+                       }
+                       data, err = os.ReadFile(path)
+                       if err != nil {
+                               return err
+                       }
+               } else if typ == fs.ModeDir {
+                       if perm != 0755 {
+                               return fmt.Errorf(
+                                       "%q: invalid permissions %#o%s",
+                                       path, perm, errMsg)
+                       }
+               } else if typ == fs.ModeSymlink {
+                       x, err := os.Readlink(path)
+                       if err != nil {
+                               return err
+                       }
+                       data = []byte(x)
+               } else {
+                       return fmt.Errorf("%q: file type not supported", path)
+               }
+
+               // Convert to absolute path as used on the target host
+               x, err := filepath.Rel(basePath, path)
+               if err != nil {
+                       return err
+               }
+               x = filepath.Join("/", x)
+
+               files[x] = &safcm.File{
+                       Path: x,
+                       Mode: typ | (fs.FileMode(perm) & fs.ModePerm),
+                       Uid:  -1,
+                       Gid:  -1,
+                       Data: data,
+               }
+               return nil
+       })
+       if err != nil {
+               if os.IsNotExist(err) {
+                       return nil, nil
+               }
+               return nil, fmt.Errorf("%s: %v", group, err)
+       }
+       return files, nil
+}
diff --git a/cmd/safcm/config/files_test.go b/cmd/safcm/config/files_test.go
new file mode 100644 (file)
index 0000000..398394c
--- /dev/null
@@ -0,0 +1,203 @@
+// Copyright (C) 2021  Simon Ruderich
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+package config
+
+import (
+       "fmt"
+       "io/fs"
+       "os"
+       "reflect"
+       "syscall"
+       "testing"
+
+       "github.com/google/go-cmp/cmp"
+
+       "ruderich.org/simon/safcm"
+)
+
+func chmod(name string, perm int) {
+       err := os.Chmod(name, FullPermToFileMode(perm))
+       if err != nil {
+               panic(err)
+       }
+}
+
+func TestLoadFiles(t *testing.T) {
+       cwd, err := os.Getwd()
+       if err != nil {
+               t.Fatal(err)
+       }
+       defer os.Chdir(cwd)
+
+       err = os.Chdir("../testdata/project")
+       if err != nil {
+               t.Fatal(err)
+       }
+
+       chmod("files-invalid-perm-dir/files", 0500)
+       defer chmod("files-invalid-perm-dir/files", 0700)
+       chmod("files-invalid-perm-dir/files/etc/", 0755)
+       chmod("files-invalid-perm-dir/files/etc/resolv.conf", 0644)
+       chmod("files-invalid-perm-dir-setgid/files", 0755)
+       chmod("files-invalid-perm-dir-setgid/files/etc/", 02755)
+       chmod("files-invalid-perm-dir-setgid/files/etc/resolv.conf", 0644)
+       chmod("files-invalid-perm-file/files", 0755)
+       chmod("files-invalid-perm-file/files/etc/", 0755)
+       chmod("files-invalid-perm-file/files/etc/resolv.conf", 0600)
+       chmod("files-invalid-perm-file-executable/files", 0755)
+       chmod("files-invalid-perm-file-executable/files/etc", 0755)
+       chmod("files-invalid-perm-file-executable/files/etc/rc.local", 0750)
+       chmod("files-invalid-perm-file-sticky/files", 0755)
+       chmod("files-invalid-perm-file-sticky/files/etc", 0755)
+       chmod("files-invalid-perm-file-sticky/files/etc/resolv.conf", 01644)
+
+       err = syscall.Mkfifo("files-invalid-type/files/invalid", 0644)
+       if err != nil {
+               t.Fatal(err)
+       }
+       defer os.Remove("files-invalid-type/files/invalid")
+
+       const errMsg = `
+The actual permissions and user/group of files and directories are not used
+(except for +x on files). 0644/0755 and current remote user/group is used per
+default. Apply different file permissions via permissions.yaml. To prevent
+confusion files must be manually chmodded 0644/0755 and directories 0755 or
+via "safcm fixperms".
+`
+
+       tests := []struct {
+               group  string
+               exp    map[string]*safcm.File
+               expErr error
+       }{
+
+               {
+                       "empty",
+                       nil,
+                       nil,
+               },
+
+               {
+                       "group",
+                       map[string]*safcm.File{
+                               "/": {
+                                       Path: "/",
+                                       Mode: fs.ModeDir | 0755,
+                                       Uid:  -1,
+                                       Gid:  -1,
+                               },
+                               "/etc": {
+                                       Path: "/etc",
+                                       Mode: fs.ModeDir | 0755,
+                                       Uid:  -1,
+                                       Gid:  -1,
+                               },
+                               "/etc/.hidden": {
+                                       Path: "/etc/.hidden",
+                                       Mode: 0644,
+                                       Uid:  -1,
+                                       Gid:  -1,
+                                       Data: []byte("..."),
+                               },
+                               "/etc/motd": {
+                                       Path: "/etc/motd",
+                                       Mode: 0644,
+                                       Uid:  -1,
+                                       Gid:  -1,
+                                       Data: []byte(`Welcome to
+{{- if .IsHost "host1.example.org"}} Host ONE
+{{- else if "host2"}} Host TWO
+{{- end}}
+
+{{if .InGroup "detected_linux"}}
+This is GNU/Linux host
+{{end}}
+{{if .InGroup "detected_freebsd"}}
+This is FreeBSD host
+{{end}}
+`),
+                               },
+                               "/etc/rc.local": {
+                                       Path: "/etc/rc.local",
+                                       Mode: 0755,
+                                       Uid:  -1,
+                                       Gid:  -1,
+                                       Data: []byte("#!/bin/sh\n"),
+                               },
+                               "/etc/resolv.conf": {
+                                       Path: "/etc/resolv.conf",
+                                       Mode: 0644,
+                                       Uid:  -1,
+                                       Gid:  -1,
+                                       Data: []byte("nameserver ::1\n"),
+                               },
+                               "/etc/test": {
+                                       Path: "/etc/test",
+                                       Mode: fs.ModeSymlink | 0777,
+                                       Uid:  -1,
+                                       Gid:  -1,
+                                       Data: []byte("doesnt-exist"),
+                               },
+                       },
+                       nil,
+               },
+
+               {
+                       "files-invalid-type",
+                       nil,
+                       fmt.Errorf("files-invalid-type: \"files-invalid-type/files/invalid\": file type not supported"),
+               },
+               {
+                       "files-invalid-perm-dir",
+                       nil,
+                       fmt.Errorf("files-invalid-perm-dir: \"files-invalid-perm-dir/files\": invalid permissions 0500" + errMsg),
+               },
+               {
+                       "files-invalid-perm-dir-setgid",
+                       nil,
+                       fmt.Errorf("files-invalid-perm-dir-setgid: \"files-invalid-perm-dir-setgid/files/etc\": invalid permissions 02755" + errMsg),
+               },
+               {
+                       "files-invalid-perm-file",
+                       nil,
+                       fmt.Errorf("files-invalid-perm-file: \"files-invalid-perm-file/files/etc/resolv.conf\": invalid permissions 0600" + errMsg),
+               },
+               {
+                       "files-invalid-perm-file-executable",
+                       nil,
+                       fmt.Errorf("files-invalid-perm-file-executable: \"files-invalid-perm-file-executable/files/etc/rc.local\": invalid permissions 0750" + errMsg),
+               },
+               {
+                       "files-invalid-perm-file-sticky",
+                       nil,
+                       fmt.Errorf("files-invalid-perm-file-sticky: \"files-invalid-perm-file-sticky/files/etc/resolv.conf\": invalid permissions 01644" + errMsg),
+               },
+       }
+
+       for _, tc := range tests {
+               res, err := LoadFiles(tc.group)
+
+               if !reflect.DeepEqual(tc.exp, res) {
+                       t.Errorf("%s: res: %s", tc.group,
+                               cmp.Diff(tc.exp, res))
+               }
+               // Ugly but the simplest way to compare errors (including nil)
+               if fmt.Sprintf("%s", err) != fmt.Sprintf("%s", tc.expErr) {
+                       t.Errorf("%s: err = %#v, want %#v",
+                               tc.group, err, tc.expErr)
+               }
+       }
+}
diff --git a/cmd/safcm/config/groups.go b/cmd/safcm/config/groups.go
new file mode 100644 (file)
index 0000000..7f7cb3f
--- /dev/null
@@ -0,0 +1,188 @@
+// Config: parse groups.yaml
+
+// Copyright (C) 2021  Simon Ruderich
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+package config
+
+import (
+       "fmt"
+       "os"
+       "regexp"
+       "sort"
+       "strings"
+
+       "gopkg.in/yaml.v2"
+)
+
+const (
+       GroupAll              = "all"
+       GroupDetectedPrefix   = "detected"
+       GroupSpecialSeparator = ":"
+       GroupRemoveSuffix     = GroupSpecialSeparator + "remove"
+)
+
+// Keep in sync with sync_info.go:infoGroupDetectedRegexp
+var groupNameRegexp = regexp.MustCompile(`^[a-z0-9_-]+$`)
+
+func LoadGroups(cfg *Config, hosts *Hosts) (map[string][]string, error) {
+       const path = "groups.yaml"
+
+       var groups map[string][]string
+       x, err := os.ReadFile(path)
+       if err != nil {
+               return nil, err
+       }
+       err = yaml.UnmarshalStrict(x, &groups)
+       if err != nil {
+               return nil, fmt.Errorf("%s: failed to load: %v", path, err)
+       }
+
+       // Sanity checks; cannot expand groups yet because detected groups are
+       // only known after connecting to the host
+       for name, members := range groups {
+               errPrefix := fmt.Sprintf("%s: group %q:", path, name)
+
+               if name == GroupAll || name == GroupAll+GroupRemoveSuffix {
+                       return nil, fmt.Errorf(
+                               "%s conflict with pre-defined group %q",
+                               errPrefix, name)
+               }
+               if hosts.Map[name] != nil {
+                       return nil, fmt.Errorf(
+                               "%s conflict with existing host",
+                               errPrefix)
+               }
+
+               if strings.HasPrefix(name, GroupDetectedPrefix) {
+                       return nil, fmt.Errorf(
+                               "%s name must not start with %q "+
+                                       "(reserved for detected groups)",
+                               errPrefix, GroupDetectedPrefix)
+               }
+               if !groupNameRegexp.MatchString(
+                       strings.TrimSuffix(name, GroupRemoveSuffix)) {
+                       return nil, fmt.Errorf(
+                               "%s name contains invalid characters "+
+                                       "(must match %s)",
+                               errPrefix, groupNameRegexp)
+               }
+
+               for _, x := range members {
+                       if x == GroupAll {
+                               continue
+                       }
+                       if strings.Contains(x, GroupSpecialSeparator) {
+                               return nil, fmt.Errorf(
+                                       "%s member %q must not contain %q",
+                                       errPrefix, x, GroupSpecialSeparator)
+                       }
+                       if strings.HasPrefix(x, GroupDetectedPrefix) {
+                               continue
+                       }
+                       if hosts.Map[x] != nil || groups[x] != nil {
+                               continue
+                       }
+                       return nil, fmt.Errorf("%s group %q not found",
+                               errPrefix, x)
+               }
+       }
+
+       // Sanity check for global configuration
+       for _, x := range cfg.GroupOrder {
+               const errPrefix = "config.yaml: group_order:"
+
+               if x == GroupAll {
+                       continue
+               }
+               if strings.Contains(x, GroupSpecialSeparator) {
+                       return nil, fmt.Errorf("%s invalid group name %q",
+                               errPrefix, x)
+               }
+               if strings.HasPrefix(x, GroupDetectedPrefix) {
+                       continue
+               }
+               if groups[x] != nil {
+                       continue
+               }
+               return nil, fmt.Errorf("%s group %q does not exist",
+                       errPrefix, x)
+       }
+
+       return groups, nil
+}
+
+func ResolveHostGroups(host string,
+       groups map[string][]string,
+       detectedGroups []string) ([]string, error) {
+
+       const maxDepth = 100
+
+       detectedGroupsMap := make(map[string]bool)
+       for _, x := range detectedGroups {
+               detectedGroupsMap[x] = true
+       }
+
+       var cycle *string
+       // Recursively check if host belongs to this group (or any referenced
+       // groups).
+       var lookup func(string, int) bool
+       lookup = func(group string, depth int) bool {
+               if depth > maxDepth {
+                       cycle = &group
+                       return false
+               }
+               for _, x := range groups[group] {
+                       if x == host || detectedGroupsMap[x] || x == GroupAll {
+                               return true
+                       }
+                       if lookup(x, depth+1) &&
+                               !lookup(x+GroupRemoveSuffix, depth+1) {
+                               return true
+                       }
+               }
+               return false
+       }
+
+       // Deterministic iteration order for error messages and tests
+       var names []string
+       for x := range groups {
+               names = append(names, x)
+       }
+       sort.Strings(names)
+
+       var res []string
+       for _, x := range names {
+               if strings.HasSuffix(x, GroupRemoveSuffix) {
+                       continue
+               }
+
+               if lookup(x, 0) && !lookup(x+GroupRemoveSuffix, 0) {
+                       res = append(res, x)
+               }
+       }
+       if cycle != nil {
+               return nil, fmt.Errorf(
+                       "groups.yaml: cycle while expanding group %q",
+                       *cycle)
+       }
+
+       res = append(res, detectedGroups...)
+       res = append(res, GroupAll) // contains all hosts
+       res = append(res, host)     // host itself is also group
+
+       sort.Strings(res)
+       return res, nil
+}
diff --git a/cmd/safcm/config/groups_test.go b/cmd/safcm/config/groups_test.go
new file mode 100644 (file)
index 0000000..ccf0968
--- /dev/null
@@ -0,0 +1,329 @@
+// Copyright (C) 2021  Simon Ruderich
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+package config
+
+import (
+       "fmt"
+       "os"
+       "reflect"
+       "testing"
+
+       "github.com/google/go-cmp/cmp"
+)
+
+func TestLoadGroups(t *testing.T) {
+       cwd, err := os.Getwd()
+       if err != nil {
+               t.Fatal(err)
+       }
+       defer os.Chdir(cwd)
+
+       err = os.Chdir("../testdata/project")
+       if err != nil {
+               t.Fatal(err)
+       }
+       hosts, err := LoadHosts()
+       if err != nil {
+               t.Fatal(err)
+       }
+       err = os.Chdir(cwd)
+       if err != nil {
+               t.Fatal(err)
+       }
+
+       tests := []struct {
+               path   string
+               cfg    *Config
+               hosts  *Hosts
+               exp    map[string][]string
+               expErr error
+       }{
+
+               {
+                       "../testdata/project",
+                       &Config{
+                               GroupOrder: []string{
+                                       "detected_linux",
+                                       "detected_freebsd",
+                               },
+                       },
+                       hosts,
+                       map[string][]string{
+                               "group": {
+                                       "detected_linux",
+                                       "detected_freebsd",
+                                       "host1.example.org",
+                               },
+                               "group:remove": {
+                                       "host2",
+                                       "detected_mips",
+                               },
+                               "group2": {
+                                       "all",
+                               },
+                               "group2:remove": {
+                                       "remove",
+                               },
+                               "all_except_some": {
+                                       "all",
+                               },
+                               "all_except_some:remove": {
+                                       "host1.example.org",
+                                       "group2",
+                               },
+                               "remove": {
+                                       "host1.example.org",
+                                       "host2",
+                                       "host3.example.net",
+                               },
+                               "remove:remove": {
+                                       "host2",
+                               },
+                       },
+                       nil,
+               },
+
+               {
+                       "../testdata/project",
+                       &Config{
+                               GroupOrder: []string{
+                                       "detected_freebsd",
+                                       "does-not-exist",
+                               },
+                       },
+                       hosts,
+                       nil,
+                       fmt.Errorf("config.yaml: group_order: group \"does-not-exist\" does not exist"),
+               },
+               {
+                       "../testdata/project",
+                       &Config{
+                               GroupOrder: []string{
+                                       "detected_freebsd",
+                                       "special:group",
+                               },
+                       },
+                       hosts,
+                       nil,
+                       fmt.Errorf("config.yaml: group_order: invalid group name \"special:group\""),
+               },
+               {
+                       "../testdata/project",
+                       &Config{
+                               GroupOrder: []string{
+                                       "detected_freebsd",
+                                       "group:remove",
+                               },
+                       },
+                       hosts,
+                       nil,
+                       fmt.Errorf("config.yaml: group_order: invalid group name \"group:remove\""),
+               },
+
+               {
+                       "../testdata/group-invalid-all",
+                       &Config{},
+                       hosts,
+                       nil,
+                       fmt.Errorf("groups.yaml: group \"all\": conflict with pre-defined group \"all\""),
+               },
+               {
+                       "../testdata/group-invalid-all-remove",
+                       &Config{},
+                       hosts,
+                       nil,
+                       fmt.Errorf("groups.yaml: group \"all:remove\": conflict with pre-defined group \"all:remove\""),
+               },
+               {
+                       "../testdata/group-invalid-conflict",
+                       &Config{},
+                       hosts,
+                       nil,
+                       fmt.Errorf("groups.yaml: group \"host2\": conflict with existing host"),
+               },
+               {
+                       "../testdata/group-invalid-detected",
+                       &Config{},
+                       &Hosts{},
+                       nil,
+                       fmt.Errorf("groups.yaml: group \"detected_linux\": name must not start with \"detected\" (reserved for detected groups)"),
+               },
+               {
+                       "../testdata/group-invalid-member",
+                       &Config{},
+                       &Hosts{},
+                       nil,
+                       fmt.Errorf("groups.yaml: group \"group1\": member \"special:member\" must not contain \":\""),
+               },
+               {
+                       "../testdata/group-invalid-missing",
+                       &Config{},
+                       &Hosts{},
+                       nil,
+                       fmt.Errorf("groups.yaml: group \"1group2\": group \"does-not-exist\" not found"),
+               },
+               {
+                       "../testdata/group-invalid-name",
+                       &Config{},
+                       &Hosts{},
+                       nil,
+                       fmt.Errorf("groups.yaml: group \"invalid.group.name\": name contains invalid characters (must match ^[a-z0-9_-]+$)"),
+               },
+       }
+
+       for _, tc := range tests {
+               err := os.Chdir(tc.path)
+               if err != nil {
+                       t.Fatal(err)
+               }
+
+               res, err := LoadGroups(tc.cfg, tc.hosts)
+
+               if !reflect.DeepEqual(tc.exp, res) {
+                       t.Errorf("%s: res: %s", tc.path,
+                               cmp.Diff(tc.exp, res))
+               }
+               // Ugly but the simplest way to compare errors (including nil)
+               if fmt.Sprintf("%s", err) != fmt.Sprintf("%s", tc.expErr) {
+                       t.Errorf("%s: err = %#v, want %#v",
+                               tc.path, err, tc.expErr)
+               }
+
+               err = os.Chdir(cwd)
+               if err != nil {
+                       t.Fatal(err)
+               }
+       }
+}
+
+func TestResolveHostGroups(t *testing.T) {
+       cwd, err := os.Getwd()
+       if err != nil {
+               t.Fatal(err)
+       }
+       defer os.Chdir(cwd)
+
+       err = os.Chdir("../testdata/project")
+       if err != nil {
+               t.Fatal(err)
+       }
+       allHosts, err := LoadHosts()
+       if err != nil {
+               t.Fatal(err)
+       }
+       allGroups, err := LoadGroups(&Config{}, allHosts)
+       if err != nil {
+               t.Fatal(err)
+       }
+
+       tests := []struct {
+               name     string
+               host     string
+               detected []string
+               exp      []string
+               expErr   error
+       }{
+
+               {
+                       "host1",
+                       "host1.example.org",
+                       nil,
+                       []string{
+                               "all",
+                               "group",
+                               "host1.example.org",
+                               "remove",
+                       },
+                       nil,
+               },
+               {
+                       "host2",
+                       "host2",
+                       nil,
+                       []string{
+                               "all",
+                               "group2",
+                               "host2",
+                       },
+                       nil,
+               },
+               {
+                       "host3",
+                       "host3.example.net",
+                       nil,
+                       []string{
+                               "all",
+                               "all_except_some",
+                               "host3.example.net",
+                               "remove",
+                       },
+                       nil,
+               },
+               {
+                       "unknown host",
+                       "unknown",
+                       nil,
+                       []string{
+                               "all",
+                               "group2",
+                               "unknown",
+                       },
+                       nil,
+               },
+
+               {
+                       "host1, detected_mips",
+                       "host1.example.org",
+                       []string{
+                               "detected_mips",
+                       },
+                       []string{
+                               "all",
+                               "detected_mips",
+                               "host1.example.org",
+                               "remove",
+                       },
+                       nil,
+               },
+               {
+                       "host2, detected_linux",
+                       "host2",
+                       []string{
+                               "detected_linux",
+                       },
+                       []string{
+                               "all",
+                               "detected_linux",
+                               "group2",
+                               "host2",
+                       },
+                       nil,
+               },
+       }
+
+       for _, tc := range tests {
+               res, err := ResolveHostGroups(tc.host, allGroups, tc.detected)
+               if !reflect.DeepEqual(tc.exp, res) {
+                       t.Errorf("%s: res: %s", tc.name,
+                               cmp.Diff(tc.exp, res))
+               }
+               // Ugly but the simplest way to compare errors (including nil)
+               if fmt.Sprintf("%s", err) != fmt.Sprintf("%s", tc.expErr) {
+                       t.Errorf("%s: err = %#v, want %#v",
+                               tc.name, err, tc.expErr)
+               }
+       }
+}
diff --git a/cmd/safcm/config/hosts.go b/cmd/safcm/config/hosts.go
new file mode 100644 (file)
index 0000000..cf750cb
--- /dev/null
@@ -0,0 +1,58 @@
+// Config: parse hosts.yaml
+
+// Copyright (C) 2021  Simon Ruderich
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+package config
+
+import (
+       "fmt"
+       "os"
+
+       "gopkg.in/yaml.v2"
+)
+
+type Hosts struct {
+       List []*Host
+       Map  map[string]*Host
+}
+
+type Host struct {
+       Name string `yaml:"name"`
+}
+
+func LoadHosts() (*Hosts, error) {
+       const path = "hosts.yaml"
+
+       var hostList []*Host
+       x, err := os.ReadFile(path)
+       if err != nil {
+               return nil, err
+       }
+       err = yaml.UnmarshalStrict(x, &hostList)
+       if err != nil {
+               return nil, fmt.Errorf("%s: failed to load: %v", path, err)
+       }
+
+       hostMap := make(map[string]*Host)
+       for _, x := range hostList {
+               hostMap[x.Name] = x
+       }
+
+       return &Hosts{
+               List: hostList,
+               Map:  hostMap,
+       }, nil
+}
diff --git a/cmd/safcm/config/packages.go b/cmd/safcm/config/packages.go
new file mode 100644 (file)
index 0000000..60ddc87
--- /dev/null
@@ -0,0 +1,55 @@
+// Config: parse packages.yaml
+
+// Copyright (C) 2021  Simon Ruderich
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+package config
+
+import (
+       "fmt"
+       "os"
+       "path/filepath"
+       "strings"
+
+       "gopkg.in/yaml.v2"
+)
+
+func LoadPackages(group string) ([]string, error) {
+       path := filepath.Join(group, "packages.yaml")
+
+       var res []string
+       x, err := os.ReadFile(path)
+       if err != nil {
+               if os.IsNotExist(err) {
+                       return nil, nil
+               }
+               return nil, err
+       }
+       err = yaml.UnmarshalStrict(x, &res)
+       if err != nil {
+               return nil, fmt.Errorf("%s: failed to load: %v", path, err)
+       }
+
+       // Sanity checks
+       for _, x := range res {
+               if len(strings.Fields(x)) != 1 {
+                       return nil, fmt.Errorf(
+                               "%s: package name %q must not contain whitespace",
+                               path, x)
+               }
+       }
+
+       return res, nil
+}
diff --git a/cmd/safcm/config/permissions.go b/cmd/safcm/config/permissions.go
new file mode 100644 (file)
index 0000000..b84b521
--- /dev/null
@@ -0,0 +1,118 @@
+// Config: parse permissions.yaml
+
+// Copyright (C) 2021  Simon Ruderich
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+package config
+
+import (
+       "fmt"
+       "io/fs"
+       "os"
+       "path/filepath"
+       "strconv"
+       "strings"
+
+       "gopkg.in/yaml.v2"
+
+       "ruderich.org/simon/safcm"
+)
+
+func LoadPermissions(group string, files map[string]*safcm.File) error {
+       path := filepath.Join(group, "permissions.yaml")
+
+       var cfg map[string]string
+       x, err := os.ReadFile(path)
+       if err != nil {
+               if os.IsNotExist(err) {
+                       return nil
+               }
+               return err
+       }
+       err = yaml.UnmarshalStrict(x, &cfg)
+       if err != nil {
+               return fmt.Errorf("%s: failed to load: %v", path, err)
+       }
+
+       for p, x := range cfg {
+               _, ok := files[p]
+               if !ok {
+                       return fmt.Errorf("%s: %q does not exist in files/",
+                               path, p)
+               }
+
+               xs := strings.Fields(x)
+               if len(xs) != 1 && len(xs) != 3 {
+                       return fmt.Errorf("%s: invalid line %q "+
+                               "(expected <perm> [<user> <group>])",
+                               path, x)
+               }
+               perm, err := strconv.ParseInt(xs[0], 8, 32)
+               if err != nil {
+                       return fmt.Errorf("%s: invalid permission %q "+
+                               "(expected e.g. %q or %q)",
+                               path, xs[0], "0644", "01777")
+               }
+               if perm > 07777 {
+                       return fmt.Errorf("%s: invalid permission %#o "+
+                               "(expected e.g. %#o or %#o)",
+                               path, perm, 0644, 01777)
+               }
+
+               file := files[p]
+               // Sanity check
+               if file.Mode.Perm()&0111 != 0 && perm&0111 == 0 {
+                       return fmt.Errorf(
+                               "%s: %q: trying to remove +x from file, "+
+                                       "manually chmod -x in files/",
+                               path, p)
+               }
+               file.Mode = file.Mode.Type() | FullPermToFileMode(int(perm))
+               if len(xs) == 3 {
+                       file.User = xs[1]
+                       file.Group = xs[2]
+               }
+       }
+
+       return nil
+}
+
+func FileModeToFullPerm(mode fs.FileMode) int {
+       perm := mode.Perm()
+       if mode&fs.ModeSticky != 0 {
+               perm |= 01000
+       }
+       if mode&fs.ModeSetgid != 0 {
+               perm |= 02000
+       }
+       if mode&fs.ModeSetuid != 0 {
+               perm |= 04000
+       }
+       return int(perm)
+}
+
+func FullPermToFileMode(perm int) fs.FileMode {
+       mode := fs.FileMode(perm & 0777)
+       if perm&01000 != 0 {
+               mode |= fs.ModeSticky
+       }
+       if perm&02000 != 0 {
+               mode |= fs.ModeSetgid
+       }
+       if perm&04000 != 0 {
+               mode |= fs.ModeSetuid
+       }
+       return mode
+}
diff --git a/cmd/safcm/config/permissions_test.go b/cmd/safcm/config/permissions_test.go
new file mode 100644 (file)
index 0000000..cec72a6
--- /dev/null
@@ -0,0 +1,248 @@
+// Copyright (C) 2021  Simon Ruderich
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+package config
+
+import (
+       "fmt"
+       "io/fs"
+       "os"
+       "reflect"
+       "testing"
+
+       "github.com/google/go-cmp/cmp"
+
+       "ruderich.org/simon/safcm"
+)
+
+func TestLoadPermissions(t *testing.T) {
+       cwd, err := os.Getwd()
+       if err != nil {
+               t.Fatal(err)
+       }
+       defer os.Chdir(cwd)
+
+       err = os.Chdir("../testdata/project")
+       if err != nil {
+               t.Fatal(err)
+       }
+
+       tests := []struct {
+               group  string
+               exp    map[string]*safcm.File
+               expErr error
+       }{
+
+               {
+                       "empty",
+                       nil,
+                       nil,
+               },
+
+               {
+                       "group",
+                       map[string]*safcm.File{
+                               "/": {
+                                       Path: "/",
+                                       Mode: fs.ModeDir | 0755 | fs.ModeSetgid,
+                                       Uid:  -1,
+                                       Gid:  -1,
+                               },
+                               "/etc": {
+                                       Path: "/etc",
+                                       Mode: fs.ModeDir | 0755,
+                                       Uid:  -1,
+                                       Gid:  -1,
+                               },
+                               "/etc/.hidden": {
+                                       Path: "/etc/.hidden",
+                                       Mode: 0100 | fs.ModeSetuid | fs.ModeSetgid | fs.ModeSticky,
+                                       Uid:  -1,
+                                       Gid:  -1,
+                                       Data: []byte("..."),
+                               },
+                               "/etc/motd": {
+                                       Path: "/etc/motd",
+                                       Mode: 0644,
+                                       Uid:  -1,
+                                       Gid:  -1,
+                                       Data: []byte(`Welcome to
+{{- if .IsHost "host1.example.org"}} Host ONE
+{{- else if "host2"}} Host TWO
+{{- end}}
+
+{{if .InGroup "detected_linux"}}
+This is GNU/Linux host
+{{end}}
+{{if .InGroup "detected_freebsd"}}
+This is FreeBSD host
+{{end}}
+`),
+                               },
+                               "/etc/rc.local": {
+                                       Path: "/etc/rc.local",
+                                       Mode: 0700,
+                                       Uid:  -1,
+                                       Gid:  -1,
+                                       Data: []byte("#!/bin/sh\n"),
+                               },
+                               "/etc/resolv.conf": {
+                                       Path:  "/etc/resolv.conf",
+                                       Mode:  0641,
+                                       User:  "user",
+                                       Uid:   -1,
+                                       Group: "group",
+                                       Gid:   -1,
+                                       Data:  []byte("nameserver ::1\n"),
+                               },
+                               "/etc/test": {
+                                       Path: "/etc/test",
+                                       Mode: fs.ModeSymlink | 0777,
+                                       Uid:  -1,
+                                       Gid:  -1,
+                                       Data: []byte("doesnt-exist"),
+                               },
+                       },
+                       nil,
+               },
+
+               {
+                       "permissions-invalid-execute",
+                       map[string]*safcm.File{
+                               "/": {
+                                       Path: "/",
+                                       Mode: fs.ModeDir | 0755,
+                                       Uid:  -1,
+                                       Gid:  -1,
+                               },
+                               "/etc": {
+                                       Path: "/etc",
+                                       Mode: fs.ModeDir | 0755,
+                                       Uid:  -1,
+                                       Gid:  -1,
+                               },
+                               "/etc/rc.local": {
+                                       Path: "/etc/rc.local",
+                                       Mode: 0755,
+                                       Uid:  -1,
+                                       Gid:  -1,
+                                       Data: []byte("#!/bin/sh\n"),
+                               },
+                       },
+                       fmt.Errorf("permissions-invalid-execute/permissions.yaml: \"/etc/rc.local\": trying to remove +x from file, manually chmod -x in files/"),
+               },
+               {
+                       "permissions-invalid-line",
+                       map[string]*safcm.File{
+                               "/": {
+                                       Path: "/",
+                                       Mode: fs.ModeDir | 0755,
+                                       Uid:  -1,
+                                       Gid:  -1,
+                               },
+                               "/etc": {
+                                       Path: "/etc",
+                                       Mode: fs.ModeDir | 0755,
+                                       Uid:  -1,
+                                       Gid:  -1,
+                               },
+                               "/etc/resolv.conf": {
+                                       Path: "/etc/resolv.conf",
+                                       Mode: 0644,
+                                       Uid:  -1,
+                                       Gid:  -1,
+                                       Data: []byte("nameserver ::1\n"),
+                               },
+                       },
+                       fmt.Errorf("permissions-invalid-line/permissions.yaml: invalid line \"invalid line\" (expected <perm> [<user> <group>])"),
+               },
+               {
+                       "permissions-invalid-path",
+                       nil,
+                       fmt.Errorf("permissions-invalid-path/permissions.yaml: \"/does/not/exist\" does not exist in files/"),
+               },
+               {
+                       "permissions-invalid-permission",
+                       map[string]*safcm.File{
+                               "/": {
+                                       Path: "/",
+                                       Mode: fs.ModeDir | 0755,
+                                       Uid:  -1,
+                                       Gid:  -1,
+                               },
+                               "/etc": {
+                                       Path: "/etc",
+                                       Mode: fs.ModeDir | 0755,
+                                       Uid:  -1,
+                                       Gid:  -1,
+                               },
+                               "/etc/resolv.conf": {
+                                       Path: "/etc/resolv.conf",
+                                       Mode: 0644,
+                                       Uid:  -1,
+                                       Gid:  -1,
+                                       Data: []byte("nameserver ::1\n"),
+                               },
+                       },
+                       fmt.Errorf("permissions-invalid-permission/permissions.yaml: invalid permission \"u=rwg=r\" (expected e.g. \"0644\" or \"01777\")"),
+               },
+               {
+                       "permissions-invalid-permission-int",
+                       map[string]*safcm.File{
+                               "/": {
+                                       Path: "/",
+                                       Mode: fs.ModeDir | 0755,
+                                       Uid:  -1,
+                                       Gid:  -1,
+                               },
+                               "/etc": {
+                                       Path: "/etc",
+                                       Mode: fs.ModeDir | 0755,
+                                       Uid:  -1,
+                                       Gid:  -1,
+                               },
+                               "/etc/resolv.conf": {
+                                       Path: "/etc/resolv.conf",
+                                       Mode: 0644,
+                                       Uid:  -1,
+                                       Gid:  -1,
+                                       Data: []byte("nameserver ::1\n"),
+                               },
+                       },
+                       fmt.Errorf("permissions-invalid-permission-int/permissions.yaml: invalid permission 066066 (expected e.g. 0644 or 01777)"),
+               },
+       }
+
+       for _, tc := range tests {
+               // Use LoadFiles() so we work on real data and don't make any
+               // mistakes generating it
+               files, err := LoadFiles(tc.group)
+               if err != nil {
+                       t.Fatalf("%s: err = %#v, want nil",
+                               tc.group, err)
+               }
+               err = LoadPermissions(tc.group, files)
+
+               if !reflect.DeepEqual(tc.exp, files) {
+                       t.Errorf("%s: res: %s", tc.group,
+                               cmp.Diff(tc.exp, files))
+               }
+               // Ugly but the simplest way to compare errors (including nil)
+               if fmt.Sprintf("%s", err) != fmt.Sprintf("%s", tc.expErr) {
+                       t.Errorf("%s: err = %#v, want %#v",
+                               tc.group, err, tc.expErr)
+               }
+       }
+}
diff --git a/cmd/safcm/config/services.go b/cmd/safcm/config/services.go
new file mode 100644 (file)
index 0000000..be2ed7b
--- /dev/null
@@ -0,0 +1,55 @@
+// Config: parse services.yaml
+
+// Copyright (C) 2021  Simon Ruderich
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+package config
+
+import (
+       "fmt"
+       "os"
+       "path/filepath"
+       "strings"
+
+       "gopkg.in/yaml.v2"
+)
+
+func LoadServices(group string) ([]string, error) {
+       path := filepath.Join(group, "services.yaml")
+
+       var res []string
+       x, err := os.ReadFile(path)
+       if err != nil {
+               if os.IsNotExist(err) {
+                       return nil, nil
+               }
+               return nil, err
+       }
+       err = yaml.UnmarshalStrict(x, &res)
+       if err != nil {
+               return nil, fmt.Errorf("%s: failed to load: %v", path, err)
+       }
+
+       // Sanity checks
+       for _, x := range res {
+               if len(strings.Fields(x)) != 1 {
+                       return nil, fmt.Errorf(
+                               "%s: service name %q must not contain whitespace",
+                               path, x)
+               }
+       }
+
+       return res, nil
+}
diff --git a/cmd/safcm/config/templates.go b/cmd/safcm/config/templates.go
new file mode 100644 (file)
index 0000000..b84b494
--- /dev/null
@@ -0,0 +1,124 @@
+// Config: parse templates.yaml and expand templates
+
+// Copyright (C) 2021  Simon Ruderich
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+package config
+
+import (
+       "bytes"
+       "fmt"
+       "os"
+       "path/filepath"
+       "strings"
+       "text/template"
+
+       "gopkg.in/yaml.v2"
+
+       "ruderich.org/simon/safcm"
+)
+
+func LoadTemplates(group string, files map[string]*safcm.File,
+       host string, groups []string,
+       allHosts *Hosts, allGroups map[string][]string) error {
+
+       path := filepath.Join(group, "templates.yaml")
+
+       var templates []string
+       x, err := os.ReadFile(path)
+       if err != nil {
+               if os.IsNotExist(err) {
+                       return nil
+               }
+               return err
+       }
+       err = yaml.UnmarshalStrict(x, &templates)
+       if err != nil {
+               return fmt.Errorf("%s: failed to load: %v", path, err)
+       }
+
+       groupsMap := make(map[string]bool)
+       for _, x := range groups {
+               groupsMap[x] = true
+       }
+       allHostsMap := make(map[string]bool)
+       for x := range allHosts.Map {
+               allHostsMap[x] = true
+       }
+       allGroupsMap := make(map[string]bool)
+       for x := range allGroups {
+               allGroupsMap[x] = true
+       }
+
+       for _, x := range templates {
+               f, ok := files[x]
+               if !ok {
+                       return fmt.Errorf("%s: %q does not exist in files/",
+                               path, x)
+               }
+               if f.Mode.Type() != 0 /* regular file */ {
+                       return fmt.Errorf("%s: %q is not a regular file",
+                               path, x)
+               }
+
+               tmplPath := filepath.Join(group, "files", x)
+
+               // Parse and expand template
+               var buf bytes.Buffer
+               tmpl, err := template.New(tmplPath).Parse(string(f.Data))
+               if err != nil {
+                       return fmt.Errorf("%s: invalid %v", path, err)
+               }
+               err = tmpl.Execute(&buf, &templateArgs{
+                       host:      host,
+                       groups:    groupsMap,
+                       allHosts:  allHostsMap,
+                       allGroups: allGroupsMap,
+               })
+               if err != nil {
+                       return fmt.Errorf("%s: %v", path, err)
+               }
+               f.Data = buf.Bytes()
+       }
+
+       return nil
+}
+
+// templateArgs is passed to .Execute() of the template.
+type templateArgs struct {
+       host      string
+       groups    map[string]bool
+       allHosts  map[string]bool
+       allGroups map[string]bool
+}
+
+// TODO: extend data passed to template
+
+func (t *templateArgs) IsHost(host string) bool {
+       // Don't permit invalid hosts to detect typos
+       if !t.allHosts[host] {
+               panic(fmt.Sprintf("host %q does not exist", host))
+       }
+       return t.host == host
+}
+func (t *templateArgs) InGroup(group string) bool {
+       // Don't permit invalid groups to detect typos; detected groups cannot
+       // be checked
+       if !t.allGroups[group] &&
+               !strings.HasPrefix(group, GroupDetectedPrefix) {
+               panic(fmt.Sprintf("group %q does not exist", group))
+       }
+       return t.groups[group]
+}
diff --git a/cmd/safcm/config/templates_test.go b/cmd/safcm/config/templates_test.go
new file mode 100644 (file)
index 0000000..6a0182c
--- /dev/null
@@ -0,0 +1,265 @@
+// Copyright (C) 2021  Simon Ruderich
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+package config
+
+import (
+       "fmt"
+       "io/fs"
+       "os"
+       "reflect"
+       "testing"
+
+       "github.com/google/go-cmp/cmp"
+
+       "ruderich.org/simon/safcm"
+)
+
+func TestLoadTemplates(t *testing.T) {
+       cwd, err := os.Getwd()
+       if err != nil {
+               t.Fatal(err)
+       }
+       defer os.Chdir(cwd)
+
+       err = os.Chdir("../testdata/project")
+       if err != nil {
+               t.Fatal(err)
+       }
+
+       allHosts, err := LoadHosts()
+       if err != nil {
+               t.Fatal(err)
+       }
+       allGroups, err := LoadGroups(&Config{}, allHosts)
+       if err != nil {
+               t.Fatal(err)
+       }
+       host := "host1.example.org"
+       groups, err := ResolveHostGroups(host, allGroups,
+               []string{"detected_amd64", "detected_linux"})
+       if err != nil {
+               t.Fatal(err)
+       }
+
+       tests := []struct {
+               group  string
+               exp    map[string]*safcm.File
+               expErr error
+       }{
+
+               {
+                       "empty",
+                       nil,
+                       nil,
+               },
+
+               {
+                       "group",
+                       map[string]*safcm.File{
+                               "/": {
+                                       Path: "/",
+                                       Mode: fs.ModeDir | 0755,
+                                       Uid:  -1,
+                                       Gid:  -1,
+                               },
+                               "/etc": {
+                                       Path: "/etc",
+                                       Mode: fs.ModeDir | 0755,
+                                       Uid:  -1,
+                                       Gid:  -1,
+                               },
+                               "/etc/.hidden": {
+                                       Path: "/etc/.hidden",
+                                       Mode: 0644,
+                                       Uid:  -1,
+                                       Gid:  -1,
+                                       Data: []byte("..."),
+                               },
+                               "/etc/motd": {
+                                       Path: "/etc/motd",
+                                       Mode: 0644,
+                                       Uid:  -1,
+                                       Gid:  -1,
+                                       Data: []byte(`Welcome to Host ONE
+
+
+This is GNU/Linux host
+
+
+`),
+                               },
+                               "/etc/rc.local": {
+                                       Path: "/etc/rc.local",
+                                       Mode: 0755,
+                                       Uid:  -1,
+                                       Gid:  -1,
+                                       Data: []byte("#!/bin/sh\n"),
+                               },
+                               "/etc/resolv.conf": {
+                                       Path: "/etc/resolv.conf",
+                                       Mode: 0644,
+                                       Uid:  -1,
+                                       Gid:  -1,
+                                       Data: []byte("nameserver ::1\n"),
+                               },
+                               "/etc/test": {
+                                       Path: "/etc/test",
+                                       Mode: fs.ModeSymlink | 0777,
+                                       Uid:  -1,
+                                       Gid:  -1,
+                                       Data: []byte("doesnt-exist"),
+                               },
+                       },
+                       nil,
+               },
+
+               {
+                       "templates-invalid-group",
+                       map[string]*safcm.File{
+                               "/": {
+                                       Path: "/",
+                                       Mode: fs.ModeDir | 0755,
+                                       Uid:  -1,
+                                       Gid:  -1,
+                               },
+                               "/etc": {
+                                       Path: "/etc",
+                                       Mode: fs.ModeDir | 0755,
+                                       Uid:  -1,
+                                       Gid:  -1,
+                               },
+                               "/etc/motd": {
+                                       Path: "/etc/motd",
+                                       Mode: 0644,
+                                       Uid:  -1,
+                                       Gid:  -1,
+                                       Data: []byte(`
+{{if .InGroup "invalid-group"}}
+...
+{{end}}
+`),
+                               },
+                       },
+                       fmt.Errorf("templates-invalid-group/templates.yaml: template: templates-invalid-group/files/etc/motd:2:5: executing \"templates-invalid-group/files/etc/motd\" at <.InGroup>: error calling InGroup: group \"invalid-group\" does not exist"),
+               },
+               {
+                       "templates-invalid-host",
+                       map[string]*safcm.File{
+                               "/": {
+                                       Path: "/",
+                                       Mode: fs.ModeDir | 0755,
+                                       Uid:  -1,
+                                       Gid:  -1,
+                               },
+                               "/etc": {
+                                       Path: "/etc",
+                                       Mode: fs.ModeDir | 0755,
+                                       Uid:  -1,
+                                       Gid:  -1,
+                               },
+                               "/etc/motd": {
+                                       Path: "/etc/motd",
+                                       Mode: 0644,
+                                       Uid:  -1,
+                                       Gid:  -1,
+                                       Data: []byte(`
+{{if .IsHost "invalid-host"}}
+...
+{{end}}
+`),
+                               },
+                       },
+                       fmt.Errorf("templates-invalid-host/templates.yaml: template: templates-invalid-host/files/etc/motd:2:5: executing \"templates-invalid-host/files/etc/motd\" at <.IsHost>: error calling IsHost: host \"invalid-host\" does not exist"),
+               },
+               {
+                       "templates-invalid-path",
+                       nil,
+                       fmt.Errorf("templates-invalid-path/templates.yaml: \"/etc/motd\" does not exist in files/"),
+               },
+               {
+                       "templates-invalid-template",
+                       map[string]*safcm.File{
+                               "/": {
+                                       Path: "/",
+                                       Mode: fs.ModeDir | 0755,
+                                       Uid:  -1,
+                                       Gid:  -1,
+                               },
+                               "/etc": {
+                                       Path: "/etc",
+                                       Mode: fs.ModeDir | 0755,
+                                       Uid:  -1,
+                                       Gid:  -1,
+                               },
+                               "/etc/motd": {
+                                       Path: "/etc/motd",
+                                       Mode: 0644,
+                                       Uid:  -1,
+                                       Gid:  -1,
+                                       Data: []byte("{{\n"),
+                               },
+                       },
+                       fmt.Errorf("templates-invalid-template/templates.yaml: invalid template: templates-invalid-template/files/etc/motd:2: unclosed action started at templates-invalid-template/files/etc/motd:1"),
+               },
+               {
+                       "templates-invalid-type",
+                       map[string]*safcm.File{
+                               "/": {
+                                       Path: "/",
+                                       Mode: fs.ModeDir | 0755,
+                                       Uid:  -1,
+                                       Gid:  -1,
+                               },
+                               "/etc": {
+                                       Path: "/etc",
+                                       Mode: fs.ModeDir | 0755,
+                                       Uid:  -1,
+                                       Gid:  -1,
+                               },
+                               "/etc/motd": {
+                                       Path: "/etc/motd",
+                                       Mode: 0644,
+                                       Uid:  -1,
+                                       Gid:  -1,
+                                       Data: []byte{},
+                               },
+                       },
+                       fmt.Errorf("templates-invalid-type/templates.yaml: \"/etc\" is not a regular file"),
+               },
+       }
+
+       for _, tc := range tests {
+               // Use LoadFiles() so we work on real data and don't make any
+               // mistakes generating it
+               files, err := LoadFiles(tc.group)
+               if err != nil {
+                       t.Fatalf("%s: err = %#v, want nil",
+                               tc.group, err)
+               }
+               err = LoadTemplates(tc.group, files,
+                       host, groups, allHosts, allGroups)
+
+               if !reflect.DeepEqual(tc.exp, files) {
+                       t.Errorf("%s: res: %s", tc.group,
+                               cmp.Diff(tc.exp, files))
+               }
+               // Ugly but the simplest way to compare errors (including nil)
+               if fmt.Sprintf("%s", err) != fmt.Sprintf("%s", tc.expErr) {
+                       t.Errorf("%s: err = %#v, want %#v",
+                               tc.group, err, tc.expErr)
+               }
+       }
+}
diff --git a/cmd/safcm/config/triggers.go b/cmd/safcm/config/triggers.go
new file mode 100644 (file)
index 0000000..69ceb09
--- /dev/null
@@ -0,0 +1,56 @@
+// Config: parse triggers.yaml
+
+// Copyright (C) 2021  Simon Ruderich
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+package config
+
+import (
+       "fmt"
+       "os"
+       "path/filepath"
+
+       "gopkg.in/yaml.v2"
+
+       "ruderich.org/simon/safcm"
+)
+
+func LoadTriggers(group string, files map[string]*safcm.File) error {
+       path := filepath.Join(group, "triggers.yaml")
+
+       var triggers map[string][]string
+       x, err := os.ReadFile(path)
+       if err != nil {
+               if os.IsNotExist(err) {
+                       return nil
+               }
+               return err
+       }
+       err = yaml.UnmarshalStrict(x, &triggers)
+       if err != nil {
+               return fmt.Errorf("%s: failed to load: %v", path, err)
+       }
+
+       for p, x := range triggers {
+               f, ok := files[p]
+               if !ok {
+                       return fmt.Errorf("%s: %q does not exist in files/",
+                               path, p)
+               }
+               f.TriggerCommands = x
+       }
+
+       return nil
+}
diff --git a/cmd/safcm/config/triggers_test.go b/cmd/safcm/config/triggers_test.go
new file mode 100644 (file)
index 0000000..a27ec6a
--- /dev/null
@@ -0,0 +1,155 @@
+// Copyright (C) 2021  Simon Ruderich
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+package config
+
+import (
+       "fmt"
+       "io/fs"
+       "os"
+       "reflect"
+       "testing"
+
+       "github.com/google/go-cmp/cmp"
+
+       "ruderich.org/simon/safcm"
+)
+
+func TestLoadTriggers(t *testing.T) {
+       cwd, err := os.Getwd()
+       if err != nil {
+               t.Fatal(err)
+       }
+       defer os.Chdir(cwd)
+
+       err = os.Chdir("../testdata/project")
+       if err != nil {
+               t.Fatal(err)
+       }
+
+       tests := []struct {
+               group  string
+               exp    map[string]*safcm.File
+               expErr error
+       }{
+
+               {
+                       "empty",
+                       nil,
+                       nil,
+               },
+
+               {
+                       "group",
+                       map[string]*safcm.File{
+                               "/": {
+                                       Path: "/",
+                                       Mode: fs.ModeDir | 0755,
+                                       Uid:  -1,
+                                       Gid:  -1,
+                                       TriggerCommands: []string{
+                                               "touch /.update",
+                                       },
+                               },
+                               "/etc": {
+                                       Path: "/etc",
+                                       Mode: fs.ModeDir | 0755,
+                                       Uid:  -1,
+                                       Gid:  -1,
+                               },
+                               "/etc/.hidden": {
+                                       Path: "/etc/.hidden",
+                                       Mode: 0644,
+                                       Uid:  -1,
+                                       Gid:  -1,
+                                       Data: []byte("..."),
+                               },
+                               "/etc/motd": {
+                                       Path: "/etc/motd",
+                                       Mode: 0644,
+                                       Uid:  -1,
+                                       Gid:  -1,
+                                       Data: []byte(`Welcome to
+{{- if .IsHost "host1.example.org"}} Host ONE
+{{- else if "host2"}} Host TWO
+{{- end}}
+
+{{if .InGroup "detected_linux"}}
+This is GNU/Linux host
+{{end}}
+{{if .InGroup "detected_freebsd"}}
+This is FreeBSD host
+{{end}}
+`),
+                               },
+                               "/etc/rc.local": {
+                                       Path: "/etc/rc.local",
+                                       Mode: 0755,
+                                       Uid:  -1,
+                                       Gid:  -1,
+                                       Data: []byte("#!/bin/sh\n"),
+                                       TriggerCommands: []string{
+                                               "/etc/rc.local",
+                                       },
+                               },
+                               "/etc/resolv.conf": {
+                                       Path: "/etc/resolv.conf",
+                                       Mode: 0644,
+                                       Uid:  -1,
+                                       Gid:  -1,
+                                       Data: []byte("nameserver ::1\n"),
+                                       TriggerCommands: []string{
+                                               "echo resolv.conf updated",
+                                       },
+                               },
+                               "/etc/test": {
+                                       Path: "/etc/test",
+                                       Mode: fs.ModeSymlink | 0777,
+                                       Uid:  -1,
+                                       Gid:  -1,
+                                       Data: []byte("doesnt-exist"),
+                               },
+                       },
+                       nil,
+               },
+
+               {
+                       "triggers-invalid-path",
+                       nil,
+                       fmt.Errorf("triggers-invalid-path/triggers.yaml: \"/etc/resolv.conf\" does not exist in files/"),
+               },
+       }
+
+       for _, tc := range tests {
+               // Use LoadFiles() so we work on real data and don't make any
+               // mistakes generating it
+               files, err := LoadFiles(tc.group)
+               if err != nil {
+                       t.Fatalf("%s: err = %#v, want nil",
+                               tc.group, err)
+               }
+               err = LoadTriggers(tc.group, files)
+
+               if !reflect.DeepEqual(tc.exp, files) {
+                       t.Errorf("%s: res: %s", tc.group,
+                               cmp.Diff(tc.exp, files))
+               }
+               // Ugly but the simplest way to compare errors (including nil)
+               if fmt.Sprintf("%s", err) != fmt.Sprintf("%s", tc.expErr) {
+                       t.Errorf("%s: err = %#v, want %#v",
+                               tc.group, err, tc.expErr)
+               }
+       }
+}
diff --git a/cmd/safcm/fixperms.go b/cmd/safcm/fixperms.go
new file mode 100644 (file)
index 0000000..6770934
--- /dev/null
@@ -0,0 +1,99 @@
+// "fixperms" sub-command: apply proper permissions in files/ directories
+
+// Copyright (C) 2021  Simon Ruderich
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+package main
+
+import (
+       "fmt"
+       "io/fs"
+       "log"
+       "os"
+       "path/filepath"
+
+       "ruderich.org/simon/safcm/cmd/safcm/config"
+)
+
+func MainFixperms() error {
+       _, _, _, err := LoadBaseFiles()
+       if err != nil {
+               return fmt.Errorf("not in a safcm directory: %v", err)
+       }
+
+       xs, err := os.ReadDir(".")
+       if err != nil {
+               return err
+       }
+       for _, x := range xs {
+               if !x.IsDir() {
+                       continue
+               }
+               path := filepath.Join(x.Name(), "files")
+
+               err := filepath.WalkDir(path, fixpermsWalkDirFunc)
+               if err != nil {
+                       if os.IsNotExist(err) {
+                               continue
+                       }
+                       return fmt.Errorf("%s: %v", path, err)
+               }
+       }
+
+       return nil
+}
+
+func fixpermsWalkDirFunc(path string, d fs.DirEntry, err error) error {
+       if err != nil {
+               return err
+       }
+
+       info, err := d.Info()
+       if err != nil {
+               return err
+       }
+       typ := info.Mode().Type()
+       perm := config.FileModeToFullPerm(info.Mode())
+
+       if typ == 0 /* regular file */ {
+               if perm != 0644 && perm != 0755 {
+                       if perm&0111 != 0 /* executable */ {
+                               perm = 0755
+                       } else {
+                               perm = 0644
+                       }
+                       log.Printf("chmodding %q to %#o", path, perm)
+                       // This is safe because perm does not include
+                       // setuid/setgid/sticky which use different values in
+                       // FileMode.
+                       err := os.Chmod(path, fs.FileMode(perm))
+                       if err != nil {
+                               return err
+                       }
+               }
+       } else if typ == fs.ModeDir {
+               if perm != 0755 {
+                       perm = 0755
+                       log.Printf("chmodding %q to %#o", path, perm)
+                       err := os.Chmod(path, fs.FileMode(perm))
+                       if err != nil {
+                               return err
+                       }
+               }
+       }
+       // Other file types are caught by regular "sync"
+
+       return nil
+}
diff --git a/cmd/safcm/main.go b/cmd/safcm/main.go
new file mode 100644 (file)
index 0000000..5916b84
--- /dev/null
@@ -0,0 +1,72 @@
+// Command line tool to manage remote hosts
+
+// Copyright (C) 2021  Simon Ruderich
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+package main
+
+import (
+       "log"
+       "os"
+
+       "ruderich.org/simon/safcm/cmd/safcm/config"
+)
+
+func usage() {
+       log.SetFlags(0)
+       log.Fatalf("usage: %[1]s sync [<options>] <host|group...>\n"+
+               "       %[1]s fixperms\n"+
+               "", os.Args[0])
+}
+
+func main() {
+       if len(os.Args) < 2 {
+               usage()
+       }
+
+       var err error
+       switch os.Args[1] {
+       case "sync":
+               err = MainSync(os.Args)
+       case "fixperms":
+               if len(os.Args) != 2 {
+                       usage()
+               }
+               err = MainFixperms()
+       default:
+               usage()
+       }
+       if err != nil {
+               log.Fatal(err)
+       }
+}
+
+func LoadBaseFiles() (*config.Config, *config.Hosts, map[string][]string,
+       error) {
+
+       cfg, err := config.LoadConfig()
+       if err != nil {
+               return nil, nil, nil, err
+       }
+       hosts, err := config.LoadHosts()
+       if err != nil {
+               return nil, nil, nil, err
+       }
+       groups, err := config.LoadGroups(cfg, hosts)
+       if err != nil {
+               return nil, nil, nil, err
+       }
+       return cfg, hosts, groups, nil
+}
diff --git a/cmd/safcm/sync.go b/cmd/safcm/sync.go
new file mode 100644 (file)
index 0000000..09ffe85
--- /dev/null
@@ -0,0 +1,390 @@
+// "sync" sub-command: sync data to remote hosts
+
+// Copyright (C) 2021  Simon Ruderich
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+package main
+
+import (
+       "flag"
+       "fmt"
+       "log"
+       "os"
+       "sort"
+       "strings"
+       "sync"
+
+       "golang.org/x/term"
+
+       "ruderich.org/simon/safcm"
+       "ruderich.org/simon/safcm/cmd/safcm/config"
+       "ruderich.org/simon/safcm/rpc"
+)
+
+type Sync struct {
+       host *config.Host
+
+       config    *config.Config      // global configuration
+       allHosts  *config.Hosts       // known hosts
+       allGroups map[string][]string // known groups
+
+       events chan<- Event // all events generated by/for this host
+
+       isTTY bool
+}
+
+type Event struct {
+       Host *config.Host
+
+       // Only one of Error, Log and ConnEvent is set in a single event
+       Error     error
+       Log       Log
+       ConnEvent rpc.ConnEvent
+
+       Escaped bool // true if untrusted input is already escaped
+}
+
+type Log struct {
+       Level safcm.LogLevel
+       Text  string
+}
+
+func MainSync(args []string) error {
+       flag.Usage = func() {
+               fmt.Fprintf(os.Stderr,
+                       "usage: %s sync [<options>] <host|group...>\n",
+                       args[0])
+               flag.PrintDefaults()
+       }
+
+       optionDryRun := flag.Bool("n", false,
+               "dry-run, show diff but don't perform any changes")
+       optionLog := flag.String("log", "info", "set log `level`; "+
+               "levels: error, info, verbose, debug, debug2, debug3")
+
+       flag.CommandLine.Parse(args[2:])
+
+       var level safcm.LogLevel
+       switch *optionLog {
+       case "error":
+               level = safcm.LogError
+       case "info":
+               level = safcm.LogInfo
+       case "verbose":
+               level = safcm.LogVerbose
+       case "debug":
+               level = safcm.LogDebug
+       case "debug2":
+               level = safcm.LogDebug2
+       case "debug3":
+               level = safcm.LogDebug3
+       default:
+               return fmt.Errorf("invalid -log value %q", *optionLog)
+       }
+
+       names := flag.Args()
+       if len(names) == 0 {
+               flag.Usage()
+               os.Exit(1)
+       }
+
+       cfg, allHosts, allGroups, err := LoadBaseFiles()
+       if err != nil {
+               return err
+       }
+       cfg.DryRun = *optionDryRun
+       cfg.LogLevel = level
+
+       toSync, err := hostsToSync(names, allHosts, allGroups)
+       if err != nil {
+               return err
+       }
+       if len(toSync) == 0 {
+               return fmt.Errorf("no hosts found")
+       }
+
+       isTTY := term.IsTerminal(int(os.Stdout.Fd()))
+
+       done := make(chan bool)
+       // Collect events from all hosts and print them
+       events := make(chan Event)
+       go func() {
+               var failed bool
+               for {
+                       x := <-events
+                       if x.Host == nil {
+                               break
+                       }
+                       logEvent(x, cfg.LogLevel, isTTY, &failed)
+               }
+               done <- failed
+       }()
+
+       // Sync all hosts concurrently
+       var wg sync.WaitGroup
+       for _, x := range toSync {
+               x := x
+
+               // Once in sync.Host() and once in the go func below
+               wg.Add(2)
+
+               go func() {
+                       sync := Sync{
+                               host:      x,
+                               config:    cfg,
+                               allHosts:  allHosts,
+                               allGroups: allGroups,
+                               events:    events,
+                               isTTY:     isTTY,
+                       }
+                       err := sync.Host(&wg)
+                       if err != nil {
+                               events <- Event{
+                                       Host:  x,
+                                       Error: err,
+                               }
+                       }
+                       wg.Done()
+               }()
+       }
+
+       wg.Wait()
+       events <- Event{} // poison pill
+       failed := <-done
+
+       if failed {
+               // Exit instead of returning an error to prevent an extra log
+               // message from main()
+               os.Exit(1)
+       }
+       return nil
+}
+
+// hostsToSync returns the list of hosts to sync based on the command line
+// arguments.
+//
+// Full host and group matches are required to prevent unexpected behavior. No
+// arguments does not expand to all hosts to prevent accidents; "all" can be
+// used instead. Both host and group names are permitted as these are unique.
+//
+// TODO: Add option to permit partial/glob matches
+func hostsToSync(names []string, allHosts *config.Hosts,
+       allGroups map[string][]string) ([]*config.Host, error) {
+
+       nameMap := make(map[string]bool)
+       for _, x := range names {
+               nameMap[x] = true
+       }
+       nameMatched := make(map[string]bool)
+       // To detect typos we must check all given names but only want to add
+       // each match once
+       hostMatched := make(map[string]bool)
+
+       var res []*config.Host
+       for _, host := range allHosts.List {
+               if nameMap[host.Name] {
+                       res = append(res, host)
+                       hostMatched[host.Name] = true
+                       nameMatched[host.Name] = true
+               }
+
+               // TODO: don't permit groups which contain "detected" groups
+               // because these are not available yet
+               groups, err := config.ResolveHostGroups(host.Name,
+                       allGroups, nil)
+               if err != nil {
+                       return nil, err
+               }
+               for _, x := range groups {
+                       if nameMap[x] {
+                               if !hostMatched[host.Name] {
+                                       res = append(res, host)
+                                       hostMatched[host.Name] = true
+                               }
+                               nameMatched[x] = true
+                       }
+               }
+       }
+
+       // Warn about unmatched names to detect typos
+       if len(nameMap) != len(nameMatched) {
+               var unmatched []string
+               for x := range nameMap {
+                       if !nameMatched[x] {
+                               unmatched = append(unmatched,
+                                       fmt.Sprintf("%q", x))
+                       }
+               }
+               sort.Strings(unmatched)
+               return nil, fmt.Errorf("hosts/groups not found: %s",
+                       strings.Join(unmatched, " "))
+       }
+
+       return res, nil
+}
+
+func logEvent(x Event, level safcm.LogLevel, isTTY bool, failed *bool) {
+       // We have multiple event sources so this is somewhat ugly.
+       var prefix, data string
+       var color Color
+       if x.Error != nil {
+               prefix = "[error]"
+               data = x.Error.Error()
+               color = ColorRed
+               // We logged an error, tell the caller
+               *failed = true
+       } else if x.Log.Level != 0 {
+               // LogError and LogDebug3 should not occur here
+               switch x.Log.Level {
+               case safcm.LogInfo:
+                       prefix = "[info]"
+               case safcm.LogVerbose:
+                       prefix = "[verbose]"
+               case safcm.LogDebug:
+                       prefix = "[debug]"
+               case safcm.LogDebug2:
+                       prefix = "[debug2]"
+               default:
+                       prefix = fmt.Sprintf("[INVALID=%d]", x.Log.Level)
+                       color = ColorRed
+               }
+               data = x.Log.Text
+       } else {
+               switch x.ConnEvent.Type {
+               case rpc.ConnEventStderr:
+                       prefix = "[stderr]"
+               case rpc.ConnEventDebug:
+                       prefix = "[debug3]"
+               case rpc.ConnEventUpload:
+                       if level < safcm.LogInfo {
+                               return
+                       }
+                       prefix = "[info]"
+                       x.ConnEvent.Data = "remote helper upload in progress"
+               default:
+                       prefix = fmt.Sprintf("[INVALID=%d]", x.ConnEvent.Type)
+                       color = ColorRed
+               }
+               data = x.ConnEvent.Data
+       }
+
+       host := x.Host.Name
+       if color != 0 {
+               host = ColorString(isTTY, color, host)
+       }
+       // Make sure to escape control characters to prevent terminal
+       // injection attacks
+       if !x.Escaped {
+               data = EscapeControlCharacters(isTTY, data)
+       }
+       log.Printf("%-9s [%s] %s", prefix, host, data)
+}
+
+func (s *Sync) Host(wg *sync.WaitGroup) error {
+       conn := rpc.NewConn(s.config.LogLevel >= safcm.LogDebug3)
+       // Pass all connection events to main loop
+       go func() {
+               for {
+                       x, ok := <-conn.Events
+                       if !ok {
+                               break
+                       }
+                       s.events <- Event{
+                               Host:      s.host,
+                               ConnEvent: x,
+                       }
+               }
+               wg.Done()
+       }()
+
+       // Connect to remote host
+       err := conn.DialSSH(s.host.Name)
+       if err != nil {
+               return err
+       }
+       defer conn.Kill()
+
+       // Collect information about remote host
+       detectedGroups, err := s.hostInfo(conn)
+       if err != nil {
+               return err
+       }
+
+       // Sync state to remote host
+       err = s.hostSync(conn, detectedGroups)
+       if err != nil {
+               return err
+       }
+
+       // Terminate connection to remote host
+       err = conn.Send(safcm.MsgQuitReq{})
+       if err != nil {
+               return err
+       }
+       _, err = conn.Recv()
+       if err != nil {
+               return err
+       }
+       err = conn.Wait()
+       if err != nil {
+               return err
+       }
+
+       return nil
+}
+
+func (s *Sync) logf(level safcm.LogLevel, escaped bool,
+       format string, a ...interface{}) {
+
+       if s.config.LogLevel < level {
+               return
+       }
+       s.events <- Event{
+               Host: s.host,
+               Log: Log{
+                       Level: level,
+                       Text:  fmt.Sprintf(format, a...),
+               },
+               Escaped: escaped,
+       }
+}
+func (s *Sync) logDebugf(format string, a ...interface{}) {
+       s.logf(safcm.LogDebug, false, format, a...)
+}
+func (s *Sync) logVerbosef(format string, a ...interface{}) {
+       s.logf(safcm.LogVerbose, false, format, a...)
+}
+
+// sendRecv sends a message over conn and waits for the response. Any MsgLog
+// messages received before the final (non MsgLog) response are passed to
+// s.log.
+func (s *Sync) sendRecv(conn *rpc.Conn, msg safcm.Msg) (safcm.Msg, error) {
+       err := conn.Send(msg)
+       if err != nil {
+               return nil, err
+       }
+       for {
+               x, err := conn.Recv()
+               if err != nil {
+                       return nil, err
+               }
+               log, ok := x.(safcm.MsgLog)
+               if ok {
+                       s.logf(log.Level, false, "%s", log.Text)
+                       continue
+               }
+               return x, nil
+       }
+}
diff --git a/cmd/safcm/sync_changes.go b/cmd/safcm/sync_changes.go
new file mode 100644 (file)
index 0000000..14d73c2
--- /dev/null
@@ -0,0 +1,213 @@
+// "sync" sub-command: format changes
+
+// Copyright (C) 2021  Simon Ruderich
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+package main
+
+import (
+       "fmt"
+       "io/fs"
+       "strings"
+
+       "ruderich.org/simon/safcm"
+       "ruderich.org/simon/safcm/cmd/safcm/config"
+)
+
+// NOTE: Be careful when implementing new format* functions. All input from
+// the remote helper is untrusted and must be either escaped with %q or by
+// calling EscapeControlCharacters().
+
+func (s *Sync) formatFileChanges(changes []safcm.FileChange) string {
+       var buf strings.Builder
+       fmt.Fprintf(&buf, "changed %d file(s):", len(changes))
+       if s.config.DryRun {
+               fmt.Fprintf(&buf, " (dry-run)")
+       }
+       fmt.Fprintf(&buf, "\n")
+       for _, x := range changes {
+               fmt.Fprintf(&buf, "%s:", s.formatTarget(x.Path))
+
+               var info []string
+               if x.Created {
+                       info = append(info,
+                               ColorString(s.isTTY, ColorGreen, "created"),
+                               formatFileType(x.New),
+                               formatFileUserGroup(x.New),
+                               formatFilePerm(x.New),
+                       )
+               } else {
+                       if x.Old.Mode.Type() != x.New.Mode.Type() {
+                               info = append(info, fmt.Sprintf("%s -> %s",
+                                       formatFileType(x.Old),
+                                       formatFileType(x.New),
+                               ))
+                       }
+                       if x.Old.User != x.New.User ||
+                               x.Old.Uid != x.New.Uid ||
+                               x.Old.Group != x.New.Group ||
+                               x.Old.Gid != x.New.Gid {
+                               info = append(info, fmt.Sprintf("%s -> %s",
+                                       formatFileUserGroup(x.Old),
+                                       formatFileUserGroup(x.New),
+                               ))
+                       }
+                       if config.FileModeToFullPerm(x.Old.Mode) !=
+                               config.FileModeToFullPerm(x.New.Mode) {
+                               info = append(info, fmt.Sprintf("%s -> %s",
+                                       formatFilePerm(x.Old),
+                                       formatFilePerm(x.New),
+                               ))
+                       }
+               }
+               if len(info) > 0 {
+                       fmt.Fprint(&buf, " ")
+                       fmt.Fprint(&buf, strings.Join(info, ", "))
+               }
+
+               if x.DataDiff != "" {
+                       fmt.Fprintf(&buf, "\n%s", s.formatDiff(x.DataDiff))
+               }
+               fmt.Fprintf(&buf, "\n")
+       }
+
+       return buf.String()
+}
+func formatFileType(info safcm.FileChangeInfo) string {
+       switch info.Mode.Type() {
+       case 0: // regular file
+               return "file"
+       case fs.ModeSymlink:
+               return "symlink"
+       case fs.ModeDir:
+               return "dir"
+       default:
+               return fmt.Sprintf("invalid type %v", info.Mode.Type())
+       }
+}
+func formatFileUserGroup(info safcm.FileChangeInfo) string {
+       return fmt.Sprintf("%s(%d) %s(%d)",
+               EscapeControlCharacters(false, info.User), info.Uid,
+               EscapeControlCharacters(false, info.Group), info.Gid)
+}
+func formatFilePerm(info safcm.FileChangeInfo) string {
+       return fmt.Sprintf("%#o", config.FileModeToFullPerm(info.Mode))
+}
+
+func (s *Sync) formatPackageChanges(changes []safcm.PackageChange) string {
+       var buf strings.Builder
+       fmt.Fprintf(&buf, "installed %d package(s):", len(changes))
+       if s.config.DryRun {
+               fmt.Fprintf(&buf, " (dry-run)")
+       }
+       fmt.Fprintf(&buf, "\n")
+       for _, x := range changes {
+               // TODO: indicate if installation failed
+               fmt.Fprintf(&buf, "%s\n", s.formatTarget(x.Name))
+       }
+       return buf.String()
+}
+
+func (s *Sync) formatServiceChanges(changes []safcm.ServiceChange) string {
+       var buf strings.Builder
+       fmt.Fprintf(&buf, "modified %d service(s):", len(changes))
+       if s.config.DryRun {
+               fmt.Fprintf(&buf, " (dry-run)")
+       }
+       fmt.Fprintf(&buf, "\n")
+       for _, x := range changes {
+               var info []string
+               if x.Started {
+                       info = append(info, "started")
+               }
+               if x.Enabled {
+                       info = append(info, "enabled")
+               }
+               fmt.Fprintf(&buf, "%s: %s\n",
+                       s.formatTarget(x.Name),
+                       strings.Join(info, ", "))
+       }
+       return buf.String()
+}
+
+func (s *Sync) formatCommandChanges(changes []safcm.CommandChange) string {
+       const indent = "   > "
+
+       var buf strings.Builder
+       fmt.Fprintf(&buf, "executed %d command(s):", len(changes))
+       if s.config.DryRun {
+               fmt.Fprintf(&buf, " (dry-run)")
+       }
+       fmt.Fprintf(&buf, "\n")
+       for _, x := range changes {
+               fmt.Fprintf(&buf, "%s", s.formatTarget(x.Command))
+               if x.Trigger != "" {
+                       fmt.Fprintf(&buf, ", trigger for %q", x.Trigger)
+               }
+               if x.Error != "" {
+                       fmt.Fprintf(&buf, ", failed: %q", x.Error)
+               }
+               if x.Output != "" {
+                       // TODO: truncate very large outputs?
+                       x := indentBlock(x.Output, indent)
+                       fmt.Fprintf(&buf, ":\n%s",
+                               EscapeControlCharacters(s.isTTY, x))
+               }
+               fmt.Fprintf(&buf, "\n")
+       }
+       return buf.String()
+}
+
+func (s *Sync) formatTarget(x string) string {
+       x = fmt.Sprintf("%q", x) // escape!
+       return ColorString(s.isTTY, ColorCyan, x)
+}
+
+func (s *Sync) formatDiff(diff string) string {
+       const indent = "   "
+
+       diff = indentBlock(diff, indent)
+       // Never color diff content as we want to color the whole diff
+       diff = EscapeControlCharacters(false, diff)
+       if !s.isTTY {
+               return diff
+       }
+
+       var res []string
+       for _, x := range strings.Split(diff, "\n") {
+               if strings.HasPrefix(x, indent+"+") {
+                       x = ColorString(s.isTTY, ColorGreen, x)
+               } else if strings.HasPrefix(x, indent+"-") {
+                       x = ColorString(s.isTTY, ColorRed, x)
+               }
+               res = append(res, x)
+       }
+       return strings.Join(res, "\n")
+}
+
+func indentBlock(x string, sep string) string {
+       if x == "" {
+               return ""
+       }
+
+       lines := strings.Split(x, "\n")
+       if lines[len(lines)-1] == "" {
+               lines = lines[:len(lines)-1]
+       } else {
+               lines = append(lines, "\\ No newline at end of file")
+       }
+
+       return sep + strings.Join(lines, "\n"+sep)
+}
diff --git a/cmd/safcm/sync_changes_test.go b/cmd/safcm/sync_changes_test.go
new file mode 100644 (file)
index 0000000..76a0168
--- /dev/null
@@ -0,0 +1,562 @@
+// Copyright (C) 2021  Simon Ruderich
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+package main
+
+import (
+       "io/fs"
+       "testing"
+
+       "github.com/google/go-cmp/cmp"
+
+       "ruderich.org/simon/safcm"
+       "ruderich.org/simon/safcm/cmd/safcm/config"
+)
+
+func TestFormatFileChanges(t *testing.T) {
+       tests := []struct {
+               name    string
+               dryRun  bool
+               changes []safcm.FileChange
+               exp     string
+       }{
+
+               {
+                       "regular",
+                       false,
+                       []safcm.FileChange{
+                               {
+                                       Path:    "created: file",
+                                       Created: true,
+                                       New: safcm.FileChangeInfo{
+                                               Mode:  0644,
+                                               User:  "user",
+                                               Uid:   1000,
+                                               Group: "group",
+                                               Gid:   2000,
+                                       },
+                               },
+                               {
+                                       Path:    "created: link",
+                                       Created: true,
+                                       New: safcm.FileChangeInfo{
+                                               Mode:  fs.ModeSymlink | 0777,
+                                               User:  "user",
+                                               Uid:   1000,
+                                               Group: "group",
+                                               Gid:   2000,
+                                       },
+                               },
+                               {
+                                       Path: "type change: file -> dir",
+                                       Old: safcm.FileChangeInfo{
+                                               Mode:  0751,
+                                               User:  "user",
+                                               Uid:   1000,
+                                               Group: "group",
+                                               Gid:   2000,
+                                       },
+                                       New: safcm.FileChangeInfo{
+                                               Mode:  fs.ModeDir | 0751,
+                                               User:  "user",
+                                               Uid:   1000,
+                                               Group: "group",
+                                               Gid:   2000,
+                                       },
+                                       DataDiff: `@@ -1,2 +1 @@
+-content
+`,
+                               },
+                               {
+                                       Path: "user change",
+                                       Old: safcm.FileChangeInfo{
+                                               Mode:  0755,
+                                               User:  "user",
+                                               Uid:   1000,
+                                               Group: "group",
+                                               Gid:   2000,
+                                       },
+                                       New: safcm.FileChangeInfo{
+                                               Mode:  0755,
+                                               User:  "user2",
+                                               Uid:   1001,
+                                               Group: "group",
+                                               Gid:   2000,
+                                       },
+                               },
+                               {
+                                       Path: "group change",
+                                       Old: safcm.FileChangeInfo{
+                                               Mode:  0755,
+                                               User:  "user",
+                                               Uid:   1000,
+                                               Group: "group",
+                                               Gid:   2000,
+                                       },
+                                       New: safcm.FileChangeInfo{
+                                               Mode:  0755,
+                                               User:  "user",
+                                               Uid:   1000,
+                                               Group: "group2",
+                                               Gid:   2001,
+                                       },
+                               },
+                               {
+                                       Path: "mode change",
+                                       Old: safcm.FileChangeInfo{
+                                               Mode:  0755,
+                                               User:  "user",
+                                               Uid:   1000,
+                                               Group: "group",
+                                               Gid:   2000,
+                                       },
+                                       New: safcm.FileChangeInfo{
+                                               Mode:  0750,
+                                               User:  "user",
+                                               Uid:   1000,
+                                               Group: "group",
+                                               Gid:   2000,
+                                       },
+                               },
+                               {
+                                       Path: "mode change (setuid)",
+                                       Old: safcm.FileChangeInfo{
+                                               Mode:  0755,
+                                               User:  "user",
+                                               Uid:   1000,
+                                               Group: "group",
+                                               Gid:   2000,
+                                       },
+                                       New: safcm.FileChangeInfo{
+                                               Mode:  0755 | fs.ModeSetuid,
+                                               User:  "user",
+                                               Uid:   1000,
+                                               Group: "group",
+                                               Gid:   2000,
+                                       },
+                               },
+                               {
+                                       Path: "content change",
+                                       Old: safcm.FileChangeInfo{
+                                               Mode:  0644,
+                                               User:  "user",
+                                               Uid:   1000,
+                                               Group: "group",
+                                               Gid:   2000,
+                                       },
+                                       New: safcm.FileChangeInfo{
+                                               Mode:  0644,
+                                               User:  "user",
+                                               Uid:   1000,
+                                               Group: "group",
+                                               Gid:   2000,
+                                       },
+                                       DataDiff: `@@ -1,2 +1,2 @@
+-old content
++content
+`,
+                               },
+                               {
+                                       Path: "multiple changes",
+                                       Old: safcm.FileChangeInfo{
+                                               Mode:  0644,
+                                               User:  "user",
+                                               Uid:   1000,
+                                               Group: "group",
+                                               Gid:   2000,
+                                       },
+                                       New: safcm.FileChangeInfo{
+                                               Mode:  fs.ModeDir | 0755,
+                                               User:  "user2",
+                                               Uid:   1001,
+                                               Group: "group2",
+                                               Gid:   2001,
+                                       },
+                                       DataDiff: `@@ -1,2 +1 @@
+-content
+`,
+                               },
+                       },
+                       `changed 9 file(s):
+"created: file": created, file, user(1000) group(2000), 0644
+"created: link": created, symlink, user(1000) group(2000), 0777
+"type change: file -> dir": file -> dir
+   @@ -1,2 +1 @@
+   -content
+    
+"user change": user(1000) group(2000) -> user2(1001) group(2000)
+"group change": user(1000) group(2000) -> user(1000) group2(2001)
+"mode change": 0755 -> 0750
+"mode change (setuid)": 0755 -> 04755
+"content change":
+   @@ -1,2 +1,2 @@
+   -old content
+   +content
+    
+"multiple changes": file -> dir, user(1000) group(2000) -> user2(1001) group2(2001), 0644 -> 0755
+   @@ -1,2 +1 @@
+   -content
+    
+`,
+               },
+
+               {
+                       "dry-run",
+                       true,
+                       []safcm.FileChange{
+                               {
+                                       Path:    "file",
+                                       Created: true,
+                                       New: safcm.FileChangeInfo{
+                                               Mode:  0644,
+                                               User:  "user",
+                                               Uid:   1000,
+                                               Group: "group",
+                                               Gid:   2000,
+                                       },
+                               },
+                       },
+                       `changed 1 file(s): (dry-run)
+"file": created, file, user(1000) group(2000), 0644
+`,
+               },
+
+               {
+                       "escaping",
+                       false,
+                       []safcm.FileChange{
+                               {
+                                       Path:    "\x00",
+                                       Created: true,
+                                       New: safcm.FileChangeInfo{
+                                               Mode:  0xFFFFFFFF,
+                                               User:  "\x01",
+                                               Uid:   -1,
+                                               Group: "\x02",
+                                               Gid:   -2,
+                                       },
+                                       DataDiff: "\x03",
+                               },
+                               {
+                                       Path: "\x00",
+                                       Old: safcm.FileChangeInfo{
+                                               Mode:  0x00000000,
+                                               User:  "\x01",
+                                               Uid:   -1,
+                                               Group: "\x02",
+                                               Gid:   -2,
+                                       },
+                                       New: safcm.FileChangeInfo{
+                                               Mode:  0xFFFFFFFF,
+                                               User:  "\x03",
+                                               Uid:   -3,
+                                               Group: "\x04",
+                                               Gid:   -4,
+                                       },
+                                       DataDiff: "\x05",
+                               },
+                       },
+                       `changed 2 file(s):
+"\x00": created, invalid type dLDpSc?---------, \x01(-1) \x02(-2), 07777
+   \x03
+   \ No newline at end of file
+"\x00": file -> invalid type dLDpSc?---------, \x01(-1) \x02(-2) -> \x03(-3) \x04(-4), 0 -> 07777
+   \x05
+   \ No newline at end of file
+`,
+               },
+       }
+
+       for _, tc := range tests {
+               s := &Sync{
+                       config: &config.Config{
+                               DryRun: tc.dryRun,
+                       },
+               }
+
+               res := s.formatFileChanges(tc.changes)
+               if tc.exp != res {
+                       t.Errorf("%s: res: %s", tc.name,
+                               cmp.Diff(tc.exp, res))
+               }
+       }
+}
+
+func TestFormatPackageChanges(t *testing.T) {
+       tests := []struct {
+               name    string
+               dryRun  bool
+               changes []safcm.PackageChange
+               exp     string
+       }{
+
+               {
+                       "regular",
+                       false,
+                       []safcm.PackageChange{
+                               {
+                                       Name: "package-one",
+                               },
+                               {
+                                       Name: "package-two",
+                               },
+                       },
+                       `installed 2 package(s):
+"package-one"
+"package-two"
+`,
+               },
+
+               {
+                       "dry-run",
+                       true,
+                       []safcm.PackageChange{
+                               {
+                                       Name: "package-one",
+                               },
+                               {
+                                       Name: "package-two",
+                               },
+                       },
+                       `installed 2 package(s): (dry-run)
+"package-one"
+"package-two"
+`,
+               },
+
+               {
+                       "escaping",
+                       false,
+                       []safcm.PackageChange{
+                               {
+                                       Name: "\x00",
+                               },
+                       },
+                       `installed 1 package(s):
+"\x00"
+`,
+               },
+       }
+
+       for _, tc := range tests {
+               s := &Sync{
+                       config: &config.Config{
+                               DryRun: tc.dryRun,
+                       },
+               }
+
+               res := s.formatPackageChanges(tc.changes)
+               if tc.exp != res {
+                       t.Errorf("%s: res: %s", tc.name,
+                               cmp.Diff(tc.exp, res))
+               }
+       }
+}
+
+func TestFormatServiceChanges(t *testing.T) {
+       tests := []struct {
+               name    string
+               dryRun  bool
+               changes []safcm.ServiceChange
+               exp     string
+       }{
+
+               {
+                       "regular",
+                       false,
+                       []safcm.ServiceChange{
+                               {
+                                       Name:    "service-one",
+                                       Started: true,
+                               },
+                               {
+                                       Name:    "service-two",
+                                       Enabled: true,
+                               },
+                               {
+                                       Name:    "service-three",
+                                       Started: true,
+                                       Enabled: true,
+                               },
+                       },
+                       `modified 3 service(s):
+"service-one": started
+"service-two": enabled
+"service-three": started, enabled
+`,
+               },
+
+               {
+                       "dry-run",
+                       true,
+                       []safcm.ServiceChange{
+                               {
+                                       Name:    "service-one",
+                                       Started: true,
+                               },
+                               {
+                                       Name:    "service-two",
+                                       Enabled: true,
+                               },
+                               {
+                                       Name:    "service-three",
+                                       Started: true,
+                                       Enabled: true,
+                               },
+                       },
+                       `modified 3 service(s): (dry-run)
+"service-one": started
+"service-two": enabled
+"service-three": started, enabled
+`,
+               },
+
+               {
+                       "escaping",
+                       false,
+                       []safcm.ServiceChange{
+                               {
+                                       Name: "\x00",
+                               },
+                               {
+                                       Name:    "\x01",
+                                       Started: true,
+                                       Enabled: true,
+                               },
+                       },
+                       `modified 2 service(s):
+"\x00": 
+"\x01": started, enabled
+`,
+               },
+       }
+
+       for _, tc := range tests {
+               s := &Sync{
+                       config: &config.Config{
+                               DryRun: tc.dryRun,
+                       },
+               }
+
+               res := s.formatServiceChanges(tc.changes)
+               if tc.exp != res {
+                       t.Errorf("%s: res: %s", tc.name,
+                               cmp.Diff(tc.exp, res))
+               }
+       }
+}
+
+func TestFormatCommandChanges(t *testing.T) {
+       tests := []struct {
+               name    string
+               dryRun  bool
+               changes []safcm.CommandChange
+               exp     string
+       }{
+
+               {
+                       "regular",
+                       false,
+                       []safcm.CommandChange{
+                               {
+                                       Command: "fake command",
+                                       Output:  "fake output",
+                               },
+                               {
+                                       Command: "fake command with no output",
+                               },
+                               {
+                                       Command: "fake command with newline",
+                                       Output:  "fake output\n",
+                               },
+                               {
+                                       Command: "fake command with more output",
+                                       Output:  "fake out\nfake put\nfake\n",
+                               },
+                               {
+                                       Command: "fake failed command",
+                                       Output:  "fake output",
+                                       Error:   "fake error",
+                               },
+                       },
+                       `executed 5 command(s):
+"fake command":
+   > fake output
+   > \ No newline at end of file
+"fake command with no output"
+"fake command with newline":
+   > fake output
+"fake command with more output":
+   > fake out
+   > fake put
+   > fake
+"fake failed command", failed: "fake error":
+   > fake output
+   > \ No newline at end of file
+`,
+               },
+
+               {
+                       "dry-run",
+                       true,
+                       []safcm.CommandChange{
+                               {
+                                       Command: "fake command",
+                                       Output:  "fake output",
+                               },
+                       },
+                       `executed 1 command(s): (dry-run)
+"fake command":
+   > fake output
+   > \ No newline at end of file
+`,
+               },
+
+               {
+                       "escaping",
+                       false,
+                       []safcm.CommandChange{
+                               {
+                                       Command: "\x00",
+                                       Trigger: "\x01",
+                                       Output:  "\x02",
+                                       Error:   "\x03",
+                               },
+                       },
+                       `executed 1 command(s):
+"\x00", trigger for "\x01", failed: "\x03":
+   > \x02
+   > \ No newline at end of file
+`,
+               },
+       }
+
+       for _, tc := range tests {
+               s := &Sync{
+                       config: &config.Config{
+                               DryRun: tc.dryRun,
+                       },
+               }
+
+               res := s.formatCommandChanges(tc.changes)
+               if tc.exp != res {
+                       t.Errorf("%s: res: %s", tc.name,
+                               cmp.Diff(tc.exp, res))
+               }
+       }
+}
diff --git a/cmd/safcm/sync_info.go b/cmd/safcm/sync_info.go
new file mode 100644 (file)
index 0000000..b0a85ee
--- /dev/null
@@ -0,0 +1,63 @@
+// "sync" sub-command: collect information from remote host
+
+// Copyright (C) 2021  Simon Ruderich
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+package main
+
+import (
+       "fmt"
+       "regexp"
+       "strings"
+
+       "ruderich.org/simon/safcm"
+       "ruderich.org/simon/safcm/cmd/safcm/config"
+       "ruderich.org/simon/safcm/rpc"
+)
+
+func (s *Sync) hostInfo(conn *rpc.Conn) ([]string, error) {
+       x, err := s.sendRecv(conn, safcm.MsgInfoReq{
+               LogLevel:     s.config.LogLevel,
+               DetectGroups: s.config.DetectGroups,
+       })
+       if err != nil {
+               return nil, err
+       }
+       resp, ok := x.(safcm.MsgInfoResp)
+       if !ok {
+               return nil, fmt.Errorf("unexpected response %v", x)
+       }
+       if resp.Error != "" {
+               return nil, fmt.Errorf("%s", resp.Error)
+       }
+       return hostInfoRespToGroups(resp), nil
+}
+
+// Keep in sync with config/groups.go:groupNameRegexp
+var infoGroupDetectedRegexp = regexp.MustCompile(`[^a-z0-9_-]+`)
+
+func hostInfoRespToGroups(resp safcm.MsgInfoResp) []string {
+       groups := []string{
+               config.GroupDetectedPrefix + "_" + resp.Goos,
+               config.GroupDetectedPrefix + "_" + resp.Goarch,
+       }
+       for _, x := range resp.Output {
+               x = strings.TrimSpace(x)
+               x = strings.ToLower(x)
+               x = infoGroupDetectedRegexp.ReplaceAllString(x, "_")
+               groups = append(groups, config.GroupDetectedPrefix+"_"+x)
+       }
+       return groups
+}
diff --git a/cmd/safcm/sync_info_test.go b/cmd/safcm/sync_info_test.go
new file mode 100644 (file)
index 0000000..b883bb7
--- /dev/null
@@ -0,0 +1,77 @@
+// Copyright (C) 2021  Simon Ruderich
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+package main
+
+import (
+       "reflect"
+       "testing"
+
+       "github.com/google/go-cmp/cmp"
+
+       "ruderich.org/simon/safcm"
+)
+
+func TestHostInfoRespToGroups(t *testing.T) {
+       tests := []struct {
+               name string
+               resp safcm.MsgInfoResp
+               exp  []string
+       }{
+
+               {
+                       "no output",
+                       safcm.MsgInfoResp{
+                               Goos:   "linux",
+                               Goarch: "amd64",
+                               Output: nil,
+                       },
+                       []string{
+                               "detected_linux",
+                               "detected_amd64",
+                       },
+               },
+
+               {
+                       "output",
+                       safcm.MsgInfoResp{
+                               Goos:   "linux",
+                               Goarch: "amd64",
+                               Output: []string{
+                                       "simple",
+                                       "with spaces",
+                                       "with UPPERcase",
+                                       "with UTF-8: Hello, ä¸–ç•Œ",
+                               },
+                       },
+                       []string{
+                               "detected_linux",
+                               "detected_amd64",
+                               "detected_simple",
+                               "detected_with_spaces",
+                               "detected_with_uppercase",
+                               "detected_with_utf-8_hello_",
+                       },
+               },
+       }
+
+       for _, tc := range tests {
+               res := hostInfoRespToGroups(tc.resp)
+               if !reflect.DeepEqual(tc.exp, res) {
+                       t.Errorf("%s: res: %s", tc.name,
+                               cmp.Diff(tc.exp, res))
+               }
+       }
+}
diff --git a/cmd/safcm/sync_sync.go b/cmd/safcm/sync_sync.go
new file mode 100644 (file)
index 0000000..e84b7f4
--- /dev/null
@@ -0,0 +1,290 @@
+// "sync" sub-command: sync files
+
+// Copyright (C) 2021  Simon Ruderich
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+package main
+
+import (
+       "fmt"
+       "os"
+       "path/filepath"
+       "sort"
+       "strings"
+
+       "ruderich.org/simon/safcm"
+       "ruderich.org/simon/safcm/cmd/safcm/config"
+       "ruderich.org/simon/safcm/rpc"
+)
+
+func (s *Sync) hostSync(conn *rpc.Conn, detectedGroups []string) error {
+       req, err := s.hostSyncReq(detectedGroups)
+       if err != nil {
+               return err
+       }
+       x, err := s.sendRecv(conn, req)
+       if err != nil {
+               return err
+       }
+       resp, ok := x.(safcm.MsgSyncResp)
+       if !ok {
+               return fmt.Errorf("unexpected response %v", x)
+       }
+
+       // Display changes
+       var changes []string
+       if len(resp.FileChanges) > 0 {
+               changes = append(changes,
+                       s.formatFileChanges(resp.FileChanges))
+       }
+       if len(resp.PackageChanges) > 0 {
+               changes = append(changes,
+                       s.formatPackageChanges(resp.PackageChanges))
+       }
+       if len(resp.ServiceChanges) > 0 {
+               changes = append(changes,
+                       s.formatServiceChanges(resp.ServiceChanges))
+       }
+       if len(resp.CommandChanges) > 0 {
+               changes = append(changes,
+                       s.formatCommandChanges(resp.CommandChanges))
+       }
+       if len(changes) > 0 {
+               s.logf(safcm.LogInfo, true, "%s",
+                       "\n"+strings.Join(changes, "\n"))
+       }
+
+       if resp.Error != "" {
+               return fmt.Errorf("%s", resp.Error)
+       }
+       return nil
+}
+
+func (s *Sync) hostSyncReq(detectedGroups []string) (
+       safcm.MsgSyncReq, error) {
+
+       var empty safcm.MsgSyncReq
+
+       groups, groupPriority, err := s.resolveHostGroups(detectedGroups)
+       if err != nil {
+               return empty, err
+       }
+       {
+               // Don't leak internal group order which is confusing without
+               // knowing the implementation details.
+               groupsSorted := make([]string, len(groups))
+               copy(groupsSorted, groups)
+               sort.Strings(groupsSorted)
+               s.logVerbosef("host groups: %s",
+                       strings.Join(groupsSorted, " "))
+
+               // Don't leak internal priority values. Instead, order groups
+               // by priority.
+               var priorities []string
+               for x := range groupPriority {
+                       priorities = append(priorities, x)
+               }
+               sort.Slice(priorities, func(i, j int) bool {
+                       a := priorities[i]
+                       b := priorities[j]
+                       return groupPriority[a] < groupPriority[b]
+               })
+               s.logVerbosef("host group priorities (desc. order): %v",
+                       strings.Join(priorities, " "))
+       }
+
+       allFiles := make(map[string]*safcm.File)
+       allPackagesMap := make(map[string]bool) // map to deduplicate
+       allServicesMap := make(map[string]bool) // map to deduplicate
+       var allCommands []string
+
+       for _, group := range groups {
+               // Skip non-existent group directories
+               _, err := os.Stat(group)
+               if os.IsNotExist(err) {
+                       continue
+               }
+
+               files, err := config.LoadFiles(group)
+               if err != nil {
+                       return empty, err
+               }
+               err = config.LoadPermissions(group, files)
+               if err != nil {
+                       return empty, err
+               }
+               err = config.LoadTemplates(group, files,
+                       s.host.Name, groups, s.allHosts, s.allGroups)
+               if err != nil {
+                       return empty, err
+               }
+               err = config.LoadTriggers(group, files)
+               if err != nil {
+                       return empty, err
+               }
+               for k, v := range files {
+                       err := s.checkFileConflict(group, k, v,
+                               allFiles, groupPriority)
+                       if err != nil {
+                               return empty, err
+                       }
+                       v.OrigGroup = group
+                       allFiles[k] = v
+               }
+
+               packages, err := config.LoadPackages(group)
+               if err != nil {
+                       return empty, err
+               }
+               for _, x := range packages {
+                       allPackagesMap[x] = true
+               }
+
+               services, err := config.LoadServices(group)
+               if err != nil {
+                       return empty, err
+               }
+               for _, x := range services {
+                       allServicesMap[x] = true
+               }
+
+               commands, err := config.LoadCommands(group)
+               if err != nil {
+                       return empty, err
+               }
+               allCommands = append(allCommands, commands...)
+       }
+
+       resolveFileDirConflicts(allFiles)
+
+       var allPackages []string
+       var allServices []string
+       for x := range allPackagesMap {
+               allPackages = append(allPackages, x)
+       }
+       for x := range allServicesMap {
+               allServices = append(allServices, x)
+       }
+       // Sort for deterministic results
+       sort.Strings(allPackages)
+       sort.Strings(allServices)
+
+       return safcm.MsgSyncReq{
+               DryRun:   s.config.DryRun,
+               Groups:   groups,
+               Files:    allFiles,
+               Packages: allPackages,
+               Services: allServices,
+               Commands: allCommands,
+       }, nil
+}
+
+// resolveHostGroups returns the groups and group priorities of the current
+// host.
+func (s *Sync) resolveHostGroups(detectedGroups []string) (
+       []string, map[string]int, error) {
+
+       groups, err := config.ResolveHostGroups(s.host.Name,
+               s.allGroups, detectedGroups)
+       if err != nil {
+               return nil, nil, err
+       }
+
+       // Early entries have higher priorities
+       groupPriority := make(map[string]int)
+       for i, x := range s.config.GroupOrder {
+               groupPriority[x] = i + 1
+       }
+       // Host itself always has highest priority
+       groupPriority[s.host.Name] = -1
+
+       // Sort groups after priority and name
+       sort.Slice(groups, func(i, j int) bool {
+               a := groups[i]
+               b := groups[j]
+               if groupPriority[a] > groupPriority[b] {
+                       return true
+               } else if groupPriority[a] < groupPriority[b] {
+                       return false
+               } else {
+                       return a < b
+               }
+       })
+
+       return groups, groupPriority, nil
+}
+
+func (s *Sync) checkFileConflict(group string, path string, file *safcm.File,
+       allFiles map[string]*safcm.File, groupPriority map[string]int) error {
+
+       old, ok := allFiles[path]
+       if !ok {
+               return nil
+       }
+
+       newPrio := groupPriority[group]
+       oldPrio := groupPriority[old.OrigGroup]
+       if oldPrio > newPrio {
+               if old.Mode.IsDir() && file.Mode.IsDir() &&
+                       old.TriggerCommands != nil {
+                       s.logDebugf("files: %q: "+
+                               "group %s overwrites triggers from group %s",
+                               path, group, old.OrigGroup)
+               }
+               return nil
+       } else if oldPrio < newPrio {
+               // Should never happen, groups are sorted by priority
+               panic("invalid group priorities")
+       }
+
+       // Directories with default permissions and no triggers do not count
+       // as conflict
+       if file.Mode.IsDir() && file.Mode == old.Mode &&
+               config.FileModeToFullPerm(file.Mode) == 0755 &&
+               file.TriggerCommands == nil && old.TriggerCommands == nil {
+               return nil
+       }
+
+       return fmt.Errorf("groups %s and %s both provide file %q\n"+
+               "Use 'group_order' in config.yaml to declare preference",
+               group, old.OrigGroup, path)
+}
+
+func resolveFileDirConflicts(files map[string]*safcm.File) {
+       var paths []string
+       for x := range files {
+               paths = append(paths, x)
+       }
+       sort.Slice(paths, func(i, j int) bool {
+               return paths[i] < paths[j]
+       })
+
+       const sep = string(filepath.Separator)
+
+       // Remove invalid paths which can result from group_order overriding
+       // paths from another group (e.g. "/foo" as file from one group and
+       // "/foo/bar" from another).
+       var last *safcm.File
+       for _, x := range paths {
+               file := files[x]
+               if last != nil &&
+                       !last.Mode.IsDir() &&
+                       strings.HasPrefix(file.Path, last.Path+sep) {
+                       delete(files, x)
+                       continue
+               }
+               last = file
+       }
+}
diff --git a/cmd/safcm/sync_sync_test.go b/cmd/safcm/sync_sync_test.go
new file mode 100644 (file)
index 0000000..f6aafd5
--- /dev/null
@@ -0,0 +1,489 @@
+// Copyright (C) 2021  Simon Ruderich
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+package main
+
+import (
+       "fmt"
+       "io"
+       "io/fs"
+       "log"
+       "os"
+       "path/filepath"
+       "reflect"
+       "testing"
+
+       "github.com/google/go-cmp/cmp"
+
+       "ruderich.org/simon/safcm"
+)
+
+func TestHostSyncReq(t *testing.T) {
+       cwd, err := os.Getwd()
+       if err != nil {
+               t.Fatal(err)
+       }
+       defer os.Chdir(cwd)
+
+       tests := []struct {
+               name      string
+               project   string
+               host      string
+               detected  []string
+               level     safcm.LogLevel
+               exp       safcm.MsgSyncReq
+               expEvents []string
+               expErr    error
+       }{
+
+               // NOTE: Also update MsgSyncReq in safcm-remote test cases
+               // when changing anything here!
+
+               {
+                       "project: host1",
+                       "project",
+                       "host1.example.org",
+                       nil,
+                       safcm.LogDebug3,
+                       safcm.MsgSyncReq{
+                               Groups: []string{
+                                       "all",
+                                       "group",
+                                       "remove",
+                                       "host1.example.org",
+                               },
+                               Files: map[string]*safcm.File{
+                                       "/": &safcm.File{Path: "/",
+                                               OrigGroup: "group",
+                                               Mode:      fs.ModeDir | 0755 | fs.ModeSetgid,
+                                               Uid:       -1,
+                                               Gid:       -1,
+                                               TriggerCommands: []string{
+                                                       "touch /.update",
+                                               },
+                                       },
+                                       "/etc": &safcm.File{
+                                               OrigGroup: "group",
+                                               Path:      "/etc",
+                                               Mode:      fs.ModeDir | 0755,
+                                               Uid:       -1,
+                                               Gid:       -1,
+                                       },
+                                       "/etc/.hidden": &safcm.File{
+                                               OrigGroup: "group",
+                                               Path:      "/etc/.hidden",
+                                               Mode:      0100 | fs.ModeSetuid | fs.ModeSetgid | fs.ModeSticky,
+                                               Uid:       -1,
+                                               Gid:       -1,
+                                               Data:      []byte("..."),
+                                       },
+                                       "/etc/motd": &safcm.File{
+                                               OrigGroup: "group",
+                                               Path:      "/etc/motd",
+                                               Mode:      0644,
+                                               Uid:       -1,
+                                               Gid:       -1,
+                                               Data:      []byte("Welcome to Host ONE\n\n\n\n"),
+                                       },
+                                       "/etc/rc.local": &safcm.File{
+                                               OrigGroup: "group",
+                                               Path:      "/etc/rc.local",
+                                               Mode:      0700,
+                                               Uid:       -1,
+                                               Gid:       -1,
+                                               Data:      []byte("#!/bin/sh\n"),
+                                               TriggerCommands: []string{
+                                                       "/etc/rc.local",
+                                               },
+                                       },
+                                       "/etc/resolv.conf": &safcm.File{
+                                               OrigGroup: "group",
+                                               Path:      "/etc/resolv.conf",
+                                               Mode:      0641,
+                                               User:      "user",
+                                               Uid:       -1,
+                                               Group:     "group",
+                                               Gid:       -1,
+                                               Data:      []byte("nameserver ::1\n"),
+                                               TriggerCommands: []string{
+                                                       "echo resolv.conf updated",
+                                               },
+                                       },
+                                       "/etc/test": &safcm.File{
+                                               OrigGroup: "group",
+                                               Path:      "/etc/test",
+                                               Mode:      os.ModeSymlink | 0777,
+                                               Uid:       -1,
+                                               Gid:       -1,
+                                               Data:      []byte("doesnt-exist"),
+                                       },
+                               },
+                               Packages: []string{
+                                       "unbound",
+                                       "unbound-anchor",
+                               },
+                               Services: []string{
+                                       "unbound",
+                               },
+                               Commands: []string{
+                                       "echo command one",
+                                       "echo -n command two",
+                               },
+                       },
+                       []string{
+                               "host1.example.org: <nil> 3 host groups: all group host1.example.org remove",
+                               "host1.example.org: <nil> 3 host group priorities (desc. order): host1.example.org",
+                       },
+                       nil,
+               },
+
+               {
+                       "project: host1 (log level info)",
+                       "project",
+                       "host1.example.org",
+                       nil,
+                       safcm.LogInfo,
+                       safcm.MsgSyncReq{
+                               Groups: []string{
+                                       "all",
+                                       "group",
+                                       "remove",
+                                       "host1.example.org",
+                               },
+                               Files: map[string]*safcm.File{
+                                       "/": &safcm.File{Path: "/",
+                                               OrigGroup: "group",
+                                               Mode:      fs.ModeDir | 0755 | fs.ModeSetgid,
+                                               Uid:       -1,
+                                               Gid:       -1,
+                                               TriggerCommands: []string{
+                                                       "touch /.update",
+                                               },
+                                       },
+                                       "/etc": &safcm.File{
+                                               OrigGroup: "group",
+                                               Path:      "/etc",
+                                               Mode:      fs.ModeDir | 0755,
+                                               Uid:       -1,
+                                               Gid:       -1,
+                                       },
+                                       "/etc/.hidden": &safcm.File{
+                                               OrigGroup: "group",
+                                               Path:      "/etc/.hidden",
+                                               Mode:      0100 | fs.ModeSetuid | fs.ModeSetgid | fs.ModeSticky,
+                                               Uid:       -1,
+                                               Gid:       -1,
+                                               Data:      []byte("..."),
+                                       },
+                                       "/etc/motd": &safcm.File{
+                                               OrigGroup: "group",
+                                               Path:      "/etc/motd",
+                                               Mode:      0644,
+                                               Uid:       -1,
+                                               Gid:       -1,
+                                               Data:      []byte("Welcome to Host ONE\n\n\n\n"),
+                                       },
+                                       "/etc/rc.local": &safcm.File{
+                                               OrigGroup: "group",
+                                               Path:      "/etc/rc.local",
+                                               Mode:      0700,
+                                               Uid:       -1,
+                                               Gid:       -1,
+                                               Data:      []byte("#!/bin/sh\n"),
+                                               TriggerCommands: []string{
+                                                       "/etc/rc.local",
+                                               },
+                                       },
+                                       "/etc/resolv.conf": &safcm.File{
+                                               OrigGroup: "group",
+                                               Path:      "/etc/resolv.conf",
+                                               Mode:      0641,
+                                               User:      "user",
+                                               Uid:       -1,
+                                               Group:     "group",
+                                               Gid:       -1,
+                                               Data:      []byte("nameserver ::1\n"),
+                                               TriggerCommands: []string{
+                                                       "echo resolv.conf updated",
+                                               },
+                                       },
+                                       "/etc/test": &safcm.File{
+                                               OrigGroup: "group",
+                                               Path:      "/etc/test",
+                                               Mode:      os.ModeSymlink | 0777,
+                                               Uid:       -1,
+                                               Gid:       -1,
+                                               Data:      []byte("doesnt-exist"),
+                                       },
+                               },
+                               Packages: []string{
+                                       "unbound",
+                                       "unbound-anchor",
+                               },
+                               Services: []string{
+                                       "unbound",
+                               },
+                               Commands: []string{
+                                       "echo command one",
+                                       "echo -n command two",
+                               },
+                       },
+                       nil,
+                       nil,
+               },
+
+               {
+                       "conflict: file",
+                       "project-conflict-file",
+                       "host1.example.org",
+                       nil,
+                       safcm.LogDebug3,
+                       safcm.MsgSyncReq{},
+                       []string{
+                               "host1.example.org: <nil> 3 host groups: all dns host1.example.org",
+                               "host1.example.org: <nil> 3 host group priorities (desc. order): host1.example.org",
+                       },
+                       fmt.Errorf("groups dns and all both provide file \"/etc/resolv.conf\"\nUse 'group_order' in config.yaml to declare preference"),
+               },
+               {
+                       "conflict: file from detected group",
+                       "project-conflict-file",
+                       "host2.example.org",
+                       []string{
+                               "detected_other",
+                       },
+                       safcm.LogDebug3,
+                       safcm.MsgSyncReq{},
+                       []string{
+                               "host2.example.org: <nil> 3 host groups: all detected_other host2.example.org other",
+                               "host2.example.org: <nil> 3 host group priorities (desc. order): host2.example.org",
+                       },
+                       fmt.Errorf("groups other and all both provide file \"/etc/resolv.conf\"\nUse 'group_order' in config.yaml to declare preference"),
+               },
+
+               {
+                       "conflict: dir",
+                       "project-conflict-dir",
+                       "host1.example.org",
+                       nil,
+                       safcm.LogDebug3,
+                       safcm.MsgSyncReq{},
+                       []string{
+                               "host1.example.org: <nil> 3 host groups: all dns host1.example.org",
+                               "host1.example.org: <nil> 3 host group priorities (desc. order): host1.example.org",
+                       },
+                       fmt.Errorf("groups dns and all both provide file \"/etc\"\nUse 'group_order' in config.yaml to declare preference"),
+               },
+               {
+                       "conflict: dir from detected group",
+                       "project-conflict-dir",
+                       "host2.example.org",
+                       []string{
+                               "detected_other",
+                       },
+                       safcm.LogDebug3,
+                       safcm.MsgSyncReq{},
+                       []string{
+                               "host2.example.org: <nil> 3 host groups: all detected_other host2.example.org other",
+                               "host2.example.org: <nil> 3 host group priorities (desc. order): host2.example.org",
+                       },
+                       fmt.Errorf("groups other and all both provide file \"/etc\"\nUse 'group_order' in config.yaml to declare preference"),
+               },
+
+               {
+                       "group: cycle",
+                       "project-group-cycle",
+                       "host1.example.org",
+                       nil,
+                       safcm.LogDebug3,
+                       safcm.MsgSyncReq{},
+                       nil,
+                       fmt.Errorf("groups.yaml: cycle while expanding group \"group-b\""),
+               },
+
+               {
+                       "group_order",
+                       "project-group_order",
+                       "host1.example.org",
+                       nil,
+                       safcm.LogDebug3,
+                       safcm.MsgSyncReq{
+                               Groups: []string{"all", "group-b", "group-a", "host1.example.org"},
+                               Files: map[string]*safcm.File{
+                                       "/": {
+                                               Path:      "/",
+                                               Mode:      fs.ModeDir | 0755,
+                                               Uid:       -1,
+                                               Gid:       -1,
+                                               OrigGroup: "host1.example.org",
+                                       },
+                                       "/etc": {
+                                               Path:      "/etc",
+                                               Mode:      fs.ModeDir | 0755,
+                                               Uid:       -1,
+                                               Gid:       -1,
+                                               OrigGroup: "host1.example.org",
+                                       },
+                                       "/etc/dir-to-file": {
+                                               Path:      "/etc/dir-to-file",
+                                               Mode:      0644,
+                                               Uid:       -1,
+                                               Gid:       -1,
+                                               Data:      []byte("dir-to-file: from group-a\n"),
+                                               OrigGroup: "group-a",
+                                       },
+                                       "/etc/dir-to-filex": {
+                                               OrigGroup: "group-b",
+                                               Path:      "/etc/dir-to-filex",
+                                               Mode:      0644,
+                                               Uid:       -1,
+                                               Gid:       -1,
+                                               Data:      []byte("dir-to-filex\n"),
+                                       },
+                                       "/etc/dir-to-link": {
+                                               Path:      "/etc/dir-to-link",
+                                               Mode:      fs.ModeSymlink | 0777,
+                                               Uid:       -1,
+                                               Gid:       -1,
+                                               Data:      []byte("target"),
+                                               OrigGroup: "group-a",
+                                       },
+                                       "/etc/dir-to-linkx": {
+                                               OrigGroup: "group-b",
+                                               Path:      "/etc/dir-to-linkx",
+                                               Mode:      0644,
+                                               Uid:       -1,
+                                               Gid:       -1,
+                                               Data:      []byte("dir-to-linkx\n"),
+                                       },
+                                       "/etc/file-to-dir": {
+                                               Path:      "/etc/file-to-dir",
+                                               Mode:      fs.ModeDir | 0755,
+                                               Uid:       -1,
+                                               Gid:       -1,
+                                               OrigGroup: "group-a",
+                                       },
+                                       "/etc/file-to-dir/file": {
+                                               Path:      "/etc/file-to-dir/file",
+                                               Mode:      0644,
+                                               Uid:       -1,
+                                               Gid:       -1,
+                                               Data:      []byte("file: from group-a\n"),
+                                               OrigGroup: "group-a",
+                                       },
+                                       "/etc/file-to-dir/dir": {
+                                               Path:      "/etc/file-to-dir/dir",
+                                               Mode:      fs.ModeDir | 0755,
+                                               Uid:       -1,
+                                               Gid:       -1,
+                                               OrigGroup: "group-a",
+                                       },
+                                       "/etc/file-to-dir/dir/file2": {
+                                               Path:      "/etc/file-to-dir/dir/file2",
+                                               Mode:      0644,
+                                               Uid:       -1,
+                                               Gid:       -1,
+                                               Data:      []byte("file2: from group-a\n"),
+                                               OrigGroup: "group-a",
+                                       },
+                                       "/etc/motd": {
+                                               Path:      "/etc/motd",
+                                               Mode:      0644,
+                                               Uid:       -1,
+                                               Gid:       -1,
+                                               Data:      []byte("motd: from host1\n"),
+                                               OrigGroup: "host1.example.org",
+                                       },
+                               },
+                       },
+                       []string{
+                               "host1.example.org: <nil> 3 host groups: all group-a group-b host1.example.org",
+                               "host1.example.org: <nil> 3 host group priorities (desc. order): host1.example.org group-a group-b all",
+                               `host1.example.org: <nil> 4 files: "/etc": group group-a overwrites triggers from group group-b`,
+                               `host1.example.org: <nil> 4 files: "/etc": group host1.example.org overwrites triggers from group group-a`,
+                       },
+                       nil,
+               },
+       }
+
+       for _, tc := range tests {
+               err = os.Chdir(filepath.Join(cwd, "testdata", tc.project))
+               if err != nil {
+                       t.Fatal(err)
+               }
+
+               // `safcm fixperms` in case user has strict umask
+               log.SetOutput(io.Discard)
+               err := MainFixperms()
+               if err != nil {
+                       t.Fatal(err)
+               }
+               log.SetOutput(os.Stderr)
+
+               cfg, allHosts, allGroups, err := LoadBaseFiles()
+               if err != nil {
+                       t.Fatal(err)
+               }
+               cfg.LogLevel = tc.level
+
+               var events []string
+               ch := make(chan Event)
+               done := make(chan struct{})
+               go func() {
+                       for {
+                               x, ok := <-ch
+                               if !ok {
+                                       break
+                               }
+                               if x.ConnEvent.Type != 0 {
+                                       panic("unexpected ConnEvent")
+                               }
+                               events = append(events,
+                                       fmt.Sprintf("%s: %v %d %s",
+                                               x.Host.Name,
+                                               x.Error, x.Log.Level,
+                                               x.Log.Text))
+                       }
+                       done <- struct{}{}
+               }()
+
+               s := &Sync{
+                       host:      allHosts.Map[tc.host],
+                       config:    cfg,
+                       allHosts:  allHosts,
+                       allGroups: allGroups,
+                       events:    ch,
+               }
+
+               res, err := s.hostSyncReq(tc.detected)
+               if !reflect.DeepEqual(tc.exp, res) {
+                       t.Errorf("%s: res: %s", tc.name,
+                               cmp.Diff(tc.exp, res))
+               }
+               // Ugly but the simplest way to compare errors (including nil)
+               if fmt.Sprintf("%s", err) != fmt.Sprintf("%s", tc.expErr) {
+                       t.Errorf("%s: err = %#v, want %#v",
+                               tc.name, err, tc.expErr)
+               }
+
+               close(ch)
+               <-done
+               if !reflect.DeepEqual(tc.expEvents, events) {
+                       t.Errorf("%s: events: %s", tc.name,
+                               cmp.Diff(tc.expEvents, events))
+               }
+
+       }
+}
diff --git a/cmd/safcm/sync_test.go b/cmd/safcm/sync_test.go
new file mode 100644 (file)
index 0000000..dba1d7e
--- /dev/null
@@ -0,0 +1,180 @@
+// Copyright (C) 2021  Simon Ruderich
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+package main
+
+import (
+       "fmt"
+       "os"
+       "reflect"
+       "testing"
+
+       "github.com/google/go-cmp/cmp"
+
+       "ruderich.org/simon/safcm/cmd/safcm/config"
+)
+
+func TestHostsToSync(t *testing.T) {
+       cwd, err := os.Getwd()
+       if err != nil {
+               t.Fatal(err)
+       }
+       defer os.Chdir(cwd)
+
+       err = os.Chdir("testdata/project")
+       if err != nil {
+               t.Fatal(err)
+       }
+       _, allHosts, allGroups, err := LoadBaseFiles()
+       if err != nil {
+               t.Fatal(err)
+       }
+
+       tests := []struct {
+               name   string
+               names  []string
+               exp    []*config.Host
+               expErr error
+       }{
+               {
+                       "empty names",
+                       nil,
+                       nil,
+                       nil,
+               },
+
+               {
+                       "no match",
+                       []string{"unknown-host/group"},
+                       nil,
+                       fmt.Errorf("hosts/groups not found: \"unknown-host/group\""),
+               },
+
+               {
+                       "host: single name",
+                       []string{"host2"},
+                       []*config.Host{
+                               allHosts.Map["host2"],
+                       },
+                       nil,
+               },
+               {
+                       "host: multiple names",
+                       []string{"host2", "host1.example.org"},
+                       []*config.Host{
+                               allHosts.Map["host1.example.org"],
+                               allHosts.Map["host2"],
+                       },
+                       nil,
+               },
+               {
+                       "host: multiple identical names",
+                       []string{"host2", "host2"},
+                       []*config.Host{
+                               allHosts.Map["host2"],
+                       },
+                       nil,
+               },
+               {
+                       "host: multiple names, including unknown",
+                       []string{"host2", "unknown-host"},
+                       nil,
+                       fmt.Errorf("hosts/groups not found: \"unknown-host\""),
+               },
+               {
+                       "host: multiple names, including unknowns",
+                       []string{"host2", "unknown-host", "unknown-host-2"},
+                       nil,
+                       fmt.Errorf("hosts/groups not found: \"unknown-host\" \"unknown-host-2\""),
+               },
+
+               {
+                       "group: single name",
+                       []string{"group"},
+                       []*config.Host{
+                               allHosts.Map["host1.example.org"],
+                       },
+                       nil,
+               },
+               {
+                       "group: multiple names",
+                       []string{"group", "group2"},
+                       []*config.Host{
+                               allHosts.Map["host1.example.org"],
+                               allHosts.Map["host2"],
+                       },
+                       nil,
+               },
+               {
+                       "group: multiple identical names",
+                       []string{"group", "group2", "group"},
+                       []*config.Host{
+                               allHosts.Map["host1.example.org"],
+                               allHosts.Map["host2"],
+                       },
+                       nil,
+               },
+               {
+                       "group: multiple names, including unknown",
+                       []string{"group", "group2", "unknown-group"},
+                       nil,
+                       fmt.Errorf("hosts/groups not found: \"unknown-group\""),
+               },
+               {
+                       "group: \"all\"",
+                       []string{"all"},
+                       []*config.Host{
+                               allHosts.Map["host1.example.org"],
+                               allHosts.Map["host2"],
+                               allHosts.Map["host3.example.net"],
+                       },
+                       nil,
+               },
+
+               {
+                       "\"all\" and name",
+                       []string{"all", "group2"},
+                       []*config.Host{
+                               allHosts.Map["host1.example.org"],
+                               allHosts.Map["host2"],
+                               allHosts.Map["host3.example.net"],
+                       },
+                       nil,
+               },
+               {
+                       "\"all\" and names",
+                       []string{"all", "group2", "host2"},
+                       []*config.Host{
+                               allHosts.Map["host1.example.org"],
+                               allHosts.Map["host2"],
+                               allHosts.Map["host3.example.net"],
+                       },
+                       nil,
+               },
+       }
+
+       for _, tc := range tests {
+               res, err := hostsToSync(tc.names, allHosts, allGroups)
+               if !reflect.DeepEqual(tc.exp, res) {
+                       t.Errorf("%s: res: %s", tc.name,
+                               cmp.Diff(tc.exp, res))
+               }
+               // Ugly but the simplest way to compare errors (including nil)
+               if fmt.Sprintf("%s", err) != fmt.Sprintf("%s", tc.expErr) {
+                       t.Errorf("%s: err = %#v, want %#v",
+                               tc.name, err, tc.expErr)
+               }
+       }
+}
diff --git a/cmd/safcm/term.go b/cmd/safcm/term.go
new file mode 100644 (file)
index 0000000..d7568e4
--- /dev/null
@@ -0,0 +1,76 @@
+// Functions for terminal output
+
+// Copyright (C) 2021  Simon Ruderich
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+package main
+
+import (
+       "fmt"
+       "regexp"
+)
+
+type Color int
+
+const (
+       _ Color = iota
+       ColorRed
+       ColorGreen
+       ColorCyan
+       ColorMagenta
+)
+
+func ColorString(isTTY bool, color Color, x string) string {
+       if !isTTY {
+               return x
+       }
+
+       var code string
+       switch color {
+       case ColorRed:
+               code = "31"
+       case ColorGreen:
+               code = "32"
+       case ColorMagenta:
+               code = "35"
+       case ColorCyan:
+               code = "36"
+       default:
+               panic(fmt.Sprintf("invalid color %v", color))
+       }
+       // TODO: check terminal support
+       return "\033[" + code + "m" + x + "\033[0m"
+}
+
+var escapeRegexp = regexp.MustCompile(`[\x00-\x08\x0B-\x1F\x7F]`)
+
+// EscapeControlCharacters escapes all ASCII control characters (except
+// newline and tab) by replacing them with their hex value.
+//
+// This function must be used when displaying any input from remote hosts to
+// prevent terminal escape code injections.
+func EscapeControlCharacters(isTTY bool, x string) string {
+       return escapeRegexp.ReplaceAllStringFunc(x, func(x string) string {
+               if len(x) != 1 {
+                       panic("invalid escapeRegexp")
+               }
+               if x == "\r" {
+                       x = "\\r" // occurs often and more readable than \x0D
+               } else {
+                       x = fmt.Sprintf("\\x%02X", x[0])
+               }
+               return ColorString(isTTY, ColorMagenta, x)
+       })
+}
diff --git a/cmd/safcm/term_test.go b/cmd/safcm/term_test.go
new file mode 100644 (file)
index 0000000..63ad3c9
--- /dev/null
@@ -0,0 +1,91 @@
+// Copyright (C) 2021  Simon Ruderich
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+package main
+
+import (
+       "testing"
+
+       "github.com/google/go-cmp/cmp"
+)
+
+func TestEscapeControlCharacters(t *testing.T) {
+       tests := []struct {
+               name  string
+               isTTY bool
+               x     string
+               exp   string
+       }{
+               {
+                       "UTF-8",
+                       false,
+                       "Hello, ä¸–ç•Œ",
+                       "Hello, ä¸–ç•Œ",
+               },
+               {
+                       "UTF-8 (TTY)",
+                       true,
+                       "Hello, ä¸–ç•Œ",
+                       "Hello, ä¸–ç•Œ",
+               },
+
+               {
+                       "color",
+                       false,
+                       ColorString(true, ColorRed, "red"),
+                       `\x1B[31mred\x1B[0m`,
+               },
+               {
+                       "color (TTY)",
+                       true,
+                       ColorString(true, ColorRed, "red"),
+                       "\x1B[35m\\x1B\x1B[0m[31mred\x1B[35m\\x1B\x1B[0m[0m",
+               },
+
+               {
+                       "\\r\\n",
+                       false,
+                       "\r\n",
+                       "\\r\n",
+               },
+               {
+                       "\\r\\n (TTY)",
+                       true,
+                       "\r\n",
+                       "\x1B[35m\\r\x1B[0m\n",
+               },
+
+               {
+                       "binary",
+                       false,
+                       "\xD6\x24\xA4\x45\xA2\x68\xD3\x0E\xD4\xC7\xC3\x1F",
+                       "\xD6$\xA4E\xA2h\xD3\\x0E\xD4\xC7\xC3\\x1F",
+               },
+               {
+                       "binary (TTY)",
+                       true,
+                       "\xD6\x24\xA4\x45\xA2\x68\xD3\x0E\xD4\xC7\xC3\x1F",
+                       "\xD6$\xA4E\xA2h\xD3\x1B[35m\\x0E\x1B[0m\xD4\xC7\xc3\x1B[35m\\x1F\x1B[0m",
+               },
+       }
+
+       for _, tc := range tests {
+               res := EscapeControlCharacters(tc.isTTY, tc.x)
+               if tc.exp != res {
+                       t.Errorf("%s: res: %s", tc.name,
+                               cmp.Diff(tc.exp, res))
+               }
+       }
+}
diff --git a/cmd/safcm/testdata/group-invalid-all-remove/groups.yaml b/cmd/safcm/testdata/group-invalid-all-remove/groups.yaml
new file mode 100644 (file)
index 0000000..9dd9717
--- /dev/null
@@ -0,0 +1,3 @@
+all:remove:
+  - host1.example.org
+  - host2
diff --git a/cmd/safcm/testdata/group-invalid-all/groups.yaml b/cmd/safcm/testdata/group-invalid-all/groups.yaml
new file mode 100644 (file)
index 0000000..203ff16
--- /dev/null
@@ -0,0 +1,3 @@
+all:
+  - host1.example.org
+  - host2
diff --git a/cmd/safcm/testdata/group-invalid-conflict/groups.yaml b/cmd/safcm/testdata/group-invalid-conflict/groups.yaml
new file mode 100644 (file)
index 0000000..1336027
--- /dev/null
@@ -0,0 +1,2 @@
+host2:
+  - host1.example.org
diff --git a/cmd/safcm/testdata/group-invalid-detected/groups.yaml b/cmd/safcm/testdata/group-invalid-detected/groups.yaml
new file mode 100644 (file)
index 0000000..587c609
--- /dev/null
@@ -0,0 +1,3 @@
+detected_linux:
+  - host1.example.org
+  - host2
diff --git a/cmd/safcm/testdata/group-invalid-member/groups.yaml b/cmd/safcm/testdata/group-invalid-member/groups.yaml
new file mode 100644 (file)
index 0000000..989877b
--- /dev/null
@@ -0,0 +1,2 @@
+group1:
+  - special:member
diff --git a/cmd/safcm/testdata/group-invalid-missing/groups.yaml b/cmd/safcm/testdata/group-invalid-missing/groups.yaml
new file mode 100644 (file)
index 0000000..be700b7
--- /dev/null
@@ -0,0 +1,2 @@
+1group2:
+  - does-not-exist
diff --git a/cmd/safcm/testdata/group-invalid-name/groups.yaml b/cmd/safcm/testdata/group-invalid-name/groups.yaml
new file mode 100644 (file)
index 0000000..f8b80d9
--- /dev/null
@@ -0,0 +1,2 @@
+invalid.group.name:
+  - host1.example.org
diff --git a/cmd/safcm/testdata/project-conflict-dir/all/files/etc/motd b/cmd/safcm/testdata/project-conflict-dir/all/files/etc/motd
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/cmd/safcm/testdata/project-conflict-dir/dns/files/etc/resolv.conf b/cmd/safcm/testdata/project-conflict-dir/dns/files/etc/resolv.conf
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/cmd/safcm/testdata/project-conflict-dir/dns/permissions.yaml b/cmd/safcm/testdata/project-conflict-dir/dns/permissions.yaml
new file mode 100644 (file)
index 0000000..b07eace
--- /dev/null
@@ -0,0 +1 @@
+/etc: 0750
diff --git a/cmd/safcm/testdata/project-conflict-dir/groups.yaml b/cmd/safcm/testdata/project-conflict-dir/groups.yaml
new file mode 100644 (file)
index 0000000..1545f80
--- /dev/null
@@ -0,0 +1,5 @@
+dns:
+  - host1.example.org
+
+other:
+  - detected_other
diff --git a/cmd/safcm/testdata/project-conflict-dir/hosts.yaml b/cmd/safcm/testdata/project-conflict-dir/hosts.yaml
new file mode 100644 (file)
index 0000000..6db683a
--- /dev/null
@@ -0,0 +1,2 @@
+- name: host1.example.org
+- name: host2.example.org
diff --git a/cmd/safcm/testdata/project-conflict-dir/other/files/etc/resolv.conf b/cmd/safcm/testdata/project-conflict-dir/other/files/etc/resolv.conf
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/cmd/safcm/testdata/project-conflict-dir/other/triggers.yaml b/cmd/safcm/testdata/project-conflict-dir/other/triggers.yaml
new file mode 100644 (file)
index 0000000..2320878
--- /dev/null
@@ -0,0 +1,2 @@
+/etc:
+  - echo /etc
diff --git a/cmd/safcm/testdata/project-conflict-file/all/files/etc/resolv.conf b/cmd/safcm/testdata/project-conflict-file/all/files/etc/resolv.conf
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/cmd/safcm/testdata/project-conflict-file/dns/files/etc/resolv.conf b/cmd/safcm/testdata/project-conflict-file/dns/files/etc/resolv.conf
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/cmd/safcm/testdata/project-conflict-file/groups.yaml b/cmd/safcm/testdata/project-conflict-file/groups.yaml
new file mode 100644 (file)
index 0000000..1545f80
--- /dev/null
@@ -0,0 +1,5 @@
+dns:
+  - host1.example.org
+
+other:
+  - detected_other
diff --git a/cmd/safcm/testdata/project-conflict-file/hosts.yaml b/cmd/safcm/testdata/project-conflict-file/hosts.yaml
new file mode 100644 (file)
index 0000000..6db683a
--- /dev/null
@@ -0,0 +1,2 @@
+- name: host1.example.org
+- name: host2.example.org
diff --git a/cmd/safcm/testdata/project-conflict-file/other/files/etc/resolv.conf b/cmd/safcm/testdata/project-conflict-file/other/files/etc/resolv.conf
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/cmd/safcm/testdata/project-group-cycle/groups.yaml b/cmd/safcm/testdata/project-group-cycle/groups.yaml
new file mode 100644 (file)
index 0000000..9aab1d4
--- /dev/null
@@ -0,0 +1,7 @@
+group-a:
+  - group-b
+group-b:
+  - group-c
+group-c:
+  - group-a
+  - host1.example.org
diff --git a/cmd/safcm/testdata/project-group-cycle/hosts.yaml b/cmd/safcm/testdata/project-group-cycle/hosts.yaml
new file mode 100644 (file)
index 0000000..57e8465
--- /dev/null
@@ -0,0 +1 @@
+- name: host1.example.org
diff --git a/cmd/safcm/testdata/project-group_order/config.yaml b/cmd/safcm/testdata/project-group_order/config.yaml
new file mode 100644 (file)
index 0000000..b409bd6
--- /dev/null
@@ -0,0 +1,4 @@
+group_order:
+  - group-a
+  - group-b
+  - all
diff --git a/cmd/safcm/testdata/project-group_order/group-a/files/etc/dir-to-file b/cmd/safcm/testdata/project-group_order/group-a/files/etc/dir-to-file
new file mode 100644 (file)
index 0000000..7b90449
--- /dev/null
@@ -0,0 +1 @@
+dir-to-file: from group-a
diff --git a/cmd/safcm/testdata/project-group_order/group-a/files/etc/dir-to-link b/cmd/safcm/testdata/project-group_order/group-a/files/etc/dir-to-link
new file mode 120000 (symlink)
index 0000000..1de5659
--- /dev/null
@@ -0,0 +1 @@
+target
\ No newline at end of file
diff --git a/cmd/safcm/testdata/project-group_order/group-a/files/etc/file-to-dir/dir/file2 b/cmd/safcm/testdata/project-group_order/group-a/files/etc/file-to-dir/dir/file2
new file mode 100644 (file)
index 0000000..42f3842
--- /dev/null
@@ -0,0 +1 @@
+file2: from group-a
diff --git a/cmd/safcm/testdata/project-group_order/group-a/files/etc/file-to-dir/file b/cmd/safcm/testdata/project-group_order/group-a/files/etc/file-to-dir/file
new file mode 100644 (file)
index 0000000..156926b
--- /dev/null
@@ -0,0 +1 @@
+file: from group-a
diff --git a/cmd/safcm/testdata/project-group_order/group-a/files/etc/motd b/cmd/safcm/testdata/project-group_order/group-a/files/etc/motd
new file mode 100644 (file)
index 0000000..e63036b
--- /dev/null
@@ -0,0 +1 @@
+motd: from group-a
diff --git a/cmd/safcm/testdata/project-group_order/group-a/triggers.yaml b/cmd/safcm/testdata/project-group_order/group-a/triggers.yaml
new file mode 100644 (file)
index 0000000..f2b6e4c
--- /dev/null
@@ -0,0 +1,2 @@
+/etc:
+  - echo from group-a
diff --git a/cmd/safcm/testdata/project-group_order/group-b/files/etc/dir-to-file/dir/file2 b/cmd/safcm/testdata/project-group_order/group-b/files/etc/dir-to-file/dir/file2
new file mode 100644 (file)
index 0000000..97b2319
--- /dev/null
@@ -0,0 +1 @@
+file2: from group-b
diff --git a/cmd/safcm/testdata/project-group_order/group-b/files/etc/dir-to-file/file b/cmd/safcm/testdata/project-group_order/group-b/files/etc/dir-to-file/file
new file mode 100644 (file)
index 0000000..b99c5fd
--- /dev/null
@@ -0,0 +1 @@
+file: from group-b
diff --git a/cmd/safcm/testdata/project-group_order/group-b/files/etc/dir-to-filex b/cmd/safcm/testdata/project-group_order/group-b/files/etc/dir-to-filex
new file mode 100644 (file)
index 0000000..851c47e
--- /dev/null
@@ -0,0 +1 @@
+dir-to-filex
diff --git a/cmd/safcm/testdata/project-group_order/group-b/files/etc/dir-to-link/dir-to-file/dir/file2 b/cmd/safcm/testdata/project-group_order/group-b/files/etc/dir-to-link/dir-to-file/dir/file2
new file mode 100644 (file)
index 0000000..97b2319
--- /dev/null
@@ -0,0 +1 @@
+file2: from group-b
diff --git a/cmd/safcm/testdata/project-group_order/group-b/files/etc/dir-to-link/dir-to-file/file b/cmd/safcm/testdata/project-group_order/group-b/files/etc/dir-to-link/dir-to-file/file
new file mode 100644 (file)
index 0000000..b99c5fd
--- /dev/null
@@ -0,0 +1 @@
+file: from group-b
diff --git a/cmd/safcm/testdata/project-group_order/group-b/files/etc/dir-to-linkx b/cmd/safcm/testdata/project-group_order/group-b/files/etc/dir-to-linkx
new file mode 100644 (file)
index 0000000..8712431
--- /dev/null
@@ -0,0 +1 @@
+dir-to-linkx
diff --git a/cmd/safcm/testdata/project-group_order/group-b/files/etc/file-to-dir b/cmd/safcm/testdata/project-group_order/group-b/files/etc/file-to-dir
new file mode 100644 (file)
index 0000000..85755ba
--- /dev/null
@@ -0,0 +1 @@
+file-to-dir: from group-b
diff --git a/cmd/safcm/testdata/project-group_order/group-b/files/etc/motd b/cmd/safcm/testdata/project-group_order/group-b/files/etc/motd
new file mode 100644 (file)
index 0000000..938bc52
--- /dev/null
@@ -0,0 +1 @@
+motd: from group-b
diff --git a/cmd/safcm/testdata/project-group_order/group-b/triggers.yaml b/cmd/safcm/testdata/project-group_order/group-b/triggers.yaml
new file mode 100644 (file)
index 0000000..10f6538
--- /dev/null
@@ -0,0 +1,2 @@
+/etc:
+  - echo from-group-b
diff --git a/cmd/safcm/testdata/project-group_order/groups.yaml b/cmd/safcm/testdata/project-group_order/groups.yaml
new file mode 100644 (file)
index 0000000..b53a9fe
--- /dev/null
@@ -0,0 +1,4 @@
+group-a:
+  - host1.example.org
+group-b:
+  - host1.example.org
diff --git a/cmd/safcm/testdata/project-group_order/host1.example.org/files/etc/motd b/cmd/safcm/testdata/project-group_order/host1.example.org/files/etc/motd
new file mode 100644 (file)
index 0000000..baea05c
--- /dev/null
@@ -0,0 +1 @@
+motd: from host1
diff --git a/cmd/safcm/testdata/project-group_order/hosts.yaml b/cmd/safcm/testdata/project-group_order/hosts.yaml
new file mode 100644 (file)
index 0000000..57e8465
--- /dev/null
@@ -0,0 +1 @@
+- name: host1.example.org
diff --git a/cmd/safcm/testdata/project/empty/.gitignore b/cmd/safcm/testdata/project/empty/.gitignore
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/cmd/safcm/testdata/project/files-invalid-perm-dir-setgid/files/etc/resolv.conf b/cmd/safcm/testdata/project/files-invalid-perm-dir-setgid/files/etc/resolv.conf
new file mode 100644 (file)
index 0000000..fd4fb85
--- /dev/null
@@ -0,0 +1 @@
+nameserver ::1
diff --git a/cmd/safcm/testdata/project/files-invalid-perm-dir/files/etc/resolv.conf b/cmd/safcm/testdata/project/files-invalid-perm-dir/files/etc/resolv.conf
new file mode 100644 (file)
index 0000000..fd4fb85
--- /dev/null
@@ -0,0 +1 @@
+nameserver ::1
diff --git a/cmd/safcm/testdata/project/files-invalid-perm-file-executable/files/etc/rc.local b/cmd/safcm/testdata/project/files-invalid-perm-file-executable/files/etc/rc.local
new file mode 100755 (executable)
index 0000000..1a24852
--- /dev/null
@@ -0,0 +1 @@
+#!/bin/sh
diff --git a/cmd/safcm/testdata/project/files-invalid-perm-file-sticky/files/etc/resolv.conf b/cmd/safcm/testdata/project/files-invalid-perm-file-sticky/files/etc/resolv.conf
new file mode 100644 (file)
index 0000000..fd4fb85
--- /dev/null
@@ -0,0 +1 @@
+nameserver ::1
diff --git a/cmd/safcm/testdata/project/files-invalid-perm-file/files/etc/resolv.conf b/cmd/safcm/testdata/project/files-invalid-perm-file/files/etc/resolv.conf
new file mode 100644 (file)
index 0000000..fd4fb85
--- /dev/null
@@ -0,0 +1 @@
+nameserver ::1
diff --git a/cmd/safcm/testdata/project/files-invalid-type/files/.gitignore b/cmd/safcm/testdata/project/files-invalid-type/files/.gitignore
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/cmd/safcm/testdata/project/group/commands.yaml b/cmd/safcm/testdata/project/group/commands.yaml
new file mode 100644 (file)
index 0000000..b488794
--- /dev/null
@@ -0,0 +1,2 @@
+- echo command one
+- echo -n command two
diff --git a/cmd/safcm/testdata/project/group/files/etc/.hidden b/cmd/safcm/testdata/project/group/files/etc/.hidden
new file mode 100644 (file)
index 0000000..90a1d60
--- /dev/null
@@ -0,0 +1 @@
+...
\ No newline at end of file
diff --git a/cmd/safcm/testdata/project/group/files/etc/motd b/cmd/safcm/testdata/project/group/files/etc/motd
new file mode 100644 (file)
index 0000000..be82b96
--- /dev/null
@@ -0,0 +1,11 @@
+Welcome to
+{{- if .IsHost "host1.example.org"}} Host ONE
+{{- else if "host2"}} Host TWO
+{{- end}}
+
+{{if .InGroup "detected_linux"}}
+This is GNU/Linux host
+{{end}}
+{{if .InGroup "detected_freebsd"}}
+This is FreeBSD host
+{{end}}
diff --git a/cmd/safcm/testdata/project/group/files/etc/rc.local b/cmd/safcm/testdata/project/group/files/etc/rc.local
new file mode 100755 (executable)
index 0000000..1a24852
--- /dev/null
@@ -0,0 +1 @@
+#!/bin/sh
diff --git a/cmd/safcm/testdata/project/group/files/etc/resolv.conf b/cmd/safcm/testdata/project/group/files/etc/resolv.conf
new file mode 100644 (file)
index 0000000..fd4fb85
--- /dev/null
@@ -0,0 +1 @@
+nameserver ::1
diff --git a/cmd/safcm/testdata/project/group/files/etc/test b/cmd/safcm/testdata/project/group/files/etc/test
new file mode 120000 (symlink)
index 0000000..5b08be5
--- /dev/null
@@ -0,0 +1 @@
+doesnt-exist
\ No newline at end of file
diff --git a/cmd/safcm/testdata/project/group/packages.yaml b/cmd/safcm/testdata/project/group/packages.yaml
new file mode 100644 (file)
index 0000000..9d8cfe1
--- /dev/null
@@ -0,0 +1,2 @@
+- unbound
+- unbound-anchor
diff --git a/cmd/safcm/testdata/project/group/permissions.yaml b/cmd/safcm/testdata/project/group/permissions.yaml
new file mode 100644 (file)
index 0000000..dcb31a8
--- /dev/null
@@ -0,0 +1,4 @@
+/: 2755
+/etc/.hidden: 07100
+/etc/rc.local: 0700
+/etc/resolv.conf: 0641 user group
diff --git a/cmd/safcm/testdata/project/group/services.yaml b/cmd/safcm/testdata/project/group/services.yaml
new file mode 100644 (file)
index 0000000..aad5a57
--- /dev/null
@@ -0,0 +1 @@
+- unbound
diff --git a/cmd/safcm/testdata/project/group/templates.yaml b/cmd/safcm/testdata/project/group/templates.yaml
new file mode 100644 (file)
index 0000000..eed5403
--- /dev/null
@@ -0,0 +1 @@
+- /etc/motd
diff --git a/cmd/safcm/testdata/project/group/triggers.yaml b/cmd/safcm/testdata/project/group/triggers.yaml
new file mode 100644 (file)
index 0000000..882dc32
--- /dev/null
@@ -0,0 +1,6 @@
+/:
+  - touch /.update
+/etc/resolv.conf:
+  - echo resolv.conf updated
+/etc/rc.local:
+  - /etc/rc.local
diff --git a/cmd/safcm/testdata/project/groups.yaml b/cmd/safcm/testdata/project/groups.yaml
new file mode 100644 (file)
index 0000000..580afaf
--- /dev/null
@@ -0,0 +1,25 @@
+group:
+  - detected_linux
+  - detected_freebsd
+  - host1.example.org
+group:remove:
+  - host2
+  - detected_mips
+
+group2:
+  - all
+group2:remove:
+  - remove
+
+all_except_some:
+  - all
+all_except_some:remove:
+  - host1.example.org
+  - group2
+
+remove:
+  - host1.example.org
+  - host2
+  - host3.example.net
+remove:remove:
+  - host2
diff --git a/cmd/safcm/testdata/project/hosts.yaml b/cmd/safcm/testdata/project/hosts.yaml
new file mode 100644 (file)
index 0000000..db24bdc
--- /dev/null
@@ -0,0 +1,3 @@
+- name: host1.example.org
+- name: host2
+- name: host3.example.net
diff --git a/cmd/safcm/testdata/project/permissions-invalid-execute/files/etc/rc.local b/cmd/safcm/testdata/project/permissions-invalid-execute/files/etc/rc.local
new file mode 100755 (executable)
index 0000000..1a24852
--- /dev/null
@@ -0,0 +1 @@
+#!/bin/sh
diff --git a/cmd/safcm/testdata/project/permissions-invalid-execute/permissions.yaml b/cmd/safcm/testdata/project/permissions-invalid-execute/permissions.yaml
new file mode 100644 (file)
index 0000000..de4a534
--- /dev/null
@@ -0,0 +1 @@
+/etc/rc.local: 0600
diff --git a/cmd/safcm/testdata/project/permissions-invalid-line/files/etc/resolv.conf b/cmd/safcm/testdata/project/permissions-invalid-line/files/etc/resolv.conf
new file mode 100644 (file)
index 0000000..fd4fb85
--- /dev/null
@@ -0,0 +1 @@
+nameserver ::1
diff --git a/cmd/safcm/testdata/project/permissions-invalid-line/permissions.yaml b/cmd/safcm/testdata/project/permissions-invalid-line/permissions.yaml
new file mode 100644 (file)
index 0000000..fc5cac2
--- /dev/null
@@ -0,0 +1 @@
+/etc/resolv.conf: invalid line
diff --git a/cmd/safcm/testdata/project/permissions-invalid-path/permissions.yaml b/cmd/safcm/testdata/project/permissions-invalid-path/permissions.yaml
new file mode 100644 (file)
index 0000000..cde0b49
--- /dev/null
@@ -0,0 +1 @@
+/does/not/exist: 0755
diff --git a/cmd/safcm/testdata/project/permissions-invalid-permission-int/files/etc/resolv.conf b/cmd/safcm/testdata/project/permissions-invalid-permission-int/files/etc/resolv.conf
new file mode 100644 (file)
index 0000000..fd4fb85
--- /dev/null
@@ -0,0 +1 @@
+nameserver ::1
diff --git a/cmd/safcm/testdata/project/permissions-invalid-permission-int/permissions.yaml b/cmd/safcm/testdata/project/permissions-invalid-permission-int/permissions.yaml
new file mode 100644 (file)
index 0000000..67434dd
--- /dev/null
@@ -0,0 +1 @@
+/etc/resolv.conf: 66066
diff --git a/cmd/safcm/testdata/project/permissions-invalid-permission/files/etc/resolv.conf b/cmd/safcm/testdata/project/permissions-invalid-permission/files/etc/resolv.conf
new file mode 100644 (file)
index 0000000..fd4fb85
--- /dev/null
@@ -0,0 +1 @@
+nameserver ::1
diff --git a/cmd/safcm/testdata/project/permissions-invalid-permission/permissions.yaml b/cmd/safcm/testdata/project/permissions-invalid-permission/permissions.yaml
new file mode 100644 (file)
index 0000000..34722b9
--- /dev/null
@@ -0,0 +1 @@
+/etc/resolv.conf: u=rwg=r
diff --git a/cmd/safcm/testdata/project/templates-invalid-group/files/etc/motd b/cmd/safcm/testdata/project/templates-invalid-group/files/etc/motd
new file mode 100644 (file)
index 0000000..58210df
--- /dev/null
@@ -0,0 +1,4 @@
+
+{{if .InGroup "invalid-group"}}
+...
+{{end}}
diff --git a/cmd/safcm/testdata/project/templates-invalid-group/templates.yaml b/cmd/safcm/testdata/project/templates-invalid-group/templates.yaml
new file mode 100644 (file)
index 0000000..eed5403
--- /dev/null
@@ -0,0 +1 @@
+- /etc/motd
diff --git a/cmd/safcm/testdata/project/templates-invalid-host/files/etc/motd b/cmd/safcm/testdata/project/templates-invalid-host/files/etc/motd
new file mode 100644 (file)
index 0000000..e5432b6
--- /dev/null
@@ -0,0 +1,4 @@
+
+{{if .IsHost "invalid-host"}}
+...
+{{end}}
diff --git a/cmd/safcm/testdata/project/templates-invalid-host/templates.yaml b/cmd/safcm/testdata/project/templates-invalid-host/templates.yaml
new file mode 100644 (file)
index 0000000..eed5403
--- /dev/null
@@ -0,0 +1 @@
+- /etc/motd
diff --git a/cmd/safcm/testdata/project/templates-invalid-path/templates.yaml b/cmd/safcm/testdata/project/templates-invalid-path/templates.yaml
new file mode 100644 (file)
index 0000000..eed5403
--- /dev/null
@@ -0,0 +1 @@
+- /etc/motd
diff --git a/cmd/safcm/testdata/project/templates-invalid-template/files/etc/motd b/cmd/safcm/testdata/project/templates-invalid-template/files/etc/motd
new file mode 100644 (file)
index 0000000..e1c0a76
--- /dev/null
@@ -0,0 +1 @@
+{{
diff --git a/cmd/safcm/testdata/project/templates-invalid-template/templates.yaml b/cmd/safcm/testdata/project/templates-invalid-template/templates.yaml
new file mode 100644 (file)
index 0000000..eed5403
--- /dev/null
@@ -0,0 +1 @@
+- /etc/motd
diff --git a/cmd/safcm/testdata/project/templates-invalid-type/files/etc/motd b/cmd/safcm/testdata/project/templates-invalid-type/files/etc/motd
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/cmd/safcm/testdata/project/templates-invalid-type/templates.yaml b/cmd/safcm/testdata/project/templates-invalid-type/templates.yaml
new file mode 100644 (file)
index 0000000..ca66596
--- /dev/null
@@ -0,0 +1 @@
+- /etc
diff --git a/cmd/safcm/testdata/project/triggers-invalid-path/triggers.yaml b/cmd/safcm/testdata/project/triggers-invalid-path/triggers.yaml
new file mode 100644 (file)
index 0000000..b8d50c7
--- /dev/null
@@ -0,0 +1,2 @@
+/etc/resolv.conf:
+  - echo resolv.conf updated
diff --git a/go.mod b/go.mod
new file mode 100644 (file)
index 0000000..cdfcef5
--- /dev/null
+++ b/go.mod
@@ -0,0 +1,11 @@
+module ruderich.org/simon/safcm
+
+go 1.16
+
+require (
+       github.com/google/go-cmp v0.5.5
+       github.com/ianbruene/go-difflib v1.2.0
+       golang.org/x/sys v0.0.0-20210324051608-47abb6519492 // indirect
+       golang.org/x/term v0.0.0-20210317153231-de623e64d2a6
+       gopkg.in/yaml.v2 v2.4.0
+)
diff --git a/go.sum b/go.sum
new file mode 100644 (file)
index 0000000..3a8b414
--- /dev/null
+++ b/go.sum
@@ -0,0 +1,15 @@
+github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
+github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
+github.com/ianbruene/go-difflib v1.2.0 h1:iARmgaCq6nW5QptdoFm0PYAyNGix3xw/xRgEwphJSZw=
+github.com/ianbruene/go-difflib v1.2.0/go.mod h1:uJbrQ06VPxjRiRIrync+E6VcWFGW2dWqw2gvQp6HQPY=
+golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210324051608-47abb6519492 h1:Paq34FxTluEPvVyayQqMPgHm+vTOrIifmcYxFBx9TLg=
+golang.org/x/sys v0.0.0-20210324051608-47abb6519492/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/term v0.0.0-20210317153231-de623e64d2a6 h1:EC6+IGYTjPpRfv9a2b/6Puw0W+hLtAhkV1tPsXhutqs=
+golang.org/x/term v0.0.0-20210317153231-de623e64d2a6/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
+golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
+golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
+gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
diff --git a/gob.go b/gob.go
new file mode 100644 (file)
index 0000000..147ea23
--- /dev/null
+++ b/gob.go
@@ -0,0 +1,50 @@
+// RPC primitives for safcm: basic connection implementation
+
+// Copyright (C) 2021  Simon Ruderich
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+package safcm
+
+import (
+       "encoding/gob"
+       "io"
+)
+
+type GobConn struct {
+       enc *gob.Encoder
+       dec *gob.Decoder
+}
+
+func NewGobConn(r io.Reader, w io.Writer) *GobConn {
+       return &GobConn{
+               enc: gob.NewEncoder(w),
+               dec: gob.NewDecoder(r),
+       }
+}
+
+func (c *GobConn) Send(x Msg) error {
+       // & lets Encode send the interface itself and not a concrete type
+       // which is necessary to Decode as an interface
+       return c.enc.Encode(&x)
+}
+
+func (c *GobConn) Recv() (Msg, error) {
+       var x Msg
+       err := c.dec.Decode(&x)
+       if err != nil {
+               return nil, err
+       }
+       return x, nil
+}
diff --git a/log.go b/log.go
new file mode 100644 (file)
index 0000000..029d975
--- /dev/null
+++ b/log.go
@@ -0,0 +1,39 @@
+// RPC primitives for safcm: logging constants
+
+// Copyright (C) 2021  Simon Ruderich
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+package safcm
+
+// LogLevel controls the number of displayed log messages. Higher levels
+// include all messages from lower levels (e.g. LogVerbose includes all
+// messages from LogInfo).
+type LogLevel int
+
+const (
+       _ LogLevel = iota
+       // Log only errors
+       LogError
+       // Log changes
+       LogInfo
+       // Log host information and changes on remote host
+       LogVerbose
+       // Log additional information and commands leading up to the changes
+       LogDebug
+       // Log output of all commands
+       LogDebug2
+       // Log all RPC messages
+       LogDebug3
+)
diff --git a/remote/remote.go b/remote/remote.go
new file mode 100644 (file)
index 0000000..3a41987
--- /dev/null
@@ -0,0 +1,27 @@
+// Embed remote helper binaries
+
+// Copyright (C) 2021  Simon Ruderich
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+package remote
+
+import (
+       "embed"
+)
+
+// Helpers contains remote helper binaries for different operating systems and
+// architectures.
+//go:embed helpers/*
+var Helpers embed.FS
diff --git a/rpc/conn.go b/rpc/conn.go
new file mode 100644 (file)
index 0000000..c59bbd3
--- /dev/null
@@ -0,0 +1,164 @@
+// Simple RPC-like protocol: implementation of connection and basic actions
+
+// Copyright (C) 2021  Simon Ruderich
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+package rpc
+
+import (
+       "bufio"
+       "fmt"
+       "os/exec"
+       "strings"
+       "sync"
+
+       "ruderich.org/simon/safcm"
+)
+
+type Conn struct {
+       Events   <-chan ConnEvent
+       events   chan<- ConnEvent // same as Events, to publish events
+       eventsWg sync.WaitGroup
+
+       debug  bool
+       remote string
+
+       cmd  *exec.Cmd
+       conn *safcm.GobConn
+}
+
+type ConnEventType int
+
+const (
+       _ ConnEventType = iota
+       ConnEventStderr
+       ConnEventDebug
+       ConnEventUpload
+)
+
+type ConnEvent struct {
+       Type ConnEventType
+       Data string
+}
+
+// NewConn creates a new connection. Events in the returned struct must be
+// regularly read or the connection will stall. This must be done before
+// DialSSH is called to open a connection.
+func NewConn(debug bool) *Conn {
+       ch := make(chan ConnEvent)
+       return &Conn{
+               Events: ch,
+               events: ch,
+               debug:  debug,
+       }
+}
+
+func (c *Conn) debugf(format string, a ...interface{}) {
+       if !c.debug {
+               return
+       }
+       c.events <- ConnEvent{
+               Type: ConnEventDebug,
+               Data: fmt.Sprintf(format, a...),
+       }
+}
+
+// Wrap safcm.GobConn's Send() and Recv() to provide debug output.
+
+// Send sends a single message to the remote.
+func (c *Conn) Send(m safcm.Msg) error {
+       // No checks for invalid Conn, a stacktrace is more helpful
+
+       c.debugf("Send: sending %#v", m)
+       return c.conn.Send(m)
+}
+
+// Recv waits for a single message from the remote.
+func (c *Conn) Recv() (safcm.Msg, error) {
+       // No checks for invalid Conn, a stacktrace is more helpful
+
+       c.debugf("Recv: waiting for message")
+       m, err := c.conn.Recv()
+       c.debugf("Recv: received msg=%#v err=%#v", m, err)
+       return m, err
+}
+
+// Wait waits for the connection to terminate. It's safe to call Wait (and
+// Kill) multiple times.
+func (c *Conn) Wait() error {
+       // But check here because Wait() can be called multiple times
+       if c.cmd == nil {
+               return fmt.Errorf("Dial*() not called or already terminated")
+       }
+
+       c.debugf("Wait: waiting for connection to terminate")
+       return c.wait()
+}
+func (c *Conn) wait() error {
+       err := c.cmd.Wait()
+       c.cmd = nil
+
+       // Wait until we've received all events from the program's stderr.
+       c.eventsWg.Wait()
+       // Notify consumers that no more events will occur.
+       close(c.events)
+       // We cannot reuse this channel.
+       c.events = nil
+       // Don't set c.Events to nil because this creates a data race when
+       // another thread is still waiting.
+
+       return err
+}
+
+// Kill forcefully terminates the connection. It's safe to call Kill (and
+// Wait) multiple times.
+func (c *Conn) Kill() error {
+       if c.cmd == nil {
+               return fmt.Errorf("Dial*() not called or already terminated")
+       }
+
+       c.debugf("Kill: killing connection")
+
+       c.cmd.Process.Kill()
+       return c.wait()
+}
+
+func (c *Conn) handleStderrAsEvents(cmd *exec.Cmd) error {
+       // cmd may differ from c.cmd here!
+       stderr, err := cmd.StderrPipe()
+       if err != nil {
+               return err
+       }
+
+       c.eventsWg.Add(1)
+       go func() {
+               r := bufio.NewReader(stderr)
+               for {
+                       x, err := r.ReadString('\n')
+                       if err != nil {
+                               break
+                       }
+                       x = strings.TrimRight(x, "\n")
+
+                       c.events <- ConnEvent{
+                               Type: ConnEventStderr,
+                               Data: x,
+                       }
+               }
+               c.eventsWg.Done()
+       }()
+
+       return nil
+}
diff --git a/rpc/dial.go b/rpc/dial.go
new file mode 100644 (file)
index 0000000..3609279
--- /dev/null
@@ -0,0 +1,301 @@
+// Simple RPC-like protocol: establish new connection and upload helper
+
+// Copyright (C) 2021  Simon Ruderich
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+package rpc
+
+import (
+       "bufio"
+       "bytes"
+       "crypto/sha512"
+       "encoding/hex"
+       "fmt"
+       "io"
+       "os/exec"
+       "strconv"
+       "strings"
+
+       "ruderich.org/simon/safcm"
+       "ruderich.org/simon/safcm/remote"
+)
+
+func (c *Conn) DialSSH(remote string) error {
+       if c.events == nil {
+               return fmt.Errorf("cannot reuse Conn")
+       }
+
+       c.debugf("DialSSH: connecting to %q", remote)
+
+       opts := "-eu"
+       if c.debug {
+               // Help debugging by showing executed shell commands
+               opts += "x"
+       }
+       c.cmd = exec.Command("ssh", remote, "/bin/sh", opts)
+       c.remote = remote
+
+       stdin, err := c.cmd.StdinPipe()
+       if err != nil {
+               return err
+       }
+       stdout, err := c.cmd.StdoutPipe()
+       if err != nil {
+               return err
+       }
+       err = c.handleStderrAsEvents(c.cmd)
+       if err != nil {
+               return err
+       }
+
+       err = c.cmd.Start()
+       if err != nil {
+               return err
+       }
+
+       err = c.dialSSH(stdin, stdout)
+       if err != nil {
+               c.Kill()
+               return err
+       }
+       c.conn = safcm.NewGobConn(stdout, stdin)
+
+       return nil
+}
+
+func (c *Conn) dialSSH(stdin io.Writer, stdout_ io.Reader) error {
+       stdout := bufio.NewReader(stdout_)
+
+       goos, err := connGetGoos(stdin, stdout)
+       if err != nil {
+               return err
+       }
+       goarch, err := connGetGoarch(stdin, stdout)
+       if err != nil {
+               return err
+       }
+       uid, err := connGetUID(stdin, stdout)
+       if err != nil {
+               return err
+       }
+
+       path := fmt.Sprintf("/tmp/safcm-remote-%d", uid)
+
+       c.debugf("DialSSH: probing remote at %q", path)
+       // Use a function so the shell cannot execute the input line-wise.
+       // This is important because we're also using stdin to send data to
+       // the script. If the shell executes the input line-wise then our
+       // script is interpreted as input for `read`.
+       //
+       // The target directory must no permit other users to delete our files
+       // or symlink attacks and arbitrary code execution is possible. For
+       // /tmp this is guaranteed by the sticky bit. Make sure it has the
+       // proper permissions.
+       //
+       // We cannot use `test -f && test -O` because this is open to TOCTOU
+       // attacks. `stat` gives use the full file state. If the file is owned
+       // by us and not a symlink then it's safe to use (assuming sticky or
+       // directory not writable by others).
+       //
+       // `test -e` is only used to prevent error messages if the file
+       // doesn't exist. It does not guard against any races.
+       _, err = fmt.Fprintf(stdin, `
+f() {
+       x=%q
+
+       dir="$(dirname "$x")"
+       if ! test "$(stat -c '%%A %%u %%g' "$dir")" = 'drwxrwxrwt 0 0'; then
+               echo "unsafe permissions on $dir, aborting" >&2
+               exit 1
+       fi
+
+       if test -e "$x" && test "$(stat -c '%%A %%u' "$x")" = "-rwx------ $(id -u)"; then
+               # Report checksum
+               sha512sum "$x"
+       else
+               # Empty checksum to request upload
+               echo
+       fi
+
+       # Wait for signal to continue
+       read upload
+
+       if test -n "$upload"; then
+               tmp="$(mktemp "$x.XXXXXX")"
+               # Report filename for upload
+               echo "$tmp"
+
+               # Wait for upload to complete
+               read unused
+
+               # Safely create new file (ln does not follow symlinks)
+               rm -f "$x"
+               ln "$tmp" "$x"
+               rm "$tmp"
+               # Make file executable
+               chmod 0700 "$x"
+       fi
+
+       exec "$x"
+}
+f
+`, path)
+       if err != nil {
+               return err
+       }
+       remoteSum, err := stdout.ReadString('\n')
+       if err != nil {
+               return err
+       }
+
+       // Get embedded helper binary
+       helper, err := remote.Helpers.ReadFile(
+               fmt.Sprintf("helpers/%s-%s", goos, goarch))
+       if err != nil {
+               return fmt.Errorf("remote not built for GOOS/GOARCH %s/%s",
+                       goos, goarch)
+       }
+
+       var upload bool
+       if remoteSum == "\n" {
+               upload = true
+               c.debugf("DialSSH: remote not present or invalid permissions")
+
+       } else {
+               x := strings.Fields(remoteSum)
+               if len(x) < 1 {
+                       return fmt.Errorf("got unexpected checksum line %q",
+                               remoteSum)
+               }
+               sha := sha512.Sum512(helper)
+               hex := hex.EncodeToString(sha[:])
+               if hex == x[0] {
+                       c.debugf("DialSSH: remote checksum matches")
+               } else {
+                       upload = true
+                       c.debugf("DialSSH: remote checksum does not match")
+               }
+       }
+
+       if upload {
+               // Notify user that an upload is going to take place.
+               c.events <- ConnEvent{
+                       Type: ConnEventUpload,
+               }
+
+               // Tell script we want to upload a new file.
+               _, err = fmt.Fprintln(stdin, "upload")
+               if err != nil {
+                       return err
+               }
+               // Get path to temporary file for upload.
+               //
+               // Write to the temporary file instead of the final path so
+               // that a concurrent run of this function won't use a
+               // partially written file. The rm in the script could still
+               // cause a missing file but at least no file with unknown
+               // content is executed.
+               path, err := stdout.ReadString('\n')
+               if err != nil {
+                       return err
+               }
+               path = strings.TrimSuffix(path, "\n")
+
+               c.debugf("DialSSH: uploading new remote to %q at %q",
+                       c.remote, path)
+
+               cmd := exec.Command("ssh", c.remote,
+                       fmt.Sprintf("cat > %q", path))
+               cmd.Stdin = bytes.NewReader(helper)
+               err = c.handleStderrAsEvents(cmd)
+               if err != nil {
+                       return err
+               }
+               err = cmd.Run()
+               if err != nil {
+                       return err
+               }
+       }
+
+       // Tell script to continue and execute the remote helper
+       _, err = fmt.Fprintln(stdin, "")
+       if err != nil {
+               return err
+       }
+
+       return nil
+}
+
+func connGetGoos(stdin io.Writer, stdout *bufio.Reader) (string, error) {
+       _, err := fmt.Fprintln(stdin, "uname -o")
+       if err != nil {
+               return "", err
+       }
+       x, err := stdout.ReadString('\n')
+       if err != nil {
+               return "", err
+       }
+       x = strings.TrimSpace(x)
+
+       // NOTE: Adapt helper uploading in dialSSH() when adding new systems
+       var goos string
+       switch x {
+       case "GNU/Linux":
+               goos = "linux"
+       default:
+               return "", fmt.Errorf("unsupported OS %q (`uname -o`)", x)
+       }
+       return goos, nil
+}
+
+func connGetGoarch(stdin io.Writer, stdout *bufio.Reader) (string, error) {
+       _, err := fmt.Fprintln(stdin, "uname -m")
+       if err != nil {
+               return "", err
+       }
+       x, err := stdout.ReadString('\n')
+       if err != nil {
+               return "", err
+       }
+       x = strings.TrimSpace(x)
+
+       // NOTE: Adapt cmd/safcm-remote/build.sh when adding new architectures
+       var goarch string
+       switch x {
+       case "x86_64":
+               goarch = "amd64"
+       default:
+               return "", fmt.Errorf("unsupported arch %q (`uname -m`)", x)
+       }
+       return goarch, nil
+}
+
+func connGetUID(stdin io.Writer, stdout *bufio.Reader) (int, error) {
+       _, err := fmt.Fprintln(stdin, "id -u")
+       if err != nil {
+               return -1, err
+       }
+       x, err := stdout.ReadString('\n')
+       if err != nil {
+               return -1, err
+       }
+       x = strings.TrimSpace(x)
+
+       uid, err := strconv.Atoi(x)
+       if err != nil {
+               return -1, fmt.Errorf("invalid UID %q (`id -u`)", x)
+       }
+       return uid, nil
+}
diff --git a/types.go b/types.go
new file mode 100644 (file)
index 0000000..267872d
--- /dev/null
+++ b/types.go
@@ -0,0 +1,139 @@
+// RPC primitives for safcm: message and additional types
+
+// Copyright (C) 2021  Simon Ruderich
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+package safcm
+
+import (
+       "encoding/gob"
+       "io/fs"
+)
+
+// Messages
+
+type Msg interface {
+       msg() // unused, only to implement interface
+}
+
+type MsgLog struct {
+       Level LogLevel
+       Text  string
+}
+
+type MsgInfoReq struct {
+       LogLevel LogLevel
+
+       DetectGroups []string
+}
+type MsgInfoResp struct {
+       Goos   string
+       Goarch string
+
+       Output []string
+       Error  string
+}
+
+type MsgSyncReq struct {
+       DryRun bool
+       Groups []string // for commands
+
+       Files    map[string]*File
+       Packages []string
+       Services []string
+       Commands []string
+}
+type MsgSyncResp struct {
+       FileChanges    []FileChange
+       PackageChanges []PackageChange
+       ServiceChanges []ServiceChange
+       CommandChanges []CommandChange
+
+       Error string
+}
+
+type MsgQuitReq struct {
+}
+type MsgQuitResp struct {
+}
+
+func (MsgLog) msg()      {}
+func (MsgInfoReq) msg()  {}
+func (MsgInfoResp) msg() {}
+func (MsgSyncReq) msg()  {}
+func (MsgSyncResp) msg() {}
+func (MsgQuitReq) msg()  {}
+func (MsgQuitResp) msg() {}
+
+func init() {
+       // Necessary to receive "into" an interface type
+       gob.Register(MsgLog{})
+       gob.Register(MsgInfoReq{})
+       gob.Register(MsgInfoResp{})
+       gob.Register(MsgSyncReq{})
+       gob.Register(MsgSyncResp{})
+       gob.Register(MsgQuitReq{})
+       gob.Register(MsgQuitResp{})
+}
+
+// Types used in messages
+
+type File struct {
+       OrigGroup string // group which provided this file
+
+       Path string
+
+       Mode  fs.FileMode
+       User  string
+       Uid   int //lint:ignore ST1003 UID is too ugly
+       Group string
+       Gid   int //lint:ignore ST1003 GID is too ugly
+
+       Data []byte
+
+       TriggerCommands []string
+}
+
+type FileChange struct {
+       Path     string
+       Created  bool
+       Old      FileChangeInfo
+       New      FileChangeInfo
+       DataDiff string
+}
+type FileChangeInfo struct {
+       Mode  fs.FileMode
+       User  string
+       Uid   int //lint:ignore ST1003 UID is too ugly
+       Group string
+       Gid   int //lint:ignore ST1003 GID is too ugly
+}
+
+type PackageChange struct {
+       Name string
+}
+
+type ServiceChange struct {
+       Name    string
+       Started bool
+       Enabled bool
+}
+
+type CommandChange struct {
+       Command string
+       Trigger string // path which triggered this command (optional)
+       Output  string // stdout and stderr combined
+       Error   string
+}