support json, jsonnet, yaml formats

- jsonnet, json and yaml support for configuration
- json and yaml support for output formats

fix #40
fix #27
This commit is contained in:
ppom 2023-10-04 12:00:00 +02:00
parent 3767fc6cf8
commit e56b851d15
11 changed files with 269 additions and 113 deletions

View file

@ -10,7 +10,7 @@ and takes action, such as banning ips.
## rationale ## rationale
i was using fail2ban since quite a long time, but i was a bit frustrated by it's cpu consumption i was using fail2ban since quite a long time, but i was a bit frustrated by its cpu consumption
and all its heavy default configuration. and all its heavy default configuration.
in my view, a security-oriented program should be simple to configure (`sudo` is a very bad example!) in my view, a security-oriented program should be simple to configure (`sudo` is a very bad example!)
@ -20,7 +20,7 @@ and an always-running daemon should be implemented in a fast language.
## configuration ## configuration
this configuration file is all that should be needed to prevent bruteforce attacks on an ssh server. this configuration file is all that should be needed to prevent brute force attacks on an ssh server.
see [reaction.service](./config/reaction.service) and [reaction.yml](./app/reaction.yml) for the fully explained examples. see [reaction.service](./config/reaction.service) and [reaction.yml](./app/reaction.yml) for the fully explained examples.
@ -32,6 +32,9 @@ definitions:
patterns: patterns:
ip: '(([0-9]{1,3}\.){3}[0-9]{1,3})|([0-9a-fA-F:]{2,90})' ip: '(([0-9]{1,3}\.){3}[0-9]{1,3})|([0-9a-fA-F:]{2,90})'
ignore:
- '127.0.0.1'
- '::1'
streams: streams:
ssh: ssh:
@ -39,17 +42,57 @@ streams:
filters: filters:
failedlogin: failedlogin:
regex: regex:
- authentication failure;.*rhost=<ip> - 'authentication failure;.*rhost=<ip>'
retry: 3 retry: 3
retry-period: 6h retryperiod: '6h'
actions: actions:
ban: ban:
cmd: *iptablesban cmd: *iptablesban
unban: unban:
cmd: *iptablesunban cmd: *iptablesunban
after: 48h after: '48h'
``` ```
jsonnet is also supported:
`/etc/reaction.jsonnet`
```jsonnet
local iptablesban = ['iptables', '-w', '-A', 'reaction', '1', '-s', '<ip>', '-j', 'DROP'];
local iptablesunban = ['iptables', '-w', '-D', 'reaction', '1', '-s', '<ip>', '-j', 'DROP'];
{
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: {
ssh: {
cmd: ['journalctl', '-fu', 'sshd.service'],
filters: {
failedlogin: {
regex: [ @'authentication failure;.*rhost=<ip>' ],
retry: 3,
retryperiod: '6h',
actions: {
ban: {
cmd: iptablesban,
},
unban: {
cmd: iptablesunban,
after: '48h',
onexit: true,
},
},
},
},
},
},
}
```
note that both yaml and jsonnet are extensions of json, so it is also inherently supported.
`/etc/systemd/system/reaction.service` `/etc/systemd/system/reaction.service`
```systemd ```systemd
[Unit] [Unit]
@ -92,7 +135,7 @@ $ go build .
in addition to the [package](https://framagit.org/ppom/nixos/-/blob/cf5448b21ae3386265485308a6cd077e8068ad77/pkgs/reaction/default.nix) in addition to the [package](https://framagit.org/ppom/nixos/-/blob/cf5448b21ae3386265485308a6cd077e8068ad77/pkgs/reaction/default.nix)
and [module](https://framagit.org/ppom/nixos/-/blob/cf5448b21ae3386265485308a6cd077e8068ad77/modules/common/reaction.nix) and [module](https://framagit.org/ppom/nixos/-/blob/cf5448b21ae3386265485308a6cd077e8068ad77/modules/common/reaction.nix)
that i didn't tried to upstream to nixpkgs yet (although they are ready), i use extensively reaction on my servers. if you're using nixos, that i didn't try to upstream to nixpkgs yet (although they are ready), i use extensively reaction on my servers. if you're using nixos,
consider reading and building upon [my own building blocks](https://framagit.org/ppom/nixos/-/blob/cf5448b21ae3386265485308a6cd077e8068ad77/modules/common/reaction-variables.nix), consider reading and building upon [my own building blocks](https://framagit.org/ppom/nixos/-/blob/cf5448b21ae3386265485308a6cd077e8068ad77/modules/common/reaction-variables.nix),
[my own non-root reaction conf, including conf for SSH, port scanning & Nginx common attack URLS](https://framagit.org/ppom/nixos/-/blob/cf5448b21ae3386265485308a6cd077e8068ad77/modules/common/reaction-custom.nix), [my own non-root reaction conf, including conf for SSH, port scanning & Nginx common attack URLS](https://framagit.org/ppom/nixos/-/blob/cf5448b21ae3386265485308a6cd077e8068ad77/modules/common/reaction-custom.nix),
and the configuration for [nextcloud](https://framagit.org/ppom/nixos/-/blob/cf5448b21ae3386265485308a6cd077e8068ad77/modules/musi/file.ppom.me.nix#L53), and the configuration for [nextcloud](https://framagit.org/ppom/nixos/-/blob/cf5448b21ae3386265485308a6cd077e8068ad77/modules/musi/file.ppom.me.nix#L53),

View file

@ -3,13 +3,14 @@ package app
import ( import (
"bufio" "bufio"
"encoding/gob" "encoding/gob"
"encoding/json"
"fmt" "fmt"
"log" "log"
"net" "net"
"os" "os"
"regexp" "regexp"
"gopkg.in/yaml.v3" "sigs.k8s.io/yaml"
) )
const ( const (
@ -48,8 +49,8 @@ func SendAndRetrieve(data Request) Response {
} }
type PatternStatus struct { type PatternStatus struct {
Matches int `yaml:"matches"` Matches int `json:"matches,omitempty"`
Actions map[string][]string `yaml:"actions"` Actions map[string][]string `json:"actions,omitempty"`
} }
type MapPatternStatus map[string]*PatternStatus type MapPatternStatus map[string]*PatternStatus
type MapPatternStatusFlush MapPatternStatus type MapPatternStatusFlush MapPatternStatus
@ -57,53 +58,14 @@ type MapPatternStatusFlush MapPatternStatus
type ClientStatus map[string]map[string]MapPatternStatus type ClientStatus map[string]map[string]MapPatternStatus
type ClientStatusFlush ClientStatus type ClientStatusFlush ClientStatus
// This block is made to hide pending_actions when empty func (mps MapPatternStatusFlush) MarshalJSON() ([]byte, error) {
// and matches_since_last_trigger when zero
type FullPatternStatus PatternStatus
type MatchesStatus struct {
Matches int `yaml:"matches"`
}
type ActionsStatus struct {
Actions map[string][]string `yaml:"actions"`
}
func (mps MapPatternStatus) MarshalYAML() (interface{}, error) {
ret := make(map[string]interface{})
for k, v := range mps {
if v.Matches == 0 {
if len(v.Actions) != 0 {
ret[k] = ActionsStatus{v.Actions}
}
} else {
if len(v.Actions) != 0 {
ret[k] = v
} else {
ret[k] = MatchesStatus{v.Matches}
}
}
}
return ret, nil
}
func (mps MapPatternStatusFlush) MarshalYAML() (interface{}, error) {
var ret interface{}
for _, v := range mps { for _, v := range mps {
if v.Matches == 0 { return json.Marshal(v)
if len(v.Actions) != 0 {
ret = ActionsStatus{v.Actions}
}
} else {
if len(v.Actions) != 0 {
ret = v
} else {
ret = MatchesStatus{v.Matches}
}
}
} }
return ret, nil return []byte(""), nil
} }
func (csf ClientStatusFlush) MarshalYAML() (interface{}, error) { func (csf ClientStatusFlush) MarshalJSON() ([]byte, error) {
ret := make(map[string]map[string]MapPatternStatusFlush) ret := make(map[string]map[string]MapPatternStatusFlush)
for k, v := range csf { for k, v := range csf {
ret[k] = make(map[string]MapPatternStatusFlush) ret[k] = make(map[string]MapPatternStatusFlush)
@ -111,7 +73,7 @@ func (csf ClientStatusFlush) MarshalYAML() (interface{}, error) {
ret[k][kk] = MapPatternStatusFlush(vv) ret[k][kk] = MapPatternStatusFlush(vv)
} }
} }
return ret, nil return json.Marshal(ret)
} }
// end block // end block
@ -122,13 +84,19 @@ func usage(err string) {
log.Fatalln(err) log.Fatalln(err)
} }
func ClientShow(streamfilter string) { func ClientShow(streamfilter, format string) {
response := SendAndRetrieve(Request{Show, streamfilter}) response := SendAndRetrieve(Request{Show, streamfilter})
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) os.Exit(1)
} }
text, err := yaml.Marshal(response.ClientStatus) var text []byte
var err error
if format == "json" {
text, err = json.MarshalIndent(response.ClientStatus, "", " ")
} else {
text, err = yaml.Marshal(response.ClientStatus)
}
if err != nil { if err != nil {
log.Fatalln("Failed to convert daemon binary response to text format:", err) log.Fatalln("Failed to convert daemon binary response to text format:", err)
} }
@ -136,13 +104,19 @@ func ClientShow(streamfilter string) {
os.Exit(0) os.Exit(0)
} }
func ClientFlush(pattern, streamfilter string) { func ClientFlush(pattern, streamfilter, format string) {
response := SendAndRetrieve(Request{Flush, pattern}) response := SendAndRetrieve(Request{Flush, pattern})
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) os.Exit(1)
} }
text, err := yaml.Marshal(ClientStatusFlush(response.ClientStatus)) var text []byte
var err error
if format == "json" {
text, err = json.MarshalIndent(ClientStatusFlush(response.ClientStatus), "", " ")
} else {
text, err = yaml.Marshal(ClientStatusFlush(response.ClientStatus))
}
if err != nil { if err != nil {
log.Fatalln("Failed to convert daemon binary response to text format:", err) log.Fatalln("Failed to convert daemon binary response to text format:", err)
} }

