├── .github └── workflows │ ├── coverage.yml │ └── tests.yml ├── .gitignore ├── .rspec ├── .rubocop.yml ├── .rubocop_todo.yml ├── AUTHORS ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── Gemfile ├── LICENSE ├── README.md ├── Rakefile ├── docker-compose-github.yml ├── docker-compose-jruby.yml ├── docker-compose-ruby-2.1.yml ├── docker-compose-ruby-2.2.yml ├── docker-compose-ruby-2.3.yml ├── docker-compose-ruby-2.4.yml ├── docker-compose-ruby-2.5.yml ├── docker-compose-ruby-2.6.yml ├── docker-compose-ruby-2.7.yml ├── docker-compose-ruby-3.0.yml ├── docker-compose-ruby-3.1.yml ├── docker-compose-ruby-3.2.yml ├── docker-compose-truffleruby.yml ├── docker-compose.yml ├── lib ├── netsnmp.rb └── netsnmp │ ├── client.rb │ ├── encryption │ ├── aes.rb │ ├── des.rb │ └── none.rb │ ├── errors.rb │ ├── extensions.rb │ ├── loggable.rb │ ├── message.rb │ ├── mib.rb │ ├── mib │ └── parser.rb │ ├── oid.rb │ ├── pdu.rb │ ├── scoped_pdu.rb │ ├── security_parameters.rb │ ├── session.rb │ ├── timeticks.rb │ ├── v3_session.rb │ ├── varbind.rb │ └── version.rb ├── netsnmp.gemspec ├── sig ├── client.rbs ├── encryption │ ├── aes.rbs │ └── des.rbs ├── errors.rbs ├── extensions.rbs ├── loggable.rbs ├── message.rbs ├── mib.rbs ├── mib │ └── parser.rbs ├── netsnmp.rbs ├── oid.rbs ├── pdu.rbs ├── scoped_pdu.rbs ├── security_parameters.rbs ├── session.rbs ├── timeticks.rbs ├── v3_session.rbs └── varbind.rbs └── spec ├── client_spec.rb ├── handlers └── celluloid_spec.rb ├── mib_spec.rb ├── oid_spec.rb ├── pdu_spec.rb ├── security_parameters_spec.rb ├── session_spec.rb ├── spec_helper.rb ├── support ├── celluloid.rb ├── request_examples.rb ├── specs.sh └── stop_docker.sh ├── timeticks_spec.rb ├── v3_session_spec.rb └── varbind_spec.rb /.github/workflows/coverage.yml: -------------------------------------------------------------------------------- 1 | # This workflow uses actions that are not certified by GitHub. 2 | # They are provided by a third-party and are governed by 3 | # separate terms of service, privacy policy, and support 4 | # documentation. 5 | # This workflow will download a prebuilt Ruby version, install dependencies and run tests with Rake 6 | # For more information see: https://github.com/marketplace/actions/setup-ruby-jruby-and-truffleruby 7 | 8 | name: Coverage 9 | 10 | on: 11 | workflow_run: 12 | workflows: 13 | - Tests 14 | branches: 15 | - master 16 | types: 17 | - success 18 | 19 | jobs: 20 | coverage: 21 | runs-on: ubuntu-latest 22 | 23 | steps: 24 | - uses: haya14busa/action-workflow_run-status@v1 25 | - uses: actions/checkout@v2 26 | - uses: actions/download-artifact@v1 27 | with: 28 | name: coverage-report 29 | - uses: actions/setup-ruby@v1 30 | with: 31 | ruby-version: '3.1' 32 | bundler-cache: true 33 | - run: bundle exec rake coverage:report 34 | - uses: actions/upload-artifact@v2 35 | with: 36 | name: coverage-report 37 | path: coverage/ 38 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | # This workflow uses actions that are not certified by GitHub. 2 | # They are provided by a third-party and are governed by 3 | # separate terms of service, privacy policy, and support 4 | # documentation. 5 | # This workflow will download a prebuilt Ruby version, install dependencies and run tests with Rake 6 | # For more information see: https://github.com/marketplace/actions/setup-ruby-jruby-and-truffleruby 7 | 8 | name: Tests 9 | 10 | on: 11 | push: 12 | branches: [ master ] 13 | pull_request: 14 | branches: [ '**' ] 15 | 16 | jobs: 17 | tests: 18 | 19 | runs-on: ubuntu-latest 20 | 21 | strategy: 22 | fail-fast: false 23 | matrix: 24 | ruby: 25 | - "ruby-2.1" 26 | - "ruby-2.2" 27 | - "ruby-2.3" 28 | - "ruby-2.4" 29 | - "ruby-2.5" 30 | - "ruby-2.6" 31 | - "ruby-2.7" 32 | - "ruby-3.0" 33 | - "ruby-3.1" 34 | - "ruby-3.2" 35 | - "jruby" 36 | - "truffleruby" 37 | 38 | steps: 39 | - uses: actions/checkout@v2 40 | - name: Cache gems 41 | uses: actions/cache@v2 42 | with: 43 | path: vendor/bundle 44 | key: ${{ runner.os }}-gems-${{ matrix.ruby }}-${{ hashFiles('**/Gemfile.lock') }} 45 | restore-keys: | 46 | ${{ runner.os }}-gems- 47 | - name: Run tests 48 | run: docker-compose -f docker-compose.yml -f docker-compose-github.yml -f docker-compose-${{matrix.ruby}}.yml run netsnmp 49 | continue-on-error: ${{ matrix.ruby == 'jruby' || matrix.ruby == 'truffleruby' }} 50 | - name: Upload coverage 51 | uses: actions/upload-artifact@v2 52 | if: always() 53 | with: 54 | name: coverage-report 55 | path: coverage/ 56 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /coverage/ 2 | /doc/ 3 | /pkg/ 4 | /tmp/ 5 | /log/ 6 | /.yardoc/ 7 | /.bundle 8 | /Gemfile.lock 9 | /vendor 10 | .env 11 | 12 | *.DS_Store 13 | *.sw[po] 14 | *.nfs* 15 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --color 2 | --require spec_helper 3 | --format progress 4 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | inherit_from: .rubocop_todo.yml 2 | 3 | require: 4 | - rubocop-performance 5 | 6 | AllCops: 7 | TargetRubyVersion: 2.7 8 | NewCops: enable 9 | 10 | Bundler/DuplicatedGem: 11 | Enabled: false 12 | 13 | Lint/SuppressedException: 14 | Exclude: 15 | - Rakefile 16 | 17 | Lint/SendWithMixinArgument: 18 | Enabled: false 19 | 20 | Layout/HeredocIndentation: 21 | Enabled: false 22 | 23 | Style/NumericPredicate: 24 | Exclude: 25 | - lib/netsnmp.rb 26 | 27 | Style/RedundantBegin: 28 | Enabled: false 29 | 30 | Style/SlicingWithRange: 31 | Enabled: false 32 | 33 | Metrics/ParameterLists: 34 | Enabled: false 35 | 36 | Style/ClassAndModuleChildren: 37 | Enabled: false 38 | 39 | Metrics/PerceivedComplexity: 40 | Enabled: false 41 | 42 | Metrics/BlockLength: 43 | Enabled: false 44 | 45 | Metrics/BlockNesting: 46 | Enabled: false 47 | 48 | Gemspec/RequiredRubyVersion: 49 | Enabled: false 50 | 51 | Style/SafeNavigation: 52 | Enabled: false 53 | 54 | Style/HashConversion: 55 | Enabled: false 56 | 57 | Performance/MethodObjectAsBlock: 58 | Enabled: false 59 | -------------------------------------------------------------------------------- /.rubocop_todo.yml: -------------------------------------------------------------------------------- 1 | # This configuration was generated by 2 | # `rubocop --auto-gen-config` 3 | # on 2018-02-07 22:49:11 +0000 using RuboCop version 0.52.1. 4 | # The point is for the user to remove these configuration records 5 | # one by one as the offenses are removed from the code base. 6 | # Note that changes in the inspected code, or installation of new 7 | # versions of RuboCop, may require this file to be generated again. 8 | 9 | # Offense count: 17 10 | Metrics/AbcSize: 11 | Max: 100 12 | 13 | # Offense count: 2 14 | # Configuration parameters: CountComments. 15 | Metrics/ClassLength: 16 | Max: 1000 17 | 18 | Metrics/ModuleLength: 19 | Max: 1000 20 | 21 | # Offense count: 8 22 | Metrics/CyclomaticComplexity: 23 | Max: 20 24 | 25 | # Offense count: 22 26 | # Configuration parameters: CountComments. 27 | Metrics/MethodLength: 28 | Max: 50 29 | 30 | # Offense count: 2 31 | # Configuration parameters: CountKeywordArgs. 32 | Metrics/ParameterLists: 33 | Max: 7 34 | 35 | # Offense count: 1 36 | Metrics/PerceivedComplexity: 37 | Max: 9 38 | 39 | # Offense count: 10 40 | Style/Documentation: 41 | Enabled: false 42 | 43 | # Offense count: 8 44 | # Cop supports --auto-correct. 45 | # Configuration parameters: Strict. 46 | Style/NumericLiterals: 47 | MinDigits: 11 48 | 49 | # Offense count: 264 50 | # Cop supports --auto-correct. 51 | # Configuration parameters: EnforcedStyle, ConsistentQuotesInMultiline. 52 | # SupportedStyles: single_quotes, double_quotes 53 | Style/StringLiterals: 54 | EnforcedStyle: double_quotes 55 | 56 | # Offense count: 112 57 | # Configuration parameters: AllowHeredoc, AllowURI, URISchemes, IgnoreCopDirectives, IgnoredPatterns. 58 | # URISchemes: http, https 59 | Layout/LineLength: 60 | Max: 189 61 | -------------------------------------------------------------------------------- /AUTHORS: -------------------------------------------------------------------------------- 1 | * Tiago Cardoso (cardoso_tiago@hotmail.com) 2 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # CHANGELOG 2 | 3 | ## master 4 | 5 | ### 0.6.4 6 | 7 | Making the octet string in msgAuthenticationParameters 0-length when no authentication is to happen (some SNMP implementations are quite strict in this point). 8 | 9 | ### 0.6.3 10 | 11 | * The `OidNotFound` exception is now raised when a response PDU is received with an empty value. 12 | 13 | ### 0.6.2 14 | 15 | #### Improvements 16 | 17 | * extended use of RBS signatures (thx to `openssl` RBS signatures). 18 | 19 | #### Bugfixes 20 | 21 | * fixed mib loading when there are no imports. 22 | * fixed default mibs by including subdirs of `/usr/share/snmp/mibs` (in debian, snmp mibs get loaded under `/iana` and `/ietf`) 23 | 24 | ### 0.6.1 25 | 26 | #### Bugfixes 27 | 28 | Removed `MSG_NOSIGNAL` flag from udp socket send calls, given that it's unnecessary for UDP transactions, it's not defined in all environments, like Mac OS. 29 | 30 | ### 0.6.0 31 | 32 | #### Features 33 | 34 | `netsnmp` supports SHA256 as an authentication protocol. You can pass `:sha256` to the `:auth_protocol` argument, as an alternative too `:sha` or `:md5`. (#29) 35 | 36 | ### 0.5.0 37 | 38 | #### Improvements 39 | 40 | * Using the `sendmsg` and `recvmsg` family of socket APIs, which allow for connectionless-oriented communication, and do not require the response packets coming from the same host:port pair (which some old SNMP agents do). 41 | 42 | #### Bugfixes 43 | 44 | * Fixed corruption of authenticated PDUs when performing auth param substitution in the payload, which was reported as causinng decryption error report PDUs being sent back. 45 | 46 | ### 0.4.2 47 | 48 | #### Improvements 49 | 50 | Errors of the [usmStats family](http://oidref.com/1.3.6.1.6.3.15.1.1) will now raise an exception, where the message will be the same as `netsnmp` message for the same use-case (#50). 51 | 52 | ### 0.4.1 53 | 54 | fixed: namespace scope-based MIB lookups weren't working for custom-loaded MIBs (#48) 55 | 56 | ### 0.4.0 57 | 58 | #### Features 59 | 60 | * New debugging logs: `NETSNMP::Client.new(..., debug: $stderr, debug_level: 2)` (can also be activated with envvar, i.e. `NETSNMP_DEBUG=2`); 61 | 62 | #### Improvements 63 | 64 | * octet strings are now returned in the original encoding; Binary strings are now returned as an "hex-string", which will be a normal string, but it'll print in hexa format, a la netsnmp. 65 | 66 | #### Bugfixes 67 | 68 | * incoming v3 message security level is now used to decide whether to decrypt/authorize (it was taking the send security level into account); 69 | * reacting to incoming REPORT pdu with `IdNotInTimeWindow` OID by updating the time and replay request PDU (something common to Cisco Routers); 70 | * Fiterling out unused bits from V3 message flags; 71 | 72 | ### 0.3.0 73 | 74 | * MIB Parser. 75 | * methods can use MIBs as well as OIDs. 76 | 77 | ```ruby 78 | client.get(oid: "sysName.0") 79 | ``` 80 | 81 | ### 0.2.0 82 | 83 | * Fix kwargs issues, enabling ruby 3. 84 | * RBS type signatures. 85 | * Bye Travis, hello Github Actions. 86 | 87 | ### 0.1.9 88 | 89 | * Fix the encoding of gauge/counter32 ASN values. 90 | 91 | ### 0.1.8 92 | 93 | * Fix for Timeticks with smaller values. 94 | 95 | ### 0.1.7 96 | 97 | * Fixed padding of counter/gauge varbinds. 98 | 99 | ### 0.1.6 100 | 101 | * Added support for 64bit varbinds, such as Counter64. 102 | 103 | ### 0.1.5 104 | 105 | * Added `NETSNMP#inform` to send INFORM PDUs as well. 106 | * Fixed encoding for counter32/gauge types for >16bit numbers. 107 | * Fixed encryption of PDU packets when data's not a multiplier of 8. 108 | 109 | ### 0.1.4 110 | 111 | * Fixed unexisting `Timeout::Error` constant, as "timeout" wasn't being required. 112 | * Allow returning multiple varbind values, when more than one PDU is sent. 113 | * Added proper support for Gauge/Counter32. 114 | * Added support for SNMPv3 timeliness. 115 | 116 | ### 0.1.3 117 | 118 | * Added the `NETSNMP::Timetick` entity, which coerces into a numeric. 119 | 120 | ### 0.1.2 121 | 122 | * Fixes for propagation of error, specific error message for unrecognized SNMP error codes as well. 123 | * encode octet strings to UTF-8 124 | 125 | ### 0.1.1 126 | 127 | * IPAddress varbind values will be converted from and to `IPAddr` objects. 128 | 129 | ### 0.1.0 130 | 131 | * `netsnmp` gem goes public. 132 | * rewrite of FFI logic into pure ruby, handling ASN1 encoding via `openssl` gem. 133 | 134 | ### 0.0.2 135 | 136 | * Fixing timeout issues. 137 | 138 | ### 0.0.1 139 | 140 | * First version, FFI-based (using C `libnetsnmp`). 141 | * (This version was very buggy, and isn't recommended for production usage). 142 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | For interactions on GitHub, Swisscom’s [Social Media Netiquette](https://www.swisscom.ch/en/residential/legal-information/social-media-netiquette.html) applies. 2 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | We welcome contributions and are really glad about your involvement in the project. 4 | 5 | ## Reporting Feedback 6 | 7 | Use GitHub’s issues feature to report problems with and request enhancements to the functionality or documentation. 8 | 9 | ## Contributing Code 10 | 11 | Code contributions are highly appreciated. Bug fixes and small enhancements can be handed in as pull-requests. For larger changes and features, please open a GitHub issue with your proposal first so the solution can be discussed. 12 | 13 | ### Commit Guidelines 14 | 15 | All your commits must follow the [conventional commit message format](https://www.conventionalcommits.org/en/v1.0.0/#summary). 16 | 17 | Valid scopes for this project are: 18 | 19 | - `editor`: changes to the script editor GUI 20 | - `runner`: changes to the script execution logic 21 | - `meta`: changes to the build process, deployment, packaging or the project setup 22 | 23 | Commits that fall into none of these scopes or change aspects in more than one of them should not specify a scope. 24 | 25 | Ideally, each commit should should represent a working and buildable state but still only contain an atomic change compared to its parent. Please rewrite history accordingly before opening a PR. 26 | 27 | ### License 28 | 29 | By contributing code or documentation, you agree to have your contribution licensed under [our license](/LICENSE). 30 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source "https://rubygems.org/" 4 | ruby RUBY_VERSION 5 | 6 | gemspec 7 | 8 | gem "rake", "~> 12.3" 9 | gem "rspec", "~> 3.5" 10 | 11 | platform :mri, :truffleruby do 12 | gem "xorcist", require: false 13 | end 14 | 15 | if RUBY_VERSION >= "3.0.0" 16 | gem "rubocop" 17 | gem "rubocop-performance" 18 | end 19 | 20 | gem "rbs" if RUBY_VERSION >= "3.0" 21 | 22 | if RUBY_VERSION < "2.3" 23 | gem "simplecov", "< 0.11.0" 24 | elsif RUBY_VERSION < "2.4" 25 | gem "docile", "< 1.4.0" 26 | gem "simplecov", "< 0.19.0" 27 | elsif RUBY_VERSION < "2.5" 28 | gem "docile", "< 1.4.0" 29 | gem "simplecov", "< 0.21.0" 30 | else 31 | gem "simplecov" 32 | end 33 | 34 | gem "celluloid-io", "~> 0.17" if RUBY_VERSION >= "2.3.0" 35 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "{}" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright {yyyy} {name of copyright owner} 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # netsnmp 2 | 3 | [![Gem Version](https://badge.fury.io/rb/netsnmp.svg)](http://rubygems.org/gems/netsnmp) 4 | ![Tests](https://github.com/swisscom/ruby-netsnmp/workflows/Tests/badge.svg) 5 | [![Code Climate](https://codeclimate.com/github/swisscom/ruby-netsnmp/badges/gpa.svg)](https://codeclimate.com/github/swisscom/ruby-netsnmp) 6 | [![Docs](http://img.shields.io/badge/yard-docs-blue.svg)](https://www.rubydoc.info/github/swisscom/ruby-netsnmp/master) 7 | 8 | The `netsnmp` gem provides a ruby native implementation of the SNMP protocol (v1/2c abd v3). 9 | 10 | ## Installation 11 | 12 | Add this line to your application's Gemfile: 13 | 14 | ```ruby 15 | gem 'netsnmp' 16 | ``` 17 | 18 | And then execute: 19 | 20 | ``` 21 | $ bundle 22 | ``` 23 | 24 | Or install it yourself as: 25 | 26 | ``` 27 | $ gem install netsnmp 28 | ``` 29 | 30 | ## Features 31 | 32 | This gem provides: 33 | 34 | * Implementation in ruby of the SNMP Protocol for v3, v2c and v1 (most notable the rfc3414 and 3826). 35 | * SNMPv3 USM supporting MD5/SHA/SHA256 auth and DES/AES128 privacy crypto algorithms. 36 | * Client/Manager API with simple interface for get, genext, set and walk. 37 | * Pure Ruby. 38 | * Support for concurrency and evented I/O. 39 | 40 | ## Why? 41 | 42 | If you look for snmp gems in ruby toolbox, you'll find a bunch. 43 | You may ask, why not just use one of them? 44 | 45 | Most of them only implement v1 and v2, so if your requirement is to use v3, you're left with only 2 choices: [net-snmp](https://github.com/mixtli/net-snmp) (unmantained since 2013) and its follow-up [net-snmp2](https://github.com/jbreeden/net-snmp2), which started as a fork to fix some bugs left unattended. Both libraries wrap the C netsnmp library using FFI, which leaves them vulnerable to the following bugs (experienced in both libraries): 46 | 47 | * Dependency of specific versions of netsnmp C package. 48 | * Memory Leaks. 49 | * Doesn't work reliable in ruby > 2.0.0-p576, crashing the VM. 50 | * Network I/O done by the library, thereby blocking the GVL, thereby making all snmp calls block the whole ruby VM. 51 | * This means, multi-threading is impossible. 52 | * This means, evented I/O is impossible. 53 | 54 | All of these issues are resolved here. 55 | 56 | ## Features 57 | 58 | * Client Interface, which supports SNMP v3, v2c, and v1 59 | * Supports get, getnext, set and walk calls 60 | * MIB support 61 | * Proxy IO object support (for eventmachine/celluloid-io) 62 | * Ruby >= 2.1 support (modern) 63 | * Pure Ruby (no FFI) 64 | * Easy PDU debugging 65 | 66 | ## Examples 67 | 68 | You can use the docker container provided under spec/support to test against these examples (the port used in the examples should be the docker external port mapped to port 161). 69 | 70 | ```ruby 71 | require 'netsnmp' 72 | 73 | # example you can test against the docker simulator provided. port attribute might be different. 74 | manager = NETSNMP::Client.new(host: "localhost", port: 33445, username: "simulator", 75 | auth_password: "auctoritas", auth_protocol: :md5, 76 | priv_password: "privatus", priv_protocol: :des, 77 | context: "a172334d7d97871b72241397f713fa12") 78 | 79 | # SNMP get 80 | manager.get(oid: "sysName.0") #=> 'tt' 81 | 82 | # SNMP walk 83 | # sysORDescr 84 | manager.walk(oid: "sysORDescr").each do |oid_code, value| 85 | # do something with them 86 | puts "for #{oid_code}: #{value}" 87 | end 88 | 89 | manager.close 90 | 91 | # SNMP set 92 | manager2 = NETSNMP::Client.new(host: "localhost", port: 33445, username: "simulator", 93 | auth_password: "auctoritas", auth_protocol: :md5, 94 | priv_password: "privatus", priv_protocol: :des, 95 | context: "0886e1397d572377c17c15036a1e6c66") 96 | 97 | # setting to 43, becos yes 98 | # sysUpTimeInstance 99 | manager2.set("1.3.6.1.2.1.1.3.0", value: 43) 100 | 101 | manager2.close 102 | ``` 103 | 104 | SNMP v2/v1 examples will be similar (beware of the differences in the initialization attributes). 105 | 106 | ## SNMP Application Types 107 | 108 | All previous examples were done specifying primitive types, i.e. unless specified otherwise, it's gonna try to convert a ruby "primitive" type to an ASN.1 primitive type, and vice-versa: 109 | 110 | * Integer -> ASN.1 Integer 111 | * String -> ASN.1 Octet String 112 | * nil -> ASN.1 Null 113 | * true, false -> ASN.1 Boolean 114 | 115 | That means that, if you pass `value: 43` to the `#set` call, it's going to build a varbind with an ASN.1 Integer. If You issue a `#get` and the response contains an ASN.1 Integer, it's going to return an Integer. 116 | 117 | However, SNMP defines application-specific ASN.1 types, for which there is support, albeit limited. Currently, there is support for ip addresses and timeticks. 118 | 119 | * IPAddr -> ASN.1 context-specific 120 | 121 | If you create an `IPAddr` object (ruby standard library `ipaddr`) and pass it to the `#set` call, it will map to the SNMP content-specific code. If the response of a `#get` call contains an ip address, it will map to an `IPAddr` object. 122 | 123 | * NETSNMP::Timeticks -> ASN.1 content-specific 124 | 125 | The `NETSNMP::Timeticks` type is internal to this library, but it is a ruby `Numeric` type. You are safe to use it "as a numeric", that is, perform calculations. 126 | 127 | 128 | Counter32 and Counter64 types will map to plain integers. 129 | 130 | You can find usage examples [here](https://github.com/swisscom/ruby-netsnmp/blob/master/spec/varbind_spec.rb). If you need support to a missing type, you have the following options: 131 | 132 | * Use the `:type` parameter in `#set` calls: 133 | ```ruby 134 | # as a symbol 135 | manager.set("somecounteroid", value: 999999, type: :counter64) 136 | # as the SNMP specific type id, if you're familiar with the protocol 137 | manager.set("somecounteroid", value: 999999, type: 6) 138 | ``` 139 | * Fork this library, extend support, write a test and submit a PR (the desired solution ;) ) 140 | 141 | ## MIB 142 | 143 | `netsnmp` will load the default MIBs from known or advertised (via `MIBDIRS`) directories (provided that they're installed in the system). These will be used for the OID conversion. 144 | 145 | Sometimes you'll need to load more, your own MIBs, in which case, you can use the following API: 146 | 147 | ```ruby 148 | require "netsnmp" 149 | 150 | NETSNMP::MIB.load("MY-MIB") 151 | # or, if it's not in any of the known locations 152 | NETSNMP::MIB.load("/path/to/MY-MIB.txt") 153 | ``` 154 | 155 | You can install common SNMP mibs by using your package manager: 156 | 157 | ``` 158 | # using apt-get 159 | > apt-get install snmp-mibs-downloader 160 | # using apk 161 | > apk --update add net-snmp-libs 162 | ``` 163 | 164 | ## Concurrency 165 | 166 | In ruby, you are usually adviced not to share IO objects across threads. The same principle applies here to `NETSNMP::Client`: provided you use it within a thread of execution, it should behave safely. So, something like this would be possible: 167 | 168 | ```ruby 169 | general_options = { auth_protocol: .... 170 | routers.map do |r| 171 | Thread.start do 172 | NETSNMP::Client.new(general_options.merge(host: r)) do |cl| 173 | cli.get(oid: "1.6.3....... 174 | 175 | end 176 | end 177 | end.each(&:join) 178 | ``` 179 | 180 | Evented IO is also supported, in that you can pass a `:proxy` object as an already opened channel of communication to the client. Very important: you have to take care of the lifecycle, as the client will not connect and will not close the object, it will assume no control over it. 181 | 182 | When passing a proxy object, you can omit the `:host` parameter. 183 | 184 | The proxy object will have to be a duck-type implementing `#send`, which is a method receiving the sending PDU payload, and return the payload of the receiving PDU. 185 | 186 | Here is a small pseudo-code example: 187 | 188 | ```ruby 189 | # beware, we are inside a warp-speed loop!!! 190 | general_options = { auth_protocol: .... 191 | proxy = SpecialUDPImplementation.new(host: router) 192 | NETSNMP::Client.new(general_options.merge(proxy: proxy)) do |cl| 193 | # this get call will eventually #send to the proxy... 194 | cli.get(oid: "1.6.3....... 195 | 196 | end 197 | # client isn't usable anymore, but now we must close to proxy 198 | proxy.close 199 | ``` 200 | 201 | For more information about this subject, the specs test this feature against celluloid-io. An eventmachine could be added, if someone would be kind enough to provide an implementation. 202 | 203 | ## Performance 204 | 205 | 206 | ### XOR 207 | 208 | This library has some workarounds to some missing features in the ruby language, namely the inexistence of a byte array structure. The closest we have is a byte stream presented as a String with ASCII encoding. A method was added to the String class called `#xor` for some operations needed internally. To prevent needless monkey-patches, Refinements have been employed. 209 | 210 | If `#xor` becomes at some point the bottleneck of your usage, this gem has also support for [xorcist](https://github.com/fny/xorcist/). You just have to add it to your Gemfile (or install it in the system): 211 | 212 | ``` 213 | # Gemfile 214 | 215 | gem 'netsnmp' 216 | 217 | # or, in the command line 218 | 219 | $ gem install netsnmp 220 | ``` 221 | 222 | and `netsnmp` will automatically pick it up. 223 | 224 | ## Auth/Priv Key 225 | 226 | If you'll use this gem often with SNMP v3 and auth/priv security level enabled, you'll have that funny feeling that everything could be a bit faster. Well, this is basically because the true performance bottleneck of this gem is the generation of the auth and pass keys used for authorization and encryption. Although this is a one-time thing for each client, its lag will be noticeable if you're running on > 100 hosts. 227 | 228 | There is a recommended work-around, but this is only usable **if you are using the same user/authpass/privpass on all the hosts!!!**. Use this with care, then: 229 | 230 | ```ruby 231 | $shared_security_parameters = NETSNMP::SecurityParameters.new(security_level: :authpriv, username: "mustermann", 232 | auth_protocol: :md5, priv_protocol: :aes, .... 233 | # this will eager-load the auth/priv_key 234 | ... 235 | 236 | # over 9000 routers are running on this event loop!!! this is just one! 237 | NETSNMP::Client.new(share_options.merge(proxy: router_proxy, security_parameters: $shared_security_parameters.dup).new do |cl| 238 | cli.get(oid: ..... 239 | end 240 | ``` 241 | 242 | ## Compatibility 243 | 244 | This library supports and is tested against ruby versions 2.1 or more recent, including ruby 3. It also supports and tests against Truffleruby. 245 | 246 | ## OpenSSL 247 | 248 | All encoding/decoding/encryption/decryption/digests are done using `openssl`, which is (still) a part of the standard library. If at some point `openssl` is removed and not specifically distributed, you'll have to install it yourself. Hopefully this will never happen. 249 | 250 | It also uses the `openssl` ASN.1 API to encode/decode BERs, which is known to be strict, and [may not be able to decode PDUs if not compliant with the supported RFC](https://github.com/swisscom/ruby-netsnmp/issues/47). 251 | 252 | ## Debugging 253 | 254 | You can either set the `NETSNMP_DEBUG` to the desided debug level (currently, 1 and 2). The logs will be written to stderr. 255 | 256 | You can also set it for a specific client: 257 | 258 | ```ruby 259 | manager2 = NETSNMP::Client.new(debug: $stderr, debug_level: 2, ....) 260 | ``` 261 | 262 | 263 | ## Tests 264 | 265 | This library uses RSpec. The client specs are "integration" tests, in that we communicate with an [snmpsim-built snmp agent simulator](https://github.com/etingof/snmpsim). 266 | 267 | 268 | ### RSpec 269 | 270 | You can run all tests by typing: 271 | 272 | ``` 273 | > bundle exec rake spec 274 | # or 275 | > bundle exec rspec 276 | ... 277 | ``` 278 | 279 | 280 | ### Docker 281 | 282 | The most straightforward way of running the tests is by using the `docker-compose` setup (which is also what's used in the CI). Run it against the ruby version you're targeting: 283 | 284 | ``` 285 | > docker-compose -f docker-compose.yml -f docker-compose-ruby-${RUBY_MAJOR_VERSION}.${RUBY_MAJOR_VERSION}.yml run netsnmp 286 | ``` 287 | 288 | The CI runs the tests against all supported ruby versions. If changes break a specific version of ruby, make sure you commit appropriate changes addressing the edge case, or let me know in the issues board, so I can help. 289 | 290 | ### SNMP Simulator 291 | 292 | The SNMP simulator runs in its own container in the `docker` setup. 293 | 294 | You can install the package yourself (ex: `pip install snmpsim`) and run the server locally, and then set the `SNMP_PORT` environment variable, where the snmp simulator is running. 295 | 296 | #### CI 297 | 298 | The job of the CI is: 299 | 300 | * Run all the tests; 301 | * Make sure the tests cover an appropriate surface of the code; 302 | * Lint the code; 303 | * (for ruby 3.0) type check the code; 304 | 305 | 306 | ## Contributing 307 | 308 | * Fork this repository 309 | * Make your changes and send me a pull request 310 | * If I like them I'll merge them 311 | * If I've accepted a patch, feel free to ask for a commit bit! 312 | 313 | ## TODO 314 | 315 | There are some features which this gem doesn't support. It was built to provide a client (or manager, in SNMP language) implementation only, and the requirements were fulfilled. However, these notable misses will stand-out: 316 | 317 | * No server (Agent, in SNMP-ish) implementation. 318 | * No getbulk support. 319 | 320 | So if you like the gem, but would rather have these features implemented, please help by sending us a PR and we'll gladly review it. 321 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "bundler/gem_tasks" 4 | 5 | begin 6 | require "rspec/core/rake_task" 7 | 8 | desc "runs the tests" 9 | RSpec::Core::RakeTask.new 10 | rescue LoadError 11 | end 12 | 13 | begin 14 | require "rubocop/rake_task" 15 | 16 | desc "Run rubocop" 17 | task :rubocop do 18 | RuboCop::RakeTask.new 19 | end 20 | rescue LoadError 21 | end 22 | 23 | namespace :coverage do 24 | desc "Aggregates coverage reports" 25 | task :report do 26 | return unless ENV.key?("CI") 27 | 28 | require "simplecov" 29 | SimpleCov.collate Dir["coverage/**/.resultset.json"] 30 | end 31 | end 32 | 33 | task default: [:spec] 34 | 35 | namespace :spec do 36 | desc "runs tests, check coverage, pushes to coverage server" 37 | if RUBY_VERSION >= "3.0.0" 38 | task ci: %w[spec rubocop] 39 | else 40 | task ci: %w[spec] 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /docker-compose-github.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | services: 3 | netsnmp: 4 | environment: 5 | - BUNDLE_PATH=/usr/local/bundle 6 | - BUNDLE_WITHOUT=development 7 | volumes: 8 | - "./vendor/bundle:/usr/local/bundle" 9 | -------------------------------------------------------------------------------- /docker-compose-jruby.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | services: 3 | netsnmp: 4 | image: jruby:9.2 5 | environment: 6 | - JRUBY_OPTS=--debug 7 | entrypoint: 8 | - bash 9 | - /home/spec/support/specs.sh -------------------------------------------------------------------------------- /docker-compose-ruby-2.1.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | services: 3 | netsnmp: 4 | image: ruby:2.1-alpine 5 | -------------------------------------------------------------------------------- /docker-compose-ruby-2.2.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | services: 3 | netsnmp: 4 | image: ruby:2.2-alpine 5 | -------------------------------------------------------------------------------- /docker-compose-ruby-2.3.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | services: 3 | netsnmp: 4 | image: ruby:2.3-alpine 5 | -------------------------------------------------------------------------------- /docker-compose-ruby-2.4.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | services: 3 | netsnmp: 4 | image: ruby:2.4-alpine 5 | -------------------------------------------------------------------------------- /docker-compose-ruby-2.5.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | services: 3 | netsnmp: 4 | image: ruby:2.5-alpine 5 | -------------------------------------------------------------------------------- /docker-compose-ruby-2.6.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | services: 3 | netsnmp: 4 | image: ruby:2.6-alpine 5 | -------------------------------------------------------------------------------- /docker-compose-ruby-2.7.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | services: 3 | netsnmp: 4 | image: ruby:2.7-alpine 5 | -------------------------------------------------------------------------------- /docker-compose-ruby-3.0.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | services: 3 | netsnmp: 4 | image: ruby:3.0-alpine 5 | -------------------------------------------------------------------------------- /docker-compose-ruby-3.1.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | services: 3 | netsnmp: 4 | image: ruby:3.1-alpine 5 | -------------------------------------------------------------------------------- /docker-compose-ruby-3.2.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | services: 3 | netsnmp: 4 | image: ruby:3.2-alpine 5 | -------------------------------------------------------------------------------- /docker-compose-truffleruby.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | services: 3 | netsnmp: 4 | image: ghcr.io/graalvm/truffleruby:latest 5 | entrypoint: 6 | - bash 7 | - /home/spec/support/specs.sh -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | services: 3 | netsnmp: 4 | environment: 5 | - SNMP_HOST=snmp-server-emulator 6 | - SNMP_PORT=1161 7 | - CI=1 8 | - BUNDLE_SILENCE_ROOT_WARNING=1 9 | - BUNDLE_JOBS=10 10 | image: ruby:alpine 11 | depends_on: 12 | - snmp-server-emulator 13 | volumes: 14 | - ./:/home 15 | entrypoint: 16 | /home/spec/support/specs.sh 17 | 18 | snmp-server-emulator: 19 | image: tandrup/snmpsim:latest 20 | ports: 21 | - 1161:1161/udp 22 | volumes: 23 | - ./spec/support/snmpsim/:/home/snmp_server/.snmpsim 24 | command: 25 | - /usr/local/bin/snmpsimd.py 26 | - --process-user=snmpsim 27 | - --process-group=nogroup 28 | - --v3-engine-id=000000000000000000000002 29 | - --agent-udpv4-endpoint=0.0.0.0:1161 30 | - --agent-udpv6-endpoint=[::0]:1161 31 | - --v3-user=simulator 32 | - --v3-auth-key=auctoritas 33 | - --v3-priv-key=privatus 34 | - --v3-user=authmd5 35 | - --v3-auth-key=maplesyrup 36 | - --v3-auth-proto=MD5 37 | - --v3-user=authsha 38 | - --v3-auth-key=maplesyrup 39 | - --v3-auth-proto=SHA 40 | - --v3-user=authsha256 41 | - --v3-auth-key=maplesyrup 42 | - --v3-auth-proto=SHA256 43 | - --v3-user=authprivshaaes 44 | - --v3-auth-key=maplesyrup 45 | - --v3-auth-proto=SHA 46 | - --v3-priv-key=maplesyrup 47 | - --v3-priv-proto=AES 48 | - --v3-user=authprivmd5aes 49 | - --v3-auth-key=maplesyrup 50 | - --v3-auth-proto=MD5 51 | - --v3-priv-key=maplesyrup 52 | - --v3-priv-proto=AES 53 | - --v3-user=authprivshades 54 | - --v3-auth-key=maplesyrup 55 | - --v3-auth-proto=SHA 56 | - --v3-priv-key=maplesyrup 57 | - --v3-priv-proto=DES 58 | - --v3-user=authprivmd5des 59 | - --v3-auth-key=maplesyrup 60 | - --v3-auth-proto=MD5 61 | - --v3-priv-key=maplesyrup 62 | - --v3-priv-proto=DES 63 | - --v3-user=unsafe 64 | -------------------------------------------------------------------------------- /lib/netsnmp.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "netsnmp/version" 4 | require "openssl" 5 | require "io/wait" 6 | require "securerandom" 7 | require "ipaddr" 8 | 9 | # core structures 10 | 11 | module NETSNMP 12 | begin 13 | require "xorcist" 14 | require "xorcist/refinements" 15 | StringExtensions = Xorcist::Refinements 16 | rescue LoadError 17 | # "no xorcist" 18 | module StringExtensions 19 | refine String do 20 | # Bitwise XOR operator for the String class 21 | def xor(other) 22 | b1 = unpack("C*") 23 | return b1 unless other 24 | 25 | b2 = other.unpack("C*") 26 | longest = [b1.length, b2.length].max 27 | b1 = ([0] * (longest - b1.length)) + b1 28 | b2 = ([0] * (longest - b2.length)) + b2 29 | b1.zip(b2).map { |a, b| a ^ b }.pack("C*") 30 | end 31 | end 32 | end 33 | end 34 | end 35 | 36 | require "netsnmp/errors" 37 | require "netsnmp/extensions" 38 | require "netsnmp/loggable" 39 | 40 | require "netsnmp/timeticks" 41 | 42 | require "netsnmp/oid" 43 | require "netsnmp/varbind" 44 | require "netsnmp/pdu" 45 | require "netsnmp/mib" 46 | require "netsnmp/session" 47 | 48 | require "netsnmp/scoped_pdu" 49 | require "netsnmp/v3_session" 50 | require "netsnmp/security_parameters" 51 | require "netsnmp/message" 52 | require "netsnmp/encryption/des" 53 | require "netsnmp/encryption/aes" 54 | 55 | require "netsnmp/client" 56 | 57 | unless Numeric.method_defined?(:positive?) 58 | # Ruby 2.3 Backport (Numeric#positive?) 59 | # 60 | module PosMethods 61 | def positive? 62 | self > 0 63 | end 64 | end 65 | Numeric.__send__(:include, PosMethods) 66 | end 67 | 68 | unless String.method_defined?(:+@) 69 | # Backport for +"", to initialize unfrozen strings from the string literal. 70 | # 71 | module LiteralStringExtensions 72 | def +@ 73 | frozen? ? dup : self 74 | end 75 | end 76 | String.__send__(:include, LiteralStringExtensions) 77 | end 78 | -------------------------------------------------------------------------------- /lib/netsnmp/client.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "timeout" 4 | 5 | module NETSNMP 6 | # Main Entity, provides the user-facing API to communicate with SNMP Agents 7 | # 8 | # Under the hood it creates a "session" (analogous to the net-snmp C session), which will be used 9 | # to proxy all the communication to the agent. the Client ensures that you only write pure ruby and 10 | # read pure ruby, not concerning with snmp-speak like PDUs, varbinds and the like. 11 | # 12 | # 13 | class Client 14 | RETRIES = 5 15 | 16 | # @param [Hash] options the options to needed to enable the SNMP client. 17 | # @option options [String, Integer, nil] :version the version of the protocol (defaults to 3). 18 | # also accepts common known declarations like :v3, "v2c", etc 19 | # @option options [Integer] :retries number of retries for each failed PDU (after which it raise timeout error. Defaults to {RETRIES} retries) 20 | # @yield [client] the instantiated client, after which it closes it for use. 21 | # @example Yielding a clinet 22 | # NETSNMP::Client.new(host: "241.232.22.12") do |client| 23 | # puts client.get(oid: "1.3.6.1.2.1.1.5.0") 24 | # end 25 | # 26 | def initialize(version: nil, **options) 27 | version = case version 28 | when Integer then version # assume the use know what he's doing 29 | when /v?1/ then 0 30 | when /v?2c?/ then 1 31 | when /v?3/ then 3 32 | else 3 # rubocop:disable Lint/DuplicateBranch 33 | end 34 | 35 | @retries = options.fetch(:retries, RETRIES).to_i 36 | @session ||= version == 3 ? V3Session.new(**options) : Session.new(version: version, **options) 37 | return unless block_given? 38 | 39 | begin 40 | yield self 41 | ensure 42 | close 43 | end 44 | end 45 | 46 | # Closes the inner section 47 | def close 48 | @session.close 49 | end 50 | 51 | # Performs an SNMP GET Request 52 | # 53 | # @see {NETSNMP::Varbind#new} 54 | # 55 | def get(*oid_opts) 56 | request = @session.build_pdu(:get, *oid_opts) 57 | response = handle_retries { @session.send(request) } 58 | yield response if block_given? 59 | values = response.varbinds.map(&:value) 60 | values.size > 1 ? values : values.first 61 | end 62 | 63 | # Performs an SNMP GETNEXT Request 64 | # 65 | # @see {NETSNMP::Varbind#new} 66 | # 67 | def get_next(*oid_opts) 68 | request = @session.build_pdu(:getnext, *oid_opts) 69 | response = handle_retries { @session.send(request) } 70 | yield response if block_given? 71 | values = response.varbinds.map { |v| [v.oid, v.value] } 72 | values.size > 1 ? values : values.first 73 | end 74 | 75 | # Perform a SNMP Walk (issues multiple subsequent GENEXT requests within the subtree rooted on an OID) 76 | # 77 | # @param [String] oid the root oid from the subtree 78 | # 79 | # @return [Enumerator] the enumerator-collection of the oid-value pairs 80 | # 81 | def walk(oid:) 82 | walkoid = OID.build(oid) 83 | Enumerator.new do |y| 84 | code = walkoid 85 | first_response_code = nil 86 | catch(:walk) do 87 | loop do 88 | get_next(oid: code) do |response| 89 | response.varbinds.each do |varbind| 90 | code = varbind.oid 91 | if !OID.parent?(walkoid, code) || 92 | varbind.value.eql?(:endofmibview) || 93 | (code == first_response_code) 94 | throw(:walk) 95 | else 96 | y << [code, varbind.value] 97 | end 98 | first_response_code ||= code 99 | end 100 | end 101 | end 102 | end 103 | end 104 | end 105 | 106 | # Perform a SNMP GETBULK Request (performs multiple GETNEXT) 107 | # 108 | # @param [String] oid the first oid 109 | # @param [Hash] options the varbind options 110 | # @option options [Integer] :errstat sets the number of objects expected for the getnext instance 111 | # @option options [Integer] :errindex number of objects repeating for all the repeating IODs. 112 | # 113 | # @return [Enumerator] the enumerator-collection of the oid-value pairs 114 | # 115 | # def get_bulk(oid) 116 | # request = @session.build_pdu(:getbulk, *oids) 117 | # request[:error_status] = options.delete(:non_repeaters) || 0 118 | # request[:error_index] = options.delete(:max_repetitions) || 10 119 | # response = @session.send(request) 120 | # Enumerator.new do |y| 121 | # response.varbinds.each do |varbind| 122 | # y << [ varbind.oid, varbind.value ] 123 | # end 124 | # end 125 | # end 126 | 127 | # Perform a SNMP SET Request 128 | # 129 | # @see {NETSNMP::Varbind#new} 130 | # 131 | def set(*oid_opts) 132 | request = @session.build_pdu(:set, *oid_opts) 133 | response = handle_retries { @session.send(request) } 134 | yield response if block_given? 135 | values = response.varbinds.map(&:value) 136 | values.size > 1 ? values : values.first 137 | end 138 | 139 | # Perform a SNMP INFORM Request 140 | # 141 | # @see {NETSNMP::Varbind#new} 142 | # 143 | def inform(*oid_opts) 144 | request = @session.build_pdu(:inform, *oid_opts) 145 | response = handle_retries { @session.send(request) } 146 | yield response if block_given? 147 | values = response.varbinds.map(&:value) 148 | values.size > 1 ? values : values.first 149 | end 150 | 151 | private 152 | 153 | # Handles timeout errors by reissuing the same pdu until it runs out or retries. 154 | def handle_retries 155 | retries = @retries 156 | begin 157 | yield 158 | rescue Timeout::Error, IdNotInTimeWindowError => e 159 | raise e if retries.zero? 160 | 161 | retries -= 1 162 | retry 163 | end 164 | end 165 | end 166 | end 167 | -------------------------------------------------------------------------------- /lib/netsnmp/encryption/aes.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module NETSNMP 4 | module Encryption 5 | class AES 6 | def initialize(priv_key, local: 0) 7 | @priv_key = priv_key 8 | @local = local 9 | end 10 | 11 | def encrypt(decrypted_data, engine_boots:, engine_time:) 12 | cipher = OpenSSL::Cipher.new("aes-128-cfb") 13 | 14 | iv, salt = generate_encryption_key(engine_boots, engine_time) 15 | 16 | cipher.encrypt 17 | cipher.iv = iv 18 | cipher.key = aes_key 19 | 20 | if (diff = decrypted_data.length % 8) != 0 21 | decrypted_data << ("\x00" * (8 - diff)) 22 | end 23 | 24 | encrypted_data = cipher.update(decrypted_data) + cipher.final 25 | 26 | [encrypted_data, salt] 27 | end 28 | 29 | def decrypt(encrypted_data, salt:, engine_boots:, engine_time:) 30 | raise Error, "invalid priv salt received" unless !salt.empty? && (salt.length % 8).zero? 31 | 32 | cipher = OpenSSL::Cipher.new("aes-128-cfb") 33 | cipher.padding = 0 34 | 35 | iv = generate_decryption_key(engine_boots, engine_time, salt) 36 | 37 | cipher.decrypt 38 | cipher.key = aes_key 39 | cipher.iv = iv 40 | decrypted_data = cipher.update(encrypted_data) + cipher.final 41 | 42 | hlen, bodylen = OpenSSL::ASN1.traverse(decrypted_data) { |_, _, x, y, *| break x, y } 43 | decrypted_data.byteslice(0, hlen + bodylen) || "".b 44 | end 45 | 46 | private 47 | 48 | # 8.1.1.1 49 | def generate_encryption_key(boots, time) 50 | salt = [0xff & (@local >> 56), 51 | 0xff & (@local >> 48), 52 | 0xff & (@local >> 40), 53 | 0xff & (@local >> 32), 54 | 0xff & (@local >> 24), 55 | 0xff & (@local >> 16), 56 | 0xff & (@local >> 8), 57 | 0xff & @local].pack("c*") 58 | @local = @local == 0xffffffffffffffff ? 0 : @local + 1 59 | 60 | iv = generate_decryption_key(boots, time, salt) 61 | 62 | [iv, salt] 63 | end 64 | 65 | def generate_decryption_key(boots, time, salt) 66 | [0xff & (boots >> 24), 67 | 0xff & (boots >> 16), 68 | 0xff & (boots >> 8), 69 | 0xff & boots, 70 | 0xff & (time >> 24), 71 | 0xff & (time >> 16), 72 | 0xff & (time >> 8), 73 | 0xff & time].pack("c*") + salt 74 | end 75 | 76 | def aes_key 77 | @priv_key[0, 16] 78 | end 79 | end 80 | end 81 | end 82 | -------------------------------------------------------------------------------- /lib/netsnmp/encryption/des.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module NETSNMP 4 | module Encryption 5 | using StringExtensions 6 | 7 | class DES 8 | def initialize(priv_key, local: 0) 9 | @priv_key = priv_key 10 | @local = local 11 | end 12 | 13 | def encrypt(decrypted_data, engine_boots:, **) 14 | cipher = OpenSSL::Cipher.new("des-cbc") 15 | 16 | iv, salt = generate_encryption_key(engine_boots) 17 | 18 | cipher.encrypt 19 | cipher.iv = iv 20 | cipher.key = des_key 21 | 22 | if (diff = decrypted_data.length % 8) != 0 23 | decrypted_data << ("\x00" * (8 - diff)) 24 | end 25 | 26 | encrypted_data = cipher.update(decrypted_data) + cipher.final 27 | [encrypted_data, salt] 28 | end 29 | 30 | def decrypt(encrypted_data, salt:, **) 31 | raise Error, "invalid priv salt received" unless (salt.length % 8).zero? 32 | raise Error, "invalid encrypted PDU received" unless (encrypted_data.length % 8).zero? 33 | 34 | cipher = OpenSSL::Cipher.new("des-cbc") 35 | cipher.padding = 0 36 | 37 | iv = generate_decryption_key(salt) 38 | 39 | cipher.decrypt 40 | cipher.key = des_key 41 | cipher.iv = iv 42 | decrypted_data = cipher.update(encrypted_data) + cipher.final 43 | 44 | hlen, bodylen = OpenSSL::ASN1.traverse(decrypted_data) { |_, _, x, y, *| break x, y } 45 | decrypted_data.byteslice(0, hlen + bodylen) || "".b 46 | end 47 | 48 | private 49 | 50 | # 8.1.1.1 51 | def generate_encryption_key(boots) 52 | pre_iv = @priv_key[8, 8] 53 | salt = [0xff & (boots >> 24), 54 | 0xff & (boots >> 16), 55 | 0xff & (boots >> 8), 56 | 0xff & boots, 57 | 0xff & (@local >> 24), 58 | 0xff & (@local >> 16), 59 | 0xff & (@local >> 8), 60 | 0xff & @local].pack("c*") 61 | @local = @local == 0xffffffff ? 0 : @local + 1 62 | 63 | iv = pre_iv.xor(salt) 64 | [iv, salt] 65 | end 66 | 67 | def generate_decryption_key(salt) 68 | pre_iv = @priv_key[8, 8] 69 | pre_iv.xor(salt) 70 | end 71 | 72 | def des_key 73 | @priv_key[0, 8] || "".b 74 | end 75 | end 76 | end 77 | end 78 | -------------------------------------------------------------------------------- /lib/netsnmp/encryption/none.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module NETSNMP 4 | module Encryption 5 | class None 6 | def initialize(*); end 7 | 8 | def encrypt(pdu) 9 | pdu.send(:to_asn) 10 | end 11 | 12 | def decrypt(stream, *) 13 | stream 14 | end 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /lib/netsnmp/errors.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module NETSNMP 4 | class Error < StandardError; end 5 | 6 | class ConnectionFailed < Error; end 7 | 8 | class AuthenticationFailed < Error; end 9 | 10 | class IdNotInTimeWindowError < Error; end 11 | 12 | class OidNotFound < StandardError; end 13 | end 14 | -------------------------------------------------------------------------------- /lib/netsnmp/extensions.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module NETSNMP 4 | module IsNumericExtensions 5 | refine String do 6 | def integer? 7 | each_byte do |byte| 8 | return false unless byte >= 48 && byte <= 57 9 | end 10 | true 11 | end 12 | end 13 | end 14 | 15 | module StringExtensions 16 | refine(String) do 17 | unless String.method_defined?(:delete_prefix) 18 | def delete_prefix(prefix) 19 | prefix = String(prefix) 20 | if rindex(prefix, 0) 21 | self[prefix.length..-1] 22 | else 23 | dup 24 | end 25 | end 26 | end 27 | 28 | unless String.method_defined?(:match?) 29 | def match?(*args) 30 | !match(*args).nil? 31 | end 32 | end 33 | 34 | unless String.method_defined?(:unpack1) 35 | def unpack1(format) 36 | unpack(format).first 37 | end 38 | end 39 | end 40 | end 41 | 42 | module ASNExtensions 43 | ASN_COLORS = { 44 | OpenSSL::ASN1::Sequence => 34, # blue 45 | OpenSSL::ASN1::OctetString => 32, # green 46 | OpenSSL::ASN1::Integer => 33, # yellow 47 | OpenSSL::ASN1::ObjectId => 35, # magenta 48 | OpenSSL::ASN1::ASN1Data => 36 # cyan 49 | }.freeze 50 | 51 | # basic types 52 | ASN_COLORS.each_key do |klass| 53 | refine(klass) do 54 | def to_hex 55 | "#{colorize_hex} (#{value.to_s.inspect})" 56 | end 57 | end 58 | end 59 | 60 | # composite types 61 | refine(OpenSSL::ASN1::Sequence) do 62 | def to_hex 63 | values = value.map(&:to_der).join 64 | hex_values = value.map(&:to_hex).map { |s| s.gsub(/(\t+)/) { "\t#{Regexp.last_match(1)}" } }.map { |s| "\n\t#{s}" }.join 65 | der = to_der 66 | der = der.sub(values, "") 67 | 68 | "#{colorize_hex(der)}#{hex_values}" 69 | end 70 | end 71 | 72 | refine(OpenSSL::ASN1::ASN1Data) do 73 | attr_reader :label 74 | 75 | def with_label(label) 76 | @label = label 77 | self 78 | end 79 | 80 | def to_hex 81 | case value 82 | when Array 83 | values = value.map(&:to_der).join 84 | hex_values = value.map(&:to_hex) 85 | .map { |s| s.gsub(/(\t+)/) { "\t#{Regexp.last_match(1)}" } } 86 | .map { |s| "\n\t#{s}" }.join 87 | der = to_der 88 | der = der.sub(values, "") 89 | else 90 | der = to_der 91 | hex_values = nil 92 | end 93 | 94 | "#{colorize_hex(der)}#{hex_values}" 95 | end 96 | 97 | private 98 | 99 | def colorize_hex(der = to_der) 100 | hex = Hexdump.dump(der, separator: " ") 101 | lbl = @label || self.class.name.split("::").last 102 | "#{lbl}: \e[#{ASN_COLORS[self.class]}m#{hex}\e[0m" 103 | end 104 | end 105 | end 106 | 107 | module Hexdump 108 | using StringExtensions 109 | 110 | def self.dump(data, width: 8, in_groups_of: 4, separator: "\n") 111 | pairs = data.unpack1("H*").scan(/.{#{in_groups_of}}/) 112 | pairs.each_slice(width).map do |row| 113 | row.join(" ") 114 | end.join(separator) 115 | end 116 | end 117 | 118 | # Like a string, but it prints an hex-string version of itself 119 | class HexString < String 120 | def inspect 121 | Hexdump.dump(to_s, in_groups_of: 2, separator: " ") 122 | end 123 | end 124 | end 125 | -------------------------------------------------------------------------------- /lib/netsnmp/loggable.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module NETSNMP 4 | module Loggable 5 | DEBUG = ENV.key?("NETSNMP_DEBUG") ? $stderr : nil 6 | DEBUG_LEVEL = ENV.fetch("NETSNMP_DEBUG", 1).to_i 7 | 8 | def initialize_logger(debug: DEBUG, debug_level: DEBUG_LEVEL, **) 9 | @debug = debug 10 | @debug_level = debug_level 11 | end 12 | 13 | private 14 | 15 | COLORS = { 16 | black: 30, 17 | red: 31, 18 | green: 32, 19 | yellow: 33, 20 | blue: 34, 21 | magenta: 35, 22 | cyan: 36, 23 | white: 37 24 | }.freeze 25 | 26 | def log(level: @debug_level) 27 | return unless @debug 28 | return unless @debug_level >= level 29 | 30 | debug_stream = @debug 31 | 32 | debug_stream << (+"\n" << yield << "\n") 33 | end 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /lib/netsnmp/message.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module NETSNMP 4 | # Factory for the SNMP v3 Message format 5 | class Message 6 | using ASNExtensions 7 | 8 | include Loggable 9 | 10 | PRIVNONE = OpenSSL::ASN1::OctetString.new("") 11 | MSG_MAX_SIZE = OpenSSL::ASN1::Integer.new(65507).with_label(:max_message_size) 12 | MSG_SECURITY_MODEL = OpenSSL::ASN1::Integer.new(3).with_label(:security_model) # usmSecurityModel 13 | MSG_VERSION = OpenSSL::ASN1::Integer.new(3).with_label(:message_version) 14 | MSG_REPORTABLE = 4 15 | 16 | def initialize(**args) 17 | initialize_logger(**args) 18 | end 19 | 20 | def verify(stream, auth_param, security_level, security_parameters:) 21 | security_parameters.verify(stream.sub(auth_param, authnone(security_parameters.auth_protocol).value), auth_param, security_level: security_level) 22 | end 23 | 24 | # @param [String] payload of an snmp v3 message which can be decoded 25 | # @param [NETSMP::SecurityParameters, #decode] security_parameters knowns how to decode the stream 26 | # 27 | # @return [NETSNMP::ScopedPDU] the decoded PDU 28 | # 29 | def decode(stream, security_parameters:) 30 | log { "received encoded V3 message" } 31 | log { Hexdump.dump(stream) } 32 | asn_tree = OpenSSL::ASN1.decode(stream).with_label(:v3_message) 33 | 34 | version, headers, sec_params, pdu_payload = asn_tree.value 35 | version.with_label(:message_version) 36 | headers.with_label(:headers) 37 | sec_params.with_label(:security_params) 38 | pdu_payload.with_label(:pdu) 39 | 40 | _, _, message_flags, = headers.value 41 | 42 | # get last byte 43 | # discard the left-outermost bits and keep the remaining two 44 | security_level = message_flags.with_label(:message_flags).value.unpack("C*").last & 3 45 | 46 | sec_params_asn = OpenSSL::ASN1.decode(sec_params.value).with_label(:security_params) 47 | 48 | engine_id, engine_boots, engine_time, username, auth_param, priv_param = sec_params_asn.value 49 | engine_id.with_label(:engine_id) 50 | engine_boots.with_label(:engine_boots) 51 | engine_time.with_label(:engine_time) 52 | username.with_label(:username) 53 | auth_param.with_label(:auth_param) 54 | priv_param.with_label(:priv_param) 55 | 56 | log(level: 2) { asn_tree.to_hex } 57 | log(level: 2) { sec_params_asn.to_hex } 58 | 59 | auth_param = auth_param.value 60 | 61 | engine_boots = engine_boots.value.to_i 62 | engine_time = engine_time.value.to_i 63 | 64 | encoded_pdu = security_parameters.decode(pdu_payload, salt: priv_param.value, 65 | engine_boots: engine_boots, 66 | engine_time: engine_time, 67 | security_level: security_level) 68 | 69 | log { "received response PDU" } 70 | pdu = ScopedPDU.decode(encoded_pdu, auth_param: auth_param, security_level: security_level) 71 | 72 | log(level: 2) { pdu.to_hex } 73 | [pdu, engine_id.value.to_s, engine_boots, engine_time] 74 | end 75 | 76 | # @param [NETSNMP::ScopedPDU] the PDU to encode in the message 77 | # @param [NETSMP::SecurityParameters, #decode] security_parameters knowns how to decode the stream 78 | # 79 | # @return [String] the byte representation of an SNMP v3 Message 80 | # 81 | def encode(pdu, security_parameters:, engine_boots: 0, engine_time: 0) 82 | log(level: 2) { pdu.to_hex } 83 | log { "encoding PDU in V3 message..." } 84 | scoped_pdu, salt_param = security_parameters.encode(pdu, salt: PRIVNONE, 85 | engine_boots: engine_boots, 86 | engine_time: engine_time) 87 | 88 | sec_params = OpenSSL::ASN1::Sequence.new([ 89 | OpenSSL::ASN1::OctetString.new(security_parameters.engine_id).with_label(:engine_id), 90 | OpenSSL::ASN1::Integer.new(engine_boots).with_label(:engine_boots), 91 | OpenSSL::ASN1::Integer.new(engine_time).with_label(:engine_time), 92 | OpenSSL::ASN1::OctetString.new(security_parameters.username).with_label(:username), 93 | authnone(security_parameters.auth_protocol), 94 | salt_param 95 | ]).with_label(:security_params) 96 | log(level: 2) { sec_params.to_hex } 97 | 98 | message_flags = MSG_REPORTABLE | security_parameters.security_level 99 | message_id = OpenSSL::ASN1::Integer.new(SecureRandom.random_number(2147483647)).with_label(:message_id) 100 | headers = OpenSSL::ASN1::Sequence.new([ 101 | message_id, 102 | MSG_MAX_SIZE, 103 | OpenSSL::ASN1::OctetString.new([String(message_flags)].pack("h*")).with_label(:message_flags), 104 | MSG_SECURITY_MODEL 105 | ]).with_label(:headers) 106 | 107 | encoded = OpenSSL::ASN1::Sequence([ 108 | MSG_VERSION, 109 | headers, 110 | OpenSSL::ASN1::OctetString.new(sec_params.to_der).with_label(:security_params), 111 | scoped_pdu 112 | ]).with_label(:v3_message) 113 | log(level: 2) { encoded.to_hex } 114 | 115 | encoded = encoded.to_der 116 | log { Hexdump.dump(encoded) } 117 | signature = security_parameters.sign(encoded) 118 | if signature 119 | log { "signing V3 message..." } 120 | auth_salt = OpenSSL::ASN1::OctetString.new(signature).with_label(:auth) 121 | log(level: 2) { auth_salt.to_hex } 122 | none_der = authnone(security_parameters.auth_protocol).to_der 123 | encoded[encoded.index(none_der), none_der.size] = auth_salt.to_der 124 | log { Hexdump.dump(encoded) } 125 | end 126 | encoded 127 | end 128 | 129 | private 130 | 131 | # https://datatracker.ietf.org/doc/html/rfc7860#section-4.2.2 part 3 132 | # https://datatracker.ietf.org/doc/html/rfc3414#section-6.3.2 part 3 133 | def authnone(auth_protocol) 134 | # https://datatracker.ietf.org/doc/html/rfc3414#section-3.1 part 8b 135 | return OpenSSL::ASN1::OctetString.new("").with_label(:auth_mask) unless auth_protocol 136 | 137 | # The digest in the msgAuthenticationParameters field is replaced by the 12 zero octets. 138 | # 24 octets for sha256 139 | number_of_octets = auth_protocol == :sha256 ? 24 : 12 140 | 141 | OpenSSL::ASN1::OctetString.new("\x00" * number_of_octets).with_label(:auth_mask) 142 | end 143 | end 144 | end 145 | -------------------------------------------------------------------------------- /lib/netsnmp/mib.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "mib/parser" 4 | 5 | module NETSNMP 6 | module MIB 7 | using IsNumericExtensions 8 | 9 | OIDREGEX = /^[\d.]*$/.freeze 10 | 11 | module_function 12 | 13 | MIBDIRS = ENV.fetch("MIBDIRS", File.join("/usr", "share", "snmp", "mibs")) 14 | .split(":") 15 | .flat_map { |dir| [dir, *Dir.glob(File.join(dir, "**", "*")).select(&File.method(:directory?))] }.uniq 16 | PARSER = Parser.new 17 | @parser_mutex = Mutex.new 18 | 19 | @modules_loaded = [] 20 | @object_identifiers = {} 21 | # Translates na identifier, such as "sysDescr", into an OID 22 | def oid(identifier) 23 | prefix, *suffix = case identifier 24 | when Array 25 | identifier.map(&:to_s) 26 | else 27 | identifier.split(".", 2).map(&:to_s) 28 | end 29 | 30 | return unless prefix 31 | 32 | # early exit if it's an OID already 33 | unless prefix.integer? 34 | load_defaults 35 | # load module if need be 36 | idx = prefix.index("::") 37 | if idx 38 | mod = prefix[0..(idx - 1)] 39 | type = prefix[(idx + 2)..-1] 40 | return if mod && !module_loaded?(mod) && !load(mod) 41 | else 42 | type = prefix 43 | end 44 | 45 | return if type.nil? || type.empty? 46 | 47 | prefix = @object_identifiers[type] || 48 | raise(Error, "can't convert #{type} to OID") 49 | 50 | end 51 | 52 | [prefix, *suffix].join(".") 53 | end 54 | 55 | def identifier(oid) 56 | @object_identifiers.select do |_, ids_oid| 57 | oid.start_with?(ids_oid) 58 | end.min_by(&:size) 59 | end 60 | 61 | # 62 | # Loads a MIB. Can be called multiple times, as it'll load it once. 63 | # 64 | # Accepts the MIB name in several ways: 65 | # 66 | # MIB.load("SNMPv2-MIB") 67 | # MIB.load("SNMPv2-MIB.txt") 68 | # MIB.load("/path/to/SNMPv2-MIB.txt") 69 | # 70 | def load(mod) 71 | unless File.file?(mod) 72 | moddir = nil 73 | MIBDIRS.each do |mibdir| 74 | if File.exist?(File.join(mibdir, mod)) 75 | moddir = File.join(mibdir, mod) 76 | break 77 | elsif File.extname(mod).empty? && File.exist?(File.join(mibdir, "#{mod}.txt")) 78 | moddir = File.join(mibdir, "#{mod}.txt") 79 | break 80 | end 81 | end 82 | return false unless moddir 83 | 84 | mod = moddir 85 | end 86 | return true if @modules_loaded.include?(mod) 87 | 88 | do_load(mod) 89 | @modules_loaded << mod 90 | true 91 | end 92 | 93 | def module_loaded?(mod) 94 | if File.file?(mod) 95 | @modules_loaded.include?(mod) 96 | else 97 | @modules_loaded.map { |path| File.basename(path, ".*") }.include?(mod) 98 | end 99 | end 100 | 101 | TYPES = ["OBJECT IDENTIFIER", "OBJECT-TYPE", "MODULE-IDENTITY", "OBJECT-IDENTITY"].freeze 102 | 103 | STATIC_MIB_TO_OID = { 104 | "iso" => "1" 105 | }.freeze 106 | 107 | # 108 | # Loads the MIB all the time, where +mod+ is the absolute path to the MIB. 109 | # 110 | def do_load(mod) 111 | data = @parser_mutex.synchronize { PARSER.parse(File.read(mod)) } 112 | 113 | imports = load_imports(data[:imports]) 114 | 115 | declarations = Hash[ 116 | data[:declarations].reject { |dec| !dec.key?(:name) || !TYPES.include?(dec[:type]) } 117 | .map { |dec| [String(dec[:name]), String(dec[:value]).split(/ +/)] } 118 | ] 119 | 120 | declarations.each do |nme, value| 121 | store_oid_in_identifiers(nme, value, imports: imports, declarations: declarations) 122 | end 123 | end 124 | 125 | def store_oid_in_identifiers(nme, value, imports:, declarations:) 126 | oid = value.flat_map do |cp| 127 | if cp.integer? 128 | cp 129 | elsif @object_identifiers.key?(cp) 130 | @object_identifiers[cp] 131 | elsif declarations.key?(cp) 132 | store_oid_in_identifiers(cp, declarations[cp], imports: imports, declarations: declarations) 133 | @object_identifiers[cp] 134 | else 135 | STATIC_MIB_TO_OID[cp] || begin 136 | imported_mod, = if imports 137 | imports.find do |_, identifiers| 138 | identifiers.include?(cp) 139 | end 140 | end 141 | 142 | raise Error, "didn't find a module to import \"#{cp}\" from" unless imported_mod 143 | 144 | load(imported_mod) 145 | 146 | @object_identifiers[cp] 147 | end 148 | end 149 | end.join(".") 150 | 151 | @object_identifiers[nme] = oid 152 | end 153 | 154 | # 155 | # Reformats the import lists into an hash indexed by module name, to a list of 156 | # imported names 157 | # 158 | def load_imports(imports) 159 | return unless imports 160 | 161 | imports = [imports] unless imports.respond_to?(:to_ary) 162 | imports.each_with_object({}) do |import, imp| 163 | imp[String(import[:name])] = case import[:ids] 164 | when Hash 165 | [String(import[:ids][:name])] 166 | else 167 | import[:ids].map { |id| String(id[:name]) } 168 | end 169 | end 170 | end 171 | 172 | def load_defaults 173 | # loading the defaults MIBS 174 | load("SNMPv2-MIB") 175 | load("IF-MIB") 176 | end 177 | 178 | def freeze 179 | super 180 | @modules_loaded.each(&:freeze).freeze 181 | @object_identifiers.each_key(&:freeze).each_value(&:freeze).freeze 182 | end 183 | end 184 | end 185 | -------------------------------------------------------------------------------- /lib/netsnmp/mib/parser.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "parslet" 4 | 5 | module NETSNMP::MIB 6 | class Parser < Parslet::Parser 7 | root :mibfile 8 | 9 | def spaced(character = nil) 10 | if character.nil? && block_given? 11 | yield >> space.repeat 12 | else 13 | str(character) >> space.repeat 14 | end 15 | end 16 | 17 | def curly(atom) 18 | str("{") >> space.repeat >> atom >> space.repeat >> str("}") 19 | end 20 | 21 | def bracketed(atom) 22 | str("(") >> space.repeat >> atom >> space.repeat >> str(")") 23 | end 24 | 25 | def square_bracketed(atom) 26 | str("[") >> space.repeat >> atom >> space.repeat >> str("]") 27 | end 28 | 29 | def with_separator(atom, separator = nil) 30 | if separator 31 | sep = if separator.is_a?(String) 32 | space.repeat >> str(separator) >> space.repeat 33 | else 34 | separator 35 | end 36 | 37 | atom >> (sep >> atom).repeat 38 | else 39 | atom >> (space.repeat >> atom).repeat 40 | end 41 | end 42 | 43 | rule(:mibfile) do 44 | space.repeat >> modules.maybe 45 | end 46 | 47 | rule(:modules) do 48 | with_separator(mod) 49 | end 50 | 51 | rule(:mod) do 52 | spaced { module_name.as(:name) } >> module_oid >> 53 | spaced("DEFINITIONS") >> colon_colon_part >> 54 | spaced("BEGIN") >> 55 | exports_part.as(:exports) >> 56 | linkage_part.as(:imports) >> 57 | declaration_part.as(:declarations) >> 58 | spaced("END") 59 | end 60 | 61 | rule(:module_name) { uppercase_identifier } 62 | 63 | rule(:module_oid) do 64 | spaced { curly(object_identifier) }.maybe 65 | end 66 | 67 | rule(:declaration_part) do 68 | spaced { declarations }.maybe 69 | end 70 | 71 | rule(:exports_part) do 72 | spaced { exports_clause }.maybe 73 | end 74 | 75 | rule(:exports_clause) do 76 | spaced("EXPORTS") >> spaced { import_identifiers } >> str(";") 77 | end 78 | 79 | rule(:linkage_part) do 80 | spaced { linkage_clause }.maybe 81 | end 82 | 83 | rule(:linkage_clause) do 84 | spaced("IMPORTS") >> spaced { import_part } >> str(";") 85 | end 86 | 87 | rule(:import_part) do 88 | imports.maybe 89 | end 90 | 91 | rule(:imports) { with_separator(import) } 92 | 93 | rule(:import) do 94 | spaced { import_identifiers.as(:ids) } >> spaced("FROM") >> module_name.as(:name) 95 | end 96 | 97 | rule(:import_identifiers) { with_separator(import_identifier.as(:name), ",") } 98 | 99 | rule(:import_identifier) do 100 | lowercase_identifier | uppercase_identifier | imported_keyword 101 | end 102 | 103 | rule(:imported_keyword) do 104 | imported_smi_keyword | 105 | str("BITS") | 106 | str("Integer32") | 107 | str("IpAddress") | 108 | str("MANDATORY-GROUPS") | 109 | str("MODULE-COMPLIANCE") | 110 | str("MODULE-IDENTITY") | 111 | str("OBJECT-GROUP") | 112 | str("OBJECT-IDENTITY") | 113 | str("OBJECT-TYPE") | 114 | str("Opaque") | 115 | str("TEXTUAL-CONVENTION") | 116 | str("TimeTicks") | 117 | str("Unsigned32") 118 | end 119 | 120 | rule(:imported_smi_keyword) do 121 | str("AGENT-CAPABILITIES") | 122 | str("Counter32") | 123 | str("Counter64") | 124 | str("Gauge32") | 125 | str("NOTIFICATION-GROUP") | 126 | str("NOTIFICATION-TYPE") | 127 | str("TRAP-TYPE") 128 | end 129 | 130 | rule(:declarations) do 131 | with_separator(declaration) 132 | end 133 | 134 | rule(:declaration) do 135 | type_declaration | 136 | value_declaration | 137 | object_identity_clause | 138 | object_type_clause | 139 | traptype_clause | 140 | notification_type_clause | 141 | module_identity_clause | 142 | module_compliance_clause | 143 | object_group_clause | 144 | notification_group_clause | 145 | agent_capabilities_clause | 146 | macro_clause 147 | end 148 | 149 | rule(:macro_clause) do 150 | spaced { macro_name.as(:name) } >> spaced { str("MACRO").as(:type) } >> colon_colon_part >> 151 | spaced("BEGIN") >> 152 | # ignoring macro clauses 153 | match("^(?!END)").repeat >> 154 | spaced("END") 155 | end 156 | 157 | rule(:macro_name) do 158 | str("MODULE-IDENTITY") | 159 | str("OBJECT-TYPE") | 160 | str("TRAP-TYPE") | 161 | str("NOTIFICATION-TYPE") | 162 | str("OBJECT-IDENTITY") | 163 | str("TEXTUAL-CONVENTION") | 164 | str("OBJECT-GROUP") | 165 | str("NOTIFICATION-GROUP") | 166 | str("MODULE-COMPLIANCE") | 167 | str("AGENT-CAPABILITIES") 168 | end 169 | 170 | rule(:agent_capabilities_clause) do 171 | spaced { lowercase_identifier.as(:name) } >> 172 | spaced { str("AGENT-CAPABILITIES").as(:type) } >> 173 | spaced("PRODUCT-RELEASE") >> spaced { text } >> 174 | spaced("STATUS") >> spaced { status } >> 175 | spaced("DESCRIPTION") >> spaced { text } >> 176 | spaced { refer_part }.maybe >> 177 | spaced { module_part_capabilities }.maybe >> 178 | colon_colon_part >> curly(object_identifier) 179 | end 180 | 181 | rule(:module_part_capabilities) do 182 | modules_capabilities 183 | end 184 | 185 | rule(:modules_capabilities) do 186 | with_separator(module_capabilities) 187 | end 188 | 189 | rule(:module_capabilities) do 190 | spaced("SUPPORTS") >> 191 | module_name_capabilities >> 192 | spaced("INCLUDES") >> curly(capabilities_groups) >> 193 | spaced { variation_part }.maybe 194 | end 195 | 196 | rule(:module_name_capabilities) do 197 | (spaced { uppercase_identifier } >> object_identifier) | uppercase_identifier 198 | end 199 | 200 | rule(:capabilities_groups) do 201 | with_separator(capabilities_group) 202 | end 203 | 204 | rule(:capabilities_group) { objectIdentifier } 205 | 206 | rule(:variation_part) do 207 | variations 208 | end 209 | 210 | rule(:variations) do 211 | with_separator(variation) 212 | end 213 | 214 | rule(:variation) do 215 | spaced("VARIATION") >> object_identifier >> 216 | spaced { syntax_part }.maybe >> 217 | spaced { write_syntax_part } >> 218 | spaced { variation_access_part }.maybe >> 219 | spaced { creation_part }.maybe >> 220 | spaced { def_val_part }.maybe >> 221 | spaced("DESCRIPTION") >> text 222 | end 223 | 224 | rule(:variation_access_part) do 225 | spaced("ACCESS") >> variation_access 226 | end 227 | 228 | rule(:variation_access) { lowercase_identifier } 229 | 230 | rule(:creation_part) do 231 | spaced("CREATION-REQUIRES") >> curly(cells) 232 | end 233 | 234 | rule(:cells) { with_separator(cell, ",") } 235 | 236 | rule(:cell) { object_identifier } 237 | 238 | rule(:notification_group_clause) do 239 | spaced { lowercase_identifier.as(:name) } >> 240 | spaced { str("NOTIFICATION-GROUP").as(:type) } >> 241 | spaced { notifications_part } >> 242 | spaced("STATUS") >> spaced { status } >> 243 | spaced("DESCRIPTION") >> spaced { text } >> 244 | spaced { refer_part }.maybe >> 245 | colon_colon_part >> curly(object_identifier) 246 | end 247 | 248 | rule(:notifications_part) do 249 | spaced("NOTIFICATIONS") >> curly(notifications) 250 | end 251 | 252 | rule(:notifications) do 253 | with_separator(notification, ",") 254 | end 255 | 256 | rule(:notification) do 257 | notification_name 258 | end 259 | 260 | rule(:object_group_clause) do 261 | spaced { lowercase_identifier.as(:name) } >> 262 | spaced { str("OBJECT-GROUP").as(:type) } >> 263 | spaced { object_group_objects_part } >> 264 | spaced("STATUS") >> spaced { status } >> 265 | spaced("DESCRIPTION") >> spaced { text } >> 266 | spaced { refer_part }.maybe >> 267 | colon_colon_part >> curly(object_identifier) 268 | end 269 | 270 | rule(:object_group_objects_part) do 271 | spaced("OBJECTS") >> curly(objects) 272 | end 273 | 274 | rule(:module_compliance_clause) do 275 | spaced { lowercase_identifier.as(:name) } >> 276 | spaced { str("MODULE-COMPLIANCE").as(:type) } >> 277 | spaced("STATUS") >> spaced { status } >> 278 | spaced("DESCRIPTION") >> spaced { text } >> 279 | spaced { refer_part }.maybe >> 280 | spaced { compliance_modules } >> 281 | colon_colon_part >> curly(object_identifier) 282 | end 283 | 284 | rule(:compliance_modules) do 285 | with_separator(compliance_module) 286 | end 287 | 288 | rule(:compliance_module) do 289 | spaced { str("MODULE") >> (space_in_line.repeat(1) >> compliance_module_name).maybe } >> 290 | spaced { mandatory_part }.maybe >> 291 | compliances.maybe 292 | end 293 | 294 | rule(:compliance_module_name) do 295 | uppercase_identifier 296 | end 297 | 298 | rule(:mandatory_part) do 299 | spaced("MANDATORY-GROUPS") >> curly(mandatory_groups) 300 | end 301 | 302 | rule(:compliances) do 303 | with_separator(compliance).as(:compliances) 304 | end 305 | 306 | rule(:compliance) do 307 | compliance_group | compliance_object 308 | end 309 | 310 | rule(:compliance_group) do 311 | spaced { str("GROUP").as(:type) } >> spaced { object_identifier.as(:name) } >> 312 | spaced("DESCRIPTION") >> text 313 | end 314 | 315 | rule(:compliance_object) do 316 | spaced { str("OBJECT").as(:type) } >> 317 | spaced { object_identifier.as(:name) } >> 318 | spaced { syntax_part }.maybe >> 319 | spaced { write_syntax_part }.maybe >> 320 | spaced { access_part }.maybe >> 321 | spaced("DESCRIPTION") >> text 322 | end 323 | 324 | rule(:syntax_part) do 325 | spaced("SYNTAX") >> syntax.as(:syntax) 326 | end 327 | 328 | rule(:write_syntax_part) do 329 | (spaced("WRITE-SYNTAX") >> spaced { syntax }).maybe 330 | end 331 | 332 | rule(:access_part) do 333 | (spaced("MIN-ACCESS") >> spaced { access }).maybe 334 | end 335 | 336 | rule(:mandatory_groups) do 337 | with_separator(mandatory_group, ",").as(:groups) 338 | end 339 | 340 | rule(:mandatory_group) { object_identifier.as(:name) } 341 | 342 | rule(:module_identity_clause) do 343 | spaced { lowercase_identifier.as(:name) } >> 344 | spaced { str("MODULE-IDENTITY").as(:type) } >> 345 | (spaced("SUBJECT-CATEGORIES") >> curly(category_ids)).maybe >> 346 | spaced("LAST-UPDATED") >> spaced { ext_utc_time } >> 347 | spaced("ORGANIZATION") >> spaced { text.as(:organization) } >> 348 | spaced("CONTACT-INFO") >> spaced { text.as(:contact_info) } >> 349 | spaced("DESCRIPTION") >> spaced { text } >> 350 | spaced { revisions }.maybe >> 351 | colon_colon_part >> curly(object_identifier.as(:value)) 352 | end 353 | 354 | rule(:ext_utc_time) { text } 355 | 356 | rule(:revisions) do 357 | with_separator(revision) 358 | end 359 | 360 | rule(:revision) do 361 | spaced("REVISION") >> spaced { ext_utc_time } >> 362 | spaced("DESCRIPTION") >> 363 | text 364 | end 365 | 366 | rule(:category_ids) do 367 | with_separator(category_id, ",") 368 | end 369 | 370 | rule(:category_id) do 371 | (spaced { lowercase_identifier } >> bracketed(number)) | lowercase_identifier 372 | end 373 | 374 | rule(:notification_type_clause) do 375 | spaced { lowercase_identifier.as(:name) } >> 376 | spaced { str("NOTIFICATION-TYPE").as(:type) } >> 377 | spaced { notification_objects_part }.maybe >> 378 | spaced("STATUS") >> spaced { status } >> 379 | spaced("DESCRIPTION") >> spaced { text } >> 380 | spaced { refer_part }.maybe >> 381 | colon_colon_part >> curly(notification_name) 382 | end 383 | 384 | rule(:notification_objects_part) do 385 | spaced("OBJECTS") >> curly(objects) 386 | end 387 | 388 | rule(:objects) do 389 | with_separator(object, ",") 390 | end 391 | 392 | rule(:object) do 393 | object_identifier 394 | end 395 | 396 | rule(:notification_name) do 397 | object_identifier 398 | end 399 | 400 | rule(:traptype_clause) do 401 | spaced { fuzzy_lowercase_identifier.as(:name) } >> 402 | spaced { str("TRAP-TYPE").as(:type) } >> spaced { enterprise_part } >> 403 | spaced { var_part }.maybe >> 404 | spaced { descr_part }.maybe >> 405 | spaced { refer_part }.maybe >> 406 | colon_colon_part >> number 407 | end 408 | 409 | rule(:enterprise_part) do 410 | (spaced("ENTERPRISE") >> object_identifier) | 411 | (spaced("ENTERPRISE") >> curly(object_identifier)) 412 | end 413 | 414 | rule(:var_part) do 415 | spaced("VARIABLES") >> curly(var_types) 416 | end 417 | 418 | rule(:var_types) do 419 | with_separator(var_type, ",") 420 | end 421 | 422 | rule(:var_type) { object_identifier } 423 | 424 | rule(:descr_part) do 425 | spaced("DESCRIPTION") >> text 426 | end 427 | 428 | rule(:object_type_clause) do 429 | spaced { lowercase_identifier.as(:name) } >> 430 | spaced { str("OBJECT-TYPE").as(:type) } >> 431 | spaced { syntax_part }.maybe >> 432 | spaced { units_part }.maybe >> 433 | spaced { max_access_part }.maybe >> 434 | (spaced("STATUS") >> spaced { status }).maybe >> 435 | spaced { description_clause }.maybe >> 436 | spaced { refer_part }.maybe >> 437 | spaced { index_part }.maybe >> 438 | spaced { mib_index }.maybe >> 439 | spaced { def_val_part }.maybe >> 440 | colon_colon_part >> curly(object_identifier.as(:value)) 441 | end 442 | 443 | rule(:object_identity_clause) do 444 | spaced { lowercase_identifier.as(:name) } >> 445 | spaced { str("OBJECT-IDENTITY").as(:type) } >> 446 | spaced("STATUS") >> spaced { status } >> 447 | spaced("DESCRIPTION") >> spaced { text } >> 448 | spaced { refer_part }.maybe >> 449 | colon_colon_part >> curly(object_identifier.as(:value)) 450 | end 451 | 452 | rule(:units_part) do 453 | spaced("UNITS") >> text.as(:units) 454 | end 455 | 456 | rule(:max_access_part) do 457 | (spaced("MAX-ACCESS") >> access) | (spaced("ACCESS") >> access) 458 | end 459 | 460 | rule(:access) { lowercase_identifier } 461 | 462 | rule(:description_clause) do 463 | spaced("DESCRIPTION") >> text 464 | end 465 | 466 | rule(:index_part) do 467 | spaced("AUGMENTS") >> curly(entry) 468 | end 469 | 470 | rule(:mib_index) do 471 | spaced("INDEX") >> curly(index_types) 472 | end 473 | 474 | rule(:def_val_part) do 475 | spaced("DEFVAL") >> curly(valueof_simple_syntax) 476 | end 477 | 478 | rule(:valueof_simple_syntax) do 479 | value | lowercase_identifier | text | curly(object_identifiers_defval) 480 | end 481 | 482 | rule(:object_identifiers_defval) do 483 | with_separator(object_identifier_defval) 484 | end 485 | 486 | rule(:object_identifier_defval) do 487 | (spaced { lowercase_identifier } >> bracketed(number)) | 488 | number 489 | end 490 | 491 | rule(:index_types) do 492 | with_separator(index_type, ",") 493 | end 494 | 495 | rule(:index_type) do 496 | (spaced("IMPLIED") >> idx) | idx 497 | end 498 | 499 | rule(:idx) do 500 | object_identifier 501 | end 502 | 503 | rule(:entry) do 504 | object_identifier 505 | end 506 | 507 | rule(:value_declaration) do 508 | spaced { fuzzy_lowercase_identifier.as(:name) } >> 509 | spaced { str("OBJECT IDENTIFIER").as(:type) } >> 510 | colon_colon_part >> curly(object_identifier.as(:value)) 511 | end 512 | 513 | rule(:fuzzy_lowercase_identifier) do 514 | lowercase_identifier | uppercase_identifier 515 | end 516 | 517 | rule(:object_identifier) do 518 | sub_identifiers 519 | end 520 | 521 | rule(:sub_identifiers) do 522 | with_separator(sub_identifier, space_in_line.repeat) 523 | end 524 | 525 | rule(:sub_identifier) do 526 | fuzzy_lowercase_identifier | 527 | number | 528 | (spaced { lowercase_identifier } >> bracketed(number)) 529 | end 530 | 531 | rule(:type_declaration) do 532 | spaced { type_name.as(:vartype) } >> colon_colon_part >> type_declaration_rhs 533 | end 534 | 535 | rule(:type_name) do 536 | uppercase_identifier | type_smi 537 | end 538 | 539 | rule(:type_smi) do 540 | type_smi_and_sppi | type_smi_only 541 | end 542 | 543 | rule(:type_declaration_rhs) do 544 | spaced { choice_clause } | 545 | (spaced { str("TEXTUAL-CONVENTION") } >> 546 | spaced { display_part }.maybe >> 547 | spaced("STATUS") >> spaced { status } >> 548 | spaced("DESCRIPTION") >> spaced { text } >> 549 | spaced { refer_part }.maybe >> 550 | spaced("SYNTAX") >> syntax) | 551 | syntax 552 | end 553 | 554 | rule(:refer_part) do 555 | spaced("REFERENCE") >> text 556 | end 557 | 558 | rule(:choice_clause) do 559 | # Ignoring choice syntax 560 | spaced { str("CHOICE").as(:type) } >> curly(match("[^}]").repeat) 561 | end 562 | 563 | rule(:syntax) do 564 | object_syntax | (spaced("BITS").as(:type) >> curly(named_bits)) 565 | end 566 | 567 | rule(:display_part) do 568 | spaced("DISPLAY-HINT") >> text 569 | end 570 | 571 | rule(:named_bits) do 572 | with_separator(named_bit, ",") 573 | end 574 | 575 | rule(:named_bit) do 576 | spaced { lowercase_identifier } >> bracketed(number) 577 | end 578 | 579 | rule(:object_syntax) do 580 | conceptual_table | 581 | entry_type | 582 | simple_syntax | 583 | application_syntax | 584 | (type_tag >> simple_syntax) | 585 | row.as(:value) 586 | end 587 | 588 | rule(:simple_syntax) do 589 | (spaced { str("INTEGER").as(:type) } >> (integer_subtype | enum_spec).maybe) | 590 | (spaced { str("Integer32").as(:type) >> space } >> integer_subtype.maybe) | 591 | (spaced { str("OCTET STRING").as(:type) } >> octetstring_subtype.maybe) | 592 | (spaced { str("OBJECT IDENTIFIER").as(:type) } >> any_subtype) | 593 | (spaced { uppercase_identifier.as(:type) } >> (integer_subtype | enum_spec | octetstring_subtype)) 594 | end 595 | 596 | rule(:application_syntax) do 597 | (spaced { str("IpAddress").as(:type) >> space } >> any_subtype) | 598 | (spaced { str("NetworkAddress").as(:type) >> space } >> any_subtype) | 599 | (spaced { str("Counter32").as(:type) >> space } >> integer_subtype.maybe) | 600 | (spaced { str("Gauge32").as(:type) >> space } >> integer_subtype.maybe) | 601 | (spaced { str("Unsigned32").as(:type) >> space } >> integer_subtype.maybe) | 602 | (spaced { str("TimeTicks").as(:type) >> space } >> any_subtype) | 603 | (spaced { str("Opaque").as(:type) >> space } >> octetstring_subtype.maybe) | 604 | (spaced { str("Counter64").as(:type) >> space } >> integer_subtype.maybe) 605 | end 606 | 607 | rule(:conceptual_table) do 608 | spaced { str("SEQUENCE OF").as(:type) } >> row.as(:value) 609 | end 610 | 611 | rule(:entry_type) do 612 | spaced { str("SEQUENCE").as(:type) } >> curly(sequence_items) 613 | end 614 | 615 | rule(:type_tag) do 616 | (spaced { square_bracketed(spaced("APPLICATION") >> number.as(:application_type)) } >> spaced("IMPLICIT")) | 617 | (spaced { square_bracketed(spaced("UNIVERSAL") >> number.as(:universal_type)) } >> spaced("IMPLICIT")) 618 | end 619 | 620 | rule(:sequence_items) do 621 | with_separator(sequence_item, ",") 622 | end 623 | 624 | rule(:sequence_item) do 625 | spaced { lowercase_identifier } >> spaced { sequence_syntax } 626 | end 627 | 628 | rule(:sequence_syntax) do 629 | str("BITS") | 630 | sequence_object_syntax | 631 | (spaced { uppercase_identifier } >> any_subtype) 632 | end 633 | 634 | rule(:sequence_object_syntax) do 635 | sequence_simple_syntax | sequence_application_syntax 636 | end 637 | 638 | rule(:sequence_simple_syntax) do 639 | (spaced("INTEGER") >> any_subtype) | 640 | (spaced("Integer32") >> any_subtype) | 641 | (spaced("OCTET STRING") >> any_subtype) | 642 | (spaced("OBJECT IDENTIFIER") >> any_subtype) 643 | end 644 | 645 | rule(:sequence_application_syntax) do 646 | (spaced { str("IpAddress") >> space } >> any_subtype) | 647 | (spaced { str("COUNTER32") } >> any_subtype) | 648 | (spaced { str("Gauge32") >> space } >> any_subtype) | 649 | (spaced { str("Unsigned32") >> space } >> any_subtype) | 650 | (spaced { str("TimeTicks") >> space } >> any_subtype) | 651 | str("Opaque") | 652 | (spaced { str("Counter64") >> space } >> any_subtype) 653 | end 654 | 655 | rule(:row) { uppercase_identifier } 656 | 657 | rule(:integer_subtype) { bracketed(ranges) } 658 | 659 | rule(:octetstring_subtype) do 660 | bracketed(spaced("SIZE") >> bracketed(ranges)) 661 | end 662 | 663 | rule(:any_subtype) do 664 | (integer_subtype | octetstring_subtype | enum_spec).maybe 665 | end 666 | 667 | rule(:enum_spec) { curly(enum_items) } 668 | 669 | rule(:enum_items) do 670 | with_separator(enum_item.as(:enum), ",") 671 | end 672 | 673 | rule(:enum_item) do 674 | fuzzy_lowercase_identifier.as(:name) >> space.repeat >> bracketed(number.as(:value)) 675 | end 676 | 677 | rule(:ranges) do 678 | with_separator(range.as(:range), "|") 679 | end 680 | 681 | rule(:range) do 682 | value.as(:min) >> space.repeat >> (str("..") >> space.repeat >> value.as(:max)).maybe 683 | end 684 | 685 | rule(:value) do 686 | number | hexstring | binstring 687 | end 688 | 689 | rule(:status) { lowercase_identifier } 690 | 691 | rule(:uppercase_identifier) do 692 | match("[A-Z]") >> match("[A-Za-z0-9-]").repeat 693 | end 694 | 695 | rule(:lowercase_identifier) do 696 | match("[a-z]") >> match("[A-Za-z0-9-]").repeat 697 | end 698 | 699 | rule(:type_smi_and_sppi) do 700 | str("IpAddress") | str("TimeTicks") | str("Opaque") | str("Integer32") | str("Unsigned32") 701 | end 702 | 703 | rule(:type_smi_only) do 704 | str("Counter") | str("Gauge32") | str("Counter64") 705 | end 706 | 707 | rule(:colon_colon_part) { spaced("::=") } 708 | 709 | rule(:space_in_line) { match('[ \t]').repeat(1) } 710 | rule(:cr) { match("\n") } 711 | rule(:space) do 712 | # this rule match all not important text 713 | (match('[ \t\r\n]') | comment_line).repeat(1) 714 | end 715 | 716 | rule(:comment_line) do 717 | (match('\-\-') >> match('[^\n]').repeat >> match('\n')) 718 | end 719 | 720 | rule(:space?) { space.maybe } 721 | rule(:digit) { match["0-9"] } 722 | rule(:hexchar) { match("[0-9a-fA-F]") } 723 | rule(:empty) { str("") } 724 | rule(:number) do 725 | ( 726 | str("-").maybe >> ( 727 | str("0") | (match("[1-9]") >> digit.repeat) 728 | ) >> ( 729 | str(".") >> digit.repeat(1) 730 | ).maybe >> ( 731 | match("[eE]") >> (str("+") | str("-")).maybe >> digit.repeat(1) 732 | ).maybe 733 | ).repeat(1) 734 | end 735 | 736 | rule(:hexstring) do 737 | str("'") >> hexchar.repeat >> str("'") >> match("[hH]") 738 | end 739 | 740 | rule(:binstring) do 741 | str("'") >> match["0-1"].repeat >> str("'") 742 | end 743 | 744 | rule(:text) do 745 | str('"') >> ( 746 | (str("\\") >> any) | (str('"').absent? >> any) 747 | ).repeat >> str('"') 748 | end 749 | end 750 | end 751 | -------------------------------------------------------------------------------- /lib/netsnmp/oid.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module NETSNMP 4 | # Abstracts the OID structure 5 | # 6 | module OID 7 | using StringExtensions 8 | 9 | OIDREGEX = /^[\d.]*$/.freeze 10 | 11 | module_function 12 | 13 | def build(id) 14 | oid = MIB.oid(id) 15 | 16 | raise Error, "no OID found for #{id}" unless oid 17 | 18 | oid = oid.delete_prefix(".") if oid.start_with?(".") 19 | oid 20 | end 21 | 22 | def to_asn(oid) 23 | OpenSSL::ASN1::ObjectId.new(oid) 24 | end 25 | 26 | # @param [OID, String] child oid another oid 27 | # @return [true, false] whether the given OID belongs to the sub-tree 28 | # 29 | def parent?(parent_oid, child_oid) 30 | child_oid.match?(/\A#{parent_oid}\./) 31 | end 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /lib/netsnmp/pdu.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "forwardable" 4 | module NETSNMP 5 | # Abstracts the PDU base structure into a ruby object. It gives access to its varbinds. 6 | # 7 | class PDU 8 | using ASNExtensions 9 | 10 | MAXREQUESTID = 0x7fffffff 11 | 12 | using ASNExtensions 13 | class << self 14 | def decode(der, **args) 15 | der = OpenSSL::ASN1.decode(der) if der.is_a?(String) 16 | 17 | *headers, request = der.value 18 | 19 | version, community = headers.map(&:value) 20 | 21 | type = request.tag 22 | 23 | *request_headers, varbinds = request.value 24 | 25 | request_id, error_status, error_index = request_headers.map(&:value).map(&:to_i) 26 | 27 | varbs = varbinds.value.map do |varbind| 28 | oid_asn, val_asn = varbind.value 29 | oid = oid_asn.value 30 | { oid: oid, value: val_asn } 31 | end 32 | 33 | new(type: type, 34 | version: version.to_i, 35 | community: community, 36 | error_status: error_status.to_i, 37 | error_index: error_index.to_i, 38 | request_id: request_id.to_i, 39 | varbinds: varbs, 40 | **args) 41 | end 42 | 43 | # factory method that abstracts initialization of the pdu types that the library supports. 44 | # 45 | # @param [Symbol] type the type of pdu structure to build 46 | # 47 | def build(type, **args) 48 | typ = case type 49 | when :get then 0 50 | when :getnext then 1 51 | # when :getbulk then 5 52 | when :set then 3 53 | when :inform then 6 54 | when :trap then 7 55 | when :response then 2 56 | when :report then 8 57 | else raise Error, "#{type} is not supported as type" 58 | end 59 | new(type: typ, **args) 60 | end 61 | end 62 | 63 | attr_reader :varbinds, :type, :version, :community, :request_id 64 | 65 | def initialize(type:, 66 | version:, 67 | community:, 68 | request_id: SecureRandom.random_number(MAXREQUESTID), 69 | error_status: 0, 70 | error_index: 0, 71 | varbinds: []) 72 | @version = version.to_i 73 | @community = community 74 | @error_status = error_status 75 | @error_index = error_index 76 | @type = type 77 | @varbinds = [] 78 | varbinds.each do |varbind| 79 | add_varbind(**varbind) 80 | end 81 | @request_id = request_id 82 | check_error_status(@error_status) 83 | end 84 | 85 | def to_der 86 | to_asn.to_der 87 | end 88 | 89 | def to_hex 90 | to_asn.to_hex 91 | end 92 | 93 | # Adds a request varbind to the pdu 94 | # 95 | # @param [OID] oid a valid oid 96 | # @param [Hash] options additional request varbind options 97 | # @option options [Object] :value the value for the oid 98 | def add_varbind(oid:, **options) 99 | @varbinds << Varbind.new(oid, **options) 100 | end 101 | alias << add_varbind 102 | 103 | def to_asn 104 | request_id_asn = OpenSSL::ASN1::Integer.new(@request_id).with_label(:request_id) 105 | error_asn = OpenSSL::ASN1::Integer.new(@error_status).with_label(:error) 106 | error_index_asn = OpenSSL::ASN1::Integer.new(@error_index).with_label(:error_index) 107 | 108 | varbind_asns = OpenSSL::ASN1::Sequence.new(@varbinds.map(&:to_asn)).with_label(:varbinds) 109 | 110 | request_asn = OpenSSL::ASN1::ASN1Data.new([request_id_asn, 111 | error_asn, error_index_asn, 112 | varbind_asns], @type, 113 | :CONTEXT_SPECIFIC).with_label(:request) 114 | 115 | OpenSSL::ASN1::Sequence.new([*encode_headers_asn, request_asn]).with_label(:pdu) 116 | end 117 | 118 | private 119 | 120 | def encode_headers_asn 121 | [ 122 | OpenSSL::ASN1::Integer.new(@version).with_label(:snmp_version), 123 | OpenSSL::ASN1::OctetString.new(@community).with_label(:community) 124 | ] 125 | end 126 | 127 | # http://www.tcpipguide.com/free/t_SNMPVersion2SNMPv2MessageFormats-5.htm#Table_219 128 | def check_error_status(status) 129 | return if status.zero? 130 | 131 | message = case status 132 | when 1 then "Response-PDU too big" 133 | when 2 then "No such name" 134 | when 3 then "Bad value" 135 | when 4 then "Read Only" 136 | when 5 then "General Error" 137 | when 6 then "Access denied" 138 | when 7 then "Wrong type" 139 | when 8 then "Wrong length" 140 | when 9 then "Wrong encoding" 141 | when 10 then "Wrong value" 142 | when 11 then "No creation" 143 | when 12 then "Inconsistent value" 144 | when 13 then "Resource unavailable" 145 | when 14 then "Commit failed" 146 | when 15 then "Undo Failed" 147 | when 16 then "Authorization Error" 148 | when 17 then "Not Writable" 149 | when 18 then "Inconsistent Name" 150 | else 151 | "Unknown Error: (#{status})" 152 | end 153 | raise Error, message 154 | end 155 | end 156 | end 157 | -------------------------------------------------------------------------------- /lib/netsnmp/scoped_pdu.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module NETSNMP 4 | class ScopedPDU < PDU 5 | using ASNExtensions 6 | 7 | attr_reader :engine_id, :security_level, :auth_param 8 | 9 | def initialize(type:, auth_param: "", security_level: 3, engine_id: nil, context: nil, **options) 10 | @auth_param = auth_param 11 | @security_level = security_level 12 | @engine_id = engine_id 13 | @context = context 14 | super(type: type, version: 3, community: nil, **options) 15 | end 16 | 17 | private 18 | 19 | def encode_headers_asn 20 | [ 21 | OpenSSL::ASN1::OctetString.new(@engine_id || "").with_label(:engine_id), 22 | OpenSSL::ASN1::OctetString.new(@context || "").with_label(:context) 23 | ] 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /lib/netsnmp/security_parameters.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module NETSNMP 4 | # This module encapsulates the public API for encrypting/decrypting and signing/verifying. 5 | # 6 | # It doesn't interact with other layers from the library, rather it is used and passed all 7 | # the arguments (consisting mostly of primitive types). 8 | # It also provides validation of the security options passed with a client is initialized in v3 mode. 9 | class SecurityParameters 10 | using StringExtensions 11 | using ASNExtensions 12 | 13 | include Loggable 14 | 15 | IPAD = "\x36" * 64 16 | OPAD = "\x5c" * 64 17 | 18 | # Timeliness is part of SNMP V3 Security 19 | # The topic is described very nice here https://www.snmpsharpnet.com/?page_id=28 20 | # https://www.ietf.org/rfc/rfc2574.txt 1.4.1 Timeliness 21 | # The probe is outdated after 150 seconds which results in a PDU Error, therefore it should expire before that and be renewed 22 | # The 150 Seconds is specified in https://www.ietf.org/rfc/rfc2574.txt 2.2.3 23 | TIMELINESS_THRESHOLD = 150 24 | 25 | attr_reader :security_level, :username, :auth_protocol, :engine_id 26 | 27 | # @param [String] username the snmp v3 username 28 | # @param [String] engine_id the device engine id (initialized to '' for report) 29 | # @param [Symbol, integer] security_level allowed snmp v3 security level (:auth_priv, :auth_no_priv, etc) 30 | # @param [Symbol, nil] auth_protocol a supported authentication protocol (currently supported: :md5, :sha, :sha256) 31 | # @param [Symbol, nil] priv_protocol a supported privacy protocol (currently supported: :des, :aes) 32 | # @param [String, nil] auth_password the authentication password 33 | # @param [String, nil] priv_password the privacy password 34 | # 35 | # @note if security level is set to :no_auth_no_priv, all other parameters are optional; if 36 | # :auth_no_priv, :auth_protocol will be coerced to :md5 (if not explicitly set), and :auth_password is 37 | # mandatory; if :auth_priv, the sentence before applies, and :priv_protocol will be coerced to :des (if 38 | # not explicitly set), and :priv_password becomes mandatory. 39 | # 40 | def initialize( 41 | username:, 42 | engine_id: "", 43 | security_level: nil, 44 | auth_protocol: nil, 45 | auth_password: nil, 46 | priv_protocol: nil, 47 | priv_password: nil, 48 | **options 49 | ) 50 | @security_level = case security_level 51 | when /no_?auth/ then 0 52 | when /auth_?no_?priv/ then 1 53 | when /auth_?priv/ then 3 54 | when Integer then security_level 55 | else 3 # rubocop:disable Lint/DuplicateBranch 56 | end 57 | @username = username 58 | @engine_id = engine_id 59 | @auth_protocol = auth_protocol.to_sym unless auth_protocol.nil? 60 | @priv_protocol = priv_protocol.to_sym unless priv_protocol.nil? 61 | 62 | if @security_level.positive? 63 | @auth_protocol ||= :md5 # this is the default 64 | raise "security level requires an auth password" if auth_password.nil? 65 | raise "auth password must have between 8 to 32 characters" unless (8..32).cover?(auth_password.length) 66 | end 67 | 68 | if @security_level > 1 69 | @priv_protocol ||= :des 70 | raise "security level requires a priv password" if priv_password.nil? 71 | raise "priv password must have between 8 to 32 characters" unless (8..32).cover?(priv_password.length) 72 | end 73 | 74 | @auth_pass_key = passkey(auth_password) if auth_password 75 | @priv_pass_key = passkey(priv_password) if priv_password 76 | initialize_logger(**options) 77 | end 78 | 79 | def engine_id=(id) 80 | @timeliness = Process.clock_gettime(Process::CLOCK_MONOTONIC, :second) 81 | @engine_id = id 82 | end 83 | 84 | # @param [#to_asn, #to_der] pdu the pdu to encode (must quack like a asn1 type) 85 | # @param [String] salt the salt to use 86 | # @param [Integer] engine_time the reported engine time 87 | # @param [Integer] engine_boots the reported boots time 88 | # 89 | # @return [Array] a pair, where the first argument in the asn structure with the encoded pdu, 90 | # and the second is the calculated salt (if it has been encrypted) 91 | def encode(pdu, salt:, engine_time:, engine_boots:) 92 | encryptor = encryption 93 | 94 | if encryptor 95 | encrypted_pdu, salt = encryptor.encrypt(pdu.to_der, engine_boots: engine_boots, 96 | engine_time: engine_time) 97 | [ 98 | OpenSSL::ASN1::OctetString.new(encrypted_pdu).with_label(:encrypted_pdu), 99 | OpenSSL::ASN1::OctetString.new(salt).with_label(:salt) 100 | ] 101 | else 102 | [pdu.to_asn, salt] 103 | end 104 | end 105 | 106 | # @param [String] der the encoded der to be decoded 107 | # @param [String] salt the salt from the incoming der 108 | # @param [Integer] engine_time the reported engine time 109 | # @param [Integer] engine_boots the reported engine boots 110 | def decode(der, salt:, engine_time:, engine_boots:, security_level: @security_level) 111 | asn = OpenSSL::ASN1.decode(der) 112 | return asn if security_level < 3 113 | 114 | encryptor = encryption 115 | return asn unless encryptor 116 | 117 | encrypted_pdu = asn.value 118 | pdu_der = encryptor.decrypt(encrypted_pdu, salt: salt, engine_time: engine_time, engine_boots: engine_boots) 119 | log(level: 2) { "message has been decrypted" } 120 | OpenSSL::ASN1.decode(pdu_der) 121 | end 122 | 123 | # @param [String] message the already encoded snmp v3 message 124 | # @return [String] the digest signature of the message payload 125 | # 126 | # @note this method is used in the process of authenticating a message 127 | def sign(message) 128 | # don't sign unless you have to 129 | return unless @auth_protocol 130 | 131 | key = auth_key.dup 132 | 133 | # SHA256 => https://datatracker.ietf.org/doc/html/rfc7860#section-4.2.2 134 | # The 24 first octets of HMAC are taken as the computed MAC value 135 | return OpenSSL::HMAC.digest("SHA256", key, message)[0, 24] if @auth_protocol == :sha256 136 | 137 | # MD5 => https://datatracker.ietf.org/doc/html/rfc3414#section-6.3.2 138 | # SHA1 => https://datatracker.ietf.org/doc/html/rfc3414#section-7.3.2 139 | key << ("\x00" * (@auth_protocol == :md5 ? 48 : 44)) 140 | k1 = key.xor(IPAD) 141 | k2 = key.xor(OPAD) 142 | 143 | digest.reset 144 | digest << (k1 + message) 145 | d1 = digest.digest 146 | 147 | digest.reset 148 | digest << (k2 + d1) 149 | # The 12 first octets of the digest are taken as the computed MAC value 150 | digest.digest[0, 12] 151 | end 152 | 153 | # @param [String] stream the encoded incoming payload 154 | # @param [String] salt the incoming payload''s salt 155 | # 156 | # @raise [NETSNMP::Error] if the message's integration has been violated 157 | def verify(stream, salt, security_level: @security_level) 158 | return if security_level.nil? || security_level < 1 159 | 160 | verisalt = sign(stream) 161 | raise Error, "invalid message authentication salt" unless verisalt == salt 162 | 163 | log(level: 2) { "message has been verified" } 164 | end 165 | 166 | def must_revalidate? 167 | return @engine_id.empty? unless authorizable? 168 | return true if @engine_id.empty? || @timeliness.nil? 169 | 170 | (Process.clock_gettime(Process::CLOCK_MONOTONIC, :second) - @timeliness) >= TIMELINESS_THRESHOLD 171 | end 172 | 173 | private 174 | 175 | def auth_key 176 | @auth_key ||= localize_key(@auth_pass_key) 177 | end 178 | 179 | def priv_key 180 | @priv_key ||= localize_key(@priv_pass_key) 181 | end 182 | 183 | def localize_key(key) 184 | digest.reset 185 | digest << key 186 | digest << @engine_id 187 | digest << key 188 | 189 | digest.digest 190 | end 191 | 192 | def passkey(password) 193 | digest.reset 194 | password_index = 0 195 | 196 | # buffer = +"" 197 | password_length = password.length 198 | while password_index < 1048576 199 | initial = password_index % password_length 200 | rotated = String(password[initial..-1]) + String(password[0, initial]) 201 | buffer = (rotated * (64 / rotated.length)) + String(rotated[0, 64 % rotated.length]) 202 | password_index += 64 203 | digest << buffer 204 | buffer.clear 205 | end 206 | 207 | dig = digest.digest 208 | dig = dig[0, 16] if @auth_protocol == :md5 209 | dig || "" 210 | end 211 | 212 | def digest 213 | @digest ||= case @auth_protocol 214 | when :md5 then OpenSSL::Digest.new("MD5") 215 | when :sha then OpenSSL::Digest.new("SHA1") 216 | when :sha256 then OpenSSL::Digest.new("SHA256") 217 | else 218 | raise Error, "unsupported auth protocol: #{@auth_protocol}" 219 | end 220 | end 221 | 222 | def encryption 223 | @encryption ||= case @priv_protocol 224 | when :des then Encryption::DES.new(priv_key) 225 | when :aes then Encryption::AES.new(priv_key) 226 | end 227 | end 228 | 229 | def authorizable? 230 | @auth_protocol != :none 231 | end 232 | end 233 | end 234 | -------------------------------------------------------------------------------- /lib/netsnmp/session.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module NETSNMP 4 | # Let's just remind that there is no session in snmp, this is just an abstraction. 5 | # 6 | class Session 7 | include Loggable 8 | 9 | TIMEOUT = 2 10 | 11 | # @param [Hash] opts the options set 12 | def initialize(version: 1, community: "public", **options) 13 | @version = case version 14 | when Integer then version # assume the use know what he's doing 15 | when /v?1/ then 0 16 | when /v?2c?/ then 1 17 | when /v?3/ then 3 18 | else 19 | raise "unsupported snmp version (#{version})" 20 | end 21 | @community = community 22 | validate(**options) 23 | initialize_logger(**options) 24 | end 25 | 26 | # Closes the session 27 | def close 28 | # if the transport came as an argument, 29 | # then let the outer realm care for its lifecycle 30 | @transport.close unless @proxy 31 | end 32 | 33 | # @param [Symbol] type the type of PDU (:get, :set, :getnext) 34 | # @param [Array] vars collection of options to generate varbinds (see {NETSMP::Varbind.new} for all the possible options) 35 | # 36 | # @return [NETSNMP::PDU] a pdu 37 | # 38 | def build_pdu(type, *vars) 39 | PDU.build(type, version: @version, community: @community, varbinds: vars) 40 | end 41 | 42 | # send a pdu, receives a pdu 43 | # 44 | # @param [NETSNMP::PDU, #to_der] an encodable request pdu 45 | # 46 | # @return [NETSNMP::PDU] the response pdu 47 | # 48 | def send(pdu) 49 | log { "sending request..." } 50 | log(level: 2) { pdu.to_hex } 51 | encoded_request = pdu.to_der 52 | log { Hexdump.dump(encoded_request) } 53 | encoded_response = @transport.send(encoded_request) 54 | log { "received response" } 55 | log { Hexdump.dump(encoded_response) } 56 | response_pdu = PDU.decode(encoded_response) 57 | log(level: 2) { response_pdu.to_hex } 58 | response_pdu 59 | end 60 | 61 | private 62 | 63 | def validate(host: nil, port: 161, proxy: nil, timeout: TIMEOUT, **) 64 | if proxy 65 | @proxy = true 66 | @transport = proxy 67 | else 68 | raise "you must provide an hostname/ip under :host" unless host 69 | 70 | @transport = Transport.new(host, port.to_i, timeout: timeout) 71 | end 72 | end 73 | 74 | class Transport 75 | MAXPDUSIZE = 0xffff + 1 76 | 77 | def initialize(host, port, timeout:) 78 | @socket = UDPSocket.new 79 | @destaddr = Socket.sockaddr_in(port, host) 80 | @timeout = timeout 81 | end 82 | 83 | def close 84 | @socket.close 85 | end 86 | 87 | def send(payload) 88 | write(payload) 89 | recv 90 | end 91 | 92 | def write(payload) 93 | perform_io do 94 | @socket.sendmsg(payload, Socket::MSG_DONTWAIT, @destaddr) 95 | end 96 | end 97 | 98 | def recv(bytesize = MAXPDUSIZE) 99 | perform_io do 100 | datagram, = @socket.recvmsg_nonblock(bytesize, Socket::MSG_DONTWAIT) 101 | datagram 102 | end 103 | end 104 | 105 | private 106 | 107 | def perform_io 108 | loop do 109 | begin 110 | return yield 111 | rescue IO::WaitReadable 112 | wait(:wait_readable) 113 | rescue IO::WaitWritable 114 | wait(:wait_writable) 115 | end 116 | end 117 | end 118 | 119 | def wait(mode) 120 | return if @socket.__send__(mode, @timeout) 121 | 122 | raise Timeout::Error, "Timeout after #{@timeout} seconds" 123 | end 124 | end 125 | end 126 | end 127 | -------------------------------------------------------------------------------- /lib/netsnmp/timeticks.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module NETSNMP 4 | class Timetick < Numeric 5 | # @param [Integer] ticks number of microseconds since the time it was read 6 | def initialize(ticks) 7 | @ticks = ticks 8 | super() 9 | end 10 | 11 | def to_s 12 | days = days_since 13 | hours = hours_since(days) 14 | minutes = minutes_since(hours) 15 | milliseconds = milliseconds_since(minutes) 16 | "Timeticks: (#{@ticks}) #{days.to_i} days, #{hours.to_i}:#{minutes.to_i}:#{milliseconds.to_f.round(2)}" 17 | end 18 | 19 | def to_i 20 | @ticks 21 | end 22 | 23 | def to_asn 24 | OpenSSL::ASN1::ASN1Data.new([@ticks].pack("N"), 3, :APPLICATION) 25 | end 26 | 27 | def coerce(other) 28 | [Timetick.new(other), self] 29 | end 30 | 31 | def <=>(other) 32 | to_i <=> other.to_i 33 | end 34 | 35 | def +(other) 36 | Timetick.new((to_i + other.to_i)) 37 | end 38 | 39 | def -(other) 40 | Timetick.new((to_i - other.to_i)) 41 | end 42 | 43 | def *(other) 44 | Timetick.new((to_i * other.to_i)) 45 | end 46 | 47 | def /(other) 48 | Timetick.new((to_i / other.to_i)) 49 | end 50 | 51 | private 52 | 53 | def days_since 54 | Rational(@ticks, 8_640_000) 55 | end 56 | 57 | def hours_since(days) 58 | Rational((days.to_f - days.to_i) * 24) 59 | end 60 | 61 | def minutes_since(hours) 62 | Rational((hours.to_f - hours.to_i) * 60) 63 | end 64 | 65 | def milliseconds_since(minutes) 66 | Rational((minutes.to_f - minutes.to_i) * 60) 67 | end 68 | end 69 | end 70 | -------------------------------------------------------------------------------- /lib/netsnmp/v3_session.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module NETSNMP 4 | # Abstraction for the v3 semantics. 5 | class V3Session < Session 6 | # @param [String, Integer] version SNMP version (always 3) 7 | def initialize(context: "", **opts) 8 | @context = context 9 | @security_parameters = opts.delete(:security_parameters) 10 | super 11 | @message_serializer = Message.new(**opts) 12 | end 13 | 14 | # @see {NETSNMP::Session#build_pdu} 15 | # 16 | # @return [NETSNMP::ScopedPDU] a pdu 17 | def build_pdu(type, *vars) 18 | engine_id = security_parameters.engine_id 19 | ScopedPDU.build(type, engine_id: engine_id, context: @context, varbinds: vars) 20 | end 21 | 22 | # @see {NETSNMP::Session#send} 23 | def send(pdu) 24 | log { "sending request..." } 25 | encoded_request = encode(pdu) 26 | encoded_response = @transport.send(encoded_request) 27 | response_pdu, * = decode(encoded_response) 28 | response_pdu 29 | end 30 | 31 | private 32 | 33 | def validate(**options) 34 | super 35 | if (s = @security_parameters) 36 | # inspect public API 37 | unless s.respond_to?(:encode) && 38 | s.respond_to?(:decode) && 39 | s.respond_to?(:sign) && 40 | s.respond_to?(:verify) 41 | raise Error, "#{s} doesn't respect the sec params public API (#encode, #decode, #sign)" 42 | end 43 | else 44 | @security_parameters = SecurityParameters.new(security_level: options[:security_level], 45 | username: options[:username], 46 | auth_protocol: options[:auth_protocol], 47 | priv_protocol: options[:priv_protocol], 48 | auth_password: options[:auth_password], 49 | priv_password: options[:priv_password]) 50 | 51 | end 52 | end 53 | 54 | def security_parameters 55 | @security_parameters.engine_id = probe_for_engine if @security_parameters.must_revalidate? 56 | @security_parameters 57 | end 58 | 59 | # sends a probe snmp v3 request, to get the additional info with which to handle the security aspect 60 | # 61 | def probe_for_engine 62 | report_sec_params = SecurityParameters.new(security_level: 0, 63 | username: @security_parameters.username) 64 | pdu = ScopedPDU.build(:get) 65 | log { "sending probe..." } 66 | encoded_report_pdu = @message_serializer.encode(pdu, security_parameters: report_sec_params) 67 | 68 | encoded_response_pdu = @transport.send(encoded_report_pdu) 69 | 70 | _, engine_id, @engine_boots, @engine_time = decode(encoded_response_pdu, security_parameters: report_sec_params) 71 | engine_id 72 | end 73 | 74 | def encode(pdu) 75 | @message_serializer.encode(pdu, security_parameters: @security_parameters, 76 | engine_boots: @engine_boots, 77 | engine_time: @engine_time) 78 | end 79 | 80 | def decode(stream, security_parameters: @security_parameters) 81 | return_pdu = @message_serializer.decode(stream, security_parameters: security_parameters) 82 | 83 | pdu, *args = return_pdu 84 | 85 | # usmStats: http://oidref.com/1.3.6.1.6.3.15.1.1 86 | if pdu.type == 8 87 | case pdu.varbinds.first.oid 88 | when "1.3.6.1.6.3.15.1.1.1.0" # usmStatsUnsupportedSecLevels 89 | raise Error, "Unsupported security level" 90 | when "1.3.6.1.6.3.15.1.1.2.0" # usmStatsNotInTimeWindows 91 | _, @engine_boots, @engine_time = args 92 | raise IdNotInTimeWindowError, "Not in time window" 93 | when "1.3.6.1.6.3.15.1.1.3.0" # usmStatsUnknownUserNames 94 | raise Error, "Unknown user name" 95 | when "1.3.6.1.6.3.15.1.1.4.0" # usmStatsUnknownEngineIDs 96 | raise Error, "Unknown engine ID" unless @security_parameters.must_revalidate? 97 | when "1.3.6.1.6.3.15.1.1.5.0" # usmStatsWrongDigests 98 | raise Error, "Authentication failure (incorrect password, community or key)" 99 | when "1.3.6.1.6.3.15.1.1.6.0" # usmStatsDecryptionErrors 100 | raise Error, "Decryption error" 101 | end 102 | end 103 | 104 | # validate_authentication 105 | @message_serializer.verify(stream, pdu.auth_param, pdu.security_level, security_parameters: @security_parameters) 106 | 107 | return_pdu 108 | end 109 | end 110 | end 111 | -------------------------------------------------------------------------------- /lib/netsnmp/varbind.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module NETSNMP 4 | # Abstracts the PDU variable structure into a ruby object 5 | # 6 | class Varbind 7 | using StringExtensions 8 | 9 | attr_reader :oid, :value 10 | 11 | def initialize(oid, value: nil, type: nil) 12 | @oid = OID.build(oid) 13 | @type = type 14 | @value = convert_val(value) if value 15 | end 16 | 17 | def to_s 18 | "#<#{self.class}:0x#{object_id.to_s(16)} @oid=#{@oid} @value=#{@value}>" 19 | end 20 | 21 | def to_der 22 | to_asn.to_der 23 | end 24 | 25 | def to_asn 26 | asn_oid = OID.to_asn(@oid) 27 | type = @type 28 | 29 | asn_val = if type 30 | convert_to_asn(type, @value) 31 | else 32 | case @value 33 | when String 34 | OpenSSL::ASN1::OctetString.new(@value) 35 | when Integer 36 | OpenSSL::ASN1::Integer.new(@value) 37 | when true, false 38 | OpenSSL::ASN1::Boolean.new(@value) 39 | when nil 40 | OpenSSL::ASN1::Null.new(nil) 41 | when IPAddr 42 | # @type ivar @value: IPAddr 43 | OpenSSL::ASN1::ASN1Data.new(@value.hton, 0, :APPLICATION) 44 | when Timetick 45 | # @type ivar @value: Timetick 46 | @value.to_asn 47 | else 48 | raise Error, "#{@value}: unsupported varbind type" 49 | end 50 | end 51 | OpenSSL::ASN1::Sequence.new([asn_oid, asn_val]) 52 | end 53 | 54 | def convert_val(asn_value) 55 | case asn_value 56 | when OpenSSL::ASN1::OctetString 57 | val = asn_value.value 58 | 59 | # it's kind of common in snmp, some stuff can't be converted, 60 | # like Hexa Strings. Parse them into a readable format a la netsnmp 61 | # https://github.com/net-snmp/net-snmp/blob/ed90aaaaea0d9cc6c5c5533f1863bae598d3b820/snmplib/mib.c#L650 62 | is_hex_string = val.each_char.any? { |c| !c.match?(/[[:print:]]/) && !c.match?(/[[:space:]]/) } 63 | 64 | val = HexString.new(val) if is_hex_string 65 | val 66 | when OpenSSL::ASN1::Primitive 67 | val = asn_value.value 68 | val = val.to_i if val.is_a?(OpenSSL::BN) 69 | val 70 | when OpenSSL::ASN1::ASN1Data 71 | # application data 72 | convert_application_asn(asn_value) 73 | # when OpenSSL::BN 74 | else 75 | asn_value # assume it's already primitive 76 | end 77 | end 78 | 79 | def convert_to_asn(typ, value) 80 | asn_val = value 81 | 82 | asn_type = if typ.is_a?(Symbol) 83 | case typ 84 | when :ipaddress then 0 85 | when :counter32 86 | asn_val = [value].pack("N*") 87 | asn_val = asn_val.delete_prefix("\x00") while asn_val[0] == "\x00".b && String(asn_val[1]).unpack1("B") != "1" 88 | 1 89 | when :gauge 90 | asn_val = [value].pack("N*") 91 | asn_val = asn_val.delete_prefix("\x00") while asn_val[0] == "\x00".b && String(asn_val[1]).unpack1("B") != "1" 92 | 2 93 | when :timetick 94 | # @type var value: Integer 95 | return Timetick.new(value).to_asn 96 | when :opaque then 4 97 | when :nsap then 5 98 | when :counter64 99 | # @type var value: Integer 100 | asn_val = [ 101 | (value >> 96) & 0xFFFFFFFF, 102 | (value >> 64) & 0xFFFFFFFF, 103 | (value >> 32) & 0xFFFFFFFF, 104 | value & 0xFFFFFFFF 105 | ].pack("NNNN") 106 | asn_val = asn_val.delete_prefix("\x00") while asn_val.start_with?("\x00") 107 | 6 108 | when :uinteger then 7 109 | else 110 | raise Error, "#{typ}: unsupported application type" 111 | end 112 | else 113 | typ 114 | end 115 | OpenSSL::ASN1::ASN1Data.new(asn_val, asn_type, :APPLICATION) 116 | end 117 | 118 | def convert_application_asn(asn) 119 | raise(OidNotFound, "No Such Instance currently exists at this OID") if asn.value.empty? 120 | 121 | case asn.tag 122 | when 0 # IP Address 123 | IPAddr.new_ntoh(asn.value) 124 | when 1, # ASN counter 32 125 | 2 # gauge 126 | unpack_32bit_integer(asn.value) 127 | when 3 # timeticks 128 | Timetick.new(unpack_32bit_integer(asn.value)) 129 | # when 4 # opaque 130 | # when 5 # NSAP 131 | when 6 # ASN Counter 64 132 | unpack_64bit_integer(asn.value) 133 | # when 7 # ASN UInteger 134 | end 135 | end 136 | 137 | private 138 | 139 | def unpack_32bit_integer(payload) 140 | payload.prepend("\x00") until (payload.bytesize % 4).zero? 141 | payload.unpack("N*")[-1].to_i 142 | end 143 | 144 | def unpack_64bit_integer(payload) 145 | payload.prepend("\x00") until (payload.bytesize % 16).zero? 146 | payload.unpack("NNNN").reduce(0) { |sum, elem| (sum << 32) + elem.to_i } 147 | end 148 | end 149 | end 150 | -------------------------------------------------------------------------------- /lib/netsnmp/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module NETSNMP 4 | VERSION = "0.6.4" 5 | end 6 | -------------------------------------------------------------------------------- /netsnmp.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require File.expand_path("lib/netsnmp/version", __dir__) 4 | 5 | Gem::Specification.new do |gem| 6 | gem.name = "netsnmp" 7 | gem.summary = "SNMP Client library" 8 | gem.description = <<-DESC 9 | Wraps the net-snmp core usage into idiomatic ruby. 10 | It is designed to support as many environments and concurrency frameworks as possible. 11 | DESC 12 | gem.requirements = ["net-snmp"] 13 | gem.version = NETSNMP::VERSION 14 | gem.license = "Apache-2.0" 15 | gem.authors = ["Tiago Cardoso"] 16 | gem.email = "cardoso_tiago@hotmail.com" 17 | gem.homepage = "" 18 | gem.platform = Gem::Platform::RUBY 19 | gem.metadata["allowed_push_host"] = "https://rubygems.org/" 20 | 21 | # Manifest 22 | gem.files = Dir["LICENSE.txt", "README.md", "AUTHORS", "lib/**/*.rb", "sig/**/*.rbs"] 23 | gem.require_paths = ["lib"] 24 | 25 | gem.add_runtime_dependency "parslet" 26 | gem.metadata["rubygems_mfa_required"] = "true" 27 | end 28 | -------------------------------------------------------------------------------- /sig/client.rbs: -------------------------------------------------------------------------------- 1 | module NETSNMP 2 | class Client 3 | RETRIES: Integer 4 | 5 | @retries: Integer 6 | @session: Session 7 | 8 | def get: (*untyped) -> untyped 9 | | (*untyped) { (PDU) -> void } -> untyped 10 | 11 | def get_next: (*untyped) -> untyped 12 | | (*untyped) { (PDU) -> void } -> untyped 13 | 14 | def set: (*untyped) -> untyped 15 | | (*untyped) { (PDU) -> void } -> untyped 16 | 17 | def inform: (*untyped) -> untyped 18 | | (*untyped) { (PDU) -> void } -> untyped 19 | 20 | def walk: (oid: oid) -> _Each[[oid_type, oid_value]] 21 | 22 | def close: () -> void 23 | 24 | private 25 | 26 | def initialize: (?version: snmp_version, **untyped) ?{ (instance) -> void } -> void 27 | 28 | def handle_retries: [U] { () -> U } -> U 29 | end 30 | end -------------------------------------------------------------------------------- /sig/encryption/aes.rbs: -------------------------------------------------------------------------------- 1 | module NETSNMP 2 | module Encryption 3 | class AES 4 | 5 | @priv_key: String 6 | @local: Integer 7 | 8 | def encrypt: (String decrypted_data, engine_boots: Integer, engine_time: Integer) -> [String, String] 9 | 10 | def decrypt: (String encrypted_data, salt: String, engine_boots: Integer, engine_time: Integer) -> String 11 | 12 | private 13 | 14 | def initialize: (String priv_key, ?local: Integer) -> untyped 15 | 16 | def generate_encryption_key: (Integer boots, Integer time) -> [String, String] 17 | 18 | def generate_decryption_key: (Integer boots, Integer time, String salt) -> String 19 | 20 | def aes_key: () -> String 21 | end 22 | end 23 | end -------------------------------------------------------------------------------- /sig/encryption/des.rbs: -------------------------------------------------------------------------------- 1 | module NETSNMP 2 | module Encryption 3 | class DES 4 | 5 | @priv_key: String 6 | @local: Integer 7 | 8 | def encrypt: (String decrypted_data, engine_boots: Integer, **untyped) -> [String, String] 9 | 10 | def decrypt: (String encrypted_data, salt: String, **untyped) -> String 11 | 12 | private 13 | 14 | def initialize: (String priv_key, ?local: Integer) -> untyped 15 | 16 | def generate_encryption_key: (Integer boots) -> [String, String] 17 | 18 | def generate_decryption_key: (String salt) -> String 19 | 20 | def des_key: () -> String 21 | end 22 | end 23 | end -------------------------------------------------------------------------------- /sig/errors.rbs: -------------------------------------------------------------------------------- 1 | module NETSNMP 2 | class Error < StandardError 3 | end 4 | 5 | class ConnectionFailed < Error 6 | end 7 | 8 | class AuthenticationFailed < Error 9 | end 10 | 11 | class IdNotInTimeWindowError < Error 12 | end 13 | end -------------------------------------------------------------------------------- /sig/extensions.rbs: -------------------------------------------------------------------------------- 1 | module NETSNMP 2 | module ASNExtensions 3 | ASN_COLORS: Hash[singleton(OpenSSL::ASN1::ASN1Data), Integer] 4 | end 5 | 6 | module Hexdump 7 | def self?.dump: (String data, ?width: Integer, ?in_groups_of: Integer, ?separator: String) -> String 8 | end 9 | 10 | class HexString < String 11 | end 12 | end -------------------------------------------------------------------------------- /sig/loggable.rbs: -------------------------------------------------------------------------------- 1 | module NETSNMP 2 | module Loggable 3 | DEBUG_LEVEL: Integer 4 | DEBUG: IO? 5 | COLORS: Hash[Symbol, Integer] 6 | 7 | def initialize_logger: (?debug: IO, ?debug_level: Integer, **untyped) -> void 8 | 9 | private 10 | 11 | def log: (?level: Integer) { () -> String } -> void 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /sig/message.rbs: -------------------------------------------------------------------------------- 1 | module NETSNMP 2 | class Message 3 | include Loggable 4 | 5 | PRIVNONE: OpenSSL::ASN1::OctetString 6 | MSG_MAX_SIZE: OpenSSL::ASN1::Integer 7 | MSG_SECURITY_MODEL: OpenSSL::ASN1::Integer 8 | MSG_VERSION: OpenSSL::ASN1::Integer 9 | MSG_REPORTABLE: Integer 10 | 11 | def verify: (String stream, String auth_param, Integer? security_level, security_parameters: SecurityParameters) -> void 12 | 13 | def decode: (String stream, security_parameters: SecurityParameters) -> [ScopedPDU, String, Integer, Integer] 14 | 15 | def encode: (ScopedPDU pdu, security_parameters: SecurityParameters, ?engine_boots: Integer, ?engine_time: Integer) -> String 16 | 17 | private 18 | 19 | def initialize: (**untyped options) -> void 20 | 21 | def authnone: (Symbol?) -> OpenSSL::ASN1::ASN1Data 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /sig/mib.rbs: -------------------------------------------------------------------------------- 1 | module NETSNMP 2 | module MIB 3 | type import = {ids: Array[{name: string}], name: string} | {ids: {name: string}, name: string} 4 | 5 | OIDREGEX: Regexp 6 | MIBDIRS: Array[String] 7 | PARSER: Parser 8 | 9 | TYPES: Array[String] 10 | STATIC_MIB_TO_OID: Hash[String, String] 11 | 12 | @parser_mutex: Thread::Mutex 13 | @modules_loaded: Array[String] 14 | @object_identifiers: Hash[String, String] 15 | 16 | def self?.oid: (oid identifier) -> String? 17 | 18 | def self?.load: (String mod) -> bool 19 | 20 | def self?.load_imports: (Array[import] | import | nil data) -> Hash[String, Array[String]]? 21 | 22 | def self?.load_defaults: () -> void 23 | 24 | def self?.do_load: (String mod) -> void 25 | 26 | def self?.module_loaded?: (String mod) -> bool 27 | 28 | def self?.store_oid_in_identifiers: (String name, Array[String] value, imports: Hash[String, Array[String]]?, declarations: Hash[String, Array[String]]) -> void 29 | 30 | # workaround 31 | class Parser 32 | def parse: (String data) -> Hash[:imports | :declarations, untyped?] 33 | end 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /sig/mib/parser.rbs: -------------------------------------------------------------------------------- 1 | module NETSNMP 2 | module MIB 3 | class Parser 4 | 5 | end 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /sig/netsnmp.rbs: -------------------------------------------------------------------------------- 1 | module NETSNMP 2 | VERSION: String 3 | 4 | interface _Logger 5 | def <<: (string) -> void 6 | end 7 | 8 | interface _Authenticate 9 | def reset: () -> void 10 | def <<: (string) -> void 11 | def digest: () -> String 12 | end 13 | 14 | interface _ToAsn 15 | def to_asn: () -> OpenSSL::ASN1::ASN1Data 16 | end 17 | 18 | type snmp_version = 0 | 1 | 3 | :v1 | :v2c | :v3 | String | nil 19 | end -------------------------------------------------------------------------------- /sig/oid.rbs: -------------------------------------------------------------------------------- 1 | module NETSNMP 2 | type oid = String | Array[_ToS] 3 | 4 | type oid_type = Integer | :ipaddress | :counter32 | :gauge | :timetick | :opaque | :nsap | :counter64 | :uinteger 5 | 6 | type oid_value = String | Integer | Timetick | true | false | nil | IPAddr 7 | 8 | # type oid_options = {} 9 | 10 | module OID 11 | OIDREGEX: Regexp 12 | 13 | def self?.build: (oid) -> String 14 | 15 | def self?.to_asn: (String oid) -> OpenSSL::ASN1::ObjectId 16 | 17 | def self?.parent?: (String oid, String oid) -> bool 18 | end 19 | end -------------------------------------------------------------------------------- /sig/pdu.rbs: -------------------------------------------------------------------------------- 1 | module NETSNMP 2 | type pdu_type = :get | :getnext | :set | :inform | :trap | :response 3 | 4 | class PDU 5 | 6 | MAXREQUESTID: Integer 7 | 8 | attr_reader varbinds: Array[Varbind] 9 | attr_reader type: Integer 10 | attr_reader version: Integer 11 | attr_reader community: String? 12 | attr_reader request_id: Integer 13 | 14 | @error_index: Integer 15 | @error_status: Integer 16 | 17 | def self.decode: (String | OpenSSL::ASN1::ASN1Data der, **untyped vars) -> instance 18 | 19 | def self.build: (pdu_type, **untyped) -> instance 20 | 21 | 22 | def to_asn: () -> OpenSSL::ASN1::ASN1Data 23 | 24 | def to_der: () -> String 25 | 26 | def to_hex: () -> String 27 | 28 | def add_varbind: (oid: String, **untyped varbind_options) -> void 29 | alias << add_varbind 30 | 31 | private 32 | 33 | def initialize: ( 34 | type: Integer, 35 | version: Integer, 36 | community: String?, 37 | ?request_id: Integer, 38 | ?error_status: Integer, 39 | ?error_index: Integer, 40 | ?varbinds: Array[varbind_options] 41 | ) -> void 42 | 43 | def encode_headers_asn: () -> [OpenSSL::ASN1::Integer, OpenSSL::ASN1::OctetString] 44 | 45 | def check_error_status: (Integer) -> void 46 | end 47 | end -------------------------------------------------------------------------------- /sig/scoped_pdu.rbs: -------------------------------------------------------------------------------- 1 | module NETSNMP 2 | class ScopedPDU < PDU 3 | attr_reader engine_id: String? 4 | attr_reader auth_param: String 5 | attr_reader security_level: Integer 6 | 7 | @context: String? 8 | 9 | private 10 | 11 | def initialize: ( 12 | type: Integer, 13 | ?auth_param: String, 14 | ?security_level: Integer, 15 | ?engine_id: String, 16 | ?context: String, 17 | **untyped 18 | ) -> void 19 | 20 | def encode_headers_asn: () -> [OpenSSL::ASN1::OctetString, OpenSSL::ASN1::OctetString] 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /sig/security_parameters.rbs: -------------------------------------------------------------------------------- 1 | module NETSNMP 2 | class SecurityParameters 3 | include Loggable 4 | 5 | type security_level = 0 | 1 | 3 6 | 7 | IPAD: String 8 | OPAD: String 9 | TIMELINESS_THRESHOLD: Integer 10 | 11 | @auth_pass_key: String 12 | @priv_pass_key: String 13 | @priv_protocol: Symbol? 14 | @digest: OpenSSL::Digest 15 | @encryption: (Encryption::AES | Encryption::DES)? 16 | @timeliness: Integer 17 | 18 | attr_reader security_level: Integer 19 | attr_reader username: String 20 | attr_reader engine_id: String 21 | attr_reader auth_protocol: Symbol? 22 | 23 | def engine_id=: (String id) -> void 24 | 25 | def encode: (PDU pdu, salt: OpenSSL::ASN1::ASN1Data, engine_time: Integer, engine_boots: Integer) -> [OpenSSL::ASN1::ASN1Data, OpenSSL::ASN1::ASN1Data] 26 | 27 | def decode: (OpenSSL::ASN1::ASN1Data | String der, salt: String, engine_time: Integer, engine_boots: Integer, ?security_level: Integer) -> OpenSSL::ASN1::ASN1Data 28 | 29 | def sign: (String message) -> String? 30 | 31 | def verify: (String stream, String salt, ?security_level: Integer?) -> void 32 | 33 | def must_revalidate?: () -> bool 34 | 35 | private 36 | 37 | def initialize: ( 38 | username: String, 39 | ?engine_id: String, 40 | ?security_level: Integer | :noauth | :auth_no_priv | :auth_priv | nil, 41 | ?auth_protocol: Symbol | nil, 42 | ?auth_password: String | nil, 43 | ?priv_protocol: Symbol | nil, 44 | ?priv_password: String | nil, 45 | **untyped options 46 | ) -> void 47 | 48 | @auth_key: String 49 | def auth_key: () -> String 50 | 51 | @priv_key: String 52 | def priv_key: () -> String 53 | 54 | def localize_key: (String key) -> String 55 | 56 | def passkey: (String password) -> String 57 | 58 | def digest: () -> OpenSSL::Digest 59 | 60 | def encryption: () -> (Encryption::AES | Encryption::DES)? 61 | 62 | def authorizable?: () -> bool 63 | end 64 | end 65 | -------------------------------------------------------------------------------- /sig/session.rbs: -------------------------------------------------------------------------------- 1 | module NETSNMP 2 | class Session 3 | include Loggable 4 | 5 | TIMEOUT: Integer 6 | 7 | @transport: _Transport 8 | @proxy: bool? 9 | @version: 0 | 1 | 3 10 | @community: String? 11 | 12 | def close: () -> void 13 | 14 | def build_pdu: (pdu_type, *untyped) -> PDU 15 | 16 | def send: (PDU) -> PDU 17 | 18 | private 19 | 20 | def initialize: (?version: snmp_version, ?community: String, **untyped) -> untyped 21 | 22 | def validate: (?host: String?, ?port: Integer, ?proxy: _Transport, ?timeout: Integer, **untyped) -> void 23 | 24 | class Transport 25 | MAXPDUSIZE: Integer 26 | 27 | @socket: UDPSocket 28 | @destaddr: String 29 | @timeout: Integer 30 | 31 | def close: () -> void 32 | 33 | def send: (String pdu_der) -> String 34 | 35 | def write: (String data) -> void 36 | 37 | def recv: (?Integer size) -> String 38 | 39 | private 40 | 41 | def initialize: (String host, Integer port, timeout: Integer) -> void 42 | 43 | def perform_io: [U] { () -> U } -> U 44 | 45 | def wait: (:wait_readable | :wait_writable) -> void 46 | end 47 | end 48 | 49 | interface _Transport 50 | def close: () -> void 51 | def send: (String payload) -> String 52 | end 53 | end -------------------------------------------------------------------------------- /sig/timeticks.rbs: -------------------------------------------------------------------------------- 1 | module NETSNMP 2 | class Timetick < Numeric 3 | @ticks: Integer 4 | 5 | def to_i: () -> Integer 6 | 7 | def to_asn: () -> OpenSSL::ASN1::ASN1Data 8 | 9 | def coerce: (Integer ticks) -> [instance, instance] 10 | 11 | def <=>: (_ToI other) -> Integer? 12 | def +: (_ToI other) -> instance 13 | def -: (_ToI other) -> instance 14 | def *: (_ToI other) -> instance 15 | def /: (_ToI other) -> instance 16 | 17 | private 18 | 19 | def initialize: (Integer ticks) -> untyped 20 | 21 | def days_since: () -> Rational 22 | def hours_since: (Rational days) -> Rational 23 | def minutes_since: (Rational hours) -> Rational 24 | def milliseconds_since: (Rational minutes) -> Rational 25 | end 26 | end -------------------------------------------------------------------------------- /sig/v3_session.rbs: -------------------------------------------------------------------------------- 1 | module NETSNMP 2 | class V3Session < Session 3 | 4 | def build_pdu: (pdu_type, *untyped) -> ScopedPDU 5 | 6 | def send: (ScopedPDU pdu) -> (PDU | [PDU, String, Integer, Integer]) 7 | 8 | private 9 | 10 | def validate: (**untyped options) -> void 11 | 12 | def encode: (ScopedPDU) -> String 13 | 14 | def initialize: (?context: String, **untyped) -> untyped 15 | 16 | def security_parameters: () -> SecurityParameters 17 | 18 | def probe_for_engine: () -> String 19 | 20 | def decode: (String, ?security_parameters: SecurityParameters) -> [PDU, String, Integer, Integer] 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /sig/varbind.rbs: -------------------------------------------------------------------------------- 1 | module NETSNMP 2 | type varbind_options = {oid: String, value: Varbind::varbind_value } 3 | | {oid: String, value: Varbind::varbind_value, type: oid_type} 4 | 5 | class Varbind 6 | 7 | type varbind_value = OpenSSL::ASN1::OctetString | OpenSSL::ASN1::Primitive | OpenSSL::ASN1::ASN1Data | oid_value 8 | 9 | attr_reader oid: String 10 | attr_reader value: oid_value 11 | 12 | @type: oid_type? 13 | 14 | def to_asn: () -> OpenSSL::ASN1::Sequence 15 | 16 | def to_der: () -> String 17 | 18 | def convert_val: (varbind_value) -> oid_value 19 | 20 | 21 | def convert_to_asn: (:ip_address, Integer) -> OpenSSL::ASN1::ASN1Data 22 | | (:counter32, Integer) -> OpenSSL::ASN1::ASN1Data 23 | | (:counter64, Integer) -> OpenSSL::ASN1::ASN1Data 24 | | (:uinteger, Integer) -> OpenSSL::ASN1::ASN1Data 25 | | (:gauge, Integer) -> OpenSSL::ASN1::ASN1Data 26 | | (:timetick, Integer) -> OpenSSL::ASN1::ASN1Data 27 | | (:opaque, Integer) -> OpenSSL::ASN1::ASN1Data 28 | | (:nsap, Integer) -> OpenSSL::ASN1::ASN1Data 29 | | (oid_type, oid_value) -> OpenSSL::ASN1::ASN1Data 30 | 31 | def convert_application_asn: (OpenSSL::ASN1::ASN1Data asn) -> oid_value 32 | 33 | private 34 | 35 | def initialize: (oid, ?value: varbind_value, ?type: oid_type) -> untyped 36 | 37 | def unpack_32bit_integer: (String) -> Integer 38 | 39 | def unpack_64bit_integer: (String) -> Integer 40 | end 41 | end -------------------------------------------------------------------------------- /spec/client_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "support/request_examples" 4 | 5 | RSpec.describe NETSNMP::Client do 6 | let(:host) { SNMPHOST } 7 | 8 | let(:device_options) do 9 | { 10 | peername: SNMPHOST, 11 | port: SNMPPORT 12 | } 13 | end 14 | describe "v1" do 15 | it_behaves_like "an snmp client" do 16 | let(:protocol_options) do 17 | { 18 | version: "1", 19 | community: "public" 20 | } 21 | end 22 | let(:get_oid) { "1.3.6.1.2.1.1.5.0" } 23 | let(:next_oid) { "1.3.6.1.2.1.1.6.0" } 24 | let(:walk_oid) { "1.3.6.1.2.1.1" } 25 | let(:set_oid) { "sysUpTime.0" } # sysUpTimeInstance 26 | let(:get_result) { "zeus.snmplabs.com (you can change this!)" } 27 | let(:next_result) { "San Francisco, California, United States" } 28 | let(:walk_result) do 29 | { 30 | "1.3.6.1.2.1.1.1.0" => "Linux zeus 4.8.6.5-smp #2 SMP Sun Nov 13 14:58:11 CDT 2016 i686", 31 | "1.3.6.1.2.1.1.2.0" => "1.3.6.1.4.1.8072.3.2.10", 32 | "1.3.6.1.2.1.1.3.0" => /Timeticks: \(\d+\) \d+ days, \d+:\d+:\d+\.\d+/, 33 | "1.3.6.1.2.1.1.4.0" => "SNMP Laboratories, info@snmplabs.com", 34 | "1.3.6.1.2.1.1.5.0" => "zeus.snmplabs.com (you can change this!)", 35 | "1.3.6.1.2.1.1.6.0" => "San Francisco, California, United States", 36 | "1.3.6.1.2.1.1.7.0" => "72", 37 | "1.3.6.1.2.1.1.8.0" => /Timeticks: \(\d+\) \d+ days, \d+:\d+:\d+\.\d+/ 38 | } 39 | end 40 | let(:set_oid_result) { 43 } 41 | end 42 | end 43 | describe "v2" do 44 | it_behaves_like "an snmp client" do 45 | let(:protocol_options) do 46 | { 47 | version: "2c", 48 | community: "public" 49 | } 50 | end 51 | let(:get_oid) { "sysName.0" } 52 | let(:next_oid) { "1.3.6.1.2.1.1.6.0" } 53 | let(:walk_oid) { "system" } 54 | let(:set_oid) { "sysUpTime.0" } 55 | let(:get_result) { "zeus.snmplabs.com (you can change this!)" } 56 | let(:next_result) { "San Francisco, California, United States" } 57 | let(:walk_result) do 58 | { 59 | "1.3.6.1.2.1.1.1.0" => "Linux zeus 4.8.6.5-smp #2 SMP Sun Nov 13 14:58:11 CDT 2016 i686", 60 | "1.3.6.1.2.1.1.2.0" => "1.3.6.1.4.1.8072.3.2.10", 61 | "1.3.6.1.2.1.1.3.0" => /Timeticks: \(\d+\) \d+ days, \d+:\d+:\d+\.\d+/, 62 | "1.3.6.1.2.1.1.4.0" => "SNMP Laboratories, info@snmplabs.com", 63 | "1.3.6.1.2.1.1.5.0" => "zeus.snmplabs.com (you can change this!)", 64 | "1.3.6.1.2.1.1.6.0" => "San Francisco, California, United States", 65 | "1.3.6.1.2.1.1.7.0" => "72", 66 | "1.3.6.1.2.1.1.8.0" => /Timeticks: \(\d+\) \d+ days, \d+:\d+:\d+\.\d+/ 67 | } 68 | end 69 | let(:set_oid_result) { 43 } 70 | 71 | context "when the returned value is a hex-string" do 72 | let(:protocol_options) do 73 | { 74 | version: "2c", 75 | community: "foreignformats/winxp1" 76 | } 77 | end 78 | let(:hex_get_oid) { "1.3.6.1.2.1.25.3.7.1.3.10.1" } 79 | let(:hex_get_result) { "\x01\x00\x00\x00" } 80 | let(:hex_get_output) { "01 00 00 00" } 81 | let(:value) { subject.get(oid: hex_get_oid) } 82 | 83 | it "returns the string, which outputs the hex-representation" do 84 | expect(value).to eq(hex_get_result) 85 | expect(value.inspect).to include(hex_get_output) 86 | end 87 | end 88 | end 89 | end 90 | 91 | describe "v3" do 92 | let(:extra_options) { {} } 93 | let(:version_options) do 94 | { 95 | version: "3", 96 | context: "a172334d7d97871b72241397f713fa12" 97 | } 98 | end 99 | let(:get_oid) { "sysName.0" } 100 | let(:next_oid) { "1.3.6.1.2.1.1.6.0" } 101 | let(:set_oid) { "sysUpTime.0" } # sysUpTimeInstance 102 | let(:walk_oid) { "1.3.6.1.2.1.1.9.1.3" } 103 | let(:get_result) { "tt" } 104 | let(:next_result) { "KK12 (edit /etc/snmp/snmpd.conf)" } 105 | let(:walk_result) do 106 | { 107 | "1.3.6.1.2.1.1.9.1.3.1" => "The SNMP Management Architecture MIB.", 108 | "1.3.6.1.2.1.1.9.1.3.2" => "The MIB for Message Processing and Dispatching.", 109 | "1.3.6.1.2.1.1.9.1.3.3" => "The management information definitions for the SNMP User-based Security Model.", 110 | "1.3.6.1.2.1.1.9.1.3.4" => "The MIB module for SNMPv2 entities", 111 | "1.3.6.1.2.1.1.9.1.3.5" => "The MIB module for managing TCP implementations", 112 | "1.3.6.1.2.1.1.9.1.3.6" => "The MIB module for managing IP and ICMP implementations", 113 | "1.3.6.1.2.1.1.9.1.3.7" => "The MIB module for managing UDP implementations", 114 | "1.3.6.1.2.1.1.9.1.3.8" => "View-based Access Control Model for SNMP." 115 | } 116 | end 117 | let(:set_oid_result) { 43 } 118 | context "with a no auth no priv policy" do 119 | let(:user_options) { { username: "unsafe", security_level: :noauth } } 120 | it_behaves_like "an snmp client" do 121 | let(:protocol_options) { version_options.merge(user_options).merge(extra_options) } 122 | # why is this here? that variation/notification community causes the simulagtor to go down 123 | # until I find the origin of the issue and patched it with an appropriated community, this 124 | # is here so that I test the set call at least once, although I'm sure it'll work always 125 | # for v3 126 | describe "#set" do 127 | let(:extra_options) { { context: "0886e1397d572377c17c15036a1e6c66" } } 128 | it "updates the value of the oid" do 129 | prev_value = subject.get(oid: set_oid) 130 | expect(prev_value).to be_a(Integer) 131 | 132 | # without type 133 | subject.set(oid: set_oid, value: set_oid_result) 134 | expect(subject.get(oid: set_oid)).to eq(set_oid_result) 135 | 136 | subject.set(oid: set_oid, value: prev_value) 137 | end 138 | end 139 | end 140 | end 141 | context "with an only auth policy" do 142 | context "speaking md5" do 143 | let(:user_options) do 144 | { username: "authmd5", security_level: :auth_no_priv, 145 | auth_password: "maplesyrup", auth_protocol: :md5 } 146 | end 147 | it_behaves_like "an snmp client" do 148 | let(:protocol_options) { version_options.merge(user_options).merge(extra_options) } 149 | end 150 | end 151 | context "speaking sha" do 152 | let(:user_options) do 153 | { username: "authsha", security_level: :auth_no_priv, 154 | auth_password: "maplesyrup", auth_protocol: :sha } 155 | end 156 | it_behaves_like "an snmp client" do 157 | let(:protocol_options) { version_options.merge(user_options).merge(extra_options) } 158 | end 159 | end 160 | context "speaking sha256" do 161 | let(:user_options) do 162 | { username: "authsha256", security_level: :auth_no_priv, 163 | auth_password: "maplesyrup", auth_protocol: :sha256 } 164 | end 165 | it_behaves_like "an snmp client" do 166 | let(:protocol_options) { version_options.merge(user_options).merge(extra_options) } 167 | end 168 | end 169 | end 170 | context "with an auth priv policy" do 171 | context "auth in md5, encrypting in des" do 172 | let(:user_options) do 173 | { username: "authprivmd5des", auth_password: "maplesyrup", 174 | auth_protocol: :md5, priv_password: "maplesyrup", 175 | priv_protocol: :des } 176 | end 177 | it_behaves_like "an snmp client" do 178 | let(:protocol_options) { version_options.merge(user_options).merge(extra_options) } 179 | end 180 | end 181 | context "auth in sha, encrypting in des" do 182 | let(:user_options) do 183 | { username: "authprivshades", auth_password: "maplesyrup", 184 | auth_protocol: :sha, priv_password: "maplesyrup", 185 | priv_protocol: :des } 186 | end 187 | it_behaves_like "an snmp client" do 188 | let(:protocol_options) { version_options.merge(user_options).merge(extra_options) } 189 | 190 | context "with wrong auth password and wrong encrypting password" do 191 | let(:user_options) do 192 | { username: "authprivmd5des", auth_password: "wrongpassword", 193 | auth_protocol: :md5, priv_password: "maplesyrup", 194 | priv_protocol: :des } 195 | end 196 | let(:protocol_options) { version_options.merge(user_options).merge(extra_options) } 197 | it "raises authentication error" do 198 | expect { subject.get(oid: get_oid) }.to raise_error(NETSNMP::Error, "Authentication failure (incorrect password, community or key)") 199 | end 200 | end 201 | end 202 | end 203 | 204 | context "auth in md5, encrypting in aes" do 205 | let(:user_options) do 206 | { username: "authprivmd5aes", auth_password: "maplesyrup", 207 | auth_protocol: :md5, priv_password: "maplesyrup", 208 | priv_protocol: :aes } 209 | end 210 | it_behaves_like "an snmp client" do 211 | let(:protocol_options) { version_options.merge(user_options).merge(extra_options) } 212 | end 213 | end 214 | context "auth in sha, encrypting in aes" do 215 | let(:user_options) do 216 | { username: "authprivshaaes", auth_password: "maplesyrup", 217 | auth_protocol: :sha, priv_password: "maplesyrup", 218 | priv_protocol: :aes } 219 | end 220 | it_behaves_like "an snmp client" do 221 | let(:protocol_options) { version_options.merge(user_options).merge(extra_options) } 222 | end 223 | end 224 | end 225 | end 226 | end 227 | -------------------------------------------------------------------------------- /spec/handlers/celluloid_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | begin 4 | require "celluloid/io" 5 | rescue LoadError # rubocop:disable Lint/SuppressedException 6 | else 7 | require_relative "../support/request_examples" 8 | require_relative "../support/celluloid" 9 | 10 | RSpec.describe "with cellulloid", type: :celluloid do 11 | include CelluloidHelpers 12 | let(:user_options) do 13 | { username: "authprivmd5des", auth_password: "maplesyrup", 14 | auth_protocol: :md5, priv_password: "maplesyrup", 15 | priv_protocol: :des } 16 | end 17 | 18 | let(:get_oid) { "1.3.6.1.2.1.1.5.0" } 19 | let(:next_oid) { "1.3.6.1.2.1.1.6.0" } 20 | let(:set_oid) { "1.3.6.1.2.1.1.3.0" } # sysUpTimeInstance 21 | let(:walk_oid) { "1.3.6.1.2.1.1.9.1.3" } 22 | let(:get_result) { "tt" } 23 | let(:next_result) { "KK12 (edit /etc/snmp/snmpd.conf)" } 24 | let(:walk_result) do 25 | { 26 | "1.3.6.1.2.1.1.9.1.3.1" => "The SNMP Management Architecture MIB.", 27 | "1.3.6.1.2.1.1.9.1.3.2" => "The MIB for Message Processing and Dispatching.", 28 | "1.3.6.1.2.1.1.9.1.3.3" => "The management information definitions for the SNMP User-based Security Model.", 29 | "1.3.6.1.2.1.1.9.1.3.4" => "The MIB module for SNMPv2 entities", 30 | "1.3.6.1.2.1.1.9.1.3.5" => "The MIB module for managing TCP implementations", 31 | "1.3.6.1.2.1.1.9.1.3.6" => "The MIB module for managing IP and ICMP implementations", 32 | "1.3.6.1.2.1.1.9.1.3.7" => "The MIB module for managing UDP implementations", 33 | "1.3.6.1.2.1.1.9.1.3.8" => "View-based Access Control Model for SNMP." 34 | } 35 | end 36 | 37 | before(:all) { Celluloid.boot } 38 | around(:each) do |example| 39 | within_io_actor { example.run } 40 | end 41 | let(:proxy) { CelluloidHelpers::Proxy.new(SNMPHOST, SNMPPORT) } 42 | after(:each) { proxy.close } 43 | 44 | it_behaves_like "an snmp client" do 45 | subject { NETSNMP::Client.new(**options) } 46 | let(:device_options) { { proxy: proxy } } 47 | let(:protocol_options) { user_options } 48 | let(:extra_options) { { version: 3, context: "a172334d7d97871b72241397f713fa12" } } 49 | end 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /spec/mib_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe NETSNMP::MIB do 4 | describe ".oid" do 5 | it { expect(described_class.oid("1.2.3.4")).to eq("1.2.3.4") } 6 | it { expect(described_class.oid("ifTable")).to eq("1.3.6.1.2.1.2.2") } 7 | it { expect(described_class.oid("sysDescr.0")).to eq("1.3.6.1.2.1.1.1.0") } 8 | it { expect(described_class.oid("ifTable.1.23")).to eq("1.3.6.1.2.1.2.2.1.23") } 9 | it { expect(described_class.oid("IF-MIB::ifTable.1.23")).to eq("1.3.6.1.2.1.2.2.1.23") } 10 | it { expect(described_class.oid("IFMIB::ifTable.1.23")).to be_nil } 11 | it { expect(described_class.oid("IF-MIB::")).to be_nil } 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /spec/oid_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe NETSNMP::OID do 4 | # let(:code) { "SNMPv2-MIB::sysDescr.0" } 5 | let(:code) { "1.3.6.1.2.1.1.1.0" } 6 | subject { described_class.build(code) } 7 | 8 | describe ".build" do 9 | it { expect(described_class.build([1, 3, 6, 1, 2, 1, 1, 1, 0]).to_s).to eq(code) } 10 | it { expect(described_class.build(".#{code}").to_s).to eq(code) } 11 | it { expect { described_class.build("blablabla") }.to raise_error(NETSNMP::Error) } 12 | end 13 | 14 | describe ".to_asn" do 15 | it { expect(described_class.to_asn(subject).to_der).to eq("\x06\b+\x06\x01\x02\x01\x01\x01\x00".b) } 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /spec/pdu_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe NETSNMP::PDU do 4 | let(:get_request_oid) { ".1.3.6.1.2.1.1.1.0" } 5 | let(:encoded_get_pdu) do 6 | "0'\002\001\000\004\006public\240\032\002\002?*\002\001\000\002\001\0000\0160\f\006\b+\006\001\002\001\001\001\000\005\000" 7 | end 8 | let(:encoded_response_pdu) do 9 | "0+\002\001\000\004\006public\242\036\002\002'\017\002\001\000\002\001\0000\0220\020\006\b+\006\001\002\001\001\001\000\004\004test" 10 | end 11 | 12 | describe "#to_der" do 13 | let(:pdu_get) do 14 | described_class.build(:get, version: 0, 15 | community: "public", 16 | request_id: 16170) 17 | end 18 | 19 | context "v1" do 20 | before { pdu_get.add_varbind(oid: get_request_oid) } 21 | it { expect(pdu_get.to_der).to eq(encoded_get_pdu.b) } 22 | end 23 | end 24 | 25 | describe "#decoding pdus" do 26 | describe "v1" do 27 | let(:pdu_response) { described_class.decode(encoded_response_pdu) } 28 | it { expect(pdu_response.version).to be(0) } 29 | it { expect(pdu_response.community).to eq("public") } 30 | it { expect(pdu_response.request_id).to be(9999) } 31 | 32 | it { expect(pdu_response.varbinds.length).to be(1) } 33 | it { expect(pdu_response.varbinds[0].oid).to eq("1.3.6.1.2.1.1.1.0") } 34 | it { expect(pdu_response.varbinds[0].value).to eq("test") } 35 | end 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /spec/security_parameters_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # FROM https://tools.ietf.org/html/rfc3414#appendix-A.2.1 4 | RSpec.describe NETSNMP::SecurityParameters do 5 | let(:engine_id) { "\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x02".b } 6 | let(:password) { "maplesyrup" } 7 | describe "#passkey" do 8 | context "md5" do 9 | subject { described_class.new(security_level: :auth_no_priv, auth_protocol: :md5, username: "username", engine_id: engine_id, auth_password: "maplesyrup") } 10 | it { expect(subject.send(:passkey, password)).to eq("\x9f\xaf\x32\x83\x88\x4e\x92\x83\x4e\xbc\x98\x47\xd8\xed\xd9\x63".b) } 11 | end 12 | context "sha" do 13 | subject { described_class.new(security_level: :auth_priv, auth_protocol: :sha, username: "username", engine_id: engine_id, auth_password: "maplesyrup", priv_password: "maplesyrup") } 14 | it { expect(subject.send(:passkey, password).b).to eq("\x9f\xb5\xcc\x03\x81\x49\x7b\x37\x93\x52\x89\x39\xff\x78\x8d\x5d\x79\x14\x52\x11".b) } 15 | end 16 | context "sha256" do 17 | subject do 18 | described_class.new(security_level: :auth_priv, auth_protocol: :sha256, username: "username", engine_id: engine_id, auth_password: "maplesyrup", priv_password: "maplesyrup") 19 | end 20 | 21 | it { expect(subject.send(:passkey, password).b).to eq("\xABQ\x01M\x1E\a\x7F`\x17\xDF+\x12\xBE\xE5\xF5\xAAr\x991w\xE9\xBBV\x9CM\xFFZL\xA0\xB4\xAF\xAC".b) } 22 | end 23 | end 24 | 25 | describe "keys" do 26 | let(:md5_sec) do 27 | described_class.new(security_level: :auth_priv, 28 | auth_protocol: :md5, 29 | priv_protocol: :des, 30 | username: "username", 31 | auth_password: password, 32 | priv_password: password, 33 | engine_id: engine_id) 34 | end 35 | let(:sha_sec) do 36 | described_class.new(security_level: :auth_priv, 37 | auth_protocol: :sha, 38 | priv_protocol: :des, 39 | username: "username", 40 | auth_password: password, 41 | priv_password: password, 42 | engine_id: engine_id) 43 | end 44 | let(:sha256_sec) do 45 | described_class.new(security_level: :auth_priv, 46 | auth_protocol: :sha256, 47 | priv_protocol: :des, 48 | username: "username", 49 | auth_password: password, 50 | priv_password: password, 51 | engine_id: engine_id) 52 | end 53 | it do 54 | expect(md5_sec.send(:auth_key)).to eq("\x52\x6f\x5e\xed\x9f\xcc\xe2\x6f\x89\x64\xc2\x93\x07\x87\xd8\x2b".b) 55 | expect(md5_sec.send(:priv_key)).to eq("\x52\x6f\x5e\xed\x9f\xcc\xe2\x6f\x89\x64\xc2\x93\x07\x87\xd8\x2b".b) 56 | expect(sha_sec.send(:auth_key)).to eq("\x66\x95\xfe\xbc\x92\x88\xe3\x62\x82\x23\x5f\xc7\x15\x1f\x12\x84\x97\xb3\x8f\x3f".b) 57 | expect(sha_sec.send(:priv_key)).to eq("\x66\x95\xfe\xbc\x92\x88\xe3\x62\x82\x23\x5f\xc7\x15\x1f\x12\x84\x97\xb3\x8f\x3f".b) 58 | expect(sha256_sec.send(:auth_key)).to eq("\x89\x82\xE0\xE5I\xE8f\xDB6\x1Akb]\x84\xCC\xCC\x11\x16-E>\xE8\xCE:dE\xC2\xD6wo\x0F\x8B".b) 59 | expect(sha256_sec.send(:priv_key)).to eq("\x89\x82\xE0\xE5I\xE8f\xDB6\x1Akb]\x84\xCC\xCC\x11\x16-E>\xE8\xCE:dE\xC2\xD6wo\x0F\x8B".b) 60 | end 61 | end 62 | 63 | context "#must_revalidate?" do 64 | let(:security_options) do 65 | { username: "authprivmd5des", auth_password: "maplesyrup", 66 | auth_protocol: :md5, priv_password: "maplesyrup", 67 | priv_protocol: :des, security_level: :auth_priv } 68 | end 69 | subject { described_class.new(**security_options) } 70 | context "for v3" do 71 | context "when initialized" do 72 | it { expect(subject.must_revalidate?).to be_truthy } 73 | end 74 | context "when given a new engine id" do 75 | before { subject.engine_id = "NEWENGINE" } 76 | it { expect(subject.must_revalidate?).to be_falsy } 77 | context "when limit surpasses" do 78 | before do 79 | subject.instance_variable_set(:@timeliness, Process.clock_gettime(Process::CLOCK_MONOTONIC, :second) - 150) 80 | end 81 | it { expect(subject.must_revalidate?).to be_truthy } 82 | context "when given a new engine id" do 83 | before { subject.engine_id = "UPDATEDENGINE" } 84 | it { expect(subject.must_revalidate?).to be_falsy } 85 | end 86 | end 87 | end 88 | end 89 | end 90 | end 91 | -------------------------------------------------------------------------------- /spec/session_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe NETSNMP::Session do 4 | let(:host) { SNMPHOST } 5 | let(:options) do 6 | { 7 | version: "2c", 8 | context: "public", 9 | port: SNMPPORT 10 | } 11 | end 12 | subject { described_class.new(host: host, **options) } 13 | after { subject.close } 14 | end 15 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | GC.auto_compact = true if GC.respond_to?(:auto_compact=) 4 | 5 | if ENV.key?("CI") 6 | require "simplecov" 7 | SimpleCov.command_name "#{RUBY_ENGINE}-#{RUBY_VERSION}" 8 | SimpleCov.coverage_dir "coverage/#{RUBY_ENGINE}-#{RUBY_VERSION}" 9 | end 10 | 11 | if defined?(SimpleCov) 12 | SimpleCov.start do 13 | add_filter ".bundle" 14 | add_filter "/spec/" 15 | end 16 | end 17 | 18 | require "bundler/setup" 19 | Bundler.require(:default, :test) 20 | 21 | require "netsnmp" 22 | 23 | SNMPPORT = ENV.fetch("SNMP_PORT", 1161).to_i 24 | SNMPHOST = ENV.fetch("SNMP_HOST", "localhost") 25 | 26 | # This file was generated by the `rspec --init` command. Conventionally, all 27 | # specs live under a `spec` directory, which RSpec adds to the `$LOAD_PATH`. 28 | # The generated `.rspec` file contains `--require spec_helper` which will cause 29 | # this file to always be loaded, without a need to explicitly require it in any 30 | # files. 31 | # 32 | # Given that it is always loaded, you are encouraged to keep this file as 33 | # light-weight as possible. Requiring heavyweight dependencies from this file 34 | # will add to the boot time of your test suite on EVERY test run, even for an 35 | # individual file that may not need all of that loaded. Instead, consider making 36 | # a separate helper file that requires the additional dependencies and performs 37 | # the additional setup, and require it from the spec files that actually need 38 | # it. 39 | # 40 | # The `.rspec` file also contains a few flags that are not defaults but that 41 | # users commonly want. 42 | # 43 | # See http://rubydoc.info/gems/rspec-core/RSpec/Core/Configuration 44 | RSpec.configure do |config| 45 | # rspec-expectations config goes here. You can use an alternate 46 | # assertion/expectation library such as wrong or the stdlib/minitest 47 | # assertions if you prefer. 48 | config.expect_with :rspec do |expectations| 49 | # This option will default to `true` in RSpec 4. It makes the `description` 50 | # and `failure_message` of custom matchers include text for helper methods 51 | # defined using `chain`, e.g.: 52 | # be_bigger_than(2).and_smaller_than(4).description 53 | # # => "be bigger than 2 and smaller than 4" 54 | # ...rather than: 55 | # # => "be bigger than 2" 56 | expectations.include_chain_clauses_in_custom_matcher_descriptions = true 57 | end 58 | 59 | # rspec-mocks config goes here. You can use an alternate test double 60 | # library (such as bogus or mocha) by changing the `mock_with` option here. 61 | config.mock_with :rspec do |mocks| 62 | # Prevents you from mocking or stubbing a method that does not exist on 63 | # a real object. This is generally recommended, and will default to 64 | # `true` in RSpec 4. 65 | mocks.verify_partial_doubles = true 66 | end 67 | end 68 | -------------------------------------------------------------------------------- /spec/support/celluloid.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Copied from celluloid-io spec helpers 4 | module CelluloidHelpers 5 | class WrapperActor 6 | include ::Celluloid::IO 7 | execute_block_on_receiver :wrap 8 | 9 | def wrap 10 | yield 11 | end 12 | end 13 | 14 | def with_wrapper_actor 15 | WrapperActor.new 16 | end 17 | 18 | def within_io_actor(&block) 19 | actor = WrapperActor.new 20 | actor.wrap(&block) 21 | ensure 22 | begin 23 | actor.terminate if actor.alive? 24 | rescue StandardError 25 | nil 26 | end 27 | end 28 | 29 | class Proxy 30 | MAXPDUSIZE = 0xffff + 1 31 | 32 | def initialize(host, port) 33 | @socket = Celluloid::IO::UDPSocket.new 34 | @socket.connect(host, port) 35 | @timeout = 2 36 | end 37 | 38 | def send(payload) 39 | @socket.send(payload, 0) 40 | recv 41 | end 42 | 43 | def recv(bytesize = MAXPDUSIZE) 44 | Celluloid.timeout(@timeout) do 45 | datagram, = @socket.recvfrom(bytesize) 46 | datagram 47 | end 48 | rescue Celluloid::TaskTimeout 49 | raise Timeout::Error, "Timeout after #{@timeout} seconds" 50 | end 51 | 52 | def close 53 | @socket.close 54 | end 55 | end 56 | end 57 | -------------------------------------------------------------------------------- /spec/support/request_examples.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.shared_examples "an snmp client" do 4 | let(:device_options) do 5 | { 6 | host: SNMPHOST, 7 | port: SNMPPORT 8 | } 9 | end 10 | let(:protocol_options) { {} } 11 | let(:extra_options) { {} } 12 | let(:options) { protocol_options.merge(device_options).merge(extra_options) } 13 | 14 | subject { described_class.new(**options) } 15 | 16 | describe "#get" do 17 | let(:value) { subject.get(oid: get_oid) } 18 | it "fetches the varbinds for a given oid" do 19 | expect(value).to eq(get_result) 20 | end 21 | context "with multiple oids" do 22 | let(:value) { subject.get({ oid: get_oid }, oid: next_oid) } 23 | it "returns the values for both" do 24 | expect(value).to be_a(Array) 25 | expect(value).to include(get_result) 26 | expect(value).to include(next_result) 27 | end 28 | end 29 | end 30 | 31 | describe "#get_next" do 32 | let(:varbind) { subject.get_next(oid: get_oid) } 33 | it "fetches the varbinds for the next oid" do 34 | oid, value = varbind 35 | expect(value).to start_with(next_result) 36 | expect(oid).to eq(next_oid) 37 | end 38 | end 39 | 40 | describe "#walk" do 41 | let(:value) { subject.walk(oid: walk_oid) } 42 | it "fetches the varbinds for the next oid" do 43 | value.each do |oid, val| 44 | match = walk_result[oid] 45 | case match 46 | when String 47 | expect(val.to_s).to eq(match) 48 | when Regexp 49 | expect(val.to_s).to match(match) 50 | else 51 | next 52 | end 53 | end 54 | end 55 | end 56 | end 57 | -------------------------------------------------------------------------------- /spec/support/specs.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | set -e 4 | 5 | RUBY_ENGINE=`ruby -e 'puts RUBY_ENGINE'` 6 | 7 | if [[ "$RUBY_ENGINE" = "truffleruby" ]]; then 8 | dnf install -y git net-snmp-utils 9 | elif [[ "$RUBY_ENGINE" = "jruby" ]]; then 10 | echo " 11 | deb http://deb.debian.org/debian/ buster main contrib non-free 12 | deb http://deb.debian.org/debian/ buster-updates main contrib non-free 13 | deb http://security.debian.org/debian-security buster/updates main contrib non-free" >> /etc/apt/sources.list 14 | apt-get update 15 | apt-get install -y git snmp-mibs-downloader 16 | else 17 | apk --update add g++ make git net-snmp-libs 18 | fi 19 | 20 | gem install bundler -v="1.17.3" --no-doc --conservative 21 | cd /home 22 | 23 | bundle -v 24 | bundle install 25 | 26 | mkdir -p /etc/ssl 27 | touch /etc/ssl/openssl.cnf 28 | cat <> /etc/ssl/openssl.cnf 29 | [provider_sect] 30 | default = default_sect 31 | legacy = legacy_sect 32 | [default_sect] 33 | activate = 1 34 | [legacy_sect] 35 | activate = 1 36 | EOT 37 | 38 | if [[ ${RUBY_VERSION:0:1} = "3" ]]; then 39 | export RUBYOPT='-rbundler/setup -rrbs/test/setup' 40 | export RBS_TEST_RAISE=true 41 | export RBS_TEST_LOGLEVEL=error 42 | export RBS_TEST_OPT='-Isig -ripaddr -ropenssl -rsocket' 43 | export RBS_TEST_TARGET='NETSNMP*' 44 | fi 45 | 46 | bundle exec rake spec:ci 47 | -------------------------------------------------------------------------------- /spec/support/stop_docker.sh: -------------------------------------------------------------------------------- 1 | sudo docker stop test-snmp-emulator 2 | sudo docker rm test-snmp-emulator 3 | sudo docker rmi snmp-server-emulator 4 | 5 | 6 | -------------------------------------------------------------------------------- /spec/timeticks_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # from https://ask.wireshark.org/questions/14002/how-to-decode-timeticks-hundreds-seconds-to-readable-date-time 4 | RSpec.describe NETSNMP::Timetick do 5 | subject { described_class.new(1525917187) } 6 | 7 | describe "as an integer" do 8 | it { expect((1 + subject).to_i).to be(1525917188) } 9 | end 10 | 11 | describe "as an embedded string" do 12 | it { expect(subject.to_s).to eq("Timeticks: (1525917187) 176 days, 14:39:31.87") } 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /spec/v3_session_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe NETSNMP::V3Session do 4 | let(:security_options) do 5 | { username: "authprivmd5des", auth_password: "maplesyrup", 6 | auth_protocol: :md5, priv_password: "maplesyrup", 7 | priv_protocol: :des, security_level: :auth_priv } 8 | end 9 | it "generates the security parameters handler" do 10 | sess = described_class.new(**security_options.merge(host: SNMPHOST, port: SNMPPORT)) 11 | # not generated yet 12 | expect(sess.instance_variable_get(:@security_parameters)).to be_a(NETSNMP::SecurityParameters) 13 | end 14 | 15 | it "allows to pass a custom one" do 16 | sec_params = NETSNMP::SecurityParameters.new(**security_options) 17 | sess = described_class.new(host: SNMPHOST, port: SNMPPORT, security_parameters: sec_params) 18 | # not generated yet 19 | expect(sess.instance_variable_get(:@security_parameters)).to be(sec_params) 20 | end 21 | 22 | it "fails if the pass object doesn't follow the expected api" do 23 | expect { described_class.new(host: SNMPHOST, port: SNMPPORT, security_parameters: double) }.to raise_error(NETSNMP::Error) 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /spec/varbind_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe NETSNMP::Varbind do 4 | using NETSNMP::StringExtensions 5 | 6 | describe "#to_der" do 7 | it { expect(described_class.new(".1.3.6.1.2.1.1.1.0").to_der).to eq("0\f\006\b+\006\001\002\001\001\001\000\005\000".b) } 8 | 9 | context "application specific" do 10 | it "converts ip addresses" do 11 | ipaddr = IPAddr.new("10.11.104.2") 12 | varbind = described_class.new(".1.3.6.1.4.1.2011.6.3.1.1.0", value: ipaddr) 13 | expect(varbind.to_der).to end_with("@\x04\n\vh\x02".b) 14 | asn = varbind.to_asn.value.last 15 | expect(varbind.convert_application_asn(asn)).to eq(ipaddr) 16 | end 17 | it "converts custom timeticks" do 18 | timetick = NETSNMP::Timetick.new(1) # yes, one timetick 19 | varbind = described_class.new(".1.3.6.1.2.1.1.3.0", value: timetick) 20 | expect(varbind.to_der).to end_with("\x04\x00\x00\x00\x01".b) # ends with an octet string rep of 1 timetick 21 | asn = varbind.to_asn.value.last 22 | expect(varbind.convert_application_asn(asn)).to eq(timetick) 23 | end 24 | 25 | context "when passed a type" do 26 | it "converts gauge32 without a leading byte" do 27 | gauge = 127 28 | varbind = described_class.new(".1.3.6.1.2.1.1.3.0", type: :gauge, value: gauge) 29 | value_str = varbind.to_der[12..-1] 30 | header = value_str[0].unpack1("B8") 31 | 32 | # Class: Primitive Application 33 | expect(header[0..1]).to eq("01") 34 | expect(header[2]).to eq("0") 35 | # Type: Integer 36 | expect(header[3..-1].to_i(2)).to eq(2) 37 | # Length & Value 38 | expect(varbind.to_der).to end_with("\x01\x7F".b) # 2 Bytes 39 | 40 | # Original Value 41 | asn = varbind.to_asn.value.last 42 | expect(varbind.convert_application_asn(asn)).to eq(gauge) 43 | end 44 | it "converts gauge32 with a leading byte" do 45 | gauge = 128 46 | varbind = described_class.new(".1.3.6.1.2.1.1.3.0", type: :gauge, value: gauge) 47 | value_str = varbind.to_der[12..-1] 48 | header = value_str[0].unpack1("B8") 49 | 50 | # Class: Primitive Application 51 | expect(header[0..1]).to eq("01") 52 | expect(header[2]).to eq("0") 53 | # Type: Integer 54 | expect(header[3..-1].to_i(2)).to eq(2) 55 | # Length & Value 56 | expect(varbind.to_der).to end_with("\x02\x00\x80".b) # 4 Bytes, all FF 57 | 58 | # Original Value 59 | asn = varbind.to_asn.value.last 60 | expect(varbind.convert_application_asn(asn)).to eq(gauge) 61 | end 62 | it "converts gauge32" do 63 | gauge = 805 64 | varbind = described_class.new(".1.3.6.1.2.1.1.3.0", type: :gauge, value: gauge) 65 | value_str = varbind.to_der[12..-1] 66 | header = value_str[0].unpack1("B8") 67 | 68 | # Class: Primitive Application 69 | expect(header[0..1]).to eq("01") 70 | expect(header[2]).to eq("0") 71 | # Type: Integer 72 | expect(header[3..-1].to_i(2)).to eq(2) 73 | # Length & Value 74 | expect(varbind.to_der).to end_with("\x02\x03%".b) 75 | 76 | # Original Value 77 | asn = varbind.to_asn.value.last 78 | expect(varbind.convert_application_asn(asn)).to eq(gauge) 79 | end 80 | it "converts counter32 without a leading byte" do 81 | counter = 127 82 | varbind = described_class.new(".1.3.6.1.2.1.1.3.0", type: :counter32, value: counter) 83 | expect(varbind.to_der).to end_with("\x01\x7F".b) 84 | asn = varbind.to_asn.value.last 85 | expect(varbind.convert_application_asn(asn)).to eq(counter) 86 | end 87 | it "converts counter32 with a leading byte" do 88 | counter = 128 89 | varbind = described_class.new(".1.3.6.1.2.1.1.3.0", type: :counter32, value: counter) 90 | expect(varbind.to_der).to end_with("\x02\x00\x80".b) 91 | asn = varbind.to_asn.value.last 92 | expect(varbind.convert_application_asn(asn)).to eq(counter) 93 | end 94 | it "converts counter32" do 95 | counter = 998932 96 | varbind = described_class.new(".1.3.6.1.2.1.1.3.0", type: :counter32, value: counter) 97 | expect(varbind.to_der).to end_with("\x0F>\x14".b) 98 | asn = varbind.to_asn.value.last 99 | expect(varbind.convert_application_asn(asn)).to eq(counter) 100 | end 101 | it "converts counter64" do 102 | counter = 998932 103 | varbind = described_class.new(".1.3.6.1.2.1.1.3.0", type: :counter64, value: counter) 104 | expect(varbind.to_der).to end_with("\x0F>\x14".b) 105 | asn = varbind.to_asn.value.last 106 | expect(varbind.convert_application_asn(asn)).to eq(counter) 107 | 108 | counter = 4294967296 109 | varbind = described_class.new(".1.3.6.1.2.1.1.3.0", type: :counter64, value: counter) 110 | expect(varbind.to_der).to end_with("\x01\x00\x00\x00\x00".b) 111 | asn = varbind.to_asn.value.last 112 | expect(varbind.convert_application_asn(asn)).to eq(counter) 113 | 114 | counter = 309084502 115 | varbind = described_class.new(".1.3.6.1.2.1.1.3.0", type: :counter64, value: counter) 116 | expect(varbind.to_der).to include("F\x04".b) 117 | asn = varbind.to_asn.value.last 118 | expect(varbind.convert_application_asn(asn)).to eq(counter) 119 | 120 | counter = 2_613_579_752_238 121 | varbind = described_class.new(".1.3.6.1.2.1.1.3.0", type: :counter64, value: counter) 122 | expect(varbind.to_der).to include("F\x06".b) 123 | asn = varbind.to_asn.value.last 124 | expect(varbind.convert_application_asn(asn)).to eq(counter) 125 | end 126 | it "converts integer ticks" do 127 | timetick = 1 128 | varbind = described_class.new(".1.3.6.1.2.1.1.3.0", type: :timetick, value: timetick) 129 | expect(varbind.to_der).to end_with("\x04\x00\x00\x00\x01".b) 130 | asn = varbind.to_asn.value.last 131 | expect(varbind.convert_application_asn(asn)).to eq(timetick) 132 | end 133 | end 134 | end 135 | end 136 | end 137 | --------------------------------------------------------------------------------