pax_global_header00006660000000000000000000000064147302667370014531gustar00rootroot0000000000000052 comment=9da74c9d8a7f370052ecc1ec32eea6ae305d510a python-proton-vpn-api-core-0.39.0/000077500000000000000000000000001473026673700167605ustar00rootroot00000000000000python-proton-vpn-api-core-0.39.0/.gitignore000066400000000000000000000001541473026673700207500ustar00rootroot00000000000000build/ dist/ MANIFEST *.pyc *.egg-info/ .vscode/ *.lock __SOURCE_APP .env cov.xml html .coverage .idea venv python-proton-vpn-api-core-0.39.0/.gitlab-ci.yml000066400000000000000000000001601473026673700214110ustar00rootroot00000000000000include: - project: 'ProtonVPN/Linux/integration/ci-libraries' ref: develop file: 'develop-pipeline.yml' python-proton-vpn-api-core-0.39.0/.gitmodules000066400000000000000000000001331473026673700211320ustar00rootroot00000000000000[submodule "scripts/devtools"] path = scripts/devtools url = ../integration/devtools.git python-proton-vpn-api-core-0.39.0/LICENSE000066400000000000000000001045141473026673700177720ustar00rootroot00000000000000 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 How to Apply These Terms to Your New Programs If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms. To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found. Copyright (C) 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 . Also add information on how to contact you by electronic and paper mail. If the program does terminal interaction, make it output a short notice like this when it starts in an interactive mode: Copyright (C) This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. This is free software, and you are welcome to redistribute it under certain conditions; type `show c' for details. The hypothetical commands `show w' and `show c' should show the appropriate parts of the General Public License. Of course, your program's commands might be different; for a GUI interface, you would use an "about box". You should also get your employer (if you work as a programmer) or school, if any, to sign a "copyright disclaimer" for the program, if necessary. For more information on this, and how to apply and follow the GNU GPL, see . The GNU General Public License does not permit incorporating your program into proprietary programs. If your program is a subroutine library, you may consider it more useful to permit linking proprietary applications with the library. If this is what you want to do, use the GNU Lesser General Public License instead of this License. But first, please read .python-proton-vpn-api-core-0.39.0/MANIFEST.in000066400000000000000000000000251473026673700205130ustar00rootroot00000000000000include versions.yml python-proton-vpn-api-core-0.39.0/README.md000066400000000000000000000023531473026673700202420ustar00rootroot00000000000000# Proton VPN Core API The `proton-vpn-core-api` acts as a facade to the other Proton VPN components, exposing a uniform API to the available Proton VPN services. ## Development Even though our CI pipelines always test and build releases using Linux distribution packages, you can use pip to set up your development environment. ### Proton package registry If you didn't do it yet, to be able to pip install Proton VPN components you'll need to set up our internal Python package registry. You can do so running the command below, after replacing `{GITLAB_TOKEN`} with your [personal access token](https://gitlab.protontech.ch/help/user/profile/personal_access_tokens.md) with the scope set to `api`. ```shell pip config set global.index-url https://__token__:{GITLAB_TOKEN}@gitlab.protontech.ch/api/v4/groups/777/-/packages/pypi/simple ``` In the index URL above, `777` is the id of the current root GitLab group, the one containing the repositories of all our Proton VPN components. ### Virtual environment You can create the virtual environment and install the rest of dependencies as follows: ```shell python3 -m venv venv source venv/bin/activate pip install -r requirements.txt ``` ### Tests You can run the tests with: ```shell pytest ``` python-proton-vpn-api-core-0.39.0/debian/000077500000000000000000000000001473026673700202025ustar00rootroot00000000000000python-proton-vpn-api-core-0.39.0/debian/.gitignore000066400000000000000000000004251473026673700221730ustar00rootroot00000000000000.debhelper debhelper-build-stamp files python3-proton-vpn-coreapi.debhelper.log python3-proton-vpn-coreapi.postinst.debhelper python3-proton-vpn-coreapi.postrm.debhelper python3-proton-vpn-coreapi.prerm.debhelper python3-proton-vpn-coreapi.substvars python3-proton-vpn-coreapi python-proton-vpn-api-core-0.39.0/debian/compat000066400000000000000000000000031473026673700214010ustar00rootroot0000000000000011 python-proton-vpn-api-core-0.39.0/debian/control000066400000000000000000000014161473026673700216070ustar00rootroot00000000000000Source: proton-vpn-api-core Section: python Priority: optional Maintainer: Proton AG Build-Depends: debhelper (>= 9), dh-python, python3-all, python3-setuptools, python3-proton-core, python3-distro, python3-sentry-sdk, python3-nacl, python3-jinja2 Standards-Version: 4.1.1 X-Python3-Version: >= 3.9 Package: python3-proton-vpn-api-core Architecture: all Depends: ${python3:Depends}, ${misc:Depends}, python3-proton-core, python3-distro, python3-sentry-sdk, python3-nacl, python3-jinja2 Breaks: proton-vpn-gtk-app (<< 4.8.2~rc3), python3-proton-vpn-network-manager (<< 0.10.2) Replaces: python3-proton-vpn-session, python3-proton-vpn-connection, python3-proton-vpn-killswitch, python3-proton-vpn-logger Description: Python3 ProtonVPN Core API python-proton-vpn-api-core-0.39.0/debian/copyright000066400000000000000000000005211473026673700221330ustar00rootroot00000000000000Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/ Source: https://github.com/ProtonVPN/ Upstream-Name: python3-proton-vpn-api-core Files: * Copyright: 2023 Proton AG License: GPL-3 The full text of the GPL version 3 is distributed in /usr/share/common-licenses/GPL-3 on Debian systems.python-proton-vpn-api-core-0.39.0/debian/rules000077500000000000000000000002011473026673700212530ustar00rootroot00000000000000#!/usr/bin/make -f #export DH_VERBOSE=1 export PYBUILD_NAME=protonvpn_api_core %: dh $@ --with python3 --buildsystem=pybuild python-proton-vpn-api-core-0.39.0/docs/000077500000000000000000000000001473026673700177105ustar00rootroot00000000000000python-proton-vpn-api-core-0.39.0/docs/conf.py000066400000000000000000000037321473026673700212140ustar00rootroot00000000000000# Configuration file for the Sphinx documentation builder. # # This file only contains a selection of the most common options. For a full # list see the documentation: # https://www.sphinx-doc.org/en/master/usage/configuration.html # -- Path setup -------------------------------------------------------------- # If extensions (or modules to document with autodoc) are in another directory, # add these directories to sys.path here. If the directory is relative to the # documentation root, use os.path.abspath to make it absolute, like shown here. # # import os # import sys # sys.path.insert(0, os.path.abspath('.')) # -- Project information ----------------------------------------------------- project = 'python-protonvpn-account' copyright = '2022, Proton' author = 'Proton' # -- General configuration --------------------------------------------------- # Add any Sphinx extension module names here, as strings. They can be # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom # ones. extensions = [ "sphinx.ext.autodoc" ] # Add any paths that contain templates here, relative to this directory. templates_path = ['_templates'] # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. # This pattern also affects html_static_path and html_extra_path. exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] # -- Options for HTML output ------------------------------------------------- # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. # html_theme = 'sphinx_rtd_theme' # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, # so a file named "default.css" will overwrite the builtin "default.css". html_static_path = ['_static'] # Order documentation in the same order as source autodoc_member_order = 'bysource' #autodoc_mock_imports = ['proton'] python-proton-vpn-api-core-0.39.0/docs/coreapi.rst000066400000000000000000000102041473026673700220610ustar00rootroot00000000000000Application ------------ .. autoclass:: proton.vpn.core.Application :members: :special-members: __init__ :undoc-members: Orchestrators -------------- Orchestrators delegate the operations on Controllers, which themselves delegate operations to the components (VPNConnection component, VPNAccount component, VPNServers components, etc) .. code-block:: ascii +------+ | View | +---+--+ | +--v--+ | App | +--+--+ | | +---------------+ +-------v--------+ | Session | | Connection | | Orchestrator <--+ Orchestrator +---------+------------------+ +------+--------+ +--------+-------+ | | | | | | | | | | | | +-------v------+ +-----v--------+ | | | VPN Servers | | User Settings| +-------------+ | | Orchestrator | | Orchestrator | | | | +-------+------+ +--------------+ | | | | +-------v---+ | | | |Proton VPN | +------v------+ +---------v-----+ +-------v------+ |Session | | Credentials | |VPN Connection | | VPN Servers | |Controller | | Controller | | Controller | | Controller | +-----------+ +-------------+ +---------------+ +--------------+ See : - :class:`proton.vpn.core.controllers.vpnsession.VPNSessionController` - :class:`proton.vpn.core.controllers.vpnconnection.VPNConnectionController` - :class:`proton.vpn.core.controllers.vpncredentials.VPNCredentialController` - :class:`proton.vpn.core.controllers.vpnservers.VPNServersController` For controllers documentation. Orchestrators -------------- .. autoclass:: proton.vpn.core.orchestrators.usersettings.UserSettingsOrchestrator :members: :special-members: __init__ :undoc-members: .. autoclass:: proton.vpn.core.orchestrators.vpnconnection.VPNConnectionOrchestrator :members: :special-members: __init__ :undoc-members: .. autoclass:: proton.vpn.core.orchestrators.vpnserver.VPNServerOrchestrator :members: :special-members: __init__ :undoc-members: .. autoclass:: proton.vpn.core.orchestrators.vpnsession.VPNSessionOrchestrator :members: :special-members: __init__ :undoc-members: Controllers -------------- Controllers implement the high level business logic of the application, ensuring that the VPN service is in a consistent state. .. autoclass:: proton.vpn.core.controllers.vpnconnection.VPNConnectionController :members: :special-members: __init__ :undoc-members: .. autoclass:: proton.vpn.core.controllers.vpncredentials.VPNCredentialController :members: :special-members: __init__ :undoc-members: .. autoclass:: proton.vpn.core.controllers.vpnservers.VPNServersController :members: :special-members: __init__ :undoc-members: .. autoclass:: proton.vpn.core.controllers.vpnsession.VPNSessionController :members: :special-members: __init__ :undoc-members: User Settings ------------- .. autoclass:: proton.vpn.core.controllers.usersettings.BasicSettings :members: :special-members: __init__ :undoc-members: Persistence ------------ .. autoclass:: proton.vpn.core.controllers.usersettings.FilePersistence :members: :special-members: __init__ :undoc-members: Views ------ An abstract view of the user interface. .. autoclass:: proton.vpn.core.views.BaseView :members: :special-members: __init__ :undoc-members: python-proton-vpn-api-core-0.39.0/docs/index.rst000066400000000000000000000004311473026673700215470ustar00rootroot00000000000000.. python-proton-account documentation master file Welcome to python-protonvpn-coreapi's documentation! ==================================================== .. toctree:: :maxdepth: 2 :caption: Contents: coreapi Indices and tables ================== * :ref:`genindex` python-proton-vpn-api-core-0.39.0/proton/000077500000000000000000000000001473026673700203015ustar00rootroot00000000000000python-proton-vpn-api-core-0.39.0/proton/vpn/000077500000000000000000000000001473026673700211045ustar00rootroot00000000000000python-proton-vpn-api-core-0.39.0/proton/vpn/connection/000077500000000000000000000000001473026673700232435ustar00rootroot00000000000000python-proton-vpn-api-core-0.39.0/proton/vpn/connection/__init__.py000066400000000000000000000025061473026673700253570ustar00rootroot00000000000000""" The public interface and the functionality that's common to all supported VPN connection backends is defined in this module. Copyright (c) 2023 Proton AG This file is part of Proton VPN. Proton VPN 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. Proton VPN 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 ProtonVPN. If not, see . """ from importlib.metadata import version, PackageNotFoundError try: __version__ = version("proton-vpn-connection") except PackageNotFoundError: __version__ = "development" # pylint: disable=wrong-import-position from .vpnconnection import VPNConnection from .interfaces import ( VPNServer, ProtocolPorts, VPNCredentials, VPNPubkeyCredentials, VPNUserPassCredentials, Settings ) __all__ = [ "VPNConnection", "VPNServer", "ProtocolPorts", "VPNCredentials", "VPNPubkeyCredentials", "VPNUserPassCredentials", "Settings" ] python-proton-vpn-api-core-0.39.0/proton/vpn/connection/constants.py000066400000000000000000000126641473026673700256420ustar00rootroot00000000000000"""Constants required to establish a VPN connection. Copyright (c) 2023 Proton AG This file is part of Proton VPN. Proton VPN 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. Proton VPN 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 ProtonVPN. If not, see . """ CA_CERT = """ -----BEGIN CERTIFICATE----- MIIFnTCCA4WgAwIBAgIUCI574SM3Lyh47GyNl0WAOYrqb5QwDQYJKoZIhvcNAQEL BQAwXjELMAkGA1UEBhMCQ0gxHzAdBgNVBAoMFlByb3RvbiBUZWNobm9sb2dpZXMg QUcxEjAQBgNVBAsMCVByb3RvblZQTjEaMBgGA1UEAwwRUHJvdG9uVlBOIFJvb3Qg Q0EwHhcNMTkxMDE3MDgwNjQxWhcNMzkxMDEyMDgwNjQxWjBeMQswCQYDVQQGEwJD SDEfMB0GA1UECgwWUHJvdG9uIFRlY2hub2xvZ2llcyBBRzESMBAGA1UECwwJUHJv dG9uVlBOMRowGAYDVQQDDBFQcm90b25WUE4gUm9vdCBDQTCCAiIwDQYJKoZIhvcN AQEBBQADggIPADCCAgoCggIBAMkUT7zMUS5C+NjQ7YoGpVFlfbN9HFgG4JiKfHB8 QxnPPRgyTi0zVOAj1ImsRilauY8Ddm5dQtd8qcApoz6oCx5cFiiSQG2uyhS/59Zl 5wqIkw1o+CgwZgeWkq04lcrxhhfPgJZRFjrYVezy/Z2Ssd18s3/FFNQ+2iV1KC2K z8eSPr50u+l9vEKsKiNGkJTdlWjoDKZM2C15i/h8Smi+PdJlx7WMTtYoVC1Fzq0r aCPDQl18kspu11b6d8ECPWghKcDIIKuA0r0nGqF1GvH1AmbC/xUaNrKgz9AfioZL MP/l22tVG3KKM1ku0eYHX7NzNHgkM2JKnBBannImQQBGTAcvvUlnfF3AHx4vzx7H ahpBz8ebThx2uv+vzu8lCVEcKjQObGwLbAONJN2enug8hwSSZQv7tz7onDQWlYh0 El5fnkrEQGbukNnSyOqTwfobvBllIPzBqdO38eZFA0YTlH9plYjIjPjGl931lFAA 3G9t0x7nxAauLXN5QVp1yoF1tzXc5kN0SFAasM9VtVEOSMaGHLKhF+IMyVX8h5Iu IRC8u5O672r7cHS+Dtx87LjxypqNhmbf1TWyLJSoh0qYhMr+BbO7+N6zKRIZPI5b MXc8Be2pQwbSA4ZrDvSjFC9yDXmSuZTyVo6Bqi/KCUZeaXKof68oNxVYeGowNeQd g/znAgMBAAGjUzBRMB0GA1UdDgQWBBR44WtTuEKCaPPUltYEHZoyhJo+4TAfBgNV HSMEGDAWgBR44WtTuEKCaPPUltYEHZoyhJo+4TAPBgNVHRMBAf8EBTADAQH/MA0G CSqGSIb3DQEBCwUAA4ICAQBBmzCQlHxOJ6izys3TVpaze+rUkA9GejgsB2DZXIcm 4Lj/SNzQsPlZRu4S0IZV253dbE1DoWlHanw5lnXwx8iU82X7jdm/5uZOwj2NqSqT bTn0WLAC6khEKKe5bPTf18UOcwN82Le3AnkwcNAaBO5/TzFQVgnVedXr2g6rmpp9 gdedeEl9acB7xqfYfkrmijqYMm+xeG2rXaanch3HjweMDuZdT/Ub5G6oir0Kowft lA1ytjXRg+X+yWymTpF/zGLYfSodWWjMKhpzZtRJZ+9B0pWXUyY7SuCj5T5SMIAu x3NQQ46wSbHRolIlwh7zD7kBgkyLe7ByLvGFKa2Vw4PuWjqYwrRbFjb2+EKAwPu6 VTWz/QQTU8oJewGFipw94Bi61zuaPvF1qZCHgYhVojRy6KcqncX2Hx9hjfVxspBZ DrVH6uofCmd99GmVu+qizybWQTrPaubfc/a2jJIbXc2bRQjYj/qmjE3hTlmO3k7V EP6i8CLhEl+dX75aZw9StkqjdpIApYwX6XNDqVuGzfeTXXclk4N4aDPwPFM/Yo/e KnvlNlKbljWdMYkfx8r37aOHpchH34cv0Jb5Im+1H07ywnshXNfUhRazOpubJRHn bjDuBwWS1/Vwp5AJ+QHsPXhJdl3qHc1szJZVJb3VyAWvG/bWApKfFuZX18tiI4N0 EA== -----END CERTIFICATE----- """ OPENVPN_V2_TEMPLATE = """ # ============================================================================== # Copyright (c) 2016-2020 Proton Technologies AG (Switzerland) # Email: contact@protonvpn.com # # The MIT License (MIT) # # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to deal # in the Software without restriction, including without limitation the rights # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell # copies of the Software, and to permit persons to whom the Software is # furnished to do so, subject to the following conditions: # # The above copyright notice and this permission notice shall be included in all # copies or substantial portions of the Software. # # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR # OTHERWISE, ARISING # FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS # IN THE SOFTWARE. # ============================================================================== {%- if enable_ipv6_support %} push-peer-info setenv UV_IPV6 1 {%- endif %} client dev tun proto {{ openvpn_protocol|lower }} {% for ip in serverlist %} {%- for port in openvpn_ports -%} remote {{ ip }} {{ port }} {% endfor %} {% endfor -%} remote-random resolv-retry infinite nobind cipher AES-256-GCM verb 3 tun-mtu 1500 mssfix 0 persist-key persist-tun reneg-sec 0 remote-cert-tls server {%- if not certificate_based %} auth-user-pass {%- endif %} {{ca_certificate}} -----BEGIN OpenVPN Static key V1----- 6acef03f62675b4b1bbd03e53b187727 423cea742242106cb2916a8a4c829756 3d22c7e5cef430b1103c6f66eb1fc5b3 75a672f158e2e2e936c3faa48b035a6d e17beaac23b5f03b10b868d53d03521d 8ba115059da777a60cbfd7b2c9c57472 78a15b8f6e68a3ef7fd583ec9f398c8b d4735dab40cbd1e3c62a822e97489186 c30a0b48c7c38ea32ceb056d3fa5a710 e10ccc7a0ddb363b08c3d2777a3395e1 0c0b6080f56309192ab5aacd4b45f55d a61fc77af39bd81a19218a79762c3386 2df55785075f37d8c71dc8a42097ee43 344739a0dd48d03025b0450cf1fb5e8c aeb893d9a96d1f15519bb3c4dcb40ee3 16672ea16c012664f8a9f11255518deb -----END OpenVPN Static key V1----- {%- if certificate_based %} {{cert}} {{priv_key}} {%- endif %} """ WIREGUARD_TEMPLATE = """ [Interface] PrivateKey = {{ wg_client_secret_key }} Address = 10.2.0.2/32 DNS = 10.2.0.1 [Peer] PublicKey = {{ wg_server_pk }} Endpoint = {{ wg_ip }}:{{ wg_port }} AllowedIPs = 0.0.0.0/0 """ python-proton-vpn-api-core-0.39.0/proton/vpn/connection/enum.py000066400000000000000000000026621473026673700245670ustar00rootroot00000000000000"""VPN connection enums. Copyright (c) 2023 Proton AG This file is part of Proton VPN. Proton VPN 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. Proton VPN 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 ProtonVPN. If not, see . """ from enum import auto, Enum, IntEnum class ConnectionStateEnum(IntEnum): """VPN connection states.""" DISCONNECTED = 0 CONNECTING = 1 CONNECTED = 2 DISCONNECTING = 3 ERROR = 4 class StateMachineEventEnum(Enum): """VPN connection events.""" INITIALIZED = auto() UP = auto() DOWN = auto() CONNECTED = auto() DISCONNECTED = auto() TIMEOUT = auto() AUTH_DENIED = auto() TUNNEL_SETUP_FAILED = auto() RETRY = auto() UNEXPECTED_ERROR = auto() DEVICE_DISCONNECTED = auto() CERTIFICATE_EXPIRED = auto() MAXIMUM_SESSIONS_REACHED = auto() UNHANDLED_ERROR = auto() class KillSwitchSetting(IntEnum): """Kill switch setting values.""" OFF = 0 ON = 1 PERMANENT = 2 python-proton-vpn-api-core-0.39.0/proton/vpn/connection/events.py000066400000000000000000000103771473026673700251310ustar00rootroot00000000000000""" VPN connection events to react to. Copyright (c) 2023 Proton AG This file is part of Proton VPN. Proton VPN 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. Proton VPN 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 ProtonVPN. If not, see . """ from __future__ import annotations from dataclasses import dataclass from typing import TYPE_CHECKING, Optional, Any from .enum import StateMachineEventEnum if TYPE_CHECKING: from proton.vpn.connection.vpnconnection import VPNConnection @dataclass class ConnectionDetails: """Connection details obtained via local agent.""" device_ip: Optional[str] = None device_country: Optional[str] = None server_ipv4: Optional[str] = None server_ipv6: Optional[str] = None # pylint: disable=too-few-public-methods @dataclass class EventContext: """ Relevant event context. Args: connection: the VPN connection object that emitted this event. reason: optional backend-dependent data providing more context about the event. error: an optional exception to be bubbled up while processing the event. """ connection: "VPNConnection" connection_details: Optional[ConnectionDetails] = None forwarded_port: Optional[int] = None reason: Optional[Any] = None error: Optional[Exception] = None class Event: """Base event that all the other events should inherit from.""" type = None def __init__(self, context: EventContext = None): if self.type is None: raise AttributeError("event attribute not defined") self.context = context or EventContext(connection=None) def check_for_errors(self): """Raises an exception if there is one.""" if self.context.error: raise self.context.error class Initialized(Event): """Event that leads to the initial state.""" type = StateMachineEventEnum.INITIALIZED class Up(Event): """Signals that the VPN connection should be started.""" type = StateMachineEventEnum.UP class Down(Event): """Signals that the VPN connection should be stopped.""" type = StateMachineEventEnum.DOWN class Connected(Event): """Signals that the VPN connection was successfully established.""" type = StateMachineEventEnum.CONNECTED class Disconnected(Event): """Signals that the VPN connection was successfully disconnected by the user.""" type = StateMachineEventEnum.DISCONNECTED class Error(Event): """Parent class for events signaling VPN disconnection.""" class DeviceDisconnected(Error): """Signals that the VPN connection dropped unintentionally.""" type = StateMachineEventEnum.DEVICE_DISCONNECTED class Timeout(Error): """Signals that a timeout occurred while trying to establish the VPN connection.""" type = StateMachineEventEnum.TIMEOUT class AuthDenied(Error): """Signals that an authentication denied occurred while trying to establish the VPN connection.""" type = StateMachineEventEnum.AUTH_DENIED class ExpiredCertificate(Error): """Signals that the passed certificate has expired and needs to be refreshed.""" type = StateMachineEventEnum.CERTIFICATE_EXPIRED class MaximumSessionsReached(Error): """Signals that for the given plan the user has too many devices/sessions connected.""" type = StateMachineEventEnum.MAXIMUM_SESSIONS_REACHED class TunnelSetupFailed(Error): """Signals that there was an error setting up the VPN tunnel.""" type = StateMachineEventEnum.TUNNEL_SETUP_FAILED class UnexpectedError(Error): """Signals that an unexpected error occurred.""" type = StateMachineEventEnum.UNEXPECTED_ERROR _event_types = [ event_type for event_type in Event.__subclasses__() if event_type is not Error # As error is an abstract class. ] _event_types.extend(Error.__subclasses__()) EVENT_TYPES = tuple(_event_types) python-proton-vpn-api-core-0.39.0/proton/vpn/connection/exceptions.py000066400000000000000000000054361473026673700260060ustar00rootroot00000000000000""" Exceptions raised by the VPN connection module. Copyright (c) 2023 Proton AG This file is part of Proton VPN. Proton VPN 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. Proton VPN 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 ProtonVPN. If not, see . """ class VPNConnectionError(Exception): """Base class for VPN specific exceptions""" def __init__(self, message, additional_context=None): self.message = message self.additional_context = additional_context super().__init__(self.message) class AuthenticationError(VPNConnectionError): """When server answers with auth_denied this exception is thrown. In many cases, an auth_denied can be thrown for multiple reasons, thus it's up to the user to decide how to proceed further. """ class ConnectionTimeoutError(VPNConnectionError): """When a connection takes too long to connect, this exception will be thrown.""" class MissingBackendDetails(VPNConnectionError): """When no VPN backend is found (NetworkManager, Native, etc) then this exception is thrown. In rare cases where it can happen that a user has some default packages installed, where the services for those packages are actually not running. Ie: NetworkManager is installed but not running and for some reason we can't access native backend, thus this exception is thrown as we can't do anything. """ class MissingProtocolDetails(VPNConnectionError): """ When no VPN protocol is found (OpenVPN, Wireguard, IKEv2, etc) then this exception is thrown. """ class ConcurrentConnectionsError(VPNConnectionError): """ Multiple concurrent connections were found, even though only one is allowed at a time. """ class FeatureError(VPNConnectionError): """ Feature errors are thrown when the server fails to set the requested connection feature. """ class FeaturePolicyError(FeatureError): """ Policy errors happen when the server fails to set the requested connection feature, either because the user doesn't have the rights to do so or because of server-side issues. """ class FeatureSyntaxError(FeatureError): """ Syntax errors are programming errors, meaning that what we the request to set the connection feature is incorrect, ie: passing wrong/non-existent values, format is incorrect, etc. """ python-proton-vpn-api-core-0.39.0/proton/vpn/connection/interfaces.py000066400000000000000000000153251473026673700257460ustar00rootroot00000000000000""" Interfaces required to be able to establish a VPN connection. Copyright (c) 2023 Proton AG This file is part of Proton VPN. Proton VPN 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. Proton VPN 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 ProtonVPN. If not, see . """ from __future__ import annotations from typing import List, Optional, Protocol from dataclasses import dataclass @dataclass class ProtocolPorts: # pylint: disable=R0801 """Dataclass for ports. These ports are mainly used for establishing VPN connections. """ udp: List tcp: List @staticmethod def from_dict(ports: dict) -> ProtocolPorts: """Creates ProtocolPorts object from data.""" # The lists are copied to avoid side effects if the dict is modified. return ProtocolPorts( udp=ports["udp"].copy(), tcp=ports["tcp"].copy() ) def to_dict(self) -> dict: """ Returns a dictionary representation of the object. """ return { "udp": self.udp.copy(), "tcp": self.tcp.copy() } @dataclass class VPNServer: # pylint: disable=too-few-public-methods,too-many-instance-attributes """ Contains the necessary data about the server to connect to. Some properties like server_id and server_name are not used to establish the connection, but they are required for bookkeeping. When the connection is retrieved from persistence, then VPN clients can use this information to be able to identify the server that the VPN connection was established to. The server name is there mainly for debugging purposes. Attributes: server_ip: server ip to connect to. domain: domain to be used for x509 verification. x25519pk: x25519 public key for wireguard peer verification. wireguard_ports: Dict of WireGuard ports, if the protocol requires them. openvpn_ports: Dict of OpenVPN ports, if the protocol requires them. server_id: ID of the server to connect to. server_name: Name of the server to connect to. """ server_ip: str openvpn_ports: ProtocolPorts wireguard_ports: ProtocolPorts domain: str x25519pk: str server_id: str server_name: str has_ipv6_support: bool label: str = None def __str__(self): return f"Server: {self.server_name} / Domain: {self.domain} / " \ f"IP: {self.server_ip} / OpenVPN Ports: {self.openvpn_ports} / " \ f"WireGuard Ports: {self.wireguard_ports}" @staticmethod def from_dict(data: dict) -> VPNServer: """ Creates a VPNServer object from a dictionary. """ return VPNServer( server_ip=data["server_ip"], openvpn_ports=ProtocolPorts.from_dict(data["openvpn_ports"]), wireguard_ports=ProtocolPorts.from_dict(data["wireguard_ports"]), domain=data["domain"], x25519pk=data["x25519pk"], server_id=data["server_id"], server_name=data["server_name"], has_ipv6_support=data["has_ipv6_support"], label=data.get("label") ) def to_dict(self) -> dict: """ Returns a dictionary representation of the object. """ return { "server_ip": self.server_ip, "openvpn_ports": self.openvpn_ports.to_dict(), "wireguard_ports": self.wireguard_ports.to_dict(), "domain": self.domain, "x25519pk": self.x25519pk, "server_id": self.server_id, "server_name": self.server_name, "has_ipv6_support": self.has_ipv6_support, "label": self.label } class VPNPubkeyCredentials(Protocol): # pylint: disable=too-few-public-methods """ Object that gets certificates and privates keys for certificate based connections. An instance of this class is to be passed to VPNCredentials. Attributes: certificate_pem: X509 client certificate in PEM format. wg_private_key: wireguard private key in base64 format. openvpn_private_key: OpenVPN private key in PEM format. """ certificate_pem: str wg_private_key: str openvpn_private_key: str class VPNUserPassCredentials(Protocol): # pylint: disable=too-few-public-methods """Provides username and password for username/password VPN authentication.""" username: str password: str class VPNCredentials(Protocol): # pylint: disable=too-few-public-methods """ Credentials are needed to establish a VPN connection. Depending on how these credentials are used, one method or the other may be irrelevant. Limitation: You could define only userpass_credentials, though at the cost that you won't be able to connect to wireguard (since it's based on certificates) and/or openvpn and ikev2 based with certificates. To guarantee maximum compatibility, it is recommended to pass both objects for username/password and certificates. """ pubkey_credentials: Optional[VPNPubkeyCredentials] userpass_credentials: Optional[VPNUserPassCredentials] class Features(Protocol): """ This class is used to define which features are supported. """ # pylint: disable=too-few-public-methods duplicate-code netshield: int moderate_nat: bool vpn_accelerator: bool port_forwarding: bool ipv6: bool class Settings(Protocol): """Optional. If you would like to pass some specific settings for VPN configuration then you should derive from this class and override its methods. Usage: .. code-block:: from proton.vpn.connection import Settings class VPNSettings(Settings): @property def dns_custom_ips(self): return ["192.12.2.1", "175.12.3.5"] Note: Not all fields are mandatory to override, only those that are actually needed, ie: .. code-block:: from proton.vpn.connection import Settings class VPNSettings(Settings): @property def dns_custom_ips(self): return ["192.12.2.1", "175.12.3.5"] Passing only this is perfectly fine. """ # pylint: disable=too-few-public-methods killswitch: int dns_custom_ips: List[str] features: Features protocol: str python-proton-vpn-api-core-0.39.0/proton/vpn/connection/persistence.py000066400000000000000000000100531473026673700261400ustar00rootroot00000000000000""" Connection persistence. Connection parameters are persisted to disk so that they can be loaded after a crash. Copyright (c) 2023 Proton AG This file is part of Proton VPN. Proton VPN 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. Proton VPN 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 ProtonVPN. If not, see . """ from __future__ import annotations import json import os from dataclasses import dataclass from json import JSONDecodeError from typing import Optional from proton.utils.environment import VPNExecutionEnvironment from proton.vpn import logging from proton.vpn.connection.interfaces import VPNServer logger = logging.getLogger(__name__) @dataclass class ConnectionParameters: """Connection parameters to be persisted to disk.""" connection_id: str backend: str protocol: str server: VPNServer @classmethod def from_dict(cls, data: dict) -> ConnectionParameters: """Creates a ConnectionParameters instance from a dictionary.""" return cls( connection_id=data["connection_id"], backend=data["backend"], protocol=data["protocol"], server=VPNServer.from_dict(data["server"]) ) def to_dict(self) -> ConnectionParameters: """Creates a dictionary from a ConnectionParameters instance.""" return { "connection_id": self.connection_id, "backend": self.backend, "protocol": self.protocol, "server": self.server.to_dict() } class ConnectionPersistence: """Saves/loads connection parameters to/from disk.""" FILENAME = "connection_persistence.json" def __init__(self, persistence_directory: str = None): self._directory = persistence_directory @property def _connection_file_path(self): if not self._directory: self._directory = os.path.join( VPNExecutionEnvironment().path_cache, "connection" ) os.makedirs(self._directory, mode=0o700, exist_ok=True) return os.path.join(self._directory, self.FILENAME) def load(self) -> Optional[ConnectionParameters]: """Returns the connection parameters loaded from disk, or None if no connection parameters were persisted yet.""" if not os.path.isfile(self._connection_file_path): return None with open(self._connection_file_path, encoding="utf-8") as file: try: file_content = json.load(file) return ConnectionParameters.from_dict(file_content) except (JSONDecodeError, KeyError, UnicodeDecodeError): logger.warning( "Unexpected error parsing connection persistence file: " f"{self._connection_file_path}", category="CONN", subcategory="PERSISTENCE", event="LOAD", exc_info=True ) return None def save(self, connection_parameters: ConnectionParameters): """Saves connection parameters to disk.""" with open(self._connection_file_path, "w", encoding="utf-8") as file: json.dump(connection_parameters.to_dict(), file) def remove(self): """Removes the connection persistence file, if it exists.""" if os.path.isfile(self._connection_file_path): os.remove(self._connection_file_path) else: logger.warning( f"Connection persistence not found when trying " f"to remove it: {self._connection_file_path}", category="CONN", subcategory="PERSISTENCE", event="REMOVE" ) python-proton-vpn-api-core-0.39.0/proton/vpn/connection/publisher.py000066400000000000000000000071141473026673700256150ustar00rootroot00000000000000""" Implementation of the Publisher/Subscriber used to signal VPN connection state changes. Copyright (c) 2023 Proton AG This file is part of Proton VPN. Proton VPN 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. Proton VPN 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 ProtonVPN. If not, see . """ import asyncio import inspect from typing import Callable, List, Optional from proton.vpn import logging logger = logging.getLogger(__name__) class Publisher: """Simple generic implementation of the publish-subscribe pattern.""" def __init__(self, subscribers: Optional[List[Callable]] = None): self._subscribers = subscribers or [] self._pending_tasks = set() def register(self, subscriber: Callable): """ Registers a subscriber to be notified of new updates. The subscribers are not expected to block, as they will be notified sequentially, one after the other in the order in which they were registered. :param subscriber: callback that will be called with the expected args/kwargs whenever there is an update. :raises ValueError: if the subscriber is not callable. """ if not callable(subscriber): raise ValueError(f"Subscriber to register is not callable: {subscriber}") if subscriber not in self._subscribers: self._subscribers.append(subscriber) def unregister(self, subscriber: Callable): """ Unregisters a subscriber. :param subscriber: the subscriber to be unregistered. """ if subscriber in self._subscribers: self._subscribers.remove(subscriber) def notify(self, *args, **kwargs): """ Notifies the subscribers about a new update. All subscribers will be called Each backend and/or protocol have to call this method whenever the connection state changes, so that each subscriber can receive states changes whenever they occur. :param connection_status: the current status of the connection :type connection_status: ConnectionStateEnum """ for subscriber in self._subscribers: try: if inspect.iscoroutinefunction(subscriber): notification_task = asyncio.create_task(subscriber(*args, **kwargs)) self._pending_tasks.add(notification_task) notification_task.add_done_callback(self._on_notification_task_done) else: subscriber(*args, **kwargs) except Exception: # pylint: disable=broad-except logger.exception(f"An error occurred notifying subscriber {subscriber}.") def _on_notification_task_done(self, task: asyncio.Task): self._pending_tasks.discard(task) task.result() def is_subscriber_registered(self, subscriber: Callable) -> bool: """Returns whether a subscriber is registered or not.""" return subscriber in self._subscribers @property def number_of_subscribers(self) -> int: """Number of currently registered subscribers.""" return len(self._subscribers) python-proton-vpn-api-core-0.39.0/proton/vpn/connection/states.py000066400000000000000000000335141473026673700251260ustar00rootroot00000000000000""" The different VPN connection states and their transitions is defined here. Copyright (c) 2023 Proton AG This file is part of Proton VPN. Proton VPN 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. Proton VPN 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 ProtonVPN. If not, see . """ from __future__ import annotations from abc import ABC, abstractmethod from dataclasses import dataclass, field from typing import TYPE_CHECKING, Optional, ClassVar from proton.vpn import logging from proton.vpn.connection import events from proton.vpn.connection.enum import ConnectionStateEnum, KillSwitchSetting from proton.vpn.connection.events import EventContext from proton.vpn.connection.exceptions import ConcurrentConnectionsError from proton.vpn.killswitch.interface import KillSwitch if TYPE_CHECKING: from proton.vpn.connection.vpnconnection import VPNConnection logger = logging.getLogger(__name__) @dataclass class StateContext: """ Relevant state context data. Attributes: event: Event that led to the current state. connection: current VPN connection. They only case where this attribute could be None is on the initial state, if there is not already an existing VPN connection. reconnection: optional VPN connection to connect to as soon as stopping the current one. kill_switch: kill switch implementation. kill_switch_setting: on, off, permanent. """ event: events.Event = field(default_factory=events.Initialized) connection: Optional["VPNConnection"] = None reconnection: Optional["VPNConnection"] = None kill_switch: ClassVar[KillSwitch] = None kill_switch_setting: ClassVar[KillSwitchSetting] = None class State(ABC): """ This is the base state from which all other states derive from. Each new state has to implement the `on_event` method. Since these states are backend agnostic. When implement a new backend the person implementing it has to have special care in correctly translating the backend specific events to known events (see `proton.vpn.connection.events`). Each state acts on the `on_event` method. Generally, if a state receives an unexpected event, it will then not update the state but rather keep the same state and should log the occurrence. The general idea of state transitions: 1) Connect happy path: Disconnected -> Connecting -> Connected 2) Connect with error path: Disconnected -> Connecting -> Error 3) Disconnect happy path: Connected -> Disconnecting -> Disconnected 4) Active connection error path: Connected -> Error Certain states will have to call methods from the state machine (see `Disconnected`, `Connected`). Both of these states call `vpn_connection.start()` and `vpn_connection.stop()`. It should be noted that these methods should be run in an async way so that it does not block the execution of the next line. States also have `context` (which are fetched from events). These can help in discovering potential issues on why certain states might an unexpected behavior. It is worth mentioning though that the contexts will always be backend specific. """ type = None def __init__(self, context: StateContext = None): self.context = context or StateContext() if self.type is None: raise TypeError("Undefined attribute \"state\" ") def _assert_no_concurrent_connections(self, event: events.Event): not_up_event = not isinstance(event, events.Up) different_connection = event.context.connection is not self.context.connection if not_up_event and different_connection: # Any state should always receive events for the same connection, the only # exception being when the Up event is received. In this case, the Up event # always carries a new connection: the new connection to be initiated. raise ConcurrentConnectionsError( f"State {self} expected events from {self.context.connection} " f"but received an event from {event.context.connection} instead." ) def on_event(self, event: events.Event) -> State: """Returns the new state based on the received event.""" self._assert_no_concurrent_connections(event) event.check_for_errors() new_state = self._on_event(event) if new_state is self: logger.warning( f"{self.type.name} state received unexpected " f"event: {type(event).__name__}", category="CONN", event="WARNING" ) return new_state @abstractmethod def _on_event( self, event: events.Event ) -> State: """Given an event, it returns the new state.""" async def run_tasks(self) -> Optional[events.Event]: """Tasks to be run when this state instance becomes the current VPN state.""" @property def forwarded_port(self) -> Optional[int]: """Returns the forwarded port if it exists.""" return self.context.event.context.forwarded_port class Disconnected(State): """ Disconnected is the initial state of a connection. It's also its final state, except if the connection could not be established due to an error. """ type = ConnectionStateEnum.DISCONNECTED def _on_event(self, event: events.Event): if isinstance(event, events.Up): return Connecting(StateContext(event=event, connection=event.context.connection)) return self async def run_tasks(self): # When the state machine is in disconnected state, a VPN connection # may have not been created yet. if self.context.connection: await self.context.connection.remove_persistence() if self.context.reconnection: # The Kill switch is enabled to avoid leaks when switching servers, even when # the kill switch setting is off. await self.context.kill_switch.enable() # When a reconnection is expected, an Up event is returned to start a new connection. # straight away. return events.Up(EventContext(connection=self.context.reconnection)) if self.context.kill_switch_setting == KillSwitchSetting.PERMANENT: # This is an abstraction leak of the network manager KS. # The only reason for enabling permanent KS here is to switch from the # routed KS to the full KS if the user cancels the connection while in # Connecting state. Otherwise, the full KS should already be there. await self.context.kill_switch.enable(permanent=True) else: await self.context.kill_switch.disable() await self.context.kill_switch.disable_ipv6_leak_protection() return None class Connecting(State): """ Connecting is the state reached when a VPN connection is requested. """ type = ConnectionStateEnum.CONNECTING _counter = 0 def _on_event(self, event: events.Event): if isinstance(event, events.Connected): return Connected(StateContext(event=event, connection=event.context.connection)) if isinstance(event, events.Down): return Disconnecting(StateContext(event=event, connection=event.context.connection)) if isinstance(event, events.Error): return Error(StateContext(event=event, connection=event.context.connection)) if isinstance(event, events.Up): # If a new connection is requested while in `Connecting` state then # cancel the current one and pass the requested connection so that it's # started as soon as the current connection is down. return Disconnecting( StateContext( event=event, connection=self.context.connection, reconnection=event.context.connection ) ) if isinstance(event, events.Disconnected): # Another process disconnected the VPN, otherwise the Disconnected # event would've been received by the Disconnecting state. return Disconnected(StateContext(event=event, connection=event.context.connection)) return self async def run_tasks(self): permanent_ks = self.context.kill_switch_setting == KillSwitchSetting.PERMANENT # The reason for always enabling the kill switch independently of the kill switch setting # is to avoid leaks when switching servers, even with the kill switch turned off. # However, when the kill switch setting is off, the kill switch has to be removed when # reaching the connected state. await self.context.kill_switch.enable( self.context.connection.server, permanent=permanent_ks ) await self.context.connection.start() class Connected(State): """ Connected is the state reached once the VPN connection has been successfully established. """ type = ConnectionStateEnum.CONNECTED def _on_event(self, event: events.Event): if isinstance(event, events.Down): return Disconnecting(StateContext(event=event, connection=event.context.connection)) if isinstance(event, events.Up): # If a new connection is requested while in `Connected` state then # cancel the current one and pass the requested connection so that it's # started as soon as the current connection is down. return Disconnecting( StateContext( event=event, connection=self.context.connection, reconnection=event.context.connection ) ) if isinstance(event, events.Error): return Error(StateContext(event=event, connection=event.context.connection)) if isinstance(event, events.Disconnected): # Another process disconnected the VPN, otherwise the Disconnected # event would've been received by the Disconnecting state. return Disconnected(StateContext(event=event, connection=event.context.connection)) return self async def run_tasks(self): if self.context.kill_switch_setting == KillSwitchSetting.OFF: await self.context.kill_switch.enable_ipv6_leak_protection() await self.context.kill_switch.disable() else: # This is specific to the routing table KS implementation and should be removed. # At this point we switch from the routed KS to the full-on KS. await self.context.kill_switch.enable( permanent=(self.context.kill_switch_setting == KillSwitchSetting.PERMANENT) ) await self.context.connection.add_persistence() class Disconnecting(State): """ Disconnecting is state reached when VPN disconnection is requested. """ type = ConnectionStateEnum.DISCONNECTING def _on_event(self, event: events.Event): if isinstance(event, (events.Disconnected, events.Error)): # Note that error events signal disconnection from the VPN due to # unexpected reasons. In this case, since the goal of the # disconnecting state is to reach the disconnected state, # both disconnected and error events lead to the desired state. if isinstance(event, events.Error): logger.warning( "Error event while disconnecting: %s (%s)", type(event).__name__, event.context.error ) return Disconnected( StateContext( event=event, connection=event.context.connection, reconnection=self.context.reconnection ) ) if isinstance(event, events.Up): # If a new connection is requested while in the `Disconnecting` state then # store the requested connection in the state context so that it's started # as soon as the current connection is down. self.context.reconnection = event.context.connection return self async def run_tasks(self): await self.context.connection.stop() class Error(State): """ Error is the state reached after a connection error. """ type = ConnectionStateEnum.ERROR def _on_event(self, event: events.Event): if isinstance(event, events.Down): return Disconnected(StateContext(event=event, connection=event.context.connection)) if isinstance(event, events.Up): return Disconnecting( StateContext( event=event, connection=self.context.connection, reconnection=event.context.connection ) ) if isinstance(event, events.Connected): return Connected( StateContext( event=event, connection=self.context.connection, ) ) if isinstance(event, events.Error): return Error(StateContext(event=event, connection=event.context.connection)) return self async def run_tasks(self): logger.warning( "Reached connection error state: %s (%s)", type(self.context.event).__name__, self.context.event.context.error ) python-proton-vpn-api-core-0.39.0/proton/vpn/connection/vpnconfiguration.py000066400000000000000000000142311473026673700272110ustar00rootroot00000000000000""" This module defines the classes holding the necessary configuration to establish a VPN connection. Copyright (c) 2023 Proton AG This file is part of Proton VPN. Proton VPN 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. Proton VPN 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 ProtonVPN. If not, see . """ import ipaddress import tempfile import os from jinja2 import Environment, BaseLoader from proton.utils.environment import ExecutionEnvironment from proton.vpn.connection.constants import \ CA_CERT, OPENVPN_V2_TEMPLATE, WIREGUARD_TEMPLATE class VPNConfiguration: """Base VPN configuration.""" PROTOCOL = None EXTENSION = None def __init__(self, vpnserver, vpncredentials, settings, use_certificate=False): self._configfile = None self._configfile_enter_level = None self._vpnserver = vpnserver self._vpncredentials = vpncredentials self._settings = settings self.use_certificate = use_certificate @classmethod def from_factory(cls, protocol): """Returns the configuration class based on the specified protocol.""" protocols = { "openvpn-tcp": OpenVPNTCPConfig, "openvpn-udp": OpenVPNUDPConfig, "wireguard": WireguardConfig, } return protocols[protocol] def __enter__(self): # We create the configuration file when we enter, # and delete it when we exit. # This is a race free way of having temporary files. if self._configfile is None: self._delete_existing_configuration() # NOTE: we should try to keep filename length # below 15 characters, including the prefix. self._configfile = tempfile.NamedTemporaryFile( dir=self.__base_path, delete=False, prefix='pvpn', suffix=self.EXTENSION, mode='w' ) self._configfile.write(self.generate()) self._configfile.close() self._configfile_enter_level = 0 self._configfile_enter_level += 1 return self._configfile.name def __exit__(self, exc_type, exc_val, exc_tb): if self._configfile is None: return self._configfile_enter_level -= 1 if self._configfile_enter_level == 0: os.unlink(self._configfile.name) self._configfile = None def _delete_existing_configuration(self): for file in self.__base_path: if file.endswith(f".{self.EXTENSION}"): os.remove(os.path.join(self.__base_path, file)) def generate(self) -> str: """Generates the configuration file content.""" raise NotImplementedError @property def __base_path(self): return ExecutionEnvironment().path_runtime @staticmethod def cidr_to_netmask(cidr) -> str: """Returns the subnet netmask from the CIDR.""" subnet = ipaddress.IPv4Network(f"0.0.0.0/{cidr}") return str(subnet.netmask) @staticmethod def is_valid_ipv4(ip_address) -> bool: """Returns True if the specified ip address is a valid IPv4 address, and False otherwise.""" try: ipaddress.ip_address(ip_address) except ValueError: return False return True class OVPNConfig(VPNConfiguration): """OpenVPN-specific configuration.""" PROTOCOL = None EXTENSION = ".ovpn" def generate(self) -> str: """Method that generates a vpn config file. Returns: string: configuration file """ openvpn_ports = self._vpnserver.openvpn_ports ports = openvpn_ports.tcp if "tcp" == self.PROTOCOL else openvpn_ports.udp enable_ipv6_support = self._vpnserver.has_ipv6_support and self._settings.ipv6 j2_values = { "enable_ipv6_support": enable_ipv6_support, "openvpn_protocol": self.PROTOCOL, "serverlist": [self._vpnserver.server_ip], "openvpn_ports": ports, "ca_certificate": CA_CERT, "certificate_based": self.use_certificate, } if self.use_certificate: j2_values["cert"] = self._vpncredentials.pubkey_credentials.certificate_pem j2_values["priv_key"] = self._vpncredentials.pubkey_credentials.openvpn_private_key template =\ (Environment(loader=BaseLoader, autoescape=True) # noqa: E501 # pylint: disable=line-too-long # nosemgrep: python.flask.security.xss.audit.direct-use-of-jinja2.direct-use-of-jinja2 .from_string(OPENVPN_V2_TEMPLATE)) return template.render(j2_values) class OpenVPNTCPConfig(OVPNConfig): """Configuration for OpenVPN using TCP.""" PROTOCOL = "tcp" class OpenVPNUDPConfig(OVPNConfig): """Configuration for OpenVPN using UDP.""" PROTOCOL = "udp" class WireguardConfig(VPNConfiguration): """Wireguard-specific configuration.""" PROTOCOL = "wireguard" EXTENSION = ".conf" def generate(self) -> str: """Method that generates a wireguard vpn configuration. """ if not self.use_certificate: raise RuntimeError("Wireguards expects certificate configuration") j2_values = { "wg_client_secret_key": self._vpncredentials.pubkey_credentials.wg_private_key, "wg_ip": self._vpnserver.server_ip, "wg_port": self._vpnserver.wireguard_ports.udp[0], "wg_server_pk": self._vpnserver.x25519pk, } template =\ (Environment(loader=BaseLoader, autoescape=True) # noqa: E501 # pylint: disable=line-too-long # nosemgrep: python.flask.security.xss.audit.direct-use-of-jinja2.direct-use-of-jinja2 .from_string(WIREGUARD_TEMPLATE)) return template.render(j2_values) python-proton-vpn-api-core-0.39.0/proton/vpn/connection/vpnconnection.py000066400000000000000000000330311473026673700265000ustar00rootroot00000000000000""" VPN connection interface. Copyright (c) 2023 Proton AG This file is part of Proton VPN. Proton VPN 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. Proton VPN 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 ProtonVPN. If not, see . """ from __future__ import annotations import asyncio import sys from abc import ABC, abstractmethod from typing import Callable, List from proton.loader import Loader from proton.vpn.connection.events import Event, EventContext from proton.vpn.connection.interfaces import VPNServer, Settings, VPNCredentials from proton.vpn.connection.persistence import ConnectionPersistence, ConnectionParameters from proton.vpn.connection.publisher import Publisher from proton.vpn.connection import states, events # pylint: disable=too-many-instance-attributes class VPNConnection(ABC): """ Defines the interface to create a new VPN connection. It's the base class for any VPN connection implementation. """ # Class attrs to be set by subclasses. backend = None protocol = None # pylint: disable=too-many-arguments def __init__( self, server: VPNServer, credentials: VPNCredentials, settings: Settings, connection_id: str = None, connection_persistence: ConnectionPersistence = None, publisher: Publisher = None, use_certificate: bool = False, ): """Initialize a VPNConnection object. :param server: VPN server to connect to. :param credentials: credentials used to authenticate to the VPN server. :param settings: Settings to be used when establishing the VPN connection. This parameter is optional. When it's not specified the default settings will be used instead. :param connection_id: unique ID of the existing connection. This parameter is optional. It should be specified only if this instance maps to an already existing network connection. :param connection_persistence: Connection persistence implementation. This parameter is optional. When not specified, the default connection persistence implementation will be used instead. :param publisher: Publisher implementation. This parameter is optional. Pass it only if you know what you are doing. :param use_certificate: whether to use a certificate for authentication, as opposed to username and password. """ self._vpnserver = server self._vpncredentials = credentials self._settings = settings self._connection_persistence = connection_persistence or ConnectionPersistence() self._publisher = publisher or Publisher() self._use_certificate = use_certificate if connection_id: self._unique_id = connection_id self.initial_state = self._initialize_persisted_connection( connection_id ) else: self._unique_id = None self.initial_state = states.Disconnected( states.StateContext( event=events.Initialized(EventContext(connection=self)), connection=self ) ) @abstractmethod def _initialize_persisted_connection(self, connection_id: str) -> states.State: """ Initializes the state of this instance of VPN connection according to previously persisted connection parameters and returns its current state. Needs to be provided by the VPN connection implementation. """ @abstractmethod async def start(self): """ Starts the VPN connection. This method returns as soon as the connection has been started, but it doesn't wait for the connection to be fully established. """ @abstractmethod async def stop(self): """Stops the VPN connection.""" @property def are_feature_updates_applied_when_active(self) -> bool: """ Returns whether the connection features updates are applied on the fly while the connection is already active, without restarting the connection. """ return False async def update_credentials(self, credentials: VPNCredentials): """ Updates the connection credentials. """ self._vpncredentials = credentials # Note that VPN connection implementations can extend this method to send # the new credentials to the back-end. That's why this method is left async. async def update_settings(self, settings: Settings): """ Updates the connection settings. """ self._settings = settings # Note that VPN connection implementations can extend this method to send # the new settings to the back-end. That's why this method is left async. def register(self, subscriber: Callable[[Event], None]): """ Registers a subscriber to be notified whenever a new connection event happens. The subscriber will be called passing the connection event as argument. """ self._publisher.register(subscriber) def unregister(self, subscriber: Callable[[Event], None]): """Unregister a previously registered connection events subscriber.""" self._publisher.unregister(subscriber) def _notify_subscribers(self, event: Event): """Notifies all subscribers of a connection event. Subscribers are called passing the connection event as argument. This is a utility method that VPN connection implementations can use to notify subscribers when a new connection event happens. :param event: the event to be notified to subscribers. """ self._publisher.notify(event=event) @staticmethod def create(server: VPNServer, credentials: VPNCredentials, settings: Settings = None, protocol: str = None, backend: str = None, use_certificate: bool = False): """ Creates a new VPN connection object. Note the VPN connection won't be initiated. For that to happen, see the `start` method. :param server: VPN server to connect to. :param credentials: Credentials used to authenticate to the VPN server. :param settings: VPN settings used to create the connection. :param protocol: protocol to connect with. If None, the default protocol will be used. :param backend: Name of the class implementing the VPNConnection interface. If None, the default implementation will be used. :param use_certificate: whether to use a certificate for authentication, as opposed to username and password. """ backend = Loader.get("backend", class_name=backend) protocol = protocol.lower() if protocol else None protocol_class = backend.factory(protocol) return protocol_class(server, credentials, settings, use_certificate=use_certificate) @property def server(self) -> VPNServer: """Returns the VPN server of this VPN connection.""" return self._vpnserver @property def server_id(self) -> str: """Returns the VPN server ID of this VPN connection.""" return self._vpnserver.server_id @property def server_name(self) -> str: """Returns the VPN server name of this VPN connection.""" return self._vpnserver.server_name @property def server_ip(self) -> str: """Returns the VPN server IP of this VPN connection.""" return self._vpnserver.server_ip @property def server_domain(self) -> str: """Returns the VPN server domain of this VPN connection.""" return self._vpnserver.domain @property def settings(self) -> Settings: """ Current settings of the connection : Some settings can be changed on the fly and are RW : netshield level, kill switch enabled/disabled, split tunneling, VPN accelerator, custom DNS. Other settings are RO and cannot be changed once the connection is instantiated: VPN protocol. """ return self._settings @classmethod @abstractmethod def _get_priority(cls) -> int: """ Priority of the VPN connection implementation. To be implemented by subclasses. When no backend is specified when creating a VPN connection instance with `VPNConnection.create`, the VPN connection implementation is chosen based on the priority value returned by this method. The lower the value, the more priority it has. Ideally, the returned priority value should not be hardcoded but calculated based on the environment. For example, a VPN connection implementation using NetworkManager could return a high priority when the NetworkManager service is running or a low priority when it's not. """ @classmethod @abstractmethod def _validate(cls) -> bool: """ Determines whether the VPN connection implementation is valid or not. To be implemented by subclasses. If this method returns `False` then the VPN connection implementation will be skipped when creating a VPN connection instance with `VPNConnection.create`. :return: `True` if the implementation is valid or `False` otherwise. """ async def add_persistence(self): """ Stores the connection parameters to disk. The connection parameters (e.g. backend, protocol, connection ID, server name) are stored to disk so that they can be loaded again after an unexpected crash. """ params = ConnectionParameters( connection_id=self._unique_id, backend=type(self).backend, protocol=type(self).protocol, server=self.server ) loop = asyncio.get_running_loop() await loop.run_in_executor(None, self._connection_persistence.save, params) async def remove_persistence(self): """ Works in the opposite way of add_persistence. It removes the persistence file. This is used in conjunction with down, since if the connection is turned down, we don't want to keep any persistence files. """ loop = asyncio.get_running_loop() await loop.run_in_executor(None, self._connection_persistence.remove) def _get_user_pass(self, apply_feature_flags=False): """*For developers* :param apply_feature_flags: if feature flags are to be suffixed to username In case of non-certificate based authentication, username and password need to be provided for authentication. In such cases, the username can be optionally suffixed with different options, of which are fetched from `self._settings` Usage: .. code-block:: from proton.vpn.connection import VPNConnection class CustomBackend(VPNConnection): backend = "custom_backend" ... def _setup(self): if not use_ceritificate: # In this case, the username will have suffixes added given # that any of the them are set in `self._settings` user, pass = self._get_user_pass() # Then add the username and password to the configurations """ user_data = self._vpncredentials.userpass_credentials username = user_data.username if apply_feature_flags: flags = self._get_feature_flags() username = "+".join([username] + flags) # each flag must be preceded by "+" return username, user_data.password def _get_feature_flags(self) -> List[str]: """ Creates a list of feature flags that are fetched from `self._settings`. These feature flags are used to suffix them to a username, to trigger server-side specific behavior. """ list_flags = [] label = self._vpnserver.label if sys.platform.startswith("linux"): list_flags.append("pl") elif sys.platform.startswith("win32") or sys.platform.startswith("cygwin"): list_flags.append("pw") elif sys.platform.startswith("darwin"): list_flags.append("pm") # This is used to ensure that the provided IP matches the one # from the exit IP. if label: list_flags.append(f"b:{label}") if self._settings is None: return list_flags enable_ipv6_support = self._vpnserver.has_ipv6_support and self._settings.ipv6 if enable_ipv6_support: list_flags.append("6") features = self._settings.features # We only need to add feature flags if there are any if features: list_flags.append(f"f{features.netshield}") if not features.vpn_accelerator: list_flags.append("nst") if features.port_forwarding: list_flags.append("pmp") if features.moderate_nat: list_flags.append("nr") return list_flags python-proton-vpn-api-core-0.39.0/proton/vpn/core/000077500000000000000000000000001473026673700220345ustar00rootroot00000000000000python-proton-vpn-api-core-0.39.0/proton/vpn/core/__init__.py000066400000000000000000000015541473026673700241520ustar00rootroot00000000000000"""Proton VPN Core API Copyright (c) 2023 Proton AG This file is part of Proton VPN. Proton VPN 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. Proton VPN 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 ProtonVPN. If not, see . """ from importlib.metadata import version, PackageNotFoundError try: __version__ = version("proton-vpn-api-core") except PackageNotFoundError: __version__ = "development" python-proton-vpn-api-core-0.39.0/proton/vpn/core/api.py000066400000000000000000000164231473026673700231650ustar00rootroot00000000000000""" Proton VPN API. Copyright (c) 2023 Proton AG This file is part of Proton VPN. Proton VPN 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. Proton VPN 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 ProtonVPN. If not, see . """ import asyncio import copy from proton.vpn.core.connection import VPNConnector from proton.vpn.core.refresher.scheduler import Scheduler from proton.vpn.core.refresher.vpn_data_refresher import VPNDataRefresher from proton.vpn.core.settings import Settings, SettingsPersistence from proton.vpn.core.session_holder import SessionHolder, ClientTypeMetadata from proton.vpn.session.dataclasses import LoginResult, BugReportForm from proton.vpn.session.account import VPNAccount from proton.vpn.session import FeatureFlags from proton.vpn.core.usage import UsageReporting class ProtonVPNAPI: # pylint: disable=too-many-public-methods """Class exposing the Proton VPN facade.""" def __init__(self, client_type_metadata: ClientTypeMetadata): self._session_holder = SessionHolder( client_type_metadata=client_type_metadata ) self._settings_persistence = SettingsPersistence() self._vpn_connector = None self._usage_reporting = UsageReporting( client_type_metadata=client_type_metadata) self.refresher = VPNDataRefresher( self._session_holder, Scheduler() ) async def get_vpn_connector(self) -> VPNConnector: """Returns an object that wraps around the raw VPN connection object. This will provide some additional helper methods related to VPN connections and VPN servers. """ if self._vpn_connector: return self._vpn_connector self._vpn_connector = await VPNConnector.get( session_holder=self._session_holder, settings_persistence=self._settings_persistence, usage_reporting=self._usage_reporting, ) self._vpn_connector.subscribe_to_certificate_updates(self.refresher) return self._vpn_connector async def load_settings(self) -> Settings: """ Returns a copy of the settings saved to disk, or the defaults if they are not found. Be sure to call save_settings if you want to apply changes. """ # Default to free user settings if the session is not loaded yet. # pylint: disable=duplicate-code user_tier = self._session_holder.user_tier or 0 loop = asyncio.get_running_loop() settings = await loop.run_in_executor( None, self._settings_persistence.get, user_tier, self.feature_flags ) self._usage_reporting.enabled = settings.anonymous_crash_reports # We have to return a copy of the settings to force the caller to # use the `save_settings` method to apply the changes. return copy.deepcopy(settings) async def save_settings(self, settings: Settings): """ Saves the settings to disk. Certain actions might be triggered by the VPN connector. For example, the kill switch might also be enabled/disabled depending on the setting value. """ loop = asyncio.get_running_loop() await loop.run_in_executor(None, self._settings_persistence.save, settings) await self._vpn_connector.apply_settings(settings) self._usage_reporting.enabled = settings.anonymous_crash_reports async def login(self, username: str, password: str) -> LoginResult: """ Logs the user in provided the right credentials. :param username: Proton account username. :param password: Proton account password. :return: The login result. """ session = self._session_holder.get_session_for(username) result = await session.login(username, password) if result.success and not session.loaded: await session.fetch_session_data() return result async def submit_2fa_code(self, code: str) -> LoginResult: """ Submits the 2-factor authentication code. :param code: 2FA code. :return: The login result. """ session = self._session_holder.session result = await session.provide_2fa(code) if result.success and not session.loaded: await session.fetch_session_data() return result def is_user_logged_in(self) -> bool: """Returns True if a user is logged in and False otherwise.""" return self._session_holder.session.logged_in @property def account_name(self) -> str: """Returns account name.""" return self._session_holder.session.AccountName @property def account_data(self) -> VPNAccount: """ Returns account data, which contains information such as (but not limited to): - Plan name/title - Max tier - Max connections - VPN Credentials - Location """ return self._session_holder.session.vpn_account @property def user_tier(self) -> int: """ Returns the Proton VPN tier. Current possible values are: * 0: Free * 2: Plus * 3: Proton employee Note: tier 1 is no longer in use. """ return self.account_data.max_tier @property def vpn_session_loaded(self) -> bool: """Returns whether the VPN session data was already loaded or not.""" return self._session_holder.session.loaded @property def server_list(self): """The last server list fetched from the REST API.""" return self._session_holder.session.server_list @property def client_config(self): """The last client configuration fetched from the REST API.""" return self._session_holder.session.client_config @property def feature_flags(self) -> FeatureFlags: """The last feature flags fetched from the REST API.""" return self._session_holder.session.feature_flags async def submit_bug_report(self, bug_report: BugReportForm): """ Submits the specified bug report to customer support. """ return await self._session_holder.session.submit_bug_report(bug_report) async def logout(self): """ Logs the current user out. :raises: VPNConnectionFoundAtLogout if the users is still connected to the VPN. """ await self.refresher.disable() await self._session_holder.session.logout() loop = asyncio.get_running_loop() await loop.run_in_executor(executor=None, func=self._settings_persistence.delete) vpn_connector = await self.get_vpn_connector() await vpn_connector.disconnect() @property def usage_reporting(self) -> UsageReporting: """Returns the usage reporting instance to send anonymous crash reports.""" return self._usage_reporting python-proton-vpn-api-core-0.39.0/proton/vpn/core/cache_handler.py000066400000000000000000000040741473026673700251530ustar00rootroot00000000000000""" Cache Handler module. Copyright (c) 2023 Proton AG This file is part of Proton VPN. Proton VPN 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. Proton VPN 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 ProtonVPN. If not, see . """ import json import os from pathlib import Path from proton.vpn import logging logger = logging.getLogger(__name__) class CacheHandler: """Used to save, load, and remove cache files.""" def __init__(self, filepath: str): self._fp = Path(filepath) @property def exists(self): """True if the cache file exists and False otherwise.""" return self._fp.is_file() def save(self, newdata: dict): """Save data to cache file.""" self._fp.parent.mkdir(parents=True, exist_ok=True) with open(self._fp, "w", encoding="utf-8") as f: # pylint: disable=C0103 json.dump(newdata, f, indent=4) # pylint: disable=C0103 def load(self): """Load data from cache file, if it exists.""" if not self.exists: return None try: with open(self._fp, "r", encoding="utf-8") as f: # pylint: disable=C0103 return json.load(f) # pylint: disable=C0103 except (json.decoder.JSONDecodeError, UnicodeDecodeError): filename = os.path.basename(self._fp) logger.warning( msg=f"Unable to decode JSON file \"{filename}\"", category="cache", event="load", exc_info=True ) return None def remove(self): """ Remove cache from disk.""" if self.exists: os.remove(self._fp) python-proton-vpn-api-core-0.39.0/proton/vpn/core/connection.py000066400000000000000000000527371473026673700245630ustar00rootroot00000000000000""" VPN connector. Copyright (c) 2023 Proton AG This file is part of Proton VPN. Proton VPN 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. Proton VPN 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 ProtonVPN. If not, see . """ from __future__ import annotations import asyncio import copy import threading from typing import Optional, runtime_checkable, Protocol from proton.loader import Loader from proton.loader.loader import PluggableComponent from proton.vpn.connection.persistence import ConnectionPersistence from proton.vpn.core.refresher import VPNDataRefresher from proton.vpn.core.session_holder import SessionHolder from proton.vpn.core.settings import SettingsPersistence from proton.vpn.killswitch.interface import KillSwitch from proton.vpn import logging from proton.vpn.connection import ( events, states, VPNConnection, VPNServer, ProtocolPorts, VPNCredentials, Settings ) from proton.vpn.connection.enum import KillSwitchSetting, ConnectionStateEnum from proton.vpn.connection.publisher import Publisher from proton.vpn.connection.states import StateContext from proton.vpn.session.client_config import ClientConfig from proton.vpn.session.dataclasses import VPNLocation from proton.vpn.session.servers import LogicalServer, ServerFeatureEnum from proton.vpn.core.usage import UsageReporting from proton.vpn.connection.exceptions import FeatureSyntaxError, FeatureError logger = logging.getLogger(__name__) @runtime_checkable class VPNStateSubscriber(Protocol): # pylint: disable=too-few-public-methods """Subscriber to connection status updates.""" def status_update(self, status: "BaseState"): # noqa """This method is called by the publisher whenever a VPN connection status update occurs. :param status: new connection status. """ class VPNConnector: # pylint: disable=too-many-instance-attributes """ Allows connecting/disconnecting to/from Proton VPN servers, as well as querying information about the current VPN connection, or subscribing to its state updates. Multiple simultaneous VPN connections are not allowed. If a connection already exists when a new one is requested then the current one is brought down before starting the new one. """ @classmethod async def get( # pylint: disable=too-many-arguments cls, session_holder: SessionHolder, settings_persistence: SettingsPersistence, usage_reporting: UsageReporting, kill_switch: KillSwitch = None, ): """ Builds a VPN connector instance and initializes it. """ connector = VPNConnector( session_holder, settings_persistence, kill_switch=kill_switch, usage_reporting=usage_reporting, ) await connector.initialize_state() return connector def __init__( # pylint: disable=too-many-arguments self, session_holder: SessionHolder, settings_persistence: SettingsPersistence, usage_reporting: UsageReporting, connection_persistence: ConnectionPersistence = None, state: states.State = None, kill_switch: KillSwitch = None, publisher: Publisher = None, ): self._session_holder = session_holder self._settings_persistence = settings_persistence self._connection_persistence = connection_persistence or ConnectionPersistence() self._current_state = state self._kill_switch = kill_switch self._publisher = publisher or Publisher() self._lock = asyncio.Lock() self._background_tasks = set() self._usage_reporting = usage_reporting self._publisher.register(self._on_state_change) def _filter_features(self, input_settings: Settings, user_tier: int = None) -> Settings: if not user_tier: user_tier = self._session_holder.user_tier or 0 settings = copy.deepcopy(input_settings) if self._is_free_tier(user_tier): # Our servers do not allow setting connection features on the free # tier, not even the defaults. settings.features = None return settings async def get_settings(self) -> Settings: """Returns the user's settings.""" # Default to free user settings if the session is not loaded yet. user_tier = self._session_holder.user_tier or 0 loop = asyncio.get_running_loop() settings = await loop.run_in_executor( None, self._settings_persistence.get, user_tier, self._session_holder.session.feature_flags ) return self._filter_features(settings, user_tier) @property def credentials(self) -> Optional[VPNCredentials]: """Returns the user's credentials.""" return self._session_holder.vpn_credentials def _set_ks_setting(self, settings: Settings): StateContext.kill_switch_setting = KillSwitchSetting(settings.killswitch) if isinstance(self.current_state, states.Disconnected): self._set_ks_impl(settings) async def update_credentials(self): """ Updates the credentials of the current connection. This is useful when the certificate used for the current connection has expired and a new one is needed. """ if self.current_connection: logger.info("Updating credentials for current connection.") await self.current_connection.update_credentials(self.credentials) async def apply_settings(self, settings: Settings): """ Sets the settings to be applied when establishing the next connection and applies them to the current connection whenever that's possible. """ self._set_ks_setting(settings) await self._apply_kill_switch_setting(KillSwitchSetting(settings.killswitch)) if self.current_connection: await self.current_connection.update_settings( self._filter_features(settings) ) async def _apply_kill_switch_setting(self, kill_switch_setting: KillSwitchSetting): """Enables/disables the kill switch depending on the setting value.""" kill_switch = self._current_state.context.kill_switch if kill_switch_setting == KillSwitchSetting.PERMANENT: await kill_switch.enable(permanent=True) # Since full KS already prevents IPv6 leaks: await kill_switch.disable_ipv6_leak_protection() elif kill_switch_setting == KillSwitchSetting.ON: if isinstance(self._current_state, states.Disconnected): await kill_switch.disable() await kill_switch.disable_ipv6_leak_protection() else: await kill_switch.enable(permanent=False) # Since full KS already prevents IPv6 leaks: await kill_switch.disable_ipv6_leak_protection() elif kill_switch_setting == KillSwitchSetting.OFF: if isinstance(self._current_state, states.Disconnected): await kill_switch.disable() await kill_switch.disable_ipv6_leak_protection() else: await kill_switch.enable_ipv6_leak_protection() await kill_switch.disable() else: raise RuntimeError(f"Unexpected kill switch setting: {kill_switch_setting}") async def _get_current_connection(self) -> Optional[VPNConnection]: """ :return: the current VPN connection or None if there isn't one. """ loop = asyncio.get_running_loop() persisted_parameters = await loop.run_in_executor(None, self._connection_persistence.load) if not persisted_parameters: return None # I'm refraining of refactoring the whole thing but this way of loading # the protocol class is madness. backend_class = Loader.get("backend", persisted_parameters.backend) backend_name = backend_class.backend if persisted_parameters.backend != backend_name: return None all_protocols = Loader.get_all(backend_name) settings = await self.get_settings() for protocol in all_protocols: if protocol.cls.protocol == persisted_parameters.protocol: vpn_connection = protocol.cls( server=persisted_parameters.server, credentials=self.credentials, settings=settings, connection_id=persisted_parameters.connection_id ) if not isinstance(vpn_connection.initial_state, states.Disconnected): return vpn_connection return None async def _get_initial_state(self): """Determines the initial state of the state machine.""" current_connection = await self._get_current_connection() if current_connection: return current_connection.initial_state return states.Disconnected( StateContext(event=events.Initialized(events.EventContext(connection=None))) ) async def initialize_state(self): """Initializes the state machine with the specified state.""" state = await self._get_initial_state() settings = await self.get_settings() StateContext.kill_switch_setting = KillSwitchSetting(settings.killswitch) self._set_ks_impl(settings) connection = state.context.connection if connection: connection.register(self._on_connection_event) # Sets the initial state of the connector and triggers the tasks associated # to the state. await self._update_state(state) # Makes sure that the kill switch state is inline with the current # kill switch setting (e.g. if the KS setting is set to "permanent" then # the permanent KS should be enabled, if it was not the case yet). await self._apply_kill_switch_setting(StateContext.kill_switch_setting) @property def current_state(self) -> states.State: """Returns the state of the current VPN connection.""" return self._current_state @property def current_connection(self) -> Optional[VPNConnection]: """Returns the current VPN connection or None if there isn't one.""" return self.current_state.context.connection if self.current_state else None @property def current_server_id(self) -> Optional[str]: """ Returns the server ID of the current VPN connection. Note that by if the current state is disconnected, `None` will be returned if a VPN connection was never established. Otherwise, the server ID of the last server the connection was established to will be returned instead. """ return self.current_connection.server_id if self.current_connection else None @property def is_connection_active(self) -> bool: """Returns whether there is currently a VPN connection ongoing or not.""" return not isinstance(self._current_state, (states.Disconnected, states.Error)) @property def is_connected(self) -> bool: """Returns whether the user is connected to a VPN server or not.""" return isinstance(self.current_state, states.Connected) @staticmethod def get_vpn_server( logical_server: LogicalServer, client_config: ClientConfig ) -> VPNServer: """ :return: a :class:`proton.vpn.vpnconnection.interfaces.VPNServer` that can be used to establish a VPN connection with :class:`proton.vpn.vpnconnection.VPNConnection`. """ physical_server = logical_server.get_random_physical_server() has_ipv6_support = ServerFeatureEnum.IPV6 in logical_server.features return VPNServer( server_ip=physical_server.entry_ip, domain=physical_server.domain, x25519pk=physical_server.x25519_pk, openvpn_ports=ProtocolPorts( udp=client_config.openvpn_ports.udp, tcp=client_config.openvpn_ports.tcp ), wireguard_ports=ProtocolPorts( udp=client_config.wireguard_ports.udp, tcp=client_config.wireguard_ports.tcp ), server_id=logical_server.id, server_name=logical_server.name, has_ipv6_support=has_ipv6_support, label=physical_server.label ) def get_available_protocols_for_backend( self, backend_name: str ) -> Optional[PluggableComponent]: """Returns available protocols for the `backend_name` raises RuntimeError: if no backends could be found.""" backend_class = Loader.get("backend", class_name=backend_name) supported_protocols = Loader.get_all(backend_class.backend) return supported_protocols # pylint: disable=too-many-arguments async def connect( self, server: VPNServer, protocol: str = None, backend: str = None ): """Connects to a VPN server.""" if not self._session_holder.session.logged_in: raise RuntimeError("Log in required before starting VPN connections.") logger.info( f"{server} / Protocol: {protocol} / Backend: {backend}", category="CONN", subcategory="CONNECT", event="START" ) # Sets the settings to be applied when establishing the next connection. settings = await self.get_settings() self._set_ks_setting(settings) protocol = protocol or settings.protocol # If IPv6 FF is disabled then the feature should not be toggled client side and # should be disabled. if not self._can_ipv6_be_toggled_client_side(settings): settings.ipv6 = False feature_flags = self._session_holder.session.feature_flags use_certificate = feature_flags.get("CertificateBasedOpenVPN") logger.info("Using certificate based authentication" f" for openvpn: {use_certificate}") connection = VPNConnection.create( server, self.credentials, settings, protocol, backend, use_certificate=use_certificate ) connection.register(self._on_connection_event) await self._on_connection_event( events.Up(events.EventContext(connection=connection)) ) async def disconnect(self): """Disconnects the current VPN connection, if any.""" await self._on_connection_event( events.Down(events.EventContext(connection=self.current_connection)) ) def register(self, subscriber: VPNStateSubscriber): """ Registers a new subscriber to connection status updates. The subscriber should have a ```status_update``` method, which will be called passing it the new connection status whenever it changes. :param subscriber: Subscriber to register. """ if not isinstance(subscriber, VPNStateSubscriber): raise ValueError( "The specified subscriber does not implement the " f"{VPNStateSubscriber.__name__} protocol." ) self._publisher.register(subscriber.status_update) def unregister(self, subscriber: VPNStateSubscriber): """ Unregister a subscriber from connection status updates. :param subscriber: Subscriber to unregister. """ if not isinstance(subscriber, VPNStateSubscriber): raise ValueError( "The specified subscriber does not implement the " f"{VPNStateSubscriber.__name__} protocol." ) self._publisher.unregister(subscriber.status_update) async def _handle_on_event(self, event: events.Event): """ Handles the event by updating the current state of the connection, and returning a new event to be processed if any. """ try: new_state = self.current_state.on_event(event) except FeatureSyntaxError as excp: self._usage_reporting.report_error(excp) logger.exception(msg=excp.message) except FeatureError as excp: logger.warning(msg=excp.message) except Exception as excp: self._usage_reporting.report_error(excp) raise excp else: return await self._update_state(new_state) return None async def _on_connection_event(self, event: events.Event): """ Callback called when a connection event happens. """ # The following lock guaranties that each new event is processed only # when the previous event was fully processed. async with self._lock: triggered_events = 0 while event: triggered_events += 1 if triggered_events > 99: raise RuntimeError("Maximum number of chained connection events was reached.") event = await self._handle_on_event(event) async def _update_state(self, new_state) -> Optional[events.Event]: if new_state is self.current_state: return None old_state = self._current_state self._current_state = new_state logger.info( f"{type(self._current_state).__name__}" f"{' (initial state)' if not old_state else ''}", category="CONN", event="STATE_CHANGED" ) if isinstance(self._current_state, states.Disconnected) \ and self._current_state.context.connection: # Unregister from connection event updates once the connection ended. self._current_state.context.connection.unregister(self._on_connection_event) new_event = await self._current_state.run_tasks() self._publisher.notify(new_state) if ( not self._current_state.context.reconnection and isinstance(self._current_state, states.Disconnected) ): self._set_ks_impl(await self.get_settings()) return new_event def _on_state_change(self, state: states.State): """Updates the user location when the connection is established.""" if not isinstance(state, states.Connected): return connection_details = state.context.event.context.connection_details if not connection_details or not connection_details.device_ip: return current_location = self._session_holder.session.vpn_account.location vpnlocation = VPNLocation( IP=connection_details.device_ip, Country=connection_details.device_country, ISP=current_location.ISP ) self._session_holder.session.set_location(vpnlocation) def _set_ks_impl(self, settings: Settings): """ By using this specific method we're leaking implementation details. Because we currently have to deal with two kill switch NetworkManager implementations, one for OpenVPN and one for WireGuard, and them not being compatible with each other, we need to ensure that when switching protocols, we only do this when we are in `Disconnected` state, to ensure that the environment is clean and we don't leave any residuals on a users machine. """ protocol = settings.protocol kill_switch_backend = KillSwitch.get(protocol=protocol) StateContext.kill_switch = self._kill_switch or kill_switch_backend() def _is_free_tier(self, user_tier: int) -> bool: return user_tier == 0 def _can_ipv6_be_toggled_client_side(self, settings: Settings) -> bool: return settings.ipv6 and\ self._session_holder.session.feature_flags.get("IPv6Support") def subscribe_to_certificate_updates(self, refresher: VPNDataRefresher): """Subscribes to certificate updates.""" refresher.set_certificate_updated_callback(self._on_certificate_updated) async def _on_certificate_updated(self): """Actions to be taken when once the certificate is updated.""" if isinstance(self.current_state, (states.Connected, states.Error)): await self.update_credentials() class Subscriber: """ Connection subscriber implementation that allows blocking until a certain state is reached. """ def __init__(self): self.state: ConnectionStateEnum = None self.events = {state: threading.Event() for state in ConnectionStateEnum} def status_update(self, state): """ This method will be called whenever a VPN connection state update occurs. :param state: new state. """ self.state = state.type self.events[self.state].set() self.events[self.state].clear() def wait_for_state(self, state: ConnectionStateEnum, timeout: int = None): """ Blocks until the specified VPN connection state is reached. :param state: target connection state. :param timeout: if specified, a TimeoutError will be raised when the target state is reached. """ state_reached = self.events[state].wait(timeout) if not state_reached: raise TimeoutError(f"Time out occurred before reaching state {state.name}.") python-proton-vpn-api-core-0.39.0/proton/vpn/core/exceptions.py000066400000000000000000000016721473026673700245750ustar00rootroot00000000000000""" List of exceptions raised in this package. Copyright (c) 2023 Proton AG This file is part of Proton VPN. Proton VPN 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. Proton VPN 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 ProtonVPN. If not, see . """ from proton.session.exceptions import ProtonError class ProtonVPNError(ProtonError): """Base exception for Proton VPN errors.""" class ServerNotFound(ProtonVPNError): """A VPN server was expected but was not found.""" python-proton-vpn-api-core-0.39.0/proton/vpn/core/refresher/000077500000000000000000000000001473026673700240215ustar00rootroot00000000000000python-proton-vpn-api-core-0.39.0/proton/vpn/core/refresher/__init__.py000066400000000000000000000014201473026673700261270ustar00rootroot00000000000000""" Copyright (c) 2023 Proton AG This file is part of Proton VPN. Proton VPN 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. Proton VPN 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 ProtonVPN. If not, see . """ from proton.vpn.core.refresher.vpn_data_refresher import VPNDataRefresher __all__ = ["VPNDataRefresher"] python-proton-vpn-api-core-0.39.0/proton/vpn/core/refresher/certificate_refresher.py000066400000000000000000000110341473026673700307210ustar00rootroot00000000000000""" Copyright (c) 2023 Proton AG This file is part of Proton VPN. Proton VPN 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. Proton VPN 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 ProtonVPN. If not, see . """ import inspect from typing import Optional, Callable from datetime import timedelta import random from proton.vpn import logging from proton.vpn.core.refresher.scheduler import RunAgain from proton.vpn.core.session_holder import SessionHolder from proton.vpn.session.credentials import VPNPubkeyCredentials from proton.session.exceptions import ( ProtonAPINotReachable, ProtonAPINotAvailable, ) logger = logging.getLogger(__name__) # pylint: disable=R0801 class CertificateRefresher: """ Service in charge of refreshing certificate, that is used to derive users private keys, to establish VPN connections. """ def __init__(self, session_holder: SessionHolder): self._session_holder = session_holder self._number_of_failed_refresh_attempts = 0 self.certificate_updated_callback: Optional[Callable] = None @property def _session(self): return self._session_holder.session @property def initial_refresh_delay(self): """Returns the initial delay before the first refresh.""" return self._session.vpn_account \ .vpn_credentials \ .pubkey_credentials \ .remaining_time_to_next_refresh async def refresh(self) -> RunAgain: """Fetches the new certificate from the REST API.""" try: certificate = await self._session.fetch_certificate() next_refresh_delay = certificate.remaining_time_to_next_refresh self._number_of_failed_refresh_attempts = 0 await self._notify() except (ProtonAPINotReachable, ProtonAPINotAvailable) as error: logger.warning(f"Certificate refresh failed: {error}") next_refresh_delay = self._get_next_refresh_delay() self._number_of_failed_refresh_attempts += 1 except Exception: logger.error( # noqa: E501 # pylint: disable=line-too-long # nosemgrep: python.lang.best-practice.logging-error-without-handling.logging-error-without-handling "Certificate refresh failed unexpectedly." "Stopping certificate refresh." ) raise logger_prefix = "Next" if self._number_of_failed_refresh_attempts: logger_prefix = f"Attempt {self._number_of_failed_refresh_attempts} for" logger.info( f"{logger_prefix} certificate refresh scheduled in " f"{timedelta(seconds=next_refresh_delay)}" ) return RunAgain.after_seconds(next_refresh_delay) def _get_next_refresh_delay(self): return min( generate_backoff_value(self._number_of_failed_refresh_attempts), VPNPubkeyCredentials.get_refresh_interval_in_seconds() ) async def _notify(self): if self.certificate_updated_callback is None: return if inspect.iscoroutinefunction(self.certificate_updated_callback): await self.certificate_updated_callback() # pylint: disable=not-callable else: raise ValueError( "Expected coroutine function but found " f"{type(self.certificate_updated_callback)}" ) def generate_backoff_value( number_of_failed_refresh_attempts: int, backoff_in_seconds: int = 1, random_component: float = None ) -> int: """Generate and return a backoff value for when API calls fail, so it can retry again without DDoS'ing the API.""" random_component = random_component or _generate_random_component() return backoff_in_seconds * 2 ** number_of_failed_refresh_attempts * random_component def _generate_random_component() -> int: """Generates random component between 1 - randones_percentage and 1 + randomness_percentage.""" return 1 + VPNPubkeyCredentials.REFRESH_RANDOMNESS *\ (2 * random.random() - 1) # nosec B311 # noqa: E501 # pylint: disable=line-too-long # nosemgrep: gitlab.bandit.B311 python-proton-vpn-api-core-0.39.0/proton/vpn/core/refresher/client_config_refresher.py000066400000000000000000000051121473026673700312420ustar00rootroot00000000000000""" Copyright (c) 2023 Proton AG This file is part of Proton VPN. Proton VPN 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. Proton VPN 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 ProtonVPN. If not, see . """ from datetime import timedelta from proton.vpn.core.refresher.scheduler import RunAgain from proton.vpn.core.session_holder import SessionHolder from proton.vpn.session.client_config import ClientConfig from proton.vpn import logging from proton.session.exceptions import ( ProtonAPINotReachable, ProtonAPINotAvailable, ) logger = logging.getLogger(__name__) # pylint: disable=R0801 class ClientConfigRefresher: """ Service in charge of refreshing VPN client configuration data. """ def __init__(self, session_holder: SessionHolder): super().__init__() self._session_holder = session_holder @property def _session(self): return self._session_holder.session @property def initial_refresh_delay(self): """Returns the initial delay before the first refresh.""" return self._session.client_config.seconds_until_expiration async def refresh(self) -> RunAgain: """Fetches the new client configuration from the REST API.""" try: new_client_config = await self._session.fetch_client_config() next_refresh_delay = new_client_config.seconds_until_expiration except (ProtonAPINotReachable, ProtonAPINotAvailable) as error: logger.warning(f"Client config refresh failed: {error}") next_refresh_delay = ClientConfig.get_refresh_interval_in_seconds() except Exception: logger.error( # nosec B311 # noqa: E501 # pylint: disable=line-too-long # nosemgrep: python.lang.best-practice.logging-error-without-handling.logging-error-without-handling "Client config refresh failed unexpectedly. " "Stopping client config refresh." ) raise logger.info( f"Next client config refresh scheduled in " f"{timedelta(seconds=next_refresh_delay)}" ) return RunAgain.after_seconds(next_refresh_delay) python-proton-vpn-api-core-0.39.0/proton/vpn/core/refresher/feature_flags_refresher.py000066400000000000000000000047731473026673700312620ustar00rootroot00000000000000""" Copyright (c) 2023 Proton AG This file is part of Proton VPN. Proton VPN 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. Proton VPN 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 ProtonVPN. If not, see . """ from datetime import timedelta from proton.vpn.core.refresher.scheduler import RunAgain from proton.vpn.core.session_holder import SessionHolder from proton.vpn.session import FeatureFlags from proton.vpn import logging from proton.session.exceptions import ( ProtonAPINotReachable, ProtonAPINotAvailable, ) logger = logging.getLogger(__name__) # pylint: disable=R0801 class FeatureFlagsRefresher: """ Service in charge of refreshing VPN client configuration data. """ def __init__(self, session_holder: SessionHolder): self._session_holder = session_holder @property def _session(self): return self._session_holder.session @property def initial_refresh_delay(self): """Returns the initial delay before the first refresh.""" return self._session.feature_flags.seconds_until_expiration async def refresh(self) -> RunAgain: """Fetches the new features from the REST API.""" try: feature_flags = await self._session.fetch_feature_flags() next_refresh_delay = feature_flags.seconds_until_expiration except (ProtonAPINotReachable, ProtonAPINotAvailable) as error: logger.warning(f"Feature flag refresh failed: {error}") next_refresh_delay = FeatureFlags.get_refresh_interval_in_seconds() except Exception: logger.error( # noqa: E501 # pylint: disable=line-too-long # nosemgrep: python.lang.best-practice.logging-error-without-handling.logging-error-without-handling "Feature flag refresh failed unexpectedly." "Stopping feature flag refresh." ) raise logger.info( f"Next feature flag refresh scheduled in " f"{timedelta(seconds=next_refresh_delay)}" ) return RunAgain.after_seconds(next_refresh_delay) python-proton-vpn-api-core-0.39.0/proton/vpn/core/refresher/scheduler.py000066400000000000000000000204771473026673700263630ustar00rootroot00000000000000""" Copyright (c) 2024 Proton AG This file is part of Proton VPN. Proton VPN 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. Proton VPN 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 ProtonVPN. If not, see . """ import asyncio import inspect import time from asyncio import CancelledError from dataclasses import dataclass from typing import Optional, Coroutine, List, Callable @dataclass class RunAgain: """Object to be returned by a task to be run again after a certain amount of time.""" delay_in_ms: int @staticmethod def after_seconds(seconds: float): """Returns a RunAgain object to be run after a certain amount of seconds.""" return RunAgain(delay_in_ms=int(seconds * 1000)) @dataclass class TaskRecord: """Record with details of the task to be executed and when.""" id: int # pylint: disable=invalid-name timestamp: float async_function: Callable[[], Coroutine] background_task: Optional[asyncio.Task] = None class Scheduler: """ Task scheduler. The goal of this implementation is to improve the accuracy of the built-in scheduler when the system is suspended/resumed. The built-in scheduler does not take into account the time the system has been suspended after a task has been scheduled to run after a certain amount of time. In this case, the clock is paused and then resumed. The way this implementation workarounds this issue is by keeping a record of tasks to be executed and the timestamp at which they should be executed. Then it periodically checks the lists for any tasks that should be executed and runs them. """ def __init__(self, check_interval_in_ms: int = 10_000): self._check_interval_in_ms = check_interval_in_ms self._error_callback = None self._last_task_id: int = 0 self._task_list: List[TaskRecord] = [] self._scheduler_task: Optional[asyncio.Task] = None def set_error_callback(self, error_callback: Callable[[Exception], None] = None): """Sets the error callback to be called when an error occurs while executing a task.""" self._error_callback = error_callback def unset_error_callback(self): """Unsets the error callback.""" self._error_callback = None @property def task_list(self): """Returns the list of tasks currently scheduled.""" return self._task_list @property def is_started(self): """Returns whether the scheduler has been started or not.""" return self._scheduler_task is not None @property def number_of_remaining_tasks(self): """Returns the number of remaining tasks to be executed.""" return len([record for record in self._task_list if not record.background_task]) def get_tasks_ready_to_fire(self) -> List[TaskRecord]: """ Returns the tasks that are ready to fire, that is the tasks with a timestamp lower or equal than the current unix time.""" now = time.time() return list(filter( lambda record: record.timestamp <= now and not record.background_task, self._task_list )) def start(self): """Starts the scheduler.""" if self.is_started: # noqa: E501 # pylint: disable=line-too-long # nosemgrep: python.lang.maintainability.is-function-without-parentheses.is-function-without-parentheses raise RuntimeError("Scheduler was already started.") self._scheduler_task = asyncio.create_task(self._run_periodic_task_list_check()) async def stop(self): """Stops the scheduler and discards all remaining tasks.""" if self.is_started: # noqa: E501 # pylint: disable=line-too-long # nosemgrep: python.lang.maintainability.is-function-without-parentheses.is-function-without-parentheses self._scheduler_task.cancel() for record in self._task_list: if record.background_task: record.background_task.cancel() self._task_list = [] await self.wait_for_shutdown() self._scheduler_task = None async def wait_for_shutdown(self, timeout=1): """Waits for the scheduler to be stopped.""" if self.is_started: # noqa: E501 # pylint: disable=line-too-long # nosemgrep: python.lang.maintainability.is-function-without-parentheses.is-function-without-parentheses try: await asyncio.wait_for(self._scheduler_task, timeout) except CancelledError: pass def run_soon(self, async_function: Callable[[], Coroutine]) -> int: """ Runs the coroutine as soon as possible. :returns: the scheduled task id. """ return self.run_after(0, async_function) def run_after( self, delay_in_seconds: float, async_function: Callable[[], Coroutine] ) -> int: """ Runs the coroutine after a delay specified in seconds. :returns: the scheduled task id. """ return self.run_at(time.time() + delay_in_seconds, async_function) def run_at( self, timestamp: float, async_function: Callable[[], Coroutine] ) -> int: """ Runs the task at the specified timestamp. :returns: the scheduled task id. """ if not inspect.iscoroutinefunction(async_function): raise ValueError("A coroutine function was expected.") self._last_task_id += 1 record = TaskRecord( id=self._last_task_id, timestamp=timestamp, async_function=async_function ) self._task_list.append(record) return record.id def cancel_task(self, task_id): """Cancels a task to be executed given its task id.""" for task in self._task_list: # noqa: E501 # pylint: disable=line-too-long # nosemgrep: python.lang.correctness.list-modify-iterating.list-modify-while-iterate if task.id == task_id: if task.background_task: task.background_task.cancel() else: self._task_list.remove(task) break # noqa: E501 # pylint: disable=line-too-long # nosemgrep: python.lang.correctness.list-modify-iterating.list-modify-while-iterate async def _run_periodic_task_list_check(self): while True: self.run_tasks_ready_to_fire() await asyncio.sleep(self._check_interval_in_ms / 1000) def run_tasks_ready_to_fire(self): """ Runs the tasks ready to be executed, that is the tasks with a timestamp lower or equal than the current unix time, and removes them from the list. """ tasks_ready_to_fire = self.get_tasks_ready_to_fire() # Run the tasks that are ready to be run. for task_record in tasks_ready_to_fire: task = asyncio.create_task(task_record.async_function()) task_record.background_task = task task.add_done_callback(self._on_task_done) def _on_task_done(self, task: asyncio.Task): # Get the task record associated with the task. task_record = next(filter(lambda record: record.background_task == task, self._task_list)) result = None try: # Bubble up exceptions, if any. result = task.result() except CancelledError: # CancelledError is raised when the task is cancelled. pass except Exception as exc: # pylint: disable=broad-except self._task_list.remove(task_record) if not self._error_callback: raise exc self._error_callback(exc) return if isinstance(result, RunAgain): # if the task record is to be run again then it's rescheduled. task_record.timestamp = time.time() + result.delay_in_ms / 1000 task_record.background_task = None else: self._task_list.remove(task_record) python-proton-vpn-api-core-0.39.0/proton/vpn/core/refresher/server_list_refresher.py000066400000000000000000000070551473026673700310100ustar00rootroot00000000000000""" Copyright (c) 2023 Proton AG This file is part of Proton VPN. Proton VPN 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. Proton VPN 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 ProtonVPN. If not, see . """ from datetime import timedelta from typing import Callable, Optional from proton.session.exceptions import ( ProtonAPINotReachable, ProtonAPINotAvailable, ) from proton.vpn import logging from proton.vpn.core.refresher.scheduler import RunAgain from proton.vpn.core.session_holder import SessionHolder from proton.vpn.session.servers.logicals import ServerList logger = logging.getLogger(__name__) class ServerListRefresher: """ Service in charge of refreshing the VPN server list/loads. """ def __init__(self, session_holder: SessionHolder): self._session_holder = session_holder self.server_list_updated_callback: Optional[Callable] = None self.server_loads_updated_callback: Optional[Callable] = None @property def _session(self): return self._session_holder.session @property def initial_refresh_delay(self): """Returns the initial delay before the first refresh.""" return self._session.server_list.seconds_until_expiration async def refresh(self) -> RunAgain: """Refreshes the server list/loads if expired, else schedules a future refresh.""" try: if self._session.server_list.expired: server_list = await self._session.fetch_server_list() self._notify_server_list() next_refresh_delay = server_list.seconds_until_expiration elif self._session.server_list.loads_expired: server_list = await self._session.update_server_loads() self._notify_server_loads() next_refresh_delay = server_list.seconds_until_expiration else: next_refresh_delay = self._session.server_list.seconds_until_expiration except (ProtonAPINotReachable, ProtonAPINotAvailable) as error: logger.warning(f"Server list refresh failed: {error}") next_refresh_delay = ServerList.get_loads_refresh_interval_in_seconds() except Exception: logger.error( # noqa: E501 # pylint: disable=line-too-long # nosemgrep: python.lang.best-practice.logging-error-without-handling.logging-error-without-handling "Server list refresh failed unexpectedly. " "Stopping server list refresh." ) raise # Let the scheduler know that this method should be run again after a delay. logger.info( f"Next server list refresh scheduled in " f"{timedelta(seconds=next_refresh_delay)}" ) return RunAgain.after_seconds(next_refresh_delay) def _notify_server_loads(self): if callable(self.server_loads_updated_callback): self.server_loads_updated_callback() # pylint: disable=not-callable def _notify_server_list(self): if callable(self.server_list_updated_callback): self.server_list_updated_callback() # pylint: disable=not-callable python-proton-vpn-api-core-0.39.0/proton/vpn/core/refresher/vpn_data_refresher.py000066400000000000000000000211171473026673700302360ustar00rootroot00000000000000""" Certain VPN data like the server list and the client configuration needs to refreshed periodically to keep it up to date. This module defines the required services to do so. Copyright (c) 2023 Proton AG This file is part of Proton VPN. Proton VPN 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. Proton VPN 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 ProtonVPN. If not, see . """ from datetime import timedelta from typing import Callable, Optional from proton.vpn import logging from proton.vpn.core.refresher.certificate_refresher import CertificateRefresher from proton.vpn.core.refresher.client_config_refresher import ClientConfigRefresher from proton.vpn.core.refresher.feature_flags_refresher import FeatureFlagsRefresher from proton.vpn.core.refresher.scheduler import Scheduler from proton.vpn.core.refresher.server_list_refresher import ServerListRefresher from proton.vpn.core.session_holder import SessionHolder from proton.vpn.session.client_config import ClientConfig from proton.vpn.session import FeatureFlags from proton.vpn.session.servers.logicals import ServerList logger = logging.getLogger(__name__) class VPNDataRefresher: # pylint: disable=too-many-instance-attributes """ Service in charge of: - retrieving the required VPN data from Proton's REST API to be able to establish VPN connection, - keeping it up to date and - notifying subscribers when VPN data has been updated. """ def __init__( # pylint: disable=too-many-arguments self, session_holder: SessionHolder, scheduler: Scheduler, client_config_refresher: ClientConfigRefresher = None, server_list_refresher: ServerListRefresher = None, certificate_refresher: CertificateRefresher = None, feature_flags_refresher: FeatureFlagsRefresher = None, ): self._session_holder = session_holder self._scheduler = scheduler self._client_config_refresher = client_config_refresher or ClientConfigRefresher( session_holder ) self._server_list_refresher = server_list_refresher or ServerListRefresher( session_holder ) self._certificate_refresher = certificate_refresher or CertificateRefresher( session_holder ) self._feature_flags_refresher = feature_flags_refresher or FeatureFlagsRefresher( session_holder ) self._client_config_refresh_task_id = None self._server_list_refresher_task_id = None self._certificate_refresher_task_id = None self._feature_flags_refresher_task_id = None def set_error_callback(self, error_callback: Callable[[Exception], None] = None): """Sets the error callback to be called when an error occurs while executing a task.""" self._scheduler.set_error_callback(error_callback) def unset_error_callback(self): """Unsets the error callback.""" self._scheduler.unset_error_callback() @property def _session(self): return self._session_holder.session def set_server_list_updated_callback(self, callback: Optional[Callable]): """Sets the callback to be called whenever the server list is updated.""" self._server_list_refresher.server_list_updated_callback = callback def set_server_loads_updated_callback(self, callback: Optional[Callable]): """Sets the callback to be called whenever the server loads are updated.""" self._server_list_refresher.server_loads_updated_callback = callback def set_certificate_updated_callback(self, callback: Optional[Callable]): """Sets the callback to be called whenever the certificate is updated.""" self._certificate_refresher.certificate_updated_callback = callback @property def server_list(self) -> ServerList: """ Returns the list of available VPN servers. """ return self._session.server_list @property def client_config(self) -> ClientConfig: """Returns the VPN client configuration.""" return self._session.client_config @property def feature_flags(self) -> FeatureFlags: """Returns VPN features.""" return self._session.feature_flags def force_refresh_certificate(self): """Force refresh certificate on demand.""" logger.info("Force refresh certificate.") self._scheduler.cancel_task(self._certificate_refresher_task_id) self._certificate_refresher_task_id = self._scheduler.run_soon( self._certificate_refresher.refresh ) @property def is_vpn_data_ready(self) -> bool: """Returns whether the necessary data from API has already been retrieved or not.""" return self._session.loaded async def enable(self): """Start retrieving data periodically from Proton's REST API.""" if self._session.loaded: self._enable() else: # The VPN session is normally loaded straight after the user logs in. However, # it could happen that it's not loaded in any of the following scenarios: # a) After a successful authentication, the HTTP requests to retrieve # the required VPN session data failed, so it was never persisted. # b) The persisted VPN session does not have the expected format. # This can happen if we introduce a breaking change or if the persisted # data is messed up because the user changes it, or it gets corrupted. await self._refresh_vpn_session_and_then_enable() async def disable(self): """Stops retrieving data periodically from Proton's REST API.""" self._scheduler.cancel_task(self._client_config_refresh_task_id) self._client_config_refresh_task_id = None self._scheduler.cancel_task(self._server_list_refresher_task_id) self._server_list_refresher_task_id = None self._scheduler.cancel_task(self._certificate_refresher_task_id) self._certificate_refresher_task_id = None self._scheduler.cancel_task(self._feature_flags_refresher_task_id) self._feature_flags_refresher_task_id = None await self._scheduler.stop() logger.info( "VPN data refresher service disabled.", category="app", subcategory="vpn_data_refresher", event="disable" ) def _enable(self): logger.info( "VPN data refresher service enabled.", category="app", subcategory="vpn_data_refresher", event="enable" ) self._client_config_refresh_task_id = self._scheduler.run_after( self._client_config_refresher.initial_refresh_delay, self._client_config_refresher.refresh ) logger.info( f"Next client config refresh scheduled in " f"{timedelta(seconds=self._client_config_refresher.initial_refresh_delay)}" ) self._server_list_refresher_task_id = self._scheduler.run_after( self._server_list_refresher.initial_refresh_delay, self._server_list_refresher.refresh ) logger.info( f"Next server list refresh scheduled in " f"{timedelta(seconds=self._server_list_refresher.initial_refresh_delay)}" ) self._certificate_refresher_task_id = self._scheduler.run_after( self._certificate_refresher.initial_refresh_delay, self._certificate_refresher.refresh ) logger.info( f"Next certificate refresh scheduled in " f"{timedelta(seconds=self._certificate_refresher.initial_refresh_delay)}" ) self._feature_flags_refresher_task_id = self._scheduler.run_after( self._feature_flags_refresher.initial_refresh_delay, self._feature_flags_refresher.refresh ) logger.info( f"Next feature flags refresh scheduled in " f"{timedelta(seconds=self._feature_flags_refresher.initial_refresh_delay)}" ) self._scheduler.start() async def _refresh_vpn_session_and_then_enable(self): logger.warning("Reloading VPN session...") await self._session.fetch_session_data() self._enable() python-proton-vpn-api-core-0.39.0/proton/vpn/core/session_holder.py000066400000000000000000000065431473026673700254360ustar00rootroot00000000000000""" Proton VPN Session API. Copyright (c) 2023 Proton AG This file is part of Proton VPN. Proton VPN 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. Proton VPN 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 ProtonVPN. If not, see . """ from __future__ import annotations from dataclasses import dataclass import platform from typing import Optional import distro from proton.sso import ProtonSSO from proton.vpn import logging from proton.vpn.connection import VPNCredentials from proton.vpn.session import VPNSession from proton.vpn.session.utils import to_semver_build_metadata_format logger = logging.getLogger(__name__) CPU_ARCHITECTURE = to_semver_build_metadata_format(platform.machine()) DISTRIBUTION_ID = distro.id() DISTRIBUTION_VERSION = distro.version() @dataclass class ClientTypeMetadata: # pylint: disable=missing-class-docstring type: str version: str architecture: str = CPU_ARCHITECTURE class SessionHolder: """Holds the current session object, initializing it lazily when requested.""" def __init__( self, client_type_metadata: ClientTypeMetadata, session: VPNSession = None ): self._proton_sso = ProtonSSO( appversion=self._get_app_version_header_value(client_type_metadata), user_agent=f"ProtonVPN/{client_type_metadata.version} " f"(Linux; {DISTRIBUTION_ID}/{DISTRIBUTION_VERSION})" ) self._session = session def get_session_for(self, username: str) -> VPNSession: """ Returns the session for the specified user. :param username: Proton account username. :return: """ self._session = self._proton_sso.get_session( account_name=username, override_class=VPNSession ) return self._session @property def session(self) -> VPNSession: """Returns the current session object.""" if not self._session: self._session = self._proton_sso.get_default_session( override_class=VPNSession ) return self._session @property def user_tier(self) -> Optional[int]: """Returns the user tier, if the session is already loaded.""" if self.session.loaded: return self.session.vpn_account.max_tier return None @property def vpn_credentials(self) -> Optional[VPNCredentials]: """Returns the VPN credentials, if the session is already loaded.""" if self.session.loaded: return self.session.vpn_account.vpn_credentials return None @staticmethod def _get_app_version_header_value(client_type_metadata: ClientTypeMetadata) -> str: app_version = f"linux-vpn-{client_type_metadata.type}@{client_type_metadata.version}" if client_type_metadata.architecture: app_version = f"{app_version}+{client_type_metadata.architecture}" return app_version python-proton-vpn-api-core-0.39.0/proton/vpn/core/settings.py000066400000000000000000000243241473026673700242530ustar00rootroot00000000000000""" This module manages the Proton VPN general settings. Copyright (c) 2023 Proton AG This file is part of Proton VPN. Proton VPN 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. Proton VPN 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 ProtonVPN. If not, see . """ from __future__ import annotations from ipaddress import ip_address, IPv4Address, IPv6Address from typing import Union, List from dataclasses import dataclass, asdict, field from enum import IntEnum import os from proton.vpn import logging from proton.utils.environment import VPNExecutionEnvironment from proton.vpn.core.cache_handler import CacheHandler from proton.vpn.killswitch.interface import KillSwitchState from proton.vpn.session.feature_flags_fetcher import FeatureFlags logger = logging.getLogger(__name__) class NetShield(IntEnum): # pylint: disable=missing-class-docstring NO_BLOCK = 0 BLOCK_MALICIOUS_URL = 1 BLOCK_ADS_AND_TRACKING = 2 SETTINGS = os.path.join( VPNExecutionEnvironment().path_config, "settings.json" ) DEFAULT_PROTOCOL = "openvpn-udp" DEFAULT_KILLSWITCH = KillSwitchState.OFF.value DEFAULT_ANONYMOUS_CRASH_REPORTS = True @dataclass class Features: """Contains features that affect a vpn connection""" # pylint: disable=duplicate-code netshield: int moderate_nat: bool vpn_accelerator: bool port_forwarding: bool @staticmethod def from_dict(data: dict, user_tier: int) -> Features: """Creates and returns `Features` from the provided dict.""" default = Features.default(user_tier) return Features( netshield=data.get("netshield", default.netshield), moderate_nat=data.get("moderate_nat", default.moderate_nat), vpn_accelerator=data.get("vpn_accelerator", default.vpn_accelerator), port_forwarding=data.get("port_forwarding", default.port_forwarding), ) def to_dict(self) -> dict: """Converts the class to dict.""" return asdict(self) @staticmethod def default(user_tier: int) -> Features: # pylint: disable=unused-argument """Creates and returns `Features` from default configurations.""" return Features( netshield=( NetShield.NO_BLOCK.value if user_tier < 1 else NetShield.BLOCK_MALICIOUS_URL.value ), moderate_nat=False, vpn_accelerator=True, port_forwarding=False, ) def is_default(self, user_tier: int) -> bool: """Returns true if the features are the default ones.""" return self == Features.default(user_tier) @dataclass class CustomDNSEntry: """Custom DNS IP object.""" ip: Union[IPv4Address, IPv6Address] # pylint: disable=invalid-name enabled: bool = True @staticmethod def from_dict(data: dict) -> CustomDNSEntry: """Creates and returns `CustomDNSEntry` from the provided dict.""" try: ip = data["ip"] # pylint: disable=invalid-name except KeyError as excp: raise ValueError("Missing 'ip' in custom DNS entry") from excp try: converted_ip = ip_address(ip) except ValueError as excp: raise ValueError("Invalid custom DNS IP") from excp return CustomDNSEntry( ip=converted_ip, enabled=data.get("enabled", True) ) def convert_ip_to_short_format(self) -> str: """Converts long format IP to short format IP. Mainly for IPv6 addresses. """ return self.ip.compressed @staticmethod def new_from_string(new_dns_ip: str, enabled: bool = True) -> CustomDNSEntry: """Returns a new CustomDNSEntry from a string IP. This is an alternative way to instantiate this class, allowing the user to pass only the string IP, which internally will validate and convert it to and IPv4Address/IPv6Address object. """ try: converted_ip = ip_address(new_dns_ip) except ValueError as excp: raise ValueError("Invalid custom DNS IP") from excp return CustomDNSEntry(ip=converted_ip, enabled=enabled) def to_dict(self) -> dict: """Converts the class to dict.""" return { "ip": self.ip.compressed, "enabled": self.enabled } @dataclass class CustomDNS: """Contains all settings related to custom DNS.""" enabled: bool = False ip_list: List[CustomDNSEntry] = field(default_factory=list) @staticmethod def from_dict(data: dict) -> CustomDNS: """Creates and returns `CustomDNS` from the provided dict.""" default = CustomDNS.default() loaded_ip_list = data.get("ip_list", default.ip_list) ip_list = [] for dns_entry_dict in loaded_ip_list: try: dns_ip = CustomDNSEntry.from_dict(dns_entry_dict) except ValueError as excp: logger.warning(msg=f"Invalid custom DNS entry: {dns_entry_dict} : {excp}") else: ip_list.append(dns_ip) return CustomDNS( enabled=data.get("enabled", default.enabled), ip_list=ip_list ) @staticmethod def default() -> CustomDNS: # pylint: disable=unused-argument """Creates and returns `CustomDNS` from default configurations.""" return CustomDNS() def get_enabled_ipv4_ips(self) -> List[IPv4Address]: """Returns a list of IPv4 custom DNSs that are enabled.""" return self._get_dns_list_based_on_ip_version(IPv4Address) def get_enabled_ipv6_ips(self) -> List[IPv6Address]: """Returns a list of IPv6 custom DNSs that are enabled.""" return self._get_dns_list_based_on_ip_version(IPv6Address) def _get_dns_list_based_on_ip_version(self, version: Union[IPv4Address, IPv6Address]): dns_list = [] for dns in self.ip_list: if isinstance(dns.ip, version) and dns.enabled: dns_list.append(dns.ip) return dns_list def to_dict(self) -> dict: """Converts the class to dict.""" return { "enabled": self.enabled, "ip_list": [ip.to_dict() for ip in self.ip_list] } @dataclass class Settings: """Contains general settings.""" protocol: str killswitch: int custom_dns: CustomDNS ipv6: bool anonymous_crash_reports: bool features: Features @staticmethod def from_dict(data: dict, user_tier: int) -> Settings: """Creates and returns `Settings` from the provided dict.""" default = Settings.default(user_tier) features = data.get("features") features = Features.from_dict(features, user_tier) if features else default.features custom_dns = data.get("custom_dns") custom_dns = CustomDNS.from_dict(custom_dns) if custom_dns else default.custom_dns return Settings( protocol=data.get("protocol", default.protocol), killswitch=data.get("killswitch", default.killswitch), custom_dns=custom_dns, ipv6=data.get("ipv6", default.ipv6), anonymous_crash_reports=data.get( "anonymous_crash_reports", default.anonymous_crash_reports ), features=features ) def to_dict(self) -> dict: """Converts the class to dict.""" return { "protocol": self.protocol, "killswitch": self.killswitch, "custom_dns": self.custom_dns.to_dict(), "ipv6": self.ipv6, "anonymous_crash_reports": self.anonymous_crash_reports, "features": self.features.to_dict() } @staticmethod def default(user_tier: int) -> Settings: """Creates and returns `Settings` from default configurations.""" return Settings( protocol=DEFAULT_PROTOCOL, killswitch=DEFAULT_KILLSWITCH, custom_dns=CustomDNS.default(), ipv6=True, anonymous_crash_reports=DEFAULT_ANONYMOUS_CRASH_REPORTS, features=Features.default(user_tier) ) class SettingsPersistence: """Persists user settings""" def __init__(self, cache_handler: CacheHandler = None): self._cache_handler = cache_handler or CacheHandler(SETTINGS) self._settings = None self._settings_are_default = True def get(self, user_tier: int, feature_flags: "FeatureFlags" = None) -> Settings: """Load the user settings, either the ones stored on disk or getting default based on tier""" feature_flags = feature_flags or FeatureFlags.default() if self._settings is not None: if self._settings_are_default: self._update_default_settings_based_on_feature_flags(feature_flags) return self._settings raw_settings = self._cache_handler.load() if raw_settings is None: self._settings = Settings.default(user_tier) self._update_default_settings_based_on_feature_flags(feature_flags) else: self._settings = Settings.from_dict(raw_settings, user_tier) self._settings_are_default = False return self._settings def _update_default_settings_based_on_feature_flags(self, feature_flags: "FeatureFlags"): if feature_flags.get("SwitchDefaultProtocolToWireguard"): self._settings.protocol = "wireguard" def save(self, settings: Settings): """Store settings to disk.""" self._cache_handler.save(settings.to_dict()) self._settings = settings self._settings_are_default = False def delete(self): """Deletes the file stored on disk containing the settings and resets internal settings property.""" self._cache_handler.remove() self._settings = None self._settings_are_default = True python-proton-vpn-api-core-0.39.0/proton/vpn/core/usage.py000066400000000000000000000174341473026673700235230ustar00rootroot00000000000000""" Copyright (c) 2023 Proton AG This file is part of Proton VPN. Proton VPN 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. Proton VPN 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 ProtonVPN. If not, see . """ import logging import os import hashlib import getpass from proton.vpn.core.session_holder import ( ClientTypeMetadata, DISTRIBUTION_VERSION, DISTRIBUTION_ID) from proton.vpn.session.utils import get_desktop_environment DSN = "https://9a5ea555a4dc48dbbb4cfa72bdbd0899@vpn-api.proton.me/core/v4/reports/sentry/25" SSL_CERT_FILE = "SSL_CERT_FILE" MACHINE_ID = "/etc/machine-id" PROTON_VPN = "protonvpn" HIDDEN_USERNAME = "" log = logging.getLogger(__name__) class UsageReporting: """Sends anonymous usage reports to Proton.""" def __init__(self, client_type_metadata: ClientTypeMetadata): self._enabled = False self._capture_exception = None self._client_type_metadata = client_type_metadata self._user_id = None self._desktop_environment = get_desktop_environment() @property def enabled(self): """Returns whether anonymous usage reporting is enabled.""" return self._enabled @enabled.setter def enabled(self, value: bool): """ Sets whether usage reporting is enabled/disabled. On unsupported platforms, this may fail, in which case UsageReporting will be disabled and an exception will be logged. """ try: self._enabled = value and self._start_sentry() except Exception: # pylint: disable=broad-except self._enabled = False log.exception("Failed to enabled usage reporting") def report_error(self, error): """ Send an error to sentry if anonymous usage reporting is enabled. On unsupported platforms, this may fail, in which case the error will will not be reported and an exception will be logged. """ try: if self._enabled: self._add_scope_metadata() self._capture_exception(error) except Exception: # pylint: disable=broad-except log.exception("Failed to report error '%s'", str(error)) @staticmethod def _get_user_id(machine_id_filepath=MACHINE_ID, user_name=None): """ Returns a unique identifier for the user. :param machine_id_filepath: The path to the machine id file, defaults to /etc/machine-id. This can be overrided for testing. :param user_name: The username to include in the hash, if None is provided, the current user is obtained from the environment. """ if not os.path.exists(machine_id_filepath): return None # We include the username in the hash to avoid collisions on machines # with multiple users. if not user_name: user_name = getpass.getuser() # We use the machine id to uniquely identify the machine, we combine it # with the application name and the username. All three are hashed to # avoid leaking any personal information. with open(machine_id_filepath, "r", encoding="utf-8") as machine_id_file: machine_id = machine_id_file.read().strip() combined = hashlib.sha256(machine_id.encode('utf-8')) combined.update(hashlib.sha256(PROTON_VPN.encode('utf-8')).digest()) combined.update(hashlib.sha256(user_name.encode('utf-8')).digest()) return str(combined.hexdigest()) @staticmethod def _sanitize_event(event, _hint, user_name=getpass.getuser()): """ Sanitize the event before sending it to sentry. This involves removing the user's name from everywhere in the event. :param event: A dictionary representing the event to sanitize. :param _hint: Unused but required by the sentry SDK. :param user_name: The username to replace in the event, defaults to the current user, but can be set for testing purposes. """ def scrub_user(data): """ Recursively scrub the username from any values in the event. """ if isinstance(data, (tuple, list)): for index, value in enumerate(data): data[index] = scrub_user(value) elif isinstance(data, dict): for key, value in data.items(): data[key] = scrub_user(value) elif isinstance(data, str): data = data.replace(user_name, HIDDEN_USERNAME) return data return scrub_user(event) def _add_scope_metadata(self): """ Unfortunately, we cannot set the user and tags on the isolation scope on startup because this is lost by the time we report an error. So we have to set the user and tags on the current scope just before reporting an error. """ import sentry_sdk # pylint: disable=import-outside-toplevel # Using configure_scope to set a tag works with older versions of # sentry (0.12.2) and so works on ubuntu 20. with sentry_sdk.configure_scope() as scope: scope.set_tag("distro_name", DISTRIBUTION_ID) scope.set_tag("distro_version", DISTRIBUTION_VERSION) scope.set_tag("desktop_environment", self._desktop_environment) if self._user_id and hasattr(scope, "set_user"): scope.set_user({"id": self._user_id}) def _start_sentry(self): """Starts the sentry SDK with the appropriate configuration.""" if self._capture_exception: return True if not self._client_type_metadata: raise ValueError("Client type metadata is not set, " "UsageReporting.init() must be called first.") import sentry_sdk # pylint: disable=import-outside-toplevel from sentry_sdk.integrations.dedupe import DedupeIntegration # pylint: disable=import-outside-toplevel from sentry_sdk.integrations.stdlib import StdlibIntegration # pylint: disable=import-outside-toplevel from sentry_sdk.integrations.modules import ModulesIntegration # pylint: disable=import-outside-toplevel # Read from SSL_CERT_FILE from environment variable, this allows us to # use an http proxy if we want to. ca_certs = os.environ.get(SSL_CERT_FILE, None) client_type_metadata = self._client_type_metadata sentry_sdk.init( dsn=DSN, before_send=UsageReporting._sanitize_event, release=f"{client_type_metadata.type}-{client_type_metadata.version}", server_name=False, # Don't send the computer name default_integrations=False, # We want to be explicit about the integrations we use integrations=[ DedupeIntegration(), # Yes we want to avoid event duplication StdlibIntegration(), # Yes we want info from the standard lib objects ModulesIntegration() # Yes we want to know what python modules are installed ], ca_certs=ca_certs ) # Store the user id so we don't have to calculate it again. self._user_id = self._get_user_id() # Store _capture_exception as a member, so it's easier to test. self._capture_exception = sentry_sdk.capture_exception return True python-proton-vpn-api-core-0.39.0/proton/vpn/killswitch/000077500000000000000000000000001473026673700232615ustar00rootroot00000000000000python-proton-vpn-api-core-0.39.0/proton/vpn/killswitch/interface/000077500000000000000000000000001473026673700252215ustar00rootroot00000000000000python-proton-vpn-api-core-0.39.0/proton/vpn/killswitch/interface/__init__.py000066400000000000000000000015561473026673700273410ustar00rootroot00000000000000""" Init module that makes the Kill Switch class to be easily importable. Copyright (c) 2023 Proton AG This file is part of Proton VPN. Proton VPN 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. Proton VPN 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 ProtonVPN. If not, see . """ from proton.vpn.killswitch.interface.killswitch import KillSwitch, KillSwitchState __all__ = ["KillSwitch", "KillSwitchState"] python-proton-vpn-api-core-0.39.0/proton/vpn/killswitch/interface/exceptions.py000066400000000000000000000027411473026673700277600ustar00rootroot00000000000000""" This module contains the exceptions to be used by kill swtich backends. Copyright (c) 2023 Proton AG This file is part of Proton VPN. Proton VPN 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. Proton VPN 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 ProtonVPN. If not, see . """ class KillSwitchException(Exception): """Base class for KillSwitch specific exceptions.""" def __init__(self, message: str, additional_context: object = None): # noqa self.message = message self.additional_context = additional_context super().__init__(self.message) class MissingKillSwitchBackendDetails(KillSwitchException): """When no KillSwitch backend is found then this exception is raised. In rare cases where it can happen that a user has some default packages installed, where the services for those packages are actually not running. Ie: NetworkManager is installed but not running and for some reason we can't access it, thus this exception is raised as we can't do anything. """ python-proton-vpn-api-core-0.39.0/proton/vpn/killswitch/interface/killswitch.py000066400000000000000000000055471473026673700277630ustar00rootroot00000000000000""" Module that contains the base class for Kill Switch implementations to extend from. Copyright (c) 2023 Proton AG This file is part of Proton VPN. Proton VPN 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. Proton VPN 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 ProtonVPN. If not, see . """ from __future__ import annotations from abc import ABC, abstractmethod from enum import IntEnum from typing import TYPE_CHECKING, Optional from proton.loader import Loader from proton.vpn.killswitch.interface.exceptions import MissingKillSwitchBackendDetails if TYPE_CHECKING: from proton.vpn.connection import VPNServer class KillSwitchState(IntEnum): # pylint: disable=missing-class-docstring OFF = 0 ON = 1 PERMANENT = 2 class KillSwitch(ABC): """ The `KillSwitch` is the base class from which all other kill switch backends need to derive from. """ @staticmethod def get(class_name: str = None, protocol: str = None) -> KillSwitch: """ Returns the kill switch implementation. :param class_name: Name of the class implementing the kill switch. This parameter is optional. If it's not provided then the existing implementation with the highest priority is returned. :param protocol: the kill switch backend to be used based on protocol. This is mainly used for backend validation. """ try: return Loader.get( type_name="killswitch", class_name=class_name, validate_params={"protocol": protocol} ) except RuntimeError as excp: raise MissingKillSwitchBackendDetails(excp) from excp @abstractmethod async def enable(self, vpn_server: Optional["VPNServer"] = None, permanent: bool = False): """ Enables the kill switch. """ @abstractmethod async def disable(self): """ Disables the kill switch. """ @abstractmethod async def enable_ipv6_leak_protection(self, permanent: bool = False): """ Enables IPv6 kill switch to prevent leaks. """ @abstractmethod async def disable_ipv6_leak_protection(self): """ Disables IPv6 kill switch to prevent leaks. """ @staticmethod @abstractmethod def _get_priority() -> int: pass @staticmethod @abstractmethod def _validate(): pass python-proton-vpn-api-core-0.39.0/proton/vpn/logging/000077500000000000000000000000001473026673700225325ustar00rootroot00000000000000python-proton-vpn-api-core-0.39.0/proton/vpn/logging/__init__.py000066400000000000000000000127321473026673700246500ustar00rootroot00000000000000""" Proton VPN Logging API. Copyright (c) 2023 Proton AG This file is part of Proton VPN. Proton VPN 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. Proton VPN 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 ProtonVPN. If not, see . """ from datetime import datetime, timezone import logging import os from logging.handlers import RotatingFileHandler from proton.utils.environment import VPNExecutionEnvironment def _format_log_attributes(category, subcategory, event, optional, msg): """Format the log message as per Proton VPN guidelines. param category: Category of a log, uppercase. :type category: string param subcategory: Subcategory of a log, uppercase (optional). :type subcategory: string param event: Event of a log, uppercase. :type event: string param optional: Additional contextual data (optional). :type optional: string param msg: The message, should contain all necessary details that help better understand the reason behind the message. :type msg: string """ _category = f"{category}" if category else "" _subcategory = f".{subcategory}" if subcategory else "" _event = f":{event}" if event else "" _optional = f" | {optional}" if optional else "" _msg = "" if msg: _msg = f" | {msg}" if event else f"{msg}" return f"{_category.upper()}{_subcategory.upper()}{_event.upper()}{_msg}{_optional}" class ProtonAdapter(logging.LoggerAdapter): """Adapter to add the allowed Proton attributes""" ALLOWED_PROTON_ATTRS = ["category", "subcategory", "event", "optional"] def process(self, msg, kwargs): # Obtain all Proton logging attributes from kwargs. # Note that they should be removed from the kwargs dict as well # before delegating to logging.Logger. Otherwise, logging.Logger # would raise an error due to unrecognized kwargs. category = kwargs.pop("category", None) subcategory = kwargs.pop("subcategory", None) event = kwargs.pop("event", None) optional = kwargs.pop("optional", None) return _format_log_attributes(category, subcategory, event, optional, msg), kwargs def getLogger(name): # noqa # pylint: disable=C0103 """ Returns the logger with the specified name, wrapped in a logging.LoggerAdapter which adds the Proton attributes to the log message. The allowed proton attributes are: category, subcategory, event and optional. Usage: .. highlight:: python .. code-block:: python import proton.vpn.core_api.vpn_logging as logging # 1. config should be called asap, but only once. logging.config("my_log_file") # 2. Get a logger per module. logger = logging.getLogger(__name__) # 3. Use any of the logger methods (debug, warning, info, error, exception,..) # passing the allowed Proton attributes (or not). logger.info( "my message", category="my_category", subcategory="my_subcategory", event="my_event", optional="optional stuff" ) The resulting log message should look like this: 2022-09-20T07:59:27.393743 | INFO | MY_CATEGORY.MY_SUBCATEGORY:MY_EVENT | my message | optional stuff """ return ProtonAdapter(logging.getLogger(name), extra={}) def config(filename, logdirpath=None): """Configure root logger. param filename: Log filename without extension. :type filename: string param logdirpath: Path to log file (optional). :type logdirpath: string """ logger = logging.getLogger() logging_level = logging.INFO if filename is None: raise ValueError("Filename must be set") filename = filename + ".log" default_logdirpath = os.path.join(VPNExecutionEnvironment().path_cache, "logs") logdirpath = logdirpath or default_logdirpath log_filepath = os.path.join(logdirpath, filename) os.makedirs(logdirpath, mode=0o700, exist_ok=True) _formatter = logging.Formatter( fmt="%(asctime)s | %(name)s:%(lineno)d | %(levelname)s | %(message)s", ) _formatter.formatTime = ( lambda record, datefmt=None: datetime.now(timezone.utc).isoformat() ) # Starts a new file at 3MB size limit _handler_file = RotatingFileHandler( log_filepath, maxBytes=3145728, backupCount=3 ) _handler_file.setFormatter(_formatter) # Handler to log to console _handler_console = logging.StreamHandler() _handler_console.setFormatter(_formatter) # Only log debug when using PROTON_VPN_DEBUG=true if os.environ.get("PROTON_VPN_DEBUG", "false").lower() == "true": logging_level = logging.DEBUG # Only log to terminal when using PROTON_VPN_LIVE=true if not _handler_console: logger.warning("Console logger is not set.") # By default log to terminal logger.addHandler(_handler_console) logger.setLevel(logging_level) if _handler_file: logger.addHandler(_handler_file) __all__ = ["getLogger", "config"] python-proton-vpn-api-core-0.39.0/proton/vpn/session/000077500000000000000000000000001473026673700225675ustar00rootroot00000000000000python-proton-vpn-api-core-0.39.0/proton/vpn/session/__init__.py000066400000000000000000000022111473026673700246740ustar00rootroot00000000000000""" Copyright (c) 2023 Proton AG This file is part of Proton VPN. Proton VPN 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. Proton VPN 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 ProtonVPN. If not, see . """ from proton.vpn.session.session import VPNSession from proton.vpn.session.account import VPNAccount from proton.vpn.session.client_config import ClientConfig from proton.vpn.session.servers.logicals import ServerList from proton.vpn.session.credentials import VPNPubkeyCredentials from proton.vpn.session.feature_flags_fetcher import FeatureFlags __all__ = [ "VPNSession", "VPNAccount", "ClientConfig", "ServerList", "VPNPubkeyCredentials", "FeatureFlags" ] python-proton-vpn-api-core-0.39.0/proton/vpn/session/account.py000066400000000000000000000115341473026673700246010ustar00rootroot00000000000000""" Copyright (c) 2023 Proton AG This file is part of Proton VPN. Proton VPN 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. Proton VPN 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 ProtonVPN. If not, see . """ from __future__ import annotations from typing import Sequence, TYPE_CHECKING from proton.vpn.session.credentials import VPNPubkeyCredentials, VPNSecrets from proton.vpn.session.dataclasses import ( VPNSettings, VPNLocation, VPNCertificate, VPNCredentials, VPNUserPassCredentials ) from proton.vpn.session.exceptions import VPNAccountDecodeError if TYPE_CHECKING: from proton.vpn.session.dataclasses import APIVPNSession class VPNAccount: """ This class is responsible to encapsulate all user vpn account information, including credentials (private keys, vpn user and password). """ def __init__( self, vpninfo: VPNSettings, certificate: VPNCertificate, secrets: VPNSecrets, location: VPNLocation ): self._vpninfo = vpninfo self._certificate = certificate self._secrets = secrets self.location = location @staticmethod def from_dict(dict_data: dict) -> VPNAccount: """Creates a VPNAccount instance from the specified dictionary for deserialization purposes.""" try: return VPNAccount( vpninfo=VPNSettings.from_dict(dict_data['vpninfo']), certificate=VPNCertificate.from_dict(dict_data['certificate']), secrets=VPNSecrets.from_dict(dict_data['secrets']), location=VPNLocation.from_dict(dict_data['location']) ) except Exception as exc: raise VPNAccountDecodeError("Invalid VPN account") from exc def set_certificate(self, new_certificate: VPNCertificate): """Set new certificate. This affects only when asking for `vpn_credentials` property as it's built on the fly. """ self._certificate = new_certificate def to_dict(self) -> dict: """ Returns this object as a dictionary for serialization purposes. """ return { "vpninfo": self._vpninfo.to_dict(), "certificate": self._certificate.to_dict(), "secrets": self._secrets.to_dict(), "location": self.location.to_dict() } @property def plan_name(self) -> str: """ :return: str `PlanName` value of the account from :class:`api_data.VPNInfo` in Non-human readable format. """ return self._vpninfo.VPN.PlanName @property def plan_title(self) -> str: """ :return: str `PlanName` value of the account from :class:`api_data.VPNInfo`, Human readable format, thus if you intend to display the plan to the user use this one instead of :class:`VPNAccount.plan_name`. """ return self._vpninfo.VPN.PlanTitle @property def max_tier(self) -> int: """ :return: int `Maxtier` value of the account from :class:`api_data.VPNInfo`. """ return self._vpninfo.VPN.MaxTier @property def max_connections(self) -> int: """ :return: int the `MaxConnect` value of the account from :class:`api_data.VPNInfo`. """ return self._vpninfo.VPN.MaxConnect @property def delinquent(self) -> bool: """ :return: bool if the account is delinquent, based the value from :class:`api_data.VPNSettings`. """ return self._vpninfo.Delinquent > 2 @property def active_connections(self) -> Sequence["APIVPNSession"]: """ :return: the list of active VPN session of the authenticated user on the infra. """ raise NotImplementedError @property def vpn_credentials(self) -> VPNCredentials: """ Return :class:`protonvpn.vpnconnection.interfaces.VPNCredentials` to provide an interface readily usable to instantiate a :class:`protonvpn.vpnconnection.VPNConnection`. """ return VPNCredentials( userpass_credentials=VPNUserPassCredentials( username=self._vpninfo.VPN.Name, password=self._vpninfo.VPN.Password ), pubkey_credentials=VPNPubkeyCredentials( api_certificate=self._certificate, secrets=self._secrets, strict=True ) ) python-proton-vpn-api-core-0.39.0/proton/vpn/session/certificates.py000066400000000000000000000316321473026673700256130ustar00rootroot00000000000000""" Copyright (c) 2023 Proton AG This file is part of Proton VPN. Proton VPN 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. Proton VPN 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 ProtonVPN. If not, see . """ import base64 import datetime import enum import hashlib import typing import nacl.bindings import cryptography.x509 from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat import cryptography.hazmat.backends class Asn1BerDecoder: # pylint: disable=missing-class-docstring _TYPE_INTEGER = 0x02 _TYPE_OCTET_STR = 0x04 _TYPE_SEQUENCE = 0x10 _TYPE_SEQUENCE_OF = 0x30 @classmethod def __get_asn1_ber_len(cls, raw: bytes) -> typing.Tuple[int, int]: """ returns : tuple (length, position start of data) """ # byte 0 : data type if raw[1] & 0x80 == 0: # The short form is a single byte, between 0 and 127. return raw[1], 2 # The long form is at least two bytes long, and has bit 8 of the first byte set to 1. # Bits 7-1 of the first byte indicate how many more bytes are in # the length field itself. # Then the remaining bytes specify the length itself, as a multi-byte integer. length_of_length = raw[1] & 0x7f data_len = 0 for b in raw[2:2 + length_of_length]: # pylint: disable=invalid-name data_len = data_len * 256 + b return data_len, length_of_length + 2 @classmethod def _transform_value_to_str_no_len_check(cls, raw: bytes) -> typing.Tuple[str, int]: """ returns : tuple (decoded string, total length) """ if raw[0] != cls._TYPE_OCTET_STR: raise ValueError(f"Not a string : {raw}") data_len, pos_data = cls.__get_asn1_ber_len(raw) return raw[pos_data:pos_data + data_len].decode("ascii"), (pos_data + data_len) @classmethod def transform_value_to_str(cls, raw: bytes) -> str: # noqa: E501 pylint: disable=missing-function-docstring data, total_len = cls._transform_value_to_str_no_len_check(raw) if total_len != len(raw): raise ValueError( F"wrong extension length : {raw} , found {total_len}, expected {len(raw)}" ) return data @classmethod def _transform_value_to_int_no_len_check(cls, raw: bytes) -> typing.Tuple[int, int]: """ returns : tuple (decoded int, total length) """ if raw[0] != cls._TYPE_INTEGER: raise ValueError(f"Not an integer : {raw}") data_len, pos_data = cls.__get_asn1_ber_len(raw) val = 0 for b in raw[pos_data:pos_data + data_len]: # pylint: disable=invalid-name val = val * 256 + b return val, (pos_data + data_len) @classmethod def transform_value_to_int(cls, raw: bytes) -> int: # noqa: E501 pylint: disable=missing-function-docstring data, total_len = cls._transform_value_to_int_no_len_check(raw) if total_len != len(raw): raise ValueError( f"wrong extension length : {raw} , found {total_len}, expected {len(raw)}" ) return data @classmethod def _transform_value_to_sequence_no_len_check(cls, raw: bytes) -> typing.Tuple[list, int]: """ returns : tuple (decoded list, total length) """ if raw[0] not in (cls._TYPE_SEQUENCE, cls._TYPE_SEQUENCE_OF): raise ValueError(f"Not a sequence : {raw}") data_len, pos_data = cls.__get_asn1_ber_len(raw) indefinite_len = bool(data_len == 0 and raw[1] == 0x80) decoded_list = [] current_pos = pos_data while True: if indefinite_len: # Indefinite length : the end is indicated by the two bytes 00 00 if raw[current_pos] == 0 and raw[current_pos + 1] == 0: current_pos += 2 if current_pos != len(raw): raise ValueError( f"wrong extension length : {raw} , " f"indefinite len ending at position {data_len}, expected {len(raw)}" ) break else: if current_pos == pos_data + data_len: break if current_pos > pos_data + data_len: raise IndexError( f"Error parsing data : current_pos = {current_pos} / " f"pos_data = {pos_data} / data_len = {data_len} / raw = {raw}" ) if raw[current_pos] == cls._TYPE_INTEGER: tmp, tmp_len = cls._transform_value_to_int_no_len_check(raw[current_pos:]) decoded_list.append(tmp) current_pos += tmp_len elif raw[current_pos] == cls._TYPE_OCTET_STR: tmp, tmp_len = cls._transform_value_to_str_no_len_check(raw[current_pos:]) decoded_list.append(tmp) current_pos += tmp_len elif raw[current_pos] in (cls._TYPE_SEQUENCE, cls._TYPE_SEQUENCE_OF): tmp, tmp_len = cls._transform_value_to_sequence_no_len_check(raw[current_pos:]) decoded_list.append(tmp) current_pos += tmp_len else: raise NotImplementedError( f"Unknown type found : 0x{raw[current_pos]:02x} " f"at position {current_pos} in raw = {raw}" ) return decoded_list, current_pos @classmethod def transform_value_to_sequence(cls, raw: bytes) -> list: # noqa: E501 pylint: disable=missing-function-docstring data, total_len = cls._transform_value_to_sequence_no_len_check(raw) if total_len != len(raw): raise ValueError( f"wrong extension length : {raw} , found {total_len}, expected {len(raw)}" ) return data class Extension: # pylint: disable=missing-class-docstring def __init__(self, cert_ext: cryptography.x509.extensions.Extension): self._cert_ext = cert_ext @property def critical(self) -> bool: # pylint: disable=missing-function-docstring return self._cert_ext.critical @property def oid(self) -> str: # pylint: disable=missing-function-docstring return self._cert_ext.oid.dotted_string @property def value(self): """ raw ASN1 value (bytes) : self.value.value """ return self._cert_ext.value.value @property def raw(self): """ Examples : OID as string : self.raw.oid.dotted_string raw ASN1 value (bytes) : self.raw.value.value """ return self._cert_ext @property def value_as_str(self) -> str: # pylint: disable=missing-function-docstring return Asn1BerDecoder.transform_value_to_str(self.value) @property def value_as_int(self) -> int: # pylint: disable=missing-function-docstring return Asn1BerDecoder.transform_value_to_int(self.value) @property def value_as_sequence(self) -> list: # pylint: disable=missing-function-docstring return Asn1BerDecoder.transform_value_to_sequence(self.value) def __str__(self): return str(self._cert_ext) def __repr__(self): return repr(self._cert_ext) class ExtName(enum.Enum): # pylint: disable=missing-class-docstring # https://confluence.protontech.ch/display/VPN/Agent+features+directory+and+format _TWO_FACTORS = "0.0.0" USER_TIER = "0.0.1" GROUPS = "0.0.2" PLATFORM = "0.0.3" NETSHIELD = "0.1.0" PORT_FW = "0.1.3" JAIL = "0.1.5" SPLIT_TCP = "0.1.6" RANDOM_NAT = "0.1.7" BOUNCING = "0.1.8" SAFE_MODE = "0.1.9" class Certificate: # pylint: disable=missing-class-docstring PROTONVPN_OID_STR = '1.3.6.1.4.1.56809.1' PROTONVPN_OID_ARRAY = PROTONVPN_OID_STR.split(".") def __init__(self, cert_pem: typing.Union[bytes, str] = None, cert_der: bytes = None): cert_input = [(cert_pem, "PEM"), (cert_der, "DER")] cert_input = [(x, x_type) for x, x_type in cert_input if x is not None] if len(cert_input) > 1: raise ValueError( "Not possible to provide multiple cert format. " f"Provided formats = {'/'.join([x_type for _, x_type in cert_input])}" ) backend_x509 = None # cryptography.sys.version_info not available in 2.6 crypto_major, crypto_minor = cryptography.__version__.split(".")[:2] if ( int(crypto_major) < 3 or int(crypto_major) == 3 and int(crypto_minor) < 1 ): # backend is required if library < 3.1 backend_x509 = cryptography.hazmat.backends.default_backend() if cert_pem is not None: if isinstance(cert_pem, str): cert_pem = cert_pem.encode("ascii") self._cert = cryptography.x509.load_pem_x509_certificate( data=cert_pem, backend=backend_x509 ) elif cert_der is not None: self._cert = cryptography.x509.load_der_x509_certificate( data=cert_der, backend=backend_x509 ) else: raise ValueError("Not provided any cert format") @property def raw(self): # pylint: disable=missing-function-docstring return self._cert @property def public_key(self) -> bytes: # pylint: disable=missing-function-docstring return self._cert.public_key().public_bytes(encoding=Encoding.Raw, format=PublicFormat.Raw) @property def proton_fingerprint(self) -> str: # pylint: disable=missing-function-docstring ed25519_pk = self.public_key x25519_pk = nacl.bindings.crypto_sign_ed25519_pk_to_curve25519(ed25519_pk) return self.get_proton_fingerprint_from_x25519_pk(x25519_pk) @property def has_valid_date(self) -> bool: # pylint: disable=missing-function-docstring return self.validity_period >= 0 @property def validity_period(self) -> float: """ remaining time the certificate is valid, in seconds. < 0 : certificate is not valid anymore. """ now_timestamp = datetime.datetime.now(datetime.timezone.utc).timestamp() return self.validity_date.timestamp() - now_timestamp @property def validity_date(self) -> datetime.datetime: # pylint: disable=missing-function-docstring # cryptography >= v42.0.0 added `not_valid_after_utc` and deprecated `not_valid_after`. if hasattr(self._cert, "not_valid_after_utc"): return self._cert.not_valid_after_utc # Because `not_valid_after` returns a naive utc # datetime object (without time zone info), we add it manually. return self._cert.not_valid_after.replace( tzinfo=datetime.timezone.utc ) @property def issued_date(self) -> datetime.datetime: # pylint: disable=missing-function-docstring # cryptography >= v42.0.0 added `not_valid_before_utc` and deprecated `not_valid_before`. if hasattr(self._cert, "not_valid_before_utc"): return self._cert.not_valid_before_utc # Because `not_valid_before` returns a naive utc # datetime object (without time zone info), we add it manually. return self._cert.not_valid_before.replace(tzinfo=datetime.timezone.utc) @property def duration(self) -> datetime.timedelta: """ certification duration """ return self.validity_date - self.issued_date @classmethod def get_proton_fingerprint_from_x25519_pk(cls, x25519_pk: bytes) -> str: # noqa: E501 pylint: disable=missing-function-docstring return base64.b64encode(hashlib.sha512(x25519_pk).digest()).decode("ascii") def get_as_der(self) -> bytes: # pylint: disable=missing-function-docstring return self._cert.public_bytes(Encoding.DER) def get_as_pem(self) -> str: # pylint: disable=missing-function-docstring return self._cert.public_bytes(Encoding.PEM).decode("ascii") @property def proton_extensions(self) -> typing.Dict[ExtName, Extension]: # noqa: E501 pylint: disable=missing-function-docstring extensions = {} for ext in self._cert.extensions: oid_array = ext.oid.dotted_string.split(".") if oid_array[:len(self.PROTONVPN_OID_ARRAY)] == self.PROTONVPN_OID_ARRAY: try: ext_name = ".".join(oid_array[len(self.PROTONVPN_OID_ARRAY):]) ext_name = ExtName(ext_name) except ValueError: continue extensions[ext_name] = Extension(ext) return extensions python-proton-vpn-api-core-0.39.0/proton/vpn/session/client_config.py000066400000000000000000000157611473026673700257560ustar00rootroot00000000000000""" Copyright (c) 2023 Proton AG This file is part of Proton VPN. Proton VPN 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. Proton VPN 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 ProtonVPN. If not, see . """ from __future__ import annotations from typing import TYPE_CHECKING from pathlib import Path import random import time from proton.utils.environment import VPNExecutionEnvironment from proton.vpn.core.cache_handler import CacheHandler from proton.vpn.session.exceptions import ClientConfigDecodeError from proton.vpn.session.utils import rest_api_request from proton.vpn.session.dataclasses.client_config import ProtocolPorts if TYPE_CHECKING: from proton.vpn.session import VPNSession DEFAULT_CLIENT_CONFIG = { "DefaultPorts": { "OpenVPN": { "UDP": [80, 51820, 4569, 1194, 5060], "TCP": [443, 7770, 8443] }, "WireGuard": { "UDP": [443, 88, 1224, 51820, 500, 4500], "TCP": [443], } }, "HolesIPs": ["62.112.9.168", "104.245.144.186"], "ServerRefreshInterval": 10, "FeatureFlags": { "NetShield": True, "GuestHoles": False, "ServerRefresh": True, "StreamingServicesLogos": True, "PortForwarding": True, "ModerateNAT": True, "SafeMode": False, "StartConnectOnBoot": True, "PollNotificationAPI": True, "VpnAccelerator": True, "SmartReconnect": True, "PromoCode": False, "WireGuardTls": True, "Telemetry": True, "NetShieldStats": True }, "SmartProtocol": { "OpenVPN": True, "IKEv2": True, "WireGuard": True, "WireGuardTCP": True, "WireGuardTLS": True }, "RatingSettings": { "EligiblePlans": [], "SuccessConnections": 3, "DaysLastReviewPassed": 100, "DaysConnected": 3, "DaysFromFirstConnection": 14 } } class ClientConfig: """ General configuration used to connect to VPN servers. """ REFRESH_INTERVAL = 3 * 60 * 60 # 3 hours REFRESH_RANDOMNESS = 0.22 # +/- 22% def __init__( self, openvpn_ports, wireguard_ports, holes_ips, server_refresh_interval, expiration_time ): # pylint: disable=R0913 self.openvpn_ports = openvpn_ports self.wireguard_ports = wireguard_ports self.holes_ips = holes_ips self.server_refresh_interval = server_refresh_interval self.expiration_time = expiration_time @classmethod def from_dict(cls, apidata: dict) -> ClientConfig: """Creates ClientConfig object from data.""" try: openvpn_ports = apidata["DefaultPorts"]["OpenVPN"] wireguard_ports = apidata["DefaultPorts"]["WireGuard"] holes_ips = apidata["HolesIPs"] server_refresh_interval = apidata["ServerRefreshInterval"] expiration_time = float(apidata.get("ExpirationTime", cls.get_expiration_time())) return ClientConfig( # No need to copy openvpn_ports, OpenVPNPorts takes care of it. ProtocolPorts.from_dict(openvpn_ports), # No need to copy wireguard_ports, WireGuardPorts takes care of it. ProtocolPorts.from_dict(wireguard_ports), # We copy the holes_ips list to avoid side effects if it's modified. holes_ips.copy(), server_refresh_interval, expiration_time ) except (KeyError, ValueError) as error: raise ClientConfigDecodeError( "Error parsing client configuration." ) from error @staticmethod def default() -> ClientConfig: """":returns: the default client configuration.""" return ClientConfig.from_dict(DEFAULT_CLIENT_CONFIG) @property def is_expired(self) -> bool: """Returns if data has expired""" current_time = time.time() return current_time > self.expiration_time @property def seconds_until_expiration(self) -> float: """ Amount of seconds left until the client configuration is considered outdated and should be fetched again from the REST API. """ seconds_left = self.expiration_time - time.time() return seconds_left if seconds_left > 0 else 0 @classmethod def _generate_random_component(cls): # 1 +/- 0.22*random # nosec B311 return 1 + cls.REFRESH_RANDOMNESS * (2 * random.random() - 1) # nosec B311 # noqa: E501 # pylint: disable=line-too-long # nosemgrep: gitlab.bandit.B311 @classmethod def get_refresh_interval_in_seconds(cls): # pylint: disable=missing-function-docstring return cls.REFRESH_INTERVAL * cls._generate_random_component() @classmethod def get_expiration_time(cls, start_time: int = None): # noqa: E501 pylint: disable=missing-function-docstring start_time = start_time if start_time is not None else time.time() return start_time + cls.get_refresh_interval_in_seconds() class ClientConfigFetcher: """ Fetches and caches the client configuration from Proton's REST API. """ ROUTE = "/vpn/v2/clientconfig" CACHE_PATH = Path(VPNExecutionEnvironment().path_cache) / "clientconfig.json" def __init__(self, session: "VPNSession"): """ :param session: session used to retrieve the client configuration. """ self._session = session self._client_config = None self._cache_file = CacheHandler(self.CACHE_PATH) def clear_cache(self): """Discards the cache, if existing.""" self._client_config = None self._cache_file.remove() async def fetch(self) -> ClientConfig: """ Fetches the client configuration from the REST API. :returns: the fetched client configuration. """ response = await rest_api_request( self._session, self.ROUTE, ) response["ExpirationTime"] = ClientConfig.get_expiration_time() self._cache_file.save(response) self._client_config = ClientConfig.from_dict(response) return self._client_config def load_from_cache(self) -> ClientConfig: """ Loads the client configuration from persistence. :returns: the persisted client configuration. If no persistence was found then the default client configuration is returned. """ cache = self._cache_file.load() self._client_config = ClientConfig.from_dict(cache) if cache else ClientConfig.default() return self._client_config python-proton-vpn-api-core-0.39.0/proton/vpn/session/credentials.py000066400000000000000000000201171473026673700254370ustar00rootroot00000000000000""" Copyright (c) 2023 Proton AG This file is part of Proton VPN. Proton VPN 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. Proton VPN 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 ProtonVPN. If not, see . """ from __future__ import annotations import base64 import random from typing import Optional from proton.vpn.session.certificates import Certificate from proton.vpn.session.dataclasses import VPNCertificate from proton.vpn.session.exceptions import (VPNCertificateExpiredError, VPNCertificateFingerprintError) from proton.vpn.session.key_mgr import KeyHandler from proton.vpn import logging logger = logging.getLogger(__name__) class VPNSecrets: """ Asymmetric crypto secrets generated locally by the client to : - connect to the VPN service - ask for a certificate to the API with the corresponding public key. """ def __init__(self, ed25519_privatekey: Optional[str] = None): self._key_handler = ( KeyHandler(base64.b64decode(ed25519_privatekey)) if ed25519_privatekey else KeyHandler() ) def get_ed5519_sk_pem(self, password: Optional[bytes] = None): """ Returns the ed5519 private key in pem format, and encrypted if a password was passed. """ return self._key_handler.get_ed25519_sk_pem(password) @property def wireguard_privatekey(self) -> str: """Wireguard private key encoded in base64. To be added locally by the user. The API route is not providing it. """ return self._key_handler.x25519_sk_str @property def ed25519_privatekey(self) -> str: """Private key in ed25519 base64 format. used to check fingerprints""" return self._key_handler.ed25519_sk_str @property def ed25519_pk_pem(self) -> str: # pylint: disable=missing-function-docstring return self._key_handler.ed25519_pk_pem @property def proton_fingerprint_from_x25519_pk(self): # pylint: disable=missing-function-docstring return self._key_handler.get_proton_fingerprint_from_x25519_pk( self._key_handler.x25519_pk_bytes ) @staticmethod def from_dict(dict_data: dict): # pylint: disable=missing-function-docstring return VPNSecrets(dict_data["ed25519_privatekey"]) def to_dict(self): # pylint: disable=missing-function-docstring return { "ed25519_privatekey": self.ed25519_privatekey } class VPNPubkeyCredentials: """ Class responsible to hold vpn public key API RAW certificates and its associated private key for authentication. """ MINIMUM_VALIDITY_PERIOD_IN_SECS = 300 # FIXME: We were asked to increase the certification duration # pylint: disable=fixme # to 7 days due to certificate refresh issues, until a proper fix is put in place. # It should be reverted to 1 day. REFRESH_INTERVAL = 60 * 60 * 24 * 7 REFRESH_RANDOMNESS = 0.22 # +/- 22% def __init__(self, api_certificate: VPNCertificate, secrets: VPNSecrets, strict: bool = True): self._api_certificate = api_certificate self._secrets = secrets self._certificate_obj = self._build_certificate( api_certificate, secrets, strict ) @classmethod def _generate_random_component(cls): # 1 +/- 0.22*random # nosec B311 return 1 + cls.REFRESH_RANDOMNESS * (2 * random.random() - 1) # nosec B311 # noqa: E501 # pylint: disable=line-too-long # nosemgrep: gitlab.bandit.B311 @classmethod def get_refresh_interval_in_seconds(cls): # pylint: disable=missing-function-docstring return cls.REFRESH_INTERVAL * cls._generate_random_component() def _build_certificate(self, api_certificate, secrets, strict): fingerprint_from_secrets = secrets.proton_fingerprint_from_x25519_pk # Get fingerprint from Certificate public key certificate = Certificate(cert_pem=api_certificate.Certificate) fingerprint_from_certificate = certificate.proton_fingerprint # Refuse to store unmatching fingerprints when strict equal True if strict: if fingerprint_from_secrets != fingerprint_from_certificate: raise VPNCertificateFingerprintError return Certificate(cert_pem=api_certificate.Certificate) def get_ed25519_sk_pem(self, password: Optional[bytes] = None): """ Returns the ed5519 private key in pem format, and encrypted if a password was passed. """ return self._secrets.get_ed5519_sk_pem(password) @property def certificate_pem(self) -> str: """ X509 client certificate in PEM format, can be used to connect for client based authentication to the local agent :raises VPNCertificateNotAvailableError: : certificate cannot be found :class:`VPNSession` must be populated with :meth:`VPNSession.refresh`. :raises VPNCertificateExpiredError: : certificate is expired. :return: :class:`api_data.VPNCertificate.Certificate` """ if not self._certificate_obj.has_valid_date: raise VPNCertificateExpiredError self._log_if_certificate_requires_to_be_refreshed_but_is_not_expired() return self._certificate_obj.get_as_pem() @property def openvpn_private_key(self) -> str: """ Get OpenVPN private key in pem format, directly usable in an OpenVPN configuration file. """ self._log_if_certificate_requires_to_be_refreshed_but_is_not_expired() return self._secrets.get_ed5519_sk_pem() @property def wg_private_key(self) -> str: """ Get Wireguard private key in base64 format, directly usable in a wireguard configuration file. This key is tied to the Proton :class:`VPNCertCredentials` by its corresponding API certificate. :return: :class:`api_data.VPNSecrets.wireguard_privatekey`: Wireguard private key in base64 format. """ self._log_if_certificate_requires_to_be_refreshed_but_is_not_expired() return self._secrets.wireguard_privatekey @property def ed_255519_private_key(self) -> str: # pylint: disable=missing-function-docstring return self._secrets.ed25519_privatekey @property def certificate_validity_remaining(self) -> Optional[float]: """ remaining time the certificate is valid, in seconds. - < 0 : certificate is not valid anymore - None we don't have a certificate. """ return self._certificate_obj.validity_period @property def remaining_time_to_next_refresh(self) -> int: """Returns a timestamp of when the next refresh should be done.""" return self._api_certificate.remaining_time_to_next_refresh @property def proton_extensions(self): # pylint: disable=missing-function-docstring return self._certificate_obj.proton_extensions @property def certificate_duration(self) -> Optional[float]: """ certificate range in seconds, even if not valid anymore. - return `None` if we don't have a certificate """ return self._certificate_obj.duration.total_seconds() def _log_if_certificate_requires_to_be_refreshed_but_is_not_expired(self): if ( self._certificate_obj.validity_period <= VPNPubkeyCredentials.MINIMUM_VALIDITY_PERIOD_IN_SECS ): logger.warning( msg="Current certificate will expire.", category="CREDENTIALS", subcategory="CERTIFICATE", event="REQUIRE_REFRESH" ) python-proton-vpn-api-core-0.39.0/proton/vpn/session/dataclasses/000077500000000000000000000000001473026673700250565ustar00rootroot00000000000000python-proton-vpn-api-core-0.39.0/proton/vpn/session/dataclasses/__init__.py000066400000000000000000000026041473026673700271710ustar00rootroot00000000000000""" Copyright (c) 2023 Proton AG This file is part of Proton VPN. Proton VPN 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. Proton VPN 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 ProtonVPN. If not, see . """ from proton.vpn.session.dataclasses.bug_report import BugReportForm from proton.vpn.session.dataclasses.certificate import VPNCertificate from proton.vpn.session.dataclasses.credentials import ( VPNUserPassCredentials, VPNCredentials ) from proton.vpn.session.dataclasses.location import VPNLocation from proton.vpn.session.dataclasses.login_result import LoginResult from proton.vpn.session.dataclasses.sessions import APIVPNSession, VPNSessions from proton.vpn.session.dataclasses.settings import VPNInfo, VPNSettings __all__ = [ "BugReportForm", "VPNCertificate", "VPNUserPassCredentials", "VPNCredentials", "VPNLocation", "LoginResult", "APIVPNSession", "VPNSessions", "VPNInfo", "VPNSettings" ] python-proton-vpn-api-core-0.39.0/proton/vpn/session/dataclasses/bug_report.py000066400000000000000000000025231473026673700276020ustar00rootroot00000000000000""" Copyright (c) 2023 Proton AG This file is part of Proton VPN. Proton VPN 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. Proton VPN 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 ProtonVPN. If not, see . """ from typing import List, IO from dataclasses import dataclass, field from proton.vpn.session.utils import generate_os_string, get_distro_version VPN_CLIENT_TYPE = "2" # 1: email; 2: VPN # pylint: disable=invalid-name @dataclass class BugReportForm: # pylint: disable=too-many-instance-attributes """Bug report form data to be submitted to customer support.""" username: str email: str title: str description: str client_version: str client: str attachments: List[IO] = field(default_factory=list) os: str = generate_os_string() # pylint: disable=invalid-name os_version: str = get_distro_version() client_type: str = VPN_CLIENT_TYPE python-proton-vpn-api-core-0.39.0/proton/vpn/session/dataclasses/certificate.py000066400000000000000000000043621473026673700277170ustar00rootroot00000000000000""" Copyright (c) 2023 Proton AG This file is part of Proton VPN. Proton VPN 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. Proton VPN 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 ProtonVPN. If not, see . """ from __future__ import annotations import time from dataclasses import dataclass from proton.vpn.session.utils import Serializable # pylint: disable=invalid-name @dataclass class VPNCertificate(Serializable): # pylint: disable=too-many-instance-attributes """ Same object structure coming from the API """ SerialNumber: str ClientKeyFingerprint: str ClientKey: str """ Client public key used to ask for this certificate in PEM format. """ Certificate: str """ Certificate value in PEM format. Contains the features requested at fetch time""" ExpirationTime: int RefreshTime: int Mode: str DeviceName: str ServerPublicKeyMode: str ServerPublicKey: str @property def remaining_time_to_next_refresh(self) -> int: """Returns a timestamp of when the next refresh should be done.""" remaining_time = self.RefreshTime - time.time() return remaining_time if remaining_time > 0 else 0 @staticmethod def _deserialize(dict_data: dict) -> VPNCertificate: return VPNCertificate( SerialNumber=dict_data["SerialNumber"], ClientKeyFingerprint=dict_data["ClientKeyFingerprint"], ClientKey=dict_data["ClientKey"], Certificate=dict_data["Certificate"], ExpirationTime=dict_data["ExpirationTime"], RefreshTime=dict_data["RefreshTime"], Mode=dict_data["Mode"], DeviceName=dict_data["DeviceName"], ServerPublicKeyMode=dict_data["ServerPublicKeyMode"], ServerPublicKey=dict_data["ServerPublicKey"] ) python-proton-vpn-api-core-0.39.0/proton/vpn/session/dataclasses/client_config/000077500000000000000000000000001473026673700276615ustar00rootroot00000000000000python-proton-vpn-api-core-0.39.0/proton/vpn/session/dataclasses/client_config/__init__.py000066400000000000000000000014311473026673700317710ustar00rootroot00000000000000""" Copyright (c) 2023 Proton AG This file is part of Proton VPN. Proton VPN 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. Proton VPN 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 ProtonVPN. If not, see . """ from proton.vpn.session.dataclasses.client_config.protocol_ports import ProtocolPorts __all__ = ["ProtocolPorts"] python-proton-vpn-api-core-0.39.0/proton/vpn/session/dataclasses/client_config/protocol_ports.py000066400000000000000000000023401473026673700333220ustar00rootroot00000000000000""" Copyright (c) 2023 Proton AG This file is part of Proton VPN. Proton VPN 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. Proton VPN 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 ProtonVPN. If not, see . """ from __future__ import annotations from typing import List from dataclasses import dataclass @dataclass class ProtocolPorts: """Dataclass for ports. These ports are mainly used for establishing VPN connections. """ udp: List tcp: List @staticmethod def from_dict(ports: dict) -> ProtocolPorts: """Creates ProtocolPorts object from data.""" # The lists are copied to avoid side effects if the dict is modified. return ProtocolPorts( udp=ports["UDP"].copy(), tcp=ports["TCP"].copy() ) python-proton-vpn-api-core-0.39.0/proton/vpn/session/dataclasses/credentials.py000066400000000000000000000025351473026673700277320ustar00rootroot00000000000000""" Copyright (c) 2023 Proton AG This file is part of Proton VPN. Proton VPN 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. Proton VPN 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 ProtonVPN. If not, see . """ from __future__ import annotations from dataclasses import dataclass from typing import TYPE_CHECKING if TYPE_CHECKING: from proton.vpn.session.credentials import VPNPubkeyCredentials # pylint: disable=invalid-name @dataclass class VPNUserPassCredentials: """ Class responsible to hold vpn user/password credentials for authentication """ username: str password: str @dataclass class VPNCredentials: """ Interface to :class:`proton.vpn.connection.interfaces.VPNCredentials` See :attr:`proton.vpn.session.VPNSession.vpn_account.vpn_credentials` to get one. """ userpass_credentials: VPNUserPassCredentials pubkey_credentials: VPNPubkeyCredentials python-proton-vpn-api-core-0.39.0/proton/vpn/session/dataclasses/location.py000066400000000000000000000024721473026673700272450ustar00rootroot00000000000000""" Copyright (c) 2023 Proton AG This file is part of Proton VPN. Proton VPN 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. Proton VPN 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 ProtonVPN. If not, see . """ from __future__ import annotations from dataclasses import dataclass from proton.vpn.session.utils import Serializable # pylint: disable=invalid-name @dataclass class VPNLocation(Serializable): """Data about the physical location the VPN client runs from.""" IP: str Country: str ISP: str @staticmethod def _deserialize(dict_data: dict) -> VPNLocation: """ Builds a Location object from a dict containing the parsed JSON response returned by the API. """ return VPNLocation( IP=dict_data["IP"], Country=dict_data["Country"], ISP=dict_data["ISP"] ) python-proton-vpn-api-core-0.39.0/proton/vpn/session/dataclasses/login_result.py000066400000000000000000000015261473026673700301420ustar00rootroot00000000000000""" Copyright (c) 2023 Proton AG This file is part of Proton VPN. Proton VPN 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. Proton VPN 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 ProtonVPN. If not, see . """ from dataclasses import dataclass @dataclass class LoginResult: # pylint: disable=missing-class-docstring success: bool authenticated: bool twofa_required: bool python-proton-vpn-api-core-0.39.0/proton/vpn/session/dataclasses/servers/000077500000000000000000000000001473026673700265475ustar00rootroot00000000000000python-proton-vpn-api-core-0.39.0/proton/vpn/session/dataclasses/servers/__init__.py000066400000000000000000000014001473026673700306530ustar00rootroot00000000000000""" Copyright (c) 2023 Proton AG This file is part of Proton VPN. Proton VPN 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. Proton VPN 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 ProtonVPN. If not, see . """ from proton.vpn.session.dataclasses.servers.country import Country __all__ = ["Country"] python-proton-vpn-api-core-0.39.0/proton/vpn/session/dataclasses/servers/country.py000066400000000000000000000025771473026673700306370ustar00rootroot00000000000000""" Copyright (c) 2023 Proton AG This file is part of Proton VPN. Proton VPN 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. Proton VPN 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 ProtonVPN. If not, see . """ from __future__ import annotations from typing import TYPE_CHECKING, List from dataclasses import dataclass from proton.vpn.session.servers.country_codes import get_country_name_by_code if TYPE_CHECKING: from proton.vpn.session.servers.logicals import LogicalServer @dataclass class Country: """Group of servers belonging to a country.""" code: str servers: List[LogicalServer] @property def name(self): """Returns the full country name.""" return get_country_name_by_code(self.code) @property def is_free(self) -> bool: """Returns whether the country has servers available to the free tier or not.""" return any(server.tier == 0 for server in self.servers) python-proton-vpn-api-core-0.39.0/proton/vpn/session/dataclasses/sessions.py000066400000000000000000000031771473026673700273060ustar00rootroot00000000000000""" Copyright (c) 2023 Proton AG This file is part of Proton VPN. Proton VPN 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. Proton VPN 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 ProtonVPN. If not, see . """ from __future__ import annotations from typing import List from dataclasses import dataclass from proton.vpn.session.utils import Serializable # pylint: disable=invalid-name @dataclass class APIVPNSession(Serializable): # pylint: disable=missing-class-docstring SessionID: str ExitIP: str Protocol: str @staticmethod def _deserialize(dict_data: dict) -> APIVPNSession: return APIVPNSession( SessionID=dict_data["SessionID"], ExitIP=dict_data["ExitIP"], Protocol=dict_data["Protocol"] ) @dataclass class VPNSessions(Serializable): """ The list of active VPN session of an account on the infra """ Sessions: List[APIVPNSession] def __len__(self): return len(self.Sessions) @staticmethod def _deserialize(dict_data: dict) -> VPNSessions: session_list = [APIVPNSession.from_dict(value) for value in dict_data['Sessions']] return VPNSessions(Sessions=session_list) python-proton-vpn-api-core-0.39.0/proton/vpn/session/dataclasses/settings.py000066400000000000000000000055261473026673700273000ustar00rootroot00000000000000""" Copyright (c) 2023 Proton AG This file is part of Proton VPN. Proton VPN 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. Proton VPN 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 ProtonVPN. If not, see . """ from __future__ import annotations from typing import List from dataclasses import dataclass from proton.vpn.session.utils import Serializable # pylint: disable=invalid-name @dataclass class VPNInfo(Serializable): # pylint: disable=too-many-instance-attributes """ Same object structure as the one coming from the API""" ExpirationTime: int Name: str Password: str GroupID: str Status: int PlanName: str PlanTitle: str MaxTier: int """ Maximum tier value that this account can vpn connect to """ MaxConnect: int """ Maximum number of simultaneous session on the infrastructure""" Groups: List[str] """ List of groups that this account belongs to """ NeedConnectionAllocation: bool @staticmethod def _deserialize(dict_data: dict) -> VPNInfo: return VPNInfo( ExpirationTime=dict_data["ExpirationTime"], Name=dict_data["Name"], Password=dict_data["Password"], GroupID=dict_data["GroupID"], Status=dict_data["Status"], PlanName=dict_data["PlanName"], PlanTitle=dict_data["PlanTitle"], MaxTier=dict_data["MaxTier"], MaxConnect=dict_data["MaxConnect"], Groups=dict_data["Groups"], NeedConnectionAllocation=dict_data["NeedConnectionAllocation"] ) @dataclass class VPNSettings(Serializable): # pylint: disable=too-many-instance-attributes """ Same object structure as the one coming from the API""" VPN: VPNInfo Services: int Subscribed: int Delinquent: int """ Encode the deliquent status of the account """ HasPaymentMethod: int Credit: int Currency: str Warnings: List[str] @staticmethod def _deserialize(dict_data: dict) -> VPNSettings: return VPNSettings( VPN=VPNInfo.from_dict(dict_data["VPN"]), Services=dict_data["Services"], Subscribed=dict_data["Subscribed"], Delinquent=dict_data["Delinquent"], HasPaymentMethod=dict_data["HasPaymentMethod"], Credit=dict_data["Credit"], Currency=dict_data["Currency"], Warnings=dict_data["Warnings"] ) python-proton-vpn-api-core-0.39.0/proton/vpn/session/exceptions.py000066400000000000000000000035101473026673700253210ustar00rootroot00000000000000""" Copyright (c) 2023 Proton AG This file is part of Proton VPN. Proton VPN 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. Proton VPN 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 ProtonVPN. If not, see . """ class VPNSessionNotLoadedError(Exception): """ Data from the current VPN session was accessed before it was loaded. """ class VPNAccountDecodeError(ValueError): """The VPN account could not be deserialized.""" class VPNCertificateError(Exception): """ Base class for certificate errors. """ class VPNCertificateExpiredError(VPNCertificateError): """ VPN Certificate is available but is expired. """ class VPNCertificateNeedRefreshError(VPNCertificateError): """ VPN Certificate is available but needs to be refreshed because is close to expiration. """ class VPNCertificateFingerprintError(VPNCertificateError): """ VPN Certificate and private key fingerprint are not matching. A new keypair should be generated and the corresponding certificate should be fetched from our REST API. """ class ServerListDecodeError(ValueError): """The server list could not be parsed.""" class ServerNotFoundError(Exception): """ The specified server could not be found in the server list. """ class ClientConfigDecodeError(ValueError): """The client configuration could not be parsed.""" python-proton-vpn-api-core-0.39.0/proton/vpn/session/feature_flags_fetcher.py000066400000000000000000000145301473026673700274530ustar00rootroot00000000000000""" Copyright (c) 2024 Proton AG This file is part of Proton VPN. Proton VPN 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. Proton VPN 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 ProtonVPN. If not, see . """ from __future__ import annotations from typing import TYPE_CHECKING from pathlib import Path from proton.utils.environment import VPNExecutionEnvironment from proton.vpn.session.utils import RefreshCalculator, rest_api_request from proton.vpn.core.cache_handler import CacheHandler if TYPE_CHECKING: from proton.vpn.session.api import VPNSession REFRESH_INTERVAL = 2 * 60 * 60 # 2 hours DEFAULT = { "toggles": [ { "name": "LinuxBetaToggle", "enabled": True, "impressionData": False, "variant": { "name": "disabled", "enabled": False } }, { "name": "WireGuardExperimental", "enabled": True, "impressionData": False, "variant": { "name": "disabled", "enabled": False } }, { "name": "TimestampedLogicals", "enabled": False, "impressionData": False, "variant": { "name": "disabled", "enabled": False } }, { "name": "IPv6Support", "enabled": False, "impressionData": False, "variant": { "name": "disabled", "enabled": False } }, { "name": "CertificateBasedOpenVPN", "enabled": False, "impressionData": False, "variant": { "name": "disabled", "enabled": False } }, { "name": "LinuxDeferredUI", "enabled": False, "impressionData": False, "variant": { "name": "disabled", "enabled": False } }, { "name": "CustomDNS", "enabled": False, "impressionData": False, "variant": { "name": "disabled", "enabled": False } }, { "name": "SwitchDefaultProtocolToWireguard", "enabled": False, "impressionData": False, "variant": { "name": "disabled", "enabled": False } }, ], "ExpirationTime": 0 } class FeatureFlags: # pylint: disable=too-few-public-methods """Contains a record of available features.""" def __init__(self, api_data: dict): self._api_data = api_data self._expiration_time = api_data.get( "ExpirationTime", RefreshCalculator.get_expiration_time( refresh_interval=REFRESH_INTERVAL ) ) def get(self, feature_flag_name: str) -> bool: """Get a feature flag by its name. Always returns `False` if the feature flag is not found. """ return self._search_for_feature_flag(feature_flag_name) def _search_for_feature_flag(self, feature_name: str) -> dict: feature_flag_dict = {} for feature in self._api_data.get("toggles", {}): if feature["name"] == feature_name: feature_flag_dict = feature break return feature_flag_dict.get("enabled", False) @property def is_expired(self) -> bool: """Returns if data has expired""" return RefreshCalculator.get_is_expired(self._expiration_time) @property def seconds_until_expiration(self) -> int: """Returns amount of seconds until it expires.""" return RefreshCalculator.get_seconds_until_expiration(self._expiration_time) @staticmethod def get_refresh_interval_in_seconds() -> int: """Returns refresh interval in seconds.""" return RefreshCalculator(REFRESH_INTERVAL).get_refresh_interval_in_seconds() @staticmethod def default() -> FeatureFlags: """Returns a feature object with default values""" return FeatureFlags(DEFAULT) class FeatureFlagsFetcher: """Fetches and caches features from Proton's REST API.""" ROUTE = "/feature/v2/frontend" CACHE_PATH = Path(VPNExecutionEnvironment().path_cache) / "features.json" def __init__( self, session: "VPNSession", refresh_calculator: RefreshCalculator = None, cache_handler: CacheHandler = None ): """ :param session: session used to retrieve the client configuration. """ self._features = None self._session = session self._refresh_calculator = refresh_calculator or RefreshCalculator self._cache_file = cache_handler or CacheHandler(self.CACHE_PATH) def clear_cache(self): """Discards the cache, if existing.""" self._features = None self._cache_file.remove() async def fetch(self) -> FeatureFlags: """ Fetches the client configuration from the REST API. :returns: the fetched client configuration. """ response = await rest_api_request( self._session, self.ROUTE, ) response["ExpirationTime"] = self._refresh_calculator\ .get_expiration_time(refresh_interval=REFRESH_INTERVAL) self._cache_file.save(response) self._features = FeatureFlags(response) return self._features def load_from_cache(self) -> FeatureFlags: """ Loads the client configuration from persistence. :returns: the persisted client configuration. If no persistence was found then the default client configuration is returned. """ cache = self._cache_file.load() self._features = FeatureFlags(cache) if cache else FeatureFlags.default() return self._features python-proton-vpn-api-core-0.39.0/proton/vpn/session/fetcher.py000066400000000000000000000144241473026673700245660ustar00rootroot00000000000000""" Copyright (c) 2023 Proton AG This file is part of Proton VPN. Proton VPN 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. Proton VPN 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 ProtonVPN. If not, see . """ from __future__ import annotations from typing import TYPE_CHECKING, Optional from proton.vpn import logging from proton.vpn.session.client_config import ClientConfigFetcher, ClientConfig from proton.vpn.session.credentials import VPNPubkeyCredentials from proton.vpn.session.dataclasses import ( VPNCertificate, VPNSessions, VPNSettings, VPNLocation ) from proton.vpn.session.servers.fetcher import ServerListFetcher from proton.vpn.session.servers.logicals import ServerList from proton.vpn.session.utils import rest_api_request from proton.vpn.session.feature_flags_fetcher import FeatureFlagsFetcher, FeatureFlags from proton.vpn.core.settings import Features if TYPE_CHECKING: from proton.vpn.session import VPNSession logger = logging.getLogger(__name__) # These are the api keys for the certificate features. API_NETSHIELD = "NetShieldLevel" API_VPN_ACCELERATOR = "SplitTCP" API_MODERATE_NAT = "RandomNAT" API_PORT_FORWARDING = "PortForwarding" class VPNSessionFetcher: """ Fetches PROTON VPN user account information. """ # Note that the API does not allow intervals shorter than 1 day. _CERT_DURATION_IN_MIN = VPNPubkeyCredentials.REFRESH_INTERVAL // 60 def __init__( self, session: "VPNSession", server_list_fetcher: Optional[ServerListFetcher] = None, client_config_fetcher: Optional[ClientConfigFetcher] = None, features_fetcher: Optional[FeatureFlagsFetcher] = None, ): self._session = session self._server_list_fetcher = server_list_fetcher or ServerListFetcher(session) self._client_config_fetcher = client_config_fetcher or ClientConfigFetcher(session) self._feature_flags_fetcher = features_fetcher or FeatureFlagsFetcher(session) async def fetch_vpn_info(self) -> VPNSettings: """Fetches client VPN information.""" return VPNSettings.from_dict( await rest_api_request(self._session, "/vpn/v2") ) async def fetch_certificate( self, client_public_key, features: Optional[Features] = None ) -> VPNCertificate: """ Fetches a certificated signed by the API server to authenticate against VPN servers. """ json_req = { "ClientPublicKey": client_public_key, "Duration": f"{self._CERT_DURATION_IN_MIN} min" } if features: json_req["Features"] = VPNSessionFetcher._convert_features(features) return VPNCertificate.from_dict( await rest_api_request( self._session, "/vpn/v1/certificate", jsondata=json_req ) ) async def fetch_active_sessions(self) -> VPNSessions: """ Fetches information about active VPN sessions. """ return VPNSessions.from_dict( await rest_api_request(self._session, "/vpn/v1/sessions") ) async def fetch_location(self) -> VPNLocation: """Fetches information about the physical location the VPN client is connected from.""" return VPNLocation.from_dict( await rest_api_request(self._session, "/vpn/v1/location") ) def load_server_list_from_cache(self) -> ServerList: """ Loads the previously persisted server list. :returns: the loaded server lists. :raises ServerListDecodeError: if the server list could not be loaded. """ return self._server_list_fetcher.load_from_cache() async def fetch_server_list(self) -> ServerList: """Fetches the list of VPN servers.""" return await self._server_list_fetcher.fetch() async def update_server_loads(self) -> ServerList: """Fetches new server loads and updates the current server list with them.""" return await self._server_list_fetcher.update_loads() def load_client_config_from_cache(self) -> ClientConfig: """ Loads the previously persisted client configuration. :returns: the loaded client configuration. :raises ClientConfigDecodeError: if the client configuration could not be loaded. """ return self._client_config_fetcher.load_from_cache() async def fetch_client_config(self) -> ClientConfig: """Fetches general client configuration to connect to VPN servers.""" return await self._client_config_fetcher.fetch() def load_feature_flags_from_cache(self) -> FeatureFlags: """ Loads the previously persisted client configuration. :returns: the loaded client configuration. :raises ClientConfigDecodeError: if the client configuration could not be loaded. """ return self._feature_flags_fetcher.load_from_cache() async def fetch_feature_flags(self) -> FeatureFlags: """Fetches general client configuration to connect to VPN servers.""" return await self._feature_flags_fetcher.fetch() def clear_cache(self): """Discards the cache, if existing.""" self._server_list_fetcher.clear_cache() self._client_config_fetcher.clear_cache() self._feature_flags_fetcher.clear_cache() @staticmethod def _convert_features(features: Features): """ This converts the settings features into a certificate request features dictionary. """ result = {} if not features.moderate_nat: result[API_MODERATE_NAT] = False if not features.vpn_accelerator: result[API_VPN_ACCELERATOR] = False if features.port_forwarding: result[API_PORT_FORWARDING] = True if features.netshield != 0: result[API_NETSHIELD] = features.netshield return result python-proton-vpn-api-core-0.39.0/proton/vpn/session/key_mgr.py000066400000000000000000000151201473026673700245750ustar00rootroot00000000000000""" Copyright (c) 2023 Proton AG This file is part of Proton VPN. Proton VPN 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. Proton VPN 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 ProtonVPN. If not, see . """ import base64 import hashlib from typing import Optional import cryptography.hazmat.primitives.asymmetric from cryptography.hazmat.primitives.serialization import Encoding, PrivateFormat from cryptography.hazmat.primitives import serialization import nacl.bindings class KeyHandler: # pylint: disable=missing-class-docstring PREFIX_SK = bytes( [int(x, 16) for x in '30:2E:02:01:00:30:05:06:03:2B:65:70:04:22:04:20'.split(':')] ) PREFIX_PK = bytes([int(x, 16) for x in '30:2A:30:05:06:03:2B:65:70:03:21:00'.split(':')]) def __init__(self, private_key=None): """ private key parameter must be in ed25519 format, from which we convert to x25519 format with nacl. But it's not possible to convert from x25519 to ed25519. """ self._private_key, self._public_key = self.__generate_key_pair(private_key=private_key) tmp_ed25519_sk = self.ed25519_sk_bytes tmp_ed25519_pk = self.ed25519_pk_bytes """ # crypto_sign_ed25519_sk_to_curve25519() is equivalent to : tmp = list(hashlib.sha512(ed25519_sk).digest()[:32]) tmp[0] &= 248 tmp[31] &= 127 tmp[31] |= 64 self._x25519_sk = bytes(tmp) """ self._x25519_sk = nacl.bindings.crypto_sign_ed25519_sk_to_curve25519( tmp_ed25519_sk + tmp_ed25519_pk ) self._x25519_pk = nacl.bindings.crypto_sign_ed25519_pk_to_curve25519(tmp_ed25519_pk) @classmethod def get_proton_fingerprint_from_x25519_pk(cls, x25519_pk: bytes) -> str: # noqa: E501 pylint: disable=missing-function-docstring return base64.b64encode(hashlib.sha512(x25519_pk).digest()).decode("ascii") @classmethod def from_sk_file(cls, ed25519sk_file): # pylint: disable=missing-function-docstring backend_default = None # cryptography.sys.version_info not available in 2.6 crypto_major, crypto_minor = cryptography.__version__.split(".")[:2] if int(crypto_major) < 3 or \ int(crypto_major) == 3 and \ int(crypto_minor) < 1: # backend is required if library < 3.1 backend_default = cryptography.hazmat.backends.default_backend() with open(file=ed25519sk_file) as file: # pylint: disable=unspecified-encoding pem_data = "".join(file.readlines()) key = serialization.load_pem_private_key( pem_data.encode("ascii"), password=None, backend=backend_default ) assert isinstance( # nosec B311, B101 # noqa: E501 # pylint: disable=line-too-long # nosemgrep: gitlab.bandit.B101 key, cryptography.hazmat.primitives.asymmetric.ed25519.Ed25519PrivateKey) # nosec: B101 private_key = key.private_bytes( Encoding.Raw, PrivateFormat.Raw, encryption_algorithm=serialization.NoEncryption() ) return KeyHandler(private_key=private_key) @property def ed25519_sk_str(self) -> str: # pylint: disable=missing-function-docstring return base64.b64encode(self.ed25519_sk_bytes).decode("ascii") @property def ed25519_sk_bytes(self) -> bytes: # pylint: disable=missing-function-docstring return self._private_key.private_bytes( Encoding.Raw, PrivateFormat.Raw, encryption_algorithm=serialization.NoEncryption() ) @property def ed25519_pk_bytes(self) -> bytes: # pylint: disable=missing-function-docstring return self._public_key.public_bytes( encoding=serialization.Encoding.Raw, format=serialization.PublicFormat.Raw ) @property def ed25519_pk_str_asn1(self) -> bytes: # pylint: disable=missing-function-docstring return base64.b64encode(self.PREFIX_PK + self.ed25519_pk_bytes) @property def ed25519_sk_pem(self) -> str: # pylint: disable=missing-function-docstring return self.get_ed25519_sk_pem() @property def ed25519_pk_pem(self) -> str: # pylint: disable=missing-function-docstring return self._public_key.public_bytes( encoding=serialization.Encoding.PEM, format=serialization.PublicFormat.SubjectPublicKeyInfo ).decode('ascii') @property def x25519_sk_bytes(self) -> bytes: # pylint: disable=missing-function-docstring return self._x25519_sk @property def x25519_pk_bytes(self) -> bytes: # pylint: disable=missing-function-docstring return self._x25519_pk @property def x25519_sk_str(self) -> str: # pylint: disable=missing-function-docstring return base64.b64encode(self._x25519_sk).decode("ascii") @property def x25519_pk_str(self) -> str: # pylint: disable=missing-function-docstring return base64.b64encode(self._x25519_pk).decode("ascii") @classmethod def __generate_key_pair(cls, private_key=None): if private_key: private_key = cryptography.hazmat.primitives.asymmetric\ .ed25519.Ed25519PrivateKey.from_private_bytes(private_key) else: private_key = cryptography.hazmat.primitives.asymmetric\ .ed25519.Ed25519PrivateKey.generate() public_key = private_key.public_key() return private_key, public_key def get_ed25519_sk_pem(self, password: Optional[bytes] = None) -> str: """ Returns the ed5519 private key in pem format, and encrypted if a password was passed. """ if password: encryption_algorithm = serialization.BestAvailableEncryption(password=password) else: encryption_algorithm = serialization.NoEncryption() return self._private_key.private_bytes( encoding=Encoding.PEM, format=PrivateFormat.PKCS8, encryption_algorithm=encryption_algorithm ).decode('ascii') def bytes_to_str_hexa(b: bytes): # pylint: disable=missing-function-docstring invalid-name return ":".join(["{:02x}".format(x) for x in b]) # pylint: disable=consider-using-f-string python-proton-vpn-api-core-0.39.0/proton/vpn/session/servers/000077500000000000000000000000001473026673700242605ustar00rootroot00000000000000python-proton-vpn-api-core-0.39.0/proton/vpn/session/servers/__init__.py000066400000000000000000000017021473026673700263710ustar00rootroot00000000000000""" Copyright (c) 2023 Proton AG This file is part of Proton VPN. Proton VPN 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. Proton VPN 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 ProtonVPN. If not, see . """ from proton.vpn.session.servers.logicals import ServerList, Country from proton.vpn.session.servers.types import \ LogicalServer, PhysicalServer, ServerFeatureEnum __all__ = [ "ServerList", "Country", "LogicalServer", "PhysicalServer", "ServerFeatureEnum", ] python-proton-vpn-api-core-0.39.0/proton/vpn/session/servers/country_codes.py000066400000000000000000000155331473026673700275210ustar00rootroot00000000000000""" Translates country codes to country names. Copyright (c) 2023 Proton AG This file is part of Proton VPN. Proton VPN 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. Proton VPN 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 ProtonVPN. If not, see . """ def get_country_name_by_code(country_code: str): """Returns country name based on provided country code.""" country_name = country_codes.get(country_code.upper(), None) # If the country name was not found then default to the country code. return country_name or country_code country_codes = { "BD": "Bangladesh", "BE": "Belgium", "BF": "Burkina Faso", "BG": "Bulgaria", "BA": "Bosnia and Herzegovina", "BB": "Barbados", "WF": "Wallis and Futuna", "BL": "Saint Barthelemy", "BM": "Bermuda", "BN": "Brunei", "BO": "Bolivia", "BH": "Bahrain", "BI": "Burundi", "BJ": "Benin", "BT": "Bhutan", "JM": "Jamaica", "BV": "Bouvet Island", "BW": "Botswana", "WS": "Samoa", "BQ": "Bonaire, Saint Eustatius and Saba ", "BR": "Brazil", "BS": "Bahamas", "JE": "Jersey", "BY": "Belarus", "BZ": "Belize", "RU": "Russia", "RW": "Rwanda", "RS": "Serbia", "TL": "East Timor", "RE": "Reunion", "TM": "Turkmenistan", "TJ": "Tajikistan", "RO": "Romania", "TK": "Tokelau", "GW": "Guinea-Bissau", "GU": "Guam", "GT": "Guatemala", "GS": "South Georgia and the South Sandwich Islands", "GR": "Greece", "GQ": "Equatorial Guinea", "GP": "Guadeloupe", "JP": "Japan", "GY": "Guyana", "GG": "Guernsey", "GF": "French Guiana", "GE": "Georgia", "GD": "Grenada", "UK": "United Kingdom", "GA": "Gabon", "SV": "El Salvador", "GN": "Guinea", "GM": "Gambia", "GL": "Greenland", "GI": "Gibraltar", "GH": "Ghana", "OM": "Oman", "TN": "Tunisia", "JO": "Jordan", "HR": "Croatia", "HT": "Haiti", "HU": "Hungary", "HK": "Hong Kong", "HN": "Honduras", "HM": "Heard Island and McDonald Islands", "VE": "Venezuela", "PR": "Puerto Rico", "PS": "Palestinian Territory", "PW": "Palau", "PT": "Portugal", "SJ": "Svalbard and Jan Mayen", "PY": "Paraguay", "IQ": "Iraq", "PA": "Panama", "PF": "French Polynesia", "PG": "Papua New Guinea", "PE": "Peru", "PK": "Pakistan", "PH": "Philippines", "PN": "Pitcairn", "PL": "Poland", "PM": "Saint Pierre and Miquelon", "ZM": "Zambia", "EH": "Western Sahara", "EE": "Estonia", "EG": "Egypt", "ZA": "South Africa", "EC": "Ecuador", "IT": "Italy", "VN": "Vietnam", "SB": "Solomon Islands", "ET": "Ethiopia", "SO": "Somalia", "ZW": "Zimbabwe", "SA": "Saudi Arabia", "ES": "Spain", "ER": "Eritrea", "ME": "Montenegro", "MD": "Moldova", "MG": "Madagascar", "MF": "Saint Martin", "MA": "Morocco", "MC": "Monaco", "UZ": "Uzbekistan", "MM": "Myanmar", "ML": "Mali", "MO": "Macao", "MN": "Mongolia", "MH": "Marshall Islands", "MK": "Macedonia", "MU": "Mauritius", "MT": "Malta", "MW": "Malawi", "MV": "Maldives", "MQ": "Martinique", "MP": "Northern Mariana Islands", "MS": "Montserrat", "MR": "Mauritania", "IM": "Isle of Man", "UG": "Uganda", "TZ": "Tanzania", "MY": "Malaysia", "MX": "Mexico", "IL": "Israel", "FR": "France", "IO": "British Indian Ocean Territory", "SH": "Saint Helena", "FI": "Finland", "FJ": "Fiji", "FK": "Falkland Islands", "FM": "Micronesia", "FO": "Faroe Islands", "NI": "Nicaragua", "NL": "Netherlands", "NO": "Norway", "NA": "Namibia", "VU": "Vanuatu", "NC": "New Caledonia", "NE": "Niger", "NF": "Norfolk Island", "NG": "Nigeria", "NZ": "New Zealand", "NP": "Nepal", "NR": "Nauru", "NU": "Niue", "CK": "Cook Islands", "XK": "Kosovo", "CI": "Ivory Coast", "CH": "Switzerland", "CO": "Colombia", "CN": "China", "CM": "Cameroon", "CL": "Chile", "CC": "Cocos Islands", "CA": "Canada", "CG": "Republic of the Congo", "CF": "Central African Republic", "CD": "Democratic Republic of the Congo", "CZ": "Czech Republic", "CY": "Cyprus", "CX": "Christmas Island", "CR": "Costa Rica", "CW": "Curacao", "CV": "Cape Verde", "CU": "Cuba", "SZ": "Swaziland", "SY": "Syria", "SX": "Sint Maarten", "KG": "Kyrgyzstan", "KE": "Kenya", "SS": "South Sudan", "SR": "Suriname", "KI": "Kiribati", "KH": "Cambodia", "KN": "Saint Kitts and Nevis", "KM": "Comoros", "ST": "Sao Tome and Principe", "SK": "Slovakia", "KR": "South Korea", "SI": "Slovenia", "KP": "North Korea", "KW": "Kuwait", "SN": "Senegal", "SM": "San Marino", "SL": "Sierra Leone", "SC": "Seychelles", "KZ": "Kazakhstan", "KY": "Cayman Islands", "SG": "Singapore", "SE": "Sweden", "SD": "Sudan", "DO": "Dominican Republic", "DM": "Dominica", "DJ": "Djibouti", "DK": "Denmark", "VG": "British Virgin Islands", "DE": "Germany", "YE": "Yemen", "DZ": "Algeria", "US": "United States", "UY": "Uruguay", "YT": "Mayotte", "UM": "United States Minor Outlying Islands", "LB": "Lebanon", "LC": "Saint Lucia", "LA": "Laos", "TV": "Tuvalu", "TW": "Taiwan", "TT": "Trinidad and Tobago", "TR": "Turkey", "LK": "Sri Lanka", "LI": "Liechtenstein", "LV": "Latvia", "TO": "Tonga", "LT": "Lithuania", "LU": "Luxembourg", "LR": "Liberia", "LS": "Lesotho", "TH": "Thailand", "TF": "French Southern Territories", "TG": "Togo", "TD": "Chad", "TC": "Turks and Caicos Islands", "LY": "Libya", "VA": "Vatican", "VC": "Saint Vincent and the Grenadines", "AE": "United Arab Emirates", "AD": "Andorra", "AG": "Antigua and Barbuda", "AF": "Afghanistan", "AI": "Anguilla", "VI": "U.S. Virgin Islands", "IS": "Iceland", "IR": "Iran", "AM": "Armenia", "AL": "Albania", "AO": "Angola", "AQ": "Antarctica", "AS": "American Samoa", "AR": "Argentina", "AU": "Australia", "AT": "Austria", "AW": "Aruba", "IN": "India", "AX": "Aland Islands", "AZ": "Azerbaijan", "IE": "Ireland", "ID": "Indonesia", "UA": "Ukraine", "QA": "Qatar", "MZ": "Mozambique" } python-proton-vpn-api-core-0.39.0/proton/vpn/session/servers/fetcher.py000066400000000000000000000153761473026673700262660ustar00rootroot00000000000000""" Copyright (c) 2023 Proton AG This file is part of Proton VPN. Proton VPN 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. Proton VPN 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 ProtonVPN. If not, see . """ from pathlib import Path from typing import Optional, TYPE_CHECKING import re from proton.utils.environment import VPNExecutionEnvironment from proton.vpn.core.cache_handler import CacheHandler from proton.vpn.session.exceptions import ServerListDecodeError from proton.vpn.session.servers.types import ServerLoad from proton.vpn.session.servers.logicals import ServerList, PersistenceKeys from proton.vpn.session.utils import rest_api_request if TYPE_CHECKING: from proton.vpn.session import VPNSession NETZONE_HEADER = "X-PM-netzone" MODIFIED_SINCE_HEADER = "If-Modified-Since" LAST_MODIFIED_HEADER = "Last-Modified" NOT_MODIFIED_STATUS = 304 # Feature flags FF_TIMESTAMPEDLOGICALS = "TimestampedLogicals" class ServerListFetcher: """Fetches the server list either from disk or from the REST API.""" ROUTE_LOGICALS = "/vpn/v1/logicals?SecureCoreFilter=all" ROUTE_LOADS = "/vpn/v1/loads" CACHE_PATH = Path(VPNExecutionEnvironment().path_cache) / "serverlist.json" """Fetches and caches the list of VPN servers from the REST API.""" def __init__( self, session: "VPNSession", server_list: Optional[ServerList] = None, cache_file: Optional[CacheHandler] = None ): self._session = session self._server_list = server_list self._cache_file = cache_file or CacheHandler(self.CACHE_PATH) def clear_cache(self): """Discards the cache, if existing.""" self._server_list = None self._cache_file.remove() async def fetch_old(self) -> ServerList: """Fetches the list of VPN servers. Warning: this is a heavy request.""" response = await rest_api_request( self._session, self.ROUTE_LOGICALS, additional_headers={ NETZONE_HEADER: self._build_header_netzone(), }, ) response[PersistenceKeys.USER_TIER.value] = self._session.vpn_account.max_tier response[PersistenceKeys.EXPIRATION_TIME.value] = ServerList.get_expiration_time() response[ PersistenceKeys.LOADS_EXPIRATION_TIME.value ] = ServerList.get_loads_expiration_time() self._cache_file.save(response) self._server_list = ServerList.from_dict(response) return self._server_list async def fetch_new(self) -> ServerList: """Fetches the list of VPN servers. Warning: this is a heavy request.""" raw_response = await rest_api_request( self._session, self.ROUTE_LOGICALS, additional_headers=self._build_additional_headers( include_modified_since=True), return_raw=True ) if raw_response.status_code == NOT_MODIFIED_STATUS: response = self._server_list.to_dict() else: response = raw_response.json entries_to_update = { PersistenceKeys.USER_TIER.value: self._session.vpn_account.max_tier, PersistenceKeys.LAST_MODIFIED_TIME.value: raw_response.find_first_header( LAST_MODIFIED_HEADER, ServerList.get_epoch_time()), PersistenceKeys.EXPIRATION_TIME.value: ServerList.get_expiration_time(), PersistenceKeys.LOADS_EXPIRATION_TIME.value: ServerList.get_loads_expiration_time() } response.update(entries_to_update) self._cache_file.save(response) self._server_list = ServerList.from_dict(response) return self._server_list async def fetch(self) -> ServerList: """Fetches the list of VPN servers. Warning: this is a heavy request.""" if self._session.feature_flags.get(FF_TIMESTAMPEDLOGICALS): return await self.fetch_new() return await self.fetch_old() async def update_loads(self) -> ServerList: """ Fetches the server loads from the REST API and updates the current server list with them.""" if not self._server_list: raise RuntimeError( "Server loads can only be updated after fetching the the full server list." ) response = await rest_api_request( self._session, self.ROUTE_LOADS, additional_headers=self._build_additional_headers(), ) server_loads = [ServerLoad(data) for data in response["LogicalServers"]] self._server_list.update(server_loads) self._cache_file.save(self._server_list.to_dict()) return self._server_list def load_from_cache(self) -> ServerList: """ Loads and returns the server list that was last persisted to the cache. :returns: the server list loaded from cache. :raises ServerListDecodeError: if the cache is not found or if the data stored in the cache is not valid. """ cache = self._cache_file.load() if not cache: raise ServerListDecodeError("Cached server list was not found") self._server_list = ServerList.from_dict(cache) return self._server_list def _build_header_netzone(self): truncated_ip_address = truncate_ip_address( self._session.vpn_account.location.IP ) return truncated_ip_address def _build_additional_headers(self, include_modified_since: bool = False): headers = {} headers[NETZONE_HEADER] = self._build_header_netzone() if include_modified_since: server_list = self._server_list if server_list: headers[MODIFIED_SINCE_HEADER] = server_list.last_modified_time else: headers[MODIFIED_SINCE_HEADER] = ServerList.get_epoch_time() return headers def truncate_ip_address(ip_address: str) -> str: """ Truncates the last octet of the specified IP address and returns it. """ match = re.match("(\\d+\\.\\d+\\.\\d+)\\.\\d+", ip_address) if not match: raise ValueError(f"Invalid IPv4 address: {ip_address}") # Replace the last byte with a zero to truncate the IP. truncated_ip = f"{match[1]}.0" return truncated_ip python-proton-vpn-api-core-0.39.0/proton/vpn/session/servers/logicals.py000066400000000000000000000316161473026673700264360ustar00rootroot00000000000000""" Copyright (c) 2023 Proton AG This file is part of Proton VPN. Proton VPN 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. Proton VPN 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 ProtonVPN. If not, see . """ from __future__ import annotations import itertools import random import time from enum import Enum from typing import Optional, List, Callable from proton.vpn import logging from proton.vpn.session.dataclasses.servers import Country from proton.vpn.session.exceptions import ServerNotFoundError, ServerListDecodeError from proton.vpn.session.servers.types import LogicalServer, \ TierEnum, ServerFeatureEnum, ServerLoad logger = logging.getLogger(__name__) UNIX_EPOCH = "Thu, 01 Jan 1970 00:00:00 GMT" class PersistenceKeys(Enum): """JSON Keys used to persist the ServerList to disk.""" LOGICALS = "LogicalServers" EXPIRATION_TIME = "ExpirationTime" LOADS_EXPIRATION_TIME = "LoadsExpirationTime" LAST_MODIFIED_TIME = "LastModifiedTime" USER_TIER = "MaxTier" class ServerList: # pylint: disable=too-many-public-methods """ Server list model class. """ LOGICALS_REFRESH_INTERVAL = 3 * 60 * 60 # 3 hours LOADS_REFRESH_INTERVAL = 15 * 60 # 15 minutes in seconds REFRESH_RANDOMNESS = 0.22 # +/- 22% """ Wrapper around a list of logical servers. """ def __init__( self, user_tier: TierEnum, logicals: Optional[List[LogicalServer]] = None, expiration_time: Optional[int] = None, loads_expiration_time: Optional[int] = None, index_servers: bool = True, last_modified_time: Optional[str] = None ): # pylint: disable=too-many-arguments self._user_tier = user_tier self._logicals = logicals or [] self._expiration_time = expiration_time if expiration_time is not None\ else ServerList.get_expiration_time() self._loads_expiration_time = loads_expiration_time if loads_expiration_time is not None\ else ServerList.get_loads_expiration_time() self._last_modified_time = last_modified_time or ServerList.get_epoch_time() if index_servers: self._logicals_by_id, self._logicals_by_name = self._build_indexes(logicals) else: self._logicals_by_id = None self._logicals_by_name = None @staticmethod def _build_indexes(logicals): logicals_by_id = {} logicals_by_name = {} for logical_server in logicals: logicals_by_id[logical_server.id] = logical_server logicals_by_name[logical_server.name] = logical_server return logicals_by_id, logicals_by_name @property def user_tier(self) -> TierEnum: """Tier of the user that requested the server list.""" return self._user_tier @property def logicals(self) -> List[LogicalServer]: """The internal list of logical servers.""" return self._logicals @property def expiration_time(self) -> float: """The expiration time of the server list as a unix timestamp.""" return self._expiration_time @property def expired(self) -> bool: """ Returns whether the server list expired, and therefore should be downloaded again, or not. """ return time.time() > self._expiration_time @property def loads_expiration_time(self) -> float: """The expiration time of the server loads as a unix timestamp.""" return self._loads_expiration_time @property def loads_expired(self) -> bool: """ Returns whether the server list loads expired, and therefore should be updated, or not. """ return time.time() > self._loads_expiration_time @property def last_modified_time(self) -> str: """The time at which the server list was fetched.""" return self._last_modified_time def update(self, server_loads: List[ServerLoad]): """Updates the server list with new server loads.""" try: for server_load in server_loads: try: logical_server = self.get_by_id(server_load.id) logical_server.update(server_load) except ServerNotFoundError: # Currently /vpn/loads returns some extra servers not returned by /vpn/logicals logger.debug(f"Logical server was not found for update: {server_load}") finally: # If something unexpected happens when updating the server loads # it's safer to always update the loads expiration time to avoid # clients potentially retrying in a loop. self._loads_expiration_time = ServerList.get_loads_expiration_time() @property def seconds_until_expiration(self) -> float: """ Amount of seconds left until the server list is considered outdated. The server list is considered outdated when - the full server list expires or - the server loads expire, whatever is the closest. """ secs_until_full_expiration = max(self.expiration_time - time.time(), 0) secs_until_loads_expiration = max(self.loads_expiration_time - time.time(), 0) return min(secs_until_full_expiration, secs_until_loads_expiration) def get_by_id(self, server_id: str) -> LogicalServer: """ :returns: the logical server with the given id. :raises ServerNotFoundError: if there is not a server with a matching id. """ if self._logicals_by_id is None: raise RuntimeError("The server list was not indexed.") try: return self._logicals_by_id[server_id] except KeyError as error: raise ServerNotFoundError( f"The server with {server_id=} was not found" ) from error def get_by_name(self, name: str) -> LogicalServer: """ :returns: the logical server with the given name. :raises ServerNotFoundError: if there is not a server with a matching name. """ if self._logicals_by_name is None: raise RuntimeError("The server list was not indexed.") try: return self._logicals_by_name[name] except KeyError as error: raise ServerNotFoundError( f"The server with {name=} was not found" ) from error def get_fastest_in_country(self, country_code: str) -> LogicalServer: """ :returns: the fastest server in the specified country and the tiers the user has access to. """ country_servers = [ server for server in self.logicals if server.exit_country.lower() == country_code.lower() ] return ServerList( self.user_tier, country_servers, index_servers=False ).get_fastest() def get_fastest(self) -> LogicalServer: """:returns: the fastest server in the tiers the user has access to.""" available_servers = [ server for server in self.logicals if ( server.enabled and server.tier <= self.user_tier and ServerFeatureEnum.SECURE_CORE not in server.features and ServerFeatureEnum.TOR not in server.features ) ] if not available_servers: raise ServerNotFoundError("No server available in the current tier") return sorted(available_servers, key=lambda server: server.score)[0] def group_by_country(self) -> List[Country]: """ Returns the servers grouped by country. Before grouping the servers, they are sorted alphabetically by country name and server name. :return: The list of countries, each of them containing the servers in that country. """ self.logicals.sort(key=sort_servers_alphabetically_by_country_and_server_name) return [ Country(country_code, list(country_servers)) for country_code, country_servers in itertools.groupby( self.logicals, lambda server: server.exit_country.lower() ) ] @classmethod def _generate_random_component(cls): # 1 +/- 0.22*random # nosec B311 return 1 + cls.REFRESH_RANDOMNESS * (2 * random.random() - 1) # nosec B311 # noqa: E501 # pylint: disable=line-too-long # nosemgrep: gitlab.bandit.B311 @classmethod def get_expiration_time(cls, start_time: int = None): """Returns the unix time at which the whole server list expires.""" start_time = start_time if start_time is not None else time.time() return start_time + cls._get_refresh_interval_in_seconds() @classmethod def get_epoch_time(cls) -> str: """Returns the default fetch time in UTC which is the unix epoch. In the format of If-Modified-Since header which is , :: GMT """ return UNIX_EPOCH @classmethod def _get_refresh_interval_in_seconds(cls): return cls.LOGICALS_REFRESH_INTERVAL * cls._generate_random_component() @classmethod def get_loads_expiration_time(cls, start_time: int = None): """ Generates the unix time at which the server loads will expire. """ start_time = start_time if start_time is not None else time.time() return start_time + cls.get_loads_refresh_interval_in_seconds() @classmethod def get_loads_refresh_interval_in_seconds(cls) -> float: """ Calculates the amount of seconds to wait before the server list should be fetched again from the REST API. """ return cls.LOADS_REFRESH_INTERVAL * cls._generate_random_component() @classmethod def from_dict( cls, data: dict ): """ :returns: the server list built from the given dictionary. """ try: user_tier = data[PersistenceKeys.USER_TIER.value] logicals = [LogicalServer(logical_dict) for logical_dict in data["LogicalServers"]] except KeyError as error: raise ServerListDecodeError("Error building server list from dict") from error expiration_time = data.get( PersistenceKeys.EXPIRATION_TIME.value, cls.get_expiration_time() ) loads_expiration_time = data.get( PersistenceKeys.LOADS_EXPIRATION_TIME.value, cls.get_loads_expiration_time() ) last_modified_time = data.get(PersistenceKeys.LAST_MODIFIED_TIME.value, ServerList.get_epoch_time()) return ServerList( user_tier=user_tier, logicals=logicals, expiration_time=expiration_time, loads_expiration_time=loads_expiration_time, last_modified_time=last_modified_time ) def to_dict(self) -> dict: """:returns: the server list instance converted back to a dictionary.""" return { PersistenceKeys.LOGICALS.value: [logical.to_dict() for logical in self.logicals], PersistenceKeys.EXPIRATION_TIME.value: self.expiration_time, PersistenceKeys.LOADS_EXPIRATION_TIME.value: self.loads_expiration_time, PersistenceKeys.LAST_MODIFIED_TIME.value: self.last_modified_time, PersistenceKeys.USER_TIER.value: self._user_tier } def __len__(self): return len(self.logicals) def __iter__(self): yield from self.logicals def __getitem__(self, item): return self.logicals[item] def sort(self, key: Callable = None): """See List.sort().""" key = key or sort_servers_alphabetically_by_country_and_server_name self.logicals.sort(key=key) def sort_servers_alphabetically_by_country_and_server_name(server: LogicalServer) -> str: """ Returns the comparison key used to sort servers alphabetically, first by exit country name and then by server name. If the server name is in the form of COUNTRY-CODE#NUMBER, then NUMBER is padded with zeros to be able to sort the server name in natural sort order. """ country_name = server.exit_country_name server_name = server.name or "" server_name = server_name.lower() if "#" in server_name: # Pad server number with zeros to achieve natural sorting server_name = f"{server_name.split('#')[0]}#" \ f"{server_name.split('#')[1].zfill(10)}" return f"{country_name}__{server_name}" python-proton-vpn-api-core-0.39.0/proton/vpn/session/servers/types.py000066400000000000000000000234321473026673700260020ustar00rootroot00000000000000""" Copyright (c) 2023 Proton AG This file is part of Proton VPN. Proton VPN 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. Proton VPN 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 ProtonVPN. If not, see . """ from __future__ import annotations import random from enum import IntFlag from typing import List, Dict from proton.vpn.session.exceptions import ServerNotFoundError from proton.vpn.session.servers.country_codes import get_country_name_by_code class TierEnum(IntFlag): """Contains the tiers used throughout the clients. The tier either block or unblock certain features and/or servers/countries. """ FREE = 0 PLUS = 2 PM = 3 # "implicit-flag-alias" has been added in 2.17.5, anything lower will throw an error. class ServerFeatureEnum(IntFlag): """ A Class representing the Server features as encoded in the feature flags field of the API: """ SECURE_CORE = 1 << 0 # 1 TOR = 1 << 1 # 2 P2P = 1 << 2 # 4 STREAMING = 1 << 3 # 8 IPV6 = 1 << 4 # 16 class PhysicalServer: """ A physical server instance contains the network information to initiate a VPN connection to the server. """ def __init__(self, data: Dict): self._data = data @property def id(self) -> str: # pylint: disable=invalid-name """Returns the physical ID of the server.""" return self._data.get("ID") @property def entry_ip(self) -> str: """Returns the IP of the entered server.""" return self._data.get("EntryIP") @property def exit_ip(self) -> str: """Returns the IP of the exited server. If you want to display to which IP a user is connected then use this one. """ return self._data.get("ExitIP") @property def domain(self) -> str: """Returns the Domain of the connected server. This is usually used for TLS Authentication. """ return self._data.get("Domain") @property def enabled(self) -> bool: """Returns if the server is enabled or not""" return self._data.get("Status") == 1 @property def generation(self) -> str: """Returns the generation of the server.""" return self._data.get("Generation") @property def label(self) -> str: """Returns the label value. If label is passed then it ensures that the `ExitIP` matches exactly to the server that we're connected. """ return self._data.get("Label") @property def services_down_reason(self) -> str: """Returns the reason of why the servers are down.""" return self._data.get("ServicesDownReason") @property def x25519_pk(self) -> str: """ X25519 public key of the physical available as a base64 encoded string. """ return self._data.get("X25519PublicKey") def __repr__(self): if self.label != '': return f'PhysicalServer<{self.domain}+b:{self.label}>' return f'PhysicalServer<{self.domain}>' class LogicalServer: # pylint: disable=too-many-public-methods """ Abstraction of a VPN server. One logical servers abstract one or more PhysicalServer instances away. """ def __init__(self, data: Dict): self._data = data def update(self, server_load: ServerLoad): """Internally updates the logical server: * Load * Score * Status """ if self.id != server_load.id: raise ValueError( "The id of the logical server does not match the one of " "the server load object" ) self._data["Load"] = server_load.load self._data["Score"] = server_load.score self._data["Status"] = 1 if server_load.enabled else 0 @property def id(self) -> str: # pylint: disable=invalid-name """Returns the id of the logical server.""" return self._data.get("ID") # Score, load and status can be modified (needed to update loads) @property def load(self) -> int: """Returns the load of the servers. This is generally only used for UI purposes. """ return self._data.get("Load") @property def score(self) -> float: """Returns the score of the server. The score is automatically calculated by the API and is used for the logic of the "Quick Connect". The lower the number is the better is for establishing a connection. """ return self._data.get("Score") @property def enabled(self) -> bool: """Returns if the server is enabled or not. Usually the API should return 0 if all physical servers are not enabled, but just to be sure we also evaluate all physical servers. """ return self._data.get("Status") == 1 and any( x.enabled for x in self.physical_servers ) # Every other propriety is readonly @property def name(self) -> str: """Name of the logical, ie: CH#10""" return self._data.get("Name") @property def entry_country(self) -> str: """2 letter country code entry, ie: CH""" return self._data.get("EntryCountry") @property def entry_country_name(self) -> str: """Full name of the entry country (e.g. Switzerland).""" return get_country_name_by_code(self.entry_country) @property def exit_country(self) -> str: """2 letter country code exit, ie: CH""" return self._data.get("ExitCountry") @property def exit_country_name(self) -> str: """Full name of the exit country (e.g. Argentina).""" return get_country_name_by_code(self.exit_country) @property def host_country(self) -> str: """2 letter country code host: CH. If there is a host country then it means that this server location is emulated, see Smart Routing definition for further clarification. """ return self._data.get("HostCountry") @property def features(self) -> List[ServerFeatureEnum]: """ List of features supported by this Logical.""" return self.__unpack_bitmap_features(self._data.get("Features", 0)) def __unpack_bitmap_features(self, server_value): server_features = [ feature_enum for feature_enum in ServerFeatureEnum if (server_value & feature_enum) != 0 ] return server_features @property def region(self) -> str: """Returns the region of the server.""" return self._data.get("Region") @property def city(self) -> str: """Returns the city of the server.""" return self._data.get("City") @property def tier(self) -> int: """Returns the minimum required tier to be able to establish a connection. Server-side check is always done, so this is mainly for UI purposes. """ return TierEnum(int(self._data.get("Tier"))) @property def latitude(self) -> float: """Returns servers latitude.""" return self._data.get("Location", {}).get("Lat") @property def longitude(self) -> float: """Returns servers longitude.""" return self._data.get("Location", {}).get("Long") @property def data(self) -> dict: """Returns a copy of the data pertaining this server.""" return self._data.copy() @property def physical_servers(self) -> List[PhysicalServer]: """ Get all the physicals of supporting a logical """ return [PhysicalServer(x) for x in self._data.get("Servers", [])] def get_random_physical_server(self) -> PhysicalServer: """ Get a random `enabled` physical linked to this logical """ enabled_servers = [x for x in self.physical_servers if x.enabled] if len(enabled_servers) == 0: raise ServerNotFoundError("No physical servers could be found") return random.choice(enabled_servers) # nosec B311 # noqa: E501 # pylint: disable=line-too-long # nosemgrep: gitlab.bandit.B311 def to_dict(self) -> Dict: """Converts this object to a dictionary for serialization purposes.""" return self._data def __repr__(self): return f'LogicalServer<{self._data.get("Name", "??")}>' class ServerLoad: """Contains data about logical servers to be updated frequently. """ def __init__(self, data: Dict): self._data = data @property def id(self) -> str: # pylint: disable=invalid-name """Returns the id of the logical server.""" return self._data.get("ID") @property def load(self) -> int: """Returns the load of the servers. This is generally only used for UI purposes. """ return self._data.get("Load") @property def score(self) -> float: """Returns the score of the server. The score is automatically calculated by the API and is used for the logic of the "Quick Connect". The lower the number is the better is for establishing a connection. """ return self._data.get("Score") @property def enabled(self) -> bool: """Returns if the server is enabled or not. """ return self._data.get("Status") == 1 def __str__(self): return str(self._data) python-proton-vpn-api-core-0.39.0/proton/vpn/session/session.py000066400000000000000000000332571473026673700246360ustar00rootroot00000000000000""" Copyright (c) 2023 Proton AG This file is part of Proton VPN. Proton VPN 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. Proton VPN 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 ProtonVPN. If not, see . """ import asyncio from os.path import basename from typing import Optional from proton.session import Session, FormData, FormField from proton.vpn import logging from proton.vpn.session.account import VPNAccount from proton.vpn.session.fetcher import VPNSessionFetcher from proton.vpn.session.client_config import ClientConfig from proton.vpn.session.credentials import VPNSecrets from proton.vpn.session.dataclasses import LoginResult, BugReportForm, VPNCertificate, VPNLocation from proton.vpn.session.servers.logicals import ServerList from proton.vpn.session.feature_flags_fetcher import FeatureFlags logger = logging.getLogger(__name__) class VPNSession(Session): """ Augmented Session that provides helpers to a persistent offline keyring access to user account information available from the PROTON VPN REST API. Usage example: .. code-block:: from proton.vpn.session import VPNSession from proton.sso import ProtonSSO sso = ProtonSSO() session=sso.get_session(username, override_class=VPNSession) session.authenticate('USERNAME','PASSWORD') if session.authenticated: pubkey_credentials = session.vpn_account.vpn_credentials.pubkey_credentials wireguard_private_key = pubkey_credentials.wg_private_key api_pem_certificate = pubkey_credentials.certificate_pem """ BUG_REPORT_ENDPOINT = "/core/v4/reports/bug" def __init__( self, *args, fetcher: Optional[VPNSessionFetcher] = None, vpn_account: Optional[VPNAccount] = None, server_list: Optional[ServerList] = None, client_config: Optional[ClientConfig] = None, feature_flags: Optional[FeatureFlags] = None, **kwargs ): # pylint: disable=too-many-arguments self._fetcher = fetcher or VPNSessionFetcher(session=self) self._vpn_account = vpn_account self._server_list = server_list self._client_config = client_config self._feature_flags = feature_flags super().__init__(*args, **kwargs) @property def loaded(self) -> bool: """:returns: whether the VPN session data was already loaded or not.""" return self._vpn_account and self._server_list and self._client_config def __setstate__(self, data): """This method is called when deserializing the session from the keyring.""" try: if 'vpn' in data: self._vpn_account = VPNAccount.from_dict(data['vpn']) # Some session data like the server list is not deserialized from the keyring data, # but from plain json file due to its size. self._server_list = self._fetcher.load_server_list_from_cache() self._client_config = self._fetcher.load_client_config_from_cache() self._feature_flags = self._fetcher.load_feature_flags_from_cache() except ValueError: logger.warning("VPN session could not be deserialized.", exc_info=True) super().__setstate__(data) def __getstate__(self): """This method is called to retrieve the session data to be serialized in the keyring.""" state = super().__getstate__() if state and self._vpn_account: state['vpn'] = self._vpn_account.to_dict() # Note the server list is not persisted to the keyring return state async def login(self, username: str, password: str) -> LoginResult: """ Logs the user in. :returns: the login result, indicating whether it was successful and whether 2FA is required or not. """ if self.logged_in: return LoginResult(success=True, authenticated=True, twofa_required=False) if not await self.async_authenticate(username, password): return LoginResult(success=False, authenticated=False, twofa_required=False) if self.needs_twofa: return LoginResult(success=False, authenticated=True, twofa_required=True) return LoginResult(success=True, authenticated=True, twofa_required=False) async def provide_2fa(self, code: str) -> LoginResult: # pylint: disable=arguments-differ # noqa: E501 """ Submits the 2FA code. :returns: whether the 2FA was successful or not. """ valid_code = await super().async_provide_2fa(code) if not valid_code: return LoginResult(success=False, authenticated=True, twofa_required=True) return LoginResult(success=True, authenticated=True, twofa_required=False) async def logout(self, no_condition_check=False, additional_headers=None) -> bool: """ Log out and reset session data. """ result = await super().async_logout(no_condition_check, additional_headers) self._vpn_account = None self._server_list = None self._client_config = None self._feature_flags = None self._fetcher.clear_cache() return result @property def logged_in(self) -> bool: """ :returns: whether the user already logged in or not. """ return self.authenticated and not self.needs_twofa async def fetch_session_data(self, features: Optional[dict] = None): """ Fetches the required session data from Proton's REST APIs. """ # We have to use `no_condition_check=True` with `_requests_lock` # because otherwise all requests after that will be blocked # until the lock created by `_requests_lock` is released. # Since the previous lock is only released at the end of the try/except/finally the # requests will never be executed, thus blocking and never releasing the lock. # Each request in `proton.session.api.Session` already creates and holds the lock by itself, # but the problem here is that we want to add additional data to be stored to the keyring. # Thus we need to resort to some manual # triggering of `_requests_lock` and `_requests_unlock`. # The former caches keyring data to memory while the latter does three different things: # 1. It checks if the new data is different from the old one # 2. If they are different then it proceeds to delete old one from keyring # 3. Add new data to the keyring # So if we want to add additional data to the keyring, as in VPN relevant data, # we must ensure that we always call `_requests_unlock()` after any requests # because this is currently the only way to store data that is attached # to a specific account. # So the consequence for passing `no_condition_check=True` is that the keyring data will # not get cached to memory, for later to be compared (as previously described). # This means that later when the comparison will be made, the "old" data will just be empty, # forcing it to always be replaced by the new data to keyring. Thus this solution is just a # temporary hack until a better approach is found. # For further clarification on how these methods see the following, in the specified order: # `proton.session.api.Session._requests_lock` # `proton.sso.sso.ProtonSSO._acquire_session_lock` # `proton.session.api.Session._requests_unlock` # `proton.sso.sso.ProtonSSO._release_session_lock` self._requests_lock(no_condition_check=True) try: secrets = ( VPNSecrets( ed25519_privatekey=self._vpn_account.vpn_credentials .pubkey_credentials.ed_255519_private_key ) if self._vpn_account else VPNSecrets() ) vpninfo, certificate, location, client_config = await asyncio.gather( self._fetcher.fetch_vpn_info(), self._fetcher.fetch_certificate( client_public_key=secrets.ed25519_pk_pem, features=features), self._fetcher.fetch_location(), self._fetcher.fetch_client_config(), ) self._vpn_account = VPNAccount( vpninfo=vpninfo, certificate=certificate, secrets=secrets, location=location ) self._client_config = client_config # The feature flags must be fetched before the server list, # since the server list can be fetched differently depending on # what feature flags are enabled. self._feature_flags = await self._fetcher.fetch_feature_flags() # The server list should be retrieved after the VPNAccount object # has been created, since it requires the location, and it should # be retrieved after the feature flags have been fetched, since it # depends in them for chosing the fetch method. self._server_list = await self._fetcher.fetch_server_list() finally: # IMPORTANT: apart from releasing the lock, _requests_unlock triggers the # serialization of the session to the keyring. self._requests_unlock() async def fetch_certificate(self, features: Optional[dict] = None) -> VPNCertificate: """Fetches new certificate from API.""" self._requests_lock(no_condition_check=True) try: secrets = ( VPNSecrets( ed25519_privatekey=self._vpn_account.vpn_credentials .pubkey_credentials.ed_255519_private_key ) ) new_certificate = await self._fetcher.fetch_certificate( client_public_key=secrets.ed25519_pk_pem, features=features ) self._vpn_account.set_certificate(new_certificate) return new_certificate finally: self._requests_unlock() @property def vpn_account(self) -> VPNAccount: """ Information related to the VPN user account. If it was not loaded yet then None is returned instead. """ return self._vpn_account def set_location(self, location: VPNLocation): """Set new location data and store it.""" self._requests_lock(no_condition_check=False) try: self._vpn_account.location = location finally: self._requests_unlock() async def fetch_server_list(self) -> ServerList: """ Fetches the server list from the REST API. """ self._server_list = await self._fetcher.fetch_server_list() return self._server_list @property def server_list(self) -> ServerList: """The current server list.""" return self._server_list async def update_server_loads(self) -> ServerList: """ Fetches the server loads from the REST API and updates the current server list with them. """ self._server_list = await self._fetcher.update_server_loads() return self._server_list async def fetch_client_config(self) -> ClientConfig: """Fetches the client configuration from the REST api.""" self._client_config = await self._fetcher.fetch_client_config() return self._client_config @property def client_config(self) -> ClientConfig: """The current client configuration.""" return self._client_config async def fetch_feature_flags(self) -> FeatureFlags: """Fetches API features that dictates which features are to be enabled or not.""" self._feature_flags = await self._fetcher.fetch_feature_flags() return self._feature_flags @property def feature_flags(self) -> FeatureFlags: """Fetches general client configuration to connect to VPN servers.""" return self._feature_flags async def submit_bug_report(self, bug_report: BugReportForm): """Submits a bug report to customer support.""" data = FormData() data.add(FormField(name="OS", value=bug_report.os)) data.add(FormField(name="OSVersion", value=bug_report.os_version)) data.add(FormField(name="Client", value=bug_report.client)) data.add(FormField(name="ClientVersion", value=bug_report.client_version)) data.add(FormField(name="ClientType", value=bug_report.client_type)) data.add(FormField(name="Title", value=bug_report.title)) data.add(FormField(name="Description", value=bug_report.description)) data.add(FormField(name="Username", value=bug_report.username)) data.add(FormField(name="Email", value=bug_report.email)) if self._vpn_account: location = self._vpn_account.location data.add(FormField(name="ISP", value=location.ISP)) data.add(FormField(name="Country", value=location.Country)) for i, attachment in enumerate(bug_report.attachments): data.add(FormField( name=f"Attachment-{i}", value=attachment, filename=basename(attachment.name) )) return await self.async_api_request( endpoint=VPNSession.BUG_REPORT_ENDPOINT, data=data ) python-proton-vpn-api-core-0.39.0/proton/vpn/session/utils.py000066400000000000000000000125631473026673700243100ustar00rootroot00000000000000""" Copyright (c) 2023 Proton AG This file is part of Proton VPN. Proton VPN 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. Proton VPN 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 ProtonVPN. If not, see . """ import re from typing import Optional import time import random import os as sys_os import json from dataclasses import asdict import distro from proton.vpn import logging logger = logging.getLogger(__name__) class Serializable: # pylint: disable=missing-class-docstring """Utility class for dataclasses.""" def to_json(self) -> str: # pylint: disable=missing-function-docstring return json.dumps(asdict(self)) def to_dict(self) -> dict: # pylint: disable=missing-function-docstring return asdict(self) @classmethod def from_dict(cls, dict_data: dict) -> 'Serializable': # noqa: E501 pylint: disable=missing-function-docstring return cls._deserialize(dict_data) @classmethod def from_json(cls, data: str) -> 'Serializable': # pylint: disable=missing-function-docstring dict_data = json.loads(data) return cls._deserialize(dict_data) @staticmethod def _deserialize(dict_data: dict) -> 'Serializable': raise NotImplementedError class RefreshCalculator: """Calculates refresh times based on a set refresh randomness value.""" def __init__( self, refresh_interval: int, refresh_randomness_in_percentage: float = None ): """ The variable refresh_randomness_in_percentage will be used to create a deviation from original refresh value. Ie: 0.22 == 22% variation, so if we make request every 3h they will happen with random deviation between 0% and 22% from the base 3h value. """ self._refresh_interval = refresh_interval self._refresh_randomness = refresh_randomness_in_percentage or 0.22 @staticmethod def get_is_expired(expiration_time: float) -> bool: """Returns if data has expired""" current_time = time.time() return current_time > expiration_time @staticmethod def get_seconds_until_expiration(expiration_time: float) -> float: """ Amount of seconds left until the client configuration is considered outdated and should be fetched again from the REST API. """ seconds_left = expiration_time - time.time() return seconds_left if seconds_left > 0 else 0 @staticmethod def get_expiration_time( refresh_interval: int, refresh_randomness: float = None, start_time: float = None ) -> float: # noqa: E501 pylint: disable=missing-function-docstring """Returns the expiration time based on either a defined start time or current time.""" start_time = start_time if start_time is not None else time.time() refresh_calculator = RefreshCalculator(refresh_interval, refresh_randomness) return start_time + refresh_calculator.get_refresh_interval_in_seconds() def get_refresh_interval_in_seconds(self) -> float: # noqa pylint: disable=missing-function-docstring return self._refresh_interval * self._generate_random_component() def _generate_random_component(self): return 1 + self._refresh_randomness * (2 * random.random() - 1) # nosec B311 # noqa: E501 # pylint: disable=line-too-long # nosemgrep: gitlab.bandit.B311 async def rest_api_request(session, route, **api_request_kwargs): # noqa: E501 pylint: disable=missing-function-docstring logger.info(f"'{route}'", category="api", event="request") response = await session.async_api_request( route, **api_request_kwargs ) logger.info(f"'{route}'", category="api", event="response") return response def to_semver_build_metadata_format(value: Optional[str]) -> Optional[str]: """ Formats the input value in a format that complies with semver's build metadata specs (https://semver.org/#spec-item-10). """ if value is None: return None value = value.replace("_", "-") # Any character not allowed by semver's build metadata suffix # specs (https://semver.org/#spec-item-10) is removed. value = re.sub(r"[^a-zA-Z0-9\-]", "", value) return value def get_desktop_environment() -> str: """Returns the current desktop environment""" return sys_os.environ.get('XDG_CURRENT_DESKTOP', "Unknown DE") def get_distro_variant() -> str: """Returns the current distro environment""" distro_variant = distro.os_release_attr('variant') return f"; {distro_variant}" if distro_variant else "" def get_distro_version() -> str: """Returns the string containing the distro version: ie: - Fedora: "39"/"40" """ return distro.version() def generate_os_string() -> str: """Returns a string which contains information such as the distro, desktop environment and distro variant if it exists""" return f"{distro.id()} ({get_desktop_environment()}{get_distro_variant()})" python-proton-vpn-api-core-0.39.0/requirements.txt000066400000000000000000000000231473026673700222370ustar00rootroot00000000000000-e ".[development]"python-proton-vpn-api-core-0.39.0/rpmbuild/000077500000000000000000000000001473026673700205765ustar00rootroot00000000000000python-proton-vpn-api-core-0.39.0/rpmbuild/BUILD/000077500000000000000000000000001473026673700214355ustar00rootroot00000000000000python-proton-vpn-api-core-0.39.0/rpmbuild/BUILD/.gitkeep000066400000000000000000000000001473026673700230540ustar00rootroot00000000000000python-proton-vpn-api-core-0.39.0/rpmbuild/BUILDROOT/000077500000000000000000000000001473026673700221415ustar00rootroot00000000000000python-proton-vpn-api-core-0.39.0/rpmbuild/BUILDROOT/.gitkeep000066400000000000000000000000001473026673700235600ustar00rootroot00000000000000python-proton-vpn-api-core-0.39.0/rpmbuild/SOURCES/000077500000000000000000000000001473026673700217215ustar00rootroot00000000000000python-proton-vpn-api-core-0.39.0/rpmbuild/SOURCES/.gitkeep000066400000000000000000000000001473026673700233400ustar00rootroot00000000000000python-proton-vpn-api-core-0.39.0/rpmbuild/SPECS/000077500000000000000000000000001473026673700214535ustar00rootroot00000000000000python-proton-vpn-api-core-0.39.0/rpmbuild/SPECS/.gitkeep000066400000000000000000000000001473026673700230720ustar00rootroot00000000000000python-proton-vpn-api-core-0.39.0/rpmbuild/SPECS/package.spec.template000066400000000000000000000030631473026673700255360ustar00rootroot00000000000000%define unmangled_name proton-vpn-api-core %define version {version} %define release 1 Prefix: %{{_prefix}} Name: python3-%{{unmangled_name}} Version: %{{version}} Release: %{{release}}%{{?dist}} Summary: %{{unmangled_name}} library Group: ProtonVPN License: GPLv3 Vendor: Proton AG URL: https://github.com/ProtonVPN/%{{unmangled_name}} Source0: %{{unmangled_name}}-%{{version}}.tar.gz BuildArch: noarch BuildRoot: %{{_tmppath}}/%{{unmangled_name}}-%{{version}}-%{{release}}-buildroot BuildRequires: python3-proton-core BuildRequires: python3-setuptools BuildRequires: python3-distro BuildRequires: python3-sentry-sdk BuildRequires: python3-pynacl BuildRequires: python3-jinja2 Requires: python3-proton-core Requires: python3-distro Requires: python3-sentry-sdk Requires: python3-pynacl Requires: python3-jinja2 Conflicts: proton-vpn-gtk-app < 4.8.2~rc3 Conflicts: python3-proton-vpn-network-manager < 0.10.2 Obsoletes: python3-proton-vpn-session Obsoletes: python3-proton-vpn-connection Obsoletes: python3-proton-vpn-killswitch Obsoletes: python3-proton-vpn-logger %{{?python_disable_dependency_generator}} %description Package %{{unmangled_name}} library. %prep %setup -n %{{unmangled_name}}-%{{version}} -n %{{unmangled_name}}-%{{version}} %build python3 setup.py build %install python3 setup.py install --single-version-externally-managed -O1 --root=$RPM_BUILD_ROOT --record=INSTALLED_FILES %files -f INSTALLED_FILES %{{python3_sitelib}}/proton/ %{{python3_sitelib}}/proton_vpn_api_core-%{{version}}*.egg-info/ %defattr(-,root,root) %changelog python-proton-vpn-api-core-0.39.0/rpmbuild/SRPMS/000077500000000000000000000000001473026673700215025ustar00rootroot00000000000000python-proton-vpn-api-core-0.39.0/rpmbuild/SRPMS/.gitkeep000066400000000000000000000000001473026673700231210ustar00rootroot00000000000000python-proton-vpn-api-core-0.39.0/scripts/000077500000000000000000000000001473026673700204475ustar00rootroot00000000000000python-proton-vpn-api-core-0.39.0/scripts/create_changelogs.py000077500000000000000000000030621473026673700244620ustar00rootroot00000000000000#!/usr/bin/env python3 ''' This program generates a deb changelog file, and rpm spec file and a CHANGELOG.md file for this project. It reads versions.yml. ''' import os import yaml import devtools.versions as versions # The root of this repo ROOT = os.path.dirname( os.path.dirname(os.path.realpath(__file__)) ) NAME = "proton-vpn-api-core" # Name of this application. VERSIONS = os.path.join(ROOT, "versions.yml") # Name of this applications versions.yml RPM = os.path.join(ROOT, "rpmbuild", "SPECS", "package.spec") # Path of spec file for rpm. RPM_TMPLT = os.path.join(ROOT, "rpmbuild", "SPECS", "package.spec.template") # Path of template spec file for rpm. DEB = os.path.join(ROOT, "debian", "changelog") # Path of debian changelog. MARKDOWN = os.path.join(ROOT, "CHANGELOG.md",) # Path of CHANGELOG.md. def build(): ''' This is what generates the rpm spec, deb changelog and markdown CHANGELOG.md file. ''' with open(VERSIONS, encoding="utf-8") as versions_file: # Load versions.yml versions_yml = list(yaml.safe_load_all(versions_file)) # Validate the versions.yml file # # This is a lint of the versions.yml and catches errors # that might not be found in the changelog generation process versions.validate_versions(versions_yml) # Make our files versions.build_rpm(RPM, versions_yml, RPM_TMPLT) versions.build_deb(DEB, versions_yml, NAME) versions.build_mkd(MARKDOWN, versions_yml) if __name__ == "__main__": build() python-proton-vpn-api-core-0.39.0/scripts/devtools/000077500000000000000000000000001473026673700223065ustar00rootroot00000000000000python-proton-vpn-api-core-0.39.0/setup.cfg000066400000000000000000000002431473026673700206000ustar00rootroot00000000000000[flake8] ignore = C901, W503, E402 max-line-length = 120 [tool:pytest] addopts = --cov=proton/vpn/core/ --cov-report html --cov-report term testpaths = tests python-proton-vpn-api-core-0.39.0/setup.py000066400000000000000000000025001473026673700204670ustar00rootroot00000000000000#!/usr/bin/env python from setuptools import setup, find_namespace_packages import re VERSIONS = 'versions.yml' VERSION = re.search(r'version: (\S+)', open(VERSIONS, encoding='utf-8').readline()).group(1) setup( name="proton-vpn-api-core", version=VERSION, description="Proton AG VPN Core API", author="Proton AG", author_email="opensource@proton.me", url="https://github.com/ProtonVPN/python-proton-vpn-api-core", install_requires=[ "proton-core", "distro", "sentry-sdk", "cryptography", "PyNaCl", "distro", "jinja2" ], extras_require={ "development": ["pytest", "pytest-coverage", "pylint", "flake8", "pytest-asyncio", "PyYAML"] }, packages=find_namespace_packages(include=[ "proton.vpn.core*", "proton.vpn.connection*", "proton.vpn.killswitch.interface*", "proton.vpn.session*", "proton.vpn.logging*" ]), python_requires=">=3.9", license="GPLv3", platforms="Linux", classifiers=[ "Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", "License :: OSI Approved :: GNU General Public License v3 (GPLv3)", "Operating System :: POSIX :: Linux", "Programming Language :: Python :: 3", "Programming Language :: Python", "Topic :: Security", ] ) python-proton-vpn-api-core-0.39.0/tests/000077500000000000000000000000001473026673700201225ustar00rootroot00000000000000python-proton-vpn-api-core-0.39.0/tests/__init__.py000066400000000000000000000012461473026673700222360ustar00rootroot00000000000000""" Copyright (c) 2024 Proton AG This file is part of Proton VPN. Proton VPN 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. Proton VPN 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 ProtonVPN. If not, see . """ python-proton-vpn-api-core-0.39.0/tests/connection/000077500000000000000000000000001473026673700222615ustar00rootroot00000000000000python-proton-vpn-api-core-0.39.0/tests/connection/__init__.py000066400000000000000000000000001473026673700243600ustar00rootroot00000000000000python-proton-vpn-api-core-0.39.0/tests/connection/common.py000066400000000000000000000042451473026673700241300ustar00rootroot00000000000000""" Copyright (c) 2023 Proton AG This file is part of Proton VPN. Proton VPN 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. Proton VPN 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 ProtonVPN. If not, see . """ from unittest.mock import Mock from proton.vpn.connection.interfaces import (Settings, VPNCredentials, VPNPubkeyCredentials, VPNServer, VPNUserPassCredentials, Features) import pathlib import os from collections import namedtuple CWD = str(pathlib.Path(__file__).parent.absolute()) PERSISTANCE_CWD = os.path.join( CWD, "connection_persistence" ) OpenVPNPorts = namedtuple("OpenVPNPorts", "udp tcp") WireGuardPorts = namedtuple("WireGuardPorts", "udp tcp") class MalformedVPNCredentials: pass class MalformedVPNServer: pass class MockVPNPubkeyCredentials(VPNPubkeyCredentials): @property def certificate_pem(self): return "pem-cert" @property def wg_private_key(self): return "wg-private-key" @property def openvpn_private_key(self): return "ovpn-private-key" class MockVPNUserPassCredentials(VPNUserPassCredentials): @property def username(self): return "test-username" @property def password(self): return "test-password" class MockVpnCredentials(VPNCredentials): @property def pubkey_credentials(self): return MockVPNPubkeyCredentials() @property def userpass_credentials(self): return MockVPNUserPassCredentials() class MockSettings(Settings): @property def dns_custom_ips(self): return ["1.1.1.1", "10.10.10.10"] @property def features(self): return Mock() python-proton-vpn-api-core-0.39.0/tests/connection/test_events.py000066400000000000000000000035561473026673700252070ustar00rootroot00000000000000""" Copyright (c) 2023 Proton AG This file is part of Proton VPN. Proton VPN 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. Proton VPN 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 ProtonVPN. If not, see . """ from unittest.mock import Mock from proton.vpn.connection import events from proton.vpn.connection.enum import StateMachineEventEnum import pytest from proton.vpn.connection.events import EventContext context = EventContext(connection=Mock()) def test_base_class_missing_event(): class DummyEvent(events.Event): pass with pytest.raises(AttributeError): DummyEvent(context) def test_base_class_expected_event(): custom_event = "test_event" class DummyEvent(events.Event): type = custom_event assert DummyEvent(context).type == custom_event @pytest.mark.parametrize( "event_class, expected_event", [ (events.Up.type, StateMachineEventEnum.UP), (events.Down.type, StateMachineEventEnum.DOWN), (events.Connected.type, StateMachineEventEnum.CONNECTED), (events.Disconnected.type, StateMachineEventEnum.DISCONNECTED), (events.Timeout.type, StateMachineEventEnum.TIMEOUT), (events.AuthDenied.type, StateMachineEventEnum.AUTH_DENIED), (events.UnexpectedError.type, StateMachineEventEnum.UNEXPECTED_ERROR), ] ) def test_individual_events(event_class, expected_event): assert event_class == expected_event python-proton-vpn-api-core-0.39.0/tests/connection/test_persistence.py000066400000000000000000000145441473026673700262260ustar00rootroot00000000000000""" Copyright (c) 2023 Proton AG This file is part of Proton VPN. Proton VPN 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. Proton VPN 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 ProtonVPN. If not, see . """ import json import os from pathlib import Path from tempfile import TemporaryDirectory import pytest from proton.vpn.connection import VPNServer, ProtocolPorts from proton.vpn.connection.persistence import ConnectionPersistence, ConnectionParameters @pytest.fixture def temp_dir() -> str: with TemporaryDirectory(suffix=__name__) as temp_dir: yield f"{temp_dir}" def test_load(temp_dir: str): with open(os.path.join(temp_dir, ConnectionPersistence.FILENAME), "w") as f: f.write('''{ "connection_id": "connection_id", "backend": "backend", "protocol": "protocol", "server": { "server_ip": "1.2.3.4", "openvpn_ports": { "udp": [12345], "tcp": [80] }, "wireguard_ports": { "udp": [54321], "tcp": [81] }, "domain": "server.domain", "x25519pk": "public_key", "server_id": "server_id", "server_name": "server_name", "has_ipv6_support": "0", "label": "label" } }''') connection_persistence = ConnectionPersistence(persistence_directory=temp_dir) persisted_parameters = connection_persistence.load() assert persisted_parameters.connection_id == "connection_id" assert persisted_parameters.backend == "backend" assert persisted_parameters.protocol == "protocol" assert persisted_parameters.server.server_ip == "1.2.3.4" assert persisted_parameters.server.openvpn_ports.udp == [12345] assert persisted_parameters.server.openvpn_ports.tcp == [80] assert persisted_parameters.server.wireguard_ports.udp == [54321] assert persisted_parameters.server.wireguard_ports.tcp == [81] assert persisted_parameters.server.domain == "server.domain" assert persisted_parameters.server.x25519pk == "public_key" assert persisted_parameters.server.server_id == "server_id" assert persisted_parameters.server.server_name == "server_name" assert persisted_parameters.server.label == "label" def test_load_returns_none_and_logs_error_when_persistence_file_contains_invalid_json(temp_dir, caplog): with open(os.path.join(temp_dir, ConnectionPersistence.FILENAME), "w") as f: f.write('{"conn') connection_persistence = ConnectionPersistence(persistence_directory=temp_dir) persisted_parameters = connection_persistence.load() assert not persisted_parameters assert len([r for r in caplog.records if r.levelname == "WARNING"]) == 1 def test_load_returns_none_and_logs_error_when_persistence_file_misses_expected_parameters(temp_dir): with open(os.path.join(temp_dir, ConnectionPersistence.FILENAME), "w") as f: f.write('{"foo": "bar"}') connection_persistence = ConnectionPersistence(persistence_directory=temp_dir) persisted_parameters = connection_persistence.load() assert not persisted_parameters def test_save_(temp_dir: str): connection_parameters = ConnectionParameters( connection_id="connection_id", backend="backend", protocol="protocol", server=VPNServer( server_ip="1.2.3.4", openvpn_ports=ProtocolPorts( udp=[12345], tcp=[80] ), wireguard_ports=ProtocolPorts( udp=[54321], tcp=[81] ), domain="server.domain", x25519pk="public_key", server_id="server_id", server_name="server_name", has_ipv6_support=False, label="label" ) ) connection_persistence = ConnectionPersistence(persistence_directory=temp_dir) connection_persistence.save(connection_parameters) with open(os.path.join(temp_dir, ConnectionPersistence.FILENAME)) as f: persistence_file_content = json.load(f) assert connection_parameters.connection_id == persistence_file_content["connection_id"] assert connection_parameters.backend == persistence_file_content["backend"] assert connection_parameters.protocol == persistence_file_content["protocol"] assert connection_parameters.server.server_ip == persistence_file_content["server"]["server_ip"] assert connection_parameters.server.openvpn_ports.udp == persistence_file_content["server"]["openvpn_ports"]["udp"] assert connection_parameters.server.openvpn_ports.tcp == persistence_file_content["server"]["openvpn_ports"]["tcp"] assert connection_parameters.server.wireguard_ports.udp == persistence_file_content["server"]["wireguard_ports"]["udp"] assert connection_parameters.server.wireguard_ports.tcp == persistence_file_content["server"]["wireguard_ports"]["tcp"] assert connection_parameters.server.domain == persistence_file_content["server"]["domain"] assert connection_parameters.server.x25519pk == persistence_file_content["server"]["x25519pk"] assert connection_parameters.server.server_id == persistence_file_content["server"]["server_id"] assert connection_parameters.server.server_name == persistence_file_content["server"]["server_name"] assert connection_parameters.server.label == persistence_file_content["server"]["label"] def test_remove(temp_dir: str): persistence_file_path = Path(temp_dir) / ConnectionPersistence.FILENAME persistence_file_path.touch() assert persistence_file_path.is_file() connection_persistence = ConnectionPersistence(persistence_directory=temp_dir) connection_persistence.remove() assert not persistence_file_path.exists() def test_remove_logs_a_warning_when_persistence_file_was_not_found( temp_dir:str, caplog ): connection_persistence = ConnectionPersistence(persistence_directory=temp_dir) connection_persistence.remove() assert len(caplog.records) == 1 assert len([r for r in caplog.records if r.levelname == "WARNING"]) == 1 python-proton-vpn-api-core-0.39.0/tests/connection/test_publisher.py000066400000000000000000000054521473026673700256750ustar00rootroot00000000000000""" Copyright (c) 2023 Proton AG This file is part of Proton VPN. Proton VPN 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. Proton VPN 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 ProtonVPN. If not, see . """ from unittest.mock import Mock, AsyncMock from proton.vpn.connection.publisher import Publisher import pytest @pytest.fixture def subscriber(): return Mock() def test_register_registers_subscriber_if_it_was_not_registered_yet(subscriber): publisher = Publisher() publisher.register(subscriber) assert publisher.is_subscriber_registered(subscriber) def test_register_does_nothing_if_the_subscriber_was_already_registered(): subscriber = Mock() publisher = Publisher(subscribers=[subscriber]) publisher.register(subscriber) assert publisher.number_of_subscribers == 1 def test_register_raises_value_error_if_subscriber_is_not_callable(): publisher = Publisher() with pytest.raises(ValueError): publisher.register(None) def test_unregister_unregisters_subscriber_if_it_was_already_registered(subscriber): publisher = Publisher(subscribers=[subscriber]) publisher.unregister(subscriber) assert not publisher.is_subscriber_registered(subscriber) def test_unregister_does_nothing_if_subscriber_was_never_registered(): publisher = Publisher() publisher.unregister(Mock()) assert publisher.number_of_subscribers == 0 @pytest.mark.asyncio async def test_notify_notifies_all_registered_subscribers(): subscribers = [Mock(), AsyncMock()] publisher = Publisher(subscribers=subscribers) publisher.notify("arg1", arg2="arg2") for subscriber in subscribers: subscriber.assert_called_with("arg1", arg2="arg2") @pytest.mark.asyncio async def test_notify_catches_and_logs_exceptions_when_notifying_subscribers(caplog): subscribers = [Mock(side_effect=RuntimeError("Bad stuff")), Mock()] publisher = Publisher(subscribers=subscribers) publisher.notify("foo") # Assert that, even though the first subscriber raised a RuntimeError, # the second one was also notified. for subscriber in subscribers: subscriber.assert_called_with("foo") # Assert that the error was logged. errors = [record for record in caplog.records if record.levelname == "ERROR"] assert errors assert errors[0].msg.startswith("An error occurred notifying subscriber")python-proton-vpn-api-core-0.39.0/tests/connection/test_states.py000066400000000000000000000333601473026673700252020ustar00rootroot00000000000000""" Copyright (c) 2023 Proton AG This file is part of Proton VPN. Proton VPN 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. Proton VPN 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 ProtonVPN. If not, see . """ from typing import Type from unittest.mock import Mock, call, AsyncMock import pytest from proton.vpn.connection import states, events from proton.vpn.connection.enum import KillSwitchSetting from proton.vpn.connection.exceptions import ConcurrentConnectionsError def test_state_subclass_raises_exception_when_missing_state(): class DummyState(states.State): pass with pytest.raises(TypeError): DummyState(states.StateContext()) def test_state_on_event_logs_warning_when_event_did_not_cause_state_transition(caplog): class DummyState(states.State): type = Mock() def _on_event(self, event: events.Event) -> states.State: return self state = DummyState(states.StateContext()) new_state = state.on_event(events.Up(events.EventContext(connection=Mock()))) assert new_state is state warnings = [record for record in caplog.records if record.levelname == "WARNING"] assert len(warnings) == 1 assert "state received unexpected event" in warnings[0].message @pytest.mark.parametrize( "event_type, concurrent_connections_error_expected", [ (event_type, event_type != events.Up) for event_type in events.EVENT_TYPES ] ) def test_state_on_event_raises_concurrent_connections_error_when_multiple_connections_are_detected( event_type, concurrent_connections_error_expected ): """ All state instance raise an exception if they receive an event carrying a connection that's not the same as the one the state instance already has on its context. The reason for this is that the current state should be receiving state updates from the same connection that led to this state. The exception to this rule is the Up event, since the goal of the Up event is to start a new connection. """ # In this case, the concrete state instance doesn't matter, since this check is done in # the base State class. state = states.Connected(states.StateContext(connection=Mock())) event = event_type(events.EventContext(connection=Mock())) try: state.on_event(event) error_raised = False except ConcurrentConnectionsError: error_raised = True assert error_raised is concurrent_connections_error_expected def assert_state_transition( state_type: Type[states.State], event_type: Type[events.Event], expected_next_state_type: Type[states.State] ): """Asserts that when calling the `on_event` method on an instance of `state_type` passing it an instance of `event_type` then the result is an instance of `expected_next_state_type`.""" connection = Mock() state = state_type(states.StateContext(connection=connection)) event = event_type(events.EventContext(connection=connection)) next_state = state.on_event(event) assert isinstance(next_state, expected_next_state_type) if next_state is not state: # The new state should keep the event that led to it in its context. assert next_state.context.event is event @pytest.mark.parametrize("state_type, event_type, expected_next_state_type", [ (states.Disconnected, events.Up, states.Connecting), (states.Connecting, events.Connected, states.Connected), (states.Connected, events.Down, states.Disconnecting), (states.Disconnecting, events.Disconnected, states.Disconnected) ]) def test_happy_flow_state_transitions(state_type, event_type, expected_next_state_type): """ {DISCONNECTED} --Up--> {CONNECTING} --Connected--> {CONNECTED} --Down--> {DISCONNECTING} --Disconnected--> {DISCONNECTED} """ assert_state_transition(state_type, event_type, expected_next_state_type) @pytest.mark.parametrize("event_type, expected_next_state_type", [ (events.Up, states.Connecting), (events.Down, states.Disconnected), (events.Disconnected, states.Disconnected), (events.Connected, states. Disconnected), # Invalid event. (events.UnexpectedError, states.Disconnected) # Invalid event. ]) def test_disconnected_on_event_transitions(event_type, expected_next_state_type): assert_state_transition(states.Disconnected, event_type, expected_next_state_type) @pytest.mark.parametrize("event_type, expected_next_state_type", [ (events.Connected, states.Connected), (events.Down, states.Disconnecting), (events.UnexpectedError, states.Error), (events.Up, states.Disconnecting), # Reconnection. (events.Disconnected, states.Disconnected) ]) def test_connecting_on_event_transitions(event_type, expected_next_state_type): assert_state_transition(states.Connecting, event_type, expected_next_state_type) @pytest.mark.parametrize("event_type, expected_next_state_type", [ (events.Down, states.Disconnecting), (events.Up, states.Disconnecting), # Reconnection. (events.UnexpectedError, states.Error), (events.Disconnected, states.Disconnected), (events.Connected, states.Connected) ]) def test_connected_on_event_transitions(event_type, expected_next_state_type): assert_state_transition(states.Connected, event_type, expected_next_state_type) @pytest.mark.parametrize("event_type, expected_next_state_type", [ (events.Disconnected, states.Disconnected), (events.Up, states.Disconnecting), # Reconnection. (events.Down, states.Disconnecting), (events.UnexpectedError, states.Disconnected), # Errors events also signal VPN disconnection (events.Connected, states.Disconnecting) # Invalid event. ]) def test_disconnecting_on_event_transitions(event_type, expected_next_state_type): assert_state_transition(states.Disconnecting, event_type, expected_next_state_type) @pytest.mark.parametrize("event_type, expected_next_state_type", [ (events.Down, states.Disconnected), (events.Up, states.Disconnecting), (events.UnexpectedError, states.Error), (events.Connected, states.Connected), (events.Disconnected, states.Error) # Invalid event. ]) def test_error_on_event_transitions(event_type, expected_next_state_type): assert_state_transition(states.Error, event_type, expected_next_state_type) @pytest.mark.parametrize("active_state_type", [ states.Connecting, states.Connected, states.Disconnecting, states.Error ]) def test_reconnection_is_triggered_when_up_event_is_received_while_a_connection_is_active( active_state_type ): """ A connection is active while in Connecting, Connected and Disconnecting states. When one of these states receives an Up event then a reconnection will be triggered. That means that, the current state will transition to Disconnecting state (to start disconnection) while keeping the new connection to be started (carried by the Up event) once the Disconnected state is reached. """ active_state = active_state_type(states.StateContext(connection=Mock())) up = events.Up(events.EventContext(connection=Mock())) disconnecting = active_state.on_event(up) assert isinstance(disconnecting, states.Disconnecting) # The connection to disconnect from is the same we were connecting to. assert disconnecting.context.connection is active_state.context.connection # The connection that we want to reconnect to is the one carried by the up event. assert disconnecting.context.reconnection is up.context.connection @pytest.mark.asyncio async def test_disconnected_run_tasks_when_reconnection_is_not_requested_and_kill_switch_is_not_permanent(): """ When reconnection is not requested and the kill switch is not set to permanent, the disconnected state should run the following tasks: - Remove persisted connection parameters. - Disable kill switch. - Disable IPv6 leak protection. """ context = Mock() context.reconnection = None # Reconnection not requested context.kill_switch_setting = KillSwitchSetting.ON context.kill_switch.disable_ipv6_leak_protection = AsyncMock(return_value=None) context.kill_switch.disable = AsyncMock(return_value=None) context.connection.remove_persistence = AsyncMock(return_value=None) disconnected = states.Disconnected(context=context) generated_event = await disconnected.run_tasks() assert context.method_calls == [ call.connection.remove_persistence(), call.kill_switch.disable(), call.kill_switch.disable_ipv6_leak_protection() ] assert generated_event is None @pytest.mark.asyncio async def test_disconnected_run_tasks_does_not_disable_the_kill_switch_when_set_to_permanent(): """ When the kill switch is not set to permanent, the disconnected state should **not** disable the kill switch. """ context = AsyncMock() context.reconnection = None # Reconnection not requested context.kill_switch_setting = KillSwitchSetting.PERMANENT context.connection.remove_persistence = AsyncMock(return_value=None) disconnected = states.Disconnected(context=context) generated_event = await disconnected.run_tasks() assert context.method_calls == [ call.connection.remove_persistence(), call.kill_switch.enable(permanent=True) ] assert generated_event is None @pytest.mark.asyncio async def test_disconnected_run_tasks_when_reconnection_is_requested_and_should_return_up_event(): """ When reconnection **is** requested while on the disconnected state then: - No connection tasks should be performed. It's very important that IPv6 leak protection or the kill switch are **not** disabled. - An Up event should be returned with the new connection to be started. """ context = AsyncMock() context.reconnection = Mock() disconnected = states.Disconnected(context=context) generated_event = await disconnected.run_tasks() assert context.method_calls == [ call.connection.remove_persistence(), call.kill_switch.enable() # Kill switch is enabled to avoid leaks when switching servers. ] assert isinstance(generated_event, events.Up) assert generated_event.context.connection is context.reconnection @pytest.mark.asyncio async def test_disconnected_run_tasks_when_there_is_no_connection(): """ When there is no current connection and reconnection was not requested, the disconnect state should run the following taks: - disable the kill switch - disable IPv6 leak protection. """ context = AsyncMock() context.connection = None context.reconnection = None disconnected = states.Disconnected(context=context) generated_event = await disconnected.run_tasks() assert context.method_calls == [ call.kill_switch.disable(), call.kill_switch.disable_ipv6_leak_protection() ] assert generated_event is None @pytest.mark.asyncio @pytest.mark.parametrize( "kill_switch_setting", [KillSwitchSetting.ON, KillSwitchSetting.PERMANENT, KillSwitchSetting.OFF] ) async def test_connecting_run_tasks(kill_switch_setting): """ The connecting state tasks are the following ones, in the specified order: 1. Enable IPv6 leak protection. 2. Enable kill switch if it's set to be enabled. 3. Start the connection. It's very important that IPv6 leak protection (and kill switch) is enabled before starting the connection. """ context = AsyncMock() context.kill_switch_setting = kill_switch_setting connecting = states.Connecting(context=context) await connecting.run_tasks() permanent_ks = kill_switch_setting == KillSwitchSetting.PERMANENT assert context.method_calls == [ call.kill_switch.enable(context.connection.server, permanent=permanent_ks), call.connection.start() ] @pytest.mark.asyncio @pytest.mark.parametrize( "kill_switch_setting", [KillSwitchSetting.ON, KillSwitchSetting.PERMANENT, KillSwitchSetting.OFF] ) async def test_connected_run_tasks(kill_switch_setting): """The tasks to be run while on the connected state is to persist the connection parameters and enable kill switch if it's set to be enabled.""" context = AsyncMock() context.kill_switch_setting = kill_switch_setting connected = states.Connected(context) await connected.run_tasks() if kill_switch_setting == KillSwitchSetting.ON: assert context.method_calls == [ call.kill_switch.enable(permanent=False), call.connection.add_persistence() ] elif kill_switch_setting == KillSwitchSetting.PERMANENT: assert context.method_calls == [ call.kill_switch.enable(permanent=True), call.connection.add_persistence() ] else: # Kill switch OFF. assert context.method_calls == [ call.kill_switch.enable_ipv6_leak_protection(), call.kill_switch.disable(), call.connection.add_persistence() ] @pytest.mark.asyncio async def test_disconnecting_run_tasks_stops_connection(): """The only task be run while on the disconnecting state is to stop the connection.""" connection = Mock() connection.stop = AsyncMock(return_value=None) disconnecting = states.Disconnecting(states.StateContext(connection=connection)) await disconnecting.run_tasks() connection_calls = connection.method_calls assert len(connection_calls) == 1 connection_calls[0].method = connection.stop python-proton-vpn-api-core-0.39.0/tests/connection/test_vpnconfiguration.py000066400000000000000000000145401473026673700272710ustar00rootroot00000000000000""" Copyright (c) 2023 Proton AG This file is part of Proton VPN. Proton VPN 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. Proton VPN 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 ProtonVPN. If not, see . """ import os import pytest from proton.vpn.connection import VPNServer, ProtocolPorts from proton.vpn.connection.vpnconfiguration import (OpenVPNTCPConfig, OpenVPNUDPConfig, OVPNConfig, VPNConfiguration, WireguardConfig) from .common import (CWD, MockSettings, MockVpnCredentials) import shutil VPNCONFIG_DIR = os.path.join(CWD, "vpnconfig") def setup_module(module): if not os.path.isdir(VPNCONFIG_DIR): os.makedirs(VPNCONFIG_DIR) def teardown_module(module): if os.path.isdir(VPNCONFIG_DIR): shutil.rmtree(VPNCONFIG_DIR) @pytest.fixture def modified_exec_env(): from proton.utils.environment import ExecutionEnvironment m = ExecutionEnvironment().path_runtime ExecutionEnvironment.path_runtime = VPNCONFIG_DIR yield ExecutionEnvironment().path_runtime ExecutionEnvironment.path_runtime = m @pytest.fixture def vpn_server(): return VPNServer( server_ip="10.10.1.1", domain="com.test-domain.www", x25519pk="wg_public_key", openvpn_ports=ProtocolPorts(tcp=[80, 1194], udp=[445, 5995]), wireguard_ports=ProtocolPorts(tcp=[443, 88], udp=[445]), server_name="TestServer#10", server_id="OYB-3pMQQA2Z2Qnp5s5nIvTVO2...lRjxhx9DCAUM9uXfM2ZUFjzPXw==", has_ipv6_support=False, label="0" ) class MockVpnConfiguration(VPNConfiguration): extension = ".test-extension" def generate(self): return "test-content" def test_not_implemented_generate(vpn_server): cfg = VPNConfiguration(vpn_server, MockVpnCredentials(), MockSettings()) with pytest.raises(NotImplementedError): cfg.generate() def test_ensure_configuration_file_is_created(modified_exec_env, vpn_server): cfg = MockVpnConfiguration(vpn_server, MockVpnCredentials(), MockSettings()) with cfg as f: assert os.path.isfile(f) def test_ensure_configuration_file_is_deleted(vpn_server): cfg = MockVpnConfiguration(vpn_server, MockVpnCredentials(), MockSettings()) fp = None with cfg as f: fp = f assert os.path.isfile(fp) assert not os.path.isfile(fp) def test_ensure_generate_is_returning_expected_content(vpn_server): cfg = MockVpnConfiguration(vpn_server, MockVpnCredentials(), MockSettings()) with cfg as f: with open(f) as _f: line = _f.readline() _cfg = MockVpnConfiguration(vpn_server, MockVpnCredentials(), MockSettings()) assert line == _cfg.generate() def test_ensure_same_configuration_file_in_case_of_duplicate(vpn_server): cfg = MockVpnConfiguration(vpn_server, MockVpnCredentials(), MockSettings()) with cfg as f: with cfg as _f: assert os.path.isfile(f) and os.path.isfile(_f) and f == _f @pytest.mark.parametrize( "expected_mask, cidr", [ ("0.0.0.0", "0"), ("255.0.0.0", "8"), ("255.255.0.0", "16"), ("255.255.255.0", "24"), ("255.255.255.255", "32") ] ) def test_cidr_to_netmask(cidr, expected_mask, vpn_server): cfg = MockVpnConfiguration(vpn_server, MockVpnCredentials(), MockSettings()) assert cfg.cidr_to_netmask(cidr) == expected_mask @pytest.mark.parametrize("ipv4", ["192.168.1.1", "109.162.10.9", "1.1.1.1", "10.10.10.10"]) def test_valid_ips(ipv4, vpn_server): cfg = MockVpnConfiguration(vpn_server, MockVpnCredentials(), MockSettings()) cfg.is_valid_ipv4(ipv4) @pytest.mark.parametrize("ipv4", ["192.168.1.90451", "109.", "1.-.1.1", "1111.10.10.10"]) def test_not_valid_ips(ipv4, vpn_server): cfg = MockVpnConfiguration(vpn_server, MockVpnCredentials(), MockSettings()) cfg.is_valid_ipv4(ipv4) @pytest.mark.parametrize("protocol", ["udp", "tcp"]) def test_ovpnconfig_with_settings(protocol, modified_exec_env, vpn_server): ovpn_cfg = OVPNConfig(vpn_server, MockVpnCredentials(), MockSettings()) ovpn_cfg._protocol = protocol output = ovpn_cfg.generate() assert ovpn_cfg._vpnserver.server_ip in output @pytest.mark.parametrize("protocol", ["udp", "tcp"]) def test_ovpnconfig_with_certificate(protocol, modified_exec_env, vpn_server): credentials = MockVpnCredentials() ovpn_cfg = OVPNConfig(vpn_server, MockVpnCredentials(), MockSettings(), use_certificate=True) ovpn_cfg._protocol = protocol output = ovpn_cfg.generate() assert credentials.pubkey_credentials.certificate_pem in output assert credentials.pubkey_credentials.openvpn_private_key in output assert "auth-user-pass" not in output def test_wireguard_config_content_generation(modified_exec_env, vpn_server): credentials = MockVpnCredentials() settings = MockSettings() wg_cfg = WireguardConfig(vpn_server, credentials, settings, True) generated_cfg = wg_cfg.generate() assert credentials.pubkey_credentials.wg_private_key in generated_cfg assert vpn_server.x25519pk in generated_cfg assert vpn_server.server_ip in generated_cfg def test_wireguard_with_non_certificate(modified_exec_env, vpn_server): wg_cfg = WireguardConfig(vpn_server, MockVpnCredentials(), MockSettings()) with pytest.raises(RuntimeError): wg_cfg.generate() @pytest.mark.parametrize( "protocol, expected_class", [ ("openvpn-tcp", OpenVPNTCPConfig), ("openvpn-udp", OpenVPNUDPConfig), ("wireguard", WireguardConfig), ] ) def test_get_expected_config_from_factory(protocol, expected_class, vpn_server): config = VPNConfiguration.from_factory(protocol) assert isinstance( config(vpn_server, MockVpnCredentials(), MockSettings()), expected_class ) python-proton-vpn-api-core-0.39.0/tests/connection/test_vpnconnection.py000066400000000000000000000164631473026673700265670ustar00rootroot00000000000000""" Copyright (c) 2023 Proton AG This file is part of Proton VPN. Proton VPN 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. Proton VPN 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 ProtonVPN. If not, see . """ import os from unittest.mock import Mock, patch import pytest from proton.vpn.connection import VPNConnection, states from proton.vpn.connection.persistence import ConnectionPersistence, ConnectionParameters from proton.vpn.connection.states import StateContext from proton.vpn.connection.interfaces import VPNServer, ProtocolPorts from .common import MockVpnCredentials @pytest.fixture def settings(): return Mock() @pytest.fixture def vpn_credentials(): return MockVpnCredentials() @pytest.fixture def vpn_server(): return VPNServer( server_ip="10.10.1.1", domain="com.test-domain.www", x25519pk="wg_public_key", openvpn_ports=ProtocolPorts(tcp=[80, 1194], udp=[445, 5995]), wireguard_ports=ProtocolPorts(tcp=[443, 88], udp=[445]), server_name="TestServer#10", server_id="OYB-3pMQQA2Z2Qnp5s5nIvTVO2...lRjxhx9DCAUM9uXfM2ZUFjzPXw==", has_ipv6_support=False, label="0" ) @pytest.fixture def connection_persistence_mock(): return Mock(ConnectionPersistence) class DummyVPNConnection(VPNConnection): """Dummy VPN connection implementing all the required abstract methods.""" backend = "dummy" protocol = "protocol" def __init__(self, *args, connection_persistence = None, **kwargs): self.initialize_persisted_connection_mock = Mock(return_value=states.Connected(StateContext(connection=self))) # Make sure we don't trigger connection persistence. connection_persistence = connection_persistence or Mock() super().__init__(*args, connection_persistence=connection_persistence, **kwargs) def _initialize_persisted_connection( self, persisted_parameters: ConnectionParameters ) -> states.State: return self.initialize_persisted_connection_mock(persisted_parameters) def start(self): pass def stop(self): pass def refresh_certificate(self): pass def _get_connection(self): return None def _validate(cls) -> bool: return True def _get_priority(cls) -> int: return 100 class InvalidVPNConnection(VPNConnection): """VPN connection class missing abstract method implementations.""" backend = "invalid" protocol = "protocol" def test_vpn_connection_subclass_raises_type_exception_if_abstract_methods_were_not_implemented(): with pytest.raises(TypeError, match="Can't instantiate abstract class"): InvalidVPNConnection(server=None, credentials=None) def test_vpn_connection_initialized_without_a_persisted_connection(): """ When a VPNConnection object is created without passing persisted parameters then it should be initialized without a unique id and with the Disconnected initial state. """ vpnconn = DummyVPNConnection( server=None, credentials=None, settings=None, connection_id=None ) assert vpnconn._unique_id is None vpnconn.initialize_persisted_connection_mock.assert_not_called() assert isinstance(vpnconn.initial_state, states.Disconnected) @pytest.mark.asyncio async def test_add_persistence(vpn_server, vpn_credentials, settings, connection_persistence_mock): vpnconn = DummyVPNConnection( vpn_server, vpn_credentials, settings=settings, connection_persistence=connection_persistence_mock, ) vpnconn._unique_id = "add-persistence" await vpnconn.add_persistence() connection_persistence_mock.save.assert_called_once() persistence_params = connection_persistence_mock.save.call_args.args[0] assert persistence_params.connection_id == "add-persistence" assert persistence_params.backend == vpnconn.backend assert persistence_params.protocol == vpnconn.protocol assert persistence_params.server == vpn_server @pytest.mark.asyncio async def test_remove_persistence(vpn_server, vpn_credentials, settings, connection_persistence_mock): vpnconn = DummyVPNConnection( vpn_server, vpn_credentials, settings, connection_persistence=connection_persistence_mock ) vpnconn._unique_id = "remove-persistence" await vpnconn.remove_persistence() connection_persistence_mock.remove.assert_called() def test_register_subscriber_delegates_to_publisher(): publisher_mock = Mock() vpnconn = DummyVPNConnection( server=None, credentials=None, settings=None, publisher=publisher_mock ) def subscriber(event): pass vpnconn.register(subscriber) publisher_mock.register.assert_called_with(subscriber) def test_unregister_subscriber_delegates_to_publisher(): publisher_mock = Mock() vpnconn = DummyVPNConnection( server=None, credentials=None, settings=None, publisher=publisher_mock ) def subscriber(event): pass vpnconn.unregister(subscriber) publisher_mock.unregister.assert_called_with(subscriber) def test_get_user_pass(vpn_server, vpn_credentials, settings): vpnconn = DummyVPNConnection(vpn_server, vpn_credentials, settings) u, p = vpn_credentials.userpass_credentials.username, vpn_credentials.userpass_credentials.password user, password = vpnconn._get_user_pass() assert u == user and p == password def test_get_user_with_default_feature_flags(vpn_server, vpn_credentials, settings): vpnconn = DummyVPNConnection(vpn_server, vpn_credentials, settings) u = vpn_credentials.userpass_credentials.username user, _ = vpnconn._get_user_pass(True) _u = "+".join([u] + vpnconn._get_feature_flags()) assert user == _u @pytest.mark.parametrize( "ns, accel, pf, rn, sf", [ ("f1", False, True, False, True), ("f2", False, True, False, True), ("f3", False, True, False, True), ("f1", True, False, True, False), ("f2", True, False, True, False), ("f3", True, False, True, False), ] ) def test_get_user_with_features(vpn_server, vpn_credentials, ns, accel, pf, rn, sf): from proton.vpn.connection.interfaces import Features class MockFeatures(Features): @property def netshield(self): return ns @property def vpn_accelerator(self): return accel @property def port_forwarding(self): return pf @property def moderate_nat(self): return rn @property def safe_mode(self): return sf settings = Mock() settings.features = MockFeatures() vpnconn = DummyVPNConnection(vpn_server, vpn_credentials, settings) u = vpn_credentials.userpass_credentials.username user, _ = vpnconn._get_user_pass(True) _u = "+".join([u] + vpnconn._get_feature_flags()) assert user == _u python-proton-vpn-api-core-0.39.0/tests/core/000077500000000000000000000000001473026673700210525ustar00rootroot00000000000000python-proton-vpn-api-core-0.39.0/tests/core/__init__.py000066400000000000000000000000001473026673700231510ustar00rootroot00000000000000python-proton-vpn-api-core-0.39.0/tests/core/refresher/000077500000000000000000000000001473026673700230375ustar00rootroot00000000000000python-proton-vpn-api-core-0.39.0/tests/core/refresher/test_certificate_refresher.py000066400000000000000000000037431473026673700310060ustar00rootroot00000000000000""" Copyright (c) 2023 Proton AG This file is part of Proton VPN. Proton VPN 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. Proton VPN 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 ProtonVPN. If not, see . """ from unittest.mock import Mock, AsyncMock import pytest from proton.vpn.core.refresher.certificate_refresher import CertificateRefresher, generate_backoff_value from proton.vpn.core.refresher.scheduler import RunAgain @pytest.mark.asyncio async def test_refresh_fetches_certificate_if_expired_and_returns_next_refresh_delay(): session_holder = Mock() session = session_holder.session refresher = CertificateRefresher(session_holder=session_holder) session.fetch_certificate = AsyncMock() new_certificate = Mock() new_certificate.remaining_time_to_next_refresh = 600 session.fetch_certificate.return_value= new_certificate next_refresh_delay = await refresher.refresh() assert next_refresh_delay == RunAgain.after_seconds(new_certificate.remaining_time_to_next_refresh) @pytest.mark.parametrize("nth_failed_attempt, expected_backoff", [ (0, 1), (1, 2), (2, 4), (3, 8), (4, 16), (5, 32) ]) def test_generate_backoff_value_generates_expected_value(nth_failed_attempt, expected_backoff): backoff_in_seconds = 1 random_component = 1 backoff = generate_backoff_value( number_of_failed_refresh_attempts=nth_failed_attempt, backoff_in_seconds=backoff_in_seconds, random_component=random_component ) assert backoff == expected_backoffpython-proton-vpn-api-core-0.39.0/tests/core/refresher/test_client_config_refresher.py000066400000000000000000000027611473026673700313260ustar00rootroot00000000000000""" Copyright (c) 2023 Proton AG This file is part of Proton VPN. Proton VPN 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. Proton VPN 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 ProtonVPN. If not, see . """ from unittest.mock import Mock, AsyncMock import pytest from proton.vpn.core.refresher.client_config_refresher import ClientConfigRefresher from proton.vpn.core.refresher.scheduler import RunAgain @pytest.mark.asyncio async def refresh_fetches_client_config_if_expired_and_returns_next_refresh_delay(): session_holder = Mock() session = session_holder.session refresher = ClientConfigRefresher(session_holder=session_holder) new_client_config = Mock() new_client_config.seconds_until_expiration = 60 session.fetch_client_config = AsyncMock() session.fetch_client_config.return_value = new_client_config next_refresh_delay = await refresher.refresh() session.fetch_client_config.assert_called_once() assert next_refresh_delay == RunAgain.after_seconds(new_client_config.seconds_until_expiration) python-proton-vpn-api-core-0.39.0/tests/core/refresher/test_feature_flags_refresher.py000066400000000000000000000027541473026673700313340ustar00rootroot00000000000000""" Copyright (c) 2023 Proton AG This file is part of Proton VPN. Proton VPN 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. Proton VPN 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 ProtonVPN. If not, see . """ from unittest.mock import Mock, AsyncMock import pytest from proton.vpn.core.refresher.feature_flags_refresher import FeatureFlagsRefresher from proton.vpn.core.refresher.scheduler import RunAgain @pytest.mark.asyncio async def test_refresh_fetches_feature_flags_and_returns_next_refresh_delay(): session_holder = Mock() session = session_holder.session refresher = FeatureFlagsRefresher(session_holder=session_holder) new_feature_flags = Mock() new_feature_flags.seconds_until_expiration = 60 session.fetch_feature_flags = AsyncMock() session.fetch_feature_flags.return_value = new_feature_flags next_refresh_delay = await refresher.refresh() session.fetch_feature_flags.assert_called_once() assert next_refresh_delay == RunAgain.after_seconds(new_feature_flags.seconds_until_expiration) python-proton-vpn-api-core-0.39.0/tests/core/refresher/test_scheduler.py000066400000000000000000000037301473026673700264310ustar00rootroot00000000000000import time from unittest.mock import AsyncMock import pytest from proton.vpn.core.refresher.scheduler import Scheduler async def dummy(): pass @pytest.mark.asyncio async def test_start_runs_tasks_ready_to_fire_periodically(): scheduler = Scheduler(check_interval_in_ms=10) task_1 = AsyncMock() async def task_1_wrapper(): await task_1() scheduler.run_after(0, task_1_wrapper) task_2 = AsyncMock() async def run_task_2_and_shutdown(): await task_2() await scheduler.stop() # stop the scheduler after the second task is executed. in_100_ms = time.time() + 0.1 scheduler.run_at(in_100_ms, run_task_2_and_shutdown) scheduler.start() await scheduler.wait_for_shutdown() task_1.assert_called_once() task_2.assert_called_once() assert len(scheduler.task_list) == 0 @pytest.mark.asyncio async def test_run_task_ready_to_fire_only_runs_tasks_with_expired_timestamps(): scheduler = Scheduler() # should run since the delay is 0 seconds. scheduler.run_after(delay_in_seconds=0, async_function=dummy) # should not run yet since the delay is 30 seconds. scheduler.run_after(delay_in_seconds=30, async_function=dummy) scheduler.run_tasks_ready_to_fire() assert scheduler.number_of_remaining_tasks == 1 @pytest.mark.asyncio async def test_stop_empties_task_list(): scheduler = Scheduler() scheduler.start() scheduler.run_after(delay_in_seconds=30, async_function=dummy) await scheduler.stop() assert not scheduler.is_started assert len(scheduler.task_list) == 0 def test_run_at_schedules_new_task(): scheduler = Scheduler() task_id = scheduler.run_at(timestamp=time.time() + 10, async_function=dummy) scheduler.task_list[0].id == task_id def test_cancel_task_removes_task_from_task_list(): scheduler = Scheduler() task_id = scheduler.run_after(0, dummy) scheduler.cancel_task(task_id) assert scheduler.number_of_remaining_tasks == 0 python-proton-vpn-api-core-0.39.0/tests/core/refresher/test_server_list_refresher.py000066400000000000000000000077171473026673700310720ustar00rootroot00000000000000""" Copyright (c) 2023 Proton AG This file is part of Proton VPN. Proton VPN 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. Proton VPN 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 ProtonVPN. If not, see . """ from unittest.mock import Mock, AsyncMock import pytest from proton.vpn.core.refresher.scheduler import RunAgain from proton.vpn.core.refresher.server_list_refresher import ServerListRefresher @pytest.mark.asyncio async def test_refresh_fetches_server_list_if_expired_and_returns_next_refresh_delay(): session_holder = Mock() session = session_holder.session # The current server list is expired. session.server_list.expired = True new_server_list = Mock() new_server_list.seconds_until_expiration = 15 * 60 session.fetch_server_list = AsyncMock() session.fetch_server_list.return_value = new_server_list refresher = ServerListRefresher(session_holder=session_holder) refresher.server_list_updated_callback = Mock() next_refresh_delay = await refresher.refresh() # A new server list should've been fetched. session.fetch_server_list.assert_called_once() # The callback to notify of server list updates should have been called. refresher.server_list_updated_callback.assert_called_once_with() # And the new refresh should've been scheduled after the new # server list/loads expire again. assert next_refresh_delay == RunAgain.after_seconds(new_server_list.seconds_until_expiration) @pytest.mark.asyncio async def test_refresh_updates_server_loads_if_expired_and_returns_next_refresh_delay(): session_holder = Mock() session = session_holder.session # Only loads are expired session.server_list.expired = False session.server_list.loads_expired = True updated_server_list = Mock() updated_server_list.seconds_until_expiration = 60 session.update_server_loads = AsyncMock() session.update_server_loads.return_value = updated_server_list refresher = ServerListRefresher(session_holder=session_holder) refresher.server_loads_updated_callback = Mock() next_refresh_delay = await refresher.refresh() # The server list should not have been fetched... session.fetch_server_list.assert_not_called() # but the loads should have been updated. session.update_server_loads.assert_called_once() # The callback to notify of server load updates should have been called. refresher.server_loads_updated_callback.assert_called_once_with() # And the next refresh should've been scheduled when the updated # server list expires. assert next_refresh_delay == RunAgain.after_seconds(updated_server_list.seconds_until_expiration) @pytest.mark.asyncio async def test_refresh_schedules_next_refresh_if_server_list_is_not_expired(): session_holder = Mock() session = session_holder.session # The current server list is not expired. session.server_list.expired = False session.server_list.loads_expired = False session.server_list.seconds_until_expiration = 60 refresher = ServerListRefresher(session_holder=session_holder) next_refresh_delay = await refresher.refresh() # The server list should not have been fetched. session.fetch_server_list.assert_not_called() # The server loads should not have been fetched either. session.update_server_loads.assert_not_called() # And the next refresh should've been scheduled when the current # server list expires. assert next_refresh_delay == RunAgain.after_seconds(session.server_list.seconds_until_expiration) python-proton-vpn-api-core-0.39.0/tests/core/refresher/test_vpn_data_refresher.py000066400000000000000000000100221473026673700303040ustar00rootroot00000000000000""" Copyright (c) 2023 Proton AG This file is part of Proton VPN. Proton VPN 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. Proton VPN 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 ProtonVPN. If not, see . """ from unittest.mock import Mock, AsyncMock, call import pytest from proton.vpn.core.refresher import VPNDataRefresher @pytest.mark.asyncio async def test_enable_schedules_all_refreshers_if_the_vpn_session_is_already_loaded(): session_holder = Mock() scheduler = Mock() client_config_refresher = Mock() client_config_refresher.initial_refresh_delay = 0 server_list_refresher = Mock() server_list_refresher.initial_refresh_delay = 0 certificate_refresher = Mock() certificate_refresher.initial_refresh_delay = 0 feature_flag_refresher = Mock() feature_flag_refresher.initial_refresh_delay = 0 refresher = VPNDataRefresher( session_holder=session_holder, scheduler=scheduler, client_config_refresher=client_config_refresher, server_list_refresher=server_list_refresher, certificate_refresher=certificate_refresher, feature_flags_refresher=feature_flag_refresher ) session_holder.session.loaded = True await refresher.enable() assert scheduler.mock_calls == [ call.run_after(client_config_refresher.initial_refresh_delay, client_config_refresher.refresh), call.run_after(server_list_refresher.initial_refresh_delay, server_list_refresher.refresh), call.run_after(certificate_refresher.initial_refresh_delay, certificate_refresher.refresh), call.run_after(feature_flag_refresher.initial_refresh_delay, feature_flag_refresher.refresh), call.start() ] @pytest.mark.asyncio async def test_enable_fetches_vpn_session_when_not_loaded_and_then_schedules_refreshers(): session_holder = Mock() scheduler = Mock() client_config_refresher = Mock() client_config_refresher.initial_refresh_delay = 0 server_list_refresher = Mock() server_list_refresher.initial_refresh_delay = 0 certificate_refresher = Mock() certificate_refresher.initial_refresh_delay = 0 feature_flag_refresher = Mock() feature_flag_refresher.initial_refresh_delay = 0 mock_manager = Mock() mock_manager.session_holder = session_holder mock_manager.scheduler = scheduler mock_manager.client_config_refresher = client_config_refresher mock_manager.server_list_refresher = server_list_refresher mock_manager.certificate_refresher = certificate_refresher mock_manager.feature_flag_refresher = feature_flag_refresher refresher = VPNDataRefresher( session_holder=session_holder, scheduler=scheduler, client_config_refresher=client_config_refresher, server_list_refresher=server_list_refresher, certificate_refresher=certificate_refresher, feature_flags_refresher=feature_flag_refresher ) session_holder.session.loaded = False session_holder.session.fetch_session_data = AsyncMock() await refresher.enable() assert mock_manager.mock_calls == [ call.session_holder.session.fetch_session_data(), call.scheduler.run_after(client_config_refresher.initial_refresh_delay, client_config_refresher.refresh), call.scheduler.run_after(server_list_refresher.initial_refresh_delay, server_list_refresher.refresh), call.scheduler.run_after(certificate_refresher.initial_refresh_delay, certificate_refresher.refresh), call.scheduler.run_after(feature_flag_refresher.initial_refresh_delay, feature_flag_refresher.refresh), call.scheduler.start() ] python-proton-vpn-api-core-0.39.0/tests/core/test_cachehandler.py000066400000000000000000000043001473026673700250610ustar00rootroot00000000000000""" Copyright (c) 2023 Proton AG This file is part of Proton VPN. Proton VPN 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. Proton VPN 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 ProtonVPN. If not, see . """ import json import os import tempfile import pytest from proton.vpn.core.cache_handler import CacheHandler class TestCacheHandler: @pytest.fixture def dir_path(self): configfolder = tempfile.TemporaryDirectory(prefix="test_cache_handler") yield configfolder configfolder.cleanup() @pytest.fixture def cache_filepath(self, dir_path): return os.path.join(dir_path.name, "test_cache_file.json") def test_save_new_cache(self, cache_filepath): cache_handler = CacheHandler(cache_filepath) cache_handler.save({"save_cache": "dummy-data"}) with open(cache_filepath, "r") as f: content = json.load(f) assert "save_cache" in content assert "dummy-data" == content["save_cache"] def test_load_stored_cache(self, cache_filepath): cache_handler = CacheHandler(cache_filepath) with open(cache_filepath, "w") as f: json.dump({"load_cache": "dummy-data"}, f) data = cache_handler.load() assert "load_cache" in data assert "dummy-data" == data["load_cache"] def test_load_cache_with_missing_file(self, cache_filepath): cache_handler = CacheHandler(cache_filepath) assert not cache_handler.load() def test_remove_cache(self, cache_filepath): cache_handler = CacheHandler(cache_filepath) with open(cache_filepath, "w") as f: json.dump({"load_cache": "dummy-data"}, f) cache_handler.remove() assert not os.path.isfile(cache_filepath) python-proton-vpn-api-core-0.39.0/tests/core/test_connection.py000066400000000000000000000211721473026673700246250ustar00rootroot00000000000000""" Copyright (c) 2023 Proton AG This file is part of Proton VPN. Proton VPN 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. Proton VPN 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 ProtonVPN. If not, see . """ from proton.vpn.core.refresher import VPNDataRefresher from proton.vpn.session.servers import LogicalServer from proton.vpn.session.client_config import ClientConfig from proton.vpn.core.connection import VPNConnector from proton.vpn.connection import events, exceptions, states from unittest.mock import Mock, AsyncMock import pytest LOGICAL_SERVER_DATA = { "Name": "IS#1", "ID": "OYB-3pMQQA2Z2Qnp5s5nIvTVO2alU6h82EGLXYHn1mpbsRvE7UfyAHbt0_EilRjxhx9DCAUM9uXfM2ZUFjzPXw==", "Status": 1, "Servers": [ { "EntryIP": "185.159.158.1", "Domain": "node-is-01.protonvpn.net", "X25519PublicKey": "yKbYe2XwbeNN9CuPZcwMF/lJp6a62NEGiHCCfpfxrnE=", "Status": 1, } ], "Label": "3", } def test_get_vpn_server_returns_vpn_server_built_from_logical_server_and_client_config(): vpn_connector_wrapper = VPNConnector( session_holder=None, settings_persistence=None, usage_reporting=None ) logical_server = LogicalServer(data=LOGICAL_SERVER_DATA) client_config = ClientConfig.default() vpn_server = vpn_connector_wrapper.get_vpn_server(logical_server, client_config) physical_server = logical_server.physical_servers[0] assert vpn_server.server_ip == physical_server.entry_ip assert vpn_server.domain == physical_server.domain assert vpn_server.x25519pk == physical_server.x25519_pk assert vpn_server.openvpn_ports.udp == client_config.openvpn_ports.udp assert vpn_server.openvpn_ports.tcp == client_config.openvpn_ports.tcp assert vpn_server.wireguard_ports.udp == client_config.wireguard_ports.udp assert vpn_server.wireguard_ports.tcp == client_config.wireguard_ports.tcp assert vpn_server.server_id == logical_server.id assert vpn_server.server_name == logical_server.name assert vpn_server.label == physical_server.label @pytest.mark.asyncio async def test__on_connection_event_swallows_and_does_not_report_policy_errors(): vpn_connector_wrapper = VPNConnector( session_holder=None, settings_persistence=None, usage_reporting=Mock(), state=states.Connected(), ) event = events.Disconnected() event.context.error = exceptions.FeaturePolicyError("Policy error") await vpn_connector_wrapper._on_connection_event(event) vpn_connector_wrapper._usage_reporting.report_error.assert_not_called() @pytest.mark.asyncio @pytest.mark.parametrize("error", [ exceptions.FeatureError("generic feature error"), exceptions.FeatureSyntaxError("Feature syntax error") ]) async def test__on_connection_event_reports_feature_syntax_errors_but_no_other_feature_error(error): vpn_connector_wrapper = VPNConnector( session_holder=None, settings_persistence=None, usage_reporting=Mock(), state=states.Connected(), ) event = events.Disconnected() event.context.error = error await vpn_connector_wrapper._on_connection_event(event) if isinstance(error, exceptions.FeatureSyntaxError): vpn_connector_wrapper._usage_reporting.report_error.assert_called_once_with(event.context.error) elif isinstance(error, exceptions.FeatureError): vpn_connector_wrapper._usage_reporting.report_error.assert_not_called() else: raise ValueError(f"Unexpected test parameter: {error}") @pytest.mark.asyncio async def test__on_connection_event_reports_unexpected_exceptions_and_bubbles_them_up(): vpn_connector_wrapper = VPNConnector( session_holder=None, settings_persistence=None, usage_reporting=Mock(), state=states.Connected(), ) event = events.Disconnected() event.context.error = Exception("Unexpected error") with pytest.raises(Exception): await vpn_connector_wrapper._on_connection_event(event) vpn_connector_wrapper._usage_reporting.report_error.assert_called_once_with(event.context.error) def test_on_state_change_stores_new_device_ip_when_successfully_connected_to_vpn_and_connection_details_and_device_ip_are_set(): publisher_mock = Mock() session_holder_mock = Mock() new_connection_details = events.ConnectionDetails( device_ip="192.168.0.1", device_country="PT", server_ipv4="0.0.0.0", server_ipv6=None, ) _ = VPNConnector( session_holder=session_holder_mock, settings_persistence=None, usage_reporting=None, connection_persistence=Mock(), publisher=publisher_mock ) on_state_change_callback = publisher_mock.register.call_args[0][0] connected_event = events.Connected( context=events.EventContext( connection=Mock(), connection_details=new_connection_details ) ) connected_state = states.Connected(context=states.StateContext(connected_event)) on_state_change_callback(connected_state) vpn_location = session_holder_mock.session.set_location.call_args[0][0] session_holder_mock.session.set_location.assert_called_once() assert vpn_location.IP == new_connection_details.device_ip def test_on_state_change_skip_store_new_device_ip_when_successfully_connected_to_vpn_and_connection_details_is_none(): publisher_mock = Mock() session_holder_mock = Mock() _ = VPNConnector( session_holder=session_holder_mock, settings_persistence=None, usage_reporting=None, connection_persistence=Mock(), publisher=publisher_mock ) on_state_change_callback = publisher_mock.register.call_args[0][0] connected_event = events.Connected( context=events.EventContext( connection=Mock(), connection_details=None ) ) connected_state = states.Connected(context=states.StateContext(connected_event)) on_state_change_callback(connected_state) session_holder_mock.session.set_location.assert_not_called() def test_on_state_change_skip_store_new_device_ip_when_successfully_connected_to_vpn_and_device_ip_is_none(): publisher_mock = Mock() session_holder_mock = Mock() new_connection_details = events.ConnectionDetails( device_ip=None, device_country="PT", server_ipv4="0.0.0.0", server_ipv6=None, ) _ = VPNConnector( session_holder=session_holder_mock, settings_persistence=None, usage_reporting=None, connection_persistence=Mock(), publisher=publisher_mock ) on_state_change_callback = publisher_mock.register.call_args[0][0] connected_event = events.Connected( context=events.EventContext( connection=Mock(), connection_details=new_connection_details ) ) connected_state = states.Connected(context=states.StateContext(connected_event)) on_state_change_callback(connected_state) session_holder_mock.session.set_location.assert_not_called() @pytest.mark.asyncio @pytest.mark.parametrize("state_class, update_credentials_expected", [ (states.Connected, True), (states.Error, True), (states.Disconnected, False), (states.Connecting, False), (states.Disconnecting, False), ]) async def test_connector_updates_connection_credentials_when_certificate_is_refreshed_and_current_state_is_connected_or_error( state_class, update_credentials_expected ): session_holder = Mock() current_state = state_class(states.StateContext(connection=AsyncMock())) connector = VPNConnector( session_holder=session_holder, settings_persistence=Mock(), usage_reporting=Mock(), connection_persistence=Mock(), state=current_state ) refresher = VPNDataRefresher(session_holder=session_holder, scheduler=Mock()) connector.subscribe_to_certificate_updates(refresher) # Trigger certificated updated callback await refresher._certificate_refresher.certificate_updated_callback() assert current_state.context.connection.update_credentials.called is update_credentials_expected if update_credentials_expected: current_state.context.connection.update_credentials.assert_called_once_with(session_holder.vpn_credentials) python-proton-vpn-api-core-0.39.0/tests/core/test_settings.py000066400000000000000000000126331473026673700243300ustar00rootroot00000000000000""" Copyright (c) 2023 Proton AG This file is part of Proton VPN. Proton VPN 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. Proton VPN 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 ProtonVPN. If not, see . """ from unittest.mock import Mock import pytest import itertools from proton.vpn.core.settings import Settings, SettingsPersistence, NetShield from proton.vpn.killswitch.interface import KillSwitchState FREE_TIER = 0 PLUS_TIER = 1 @pytest.fixture def default_free_settings_dict(): return { "protocol": "openvpn-udp", "killswitch": KillSwitchState.OFF.value, "custom_dns": { "enabled": False, "ip_list": [] }, "ipv6": True, "anonymous_crash_reports": True, "features": { "netshield": NetShield.NO_BLOCK.value, "moderate_nat": False, "vpn_accelerator": True, "port_forwarding": False, } } def test_settings_get_default(default_free_settings_dict): free_settings = Settings.default(FREE_TIER) assert free_settings.to_dict() == default_free_settings_dict def test_settings_save_to_disk(default_free_settings_dict): free_settings = Settings.default(FREE_TIER) cache_handler_mock = Mock() sp = SettingsPersistence(cache_handler_mock) sp.save(free_settings) cache_handler_mock.save.assert_called_once_with(free_settings.to_dict()) def test_settings_persistence_get_returns_default_settings_and_does_not_persist_them(default_free_settings_dict): cache_handler_mock = Mock() cache_handler_mock.load.return_value = None sp = SettingsPersistence(cache_handler_mock) sp.get(FREE_TIER, Mock(name="feature-flags")) cache_handler_mock.save.assert_not_called() def test_settings_persistence_save_persisted_settings(default_free_settings_dict): cache_handler_mock = Mock() sp = SettingsPersistence(cache_handler_mock) sp.save(Settings.from_dict(default_free_settings_dict, FREE_TIER)) cache_handler_mock.save.assert_called() def test_settings_persistence_get_returns_in_memory_settings_if_they_were_already_loaded(default_free_settings_dict): cache_handler_mock = Mock() cache_handler_mock.load.return_value = default_free_settings_dict sp = SettingsPersistence(cache_handler_mock) sp.get(FREE_TIER, Mock(name="feature-flags")) # The persistend settings should be loaded once, not twice. cache_handler_mock.load.assert_called_once() @pytest.mark.parametrize("user_tier", [FREE_TIER, PLUS_TIER]) def test_settings_persistence_ensure_features_are_loaded_with_default_values_based_on_user_tier(user_tier): cache_handler_mock = Mock() cache_handler_mock.load.return_value = None sp = SettingsPersistence(cache_handler_mock) settings = sp.get(user_tier, Mock(name="feature-flags")) if user_tier == FREE_TIER: assert settings.features.netshield == NetShield.NO_BLOCK.value else: assert settings.features.netshield == NetShield.BLOCK_MALICIOUS_URL.value def test_settings_persistence_delete_removes_persisted_settings(default_free_settings_dict): cache_handler_mock = Mock() cache_handler_mock.load.return_value = default_free_settings_dict sp = SettingsPersistence(cache_handler_mock) sp.get(FREE_TIER, Mock(name="feature-flags")) sp.delete() cache_handler_mock.remove.assert_called_once() def test_get_ipv4_custom_dns_ips_returns_only_valid_ips(default_free_settings_dict): valid_ips = [ {"ip": "1.1.1.1"}, {"ip": "2.2.2.2"}, {"ip": "3.3.3.3"} ] invalid_ips = [ {"ip": "asdasd"}, {"ip": "wasd2.q212.123123"}, {"ip": "123123123.123123123.123123123.123123"}, {"ip": "ef0e:e1d4:87f9:a578:5e52:fb88:46a7:010a"} ] default_free_settings_dict["custom_dns"]["ip_list"] = list(itertools.chain.from_iterable([valid_ips, invalid_ips])) sp = Settings.from_dict(default_free_settings_dict, FREE_TIER) list_of_ipv4_addresses_in_string_form = [ip.exploded for ip in sp.custom_dns.get_enabled_ipv4_ips()] assert [dns["ip"] for dns in valid_ips] == list_of_ipv4_addresses_in_string_form def test_get_ipv6_custom_dns_ips_returns_only_valid_ips(default_free_settings_dict): valid_ips = [ {"ip": "ef0e:e1d4:87f9:a578:5e52:fb88:46a7:010a"}, {"ip": "0275:ef68:faeb:736b:49af:36f7:1620:9308"}, {"ip": "4e69:39c4:9c55:5b26:7fa7:730e:4012:48b6"} ] invalid_ips = [ {"ip": "asdasd"}, {"ip": "wasd2.q212.123123"}, {"ip": "1.1.1.1"}, {"ip": "2.2.2.2"}, {"ip": "3.3.3.3"}, {"ip": "123123123.123123123.123123123.123123"} ] default_free_settings_dict["custom_dns"]["ip_list"] = list(itertools.chain.from_iterable([valid_ips, invalid_ips])) sp = Settings.from_dict(default_free_settings_dict, FREE_TIER) list_of_ipv6_addresses_in_string_form = [ip.exploded for ip in sp.custom_dns.get_enabled_ipv6_ips()] assert [dns["ip"] for dns in valid_ips] == list_of_ipv6_addresses_in_string_form python-proton-vpn-api-core-0.39.0/tests/core/test_usage.py000066400000000000000000000115001473026673700235640ustar00rootroot00000000000000""" Copyright (c) 2023 Proton AG This file is part of Proton VPN. Proton VPN 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. Proton VPN 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 ProtonVPN. If not, see . """ import copy import os import pytest from types import SimpleNamespace import tempfile import json from proton.vpn.core.session_holder import ClientTypeMetadata from proton.vpn.core.usage import UsageReporting SECRET_FILE = "secret.txt" SECRET_PATH = os.path.join("/home/wozniak/5nkfiudfmk/.cache", SECRET_FILE) MACHINE_ID = "bg77t2rmpjhgt9zim5gkz4t78jfur39f" SENTRY_USER_ID = "70cf75689cecae78ec588316320d76477c71031f7fd172dd5577ac95934d4499" USERNAME = "tester" EVENT_TO_SEND = { "frames": [ { "filename": "/home/tester/src/quick_connect_widget.py", "abs_path": "/home/tester/src/quick_connect_widget.py", "function": "_on_disconnect_button_clicked", "module": "proton.vpn.app.gtk.widgets.vpn.quick_connect_widget", "lineno": 102, "pre_context": [ " future = self._controller.connect_to_fastest_server()", " future.add_done_callback(lambda f: GLib.idle_add(f.result)) # bubble up exceptions if any.", "", " def _on_disconnect_button_clicked(self, _):", " logger.info(\"Disconnect from VPN\", category=\"ui\", event=\"disconnect\")" ], "context_line": " future = self._controller.disconnect()", "post_context": [ " future.add_done_callback(lambda f: GLib.idle_add(f.result)) # bubble up exceptions if any." ], "vars": { "self": "", "_": "" }, "in_app": True }, { "filename": "/home/tester/src/ProtonVPN/linux/proton-vpn-gtk-app/proton/vpn/app/gtk/controller.py", "abs_path": "/home/tester/src/ProtonVPN/linux/proton-vpn-gtk-app/proton/vpn/app/gtk/controller.py", "function": "disconnect", "module": "proton.vpn.app.gtk.controller", "lineno": 224, "pre_context": [ " :return: A Future object that resolves once the connection reaches the", " \"disconnected\" state.", " \"\"\"", " error = FileNotFoundError(\"This method is not implemented\")", " error.filename = \"/home/wozniak/randomfile.py\"" ], "context_line": " raise error", "post_context": [ "", " return self.executor.submit(self._connector.disconnect)", "", " @property", " def account_name(self) -> str:" ], "vars": { "self": "", "error": "FileNotFoundError('This method is not implemented')" }, "in_app": True } ] } @pytest.mark.parametrize("enabled", [True, False]) def test_usage_report_enabled(enabled): report_error = SimpleNamespace(invoked=False) usage_reporting = UsageReporting(ClientTypeMetadata("test_usage.py", "none")) def capture_exception(error): report_error.invoked = True usage_reporting.enabled = enabled usage_reporting._capture_exception = capture_exception EMPTY_ERROR = None usage_reporting.report_error(EMPTY_ERROR) assert report_error.invoked == enabled, "UsageReporting enable state does not match the error reporting" def test_sanitize_event(): event = copy.deepcopy(EVENT_TO_SEND) UsageReporting._sanitize_event(event, None, "tester") assert USERNAME in json.dumps(EVENT_TO_SEND), "Username should be in the event" assert USERNAME not in json.dumps(event), "Username should not be in the event" def test_userid_calaculation(): with tempfile.NamedTemporaryFile() as file: file.write(MACHINE_ID.encode('utf-8')) file.seek(0) assert UsageReporting._get_user_id( machine_id_filepath=file.name, user_name=USERNAME) == SENTRY_USER_ID, "Error hashing does not match the expected value" python-proton-vpn-api-core-0.39.0/tests/killswitch/000077500000000000000000000000001473026673700222775ustar00rootroot00000000000000python-proton-vpn-api-core-0.39.0/tests/killswitch/__init__.py000066400000000000000000000000001473026673700243760ustar00rootroot00000000000000python-proton-vpn-api-core-0.39.0/tests/killswitch/test_killswitch.py000066400000000000000000000024551473026673700260730ustar00rootroot00000000000000""" Copyright (c) 2023 Proton AG This file is part of Proton VPN. Proton VPN 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. Proton VPN 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 ProtonVPN. If not, see . """ import pytest from proton.vpn.killswitch.interface import KillSwitch def test_instantiation_of_abstract_killswitch_class_fails(): with pytest.raises(TypeError): KillSwitch() class KillSwitchImpl(KillSwitch): async def enable(self, vpn_server=None): pass async def disable(self): pass async def enable_ipv6_leak_protection(self): pass async def disable_ipv6_leak_protection(self): pass async def _validate(self): pass async def _get_priority(self): return 1 def test_subclass_instantiation_with_required_method_implementations(): KillSwitchImpl() python-proton-vpn-api-core-0.39.0/tests/logger/000077500000000000000000000000001473026673700214015ustar00rootroot00000000000000python-proton-vpn-api-core-0.39.0/tests/logger/test_logger.py000066400000000000000000000066401473026673700242770ustar00rootroot00000000000000""" Copyright (c) 2023 Proton AG This file is part of Proton VPN. Proton VPN 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. Proton VPN 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 ProtonVPN. If not, see . """ import pytest import tempfile from proton.vpn import logging import logging as _logging @pytest.fixture(scope="module") def test_logger(): with tempfile.TemporaryDirectory() as tmpdir: logging.config("test-file", logdirpath=tmpdir) logger = logging.getLogger(__name__) logger.setLevel(_logging.DEBUG) yield logger def log_debug(logger): logger.debug("test-message-debug", category="CAT", event="EV") def log_info(logger): logger.info("test-message-info", category="CAT", event="EV") def log_warning(logger): logger.warning("warning", category="CAT", event="EV") def log_error(logger): logger.error("error", category="CAT", event="EV") def log_critical(logger): logger.critical("critical", category="CAT", event="EV") def log_exception(logger): try: raise Exception("test") except Exception: logger.exception("exception", category="CAT", event="EV") def test_debug_with_custom_properties(caplog, test_logger): caplog.clear() log_debug(test_logger) for record in caplog.records: assert record.levelname == "DEBUG" assert len(caplog.records) == 1 def test_info_with_custom_properties(caplog, test_logger): caplog.clear() log_info(test_logger) for record in caplog.records: assert record.levelname == "INFO" assert len(caplog.records) == 1 def test_warning_with_custom_properties(caplog, test_logger): caplog.clear() log_warning(test_logger) for record in caplog.records: assert record.levelname == "WARNING" assert len(caplog.records) == 1 def test_error_with_custom_properties(caplog, test_logger): caplog.clear() log_error(test_logger) for record in caplog.records: assert record.levelname == "ERROR" assert len(caplog.records) == 1 def test_critical_with_custom_properties(caplog, test_logger): caplog.clear() log_critical(test_logger) for record in caplog.records: assert record.levelname == "CRITICAL" assert len(caplog.records) == 1 def test_exception_with_custom_properties(caplog, test_logger): caplog.clear() log_exception(test_logger) for record in caplog.records: assert record.levelname == "ERROR" assert len(caplog.records) == 1 assert "exception" in caplog.text def test_debug_with_only_message_logging_properties(caplog, test_logger): caplog.clear() test_logger.debug(msg="test-default-debug") for record in caplog.records: assert record.levelname == "DEBUG" assert len(caplog.records) == 1 assert "test-default-debug" in caplog.text def test_debug_with_no_logging_properties(caplog, test_logger): caplog.clear() test_logger.debug(msg="") assert len(caplog.records) == 1 assert "" in caplog.text python-proton-vpn-api-core-0.39.0/tests/session/000077500000000000000000000000001473026673700216055ustar00rootroot00000000000000python-proton-vpn-api-core-0.39.0/tests/session/__init__.py000066400000000000000000000012461473026673700237210ustar00rootroot00000000000000""" Copyright (c) 2024 Proton AG This file is part of Proton VPN. Proton VPN 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. Proton VPN 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 ProtonVPN. If not, see . """ python-proton-vpn-api-core-0.39.0/tests/session/data/000077500000000000000000000000001473026673700225165ustar00rootroot00000000000000python-proton-vpn-api-core-0.39.0/tests/session/data/README.md000066400000000000000000000003631473026673700237770ustar00rootroot00000000000000Important ========= The certificate fetched from the API was fetched with the given private key in vpn_secrets.json. The reason for that being that we want to check if the certificate matches the fingerprint from the corresponding public key.python-proton-vpn-api-core-0.39.0/tests/session/data/api_cert_response.json000066400000000000000000000026071473026673700271220ustar00rootroot00000000000000{ "Code": 1000, "SerialNumber": "154197323", "ClientKeyFingerprint": "a3CzIFFDKF5w4CtPDaz8mWZWzljRb+SqGTkvktCqznMhUemScDonoinYDz8ncOfQw7WI0Ek5aombSVSITnQDTw==", "ClientKey": "-----BEGIN PUBLIC KEY-----\nMCowBQYDK2VwAyEAAoqBxaQgj21lzBd9YG0iotoSoHLXQDYS2LdDtiE6Jtk=\n-----END PUBLIC KEY-----", "Certificate": "-----BEGIN CERTIFICATE-----\nMIICJjCCAdigAwIBAgIECTDdSzAFBgMrZXAwMTEvMC0GA1UEAwwmUHJvdG9uVlBO\nIENsaWVudCBDZXJ0aWZpY2F0ZSBBdXRob3JpdHkwHhcNMjIwMTIwMjAyOTIxWhcN\nMjIwMTIxMjAyOTIyWjAUMRIwEAYDVQQDDAkxNTQxOTczMjMwKjAFBgMrZXADIQAC\nioHFpCCPbWXMF31gbSKi2hKgctdANhLYt0O2ITom2aOCAS0wggEpMB0GA1UdDgQW\nBBS/pHNS2Vf2irz16Cu8uw07PZHJ9zATBgwrBgEEAYO7aQEAAAAEAwIBADATBgwr\nBgEEAYO7aQEAAAEEAwIBATBQBgwrBgEEAYO7aQEAAAIEQDA+BAh2cG5iYXNpYwQY\ndnBuLWF1dGhvcml6ZWQtZm9yLWNoLTMyBBh2cG4tYXV0aG9yaXplZC1mb3ItY2gt\nMzMwDgYDVR0PAQH/BAQDAgeAMAwGA1UdEwEB/wQCMAAwEwYDVR0lBAwwCgYIKwYB\nBQUHAwIwWQYDVR0jBFIwUIAUs+HMEJai+CKly9zPRAZGLOuSzgWhNaQzMDExLzAt\nBgNVBAMMJlByb3RvblZQTiBDbGllbnQgQ2VydGlmaWNhdGUgQXV0aG9yaXR5ggEB\nMAUGAytlcANBAKK+E6d7Rxn7X1u4s4AtJuD3kj6UjBEC3cFr3+A+tiV/THc19Qkr\n666A5Ass0n2LsjENVnAJ9VQ6x5lg7011sQk=\n-----END CERTIFICATE-----\n", "ExpirationTime": 1642796962, "RefreshTime": 1642775362, "Mode": "session", "DeviceName": "", "ServerPublicKeyMode": "EC", "ServerPublicKey": "-----BEGIN PUBLIC KEY-----\nMCowBQYDK2VwAyEANm3aIvkeaMO9ctcIeEfM4K1ME3bU9feum5sWQ3Sdx+o=\n-----END PUBLIC KEY-----\n" }python-proton-vpn-api-core-0.39.0/tests/session/data/api_vpn_location_response.json000066400000000000000000000002261473026673700306530ustar00rootroot00000000000000{ "Code": 1000, "IP": "83.76.246.115", "Lat": 46.1952, "Long": 6.1436, "Country": "CH", "ISP": "World-Connect Services SARL" }python-proton-vpn-api-core-0.39.0/tests/session/data/api_vpnsessions_response.json000066400000000000000000000005261473026673700305550ustar00rootroot00000000000000{ "Code": 1000, "Sessions": [ { "SessionID": "9A35C20A09AC0833157B320C408CD679", "ExitIP": "1.2.3.4", "Protocol": "openvpn" }, { "SessionID": "9A35C20A09AC0833157B320C408CD67A", "ExitIP": "5.6.7.8", "Protocol": "openvpn" } ] }python-proton-vpn-api-core-0.39.0/tests/session/data/api_vpnsettings_response.json000066400000000000000000000010331473026673700305410ustar00rootroot00000000000000{ "Code": 1000, "VPN": { "ExpirationTime": 1, "Name": "test", "Password": "passwordtest", "GroupID": "testgroup", "Status": 1, "PlanName": "free", "PlanTitle": "mock_title", "MaxTier": 0, "MaxConnect": 2, "Groups": [ "vpnfree" ], "NeedConnectionAllocation": false }, "Services": 5, "Subscribed": 0, "Delinquent": 0, "HasPaymentMethod": 1, "Credit": 17091, "Currency": "EUR", "Warnings": [] }python-proton-vpn-api-core-0.39.0/tests/session/data/vpn_secrets.json000066400000000000000000000001151473026673700257410ustar00rootroot00000000000000{ "ed25519_privatekey" : "rNW3dL5A3dUrQX3ZKbVAFLjSFJdvDU5JzjrRrnI+cos=" }python-proton-vpn-api-core-0.39.0/tests/session/dataclasses/000077500000000000000000000000001473026673700240745ustar00rootroot00000000000000python-proton-vpn-api-core-0.39.0/tests/session/dataclasses/__init__.py000066400000000000000000000012461473026673700262100ustar00rootroot00000000000000""" Copyright (c) 2024 Proton AG This file is part of Proton VPN. Proton VPN 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. Proton VPN 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 ProtonVPN. If not, see . """ python-proton-vpn-api-core-0.39.0/tests/session/dataclasses/test_certificate.py000066400000000000000000000031531473026673700277710ustar00rootroot00000000000000""" Copyright (c) 2024 Proton AG This file is part of Proton VPN. Proton VPN 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. Proton VPN 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 ProtonVPN. If not, see . """ from dataclasses import asdict import pytest from proton.vpn.session.dataclasses import VPNCertificate @pytest.fixture def vpncertificate_data(): return { "SerialNumber": "asd879hnna!as", "ClientKeyFingerprint": "fingerprint", "ClientKey": "as243sdfs4", "Certificate": "certificate", "ExpirationTime": 123456789, "RefreshTime": 123456789, "Mode": "on", "DeviceName": "mock-device", "ServerPublicKeyMode": "mock-mode", "ServerPublicKey": "mock-key" } def test_vpncertificate_deserializes_expected_dict_keys(vpncertificate_data): vpncertificate = VPNCertificate.from_dict(vpncertificate_data) assert asdict(vpncertificate) == vpncertificate_data def test_vpncertificate_deserialize_should_not_crash_with_unexpected_dict_keys(vpncertificate_data): vpncertificate_data["unexpected_keyword"] = "keyword and data" VPNCertificate.from_dict(vpncertificate_data) python-proton-vpn-api-core-0.39.0/tests/session/dataclasses/test_location.py000066400000000000000000000024621473026673700273210ustar00rootroot00000000000000""" Copyright (c) 2024 Proton AG This file is part of Proton VPN. Proton VPN 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. Proton VPN 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 ProtonVPN. If not, see . """ import pytest from dataclasses import asdict from proton.vpn.session.dataclasses import VPNLocation @pytest.fixture def vpnlocation_data(): return { "IP": "192.168.0.1", "Country": "Switzerland", "ISP": "SwissRandomProvider", } def test_vpnlocation_deserializes_expected_dict_keys(vpnlocation_data): vpnlocation = VPNLocation.from_dict(vpnlocation_data) assert asdict(vpnlocation) == vpnlocation_data def test_vpnlocation_deserialize_should_not_crash_with_unexpected_dict_keys(vpnlocation_data): vpnlocation_data["unexpected_keyword"] = "keyword and data" VPNLocation.from_dict(vpnlocation_data) python-proton-vpn-api-core-0.39.0/tests/session/dataclasses/test_session.py000066400000000000000000000043331473026673700271730ustar00rootroot00000000000000""" Copyright (c) 2024 Proton AG This file is part of Proton VPN. Proton VPN 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. Proton VPN 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 ProtonVPN. If not, see . """ from dataclasses import asdict import pytest from proton.vpn.session.dataclasses import VPNSessions, APIVPNSession @pytest.fixture def vpnsession_data(): return { "SessionID": "session1", "ExitIP": "2.2.2.1", "Protocol": "openvpn-tcp", } def test_vpnsession_deserializes_expected_dict_keys(vpnsession_data): vpnsession = APIVPNSession.from_dict(vpnsession_data) assert asdict(vpnsession) == vpnsession_data def test_vpnsession_deserialize_should_not_crash_with_unexpected_dict_keys(vpnsession_data): vpnsession_data["unexpected_keyword"] = "keyword and data" APIVPNSession.from_dict(vpnsession_data) @pytest.fixture def vpnsessions_data(): return { "Sessions": [ { "SessionID": "session1", "ExitIP": "2.2.2.1", "Protocol": "openvpn-tcp", }, { "SessionID": "session2", "ExitIP": "2.2.2.3", "Protocol": "openvpn-udp", }, { "SessionID": "session3", "ExitIP": "2.2.2.53", "Protocol": "wireguard", } ] } def test_vpnsessions_deserializes_expected_dict_keys(vpnsessions_data): vpnsessions = VPNSessions.from_dict(vpnsessions_data) assert asdict(vpnsessions) == vpnsessions_data def test_vpnsessions_deserialize_should_not_crash_with_unexpected_dict_keys(vpnsessions_data): vpnsessions_data["unexpected_keyword"] = "keyword and data" VPNSessions.from_dict(vpnsessions_data) python-proton-vpn-api-core-0.39.0/tests/session/dataclasses/test_settings.py000066400000000000000000000042311473026673700273450ustar00rootroot00000000000000""" Copyright (c) 2024 Proton AG This file is part of Proton VPN. Proton VPN 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. Proton VPN 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 ProtonVPN. If not, see . """ from dataclasses import asdict import pytest from proton.vpn.session.dataclasses import VPNSettings, VPNInfo @pytest.fixture def vpninfo_data(): return { "ExpirationTime": 1, "Name": "random_user", "Password": "asdKJkjb12", "GroupID": "test-group", "Status": 1, "PlanName": "test plan", "PlanTitle": "test title", "MaxTier": 1, "MaxConnect": 1, "Groups": ["group1", "group2"], "NeedConnectionAllocation": False, } def test_vpninfo_deserializes_expected_dict_keys(vpninfo_data): vpninfo = VPNInfo.from_dict(vpninfo_data) assert asdict(vpninfo) == vpninfo_data def test_vpninfo_deserialize_should_not_crash_with_unexpected_dict_keys(vpninfo_data): vpninfo_data["unexpected_keyword"] = "keyword and data" VPNInfo.from_dict(vpninfo_data) @pytest.fixture def vpnsettings_data(vpninfo_data): return { "VPN": vpninfo_data, "Services": 1, "Subscribed": 1, "Delinquent": 0, "HasPaymentMethod": 1, "Credit": 1234, "Currency": "€", "Warnings": [], } def test_vpnsettings_deserializes_expected_dict_keys(vpnsettings_data): vpnsettings = VPNSettings.from_dict(vpnsettings_data) assert asdict(vpnsettings) == vpnsettings_data def test_vpnsettings_deserialize_should_not_crash_with_unexpected_dict_keys(vpnsettings_data): vpnsettings_data["unexpected_keyword"] = "keyword and data" VPNSettings.from_dict(vpnsettings_data) python-proton-vpn-api-core-0.39.0/tests/session/servers/000077500000000000000000000000001473026673700232765ustar00rootroot00000000000000python-proton-vpn-api-core-0.39.0/tests/session/servers/__init__.py000066400000000000000000000012461473026673700254120ustar00rootroot00000000000000""" Copyright (c) 2023 Proton AG This file is part of Proton VPN. Proton VPN 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. Proton VPN 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 ProtonVPN. If not, see . """ python-proton-vpn-api-core-0.39.0/tests/session/servers/test_fetcher.py000066400000000000000000000020031473026673700263220ustar00rootroot00000000000000""" Copyright (c) 2023 Proton AG This file is part of Proton VPN. Proton VPN 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. Proton VPN 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 ProtonVPN. If not, see . """ import pytest from proton.vpn.session.servers.fetcher import truncate_ip_address def test_truncate_ip_replaces_last_ip_address_byte_with_a_zero(): assert truncate_ip_address("1.2.3.4") == "1.2.3.0" def test_truncate_ip_raises_exception_when_ip_address_is_invalid(): with pytest.raises(ValueError): truncate_ip_address("foobar") python-proton-vpn-api-core-0.39.0/tests/session/servers/test_logicals.py000066400000000000000000000113721473026673700265100ustar00rootroot00000000000000""" Copyright (c) 2023 Proton AG This file is part of Proton VPN. Proton VPN 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. Proton VPN 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 ProtonVPN. If not, see . """ from proton.vpn.session.servers import LogicalServer, ServerFeatureEnum from proton.vpn.session.servers.logicals import sort_servers_alphabetically_by_country_and_server_name, ServerList def test_server_list_get_fastest(): api_response = { "Code": 1000, "LogicalServers": [ { "ID": 1, "Name": "JP#10", "Status": 1, "Servers": [{"Status": 1}], "Score": 15.0, # AR#9 has better score (lower is better) "Tier": 2, "ExitCountry": "JP", }, { "ID": 2, "Name": "AR#11", "Status": 1, "Servers": [{"Status": 1}], "Score": 1.0, # Even though it has a better score than CH#9, "Tier": 3, # it's not in the user tier (2). "ExitCountry": "AR", }, { "ID": 3, "Name": "AR#9", "Status": 1, "Servers": [{"Status": 1}], "Score": 10.0, # Fastest server in the user tier (2) "Tier": 2, "ExitCountry": "AR", }, { "ID": 4, "Name": "CH#18-TOR", "Status": 1, "Servers": [{"Status": 1}], "Score": 7.0, # Even though it has a better score than AR#9, "Features": ServerFeatureEnum.TOR, # TOR servers should be ignored. "Tier": 2, "ExitCountry": "CH", }, { "ID": 5, "Name": "CH-US#1", "Status": 1, "Servers": [{"Status": 1}], "Score": 8.0, # Even though it has a better score than AR#9, "Features": ServerFeatureEnum.SECURE_CORE, # secure core servers should be ignored. "Tier": 2, "ExitCountry": "CH", }, { "ID": 6, "Name": "JP#1", "Score": 9.0, # Even though it has a better score than AR#9, "Status": 0, # this server is not enabled. "Servers": [{"Status": 0}], "Tier": 2, "ExitCountry": "JP", }, ] } server_list = ServerList( user_tier=2, logicals=[LogicalServer(ls) for ls in api_response["LogicalServers"]] ) fastest = server_list.get_fastest() assert fastest.name == "AR#9" def test_sort_servers_alphabetically_by_country_and_server_name(): api_response = { "Code": 1000, "LogicalServers": [ { "ID": 2, "Name": "AR#10", "Status": 1, "Servers": [{"Status": 1}], "ExitCountry": "AR", }, { "ID": 1, "Name": "JP-FREE#10", "Status": 1, "Servers": [{"Status": 1}], "ExitCountry": "JP", }, { "ID": 3, "Name": "AR#9", "Status": 1, "Servers": [{"Status": 1}], "ExitCountry": "AR", }, { "ID": 5, "Name": "Random Name", "Status": 1, "Servers": [{"Status": 1}], "ExitCountry": "JP", }, { "ID": 4, "Name": "JP#9", "Status": 1, "Servers": [{"Status": 1}], "ExitCountry": "JP", }, ] } logicals = [LogicalServer(server_dict) for server_dict in api_response["LogicalServers"]] logicals.sort(key=sort_servers_alphabetically_by_country_and_server_name) expected_server_name_order = ["AR#9", "AR#10", "JP#9", "JP-FREE#10", "Random Name"] actual_server_name_order = [server.name for server in logicals] assert actual_server_name_order == expected_server_name_order python-proton-vpn-api-core-0.39.0/tests/session/servers/test_types.py000066400000000000000000000111641473026673700260560ustar00rootroot00000000000000""" Copyright (c) 2023 Proton AG This file is part of Proton VPN. Proton VPN 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. Proton VPN 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 ProtonVPN. If not, see . """ from proton.vpn.session.exceptions import ServerNotFoundError from proton.vpn.session.servers.types import PhysicalServer, LogicalServer, ServerLoad from proton.vpn.session.servers.country_codes import get_country_name_by_code import pytest ID = "PHYS_SERVER_1" ENTRY_IP = "192.168.0.1" EXIT_IP = "192.168.0.2" DOMAIN = "test.mock-domain.net" STATUS = 1 GENERATION = 0 LABEL = "TestLabel" SERVICEDOWNREASON = None X25519_PK = "UBA8UbeQMmwfFeBp2lwwqwa/aF606BQKjzKHmNoJ03E=" MOCK_PHYSICAL = { "ID": ID, "EntryIP": ENTRY_IP, "ExitIP": EXIT_IP, "Domain": DOMAIN, "Status": STATUS, "Generation": GENERATION, "Label": LABEL, "ServicesDownReason": SERVICEDOWNREASON, "X25519PublicKey": X25519_PK, } NAME = "MOCK-SERVER#1" ENTRYCOUNTRY = "CA" EXITCOUNTRY = "CA" TIER = 0 FEATURES = 0 REGION = None CITY = "Toronto" SCORE = 2.4273928 HOSTCOUNTRY = None L_ID = "BzHqSTaqcpjIY9SncE5s7FpjBrPjiGOucCyJmwA6x4nTNqlElfKvCQFr9xUa2KgQxAiHv4oQQmAkcA56s3ZiGQ==" LAT = 32 LONG = 40 L_STATUS = 1 LOAD = 45 MOCK_LOGICAL = { "Name": NAME, "EntryCountry": ENTRYCOUNTRY, "ExitCountry": EXITCOUNTRY, "Domain": DOMAIN, "Tier": TIER, "Features": FEATURES, "Region": REGION, "City": CITY, "Score": SCORE, "HostCountry": HOSTCOUNTRY, "ID": L_ID, "Location": { "Lat": LAT, "Long": LONG }, "Status": L_STATUS, "Servers": [MOCK_PHYSICAL], "Load": LOAD } class TestPhysicalServer: def test_init_server(self): server = PhysicalServer(MOCK_PHYSICAL) assert server.id == ID assert server.entry_ip == ENTRY_IP assert server.exit_ip == EXIT_IP assert server.domain == DOMAIN assert server.enabled == STATUS assert server.generation == GENERATION assert server.label == LABEL assert server.services_down_reason == SERVICEDOWNREASON assert server.x25519_pk == X25519_PK class TestLogicalServer: def test_init_server(self): server = LogicalServer(MOCK_LOGICAL) assert server.id == L_ID assert server.load == LOAD assert server.score == SCORE assert server.enabled == L_STATUS assert server.name == NAME assert server.entry_country == ENTRYCOUNTRY assert server.entry_country_name == get_country_name_by_code(server.entry_country) assert server.exit_country == EXITCOUNTRY assert server.exit_country_name == get_country_name_by_code(server.exit_country) assert server.host_country == HOSTCOUNTRY assert server.features == [] assert server.region == REGION assert server.city == CITY assert server.tier == TIER assert server.latitude == LAT assert server.longitude == LONG assert server.physical_servers[0].domain == PhysicalServer(MOCK_PHYSICAL).domain assert server.physical_servers[0].entry_ip == PhysicalServer(MOCK_PHYSICAL).entry_ip assert server.physical_servers[0].exit_ip == PhysicalServer(MOCK_PHYSICAL).exit_ip def test_update(self): server = LogicalServer(MOCK_LOGICAL) server_load = ServerLoad({ "ID": L_ID, "Load": 55, "Score": 3.14159, "enabled": 0 }) server.update(server_load) assert server.load == 55 assert server.score == 3.14159 assert not server.enabled def test_get_data(self): server = LogicalServer(MOCK_LOGICAL) _data = server.data _data["Name"] = "test-name" assert server.name != _data["Name"] def test_get_random_server(self): server = LogicalServer(MOCK_LOGICAL) _s = server.get_random_physical_server() assert _s.x25519_pk == X25519_PK def test_get_random_server_raises_exception(self): logical_copy = MOCK_LOGICAL.copy() logical_copy["Servers"] = [] server = LogicalServer(logical_copy) with pytest.raises(ServerNotFoundError): server.get_random_physical_server() python-proton-vpn-api-core-0.39.0/tests/session/test_clientconfig.py000066400000000000000000000061171473026673700256670ustar00rootroot00000000000000""" Copyright (c) 2023 Proton AG This file is part of Proton VPN. Proton VPN 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. Proton VPN 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 ProtonVPN. If not, see . """ import pytest from proton.vpn.session.exceptions import ClientConfigDecodeError from proton.vpn.session.session import ClientConfig import time EXPIRATION_TIME = time.time() @pytest.fixture def apidata(): return { "Code": 1000, "DefaultPorts": { "OpenVPN": { "UDP": [80, 51820, 4569, 1194, 5060], "TCP": [443, 7770, 8443] }, "WireGuard": { "UDP": [443, 88, 1224, 51820, 500, 4500], "TCP": [443], } }, "HolesIPs": ["62.112.9.168", "104.245.144.186"], "ServerRefreshInterval": 10, "FeatureFlags": { "NetShield": True, "GuestHoles": False, "ServerRefresh": True, "StreamingServicesLogos": True, "PortForwarding": True, "ModerateNAT": True, "SafeMode": False, "StartConnectOnBoot": True, "PollNotificationAPI": True, "VpnAccelerator": True, "SmartReconnect": True, "PromoCode": False, "WireGuardTls": True, "Telemetry": True, "NetShieldStats": True }, "SmartProtocol": { "OpenVPN": True, "IKEv2": True, "WireGuard": True, "WireGuardTCP": True, "WireGuardTLS": True }, "RatingSettings": { "EligiblePlans": [], "SuccessConnections": 3, "DaysLastReviewPassed": 100, "DaysConnected": 3, "DaysFromFirstConnection": 14 }, "ExpirationTime": EXPIRATION_TIME } def test_from_dict(apidata): client_config = ClientConfig.from_dict(apidata) assert client_config.openvpn_ports.udp == apidata["DefaultPorts"]["OpenVPN"]["UDP"] assert client_config.openvpn_ports.tcp == apidata["DefaultPorts"]["OpenVPN"]["TCP"] assert client_config.wireguard_ports.udp == apidata["DefaultPorts"]["WireGuard"]["UDP"] assert client_config.wireguard_ports.tcp == apidata["DefaultPorts"]["WireGuard"]["TCP"] assert client_config.holes_ips == apidata["HolesIPs"] assert client_config.server_refresh_interval == apidata["ServerRefreshInterval"] assert client_config.expiration_time == EXPIRATION_TIME def test_from_dict_raises_error_when_dict_does_not_have_expected_keys(): with pytest.raises(ClientConfigDecodeError): ClientConfig.from_dict({}) python-proton-vpn-api-core-0.39.0/tests/session/test_feature_flags_fetcher.py000066400000000000000000000062321473026673700275300ustar00rootroot00000000000000""" Copyright (c) 2024 Proton AG This file is part of Proton VPN. Proton VPN 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. Proton VPN 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 ProtonVPN. If not, see . """ from unittest.mock import Mock, patch import pytest import time from proton.vpn.session.feature_flags_fetcher import FeatureFlagsFetcher, DEFAULT, FeatureFlags EXPIRATION_TIME = time.time() @pytest.fixture def apidata(): return { "Code": 1000, "toggles": DEFAULT["toggles"] } @patch("proton.vpn.session.feature_flags_fetcher.rest_api_request") @pytest.mark.asyncio async def test_fetch_returns_feature_flags_from_proton_rest_api(mock_rest_api_request, apidata): mock_cache_handler = Mock() mock_refresh_calculator = Mock() expiration_time_in_seconds = 10 mock_refresh_calculator.get_expiration_time.return_value = expiration_time_in_seconds mock_rest_api_request.return_value = apidata ff = FeatureFlagsFetcher(Mock(), mock_refresh_calculator, mock_cache_handler) features = await ff.fetch() assert features.get("LinuxBetaToggle") == apidata["toggles"][0]["enabled"] assert features.get("WireGuardExperimental") == apidata["toggles"][1]["enabled"] assert features.get("TimestampedLogicals") == apidata["toggles"][2]["enabled"] def test_load_from_cache_returns_feature_flags_from_cache(apidata): mock_cache_handler = Mock() expiration_time_in_seconds = time.time() apidata["ExpirationTime"] = expiration_time_in_seconds mock_cache_handler.load.return_value = apidata ff = FeatureFlagsFetcher(Mock(), Mock(), mock_cache_handler) features = ff.load_from_cache() assert features.get("LinuxBetaToggle") == apidata["toggles"][0]["enabled"] assert features.get("WireGuardExperimental") == apidata["toggles"][1]["enabled"] assert features.get("TimestampedLogicals") == apidata["toggles"][2]["enabled"] def test_load_from_cache_returns_default_feature_flags_when_no_cache_is_found(): mock_cache_handler = Mock() mock_cache_handler.load.return_value = None ff = FeatureFlagsFetcher(Mock(), Mock(), mock_cache_handler) features = ff.load_from_cache() assert features.get("LinuxBetaToggle") == DEFAULT["toggles"][0]["enabled"] assert features.get("WireGuardExperimental") == DEFAULT["toggles"][1]["enabled"] assert features.get("TimestampedLogicals") == DEFAULT["toggles"][2]["enabled"] def test_get_feature_flag_returns_false_when_feature_flag_does_not_exist(apidata): mock_cache_handler = Mock() mock_cache_handler.load.return_value = apidata ff = FeatureFlagsFetcher(Mock(), Mock(), mock_cache_handler) features = ff.load_from_cache() assert features.get("dummy-feature") is False python-proton-vpn-api-core-0.39.0/tests/session/test_fetcher.py000066400000000000000000000024061473026673700246400ustar00rootroot00000000000000""" Copyright (c) 2023 Proton AG This file is part of Proton VPN. Proton VPN 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. Proton VPN 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 ProtonVPN. If not, see . """ import proton.vpn.session.fetcher as fetcher from proton.vpn.session.fetcher import VPNSessionFetcher from proton.vpn.core.settings import Features def test_extract_features(): actual = VPNSessionFetcher._convert_features( Features( netshield=2, moderate_nat=False, vpn_accelerator=False, port_forwarding=True, ) ) expected = { fetcher.API_NETSHIELD: 2, fetcher.API_VPN_ACCELERATOR: False, fetcher.API_MODERATE_NAT: False, fetcher.API_PORT_FORWARDING: True, } assert actual == expected python-proton-vpn-api-core-0.39.0/tests/session/test_session.py000066400000000000000000000103221473026673700246770ustar00rootroot00000000000000""" Copyright (c) 2023 Proton AG This file is part of Proton VPN. Proton VPN 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. Proton VPN 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 ProtonVPN. If not, see . """ import tempfile from os.path import basename from unittest.mock import patch from unittest.mock import Mock import pytest from proton.vpn.session import VPNSession from proton.vpn.session.dataclasses import BugReportForm MOCK_ISP = "Proton ISP" MOCK_COUNTRY = "Middle Earth" def create_mock_vpn_account(): vpn_account = Mock vpn_account.location = Mock() vpn_account.location.ISP = MOCK_ISP vpn_account.location.Country = MOCK_COUNTRY return vpn_account @pytest.mark.asyncio async def test_submit_report(): s = VPNSession() s._vpn_account = create_mock_vpn_account() attachments = [] with tempfile.NamedTemporaryFile(mode="rb") as attachment1, tempfile.NamedTemporaryFile(mode="rb") as attachment2: attachments.append(attachment1) attachments.append(attachment2) bug_report = BugReportForm( username="test_user", email="email@pm.me", title="This is a title example", description="This is a description example", client_version="1.0.0", client="Example", attachments=attachments ) with patch.object(s, "async_api_request") as patched_async_api_request: await s.submit_bug_report(bug_report) patched_async_api_request.assert_called_once() api_request_kwargs = patched_async_api_request.call_args.kwargs assert api_request_kwargs["endpoint"] == s.BUG_REPORT_ENDPOINT submitted_data = api_request_kwargs["data"] assert len(submitted_data.fields) == 13 form_field = submitted_data.fields[0] assert form_field.name == "OS" assert form_field.value == bug_report.os form_field = submitted_data.fields[1] assert form_field.name == "OSVersion" assert form_field.value == bug_report.os_version form_field = submitted_data.fields[2] assert form_field.name == "Client" assert form_field.value == bug_report.client form_field = submitted_data.fields[3] assert form_field.name == "ClientVersion" assert form_field.value == bug_report.client_version form_field = submitted_data.fields[4] assert form_field.name == "ClientType" assert form_field.value == bug_report.client_type form_field = submitted_data.fields[5] assert form_field.name == "Title" assert form_field.value == bug_report.title form_field = submitted_data.fields[6] assert form_field.name == "Description" assert form_field.value == bug_report.description form_field = submitted_data.fields[7] assert form_field.name == "Username" assert form_field.value == bug_report.username form_field = submitted_data.fields[8] assert form_field.name == "Email" assert form_field.value == bug_report.email form_field = submitted_data.fields[9] assert form_field.name == "ISP" assert form_field.value == MOCK_ISP form_field = submitted_data.fields[10] assert form_field.name == "Country" assert form_field.value == MOCK_COUNTRY form_field = submitted_data.fields[11] assert form_field.name == "Attachment-0" assert form_field.value == bug_report.attachments[0] assert form_field.filename == basename(form_field.value.name) form_field = submitted_data.fields[12] assert form_field.name == "Attachment-1" assert form_field.value == bug_report.attachments[1] assert form_field.filename == basename(form_field.value.name) python-proton-vpn-api-core-0.39.0/tests/session/test_utils.py000066400000000000000000000007561473026673700243660ustar00rootroot00000000000000import pytest from proton.vpn.session.utils import to_semver_build_metadata_format @pytest.mark.parametrize("input,expected_output", [ ("x86_64", "x86-64"), # Underscores are replaced by hyphens ("aarch64", "aarch64"), ("!@#$%^&*()+=<>~,./?\\|[]{} ", ""), # Only alphanumeric characters and hyphens allowed. ("", ""), (None, None) ]) def test_to_semver_build_metadata_format(input, expected_output): assert to_semver_build_metadata_format(input) == expected_output python-proton-vpn-api-core-0.39.0/tests/session/test_vpnaccount.py000066400000000000000000000177721473026673700254140ustar00rootroot00000000000000""" Copyright (c) 2023 Proton AG This file is part of Proton VPN. Proton VPN 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. Proton VPN 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 ProtonVPN. If not, see . """ import json import pathlib import pytest from proton.vpn.session import VPNSession, VPNPubkeyCredentials from proton.vpn.session.fetcher import ( VPNCertificate, VPNSessions, VPNSettings ) from proton.vpn.session.credentials import VPNSecrets from proton.vpn.session.dataclasses import VPNLocation from proton.vpn.session.certificates import Certificate from proton.vpn.session.exceptions import ( VPNCertificateExpiredError, VPNCertificateFingerprintError, VPNCertificateError ) DATA_DIR = pathlib.Path(__file__).parent.absolute() / 'data' with open(DATA_DIR / 'api_cert_response.json', 'r') as f: VPN_CERTIFICATE_API_RESPONSE = json.load(f) del VPN_CERTIFICATE_API_RESPONSE["Code"] with open(DATA_DIR / 'api_vpnsettings_response.json', 'r') as f: VPN_API_RESPONSE = json.load(f) del VPN_API_RESPONSE["Code"] with open(DATA_DIR / 'api_vpnsessions_response.json', 'r') as f: VPN_SESSIONS_API_RESPONSE = json.load(f) del VPN_SESSIONS_API_RESPONSE["Code"] with open(DATA_DIR / 'api_vpn_location_response.json', 'r') as f: VPN_LOCATION_API_RESPONSE = json.load(f) del VPN_LOCATION_API_RESPONSE["Code"] with open(DATA_DIR / 'vpn_secrets.json', 'r') as f: VPN_SECRETS_DICT = json.load(f) class TestVpnAccountSerialize: def test_fingerprints(self): # Check if our fingerprints are matching for secrets, API and Certificate # Get fingerprint from the secrets. Wireguard private key from the API is in ED25519 FORMAT ? private_key = VPN_SECRETS_DICT["ed25519_privatekey"] vpn_secrets = VPNSecrets(private_key) fingerprint_from_secrets = vpn_secrets.proton_fingerprint_from_x25519_pk # Get fingerprint from API fingerprint_from_api = VPN_CERTIFICATE_API_RESPONSE["ClientKeyFingerprint"] # Get fingerprint from Certificate certificate = Certificate(cert_pem=VPN_CERTIFICATE_API_RESPONSE["Certificate"]) fingerprint_from_certificate = certificate.proton_fingerprint assert fingerprint_from_api == fingerprint_from_certificate assert fingerprint_from_secrets == fingerprint_from_certificate def test_vpnaccount_from_dict(self): vpnaccount = VPNSettings.from_dict(VPN_API_RESPONSE) assert vpnaccount.VPN.Name == "test" assert vpnaccount.VPN.Password == "passwordtest" def test_vpnaccount_to_dict(self): assert VPNSettings.from_dict(VPN_API_RESPONSE).to_dict() == VPN_API_RESPONSE def test_vpncertificate_from_dict(self): cert = VPNCertificate.from_dict(VPN_CERTIFICATE_API_RESPONSE) assert cert.SerialNumber == VPN_CERTIFICATE_API_RESPONSE["SerialNumber"] assert cert.ClientKeyFingerprint == VPN_CERTIFICATE_API_RESPONSE["ClientKeyFingerprint"] assert cert.ClientKey == VPN_CERTIFICATE_API_RESPONSE["ClientKey"] assert cert.Certificate == VPN_CERTIFICATE_API_RESPONSE["Certificate"] assert cert.ExpirationTime == VPN_CERTIFICATE_API_RESPONSE["ExpirationTime"] assert cert.RefreshTime == VPN_CERTIFICATE_API_RESPONSE["RefreshTime"] assert cert.Mode == VPN_CERTIFICATE_API_RESPONSE["Mode"] assert cert.DeviceName == VPN_CERTIFICATE_API_RESPONSE["DeviceName"] assert cert.ServerPublicKeyMode == VPN_CERTIFICATE_API_RESPONSE["ServerPublicKeyMode"] assert cert.ServerPublicKey == VPN_CERTIFICATE_API_RESPONSE["ServerPublicKey"] def test_vpncertificate_to_dict(self): assert VPNCertificate.from_dict(VPN_CERTIFICATE_API_RESPONSE).to_dict() == VPN_CERTIFICATE_API_RESPONSE def test_secrets_from_dict(self): secrets = VPNSecrets.from_dict(VPN_SECRETS_DICT) assert secrets.ed25519_privatekey == "rNW3dL5A3dUrQX3ZKbVAFLjSFJdvDU5JzjrRrnI+cos=" def test_secrets_to_dict(self): assert VPNSecrets.from_dict(VPN_SECRETS_DICT).to_dict() == VPN_SECRETS_DICT def test_sessions_from_dict(self): sessions = VPNSessions.from_dict(VPN_SESSIONS_API_RESPONSE) assert(len(sessions.Sessions)==2) assert(sessions.Sessions[0].ExitIP=='1.2.3.4') assert(sessions.Sessions[1].ExitIP=='5.6.7.8') def test_location_from_dict(self): location = VPNLocation.from_dict(VPN_LOCATION_API_RESPONSE) assert location.IP == VPN_LOCATION_API_RESPONSE["IP"] assert location.Country == VPN_LOCATION_API_RESPONSE["Country"] assert location.ISP == VPN_LOCATION_API_RESPONSE["ISP"] def test_location_to_dict(self): # We delete it because the VPNLocation does not contain these two properties, # even though the API response returns these values, del VPN_LOCATION_API_RESPONSE["Lat"] del VPN_LOCATION_API_RESPONSE["Long"] assert VPNLocation.from_dict(VPN_LOCATION_API_RESPONSE).to_dict() == VPN_LOCATION_API_RESPONSE class TestVpnAccount: def test_vpn_session___setstate__(self): vpnsession = VPNSession() vpndata={ "vpn" : { "vpninfo": VPN_API_RESPONSE, "certificate": VPN_CERTIFICATE_API_RESPONSE, "location": VPN_LOCATION_API_RESPONSE, "secrets": VPN_SECRETS_DICT } } vpnsession.__setstate__(vpndata) vpn_account = vpnsession.vpn_account assert vpn_account.max_tier == 0 assert vpn_account.max_connections == 2 assert vpn_account.plan_name == vpndata["vpn"]["vpninfo"]["VPN"]["PlanName"] assert vpn_account.plan_title == vpndata["vpn"]["vpninfo"]["VPN"]["PlanTitle"] assert not vpn_account.delinquent assert vpn_account.location.to_dict() == vpndata["vpn"]["location"] vpncredentials = vpnsession.vpn_account.vpn_credentials assert vpncredentials.userpass_credentials.username == vpndata["vpn"]["vpninfo"]["VPN"]["Name"] assert vpncredentials.userpass_credentials.password == vpndata["vpn"]["vpninfo"]["VPN"]["Password"] assert vpncredentials.pubkey_credentials.ed_255519_private_key == vpndata["vpn"]["secrets"]["ed25519_privatekey"] class TestPubkeyCredentials: def test_certificate_fingerprint_mismatch(self): # Generate a new keypair. This means its fingerprint won't match the one # from /vpn/v1/certificate. with pytest.raises(VPNCertificateFingerprintError): VPNPubkeyCredentials( api_certificate=VPNCertificate.from_dict(VPN_CERTIFICATE_API_RESPONSE), # A new keypair is generated: its fingerprint won't match the one returned by /vpn/v1/certificate. secrets=VPNSecrets(), ) def test_certificate_duration(self): pubkey_credentials = VPNPubkeyCredentials( api_certificate=VPNCertificate.from_dict(VPN_CERTIFICATE_API_RESPONSE), # A new keypair is generated: its fingerprint won't match the one returned by /vpn/v1/certificate. secrets=VPNSecrets.from_dict(VPN_SECRETS_DICT), ) assert(pubkey_credentials.certificate_duration == 86401.0) def test_expired_certificate(self): with pytest.raises(VPNCertificateExpiredError): pubkey_credentials = VPNPubkeyCredentials( api_certificate=VPNCertificate.from_dict(VPN_CERTIFICATE_API_RESPONSE), # A new keypair is generated: its fingerprint won't match the one returned by /vpn/v1/certificate. secrets=VPNSecrets.from_dict(VPN_SECRETS_DICT), ) pubkey_credentials.certificate_pem() python-proton-vpn-api-core-0.39.0/versions.yml000066400000000000000000000606401473026673700213610ustar00rootroot00000000000000version: 0.39.0 time: 2024/12/17 13:00 author: Alexandru Cheltuitor email: alexandru.cheltuitor@proton.ch urgency: low stability: unstable description: - Update event context so that it passes a forwarded port. --- version: 0.38.6 time: 2024/12/16 10:00 author: Alexandru Cheltuitor email: alexandru.cheltuitor@proton.ch urgency: low stability: unstable description: - Ensure default settings use feature flags even after login the next time they are fetched. --- version: 0.38.5 time: 2024/12/11 16:00 author: Alexandru Cheltuitor email: alexandru.cheltuitor@proton.ch urgency: low stability: unstable description: - Switch default protocol to WireGuard if feature flag is present. --- version: 0.38.4 time: 2024/12/09 12:00 author: Alexandru Cheltuitor email: alexandru.cheltuitor@proton.ch urgency: low stability: unstable description: - Ensure no crash occurs if cache files are non-decodable. - Set default expiration time for features flags to expired, so that they're fetched from the API and cached as soon as possible. --- version: 0.38.2 time: 2024/11/26 11:56 author: Josep Llaneras email: josep.llaneras@proton.ch urgency: low stability: unstable description: - Emit connection state update after state tasks are completed --- version: 0.38.1 time: 2024/11/25 14:00 author: Alexandru Cheltuitor email: alexandru.cheltuitor@proton.ch urgency: low stability: unstable description: - Update how time is calculated in logging module. --- version: 0.38.0 time: 2024/11/19 13:00 author: Alexandru Cheltuitor email: alexandru.cheltuitor@proton.ch urgency: low stability: unstable description: - Drop Ubuntu 20.04 support. --- version: 0.37.2 time: 2024/11/14 16:33 author: Luke Titley email: luke.titley@proton.ch urgency: low stability: unstable description: - Added semgrep scanning to CI. --- version: 0.37.1 time: 2024/11/08 16:00 author: Alexandru Cheltuitor email: alexandru.cheltuitor@proton.ch urgency: low stability: unstable description: - Refactor custom DNS. --- version: 0.37.0 time: 2024/11/05 12:00 author: Alexandru Cheltuitor email: alexandru.cheltuitor@proton.ch urgency: low stability: unstable description: - Introduce custom DNS. --- version: 0.36.6 time: 2024/10/30 14:50 author: Luke Titley email: luke.titley@proton.ch urgency: low stability: unstable description: - Automatically generate the changelog files for debian and fedora. --- version: 0.36.5 time: 2024/10/30 07:00 author: Josep Llaneras email: josep.llaneras@proton.ch urgency: low stability: unstable description: - Switch to /vpn/v2 API. - Use versioned API endpoints. --- version: 0.36.4 time: 2024/10/09 14:50 author: Luke Titley email: luke.titley@proton.ch urgency: low stability: unstable description: - Automatically generate the changelog files for debian and fedora. --- version: 0.36.3 time: 2024/10/09 10:00 author: Luke Titley email: luke.titley@proton.ch urgency: low stability: unstable description: - Fix for certificate based authentication for openvpn, feature flag was out of date. --- version: 0.36.2 time: 2024/10/08 15:00 author: Josep Llaneras email: josep.llaneras@proton.ch urgency: low stability: unstable description: - Fix certificate expired regression --- version: 0.36.1 time: 2024/10/04 10:00 author: Luke Titley email: luke.titley@proton.ch urgency: low stability: unstable description: - Enable certificate based authentication for openvpn. --- version: 0.35.8 time: 2024/10/03 10:00 author: Alexandru Cheltuitor email: alexandru.cheltuitor@proton.ch urgency: low stability: unstable description: - Improve logic on when to update location details. - Add tests. --- version: 0.35.7 time: 2024/10/02 15:00 author: Luke Titley email: luke.titley@proton.ch urgency: low stability: unstable description: - Use a 'before_send' callback in sentry to sanitize events in sentry --- version: 0.35.6 time: 2024/10/02 13:00 author: Alexandru Cheltuitor email: alexandru.cheltuitor@proton.ch urgency: low stability: unstable description: - Update location object after successfully connecting to VPN server via local agent. --- version: 0.35.5 time: 2024/09/27 11:00 author: Josep Llaneras email: josep.llaneras@proton.ch urgency: medium stability: unstable description: - Fix regression sending errors to sentry. --- version: 0.35.4 time: 2024/09/24 12:00 author: Luke Titley email: luke.titley@proton.ch urgency: medium stability: unstable description: - Fix to rpm package.spec, added accidentally removed Obsoletes statement. --- version: 0.35.3 time: 2024/09/24 12:00 author: Luke Titley email: luke.titley@proton.ch urgency: medium stability: unstable description: - Send all errors to sentry, but swallow api errors. --- version: 0.35.2 time: 2024/09/23 12:00 author: Alexandru Cheltuitor email: alexandru.cheltuitor@proton.ch urgency: medium stability: unstable description: - Merge logger package into this one. --- version: 0.35.1 time: 2024/09/23 11:00 author: Josep Llaneras email: josep.llaneras@proton.ch urgency: medium stability: unstable description: - Fix refregresion (logout user on 401 API error). --- version: 0.35.0 time: 2024/09/09 11:00 author: Alexandru Cheltuitor email: alexandru.cheltuitor@proton.ch urgency: medium stability: unstable description: - Catch and send LA errors to sentry. --- version: 0.34.0 time: 2024/09/13 16:00 author: Josep Llaneras email: josep.llaneras@proton.ch urgency: medium stability: unstable description: - Import refreshers from app. --- version: 0.33.12 time: 2024/09/06 11:00 author: Alexandru Cheltuitor email: alexandru.cheltuitor@proton.ch urgency: medium stability: unstable description: - Ensure there is a way to disable IPv6. --- version: 0.33.11 time: 2024/09/02 14:00 author: Alexandru Cheltuitor email: alexandru.cheltuitor@proton.ch urgency: medium stability: unstable description: - Change IPv6 default value and move out of the features dict. --- version: 0.33.10 time: 2024/08/30 16:00 author: Alexandru Cheltuitor email: alexandru.cheltuitor@proton.ch urgency: medium stability: unstable description: - Properly configure OpenVPN with IPv6 value. --- version: 0.33.9 time: 2024/08/29 16:00 author: Alexandru Cheltuitor email: alexandru.cheltuitor@proton.ch urgency: medium stability: unstable description: - Pass IPv6 value. --- version: 0.33.8 time: 2024/08/28 12:00 author: Luke Titley email: luke.titley@proton.ch urgency: medium stability: unstable description: - Put changes to fetching with timestamp (If-Modified-Since), behind a feature flag. --- version: 0.33.7 time: 2024/08/28 11:00 author: Luke Titley email: luke.titley@proton.ch urgency: medium stability: unstable description: - Fixes support for 'If-Modified-Since', expiration times. --- version: 0.33.6 time: 2024/08/27 16:00 author: Luke Titley email: luke.titley@proton.ch urgency: medium stability: unstable description: - Fixes support for 'If-Modified-Since' header in server list requests. --- version: 0.33.5 time: 2024/08/26 16:00 author: Luke Titley email: luke.titley@proton.ch urgency: medium stability: unstable description: - This adds support for 'If-Modified-Since' header in server list requests. --- version: 0.33.4 time: 2024/08/22 16:00 author: Luke Titley email: luke.titley@proton.ch urgency: medium stability: unstable description: - Make sure features cant be request after connection as well. --- version: 0.33.3 time: 2024/08/22 11:30 author: Josep Llaneras email: josep.llaneras@proton.ch urgency: medium stability: unstable description: - Expose property in VPNConnection to know if features can be applied on active connections. --- version: 0.33.2 time: 2024/08/21 16:00 author: Luke Titley email: luke.titley@proton.ch urgency: medium stability: unstable description: - Tier 0 level users can't control the features they have. So don't send any feature requests for them. --- version: 0.33.1 time: 2024/08/21 15:00 author: Josep Llaneras email: josep.llaneras@proton.ch urgency: medium stability: unstable description: - Fix crash after logout --- version: 0.33.0 time: 2024/08/20 16:00 author: Josep Llaneras email: josep.llaneras@proton.ch urgency: medium stability: unstable description: - Get rid of VPNConnectorWrapper. --- version: 0.32.2 time: 2024/08/20 12:00 author: Josep Llaneras email: josep.llaneras@proton.ch urgency: medium stability: unstable description: - Enable wireguard feature flag by default. --- version: 0.32.1 time: 2024/08/12 14:00 author: Josep Llaneras email: josep.llaneras@proton.ch urgency: medium stability: unstable description: - Handle UnicodeDecodeError when loading persisted VPN connection. --- version: 0.32.0 time: 2024/08/12 09:00 author: Josep Llaneras email: josep.llaneras@proton.ch urgency: medium stability: unstable description: - Update connection features via local agent if available. --- version: 0.31.0 time: 2024/08/08 11:00 author: Luke Titley email: luke.titley@proton.ch urgency: medium stability: unstable description: - Disconnect and notify the user when the maximum number of sessions is reached. --- version: 0.30.0 time: 2024/07/26 15:00 author: Alexandru Cheltuitor email: alexandru.cheltuitor@proton.ch urgency: medium stability: unstable description: - Handle ExpiredCertificate events. --- version: 0.29.4 time: 2024/07/17 15:00 author: Alexandru Cheltuitor email: alexandru.cheltuitor@proton.ch urgency: medium stability: unstable description: - Update default feature flags and update feature flags interface. --- version: 0.29.3 time: 2024/07/17 13:00 author: Josep Llaneras email: josep.llaneras@proton.ch urgency: medium stability: unstable description: - Update credentials in the background --- version: 0.29.2 time: 2024/07/12 15:00 author: Josep Llaneras email: josep.llaneras@proton.ch urgency: medium stability: unstable description: - Fix crash initializing VPN connector. --- version: 0.29.1 time: 2024/07/12 15:00 author: Josep Llaneras email: josep.llaneras@proton.ch urgency: medium stability: unstable description: - Update VPN credentials when an active VPN connection is found at startup. --- version: 0.29.0 time: 2024/07/10 15:00 author: Josep Llaneras email: josep.llaneras@proton.ch urgency: medium stability: unstable description: - Merge connection and kill switch packages into this one. --- version: 0.28.1 time: 2024/07/11 12:00 author: Alexandru Cheltuitor email: alexandru.cheltuitor@proton.ch urgency: medium stability: unstable description: - Improve testing to capture when default value is being passed. --- version: 0.28.0 time: 2024/07/10 12:00 author: Alexandru Cheltuitor email: alexandru.cheltuitor@proton.ch urgency: medium stability: unstable description: - Implement and expose feature flags. --- version: 0.27.3 time: 2024/07/09 15:34 author: Luke Titley email: luke.titley@proton.ch urgency: medium stability: unstable description: - Move local agent management into wireguard backend. --- version: 0.27.2 time: 2024/07/09 09:00 author: Josep Llaneras email: josep.llaneras@proton.ch urgency: medium stability: unstable description: - Send CPU architecture following semver's specs. --- version: 0.27.1 time: 2024/07/2 13:00 author: Luke Titley email: luke.titley@proton.ch urgency: medium stability: unstable description: - Switched over to async local agent api. --- version: 0.27.0 time: 2024/07/1 10:00 author: Alexandru Cheltuitor email: alexandru.cheltuitor@proton.ch urgency: medium stability: unstable description: - Attempt to use external local agent package, otherwise fallback to existent one. --- version: 0.26.4 time: 2024/06/24 17:00 author: Luke Titley email: luke.titley@proton.ch urgency: medium stability: unstable description: - Add the architecture in the appversion field for ProtonSSO. --- version: 0.26.3 time: 2024/06/17 17:00 author: Luke Titley email: luke.titley@proton.ch urgency: medium stability: unstable description: - Switch over to automatically generated changelogs for debian and rpm. --- version: 0.26.2 time: 2024/06/10 11:43 author: Josep Llaneras email: josep.llaneras@proton.ch urgency: medium stability: unstable description: - Fix sentry error sanitization crash. --- version: 0.26.1 time: 2024/06/04 13:03 author: Josep Llaneras email: josep.llaneras@proton.ch urgency: medium stability: unstable description: - Fix certificate duration regression. --- version: 0.26.0 time: 2024/05/30 09:37 author: Josep Llaneras email: josep.llaneras@proton.ch urgency: medium stability: unstable description: - Send wireguard certificate to server via local agent. --- version: 0.25.1 time: 2024/05/24 14:55 author: Josep Llaneras email: josep.llaneras@proton.ch urgency: medium stability: unstable description: - Increase certificate duration. --- version: 0.25.0 time: 2024/05/23 10:00 author: Luke Titley email: luke.titley@proton.ch urgency: medium stability: unstable description: - Refactor of Settings to ensure settings are only saved when they are changed. --- version: 0.24.5 time: 2024/05/08 10:00 author: Alexandru Cheltuitor email: alexandru.cheltuitor@proton.ch urgency: medium stability: unstable description: - Stop raising exceptions when getting wireguard certificate and it is expired. --- version: 0.24.4 time: 2024/05/07 10:00 author: Luke Titley email: luke.titley@proton.ch urgency: medium stability: unstable description: - Filter OSError not just FileNotFound error in sentry. --- version: 0.24.3 time: 2024/05/03 10:00 author: Luke Titley email: luke.titley@proton.ch urgency: medium stability: unstable description: - Set the sentry user id based on a hash of /etc/machine-id. --- version: 0.24.2 time: 2024/05/02 15:00 author: Alexandru Cheltuitor email: alexandru.cheltuitor@proton.ch urgency: medium stability: unstable description: - Fix deprecation warning when calculatin WireGuard certificate validity period. --- version: 0.24.1 time: 2024/04/30 15:58 author: Josep Llaneras email: josep.llaneras@proton.ch urgency: medium stability: unstable description: - Fix error saving cache file when parent directory does not exist. --- version: 0.24.0 time: 2024/04/30 16:00 author: Luke Titley email: luke.titley@proton.ch urgency: medium stability: unstable description: - Only initialize sentry on first enable. - Forward SSL_CERT_FILE environment variable to sentry. --- version: 0.23.1 time: 2024/04/23 16:00 author: Luke Titley email: luke.titley@proton.ch urgency: medium stability: unstable description: - Added missing pip dependencies. --- version: 0.23.0 time: 2024/04/22 14:00 author: Luke Titley email: luke.titley@proton.ch urgency: medium stability: unstable description: - Merged proton-vpn-api-session package into this one. --- version: 0.22.5 time: 2024/04/18 16:00 author: Luke Titley email: luke.titley@proton.ch urgency: medium stability: unstable description: - Pass requested features through to session login and two factor submit. --- version: 0.22.4 time: 2024/04/16 15:00 author: Alexandru Cheltuitor email: alexandru.cheltuitor@proton.ch urgency: medium stability: unstable description: - Provide method to update certificate. --- version: 0.22.3 time: 2024/04/10 09:07 author: Luke Titley email: luke.titley@proton.ch urgency: medium stability: unstable description: - Ensure that crash reporting state is preserved between restarts. --- version: 0.22.2 time: 2024/04/10 09:07 author: Luke Titley email: luke.titley@proton.ch urgency: medium stability: unstable description: - Explicitly state the sentry integrations we want. Dont include the ExceptHookIntegration. --- version: 0.22.1 time: 2024/04/10 09:07 author: Luke Titley email: luke.titley@proton.ch urgency: medium stability: unstable description: - Change url for sentry, dont send server_name, use older sentry api. --- version: 0.22.0 time: 2024/04/05 09:07 author: Luke Titley email: luke.titley@proton.ch urgency: medium stability: unstable description: - Add mechanism to send errors anonymously to sentry. --- version: 0.21.2 time: 2024/04/04 09:07 author: Alexandru Cheltuitor email: alexandru.cheltuitor@proton.ch urgency: medium stability: unstable description: - Return list of protocol plugins for a specific backend instead of returning a list of protocols names. --- version: 0.21.1 time: 2024/03/01 09:07 author: Alexandru Cheltuitor email: alexandru.cheltuitor@proton.ch urgency: medium stability: unstable description: - Add WireGuard ports. --- version: 0.21.0 time: 2024/02/16 09:07 author: Josep Llaneras email: josep.llaneras@proton.ch urgency: medium stability: unstable description: - Apply kill switch setting immediately. --- version: 0.20.4 time: 2024/02/14 14:57 author: Josep Llaneras email: josep.llaneras@proton.ch urgency: medium stability: unstable description: - Initialize VPNConnector with settings. --- version: 0.20.3 time: 2023/12/13 11:33 author: Josep Llaneras email: josep.llaneras@proton.ch urgency: medium stability: unstable description: - Make VPN connection API async. --- version: 0.20.2 time: 2023/11/08 08:51 author: Josep Llaneras email: josep.llaneras@proton.ch urgency: medium stability: unstable description: - Make API async and avoid thread-safety issues in asyncio code. - Move bug report submission to proton-vpn-session. --- version: 0.20.1 time: 2023/10/10 10:00 author: Alexandru Cheltuitor email: alexandru.cheltuitor@proton.ch urgency: medium stability: unstable description: - Update dependencies. --- version: 0.20.0 time: 2023/09/15 10:00 author: Alexandru Cheltuitor email: alexandru.cheltuitor@proton.ch urgency: medium stability: unstable description: - Expose properties which allow to access account related data. --- version: 0.19.0 time: 2023/09/04 10:00 author: Alexandru Cheltuitor email: alexandru.cheltuitor@proton.ch urgency: medium stability: unstable description: - Add kill switch to settings and add dependency for base kill switch package. --- version: 0.18.0 time: 2023/07/19 10:00 author: Alexandru Cheltuitor email: alexandru.cheltuitor@proton.ch urgency: medium stability: unstable description: - Rename setting random_nat to moderate_nat to conform to API specs. --- version: 0.17.0 time: 2023/07/07 15:00 author: Alexandru Cheltuitor email: alexandru.cheltuitor@proton.ch urgency: medium stability: unstable description: - Enable NetShield by default on paid plans. --- version: 0.16.0 time: 2023/07/05 13:00 author: Alexandru Cheltuitor email: alexandru.cheltuitor@proton.ch urgency: medium stability: unstable description: - Add protocol entry to settings. --- version: 0.15.0 time: 2023/07/03 15:00 author: Alexandru Cheltuitor email: alexandru.cheltuitor@proton.ch urgency: medium stability: unstable description: - Implement save method for settings. --- version: 0.14.0 time: 2023/06/20 16:00 author: Alexandru Cheltuitor email: alexandru.cheltuitor@proton.ch urgency: medium stability: unstable description: - Remove split tunneling and ipv6 options from settings. --- version: 0.13.0 time: 2023/06/14 15:21 author: Josep Llaneras email: josep.llaneras@proton.ch urgency: medium stability: unstable description: - Expose server loads update. --- version: 0.12.1 time: 2023/06/08 09:57 author: Josep Llaneras email: josep.llaneras@proton.ch urgency: medium stability: unstable description: - Fix settings defaults. --- version: 0.12.0 time: 2023/06/06 15:27 author: Josep Llaneras email: josep.llaneras@proton.ch urgency: medium stability: unstable description: - Pass X-PM-netzone header when retrieving /vpn/logicals and /vpn/loads. --- version: 0.11.0 time: 2023/06/02 12:00 author: Alexandru Cheltuitor email: alexandru.cheltuitor@proton.ch urgency: medium stability: unstable description: - Ensure general settings are taken into account when establishing a vpn connection. --- version: 0.10.3 time: 2023/05/26 16:00 author: Alexandru Cheltuitor email: alexandru.cheltuitor@proton.ch urgency: medium stability: unstable description: - Specify exit IP of physical server. --- version: 0.10.2 time: 2023/04/24 16:00 author: Alexandru Cheltuitor email: alexandru.cheltuitor@proton.ch urgency: medium stability: unstable description: - Fix issue where multiple attachments were overwritten when submitting a bug report. --- version: 0.10.1 time: 2023/04/03 13:54 author: Josep Llaneras email: josep.llaneras@proton.ch urgency: medium stability: unstable description: - Adapt to VPN connection refactoring. --- version: 0.10.0 time: 2023/02/28 09:00 author: Alexandru Cheltuitor email: alexandru.cheltuitor@proton.ch urgency: medium stability: unstable description: - Implement new appversion format. --- version: 0.9.0 time: 2023/02/14 11:00 author: Alexandru Cheltuitor email: alexandru.cheltuitor@proton.ch urgency: medium stability: unstable description: - Use standardized paths for cache and settings. --- version: 0.8.2 time: 2023/02/07 15:00 author: Alexandru Cheltuitor email: alexandru.cheltuitor@proton.ch urgency: medium stability: unstable description: - Do not raise exception during logout if there is an active connection. --- version: 0.8.1 time: 2023/01/20 14:12 author: Josep Llaneras email: josep.llaneras@proton.ch urgency: medium stability: unstable description: - Send bug report using proton-core. --- version: 0.8.0 time: 2023/01/17 11:00 author: Alexandru Cheltuitor email: alexandru.cheltuitor@proton.ch urgency: medium stability: unstable description: - 'Feature: Report a bug.' --- version: 0.7.0 time: 2023/01/13 17:38 author: Josep Llaneras email: josep.llaneras@proton.ch urgency: medium stability: unstable description: - Move get_vpn_server to VPNConnectionHolder. --- version: 0.6.0 time: 2023/01/12 10:31 author: Josep Llaneras email: josep.llaneras@proton.ch urgency: medium stability: unstable description: - Expose methods to load api data from the cache stored in disk. --- version: 0.5.0 time: 2022/12/05 17:39 author: Josep Llaneras email: josep.llaneras@proton.ch urgency: medium stability: unstable description: - Persist VPN server to disk. --- version: 0.4.0 time: 2022/11/29 16:00 author: Alexandru Cheltuitor email: alexandru.cheltuitor@proton.ch urgency: medium stability: unstable description: - Decoupled VPNServers and ClientConfig. - All methods that return a server will now return a LogicalServer instead of VPNServer. --- version: 0.3.1 time: 2022/11/25 16:44 author: Josep Llaneras email: josep.llaneras@proton.ch urgency: medium stability: unstable description: - Check if there is an active connection before logging out. --- version: 0.3.0 time: 2022/11/17 16:00 author: Alexandru Cheltuitor email: alexandru.cheltuitor@proton.ch urgency: medium stability: unstable description: - Fetch and cache clientconfig data from API. --- version: 0.2.7 time: 2022/11/15 17:47 author: Josep Llaneras email: josep.llaneras@proton.ch urgency: medium stability: unstable description: - Allow cancelling a VPN connection before it is established. --- version: 0.2.6 time: 2022/11/15 15:07 author: Josep Llaneras email: josep.llaneras@proton.ch urgency: medium stability: unstable description: - Check connection status before connecting/disconnecting. --- version: 0.2.5 time: 2022/11/11 16:00 author: Alexandru Cheltuitor email: alexandru.cheltuitor@proton.ch urgency: medium stability: unstable description: - Add Proton VPN logging library. --- version: 0.2.4 time: 2022/11/09 16:20 author: Josep Llaneras email: josep.llaneras@proton.ch urgency: medium stability: unstable description: - Lazy load the currently active Proton VPN connection, if existing. --- version: 0.2.3 time: 2022/11/08 10:00 author: Alexandru Cheltuitor email: alexandru.cheltuitor@proton.ch urgency: medium stability: unstable description: - Ensure that appversion and user-agent are passed when making API calls. --- version: 0.2.2 time: 2022/11/04 10:00 author: Alexandru Cheltuitor email: alexandru.cheltuitor@proton.ch urgency: medium stability: unstable description: - Ensure that before establishing a new connection, the previous connection is disconnected, if there is one. --- version: 0.2.1 time: 2022/09/26 15:49 author: Josep Llaneras email: josep.llaneras@proton.ch urgency: medium stability: unstable description: - Delete cache at logout. --- version: 0.2.0 time: 2022/09/22 09:05 author: Josep Llaneras email: josep.llaneras@proton.ch urgency: medium stability: unstable description: - Add method to obtain the user's tier. --- version: 0.1.0 time: 2022/09/20 09:30 author: Alexandru Cheltuitor email: alexandru.cheltuitor@proton.ch urgency: medium stability: UNRELEASED description: - Add logging. --- version: 0.0.4 time: 2022/09/19 08:30 author: Josep Llaneras email: josep.llaneras@proton.ch urgency: medium stability: UNRELEASED description: - Cache VPN connection. --- version: 0.0.3 time: 2022/09/08 07:30 author: Josep Llaneras email: josep.llaneras@proton.ch urgency: medium stability: UNRELEASED description: - VPN servers retrieval. --- version: 0.0.2 time: 2022/05/25 15:38 author: Proton Technologies AG email: opensource@proton.me urgency: medium stability: UNRELEASED description: - Fixing and simplifying 2FA logic. --- version: 0.0.1 time: 2022/03/14 15:38 author: Proton Technologies AG email: opensource@proton.me urgency: medium stability: UNRELEASED description: - First release.