Skip to content

Commit

Permalink
functional tests (#48)
Browse files Browse the repository at this point in the history
Signed-off-by: Shivam Sandbhor <[email protected]>
Signed-off-by: Shivam Sandbhor <[email protected]>
Co-authored-by: Shivam Sandbhor <[email protected]>
Co-authored-by: Shivam Sandbhor <[email protected]>
  • Loading branch information
3 people authored Feb 23, 2023
1 parent 910291b commit 4f39d84
Show file tree
Hide file tree
Showing 20 changed files with 773 additions and 17 deletions.
4 changes: 2 additions & 2 deletions .github/workflows/build-binary-package.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,12 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Set up Go 1.19
uses: actions/setup-go@v1
uses: actions/setup-go@v3
with:
go-version: 1.19
id: go
- name: Check out code into the Go module directory
uses: actions/checkout@v2
uses: actions/checkout@v3
- name: Build the binaries
run: make release
- name: Upload to release
Expand Down
34 changes: 34 additions & 0 deletions .github/workflows/functional_tests.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
name: Functional Tests

on:
push:
branches: [ main ]
pull_request:
branches: [ main ]

jobs:
install_crowdsec:
name: Build
runs-on: ubuntu-latest
steps:
- name: Set up Go 1.19
uses: actions/setup-go@v3
with:
go-version: 1.19
id: go
- name: Check out code into the Go module directory
uses: actions/checkout@v3
- id: cache-pipenv
uses: actions/cache@v3
with:
path: ~/.local/share/virtualenvs
key: ${{ runner.os }}-pipenv-${{ hashFiles('**/Pipfile.lock') }}
- name: Install deps for tests
run: |
sudo apt install -y nftables iptables ipset
python3 -m pip install --upgrade pipenv wheel
- name: Run tests
run: |
make build
pipenv install --deploy
pipenv run pytest
8 changes: 7 additions & 1 deletion .github/workflows/go.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,14 @@ jobs:
go-version: 1.19
id: go
- name: Check out code into the Go module directory
uses: actions/checkout@v2
uses: actions/checkout@v3
- name: Build
run: make build
- name: Test
run: go test -v
- name: golangci-lint
uses: golangci/golangci-lint-action@v3
with:
version: v1.51
args: --issues-exit-code=1 --timeout 10m
only-new-issues: false
6 changes: 1 addition & 5 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,9 +1,5 @@
# Binaries for programs and plugins
*.exe
*.exe~
*.dll
*.so
*.dylib
crowdsec-custom-bouncer

# Test binary, built with `go test -c`
*.test
Expand Down
9 changes: 8 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ export LD_OPTS=-ldflags "-s -w -X github.com/crowdsecurity/cs-custom-bouncer/pkg

RELDIR = "crowdsec-custom-bouncer-${BUILD_VERSION}"

PYTHON=python3
PIP=pip

all: clean test build

static: clean
Expand Down Expand Up @@ -51,4 +54,8 @@ release: build
@chmod +x $(RELDIR)/uninstall.sh
@chmod +x $(RELDIR)/upgrade.sh
@tar cvzf crowdsec-custom-bouncer.tgz $(RELDIR)


.PHONY: func-tests
func-tests: build
pipenv install --dev
pipenv run pytest -v
12 changes: 12 additions & 0 deletions Pipfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
[packages]
pytest = "*"
flask = "*"
pytimeparse = "*"
psutil = "*"

[dev-packages]
gnureadline = "*"
ipdb = "*"

[requires]
python_version = "3.10"
386 changes: 386 additions & 0 deletions Pipfile.lock

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions config/crowdsec-custom-bouncer.yaml
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
bin_path: ${BINARY_PATH}
feed_via_stdin: false # Invokes binary once and feeds incoming decisions to it's stdin.
total_retries: 0 # number of times to restart binary. relevant if feed_via_stdin=true . Set to -1 for infinite retries.
scenarios_containing: []
scenarios_not_containing: []
scenarios_containing: [] # ignore IPs banned for triggering scenarios not containing either of provided word, eg ["ssh", "http"]
scenarios_not_containing: [] # ignore IPs banned for triggering scenarios containing either of provided word
origins: []
piddir: /var/run/
update_frequency: 10s
Expand Down
5 changes: 4 additions & 1 deletion custom.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ type DecisionKey struct {

type DecisionWithAction struct {
models.Decision
ID int64 `json:"id"`
Action string `json:"action,omitempty"`
}

Expand Down Expand Up @@ -73,6 +74,7 @@ func (c *customBouncer) Add(decision *models.Decision) error {
}
if c.feedViaStdin {
fmt.Fprintln(c.binaryStdin, str)
c.newDecisionValueSet[decisionToDecisionKey(decision)] = struct{}{}
return nil
}
cmd := exec.Command(c.path, "add", *decision.Value, strconv.Itoa(int(banDuration.Seconds())), *decision.Scenario, str)
Expand All @@ -99,6 +101,7 @@ func (c *customBouncer) Delete(decision *models.Decision) error {
}
if c.feedViaStdin {
fmt.Fprintln(c.binaryStdin, str)
c.expiredDecisionValueSet[decisionToDecisionKey(decision)] = struct{}{}
return nil
}
if err != nil {
Expand All @@ -118,7 +121,7 @@ func (c *customBouncer) ShutDown() error {
}

func serializeDecision(decision *models.Decision, action string) (string, error) {
d := DecisionWithAction{Decision: *decision, Action: action}
d := DecisionWithAction{Decision: *decision, Action: action, ID: decision.ID}
serbyte, err := json.Marshal(d)
if err != nil {
return "", fmt.Errorf("serialize error : %s", err)
Expand Down
2 changes: 1 addition & 1 deletion custom_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ func parseLine(line string) parsedLine {

func cleanup() {
if _, err := os.Stat(binaryOutputFile); err != nil {
fmt.Println("didnt found the file")
fmt.Println("didn't found the file")
return
}
os.Remove(binaryOutputFile)
Expand Down
11 changes: 7 additions & 4 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -125,8 +125,10 @@ func main() {
return
}
cacheResetTicker := time.NewTicker(config.CacheRetentionDuration)

go bouncer.Run()
go func() {
bouncer.Run()
t.Kill(fmt.Errorf("stream init failed"))
}()
if config.PrometheusConfig.Enabled {
listenOn := net.JoinHostPort(
config.PrometheusConfig.ListenAddress,
Expand Down Expand Up @@ -178,7 +180,6 @@ func main() {
log.Error("maximum retries exceeded for binary. Exiting")
t.Kill(err)
return err

},
)

Expand All @@ -192,7 +193,9 @@ func main() {
log.Infoln("terminating bouncer process")
if config.PrometheusConfig.Enabled {
log.Infoln("terminating prometheus server")
promServer.Shutdown(context.Background())
if err := promServer.Shutdown(context.Background()); err != nil {
log.Errorf("unable to shutdown prometheus server: %s", err)
}
}
return nil
case decisions := <-bouncer.Stream:
Expand Down
3 changes: 3 additions & 0 deletions pytest-debug.ini
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
[pytest]
# drop to pdb on first failure
addopts = --pdb --pdbcls=IPython.terminal.debugger:Pdb
2 changes: 2 additions & 0 deletions pytest.ini
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
[pytest]
addopts =
Empty file added tests/__init__.py
Empty file.
139 changes: 139 additions & 0 deletions tests/mock_lapi.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
import datetime
import logging
from datetime import timedelta
from ipaddress import ip_address
from threading import Thread
from time import sleep

from flask import Flask, abort, request
from pytimeparse.timeparse import timeparse
from werkzeug.serving import make_server


# This is the "database" of our dummy LAPI
class DataStore:
def __init__(self) -> None:
self.id = 0
self.decisions = []
self.bouncer_lastpull_by_api_key = {}

def insert_decisions(self, decisions):
for i, _ in enumerate(decisions):
decisions[i]["created_at"] = datetime.datetime.now()
decisions[i]["deleted_at"] = self.get_decision_expiry_time(decisions[i])
decisions[i]["id"] = self.id
self.id += 1
self.decisions.extend(decisions)

# This methods can be made more generic by taking lambda expr as input for filtering
# decisions to delete
def delete_decisions_by_ip(self, ip):
for i, decision in enumerate(self.decisions):
if ip_address(decision["value"]) == ip_address(ip):
self.decisions[i]["deleted_at"] = datetime.datetime.now()

def delete_decision_by_id(self, id):
for i, decision in enumerate(self.decisions):
if decision["id"] == id:
self.decisions[i]["deleted_at"] = datetime.datetime.now()
break

def update_bouncer_pull(self, api_key):
self.bouncer_lastpull_by_api_key[api_key] = datetime.datetime.now()

def get_active_and_expired_decisions_since(self, since):
expired_decisions = []
active_decisions = []

for decision in self.decisions:
# decision["deleted_at"] > datetime.datetime.now() means that decision hasn't yet expired
if decision["deleted_at"] > since and decision["deleted_at"] < datetime.datetime.now():
expired_decisions.append(decision)

elif decision["created_at"] > since:
active_decisions.append(decision)
return active_decisions, expired_decisions

def get_decisions_for_bouncer(self, api_key, startup=False):
if startup or api_key not in self.bouncer_lastpull_by_api_key:
since = datetime.datetime.min
self.bouncer_lastpull_by_api_key[api_key] = since
else:
since = self.bouncer_lastpull_by_api_key[api_key]

self.update_bouncer_pull(api_key)
return self.get_active_and_expired_decisions_since(since)

@staticmethod
def get_decision_expiry_time(decision):
return decision["created_at"] + timedelta(seconds=timeparse(decision["duration"]))


class MockLAPI:
def __init__(self) -> None:
self.app = Flask(__name__)
self.app.add_url_rule("/v1/decisions/stream", view_func=self.decisions)
log = logging.getLogger("werkzeug")
log.setLevel(logging.ERROR)
self.app.logger.disabled = True
log.disabled = True
self.ds = DataStore()

def decisions(self):
api_key = request.headers.get("x-api-key")
if not api_key:
abort(404)
startup = True if request.args.get("startup") == "true" else False
active_decisions, expired_decisions = self.ds.get_decisions_for_bouncer(api_key, startup)
return {
"new": formatted_decisions(active_decisions),
"deleted": formatted_decisions(expired_decisions),
}

def start(self, port=8081):
self.server_thread = ServerThread(self.app, port=port)
self.server_thread.start()

def stop(self):
self.server_thread.shutdown()


def formatted_decisions(decisions):
formatted_decisions = []
for decision in decisions:
expiry_time = decision["created_at"] + timedelta(seconds=timeparse(decision["duration"]))
duration = expiry_time - datetime.datetime.now()
formatted_decisions.append(
{
"duration": f"{duration.total_seconds()}s",
"id": decision["id"],
"origin": decision["origin"],
"scenario": "cscli",
"scope": decision["scope"],
"type": decision["type"],
"value": decision["value"],
}
)
return formatted_decisions


# Copied from https://stackoverflow.com/a/45017691 .
# We run server inside thread instead of process to avoid
# huge complexity of sharing objects
class ServerThread(Thread):
def __init__(self, app, port=8081):
Thread.__init__(self)
self.server = make_server("127.0.0.1", port, app)
self.ctx = app.app_context()
self.ctx.push()

def run(self):
self.server.serve_forever()

def shutdown(self):
self.server.shutdown()


if __name__ == "__main__":
MockLAPI().start()
sleep(100)
Empty file added tests/stdinmode/__init__.py
Empty file.
20 changes: 20 additions & 0 deletions tests/stdinmode/crowdsec-custom-bouncer.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
bin_path: tests/stdinmode/custombinary
feed_via_stdin: true # Invokes binary once and feeds incoming decisions to it's stdin.
total_retries: 2 # number of times to restart binary. relevant if feed_via_stdin=true . Set to -1 for infinite retries.
scenarios_containing: [] # ignore IPs banned for triggering scenarios not containing either of provided word, eg ["ssh", "http"]
scenarios_not_containing: [] # ignore IPs banned for triggering scenarios containing either of provided word
origins: []
piddir: /var/run/
update_frequency: 0.1s
cache_retention_duration: 10s
daemonize: false
log_mode: stdout
log_dir: /var/log/
log_level: info
api_url: http://localhost:8081/
api_key: 1237adaf7a1724ac68a3288828820a67

prometheus:
enabled: false
listen_addr: 127.0.0.1
listen_port: 60602
7 changes: 7 additions & 0 deletions tests/stdinmode/custombinary
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
#!/bin/bash
rm data.txt > /dev/null
while read line
do
echo "$line" >> data.txt
done
# rm data.txt > /dev/null
Loading

0 comments on commit 4f39d84

Please sign in to comment.