pax_global_header 0000666 0000000 0000000 00000000064 14730266737 0014531 g ustar 00root root 0000000 0000000 52 comment=9da74c9d8a7f370052ecc1ec32eea6ae305d510a
python-proton-vpn-api-core-0.39.0/ 0000775 0000000 0000000 00000000000 14730266737 0016760 5 ustar 00root root 0000000 0000000 python-proton-vpn-api-core-0.39.0/.gitignore 0000664 0000000 0000000 00000000154 14730266737 0020750 0 ustar 00root root 0000000 0000000 build/
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.yml 0000664 0000000 0000000 00000000160 14730266737 0021411 0 ustar 00root root 0000000 0000000 include:
- project: 'ProtonVPN/Linux/integration/ci-libraries'
ref: develop
file: 'develop-pipeline.yml'
python-proton-vpn-api-core-0.39.0/.gitmodules 0000664 0000000 0000000 00000000133 14730266737 0021132 0 ustar 00root root 0000000 0000000 [submodule "scripts/devtools"]
path = scripts/devtools
url = ../integration/devtools.git
python-proton-vpn-api-core-0.39.0/LICENSE 0000664 0000000 0000000 00000104514 14730266737 0017772 0 ustar 00root root 0000000 0000000 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.in 0000664 0000000 0000000 00000000025 14730266737 0020513 0 ustar 00root root 0000000 0000000 include versions.yml
python-proton-vpn-api-core-0.39.0/README.md 0000664 0000000 0000000 00000002353 14730266737 0020242 0 ustar 00root root 0000000 0000000 # 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/ 0000775 0000000 0000000 00000000000 14730266737 0020202 5 ustar 00root root 0000000 0000000 python-proton-vpn-api-core-0.39.0/debian/.gitignore 0000664 0000000 0000000 00000000425 14730266737 0022173 0 ustar 00root root 0000000 0000000 .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/compat 0000664 0000000 0000000 00000000003 14730266737 0021401 0 ustar 00root root 0000000 0000000 11
python-proton-vpn-api-core-0.39.0/debian/control 0000664 0000000 0000000 00000001416 14730266737 0021607 0 ustar 00root root 0000000 0000000 Source: 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/copyright 0000664 0000000 0000000 00000000521 14730266737 0022133 0 ustar 00root root 0000000 0000000 Format: 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/rules 0000775 0000000 0000000 00000000201 14730266737 0021253 0 ustar 00root root 0000000 0000000 #!/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/ 0000775 0000000 0000000 00000000000 14730266737 0017710 5 ustar 00root root 0000000 0000000 python-proton-vpn-api-core-0.39.0/docs/conf.py 0000664 0000000 0000000 00000003732 14730266737 0021214 0 ustar 00root root 0000000 0000000 # 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.rst 0000664 0000000 0000000 00000010204 14730266737 0022061 0 ustar 00root root 0000000 0000000 Application
------------
.. 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.rst 0000664 0000000 0000000 00000000431 14730266737 0021547 0 ustar 00root root 0000000 0000000 .. 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/ 0000775 0000000 0000000 00000000000 14730266737 0020301 5 ustar 00root root 0000000 0000000 python-proton-vpn-api-core-0.39.0/proton/vpn/ 0000775 0000000 0000000 00000000000 14730266737 0021104 5 ustar 00root root 0000000 0000000 python-proton-vpn-api-core-0.39.0/proton/vpn/connection/ 0000775 0000000 0000000 00000000000 14730266737 0023243 5 ustar 00root root 0000000 0000000 python-proton-vpn-api-core-0.39.0/proton/vpn/connection/__init__.py 0000664 0000000 0000000 00000002506 14730266737 0025357 0 ustar 00root root 0000000 0000000 """
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.py 0000664 0000000 0000000 00000012664 14730266737 0025642 0 ustar 00root root 0000000 0000000 """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.py 0000664 0000000 0000000 00000002662 14730266737 0024567 0 ustar 00root root 0000000 0000000 """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.py 0000664 0000000 0000000 00000010377 14730266737 0025131 0 ustar 00root root 0000000 0000000 """
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.py 0000664 0000000 0000000 00000005436 14730266737 0026006 0 ustar 00root root 0000000 0000000 """
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.py 0000664 0000000 0000000 00000015325 14730266737 0025746 0 ustar 00root root 0000000 0000000 """
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.py 0000664 0000000 0000000 00000010053 14730266737 0026140 0 ustar 00root root 0000000 0000000 """
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.py 0000664 0000000 0000000 00000007114 14730266737 0025615 0 ustar 00root root 0000000 0000000 """
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.py 0000664 0000000 0000000 00000033514 14730266737 0025126 0 ustar 00root root 0000000 0000000 """
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.py 0000664 0000000 0000000 00000014231 14730266737 0027211 0 ustar 00root root 0000000 0000000 """
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.py 0000664 0000000 0000000 00000033031 14730266737 0026500 0 ustar 00root root 0000000 0000000 """
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/ 0000775 0000000 0000000 00000000000 14730266737 0022034 5 ustar 00root root 0000000 0000000 python-proton-vpn-api-core-0.39.0/proton/vpn/core/__init__.py 0000664 0000000 0000000 00000001554 14730266737 0024152 0 ustar 00root root 0000000 0000000 """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.py 0000664 0000000 0000000 00000016423 14730266737 0023165 0 ustar 00root root 0000000 0000000 """
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.py 0000664 0000000 0000000 00000004074 14730266737 0025153 0 ustar 00root root 0000000 0000000 """
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.py 0000664 0000000 0000000 00000052737 14730266737 0024563 0 ustar 00root root 0000000 0000000 """
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.py 0000664 0000000 0000000 00000001672 14730266737 0024575 0 ustar 00root root 0000000 0000000 """
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/ 0000775 0000000 0000000 00000000000 14730266737 0024021 5 ustar 00root root 0000000 0000000 python-proton-vpn-api-core-0.39.0/proton/vpn/core/refresher/__init__.py 0000664 0000000 0000000 00000001420 14730266737 0026127 0 ustar 00root root 0000000 0000000 """
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.py 0000664 0000000 0000000 00000011034 14730266737 0030721 0 ustar 00root root 0000000 0000000 """
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.py 0000664 0000000 0000000 00000005112 14730266737 0031242 0 ustar 00root root 0000000 0000000 """
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.py 0000664 0000000 0000000 00000004773 14730266737 0031262 0 ustar 00root root 0000000 0000000 """
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.py 0000664 0000000 0000000 00000020477 14730266737 0026363 0 ustar 00root root 0000000 0000000 """
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.py 0000664 0000000 0000000 00000007055 14730266737 0031010 0 ustar 00root root 0000000 0000000 """
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.py 0000664 0000000 0000000 00000021117 14730266737 0030236 0 ustar 00root root 0000000 0000000 """
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.py 0000664 0000000 0000000 00000006543 14730266737 0025436 0 ustar 00root root 0000000 0000000 """
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.py 0000664 0000000 0000000 00000024324 14730266737 0024253 0 ustar 00root root 0000000 0000000 """
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.py 0000664 0000000 0000000 00000017434 14730266737 0023523 0 ustar 00root root 0000000 0000000 """
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/ 0000775 0000000 0000000 00000000000 14730266737 0023261 5 ustar 00root root 0000000 0000000 python-proton-vpn-api-core-0.39.0/proton/vpn/killswitch/interface/ 0000775 0000000 0000000 00000000000 14730266737 0025221 5 ustar 00root root 0000000 0000000 python-proton-vpn-api-core-0.39.0/proton/vpn/killswitch/interface/__init__.py 0000664 0000000 0000000 00000001556 14730266737 0027341 0 ustar 00root root 0000000 0000000 """
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.py 0000664 0000000 0000000 00000002741 14730266737 0027760 0 ustar 00root root 0000000 0000000 """
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.py 0000664 0000000 0000000 00000005547 14730266737 0027763 0 ustar 00root root 0000000 0000000 """
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/ 0000775 0000000 0000000 00000000000 14730266737 0022532 5 ustar 00root root 0000000 0000000 python-proton-vpn-api-core-0.39.0/proton/vpn/logging/__init__.py 0000664 0000000 0000000 00000012732 14730266737 0024650 0 ustar 00root root 0000000 0000000 """
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/ 0000775 0000000 0000000 00000000000 14730266737 0022567 5 ustar 00root root 0000000 0000000 python-proton-vpn-api-core-0.39.0/proton/vpn/session/__init__.py 0000664 0000000 0000000 00000002211 14730266737 0024674 0 ustar 00root root 0000000 0000000 """
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.py 0000664 0000000 0000000 00000011534 14730266737 0024601 0 ustar 00root root 0000000 0000000 """
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.py 0000664 0000000 0000000 00000031632 14730266737 0025613 0 ustar 00root root 0000000 0000000 """
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.py 0000664 0000000 0000000 00000015761 14730266737 0025756 0 ustar 00root root 0000000 0000000 """
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.py 0000664 0000000 0000000 00000020117 14730266737 0025437 0 ustar 00root root 0000000 0000000 """
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/ 0000775 0000000 0000000 00000000000 14730266737 0025056 5 ustar 00root root 0000000 0000000 python-proton-vpn-api-core-0.39.0/proton/vpn/session/dataclasses/__init__.py 0000664 0000000 0000000 00000002604 14730266737 0027171 0 ustar 00root root 0000000 0000000 """
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.py 0000664 0000000 0000000 00000002523 14730266737 0027602 0 ustar 00root root 0000000 0000000 """
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.py 0000664 0000000 0000000 00000004362 14730266737 0027717 0 ustar 00root root 0000000 0000000 """
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/ 0000775 0000000 0000000 00000000000 14730266737 0027661 5 ustar 00root root 0000000 0000000 python-proton-vpn-api-core-0.39.0/proton/vpn/session/dataclasses/client_config/__init__.py 0000664 0000000 0000000 00000001431 14730266737 0031771 0 ustar 00root root 0000000 0000000 """
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.py 0000664 0000000 0000000 00000002340 14730266737 0033322 0 ustar 00root root 0000000 0000000 """
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.py 0000664 0000000 0000000 00000002535 14730266737 0027732 0 ustar 00root root 0000000 0000000 """
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.py 0000664 0000000 0000000 00000002472 14730266737 0027245 0 ustar 00root root 0000000 0000000 """
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.py 0000664 0000000 0000000 00000001526 14730266737 0030142 0 ustar 00root root 0000000 0000000 """
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/ 0000775 0000000 0000000 00000000000 14730266737 0026547 5 ustar 00root root 0000000 0000000 python-proton-vpn-api-core-0.39.0/proton/vpn/session/dataclasses/servers/__init__.py 0000664 0000000 0000000 00000001400 14730266737 0030653 0 ustar 00root root 0000000 0000000 """
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.py 0000664 0000000 0000000 00000002577 14730266737 0030637 0 ustar 00root root 0000000 0000000 """
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.py 0000664 0000000 0000000 00000003177 14730266737 0027306 0 ustar 00root root 0000000 0000000 """
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.py 0000664 0000000 0000000 00000005526 14730266737 0027300 0 ustar 00root root 0000000 0000000 """
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.py 0000664 0000000 0000000 00000003510 14730266737 0025321 0 ustar 00root root 0000000 0000000 """
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.py 0000664 0000000 0000000 00000014530 14730266737 0027453 0 ustar 00root root 0000000 0000000 """
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.py 0000664 0000000 0000000 00000014424 14730266737 0024566 0 ustar 00root root 0000000 0000000 """
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.py 0000664 0000000 0000000 00000015120 14730266737 0024575 0 ustar 00root root 0000000 0000000 """
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/ 0000775 0000000 0000000 00000000000 14730266737 0024260 5 ustar 00root root 0000000 0000000 python-proton-vpn-api-core-0.39.0/proton/vpn/session/servers/__init__.py 0000664 0000000 0000000 00000001702 14730266737 0026371 0 ustar 00root root 0000000 0000000 """
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.py 0000664 0000000 0000000 00000015533 14730266737 0027521 0 ustar 00root root 0000000 0000000 """
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.py 0000664 0000000 0000000 00000015376 14730266737 0026266 0 ustar 00root root 0000000 0000000 """
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.py 0000664 0000000 0000000 00000031616 14730266737 0026436 0 ustar 00root root 0000000 0000000 """
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.py 0000664 0000000 0000000 00000023432 14730266737 0026002 0 ustar 00root root 0000000 0000000 """
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.py 0000664 0000000 0000000 00000033257 14730266737 0024636 0 ustar 00root root 0000000 0000000 """
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.py 0000664 0000000 0000000 00000012563 14730266737 0024310 0 ustar 00root root 0000000 0000000 """
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.txt 0000664 0000000 0000000 00000000023 14730266737 0022237 0 ustar 00root root 0000000 0000000 -e ".[development]" python-proton-vpn-api-core-0.39.0/rpmbuild/ 0000775 0000000 0000000 00000000000 14730266737 0020576 5 ustar 00root root 0000000 0000000 python-proton-vpn-api-core-0.39.0/rpmbuild/BUILD/ 0000775 0000000 0000000 00000000000 14730266737 0021435 5 ustar 00root root 0000000 0000000 python-proton-vpn-api-core-0.39.0/rpmbuild/BUILD/.gitkeep 0000664 0000000 0000000 00000000000 14730266737 0023054 0 ustar 00root root 0000000 0000000 python-proton-vpn-api-core-0.39.0/rpmbuild/BUILDROOT/ 0000775 0000000 0000000 00000000000 14730266737 0022141 5 ustar 00root root 0000000 0000000 python-proton-vpn-api-core-0.39.0/rpmbuild/BUILDROOT/.gitkeep 0000664 0000000 0000000 00000000000 14730266737 0023560 0 ustar 00root root 0000000 0000000 python-proton-vpn-api-core-0.39.0/rpmbuild/SOURCES/ 0000775 0000000 0000000 00000000000 14730266737 0021721 5 ustar 00root root 0000000 0000000 python-proton-vpn-api-core-0.39.0/rpmbuild/SOURCES/.gitkeep 0000664 0000000 0000000 00000000000 14730266737 0023340 0 ustar 00root root 0000000 0000000 python-proton-vpn-api-core-0.39.0/rpmbuild/SPECS/ 0000775 0000000 0000000 00000000000 14730266737 0021453 5 ustar 00root root 0000000 0000000 python-proton-vpn-api-core-0.39.0/rpmbuild/SPECS/.gitkeep 0000664 0000000 0000000 00000000000 14730266737 0023072 0 ustar 00root root 0000000 0000000 python-proton-vpn-api-core-0.39.0/rpmbuild/SPECS/package.spec.template 0000664 0000000 0000000 00000003063 14730266737 0025536 0 ustar 00root root 0000000 0000000 %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/ 0000775 0000000 0000000 00000000000 14730266737 0021502 5 ustar 00root root 0000000 0000000 python-proton-vpn-api-core-0.39.0/rpmbuild/SRPMS/.gitkeep 0000664 0000000 0000000 00000000000 14730266737 0023121 0 ustar 00root root 0000000 0000000 python-proton-vpn-api-core-0.39.0/scripts/ 0000775 0000000 0000000 00000000000 14730266737 0020447 5 ustar 00root root 0000000 0000000 python-proton-vpn-api-core-0.39.0/scripts/create_changelogs.py 0000775 0000000 0000000 00000003062 14730266737 0024462 0 ustar 00root root 0000000 0000000 #!/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/ 0000775 0000000 0000000 00000000000 14730266737 0022306 5 ustar 00root root 0000000 0000000 python-proton-vpn-api-core-0.39.0/setup.cfg 0000664 0000000 0000000 00000000243 14730266737 0020600 0 ustar 00root root 0000000 0000000 [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.py 0000664 0000000 0000000 00000002500 14730266737 0020467 0 ustar 00root root 0000000 0000000 #!/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/ 0000775 0000000 0000000 00000000000 14730266737 0020122 5 ustar 00root root 0000000 0000000 python-proton-vpn-api-core-0.39.0/tests/__init__.py 0000664 0000000 0000000 00000001246 14730266737 0022236 0 ustar 00root root 0000000 0000000 """
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/ 0000775 0000000 0000000 00000000000 14730266737 0022261 5 ustar 00root root 0000000 0000000 python-proton-vpn-api-core-0.39.0/tests/connection/__init__.py 0000664 0000000 0000000 00000000000 14730266737 0024360 0 ustar 00root root 0000000 0000000 python-proton-vpn-api-core-0.39.0/tests/connection/common.py 0000664 0000000 0000000 00000004245 14730266737 0024130 0 ustar 00root root 0000000 0000000 """
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.py 0000664 0000000 0000000 00000003556 14730266737 0025207 0 ustar 00root root 0000000 0000000 """
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.py 0000664 0000000 0000000 00000014544 14730266737 0026226 0 ustar 00root root 0000000 0000000 """
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.py 0000664 0000000 0000000 00000005452 14730266737 0025675 0 ustar 00root root 0000000 0000000 """
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.py 0000664 0000000 0000000 00000033360 14730266737 0025202 0 ustar 00root root 0000000 0000000 """
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.py 0000664 0000000 0000000 00000014540 14730266737 0027271 0 ustar 00root root 0000000 0000000 """
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.py 0000664 0000000 0000000 00000016463 14730266737 0026567 0 ustar 00root root 0000000 0000000 """
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/ 0000775 0000000 0000000 00000000000 14730266737 0021052 5 ustar 00root root 0000000 0000000 python-proton-vpn-api-core-0.39.0/tests/core/__init__.py 0000664 0000000 0000000 00000000000 14730266737 0023151 0 ustar 00root root 0000000 0000000 python-proton-vpn-api-core-0.39.0/tests/core/refresher/ 0000775 0000000 0000000 00000000000 14730266737 0023037 5 ustar 00root root 0000000 0000000 python-proton-vpn-api-core-0.39.0/tests/core/refresher/test_certificate_refresher.py 0000664 0000000 0000000 00000003743 14730266737 0031006 0 ustar 00root root 0000000 0000000 """
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_backoff python-proton-vpn-api-core-0.39.0/tests/core/refresher/test_client_config_refresher.py 0000664 0000000 0000000 00000002761 14730266737 0031326 0 ustar 00root root 0000000 0000000 """
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.py 0000664 0000000 0000000 00000002754 14730266737 0031334 0 ustar 00root root 0000000 0000000 """
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.py 0000664 0000000 0000000 00000003730 14730266737 0026431 0 ustar 00root root 0000000 0000000 import 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.py 0000664 0000000 0000000 00000007717 14730266737 0031072 0 ustar 00root root 0000000 0000000 """
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.py 0000664 0000000 0000000 00000010022 14730266737 0030304 0 ustar 00root root 0000000 0000000 """
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.py 0000664 0000000 0000000 00000004300 14730266737 0025061 0 ustar 00root root 0000000 0000000 """
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.py 0000664 0000000 0000000 00000021172 14730266737 0024625 0 ustar 00root root 0000000 0000000 """
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.py 0000664 0000000 0000000 00000012633 14730266737 0024330 0 ustar 00root root 0000000 0000000 """
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.py 0000664 0000000 0000000 00000011500 14730266737 0023564 0 ustar 00root root 0000000 0000000 """
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/ 0000775 0000000 0000000 00000000000 14730266737 0022277 5 ustar 00root root 0000000 0000000 python-proton-vpn-api-core-0.39.0/tests/killswitch/__init__.py 0000664 0000000 0000000 00000000000 14730266737 0024376 0 ustar 00root root 0000000 0000000 python-proton-vpn-api-core-0.39.0/tests/killswitch/test_killswitch.py 0000664 0000000 0000000 00000002455 14730266737 0026073 0 ustar 00root root 0000000 0000000 """
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/ 0000775 0000000 0000000 00000000000 14730266737 0021401 5 ustar 00root root 0000000 0000000 python-proton-vpn-api-core-0.39.0/tests/logger/test_logger.py 0000664 0000000 0000000 00000006640 14730266737 0024277 0 ustar 00root root 0000000 0000000 """
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/ 0000775 0000000 0000000 00000000000 14730266737 0021605 5 ustar 00root root 0000000 0000000 python-proton-vpn-api-core-0.39.0/tests/session/__init__.py 0000664 0000000 0000000 00000001246 14730266737 0023721 0 ustar 00root root 0000000 0000000 """
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/ 0000775 0000000 0000000 00000000000 14730266737 0022516 5 ustar 00root root 0000000 0000000 python-proton-vpn-api-core-0.39.0/tests/session/data/README.md 0000664 0000000 0000000 00000000363 14730266737 0023777 0 ustar 00root root 0000000 0000000 Important
=========
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.json 0000664 0000000 0000000 00000002607 14730266737 0027122 0 ustar 00root root 0000000 0000000 {
"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.json 0000664 0000000 0000000 00000000226 14730266737 0030653 0 ustar 00root root 0000000 0000000 {
"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.json 0000664 0000000 0000000 00000000526 14730266737 0030555 0 ustar 00root root 0000000 0000000 {
"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.json 0000664 0000000 0000000 00000001033 14730266737 0030541 0 ustar 00root root 0000000 0000000 {
"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.json 0000664 0000000 0000000 00000000115 14730266737 0025741 0 ustar 00root root 0000000 0000000 {
"ed25519_privatekey" : "rNW3dL5A3dUrQX3ZKbVAFLjSFJdvDU5JzjrRrnI+cos="
} python-proton-vpn-api-core-0.39.0/tests/session/dataclasses/ 0000775 0000000 0000000 00000000000 14730266737 0024074 5 ustar 00root root 0000000 0000000 python-proton-vpn-api-core-0.39.0/tests/session/dataclasses/__init__.py 0000664 0000000 0000000 00000001246 14730266737 0026210 0 ustar 00root root 0000000 0000000 """
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.py 0000664 0000000 0000000 00000003153 14730266737 0027771 0 ustar 00root root 0000000 0000000 """
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.py 0000664 0000000 0000000 00000002462 14730266737 0027321 0 ustar 00root root 0000000 0000000 """
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.py 0000664 0000000 0000000 00000004333 14730266737 0027173 0 ustar 00root root 0000000 0000000 """
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.py 0000664 0000000 0000000 00000004231 14730266737 0027345 0 ustar 00root root 0000000 0000000 """
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/ 0000775 0000000 0000000 00000000000 14730266737 0023276 5 ustar 00root root 0000000 0000000 python-proton-vpn-api-core-0.39.0/tests/session/servers/__init__.py 0000664 0000000 0000000 00000001246 14730266737 0025412 0 ustar 00root root 0000000 0000000 """
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.py 0000664 0000000 0000000 00000002003 14730266737 0026322 0 ustar 00root root 0000000 0000000 """
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.py 0000664 0000000 0000000 00000011372 14730266737 0026510 0 ustar 00root root 0000000 0000000 """
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.py 0000664 0000000 0000000 00000011164 14730266737 0026056 0 ustar 00root root 0000000 0000000 """
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.py 0000664 0000000 0000000 00000006117 14730266737 0025667 0 ustar 00root root 0000000 0000000 """
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.py 0000664 0000000 0000000 00000006232 14730266737 0027530 0 ustar 00root root 0000000 0000000 """
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.py 0000664 0000000 0000000 00000002406 14730266737 0024640 0 ustar 00root root 0000000 0000000 """
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.py 0000664 0000000 0000000 00000010322 14730266737 0024677 0 ustar 00root root 0000000 0000000 """
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.py 0000664 0000000 0000000 00000000756 14730266737 0024366 0 ustar 00root root 0000000 0000000 import 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.py 0000664 0000000 0000000 00000017772 14730266737 0025414 0 ustar 00root root 0000000 0000000 """
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.yml 0000664 0000000 0000000 00000060640 14730266737 0021361 0 ustar 00root root 0000000 0000000 version: 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.