From 799ba88823ec790d2e207acc5f4327b8ac23384f Mon Sep 17 00:00:00 2001 From: ppom <> Date: Sun, 3 Sep 2023 12:13:18 +0200 Subject: [PATCH] New unified CLI design fixes #25 thanks @bertille-ddp for comments && suggestions! --- .gitignore | 1 + app/client.go | 58 ++++++---- app/{reaction.go => daemon.go} | 17 +-- app/main.go | 196 ++++++++++++++++++++++++++++++++ app/pipe.go | 19 ++-- app/reaction.yml | 54 +++++++++ app/startup.go | 2 +- config/reaction.test.yml | 3 + config/reaction.yml | 53 +-------- reaction/main.go => reaction.go | 0 reactionc/main.go | 9 -- 11 files changed, 306 insertions(+), 106 deletions(-) rename app/{reaction.go => daemon.go} (94%) create mode 100644 app/main.go create mode 100644 app/reaction.yml mode change 100644 => 120000 config/reaction.yml rename reaction/main.go => reaction.go (100%) delete mode 100644 reactionc/main.go diff --git a/.gitignore b/.gitignore index 4ee9af4..2154c2b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ /reaction.db /result +/reaction diff --git a/app/client.go b/app/client.go index 7d73126..94d1220 100644 --- a/app/client.go +++ b/app/client.go @@ -1,11 +1,13 @@ package app import ( + "bufio" "encoding/gob" "fmt" "log" "net" "os" + "regexp" ) const ( @@ -21,12 +23,11 @@ type Request struct { type Response struct { Err error Actions ReadableMap + Number int } -const SocketPath = "/run/reaction/reaction.sock" - func SendAndRetrieve(data Request) Response { - conn, err := net.Dial("unix", SocketPath) + conn, err := net.Dial("unix", *SocketPath) if err != nil { log.Fatalln("Error opening connection top daemon:", err) } @@ -50,26 +51,37 @@ func usage(err string) { log.Fatalln(err) } -func CLI() { - if len(os.Args) <= 1 { - response := SendAndRetrieve(Request{Query, ""}) - if response.Err != nil { - log.Fatalln("Received error from daemon:", response.Err) - } - fmt.Println(response.Actions.ToString()) - os.Exit(0) +func ClientQuery(streamfilter string) { + response := SendAndRetrieve(Request{Query, streamfilter}) + if response.Err != nil { + log.Fatalln("Received error from daemon:", response.Err) + os.Exit(1) } - switch os.Args[1] { - case "flush": - if len(os.Args) != 3 { - usage("flush takes one argument") - } - response := SendAndRetrieve(Request{Flush, os.Args[2]}) - if response.Err != nil { - log.Fatalln("Received error from daemon:", response.Err) - } - os.Exit(0) - default: - usage("first argument must be `flush`") + fmt.Println(response.Actions.ToString()) + os.Exit(0) +} + +func ClientFlush(pattern, streamfilter string) { + response := SendAndRetrieve(Request{Flush, pattern}) + if response.Err != nil { + log.Fatalln("Received error from daemon:", response.Err) + os.Exit(1) + } + fmt.Printf("flushed pattern %v times\n", response.Number) + os.Exit(0) +} + +func Match(reg *regexp.Regexp, line string) { + if reg.MatchString(line) { + fmt.Printf("\033[32mmatching\033[0m: %v\n", line) + } else { + fmt.Printf("\033[31mno match\033[0m: %v\n", line) + } +} + +func MatchStdin(reg *regexp.Regexp) { + scanner := bufio.NewScanner(os.Stdin) + for scanner.Scan() { + Match(reg, scanner.Text()) } } diff --git a/app/reaction.go b/app/daemon.go similarity index 94% rename from app/reaction.go rename to app/daemon.go index 3b32176..3308bc6 100644 --- a/app/reaction.go +++ b/app/daemon.go @@ -3,7 +3,6 @@ package app import ( "bufio" "encoding/gob" - "flag" "syscall" // "fmt" @@ -198,18 +197,10 @@ var wgActions sync.WaitGroup var db *gob.Encoder -func Main() { - confFilename := flag.String("c", "", "configuration file. see an example at https://framagit.org/ppom/reaction/-/blob/main/reaction.yml") - flag.Parse() - - if *confFilename == "" { - flag.PrintDefaults() - os.Exit(2) - } - +func Daemon(confFilename string) { actionStore.store = make(ActionMap) - conf := parseConf(*confFilename) + conf := parseConf(confFilename) db = conf.updateFromDB() // Ready to start @@ -226,7 +217,7 @@ func Main() { go stream.handle(endSignals) } - go Serve() + go ServeSocket() for { select { @@ -251,7 +242,7 @@ func quit() { // wait for them to complete wgActions.Wait() // delete pipe - err := os.Remove(SocketPath) + err := os.Remove(*SocketPath) if err != nil { log.Println("Failed to remove socket:", err) } diff --git a/app/main.go b/app/main.go new file mode 100644 index 0000000..728976b --- /dev/null +++ b/app/main.go @@ -0,0 +1,196 @@ +package app + +import ( + _ "embed" + "flag" + "fmt" + "os" + "regexp" +) + +func addStringFlag(names []string, defvalue string, f *flag.FlagSet) *string { + var value string + for _, name := range names { + f.StringVar(&value, name, defvalue, "") + } + return &value +} + +func addBoolFlag(names []string, f *flag.FlagSet) *bool { + var value bool + for _, name := range names { + f.BoolVar(&value, name, false, "") + } + return &value +} + +var SocketPath *string + +func addSocketFlag(f *flag.FlagSet) *string { + return addStringFlag( + []string{"s", "socket"}, + "/run/reaction/reaction.sock", + f) +} + +func addConfFlag(f *flag.FlagSet) *string { + return addStringFlag( + []string{"c", "config"}, + "", + f) +} + +func addFormatFlag(f *flag.FlagSet) *string { + return addStringFlag( + []string{"f", "format"}, + "yaml", + f) +} + +func subCommandParse(f *flag.FlagSet, maxRemainingArgs int) { + help := addBoolFlag([]string{"h", "help"}, f) + f.Parse(os.Args[2:]) + if *help { + basicUsage() + os.Exit(0) + } + if len(f.Args()) > maxRemainingArgs { + fmt.Printf("ERROR unrecognized argument(s): %v\n", f.Args()[maxRemainingArgs:]) + basicUsage() + os.Exit(1) + } +} + +func basicUsage() { + const ( + bold = "\033[1m" + reset = "\033[0m" + ) + fmt.Print(`usage: + +` + bold + `reaction start` + reset + ` + # start the daemon + + # options: + -c/--config CONFIG_FILE # configuration file (required) + -s/--socket SOCKET # path to the client-daemon communication socket + # (default: /run/reaction/reaction.sock) + +` + bold + `reaction example-conf` + reset + ` + # print a configuration file example + +` + bold + `reaction show` + reset + ` [.STREAM[.FILTER]] + # show which actions are still to be run + # (e.g know what is currenly banned) + + # optional argument: limit to STREAM and FILTER + + # options: + -f/--format yaml|json # (default: yaml) + -s/--socket SOCKET # path to the client-daemon communication socket + +` + bold + `reaction flush` + reset + ` TARGET [.STREAM[.FILTER]] + # run currently pending actions for the specified TARGET + + # optional argument: limit to STREAM and FILTER + + # options: + -s/--socket SOCKET # path to the client-daemon communication socket + +` + bold + `reaction test-regex` + reset + ` REGEX LINE # test REGEX against LINE +cat FILE | ` + bold + `reaction test-regex` + reset + ` REGEX # test REGEX against each line of FILE +`) +} + +//go:embed reaction.yml +var exampleConf string + +func Main() { + if len(os.Args) <= 1 { + fmt.Println("No argument provided") + basicUsage() + os.Exit(1) + } else if os.Args[1] == "-h" || os.Args[1] == "--help" { + basicUsage() + os.Exit(0) + } + f := flag.NewFlagSet(os.Args[1], flag.ContinueOnError) + switch os.Args[1] { + case "help", "-h", "--help": + basicUsage() + + case "example-conf": + subCommandParse(f, 0) + fmt.Print(exampleConf) + + case "start": + SocketPath = addSocketFlag(f) + confFilename := addConfFlag(f) + subCommandParse(f, 0) + if *confFilename == "" { + fmt.Println("no configuration file provided") + basicUsage() + os.Exit(1) + } + Daemon(*confFilename) + + case "show": + SocketPath = addSocketFlag(f) + queryFormat := addFormatFlag(f) + subCommandParse(f, 1) + // if *queryFormat != "yaml" && *queryFormat != "json" { + // fmt.Println("only `yaml` and `json` formats are supported.") + // f.PrintDefaults() + // os.Exit(1) + // } + if *queryFormat != "yaml" { + fmt.Println("for now, only `yaml` format is supported.") + os.Exit(1) + } + if f.Arg(0) != "" { + fmt.Println("for now, .STREAM.FILTER is not supported") + os.Exit(1) + } + // f.Arg(0) is "" if there is no remaining argument + ClientQuery(f.Arg(0)) + + case "flush": + SocketPath = addSocketFlag(f) + subCommandParse(f, 2) + if f.Arg(0) == "" { + fmt.Println("subcommand flush takes at least one TARGET argument") + os.Exit(1) + } + if f.Arg(1) != "" { + fmt.Println("for now, the .stream[.filter] argument is not supported") + os.Exit(1) + } + ClientFlush(f.Arg(0), f.Arg(1)) + + case "test-regex": + // socket not needed, no interaction with the daemon + subCommandParse(f, 2) + if f.Arg(0) == "" { + fmt.Println("subcommand test-regex takes at least one REGEX argument") + basicUsage() + os.Exit(1) + } + regex, err := regexp.Compile(f.Arg(0)) + if err != nil { + fmt.Printf("ERROR the specified regex is invalid: %v", err) + os.Exit(1) + } + if f.Arg(1) == "" { + fmt.Println("INFO no second argument. reading from stdin.") + + MatchStdin(regex) + } else { + Match(regex, f.Arg(1)) + } + + default: + fmt.Println("subcommand not recognized") + basicUsage() + os.Exit(1) + } +} diff --git a/app/pipe.go b/app/pipe.go index fa02e8a..0bc97ed 100644 --- a/app/pipe.go +++ b/app/pipe.go @@ -60,17 +60,20 @@ func (a *ActionStore) Quit() { } // Called by a CLI -func (a *ActionStore) Flush(pattern string) { +func (a *ActionStore) Flush(pattern string) int { + var cpt int a.mutex.Lock() defer a.mutex.Unlock() if a.store[pattern] != nil { for _, action := range a.store[pattern] { for sig := range action { - close(sig) + sig <- true } + cpt++ } } delete(a.store, pattern) + return cpt } // Called by a CLI @@ -111,19 +114,19 @@ func (r ReadableMap) ToString() string { // Socket-related, server-related functions func createOpenSocket() net.Listener { - err := os.MkdirAll(path.Dir(SocketPath), 0755) + err := os.MkdirAll(path.Dir(*SocketPath), 0755) if err != nil { log.Fatalln("FATAL Failed to create socket directory") } - _, err = os.Stat(SocketPath) + _, err = os.Stat(*SocketPath) if err == nil { log.Println("WARN socket", SocketPath, "already exists: Is the daemon already running? Deleting.") - err = os.Remove(SocketPath) + err = os.Remove(*SocketPath) if err != nil { log.Fatalln("FATAL Failed to remove socket:", err) } } - ln, err := net.Listen("unix", SocketPath) + ln, err := net.Listen("unix", *SocketPath) if err != nil { log.Fatalln("FATAL Failed to create socket:", err) } @@ -131,7 +134,7 @@ func createOpenSocket() net.Listener { } // Handle connections -func Serve() { +func ServeSocket() { ln := createOpenSocket() defer ln.Close() for { @@ -154,7 +157,7 @@ func Serve() { case Query: response.Actions = actionStore.store.ToReadable() case Flush: - actionStore.Flush(request.Pattern) + response.Number = actionStore.Flush(request.Pattern) default: log.Println("ERROR Invalid Message from cli: unrecognised Request type") return diff --git a/app/reaction.yml b/app/reaction.yml new file mode 100644 index 0000000..0165849 --- /dev/null +++ b/app/reaction.yml @@ -0,0 +1,54 @@ +--- +# definitions are just a place to put chunks of conf you want to reuse in another place +# they're not readed by reaction +definitions: + - &iptablesban [ "iptables" "-w" "-A" "reaction" "1" "-s" "" "-j" "DROP" ] + - &iptablesunban [ "iptables" "-w" "-D" "reaction" "1" "-s" "" "-j" "DROP" ] + +# patterns are substitued in regexes. +# when a filter performs an action, it replaces the found pattern +patterns: + ip: + # reaction regex syntax is defined here: https://github.com/google/re2/wiki/Syntax + regex: '(?:(?:[0-9]{1,3}\.){3}[0-9]{1,3})|(?:[0-9a-fA-F:]{2,90})' + ignore: + - 127.0.0.1 + - ::1 + +# streams are command that are run +# their output will be used by one or more filters +streams: + # streams have a user-defined name + ssh: + # note that if the command is not in environment's `PATH` + # its full path must be given. + cmd: [ "journalctl" "-fu" "sshd.service" ] + # filters are a set of regexes on a stream + # when a regex matches, it will trigger the filter's actions + filters: + # filters have a user-defined name + failedlogin: + # reaction regex syntax is defined here: https://github.com/google/re2/wiki/Syntax + regex: + - authentication failure;.*rhost= + # if retry and retry-period are defined, + # the actions will only take place if a same pattern is + # found `retry` times in a `retry-period` interval + retry: 3 + # format is defined here: https://pkg.go.dev/time#ParseDuration + retry-period: 6h + actions: + # actions have a user-defined name + ban: + # YAML substitutes *reference by the value at &reference + cmd: *iptablesban + unban: + cmd: *iptablesunban + # if after is defined, the action will not take place immediately, but after a specified duration. + # same format as retry-period + after: 48h + # let's say reaction is quitting. does it run all those pending commands which had an `after` duration set? + # if you want reaction to run those pending commands before exiting, you can set this: + # onexit: true + # (defaults to false) + # here it is not useful because we will flush the chain containing the bans anyway (see ./reaction.service) diff --git a/app/startup.go b/app/startup.go index 611d19b..1b525fb 100644 --- a/app/startup.go +++ b/app/startup.go @@ -173,7 +173,7 @@ func (c *Conf) setup() { } action.afterDuration = afterDuration } else if action.OnExit { - log.Fatalln("FATAL Bad configuration: Cannot have `onexit:true` without an `after` directive in", stream.name, ".", filter.name, ".", action.name) + log.Fatalln("FATAL Bad configuration: Cannot have `onexit: true` without an `after` directive in", stream.name, ".", filter.name, ".", action.name) } if filter.longuestActionDuration == nil || filter.longuestActionDuration.Milliseconds() < action.afterDuration.Milliseconds() { filter.longuestActionDuration = &action.afterDuration diff --git a/config/reaction.test.yml b/config/reaction.test.yml index 862ebce..95d9000 100644 --- a/config/reaction.test.yml +++ b/config/reaction.test.yml @@ -17,3 +17,6 @@ streams: actions: damn: cmd: [ "echo", "" ] + undamn: + cmd: [ "echo", "undamn", "" ] + after: 1m diff --git a/config/reaction.yml b/config/reaction.yml deleted file mode 100644 index a3e6310..0000000 --- a/config/reaction.yml +++ /dev/null @@ -1,52 +0,0 @@ ---- -# definitions are just a place to put chunks of conf you want to reuse in another place -# they're not readed by reaction -definitions: - - &iptablesban [ "iptables" "-w" "-A" "reaction" "1" "-s" "" "-j" "DROP" ] - - &iptablesunban [ "iptables" "-w" "-D" "reaction" "1" "-s" "" "-j" "DROP" ] - -# patterns are substitued in regexes. -# when a filter performs an action, it replaces the found pattern -patterns: - ip: - regex: '(?:(?:[0-9]{1,3}\.){3}[0-9]{1,3})|(?:[0-9a-fA-F:]{2,90})' - ignore: - - 127.0.0.1 - - ::1 - -# streams are command that are run -# their output will be used by one or more filters -streams: - # streams have a user-defined name - ssh: - # note that if the command is not in environment's `PATH` - # its full path must be given. - cmd: [ "journalctl" "-fu" "sshd.service" ] - # filters are a set of regexes on a stream - # when a regex matches, it will trigger the filter's actions - filters: - # filters have a user-defined name - failedlogin: - regex: - - authentication failure;.*rhost= - # if retry and retry-period are defined, - # the actions will only take place if a same pattern is - # found `retry` times in a `retry-period` interval - retry: 3 - # format is defined here: https://pkg.go.dev/time#ParseDuration - retry-period: 6h - actions: - # actions have a user-defined name - ban: - # YAML substitutes *reference by the value at &reference - cmd: *iptablesban - unban: - cmd: *iptablesunban - # if after is defined, the action will not take place immediately, but after a specified duration. - # same format as retry-period - after: 48h - # let's say reaction is quitting. does it run all those pending commands which had an `after` duration set? - # if you want reaction to run those pending commands before exiting, you can set this: - # onexit: true - # (defaults to false) - # here it is not useful because we will flush the chain containing the bans anyway (see ./reaction.service) diff --git a/config/reaction.yml b/config/reaction.yml new file mode 120000 index 0000000..bef3f04 --- /dev/null +++ b/config/reaction.yml @@ -0,0 +1 @@ +app/reaction.yml \ No newline at end of file diff --git a/reaction/main.go b/reaction.go similarity index 100% rename from reaction/main.go rename to reaction.go diff --git a/reactionc/main.go b/reactionc/main.go deleted file mode 100644 index 43b2039..0000000 --- a/reactionc/main.go +++ /dev/null @@ -1,9 +0,0 @@ -package main - -import ( - "framagit.org/ppom/reaction/app" -) - -func main() { - app.CLI() -}