commit d8f7e538537478c5d375d86da2b064dfa88a966f Author: Milan Stute Date: Thu May 16 11:28:33 2019 +0200 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6ac6e66 --- /dev/null +++ b/.gitignore @@ -0,0 +1,108 @@ +# Created by https://www.gitignore.io/api/python,visualstudiocode + +### Python ### +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.coverage +.coverage.* +.cache +.pytest_cache/ +nosetests.xml +coverage.xml +*.cover +.hypothesis/ + +# Translations +*.mo +*.pot + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# pyenv +.python-version + +# celery beat schedule file +celerybeat-schedule.* + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ + +### VisualStudioCode ### +.vscode/* +.history + +### IDEA ### +.idea/ diff --git a/COPYING b/COPYING new file mode 100644 index 0000000..4cc504a --- /dev/null +++ b/COPYING @@ -0,0 +1,1082 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + + + GNU LESSER GENERAL PUBLIC LICENSE + Version 2.1, February 1999 + + Copyright (C) 1991, 1999 Free Software Foundation, Inc. + 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + +[This is the first released version of the Lesser GPL. It also counts + as the successor of the GNU Library Public License, version 2, hence + the version number 2.1.] + + Preamble + + The licenses for most software are designed to take away your +freedom to share and change it. By contrast, the GNU General Public +Licenses are intended to guarantee your freedom to share and change +free software--to make sure the software is free for all its users. + + This license, the Lesser General Public License, applies to some +specially designated software packages--typically libraries--of the +Free Software Foundation and other authors who decide to use it. You +can use it too, but we suggest you first think carefully about whether +this license or the ordinary General Public License is the better +strategy to use in any particular case, based on the explanations below. + + When we speak of free software, we are referring to freedom of use, +not price. Our General Public Licenses are designed to make sure that +you have the freedom to distribute copies of free software (and charge +for this service if you wish); that you receive source code or can get +it if you want it; that you can change the software and use pieces of +it in new free programs; and that you are informed that you can do +these things. + + To protect your rights, we need to make restrictions that forbid +distributors to deny you these rights or to ask you to surrender these +rights. These restrictions translate to certain responsibilities for +you if you distribute copies of the library or if you modify it. + + For example, if you distribute copies of the library, whether gratis +or for a fee, you must give the recipients all the rights that we gave +you. You must make sure that they, too, receive or can get the source +code. If you link other code with the library, you must provide +complete object files to the recipients, so that they can relink them +with the library after making changes to the library and recompiling +it. And you must show them these terms so they know their rights. + + We protect your rights with a two-step method: (1) we copyright the +library, and (2) we offer you this license, which gives you legal +permission to copy, distribute and/or modify the library. + + To protect each distributor, we want to make it very clear that +there is no warranty for the free library. Also, if the library is +modified by someone else and passed on, the recipients should know +that what they have is not the original version, so that the original +author's reputation will not be affected by problems that might be +introduced by others. + + Finally, software patents pose a constant threat to the existence of +any free program. We wish to make sure that a company cannot +effectively restrict the users of a free program by obtaining a +restrictive license from a patent holder. Therefore, we insist that +any patent license obtained for a version of the library must be +consistent with the full freedom of use specified in this license. + + Most GNU software, including some libraries, is covered by the +ordinary GNU General Public License. This license, the GNU Lesser +General Public License, applies to certain designated libraries, and +is quite different from the ordinary General Public License. We use +this license for certain libraries in order to permit linking those +libraries into non-free programs. + + When a program is linked with a library, whether statically or using +a shared library, the combination of the two is legally speaking a +combined work, a derivative of the original library. The ordinary +General Public License therefore permits such linking only if the +entire combination fits its criteria of freedom. The Lesser General +Public License permits more lax criteria for linking other code with +the library. + + We call this license the "Lesser" General Public License because it +does Less to protect the user's freedom than the ordinary General +Public License. It also provides other free software developers Less +of an advantage over competing non-free programs. These disadvantages +are the reason we use the ordinary General Public License for many +libraries. However, the Lesser license provides advantages in certain +special circumstances. + + For example, on rare occasions, there may be a special need to +encourage the widest possible use of a certain library, so that it becomes +a de-facto standard. To achieve this, non-free programs must be +allowed to use the library. A more frequent case is that a free +library does the same job as widely used non-free libraries. In this +case, there is little to gain by limiting the free library to free +software only, so we use the Lesser General Public License. + + In other cases, permission to use a particular library in non-free +programs enables a greater number of people to use a large body of +free software. For example, permission to use the GNU C Library in +non-free programs enables many more people to use the whole GNU +operating system, as well as its variant, the GNU/Linux operating +system. + + Although the Lesser General Public License is Less protective of the +users' freedom, it does ensure that the user of a program that is +linked with the Library has the freedom and the wherewithal to run +that program using a modified version of the Library. + + The precise terms and conditions for copying, distribution and +modification follow. Pay close attention to the difference between a +"work based on the library" and a "work that uses the library". The +former contains code derived from the library, whereas the latter must +be combined with the library in order to run. + + GNU LESSER GENERAL PUBLIC LICENSE + TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION + + 0. This License Agreement applies to any software library or other +program which contains a notice placed by the copyright holder or +other authorized party saying it may be distributed under the terms of +this Lesser General Public License (also called "this License"). +Each licensee is addressed as "you". + + A "library" means a collection of software functions and/or data +prepared so as to be conveniently linked with application programs +(which use some of those functions and data) to form executables. + + The "Library", below, refers to any such software library or work +which has been distributed under these terms. A "work based on the +Library" means either the Library or any derivative work under +copyright law: that is to say, a work containing the Library or a +portion of it, either verbatim or with modifications and/or translated +straightforwardly into another language. (Hereinafter, translation is +included without limitation in the term "modification".) + + "Source code" for a work means the preferred form of the work for +making modifications to it. For a library, complete source code means +all the source code for all modules it contains, plus any associated +interface definition files, plus the scripts used to control compilation +and installation of the library. + + Activities other than copying, distribution and modification are not +covered by this License; they are outside its scope. The act of +running a program using the Library is not restricted, and output from +such a program is covered only if its contents constitute a work based +on the Library (independent of the use of the Library in a tool for +writing it). Whether that is true depends on what the Library does +and what the program that uses the Library does. + + 1. You may copy and distribute verbatim copies of the Library's +complete source code as you receive it, in any medium, provided that +you conspicuously and appropriately publish on each copy an +appropriate copyright notice and disclaimer of warranty; keep intact +all the notices that refer to this License and to the absence of any +warranty; and distribute a copy of this License along with the +Library. + + You may charge a fee for the physical act of transferring a copy, +and you may at your option offer warranty protection in exchange for a +fee. + + 2. You may modify your copy or copies of the Library or any portion +of it, thus forming a work based on the Library, and copy and +distribute such modifications or work under the terms of Section 1 +above, provided that you also meet all of these conditions: + + a) The modified work must itself be a software library. + + b) You must cause the files modified to carry prominent notices + stating that you changed the files and the date of any change. + + c) You must cause the whole of the work to be licensed at no + charge to all third parties under the terms of this License. + + d) If a facility in the modified Library refers to a function or a + table of data to be supplied by an application program that uses + the facility, other than as an argument passed when the facility + is invoked, then you must make a good faith effort to ensure that, + in the event an application does not supply such function or + table, the facility still operates, and performs whatever part of + its purpose remains meaningful. + + (For example, a function in a library to compute square roots has + a purpose that is entirely well-defined independent of the + application. Therefore, Subsection 2d requires that any + application-supplied function or table used by this function must + be optional: if the application does not supply it, the square + root function must still compute square roots.) + +These requirements apply to the modified work as a whole. If +identifiable sections of that work are not derived from the Library, +and can be reasonably considered independent and separate works in +themselves, then this License, and its terms, do not apply to those +sections when you distribute them as separate works. But when you +distribute the same sections as part of a whole which is a work based +on the Library, the distribution of the whole must be on the terms of +this License, whose permissions for other licensees extend to the +entire whole, and thus to each and every part regardless of who wrote +it. + +Thus, it is not the intent of this section to claim rights or contest +your rights to work written entirely by you; rather, the intent is to +exercise the right to control the distribution of derivative or +collective works based on the Library. + +In addition, mere aggregation of another work not based on the Library +with the Library (or with a work based on the Library) on a volume of +a storage or distribution medium does not bring the other work under +the scope of this License. + + 3. You may opt to apply the terms of the ordinary GNU General Public +License instead of this License to a given copy of the Library. To do +this, you must alter all the notices that refer to this License, so +that they refer to the ordinary GNU General Public License, version 2, +instead of to this License. (If a newer version than version 2 of the +ordinary GNU General Public License has appeared, then you can specify +that version instead if you wish.) Do not make any other change in +these notices. + + Once this change is made in a given copy, it is irreversible for +that copy, so the ordinary GNU General Public License applies to all +subsequent copies and derivative works made from that copy. + + This option is useful when you wish to copy part of the code of +the Library into a program that is not a library. + + 4. You may copy and distribute the Library (or a portion or +derivative of it, under Section 2) in object code or executable form +under the terms of Sections 1 and 2 above provided that you accompany +it with the complete corresponding machine-readable source code, which +must be distributed under the terms of Sections 1 and 2 above on a +medium customarily used for software interchange. + + If distribution of object code is made by offering access to copy +from a designated place, then offering equivalent access to copy the +source code from the same place satisfies the requirement to +distribute the source code, even though third parties are not +compelled to copy the source along with the object code. + + 5. A program that contains no derivative of any portion of the +Library, but is designed to work with the Library by being compiled or +linked with it, is called a "work that uses the Library". Such a +work, in isolation, is not a derivative work of the Library, and +therefore falls outside the scope of this License. + + However, linking a "work that uses the Library" with the Library +creates an executable that is a derivative of the Library (because it +contains portions of the Library), rather than a "work that uses the +library". The executable is therefore covered by this License. +Section 6 states terms for distribution of such executables. + + When a "work that uses the Library" uses material from a header file +that is part of the Library, the object code for the work may be a +derivative work of the Library even though the source code is not. +Whether this is true is especially significant if the work can be +linked without the Library, or if the work is itself a library. The +threshold for this to be true is not precisely defined by law. + + If such an object file uses only numerical parameters, data +structure layouts and accessors, and small macros and small inline +functions (ten lines or less in length), then the use of the object +file is unrestricted, regardless of whether it is legally a derivative +work. (Executables containing this object code plus portions of the +Library will still fall under Section 6.) + + Otherwise, if the work is a derivative of the Library, you may +distribute the object code for the work under the terms of Section 6. +Any executables containing that work also fall under Section 6, +whether or not they are linked directly with the Library itself. + + 6. As an exception to the Sections above, you may also combine or +link a "work that uses the Library" with the Library to produce a +work containing portions of the Library, and distribute that work +under terms of your choice, provided that the terms permit +modification of the work for the customer's own use and reverse +engineering for debugging such modifications. + + You must give prominent notice with each copy of the work that the +Library is used in it and that the Library and its use are covered by +this License. You must supply a copy of this License. If the work +during execution displays copyright notices, you must include the +copyright notice for the Library among them, as well as a reference +directing the user to the copy of this License. Also, you must do one +of these things: + + a) Accompany the work with the complete corresponding + machine-readable source code for the Library including whatever + changes were used in the work (which must be distributed under + Sections 1 and 2 above); and, if the work is an executable linked + with the Library, with the complete machine-readable "work that + uses the Library", as object code and/or source code, so that the + user can modify the Library and then relink to produce a modified + executable containing the modified Library. (It is understood + that the user who changes the contents of definitions files in the + Library will not necessarily be able to recompile the application + to use the modified definitions.) + + b) Use a suitable shared library mechanism for linking with the + Library. A suitable mechanism is one that (1) uses at run time a + copy of the library already present on the user's computer system, + rather than copying library functions into the executable, and (2) + will operate properly with a modified version of the library, if + the user installs one, as long as the modified version is + interface-compatible with the version that the work was made with. + + c) Accompany the work with a written offer, valid for at + least three years, to give the same user the materials + specified in Subsection 6a, above, for a charge no more + than the cost of performing this distribution. + + d) If distribution of the work is made by offering access to copy + from a designated place, offer equivalent access to copy the above + specified materials from the same place. + + e) Verify that the user has already received a copy of these + materials or that you have already sent this user a copy. + + For an executable, the required form of the "work that uses the +Library" must include any data and utility programs needed for +reproducing the executable from it. However, as a special exception, +the materials to be distributed need not include anything that is +normally distributed (in either source or binary form) with the major +components (compiler, kernel, and so on) of the operating system on +which the executable runs, unless that component itself accompanies +the executable. + + It may happen that this requirement contradicts the license +restrictions of other proprietary libraries that do not normally +accompany the operating system. Such a contradiction means you cannot +use both them and the Library together in an executable that you +distribute. + + 7. You may place library facilities that are a work based on the +Library side-by-side in a single library together with other library +facilities not covered by this License, and distribute such a combined +library, provided that the separate distribution of the work based on +the Library and of the other library facilities is otherwise +permitted, and provided that you do these two things: + + a) Accompany the combined library with a copy of the same work + based on the Library, uncombined with any other library + facilities. This must be distributed under the terms of the + Sections above. + + b) Give prominent notice with the combined library of the fact + that part of it is a work based on the Library, and explaining + where to find the accompanying uncombined form of the same work. + + 8. You may not copy, modify, sublicense, link with, or distribute +the Library except as expressly provided under this License. Any +attempt otherwise to copy, modify, sublicense, link with, or +distribute the Library is void, and will automatically terminate your +rights under this License. However, parties who have received copies, +or rights, from you under this License will not have their licenses +terminated so long as such parties remain in full compliance. + + 9. You are not required to accept this License, since you have not +signed it. However, nothing else grants you permission to modify or +distribute the Library or its derivative works. These actions are +prohibited by law if you do not accept this License. Therefore, by +modifying or distributing the Library (or any work based on the +Library), you indicate your acceptance of this License to do so, and +all its terms and conditions for copying, distributing or modifying +the Library or works based on it. + + 10. Each time you redistribute the Library (or any work based on the +Library), the recipient automatically receives a license from the +original licensor to copy, distribute, link with or modify the Library +subject to these terms and conditions. You may not impose any further +restrictions on the recipients' exercise of the rights granted herein. +You are not responsible for enforcing compliance by third parties with +this License. + + 11. If, as a consequence of a court judgment or allegation of patent +infringement or for any other reason (not limited to patent issues), +conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot +distribute so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you +may not distribute the Library at all. For example, if a patent +license would not permit royalty-free redistribution of the Library by +all those who receive copies directly or indirectly through you, then +the only way you could satisfy both it and this License would be to +refrain entirely from distribution of the Library. + +If any portion of this section is held invalid or unenforceable under any +particular circumstance, the balance of the section is intended to apply, +and the section as a whole is intended to apply in other circumstances. + +It is not the purpose of this section to induce you to infringe any +patents or other property right claims or to contest validity of any +such claims; this section has the sole purpose of protecting the +integrity of the free software distribution system which is +implemented by public license practices. Many people have made +generous contributions to the wide range of software distributed +through that system in reliance on consistent application of that +system; it is up to the author/donor to decide if he or she is willing +to distribute software through any other system and a licensee cannot +impose that choice. + +This section is intended to make thoroughly clear what is believed to +be a consequence of the rest of this License. + + 12. If the distribution and/or use of the Library is restricted in +certain countries either by patents or by copyrighted interfaces, the +original copyright holder who places the Library under this License may add +an explicit geographical distribution limitation excluding those countries, +so that distribution is permitted only in or among countries not thus +excluded. In such case, this License incorporates the limitation as if +written in the body of this License. + + 13. The Free Software Foundation may publish revised and/or new +versions of the Lesser General Public License from time to time. +Such new versions will be similar in spirit to the present version, +but may differ in detail to address new problems or concerns. + +Each version is given a distinguishing version number. If the Library +specifies a version number of this License which applies to it and +"any later version", you have the option of following the terms and +conditions either of that version or of any later version published by +the Free Software Foundation. If the Library does not specify a +license version number, you may choose any version ever published by +the Free Software Foundation. + + 14. If you wish to incorporate parts of the Library into other free +programs whose distribution conditions are incompatible with these, +write to the author to ask for permission. For software which is +copyrighted by the Free Software Foundation, write to the Free +Software Foundation; we sometimes make exceptions for this. Our +decision will be guided by the two goals of preserving the free status +of all derivatives of our free software and of promoting the sharing +and reuse of software generally. + + NO WARRANTY + + 15. BECAUSE THE LIBRARY IS LICENSED FREE OF CHARGE, THERE IS NO +WARRANTY FOR THE LIBRARY, TO THE EXTENT PERMITTED BY APPLICABLE LAW. +EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR +OTHER PARTIES PROVIDE THE LIBRARY "AS IS" WITHOUT WARRANTY OF ANY +KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE +LIBRARY IS WITH YOU. SHOULD THE LIBRARY PROVE DEFECTIVE, YOU ASSUME +THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN +WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY +AND/OR REDISTRIBUTE THE LIBRARY AS PERMITTED ABOVE, BE LIABLE TO YOU +FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR +CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE +LIBRARY (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING +RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A +FAILURE OF THE LIBRARY TO OPERATE WITH ANY OTHER SOFTWARE), EVEN IF +SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH +DAMAGES. + + END OF TERMS AND CONDITIONS \ No newline at end of file diff --git a/Manifest.in b/Manifest.in new file mode 100644 index 0000000..a1fa3a7 --- /dev/null +++ b/Manifest.in @@ -0,0 +1,7 @@ +include README.md +exclude .gitignore +prune .cache +prune .git +prune build +prune dist +recursive-exclude *.egg-info * diff --git a/README.md b/README.md new file mode 100644 index 0000000..7273dc3 --- /dev/null +++ b/README.md @@ -0,0 +1,96 @@ +# OpenDrop: an Open Source AirDrop Implementation + +*OpenDrop* is a command-line tool that allows sharing files between devices directly over Wi-Fi. Its unique feature is that it is protocol-compatible with Apple AirDrop which allows to share files with Apple devices running iOS and macOS. +Currently (and probably also for the foreseeable future), OpenDrop only supports sending to Apple devices that are discoverable by *everybody* as the default *contacts only* mode requires [Apple-signed certificates](https://www.apple.com/certificateauthority/pdf/Apple_AAI_CPS_v6.1.pdf). + + +## Disclaimer + +OpenDrop is experimental software and is the result of reverse engineering efforts by the [Open Wireless Link](https://owlink.org) project. +Therefore, it does not support all features of AirDrop or might be incompatible with future AirDrop versions. +OpenDrop is not affiliated with or endorsed by Apple Inc. Use this code at your own risk. + + +## Requirements + +To achieve compatibility with Apple AirDrop, OpenDrop requires the target platform to support a specific Wi-Fi link layer as well as several libraries. + +**Apple Wireless Direct Link.** +As AirDrop exclusively runs over Apple Wireless Direct Link (AWDL), OpenDrop is only supported on macOS or on Linux systems running +an open re-implementation of AWDL. + +**Libraries.** +OpenDrop relies on current versions of [OpenSSL](https://www.openssl.org) and [libarchive](https://www.libarchive.org). +macOS ships with rather old versions of the two, so you will need to install newer version, for example, via [Homebrew](https://brew.sh). +In any case, you will need to set the two environmental variables `LIBARCHIVE` and `LIBCRYPTO` accordingly. +For example, use `brew` to install the libraries: +```bash +brew install libarchive openssl@1.1 +``` +Then set environmental variables: +```bash +export LIBARCHIVE=/usr/local/opt/libarchive/lib/libarchive.dylib +export LIBCRYPTO=/usr/local/opt/openssl@1.1/lib/libcrypto.dylib +``` +Linux distributions should ship with more up-to-date versions, so this won't be necessary. + + +## Installation + +Installation of the python package is straight forward. +After cloning this repository to ``, install via `pip3`: +``` +pip3 install +``` + + +## Usage + +We briefly explain how to send and receive files using `opendrop`. +To see all command line options, run `opendrop -h`. + +### Sending a File + +Sending a file is typically a two-step procedure. You first discover devices in proximity using the `find` command. +Stop the process once you have found the receiver. +``` +$ opendrop find +Looking for receivers. Press enter to stop ... +Found index 0 ID eccb2f2dcfe7 name John’s iPhone +Found index 1 ID e63138ac6ba8 name Jane’s MacBook Pro +``` +You can then `send` a file using +``` +$ opendrop send -r 0 -f /path/to/some/file +Asking receiver to accept ... +Receiver accepted +Uploading file ... +Uploading has been successful +``` +Instead of the `index`, you can also use `ID` or `name`. +OpenDrop will try to interpret the input in the order (1) `index`, (2) `ID`, and (3) `name` and fail if no match was found. + +### Receiving Files + +Receiving is much easier. Simply use the `receive` command. OpenDrop will accept all incoming files automatically and put received files in the current directory. +``` +$ opendrop receive +``` + + +## Related Papers + +* Milan Stute, Sashank Narain, Alex Mariotto, Alexander Heinrich, David Kreitschmann, Guevara Noubir, and Matthias Hollick. **A Billion Open Interfaces for Eve and Mallory: MitM, DoS, and Tracking Attacks on iOS and macOS Through Apple Wireless Direct Link.** *28th USENIX Security Symposium (USENIX Security ’19)*, August 14–16, 2019, Santa Clara, CA, USA. [Link](https://www.usenix.org/conference/usenixsecurity19/presentation/stute) + + +## Authors + +* **Milan Stute** ([email](mailto:mstute@seemoo.tu-darmstadt.de), [web](https://seemoo.de/mstute)) +* **Alexander Heinrich** + + +## License + +OpenDrop is licensed under the **GNU General Public License v3.0**. +We use a modified version of the [`python-zeroconf`](https://pypi.org/project/zeroconf/) package (essentially adding rudimentary IPv6 and AWDL support) which is licensed under the **GNU Lesser General Public License v2.1**. +Both licenses are found in the `COPYING` file. diff --git a/opendrop/__init__.py b/opendrop/__init__.py new file mode 100644 index 0000000..e28c712 --- /dev/null +++ b/opendrop/__init__.py @@ -0,0 +1,24 @@ +""" +OpenDrop: an open source AirDrop implementation +Copyright (C) 2018 Milan Stute +Copyright (C) 2018 Alexander Heinrich + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +""" + +import logging + +__version__ = '0.10' + +logger = logging.getLogger(__name__) diff --git a/opendrop/__main__.py b/opendrop/__main__.py new file mode 100644 index 0000000..55b6281 --- /dev/null +++ b/opendrop/__main__.py @@ -0,0 +1,22 @@ +""" +OpenDrop: an open source AirDrop implementation +Copyright (C) 2018 Milan Stute +Copyright (C) 2018 Alexander Heinrich + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +""" + +from opendrop import cli + +cli.main() diff --git a/opendrop/certs/apple_root_ca.pem b/opendrop/certs/apple_root_ca.pem new file mode 100644 index 0000000..a27252d --- /dev/null +++ b/opendrop/certs/apple_root_ca.pem @@ -0,0 +1,28 @@ +-----BEGIN CERTIFICATE----- +MIIEuzCCA6OgAwIBAgIBAjANBgkqhkiG9w0BAQUFADBiMQswCQYDVQQGEwJVUzET +MBEGA1UEChMKQXBwbGUgSW5jLjEmMCQGA1UECxMdQXBwbGUgQ2VydGlmaWNhdGlv +biBBdXRob3JpdHkxFjAUBgNVBAMTDUFwcGxlIFJvb3QgQ0EwHhcNMDYwNDI1MjE0 +MDM2WhcNMzUwMjA5MjE0MDM2WjBiMQswCQYDVQQGEwJVUzETMBEGA1UEChMKQXBw +bGUgSW5jLjEmMCQGA1UECxMdQXBwbGUgQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkx +FjAUBgNVBAMTDUFwcGxlIFJvb3QgQ0EwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAw +ggEKAoIBAQDkkakJH5HbHkdQ6wXtXnmELes2oldMVeyLGYne+Uts9QerIjAC6Bg+ ++FAJ039BqJj50cpmnCRrEdCju+QbKsMflZ56DKRHi1vUFjczy8QPTc4UadHJGXL1 +XQ7Vf1+b8iUDulWPTV0N8WQ1IxVLFVkds5T39pyez1C6wVhQZ48ItCD3y6wsIG9w +tj8BMIy3Q88PnT3zK0koGsj+zrW5DtleHNbLPbU6rfQPDgCSC7EhFi501TwN22IW +q6NxkkdTVcGvL0Gz+PvjcM3mo0xFfh9Ma1CWQYnEdGILEINBhzOKgbEwWOxaBDKM +aLOPHd5lc/9nXmW8Sdh2nzMUZaF3lMktAgMBAAGjggF6MIIBdjAOBgNVHQ8BAf8E +BAMCAQYwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUK9BpR5R2Cf70a40uQKb3 +R01/CF4wHwYDVR0jBBgwFoAUK9BpR5R2Cf70a40uQKb3R01/CF4wggERBgNVHSAE +ggEIMIIBBDCCAQAGCSqGSIb3Y2QFATCB8jAqBggrBgEFBQcCARYeaHR0cHM6Ly93 +d3cuYXBwbGUuY29tL2FwcGxlY2EvMIHDBggrBgEFBQcCAjCBthqBs1JlbGlhbmNl +IG9uIHRoaXMgY2VydGlmaWNhdGUgYnkgYW55IHBhcnR5IGFzc3VtZXMgYWNjZXB0 +YW5jZSBvZiB0aGUgdGhlbiBhcHBsaWNhYmxlIHN0YW5kYXJkIHRlcm1zIGFuZCBj +b25kaXRpb25zIG9mIHVzZSwgY2VydGlmaWNhdGUgcG9saWN5IGFuZCBjZXJ0aWZp +Y2F0aW9uIHByYWN0aWNlIHN0YXRlbWVudHMuMA0GCSqGSIb3DQEBBQUAA4IBAQBc +NplMLXi37Yyb3PN3m/J20ncwT8EfhYOFG5k9RzfyqZtAjizUsZAS2L70c5vu0mQP +y3lPNNiiPvl4/2vIB+x9OYOLUyDTOMSxv5pPCmv/K/xZpwUJfBdAVhEedNO3iyM7 +R6PVbyTi69G3cN8PReEnyvFteO3ntRcXqNx+IjXKJdXZD9Zr1KIkIxH3oayPc4Fg +xhtbCS+SsvhESPBgOJ4V9T0mZyCKM2r3DYLP3uujL/lTaltkwGMzd/c6ByxW69oP +IQ7aunMZT7XZNn/Bh1XZp5m5MkL72NVxnn6hUrcbvZNCJBIqxw8dtk2cXmPIS4AX +UKqK1drk/NAJBzewdXUh +-----END CERTIFICATE----- diff --git a/opendrop/cli.py b/opendrop/cli.py new file mode 100644 index 0000000..02fa4aa --- /dev/null +++ b/opendrop/cli.py @@ -0,0 +1,196 @@ +""" +OpenDrop: an open source AirDrop implementation +Copyright (C) 2018 Milan Stute +Copyright (C) 2018 Alexander Heinrich + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +""" + +import time + +import ipaddress +import logging +import argparse +import sys +import json +import os +import threading + +from .client import AirDropBrowser, AirDropClient +from .config import AirDropConfig, AirDropReceiverFlags +from .server import AirDropServer + +logger = logging.getLogger(__name__) + + +def main(): + AirDropCli(sys.argv[1:]) + + +class AirDropCli: + + def __init__(self, args): + parser = argparse.ArgumentParser() + parser.add_argument('action', choices=['receive', 'find', 'send']) + parser.add_argument('-f', '--file', help='File to be sent') + parser.add_argument('-r', '--receiver', help='Peer to send file to (can be index, ID, or hostname)') + parser.add_argument('-e', '--email', nargs='*', help='User\'s email addresses (currently unused)') + parser.add_argument('-p', '--phone', nargs='*', help='User\'s phone numbers (currently unused)') + parser.add_argument('-l', '--legacy', help='Enable legacy mode', action='store_true') + parser.add_argument('-d', '--debug', help='Enable debug mode', action='store_true') + parser.add_argument('-i', '--interface', help='Which AWDL interface to use', default='awdl0') + args = parser.parse_args(args) + + if args.debug: + logging.basicConfig(level=logging.DEBUG, format='%(asctime)s %(levelname)-8s %(name)s: %(message)s') + else: + logging.basicConfig(level=logging.INFO, format='%(message)s') + + # TODO put emails and phone in canonical form (lower case, no '+' sign, etc.) + + self.config = AirDropConfig(email=args.email, phone=args.phone, legacy=args.legacy, + debug=args.debug, interface=args.interface) + self.server = None + self.client = None + self.browser = None + self.sending_started = False + self.discover = [] + self.lock = threading.Lock() + + try: + if args.action == 'receive': + self.receive() + elif args.action == 'find': + self.find() + else: # args.action == 'send' + if args.file is None: + parser.error('Need -f,--file when using send') + if not os.path.isfile(args.file): + parser.error('File in -f,--file not found') + self.file = args.file + if args.receiver is None: + parser.error('Need -r,--receiver when using send') + self.receiver = args.receiver + self.send() + except KeyboardInterrupt: + if self.browser is not None: + self.browser.stop() + if self.server is not None: + self.server.stop() + + def find(self): + logger.info('Looking for receivers. Press enter to stop ...') + self.browser = AirDropBrowser(self.config) + self.browser.start(callback_add=self._found_receiver) + try: + input() + except KeyboardInterrupt: + pass + finally: + self.browser.stop() + logger.debug('Save discovery results to {}'.format(self.config.discovery_report)) + with open(self.config.discovery_report, 'w') as f: + json.dump(self.discover, f) + + def _found_receiver(self, info): + thread = threading.Thread(target=self._send_discover, args=(info,)) + thread.start() + + def _send_discover(self, info): + try: + address = ipaddress.ip_address(info.address).compressed + except ValueError: + return # not a valid address + id = info.name.split('.')[0] + hostname = info.server + port = int(info.port) + logger.debug('AirDrop service found: {}, {}:{}, ID {}'.format(hostname, address, port, id)) + client = AirDropClient(self.config, (address, int(port))) + flags = int(info.properties[b'flags']) + + if flags & AirDropReceiverFlags.SUPPORTS_DISCOVER_MAYBE: + try: + receiver_name = client.send_discover() + except TimeoutError: + pass + else: + receiver_name = None + discoverable = receiver_name is not None + + index = len(self.discover) + node_info = { + 'name': receiver_name, + 'address': address, + 'port': port, + 'id': id, + 'flags': flags, + 'discoverable': discoverable, + } + self.lock.acquire() + self.discover.append(node_info) + if discoverable: + logger.info('Found index {} ID {} name {}'.format(index, id, receiver_name)) + self.lock.release() + + def receive(self): + self.server = AirDropServer(self.config) + self.server.start_service() + self.server.start_server() + + def send(self): + info = self._get_receiver_info() + if info is None: + return + self.client = AirDropClient(self.config, (info['address'], info['port'])) + logger.info('Asking receiver to accept ...') + if not self.client.send_ask(self.file): + logger.warning('Receiver declined') + return + logger.info('Receiver accepted') + logger.info('Uploading file ...') + if not self.client.send_upload(self.file): + logger.warning('Uploading has failed') + return + logger.info('Uploading has been successful') + + def _get_receiver_info(self): + if not os.path.exists(self.config.discovery_report): + logger.error('No discovery report exists, please run \'opendrop find\' first') + return None + age = time.time() - os.path.getmtime(self.config.discovery_report) + if age > 60: # warn if report is older than a minute + logger.warning('Old discovery report (%.1f seconds), consider running \'opendrop find\' again', age) + with open(self.config.discovery_report, 'r') as f: + infos = json.load(f) + + # (1) try 'index' + try: + self.receiver = int(self.receiver) + return infos[self.receiver] + except ValueError: + pass + except IndexError: + pass + # (2) try 'id' + if len(self.receiver) is 12: + for info in infos: + if info['id'] == self.receiver: + return info + # (3) try hostname + for info in infos: + if info['name'] == self.receiver: + return info + # (fail) + logger.error('Receiver does not exist (check -r,--receiver format or try \'opendrop find\' again') + return None diff --git a/opendrop/client.py b/opendrop/client.py new file mode 100644 index 0000000..e149a04 --- /dev/null +++ b/opendrop/client.py @@ -0,0 +1,284 @@ +""" +OpenDrop: an open source AirDrop implementation +Copyright (C) 2018 Milan Stute +Copyright (C) 2018 Alexander Heinrich + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +""" + +import fleep +import http +import ipaddress +import logging +import os +import io +import libarchive +import platform +import plistlib +import socket +from http import client + +from .util import AirDropUtil, AbsArchiveWrite +from .zeroconf import ServiceBrowser, Zeroconf + +logger = logging.getLogger(__name__) + + +class AirDropBrowser: + + def __init__(self, config): + self.legacy_mode = config.legacy + if self.legacy_mode: + self.useIPv6 = False + else: + self.useIPv6 = True + self.ip_interface_name = config.interface + + self.ip_addr, self.byte_address = AirDropUtil.get_ip_for_interface(self.ip_interface_name, ipv6=self.useIPv6) + + if self.ip_addr is None: + raise RuntimeError('Interface {} does not have IP(v6) address'.format(self.ip_interface_name)) + + if self.legacy_mode: + self.zeroconf = Zeroconf() + else: + self.zeroconf = Zeroconf(interfaces=[self.ip_addr], ipv6_interface_name=self.ip_interface_name) + + self.callback_add = None + self.callback_remove = None + self.browser = None + + def start(self, callback_add=None, callback_remove=None): + """ + Start the AirDropBrowser to discover other AirDrop devices + """ + if self.browser is not None: + return # already started + self.callback_add = callback_add + self.callback_remove = callback_remove + self.browser = ServiceBrowser(self.zeroconf, '_airdrop._tcp.local.', self) + + def stop(self): + self.browser.cancel() + self.browser = None + self.zeroconf.close() + + def add_service(self, zeroconf, type, name): + info = zeroconf.get_service_info(type, name) + logger.debug('Add service {}'.format(name)) + if self.callback_add is not None: + self.callback_add(info) + + def remove_service(self, zeroconf, type, name): + info = zeroconf.get_service_info(type, name) + logger.debug('Remove service {}'.format(name)) + if self.callback_remove is not None: + self.callback_remove(info) + + +class AirDropClient: + + def __init__(self, config, receiver): + self.config = config + self.receiver_host = receiver[0] + self.receiver_port = receiver[1] + self.http_conn = None + + def send_POST(self, url, body, headers=None): + logger.debug('Send {} request'.format(url)) + + AirDropUtil.write_debug(self.config, body, 'send_{}_request.plist'.format(url.lower().strip('/'))) + + _headers = self._get_headers() + if headers is not None: + for key, val in headers.items(): + _headers[key] = val + if self.http_conn is None: + # Use single connection + self.http_conn = HTTPSConnectionAWDL(self.receiver_host, self.receiver_port, + interface_name=self.config.interface, + context=self.config.get_ssl_context()) + self.http_conn.request('POST', url, body=body, headers=_headers) + http_resp = self.http_conn.getresponse() + + response_bytes = http_resp.read() + AirDropUtil.write_debug(self.config, response_bytes, 'send_{}_response.plist'.format(url.lower().strip('/'))) + + if http_resp.status != 200: + status = False + logger.debug('{} request failed: {}'.format(url, http_resp.status)) + else: + status = True + logger.debug('{} request successful'.format(url)) + return status, response_bytes + + def send_discover(self): + discover_body = {} + if self.config.record_data: + discover_body['SenderRecordData'] = self.config.record_data + + discover_plist_binary = plistlib.dumps(discover_body, fmt=plistlib.FMT_BINARY) + success, response_bytes = self.send_POST('/Discover', discover_plist_binary) + response = plistlib.loads(response_bytes) + + # if name is returned, then receiver is discoverable + return response.get('ReceiverComputerName') + + def send_ask(self, file_path, icon=None): + ask_body = { + 'SenderComputerName': self.config.computer_name, + 'BundleID': 'com.apple.finder', + 'SenderModelName': self.config.computer_model, + 'SenderID': self.config.service_id, + 'ConvertMediaFormats': False, + } + if self.config.legacy: + ask_body['SenderEmailHash'] = AirDropUtil.doubleSHA1Hash(self.config.email) + ask_body['SenderPhoneHash'] = AirDropUtil.doubleSHA1Hash(self.config.phone) + if self.config.record_data: + ask_body['SenderRecordData'] = self.config.record_data + + if isinstance(file_path, str): + file_path = [file_path] + + # generate icon for first file + with open(file_path[0], 'rb') as f: + file_header = f.read(128) + flp = fleep.get(file_header) + if not icon and len(flp.mime) > 0 and 'image' in flp.mime[0]: + icon = AirDropUtil.generate_file_icon(f.name) + if icon: + ask_body['FileIcon'] = icon + + def file_entries(files): + for file in files: + file_name = os.path.basename(file) + file_entry = { + 'FileName': file_name, + 'FileType': AirDropUtil.get_uti_type(flp), + 'FileBomPath': os.path.join('.', file_name), + 'FileIsDirectory': os.path.isdir(file_name), + 'ConvertMediaFormats': 0 + } + yield file_entry + ask_body['Files'] = [e for e in file_entries(file_path)] + ask_body['Items'] = [] + + ask_binary = plistlib.dumps(ask_body, fmt=plistlib.FMT_BINARY) + success, _ = self.send_POST('/Ask', ask_binary) + + return success + + def send_upload(self, file_path): + """ + Send a file to a receiver. + """ + headers = { + 'Content-Type': 'application/x-cpio', + } + + # Create archive in memory ... + stream = io.BytesIO() + with libarchive.custom_writer(stream.write, 'cpio', filter_name='gzip', + archive_write_class=AbsArchiveWrite) as archive: + for f in [file_path]: + ff = os.path.basename(f) + archive.add_abs_file(f, os.path.join('.', ff)) + stream.seek(0) + + # ... then send in chunked mode + success, _ = self.send_POST('/Upload', stream, headers=headers) + + # TODO better: write archive chunk whenever send_POST does a read to avoid having the whole archive in memory + + return success + + def _get_headers(self): + """ + Get the headers for requests sent + """ + headers = { + 'Content-Type': 'application/octet-stream', + 'Connection': 'keep-alive', + 'Accept': '*/*', + 'User-Agent': 'AirDrop/1.0', + 'Accept-Language': 'en-us', + 'Accept-Encoding': 'br, gzip, deflate' + } + return headers + + +class HTTPSConnectionAWDL(http.client.HTTPSConnection): + """ + This class allows to bind the HTTPConnection to a specific network interface + """ + + def __init__(self, host, port=None, key_file=None, cert_file=None, timeout=None, source_address=None, + *, context=None, check_hostname=None, interface_name=None): + + if interface_name is not None: + if '%' not in host: + if isinstance(ipaddress.ip_address(host), ipaddress.IPv6Address): + host = host + '%' + interface_name + + if timeout is None: + timeout = socket.getdefaulttimeout() + + super(HTTPSConnectionAWDL, self).__init__(host=host, port=port, key_file=key_file, cert_file=cert_file, + timeout=timeout, source_address=source_address, context=context, + check_hostname=check_hostname) + + self.interface_name = interface_name + self._create_connection = self.create_connection_awdl + + def create_connection_awdl(self, address, timeout=socket.getdefaulttimeout(), source_address=None): + """Connect to *address* and return the socket object. + + Convenience function. Connect to *address* (a 2-tuple ``(host, + port)``) and return the socket object. Passing the optional + *timeout* parameter will set the timeout on the socket instance + before attempting to connect. If no *timeout* is supplied, the + global default timeout setting returned by :func:`getdefaulttimeout` + is used. If *source_address* is set it must be a tuple of (host, port) + for the socket to bind as a source address before making the connection. + A host of '' or port 0 tells the OS to use the default. + """ + + host, port = address + err = None + for res in socket.getaddrinfo(host, port, 0, socket.SOCK_STREAM): + af, socktype, proto, canonname, sa = res + sock = None + try: + sock = socket.socket(af, socktype, proto) + if timeout is not socket.getdefaulttimeout(): + sock.settimeout(timeout) + if self.interface_name == 'awdl0' and platform.system() == 'Darwin': + sock.setsockopt(socket.SOL_SOCKET, 0x1104, 1) + if source_address: + sock.bind(source_address) + sock.connect(sa) + # Break explicitly a reference cycle + err = None + return sock + + except socket.error as _: + err = _ + if sock is not None: + sock.close() + + if err is not None: + raise err + else: + raise socket.error('getaddrinfo returns an empty list') diff --git a/opendrop/config.py b/opendrop/config.py new file mode 100644 index 0000000..049e73b --- /dev/null +++ b/opendrop/config.py @@ -0,0 +1,117 @@ +""" +OpenDrop: an open source AirDrop implementation +Copyright (C) 2018 Milan Stute +Copyright (C) 2018 Alexander Heinrich + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +""" + +import os +import logging +from pkg_resources import resource_filename +import socket +import ssl +import random +import subprocess + +logger = logging.getLogger(__name__) + + +class AirDropReceiverFlags: + """ + Recovered from sharingd`receiverSupportsX methods. + A valid node needs to either have SUPPORTS_PIPELINING or SUPPORTS_MIXED_TYPES + according to sharingd`[SDBonjourBrowser removeInvalidNodes:]. + """ + SUPPORTS_URL = 0x01 + SUPPORTS_DVZIP = 0x02 + SUPPORTS_PIPELINING = 0x04 + SUPPORTS_MIXED_TYPES = 0x08 + SUPPORTS_UNKNOWN1 = 0x10 + SUPPORTS_UNKNOWN2 = 0x20 + SUPPORTS_IRIS = 0x40 + SUPPORTS_DISCOVER_MAYBE = 0x80 # Probably indicates that server supports /Discover URL + + +class AirDropConfig: + + def __init__(self, host_name=None, computer_name=None, computer_model='OpenDrop', server_port=8771, + airdrop_dir='~/.opendrop', service_id=None, + email=None, phone=None, legacy=False, debug=False, interface=None): + self.airdrop_dir = os.path.expanduser(airdrop_dir) + + self.discovery_report = os.path.join(self.airdrop_dir, 'discover.last.json') + + if host_name is None: + host_name = socket.gethostname() + self.host_name = host_name + if computer_name is None: + computer_name = host_name + self.computer_name = computer_name + self.computer_model = computer_model + self.port = server_port + + if service_id is None: + service_id = '{0:0{1}x}'.format(random.randint(0, 0xffffffffffff), 12) # random 6-byte string in base16 + self.service_id = service_id + + self.debug = debug + self.debug_dir = os.path.join(self.airdrop_dir, 'debug') + + self.legacy = legacy + + if interface is None: + interface = 'awdl0' if not self.legacy else 'en0' + self.interface = interface + + if email is None: + email = [] + self.email = email + if phone is None: + phone = [] + self.phone = phone + + # Bare minimum, we currently do not support anything else + self.flags = AirDropReceiverFlags.SUPPORTS_MIXED_TYPES | AirDropReceiverFlags.SUPPORTS_DISCOVER_MAYBE + + self.root_ca_file = resource_filename('opendrop', 'certs/apple_root_ca.pem') + if not os.path.exists(self.root_ca_file): + raise FileNotFoundError('Need Apple root CA certificate: {}'.format(self.root_ca_file)) + + self.key_dir = os.path.join(self.airdrop_dir, 'keys') + self.cert_file = os.path.join(self.key_dir, 'certificate.pem') + self.key_file = os.path.join(self.key_dir, 'key.pem') + + if not os.path.exists(self.cert_file) or not os.path.exists(self.key_file): + logger.info('Key file or certificate does not exist') + self.create_default_key() + + # TODO extract record data from a sample exchange + self.record_data = None + + def create_default_key(self): + logger.info('Create new self-signed certificate in {}'.format(self.key_dir)) + if not os.path.exists(self.key_dir): + os.makedirs(self.key_dir) + subprocess.run(['openssl', 'req', '-newkey', 'rsa:2048', '-nodes', '-keyout', 'key.pem', + '-x509', '-days', '365', '-out', 'certificate.pem', + '-subj', '/CN={}'.format(self.computer_name)], cwd=self.key_dir, + stdout=subprocess.PIPE, stderr=subprocess.PIPE) + + def get_ssl_context(self): + sslctxt = ssl.SSLContext() + sslctxt.load_cert_chain(self.cert_file, keyfile=self.key_file) + sslctxt.load_verify_locations(cafile=self.root_ca_file) + sslctxt.verify_mode = ssl.CERT_NONE # we accept self-signed certificates as does Apple + return sslctxt diff --git a/opendrop/server.py b/opendrop/server.py new file mode 100644 index 0000000..8e3c2f7 --- /dev/null +++ b/opendrop/server.py @@ -0,0 +1,318 @@ +""" +OpenDrop: an open source AirDrop implementation +Copyright (C) 2018 Milan Stute +Copyright (C) 2018 Alexander Heinrich + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +""" +import io + +import logging +import platform +import plistlib +import socket +from http.server import HTTPServer, BaseHTTPRequestHandler +import json +import libarchive +import libarchive.extract +import libarchive.read +from .zeroconf import Zeroconf, ServiceInfo +import time + +from .util import AirDropUtil + +logger = logging.getLogger(__name__) + + +class AirDropServer: + """ + Announces an HTTPS AirDrop server in the local network via mDNS. + """ + + def __init__(self, config): + self.config = config + + if self.config.legacy: + self.useIPv6 = False + else: + self.useIPv6 = True + self.ip_interface_name = self.config.interface + + if self.useIPv6: + self.address_family = socket.AF_INET6 + self.serveraddress = ('::', self.config.port) + self.ServerClass = HTTPServerV6 + else: + self.address_family = socket.AF_INET + self.serveraddress = ('0.0.0.0', self.config.port) + self.ServerClass = HTTPServer + + self.ServerClass.allow_reuse_address = False + + self.ip_addr, self.byte_address = AirDropUtil.get_ip_for_interface(self.ip_interface_name, ipv6=self.useIPv6) + + self.Handler = AirDropServerHandler + self.Handler.config = self.config + + if self.config.legacy: + self.zeroconf = Zeroconf() + else: + self.zeroconf = Zeroconf(interfaces=[self.ip_addr], ipv6_interface_name=self.ip_interface_name, + apple_mdns=True) + + self.http_server = self._init_server() + self.service_info = self._init_service() + + def _init_service(self): + properties = self.get_properties() + server = self.config.host_name + '.local.' + service_name = self.config.service_id + '._airdrop._tcp.local.' + info = ServiceInfo( + '_airdrop._tcp.local.', service_name, + self.byte_address, self.config.port, 0, 0, properties, server) + return info + + def start_service(self): + logger.info('Announcing service: host {}, address {}, port {}'.format(self.config.host_name, + self.ip_addr, self.config.port)) + self.zeroconf.register_service(self.service_info) + + def _init_server(self): + try: + httpd = self.ServerClass(self.serveraddress, self.Handler) + except OSError: + # Address in use. Change port + self.config.port = self.config.port + 1 + self.serveraddress = (self.serveraddress[0], self.config.port) + httpd = self.ServerClass(self.serveraddress, self.Handler) + + # Adapt socket for awdl0 + if self.ip_interface_name == 'awdl0' and platform.system() == 'Darwin': + httpd.socket.setsockopt(socket.SOL_SOCKET, 0x1104, 1) + + httpd.socket = self.config.get_ssl_context().wrap_socket(sock=httpd.socket, server_side=True) + + return httpd + + def start_server(self): + logger.info('Starting HTTPS server') + self.http_server.serve_forever() + + def stop(self): + self.zeroconf.unregister_all_services() + self.http_server.shutdown() + + def get_properties(self): + properties = {b'flags': str(self.config.flags).encode('utf-8')} + if self.config.legacy: + properties[b'phash'] = AirDropUtil.doubleSHA1Hash(self.config.phone).encode('utf-8') + properties[b'nhash'] = False + properties[b'ehash'] = AirDropUtil.doubleSHA1Hash(self.config.email).encode('utf-8') + properties[b'cname'] = self.config.computer_name.encode('utf-8') + return properties + + +class HTTPServerV6(HTTPServer): + address_family = socket.AF_INET6 + + +class AirDropServerHandler(BaseHTTPRequestHandler): + """ + Server which responds to AirDrop HTTP POST requests + """ + protocol_version = 'HTTP/1.1' + config = None + + def _set_response(self, content_length): + """ + Setting the default values for a successful response + """ + self.send_response(200) + self.send_header('Content-Length', content_length) + self.end_headers() + + def do_HEAD(self): + """ + Answer head requests + """ + self.send_response(200) + self.send_header('Content-type', 'text/html') + self.end_headers() + + def do_GET(self): + """ + Answer get requests + """ + logger.debug('GET request at {}'.format(self.path)) + body = '\n'.encode('utf-8') + self._set_response(len(body)) + self.wfile.write(body) + + def handle_discover(self): + content_length = int(self.headers['Content-Length']) + post_data = self.rfile.read(content_length) + + AirDropUtil.write_debug(self.config, post_data, 'receive_discover_request.plist') + + # sample media capabilities as recorded from macOS 10.13.3 + media_capabilities = { + 'Version': 1, + # don't advertise any codec/container support so we receive legacy file formats (JPEG instead of HEIF, etc.) + # 'Codecs': { + # 'hvc1': { + # 'Profiles': { + # 'VTPerProfileSupport': { + # '1': {'VTMaxPlaybackLevel': 120}, + # '2': {'VTMaxPlaybackLevel': 120}, + # '3': {} + # }, + # 'VTSupportedProfiles': [1, 2, 3] + # } + # } + # }, + # 'ContainerFormats': { + # 'public.heif-standard': { + # 'HeifSubtypes': ['public.avci', 'public.heic', 'public.heif'] + # } + # }, + # 'Vendor': { + # 'com.apple': { + # 'OSVersion': [10, 13, 3], + # 'OSBuildVersion': '17D102', + # 'LivePhotoFormatVersion': '1' + # } + # } + } + media_capabilities_json = json.JSONEncoder().encode(media_capabilities) + media_capabilities_binary = media_capabilities_json.encode('utf-8') + discover_answer = { + 'ReceiverMediaCapabilities': media_capabilities_binary, + 'ReceiverComputerName': self.config.computer_name, + 'ReceiverModelName': self.config.computer_model, + } + if self.config.record_data: + discover_answer['ReceiverRecordData'] = self.config.record_data + + discover_answer_binary = plistlib.dumps(discover_answer, fmt=plistlib.FMT_BINARY) + + AirDropUtil.write_debug(self.config, discover_answer_binary, 'receive_discover_response.plist') + + # Change to actual length + self._set_response(len(discover_answer_binary)) + self.wfile.write(discover_answer_binary) + + def handle_ask(self): + content_length = int(self.headers['Content-Length']) + post_data = self.rfile.read(content_length) + + AirDropUtil.write_debug(self.config, post_data, 'receive_ask_request.plist') + + ask_response = {'ReceiverModelName': self.config.computer_model, + 'ReceiverComputerName': self.config.computer_name} + ask_resp_binary = plistlib.dumps(ask_response, fmt=plistlib.FMT_BINARY) + + AirDropUtil.write_debug(self.config, ask_resp_binary, 'receive_ask_response.plist') + + self._set_response(len(ask_resp_binary)) + self.wfile.write(ask_resp_binary) + + def handle_upload(self): + if self.headers.get('content-type', '').lower() != 'application/x-cpio': + logger.warning('Unsupported content-type: {}'.format(self.headers.get('content-type'))) + self.send_response(406) # Unprocessable Entity + self.send_header('Content-Type', 'application/x-cpio') + self.send_header('Content-Length', 0) + self.send_header('Connection', 'close') + self.end_headers() + return + + # If pipelining is not support, 'Expect: 100-continue' is sent to which we need to respond + if self.headers.get('expect', '').lower() == '100-continue': + self.send_response(100) + self.send_header('Content-Length', 0) + self.end_headers() + + if self.headers.get('transfer-encoding', '').lower() != 'chunked': + logger.warning('Expect chunked transfer encoding') + self.send_response(400) # Bad Request + self.send_header('Transfer-Encoding', 'Chunked') + self.send_header('Content-Length', 0) + self.send_header('Connection', 'close') + self.end_headers() + return + + class HTTPChunkedReader(io.RawIOBase): + def __init__(self, rfile, *args, **kwargs): + super().__init__(*args, **kwargs) + self.rfile = rfile + self.chunk = None + self.total = 0 + + def _next_chunk(self): + if self.chunk is None or len(self.chunk) is 0: + length = int(self.rfile.readline().rstrip(), 16) + self.chunk = self.rfile.read(length) + self.rfile.readline() # strip trailing \n\r + + def readinto(self, buf): + self._next_chunk() + l = min(len(self.chunk), len(buf)) + buf[:l] = self.chunk[:l] + self.chunk = self.chunk[l:] + self.total += l + return l + + def extract_stream(stream, flags=0): + """ + Extracts an archive from memory into the current directory. + """ + + with libarchive.read.stream_reader(stream) as archive: + libarchive.extract.extract_entries(archive, flags) + + logger.info('Receiving file(s) ...') + start = time.time() + reader = HTTPChunkedReader(self.rfile) + extract_stream(reader) + + transferred = reader.total / 1024.0 / 1024.0 + speed = transferred / (time.time() - start) + logger.info('File(s) received (size {:.02f} MB, speed {:.02f} MB/s)'.format(transferred, speed)) + + self.send_response(200) + self.send_header('Content-Length', 0) + self.send_header('Connection', 'close') + self.end_headers() + + def do_POST(self): + """ + Handle post requests + """ + + logger.debug('POST request at {}'.format(self.path)) + logger.debug('Headers\n{}'.format(self.headers)) + + if self.path == '/Discover': + self.handle_discover() + elif self.path == '/Ask': + self.handle_ask() + elif self.path == '/Upload': + self.handle_upload() + else: + answer = 'POST request for {}'.format(self.path).encode('utf-8') + self._set_response(len(answer)) + self.wfile.write(answer) + + def log_message(self, format, *args): + logger.debug('{} - - [{}] {}'.format(self.client_address[0], self.log_date_time_string(), format % args)) diff --git a/opendrop/util.py b/opendrop/util.py new file mode 100644 index 0000000..87af69e --- /dev/null +++ b/opendrop/util.py @@ -0,0 +1,293 @@ +""" +OpenDrop: an open source AirDrop implementation +Copyright (C) 2018 Milan Stute +Copyright (C) 2018 Alexander Heinrich + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +""" + +import netifaces + +import base64 +import datetime +import io +import ipaddress +import os +import platform +import plistlib +import socket +from Crypto.Hash import SHA, SHA256 +from PIL import Image, ExifTags +from libarchive import ffi +from libarchive.entry import new_archive_entry, ArchiveEntry +from libarchive.ffi import ( + ARCHIVE_EOF, + entry_sourcepath, + entry_clear, + read_next_header2, + read_disk_descend, + write_header, + write_data, + write_finish_entry, +) +from libarchive.write import ArchiveWrite, new_archive_read_disk +if platform.system() == 'Darwin' and os.getenv('LIBCRYPTO') is not None: + import ctypescrypto + from ctypes import CDLL, c_uint64, c_void_p + ctypescrypto.__libname__ = os.environ['LIBCRYPTO'] + ctypescrypto.libcrypto = CDLL(ctypescrypto.__libname__) + if hasattr(ctypescrypto.libcrypto,'OPENSSL_init_crypto'): + ctypescrypto.libcrypto.OPENSSL_init_crypto.argtypes = (c_uint64,c_void_p) + ctypescrypto.libcrypto.OPENSSL_init_crypto(2+4+8+0x40,None) + strings_loaded = True + else: + ctypescrypto.libcrypto.OPENSSL_add_all_algorithms_conf() + strings_loaded = False +from ctypescrypto import cms, x509, pkey, oid + + +class AirDropUtil: + """ + This class contains a set of utility functions that support the opendrop implementation + They have been moved, because the opendrop files tend to get too long + """ + + @staticmethod + def get_uti_type(flp) -> str: + """ + Get the Apple conform UTI Type from a flp instance which has been used on the data which should be sent + + :param flp: fleep object + """ + + # Default UTI Type + uti_type = 'public.content' + if len(flp.mime) == 0 or len(flp.type) == 0: + return uti_type + + mime = flp.mime[0] + f_type = flp.type[0] + if 'image' in mime: + uti_type = 'public.image' + + if 'jpg' in mime: + uti_type = 'public.jpeg' + elif 'jp2' in mime: + uti_type = 'public.jpeg-2000' + elif 'gif' in mime: + uti_type = 'com.compuserve.gif' + elif 'png' in mime: + uti_type = 'public.png' + elif 'raw' in mime or 'raw' in f_type: + uti_type = 'public.camera-raw-image' + elif 'audio' in f_type: + uti_type = 'public.audio' + elif 'video' in f_type: + uti_type = 'public.video' + elif 'archive' in f_type: + uti_type = 'public.data' + + if 'gzip' in mime: + uti_type = 'org.gnu.gnu-zip-archive' + if 'zip' in mime: + uti_type = 'public.zip-archive' + + return uti_type + + @staticmethod + def record_data(config, tls_cert, sign_cert, key): + """ + This method generates the sender record data and will sign it using the CMS format. + + This code serves documentation purposes only and is UNTESTED. To be accepted by Apple clients, we would need the + Apple-owned private key of the signing certificate. + + :param tls_cert: path to certificate used for AirDrop TLS connections + :param sign_cert: path to signing certificate + :param key: path to private key to the signing certificate + """ + + valid_date = datetime.datetime.now() - datetime.timedelta(days=3) + valid_date_string = valid_date.strftime('%Y-%m-%dT%H:%M:%SZ') + + emails_hashed = [SHA256.new(email.encode('utf-8')).hexdigest() for email in config.email] + phone_numbers_hashed = [SHA256.new(phone_number.encode('utf-8')).hexdigest() for phone_number in config.phone] + + # Get the common name of the TLS certificate + with open(tls_cert, 'rb') as cert_file: + cert = x509.X509(cert_file.read()) + cn = cert.subject[oid.Oid('2.5.4.3')] + encDsID = cn.replace('com.apple.idms.appleid.prd.', '') + + # Construct record data + record_data = { + 'Version': 2, + 'encDsID': encDsID, # Common name suffix of the certificate + 'altDsID': encDsID, # Same as encDsID + 'SuggestValidDuration': 30 * 24 * 60 * 60, # in seconds + 'ValidAsOf': valid_date_string, # 3 days before now + 'ValidatedEmailHashes': emails_hashed, + 'ValidatedPhoneHashes': phone_numbers_hashed, + } + record_data_plist = plistlib.dumps(record_data, fmt=plistlib.FMT_XML) + + with open(sign_cert, 'rb') as sign_cert_file: + with open(key, 'rb') as key_file: + cert = x509.X509(sign_cert_file.read()) + key = pkey.PKey(privkey=key_file.read()) + # possibly need to add intermediate certs + cms_signed = cms.SignedData.create(record_data_plist, cert=cert, pkey=key, certs=None, + flags=cms.Flags.PARTIAL) + signed_data = AirDropUtil.pem2der(cms_signed.pem()) + + return signed_data + + @staticmethod + def doubleSHA1Hash(toHash): + """ + This method gets an array of strings as input and creates a double SHA-1 Hash formatted in BASE64 from it. + It will return a comma seperated list of SHA-1 hashes in BASE64 + + :param toHash: An iterable which contains one or many str + """ + + single_hashed = [SHA.new(to_hash.encode('utf-8')).digest() for to_hash in toHash] + double_hashed = [SHA.new(single).digest() for single in single_hashed] + + double_hashed_base64 = [base64.b64encode(h).decode('utf-8') for h in double_hashed] + hash_string = ','.join(double_hashed_base64) + + return hash_string + + @staticmethod + def pem2der(s): + """ + Create DER Formatted bytes from a PEM Base64 String + + :param s: PEM formatted string + """ + start = s.find('-----\n') + finish = s.rfind('\n-----END') + data = s[start + 6:finish] + return base64.b64decode(data) + + @staticmethod + def generate_file_icon(file_path): + """ + Generates a small and a big thumbnail of an image + This will make it possible to preview the sent file + + :param file_path: The path to the image + """ + im = Image.open(file_path) + + # rotate according to EXIF tags + try: + exif = dict((ExifTags.TAGS[k], v) for k, v in im._getexif().items() if k in ExifTags.TAGS) + angles = {3: 180, 6: 270, 8: 90} + orientation = exif['Orientation'] + if orientation in angles.keys(): + im = im.rotate(angles[orientation], expand=True) + except AttributeError: + pass # no EXIF data available + + # Big image + im.thumbnail((540, 540), Image.ANTIALIAS) + imgByteArr = io.BytesIO() + im.save(imgByteArr, format='JPEG2000') + file_icon = imgByteArr.getvalue() + + # Small image + #im.thumbnail((64, 64), Image.ANTIALIAS) + #imgByteArr = io.BytesIO() + #im.save(imgByteArr, format='JPEG2000') + #small_file_icon = imgByteArr.getvalue() + + return file_icon + + + @staticmethod + def get_ip_for_interface(interface_name, ipv6=False): + """ + Get the ip address in IPv4 or IPv6 for a specific network interface + + :param str interace_name: declares the network interface name for which the ip should be accessed + :param bool ipv6: Boolean indicating if the ipv6 address should be rertrieved + :return: (str ipaddress, byte ipaddress_bytes) returns a tuple with the ip address as a string and in bytes + """ + addresses = netifaces.ifaddresses(interface_name) + + if netifaces.AF_INET6 in addresses and ipv6: + # Use the normal ipv6 address + addr = addresses[netifaces.AF_INET6][0]['addr'].split('%')[0] + bytes_addr = ipaddress.IPv6Address(addr).packed + elif netifaces.AF_INET in addresses and not ipv6: + addr = addresses[netifaces.AF_INET][0]['addr'] + bytes_addr = socket.inet_aton(addr) + else: + addr = None + bytes_addr = None + + return addr, bytes_addr + + @staticmethod + def write_debug(config, data, file_name): + if not config.debug: + return + if not os.path.exists(config.debug_dir): + os.makedirs(config.debug_dir) + debug_file_path = os.path.join(config.debug_dir, file_name) + with open(debug_file_path, 'wb') as file: + if hasattr(data, 'read'): + file.write(data.read()) + data.seek(0) # reset cursor position + else: # assume bytes-like + file.write(data) + + +class AbsArchiveWrite(ArchiveWrite): + def add_abs_file(self, path, store_path): + """ + Read the given paths from disk and add them to the archive. + """ + write_p = self._pointer + + block_size = ffi.write_get_bytes_per_block(write_p) + if block_size <= 0: + block_size = 10240 # pragma: no cover + + with new_archive_entry() as entry_p: + entry = ArchiveEntry(None, entry_p) + with new_archive_read_disk(path) as read_p: + while 1: + r = read_next_header2(read_p, entry_p) + if r == ARCHIVE_EOF: + break + entry.pathname = store_path + read_disk_descend(read_p) + write_header(write_p, entry_p) + try: + with open(entry_sourcepath(entry_p), 'rb') as f: + while 1: + data = f.read(block_size) + if not data: + break + write_data(write_p, data, len(data)) + except IOError as e: + if e.errno != 21: + raise # pragma: no cover + write_finish_entry(write_p) + entry_clear(entry_p) + if os.path.isdir(path): + break diff --git a/opendrop/zeroconf.py b/opendrop/zeroconf.py new file mode 100644 index 0000000..3214eb9 --- /dev/null +++ b/opendrop/zeroconf.py @@ -0,0 +1,2218 @@ +""" Multicast DNS Service Discovery for Python, v0.14-wmcbrine + Copyright 2003 Paul Scott-Murphy, 2014 William McBrine + + This module provides a framework for the use of DNS Service Discovery + using IP multicast. + + This library is free software; you can redistribute it and/or + modify it under the terms of the GNU Lesser General Public + License as published by the Free Software Foundation; either + version 2.1 of the License, or (at your option) any later version. + + This library is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public + License along with this library; if not, write to the Free Software + Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 + USA +""" + +import enum +import errno +import logging +import re +import select +import socket +import struct +import sys +import platform +import threading +import time +from functools import reduce + +import netifaces + +__author__ = 'Paul Scott-Murphy, William McBrine' +__maintainer__ = 'Jakub Stasiak ' +__version__ = '0.20.0' +__license__ = 'LGPL' + + +__all__ = [ + "__version__", + "Zeroconf", "ServiceInfo", "ServiceBrowser", + "Error", "InterfaceChoice", "ServiceStateChange", +] + + +consoleHandler = logging.StreamHandler() +log = logging.getLogger(__name__) +log.addHandler(consoleHandler) + +if log.level == logging.NOTSET: + log.setLevel(logging.INFO) + +# Some timing constants + +_UNREGISTER_TIME = 125 +_CHECK_TIME = 175 +_REGISTER_TIME = 225 +_LISTENER_TIME = 200 +_BROWSER_TIME = 500 + +# Some DNS constants + +_MDNS_ADDR = '224.0.0.251' +_MDNS_ADDR_IPV6 = 'ff02::fb' +_MDNS_PORT = 5353 +_DNS_PORT = 53 +_DNS_TTL = 65536 #60 * 60 # one hour default TTL + +_MAX_MSG_TYPICAL = 1460 # unused +_MAX_MSG_ABSOLUTE = 8966 + +_FLAGS_QR_MASK = 0x8000 # query response mask +_FLAGS_QR_QUERY = 0x0000 # query +_FLAGS_QR_RESPONSE = 0x8000 # response + +_FLAGS_AA = 0x0400 # Authoritative answer +_FLAGS_TC = 0x0200 # Truncated +_FLAGS_RD = 0x0100 # Recursion desired +_FLAGS_RA = 0x8000 # Recursion available + +_FLAGS_Z = 0x0040 # Zero +_FLAGS_AD = 0x0020 # Authentic data +_FLAGS_CD = 0x0010 # Checking disabled + +_CLASS_IN = 1 +_CLASS_CS = 2 +_CLASS_CH = 3 +_CLASS_HS = 4 +_CLASS_NONE = 254 +_CLASS_ANY = 255 +_CLASS_MASK = 0x7FFF +_CLASS_UNIQUE = 0x8000 + +_TYPE_A = 1 +_TYPE_NS = 2 +_TYPE_MD = 3 +_TYPE_MF = 4 +_TYPE_CNAME = 5 +_TYPE_SOA = 6 +_TYPE_MB = 7 +_TYPE_MG = 8 +_TYPE_MR = 9 +_TYPE_NULL = 10 +_TYPE_WKS = 11 +_TYPE_PTR = 12 +_TYPE_HINFO = 13 +_TYPE_MINFO = 14 +_TYPE_MX = 15 +_TYPE_TXT = 16 +_TYPE_AAAA = 28 +_TYPE_SRV = 33 +_TYPE_ANY = 255 +_TYPE_NSEC = 47 + +# Mapping constants to names + +_CLASSES = {_CLASS_IN: "in", + _CLASS_CS: "cs", + _CLASS_CH: "ch", + _CLASS_HS: "hs", + _CLASS_NONE: "none", + _CLASS_ANY: "any"} + +_TYPES = {_TYPE_A: "a", + _TYPE_NS: "ns", + _TYPE_MD: "md", + _TYPE_MF: "mf", + _TYPE_CNAME: "cname", + _TYPE_SOA: "soa", + _TYPE_MB: "mb", + _TYPE_MG: "mg", + _TYPE_MR: "mr", + _TYPE_NULL: "null", + _TYPE_WKS: "wks", + _TYPE_PTR: "ptr", + _TYPE_HINFO: "hinfo", + _TYPE_MINFO: "minfo", + _TYPE_MX: "mx", + _TYPE_TXT: "txt", + _TYPE_AAAA: "quada", + _TYPE_SRV: "srv", + _TYPE_ANY: "any"} + +_HAS_A_TO_Z = re.compile(r'[A-Za-z]') +_HAS_ONLY_A_TO_Z_NUM_HYPHEN = re.compile(r'^[A-Za-z0-9\-]+$') +_HAS_ASCII_CONTROL_CHARS = re.compile(r'[\x00-\x1f\x7f]') + +int2byte = struct.Struct(">B").pack + + +@enum.unique +class InterfaceChoice(enum.Enum): + Default = 1 + All = 2 + + +@enum.unique +class ServiceStateChange(enum.Enum): + Added = 1 + Removed = 2 + + +HOST_ONLY_NETWORK_MASK = '255.255.255.255' + + +# utility functions + + +def current_time_millis(): + """Current system time in milliseconds""" + return time.time() * 1000 + + +def service_type_name(type_): + """ + Validate a fully qualified service name, instance or subtype. [rfc6763] + + Returns fully qualified service name. + + Domain names used by mDNS-SD take the following forms: + + . <_tcp|_udp> . local. + . . <_tcp|_udp> . local. + ._sub . . <_tcp|_udp> . local. + + 1) must end with 'local.' + + This is true because we are implementing mDNS and since the 'm' means + multi-cast, the 'local.' domain is mandatory. + + 2) local is preceded with either '_udp.' or '_tcp.' + + 3) service name precedes <_tcp|_udp> + + The rules for Service Names [RFC6335] state that they may be no more + than fifteen characters long (not counting the mandatory underscore), + consisting of only letters, digits, and hyphens, must begin and end + with a letter or digit, must not contain consecutive hyphens, and + must contain at least one letter. + + The instance name and sub type may be up to 63 bytes. + + The portion of the Service Instance Name is a user- + friendly name consisting of arbitrary Net-Unicode text [RFC5198]. It + MUST NOT contain ASCII control characters (byte values 0x00-0x1F and + 0x7F) [RFC20] but otherwise is allowed to contain any characters, + without restriction, including spaces, uppercase, lowercase, + punctuation -- including dots -- accented characters, non-Roman text, + and anything else that may be represented using Net-Unicode. + + :param type_: Type, SubType or service name to validate + :return: fully qualified service name (eg: _http._tcp.local.) + """ + if not (type_.endswith('._tcp.local.') or type_.endswith('._udp.local.')): + raise BadTypeInNameException( + "Type '%s' must end with '._tcp.local.' or '._udp.local.'" % + type_) + + remaining = type_[:-len('._tcp.local.')].split('.') + name = remaining.pop() + if not name: + raise BadTypeInNameException("No Service name found") + + if len(remaining) == 1 and len(remaining[0]) == 0: + raise BadTypeInNameException( + "Type '%s' must not start with '.'" % type_) + + if name[0] != '_': + raise BadTypeInNameException( + "Service name (%s) must start with '_'" % name) + + # remove leading underscore + name = name[1:] + + if len(name) > 15: + raise BadTypeInNameException( + "Service name (%s) must be <= 15 bytes" % name) + + if '--' in name: + raise BadTypeInNameException( + "Service name (%s) must not contain '--'" % name) + + if '-' in (name[0], name[-1]): + raise BadTypeInNameException( + "Service name (%s) may not start or end with '-'" % name) + + if not _HAS_A_TO_Z.search(name): + raise BadTypeInNameException( + "Service name (%s) must contain at least one letter (eg: 'A-Z')" % + name) + + if not _HAS_ONLY_A_TO_Z_NUM_HYPHEN.search(name): + raise BadTypeInNameException( + "Service name (%s) must contain only these characters: " + "A-Z, a-z, 0-9, hyphen ('-')" % name) + + if remaining and remaining[-1] == '_sub': + remaining.pop() + if len(remaining) == 0 or len(remaining[0]) == 0: + raise BadTypeInNameException( + "_sub requires a subtype name") + + if len(remaining) > 1: + remaining = ['.'.join(remaining)] + + if remaining: + length = len(remaining[0].encode('utf-8')) + if length > 63: + raise BadTypeInNameException("Too long: '%s'" % remaining[0]) + + if _HAS_ASCII_CONTROL_CHARS.search(remaining[0]): + raise BadTypeInNameException( + "Ascii control character 0x00-0x1F and 0x7F illegal in '%s'" % + remaining[0]) + + return '_' + name + type_[-len('._tcp.local.'):] + + +# Exceptions + + +class Error(Exception): + pass + + +class IncomingDecodeError(Error): + pass + + +class NonUniqueNameException(Error): + pass + + +class NamePartTooLongException(Error): + pass + + +class AbstractMethodException(Error): + pass + + +class BadTypeInNameException(Error): + pass + +# implementation classes + + +class QuietLogger: + _seen_logs = {} + + @classmethod + def log_exception_warning(cls, logger_data=None): + exc_info = sys.exc_info() + exc_str = str(exc_info[1]) + if exc_str not in cls._seen_logs: + # log at warning level the first time this is seen + cls._seen_logs[exc_str] = exc_info + logger = log.warning + else: + logger = log.debug + if logger_data is not None: + logger(*logger_data) + logger('Exception occurred:', exc_info=exc_info) + + @classmethod + def log_warning_once(cls, *args): + msg_str = args[0] + if msg_str not in cls._seen_logs: + cls._seen_logs[msg_str] = 0 + logger = log.warning + else: + logger = log.debug + cls._seen_logs[msg_str] += 1 + logger(*args) + + +class DNSEntry: + + """A DNS entry""" + + def __init__(self, name, type_, class_): + self.key = name.lower() + self.name = name + self.type = type_ + self.class_ = class_ & _CLASS_MASK + self.unique = (class_ & _CLASS_UNIQUE) != 0 + + def __eq__(self, other): + """Equality test on name, type, and class""" + return (isinstance(other, DNSEntry) and + self.name.lower() == other.name.lower() and + self.type == other.type and + self.class_ == other.class_) + + def __ne__(self, other): + """Non-equality test""" + return not self.__eq__(other) + + @staticmethod + def get_class_(class_): + """Class accessor""" + return _CLASSES.get(class_, "?(%s)" % class_) + + @staticmethod + def get_type(t): + """Type accessor""" + return _TYPES.get(t, "?(%s)" % t) + + def to_string(self, hdr, other): + """String representation with additional information""" + result = "%s[%s,%s" % (hdr, self.get_type(self.type), + self.get_class_(self.class_)) + if self.unique: + result += "-unique," + else: + result += "," + result += self.name + if other is not None: + result += ",%s]" % other + else: + result += "]" + return result + + +class DNSQuestion(DNSEntry): + + """A DNS question entry""" + + def __init__(self, name, type_, class_): + DNSEntry.__init__(self, name, type_, class_) + + def answered_by(self, rec): + """Returns true if the question is answered by the record""" + return (self.class_ == rec.class_ and + (self.type == rec.type or self.type == _TYPE_ANY) and + self.name == rec.name) + + def __repr__(self): + """String representation""" + return DNSEntry.to_string(self, "question", None) + + +class DNSRecord(DNSEntry): + + """A DNS record - like a DNS entry, but has a TTL""" + + def __init__(self, name, type_, class_, ttl): + DNSEntry.__init__(self, name, type_, class_) + self.ttl = ttl + self.created = current_time_millis() + + def __eq__(self, other): + """Abstract method""" + raise AbstractMethodException + + def __ne__(self, other): + """Non-equality test""" + return not self.__eq__(other) + + def suppressed_by(self, msg): + """Returns true if any answer in a message can suffice for the + information held in this record.""" + for record in msg.answers: + if self.suppressed_by_answer(record): + return True + return False + + def suppressed_by_answer(self, other): + """Returns true if another record has same name, type and class, + and if its TTL is at least half of this record's.""" + return self == other and other.ttl > (self.ttl / 2) + + def get_expiration_time(self, percent): + """Returns the time at which this record will have expired + by a certain percentage.""" + return self.created + (percent * self.ttl * 10) + + def get_remaining_ttl(self, now): + """Returns the remaining TTL in seconds.""" + return max(0, (self.get_expiration_time(100) - now) / 1000.0) + + def is_expired(self, now): + """Returns true if this record has expired.""" + return self.get_expiration_time(100) <= now + + def is_stale(self, now): + """Returns true if this record is at least half way expired.""" + return self.get_expiration_time(50) <= now + + def reset_ttl(self, other): + """Sets this record's TTL and created time to that of + another record.""" + self.created = other.created + self.ttl = other.ttl + + def write(self, out): + """Abstract method""" + raise AbstractMethodException + + def to_string(self, other): + """String representation with additional information""" + arg = "%s/%s,%s" % ( + self.ttl, self.get_remaining_ttl(current_time_millis()), other) + return DNSEntry.to_string(self, "record", arg) + + +class DNSAddress(DNSRecord): + + """A DNS address record""" + + def __init__(self, name, type_, class_, ttl, address): + DNSRecord.__init__(self, name, type_, class_, ttl) + self.address = address + + def write(self, out): + """Used in constructing an outgoing packet""" + out.write_string(self.address) + + def __eq__(self, other): + """Tests equality on address""" + return (isinstance(other, DNSAddress) and DNSEntry.__eq__(self, other) and + self.address == other.address) + + def __ne__(self, other): + """Non-equality test""" + return not self.__eq__(other) + + def __repr__(self): + """String representation""" + try: + return str(socket.inet_ntoa(self.address)) + except Exception: # TODO stop catching all Exceptions + return str(self.address) + + +class DNSHinfo(DNSRecord): + + """A DNS host information record""" + + def __init__(self, name, type_, class_, ttl, cpu, os): + DNSRecord.__init__(self, name, type_, class_, ttl) + try: + self.cpu = cpu.decode('utf-8') + except AttributeError: + self.cpu = cpu + try: + self.os = os.decode('utf-8') + except AttributeError: + self.os = os + + def write(self, out): + """Used in constructing an outgoing packet""" + out.write_character_string(self.cpu.encode('utf-8')) + out.write_character_string(self.os.encode('utf-8')) + + def __eq__(self, other): + """Tests equality on cpu and os""" + return (isinstance(other, DNSHinfo) and DNSEntry.__eq__(self, other) and + self.cpu == other.cpu and self.os == other.os) + + def __ne__(self, other): + """Non-equality test""" + return not self.__eq__(other) + + def __repr__(self): + """String representation""" + return self.cpu + " " + self.os + + +class DNSPointer(DNSRecord): + + """A DNS pointer record""" + + def __init__(self, name, type_, class_, ttl, alias): + DNSRecord.__init__(self, name, type_, class_, ttl) + self.alias = alias + + def write(self, out): + """Used in constructing an outgoing packet""" + out.write_name(self.alias) + + def __eq__(self, other): + """Tests equality on alias""" + return (isinstance(other, DNSPointer) and DNSEntry.__eq__(self, other) and + self.alias == other.alias) + + def __ne__(self, other): + """Non-equality test""" + return not self.__eq__(other) + + def __repr__(self): + """String representation""" + return self.to_string(self.alias) + + +class DNSText(DNSRecord): + + """A DNS text record""" + + def __init__(self, name, type_, class_, ttl, text): + assert isinstance(text, (bytes, type(None))) + DNSRecord.__init__(self, name, type_, class_, ttl) + self.text = text + + def write(self, out): + """Used in constructing an outgoing packet""" + out.write_string(self.text) + + def __eq__(self, other): + """Tests equality on text""" + return (isinstance(other, DNSText) and DNSEntry.__eq__(self, other) and + self.text == other.text) + + def __ne__(self, other): + """Non-equality test""" + return not self.__eq__(other) + + def __repr__(self): + """String representation""" + if len(self.text) > 10: + return self.to_string(self.text[:7]) + "..." + else: + return self.to_string(self.text) + + +class DNSService(DNSRecord): + + """A DNS service record""" + + def __init__(self, name, type_, class_, ttl, + priority, weight, port, server): + DNSRecord.__init__(self, name, type_, class_, ttl) + self.priority = priority + self.weight = weight + self.port = port + self.server = server + + def write(self, out): + """Used in constructing an outgoing packet""" + out.write_short(self.priority) + out.write_short(self.weight) + out.write_short(self.port) + out.write_name(self.server) + + def __eq__(self, other): + """Tests equality on priority, weight, port and server""" + return (isinstance(other, DNSService) and + DNSEntry.__eq__(self, other) and + self.priority == other.priority and + self.weight == other.weight and + self.port == other.port and + self.server == other.server) + + def __ne__(self, other): + """Non-equality test""" + return not self.__eq__(other) + + def __repr__(self): + """String representation""" + return self.to_string("%s:%s" % (self.server, self.port)) + + +class DNSIncoming(QuietLogger): + + """Object representation of an incoming DNS packet""" + + def __init__(self, data): + """Constructor from string holding bytes of packet""" + self.offset = 0 + self.data = data + self.questions = [] + self.answers = [] + self.id = 0 + self.flags = 0 + self.num_questions = 0 + self.num_answers = 0 + self.num_authorities = 0 + self.num_additionals = 0 + self.valid = False + + try: + self.read_header() + self.read_questions() + self.read_others() + self.valid = True + + except (IndexError, struct.error, IncomingDecodeError): + self.log_exception_warning(( + 'Choked at offset %d while unpacking %r', self.offset, data)) + + def unpack(self, format_): + length = struct.calcsize(format_) + info = struct.unpack( + format_, self.data[self.offset:self.offset + length]) + self.offset += length + return info + + def read_header(self): + """Reads header portion of packet""" + (self.id, self.flags, self.num_questions, self.num_answers, + self.num_authorities, self.num_additionals) = self.unpack(b'!6H') + + def read_questions(self): + """Reads questions section of packet""" + for i in range(self.num_questions): + name = self.read_name() + type_, class_ = self.unpack(b'!HH') + + question = DNSQuestion(name, type_, class_) + self.questions.append(question) + + # def read_int(self): + # """Reads an integer from the packet""" + # return self.unpack(b'!I')[0] + + def read_character_string(self): + """Reads a character string from the packet""" + length = self.data[self.offset] + self.offset += 1 + return self.read_string(length) + + def read_string(self, length): + """Reads a string of a given length from the packet""" + info = self.data[self.offset:self.offset + length] + self.offset += length + return info + + def read_unsigned_short(self): + """Reads an unsigned short from the packet""" + return self.unpack(b'!H')[0] + + def read_others(self): + """Reads the answers, authorities and additionals section of the + packet""" + n = self.num_answers + self.num_authorities + self.num_additionals + for i in range(n): + domain = self.read_name() + type_, class_, ttl, length = self.unpack(b'!HHiH') + + rec = None + if type_ == _TYPE_A: + rec = DNSAddress( + domain, type_, class_, ttl, self.read_string(4)) + elif type_ == _TYPE_CNAME or type_ == _TYPE_PTR: + rec = DNSPointer( + domain, type_, class_, ttl, self.read_name()) + elif type_ == _TYPE_TXT: + rec = DNSText( + domain, type_, class_, ttl, self.read_string(length)) + elif type_ == _TYPE_SRV: + rec = DNSService( + domain, type_, class_, ttl, + self.read_unsigned_short(), self.read_unsigned_short(), + self.read_unsigned_short(), self.read_name()) + elif type_ == _TYPE_HINFO: + rec = DNSHinfo( + domain, type_, class_, ttl, + self.read_character_string(), self.read_character_string()) + elif type_ == _TYPE_AAAA: + rec = DNSAddress( + domain, type_, class_, ttl, self.read_string(16)) + else: + # Try to ignore types we don't know about + # Skip the payload for the resource record so the next + # records can be parsed correctly + self.offset += length + + if rec is not None: + self.answers.append(rec) + + def is_query(self): + """Returns true if this is a query""" + return (self.flags & _FLAGS_QR_MASK) == _FLAGS_QR_QUERY + + def is_response(self): + """Returns true if this is a response""" + return (self.flags & _FLAGS_QR_MASK) == _FLAGS_QR_RESPONSE + + def read_utf(self, offset, length): + """Reads a UTF-8 string of a given length from the packet""" + return str(self.data[offset:offset + length], 'utf-8', 'replace') + + def read_name(self): + """Reads a domain name from the packet""" + result = '' + off = self.offset + next_ = -1 + first = off + + while True: + length = self.data[off] + off += 1 + if length == 0: + break + t = length & 0xC0 + if t == 0x00: + result = ''.join((result, self.read_utf(off, length) + '.')) + off += length + elif t == 0xC0: + if next_ < 0: + next_ = off + 1 + off = ((length & 0x3F) << 8) | self.data[off] + if off >= first: + raise IncomingDecodeError( + "Bad domain name (circular) at %s" % (off,)) + first = off + else: + raise IncomingDecodeError("Bad domain name at %s" % (off,)) + + if next_ >= 0: + self.offset = next_ + else: + self.offset = off + + return result + + +class DNSOutgoing: + + """Object representation of an outgoing packet""" + + def __init__(self, flags, multicast=True): + self.finished = False + self.id = 0 + self.multicast = multicast + self.flags = flags + self.names = {} + self.data = [] + self.size = 12 + self.state = self.State.init + + self.questions = [] + self.answers = [] + self.authorities = [] + self.additionals = [] + + def __repr__(self): + return '' % ', '.join([ + 'multicast=%s' % self.multicast, + 'flags=%s' % self.flags, + 'questions=%s' % self.questions, + 'answers=%s' % self.answers, + 'authorities=%s' % self.authorities, + 'additionals=%s' % self.additionals, + ]) + + class State(enum.Enum): + init = 0 + finished = 1 + + def add_question(self, record): + """Adds a question""" + self.questions.append(record) + + def add_answer(self, inp, record): + """Adds an answer""" + if not record.suppressed_by(inp): + self.add_answer_at_time(record, 0) + + def add_answer_at_time(self, record, now): + """Adds an answer if it does not expire by a certain time""" + if record is not None: + if now == 0 or not record.is_expired(now): + self.answers.append((record, now)) + + def add_authorative_answer(self, record): + """Adds an authoritative answer""" + self.authorities.append(record) + + def add_additional_answer(self, record): + """ Adds an additional answer + + From: RFC 6763, DNS-Based Service Discovery, February 2013 + + 12. DNS Additional Record Generation + + DNS has an efficiency feature whereby a DNS server may place + additional records in the additional section of the DNS message. + These additional records are records that the client did not + explicitly request, but the server has reasonable grounds to expect + that the client might request them shortly, so including them can + save the client from having to issue additional queries. + + This section recommends which additional records SHOULD be generated + to improve network efficiency, for both Unicast and Multicast DNS-SD + responses. + + 12.1. PTR Records + + When including a DNS-SD Service Instance Enumeration or Selective + Instance Enumeration (subtype) PTR record in a response packet, the + server/responder SHOULD include the following additional records: + + o The SRV record(s) named in the PTR rdata. + o The TXT record(s) named in the PTR rdata. + o All address records (type "A" and "AAAA") named in the SRV rdata. + + 12.2. SRV Records + + When including an SRV record in a response packet, the + server/responder SHOULD include the following additional records: + + o All address records (type "A" and "AAAA") named in the SRV rdata. + + """ + self.additionals.append(record) + + def pack(self, format_, value): + self.data.append(struct.pack(format_, value)) + self.size += struct.calcsize(format_) + + def write_byte(self, value): + """Writes a single byte to the packet""" + self.pack(b'!c', int2byte(value)) + + def insert_short(self, index, value): + """Inserts an unsigned short in a certain position in the packet""" + self.data.insert(index, struct.pack(b'!H', value)) + self.size += 2 + + def write_short(self, value): + """Writes an unsigned short to the packet""" + self.pack(b'!H', value) + + def write_int(self, value): + """Writes an unsigned integer to the packet""" + self.pack(b'!I', int(value)) + + def write_string(self, value): + """Writes a string to the packet""" + assert isinstance(value, bytes) + self.data.append(value) + self.size += len(value) + + def write_utf(self, s): + """Writes a UTF-8 string of a given length to the packet""" + utfstr = s.encode('utf-8') + length = len(utfstr) + if length > 64: + raise NamePartTooLongException + self.write_byte(length) + self.write_string(utfstr) + + def write_character_string(self, value): + assert isinstance(value, bytes) + length = len(value) + if length > 256: + raise NamePartTooLongException + self.write_byte(length) + self.write_string(value) + + def write_name(self, name): + """ + Write names to packet + + 18.14. Name Compression + + When generating Multicast DNS messages, implementations SHOULD use + name compression wherever possible to compress the names of resource + records, by replacing some or all of the resource record name with a + compact two-byte reference to an appearance of that data somewhere + earlier in the message [RFC1035]. + """ + + # split name into each label + parts = name.split('.') + if not parts[-1]: + parts.pop() + + # construct each suffix + name_suffices = ['.'.join(parts[i:]) for i in range(len(parts))] + + # look for an existing name or suffix + for count, sub_name in enumerate(name_suffices): + if sub_name in self.names: + break + else: + count += 1 + + # note the new names we are saving into the packet + for suffix in name_suffices[:count]: + self.names[suffix] = self.size + len(name) - len(suffix) - 1 + + # write the new names out. + for part in parts[:count]: + self.write_utf(part) + + # if we wrote part of the name, create a pointer to the rest + if count != len(name_suffices): + # Found substring in packet, create pointer + index = self.names[name_suffices[count]] + self.write_byte((index >> 8) | 0xC0) + self.write_byte(index & 0xFF) + else: + # this is the end of a name + self.write_byte(0) + + def write_question(self, question): + """Writes a question to the packet""" + self.write_name(question.name) + self.write_short(question.type) + self.write_short(question.class_) + + def write_record(self, record, now): + """Writes a record (answer, authoritative answer, additional) to + the packet""" + if self.state == self.State.finished: + return 1 + + start_data_length, start_size = len(self.data), self.size + self.write_name(record.name) + self.write_short(record.type) + if record.unique and self.multicast: + self.write_short(record.class_ | _CLASS_UNIQUE) + else: + self.write_short(record.class_) + if now == 0: + self.write_int(record.ttl) + else: + self.write_int(record.get_remaining_ttl(now)) + index = len(self.data) + + # Adjust size for the short we will write before this record + self.size += 2 + record.write(self) + self.size -= 2 + + length = sum((len(d) for d in self.data[index:])) + # Here is the short we adjusted for + self.insert_short(index, length) + + # if we go over, then rollback and quit + if self.size > _MAX_MSG_ABSOLUTE: + while len(self.data) > start_data_length: + self.data.pop() + self.size = start_size + self.state = self.State.finished + return 1 + return 0 + + def packet(self): + """Returns a string containing the packet's bytes + + No further parts should be added to the packet once this + is done.""" + + overrun_answers, overrun_authorities, overrun_additionals = 0, 0, 0 + + if self.state != self.State.finished: + for question in self.questions: + self.write_question(question) + for answer, time_ in self.answers: + overrun_answers += self.write_record(answer, time_) + for authority in self.authorities: + overrun_authorities += self.write_record(authority, 0) + for additional in self.additionals: + overrun_additionals += self.write_record(additional, 0) + self.state = self.State.finished + + self.insert_short(0, len(self.additionals) - overrun_additionals) + self.insert_short(0, len(self.authorities) - overrun_authorities) + self.insert_short(0, len(self.answers) - overrun_answers) + self.insert_short(0, len(self.questions)) + self.insert_short(0, self.flags) + if self.multicast: + self.insert_short(0, 0) + else: + self.insert_short(0, self.id) + return b''.join(self.data) + + +class DNSCache: + + """A cache of DNS entries""" + + def __init__(self): + self.cache = {} + + def add(self, entry): + """Adds an entry""" + # Insert first in list so get returns newest entry + self.cache.setdefault(entry.key, []).insert(0, entry) + + def remove(self, entry): + """Removes an entry""" + try: + list_ = self.cache[entry.key] + list_.remove(entry) + except (KeyError, ValueError): + pass + + def get(self, entry): + """Gets an entry by key. Will return None if there is no + matching entry.""" + try: + list_ = self.cache[entry.key] + for cached_entry in list_: + if entry.__eq__(cached_entry): + return cached_entry + except (KeyError, ValueError): + return None + + def get_by_details(self, name, type_, class_): + """Gets an entry by details. Will return None if there is + no matching entry.""" + entry = DNSEntry(name, type_, class_) + return self.get(entry) + + def entries_with_name(self, name): + """Returns a list of entries whose key matches the name.""" + try: + return self.cache[name.lower()] + except KeyError: + return [] + + def current_entry_with_name_and_alias(self, name, alias): + now = current_time_millis() + for record in self.entries_with_name(name): + if (record.type == _TYPE_PTR and + not record.is_expired(now) and + record.alias == alias): + return record + + def entries(self): + """Returns a list of all entries""" + if not self.cache: + return [] + else: + # avoid size change during iteration by copying the cache + values = list(self.cache.values()) + return reduce(lambda a, b: a + b, values) + + +class Engine(threading.Thread): + + """An engine wraps read access to sockets, allowing objects that + need to receive data from sockets to be called back when the + sockets are ready. + + A reader needs a handle_read() method, which is called when the socket + it is interested in is ready for reading. + + Writers are not implemented here, because we only send short + packets. + """ + + def __init__(self, zc): + threading.Thread.__init__(self, name='zeroconf-Engine') + self.daemon = True + self.zc = zc + self.readers = {} # maps socket to reader + self.timeout = 5 + self.condition = threading.Condition() + self.start() + + def run(self): + while not self.zc.done: + with self.condition: + rs = self.readers.keys() + if len(rs) == 0: + # No sockets to manage, but we wait for the timeout + # or addition of a socket + self.condition.wait(self.timeout) + + if len(rs) != 0: + try: + rr, wr, er = select.select(rs, [], [], self.timeout) + if not self.zc.done: + for socket_ in rr: + reader = self.readers.get(socket_) + if reader: + reader.handle_read(socket_) + + except (select.error, socket.error) as e: + # If the socket was closed by another thread, during + # shutdown, ignore it and exit + if e.args[0] != socket.EBADF or not self.zc.done: + raise + + def add_reader(self, reader, socket_): + with self.condition: + self.readers[socket_] = reader + self.condition.notify() + + def del_reader(self, socket_): + with self.condition: + del self.readers[socket_] + self.condition.notify() + + +class Listener(QuietLogger): + + """A Listener is used by this module to listen on the multicast + group to which DNS messages are sent, allowing the implementation + to cache information as it arrives. + + It requires registration with an Engine object in order to have + the read() method called when a socket is available for reading.""" + + def __init__(self, zc): + self.zc = zc + self.data = None + + def handle_read(self, socket_): + try: + data, addr = socket_.recvfrom(_MAX_MSG_ABSOLUTE) + port = addr[1] + addr = addr[0] + except Exception as exc: + self.log_exception_warning() + print("Exception raised", exc) + return + + log.debug('Received from %r:%r: %r ', addr, port, data) + + self.data = data + msg = DNSIncoming(data) + if not msg.valid: + pass + + elif msg.is_query(): + # Always multicast responses + if port == _MDNS_PORT: + if self.zc.address_family is socket.AF_INET6: + self.zc.handle_query(msg, _MDNS_ADDR_IPV6, _MDNS_PORT) + else: + self.zc.handle_query(msg, _MDNS_ADDR, _MDNS_PORT) + + # If it's not a multicast query, reply via unicast + # and multicast + elif port == _DNS_PORT: + if self.zc.address_family is socket.AF_INET6: + self.zc.handle_query(msg, addr, port) + self.zc.handle_query(msg, _MDNS_ADDR_IPV6, _MDNS_PORT) + else: + self.zc.handle_query(msg, addr, port) + self.zc.handle_query(msg, _MDNS_ADDR, _MDNS_PORT) + + else: + self.zc.handle_response(msg) + + +class Reaper(threading.Thread): + + """A Reaper is used by this module to remove cache entries that + have expired.""" + + def __init__(self, zc): + threading.Thread.__init__(self, name='zeroconf-Reaper') + self.daemon = True + self.zc = zc + self.start() + + def run(self): + while True: + self.zc.wait(10 * 1000) + if self.zc.done: + return + now = current_time_millis() + for record in self.zc.cache.entries(): + if record.is_expired(now): + self.zc.update_record(now, record) + self.zc.cache.remove(record) + + +class Signal: + def __init__(self): + self._handlers = [] + + def fire(self, **kwargs): + for h in list(self._handlers): + h(**kwargs) + + @property + def registration_interface(self): + return SignalRegistrationInterface(self._handlers) + + +class SignalRegistrationInterface: + + def __init__(self, handlers): + self._handlers = handlers + + def register_handler(self, handler): + self._handlers.append(handler) + return self + + def unregister_handler(self, handler): + self._handlers.remove(handler) + return self + + +class ServiceBrowser(threading.Thread): + + """Used to browse for a service of a specific type. + + The listener object will have its add_service() and + remove_service() methods called when this browser + discovers changes in the services availability.""" + + def __init__(self, zc, type_, handlers=None, listener=None): + """Creates a browser for a specific type""" + assert handlers or listener, 'You need to specify at least one handler' + if not type_.endswith(service_type_name(type_)): + raise BadTypeInNameException + threading.Thread.__init__( + self, name='zeroconf-ServiceBrowser_' + type_) + self.daemon = True + self.zc = zc + self.type = type_ + self.services = {} + self.next_time = current_time_millis() + self.delay = _BROWSER_TIME + self._handlers_to_call = [] + + self._service_state_changed = Signal() + + self.done = False + + if hasattr(handlers, 'add_service'): + listener = handlers + handlers = None + + handlers = handlers or [] + + if listener: + def on_change(zeroconf, service_type, name, state_change): + args = (zeroconf, service_type, name) + if state_change is ServiceStateChange.Added: + listener.add_service(*args) + elif state_change is ServiceStateChange.Removed: + listener.remove_service(*args) + else: + raise NotImplementedError(state_change) + handlers.append(on_change) + + for h in handlers: + self.service_state_changed.register_handler(h) + + self.start() + + @property + def service_state_changed(self): + return self._service_state_changed.registration_interface + + def update_record(self, zc, now, record): + """Callback invoked by Zeroconf when new information arrives. + + Updates information required by browser in the Zeroconf cache.""" + + def enqueue_callback(state_change, name): + self._handlers_to_call.append( + lambda zeroconf: self._service_state_changed.fire( + zeroconf=zeroconf, + service_type=self.type, + name=name, + state_change=state_change, + )) + + if record.type == _TYPE_PTR and record.name == self.type: + expired = record.is_expired(now) + service_key = record.alias.lower() + try: + old_record = self.services[service_key] + except KeyError: + if not expired: + self.services[service_key] = record + enqueue_callback(ServiceStateChange.Added, record.alias) + else: + if not expired: + old_record.reset_ttl(record) + else: + del self.services[service_key] + enqueue_callback(ServiceStateChange.Removed, record.alias) + return + + expires = record.get_expiration_time(75) + if expires < self.next_time: + self.next_time = expires + + def cancel(self): + self.done = True + self.zc.remove_listener(self) + self.join() + + def run(self): + self.zc.add_listener(self, DNSQuestion(self.type, _TYPE_PTR, _CLASS_IN)) + + while True: + now = current_time_millis() + if len(self._handlers_to_call) == 0 and self.next_time > now: + self.zc.wait(self.next_time - now) + if self.zc.done or self.done: + return + now = current_time_millis() + if self.next_time <= now: + out = DNSOutgoing(_FLAGS_QR_QUERY) + out.add_question(DNSQuestion(self.type, _TYPE_PTR, _CLASS_IN)) + for record in self.services.values(): + if not record.is_stale(now): + out.add_answer_at_time(record, now) + + self.zc.send(out) + self.next_time = now + self.delay + self.delay = min(20 * 1000, self.delay * 2) + + if len(self._handlers_to_call) > 0 and not self.zc.done: + handler = self._handlers_to_call.pop(0) + handler(self.zc) + + +class ServiceInfo: + + """Service information""" + + def __init__(self, type_, name, address=None, port=None, weight=0, + priority=0, properties=None, server=None): + """Create a service description. + + type_: fully qualified service type name + name: fully qualified service name + address: IP address as unsigned short, network byte order + port: port that the service runs on + weight: weight of the service + priority: priority of the service + properties: dictionary of properties (or a string holding the + bytes for the text field) + server: fully qualified name for service host (defaults to name)""" + + if not type_.endswith(service_type_name(name)): + raise BadTypeInNameException + self.type = type_ + self.name = name + self.address = address + self.port = port + self.weight = weight + self.priority = priority + if server: + self.server = server + else: + self.server = name + self._properties = {} + self._set_properties(properties) + + @property + def properties(self): + return self._properties + + def _set_properties(self, properties): + """Sets properties and text of this info from a dictionary""" + if isinstance(properties, dict): + self._properties = properties + list_ = [] + result = b'' + for key, value in properties.items(): + if isinstance(key, str): + key = key.encode('utf-8') + + if value is None: + suffix = b'' + elif isinstance(value, str): + suffix = value.encode('utf-8') + elif isinstance(value, bytes): + suffix = value + elif isinstance(value, int): + if value: + suffix = b'true' + else: + suffix = b'false' + else: + suffix = b'' + list_.append(b'='.join((key, suffix))) + for item in list_: + result = b''.join((result, int2byte(len(item)), item)) + self.text = result + else: + self.text = properties + + def _set_text(self, text): + """Sets properties and text given a text field""" + self.text = text + result = {} + end = len(text) + index = 0 + strs = [] + while index < end: + length = text[index] + index += 1 + strs.append(text[index:index + length]) + index += length + + for s in strs: + parts = s.split(b'=', 1) + try: + key, value = parts + except ValueError: + # No equals sign at all + key = s + value = False + else: + if value == b'true': + value = True + elif value == b'false' or not value: + value = False + + # Only update non-existent properties + if key and result.get(key) is None: + result[key] = value + + self._properties = result + + def get_name(self): + """Name accessor""" + if self.type is not None and self.name.endswith("." + self.type): + return self.name[:len(self.name) - len(self.type) - 1] + return self.name + + def update_record(self, zc, now, record): + """Updates service information from a DNS record""" + if record is not None and not record.is_expired(now): + if record.type == _TYPE_A or record.type == _TYPE_AAAA: + # if record.name == self.name: + if record.name == self.server: + self.address = record.address + elif record.type == _TYPE_SRV: + if record.name == self.name: + self.server = record.server + self.port = record.port + self.weight = record.weight + self.priority = record.priority + # self.address = None + update_type = _TYPE_A + if zc.address_family == socket.AF_INET6: + update_type = _TYPE_AAAA + + self.update_record( + zc, now, zc.cache.get_by_details( + self.server, update_type, _CLASS_IN)) + elif record.type == _TYPE_TXT: + if record.name == self.name: + self._set_text(record.text) + + def request(self, zc, timeout): + """Returns true if the service could be discovered on the + network, and updates this object with details discovered. + """ + now = current_time_millis() + delay = _LISTENER_TIME + next_ = now + delay + last = now + timeout + + record_types_for_check_cache = [ + (_TYPE_SRV, _CLASS_IN), + (_TYPE_TXT, _CLASS_IN), + ] + if self.server is not None: + if zc.address_family is socket.AF_INET: + record_types_for_check_cache.append((_TYPE_A, _CLASS_IN)) + else: #IPv6 + record_types_for_check_cache.append((_TYPE_AAAA, _CLASS_IN)) + for record_type in record_types_for_check_cache: + cached = zc.cache.get_by_details(self.name, *record_type) + cached_server = zc.cache.get_by_details(self.server.lower(), *record_type) + if cached: + self.update_record(zc, now, cached) + if cached_server: + self.update_record(zc, now, cached_server) + + if None not in (self.server, self.address, self.text): + return True + + try: + zc.add_listener(self, DNSQuestion(self.name, _TYPE_ANY, _CLASS_IN)) + while None in (self.server, self.address, self.text): + if last <= now: + return False + if next_ <= now: + out = DNSOutgoing(_FLAGS_QR_QUERY) + out.add_question( + DNSQuestion(self.name, _TYPE_SRV, _CLASS_IN)) + out.add_answer_at_time( + zc.cache.get_by_details( + self.name, _TYPE_SRV, _CLASS_IN), now) + + out.add_question( + DNSQuestion(self.name, _TYPE_TXT, _CLASS_IN)) + out.add_answer_at_time( + zc.cache.get_by_details( + self.name, _TYPE_TXT, _CLASS_IN), now) + + if self.server is not None: + out_type = _TYPE_A + if zc.address_family == socket.AF_INET6: #IPv6 + out_type = _TYPE_AAAA + + out.add_question( + DNSQuestion(self.server, out_type, _CLASS_IN)) + out.add_answer_at_time( + zc.cache.get_by_details( + self.server, out_type, _CLASS_IN), now) + zc.send(out) + next_ = now + delay + delay *= 2 + + zc.wait(min(next_, last) - now) + now = current_time_millis() + finally: + zc.remove_listener(self) + + return True + + def __eq__(self, other): + """Tests equality of service name""" + return isinstance(other, ServiceInfo) and other.name == self.name + + def __ne__(self, other): + """Non-equality test""" + return not self.__eq__(other) + + def __repr__(self): + """String representation""" + return '%s(%s)' % ( + type(self).__name__, + ', '.join( + '%s=%r' % (name, getattr(self, name)) + for name in ( + 'type', 'name', 'address', 'port', 'weight', 'priority', + 'server', 'properties', + ) + ) + ) + + +class ZeroconfServiceTypes: + """ + Return all of the advertised services on any local networks + """ + def __init__(self): + self.found_services = set() + + def add_service(self, zc, type_, name): + self.found_services.add(name) + + def remove_service(self, zc, type_, name): + pass + + @classmethod + def find(cls, zc=None, timeout=5, interfaces=InterfaceChoice.All): + """ + Return all of the advertised services on any local networks. + + :param zc: Zeroconf() instance. Pass in if already have an + instance running or if non-default interfaces are needed + :param timeout: seconds to wait for any responses + :return: tuple of service type strings + """ + local_zc = zc or Zeroconf(interfaces=interfaces) + listener = cls() + browser = ServiceBrowser( + local_zc, '_services._dns-sd._udp.local.', listener=listener) + + # wait for responses + time.sleep(timeout) + + # close down anything we opened + if zc is None: + local_zc.close() + else: + browser.cancel() + + return tuple(sorted(listener.found_services)) + + +def get_all_addresses(address_family): + return list(set( + addr['addr'] + for iface in netifaces.interfaces() + for addr in netifaces.ifaddresses(iface).get(address_family, []) + if addr.get('netmask') != HOST_ONLY_NETWORK_MASK + )) + + +def normalize_interface_choice(choice, address_family, interface_name): + if choice is InterfaceChoice.Default and address_family is socket.AF_INET: + choice = ['0.0.0.0'] + elif choice is InterfaceChoice.All and address_family is socket.AF_INET: + choice = get_all_addresses(address_family) + elif address_family is socket.AF_INET6: #IPv6 + if choice is InterfaceChoice.Default or choice is InterfaceChoice.All: + #Get the ip of the interface + ip6_addr = netifaces.ifaddresses(interface_name)[address_family][0]['addr'] + ip6_addr = ip6_addr.split("%")[0] + return [ip6_addr] + else: + return choice + return choice + + +def new_socket(interface_number=None, interface_name=None): + """ + Create a new socket for a given interface_number. + interface_name and interface_number have to be for the same interface + + """ + #Default is IPv4 + address_family = socket.AF_INET + + intf = None #The interface used to transmit data + + if interface_number is None: + #No ipv6 interface given. Stay in ipv4 + intf = socket.gethostbyname(socket.gethostname()) + else: #IPv6 + intf = int(interface_number) + address_family = socket.AF_INET6 + + #Create socket + s = socket.socket(address_family, socket.SOCK_DGRAM) + + s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + + # SO_REUSEADDR should be equivalent to SO_REUSEPORT for + # multicast UDP sockets (p 731, "TCP/IP Illustrated, + # Volume 2"), but some BSD-derived systems require + # SO_REUSEPORT to be specified explicity. Also, not all + # versions of Python have SO_REUSEPORT available. + # Catch OSError and socket.error for kernel versions <3.9 because lacking + # SO_REUSEPORT support. + try: + reuseport = socket.SO_REUSEPORT + except AttributeError: + pass + else: + try: + s.setsockopt(socket.SOL_SOCKET, reuseport, 1) + except (OSError, socket.error) as err: + # OSError on python 3, socket.error on python 2 + if not err.errno == errno.ENOPROTOOPT: + raise + + + # OpenBSD needs the ttl and loop values for the IP_MULTICAST_TTL and + # IP_MULTICAST_LOOP socket options as an unsigned char. + if address_family is socket.AF_INET: + ttl = struct.pack(b'B', 255) + s.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_TTL, ttl) + loop = struct.pack(b'B', 1) + s.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_LOOP, loop) + + else: #IPv6 + s.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_MULTICAST_HOPS, 255) + s.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_MULTICAST_LOOP, 1) + + if interface_name is "awdl0" and platform.system() == 'Darwin': + s.setsockopt(socket.SOL_SOCKET, 0x1104, 1) + + #Bind the Socket + s.bind(('', _MDNS_PORT)) + return s + + +def get_errno(e): + assert isinstance(e, socket.error) + return e.args[0] + + +class Zeroconf(QuietLogger): + + """Implementation of Zeroconf Multicast DNS Service Discovery + + Supports registration, unregistration, queries and browsing. + """ + + def __init__( + self, + interfaces=InterfaceChoice.All, + ipv6_interface_name=None, + apple_mdns=False, + ): + """Creates an instance of the Zeroconf class, establishing + multicast communications, listening and reaping threads. + + :type interfaces: :class:`InterfaceChoice` or sequence of ip addresses + :param ipv6_interface_name: string defining the name of the IPv6 interface that should be used. None if IPv4 should be used + :param apple_mdns: For Apple's mdns services._dns-sd._udp.local. has to be included in an answer + """ + # hook for threads + self._GLOBAL_DONE = False + + if ipv6_interface_name is None: #IPv4 + self._listen_socket = new_socket() + self.address_family = socket.AF_INET + else: #IPv6 + self.if_index = socket.if_nametoindex(ipv6_interface_name) + self._listen_socket = new_socket(interface_number=self.if_index, interface_name=ipv6_interface_name) + self.address_family = socket.AF_INET6 + + self.apple_mdns = apple_mdns + + interfaces = normalize_interface_choice(interfaces, self.address_family, ipv6_interface_name) + + # print("Interfaces", interfaces) + + self._respond_sockets = [] + + for i in interfaces: + log.debug('Adding %r to multicast group', i) + try: + if self.address_family is socket.AF_INET: + self._listen_socket.setsockopt( + socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, + socket.inet_aton(_MDNS_ADDR) + socket.inet_aton(i)) + else: #IPv6 + self.ifn = struct.pack("I", self.if_index) + group = socket.inet_pton(socket.AF_INET6,_MDNS_ADDR_IPV6) + self.ifn + self._listen_socket.setsockopt( + socket.IPPROTO_IPV6, socket.IPV6_JOIN_GROUP, group + ) + + except socket.error as e: + if get_errno(e) == errno.EADDRINUSE: + log.info( + 'Address in use when adding %s to multicast group, ' + 'it is expected to happen on some systems', i, + ) + elif get_errno(e) == errno.EADDRNOTAVAIL: + log.info( + 'Address not available when adding %s to multicast ' + 'group, it is expected to happen on some systems', i, + ) + continue + else: + raise + + respond_socket = None + if self.address_family is socket.AF_INET: #IPv4 + respond_socket = new_socket() + respond_socket.setsockopt( + socket.IPPROTO_IP, socket.IP_MULTICAST_IF, socket.inet_aton(i)) + else: #IPv6 + respond_socket = new_socket(self.if_index) + respond_socket.setsockopt( + socket.IPPROTO_IPV6, socket.IPV6_MULTICAST_IF, self.ifn + ) + + self._respond_sockets.append(respond_socket) + + self.listeners = [] + self.browsers = {} + self.services = {} + self.servicetypes = {} + + self.cache = DNSCache() + + self.condition = threading.Condition() + + self.engine = Engine(self) + self.listener = Listener(self) + self.engine.add_reader(self.listener, self._listen_socket) + self.reaper = Reaper(self) + + self.debug = None + + @property + def done(self): + return self._GLOBAL_DONE + + def wait(self, timeout): + """Calling thread waits for a given number of milliseconds or + until notified.""" + with self.condition: + self.condition.wait(timeout / 1000.0) + + def notify_all(self): + """Notifies all waiting threads""" + with self.condition: + self.condition.notify_all() + + def get_service_info(self, type_, name, timeout=3000): + """Returns network's service information for a particular + name and type, or None if no service matches by the timeout, + which defaults to 3 seconds.""" + info = ServiceInfo(type_, name) + if info.request(self, timeout): + return info + + def add_service_listener(self, type_, listener): + """Adds a listener for a particular service type. This object + will then have its update_record method called when information + arrives for that type.""" + self.remove_service_listener(listener) + self.browsers[listener] = ServiceBrowser(self, type_, listener) + + def remove_service_listener(self, listener): + """Removes a listener from the set that is currently listening.""" + if listener in self.browsers: + self.browsers[listener].cancel() + del self.browsers[listener] + + def remove_all_service_listeners(self): + """Removes a listener from the set that is currently listening.""" + for listener in [k for k in self.browsers]: + self.remove_service_listener(listener) + + def register_service(self, info, ttl=_DNS_TTL, allow_name_change=False): + """Registers service information to the network with a default TTL + of 60 seconds. Zeroconf will then respond to requests for + information for that service. The name of the service may be + changed if needed to make it unique on the network.""" + self.check_service(info, allow_name_change) + self.services[info.name.lower()] = info + if info.type in self.servicetypes: + self.servicetypes[info.type] += 1 + else: + self.servicetypes[info.type] = 1 + now = current_time_millis() + next_time = now + i = 0 + while i < 3: + if now < next_time: + self.wait(next_time - now) + now = current_time_millis() + continue + out = DNSOutgoing(_FLAGS_QR_RESPONSE | _FLAGS_AA) + out.add_answer_at_time( + DNSPointer(info.type, _TYPE_PTR, _CLASS_IN, ttl, info.name), 0) + out.add_answer_at_time( + DNSService(info.name, _TYPE_SRV, _CLASS_IN, + ttl, info.priority, info.weight, info.port, + info.server), 0) + + out.add_answer_at_time( + DNSText(info.name, _TYPE_TXT, _CLASS_IN, ttl, info.text), 0) + if info.address: + if self.address_family == socket.AF_INET: + out.add_answer_at_time( + DNSAddress(info.server, _TYPE_A, _CLASS_IN, + ttl, info.address), 0) + else: #IPv6 + out.add_answer_at_time( + DNSAddress(info.server, _TYPE_AAAA, _CLASS_IN, + ttl, info.address), 0) + + self.send(out) + i += 1 + next_time += _REGISTER_TIME + + def unregister_service(self, info): + """Unregister a service.""" + try: + del self.services[info.name.lower()] + if self.servicetypes[info.type] > 1: + self.servicetypes[info.type] -= 1 + else: + del self.servicetypes[info.type] + except Exception as e: # TODO stop catching all Exceptions + log.exception('Unknown error, possibly benign: %r', e) + now = current_time_millis() + next_time = now + i = 0 + while i < 3: + if now < next_time: + self.wait(next_time - now) + now = current_time_millis() + continue + out = DNSOutgoing(_FLAGS_QR_RESPONSE | _FLAGS_AA) + out.add_answer_at_time( + DNSPointer(info.type, _TYPE_PTR, _CLASS_IN, 0, info.name), 0) + out.add_answer_at_time( + DNSService(info.name, _TYPE_SRV, _CLASS_IN, 0, + info.priority, info.weight, info.port, info.name), 0) + out.add_answer_at_time( + DNSText(info.name, _TYPE_TXT, _CLASS_IN, 0, info.text), 0) + + if info.address: + out_type = _TYPE_A + if self.address_family == socket.AF_INET6: #IPv6 + out_type = _TYPE_AAAA + + out.add_answer_at_time( + DNSAddress(info.server, out_type, _CLASS_IN, 0, + info.address), 0) + self.send(out) + i += 1 + next_time += _UNREGISTER_TIME + + def unregister_all_services(self): + """Unregister all registered services.""" + if len(self.services) > 0: + now = current_time_millis() + next_time = now + i = 0 + while i < 3: + if now < next_time: + self.wait(next_time - now) + now = current_time_millis() + continue + out = DNSOutgoing(_FLAGS_QR_RESPONSE | _FLAGS_AA) + for info in self.services.values(): + out.add_answer_at_time(DNSPointer( + info.type, _TYPE_PTR, _CLASS_IN, 0, info.name), 0) + out.add_answer_at_time(DNSService( + info.name, _TYPE_SRV, _CLASS_IN, 0, + info.priority, info.weight, info.port, info.server), 0) + out.add_answer_at_time(DNSText( + info.name, _TYPE_TXT, _CLASS_IN, 0, info.text), 0) + if info.address: + out_type = _TYPE_A + if self.address_family == socket.AF_INET6: #IPv6 + out_type = _TYPE_AAAA + + out.add_answer_at_time(DNSAddress( + info.server, out_type, _CLASS_IN, 0, + info.address), 0) + self.send(out) + i += 1 + next_time += _UNREGISTER_TIME + + def check_service(self, info, allow_name_change): + """Checks the network for a unique service name, modifying the + ServiceInfo passed in if it is not unique.""" + + # This is kind of funky because of the subtype based tests + # need to make subtypes a first class citizen + service_name = service_type_name(info.name) + if not info.type.endswith(service_name): + raise BadTypeInNameException + + instance_name = info.name[:-len(service_name) - 1] + next_instance_number = 2 + + now = current_time_millis() + next_time = now + i = 0 + while i < 3: + # check for a name conflict + while self.cache.current_entry_with_name_and_alias( + info.type, info.name): + if not allow_name_change: + raise NonUniqueNameException + + # change the name and look for a conflict + info.name = '%s-%s.%s' % ( + instance_name, next_instance_number, info.type) + next_instance_number += 1 + service_type_name(info.name) + next_time = now + i = 0 + + if now < next_time: + self.wait(next_time - now) + now = current_time_millis() + continue + + out = DNSOutgoing(_FLAGS_QR_QUERY | _FLAGS_AA) + self.debug = out + out.add_question(DNSQuestion(info.type, _TYPE_PTR, _CLASS_IN)) + out.add_authorative_answer(DNSPointer( + info.type, _TYPE_PTR, _CLASS_IN, _DNS_TTL, info.name)) + self.send(out) + i += 1 + next_time += _CHECK_TIME + + def add_listener(self, listener, question): + """Adds a listener for a given question. The listener will have + its update_record method called when information is available to + answer the question.""" + now = current_time_millis() + self.listeners.append(listener) + if question is not None: + for record in self.cache.entries_with_name(question.name): + if question.answered_by(record) and not record.is_expired(now): + listener.update_record(self, now, record) + self.notify_all() + + def remove_listener(self, listener): + """Removes a listener.""" + try: + self.listeners.remove(listener) + self.notify_all() + except Exception as e: # TODO stop catching all Exceptions + log.exception('Unknown error, possibly benign: %r', e) + + def update_record(self, now, rec): + """Used to notify listeners of new information that has updated + a record.""" + for listener in self.listeners: + listener.update_record(self, now, rec) + self.notify_all() + + def handle_response(self, msg): + """Deal with incoming response packets. All answers + are held in the cache, and listeners are notified.""" + now = current_time_millis() + for record in msg.answers: + expired = record.is_expired(now) + if record in self.cache.entries(): + if expired: + self.cache.remove(record) + else: + entry = self.cache.get(record) + if entry is not None: + entry.reset_ttl(record) + else: + self.cache.add(record) + + for record in msg.answers: + self.update_record(now, record) + + def handle_query(self, msg, addr, port): + """Deal with incoming query packets. Provides a response if + possible.""" + out = None + + # Support unicast client responses + # + if port != _MDNS_PORT: + out = DNSOutgoing(_FLAGS_QR_RESPONSE | _FLAGS_AA, multicast=False) + for question in msg.questions: + out.add_question(question) + + for question in msg.questions: + if question.type == _TYPE_PTR: + if question.name == "_services._dns-sd._udp.local.": + for stype in self.servicetypes.keys(): + if out is None: + out = DNSOutgoing(_FLAGS_QR_RESPONSE | _FLAGS_AA) + out.add_answer(msg, DNSPointer( + "_services._dns-sd._udp.local.", _TYPE_PTR, + _CLASS_IN, _DNS_TTL, stype)) + for service in self.services.values(): + if question.name == service.type: + if out is None: + out = DNSOutgoing(_FLAGS_QR_RESPONSE | _FLAGS_AA) + out.add_answer(msg, DNSPointer( + service.type, _TYPE_PTR, + _CLASS_IN, _DNS_TTL, service.name)) + + if self.apple_mdns and not out is None: + for stype in self.servicetypes.keys(): + out.add_answer(msg, DNSPointer( + "_services._dns-sd._udp.local.", _TYPE_PTR, + _CLASS_IN, _DNS_TTL, stype)) + else: + try: + if out is None: + out = DNSOutgoing(_FLAGS_QR_RESPONSE | _FLAGS_AA) + + # Answer A record queries for any service addresses we know + # Used in IPv4 + if question.type in (_TYPE_A, _TYPE_ANY) and self.address_family == socket.AF_INET: + for service in self.services.values(): + if service.server.lower() == question.name.lower(): + out.add_answer(msg, DNSAddress( + question.name, _TYPE_A, + _CLASS_IN | _CLASS_UNIQUE, + _DNS_TTL, service.address)) + + #React on query for AAAA record + # In IPv6 + elif question.type in (_TYPE_AAAA, _TYPE_ANY) and self.address_family == socket.AF_INET6: #IPv6 + for service in self.services.values(): + if service.server == question.name.lower(): + out.add_answer(msg, DNSAddress( + question.name, _TYPE_AAAA, + _CLASS_IN | _CLASS_UNIQUE, + _DNS_TTL, service.address)) + + service = self.services.get(question.name.lower(), None) + if not service: + continue + + if question.type in (_TYPE_SRV, _TYPE_ANY): + out.add_answer(msg, DNSService( + question.name, _TYPE_SRV, _CLASS_IN | _CLASS_UNIQUE, + _DNS_TTL, service.priority, service.weight, + service.port, service.server)) + if question.type in (_TYPE_TXT, _TYPE_ANY): + out.add_answer(msg, DNSText( + question.name, _TYPE_TXT, _CLASS_IN | _CLASS_UNIQUE, + _DNS_TTL, service.text)) + if question.type == _TYPE_SRV: + address_type = _TYPE_A + if self.address_family == socket.AF_INET6: + address_type = _TYPE_AAAA + out.add_additional_answer(DNSAddress( + service.server, address_type, _CLASS_IN | _CLASS_UNIQUE, + _DNS_TTL, service.address)) + + except Exception: # TODO stop catching all Exceptions + self.log_exception_warning() + + if out is not None and out.answers: + out.id = msg.id + self.send(out, addr, port) + + def send(self, out, addr=None, port=_MDNS_PORT): + """Sends an outgoing packet.""" + + if self.address_family is socket.AF_INET6: + #Wrong address set. Change to ipv6 mdns + addr = (addr or _MDNS_ADDR_IPV6, port, 0, 0,) + else: + addr = (addr or _MDNS_ADDR, port,) + + + packet = out.packet() + if len(packet) > _MAX_MSG_ABSOLUTE: + self.log_warning_once("Dropping %r over-sized packet (%d bytes) %r", + out, len(packet), packet) + return + log.debug('Sending %r (%d bytes) as %r...', out, len(packet), packet) + for s in self._respond_sockets: + if self._GLOBAL_DONE: + return + try: + bytes_sent = s.sendto(packet, 0, addr) + except Exception: # TODO stop catching all Exceptions + # on send errors, log the exception and keep going + self.log_exception_warning() + else: + if bytes_sent != len(packet): + self.log_warning_once( + '!!! sent %d out of %d bytes to %r' % ( + bytes_sent, len(packet)), s) + + def close(self): + """Ends the background threads, and prevent this instance from + servicing further queries.""" + if not self._GLOBAL_DONE: + self._GLOBAL_DONE = True + # remove service listeners + self.remove_all_service_listeners() + self.unregister_all_services() + + # shutdown recv socket and thread + self.engine.del_reader(self._listen_socket) + + if self.address_family == socket.AF_INET: + self._listen_socket.setsockopt(socket.SOL_IP, socket.IP_DROP_MEMBERSHIP, + socket.inet_aton(_MDNS_ADDR) + socket.inet_aton('0.0.0.0')) + else: + group = socket.inet_pton(socket.AF_INET6,_MDNS_ADDR_IPV6) + self.ifn + self._listen_socket.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_LEAVE_GROUP,group) + + self._listen_socket.close() + self.engine.join() + + # shutdown the rest + self.notify_all() + self.reaper.join() + for s in self._respond_sockets: + s.close() diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..6dc6a0f --- /dev/null +++ b/setup.py @@ -0,0 +1,41 @@ +from opendrop import __version__ +from codecs import open +from os.path import abspath, dirname, join +from setuptools import find_packages, setup + +this_dir = abspath(dirname(__file__)) +with open(join(this_dir, 'README.md'), encoding='utf-8') as file: + long_description = file.read() + +setup( + name='opendrop', + version=__version__, + description='An Open Source AirDrop Implementation', + long_description=long_description, + url='https://owlink.org', + author='Milan Stute, Alexander Heinrich', + classifiers=[ + 'Intended Audience :: Developers', + 'Topic :: Utilities', + 'License :: Public Domain', + 'Natural Language :: English', + 'Operating System :: OS Independent', + 'Programming Language :: Python :: 3', + 'Programming Language :: Python :: 3.2', + 'Programming Language :: Python :: 3.3', + 'Programming Language :: Python :: 3.6.3', + ], + keywords='cli', + packages=find_packages(exclude=['docs']), + package_data={ + 'opendrop': ['certs/*.pem'] + }, + + install_requires=['pycrypto', 'requests', 'fleep', 'netifaces', 'Pillow', + 'requests_toolbelt', 'ctypescrypto', 'libarchive-c'], + entry_points={ + 'console_scripts': [ + 'opendrop=opendrop.cli:main', + ], + }, +)