View file

@ -57,8 +57,7 @@ func subCommandParse(f *flag.FlagSet, maxRemainingArgs int) {
} }
// FIXME add this options for show & flush // FIXME add this options for show & flush
// -l/--limit .STREAM[.FILTER] # limit to stream and filter // -l/--limit .STREAM[.FILTER] # limit to stream and filter
// -f/--format yaml|json # (default: yaml)
func basicUsage() { func basicUsage() {
const ( const (
bold = "\033[1m" bold = "\033[1m"
@ -70,9 +69,9 @@ func basicUsage() {
# start the daemon # start the daemon
# options: # options:
-c/--config CONFIG_FILE # configuration file (required) -c/--config CONFIG_FILE # configuration file in json, jsonnet or yaml format (required)
-s/--socket SOCKET # path to the client-daemon communication socket -s/--socket SOCKET # path to the client-daemon communication socket
# (default: /run/reaction/reaction.sock) # (default: /run/reaction/reaction.sock)
` + bold + `reaction example-conf` + reset + ` ` + bold + `reaction example-conf` + reset + `
# print a configuration file example # print a configuration file example
@ -82,17 +81,19 @@ func basicUsage() {
# (e.g know what is currenly banned) # (e.g know what is currenly banned)
# options: # options:
-s/--socket SOCKET # path to the client-daemon communication socket -s/--socket SOCKET # path to the client-daemon communication socket
-f/--format yaml|json # (default: yaml)
` + bold + `reaction flush` + reset + ` TARGET ` + bold + `reaction flush` + reset + ` TARGET
# run currently active matches and pending actions for the specified TARGET # run currently active matches and pending actions for the specified TARGET
# (then show flushed matches and actions) # (then show flushed matches and actions)
# options: # options:
-s/--socket SOCKET # path to the client-daemon communication socket -s/--socket SOCKET # path to the client-daemon communication socket
-f/--format yaml|json # (default: yaml)
` + bold + `reaction test-regex` + reset + ` REGEX LINE # test REGEX against LINE ` + 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 cat FILE | ` + bold + `reaction test-regex` + reset + ` REGEX # test REGEX against each line of FILE
`) `)
} }
@ -133,13 +134,9 @@ func Main() {
queryFormat := addFormatFlag(f) queryFormat := addFormatFlag(f)
limit := addLimitFlag(f) limit := addLimitFlag(f)
subCommandParse(f, 0) subCommandParse(f, 0)
// if *queryFormat != "yaml" && *queryFormat != "json" { if *queryFormat != "yaml" && *queryFormat != "json" {
// fmt.Println("only `yaml` and `json` formats are supported.") fmt.Println("only yaml and json formats are supported")
// f.PrintDefaults() f.PrintDefaults()
// os.Exit(1)
// }
if *queryFormat != "yaml" {
fmt.Println("for now, only `yaml` format is supported.")
os.Exit(1) os.Exit(1)
} }
if *limit != "" { if *limit != "" {
@ -147,12 +144,18 @@ func Main() {
os.Exit(1) os.Exit(1)
} }
// f.Arg(0) is "" if there is no remaining argument // f.Arg(0) is "" if there is no remaining argument
ClientShow(*limit) ClientShow(*limit, *queryFormat)
case "flush": case "flush":
SocketPath = addSocketFlag(f) SocketPath = addSocketFlag(f)
queryFormat := addFormatFlag(f)
limit := addLimitFlag(f) limit := addLimitFlag(f)
subCommandParse(f, 1) subCommandParse(f, 1)
if *queryFormat != "yaml" && *queryFormat != "json" {
fmt.Println("only yaml and json formats are supported")
f.PrintDefaults()
os.Exit(1)
}
if f.Arg(0) == "" { if f.Arg(0) == "" {
fmt.Println("subcommand flush takes one TARGET argument") fmt.Println("subcommand flush takes one TARGET argument")
basicUsage() basicUsage()
@ -162,7 +165,7 @@ func Main() {
fmt.Println("for now, -l/--limit is not supported") fmt.Println("for now, -l/--limit is not supported")
os.Exit(1) os.Exit(1)
} }
ClientFlush(f.Arg(0), f.Arg(1)) ClientFlush(f.Arg(0), *limit, *queryFormat)
case "test-regex": case "test-regex":
// socket not needed, no interaction with the daemon // socket not needed, no interaction with the daemon
@ -178,7 +181,7 @@ func Main() {
os.Exit(1) os.Exit(1)
} }
if f.Arg(1) == "" { if f.Arg(1) == "" {
fmt.Println("INFO no second argument. reading from stdin.") fmt.Println("INFO no second argument: reading from stdin")
MatchStdin(regex) MatchStdin(regex)
} else { } else {

View file

@ -35,12 +35,12 @@ streams:
# <ip> is predefined in the patterns section # <ip> is predefined in the patterns section
# ip's regex is inserted in the following regex # ip's regex is inserted in the following regex
- authentication failure;.*rhost=<ip> - authentication failure;.*rhost=<ip>
# if retry and retry-period are defined, # if retry and retryperiod are defined,
# the actions will only take place if a same pattern is # the actions will only take place if a same pattern is
# found `retry` times in a `retry-period` interval # found `retry` times in a `retryperiod` interval
retry: 3 retry: 3
# format is defined here: https://pkg.go.dev/time#ParseDuration # format is defined here: https://pkg.go.dev/time#ParseDuration
retry-period: 6h retryperiod: 6h
# actions are run by the filter when regexes are matched # actions are run by the filter when regexes are matched
actions: actions:
# actions have a user-defined name # actions have a user-defined name
@ -50,7 +50,7 @@ streams:
unban: unban:
cmd: *iptablesunban cmd: *iptablesunban
# if after is defined, the action will not take place immediately, but after a specified duration # if after is defined, the action will not take place immediately, but after a specified duration
# same format as retry-period # same format as retryperiod
after: 48h after: 48h
# let's say reaction is quitting. does it run all those pending commands which had an `after` duration set? # 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: # if you want reaction to run those pending commands before exiting, you can set this:

View file

@ -1,6 +1,7 @@
package app package app
import ( import (
"encoding/json"
"fmt" "fmt"
"log" "log"
"os" "os"
@ -8,7 +9,7 @@ import (
"strings" "strings"
"time" "time"
"gopkg.in/yaml.v3" "github.com/google/go-jsonnet"
) )
func (c *Conf) setup() { func (c *Conf) setup() {
@ -131,13 +132,21 @@ func (c *Conf) setup() {
func parseConf(filename string) *Conf { func parseConf(filename string) *Conf {
data, err := os.ReadFile(filename) data, err := os.Open(filename)
if err != nil { if err != nil {
log.Fatalln("FATAL Failed to read configuration file:", err) log.Fatalln("FATAL Failed to read configuration file:", err)
} }
var conf Conf var conf Conf
err = yaml.Unmarshal(data, &conf) if filename[len(filename)-4:] == ".yml" || filename[len(filename)-5:] == ".yaml" {
err = jsonnet.NewYAMLToJSONDecoder(data).Decode(&conf)
} else {
var jsondata string
jsondata, err = jsonnet.MakeVM().EvaluateFile(filename)
if err == nil {
err = json.Unmarshal([]byte(jsondata), &conf)
}
}
if err != nil { if err != nil {
log.Fatalln("FATAL Failed to parse configuration file:", err) log.Fatalln("FATAL Failed to parse configuration file:", err)
} }

View file

@ -8,54 +8,54 @@ import (
) )
type Conf struct { type Conf struct {
Patterns map[string]*Pattern `yaml:"patterns"` Patterns map[string]*Pattern `json:"patterns"`
Streams map[string]*Stream `yaml:"streams"` Streams map[string]*Stream `json:"streams"`
} }
type Pattern struct { type Pattern struct {
Regex string `yaml:"regex"` Regex string `json:"regex"`
Ignore []string `yaml:"ignore"` Ignore []string `json:"ignore"`
name string `yaml:"-"` name string `json:"-"`
nameWithBraces string `yaml:"-"` nameWithBraces string `json:"-"`
} }
// Stream, Filter & Action structures must never be copied. // Stream, Filter & Action structures must never be copied.
// They're always referenced through pointers // They're always referenced through pointers
type Stream struct { type Stream struct {
name string `yaml:"-"` name string `json:"-"`
Cmd []string `yaml:"cmd"` Cmd []string `json:"cmd"`
Filters map[string]*Filter `yaml:"filters"` Filters map[string]*Filter `json:"filters"`
} }
type Filter struct { type Filter struct {
stream *Stream `yaml:"-"` stream *Stream `json:"-"`
name string `yaml:"-"` name string `json:"-"`
Regex []string `yaml:"regex"` Regex []string `json:"regex"`
compiledRegex []regexp.Regexp `yaml:"-"` compiledRegex []regexp.Regexp `json:"-"`
pattern *Pattern `yaml:"-"` pattern *Pattern `json:"-"`
Retry int `yaml:"retry"` Retry int `json:"retry"`
RetryPeriod string `yaml:"retry-period"` RetryPeriod string `json:"retryperiod"`
retryDuration time.Duration `yaml:"-"` retryDuration time.Duration `json:"-"`
Actions map[string]*Action `yaml:"actions"` Actions map[string]*Action `json:"actions"`
longuestActionDuration *time.Duration longuestActionDuration *time.Duration
} }
type Action struct { type Action struct {
filter *Filter `yaml:"-"` filter *Filter `json:"-"`
name string `yaml:"-"` name string `json:"-"`
Cmd []string `yaml:"cmd"` Cmd []string `json:"cmd"`
After string `yaml:"after"` After string `json:"after"`
afterDuration time.Duration `yaml:"-"` afterDuration time.Duration `json:"-"`
OnExit bool `yaml:"onexit"` OnExit bool `json:"onexit"`
} }
type LogEntry struct { type LogEntry struct {

88
config/reaction.jsonnet Normal file
View file

@ -0,0 +1,88 @@
// This file is using JSONNET, a complete configuration language based on JSON
// See https://jsonnet.org
// JSONNET is a superset of JSON, so one can write plain JSON files if wanted.
// variables defined for later use.
local iptablesban = ['iptables', '-w', '-A', 'reaction', '1', '-s', '<ip>', '-j', 'DROP'];
local 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
// jsonnet's @'string' is for verbatim strings
regex: @'(?:(?:[0-9]{1,3}\.){3}[0-9]{1,3})|(?:[0-9a-fA-F:]{2,90})',
ignore: ['127.0.0.1', '::1'],
},
},
// streams are commands
// they're run and their ouptut is captured
// *example:* `tail -f /var/log/nginx/access.log`
// 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 run actions when they match regexes on a stream
filters: {
// filters have a user-defined name
failedlogin: {
// reaction's regex syntax is defined here: https://github.com/google/re2/wiki/Syntax
regex: [
// <ip> is predefined in the patterns section
// ip's regex is inserted in the following regex
'authentication failure;.*rhost=<ip>',
],
// if retry and retryperiod are defined,
// the actions will only take place if a same pattern is
// found `retry` times in a `retryperiod` interval
retry: 3,
// format is defined here: https://pkg.go.dev/time#ParseDuration
retryperiod: '6h',
// actions are run by the filter when regexes are matched
actions: {
// actions have a user-defined name
ban: {
// JSONNET substitutes the variable (defined at the beginning of the file)
cmd: iptablesban,
},
unban: {
cmd: iptablesunban,
// if after is defined, the action will not take place immediately, but after a specified duration
// same format as retryperiod
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 /conf/reaction.service)
},
},
},
},
},
},
}
// persistence
// tldr; when an `after` action is set in a filter, such filter acts as a 'jail',
// which is persisted after reboots.
// full;
// when a filter is triggered, there are 2 flows:
//
// if none of its actions have an `after` directive set:
// no action will be replayed.
//
// else (if at least one action has an `after` directive set):
// if reaction stops while `after` actions are pending:
// and reaction starts again while those actions would still be pending:
// reaction executes the past actions (actions without after or with then+after < now)
// and plans the execution of future actions (actions with then+after > now)

View file

@ -0,0 +1,30 @@
{
patterns: {
num: {
regex: '[0-9]+',
},
},
streams: {
tailDown1: {
cmd: ['sh', '-c', "echo 1 2 3 4 5 1 2 3 4 5 1 2 3 4 1 2 3 4 1 2 3 4 1 2 3 4 1 2 3 4 1 2 3 4 1 2 3 4 1 2 3 4 1 2 3 4 1 2 3 4 1 2 3 4 1 2 3 4 1 2 3 4 1 2 3 4 1 2 3 4 1 2 3 4 1 2 3 4 1 2 3 4 1 2 3 4 1 2 3 4 1 2 3 4 1 2 3 4 1 2 3 4 1 2 3 4 1 2 3 4 | tr ' ' '\n' | while read i; do sleep 2; echo found $(($i % 10)); done"],
filters: {
findIP: {
regex: ['^found <num>$'],
retry: 3,
retryperiod: '30s',
actions: {
damn: {
cmd: ['echo', '<num>'],
},
undamn: {
cmd: ['echo', 'undamn', '<num>'],
after: '30s',
onexit: true,
},
},
},
},
},
},
}

View file

@ -15,7 +15,7 @@ streams:
regex: regex:
- '^found <num>$' - '^found <num>$'
retry: 3 retry: 3
retry-period: 30s retryperiod: 30s
actions: actions:
damn: damn:
cmd: [ "echo", "<num>" ] cmd: [ "echo", "<num>" ]

5
go.mod
View file

@ -3,5 +3,8 @@ module framagit.org/ppom/reaction
go 1.19 go 1.19
require ( require (
gopkg.in/yaml.v3 v3.0.1 github.com/google/go-jsonnet v0.20.0
sigs.k8s.io/yaml v1.1.0
) )
require gopkg.in/yaml.v2 v2.4.0 // indirect

12
go.sum
View file

@ -1,4 +1,10 @@
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/google/go-jsonnet v0.20.0 h1:WG4TTSARuV7bSm4PMB4ohjxe33IHT5WVTrJSU33uT4g=
github.com/google/go-jsonnet v0.20.0/go.mod h1:VbgWF9JX7ztlv770x/TolZNGGFfiHEVx9G6ca2eUmeA=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v2 v2.2.7 h1:VUgggvou5XRW9mHwD/yXxIYSMtY0zoKQf/v226p2nyo=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v2 v2.2.7/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
sigs.k8s.io/yaml v1.1.0 h1:4A07+ZFc2wgJwo8YNlQpr1rVlgUDlxXHhPJciaPY5gs=
sigs.k8s.io/yaml v1.1.0/go.mod h1:UJmg0vDUVViEyp3mgSv9WPwZCDxu4rQW1olrI1uml+o=