├── .circleci └── config.yml ├── .dockerignore ├── .github ├── ISSUE_TEMPLATE.md ├── dependabot.yml └── workflows │ ├── automerge-dependabot.yml │ ├── ci.yml │ ├── codeql.yml │ ├── container_description.yml │ └── fuzzing.yml ├── .gitignore ├── .promu.yml ├── CODE_OF_CONDUCT.md ├── Dockerfile ├── LICENSE ├── Makefile ├── Makefile.common ├── NOTICE ├── README.md ├── SECURITY.md ├── VERSION ├── alertparser ├── alert_parser.go ├── alert_parser_test.go ├── test_mixed_alerts.json ├── test_mixed_bucket.json ├── test_resolved_alerts.json ├── test_resolved_and_firing_oid_from_labels_alerts.json ├── test_resolved_default_firing_oid_alerts.json ├── test_resolved_default_resolved_oid_alerts.json ├── test_resolved_oid_from_labels_alerts.json └── test_unique_alert.json ├── commons ├── commons.go ├── commons_test.go ├── test_alerts.json ├── test_groups.json └── test_groups_alertname.json ├── configuration ├── configuration.go └── configuration_test.go ├── description-template.tpl ├── go.mod ├── go.sum ├── httpserver ├── http_server.go ├── http_server_test.go ├── test_mixed_alerts.json ├── test_mixed_traps.json ├── test_no_body.json ├── test_unprocessable_alerts.json └── test_wrong_oid_alerts.json ├── mibs └── SNMP-NOTIFIER-MIB.my ├── scripts ├── kubernetes │ ├── alertmanager-webhook-configuration.yaml │ ├── chart-values.yaml │ ├── secrets.yaml │ └── snmp-server.yaml └── local │ ├── listen.sh │ ├── snmptrapd.conf │ └── test_mixed_alerts.json ├── snmp_notifier.go ├── telemetry └── telemetry.go ├── test ├── integration.go └── integration_test.go ├── trapsender ├── test_mixed_bucket.json ├── test_mixed_bucket_user_objects.json ├── test_mixed_traps.json ├── test_mixed_traps_custom_base_oid.json ├── test_mixed_traps_user_objects.json ├── trap_sender.go └── trap_sender_test.go └── types └── types.go /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | --- 2 | # SNMP Notifier has switched to GitHub action. 3 | # Circle CI is not disabled repository-wise so that previous pull requests 4 | # continue working. 5 | # This file does not generate any CircleCI workflow. 6 | 7 | version: 2.1 8 | 9 | executors: 10 | golang: 11 | docker: 12 | - image: busybox 13 | 14 | jobs: 15 | noopjob: 16 | executor: golang 17 | 18 | steps: 19 | - run: 20 | command: "true" 21 | 22 | workflows: 23 | version: 2 24 | prometheus: 25 | jobs: 26 | - noopjob 27 | triggers: 28 | - schedule: 29 | cron: "0 0 30 2 *" 30 | filters: 31 | branches: 32 | only: 33 | - main -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | .build/ 2 | .tarballs/ 3 | 4 | !.build/linux-amd64/ 5 | !.build/linux-armv7/ 6 | !.build/linux-arm64/ 7 | !.build/linux-ppc64le/ 8 | !.build/linux-s390x/ 9 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | **What did you do?** 2 | 3 | **What did you expect to see?** 4 | 5 | **What did you see instead? Under which circumstances?** 6 | 7 | **Environment** 8 | 9 | * System information: 10 | 11 | insert output of `uname -srm` here 12 | 13 | * SNMP notifier version: 14 | 15 | insert output of `snmp_notifier --version` here 16 | 17 | * Alertmanager version: 18 | 19 | insert output of `alertmanager --version` here 20 | 21 | * Prometheus version: 22 | 23 | insert output of `prometheus -version` here (if relevant to the issue) 24 | 25 | * Alertmanager command line: 26 | ``` 27 | insert command line here 28 | ``` 29 | 30 | * SNMP notifier command line: 31 | ``` 32 | insert command line here 33 | ``` 34 | 35 | * Prometheus alert file: 36 | ``` 37 | insert configuration here (if relevant to the issue) 38 | ``` 39 | 40 | * Logs: 41 | ``` 42 | insert logs relevant to the issue here 43 | ``` 44 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "docker" 4 | directory: "/" 5 | schedule: 6 | interval: "monthly" 7 | - package-ecosystem: "github-actions" 8 | directories: 9 | - "/" 10 | - "/scripts" 11 | schedule: 12 | interval: "monthly" 13 | - package-ecosystem: gomod 14 | directory: "/" 15 | schedule: 16 | interval: daily 17 | time: "04:00" 18 | open-pull-requests-limit: 10 19 | -------------------------------------------------------------------------------- /.github/workflows/automerge-dependabot.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Dependabot auto-merge 3 | on: pull_request 4 | 5 | concurrency: 6 | group: ${{ github.workflow }}-${{ (github.event.pull_request && github.event.pull_request.number) || github.ref || github.run_id }} 7 | cancel-in-progress: true 8 | 9 | permissions: 10 | contents: read 11 | 12 | jobs: 13 | dependabot: 14 | permissions: 15 | contents: write 16 | pull-requests: write 17 | runs-on: ubuntu-latest 18 | if: ${{ github.event.pull_request.user.login == 'dependabot[bot]' && github.repository_owner == 'maxwo' }} 19 | steps: 20 | - name: Dependabot metadata 21 | id: metadata 22 | uses: dependabot/fetch-metadata@d7267f607e9d3fb96fc2fbe83e0af444713e90b7 # v2.3.0 23 | with: 24 | github-token: "${{ secrets.GITHUB_TOKEN }}" 25 | - name: Enable auto-merge for Dependabot PRs 26 | if: ${{steps.metadata.outputs.update-type == 'version-update:semver-minor' || steps.metadata.outputs.update-type == 'version-update:semver-patch'}} 27 | run: gh pr merge --auto --merge "$PR_URL" 28 | env: 29 | PR_URL: ${{github.event.pull_request.html_url}} 30 | GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} 31 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: CI 3 | on: 4 | pull_request: 5 | types: [opened, reopened] 6 | push: 7 | 8 | jobs: 9 | 10 | # golangci: 11 | # name: golangci-lint 12 | # runs-on: ubuntu-latest 13 | # steps: 14 | # - name: Checkout repository 15 | # uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 16 | # - name: Install Go 17 | # uses: actions/setup-go@0aaccfd150d50ccaeb58ebd88d36e91967a5f35b # v5.4.0 18 | # with: 19 | # go-version: 1.24.x 20 | # - name: Install snmp_exporter/generator dependencies 21 | # run: sudo apt-get update && sudo apt-get -y install libsnmp-dev 22 | # if: github.repository == 'prometheus/snmp_exporter' 23 | # - name: Lint 24 | # uses: golangci/golangci-lint-action@1481404843c368bc19ca9406f87d6e0fc97bdcfd # v7.0.0 25 | # with: 26 | # args: --verbose 27 | # # Make sure to sync this with Makefile.common and scripts/golangci-lint.yml. 28 | # version: v2.0.2 29 | 30 | test: 31 | name: Test 32 | runs-on: ubuntu-latest 33 | # Whenever the Go version is updated here, .promu.yml 34 | # should also be updated. 35 | container: 36 | image: quay.io/prometheus/golang-builder:1.24-base 37 | steps: 38 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 39 | - uses: prometheus/promci@443c7fc2397e946bc9f5029e313a9c3441b9b86d # v0.4.7 40 | - uses: ./.github/promci/actions/setup_environment 41 | - run: make 42 | - run: git diff --exit-code 43 | 44 | build: 45 | name: Build SNMP notifier for common architectures 46 | runs-on: ubuntu-latest 47 | if: | 48 | !(github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v2.')) 49 | && 50 | !(github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v3.')) 51 | && 52 | !(github.event_name == 'pull_request' && startsWith(github.event.pull_request.base.ref, 'release-')) 53 | && 54 | !(github.event_name == 'push' && github.event.ref == 'refs/heads/main') 55 | 56 | strategy: 57 | matrix: 58 | thread: [0, 1, 2] 59 | steps: 60 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 61 | - uses: prometheus/promci@443c7fc2397e946bc9f5029e313a9c3441b9b86d # v0.4.7 62 | - uses: ./.github/promci/actions/build 63 | with: 64 | promu_opts: "-p linux/amd64 -p windows/amd64 -p linux/arm64 -p darwin/amd64 -p darwin/arm64 -p linux/386" 65 | parallelism: 3 66 | thread: ${{ matrix.thread }} 67 | 68 | build_all: 69 | name: Build SNMP Notifier for all architectures 70 | runs-on: ubuntu-latest 71 | if: | 72 | (github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v2.')) 73 | || 74 | (github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v3.')) 75 | || 76 | (github.event_name == 'pull_request' && startsWith(github.event.pull_request.base.ref, 'release-')) 77 | || 78 | (github.event_name == 'push' && github.event.ref == 'refs/heads/main') 79 | strategy: 80 | matrix: 81 | thread: [ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11 ] 82 | 83 | # Whenever the Go version is updated here, .promu.yml 84 | # should also be updated. 85 | steps: 86 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 87 | - uses: prometheus/promci@443c7fc2397e946bc9f5029e313a9c3441b9b86d # v0.4.7 88 | - uses: ./.github/promci/actions/build 89 | with: 90 | parallelism: 12 91 | thread: ${{ matrix.thread }} 92 | 93 | build_all_status: 94 | # This status check aggregates the individual matrix jobs of the "Build 95 | # Prometheus for all architectures" step into a final status. Fails if a 96 | # single matrix job fails, succeeds if all matrix jobs succeed. 97 | # See https://github.com/orgs/community/discussions/4324 for why this is 98 | # needed 99 | name: Report status of build Prometheus for all architectures 100 | runs-on: ubuntu-latest 101 | needs: [build_all] 102 | # The run condition needs to include always(). Otherwise actions 103 | # behave unexpected: 104 | # only "needs" will make the Status Report be skipped if one of the builds fails https://docs.github.com/en/actions/writing-workflows/choosing-what-your-workflow-does/using-jobs-in-a-workflow#defining-prerequisite-jobs 105 | # And skipped is treated as success https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/collaborat[…]n-repositories-with-code-quality-features/about-status-checks 106 | # Adding always ensures that the status check is run independently of the 107 | # results of Build All 108 | if: always() && github.event_name == 'pull_request' && startsWith(github.event.pull_request.base.ref, 'release-') 109 | steps: 110 | - name: Successful build 111 | if: ${{ !(contains(needs.*.result, 'failure')) && !(contains(needs.*.result, 'cancelled')) }} 112 | run: exit 0 113 | - name: Failing or cancelled build 114 | if: ${{ contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') }} 115 | run: exit 1 116 | 117 | # fuzzing: 118 | # uses: ./.github/workflows/fuzzing.yml 119 | # if: github.event_name == 'pull_request' 120 | 121 | codeql: 122 | uses: ./.github/workflows/codeql.yml 123 | 124 | publish_main: 125 | name: Publish main branch artifacts 126 | runs-on: ubuntu-latest 127 | needs: [test, codeql, build_all] 128 | if: github.event_name == 'push' && github.event.ref == 'refs/heads/main' 129 | steps: 130 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 131 | - uses: prometheus/promci@443c7fc2397e946bc9f5029e313a9c3441b9b86d # v0.4.7 132 | - uses: ./.github/promci/actions/publish_main 133 | with: 134 | docker_hub_organization: maxwo 135 | docker_hub_login: ${{ secrets.DOCKER_HUB_LOGIN }} 136 | docker_hub_password: ${{ secrets.DOCKER_HUB_PASSWORD }} 137 | 138 | publish_release: 139 | name: Publish release artefacts 140 | runs-on: ubuntu-latest 141 | needs: [test, codeql, build_all] 142 | if: | 143 | (github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v2.')) 144 | || 145 | (github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v3.')) 146 | steps: 147 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 148 | - uses: prometheus/promci@443c7fc2397e946bc9f5029e313a9c3441b9b86d # v0.4.7 149 | - uses: ./.github/promci/actions/publish_release 150 | with: 151 | docker_hub_organization: maxwo 152 | docker_hub_login: ${{ secrets.DOCKER_HUB_LOGIN }} 153 | docker_hub_password: ${{ secrets.DOCKER_HUB_PASSWORD }} 154 | github_token: ${{ secrets.GITHUB_TOKEN }} -------------------------------------------------------------------------------- /.github/workflows/codeql.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL Advanced" 13 | 14 | on: 15 | workflow_call: 16 | push: 17 | branches: [ "main" ] 18 | schedule: 19 | - cron: '24 22 * * 1' 20 | 21 | jobs: 22 | analyze: 23 | name: Analyze (${{ matrix.language }}) 24 | # Runner size impacts CodeQL analysis time. To learn more, please see: 25 | # - https://gh.io/recommended-hardware-resources-for-running-codeql 26 | # - https://gh.io/supported-runners-and-hardware-resources 27 | # - https://gh.io/using-larger-runners (GitHub.com only) 28 | # Consider using larger runners or machines with greater resources for possible analysis time improvements. 29 | runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }} 30 | permissions: 31 | # required for all workflows 32 | security-events: write 33 | 34 | # required to fetch internal or private CodeQL packs 35 | packages: read 36 | 37 | # only required for workflows in private repositories 38 | actions: read 39 | contents: read 40 | 41 | strategy: 42 | fail-fast: false 43 | matrix: 44 | include: 45 | - language: actions 46 | build-mode: none 47 | - language: go 48 | build-mode: autobuild 49 | # CodeQL supports the following values keywords for 'language': 'actions', 'c-cpp', 'csharp', 'go', 'java-kotlin', 'javascript-typescript', 'python', 'ruby', 'swift' 50 | # Use `c-cpp` to analyze code written in C, C++ or both 51 | # Use 'java-kotlin' to analyze code written in Java, Kotlin or both 52 | # Use 'javascript-typescript' to analyze code written in JavaScript, TypeScript or both 53 | # To learn more about changing the languages that are analyzed or customizing the build mode for your analysis, 54 | # see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/customizing-your-advanced-setup-for-code-scanning. 55 | # If you are analyzing a compiled language, you can modify the 'build-mode' for that language to customize how 56 | # your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages 57 | steps: 58 | - name: Checkout repository 59 | uses: actions/checkout@v4 60 | 61 | # Add any setup steps before running the `github/codeql-action/init` action. 62 | # This includes steps like installing compilers or runtimes (`actions/setup-node` 63 | # or others). This is typically only required for manual builds. 64 | # - name: Setup runtime (example) 65 | # uses: actions/setup-example@v1 66 | 67 | # Initializes the CodeQL tools for scanning. 68 | - name: Initialize CodeQL 69 | uses: github/codeql-action/init@v3 70 | with: 71 | languages: ${{ matrix.language }} 72 | build-mode: ${{ matrix.build-mode }} 73 | # If you wish to specify custom queries, you can do so here or in a config file. 74 | # By default, queries listed here will override any specified in a config file. 75 | # Prefix the list here with "+" to use these queries and those in the config file. 76 | 77 | # For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs 78 | # queries: security-extended,security-and-quality 79 | 80 | # If the analyze step fails for one of the languages you are analyzing with 81 | # "We were unable to automatically build your code", modify the matrix above 82 | # to set the build mode to "manual" for that language. Then modify this step 83 | # to build your code. 84 | # ℹ️ Command-line programs to run using the OS shell. 85 | # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun 86 | - if: matrix.build-mode == 'manual' 87 | shell: bash 88 | run: | 89 | echo 'If you are using a "manual" build mode for one or more of the' \ 90 | 'languages you are analyzing, replace this with the commands to build' \ 91 | 'your code, for example:' 92 | echo ' make bootstrap' 93 | echo ' make release' 94 | exit 1 95 | 96 | - name: Perform CodeQL Analysis 97 | uses: github/codeql-action/analyze@v3 98 | with: 99 | category: "/language:${{matrix.language}}" 100 | -------------------------------------------------------------------------------- /.github/workflows/container_description.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Push README to Docker Hub 3 | on: 4 | push: 5 | paths: 6 | - "README.md" 7 | - ".github/workflows/container_description.yml" 8 | branches: [ main, master ] 9 | 10 | permissions: 11 | contents: read 12 | 13 | jobs: 14 | PushDockerHubReadme: 15 | runs-on: ubuntu-latest 16 | name: Push README to Docker Hub 17 | if: github.repository_owner == 'maxwo' # Don't run this workflow on forks. 18 | steps: 19 | - name: git checkout 20 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 21 | - name: Set docker hub repo name 22 | run: echo "DOCKER_REPO_NAME=$(make docker-repo-name)" >> $GITHUB_ENV 23 | - name: Push README to Dockerhub 24 | uses: christian-korneck/update-container-description-action@d36005551adeaba9698d8d67a296bd16fa91f8e8 # v1 25 | env: 26 | DOCKER_USER: ${{ secrets.DOCKER_HUB_LOGIN }} 27 | DOCKER_PASS: ${{ secrets.DOCKER_HUB_PASSWORD }} 28 | with: 29 | destination_container_repo: ${{ env.DOCKER_REPO_NAME }} 30 | provider: dockerhub 31 | short_description: ${{ env.DOCKER_REPO_NAME }} 32 | # Empty string results in README-containers.md being pushed if it 33 | # exists. Otherwise, README.md is pushed. 34 | readme_file: '' 35 | -------------------------------------------------------------------------------- /.github/workflows/fuzzing.yml: -------------------------------------------------------------------------------- 1 | name: CIFuzz 2 | on: 3 | workflow_call: 4 | permissions: 5 | contents: read 6 | 7 | jobs: 8 | Fuzzing: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: Build Fuzzers 12 | id: build 13 | uses: google/oss-fuzz/infra/cifuzz/actions/build_fuzzers@master 14 | with: 15 | oss-fuzz-project-name: "prometheus" 16 | dry-run: false 17 | - name: Run Fuzzers 18 | uses: google/oss-fuzz/infra/cifuzz/actions/run_fuzzers@master 19 | with: 20 | oss-fuzz-project-name: "prometheus" 21 | fuzz-seconds: 600 22 | dry-run: false 23 | - name: Upload Crash 24 | uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 25 | if: failure() && steps.build.outcome == 'success' 26 | with: 27 | name: artifacts 28 | path: ./out/artifacts 29 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Compiled Object files, Static and Dynamic libs (Shared Objects) 2 | *.o 3 | *.a 4 | *.so 5 | 6 | # Idea 7 | .idea 8 | 9 | # Folders 10 | _obj 11 | _test 12 | 13 | # Architecture specific extensions/prefixes 14 | *.[568vq] 15 | [568vq].out 16 | 17 | *.cgo1.go 18 | *.cgo2.c 19 | _cgo_defun.c 20 | _cgo_gotypes.go 21 | _cgo_export.* 22 | 23 | _testmain.go 24 | 25 | *.exe 26 | dependencies-stamp 27 | /snmp_notifier 28 | /.build 29 | /.release 30 | /.tarballs 31 | .deps 32 | *.tar.gz 33 | on 34 | -------------------------------------------------------------------------------- /.promu.yml: -------------------------------------------------------------------------------- 1 | go: 2 | version: 1.24 3 | repository: 4 | path: github.com/maxwo/snmp_notifier 5 | build: 6 | prefix: . 7 | flags: -a -tags netgo 8 | ldflags: | 9 | -s 10 | -X github.com/prometheus/common/version.Version={{.Version}} 11 | -X github.com/prometheus/common/version.Revision={{.Revision}} 12 | -X github.com/prometheus/common/version.Branch={{.Branch}} 13 | -X github.com/prometheus/common/version.BuildUser={{user}}@{{host}} 14 | -X github.com/prometheus/common/version.BuildDate={{date "20060102-15:04:05"}} 15 | tarball: 16 | files: 17 | - description-template.tpl 18 | - LICENSE 19 | - NOTICE 20 | crossbuild: 21 | platforms: 22 | - aix 23 | - darwin 24 | - dragonfly 25 | - freebsd 26 | - illumos 27 | - linux/386 28 | - linux/amd64 29 | - linux/arm 30 | - linux/arm64 31 | - linux/mips 32 | - linux/mips64 33 | - linux/mips64le 34 | - linux/mipsle 35 | - linux/ppc64le 36 | - linux/riscv64 37 | - linux/s390x 38 | - netbsd 39 | - openbsd/386 40 | - openbsd/amd64 41 | - openbsd/arm64 42 | - windows 43 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # SNMP Notifier Code of Conduct 2 | 3 | SNMP Notifier project follows the [CNCF Code of Conduct](https://github.com/cncf/foundation/blob/main/code-of-conduct.md). 4 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | ARG ARCH="amd64" 2 | ARG OS="linux" 3 | 4 | FROM debian AS builder 5 | 6 | ARG ARCH="amd64" 7 | ARG OS="linux" 8 | 9 | RUN mkdir -p /rootdir/etc/snmp_notifier 10 | COPY .build/${OS}-${ARCH}/snmp_notifier /rootdir/bin/snmp_notifier 11 | COPY description-template.tpl /rootdir/etc/snmp_notifier/description-template.tpl 12 | COPY LICENSE NOTICE /rootdir/ 13 | 14 | FROM quay.io/prometheus/busybox-${OS}-${ARCH}:latest 15 | LABEL maintainer="Maxime Wojtczak " 16 | 17 | COPY --from=builder rootdir / 18 | 19 | EXPOSE 9464 20 | ENTRYPOINT [ "/bin/snmp_notifier" ] 21 | CMD ["--trap.description-template=/etc/snmp_notifier/description-template.tpl"] 22 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright 2018 Maxime Wojtczak 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. 203 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # Copyright 2018 Maxime Wojtczak 2 | # Licensed under the Apache License, Version 2.0 (the "License"); 3 | # you may not use this file except in compliance with the License. 4 | # You may obtain a copy of the License at 5 | # 6 | # http://www.apache.org/licenses/LICENSE-2.0 7 | # 8 | # Unless required by applicable law or agreed to in writing, software 9 | # distributed under the License is distributed on an "AS IS" BASIS, 10 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | # See the License for the specific language governing permissions and 12 | # limitations under the License. 13 | 14 | default: precheck style unused build test 15 | 16 | include Makefile.common 17 | 18 | STATICCHECK_IGNORE = 19 | 20 | DOCKER_IMAGE_NAME := snmp-notifier 21 | 22 | ifdef DEBUG 23 | bindata_flags = -debug 24 | endif 25 | 26 | mkfile_path := $(abspath $(lastword $(MAKEFILE_LIST))) 27 | mkfile_dir := $(dir $(mkfile_path)) 28 | 29 | install-docker: 30 | apt-get update 31 | apt-get install --yes ca-certificates curl gnupg 32 | mkdir -p /etc/apt/keyrings 33 | curl -fsSL https://download.docker.com/linux/debian/gpg | gpg --dearmor -o /etc/apt/keyrings/docker.gpg 34 | echo "deb [arch=$(shell dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/debian buster stable" | tee /etc/apt/sources.list.d/docker.list > /dev/null 35 | apt-get update 36 | apt-get install --yes docker-ce-cli 37 | 38 | listen: 39 | snmptrapd -m ALL -m +SNMP-NOTIFIER-MIB -M +$(mkfile_dir)/mibs/ -f -Of -Lo -c scripts/local/snmptrapd.conf 40 | 41 | install-github-release: 42 | apt-get update 43 | apt-get install --yes bzip2 44 | mkdir -v -p ${HOME}/bin 45 | curl -L 'https://github.com/github-release/github-release/releases/download/v0.7.2/linux-amd64-github-release.tar.bz2' | tar xvjf - --strip-components 3 -C ${HOME}/bin 46 | 47 | k8s-install: k8s-snmp-server-install k8s-snmp-notifier-install 48 | 49 | k8s-helm-update: 50 | helm repo add prometheus-community https://prometheus-community.github.io/helm-charts 51 | helm repo update 52 | 53 | k8s-prometheus-install: k8s-helm-update 54 | helm install prometheus prometheus-community/kube-prometheus-stack 55 | 56 | k8s-snmp-server-install: 57 | kubectl create configmap snmp-notifier-mib --from-file=mibs/SNMP-NOTIFIER-MIB.my 58 | kubectl apply -f scripts/kubernetes/snmp-server.yaml 59 | 60 | k8s-snmp-notifier-install: k8s-prometheus-install 61 | kubectl apply -f scripts/kubernetes/secrets.yaml 62 | helm install snmp-notifier prometheus-community/alertmanager-snmp-notifier --values scripts/kubernetes/chart-values.yaml 63 | kubectl apply -f scripts/kubernetes/alertmanager-webhook-configuration.yaml 64 | 65 | k8s-cleanup: 66 | kubectl delete -f scripts/kubernetes/alertmanager-webhook-configuration.yaml 67 | helm uninstall snmp-notifier || true 68 | kubectl delete -f scripts/kubernetes/secrets.yaml || true 69 | helm uninstall prometheus || true 70 | kubectl delete -f scripts/kubernetes/snmp-server.yaml || true 71 | kubectl delete configmap snmp-notifier-mib || true 72 | -------------------------------------------------------------------------------- /Makefile.common: -------------------------------------------------------------------------------- 1 | # Copyright 2018 The Prometheus Authors 2 | # Licensed under the Apache License, Version 2.0 (the "License"); 3 | # you may not use this file except in compliance with the License. 4 | # You may obtain a copy of the License at 5 | # 6 | # http://www.apache.org/licenses/LICENSE-2.0 7 | # 8 | # Unless required by applicable law or agreed to in writing, software 9 | # distributed under the License is distributed on an "AS IS" BASIS, 10 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | # See the License for the specific language governing permissions and 12 | # limitations under the License. 13 | 14 | 15 | # A common Makefile that includes rules to be reused in different prometheus projects. 16 | # !!! Open PRs only against the prometheus/prometheus/Makefile.common repository! 17 | 18 | # Example usage : 19 | # Create the main Makefile in the root project directory. 20 | # include Makefile.common 21 | # customTarget: 22 | # @echo ">> Running customTarget" 23 | # 24 | 25 | # Ensure GOBIN is not set during build so that promu is installed to the correct path 26 | unexport GOBIN 27 | 28 | GO ?= go 29 | GOFMT ?= $(GO)fmt 30 | FIRST_GOPATH := $(firstword $(subst :, ,$(shell $(GO) env GOPATH))) 31 | GOOPTS ?= 32 | GOHOSTOS ?= $(shell $(GO) env GOHOSTOS) 33 | GOHOSTARCH ?= $(shell $(GO) env GOHOSTARCH) 34 | 35 | GO_VERSION ?= $(shell $(GO) version) 36 | GO_VERSION_NUMBER ?= $(word 3, $(GO_VERSION)) 37 | PRE_GO_111 ?= $(shell echo $(GO_VERSION_NUMBER) | grep -E 'go1\.(10|[0-9])\.') 38 | 39 | PROMU := $(FIRST_GOPATH)/bin/promu 40 | pkgs = ./... 41 | 42 | ifeq (arm, $(GOHOSTARCH)) 43 | GOHOSTARM ?= $(shell GOARM= $(GO) env GOARM) 44 | GO_BUILD_PLATFORM ?= $(GOHOSTOS)-$(GOHOSTARCH)v$(GOHOSTARM) 45 | else 46 | GO_BUILD_PLATFORM ?= $(GOHOSTOS)-$(GOHOSTARCH) 47 | endif 48 | 49 | GOTEST := $(GO) test 50 | GOTEST_DIR := 51 | ifneq ($(CIRCLE_JOB),) 52 | ifneq ($(shell command -v gotestsum 2> /dev/null),) 53 | GOTEST_DIR := test-results 54 | GOTEST := gotestsum --junitfile $(GOTEST_DIR)/unit-tests.xml -- 55 | endif 56 | endif 57 | 58 | PROMU_VERSION ?= 0.17.0 59 | PROMU_URL := https://github.com/prometheus/promu/releases/download/v$(PROMU_VERSION)/promu-$(PROMU_VERSION).$(GO_BUILD_PLATFORM).tar.gz 60 | 61 | SKIP_GOLANGCI_LINT := 62 | GOLANGCI_LINT := 63 | GOLANGCI_LINT_OPTS ?= 64 | GOLANGCI_LINT_VERSION ?= v2.0.2 65 | # golangci-lint only supports linux, darwin and windows platforms on i386/amd64/arm64. 66 | # windows isn't included here because of the path separator being different. 67 | ifeq ($(GOHOSTOS),$(filter $(GOHOSTOS),linux darwin)) 68 | ifeq ($(GOHOSTARCH),$(filter $(GOHOSTARCH),amd64 i386 arm64)) 69 | # If we're in CI and there is an Actions file, that means the linter 70 | # is being run in Actions, so we don't need to run it here. 71 | ifneq (,$(SKIP_GOLANGCI_LINT)) 72 | GOLANGCI_LINT := 73 | else ifeq (,$(CIRCLE_JOB)) 74 | GOLANGCI_LINT := $(FIRST_GOPATH)/bin/golangci-lint 75 | else ifeq (,$(wildcard .github/workflows/golangci-lint.yml)) 76 | GOLANGCI_LINT := $(FIRST_GOPATH)/bin/golangci-lint 77 | endif 78 | endif 79 | endif 80 | 81 | PREFIX ?= $(shell pwd) 82 | BIN_DIR ?= $(shell pwd) 83 | DOCKER_IMAGE_TAG ?= $(subst /,-,$(shell git rev-parse --abbrev-ref HEAD)) 84 | DOCKERFILE_PATH ?= ./Dockerfile 85 | DOCKERBUILD_CONTEXT ?= ./ 86 | DOCKER_REPO ?= maxwo 87 | DOCKER_ARCHS ?= amd64 armv7 arm64 ppc64le s390x 88 | 89 | BUILD_DOCKER_ARCHS = $(addprefix common-docker-,$(DOCKER_ARCHS)) 90 | PUBLISH_DOCKER_ARCHS = $(addprefix common-docker-publish-,$(DOCKER_ARCHS)) 91 | TAG_DOCKER_ARCHS = $(addprefix common-docker-tag-latest-,$(DOCKER_ARCHS)) 92 | 93 | SANITIZED_DOCKER_IMAGE_TAG := $(subst +,-,$(DOCKER_IMAGE_TAG)) 94 | 95 | ifeq ($(GOHOSTARCH),amd64) 96 | ifeq ($(GOHOSTOS),$(filter $(GOHOSTOS),linux freebsd darwin windows)) 97 | # Only supported on amd64 98 | # test-flags := -race 99 | endif 100 | endif 101 | 102 | # This rule is used to forward a target like "build" to "common-build". This 103 | # allows a new "build" target to be defined in a Makefile which includes this 104 | # one and override "common-build" without override warnings. 105 | %: common-% ; 106 | 107 | .PHONY: common-all 108 | common-all: precheck style check_license lint yamllint unused build test 109 | 110 | .PHONY: common-style 111 | common-style: 112 | @echo ">> checking code style" 113 | @fmtRes=$$($(GOFMT) -d $$(find . -path ./vendor -prune -o -name '*.go' -print)); \ 114 | if [ -n "$${fmtRes}" ]; then \ 115 | echo "gofmt checking failed!"; echo "$${fmtRes}"; echo; \ 116 | echo "Please ensure you are using $$($(GO) version) for formatting code."; \ 117 | exit 1; \ 118 | fi 119 | 120 | .PHONY: common-check_license 121 | common-check_license: 122 | @echo ">> checking license header" 123 | @licRes=$$(for file in $$(find . -type f -iname '*.go' ! -path './vendor/*') ; do \ 124 | awk 'NR<=3' $$file | grep -Eq "(Copyright|generated|GENERATED)" || echo $$file; \ 125 | done); \ 126 | if [ -n "$${licRes}" ]; then \ 127 | echo "license header checking failed:"; echo "$${licRes}"; \ 128 | exit 1; \ 129 | fi 130 | 131 | .PHONY: common-deps 132 | common-deps: 133 | @echo ">> getting dependencies" 134 | $(GO) mod download 135 | 136 | .PHONY: update-go-deps 137 | update-go-deps: 138 | @echo ">> updating Go dependencies" 139 | @for m in $$($(GO) list -mod=readonly -m -f '{{ if and (not .Indirect) (not .Main)}}{{.Path}}{{end}}' all); do \ 140 | $(GO) get -d $$m; \ 141 | done 142 | $(GO) mod tidy 143 | 144 | .PHONY: common-test-short 145 | common-test-short: $(GOTEST_DIR) 146 | @echo ">> running short tests" 147 | $(GOTEST) -short $(GOOPTS) $(pkgs) 148 | 149 | .PHONY: common-test 150 | common-test: $(GOTEST_DIR) 151 | @echo ">> running all tests" 152 | $(GOTEST) $(test-flags) $(GOOPTS) $(pkgs) 153 | 154 | $(GOTEST_DIR): 155 | @mkdir -p $@ 156 | 157 | .PHONY: common-format 158 | common-format: 159 | @echo ">> formatting code" 160 | $(GO) fmt $(pkgs) 161 | 162 | .PHONY: common-vet 163 | common-vet: 164 | @echo ">> vetting code" 165 | $(GO) vet $(GOOPTS) $(pkgs) 166 | 167 | .PHONY: common-lint 168 | common-lint: $(GOLANGCI_LINT) 169 | ifdef GOLANGCI_LINT 170 | @echo ">> running golangci-lint" 171 | $(GOLANGCI_LINT) run $(GOLANGCI_LINT_OPTS) $(pkgs) 172 | endif 173 | 174 | .PHONY: common-lint-fix 175 | common-lint-fix: $(GOLANGCI_LINT) 176 | ifdef GOLANGCI_LINT 177 | @echo ">> running golangci-lint fix" 178 | $(GOLANGCI_LINT) run --fix $(GOLANGCI_LINT_OPTS) $(pkgs) 179 | endif 180 | 181 | .PHONY: common-yamllint 182 | common-yamllint: 183 | @echo ">> running yamllint on all YAML files in the repository" 184 | ifeq (, $(shell command -v yamllint 2> /dev/null)) 185 | @echo "yamllint not installed so skipping" 186 | else 187 | yamllint . 188 | endif 189 | 190 | # For backward-compatibility. 191 | .PHONY: common-staticcheck 192 | common-staticcheck: lint 193 | 194 | .PHONY: common-unused 195 | common-unused: 196 | @echo ">> running check for unused/missing packages in go.mod" 197 | $(GO) mod tidy 198 | @git diff --exit-code -- go.sum go.mod 199 | 200 | .PHONY: common-build 201 | common-build: promu 202 | @echo ">> building binaries" 203 | $(PROMU) build --prefix $(PREFIX) $(PROMU_BINARIES) 204 | 205 | .PHONY: common-tarball 206 | common-tarball: promu 207 | @echo ">> building release tarball" 208 | $(PROMU) tarball --prefix $(PREFIX) $(BIN_DIR) 209 | 210 | .PHONY: common-docker-repo-name 211 | common-docker-repo-name: 212 | @echo "$(DOCKER_REPO)/$(DOCKER_IMAGE_NAME)" 213 | 214 | .PHONY: common-docker $(BUILD_DOCKER_ARCHS) 215 | common-docker: $(BUILD_DOCKER_ARCHS) 216 | $(BUILD_DOCKER_ARCHS): common-docker-%: 217 | docker build -t "$(DOCKER_REPO)/$(DOCKER_IMAGE_NAME)-linux-$*:$(SANITIZED_DOCKER_IMAGE_TAG)" \ 218 | -f $(DOCKERFILE_PATH) \ 219 | --build-arg ARCH="$*" \ 220 | --build-arg OS="linux" \ 221 | $(DOCKERBUILD_CONTEXT) 222 | 223 | .PHONY: common-docker-publish $(PUBLISH_DOCKER_ARCHS) 224 | common-docker-publish: $(PUBLISH_DOCKER_ARCHS) 225 | $(PUBLISH_DOCKER_ARCHS): common-docker-publish-%: 226 | docker push "$(DOCKER_REPO)/$(DOCKER_IMAGE_NAME)-linux-$*:$(SANITIZED_DOCKER_IMAGE_TAG)" 227 | 228 | DOCKER_MAJOR_VERSION_TAG = $(firstword $(subst ., ,$(shell cat VERSION))) 229 | .PHONY: common-docker-tag-latest $(TAG_DOCKER_ARCHS) 230 | common-docker-tag-latest: $(TAG_DOCKER_ARCHS) 231 | $(TAG_DOCKER_ARCHS): common-docker-tag-latest-%: 232 | docker tag "$(DOCKER_REPO)/$(DOCKER_IMAGE_NAME)-linux-$*:$(SANITIZED_DOCKER_IMAGE_TAG)" "$(DOCKER_REPO)/$(DOCKER_IMAGE_NAME)-linux-$*:latest" 233 | docker tag "$(DOCKER_REPO)/$(DOCKER_IMAGE_NAME)-linux-$*:$(SANITIZED_DOCKER_IMAGE_TAG)" "$(DOCKER_REPO)/$(DOCKER_IMAGE_NAME)-linux-$*:v$(DOCKER_MAJOR_VERSION_TAG)" 234 | 235 | .PHONY: common-docker-manifest 236 | common-docker-manifest: 237 | DOCKER_CLI_EXPERIMENTAL=enabled docker manifest create -a "$(DOCKER_REPO)/$(DOCKER_IMAGE_NAME):$(SANITIZED_DOCKER_IMAGE_TAG)" $(foreach ARCH,$(DOCKER_ARCHS),$(DOCKER_REPO)/$(DOCKER_IMAGE_NAME)-linux-$(ARCH):$(SANITIZED_DOCKER_IMAGE_TAG)) 238 | DOCKER_CLI_EXPERIMENTAL=enabled docker manifest push "$(DOCKER_REPO)/$(DOCKER_IMAGE_NAME):$(SANITIZED_DOCKER_IMAGE_TAG)" 239 | 240 | .PHONY: promu 241 | promu: $(PROMU) 242 | 243 | $(PROMU): 244 | $(eval PROMU_TMP := $(shell mktemp -d)) 245 | curl -s -L $(PROMU_URL) | tar -xvzf - -C $(PROMU_TMP) 246 | mkdir -p $(FIRST_GOPATH)/bin 247 | cp $(PROMU_TMP)/promu-$(PROMU_VERSION).$(GO_BUILD_PLATFORM)/promu $(FIRST_GOPATH)/bin/promu 248 | rm -r $(PROMU_TMP) 249 | 250 | .PHONY: proto 251 | proto: 252 | @echo ">> generating code from proto files" 253 | @./scripts/genproto.sh 254 | 255 | ifdef GOLANGCI_LINT 256 | $(GOLANGCI_LINT): 257 | mkdir -p $(FIRST_GOPATH)/bin 258 | curl -sfL https://raw.githubusercontent.com/golangci/golangci-lint/$(GOLANGCI_LINT_VERSION)/install.sh \ 259 | | sed -e '/install -d/d' \ 260 | | sh -s -- -b $(FIRST_GOPATH)/bin $(GOLANGCI_LINT_VERSION) 261 | endif 262 | 263 | .PHONY: precheck 264 | precheck:: 265 | 266 | define PRECHECK_COMMAND_template = 267 | precheck:: $(1)_precheck 268 | 269 | PRECHECK_COMMAND_$(1) ?= $(1) $$(strip $$(PRECHECK_OPTIONS_$(1))) 270 | .PHONY: $(1)_precheck 271 | $(1)_precheck: 272 | @if ! $$(PRECHECK_COMMAND_$(1)) 1>/dev/null 2>&1; then \ 273 | echo "Execution of '$$(PRECHECK_COMMAND_$(1))' command failed. Is $(1) installed?"; \ 274 | exit 1; \ 275 | fi 276 | endef 277 | 278 | govulncheck: install-govulncheck 279 | govulncheck ./... 280 | 281 | install-govulncheck: 282 | command -v govulncheck > /dev/null || go install golang.org/x/vuln/cmd/govulncheck@latest -------------------------------------------------------------------------------- /NOTICE: -------------------------------------------------------------------------------- 1 | SNMP Notifier for Prometheus Alert Manager 2 | Copyright 2022 Maxime Wojtczak 3 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SNMP Notifier 2 | 3 | [![CircleCI](https://circleci.com/gh/maxwo/snmp_notifier/tree/main.svg?style=svg)](https://circleci.com/gh/maxwo/snmp_notifier/tree/main) 4 | [![Go Report Card](https://goreportcard.com/badge/github.com/maxwo/snmp_notifier)](https://goreportcard.com/report/github.com/maxwo/snmp_notifier) 5 | 6 | `snmp_notifier` receives alerts from the Prometheus' Alertmanager and routes them as SNMP traps. 7 | 8 | ## Overview 9 | 10 | The SNMP notifier receives alerts and sends them as SNMP traps to any given SNMP poller. 11 | 12 | It has been created to handle older monitoring and alerting systems such as Nagios or Centreon. 13 | 14 | Prometheus' Alertmanager sends the alerts to the SNMP notifier on its HTTP API. The SNMP notifier then looks for OID in the given alerts' labels. Each trap is sent with a unique ID, which allows, if the alert is updated or once it is resolved, to send additional traps with updated status and data. 15 | 16 | ## Install 17 | 18 | There are various ways to install the SNMP notifier: 19 | 20 | ### Helm Chart 21 | 22 | The SNMP notifier chart is available via the [Prometheus Community Kubernetes Helm Charts](https://github.com/prometheus-community/helm-charts): 23 | 24 | ```console 25 | helm repo add prometheus-community https://prometheus-community.github.io/helm-charts 26 | helm install snmp-notifier prometheus-community/alertmanager-snmp-notifier --set 'snmpNotifier.snmpDestinations={my-snmp-server:162}' 27 | ``` 28 | 29 | Refer to the [chart values](https://github.com/prometheus-community/helm-charts/blob/main/charts/alertmanager-snmp-notifier/values.yaml) to see the available options. 30 | 31 | ### Docker Images 32 | 33 | Docker images are available on the [Docker Hub](https://hub.docker.com/r/maxwo/snmp-notifier). 34 | 35 | ### Precompiled binaries 36 | 37 | Precompiled binaries are available in the [_release_ section](https://github.com/maxwo/snmp_notifier/releases) of this repository. 38 | 39 | ### Compiling the binary 40 | 41 | Check out the source code and build it manually: 42 | 43 | ```console 44 | git clone https://github.com/maxwo/snmp_notifier.git 45 | cd snmp_notifier 46 | make build 47 | ./snmp_notifier 48 | ``` 49 | 50 | ## Running and configuration 51 | 52 | ### Prometheus' alerts configuration 53 | 54 | OID may be added to the alert labels to identify the kind of trap to be sent: 55 | 56 | --- 57 | 58 | A default OID is specified in the SNMP notifier if none is found in the alert. This can be useful if you want all the alerts to share the same OID. 59 | 60 | --- 61 | 62 | ```yaml 63 | groups: 64 | - name: service 65 | rules: 66 | - alert: ServiceIsDown 67 | expr: up == 0 68 | for: 5m 69 | labels: 70 | severity: "critical" 71 | type: "service" 72 | oid: "1.3.6.1.4.1.123.0.10.1.1.1.5.1" 73 | environment: "production" 74 | annotations: 75 | description: "Service {{ $labels.job }} on {{ $labels.instance }} is down" 76 | summary: "A service is down." 77 | ``` 78 | 79 | ### Alertmanager configuration 80 | 81 | The Alertmanager should be configured with the SNMP notifier as alert receiver: 82 | 83 | ```yaml 84 | receivers: 85 | - name: "snmp_notifier" 86 | webhook_configs: 87 | - send_resolved: true 88 | url: http://snmp.notifier.service:9464/alerts 89 | ``` 90 | 91 | Note that the `send_resolved` option allows the notifier to update the trap status to normal. 92 | 93 | ### SNMP notifier configuration 94 | 95 | Launch the `snmp_notifier` executable with the help flag to see the available options. 96 | 97 | ```console 98 | $ ./snmp_notifier --help 99 | usage: snmp_notifier [] 100 | 101 | A tool to relay Prometheus alerts as SNMP traps 102 | 103 | 104 | Flags: 105 | -h, --[no-]help Show context-sensitive help (also try --help-long and --help-man). 106 | --web.listen-address=:9464 ... 107 | Addresses on which to expose metrics and web interface. Repeatable for multiple addresses. Examples: `:9100` or `[::1]:9100` for http, 108 | `vsock://:9100` for vsock 109 | --web.config.file="" Path to configuration file that can enable TLS or authentication. See: 110 | https://github.com/prometheus/exporter-toolkit/blob/master/docs/web-configuration.md 111 | --alert.severity-label="severity" 112 | Label where to find the alert severity. 113 | --alert.severities="critical,warning,info" 114 | The ordered list of alert severities, from more priority to less priority. 115 | --alert.default-severity="critical" 116 | The alert severity if none is provided via labels. 117 | --snmp.version=V2c SNMP version. V2c and V3 are currently supported. 118 | --snmp.destination=127.0.0.1:162 ... 119 | SNMP trap server destination. 120 | --snmp.retries=1 SNMP number of retries 121 | --snmp.timeout=5s SNMP timeout duration 122 | --snmp.community="public" SNMP community (V2c only). Passing secrets to the command line is not recommended, consider using the SNMP_NOTIFIER_COMMUNITY environment variable 123 | instead. ($SNMP_NOTIFIER_COMMUNITY) 124 | --[no-]snmp.authentication-enabled 125 | Enable SNMP authentication (V3 only). 126 | --snmp.authentication-protocol=MD5 127 | Protocol for password encryption (V3 only). MD5 and SHA are currently supported. 128 | --snmp.authentication-username=USERNAME 129 | SNMP authentication username (V3 only). Passing secrets to the command line is not recommended, consider using the SNMP_NOTIFIER_AUTH_USERNAME 130 | environment variable instead. ($SNMP_NOTIFIER_AUTH_USERNAME) 131 | --snmp.authentication-password=PASSWORD 132 | SNMP authentication password (V3 only). Passing secrets to the command line is not recommended, consider using the SNMP_NOTIFIER_AUTH_PASSWORD 133 | environment variable instead. ($SNMP_NOTIFIER_AUTH_PASSWORD) 134 | --[no-]snmp.private-enabled 135 | Enable SNMP encryption (V3 only). 136 | --snmp.private-protocol=DES 137 | Protocol for SNMP data transmission (V3 only). DES and AES are currently supported. 138 | --snmp.private-password=SECRET 139 | SNMP private password (V3 only). Passing secrets to the command line is not recommended, consider using the SNMP_NOTIFIER_PRIV_PASSWORD environment 140 | variable instead. ($SNMP_NOTIFIER_PRIV_PASSWORD) 141 | --snmp.security-engine-id=SECURITY_ENGINE_ID 142 | SNMP security engine ID (V3 only). 143 | --snmp.context-engine-id=CONTEXT_ENGINE_ID 144 | SNMP context engine ID (V3 only). 145 | --snmp.context-name=CONTEXT_ENGINE_NAME 146 | SNMP context name (V3 only). 147 | --trap.default-oid="1.3.6.1.4.1.98789.1" 148 | Default trap OID. 149 | --trap.oid-label="oid" Label containing a custom trap OID. 150 | --trap.resolution-default-oid=TRAP.RESOLUTION-DEFAULT-OID 151 | Resolution trap OID, if different from the firing trap OID. 152 | --trap.resolution-oid-label=TRAP.RESOLUTION-OID-LABEL 153 | Label containing a custom resolution trap OID, if different from the firing trap OID. 154 | --trap.default-objects-base-oid="1.3.6.1.4.1.98789.2" 155 | Base OID for default trap objects. 156 | --trap.description-template=description-template.tpl 157 | Trap description template. 158 | --trap.user-objects-base-oid="1.3.6.1.4.1.98789.3" 159 | Base OID for user-defined trap objects. 160 | --trap.user-object=4=user-object-template.tpl ... 161 | User object sub-OID and template, e.g. --trap.user-object=4=new-object.template.tpl to add a sub-object to the trap, with the given template file. 162 | You may add several user objects using that flag several times. 163 | --log.level=info Only log messages with the given severity or above. One of: [debug, info, warn, error] 164 | --log.format=logfmt Output format of log messages. One of: [logfmt, json] 165 | --[no-]version Show application version. 166 | ``` 167 | 168 | Also, it is recommended to use the following environment variables to set the SNMP secrets: 169 | 170 | | Environment variable | Configuration | Default | 171 | | --------------------------- | --------------------------------------------- | ------- | 172 | | SNMP_NOTIFIER_COMMUNITY | SNMP community for SNMP v2c | public | 173 | | SNMP_NOTIFIER_AUTH_USERNAME | SNMP authentication username for SNMP v3 | | 174 | | SNMP_NOTIFIER_AUTH_PASSWORD | SNMP authentication password for SNMP v3 | | 175 | | SNMP_NOTIFIER_PRIV_PASSWORD | SNMP private (or server) password for SNMP v3 | | 176 | 177 | Any Go template directive may be used in the `trap.description-template` file. 178 | 179 | ## Examples 180 | 181 | ### Simple Usage 182 | 183 | Here are 2 example traps received with the default configuration. It includes 2 firing alerts sharing the same OID, and 1 resolved alert. 184 | 185 | Traps include 3 fields: 186 | 187 | - a trap unique ID; 188 | - the alert/trap status; 189 | - a description of the alerts. 190 | 191 | ```console 192 | $ snmptrapd -m ALL -m +SNMP-NOTIFIER-MIB -f -Of -Lo -c scripts/snmptrapd.conf 193 | Agent Address: 0.0.0.0 194 | Agent Hostname: localhost 195 | Date: 1 - 0 - 0 - 1 - 1 - 1970 196 | Enterprise OID: . 197 | Trap Type: Cold Start 198 | Trap Sub-Type: 0 199 | Community/Infosec Context: TRAP2, SNMP v2c, community public 200 | Uptime: 0 201 | Description: Cold Start 202 | PDU Attribute/Value Pair Array: 203 | .iso.org.dod.internet.mgmt.mib-2.system.sysUpTime.sysUpTimeInstance = Timeticks: (853395100) 98 days, 18:32:31.00 204 | .iso.org.dod.internet.snmpV2.snmpModules.snmpMIB.snmpMIBObjects.snmpTrap.snmpTrapOID.0 = OID: .iso.org.dod.internet.private.enterprises.snmpNotifier.snmpNotifierDefaultTrap 205 | .iso.org.dod.internet.private.enterprises.snmpNotifier.snmpNotifierAlertsObjects.snmpNotifierAlertId = STRING: "1.3.6.1.4.1.98789[environment=production,label=test]" 206 | .iso.org.dod.internet.private.enterprises.snmpNotifier.snmpNotifierAlertsObjects.snmpNotifierAlertSeverity = STRING: "critical" 207 | .iso.org.dod.internet.private.enterprises.snmpNotifier.snmpNotifierAlertsObjects.snmpNotifierAlertDescription = STRING: "2/3 alerts are firing: 208 | 209 | Status: critical 210 | - Alert: TestAlert 211 | Summary: this is the summary 212 | Description: this is the description on job1 213 | 214 | Status: warning 215 | - Alert: TestAlert 216 | Summary: this is the random summary 217 | Description: this is the description of alert 1" 218 | -------------- 219 | ``` 220 | 221 | ### With User Objects 222 | 223 | You may add additional fields thanks to the `--trap.user-object` arguments. 224 | 225 | For instance, the template `{{ len .Alerts }} alerts are firing.` given in the `--trap.user-object=4=alert-count.tpl` argument will produce: 226 | 227 | ```console 228 | $ snmptrapd -m ALL -m +SNMP-NOTIFIER-MIB -f -Of -Lo -c scripts/snmptrapd.conf 229 | Agent Address: 0.0.0.0 230 | Agent Hostname: localhost 231 | Date: 1 - 0 - 0 - 1 - 1 - 1970 232 | Enterprise OID: . 233 | Trap Type: Cold Start 234 | Trap Sub-Type: 0 235 | Community/Infosec Context: TRAP2, SNMP v2c, community public 236 | Uptime: 0 237 | Description: Cold Start 238 | PDU Attribute/Value Pair Array: 239 | .iso.org.dod.internet.mgmt.mib-2.system.sysUpTime.sysUpTimeInstance = Timeticks: (2665700) 7:24:17.00 240 | .iso.org.dod.internet.snmpV2.snmpModules.snmpMIB.snmpMIBObjects.snmpTrap.snmpTrapOID.0 = OID: .iso.org.dod.internet.private.enterprises.snmpNotifier.snmpNotifierDefaultTrap 241 | .iso.org.dod.internet.private.enterprises.snmpNotifier.snmpNotifierAlertsObjects.snmpNotifierAlertId = STRING: "1.3.6.1.4.1.98789[environment=production,label=test]" 242 | .iso.org.dod.internet.private.enterprises.snmpNotifier.snmpNotifierAlertsObjects.snmpNotifierAlertSeverity = STRING: "critical" 243 | .iso.org.dod.internet.private.enterprises.snmpNotifier.snmpNotifierAlertsObjects.snmpNotifierAlertDescription = STRING: "2/3 alerts are firing: 244 | 245 | Status: critical 246 | - Alert: TestAlert 247 | Summary: this is the summary 248 | Description: this is the description on job1 249 | 250 | Status: warning 251 | - Alert: TestAlert 252 | Summary: this is the random summary 253 | Description: this is the description of alert 1" 254 | .iso.org.dod.internet.private.enterprises.snmpNotifier.snmpNotifierAlertsUserObjects.4 = STRING: "2 alerts are firing." 255 | -------------- 256 | ```` 257 | 258 | ## Contributing 259 | 260 | Issues, feedback, PR welcome. 261 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Reporting a security issue 2 | 3 | SNMP Notifier follows the Prometheus security policy. 4 | 5 | The Prometheus security policy, including how to report vulnerabilities, can be 6 | found here: 7 | 8 | 9 | -------------------------------------------------------------------------------- /VERSION: -------------------------------------------------------------------------------- 1 | 2.0.0 2 | -------------------------------------------------------------------------------- /alertparser/alert_parser.go: -------------------------------------------------------------------------------- 1 | // Copyright 2018 Maxime Wojtczak 2 | // Licensed under the Apache License, Version 2.0 (the "License"); 3 | // you may not use this file except in compliance with the License. 4 | // You may obtain a copy of the License at 5 | // 6 | // http://www.apache.org/licenses/LICENSE-2.0 7 | // 8 | // Unless required by applicable law or agreed to in writing, software 9 | // distributed under the License is distributed on an "AS IS" BASIS, 10 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | // See the License for the specific language governing permissions and 12 | // limitations under the License. 13 | 14 | package alertparser 15 | 16 | import ( 17 | "fmt" 18 | "log/slog" 19 | "strings" 20 | 21 | "github.com/maxwo/snmp_notifier/commons" 22 | "github.com/maxwo/snmp_notifier/types" 23 | ) 24 | 25 | // AlertParser parses alerts from the Prometheus Alert Manager 26 | type AlertParser struct { 27 | logger *slog.Logger 28 | configuration Configuration 29 | } 30 | 31 | // Configuration stores configuration of an AlertParser 32 | type Configuration struct { 33 | Severities []string 34 | SeverityLabel string 35 | DefaultSeverity string 36 | TrapDefaultOID string 37 | TrapOIDLabel string 38 | TrapResolutionDefaultOID *string 39 | TrapResolutionOIDLabel *string 40 | TrapDefaultObjectsBaseOID string 41 | TrapUserObjectsBaseOID string 42 | } 43 | 44 | // New creates an AlertParser instance 45 | func New(configuration Configuration, logger *slog.Logger) AlertParser { 46 | return AlertParser{logger, configuration} 47 | } 48 | 49 | // Parse parses alerts coming from the Prometheus Alert Manager to group them by traps 50 | func (alertParser AlertParser) Parse(alertsData types.AlertsData) (*types.AlertBucket, error) { 51 | var ( 52 | alertGroups = map[string]*types.AlertGroup{} 53 | groupID string 54 | ) 55 | groupID = generateGroupID(alertsData) 56 | for _, alert := range alertsData.Alerts { 57 | trapOID, err := alertParser.getAlertOID(alert) 58 | if err != nil { 59 | return nil, err 60 | } 61 | alertIDForGrouping, err := alertParser.getFiringAndResolvedTrapsOID(alert) 62 | if err != nil { 63 | return nil, err 64 | } 65 | alertParser.logger.Debug("add to a new group", "group", *alertIDForGrouping, "alert", alert) 66 | 67 | key := strings.Join([]string{*alertIDForGrouping, "[", groupID, "]"}, "") 68 | if _, found := alertGroups[key]; !found { 69 | alertGroups[key] = &types.AlertGroup{ 70 | TrapOID: *trapOID, 71 | GroupID: groupID, 72 | GroupLabels: alertsData.GroupLabels, 73 | CommonLabels: alertsData.CommonLabels, 74 | CommonAnnotations: alertsData.CommonAnnotations, 75 | Severity: alertParser.getLowestSeverity(), 76 | Alerts: []types.Alert{}, 77 | DeclaredAlerts: []types.Alert{}, 78 | DefaultObjectsBaseOID: alertParser.configuration.TrapDefaultObjectsBaseOID, 79 | UserObjectsBaseOID: alertParser.configuration.TrapUserObjectsBaseOID, 80 | } 81 | } 82 | alertGroups[key].DeclaredAlerts = append(alertGroups[key].DeclaredAlerts, alert) 83 | if alert.Status == "firing" { 84 | err = alertParser.addAlertToGroup(alertGroups[key], alert) 85 | if err != nil { 86 | return nil, err 87 | } 88 | } 89 | } 90 | 91 | return &types.AlertBucket{AlertGroups: alertGroups}, nil 92 | } 93 | 94 | func (alertParser AlertParser) addAlertToGroup(alertGroup *types.AlertGroup, alert types.Alert) error { 95 | var severity = alertParser.configuration.DefaultSeverity 96 | if _, found := alert.Labels[alertParser.configuration.SeverityLabel]; found { 97 | severity = alert.Labels[alertParser.configuration.SeverityLabel] 98 | } 99 | 100 | var currentGroupSeverityIndex = commons.IndexOf(alertGroup.Severity, alertParser.configuration.Severities) 101 | var alertSeverityIndex = commons.IndexOf(severity, alertParser.configuration.Severities) 102 | if alertSeverityIndex == -1 { 103 | return fmt.Errorf("incorrect severity: %s", severity) 104 | } 105 | // Update group severity 106 | if alertSeverityIndex < currentGroupSeverityIndex { 107 | alertGroup.Severity = severity 108 | } 109 | alertGroup.Alerts = append(alertGroup.Alerts, alert) 110 | return nil 111 | } 112 | 113 | func (alertParser AlertParser) getAlertOID(alert types.Alert) (*string, error) { 114 | if alert.Status == "firing" { 115 | return alertParser.getFiringAlertOID(alert) 116 | } else { 117 | return alertParser.getResolvedAlertOID(alert) 118 | } 119 | } 120 | 121 | func (alertParser AlertParser) getFiringAndResolvedTrapsOID(alert types.Alert) (*string, error) { 122 | firingTrapOID, err := alertParser.getFiringAlertOID(alert) 123 | if err != nil { 124 | return nil, err 125 | } 126 | 127 | resolvedTrapOID, err := alertParser.getResolvedAlertOID(alert) 128 | if err != nil { 129 | return nil, err 130 | } 131 | 132 | alertParser.logger.Debug("trap firing and resolution OID", "firingTrapOID", *firingTrapOID, "resolvedTrapOID", *resolvedTrapOID) 133 | if *firingTrapOID == *resolvedTrapOID { 134 | return firingTrapOID, nil 135 | } else { 136 | groupIDForAlert := strings.Join([]string{*firingTrapOID, *resolvedTrapOID}, "-") 137 | return &groupIDForAlert, nil 138 | } 139 | } 140 | 141 | func (alertParser AlertParser) getFiringAlertOID(alert types.Alert) (*string, error) { 142 | firingTrapOID := alertParser.configuration.TrapDefaultOID 143 | 144 | if value, found := alert.Labels[alertParser.configuration.TrapOIDLabel]; found { 145 | firingTrapOID = value 146 | } 147 | 148 | if !commons.IsOID(firingTrapOID) { 149 | return nil, fmt.Errorf("invalid OID provided: \"%s\"", firingTrapOID) 150 | } 151 | 152 | return &firingTrapOID, nil 153 | } 154 | 155 | func (alertParser AlertParser) getResolvedAlertOID(alert types.Alert) (*string, error) { 156 | resolvedTrapOID := alertParser.configuration.TrapDefaultOID 157 | 158 | if alertParser.configuration.TrapResolutionDefaultOID != nil { 159 | resolvedTrapOID = *alertParser.configuration.TrapResolutionDefaultOID 160 | } 161 | 162 | if value, found := alert.Labels[alertParser.configuration.TrapOIDLabel]; found { 163 | resolvedTrapOID = value 164 | } 165 | 166 | if alertParser.configuration.TrapResolutionOIDLabel != nil { 167 | if value, found := alert.Labels[*alertParser.configuration.TrapResolutionOIDLabel]; found { 168 | resolvedTrapOID = value 169 | } 170 | } 171 | 172 | if !commons.IsOID(resolvedTrapOID) { 173 | return nil, fmt.Errorf("invalid OID provided: \"%s\"", resolvedTrapOID) 174 | } 175 | 176 | return &resolvedTrapOID, nil 177 | } 178 | 179 | func (alertParser AlertParser) getLowestSeverity() string { 180 | return alertParser.configuration.Severities[len(alertParser.configuration.Severities)-1] 181 | } 182 | 183 | func generateGroupID(alertsData types.AlertsData) string { 184 | var ( 185 | pairs []string 186 | ) 187 | for _, pair := range alertsData.GroupLabels.SortedPairs() { 188 | pairs = append(pairs, fmt.Sprintf("%s=%s", pair.Name, pair.Value)) 189 | } 190 | return strings.Join(pairs, ",") 191 | } 192 | -------------------------------------------------------------------------------- /alertparser/alert_parser_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2018 Maxime Wojtczak 2 | // Licensed under the Apache License, Version 2.0 (the "License"); 3 | // you may not use this file except in compliance with the License. 4 | // You may obtain a copy of the License at 5 | // 6 | // http://www.apache.org/licenses/LICENSE-2.0 7 | // 8 | // Unless required by applicable law or agreed to in writing, software 9 | // distributed under the License is distributed on an "AS IS" BASIS, 10 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | // See the License for the specific language governing permissions and 12 | // limitations under the License. 13 | 14 | package alertparser 15 | 16 | import ( 17 | "bytes" 18 | "encoding/json" 19 | "log/slog" 20 | "os" 21 | "testing" 22 | 23 | "github.com/maxwo/snmp_notifier/types" 24 | 25 | "github.com/go-test/deep" 26 | ) 27 | 28 | var resolutionOIDForTest = "2.2" 29 | var resolutionOIDLabelForTest = "resolution-oid" 30 | var wrongResolutionOIDLabelForTest = "severity" 31 | 32 | func TestUniqueAlertBuckets(t *testing.T) { 33 | expectTrapOIDFromUniqueAlertAndConfiguration(t, 34 | Configuration{ 35 | TrapDefaultOID: "1.1", 36 | TrapOIDLabel: "oid", 37 | DefaultSeverity: "critical", 38 | Severities: []string{"critical", "warning", "info"}, 39 | SeverityLabel: "severity", 40 | TrapDefaultObjectsBaseOID: "4.4.4", 41 | TrapUserObjectsBaseOID: "4.4.5", 42 | }, 43 | "1.1[environment=production,label=test]", 44 | "1.1") 45 | } 46 | 47 | func TestUniqueResolvedAlertBuckets(t *testing.T) { 48 | expectTrapOIDFromUniqueResolvedAlertAndConfiguration(t, 49 | Configuration{ 50 | TrapDefaultOID: "1.1", 51 | TrapOIDLabel: "oid", 52 | DefaultSeverity: "critical", 53 | Severities: []string{"critical", "warning", "info"}, 54 | SeverityLabel: "severity", 55 | TrapDefaultObjectsBaseOID: "4.4.4", 56 | TrapUserObjectsBaseOID: "4.4.5", 57 | }, 58 | "1.1[environment=production,label=test]", 59 | "1.1") 60 | } 61 | 62 | func TestUniqueAlertBucketsWithCustomFiringOID(t *testing.T) { 63 | expectTrapOIDFromUniqueAlertAndConfiguration(t, 64 | Configuration{ 65 | TrapDefaultOID: "1.1", 66 | TrapOIDLabel: "firing-oid", 67 | DefaultSeverity: "critical", 68 | Severities: []string{"critical", "warning", "info"}, 69 | SeverityLabel: "severity", 70 | TrapDefaultObjectsBaseOID: "4.4.4", 71 | TrapUserObjectsBaseOID: "4.4.5", 72 | }, 73 | "1.2.3[environment=production,label=test]", 74 | "1.2.3") 75 | } 76 | 77 | func TestUniqueResolvedAlertBucketsWithCustomFiringOID(t *testing.T) { 78 | expectTrapOIDFromUniqueResolvedAlertAndConfiguration(t, 79 | Configuration{ 80 | TrapDefaultOID: "1.1", 81 | TrapOIDLabel: "firing-oid", 82 | DefaultSeverity: "critical", 83 | Severities: []string{"critical", "warning", "info"}, 84 | SeverityLabel: "severity", 85 | TrapDefaultObjectsBaseOID: "4.4.4", 86 | TrapUserObjectsBaseOID: "4.4.5", 87 | }, 88 | "1.2.3[environment=production,label=test]", 89 | "1.2.3") 90 | } 91 | 92 | func TestUniqueAlertBucketsWithResolutionTrapOID(t *testing.T) { 93 | expectTrapOIDFromUniqueAlertAndConfiguration(t, 94 | Configuration{ 95 | TrapDefaultOID: "1.1", 96 | TrapOIDLabel: "oid", 97 | TrapResolutionDefaultOID: &resolutionOIDForTest, 98 | DefaultSeverity: "critical", 99 | Severities: []string{"critical", "warning", "info"}, 100 | SeverityLabel: "severity", 101 | TrapDefaultObjectsBaseOID: "4.4.4", 102 | TrapUserObjectsBaseOID: "4.4.5", 103 | }, 104 | "1.1-2.2[environment=production,label=test]", 105 | "1.1") 106 | } 107 | 108 | func TestUniqueResolvedAlertBucketsWithResolutionTrapOID(t *testing.T) { 109 | expectTrapOIDFromUniqueResolvedAlertAndConfiguration(t, 110 | Configuration{ 111 | TrapDefaultOID: "1.1", 112 | TrapOIDLabel: "oid", 113 | TrapResolutionDefaultOID: &resolutionOIDForTest, 114 | DefaultSeverity: "critical", 115 | Severities: []string{"critical", "warning", "info"}, 116 | SeverityLabel: "severity", 117 | TrapDefaultObjectsBaseOID: "4.4.4", 118 | TrapUserObjectsBaseOID: "4.4.5", 119 | }, 120 | "1.1-2.2[environment=production,label=test]", 121 | "2.2") 122 | } 123 | 124 | // any firing trap custom OID has higher priority than default resolution trap OID 125 | func TestUniqueAlertBucketsWithResolutionTrapOIDAndCustomFiringTrapOID(t *testing.T) { 126 | expectTrapOIDFromUniqueAlertAndConfiguration(t, 127 | Configuration{ 128 | TrapDefaultOID: "1.1", 129 | TrapOIDLabel: "firing-oid", 130 | TrapResolutionDefaultOID: &resolutionOIDForTest, 131 | DefaultSeverity: "critical", 132 | Severities: []string{"critical", "warning", "info"}, 133 | SeverityLabel: "severity", 134 | TrapDefaultObjectsBaseOID: "4.4.4", 135 | TrapUserObjectsBaseOID: "4.4.5", 136 | }, 137 | "1.2.3[environment=production,label=test]", 138 | "1.2.3") 139 | } 140 | 141 | func TestUniqueResolvedAlertBucketsWithResolutionTrapOIDAndCustomFiringTrapOID(t *testing.T) { 142 | expectTrapOIDFromUniqueResolvedAlertAndConfiguration(t, 143 | Configuration{ 144 | TrapDefaultOID: "1.1", 145 | TrapOIDLabel: "firing-oid", 146 | TrapResolutionDefaultOID: &resolutionOIDForTest, 147 | DefaultSeverity: "critical", 148 | Severities: []string{"critical", "warning", "info"}, 149 | SeverityLabel: "severity", 150 | TrapDefaultObjectsBaseOID: "4.4.4", 151 | TrapUserObjectsBaseOID: "4.4.5", 152 | }, 153 | "1.2.3[environment=production,label=test]", 154 | "1.2.3") 155 | } 156 | 157 | // any custom resolution OID has higher priority than custom firing trap OID 158 | func TestUniqueAlertBucketsWithResolutionTrapOIDAndCustomFiringAndResolutionTrapOID(t *testing.T) { 159 | expectTrapOIDFromUniqueAlertAndConfiguration(t, 160 | Configuration{ 161 | TrapDefaultOID: "1.1", 162 | TrapOIDLabel: "firing-oid", 163 | TrapResolutionOIDLabel: &resolutionOIDLabelForTest, 164 | DefaultSeverity: "critical", 165 | Severities: []string{"critical", "warning", "info"}, 166 | SeverityLabel: "severity", 167 | TrapDefaultObjectsBaseOID: "4.4.4", 168 | TrapUserObjectsBaseOID: "4.4.5", 169 | }, 170 | "1.2.3-1.2.4[environment=production,label=test]", 171 | "1.2.3") 172 | } 173 | 174 | func TestUniqueResolvedAlertBucketsWithResolutionTrapOIDAndCustomFiringAndResolutionTrapOID(t *testing.T) { 175 | expectTrapOIDFromUniqueResolvedAlertAndConfiguration(t, 176 | Configuration{ 177 | TrapDefaultOID: "1.1", 178 | TrapOIDLabel: "firing-oid", 179 | TrapResolutionOIDLabel: &resolutionOIDLabelForTest, 180 | DefaultSeverity: "critical", 181 | Severities: []string{"critical", "warning", "info"}, 182 | SeverityLabel: "severity", 183 | TrapDefaultObjectsBaseOID: "4.4.4", 184 | TrapUserObjectsBaseOID: "4.4.5", 185 | }, 186 | "1.2.3-1.2.4[environment=production,label=test]", 187 | "1.2.4") 188 | } 189 | 190 | func TestMixedAlertBuckets(t *testing.T) { 191 | alerts := readAlertFile(t, "test_mixed_alerts.json") 192 | buckets := readBucketsFile(t, "test_mixed_bucket.json") 193 | 194 | expectAlertBuckets( 195 | t, 196 | Configuration{ 197 | TrapDefaultOID: "1.1", 198 | TrapOIDLabel: "oid", 199 | DefaultSeverity: "critical", 200 | Severities: []string{"critical", "warning", "info"}, 201 | SeverityLabel: "severity", 202 | TrapDefaultObjectsBaseOID: "4.4.4", 203 | TrapUserObjectsBaseOID: "4.4.5", 204 | }, 205 | alerts, 206 | buckets, 207 | ) 208 | } 209 | 210 | func TestAlertBucketsWithTrapResolutionDefaultOID(t *testing.T) { 211 | alerts := readAlertFile(t, "test_resolved_alerts.json") 212 | buckets := readBucketsFile(t, "test_resolved_default_resolved_oid_alerts.json") 213 | 214 | expectAlertBuckets( 215 | t, 216 | Configuration{ 217 | TrapDefaultOID: "1.1", 218 | TrapOIDLabel: "oid", 219 | TrapResolutionDefaultOID: &resolutionOIDForTest, 220 | TrapResolutionOIDLabel: &resolutionOIDLabelForTest, 221 | DefaultSeverity: "critical", 222 | Severities: []string{"critical", "warning", "info"}, 223 | SeverityLabel: "severity", 224 | TrapDefaultObjectsBaseOID: "4.4.4", 225 | TrapUserObjectsBaseOID: "4.4.5", 226 | }, 227 | alerts, 228 | buckets, 229 | ) 230 | } 231 | 232 | func TestAlertBucketsWithFiringAndResolvedAlerts(t *testing.T) { 233 | alerts := readAlertFile(t, "test_resolved_alerts.json") 234 | buckets := readBucketsFile(t, "test_resolved_default_firing_oid_alerts.json") 235 | 236 | alerts.Alerts[0].Status = "firing" 237 | 238 | expectAlertBuckets( 239 | t, 240 | Configuration{ 241 | TrapDefaultOID: "1.1", 242 | TrapOIDLabel: "oid", 243 | TrapResolutionDefaultOID: &resolutionOIDForTest, 244 | TrapResolutionOIDLabel: &resolutionOIDLabelForTest, 245 | DefaultSeverity: "critical", 246 | Severities: []string{"critical", "warning", "info"}, 247 | SeverityLabel: "severity", 248 | TrapDefaultObjectsBaseOID: "4.4.4", 249 | TrapUserObjectsBaseOID: "4.4.5", 250 | }, 251 | alerts, 252 | buckets, 253 | ) 254 | } 255 | 256 | func TestAlertBucketsWithResolvedTrapOIDFromLabels(t *testing.T) { 257 | alerts := readAlertFile(t, "test_resolved_alerts.json") 258 | buckets := readBucketsFile(t, "test_resolved_oid_from_labels_alerts.json") 259 | 260 | alerts.Alerts[0].Labels["resolution-oid"] = "7.7.7" 261 | alerts.Alerts[1].Labels["resolution-oid"] = "7.7.7" 262 | 263 | expectAlertBuckets( 264 | t, 265 | Configuration{ 266 | TrapDefaultOID: "1.1", 267 | TrapOIDLabel: "oid", 268 | TrapResolutionDefaultOID: &resolutionOIDForTest, 269 | TrapResolutionOIDLabel: &resolutionOIDLabelForTest, 270 | DefaultSeverity: "critical", 271 | Severities: []string{"critical", "warning", "info"}, 272 | SeverityLabel: "severity", 273 | TrapDefaultObjectsBaseOID: "4.4.4", 274 | TrapUserObjectsBaseOID: "4.4.5", 275 | }, 276 | alerts, 277 | buckets, 278 | ) 279 | } 280 | 281 | func TestAlertBucketsWithFiringAndResolvedTrapOIDFromLabels(t *testing.T) { 282 | alerts := readAlertFile(t, "test_resolved_alerts.json") 283 | buckets := readBucketsFile(t, "test_resolved_and_firing_oid_from_labels_alerts.json") 284 | 285 | alerts.Alerts[0].Labels["oid"] = "8.8.8" 286 | alerts.Alerts[1].Labels["oid"] = "8.8.8" 287 | alerts.Alerts[0].Labels["resolution-oid"] = "7.7.7" 288 | alerts.Alerts[1].Labels["resolution-oid"] = "7.7.7" 289 | 290 | expectAlertBuckets( 291 | t, 292 | Configuration{ 293 | TrapDefaultOID: "1.1", 294 | TrapOIDLabel: "oid", 295 | TrapResolutionDefaultOID: &resolutionOIDForTest, 296 | TrapResolutionOIDLabel: &resolutionOIDLabelForTest, 297 | DefaultSeverity: "critical", 298 | Severities: []string{"critical", "warning", "info"}, 299 | SeverityLabel: "severity", 300 | TrapDefaultObjectsBaseOID: "4.4.4", 301 | TrapUserObjectsBaseOID: "4.4.5", 302 | }, 303 | alerts, 304 | buckets, 305 | ) 306 | } 307 | 308 | func TestSeverityLabelValueCheck(t *testing.T) { 309 | alerts := readAlertFile(t, "test_mixed_alerts.json") 310 | 311 | alerts.Alerts[0].Labels["severity"] = "unknown" 312 | 313 | expectAlertParserError( 314 | t, 315 | Configuration{ 316 | TrapDefaultOID: "1.1", 317 | TrapOIDLabel: "oid", 318 | DefaultSeverity: "critical", 319 | Severities: []string{"critical", "warning", "info"}, 320 | SeverityLabel: "severity", 321 | TrapDefaultObjectsBaseOID: "4.4.4", 322 | TrapUserObjectsBaseOID: "4.4.5", 323 | }, 324 | alerts, 325 | ) 326 | } 327 | 328 | func TestOIDLabelValueCheck(t *testing.T) { 329 | alerts := readAlertFile(t, "test_mixed_alerts.json") 330 | 331 | alerts.Alerts[0].Labels["oid"] = "1.a.2.3" 332 | 333 | expectAlertParserError( 334 | t, 335 | Configuration{ 336 | TrapDefaultOID: "1.1", 337 | TrapOIDLabel: "oid", 338 | DefaultSeverity: "critical", 339 | Severities: []string{"critical", "warning", "info"}, 340 | SeverityLabel: "severity", 341 | TrapDefaultObjectsBaseOID: "4.4.4", 342 | TrapUserObjectsBaseOID: "4.4.5", 343 | }, 344 | alerts, 345 | ) 346 | } 347 | 348 | func TestResolvedOIDLabelValueCheck(t *testing.T) { 349 | alerts := readAlertFile(t, "test_mixed_alerts.json") 350 | 351 | expectAlertParserError( 352 | t, 353 | Configuration{ 354 | TrapDefaultOID: "1.1", 355 | TrapOIDLabel: "oid", 356 | TrapResolutionOIDLabel: &wrongResolutionOIDLabelForTest, // tries to use severity as OID 357 | DefaultSeverity: "critical", 358 | Severities: []string{"critical", "warning", "info"}, 359 | SeverityLabel: "severity", 360 | TrapDefaultObjectsBaseOID: "4.4.4", 361 | TrapUserObjectsBaseOID: "4.4.5", 362 | }, 363 | alerts, 364 | ) 365 | } 366 | 367 | func expectAlertParserError(t *testing.T, configuration Configuration, alerts types.AlertsData) { 368 | parser := New(configuration, slog.New(slog.NewTextHandler(os.Stdout, nil))) 369 | _, err := parser.Parse(alerts) 370 | 371 | if err == nil { 372 | t.Fatal("An unexpected error occurred:", err) 373 | } 374 | } 375 | 376 | func expectTrapOIDFromUniqueAlertAndConfiguration(t *testing.T, configuration Configuration, groupID string, trapOID string) { 377 | alerts := readAlertFile(t, "test_unique_alert.json") 378 | expectTrapOIDAndGroupIDFromAlertAndConfiguration(t, configuration, alerts, groupID, trapOID) 379 | } 380 | 381 | func expectTrapOIDFromUniqueResolvedAlertAndConfiguration(t *testing.T, configuration Configuration, groupID string, trapOID string) { 382 | alerts := readAlertFile(t, "test_unique_alert.json") 383 | alerts.Alerts[0].Status = "resolved" 384 | expectTrapOIDAndGroupIDFromAlertAndConfiguration(t, configuration, alerts, groupID, trapOID) 385 | } 386 | 387 | func expectTrapOIDAndGroupIDFromAlertAndConfiguration(t *testing.T, configuration Configuration, alerts types.AlertsData, groupID string, trapOID string) { 388 | buckets := getAlertBuckets(t, configuration, alerts) 389 | 390 | if value, found := buckets.AlertGroups[groupID]; found { 391 | if value.TrapOID != trapOID { 392 | t.Error("unexpected trap OID", "trapOID", value.TrapOID) 393 | } 394 | } else { 395 | t.Error("expected group ID not found", "groupID", groupID, "buckets", buckets) 396 | } 397 | } 398 | 399 | func expectAlertBuckets(t *testing.T, configuration Configuration, alerts types.AlertsData, expectedBuckets types.AlertBucket) { 400 | actualBuckets := getAlertBuckets(t, configuration, alerts) 401 | 402 | if diff := deep.Equal(actualBuckets, expectedBuckets); diff != nil { 403 | t.Error(diff) 404 | } 405 | } 406 | 407 | func getAlertBuckets(t *testing.T, configuration Configuration, alerts types.AlertsData) types.AlertBucket { 408 | parser := New(configuration, slog.New(slog.NewTextHandler(os.Stdout, nil))) 409 | buckets, err := parser.Parse(alerts) 410 | 411 | if err != nil { 412 | t.Fatal("An error occured") 413 | } 414 | 415 | return *buckets 416 | } 417 | 418 | func readAlertFile(t *testing.T, alertFileName string) types.AlertsData { 419 | alertsByteData, err := os.ReadFile(alertFileName) 420 | if err != nil { 421 | t.Fatal("Error while reading alert file:", err) 422 | } 423 | alertsReader := bytes.NewReader(alertsByteData) 424 | alertsData := types.AlertsData{} 425 | err = json.NewDecoder(alertsReader).Decode(&alertsData) 426 | if err != nil { 427 | t.Fatal("Error while parsing alert file:", err) 428 | } 429 | return alertsData 430 | } 431 | 432 | func readBucketsFile(t *testing.T, bucketFileName string) types.AlertBucket { 433 | bucketByteData, err := os.ReadFile(bucketFileName) 434 | if err != nil { 435 | t.Fatal("Error while reading bucket file:", err) 436 | } 437 | bucketReader := bytes.NewReader(bucketByteData) 438 | bucketData := types.AlertBucket{} 439 | err = json.NewDecoder(bucketReader).Decode(&bucketData) 440 | if err != nil { 441 | t.Fatal("Error while parsing bucket file:", err) 442 | } 443 | return bucketData 444 | } 445 | -------------------------------------------------------------------------------- /alertparser/test_mixed_alerts.json: -------------------------------------------------------------------------------- 1 | { 2 | "GroupLabels": { 3 | "label": "test", 4 | "environment": "production" 5 | }, 6 | "Alerts": [ 7 | { 8 | "status": "firing", 9 | "labels": { 10 | "environment": "production", 11 | "label": "test", 12 | "severity": "warning", 13 | "alertname": "TestAlert", 14 | "oid": "1.2.3.2.1" 15 | }, 16 | "annotations": { 17 | "summary": "this is the random summary", 18 | "description": "this is the description of alert 1" 19 | } 20 | }, 21 | { 22 | "status": "resolved", 23 | "labels": { 24 | "environment": "production", 25 | "label": "test", 26 | "severity": "warning", 27 | "alertname": "TestAlert", 28 | "oid": "1.2.3.1.1" 29 | }, 30 | "annotations": { 31 | "summary": "this is the random summary", 32 | "description": "this is the description of the alert" 33 | } 34 | }, 35 | { 36 | "status": "firing", 37 | "labels": { 38 | "environment": "production", 39 | "label": "test", 40 | "severity": "critical", 41 | "alertname": "TestAlert", 42 | "oid": "1.2.3.2.1" 43 | }, 44 | "annotations": { 45 | "summary": "this is the summary", 46 | "description": "this is the description on job1" 47 | } 48 | }, 49 | { 50 | "status": "firing", 51 | "labels": { 52 | "environment": "production", 53 | "label": "test", 54 | "severity": "critical", 55 | "alertname": "TestAlertWithoutOID" 56 | }, 57 | "annotations": { 58 | "summary": "this is the summary", 59 | "description": "this is the description on TestAlertWithoutOID" 60 | } 61 | } 62 | ] 63 | } 64 | -------------------------------------------------------------------------------- /alertparser/test_mixed_bucket.json: -------------------------------------------------------------------------------- 1 | { 2 | "AlertGroups": { 3 | "1.2.3.2.1[environment=production,label=test]": { 4 | "TrapOID": "1.2.3.2.1", 5 | "GroupID": "environment=production,label=test", 6 | "DefaultObjectsBaseOID": "4.4.4", 7 | "UserObjectsBaseOID": "4.4.5", 8 | "GroupLabels": { 9 | "environment": "production", 10 | "label": "test" 11 | }, 12 | "Severity": "critical", 13 | "Alerts": [ 14 | { 15 | "status": "firing", 16 | "labels": { 17 | "environment": "production", 18 | "label": "test", 19 | "severity": "warning", 20 | "alertname": "TestAlert", 21 | "oid": "1.2.3.2.1" 22 | }, 23 | "annotations": { 24 | "summary": "this is the random summary", 25 | "description": "this is the description of alert 1" 26 | } 27 | }, 28 | { 29 | "status": "firing", 30 | "labels": { 31 | "environment": "production", 32 | "label": "test", 33 | "severity": "critical", 34 | "alertname": "TestAlert", 35 | "oid": "1.2.3.2.1" 36 | }, 37 | "annotations": { 38 | "summary": "this is the summary", 39 | "description": "this is the description on job1" 40 | } 41 | } 42 | ], 43 | "DeclaredAlerts": [ 44 | { 45 | "status": "firing", 46 | "labels": { 47 | "environment": "production", 48 | "label": "test", 49 | "severity": "warning", 50 | "alertname": "TestAlert", 51 | "oid": "1.2.3.2.1" 52 | }, 53 | "annotations": { 54 | "summary": "this is the random summary", 55 | "description": "this is the description of alert 1" 56 | } 57 | }, 58 | { 59 | "status": "firing", 60 | "labels": { 61 | "environment": "production", 62 | "label": "test", 63 | "severity": "critical", 64 | "alertname": "TestAlert", 65 | "oid": "1.2.3.2.1" 66 | }, 67 | "annotations": { 68 | "summary": "this is the summary", 69 | "description": "this is the description on job1" 70 | } 71 | } 72 | ] 73 | }, 74 | "1.1[environment=production,label=test]": { 75 | "TrapOID": "1.1", 76 | "GroupID": "environment=production,label=test", 77 | "DefaultObjectsBaseOID": "4.4.4", 78 | "UserObjectsBaseOID": "4.4.5", 79 | "GroupLabels": { 80 | "environment": "production", 81 | "label": "test" 82 | }, 83 | "Severity": "critical", 84 | "Alerts": [ 85 | { 86 | "status": "firing", 87 | "labels": { 88 | "environment": "production", 89 | "label": "test", 90 | "severity": "critical", 91 | "alertname": "TestAlertWithoutOID" 92 | }, 93 | "annotations": { 94 | "summary": "this is the summary", 95 | "description": "this is the description on TestAlertWithoutOID" 96 | } 97 | } 98 | ], 99 | "DeclaredAlerts": [ 100 | { 101 | "status": "firing", 102 | "labels": { 103 | "environment": "production", 104 | "label": "test", 105 | "severity": "critical", 106 | "alertname": "TestAlertWithoutOID" 107 | }, 108 | "annotations": { 109 | "summary": "this is the summary", 110 | "description": "this is the description on TestAlertWithoutOID" 111 | } 112 | } 113 | ] 114 | }, 115 | "1.2.3.1.1[environment=production,label=test]": { 116 | "TrapOID": "1.2.3.1.1", 117 | "GroupID": "environment=production,label=test", 118 | "DefaultObjectsBaseOID": "4.4.4", 119 | "UserObjectsBaseOID": "4.4.5", 120 | "GroupLabels": { 121 | "environment": "production", 122 | "label": "test" 123 | }, 124 | "Severity": "info", 125 | "Alerts": [], 126 | "DeclaredAlerts": [ 127 | { 128 | "status": "resolved", 129 | "labels": { 130 | "environment": "production", 131 | "label": "test", 132 | "severity": "warning", 133 | "alertname": "TestAlert", 134 | "oid": "1.2.3.1.1" 135 | }, 136 | "annotations": { 137 | "summary": "this is the random summary", 138 | "description": "this is the description of the alert" 139 | } 140 | } 141 | ] 142 | } 143 | } 144 | } 145 | -------------------------------------------------------------------------------- /alertparser/test_resolved_alerts.json: -------------------------------------------------------------------------------- 1 | { 2 | "GroupLabels": { 3 | "label": "test", 4 | "environment": "production" 5 | }, 6 | "Alerts": [ 7 | { 8 | "status": "resolved", 9 | "labels": { 10 | "environment": "production", 11 | "label": "test", 12 | "severity": "warning", 13 | "alertname": "TestAlert1" 14 | }, 15 | "annotations": { 16 | "summary": "sample alert 1", 17 | "description": "part of a group" 18 | } 19 | }, 20 | { 21 | "status": "resolved", 22 | "labels": { 23 | "environment": "production", 24 | "label": "test", 25 | "severity": "critical", 26 | "alertname": "TestAlert2" 27 | }, 28 | "annotations": { 29 | "summary": "sample alert 2", 30 | "description": "part of a group" 31 | } 32 | } 33 | ] 34 | } 35 | -------------------------------------------------------------------------------- /alertparser/test_resolved_and_firing_oid_from_labels_alerts.json: -------------------------------------------------------------------------------- 1 | { 2 | "AlertGroups": { 3 | "8.8.8-7.7.7[environment=production,label=test]": { 4 | "TrapOID": "7.7.7", 5 | "GroupID": "environment=production,label=test", 6 | "DefaultObjectsBaseOID": "4.4.4", 7 | "UserObjectsBaseOID": "4.4.5", 8 | "GroupLabels": { 9 | "environment": "production", 10 | "label": "test" 11 | }, 12 | "Severity": "info", 13 | "Alerts": [], 14 | "DeclaredAlerts": [ 15 | { 16 | "status": "resolved", 17 | "labels": { 18 | "environment": "production", 19 | "label": "test", 20 | "severity": "warning", 21 | "alertname": "TestAlert1", 22 | "oid": "8.8.8", 23 | "resolution-oid": "7.7.7" 24 | }, 25 | "annotations": { 26 | "summary": "sample alert 1", 27 | "description": "part of a group" 28 | } 29 | }, 30 | { 31 | "status": "resolved", 32 | "labels": { 33 | "environment": "production", 34 | "label": "test", 35 | "severity": "critical", 36 | "alertname": "TestAlert2", 37 | "oid": "8.8.8", 38 | "resolution-oid": "7.7.7" 39 | }, 40 | "annotations": { 41 | "summary": "sample alert 2", 42 | "description": "part of a group" 43 | } 44 | } 45 | ] 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /alertparser/test_resolved_default_firing_oid_alerts.json: -------------------------------------------------------------------------------- 1 | { 2 | "AlertGroups": { 3 | "1.1-2.2[environment=production,label=test]": { 4 | "TrapOID": "1.1", 5 | "GroupID": "environment=production,label=test", 6 | "DefaultObjectsBaseOID": "4.4.4", 7 | "UserObjectsBaseOID": "4.4.5", 8 | "GroupLabels": { 9 | "environment": "production", 10 | "label": "test" 11 | }, 12 | "Severity": "warning", 13 | "Alerts": [ 14 | { 15 | "status": "firing", 16 | "labels": { 17 | "environment": "production", 18 | "label": "test", 19 | "severity": "warning", 20 | "alertname": "TestAlert1" 21 | }, 22 | "annotations": { 23 | "summary": "sample alert 1", 24 | "description": "part of a group" 25 | } 26 | } 27 | ], 28 | "DeclaredAlerts": [ 29 | { 30 | "status": "firing", 31 | "labels": { 32 | "environment": "production", 33 | "label": "test", 34 | "severity": "warning", 35 | "alertname": "TestAlert1" 36 | }, 37 | "annotations": { 38 | "summary": "sample alert 1", 39 | "description": "part of a group" 40 | } 41 | }, 42 | { 43 | "status": "resolved", 44 | "labels": { 45 | "environment": "production", 46 | "label": "test", 47 | "severity": "critical", 48 | "alertname": "TestAlert2" 49 | }, 50 | "annotations": { 51 | "summary": "sample alert 2", 52 | "description": "part of a group" 53 | } 54 | } 55 | ] 56 | } 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /alertparser/test_resolved_default_resolved_oid_alerts.json: -------------------------------------------------------------------------------- 1 | { 2 | "AlertGroups": { 3 | "1.1-2.2[environment=production,label=test]": { 4 | "TrapOID": "2.2", 5 | "GroupID": "environment=production,label=test", 6 | "DefaultObjectsBaseOID": "4.4.4", 7 | "UserObjectsBaseOID": "4.4.5", 8 | "GroupLabels": { 9 | "environment": "production", 10 | "label": "test" 11 | }, 12 | "Severity": "info", 13 | "Alerts": [], 14 | "DeclaredAlerts": [ 15 | { 16 | "status": "resolved", 17 | "labels": { 18 | "environment": "production", 19 | "label": "test", 20 | "severity": "warning", 21 | "alertname": "TestAlert1" 22 | }, 23 | "annotations": { 24 | "summary": "sample alert 1", 25 | "description": "part of a group" 26 | } 27 | }, 28 | { 29 | "status": "resolved", 30 | "labels": { 31 | "environment": "production", 32 | "label": "test", 33 | "severity": "critical", 34 | "alertname": "TestAlert2" 35 | }, 36 | "annotations": { 37 | "summary": "sample alert 2", 38 | "description": "part of a group" 39 | } 40 | } 41 | ] 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /alertparser/test_resolved_oid_from_labels_alerts.json: -------------------------------------------------------------------------------- 1 | { 2 | "AlertGroups": { 3 | "1.1-7.7.7[environment=production,label=test]": { 4 | "TrapOID": "7.7.7", 5 | "GroupID": "environment=production,label=test", 6 | "DefaultObjectsBaseOID": "4.4.4", 7 | "UserObjectsBaseOID": "4.4.5", 8 | "GroupLabels": { 9 | "environment": "production", 10 | "label": "test" 11 | }, 12 | "Severity": "info", 13 | "Alerts": [], 14 | "DeclaredAlerts": [ 15 | { 16 | "status": "resolved", 17 | "labels": { 18 | "environment": "production", 19 | "label": "test", 20 | "severity": "warning", 21 | "alertname": "TestAlert1", 22 | "resolution-oid": "7.7.7" 23 | }, 24 | "annotations": { 25 | "summary": "sample alert 1", 26 | "description": "part of a group" 27 | } 28 | }, 29 | { 30 | "status": "resolved", 31 | "labels": { 32 | "environment": "production", 33 | "label": "test", 34 | "severity": "critical", 35 | "alertname": "TestAlert2", 36 | "resolution-oid": "7.7.7" 37 | }, 38 | "annotations": { 39 | "summary": "sample alert 2", 40 | "description": "part of a group" 41 | } 42 | } 43 | ] 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /alertparser/test_unique_alert.json: -------------------------------------------------------------------------------- 1 | { 2 | "GroupLabels": { 3 | "label": "test", 4 | "environment": "production" 5 | }, 6 | "Alerts": [ 7 | { 8 | "status": "firing", 9 | "labels": { 10 | "environment": "production", 11 | "label": "test", 12 | "severity": "warning", 13 | "alertname": "TestAlert", 14 | "firing-oid": "1.2.3", 15 | "resolution-oid": "1.2.4" 16 | }, 17 | "annotations": { 18 | "summary": "this is a random summary", 19 | "description": "this a description" 20 | } 21 | } 22 | ] 23 | } 24 | -------------------------------------------------------------------------------- /commons/commons.go: -------------------------------------------------------------------------------- 1 | // Copyright 2018 Maxime Wojtczak 2 | // Licensed under the Apache License, Version 2.0 (the "License"); 3 | // you may not use this file except in compliance with the License. 4 | // You may obtain a copy of the License at 5 | // 6 | // http://www.apache.org/licenses/LICENSE-2.0 7 | // 8 | // Unless required by applicable law or agreed to in writing, software 9 | // distributed under the License is distributed on an "AS IS" BASIS, 10 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | // See the License for the specific language governing permissions and 12 | // limitations under the License. 13 | 14 | package commons 15 | 16 | import ( 17 | "bytes" 18 | "regexp" 19 | 20 | "text/template" 21 | 22 | "github.com/maxwo/snmp_notifier/types" 23 | ) 24 | 25 | var oidRegexp = regexp.MustCompile("^[0-9]+((\\.[0-9]+)*)$") 26 | 27 | // FillTemplate is a boiler-plate function to fill a template 28 | func FillTemplate(object interface{}, tmpl template.Template) (*string, error) { 29 | buf := &bytes.Buffer{} 30 | err := tmpl.Execute(buf, object) 31 | if err != nil { 32 | return nil, err 33 | } 34 | var result = buf.String() 35 | return &result, err 36 | } 37 | 38 | // GroupAlertsByLabel groups several alerts by a given label. If the label does not exists, then a "" key is created 39 | func GroupAlertsByLabel(alerts []types.Alert, label string) (*map[string][]types.Alert, error) { 40 | return GroupAlertsBy(alerts, getAlertLabel(label)) 41 | } 42 | 43 | // GroupAlertsByName groups several alerts by their names 44 | func GroupAlertsByName(alerts []types.Alert) (*map[string][]types.Alert, error) { 45 | return GroupAlertsBy(alerts, getAlertLabel("alertname")) 46 | } 47 | 48 | // GroupAlertsByStatus groups several alerts by their statuses 49 | func GroupAlertsByStatus(alerts []types.Alert) (*map[string][]types.Alert, error) { 50 | return GroupAlertsBy(alerts, getAlertStatus()) 51 | } 52 | 53 | func getAlertStatus() types.GetAlertGroupName { 54 | return func(alert types.Alert) (*string, error) { 55 | return &alert.Status, nil 56 | } 57 | } 58 | 59 | func getAlertLabel(label string) types.GetAlertGroupName { 60 | return func(alert types.Alert) (*string, error) { 61 | value := "" 62 | if _, found := alert.Labels[label]; found { 63 | value = alert.Labels[label] 64 | } 65 | return &value, nil 66 | } 67 | } 68 | 69 | // GroupAlertsBy groups given alerts according to an ID 70 | func GroupAlertsBy(alerts []types.Alert, groupNameFunction types.GetAlertGroupName) (*map[string][]types.Alert, error) { 71 | var groups = make(map[string][]types.Alert) 72 | for _, alert := range alerts { 73 | groupName, err := groupNameFunction(alert) 74 | if err != nil { 75 | return nil, err 76 | } 77 | groups[*groupName] = append(groups[*groupName], alert) 78 | } 79 | return &groups, nil 80 | } 81 | 82 | // IsOID checks if a given string is a valid OID 83 | func IsOID(text string) bool { 84 | return oidRegexp.MatchString(text) 85 | } 86 | 87 | // IndexOf returns the position of a given element in a string slice 88 | func IndexOf(element string, data []string) int { 89 | for k, v := range data { 90 | if element == v { 91 | return k 92 | } 93 | } 94 | return -1 95 | } 96 | -------------------------------------------------------------------------------- /commons/commons_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2018 Maxime Wojtczak 2 | // Licensed under the Apache License, Version 2.0 (the "License"); 3 | // you may not use this file except in compliance with the License. 4 | // You may obtain a copy of the License at 5 | // 6 | // http://www.apache.org/licenses/LICENSE-2.0 7 | // 8 | // Unless required by applicable law or agreed to in writing, software 9 | // distributed under the License is distributed on an "AS IS" BASIS, 10 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | // See the License for the specific language governing permissions and 12 | // limitations under the License. 13 | 14 | package commons 15 | 16 | import ( 17 | "bytes" 18 | "encoding/json" 19 | "fmt" 20 | "io/ioutil" 21 | "testing" 22 | 23 | "text/template" 24 | 25 | "github.com/go-test/deep" 26 | 27 | "github.com/maxwo/snmp_notifier/types" 28 | ) 29 | 30 | func TestFillTemplate(t *testing.T) { 31 | var tests = []struct { 32 | template string 33 | object interface{} 34 | result string 35 | }{ 36 | { 37 | "Hello {{ .Title }}", 38 | struct { 39 | Title string 40 | }{ 41 | "title", 42 | }, 43 | "Hello title", 44 | }, 45 | } 46 | for _, test := range tests { 47 | template, err := template.New(test.template).Parse(test.template) 48 | if err != nil { 49 | t.Error("Unable to compile template", err) 50 | continue 51 | } 52 | if result, _ := FillTemplate(test.object, *template); *result != test.result { 53 | t.Errorf("FillTemplate of [%s] shoud be [%s], got [%s]", test.template, test.result, *result) 54 | } 55 | } 56 | } 57 | 58 | func TestGroupAlertsByName(t *testing.T) { 59 | var tests = []struct { 60 | AlertsFileName string 61 | GroupsFileName string 62 | ExpectError bool 63 | }{ 64 | { 65 | "test_alerts.json", 66 | "test_groups_alertname.json", 67 | false, 68 | }, 69 | } 70 | 71 | for _, test := range tests { 72 | alertsByteData, err := ioutil.ReadFile(test.AlertsFileName) 73 | if err != nil { 74 | t.Fatal("Error while reading alert file", err) 75 | } 76 | alertsReader := bytes.NewReader(alertsByteData) 77 | alertsData := []types.Alert{} 78 | err = json.NewDecoder(alertsReader).Decode(&alertsData) 79 | if err != nil { 80 | t.Fatal("Error while parsing alert file", err) 81 | } 82 | 83 | groupsByteData, err := ioutil.ReadFile(test.GroupsFileName) 84 | if err != nil { 85 | t.Fatal("Error while reading group file", err) 86 | } 87 | groupsReader := bytes.NewReader(groupsByteData) 88 | groupsData := map[string][]types.Alert{} 89 | err = json.NewDecoder(groupsReader).Decode(&groupsData) 90 | if err != nil { 91 | t.Fatal("Error while parsing group file", err) 92 | } 93 | 94 | groups, err := GroupAlertsByName(alertsData) 95 | 96 | if test.ExpectError && err == nil { 97 | t.Error("An error was expected") 98 | continue 99 | } 100 | 101 | if !test.ExpectError && err != nil { 102 | t.Error("Unexpected error", err) 103 | continue 104 | } 105 | 106 | if err == nil { 107 | if diff := deep.Equal(groupsData, *groups); diff != nil { 108 | t.Error(diff) 109 | } 110 | } 111 | } 112 | } 113 | 114 | func TestGroupAlertsByLabel(t *testing.T) { 115 | var tests = []struct { 116 | AlertsFileName string 117 | Label string 118 | GroupsFileName string 119 | ExpectError bool 120 | }{ 121 | { 122 | "test_alerts.json", 123 | "alertname", 124 | "test_groups_alertname.json", 125 | false, 126 | }, 127 | } 128 | 129 | for _, test := range tests { 130 | alertsByteData, err := ioutil.ReadFile(test.AlertsFileName) 131 | if err != nil { 132 | t.Fatal("Error while reading alert file", err) 133 | } 134 | alertsReader := bytes.NewReader(alertsByteData) 135 | alertsData := []types.Alert{} 136 | err = json.NewDecoder(alertsReader).Decode(&alertsData) 137 | if err != nil { 138 | t.Fatal("Error while parsing alert file", err) 139 | } 140 | 141 | groupsByteData, err := ioutil.ReadFile(test.GroupsFileName) 142 | if err != nil { 143 | t.Fatal("Error while reading group file", err) 144 | } 145 | groupsReader := bytes.NewReader(groupsByteData) 146 | groupsData := map[string][]types.Alert{} 147 | err = json.NewDecoder(groupsReader).Decode(&groupsData) 148 | if err != nil { 149 | t.Fatal("Error while parsing group file", err) 150 | } 151 | 152 | groups, err := GroupAlertsByLabel(alertsData, test.Label) 153 | 154 | if test.ExpectError && err == nil { 155 | t.Error("An error was expected") 156 | continue 157 | } 158 | 159 | if !test.ExpectError && err != nil { 160 | t.Error("Unexpected error", err) 161 | continue 162 | } 163 | 164 | if err == nil { 165 | if diff := deep.Equal(groupsData, *groups); diff != nil { 166 | t.Error(diff) 167 | } 168 | } 169 | } 170 | } 171 | 172 | func TestGroupAlertsBy(t *testing.T) { 173 | var tests = []struct { 174 | AlertsFileName string 175 | Classifier types.GetAlertGroupName 176 | GroupsFileName string 177 | ExpectError bool 178 | }{ 179 | { 180 | "test_alerts.json", 181 | func(alert types.Alert) (*string, error) { 182 | oid := alert.Labels["oid"] 183 | return &oid, nil 184 | }, 185 | "test_groups.json", 186 | false, 187 | }, 188 | { 189 | "test_alerts.json", 190 | func(alert types.Alert) (*string, error) { 191 | return nil, fmt.Errorf("Ohlala") 192 | }, 193 | "test_groups.json", 194 | true, 195 | }, 196 | } 197 | 198 | for _, test := range tests { 199 | alertsByteData, err := ioutil.ReadFile(test.AlertsFileName) 200 | if err != nil { 201 | t.Fatal("Error while reading alert file", err) 202 | } 203 | alertsReader := bytes.NewReader(alertsByteData) 204 | alertsData := []types.Alert{} 205 | err = json.NewDecoder(alertsReader).Decode(&alertsData) 206 | if err != nil { 207 | t.Fatal("Error while parsing alert file", err) 208 | } 209 | 210 | groupsByteData, err := ioutil.ReadFile(test.GroupsFileName) 211 | if err != nil { 212 | t.Fatal("Error while reading group file", err) 213 | } 214 | groupsReader := bytes.NewReader(groupsByteData) 215 | groupsData := map[string][]types.Alert{} 216 | err = json.NewDecoder(groupsReader).Decode(&groupsData) 217 | if err != nil { 218 | t.Fatal("Error while parsing group file", err) 219 | } 220 | 221 | groups, err := GroupAlertsBy(alertsData, test.Classifier) 222 | 223 | if test.ExpectError && err == nil { 224 | t.Error("An error was expected") 225 | continue 226 | } 227 | 228 | if !test.ExpectError && err != nil { 229 | t.Error("Unexpected error", err) 230 | continue 231 | } 232 | 233 | if err == nil { 234 | if diff := deep.Equal(groupsData, *groups); diff != nil { 235 | t.Error(diff) 236 | } 237 | } 238 | } 239 | } 240 | 241 | func TestIsOID(t *testing.T) { 242 | var oids = map[string]bool{ 243 | "1": true, 244 | "1.1": true, 245 | "1.2.3.4.5.6.7.8.9": true, 246 | "dlfjqklsjf": false, 247 | "": false, 248 | "1.": false, 249 | "1a": false, 250 | "1.a": false, 251 | "aaaaa1.1.1": false, 252 | "1.1aaaa": false, 253 | } 254 | for oid, result := range oids { 255 | if IsOID(oid) != result { 256 | t.Errorf("OID %s shoud be %t", oid, result) 257 | } 258 | } 259 | } 260 | 261 | func TestIndexOf(t *testing.T) { 262 | var tests = []struct { 263 | list []string 264 | value string 265 | result int 266 | }{ 267 | { 268 | []string{ 269 | "element1", 270 | "element2", 271 | }, 272 | "element2", 273 | 1, 274 | }, 275 | { 276 | []string{ 277 | "element1", 278 | "element2", 279 | }, 280 | "not_found", 281 | -1, 282 | }, 283 | } 284 | for _, test := range tests { 285 | if IndexOf(test.value, test.list) != test.result { 286 | t.Errorf("IndexOf of %s shoud be %d", test.value, test.result) 287 | } 288 | } 289 | } 290 | -------------------------------------------------------------------------------- /commons/test_alerts.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "status": "firing", 4 | "labels": { 5 | "severity": "warning", 6 | "alertname": "TestAlert1", 7 | "oid": "1.2.3.2.1" 8 | }, 9 | "annotations": { 10 | "summary": "this is the random summary", 11 | "description": "this is the description of alert 1" 12 | } 13 | }, 14 | { 15 | "status": "resolved", 16 | "labels": { 17 | "severity": "warning", 18 | "alertname": "TestAlert2", 19 | "oid": "1.2.3.1.1" 20 | }, 21 | "annotations": { 22 | "summary": "this is the random summary", 23 | "description": "this is the description of ActiveMQ alert" 24 | } 25 | }, 26 | { 27 | "status": "firing", 28 | "labels": { 29 | "severity": "critical", 30 | "alertname": "TestAlert1", 31 | "oid": "1.2.3.2.1" 32 | }, 33 | "annotations": { 34 | "summary": "this is the summary", 35 | "description": "this is the description on job1" 36 | } 37 | } 38 | ] 39 | -------------------------------------------------------------------------------- /commons/test_groups.json: -------------------------------------------------------------------------------- 1 | { 2 | "1.2.3.2.1": [ 3 | { 4 | "status": "firing", 5 | "labels": { 6 | "severity": "warning", 7 | "alertname": "TestAlert1", 8 | "oid": "1.2.3.2.1" 9 | }, 10 | "annotations": { 11 | "summary": "this is the random summary", 12 | "description": "this is the description of alert 1" 13 | } 14 | }, 15 | { 16 | "status": "firing", 17 | "labels": { 18 | "severity": "critical", 19 | "alertname": "TestAlert1", 20 | "oid": "1.2.3.2.1" 21 | }, 22 | "annotations": { 23 | "summary": "this is the summary", 24 | "description": "this is the description on job1" 25 | } 26 | } 27 | ], 28 | "1.2.3.1.1": [ 29 | { 30 | "status": "resolved", 31 | "labels": { 32 | "severity": "warning", 33 | "alertname": "TestAlert2", 34 | "oid": "1.2.3.1.1" 35 | }, 36 | "annotations": { 37 | "summary": "this is the random summary", 38 | "description": "this is the description of ActiveMQ alert" 39 | } 40 | } 41 | ] 42 | } 43 | -------------------------------------------------------------------------------- /commons/test_groups_alertname.json: -------------------------------------------------------------------------------- 1 | { 2 | "TestAlert1": [ 3 | { 4 | "status": "firing", 5 | "labels": { 6 | "severity": "warning", 7 | "alertname": "TestAlert1", 8 | "oid": "1.2.3.2.1" 9 | }, 10 | "annotations": { 11 | "summary": "this is the random summary", 12 | "description": "this is the description of alert 1" 13 | } 14 | }, 15 | { 16 | "status": "firing", 17 | "labels": { 18 | "severity": "critical", 19 | "alertname": "TestAlert1", 20 | "oid": "1.2.3.2.1" 21 | }, 22 | "annotations": { 23 | "summary": "this is the summary", 24 | "description": "this is the description on job1" 25 | } 26 | } 27 | ], 28 | "TestAlert2": [ 29 | { 30 | "status": "resolved", 31 | "labels": { 32 | "severity": "warning", 33 | "alertname": "TestAlert2", 34 | "oid": "1.2.3.1.1" 35 | }, 36 | "annotations": { 37 | "summary": "this is the random summary", 38 | "description": "this is the description of ActiveMQ alert" 39 | } 40 | } 41 | ] 42 | } 43 | -------------------------------------------------------------------------------- /configuration/configuration.go: -------------------------------------------------------------------------------- 1 | // Copyright 2018 Maxime Wojtczak 2 | // Licensed under the Apache License, Version 2.0 (the "License"); 3 | // you may not use this file except in compliance with the License. 4 | // You may obtain a copy of the License at 5 | // 6 | // http://www.apache.org/licenses/LICENSE-2.0 7 | // 8 | // Unless required by applicable law or agreed to in writing, software 9 | // distributed under the License is distributed on an "AS IS" BASIS, 10 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | // See the License for the specific language governing permissions and 12 | // limitations under the License. 13 | 14 | package configuration 15 | 16 | import ( 17 | "fmt" 18 | "path/filepath" 19 | "sort" 20 | "strings" 21 | "text/template" 22 | 23 | "log/slog" 24 | 25 | "github.com/prometheus/common/promslog" 26 | "github.com/prometheus/common/promslog/flag" 27 | "github.com/prometheus/exporter-toolkit/web/kingpinflag" 28 | 29 | "github.com/maxwo/snmp_notifier/alertparser" 30 | "github.com/maxwo/snmp_notifier/commons" 31 | "github.com/maxwo/snmp_notifier/httpserver" 32 | "github.com/maxwo/snmp_notifier/trapsender" 33 | 34 | "strconv" 35 | 36 | kingpin "github.com/alecthomas/kingpin/v2" 37 | "github.com/prometheus/common/version" 38 | ) 39 | 40 | // SNMPNotifierConfiguration handles the configuration of the whole application 41 | type SNMPNotifierConfiguration struct { 42 | AlertParserConfiguration alertparser.Configuration 43 | TrapSenderConfiguration trapsender.Configuration 44 | HTTPServerConfiguration httpserver.Configuration 45 | } 46 | 47 | var ( 48 | snmpCommunityEnvironmentVariable = "SNMP_NOTIFIER_COMMUNITY" 49 | snmpAuthUsernameEnvironmentVariable = "SNMP_NOTIFIER_AUTH_USERNAME" 50 | snmpAuthPasswordEnvironmentVariable = "SNMP_NOTIFIER_AUTH_PASSWORD" 51 | snmpPrivPasswordEnvironmentVariable = "SNMP_NOTIFIER_PRIV_PASSWORD" 52 | ) 53 | 54 | // ParseConfiguration parses the command line for configurations 55 | func ParseConfiguration(args []string) (*SNMPNotifierConfiguration, *slog.Logger, error) { 56 | var ( 57 | application = kingpin.New("snmp_notifier", "A tool to relay Prometheus alerts as SNMP traps") 58 | toolKitConfiguration = kingpinflag.AddFlags(application, ":9464") 59 | 60 | alertSeverityLabel = application.Flag("alert.severity-label", "Label where to find the alert severity.").Default("severity").String() 61 | alertSeverities = application.Flag("alert.severities", "The ordered list of alert severities, from more priority to less priority.").Default("critical,warning,info").String() 62 | alertDefaultSeverity = application.Flag("alert.default-severity", "The alert severity if none is provided via labels.").Default("critical").String() 63 | 64 | // SNMP configuration 65 | snmpVersion = application.Flag("snmp.version", "SNMP version. V2c and V3 are currently supported.").Default("V2c").HintOptions("V2c", "V3").Enum("V2c", "V3") 66 | snmpDestination = application.Flag("snmp.destination", "SNMP trap server destination.").Default("127.0.0.1:162").TCPList() 67 | snmpRetries = application.Flag("snmp.retries", "SNMP number of retries").Default("1").Uint() 68 | snmpTimeout = application.Flag("snmp.timeout", "SNMP timeout duration").Default("5s").Duration() 69 | 70 | // V2c only 71 | snmpCommunity = application.Flag("snmp.community", "SNMP community (V2c only). Passing secrets to the command line is not recommended, consider using the SNMP_NOTIFIER_COMMUNITY environment variable instead.").Envar(snmpCommunityEnvironmentVariable).Default("public").String() 72 | 73 | // V3 only 74 | snmpAuthenticationEnabled = application.Flag("snmp.authentication-enabled", "Enable SNMP authentication (V3 only).").Default("false").Bool() 75 | snmpAuthenticationProtocol = application.Flag("snmp.authentication-protocol", "Protocol for password encryption (V3 only). MD5 and SHA are currently supported.").Default("MD5").HintOptions("MD5", "SHA").Enum("MD5", "SHA") 76 | snmpAuthenticationUsername = application.Flag("snmp.authentication-username", "SNMP authentication username (V3 only). Passing secrets to the command line is not recommended, consider using the SNMP_NOTIFIER_AUTH_USERNAME environment variable instead.").PlaceHolder("USERNAME").Envar(snmpAuthUsernameEnvironmentVariable).String() 77 | snmpAuthenticationPassword = application.Flag("snmp.authentication-password", "SNMP authentication password (V3 only). Passing secrets to the command line is not recommended, consider using the SNMP_NOTIFIER_AUTH_PASSWORD environment variable instead.").PlaceHolder("PASSWORD").Envar(snmpAuthPasswordEnvironmentVariable).String() 78 | snmpPrivateEnabled = application.Flag("snmp.private-enabled", "Enable SNMP encryption (V3 only).").Default("false").Bool() 79 | snmpPrivateProtocol = application.Flag("snmp.private-protocol", "Protocol for SNMP data transmission (V3 only). DES and AES are currently supported.").Default("DES").HintOptions("DES", "AES").Enum("DES", "AES") 80 | snmpPrivatePassword = application.Flag("snmp.private-password", "SNMP private password (V3 only). Passing secrets to the command line is not recommended, consider using the SNMP_NOTIFIER_PRIV_PASSWORD environment variable instead.").PlaceHolder("SECRET").Envar(snmpPrivPasswordEnvironmentVariable).String() 81 | snmpSecurityEngineID = application.Flag("snmp.security-engine-id", "SNMP security engine ID (V3 only).").PlaceHolder("SECURITY_ENGINE_ID").String() 82 | snmpContextEngineID = application.Flag("snmp.context-engine-id", "SNMP context engine ID (V3 only).").PlaceHolder("CONTEXT_ENGINE_ID").String() 83 | snmpContextName = application.Flag("snmp.context-name", "SNMP context name (V3 only).").PlaceHolder("CONTEXT_ENGINE_NAME").String() 84 | 85 | // Trap configurations 86 | trapDefaultOID = application.Flag("trap.default-oid", "Default trap OID.").Default("1.3.6.1.4.1.98789.1").String() 87 | trapOIDLabel = application.Flag("trap.oid-label", "Label containing a custom trap OID.").Default("oid").String() 88 | trapResolutionDefaultOID = application.Flag("trap.resolution-default-oid", "Resolution trap OID, if different from the firing trap OID.").String() 89 | trapResolutionOIDLabel = application.Flag("trap.resolution-oid-label", "Label containing a custom resolution trap OID, if different from the firing trap OID.").String() 90 | trapDefaultObjectsBaseOID = application.Flag("trap.default-objects-base-oid", "Base OID for default trap objects.").Default("1.3.6.1.4.1.98789.2").String() 91 | trapDescriptionTemplate = application.Flag("trap.description-template", "Trap description template.").Default("description-template.tpl").ExistingFile() 92 | trapUserObjectsBaseOID = application.Flag("trap.user-objects-base-oid", "Base OID for user-defined trap objects.").Default("1.3.6.1.4.1.98789.3").String() 93 | trapUserObject = application.Flag("trap.user-object", "User object sub-OID and template, e.g. --trap.user-object=4=new-object.template.tpl to add a sub-object to the trap, with the given template file. You may add several user objects using that flag several times.").PlaceHolder("4=user-object-template.tpl").StringMap() 94 | ) 95 | 96 | promslogConfig := &promslog.Config{} 97 | flag.AddFlags(application, promslogConfig) 98 | 99 | application.Version(version.Print("snmp_notifier")) 100 | application.HelpFlag.Short('h') 101 | kingpin.MustParse(application.Parse(args)) 102 | 103 | logger := promslog.New(promslogConfig) 104 | logger.Info("Starting snmp_notifier", "version", version.Info()) 105 | logger.Info("Build context", "build_context", version.BuildContext()) 106 | 107 | descriptionTemplate, err := template.New(filepath.Base(*trapDescriptionTemplate)).Funcs(template.FuncMap{ 108 | "groupAlertsByLabel": commons.GroupAlertsByLabel, 109 | "groupAlertsByName": commons.GroupAlertsByName, 110 | "groupAlertsByStatus": commons.GroupAlertsByStatus, 111 | }).ParseFiles(*trapDescriptionTemplate) 112 | if err != nil { 113 | return nil, logger, err 114 | } 115 | 116 | minimumUserObjectSubOID := 0 117 | if *trapDefaultObjectsBaseOID == *trapUserObjectsBaseOID { 118 | logger.Warn("using the same OID for default objects and user objects is deprecated, and will be removed in future versions. Please consider using different OID") 119 | minimumUserObjectSubOID = 4 120 | } 121 | 122 | userObjectsTemplates := make(map[int]template.Template) 123 | if trapUserObject != nil { 124 | for subOid, templatePath := range *trapUserObject { 125 | oidValue, err := strconv.Atoi(subOid) 126 | if err != nil || oidValue < minimumUserObjectSubOID { 127 | return nil, logger, fmt.Errorf("invalid object ID: %s. Object ID must be a number greater or equal to 4", subOid) 128 | } 129 | 130 | _, defined := userObjectsTemplates[oidValue] 131 | if defined { 132 | return nil, logger, fmt.Errorf("invalid object ID: %d defined twice", oidValue) 133 | } 134 | 135 | currentTemplate, err := template.New(filepath.Base(templatePath)).Funcs(template.FuncMap{ 136 | "groupAlertsByLabel": commons.GroupAlertsByLabel, 137 | "groupAlertsByName": commons.GroupAlertsByName, 138 | "groupAlertsByStatus": commons.GroupAlertsByStatus, 139 | }).ParseFiles(templatePath) 140 | if err != nil { 141 | return nil, logger, err 142 | } 143 | 144 | userObjectsTemplates[oidValue] = *currentTemplate 145 | } 146 | } 147 | 148 | subOIDs := make([]int, 0, len(userObjectsTemplates)) 149 | for subOID := range userObjectsTemplates { 150 | subOIDs = append(subOIDs, subOID) 151 | } 152 | sort.Ints(subOIDs) 153 | 154 | userObjects := make([]trapsender.UserObject, len(userObjectsTemplates)) 155 | for index, subOID := range subOIDs { 156 | contentTemplate := userObjectsTemplates[subOID] 157 | userObject := trapsender.UserObject{ 158 | SubOID: subOID, 159 | ContentTemplate: contentTemplate, 160 | } 161 | userObjects[index] = userObject 162 | } 163 | 164 | if !commons.IsOID(*trapDefaultOID) { 165 | return nil, logger, fmt.Errorf("invalid default trap OID provided: %s", *trapDefaultOID) 166 | } 167 | 168 | if *trapResolutionDefaultOID != "" && !commons.IsOID(*trapResolutionDefaultOID) { 169 | return nil, logger, fmt.Errorf("invalid resolution trap OID provided: %s", *trapResolutionDefaultOID) 170 | } else if *trapResolutionDefaultOID == "" { 171 | trapResolutionDefaultOID = nil 172 | } 173 | 174 | if *trapResolutionOIDLabel == "" { 175 | trapResolutionOIDLabel = nil 176 | } 177 | 178 | if !commons.IsOID(*trapDefaultObjectsBaseOID) { 179 | return nil, logger, fmt.Errorf("invalid default objects base OID provided: %s", *trapDefaultObjectsBaseOID) 180 | } 181 | 182 | if !commons.IsOID(*trapUserObjectsBaseOID) { 183 | return nil, logger, fmt.Errorf("invalid user objects base OID provided: %s", *trapUserObjectsBaseOID) 184 | } 185 | 186 | severities := strings.Split(*alertSeverities, ",") 187 | 188 | alertParserConfiguration := alertparser.Configuration{ 189 | TrapDefaultOID: *trapDefaultOID, 190 | TrapOIDLabel: *trapOIDLabel, 191 | TrapResolutionDefaultOID: trapResolutionDefaultOID, 192 | TrapResolutionOIDLabel: trapResolutionOIDLabel, 193 | DefaultSeverity: *alertDefaultSeverity, 194 | Severities: severities, 195 | SeverityLabel: *alertSeverityLabel, 196 | TrapDefaultObjectsBaseOID: *trapDefaultObjectsBaseOID, 197 | TrapUserObjectsBaseOID: *trapUserObjectsBaseOID, 198 | } 199 | 200 | isV2c := *snmpVersion == "V2c" 201 | 202 | snmpDestinations := []string{} 203 | for _, destination := range *snmpDestination { 204 | snmpDestinations = append(snmpDestinations, destination.String()) 205 | } 206 | 207 | trapSenderConfiguration := trapsender.Configuration{ 208 | SNMPVersion: *snmpVersion, 209 | SNMPDestination: snmpDestinations, 210 | SNMPRetries: *snmpRetries, 211 | DescriptionTemplate: *descriptionTemplate, 212 | UserObjects: userObjects, 213 | SNMPTimeout: *snmpTimeout, 214 | } 215 | 216 | if isV2c { 217 | trapSenderConfiguration.SNMPCommunity = *snmpCommunity 218 | } 219 | 220 | if !isV2c { 221 | trapSenderConfiguration.SNMPAuthenticationUsername = *snmpAuthenticationUsername 222 | trapSenderConfiguration.SNMPSecurityEngineID = *snmpSecurityEngineID 223 | trapSenderConfiguration.SNMPContextEngineID = *snmpContextEngineID 224 | trapSenderConfiguration.SNMPContextName = *snmpContextName 225 | } 226 | 227 | if isV2c && (*snmpAuthenticationEnabled || *snmpPrivateEnabled) { 228 | return nil, logger, fmt.Errorf("SNMP authentication or private only available with SNMP v3") 229 | } 230 | 231 | if !*snmpAuthenticationEnabled && *snmpPrivateEnabled { 232 | return nil, logger, fmt.Errorf("SNMP private encryption requires authentication enabled") 233 | } 234 | 235 | if *snmpAuthenticationEnabled { 236 | trapSenderConfiguration.SNMPAuthenticationEnabled = *snmpAuthenticationEnabled 237 | trapSenderConfiguration.SNMPAuthenticationProtocol = *snmpAuthenticationProtocol 238 | trapSenderConfiguration.SNMPAuthenticationPassword = *snmpAuthenticationPassword 239 | } 240 | if *snmpPrivateEnabled { 241 | trapSenderConfiguration.SNMPPrivateEnabled = *snmpPrivateEnabled 242 | trapSenderConfiguration.SNMPPrivateProtocol = *snmpPrivateProtocol 243 | trapSenderConfiguration.SNMPPrivatePassword = *snmpPrivatePassword 244 | } 245 | 246 | httpServerConfiguration := httpserver.Configuration{ 247 | ToolKitConfiguration: *toolKitConfiguration, 248 | } 249 | 250 | configuration := SNMPNotifierConfiguration{ 251 | AlertParserConfiguration: alertParserConfiguration, 252 | TrapSenderConfiguration: trapSenderConfiguration, 253 | HTTPServerConfiguration: httpServerConfiguration, 254 | } 255 | 256 | return &configuration, logger, err 257 | } 258 | -------------------------------------------------------------------------------- /configuration/configuration_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2018 Maxime Wojtczak 2 | // Licensed under the Apache License, Version 2.0 (the "License"); 3 | // you may not use this file except in compliance with the License. 4 | // You may obtain a copy of the License at 5 | // 6 | // http://www.apache.org/licenses/LICENSE-2.0 7 | // 8 | // Unless required by applicable law or agreed to in writing, software 9 | // distributed under the License is distributed on an "AS IS" BASIS, 10 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | // See the License for the specific language governing permissions and 12 | // limitations under the License. 13 | 14 | package configuration 15 | 16 | import ( 17 | "fmt" 18 | "log" 19 | "os" 20 | "path/filepath" 21 | "strings" 22 | "testing" 23 | "text/template" 24 | "time" 25 | 26 | "github.com/maxwo/snmp_notifier/alertparser" 27 | "github.com/maxwo/snmp_notifier/commons" 28 | "github.com/maxwo/snmp_notifier/httpserver" 29 | "github.com/maxwo/snmp_notifier/trapsender" 30 | "github.com/prometheus/exporter-toolkit/web" 31 | 32 | "github.com/go-test/deep" 33 | ) 34 | 35 | var falseValue = false 36 | var emptyString = "" 37 | var testListenAddresses = []string{":1234"} 38 | 39 | func TestDefaultConfiguration(t *testing.T) { 40 | expectConfigurationFromCommandLine(t, 41 | "--web.listen-address=:1234 --trap.description-template=../description-template.tpl", 42 | SNMPNotifierConfiguration{ 43 | alertparser.Configuration{ 44 | TrapDefaultOID: "1.3.6.1.4.1.98789.1", 45 | TrapOIDLabel: "oid", 46 | DefaultSeverity: "critical", 47 | SeverityLabel: "severity", 48 | Severities: []string{"critical", "warning", "info"}, 49 | TrapDefaultObjectsBaseOID: "1.3.6.1.4.1.98789.2", 50 | TrapUserObjectsBaseOID: "1.3.6.1.4.1.98789.3", 51 | }, 52 | trapsender.Configuration{ 53 | SNMPVersion: "V2c", 54 | SNMPDestination: []string{"127.0.0.1:162"}, 55 | SNMPRetries: 1, 56 | SNMPTimeout: 5 * time.Second, 57 | SNMPCommunity: "public", 58 | UserObjects: make([]trapsender.UserObject, 0), 59 | }, 60 | httpserver.Configuration{ 61 | ToolKitConfiguration: web.FlagConfig{ 62 | WebSystemdSocket: &falseValue, 63 | WebConfigFile: &emptyString, 64 | WebListenAddresses: &testListenAddresses, 65 | }, 66 | }, 67 | }, 68 | ) 69 | } 70 | 71 | func TestSimpleConfiguration(t *testing.T) { 72 | expectConfigurationFromCommandLine(t, 73 | "--web.listen-address=:1234 --trap.description-template=../description-template.tpl --snmp.timeout=10s", 74 | SNMPNotifierConfiguration{ 75 | alertparser.Configuration{ 76 | TrapDefaultOID: "1.3.6.1.4.1.98789.1", 77 | TrapOIDLabel: "oid", 78 | DefaultSeverity: "critical", 79 | SeverityLabel: "severity", 80 | Severities: []string{"critical", "warning", "info"}, 81 | TrapDefaultObjectsBaseOID: "1.3.6.1.4.1.98789.2", 82 | TrapUserObjectsBaseOID: "1.3.6.1.4.1.98789.3", 83 | }, 84 | trapsender.Configuration{ 85 | SNMPVersion: "V2c", 86 | SNMPDestination: []string{"127.0.0.1:162"}, 87 | SNMPRetries: 1, 88 | SNMPTimeout: 10 * time.Second, 89 | SNMPCommunity: "public", 90 | UserObjects: make([]trapsender.UserObject, 0), 91 | }, 92 | httpserver.Configuration{ 93 | ToolKitConfiguration: web.FlagConfig{ 94 | WebSystemdSocket: &falseValue, 95 | WebConfigFile: &emptyString, 96 | WebListenAddresses: &testListenAddresses, 97 | }, 98 | }, 99 | }, 100 | ) 101 | } 102 | 103 | func TestV2Configuration(t *testing.T) { 104 | expectConfigurationFromCommandLineAndEnvironmentVariables( 105 | t, 106 | "--web.listen-address=:1234 --trap.description-template=../description-template.tpl --snmp.destination=127.0.0.2:163 --snmp.retries=4 --trap.default-oid=4.4.4 --trap.oid-label=other-oid --alert.default-severity=warning --alert.severity-label=severity --alert.severities=critical,error,warning,info", 107 | map[string]string{ 108 | "SNMP_NOTIFIER_COMMUNITY": "private", 109 | }, 110 | SNMPNotifierConfiguration{ 111 | alertparser.Configuration{ 112 | TrapDefaultOID: "4.4.4", 113 | TrapOIDLabel: "other-oid", 114 | DefaultSeverity: "warning", 115 | SeverityLabel: "severity", 116 | Severities: []string{"critical", "error", "warning", "info"}, 117 | TrapDefaultObjectsBaseOID: "1.3.6.1.4.1.98789.2", 118 | TrapUserObjectsBaseOID: "1.3.6.1.4.1.98789.3", 119 | }, 120 | trapsender.Configuration{ 121 | SNMPVersion: "V2c", 122 | SNMPDestination: []string{"127.0.0.2:163"}, 123 | SNMPRetries: 4, 124 | SNMPTimeout: 5 * time.Second, 125 | SNMPCommunity: "private", 126 | UserObjects: make([]trapsender.UserObject, 0), 127 | }, 128 | httpserver.Configuration{ 129 | ToolKitConfiguration: web.FlagConfig{ 130 | WebSystemdSocket: &falseValue, 131 | WebConfigFile: &emptyString, 132 | WebListenAddresses: &testListenAddresses, 133 | }, 134 | }, 135 | }, 136 | ) 137 | } 138 | 139 | func TestV3Configuration(t *testing.T) { 140 | expectConfigurationFromCommandLineAndEnvironmentVariables( 141 | t, 142 | "--web.listen-address=:1234 --snmp.version=V3 --trap.description-template=../description-template.tpl --snmp.destination=127.0.0.2:163 --snmp.retries=4 --trap.default-oid=4.4.4 --trap.oid-label=other-oid --alert.default-severity=warning --alert.severity-label=severity --alert.severities=critical,error,warning,info", 143 | map[string]string{ 144 | "SNMP_NOTIFIER_COMMUNITY": "private", 145 | }, 146 | SNMPNotifierConfiguration{ 147 | alertparser.Configuration{ 148 | TrapDefaultOID: "4.4.4", 149 | TrapOIDLabel: "other-oid", 150 | DefaultSeverity: "warning", 151 | SeverityLabel: "severity", 152 | Severities: []string{"critical", "error", "warning", "info"}, 153 | TrapDefaultObjectsBaseOID: "1.3.6.1.4.1.98789.2", 154 | TrapUserObjectsBaseOID: "1.3.6.1.4.1.98789.3", 155 | }, 156 | trapsender.Configuration{ 157 | SNMPVersion: "V3", 158 | SNMPDestination: []string{"127.0.0.2:163"}, 159 | SNMPRetries: 4, 160 | SNMPTimeout: 5 * time.Second, 161 | UserObjects: make([]trapsender.UserObject, 0), 162 | }, 163 | httpserver.Configuration{ 164 | ToolKitConfiguration: web.FlagConfig{ 165 | WebSystemdSocket: &falseValue, 166 | WebConfigFile: &emptyString, 167 | WebListenAddresses: &testListenAddresses, 168 | }, 169 | }, 170 | }, 171 | ) 172 | } 173 | 174 | func TestV3AuthenticationConfiguration(t *testing.T) { 175 | expectConfigurationFromCommandLineAndEnvironmentVariables( 176 | t, 177 | "--web.listen-address=:1234 --snmp.version=V3 --snmp.authentication-enabled --trap.description-template=../description-template.tpl --snmp.destination=127.0.0.2:163 --snmp.retries=4 --trap.default-oid=4.4.4 --trap.oid-label=other-oid --alert.default-severity=warning --alert.severity-label=severity --alert.severities=critical,error,warning,info", 178 | map[string]string{ 179 | "SNMP_NOTIFIER_AUTH_USERNAME": "username_v3", 180 | "SNMP_NOTIFIER_AUTH_PASSWORD": "password_v3", 181 | }, 182 | SNMPNotifierConfiguration{ 183 | alertparser.Configuration{ 184 | TrapDefaultOID: "4.4.4", 185 | TrapOIDLabel: "other-oid", 186 | DefaultSeverity: "warning", 187 | SeverityLabel: "severity", 188 | Severities: []string{"critical", "error", "warning", "info"}, 189 | TrapDefaultObjectsBaseOID: "1.3.6.1.4.1.98789.2", 190 | TrapUserObjectsBaseOID: "1.3.6.1.4.1.98789.3", 191 | }, 192 | trapsender.Configuration{ 193 | SNMPVersion: "V3", 194 | SNMPDestination: []string{"127.0.0.2:163"}, 195 | SNMPRetries: 4, 196 | SNMPTimeout: 5 * time.Second, 197 | SNMPAuthenticationEnabled: true, 198 | SNMPAuthenticationProtocol: "MD5", 199 | SNMPAuthenticationUsername: "username_v3", 200 | SNMPAuthenticationPassword: "password_v3", 201 | UserObjects: make([]trapsender.UserObject, 0), 202 | }, 203 | httpserver.Configuration{ 204 | ToolKitConfiguration: web.FlagConfig{ 205 | WebSystemdSocket: &falseValue, 206 | WebConfigFile: &emptyString, 207 | WebListenAddresses: &testListenAddresses, 208 | }, 209 | }, 210 | }, 211 | ) 212 | } 213 | 214 | func TestV3AuthenticationAndPrivateConfiguration(t *testing.T) { 215 | expectConfigurationFromCommandLineAndEnvironmentVariables( 216 | t, "--web.listen-address=:1234 --snmp.version=V3 --snmp.private-enabled --snmp.authentication-enabled --trap.description-template=../description-template.tpl --snmp.destination=127.0.0.2:163 --snmp.retries=4 --trap.default-oid=4.4.4 --trap.oid-label=other-oid --alert.default-severity=warning --alert.severity-label=severity --alert.severities=critical,error,warning,info", 217 | map[string]string{ 218 | "SNMP_NOTIFIER_AUTH_USERNAME": "username_v3", 219 | "SNMP_NOTIFIER_AUTH_PASSWORD": "password_v3", 220 | "SNMP_NOTIFIER_PRIV_PASSWORD": "priv_password_v3", 221 | }, 222 | SNMPNotifierConfiguration{ 223 | alertparser.Configuration{ 224 | TrapDefaultOID: "4.4.4", 225 | TrapOIDLabel: "other-oid", 226 | DefaultSeverity: "warning", 227 | SeverityLabel: "severity", 228 | Severities: []string{"critical", "error", "warning", "info"}, 229 | TrapDefaultObjectsBaseOID: "1.3.6.1.4.1.98789.2", 230 | TrapUserObjectsBaseOID: "1.3.6.1.4.1.98789.3", 231 | }, 232 | trapsender.Configuration{ 233 | SNMPVersion: "V3", 234 | SNMPDestination: []string{"127.0.0.2:163"}, 235 | SNMPRetries: 4, 236 | SNMPTimeout: 5 * time.Second, 237 | SNMPPrivateEnabled: true, 238 | SNMPPrivateProtocol: "DES", 239 | SNMPPrivatePassword: "priv_password_v3", 240 | SNMPAuthenticationEnabled: true, 241 | SNMPAuthenticationProtocol: "MD5", 242 | SNMPAuthenticationUsername: "username_v3", 243 | SNMPAuthenticationPassword: "password_v3", 244 | UserObjects: make([]trapsender.UserObject, 0), 245 | }, 246 | httpserver.Configuration{ 247 | ToolKitConfiguration: web.FlagConfig{ 248 | WebSystemdSocket: &falseValue, 249 | WebConfigFile: &emptyString, 250 | WebListenAddresses: &testListenAddresses, 251 | }, 252 | }, 253 | }, 254 | ) 255 | } 256 | 257 | func TestConfigurationWithDifferentResolvedTrapOIDConfiguration(t *testing.T) { 258 | resolutionOID := "1.3.6.1.4.1.123456" 259 | expectConfigurationFromCommandLine(t, 260 | "--web.listen-address=:1234 --trap.resolution-default-oid=1.3.6.1.4.1.123456 --trap.description-template=../description-template.tpl", 261 | SNMPNotifierConfiguration{ 262 | alertparser.Configuration{ 263 | TrapDefaultOID: "1.3.6.1.4.1.98789.1", 264 | TrapOIDLabel: "oid", 265 | TrapResolutionDefaultOID: &resolutionOID, 266 | DefaultSeverity: "critical", 267 | SeverityLabel: "severity", 268 | Severities: []string{"critical", "warning", "info"}, 269 | TrapDefaultObjectsBaseOID: "1.3.6.1.4.1.98789.2", 270 | TrapUserObjectsBaseOID: "1.3.6.1.4.1.98789.3", 271 | }, 272 | trapsender.Configuration{ 273 | SNMPVersion: "V2c", 274 | SNMPDestination: []string{"127.0.0.1:162"}, 275 | SNMPRetries: 1, 276 | SNMPTimeout: 5 * time.Second, 277 | SNMPCommunity: "public", 278 | UserObjects: make([]trapsender.UserObject, 0), 279 | }, 280 | httpserver.Configuration{ 281 | ToolKitConfiguration: web.FlagConfig{ 282 | WebSystemdSocket: &falseValue, 283 | WebConfigFile: &emptyString, 284 | WebListenAddresses: &testListenAddresses, 285 | }, 286 | }, 287 | }, 288 | ) 289 | } 290 | 291 | func TestConfigurationWithDifferentResolvedTrapLabelOIDConfiguration(t *testing.T) { 292 | // resolutionOID := "1.3.6.1.4.1.123456" 293 | resolutionOIDLabel := "oid-on-resolution" 294 | expectConfigurationFromCommandLine(t, 295 | "--web.listen-address=:1234 --trap.resolution-oid-label=oid-on-resolution --trap.description-template=../description-template.tpl", 296 | SNMPNotifierConfiguration{ 297 | alertparser.Configuration{ 298 | TrapDefaultOID: "1.3.6.1.4.1.98789.1", 299 | TrapOIDLabel: "oid", 300 | TrapResolutionOIDLabel: &resolutionOIDLabel, 301 | DefaultSeverity: "critical", 302 | SeverityLabel: "severity", 303 | Severities: []string{"critical", "warning", "info"}, 304 | TrapDefaultObjectsBaseOID: "1.3.6.1.4.1.98789.2", 305 | TrapUserObjectsBaseOID: "1.3.6.1.4.1.98789.3", 306 | }, 307 | trapsender.Configuration{ 308 | SNMPVersion: "V2c", 309 | SNMPDestination: []string{"127.0.0.1:162"}, 310 | SNMPRetries: 1, 311 | SNMPTimeout: 5 * time.Second, 312 | SNMPCommunity: "public", 313 | UserObjects: make([]trapsender.UserObject, 0), 314 | }, 315 | httpserver.Configuration{ 316 | ToolKitConfiguration: web.FlagConfig{ 317 | WebSystemdSocket: &falseValue, 318 | WebConfigFile: &emptyString, 319 | WebListenAddresses: &testListenAddresses, 320 | }, 321 | }, 322 | }, 323 | ) 324 | } 325 | 326 | func TestMalFormedTrapOID(t *testing.T) { 327 | expectConfigurationFromCommandLineError( 328 | t, 329 | "--trap.default-oid=A.1.1.1 --trap.description-template=../description-template.tpl", 330 | ) 331 | } 332 | 333 | func TestMalFormedTrapBaseOID(t *testing.T) { 334 | expectConfigurationFromCommandLineError( 335 | t, 336 | "--trap.default-objects-base-oid=A.1.1.1 --trap.description-template=../description-template.tpl", 337 | ) 338 | } 339 | 340 | func TestMalFormedResolutionTrapOID(t *testing.T) { 341 | expectConfigurationFromCommandLineError( 342 | t, 343 | "--trap.resolution-default-oid=A.1.1.1 --trap.description-template=../description-template.tpl", 344 | ) 345 | } 346 | 347 | func TestMalFormedTrapUserObjectsOID(t *testing.T) { 348 | expectConfigurationFromCommandLineError( 349 | t, 350 | "--trap.user-objects-base-oid=A.1.1.1 --trap.description-template=../description-template.tpl", 351 | ) 352 | } 353 | 354 | func TestConfigurationMixingV2AndV3Elements(t *testing.T) { 355 | expectConfigurationFromCommandLineError( 356 | t, 357 | "--web.listen-address=:1234 --snmp.version=V3 --snmp.private-enabled --trap.description-template=../description-template.tpl --snmp.destination=127.0.0.2:163 --snmp.retries=4 --trap.default-oid=4.4.4 --trap.oid-label=other-oid --alert.default-severity=warning --alert.severity-label=severity --alert.severities=critical,error,warning,info", 358 | ) 359 | } 360 | 361 | func TestConfigurationMixingV2AndV3AuthenticationAndPrivate(t *testing.T) { 362 | expectConfigurationFromCommandLineError( 363 | t, 364 | "--web.listen-address=:1234 --snmp.version=V2c --snmp.private-enabled --snmp.authentication-enabled --trap.description-template=../description-template.tpl --snmp.destination=127.0.0.2:163 --snmp.retries=4 --trap.default-oid=4.4.4 --trap.oid-label=other-oid --alert.default-severity=warning --alert.severity-label=severity --alert.severities=critical,error,warning,info", 365 | ) 366 | } 367 | 368 | func TestConfigurationMixingV2AndV3Authentication(t *testing.T) { 369 | expectConfigurationFromCommandLineError( 370 | t, 371 | "--web.listen-address=:1234 --snmp.version=V2c --snmp.authentication-enabled --trap.description-template=../description-template.tpl --snmp.destination=127.0.0.2:163 --snmp.retries=4 --trap.default-oid=4.4.4 --trap.oid-label=other-oid --alert.default-severity=warning --alert.severity-label=severity --alert.severities=critical,error,warning,info", 372 | ) 373 | } 374 | 375 | func TestConfigurationMixingV2AndV3Private(t *testing.T) { 376 | expectConfigurationFromCommandLineError( 377 | t, 378 | "--web.listen-address=:1234 --snmp.version=V2c --snmp.private-enabled --trap.description-template=../description-template.tpl --snmp.destination=127.0.0.2:163 --snmp.retries=4 --trap.default-oid=4.4.4 --trap.oid-label=other-oid --alert.default-severity=warning --alert.severity-label=severity --alert.severities=critical,error,warning,info", 379 | ) 380 | } 381 | 382 | func expectConfigurationFromCommandLine(t *testing.T, commandLine string, configuration SNMPNotifierConfiguration) { 383 | expectConfigurationFromCommandLineAndEnvironmentVariables( 384 | t, 385 | commandLine, 386 | map[string]string{}, 387 | configuration, 388 | ) 389 | } 390 | 391 | func expectConfigurationFromCommandLineAndEnvironmentVariables(t *testing.T, commandLine string, environmentVariables map[string]string, configuration SNMPNotifierConfiguration) { 392 | os.Clearenv() 393 | for variable, value := range environmentVariables { 394 | os.Setenv(variable, value) 395 | } 396 | elements := strings.Split(commandLine, " ") 397 | log.Print(elements) 398 | parsedConfiguration, _, err := ParseConfiguration(elements) 399 | 400 | if err != nil { 401 | t.Error("error occured and no expected error", "err", err) 402 | } 403 | 404 | if err == nil { 405 | descriptionTemplate, err := template.New(filepath.Base("description-template.tpl")).Funcs(template.FuncMap{ 406 | "groupAlertsByLabel": commons.GroupAlertsByLabel, 407 | "groupAlertsByName": commons.GroupAlertsByName, 408 | }).ParseFiles("../description-template.tpl") 409 | if err != nil { 410 | t.Fatal("Error while generating default description template") 411 | } 412 | 413 | configuration.TrapSenderConfiguration.DescriptionTemplate = *descriptionTemplate 414 | 415 | if diff := deep.Equal(*parsedConfiguration, configuration); diff != nil { 416 | t.Error(diff) 417 | } 418 | } 419 | } 420 | 421 | func expectConfigurationFromCommandLineError(t *testing.T, commandLine string) { 422 | expectConfigurationErrorFromCommandLineAndEnvironmentVariables( 423 | t, 424 | commandLine, 425 | map[string]string{}, 426 | ) 427 | } 428 | 429 | func expectConfigurationErrorFromCommandLineAndEnvironmentVariables(t *testing.T, commandLine string, environmentVariables map[string]string) { 430 | os.Clearenv() 431 | for variable, value := range environmentVariables { 432 | os.Setenv(variable, value) 433 | } 434 | elements := strings.Split(commandLine, " ") 435 | log.Print(elements) 436 | _, _, err := ParseConfiguration(elements) 437 | fmt.Printf("err: %s\n", err) 438 | if err == nil { 439 | t.Error("expected error, but none occured") 440 | } 441 | } 442 | -------------------------------------------------------------------------------- /description-template.tpl: -------------------------------------------------------------------------------- 1 | {{- if .Alerts -}} 2 | {{ len .Alerts }}/{{ len .DeclaredAlerts }} alerts are firing: 3 | 4 | {{ range $severity, $alerts := (groupAlertsByLabel .Alerts "severity") -}} 5 | Status: {{ $severity }} 6 | 7 | {{- range $index, $alert := $alerts }} 8 | - Alert: {{ $alert.Labels.alertname }} 9 | Summary: {{ $alert.Annotations.summary }} 10 | Description: {{ $alert.Annotations.description }} 11 | {{ end }} 12 | {{ end }} 13 | {{ else -}} 14 | Status: OK 15 | {{- end -}} 16 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/maxwo/snmp_notifier 2 | 3 | require ( 4 | github.com/alecthomas/kingpin/v2 v2.4.0 5 | github.com/go-test/deep v1.1.1 6 | github.com/k-sone/snmpgo v3.2.0+incompatible 7 | github.com/prometheus/alertmanager v0.28.1 8 | github.com/prometheus/client_golang v1.22.0 9 | github.com/prometheus/common v0.64.0 10 | github.com/prometheus/exporter-toolkit v0.14.0 11 | github.com/shirou/gopsutil v3.21.11+incompatible 12 | ) 13 | 14 | require ( 15 | github.com/alecthomas/units v0.0.0-20240927000941-0f3dac36c52b // indirect 16 | github.com/beorn7/perks v1.0.1 // indirect 17 | github.com/cespare/xxhash/v2 v2.3.0 // indirect 18 | github.com/coreos/go-systemd/v22 v22.5.0 // indirect 19 | github.com/geoffgarside/ber v1.1.0 // indirect 20 | github.com/go-ole/go-ole v1.2.6 // indirect 21 | github.com/jpillora/backoff v1.0.0 // indirect 22 | github.com/mdlayher/socket v0.4.1 // indirect 23 | github.com/mdlayher/vsock v1.2.1 // indirect 24 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect 25 | github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f // indirect 26 | github.com/prometheus/client_model v0.6.2 // indirect 27 | github.com/prometheus/procfs v0.15.1 // indirect 28 | github.com/shurcooL/httpfs v0.0.0-20230704072500-f1e31cf0ba5c // indirect 29 | github.com/shurcooL/vfsgen v0.0.0-20230704071429-0000e147ea92 // indirect 30 | github.com/tklauser/go-sysconf v0.3.11 // indirect 31 | github.com/tklauser/numcpus v0.6.0 // indirect 32 | github.com/xhit/go-str2duration/v2 v2.1.0 // indirect 33 | github.com/yusufpapurcu/wmi v1.2.2 // indirect 34 | golang.org/x/crypto v0.38.0 // indirect 35 | golang.org/x/net v0.40.0 // indirect 36 | golang.org/x/oauth2 v0.30.0 // indirect 37 | golang.org/x/sync v0.14.0 // indirect 38 | golang.org/x/sys v0.33.0 // indirect 39 | golang.org/x/text v0.25.0 // indirect 40 | google.golang.org/protobuf v1.36.6 // indirect 41 | gopkg.in/yaml.v2 v2.4.0 // indirect 42 | ) 43 | 44 | go 1.24.2 45 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/alecthomas/kingpin/v2 v2.4.0 h1:f48lwail6p8zpO1bC4TxtqACaGqHYA22qkHjHpqDjYY= 2 | github.com/alecthomas/kingpin/v2 v2.4.0/go.mod h1:0gyi0zQnjuFk8xrkNKamJoyUo382HRL7ATRpFZCw6tE= 3 | github.com/alecthomas/units v0.0.0-20240927000941-0f3dac36c52b h1:mimo19zliBX/vSQ6PWWSL9lK8qwHozUj03+zLoEB8O0= 4 | github.com/alecthomas/units v0.0.0-20240927000941-0f3dac36c52b/go.mod h1:fvzegU4vN3H1qMT+8wDmzjAcDONcgo2/SZ/TyfdUOFs= 5 | github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= 6 | github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= 7 | github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= 8 | github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= 9 | github.com/coreos/go-systemd/v22 v22.5.0 h1:RrqgGjYQKalulkV8NGVIfkXQf6YYmOyiJKk8iXXhfZs= 10 | github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= 11 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 12 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 13 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 14 | github.com/geoffgarside/ber v1.1.0 h1:qTmFG4jJbwiSzSXoNJeHcOprVzZ8Ulde2Rrrifu5U9w= 15 | github.com/geoffgarside/ber v1.1.0/go.mod h1:jVPKeCbj6MvQZhwLYsGwaGI52oUorHoHKNecGT85ZCc= 16 | github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= 17 | github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= 18 | github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U= 19 | github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= 20 | github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= 21 | github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= 22 | github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 23 | github.com/jpillora/backoff v1.0.0 h1:uvFg412JmmHBHw7iwprIxkPMI+sGQ4kzOWsMeHnm2EA= 24 | github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4= 25 | github.com/k-sone/snmpgo v3.2.0+incompatible h1:2NogYilKYSia0f+seO9P7aRa6MKG6RcnNc1L74L8WOw= 26 | github.com/k-sone/snmpgo v3.2.0+incompatible/go.mod h1:9MC6LeG1sGPgrwnmu/V/ncg9P2M5zS5IvE+c4KZj25g= 27 | github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= 28 | github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= 29 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 30 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 31 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 32 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 33 | github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= 34 | github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= 35 | github.com/mdlayher/socket v0.4.1 h1:eM9y2/jlbs1M615oshPQOHZzj6R6wMT7bX5NPiQvn2U= 36 | github.com/mdlayher/socket v0.4.1/go.mod h1:cAqeGjoufqdxWkD7DkpyS+wcefOtmu5OQ8KuoJGIReA= 37 | github.com/mdlayher/vsock v1.2.1 h1:pC1mTJTvjo1r9n9fbm7S1j04rCgCzhCOS5DY0zqHlnQ= 38 | github.com/mdlayher/vsock v1.2.1/go.mod h1:NRfCibel++DgeMD8z/hP+PPTjlNJsdPOmxcnENvE+SE= 39 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= 40 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= 41 | github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f h1:KUppIJq7/+SVif2QVs3tOP0zanoHgBEVAwHxUSIzRqU= 42 | github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= 43 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 44 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 45 | github.com/prometheus/alertmanager v0.28.1 h1:BK5pCoAtaKg01BYRUJhEDV1tqJMEtYBGzPw8QdvnnvA= 46 | github.com/prometheus/alertmanager v0.28.1/go.mod h1:0StpPUDDHi1VXeM7p2yYfeZgLVi/PPlt39vo9LQUHxM= 47 | github.com/prometheus/client_golang v1.22.0 h1:rb93p9lokFEsctTys46VnV1kLCDpVZ0a/Y92Vm0Zc6Q= 48 | github.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0= 49 | github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= 50 | github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= 51 | github.com/prometheus/common v0.64.0 h1:pdZeA+g617P7oGv1CzdTzyeShxAGrTBsolKNOLQPGO4= 52 | github.com/prometheus/common v0.64.0/go.mod h1:0gZns+BLRQ3V6NdaerOhMbwwRbNh9hkGINtQAsP5GS8= 53 | github.com/prometheus/exporter-toolkit v0.14.0 h1:NMlswfibpcZZ+H0sZBiTjrA3/aBFHkNZqE+iCj5EmRg= 54 | github.com/prometheus/exporter-toolkit v0.14.0/go.mod h1:Gu5LnVvt7Nr/oqTBUC23WILZepW0nffNo10XdhQcwWA= 55 | github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= 56 | github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= 57 | github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= 58 | github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= 59 | github.com/shirou/gopsutil v3.21.11+incompatible h1:+1+c1VGhc88SSonWP6foOcLhvnKlUeu/erjjvaPEYiI= 60 | github.com/shirou/gopsutil v3.21.11+incompatible/go.mod h1:5b4v6he4MtMOwMlS0TUMTu2PcXUg8+E1lC7eC3UO/RA= 61 | github.com/shurcooL/httpfs v0.0.0-20230704072500-f1e31cf0ba5c h1:aqg5Vm5dwtvL+YgDpBcK1ITf3o96N/K7/wsRXQnUTEs= 62 | github.com/shurcooL/httpfs v0.0.0-20230704072500-f1e31cf0ba5c/go.mod h1:owqhoLW1qZoYLZzLnBw+QkPP9WZnjlSWihhxAJC1+/M= 63 | github.com/shurcooL/vfsgen v0.0.0-20230704071429-0000e147ea92 h1:OfRzdxCzDhp+rsKWXuOO2I/quKMJ/+TQwVbIP/gltZg= 64 | github.com/shurcooL/vfsgen v0.0.0-20230704071429-0000e147ea92/go.mod h1:7/OT02F6S6I7v6WXb+IjhMuZEYfH/RJ5RwEWnEo5BMg= 65 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 66 | github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= 67 | github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= 68 | github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= 69 | github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 70 | github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= 71 | github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= 72 | github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 73 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 74 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 75 | github.com/tklauser/go-sysconf v0.3.11 h1:89WgdJhk5SNwJfu+GKyYveZ4IaJ7xAkecBo+KdJV0CM= 76 | github.com/tklauser/go-sysconf v0.3.11/go.mod h1:GqXfhXY3kiPa0nAXPDIQIWzJbMCB7AmcWpGR8lSZfqI= 77 | github.com/tklauser/numcpus v0.6.0 h1:kebhY2Qt+3U6RNK7UqpYNA+tJ23IBEGKkB7JQBfDYms= 78 | github.com/tklauser/numcpus v0.6.0/go.mod h1:FEZLMke0lhOUG6w2JadTzp0a+Nl8PF/GFkQ5UVIcaL4= 79 | github.com/xhit/go-str2duration/v2 v2.1.0 h1:lxklc02Drh6ynqX+DdPyp5pCKLUQpRT8bp8Ydu2Bstc= 80 | github.com/xhit/go-str2duration/v2 v2.1.0/go.mod h1:ohY8p+0f07DiV6Em5LKB0s2YpLtXVyJfNt1+BlmyAsU= 81 | github.com/yusufpapurcu/wmi v1.2.2 h1:KBNDSne4vP5mbSWnJbO+51IMOXJB67QiYCSBrubbPRg= 82 | github.com/yusufpapurcu/wmi v1.2.2/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= 83 | golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8= 84 | golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw= 85 | golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY= 86 | golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds= 87 | golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= 88 | golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= 89 | golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ= 90 | golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= 91 | golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 92 | golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 93 | golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= 94 | golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 95 | golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4= 96 | golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA= 97 | golang.org/x/tools v0.28.0 h1:WuB6qZ4RPCQo5aP3WdKZS7i595EdWqWR8vqJTlwTVK8= 98 | golang.org/x/tools v0.28.0/go.mod h1:dcIOrVd3mfQKTgrDVQHqCPMWy6lnhfhtX3hLXYVLfRw= 99 | google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= 100 | google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= 101 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 102 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= 103 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 104 | gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= 105 | gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= 106 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 107 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 108 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 109 | -------------------------------------------------------------------------------- /httpserver/http_server.go: -------------------------------------------------------------------------------- 1 | // Copyright 2018 Maxime Wojtczak 2 | // Licensed under the Apache License, Version 2.0 (the "License"); 3 | // you may not use this file except in compliance with the License. 4 | // You may obtain a copy of the License at 5 | // 6 | // http://www.apache.org/licenses/LICENSE-2.0 7 | // 8 | // Unless required by applicable law or agreed to in writing, software 9 | // distributed under the License is distributed on an "AS IS" BASIS, 10 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | // See the License for the specific language governing permissions and 12 | // limitations under the License. 13 | 14 | package httpserver 15 | 16 | import ( 17 | "encoding/json" 18 | "fmt" 19 | "io" 20 | "log/slog" 21 | "net/http" 22 | "strconv" 23 | 24 | "github.com/prometheus/exporter-toolkit/web" 25 | 26 | "github.com/maxwo/snmp_notifier/alertparser" 27 | "github.com/maxwo/snmp_notifier/telemetry" 28 | "github.com/maxwo/snmp_notifier/trapsender" 29 | "github.com/maxwo/snmp_notifier/types" 30 | 31 | "github.com/prometheus/client_golang/prometheus/promhttp" 32 | 33 | "github.com/prometheus/common/version" 34 | ) 35 | 36 | // HTTPServer listens for alerts on /alerts endpoint, and sends them as SNMP traps. 37 | type HTTPServer struct { 38 | configuration Configuration 39 | alertParser alertparser.AlertParser 40 | trapSender trapsender.TrapSender 41 | logger *slog.Logger 42 | server *http.Server 43 | } 44 | 45 | // Configuration describes the configuration for serving HTTP requests 46 | type Configuration struct { 47 | ToolKitConfiguration web.FlagConfig 48 | } 49 | 50 | // New creates an HTTPServer instance 51 | func New(configuration Configuration, alertParser alertparser.AlertParser, trapSender trapsender.TrapSender, logger *slog.Logger) *HTTPServer { 52 | return &HTTPServer{configuration, alertParser, trapSender, logger, nil} 53 | } 54 | 55 | // Configure creates and configures the HTTP server 56 | func (httpServer HTTPServer) Start() error { 57 | mux := http.NewServeMux() 58 | server := &http.Server{ 59 | Handler: mux, 60 | } 61 | 62 | mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { 63 | w.Write([]byte(` 64 | SNMP Notifier 65 | 66 |

