pax_global_header 0000666 0000000 0000000 00000000064 15151554407 0014521 g ustar 00root root 0000000 0000000 52 comment=8553b25443b40a251e073e4c7fd7280cafc99821
python-proton-vpn-api-core-4.16.0/ 0000775 0000000 0000000 00000000000 15151554407 0016747 5 ustar 00root root 0000000 0000000 python-proton-vpn-api-core-4.16.0/.gitignore 0000664 0000000 0000000 00000000230 15151554407 0020732 0 ustar 00root root 0000000 0000000 build/
dist/
MANIFEST
*.pyc
*.egg-info/
.vscode/
*.lock
__SOURCE_APP
.env
cov.xml
html
.vscode
.coverage
.idea
venv
package.spec
changelog
CHANGELOG.md
python-proton-vpn-api-core-4.16.0/.gitlab-ci.yml 0000664 0000000 0000000 00000000160 15151554407 0021400 0 ustar 00root root 0000000 0000000 include:
- project: 'ProtonVPN/Linux/integration/ci-libraries'
ref: develop
file: 'develop-pipeline.yml'
python-proton-vpn-api-core-4.16.0/.gitmodules 0000664 0000000 0000000 00000000133 15151554407 0021121 0 ustar 00root root 0000000 0000000 [submodule "scripts/devtools"]
path = scripts/devtools
url = ../integration/devtools.git
python-proton-vpn-api-core-4.16.0/CODEOWNERS 0000664 0000000 0000000 00000000510 15151554407 0020336 0 ustar 00root root 0000000 0000000 # ownership: loose
* @ProtonVPN/groups/linux-developers
/debian/ @ProtonVPN/groups/linux-developers
/docs/ @ProtonVPN/groups/linux-developers
/proton/ @ProtonVPN/groups/linux-developers
/rpmbuild/ @ProtonVPN/groups/linux-developers
/scripts/ @ProtonVPN/groups/linux-developers
/tests/ @ProtonVPN/groups/linux-developers
python-proton-vpn-api-core-4.16.0/LICENSE 0000664 0000000 0000000 00000104514 15151554407 0017761 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-4.16.0/MANIFEST.in 0000664 0000000 0000000 00000000025 15151554407 0020502 0 ustar 00root root 0000000 0000000 include versions.yml
python-proton-vpn-api-core-4.16.0/README.md 0000664 0000000 0000000 00000003075 15151554407 0020233 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.
### Known issues
This component depends on the `PyGObject` python package.
To be able to pip install `PyGObject`, please check the required distribution packages in the
[official documentation](https://pygobject.readthedocs.io/en/latest/devguide/dev_environ.html).
```shell
sudo apt install pkg-config libdbus-1-dev libglib2.0-dev
```
### 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-4.16.0/debian/ 0000775 0000000 0000000 00000000000 15151554407 0020171 5 ustar 00root root 0000000 0000000 python-proton-vpn-api-core-4.16.0/debian/.gitignore 0000664 0000000 0000000 00000000425 15151554407 0022162 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-4.16.0/debian/compat 0000664 0000000 0000000 00000000003 15151554407 0021370 0 ustar 00root root 0000000 0000000 11
python-proton-vpn-api-core-4.16.0/debian/control 0000664 0000000 0000000 00000002331 15151554407 0021573 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 (>= 0.5.0),
python3-distro, python3-sentry-sdk, python3-nacl,
python3-fido2, python3-packaging,
python3-proton-vpn-local-agent(>= 1.5.0), network-manager, network-manager-openvpn, network-manager-openvpn-gnome, python3-gi, python3-gi-cairo, gir1.2-nm-1.0, 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 (>= 0.5.0),
python3-distro, python3-sentry-sdk, python3-nacl,
python3-fido2, python3-packaging,
python3-proton-vpn-local-agent(>= 1.5.0), network-manager, network-manager-openvpn, network-manager-openvpn-gnome, python3-gi, python3-gi-cairo, gir1.2-nm-1.0, python3-jinja2
Breaks: proton-vpn-gtk-app (<< 4.14.2), python3-proton-vpn-network-manager (<< 0.13.5)
Replaces: python3-proton-vpn-session, python3-proton-vpn-connection, python3-proton-vpn-killswitch, python3-proton-vpn-logger, python3-proton-vpn-network-manager
Description: Python3 ProtonVPN Core API
python-proton-vpn-api-core-4.16.0/debian/copyright 0000664 0000000 0000000 00000000521 15151554407 0022122 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-4.16.0/debian/rules 0000775 0000000 0000000 00000000201 15151554407 0021242 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-4.16.0/docs/ 0000775 0000000 0000000 00000000000 15151554407 0017677 5 ustar 00root root 0000000 0000000 python-proton-vpn-api-core-4.16.0/docs/conf.py 0000664 0000000 0000000 00000003732 15151554407 0021203 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-4.16.0/docs/coreapi.rst 0000664 0000000 0000000 00000010204 15151554407 0022050 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-4.16.0/docs/index.rst 0000664 0000000 0000000 00000000431 15151554407 0021536 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-4.16.0/proton/ 0000775 0000000 0000000 00000000000 15151554407 0020270 5 ustar 00root root 0000000 0000000 python-proton-vpn-api-core-4.16.0/proton/vpn/ 0000775 0000000 0000000 00000000000 15151554407 0021073 5 ustar 00root root 0000000 0000000 python-proton-vpn-api-core-4.16.0/proton/vpn/backend/ 0000775 0000000 0000000 00000000000 15151554407 0022462 5 ustar 00root root 0000000 0000000 python-proton-vpn-api-core-4.16.0/proton/vpn/backend/networkmanager/ 0000775 0000000 0000000 00000000000 15151554407 0025506 5 ustar 00root root 0000000 0000000 python-proton-vpn-api-core-4.16.0/proton/vpn/backend/networkmanager/core/ 0000775 0000000 0000000 00000000000 15151554407 0026436 5 ustar 00root root 0000000 0000000 python-proton-vpn-api-core-4.16.0/proton/vpn/backend/networkmanager/core/__init__.py 0000664 0000000 0000000 00000001523 15151554407 0030550 0 ustar 00root root 0000000 0000000 """NetworkManager backend.
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 .networkmanager import LinuxNetworkManager
from .localagent_mixin import LocalAgentMixin
__all__ = ["LinuxNetworkManager", "LocalAgentMixin"]
python-proton-vpn-api-core-4.16.0/proton/vpn/backend/networkmanager/core/local_agent/ 0000775 0000000 0000000 00000000000 15151554407 0030706 5 ustar 00root root 0000000 0000000 python-proton-vpn-api-core-4.16.0/proton/vpn/backend/networkmanager/core/local_agent/__init__.py 0000664 0000000 0000000 00000002771 15151554407 0033026 0 ustar 00root root 0000000 0000000 """
Local Agent module.
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 proton.vpn.local_agent
import proton.vpn.logging
from proton.vpn.local_agent import ( # pylint: disable=no-name-in-module, import-error
AgentConnector, AgentConnection, Status,
State, Reason, ReasonCode, AgentFeatures,
LocalAgentError, ExpiredCertificateError,
PolicyAPIError, SyntaxAPIError, APIError,
ConnectionDetails
)
from .listener import AgentListener
# Initialize logging for the local agent module, this forwards the rust
# logging to the python logging module.
proton.vpn.local_agent.init_logger(proton.vpn.logging.getLogger)
__all__ = [
"AgentConnector", "AgentConnection", "Status",
"State", "Reason", "ReasonCode", "AgentFeatures",
"LocalAgentError", "ExpiredCertificateError",
"PolicyAPIError", "SyntaxAPIError", "APIError",
"ConnectionDetails", "AgentListener"
]
python-proton-vpn-api-core-4.16.0/proton/vpn/backend/networkmanager/core/local_agent/listener.py 0000664 0000000 0000000 00000006072 15151554407 0033112 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
from collections.abc import Callable
from proton.vpn.local_agent import ( # pylint: disable=no-name-in-module, import-error
Listener, AgentFeatures, Status
)
from proton.vpn.session.credentials import VPNPubkeyCredentials
from proton.vpn import logging
logger = logging.getLogger(__name__)
class AgentListener:
"""Listens for local agent messages asynchronously."""
def __init__(self, on_status: Callable[[Status], None], on_error: Callable[[Exception], None]):
self._listener = None
self._future = None
self._on_status_callback = on_status
self._on_error_callback = on_error
async def connect(self, domain: str, credentials: VPNPubkeyCredentials):
"""Establishes a connection to local agent server.
:param domain: the domain of the VPN server.
:param credentials: object that contains all necessary user credentials.
:raises ExpiredCertificateError: when the certificate provided is expired or invalid.
:raises TimeoutError: when the connection to LA server times out.
:raises Exception: any other exceptions.
"""
private_key = credentials.get_ed25519_sk_pem()
certificate = credentials.certificate_pem
self._listener = await Listener.connect(domain, private_key,
certificate)
async def listen(self):
"""Starts the background process of listening to incoming messages from LA."""
self._future = self._listener.listen(
self._on_status_callback, self._on_error_callback
)
await self._wait_for_it(self._future)
async def request_features(self, features: AgentFeatures):
"""Requests the features to be set on the current VPN connection."""
await self._listener.request_features(features)
async def stop(self):
"""Stops reading incoming messages from LA."""
if not self._future or self._future.done():
return
self._future.cancel()
await self._wait_for_it(self._future)
logger.info("Agent listener successfully stopped.")
@property
def is_running(self) -> bool:
"""Returns if the future is still running."""
return self._future and not self._future.done()
@staticmethod
async def _wait_for_it(future):
try:
await future
except asyncio.CancelledError:
pass
python-proton-vpn-api-core-4.16.0/proton/vpn/backend/networkmanager/core/localagent_mixin.py 0000664 0000000 0000000 00000024027 15151554407 0032332 0 ustar 00root root 0000000 0000000 """
Base class for VPN connections created with NetworkManager.
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 logging
import random
from concurrent import futures
from proton.vpn.connection import events
from proton.vpn.connection.events import EventContext
from proton.vpn.connection.exceptions \
import FeatureError, FeaturePolicyError, FeatureSyntaxError
from proton.vpn.core.settings.features import Features
from proton.vpn.backend.networkmanager.core.local_agent import (
AgentListener, Status, ExpiredCertificateError, AgentFeatures,
ReasonCode, PolicyAPIError, SyntaxAPIError, APIError, State
)
logger = logging.getLogger(__name__)
TWOFA_REASON_CODES = [
ReasonCode.REASON_CODE_2FA_UNSPECIFIED,
ReasonCode.REASON_CODE_2FA_EXPIRED,
ReasonCode.REASON_CODE_2FA_SITUATION_CHANGED
]
class LocalAgentMixin: # pylint: disable=too-few-public-methods
"""
This mixin provides the functionality to start and stop the local agent
client and to listen for status updates and errors from the local agent
server.
"""
def __init__(self):
self._agent_listener = AgentListener(
on_status=self.__on_local_agent_status,
on_error=self.__on_local_agent_error
)
async def _start_local_agent_listener(self):
"""
This method starts the local agent listener and waits for it to
complete.
"""
if self._agent_listener.is_running: # noqa: E501 # pylint: disable=line-too-long # nosemgrep: python.lang.maintainability.is-function-without-parentheses.is-function-without-parentheses
logger.info("Closing existing agent connection...")
await self._agent_listener.stop()
logger.info("Waiting for agent status from %s...",
self._vpnserver.domain)
context = EventContext(connection=self)
agent_connection_drops = 0
while True:
if not await self.__attempt_to_connect_to_listener(context):
return
if not await self.__attempt_to_request_connection_features(context):
return
if not await self.__attempt_to_listen(context):
agent_connection_drops += 1
# nosemgrep: gitlab.bandit.B311
sleep_seconds = random.uniform(0, 10) # nosec B311
logger.warning(
"Agent connection dropped (#%s). Retrying in %.1f seconds.",
agent_connection_drops, sleep_seconds
)
await asyncio.sleep(sleep_seconds)
continue
return
# Absorb CancelledError before it is logged by Future's done callback handler
def _handle_future_result(self, future: futures.Future):
try:
future.result()
except futures.CancelledError:
pass
def _async_start_local_agent_listener(self):
"""
This schedules the local agent listener to start, but it will not wait
for it.
"""
future = asyncio.run_coroutine_threadsafe(
self._start_local_agent_listener(),
self._asyncio_loop
)
future.add_done_callback(self._handle_future_result)
def _async_stop_local_agent_listener(self):
"""
This schedules the local agent listener to stop, but it will not wait
for it.
"""
future = asyncio.run_coroutine_threadsafe(
self._agent_listener.stop(),
self._asyncio_loop
)
future.add_done_callback(self._handle_future_result)
async def _request_connection_features(self, features: Features):
if features.are_free_tier_defaults():
# No need to request default features, since they are available by default.
# Also, the local agent server raises an error if features are requested
# for free users, even when the requested features are the default ones.
logger.info("Using default VPN connection features.")
return
logger.info("Requesting VPN connection features...")
agent_features = self.__get_agent_features(features)
await self._agent_listener.request_features(agent_features)
logger.info("VPN connection features requested.")
def __on_local_agent_status(self, status: Status):
"""The local agent listener calls this method whenever a new status is
read from the local agent connection."""
logger.info("Agent status received: %s", status)
context = EventContext(
connection=self,
connection_details=status.connection_details,
forwarded_port=status.features.forwarded_port if status.features else None
)
if status.state == State.CONNECTED:
self._notify_subscribers_threadsafe(events.Connected(context))
elif status.state == State.HARD_JAILED:
self.__handle_hard_jailed_state(status)
def __handle_hard_jailed_state(self, status: Status):
context = EventContext(connection=self,
connection_details=status.connection_details)
if status.reason.code == ReasonCode.CERTIFICATE_EXPIRED:
self._notify_subscribers_threadsafe(
events.ExpiredCertificate(context)
)
elif status.reason.code in TWOFA_REASON_CODES:
self._notify_subscribers_threadsafe(
events.TwoFARequired(context)
)
elif self.__has_reached_max_amount_of_concurrent_vpn_connections(status.reason.code):
self._notify_subscribers_threadsafe(
events.MaximumSessionsReached(context)
)
else:
self._notify_subscribers_threadsafe(
events.UnexpectedError(context)
)
def __has_reached_max_amount_of_concurrent_vpn_connections(
self, code: ReasonCode) -> bool:
"""Check if a user has reached the maximum number of concurrent VPN
sessions/connections permitted for the current tier."""
return code in (
ReasonCode.MAX_SESSIONS_UNKNOWN,
ReasonCode.MAX_SESSIONS_FREE,
ReasonCode.MAX_SESSIONS_BASIC,
ReasonCode.MAX_SESSIONS_PLUS,
ReasonCode.MAX_SESSIONS_VISIONARY,
ReasonCode.MAX_SESSIONS_PRO
)
def __on_local_agent_error(self, error: Exception):
"""
The local agent listener calls this method whenever a new error message
read from the local agent connection.
:param error: The error received from the local agent.
"""
event = self.__get_event_from_error_message(error)
self._notify_subscribers_threadsafe(event)
def __get_event_from_error_message(self, error: Exception) -> events.Event:
exception_message = str(error)
if isinstance(error, PolicyAPIError):
new_error = FeaturePolicyError(exception_message)
elif isinstance(error, SyntaxAPIError):
new_error = FeatureSyntaxError(exception_message)
elif isinstance(error, APIError):
new_error = FeatureError(exception_message)
else:
new_error = Exception(exception_message)
return events.UnexpectedError(EventContext(error=new_error,
connection=self))
async def __attempt_to_connect_to_listener(self,
context: EventContext) -> bool:
try:
await self._agent_listener.connect(
self._vpnserver.domain, self._vpncredentials.pubkey_credentials
)
except ExpiredCertificateError:
self._notify_subscribers_threadsafe(
events.ExpiredCertificate(context))
return False
except TimeoutError:
logger.info("Connect timeout")
self._notify_subscribers_threadsafe(events.Timeout(context))
return False
except Exception:
self._notify_subscribers_threadsafe(
events.UnexpectedError(context))
raise
return True
async def __attempt_to_request_connection_features(self,
context: EventContext) -> bool:
try:
await self._request_connection_features(self.settings.features)
except TimeoutError:
self._notify_subscribers_threadsafe(events.Timeout(context))
return False
except Exception:
self._notify_subscribers_threadsafe(
events.UnexpectedError(context))
raise
return True
async def __attempt_to_listen(self, context: EventContext) -> bool:
try:
await self._agent_listener.listen()
return True
except TimeoutError:
return False
except Exception:
self._notify_subscribers_threadsafe(
events.UnexpectedError(context))
raise
def __get_agent_features(self, features: Features) -> AgentFeatures:
randomized_nat = (not features.moderate_nat
if features.moderate_nat is not None else None)
return AgentFeatures(
netshield_level=features.netshield,
randomized_nat=randomized_nat,
split_tcp=features.vpn_accelerator,
port_forwarding=features.port_forwarding,
bouncing=self._vpnserver.label,
jail=None # Currently not in use
)
python-proton-vpn-api-core-4.16.0/proton/vpn/backend/networkmanager/core/networkmanager.py 0000664 0000000 0000000 00000024373 15151554407 0032045 0 ustar 00root root 0000000 0000000 """
Base class for VPN connections created with NetworkManager.
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 logging
from typing import Optional
from proton.loader import Loader
from proton.vpn.connection import VPNConnection, events, states
from proton.vpn.connection.events import EventContext, Event
from proton.vpn.connection.vpnconfiguration import VPNConfiguration
from proton.vpn.connection.states import StateContext
from proton.vpn.backend.networkmanager.core.nmclient import (NM, NMClient, GLib)
from proton.vpn.backend.networkmanager.core import tcpcheck
logger = logging.getLogger(__name__)
class LinuxNetworkManager(VPNConnection):
"""VPNConnections based on Linux Network Manager backend.
The LinuxNetworkManager connection backend could return VPNConnection
instances based on protocols such as OpenVPN, IKEv2 or Wireguard.
To do this, this class needs to be extended so that the subclass
initializes the NetworkManager connection appropriately.
"""
SIGNAL_NAME = "vpn-state-changed"
backend = "linuxnetworkmanager"
def __init__(self, *args, nm_client: NMClient = None, **kwargs):
self.__nm_client = nm_client
self._asyncio_loop = asyncio.get_running_loop()
self._cancelled = False
super().__init__(*args, **kwargs)
@property
def nm_client(self):
"""Returns the NetworkManager client."""
if not self.__nm_client:
self.__nm_client = NMClient()
return self.__nm_client
@classmethod
def factory(cls, protocol: str = None):
"""Returns the VPN connection implementation class
for the specified protocol."""
return Loader.get(LinuxNetworkManager.backend, class_name=protocol)
async def start(self):
"""
Starts a VPN connection using NetworkManager.
"""
# The VPN connection is started only if at least one of the TCP ports of the server to
# connect to is open. The reason for doing this check is that, after introducing the
# dummy kill switch network interface, the VPN connection backend tries to use it
# to establish the VPN connection.
self._cancelled = False
server_reachable = await tcpcheck.is_any_port_reachable(
self._vpnserver.server_ip,
self._vpnserver.openvpn_ports.tcp
)
if not server_reachable:
logger.info("VPN server NOT reachable.")
self._notify_subscribers(events.Timeout(EventContext(connection=self)))
return
logger.info("VPN server REACHABLE.")
if self._cancelled:
logger.info("Connection cancelled.")
self._notify_subscribers(events.Disconnected(EventContext(connection=self)))
return
future_connection = self.setup() # Creates the network manager connection.
loop = asyncio.get_running_loop()
try:
connection = await loop.run_in_executor(None, future_connection.result)
except GLib.GError:
logger.exception("Error adding NetworkManager connection.")
self._notify_subscribers(
events.TunnelSetupFailed(
context=EventContext(
connection=self,
error=NM.VpnConnectionStateReason.NONE.real
)
)
)
return
if self._cancelled:
logger.info("Connection cancelled.")
await self.remove_connection(connection)
self._notify_subscribers(events.Disconnected(EventContext(connection=self)))
return
try:
future_vpn_connection = self.nm_client.start_connection_async(connection)
vpn_connection = await loop.run_in_executor(
None, future_vpn_connection.result
)
# Start listening for vpn state changes.
vpn_connection.connect(
self.SIGNAL_NAME,
self._on_state_changed
)
except GLib.GError:
logger.exception("Error starting NetworkManager connection.")
self._notify_subscribers(
events.TunnelSetupFailed(
context=EventContext(
connection=self,
error=NM.VpnConnectionStateReason.NONE.real
)
)
)
await self.remove_connection(connection)
async def stop(self, connection=None):
"""Stops the VPN connection."""
# We directly remove the connection to avoid leaking NM connections.
if not self._is_nm_connection_active():
self._notify_subscribers(
events.Disconnected(EventContext(connection=self))
)
connection = connection or self._get_nm_connection()
if not connection:
# It can happen that a connection is stopped while checking if the server
# is reachable, but before the underlying NM connection is created.
# In that case we flag it as cancelled, so the creation of the underlying
# NM connection is skipped.
self._cancelled = True
else:
await self.remove_connection(connection)
async def remove_connection(self, connection=None):
"""Removes the VPN connection."""
connection = connection or self._get_nm_connection()
if not connection:
return
future = self.nm_client.remove_connection_async(connection)
loop = asyncio.get_running_loop()
await loop.run_in_executor(None, future.result)
self._unique_id = None
async def remove_persistence(self):
await super().remove_persistence()
await self.remove_connection()
def _notify_subscribers_threadsafe(self, event: Event):
"""
When notifying subscribers from different thread than then one running the
asyncio loop, this method must be used for thread-safety reasons.
"""
self._asyncio_loop.call_soon_threadsafe(
self._notify_subscribers, event
)
def _initialize_persisted_connection(
self, connection_id: str
) -> states.State:
"""Abstract method implementation."""
context = StateContext(
event=events.Initialized(EventContext(connection=self)),
connection=self
)
active_connection = self.nm_client.get_active_connection(
connection_id
)
if active_connection:
active_connection.connect(
self.SIGNAL_NAME,
self._on_state_changed
)
return states.Connected(context)
inactive_connection = self.nm_client.get_connection(connection_id)
if inactive_connection:
# If the connection is inactive then it means that it unexpectedly dropped.
# Note that when the user willingly disconnects then the VPN connection is discarded.
return states.Error(context)
return states.Disconnected(context)
def _on_state_changed(
self, vpn_connection: NM.VpnConnection, state: int, reason: int
):
"""
Use to respond to state changes in the VPN connection, must be
implemented by the derived class.
"""
raise NotImplementedError
def _get_servername(self) -> "str":
server_name = self._vpnserver.server_name or "Connection"
return f"ProtonVPN {server_name}"
def setup(self):
"""
Every protocol derived from this class has to override this method
in order to have it working.
"""
raise NotImplementedError
@property
def enable_ipv6_support(self) -> bool:
"""Returns if IPv6 support is enabled or not."""
return self._vpnserver.has_ipv6_support and self._settings.ipv6
@classmethod
def _get_priority(cls):
return 100
@classmethod
def _validate(cls):
# FIX ME: This should do a validation to ensure that NM can be used
return True
def _import_vpn_config(
self,
vpnconfig: VPNConfiguration
) -> NM.SimpleConnection:
"""
Imports the vpn connection configurations into NM
and stores the connection on non-volatile memory.
:return: imported vpn connection
:rtype: NM.SimpleConnection
"""
vpn_plugin_list = NM.VpnPluginInfo.list_load()
connection = None
with vpnconfig as filename:
for plugin in vpn_plugin_list:
plugin_editor = plugin.load_editor_plugin()
# return a NM.SimpleConnection (NM.Connection)
# https://lazka.github.io/pgi-docs/NM-1.0/classes/SimpleConnection.html
try:
# plugin_name = plugin.props.name
connection = plugin_editor.import_(filename)
break
except GLib.Error:
continue
if connection is None:
raise NotImplementedError(
"Support for given configuration is not implemented"
)
# https://lazka.github.io/pgi-docs/NM-1.0/classes/Connection.html#NM.Connection.normalize
connection.normalize()
return connection
def _get_nm_connection(self) -> Optional[NM.RemoteConnection]:
"""Get ProtonVPN connection, if there is one."""
if not self._unique_id:
return None
return self.nm_client.get_connection(self._unique_id)
def _is_nm_connection_active(self):
if not self._unique_id:
return False
return bool(self.nm_client.get_active_connection(self._unique_id))
python-proton-vpn-api-core-4.16.0/proton/vpn/backend/networkmanager/core/nmclient.py 0000664 0000000 0000000 00000024177 15151554407 0030634 0 ustar 00root root 0000000 0000000 """
Wrapper over the NetworkManager client.
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
from concurrent.futures import Future
from threading import Thread, Lock
from typing import Callable, Optional
import gi
gi.require_version("NM", "1.0") # noqa: required before importing NM module
# pylint: disable=wrong-import-position
from gi.repository import NM, GLib
from proton.vpn.connection.exceptions import VPNConnectionError
logger = logging.getLogger(__name__)
class NMClient:
"""
Wrapper over the NetworkManager client.
It also starts the GLib main loop used by the NetworkManager client.
"""
_lock = Lock()
_main_context = None
_nm_client = None
@classmethod
def initialize_nm_client_singleton(cls):
"""
Initializes the NetworkManager client singleton.
If the singleton was initialized, this method will do nothing. However,
if the singleton wasn't initialized it will initialize it, starting
a new GLib MainLoop.
A double-checked lock is used to avoid the possibility of multiple
threads concurrently creating multiple instances of the NM client
(with their own main loops).
"""
if cls._nm_client:
return
with cls._lock:
if not cls._nm_client:
cls._initialize_nm_client_singleton()
@classmethod
def _initialize_nm_client_singleton(cls):
cls._main_context = GLib.MainContext()
cls._nm_client = NM.Client()
# Setting daemon=True when creating the thread makes that this thread
# exits abruptly when the python process exits. It would be better to
# exit the thread running the main loop calling self._main_loop.quit().
Thread(target=cls._run_main_loop, daemon=True).start()
callback, future = cls.create_nmcli_callback(
finish_method_name="new_finish"
)
def new_async():
cls._assert_running_on_main_loop_thread()
cls._nm_client.new_async(cancellable=None, callback=callback, user_data=None)
cls._run_on_main_loop_thread(new_async)
cls._nm_client = future.result()
@classmethod
def _run_main_loop(cls):
main_loop = GLib.MainLoop(cls._main_context)
cls._main_context.push_thread_default()
main_loop.run()
@classmethod
def _assert_running_on_main_loop_thread(cls):
"""
This method asserts that the thread running it is the one iterating
GLib's main loop.
It's useful to call this method at the beginning of any code block
that's supposed to run in GLib's main loop, to avoid hard-to-debug
issues.
For more info:
https://developer.gnome.org/documentation/tutorials/main-contexts.html#checking-threading
"""
assert cls._main_context.is_owner() # nosec B311, B101 # noqa: E501 # pylint: disable=line-too-long # nosemgrep: gitlab.bandit.B101
@classmethod
def _run_on_main_loop_thread(cls, function):
cls._main_context.invoke_full(priority=GLib.PRIORITY_DEFAULT, function=function)
@classmethod
def create_nmcli_callback(cls, finish_method_name: str) -> (Callable, Future):
"""Creates a callback for the NM client finish method and a Future that will
resolve once the callback is called."""
future = Future()
future.set_running_or_notify_cancel()
def callback(source_object, res, userdata): # pylint: disable=unused-argument
cls._assert_running_on_main_loop_thread()
try:
# On errors, according to the docs, the callback can be called
# with source_object/res set to None.
# https://lazka.github.io/pgi-docs/index.html#NM-1.0/classes/Client.html#NM.Client.new_async
if not source_object or not res:
raise VPNConnectionError(
f"An unexpected error occurred initializing NMClient: "
f"source_object = {source_object}, res = {res}."
)
result = getattr(source_object, finish_method_name)(res)
# According to the docs, None is returned on errors
# https://lazka.github.io/pgi-docs/index.html#NM-1.0/classes/Client.html#NM.Client.new_finish
if not result:
raise VPNConnectionError(
"An unexpected error occurred initializing NMCLient"
)
future.set_result(result)
except BaseException as exc: # pylint: disable=broad-except
future.set_exception(exc)
return callback, future
def __init__(self):
self.initialize_nm_client_singleton()
def commit_changes_async(
self, new_connection: NM.RemoteConnection
) -> Future:
"""
Commits changes asynchronously.
https://lazka.github.io/pgi-docs/#NM-1.0/classes/RemoteConnection.html#NM.RemoteConnection.commit_changes_async
:return: a Future to keep track of completion.
"""
callback, future = self.create_nmcli_callback(
finish_method_name="commit_changes_finish"
)
def commit_changes_async():
self._assert_running_on_main_loop_thread()
new_connection.commit_changes_async(
True,
None,
callback,
None
)
self._run_on_main_loop_thread(commit_changes_async)
return future
def add_connection_async(self, connection: NM.Connection) -> Future:
"""
Adds a new connection asynchronously.
https://lazka.github.io/pgi-docs/#NM-1.0/classes/Client.html#NM.Client.add_connection_async
:param connection: connection to be added.
:return: a Future to keep track of completion.
"""
callback, future = self.create_nmcli_callback(
finish_method_name="add_connection_finish"
)
def add_connection_async():
self._assert_running_on_main_loop_thread()
self._nm_client.add_connection_async(
connection=connection,
save_to_disk=False,
cancellable=None,
callback=callback,
user_data=None
)
self._run_on_main_loop_thread(add_connection_async)
return future
def start_connection_async(self, connection: NM.Connection) -> Future:
"""Starts a VPN connection asynchronously.
:param connection: connection to be started.
:return: Future to know when the connection has been started. Note that
is just after the connection has started but before it is established.
"""
callback, future = self.create_nmcli_callback(
finish_method_name="activate_connection_finish"
)
def activate_connection_async():
self._assert_running_on_main_loop_thread()
self._nm_client.activate_connection_async(
connection,
None,
None,
None,
callback,
None
)
self._run_on_main_loop_thread(activate_connection_async)
return future
def stop_connection_async(self, connection: NM.ActiveConnection) -> Future:
"""Stops a VPN connection asynchronously.
:param connection: connection to be stopped.
:return: Future to know when the connection has been stopped.
"""
callback, future = self.create_nmcli_callback(
finish_method_name="deactivate_connection_finish"
)
def deactivate_connection_async():
self._assert_running_on_main_loop_thread()
self._nm_client.deactivate_connection_async(
connection,
None,
callback,
None
)
self._run_on_main_loop_thread(deactivate_connection_async)
return future
def remove_connection_async(
self, connection: NM.RemoteConnection
) -> Future:
"""
Removes the specified connection asynchronously.
https://lazka.github.io/pgi-docs/#NM-1.0/classes/RemoteConnection.html#NM.RemoteConnection.delete_async
:param connection: connection to be removed.
:return: a Future to keep track of completion.
"""
callback, future = self.create_nmcli_callback(
finish_method_name="delete_finish"
)
def delete_async():
self._assert_running_on_main_loop_thread()
connection.delete_async(
None,
callback,
None
)
self._run_on_main_loop_thread(delete_async)
return future
def get_active_connection(self, uuid: str) -> Optional[NM.ActiveConnection]:
"""
Returns the specified active connection, if existing.
:param uuid: UUID of the active connection.
:return: the active connection if it was found. Otherwise, None.
"""
active_connections = self._nm_client.get_active_connections()
for connection in active_connections:
if connection.get_uuid() == uuid:
return connection
return None
def get_connection(self, uuid: str) -> Optional[NM.RemoteConnection]:
"""
Returns the specified connection, if existing.
:param uuid: UUID of the connection.
:return: the connection if it was found. Otherwise, None.
"""
return self._nm_client.get_connection_by_uuid(uuid)
python-proton-vpn-api-core-4.16.0/proton/vpn/backend/networkmanager/core/tcpcheck.py 0000664 0000000 0000000 00000005262 15151554407 0030601 0 ustar 00root root 0000000 0000000 """
Utility module to do TCP connection checks.
"""
import asyncio
import ipaddress
import logging
import socket
from asyncio import as_completed
from contextlib import closing
from typing import List, Union
logger = logging.getLogger(__name__)
DEFAULT_TIMEOUT = 5
def _get_address_family(
ip_address: Union[ipaddress.IPv4Address, ipaddress.IPv6Address]
) -> socket.AddressFamily: # pylint: disable=no-member
if isinstance(ip_address, ipaddress.IPv4Address):
return socket.AF_INET
if isinstance(ip_address, ipaddress.IPv6Address):
return socket.AF_INET6
raise TypeError(f"Invalid IP address: {ip_address}")
def is_port_reachable(
ip_address: str, port: str, timeout_in_secs: int = DEFAULT_TIMEOUT
) -> bool:
"""
Checks if the specified IP address is reachable by opening a TCP socket
to the specified port.
:param ip_address: IP address to connect to.
:param port: TCP port to connect to.
:param timeout_in_secs: Optional connection timeout.
:returns: True if a socket could be opened to the specified address/port,
or False otherwise.
"""
ip_address = ipaddress.ip_address(ip_address)
address_family = _get_address_family(ip_address)
logger.debug("Checking %s:%s...", ip_address, port)
with closing(socket.socket(address_family, socket.SOCK_STREAM)) as sock:
sock.settimeout(timeout_in_secs)
socket_result = sock.connect_ex((f"{ip_address}", port))
logger.debug("%s:%s => %s", ip_address, port, socket_result)
return socket_result == 0
async def is_any_port_reachable(
ip_address: str, ports: List[str], timeout: int = DEFAULT_TIMEOUT
) -> bool:
"""
Checks if the specified IP address is reachable by opening a TCP socket
to any of the specified ports.
All ports are probed in parallel. As soon as a socket is opened to
one of them, connectivity is reported by returning True. If all
the connections to the different ports fail, then False is returned.
:param ip_address: IP address to connect to.
:param port: TCP ports to try to connect to.
:param timeout_in_secs: Optional connection timeout.
:returns: True if a socket could be opened to the specified address/ports,
or False otherwise.
"""
loop = asyncio.get_running_loop()
async def _is_port_reachable(port):
return await loop.run_in_executor(None, is_port_reachable, ip_address, port, timeout)
tasks = [
asyncio.create_task(_is_port_reachable(port))
for port in ports
]
for task in as_completed(tasks):
try:
return await task
except Exception: # pylint: disable=broad-except
return False
python-proton-vpn-api-core-4.16.0/proton/vpn/backend/networkmanager/killswitch/ 0000775 0000000 0000000 00000000000 15151554407 0027663 5 ustar 00root root 0000000 0000000 python-proton-vpn-api-core-4.16.0/proton/vpn/backend/networkmanager/killswitch/default/ 0000775 0000000 0000000 00000000000 15151554407 0031307 5 ustar 00root root 0000000 0000000 python-proton-vpn-api-core-4.16.0/proton/vpn/backend/networkmanager/killswitch/default/__init__.py 0000664 0000000 0000000 00000001564 15151554407 0033426 0 ustar 00root root 0000000 0000000 """
Init module that makes the NetworkManager 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.backend.networkmanager.killswitch.default.nmkillswitch import NMKillSwitch
__all__ = ["NMKillSwitch"]
killswitch_connection.py 0000664 0000000 0000000 00000015017 15151554407 0036202 0 ustar 00root root 0000000 0000000 python-proton-vpn-api-core-4.16.0/proton/vpn/backend/networkmanager/killswitch/default """
This module contains all configurations needed to create
connection Kill Switches for NM.Client.
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 .
"""
# pylint: disable=duplicate-code
from dataclasses import dataclass, field
import uuid
import gi # pylint: disable=C0411
gi.require_version("NM", "1.0")
from gi.repository import NM, GLib # noqa: E402 pylint: disable=C0413
DEFAULT_METRIC = -1
@dataclass
class KillSwitchGeneralConfig: # pylint: disable=missing-class-docstring
human_readable_id: str
interface_name: str
@dataclass
class KillSwitchIPConfig: # pylint: disable=missing-class-docstring
addresses: list
dns: list
dns_priority: str
ignore_auto_dns: bool
route_metric: str
gateway: str = None
routes: list = field(default_factory=list)
class KillSwitchConnection: # pylint: disable=too-few-public-methods
"""Connection that is used to configure different types of Kill Switch
connection. Are easily configured with the help of `KillSwitchGeneralConfig`
and `KillSwitchIPConfig`.
"""
def __init__(
self,
general_settings: KillSwitchGeneralConfig,
ipv6_settings: KillSwitchIPConfig,
ipv4_settings: KillSwitchIPConfig
):
self._connection_profile = None
self._general_settings = general_settings
self._ipv6_settings = ipv6_settings
self._ipv4_settings = ipv4_settings
@property
def connection(self) -> NM.Connection:
"""Lazy return connection object"""
if self._connection_profile is None:
self._create_connection_profile()
return self._connection_profile
def _create_connection_profile(self):
self._connection_profile = NM.SimpleConnection.new()
s_con = NM.SettingConnection.new()
s_con.set_property(NM.SETTING_CONNECTION_ID, self._general_settings.human_readable_id)
s_con.set_property(
NM.SETTING_CONNECTION_INTERFACE_NAME,
self._general_settings.interface_name
)
s_con.set_property(NM.SETTING_CONNECTION_UUID, str(uuid.uuid4()))
s_con.set_property(NM.SETTING_CONNECTION_TYPE, NM.SETTING_DUMMY_SETTING_NAME)
s_dummy = NM.SettingDummy.new()
s_ipv4 = self._generate_ipv4_settings()
s_ipv6 = self._generate_ipv6_settings()
self._connection_profile.add_setting(s_con)
self._connection_profile.add_setting(s_ipv4)
self._connection_profile.add_setting(s_ipv6)
self._connection_profile.add_setting(s_dummy)
# Ensures the properties get correct values
# https://lazka.github.io/pgi-docs/index.html#NM-1.0/classes/Connection.html#NM.Connection.verify
if not s_con.verify():
raise RuntimeError("Connection has invalid properties")
def _generate_ipv4_settings(self):
"""
For documentation see:
https://lazka.github.io/pgi-docs/index.html#NM-1.0/classes/SettingIPConfig.html#NM.SettingIPConfig
https://lazka.github.io/pgi-docs/NM-1.0/classes/IPAddress.html#NM.IPAddress
https://lazka.github.io/pgi-docs/NM-1.0/classes/IPRoute.html#NM.IPRoute.new
"""
s_ip4 = NM.SettingIP4Config.new()
if self._ipv4_settings is None:
s_ip4.set_property(NM.SETTING_IP_CONFIG_METHOD, NM.SETTING_IP4_CONFIG_METHOD_DISABLED)
return s_ip4
# inform NM that the IP configuration is manual
s_ip4.set_property(NM.SETTING_IP_CONFIG_METHOD, NM.SETTING_IP4_CONFIG_METHOD_MANUAL)
# Add addresses
for address in self._ipv4_settings.addresses:
ipv4, prefix = str(address).split("/")
s_ip4.add_address(
NM.IPAddress.new(GLib.SYSDEF_AF_INET, ipv4, int(prefix))
)
# Add DNS
for dns in self._ipv4_settings.dns:
s_ip4.add_dns(dns)
# Add routes
for route in self._ipv4_settings.routes:
ipv4, prefix = str(route).split("/")
s_ip4.add_route(
NM.IPRoute.new(
family=GLib.SYSDEF_AF_INET, dest=ipv4, prefix=int(prefix),
next_hop=None, metric=DEFAULT_METRIC
)
)
# Rest of the configs
s_ip4.props.dns_priority = self._ipv4_settings.dns_priority
s_ip4.props.route_metric = self._ipv4_settings.route_metric
s_ip4.props.ignore_auto_dns = self._ipv4_settings.ignore_auto_dns
if self._ipv4_settings.gateway:
s_ip4.props.gateway = self._ipv4_settings.gateway
return s_ip4
def _generate_ipv6_settings(self):
"""
For documentation see:
https://lazka.github.io/pgi-docs/index.html#NM-1.0/classes/SettingIPConfig.html#NM.SettingIPConfig
https://lazka.github.io/pgi-docs/NM-1.0/classes/IPAddress.html#NM.IPAddress
https://lazka.github.io/pgi-docs/NM-1.0/classes/IPRoute.html#NM.IPRoute.new
"""
s_ip6 = NM.SettingIP6Config.new()
if self._ipv6_settings is None:
s_ip6.set_property(NM.SETTING_IP_CONFIG_METHOD, NM.SETTING_IP6_CONFIG_METHOD_DISABLED)
return s_ip6
# inform NM that the IP configuration is manual
s_ip6.set_property(NM.SETTING_IP_CONFIG_METHOD, NM.SETTING_IP6_CONFIG_METHOD_MANUAL)
# Add addresses
for address in self._ipv6_settings.addresses:
ip, prefix = str(address).split("/") # pylint: disable=invalid-name
s_ip6.add_address(
NM.IPAddress.new(GLib.SYSDEF_AF_INET6, ip, int(prefix))
)
# Add DNS
for dns in self._ipv6_settings.dns:
s_ip6.add_dns(dns)
# Rest of the configs
s_ip6.props.dns_priority = self._ipv6_settings.dns_priority
s_ip6.props.route_metric = self._ipv6_settings.route_metric
s_ip6.props.ignore_auto_dns = self._ipv6_settings.ignore_auto_dns
if self._ipv6_settings.gateway:
s_ip6.props.gateway = self._ipv6_settings.gateway
return s_ip6
killswitch_connection_handler.py 0000664 0000000 0000000 00000024017 15151554407 0037677 0 ustar 00root root 0000000 0000000 python-proton-vpn-api-core-4.16.0/proton/vpn/backend/networkmanager/killswitch/default """
This modules contains the classes that communicate with NetworkManager.
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 ipaddress import ip_network
import asyncio
import concurrent.futures
from proton.vpn import logging
from proton.vpn.backend.networkmanager.killswitch.default.nmclient import NMClient
from proton.vpn.backend.networkmanager.killswitch.default.killswitch_connection import (
KillSwitchConnection, KillSwitchGeneralConfig, KillSwitchIPConfig
)
logger = logging.getLogger(__name__)
LOCAL_AGENT_SERVER_ADDR = "10.2.0.1" # 10.2.0.1:65432
def _get_connection_id(prefix: str, permanent: bool, ipv6: bool = False, routed: bool = False):
if ipv6:
return f"{prefix}-killswitch-ipv6{'-perm' if permanent else ''}"
return f"{prefix}{'-routed' if routed else ''}-killswitch{'-perm' if permanent else ''}"
def _get_interface_name(permanent: bool, ipv6: bool = False, routed: bool = False):
if ipv6:
return f"ipv6leakintrf{'1' if permanent else '0'}"
return f"{'pvpnrouteintrf' if routed else 'pvpnksintrf'}{'1' if permanent else '0'}"
async def _wrap_future(future: concurrent.futures.Future, timeout=5):
"""Wraps a concurrent.future.Future object in an asyncio.Future object."""
return await asyncio.wait_for(
asyncio.wrap_future(future, loop=asyncio.get_running_loop()),
timeout=timeout
)
def build_routes_list(server_ip: str):
"""
Builds a list of routes to block all traffic except the server IP and
the local agent server IP.
"""
local_agent_network = ip_network(LOCAL_AGENT_SERVER_ADDR)
most_networks = ip_network('0.0.0.0/0').address_exclude(
ip_network(server_ip))
for network_a in most_networks:
if local_agent_network.overlaps(network_a):
for network_b in network_a.address_exclude(local_agent_network):
yield network_b
else:
yield network_a
class KillSwitchConnectionHandler:
"""Kill switch connection management."""
def __init__(self, nm_client: NMClient = None, connection_prefix: str = None):
self._nm_client = nm_client
self._connection_prefix = connection_prefix or "pvpn"
self._ipv6_ks_settings = KillSwitchIPConfig(
addresses=["fdeb:446c:912d:08da::/64"],
dns=["::1"],
dns_priority=-1400,
gateway="fdeb:446c:912d:08da::1",
ignore_auto_dns=True,
route_metric=95
)
@staticmethod
def _get_ipv4_ks_settings(server_ip: str = None):
if server_ip:
# accept/block all routes except the server IP route.
routes = list(build_routes_list(server_ip))
gateway = None
else:
routes = [] # accept/block all routes.
gateway = "100.85.0.1"
return KillSwitchIPConfig(
addresses=["100.85.0.1/24"],
dns=["0.0.0.0"], # nosec hardcoded_bind_all_interfaces
dns_priority=-1400,
gateway=gateway,
ignore_auto_dns=True,
route_metric=98,
routes=routes
)
@property
def nm_client(self):
"""Returns the NetworkManager client."""
if self._nm_client is None:
self._nm_client = NMClient()
return self._nm_client
@property
def is_network_manager_running(self) -> bool:
"""Returns if the Network Manager daemon is running or not."""
return self.nm_client.get_nm_running()
@property
def is_connectivity_check_enabled(self) -> bool:
"""Returns if connectivity_check property is enabled or not."""
return self.nm_client.connectivity_check_get_enabled()
async def add_full_killswitch_connection(self, permanent: bool):
"""Adds full kill switch connection to Network Manager. This connection blocks all
outgoing traffic when not connected to VPN, with the exception of torrent client which will
require to be bonded to the VPN interface.."""
await self._ensure_connectivity_check_is_disabled()
connection_id = _get_connection_id(self._connection_prefix, permanent)
connection = self.nm_client.get_active_connection(
conn_id=connection_id
)
if connection:
logger.debug("Kill switch was already present.")
return
interface_name = _get_interface_name(permanent)
general_config = KillSwitchGeneralConfig(
human_readable_id=connection_id,
interface_name=interface_name
)
kill_switch = KillSwitchConnection(
general_config,
ipv4_settings=self._get_ipv4_ks_settings(),
ipv6_settings=self._ipv6_ks_settings,
)
await _wrap_future(
self.nm_client.add_connection_async(kill_switch.connection, save_to_disk=permanent)
)
logger.debug(f"{'Permanent' if permanent else 'Non-permanent'} kill switch added.")
await self._remove_connection(
connection_id=_get_connection_id(self._connection_prefix, permanent=not permanent)
)
logger.debug(f"{'Non-permanent' if permanent else 'Permanent'} kill switch removed.")
async def add_routed_killswitch_connection(self, server_ip: str, permanent: bool):
"""Add routed kill switch connection to Network Manager.
This connection has a "hole punched in it", to allow only the server IP to
access the outside world while blocking all other outgoing traffic. This is only
temporary though as it will be removed once we establish a VPN connection and will
get replaced by the full kill switch connection.
"""
await self._ensure_connectivity_check_is_disabled()
general_config = KillSwitchGeneralConfig(
human_readable_id=_get_connection_id(self._connection_prefix, permanent, routed=True),
interface_name=_get_interface_name(permanent, routed=True)
)
kill_switch = KillSwitchConnection(
general_config,
ipv4_settings=self._get_ipv4_ks_settings(server_ip),
ipv6_settings=self._ipv6_ks_settings,
)
await _wrap_future(
self.nm_client.add_connection_async(kill_switch.connection, save_to_disk=permanent)
)
logger.debug("Routed kill switch added.")
async def add_ipv6_leak_protection(self):
"""Adds IPv6 kill switch to NetworkManager. This connection is mainly
to prevent IPv6 leaks while using IPv4."""
await self._ensure_connectivity_check_is_disabled()
connection_id = _get_connection_id(
self._connection_prefix, permanent=False, ipv6=True
)
connection = self.nm_client.get_active_connection(
conn_id=connection_id)
if connection:
logger.debug("IPv6 leak protection already present.")
return
interface_name = _get_interface_name(permanent=False, ipv6=True)
general_config = KillSwitchGeneralConfig(
human_readable_id=connection_id,
interface_name=interface_name
)
kill_switch = KillSwitchConnection(
general_config,
ipv4_settings=None,
ipv6_settings=self._ipv6_ks_settings,
)
await _wrap_future(
self.nm_client.add_connection_async(kill_switch.connection, save_to_disk=False)
)
logger.debug("IPv6 leak protection added.")
async def remove_full_killswitch_connection(self):
"""Removes full kill switch connection."""
logger.debug("Removing full kill switch...")
await self._remove_connection(
_get_connection_id(self._connection_prefix, permanent=True)
)
await self._remove_connection(
_get_connection_id(self._connection_prefix, permanent=False)
)
logger.debug("Full kill switch removed.")
async def remove_routed_killswitch_connection(self):
"""Removes routed kill switch connection."""
logger.debug("Removing routed kill switch...")
await self._remove_connection(
_get_connection_id(self._connection_prefix, permanent=True, routed=True)
)
await self._remove_connection(
_get_connection_id(self._connection_prefix, permanent=False, routed=True)
)
logger.debug("Routed kill switch removed.")
async def remove_ipv6_leak_protection(self):
"""Removes IPv6 kill switch connection."""
logger.debug("Removing IPv6 leak protection...")
await self._remove_connection(
_get_connection_id(self._connection_prefix, permanent=False, ipv6=True)
)
logger.debug("IP6 leak protection removed.")
async def _remove_connection(self, connection_id: str):
connection = self.nm_client.get_connection(
conn_id=connection_id)
logger.debug(f"Attempting to remove {connection_id}: {connection}")
if not connection:
logger.debug(f"There was no {connection_id} to remove")
return
await _wrap_future(self.nm_client.remove_connection_async(connection))
async def _ensure_connectivity_check_is_disabled(self):
if self.is_connectivity_check_enabled: # noqa: E501 # pylint: disable=line-too-long # nosemgrep: python.lang.maintainability.is-function-without-parentheses.is-function-without-parentheses
await _wrap_future(self.nm_client.disable_connectivity_check())
logger.info("Network connectivity check was disabled.")
python-proton-vpn-api-core-4.16.0/proton/vpn/backend/networkmanager/killswitch/default/nmclient.py 0000664 0000000 0000000 00000032370 15151554407 0033477 0 ustar 00root root 0000000 0000000 """
Wrapper over the NetworkManager client.
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 .
"""
# pylint: disable=duplicate-code
from concurrent.futures import Future
from threading import Thread, Lock
from typing import Optional
from packaging.version import Version
import gi
gi.require_version("NM", "1.0")
from gi.repository import NM, GLib, Gio, GObject # pylint: disable=C0413 # noqa: E402
from proton.vpn import logging # noqa: E402 pylint: disable=wrong-import-position
logger = logging.getLogger(__name__)
def _create_future():
"""Creates a future and sets its internal state as running."""
future = Future()
future.set_running_or_notify_cancel()
return future
class NMClient:
"""
Wrapper over the NetworkManager client.
It also starts the GLib main loop used by the NetworkManager client.
"""
_lock = Lock()
_main_context = None
_nm_client = None
@classmethod
def initialize_nm_client_singleton(cls):
"""
Initializes the NetworkManager client singleton.
If the singleton was initialized, this method will do nothing. However,
if the singleton wasn't initialized it will initialize it, starting
a new GLib MainLoop.
A double-checked lock is used to avoid the possibility of multiple
threads concurrently creating multiple instances of the NM client
(with their own main loops).
"""
if cls._nm_client:
return
with cls._lock:
if not cls._nm_client:
cls._initialize_nm_client_singleton()
@classmethod
def _initialize_nm_client_singleton(cls):
cls._main_context = GLib.MainContext()
# Setting daemon=True when creating the thread makes that this thread
# exits abruptly when the python process exits. It would be better to
# exit the thread running the main loop calling self._main_loop.quit().
Thread(target=cls._run_glib_loop, daemon=True).start()
def _init_nm_client():
# It's important the NM.Client instance is created in the thread
# running the GLib event loop so that then that's the thread used
# for all GLib asynchronous operations.
return NM.Client.new(cancellable=None)
cls._nm_client = cls._run_on_glib_loop_thread(_init_nm_client).result()
@classmethod
def _run_glib_loop(cls):
main_loop = GLib.MainLoop(cls._main_context)
cls._main_context.push_thread_default()
main_loop.run()
@classmethod
def _assert_running_on_glib_loop_thread(cls):
"""
This method asserts that the thread running it is the one iterating
GLib's main loop.
It's useful to call this method at the beginning of any code block
that's supposed to run in GLib's main loop, to avoid hard-to-debug
issues.
For more info:
https://developer.gnome.org/documentation/tutorials/main-contexts.html#checking-threading
"""
if not cls._main_context.is_owner():
raise RuntimeError("Code being run outside GLib's main loop.")
@classmethod
def _run_on_glib_loop_thread(cls, function, *args, **kwargs) -> Future:
future = _create_future()
def wrapper():
cls._assert_running_on_glib_loop_thread()
try:
future.set_result(function(*args, **kwargs))
except BaseException as exc: # pylint: disable=broad-except
future.set_exception(exc)
cls._main_context.invoke_full(priority=GLib.PRIORITY_DEFAULT, function=wrapper)
return future
def __init__(self):
self.initialize_nm_client_singleton()
def add_connection_async(
self, connection: NM.Connection, save_to_disk: bool = False
) -> Future:
"""
Adds a new connection asynchronously.
https://lazka.github.io/pgi-docs/#NM-1.0/classes/Client.html#NM.Client.add_connection_async
:param connection: connection to be added.
:return: a Future to keep track of completion.
"""
future_conn_activated = _create_future()
def _on_interface_state_changed(_device, new_state, _old_state, _reason):
"""
Monitors kill switch interface state changes and resolves
the future as soon as the interface reaches the activated state
"""
logger.debug(
f"{connection.get_interface_name()} interface state changed "
f"to {NM.DeviceState(new_state).value_name}"
)
if (
NM.DeviceState(new_state) == NM.DeviceState.ACTIVATED
and not future_conn_activated.done()
):
future_conn_activated.set_result(None)
def _on_interface_added(_nm_client, device):
"""
Monitors interface creation. As soon as the kill switch interface
is created it sets up the call back to monitor interface state changes.
"""
logger.debug(
f"{device.get_iface()} interface added in state {device.get_state().value_name}"
)
if not device.get_iface() == connection.get_interface_name():
return
handler_id = device.connect("state-changed", _on_interface_state_changed)
future_conn_activated.add_done_callback(
lambda f: self._run_on_glib_loop_thread(
GObject.signal_handler_disconnect, device, handler_id
).result()
)
def _on_connection_added(nm_client, res, _user_data):
try:
# Make sure exceptions creating the connection are passed to the future.
nm_client.add_connection_finish(res)
except Exception as exc: # pylint: disable=broad-except
future_conn_activated.set_exception(
RuntimeError(
f"Error setting adding KS connection: {nm_client=}, {res=}"
).with_traceback(exc.__traceback__)
)
return
def _add_connection_async():
# Set up interface connection monitoring, which resolves the future
# once the kill switch is active.
handler_id = self._nm_client.connect("device-added", _on_interface_added)
future_conn_activated.add_done_callback(
lambda f: self._run_on_glib_loop_thread(
GObject.signal_handler_disconnect, self._nm_client, handler_id
).result()
)
# Add kill switch connection asynchronously.
self._nm_client.add_connection_async(
connection=connection,
save_to_disk=save_to_disk,
cancellable=None,
callback=_on_connection_added,
user_data=None
)
self._run_on_glib_loop_thread(_add_connection_async).result()
return future_conn_activated
def remove_connection_async(
self, connection: NM.RemoteConnection
) -> Future:
"""
Removes the specified connection asynchronously.
https://lazka.github.io/pgi-docs/#NM-1.0/classes/RemoteConnection.html#NM.RemoteConnection.delete_async
:param connection: connection to be removed.
:return: a Future to keep track of completion.
"""
future_interface_removed = _create_future()
def _on_connection_removed(connection, result, _user_data):
try:
connection.delete_finish(result)
except Exception as exc: # pylint: disable=broad-except
future_interface_removed.set_exception(
RuntimeError(
f"Error removing KS connection: {connection=}, {result=}"
).with_traceback(exc.__traceback__)
)
def _on_interface_removed(_nm_client, device):
logger.debug(
f"{device.get_iface()} was removed."
)
if device.get_iface() == connection.get_interface_name():
future_interface_removed.set_result(None)
def _remove_connection_async():
handler_id = self._nm_client.connect("device-removed", _on_interface_removed)
future_interface_removed.add_done_callback(
lambda f: self._run_on_glib_loop_thread(
GObject.signal_handler_disconnect, self._nm_client, handler_id
).result()
)
connection.delete_async(
None,
_on_connection_removed,
None
)
self._run_on_glib_loop_thread(_remove_connection_async).result()
return future_interface_removed
def get_active_connection(self, conn_id: str) -> Optional[NM.ActiveConnection]:
"""
Returns the specified active connection, if existing.
:param conn_id: ID of the active connection.
:return: the active connection if it was found. Otherwise, None.
"""
def _get_active_connection():
active_connections = self._nm_client.get_active_connections()
for connection in active_connections:
if connection.get_id() == conn_id:
return connection
return None
return self._run_on_glib_loop_thread(_get_active_connection).result()
def get_connection(self, conn_id: str) -> Optional[NM.RemoteConnection]:
"""
Returns the specified connection, if existing.
:param conn_id: ID of the connection.
:return: the connection if it was found. Otherwise, None.
"""
return self._run_on_glib_loop_thread(
self._nm_client.get_connection_by_id, conn_id
).result()
def get_nm_running(self) -> bool:
"""Returns if NetworkManager daemon is running or not."""
return self._run_on_glib_loop_thread(
self._nm_client.get_nm_running
).result()
def connectivity_check_get_enabled(self) -> bool:
"""Returns if connectivity check is enabled or not."""
return self._run_on_glib_loop_thread(
self._nm_client.connectivity_check_get_enabled
).result()
def disable_connectivity_check(self) -> Future:
"""Since `connectivity_check_set_enabled` has been deprecated,
we have to resort to lower lever commands.
https://lazka.github.io/pgi-docs/#NM-1.0/classes/Client.html#NM.Client.connectivity_check_set_enabled
This change is necessary since if this feature is enabled,
dummy connection are inflated with a value of 20000.
https://developer-old.gnome.org/NetworkManager/stable/NetworkManager.conf.html
(see under `connectivity section`)
"""
if Version(self._nm_client.get_version()) < Version("1.24.0"):
# NM.Client.connectivity_check_set_enabled is deprecated since version 1.22
# but the replacement method is only available in version 1.24.
return self._run_on_glib_loop_thread(
self._nm_client.connectivity_check_set_enabled, False
)
return self._dbus_set_property(
object_path="/org/freedesktop/NetworkManager",
interface_name="org.freedesktop.NetworkManager",
property_name="ConnectivityCheckEnabled",
value=GLib.Variant("b", False),
timeout_msec=-1,
cancellable=None
)
def _dbus_set_property(
self, *userdata, object_path: str, interface_name: str, property_name: str,
value: GLib.Variant, timeout_msec: int = -1,
cancellable: Gio.Cancellable = None,
) -> Future: # pylint: disable=too-many-arguments
"""Set NM properties since dedicated methods have been deprecated deprecated.
Source: https://lazka.github.io/pgi-docs/#NM-1.0/classes/Client.html""" # noqa
future = _create_future()
def _on_property_set(nm_client, res, _user_data):
if not nm_client or not res or not nm_client.dbus_set_property_finish(res):
future.set_exception(
RuntimeError(
f"Error disabling network connectivity check: {nm_client=}, {res=}"
)
)
return
future.set_result(None)
def _set_property_async():
self._assert_running_on_glib_loop_thread()
self._nm_client.dbus_set_property(
object_path, interface_name, property_name,
value, timeout_msec, cancellable, _on_property_set,
userdata
)
self._run_on_glib_loop_thread(_set_property_async).result()
return future
nmkillswitch.py 0000664 0000000 0000000 00000011614 15151554407 0034315 0 ustar 00root root 0000000 0000000 python-proton-vpn-api-core-4.16.0/proton/vpn/backend/networkmanager/killswitch/default """
Module for Kill Switch based on Network Manager.
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 .
"""
# pylint: disable=duplicate-code
from typing import Optional, TYPE_CHECKING
import subprocess # nosec B404:blacklist # nosemgrep: gitlab.bandit.B404
from proton.vpn.killswitch.interface import KillSwitch
from proton.vpn.backend.networkmanager.killswitch.default.killswitch_connection_handler\
import KillSwitchConnectionHandler
from proton.vpn import logging
if TYPE_CHECKING:
from proton.vpn.connection import VPNServer
logger = logging.getLogger(__name__)
class NMKillSwitch(KillSwitch):
"""
Kill Switch implementation using NetworkManager.
A dummy Network Manager connection is created to block all non-VPN traffic.
The way it works is that the dummy connection blocking non-VPN traffic is
added with a lower priority than the VPN connection but with a higher
priority than the other network manager connections. This way, the routing
table uses the dummy connection for any traffic that does not go to the
primary VPN connection.
"""
def __init__(self, ks_handler: KillSwitchConnectionHandler = None):
self._ks_handler = ks_handler or KillSwitchConnectionHandler()
super().__init__()
async def enable(
self, vpn_server: Optional["VPNServer"] = None, permanent: bool = False
): # noqa
"""Enables general kill switch."""
# The full KS blocks all traffic except the one going to an already
# existing VPN interface.
await self._ks_handler.add_full_killswitch_connection(permanent)
# If the routed KS is already enabled then it needs to be removed.
# There is no way to just update it with the new VPN server IP.
await self._ks_handler.remove_routed_killswitch_connection()
if not vpn_server:
return
# The routed KS blocks all traffic except the one going to the specified VPN server IP.
await self._ks_handler.add_routed_killswitch_connection(vpn_server.server_ip, permanent)
# At this point the full KS is removed to allow establishing the new VPN connection
# to the specified server IP.
await self._ks_handler.remove_full_killswitch_connection()
async def disable(self):
"""Disables general kill switch."""
await self._ks_handler.remove_full_killswitch_connection()
await self._ks_handler.remove_routed_killswitch_connection()
async def enable_ipv6_leak_protection(self, permanent: bool = False):
"""Enables IPv6 kill switch."""
await self._ks_handler.add_ipv6_leak_protection()
async def disable_ipv6_leak_protection(self):
"""Disables IPv6 kill switch."""
await self._ks_handler.remove_ipv6_leak_protection()
@staticmethod
def _get_priority() -> int:
return 100
@staticmethod
def _validate():
try:
KillSwitchConnectionHandler().is_network_manager_running # noqa pylint: disable=expression-not-assigned disable=line-too-long # nosemgrep: python.lang.maintainability.is-function-without-parentheses.is-function-without-parentheses
except (ModuleNotFoundError, ImportError):
logger.error("NetworkManager is not running.")
return False
# libnetplan0 is the first version that is present in Ubuntu 22.04. In Ubuntu 24.04
# the package name changes to libnetplan1, and it's not compatible with this kill
# switch implementation when IPv6 is disabled via the ipv6.disabled kernel option.
try:
result = subprocess.run(
["/usr/bin/apt", "show", "libnetplan1"],
capture_output=True,
check=True, shell=False
) # nosec B603:subprocess_without_shell_equals_true
except (FileNotFoundError, subprocess.CalledProcessError):
pass
else:
stdout_decoded = result.stdout.decode("utf8").split("\n")
for package_info_line in stdout_decoded:
if package_info_line.startswith("Version: 1.0.0"):
logger.warning(
"Kill switch is not compatible with libnetplan1 v1.0.0. "
"Please upgrade libnetplan1 package to v1.1.1"
)
break
return True
python-proton-vpn-api-core-4.16.0/proton/vpn/backend/networkmanager/killswitch/wireguard/ 0000775 0000000 0000000 00000000000 15151554407 0031654 5 ustar 00root root 0000000 0000000 python-proton-vpn-api-core-4.16.0/proton/vpn/backend/networkmanager/killswitch/wireguard/__init__.py0000664 0000000 0000000 00000001574 15151554407 0033774 0 ustar 00root root 0000000 0000000 """
Init module that makes the NetworkManager 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.backend.networkmanager.killswitch.wireguard.wgkillswitch \
import WGKillSwitch
__all__ = ["WGKillSwitch"]
killswitch_connection.py 0000664 0000000 0000000 00000014770 15151554407 0036554 0 ustar 00root root 0000000 0000000 python-proton-vpn-api-core-4.16.0/proton/vpn/backend/networkmanager/killswitch/wireguard """
This module contains all configurations needed to create
connection Kill Switches for NM.Client.
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 .
"""
# pylint: disable=duplicate-code
from dataclasses import dataclass, field
import uuid
import gi
gi.require_version("NM", "1.0")
from gi.repository import NM, GLib # pylint: disable=C0413 # noqa: E402
DEFAULT_METRIC = -1
@dataclass
class KillSwitchGeneralConfig: # pylint: disable=missing-class-docstring
human_readable_id: str
interface_name: str
@dataclass
class KillSwitchIPConfig: # pylint: disable=missing-class-docstring
addresses: list
dns: list
dns_priority: str
ignore_auto_dns: bool
route_metric: str
gateway: str = None
routes: list = field(default_factory=list)
class KillSwitchConnection: # pylint: disable=too-few-public-methods
"""Connection that is used to configure different types of Kill Switch
connection. Are easily configured with the help of `KillSwitchGeneralConfig`
and `KillSwitchIPConfig`.
"""
def __init__(
self,
general_settings: KillSwitchGeneralConfig,
ipv6_settings: KillSwitchIPConfig,
ipv4_settings: KillSwitchIPConfig
):
self._connection_profile = None
self._general_settings = general_settings
self._ipv6_settings = ipv6_settings
self._ipv4_settings = ipv4_settings
@property
def connection(self) -> NM.Connection:
"""Lazy return connection object"""
if self._connection_profile is None:
self._create_connection_profile()
return self._connection_profile
def _create_connection_profile(self):
self._connection_profile = NM.SimpleConnection.new()
s_con = NM.SettingConnection.new()
s_con.set_property(NM.SETTING_CONNECTION_ID, self._general_settings.human_readable_id)
s_con.set_property(
NM.SETTING_CONNECTION_INTERFACE_NAME,
self._general_settings.interface_name
)
s_con.set_property(NM.SETTING_CONNECTION_UUID, str(uuid.uuid4()))
s_con.set_property(NM.SETTING_CONNECTION_TYPE, NM.SETTING_DUMMY_SETTING_NAME)
s_dummy = NM.SettingDummy.new()
s_ipv4 = self._generate_ipv4_settings()
s_ipv6 = self._generate_ipv6_settings()
self._connection_profile.add_setting(s_con)
self._connection_profile.add_setting(s_ipv4)
self._connection_profile.add_setting(s_ipv6)
self._connection_profile.add_setting(s_dummy)
# Ensures the properties get correct values
# https://lazka.github.io/pgi-docs/index.html#NM-1.0/classes/Connection.html#NM.Connection.verify
if not s_con.verify():
raise RuntimeError("Connection has invalid properties")
def _generate_ipv4_settings(self):
"""
For documentation see:
https://lazka.github.io/pgi-docs/index.html#NM-1.0/classes/SettingIPConfig.html#NM.SettingIPConfig
https://lazka.github.io/pgi-docs/NM-1.0/classes/IPAddress.html#NM.IPAddress
https://lazka.github.io/pgi-docs/NM-1.0/classes/IPRoute.html#NM.IPRoute.new
"""
s_ip4 = NM.SettingIP4Config.new()
if self._ipv4_settings is None:
s_ip4.set_property(NM.SETTING_IP_CONFIG_METHOD, NM.SETTING_IP4_CONFIG_METHOD_DISABLED)
return s_ip4
# inform NM that the IP configuration is manual
s_ip4.set_property(NM.SETTING_IP_CONFIG_METHOD, NM.SETTING_IP4_CONFIG_METHOD_MANUAL)
# Add addresses
for address in self._ipv4_settings.addresses:
ipv4, prefix = str(address).split("/")
s_ip4.add_address(
NM.IPAddress.new(GLib.SYSDEF_AF_INET, ipv4, int(prefix))
)
# Add DNS
for dns in self._ipv4_settings.dns:
s_ip4.add_dns(dns)
# Add routes
for route in self._ipv4_settings.routes:
ipv4, prefix = str(route).split("/")
s_ip4.add_route(
NM.IPRoute.new(
family=GLib.SYSDEF_AF_INET, dest=ipv4, prefix=int(prefix),
next_hop=None, metric=DEFAULT_METRIC
)
)
# Rest of the configs
s_ip4.props.dns_priority = self._ipv4_settings.dns_priority
s_ip4.props.route_metric = self._ipv4_settings.route_metric
s_ip4.props.ignore_auto_dns = self._ipv4_settings.ignore_auto_dns
if self._ipv4_settings.gateway:
s_ip4.props.gateway = self._ipv4_settings.gateway
return s_ip4
def _generate_ipv6_settings(self):
"""
For documentation see:
https://lazka.github.io/pgi-docs/index.html#NM-1.0/classes/SettingIPConfig.html#NM.SettingIPConfig
https://lazka.github.io/pgi-docs/NM-1.0/classes/IPAddress.html#NM.IPAddress
https://lazka.github.io/pgi-docs/NM-1.0/classes/IPRoute.html#NM.IPRoute.new
"""
s_ip6 = NM.SettingIP6Config.new()
if self._ipv6_settings is None:
s_ip6.set_property(NM.SETTING_IP_CONFIG_METHOD, NM.SETTING_IP6_CONFIG_METHOD_DISABLED)
return s_ip6
# inform NM that the IP configuration is manual
s_ip6.set_property(NM.SETTING_IP_CONFIG_METHOD, NM.SETTING_IP6_CONFIG_METHOD_MANUAL)
# Add addresses
for address in self._ipv6_settings.addresses:
ip, prefix = str(address).split("/") # pylint: disable=invalid-name
s_ip6.add_address(
NM.IPAddress.new(GLib.SYSDEF_AF_INET6, ip, int(prefix))
)
# Add DNS
for dns in self._ipv6_settings.dns:
s_ip6.add_dns(dns)
# Rest of the configs
s_ip6.props.dns_priority = self._ipv6_settings.dns_priority
s_ip6.props.route_metric = self._ipv6_settings.route_metric
s_ip6.props.ignore_auto_dns = self._ipv6_settings.ignore_auto_dns
if self._ipv6_settings.gateway:
s_ip6.props.gateway = self._ipv6_settings.gateway
return s_ip6
killswitch_connection_handler.py 0000664 0000000 0000000 00000025673 15151554407 0040255 0 ustar 00root root 0000000 0000000 python-proton-vpn-api-core-4.16.0/proton/vpn/backend/networkmanager/killswitch/wireguard """
This modules contains the classes that communicate with NetworkManager.
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 .
"""
# pylint: disable=duplicate-code
# pylint: disable=duplicate-code
import re
import subprocess # nosec blacklist # nosemgrep: gitlab.bandit.B404
import asyncio
import concurrent.futures
from proton.vpn import logging
from proton.vpn.backend.networkmanager.killswitch.wireguard.nmclient import (
NMClient, GatewayNotFoundError
)
from proton.vpn.backend.networkmanager.killswitch.wireguard.killswitch_connection import (
KillSwitchConnection, KillSwitchGeneralConfig, KillSwitchIPConfig
)
logger = logging.getLogger(__name__)
def _get_connection_id(permanent: bool, ipv6: bool = False):
if ipv6:
return f"pvpn-killswitch-ipv6{'-perm' if permanent else ''}"
return f"pvpn-killswitch{'-perm' if permanent else ''}"
def _get_interface_name(permanent: bool, ipv6: bool = False):
if ipv6:
return f"ipv6leakintrf{'1' if permanent else '0'}"
return f"pvpnksintrf{'1' if permanent else '0'}"
async def _wrap_future(future: concurrent.futures.Future, timeout=10):
"""Wraps a concurrent.future.Future object in an asyncio.Future object."""
return await asyncio.wait_for(
asyncio.wrap_future(future, loop=asyncio.get_running_loop()),
timeout=timeout
)
class KillSwitchConnectionHandler:
"""Kill switch connection management."""
def __init__(
self, nm_client: NMClient = None,
config: KillSwitchGeneralConfig = None,
server_ip: str = None
):
self._nm_client = nm_client
self._config = config
self._ipv4_ks_settings = KillSwitchIPConfig(
addresses=["100.85.0.1/24"],
dns=["0.0.0.0"], # nosec hardcoded_bind_all_interfaces
dns_priority=-1400,
gateway="100.85.0.1",
ignore_auto_dns=True,
route_metric=98,
routes=[] # accept/block all routes.
)
self._ipv6_ks_settings = KillSwitchIPConfig(
addresses=["fdeb:446c:912d:08da::/64"],
dns=["::1"],
dns_priority=-1400,
gateway="fdeb:446c:912d:08da::1",
ignore_auto_dns=True,
route_metric=95
)
self._server_ip = server_ip
@property
def nm_client(self):
"""Returns the NetworkManager client."""
if self._nm_client is None:
self._nm_client = NMClient()
return self._nm_client
@property
def is_network_manager_running(self) -> bool:
"""Returns if the Network Manager daemon is running or not."""
return self.nm_client.get_nm_running()
@property
def is_connectivity_check_enabled(self) -> bool:
"""Returns if connectivity_check property is enabled or not."""
return self.nm_client.connectivity_check_get_enabled()
async def add_kill_switch_connection(self, permanent: bool):
"""Adds a dummy connection that swallows all traffic it receives.
This dummy connection has more priority than an ethernet/wifi
interface but with less priority than the VPN connection."""
await self._ensure_connectivity_check_is_disabled()
general_config = self._config or KillSwitchGeneralConfig(
human_readable_id=_get_connection_id(permanent),
interface_name=_get_interface_name(permanent)
)
connection = self.nm_client.get_active_connection(
conn_id=general_config.human_readable_id
)
if connection:
logger.debug("Kill switch was already present.")
return
kill_switch = KillSwitchConnection(
general_config,
ipv4_settings=self._ipv4_ks_settings,
ipv6_settings=self._ipv6_ks_settings,
)
await _wrap_future(
self.nm_client.add_connection_async(kill_switch.connection, save_to_disk=permanent)
)
logger.debug(f"{'Permanent' if permanent else 'Non-permanent'} kill switch added.")
await self._remove_connection(
connection_id=_get_connection_id(permanent=not permanent)
)
logger.debug(f"{'Non-permanent' if permanent else 'Permanent'} kill switch removed.")
async def add_vpn_server_route(self, server_ip: str):
"""Add route to allow outgoing traffic to the specified IP."""
await self._ensure_connectivity_check_is_disabled()
if not self.nm_client.is_monitoring_network_config_changes():
self._start_monitoring_network_config_changes()
devices = self.nm_client.get_physical_devices()
for device in devices:
try:
await _wrap_future(
self.nm_client.add_route_to_device(
device, new_server_ip=server_ip,
old_server_ip=self._server_ip
)
)
except GatewayNotFoundError as error:
logger.warning(
"Wireguard connection cannot use interface "
f"'{device.get_iface()}: {error}'")
continue
# The new route doesn't seem to be available straight away.
# For this reason, the routing table is polled until the route has been added.
await self._wait_for_vpn_server_route(
server_ip, device.get_iface(), found=True
)
self._server_ip = server_ip
async def remove_vpn_server_route(self):
"""
Remove a previously added VPN server route.
If the route is not found then nothing happens.
"""
if self.nm_client.is_monitoring_network_config_changes():
self.nm_client.stop_monitoring_network_config_changes()
if not self._server_ip:
return
devices = self.nm_client.get_physical_devices()
for device in devices:
await _wrap_future(
self.nm_client.remove_route_from_device(device, self._server_ip)
)
# The route doesn't seem to be removed straight away.
# For this reason, the routing table is polled until the route has been removed.
await self._wait_for_vpn_server_route(self._server_ip, device.get_iface(), found=False)
self._server_ip = None
@staticmethod
async def _run_ip_route_command():
def run():
return subprocess.run( # nosec subprocess_without_shell_equals_true
["/usr/sbin/ip", "route"], capture_output=True, encoding="utf-8", check=True
)
loop = asyncio.get_running_loop()
return await loop.run_in_executor(None, run)
@classmethod
async def _wait_for_vpn_server_route(
cls, server_ip: str, interface_name: str, found: bool = True
):
server_route = f"{server_ip} via .* dev {interface_name} .*"
for delay in [0.5, 0.5, 1, 1, 2]:
result = await cls._run_ip_route_command()
if bool(re.search(server_route, result.stdout)) is found:
return
await asyncio.sleep(delay)
raise TimeoutError(
f"Error waiting for server route to be {'added' if found else 'removed'}"
)
def _start_monitoring_network_config_changes(self):
loop = asyncio.get_running_loop()
def on_active_connection_changed(active_connection):
device = active_connection.get_devices()[0]
gateway = active_connection.get_ip4_config().get_gateway()
logger.info(f"Interface {device.get_iface()} switched to gateway {gateway}")
future = self.nm_client.add_route_to_device(device, self._server_ip, gateway)
future.add_done_callback(lambda f: loop.call_soon_threadsafe(f.result))
self.nm_client.start_monitoring_network_config_changes(on_active_connection_changed)
async def add_ipv6_leak_protection(self):
"""Adds IPv6 kill switch to prevent IPv6 leaks while using IPv4."""
await self._ensure_connectivity_check_is_disabled()
connection_id = _get_connection_id(permanent=False, ipv6=True)
connection = self.nm_client.get_active_connection(
conn_id=connection_id)
if connection:
logger.debug("IPv6 leak protection already present.")
return
interface_name = _get_interface_name(permanent=False, ipv6=True)
general_config = KillSwitchGeneralConfig(
human_readable_id=connection_id,
interface_name=interface_name
)
kill_switch = KillSwitchConnection(
general_config,
ipv4_settings=None,
ipv6_settings=self._ipv6_ks_settings,
)
await _wrap_future(
self.nm_client.add_connection_async(kill_switch.connection, save_to_disk=False)
)
logger.debug("IPv6 leak protection added.")
async def remove_killswitch_connection(self):
"""Removes full kill switch connection."""
logger.debug("Removing full kill switch...")
if self._config:
await self._remove_connection(self._config.human_readable_id)
else:
await self._remove_connection(_get_connection_id(permanent=True))
await self._remove_connection(_get_connection_id(permanent=False))
logger.debug("Full kill switch removed.")
async def remove_ipv6_leak_protection(self):
"""Removes IPv6 kill switch connection."""
logger.debug("Removing IPv6 leak protection...")
await self._remove_connection(_get_connection_id(permanent=False, ipv6=True))
logger.debug("IP6 leak protection removed.")
async def _remove_connection(self, connection_id: str):
connection = self.nm_client.get_connection(
conn_id=connection_id)
logger.debug(f"Attempting to remove {connection_id}: {connection}")
if not connection:
logger.debug(f"There was no {connection_id} to remove")
return
await _wrap_future(self.nm_client.remove_connection_async(connection))
async def _ensure_connectivity_check_is_disabled(self):
if self.is_connectivity_check_enabled: # noqa: E501 # pylint: disable=line-too-long # nosemgrep: python.lang.maintainability.is-function-without-parentheses.is-function-without-parentheses
await _wrap_future(self.nm_client.disable_connectivity_check())
logger.info("Network connectivity check was disabled.")
python-proton-vpn-api-core-4.16.0/proton/vpn/backend/networkmanager/killswitch/wireguard/nmclient.py0000664 0000000 0000000 00000052214 15151554407 0034043 0 ustar 00root root 0000000 0000000 """
Wrapper over the NetworkManager client.
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 .
"""
# pylint: disable=duplicate-code
from concurrent.futures import Future
from threading import Thread, Lock
from typing import Optional, List
from packaging.version import Version
import gi
gi.require_version("NM", "1.0")
from gi.repository import NM, GLib, Gio, GObject # pylint: disable=C0413 # noqa: E402
from proton.vpn import logging # noqa: E402 pylint: disable=wrong-import-position
logger = logging.getLogger(__name__)
class GatewayNotFoundError(Exception):
"""Error raised when trying to obtain the gateway from a device that
doesn't have one set."""
def _create_future():
"""Creates a future and sets its internal state as running."""
future = Future()
future.set_running_or_notify_cancel()
return future
class NMClient:
"""
Wrapper over the NetworkManager client.
It also starts the GLib main loop used by the NetworkManager client.
"""
_lock = Lock()
_main_context = None
_nm_client = None
@classmethod
def initialize_nm_client_singleton(cls):
"""
Initializes the NetworkManager client singleton.
If the singleton was initialized, this method will do nothing. However,
if the singleton wasn't initialized it will initialize it, starting
a new GLib MainLoop.
A double-checked lock is used to avoid the possibility of multiple
threads concurrently creating multiple instances of the NM client
(with their own main loops).
"""
if cls._nm_client:
return
with cls._lock:
if not cls._nm_client:
cls._initialize_nm_client_singleton()
@classmethod
def _initialize_nm_client_singleton(cls):
cls._main_context = GLib.MainContext()
# Setting daemon=True when creating the thread makes that this thread
# exits abruptly when the python process exits. It would be better to
# exit the thread running the main loop calling self._main_loop.quit().
Thread(target=cls._run_glib_loop, daemon=True).start()
def _init_nm_client():
# It's important the NM.Client instance is created in the thread
# running the GLib event loop so that then that's the thread used
# for all GLib asynchronous operations.
return NM.Client.new(cancellable=None)
cls._nm_client = cls._run_on_glib_loop_thread(_init_nm_client).result()
@classmethod
def _run_glib_loop(cls):
main_loop = GLib.MainLoop(cls._main_context)
cls._main_context.push_thread_default()
main_loop.run()
@classmethod
def _assert_running_on_glib_loop_thread(cls):
"""
This method asserts that the thread running it is the one i:terating
GLib's main loop.
It's useful to call this method at the beginning of any code block
that's supposed to run in GLib's main loop, to avoid hard-to-debug
issues.
For more info:
https://developer.gnome.org/documentation/tutorials/main-contexts.html#checking-threading
"""
if not cls._main_context.is_owner():
raise RuntimeError("Code being run outside GLib's main loop.")
@classmethod
def _run_on_glib_loop_thread(cls, function, *args, **kwargs) -> Future:
future = _create_future()
def wrapper():
cls._assert_running_on_glib_loop_thread()
try:
future.set_result(function(*args, **kwargs))
except BaseException as exc: # pylint: disable=broad-except
future.set_exception(exc)
cls._main_context.invoke_full(priority=GLib.PRIORITY_DEFAULT, function=wrapper)
return future
def __init__(self):
self.initialize_nm_client_singleton()
self._signal_handlers = []
def add_connection_async(
self, connection: NM.Connection, save_to_disk: bool = False
) -> Future:
"""
Adds a new connection asynchronously.
https://lazka.github.io/pgi-docs/#NM-1.0/classes/Client.html#NM.Client.add_connection_async
:param connection: connection to be added.
:return: a Future to keep track of completion.
"""
future_conn_activated = _create_future()
def _on_interface_state_changed(_device, new_state, _old_state, _reason):
"""
Monitors kill switch interface state changes and resolves
the future as soon as the interface reaches the activated state
"""
logger.debug(
f"{connection.get_interface_name()} interface state changed "
f"to {NM.DeviceState(new_state).value_name}"
)
if (
NM.DeviceState(new_state) == NM.DeviceState.ACTIVATED
and not future_conn_activated.done()
):
future_conn_activated.set_result(None)
def _on_interface_added(_nm_client, device):
"""
Monitors interface creation. As soon as the kill switch interface
is created it sets up the call back to monitor interface state changes.
"""
logger.debug(
f"{device.get_iface()} interface added in state {device.get_state().value_name}"
)
if not device.get_iface() == connection.get_interface_name():
return
handler_id = device.connect("state-changed", _on_interface_state_changed)
future_conn_activated.add_done_callback(
lambda f: self._run_on_glib_loop_thread(
GObject.signal_handler_disconnect, device, handler_id
).result()
)
def _on_connection_added(nm_client, res, _user_data):
try:
# Make sure exceptions creating the connection are passed to the future.
nm_client.add_connection_finish(res)
except Exception as exc: # pylint: disable=broad-except
future_conn_activated.set_exception(
RuntimeError(
f"Error adding KS connection: {exc}"
).with_traceback(exc.__traceback__)
)
return
def _add_connection_async():
# Set up interface connection monitoring, which resolves the future
# once the kill switch is active.
handler_id = self._nm_client.connect("device-added", _on_interface_added)
future_conn_activated.add_done_callback(
lambda f: self._run_on_glib_loop_thread(
GObject.signal_handler_disconnect, self._nm_client, handler_id
).result()
)
# Add kill switch connection asynchronously.
self._nm_client.add_connection_async(
connection=connection,
save_to_disk=save_to_disk,
cancellable=None,
callback=_on_connection_added,
user_data=None
)
self._run_on_glib_loop_thread(_add_connection_async).result()
return future_conn_activated
def get_physical_devices(self) -> List[NM.Device]:
"""Returns all the active ethernet/wifi devices."""
return [
device for device in self._nm_client.get_devices() if (
device.get_device_type() in (NM.DeviceType.ETHERNET, NM.DeviceType.WIFI)
and device.get_state() is NM.DeviceState.ACTIVATED
and device.get_active_connection() # Maybe this is redundant.
)
]
def _is_ethernet_or_wifi_connection(self, active_connection: NM.ActiveConnection):
return active_connection.props.type in (
NM.SETTING_WIRED_SETTING_NAME, NM.SETTING_WIRELESS_SETTING_NAME
)
def get_ethernet_and_wifi_connections(self) -> List[NM.ActiveConnection]:
"""Returns the list of ethernet and wifi connections."""
return [
ac for ac in self._nm_client.get_active_connections()
if self._is_ethernet_or_wifi_connection(ac)
]
def is_monitoring_network_config_changes(self) -> bool:
"""
Returns whether monitoring of network configuration changes is already
enabled or not.
"""
return bool(self._signal_handlers)
def start_monitoring_network_config_changes(self, callback):
"""Starts monitoring network configuration changes."""
if self.is_monitoring_network_config_changes():
raise RuntimeError("Network config changes are already being monitored.")
def on_active_connection_changed(active_connection, changed_field):
if (
changed_field.name == NM.DEVICE_IP4_CONFIG
and active_connection.get_ip4_config()
and active_connection.get_ip4_config().get_gateway()
):
callback(active_connection)
# Monitor current active (wifi/ethernet) connection changes.
connections = self.get_ethernet_and_wifi_connections()
for con in connections:
handler_id = con.connect("notify", on_active_connection_changed)
self._signal_handlers.append((con, handler_id))
def on_active_connection_added(_nm_client, active_connection):
if self._is_ethernet_or_wifi_connection(active_connection):
# If the new active connection is a wifi/ethernet connection then
# start monitoring its config changes.
handler_id = active_connection.connect("notify", on_active_connection_changed)
self._signal_handlers.append((active_connection, handler_id))
# Monitor new active connections.
handler_id = self._nm_client.connect("active-connection-added", on_active_connection_added)
self._signal_handlers.append((self._nm_client, handler_id))
def stop_monitoring_network_config_changes(self):
"""Stops monitoring network configuration changes."""
for instance, handler_id in self._signal_handlers:
instance.disconnect(handler_id)
self._signal_handlers.clear()
@classmethod
def _add_ipv4_route(
cls, active_connection: NM.ActiveConnection,
server_ip: str, gateway: str
):
cls._assert_running_on_glib_loop_thread()
connection = active_connection.get_connection()
config = connection.get_setting_ip4_config()
config.add_route(
NM.IPRoute.new(
family=GLib.SYSDEF_AF_INET,
dest=server_ip,
prefix=32,
next_hop=gateway,
metric=-1 # -1 just means that the default metric is applied.
)
)
@classmethod
def _remove_ipv4_routes(
cls, active_connection: NM.ActiveConnection,
server_ip: str
):
cls._assert_running_on_glib_loop_thread()
connection = active_connection.get_connection()
config = connection.get_setting_ip4_config()
routes_to_remove = []
for i in range(config.get_num_routes()):
route = config.get_route(i)
if route.get_dest() == server_ip and route.get_prefix() == 32:
routes_to_remove.append(route)
for route in routes_to_remove:
config.remove_route_by_value(route)
@classmethod
def _apply_connection_async(
cls, active_connection: NM.ActiveConnection, future: Future
):
cls._assert_running_on_glib_loop_thread()
device = active_connection.get_devices()[0]
def on_device_reapplied(device, result, _data=None):
try:
device.reapply_finish(result)
future.set_result(None)
except Exception as exc: # pylint: disable=broad-except
future.set_exception(exc)
def on_connection_commited(connection, result, _data=None):
try:
connection.commit_changes_finish(result)
device.reapply_async(
# Not sure if it's ok to always pass version_id and flags set to 0.
connection, version_id=0, flags=0,
cancellable=None, callback=on_device_reapplied
)
except Exception as exc: # pylint: disable=broad-except
future.set_exception(exc)
# By not saving the changes to disk, the route is gone after a restart.
active_connection.get_connection().commit_changes_async(
save_to_disk=False, cancellable=None, callback=on_connection_commited
)
@classmethod
def add_route_to_device(
cls, device: NM.Device, new_server_ip: str, old_server_ip: Optional[str] = None
) -> Future:
"""
Adds a route to the device to reach the new server ip via the gateway configured
on the device.
:param device: the device to apply the route to.
:param new_server_ip: the IP to apply the route for.
:param old_server_ip: if specified, routes for this IP will be removed before
adding the new one.
"""
route_added_future = _create_future()
active_connection = device.get_active_connection()
def _add_ipv4_route():
try:
if old_server_ip:
cls._remove_ipv4_routes(active_connection, old_server_ip)
# Remove any existing routes to the new server that may have been left over.
cls._remove_ipv4_routes(active_connection, new_server_ip)
gateway = active_connection.get_ip4_config().get_gateway()
if not gateway:
raise GatewayNotFoundError(
"Gateway not found on interface "
f"'{device.get_iface()}'"
)
cls._add_ipv4_route(active_connection, new_server_ip, gateway)
cls._apply_connection_async(active_connection, route_added_future)
except Exception as exc: # pylint: disable=broad-except
route_added_future.set_exception(exc)
cls._run_on_glib_loop_thread(_add_ipv4_route)
return route_added_future
@classmethod
def remove_route_from_device(cls, device: NM.Device, server_ip: str) -> Future:
"""
Removes all the routes pointing to the specified server IP
for the specified device.
"""
route_removed_future = _create_future()
active_connection = device.get_active_connection()
def _remove_ipv4_routes():
try:
cls._remove_ipv4_routes(active_connection, server_ip)
cls._apply_connection_async(active_connection, route_removed_future)
except Exception as exc: # pylint: disable=broad-except
route_removed_future.set_exception(exc)
cls._run_on_glib_loop_thread(_remove_ipv4_routes)
return route_removed_future
def remove_connection_async(
self, connection: NM.RemoteConnection
) -> Future:
"""
Removes the specified connection asynchronously.
https://lazka.github.io/pgi-docs/#NM-1.0/classes/RemoteConnection.html#NM.RemoteConnection.delete_async
:param connection: connection to be removed.
:return: a Future to keep track of completion.
"""
future_interface_removed = _create_future()
def _on_connection_removed(connection, result, _user_data):
try:
connection.delete_finish(result)
except Exception as exc: # pylint: disable=broad-except
future_interface_removed.set_exception(
RuntimeError(
f"Error removing KS connection: {exc}"
).with_traceback(exc.__traceback__)
)
def _on_interface_removed(_nm_client, device):
logger.debug(
f"{device.get_iface()} was removed."
)
if device.get_iface() == connection.get_interface_name():
future_interface_removed.set_result(None)
def _remove_connection_async():
handler_id = self._nm_client.connect("device-removed", _on_interface_removed)
future_interface_removed.add_done_callback(
lambda f: self._run_on_glib_loop_thread(
GObject.signal_handler_disconnect, self._nm_client, handler_id
).result()
)
connection.delete_async(
None,
_on_connection_removed,
None
)
self._run_on_glib_loop_thread(_remove_connection_async).result()
return future_interface_removed
def get_active_connection(self, conn_id: str) -> Optional[NM.ActiveConnection]:
"""
Returns the specified active connection, if existing.
:param conn_id: ID of the active connection.
:return: the active connection if it was found. Otherwise, None.
"""
def _get_active_connection():
active_connections = self._nm_client.get_active_connections()
for connection in active_connections:
if connection.get_id() == conn_id:
return connection
return None
return self._run_on_glib_loop_thread(_get_active_connection).result()
def get_connection(self, conn_id: str) -> Optional[NM.RemoteConnection]:
"""
Returns the specified connection, if existing.
:param conn_id: ID of the connection.
:return: the connection if it was found. Otherwise, None.
"""
return self._run_on_glib_loop_thread(
self._nm_client.get_connection_by_id, conn_id
).result()
def get_nm_running(self) -> bool:
"""Returns if NetworkManager daemon is running or not."""
return self._run_on_glib_loop_thread(
self._nm_client.get_nm_running
).result()
def connectivity_check_get_enabled(self) -> bool:
"""Returns if connectivity check is enabled or not."""
return self._run_on_glib_loop_thread(
self._nm_client.connectivity_check_get_enabled
).result()
def disable_connectivity_check(self) -> Future:
"""Since `connectivity_check_set_enabled` has been deprecated,
we have to resort to lower lever commands.
https://lazka.github.io/pgi-docs/#NM-1.0/classes/Client.html#NM.Client.connectivity_check_set_enabled
This change is necessary since if this feature is enabled,
dummy connection are inflated with a value of 20000.
https://developer-old.gnome.org/NetworkManager/stable/NetworkManager.conf.html
(see under `connectivity section`)
"""
if Version(self._nm_client.get_version()) < Version("1.24.0"):
# NM.Client.connectivity_check_set_enabled is deprecated since version 1.22
# but the replacement method is only available in version 1.24.
return self._run_on_glib_loop_thread(
self._nm_client.connectivity_check_set_enabled, False
)
return self._dbus_set_property(
object_path="/org/freedesktop/NetworkManager",
interface_name="org.freedesktop.NetworkManager",
property_name="ConnectivityCheckEnabled",
value=GLib.Variant("b", False),
timeout_msec=-1,
cancellable=None
)
def _dbus_set_property( # pylint: disable=too-many-arguments
self, *userdata, object_path: str, interface_name: str, property_name: str,
value: GLib.Variant, timeout_msec: int = -1,
cancellable: Gio.Cancellable = None,
) -> Future:
"""Set NM properties since dedicated methods have been deprecated deprecated.
Source: https://lazka.github.io/pgi-docs/#NM-1.0/classes/Client.html""" # noqa
future = _create_future()
def _on_property_set(nm_client, res, _user_data):
if not nm_client or not res or not nm_client.dbus_set_property_finish(res):
future.set_exception(
RuntimeError(
f"Error disabling network connectivity check: {nm_client=}, {res=}"
)
)
return
future.set_result(None)
def _set_property_async():
self._assert_running_on_glib_loop_thread()
self._nm_client.dbus_set_property(
object_path, interface_name, property_name,
value, timeout_msec, cancellable, _on_property_set,
userdata
)
self._run_on_glib_loop_thread(_set_property_async).result()
return future
python-proton-vpn-api-core-4.16.0/proton/vpn/backend/networkmanager/killswitch/wireguard/util.py 0000664 0000000 0000000 00000002466 15151554407 0033213 0 ustar 00root root 0000000 0000000 """
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 .
"""
# pylint: disable=duplicate-code
from proton.vpn import logging
logger = logging.getLogger(__name__)
def is_ipv6_disabled() -> bool:
"""Returns if IPv6 is disabled at kernel level or not."""
filepath = "/sys/module/ipv6/parameters/disable"
try:
with open(filepath, "r", encoding="utf-8") as ipv6_disabled_file:
if ipv6_disabled_file.read().strip() != "0":
return True
except FileNotFoundError:
# If flatpak then we don't have access to the file and assume
# IPv6 is enabled.
logger.error("Unable to figure if IPv6 is enabled or disabled, probably flatpak install.")
return False
return False
wgkillswitch.py 0000664 0000000 0000000 00000011600 15151554407 0034660 0 ustar 00root root 0000000 0000000 python-proton-vpn-api-core-4.16.0/proton/vpn/backend/networkmanager/killswitch/wireguard """
Module for Kill Switch based on Network Manager.
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 .
"""
# pylint: disable=duplicate-code
from typing import Optional, TYPE_CHECKING
import subprocess # nosec B404:blacklist # nosemgrep
from proton.vpn.killswitch.interface import KillSwitch
from proton.vpn.backend.networkmanager.killswitch.wireguard.killswitch_connection_handler\
import KillSwitchConnectionHandler
from proton.vpn import logging
if TYPE_CHECKING:
from proton.vpn.connection import VPNServer
logger = logging.getLogger(__name__)
class WGKillSwitch(KillSwitch):
"""
Kill Switch implementation using NetworkManager.
A dummy Network Manager connection is created to block all non-VPN traffic.
The way it works is that the dummy connection blocking non-VPN traffic is
added with a lower priority than the VPN connection but with a higher
priority than the other network manager connections. This way, the routing
table uses the dummy connection for any traffic that does not go to the
primary VPN connection.
"""
def __init__(
self, ks_handler: Optional[KillSwitchConnectionHandler] = None
):
self._ks_handler = ks_handler or KillSwitchConnectionHandler()
super().__init__()
async def enable(
self, vpn_server: Optional["VPNServer"] = None, permanent: bool = False
): # noqa
"""Enables the kill switch."""
# Block all traffic.
await self._ks_handler.add_kill_switch_connection(permanent)
if not vpn_server:
return
# Allow traffic going to the VPN server IP.
await self._ks_handler.add_vpn_server_route(
server_ip=vpn_server.server_ip
)
async def disable(self):
"""Disables general kill switch."""
await self._ks_handler.remove_killswitch_connection()
await self._ks_handler.remove_vpn_server_route()
async def enable_ipv6_leak_protection(self, permanent: bool = False):
"""Enables IPv6 kill switch."""
# Note that IPv6 leak protection is not required when using wireguard,
# since wireguard already prevents IPv6 leaks. IPv6 leak protection is
# added in case this kill switch implementation is also used on OpenVPN.
await self._ks_handler.add_ipv6_leak_protection()
async def disable_ipv6_leak_protection(self):
"""Disables IPv6 kill switch."""
await self._ks_handler.remove_ipv6_leak_protection()
@staticmethod
def _get_priority() -> int:
# The priority value is higher than the previous KS implementation (100)
# so that this implementation takes precedence if both are installed.
return 101
@staticmethod
def _validate(validate_params: dict = None):
if not validate_params or validate_params.get("protocol") != "wireguard":
return False
try:
KillSwitchConnectionHandler().is_network_manager_running # noqa pylint: disable=expression-not-assigned disable=line-too-long # nosemgrep: python.lang.maintainability.is-function-without-parentheses.is-function-without-parentheses
except (ModuleNotFoundError, ImportError):
logger.error("NetworkManager is not running.")
return False
# libnetplan0 is the first version that is present in Ubuntu 22.04. In Ubuntu 24.04
# the package name changes to libnetplan1, and it's not compatible with this kill
# switch implementation when IPv6 is disabled via the ipv6.disabled kernel option.
try:
result = subprocess.run(
["/usr/bin/apt", "show", "libnetplan1"],
capture_output=True,
check=True, shell=False
) # nosec B603:subprocess_without_shell_equals_true
except (FileNotFoundError, subprocess.CalledProcessError):
pass
else:
stdout_decoded = result.stdout.decode("utf8").split("\n")
for package_info_line in stdout_decoded:
if package_info_line.startswith("Version: 1.0.0"):
logger.warning(
"Kill switch is not compatible with libnetplan1 v1.0.0. "
"Please upgrade libnetplan1 package to v1.1.1"
)
break
return True
python-proton-vpn-api-core-4.16.0/proton/vpn/backend/networkmanager/protocol/ 0000775 0000000 0000000 00000000000 15151554407 0027347 5 ustar 00root root 0000000 0000000 python-proton-vpn-api-core-4.16.0/proton/vpn/backend/networkmanager/protocol/openvpn/ 0000775 0000000 0000000 00000000000 15151554407 0031034 5 ustar 00root root 0000000 0000000 python-proton-vpn-api-core-4.16.0/proton/vpn/backend/networkmanager/protocol/openvpn/__init__.py 0000664 0000000 0000000 00000001514 15151554407 0033146 0 ustar 00root root 0000000 0000000 """
This module contains the backends implementing the supported OpenVPN protocols.
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 .openvpn import OpenVPNTCP, OpenVPNUDP
__all__ = ["OpenVPNTCP", "OpenVPNUDP"]
python-proton-vpn-api-core-4.16.0/proton/vpn/backend/networkmanager/protocol/openvpn/openvpn.py 0000664 0000000 0000000 00000040627 15151554407 0033104 0 ustar 00root root 0000000 0000000 """
OpenVPN protocol implementations.
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 concurrent.futures import Future
import secrets
from getpass import getuser
from ipaddress import IPv4Address, IPv6Address
from typing import Union
import logging
from jinja2 import Environment, BaseLoader
from gi.repository import NM
import gi
from proton.vpn.backend.networkmanager.core import (LinuxNetworkManager, LocalAgentMixin)
from proton.vpn.connection.vpnconfiguration import VPNConfiguration
from proton.vpn.connection.constants import CA_CERT
from proton.vpn.connection import events, states
from proton.vpn.connection.events import EventContext
from proton.vpn.connection.interfaces import Settings
gi.require_version("NM", "1.0")
logger = logging.getLogger(__name__)
SECRET_PASSWORD_FIELD = "password" # pylint: disable=line-too-long # noqa: E501 # nosec B105 # nosemgrep: generic.secrets.gitleaks.hashicorp-tf-password.hashicorp-tf-password
SECRET_CERT_PASS_FIELD = "cert-pass" # pylint: disable=line-too-long # noqa: E501 # nosec B105 # nosemgrep: generic.secrets.gitleaks.hashicorp-tf-password.hashicorp-tf-password
PASSPHRASE_SECRET_LENGTH = 16
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
{{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-----
{{cert}}
{{priv_key}}
"""
class OVPNConfig(VPNConfiguration):
"""OpenVPN-specific configuration."""
PROTOCOL = None
EXTENSION = ".ovpn"
def __init__(self, private_key_passphrase, *args, **kwargs):
super().__init__(*args, **kwargs)
self._private_key_passphrase = private_key_passphrase
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,
}
j2_values["cert"] =\
self._vpncredentials.pubkey_credentials.certificate_pem
password = self._private_key_passphrase.encode()
j2_values["priv_key"] = self._vpncredentials.pubkey_credentials.\
get_ed25519_sk_pem(password=password)
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"
PROTOCOLS = {
"openvpn-tcp": OpenVPNTCPConfig,
"openvpn-udp": OpenVPNUDPConfig,
}
class OpenVPN(LinuxNetworkManager, LocalAgentMixin):
"""Base class for the backends implementing the OpenVPN protocols."""
VIRTUAL_DEVICE_NAME = "proton0"
connection = None
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
LocalAgentMixin.__init__(self)
self._vpn_settings = None
self._connection_settings = None
def setup(self) -> Future:
"""Methods that creates and applies any necessary changes to the connection."""
passphrase = self._make_passphrase()
self._generate_connection(passphrase)
self._modify_connection(passphrase)
return self.nm_client.add_connection_async(self.connection)
def _make_passphrase(self):
return secrets.token_hex(PASSPHRASE_SECRET_LENGTH)
def _generate_connection(self, private_key_passphrase):
vpnconfig = PROTOCOLS[self.protocol](
private_key_passphrase,
self._vpnserver, self._vpncredentials, self._settings
)
self.connection = self._import_vpn_config(vpnconfig)
self._unique_id = self.connection.get_uuid()
self._vpn_settings = self.connection.get_setting_vpn()
self._connection_settings = self.connection.get_setting_connection()
def _modify_connection(self, private_key_passphrase):
"""Configure imported vpn connection.
"""
self._set_custom_connection_id()
self._set_connection_user_owned()
self._set_server_certificate_check()
self._set_dns()
self._set_vpn_cert_credentials(private_key_passphrase)
self.connection.add_setting(self._connection_settings)
async def update_credentials(self, credentials):
await super().update_credentials(credentials)
await self._start_local_agent_listener()
def _set_custom_connection_id(self):
self._connection_settings.set_property(NM.SETTING_CONNECTION_ID, self._get_servername())
def _set_connection_user_owned(self):
# returns NM.SettingConnection
# https://lazka.github.io/pgi-docs/NM-1.0/classes/SettingConnection.html#NM.SettingConnection
self._connection_settings.add_permission(
NM.SETTING_USER_SETTING_NAME,
getuser(),
None
)
def _set_server_certificate_check(self):
appened_domain = "name:" + self._vpnserver.domain
self._vpn_settings.add_data_item(
"verify-x509-name", appened_domain
)
def _set_dns(self):
"""Apply dns configurations to ProtonVPN connection."""
# pylint: disable=duplicate-code
ipv4_config = self.connection.get_setting_ip4_config()
ipv6_config = self.connection.get_setting_ip6_config()
self._configure_dns(nm_setting=ipv4_config, ip_version=IPv4Address)
if self.enable_ipv6_support:
self._configure_dns(nm_setting=ipv6_config, ip_version=IPv6Address)
self.connection.add_setting(ipv4_config)
self.connection.add_setting(ipv6_config)
def _configure_dns(
self,
nm_setting: Union[NM.SettingIP4Config, NM.SettingIP6Config],
ip_version: Union[IPv4Address, IPv6Address],
dns_priority: int = -1500,
):
"""Sets DNS."""
if ip_version not in [IPv4Address, IPv6Address]:
raise ValueError(f"Unknown IP version: {ip_version}")
nm_setting.set_property(NM.SETTING_IP_CONFIG_DNS_PRIORITY, dns_priority)
custom_dns_ips = self._settings.custom_dns\
.get_enabled_dns_list_based_on_ip_version(ip_version)
ip_addresses = [dns.exploded for dns in custom_dns_ips]
# OpenVPN sets DNS automatically if nothing is passed.
if self._settings.custom_dns.enabled and ip_addresses:
nm_setting.set_property(NM.SETTING_IP_CONFIG_DNS, ip_addresses)
nm_setting.set_property(NM.SETTING_IP_CONFIG_IGNORE_AUTO_DNS, True)
def _set_vpn_cert_credentials(self, private_key_passphrase: str):
"""
Add passphrase to decrypt the private key.
"""
self._vpn_settings.add_secret(SECRET_CERT_PASS_FIELD,
private_key_passphrase)
def _initialize_persisted_connection(self, connection_id: str) -> states.State:
state = super()._initialize_persisted_connection(connection_id)
if isinstance(state, states.Connected):
self._async_start_local_agent_listener()
return state
# pylint: disable=unused-argument
def _on_state_changed(
self, vpn_connection: NM.VpnConnection, state: int, reason: int
):
"""
When the vpn state changes, NM emits a signal with the state and
reason for the change. This callback will receive these updates
and translate for them accordingly for the state machine,
as the state machine is backend agnostic.
Note this method is called from the thread running the GLib main loop.
Interactions between code in this method and the asyncio loop must
be done in a thread-safe manner.
:param state: connection state update
:type state: int
:param reason: the reason for the state update
:type reason: int
"""
try:
state = NM.VpnConnectionState(state)
except ValueError:
logger.warning("Unexpected VPN connection state: %s", state)
state = NM.VpnConnectionState.UNKNOWN
try:
reason = NM.VpnConnectionStateReason(reason)
except ValueError:
logger.warning("Unexpected VPN connection state reason: %s", reason)
reason = NM.VpnConnectionStateReason.UNKNOWN
logger.debug(
"VPN connection state changed: state=%s, reason=%s",
state.value_name, reason.value_name
)
def start_local_agent():
self._async_start_local_agent_listener()
def stop_local_agent():
self._async_stop_local_agent_listener()
if state is NM.VpnConnectionState.ACTIVATED:
start_local_agent()
self._notify_subscribers_threadsafe(
events.Connected(EventContext(connection=self))
)
elif state is NM.VpnConnectionState.FAILED:
if reason in [
NM.VpnConnectionStateReason.CONNECT_TIMEOUT,
NM.VpnConnectionStateReason.SERVICE_START_TIMEOUT
]:
self._notify_subscribers_threadsafe(
events.Timeout(EventContext(connection=self, reason=reason))
)
elif reason in [
NM.VpnConnectionStateReason.NO_SECRETS,
NM.VpnConnectionStateReason.LOGIN_FAILED
]:
# NO_SECRETS is passed when the user cancels the NM popup
# to introduce the OpenVPN password. If we switch auth to
# certificates, we should treat NO_SECRETS as an
# UnexpectedDisconnection event.
self._notify_subscribers_threadsafe(
events.AuthDenied(EventContext(connection=self, reason=reason))
)
else:
# reason in [
# NM.VpnConnectionStateReason.UNKNOWN,
# NM.VpnConnectionStateReason.NONE,
# NM.VpnConnectionStateReason.USER_DISCONNECTED,
# NM.VpnConnectionStateReason.DEVICE_DISCONNECTED,
# NM.VpnConnectionStateReason.SERVICE_STOPPED,
# NM.VpnConnectionStateReason.IP_CONFIG_INVALID,
# NM.VpnConnectionStateReason.SERVICE_START_FAILED,
# NM.VpnConnectionStateReason.CONNECTION_REMOVED,
# ]
self._notify_subscribers_threadsafe(
events.UnexpectedError(EventContext(connection=self, reason=reason))
)
elif state == NM.VpnConnectionState.DISCONNECTED:
if reason in [NM.VpnConnectionStateReason.USER_DISCONNECTED]:
stop_local_agent()
self._notify_subscribers_threadsafe(
events.Disconnected(EventContext(connection=self, reason=reason))
)
elif reason is NM.VpnConnectionStateReason.DEVICE_DISCONNECTED:
stop_local_agent()
self._notify_subscribers_threadsafe(
events.DeviceDisconnected(EventContext(connection=self, reason=reason))
)
else:
# reason in [
# NM.VpnConnectionStateReason.UNKNOWN,
# NM.VpnConnectionStateReason.NONE,
# NM.VpnConnectionStateReason.SERVICE_STOPPED,
# NM.VpnConnectionStateReason.IP_CONFIG_INVALID,
# NM.VpnConnectionStateReason.CONNECT_TIMEOUT,
# NM.VpnConnectionStateReason.SERVICE_START_TIMEOUT,
# NM.VpnConnectionStateReason.SERVICE_START_FAILED,
# NM.VpnConnectionStateReason.NO_SECRETS,
# NM.VpnConnectionStateReason.LOGIN_FAILED,
# NM.VpnConnectionStateReason.CONNECTION_REMOVED,
# ]
self._notify_subscribers_threadsafe(
events.UnexpectedError(EventContext(connection=self, reason=reason))
)
else:
logger.debug("Ignoring VPN state change: %s", state.value_name)
async def update_settings(self, settings: Settings):
"""Update features on the active agent connection."""
await super().update_settings(settings)
if self._agent_listener.is_running: # noqa: E501 # pylint: disable=line-too-long # nosemgrep: python.lang.maintainability.is-function-without-parentheses.is-function-without-parentheses
await self._request_connection_features(settings.features)
class OpenVPNTCP(OpenVPN):
"""Creates a OpenVPNTCP connection."""
protocol = "openvpn-tcp"
ui_protocol = "OpenVPN (TCP)"
@classmethod
def _get_priority(cls):
return 1
@classmethod
def _validate(cls):
# FIX ME: This should do a validation to ensure that NM can be used
return True
class OpenVPNUDP(OpenVPN):
"""Creates a OpenVPNUDP connection."""
protocol = "openvpn-udp"
ui_protocol = "OpenVPN (UDP)"
@classmethod
def _get_priority(cls):
return 1
@classmethod
def _validate(cls):
# FIX ME: This should do a validation to ensure that NM can be used
return True
python-proton-vpn-api-core-4.16.0/proton/vpn/backend/networkmanager/protocol/wireguard/ 0000775 0000000 0000000 00000000000 15151554407 0031340 5 ustar 00root root 0000000 0000000 python-proton-vpn-api-core-4.16.0/proton/vpn/backend/networkmanager/protocol/wireguard/__init__.py 0000664 0000000 0000000 00000001462 15151554407 0033454 0 ustar 00root root 0000000 0000000 """
This module contains the backends implementing the supported OpenVPN protocols.
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 .wireguard import Wireguard
__all__ = ["Wireguard"]
python-proton-vpn-api-core-4.16.0/proton/vpn/backend/networkmanager/protocol/wireguard/wireguard.py 0000664 0000000 0000000 00000031064 15151554407 0033707 0 ustar 00root root 0000000 0000000 """
This module contains the backends implementing the supported OpenVPN protocols.
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 os
from random import randrange
from typing import Optional, Union
from ipaddress import IPv4Address, IPv6Address
import socket
import uuid
import logging
from getpass import getuser
from concurrent.futures import Future
from dataclasses import dataclass
import gi
gi.require_version("NM", "1.0") # noqa: required before importing NM module
# pylint: disable=wrong-import-position
from gi.repository import NM
from proton.vpn.connection import events, states
from proton.vpn.connection.events import EventContext
from proton.vpn.connection.interfaces import Settings
from proton.vpn.backend.networkmanager.core import (LinuxNetworkManager, LocalAgentMixin)
logger = logging.getLogger(__name__)
FWMARK_ENV_VAR = "PROTON_VPN_FWMARK"
MIN_FWMARK_VALUE = 51821
MAX_FWMARK_VALUE = 2**32 # 32-bit integer
@dataclass
class Config:
"""Contains all specific details for networking configuration."""
address: str
address_prefix: int
dns_ip: str
allowed_ip: str
dns_search: str = "~"
dns_priority: int = -1500
@dataclass
class WireGuardConfig:
"""Contains networking configurations for both IPv4/6."""
ipv4: Config
ipv6: Config
def get_dns_ip_for_protocol_version(self, ip_version: Union[IPv4Address, IPv6Address]):
"""Returns dns IP value based on IP version."""
if ip_version == IPv4Address:
return self.ipv4.dns_ip
return self.ipv6.dns_ip
def get_dns_search_for_protocol_version(self, ip_version: Union[IPv4Address, IPv6Address]):
"""Returns dns search value based on IP version."""
if ip_version == IPv4Address:
return self.ipv4.dns_search
return self.ipv6.dns_search
wg_config = WireGuardConfig(
ipv4=Config(
address="10.2.0.2",
address_prefix=32,
dns_ip="10.2.0.1",
allowed_ip="0.0.0.0/0",
),
ipv6=Config(
address="2a07:b944::2:2",
address_prefix=128,
dns_ip="2a07:b944::2:1",
allowed_ip="::/0",
)
)
def get_fwmark_from_env_var() -> Optional[int]:
"""
Returns the fwmark from the env var or None if not available or not valid.
"""
fwmark_str = os.getenv(FWMARK_ENV_VAR)
if not fwmark_str:
return None
try:
fwmark = int(fwmark_str)
if fwmark not in range(MIN_FWMARK_VALUE, MAX_FWMARK_VALUE):
raise ValueError("fwmark out of range")
return fwmark
except ValueError:
logger.error(
"The %s env var should contain an integer "
"higher or equal than %s and lower than %s",
FWMARK_ENV_VAR, MIN_FWMARK_VALUE, MAX_FWMARK_VALUE
)
return None
def get_random_fwmark() -> int:
"""Returns a random fwmark within the expected range."""
# nosemgrep: gitlab.bandit.B311
return randrange(MIN_FWMARK_VALUE, MAX_FWMARK_VALUE) # nosec B311
class Wireguard(LinuxNetworkManager, LocalAgentMixin):
"""Creates a Wireguard connection."""
SIGNAL_NAME: str = "state-changed"
VIRTUAL_DEVICE_NAME: str = "proton0"
protocol: str = "wireguard"
ui_protocol: str = "WireGuard"
connection: Optional[NM.SimpleConnection] = None
FWMARK: int = get_fwmark_from_env_var() or get_random_fwmark()
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
LocalAgentMixin.__init__(self)
self._connection_settings = None
def setup(self) -> Future:
"""Methods that creates and applies any necessary changes to the connection."""
self._generate_connection()
self._modify_connection()
return self.nm_client.add_connection_async(self.connection)
def _generate_connection(self):
self._unique_id = str(uuid.uuid4())
self._connection_settings = NM.SettingConnection.new()
self.connection = NM.SimpleConnection.new()
async def update_credentials(self, credentials):
"""Notifies the vpn server that the wireguard certificate needs a refresh."""
await super().update_credentials(credentials)
await self._start_local_agent_listener()
@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 True
async def update_settings(self, settings: Settings):
"""Update features on the active agent connection."""
await super().update_settings(settings)
if self._agent_listener.is_running: # noqa: E501 # pylint: disable=line-too-long # nosemgrep: python.lang.maintainability.is-function-without-parentheses.is-function-without-parentheses
await self._request_connection_features(settings.features)
def _modify_connection(self):
self._set_custom_connection_id()
self._set_uuid()
self._set_interface_name()
self._set_connection_type()
self._set_connection_user_owned()
self.connection.add_setting(self._connection_settings)
self._set_route()
self._set_dns()
self._set_wireguard_properties()
self.connection.verify()
def _set_custom_connection_id(self):
self._connection_settings.set_property(NM.SETTING_CONNECTION_ID, self._get_servername())
def _set_uuid(self):
self._connection_settings.set_property(NM.SETTING_CONNECTION_UUID, self._unique_id)
def _set_interface_name(self):
self._connection_settings.set_property(
NM.SETTING_CONNECTION_INTERFACE_NAME, self.VIRTUAL_DEVICE_NAME
)
def _set_connection_type(self):
self._connection_settings.set_property(
NM.SETTING_CONNECTION_TYPE, NM.SETTING_WIREGUARD_SETTING_NAME
)
def _set_connection_user_owned(self):
self._connection_settings.add_permission(
NM.SETTING_USER_SETTING_NAME,
getuser(),
None
)
def _set_route(self):
ipv4_config = NM.SettingIP4Config.new()
ipv6_config = NM.SettingIP6Config.new()
ipv4_config.set_property(NM.SETTING_IP_CONFIG_METHOD, NM.SETTING_IP4_CONFIG_METHOD_MANUAL)
ipv4_config.add_address(
NM.IPAddress.new(socket.AF_INET, wg_config.ipv4.address, wg_config.ipv4.address_prefix)
)
if self.enable_ipv6_support:
ipv6_config.set_property(
NM.SETTING_IP_CONFIG_METHOD, NM.SETTING_IP6_CONFIG_METHOD_MANUAL
)
ipv6_config.add_address(
NM.IPAddress.new(
socket.AF_INET6, wg_config.ipv6.address, wg_config.ipv6.address_prefix
)
)
else:
ipv6_config.set_property(
NM.SETTING_IP_CONFIG_METHOD, NM.SETTING_IP6_CONFIG_METHOD_DISABLED
)
self.connection.add_setting(ipv4_config)
self.connection.add_setting(ipv6_config)
def _set_dns(self):
ipv4_config = self.connection.get_setting_ip4_config()
ipv6_config = self.connection.get_setting_ip6_config()
self._configure_dns(nm_setting=ipv4_config, ip_version=IPv4Address)
if self.enable_ipv6_support:
self._configure_dns(nm_setting=ipv6_config, ip_version=IPv6Address)
self.connection.add_setting(ipv4_config)
self.connection.add_setting(ipv6_config)
def _configure_dns(
self,
nm_setting: Union[NM.SettingIP4Config, NM.SettingIP6Config],
ip_version: Union[IPv4Address, IPv6Address],
dns_priority: int = -1500,
):
"""Sets DNS."""
if ip_version not in [IPv4Address, IPv6Address]:
raise ValueError(f"Unknown IP version: {ip_version}")
nm_setting.set_property(NM.SETTING_IP_CONFIG_DNS_PRIORITY, dns_priority)
nm_setting.set_property(NM.SETTING_IP_CONFIG_IGNORE_AUTO_DNS, True)
# pylint: disable=duplicate-code
custom_dns_ips = self._settings.custom_dns\
.get_enabled_dns_list_based_on_ip_version(ip_version)
ip_addresses = [dns.exploded for dns in custom_dns_ips]
# If custom DNS is disabled or there are no IP addresses then
# we need to set anyway the DNS because WG does not handle it automatically
# like OpenVPN does.
if self._settings.custom_dns.enabled and ip_addresses:
nm_setting.set_property(NM.SETTING_IP_CONFIG_DNS, ip_addresses)
else:
nm_setting.add_dns(wg_config.get_dns_ip_for_protocol_version(ip_version))
nm_setting.add_dns_search(wg_config.get_dns_search_for_protocol_version(ip_version))
def _set_wireguard_properties(self):
peer = NM.WireGuardPeer.new()
wireguard_config = NM.SettingWireGuard.new()
peer.append_allowed_ip(wg_config.ipv4.allowed_ip, False)
peer.set_endpoint(
f"{self._vpnserver.server_ip}:{self._vpnserver.wireguard_ports.udp[0]}",
False
)
peer.set_public_key(self._vpnserver.x25519pk, False)
if self.enable_ipv6_support:
peer.append_allowed_ip(wg_config.ipv6.allowed_ip, False)
# Seal the NM.WireGuardPeer instance. Afterwards, it is a bug to call all functions that
# modify the instance (except ref/unref). A sealed instance cannot be unsealed again,
# but you can create an unsealed copy with NM.WireGuardPeer.new_clone().
# https://lazka.github.io/pgi-docs/index.html#NM-1.0/classes/WireGuardPeer.html#NM.WireGuardPeer.seal
peer.seal()
# Ensures that the configurations are valid
# https://lazka.github.io/pgi-docs/index.html#NM-1.0/classes/WireGuardPeer.html#NM.WireGuardPeer.is_valid
peer.is_valid(True, True)
wireguard_config.append_peer(peer)
wireguard_config.set_property(
NM.SETTING_WIREGUARD_PRIVATE_KEY,
self._vpncredentials.pubkey_credentials.wg_private_key
)
wireguard_config.set_property(
NM.SETTING_WIREGUARD_FWMARK, self.FWMARK
)
self.connection.add_setting(wireguard_config)
# pylint: disable=arguments-renamed
def _on_state_changed(
self, _: NM.ActiveConnection, state: int, reason: int
):
"""
When the connection state changes, NM emits a signal with the state and
reason for the change. This callback will receive these updates
and translate for them accordingly for the state machine,
as the state machine is backend agnostic.
:param state: connection state update
:type state: int
:param reason: the reason for the state update
:type reason: int
"""
state = NM.ActiveConnectionState(state)
reason = NM.ActiveConnectionStateReason(reason)
logger.debug(
"Wireguard connection state changed: state=%s, reason=%s",
state.value_name, reason.value_name
)
if state is NM.ActiveConnectionState.ACTIVATED:
self._async_start_local_agent_listener()
elif state == NM.ActiveConnectionState.DEACTIVATED:
self._async_stop_local_agent_listener()
self._notify_subscribers_threadsafe(
events.Disconnected(EventContext(connection=self))
)
else:
logger.debug("Ignoring VPN state change: %s", state.value_name)
def _initialize_persisted_connection(
self, connection_id: str
) -> states.State:
"""Implemented in wireguard so we can start local agent listener."""
state = super()._initialize_persisted_connection(connection_id)
if isinstance(state, states.Connected):
self._async_start_local_agent_listener()
return state
@classmethod
def _get_priority(cls):
return 1
@classmethod
def _validate(cls):
# FIX ME: This should do a validation to ensure that NM can be used
return True
python-proton-vpn-api-core-4.16.0/proton/vpn/connection/ 0000775 0000000 0000000 00000000000 15151554407 0023232 5 ustar 00root root 0000000 0000000 python-proton-vpn-api-core-4.16.0/proton/vpn/connection/__init__.py 0000664 0000000 0000000 00000002506 15151554407 0025346 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-4.16.0/proton/vpn/connection/constants.py 0000664 0000000 0000000 00000005305 15151554407 0025623 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-----
"""
python-proton-vpn-api-core-4.16.0/proton/vpn/connection/enum.py 0000664 0000000 0000000 00000002716 15151554407 0024556 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()
TWOFA_REQUIRED = auto()
class KillSwitchSetting(IntEnum):
"""Kill switch setting values."""
OFF = 0
ON = 1
PERMANENT = 2
python-proton-vpn-api-core-4.16.0/proton/vpn/connection/events.py 0000664 0000000 0000000 00000010611 15151554407 0025107 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
class TwoFARequired(Error):
"""Signals that 2 factor authentication is required."""
type = StateMachineEventEnum.TWOFA_REQUIRED
_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-4.16.0/proton/vpn/connection/exceptions.py 0000664 0000000 0000000 00000005654 15151554407 0025777 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.
"""
class HardJailedTwoFAError(VPNConnectionError):
"""
When a user is hard jailed due to 2FA issues, this exception is thrown.
"""
python-proton-vpn-api-core-4.16.0/proton/vpn/connection/interfaces.py 0000664 0000000 0000000 00000015325 15151554407 0025735 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-4.16.0/proton/vpn/connection/persistence.py 0000664 0000000 0000000 00000010053 15151554407 0026127 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-4.16.0/proton/vpn/connection/publisher.py 0000664 0000000 0000000 00000007114 15151554407 0025604 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-4.16.0/proton/vpn/connection/states.py 0000664 0000000 0000000 00000040050 15151554407 0025106 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
from proton.vpn.split_tunneling.exceptions import SplitTunnelingError
from proton.vpn.split_tunneling.interface import SplitTunneling
from proton.vpn.core.settings.split_tunneling import SplitTunneling as SplitTunnelingSetting
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.
split_tunneling: split tunneling implementation, if available.
split_tunneling_setting: split tunneling configuration.
"""
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
split_tunneling: ClassVar[Optional[SplitTunneling]] = None
split_tunneling_setting: ClassVar[SplitTunnelingSetting] = 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
# Indicates whether this state should be notified early (i.e. before running
# the tasks associated to the state) or not (i.e. after running the tasks
# associated to the state)
notify_early = False
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()
if self.context.split_tunneling:
# ST config is always cleared independently of if ST is disabled via settings or
# via feature flag to make sure existing config is always cleaned up.
try:
await self.context.split_tunneling.clear_config()
except SplitTunnelingError:
# We decided to treat split tunneling error as non-fatal, to prevent they
# impact the core VPN functionality.
logger.exception("Error clearing split tunneling configuration")
return None
class Connecting(State):
"""
Connecting is the state reached when a VPN connection is requested.
"""
type = ConnectionStateEnum.CONNECTING
# Connection state subscribers will be notified of the Connecting state before running
# the tasks associated to this state.
notify_early = True
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))
if isinstance(event, events.Connected):
return Connected(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()
if self.context.split_tunneling_setting.enabled:
try:
await self.context.split_tunneling.set_config(
self.context.split_tunneling_setting
.get_config()
)
except SplitTunnelingError:
# We decided to treat split tunneling error as non-fatal, to prevent they
# impact the core VPN functionality.
logger.exception("Error setting split tunnel configuration")
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
# Connection state subscribers will be notified of the Disconnecting state before running
# the tasks associated to this state.
notify_early = True
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-4.16.0/proton/vpn/connection/vpnconfiguration.py 0000664 0000000 0000000 00000006256 15151554407 0027210 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 proton.utils.environment import ExecutionEnvironment
class VPNConfiguration:
"""Base VPN configuration."""
PROTOCOL = None
EXTENSION = None
def __init__(self, vpnserver, vpncredentials, settings):
self._configfile = None
self._configfile_enter_level = None
self._vpnserver = vpnserver
self._vpncredentials = vpncredentials
self._settings = settings
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
python-proton-vpn-api-core-4.16.0/proton/vpn/connection/vpnconnection.py 0000664 0000000 0000000 00000032166 15151554407 0026477 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,
):
"""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.
"""
self._vpnserver = server
self._vpncredentials = credentials
self._settings = settings
self._connection_persistence = connection_persistence or ConnectionPersistence()
self._publisher = publisher or Publisher()
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): # pylint: disable=too-many-arguments
"""
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.
"""
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)
@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-4.16.0/proton/vpn/core/ 0000775 0000000 0000000 00000000000 15151554407 0022023 5 ustar 00root root 0000000 0000000 python-proton-vpn-api-core-4.16.0/proton/vpn/core/__init__.py 0000664 0000000 0000000 00000001554 15151554407 0024141 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-4.16.0/proton/vpn/core/api.py 0000664 0000000 0000000 00000024006 15151554407 0023150 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 threading import Event
from typing import Optional
from proton.vpn import logging
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
from proton.session.api import Fido2Assertion
from proton.vpn.session.u2f_interaction import UserInteraction
logger = logging.getLogger(__name__)
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()
)
self._split_tunneling_client = None
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._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(code)
if result.success and not session.loaded:
await session.fetch_session_data()
return result
@property
def is_fido2_lib_available(self) -> bool:
"""
Returns whether we support U2F/FIDO2 security keys for 2FA on this platform.
This is deprecated, use is_fido2_available instead.
"""
logger.warning("is_fido2_lib_available is deprecated, use is_fido2_available instead")
return bool(self._session_holder.session.fido2_lib_available)
@property
def supports_fido2(self) -> bool:
"""
Returns if
- We support U2F/FIDO2 security keys for 2FA on this platform.
- The user has fido2 keys registered.
This only returns True if both conditions are met and if the user
is currently authenticating a session that requires 2FA.
"""
lib_available = self.is_fido2_lib_available
supports_fido2 = self._session_holder.session.supports_fido2
return bool(lib_available and supports_fido2)
async def generate_2fa_fido2_assertion(
self,
user_interaction: Optional[UserInteraction] = None,
cancel_assertion: Optional[Event] = None
) -> Fido2Assertion:
"""
Generates a 2FA assertion using a U2F/FIDO2 security key.
:param user_interaction: object handling any required user interaction
while generating the assertion.
:param cancel_assertion: optional event that can be set to cancel the
fido 2 assertion process.
:returns: the generated FIDO 2 assertion.
"""
return await self._session_holder.session.generate_2fa_fido2_assertion(
user_interaction, cancel_assertion
)
async def submit_2fa_fido2(self, fido2_assertion: Fido2Assertion) -> LoginResult:
"""
Submits the 2-factor authentication using a U2F/FIDO2 security key.
:return: The login result.
"""
session = self._session_holder.session
result = await session.provide_2fa_fido2(fido2_assertion)
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
@property
def split_tunneling_available(self) -> bool:
"""Deprecated, use VPNConnector.is_split_tunneling_available."""
logger.warning("Deprecated: use VPNConnector.is_split_tunneling_available instead")
# nosemgrep: python.lang.maintainability.is-function-without-parentheses.is-function-without-parentheses # pylint: disable=line-too-long # noqa: E501
return self._vpn_connector.is_split_tunneling_available
python-proton-vpn-api-core-4.16.0/proton/vpn/core/cache_handler.py 0000664 0000000 0000000 00000004235 15151554407 0025141 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 it exists, but content is not valid json, None is returned instead.
"""
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-4.16.0/proton/vpn/core/cache_handlers/ 0000775 0000000 0000000 00000000000 15151554407 0024746 5 ustar 00root root 0000000 0000000 python-proton-vpn-api-core-4.16.0/proton/vpn/core/cache_handlers/__init__.py 0000664 0000000 0000000 00000001565 15151554407 0027066 0 ustar 00root root 0000000 0000000 """
This module contains the cache handlers used in 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 proton.vpn.core.cache_handlers.port_forward_file_handler import PortForwardFileHandler
__all__ = [
"PortForwardFileHandler",
]
python-proton-vpn-api-core-4.16.0/proton/vpn/core/cache_handlers/port_forward_file_handler.py 0000664 0000000 0000000 00000004246 15151554407 0032532 0 ustar 00root root 0000000 0000000 """
This module contains then logic related to storing forwarded port to file.
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 pathlib
from proton.vpn.connection import states
from proton.utils.environment import VPNExecutionEnvironment
DEFAULT_FORWARDED_PORT_FILEPATH = os.path.join(
VPNExecutionEnvironment().path_runtime, "forwarded_port"
)
class PortForwardFileHandler: # pylint: disable=too-few-public-methods
"""Takes care of file creating/deleting and writing port to file."""
def __init__(self, filepath: str = DEFAULT_FORWARDED_PORT_FILEPATH):
self._filepath = pathlib.Path(filepath)
def on_state_change_update_port(self, state: states.State):
"""Handles the new state.
If the state is `Connected`, it writes the forwarded port to the file.
If the state is not `Connected`, it removes the port from the file.
Args:
state (State): newly received state object.
"""
if isinstance(state, states.Connected) and state.forwarded_port:
self._write_port_to_file(state.forwarded_port)
elif isinstance(state, (states.Connected, states.Disconnected)):
self._remove_port_from_file()
def _write_port_to_file(self, port: int):
"""Writes port to file."""
with open(self._filepath, mode="w", encoding="utf8") as file:
file.write(str(port))
def _remove_port_from_file(self):
"""Removes the port from the file."""
with open(self._filepath, mode="w", encoding="utf8") as file:
file.write("")
python-proton-vpn-api-core-4.16.0/proton/vpn/core/connection.py 0000664 0000000 0000000 00000061376 15151554407 0024551 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
from copy import deepcopy
import os
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.core.settings.split_tunneling import SplitTunneling as SplitTunnelingSetting
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
from proton.vpn.split_tunneling.interface import SplitTunneling
from proton.vpn.core.cache_handlers import PortForwardFileHandler
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.
"""
split_tunneling = await SplitTunneling.get(os.getuid())
connector = VPNConnector(
session_holder,
settings_persistence,
kill_switch=kill_switch,
usage_reporting=usage_reporting,
split_tunneling=split_tunneling
)
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: Optional[ConnectionPersistence] = None,
state: Optional[states.State] = None,
kill_switch: Optional[KillSwitch] = None,
split_tunneling: Optional[SplitTunneling] = None,
publisher: Optional[Publisher] = None,
port_forward_file_handler: PortForwardFileHandler = 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._split_tunneling = split_tunneling
self._publisher = publisher or Publisher()
self._lock = asyncio.Lock()
self._background_tasks = set()
self._usage_reporting = usage_reporting
self._port_forward_file_handler = port_forward_file_handler or PortForwardFileHandler()
self._publisher.register(self._on_state_change_update_location)
self._publisher.register(self._port_forward_file_handler.on_state_change_update_port)
@property
def is_split_tunneling_available(self) -> bool:
"""Returns if split tunneling is available or not."""
return bool(self._split_tunneling)
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
)
return settings
@property
def credentials(self) -> Optional[VPNCredentials]:
"""Returns the user's credentials."""
return self._session_holder.vpn_credentials
def _set_ks_setting(self, ks_setting: KillSwitchSetting, protocol: str):
StateContext.kill_switch_setting = ks_setting
if isinstance(self.current_state, states.Disconnected):
self._set_ks_impl(protocol)
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.
"""
ks_setting = KillSwitchSetting(settings.killswitch)
protocol = settings.protocol
self._set_ks_setting(ks_setting, protocol)
await self._apply_kill_switch_setting(ks_setting)
if self.current_connection:
await self.current_connection.update_settings(settings)
st_setting = settings.features.split_tunneling
self._set_split_tunneling_setting(st_setting)
# nosemgrep: python.lang.maintainability.is-function-without-parentheses.is-function-without-parentheses # pylint: disable=line-too-long # noqa: E501
if self.is_split_tunneling_available and self.is_connected:
await self._apply_split_tunneling_settings(st_setting, ks_setting)
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 _apply_split_tunneling_settings(
self, st_settings: SplitTunnelingSetting, ks_setting: KillSwitchSetting
):
if ks_setting != KillSwitchSetting.OFF:
logger.warning("Split tunneling is not compatible with the kill switch feature")
return
if not st_settings.enabled:
await self._split_tunneling.clear_config()
else:
await self._split_tunneling.set_config(
st_settings.get_config()
)
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."""
# It's possible that the user is not logged in but that there is
# a persisted connection, in this case we need to ignore the persisted
# connection and return the disconnected state.
if self._session_holder.session.logged_in:
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.protocol)
self._set_split_tunneling_setting(settings.features.split_tunneling)
self._set_split_tunneling_impl()
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()
# FIXME: this adds a big delay before creating the connection # pylint: disable=fixme
self._set_ks_setting(KillSwitchSetting(settings.killswitch), settings.protocol)
self._set_split_tunneling_setting(settings.features.split_tunneling)
protocol = protocol or settings.protocol
connection = VPNConnection.create(
server, self.credentials, settings, protocol, backend
)
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
if isinstance(new_state.context.event, events.TwoFARequired) and \
isinstance(old_state.context.event, events.TwoFARequired):
return None
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)
if isinstance(old_state, states.Connected) and isinstance(new_state, states.Connected):
# A Connected state can transition to a new Connected state when local agent
# sends a new connected event, e.g. with a new port forwarding port. In this case,
# the connection subscribers are notified (to update data on the client UI)
# but the tasks associated with the connected state do not need to be run again
# since the connection state didn't really change, only its context data did.
self._publisher.notify(new_state)
return None
if self._current_state.notify_early:
self._publisher.notify(new_state)
new_event = await self._current_state.run_tasks()
else:
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()).protocol)
return new_event
def _on_state_change_update_location(self, state: states.State):
"""Updates the user location when the connection is established."""
connection_details = self._get_connection_details_from_state(state)
if not connection_details:
return
current_location = self._session_holder.session.vpn_account.location
self._session_holder.session.set_location(
self._create_new_vpn_location(connection_details, current_location)
)
def _get_connection_details_from_state(
self, state: states.State
) -> Optional[events.ConnectionDetails]:
if not isinstance(state, states.Connected):
return None
connection_details = state.context.event.context.connection_details
if not connection_details or not connection_details.device_ip:
return None
return connection_details
def _create_new_vpn_location(self, connection_details, current_location) -> VPNLocation:
return VPNLocation(
IP=connection_details.device_ip,
Country=connection_details.device_country,
ISP=current_location.ISP,
Long=current_location.Long,
Lat=current_location.Lat
)
def _set_ks_impl(self, protocol: str):
"""
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.
"""
kill_switch_backend = KillSwitch.get(protocol=protocol)
StateContext.kill_switch = self._kill_switch or kill_switch_backend()
def _set_split_tunneling_setting(self, st_setting: SplitTunnelingSetting):
st_setting = deepcopy(st_setting)
st_setting.enabled = (
self._split_tunneling
and st_setting.enabled
and not self._is_free_tier()
)
StateContext.split_tunneling_setting = st_setting
def _set_split_tunneling_impl(self):
StateContext.split_tunneling = self._split_tunneling
def _get_user_tier(self) -> int:
# Default to free tier if session is not loaded yet
return self._session_holder.user_tier or 0
def _is_free_tier(self) -> bool:
return self._get_user_tier() == 0
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-4.16.0/proton/vpn/core/exceptions.py 0000664 0000000 0000000 00000002356 15151554407 0024564 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 __future__ import annotations
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."""
class VPNDaemonError(Exception):
"""Base class for Proton API specific exceptions"""
def __init__(self, message, additional_context=None):
self.message = message
self.additional_context = additional_context
super().__init__(self.message)
python-proton-vpn-api-core-4.16.0/proton/vpn/core/refresher/ 0000775 0000000 0000000 00000000000 15151554407 0024010 5 ustar 00root root 0000000 0000000 python-proton-vpn-api-core-4.16.0/proton/vpn/core/refresher/__init__.py 0000664 0000000 0000000 00000001420 15151554407 0026116 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-4.16.0/proton/vpn/core/refresher/certificate_refresher.py 0000664 0000000 0000000 00000013445 15151554407 0030720 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,
ProtonAPIError
)
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 ProtonAPIError as error:
if error.http_code != 429:
raise
logger.warning(f"Certificate refresh failed {error}")
next_refresh_delay = self._get_next_refresh_delay()
self._number_of_failed_refresh_attempts += 1
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)
async def update_if_necessary(self):
"""Fetches a new certificate from the REST API
if the current certificate has expired or will soon expire"""
pubkey_credentials = self._session.vpn_account.vpn_credentials.pubkey_credentials
if pubkey_credentials.remaining_time_to_next_refresh > 0:
return # too early to update the certificate
try:
await self._session.fetch_certificate()
await self._notify()
except ProtonAPIError as error:
if error.http_code != 429:
raise
logger.warning(f"Certificate refresh failed {error}")
except (ProtonAPINotReachable, ProtonAPINotAvailable) as error:
logger.warning(f"Certificate refresh failed: {error}")
except Exception:
logger.error(
"Certificate refresh failed unexpectedly."
"Stopping certificate refresh."
)
raise
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-4.16.0/proton/vpn/core/refresher/client_config_refresher.py 0000664 0000000 0000000 00000007166 15151554407 0031244 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,
ProtonAPIError
)
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 update_if_necessary(self):
"""Fetches the new client config if the current one has expired"""
if not self._session.client_config.is_expired:
return # no need to update too early
try:
await self._session.fetch_client_config()
except ProtonAPIError as error:
if error.http_code != 429:
raise
logger.warning(f"Client config refresh failed: {error}")
except (ProtonAPINotReachable, ProtonAPINotAvailable) as error:
logger.warning(f"Client config refresh failed: {error}")
except Exception:
logger.error(
"Client config refresh failed unexpectedly. "
"Stopping client config refresh."
)
raise
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 ProtonAPIError as error:
if error.http_code != 429:
raise
logger.warning(f"Client config refresh failed: {error}")
next_refresh_delay = ClientConfig.get_refresh_interval_in_seconds()
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-4.16.0/proton/vpn/core/refresher/feature_flags_refresher.py 0000664 0000000 0000000 00000007040 15151554407 0031237 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,
ProtonAPIError
)
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 update_if_necessary(self):
"""Fetches the new feature flags if the current list has expired"""
if not self._session.feature_flags.is_expired:
return # no need to update too early
try:
await self._session.fetch_feature_flags()
except ProtonAPIError as error:
if error.http_code != 429:
raise
logger.warning(f"Feature flag refresh failed {error}")
except (ProtonAPINotReachable, ProtonAPINotAvailable) as error:
logger.warning(f"Feature flag refresh failed: {error}")
except Exception:
logger.error(
"Feature flag refresh failed unexpectedly."
"Stopping feature flag refresh."
)
raise
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 ProtonAPIError as error:
if error.http_code != 429:
raise
logger.warning(f"Feature flag refresh failed {error}")
next_refresh_delay = FeatureFlags.get_refresh_interval_in_seconds()
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-4.16.0/proton/vpn/core/refresher/scheduler.py 0000664 0000000 0000000 00000020477 15151554407 0026352 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-4.16.0/proton/vpn/core/refresher/server_list_refresher.py 0000664 0000000 0000000 00000010064 15151554407 0030771 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,
ProtonAPIError
)
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 update_if_necessary(self) -> float:
"""Refreshes the server list/loads if expired, and returns seconds till next expiration"""
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 ProtonAPIError as error:
if error.http_code != 429:
raise
logger.warning(f"Server list refresh failed: {error}")
next_refresh_delay = ServerList.get_loads_refresh_interval_in_seconds()
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
return next_refresh_delay
async def refresh(self) -> RunAgain:
"""Refreshes the server list/loads if expired, else schedules a future refresh."""
next_refresh_delay = await self.update_if_necessary()
# 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-4.16.0/proton/vpn/core/refresher/vpn_data_refresher.py 0000664 0000000 0000000 00000024316 15151554407 0030231 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
async def get_up_to_date_server_list(self) -> ServerList:
"""
Returns the list of available VPN servers, after updating if expired.
"""
await self._refresh_vpn_session_if_necessary()
if not self._scheduler.is_started:
# only update if scheduler isn't tasked with doing so
await self._server_list_refresher.update_if_necessary()
return self._session.server_list
async def get_up_to_date_client_config(self) -> ClientConfig:
"""
Returns the VPN client configuration, after updating if expired.
"""
await self._refresh_vpn_session_if_necessary()
if not self._scheduler.is_started:
# only update if scheduler isn't tasked with doing so
await self._client_config_refresher.update_if_necessary()
return self._session.client_config
async def get_up_to_date_feature_flags(self) -> FeatureFlags:
"""
Returns the feature flags list, after updating if expired.
"""
await self._refresh_vpn_session_if_necessary()
if not self._scheduler.is_started:
# only update if scheduler isn't tasked with doing so
await self._feature_flags_refresher.update_if_necessary()
return self._session.feature_flags
async def update_certificate_if_necessary(self):
"""
Updates the API certificate if it has expired, or will soon do so.
"""
await self._refresh_vpn_session_if_necessary()
if not self._scheduler.is_started:
# only update if scheduler isn't tasked with doing so
await self._certificate_refresher.update_if_necessary()
@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."""
await self._refresh_vpn_session_if_necessary()
self._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_if_necessary(self):
if not self._session.loaded:
# 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.
logger.warning("Reloading VPN session...")
await self._session.fetch_session_data()
python-proton-vpn-api-core-4.16.0/proton/vpn/core/session_holder.py 0000664 0000000 0000000 00000011537 15151554407 0025424 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
from pathlib import Path
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, get_core_api_semver_version
logger = logging.getLogger(__name__)
CPU_ARCHITECTURE = to_semver_build_metadata_format(platform.machine())
DISTRIBUTION_ID = distro.id()
DISTRIBUTION_VERSION = distro.version()
def _is_beta_repo_installed() -> bool:
if distro.id() == "debian" or distro.like() == "debian":
return Path("/etc/apt/sources.list.d/protonvpn-beta.sources").is_file()
if distro.id() == "fedora" or distro.like() == "fedora":
return Path("/etc/yum.repos.d/protonvpn-beta.repo").is_file()
return False
BETA_REPO_INSTALLED = _is_beta_repo_installed()
@dataclass
class ClientTypeMetadata: # pylint: disable=missing-class-docstring
type: str
version: str = get_core_api_semver_version()
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
@classmethod
def _get_app_version_header_value(cls, client_type_metadata: ClientTypeMetadata) -> str:
app_version = f"linux-vpn-{client_type_metadata.type}@{client_type_metadata.version}"
version_metadata = cls._get_version_metadata()
if version_metadata:
app_version += f"+{version_metadata}"
return app_version
@staticmethod
def _get_version_metadata():
"""
The following version metadata is sent to the REST API for the following reasons:
1. We want to have an idea on the amount of total users running on each CPU architecture,
to know which archs we should target when compiling components written in rust.
2. We want to know if users have our early release linux repositories installed, so that
2.a We have an idea of the amount of early release users.
2.b We can enable feature flags only for early release users (i.e. users with our
official beta release package installed).
The reason why we do this unconventional use of the semver build metadata section is
that currently our infra doesn't allow passing/parsing this information through another
header that's not x-pm-appversion.
"""
metas = []
if CPU_ARCHITECTURE:
metas.append(CPU_ARCHITECTURE)
if BETA_REPO_INSTALLED:
metas.append("beta")
return ".".join(metas)
python-proton-vpn-api-core-4.16.0/proton/vpn/core/settings/ 0000775 0000000 0000000 00000000000 15151554407 0023663 5 ustar 00root root 0000000 0000000 python-proton-vpn-api-core-4.16.0/proton/vpn/core/settings/__init__.py 0000664 0000000 0000000 00000002254 15151554407 0025777 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 proton.vpn.core.settings.settings import Settings, SettingsPersistence
from proton.vpn.core.settings.split_tunneling import SplitTunneling, \
SplitTunnelingConfig, SplitTunnelingMode
from proton.vpn.core.settings.features import NetShield
from proton.vpn.core.settings.custom_dns import CustomDNSEntry
__all__ = [
"Settings", "SettingsPersistence", "NetShield",
"CustomDNSEntry", "SplitTunneling", "SplitTunnelingConfig", "SplitTunnelingMode"
]
python-proton-vpn-api-core-4.16.0/proton/vpn/core/settings/custom_dns.py 0000664 0000000 0000000 00000011511 15151554407 0026412 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 dataclasses import dataclass, field
from typing import Union
from ipaddress import ip_address, IPv4Address, IPv6Address
from proton.vpn import logging
logger = logging.getLogger(__name__)
@dataclass
class CustomDNSEntry:
"""Custom DNS IP object."""
ip: Union[IPv4Address, IPv6Address] # pylint: disable=invalid-name
enabled: bool = True
@staticmethod
def from_dict(data: dict[str, Union[bool, str]]) -> 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=bool(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[str, Union[str, bool]]:
"""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) # type: ignore
@staticmethod
def from_dict(data: dict[str, Union[bool, str, list]]) -> CustomDNS:
"""Creates and returns `CustomDNS` from the provided dict."""
default = CustomDNS.default()
loaded_ip_list: dict[str, Union[bool, str]] = 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_enabled_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_enabled_dns_list_based_on_ip_version(IPv6Address)
def get_enabled_dns_list_based_on_ip_version(
self, version: Union[IPv4Address, IPv6Address]
) -> list[Union[str, None]]:
"""Returns a list of IPs based on provided IP version."""
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[str, Union[bool, list[dict[str, Union[str, bool]]]]]:
"""Converts the class to dict."""
return {
"enabled": self.enabled,
"ip_list": [ip.to_dict() for ip in self.ip_list]
}
python-proton-vpn-api-core-4.16.0/proton/vpn/core/settings/features.py 0000664 0000000 0000000 00000006453 15151554407 0026063 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 dataclasses import dataclass
from enum import IntEnum
from proton.vpn.core.settings.split_tunneling import SplitTunneling
from proton.vpn import logging
logger = logging.getLogger(__name__)
class NetShield(IntEnum): # pylint: disable=missing-class-docstring
NO_BLOCK = 0
BLOCK_MALICIOUS_URL = 1
BLOCK_ADS_AND_TRACKING = 2
@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
split_tunneling: SplitTunneling
def are_free_tier_defaults(self):
"""Returns True if the features are the ones set for the free tier."""
return self == Features.default(user_tier=0)
@staticmethod
def from_dict(data: dict, user_tier: int) -> Features:
"""Creates and returns `Features` from the provided dict."""
default = Features.default(user_tier)
split_tunneling = data.get("split_tunneling")
split_tunneling = SplitTunneling.from_dict(split_tunneling) \
if split_tunneling else SplitTunneling()
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),
split_tunneling=split_tunneling
)
def to_dict(self) -> dict:
"""Converts the class to dict."""
return {
"netshield": self.netshield,
"moderate_nat": self.moderate_nat,
"vpn_accelerator": self.vpn_accelerator,
"port_forwarding": self.port_forwarding,
"split_tunneling": self.split_tunneling.to_dict(),
}
@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,
split_tunneling=SplitTunneling()
)
def is_default(self, user_tier: int) -> bool:
"""Returns true if the features are the default ones."""
return self == Features.default(user_tier)
python-proton-vpn-api-core-4.16.0/proton/vpn/core/settings/settings.py 0000664 0000000 0000000 00000010760 15151554407 0026101 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 dataclasses import dataclass
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.core.settings.custom_dns import CustomDNS
from proton.vpn.core.settings.features import Features
logger = logging.getLogger(__name__)
SETTINGS = os.path.join(
VPNExecutionEnvironment().path_config,
"settings.json"
)
DEFAULT_PROTOCOL = "wireguard"
DEFAULT_KILLSWITCH = KillSwitchState.OFF.value
DEFAULT_ANONYMOUS_CRASH_REPORTS = True
@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) -> Settings:
"""Load the user settings, either the ones stored on disk or getting
default based on tier"""
if self._settings is not None:
return self._settings
raw_settings = self._cache_handler.load()
if raw_settings is None:
self._settings = Settings.default(user_tier)
else:
self._settings = Settings.from_dict(raw_settings, user_tier)
self._settings_are_default = False
return self._settings
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-4.16.0/proton/vpn/core/settings/split_tunneling.py 0000664 0000000 0000000 00000012045 15151554407 0027455 0 ustar 00root root 0000000 0000000 """All the assets the app uses are available in this module.
Copyright (c) 2025 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, Dict
from enum import Enum
from dataclasses import dataclass, field
class SplitTunnelingMode(Enum):
"""Enum for split tunneling mode.
"""
EXCLUDE = "exclude"
INCLUDE = "include"
@dataclass
class SplitTunnelingConfig:
"""Contains split tunneling data.
"""
mode: SplitTunnelingMode = SplitTunnelingMode.EXCLUDE
app_paths: List[str] = field(default_factory=list)
ip_ranges: List[str] = field(default_factory=list)
@staticmethod
def from_dict(data: dict):
"""Generates `SplitTunnelingConfig` from regular python dict.
Args:
data (dict): the dict containing the necessary information
Returns:
SplitTunnelingConfig: new `SplitTunnelingConfig`
"""
if not data:
return SplitTunnelingConfig()
return SplitTunnelingConfig(
mode=SplitTunnelingMode(data.get("mode")),
app_paths=data.get("app_paths", []),
ip_ranges=data.get("ip_ranges", [])
)
def to_dict(self) -> dict:
"""Converts actual object to dict.
Returns:
dict: current object in dict
"""
return {
"mode": self.mode.value,
"app_paths": self.app_paths,
"ip_ranges": self.ip_ranges
}
@dataclass
class SplitTunneling:
"""Config that is used for split tunneling
"""
enabled: bool = False
mode: SplitTunnelingMode = SplitTunnelingMode.EXCLUDE
config_by_mode: dict[SplitTunnelingMode, SplitTunnelingConfig] = field(
default_factory=lambda: { # nosemgrep: python.lang.maintainability.return.return-not-in-function # pylint: disable=line-too-long # noqa: E501
SplitTunnelingMode.EXCLUDE.value: SplitTunnelingConfig(mode=SplitTunnelingMode.EXCLUDE),
SplitTunnelingMode.INCLUDE.value: SplitTunnelingConfig(mode=SplitTunnelingMode.INCLUDE)
}
)
@property
def exclude(self) -> SplitTunnelingConfig:
"""Returns the split tunneling config for the exclude mode."""
return self.config_by_mode.get(
SplitTunnelingMode.EXCLUDE.value,
SplitTunnelingConfig(mode=SplitTunnelingMode.EXCLUDE)
)
@property
def include(self) -> SplitTunnelingConfig:
"""Returns the split tunneling config for the include mode."""
return self.config_by_mode.get(
SplitTunnelingMode.INCLUDE.value,
SplitTunnelingConfig(mode=SplitTunnelingMode.INCLUDE)
)
@staticmethod
def from_dict(data: dict) -> SplitTunneling:
"""Generates `SplitTunneling` from regular python dict.
Args:
data (dict): the dict containing the necessary information
Returns:
SplitTunneling: new `SplitTunneling`
"""
if not data:
return SplitTunneling()
raw_data = data.get("config_by_mode")
if not raw_data:
config_by_mode = {
SplitTunnelingMode.EXCLUDE.value:
SplitTunnelingConfig(mode=SplitTunnelingMode.EXCLUDE),
SplitTunnelingMode.INCLUDE.value:
SplitTunnelingConfig(mode=SplitTunnelingMode.INCLUDE)
}
else:
config_by_mode = {
SplitTunnelingMode(k).value: SplitTunnelingConfig.from_dict(v)
for k, v in raw_data.items()
}
mode = SplitTunnelingMode(data.get("mode", SplitTunnelingMode.EXCLUDE.value))
return SplitTunneling(
enabled=data.get("enabled", False),
mode=mode,
config_by_mode=config_by_mode
)
def get_config(self) -> SplitTunnelingConfig:
"""Returns the split tunneling config for the currently selected mode.
Returns:
SplitTunnelingConfig: the split tunneling object
"""
return self.config_by_mode[self.mode.value]
def to_dict(self) -> Dict[str, object]:
"""Converts actual object to dict.
Returns:
dict: current object in dict
"""
config_by_mode: dict[str, dict[str, str]] = \
{k: v.to_dict() for k, v in self.config_by_mode.items()}
return {
"enabled": self.enabled,
"mode": self.mode.value,
"config_by_mode": config_by_mode
}
python-proton-vpn-api-core-4.16.0/proton/vpn/core/usage.py 0000664 0000000 0000000 00000021270 15151554407 0023503 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
import re
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"
# This is how we anonymise the data sent to us in sentry messages.
#
# The regular expressions will be matched against lower and upper case matches
# so the regular expression doesn't needs to include casing.
#
# {user} is a special key that will be replaced with the username of the
# current user. It makes for a more accurant regex match.
PRIVACY_REPLACEMENTS = [
# Username in home directory
(r'\/home\/{user}\/', r"/home//"),
# Email addresses
(r'[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{{2,}}', r""),
# GPG ID addresses
(r'gpg: encrypted with.*', r""),
]
log = logging.getLogger(__name__)
def scrub_private_data(data, keys):
"""
Recursively scrub private data from any values in the event.
:param data: The value that needs to be scrubbed.
:param keys: A dictionary of values to substitute before evaluating the
regex replacement.
:returns: The contents of data with any discovered personal information
replaced by .
"""
if isinstance(data, (tuple, list)):
for index, value in enumerate(data):
data[index] = scrub_private_data(value, keys)
elif isinstance(data, dict):
for key, value in data.items():
data[key] = scrub_private_data(value, keys)
elif isinstance(data, str):
for expression, replacement in PRIVACY_REPLACEMENTS:
pattern = expression.format(**keys)
data = re.sub(pattern, replacement, data, flags=re.IGNORECASE)
return data
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.
: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.
"""
return scrub_private_data(
event,
{
"user": user_name
}
)
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-4.16.0/proton/vpn/killswitch/ 0000775 0000000 0000000 00000000000 15151554407 0023250 5 ustar 00root root 0000000 0000000 python-proton-vpn-api-core-4.16.0/proton/vpn/killswitch/interface/ 0000775 0000000 0000000 00000000000 15151554407 0025210 5 ustar 00root root 0000000 0000000 python-proton-vpn-api-core-4.16.0/proton/vpn/killswitch/interface/__init__.py 0000664 0000000 0000000 00000001556 15151554407 0027330 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-4.16.0/proton/vpn/killswitch/interface/exceptions.py 0000664 0000000 0000000 00000002741 15151554407 0027747 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-4.16.0/proton/vpn/killswitch/interface/killswitch.py 0000664 0000000 0000000 00000005547 15151554407 0027752 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-4.16.0/proton/vpn/logging/ 0000775 0000000 0000000 00000000000 15151554407 0022521 5 ustar 00root root 0000000 0000000 python-proton-vpn-api-core-4.16.0/proton/vpn/logging/__init__.py 0000664 0000000 0000000 00000012732 15151554407 0024637 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-4.16.0/proton/vpn/session/ 0000775 0000000 0000000 00000000000 15151554407 0022556 5 ustar 00root root 0000000 0000000 python-proton-vpn-api-core-4.16.0/proton/vpn/session/__init__.py 0000664 0000000 0000000 00000002211 15151554407 0024663 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-4.16.0/proton/vpn/session/account.py 0000664 0000000 0000000 00000011534 15151554407 0024570 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-4.16.0/proton/vpn/session/certificates.py 0000664 0000000 0000000 00000031632 15151554407 0025602 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-4.16.0/proton/vpn/session/client_config.py 0000664 0000000 0000000 00000016644 15151554407 0025746 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
from proton.vpn import logging
logger = logging.getLogger(__name__)
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, or it's invalid then the default client configuration is returned.
"""
cache = self._cache_file.load()
if cache:
try:
self._client_config = ClientConfig.from_dict(cache)
except ClientConfigDecodeError:
logger.warning(
"Client config could not be deserialized. Using defaults.",
exc_info=True
)
self._client_config = ClientConfig.default()
else:
self._client_config = ClientConfig.default()
return self._client_config
python-proton-vpn-api-core-4.16.0/proton/vpn/session/credentials.py 0000664 0000000 0000000 00000017531 15151554407 0025434 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 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`.
:return: :class:`api_data.VPNCertificate.Certificate`
"""
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-4.16.0/proton/vpn/session/dataclasses/ 0000775 0000000 0000000 00000000000 15151554407 0025045 5 ustar 00root root 0000000 0000000 python-proton-vpn-api-core-4.16.0/proton/vpn/session/dataclasses/__init__.py 0000664 0000000 0000000 00000002604 15151554407 0027160 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-4.16.0/proton/vpn/session/dataclasses/bug_report.py 0000664 0000000 0000000 00000002523 15151554407 0027571 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-4.16.0/proton/vpn/session/dataclasses/certificate.py 0000664 0000000 0000000 00000004362 15151554407 0027706 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-4.16.0/proton/vpn/session/dataclasses/client_config/ 0000775 0000000 0000000 00000000000 15151554407 0027650 5 ustar 00root root 0000000 0000000 python-proton-vpn-api-core-4.16.0/proton/vpn/session/dataclasses/client_config/__init__.py 0000664 0000000 0000000 00000001431 15151554407 0031760 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-4.16.0/proton/vpn/session/dataclasses/client_config/protocol_ports.py 0000664 0000000 0000000 00000002340 15151554407 0033311 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-4.16.0/proton/vpn/session/dataclasses/credentials.py 0000664 0000000 0000000 00000002535 15151554407 0027721 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-4.16.0/proton/vpn/session/dataclasses/location.py 0000664 0000000 0000000 00000002730 15151554407 0027231 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 Optional
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
Long: Optional[float]
Lat: Optional[float]
@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"],
Long=dict_data.get("Long"),
Lat=dict_data.get("Lat"),
)
python-proton-vpn-api-core-4.16.0/proton/vpn/session/dataclasses/login_result.py 0000664 0000000 0000000 00000001526 15151554407 0030131 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-4.16.0/proton/vpn/session/dataclasses/servers/ 0000775 0000000 0000000 00000000000 15151554407 0026536 5 ustar 00root root 0000000 0000000 python-proton-vpn-api-core-4.16.0/proton/vpn/session/dataclasses/servers/__init__.py 0000664 0000000 0000000 00000001460 15151554407 0030650 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,
SecureCoreGroup
)
__all__ = ["Country", "SecureCoreGroup"]
python-proton-vpn-api-core-4.16.0/proton/vpn/session/dataclasses/servers/country.py 0000664 0000000 0000000 00000022603 15151554407 0030616 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 Iterable, List, Optional, Protocol, TYPE_CHECKING
from dataclasses import dataclass
import itertools
from proton.vpn.session.servers.country_codes import get_country_name_by_code
from proton.vpn.session.servers.types import ServerFeatureEnum
if TYPE_CHECKING:
from proton.vpn.session.servers.logicals import LogicalServer
class ServerGroup(Protocol): # pylint: disable=too-few-public-methods
"""A group of servers."""
name: str
servers: list[LogicalServer]
free: bool # Whether the group has servers available to the free tier or not
features: set[ServerFeatureEnum]
under_maintenance: bool
smart_routing: bool
free_servers: list[LogicalServer]
paid_servers: list[LogicalServer]
SECURE_CORE_GROUP_NAME = "Via Secure Core"
class SecureCoreGroup(ServerGroup):
"""A group of secure core servers. Secure Core is paid only."""
def __init__(self, servers: list[LogicalServer]):
"""
:param servers: List of logical servers in this secure core group.
"""
self._servers = list(servers)
self._analysis = ServerAnalysis.analyze_servers(self._servers)
@property
def name(self) -> str:
"""Returns the secure core group name."""
return SECURE_CORE_GROUP_NAME
@property
def servers(self) -> List[LogicalServer]:
"""Returns the list of logical servers in this secure core group."""
return self._servers
@property
def free(self) -> bool:
"""Returns whether the group has servers available to the free tier or not."""
return self._analysis.free
@property
def features(self) -> set[ServerFeatureEnum]:
"""Returns the set of features available in this group."""
return {ServerFeatureEnum.SECURE_CORE}
@property
def under_maintenance(self) -> bool:
"""Returns whether all servers in this group are under maintenance."""
return self._analysis.under_maintenance
@property
def smart_routing(self) -> bool:
"""Returns whether all servers in this group use smart routing."""
return self._analysis.smart_routing
@property
def free_servers(self) -> Iterable[LogicalServer]:
"""Returns the free servers only."""
return filter(lambda server: server.free, self._servers)
@property
def paid_servers(self) -> Iterable[LogicalServer]:
"""Returns the paid servers only."""
return filter(lambda server: not server.free, self._servers)
class City(ServerGroup):
"""A city that belongs to a country."""
def __init__(self, name: str, servers: list[LogicalServer]):
self._name = name
self._servers = servers
self._analysis = ServerAnalysis.analyze_servers(servers)
@property
def name(self) -> str:
"""Returns the city name."""
return self._name
@property
def servers(self) -> list[LogicalServer]:
"""Returns the list of logical servers in this city."""
return self._servers
@property
def free(self) -> bool:
"""Returns whether the city has servers available to the free tier or not."""
return self._analysis.free
@property
def features(self) -> set[ServerFeatureEnum]:
"""Returns the set of features available in this city."""
return self._analysis.features
@property
def under_maintenance(self) -> bool:
"""Returns whether all servers in this city are under maintenance."""
return self._analysis.under_maintenance
@property
def smart_routing(self) -> bool:
"""Returns whether all servers in this city use smart routing."""
return self._analysis.smart_routing
@property
def free_servers(self) -> Iterable[LogicalServer]:
"""Returns the free servers only."""
return filter(lambda server: server.free, self._servers)
@property
def paid_servers(self) -> Iterable[LogicalServer]:
"""Returns the paid servers only."""
return filter(lambda server: not server.free, self._servers)
class Country(ServerGroup): # pylint: disable=too-many-instance-attributes
"""Group of servers belonging to a country."""
def __init__(self, code: str, servers: List[LogicalServer]):
"""
:param code: The country code (ISO 3166-1 alpha-2).
:param servers: List of logical servers in this country sorted by city name.
"""
self._code = code
self._servers = servers
self._analysis = ServerAnalysis.analyze_servers(servers)
self._cities = self._build_cities(servers)
self._secure_core_group = self._build_secure_core_group(servers)
@property
def code(self) -> str:
"""Returns the country code (ISO 3166-1 alpha-2)."""
return self._code
@property
def name(self):
"""Returns the full country name."""
return get_country_name_by_code(self.code)
@property
def servers(self) -> List[LogicalServer]:
"""Returns the list of logical servers in this country."""
return self._servers
@property
def cities(self) -> list[City]:
"""Returns the list of cities available in the country."""
return self._cities
@property
def free(self) -> bool:
"""Returns whether the country has servers available to the free tier or not."""
return self._analysis.free
@property
def features(self) -> set[ServerFeatureEnum]:
"""Returns the set of features available in this country."""
return self._analysis.features
@property
def under_maintenance(self) -> bool:
"""Returns whether all servers in this country are under maintenance."""
return self._analysis.under_maintenance
@property
def smart_routing(self) -> bool:
"""Returns whether all servers in this country use smart routing."""
return self._analysis.smart_routing
@property
def free_cities(self) -> Iterable[City]:
"""Returns the free cities only."""
return filter(lambda city: city.free, self.cities)
@property
def paid_cities(self) -> Iterable[City]:
"""Returns the paid cities only."""
return filter(lambda city: not city.free, self.cities)
@property
def secure_core_group(self) -> Optional[SecureCoreGroup]:
"""Returns the secure core servers group."""
return self._secure_core_group
def _build_cities(self, servers: List[LogicalServer]) -> list[City]:
"""Returns the list of cities available in the country."""
non_secure_core_servers = filter(
lambda servers: ServerFeatureEnum.SECURE_CORE not in servers.features, servers
)
return [
City(city_name, list(city_servers))
for city_name, city_servers in itertools.groupby(
non_secure_core_servers,
key=lambda server: server.city
)
]
def _build_secure_core_group(self, servers: List[LogicalServer]) -> SecureCoreGroup:
secure_core_servers = list(filter(
lambda server: ServerFeatureEnum.SECURE_CORE in server.features, self.servers
))
if not secure_core_servers:
return None
return SecureCoreGroup(secure_core_servers)
@dataclass
class ServerAnalysis:
"""Contains a summary of the state all the servers in a given location."""
smart_routing: bool
under_maintenance: bool
free: bool
features: set[ServerFeatureEnum]
@staticmethod
def analyze_servers(ordered_servers: List[LogicalServer]) -> ServerAnalysis:
"""
Iterates over the ordered list of servers and extracts information
to be displayed on the server list row.
"""
free_location = None
features = set()
# The country is set under maintenance until the opposite is proven.
under_maintenance = True
# Smart routing is assumed to be used until the opposite is proven.
smart_routing_location = True
for server in ordered_servers:
features.update(server.features)
free_location = free_location or server.tier == 0
# The country is under maintenance if (1) that was the case up until now and
# (2) the current server is also under maintenance (i.e. is not enabled).
under_maintenance = (under_maintenance and not server.enabled)
# A country is flagged as a "Smart routing" location if *all* servers are
# actually physically located in a neighboring country.
smart_routing_location = \
(smart_routing_location and server.smart_routing)
return ServerAnalysis(
smart_routing_location,
under_maintenance,
free_location,
features
)
python-proton-vpn-api-core-4.16.0/proton/vpn/session/dataclasses/sessions.py 0000664 0000000 0000000 00000003177 15151554407 0027275 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-4.16.0/proton/vpn/session/dataclasses/settings.py 0000664 0000000 0000000 00000005526 15151554407 0027267 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-4.16.0/proton/vpn/session/exceptions.py 0000664 0000000 0000000 00000005314 15151554407 0025314 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."""
class SecurityKeyError(Exception):
"""Base exception for security key errors."""
class Fido2NotSupportedError(SecurityKeyError):
"""Raised when FIDO2 authentication is not available on the current session."""
class SecurityKeyNotFoundError(SecurityKeyError):
"""Raised when no security key is found."""
class InvalidSecurityKeyError(SecurityKeyError):
"""Raised when the security key is invalid or cannot be used."""
class SecurityKeyTimeoutError(SecurityKeyError):
"""Raised when the security key operation times out."""
class SecurityKeyPINNotSetError(SecurityKeyError):
"""
Raised when the FIDO 2 server requires a PIN to be set
on the security key, but the user didn't set it.
"""
class SecurityKeyPINInvalidError(SecurityKeyError):
"""
Raised when the security key has a PIN set but the one the
user provided is not correct.
"""
python-proton-vpn-api-core-4.16.0/proton/vpn/session/feature_flags_fetcher.py 0000664 0000000 0000000 00000012207 15151554407 0027441 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
import os
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
FF_ENV_VAR = "PROTON_VPN_FEATURE_FLAG_{flag}"
DEFAULT = {
"toggles": [
{
"name": "BinaryServerStatus",
"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.
"""
# Allow environment variable override for feature flags
# this makes it easy to enable a feature flag for testing purposes
if self._search_for_env_var(feature_flag_name):
return True
return self._search_for_feature_flag(feature_flag_name)
def _search_for_env_var(self, feature_name: str) -> bool:
env_var = FF_ENV_VAR.format(flag=feature_name)
return env_var in os.environ
def _search_for_feature_flag(self, feature_name: str) -> bool:
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-4.16.0/proton/vpn/session/fetcher.py 0000664 0000000 0000000 00000014764 15151554407 0024564 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.server_list_fetcher import ServerListFetcher
from proton.vpn.session.servers.server_list_fetcher import EndpointVersion
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.features 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, endpoint_version: EndpointVersion) -> ServerList:
"""Fetches the list of VPN servers."""
return await self._server_list_fetcher.fetch(endpoint_version)
async def update_server_loads(
self, endpoint_version: EndpointVersion) -> ServerList:
"""Fetches new server loads and updates the current server list with them."""
return await self._server_list_fetcher.update_loads(endpoint_version)
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-4.16.0/proton/vpn/session/fido2_1.py 0000664 0000000 0000000 00000004267 15151554407 0024364 0 ustar 00root root 0000000 0000000 """
FIDO2 1.x API implementation for ProtonVPN session handling.
Copyright (c) 2025 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 .
"""
# pylint: disable=no-name-in-module
from fido2.hid import CtapHidDevice
from fido2.client import (
Fido2Client, PublicKeyCredentialRequestOptions as Options,
UserInteraction as Fido2UserInteraction,
AssertionSelection
)
from proton.session.api import Fido2AssertionParameters, Fido2Assertion
def create_client(device: CtapHidDevice,
origin: str,
user_interaction: Fido2UserInteraction) -> Fido2Client:
"""Create a FIDO2 client for the given device."""
return Fido2Client( # pylint: disable=unexpected-keyword-arg
device,
origin,
user_interaction=user_interaction
)
def create_options(assertion_parameters: Fido2AssertionParameters) -> Options:
"""Create a FIDO2 options for the given assertion parameters."""
return Options(
challenge=assertion_parameters.challenge,
rp_id=assertion_parameters.rp_id,
allow_credentials=assertion_parameters.allow_credentials,
user_verification=assertion_parameters.user_verification
)
def create_from_client_assertion(assertion: AssertionSelection) -> Fido2Assertion:
"""Create a FIDO2 assertion from the given client assertion."""
response = assertion.get_response(0)
return Fido2Assertion(
client_data=bytes(response.client_data),
authenticator_data=bytes(response.authenticator_data),
signature=bytes(response.signature),
credential_id=bytes(response.credential_id)
)
python-proton-vpn-api-core-4.16.0/proton/vpn/session/fido2_2.py 0000664 0000000 0000000 00000004713 15151554407 0024361 0 ustar 00root root 0000000 0000000 """
FIDO2 2.x API implementation for ProtonVPN session handling.
Copyright (c) 2025 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 .
"""
# pylint: disable=no-name-in-module
from fido2.hid import CtapHidDevice
from fido2.client import (Fido2Client, DefaultClientDataCollector,
UserInteraction as Fido2UserInteraction,
AssertionSelection)
from fido2.webauthn import (
PublicKeyCredentialRequestOptions as Options,
UserVerificationRequirement,
)
from proton.session.api import Fido2AssertionParameters, Fido2Assertion
def create_client(device: CtapHidDevice,
origin: str,
user_interaction: Fido2UserInteraction) -> Fido2Client:
"""Create a FIDO2 client for the given device."""
collector = DefaultClientDataCollector(origin)
return Fido2Client( # pylint: disable=unexpected-keyword-arg
device,
collector,
user_interaction=user_interaction
)
def create_options(assertion_parameters: Fido2AssertionParameters) -> Options:
"""Create a FIDO2 options for the given assertion parameters."""
user_verification = UserVerificationRequirement(
assertion_parameters.user_verification
)
return Options(
challenge=assertion_parameters.challenge,
rp_id=assertion_parameters.rp_id,
allow_credentials=assertion_parameters.allow_credentials,
user_verification=user_verification
)
def create_from_client_assertion(assertion: AssertionSelection) -> Fido2Assertion:
"""Create a FIDO2 assertion from the given client assertion."""
result = assertion.get_response(0)
return Fido2Assertion(
client_data=bytes(result.response.client_data),
authenticator_data=bytes(result.response.authenticator_data),
signature=bytes(result.response.signature),
credential_id=result.raw_id
)
python-proton-vpn-api-core-4.16.0/proton/vpn/session/fido2_handler.py 0000664 0000000 0000000 00000002767 15151554407 0025644 0 ustar 00root root 0000000 0000000 """
Copyright (c) 2025 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
from packaging.version import Version
fido2_version = Version(version("fido2"))
if fido2_version >= Version("2.0.0"):
from proton.vpn.session.fido2_2 import (create_client, # noqa: F401
create_options,
create_from_client_assertion)
elif fido2_version >= Version("1.1.2"):
from proton.vpn.session.fido2_1 import (create_client, # noqa: F401
create_options,
create_from_client_assertion)
else:
raise ImportError(
f"python3-fido2 version {fido2_version} not supported. "
"Version 1.1.2 or higher required."
)
__all__ = [
"create_client",
"create_options",
"create_from_client_assertion"
]
python-proton-vpn-api-core-4.16.0/proton/vpn/session/key_mgr.py 0000664 0000000 0000000 00000015120 15151554407 0024564 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-4.16.0/proton/vpn/session/servers/ 0000775 0000000 0000000 00000000000 15151554407 0024247 5 ustar 00root root 0000000 0000000 python-proton-vpn-api-core-4.16.0/proton/vpn/session/servers/__init__.py 0000664 0000000 0000000 00000002125 15151554407 0026360 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
from proton.vpn.session.dataclasses.servers.country import \
Country, City, SecureCoreGroup
from proton.vpn.session.servers.types import \
LogicalServer, PhysicalServer, ServerFeatureEnum, TierEnum
__all__ = [
"ServerList",
"Country",
"City",
"LogicalServer",
"PhysicalServer",
"ServerFeatureEnum",
"TierEnum",
"SecureCoreGroup"
]
python-proton-vpn-api-core-4.16.0/proton/vpn/session/servers/country_codes.py 0000664 0000000 0000000 00000016611 15151554407 0027506 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 .
"""
from typing import Optional
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
def get_country_code_for_name(country_name: str) -> Optional[str]:
"""Returns country code based on provided country name, or None if not present"""
for code, name in country_codes.items():
if name.lower() == country_name.lower():
return code
return None
def validate_country_code(country_code: str) -> Optional[str]:
"""Returns the provided country code if it is recognised, or None otherwise"""
if country_code.upper() in country_codes:
return country_code
return None
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-4.16.0/proton/vpn/session/servers/logicals.py 0000664 0000000 0000000 00000043052 15151554407 0026422 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 functools
import itertools
import random
import time
from enum import Enum
from typing import Callable, Generator, Iterable, List, Optional
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" # pylint: disable=R0902
EXPIRATION_TIME = "ExpirationTime"
LOADS_EXPIRATION_TIME = "LoadsExpirationTime"
LAST_MODIFIED_TIME = "LastModifiedTime"
USER_TIER = "MaxTier"
STATUS_TOKEN = "StatusToken" # nosec B105
class ServerList: # pylint: disable=R0902, R0904
"""
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,
status_token: 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
self._status_token = status_token
@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.upper()] = 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
@property
def status_token(self) -> Optional[str]:
"""The token used to recover the status endpoint"""
return self._status_token
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.")
upper_case_name = name.upper()
try:
return self._logicals_by_name[upper_case_name]
except KeyError as error:
raise ServerNotFoundError(
f"The server with {name=} was not found"
) from error
@staticmethod
def get_fastest_server(servers: Iterable[LogicalServer]) -> Optional[LogicalServer]:
"""
:returns: Fastest server from the passed LogicalServer iterable
"""
return min(servers, key=lambda s: s.score, default=None)
@staticmethod
def get_available_servers(
servers: Iterable[LogicalServer],
user_tier: TierEnum
) -> Generator[LogicalServer]:
"""
:returns: Generator producing available servers
from the passed LogicalServer iterable
"""
return (
server for server in servers
if (
server.enabled
and server.tier <= user_tier
)
)
@staticmethod
def _compact_features(features: List[ServerFeatureEnum]) -> ServerFeatureEnum:
return functools.reduce(lambda f1, f2: f1 | f2, features, 0)
@staticmethod
def get_servers_with_features(
servers: Iterable[LogicalServer],
request_features: ServerFeatureEnum = 0,
exclude_features: ServerFeatureEnum = 0,
) -> Generator[LogicalServer]:
"""
:returns: Generator producing servers matching/excluding specified features
from the passed LogicalServer iterable
"""
return (
s for s in servers
if (ServerList._compact_features(s.features) & exclude_features) == 0
and (ServerList._compact_features(s.features) & request_features) == request_features
)
@staticmethod
def get_servers_in_country_code(
servers: Iterable[LogicalServer],
country_code: str
) -> Generator[LogicalServer]:
"""
:returns: Generator producing servers in the requested country
from the passed LogicalServer iterable
"""
return (
server for server in servers
if server.exit_country.lower() == country_code.lower()
)
@staticmethod
def get_servers_in_city(
servers: Iterable[LogicalServer],
city_name: str
) -> Generator[LogicalServer]:
"""
:returns: Generator producing servers in the requested city
from the passed LogicalServer iterable
"""
return (
server for server in servers
if server.city.lower() == city_name.lower()
)
def get_fastest_in_country(self, country_code: str) -> LogicalServer:
"""
:returns: the fastest server for the specified country code and the tiers
the user has access to.
"""
country_servers = ServerList.get_servers_in_country_code(self.logicals, country_code)
available_country_servers =\
ServerList.get_available_servers(country_servers, self.user_tier)
available_country_servers =\
ServerList.get_servers_with_features(
available_country_servers,
exclude_features=ServerFeatureEnum.SECURE_CORE | ServerFeatureEnum.TOR
)
fastest_available_server = ServerList.get_fastest_server(available_country_servers)
if not fastest_available_server:
raise ServerNotFoundError("No server available in the current tier")
return fastest_available_server
def get_fastest_in_city(self, city_name: str) -> LogicalServer:
"""
:returns: the fastest server in the specified city and the tiers
the user has access to.
"""
city_servers = ServerList.get_servers_in_city(self.logicals, city_name)
available_city_servers = ServerList.get_available_servers(city_servers, self.user_tier)
available_city_servers =\
ServerList.get_servers_with_features(
available_city_servers,
exclude_features=ServerFeatureEnum.SECURE_CORE | ServerFeatureEnum.TOR
)
fastest_available_server = ServerList.get_fastest_server(available_city_servers)
if not fastest_available_server:
raise ServerNotFoundError("No server available in the current tier")
return fastest_available_server
def get_fastest(self) -> LogicalServer:
""":returns: the fastest server in the tiers the user has access to."""
available_servers = ServerList.get_available_servers(self.logicals, self.user_tier)
available_servers =\
ServerList.get_servers_with_features(
available_servers,
exclude_features=ServerFeatureEnum.SECURE_CORE | ServerFeatureEnum.TOR
)
fastest_available_server = ServerList.get_fastest_server(available_servers)
if not fastest_available_server:
raise ServerNotFoundError("No server available in the current tier")
return fastest_available_server
def group_by_country(self, cities: bool = False) -> List[Country]:
"""
Returns the servers grouped by country.
:param cities: whether to group the servers by cities as well.
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.
"""
if cities:
self.sort(sort_servers_by_country_and_city_and_enabled_and_load)
else:
self.sort()
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())
status_token = data.get(PersistenceKeys.STATUS_TOKEN.value, None)
return ServerList(
user_tier=user_tier,
logicals=logicals,
expiration_time=expiration_time,
loads_expiration_time=loads_expiration_time,
last_modified_time=last_modified_time,
status_token=status_token
)
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,
PersistenceKeys.STATUS_TOKEN.value: self._status_token
}
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}"
def sort_servers_by_country_and_city_and_enabled_and_load(server: LogicalServer) -> str:
"""
Returns the comparison key used to sort servers by country name, city name,
whether they are enabled or not, and load.
"""
return (server.exit_country_name, server.city, 0 if server.enabled else 1, server.load)
python-proton-vpn-api-core-4.16.0/proton/vpn/session/servers/server_list_fetcher.py 0000664 0000000 0000000 00000024460 15151554407 0030670 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 enum import Enum
from pathlib import Path
from typing import Optional, TYPE_CHECKING, Dict, Tuple
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
from proton.vpn.session.dataclasses import VPNLocation
try:
# The import of the proton.vpn.lib module is delayed to avoid
# importing it when the feature flag is disabled.
from proton.vpn.lib import ServerStatus # pylint: disable=C0415, E0401, E0611
VPN_LIB_AVAILABLE = True
except ImportError:
VPN_LIB_AVAILABLE = False
NETZONE_HEADER = "X-PM-netzone"
MODIFIED_SINCE_HEADER = "If-Modified-Since"
LAST_MODIFIED_HEADER = "Last-Modified"
NOT_MODIFIED_STATUS = 304
class EndpointVersion(Enum):
"""Used to choose between v1 endpoint or v2"""
V1 = 1
V2 = 2
class MixinEndpointV1: # pylint: disable=R0903
"""
Mixin class for the v1 endpoints of the Proton VPN REST API.
"""
LOGICALS = "/vpn/v1/logicals?SecureCoreFilter=all"
LOADS = "/vpn/v1/loads"
async def _v1_fetch_logicals(self) -> Tuple[Dict, str]:
return await self._request_logicals(MixinEndpointV1.LOGICALS)
async def _v1_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.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
class MixinEndpointV2: # pylint: disable=R0903
"""
Mixin class for the v2 endpoints of the Proton VPN REST API.
"""
LOGICALS = "/vpn/v2/logicals?SecureCoreFilter=all"
STATUS = "/vpn/v2/status/{token}/binary"
def _convert_load(self, load: Dict) -> Dict:
# Going forward we want to properly integrate, IsEnabled, IsVisible and
# IsAutoconnectable see VPNLINUX-1502.
score = load["Score"] + (0 if load["IsAutoconnectable"] else 1000)
return {
"Load": load["Load"],
"Score": score,
"Status": load["IsEnabled"] and load["IsVisible"],
}
async def _v2_fetch_logicals(self, location: Optional["VPNLocation"]) -> Tuple[Dict, str]:
logicals, last_modified_time =\
await self._request_logicals(MixinEndpointV2.LOGICALS)
self._server_status = ServerStatus(
logicals,
user_location={
"Latitude": location.Lat,
"Longitude": location.Long
},
user_country=location.Country
)
status = await self._request_status(
MixinEndpointV2.STATUS.format(token=logicals["StatusID"])
)
loads = self._server_status.compute_loads(status)
# Splice the loads into the servers
servers = logicals["LogicalServers"]
if len(loads) != len(servers):
raise RuntimeError(
"Loads computation produced a different number of servers "
"than the logicals list. This is unexpected."
)
for server, load in zip(servers, loads):
server.update(self._convert_load(load))
return logicals, last_modified_time
async def _v2_update_loads(self, status) -> 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."
)
binary_status = await self._request_status(
MixinEndpointV2.STATUS.format(token=status)
)
loads = self._server_status.compute_loads(binary_status)
server_loads = [ServerLoad(self._convert_load(data)) for data in loads]
self._server_list.update(server_loads)
self._cache_file.save(self._server_list.to_dict())
return self._server_list
class ServerListFetcher(MixinEndpointV1, MixinEndpointV2):
"""Fetches the server list either from disk or from the REST API."""
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 _request_logicals(self, endpoint: str) -> Tuple[Dict, str]:
raw_response = await rest_api_request(
self._session,
endpoint,
additional_headers=self._build_additional_headers(),
return_raw=True
)
if raw_response.status_code == NOT_MODIFIED_STATUS:
response = self._server_list.to_dict()
else:
response = raw_response.json
# The last modified time
last_modified_time = raw_response.find_first_header(
LAST_MODIFIED_HEADER, ServerList.get_epoch_time()
)
return response, last_modified_time
async def _request_status(self, endpoint):
status_response = await rest_api_request(
self._session,
endpoint,
additional_headers=self._build_additional_headers(),
return_raw=True
)
return status_response.data
def _v2_validate_location(self) -> Optional["VPNLocation"]:
location = self._session.vpn_account.location
if (location.Lat is None or location.Long is None):
return None
return location
async def fetch(self, endpoint_version: EndpointVersion) -> ServerList:
"""Fetches the list of VPN servers. Warning: this is a heavy request."""
location = self._v2_validate_location()
use_v2 = endpoint_version == EndpointVersion.V2 \
and VPN_LIB_AVAILABLE\
and location is not None
if use_v2:
response, last_modified_time = await self._v2_fetch_logicals(location)
else:
response, last_modified_time = await self._v1_fetch_logicals()
Keys = PersistenceKeys
entries_to_update = {
Keys.USER_TIER.value: self._session.vpn_account.max_tier,
Keys.LAST_MODIFIED_TIME.value: last_modified_time,
Keys.EXPIRATION_TIME.value: ServerList.get_expiration_time(),
Keys.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 update_loads(self, endpoint_version: EndpointVersion) -> ServerList:
"""
Queries the REST API for the latest server loads and updates
the current server list with them, return the updated server list.
"""
status_token = self._server_list.status_token
if status_token and (endpoint_version == EndpointVersion.V2):
server_list = await self._v2_update_loads(status_token)
else:
server_list = await self._v1_update_loads()
return 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_additional_headers(self) -> Dict[str, str]:
headers = {}
headers[NETZONE_HEADER] = self._build_header_netzone()
headers[MODIFIED_SINCE_HEADER] = self._extract_modified_since_header()
return headers
def _build_header_netzone(self) -> str:
truncated_ip_address = truncate_ip_address(
self._session.vpn_account.location.IP
)
return truncated_ip_address
def _extract_modified_since_header(self) -> str:
return self._server_list.last_modified_time \
if self._server_list \
else ServerList.get_epoch_time()
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-4.16.0/proton/vpn/session/servers/types.py 0000664 0000000 0000000 00000024441 15151554407 0025772 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
)
@property
def under_maintenance(self) -> bool:
"""Returns whether this server should be considered under maintenance or not."""
return not self.enabled
# 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 smart_routing(self) -> bool:
"""Returns whether this server uses smart routing technology or not."""
return self.host_country is not None
@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 free(self) -> bool:
"""Returns whether this server is available to the free tier or not."""
return self.tier == TierEnum.FREE
@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-4.16.0/proton/vpn/session/session.py 0000664 0000000 0000000 00000040631 15151554407 0024617 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 threading import Event
from typing import Optional
from proton.session import Session, FormData, FormField
from proton.session.api import Fido2Assertion, Fido2AssertionParameters
from proton.vpn import logging
from proton.vpn.session.account import VPNAccount
from proton.vpn.session.fetcher import (VPNSessionFetcher, EndpointVersion)
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.exceptions import VPNAccountDecodeError, ServerListDecodeError
from proton.vpn.session.servers.logicals import ServerList
from proton.vpn.session.feature_flags_fetcher import FeatureFlags
from proton.vpn.session.u2f_interaction import UserInteraction
logger = logging.getLogger(__name__)
BINARY_SERVER_STATUS = "BinaryServerStatus"
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)
try:
# pylint: disable=import-outside-toplevel
from proton.vpn.session.u2f import U2FKeys
self._u2f_keys = U2FKeys()
except ImportError as error:
self._u2f_keys = None
logger.warning(error)
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 \
and self._feature_flags
def __setstate__(self, data):
"""This method is called when deserializing the session."""
# It might be useful to load feature flags cache/defaults, even in logged out state.
self._feature_flags = self._fetcher.load_feature_flags_from_cache()
if 'vpn' in data:
try:
self._vpn_account = VPNAccount.from_dict(data['vpn'])
except VPNAccountDecodeError:
logger.warning("VPN account could not be deserialized", exc_info=True)
try:
self._server_list = self._fetcher.load_server_list_from_cache()
except ServerListDecodeError:
logger.warning("Server list could not be deserialized", exc_info=True)
self._client_config = self._fetcher.load_client_config_from_cache()
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_code(self, code: str) -> LoginResult:
"""
Submits the 2FA code.
:returns: whether the 2FA was successful or not.
"""
valid_code = await super().async_validate_2fa_code(code)
if not valid_code:
return LoginResult(success=False, authenticated=True, twofa_required=True)
return LoginResult(success=True, authenticated=True, twofa_required=False)
@property
def fido2_lib_available(self) -> Fido2AssertionParameters:
"""
:returns: whether the expected FIDO2 library is available or not.
"""
return bool(self._u2f_keys)
async def generate_2fa_fido2_assertion(
self,
user_interaction: Optional[UserInteraction] = None,
cancel_assertion: Optional[Event] = None
) -> Fido2Assertion:
"""
Scans for U2F/FIDO2 keys and generates a FIDO2 assertion.
:param user_interaction: object handling any required user interaction
while generating the assertion.
:param cancel_assertion: optional event that can be set to cancel the
fido 2 assertion process.
:returns: the generated FIDO2 assertion.
"""
if not self.fido2_lib_available:
raise RuntimeError("U2F/FIDO2 support is not available on this platform")
return await self._u2f_keys.scan_keys_and_get_assertion(
self, user_interaction, cancel_assertion
)
async def provide_2fa_fido2(self, fido2_assertion: Fido2Assertion) -> LoginResult:
"""
Submits the 2FA FIDO2 assertion.
:returns: whether the 2FA was successful or not.
"""
if not self.fido2_lib_available:
raise RuntimeError("U2F/FIDO2 support is not available on this platform")
valid_assertion = await super().async_validate_2fa_fido2(fido2_assertion)
if not valid_assertion:
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.
await self.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()
def _serverlist_endpoint_version(self) -> EndpointVersion:
"""Returns the endpoint version to be used for server list fetching."""
if self._feature_flags.get(BINARY_SERVER_STATUS):
return EndpointVersion.V2
return EndpointVersion.V1
async def fetch_server_list(self) -> ServerList:
"""
Fetches the server list from the REST API.
"""
self._server_list = await self._fetcher.fetch_server_list(
self._serverlist_endpoint_version()
)
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(
self._serverlist_endpoint_version()
)
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."""
if self._feature_flags is None:
return FeatureFlags.default()
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-4.16.0/proton/vpn/session/u2f.py 0000664 0000000 0000000 00000023125 15151554407 0023627 0 ustar 00root root 0000000 0000000 """
Copyright (c) 2025 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 .
"""
# pylint: disable=C0413
import asyncio
from getpass import getpass
from threading import Event
from typing import Iterator, Optional
from proton.vpn.session import fido2_handler
from proton.session import Session
from proton.session.api import Fido2AssertionParameters, Fido2Assertion
from proton.vpn.session.exceptions import (
SecurityKeyError, Fido2NotSupportedError,
SecurityKeyNotFoundError, InvalidSecurityKeyError,
SecurityKeyPINInvalidError, SecurityKeyPINNotSetError,
SecurityKeyTimeoutError
)
from proton.vpn.session.u2f_interaction import UserInteraction
from fido2.hid import CtapHidDevice # pylint: disable=wrong-import-order
# pylint: disable=no-name-in-module
from fido2.client import ( # pylint: disable=wrong-import-order
Fido2Client, ClientError, UserInteraction as Fido2UserInteraction
)
from fido2.ctap import CtapError # pylint: disable=wrong-import-order
from fido2.ctap2.pin import ClientPin # pylint: disable=wrong-import-order
class Fido2UserInteractionAdaptor(Fido2UserInteraction):
"""
Wraps a UserInteraction object to provide the Fido2UserInteraction
interface.
See UserInteraction in fido2.client for details.
"""
def __init__(self, user_interaction: UserInteraction):
"""Initialize the adaptor with the given UserInteraction object."""
self._user_interaction = user_interaction
def prompt_up(self) -> None:
"""Called when the authenticator is awaiting a user presence check."""
self._user_interaction.prompt_up()
def request_pin(
self, permissions: ClientPin.PERMISSION, rp_id: Optional[str]
) -> Optional[str]:
"""Called when the client requires a PIN from the user"""
return self._user_interaction.request_pin(permissions, rp_id)
def request_uv(
self, permissions: ClientPin.PERMISSION, rp_id: Optional[str]
) -> bool:
"""Called when the client is about to request UV from the user."""
return self._user_interaction.request_uv(permissions, rp_id)
class U2FKeys:
"""Manage U2F keys."""
def list_devices(self) -> Iterator[CtapHidDevice]:
"""List all connected FIDO2 devices."""
return CtapHidDevice.list_devices()
async def scan_keys_and_get_assertion(
self,
session: Session,
user_interaction: Optional[UserInteraction] = None,
cancel_assertion: Optional[Event] = None
) -> Fido2Assertion:
"""
Select a FIDO2 client and get an assertion from it.
:param session: user session.
:param user_interaction: optional object handling any required user interaction.
:param cancel_assertion: optional event to be able to cancel the ongoing assertion.
:returns: the generated FIDO2 assertion.
"""
if not session.supports_fido2:
raise Fido2NotSupportedError("Session does not support FIDO2 authentication")
origin = "https://" + session.supports_fido2.rp_id
fido2_clients = [
# pylint: disable=unexpected-keyword-arg
fido2_handler.create_client(
device,
origin,
Fido2UserInteractionAdaptor(user_interaction)
if user_interaction else Fido2UserInteraction()
)
for device in self.list_devices()
]
if not fido2_clients:
raise SecurityKeyNotFoundError("No security key found")
if len(fido2_clients) == 1:
selected_client = fido2_clients[0]
else:
user_interaction.request_key_selection()
selected_client = await self._touch_key_to_use(fido2_clients)
assertion_parameters = session.supports_fido2
cancel_assertion = cancel_assertion or Event()
return await self.get_assertion_from_client(
selected_client, assertion_parameters, cancel_assertion
)
async def get_assertion_from_client(
self,
client: Fido2Client,
assertion_parameters: Fido2AssertionParameters,
cancel_assertion: Event
) -> Fido2Assertion:
"""Get an assertion from the given FIDO2 client."""
options = fido2_handler.create_options(assertion_parameters)
try:
assertion_selection = await asyncio.to_thread(
client.get_assertion, options, cancel_assertion
)
except ClientError as error:
if error.code == ClientError.ERR.DEVICE_INELIGIBLE:
raise InvalidSecurityKeyError("The security key is not eligible") from error
if error.code == ClientError.ERR.TIMEOUT:
raise SecurityKeyTimeoutError("The security key operation timed out") from error
if error.code == ClientError.ERR.CONFIGURATION_UNSUPPORTED:
raise SecurityKeyPINNotSetError(
"The security key doesn't have a PIN set but the server requires it"
) from error
if (
error.code == ClientError.ERR.BAD_REQUEST
and isinstance(error.cause, CtapError)
and error.cause.ERR.PIN_INVALID
):
raise SecurityKeyPINInvalidError(
"The security key PIN provided is not valid"
) from error
raise SecurityKeyError("An error occurred with the security key") from error
except OSError as error:
# if the key is removed while the client is waitint for it to be touched we get:
# OSError: [Errno 19] No such device
raise SecurityKeyNotFoundError("The security key could not be accessed") from error
except Exception as error:
raise SecurityKeyError("An error occcurred with the security key") from error
return fido2_handler.create_from_client_assertion(assertion_selection)
async def _touch_key_to_use(self, fido2_clients: list[Fido2Client]) -> Fido2Client:
cancel_key_selection = Event()
tasks = [
asyncio.create_task(asyncio.to_thread(
self._client_selection, client, cancel_key_selection
))
for client in fido2_clients
]
done_tasks, _ = await asyncio.wait(tasks)
results = [task.result() for task in done_tasks]
selected_client = [client for client in results if client is not None].pop()
return selected_client
def _client_selection(
self, client: Fido2Client, cancel_client_selection: Event
) -> Optional[Fido2Client]:
try:
# Block until user touches the key or event is set
client.selection(cancel_client_selection)
except ClientError as error:
if error.code != ClientError.ERR.TIMEOUT:
raise
return None
# Cancel other client selections
cancel_client_selection.set()
# Return the selected client
return client
class CLIUserInteraction:
"""
Provides user interaction via CLI to the Fido2 client.
The interface currently follows the fido2.client.UserInteraction interface,
adding some methods to it.
"""
def prompt_up(self) -> None:
"""Called when the authenticator is awaiting a user presence check."""
print("If your security key has a button or a gold disc, tap it now to authenticate.")
def request_pin(
self, *_args, **_kwargs
) -> Optional[str]:
"""Called when the client requires a PIN from the user.
Should return a PIN, or None/Empty to cancel."""
return getpass("Introduce your PIN and press enter: ")
def request_uv(self, *_args, **_kwargs) -> bool:
"""Called when the client is about to request UV from the user.
Should return True if allowed, or False to cancel."""
return True
def request_key_selection(self):
"""Called when multiple keys are found and the user needs to select one
by touching it."""
print(
"Multiple security keys were detected. "
"If your security key has a button or a gold disc, tap it now to select it."
)
async def main():
"""Example usage of the U2FKeys class."""
session = Session()
username = input("Enter your username: ")
password = getpass("Enter your password: ")
await session.async_authenticate(username=username, password=password)
if not session.authenticated:
raise RuntimeError("Authentication failed")
if not session.needs_twofa:
raise RuntimeError("Session does not need 2FA")
print("Scanning for keys...")
manager = U2FKeys()
assertion = await manager.scan_keys_and_get_assertion(session, CLIUserInteraction())
print("FIDO2 assertion:", assertion)
result = await session.async_validate_2fa_fido2(assertion)
if result:
print("2FA successful, session is now fully authenticated.")
else:
print("2FA failed.")
await session.async_logout()
if __name__ == "__main__":
asyncio.run(main())
python-proton-vpn-api-core-4.16.0/proton/vpn/session/u2f_interaction.py 0000664 0000000 0000000 00000003055 15151554407 0026226 0 ustar 00root root 0000000 0000000 """
Copyright (c) 2025 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 Optional, Protocol
class UserInteraction(Protocol):
"""
Provides user interaction to the Fido2 client.
The interface currently follows the fido2.client.UserInteraction interface,
adding some methods to it.
"""
def prompt_up(self) -> None:
"""Called when the authenticator is awaiting a user presence check."""
def request_pin(
self, *args, **kwargs
) -> Optional[str]:
"""Called when the client requires a PIN from the user.
Should return a PIN, or None/Empty to cancel."""
def request_uv(self, *args, **kwargs) -> bool:
"""Called when the client is about to request UV from the user.
Should return True if allowed, or False to cancel."""
def request_key_selection(self) -> None:
"""Called when multiple keys are found and the user needs to select one
by touching it."""
python-proton-vpn-api-core-4.16.0/proton/vpn/session/utils.py 0000664 0000000 0000000 00000015170 15151554407 0024274 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 importlib import metadata
from typing import Optional
import time
import random
import os as sys_os
import json
from dataclasses import asdict
import distro
from packaging.version import Version
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 semver_from_pep440(pep440_version: str) -> str:
"""
Converts a PEP440 version to a semver version.
Disclaimers:
- It assumes the PEP440 version contains the major, minor, micro triplet (e.g. 1.2.3).
- Date-based releases are not supported (e.g. 2023.05).
- Post release segments are not supported, since semver doesn't allow them.
https://peps.python.org/pep-0440
https://semver.org
"""
ver = Version(pep440_version)
# Even though PEP440 doesn't require it, our versions always contain
# the major, minor, and micro triplet.
result = f"{ver.major}.{ver.minor}.{ver.micro}"
if ver.pre is not None:
prerelease_mappings = {
"a": "alpha",
"b": "beta",
"rc": "rc"
}
result += f"-{prerelease_mappings[ver.pre[0]]}.{ver.pre[1]}"
if ver.dev is not None:
result += f"-dev.{ver.dev}"
if ver.local is not None:
result += f"+{ver.local}"
return result
def get_core_api_semver_version() -> str:
"""
Converts the PEP440 version of this python package to the equivalent semver version.
"""
return semver_from_pep440(metadata.version("proton-vpn-api-core"))
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-4.16.0/proton/vpn/split_tunneling/ 0000775 0000000 0000000 00000000000 15151554407 0024311 5 ustar 00root root 0000000 0000000 python-proton-vpn-api-core-4.16.0/proton/vpn/split_tunneling/__init__.py 0000664 0000000 0000000 00000001461 15151554407 0026424 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.vpn.split_tunneling.interface import SplitTunneling
__all__ = ["SplitTunneling"]
python-proton-vpn-api-core-4.16.0/proton/vpn/split_tunneling/exceptions.py 0000664 0000000 0000000 00000001546 15151554407 0027052 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.vpn.core.exceptions import VPNDaemonError
class SplitTunnelingError(VPNDaemonError):
"""Split tunneling exception error.
"""
python-proton-vpn-api-core-4.16.0/proton/vpn/split_tunneling/interface.py 0000664 0000000 0000000 00000006233 15151554407 0026627 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 __future__ import annotations
from abc import ABC, abstractmethod
from typing import Optional, Union
from proton.loader import Loader
from proton.vpn import logging
from proton.vpn.core.settings import SplitTunnelingConfig
logger = logging.getLogger(__name__)
class SplitTunneling(ABC):
"""Defines the interface to create a new split tunneling client.
"""
def __init__(self, uid: int):
self._uid: int = uid
@staticmethod
async def get(uid: int) -> Optional[SplitTunneling]:
"""
Returns the split tunneling implementation.
:param uid: unix user ID
"""
try:
split_tunneling_class = Loader.get(
type_name="split_tunneling"
)
except RuntimeError:
logger.warning("Split tunneling backend not found")
return None
return await split_tunneling_class.build(uid)
@staticmethod
@abstractmethod
async def build(uid: int) -> SplitTunneling:
"""Builds and returns the split tunneling instance."""
@abstractmethod
async def set_config(self, config: SplitTunnelingConfig) -> None:
"""Sets a new config for instance uid.
Args:
config (SplitTunnelingConfig): the object containing the data
uid (int): uid that the config has to be stored for
"""
@abstractmethod
async def get_config(self) -> Optional[SplitTunnelingConfig]:
"""Returns config for instance uid.
Args:
uid (int): uid to get the data for
Returns:
SplitTunnelingConfig: contains data stored for the specified uid
"""
@abstractmethod
async def clear_config(self) -> None:
"""Clears config stored for instance uid.
Args:
uid (int): uid that data is to be cleared for
"""
@abstractmethod
async def get_all_configs(
self
) -> Union[
list[tuple[int, SplitTunnelingConfig]],
list
]:
"""Clears data stored for the specified uid.
"""
@classmethod
@abstractmethod
def _get_priority(cls) -> int:
"""
Priority of the split tunneling implementation.
To be implemented by subclasses.
"""
@classmethod
@abstractmethod
def _validate(cls) -> bool:
"""
Determines whether the split tunneling connection implementation is valid or not.
To be implemented by subclasses.
"""
python-proton-vpn-api-core-4.16.0/requirements.txt 0000664 0000000 0000000 00000000024 15151554407 0022227 0 ustar 00root root 0000000 0000000 -e ".[development]"
python-proton-vpn-api-core-4.16.0/rpmbuild/ 0000775 0000000 0000000 00000000000 15151554407 0020565 5 ustar 00root root 0000000 0000000 python-proton-vpn-api-core-4.16.0/rpmbuild/BUILD/ 0000775 0000000 0000000 00000000000 15151554407 0021424 5 ustar 00root root 0000000 0000000 python-proton-vpn-api-core-4.16.0/rpmbuild/BUILD/.gitkeep 0000664 0000000 0000000 00000000000 15151554407 0023043 0 ustar 00root root 0000000 0000000 python-proton-vpn-api-core-4.16.0/rpmbuild/BUILDROOT/ 0000775 0000000 0000000 00000000000 15151554407 0022130 5 ustar 00root root 0000000 0000000 python-proton-vpn-api-core-4.16.0/rpmbuild/BUILDROOT/.gitkeep 0000664 0000000 0000000 00000000000 15151554407 0023547 0 ustar 00root root 0000000 0000000 python-proton-vpn-api-core-4.16.0/rpmbuild/SOURCES/ 0000775 0000000 0000000 00000000000 15151554407 0021710 5 ustar 00root root 0000000 0000000 python-proton-vpn-api-core-4.16.0/rpmbuild/SOURCES/.gitkeep 0000664 0000000 0000000 00000000000 15151554407 0023327 0 ustar 00root root 0000000 0000000 python-proton-vpn-api-core-4.16.0/rpmbuild/SPECS/ 0000775 0000000 0000000 00000000000 15151554407 0021442 5 ustar 00root root 0000000 0000000 python-proton-vpn-api-core-4.16.0/rpmbuild/SPECS/.gitkeep 0000664 0000000 0000000 00000000000 15151554407 0023061 0 ustar 00root root 0000000 0000000 python-proton-vpn-api-core-4.16.0/rpmbuild/SPECS/package.spec.template 0000664 0000000 0000000 00000004143 15151554407 0025525 0 ustar 00root root 0000000 0000000 %define unmangled_name proton-vpn-api-core
%define pep_625_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: %{{pep_625_name}}-%{{version}}.tar.gz
BuildArch: noarch
BuildRoot: %{{_tmppath}}/%{{pep_625_name}}-%{{version}}-%{{release}}-buildroot
BuildRequires: python3-proton-core >= 0.5.0
BuildRequires: python3-setuptools
BuildRequires: python3-distro
BuildRequires: python3-sentry-sdk
BuildRequires: python3-pynacl
BuildRequires: python3-fido2
BuildRequires: python3-packaging
# Network manager backend dependencies
BuildRequires: python3-gobject
BuildRequires: NetworkManager
BuildRequires: NetworkManager-openvpn
BuildRequires: NetworkManager-openvpn-gnome
BuildRequires: gobject-introspection
BuildRequires: python3-proton-vpn-local-agent >= 1.5.0
BuildRequires: python3-jinja2
Requires: python3-proton-core >= 0.5.0
Requires: python3-distro
Requires: python3-sentry-sdk
Requires: python3-pynacl
Requires: python3-fido2
Requires: python3-packaging
# Network manager backend dependencies
Requires: python3-gobject
Requires: NetworkManager
Requires: NetworkManager-openvpn
Requires: NetworkManager-openvpn-gnome
Requires: gobject-introspection
Requires: python3-proton-vpn-local-agent >= 1.5.0
Requires: python3-jinja2
Conflicts: proton-vpn-gtk-app < 4.14.2
Conflicts: python3-proton-vpn-network-manager < 0.13.5
Obsoletes: python3-proton-vpn-session
Obsoletes: python3-proton-vpn-connection
Obsoletes: python3-proton-vpn-killswitch
Obsoletes: python3-proton-vpn-logger
Obsoletes: python3-proton-vpn-lib
Obsoletes: python3-proton-vpn-network-manager
%{{?python_disable_dependency_generator}}
%description
Package %{{unmangled_name}} library.
%prep
%setup -q -n %{{pep_625_name}}-%{{version}}
%build
%pyproject_wheel
%install
%pyproject_install
%pyproject_save_files proton
%files -n %{{name}} -f %{{pyproject_files}}
%changelog
python-proton-vpn-api-core-4.16.0/rpmbuild/SRPMS/ 0000775 0000000 0000000 00000000000 15151554407 0021471 5 ustar 00root root 0000000 0000000 python-proton-vpn-api-core-4.16.0/rpmbuild/SRPMS/.gitkeep 0000664 0000000 0000000 00000000000 15151554407 0023110 0 ustar 00root root 0000000 0000000 python-proton-vpn-api-core-4.16.0/scripts/ 0000775 0000000 0000000 00000000000 15151554407 0020436 5 ustar 00root root 0000000 0000000 python-proton-vpn-api-core-4.16.0/scripts/create_changelogs.py 0000775 0000000 0000000 00000003062 15151554407 0024451 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-4.16.0/scripts/devtools/ 0000775 0000000 0000000 00000000000 15151554407 0022275 5 ustar 00root root 0000000 0000000 python-proton-vpn-api-core-4.16.0/setup.cfg 0000664 0000000 0000000 00000000243 15151554407 0020567 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-4.16.0/setup.py 0000664 0000000 0000000 00000005025 15151554407 0020463 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",
include_package_data=True,
install_requires=[
"proton-core", "distro", "sentry-sdk",
"cryptography", "PyNaCl", "distro", "fido2", "packaging",
"pygobject", "pycairo", "jinja2", "proton-vpn-local-agent" # network manager backend
],
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*",
"proton.vpn.split_tunneling*",
"proton.vpn.backend.networkmanager.core*",
"proton.vpn.backend.networkmanager.protocol.openvpn*",
"proton.vpn.backend.networkmanager.protocol.wireguard*",
"proton.vpn.backend.networkmanager.killswitch.default*",
"proton.vpn.backend.networkmanager.killswitch.wireguard*",
]),
entry_points={
"proton_loader_backend": [
"linuxnetworkmanager = proton.vpn.backend.networkmanager.core:LinuxNetworkManager",
],
"proton_loader_linuxnetworkmanager": [
"openvpn-tcp = proton.vpn.backend.networkmanager.protocol.openvpn:OpenVPNTCP",
"openvpn-udp = proton.vpn.backend.networkmanager.protocol.openvpn:OpenVPNUDP",
"wireguard = proton.vpn.backend.networkmanager.protocol.wireguard:Wireguard",
],
"proton_loader_killswitch": [
"default = proton.vpn.backend.networkmanager.killswitch.default:NMKillSwitch",
"wireguard = proton.vpn.backend.networkmanager.killswitch.wireguard:WGKillSwitch",
]
},
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-4.16.0/tests/ 0000775 0000000 0000000 00000000000 15151554407 0020111 5 ustar 00root root 0000000 0000000 python-proton-vpn-api-core-4.16.0/tests/__init__.py 0000664 0000000 0000000 00000001246 15151554407 0022225 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-4.16.0/tests/connection/ 0000775 0000000 0000000 00000000000 15151554407 0022250 5 ustar 00root root 0000000 0000000 python-proton-vpn-api-core-4.16.0/tests/connection/__init__.py 0000664 0000000 0000000 00000000000 15151554407 0024347 0 ustar 00root root 0000000 0000000 python-proton-vpn-api-core-4.16.0/tests/connection/common.py 0000664 0000000 0000000 00000004403 15151554407 0024113 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"
def get_ed25519_sk_pem(self, password=None):
return "encrypted-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-4.16.0/tests/connection/test_events.py 0000664 0000000 0000000 00000003556 15151554407 0025176 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-4.16.0/tests/connection/test_persistence.py 0000664 0000000 0000000 00000014544 15151554407 0026215 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-4.16.0/tests/connection/test_publisher.py 0000664 0000000 0000000 00000005452 15151554407 0025664 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-4.16.0/tests/connection/test_states.py 0000664 0000000 0000000 00000037545 15151554407 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
from proton.vpn.core.settings.split_tunneling import SplitTunneling as SplitTunnelingSetting
from proton.vpn.split_tunneling.exceptions import SplitTunnelingError
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(spec=states.StateContext)
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)
context.split_tunneling.clear_config = 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(),
call.split_tunneling.clear_config()
]
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)
context.split_tunneling.clear_config = 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),
call.split_tunneling.clear_config()
]
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.AsyncMock
- 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(),
call.split_tunneling.clear_config()
]
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(name="context")
context.kill_switch_setting = kill_switch_setting
context.event.context.forwarded_port = None
context.split_tunneling_setting = Mock(name="split_tunneling_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.split_tunneling.set_config(context.split_tunneling_setting.get_config()),
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
@pytest.mark.asyncio
async def test_connected_run_tasks_swallows_split_tunneling_errors():
connection = Mock()
connection.add_persistence = AsyncMock()
context = states.StateContext(connection=connection)
context.kill_switch = AsyncMock()
context.kill_switch_setting = KillSwitchSetting.OFF
context.split_tunneling = AsyncMock()
context.split_tunneling_setting = SplitTunnelingSetting(enabled=True)
context.split_tunneling.set_config = AsyncMock(side_effect=SplitTunnelingError("forced error"))
connected = states.Connected(context)
await connected.run_tasks()
# The split tunneling exception shouldn't have bubbled up when applying ST config
context.split_tunneling.set_config.assert_called_once()
@pytest.mark.asyncio
async def test_disconnected_run_tasks_swallows_split_tunneling_errors():
context = states.StateContext(connection=None, reconnection=None)
context.kill_switch = AsyncMock()
context.kill_switch_setting = KillSwitchSetting.OFF
context.split_tunneling = AsyncMock()
context.split_tunneling_setting = SplitTunnelingSetting(enabled=True)
context.split_tunneling.clear_config = AsyncMock(side_effect=SplitTunnelingError("forced error"))
disconnected = states.Disconnected(context)
await disconnected.run_tasks()
# The split tunneling exception shouldn't have bubbled up when clearing ST config
context.split_tunneling.clear_config.assert_called_once()
python-proton-vpn-api-core-4.16.0/tests/connection/test_vpnconfiguration.py 0000664 0000000 0000000 00000010241 15151554407 0027252 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 VPNConfiguration
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)
python-proton-vpn-api-core-4.16.0/tests/connection/test_vpnconnection.py 0000664 0000000 0000000 00000016462 15151554407 0026555 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-4.16.0/tests/core/ 0000775 0000000 0000000 00000000000 15151554407 0021041 5 ustar 00root root 0000000 0000000 python-proton-vpn-api-core-4.16.0/tests/core/__init__.py 0000664 0000000 0000000 00000000000 15151554407 0023140 0 ustar 00root root 0000000 0000000 python-proton-vpn-api-core-4.16.0/tests/core/refresher/ 0000775 0000000 0000000 00000000000 15151554407 0023026 5 ustar 00root root 0000000 0000000 python-proton-vpn-api-core-4.16.0/tests/core/refresher/test_certificate_refresher.py 0000664 0000000 0000000 00000005145 15151554407 0030773 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
from proton.session.exceptions import ProtonAPIError
@pytest.mark.asyncio
async def test_refresh_schedules_next_refresh_if_certificate_is_expired_and_api_error_exception_is_raised():
session_holder = Mock()
session = session_holder.session
refresher = CertificateRefresher(session_holder=session_holder)
session.fetch_certificate = AsyncMock(side_effect=ProtonAPIError(
http_code=429,
http_headers={},
json_data={"Code": 429, "Error": "Error message"}
))
new_certificate = Mock()
new_certificate.remaining_time_to_next_refresh = 600
next_refresh_delay = await refresher.refresh()
@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-4.16.0/tests/core/refresher/test_client_config_refresher.py 0000664 0000000 0000000 00000004163 15151554407 0031313 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
from proton.session.exceptions import ProtonAPIError
@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)
@pytest.mark.asyncio
async def test_refresh_schedules_next_refresh_if_client_config_is_expired_and_api_error_exception_is_raised():
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(side_effect=ProtonAPIError(
http_code=429,
http_headers={},
json_data={"Code": 429, "Error": "Error message"}
))
next_refresh_delay = await refresher.refresh()
python-proton-vpn-api-core-4.16.0/tests/core/refresher/test_feature_flags_refresher.py 0000664 0000000 0000000 00000004157 15151554407 0031322 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
from proton.session.exceptions import ProtonAPIError
@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)
@pytest.mark.asyncio
async def test_refresh_schedules_next_refresh_if_feature_flags_is_expired_and_api_error_exception_is_raised():
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(side_effect=ProtonAPIError(
http_code=429,
http_headers={},
json_data={"Code": 429, "Error": "Error message"}
))
next_refresh_delay = await refresher.refresh()
python-proton-vpn-api-core-4.16.0/tests/core/refresher/test_scheduler.py 0000664 0000000 0000000 00000003730 15151554407 0026420 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-4.16.0/tests/core/refresher/test_server_list_refresher.py 0000664 0000000 0000000 00000012034 15151554407 0031045 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, patch
import pytest
from proton.vpn.core.refresher.scheduler import RunAgain
from proton.vpn.core.refresher.server_list_refresher import ServerListRefresher
from proton.session.exceptions import ProtonAPIError
@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)
@pytest.mark.asyncio
@patch("proton.vpn.core.refresher.server_list_refresher.ServerList.get_loads_refresh_interval_in_seconds")
async def test_refresh_schedules_next_refresh_if_server_list_is_expired_and_api_error_exception_is_raised(mock_get_loads_refresh_interval_in_seconds):
get_loads_refresh_interval_in_seconds = 10
mock_get_loads_refresh_interval_in_seconds.return_value = get_loads_refresh_interval_in_seconds
session_holder = Mock()
session = session_holder.session
# The current server list is expired.
session.server_list.expired = True
# Mock a 429 response from the server
session.fetch_server_list = AsyncMock(side_effect=ProtonAPIError(
http_code=429,
http_headers={},
json_data={"Code": 429, "Error": "Error message"}
))
refresher = ServerListRefresher(session_holder=session_holder)
refresher.server_list_updated_callback = Mock()
# No error message should be raised since it's been swallowed
next_refresh_delay = await refresher.refresh()
python-proton-vpn-api-core-4.16.0/tests/core/refresher/test_vpn_data_refresher.py 0000664 0000000 0000000 00000010022 15151554407 0030273 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-4.16.0/tests/core/test_cachehandler.py 0000664 0000000 0000000 00000004300 15151554407 0025050 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-4.16.0/tests/core/test_connection.py 0000664 0000000 0000000 00000023607 15151554407 0024621 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,
port_forward_file_handler=Mock()
)
on_state_change_update_location_callback = publisher_mock.register.call_args_list[0][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_update_location_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_notifies_port_forwarding_file_handler_with_new_state():
publisher_mock = Mock()
session_holder_mock = Mock()
port_forwarding_file_handler_mock = Mock(name="port_forwarding_file_handler_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,
port_forward_file_handler=port_forwarding_file_handler_mock
)
on_state_change_update_port_callback = publisher_mock.register.call_args_list[1][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_update_port_callback(connected_state)
port_forwarding_file_handler_mock.on_state_change_update_port.assert_called_once_with(connected_state)
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-4.16.0/tests/core/test_settings.py 0000664 0000000 0000000 00000014552 15151554407 0024321 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 copy
import pytest
import itertools
from proton.vpn.core.settings import Settings, SettingsPersistence, NetShield, SplitTunnelingMode
from proton.vpn.killswitch.interface import KillSwitchState
FREE_TIER = 0
PLUS_TIER = 1
@pytest.fixture
def default_free_settings_dict():
return {
"protocol": "wireguard",
"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,
"split_tunneling": {
"enabled": False,
"mode": SplitTunnelingMode.EXCLUDE.value,
"config_by_mode": {
SplitTunnelingMode.EXCLUDE.value: {
"mode": SplitTunnelingMode.EXCLUDE.value,
"app_paths": [],
"ip_ranges": []
},
SplitTunnelingMode.INCLUDE.value: {
"mode": SplitTunnelingMode.INCLUDE.value,
"app_paths": [],
"ip_ranges": []
},
}
},
}
}
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)
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)
# 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)
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)
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
def test_settings_default_split_tunneling_data_is_built_when_config_by_mode_value_is_invalid(default_free_settings_dict):
deep_copy_default_free_settings_dict = copy.deepcopy(default_free_settings_dict)
default_free_settings_dict["features"]["split_tunneling"]["config_by_mode"] = {}
free_settings = Settings.default(FREE_TIER)
free_settings.to_dict() == deep_copy_default_free_settings_dict
python-proton-vpn-api-core-4.16.0/tests/core/test_usage.py 0000664 0000000 0000000 00000014334 15151554407 0023563 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"
EMAIL = "toby.tubface@bucketworld.com"
GPG_KEY_ID = "D212342HE93E32P8"
EVENT_WITH_USERNAME = {
"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
}
]
}
EVENT_WITH_GPG_ID = {
"exception": {
"values": [
{
"type": "DBusErrorResponse",
"value": "[me.grimsteel.PassSecretService.GPGError] ('gpg: encrypted with cv25519 key, ID D212342HE93E32P8, created 2025-02-03\\n \"Toby (pass & proton) \"\\ngpg: [don\\'t know]: invalid packet (ctb=6c)\\ngpg: packet(5) with unknown version 18\\n',)",
}
]
}
}
EVENT_WITH_EMAIL = {
"exception": {
"values": [
{
"type": "DBusErrorResponse",
"value": "Some error that includes an email address: toby.tubface@bucketworld.com',)",
}
]
}
}
@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_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"
def test_sanitize_event_for_username():
event = copy.deepcopy(EVENT_WITH_USERNAME)
UsageReporting._sanitize_event(event, None, USERNAME)
assert USERNAME in json.dumps(EVENT_WITH_USERNAME), "Username should be in the event"
assert USERNAME not in json.dumps(event), "Username should not be in the event"
def test_sanitize_event_for_email():
event = copy.deepcopy(EVENT_WITH_EMAIL)
UsageReporting._sanitize_event(event, None, USERNAME)
assert EMAIL in json.dumps(EVENT_WITH_EMAIL), "Email should be in the event"
assert EMAIL not in json.dumps(event), "Email should not be in the event"
def test_sanitize_event_for_gpg_id():
event = copy.deepcopy(EVENT_WITH_GPG_ID)
UsageReporting._sanitize_event(event, None, USERNAME)
assert GPG_KEY_ID in json.dumps(EVENT_WITH_GPG_ID), "GPG ID should be in the event"
assert GPG_KEY_ID not in json.dumps(event), "GPG ID should not be in the event"
python-proton-vpn-api-core-4.16.0/tests/killswitch/ 0000775 0000000 0000000 00000000000 15151554407 0022266 5 ustar 00root root 0000000 0000000 python-proton-vpn-api-core-4.16.0/tests/killswitch/__init__.py 0000664 0000000 0000000 00000000000 15151554407 0024365 0 ustar 00root root 0000000 0000000 python-proton-vpn-api-core-4.16.0/tests/killswitch/test_killswitch.py 0000664 0000000 0000000 00000002455 15151554407 0026062 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-4.16.0/tests/logger/ 0000775 0000000 0000000 00000000000 15151554407 0021370 5 ustar 00root root 0000000 0000000 python-proton-vpn-api-core-4.16.0/tests/logger/test_logger.py 0000664 0000000 0000000 00000006640 15151554407 0024266 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-4.16.0/tests/networkmanager/ 0000775 0000000 0000000 00000000000 15151554407 0023135 5 ustar 00root root 0000000 0000000 python-proton-vpn-api-core-4.16.0/tests/networkmanager/__init__.py 0000664 0000000 0000000 00000000000 15151554407 0025234 0 ustar 00root root 0000000 0000000 python-proton-vpn-api-core-4.16.0/tests/networkmanager/core/ 0000775 0000000 0000000 00000000000 15151554407 0024065 5 ustar 00root root 0000000 0000000 python-proton-vpn-api-core-4.16.0/tests/networkmanager/core/__init__.py 0000664 0000000 0000000 00000000000 15151554407 0026164 0 ustar 00root root 0000000 0000000 python-proton-vpn-api-core-4.16.0/tests/networkmanager/core/boilerplate.py 0000664 0000000 0000000 00000003157 15151554407 0026747 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
from typing import List
@dataclass
class VPNServer:
server_ip: str = None
openvpn_ports: object = None
wireguard_ports: object = None
x25519pk: str = None
domain: str = None
servername: str = None
@dataclass
class VPNPubKeyCredentials:
certificate_pem: str = None
wg_private_key: str = None
openvpn_private_key: str = None
@dataclass
class VPNUserPassCredentials:
username: str = None
password: str = None
@dataclass
class VPNCredentials:
pubkey_credentials: VPNPubKeyCredentials = None
userpass_credentials: VPNUserPassCredentials = None
@dataclass
class Features:
netshield: int = None
vpn_accelerator: bool = None
port_forwarding: bool = None
random_nat: bool = None
safe_mode: bool = None
@dataclass
class Settings:
dns_custom_ips: List[str] = None
split_tunneling_ips: List[str] = None
ipv6: bool = None
features: Features = None
python-proton-vpn-api-core-4.16.0/tests/networkmanager/core/test_networkmanager.py 0000664 0000000 0000000 00000020467 15151554407 0030533 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 concurrent.futures import Future
from unittest.mock import Mock, patch, AsyncMock, DEFAULT
import gi
from proton.vpn.connection.persistence import ConnectionParameters
gi.require_version("NM", "1.0") # noqa: required before importing NM module
from gi.repository import NM, GLib
import pytest
from tests.networkmanager.core.boilerplate import VPNServer, VPNCredentials, Settings
from proton.vpn.backend.networkmanager.core import LinuxNetworkManager
from proton.vpn.connection.events import EventContext
from proton.vpn.connection import states
from proton.vpn.connection import events
from collections import namedtuple
OpenVPNPorts = namedtuple("OpenVPNPorts", "udp tcp")
class LinuxNetworkManagerProtocol(LinuxNetworkManager):
"""Dummy protocol just to unit test the base LinuxNetworkManager class."""
protocol = "Dummy protocol"
def __init__(self, *args, connection_persistence=None, **kwargs):
# Make sure we don't trigger connection persistence nor the kill switch.
connection_persistence = connection_persistence or Mock()
super().__init__(*args, connection_persistence=connection_persistence,
**kwargs)
def setup(self):
# to be mocked in tests
pass
@pytest.fixture
def nm_client_mock():
return Mock()
def create_nm_protocol(nm_client_mock):
return LinuxNetworkManagerProtocol(
VPNServer(
openvpn_ports=OpenVPNPorts([00], [00])
), VPNCredentials(), Settings(), nm_client=nm_client_mock
)
@pytest.mark.asyncio
@patch("proton.vpn.backend.networkmanager.core.networkmanager.tcpcheck")
async def test_start(tcpcheck_patch, nm_client_mock):
# Mock successful TCP connection check.
tcpcheck_patch.is_any_port_reachable = AsyncMock()
nm_protocol = create_nm_protocol(nm_client_mock)
with patch.object(nm_protocol, "setup") as setup_mock:
start_connection_future = Future()
nm_client_mock.start_connection_async.return_value = start_connection_future
connection_mock = setup_mock.return_value.result()
start_connection_future.set_result(connection_mock)
await nm_protocol.start()
setup_mock.assert_called_once()
nm_client_mock.start_connection_async.assert_called_once_with(connection_mock)
# Assert that once the connection has been activated, the expected callback
# is hooked to monitor vpn connection state changes.
connection_mock.connect.assert_called_once_with(
"vpn-state-changed",
nm_protocol._on_state_changed
)
@pytest.mark.asyncio
@patch("proton.vpn.backend.networkmanager.core.networkmanager.tcpcheck")
async def test_start_generates_timeout_event_when_the_tcp_connection_check_fails(
tcpcheck_patch, nm_client_mock
):
# Mock failed TCP connection check.
tcpcheck_patch.is_any_port_reachable = AsyncMock(return_value=False)
connection_subscriber = Mock()
nm_protocol = create_nm_protocol(nm_client_mock)
nm_protocol.register(connection_subscriber)
with patch.object(nm_protocol, "setup") as setup_mock:
await nm_protocol.start()
setup_mock.assert_not_called()
connection_subscriber.assert_called_once()
generated_event = connection_subscriber.call_args.kwargs["event"]
assert isinstance(generated_event, events.Timeout)
@pytest.mark.asyncio
@patch("proton.vpn.backend.networkmanager.core.networkmanager.tcpcheck")
async def test_start_generates_tunnel_setup_failed_event_on_connection_setup_errors(
tcpcheck_patch, nm_client_mock
):
nm_protocol = create_nm_protocol(nm_client_mock)
# Mock successful TCP connection check.
tcpcheck_patch.is_any_port_reachable = AsyncMock(return_value=True)
with patch.object(nm_protocol, "setup") as setup_mock:
# Mock error on connection setup.
setup_connection_future = Future()
setup_connection_future.set_exception(GLib.GError)
setup_mock.return_value = setup_connection_future
connection_subscriber = Mock()
nm_protocol.register(connection_subscriber)
await nm_protocol.start()
setup_mock.assert_called()
connection_subscriber.assert_called_once()
generated_event = connection_subscriber.call_args.kwargs["event"]
assert isinstance(generated_event, events.TunnelSetupFailed)
@pytest.mark.asyncio
@patch("proton.vpn.backend.networkmanager.core.networkmanager.tcpcheck")
async def test_start_generates_tunnel_setup_failed_event_on_connection_activation_errors_and_removes_connection(
tcpcheck_patch, nm_client_mock
):
nm_protocol = create_nm_protocol(nm_client_mock)
# Mock successful TCP connection check.
tcpcheck_patch.is_any_port_reachable = AsyncMock(return_value=True)
with patch.multiple(nm_protocol, setup=DEFAULT, remove_connection=DEFAULT) as mocks:
# Mock successful connection setup.
connection = Mock()
setup_connection_future = Future()
setup_connection_future.set_result(connection)
mocks["setup"].return_value = setup_connection_future
# Mock error on connection activation.
start_connection_future = Future()
start_connection_future.set_exception(GLib.GError)
nm_client_mock.start_connection_async.return_value = start_connection_future
connection_subscriber = Mock()
nm_protocol.register(connection_subscriber)
await nm_protocol.start()
nm_client_mock.start_connection_async.assert_called_once_with(connection)
connection_subscriber.assert_called_once()
generated_event = connection_subscriber.call_args.kwargs["event"]
assert isinstance(generated_event, events.TunnelSetupFailed)
mocks["remove_connection"].assert_called_once()
@pytest.mark.asyncio
async def test_remove_connection(nm_client_mock):
nm_protocol = create_nm_protocol(nm_client_mock)
connection_mock = Mock()
await nm_protocol.remove_connection(connection_mock)
nm_client_mock.remove_connection_async.assert_called_once_with(connection_mock)
assert nm_protocol._unique_id is None
@pytest.mark.asyncio
async def test_stop_connection_removes_connection(nm_client_mock):
nm_protocol = create_nm_protocol(nm_client_mock)
with patch.object(nm_protocol, "remove_connection"):
connection = Mock()
await nm_protocol.stop(connection)
nm_protocol.remove_connection.assert_called_once_with(connection)
@pytest.mark.asyncio
@pytest.mark.parametrize(
"active_nm_connection, inactive_nm_connection, expected_state",
[
(
Mock(),
None,
states.Connected, # When there is an active connection the initial state is connected.
),
(
None,
None,
states.Disconnected # When there is not a connection, the initial state is disconnected.
),
(
None,
Mock(),
states.Error # When there is an inactive connection, the initial state is Error.
),
]
)
async def test_initialize_persisted_connection_determines_initial_connection_state(
active_nm_connection, inactive_nm_connection, expected_state
):
nm_client_mock = Mock()
nm_client_mock.get_active_connection.return_value = active_nm_connection
nm_client_mock.get_connection.return_value = inactive_nm_connection
# The VPNConnection constructor calls `_initialize_persisted_connection`
# when `connection_id` is provided.
nm_protocol = LinuxNetworkManagerProtocol(
server=None,
credentials=None,
settings=None,
connection_id="connection_id",
nm_client=nm_client_mock
)
assert isinstance(nm_protocol.initial_state, expected_state)
python-proton-vpn-api-core-4.16.0/tests/networkmanager/killswitch/ 0000775 0000000 0000000 00000000000 15151554407 0025312 5 ustar 00root root 0000000 0000000 python-proton-vpn-api-core-4.16.0/tests/networkmanager/killswitch/__init__.py 0000664 0000000 0000000 00000000000 15151554407 0027411 0 ustar 00root root 0000000 0000000 python-proton-vpn-api-core-4.16.0/tests/networkmanager/killswitch/default/ 0000775 0000000 0000000 00000000000 15151554407 0026736 5 ustar 00root root 0000000 0000000 python-proton-vpn-api-core-4.16.0/tests/networkmanager/killswitch/default/__init__.py 0000664 0000000 0000000 00000000000 15151554407 0031035 0 ustar 00root root 0000000 0000000 python-proton-vpn-api-core-4.16.0/tests/networkmanager/killswitch/default/test_nmkillswitch.py 0000664 0000000 0000000 00000007207 15151554407 0033065 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 ipaddress import ip_network, collapse_addresses
from proton.vpn.backend.networkmanager.killswitch.default import NMKillSwitch
from proton.vpn.backend.networkmanager.killswitch.default.killswitch_connection_handler \
import build_routes_list, LOCAL_AGENT_SERVER_ADDR
@pytest.fixture
def vpn_server():
vpn_server_mock = Mock()
vpn_server_mock.server_ip = "1.1.1.1"
return vpn_server_mock
@pytest.mark.asyncio
async def test_enable_without_vpn_server_adds_full_ks_and_removes_routed_ks():
ks_handler_mock = AsyncMock()
nm_killswitch = NMKillSwitch(ks_handler_mock)
await nm_killswitch.enable()
assert ks_handler_mock.method_calls == [
call.add_full_killswitch_connection(False),
call.remove_routed_killswitch_connection(),
]
@pytest.mark.asyncio
async def test_enable_with_vpn_server(vpn_server):
"""
When enabling the KS specifying a vpn server to connect to we expect:
1) The full KS is added first, to block all network traffic until the routed KS is set up.
2) The routed KS is removed (if found).
2) A new routed KS whitelisting the VPN server IP is added.
4) The full KS is removed to let the routed KS take over.
"""
ks_handler_mock = AsyncMock()
nm_killswitch = NMKillSwitch(ks_handler_mock)
await nm_killswitch.enable(vpn_server)
assert ks_handler_mock.method_calls == [
call.add_full_killswitch_connection(False),
call.remove_routed_killswitch_connection(),
call.add_routed_killswitch_connection(vpn_server.server_ip, False),
call.remove_full_killswitch_connection()
]
@pytest.mark.asyncio
async def test_disable_killswitch_removes_full_and_routed_ks():
ks_handler_mock = AsyncMock()
nm_killswitch = NMKillSwitch(ks_handler_mock)
await nm_killswitch.disable()
assert ks_handler_mock.method_calls == [
call.remove_full_killswitch_connection(),
call.remove_routed_killswitch_connection()
]
@pytest.mark.asyncio
async def test_enable_ipv6_leak_protection_adds_ipv6_ks():
ks_handler_mock = AsyncMock()
nm_killswitch = NMKillSwitch(ks_handler_mock)
await nm_killswitch.enable_ipv6_leak_protection()
assert ks_handler_mock.method_calls == [
call.add_ipv6_leak_protection()
]
@pytest.mark.asyncio
async def test_disable_ipv6_leak_protection_removes_ipv6_ks():
ks_handler_mock = AsyncMock()
nm_killswitch = NMKillSwitch(ks_handler_mock)
await nm_killswitch.disable_ipv6_leak_protection()
assert ks_handler_mock.method_calls == [
call.remove_ipv6_leak_protection()
]
def test_build_routes_list():
vpn_server = "192.168.2.1"
allowed_routes = [
ip_network(LOCAL_AGENT_SERVER_ADDR),
ip_network(vpn_server)
]
forbidden_routes = list(build_routes_list(vpn_server))
all_routes = list(collapse_addresses(allowed_routes + forbidden_routes))
assert all_routes == [ip_network('0.0.0.0/0')]
python-proton-vpn-api-core-4.16.0/tests/networkmanager/killswitch/wireguard/ 0000775 0000000 0000000 00000000000 15151554407 0027303 5 ustar 00root root 0000000 0000000 python-proton-vpn-api-core-4.16.0/tests/networkmanager/killswitch/wireguard/__init__.py 0000664 0000000 0000000 00000000000 15151554407 0031402 0 ustar 00root root 0000000 0000000 python-proton-vpn-api-core-4.16.0/tests/networkmanager/killswitch/wireguard/test_wgkillswitch.py 0000664 0000000 0000000 00000006651 15151554407 0033437 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, PropertyMock, patch
import pytest
from proton.vpn.backend.networkmanager.killswitch.wireguard import WGKillSwitch
from proton.vpn.backend.networkmanager.killswitch.wireguard.killswitch_connection_handler import KillSwitchConnectionHandler
@pytest.fixture
def vpn_server():
vpn_server_mock = Mock()
vpn_server_mock.server_ip = "1.1.1.1"
return vpn_server_mock
@pytest.mark.asyncio
async def test_enable_without_vpn_server_adds_ks_connection():
ks_handler_mock = AsyncMock()
wg_ks = WGKillSwitch(ks_handler_mock)
await wg_ks.enable()
assert ks_handler_mock.method_calls == [
call.add_kill_switch_connection(False)
]
@pytest.mark.asyncio
async def test_enable_with_vpn_server_adds_ks_connection_and_route_for_server(vpn_server):
ks_handler_mock = AsyncMock()
wg_ks = WGKillSwitch(ks_handler_mock)
await wg_ks.enable(vpn_server)
assert ks_handler_mock.method_calls == [
call.add_kill_switch_connection(False),
call.add_vpn_server_route(server_ip=vpn_server.server_ip)
]
@pytest.mark.asyncio
async def test_disable_killswitch_removes_full_and_route_for_server():
ks_handler_mock = AsyncMock()
wg_ks = WGKillSwitch(ks_handler_mock)
await wg_ks.disable()
assert ks_handler_mock.method_calls == [
call.remove_killswitch_connection(),
call.remove_vpn_server_route()
]
@pytest.mark.asyncio
async def test_enable_ipv6_leak_protection_adds_ipv6_ks():
ks_handler_mock = AsyncMock()
wg_ks = WGKillSwitch(ks_handler_mock)
await wg_ks.enable_ipv6_leak_protection()
assert ks_handler_mock.method_calls == [
call.add_ipv6_leak_protection()
]
@pytest.mark.asyncio
async def test_disable_ipv6_leak_protection_removes_ipv6_ks():
ks_handler_mock = AsyncMock()
wg_ks = WGKillSwitch(ks_handler_mock)
await wg_ks.disable_ipv6_leak_protection()
assert ks_handler_mock.method_calls == [
call.remove_ipv6_leak_protection()
]
@pytest.fixture
def monkey_patch_connection_handler():
original = KillSwitchConnectionHandler.is_network_manager_running
KillSwitchConnectionHandler.is_network_manager_running = PropertyMock(return_value=True)
yield KillSwitchConnectionHandler
KillSwitchConnectionHandler.is_network_manager_running = original
@pytest.mark.parametrize("validate_params_dict, assert_bool", [
(None, False),
({}, False),
({"protocol": "openvpn"}, False),
({"protocol": "wireguard"}, True)
])
@patch("proton.vpn.backend.networkmanager.killswitch.wireguard.wgkillswitch.subprocess")
def test_backend_validate(mock_subprocess, validate_params_dict, assert_bool, monkey_patch_connection_handler):
assert WGKillSwitch._validate(validate_params_dict) == assert_bool
python-proton-vpn-api-core-4.16.0/tests/networkmanager/openvpn/ 0000775 0000000 0000000 00000000000 15151554407 0024622 5 ustar 00root root 0000000 0000000 python-proton-vpn-api-core-4.16.0/tests/networkmanager/openvpn/boilerplate.py 0000664 0000000 0000000 00000004637 15151554407 0027510 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 unittest.mock import Mock
from proton.vpn.connection import VPNServer, ProtocolPorts
from proton.vpn.connection.interfaces import (Settings, VPNCredentials,
VPNPubkeyCredentials, VPNServer,
VPNUserPassCredentials)
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"
def get_ed25519_sk_pem(self, password=None):
return "encrypted-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()
@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"
)
python-proton-vpn-api-core-4.16.0/tests/networkmanager/openvpn/test_openvpn.py 0000664 0000000 0000000 00000010413 15151554407 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 unittest.mock import Mock, patch
import gi
gi.require_version("NM", "1.0") # noqa: required before importing NM module
from gi.repository import NM
import pytest
from proton.vpn.backend.networkmanager.protocol.openvpn.openvpn \
import OpenVPN
from proton.vpn.connection import events
from collections import namedtuple
from boilerplate import (MockVpnCredentials, MockSettings, vpn_server)
OpenVPNPorts = namedtuple("OpenVPNPorts", "udp tcp")
@pytest.fixture
def nm_client_mock():
return Mock()
@pytest.mark.asyncio
@pytest.mark.parametrize(
"state, reason, expected_event",
[
(
NM.VpnConnectionState.ACTIVATED,
NM.VpnConnectionStateReason.NONE,
events.Connected
),
(
NM.VpnConnectionState.FAILED,
NM.VpnConnectionStateReason.CONNECT_TIMEOUT,
events.Timeout
),
(
NM.VpnConnectionState.FAILED,
NM.VpnConnectionStateReason.SERVICE_START_TIMEOUT,
events.Timeout
),
(
NM.VpnConnectionState.FAILED,
NM.VpnConnectionStateReason.NO_SECRETS,
events.AuthDenied
),
(
NM.VpnConnectionState.FAILED,
NM.VpnConnectionStateReason.LOGIN_FAILED,
events.AuthDenied
),
(
NM.VpnConnectionState.FAILED,
NM.VpnConnectionStateReason.IP_CONFIG_INVALID,
events.UnexpectedError
),
(
NM.VpnConnectionState.FAILED,
NM.VpnConnectionStateReason.SERVICE_STOPPED,
events.UnexpectedError
),
(
NM.VpnConnectionState.FAILED,
NM.VpnConnectionStateReason.CONNECTION_REMOVED,
events.UnexpectedError
),
(
NM.VpnConnectionState.FAILED,
NM.VpnConnectionStateReason.SERVICE_START_FAILED,
events.UnexpectedError
),
(
NM.VpnConnectionState.FAILED,
NM.VpnConnectionStateReason.UNKNOWN,
events.UnexpectedError
),
(
NM.VpnConnectionState.FAILED,
NM.VpnConnectionStateReason.NONE,
events.UnexpectedError
),
(
NM.VpnConnectionState.DISCONNECTED,
NM.VpnConnectionStateReason.DEVICE_DISCONNECTED,
events.DeviceDisconnected
),
(
NM.VpnConnectionState.DISCONNECTED,
NM.VpnConnectionStateReason.USER_DISCONNECTED,
events.Disconnected
),
(
NM.VpnConnectionState.DISCONNECTED,
NM.VpnConnectionStateReason.NONE,
events.UnexpectedError
),
]
)
@patch("proton.vpn.backend.networkmanager.protocol.openvpn.openvpn.OpenVPN._notify_subscribers_threadsafe")
async def test_on_state_changed(_notify_subscribers_threadsafe, nm_client_mock,
vpn_server, state, reason, expected_event):
_notify_subscribers_threadsafe.return_value = None
nm_protocol = OpenVPN(
vpn_server, MockVpnCredentials(), MockSettings(),
nm_client=nm_client_mock
)
nm_protocol._on_state_changed(None, state, reason)
# assert that the OpenVPN._notify_subscribers method was called with the
# expected event
_notify_subscribers_threadsafe.assert_called_once()
assert isinstance(_notify_subscribers_threadsafe.call_args.args[0],
expected_event)
python-proton-vpn-api-core-4.16.0/tests/networkmanager/openvpn/test_openvpnconfiguration.py 0000664 0000000 0000000 00000005321 15151554407 0032511 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 shutil
import os
import pathlib
import pytest
from unittest.mock import Mock
from proton.vpn.connection import VPNServer, ProtocolPorts
from proton.vpn.connection.vpnconfiguration import (VPNConfiguration)
from proton.vpn.backend.networkmanager.protocol.openvpn.openvpn import OVPNConfig
from proton.vpn.connection.interfaces import (Settings, VPNCredentials,
VPNPubkeyCredentials, VPNServer,
VPNUserPassCredentials, Features)
from proton.vpn.connection import VPNServer, ProtocolPorts
from boilerplate import (MockVpnCredentials, MockSettings, vpn_server)
CWD = str(pathlib.Path(__file__).parent.absolute())
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.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())
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
python-proton-vpn-api-core-4.16.0/tests/networkmanager/wireguard/ 0000775 0000000 0000000 00000000000 15151554407 0025126 5 ustar 00root root 0000000 0000000 python-proton-vpn-api-core-4.16.0/tests/networkmanager/wireguard/__init__.py 0000664 0000000 0000000 00000000000 15151554407 0027225 0 ustar 00root root 0000000 0000000 python-proton-vpn-api-core-4.16.0/tests/networkmanager/wireguard/test_wireguard.py 0000664 0000000 0000000 00000001234 15151554407 0030530 0 ustar 00root root 0000000 0000000 import os
import pytest
from proton.vpn.backend.networkmanager.protocol.wireguard.wireguard import FWMARK_ENV_VAR, get_fwmark_from_env_var
def test_get_fwmark_from_env_var_returns_env_var_value_if_available_and_valid():
os.environ[FWMARK_ENV_VAR] = "51821"
assert get_fwmark_from_env_var() == 51821
@pytest.mark.parametrize(
"env_var_value", [
"51820", # too small
str(2**32), # too big
"#!!?" # invalid int,
]
)
def test_get_fwmark_from_env_var_returns_None_if_env_var_contains_invalid_value(env_var_value):
os.environ[FWMARK_ENV_VAR] = env_var_value
assert get_fwmark_from_env_var() is None python-proton-vpn-api-core-4.16.0/tests/session/ 0000775 0000000 0000000 00000000000 15151554407 0021574 5 ustar 00root root 0000000 0000000 python-proton-vpn-api-core-4.16.0/tests/session/__init__.py 0000664 0000000 0000000 00000001246 15151554407 0023710 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-4.16.0/tests/session/data/ 0000775 0000000 0000000 00000000000 15151554407 0022505 5 ustar 00root root 0000000 0000000 python-proton-vpn-api-core-4.16.0/tests/session/data/README.md 0000664 0000000 0000000 00000000363 15151554407 0023766 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-4.16.0/tests/session/data/api_cert_response.json 0000664 0000000 0000000 00000002607 15151554407 0027111 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-4.16.0/tests/session/data/api_vpn_location_response.json 0000664 0000000 0000000 00000000226 15151554407 0030642 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-4.16.0/tests/session/data/api_vpnsessions_response.json 0000664 0000000 0000000 00000000526 15151554407 0030544 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-4.16.0/tests/session/data/api_vpnsettings_response.json 0000664 0000000 0000000 00000001033 15151554407 0030530 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-4.16.0/tests/session/data/vpn_secrets.json 0000664 0000000 0000000 00000000115 15151554407 0025730 0 ustar 00root root 0000000 0000000 {
"ed25519_privatekey" : "rNW3dL5A3dUrQX3ZKbVAFLjSFJdvDU5JzjrRrnI+cos="
} python-proton-vpn-api-core-4.16.0/tests/session/dataclasses/ 0000775 0000000 0000000 00000000000 15151554407 0024063 5 ustar 00root root 0000000 0000000 python-proton-vpn-api-core-4.16.0/tests/session/dataclasses/__init__.py 0000664 0000000 0000000 00000001246 15151554407 0026177 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-4.16.0/tests/session/dataclasses/test_certificate.py 0000664 0000000 0000000 00000003153 15151554407 0027760 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-4.16.0/tests/session/dataclasses/test_country.py 0000664 0000000 0000000 00000026326 15151554407 0027210 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.types import LogicalServer, ServerFeatureEnum
from proton.vpn.session.dataclasses.servers.country import City, Country, ServerAnalysis
COUNTRY_CODE = "AR"
COUNTRY_NAME = "Argentina"
CITIES = ["Buenos Aires", "Rosario"]
ROSARIO_CITY_FEATURES = {
ServerFeatureEnum.P2P, ServerFeatureEnum.STREAMING, ServerFeatureEnum.IPV6
}
@pytest.fixture()
def servers_raw() -> list[dict]:
return [
{
"ID": 2,
"Name": "AR#11",
"Status": 1,
"Servers": [{"Status": 1}],
"Score": 1.0, # Even though it has a better score than CH#9,
"Tier": 2, # it's not in the user tier (2).
"ExitCountry": "AR",
"City": "Buenos Aires",
"Features": ServerFeatureEnum.P2P | ServerFeatureEnum.STREAMING
},
{
"ID": 3,
"Name": "AR#13",
"Status": 1,
"Servers": [{"Status": 1}],
"Score": 2.0, # Even though it has a better score than CH#9,
"Tier": 2, # it's not in the user tier (2).
"ExitCountry": "AR",
"City": "Rosario",
"Features": ServerFeatureEnum.P2P | ServerFeatureEnum.STREAMING | ServerFeatureEnum.IPV6
},
{
"ID": 4,
"Name": "AR#14",
"Status": 1,
"Servers": [{"Status": 1}],
"Score": 2.0, # Even though it has a better score than CH#9,
"Tier": 2, # it's not in the user tier (2).
"ExitCountry": "AR",
"City": "Rosario",
"Features": ServerFeatureEnum.P2P
}
]
@pytest.fixture()
def non_free_logical_servers(servers_raw) -> list[LogicalServer]:
return [
LogicalServer(servers_raw[0]),
LogicalServer(servers_raw[1]),
LogicalServer(servers_raw[2])
]
@pytest.fixture()
def free_logical_servers(servers_raw) -> list[LogicalServer]:
servers_raw[0]["Tier"] = 0
servers_raw[1]["Tier"] = 0
servers_raw[2]["Tier"] = 0
return [
LogicalServer(servers_raw[0]),
LogicalServer(servers_raw[1]),
LogicalServer(servers_raw[2])
]
@pytest.fixture()
def mixed_free_and_non_logical_servers(servers_raw) -> list[LogicalServer]:
servers_raw[1]["Tier"] = 0
return [
LogicalServer(servers_raw[0]),
LogicalServer(servers_raw[1]),
LogicalServer(servers_raw[2])
]
class TestCountry:
def test_name_is_correctly_returned_when_passing_country_code(self):
country = Country(COUNTRY_CODE, [])
assert country.name == COUNTRY_NAME
def test_cities_are_grouped(self, non_free_logical_servers):
country = Country(COUNTRY_CODE, non_free_logical_servers)
cities = country.cities
assert cities[0].name == CITIES[0]
assert cities[1].name == CITIES[1]
assert len(cities) == len(CITIES)
class TestServerAnalysis:
def test_analyze_servers_returns_under_maintenance_true_when_all_servers_are_under_maintenance(self):
# Create servers that are all under maintenance (Status=0 or no enabled physical servers)
servers_data = [
{
"ID": 1,
"Name": "AR#1",
"Status": 0, # Logical server disabled
"Servers": [{"Status": 0}], # Physical server disabled
"Tier": 2,
"ExitCountry": "AR",
"City": "Buenos Aires",
"Features": 0
},
{
"ID": 2,
"Name": "AR#2",
"Status": 0,
"Servers": [{"Status": 0}],
"Tier": 2,
"ExitCountry": "AR",
"City": "Buenos Aires",
"Features": 0
}
]
servers = [LogicalServer(data) for data in servers_data]
analysis = ServerAnalysis.analyze_servers(servers)
assert analysis.under_maintenance is True
def test_analyze_servers_returns_under_maintenance_false_when_some_servers_are_not_under_maintenance(self):
# Create servers where at least one is enabled (not under maintenance)
servers_data = [
{
"ID": 1,
"Name": "AR#1",
"Status": 0, # Logical server disabled
"Servers": [{"Status": 0}], # Physical server disabled
"Tier": 2,
"ExitCountry": "AR",
"City": "Buenos Aires",
"Features": 0
},
{
"ID": 2,
"Name": "AR#2",
"Status": 1, # Logical server enabled
"Servers": [{"Status": 1}], # Physical server enabled
"Tier": 2,
"ExitCountry": "AR",
"City": "Buenos Aires",
"Features": 0
}
]
servers = [LogicalServer(data) for data in servers_data]
analysis = ServerAnalysis.analyze_servers(servers)
assert analysis.under_maintenance is False
def test_analyze_servers_returns_smart_routing_when_any_server_has_smart_routing(self):
# Note: Based on implementation, smart_routing is True only when ALL servers have smart_routing
# The test name says "any" but the logic requires "all"
servers_data = [
{
"ID": 1,
"Name": "AR#1",
"Status": 1,
"Servers": [{"Status": 1}],
"Tier": 2,
"ExitCountry": "AR",
"City": "Buenos Aires",
"Features": 0,
"HostCountry": "US" # Smart routing enabled
},
{
"ID": 2,
"Name": "AR#2",
"Status": 1,
"Servers": [{"Status": 1}],
"Tier": 2,
"ExitCountry": "AR",
"City": "Buenos Aires",
"Features": 0,
"HostCountry": "US" # Smart routing enabled
}
]
servers = [LogicalServer(data) for data in servers_data]
analysis = ServerAnalysis.analyze_servers(servers)
assert analysis.smart_routing is True
def test_analyze_servers_returns_smart_routing_false_when_not_all_servers_have_smart_routing(self):
# Create servers where at least one doesn't have smart routing
servers_data = [
{
"ID": 1,
"Name": "AR#1",
"Status": 1,
"Servers": [{"Status": 1}],
"Tier": 2,
"ExitCountry": "AR",
"City": "Buenos Aires",
"Features": 0,
"HostCountry": "US" # Smart routing enabled
},
{
"ID": 2,
"Name": "AR#2",
"Status": 1,
"Servers": [{"Status": 1}],
"Tier": 2,
"ExitCountry": "AR",
"City": "Buenos Aires",
"Features": 0
# No HostCountry - smart routing not enabled
}
]
servers = [LogicalServer(data) for data in servers_data]
analysis = ServerAnalysis.analyze_servers(servers)
assert analysis.smart_routing is False
def test_analyze_servers_returns_free_when_any_server_is_free(self):
servers_data = [
{
"ID": 1,
"Name": "AR#1",
"Status": 1,
"Servers": [{"Status": 1}],
"Tier": 2, # Not free
"ExitCountry": "AR",
"City": "Buenos Aires",
"Features": 0
},
{
"ID": 2,
"Name": "AR#2",
"Status": 1,
"Servers": [{"Status": 1}],
"Tier": 0, # Free tier
"ExitCountry": "AR",
"City": "Buenos Aires",
"Features": 0
}
]
servers = [LogicalServer(data) for data in servers_data]
analysis = ServerAnalysis.analyze_servers(servers)
assert analysis.free is True
def test_analyze_servers_returns_free_false_when_no_servers_are_free(self):
# Create servers where none are free (all have tier > 0)
servers_data = [
{
"ID": 1,
"Name": "AR#1",
"Status": 1,
"Servers": [{"Status": 1}],
"Tier": 2, # Not free
"ExitCountry": "AR",
"City": "Buenos Aires",
"Features": 0
},
{
"ID": 2,
"Name": "AR#2",
"Status": 1,
"Servers": [{"Status": 1}],
"Tier": 2, # Not free
"ExitCountry": "AR",
"City": "Buenos Aires",
"Features": 0
}
]
servers = [LogicalServer(data) for data in servers_data]
analysis = ServerAnalysis.analyze_servers(servers)
assert analysis.free is False
def test_analyze_servers_groups_features_from_all_servers(self):
servers_data = [
{
"ID": 1,
"Name": "AR#1",
"Status": 1,
"Servers": [{"Status": 1}],
"Tier": 2,
"ExitCountry": "AR",
"City": "Buenos Aires",
"Features": ServerFeatureEnum.P2P | ServerFeatureEnum.STREAMING
},
{
"ID": 2,
"Name": "AR#2",
"Status": 1,
"Servers": [{"Status": 1}],
"Tier": 2,
"ExitCountry": "AR",
"City": "Rosario",
"Features": ServerFeatureEnum.IPV6 | ServerFeatureEnum.TOR
},
{
"ID": 3,
"Name": "AR#3",
"Status": 1,
"Servers": [{"Status": 1}],
"Tier": 2,
"ExitCountry": "AR",
"City": "Rosario",
"Features": ServerFeatureEnum.P2P # Overlapping feature
}
]
servers = [LogicalServer(data) for data in servers_data]
analysis = ServerAnalysis.analyze_servers(servers)
# Features should be a set containing all unique features from all servers
expected_features = {
ServerFeatureEnum.P2P,
ServerFeatureEnum.STREAMING,
ServerFeatureEnum.IPV6,
ServerFeatureEnum.TOR
}
assert analysis.features == expected_features
python-proton-vpn-api-core-4.16.0/tests/session/dataclasses/test_location.py 0000664 0000000 0000000 00000002541 15151554407 0027306 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",
"Long": 7.4474,
"Lat": 46.9480
}
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-4.16.0/tests/session/dataclasses/test_session.py 0000664 0000000 0000000 00000004333 15151554407 0027162 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-4.16.0/tests/session/dataclasses/test_settings.py 0000664 0000000 0000000 00000004231 15151554407 0027334 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-4.16.0/tests/session/servers/ 0000775 0000000 0000000 00000000000 15151554407 0023265 5 ustar 00root root 0000000 0000000 python-proton-vpn-api-core-4.16.0/tests/session/servers/__init__.py 0000664 0000000 0000000 00000001246 15151554407 0025401 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-4.16.0/tests/session/servers/test_fetcher.py 0000664 0000000 0000000 00000002017 15151554407 0026316 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.server_list_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-4.16.0/tests/session/servers/test_logicals.py 0000664 0000000 0000000 00000033003 15151554407 0026472 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 functools
from typing import List
import pytest
from unittest.mock import Mock
from proton.vpn.session.servers import LogicalServer, ServerFeatureEnum
from proton.vpn.session.servers.logicals import (
sort_servers_alphabetically_by_country_and_server_name,
sort_servers_by_country_and_city_and_enabled_and_load,
ServerList
)
def _compact_features(features: List[ServerFeatureEnum]) -> ServerFeatureEnum:
return functools.reduce(lambda f1, f2: f1 | f2, features, 0)
@pytest.fixture(name="api_response")
def fixture_api_response() -> str:
return {
"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",
"City": "Tokyo",
"Features": ServerFeatureEnum.P2P | ServerFeatureEnum.STREAMING
},
{
"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",
"City": "Buenos Aires",
"Features": ServerFeatureEnum.P2P | ServerFeatureEnum.STREAMING
},
{
"ID": 3,
"Name": "AR#13",
"Status": 1,
"Servers": [{"Status": 1}],
"Score": 2.0, # Even though it has a better score than CH#9,
"Tier": 3, # it's not in the user tier (2).
"ExitCountry": "AR",
"City": "Rosario",
"Features": ServerFeatureEnum.P2P | ServerFeatureEnum.STREAMING | ServerFeatureEnum.IPV6
},
{
"ID": 4,
"Name": "AR#14",
"Status": 1,
"Servers": [{"Status": 1}],
"Score": 3.0, # Even though it has a better score than CH#9,
"Tier": 3, # it's not in the user tier (2).
"ExitCountry": "AR",
"City": "Rosario",
"Features": ServerFeatureEnum.P2P | ServerFeatureEnum.IPV6
},
{
"ID": 5,
"Name": "AR#9",
"Status": 1,
"Servers": [{"Status": 1}],
"Score": 10.0, # Fastest server in the user tier (2)
"Tier": 2,
"ExitCountry": "AR",
"City": "Rosario"
},
{
"ID": 6,
"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",
"City": "Wuhan"
},
{
"ID": 7,
"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",
"City": "Beijing"
},
{
"ID": 8,
"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",
"City": "Osaka",
},
]
}
def test_server_list_get_fastest(api_response: str):
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_server_list_get_fastest_in_country(api_response: str):
server_list = ServerList(
user_tier=3,
logicals=[LogicalServer(ls) for ls in api_response["LogicalServers"]]
)
fastest = server_list.get_fastest_in_country("AR")
assert fastest.name == "AR#11"
def test_server_list_get_fastest_in_city(api_response: str):
server_list = ServerList(
user_tier=3,
logicals=[LogicalServer(ls) for ls in api_response["LogicalServers"]]
)
fastest = server_list.get_fastest_in_city("Rosario")
assert fastest.name == "AR#13"
def test_server_list_get_available_servers(api_response: str):
server_list = ServerList(
user_tier=2,
logicals=[LogicalServer(ls) for ls in api_response["LogicalServers"]]
)
available_servers_generator = \
ServerList.get_available_servers(server_list.logicals, user_tier=2)
for server in available_servers_generator:
assert server.tier <= 2 and server.enabled
@pytest.mark.parametrize("features_requested, features_excluded", [
(ServerFeatureEnum.P2P, 0),
(ServerFeatureEnum.IPV6 | ServerFeatureEnum.STREAMING, 0),
(ServerFeatureEnum.P2P, ServerFeatureEnum.TOR | ServerFeatureEnum.IPV6),
(0, 0),
(ServerFeatureEnum.TOR, ServerFeatureEnum.TOR),
])
def test_server_list_get_servers_with_features(
api_response: str,
features_requested: ServerFeatureEnum,
features_excluded: ServerFeatureEnum
):
server_list = ServerList(
user_tier=2,
logicals=[LogicalServer(ls) for ls in api_response["LogicalServers"]]
)
servers_with_features_generator = \
ServerList.get_servers_with_features(
server_list.logicals,
features_requested,
features_excluded
)
servers_with_features = list(servers_with_features_generator)
# test for empty server list when requested and excluded features have an intersection
if features_requested & features_excluded != 0:
assert len(servers_with_features) == 0
for server in servers_with_features:
assert _compact_features(server.features) & features_excluded == 0 \
and _compact_features(server.features) & features_requested == features_requested
def test_server_list_get_country_servers(api_response: str):
server_list = ServerList(
user_tier=2,
logicals=[LogicalServer(ls) for ls in api_response["LogicalServers"]]
)
country_servers_generator = \
ServerList.get_servers_in_city(server_list.logicals, "AR")
for server in country_servers_generator:
assert server.exit_country == "AR"
def test_server_list_get_city_servers(api_response: str):
server_list = ServerList(
user_tier=2,
logicals=[LogicalServer(ls) for ls in api_response["LogicalServers"]]
)
city_servers_generator = \
ServerList.get_servers_in_country_code(server_list.logicals, "rosario")
for server in city_servers_generator:
assert server.city == "rosario"
def test_server_list_get_fastest_server(api_response: str):
server_list = ServerList(
user_tier=2,
logicals=[LogicalServer(ls) for ls in api_response["LogicalServers"]]
)
fastest_server = ServerList.get_fastest_server(server_list.logicals)
assert fastest_server.name == "AR#11"
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
def test_sort_servers_by_country_and_city_and_enabled_and_load():
api_response = {
"Code": 1000,
"LogicalServers": [
{
"ID": 1,
"Name": "AR#10",
"Status": 1,
"Servers": [{"Status": 1}],
"Load": 50,
"ExitCountry": "AR",
"City": "Buenos Aires",
},
{
"ID": 2,
"Name": "AR#9",
"Status": 1,
"Servers": [{"Status": 1}],
"Load": 30, # Lower load, should come before AR#10 in same city
"ExitCountry": "AR",
"City": "Buenos Aires",
},
{
"ID": 3,
"Name": "AR#5",
"Status": 0, # Disabled, should come after enabled servers
"Servers": [{"Status": 0}],
"Load": 20,
"ExitCountry": "AR",
"City": "Buenos Aires",
},
{
"ID": 4,
"Name": "AR#15",
"Status": 1,
"Servers": [{"Status": 1}],
"Load": 20, # Lowest load in Rosario
"ExitCountry": "AR",
"City": "Rosario",
},
{
"ID": 5,
"Name": "JP#9",
"Status": 1,
"Servers": [{"Status": 1}],
"Load": 40,
"ExitCountry": "JP",
"City": "Tokyo",
},
{
"ID": 6,
"Name": "JP#1",
"Status": 1,
"Servers": [{"Status": 1}],
"Load": 25, # Lower load than JP#9
"ExitCountry": "JP",
"City": "Tokyo",
},
{
"ID": 7,
"Name": "JP#10",
"Status": 0, # Disabled
"Servers": [{"Status": 0}],
"Load": 10, # Even lower load, but disabled
"ExitCountry": "JP",
"City": "Tokyo",
},
{
"ID": 8,
"Name": "JP#5",
"Status": 1,
"Servers": [{"Status": 1}],
"Load": 35,
"ExitCountry": "JP",
"City": "Osaka", # Different city, should come after Tokyo
},
]
}
logicals = [LogicalServer(server_dict) for server_dict in api_response["LogicalServers"]]
logicals.sort(key=sort_servers_by_country_and_city_and_enabled_and_load)
# Expected order:
# Enabled servers should come first (True sorts after False, but the function should invert this)
# 1. Argentina (AR) - Buenos Aires - Enabled first (by load: 30 < 50), then disabled
# - AR#9 (enabled, load 30)
# - AR#10 (enabled, load 50)
# - AR#5 (disabled, load 20)
# 2. Argentina (AR) - Rosario
# - AR#15 (enabled, load 20)
# 3. Japan (JP) - Osaka
# - JP#5 (enabled, load 35)
# 4. Japan (JP) - Tokyo - Enabled first (by load: 25 < 40), then disabled
# - JP#1 (enabled, load 25)
# - JP#9 (enabled, load 40)
# - JP#10 (disabled, load 10)
expected_server_name_order = [
"AR#9", # Argentina, Buenos Aires, enabled, load 30
"AR#10", # Argentina, Buenos Aires, enabled, load 50
"AR#5", # Argentina, Buenos Aires, disabled, load 20
"AR#15", # Argentina, Rosario, enabled, load 20
"JP#5", # Japan, Osaka, enabled, load 35
"JP#1", # Japan, Tokyo, enabled, load 25
"JP#9", # Japan, Tokyo, enabled, load 40
"JP#10", # Japan, Tokyo, disabled, load 10
]
actual_server_name_order = [server.name for server in logicals]
assert actual_server_name_order == expected_server_name_order
python-proton-vpn-api-core-4.16.0/tests/session/servers/test_types.py 0000664 0000000 0000000 00000011164 15151554407 0026045 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-4.16.0/tests/session/test_clientconfig.py 0000664 0000000 0000000 00000006117 15151554407 0025656 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-4.16.0/tests/session/test_feature_flags_fetcher.py 0000664 0000000 0000000 00000010341 15151554407 0027513 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, FeatureFlags
EXPIRATION_TIME = time.time()
@pytest.fixture
def api_data():
TEST_DATA = {
"Code": 1000,
"toggles": [
{
"name": "EnabledInApiDisabledInDefault",
"enabled": True,
"impressionData": False,
"variant": {
"name": "disabled",
"enabled": False
}
},
{
"name": "DisabledInApiEnabledInDefault",
"enabled": False,
"impressionData": False,
"variant": {
"name": "disabled",
"enabled": False
}
},
]
}
return TEST_DATA
@pytest.fixture
def default_data():
TEST_DATA = {
"toggles": [
{
"name": "EnabledInApiDisabledInDefault",
"enabled": False,
"impressionData": False,
"variant": {
"name": "disabled",
"enabled": False
}
},
{
"name": "DisabledInApiEnabledInDefault",
"enabled": True,
"impressionData": False,
"variant": {
"name": "disabled",
"enabled": False
}
}
]
}
return TEST_DATA
@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, api_data):
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 = api_data
ff = FeatureFlagsFetcher(Mock(), mock_refresh_calculator, mock_cache_handler)
features = await ff.fetch()
assert features.get("EnabledInApiDisabledInDefault") == True
assert features.get("DisabledInApiEnabledInDefault") == False
def test_load_from_cache_returns_feature_flags_from_cache(api_data):
mock_cache_handler = Mock()
expiration_time_in_seconds = time.time()
api_data["ExpirationTime"] = expiration_time_in_seconds
mock_cache_handler.load.return_value = api_data
ff = FeatureFlagsFetcher(Mock(), Mock(), mock_cache_handler)
features = ff.load_from_cache()
assert features.get("EnabledInApiDisabledInDefault") == True
assert features.get("DisabledInApiEnabledInDefault") == False
@patch('proton.vpn.session.feature_flags_fetcher.FeatureFlags.default')
def test_load_from_cache_returns_default_feature_flags_when_no_cache_is_found(feature_flags_mock,default_data):
feature_flags_mock.return_value = FeatureFlags(default_data)
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("EnabledInApiDisabledInDefault") == False
assert features.get("DisabledInApiEnabledInDefault") == True
def test_get_feature_flag_returns_false_when_feature_flag_does_not_exist(api_data):
mock_cache_handler = Mock()
mock_cache_handler.load.return_value = api_data
ff = FeatureFlagsFetcher(Mock(), Mock(), mock_cache_handler)
features = ff.load_from_cache()
assert features.get("dummy-feature") is False
python-proton-vpn-api-core-4.16.0/tests/session/test_fetcher.py 0000664 0000000 0000000 00000002574 15151554407 0024635 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 SplitTunnelingConfig
from proton.vpn.core.settings.features import Features
def test_extract_features():
actual = VPNSessionFetcher._convert_features(
Features(
netshield=2,
moderate_nat=False,
vpn_accelerator=False,
port_forwarding=True,
split_tunneling=SplitTunnelingConfig()
)
)
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-4.16.0/tests/session/test_session.py 0000664 0000000 0000000 00000010322 15151554407 0024666 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-4.16.0/tests/session/test_utils.py 0000664 0000000 0000000 00000001630 15151554407 0024345 0 ustar 00root root 0000000 0000000 import pytest
from proton.vpn.session.utils import to_semver_build_metadata_format, semver_from_pep440
@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
@pytest.mark.parametrize("pep440_version, expected_semver_version", [
("1.2.3", "1.2.3"),
("1.2.3a4", "1.2.3-alpha.4"),
("1.2.3b4", "1.2.3-beta.4"),
("1.2.3rc4", "1.2.3-rc.4"),
("1.2.3a4.dev5+abc", "1.2.3-alpha.4-dev.5+abc")
])
def test_from_pep440(pep440_version, expected_semver_version):
result = semver_from_pep440(pep440_version)
assert result == expected_semver_version
python-proton-vpn-api-core-4.16.0/tests/session/test_vpnaccount.py 0000664 0000000 0000000 00000016451 15151554407 0025374 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):
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)
python-proton-vpn-api-core-4.16.0/versions.yml 0000664 0000000 0000000 00000107406 15151554407 0021352 0 ustar 00root root 0000000 0000000 version: 4.16.0
time: 2026/02/27 10:10
author: Pep Llaneras
email: josep.llaneras@proton.ch
urgency: low
stability: unstable
description:
- "feat: distinguish secure core servers"
---
version: 4.15.2
time: 2026/02/17 10:43
author: Elena Svilpe
email: elena.svilpe@proton.ch
urgency: low
stability: unstable
description:
- "fix: fix app version break clause"
---
version: 4.15.1
time: 2026/02/10 12:01
author: Elena Svilpe
email: elena.svilpe@proton.ch
urgency: low
stability: unstable
description:
- "chore: feature flag cleanup"
---
version: 4.15.0
time: 2025/12/30 14:00
author: Pep Llaneras
email: josep.llaneras@proton.ch
urgency: low
stability: unstable
description:
- "feat: Preprocess country/city features"
---
version: 4.14.3
time: 2025/12/12 13:45
author: Alexandru Cheltuitor
email: alexandru.cheltuitor@proton.ch
urgency: low
stability: unstable
description:
- "feat: add cities to country dataclass"
---
version: 4.14.2
time: 2025/12/09 09:43
author: Richard Paterson
email: richard.paterson@proton.ch
urgency: low
stability: unstable
description:
- "feat: Add composable filters to ServerList"
---
version: 4.14.1
time: 2025/12/03 13:34
author: Luke Titley
email: luke.titley@proton.ch
urgency: low
stability: unstable
description:
- "chore: Only allow v2 endpoint if params exist"
---
version: 4.14.0
time: 2025/11/17 16:18
author: Pep Llaneras
email: josep.llaneras@proton.ch
urgency: low
stability: unstable
description:
- "feat: merge network manager backend"
---
version: 4.13.2
time: 2025/11/04 10:27
author: Richard Paterson
email: richard.paterson@proton.ch
urgency: low
stability: unstable
description:
- "feat: Ensure server specification is case insensitive"
---
version: 4.13.1
time: 2025/10/29 16:57
author: Pep Llaneras
email: josep.llaneras@proton.ch
urgency: low
stability: unstable
description:
- "fix: Fix fedora 43 upgrade process"
---
version: 4.13.0
time: 2025/10/27 10:13
author: Luke Titley
email: luke.titley@proton.ch
urgency: low
stability: unstable
description:
- "feat: Added 'supports_fido2 method to api'"
---
version: 4.12.2
time: 2025/10/23 13:08
author: Richard Paterson
email: richard.paterson@proton.ch
urgency: low
stability: unstable
description:
- "feat: Add test from pep440 to semver conversion"
---
version: 4.12.1
time: 2025/10/23 10:40
author: Pep Llaneras
email: josep.llaneras@proton.ch
urgency: low
stability: unstable
description:
- |-
chore: change versioning strategy to support the CLI
This package has taken over responsibility for the api versioning logic
(previously in the application). This necessitates bumping the version to match
what was previously sent (by the application).
---
version: 0.48.2
time: 2025/10/22 15:33
author: Luke Titley
email: luke.titley@proton.ch
urgency: low
stability: unstable
description:
- "fix: [VPNLINUX-1415] Support for Fedora 43, fix for debian 11/12"
---
version: 0.48.1
time: 2025/10/15 16:21
author: Luke Titley
email: luke.titley@proton.ch
urgency: low
stability: unstable
description:
- "fix: [VPNLINUX-1415] Support for Fedora 43"
---
version: 0.48.0
time: 2025/09/24 14:05
author: Pep Llaneras
email: josep.llaneras@proton.ch
urgency: low
stability: unstable
description:
- "feat: handle U2F key PIN"
---
version: 0.47.1
time: 2025/09/24 13:00
author: Alexandru Cheltuitor
email: alexandru.cheltuitor@proton.ch
urgency: low
stability: unstable
description:
- "fix: update spec file."
---
version: 0.47.0
time: 2025/09/24 10:54
author: Alexandru Cheltuitor
email: alexandru.cheltuitor@proton.ch
urgency: low
stability: unstable
description:
- "fix: remove proton-vpn-lib dependency"
---
version: 0.46.3
time: 2025/09/24 07:55
author: Luke Titley
email: luke.titley@proton.ch
urgency: low
stability: unstable
description:
- "fix: Dont error twice with 2fa hard-jailing"
---
version: 0.46.2
time: 2025/09/17 16:14
author: Luke Titley
email: luke.titley@proton.ch
urgency: low
stability: unstable
description:
- "feat: Support 2fa hard-jailing"
---
version: 0.46.1
time: 2025/09/15 15:00
author: Alexandru Cheltuitor
email: alexandru.cheltuitor@proton.ch
urgency: low
stability: unstable
description:
- "fix: ensure dependency breaks b0 versions of the app"
---
version: 0.46.0
time: 2025/09/11 14:48
author: Pep Llaneras
email: josep.llaneras@proton.ch
urgency: low
stability: unstable
description:
- "feat: add security key support for 2FA"
---
version: 0.45.14
time: 2025/09/09 09:24
author: Richard Paterson
email: richard.paterson@proton.ch
urgency: low
stability: unstable
description:
- "feat: Add fastest server in city"
---
version: 0.45.13
time: 2025/09/03 16:35
author: Luke Titley
email: luke.titley@proton.ch
urgency: low
stability: unstable
description:
- "fix: Replace .config with .get_config"
---
version: 0.45.12
time: 2025/08/29 12:02
author: Richard Paterson
email: richard.paterson@proton.ch
urgency: low
stability: unstable
description:
- "feat: add synchronous path to updating client config, feature flags and certificate caches for CLI"
---
version: 0.45.11
time: 2025/08/14 12:39
author: Alexandru Cheltuitor
email: alexandru.cheltuitor@proton.ch
urgency: low
stability: unstable
description:
- "fix: ensure default data is loaded if dict value is invalid"
---
version: 0.45.10
time: 2025/08/14 10:51
author: Richard Paterson
email: richard.paterson@proton.ch
urgency: low
stability: unstable
description:
- "feat: add synchronous path to updating serverlist cache for CLI"
---
version: 0.45.9
time: 2025/08/13 11:15
author: Alexandru Cheltuitor
email: alexandru.cheltuitor@proton.ch
urgency: low
stability: unstable
description:
- "fix: add properties for easier access"
---
version: 0.45.8
time: 2025/08/08 12:20
author: Alexandru Cheltuitor
email: alexandru.cheltuitor@proton.ch
urgency: low
stability: unstable
description:
- "fix: fix logic when loading st persisted settings"
---
version: 0.45.7
time: 2025/08/07 15:13
author: Alexandru Cheltuitor
email: alexandru.cheltuitor@proton.ch
urgency: low
stability: unstable
description:
- "fix: persist st settings per mode"
---
version: 0.45.6
time: 2025/08/06 11:51
author: Alexandru Cheltuitor
email: alexandru.cheltuitor@proton.ch
urgency: low
stability: unstable
description:
- "Update version breaks clause"
---
version: 0.45.5
time: 2025/08/05 10:04
author: Alexandru Cheltuitor
email: alexandru.cheltuitor@proton.ch
urgency: low
stability: unstable
description:
- "fix: fix pf not being properly written to file"
---
version: 0.45.4
time: 2025/07/31 09:44
author: Pep Llaneras
email: josep.llaneras@proton.ch
urgency: low
stability: unstable
description:
- "fix: treat split tunneling errors as non-fatal"
---
version: 0.45.3
time: 2025/07/09 15:42
author: Luke Titley
email: luke.titley@proton.ch
urgency: low
stability: unstable
description:
- "fix: Always return a default set of FeatureFlags if none have been got yet"
---
version: 0.45.2
time: 2025/07/08 10:03
author: Pep Llaneras
email: josep.llaneras@proton.ch
urgency: low
stability: unstable
description:
- "fix: enable split tunneling if FF is enabled and tier is not free"
---
version: 0.45.1
time: 2025/07/07 10:11
author: Pep Llaneras
email: josep.llaneras@proton.ch
urgency: low
stability: unstable
description:
- "fix: do not nullify connection features"
---
version: 0.45.0
time: 2025/07/03 15:00
author: Josep Llaneras
email: josep.llaneras@proton.ch
urgency: low
stability: unstable
description:
- "feat: add split tunneling feature"
---
version: 0.44.5
time: 2025/06/16 15:54
author: Alexandru Cheltuitor
email: alexandru.cheltuitor@proton.ch
urgency: low
stability: unstable
description:
- "fix: correct bug in settings generation from dict"
---
version: 0.44.4
time: 2025/06/16 09:22
author: Luke Titley
email: luke.titley@proton.ch
urgency: low
stability: unstable
description:
- "fix: location Lat and Long can be None (for now)"
---
version: 0.44.3
time: 2025/06/13 11:00
author: Alexandru Cheltuitor
email: alexandru.cheltuitor@proton.ch
urgency: low
stability: unstable
description:
- Update settings data structure.
---
version: 0.44.2
time: 2025/06/12 13:00
author: Alexandru Cheltuitor
email: alexandru.cheltuitor@proton.ch
urgency: low
stability: unstable
description:
- Fix semgrep warning.
---
version: 0.44.1
time: 2025/06/12 12:38
author: Luke Titley
email: luke.titley@proton.ch
urgency: low
stability: unstable
description:
- "fix: Fixes to issues reported by qa"
---
version: 0.44.0
time: 2025/06/12 10:00
author: Alexandru Cheltuitor
email: alexandru.cheltuitor@proton.ch
urgency: low
stability: unstable
description:
- Expose split tunneling API.
---
version: 0.43.0
time: 2025/06/11 16:17
author: Luke Titley
email: luke.titley@proton.ch
urgency: low
stability: unstable
description:
- Integration of v2/status endpoint
---
version: 0.42.5
time: 2025/04/30 14:16
author: Luke Titley
email: luke.titley@proton.ch
urgency: low
stability: unstable
description:
- Filter out email addresses and gpg id
---
version: 0.42.4
time: 2025/04/04 13:00
author: Alexandru Cheltuitor
email: alexandru.cheltuitor@proton.ch
urgency: low
stability: unstable
description:
- Swallow and re-schedule server refresh when we receive HTTP code 429.
---
version: 0.42.3
time: 2025/02/24 12:00
author: Alexandru Cheltuitor
email: alexandru.cheltuitor@proton.ch
urgency: low
stability: unstable
description:
- Expose method to get DNS IPs based on provided IP version.
---
version: 0.42.2
time: 2025/02/20 10:41
author: Pep Llaneras
email: josep.llaneras@proton.ch
urgency: low
stability: unstable
description:
- Always load feature flag cache/defaults
---
version: 0.42.1
time: 2025/02/17 14:50
author: Luke Titley
email: luke.titley@proton.ch
urgency: low
stability: unstable
description:
- Add a breaks/conflicts clause for python3-proton-vpn-network-manager < 0.12.10
---
version: 0.42.0
time: 2025/02/14 16:17
author: Luke Titley
email: luke.titley@proton.ch
urgency: low
stability: unstable
description:
- Fix Certificate Expired error when requesting certificate in pem format.
---
version: 0.41.5
time: 2025/02/07 14:46
author: Luke Titley
email: luke.titley@proton.ch
urgency: low
stability: unstable
description:
- Switch DisplayPortForwarding env var to Feature Flag
---
version: 0.41.4
time: 2025/02/06 11:35
author: Luke Titley
email: luke.titley@proton.ch
urgency: low
stability: unstable
description:
- Added local agent to OpenVPN, minor bug fix
---
version: 0.41.3
time: 2025/02/05 09:50
author: Luke Titley
email: luke.titley@proton.ch
urgency: low
stability: unstable
description:
- Added local agent to OpenVPN
---
version: 0.41.2
time: 2025/01/29 13:00
author: Alexandru Cheltuitor
email: alexandru.cheltuitor@proton.ch
urgency: low
stability: unstable
description:
- Manage file containing forwarded port.
---
version: 0.41.0
time: 2025/01/27 20:11
author: Luke Titley
email: luke.titley@proton.ch
urgency: low
stability: unstable
description:
- Encrypt openvpn certificate private key
---
version: 0.40.2
time: 2025/01/23 15:13
author: Josep Llaneras
email: josep.llaneras@proton.ch
urgency: low
stability: unstable
description:
- Notify connecting/disconnecting state early
---
version: 0.40.1
time: 2025/01/21 17:54
author: Josep Llaneras
email: josep.llaneras@proton.ch
urgency: low
stability: unstable
description:
- Notify subscribers when state context changes
---
version: 0.39.2
time: 2025/01/17 16:20
author: Luke Titley
email: luke.titley@proton.ch
urgency: low
stability: unstable
description:
- Fix sentry event username masking
---
version: 0.39.1
time: 2024/12/31 15:00
author: Alexandru Cheltuitor
email: alexandru.cheltuitor@proton.ch
urgency: low
stability: unstable
description:
- Cleanup old feature flags.
---
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/13 17: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/11 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 14: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.