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