diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..00ba627 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,11 @@ +# We include .git in the build context because excluding it would break the +# "make release" target, which uses git to retrieve the build version and tag. +#.git + +crowdsec-custom-bouncer +crowdsec-custom-bouncer-* +crowdsec-custom-bouncer.tgz +docs/ +debian/ +rpm/ +test/ diff --git a/.github/workflows/release_publish_docker-image.yml b/.github/workflows/release_publish_docker-image.yml new file mode 100644 index 0000000..4473b38 --- /dev/null +++ b/.github/workflows/release_publish_docker-image.yml @@ -0,0 +1,78 @@ +name: Publish Docker image + +on: + release: + types: + - released + - prereleased + +permissions: + contents: read + packages: write + +jobs: + push_to_registry: + name: Push Docker image to Docker Hub + runs-on: ubuntu-latest + steps: + - + name: Check out the repo + uses: actions/checkout@v4 + with: + fetch-depth: 0 + - + name: Prepare + id: prep + run: | + DOCKER_IMAGE=crowdsecurity/blocklist-mirror + GHCR_IMAGE=ghcr.io/${{ github.repository_owner }}/blocklist-mirror + VERSION=edge + if [[ $GITHUB_REF == refs/tags/* ]]; then + VERSION=${GITHUB_REF#refs/tags/} + elif [[ $GITHUB_REF == refs/heads/* ]]; then + VERSION=$(echo ${GITHUB_REF#refs/heads/} | sed -E 's#/+#-#g') + elif [[ $GITHUB_REF == refs/pull/* ]]; then + VERSION=pr-${{ github.event.number }} + fi + TAGS="${DOCKER_IMAGE}:${VERSION},${GHCR_IMAGE}:${VERSION}" + if [[ "${{ github.event_name }}" == "release" && "${{ github.event.release.prerelease }}" == "false" ]]; then + TAGS=$TAGS,${DOCKER_IMAGE}:latest,${GHCR_IMAGE}:latest + fi + echo "version=${VERSION}" >> $GITHUB_OUTPUT + echo "tags=${TAGS}" >> $GITHUB_OUTPUT + echo "created=$(date -u +'%Y-%m-%dT%H:%M:%SZ')" >> $GITHUB_OUTPUT + - + name: Set up QEMU + uses: docker/setup-qemu-action@v3 + - + name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + - + name: Login to DockerHub + if: github.event_name == 'release' + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + + - name: Login to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.repository_owner }} + password: ${{ secrets.GITHUB_TOKEN }} + - + name: Build and push + uses: docker/build-push-action@v5 + with: + context: . + file: ./Dockerfile + push: ${{ github.event_name == 'release' }} + tags: ${{ steps.prep.outputs.tags }} + # Supported by golang:1.18-alpine: linux/386,linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64/v8,linux/ppc64le,linux/s390x + # Supported by alpine: same + platforms: linux/386,linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64/v8,linux/ppc64le,linux/s390x + labels: | + org.opencontainers.image.source=${{ github.event.repository.html_url }} + org.opencontainers.image.created=${{ steps.prep.outputs.created }} + org.opencontainers.image.revision=${{ github.sha }} diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..73392c9 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,16 @@ +ARG GOVERSION=1.24 + +FROM docker.io/golang:${GOVERSION}-alpine AS build + +WORKDIR /go/src/cs-custom-bouncer + +RUN apk add --update --no-cache make git +COPY . . + +RUN make build DOCKER_BUILD=1 + +FROM alpine:3.21 +COPY --from=build /go/src/cs-custom-bouncer/crowdsec-custom-bouncer /usr/local/bin/crowdsec-custom-bouncer +COPY --from=build /go/src/cs-custom-bouncer/config/crowdsec-custom-bouncer.yaml /etc/crowdsec/bouncers/crowdsec-custom-bouncer.yaml + +ENTRYPOINT ["/usr/local/bin/crowdsec-custom-bouncer", "-c", "/etc/crowdsec/bouncers/crowdsec-custom-bouncer.yaml"] diff --git a/cmd/root.go b/cmd/root.go index 1b3cce9..a3e4111 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -74,7 +74,7 @@ func deleteDecisions(custom *custom.CustomBouncer, decisions []*models.Decision) func addDecisions(custom *custom.CustomBouncer, decisions []*models.Decision) { if len(decisions) == 1 { - log.Infof("adding 1 decision") + log.Info("adding 1 decision") } else { log.Infof("adding %d decisions", len(decisions)) } @@ -102,19 +102,30 @@ func feedViaStdin(ctx context.Context, custom *custom.CustomBouncer, config *cfg return c.Wait() } - var err error - if config.TotalRetries == -1 { - for { - err = f() - log.Errorf("Binary exited: %s", err) - } - } else { - for i := 1; i <= config.TotalRetries; i++ { - err = f() - log.Errorf("Binary exited (retry %d/%d): %s", i, config.TotalRetries, err) + + attempt := 1 + delay := 0 * time.Second + + for config.TotalRetries == -1 || attempt <= config.TotalRetries { + time.Sleep(delay) + err := f() + switch { + case err == nil: + log.Warningf("custom program exited with no error (retry %d/%d) -- the command is not supposed to quit when using stdin", attempt, config.TotalRetries) + case errors.Is(err, context.Canceled): + log.Info("custom program terminated") + return nil + case config.TotalRetries == 1: + log.Errorf("custom program exited: %s", err) + default: + log.Errorf("custom program exited (retry %d/%d): %s", attempt, config.TotalRetries, err) } + + delay = 2 * time.Second + attempt++ } - return errors.New("maximum retries exceeded for binary. Exiting") + + return errors.New("maximum retries exceeded for program execution") } func Execute() error { @@ -164,7 +175,7 @@ func Execute() error { log.Infof("Starting %s %s", name, version.String()) - if err = custom.Init(); err != nil { + if err := custom.Init(); err != nil { return err } @@ -178,8 +189,7 @@ func Execute() error { bouncer := &csbouncer.StreamBouncer{} bouncer.UserAgent = fmt.Sprintf("%s/%s", name, version.String()) - err = bouncer.ConfigReader(strings.NewReader(configExpanded)) - if err != nil { + if err := bouncer.ConfigReader(strings.NewReader(configExpanded)); err != nil { return fmt.Errorf("unable to configure bouncer: %w", err) } @@ -212,7 +222,9 @@ func Execute() error { prometheus.MustRegister(csbouncer.TotalLAPICalls, csbouncer.TotalLAPIError) go func() { log.Infof("Serving metrics at %s", listenOn+"/metrics") - log.Error(promServer.ListenAndServe()) + if err := promServer.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + log.Error(err) + } // don't need to cancel context here, prometheus is not critical }() } @@ -235,7 +247,7 @@ func Execute() error { log.Errorf("unable to shutdown prometheus server: %s", err) } } - return nil + return ctx.Err() case decisions := <-bouncer.Stream: if decisions == nil { continue diff --git a/config/crowdsec-custom-bouncer.yaml b/config/crowdsec-custom-bouncer.yaml index b66aad4..3a55e84 100644 --- a/config/crowdsec-custom-bouncer.yaml +++ b/config/crowdsec-custom-bouncer.yaml @@ -20,7 +20,7 @@ log_compression: true log_max_size: 100 log_max_backups: 3 log_max_age: 30 -api_url: http://localhost:8080/ +api_url: ${CROWDSEC_LAPI_URL} api_key: ${API_KEY} prometheus: diff --git a/go.mod b/go.mod index f4c79e4..9bad5b1 100644 --- a/go.mod +++ b/go.mod @@ -5,7 +5,7 @@ go 1.24.1 require ( github.com/coreos/go-systemd/v22 v22.5.0 github.com/crowdsecurity/crowdsec v1.6.8 - github.com/crowdsecurity/go-cs-bouncer v0.0.14 + github.com/crowdsecurity/go-cs-bouncer v0.0.15-0.20250331125736-2a8a151b96a0 github.com/crowdsecurity/go-cs-lib v0.0.16 github.com/prometheus/client_golang v1.18.0 github.com/sirupsen/logrus v1.9.3 diff --git a/go.sum b/go.sum index a48eb3f..0adf363 100644 --- a/go.sum +++ b/go.sum @@ -15,8 +15,8 @@ github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSV github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/crowdsecurity/crowdsec v1.6.8 h1:c9C6Q0yBx6kUB878nH4Ib7TVwYF/1Dzw4LRuJvt0dJU= github.com/crowdsecurity/crowdsec v1.6.8/go.mod h1:PiTkIhJ55g8jnkObrO55LSaw2dRRd37M5WVnS4rkiNI= -github.com/crowdsecurity/go-cs-bouncer v0.0.14 h1:0hxOaa59pMT274qDzJXNxps4QfMnhSNss+oUn36HTpw= -github.com/crowdsecurity/go-cs-bouncer v0.0.14/go.mod h1:4nSF37v7i98idHM6cw1o0V0XgiY25EjTLfFFXvqg6OA= +github.com/crowdsecurity/go-cs-bouncer v0.0.15-0.20250331125736-2a8a151b96a0 h1:TczufDPouQEJLVRZqRxnFU/Rb7ilRxCwVuTK1FvpeSM= +github.com/crowdsecurity/go-cs-bouncer v0.0.15-0.20250331125736-2a8a151b96a0/go.mod h1:4nSF37v7i98idHM6cw1o0V0XgiY25EjTLfFFXvqg6OA= github.com/crowdsecurity/go-cs-lib v0.0.16 h1:2/htodjwc/sfsv4deX8F/2Fzg1bOI8w3O1/BPSvvsB0= github.com/crowdsecurity/go-cs-lib v0.0.16/go.mod h1:XwGcvTt4lMq4Tm1IRMSKMDf0CVrnytTU8Uoofa7AR+g= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= diff --git a/pkg/cfg/config.go b/pkg/cfg/config.go index 5dd61f7..0b031e5 100644 --- a/pkg/cfg/config.go +++ b/pkg/cfg/config.go @@ -78,5 +78,9 @@ func NewConfig(reader io.Reader) (*BouncerConfig, error) { config.CacheRetentionDuration = 10 * time.Second } + if config.TotalRetries == 0 { + config.TotalRetries = 1 + } + return config, nil } diff --git a/test/tests/bouncer/test_custom_bouncer.py b/test/tests/bouncer/test_custom_bouncer.py index 578f095..35d506c 100644 --- a/test/tests/bouncer/test_custom_bouncer.py +++ b/test/tests/bouncer/test_custom_bouncer.py @@ -169,20 +169,22 @@ def test_binary_monitor(bouncer_with_lapi): # Let's kill custom-stream and see if it's restarted max_retry times (2) cb.halt_children() + time.sleep(2) cb.wait_for_child(timeout=2) assert len(cb.children()) == 1 cb.wait_for_lines_fnmatch( [ - "*Binary exited (retry 1/3): signal: killed*", + "*custom program exited (retry 1/3): signal: killed*", ] ) cb.halt_children() + time.sleep(2) cb.wait_for_child(timeout=2) assert len(cb.children()) == 1 cb.wait_for_lines_fnmatch( [ - "*Binary exited (retry 2/3): signal: killed*", + "*custom program exited (retry 2/3): signal: killed*", ] ) @@ -192,7 +194,7 @@ def test_binary_monitor(bouncer_with_lapi): cb.proc.wait() assert not cb.proc.is_running() cb.wait_for_lines_fnmatch( - ["*Binary exited (retry 3/3): signal: killed*", "*maximum retries exceeded for binary. Exiting*"] + ["*custom program exited (retry 3/3): signal: killed*", "*maximum retries exceeded for program execution*"] ) diff --git a/test/tests/pkg/test_build_deb.py b/test/tests/pkg/test_build_deb.py index 640513a..60c9b82 100644 --- a/test/tests/pkg/test_build_deb.py +++ b/test/tests/pkg/test_build_deb.py @@ -1,3 +1,5 @@ +from pathlib import Path + import pytest pytestmark = pytest.mark.deb @@ -5,6 +7,6 @@ # This test has the side effect of building the package and leaving it in the # project's parent directory. -def test_deb_build(deb_package, skip_unless_deb): +def test_deb_build(deb_package: Path, skip_unless_deb): """Test that the package can be built.""" assert deb_package.exists(), f"Package {deb_package} not found"