SNMP Notifier

67 |

SNMP Notifier metrics

68 |

SNMP alerts endpoint

69 |

health endpoint

70 |

Build

71 |
` + version.Info() + ` ` + version.BuildContext() + `
72 | 73 | `)) 74 | }) 75 | 76 | mux.HandleFunc("/alerts", func(w http.ResponseWriter, req *http.Request) { 77 | httpServer.logger.Info("Handling /alerts webhook request") 78 | 79 | defer req.Body.Close() 80 | 81 | data := types.AlertsData{} 82 | err := json.NewDecoder(req.Body).Decode(&data) 83 | if err != nil { 84 | httpServer.errorHandler(w, http.StatusUnprocessableEntity, err, &data) 85 | return 86 | } 87 | 88 | alertBucket, err := httpServer.alertParser.Parse(data) 89 | if err != nil { 90 | httpServer.errorHandler(w, http.StatusBadRequest, err, &data) 91 | return 92 | } 93 | 94 | err = httpServer.trapSender.SendAlertTraps(*alertBucket) 95 | if err != nil { 96 | httpServer.errorHandler(w, http.StatusBadGateway, err, &data) 97 | return 98 | } 99 | 100 | telemetry.RequestTotal.WithLabelValues("200").Inc() 101 | }) 102 | 103 | mux.Handle("/metrics", promhttp.Handler()) 104 | mux.HandleFunc("/health", healthHandler) 105 | 106 | if err := web.ListenAndServe(server, &httpServer.configuration.ToolKitConfiguration, httpServer.logger); err != nil { 107 | httpServer.logger.Error("Unable to listen", "err", err.Error()) 108 | return err 109 | } 110 | 111 | httpServer.server = server 112 | 113 | return nil 114 | } 115 | 116 | func (httpServer HTTPServer) Stop() error { 117 | if httpServer.server != nil { 118 | httpServer.logger.Error("No server started") 119 | return httpServer.server.Close() 120 | } 121 | return nil 122 | } 123 | 124 | func healthHandler(w http.ResponseWriter, r *http.Request) { 125 | io.WriteString(w, "Health: OK\n") 126 | } 127 | 128 | func (httpServer HTTPServer) errorHandler(w http.ResponseWriter, status int, err error, data *types.AlertsData) { 129 | w.WriteHeader(status) 130 | 131 | response := struct { 132 | Error bool 133 | Status int 134 | Message string 135 | }{ 136 | true, 137 | status, 138 | err.Error(), 139 | } 140 | // JSON response 141 | bytes, _ := json.Marshal(response) 142 | json := string(bytes[:]) 143 | fmt.Fprint(w, json) 144 | 145 | httpServer.logger.Error("error while handling request", "status", status, "statustext", http.StatusText(status), "err", err, "data", data) 146 | telemetry.RequestTotal.WithLabelValues(strconv.FormatInt(int64(status), 10)).Inc() 147 | } 148 | -------------------------------------------------------------------------------- /httpserver/http_server_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2018 Maxime Wojtczak 2 | // Licensed under the Apache License, Version 2.0 (the "License"); 3 | // you may not use this file except in compliance with the License. 4 | // You may obtain a copy of the License at 5 | // 6 | // http://www.apache.org/licenses/LICENSE-2.0 7 | // 8 | // Unless required by applicable law or agreed to in writing, software 9 | // distributed under the License is distributed on an "AS IS" BASIS, 10 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | // See the License for the specific language governing permissions and 12 | // limitations under the License. 13 | 14 | package httpserver 15 | 16 | import ( 17 | "bytes" 18 | "encoding/json" 19 | "fmt" 20 | "io" 21 | "log" 22 | "log/slog" 23 | "math/rand" 24 | "net/http" 25 | "os" 26 | "strings" 27 | "testing" 28 | "time" 29 | 30 | "github.com/k-sone/snmpgo" 31 | "github.com/maxwo/snmp_notifier/alertparser" 32 | "github.com/maxwo/snmp_notifier/trapsender" 33 | 34 | testutils "github.com/maxwo/snmp_notifier/test" 35 | 36 | "text/template" 37 | 38 | "github.com/prometheus/exporter-toolkit/web" 39 | ) 40 | 41 | var dummyDescriptionTemplate = `{{ len .Alerts }}/{{ len .DeclaredAlerts }} alerts are firing: 42 | {{ range $key, $value := .Alerts }}Alert name: {{ $value.Labels.alertname }} 43 | Severity: {{ $value.Labels.severity }} 44 | Summary: {{ $value.Annotations.summary }} 45 | Description: {{ $value.Annotations.description }} 46 | {{ end -}}` 47 | 48 | type Test struct { 49 | AlertsFileName string 50 | TrapsFileName string 51 | SNMPDestinationPort int 52 | URI string 53 | Verb string 54 | ExpectStatus int 55 | } 56 | 57 | func TestAlertNotification(t *testing.T) { 58 | port, server, trapChannel, err := testutils.LaunchTrapReceiver() 59 | if err != nil { 60 | t.Fatal("msg", "Error while starting SNMP server:", "err", err) 61 | } 62 | defer server.Close() 63 | 64 | expectHTTPStatus(t, *port, "POST", "/alerts", "test_mixed_alerts.json", 200) 65 | expectSNMPTraps(t, "test_mixed_traps.json", trapChannel) 66 | } 67 | 68 | func TestBadAlertNotification(t *testing.T) { 69 | port, server, trapChannel, err := testutils.LaunchTrapReceiver() 70 | if err != nil { 71 | t.Fatal("msg", "Error while starting SNMP server:", "err", err) 72 | } 73 | defer server.Close() 74 | 75 | expectHTTPStatus(t, *port, "POST", "/alerts", "test_unprocessable_alerts.json", 422) 76 | expectNoSNMPTrap(t, trapChannel) 77 | } 78 | 79 | func TestBadSNMPDestination(t *testing.T) { 80 | expectHTTPStatus(t, 123, "POST", "/alerts", "test_mixed_alerts.json", 502) 81 | } 82 | 83 | func TestMalformedOIDLabel(t *testing.T) { 84 | port, server, trapChannel, err := testutils.LaunchTrapReceiver() 85 | if err != nil { 86 | t.Fatal("msg", "Error while starting SNMP server:", "err", err) 87 | } 88 | defer server.Close() 89 | 90 | expectHTTPStatus(t, *port, "POST", "/alerts", "test_wrong_oid_alerts.json", 400) 91 | expectNoSNMPTrap(t, trapChannel) 92 | } 93 | 94 | func TestCallRootURI(t *testing.T) { 95 | port, server, trapChannel, err := testutils.LaunchTrapReceiver() 96 | if err != nil { 97 | t.Fatal("msg", "Error while starting SNMP server:", "err", err) 98 | } 99 | defer server.Close() 100 | 101 | expectHTTPStatus(t, *port, "GET", "/", "test_mixed_alerts.json", 200) 102 | expectNoSNMPTrap(t, trapChannel) 103 | } 104 | 105 | func TestCallHealthURI(t *testing.T) { 106 | port, server, trapChannel, err := testutils.LaunchTrapReceiver() 107 | if err != nil { 108 | t.Fatal("msg", "Error while starting SNMP server:", "err", err) 109 | } 110 | defer server.Close() 111 | 112 | expectHTTPStatus(t, *port, "GET", "/health", "test_mixed_alerts.json", 200) 113 | expectNoSNMPTrap(t, trapChannel) 114 | } 115 | 116 | func expectHTTPStatus(t *testing.T, snmpDestinationPort int32, verb string, uri string, body string, status int) { 117 | httpserver, notifierPort := launchHTTPServer(t, snmpDestinationPort) 118 | defer httpserver.Stop() 119 | 120 | t.Log("Testing with file", body) 121 | alertsByteData, err := os.ReadFile(body) 122 | if err != nil { 123 | t.Fatal("Error while reading alert file:", err) 124 | } 125 | alertsReader := bytes.NewReader(alertsByteData) 126 | 127 | url := fmt.Sprintf("http://127.0.0.1:%d%s", notifierPort, uri) 128 | req, err := http.NewRequest(verb, url, alertsReader) 129 | if err != nil { 130 | t.Fatal("Error while building request:", err) 131 | } 132 | req.Header.Set("Content-Type", "application/json") 133 | 134 | client := &http.Client{} 135 | resp, err := client.Do(req) 136 | if err != nil { 137 | t.Fatal("Error while sending request:", err) 138 | } 139 | defer resp.Body.Close() 140 | 141 | t.Log("response Status:", resp.Status) 142 | t.Log("response Headers:", resp.Header) 143 | response, _ := io.ReadAll(resp.Body) 144 | t.Log("response Body:", string(response)) 145 | 146 | if resp.StatusCode != status { 147 | t.Fatal(status, "status expected, but got:", resp.StatusCode) 148 | } 149 | } 150 | 151 | func launchHTTPServer(t *testing.T, port int32) (*HTTPServer, int) { 152 | notfierRandomPort := 10000 + rand.Intn(10000) 153 | 154 | snmpDestination := fmt.Sprintf("127.0.0.1:%d", port) 155 | notifierAddress := fmt.Sprintf(":%d", notfierRandomPort) 156 | 157 | alertParserConfiguration := alertparser.Configuration{ 158 | TrapDefaultOID: "1.2.3", 159 | TrapOIDLabel: "oid", 160 | DefaultSeverity: "critical", 161 | Severities: strings.Split("critical,warning,info", ","), 162 | SeverityLabel: "severity", 163 | TrapDefaultObjectsBaseOID: "1.7.8", 164 | TrapUserObjectsBaseOID: "1.7.9", 165 | } 166 | alertParser := alertparser.New(alertParserConfiguration, slog.New(slog.NewTextHandler(os.Stdout, nil))) 167 | 168 | descriptionTemplate, err := template.New("description").Parse(dummyDescriptionTemplate) 169 | if err != nil { 170 | t.Fatal("Error while building template") 171 | } 172 | 173 | var falseValue = false 174 | var emptyString = "" 175 | 176 | trapSenderConfiguration := trapsender.Configuration{ 177 | SNMPDestination: []string{snmpDestination}, 178 | SNMPRetries: 1, 179 | SNMPVersion: "V2c", 180 | SNMPTimeout: 5 * time.Second, 181 | SNMPCommunity: "public", 182 | SNMPAuthenticationEnabled: false, 183 | SNMPAuthenticationProtocol: "", 184 | SNMPAuthenticationUsername: "", 185 | SNMPAuthenticationPassword: "", 186 | SNMPPrivateEnabled: false, 187 | SNMPPrivateProtocol: "", 188 | SNMPPrivatePassword: "", 189 | SNMPSecurityEngineID: "", 190 | SNMPContextEngineID: "", 191 | SNMPContextName: "", 192 | DescriptionTemplate: *descriptionTemplate, 193 | UserObjects: make([]trapsender.UserObject, 0), 194 | } 195 | 196 | logger := slog.New(slog.NewTextHandler(os.Stdout, nil)) 197 | 198 | trapSender := trapsender.New(trapSenderConfiguration, logger) 199 | 200 | httpServerConfiguration := Configuration{ 201 | web.FlagConfig{ 202 | WebListenAddresses: &[]string{notifierAddress}, 203 | WebSystemdSocket: &falseValue, 204 | WebConfigFile: &emptyString, 205 | }, 206 | } 207 | httpServer := New(httpServerConfiguration, alertParser, trapSender, logger) 208 | go func() { 209 | if err := httpServer.Start(); err != nil { 210 | t.Error("err", err) 211 | } 212 | }() 213 | time.Sleep(200 * time.Millisecond) 214 | 215 | return httpServer, notfierRandomPort 216 | } 217 | 218 | func expectNoSNMPTrap(t *testing.T, trapChannel chan *snmpgo.TrapRequest) { 219 | receivedTraps := testutils.ReadTraps(trapChannel) 220 | 221 | log.Print("Traps received:", receivedTraps) 222 | 223 | if len(receivedTraps) != 0 { 224 | t.Fatal("no traps expected, but received", receivedTraps) 225 | } 226 | } 227 | 228 | func expectSNMPTraps(t *testing.T, trapsFileName string, trapChannel chan *snmpgo.TrapRequest) { 229 | receivedTraps := testutils.ReadTraps(trapChannel) 230 | 231 | log.Print("Traps received:", receivedTraps) 232 | 233 | expectedTrapsByteData, err := os.ReadFile(trapsFileName) 234 | if err != nil { 235 | t.Fatal("Error while reading traps file:", err) 236 | } 237 | expectedTrapsReader := bytes.NewReader(expectedTrapsByteData) 238 | expectedTrapsData := []map[string]string{} 239 | err = json.NewDecoder(expectedTrapsReader).Decode(&expectedTrapsData) 240 | if err != nil { 241 | t.Fatal("Error while parsing traps file:", err) 242 | } 243 | 244 | if len(receivedTraps) != len(expectedTrapsData) { 245 | t.Fatal(len(expectedTrapsData), "traps expected, but received", receivedTraps) 246 | } 247 | 248 | for _, expectedTrap := range expectedTrapsData { 249 | if !testutils.FindTrap(receivedTraps, expectedTrap) { 250 | t.Fatal("Expected trap not found:", expectedTrap) 251 | } 252 | } 253 | } 254 | -------------------------------------------------------------------------------- /httpserver/test_mixed_alerts.json: -------------------------------------------------------------------------------- 1 | { 2 | "receiver": "snmp-notifier", 3 | "status": "firing", 4 | "groupLabels": { 5 | "environment": "production", 6 | "label": "test" 7 | }, 8 | "alerts": [ 9 | { 10 | "status": "firing", 11 | "labels": { 12 | "severity": "warning", 13 | "alertname": "TestAlert", 14 | "oid": "1.2.3.2.1" 15 | }, 16 | "annotations": { 17 | "summary": "this is the random summary", 18 | "description": "this is the description of alert 1" 19 | } 20 | }, 21 | { 22 | "status": "resolved", 23 | "labels": { 24 | "severity": "warning", 25 | "alertname": "TestAlert", 26 | "oid": "1.2.3.1.1" 27 | }, 28 | "annotations": { 29 | "summary": "this is the random summary", 30 | "description": "this is the description of ActiveMQ alert" 31 | } 32 | }, 33 | { 34 | "status": "firing", 35 | "labels": { 36 | "severity": "critical", 37 | "alertname": "TestAlert", 38 | "oid": "1.2.3.2.1", 39 | "res-oid": "1.9.9.9.9.9" 40 | }, 41 | "annotations": { 42 | "summary": "this is the summary", 43 | "description": "this is the description on job1" 44 | } 45 | }, 46 | { 47 | "status": "resolved", 48 | "labels": { 49 | "severity": "critical", 50 | "alertname": "TestAlert", 51 | "oid": "1.2.3.2.1" 52 | }, 53 | "annotations": { 54 | "summary": "this is the summary", 55 | "description": "this is the description on TestAlertWithoutOID" 56 | } 57 | } 58 | ] 59 | } 60 | -------------------------------------------------------------------------------- /httpserver/test_mixed_traps.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "1.7.8.1": "1.2.3.1.1[environment=production,label=test]", 4 | "1.7.8.2": "info", 5 | "1.7.8.3": "0/1 alerts are firing:" 6 | }, 7 | { 8 | "1.7.8.1": "1.2.3.2.1[environment=production,label=test]", 9 | "1.7.8.2": "critical", 10 | "1.7.8.3": "2/3 alerts are firing:\nAlert name: TestAlert\nSeverity: warning\nSummary: this is the random summary\nDescription: this is the description of alert 1\nAlert name: TestAlert\nSeverity: critical\nSummary: this is the summary\nDescription: this is the description on job1" 11 | } 12 | ] 13 | -------------------------------------------------------------------------------- /httpserver/test_no_body.json: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maxwo/snmp_notifier/44368f1303d066905571d269c467ccff5fd86269/httpserver/test_no_body.json -------------------------------------------------------------------------------- /httpserver/test_unprocessable_alerts.json: -------------------------------------------------------------------------------- 1 | { 2 | "receiver": "snmp-notifier", 3 | "status": "firing", 4 | "alerts": [ 5 | { 6 | "status": "firing", 7 | "labels": { 8 | "severity": "warning", 9 | "alertname": "TestAlert", 10 | "oid": "1.2.3.2.1" 11 | }, 12 | "annotations": { 13 | "summary": "this is the random summary", 14 | "description": "this is the description of alert 1" 15 | } 16 | }, 17 | { 18 | "status": "resolved", 19 | "labels": { 20 | "severity": "war 21 | -------------------------------------------------------------------------------- /httpserver/test_wrong_oid_alerts.json: -------------------------------------------------------------------------------- 1 | { 2 | "receiver": "snmp-notifier", 3 | "status": "firing", 4 | "alerts": [ 5 | { 6 | "status": "firing", 7 | "labels": { 8 | "severity": "warning", 9 | "alertname": "TestAlert", 10 | "oid": "1.2.3.2.1" 11 | }, 12 | "annotations": { 13 | "summary": "this is the random summary", 14 | "description": "this is the description of alert 1" 15 | } 16 | }, 17 | { 18 | "status": "resolved", 19 | "labels": { 20 | "severity": "warning", 21 | "alertname": "TestAlert", 22 | "oid": "1.2.3.1.1aaa" 23 | }, 24 | "annotations": { 25 | "summary": "this is the random summary", 26 | "description": "this is the description of ActiveMQ alert" 27 | } 28 | }, 29 | { 30 | "status": "firing", 31 | "labels": { 32 | "severity": "critical", 33 | "alertname": "TestAlert", 34 | "oid": "1.2.3.2.1" 35 | }, 36 | "annotations": { 37 | "summary": "this is the summary", 38 | "description": "this is the description on job1" 39 | } 40 | } 41 | ] 42 | } 43 | -------------------------------------------------------------------------------- /mibs/SNMP-NOTIFIER-MIB.my: -------------------------------------------------------------------------------- 1 | SNMP-NOTIFIER-MIB DEFINITIONS ::= BEGIN 2 | 3 | IMPORTS 4 | MODULE-IDENTITY, enterprises, OBJECT-TYPE, NOTIFICATION-TYPE FROM SNMPv2-SMI 5 | DisplayString FROM SNMPv2-TC; 6 | 7 | snmpNotifier MODULE-IDENTITY 8 | LAST-UPDATED "202301070000Z" 9 | ORGANIZATION "SNMP Notifier" 10 | CONTACT-INFO 11 | "SNMP Notifier 12 | 13 | https://github.com/maxwo/snmp_notifier/ 14 | 15 | " 16 | 17 | DESCRIPTION 18 | "This MIB contains definition of the SNMP Traps 19 | associated to alerts sent by the SNMP Notifier" 20 | 21 | REVISION 22 | "202301070000Z" 23 | DESCRIPTION 24 | "Added sub objects to the alerts subtree" 25 | REVISION 26 | "201912260000Z" 27 | 28 | DESCRIPTION 29 | "First revision that includes only the alerts subtree" 30 | ::= { enterprises 98789 } 31 | 32 | snmpNotifierAlertsObjects OBJECT IDENTIFIER ::= { snmpNotifier 2 } 33 | 34 | snmpNotifierAlertsUserObjects OBJECT IDENTIFIER ::= { snmpNotifier 3 } 35 | 36 | snmpNotifierAlertId OBJECT-TYPE 37 | SYNTAX DisplayString 38 | MAX-ACCESS accessible-for-notify 39 | STATUS current 40 | DESCRIPTION "The ID of the SNMP notifier alert." 41 | ::= { snmpNotifierAlertsObjects 1 } 42 | 43 | snmpNotifierAlertSeverity OBJECT-TYPE 44 | SYNTAX DisplayString 45 | MAX-ACCESS accessible-for-notify 46 | STATUS current 47 | DESCRIPTION "The severity of the SNMP notifier alert." 48 | ::= { snmpNotifierAlertsObjects 2 } 49 | 50 | snmpNotifierAlertDescription OBJECT-TYPE 51 | SYNTAX DisplayString 52 | MAX-ACCESS accessible-for-notify 53 | STATUS current 54 | DESCRIPTION "The description of the SNMP notifier alert." 55 | ::= { snmpNotifierAlertsObjects 3 } 56 | 57 | snmpNotifierDefaultTrap NOTIFICATION-TYPE 58 | OBJECTS { 59 | snmpNotifierAlertId, 60 | snmpNotifierAlertSeverity, 61 | snmpNotifierAlertDescription 62 | } 63 | STATUS current 64 | DESCRIPTION "The default SNMP notifier notification" 65 | ::= { snmpNotifier 1 } 66 | END 67 | -------------------------------------------------------------------------------- /scripts/kubernetes/alertmanager-webhook-configuration.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: monitoring.coreos.com/v1alpha1 2 | kind: AlertmanagerConfig 3 | metadata: 4 | name: snmp-notifier-webhook 5 | labels: 6 | alertmanagerConfig: snmp-notifier-webhook 7 | release: prometheus 8 | spec: 9 | route: 10 | groupBy: ['alertname'] 11 | groupWait: 15s 12 | groupInterval: 15s 13 | repeatInterval: 15s 14 | receiver: 'snmp-notifier' 15 | receivers: 16 | - name: 'snmp-notifier' 17 | webhookConfigs: 18 | - sendResolved: true 19 | url: 'http://snmp-notifier-alertmanager-snmp-notifier:9464/alerts' 20 | -------------------------------------------------------------------------------- /scripts/kubernetes/chart-values.yaml: -------------------------------------------------------------------------------- 1 | serviceMonitor: 2 | enabled: true 3 | namespace: default 4 | labels: 5 | release: prometheus 6 | 7 | image: 8 | tag: main 9 | 10 | autoscaling: 11 | enabled: true 12 | 13 | ingress: 14 | enabled: true 15 | className: "nginx" 16 | hosts: 17 | - host: snmp-notifier.k8s.local 18 | paths: 19 | - path: / 20 | pathType: ImplementationSpecific 21 | 22 | snmpNotifier: 23 | # extraArgs allows to pass SNMP notifier configurations, as described on https://github.com/maxwo/snmp_notifier#snmp-notifier-configuration 24 | extraArgs: 25 | - --alert.severity-label=severity 26 | 27 | # snmpDestinations is the list of SNMP servers to send the traps to 28 | snmpDestinations: 29 | - snmp-server:162 30 | 31 | # SNMP authentication secrets, that may be instanciated by the chart, or may use an already created secret 32 | snmpCommunity: public 33 | # snmpAuthenticationUsername: my_authentication_username 34 | # snmpAuthenticationPassword: my_authentication_password 35 | # snmpPrivatePassword: my_private_password 36 | # snmpCommunitySecret: 37 | # name: test-secret 38 | # key: communitySec 39 | snmpAuthenticationUsernameSecret: 40 | name: test-secret 41 | key: authenticationUsernameSec 42 | snmpAuthenticationPasswordSecret: 43 | name: test-secret 44 | key: authenticationPasswordSec 45 | snmpPrivatePasswordSecret: 46 | name: test-secret 47 | key: privatePasswordSec 48 | 49 | # trapTemplates allows to customize the description of the traps, and add traps' user objects 50 | trapTemplates: 51 | description: | 52 | {{- if .Alerts -}} 53 | {{ len .Alerts }}/{{ len .DeclaredAlerts }} alerts are firing: 54 | 55 | {{ range $severity, $alerts := (groupAlertsByLabel .Alerts "severity") -}} 56 | Status: {{ $severity }} 57 | 58 | {{- range $index, $alert := $alerts }} 59 | - Alert: {{ $alert.Labels.alertname }} 60 | Summary: {{ $alert.Annotations.summary }} 61 | Description: {{ $alert.Annotations.description }} 62 | {{ end }} 63 | {{ end }} 64 | {{ else -}} 65 | Status: OK 66 | {{- end -}} 67 | 68 | userObjects: 69 | - subOid: 1 70 | template: | 71 | {{- if .Alerts -}} 72 | Status: NOK 73 | {{- else -}} 74 | Status: OK 75 | {{- end -}} 76 | - subOid: 5 77 | template: | 78 | This is a constant 79 | -------------------------------------------------------------------------------- /scripts/kubernetes/secrets.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Secret 3 | metadata: 4 | name: test-secret 5 | type: Opaque 6 | data: 7 | communitySec: Y29tbXVuaXR5X2Zyb21fc2VjcmV0Cg== 8 | authenticationUsernameSec: YXV0aGVudGljYXRpb25Vc2VybmFtZV9mcm9tX3NlY3JldAo= 9 | authenticationPasswordSec: YXV0aGVudGljYXRpb25QYXNzd29yZF9mcm9tX3NlY3JldAo= 10 | privatePasswordSec: cHJpdmF0ZVBhc3N3b3JkX2Zyb21fc2VjcmV0Cg== 11 | -------------------------------------------------------------------------------- /scripts/kubernetes/snmp-server.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | apiVersion: v1 3 | kind: ConfigMap 4 | metadata: 5 | name: snmptrapd-configuration 6 | data: 7 | snmptrapd.conf: | 8 | # A list of listening addresses, on which to receive incoming SNMP notifications 9 | snmpTrapdAddr udp:1162 10 | snmpTrapdAddr udp6:1162 11 | 12 | format2 %V\n% Agent Hostname: %B \n Community/Infosec Context: %P \n Description: %W \n PDU Attribute/Value Pair Array:\n%v \n -------------- \n 13 | 14 | disableAuthorization yes 15 | 16 | authCommunity log public 17 | 18 | --- 19 | kind: Service 20 | apiVersion: v1 21 | metadata: 22 | name: snmp-server 23 | spec: 24 | type: ClusterIP 25 | ports: 26 | - name: snmp 27 | port: 162 28 | targetPort: 1162 29 | protocol: UDP 30 | selector: 31 | app.kubernetes.io/name: snmp-server 32 | app.kubernetes.io/instance: release-name 33 | --- 34 | apiVersion: apps/v1 35 | kind: Deployment 36 | metadata: 37 | name: snmp-server 38 | spec: 39 | replicas: 1 40 | selector: 41 | matchLabels: 42 | app.kubernetes.io/name: snmp-server 43 | app.kubernetes.io/instance: release-name 44 | strategy: 45 | rollingUpdate: 46 | maxSurge: 1 47 | maxUnavailable: 0 48 | type: RollingUpdate 49 | template: 50 | metadata: 51 | labels: 52 | app.kubernetes.io/name: snmp-server 53 | app.kubernetes.io/instance: release-name 54 | annotations: {} 55 | spec: 56 | restartPolicy: Always 57 | containers: 58 | - name: snmp-server 59 | image: "zabbix/zabbix-snmptraps" 60 | imagePullPolicy: IfNotPresent 61 | args: 62 | resources: {} 63 | ports: 64 | - containerPort: 1162 65 | name: snmp 66 | volumeMounts: 67 | - name: mibs 68 | mountPath: "/var/lib/zabbix/mibs" 69 | readOnly: true 70 | - name: configuration 71 | mountPath: "/etc/snmp" 72 | readOnly: true 73 | volumes: 74 | - name: mibs 75 | configMap: 76 | name: snmp-notifier-mib 77 | items: 78 | - key: "SNMP-NOTIFIER-MIB.my" 79 | path: "SNMP-NOTIFIER-MIB.my" 80 | - name: configuration 81 | configMap: 82 | name: snmptrapd-configuration 83 | items: 84 | - key: "snmptrapd.conf" 85 | path: "snmptrapd.conf" 86 | -------------------------------------------------------------------------------- /scripts/local/listen.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -ex 4 | 5 | rm -rf /var/db/net-snmp 6 | mkdir -p $HOME/.snmp/mibs 7 | ln -s $PWD/../mibs/SNMP-NOTIFIER-MIB.my $HOME/.snmp/mibs/SNMP-NOTIFIER-MIB.my || true 8 | snmptrapd -m ALL -m +SNMP-NOTIFIER-MIB -f -Of -Lo -c snmptrapd.conf 9 | -------------------------------------------------------------------------------- /scripts/local/snmptrapd.conf: -------------------------------------------------------------------------------- 1 | format2 %V\n% Agent Address: %A \n Agent Hostname: %B \n Date: %H - %J - %K - %L - %M - %Y \n Enterprise OID: %N \n Trap Type: %W \n Trap Sub-Type: %q \n Community/Infosec Context: %P \n Uptime: %T \n Description: %W \n PDU Attribute/Value Pair Array:\n%v \n -------------- \n 2 | 3 | disableAuthorization yes 4 | 5 | authCommunity log public 6 | -------------------------------------------------------------------------------- /scripts/local/test_mixed_alerts.json: -------------------------------------------------------------------------------- 1 | { 2 | "receiver": "snmp-notifier", 3 | "status": "firing", 4 | "groupLabels": { 5 | "environment": "production", 6 | "label": "test" 7 | }, 8 | "alerts": [ 9 | { 10 | "status": "firing", 11 | "labels": { 12 | "severity": "warning", 13 | "alertname": "TestAlert" 14 | }, 15 | "annotations": { 16 | "summary": "this is the random summary", 17 | "description": "this is the description of alert 1" 18 | } 19 | }, 20 | { 21 | "status": "resolved", 22 | "labels": { 23 | "severity": "warning", 24 | "alertname": "TestAlert" 25 | }, 26 | "annotations": { 27 | "summary": "this is the random summary", 28 | "description": "this is the description of ActiveMQ alert" 29 | } 30 | }, 31 | { 32 | "status": "firing", 33 | "labels": { 34 | "severity": "critical", 35 | "alertname": "TestAlert" 36 | }, 37 | "annotations": { 38 | "summary": "this is the summary", 39 | "description": "this is the description on job1" 40 | } 41 | } 42 | ] 43 | } 44 | -------------------------------------------------------------------------------- /snmp_notifier.go: -------------------------------------------------------------------------------- 1 | // Copyright 2018 Maxime Wojtczak 2 | // Licensed under the Apache License, Version 2.0 (the "License"); 3 | // you may not use this file except in compliance with the License. 4 | // You may obtain a copy of the License at 5 | // 6 | // http://www.apache.org/licenses/LICENSE-2.0 7 | // 8 | // Unless required by applicable law or agreed to in writing, software 9 | // distributed under the License is distributed on an "AS IS" BASIS, 10 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | // See the License for the specific language governing permissions and 12 | // limitations under the License. 13 | 14 | package main 15 | 16 | import ( 17 | "fmt" 18 | "os" 19 | 20 | "github.com/maxwo/snmp_notifier/alertparser" 21 | "github.com/maxwo/snmp_notifier/configuration" 22 | "github.com/maxwo/snmp_notifier/httpserver" 23 | "github.com/maxwo/snmp_notifier/telemetry" 24 | "github.com/maxwo/snmp_notifier/trapsender" 25 | ) 26 | 27 | func main() { 28 | configuration, logger, err := configuration.ParseConfiguration(os.Args[1:]) 29 | if logger == nil { 30 | fmt.Fprintln(os.Stderr, "logger is nil") 31 | os.Exit(1) 32 | } 33 | if err != nil { 34 | logger.Error("unable to parse configuration", "err", err.Error()) 35 | os.Exit(1) 36 | } 37 | 38 | logger.Debug("debugging configuration", "configuration", configuration) 39 | 40 | trapSender := trapsender.New(configuration.TrapSenderConfiguration, logger) 41 | alertParser := alertparser.New(configuration.AlertParserConfiguration, logger) 42 | httpServer := httpserver.New(configuration.HTTPServerConfiguration, alertParser, trapSender, logger) 43 | 44 | telemetry.Init() 45 | 46 | if err := httpServer.Start(); err != nil { 47 | logger.Error("error while launching the SNMP notifier", "err", err.Error()) 48 | os.Exit(1) 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /telemetry/telemetry.go: -------------------------------------------------------------------------------- 1 | // Copyright 2018 Maxime Wojtczak 2 | // Licensed under the Apache License, Version 2.0 (the "License"); 3 | // you may not use this file except in compliance with the License. 4 | // You may obtain a copy of the License at 5 | // 6 | // http://www.apache.org/licenses/LICENSE-2.0 7 | // 8 | // Unless required by applicable law or agreed to in writing, software 9 | // distributed under the License is distributed on an "AS IS" BASIS, 10 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | // See the License for the specific language governing permissions and 12 | // limitations under the License. 13 | 14 | package telemetry 15 | 16 | import "github.com/prometheus/client_golang/prometheus" 17 | 18 | var ( 19 | // RequestTotal counts the number of received HTTP calls 20 | RequestTotal = prometheus.NewCounterVec( 21 | prometheus.CounterOpts{ 22 | Name: "snmp_notifier_requests_total", 23 | Help: "Total number of HTTP requests by status code.", 24 | }, 25 | []string{"code"}, 26 | ) 27 | // SNMPSentTotal counts the number of SNMP traps sent. 28 | SNMPTrapTotal = prometheus.NewCounterVec( 29 | prometheus.CounterOpts{ 30 | Name: "snmp_notifier_traps_total", 31 | Help: "Total number of trap by SNMP destination and outcome.", 32 | }, 33 | []string{"destination", "outcome"}, 34 | ) 35 | ) 36 | 37 | // Init starts Prometheus metric counters collection 38 | func Init() { 39 | prometheus.Register(RequestTotal) 40 | prometheus.Register(SNMPTrapTotal) 41 | } 42 | -------------------------------------------------------------------------------- /test/integration.go: -------------------------------------------------------------------------------- 1 | // Copyright 2018 Maxime Wojtczak 2 | // Licensed under the Apache License, Version 2.0 (the "License"); 3 | // you may not use this file except in compliance with the License. 4 | // You may obtain a copy of the License at 5 | // 6 | // http://www.apache.org/licenses/LICENSE-2.0 7 | // 8 | // Unless required by applicable law or agreed to in writing, software 9 | // distributed under the License is distributed on an "AS IS" BASIS, 10 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | // See the License for the specific language governing permissions and 12 | // limitations under the License. 13 | 14 | package test 15 | 16 | import ( 17 | "fmt" 18 | "log" 19 | "math/rand/v2" 20 | "time" 21 | 22 | "github.com/k-sone/snmpgo" 23 | ) 24 | 25 | type testTrapListener struct { 26 | traps chan *snmpgo.TrapRequest 27 | } 28 | 29 | func (trapListener *testTrapListener) OnTRAP(trap *snmpgo.TrapRequest) { 30 | log.Print("trap received in listener: ", trap) 31 | trapListener.traps <- trap 32 | } 33 | 34 | // LaunchTrapReceiver provides a SNMP server for testing purposes 35 | func LaunchTrapReceiver() (*int32, *snmpgo.TrapServer, chan *snmpgo.TrapRequest, error) { 36 | port := 10000 + rand.Int32()%10000 37 | address := fmt.Sprintf("127.0.0.1:%d", port) 38 | trapServer, err := snmpgo.NewTrapServer(snmpgo.ServerArguments{ 39 | LocalAddr: address, 40 | }) 41 | if err != nil { 42 | return nil, nil, nil, err 43 | } 44 | err = trapServer.AddSecurity(&snmpgo.SecurityEntry{ 45 | Version: snmpgo.V2c, 46 | Community: "public", 47 | }) 48 | if err != nil { 49 | return nil, nil, nil, err 50 | } 51 | err = trapServer.AddSecurity(&snmpgo.SecurityEntry{ 52 | Version: snmpgo.V3, 53 | SecurityLevel: snmpgo.AuthPriv, 54 | UserName: "v3_username", 55 | AuthPassword: "v3_password", 56 | AuthProtocol: snmpgo.AuthProtocol("SHA"), 57 | PrivPassword: "v3_private_secret", 58 | PrivProtocol: snmpgo.PrivProtocol("AES"), 59 | SecurityEngineId: "8000000004736e6d70676f", 60 | }) 61 | if err != nil { 62 | return nil, nil, nil, err 63 | } 64 | traps := make(chan *snmpgo.TrapRequest) 65 | go launchSNMPServer(trapServer, traps) 66 | time.Sleep(200 * time.Millisecond) 67 | return &port, trapServer, traps, nil 68 | } 69 | 70 | func launchSNMPServer(trapServer *snmpgo.TrapServer, traps chan *snmpgo.TrapRequest) { 71 | log.Print("Serving SNMP server...") 72 | err := trapServer.Serve(&testTrapListener{traps}) 73 | if err != nil { 74 | log.Fatal(err) 75 | } 76 | } 77 | 78 | // ReadTraps reads all available traps sent to server 79 | func ReadTraps(trapChannel chan *snmpgo.TrapRequest) []snmpgo.TrapRequest { 80 | var ( 81 | trapsReceived []snmpgo.TrapRequest 82 | end bool 83 | ) 84 | for !end { 85 | select { 86 | case trap := <-trapChannel: 87 | trapsReceived = append(trapsReceived, *trap) 88 | case <-time.After(200 * time.Millisecond): 89 | end = true 90 | } 91 | } 92 | return trapsReceived 93 | } 94 | 95 | // FindTrap search a trap matching the given variables 96 | func FindTrap(trapsReceived []snmpgo.TrapRequest, variables map[string]string) bool { 97 | var ( 98 | found bool 99 | ) 100 | for _, trap := range trapsReceived { 101 | doCurrentMatch := true 102 | for oid, value := range variables { 103 | oidPrefix, _ := snmpgo.NewOid(oid) 104 | varBind := trap.Pdu.VarBinds().MatchOid(oidPrefix) 105 | if varBind == nil || varBind.Variable.String() != value { 106 | doCurrentMatch = false 107 | } 108 | } 109 | found = found || doCurrentMatch 110 | } 111 | return found 112 | } 113 | -------------------------------------------------------------------------------- /test/integration_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2018 Maxime Wojtczak 2 | // Licensed under the Apache License, Version 2.0 (the "License"); 3 | // you may not use this file except in compliance with the License. 4 | // You may obtain a copy of the License at 5 | // 6 | // http://www.apache.org/licenses/LICENSE-2.0 7 | // 8 | // Unless required by applicable law or agreed to in writing, software 9 | // distributed under the License is distributed on an "AS IS" BASIS, 10 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | // See the License for the specific language governing permissions and 12 | // limitations under the License. 13 | 14 | package test 15 | 16 | import ( 17 | "fmt" 18 | "testing" 19 | 20 | "github.com/k-sone/snmpgo" 21 | ) 22 | 23 | func TestReadV2Traps(t *testing.T) { 24 | 25 | port, server, channel, err := LaunchTrapReceiver() 26 | if err != nil { 27 | t.Fatal("Error while opening server", err) 28 | } 29 | defer server.Close() 30 | 31 | sendTrapV2(t, *port, "first trap") 32 | sendTrapV3(t, *port, "second trap") 33 | 34 | traps := ReadTraps(channel) 35 | 36 | if !FindTrap(traps, map[string]string{"1.1.1.3": "first trap"}) { 37 | t.Error("Cannot find first trap") 38 | } 39 | if !FindTrap(traps, map[string]string{"1.1.1.3": "second trap"}) { 40 | t.Error("Cannot find second trap") 41 | } 42 | } 43 | 44 | func TestFindTrap(t *testing.T) { 45 | 46 | port, server, channel, err := LaunchTrapReceiver() 47 | if err != nil { 48 | t.Fatal("Error while opening server", err) 49 | } 50 | defer server.Close() 51 | 52 | sendTrapV2(t, *port, "first trap") 53 | sendTrapV2(t, *port, "second trap") 54 | 55 | traps := ReadTraps(channel) 56 | 57 | if !FindTrap(traps, map[string]string{"1.1.1.3": "first trap", "1.1.1.4": "this is a constant"}) { 58 | t.Error("Findable trap found") 59 | } 60 | if FindTrap(traps, map[string]string{"1.1.1.3": "third non-existing trap", "1.1.1.4": "this is a constant"}) { 61 | t.Error("Unfindable trap found") 62 | } 63 | } 64 | 65 | func sendTrapV2(t *testing.T, port int32, text string) { 66 | var ( 67 | varBinds snmpgo.VarBinds 68 | ) 69 | 70 | uptime := 0 71 | trapOid, _ := snmpgo.NewOid("1.1.1") 72 | textOid, _ := snmpgo.NewOid("1.1.1.3") 73 | constantOid, _ := snmpgo.NewOid("1.1.1.4") 74 | varBinds = append(varBinds, snmpgo.NewVarBind(snmpgo.OidSysUpTime, snmpgo.NewTimeTicks(uint32(uptime*100)))) 75 | varBinds = append(varBinds, snmpgo.NewVarBind(snmpgo.OidSnmpTrap, trapOid)) 76 | varBinds = append(varBinds, snmpgo.NewVarBind(textOid, snmpgo.NewOctetString([]byte(text)))) 77 | varBinds = append(varBinds, snmpgo.NewVarBind(constantOid, snmpgo.NewOctetString([]byte("this is a constant")))) 78 | 79 | snmp, err := snmpgo.NewSNMP(snmpgo.SNMPArguments{ 80 | Version: snmpgo.V2c, 81 | Address: fmt.Sprintf("127.0.0.1:%d", port), 82 | Retries: 1, 83 | Community: "public", 84 | }) 85 | if err != nil { 86 | t.Fatal("Error while creating SNMP connection", err) 87 | } 88 | err = snmp.Open() 89 | if err != nil { 90 | t.Fatal("Error while opening SNMP connection", err) 91 | } 92 | defer snmp.Close() 93 | 94 | err = snmp.V2Trap(varBinds) 95 | if err != nil { 96 | t.Fatal("Error while sending trap", err) 97 | } 98 | } 99 | 100 | func sendTrapV3(t *testing.T, port int32, text string) { 101 | var ( 102 | varBinds snmpgo.VarBinds 103 | ) 104 | 105 | uptime := 0 106 | trapOid, _ := snmpgo.NewOid("1.1.1") 107 | textOid, _ := snmpgo.NewOid("1.1.1.3") 108 | constantOid, _ := snmpgo.NewOid("1.1.1.4") 109 | varBinds = append(varBinds, snmpgo.NewVarBind(snmpgo.OidSysUpTime, snmpgo.NewTimeTicks(uint32(uptime*100)))) 110 | varBinds = append(varBinds, snmpgo.NewVarBind(snmpgo.OidSnmpTrap, trapOid)) 111 | varBinds = append(varBinds, snmpgo.NewVarBind(textOid, snmpgo.NewOctetString([]byte(text)))) 112 | varBinds = append(varBinds, snmpgo.NewVarBind(constantOid, snmpgo.NewOctetString([]byte("this is a constant")))) 113 | 114 | snmp, err := snmpgo.NewSNMP(snmpgo.SNMPArguments{ 115 | Version: snmpgo.V3, 116 | Address: fmt.Sprintf("127.0.0.1:%d", port), 117 | Retries: 1, 118 | SecurityLevel: snmpgo.AuthPriv, 119 | UserName: "v3_username", 120 | AuthPassword: "v3_password", 121 | AuthProtocol: snmpgo.AuthProtocol("SHA"), 122 | PrivPassword: "v3_private_secret", 123 | PrivProtocol: snmpgo.PrivProtocol("AES"), 124 | SecurityEngineId: "8000000004736e6d70676f", 125 | }) 126 | if err != nil { 127 | t.Fatal("Error while creating SNMP connection", err) 128 | } 129 | err = snmp.Open() 130 | if err != nil { 131 | t.Fatal("Error while opening SNMP connection", err) 132 | } 133 | defer snmp.Close() 134 | 135 | err = snmp.V2Trap(varBinds) 136 | if err != nil { 137 | t.Fatal("Error while sending trap", err) 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /trapsender/test_mixed_bucket.json: -------------------------------------------------------------------------------- 1 | { 2 | "AlertGroups": { 3 | "1.2.3.2.1[environment=production,label=test]": { 4 | "TrapOID": "1.2.3.2.1", 5 | "GroupID": "environment=production,label=test", 6 | "DefaultObjectsBaseOID": "1.2.3.2.2", 7 | "UserObjectsBaseOID": "1.2.3.2.2", 8 | "Severity": "critical", 9 | "Alerts": [ 10 | { 11 | "status": "firing", 12 | "labels": { 13 | "severity": "warning", 14 | "alertname": "TestAlert", 15 | "oid": "1.2.3.2.1" 16 | }, 17 | "annotations": { 18 | "summary": "this is the random summary", 19 | "description": "this is the description of alert 1" 20 | } 21 | }, 22 | { 23 | "status": "firing", 24 | "labels": { 25 | "severity": "critical", 26 | "alertname": "TestAlert", 27 | "oid": "1.2.3.2.1" 28 | }, 29 | "annotations": { 30 | "summary": "this is the summary", 31 | "description": "this is the description on job1" 32 | } 33 | } 34 | ], 35 | "DeclaredAlerts": [ 36 | { 37 | "status": "firing", 38 | "labels": { 39 | "severity": "warning", 40 | "alertname": "TestAlert", 41 | "oid": "1.2.3.2.1" 42 | }, 43 | "annotations": { 44 | "summary": "this is the random summary", 45 | "description": "this is the description of alert 1" 46 | } 47 | }, 48 | { 49 | "status": "firing", 50 | "labels": { 51 | "severity": "critical", 52 | "alertname": "TestAlert", 53 | "oid": "1.2.3.2.1" 54 | }, 55 | "annotations": { 56 | "summary": "this is the summary", 57 | "description": "this is the description on job1" 58 | } 59 | }, 60 | { 61 | "status": "resolved", 62 | "labels": { 63 | "severity": "critical", 64 | "alertname": "TestAlert", 65 | "oid": "1.2.3.2.1" 66 | }, 67 | "annotations": { 68 | "summary": "this is the summary", 69 | "description": "this is the description on TestAlertWithoutOID" 70 | } 71 | } 72 | ] 73 | }, 74 | "1.2.3.1.1[environment=production,label=test]": { 75 | "TrapOID": "1.2.3.1.1", 76 | "GroupID": "environment=production,label=test", 77 | "DefaultObjectsBaseOID": "1.2.3.2.2", 78 | "UserObjectsBaseOID": "1.2.3.2.2", 79 | "Severity": "info", 80 | "Alerts": [], 81 | "DeclaredAlerts": [ 82 | { 83 | "status": "resolved", 84 | "labels": { 85 | "environment": "production", 86 | "label": "test", 87 | "severity": "critical", 88 | "alertname": "TestAlertWithoutOID" 89 | }, 90 | "annotations": { 91 | "summary": "this is the summary", 92 | "description": "this is the description on TestAlertWithoutOID" 93 | } 94 | } 95 | ] 96 | } 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /trapsender/test_mixed_bucket_user_objects.json: -------------------------------------------------------------------------------- 1 | { 2 | "AlertGroups": { 3 | "1.2.3.2.1[environment=production,label=test]": { 4 | "TrapOID": "1.2.3.2.1", 5 | "GroupID": "environment=production,label=test", 6 | "DefaultObjectsBaseOID": "1.2.3.2.2", 7 | "UserObjectsBaseOID": "1.2.3.7", 8 | "Severity": "critical", 9 | "Alerts": [ 10 | { 11 | "status": "firing", 12 | "labels": { 13 | "severity": "warning", 14 | "alertname": "TestAlert", 15 | "oid": "1.2.3.2.1" 16 | }, 17 | "annotations": { 18 | "summary": "this is the random summary", 19 | "description": "this is the description of alert 1" 20 | } 21 | }, 22 | { 23 | "status": "firing", 24 | "labels": { 25 | "severity": "critical", 26 | "alertname": "TestAlert", 27 | "oid": "1.2.3.2.1" 28 | }, 29 | "annotations": { 30 | "summary": "this is the summary", 31 | "description": "this is the description on job1" 32 | } 33 | } 34 | ], 35 | "DeclaredAlerts": [ 36 | { 37 | "status": "firing", 38 | "labels": { 39 | "severity": "warning", 40 | "alertname": "TestAlert", 41 | "oid": "1.2.3.2.1" 42 | }, 43 | "annotations": { 44 | "summary": "this is the random summary", 45 | "description": "this is the description of alert 1" 46 | } 47 | }, 48 | { 49 | "status": "firing", 50 | "labels": { 51 | "severity": "critical", 52 | "alertname": "TestAlert", 53 | "oid": "1.2.3.2.1" 54 | }, 55 | "annotations": { 56 | "summary": "this is the summary", 57 | "description": "this is the description on job1" 58 | } 59 | }, 60 | { 61 | "status": "resolved", 62 | "labels": { 63 | "severity": "critical", 64 | "alertname": "TestAlert", 65 | "oid": "1.2.3.2.1" 66 | }, 67 | "annotations": { 68 | "summary": "this is the summary", 69 | "description": "this is the description on TestAlertWithoutOID" 70 | } 71 | } 72 | ] 73 | }, 74 | "1.2.3.1.1[environment=production,label=test]": { 75 | "TrapOID": "1.2.3.1.1", 76 | "GroupID": "environment=production,label=test", 77 | "DefaultObjectsBaseOID": "1.2.3.2.2", 78 | "UserObjectsBaseOID": "1.2.3.7", 79 | "Severity": "info", 80 | "Alerts": [], 81 | "DeclaredAlerts": [ 82 | { 83 | "status": "resolved", 84 | "labels": { 85 | "environment": "production", 86 | "label": "test", 87 | "severity": "critical", 88 | "alertname": "TestAlertWithoutOID" 89 | }, 90 | "annotations": { 91 | "summary": "this is the summary", 92 | "description": "this is the description on TestAlertWithoutOID" 93 | } 94 | } 95 | ] 96 | } 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /trapsender/test_mixed_traps.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "1.2.3.2.2.1": "1.2.3.1.1[environment=production,label=test]", 4 | "1.2.3.2.2.2": "info", 5 | "1.2.3.2.2.3": "0/1 alerts are firing:" 6 | }, 7 | { 8 | "1.2.3.2.2.1": "1.2.3.2.1[environment=production,label=test]", 9 | "1.2.3.2.2.2": "critical", 10 | "1.2.3.2.2.3": "2/3 alerts are firing:\nAlert name: TestAlert\nSeverity: warning\nSummary: this is the random summary\nDescription: this is the description of alert 1\nAlert name: TestAlert\nSeverity: critical\nSummary: this is the summary\nDescription: this is the description on job1" 11 | } 12 | ] 13 | -------------------------------------------------------------------------------- /trapsender/test_mixed_traps_custom_base_oid.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "1.2.3.2.2.1": "1.2.3.1.1[environment=production,label=test]", 4 | "1.2.3.2.2.2": "info", 5 | "1.2.3.2.2.3": "0/1 alerts are firing:" 6 | }, 7 | { 8 | "1.2.3.2.2.1": "1.2.3.2.1[environment=production,label=test]", 9 | "1.2.3.2.2.2": "critical", 10 | "1.2.3.2.2.3": "2/3 alerts are firing:\nAlert name: TestAlert\nSeverity: warning\nSummary: this is the random summary\nDescription: this is the description of alert 1\nAlert name: TestAlert\nSeverity: critical\nSummary: this is the summary\nDescription: this is the description on job1" 11 | } 12 | ] 13 | -------------------------------------------------------------------------------- /trapsender/test_mixed_traps_user_objects.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "1.2.3.2.2.1": "1.2.3.1.1[environment=production,label=test]", 4 | "1.2.3.2.2.2": "info", 5 | "1.2.3.2.2.3": "0/1 alerts are firing:", 6 | "1.2.3.7.8": "Alert count: 0" 7 | }, 8 | { 9 | "1.2.3.2.2.1": "1.2.3.2.1[environment=production,label=test]", 10 | "1.2.3.2.2.2": "critical", 11 | "1.2.3.2.2.3": "2/3 alerts are firing:\nAlert name: TestAlert\nSeverity: warning\nSummary: this is the random summary\nDescription: this is the description of alert 1\nAlert name: TestAlert\nSeverity: critical\nSummary: this is the summary\nDescription: this is the description on job1", 12 | "1.2.3.7.8": "Alert count: 2" 13 | } 14 | ] 15 | -------------------------------------------------------------------------------- /trapsender/trap_sender.go: -------------------------------------------------------------------------------- 1 | // Copyright 2018 Maxime Wojtczak 2 | // Licensed under the Apache License, Version 2.0 (the "License"); 3 | // you may not use this file except in compliance with the License. 4 | // You may obtain a copy of the License at 5 | // 6 | // http://www.apache.org/licenses/LICENSE-2.0 7 | // 8 | // Unless required by applicable law or agreed to in writing, software 9 | // distributed under the License is distributed on an "AS IS" BASIS, 10 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | // See the License for the specific language governing permissions and 12 | // limitations under the License. 13 | 14 | package trapsender 15 | 16 | import ( 17 | "errors" 18 | "log/slog" 19 | "math" 20 | "strconv" 21 | "strings" 22 | "time" 23 | 24 | "github.com/maxwo/snmp_notifier/commons" 25 | "github.com/maxwo/snmp_notifier/telemetry" 26 | "github.com/maxwo/snmp_notifier/types" 27 | 28 | "text/template" 29 | 30 | "github.com/k-sone/snmpgo" 31 | "github.com/shirou/gopsutil/host" 32 | ) 33 | 34 | // TrapSender sends traps according to given alerts 35 | type TrapSender struct { 36 | logger *slog.Logger 37 | configuration Configuration 38 | snmpConnectionArguments []snmpgo.SNMPArguments 39 | } 40 | 41 | // Configuration describes the configuration for sending traps 42 | type Configuration struct { 43 | SNMPDestination []string 44 | SNMPRetries uint 45 | SNMPVersion string 46 | SNMPTimeout time.Duration 47 | 48 | SNMPCommunity string 49 | 50 | SNMPAuthenticationEnabled bool 51 | SNMPAuthenticationProtocol string 52 | SNMPAuthenticationUsername string 53 | SNMPAuthenticationPassword string 54 | SNMPPrivateEnabled bool 55 | SNMPPrivateProtocol string 56 | SNMPPrivatePassword string 57 | SNMPSecurityEngineID string 58 | SNMPContextEngineID string 59 | SNMPContextName string 60 | 61 | DescriptionTemplate template.Template 62 | UserObjects []UserObject 63 | } 64 | 65 | // UserObject describes a custom field sent via SNMP 66 | type UserObject struct { 67 | SubOID int 68 | ContentTemplate template.Template 69 | } 70 | 71 | // New creates a new TrapSender 72 | func New(configuration Configuration, logger *slog.Logger) TrapSender { 73 | snmpConnectionArguments := generationConnectionArguments(configuration) 74 | return TrapSender{logger, configuration, snmpConnectionArguments} 75 | } 76 | 77 | // SendAlertTraps sends a bucket of alerts to the given SNMP connection 78 | func (trapSender TrapSender) SendAlertTraps(alertBucket types.AlertBucket) error { 79 | traps, err := trapSender.generateTraps(alertBucket) 80 | if err != nil { 81 | for _, connection := range trapSender.snmpConnectionArguments { 82 | telemetry.SNMPTrapTotal.WithLabelValues(connection.Address, "failure").Add(float64(len(traps))) 83 | } 84 | return err 85 | } 86 | 87 | hasError := false 88 | 89 | for _, connection := range trapSender.snmpConnectionArguments { 90 | err := trapSender.sendTraps(connection, traps) 91 | if err != nil { 92 | hasError = true 93 | } 94 | } 95 | 96 | if hasError { 97 | return errors.New("error while sending one or more traps") 98 | } 99 | return nil 100 | } 101 | 102 | func (trapSender TrapSender) sendTraps(connectionArguments snmpgo.SNMPArguments, traps []snmpgo.VarBinds) error { 103 | distinationForMetrics := connectionArguments.Address 104 | 105 | snmp, err := snmpgo.NewSNMP(connectionArguments) 106 | if err != nil { 107 | trapSender.logger.Error("error while creating SNMP connection", "err", err.Error()) 108 | telemetry.SNMPTrapTotal.WithLabelValues(distinationForMetrics, "failure").Add(float64(len(traps))) 109 | return err 110 | } 111 | 112 | err = snmp.Open() 113 | if err != nil { 114 | trapSender.logger.Error("error while opening SNMP connection", "err", err.Error()) 115 | telemetry.SNMPTrapTotal.WithLabelValues(distinationForMetrics, "failure").Add(float64(len(traps))) 116 | return err 117 | } 118 | 119 | defer func() { 120 | snmp.Close() 121 | }() 122 | 123 | uptime, _ := host.Uptime() 124 | if uptime > math.MaxInt32 { 125 | uptime = 0 126 | } 127 | 128 | hasError := false 129 | for _, trap := range traps { 130 | err = snmp.V2TrapWithBootsTime(trap, 0, int(uptime)) 131 | if err != nil { 132 | telemetry.SNMPTrapTotal.WithLabelValues(distinationForMetrics, "failure").Inc() 133 | trapSender.logger.Error("error while generating trap", "destination", distinationForMetrics, "err", err.Error()) 134 | hasError = true 135 | } 136 | telemetry.SNMPTrapTotal.WithLabelValues(distinationForMetrics, "success").Inc() 137 | } 138 | 139 | if hasError { 140 | return errors.New("error while sending one or more traps") 141 | } 142 | return nil 143 | } 144 | 145 | func (trapSender TrapSender) generateTraps(alertBucket types.AlertBucket) ([]snmpgo.VarBinds, error) { 146 | var ( 147 | traps []snmpgo.VarBinds 148 | ) 149 | for uniqueTrapID, alertGroup := range alertBucket.AlertGroups { 150 | varBinds, err := trapSender.generateVarBinds(uniqueTrapID, *alertGroup) 151 | if err != nil { 152 | return nil, err 153 | } 154 | 155 | traps = append(traps, varBinds) 156 | } 157 | return traps, nil 158 | } 159 | 160 | func (trapSender TrapSender) generateVarBinds(uniqueTrapID string, alertGroup types.AlertGroup) (snmpgo.VarBinds, error) { 161 | var ( 162 | varBinds snmpgo.VarBinds 163 | ) 164 | 165 | description, err := commons.FillTemplate(alertGroup, trapSender.configuration.DescriptionTemplate) 166 | if err != nil { 167 | return nil, err 168 | } 169 | 170 | baseOid := alertGroup.DefaultObjectsBaseOID 171 | userObjectsBaseOID := alertGroup.UserObjectsBaseOID 172 | trapOid, _ := snmpgo.NewOid(alertGroup.TrapOID) 173 | 174 | varBinds = addUpTime(varBinds) 175 | varBinds = append(varBinds, snmpgo.NewVarBind(snmpgo.OidSnmpTrap, trapOid)) 176 | varBinds = addTrapSubObject(varBinds, baseOid, 1, uniqueTrapID) 177 | varBinds = addTrapSubObject(varBinds, baseOid, 2, alertGroup.Severity) 178 | varBinds = addTrapSubObject(varBinds, baseOid, 3, *description) 179 | 180 | for _, userObject := range trapSender.configuration.UserObjects { 181 | value, err := commons.FillTemplate(alertGroup, userObject.ContentTemplate) 182 | if err != nil { 183 | return nil, err 184 | } 185 | varBinds = addTrapSubObject(varBinds, userObjectsBaseOID, userObject.SubOID, *value) 186 | } 187 | 188 | return varBinds, nil 189 | } 190 | 191 | func addUpTime(varBinds snmpgo.VarBinds) snmpgo.VarBinds { 192 | uptime, _ := host.Uptime() 193 | return append(varBinds, snmpgo.NewVarBind(snmpgo.OidSysUpTime, snmpgo.NewTimeTicks(uint32(uptime*100)))) 194 | } 195 | 196 | func addTrapSubObject(varBinds snmpgo.VarBinds, baseOid string, subOid int, value string) snmpgo.VarBinds { 197 | oidString := strings.Join([]string{baseOid, strconv.Itoa(subOid)}, ".") 198 | oid, _ := snmpgo.NewOid(oidString) 199 | return append(varBinds, snmpgo.NewVarBind(oid, snmpgo.NewOctetString([]byte(strings.TrimSpace(value))))) 200 | } 201 | 202 | func generationConnectionArguments(configuration Configuration) []snmpgo.SNMPArguments { 203 | snmpArguments := []snmpgo.SNMPArguments{} 204 | for _, destination := range configuration.SNMPDestination { 205 | snmpArgument := snmpgo.SNMPArguments{ 206 | Address: destination, 207 | Retries: configuration.SNMPRetries, 208 | Timeout: configuration.SNMPTimeout, 209 | } 210 | 211 | if configuration.SNMPVersion == "V2c" { 212 | snmpArgument.Version = snmpgo.V2c 213 | snmpArgument.Community = configuration.SNMPCommunity 214 | } 215 | 216 | if configuration.SNMPVersion == "V3" { 217 | snmpArgument.Version = snmpgo.V3 218 | snmpArgument.UserName = configuration.SNMPAuthenticationUsername 219 | 220 | if configuration.SNMPAuthenticationEnabled && configuration.SNMPPrivateEnabled { 221 | snmpArgument.SecurityLevel = snmpgo.AuthPriv 222 | } else if configuration.SNMPAuthenticationEnabled { 223 | snmpArgument.SecurityLevel = snmpgo.AuthNoPriv 224 | } else { 225 | snmpArgument.SecurityLevel = snmpgo.NoAuthNoPriv 226 | } 227 | 228 | if configuration.SNMPPrivateEnabled { 229 | snmpArgument.PrivProtocol = snmpgo.PrivProtocol(configuration.SNMPPrivateProtocol) 230 | snmpArgument.PrivPassword = configuration.SNMPPrivatePassword 231 | } 232 | 233 | if configuration.SNMPAuthenticationEnabled { 234 | snmpArgument.AuthProtocol = snmpgo.AuthProtocol(configuration.SNMPAuthenticationProtocol) 235 | snmpArgument.AuthPassword = configuration.SNMPAuthenticationPassword 236 | } 237 | 238 | snmpArgument.SecurityEngineId = configuration.SNMPSecurityEngineID 239 | snmpArgument.ContextEngineId = configuration.SNMPContextEngineID 240 | snmpArgument.ContextName = configuration.SNMPContextName 241 | } 242 | 243 | snmpArguments = append(snmpArguments, snmpArgument) 244 | } 245 | 246 | return snmpArguments 247 | } 248 | -------------------------------------------------------------------------------- /trapsender/trap_sender_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2018 Maxime Wojtczak 2 | // Licensed under the Apache License, Version 2.0 (the "License"); 3 | // you may not use this file except in compliance with the License. 4 | // You may obtain a copy of the License at 5 | // 6 | // http://www.apache.org/licenses/LICENSE-2.0 7 | // 8 | // Unless required by applicable law or agreed to in writing, software 9 | // distributed under the License is distributed on an "AS IS" BASIS, 10 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | // See the License for the specific language governing permissions and 12 | // limitations under the License. 13 | 14 | package trapsender 15 | 16 | import ( 17 | "bytes" 18 | "encoding/json" 19 | "fmt" 20 | "log" 21 | "os" 22 | "testing" 23 | "time" 24 | 25 | "text/template" 26 | 27 | "github.com/maxwo/snmp_notifier/types" 28 | 29 | "log/slog" 30 | 31 | testutils "github.com/maxwo/snmp_notifier/test" 32 | 33 | "github.com/k-sone/snmpgo" 34 | ) 35 | 36 | var dummyDescriptionTemplate = `{{ len .Alerts }}/{{ len .DeclaredAlerts }} alerts are firing: 37 | {{ range $key, $value := .Alerts }}Alert name: {{ $value.Labels.alertname }} 38 | Severity: {{ $value.Labels.severity }} 39 | Summary: {{ $value.Annotations.summary }} 40 | Description: {{ $value.Annotations.description }} 41 | {{ end -}}` 42 | 43 | var invalidDescriptionTemplate = `{{ range $key, $value := .InvalidAlerts }}{{ end }}` 44 | 45 | var userObjectTemplate = `Alert count: {{ len .Alerts }}` 46 | 47 | func TestSimpleV2Trap(t *testing.T) { 48 | port, server, channel, err := testutils.LaunchTrapReceiver() 49 | if err != nil { 50 | t.Fatal("Error while opening server:", err) 51 | } 52 | defer server.Close() 53 | 54 | expectTraps(t, "test_mixed_bucket.json", 55 | "test_mixed_traps.json", 56 | Configuration{ 57 | SNMPDestination: []string{fmt.Sprintf("127.0.0.1:%d", *port)}, 58 | SNMPRetries: 1, 59 | SNMPVersion: "V2c", 60 | SNMPTimeout: 5 * time.Second, 61 | SNMPCommunity: "public", 62 | SNMPAuthenticationEnabled: false, 63 | SNMPAuthenticationProtocol: "", 64 | SNMPAuthenticationUsername: "", 65 | SNMPAuthenticationPassword: "", 66 | SNMPPrivateEnabled: false, 67 | SNMPPrivateProtocol: "", 68 | SNMPPrivatePassword: "", 69 | SNMPSecurityEngineID: "", 70 | SNMPContextEngineID: "", 71 | SNMPContextName: "", 72 | DescriptionTemplate: *template.Must(template.New("dummyDescriptionTemplate").Parse(dummyDescriptionTemplate)), 73 | UserObjects: make([]UserObject, 0), 74 | }, channel) 75 | } 76 | 77 | func TestV2TrapWithUserObject(t *testing.T) { 78 | port, server, channel, err := testutils.LaunchTrapReceiver() 79 | if err != nil { 80 | t.Fatal("Error while opening server:", err) 81 | } 82 | defer server.Close() 83 | 84 | expectTraps(t, "test_mixed_bucket_user_objects.json", 85 | "test_mixed_traps_user_objects.json", 86 | Configuration{ 87 | SNMPDestination: []string{fmt.Sprintf("127.0.0.1:%d", *port)}, 88 | SNMPRetries: 1, 89 | SNMPVersion: "V2c", 90 | SNMPTimeout: 5 * time.Second, 91 | SNMPCommunity: "public", 92 | SNMPAuthenticationEnabled: false, 93 | SNMPAuthenticationProtocol: "", 94 | SNMPAuthenticationUsername: "", 95 | SNMPAuthenticationPassword: "", 96 | SNMPPrivateEnabled: false, 97 | SNMPPrivateProtocol: "", 98 | SNMPPrivatePassword: "", 99 | SNMPSecurityEngineID: "", 100 | SNMPContextEngineID: "", 101 | SNMPContextName: "", 102 | DescriptionTemplate: *template.Must(template.New("dummyDescriptionTemplate").Parse(dummyDescriptionTemplate)), 103 | UserObjects: []UserObject{ 104 | { 105 | SubOID: 8, 106 | ContentTemplate: *template.Must(template.New("userObjectTemplate").Parse(userObjectTemplate)), 107 | }, 108 | }, 109 | }, channel) 110 | } 111 | 112 | func TestV2TrapWithCustomOID(t *testing.T) { 113 | port, server, channel, err := testutils.LaunchTrapReceiver() 114 | if err != nil { 115 | t.Fatal("Error while opening server:", err) 116 | } 117 | defer server.Close() 118 | 119 | expectTraps(t, 120 | "test_mixed_bucket.json", 121 | "test_mixed_traps_custom_base_oid.json", 122 | Configuration{ 123 | SNMPDestination: []string{fmt.Sprintf("127.0.0.1:%d", *port)}, 124 | SNMPRetries: 1, 125 | SNMPVersion: "V2c", 126 | SNMPTimeout: 5 * time.Second, 127 | SNMPCommunity: "public", 128 | SNMPAuthenticationEnabled: false, 129 | SNMPAuthenticationProtocol: "", 130 | SNMPAuthenticationUsername: "", 131 | SNMPAuthenticationPassword: "", 132 | SNMPPrivateEnabled: false, 133 | SNMPPrivateProtocol: "", 134 | SNMPPrivatePassword: "", 135 | SNMPSecurityEngineID: "", 136 | SNMPContextEngineID: "", 137 | SNMPContextName: "", 138 | DescriptionTemplate: *template.Must(template.New("dummyDescriptionTemplate").Parse(dummyDescriptionTemplate)), 139 | UserObjects: make([]UserObject, 0), 140 | }, channel) 141 | } 142 | 143 | func TestSimpleV3Trap(t *testing.T) { 144 | port, server, channel, err := testutils.LaunchTrapReceiver() 145 | if err != nil { 146 | t.Fatal("Error while opening server:", err) 147 | } 148 | defer server.Close() 149 | 150 | expectTraps(t, 151 | "test_mixed_bucket.json", 152 | "test_mixed_traps.json", 153 | Configuration{ 154 | SNMPDestination: []string{fmt.Sprintf("127.0.0.1:%d", *port)}, 155 | SNMPRetries: 1, 156 | SNMPVersion: "V3", 157 | SNMPTimeout: 5 * time.Second, 158 | SNMPCommunity: "", 159 | SNMPAuthenticationEnabled: true, 160 | SNMPAuthenticationProtocol: "SHA", 161 | SNMPAuthenticationUsername: "v3_username", 162 | SNMPAuthenticationPassword: "v3_password", 163 | SNMPPrivateEnabled: true, 164 | SNMPPrivateProtocol: "AES", 165 | SNMPPrivatePassword: "v3_private_secret", 166 | SNMPSecurityEngineID: "8000000004736e6d70676f", 167 | SNMPContextEngineID: "", 168 | SNMPContextName: "", 169 | DescriptionTemplate: *template.Must(template.New("dummyDescriptionTemplate").Parse(dummyDescriptionTemplate)), 170 | UserObjects: make([]UserObject, 0), 171 | }, 172 | channel) 173 | } 174 | 175 | func TestV2TrapWithInvalidDescriptionTemplate(t *testing.T) { 176 | port, server, _, err := testutils.LaunchTrapReceiver() 177 | if err != nil { 178 | t.Fatal("Error while opening server:", err) 179 | } 180 | defer server.Close() 181 | 182 | expectErrorOnSending(t, 183 | "test_mixed_bucket.json", 184 | Configuration{ 185 | SNMPDestination: []string{fmt.Sprintf("127.0.0.1:%d", *port)}, 186 | SNMPRetries: 1, 187 | SNMPVersion: "V2c", 188 | SNMPTimeout: 5 * time.Second, 189 | SNMPCommunity: "public", 190 | SNMPAuthenticationEnabled: false, 191 | SNMPAuthenticationProtocol: "", 192 | SNMPAuthenticationUsername: "", 193 | SNMPAuthenticationPassword: "", 194 | SNMPPrivateEnabled: false, 195 | SNMPPrivateProtocol: "", 196 | SNMPPrivatePassword: "", 197 | SNMPSecurityEngineID: "", 198 | SNMPContextEngineID: "", 199 | SNMPContextName: "", 200 | DescriptionTemplate: *template.Must(template.New("invalidDescriptionTemplate").Parse(invalidDescriptionTemplate)), 201 | UserObjects: make([]UserObject, 0), 202 | }) 203 | } 204 | 205 | func TestV3TrapWithAuthenticationError(t *testing.T) { 206 | port, server, _, err := testutils.LaunchTrapReceiver() 207 | if err != nil { 208 | t.Fatal("Error while opening server:", err) 209 | } 210 | defer server.Close() 211 | 212 | expectErrorOnSending(t, 213 | "test_mixed_bucket.json", 214 | Configuration{ 215 | SNMPDestination: []string{fmt.Sprintf("127.0.0.1:%d", *port)}, 216 | SNMPRetries: 1, 217 | SNMPVersion: "V3", 218 | SNMPTimeout: 5 * time.Second, 219 | SNMPCommunity: "", 220 | SNMPAuthenticationEnabled: true, 221 | SNMPAuthenticationProtocol: "SHA", 222 | SNMPAuthenticationUsername: "v3_username", 223 | SNMPAuthenticationPassword: "v3_password", 224 | SNMPPrivateEnabled: true, 225 | SNMPPrivateProtocol: "AES", 226 | SNMPPrivatePassword: "v3_private_secret", 227 | SNMPSecurityEngineID: "", 228 | SNMPContextEngineID: "", 229 | SNMPContextName: "", 230 | DescriptionTemplate: *template.Must(template.New("dummyDescriptionTemplate").Parse(dummyDescriptionTemplate)), 231 | UserObjects: make([]UserObject, 0), 232 | }) 233 | } 234 | 235 | func expectErrorOnSending(t *testing.T, bucketFileName string, configuration Configuration) { 236 | bucketByteData, err := os.ReadFile(bucketFileName) 237 | if err != nil { 238 | t.Fatal("Error while reading bucket file:", err) 239 | } 240 | bucketReader := bytes.NewReader(bucketByteData) 241 | bucketData := types.AlertBucket{} 242 | err = json.NewDecoder(bucketReader).Decode(&bucketData) 243 | if err != nil { 244 | t.Fatal("Error while parsing bucket file:", err) 245 | } 246 | 247 | trapSender := New(configuration, slog.New(slog.NewTextHandler(os.Stdout, nil))) 248 | 249 | err = trapSender.SendAlertTraps(bucketData) 250 | if err == nil { 251 | t.Error("An error was expected") 252 | } 253 | } 254 | 255 | func expectTraps(t *testing.T, bucketFileName string, trapFileName string, configuration Configuration, channel chan *snmpgo.TrapRequest) { 256 | 257 | bucketByteData, err := os.ReadFile(bucketFileName) 258 | if err != nil { 259 | t.Fatal("Error while reading bucket file:", err) 260 | } 261 | bucketReader := bytes.NewReader(bucketByteData) 262 | bucketData := types.AlertBucket{} 263 | err = json.NewDecoder(bucketReader).Decode(&bucketData) 264 | if err != nil { 265 | t.Fatal("Error while parsing bucket file:", err) 266 | } 267 | 268 | trapSender := New(configuration, slog.New(slog.NewTextHandler(os.Stdout, nil))) 269 | 270 | err = trapSender.SendAlertTraps(bucketData) 271 | if err != nil { 272 | t.Error("An unexpected error occurred:", err) 273 | } 274 | 275 | if err == nil { 276 | receivedTraps := testutils.ReadTraps(channel) 277 | 278 | log.Print("Traps received:", receivedTraps) 279 | 280 | if len(receivedTraps) != 2 { 281 | t.Error("2 traps expected, but received", receivedTraps) 282 | } 283 | 284 | expectedTrapsByteData, err := os.ReadFile(trapFileName) 285 | if err != nil { 286 | t.Fatal("Error while reading traps file:", err) 287 | } 288 | expectedTrapsReader := bytes.NewReader(expectedTrapsByteData) 289 | expectedTrapsData := []map[string]string{} 290 | err = json.NewDecoder(expectedTrapsReader).Decode(&expectedTrapsData) 291 | if err != nil { 292 | t.Fatal("Error while parsing traps file:", err) 293 | } 294 | 295 | for _, expectedTrap := range expectedTrapsData { 296 | if !testutils.FindTrap(receivedTraps, expectedTrap) { 297 | t.Fatal("Expected trap not found:", expectedTrap) 298 | } 299 | } 300 | } 301 | } 302 | -------------------------------------------------------------------------------- /types/types.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | import ( 4 | alertmanagertemplate "github.com/prometheus/alertmanager/template" 5 | ) 6 | 7 | // Alert is an alert received from the Alertmanager 8 | type Alert = alertmanagertemplate.Alert 9 | 10 | // Alerts is a set of alerts received from the Alertmanager 11 | type Alerts = alertmanagertemplate.Alerts 12 | 13 | // AlertsData is the alerts object received from the Alertmanager 14 | type AlertsData = alertmanagertemplate.Data 15 | 16 | // AlertBucket mutualizes alerts by Trap IDs 17 | type AlertBucket struct { 18 | AlertGroups map[string]*AlertGroup 19 | } 20 | 21 | // AlertGroup type, with OID and group ID 22 | type AlertGroup struct { 23 | TrapOID string 24 | GroupID string 25 | DefaultObjectsBaseOID string 26 | UserObjectsBaseOID string 27 | GroupLabels map[string]string 28 | CommonLabels map[string]string 29 | CommonAnnotations map[string]string 30 | Severity string 31 | Alerts []Alert 32 | DeclaredAlerts []Alert 33 | } 34 | 35 | // GetAlertGroupName allows to retrieve a group name from a given alert 36 | type GetAlertGroupName func(Alert) (*string, error) 37 | --------------------------------------------------------------------------------