New unified CLI design

fixes #25
thanks @bertille-ddp for comments && suggestions!
This commit is contained in:
ppom 2023-09-03 12:13:18 +02:00
parent cbfad9303f
commit 799ba88823
11 changed files with 306 additions and 106 deletions

1
.gitignore vendored
View File

@ -1,2 +1,3 @@
/reaction.db /reaction.db
/result /result
/reaction

View File

@ -1,11 +1,13 @@
package app package app
import ( import (
"bufio"
"encoding/gob" "encoding/gob"
"fmt" "fmt"
"log" "log"
"net" "net"
"os" "os"
"regexp"
) )
const ( const (
@ -21,12 +23,11 @@ type Request struct {
type Response struct { type Response struct {
Err error Err error
Actions ReadableMap Actions ReadableMap
Number int
} }
const SocketPath = "/run/reaction/reaction.sock"
func SendAndRetrieve(data Request) Response { func SendAndRetrieve(data Request) Response {
conn, err := net.Dial("unix", SocketPath) conn, err := net.Dial("unix", *SocketPath)
if err != nil { if err != nil {
log.Fatalln("Error opening connection top daemon:", err) log.Fatalln("Error opening connection top daemon:", err)
} }
@ -50,26 +51,37 @@ func usage(err string) {
log.Fatalln(err) log.Fatalln(err)
} }
func CLI() { func ClientQuery(streamfilter string) {
if len(os.Args) <= 1 { response := SendAndRetrieve(Request{Query, streamfilter})
response := SendAndRetrieve(Request{Query, ""})
if response.Err != nil { if response.Err != nil {
log.Fatalln("Received error from daemon:", response.Err) log.Fatalln("Received error from daemon:", response.Err)
os.Exit(1)
} }
fmt.Println(response.Actions.ToString()) fmt.Println(response.Actions.ToString())
os.Exit(0) os.Exit(0)
} }
switch os.Args[1] {
case "flush": func ClientFlush(pattern, streamfilter string) {
if len(os.Args) != 3 { response := SendAndRetrieve(Request{Flush, pattern})
usage("flush takes one <PATTERN> argument")
}
response := SendAndRetrieve(Request{Flush, os.Args[2]})
if response.Err != nil { if response.Err != nil {
log.Fatalln("Received error from daemon:", response.Err) log.Fatalln("Received error from daemon:", response.Err)
os.Exit(1)
} }
fmt.Printf("flushed pattern %v times\n", response.Number)
os.Exit(0) os.Exit(0)
default: }
usage("first argument must be `flush`")
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())
} }
} }

View File

@ -3,7 +3,6 @@ package app
import ( import (
"bufio" "bufio"
"encoding/gob" "encoding/gob"
"flag"
"syscall" "syscall"
// "fmt" // "fmt"
@ -198,18 +197,10 @@ var wgActions sync.WaitGroup
var db *gob.Encoder var db *gob.Encoder
func Main() { func Daemon(confFilename string) {
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)
}
actionStore.store = make(ActionMap) actionStore.store = make(ActionMap)
conf := parseConf(*confFilename) conf := parseConf(confFilename)
db = conf.updateFromDB() db = conf.updateFromDB()
// Ready to start // Ready to start
@ -226,7 +217,7 @@ func Main() {
go stream.handle(endSignals) go stream.handle(endSignals)
} }
go Serve() go ServeSocket()
for { for {
select { select {
@ -251,7 +242,7 @@ func quit() {
// wait for them to complete // wait for them to complete
wgActions.Wait() wgActions.Wait()
// delete pipe // delete pipe
err := os.Remove(SocketPath) err := os.Remove(*SocketPath)
if err != nil { if err != nil {
log.Println("Failed to remove socket:", err) log.Println("Failed to remove socket:", err)
} }

196
app/main.go Normal file
View File

@ -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)
}
}

View File

@ -60,17 +60,20 @@ func (a *ActionStore) Quit() {
} }
// Called by a CLI // Called by a CLI
func (a *ActionStore) Flush(pattern string) { func (a *ActionStore) Flush(pattern string) int {
var cpt int
a.mutex.Lock() a.mutex.Lock()
defer a.mutex.Unlock() defer a.mutex.Unlock()
if a.store[pattern] != nil { if a.store[pattern] != nil {
for _, action := range a.store[pattern] { for _, action := range a.store[pattern] {
for sig := range action { for sig := range action {
close(sig) sig <- true
} }
cpt++
} }
} }
delete(a.store, pattern) delete(a.store, pattern)
return cpt
} }
// Called by a CLI // Called by a CLI
@ -111,19 +114,19 @@ func (r ReadableMap) ToString() string {
// Socket-related, server-related functions // Socket-related, server-related functions
func createOpenSocket() net.Listener { func createOpenSocket() net.Listener {
err := os.MkdirAll(path.Dir(SocketPath), 0755) err := os.MkdirAll(path.Dir(*SocketPath), 0755)
if err != nil { if err != nil {
log.Fatalln("FATAL Failed to create socket directory") log.Fatalln("FATAL Failed to create socket directory")
} }
_, err = os.Stat(SocketPath) _, err = os.Stat(*SocketPath)
if err == nil { if err == nil {
log.Println("WARN socket", SocketPath, "already exists: Is the daemon already running? Deleting.") log.Println("WARN socket", SocketPath, "already exists: Is the daemon already running? Deleting.")
err = os.Remove(SocketPath) err = os.Remove(*SocketPath)
if err != nil { if err != nil {
log.Fatalln("FATAL Failed to remove socket:", err) log.Fatalln("FATAL Failed to remove socket:", err)
} }
} }
ln, err := net.Listen("unix", SocketPath) ln, err := net.Listen("unix", *SocketPath)
if err != nil { if err != nil {
log.Fatalln("FATAL Failed to create socket:", err) log.Fatalln("FATAL Failed to create socket:", err)
} }
@ -131,7 +134,7 @@ func createOpenSocket() net.Listener {
} }
// Handle connections // Handle connections
func Serve() { func ServeSocket() {
ln := createOpenSocket() ln := createOpenSocket()
defer ln.Close() defer ln.Close()
for { for {
@ -154,7 +157,7 @@ func Serve() {
case Query: case Query:
response.Actions = actionStore.store.ToReadable() response.Actions = actionStore.store.ToReadable()
case Flush: case Flush:
actionStore.Flush(request.Pattern) response.Number = actionStore.Flush(request.Pattern)
default: default:
log.Println("ERROR Invalid Message from cli: unrecognised Request type") log.Println("ERROR Invalid Message from cli: unrecognised Request type")
return return

54
app/reaction.yml Normal file
View File

@ -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" "<ip>" "-j" "DROP" ]
- &iptablesunban [ "iptables" "-w" "-D" "reaction" "1" "-s" "<ip>" "-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=<ip>
# 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)

View File

@ -17,3 +17,6 @@ streams:
actions: actions:
damn: damn:
cmd: [ "echo", "<ip>" ] cmd: [ "echo", "<ip>" ]
undamn:
cmd: [ "echo", "undamn", "<ip>" ]
after: 1m

View File

@ -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" "<ip>" "-j" "DROP" ]
- &iptablesunban [ "iptables" "-w" "-D" "reaction" "1" "-s" "<ip>" "-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=<ip>
# 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)

1
config/reaction.yml Symbolic link
View File

@ -0,0 +1 @@
app/reaction.yml

View File

@ -1,9 +0,0 @@
package main
import (
"framagit.org/ppom/reaction/app"
)
func main() {
app.CLI()
}