[
  {
    "path": ".envrc",
    "content": "use flake\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/bug_report.yaml",
    "content": "name: Bug report\ndescription: Report a bug encountered while operating crowdsec\nlabels: kind/bug\nbody:\n  - type: textarea\n    id: problem\n    attributes:\n      label: What happened?\n      description: |\n        Please provide as much info as possible. Not doing so may result in your bug not being addressed in a timely manner.\n        If this matter is security related, please disclose it privately to security@crowdsec.net\n    validations:\n      required: true\n\n  - type: textarea\n    id: expected\n    attributes:\n      label: What did you expect to happen?\n    validations:\n      required: true\n\n  - type: textarea\n    id: repro\n    attributes:\n      label: How can we reproduce it (as minimally and precisely as possible)?\n    validations:\n      required: true\n\n  - type: textarea\n    id: additional\n    attributes:\n      label: Anything else we need to know?\n\n  - type: textarea\n    id: Version\n    attributes:\n      label: version\n      value: |\n        remediation component version:\n\n        <details>\n\n        ```console\n        $ crowdsec-firewall-bouncer --version\n        # paste output here\n        ```\n\n        </details>\n    validations:\n      required: true\n  - type: textarea\n    id: CS-Version\n    attributes:\n      label: crowdsec version\n      value: |\n        crowdsec version:\n\n        <details>\n        \n        ```console\n        $ crowdsec --version\n        # paste output here\n        ```\n\n        </details>\n    validations:\n      required: true\n\n  - type: textarea\n    id: osVersion\n    attributes:\n      label: OS version\n      value: |\n        <details>\n\n        ```console\n        # On Linux:\n        $ cat /etc/os-release\n        # paste output here\n        $ uname -a\n        # paste output here\n\n        # On Windows:\n        C:\\> wmic os get Caption, Version, BuildNumber, OSArchitecture\n        # paste output here\n        ```\n\n        </details>"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/config.yml",
    "content": "contact_links:\n  - name: Support Request\n    url: https://discourse.crowdsec.net\n    about: Support request or question relating to Crowdsec"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/feature_request.yaml",
    "content": "name: Feature request\ndescription: Suggest an improvement or a new feature\nbody:\n  - type: textarea\n    id: feature\n    attributes:\n      label: What would you like to be added?\n      description: |\n        Significant feature requests are unlikely to make progress as issues. Please consider engaging on discord (discord.gg/crowdsec) and forums (https://discourse.crowdsec.net), instead.\n      value: |\n        For feature request please pick a kind label by removing `<!-- -->` that wrap the example lines below\n\n\n        <!-- /kind feature -->\n        <!-- Completely new feature not currently available  -->\n\n        <!-- /kind enhancement -->\n        <!-- Feature is available but this extends or adds extra functionality -->\n    validations:\n      required: true\n\n  - type: textarea\n    id: rationale\n    attributes:\n      label: Why is this needed?\n    validations:\n      required: true\n"
  },
  {
    "path": ".github/dependabot.yml",
    "content": "version: 2\nupdates:\n  - package-ecosystem: \"github-actions\"\n    directory: \"/\"\n    schedule:\n      interval: \"daily\"\n    labels:\n      - \"kind/dependencies\"\n      - \"github-actions\"\n  - package-ecosystem: \"gomod\"\n    directory: \"/\"\n    schedule:\n      interval: \"daily\"\n    labels:\n      - \"kind/dependencies\"\n      - \"go\"\n  - package-ecosystem: \"uv\"\n    directory: \"test\"\n    schedule:\n      interval: \"daily\"\n    labels:\n      - \"kind/dependencies\"\n      - \"python\"\n"
  },
  {
    "path": ".github/governance.yml",
    "content": "version: v1\n\nissue:\n  captures:\n    - regex: 'version: v(.+)-'\n      github_release: true\n      ignore_case: true\n      label: 'version/$CAPTURED'\n\n  labels:\n    - prefix: triage\n      list: ['accepted']\n      multiple: false\n      author_association:\n        collaborator: true\n        member: true\n        owner: true\n      needs:\n        comment: |\n          @$AUTHOR: Thanks for opening an issue, it is currently awaiting triage.\n\n          In the meantime, you can:\n\n          1. Check [Documentation](https://docs.crowdsec.net/docs/next/bouncers/firewall) to see if your issue can be self resolved.\n          2. You can also join our [Discord](https://discord.gg/crowdsec)\n\n    - prefix: kind\n      list: ['feature', 'bug', 'packaging', 'enhancement']\n      multiple: false\n      author_association:\n        author: true\n        collaborator: true\n        member: true\n        owner: true\n      needs:\n        comment: |\n          @$AUTHOR: There are no 'kind' label on this issue. You need a 'kind' label to start the triage process.\n          * `/kind feature`\n          * `/kind enhancement`\n          * `/kind bug`\n          * `/kind packaging`\n"
  },
  {
    "path": ".github/release-drafter.yml",
    "content": "template: |\n  ## What’s Changed\n\n  $CHANGES\n"
  },
  {
    "path": ".github/release.py",
    "content": "#!/usr/bin/env python3\n\nimport argparse\nimport json\nimport os\nimport shutil\nimport subprocess\nimport sys\n\n\ndef _goos():\n    yield 'linux'\n    yield 'freebsd'\n\n\ndef _goarch(goos):\n    yield '386'\n    yield 'amd64'\n    yield 'arm'\n    yield 'arm64'\n    if goos == 'linux':\n        yield 'ppc64le'\n        yield 's390x'\n    yield 'riscv64'\n\n\ndef _goarm(goarch):\n    if goarch != 'arm':\n        yield ''\n        return\n    yield '6'\n    yield '7'\n\n\ndef _build_tarball(os):\n    if os == 'linux':\n        yield True\n    else:\n        yield False\n\n\ndef filename_for_entry(prog_name, entry):\n    arch = entry['goarch']\n    if entry['goarch'] == 'arm':\n        arch += 'v' + entry['goarm']\n    ret = f'{prog_name}-{entry[\"goos\"]}-{arch}'\n    if entry['build_tarball']:\n        ret += '.tgz'\n    return ret\n\n\ndef matrix(prog_name):\n    for goos in _goos():\n        for goarch in _goarch(goos):\n            if (goos, goarch) == ('freebsd', 'riscv64'):\n                # platform not supported\n                # gopsutil/v4@v4.25.8/cpu/cpu_freebsd.go:85:13: undefined: cpuTimes\n                continue\n            for goarm in _goarm(goarch):\n                for build_tarball in _build_tarball(goos):\n                    yield {\n                        'goos': goos,\n                        'goarch': goarch,\n                        'goarm': goarm,\n                        'build_tarball': build_tarball,\n                    }\n\n\ndef print_matrix(prog_name):\n    j = {'include': list(matrix(prog_name))}\n\n    if os.isatty(sys.stdout.fileno()):\n        print(json.dumps(j, indent=2))\n    else:\n        print(json.dumps(j))\n\n\ndefault_tarball = {\n    'goos': 'linux',\n    'goarch': 'amd64',\n    'goarm': '',\n    'build_tarball': True,\n}\n\ndefault_binary = {\n    'goos': 'linux',\n    'goarch': 'amd64',\n    'goarm': '',\n    'build_tarball': False,\n}\n\n\ndef run_build(prog_name):\n    # call the makefile for each matrix entry\n\n    default_tarball_filename = None\n    default_binary_filename = None\n\n    for entry in matrix(prog_name):\n        env = {'GOOS': entry['goos'], 'GOARCH': entry['goarch']}\n\n        if entry['goarm']:\n            env['GOARM'] = entry['goarm']\n\n        if entry['build_tarball']:\n            target = 'tarball'\n        else:\n            target = 'binary'\n\n        print(f\"Running make {target} for {env}\")\n\n        subprocess.run(['make', target], env=os.environ | env, check=True)\n\n        want_filename = filename_for_entry(prog_name, entry)\n\n        if entry['build_tarball']:\n            os.rename(f'{prog_name}.tgz', want_filename)\n        else:\n            os.rename(f'{prog_name}', want_filename)\n\n        # if this is the default tarball or binary, save the filename\n        # we'll use it later to publish a \"default\" package\n\n        if entry == default_tarball:\n            default_tarball_filename = want_filename\n\n        if entry == default_binary:\n            default_binary_filename = want_filename\n\n        # Remove the directory to reuse it\n        subprocess.run(['make', 'clean-release-dir'], env=os.environ | env, check=True)\n\n    # publish the default tarball and binary\n    if default_tarball_filename:\n        shutil.copy(default_tarball_filename, f'{prog_name}.tgz')\n\n    if default_binary_filename:\n        shutil.copy(default_binary_filename, f'{prog_name}')\n\n\ndef main():\n    parser = argparse.ArgumentParser(\n        description='Build release binaries and tarballs for all supported platforms')\n    parser.add_argument('action', help='Action to perform (ex. run-build, print-matrix)')\n    parser.add_argument('prog_name', help='Name of the program (ex. crowdsec-firewall-bouncer)')\n\n    args = parser.parse_args()\n\n    if args.action == 'print-matrix':\n        print_matrix(args.prog_name)\n\n    if args.action == 'run-build':\n        run_build(args.prog_name)\n\n\nif __name__ == '__main__':\n    main()\n"
  },
  {
    "path": ".github/workflows/build-binary-package.yml",
    "content": "name: build-binary-package\n\non:\n  release:\n    types:\n    - prereleased\n\npermissions:\n  # Use write for: hub release edit\n  contents: write\n\nenv:\n  PROGRAM_NAME: crowdsec-firewall-bouncer\n\njobs:\n  build:\n    name: Build and upload all platforms\n    runs-on: ubuntu-latest\n\n    steps:\n\n    - name: Check out repository\n      uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2\n      with:\n        fetch-depth: 0\n\n    - name: Set up Go\n      uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0\n      with:\n        go-version-file: go.mod\n\n    - name: Build all platforms\n      run: |\n        # build platform-all first so the .xz vendor file is not removed\n        make platform-all vendor\n\n    - name: Upload to release\n      env:\n        GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n      run: |\n        tag_name=\"${GITHUB_REF##*/}\"\n        # this will upload the $PROGRAM_NAME-vendor.tar.xz file as well\n        gh release upload \"$tag_name\" $PROGRAM_NAME* vendor.tgz\n"
  },
  {
    "path": ".github/workflows/governance-bot.yaml",
    "content": "# .github/workflow/governance.yml\n\non:\n  pull_request_target:\n    types: [ synchronize, opened, labeled, unlabeled ]\n  issues:\n    types: [ opened, labeled, unlabeled ]\n  issue_comment:\n    types: [ created ]\n\n# You can use permissions to modify the default permissions granted to the GITHUB_TOKEN, \n# adding or removing access as required, so that you only allow the minimum required access. \npermissions:\n  contents: read\n  issues: write\n  pull-requests: write\n  statuses: write\n  checks: write\n\njobs:\n  governance:\n    name: Governance\n    runs-on: ubuntu-latest\n    steps:\n      # Semantic versioning, lock to different version: v2, v2.0 or a commit hash.\n      - uses: BirthdayResearch/oss-governance-bot@3abd2d1fd2376ba9990fbc795e7a4c54254e9c61 # v4.0.0\n        with:\n          # You can use a PAT to post a comment/label/status so that it shows up as a user instead of github-actions\n          github-token: ${{secrets.GITHUB_TOKEN}} # optional, default to '${{ github.token }}'\n          config-path: .github/governance.yml # optional, default to '.github/governance.yml'\n"
  },
  {
    "path": ".github/workflows/lint.yml",
    "content": "name: Static Analysis\n\non:\n  push:\n    branches: [ main ]\n  pull_request:\n    branches: [ main ]\n\n\njobs:\n  build:\n    name: \"golangci-lint + codeql\"\n    runs-on: ubuntu-latest\n\n    permissions:\n      security-events: write\n\n    steps:\n      - name: Check out code into the Go module directory\n        uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2\n        with:\n          fetch-depth: 0\n\n      - name: Set up Go\n        uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0\n        with:\n          go-version-file: go.mod\n\n      - name: Initialize CodeQL\n        uses: github/codeql-action/init@c10b8064de6f491fea524254123dbe5e09572f13 # v4.35.1\n        with:\n          languages: go, python\n\n      - name: Build\n        run: |\n          make build\n\n      - name: golangci-lint\n        uses: golangci/golangci-lint-action@1e7e51e771db61008b38414a730f564565cf7c20 # v9.2.0\n        with:\n          version: v2.9\n          args: --issues-exit-code=1 --timeout 10m\n          only-new-issues: false\n\n      - name: Perform CodeQL Analysis\n        uses: github/codeql-action/analyze@c10b8064de6f491fea524254123dbe5e09572f13 # v4.35.1\n"
  },
  {
    "path": ".github/workflows/release-drafter.yml",
    "content": "name: Release Drafter\n\non:\n  push:\n    # branches to consider in the event; optional, defaults to all\n    branches:\n      - main\n\npermissions:\n  contents: read\n\njobs:\n  update_release_draft:\n    permissions:\n      # write permission is required to create a github release\n      contents: write\n      # write permission is required for autolabeler\n      # otherwise, read permission is required at least\n      pull-requests: read\n    runs-on: ubuntu-latest\n    name: Update the release draft\n    steps:\n      # Drafts your next Release notes as Pull Requests are merged into \"main\"\n      - uses: release-drafter/release-drafter@139054aeaa9adc52ab36ddf67437541f039b88e2 # v7.1.1\n        with:\n          config-name: release-drafter.yml\n          # (Optional) specify config name to use, relative to .github/. Default: release-drafter.yml\n          # config-name: my-config.yml\n        env:\n          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n"
  },
  {
    "path": ".github/workflows/tests.yml",
    "content": "name: Build + tests\n\non:\n  push:\n    branches: [ main ]\n  pull_request:\n    branches: [ main ]\n\npermissions:\n  contents: read\n\njobs:\n  build:\n    name: \"Build + tests\"\n    runs-on: ubuntu-latest\n\n    steps:\n\n    - name: Check out code into the Go module directory\n      uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2\n      with:\n        fetch-depth: 0\n\n    - name: Set up Go\n      uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0\n      with:\n        go-version-file: go.mod\n\n    - name: Build\n      run: |\n        make build\n\n    - name: Run unit tests\n      run: |\n        go install github.com/kyoh86/richgo@v0.3.12\n        set -o pipefail\n        make test | richgo testfilter\n      env:\n        RICHGO_FORCE_COLOR: 1\n\n    - name: Install uv\n      uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0\n      with:\n        version: 0.5.24\n        enable-cache: true\n        cache-dependency-glob: \"test/uv.lock\"\n\n    - name: \"Set up Python\"\n      uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0\n      with:\n        python-version-file: \"test/.python-version\"\n\n    - name: Install the project\n      working-directory: ./test\n      run: uv sync --all-extras --dev\n\n    - name: Install functional test dependencies\n      run: |\n        sudo apt update\n        sudo apt install -y nftables iptables ipset\n        docker network create net-test\n\n    - name: Run functional tests\n      env:\n        CROWDSEC_TEST_VERSION: dev\n        CROWDSEC_TEST_FLAVORS: full\n        CROWDSEC_TEST_NETWORK: net-test\n        CROWDSEC_TEST_TIMEOUT: 60\n        PYTEST_ADDOPTS: --durations=0 -vv --color=yes -m \"not (deb or rpm)\"\n      working-directory: ./test\n      run: |\n        # everything except for\n        # - install (requires root, ignored by default)\n        # - backends (requires root, ignored by default)\n        # - deb/rpm (on their own workflows)\n        uv run pytest\n        # these need root\n        sudo -E $(which uv) run pytest ./tests/backends\n        sudo -E $(which uv) run pytest ./tests/install/no_crowdsec\n        # these need a running crowdsec\n        docker run -d --name crowdsec -e CI_TESTING=true -e DISABLE_ONLINE_API=true -e CROWDSEC_BYPASS_DB_VOLUME_CHECK=true -ti crowdsecurity/crowdsec\n        cat >/usr/local/bin/cscli <<'EOT'\n        #!/bin/sh\n        docker exec crowdsec cscli \"$@\"\n        EOT\n        chmod u+x /usr/local/bin/cscli\n        sleep 5\n        sudo -E $(which uv) run pytest ./tests/install/with_crowdsec\n\n    - name: Lint\n      working-directory: ./test\n      run: |\n        uv run ruff check\n        uv run basedpyright\n\n"
  },
  {
    "path": ".github/workflows/tests_deb.yml",
    "content": "name: Test .deb packaging\n\non:\n  push:\n    branches: [ main ]\n  pull_request:\n    branches: [ main ]\n\npermissions:\n  contents: read\n\njobs:\n  build:\n    name: \"Test .deb packages\"\n    runs-on: ubuntu-latest\n\n    steps:\n\n    - name: Check out code into the Go module directory\n      uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2\n      with:\n        fetch-depth: 0\n\n    - name: Set up Go\n      uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0\n      with:\n        go-version-file: go.mod\n\n    - name: Install uv\n      uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0\n      with:\n        version: 0.5.24\n        enable-cache: true\n        cache-dependency-glob: \"test/uv.lock\"\n\n    - name: \"Set up Python\"\n      uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0\n      with:\n        python-version-file: \"test/.python-version\"\n\n    - name: Install the project\n      run: uv sync --all-extras --dev\n      working-directory: ./test\n\n    - name: Install functional test dependencies\n      run: |\n        sudo apt update\n        sudo apt install -y nftables iptables ipset build-essential debhelper devscripts fakeroot lintian\n        docker network create net-test\n\n    - name: Run functional tests\n      env:\n        CROWDSEC_TEST_VERSION: dev\n        CROWDSEC_TEST_FLAVORS: full\n        CROWDSEC_TEST_NETWORK: net-test\n        CROWDSEC_TEST_TIMEOUT: 60\n        PYTEST_ADDOPTS: --durations=0 -vv --color=yes\n      working-directory: ./test\n      run: |\n        uv run pytest ./tests/pkg/test_build_deb.py\n        sudo -E $(which uv) run pytest -m deb ./tests/install/no_crowdsec\n"
  },
  {
    "path": ".gitignore",
    "content": "# Test binary, built with `go test -c`\n*.test\n\n# Output of the go coverage tool, specifically when used with LiteIDE\n*.out\n\n# Dependencies are not vendored by default, but a tarball is created by \"make vendor\"\n# and provided in the release. Used by freebsd, gentoo, etc.\nvendor/\nvendor.tgz\n\n# Python\n__pycache__/\n*.py[cod]\n*$py.class\nvenv/\n\n# built by make\n/crowdsec-firewall-bouncer\n/crowdsec-firewall-bouncer-*\n/crowdsec-firewall-bouncer.tgz\n\n# built by dpkg-buildpackage\n/debian/crowdsec-firewall-bouncer-iptables\n/debian/crowdsec-firewall-bouncer-nftables\n/debian/files\n/debian/*.substvars\n/debian/*.debhelper\n/debian/*-stamp\n\n# built by rpmbuild\n/rpm/BUILD\n/rpm/BUILDROOT\n/rpm/RPMS\n/rpm/SOURCES/*.tar.gz\n/rpm/SRPMS\n\n# nix generated dirs\n.direnv\n.devenv\n"
  },
  {
    "path": ".golangci.yml",
    "content": "version: \"2\"\n\nlinters:\n  default: all\n  disable:\n    - cyclop    # revive\n    - funlen    # revive\n    - gocognit  # revive\n    - gocyclo   # revive\n    - lll       # revive\n    - wsl       # wsl_v5\n\n    - depguard\n    - dupl\n    - err113\n    - exhaustruct\n    - gochecknoglobals\n    - goconst\n    - godox\n    - gosec\n    - ireturn\n    - mnd\n    - nlreturn\n    - paralleltest\n    - tagliatelle\n    - testpackage\n    - unparam\n    - varnamelen\n    - whitespace\n    - wrapcheck\n    - funcorder\n    - wsl_v5\n    - noinlineerr\n    - noctx\n    - godoclint\n    - prealloc\n  settings:\n\n    errcheck:\n      check-type-assertions: false\n\n    gocritic:\n      enable-all: true\n      disabled-checks:\n        - appendCombine\n        - paramTypeCombine\n        - sloppyReassign\n        - unnamedResult\n        - importShadow\n\n    govet:\n      disable:\n        - fieldalignment\n      enable-all: true\n\n    maintidx:\n      # raise this after refactoring\n      under: 23\n\n    misspell:\n      locale: US\n\n    modernize:\n      disable:\n        - stringsseq\n\n    nestif:\n      # lower this after refactoring\n      min-complexity: 13\n\n    nlreturn:\n      block-size: 4\n\n    nolintlint:\n      require-explanation: false\n      require-specific: false\n      allow-unused: false\n\n    revive:\n      severity: error\n      enable-all-rules: true\n      rules:\n        - name: add-constant\n          disabled: true\n        - name: cognitive-complexity\n          arguments:\n            # lower this after refactoring\n            - 49\n        - name: comment-spacings\n          disabled: true\n        - name: confusing-results\n          disabled: true\n        - name: cyclomatic\n          arguments:\n            # lower this after refactoring\n            - 29\n        - name: enforce-switch-style\n          disabled: true\n        - name: flag-parameter\n          disabled: true\n        - name: function-length\n          arguments:\n            # lower this after refactoring\n            - 74\n            - 153\n        - name: identical-switch-branches\n          disabled: true\n        - name: import-alias-naming\n          disabled: true\n        - name: import-shadowing\n          disabled: true\n        - name: line-length-limit\n          disabled: true\n        - name: nested-structs\n          disabled: true\n        - name: exported\n          disabled: true\n        - name: unexported-return\n          disabled: true\n        - name: unhandled-error\n          arguments:\n            - fmt.Print\n            - fmt.Printf\n            - fmt.Println\n        - name: function-result-limit\n          arguments:\n            - 5\n        - name: var-naming\n          disabled: true\n    staticcheck:\n      checks:\n        - all\n    wsl:\n      allow-trailing-comment: true\n  exclusions:\n    presets:\n      - comments\n      - common-false-positives\n      - legacy\n      - std-error-handling\n    rules:\n      - linters:\n          - govet\n        text: 'shadow: declaration of \"(err|ctx)\" shadows declaration'\n\n      - linters:\n          - revive\n        text: 'deep-exit: calls to flag.Parse only in main\\(\\) or init\\(\\) functions'\n    paths:\n      - third_party$\n      - builtin$\n      - examples$\n\nissues:\n  max-issues-per-linter: 0\n  max-same-issues: 0\n\nformatters:\n  enable:\n    - gci\n    - gofumpt\n\n  settings:\n    gci:\n      sections:\n        - standard\n        - default\n        - prefix(github.com/crowdsecurity)\n        - prefix(github.com/crowdsecurity/crowdsec)\n        - prefix(github.com/crowdsecurity/cs-firewall-bouncer)\n  exclusions:\n    paths:\n      - third_party$\n      - builtin$\n      - examples$\n"
  },
  {
    "path": "LICENSE",
    "content": "MIT License\n\nCopyright (c) 2020-2021 crowdsecurity\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE."
  },
  {
    "path": "Makefile",
    "content": "GO = go\nGOBUILD = $(GO) build\nGOTEST = $(GO) test\n\nBINARY_NAME=crowdsec-firewall-bouncer\nTARBALL_NAME=$(BINARY_NAME).tgz\n\nifdef BUILD_STATIC\n$(warning WARNING: The BUILD_STATIC variable is deprecated and has no effect. Builds are static by default now.)\nendif\n\n# Versioning information can be overridden in the environment\nBUILD_VERSION?=$(shell git describe --tags)\nBUILD_TIMESTAMP?=$(shell date +%F\"_\"%T)\nBUILD_TAG?=$(shell git rev-parse HEAD)\n\nLD_OPTS_VARS=\\\n-X 'github.com/crowdsecurity/go-cs-lib/version.Version=$(BUILD_VERSION)' \\\n-X 'github.com/crowdsecurity/go-cs-lib/version.BuildDate=$(BUILD_TIMESTAMP)' \\\n-X 'github.com/crowdsecurity/go-cs-lib/version.Tag=$(BUILD_TAG)'\n\nifneq (,$(DOCKER_BUILD))\nLD_OPTS_VARS += -X 'github.com/crowdsecurity/go-cs-lib/version.System=docker'\nendif\n\nexport CGO_ENABLED=0\nexport LD_OPTS=-ldflags \"-s -extldflags '-static' $(LD_OPTS_VARS)\" \\\n\t-trimpath -tags netgo\n\n.PHONY: all\nall: build test\n\n# same as \"$(MAKE) -f debian/rules clean\" but without the dependency on debhelper\n.PHONY: clean-debian\nclean-debian:\n\t@$(RM) -r debian/crowdsec-firewall-bouncer-iptables\n\t@$(RM) -r debian/crowdsec-firewall-bouncer-nftables\n\t@$(RM) -r debian/files\n\t@$(RM) -r debian/.debhelper\n\t@$(RM) -r debian/*.substvars\n\t@$(RM) -r debian/*-stamp\n\n.PHONY: clean-rpm\nclean-rpm:\n\t@$(RM) -r rpm/BUILD\n\t@$(RM) -r rpm/BUILDROOT\n\t@$(RM) -r rpm/RPMS\n\t@$(RM) -r rpm/SOURCES/*.tar.gz\n\t@$(RM) -r rpm/SRPMS\n\n# Remove everything including all platform binaries and tarballs\n.PHONY: clean\nclean: clean-release-dir clean-debian clean-rpm\n\t@$(RM) $(BINARY_NAME)\n\t@$(RM) $(TARBALL_NAME)\n\t@$(RM) -r $(BINARY_NAME)-*\t# platform binary name and leftover release dir\n\t@$(RM) $(BINARY_NAME)-*.tgz\t# platform release file\n\n#\n# Build binaries\n#\n\n.PHONY: binary\nbinary:\n\t$(GOBUILD) $(LD_OPTS) -o $(BINARY_NAME)\n\n.PHONY: build\nbuild: clean binary\n\n#\n# Unit and integration tests\n#\n\n.PHONY: lint\nlint:\n\tgolangci-lint run\n\n.PHONY: test\ntest:\n\t@$(GOTEST) $(LD_OPTS) ./...\n\n.PHONY: func-tests\nfunc-tests: build\n\tpipenv install --dev\n\tpipenv run pytest -v\n\n#\n# Build release tarballs\n#\n\nRELDIR = $(BINARY_NAME)-$(BUILD_VERSION)\n\n.PHONY: vendor\nvendor: vendor-remove\n\t$(GO) mod vendor\n\ttar czf vendor.tgz vendor\n\ttar --create --auto-compress --file=$(RELDIR)-vendor.tar.xz vendor\n\n.PHONY: vendor-remove\nvendor-remove:\n\t$(RM) -r vendor vendor.tgz *-vendor.tar.xz\n\n# Called during platform-all, to reuse the directory for other platforms\n.PHONY: clean-release-dir\nclean-release-dir:\n\t@$(RM) -r $(RELDIR)\n\n.PHONY: tarball\ntarball: binary\n\t@if [ -z $(BUILD_VERSION) ]; then BUILD_VERSION=\"local\" ; fi\n\t@if [ -d $(RELDIR) ]; then echo \"$(RELDIR) already exists, please run 'make clean' and retry\" ;  exit 1 ; fi\n\t@echo Building Release to dir $(RELDIR)\n\t@mkdir -p $(RELDIR)/scripts\n\t@cp $(BINARY_NAME) $(RELDIR)/\n\t@cp -R ./config $(RELDIR)/\n\t@cp ./scripts/install.sh $(RELDIR)/\n\t@cp ./scripts/uninstall.sh $(RELDIR)/\n\t@cp ./scripts/upgrade.sh $(RELDIR)/\n\t@cp ./scripts/_bouncer.sh $(RELDIR)/scripts/\n\t@chmod +x $(RELDIR)/install.sh\n\t@chmod +x $(RELDIR)/uninstall.sh\n\t@chmod +x $(RELDIR)/upgrade.sh\n\t@tar cvzf $(TARBALL_NAME) $(RELDIR)\n\n.PHONY: release\nrelease: clean tarball\n\n#\n# Build binaries and release tarballs for all platforms\n#\n\n.PHONY: platform-all\nplatform-all: clean\n\tpython3 .github/release.py run-build $(BINARY_NAME)\n"
  },
  {
    "path": "README.md",
    "content": "<p align=\"center\">\n<img src=\"https://github.com/crowdsecurity/cs-firewall-bouncer/raw/main/docs/assets/crowdsec_linux_logo.png\" alt=\"CrowdSec\" title=\"CrowdSec\" width=\"300\" height=\"280\" />\n</p>\n<p align=\"center\">\n<img src=\"https://img.shields.io/badge/build-pass-green\">\n<img src=\"https://img.shields.io/badge/tests-pass-green\">\n</p>\n<p align=\"center\">\n&#x1F4DA; <a href=\"#installation\">Documentation</a>\n&#x1F4A0; <a href=\"https://hub.crowdsec.net\">Hub</a>\n&#128172; <a href=\"https://discourse.crowdsec.net\">Discourse </a>\n</p>\n\n\n# crowdsec-firewall-bouncer\nCrowdsec bouncer written in golang for firewalls.\n\ncrowdsec-firewall-bouncer will fetch new and old decisions from a CrowdSec API to add them in a blocklist used by supported firewalls.\n\nSupported firewalls:\n - iptables (IPv4 :heavy_check_mark: / IPv6 :heavy_check_mark: )\n - nftables (IPv4 :heavy_check_mark: / IPv6 :heavy_check_mark: )\n - ipset only (IPv4 :heavy_check_mark: / IPv6 :heavy_check_mark: )\n - pf (IPV4 :heavy_check_mark: / IPV6 :heavy_check_mark: )\n\n# Installation\n\nPlease follow the [official documentation](https://doc.crowdsec.net/docs/bouncers/firewall).\n"
  },
  {
    "path": "cmd/root.go",
    "content": "package cmd\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"flag\"\n\t\"fmt\"\n\t\"net\"\n\t\"net/http\"\n\t\"os\"\n\t\"os/signal\"\n\t\"slices\"\n\t\"strings\"\n\t\"syscall\"\n\n\t\"github.com/prometheus/client_golang/prometheus\"\n\t\"github.com/prometheus/client_golang/prometheus/promhttp\"\n\tlog \"github.com/sirupsen/logrus\"\n\t\"golang.org/x/sync/errgroup\"\n\n\tcsbouncer \"github.com/crowdsecurity/go-cs-bouncer\"\n\t\"github.com/crowdsecurity/go-cs-lib/csdaemon\"\n\t\"github.com/crowdsecurity/go-cs-lib/csstring\"\n\t\"github.com/crowdsecurity/go-cs-lib/version\"\n\n\t\"github.com/crowdsecurity/crowdsec/pkg/models\"\n\n\t\"github.com/crowdsecurity/cs-firewall-bouncer/pkg/backend\"\n\t\"github.com/crowdsecurity/cs-firewall-bouncer/pkg/cfg\"\n\t\"github.com/crowdsecurity/cs-firewall-bouncer/pkg/metrics\"\n)\n\nconst bouncerType = \"crowdsec-firewall-bouncer\"\n\nvar errSignalShutdown = errors.New(\"signal shutdown\")\n\nfunc backendCleanup(backend *backend.BackendCTX) {\n\tlog.Info(\"Shutting down backend\")\n\n\tif err := backend.ShutDown(); err != nil {\n\t\tlog.Errorf(\"while shutting down backend: %s\", err)\n\t}\n}\n\nfunc HandleSignals(ctx context.Context) error {\n\tsignalChan := make(chan os.Signal, 1)\n\tsignal.Notify(signalChan, syscall.SIGTERM, os.Interrupt)\n\n\tselect {\n\tcase s := <-signalChan:\n\t\tswitch s {\n\t\tcase syscall.SIGTERM:\n\t\t\treturn errSignalShutdown\n\t\tcase os.Interrupt: // cross-platform SIGINT\n\t\t\treturn errSignalShutdown\n\t\t}\n\tcase <-ctx.Done():\n\t\treturn ctx.Err()\n\t}\n\n\treturn nil\n}\n\nfunc deleteDecisions(backend *backend.BackendCTX, decisions []*models.Decision, config *cfg.BouncerConfig) {\n\tnbDeletedDecisions := 0\n\n\tfor _, d := range decisions {\n\t\tif !slices.Contains(config.SupportedDecisionsTypes, strings.ToLower(*d.Type)) {\n\t\t\tlog.Debugf(\"decisions for ip '%s' will not be deleted because its type is '%s'\", *d.Value, *d.Type)\n\t\t\tcontinue\n\t\t}\n\n\t\tif err := backend.Delete(d); err != nil {\n\t\t\tif !strings.Contains(err.Error(), \"netlink receive: no such file or directory\") {\n\t\t\t\tlog.Errorf(\"unable to delete decision for '%s': %s\", *d.Value, err)\n\t\t\t}\n\n\t\t\tcontinue\n\t\t}\n\n\t\tlog.Debugf(\"deleted %s\", *d.Value)\n\n\t\tnbDeletedDecisions++\n\t}\n\n\tnoun := \"decisions\"\n\tif nbDeletedDecisions == 1 {\n\t\tnoun = \"decision\"\n\t}\n\n\tif nbDeletedDecisions > 0 {\n\t\tlog.Debug(\"committing expired decisions\")\n\n\t\tif err := backend.Commit(); err != nil {\n\t\t\tlog.Errorf(\"unable to commit expired decisions %v\", err)\n\t\t\treturn\n\t\t}\n\n\t\tlog.Debug(\"committed expired decisions\")\n\t\tlog.Infof(\"%d %s deleted\", nbDeletedDecisions, noun)\n\t}\n}\n\nfunc addDecisions(backend *backend.BackendCTX, decisions []*models.Decision, config *cfg.BouncerConfig) {\n\tnbNewDecisions := 0\n\n\tfor _, d := range decisions {\n\t\tif !slices.Contains(config.SupportedDecisionsTypes, strings.ToLower(*d.Type)) {\n\t\t\tlog.Debugf(\"decisions for ip '%s' will not be added because its type is '%s'\", *d.Value, *d.Type)\n\t\t\tcontinue\n\t\t}\n\n\t\tif err := backend.Add(d); err != nil {\n\t\t\tlog.Errorf(\"unable to insert decision for '%s': %s\", *d.Value, err)\n\t\t\tcontinue\n\t\t}\n\n\t\tlog.Debugf(\"Adding '%s' for '%s'\", *d.Value, *d.Duration)\n\n\t\tnbNewDecisions++\n\t}\n\n\tnoun := \"decisions\"\n\tif nbNewDecisions == 1 {\n\t\tnoun = \"decision\"\n\t}\n\n\tif nbNewDecisions > 0 {\n\t\tlog.Debug(\"committing added decisions\")\n\n\t\tif err := backend.Commit(); err != nil {\n\t\t\tlog.Errorf(\"unable to commit add decisions %v\", err)\n\t\t\treturn\n\t\t}\n\n\t\tlog.Debug(\"committed added decisions\")\n\t\tlog.Infof(\"%d %s added\", nbNewDecisions, noun)\n\t}\n}\n\nfunc Execute() error {\n\tconfigPath := flag.String(\"c\", \"\", \"path to crowdsec-firewall-bouncer.yaml\")\n\tverbose := flag.Bool(\"v\", false, \"set verbose mode\")\n\tbouncerVersion := flag.Bool(\"V\", false, \"display version and exit (deprecated)\")\n\tflag.BoolVar(bouncerVersion, \"version\", *bouncerVersion, \"display version and exit\")\n\ttestConfig := flag.Bool(\"t\", false, \"test config and exit\")\n\tshowConfig := flag.Bool(\"T\", false, \"show full config (.yaml + .yaml.local) and exit\")\n\n\tflag.Parse()\n\n\tif *bouncerVersion {\n\t\tfmt.Fprint(os.Stdout, version.FullString())\n\t\treturn nil\n\t}\n\n\tif configPath == nil || *configPath == \"\" {\n\t\treturn errors.New(\"configuration file is required\")\n\t}\n\n\tconfigMerged, err := cfg.MergedConfig(*configPath)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"unable to read config file: %w\", err)\n\t}\n\n\tif *showConfig {\n\t\tfmt.Fprintln(os.Stdout, string(configMerged))\n\t\treturn nil\n\t}\n\n\tconfigExpanded := csstring.StrictExpand(string(configMerged), os.LookupEnv)\n\n\tconfig, err := cfg.NewConfig(strings.NewReader(configExpanded))\n\tif err != nil {\n\t\treturn fmt.Errorf(\"unable to load configuration: %w\", err)\n\t}\n\n\tif *verbose && !log.IsLevelEnabled(log.DebugLevel) {\n\t\tlog.SetLevel(log.DebugLevel)\n\t}\n\n\tlog.Infof(\"Starting %s %s\", bouncerType, version.String())\n\n\tbackend, err := backend.NewBackend(config)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tif err = backend.Init(); err != nil {\n\t\treturn err\n\t}\n\n\tdefer backendCleanup(backend)\n\n\tbouncer := &csbouncer.StreamBouncer{}\n\n\terr = bouncer.ConfigReader(strings.NewReader(configExpanded))\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tbouncer.UserAgent = fmt.Sprintf(\"%s/%s\", bouncerType, version.String())\n\tif err := bouncer.Init(); err != nil {\n\t\treturn fmt.Errorf(\"unable to configure bouncer: %w\", err)\n\t}\n\n\tif *testConfig {\n\t\tlog.Info(\"config is valid\")\n\t\treturn nil\n\t}\n\n\tif bouncer.InsecureSkipVerify != nil {\n\t\tlog.Debugf(\"InsecureSkipVerify is set to %t\", *bouncer.InsecureSkipVerify)\n\t}\n\n\tg, ctx := errgroup.WithContext(context.Background())\n\n\tg.Go(func() error {\n\t\treturn bouncer.Run(ctx)\n\t})\n\n\tmHandler := metrics.Handler{\n\t\tBackend: backend,\n\t}\n\n\tmetricsProvider, err := csbouncer.NewMetricsProvider(bouncer.APIClient, bouncerType, mHandler.MetricsUpdater, log.StandardLogger())\n\tif err != nil {\n\t\treturn fmt.Errorf(\"unable to create metrics provider: %w\", err)\n\t}\n\n\tg.Go(func() error {\n\t\treturn metricsProvider.Run(ctx)\n\t})\n\n\tif config.Mode == cfg.IptablesMode || config.Mode == cfg.NftablesMode || config.Mode == cfg.IpsetMode || config.Mode == cfg.PfMode {\n\t\tmetrics.Map.MustRegisterAll()\n\t}\n\n\tprometheus.MustRegister(csbouncer.TotalLAPICalls, csbouncer.TotalLAPIError)\n\n\tif config.PrometheusConfig.Enabled {\n\t\tgo func() {\n\t\t\thttp.Handle(\"/metrics\", mHandler.ComputeMetricsHandler(promhttp.Handler()))\n\n\t\t\tlistenOn := net.JoinHostPort(\n\t\t\t\tconfig.PrometheusConfig.ListenAddress,\n\t\t\t\tconfig.PrometheusConfig.ListenPort,\n\t\t\t)\n\t\t\tlog.Infof(\"Serving metrics at %s\", listenOn+\"/metrics\")\n\t\t\tlog.Error(http.ListenAndServe(listenOn, nil))\n\t\t}()\n\t}\n\n\tg.Go(func() error {\n\t\tlog.Infof(\"Processing new and deleted decisions . . .\")\n\n\t\tfor {\n\t\t\tselect {\n\t\t\tcase <-ctx.Done():\n\t\t\t\treturn nil\n\t\t\tcase decisions := <-bouncer.Stream:\n\t\t\t\tif decisions == nil {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\n\t\t\t\tdeleteDecisions(backend, decisions.Deleted, config)\n\t\t\t\taddDecisions(backend, decisions.New, config)\n\t\t\t}\n\t\t}\n\t})\n\n\tif config.Daemon != nil {\n\t\tif *config.Daemon {\n\t\t\tlog.Debug(\"Ignoring deprecated 'daemonize' option\")\n\t\t} else {\n\t\t\tlog.Warn(\"The 'daemonize' config option is deprecated and treated as always true\")\n\t\t}\n\t}\n\n\t_ = csdaemon.Notify(csdaemon.Ready, log.StandardLogger())\n\n\tg.Go(func() error {\n\t\treturn HandleSignals(ctx)\n\t})\n\n\tif err := g.Wait(); err != nil {\n\t\tif errors.Is(err, errSignalShutdown) {\n\t\t\tlog.Info(\"Received shutdown signal, exiting gracefully\")\n\t\t\treturn nil\n\t\t}\n\n\t\treturn fmt.Errorf(\"process terminated with error: %w\", err)\n\t}\n\n\treturn nil\n}\n"
  },
  {
    "path": "config/crowdsec-firewall-bouncer.service",
    "content": "[Unit]\nDescription=The firewall bouncer for CrowdSec\nAfter=syslog.target network.target remote-fs.target nss-lookup.target crowdsec.service\n\n[Service]\nType=notify\nExecStart=${BIN} -c ${CFG}/crowdsec-firewall-bouncer.yaml\nExecStartPre=${BIN} -c ${CFG}/crowdsec-firewall-bouncer.yaml -t\nExecStartPost=/bin/sleep 0.1\nRestart=always\nRestartSec=10\nLimitNOFILE=65536\n# don't send a termination signal to the children processes,\n# because the iptables backend needs to run ipset multiple times to properly shutdown\nKillMode=mixed\n\n[Install]\nWantedBy=multi-user.target\n"
  },
  {
    "path": "config/crowdsec-firewall-bouncer.yaml",
    "content": "mode: ${BACKEND}\nupdate_frequency: 10s\nlog_mode: file\nlog_dir: /var/log/\nlog_level: info\nlog_compression: true\nlog_max_size: 100\nlog_max_backups: 3\nlog_max_age: 30\napi_url: http://127.0.0.1:8080/\napi_key: ${API_KEY}\n## TLS Authentication\n# cert_path: /etc/crowdsec/tls/cert.pem\n# key_path: /etc/crowdsec/tls/key.pem\n# ca_cert_path: /etc/crowdsec/tls/ca.crt\ninsecure_skip_verify: false\ndisable_ipv6: false\ndeny_action: DROP\ndeny_log: false\nsupported_decisions_types:\n  - ban\n#to change log prefix\n#deny_log_prefix: \"crowdsec: \"\n#to change the blacklists name\nblacklists_ipv4: crowdsec-blacklists\nblacklists_ipv6: crowdsec6-blacklists\n#type of ipset to use\nipset_type: nethash\n#if present, insert rule in those chains\niptables_chains:\n  - INPUT\n#  - FORWARD\n#  - DOCKER-USER\niptables_add_rule_comments: true\n\n## nftables\nnftables:\n  ipv4:\n    enabled: true\n    set-only: false\n    table: crowdsec\n    chain: crowdsec-chain\n    priority: -10\n  ipv6:\n    enabled: true\n    set-only: false\n    table: crowdsec6\n    chain: crowdsec6-chain\n    priority: -10\n\nnftables_hooks:\n  - input\n  - forward\n\n# packet filter\npf:\n  # an empty string disables the anchor\n  anchor_name: \"\"\n\nprometheus:\n  enabled: false\n  listen_addr: 127.0.0.1\n  listen_port: 60601\n"
  },
  {
    "path": "debian/changelog",
    "content": "crowdsec-firewall-bouncer (1.0.12) UNRELEASED; urgency=medium\n\n  * debian package\n  * pf support\n\n -- Manuel Sabban <manuel@crowdsec.net>  Mon, 08 Feb 2021 09:38:06 +0100\n"
  },
  {
    "path": "debian/compat",
    "content": "11\n"
  },
  {
    "path": "debian/control",
    "content": "Source: crowdsec-firewall-bouncer\nMaintainer: Crowdsec Team <debian@crowdsec.net>\nBuild-Depends: debhelper\nSection: admin\nPriority: optional\n\nPackage: crowdsec-firewall-bouncer-iptables\nArchitecture: any\nDescription: Firewall bouncer for Crowdsec (iptables+ipset)\nDepends: gettext-base, iptables, ipset\nReplaces: crowdsec-firewall-bouncer\nConflicts: crowdsec-firewall-bouncer-nftables\n\nPackage: crowdsec-firewall-bouncer-nftables\nArchitecture: any\nDescription: Firewall bouncer for Crowdsec (nftables)\nDepends: gettext-base, nftables\nReplaces: crowdsec-firewall-bouncer\nConflicts: crowdsec-firewall-bouncer-iptables\n"
  },
  {
    "path": "debian/crowdsec-firewall-bouncer-iptables.postinst",
    "content": "#!/bin/sh\n\nsystemctl daemon-reload\n\n#shellcheck source=./scripts/_bouncer.sh\n. \"/usr/lib/$DPKG_MAINTSCRIPT_PACKAGE/_bouncer.sh\"\nSTART=1\n\nif [ \"$1\" = \"configure\" ]; then\n    if need_api_key; then\n        if ! set_api_key; then\n            START=0\n        fi\n    fi\nfi\n\nsystemctl --quiet is-enabled \"$SERVICE\" || systemctl unmask \"$SERVICE\" && systemctl enable \"$SERVICE\"\n\nset_local_port\n\nif [ \"$START\" -eq 0 ]; then\n    echo \"no api key was generated, you can generate one on your LAPI server by running 'cscli bouncers add <bouncer_name>' and add it to '$CONFIG'\" >&2\nelse\n    systemctl start \"$SERVICE\"\nfi\n"
  },
  {
    "path": "debian/crowdsec-firewall-bouncer-iptables.postrm",
    "content": "#!/bin/sh\n\nset -eu\n\nBOUNCER=\"crowdsec-firewall-bouncer\"\nCONFIG=\"/etc/crowdsec/bouncers/$BOUNCER.yaml\"\n\nif [ \"$1\" = \"purge\" ]; then\n    if [ -f \"$CONFIG.id\" ]; then\n        bouncer_id=$(cat \"$CONFIG.id\")\n        cscli -oraw bouncers delete \"$bouncer_id\" 2>/dev/null || true\n        rm -f \"$CONFIG.id\"\n    fi\nfi\n"
  },
  {
    "path": "debian/crowdsec-firewall-bouncer-iptables.preinst",
    "content": "#!/bin/sh\n\nset -e\n\n# Source debconf library.\n. /usr/share/debconf/confmodule\n"
  },
  {
    "path": "debian/crowdsec-firewall-bouncer-iptables.prerm",
    "content": "#!/bin/sh\n\nset -eu\n\nBOUNCER=\"crowdsec-firewall-bouncer\"\n\nsystemctl stop \"$BOUNCER\" || echo \"cannot stop service\"\nsystemctl disable \"$BOUNCER\" || echo \"cannot disable service\"\n"
  },
  {
    "path": "debian/crowdsec-firewall-bouncer-nftables.postinst",
    "content": "#!/bin/sh\n\nsystemctl daemon-reload\n\n#shellcheck source=./scripts/_bouncer.sh\n. \"/usr/lib/$DPKG_MAINTSCRIPT_PACKAGE/_bouncer.sh\"\nSTART=1\n\nif [ \"$1\" = \"configure\" ]; then\n    if need_api_key; then\n        if ! set_api_key; then\n            START=0\n        fi\n    fi\nfi\n\nsystemctl --quiet is-enabled \"$SERVICE\" || systemctl unmask \"$SERVICE\" && systemctl enable \"$SERVICE\"\n\nset_local_port\n\nif [ \"$START\" -eq 0 ]; then\n    echo \"no api key was generated, you can generate one on your LAPI server by running 'cscli bouncers add <bouncer_name>' and add it to '$CONFIG'\" >&2\nelse\n    systemctl start \"$SERVICE\"\nfi\n"
  },
  {
    "path": "debian/crowdsec-firewall-bouncer-nftables.postrm",
    "content": "#!/bin/sh\n\nset -eu\n\nBOUNCER=\"crowdsec-firewall-bouncer\"\nCONFIG=\"/etc/crowdsec/bouncers/$BOUNCER.yaml\"\n\nif [ \"$1\" = \"purge\" ]; then\n    if [ -f \"$CONFIG.id\" ]; then\n        bouncer_id=$(cat \"$CONFIG.id\")\n        cscli -oraw bouncers delete \"$bouncer_id\" 2>/dev/null || true\n        rm -f \"$CONFIG.id\"\n    fi\nfi\n"
  },
  {
    "path": "debian/crowdsec-firewall-bouncer-nftables.preinst",
    "content": "#!/bin/sh\n\nset -e\n\n# Source debconf library.\n. /usr/share/debconf/confmodule\n\necho \"pre-inst (nftables)\" \n"
  },
  {
    "path": "debian/crowdsec-firewall-bouncer-nftables.prerm",
    "content": "#!/bin/sh\n\nset -eu\n\nBOUNCER=\"crowdsec-firewall-bouncer\"\n\nsystemctl stop \"$BOUNCER\" || echo \"cannot stop service\"\nsystemctl disable \"$BOUNCER\" || echo \"cannot disable service\"\n"
  },
  {
    "path": "debian/rules",
    "content": "#!/usr/bin/make -f\n\nexport DEB_VERSION=$(shell dpkg-parsechangelog | grep -E '^Version:' | cut -f 2 -d ' ')\nexport BUILD_VERSION=v${DEB_VERSION}-debian-pragmatic\n\n%:\n\tdh $@\n\noverride_dh_systemd_start:\n\techo \"Not running dh_systemd_start\"\noverride_dh_auto_clean:\noverride_dh_auto_test:\noverride_dh_auto_build:\noverride_dh_auto_install:\n\t@make build\n\n\t@BOUNCER=crowdsec-firewall-bouncer; \\\n\tfor BACKEND in iptables nftables; do \\\n\t\tPKG=\"$$BOUNCER-$$BACKEND\"; \\\n\t\tinstall -D $$BOUNCER -t \"debian/$$PKG/usr/bin/\"; \\\n\t\tinstall -D scripts/_bouncer.sh -t \"debian/$$PKG/usr/lib/$$PKG/\"; \\\n\t\tmkdir -p \"debian/$$PKG/etc/crowdsec/bouncers\"; \\\n\t\t(umask 177 && BACKEND=$$BACKEND envsubst '$$BACKEND' < \"config/$$BOUNCER.yaml\" > \"debian/$$PKG/etc/crowdsec/bouncers/$$BOUNCER.yaml\"); \\\n\t\tmkdir -p \"debian/$$PKG/etc/systemd/system\"; \\\n\t\tBIN=\"/usr/bin/$$BOUNCER\" CFG=\"/etc/crowdsec/bouncers\" envsubst '$$BIN $$CFG' < \"config/$$BOUNCER.service\" > \"debian/$$PKG/etc/systemd/system/$$BOUNCER.service\"; \\\n\t\tmkdir -p \"debian/$$PKG/usr/sbin/\"; \\\n\t\tln -s \"/usr/bin/$$BOUNCER\" \"debian/$$PKG/usr/sbin/$$BOUNCER\"; \\\n\tdone\n\nexecute_after_dh_fixperms:\n\t@BOUNCER=crowdsec-firewall-bouncer; \\\n\tfor BACKEND in iptables nftables; do \\\n\t\tPKG=\"$$BOUNCER-$$BACKEND\"; \\\n\t\tchmod 0755 \"debian/$$PKG/usr/bin/$$BOUNCER\"; \\\n\t\tchmod 0600 \"debian/$$PKG/usr/lib/$$PKG/_bouncer.sh\"; \\\n\t\tchmod 0600 \"debian/$$PKG/etc/crowdsec/bouncers/$$BOUNCER.yaml\"; \\\n\t\tchmod 0644 \"debian/$$PKG/etc/systemd/system/$$BOUNCER.service\"; \\\n\tdone\n"
  },
  {
    "path": "flake.nix",
    "content": "{\n  description = \"A Nix-flake-based Go 1.22 development environment\";\n\n  inputs.nixpkgs.url = \"https://flakehub.com/f/NixOS/nixpkgs/0.1.*.tar.gz\";\n\n  outputs = { self, nixpkgs }:\n    let\n      goVersion = 24; # Change this to update the whole stack\n\n      supportedSystems = [ \"x86_64-linux\" \"aarch64-linux\" \"x86_64-darwin\" \"aarch64-darwin\" ];\n      forEachSupportedSystem = f: nixpkgs.lib.genAttrs supportedSystems (system: f {\n        pkgs = import nixpkgs {\n          inherit system;\n          overlays = [ self.overlays.default ];\n        };\n      });\n    in\n    {\n      overlays.default = final: prev: {\n        go = final.\"go_1_${toString goVersion}\";\n      };\n\n      devShells = forEachSupportedSystem ({ pkgs }: {\n        default = pkgs.mkShell {\n          packages = with pkgs; [\n            # go (version is specified by overlay)\n            go\n\n            # goimports, godoc, etc.\n            gotools\n\n            # https://github.com/golangci/golangci-lint\n            golangci-lint\n            golangci-lint-langserver\n            gopls\n          ];\n        };\n      });\n    };\n}\n"
  },
  {
    "path": "go.mod",
    "content": "module github.com/crowdsecurity/cs-firewall-bouncer\n\ngo 1.25.2\n\nrequire (\n\tgithub.com/crowdsecurity/crowdsec v1.7.7\n\tgithub.com/crowdsecurity/go-cs-bouncer v0.0.21\n\tgithub.com/crowdsecurity/go-cs-lib v0.0.25\n\tgithub.com/google/nftables v0.3.0\n\tgithub.com/prometheus/client_golang v1.23.2\n\tgithub.com/prometheus/client_model v0.6.2\n\tgithub.com/sirupsen/logrus v1.9.4\n\tgithub.com/stretchr/testify v1.11.1\n\tgolang.org/x/sync v0.20.0\n\tgolang.org/x/sys v0.42.0\n\tgopkg.in/natefinch/lumberjack.v2 v2.2.1\n\tgopkg.in/yaml.v2 v2.4.0\n)\n\nrequire (\n\tgithub.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect\n\tgithub.com/beorn7/perks v1.0.1 // indirect\n\tgithub.com/cenkalti/backoff/v5 v5.0.3 // indirect\n\tgithub.com/cespare/xxhash/v2 v2.3.0 // indirect\n\tgithub.com/coreos/go-systemd/v22 v22.5.0 // indirect\n\tgithub.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect\n\tgithub.com/ebitengine/purego v0.8.4 // indirect\n\tgithub.com/go-ole/go-ole v1.2.6 // indirect\n\tgithub.com/go-openapi/analysis v0.23.0 // indirect\n\tgithub.com/go-openapi/errors v0.22.2 // indirect\n\tgithub.com/go-openapi/jsonpointer v0.21.1 // indirect\n\tgithub.com/go-openapi/jsonreference v0.21.0 // indirect\n\tgithub.com/go-openapi/loads v0.22.0 // indirect\n\tgithub.com/go-openapi/spec v0.21.0 // indirect\n\tgithub.com/go-openapi/strfmt v0.23.0 // indirect\n\tgithub.com/go-openapi/swag v0.23.1 // indirect\n\tgithub.com/go-openapi/validate v0.24.0 // indirect\n\tgithub.com/goccy/go-yaml v1.18.0 // indirect\n\tgithub.com/golang-jwt/jwt/v4 v4.5.2 // indirect\n\tgithub.com/google/go-cmp v0.7.0 // indirect\n\tgithub.com/google/go-querystring v1.1.0 // indirect\n\tgithub.com/google/uuid v1.6.0 // indirect\n\tgithub.com/josharian/intern v1.0.0 // indirect\n\tgithub.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect\n\tgithub.com/mailru/easyjson v0.9.0 // indirect\n\tgithub.com/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42 // indirect\n\tgithub.com/mdlayher/socket v0.5.1 // indirect\n\tgithub.com/mitchellh/mapstructure v1.5.0 // indirect\n\tgithub.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect\n\tgithub.com/oklog/ulid v1.3.1 // indirect\n\tgithub.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect\n\tgithub.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect\n\tgithub.com/prometheus/common v0.66.1 // indirect\n\tgithub.com/prometheus/procfs v0.17.0 // indirect\n\tgithub.com/shirou/gopsutil/v4 v4.25.8 // indirect\n\tgithub.com/tklauser/go-sysconf v0.3.15 // indirect\n\tgithub.com/tklauser/numcpus v0.10.0 // indirect\n\tgithub.com/yusufpapurcu/wmi v1.2.4 // indirect\n\tgo.mongodb.org/mongo-driver v1.17.4 // indirect\n\tgo.yaml.in/yaml/v2 v2.4.2 // indirect\n\tgolang.org/x/net v0.48.0 // indirect\n\tgoogle.golang.org/protobuf v1.36.10 // indirect\n\tgopkg.in/yaml.v3 v3.0.1 // indirect\n)\n"
  },
  {
    "path": "go.sum",
    "content": "github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so=\ngithub.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw=\ngithub.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=\ngithub.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=\ngithub.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=\ngithub.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=\ngithub.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=\ngithub.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=\ngithub.com/coreos/go-systemd/v22 v22.5.0 h1:RrqgGjYQKalulkV8NGVIfkXQf6YYmOyiJKk8iXXhfZs=\ngithub.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=\ngithub.com/crowdsecurity/crowdsec v1.7.7 h1:sduZN763iXsrZodocWDrsR//7nLeffGu+RVkkIsbQkE=\ngithub.com/crowdsecurity/crowdsec v1.7.7/go.mod h1:L1HLGPDnBYCcY+yfSFnuBbQ1G9DHEJN9c+Kevv9F+4Q=\ngithub.com/crowdsecurity/go-cs-bouncer v0.0.21 h1:arPz0VtdVSaz+auOSfHythzkZVLyy18CzYvYab8UJDU=\ngithub.com/crowdsecurity/go-cs-bouncer v0.0.21/go.mod h1:4JiH0XXA4KKnnWThItUpe5+heJHWzsLOSA2IWJqUDBA=\ngithub.com/crowdsecurity/go-cs-lib v0.0.25 h1:Ov6VPW9yV+OPsbAIQk1iTkEWhwkpaG0v3lrBzeqjzj4=\ngithub.com/crowdsecurity/go-cs-lib v0.0.25/go.mod h1:X0GMJY2CxdA1S09SpuqIKaWQsvRGxXmecUp9cP599dE=\ngithub.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=\ngithub.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=\ngithub.com/ebitengine/purego v0.8.4 h1:CF7LEKg5FFOsASUj0+QwaXf8Ht6TlFxg09+S9wz0omw=\ngithub.com/ebitengine/purego v0.8.4/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=\ngithub.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY=\ngithub.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=\ngithub.com/go-openapi/analysis v0.23.0 h1:aGday7OWupfMs+LbmLZG4k0MYXIANxcuBTYUC03zFCU=\ngithub.com/go-openapi/analysis v0.23.0/go.mod h1:9mz9ZWaSlV8TvjQHLl2mUW2PbZtemkE8yA5v22ohupo=\ngithub.com/go-openapi/errors v0.22.2 h1:rdxhzcBUazEcGccKqbY1Y7NS8FDcMyIRr0934jrYnZg=\ngithub.com/go-openapi/errors v0.22.2/go.mod h1:+n/5UdIqdVnLIJ6Q9Se8HNGUXYaY6CN8ImWzfi/Gzp0=\ngithub.com/go-openapi/jsonpointer v0.21.1 h1:whnzv/pNXtK2FbX/W9yJfRmE2gsmkfahjMKB0fZvcic=\ngithub.com/go-openapi/jsonpointer v0.21.1/go.mod h1:50I1STOfbY1ycR8jGz8DaMeLCdXiI6aDteEdRNNzpdk=\ngithub.com/go-openapi/jsonreference v0.21.0 h1:Rs+Y7hSXT83Jacb7kFyjn4ijOuVGSvOdF2+tg1TRrwQ=\ngithub.com/go-openapi/jsonreference v0.21.0/go.mod h1:LmZmgsrTkVg9LG4EaHeY8cBDslNPMo06cago5JNLkm4=\ngithub.com/go-openapi/loads v0.22.0 h1:ECPGd4jX1U6NApCGG1We+uEozOAvXvJSF4nnwHZ8Aco=\ngithub.com/go-openapi/loads v0.22.0/go.mod h1:yLsaTCS92mnSAZX5WWoxszLj0u+Ojl+Zs5Stn1oF+rs=\ngithub.com/go-openapi/spec v0.21.0 h1:LTVzPc3p/RzRnkQqLRndbAzjY0d0BCL72A6j3CdL9ZY=\ngithub.com/go-openapi/spec v0.21.0/go.mod h1:78u6VdPw81XU44qEWGhtr982gJ5BWg2c0I5XwVMotYk=\ngithub.com/go-openapi/strfmt v0.23.0 h1:nlUS6BCqcnAk0pyhi9Y+kdDVZdZMHfEKQiS4HaMgO/c=\ngithub.com/go-openapi/strfmt v0.23.0/go.mod h1:NrtIpfKtWIygRkKVsxh7XQMDQW5HKQl6S5ik2elW+K4=\ngithub.com/go-openapi/swag v0.23.1 h1:lpsStH0n2ittzTnbaSloVZLuB5+fvSY/+hnagBjSNZU=\ngithub.com/go-openapi/swag v0.23.1/go.mod h1:STZs8TbRvEQQKUA+JZNAm3EWlgaOBGpyFDqQnDHMef0=\ngithub.com/go-openapi/validate v0.24.0 h1:LdfDKwNbpB6Vn40xhTdNZAnfLECL81w+VX3BumrGD58=\ngithub.com/go-openapi/validate v0.24.0/go.mod h1:iyeX1sEufmv3nPbBdX3ieNviWnOZaJ1+zquzJEf2BAQ=\ngithub.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=\ngithub.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=\ngithub.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=\ngithub.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI=\ngithub.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=\ngithub.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=\ngithub.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=\ngithub.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=\ngithub.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=\ngithub.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8=\ngithub.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU=\ngithub.com/google/nftables v0.3.0 h1:bkyZ0cbpVeMHXOrtlFc8ISmfVqq5gPJukoYieyVmITg=\ngithub.com/google/nftables v0.3.0/go.mod h1:BCp9FsrbF1Fn/Yu6CLUc9GGZFw/+hsxfluNXXmxBfRM=\ngithub.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=\ngithub.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=\ngithub.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=\ngithub.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=\ngithub.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=\ngithub.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=\ngithub.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=\ngithub.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=\ngithub.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=\ngithub.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=\ngithub.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=\ngithub.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=\ngithub.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=\ngithub.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=\ngithub.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4=\ngithub.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I=\ngithub.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4=\ngithub.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=\ngithub.com/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42 h1:A1Cq6Ysb0GM0tpKMbdCXCIfBclan4oHk1Jb+Hrejirg=\ngithub.com/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42/go.mod h1:BB4YCPDOzfy7FniQ/lxuYQ3dgmM2cZumHbK8RpTjN2o=\ngithub.com/mdlayher/socket v0.5.1 h1:VZaqt6RkGkt2OE9l3GcC6nZkqD3xKeQLyfleW/uBcos=\ngithub.com/mdlayher/socket v0.5.1/go.mod h1:TjPLHI1UgwEv5J1B5q0zTZq12A/6H7nKmtTanQE37IQ=\ngithub.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=\ngithub.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=\ngithub.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=\ngithub.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=\ngithub.com/oklog/ulid v1.3.1 h1:EGfNDEx6MqHz8B3uNV6QAib1UR2Lm97sHi3ocA6ESJ4=\ngithub.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U=\ngithub.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=\ngithub.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=\ngithub.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=\ngithub.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=\ngithub.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=\ngithub.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=\ngithub.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=\ngithub.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=\ngithub.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs=\ngithub.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA=\ngithub.com/prometheus/procfs v0.17.0 h1:FuLQ+05u4ZI+SS/w9+BWEM2TXiHKsUQ9TADiRH7DuK0=\ngithub.com/prometheus/procfs v0.17.0/go.mod h1:oPQLaDAMRbA+u8H5Pbfq+dl3VDAvHxMUOVhe0wYB2zw=\ngithub.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M=\ngithub.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA=\ngithub.com/shirou/gopsutil/v4 v4.25.8 h1:NnAsw9lN7587WHxjJA9ryDnqhJpFH6A+wagYWTOH970=\ngithub.com/shirou/gopsutil/v4 v4.25.8/go.mod h1:q9QdMmfAOVIw7a+eF86P7ISEU6ka+NLgkUxlopV4RwI=\ngithub.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=\ngithub.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=\ngithub.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s=\ngithub.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0=\ngithub.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=\ngithub.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=\ngithub.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=\ngithub.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=\ngithub.com/tklauser/go-sysconf v0.3.15 h1:VE89k0criAymJ/Os65CSn1IXaol+1wrsFHEB8Ol49K4=\ngithub.com/tklauser/go-sysconf v0.3.15/go.mod h1:Dmjwr6tYFIseJw7a3dRLJfsHAMXZ3nEnL/aZY+0IuI4=\ngithub.com/tklauser/numcpus v0.10.0 h1:18njr6LDBk1zuna922MgdjQuJFjrdppsZG60sHGfjso=\ngithub.com/tklauser/numcpus v0.10.0/go.mod h1:BiTKazU708GQTYF4mB+cmlpT2Is1gLk7XVuEeem8LsQ=\ngithub.com/vishvananda/netns v0.0.4 h1:Oeaw1EM2JMxD51g9uhtC0D7erkIjgmj8+JZc26m1YX8=\ngithub.com/vishvananda/netns v0.0.4/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM=\ngithub.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=\ngithub.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=\ngo.mongodb.org/mongo-driver v1.17.4 h1:jUorfmVzljjr0FLzYQsGP8cgN/qzzxlY9Vh0C9KFXVw=\ngo.mongodb.org/mongo-driver v1.17.4/go.mod h1:Hy04i7O2kC4RS06ZrhPRqj/u4DTYkFDAAccj+rVKqgQ=\ngo.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=\ngo.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=\ngo.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=\ngo.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=\ngolang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=\ngolang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=\ngolang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=\ngolang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=\ngolang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=\ngolang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=\ngolang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=\ngoogle.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=\ngoogle.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=\ngopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=\ngopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=\ngopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=\ngopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=\ngopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=\ngopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=\ngopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=\ngopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=\ngopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=\n"
  },
  {
    "path": "main.go",
    "content": "package main\n\nimport (\n\tlog \"github.com/sirupsen/logrus\"\n\n\t\"github.com/crowdsecurity/cs-firewall-bouncer/cmd\"\n)\n\nfunc main() {\n\terr := cmd.Execute()\n\tif err != nil {\n\t\tlog.Fatal(err)\n\t}\n}\n"
  },
  {
    "path": "pkg/backend/backend.go",
    "content": "package backend\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n\t\"runtime\"\n\n\tlog \"github.com/sirupsen/logrus\"\n\n\t\"github.com/crowdsecurity/crowdsec/pkg/models\"\n\n\t\"github.com/crowdsecurity/cs-firewall-bouncer/pkg/cfg\"\n\t\"github.com/crowdsecurity/cs-firewall-bouncer/pkg/dryrun\"\n\t\"github.com/crowdsecurity/cs-firewall-bouncer/pkg/iptables\"\n\t\"github.com/crowdsecurity/cs-firewall-bouncer/pkg/nftables\"\n\t\"github.com/crowdsecurity/cs-firewall-bouncer/pkg/pf\"\n\t\"github.com/crowdsecurity/cs-firewall-bouncer/pkg/types\"\n)\n\ntype BackendCTX struct {\n\tfirewall types.Backend\n}\n\nfunc (b *BackendCTX) Init() error {\n\treturn b.firewall.Init()\n}\n\nfunc (b *BackendCTX) Commit() error {\n\treturn b.firewall.Commit()\n}\n\nfunc (b *BackendCTX) ShutDown() error {\n\treturn b.firewall.ShutDown()\n}\n\nfunc (b *BackendCTX) Add(decision *models.Decision) error {\n\treturn b.firewall.Add(decision)\n}\n\nfunc (b *BackendCTX) Delete(decision *models.Decision) error {\n\treturn b.firewall.Delete(decision)\n}\n\nfunc (b *BackendCTX) CollectMetrics() {\n\tlog.Trace(\"Collecting backend-specific metrics\")\n\tb.firewall.CollectMetrics()\n}\n\nfunc isPFSupported(runtimeOS string) bool {\n\tvar supported bool\n\n\tswitch runtimeOS {\n\tcase \"openbsd\", \"freebsd\":\n\t\tsupported = true\n\tdefault:\n\t\tsupported = false\n\t}\n\n\treturn supported\n}\n\nfunc NewBackend(config *cfg.BouncerConfig) (*BackendCTX, error) {\n\tvar err error\n\n\tb := &BackendCTX{}\n\n\tlog.Infof(\"backend type: %s\", config.Mode)\n\n\tif config.DisableIPV6 {\n\t\tlog.Info(\"IPV6 is disabled\")\n\t}\n\n\tif config.DisableIPV4 {\n\t\tlog.Info(\"IPV4 is disabled\")\n\t}\n\n\tswitch config.Mode {\n\tcase cfg.IptablesMode, cfg.IpsetMode:\n\t\tif runtime.GOOS != \"linux\" {\n\t\t\treturn nil, errors.New(\"iptables and ipset is linux only\")\n\t\t}\n\n\t\tb.firewall, err = iptables.NewIPTables(config)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\tcase cfg.NftablesMode:\n\t\tif runtime.GOOS != \"linux\" {\n\t\t\treturn nil, errors.New(\"nftables is linux only\")\n\t\t}\n\n\t\tb.firewall, err = nftables.NewNFTables(config)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\tcase \"pf\":\n\t\tif !isPFSupported(runtime.GOOS) {\n\t\t\tlog.Warning(\"pf mode can only work with openbsd and freebsd. It is available on other platforms only for testing purposes\")\n\t\t}\n\n\t\tb.firewall, err = pf.NewPF(config)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\tcase \"dry-run\":\n\t\tb.firewall, err = dryrun.NewDryRun(config)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\tdefault:\n\t\treturn b, fmt.Errorf(\"firewall '%s' is not supported\", config.Mode)\n\t}\n\n\treturn b, nil\n}\n"
  },
  {
    "path": "pkg/cfg/config.go",
    "content": "package cfg\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\n\tlog \"github.com/sirupsen/logrus\"\n\t\"gopkg.in/yaml.v2\"\n\n\t\"github.com/crowdsecurity/go-cs-lib/csyaml\"\n\t\"github.com/crowdsecurity/go-cs-lib/ptr\"\n)\n\ntype PrometheusConfig struct {\n\tEnabled       bool   `yaml:\"enabled\"`\n\tListenAddress string `yaml:\"listen_addr\"`\n\tListenPort    string `yaml:\"listen_port\"`\n}\n\ntype nftablesFamilyConfig struct {\n\tEnabled  *bool  `yaml:\"enabled\"`\n\tSetOnly  bool   `yaml:\"set-only\"`\n\tTable    string `yaml:\"table\"`\n\tChain    string `yaml:\"chain\"`\n\tPriority int    `yaml:\"priority\"`\n}\n\nconst (\n\tIpsetMode    = \"ipset\"\n\tIptablesMode = \"iptables\"\n\tNftablesMode = \"nftables\"\n\tPfMode       = \"pf\"\n\tDryRunMode   = \"dry-run\"\n)\n\ntype BouncerConfig struct {\n\tMode               string        `yaml:\"mode\"`    // ipset,iptables,tc\n\tPidDir             string        `yaml:\"pid_dir\"` // unused\n\tUpdateFrequency    string        `yaml:\"update_frequency\"`\n\tDaemon             *bool         `yaml:\"daemonize\"` // unused\n\tLogging            LoggingConfig `yaml:\",inline\"`\n\tDisableIPV6        bool          `yaml:\"disable_ipv6\"`\n\tDisableIPV4        bool          `yaml:\"disable_ipv4\"`\n\tDenyAction         string        `yaml:\"deny_action\"`\n\tDenyLog            bool          `yaml:\"deny_log\"`\n\tDenyLogPrefix      string        `yaml:\"deny_log_prefix\"`\n\tBlacklistsIpv4     string        `yaml:\"blacklists_ipv4\"`\n\tBlacklistsIpv6     string        `yaml:\"blacklists_ipv6\"`\n\tSetType            string        `yaml:\"ipset_type\"`\n\tSetSize            int           `yaml:\"ipset_size\"`\n\tSetDisableTimeouts bool          `yaml:\"ipset_disable_timeouts\"`\n\n\t// specific to iptables, following https://github.com/crowdsecurity/cs-firewall-bouncer/issues/19\n\tIptablesChains          []string `yaml:\"iptables_chains\"`\n\tIptablesV4Chains        []string `yaml:\"iptables_v4_chains\"`\n\tIptablesV6Chains        []string `yaml:\"iptables_v6_chains\"`\n\tIptablesAddRuleComments bool     `yaml:\"iptables_add_rule_comments\"`\n\n\tSupportedDecisionsTypes []string `yaml:\"supported_decisions_types\"`\n\t// specific to nftables, following https://github.com/crowdsecurity/cs-firewall-bouncer/issues/74\n\tNftables struct {\n\t\tIpv4 nftablesFamilyConfig `yaml:\"ipv4\"`\n\t\tIpv6 nftablesFamilyConfig `yaml:\"ipv6\"`\n\t} `yaml:\"nftables\"`\n\tNftablesHooks []string `yaml:\"nftables_hooks\"`\n\tPF            struct {\n\t\tAnchorName string `yaml:\"anchor_name\"`\n\t\tBatchSize  int    `yaml:\"batch_size\"`\n\t} `yaml:\"pf\"`\n\tPrometheusConfig PrometheusConfig `yaml:\"prometheus\"`\n}\n\n// MergedConfig() returns the byte content of the patched configuration file (with .yaml.local).\nfunc MergedConfig(configPath string) ([]byte, error) {\n\tpatcher := csyaml.NewPatcher(configPath, \".local\")\n\n\tdata, err := patcher.MergedPatchContent()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn data, nil\n}\n\nfunc NewConfig(reader io.Reader) (*BouncerConfig, error) {\n\tconfig := &BouncerConfig{\n\t\tIptablesAddRuleComments: true,\n\t}\n\n\tfcontent, err := io.ReadAll(reader)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\terr = yaml.Unmarshal(fcontent, &config)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to unmarshal: %w\", err)\n\t}\n\n\tif err = config.Logging.setup(\"crowdsec-firewall-bouncer.log\"); err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to setup logging: %w\", err)\n\t}\n\n\tif config.Mode == \"\" {\n\t\treturn nil, errors.New(\"config does not contain 'mode'\")\n\t}\n\n\tif len(config.SupportedDecisionsTypes) == 0 {\n\t\tconfig.SupportedDecisionsTypes = []string{\"ban\"}\n\t}\n\n\tif config.PidDir != \"\" {\n\t\tlog.Debug(\"Ignoring deprecated 'pid_dir' option\")\n\t}\n\n\tif config.DenyLog && config.DenyLogPrefix == \"\" {\n\t\tconfig.DenyLogPrefix = \"crowdsec drop: \"\n\t}\n\n\t// for config file backward compatibility\n\tif config.BlacklistsIpv4 == \"\" {\n\t\tconfig.BlacklistsIpv4 = \"crowdsec-blacklists\"\n\t}\n\n\tif config.BlacklistsIpv6 == \"\" {\n\t\tconfig.BlacklistsIpv6 = \"crowdsec6-blacklists\"\n\t}\n\n\tif config.SetType == \"\" {\n\t\tconfig.SetType = \"nethash\"\n\t}\n\n\tif config.SetSize == 0 {\n\t\tconfig.SetSize = 131072\n\t}\n\n\tif config.DisableIPV4 && config.DisableIPV6 && config.Mode != NftablesMode {\n\t\t// we return an error for pf or iptables because nftables has it own way to handle this\n\t\treturn nil, errors.New(\"both IPv4 and IPv6 disabled, doing nothing\")\n\t}\n\n\tswitch config.Mode {\n\tcase NftablesMode:\n\t\terr := nftablesConfig(config)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\tcase IpsetMode, IptablesMode:\n\t\t// nothing specific to do\n\tcase PfMode:\n\t\terr := pfConfig(config)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\tcase DryRunMode:\n\t\t// nothing specific to do\n\tdefault:\n\t\tlog.Warningf(\"unexpected %s mode\", config.Mode)\n\t}\n\n\treturn config, nil\n}\n\nfunc pfConfig(config *BouncerConfig) error {\n\tif config.PF.BatchSize != 0 {\n\t\tlog.Warning(\"Option pf.batch_size is deprecated and ignored, all IPs are loaded at once\")\n\t}\n\n\treturn nil\n}\n\nfunc nftablesConfig(config *BouncerConfig) error {\n\t// deal with defaults in a backward compatible way\n\tif config.Nftables.Ipv4.Enabled == nil {\n\t\tconfig.Nftables.Ipv4.Enabled = ptr.Of(!config.DisableIPV4)\n\t}\n\n\tif config.Nftables.Ipv6.Enabled == nil {\n\t\tconfig.Nftables.Ipv6.Enabled = ptr.Of(!config.DisableIPV6)\n\t}\n\n\tif *config.Nftables.Ipv4.Enabled {\n\t\tif config.Nftables.Ipv4.Table == \"\" {\n\t\t\tconfig.Nftables.Ipv4.Table = \"crowdsec\"\n\t\t}\n\n\t\tif config.Nftables.Ipv4.Chain == \"\" {\n\t\t\tconfig.Nftables.Ipv4.Chain = \"crowdsec-chain\"\n\t\t}\n\t}\n\n\tif *config.Nftables.Ipv6.Enabled {\n\t\tif config.Nftables.Ipv6.Table == \"\" {\n\t\t\tconfig.Nftables.Ipv6.Table = \"crowdsec6\"\n\t\t}\n\n\t\tif config.Nftables.Ipv6.Chain == \"\" {\n\t\t\tconfig.Nftables.Ipv6.Chain = \"crowdsec6-chain\"\n\t\t}\n\t}\n\n\tif !*config.Nftables.Ipv4.Enabled && !*config.Nftables.Ipv6.Enabled {\n\t\treturn errors.New(\"both IPv4 and IPv6 disabled, doing nothing\")\n\t}\n\n\tif len(config.NftablesHooks) == 0 {\n\t\tconfig.NftablesHooks = []string{\"input\"}\n\t}\n\n\treturn nil\n}\n"
  },
  {
    "path": "pkg/cfg/logging.go",
    "content": "package cfg\n\nimport (\n\t\"errors\"\n\t\"io\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"time\"\n\n\tlog \"github.com/sirupsen/logrus\"\n\t\"github.com/sirupsen/logrus/hooks/writer\"\n\t\"gopkg.in/natefinch/lumberjack.v2\"\n\n\t\"github.com/crowdsecurity/go-cs-lib/ptr\"\n)\n\ntype LoggingConfig struct {\n\tLogLevel     *log.Level `yaml:\"log_level\"`\n\tLogMode      string     `yaml:\"log_mode\"`\n\tLogDir       string     `yaml:\"log_dir\"`\n\tLogMaxSize   int        `yaml:\"log_max_size,omitempty\"`\n\tLogMaxFiles  int        `yaml:\"log_max_files,omitempty\"`\n\tLogMaxAge    int        `yaml:\"log_max_age,omitempty\"`\n\tCompressLogs *bool      `yaml:\"compress_logs,omitempty\"`\n}\n\nfunc (c *LoggingConfig) LoggerForFile(fileName string) (io.Writer, error) {\n\tif c.LogMode == \"stdout\" {\n\t\treturn os.Stderr, nil\n\t}\n\n\t// default permissions will be 0600 from lumberjack\n\t// and are preserved if the file already exists\n\n\tl := &lumberjack.Logger{\n\t\tFilename:   filepath.Join(c.LogDir, fileName),\n\t\tMaxSize:    c.LogMaxSize,\n\t\tMaxBackups: c.LogMaxFiles,\n\t\tMaxAge:     c.LogMaxAge,\n\t\tCompress:   *c.CompressLogs,\n\t}\n\n\treturn l, nil\n}\n\nfunc (c *LoggingConfig) setDefaults() {\n\tif c.LogMode == \"\" {\n\t\tc.LogMode = \"stdout\"\n\t}\n\n\tif c.LogDir == \"\" {\n\t\tc.LogDir = \"/var/log/\"\n\t}\n\n\tif c.LogLevel == nil {\n\t\tc.LogLevel = ptr.Of(log.InfoLevel)\n\t}\n\n\tif c.LogMaxSize == 0 {\n\t\tc.LogMaxSize = 500\n\t}\n\n\tif c.LogMaxFiles == 0 {\n\t\tc.LogMaxFiles = 3\n\t}\n\n\tif c.LogMaxAge == 0 {\n\t\tc.LogMaxAge = 30\n\t}\n\n\tif c.CompressLogs == nil {\n\t\tc.CompressLogs = ptr.Of(true)\n\t}\n}\n\nfunc (c *LoggingConfig) validate() error {\n\tif c.LogMode != \"stdout\" && c.LogMode != \"file\" {\n\t\treturn errors.New(\"log_mode should be either 'stdout' or 'file'\")\n\t}\n\n\treturn nil\n}\n\nfunc (c *LoggingConfig) setup(fileName string) error {\n\tc.setDefaults()\n\n\tif err := c.validate(); err != nil {\n\t\treturn err\n\t}\n\n\tlog.SetLevel(*c.LogLevel)\n\n\tif c.LogMode == \"stdout\" {\n\t\treturn nil\n\t}\n\n\tlog.SetFormatter(&log.TextFormatter{TimestampFormat: time.RFC3339, FullTimestamp: true})\n\n\tlogger, err := c.LoggerForFile(fileName)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tlog.SetOutput(logger)\n\n\t// keep stderr for panic/fatal, otherwise process failures\n\t// won't be visible enough\n\tlog.AddHook(&writer.Hook{\n\t\tWriter: os.Stderr,\n\t\tLogLevels: []log.Level{\n\t\t\tlog.PanicLevel,\n\t\t\tlog.FatalLevel,\n\t\t},\n\t})\n\n\treturn nil\n}\n"
  },
  {
    "path": "pkg/dryrun/dryrun.go",
    "content": "package dryrun\n\nimport (\n\tlog \"github.com/sirupsen/logrus\"\n\n\t\"github.com/crowdsecurity/crowdsec/pkg/models\"\n\n\t\"github.com/crowdsecurity/cs-firewall-bouncer/pkg/cfg\"\n\t\"github.com/crowdsecurity/cs-firewall-bouncer/pkg/types\"\n)\n\ntype dryRun struct{}\n\nfunc NewDryRun(_ *cfg.BouncerConfig) (types.Backend, error) {\n\treturn &dryRun{}, nil\n}\n\nfunc (*dryRun) Init() error {\n\tlog.Infof(\"backend.Init() called\")\n\treturn nil\n}\n\nfunc (*dryRun) Commit() error {\n\tlog.Infof(\"backend.Commit() called\")\n\treturn nil\n}\n\nfunc (*dryRun) Add(decision *models.Decision) error {\n\tlog.Infof(\"backend.Add() called with %s\", *decision.Value)\n\treturn nil\n}\n\nfunc (*dryRun) CollectMetrics() {\n\tlog.Infof(\"backend.CollectMetrics() called\")\n}\n\nfunc (*dryRun) Delete(decision *models.Decision) error {\n\tlog.Infof(\"backend.Delete() called with %s\", *decision.Value)\n\treturn nil\n}\n\nfunc (*dryRun) ShutDown() error {\n\tlog.Infof(\"backend.ShutDown() called\")\n\treturn nil\n}\n"
  },
  {
    "path": "pkg/ipsetcmd/ipset.go",
    "content": "package ipsetcmd\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n\t\"os/exec\"\n\t\"strconv\"\n\t\"strings\"\n\n\tlog \"github.com/sirupsen/logrus\"\n)\n\ntype IPSet struct {\n\tbinaryPath string\n\tsetName    string\n}\n\ntype CreateOptions struct {\n\tTimeout         string\n\tMaxElem         string\n\tFamily          string\n\tType            string\n\tDisableTimeouts bool\n}\n\nconst ipsetBinary = \"ipset\"\n\nfunc NewIPSet(setName string) (*IPSet, error) {\n\tipsetBin, err := exec.LookPath(ipsetBinary)\n\tif err != nil {\n\t\treturn nil, errors.New(\"unable to find ipset\")\n\t}\n\n\treturn &IPSet{\n\t\tbinaryPath: ipsetBin,\n\t\tsetName:    setName,\n\t}, nil\n}\n\n// Wraps all the ipset commands.\nfunc (i *IPSet) Create(opts CreateOptions) error {\n\tcmdArgs := []string{\"create\", i.setName}\n\n\tif opts.Type != \"\" {\n\t\tcmdArgs = append(cmdArgs, opts.Type)\n\t}\n\n\tif opts.Timeout != \"\" && !opts.DisableTimeouts {\n\t\tcmdArgs = append(cmdArgs, \"timeout\", opts.Timeout)\n\t}\n\n\tif opts.MaxElem != \"\" {\n\t\tcmdArgs = append(cmdArgs, \"maxelem\", opts.MaxElem)\n\t}\n\n\tif opts.Family != \"\" {\n\t\tcmdArgs = append(cmdArgs, \"family\", opts.Family)\n\t}\n\n\tcmd := exec.Command(i.binaryPath, cmdArgs...)\n\n\tlog.Debugf(\"ipset create command: %v\", cmd.String())\n\n\tout, err := cmd.CombinedOutput()\n\tif err != nil {\n\t\treturn fmt.Errorf(\"error creating ipset: %s\", out)\n\t}\n\n\treturn nil\n}\n\nfunc (i *IPSet) Add(entry string) error {\n\tcmd := exec.Command(i.binaryPath, \"add\", i.setName, entry)\n\n\tlog.Debugf(\"ipset add command: %v\", cmd.String())\n\n\tout, err := cmd.CombinedOutput()\n\tif err != nil {\n\t\treturn fmt.Errorf(\"error creating ipset: %s\", out)\n\t}\n\n\treturn nil\n}\n\nfunc (i *IPSet) DeleteEntry(entry string) error {\n\tcmd := exec.Command(i.binaryPath, \"del\", i.setName, entry)\n\n\tlog.Debugf(\"ipset delete entry command: %v\", cmd.String())\n\n\tout, err := cmd.CombinedOutput()\n\tif err != nil {\n\t\treturn fmt.Errorf(\"error creating ipset: %s\", out)\n\t}\n\n\treturn nil\n}\n\nfunc (i *IPSet) List() ([]string, error) {\n\tcmd := exec.Command(i.binaryPath, \"list\", i.setName)\n\n\tlog.Debugf(\"ipset list command: %v\", cmd.String())\n\n\tout, err := cmd.CombinedOutput()\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"error listing ipset: %s\", out)\n\t}\n\n\treturn strings.Split(string(out), \"\\n\"), nil\n}\n\nfunc (i *IPSet) Flush() error {\n\tcmd := exec.Command(i.binaryPath, \"flush\", i.setName)\n\n\tlog.Debugf(\"ipset flush command: %v\", cmd.String())\n\n\tout, err := cmd.CombinedOutput()\n\tif err != nil {\n\t\treturn fmt.Errorf(\"error flushing ipset: %s\", out)\n\t}\n\n\treturn nil\n}\n\nfunc (i *IPSet) Destroy() error {\n\tcmd := exec.Command(i.binaryPath, \"destroy\", i.setName)\n\n\tlog.Debugf(\"ipset destroy command: %v\", cmd.String())\n\n\tout, err := cmd.CombinedOutput()\n\tif err != nil {\n\t\treturn fmt.Errorf(\"error destroying ipset: %s\", out)\n\t}\n\n\treturn nil\n}\n\nfunc (i *IPSet) Rename(toSetName string) error {\n\tcmd := exec.Command(i.binaryPath, \"rename\", i.setName, toSetName)\n\n\tlog.Debugf(\"ipset rename command: %v\", cmd.String())\n\n\tout, err := cmd.CombinedOutput()\n\tif err != nil {\n\t\treturn fmt.Errorf(\"error renaming ipset: %s\", out)\n\t}\n\n\ti.setName = toSetName\n\n\treturn nil\n}\n\nfunc (i *IPSet) Test(entry string) error {\n\tcmd := exec.Command(i.binaryPath, \"test\", i.setName, entry)\n\n\tlog.Debugf(\"ipset test command: %v\", cmd.String())\n\n\tout, err := cmd.CombinedOutput()\n\tif err != nil {\n\t\treturn fmt.Errorf(\"error testing ipset: %s\", out)\n\t}\n\n\treturn nil\n}\n\nfunc (i *IPSet) Save() ([]string, error) {\n\tcmd := exec.Command(i.binaryPath, \"save\", i.setName)\n\n\tlog.Debugf(\"ipset save command: %v\", cmd.String())\n\n\tout, err := cmd.CombinedOutput()\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"error saving ipset: %s\", out)\n\t}\n\n\treturn strings.Split(string(out), \"\\n\"), nil\n}\n\nfunc (i *IPSet) Restore(filename string) error {\n\tcmd := exec.Command(i.binaryPath, \"restore\", \"-file\", filename)\n\n\tlog.Debugf(\"ipset restore command: %v\", cmd.String())\n\n\tout, err := cmd.CombinedOutput()\n\tif err != nil {\n\t\treturn fmt.Errorf(\"error restoring ipset: %s\", out)\n\t}\n\n\treturn nil\n}\n\nfunc (i *IPSet) Swap(toSetName string) error {\n\tcmd := exec.Command(i.binaryPath, \"swap\", i.setName, toSetName)\n\n\tlog.Debugf(\"ipset swap command: %v\", cmd.String())\n\n\tout, err := cmd.CombinedOutput()\n\tif err != nil {\n\t\treturn fmt.Errorf(\"error swapping ipset: %s\", out)\n\t}\n\n\ti.setName = toSetName\n\n\treturn nil\n}\n\nfunc (i *IPSet) Name() string {\n\treturn i.setName\n}\n\nfunc (i *IPSet) Exists() bool {\n\tcmd := exec.Command(i.binaryPath, \"list\", i.setName)\n\n\terr := cmd.Run()\n\n\treturn err == nil\n}\n\nfunc (i *IPSet) Len() int {\n\tcmd := exec.Command(i.binaryPath, \"list\", i.setName)\n\n\tlog.Debugf(\"ipset list command: %v\", cmd.String())\n\n\tout, err := cmd.CombinedOutput()\n\tif err != nil {\n\t\treturn 0\n\t}\n\n\tfor _, line := range strings.Split(string(out), \"\\n\") {\n\t\tif !strings.Contains(strings.ToLower(line), \"number of entries:\") {\n\t\t\tcontinue\n\t\t}\n\n\t\tfields := strings.Split(line, \":\")\n\t\tif len(fields) != 2 {\n\t\t\tcontinue\n\t\t}\n\n\t\tcount, err := strconv.Atoi(strings.TrimSpace(fields[1]))\n\t\tif err != nil {\n\t\t\treturn 0\n\t\t}\n\n\t\treturn count\n\t}\n\n\treturn 0\n}\n\n// Helpers.\nfunc GetSetsStartingWith(name string) (map[string]*IPSet, error) {\n\tcmd := exec.Command(ipsetBinary, \"list\", \"-name\")\n\n\tlog.Debugf(\"ipset list command: %v\", cmd.String())\n\n\tout, err := cmd.CombinedOutput()\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"error listing ipset: %s\", out)\n\t}\n\n\tsets := make(map[string]*IPSet, 0)\n\n\tfor _, line := range strings.Split(string(out), \"\\n\") {\n\t\tif !strings.HasPrefix(line, name) {\n\t\t\tcontinue\n\t\t}\n\n\t\tfields := strings.Fields(line)\n\t\tif len(fields) != 1 {\n\t\t\tcontinue\n\t\t}\n\n\t\tset, err := NewIPSet(fields[0])\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\tsets[fields[0]] = set\n\t}\n\n\treturn sets, nil\n}\n"
  },
  {
    "path": "pkg/iptables/iptables.go",
    "content": "//go:build linux\n\npackage iptables\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n\t\"os/exec\"\n\t\"slices\"\n\t\"strings\"\n\n\tlog \"github.com/sirupsen/logrus\"\n\n\t\"github.com/crowdsecurity/crowdsec/pkg/models\"\n\n\t\"github.com/crowdsecurity/cs-firewall-bouncer/pkg/cfg\"\n\t\"github.com/crowdsecurity/cs-firewall-bouncer/pkg/ipsetcmd\"\n\t\"github.com/crowdsecurity/cs-firewall-bouncer/pkg/types\"\n)\n\nconst (\n\tIPTablesDroppedPacketIdx = 0\n\tIPTablesDroppedByteIdx   = 1\n)\n\ntype iptables struct {\n\tv4 *ipTablesContext\n\tv6 *ipTablesContext\n}\n\nfunc NewIPTables(config *cfg.BouncerConfig) (types.Backend, error) {\n\tvar err error\n\n\tret := &iptables{}\n\n\tdefaultSet, err := ipsetcmd.NewIPSet(\"\")\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tallowedActions := []string{\"DROP\", \"REJECT\", \"TARPIT\", \"LOG\"}\n\n\ttarget := strings.ToUpper(config.DenyAction)\n\tif target == \"\" {\n\t\ttarget = \"DROP\"\n\t}\n\n\tlog.Infof(\"using '%s' as deny_action\", target)\n\n\tif !slices.Contains(allowedActions, target) {\n\t\treturn nil, fmt.Errorf(\"invalid deny_action '%s', must be one of %s\", config.DenyAction, strings.Join(allowedActions, \", \"))\n\t}\n\n\tv4Sets := make(map[string]*ipsetcmd.IPSet)\n\tv6Sets := make(map[string]*ipsetcmd.IPSet)\n\n\tipv4Ctx := &ipTablesContext{\n\t\tversion:              \"v4\",\n\t\tSetName:              config.BlacklistsIpv4,\n\t\tSetType:              config.SetType,\n\t\tSetSize:              config.SetSize,\n\t\tipsetDisableTimeouts: config.SetDisableTimeouts,\n\t\tChains:               []string{},\n\t\tdefaultSet:           defaultSet,\n\t\ttarget:               target,\n\t\tloggingEnabled:       config.DenyLog,\n\t\tloggingPrefix:        config.DenyLogPrefix,\n\t\taddRuleComments:      config.IptablesAddRuleComments,\n\t}\n\tipv6Ctx := &ipTablesContext{\n\t\tversion:              \"v6\",\n\t\tSetName:              config.BlacklistsIpv6,\n\t\tSetType:              config.SetType,\n\t\tSetSize:              config.SetSize,\n\t\tipsetDisableTimeouts: config.SetDisableTimeouts,\n\t\tChains:               []string{},\n\t\tdefaultSet:           defaultSet,\n\t\ttarget:               target,\n\t\tloggingEnabled:       config.DenyLog,\n\t\tloggingPrefix:        config.DenyLogPrefix,\n\t\taddRuleComments:      config.IptablesAddRuleComments,\n\t}\n\n\tif !config.DisableIPV4 {\n\t\tipv4Ctx.iptablesSaveBin, err = exec.LookPath(\"iptables-save\")\n\t\tif err != nil {\n\t\t\treturn nil, errors.New(\"unable to find iptables-save\")\n\t\t}\n\n\t\tif config.Mode == cfg.IpsetMode {\n\t\t\tipv4Ctx.ipsetContentOnly = true\n\n\t\t\tset, err := ipsetcmd.NewIPSet(config.BlacklistsIpv4)\n\t\t\tif err != nil {\n\t\t\t\treturn nil, err\n\t\t\t}\n\n\t\t\tv4Sets[\"ipset\"] = set\n\t\t} else {\n\t\t\tipv4Ctx.iptablesBin, err = exec.LookPath(\"iptables\")\n\t\t\tif err != nil {\n\t\t\t\treturn nil, errors.New(\"unable to find iptables\")\n\t\t\t}\n\n\t\t\t// Try to \"adopt\" any leftover sets from a previous run if we crashed\n\t\t\t// They will get flushed/deleted just after\n\t\t\tv4Sets, _ = ipsetcmd.GetSetsStartingWith(config.BlacklistsIpv4)\n\n\t\t\tconfig.IptablesV4Chains = append(config.IptablesV4Chains, config.IptablesChains...)\n\t\t\tipv4Ctx.Chains = config.IptablesV4Chains\n\t\t}\n\n\t\tipv4Ctx.ipsets = v4Sets\n\t\tret.v4 = ipv4Ctx\n\t}\n\n\tif !config.DisableIPV6 {\n\t\tipv6Ctx.iptablesSaveBin, err = exec.LookPath(\"ip6tables-save\")\n\t\tif err != nil {\n\t\t\treturn nil, errors.New(\"unable to find ip6tables-save\")\n\t\t}\n\n\t\tif config.Mode == cfg.IpsetMode {\n\t\t\tipv6Ctx.ipsetContentOnly = true\n\n\t\t\tset, err := ipsetcmd.NewIPSet(config.BlacklistsIpv6)\n\t\t\tif err != nil {\n\t\t\t\treturn nil, err\n\t\t\t}\n\n\t\t\tv6Sets[\"ipset\"] = set\n\t\t} else {\n\t\t\tipv6Ctx.iptablesBin, err = exec.LookPath(\"ip6tables\")\n\t\t\tif err != nil {\n\t\t\t\treturn nil, errors.New(\"unable to find ip6tables\")\n\t\t\t}\n\n\t\t\tv6Sets, _ = ipsetcmd.GetSetsStartingWith(config.BlacklistsIpv6)\n\t\t\tconfig.IptablesV6Chains = append(config.IptablesV6Chains, config.IptablesChains...)\n\t\t\tipv6Ctx.Chains = config.IptablesV6Chains\n\t\t}\n\n\t\tipv6Ctx.ipsets = v6Sets\n\t\tret.v6 = ipv6Ctx\n\t}\n\n\treturn ret, nil\n}\n\nfunc (ipt *iptables) Init() error {\n\tif ipt.v4 != nil {\n\t\tlog.Info(\"iptables for ipv4 initiated\")\n\n\t\t// flush before init\n\t\tif err := ipt.v4.shutDown(); err != nil {\n\t\t\treturn fmt.Errorf(\"iptables shutdown failed: %w\", err)\n\t\t}\n\n\t\tif !ipt.v4.ipsetContentOnly {\n\t\t\tipt.v4.setupChain()\n\t\t}\n\t}\n\n\tif ipt.v6 != nil {\n\t\tlog.Info(\"iptables for ipv6 initiated\")\n\n\t\tif err := ipt.v6.shutDown(); err != nil {\n\t\t\treturn fmt.Errorf(\"iptables shutdown failed: %w\", err)\n\t\t}\n\n\t\tif !ipt.v6.ipsetContentOnly {\n\t\t\tipt.v6.setupChain()\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc (ipt *iptables) Commit() error {\n\tif ipt.v4 != nil {\n\t\terr := ipt.v4.commit()\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"ipset for ipv4 commit failed: %w\", err)\n\t\t}\n\t}\n\n\tif ipt.v6 != nil {\n\t\terr := ipt.v6.commit()\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"ipset for ipv6 commit failed: %w\", err)\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc (ipt *iptables) Add(decision *models.Decision) error {\n\tif strings.HasPrefix(*decision.Type, \"simulation:\") {\n\t\tlog.Debugf(\"measure against '%s' is in simulation mode, skipping it\", *decision.Value)\n\t\treturn nil\n\t}\n\n\tif strings.Contains(*decision.Value, \":\") {\n\t\tif ipt.v6 == nil {\n\t\t\tlog.Debugf(\"not adding '%s' because ipv6 is disabled\", *decision.Value)\n\t\t\treturn nil\n\t\t}\n\n\t\tipt.v6.add(decision)\n\t} else {\n\t\tif ipt.v4 == nil {\n\t\t\tlog.Debugf(\"not adding '%s' because ipv4 is disabled\", *decision.Value)\n\t\t\treturn nil\n\t\t}\n\t\tipt.v4.add(decision)\n\t}\n\n\treturn nil\n}\n\nfunc (ipt *iptables) ShutDown() error {\n\tif ipt.v4 != nil {\n\t\tif err := ipt.v4.shutDown(); err != nil {\n\t\t\treturn fmt.Errorf(\"iptables for ipv4 shutdown failed: %w\", err)\n\t\t}\n\t}\n\n\tif ipt.v6 != nil {\n\t\tif err := ipt.v6.shutDown(); err != nil {\n\t\t\treturn fmt.Errorf(\"iptables for ipv6 shutdown failed: %w\", err)\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc (ipt *iptables) Delete(decision *models.Decision) error {\n\tdone := false\n\n\tif strings.Contains(*decision.Value, \":\") {\n\t\tif ipt.v6 == nil {\n\t\t\tlog.Debugf(\"not deleting '%s' because ipv6 is disabled\", *decision.Value)\n\t\t\treturn nil\n\t\t}\n\n\t\tif err := ipt.v6.delete(decision); err != nil {\n\t\t\treturn errors.New(\"failed deleting ban\")\n\t\t}\n\n\t\tdone = true\n\t}\n\n\tif strings.Contains(*decision.Value, \".\") {\n\t\tif ipt.v4 == nil {\n\t\t\tlog.Debugf(\"not deleting '%s' because ipv4 is disabled\", *decision.Value)\n\t\t\treturn nil\n\t\t}\n\n\t\tif err := ipt.v4.delete(decision); err != nil {\n\t\t\treturn errors.New(\"failed deleting ban\")\n\t\t}\n\n\t\tdone = true\n\t}\n\n\tif !done {\n\t\treturn fmt.Errorf(\"failed deleting ban: ip %s was not recognized\", *decision.Value)\n\t}\n\n\treturn nil\n}\n"
  },
  {
    "path": "pkg/iptables/iptables_context.go",
    "content": "//go:build linux\n\npackage iptables\n\nimport (\n\t\"fmt\"\n\t\"os\"\n\t\"os/exec\"\n\t\"slices\"\n\t\"strconv\"\n\t\"strings\"\n\t\"time\"\n\n\tlog \"github.com/sirupsen/logrus\"\n\n\t\"github.com/crowdsecurity/crowdsec/pkg/models\"\n\n\t\"github.com/crowdsecurity/cs-firewall-bouncer/pkg/ipsetcmd\"\n)\n\nconst (\n\tchainName           = \"CROWDSEC_CHAIN\"\n\tloggingChainName    = \"CROWDSEC_LOG\"\n\tdockerUserChainName = \"DOCKER-USER\"\n\tmaxBanSeconds       = 2147483\n\tdefaultTimeout      = \"300\"\n)\n\ntype ipTablesContext struct {\n\tversion              string\n\tiptablesBin          string\n\tiptablesSaveBin      string\n\tSetName              string // crowdsec-netfilter\n\tSetType              string\n\tSetSize              int\n\tipsetContentOnly     bool\n\tipsetDisableTimeouts bool\n\tChains               []string\n\n\ttarget string\n\n\tipsets     map[string]*ipsetcmd.IPSet\n\tdefaultSet *ipsetcmd.IPSet // This one is only used to restore the content, as the file will contain the name of the set for each decision\n\n\ttoAdd []*models.Decision\n\ttoDel []*models.Decision\n\n\t// To avoid issues with set name length (ipsest name length is limited to 31 characters)\n\t// Store the origin of the decisions, and use the index in the slice as the name\n\t// This is not stable (ie, between two runs, the index of a set can change), but it's (probably) not an issue\n\toriginSetMapping []string\n\n\tloggingEnabled bool\n\tloggingPrefix  string\n\n\taddRuleComments bool\n}\n\nfunc (ctx *ipTablesContext) chainExist(chainName string) bool {\n\tcmd := []string{\"-L\", chainName, \"-t\", \"filter\"}\n\tc := exec.Command(ctx.iptablesBin, cmd...)\n\n\tif _, err := c.CombinedOutput(); err != nil {\n\t\treturn false\n\t}\n\n\treturn true\n}\n\nfunc (ctx *ipTablesContext) setupChain() {\n\tcmd := []string{\"-N\", chainName, \"-t\", \"filter\"}\n\n\tc := exec.Command(ctx.iptablesBin, cmd...)\n\n\tlog.Infof(\"Creating chain : %s %s\", ctx.iptablesBin, strings.Join(cmd, \" \"))\n\n\tif out, err := c.CombinedOutput(); err != nil {\n\t\tlog.Errorf(\"error while creating chain : %v --> %s\", err, string(out))\n\t\treturn\n\t}\n\n\tfor _, chain := range ctx.Chains {\n\t\tcmd = []string{\"-I\", chain, \"-j\", chainName}\n\n\t\tc = exec.Command(ctx.iptablesBin, cmd...)\n\n\t\tlog.Infof(\"Adding rule : %s %s\", ctx.iptablesBin, strings.Join(cmd, \" \"))\n\n\t\tif out, err := c.CombinedOutput(); err != nil {\n\t\t\tlog.Errorf(\"error while adding rule : %v --> %s\", err, string(out))\n\t\t\tcontinue\n\t\t}\n\t}\n\n\tif ctx.loggingEnabled {\n\t\t// Create the logging chain\n\t\tcmd = []string{\"-N\", loggingChainName, \"-t\", \"filter\"}\n\n\t\tc = exec.Command(ctx.iptablesBin, cmd...)\n\n\t\tlog.Infof(\"Creating logging chain : %s %s\", ctx.iptablesBin, strings.Join(cmd, \" \"))\n\n\t\tif out, err := c.CombinedOutput(); err != nil {\n\t\t\tlog.Errorf(\"error while creating logging chain : %v --> %s\", err, string(out))\n\t\t\treturn\n\t\t}\n\n\t\t// Insert the logging rule\n\t\tcmd = []string{\"-I\", loggingChainName, \"-j\", \"LOG\", \"--log-prefix\", ctx.loggingPrefix}\n\n\t\tc = exec.Command(ctx.iptablesBin, cmd...)\n\n\t\tlog.Infof(\"Adding logging rule : %s %s\", ctx.iptablesBin, strings.Join(cmd, \" \"))\n\n\t\tif out, err := c.CombinedOutput(); err != nil {\n\t\t\tlog.Errorf(\"error while adding logging rule : %v --> %s\", err, string(out))\n\t\t}\n\n\t\t// Add the desired target to the logging chain\n\n\t\tcmd = []string{\"-A\", loggingChainName, \"-j\", ctx.target}\n\n\t\tc = exec.Command(ctx.iptablesBin, cmd...)\n\n\t\tlog.Infof(\"Adding target rule to logging chain : %s %s\", ctx.iptablesBin, strings.Join(cmd, \" \"))\n\n\t\tif out, err := c.CombinedOutput(); err != nil {\n\t\t\tlog.Errorf(\"error while setting logging chain policy : %v --> %s\", err, string(out))\n\t\t}\n\t}\n\n\tif ctx.chainExist(dockerUserChainName) && !slices.Contains(ctx.Chains, dockerUserChainName) {\n\t\t// if the DOCKER-USER chain exists, but is not configured by the user, warn them as their containers will not be protected\n\t\tlog.Warnf(\"The %s chain exists, but is not configured for use by the bouncer. The bouncer will not block traffic destined for your containers\", dockerUserChainName)\n\t}\n}\n\nfunc (ctx *ipTablesContext) deleteChain() {\n\tfor _, chain := range ctx.Chains {\n\t\tcmd := []string{\"-D\", chain, \"-j\", chainName}\n\n\t\tc := exec.Command(ctx.iptablesBin, cmd...)\n\n\t\tlog.Infof(\"Deleting rule : %s %s\", ctx.iptablesBin, strings.Join(cmd, \" \"))\n\n\t\tif out, err := c.CombinedOutput(); err != nil {\n\t\t\tlog.Errorf(\"error while removing rule : %v --> %s\", err, string(out))\n\t\t}\n\t}\n\n\tcmd := []string{\"-F\", chainName}\n\n\tc := exec.Command(ctx.iptablesBin, cmd...)\n\n\tlog.Infof(\"Flushing chain : %s %s\", ctx.iptablesBin, strings.Join(cmd, \" \"))\n\n\tif out, err := c.CombinedOutput(); err != nil {\n\t\tlog.Errorf(\"error while flushing chain : %v --> %s\", err, string(out))\n\t}\n\n\tcmd = []string{\"-X\", chainName}\n\n\tc = exec.Command(ctx.iptablesBin, cmd...)\n\n\tlog.Infof(\"Deleting chain : %s %s\", ctx.iptablesBin, strings.Join(cmd, \" \"))\n\n\tif out, err := c.CombinedOutput(); err != nil {\n\t\tlog.Errorf(\"error while deleting chain : %v --> %s\", err, string(out))\n\t}\n\n\tif ctx.loggingEnabled {\n\t\tcmd = []string{\"-F\", loggingChainName}\n\n\t\tc = exec.Command(ctx.iptablesBin, cmd...)\n\n\t\tlog.Infof(\"Flushing logging chain : %s %s\", ctx.iptablesBin, strings.Join(cmd, \" \"))\n\n\t\tif out, err := c.CombinedOutput(); err != nil {\n\t\t\tlog.Errorf(\"error while flushing logging chain : %v --> %s\", err, string(out))\n\t\t}\n\n\t\tcmd = []string{\"-X\", loggingChainName}\n\n\t\tc = exec.Command(ctx.iptablesBin, cmd...)\n\n\t\tlog.Infof(\"Deleting logging chain : %s %s\", ctx.iptablesBin, strings.Join(cmd, \" \"))\n\n\t\tif out, err := c.CombinedOutput(); err != nil {\n\t\t\tlog.Errorf(\"error while deleting logging chain : %v --> %s\", err, string(out))\n\t\t}\n\t}\n}\n\nfunc (ctx *ipTablesContext) createRule(setName string, origin string) {\n\ttarget := ctx.target\n\n\tif ctx.loggingEnabled {\n\t\ttarget = loggingChainName\n\t}\n\n\tcmd := []string{\"-I\", chainName, \"-m\", \"set\", \"--match-set\", setName, \"src\", \"-j\", target}\n\n\tif ctx.addRuleComments {\n\t\tcmd = append(cmd, \"-m\", \"comment\", \"--comment\", \"CrowdSec: \"+origin)\n\t}\n\n\tc := exec.Command(ctx.iptablesBin, cmd...)\n\n\tlog.Infof(\"Creating rule : %s %s\", ctx.iptablesBin, strings.Join(cmd, \" \"))\n\n\tif out, err := c.CombinedOutput(); err != nil {\n\t\tlog.Errorf(\"error while inserting set entry in iptables : %v --> %s\", err, string(out))\n\t}\n}\n\nfunc (ctx *ipTablesContext) commit() error {\n\ttmpFile, err := os.CreateTemp(\"\", \"cs-firewall-bouncer-ipset-\")\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tdefer func() {\n\t\ttmpFile.Close()\n\t\tos.Remove(tmpFile.Name())\n\n\t\tctx.toAdd = nil\n\t\tctx.toDel = nil\n\t}()\n\n\tfor _, decision := range ctx.toDel {\n\t\tvar (\n\t\t\tset *ipsetcmd.IPSet\n\t\t\tok  bool\n\t\t)\n\n\t\t// Decisions coming from lists will have \"lists\" as origin, and the scenario will be the list name\n\t\t// We use those to build a custom origin because we want to track metrics per list\n\t\t// In case of other origin (crowdsec, cscli, ...), we do not really care about the scenario, it would be too noisy\n\t\torigin := *decision.Origin\n\t\tif origin == \"lists\" {\n\t\t\torigin = origin + \":\" + *decision.Scenario\n\t\t}\n\n\t\tif ctx.ipsetContentOnly {\n\t\t\tset = ctx.ipsets[\"ipset\"]\n\t\t} else {\n\t\t\tset, ok = ctx.ipsets[origin]\n\t\t\tif !ok {\n\t\t\t\t// No set for this origin, skip, as there's nothing to delete\n\t\t\t\tcontinue\n\t\t\t}\n\t\t}\n\n\t\tdelCmd := fmt.Sprintf(\"del %s %s -exist\\n\", set.Name(), *decision.Value)\n\n\t\tlog.Debugf(\"%s\", delCmd)\n\n\t\t_, err = tmpFile.WriteString(delCmd)\n\t\tif err != nil {\n\t\t\tlog.Errorf(\"error while writing to temp file : %s\", err)\n\t\t\tcontinue\n\t\t}\n\t}\n\n\tfor _, decision := range ctx.toAdd {\n\t\tbanDuration, err := time.ParseDuration(*decision.Duration)\n\t\tif err != nil {\n\t\t\tlog.Errorf(\"error while parsing ban duration : %s\", err)\n\t\t\tcontinue\n\t\t}\n\n\t\tvar (\n\t\t\tset *ipsetcmd.IPSet\n\t\t\tok  bool\n\t\t)\n\n\t\tif banDuration.Seconds() > maxBanSeconds {\n\t\t\tlog.Warnf(\"Ban duration too long (%d seconds), maximum for ipset is %d, setting duration to %d\", int(banDuration.Seconds()), maxBanSeconds, maxBanSeconds-1)\n\t\t\tbanDuration = time.Duration(maxBanSeconds-1) * time.Second\n\t\t}\n\n\t\torigin := *decision.Origin\n\n\t\tif origin == \"lists\" {\n\t\t\torigin = origin + \":\" + *decision.Scenario\n\t\t}\n\n\t\tif ctx.ipsetContentOnly {\n\t\t\tset = ctx.ipsets[\"ipset\"]\n\t\t} else {\n\t\t\tset, ok = ctx.ipsets[origin]\n\n\t\t\tif !ok {\n\t\t\t\tidx := slices.Index(ctx.originSetMapping, origin)\n\n\t\t\t\tif idx == -1 {\n\t\t\t\t\tctx.originSetMapping = append(ctx.originSetMapping, origin)\n\t\t\t\t\tidx = len(ctx.originSetMapping) - 1\n\t\t\t\t}\n\n\t\t\t\tsetName := fmt.Sprintf(\"%s-%d\", ctx.SetName, idx)\n\n\t\t\t\tlog.Infof(\"Using %s as set for origin %s\", setName, origin)\n\n\t\t\t\tset, err = ipsetcmd.NewIPSet(setName)\n\t\t\t\tif err != nil {\n\t\t\t\t\tlog.Errorf(\"error while creating ipset : %s\", err)\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\n\t\t\t\tfamily := \"inet\"\n\n\t\t\t\tif ctx.version == \"v6\" {\n\t\t\t\t\tfamily = \"inet6\"\n\t\t\t\t}\n\n\t\t\t\terr = set.Create(ipsetcmd.CreateOptions{\n\t\t\t\t\tFamily:          family,\n\t\t\t\t\tTimeout:         defaultTimeout,\n\t\t\t\t\tMaxElem:         strconv.Itoa(ctx.SetSize),\n\t\t\t\t\tType:            ctx.SetType,\n\t\t\t\t\tDisableTimeouts: ctx.ipsetDisableTimeouts,\n\t\t\t\t})\n\t\t\t\t// Ignore errors if the set already exists\n\t\t\t\tif err != nil {\n\t\t\t\t\tlog.Errorf(\"error while creating ipset : %s\", err)\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\n\t\t\t\tctx.ipsets[origin] = set\n\n\t\t\t\tif !ctx.ipsetContentOnly {\n\t\t\t\t\t// Create the rule to use the set\n\t\t\t\t\tctx.createRule(set.Name(), origin)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\tvar addCmd string\n\t\tif ctx.ipsetDisableTimeouts {\n\t\t\taddCmd = fmt.Sprintf(\"add %s %s -exist\\n\", set.Name(), *decision.Value)\n\t\t} else {\n\t\t\taddCmd = fmt.Sprintf(\"add %s %s timeout %d -exist\\n\", set.Name(), *decision.Value, int(banDuration.Seconds()))\n\t\t}\n\n\t\tlog.Debugf(\"%s\", addCmd)\n\n\t\t_, err = tmpFile.WriteString(addCmd)\n\t\tif err != nil {\n\t\t\tlog.Errorf(\"error while writing to temp file : %s\", err)\n\t\t\tcontinue\n\t\t}\n\t}\n\n\tif len(ctx.toAdd) == 0 && len(ctx.toDel) == 0 {\n\t\treturn nil\n\t}\n\n\treturn ctx.defaultSet.Restore(tmpFile.Name())\n}\n\nfunc (ctx *ipTablesContext) add(decision *models.Decision) {\n\tctx.toAdd = append(ctx.toAdd, decision)\n}\n\nfunc (ctx *ipTablesContext) shutDown() error {\n\t// Remove rules\n\tif !ctx.ipsetContentOnly {\n\t\tctx.deleteChain()\n\t}\n\n\ttime.Sleep(1 * time.Second)\n\n\t// Clean sets\n\tfor _, set := range ctx.ipsets {\n\t\tif ctx.ipsetContentOnly {\n\t\t\terr := set.Flush()\n\t\t\tif err != nil {\n\t\t\t\tlog.Errorf(\"error while flushing ipset : %s\", err)\n\t\t\t}\n\t\t} else {\n\t\t\terr := set.Destroy()\n\t\t\tif err != nil {\n\t\t\t\tlog.Errorf(\"error while destroying set %s : %s\", set.Name(), err)\n\t\t\t}\n\t\t}\n\t}\n\n\tif !ctx.ipsetContentOnly {\n\t\t// In case we are starting, just reset the map\n\t\tctx.ipsets = make(map[string]*ipsetcmd.IPSet)\n\t}\n\n\treturn nil\n}\n\nfunc (ctx *ipTablesContext) delete(decision *models.Decision) error {\n\tctx.toDel = append(ctx.toDel, decision)\n\treturn nil\n}\n"
  },
  {
    "path": "pkg/iptables/iptables_stub.go",
    "content": "//go:build !linux\n\npackage iptables\n\nimport (\n\t\"github.com/crowdsecurity/cs-firewall-bouncer/pkg/cfg\"\n\t\"github.com/crowdsecurity/cs-firewall-bouncer/pkg/types\"\n)\n\nfunc NewIPTables(config *cfg.BouncerConfig) (types.Backend, error) {\n\treturn nil, nil\n}\n"
  },
  {
    "path": "pkg/iptables/metrics.go",
    "content": "//go:build linux\n\npackage iptables\n\nimport (\n\t\"bufio\"\n\t\"os/exec\"\n\t\"regexp\"\n\t\"strconv\"\n\t\"strings\"\n\n\t\"github.com/prometheus/client_golang/prometheus\"\n\tlog \"github.com/sirupsen/logrus\"\n\n\t\"github.com/crowdsecurity/cs-firewall-bouncer/pkg/metrics\"\n)\n\n// iptables does not provide a \"nice\" way to get the counters for a rule, so we have to parse the output of iptables-save\n// chainRegexp is just used to get the counters for the chain CROWDSEC_CHAIN (the chain managed by the bouncer that will contains our rules) from the JUMP rule\n// ruleRegexp is used to get the counters for the rules we have added that will actually block the traffic\n// Example output of iptables-save :\n// [2080:13210403] -A INPUT -j CROWDSEC_CHAIN\n// ...\n// [0:0] -A CROWDSEC_CHAIN -m set --match-set test-set-ipset-mode-0 src -j DROP\n// First number is the number of packets, second is the number of bytes\n// In case of a jump, the counters represent the number of packets and bytes that have been processed by the chain (ie, whether the packets have been accepted or dropped)\n// In case of a rule, the counters represent the number of packets and bytes that have been matched by the rule (ie, the packets that have been dropped).\n\nvar (\n\tchainRegexp = regexp.MustCompile(`^\\[(\\d+):(\\d+)\\]`)\n\truleRegexp  = regexp.MustCompile(`^\\[(\\d+):(\\d+)\\] -A [0-9A-Za-z_-]+ -m set --match-set (.*) src .*-j \\w+`)\n)\n\n// In ipset mode, we have to track the numbers of processed bytes/packets at the chain level\n// This is not really accurate, as a rule *before* the crowdsec rule could impact the numbers, but we don't have any other way.\n\nvar (\n\tipsetChainDeclaration = regexp.MustCompile(`^:([0-9A-Za-z_-]+) ([0-9A-Za-z_-]+) \\[(\\d+):(\\d+)\\]`)\n\tipsetRule             = regexp.MustCompile(`^\\[(\\d+):(\\d+)\\] -A ([0-9A-Za-z_-]+)`)\n)\n\nfunc (ctx *ipTablesContext) collectMetricsIptables(scanner *bufio.Scanner) (map[string]uint64, map[string]uint64, uint64, uint64) {\n\tprocessedBytes := uint64(0)\n\tprocessedPackets := uint64(0)\n\n\tdroppedBytes := make(map[string]uint64)\n\tdroppedPackets := make(map[string]uint64)\n\n\tfor scanner.Scan() {\n\t\tline := scanner.Text()\n\n\t\tif line == \"\" {\n\t\t\tcontinue\n\t\t}\n\n\t\t// Ignore chain declaration\n\t\tif line[0] == ':' {\n\t\t\tcontinue\n\t\t}\n\n\t\t// Jump to our chain, we can get the processed packets and bytes\n\t\tif strings.Contains(line, \"-j \"+chainName) {\n\t\t\tmatches := chainRegexp.FindStringSubmatch(line)\n\t\t\tif len(matches) != 3 {\n\t\t\t\tlog.Errorf(\"error while parsing counters : %s | not enough matches\", line)\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\tval, err := strconv.ParseUint(matches[1], 10, 64)\n\t\t\tif err != nil {\n\t\t\t\tlog.Errorf(\"error while parsing counters : %s | %s\", line, err)\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\tprocessedPackets += val\n\n\t\t\tval, err = strconv.ParseUint(matches[2], 10, 64)\n\t\t\tif err != nil {\n\t\t\t\tlog.Errorf(\"error while parsing counters : %s | %s\", line, err)\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\tprocessedBytes += val\n\n\t\t\tcontinue\n\t\t}\n\n\t\t// This is a rule\n\t\tif strings.Contains(line, \"-A \"+chainName) {\n\t\t\tmatches := ruleRegexp.FindStringSubmatch(line)\n\t\t\tif len(matches) != 4 {\n\t\t\t\tlog.Errorf(\"error while parsing counters : %s | not enough matches\", line)\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\toriginIDStr, found := strings.CutPrefix(matches[3], ctx.SetName+\"-\")\n\t\t\tif !found {\n\t\t\t\tlog.Errorf(\"error while parsing counters : %s | no origin found\", line)\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\toriginID, err := strconv.Atoi(originIDStr)\n\t\t\tif err != nil {\n\t\t\t\tlog.Errorf(\"error while parsing counters : %s | %s\", line, err)\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\tif len(ctx.originSetMapping) <= originID {\n\t\t\t\tlog.Errorf(\"Found unknown origin id : %d\", originID)\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\torigin := ctx.originSetMapping[originID]\n\n\t\t\tval, err := strconv.ParseUint(matches[1], 10, 64)\n\t\t\tif err != nil {\n\t\t\t\tlog.Errorf(\"error while parsing counters : %s | %s\", line, err)\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\tdroppedPackets[origin] += val\n\n\t\t\tval, err = strconv.ParseUint(matches[2], 10, 64)\n\t\t\tif err != nil {\n\t\t\t\tlog.Errorf(\"error while parsing counters : %s | %s\", line, err)\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\tdroppedBytes[origin] += val\n\t\t}\n\t}\n\n\treturn droppedPackets, droppedBytes, processedPackets, processedBytes\n}\n\ntype chainCounters struct {\n\tbytes   uint64\n\tpackets uint64\n}\n\n// In ipset mode, we only get dropped packets and bytes by matching on the set name in the rule\n// It's probably not perfect, but good enough for most users.\nfunc (ctx *ipTablesContext) collectMetricsIpset(scanner *bufio.Scanner) (map[string]uint64, map[string]uint64, uint64, uint64) {\n\tprocessedBytes := uint64(0)\n\tprocessedPackets := uint64(0)\n\n\tdroppedBytes := make(map[string]uint64)\n\tdroppedPackets := make(map[string]uint64)\n\n\t// We need to store the counters for all chains\n\t// As we don't know in which chain the user has setup the rules\n\t// We'll resolve the value laters.\n\tchainsCounter := make(map[string]chainCounters)\n\n\t// Hardcode the origin to ipset as we cannot know it based on the rule.\n\tdroppedBytes[\"ipset\"] = 0\n\tdroppedPackets[\"ipset\"] = 0\n\n\tfor scanner.Scan() {\n\t\tline := scanner.Text()\n\n\t\tif line == \"\" {\n\t\t\tcontinue\n\t\t}\n\n\t\t// Chain declaration\n\t\tif line[0] == ':' {\n\t\t\tmatches := ipsetChainDeclaration.FindStringSubmatch(line)\n\t\t\tif len(matches) != 5 {\n\t\t\t\tlog.Errorf(\"error while parsing counters : %s | not enough matches\", line)\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\tlog.Debugf(\"Found chain %s with matches %+v\", matches[1], matches)\n\n\t\t\tc, ok := chainsCounter[matches[1]]\n\t\t\tif !ok {\n\t\t\t\tc = chainCounters{}\n\t\t\t}\n\n\t\t\tval, err := strconv.ParseUint(matches[3], 10, 64)\n\t\t\tif err != nil {\n\t\t\t\tlog.Errorf(\"error while parsing counters : %s\", line)\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\tc.packets += val\n\n\t\t\tval, err = strconv.ParseUint(matches[4], 10, 64)\n\t\t\tif err != nil {\n\t\t\t\tlog.Errorf(\"error while parsing counters : %s\", line)\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\tc.bytes += val\n\t\t\tchainsCounter[matches[1]] = c\n\n\t\t\tcontinue\n\t\t}\n\n\t\t// Assume that if a line contains the set name, it's a rule we are interested in.\n\t\tif strings.Contains(line, ctx.SetName) {\n\t\t\tmatches := ipsetRule.FindStringSubmatch(line)\n\t\t\tif len(matches) != 4 {\n\t\t\t\tlog.Errorf(\"error while parsing counters : %s | not enough matches\", line)\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\tval, err := strconv.ParseUint(matches[1], 10, 64)\n\t\t\tif err != nil {\n\t\t\t\tlog.Errorf(\"error while parsing counters : %s\", line)\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\tdroppedPackets[\"ipset\"] += val\n\n\t\t\tval, err = strconv.ParseUint(matches[2], 10, 64)\n\t\t\tif err != nil {\n\t\t\t\tlog.Errorf(\"error while parsing counters : %s\", line)\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\tdroppedBytes[\"ipset\"] += val\n\n\t\t\t// Resolve the chain counters\n\t\t\tc, ok := chainsCounter[matches[3]]\n\t\t\tif !ok {\n\t\t\t\tlog.Errorf(\"error while parsing counters : %s | chain not found\", line)\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\tprocessedPackets += c.packets\n\t\t\tprocessedBytes += c.bytes\n\t\t}\n\t}\n\n\treturn droppedPackets, droppedBytes, processedPackets, processedBytes\n}\n\nfunc (ctx *ipTablesContext) collectMetrics() (map[string]uint64, map[string]uint64, uint64, uint64, error) {\n\t//-c is required to get the counters\n\tcmd := []string{ctx.iptablesSaveBin, \"-c\", \"-t\", \"filter\"}\n\tsaveCmd := exec.Command(cmd[0], cmd[1:]...)\n\n\tout, err := saveCmd.CombinedOutput()\n\tif err != nil {\n\t\tlog.Errorf(\"error while getting iptables rules with cmd %+v : %v --> %s\", cmd, err, string(out))\n\t\treturn nil, nil, 0, 0, err\n\t}\n\n\tvar (\n\t\tprocessedBytes   uint64\n\t\tprocessedPackets uint64\n\t\tdroppedBytes     map[string]uint64\n\t\tdroppedPackets   map[string]uint64\n\t)\n\n\tscanner := bufio.NewScanner(strings.NewReader(string(out)))\n\n\tif !ctx.ipsetContentOnly {\n\t\tdroppedPackets, droppedBytes, processedPackets, processedBytes = ctx.collectMetricsIptables(scanner)\n\t} else {\n\t\tdroppedPackets, droppedBytes, processedPackets, processedBytes = ctx.collectMetricsIpset(scanner)\n\t}\n\n\tlog.Debugf(\"Processed %d packets and %d bytes\", processedPackets, processedBytes)\n\tlog.Debugf(\"Dropped packets : %v\", droppedPackets)\n\tlog.Debugf(\"Dropped bytes : %v\", droppedBytes)\n\n\treturn droppedPackets, droppedBytes, processedPackets, processedBytes, nil\n}\n\nfunc (ipt *iptables) CollectMetrics() {\n\tif ipt.v4 != nil {\n\t\tfor origin, set := range ipt.v4.ipsets {\n\t\t\tmetrics.Map[metrics.ActiveBannedIPs].Gauge.With(prometheus.Labels{\"ip_type\": \"ipv4\", \"origin\": origin}).Set(float64(set.Len()))\n\t\t}\n\n\t\tipv4DroppedPackets, ipv4DroppedBytes, ipv4ProcessedPackets, ipv4ProcessedBytes, err := ipt.v4.collectMetrics()\n\t\tif err != nil {\n\t\t\tlog.Errorf(\"can't collect dropped packets for ipv4 from iptables: %s\", err)\n\t\t} else {\n\t\t\tmetrics.Map[metrics.ProcessedPackets].Gauge.With(prometheus.Labels{\"ip_type\": \"ipv4\"}).Set(float64(ipv4ProcessedPackets))\n\t\t\tmetrics.Map[metrics.ProcessedBytes].Gauge.With(prometheus.Labels{\"ip_type\": \"ipv4\"}).Set(float64(ipv4ProcessedBytes))\n\n\t\t\tfor origin, count := range ipv4DroppedPackets {\n\t\t\t\tmetrics.Map[metrics.DroppedPackets].Gauge.With(prometheus.Labels{\"ip_type\": \"ipv4\", \"origin\": origin}).Set(float64(count))\n\t\t\t}\n\n\t\t\tfor origin, count := range ipv4DroppedBytes {\n\t\t\t\tmetrics.Map[metrics.DroppedBytes].Gauge.With(prometheus.Labels{\"ip_type\": \"ipv4\", \"origin\": origin}).Set(float64(count))\n\t\t\t}\n\t\t}\n\t}\n\n\tif ipt.v6 != nil {\n\t\tfor origin, set := range ipt.v6.ipsets {\n\t\t\tmetrics.Map[metrics.ActiveBannedIPs].Gauge.With(prometheus.Labels{\"ip_type\": \"ipv6\", \"origin\": origin}).Set(float64(set.Len()))\n\t\t}\n\n\t\tipv6DroppedPackets, ipv6DroppedBytes, ipv6ProcessedPackets, ipv6ProcessedBytes, err := ipt.v6.collectMetrics()\n\t\tif err != nil {\n\t\t\tlog.Errorf(\"can't collect dropped packets for ipv6 from iptables: %s\", err)\n\t\t} else {\n\t\t\tmetrics.Map[metrics.ProcessedPackets].Gauge.With(prometheus.Labels{\"ip_type\": \"ipv6\"}).Set(float64(ipv6ProcessedPackets))\n\t\t\tmetrics.Map[metrics.ProcessedBytes].Gauge.With(prometheus.Labels{\"ip_type\": \"ipv6\"}).Set(float64(ipv6ProcessedBytes))\n\n\t\t\tfor origin, count := range ipv6DroppedPackets {\n\t\t\t\tmetrics.Map[metrics.DroppedPackets].Gauge.With(prometheus.Labels{\"ip_type\": \"ipv6\", \"origin\": origin}).Set(float64(count))\n\t\t\t}\n\n\t\t\tfor origin, count := range ipv6DroppedBytes {\n\t\t\t\tmetrics.Map[metrics.DroppedBytes].Gauge.With(prometheus.Labels{\"ip_type\": \"ipv6\", \"origin\": origin}).Set(float64(count))\n\t\t\t}\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "pkg/metrics/metrics.go",
    "content": "package metrics\n\nimport (\n\t\"net/http\"\n\t\"time\"\n\n\t\"github.com/prometheus/client_golang/prometheus\"\n\tio_prometheus_client \"github.com/prometheus/client_model/go\"\n\tlog \"github.com/sirupsen/logrus\"\n\n\t\"github.com/crowdsecurity/go-cs-lib/ptr\"\n\n\t\"github.com/crowdsecurity/crowdsec/pkg/models\"\n)\n\nconst CollectionInterval = time.Second * 10\n\ntype metricName string\n\nconst (\n\tDroppedPackets   metricName = \"fw_bouncer_dropped_packets\"\n\tDroppedBytes     metricName = \"fw_bouncer_dropped_bytes\"\n\tProcessedPackets metricName = \"fw_bouncer_processed_packets\"\n\tProcessedBytes   metricName = \"fw_bouncer_processed_bytes\"\n\tActiveBannedIPs  metricName = \"fw_bouncer_banned_ips\"\n)\n\ntype backendCollector interface {\n\tCollectMetrics()\n}\n\ntype Handler struct {\n\tBackend backendCollector\n}\n\ntype metricConfig struct {\n\tName         string\n\tUnit         string\n\tGauge        *prometheus.GaugeVec\n\tLabelKeys    []string\n\tLastValueMap map[string]float64 // keep last value to send deltas -- nil if absolute\n\tKeyFunc      func(labels []*io_prometheus_client.LabelPair) string\n}\n\ntype metricMap map[metricName]*metricConfig\n\nfunc (m metricMap) MustRegisterAll() {\n\tfor _, met := range m {\n\t\tprometheus.MustRegister(met.Gauge)\n\t}\n}\n\nvar Map = metricMap{\n\tActiveBannedIPs: {\n\t\tName: \"active_decisions\",\n\t\tUnit: \"ip\",\n\t\tGauge: prometheus.NewGaugeVec(prometheus.GaugeOpts{\n\t\t\tName: string(ActiveBannedIPs),\n\t\t\tHelp: \"Denotes the number of IPs which are currently banned\",\n\t\t}, []string{\"origin\", \"ip_type\"}),\n\t\tLabelKeys:    []string{\"origin\", \"ip_type\"},\n\t\tLastValueMap: nil,\n\t\tKeyFunc:      func([]*io_prometheus_client.LabelPair) string { return \"\" },\n\t},\n\tDroppedBytes: {\n\t\tName: \"dropped\",\n\t\tUnit: \"byte\",\n\t\tGauge: prometheus.NewGaugeVec(prometheus.GaugeOpts{\n\t\t\tName: string(DroppedBytes),\n\t\t\tHelp: \"Denotes the number of total dropped bytes because of rule(s) created by crowdsec\",\n\t\t}, []string{\"origin\", \"ip_type\"}),\n\t\tLabelKeys:    []string{\"origin\", \"ip_type\"},\n\t\tLastValueMap: make(map[string]float64),\n\t\tKeyFunc: func(labels []*io_prometheus_client.LabelPair) string {\n\t\t\treturn getLabelValue(labels, \"origin\") + getLabelValue(labels, \"ip_type\")\n\t\t},\n\t},\n\tDroppedPackets: {\n\t\tName: \"dropped\",\n\t\tUnit: \"packet\",\n\t\tGauge: prometheus.NewGaugeVec(prometheus.GaugeOpts{\n\t\t\tName: string(DroppedPackets),\n\t\t\tHelp: \"Denotes the number of total dropped packets because of rule(s) created by crowdsec\",\n\t\t}, []string{\"origin\", \"ip_type\"}),\n\t\tLabelKeys:    []string{\"origin\", \"ip_type\"},\n\t\tLastValueMap: make(map[string]float64),\n\t\tKeyFunc: func(labels []*io_prometheus_client.LabelPair) string {\n\t\t\treturn getLabelValue(labels, \"origin\") + getLabelValue(labels, \"ip_type\")\n\t\t},\n\t},\n\tProcessedBytes: {\n\t\tName: \"processed\",\n\t\tUnit: \"byte\",\n\t\tGauge: prometheus.NewGaugeVec(prometheus.GaugeOpts{\n\t\t\tName: string(ProcessedBytes),\n\t\t\tHelp: \"Denotes the number of total processed bytes by the rules created by crowdsec\",\n\t\t}, []string{\"ip_type\"}),\n\t\tLabelKeys:    []string{\"ip_type\"},\n\t\tLastValueMap: make(map[string]float64),\n\t\tKeyFunc: func(labels []*io_prometheus_client.LabelPair) string {\n\t\t\treturn getLabelValue(labels, \"ip_type\")\n\t\t},\n\t},\n\tProcessedPackets: {\n\t\tName: \"processed\",\n\t\tUnit: \"packet\",\n\t\tGauge: prometheus.NewGaugeVec(prometheus.GaugeOpts{\n\t\t\tName: string(ProcessedPackets),\n\t\t\tHelp: \"Denotes the number of total processed packets by the rules created by crowdsec\",\n\t\t}, []string{\"ip_type\"}),\n\t\tLabelKeys:    []string{\"ip_type\"},\n\t\tLastValueMap: make(map[string]float64),\n\t\tKeyFunc: func(labels []*io_prometheus_client.LabelPair) string {\n\t\t\treturn getLabelValue(labels, \"ip_type\")\n\t\t},\n\t},\n}\n\nfunc getLabelValue(labels []*io_prometheus_client.LabelPair, key string) string {\n\tfor _, label := range labels {\n\t\tif label.GetName() == key {\n\t\t\treturn label.GetValue()\n\t\t}\n\t}\n\n\treturn \"\"\n}\n\n// MetricsUpdater receives a metrics struct with basic data and populates it with the current metrics.\nfunc (m Handler) MetricsUpdater(met *models.RemediationComponentsMetrics, updateInterval time.Duration) {\n\tlog.Debugf(\"Updating metrics\")\n\n\tm.Backend.CollectMetrics()\n\n\t// Most of the common fields are set automatically by the metrics provider\n\t// We only need to care about the metrics themselves\n\n\tpromMetrics, err := prometheus.DefaultGatherer.Gather()\n\tif err != nil {\n\t\tlog.Errorf(\"unable to gather prometheus metrics: %s\", err)\n\t\treturn\n\t}\n\n\tmet.Metrics = append(met.Metrics, &models.DetailedMetrics{\n\t\tMeta: &models.MetricsMeta{\n\t\t\tUtcNowTimestamp:   ptr.Of(time.Now().Unix()),\n\t\t\tWindowSizeSeconds: ptr.Of(int64(updateInterval.Seconds())),\n\t\t},\n\t\tItems: make([]*models.MetricsDetailItem, 0),\n\t})\n\n\tfor _, metricFamily := range promMetrics {\n\t\tcfg, ok := Map[metricName(metricFamily.GetName())]\n\t\tif !ok {\n\t\t\tcontinue\n\t\t}\n\n\t\tfor _, metric := range metricFamily.GetMetric() {\n\t\t\tlabels := metric.GetLabel()\n\t\t\tvalue := metric.GetGauge().GetValue()\n\n\t\t\tlabelMap := make(map[string]string)\n\t\t\tfor _, key := range cfg.LabelKeys {\n\t\t\t\tlabelMap[key] = getLabelValue(labels, key)\n\t\t\t}\n\n\t\t\tfinalValue := value\n\n\t\t\tif cfg.LastValueMap == nil {\n\t\t\t\t// always send absolute values\n\t\t\t\tlog.Debugf(\"Sending %s for %+v %f\", cfg.Name, labelMap, finalValue)\n\t\t\t} else {\n\t\t\t\t// the final value to send must be relative, and never negative\n\t\t\t\t// because the firewall counter may have been reset since last collection.\n\t\t\t\tkey := cfg.KeyFunc(labels)\n\n\t\t\t\t// no need to guard access to LastValueMap, as we are in the main thread -- it's\n\t\t\t\t// the gauge that is updated by the requests\n\t\t\t\tfinalValue = value - cfg.LastValueMap[key]\n\n\t\t\t\tif finalValue < 0 {\n\t\t\t\t\tfinalValue = -finalValue\n\n\t\t\t\t\tlog.Warningf(\"metric value for %s %+v is negative, assuming external counter was reset\", cfg.Name, labelMap)\n\t\t\t\t}\n\n\t\t\t\tcfg.LastValueMap[key] = value\n\t\t\t\tlog.Debugf(\"Sending %s for %+v %f | current value: %f | previous value: %f\", cfg.Name, labelMap, finalValue, value, cfg.LastValueMap[key])\n\t\t\t}\n\n\t\t\tmet.Metrics[0].Items = append(met.Metrics[0].Items, &models.MetricsDetailItem{\n\t\t\t\tName:   ptr.Of(cfg.Name),\n\t\t\t\tValue:  &finalValue,\n\t\t\t\tLabels: labelMap,\n\t\t\t\tUnit:   ptr.Of(cfg.Unit),\n\t\t\t})\n\t\t}\n\t}\n}\n\nfunc (m Handler) ComputeMetricsHandler(next http.Handler) http.Handler {\n\treturn http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tm.Backend.CollectMetrics()\n\t\tnext.ServeHTTP(w, r)\n\t})\n}\n"
  },
  {
    "path": "pkg/nftables/metrics.go",
    "content": "//go:build linux\n\npackage nftables\n\nimport (\n\t\"fmt\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/google/nftables\"\n\t\"github.com/google/nftables/expr\"\n\t\"github.com/prometheus/client_golang/prometheus\"\n\tlog \"github.com/sirupsen/logrus\"\n\n\t\"github.com/crowdsecurity/cs-firewall-bouncer/pkg/metrics\"\n)\n\nfunc (c *nftContext) collectDroppedPackets() (map[string]uint64, map[string]uint64, uint64, uint64, error) {\n\tdroppedPackets := make(map[string]uint64)\n\tdroppedBytes := make(map[string]uint64)\n\tprocessedPackets := uint64(0)\n\tprocessedBytes := uint64(0)\n\n\tobjs, err := c.conn.GetNamedObjects(c.table)\n\tif err != nil {\n\t\treturn nil, nil, 0, 0, fmt.Errorf(\"can't get named objects for table %s: %w\", c.table.Name, err)\n\t}\n\n\tfor _, obj := range objs {\n\t\to, ok := obj.(*nftables.NamedObj)\n\t\tif !ok {\n\t\t\tcontinue\n\t\t}\n\n\t\tif o.Type != nftables.ObjTypeCounter {\n\t\t\tcontinue\n\t\t}\n\n\t\tcounterObj, ok := o.Obj.(*expr.Counter)\n\t\tif !ok {\n\t\t\tcontinue\n\t\t}\n\n\t\tif o.Name == \"processed\" {\n\t\t\tprocessedPackets = counterObj.Packets\n\t\t\tprocessedBytes = counterObj.Bytes\n\n\t\t\tcontinue\n\t\t}\n\n\t\torigin, found := strings.CutPrefix(o.Name, c.blacklists+\"-\")\n\t\tif !found || origin == \"\" {\n\t\t\tcontinue\n\t\t}\n\n\t\tdroppedPackets[origin] += counterObj.Packets\n\t\tdroppedBytes[origin] += counterObj.Bytes\n\t}\n\n\treturn droppedPackets, droppedBytes, processedPackets, processedBytes, nil\n}\n\nfunc (c *nftContext) collectActiveBannedIPs() (map[string]int, error) {\n\t// Find the size of the set we have created\n\tret := make(map[string]int)\n\n\tfor origin, set := range c.sets {\n\t\tsetContent, err := c.conn.GetSetElements(set)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"can't get set elements for %s: %w\", set.Name, err)\n\t\t}\n\n\t\tif c.setOnly {\n\t\t\tret[c.blacklists] = len(setContent)\n\t\t} else {\n\t\t\tret[origin] = len(setContent)\n\t\t}\n\n\t\treturn ret, nil\n\t}\n\n\treturn ret, nil\n}\n\nfunc (c *nftContext) collectDropped() (map[string]uint64, map[string]uint64, uint64, uint64, map[string]int) {\n\tif c.conn == nil {\n\t\treturn nil, nil, 0, 0, nil\n\t}\n\n\tdroppedPackets, droppedBytes, processedPackets, processedBytes, err := c.collectDroppedPackets()\n\tif err != nil {\n\t\tlog.Errorf(\"can't collect dropped packets for ip%s from nft: %s\", c.version, err)\n\t}\n\n\tbanned, err := c.collectActiveBannedIPs()\n\tif err != nil {\n\t\tlog.Errorf(\"can't collect total banned IPs for ip%s from nft: %s\", c.version, err)\n\t}\n\n\treturn droppedPackets, droppedBytes, processedPackets, processedBytes, banned\n}\n\nfunc getOriginForList(origin string) string {\n\tif !strings.HasPrefix(origin, \"lists-\") {\n\t\treturn origin\n\t}\n\n\treturn strings.Replace(origin, \"-\", \":\", 1)\n}\n\nfunc (n *nft) CollectMetrics() {\n\tstartTime := time.Now()\n\tip4DroppedPackets, ip4DroppedBytes, ip4ProcessedPackets, ip4ProcessedBytes, bannedIP4 := n.v4.collectDropped()\n\tip6DroppedPackets, ip6DroppedBytes, ip6ProcessedPackets, ip6ProcessedBytes, bannedIP6 := n.v6.collectDropped()\n\n\tlog.Debugf(\"metrics collection took %s\", time.Since(startTime))\n\tlog.Debugf(\"ip4: dropped packets: %+v, dropped bytes: %+v, banned IPs: %+v, proccessed packets: %d, processed bytes: %d\", ip4DroppedPackets, ip4DroppedBytes, bannedIP4, ip4ProcessedPackets, ip4ProcessedBytes)\n\tlog.Debugf(\"ip6: dropped packets: %+v, dropped bytes: %+v, banned IPs: %+v, proccessed packets: %d, processed bytes: %d\", ip6DroppedPackets, ip6DroppedBytes, bannedIP6, ip6ProcessedPackets, ip6ProcessedBytes)\n\n\tmetrics.Map[metrics.ProcessedPackets].Gauge.With(prometheus.Labels{\"ip_type\": \"ipv4\"}).Set(float64(ip4ProcessedPackets))\n\tmetrics.Map[metrics.ProcessedBytes].Gauge.With(prometheus.Labels{\"ip_type\": \"ipv4\"}).Set(float64(ip4ProcessedBytes))\n\n\tmetrics.Map[metrics.ProcessedPackets].Gauge.With(prometheus.Labels{\"ip_type\": \"ipv6\"}).Set(float64(ip6ProcessedPackets))\n\tmetrics.Map[metrics.ProcessedBytes].Gauge.With(prometheus.Labels{\"ip_type\": \"ipv6\"}).Set(float64(ip6ProcessedBytes))\n\n\tfor origin, count := range bannedIP4 {\n\t\torigin = getOriginForList(origin)\n\t\tmetrics.Map[metrics.ActiveBannedIPs].Gauge.With(prometheus.Labels{\"origin\": origin, \"ip_type\": \"ipv4\"}).Set(float64(count))\n\t}\n\n\tfor origin, count := range bannedIP6 {\n\t\torigin = getOriginForList(origin)\n\t\tmetrics.Map[metrics.ActiveBannedIPs].Gauge.With(prometheus.Labels{\"origin\": origin, \"ip_type\": \"ipv6\"}).Set(float64(count))\n\t}\n\n\tfor origin, count := range ip4DroppedPackets {\n\t\torigin = getOriginForList(origin)\n\t\tmetrics.Map[metrics.DroppedPackets].Gauge.With(prometheus.Labels{\"origin\": origin, \"ip_type\": \"ipv4\"}).Set(float64(count))\n\t}\n\n\tfor origin, count := range ip6DroppedPackets {\n\t\torigin = getOriginForList(origin)\n\t\tmetrics.Map[metrics.DroppedPackets].Gauge.With(prometheus.Labels{\"origin\": origin, \"ip_type\": \"ipv6\"}).Set(float64(count))\n\t}\n\n\tfor origin, count := range ip4DroppedBytes {\n\t\torigin = getOriginForList(origin)\n\t\tmetrics.Map[metrics.DroppedBytes].Gauge.With(prometheus.Labels{\"origin\": origin, \"ip_type\": \"ipv4\"}).Set(float64(count))\n\t}\n\n\tfor origin, count := range ip6DroppedBytes {\n\t\torigin = getOriginForList(origin)\n\t\tmetrics.Map[metrics.DroppedBytes].Gauge.With(prometheus.Labels{\"origin\": origin, \"ip_type\": \"ipv6\"}).Set(float64(count))\n\t}\n}\n"
  },
  {
    "path": "pkg/nftables/nftables.go",
    "content": "//go:build linux\n\npackage nftables\n\nimport (\n\t\"fmt\"\n\t\"net\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/google/nftables\"\n\t\"github.com/google/nftables/binaryutil\"\n\tlog \"github.com/sirupsen/logrus\"\n\n\t\"github.com/crowdsecurity/crowdsec/pkg/models\"\n\n\t\"github.com/crowdsecurity/cs-firewall-bouncer/pkg/cfg\"\n)\n\nconst (\n\tchunkSize      = 200\n\tdefaultTimeout = \"4h\"\n)\n\ntype nft struct {\n\tv4                *nftContext\n\tv6                *nftContext\n\tdecisionsToAdd    []*models.Decision\n\tdecisionsToDelete []*models.Decision\n\tDenyAction        string\n\tDenyLog           bool\n\tDenyLogPrefix     string\n\tHooks             []string\n}\n\nfunc NewNFTables(config *cfg.BouncerConfig) (*nft, error) {\n\tret := &nft{\n\t\tv4:            NewNFTV4Context(config),\n\t\tv6:            NewNFTV6Context(config),\n\t\tDenyAction:    config.DenyAction,\n\t\tDenyLog:       config.DenyLog,\n\t\tDenyLogPrefix: config.DenyLogPrefix,\n\t\tHooks:         config.NftablesHooks,\n\t}\n\n\treturn ret, nil\n}\n\nfunc (n *nft) Init() error {\n\tlog.Debug(\"nftables: Init()\")\n\n\tif err := n.v4.init(n.Hooks); err != nil {\n\t\treturn err\n\t}\n\n\tif err := n.v6.init(n.Hooks); err != nil {\n\t\treturn err\n\t}\n\n\tlog.Infof(\"nftables initiated\")\n\n\treturn nil\n}\n\nfunc (n *nft) Add(decision *models.Decision) error {\n\tn.decisionsToAdd = append(n.decisionsToAdd, decision)\n\treturn nil\n}\n\nfunc (n *nft) getBannedState() (map[string]struct{}, error) {\n\tbanned := make(map[string]struct{})\n\tif err := n.v4.setBanned(banned); err != nil {\n\t\treturn nil, err\n\t}\n\n\tif err := n.v6.setBanned(banned); err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn banned, nil\n}\n\nfunc (n *nft) reset() {\n\tn.decisionsToAdd = make([]*models.Decision, 0)\n\tn.decisionsToDelete = make([]*models.Decision, 0)\n}\n\nfunc (n *nft) commitDeletedDecisions() error {\n\tbanned, err := n.getBannedState()\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to get current state: %w\", err)\n\t}\n\n\tip4 := []nftables.SetElement{}\n\tip6 := []nftables.SetElement{}\n\n\tn.decisionsToDelete = normalizedDecisions(n.decisionsToDelete)\n\n\tfor _, decision := range n.decisionsToDelete {\n\t\tip := net.ParseIP(*decision.Value)\n\t\tif _, ok := banned[ip.String()]; !ok {\n\t\t\tlog.Debugf(\"not deleting %s since it's not in the set\", ip)\n\t\t\tcontinue\n\t\t}\n\n\t\tif strings.Contains(ip.String(), \":\") {\n\t\t\tif n.v6.conn != nil {\n\t\t\t\tlog.Tracef(\"adding %s to buffer\", ip)\n\n\t\t\t\tip6 = append(ip6, nftables.SetElement{Key: ip.To16()})\n\t\t\t}\n\n\t\t\tcontinue\n\t\t}\n\n\t\tif n.v4.conn != nil {\n\t\t\tlog.Tracef(\"adding %s to buffer\", ip)\n\n\t\t\tip4 = append(ip4, nftables.SetElement{Key: ip.To4()})\n\t\t}\n\t}\n\n\tif len(ip4) > 0 {\n\t\tlog.Debugf(\"removing %d ip%s elements from set\", len(ip4), n.v4.version)\n\n\t\tif err := n.v4.deleteElements(ip4); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tif len(ip6) > 0 {\n\t\tlog.Debugf(\"removing %d ip%s elements from set\", len(ip6), n.v6.version)\n\n\t\tif err := n.v6.deleteElements(ip6); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc (n *nft) createSetAndRuleForOrigin(ctx *nftContext, origin string) error {\n\tif _, ok := ctx.sets[origin]; !ok {\n\t\t// First time we see this origin, create the rule/set for all hooks\n\t\tset := &nftables.Set{\n\t\t\tName:         fmt.Sprintf(\"%s-%s\", ctx.blacklists, origin),\n\t\t\tTable:        ctx.table,\n\t\t\tKeyType:      ctx.typeIPAddr,\n\t\t\tKeyByteOrder: binaryutil.BigEndian,\n\t\t\tHasTimeout:   true,\n\t\t}\n\n\t\tctx.sets[origin] = set\n\n\t\tif err := ctx.conn.AddSet(set, []nftables.SetElement{}); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tfor _, chain := range ctx.chains {\n\t\t\trule, err := ctx.createRule(chain, set, n.DenyLog, n.DenyLogPrefix, n.DenyAction)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\tctx.conn.AddRule(rule)\n\t\t\tlog.Infof(\"Created set and rule for origin %s and type %s in chain %s\", origin, ctx.typeIPAddr.Name, chain.Name)\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc (n *nft) commitAddedDecisions() error {\n\tbanned, err := n.getBannedState()\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to get current state: %w\", err)\n\t}\n\n\tip4 := make(map[string][]nftables.SetElement, 0)\n\tip6 := make(map[string][]nftables.SetElement, 0)\n\n\tn.decisionsToAdd = normalizedDecisions(n.decisionsToAdd)\n\n\tfor _, decision := range n.decisionsToAdd {\n\t\tip := net.ParseIP(*decision.Value)\n\t\tif _, ok := banned[ip.String()]; ok {\n\t\t\tlog.Debugf(\"not adding %s since it's already in the set\", ip)\n\t\t\tcontinue\n\t\t}\n\n\t\tt, _ := time.ParseDuration(*decision.Duration)\n\n\t\torigin := *decision.Origin\n\n\t\tif origin == \"lists\" {\n\t\t\torigin = origin + \"-\" + *decision.Scenario\n\t\t}\n\n\t\tif strings.Contains(ip.String(), \":\") {\n\t\t\tif n.v6.conn != nil {\n\t\t\t\tif n.v6.setOnly {\n\t\t\t\t\torigin = n.v6.blacklists\n\t\t\t\t}\n\n\t\t\t\tlog.Tracef(\"adding %s to buffer\", ip)\n\n\t\t\t\tif _, ok := ip6[origin]; !ok {\n\t\t\t\t\tip6[origin] = make([]nftables.SetElement, 0)\n\t\t\t\t}\n\n\t\t\t\tip6[origin] = append(ip6[origin], nftables.SetElement{Timeout: t, Key: ip.To16()})\n\n\t\t\t\tif !n.v6.setOnly {\n\t\t\t\t\terr := n.createSetAndRuleForOrigin(n.v6, origin)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn err\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tcontinue\n\t\t}\n\n\t\tif n.v4.conn != nil {\n\t\t\tif n.v4.setOnly {\n\t\t\t\torigin = n.v4.blacklists\n\t\t\t}\n\n\t\t\tlog.Tracef(\"adding %s to buffer\", ip)\n\n\t\t\tif _, ok := ip4[origin]; !ok {\n\t\t\t\tip4[origin] = make([]nftables.SetElement, 0)\n\t\t\t}\n\n\t\t\tip4[origin] = append(ip4[origin], nftables.SetElement{Timeout: t, Key: ip.To4()})\n\n\t\t\tif !n.v4.setOnly {\n\t\t\t\terr := n.createSetAndRuleForOrigin(n.v4, origin)\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\tif err := n.v4.addElements(ip4); err != nil {\n\t\treturn err\n\t}\n\n\treturn n.v6.addElements(ip6)\n}\n\nfunc (n *nft) Commit() error {\n\tdefer n.reset()\n\n\tif err := n.commitDeletedDecisions(); err != nil {\n\t\treturn err\n\t}\n\n\treturn n.commitAddedDecisions()\n}\n\ntype tmpDecisions struct {\n\tduration time.Duration\n\torigin   string\n\tscenario string\n}\n\n// remove duplicates, normalize decision timeouts, keep the longest decision when dups are present.\nfunc normalizedDecisions(decisions []*models.Decision) []*models.Decision {\n\tvals := make(map[string]tmpDecisions)\n\tfinalDecisions := make([]*models.Decision, 0)\n\n\tfor _, d := range decisions {\n\t\tt, err := time.ParseDuration(*d.Duration)\n\t\tif err != nil {\n\t\t\tt, _ = time.ParseDuration(defaultTimeout)\n\t\t}\n\n\t\t*d.Value = strings.Split(*d.Value, \"/\")[0]\n\t\tif longest, ok := vals[*d.Value]; !ok || t > longest.duration {\n\t\t\tvals[*d.Value] = tmpDecisions{\n\t\t\t\tduration: t,\n\t\t\t\torigin:   *d.Origin,\n\t\t\t\tscenario: *d.Scenario,\n\t\t\t}\n\t\t}\n\t}\n\n\tfor ip, decision := range vals {\n\t\td := decision.duration.String()\n\t\ti := ip // copy it because we don't same value for all decisions as `ip` is same pointer :)\n\t\torigin := decision.origin\n\t\tscenario := decision.scenario\n\n\t\tfinalDecisions = append(finalDecisions, &models.Decision{\n\t\t\tDuration: &d,\n\t\t\tValue:    &i,\n\t\t\tOrigin:   &origin,\n\t\t\tScenario: &scenario,\n\t\t})\n\t}\n\n\treturn finalDecisions\n}\n\nfunc (n *nft) Delete(decision *models.Decision) error {\n\tn.decisionsToDelete = append(n.decisionsToDelete, decision)\n\treturn nil\n}\n\nfunc (n *nft) ShutDown() error {\n\tif err := n.v4.shutDown(); err != nil {\n\t\treturn err\n\t}\n\n\treturn n.v6.shutDown()\n}\n"
  },
  {
    "path": "pkg/nftables/nftables_context.go",
    "content": "//go:build linux\n\npackage nftables\n\nimport (\n\t\"fmt\"\n\t\"net\"\n\t\"strings\"\n\n\t\"github.com/google/nftables\"\n\t\"github.com/google/nftables/binaryutil\"\n\t\"github.com/google/nftables/expr\"\n\tlog \"github.com/sirupsen/logrus\"\n\t\"golang.org/x/sys/unix\"\n\n\t\"github.com/crowdsecurity/go-cs-lib/slicetools\"\n\n\t\"github.com/crowdsecurity/cs-firewall-bouncer/pkg/cfg\"\n)\n\nvar HookNameToHookID = map[string]nftables.ChainHook{\n\t\"prerouting\":  *nftables.ChainHookPrerouting,\n\t\"input\":       *nftables.ChainHookInput,\n\t\"forward\":     *nftables.ChainHookForward,\n\t\"output\":      *nftables.ChainHookOutput,\n\t\"postrouting\": *nftables.ChainHookPostrouting,\n\t\"ingress\":     *nftables.ChainHookIngress,\n}\n\ntype nftContext struct {\n\tchains        map[string]*nftables.Chain\n\tconn          *nftables.Conn\n\tsets          map[string]*nftables.Set\n\ttable         *nftables.Table\n\ttableFamily   nftables.TableFamily\n\ttypeIPAddr    nftables.SetDatatype\n\tversion       string\n\tpayloadOffset uint32\n\tpayloadLength uint32\n\tpriority      int\n\tblacklists    string\n\tchainName     string\n\ttableName     string\n\tsetOnly       bool\n}\n\n// convert a binary representation of an IP (4 or 16 bytes) to a string.\nfunc reprIP(ip []byte) string {\n\treturn net.IP(ip).String()\n}\n\nfunc NewNFTV4Context(config *cfg.BouncerConfig) *nftContext {\n\tif !*config.Nftables.Ipv4.Enabled {\n\t\tlog.Debug(\"nftables: ipv4 disabled\")\n\n\t\treturn &nftContext{}\n\t}\n\n\tlog.Debug(\"nftables: ipv4 enabled\")\n\n\tret := &nftContext{\n\t\tconn:          &nftables.Conn{},\n\t\tversion:       \"v4\",\n\t\ttableFamily:   nftables.TableFamilyIPv4,\n\t\ttypeIPAddr:    nftables.TypeIPAddr,\n\t\tpayloadOffset: 12,\n\t\tpayloadLength: 4,\n\t\ttableName:     config.Nftables.Ipv4.Table,\n\t\tchainName:     config.Nftables.Ipv4.Chain,\n\t\tblacklists:    config.BlacklistsIpv4,\n\t\tsetOnly:       config.Nftables.Ipv4.SetOnly,\n\t\tpriority:      config.Nftables.Ipv4.Priority,\n\t\tsets:          make(map[string]*nftables.Set),\n\t}\n\n\tlog.Debugf(\"nftables: ipv4: %t, table: %s, chain: %s, blacklist: %s, set-only: %t\",\n\t\t*config.Nftables.Ipv4.Enabled, ret.tableName, ret.chainName, ret.blacklists, ret.setOnly)\n\n\treturn ret\n}\n\nfunc NewNFTV6Context(config *cfg.BouncerConfig) *nftContext {\n\tif !*config.Nftables.Ipv6.Enabled {\n\t\tlog.Debug(\"nftables: ipv6 disabled\")\n\n\t\treturn &nftContext{}\n\t}\n\n\tlog.Debug(\"nftables: ipv6 enabled\")\n\n\tret := &nftContext{\n\t\tconn:          &nftables.Conn{},\n\t\tversion:       \"v6\",\n\t\ttableFamily:   nftables.TableFamilyIPv6,\n\t\ttypeIPAddr:    nftables.TypeIP6Addr,\n\t\tpayloadOffset: 8,\n\t\tpayloadLength: 16,\n\t\ttableName:     config.Nftables.Ipv6.Table,\n\t\tchainName:     config.Nftables.Ipv6.Chain,\n\t\tblacklists:    config.BlacklistsIpv6,\n\t\tsetOnly:       config.Nftables.Ipv6.SetOnly,\n\t\tpriority:      config.Nftables.Ipv6.Priority,\n\t\tsets:          make(map[string]*nftables.Set),\n\t}\n\n\tlog.Debugf(\"nftables: ipv6: %t, table6: %s, chain6: %s, blacklist: %s, set-only6: %t\",\n\t\t*config.Nftables.Ipv6.Enabled, ret.tableName, ret.chainName, ret.blacklists, ret.setOnly)\n\n\treturn ret\n}\n\n// setBanned retrieves the list of banned IPs from the nftables set and adds them to the banned map.\nfunc (c *nftContext) setBanned(banned map[string]struct{}) error {\n\tif c.conn == nil {\n\t\treturn nil\n\t}\n\n\tfor _, set := range c.sets {\n\t\telements, err := c.conn.GetSetElements(set)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tfor i := range elements {\n\t\t\tbanned[net.IP(elements[i].Key).String()] = struct{}{}\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc (c *nftContext) initSetOnly() error {\n\tvar err error\n\n\t// Use existing nftables configuration\n\tlog.Debugf(\"nftables: ip%s set-only\", c.version)\n\n\tc.table, err = c.lookupTable()\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tset, err := c.conn.GetSetByName(c.table, c.blacklists)\n\tif err != nil {\n\t\tlog.Debugf(\"nftables: could not find ip%s blacklist '%s' in table '%s': creating...\", c.version, c.blacklists, c.tableName)\n\n\t\tset = &nftables.Set{\n\t\t\tName:         c.blacklists,\n\t\t\tTable:        c.table,\n\t\t\tKeyType:      c.typeIPAddr,\n\t\t\tKeyByteOrder: binaryutil.BigEndian,\n\t\t\tHasTimeout:   true,\n\t\t}\n\n\t\tif err := c.conn.AddSet(set, []nftables.SetElement{}); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tif err := c.conn.Flush(); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tc.sets[c.blacklists] = set\n\tlog.Debugf(\"nftables: ip%s set '%s' configured\", c.version, c.blacklists)\n\n\treturn nil\n}\n\nfunc (c *nftContext) initOwnTable(hooks []string) error {\n\tlog.Debugf(\"nftables: ip%s own table\", c.version)\n\n\tc.table = c.conn.AddTable(&nftables.Table{\n\t\tFamily: c.tableFamily,\n\t\tName:   c.tableName,\n\t})\n\n\tfor _, hook := range hooks {\n\t\thooknum := HookNameToHookID[hook]\n\t\tpriority := nftables.ChainPriority(c.priority)\n\t\tchain := c.conn.AddChain(&nftables.Chain{\n\t\t\tName:     c.chainName + \"-\" + hook,\n\t\t\tTable:    c.table,\n\t\t\tType:     nftables.ChainTypeFilter,\n\t\t\tHooknum:  &hooknum,\n\t\t\tPriority: &priority,\n\t\t})\n\n\t\tnamedCounter := nftables.NamedObj{\n\t\t\tTable: c.table,\n\t\t\tName:  \"processed\",\n\t\t\tType:  nftables.ObjTypeCounter,\n\t\t\tObj:   &expr.Counter{},\n\t\t}\n\n\t\tc.conn.AddObject(&namedCounter)\n\n\t\t// We flush here because we need to create a reference to the counter in the rule\n\t\terr := c.conn.Flush()\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"nftables: failed to flush conn: %w\", err)\n\t\t}\n\n\t\tr := &nftables.Rule{\n\t\t\tTable: c.table,\n\t\t\tChain: chain,\n\t\t\tExprs: []expr.Any{\n\t\t\t\t&expr.Objref{\n\t\t\t\t\tType: int(nftables.ObjTypeCounter), // The nftables library does use the ObjType enum here, just cast it\n\t\t\t\t\tName: namedCounter.Name,\n\t\t\t\t},\n\t\t\t},\n\t\t}\n\n\t\tc.conn.AddRule(r)\n\n\t\tc.chains[hook] = chain\n\n\t\tlog.Debugf(\"nftables: ip%s chain '%s' created\", c.version, chain.Name)\n\t\t// Rules and sets are created on the fly when we detect a new origin\n\t}\n\n\tif err := c.conn.Flush(); err != nil {\n\t\treturn err\n\t}\n\n\tlog.Debugf(\"nftables: ip%s table created\", c.version)\n\n\treturn nil\n}\n\nfunc (c *nftContext) init(hooks []string) error {\n\tif c.conn == nil {\n\t\treturn nil\n\t}\n\n\tif c.chains == nil {\n\t\tc.chains = make(map[string]*nftables.Chain)\n\t}\n\n\tlog.Debugf(\"nftables: ip%s init starting\", c.version)\n\n\tvar err error\n\n\tif c.setOnly {\n\t\terr = c.initSetOnly()\n\t} else {\n\t\terr = c.initOwnTable(hooks)\n\t}\n\n\tif err != nil && strings.Contains(err.Error(), \"out of range\") {\n\t\treturn fmt.Errorf(\"nftables: %w. Please check the name length of tables, sets and chains. \"+\n\t\t\t\"Some legacy systems have 32 or 15 character limits. \"+\n\t\t\t\"For example, use 'crowdsec-set' instead of 'crowdsec-blacklists'\", err)\n\t}\n\n\treturn err\n}\n\nfunc (c *nftContext) lookupTable() (*nftables.Table, error) {\n\ttables, err := c.conn.ListTables()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tfor _, t := range tables {\n\t\tif t.Name == c.tableName {\n\t\t\treturn t, nil\n\t\t}\n\t}\n\n\treturn nil, fmt.Errorf(\"nftables: could not find table '%s'\", c.tableName)\n}\n\nfunc (c *nftContext) createRule(chain *nftables.Chain, set *nftables.Set,\n\tdenyLog bool, denyLogPrefix string, denyAction string,\n) (*nftables.Rule, error) {\n\tnamedCounter := nftables.NamedObj{\n\t\tTable: c.table,\n\t\tName:  set.Name,\n\t\tType:  nftables.ObjTypeCounter,\n\t\tObj:   &expr.Counter{},\n\t}\n\n\tc.conn.AddObject(&namedCounter)\n\n\t// We flush here because we need to create a reference to the counter in the rule\n\tif err := c.conn.Flush(); err != nil {\n\t\treturn nil, fmt.Errorf(\"nftables: failed to flush conn: %w\", err)\n\t}\n\n\tr := &nftables.Rule{\n\t\tTable:    c.table,\n\t\tChain:    chain,\n\t\tExprs:    []expr.Any{},\n\t\tUserData: []byte(set.Name),\n\t}\n\t// [ payload load 4b @ network header + 16 => reg 1 ]\n\tr.Exprs = append(r.Exprs, &expr.Payload{\n\t\tDestRegister: 1,\n\t\tBase:         expr.PayloadBaseNetworkHeader,\n\t\tOffset:       c.payloadOffset,\n\t\tLen:          c.payloadLength,\n\t})\n\t// [ lookup reg 1 set whitelist ]\n\tr.Exprs = append(r.Exprs, &expr.Lookup{\n\t\tSourceRegister: 1,\n\t\tSetName:        set.Name,\n\t\tSetID:          set.ID,\n\t})\n\n\tr.Exprs = append(r.Exprs, &expr.Objref{\n\t\tType: int(nftables.ObjTypeCounter), // The nftables library does use the ObjType enum here, just cast it\n\t\tName: namedCounter.Name,\n\t})\n\n\tif denyLog {\n\t\tr.Exprs = append(r.Exprs, &expr.Log{\n\t\t\tKey:  1 << unix.NFTA_LOG_PREFIX,\n\t\t\tData: []byte(denyLogPrefix),\n\t\t})\n\t}\n\n\taction := strings.ToUpper(denyAction)\n\tif action == \"\" {\n\t\taction = \"DROP\"\n\t}\n\n\tswitch action {\n\tcase \"DROP\":\n\t\tr.Exprs = append(r.Exprs, &expr.Verdict{\n\t\t\tKind: expr.VerdictDrop,\n\t\t})\n\tcase \"REJECT\":\n\t\tr.Exprs = append(r.Exprs, &expr.Reject{\n\t\t\tType: unix.NFT_REJECT_ICMP_UNREACH,\n\t\t\tCode: unix.NFT_REJECT_ICMPX_ADMIN_PROHIBITED,\n\t\t})\n\tdefault:\n\t\treturn nil, fmt.Errorf(\"invalid deny_action '%s', must be one of DROP, REJECT\", action)\n\t}\n\n\tlog.Tracef(\"using '%s' as deny_action\", action)\n\n\treturn r, nil\n}\n\nfunc (c *nftContext) deleteElementChunk(els []nftables.SetElement) error {\n\t// FIXME: only delete IPs from the set they are in\n\t// But this could lead to strange behavior if we have duplicate decisions with different origins\n\tfor _, set := range c.sets {\n\t\tlog.Debugf(\"removing %d ip%s elements from set %s\", len(els), c.version, set.Name)\n\n\t\tif err := c.conn.SetDeleteElements(set, els); err != nil {\n\t\t\treturn fmt.Errorf(\"failed to remove ip%s elements from set: %w\", c.version, err)\n\t\t}\n\n\t\tif err := c.conn.Flush(); err != nil {\n\t\t\tif len(els) == 1 {\n\t\t\t\tlog.Debugf(\"deleting %s, failed to flush: %s\", reprIP(els[0].Key), err)\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\tlog.Debugf(\"failed to flush chunk of %d elements, will retry each one: %s\", len(els), err)\n\n\t\t\tfor i := range els {\n\t\t\t\tif err := c.deleteElementChunk([]nftables.SetElement{els[i]}); err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc (c *nftContext) deleteElements(els []nftables.SetElement) error {\n\tif len(els) <= chunkSize {\n\t\treturn c.deleteElementChunk(els)\n\t}\n\n\tlog.Debugf(\"splitting %d elements into chunks of %d\", len(els), chunkSize)\n\n\tfor _, chunk := range slicetools.Chunks(els, chunkSize) {\n\t\tif err := c.deleteElementChunk(chunk); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc (c *nftContext) addElements(els map[string][]nftables.SetElement) error {\n\tvar setName string\n\n\tfor origin, set := range c.sets {\n\t\tif c.setOnly {\n\t\t\tsetName = c.blacklists\n\t\t} else {\n\t\t\tsetName = fmt.Sprintf(\"%s-%s\", c.blacklists, origin)\n\t\t}\n\n\t\tlog.Debugf(\"Using %s as origin | len of IPs: %d | set name is %s\", origin, len(els[origin]), setName)\n\n\t\tfor _, chunk := range slicetools.Chunks(els[origin], chunkSize) {\n\t\t\tlog.Debugf(\"adding %d ip%s elements to set %s\", len(chunk), c.version, setName)\n\n\t\t\tif err := c.conn.SetAddElements(set, chunk); err != nil {\n\t\t\t\treturn fmt.Errorf(\"failed to add ip%s elements to set: %w\", c.version, err)\n\t\t\t}\n\n\t\t\tif err := c.conn.Flush(); err != nil {\n\t\t\t\treturn fmt.Errorf(\"failed to flush ip%s conn: %w\", c.version, err)\n\t\t\t}\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc (c *nftContext) shutDown() error {\n\tif c.conn == nil {\n\t\treturn nil\n\t}\n\n\tif c.setOnly {\n\t\t// Flush blacklist4 set empty\n\t\tlog.Infof(\"flushing '%s' set in '%s' table\", c.sets[c.blacklists].Name, c.table.Name)\n\t\tc.conn.FlushSet(c.sets[c.blacklists])\n\t} else {\n\t\tlog.Infof(\"removing '%s' table\", c.table.Name)\n\t\tc.conn.DelTable(c.table)\n\t}\n\n\treturn c.conn.Flush()\n}\n"
  },
  {
    "path": "pkg/nftables/nftables_stub.go",
    "content": "//go:build !linux\n\npackage nftables\n\nimport (\n\t\"github.com/crowdsecurity/cs-firewall-bouncer/pkg/cfg\"\n\t\"github.com/crowdsecurity/cs-firewall-bouncer/pkg/types\"\n)\n\nfunc NewNFTables(config *cfg.BouncerConfig) (types.Backend, error) {\n\treturn nil, nil\n}\n"
  },
  {
    "path": "pkg/pf/metrics.go",
    "content": "package pf\n\nimport (\n\t\"bufio\"\n\t\"regexp\"\n\t\"slices\"\n\t\"strconv\"\n\t\"strings\"\n\n\t\"github.com/prometheus/client_golang/prometheus\"\n\tlog \"github.com/sirupsen/logrus\"\n\n\t\"github.com/crowdsecurity/cs-firewall-bouncer/pkg/metrics\"\n)\n\ntype counter struct {\n\tpackets uint64\n\tbytes   uint64\n}\n\nvar (\n\t// table names can contain _ or - characters.\n\trexpTable   = regexp.MustCompile(`^block .* from <(?P<table>[^ ]+)> .*\"$`)\n\trexpMetrics = regexp.MustCompile(`^\\s+\\[.*Packets: (?P<packets>\\d+)\\s+Bytes: (?P<bytes>\\d+).*\\]$`)\n)\n\nfunc parseMetrics(reader *strings.Reader, tables []string) map[string]counter {\n\tret := make(map[string]counter)\n\n\t// scan until we find a table name between <>\n\tscanner := bufio.NewScanner(reader)\n\tfor scanner.Scan() {\n\t\tline := scanner.Text()\n\t\t// parse the line and extract the table name\n\t\tmatch := rexpTable.FindStringSubmatch(line)\n\t\tif len(match) == 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\ttable := match[1]\n\t\t// if the table is not in the list of tables we want to parse, skip it\n\t\tif !slices.Contains(tables, table) {\n\t\t\tcontinue\n\t\t}\n\n\t\t// parse the line with the actual metrics\n\t\tif !scanner.Scan() {\n\t\t\tbreak\n\t\t}\n\n\t\tline = scanner.Text()\n\n\t\tmatch = rexpMetrics.FindStringSubmatch(line)\n\t\tif len(match) == 0 {\n\t\t\tlog.Errorf(\"failed to parse metrics: %s\", line)\n\t\t\tcontinue\n\t\t}\n\n\t\tpackets, err := strconv.ParseUint(match[1], 10, 64)\n\t\tif err != nil {\n\t\t\tlog.Errorf(\"failed to parse metrics - dropped packets: %s\", err)\n\n\t\t\tpackets = 0\n\t\t}\n\n\t\tbytes, err := strconv.ParseUint(match[2], 10, 64)\n\t\tif err != nil {\n\t\t\tlog.Errorf(\"failed to parse metrics - dropped bytes: %s\", err)\n\n\t\t\tbytes = 0\n\t\t}\n\n\t\tret[table] = counter{\n\t\t\tpackets: packets,\n\t\t\tbytes:   bytes,\n\t\t}\n\t}\n\n\treturn ret\n}\n\n// countIPs returns the number of IPs in a table.\nfunc countIPs(table string) int {\n\tcmd := execPfctl(\"\", \"-T\", \"show\", \"-t\", table)\n\n\tout, err := cmd.Output()\n\tif err != nil {\n\t\tlog.Errorf(\"failed to run 'pfctl -T show -t %s': %s\", table, err)\n\t\treturn 0\n\t}\n\n\t// one IP per line\n\treturn strings.Count(string(out), \"\\n\")\n}\n\n// CollectMetrics collects metrics from pfctl.\n// In pf mode the firewall rules are not controlled by the bouncer, so we can only\n// trust they are set up correctly, and retrieve stats from the pfctl tables.\nfunc (pf *pf) CollectMetrics() {\n\ttables := []string{}\n\n\tif pf.inet != nil {\n\t\ttables = append(tables, pf.inet.table)\n\t}\n\n\tif pf.inet6 != nil {\n\t\ttables = append(tables, pf.inet6.table)\n\t}\n\n\tcmd := execPfctl(\"\", \"-v\", \"-sr\")\n\n\tout, err := cmd.Output()\n\tif err != nil {\n\t\tlog.Errorf(\"failed to run 'pfctl -v -sr': %s\", err)\n\t\treturn\n\t}\n\n\treader := strings.NewReader(string(out))\n\tstats := parseMetrics(reader, tables)\n\n\tfor _, table := range tables {\n\t\tst, ok := stats[table]\n\t\tif !ok {\n\t\t\tcontinue\n\t\t}\n\n\t\tdroppedPackets := float64(st.packets)\n\t\tdroppedBytes := float64(st.bytes)\n\t\tbannedIPs := countIPs(table)\n\n\t\tif pf.inet != nil && table == pf.inet.table {\n\t\t\tmetrics.Map[metrics.DroppedPackets].Gauge.With(prometheus.Labels{\"ip_type\": \"ipv4\", \"origin\": \"\"}).Set(droppedPackets)\n\t\t\tmetrics.Map[metrics.DroppedBytes].Gauge.With(prometheus.Labels{\"ip_type\": \"ipv4\", \"origin\": \"\"}).Set(droppedBytes)\n\t\t\tmetrics.Map[metrics.ActiveBannedIPs].Gauge.With(prometheus.Labels{\"ip_type\": \"ipv4\", \"origin\": \"\"}).Set(float64(bannedIPs))\n\t\t} else if pf.inet6 != nil && table == pf.inet6.table {\n\t\t\tmetrics.Map[metrics.DroppedPackets].Gauge.With(prometheus.Labels{\"ip_type\": \"ipv6\", \"origin\": \"\"}).Set(droppedPackets)\n\t\t\tmetrics.Map[metrics.DroppedBytes].Gauge.With(prometheus.Labels{\"ip_type\": \"ipv6\", \"origin\": \"\"}).Set(droppedBytes)\n\t\t\tmetrics.Map[metrics.ActiveBannedIPs].Gauge.With(prometheus.Labels{\"ip_type\": \"ipv6\", \"origin\": \"\"}).Set(float64(bannedIPs))\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "pkg/pf/metrics_test.go",
    "content": "package pf\n\nimport (\n\t\"strings\"\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/require\"\n)\n\nfunc TestParseMetrics(t *testing.T) {\n\tmetricsInput := `block drop in quick inet from <crowdsec_blacklists> to any label \"CrowdSec IPv4\"\n  [ Evaluations: 1519      Packets: 16         Bytes: 4096           States: 0     ]\n  [ Inserted: uid 0 pid 14219 State Creations: 0     ]\nblock drop in quick inet6 from <crowdsec6_blacklists> to any label \"CrowdSec IPv6\"\n  [ Evaluations: 914       Packets: 8          Bytes: 2048           States: 0     ]\n  [ Inserted: uid 0 pid 14219 State Creations: 0     ]`\n\n\treader := strings.NewReader(metricsInput)\n\ttables := []string{\"crowdsec_blacklists\", \"crowdsec6_blacklists\"}\n\n\tmetrics := parseMetrics(reader, tables)\n\n\trequire.Contains(t, metrics, \"crowdsec_blacklists\")\n\trequire.Contains(t, metrics, \"crowdsec6_blacklists\")\n\n\tip4Metrics := metrics[\"crowdsec_blacklists\"]\n\tassert.Equal(t, uint64(16), ip4Metrics.packets)\n\tassert.Equal(t, uint64(4096), ip4Metrics.bytes)\n\n\tip6Metrics := metrics[\"crowdsec6_blacklists\"]\n\tassert.Equal(t, uint64(8), ip6Metrics.packets)\n\tassert.Equal(t, uint64(2048), ip6Metrics.bytes)\n}\n"
  },
  {
    "path": "pkg/pf/pf.go",
    "content": "package pf\n\nimport (\n\t\"fmt\"\n\t\"os\"\n\t\"os/exec\"\n\t\"strings\"\n\n\tlog \"github.com/sirupsen/logrus\"\n\n\t\"github.com/crowdsecurity/crowdsec/pkg/models\"\n\n\t\"github.com/crowdsecurity/cs-firewall-bouncer/pkg/cfg\"\n\t\"github.com/crowdsecurity/cs-firewall-bouncer/pkg/types\"\n)\n\ntype pf struct {\n\tinet              *pfContext\n\tinet6             *pfContext\n\tdecisionsToAdd    []*models.Decision\n\tdecisionsToDelete []*models.Decision\n}\n\nconst (\n\tpfctlCmd = \"/sbin/pfctl\"\n\tpfDevice = \"/dev/pf\"\n)\n\nfunc NewPF(config *cfg.BouncerConfig) (types.Backend, error) {\n\tret := &pf{}\n\n\tinetCtx := &pfContext{\n\t\ttable:   config.BlacklistsIpv4,\n\t\tproto:   \"inet\",\n\t\tanchor:  config.PF.AnchorName,\n\t\tversion: \"ipv4\",\n\t}\n\n\tinet6Ctx := &pfContext{\n\t\ttable:   config.BlacklistsIpv6,\n\t\tproto:   \"inet6\",\n\t\tanchor:  config.PF.AnchorName,\n\t\tversion: \"ipv6\",\n\t}\n\n\tif !config.DisableIPV4 {\n\t\tret.inet = inetCtx\n\t}\n\n\tif !config.DisableIPV6 {\n\t\tret.inet6 = inet6Ctx\n\t}\n\n\treturn ret, nil\n}\n\n// execPfctl runs a pfctl command by prepending the anchor name if needed.\n// Some commands return an error if an anchor is specified.\nfunc execPfctl(anchor string, arg ...string) *exec.Cmd {\n\tif anchor != \"\" {\n\t\targ = append([]string{\"-a\", anchor}, arg...)\n\t}\n\n\tlog.Debugf(\"Running: %s %s\", pfctlCmd, arg)\n\n\treturn exec.Command(pfctlCmd, arg...)\n}\n\nfunc (pf *pf) Init() error {\n\tif _, err := os.Stat(pfDevice); err != nil {\n\t\treturn fmt.Errorf(\"%s device not found: %w\", pfDevice, err)\n\t}\n\n\tif _, err := exec.LookPath(pfctlCmd); err != nil {\n\t\treturn fmt.Errorf(\"%s command not found: %w\", pfctlCmd, err)\n\t}\n\n\tif pf.inet != nil {\n\t\tif err := pf.inet.init(); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tif pf.inet6 != nil {\n\t\tif err := pf.inet6.init(); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc (pf *pf) Commit() error {\n\tdefer pf.reset()\n\n\tif err := pf.commitDeletedDecisions(); err != nil {\n\t\treturn err\n\t}\n\n\treturn pf.commitAddedDecisions()\n}\n\nfunc (pf *pf) Add(decision *models.Decision) error {\n\tpf.decisionsToAdd = append(pf.decisionsToAdd, decision)\n\treturn nil\n}\n\nfunc (pf *pf) reset() {\n\tpf.decisionsToAdd = make([]*models.Decision, 0)\n\tpf.decisionsToDelete = make([]*models.Decision, 0)\n}\n\nfunc (pf *pf) commitDeletedDecisions() error {\n\tipv4decisions := make([]*models.Decision, 0)\n\tipv6decisions := make([]*models.Decision, 0)\n\n\tfor _, d := range pf.decisionsToDelete {\n\t\tif strings.Contains(*d.Value, \":\") && pf.inet6 != nil {\n\t\t\tipv6decisions = append(ipv6decisions, d)\n\t\t} else if pf.inet != nil {\n\t\t\tipv4decisions = append(ipv4decisions, d)\n\t\t}\n\t}\n\n\tif len(ipv6decisions) > 0 {\n\t\tif pf.inet6 == nil {\n\t\t\tlog.Debugf(\"not removing '%d' decisions because ipv6 is disabled\", len(ipv6decisions))\n\t\t} else if err := pf.inet6.delete(ipv6decisions); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tif len(ipv4decisions) > 0 {\n\t\tif pf.inet == nil {\n\t\t\tlog.Debugf(\"not removing '%d' decisions because ipv4 is disabled\", len(ipv4decisions))\n\t\t} else if err := pf.inet.delete(ipv4decisions); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc (pf *pf) commitAddedDecisions() error {\n\tipv4decisions := make([]*models.Decision, 0)\n\tipv6decisions := make([]*models.Decision, 0)\n\n\tfor _, d := range pf.decisionsToAdd {\n\t\tif strings.Contains(*d.Value, \":\") && pf.inet6 != nil {\n\t\t\tipv6decisions = append(ipv6decisions, d)\n\t\t} else if pf.inet != nil {\n\t\t\tipv4decisions = append(ipv4decisions, d)\n\t\t}\n\t}\n\n\tif len(ipv6decisions) > 0 {\n\t\tif pf.inet6 == nil {\n\t\t\tlog.Debugf(\"not adding '%d' decisions because ipv6 is disabled\", len(ipv6decisions))\n\t\t} else if err := pf.inet6.add(ipv6decisions); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tif len(ipv4decisions) > 0 {\n\t\tif pf.inet == nil {\n\t\t\tlog.Debugf(\"not adding '%d' decisions because ipv4 is disabled\", len(ipv4decisions))\n\t\t} else if err := pf.inet.add(ipv4decisions); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc (pf *pf) Delete(decision *models.Decision) error {\n\tpf.decisionsToDelete = append(pf.decisionsToDelete, decision)\n\treturn nil\n}\n\nfunc (pf *pf) ShutDown() error {\n\tlog.Infof(\"flushing 'crowdsec' table(s)\")\n\n\tif pf.inet != nil {\n\t\tif err := pf.inet.shutDown(); err != nil {\n\t\t\treturn fmt.Errorf(\"unable to flush %s table (%s): \", pf.inet.version, pf.inet.table)\n\t\t}\n\t}\n\n\tif pf.inet6 != nil {\n\t\tif err := pf.inet6.shutDown(); err != nil {\n\t\t\treturn fmt.Errorf(\"unable to flush %s table (%s): \", pf.inet6.version, pf.inet6.table)\n\t\t}\n\t}\n\n\treturn nil\n}\n"
  },
  {
    "path": "pkg/pf/pf_context.go",
    "content": "package pf\n\nimport (\n\t\"bufio\"\n\t\"fmt\"\n\t\"maps\"\n\t\"os\"\n\t\"os/exec\"\n\t\"slices\"\n\t\"strings\"\n\n\tlog \"github.com/sirupsen/logrus\"\n\n\t\"github.com/crowdsecurity/crowdsec/pkg/models\"\n)\n\ntype pfContext struct {\n\tproto   string\n\tanchor  string\n\ttable   string\n\tversion string\n}\n\nconst backendName = \"pf\"\n\nfunc decisionsToIPs(decisions []*models.Decision) []string {\n\tips := make([]string, 0, len(decisions))\n\n\tfor i, d := range decisions {\n\t\tif d == nil || d.Value == nil {\n\t\t\tcontinue\n\t\t}\n\n\t\tips[i] = *d.Value\n\t}\n\n\treturn ips\n}\n\nfunc writeIPsToFile(ips []string) (string, error) {\n\tf, err := os.CreateTemp(\"\", \"crowdsec-ips-*.txt\")\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\n\tname := f.Name()\n\tdone := false\n\n\tdefer func() {\n\t\tif !done {\n\t\t\t_ = f.Close()\n\t\t\t_ = os.Remove(name)\n\t\t}\n\t}()\n\n\tw := bufio.NewWriter(f)\n\tfor _, ip := range ips {\n\t\tif _, err = w.WriteString(ip + \"\\n\"); err != nil {\n\t\t\treturn \"\", err\n\t\t}\n\t}\n\n\tif err = w.Flush(); err != nil {\n\t\treturn \"\", err\n\t}\n\n\tif err = f.Close(); err != nil {\n\t\treturn \"\", err\n\t}\n\n\tdone = true\n\n\treturn name, nil\n}\n\nfunc (ctx *pfContext) checkTable() error {\n\tlog.Infof(\"Checking pf table: %s\", ctx.table)\n\n\tcmd := execPfctl(ctx.anchor, \"-s\", \"Tables\")\n\n\tout, err := cmd.CombinedOutput()\n\tif err != nil {\n\t\treturn fmt.Errorf(\"pfctl error: %s - %w\", out, err)\n\t}\n\n\tif !strings.Contains(string(out), ctx.table) {\n\t\tif ctx.anchor != \"\" {\n\t\t\treturn fmt.Errorf(\"table %s in anchor %s doesn't exist\", ctx.table, ctx.anchor)\n\t\t}\n\n\t\treturn fmt.Errorf(\"table %s doesn't exist\", ctx.table)\n\t}\n\n\treturn nil\n}\n\nfunc (ctx *pfContext) shutDown() error {\n\tcmd := execPfctl(ctx.anchor, \"-t\", ctx.table, \"-T\", \"flush\")\n\tlog.Infof(\"pf table clean-up: %s\", cmd)\n\n\tif out, err := cmd.CombinedOutput(); err != nil {\n\t\tlog.Errorf(\"Error while flushing table (%s): %v --> %s\", cmd, err, out)\n\t}\n\n\treturn nil\n}\n\n// getStateIPs returns a list of IPs that are currently in the state table.\nfunc getStateIPs() (map[string]bool, error) {\n\tret := make(map[string]bool)\n\n\tcmd := exec.Command(pfctlCmd, \"-s\", \"states\")\n\n\tout, err := cmd.Output()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tscanner := bufio.NewScanner(strings.NewReader(string(out)))\n\tfor scanner.Scan() {\n\t\tfields := strings.Fields(scanner.Text())\n\t\tif len(fields) < 6 {\n\t\t\tcontinue\n\t\t}\n\n\t\t// don't bother to parse the direction, we'll block both anyway\n\n\t\t// right side\n\t\tip := fields[4]\n\t\tif strings.Contains(ip, \":\") {\n\t\t\tip = strings.Split(ip, \":\")[0]\n\t\t}\n\n\t\tret[ip] = true\n\n\t\t// left side\n\t\tip = fields[2]\n\t\tif strings.Contains(ip, \":\") {\n\t\t\tip = strings.Split(ip, \":\")[0]\n\t\t}\n\n\t\tret[ip] = true\n\t}\n\n\tlog.Debugf(\"Found IPs in state table: %v\", len(ret))\n\n\treturn ret, nil\n}\n\nfunc (ctx *pfContext) add(decisions []*models.Decision) error {\n\tlog.Debugf(\"Adding %d decisions\", len(decisions))\n\n\tips := decisionsToIPs(decisions)\n\n\tfile, err := writeIPsToFile(ips)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"writing decisions to temp file: %w\", err)\n\t}\n\tdefer os.Remove(file)\n\n\tcmd := execPfctl(ctx.anchor, \"-t\", ctx.table, \"-T\", \"add\", \"-f\", file)\n\tif out, err := cmd.CombinedOutput(); err != nil {\n\t\treturn fmt.Errorf(\"error while adding to table (%s): %w --> %s\", cmd, err, out)\n\t}\n\n\tbannedIPs := make(map[string]bool, len(ips))\n\tfor _, ip := range ips {\n\t\tbannedIPs[ip] = true\n\t}\n\n\tif len(bannedIPs) == 0 {\n\t\tlog.Debugf(\"No new banned IPs\")\n\t\treturn nil\n\t}\n\n\tif log.IsLevelEnabled(log.DebugLevel) {\n\t\tkeys := slices.Collect(maps.Keys(bannedIPs))\n\t\tslices.Sort(keys)\n\t\tlog.Debugf(\"New banned IPs: %v\", keys)\n\t}\n\n\tstateIPs, err := getStateIPs()\n\tif err != nil {\n\t\treturn fmt.Errorf(\"error while getting state IPs: %w\", err)\n\t}\n\n\t// Reset the states of connections coming from or going to an IP if it's both in stateIPs and bannedIPs\n\n\tfor ip := range bannedIPs {\n\t\tif stateIPs[ip] {\n\t\t\t// incoming\n\t\t\tcmd := execPfctl(\"\", \"-k\", ip)\n\t\t\tif out, err := cmd.CombinedOutput(); err != nil {\n\t\t\t\tlog.Errorf(\"Error while flushing state (%s): %v --> %s\", cmd, err, out)\n\t\t\t}\n\n\t\t\t// outgoing\n\t\t\tcmd = execPfctl(\"\", \"-k\", \"0.0.0.0/0\", \"-k\", ip)\n\t\t\tif out, err := cmd.CombinedOutput(); err != nil {\n\t\t\t\tlog.Errorf(\"Error while flushing state (%s): %v --> %s\", cmd, err, out)\n\t\t\t}\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc (ctx *pfContext) delete(decisions []*models.Decision) error {\n\tlog.Debugf(\"Removing %d decisions\", len(decisions))\n\n\tips := decisionsToIPs(decisions)\n\n\tfile, err := writeIPsToFile(ips)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"writing decisions to temp file: %w\", err)\n\t}\n\tdefer os.Remove(file)\n\n\tcmd := execPfctl(ctx.anchor, \"-t\", ctx.table, \"-T\", \"delete\", \"-f\", file)\n\tif out, err := cmd.CombinedOutput(); err != nil {\n\t\tlog.Infof(\"Error while deleting from table (%s): %v --> %s\", cmd, err, out)\n\t}\n\n\treturn nil\n}\n\nfunc (ctx *pfContext) init() error {\n\tif err := ctx.shutDown(); err != nil {\n\t\treturn fmt.Errorf(\"pf table flush failed: %w\", err)\n\t}\n\n\tif err := ctx.checkTable(); err != nil {\n\t\treturn fmt.Errorf(\"pf init failed: %w\", err)\n\t}\n\n\tlog.Infof(\"%s initiated for %s\", backendName, ctx.version)\n\n\treturn nil\n}\n"
  },
  {
    "path": "pkg/types/types.go",
    "content": "package types\n\nimport (\n\t\"github.com/crowdsecurity/crowdsec/pkg/models\"\n)\n\ntype Backend interface {\n\tInit() error\n\tShutDown() error\n\tAdd(decision *models.Decision) error\n\tDelete(decision *models.Decision) error\n\tCommit() error\n\tCollectMetrics()\n}\n"
  },
  {
    "path": "rpm/SOURCES/80-crowdsec-firewall-bouncer.preset",
    "content": "# This file is part of crowdsec-firewall-bouncer\n\nenable crowdsec-firewall-bouncer.service\n"
  },
  {
    "path": "rpm/SPECS/crowdsec-firewall-bouncer.spec",
    "content": "Name:      crowdsec-firewall-bouncer-iptables\nVersion:   %(echo $VERSION)\nRelease:   %(echo $PACKAGE_NUMBER)%{?dist}\nSummary:   Firewall bouncer for Crowdsec (iptables+ipset configuration)\n\nLicense:   MIT\nURL:       https://crowdsec.net\nSource0:   https://github.com/crowdsecurity/%{name}/archive/v%(echo $VERSION).tar.gz\nSource1:   80-crowdsec-firewall-bouncer.preset\nBuildRoot: %{_tmppath}/%{name}-%{version}-%{release}-root-%(%{__id_u} -n)\n\nBuildRequires: make\n%{?fc33:BuildRequires: systemd-rpm-macros}\n\nRequires: gettext,iptables,ipset,ipset-libs\n\n%define debug_package %{nil}\n\n%define version_number %(echo $VERSION)\n%define releasever %(echo $RELEASEVER)\n%global local_version v%{version_number}-%{releasever}-rpm\n%global name crowdsec-firewall-bouncer\n%global __mangle_shebangs_exclude_from /usr/bin/env\n\n%prep\n%setup -q -T -b 0 -n %{name}-%{version_number}\n\n%build\nBUILD_VERSION=%{local_version} make\n\n%install\nrm -rf %{buildroot}\n\ninstall -m 755 -D %{name} %{buildroot}%{_bindir}/%{name}\ninstall -m 600 -D config/%{name}.yaml %{buildroot}/etc/crowdsec/bouncers/%{name}.yaml\ninstall -m 600 -D scripts/_bouncer.sh %{buildroot}/usr/lib/%{name}/_bouncer.sh\n\nmkdir -p %{buildroot}%{_unitdir}\nBIN=%{_bindir}/%{name} CFG=/etc/crowdsec/bouncers envsubst '$BIN $CFG' < config/%{name}.service > %{buildroot}%{_unitdir}/%{name}.service\n\ninstall -D -m 644 %{SOURCE1} %{buildroot}%{_presetdir}/80-crowdsec-firewall-bouncer.preset\n\n%clean\nrm -rf %{buildroot}\n\n%changelog\n* Tue Feb 16 2021 Manuel Sabban <manuel@crowdsec.net>\n- First initial packaging\n\n# ------------------------------------\n# iptables\n# ------------------------------------\n\n%description -n %{name}-iptables\n\n%files -n %{name}-iptables\n%defattr(-,root,root,-)\n%{_bindir}/%{name}\n/usr/lib/%{name}/_bouncer.sh\n%{_unitdir}/%{name}.service\n%config(noreplace) /etc/crowdsec/bouncers/%{name}.yaml\n%config(noreplace) %{_presetdir}/80-crowdsec-firewall-bouncer.preset\n\n%post -n %{name}-iptables\nsystemctl daemon-reload\n\n. /usr/lib/%{name}/_bouncer.sh\nSTART=1\n\nif grep -q '${BACKEND}' \"$CONFIG\"; then\n  newconfig=$(BACKEND=\"iptables\" envsubst '$BACKEND' < \"$CONFIG\")\n  (umask 177 && echo \"$newconfig\" > \"$CONFIG\")\nfi\n\nif [ \"$1\" = \"1\" ]; then\n    if need_api_key; then\n        if ! set_api_key; then\n            START=0\n        fi\n    fi\nfi\n\nset_local_port\n\nif [ ! -e /usr/sbin/crowdsec-firewall-bouncer ]; then\n    if [ ! -L /usr/sbin ]; then\n        ln -s ../bin/crowdsec-firewall-bouncer /usr/sbin/crowdsec-firewall-bouncer\n    fi\nfi\n\n\n%systemd_post %{name}.service\n\nif [ \"$START\" -eq 0 ]; then\n    echo \"no api key was generated, you can generate one on your LAPI Server by running 'cscli bouncers add <bouncer_name>' and add it to '$CONFIG'\" >&2\nelse\n    %if 0%{?fc35}\n    systemctl enable \"$SERVICE\"\n    %endif\n    systemctl start \"$SERVICE\"\nfi\n\necho \"$BOUNCER has been successfully installed\"\n\n%preun -n %{name}-iptables\n. /usr/lib/%{name}/_bouncer.sh\n\nif [ \"$1\" = \"0\" ]; then\n    systemctl stop \"$SERVICE\" || echo \"cannot stop service\"\n    systemctl disable \"$SERVICE\" || echo \"cannot disable service\"\n    delete_bouncer\nfi\n\n%postun -n %{name}-iptables\nif [ \"$1\" = \"1\" ]; then\n    systemctl restart %{name} || echo \"cannot restart service\"\nfi\n\nif [ -L /usr/sbin/crowdsec-firewall-bouncer ]; then\n    rm -f /usr/sbin/crowdsec-firewall-bouncer\nfi\n\n\n# ------------------------------------\n# nftables\n# ------------------------------------\n\n%package -n %{name}-nftables\nSummary:  Firewall bouncer for Crowdsec (nftables configuration)\nRequires: nftables,gettext\n\n%description -n %{name}-nftables\n\n%files -n %{name}-nftables\n%defattr(-,root,root,-)\n%{_bindir}/%{name}\n/usr/lib/%{name}/_bouncer.sh\n%{_unitdir}/%{name}.service\n%config(noreplace) /etc/crowdsec/bouncers/%{name}.yaml\n%config(noreplace) %{_presetdir}/80-crowdsec-firewall-bouncer.preset\n\n%post -n %{name}-nftables\nsystemctl daemon-reload\n\n. /usr/lib/%{name}/_bouncer.sh\nSTART=1\n\nif grep -q '${BACKEND}' \"$CONFIG\"; then\n  newconfig=$(BACKEND=\"nftables\" envsubst '$BACKEND' < \"$CONFIG\")\n  (umask 177 && echo \"$newconfig\" > \"$CONFIG\")\nfi\n\nif [ \"$1\" = \"1\" ]; then\n    if need_api_key; then\n        if ! set_api_key; then\n            START=0\n        fi\n    fi\nfi\n\nset_local_port\n\nif [ ! -e /usr/sbin/crowdsec-firewall-bouncer ]; then\n    if [ ! -L /usr/sbin ]; then\n        ln -s ../bin/crowdsec-firewall-bouncer /usr/sbin/crowdsec-firewall-bouncer\n    fi\nfi\n\n%systemd_post %{name}.service\n\nif [ \"$START\" -eq 0 ]; then\n    echo \"no api key was generated, you can generate one on your LAPI Server by running 'cscli bouncers add <bouncer_name>' and add it to '$CONFIG'\" >&2\nelse\n    %if 0%{?fc35}\n    systemctl enable \"$SERVICE\"\n    %endif\n    systemctl start \"$SERVICE\"\nfi\n\necho \"$BOUNCER has been successfully installed\"\n\n%preun -n %{name}-nftables\n. /usr/lib/%{name}/_bouncer.sh\n\nif [ \"$1\" = \"0\" ]; then\n    systemctl stop \"$SERVICE\" || echo \"cannot stop service\"\n    systemctl disable \"$SERVICE\" || echo \"cannot disable service\"\n    delete_bouncer\nfi\n\n%postun -n %{name}-nftables\nif [ \"$1\" = \"1\" ]; then\n    systemctl restart %{name} || echo \"cannot restart service\"\nfi\n\nif [ -L /usr/sbin/crowdsec-firewall-bouncer ]; then\n    rm -f /usr/sbin/crowdsec-firewall-bouncer\nfi\n"
  },
  {
    "path": "scripts/_bouncer.sh",
    "content": "#!/bin/sh\n#shellcheck disable=SC3043\n\nset -eu\n\nBOUNCER=\"crowdsec-firewall-bouncer\"\nBOUNCER_PREFIX=$(echo \"$BOUNCER\" | sed 's/crowdsec-/cs-/g')\n\n# This is a library of functions that can be sourced by other scripts\n# to install and configure bouncers.\n#\n# While not requiring bash, it is not strictly POSIX-compliant because\n# it uses local variables, but it should work with every modern shell.\n#\n# Since passing/parsing arguments in posix sh is tricky, we share\n# some environment variables with the functions. It's a matter of\n# readability balance between shorter vs cleaner code.\n\nif [ -n \"${NO_COLOR-}\" ] || [ ! -t 1 ]; then\n    # terminal is not interactive; no colors\n    FG_RED=\"\"\n    FG_GREEN=\"\"\n    FG_YELLOW=\"\"\n    FG_CYAN=\"\"\n    RESET=\"\"\nelif [ -n \"${TERM-}\" ] && tput sgr0 >/dev/null 2>&1; then\n    # terminfo\n    FG_RED=$(tput setaf 1)\n    FG_GREEN=$(tput setaf 2)\n    FG_YELLOW=$(tput setaf 3)\n    FG_CYAN=$(tput setaf 6)\n    RESET=$(tput sgr0)\nelse\n    FG_RED=$(printf '%b' '\\033[31m')\n    FG_GREEN=$(printf '%b' '\\033[32m')\n    FG_YELLOW=$(printf '%b' '\\033[33m')\n    FG_CYAN=$(printf '%b' '\\033[36m')\n    RESET=$(printf '%b' '\\033[0m')\nfi\n\nmsg() {\n    case \"$1\" in\n        info) echo \"${FG_CYAN}$2${RESET}\" >&2 ;;\n        warn) echo \"${FG_YELLOW}WARN:${RESET} $2\" >&2 ;;\n        err) echo \"${FG_RED}ERR:${RESET} $2\" >&2 ;;\n        succ) echo \"${FG_GREEN}$2${RESET}\" >&2 ;;\n        *) echo \"$1\" >&2 ;;\n    esac\n}\n\nrequire() {\n    set | grep -q \"^$1=\" || { msg err \"missing required variable \\$$1\"; exit 1; }\n    shift\n    [ \"$#\" -eq 0 ] || require \"$@\"\n}\n\n# shellcheck disable=SC2034\n{\nSERVICE=\"$BOUNCER.service\"\nBIN_PATH_INSTALLED=\"/usr/local/bin/$BOUNCER\"\nBIN_PATH=\"./$BOUNCER\"\nCONFIG_DIR=\"/etc/crowdsec/bouncers\"\nCONFIG_FILE=\"$BOUNCER.yaml\"\nCONFIG=\"$CONFIG_DIR/$CONFIG_FILE\"\nSYSTEMD_PATH_FILE=\"/etc/systemd/system/$SERVICE\"\n}\n\nassert_root() {\n    #shellcheck disable=SC2312\n    if [ \"$(id -u)\" -ne 0 ]; then\n        msg err \"This script must be run as root\"\n        exit 1\n    fi\n}\n\n# Check if the configuration file contains a variable\n# which has not yet been interpolated, like \"$API_KEY\",\n# and return true if it does.\nconfig_not_set() {\n    require 'CONFIG'\n    local varname before after\n\n    varname=$1\n    if [ \"$varname\" = \"\" ]; then\n        msg err \"missing required variable name\"\n        exit 1\n    fi\n\n    before=$(\"$BOUNCER\" -c \"$CONFIG\" -T)\n    # shellcheck disable=SC2016\n    after=$(echo \"$before\" | envsubst \"\\$$varname\")\n\n    if [ \"$before\" = \"$after\" ]; then\n        return 1\n    fi\n    return 0\n}\n\nneed_api_key() {\n    if config_not_set 'API_KEY'; then\n        return 0\n    fi\n    return 1\n}\n\n# Interpolate a variable in the config file with a value.\nset_config_var_value() {\n    require 'CONFIG'\n    local varname value before\n\n    varname=$1\n    if [ \"$varname\" = \"\" ]; then\n        msg err \"missing required variable name\"\n        exit 1\n    fi\n\n    value=$2\n    if [ \"$value\" = \"\" ]; then\n        msg err \"missing required variable value\"\n        exit 1\n    fi\n\n    before=$(cat \"$CONFIG\")\n    (umask 177 && echo \"$before\" | \\\n        env \"$varname=$value\" envsubst \"\\$$varname\" >\"$CONFIG\")\n}\n\nset_api_key() {\n    require 'CONFIG' 'BOUNCER_PREFIX'\n    local api_key ret bouncer_id before\n    # if we can't set the key, the user will take care of it\n    ret=0\n\n    if command -v cscli >/dev/null; then\n        echo \"cscli/crowdsec is present, generating API key\" >&2\n        bouncer_id=\"$BOUNCER_PREFIX-$(date +%s)\"\n        api_key=$(cscli -oraw bouncers add \"$bouncer_id\" || true)\n        if [ \"$api_key\" = \"\" ]; then\n            echo \"failed to create API key\" >&2\n            api_key=\"<API_KEY>\"\n            ret=1\n        else\n            echo \"API Key successfully created\" >&2\n            echo \"$bouncer_id\" > \"$CONFIG.id\"\n        fi\n    else\n        echo \"cscli/crowdsec is not present, please set the API key manually\" >&2\n        api_key=\"<API_KEY>\"\n        ret=1\n    fi\n\n    if [ \"$api_key\" != \"\" ]; then\n        set_config_var_value 'API_KEY' \"$api_key\"\n    fi\n\n    return \"$ret\"\n}\n\nset_local_port() {\n    require 'CONFIG'\n    local port\n    command -v cscli >/dev/null || return 0\n    # the following will fail with a non-LAPI local crowdsec, leaving empty port\n    port=$(cscli config show -oraw --key \"Config.API.Server.ListenURI\" 2>/dev/null | cut -d \":\" -f2 || true)\n    if [ \"$port\" != \"\" ]; then\n        sed -i \"s/localhost:8080/127.0.0.1:$port/g\" \"$CONFIG\"\n        sed -i \"s/127.0.0.1:8080/127.0.0.1:$port/g\" \"$CONFIG\"\n    fi\n}\n\nset_local_lapi_url() {\n    require 'CONFIG'\n    local port before varname\n    # $varname is the name of the variable to interpolate\n    # in the config file with the URL of the LAPI server,\n    # assuming it is running on the same host as the\n    # bouncer.\n    varname=$1\n    if [ \"$varname\" = \"\" ]; then\n        msg err \"missing required variable name\"\n        exit 1\n    fi\n    command -v cscli >/dev/null || return 0\n\n    port=$(cscli config show -oraw --key \"Config.API.Server.ListenURI\" 2>/dev/null | cut -d \":\" -f2 || true)\n    if [ \"$port\" = \"\" ]; then\n        port=8080\n    fi\n\n    set_config_var_value \"$varname\" \"http://127.0.0.1:$port\"\n}\n\ndelete_bouncer() {\n    require 'CONFIG'\n    local bouncer_id\n    if [ -f \"$CONFIG.id\" ]; then\n        bouncer_id=$(cat \"$CONFIG.id\")\n        cscli -oraw bouncers delete \"$bouncer_id\" 2>/dev/null || true\n        rm -f \"$CONFIG.id\"\n    fi\n}\n\nupgrade_bin() {\n    require 'BIN_PATH' 'BIN_PATH_INSTALLED'\n    rm \"$BIN_PATH_INSTALLED\"\n    install -v -m 0755 -D \"$BIN_PATH\" \"$BIN_PATH_INSTALLED\"\n}\n"
  },
  {
    "path": "scripts/install.sh",
    "content": "#!/bin/sh\n\nset -eu\n\n. ./scripts/_bouncer.sh\n\nassert_root\n\n# --------------------------------- #\n\nAPI_KEY=\"<API_KEY>\"\n\ninstall_pkg() {\n    pkg=\"$1\"\n    if [ -f /etc/redhat-release ]; then\n        yum install -y \"$pkg\"\n    elif grep -q \"Amazon Linux release 2 (Karoo)\" /etc/system-release 2>/dev/null; then\n        yum install -y \"$pkg\"\n    elif grep -q \"suse\" /etc/os-release 2>/dev/null; then\n        zypper install -y \"$pkg\"\n    elif [ -f /etc/debian_version ]; then\n        apt install -y \"$pkg\"\n    else\n        msg warn \"This distribution is not supported\"\n        return 1\n    fi\n    msg succ \"$pkg successfully installed\"\n    return 0\n}\n\ncheck_firewall() {\n    # Default firewall backend is nftables\n    FW_BACKEND=\"nftables\"\n\n    iptables=\"true\"\n    if command -v iptables >/dev/null; then \n        FW_BACKEND=\"iptables\"\n        msg info \"iptables found\"\n    else\n        msg warn \"iptables not found\"\n        iptables=\"false\"\n    fi\n\n    nftables=\"true\"\n    if command -v nft >/dev/null; then \n        FW_BACKEND=\"nftables\"\n        msg info \"nftables found\"\n    else\n        msg warn \"nftables not found\"\n        nftables=\"false\"\n    fi\n\n    if [ \"$nftables\" = \"false\" ] && [ \"$iptables\" = \"false\" ]; then\n        printf '%s ' \"No firewall found, do you want to install nftables (Y/n) ?\"\n        read -r answer\n        if echo \"$answer\" | grep -iq '^n'; then\n            msg err \"unable to continue without nftables. Please install nftables or iptables to use this bouncer.\"\n            exit 1\n        fi\n        # shellcheck disable=SC2310\n        install_pkg nftables || ( msg err \"Cannot install nftables, please install it manually\"; exit 1 )\n    fi\n\n    if [ \"$nftables\" = \"true\" ] && [ \"$iptables\" = \"true\" ]; then\n        printf '%s ' \"Found nftables (default) and iptables, which firewall do you want to use (nftables/iptables) ?\"\n        read -r answer\n        if [ \"$answer\" = \"iptables\" ]; then\n            FW_BACKEND=\"iptables\"\n        fi\n    fi\n\n    if [ \"$FW_BACKEND\" = \"iptables\" ]; then\n        check_ipset\n    fi\n}\n\ncheck_ipset() {\n    if ! command -v ipset >/dev/null; then\n        printf '%s ' \"ipset not found, do you want to install it (Y/n) ?\"\n        read -r answer\n        if echo \"$answer\" | grep -iq '^n'; then\n            msg err \"unable to continue without ipset. Exiting\"\n            exit 1\n        fi\n        # shellcheck disable=SC2310\n        install_pkg ipset || ( msg err \"Cannot install ipset, please install it manually\"; exit 1 )\n    fi\n}\n\ngen_apikey() {\n    if command -v cscli >/dev/null; then\n        msg succ \"cscli found, generating bouncer api key.\"\n        bouncer_id=\"$BOUNCER_PREFIX-$(date +%s)\"\n        API_KEY=$(cscli -oraw bouncers add \"$bouncer_id\")\n        echo \"$bouncer_id\" > \"$CONFIG.id\"\n        msg info \"API Key: $API_KEY\"\n        READY=\"yes\"\n    else\n        msg warn \"cscli not found, you will need to generate an api key.\"\n        READY=\"no\"\n    fi\n}\n\ngen_config_file() {\n    # shellcheck disable=SC2016\n    (umask 177 && API_KEY=\"$API_KEY\" BACKEND=\"$FW_BACKEND\" envsubst '$API_KEY $BACKEND' <\"./config/$CONFIG_FILE\" > \"$CONFIG\")\n}\n\ninstall_bouncer() {\n    if [ ! -f \"$BIN_PATH\" ]; then\n        msg err \"$BIN_PATH not found, exiting.\"\n        exit 1\n    fi\n    if [ -e \"$BIN_PATH_INSTALLED\" ]; then\n        msg err \"$BIN_PATH_INSTALLED is already installed. Exiting\"\n        exit 1\n    fi\n    msg info \"Installing $BOUNCER\"\n    check_firewall\n    install -v -m 0755 -D \"$BIN_PATH\" \"$BIN_PATH_INSTALLED\"\n    mkdir -p \"$(dirname \"$CONFIG\")\"\n    # shellcheck disable=SC2016\n    CFG=${CONFIG_DIR} BIN=${BIN_PATH_INSTALLED} envsubst '$CFG $BIN' <\"./config/$SERVICE\" >\"$SYSTEMD_PATH_FILE\"\n    systemctl daemon-reload\n    gen_apikey\n    gen_config_file\n    set_local_port\n}\n\n# --------------------------------- #\n\ninstall_bouncer\n\nsystemctl enable \"$SERVICE\"\nif [ \"$READY\" = \"yes\" ]; then\n    systemctl start \"$SERVICE\"\nelse\n    msg warn \"service not started. You need to get an API key and configure it in $CONFIG\"\nfi\n\nmsg succ \"The $BOUNCER service has been installed.\"\nexit 0\n"
  },
  {
    "path": "scripts/uninstall.sh",
    "content": "#!/bin/sh\n\nset -eu\n\n. ./scripts/_bouncer.sh\n\nassert_root\n\n# --------------------------------- #\n\nuninstall() {\n    systemctl stop \"$SERVICE\" || true\n    delete_bouncer\n    rm -f \"$CONFIG\"\n    rm -f \"$SYSTEMD_PATH_FILE\"\n    rm -f \"$BIN_PATH_INSTALLED\"\n    rm -f \"/var/log/$BOUNCER.log\"\n}\n\nuninstall\nmsg succ \"$BOUNCER has been successfully uninstalled\"\nexit 0\n"
  },
  {
    "path": "scripts/upgrade.sh",
    "content": "#!/bin/sh\n\nset -eu\n\n. ./scripts/_bouncer.sh\n\nassert_root\n\n# --------------------------------- #\n\nsystemctl stop \"$SERVICE\"\n\nif ! upgrade_bin; then\n    msg err \"failed to upgrade $BOUNCER\"\n    exit 1\nfi\n\nsystemctl start \"$SERVICE\" || msg warn \"$SERVICE failed to start, please check the systemd logs\"\n\nmsg succ \"$BOUNCER upgraded successfully.\"\nexit 0\n"
  },
  {
    "path": "test/.python-version",
    "content": "3.12\n"
  },
  {
    "path": "test/README.md",
    "content": ""
  },
  {
    "path": "test/default.env",
    "content": "CROWDSEC_TEST_VERSION=\"dev\"\nCROWDSEC_TEST_FLAVORS=\"full\"\nCROWDSEC_TEST_NETWORK=\"net-test\"\n"
  },
  {
    "path": "test/pyproject.toml",
    "content": "[project]\nname = \"cs-firewall-bouncer-tests\"\nversion = \"0.1.0\"\ndescription = \"Tests for cs-firewall-bouncer\"\nreadme = \"README.md\"\nrequires-python = \">=3.12\"\ndependencies = [\n    \"flask>=3.1.0\",\n    \"pexpect>=4.9.0\",\n    \"psutil>=6.1.1\",\n    \"pytest>=8.3.4\",\n    \"pytest-cs>=0.7.24\",\n    \"pytest-dependency>=0.6.0\",\n    \"pytest-dotenv>=0.5.2\",\n    \"pytimeparse>=1.1.8\",\n    \"zxcvbn>=4.4.28\",\n]\n\n[tool.uv.sources]\npytest-cs = { git = \"https://github.com/crowdsecurity/pytest-cs\" }\n\n[dependency-groups]\ndev = [\n    \"basedpyright>=1.26.0\",\n    \"ipdb>=0.13.13\",\n    \"ruff>=0.9.4\",\n]\n\n#[tool.uv.sources]\n#pytest-cs = { path = \"../../../pytest-cs\", editable = true }\n\n\n[tool.ruff]\n\nline-length = 120\n\n[tool.ruff.lint]\nselect = [\n  \"ALL\"\n]\n\nignore = [\n    \"ANN\",      # Missing type annotations\n    \"A002\",     # Function argument `id` is shadowing a Python builtin\n    \"ARG001\",   # Unused function argument: `...`\n    \"COM812\",   # Trailing comma missing\n    \"D100\",     # Missing docstring in public module\n    \"D101\",     # Missing docstring in public class\n    \"D102\",     # Missing docstring in public method\n    \"D103\",     # Missing docstring in public function\n    \"D104\",     # Missing docstring in public package\n    \"D107\",     # Missing docstring in __init__\n    \"D203\",     # incorrect-blank-line-before-class\n    \"D212\",     # Multi-line docstring summary should start at the first line\n    \"D212\",     # Multi-line docstring summary should start at the first line\n    \"D400\",     # First line should end with a period\n    \"D415\",     # First line should end with a period, question mark, or exclamation point\n    \"DTZ005\",   # `datetime.datetime.now()` called without a `tz` argument\n    \"DTZ901\",   # Use of `datetime.datetime.min` without timezone information\n    \"EM102\",    # Exception must not use an f-string literal, assign to variable first\n    \"ERA001\",   # Found commented-out code\n    \"FBT002\",   # Boolean default positional argument in function definition\n    \"FIX002\",   # Line contains TODO, consider resolving the issue\n    \"FIX003\",   # Line contains XXX, consider resolving the issue\n    \"N802\",     # Function name `testLogging` should be lowercase\n    \"PLW1510\",  # `subprocess.run` without explicit `check` argument\n    \"S101\",     # Use of 'assert' detected\n    \"S104\",     # Possible binding to all interfaces\n    \"S314\",     # Using `xml` to parse untrusted data is known to be vulnerable to XML attacks; use `defusedxml` equivalents\n    \"S603\",     # `subprocess` call: check for execution of untrusted input\n    \"S604\",     # Function call with `shell=True` parameter identified, security issue\n    \"S607\",     # Starting a process with a partial executable path\n    \"SIM108\",   # Use ternary operator `...` instead of `if`-`else`-block\n    \"TD001\",    # Invalid TODO tag: `XXX`\n    \"TD002\",    # Missing author in TODO\n    \"TD003\",    # Missing issue link for this TODO\n    \"TRY003\",   # Avoid specifying long messages outside the exception class\n    \"PLR2004\",  # Magic value used in comparison, consider replacing `...` with a constant variable\n    \"PLR0913\",  # Too many arguments in function definition (6 > 5)\n    \"PTH107\",   # `os.remove()` should be replaced by `Path.unlink()`\n    \"PTH108\",   # `os.unlink()` should be replaced by `Path.unlink()`\n    \"PTH110\",   # `os.path.exists()` should be replaced by `Path.exists()`\n    \"PTH116\",   # `os.stat()` should be replaced by `Path.stat()`, `Path.owner()`, or `Path.group()`\n    \"PTH120\",   # `os.path.dirname()` should be replaced by `Path.parent`\n    \"PTH123\",   # `open()` should be replaced by `Path.open()`\n    \"PT009\",    # Use a regular `assert` instead of unittest-style `assertEqual`\n    \"PT022\",    # No teardown in fixture `fw_cfg_factory`, use `return` instead of `yield`\n    \"TID252\",   # Prefer absolute imports over relative imports from parent modules\n    \"UP022\",    # Prefer `capture_output` over sending `stdout` and `stderr` to `PIPE`\n]\n\n[tool.basedpyright]\nreportAny = \"none\"\nreportArgumentType = \"none\"\nreportAttributeAccessIssue = \"none\"\nreportImplicitOverride = \"none\"\nreportImplicitStringConcatenation = \"none\"\nreportMissingParameterType = \"none\"\nreportMissingTypeStubs = \"none\"\nreportOptionalMemberAccess = \"none\"\nreportUnannotatedClassAttribute = \"none\"\nreportUninitializedInstanceVariable = \"none\"\nreportUnknownArgumentType = \"none\"\nreportUnknownMemberType = \"none\"\nreportUnknownParameterType = \"none\"\nreportUnknownVariableType = \"none\"\nreportUnusedCallResult = \"none\"\nreportUnusedParameter = \"none\"\n"
  },
  {
    "path": "test/pytest.ini",
    "content": "[pytest]\naddopts =\n    --pdbcls=IPython.terminal.debugger:Pdb\n    --ignore=tests/install\n    --ignore=tests/backends\n    --strict-markers\nmarkers:\n    deb: mark tests related to deb packaging\n    rpm: mark tests related to rpm packaging\n    systemd_debug: dump systemd status and journal on test failure\nenv_files =\n    .env\n    default.env\n"
  },
  {
    "path": "test/tests/__init__.py",
    "content": ""
  },
  {
    "path": "test/tests/backends/__init__.py",
    "content": ""
  },
  {
    "path": "test/tests/backends/iptables/__init__.py",
    "content": ""
  },
  {
    "path": "test/tests/backends/iptables/crowdsec-firewall-bouncer-logging.yaml",
    "content": "mode: iptables\nupdate_frequency: 0.1s\nlog_mode: stdout\nlog_dir: ./\nlog_level: info\napi_url: http://127.0.0.1:8081/\napi_key: 1237adaf7a1724ac68a3288828820a67\ndisable_ipv6: false\ndeny_action: DROP\ndeny_log: true\ndeny_log_prefix: \"blocked by crowdsec\"\nsupported_decisions_types:\n  - ban\niptables_chains:\n  - INPUT\n"
  },
  {
    "path": "test/tests/backends/iptables/crowdsec-firewall-bouncer.yaml",
    "content": "mode: iptables\nupdate_frequency: 0.1s\nlog_mode: stdout\nlog_dir: ./\nlog_level: info\napi_url: http://127.0.0.1:8081/\napi_key: 1237adaf7a1724ac68a3288828820a67\ndisable_ipv6: false\ndeny_action: DROP\ndeny_log: false\nsupported_decisions_types:\n  - ban\niptables_chains:\n  - INPUT\n"
  },
  {
    "path": "test/tests/backends/iptables/test_iptables.py",
    "content": "import os\nimport subprocess\nimport unittest\nimport xml.etree.ElementTree as ET\nfrom ipaddress import ip_address\nfrom pathlib import Path\nfrom time import sleep\n\nfrom ..mock_lapi import MockLAPI\nfrom ..utils import generate_n_decisions, new_decision, run_cmd\n\nSCRIPT_DIR = Path(os.path.dirname(os.path.realpath(__file__)))\nPROJECT_ROOT = SCRIPT_DIR.parent.parent.parent.parent\nBINARY_PATH = PROJECT_ROOT.joinpath(\"crowdsec-firewall-bouncer\")\nCONFIG_PATH = SCRIPT_DIR.joinpath(\"crowdsec-firewall-bouncer.yaml\")\nCONFIG_PATH_LOGGING = SCRIPT_DIR.joinpath(\"crowdsec-firewall-bouncer-logging.yaml\")\n\nSET_NAME_IPV4 = \"crowdsec-blacklists-0\"\nSET_NAME_IPV6 = \"crowdsec6-blacklists-0\"\n\nRULES_CHAIN_NAME = \"CROWDSEC_CHAIN\"\nLOGGING_CHAIN_NAME = \"CROWDSEC_LOG\"\nCHAIN_NAME = \"INPUT\"\n\n\nclass TestIPTables(unittest.TestCase):\n    def setUp(self):\n        self.fb = subprocess.Popen([BINARY_PATH, \"-c\", CONFIG_PATH])\n        self.lapi = MockLAPI()\n        self.lapi.start()\n        return super().setUp()\n\n    def tearDown(self):\n        self.fb.kill()\n        self.fb.wait()\n        self.lapi.stop()\n\n    def test_table_rule_set_are_created(self):\n        d1 = generate_n_decisions(3)\n        d2 = generate_n_decisions(1, ipv4=False)\n        self.lapi.ds.insert_decisions(d1 + d2)\n        sleep(3)\n\n        # IPV4 Chain\n        # Check the rules with the sets\n        output = run_cmd(\"iptables\", \"-L\", RULES_CHAIN_NAME)\n        rules = [line for line in output.split(\"\\n\") if SET_NAME_IPV4 in line]\n\n        self.assertEqual(len(rules), 1)\n        assert f\"match-set {SET_NAME_IPV4} src\" in rules[0]\n\n        # Check the JUMP to CROWDSEC_CHAIN\n        output = run_cmd(\"iptables\", \"-L\", CHAIN_NAME)\n        rules = [line for line in output.split(\"\\n\") if RULES_CHAIN_NAME in line]\n\n        self.assertEqual(len(rules), 1)\n        assert f\"{RULES_CHAIN_NAME}\" in rules[0]\n\n        # IPV6 Chain\n        output = run_cmd(\"ip6tables\", \"-L\", RULES_CHAIN_NAME)\n        rules = [line for line in output.split(\"\\n\") if SET_NAME_IPV6 in line]\n\n        self.assertEqual(len(rules), 1)\n        assert f\"match-set {SET_NAME_IPV6} src\" in rules[0]\n\n        # Check the JUMP to CROWDSEC_CHAIN\n        output = run_cmd(\"ip6tables\", \"-L\", CHAIN_NAME)\n        rules = [line for line in output.split(\"\\n\") if RULES_CHAIN_NAME in line]\n\n        self.assertEqual(len(rules), 1)\n        assert f\"{RULES_CHAIN_NAME}\" in rules[0]\n\n        output = run_cmd(\"ipset\", \"list\")\n\n        assert SET_NAME_IPV6 in output\n        assert SET_NAME_IPV4 in output\n\n    def test_duplicate_decisions_across_decision_stream(self):\n        d1, d2, d3 = generate_n_decisions(3, dup_count=1)\n        self.lapi.ds.insert_decisions([d1])\n        sleep(3)\n        res = get_set_elements(SET_NAME_IPV4)\n        self.assertEqual(res, {\"0.0.0.0\"})\n\n        self.lapi.ds.insert_decisions([d2, d3])\n        sleep(3)\n        assert self.fb.poll() is None\n        self.assertEqual(get_set_elements(SET_NAME_IPV4), {\"0.0.0.0\", \"0.0.0.1\"})\n\n        self.lapi.ds.delete_decision_by_id(d1[\"id\"])\n        self.lapi.ds.delete_decision_by_id(d2[\"id\"])\n        sleep(3)\n        self.assertEqual(get_set_elements(SET_NAME_IPV4), set())\n        assert self.fb.poll() is None\n\n        self.lapi.ds.delete_decision_by_id(d3[\"id\"])\n        sleep(3)\n        self.assertEqual(get_set_elements(SET_NAME_IPV6), set())\n        assert self.fb.poll() is None\n\n    def test_decision_insertion_deletion_ipv4(self):\n        total_decisions, duplicate_decisions = 100, 23\n        decisions = generate_n_decisions(total_decisions, dup_count=duplicate_decisions)\n        self.lapi.ds.insert_decisions(decisions)\n        sleep(3)  # let the bouncer insert the decisions\n\n        set_elements = get_set_elements(SET_NAME_IPV4)\n        self.assertEqual(len(set_elements), total_decisions - duplicate_decisions)\n        self.assertEqual({i[\"value\"] for i in decisions}, set_elements)\n        self.assertIn(\"0.0.0.0\", set_elements)\n\n        self.lapi.ds.delete_decisions_by_ip(\"0.0.0.0\")\n        sleep(3)\n\n        set_elements = get_set_elements(SET_NAME_IPV4)\n        self.assertEqual({i[\"value\"] for i in decisions if i[\"value\"] != \"0.0.0.0\"}, set_elements)\n        self.assertEqual(len(set_elements), total_decisions - duplicate_decisions - 1)\n        self.assertNotIn(\"0.0.0.0\", set_elements)\n\n    def test_decision_insertion_deletion_ipv6(self):\n        total_decisions, duplicate_decisions = 100, 23\n        decisions = generate_n_decisions(total_decisions, dup_count=duplicate_decisions, ipv4=False)\n        self.lapi.ds.insert_decisions(decisions)\n        sleep(3)\n\n        set_elements = get_set_elements(SET_NAME_IPV6)\n        set_elements = set(map(ip_address, set_elements))\n        self.assertEqual(len(set_elements), total_decisions - duplicate_decisions)\n        self.assertEqual({ip_address(i[\"value\"]) for i in decisions}, set_elements)\n        self.assertIn(ip_address(\"::1:0:3\"), set_elements)\n\n        self.lapi.ds.delete_decisions_by_ip(\"::1:0:3\")\n        sleep(3)\n\n        set_elements = get_set_elements(SET_NAME_IPV6)\n        set_elements = set(map(ip_address, set_elements))\n        self.assertEqual(len(set_elements), total_decisions - duplicate_decisions - 1)\n        self.assertEqual(\n            {ip_address(i[\"value\"]) for i in decisions if ip_address(i[\"value\"]) != ip_address(\"::1:0:3\")},\n            set_elements,\n        )\n        self.assertNotIn(ip_address(\"::1:0:3\"), set_elements)\n\n    def test_longest_decision_insertion(self):\n        decisions = [\n            {\n                \"value\": \"123.45.67.12\",\n                \"scope\": \"ip\",\n                \"type\": \"ban\",\n                \"origin\": \"script\",\n                \"duration\": f\"{i}h\",\n                \"reason\": \"for testing\",\n            }\n            for i in range(1, 201)\n        ]\n        self.lapi.ds.insert_decisions(decisions)\n        sleep(3)\n        elems = get_set_elements(SET_NAME_IPV4, with_timeout=True)\n        self.assertEqual(len(elems), 1)\n        elems = list(elems)\n        self.assertEqual(elems[0][0], \"123.45.67.12\")\n        self.assertLessEqual(abs(elems[0][1] - 200 * 60 * 60), 15)\n\n\ndef get_set_elements(set_name, with_timeout=False):\n    output = run_cmd(\"ipset\", \"list\", \"-o\", \"xml\")\n    root = ET.fromstring(output)\n    elements = set()\n    for member in root.findall(f\"ipset[@name='{set_name}']/members/member\"):\n        if with_timeout:\n            to_add = (member.find(\"elem\").text, int(member.find(\"timeout\").text))\n        else:\n            to_add = member.find(\"elem\").text\n        elements.add(to_add)\n    return elements\n\n\nclass TestIPTablesLogging(unittest.TestCase):\n    def setUp(self):\n        self.fb = subprocess.Popen([BINARY_PATH, \"-c\", CONFIG_PATH_LOGGING])\n        self.lapi = MockLAPI()\n        self.lapi.start()\n        return super().setUp()\n\n    def tearDown(self):\n        self.fb.kill()\n        self.fb.wait()\n        self.lapi.stop()\n\n    def testLogging(self):\n        # We use 1.1.1.1 because we want to see some dropped packets in the logs\n        # We know this IP responds to ping, and the response will be dropped by the firewall\n        d = new_decision(\"1.1.1.1\")\n        self.lapi.ds.insert_decisions([d])\n        sleep(3)\n\n        # Check if our logging chain is in place\n\n        output = run_cmd(\"iptables\", \"-L\", LOGGING_CHAIN_NAME)\n        rules = [line for line in output.split(\"\\n\") if \"anywhere\" in line]\n\n        # 2 rules: one logging, one generic drop\n        self.assertEqual(len(rules), 2)\n\n        # Check if the logging chain is called from the main chain\n        output = run_cmd(\"iptables\", \"-L\", CHAIN_NAME)\n\n        rules = [line for line in output.split(\"\\n\") if RULES_CHAIN_NAME in line]\n\n        self.assertEqual(len(rules), 1)\n\n        # Check if logging/drop chain is called from the rules chain\n        output = run_cmd(\"iptables\", \"-L\", RULES_CHAIN_NAME)\n\n        rules = [line for line in output.split(\"\\n\") if LOGGING_CHAIN_NAME in line]\n\n        self.assertEqual(len(rules), 1)\n\n        # Now, try to ping the IP\n\n        output = run_cmd(\n            \"curl\", \"--connect-timeout\", \"1\", \"1.1.1.1\", ignore_error=True\n        )  # We don't care about the output, we just want to trigger the rule\n\n        # Check if the firewall has logged the dropped response\n\n        output = run_cmd(\"dmesg | tail -n 10\", shell=True)\n\n        assert \"blocked by crowdsec\" in output\n"
  },
  {
    "path": "test/tests/backends/mock_lapi.py",
    "content": "import datetime\nimport logging\nfrom datetime import timedelta\nfrom ipaddress import ip_address\nfrom threading import Thread\nfrom time import sleep\n\nfrom flask import Flask, abort, request\nfrom pytimeparse.timeparse import timeparse\nfrom werkzeug.serving import make_server\n\n\n# This is the \"database\" of our dummy LAPI\nclass DataStore:\n    def __init__(self) -> None:\n        self.id = 0\n        self.decisions = []\n        self.bouncer_lastpull_by_api_key = {}\n\n    def insert_decisions(self, decisions):\n        for i, _ in enumerate(decisions):\n            decisions[i][\"created_at\"] = datetime.datetime.now()\n            decisions[i][\"deleted_at\"] = self.get_decision_expiry_time(decisions[i])\n            decisions[i][\"id\"] = self.id\n            self.id += 1\n        self.decisions.extend(decisions)\n\n    # This methods can be made more generic by taking lambda expr as input for filtering\n    # decisions to delete\n    def delete_decisions_by_ip(self, ip):\n        for i, decision in enumerate(self.decisions):\n            if ip_address(decision[\"value\"]) == ip_address(ip):\n                self.decisions[i][\"deleted_at\"] = datetime.datetime.now()\n\n    def delete_decision_by_id(self, id):\n        for i, decision in enumerate(self.decisions):\n            if decision[\"id\"] == id:\n                self.decisions[i][\"deleted_at\"] = datetime.datetime.now()\n                break\n\n    def update_bouncer_pull(self, api_key):\n        self.bouncer_lastpull_by_api_key[api_key] = datetime.datetime.now()\n\n    def get_active_and_expired_decisions_since(self, since):\n        expired_decisions = []\n        active_decisions = []\n\n        for decision in self.decisions:\n            # decision[\"deleted_at\"] > datetime.datetime.now()  means that decision hasn't yet expired\n            if decision[\"deleted_at\"] > since and decision[\"deleted_at\"] < datetime.datetime.now():\n                expired_decisions.append(decision)\n\n            elif decision[\"created_at\"] > since:\n                active_decisions.append(decision)\n        return active_decisions, expired_decisions\n\n    def get_decisions_for_bouncer(self, api_key, startup=False):\n        if startup or api_key not in self.bouncer_lastpull_by_api_key:\n            since = datetime.datetime.min\n            self.bouncer_lastpull_by_api_key[api_key] = since\n        else:\n            since = self.bouncer_lastpull_by_api_key[api_key]\n\n        self.update_bouncer_pull(api_key)\n        return self.get_active_and_expired_decisions_since(since)\n\n    @staticmethod\n    def get_decision_expiry_time(decision):\n        return decision[\"created_at\"] + timedelta(seconds=timeparse(decision[\"duration\"]))\n\n\nclass MockLAPI:\n    def __init__(self) -> None:\n        self.app = Flask(__name__)\n        self.app.add_url_rule(\"/v1/decisions/stream\", view_func=self.decisions)\n        log = logging.getLogger(\"werkzeug\")\n        log.setLevel(logging.ERROR)\n        self.app.logger.disabled = True\n        log.disabled = True\n        self.ds = DataStore()\n\n    def decisions(self):\n        api_key = request.headers.get(\"x-api-key\")\n        if not api_key:\n            abort(404)\n        startup = request.args.get(\"startup\") == \"true\"\n        active_decisions, expired_decisions = self.ds.get_decisions_for_bouncer(api_key, startup)\n        return {\n            \"new\": formatted_decisions(active_decisions),\n            \"deleted\": formatted_decisions(expired_decisions),\n        }\n\n    def start(self, port=8081):\n        self.server_thread = ServerThread(self.app, port=port)\n        self.server_thread.start()\n\n    def stop(self):\n        self.server_thread.shutdown()\n\n\ndef formatted_decisions(decisions):\n    formatted_decisions = []\n    for decision in decisions:\n        expiry_time = decision[\"created_at\"] + timedelta(seconds=timeparse(decision[\"duration\"]))\n        duration = expiry_time - datetime.datetime.now()\n        formatted_decisions.append(\n            {\n                \"duration\": f\"{duration.total_seconds()}s\",\n                \"id\": decision[\"id\"],\n                \"origin\": decision[\"origin\"],\n                \"scenario\": \"cscli\",\n                \"scope\": decision[\"scope\"],\n                \"type\": decision[\"type\"],\n                \"value\": decision[\"value\"],\n            }\n        )\n    return formatted_decisions\n\n\n# Copied from https://stackoverflow.com/a/45017691 .\n# We run server inside thread instead of process to avoid\n# huge complexity of sharing objects\nclass ServerThread(Thread):\n    def __init__(self, app, port=8081):\n        Thread.__init__(self)\n        self.server = make_server(\"127.0.0.1\", port, app)\n        self.ctx = app.app_context()\n        self.ctx.push()\n\n    def run(self):\n        self.server.serve_forever()\n\n    def shutdown(self):\n        self.server.shutdown()\n\n\nif __name__ == \"__main__\":\n    MockLAPI().start()\n    sleep(100)\n"
  },
  {
    "path": "test/tests/backends/nftables/__init__.py",
    "content": ""
  },
  {
    "path": "test/tests/backends/nftables/crowdsec-firewall-bouncer.yaml",
    "content": "mode: nftables\nupdate_frequency: 0.01s\nlog_mode: stdout\nlog_dir: ./\nlog_level: info\napi_url: http://127.0.0.1:8081/\napi_key: 1237adaf7a1724ac68a3288828820a67\ndisable_ipv6: false\ndeny_action: DROP\ndeny_log: false\nsupported_decisions_types:\n  - ban\niptables_chains:\n  - INPUT\n\nnftables_hooks:\n  - input\n  - forward\n\nnftables:\n  ipv4:\n    enabled: true\n    set-only: false\n    table: crowdsec\n    chain: crowdsec-chain\n  ipv6:\n    enabled: true\n    set-only: false\n    table: crowdsec6\n    chain: crowdsec6-chain\n"
  },
  {
    "path": "test/tests/backends/nftables/test_nftables.py",
    "content": "import json\nimport os\nimport subprocess\nimport unittest\nfrom ipaddress import ip_address\nfrom pathlib import Path\nfrom time import sleep\n\nfrom ..mock_lapi import MockLAPI\nfrom ..utils import generate_n_decisions, run_cmd\n\nSCRIPT_DIR = Path(os.path.dirname(os.path.realpath(__file__)))\nPROJECT_ROOT = SCRIPT_DIR.parent.parent.parent.parent\nBINARY_PATH = PROJECT_ROOT.joinpath(\"crowdsec-firewall-bouncer\")\nCONFIG_PATH = SCRIPT_DIR.joinpath(\"crowdsec-firewall-bouncer.yaml\")\n\n\nclass TestNFTables(unittest.TestCase):\n    def setUp(self):\n        self.fb = subprocess.Popen([BINARY_PATH, \"-c\", CONFIG_PATH])\n        self.lapi = MockLAPI()\n        self.lapi.start()\n        return super().setUp()\n\n    def tearDown(self):\n        self.fb.kill()\n        self.fb.wait()\n        self.lapi.stop()\n        run_cmd(\"nft\", \"delete\", \"table\", \"ip\", \"crowdsec\", ignore_error=True)\n        run_cmd(\"nft\", \"delete\", \"table\", \"ip6\", \"crowdsec6\", ignore_error=True)\n\n    def test_table_rule_set_are_created(self):\n        d1 = generate_n_decisions(3)\n        d2 = generate_n_decisions(1, ipv4=False)\n        self.lapi.ds.insert_decisions(d1 + d2)\n        sleep(1)\n        output = json.loads(run_cmd(\"nft\", \"-j\", \"list\", \"tables\"))\n        tables = {(node[\"table\"][\"family\"], node[\"table\"][\"name\"]) for node in output[\"nftables\"] if \"table\" in node}\n        assert (\"ip6\", \"crowdsec6\") in tables\n        assert (\"ip\", \"crowdsec\") in tables\n\n        # IPV4\n        output = json.loads(run_cmd(\"nft\", \"-j\", \"list\", \"table\", \"ip\", \"crowdsec\"))\n        sets = {\n            (node[\"set\"][\"family\"], node[\"set\"][\"name\"], node[\"set\"][\"type\"])\n            for node in output[\"nftables\"]\n            if \"set\" in node\n        }\n        assert (\"ip\", \"crowdsec-blacklists-script\", \"ipv4_addr\") in sets\n        rules = {node[\"rule\"][\"chain\"] for node in output[\"nftables\"] if \"rule\" in node}  # maybe stricter check ?\n        assert \"crowdsec-chain-forward\" in rules\n        assert \"crowdsec-chain-input\" in rules\n\n        # IPV6\n        output = json.loads(run_cmd(\"nft\", \"-j\", \"list\", \"table\", \"ip6\", \"crowdsec6\"))\n        sets = {\n            (node[\"set\"][\"family\"], node[\"set\"][\"name\"], node[\"set\"][\"type\"])\n            for node in output[\"nftables\"]\n            if \"set\" in node\n        }\n        assert (\"ip6\", \"crowdsec6-blacklists-script\", \"ipv6_addr\") in sets\n\n        rules = {node[\"rule\"][\"chain\"] for node in output[\"nftables\"] if \"rule\" in node}  # maybe stricter check ?\n        assert \"crowdsec6-chain-input\" in rules\n        assert \"crowdsec6-chain-forward\" in rules\n\n    def test_duplicate_decisions_across_decision_stream(self):\n        d1, d2, d3 = generate_n_decisions(3, dup_count=1)\n        self.lapi.ds.insert_decisions([d1])\n        sleep(1)\n        self.assertEqual(\n            get_set_elements(\"ip\", \"crowdsec\", \"crowdsec-blacklists-script\"),\n            {\"0.0.0.0\"},\n        )\n\n        self.lapi.ds.insert_decisions([d2, d3])\n        sleep(1)\n        assert self.fb.poll() is None\n        self.assertEqual(\n            get_set_elements(\"ip\", \"crowdsec\", \"crowdsec-blacklists-script\"),\n            {\"0.0.0.0\", \"0.0.0.1\"},\n        )\n\n        self.lapi.ds.delete_decision_by_id(d1[\"id\"])\n        self.lapi.ds.delete_decision_by_id(d2[\"id\"])\n        sleep(1)\n        self.assertEqual(get_set_elements(\"ip\", \"crowdsec\", \"crowdsec-blacklists-script\"), set())\n        assert self.fb.poll() is None\n\n        self.lapi.ds.delete_decision_by_id(d3[\"id\"])\n        sleep(1)\n        self.assertEqual(get_set_elements(\"ip\", \"crowdsec\", \"crowdsec-blacklists-script\"), set())\n        assert self.fb.poll() is None\n\n    def test_decision_insertion_deletion_ipv4(self):\n        total_decisions, duplicate_decisions = 100, 23\n        decisions = generate_n_decisions(total_decisions, dup_count=duplicate_decisions)\n        self.lapi.ds.insert_decisions(decisions)\n        sleep(1)  # let the bouncer insert the decisions\n\n        set_elements = get_set_elements(\"ip\", \"crowdsec\", \"crowdsec-blacklists-script\")\n        self.assertEqual(len(set_elements), total_decisions - duplicate_decisions)\n        assert {i[\"value\"] for i in decisions} == set_elements\n        assert \"0.0.0.0\" in set_elements\n\n        self.lapi.ds.delete_decisions_by_ip(\"0.0.0.0\")\n        sleep(1)\n\n        set_elements = get_set_elements(\"ip\", \"crowdsec\", \"crowdsec-blacklists-script\")\n        assert {i[\"value\"] for i in decisions if i[\"value\"] != \"0.0.0.0\"} == set_elements\n        assert len(set_elements) == total_decisions - duplicate_decisions - 1\n        assert \"0.0.0.0\" not in set_elements\n\n    def test_decision_insertion_deletion_ipv6(self):\n        total_decisions, duplicate_decisions = 100, 23\n        decisions = generate_n_decisions(total_decisions, dup_count=duplicate_decisions, ipv4=False)\n        self.lapi.ds.insert_decisions(decisions)\n        sleep(1)\n\n        set_elements = get_set_elements(\"ip6\", \"crowdsec6\", \"crowdsec6-blacklists-script\")\n        set_elements = set(map(ip_address, set_elements))\n        assert len(set_elements) == total_decisions - duplicate_decisions\n        assert {ip_address(i[\"value\"]) for i in decisions} == set_elements\n        assert ip_address(\"::1:0:3\") in set_elements\n\n        self.lapi.ds.delete_decisions_by_ip(\"::1:0:3\")\n        sleep(1)\n\n        set_elements = get_set_elements(\"ip6\", \"crowdsec6\", \"crowdsec6-blacklists-script\")\n        set_elements = set(map(ip_address, set_elements))\n        self.assertEqual(len(set_elements), total_decisions - duplicate_decisions - 1)\n        assert (\n            {ip_address(i[\"value\"]) for i in decisions if ip_address(i[\"value\"]) != ip_address(\"::1:0:3\")}\n        ) == set_elements\n        assert ip_address(\"::1:0:3\") not in set_elements\n\n    def test_longest_decision_insertion(self):\n        decisions = [\n            {\n                \"value\": \"123.45.67.12\",\n                \"scope\": \"ip\",\n                \"type\": \"ban\",\n                \"origin\": \"script\",\n                \"duration\": f\"{i}h\",\n                \"reason\": \"for testing\",\n            }\n            for i in range(1, 201)\n        ]\n        self.lapi.ds.insert_decisions(decisions)\n        sleep(1)\n        elems = get_set_elements(\"ip\", \"crowdsec\", \"crowdsec-blacklists-script\", with_timeout=True)\n        assert len(elems) == 1\n        elems = list(elems)\n        assert elems[0][0] == \"123.45.67.12\"\n        assert abs(elems[0][1] - 200 * 60 * 60) <= 3\n\n\ndef get_set_elements(family, table_name, set_name, with_timeout=False):\n    output = json.loads(run_cmd(\"nft\", \"-j\", \"list\", \"set\", family, table_name, set_name))\n    for node in output[\"nftables\"]:\n        if \"set\" not in node or \"elem\" not in node[\"set\"]:\n            continue\n        if not isinstance(node[\"set\"][\"elem\"][0], dict):\n            return set(node[\"set\"][\"elem\"])\n\n        if not with_timeout:\n            return {elem[\"elem\"][\"val\"] for elem in node[\"set\"][\"elem\"]}\n        return {(elem[\"elem\"][\"val\"], elem[\"elem\"][\"timeout\"]) for elem in node[\"set\"][\"elem\"]}\n    return set()\n"
  },
  {
    "path": "test/tests/backends/utils.py",
    "content": "import subprocess\nfrom ipaddress import ip_address\n\n\ndef run_cmd(*cmd, ignore_error=False, shell=False):\n    p = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, shell=shell)\n    if not ignore_error and p.returncode:\n        raise SystemExit(f\"{cmd} exited with non-zero code with following logs:\\n {p.stdout}\")\n\n    return p.stdout\n\n\ndef generate_n_decisions(n: int, action=\"ban\", dup_count=0, ipv4=True, duration=\"4h\"):\n    if dup_count >= n:\n        raise SystemExit(f\"generate_n_decisions got dup_count={dup_count} which is >=n\")\n\n    unique_decision_count = n - dup_count\n    decisions = []\n    for i in range(unique_decision_count):\n        if ipv4:\n            ip = ip_address(i)\n        else:\n            ip = ip_address(2**32 + i)\n        decisions.append(\n            {\n                \"value\": ip.__str__(),\n                \"scope\": \"ip\",\n                \"type\": action,\n                \"origin\": \"script\",\n                \"duration\": duration,\n                \"reason\": \"for testing\",\n            }\n        )\n    decisions += decisions[: n % unique_decision_count]\n    decisions *= n // unique_decision_count\n    return decisions\n\n\ndef new_decision(ip: str):\n    return {\n        \"value\": ip,\n        \"scope\": \"ip\",\n        \"type\": \"ban\",\n        \"origin\": \"script\",\n        \"duration\": \"4h\",\n        \"reason\": \"for testing\",\n    }\n"
  },
  {
    "path": "test/tests/bouncer/__init__.py",
    "content": ""
  },
  {
    "path": "test/tests/bouncer/test_firewall_bouncer.py",
    "content": "import json\n\n\ndef test_backend_mode(bouncer, fw_cfg_factory):\n    cfg = fw_cfg_factory()\n\n    del cfg[\"mode\"]\n\n    with bouncer(cfg) as fw:\n        fw.wait_for_lines_fnmatch(\n            [\n                \"*unable to load configuration: config does not contain 'mode'*\",\n            ]\n        )\n        fw.proc.wait(timeout=0.2)\n        assert not fw.proc.is_running()\n\n    cfg[\"mode\"] = \"whatever\"\n\n    with bouncer(cfg) as fw:\n        fw.wait_for_lines_fnmatch(\n            [\n                \"*firewall 'whatever' is not supported*\",\n            ]\n        )\n        fw.proc.wait(timeout=0.2)\n        assert not fw.proc.is_running()\n\n    cfg[\"mode\"] = \"dry-run\"\n\n    with bouncer(cfg) as fw:\n        fw.wait_for_lines_fnmatch(\n            [\n                \"*Starting crowdsec-firewall-bouncer*\",\n                \"*backend type: dry-run*\",\n                \"*backend.Init() called*\",\n                \"*unable to configure bouncer: config does not contain LAPI url*\",\n            ]\n        )\n        fw.proc.wait(timeout=0.2)\n        assert not fw.proc.is_running()\n\n\ndef test_api_url(crowdsec, bouncer, fw_cfg_factory):\n    cfg = fw_cfg_factory()\n\n    with bouncer(cfg) as fw:\n        fw.wait_for_lines_fnmatch(\n            [\n                \"*unable to configure bouncer: config does not contain LAPI url*\",\n            ]\n        )\n        fw.proc.wait()\n        assert not fw.proc.is_running()\n\n    cfg[\"api_url\"] = \"\"\n\n    with bouncer(cfg) as fw:\n        fw.wait_for_lines_fnmatch(\n            [\n                \"*unable to configure bouncer: config does not contain LAPI url*\",\n            ]\n        )\n        fw.proc.wait()\n        assert not fw.proc.is_running()\n\n\ndef test_api_key(crowdsec, bouncer, fw_cfg_factory, api_key_factory, bouncer_under_test):\n    api_key = api_key_factory()\n    env = {\"BOUNCER_KEY_bouncer\": api_key}\n\n    with crowdsec(environment=env) as lapi:\n        lapi.wait_for_http(8080, \"/health\")\n        port = lapi.probe.get_bound_port(\"8080\")\n\n        cfg = fw_cfg_factory()\n        cfg[\"api_url\"] = f\"http://localhost:{port}\"\n\n        with bouncer(cfg) as fw:\n            fw.wait_for_lines_fnmatch(\n                [\n                    \"*unable to configure bouncer: config does not contain LAPI key or certificate*\",\n                ]\n            )\n            fw.proc.wait()\n            assert not fw.proc.is_running()\n\n        cfg[\"api_key\"] = \"badkey\"\n\n        with bouncer(cfg) as fw:\n            fw.wait_for_lines_fnmatch(\n                [\n                    \"*Using API key auth*\",\n                    \"*process terminated with error: API error: access forbidden*\",\n                ]\n            )\n            fw.proc.wait()\n            assert not fw.proc.is_running()\n\n        cfg[\"api_key\"] = api_key\n\n        with bouncer(cfg) as fw:\n            fw.wait_for_lines_fnmatch(\n                [\n                    \"*Using API key auth*\",\n                    \"*Processing new and deleted decisions*\",\n                ]\n            )\n            assert fw.proc.is_running()\n\n            # check that the bouncer is registered\n            res = lapi.cont.exec_run(\"cscli bouncers list -o json\")\n            assert res.exit_code == 0\n            bouncers = json.loads(res.output)\n            assert len(bouncers) == 1\n            assert bouncers[0][\"name\"] == \"bouncer\"\n            assert bouncers[0][\"auth_type\"] == \"api-key\"\n            assert bouncers[0][\"type\"] == bouncer_under_test\n"
  },
  {
    "path": "test/tests/bouncer/test_iptables_deny_action.py",
    "content": "def test_iptables_deny_action(bouncer, fw_cfg_factory):\n    cfg = fw_cfg_factory()\n\n    cfg[\"log_level\"] = \"trace\"\n    cfg[\"mode\"] = \"iptables\"\n\n    with bouncer(cfg) as fw:\n        fw.wait_for_lines_fnmatch(\n            [\n                \"*using 'DROP' as deny_action*\",\n            ]\n        )\n        fw.proc.wait(timeout=5)\n        assert not fw.proc.is_running()\n\n    cfg[\"deny_action\"] = \"drop\"\n\n    with bouncer(cfg) as fw:\n        fw.wait_for_lines_fnmatch(\n            [\n                \"*using 'DROP' as deny_action*\",\n            ]\n        )\n        fw.proc.wait(timeout=5)\n        assert not fw.proc.is_running()\n\n    cfg[\"deny_action\"] = \"reject\"\n\n    with bouncer(cfg) as fw:\n        fw.wait_for_lines_fnmatch(\n            [\n                \"*using 'REJECT' as deny_action*\",\n            ]\n        )\n        fw.proc.wait(timeout=5)\n        assert not fw.proc.is_running()\n\n    cfg[\"deny_action\"] = \"tarpit\"\n\n    with bouncer(cfg) as fw:\n        fw.wait_for_lines_fnmatch(\n            [\n                \"*using 'TARPIT' as deny_action*\",\n            ]\n        )\n        fw.proc.wait(timeout=5)\n        assert not fw.proc.is_running()\n\n    cfg[\"deny_action\"] = \"somethingelse\"\n\n    with bouncer(cfg) as fw:\n        fw.wait_for_lines_fnmatch(\n            [\n                \"*invalid deny_action 'somethingelse', must be one of DROP, REJECT, TARPIT*\",\n            ]\n        )\n        fw.proc.wait(timeout=5)\n        assert not fw.proc.is_running()\n"
  },
  {
    "path": "test/tests/bouncer/test_tls.py",
    "content": "import json\n\n\ndef test_tls_server(crowdsec, certs_dir, api_key_factory, bouncer, fw_cfg_factory):\n    \"\"\"TLS with server-only certificate\"\"\"\n    api_key = api_key_factory()\n\n    lapi_env = {\n        \"CACERT_FILE\": \"/etc/ssl/crowdsec/ca.crt\",\n        \"LAPI_CERT_FILE\": \"/etc/ssl/crowdsec/lapi.crt\",\n        \"LAPI_KEY_FILE\": \"/etc/ssl/crowdsec/lapi.key\",\n        \"USE_TLS\": \"true\",\n        \"LOCAL_API_URL\": \"https://localhost:8080\",\n        \"BOUNCER_KEY_bouncer\": api_key,\n    }\n\n    certs = certs_dir(lapi_hostname=\"lapi\")\n\n    volumes = {\n        certs: {\"bind\": \"/etc/ssl/crowdsec\", \"mode\": \"ro\"},\n    }\n\n    with crowdsec(environment=lapi_env, volumes=volumes) as cs:\n        cs.wait_for_log(\"*CrowdSec Local API listening*\")\n        # TODO: wait_for_https\n        cs.wait_for_http(8080, \"/health\", want_status=None)\n\n        port = cs.probe.get_bound_port(\"8080\")\n        cfg = fw_cfg_factory()\n        cfg[\"api_url\"] = f\"https://localhost:{port}\"\n        cfg[\"api_key\"] = api_key\n\n        with bouncer(cfg) as cb:\n            cb.wait_for_lines_fnmatch(\n                [\n                    \"*backend type: dry-run*\",\n                    \"*Using API key auth*\",\n                    \"*auth-api: auth with api key failed*\",\n                    \"*tls: failed to verify certificate: x509: certificate signed by unknown authority*\",\n                ]\n            )\n\n        cfg[\"ca_cert_path\"] = (certs / \"ca.crt\").as_posix()\n\n        with bouncer(cfg) as cb:\n            cb.wait_for_lines_fnmatch(\n                [\n                    \"*backend type: dry-run*\",\n                    \"*Using CA cert *ca.crt*\",\n                    \"*Using API key auth*\",\n                    \"*Processing new and deleted decisions*\",\n                ]\n            )\n\n\ndef test_tls_mutual(crowdsec, certs_dir, api_key_factory, bouncer, fw_cfg_factory, bouncer_under_test):\n    \"\"\"TLS with two-way bouncer/lapi authentication\"\"\"\n    lapi_env = {\n        \"CACERT_FILE\": \"/etc/ssl/crowdsec/ca.crt\",\n        \"LAPI_CERT_FILE\": \"/etc/ssl/crowdsec/lapi.crt\",\n        \"LAPI_KEY_FILE\": \"/etc/ssl/crowdsec/lapi.key\",\n        \"USE_TLS\": \"true\",\n        \"LOCAL_API_URL\": \"https://localhost:8080\",\n    }\n\n    certs = certs_dir(lapi_hostname=\"lapi\")\n\n    volumes = {\n        certs: {\"bind\": \"/etc/ssl/crowdsec\", \"mode\": \"ro\"},\n    }\n\n    with crowdsec(environment=lapi_env, volumes=volumes) as cs:\n        cs.wait_for_log(\"*CrowdSec Local API listening*\")\n        # TODO: wait_for_https\n        cs.wait_for_http(8080, \"/health\", want_status=None)\n\n        port = cs.probe.get_bound_port(\"8080\")\n        cfg = fw_cfg_factory()\n        cfg[\"api_url\"] = f\"https://localhost:{port}\"\n        cfg[\"ca_cert_path\"] = (certs / \"ca.crt\").as_posix()\n\n        cfg[\"cert_path\"] = (certs / \"agent.crt\").as_posix()\n        cfg[\"key_path\"] = (certs / \"agent.key\").as_posix()\n\n        with bouncer(cfg) as cb:\n            cb.wait_for_lines_fnmatch(\n                [\n                    \"*Starting crowdsec-firewall-bouncer*\",\n                    \"*Using CA cert*\",\n                    \"*Using cert auth with cert * and key *\",\n                    \"*API error: access forbidden*\",\n                ]\n            )\n\n        cs.wait_for_log(\"*client certificate OU ?agent-ou? doesn't match expected OU ?bouncer-ou?*\")\n\n        cfg[\"cert_path\"] = (certs / \"bouncer.crt\").as_posix()\n        cfg[\"key_path\"] = (certs / \"bouncer.key\").as_posix()\n\n        with bouncer(cfg) as cb:\n            cb.wait_for_lines_fnmatch(\n                [\n                    \"*backend type: dry-run*\",\n                    \"*Using CA cert*\",\n                    \"*Using cert auth with cert * and key *\",\n                    \"*Processing new and deleted decisions . . .*\",\n                ]\n            )\n\n            # check that the bouncer is registered\n            res = cs.cont.exec_run(\"cscli bouncers list -o json\")\n            assert res.exit_code == 0\n            bouncers = json.loads(res.output)\n            assert len(bouncers) == 1\n            assert bouncers[0][\"name\"].startswith(\"@\")\n            assert bouncers[0][\"auth_type\"] == \"tls\"\n            assert bouncers[0][\"type\"] == bouncer_under_test\n\n\ndef test_api_key_and_cert(crowdsec, certs_dir, api_key_factory, bouncer, fw_cfg_factory):\n    \"\"\"Attempt to send an api key and a certificate too\"\"\"\n    api_key = api_key_factory()\n\n    lapi_env = {\n        \"CACERT_FILE\": \"/etc/ssl/crowdsec/ca.crt\",\n        \"LAPI_CERT_FILE\": \"/etc/ssl/crowdsec/lapi.crt\",\n        \"LAPI_KEY_FILE\": \"/etc/ssl/crowdsec/lapi.key\",\n        \"USE_TLS\": \"true\",\n        \"LOCAL_API_URL\": \"https://localhost:8080\",\n        \"BOUNCER_KEY_bouncer\": api_key,\n    }\n\n    certs = certs_dir(lapi_hostname=\"lapi\")\n\n    volumes = {\n        certs: {\"bind\": \"/etc/ssl/crowdsec\", \"mode\": \"ro\"},\n    }\n\n    with crowdsec(environment=lapi_env, volumes=volumes) as cs:\n        cs.wait_for_log(\"*CrowdSec Local API listening*\")\n        cs.wait_for_http(8080, \"/health\", want_status=None)\n\n        port = cs.probe.get_bound_port(\"8080\")\n        cfg = fw_cfg_factory()\n        cfg[\"api_url\"] = f\"https://localhost:{port}\"\n        cfg[\"ca_cert_path\"] = (certs / \"ca.crt\").as_posix()\n        cfg[\"api_key\"] = api_key\n\n        cfg[\"cert_path\"] = (certs / \"bouncer.crt\").as_posix()\n        cfg[\"key_path\"] = (certs / \"bouncer.key\").as_posix()\n\n        cs.wait_for_log(\"*Starting processing data*\")\n\n        with bouncer(cfg) as cb:\n            cb.wait_for_lines_fnmatch(\n                [\n                    \"*Starting crowdsec-firewall-bouncer*\",\n                    \"*unable to configure bouncer: api client init: cannot use both API key and certificate auth*\",\n                ]\n            )\n"
  },
  {
    "path": "test/tests/bouncer/test_yaml_local.py",
    "content": "import os\n\n\ndef test_yaml_local(bouncer, fw_cfg_factory):\n    cfg = fw_cfg_factory()\n\n    cfg.pop(\"mode\")\n\n    with bouncer(cfg) as fw:\n        fw.wait_for_lines_fnmatch(\n            [\n                \"*unable to load configuration: config does not contain 'mode'*\",\n            ]\n        )\n        fw.proc.wait(timeout=0.2)\n        assert not fw.proc.is_running()\n\n    config_local = {\"mode\": \"whatever\"}\n\n    with bouncer(cfg, config_local=config_local) as fw:\n        fw.wait_for_lines_fnmatch(\n            [\n                \"*firewall 'whatever' is not supported*\",\n            ]\n        )\n        fw.proc.wait(timeout=0.2)\n        assert not fw.proc.is_running()\n\n    # variable expansion\n\n    config_local = {\"mode\": \"$BOUNCER_MODE\"}\n\n    os.environ[\"BOUNCER_MODE\"] = \"fromenv\"\n\n    with bouncer(cfg, config_local=config_local) as fw:\n        fw.wait_for_lines_fnmatch(\n            [\n                \"*firewall 'fromenv' is not supported*\",\n            ]\n        )\n        fw.proc.wait(timeout=0.2)\n        assert not fw.proc.is_running()\n"
  },
  {
    "path": "test/tests/conftest.py",
    "content": "import contextlib\n\nimport pytest\nfrom pytest_cs import plugin\n\n# pytest_exception_interact = plugin.pytest_exception_interact\n\n\n# provide the name of the bouncer binary to test\n@pytest.fixture(scope=\"session\")\ndef bouncer_under_test():\n    return \"crowdsec-firewall-bouncer\"\n\n\n# Create a lapi container, register a bouncer and run it with the updated config.\n# - Return context manager that yields a tuple of (bouncer, lapi)\n@pytest.fixture(scope=\"session\")\ndef bouncer_with_lapi(bouncer, crowdsec, fw_cfg_factory, api_key_factory: plugin.ApiKeyFactoryType):\n    @contextlib.contextmanager\n    def closure(config_lapi=None, config_bouncer=None, api_key=None):\n        if config_bouncer is None:\n            config_bouncer = {}\n        if config_lapi is None:\n            config_lapi = {}\n        # can be overridden by config_lapi + config_bouncer\n        api_key = api_key_factory()\n        env = {\n            \"BOUNCER_KEY_custom\": api_key,\n        }\n        try:\n            env.update(config_lapi)\n            with crowdsec(environment=env) as lapi:\n                lapi.wait_for_http(8080, \"/health\")\n                port = lapi.probe.get_bound_port(\"8080\")\n                cfg = fw_cfg_factory()\n                cfg[\"api_url\"] = f\"http://localhost:{port}/\"\n                cfg[\"api_key\"] = api_key\n                cfg.update(config_bouncer)\n                with bouncer(cfg) as cb:\n                    yield cb, lapi\n        finally:\n            pass\n\n    yield closure\n\n\n_default_config = {\n    \"mode\": \"dry-run\",\n    \"log_level\": \"info\",\n}\n\n\n@pytest.fixture(scope=\"session\")\ndef fw_cfg_factory():\n    def closure(**kw):\n        cfg = _default_config.copy()\n        cfg |= kw\n        return cfg | kw\n\n    yield closure\n"
  },
  {
    "path": "test/tests/install/__init__.py",
    "content": ""
  },
  {
    "path": "test/tests/install/no_crowdsec/__init__.py",
    "content": ""
  },
  {
    "path": "test/tests/install/no_crowdsec/test_no_crowdsec_deb.py",
    "content": "import os\nimport subprocess\n\nimport pytest\n\npytestmark = pytest.mark.deb\n\n\ndef test_deb_install_purge(deb_package_path, bouncer_under_test, must_be_root):\n    # test the full install-purge cycle, doing that in separate tests would\n    # be a bit too much\n\n    # TODO: remove and reinstall\n\n    # use the package built as non-root by test_deb_build()\n    assert deb_package_path.exists(), f\"This test requires {deb_package_path}\"\n\n    bouncer_exe = f\"/usr/bin/{bouncer_under_test}\"\n    assert not os.path.exists(bouncer_exe)\n\n    config = f\"/etc/crowdsec/bouncers/{bouncer_under_test}.yaml\"\n    assert not os.path.exists(config)\n\n    # install the package\n    p = subprocess.run(\n        [\"dpkg\", \"--install\", deb_package_path.as_posix()],\n        stdout=subprocess.PIPE,\n        stderr=subprocess.PIPE,\n        encoding=\"utf-8\",\n    )\n    assert p.returncode == 0, f\"Failed to install {deb_package_path}\"\n\n    assert os.path.exists(bouncer_exe)\n    assert os.stat(bouncer_exe).st_mode & 0o777 == 0o755\n\n    assert os.path.exists(config)\n    assert os.stat(config).st_mode & 0o777 == 0o600\n\n    p = subprocess.check_output([\"dpkg-deb\", \"-f\", deb_package_path.as_posix(), \"Package\"], encoding=\"utf-8\")\n    package_name = p.strip()\n\n    p = subprocess.run(\n        [\"dpkg\", \"--purge\", package_name],\n        stdout=subprocess.PIPE,\n        stderr=subprocess.PIPE,\n        encoding=\"utf-8\",\n    )\n    assert p.returncode == 0, f\"Failed to purge {package_name}\"\n\n    assert not os.path.exists(bouncer_exe)\n    assert not os.path.exists(config)\n"
  },
  {
    "path": "test/tests/install/no_crowdsec/test_no_crowdsec_scripts.py",
    "content": "import os\nimport re\n\nimport pexpect\nimport pytest\nimport yaml\n\nBOUNCER = \"crowdsec-firewall-bouncer\"\nCONFIG = f\"/etc/crowdsec/bouncers/{BOUNCER}.yaml\"\n\n\n@pytest.mark.dependency\ndef test_install_no_crowdsec(project_repo, bouncer_binary, must_be_root):\n    c = pexpect.spawn(\"/usr/bin/sh\", [\"scripts/install.sh\"], cwd=project_repo)\n\n    c.expect(f\"Installing {BOUNCER}\")\n    c.expect(\"iptables found\")\n    c.expect(\"nftables found\")\n    c.expect(re.escape(\"Found nftables (default) and iptables, which firewall do you want to use (nftables/iptables)\"))\n    c.sendline(\"nftables\")\n    c.expect(\"WARN.* cscli not found, you will need to generate an api key.\")\n    c.expect(f\"WARN.* service not started. You need to get an API key and configure it in {CONFIG}\")\n    c.expect(f\"The {BOUNCER} service has been installed.\")\n    c.wait()\n    assert c.terminated\n    assert c.exitstatus == 0\n\n    with open(CONFIG) as f:\n        y = yaml.safe_load(f)\n        assert y[\"api_key\"] == \"<API_KEY>\"\n        assert y[\"mode\"] == \"nftables\"\n\n    assert os.path.exists(CONFIG)\n    assert os.stat(CONFIG).st_mode & 0o777 == 0o600\n    assert os.path.exists(f\"/usr/local/bin/{BOUNCER}\")\n    assert os.stat(f\"/usr/local/bin/{BOUNCER}\").st_mode & 0o777 == 0o755\n\n    c = pexpect.spawn(\"/usr/bin/sh\", [\"scripts/install.sh\"], cwd=project_repo)\n\n    c.expect(f\"ERR.* /usr/local/bin/{BOUNCER} is already installed. Exiting\")\n\n\n@pytest.mark.dependency(depends=[\"test_install_no_crowdsec\"])\ndef test_upgrade_no_crowdsec(project_repo, must_be_root):\n    os.remove(f\"/usr/local/bin/{BOUNCER}\")\n\n    c = pexpect.spawn(\"/usr/bin/sh\", [\"scripts/upgrade.sh\"], cwd=project_repo)\n\n    c.expect(f\"{BOUNCER} upgraded successfully\")\n    c.wait()\n    assert c.terminated\n    assert c.exitstatus == 0\n\n    assert os.path.exists(f\"/usr/local/bin/{BOUNCER}\")\n    assert os.stat(f\"/usr/local/bin/{BOUNCER}\").st_mode & 0o777 == 0o755\n\n\n@pytest.mark.dependency(depends=[\"test_upgrade_no_crowdsec\"])\ndef test_uninstall_no_crowdsec(project_repo, must_be_root):\n    c = pexpect.spawn(\"/usr/bin/sh\", [\"scripts/uninstall.sh\"], cwd=project_repo)\n\n    c.expect(f\"{BOUNCER} has been successfully uninstalled\")\n    c.wait()\n    assert c.terminated\n    assert c.exitstatus == 0\n\n    assert not os.path.exists(CONFIG)\n    assert not os.path.exists(f\"/usr/local/bin/{BOUNCER}\")\n"
  },
  {
    "path": "test/tests/install/with_crowdsec/__init__.py",
    "content": ""
  },
  {
    "path": "test/tests/install/with_crowdsec/test_crowdsec_deb.py",
    "content": "import os\nimport subprocess\nfrom pathlib import Path\n\nimport pytest\nimport yaml\nfrom zxcvbn import zxcvbn\n\npytestmark = pytest.mark.deb\n\n\n# TODO: use fixtures to install/purge and register/unregister bouncers\n\n\ndef test_deb_install_purge(deb_package_path, bouncer_under_test, must_be_root):\n    # test the full install-purge cycle, doing that in separate tests would\n    # be a bit too much\n\n    # TODO: remove and reinstall\n\n    # use the package built as non-root by test_deb_build()\n    assert deb_package_path.exists(), f\"This test requires {deb_package_path}\"\n\n    p = subprocess.check_output([\"dpkg-deb\", \"-f\", deb_package_path.as_posix(), \"Package\"], encoding=\"utf-8\")\n    package_name = p.strip()\n\n    subprocess.check_call([\"dpkg\", \"--purge\", package_name])\n\n    bouncer_exe = f\"/usr/bin/{bouncer_under_test}\"\n    assert not os.path.exists(bouncer_exe)\n\n    config = f\"/etc/crowdsec/bouncers/{bouncer_under_test}.yaml\"\n    assert not os.path.exists(config)\n\n    # install the package\n    p = subprocess.run(\n        [\"dpkg\", \"--install\", deb_package_path.as_posix()],\n        stdout=subprocess.PIPE,\n        stderr=subprocess.PIPE,\n        encoding=\"utf-8\",\n    )\n    assert p.returncode == 0, f\"Failed to install {deb_package_path}\"\n\n    assert os.path.exists(bouncer_exe)\n    assert os.stat(bouncer_exe).st_mode & 0o777 == 0o755\n\n    assert os.path.exists(config)\n    assert os.stat(config).st_mode & 0o777 == 0o600\n\n    with open(config) as f:\n        cfg = yaml.safe_load(f)\n        api_key = cfg[\"api_key\"]\n        # the api key has been set to a random value\n        assert zxcvbn(api_key)[\"score\"] == 4, f\"weak api_key: '{api_key}'\"\n\n    with open(config + \".id\") as f:\n        bouncer_name = f.read().strip()\n\n    p = subprocess.check_output([\"cscli\", \"bouncers\", \"list\", \"-o\", \"json\"])\n    bouncers = yaml.safe_load(p)\n    assert len([b for b in bouncers if b[\"name\"] == bouncer_name]) == 1\n\n    p = subprocess.run(\n        [\"dpkg\", \"--purge\", package_name],\n        stdout=subprocess.PIPE,\n        stderr=subprocess.PIPE,\n        encoding=\"utf-8\",\n    )\n    assert p.returncode == 0, f\"Failed to purge {package_name}\"\n\n    assert not os.path.exists(bouncer_exe)\n    assert not os.path.exists(config)\n\n\ndef test_deb_install_purge_yaml_local(deb_package_path, bouncer_under_test, must_be_root):\n    \"\"\"\n    Check .deb package installation with:\n\n    - a pre-existing .yaml.local file with an api key\n    - a pre-registered bouncer\n\n    => the configuration files are not touched (no new api key)\n    \"\"\"\n    assert deb_package_path.exists(), f\"This test requires {deb_package_path}\"\n\n    p = subprocess.check_output([\"dpkg-deb\", \"-f\", deb_package_path.as_posix(), \"Package\"], encoding=\"utf-8\")\n    package_name = p.strip()\n\n    subprocess.check_call([\"dpkg\", \"--purge\", package_name])\n    subprocess.run([\"cscli\", \"bouncers\", \"delete\", \"testbouncer\"])\n\n    bouncer_exe = f\"/usr/bin/{bouncer_under_test}\"\n    config = Path(f\"/etc/crowdsec/bouncers/{bouncer_under_test}.yaml\")\n    config.parent.mkdir(parents=True, exist_ok=True)\n\n    subprocess.check_call([\"cscli\", \"bouncers\", \"add\", \"testbouncer\", \"-k\", \"123456\"])\n\n    with open(config.with_suffix(\".yaml.local\"), \"w\") as f:\n        f.write('api_key: \"123456\"')\n\n    p = subprocess.run(\n        [\"dpkg\", \"--install\", deb_package_path.as_posix()],\n        stdout=subprocess.PIPE,\n        stderr=subprocess.PIPE,\n        encoding=\"utf-8\",\n    )\n    assert p.returncode == 0, f\"Failed to install {deb_package_path}\"\n\n    assert os.path.exists(bouncer_exe)\n    assert os.path.exists(config)\n\n    with open(config) as f:\n        cfg = yaml.safe_load(f)\n        api_key = cfg[\"api_key\"]\n        # the api key has not been set\n        assert api_key == \"${API_KEY}\"\n\n    p = subprocess.check_output([bouncer_exe, \"-c\", config, \"-T\"])\n    merged_config = yaml.safe_load(p)\n    assert merged_config[\"api_key\"] == \"123456\"\n\n    os.unlink(config.with_suffix(\".yaml.local\"))\n\n    p = subprocess.run(\n        [\"dpkg\", \"--purge\", package_name],\n        stdout=subprocess.PIPE,\n        stderr=subprocess.PIPE,\n        encoding=\"utf-8\",\n    )\n    assert p.returncode == 0, f\"Failed to purge {package_name}\"\n\n    assert not os.path.exists(bouncer_exe)\n    assert not os.path.exists(config)\n"
  },
  {
    "path": "test/tests/install/with_crowdsec/test_crowdsec_scripts.py",
    "content": "import os\nimport re\n\nimport pexpect\nimport pytest\nimport yaml\nfrom pytest_cs.lib import cscli, text\n\nBOUNCER = \"crowdsec-firewall-bouncer\"\nCONFIG = f\"/etc/crowdsec/bouncers/{BOUNCER}.yaml\"\n\n\n@pytest.mark.systemd_debug(BOUNCER)\n@pytest.mark.dependency\ndef test_install_crowdsec(project_repo, bouncer_binary, must_be_root):\n    c = pexpect.spawn(\"/usr/bin/sh\", [\"scripts/install.sh\"], encoding=\"utf-8\", cwd=project_repo, env={\"NO_COLOR\": \"1\"})\n\n    c.expect(f\"Installing {BOUNCER}\")\n    c.expect(\"iptables found\")\n    c.expect(\"nftables found\")\n    c.expect(re.escape(\"Found nftables (default) and iptables, which firewall do you want to use (nftables/iptables)\"))\n    c.sendline(\"nftables\")\n    c.expect(\"cscli found, generating bouncer api key.\")\n    c.expect(\"API Key: (.*)\")\n\n    api_key = text.nocolor(c.match.group(1).strip())\n    # XXX: what do we expect here ?\n    c.wait()\n    assert c.terminated\n    # XXX: partial configuration, the service won't start\n    # assert c.exitstatus == 0\n\n    # installed files\n    assert os.path.exists(CONFIG)\n    assert os.stat(CONFIG).st_mode & 0o777 == 0o600\n    assert os.path.exists(f\"/usr/local/bin/{BOUNCER}\")\n    assert os.stat(f\"/usr/local/bin/{BOUNCER}\").st_mode & 0o777 == 0o755\n\n    # configuration check\n    with open(CONFIG) as f:\n        y = yaml.safe_load(f)\n        assert y[\"api_key\"] == api_key\n\n    # the bouncer is registered\n    with open(f\"{CONFIG}.id\") as f:\n        bouncer_name = f.read().strip()\n\n    assert len(list(cscli.get_bouncers(name=bouncer_name))) == 1\n\n    c = pexpect.spawn(\"/usr/bin/sh\", [\"scripts/install.sh\"], encoding=\"utf-8\", cwd=project_repo)\n\n    c.expect(f\"ERR:.* /usr/local/bin/{BOUNCER} is already installed. Exiting\")\n\n\n@pytest.mark.dependency(depends=[\"test_install_crowdsec\"])\ndef test_upgrade_crowdsec(project_repo, must_be_root):\n    os.remove(f\"/usr/local/bin/{BOUNCER}\")\n\n    c = pexpect.spawn(\"/usr/bin/sh\", [\"scripts/upgrade.sh\"], encoding=\"utf-8\", cwd=project_repo)\n\n    c.expect(f\"{BOUNCER} upgraded successfully\")\n    c.wait()\n    assert c.terminated\n    assert c.exitstatus == 0\n\n    assert os.path.exists(f\"/usr/local/bin/{BOUNCER}\")\n    assert os.stat(f\"/usr/local/bin/{BOUNCER}\").st_mode & 0o777 == 0o755\n\n\n@pytest.mark.dependency(depends=[\"test_upgrade_crowdsec\"])\ndef test_uninstall_crowdsec(project_repo, must_be_root):\n    # the bouncer is registered\n    with open(f\"{CONFIG}.id\") as f:\n        bouncer_name = f.read().strip()\n\n    c = pexpect.spawn(\"/usr/bin/sh\", [\"scripts/uninstall.sh\"], encoding=\"utf-8\", cwd=project_repo)\n\n    c.expect(f\"{BOUNCER} has been successfully uninstalled\")\n    c.wait()\n    assert c.terminated\n    assert c.exitstatus == 0\n\n    # installed files\n    assert not os.path.exists(CONFIG)\n    assert not os.path.exists(f\"/usr/local/bin/{BOUNCER}\")\n\n    # the bouncer is unregistered\n    assert len(list(cscli.get_bouncers(name=bouncer_name))) == 0\n"
  },
  {
    "path": "test/tests/pkg/__init__.py",
    "content": ""
  },
  {
    "path": "test/tests/pkg/test_build_deb.py",
    "content": "import pytest\n\npytestmark = pytest.mark.deb\n\n\n# This test has the side effect of building the package and leaving it in the\n# project's parent directory.\ndef test_deb_build(deb_package, skip_unless_deb):\n    \"\"\"Test that the package can be built.\"\"\"\n    assert deb_package.exists(), f\"Package {deb_package} not found\"\n"
  },
  {
    "path": "test/tests/pkg/test_build_rpm.py",
    "content": "import pytest\n\npytestmark = pytest.mark.rpm\n\n\ndef test_rpm_build(rpm_package, skip_unless_rpm):\n    \"\"\"Test that the package can be built.\"\"\"\n    assert rpm_package.exists(), f\"Package {rpm_package} not found\"\n"
  },
  {
    "path": "test/tests/pkg/test_scripts_nonroot.py",
    "content": "import os\nimport subprocess\n\n\ndef test_scripts_nonroot(project_repo, bouncer_binary, must_be_nonroot):\n    assert os.geteuid() != 0, \"This test must be run as non-root\"\n\n    for script in [\"install.sh\", \"upgrade.sh\", \"uninstall.sh\"]:\n        c = subprocess.run(\n            [\"/usr/bin/sh\", f\"scripts/{script}\"],\n            stdout=subprocess.PIPE,\n            stderr=subprocess.PIPE,\n            cwd=project_repo,\n            encoding=\"utf-8\",\n        )\n\n        assert c.returncode == 1\n        assert c.stdout == \"\"\n        assert \"This script must be run as root\" in c.stderr\n"
  }
]