├── .coveragerc ├── .gitignore ├── .prospector.yaml ├── .tito ├── packages │ ├── .readme │ └── python-pypureomapi └── tito.props ├── .travis.yml ├── LICENSE.md ├── MANIFEST.in ├── README.md ├── debian ├── changelog ├── compat ├── control ├── copyright ├── python-pypureomapi-doc.docs ├── rules ├── source │ └── format └── watch ├── pypureomapi.py ├── pypureomapi.spec ├── setup.cfg └── setup.py /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | include=pypureomapi.py 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | *.pyc 3 | __pycache__ 4 | build 5 | test_live_omapi.py 6 | MANIFEST 7 | dist/ 8 | -------------------------------------------------------------------------------- /.prospector.yaml: -------------------------------------------------------------------------------- 1 | pylint: 2 | disable: 3 | - line-too-long 4 | -------------------------------------------------------------------------------- /.tito/packages/.readme: -------------------------------------------------------------------------------- 1 | the .tito/packages directory contains metadata files 2 | named after their packages. Each file has the latest tagged 3 | version and the project's relative directory. 4 | -------------------------------------------------------------------------------- /.tito/packages/python-pypureomapi: -------------------------------------------------------------------------------- 1 | 0.8-1 ./ 2 | -------------------------------------------------------------------------------- /.tito/tito.props: -------------------------------------------------------------------------------- 1 | [buildconfig] 2 | builder = tito.builder.Builder 3 | tagger = tito.tagger.VersionTagger 4 | changelog_do_not_remove_cherrypick = 0 5 | changelog_format = %s (%ae) 6 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | arch: 3 | - amd64 4 | - ppc64le 5 | python: 6 | - 2.7 7 | - 3.4 8 | - 3.5 9 | - 3.6 10 | install: 11 | script: 12 | - python pypureomapi.py -v 13 | 14 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 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 | Copyright 2010-2017 Cygnus Networks GmbH 179 | 180 | Licensed under the Apache License, Version 2.0 (the "License"); 181 | you may not use this file except in compliance with the License. 182 | You may obtain a copy of the License at 183 | 184 | http://www.apache.org/licenses/LICENSE-2.0 185 | 186 | Unless required by applicable law or agreed to in writing, software 187 | distributed under the License is distributed on an "AS IS" BASIS, 188 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 189 | See the License for the specific language governing permissions and 190 | limitations under the License. 191 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE.md -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Codacy Badge](https://api.codacy.com/project/badge/Grade/eeca983d807b472fa8539506de47ffa6)](https://app.codacy.com/gh/CygnusNetworks/pypureomapi?utm_source=github.com&utm_medium=referral&utm_content=CygnusNetworks/pypureomapi&utm_campaign=Badge_Grade_Dashboard) 2 | [![Build Status](https://travis-ci.org/CygnusNetworks/pypureomapi.svg?branch=master)](https://travis-ci.org/CygnusNetworks/pypureomapi) 3 | [![Latest Version](https://img.shields.io/pypi/v/pypureomapi.svg)](https://pypi.python.org/pypi/pypureomapi) 4 | [![PyPi Status](https://img.shields.io/pypi/status/pypureomapi.svg)](https://pypi.python.org/pypi/pypureomapi) [![PyPi Versions](https://img.shields.io/pypi/pyversions/pypureomapi.svg)](https://pypi.python.org/pypi/pypureomapi) 5 | 6 | pypureomapi 7 | =========== 8 | 9 | pypureomapi is a Python implementation of the DHCP OMAPI protocol used in the most popular Linux DHCP server from ISC. 10 | It can be used to query and modify leases and other objects exported by an ISC DHCP server. 11 | The interaction can be authenticated using HMAC-MD5. Besides basic ready to use operations, custom interaction can be implemented with limited effort. 12 | It provides error checking and extensibility. 13 | 14 | ## Server side configugration for ISC DHCP3 15 | 16 | To allow a OMAPI access to your ISC DHCP3 DHCP Server you should define the following in your dhcpd.conf config file: 17 | 18 | ``` 19 | key defomapi { 20 | algorithm hmac-md5; 21 | secret +bFQtBCta6j2vWkjPkNFtgA==; # FIXME: replace by your own dnssec key (see below)!!! 22 | }; 23 | 24 | omapi-key defomapi; 25 | omapi-port 7911; 26 | ``` 27 | 28 | Replace the given secret by a key created on your own! 29 | 30 | To generate a key use the following command: 31 | 32 | ``` 33 | /usr/sbin/dnssec-keygen -a HMAC-MD5 -b 128 -n USER defomapi 34 | ``` 35 | 36 | which will create two files containing a HMAC MD5 key. Alternatively, it 37 | is possible to generate the key value for the config file directly: 38 | 39 | ``` 40 | dd if=/dev/urandom bs=16 count=1 2>/dev/null | openssl enc -e -base64 41 | ``` 42 | 43 | ## Example omapi lookup 44 | 45 | This is a short example, of how to use basic lookup functions **lookup_mac** and **lookup_ip** to quickly query a DHCP lease on a ISC DHCP Server. 46 | 47 | Python 3 example: 48 | ``` 49 | import pypureomapi 50 | 51 | KEYNAME=b"defomapi" 52 | BASE64_ENCODED_KEY=b"+bFQtBCta6j2vWkjPkNFtgA==" # FIXME: be sure to replace this by your own key!!! 53 | 54 | dhcp_server_ip="127.0.0.1" 55 | port = 7911 # Port of the omapi service 56 | 57 | omapi = pypureomapi.Omapi(dhcp_server_ip, port, KEYNAME, BASE64_ENCODED_KEY) 58 | mac = omapi.lookup_mac("192.168.0.250") 59 | print("%s is currently assigned to mac %s" % (lease_ip, mac)) 60 | 61 | ip = omapi.lookup_ip(mac) 62 | print("%s mac currently has ip %s assigned" % (mac, ip)) 63 | ``` 64 | 65 | Python 2 example: 66 | ``` 67 | from __future__ import print_function 68 | import pypureomapi 69 | 70 | KEYNAME="defomapi" 71 | BASE64_ENCODED_KEY="+bFQtBCta6j2vWkjPkNFtgA==" # FIXME: be sure to replace this by your own key!!! 72 | 73 | dhcp_server_ip="127.0.0.1" 74 | port = 7911 # Port of the omapi service 75 | 76 | omapi = pypureomapi.Omapi(dhcp_server_ip, port, KEYNAME, BASE64_ENCODED_KEY) 77 | mac = omapi.lookup_mac("192.168.0.250") 78 | print("%s is currently assigned to mac %s" % (lease_ip, mac)) 79 | 80 | ip = omapi.lookup_ip(mac) 81 | print("%s mac currently has ip %s assigned" % (mac, ip)) 82 | ``` 83 | 84 | If you need full lease information, you can also query the full lease directly by using **lookup_by_lease**, which gives you the full lease details as output: 85 | 86 | ``` 87 | lease = omapi.lookup_by_lease(mac="24:79:2a:0a:13:c0") 88 | for k, v in lease.items(): 89 | print("%s: %s" % (k, v)) 90 | ``` 91 | 92 | Output: 93 | ``` 94 | state: 2 95 | ip-address: 192.168.10.167 96 | dhcp-client-identifier: b'\x01$y*\x06U\xc0' 97 | subnet: 6126 98 | pool: 6127 99 | hardware-address: 24:79:2a:0a:13:c0 100 | hardware-type: 1 101 | ends: 1549885690 102 | starts: 1549885390 103 | tstp: 1549885840 104 | tsfp: 1549885840 105 | atsfp: 1549885840 106 | cltt: 1549885390 107 | flags: 0 108 | clientip: b'192.168.10.167' 109 | clientmac: b'24:79:2a:0a:13:c0' 110 | clientmac_hostname: b'24792a0a13c0' 111 | vendor-class-identifier: b'Ruckus CPE' 112 | agent.circuit-id: b'\x00\x04\x00\x12\x00-' 113 | agent.remote-id: b'\x00\x06\x00\x12\xf2\x8e!\x00' 114 | agent.subscriber-id: b'wifi-basement' 115 | ``` 116 | 117 | To check if a lease is still valid, you should check ends and state: 118 | 119 | ``` 120 | if lease["ends"] < time.time() or lease["state"] != 2: 121 | print("Lease is not valid") 122 | ``` 123 | 124 | Most attributes will be decoded directly into the corresponding human readable values. 125 | Converted attributes are ip-address, hardware-address and all 32 bit and 8 bit integer values. If you need raw values, you can add a raw option to the lookup: 126 | 127 | ``` 128 | lease = omapi.lookup_by_lease(mac="24:79:2a:0a:13:c0", raw=True) 129 | for k, v in res.items(): 130 | print("%s: %s" % (k, v)) 131 | ``` 132 | 133 | Output: 134 | 135 | ``` 136 | b'state': b'\x00\x00\x00\x02' 137 | b'ip-address': b'\xc0\xa8\n\xa7' 138 | ... 139 | ``` 140 | 141 | The following lookup functions are implemented, allowing directly querying the different types: 142 | 143 | * lookup_ip_host(mac) - lookups up a host object (static defined host) by mac 144 | * lookup_ip(mac) - lookups a lease object by mac and returns the ip 145 | * lookup_host(name) - lookups a host object by name and returns the ip, mac and hostname 146 | * lookup_host_host(mac) - lookups a host object by mac and returns the ip, mac and name 147 | * lookup_hostname(ip) - lookups a lease object by ip and returns the client-hostname 148 | 149 | These special functions use: 150 | 151 | * lookup_by_host - generic lookup function for host objects 152 | * lookup_by_lease - generic lookup function for lease objects 153 | 154 | which provide full access to complete lease data. 155 | 156 | ## Add and delete host objects 157 | 158 | For adding and deleting host objects (static DHCP leases), there are multiple functions: 159 | 160 | * add_host(ip, mac) 161 | * add_host_supersede_name(ip, mac, name) 162 | * add_host_without_ip(mac) 163 | * add_host_supersede(ip, mac, name, hostname=None, router=None, domain=None) 164 | * add_group(groupname, statements) 165 | * add_host_with_group(ip, mac, groupname)) 166 | 167 | See http://jpmens.net/2011/07/20/dynamically-add-static-leases-to-dhcpd/ for original idea (which is now merged) and detailed explanation. 168 | 169 | # Custom Integration 170 | 171 | Assuming there already is a connection named `o` (i.e. a `Omapi` instance, see [Example]). 172 | To craft your own communication with the server you need to create an `OmapiMessage`, send it, receive a response and evaluate that response being an `OmapiMessage` as well. So here we go and create our first message. 173 | ``` 174 | m1 = OmapiMessage.open("host") 175 | ``` 176 | We are using a named constructor (`OmapiMessage.open`). It fills in the opcode (as `OMAPI_OP_OPEN`), generates a random transaction id, and uses the parameter for the type field. This is the thing you want almost all the time. In this case we are going to open a host object, but we did not specify which host to open. For example we can select a host by its name. 177 | ``` 178 | m1.update_object(dict(name="foo")) 179 | ``` 180 | The next step is to interact with the DHCP server. The easiest way to do so is using the `query_server` method. It takes an `OmapiMessage`and returns another. 181 | ``` 182 | r1 = o.query_server(m1) 183 | ``` 184 | The returned `OmapiMessage` contains the parsed response from the server. Since opening can fail, we need to check the `opcode` attribute. In case of success its value is `OMAPI_OP_UPDATE`. As with files on unix we now have a descriptor called `r1.handle`. So now we are to modify some attribute about this host. Say we want to set its group. To do so we construct a new message and reference the opened host object via its handle. 185 | ``` 186 | m2 = OmapiMessage.update(r1.handle) 187 | ``` 188 | Again `OmapiMessage.update` is a named constructor. It fills in the opcode (as `OMAPI_OP_UPDATE`), generates a random transaction id and fills in the handle. So now we need to add the actual modification to the message and send the message to the server. 189 | ``` 190 | m2.update_object(dict(group="bar")) 191 | r2 = o.query_server(m2) 192 | ``` 193 | We receive a new message and need to check the returned `opcode` which should be `OMAPI_OP_UPDATE` again. Now we have a complete sequence. 194 | 195 | As can be seen, the OMAPI protocol permits flexible interaction and it would be unreasonable to include every possibility as library functions. Instead you are encouraged to subclass the `Omapi` class and define your own methods. If they prove useful in multiple locations, please submit them to the issue tracker. 196 | -------------------------------------------------------------------------------- /debian/changelog: -------------------------------------------------------------------------------- 1 | pypureomapi (0.8) UNRELEASED; urgency=medium 2 | 3 | * New upstream release 4 | * Add dh-python to build-dependency; Closes: #950061 5 | * Use updated description from upstream 6 | * Update Debian policy 7 | 8 | -- Dr. Torge Szczepanek Wed, 29 Jan 2020 09:58:32 +0100 9 | 10 | pypureomapi (0.4-1.1) unstable; urgency=medium 11 | 12 | * Non-maintainer upload. 13 | * Drop python2 support; Closes: #937509 14 | 15 | -- Sandro Tosi Fri, 04 Oct 2019 22:02:53 -0400 16 | 17 | pypureomapi (0.4-1) unstable; urgency=low 18 | 19 | [ Dr. Torge Szczepanek ] 20 | * Switch License to Apache 2.0 21 | * PEP-8 cleanups 22 | * Use new-style classes 23 | * Disabled doctests - to be included again later in next upstream release 24 | * Change project source from Google Code to Github after upstream migration 25 | * Change maintainer to Torge Szczepanek and remove 26 | Helmut Grohne from uploaders 27 | [ Helmut Grohne ] 28 | * Bump python dependency to 2.6 since we need it. 29 | * Use logging module for debugging. 30 | * Switch to native versioning to not confuse tools. 31 | * Switch from pysupport to dh_python2. 32 | * Update packaging 33 | 34 | -- Dr. Torge Szczepanek Thu, 02 Jul 2015 18:29:03 +0200 35 | 36 | pypureomapi (0.3-1) unstable; urgency=low 37 | 38 | * New upstream release. 39 | + Update debian/copyright years. 40 | + Minimum Python version bumped to 2.6. 41 | * Bump Standards-Version from 3.9.2 to 3.9.4: No changes needed. 42 | * Move required Python version to X-Python-Version to comply with python 43 | policy 2.3. 44 | * Update debian/copyright from dep5 draft to final version. 45 | * Added OmapiMessage.update. 46 | * Export {,un}pack_{ip,mac}. 47 | * Forward compatibility with python3. 48 | 49 | -- Helmut Grohne Wed, 26 Jun 2013 22:03:20 +0200 50 | 51 | pypureomapi (0.2-1) unstable; urgency=low 52 | 53 | * Initial release. (Closes: #602921: RFP: pypureomapi -- ISC DHCP 54 | OMAPI protocol implementation in Python) 55 | * Set debian/source/format to 1.0. 56 | * Added missing (empty) ${misc:Depends} for debhelper. 57 | * Invoke test suite in override_dh_auto_test. 58 | 59 | -- Helmut Grohne Mon, 05 Dec 2011 11:24:34 +0100 60 | 61 | pypureomapi (0.1-1) unstable; urgency=low 62 | 63 | * Initial release. 64 | 65 | -- Helmut Grohne Thu, 08 Dec 2011 08:57:55 +0100 66 | -------------------------------------------------------------------------------- /debian/compat: -------------------------------------------------------------------------------- 1 | 10 2 | -------------------------------------------------------------------------------- /debian/control: -------------------------------------------------------------------------------- 1 | Source: pypureomapi 2 | Maintainer: Dr. Torge Szczepanek 3 | Standards-Version: 4.4.1 4 | Section: python 5 | Priority: optional 6 | Homepage: https://github.com/CygnusNetworks/pypureomapi 7 | Build-Depends: debhelper (>= 9), python3, python3-all, dh-python 8 | X-Python3-Version: >= 3.7 9 | 10 | Package: python3-pypureomapi 11 | Architecture: all 12 | Depends: ${python3:Depends}, ${misc:Depends} 13 | Description: ISC DHCP OMAPI protocol implementation in Python3 14 | pypureomapi is a Python implementation of the DHCP OMAPI protocol 15 | used in the most popular Linux DHCP server from ISC. 16 | It can be used to query and modify leases and other objects exported 17 | by an ISC DHCP server. 18 | The interaction can be authenticated using HMAC-MD5. Besides basic 19 | ready to use operations, custom interaction can be implemented with 20 | limited effort. It provides error checking and extensibility. 21 | -------------------------------------------------------------------------------- /debian/copyright: -------------------------------------------------------------------------------- 1 | Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/ 2 | Upstream-Name: pypureomapi 3 | Upstream-Contact: Dr. Torge Szczepanek 4 | Source: https://github.com/CygnusNetworks/pypureomapi 5 | 6 | Files: * 7 | Copyright: 2010-2020, Cygnus Networks GmbH 8 | License: Apache-2.0 9 | Licensed under the Apache License, Version 2.0 (the "License"); 10 | you may not use this file except in compliance with the License. 11 | You may obtain a copy of the License at 12 | . 13 | http://www.apache.org/licenses/LICENSE-2.0 14 | . 15 | Unless required by applicable law or agreed to in writing, software 16 | distributed under the License is distributed on an "AS IS" BASIS, 17 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 18 | See the License for the specific language governing permissions and 19 | limitations under the License. 20 | . 21 | On Debian systems, the complete text of the Apache License Version 2.0 22 | can be found in `/usr/share/common-licenses/Apache-2.0'. 23 | -------------------------------------------------------------------------------- /debian/python-pypureomapi-doc.docs: -------------------------------------------------------------------------------- 1 | README.md 2 | -------------------------------------------------------------------------------- /debian/rules: -------------------------------------------------------------------------------- 1 | #!/usr/bin/make -f 2 | 3 | export PYBUILD_NAME=pypureomapi 4 | 5 | %: 6 | dh $@ --with python3 --buildsystem=pybuild 7 | -------------------------------------------------------------------------------- /debian/source/format: -------------------------------------------------------------------------------- 1 | 3.0 (native) 2 | -------------------------------------------------------------------------------- /debian/watch: -------------------------------------------------------------------------------- 1 | version=3 2 | opts=filenamemangle=s/.+\/v?(\d\S*)\.tar\.gz/-$1\.tar\.gz/ \ 3 | https://github.com/CygnusNetworks/pypureomapi/tags .*/v?(\d\S*)\.tar\.gz 4 | -------------------------------------------------------------------------------- /pypureomapi.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf8 -*- 3 | # pylint:disable=too-many-lines 4 | 5 | # library for communicating with an isc dhcp server over the omapi protocol 6 | # 7 | # Copyright 2010-2017 Cygnus Networks GmbH 8 | # 9 | # Licensed under the Apache License, Version 2.0 (the "License"); 10 | # you may not use this file except in compliance with the License. 11 | # You may obtain a copy of the License at 12 | # 13 | # http://www.apache.org/licenses/LICENSE-2.0 14 | # 15 | # Unless required by applicable law or agreed to in writing, software 16 | # distributed under the License is distributed on an "AS IS" BASIS, 17 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 18 | # See the License for the specific language governing permissions and 19 | # limitations under the License. 20 | 21 | 22 | # Message format: 23 | 24 | # authid (netint32) 25 | # authlen (netint32) 26 | # opcode (netint32) 27 | # handle (netint32) 28 | # tid (netint32) 29 | # rid (netint32) 30 | # message (dictionary) 31 | # object (dictionary) 32 | # signature (length is authlen) 33 | 34 | # dictionary = entry* 0x00 0x00 35 | # entry = key (net16str) value (net32str) 36 | 37 | import binascii 38 | import struct 39 | import hashlib 40 | import hmac 41 | import io 42 | import logging 43 | import socket 44 | import random 45 | import operator 46 | try: 47 | basestring 48 | except NameError: 49 | basestring = str # pylint:disable=W0622 50 | 51 | __author__ = "Helmut Grohne, Dr. Torge Szczepanek" 52 | __copyright__ = "Cygnus Networks GmbH" 53 | __license__ = "Apache-2.0" 54 | __version__ = "0.8" 55 | __maintainer__ = "Dr. Torge Szczepanek" 56 | __email__ = "debian@cygnusnetworks.de" 57 | 58 | 59 | __all__ = [] 60 | 61 | logger = logging.getLogger("pypureomapi") 62 | sysrand = random.SystemRandom() 63 | 64 | __all__.extend("OMAPI_OP_OPEN OMAPI_OP_REFRESH OMAPI_OP_UPDATE".split()) 65 | __all__.extend("OMAPI_OP_NOTIFY OMAPI_OP_STATUS OMAPI_OP_DELETE".split()) 66 | OMAPI_OP_OPEN = 1 67 | OMAPI_OP_REFRESH = 2 68 | OMAPI_OP_UPDATE = 3 69 | OMAPI_OP_NOTIFY = 4 70 | OMAPI_OP_STATUS = 5 71 | OMAPI_OP_DELETE = 6 72 | 73 | 74 | def repr_opcode(opcode): 75 | """Returns a textual representation for the given opcode. 76 | @type opcode: int 77 | @rtype: str 78 | """ 79 | opmap = {1: "open", 2: "refresh", 3: "update", 4: "notify", 5: "status", 6: "delete"} 80 | return opmap.get(opcode, "unknown (%d)" % opcode) 81 | 82 | 83 | __all__.append("OmapiError") 84 | 85 | 86 | class OmapiError(Exception): 87 | """OMAPI exception base class.""" 88 | 89 | 90 | __all__.append("OmapiSizeLimitError") 91 | 92 | 93 | class OmapiSizeLimitError(OmapiError): 94 | """Packet size limit reached.""" 95 | def __init__(self): 96 | OmapiError.__init__(self, "Packet size limit reached.") 97 | 98 | 99 | __all__.append("OmapiErrorNotFound") 100 | 101 | 102 | class OmapiErrorNotFound(OmapiError): 103 | """Not found.""" 104 | def __init__(self): 105 | OmapiError.__init__(self, "not found") 106 | 107 | 108 | __all__.append("OmapiErrorAttributeNotFound") 109 | 110 | 111 | class OmapiErrorAttributeNotFound(OmapiErrorNotFound): 112 | """Attribute not found.""" 113 | def __init__(self): # pylint:disable=super-init-not-called 114 | OmapiError.__init__(self, "attribute not found") # pylint:disable=non-parent-init-called 115 | 116 | 117 | class OutBuffer(object): # pylint:disable=useless-object-inheritance 118 | """Helper class for constructing network packets.""" 119 | sizelimit = 65536 120 | 121 | def __init__(self): 122 | self.buff = io.BytesIO() 123 | 124 | def __len__(self): 125 | """Return the number of bytes in the buffer. 126 | @rtype: int 127 | """ 128 | # On Py2.7 tell returns long, but __len__ is required to return int. 129 | return int(self.buff.tell()) 130 | 131 | def add(self, data): 132 | """ 133 | >>> ob = OutBuffer().add(OutBuffer.sizelimit * b"x") 134 | >>> ob.add(b"y") # doctest: +ELLIPSIS 135 | Traceback (most recent call last): 136 | ... 137 | OmapiSizeLimitError: ... 138 | 139 | @type data: bytes 140 | @returns: self 141 | @raises OmapiSizeLimitError: 142 | """ 143 | if len(self) + len(data) > self.sizelimit: 144 | raise OmapiSizeLimitError() 145 | self.buff.write(data) 146 | return self 147 | 148 | def add_net32int(self, integer): 149 | """ 150 | @type integer: int 151 | @param integer: a 32bit unsigned integer 152 | @returns: self 153 | @raises OmapiSizeLimitError: 154 | """ 155 | if integer < 0 or integer >= (1 << 32): 156 | raise ValueError("not a 32bit unsigned integer") 157 | return self.add(struct.pack("!L", integer)) 158 | 159 | def add_net16int(self, integer): 160 | """ 161 | @type integer: int 162 | @param integer: a 16bit unsigned integer 163 | @returns: self 164 | @raises OmapiSizeLimitError: 165 | """ 166 | if integer < 0 or integer >= (1 << 16): 167 | raise ValueError("not a 16bit unsigned integer") 168 | return self.add(struct.pack("!H", integer)) 169 | 170 | def add_net32string(self, string): 171 | """ 172 | >>> r = b'\\x00\\x00\\x00\\x01x' 173 | >>> OutBuffer().add_net32string(b"x").getvalue() == r 174 | True 175 | 176 | @type string: bytes 177 | @param string: maximum length must fit in a 32bit integer 178 | @returns: self 179 | @raises OmapiSizeLimitError: 180 | """ 181 | if len(string) >= (1 << 32): 182 | raise ValueError("string too long") 183 | return self.add_net32int(len(string)).add(string) 184 | 185 | def add_net16string(self, string): 186 | """ 187 | >>> OutBuffer().add_net16string(b"x").getvalue() == b'\\x00\\x01x' 188 | True 189 | 190 | @type string: bytes 191 | @param string: maximum length must fit in a 16bit integer 192 | @returns: self 193 | @raises OmapiSizeLimitError: 194 | """ 195 | if len(string) >= (1 << 16): 196 | raise ValueError("string too long") 197 | return self.add_net16int(len(string)).add(string) 198 | 199 | def add_bindict(self, items): 200 | """ 201 | >>> r = b'\\x00\\x03foo\\x00\\x00\\x00\\x03bar\\x00\\x00' 202 | >>> OutBuffer().add_bindict({b"foo": b"bar"}).getvalue() == r 203 | True 204 | 205 | @type items: [(bytes, bytes)] or {bytes: bytes} 206 | @returns: self 207 | @raises OmapiSizeLimitError: 208 | """ 209 | if not isinstance(items, list): 210 | items = items.items() 211 | for key, value in items: 212 | self.add_net16string(key).add_net32string(value) 213 | return self.add(b"\x00\x00") # end marker 214 | 215 | def getvalue(self): 216 | """ 217 | >>> OutBuffer().add(b"sp").add(b"am").getvalue() == b"spam" 218 | True 219 | 220 | @rtype: bytes 221 | """ 222 | return self.buff.getvalue() 223 | 224 | def consume(self, length): 225 | """ 226 | >>> OutBuffer().add(b"spam").consume(2).getvalue() == b"am" 227 | True 228 | 229 | @type length: int 230 | @returns: self 231 | """ 232 | self.buff = io.BytesIO(self.getvalue()[length:]) 233 | return self 234 | 235 | 236 | class OmapiStartupMessage(object): # pylint:disable=useless-object-inheritance 237 | """Class describing the protocol negotiation messages. 238 | 239 | >>> s = OmapiStartupMessage().as_string() 240 | >>> s == b"\\0\\0\\0\\x64\\0\\0\\0\\x18" 241 | True 242 | >>> next(InBuffer(s).parse_startup_message()).validate() 243 | >>> OmapiStartupMessage(42).validate() 244 | Traceback (most recent call last): 245 | ... 246 | OmapiError: protocol mismatch 247 | """ 248 | implemented_protocol_version = 100 249 | implemented_header_size = 4 * 6 250 | 251 | def __init__(self, protocol_version=None, header_size=None): 252 | """ 253 | @type protocol_version: int or None 254 | @type header_size: int or None 255 | """ 256 | if protocol_version is None: 257 | protocol_version = self.implemented_protocol_version 258 | if header_size is None: 259 | header_size = self.implemented_header_size 260 | self.protocol_version = protocol_version 261 | self.header_size = header_size 262 | 263 | def validate(self): 264 | """Checks whether this OmapiStartupMessage matches the implementation. 265 | @raises OmapiError: 266 | """ 267 | if self.implemented_protocol_version != self.protocol_version: 268 | raise OmapiError("protocol mismatch") 269 | if self.implemented_header_size != self.header_size: 270 | raise OmapiError("header size mismatch") 271 | 272 | def as_string(self): 273 | """ 274 | @rtype: bytes 275 | """ 276 | ret = OutBuffer() 277 | self.serialize(ret) 278 | return ret.getvalue() 279 | 280 | def serialize(self, outbuffer): 281 | """Serialize this OmapiStartupMessage to the given outbuffer. 282 | @type outbuffer: OutBuffer 283 | """ 284 | outbuffer.add_net32int(self.protocol_version) 285 | outbuffer.add_net32int(self.header_size) 286 | 287 | def dump_oneline(self): 288 | """ 289 | @rtype: str 290 | @returns: a human readable representation in one line 291 | """ 292 | return "protocol_version=%d header_size=%d" % (self.protocol_version, self.header_size) 293 | 294 | 295 | class OmapiAuthenticatorBase(object): # pylint:disable=useless-object-inheritance 296 | """Base class for OMAPI authenticators. 297 | @cvar authlen: is the length of a signature as returned by the sign method 298 | @type authlen: int 299 | @cvar algorithm: is a textual name for the algorithm 300 | @type algorithm: str or None 301 | @ivar authid: is the authenticator id as assigned during the handshake 302 | @type authid: int 303 | """ 304 | authlen = -1 # must be overwritten 305 | algorithm = None 306 | authid = -1 # will be an instance attribute 307 | 308 | def __init__(self): 309 | pass 310 | 311 | def auth_object(self): 312 | """ 313 | @rtype: {bytes: bytes} 314 | @returns: object part of an omapi authentication message 315 | """ 316 | raise NotImplementedError 317 | 318 | def sign(self, message): 319 | """ 320 | @type message: bytes 321 | @rtype: bytes 322 | @returns: a signature of length self.authlen 323 | """ 324 | raise NotImplementedError() 325 | 326 | 327 | class OmapiNullAuthenticator(OmapiAuthenticatorBase): 328 | authlen = 0 329 | authid = 0 # always 0 330 | 331 | def __init__(self): 332 | OmapiAuthenticatorBase.__init__(self) 333 | 334 | def auth_object(self): 335 | return {} 336 | 337 | def sign(self, _): 338 | return b"" 339 | 340 | 341 | class OmapiHMACMD5Authenticator(OmapiAuthenticatorBase): 342 | authlen = 16 343 | algorithm = b"hmac-md5.SIG-ALG.REG.INT." 344 | 345 | def __init__(self, user, key): 346 | """ 347 | @type user: bytes 348 | @type key: bytes 349 | @param key: base64 encoded key 350 | @raises binascii.Error: for bad base64 encoding 351 | """ 352 | OmapiAuthenticatorBase.__init__(self) 353 | assert isinstance(user, bytes) 354 | self.user = user 355 | assert isinstance(key, bytes) 356 | self.key = binascii.a2b_base64(key) 357 | 358 | def auth_object(self): 359 | return {b"name": self.user, b"algorithm": self.algorithm} 360 | 361 | def sign(self, message): 362 | """ 363 | >>> authlen = OmapiHMACMD5Authenticator.authlen 364 | >>> len(OmapiHMACMD5Authenticator(b"foo", 16*b"x").sign(b"baz")) == authlen 365 | True 366 | 367 | @type message: bytes 368 | @rtype: bytes 369 | @returns: a signature of length self.authlen 370 | """ 371 | return hmac.HMAC(self.key, message, digestmod=hashlib.md5).digest() 372 | 373 | 374 | __all__.append("OmapiMessage") 375 | 376 | 377 | class OmapiMessage(object): # pylint:disable=too-many-instance-attributes,useless-object-inheritance 378 | """ 379 | @type authid: int 380 | @ivar authid: The id of the message authenticator. 381 | @type opcode: int 382 | @ivar opcode: One out of 383 | OMAPI_OP_{OPEN,REFRESH,UPDATE,NOTIFY,STATUS,DELETE}. 384 | @type handle: int 385 | @ivar handle: The id of a handle acquired from a previous request or 0. 386 | @type tid: int 387 | @ivar tid: Transmission identifier. 388 | @type rid: int 389 | @ivar rid: Receive identifier (of a response is the tid of the request). 390 | @type message: [(bytes, bytes)] 391 | @ivar message: A list of (key, value) pairs. 392 | @type obj: [(bytes, bytes)] 393 | @ivar obj: A list of (key, value) pairs. 394 | @type signature: bytes 395 | @ivar signature: A signature on this message as generated by an 396 | authenticator. 397 | """ 398 | def __init__(self, authid=0, opcode=0, handle=0, tid=0, rid=0, message=None, obj=None, signature=b""): # pylint:disable=too-many-arguments 399 | """ 400 | Construct an OmapiMessage from the given fields. No error 401 | checking is performed. 402 | 403 | @type authid: int 404 | @type opcode: int 405 | @type handle: int 406 | @type tid: int 407 | @param tid: The special value -1 causes a tid to be generated randomly. 408 | @type rid: int 409 | @type message: [(bytes, bytes)] 410 | @type obj: [(bytes, bytes)] 411 | @type signature: str 412 | @rtype: OmapiMessage 413 | """ 414 | self.authid, self.opcode, self.handle = authid, opcode, handle 415 | self.handle, self.tid, self.rid = handle, tid, rid 416 | self.message = message or [] 417 | self.obj = obj or [] 418 | self.signature = signature 419 | 420 | if self.tid == -1: 421 | self.generate_tid() 422 | 423 | def generate_tid(self): 424 | """Generate a random transmission id for this OMAPI message. 425 | 426 | >>> OmapiMessage(tid=-1).tid != OmapiMessage(tid=-1).tid 427 | True 428 | """ 429 | self.tid = sysrand.randrange(0, 1 << 32) 430 | 431 | def serialize(self, outbuffer, forsigning=False): 432 | """ 433 | @type outbuffer: OutBuffer 434 | @type forsigning: bool 435 | @raises OmapiSizeLimitError: 436 | """ 437 | if not forsigning: 438 | outbuffer.add_net32int(self.authid) 439 | outbuffer.add_net32int(len(self.signature)) 440 | outbuffer.add_net32int(self.opcode) 441 | outbuffer.add_net32int(self.handle) 442 | outbuffer.add_net32int(self.tid) 443 | outbuffer.add_net32int(self.rid) 444 | outbuffer.add_bindict(self.message) 445 | outbuffer.add_bindict(self.obj) 446 | if not forsigning: 447 | outbuffer.add(self.signature) 448 | 449 | def as_string(self, forsigning=False): 450 | """ 451 | >>> len(OmapiMessage().as_string(True)) >= 24 452 | True 453 | 454 | @type forsigning: bool 455 | @rtype: bytes 456 | @raises OmapiSizeLimitError: 457 | """ 458 | ret = OutBuffer() 459 | self.serialize(ret, forsigning) 460 | return ret.getvalue() 461 | 462 | def sign(self, authenticator): 463 | """Sign this OMAPI message. 464 | @type authenticator: OmapiAuthenticatorBase 465 | """ 466 | self.authid = authenticator.authid 467 | self.signature = b"\0" * authenticator.authlen # provide authlen 468 | self.signature = authenticator.sign(self.as_string(forsigning=True)) 469 | assert len(self.signature) == authenticator.authlen 470 | 471 | def verify(self, authenticators): 472 | """Verify this OMAPI message. 473 | 474 | >>> a1 = OmapiHMACMD5Authenticator(b"egg", b"spam") 475 | >>> a2 = OmapiHMACMD5Authenticator(b"egg", b"tomatoes") 476 | >>> a1.authid = a2.authid = 5 477 | >>> m = OmapiMessage.open(b"host") 478 | >>> m.verify({a1.authid: a1}) 479 | False 480 | >>> m.sign(a1) 481 | >>> m.verify({a1.authid: a1}) 482 | True 483 | >>> m.sign(a2) 484 | >>> m.verify({a1.authid: a1}) 485 | False 486 | 487 | @type authenticators: {int: OmapiAuthenticatorBase} 488 | @rtype: bool 489 | """ 490 | try: 491 | return authenticators[self.authid]. sign(self.as_string(forsigning=True)) == self.signature 492 | except KeyError: 493 | return False 494 | 495 | @classmethod 496 | def open(cls, typename): 497 | """Create an OMAPI open message with given typename. 498 | @type typename: bytes 499 | @rtype: OmapiMessage 500 | """ 501 | return cls(opcode=OMAPI_OP_OPEN, message=[(b"type", typename)], tid=-1) 502 | 503 | @classmethod 504 | def update(cls, handle): 505 | """Create an OMAPI update message for the given handle. 506 | @type handle: int 507 | @rtype: OmapiMessage 508 | """ 509 | return cls(opcode=OMAPI_OP_UPDATE, handle=handle, tid=-1) 510 | 511 | @classmethod 512 | def delete(cls, handle): 513 | """Create an OMAPI delete message for given handle. 514 | @type handle: int 515 | @rtype: OmapiMessage 516 | """ 517 | return cls(opcode=OMAPI_OP_DELETE, handle=handle, tid=-1) 518 | 519 | def is_response(self, other): 520 | """Check whether this OMAPI message is a response to the given 521 | OMAPI message. 522 | @rtype: bool 523 | """ 524 | return self.rid == other.tid 525 | 526 | def update_object(self, update): 527 | """ 528 | @type update: {bytes: bytes} 529 | """ 530 | self.obj = [(key, value) for key, value in self.obj if key not in update] 531 | self.obj.extend(update.items()) 532 | 533 | def dump(self): 534 | """ 535 | @rtype: str 536 | @returns: a human readable representation of the message 537 | """ 538 | return "".join(("Omapi message attributes:\n", "authid:\t\t%d\n" % self.authid, "authlen:\t%d\n" % len(self.signature), "opcode:\t\t%s\n" % repr_opcode(self.opcode), "handle:\t\t%d\n" % self.handle, "tid:\t\t%d\n" % self.tid, "rid:\t\t%d\n" % self.rid, "message:\t%r\n" % self.message, "obj:\t\t%r\n" % self.obj, "signature:\t%r\n" % self.signature)) 539 | 540 | def dump_oneline(self): 541 | """ 542 | @rtype: str 543 | @returns: a barely human readable representation in one line 544 | """ 545 | return "authid=%d authlen=%d opcode=%s handle=%d tid=%d rid=%d message=%r obj=%r signature=%r" % (self.authid, len(self.signature), repr_opcode(self.opcode), self.handle, self.tid, self.rid, self.message, self.obj, self.signature) 546 | 547 | 548 | def parse_map(filterfun, parser): 549 | """Creates a new parser that passes the result of the given parser through 550 | the given filterfun. 551 | 552 | >>> list(parse_map(int, (None, "42"))) 553 | [None, 42] 554 | 555 | @type filterfun: obj -> obj 556 | @param parser: parser 557 | @returns: parser 558 | """ 559 | for element in parser: 560 | if element is None: 561 | yield None 562 | else: 563 | yield filterfun(element) 564 | break 565 | 566 | 567 | def parse_chain(*args): 568 | """Creates a new parser that executes the passed parsers (args) with the 569 | previous results and yields a tuple of the results. 570 | 571 | >>> list(parse_chain(lambda: (None, 1), lambda one: (None, 2))) 572 | [None, None, (1, 2)] 573 | 574 | @param args: parsers 575 | @returns: parser 576 | """ 577 | items = [] 578 | for parser in args: 579 | for element in parser(*items): 580 | if element is None: 581 | yield None 582 | else: 583 | items.append(element) 584 | break 585 | yield tuple(items) 586 | 587 | 588 | class InBuffer(object): # pylint:disable=useless-object-inheritance 589 | sizelimit = 65536 590 | 591 | def __init__(self, initial=b""): 592 | """ 593 | @type initial: bytes 594 | @param initial: initial value of the buffer 595 | @raises OmapiSizeLimitError: 596 | """ 597 | self.buff = b"" 598 | self.totalsize = 0 599 | if initial: 600 | self.feed(initial) 601 | 602 | def feed(self, data): 603 | """ 604 | @type data: bytes 605 | @returns: self 606 | @raises OmapiSizeLimitError: 607 | """ 608 | if self.totalsize + len(data) > self.sizelimit: 609 | raise OmapiSizeLimitError() 610 | self.buff += data 611 | self.totalsize += len(data) 612 | return self 613 | 614 | def resetsize(self): 615 | """This method is to be called after handling a packet to 616 | reset the total size to be parsed at once and that way not 617 | overflow the size limit. 618 | """ 619 | self.totalsize = len(self.buff) 620 | 621 | def parse_fixedbuffer(self, length): 622 | """ 623 | @type length: int 624 | """ 625 | while len(self.buff) < length: 626 | yield None 627 | result = self.buff[:length] 628 | self.buff = self.buff[length:] 629 | yield result 630 | 631 | def parse_net16int(self): 632 | """ 633 | >>> hex(next(InBuffer(b"\\x01\\x02").parse_net16int())) 634 | '0x102' 635 | """ 636 | return parse_map(lambda data: struct.unpack("!H", data)[0], self.parse_fixedbuffer(2)) 637 | 638 | def parse_net32int(self): 639 | """ 640 | >>> hex(int(next(InBuffer(b"\\x01\\0\\0\\x02").parse_net32int()))) 641 | '0x1000002' 642 | """ 643 | return parse_map(lambda data: struct.unpack("!L", data)[0], self.parse_fixedbuffer(4)) 644 | 645 | def parse_net16string(self): 646 | """ 647 | >>> next(InBuffer(b"\\0\\x03eggs").parse_net16string()) == b'egg' 648 | True 649 | """ 650 | return parse_map(operator.itemgetter(1), parse_chain(self.parse_net16int, self.parse_fixedbuffer)) 651 | 652 | def parse_net32string(self): 653 | """ 654 | >>> next(InBuffer(b"\\0\\0\\0\\x03eggs").parse_net32string()) == b'egg' 655 | True 656 | """ 657 | return parse_map(operator.itemgetter(1), parse_chain(self.parse_net32int, self.parse_fixedbuffer)) 658 | 659 | def parse_bindict(self): 660 | """ 661 | >>> d = b"\\0\\x01a\\0\\0\\0\\x01b\\0\\0spam" 662 | >>> next(InBuffer(d).parse_bindict()) == [(b'a', b'b')] 663 | True 664 | """ 665 | entries = [] 666 | try: # pylint:disable=too-many-nested-blocks 667 | while True: 668 | for key in self.parse_net16string(): 669 | if key is None: 670 | yield None 671 | elif not key: 672 | raise StopIteration() 673 | else: 674 | for value in self.parse_net32string(): 675 | if value is None: 676 | yield None 677 | else: 678 | entries.append((key, value)) 679 | break 680 | break 681 | # Abusing StopIteration here, since nothing should be throwing 682 | # it at us. 683 | except StopIteration: 684 | yield entries 685 | 686 | def parse_startup_message(self): 687 | """results in an OmapiStartupMessage 688 | 689 | >>> d = b"\\0\\0\\0\\x64\\0\\0\\0\\x18" 690 | >>> next(InBuffer(d).parse_startup_message()).validate() 691 | """ 692 | return parse_map(lambda args: OmapiStartupMessage(*args), parse_chain(self.parse_net32int, lambda _: self.parse_net32int())) 693 | 694 | def parse_message(self): 695 | """results in an OmapiMessage""" 696 | parser = parse_chain(self.parse_net32int, # authid 697 | lambda *_: self.parse_net32int(), # authlen 698 | lambda *_: self.parse_net32int(), # opcode 699 | lambda *_: self.parse_net32int(), # handle 700 | lambda *_: self.parse_net32int(), # tid 701 | lambda *_: self.parse_net32int(), # rid 702 | lambda *_: self.parse_bindict(), # message 703 | lambda *_: self.parse_bindict(), # object 704 | lambda *args: self.parse_fixedbuffer(args[1])) # signature 705 | return parse_map(lambda args: # skip authlen in args: 706 | OmapiMessage(*(args[0:1] + args[2:])), parser) 707 | 708 | 709 | if isinstance(bytes(b"x")[0], int): 710 | def bytes_to_int_seq(b): 711 | return b 712 | int_seq_to_bytes = bytes # raises ValueError 713 | else: 714 | def bytes_to_int_seq(b): 715 | return [ord(x) for x in b] 716 | 717 | def int_seq_to_bytes(s): 718 | return "".join([chr(x) for x in s]) # raises ValueError 719 | 720 | 721 | __all__.append("pack_ip") 722 | 723 | 724 | def pack_ip(ipstr): 725 | """Converts an ip address given in dotted notation to a four byte 726 | string in network byte order. 727 | 728 | >>> len(pack_ip("127.0.0.1")) 729 | 4 730 | >>> pack_ip("foo") 731 | Traceback (most recent call last): 732 | ... 733 | ValueError: given ip address has an invalid number of dots 734 | 735 | @type ipstr: str 736 | @rtype: bytes 737 | @raises ValueError: for badly formatted ip addresses 738 | """ 739 | if not isinstance(ipstr, basestring): 740 | raise ValueError("given ip address is not a string") 741 | parts = ipstr.split('.') 742 | if len(parts) != 4: 743 | raise ValueError("given ip address has an invalid number of dots") 744 | parts = [int(x) for x in parts] # raises ValueError 745 | return int_seq_to_bytes(parts) # raises ValueError 746 | 747 | 748 | __all__.append("unpack_ip") 749 | 750 | 751 | def unpack_ip(fourbytes): 752 | """Converts an ip address given in a four byte string in network 753 | byte order to a string in dotted notation. 754 | 755 | >>> unpack_ip(b"dead") 756 | '100.101.97.100' 757 | >>> unpack_ip(b"alive") 758 | Traceback (most recent call last): 759 | ... 760 | ValueError: given buffer is not exactly four bytes long 761 | 762 | @type fourbytes: bytes 763 | @rtype: str 764 | @raises ValueError: for bad input 765 | """ 766 | if not isinstance(fourbytes, bytes): 767 | raise ValueError("given buffer is not a string") 768 | if len(fourbytes) != 4: 769 | raise ValueError("given buffer is not exactly four bytes long") 770 | return ".".join([str(x) for x in bytes_to_int_seq(fourbytes)]) 771 | 772 | 773 | __all__.append("pack_mac") 774 | 775 | 776 | def pack_mac(macstr): 777 | """Converts a mac address given in colon delimited notation to a 778 | six byte string in network byte order. 779 | 780 | >>> pack_mac("30:31:32:33:34:35") == b'012345' 781 | True 782 | >>> pack_mac("bad") 783 | Traceback (most recent call last): 784 | ... 785 | ValueError: given mac addresses has an invalid number of colons 786 | 787 | 788 | @type macstr: str 789 | @rtype: bytes 790 | @raises ValueError: for badly formatted mac addresses 791 | """ 792 | if not isinstance(macstr, basestring): 793 | raise ValueError("given mac addresses is not a string") 794 | parts = macstr.split(":") 795 | if len(parts) != 6: 796 | raise ValueError("given mac addresses has an invalid number of colons") 797 | parts = [int(part, 16) for part in parts] # raises ValueError 798 | return int_seq_to_bytes(parts) # raises ValueError 799 | 800 | 801 | __all__.append("unpack_mac") 802 | 803 | 804 | def unpack_mac(sixbytes): 805 | """Converts a mac address given in a six byte string in network 806 | byte order to a string in colon delimited notation. 807 | 808 | >>> unpack_mac(b"012345") 809 | '30:31:32:33:34:35' 810 | >>> unpack_mac(b"bad") 811 | Traceback (most recent call last): 812 | ... 813 | ValueError: given buffer is not exactly six bytes long 814 | 815 | @type sixbytes: bytes 816 | @rtype: str 817 | @raises ValueError: for bad input 818 | """ 819 | if not isinstance(sixbytes, bytes): 820 | raise ValueError("given buffer is not a string") 821 | if len(sixbytes) != 6: 822 | raise ValueError("given buffer is not exactly six bytes long") 823 | return ":".join(["%2.2x".__mod__(x) for x in bytes_to_int_seq(sixbytes)]) 824 | 825 | 826 | class LazyStr(object): # pylint:disable=too-few-public-methods,useless-object-inheritance 827 | def __init__(self, fnc): 828 | self.function = fnc 829 | 830 | def __str__(self): 831 | return self.function() 832 | 833 | 834 | class TCPClientTransport(object): # pylint:disable=useless-object-inheritance 835 | """PEP 3156 dummy transport class to support OmapiProtocol class.""" 836 | def __init__(self, protocol, host, port, timeout=None): 837 | self.protocol = protocol 838 | self.connection = socket.socket() 839 | self.connection.settimeout(timeout) 840 | self.connection.connect((host, port)) 841 | self.protocol.connection_made(self) 842 | 843 | def close(self): 844 | """Close the omapi connection if it is open.""" 845 | if self.connection: 846 | self.connection.close() 847 | self.connection = None 848 | 849 | def fill_inbuffer(self): 850 | """Read bytes from the connection and hand them to the protocol. 851 | @raises OmapiError: 852 | @raises socket.error: 853 | """ 854 | if not self.connection: 855 | raise OmapiError("not connected") 856 | try: 857 | data = self.connection.recv(2048) 858 | except socket.error: 859 | self.close() 860 | raise 861 | if not data: 862 | self.close() 863 | raise OmapiError("connection closed") 864 | try: 865 | self.protocol.data_received(data) 866 | except OmapiSizeLimitError: 867 | self.close() 868 | raise 869 | 870 | def write(self, data): 871 | """Send all of data to the connection. 872 | 873 | @type data: bytes 874 | @raises socket.error: 875 | """ 876 | try: 877 | self.connection.sendall(data) 878 | except socket.error: 879 | self.close() 880 | raise 881 | 882 | 883 | class OmapiProtocol(object): # pylint:disable=useless-object-inheritance 884 | """PEP 3156 like protocol class for Omapi. 885 | 886 | This interface is not yet to be relied upon. 887 | """ 888 | def __init__(self): 889 | self.transport = None 890 | self.authenticators = {0: OmapiNullAuthenticator()} 891 | self.defauth = 0 892 | self.inbuffer = InBuffer() 893 | self.current_parser = self.inbuffer.parse_startup_message() 894 | 895 | def connection_made(self, transport): 896 | self.transport = transport 897 | message = OmapiStartupMessage() 898 | logger.debug("sending omapi startup message %s", LazyStr(message.dump_oneline)) 899 | self.transport.write(message.as_string()) 900 | 901 | def data_received(self, data): 902 | """ 903 | @type data: bytes 904 | """ 905 | self.inbuffer.feed(data) 906 | while True: 907 | if self.current_parser is None: 908 | self.current_parser = self.inbuffer.parse_message() 909 | result = next(self.current_parser) 910 | if result is None: 911 | return 912 | self.current_parser = None 913 | self.inbuffer.resetsize() 914 | if isinstance(result, OmapiStartupMessage): 915 | logger.debug("received omapi startup message %s", LazyStr(result.dump_oneline)) 916 | self.startup_received(result) 917 | else: 918 | assert isinstance(result, OmapiMessage) 919 | logger.debug("received %s", LazyStr(result.dump_oneline)) 920 | self.message_received(result) 921 | 922 | def startup_received(self, startup_message): 923 | try: 924 | startup_message.validate() 925 | except OmapiError: 926 | self.transport.close() 927 | raise 928 | self.startup_completed() 929 | 930 | @staticmethod 931 | def startup_completed(): 932 | logger.debug("omapi connection initialized") 933 | 934 | def message_received(self, message): 935 | pass 936 | 937 | def send_message(self, message, sign=True): 938 | """Send the given message to the connection. 939 | 940 | @type message: OmapiMessage 941 | @param sign: whether the message needs to be signed 942 | @raises OmapiError: 943 | @raises socket.error: 944 | """ 945 | if sign: 946 | message.sign(self.authenticators[self.defauth]) 947 | logger.debug("sending %s", LazyStr(message.dump_oneline)) 948 | self.transport.write(message.as_string()) 949 | 950 | 951 | __all__.append("Omapi") 952 | 953 | 954 | class Omapi(object): # pylint:disable=too-many-public-methods,useless-object-inheritance 955 | def __init__(self, hostname, port, username=None, key=None, timeout=None): # pylint:disable=too-many-arguments 956 | """ 957 | @type hostname: str 958 | @type port: int 959 | @type username: bytes or None 960 | @type key: bytes or None 961 | @param key: if given, it must be base64 encoded 962 | @raises binascii.Error: for bad base64 encoding 963 | @raises socket.error: 964 | @raises OmapiError: 965 | """ 966 | self.hostname = hostname 967 | self.port = port 968 | self.protocol = OmapiProtocol() 969 | self.recv_message_queue = [] 970 | self.protocol.startup_completed = lambda: self.recv_message_queue.append(None) 971 | self.protocol.message_received = self.recv_message_queue.append 972 | 973 | newauth = None 974 | if username is not None and key is not None: 975 | newauth = OmapiHMACMD5Authenticator(username, key) 976 | 977 | self.transport = TCPClientTransport(self.protocol, hostname, port, timeout=timeout) 978 | 979 | self.recv_protocol_initialization() 980 | 981 | if newauth: 982 | self.initialize_authenticator(newauth) 983 | 984 | def close(self): 985 | """Close the omapi connection if it is open.""" 986 | self.transport.close() 987 | 988 | def check_connected(self): 989 | """Raise an OmapiError unless connected. 990 | @raises OmapiError: 991 | """ 992 | if not self.transport.connection: 993 | raise OmapiError("not connected") 994 | 995 | def recv_protocol_initialization(self): 996 | """ 997 | @raises OmapiError: 998 | @raises socket.error: 999 | """ 1000 | while not self.recv_message_queue: 1001 | self.transport.fill_inbuffer() 1002 | message = self.recv_message_queue.pop(0) 1003 | assert message is None 1004 | 1005 | def receive_message(self): 1006 | """Read the next message from the connection. 1007 | @rtype: OmapiMessage 1008 | @raises OmapiError: 1009 | @raises socket.error: 1010 | """ 1011 | while not self.recv_message_queue: 1012 | self.transport.fill_inbuffer() 1013 | message = self.recv_message_queue.pop(0) 1014 | assert message is not None 1015 | if not message.verify(self.protocol.authenticators): 1016 | self.close() 1017 | raise OmapiError("bad omapi message signature") 1018 | return message 1019 | 1020 | def receive_response(self, message, insecure=False): 1021 | """Read the response for the given message. 1022 | @type message: OmapiMessage 1023 | @type insecure: bool 1024 | @param insecure: avoid an OmapiError about a wrong authenticator 1025 | @rtype: OmapiMessage 1026 | @raises OmapiError: 1027 | @raises socket.error: 1028 | """ 1029 | response = self.receive_message() 1030 | if not response.is_response(message): 1031 | raise OmapiError("received message is not the desired response") 1032 | # signature already verified 1033 | if response.authid != self.protocol.defauth and not insecure: 1034 | raise OmapiError("received message is signed with wrong authenticator") 1035 | return response 1036 | 1037 | def send_message(self, message, sign=True): 1038 | """Sends the given message to the connection. 1039 | @type message: OmapiMessage 1040 | @type sign: bool 1041 | @param sign: whether the message needs to be signed 1042 | @raises OmapiError: 1043 | @raises socket.error: 1044 | """ 1045 | self.check_connected() 1046 | self.protocol.send_message(message, sign) 1047 | 1048 | def query_server(self, message): 1049 | """Send the message and receive a response for it. 1050 | @type message: OmapiMessage 1051 | @rtype: OmapiMessage 1052 | @raises OmapiError: 1053 | @raises socket.error: 1054 | """ 1055 | self.send_message(message) 1056 | return self.receive_response(message) 1057 | 1058 | def initialize_authenticator(self, authenticator): 1059 | """ 1060 | @type authenticator: OmapiAuthenticatorBase 1061 | @raises OmapiError: 1062 | @raises socket.error: 1063 | """ 1064 | msg = OmapiMessage.open(b"authenticator") 1065 | msg.update_object(authenticator.auth_object()) 1066 | response = self.query_server(msg) 1067 | if response.opcode != OMAPI_OP_UPDATE: 1068 | raise OmapiError("received non-update response for open") 1069 | authid = response.handle 1070 | if authid == 0: 1071 | raise OmapiError("received invalid authid from server") 1072 | self.protocol.authenticators[authid] = authenticator 1073 | authenticator.authid = authid 1074 | self.protocol.defauth = authid 1075 | logger.debug("successfully initialized default authid %d", authid) 1076 | 1077 | def lookup_ip_host(self, mac): 1078 | """Lookup a host object with with given mac address. 1079 | 1080 | @type mac: str 1081 | @raises ValueError: 1082 | @raises OmapiError: 1083 | @raises OmapiErrorNotFound: if no lease object with the given mac could be found 1084 | @raises OmapiErrorAttributeNotFound: if lease could be found, but objects lacks a ip 1085 | @raises socket.error: 1086 | """ 1087 | res = self.lookup_by_host(mac=mac) 1088 | try: 1089 | return res["ip-address"] 1090 | except KeyError: 1091 | raise OmapiErrorAttributeNotFound() # pylint:disable=raise-missing-from 1092 | 1093 | def lookup_ip(self, mac): 1094 | """Look for a lease object with given mac address and return the 1095 | assigned ip address. 1096 | 1097 | @type mac: str 1098 | @rtype: str or None 1099 | @raises ValueError: 1100 | @raises OmapiError: 1101 | @raises OmapiErrorNotFound: if no lease object with the given mac could be found 1102 | @raises OmapiErrorAttributeNotFound: if lease could be found, but objects lacks a ip 1103 | @raises socket.error: 1104 | """ 1105 | res = self.lookup_by_lease(mac=mac) 1106 | try: 1107 | return res["ip-address"] 1108 | except KeyError: 1109 | raise OmapiErrorAttributeNotFound() # pylint:disable=raise-missing-from 1110 | 1111 | def lookup_mac(self, ip): 1112 | """Look up a lease object with given ip address and return the 1113 | associated mac address. 1114 | 1115 | @type ip: str 1116 | @rtype: str or None 1117 | @raises ValueError: 1118 | @raises OmapiError: 1119 | @raises OmapiErrorNotFound: if no lease object with the given ip could be found 1120 | @raises OmapiErrorAttributeNotFound: if lease could be found, but objects lacks a mac 1121 | @raises socket.error: 1122 | """ 1123 | res = self.lookup_by_lease(ip=ip) 1124 | try: 1125 | return res["hardware-address"] 1126 | except KeyError: 1127 | raise OmapiErrorAttributeNotFound() # pylint:disable=raise-missing-from 1128 | 1129 | def lookup_host(self, name): 1130 | """Look for a host object with given name and return the 1131 | name, mac, and ip address 1132 | 1133 | @type name: str 1134 | @rtype: dict or None 1135 | @raises ValueError: 1136 | @raises OmapiError: 1137 | @raises OmapiErrorNotFound: if no host object with the given name could be found 1138 | @raises OmapiErrorAttributeNotFound: if lease could be found, but objects lacks ip, mac or name 1139 | @raises socket.error: 1140 | """ 1141 | res = self.lookup_by_host(name=name) 1142 | try: 1143 | return dict(ip=res["ip-address"], mac=res["hardware-address"], hostname=res["name"].decode('utf-8')) 1144 | except KeyError: 1145 | raise OmapiErrorAttributeNotFound() # pylint:disable=raise-missing-from 1146 | 1147 | def lookup_host_by_ip(self, ip): 1148 | """Look for a host object with given ip address and return the 1149 | name, mac, and ip address 1150 | 1151 | @type ip: str 1152 | @rtype: dict or None 1153 | @raises ValueError: 1154 | @raises OmapiError: 1155 | @raises OmapiErrorNotFound: if no host object with the given name could be found 1156 | @raises OmapiErrorAttributeNotFound: if lease could be found, but objects lacks ip, mac or name 1157 | @raises socket.error: 1158 | """ 1159 | res = self.lookup_by_host(ip=ip) 1160 | try: 1161 | return dict(ip=res["ip-address"], mac=res["hardware-address"], hostname=res["name"].decode('utf-8')) 1162 | except KeyError: 1163 | raise OmapiErrorAttributeNotFound() # pylint:disable=raise-missing-from 1164 | 1165 | def lookup_host_host(self, mac): 1166 | """Look for a host object with given mac address and return the 1167 | name, mac, and ip address 1168 | 1169 | @type mac: str 1170 | @rtype: dict or None 1171 | @raises ValueError: 1172 | @raises OmapiError: 1173 | @raises OmapiErrorNotFound: if no host object with the given mac address could be found 1174 | @raises OmapiErrorAttributeNotFound: if lease could be found, but objects lacks ip, mac or name 1175 | @raises socket.error: 1176 | """ 1177 | res = self.lookup_by_host(mac=mac) 1178 | try: 1179 | return dict(ip=res["ip-address"], mac=res["hardware-address"], name=res["name"].decode('utf-8')) 1180 | except KeyError: 1181 | raise OmapiErrorAttributeNotFound() # pylint:disable=raise-missing-from 1182 | 1183 | def lookup_hostname(self, ip): 1184 | """Look up a lease object with given ip address and return the associated client hostname. 1185 | 1186 | @type ip: str 1187 | @rtype: str or None 1188 | @raises ValueError: 1189 | @raises OmapiError: 1190 | @raises OmapiErrorNotFound: if no lease object with the given ip address could be found 1191 | @raises OmapiErrorAttributeNotFound: if lease could be found, but objects lacks a hostname 1192 | @raises socket.error: 1193 | """ 1194 | res = self.lookup_by_lease(ip=ip) 1195 | if "client-hostname" not in res: 1196 | raise OmapiErrorAttributeNotFound() 1197 | return res["client-hostname"].decode('utf-8') 1198 | 1199 | def lookup_by_host(self, **kwargs): 1200 | return self.__lookup("host", **kwargs) 1201 | 1202 | def lookup_by_lease(self, **kwargs): 1203 | return self.__lookup("lease", **kwargs) 1204 | 1205 | def lookup_failoverstate(self, name, attribute): 1206 | """Look up a failover-state object with given peer name and return the associated attribute. 1207 | 1208 | @type name: str 1209 | @type attribute: str 1210 | @rtype: str or None 1211 | @raises ValueError: 1212 | @raises OmapiError: 1213 | @raises OmapiErrorNotFound: if no failover-state object with the given name could be found 1214 | @raises OmapiErrorAttributeNotFound: if failover-state object could be found, but object lacks the attribute 1215 | @raises socket.error: 1216 | """ 1217 | res = self.lookup_by_failoverstate(name=name) 1218 | if attribute not in res: 1219 | raise OmapiErrorAttributeNotFound() 1220 | return res[attribute] 1221 | 1222 | def lookup_by_failoverstate(self, **kwargs): 1223 | return self.__lookup("failover-state", **kwargs) 1224 | 1225 | def __lookup(self, ltype, **kwargs): 1226 | """Generic Lookup function 1227 | 1228 | @type ltype: str 1229 | @type rvalues: list 1230 | @type ip: str 1231 | @type mac: str 1232 | @type name: str 1233 | @rtype: dict or str (if len(rvalues) == 1) or None 1234 | @raises ValueError: 1235 | @raises OmapiError: 1236 | @raises OmapiErrorNotFound: if no host object with the given name 1237 | could be found or the object lacks an ip address or mac 1238 | @raises socket.error: 1239 | """ 1240 | ltype_utf = ltype.encode("utf-8") 1241 | assert ltype_utf in [b"host", b"lease", b"failover-state"] 1242 | msg = OmapiMessage.open(ltype_utf) 1243 | for k in kwargs: # pylint:disable=consider-using-dict-items 1244 | if k == "raw": 1245 | continue 1246 | _k = k.replace("_", "-") 1247 | if _k in ["ip", "ip-address"]: 1248 | msg.obj.append((b"ip-address", pack_ip(kwargs[k]))) 1249 | elif _k in ["mac", "hardware-address"]: 1250 | msg.obj.append((b"hardware-address", pack_mac(kwargs[k]))) 1251 | msg.obj.append((b"hardware-type", struct.pack("!I", 1))) 1252 | elif _k == "name": 1253 | msg.obj.append((b"name", kwargs[k].encode('utf-8'))) 1254 | else: 1255 | msg.obj.append((str(_k).encode(), kwargs[k].encode('utf-8'))) 1256 | response = self.query_server(msg) 1257 | if response.opcode != OMAPI_OP_UPDATE: 1258 | raise OmapiErrorNotFound() 1259 | if "raw" in kwargs and kwargs["raw"]: 1260 | return dict(response.obj) 1261 | res = {} 1262 | for k, v in dict(response.obj).items(): 1263 | _k = k.decode('utf-8') 1264 | try: 1265 | if _k == "ip-address": 1266 | v = unpack_ip(v) 1267 | elif _k in ["hardware-address"]: 1268 | v = unpack_mac(v) 1269 | elif _k in ["starts", "ends", "tstp", "tsfp", "atsfp", "cltt", "subnet", "pool", "state", "hardware-type"]: 1270 | v = struct.unpack(">I", v)[0] 1271 | elif _k in ["flags"]: 1272 | v = struct.unpack(">I", v)[0] 1273 | except struct.error: 1274 | pass 1275 | res[_k] = v 1276 | return res 1277 | 1278 | def add_host(self, ip, mac): 1279 | """Create a host object with given ip address and and mac address. 1280 | 1281 | @type ip: str 1282 | @type mac: str 1283 | @raises ValueError: 1284 | @raises OmapiError: 1285 | @raises socket.error: 1286 | """ 1287 | msg = OmapiMessage.open(b"host") 1288 | msg.message.append((b"create", struct.pack("!I", 1))) 1289 | msg.message.append((b"exclusive", struct.pack("!I", 1))) 1290 | msg.obj.append((b"hardware-address", pack_mac(mac))) 1291 | msg.obj.append((b"hardware-type", struct.pack("!I", 1))) 1292 | msg.obj.append((b"ip-address", pack_ip(ip))) 1293 | response = self.query_server(msg) 1294 | if response.opcode != OMAPI_OP_UPDATE: 1295 | raise OmapiError("add failed") 1296 | 1297 | def add_host_supersede_name(self, ip, mac, name): # pylint:disable=E0213 1298 | """Add a host with a fixed-address and override its hostname with the given name. 1299 | @type self: Omapi 1300 | @type ip: str 1301 | @type mac: str 1302 | @type name: str 1303 | @raises ValueError: 1304 | @raises OmapiError: 1305 | @raises socket.error: 1306 | """ 1307 | msg = OmapiMessage.open(b"host") 1308 | msg.message.append((b"create", struct.pack("!I", 1))) 1309 | msg.message.append((b"exclusive", struct.pack("!I", 1))) 1310 | msg.obj.append((b"hardware-address", pack_mac(mac))) 1311 | msg.obj.append((b"hardware-type", struct.pack("!I", 1))) 1312 | msg.obj.append((b"ip-address", pack_ip(ip))) 1313 | msg.obj.append((b"name", name.encode('utf-8'))) 1314 | msg.obj.append((b"statements", 'supersede host-name "{0}";'.format(name).encode('utf-8'))) 1315 | response = self.query_server(msg) 1316 | if response.opcode != OMAPI_OP_UPDATE: 1317 | raise OmapiError("add failed") 1318 | 1319 | def add_host_without_ip(self, mac): 1320 | """Create a host object with given mac address without assigning a static ip address. 1321 | @type mac: str 1322 | @raises ValueError: 1323 | @raises OmapiError: 1324 | @raises socket.error: 1325 | """ 1326 | msg = OmapiMessage.open(b"host") 1327 | msg.message.append((b"create", struct.pack("!I", 1))) 1328 | msg.message.append((b"exclusive", struct.pack("!I", 1))) 1329 | msg.obj.append((b"hardware-address", pack_mac(mac))) 1330 | msg.obj.append((b"hardware-type", struct.pack("!I", 1))) 1331 | response = self.query_server(msg) 1332 | if response.opcode != OMAPI_OP_UPDATE: 1333 | raise OmapiError("add failed") 1334 | 1335 | def add_host_supersede(self, ip, mac, name, hostname=None, router=None, domain=None): # pylint:disable=too-many-arguments 1336 | """Create a host object with given ip, mac, name, hostname, router and 1337 | domain. hostname, router and domain are optional arguments. 1338 | 1339 | @type ip: str 1340 | @type mac: str 1341 | @type name: str 1342 | @type hostname: str 1343 | @type router: str 1344 | @type domain: str 1345 | @raises OmapiError: 1346 | @raises socket.error: 1347 | """ 1348 | stmts = [] 1349 | 1350 | msg = OmapiMessage.open(b"host") 1351 | msg.message.append((b"create", struct.pack("!I", 1))) 1352 | msg.obj.append((b"name", name.encode('utf-8'))) 1353 | msg.obj.append((b"hardware-address", pack_mac(mac))) 1354 | msg.obj.append((b"hardware-type", struct.pack("!I", 1))) 1355 | msg.obj.append((b"ip-address", pack_ip(ip))) 1356 | if hostname: 1357 | stmts.append('supersede host-name "{0}";\n '.format(hostname)) 1358 | if router: 1359 | stmts.append('supersede routers {0};\n '.format(router)) 1360 | if domain: 1361 | stmts.append('supersede domain-name "{0}";'.format(domain)) 1362 | if stmts: 1363 | encoded_stmts = "".join(stmts).encode("utf-8") 1364 | msg.obj.append((b"statements", encoded_stmts)) 1365 | 1366 | response = self.query_server(msg) 1367 | if response.opcode != OMAPI_OP_UPDATE: 1368 | raise OmapiError("add failed") 1369 | 1370 | def del_host(self, mac): 1371 | """Delete a host object with with given mac address. 1372 | 1373 | @type mac: str 1374 | @raises ValueError: 1375 | @raises OmapiError: 1376 | @raises OmapiErrorNotFound: if no lease object with the given 1377 | mac address could be found 1378 | @raises socket.error: 1379 | """ 1380 | msg = OmapiMessage.open(b"host") 1381 | msg.obj.append((b"hardware-address", pack_mac(mac))) 1382 | msg.obj.append((b"hardware-type", struct.pack("!I", 1))) 1383 | response = self.query_server(msg) 1384 | if response.opcode != OMAPI_OP_UPDATE: 1385 | raise OmapiErrorNotFound() 1386 | if response.handle == 0: 1387 | raise OmapiError("received invalid handle from server") 1388 | response = self.query_server(OmapiMessage.delete(response.handle)) 1389 | if response.opcode != OMAPI_OP_STATUS: 1390 | raise OmapiError("delete failed") 1391 | 1392 | def add_group(self, groupname, statements): 1393 | """ 1394 | Adds a group 1395 | @type groupname: bytes 1396 | @type statements: str 1397 | """ 1398 | msg = OmapiMessage.open(b"group") 1399 | msg.message.append(("create", struct.pack("!I", 1))) 1400 | msg.obj.append(("name", groupname)) 1401 | msg.obj.append(("statements", statements)) 1402 | response = self.query_server(msg) 1403 | if response.opcode != OMAPI_OP_UPDATE: 1404 | raise OmapiError("add group failed") 1405 | 1406 | def add_host_with_group(self, ip, mac, groupname): 1407 | """ 1408 | Adds a host with given ip and mac in a group named groupname 1409 | @type ip: str 1410 | @type mac: str 1411 | @type groupname: str 1412 | """ 1413 | msg = OmapiMessage.open(b"host") 1414 | msg.message.append(("create", struct.pack("!I", 1))) 1415 | msg.message.append(("exclusive", struct.pack("!I", 1))) 1416 | msg.obj.append(("hardware-address", pack_mac(mac))) 1417 | msg.obj.append(("hardware-type", struct.pack("!I", 1))) 1418 | msg.obj.append(("ip-address", pack_ip(ip))) 1419 | msg.obj.append(("group", groupname)) 1420 | response = self.query_server(msg) 1421 | if response.opcode != OMAPI_OP_UPDATE: 1422 | raise OmapiError("add failed") 1423 | 1424 | def change_group(self, name, group): 1425 | """Change the group of a host given the name of the host. 1426 | @type name: str 1427 | @type group: str 1428 | """ 1429 | m1 = OmapiMessage.open(b"host") 1430 | m1.update_object(dict(name=name)) 1431 | r1 = self.query_server(m1) 1432 | if r1.opcode != OMAPI_OP_UPDATE: 1433 | raise OmapiError("opening host %s failed" % name) 1434 | m2 = OmapiMessage.update(r1.handle) 1435 | m2.update_object(dict(group=group)) 1436 | r2 = self.query_server(m2) 1437 | if r2.opcode != OMAPI_OP_UPDATE: 1438 | raise OmapiError("changing group of host %s to %s failed" % (name, group)) 1439 | 1440 | 1441 | if __name__ == "__main__": 1442 | import doctest 1443 | doctest.testmod() 1444 | -------------------------------------------------------------------------------- /pypureomapi.spec: -------------------------------------------------------------------------------- 1 | %if 0%{?rhel} && 0%{?rhel} <= 7 2 | %{!?py2_build: %global py2_build %{__python2} setup.py build} 3 | %{!?py2_install: %global py2_install %{__python2} setup.py install --skip-build --root %{buildroot}} 4 | %endif 5 | 6 | %if (0%{?fedora} >= 21 || 0%{?rhel} >= 8) 7 | %global with_python3 1 8 | %endif 9 | 10 | %define srcname pypureomapi 11 | %define version 0.8 12 | %define release 1 13 | %define sum Cygnus Networks GmbH %{srcname} package 14 | 15 | Name: python-%{srcname} 16 | Version: %{version} 17 | Release: %{release}%{?dist} 18 | Summary: %{sum} 19 | License: proprietary 20 | Source0: python-%{srcname}-%{version}.tar.gz 21 | 22 | BuildArch: noarch 23 | BuildRequires: python2-devel, python-setuptools 24 | %if 0%{?with_check} 25 | BuildRequires: pytest 26 | %endif # with_check 27 | Requires: python-setuptools 28 | 29 | %{?python_provide:%python_provide python-%{project}} 30 | 31 | %if 0%{?with_python3} 32 | BuildRequires: python3-devel 33 | BuildRequires: python3-setuptools 34 | %if 0%{?with_check} 35 | BuildRequires: python3-pytest 36 | %endif # with_check 37 | %endif # with_python3 38 | 39 | %description 40 | %{sum} 41 | 42 | %if 0%{?with_python3} 43 | %package -n python3-%{project} 44 | Summary: %{sum} 45 | %{?python_provide:%python_provide python3-%{project}} 46 | Requires: python3-setuptools 47 | 48 | %description -n python3-%{project} 49 | %{sum} 50 | %endif # with_python3 51 | 52 | %prep 53 | %setup -q -n python-%{srcname}-%{version} 54 | 55 | %build 56 | %py2_build 57 | 58 | %if 0%{?with_python3} 59 | %py3_build 60 | %endif # with_python3 61 | 62 | 63 | %install 64 | %py2_install 65 | 66 | %if 0%{?with_python3} 67 | %py3_install 68 | %endif # with_python3 69 | 70 | %if 0%{?with_check} 71 | %check 72 | LANG=en_US.utf8 py.test-%{python2_version} -vv tests 73 | 74 | %if 0%{?with_python3} 75 | LANG=en_US.utf8 py.test-%{python3_version} -vv tests 76 | %endif # with_python3 77 | %endif # with_check 78 | 79 | %files 80 | %{python2_sitelib}/%{srcname}.py* 81 | %{python2_sitelib}/%{srcname}-%{version}-py2.*.egg-info 82 | 83 | %if 0%{?with_python3} 84 | %files -n python3-%{project} 85 | %dir %{python3_sitelib}/%{srcname}/__pycache__ 86 | %{python2_sitelib}/%{srcname}.py* 87 | %{python3_sitelib}/%{srcname}-%{version}-py3.*.egg-info 88 | %{python3_sitelib}/%{srcname}/__pycache__/*.py* 89 | %endif # with_python3 90 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | description-file = README.md 3 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf8 -*- 3 | # library for communicating with an isc dhcp server over the omapi protocol 4 | # 5 | # Copyright 2010-2017 Cygnus Networks GmbH 6 | # 7 | # Licensed under the Apache License, Version 2.0 (the "License"); 8 | # you may not use this file except in compliance with the License. 9 | # You may obtain a copy of the License at 10 | # 11 | # http://www.apache.org/licenses/LICENSE-2.0 12 | # 13 | # Unless required by applicable law or agreed to in writing, software 14 | # distributed under the License is distributed on an "AS IS" BASIS, 15 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 16 | # See the License for the specific language governing permissions and 17 | # limitations under the License. 18 | 19 | import distutils.core 20 | 21 | distutils.core.setup(name='pypureomapi', 22 | version='0.8', 23 | description="ISC DHCP OMAPI protocol implementation in Python", 24 | long_description="This module provides a OMAPI implementation for managing ISC DHCP server by OMAPI protocol purely in Python code. You can query, create or modify ISC DHCP leases with this module. This module grew out of frustration about pyomapi and later pyomapic, which use swig and the static library provided with ISC DHCP without proper error handling. pypureomapi fixes these issues and can be used more or less as a drop-in replacement for pyomapic.", 25 | author='Helmut Grohne', 26 | author_email='h.grohne@cygnusnetworks.de', 27 | maintainer='Dr. Torge Szczepanek', 28 | maintainer_email='debian@cygnusnetworks.de', 29 | license='Apache-2.0', 30 | url='https://github.com/CygnusNetworks/pypureomapi', 31 | py_modules=['pypureomapi'], 32 | classifiers=[ 33 | "Development Status :: 5 - Production/Stable", 34 | "Intended Audience :: System Administrators", 35 | "License :: OSI Approved :: Apache Software License", 36 | "Programming Language :: Python", 37 | "Programming Language :: Python :: 2", 38 | "Programming Language :: Python :: 2.6", 39 | "Programming Language :: Python :: 2.7", 40 | "Programming Language :: Python :: 3", 41 | "Programming Language :: Python :: 3.4", 42 | "Programming Language :: Python :: 3.5", 43 | "Programming Language :: Python :: 3.6", 44 | "Topic :: Internet", 45 | "Topic :: System :: Networking", 46 | "Topic :: Software Development :: Libraries :: Python Modules", 47 | ] 48 | ) 49 | --------------------------------------------------------------------------------