--- /dev/null
+/remote/helpers/
+/safcm
+/tags
--- /dev/null
+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
--- /dev/null
+#!/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
--- /dev/null
+// 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
+}
--- /dev/null
+// 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...))
+}
--- /dev/null
+// 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
+}
--- /dev/null
+// 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, " ")
+}
--- /dev/null
+// 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()
+}
--- /dev/null
+// 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
+}
--- /dev/null
+// 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))
+ }
+ }
+}
--- /dev/null
+// 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
+}
--- /dev/null
+// 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
+}
--- /dev/null
+// 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")
+}
--- /dev/null
+// 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
+}
--- /dev/null
+// 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))
+ }
+ }
+}
--- /dev/null
+// 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")
+}
--- /dev/null
+// 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
+}
--- /dev/null
+// 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))
+ }
+ }
+}
--- /dev/null
+// 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
+}
--- /dev/null
+// 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
+}
--- /dev/null
+// 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
+}
--- /dev/null
+// 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))
+ }
+ }
+}
--- /dev/null
+// 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
+}
--- /dev/null
+// 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
+}
--- /dev/null
+// 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
+}
--- /dev/null
+// 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)
+ }
+ }
+}
--- /dev/null
+// 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
+}
--- /dev/null
+// 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)
+ }
+ }
+}
--- /dev/null
+// 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
+}
--- /dev/null
+// 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
+}
--- /dev/null
+// 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
+}
--- /dev/null
+// 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)
+ }
+ }
+}
--- /dev/null
+// 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
+}
--- /dev/null
+// 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]
+}
--- /dev/null
+// 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)
+ }
+ }
+}
--- /dev/null
+// 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
+}
--- /dev/null
+// 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)
+ }
+ }
+}
--- /dev/null
+// "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
+}
--- /dev/null
+// 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
+}
--- /dev/null
+// "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
+ }
+}
--- /dev/null
+// "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)
+}
--- /dev/null
+// 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))
+ }
+ }
+}
--- /dev/null
+// "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
+}
--- /dev/null
+// 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))
+ }
+ }
+}
--- /dev/null
+// "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
+ }
+}
--- /dev/null
+// 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))
+ }
+
+ }
+}
--- /dev/null
+// 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)
+ }
+ }
+}
--- /dev/null
+// 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)
+ })
+}
--- /dev/null
+// 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))
+ }
+ }
+}
--- /dev/null
+all:remove:
+ - host1.example.org
+ - host2
--- /dev/null
+all:
+ - host1.example.org
+ - host2
--- /dev/null
+host2:
+ - host1.example.org
--- /dev/null
+detected_linux:
+ - host1.example.org
+ - host2
--- /dev/null
+group1:
+ - special:member
--- /dev/null
+1group2:
+ - does-not-exist
--- /dev/null
+invalid.group.name:
+ - host1.example.org
--- /dev/null
+/etc: 0750
--- /dev/null
+dns:
+ - host1.example.org
+
+other:
+ - detected_other
--- /dev/null
+- name: host1.example.org
+- name: host2.example.org
--- /dev/null
+/etc:
+ - echo /etc
--- /dev/null
+dns:
+ - host1.example.org
+
+other:
+ - detected_other
--- /dev/null
+- name: host1.example.org
+- name: host2.example.org
--- /dev/null
+group-a:
+ - group-b
+group-b:
+ - group-c
+group-c:
+ - group-a
+ - host1.example.org
--- /dev/null
+- name: host1.example.org
--- /dev/null
+group_order:
+ - group-a
+ - group-b
+ - all
--- /dev/null
+dir-to-file: from group-a
--- /dev/null
+target
\ No newline at end of file
--- /dev/null
+file2: from group-a
--- /dev/null
+file: from group-a
--- /dev/null
+motd: from group-a
--- /dev/null
+/etc:
+ - echo from group-a
--- /dev/null
+file2: from group-b
--- /dev/null
+file: from group-b
--- /dev/null
+dir-to-filex
--- /dev/null
+file2: from group-b
--- /dev/null
+file: from group-b
--- /dev/null
+dir-to-linkx
--- /dev/null
+file-to-dir: from group-b
--- /dev/null
+motd: from group-b
--- /dev/null
+/etc:
+ - echo from-group-b
--- /dev/null
+group-a:
+ - host1.example.org
+group-b:
+ - host1.example.org
--- /dev/null
+motd: from host1
--- /dev/null
+- name: host1.example.org
--- /dev/null
+nameserver ::1
--- /dev/null
+nameserver ::1
--- /dev/null
+nameserver ::1
--- /dev/null
+nameserver ::1
--- /dev/null
+- echo command one
+- echo -n command two
--- /dev/null
+...
\ No newline at end of file
--- /dev/null
+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}}
--- /dev/null
+nameserver ::1
--- /dev/null
+doesnt-exist
\ No newline at end of file
--- /dev/null
+- unbound
+- unbound-anchor
--- /dev/null
+/: 2755
+/etc/.hidden: 07100
+/etc/rc.local: 0700
+/etc/resolv.conf: 0641 user group
--- /dev/null
+- /etc/motd
--- /dev/null
+/:
+ - touch /.update
+/etc/resolv.conf:
+ - echo resolv.conf updated
+/etc/rc.local:
+ - /etc/rc.local
--- /dev/null
+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
--- /dev/null
+- name: host1.example.org
+- name: host2
+- name: host3.example.net
--- /dev/null
+/etc/rc.local: 0600
--- /dev/null
+nameserver ::1
--- /dev/null
+/etc/resolv.conf: invalid line
--- /dev/null
+/does/not/exist: 0755
--- /dev/null
+nameserver ::1
--- /dev/null
+/etc/resolv.conf: 66066
--- /dev/null
+nameserver ::1
--- /dev/null
+/etc/resolv.conf: u=rwg=r
--- /dev/null
+
+{{if .InGroup "invalid-group"}}
+...
+{{end}}
--- /dev/null
+- /etc/motd
--- /dev/null
+
+{{if .IsHost "invalid-host"}}
+...
+{{end}}
--- /dev/null
+- /etc/motd
--- /dev/null
+- /etc/motd
--- /dev/null
+- /etc/motd
--- /dev/null
+/etc/resolv.conf:
+ - echo resolv.conf updated
--- /dev/null
+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
+)
--- /dev/null
+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=
--- /dev/null
+// 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
+}
--- /dev/null
+// 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
+)
--- /dev/null
+// 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
--- /dev/null
+// 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
+}
--- /dev/null
+// 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
+}
--- /dev/null
+// 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
+}