Compare commits

..

No commits in common. "master" and "0.5.1" have entirely different histories.

24 changed files with 321 additions and 1144 deletions

View File

@ -1,45 +0,0 @@
name: Build Status
on: [push, pull_request]
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ['3.8', '3.9', '3.10']
steps:
- uses: actions/checkout@v2
- uses: actions/setup-python@v2
with:
python-version: ${{ matrix.python-version }}
- name: Install Tox and any other packages
run: |
wget https://gitlab.matrix.org/matrix-org/olm/-/archive/master/olm-master.tar.bz2
tar -xvf olm-master.tar.bz2
pushd olm-master && make && sudo make PREFIX="/usr" install && popd
rm -r olm-master
pip install tox
- name: Run Tox
run: tox -e py
coverage:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Setup Python
uses: actions/setup-python@v2
with:
python-version: "3.10"
- name: Install Tox and any other packages
run: |
wget https://gitlab.matrix.org/matrix-org/olm/-/archive/master/olm-master.tar.bz2
tar -xvf olm-master.tar.bz2
pushd olm-master && make && sudo make PREFIX="/usr" install && popd
rm -r olm-master
pip install tox
- name: Run Tox
run: tox -e coverage

View File

@ -16,14 +16,14 @@ before_install:
matrix: matrix:
include: include:
- python: 3.8 - python: 3.6
env: TOXENV=py38 env: TOXENV=py36
- python: 3.9 - python: 3.7
env: TOXENV=py39 env: TOXENV=py37
- python: 3.9 - python: 3.7
env: TOXENV=coverage env: TOXENV=coverage
install: pip install tox-travis aioresponses install: pip install tox-travis PyGObject dbus-python aioresponses
script: tox script: tox
after_success: after_success:

View File

@ -1,177 +0,0 @@
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## 0.10.5 2022-09-28
### Added
- [[#137]] Proxy the v3 endpoints as well
### Fixed
- [[#130]] Make sure the token variable is declared
[#137]: https://github.com/matrix-org/pantalaimon/pull/137
[#130]: https://github.com/matrix-org/pantalaimon/pull/130
## 0.10.4 2022-02-04
### Fixed
- [[#122]] Fix the GLib import for panctl on some distributions
- [[#120]] Don't use strip to filter Bearer from the auth header
- [[#118]] Don't use the raw path if we need to sanitize filters, fixing room
history fetching for Fractal
[#122]: https://github.com/matrix-org/pantalaimon/pull/122
[#120]: https://github.com/matrix-org/pantalaimon/pull/120
[#118]: https://github.com/matrix-org/pantalaimon/pull/118
## 0.10.3 2021-09-02
### Fixed
- [[#105]] Use the raw_path when forwarding requests, avoiding URL
decoding/encoding issues.
[#105]: https://github.com/matrix-org/pantalaimon/pull/105
## 0.10.2 2021-07-14
### Fixed
- [[#103]] Prevent E2EE downgrade on failed syncs
[#103]: https://github.com/matrix-org/pantalaimon/pull/103
## 0.10.1 2021-07-06
### Fixed
- [[#100]] Don't require the rooms dicts in the sync response
- [[#99]] Thumbnails not generating for media uploaded in unencrypted rooms
whole LRU cache when it shouldn't
[#100]: https://github.com/matrix-org/pantalaimon/pull/100
[#99]: https://github.com/matrix-org/pantalaimon/pull/99
## 0.10.0 2021-05-14
### Added
- [[#98]] Add the ability to remove old room keys
- [[#95]] Encrypt thumbnails uploaded by a client
### Fixed
- [[#96]] Split out the media cache loading logic to avoid returning the
whole LRU cache when it shouldn't
[#98]: https://github.com/matrix-org/pantalaimon/pull/98
[#96]: https://github.com/matrix-org/pantalaimon/pull/96
[#95]: https://github.com/matrix-org/pantalaimon/pull/95
## 0.9.3 2021-05-14
### Changed
- [[#73f68c7]] Bump the allowed nio version
[73f68c7]: https://github.com/matrix-org/pantalaimon/commit/73f68c76fb05037bd7fe71688ce39eb1f526a385
## 0.9.2 2021-03-10
### Changed
- [[#89]] Bump the allowed nio version
[#89]: https://github.com/matrix-org/pantalaimon/pull/89
## 0.9.1 2021-01-19
### Changed
- [[3baae08]] Bump the allowed nio version
[3baae08]: https://github.com/matrix-org/pantalaimon/commit/3baae08ac36e258632e224b655e177a765a939f3
## 0.9.0 2021-01-19
### Fixed
- [[59051c5]] Fix the notification initialization allowing the DBUS thread to
start again
### Added
- [[#79]] Support media uploads, thanks to @aspacca
[59051c5]: https://github.com/matrix-org/pantalaimon/commit/59051c530a343a6887ea0f9ccddd6f6964f6d923
[#79]: https://github.com/matrix-org/pantalaimon/pull/79
## 0.8.0 2020-09-30
### Changed
- [[#69]] If no password is provided to /login, the daemon will re-use the original login response.
[#69]: https://github.com/matrix-org/pantalaimon/pull/69
## 0.7.0 2020-09-02
### Fixed
- [[#60]] Sanitize the GET /rooms/{room_id}/messages filters as well.
- [[#62]] Store media info when decrypting instead of using a event callback.
### Changed
- [[d425e2d]] Increase the max POST size.
[#62]: https://github.com/matrix-org/pantalaimon/pull/62
[#60]: https://github.com/matrix-org/pantalaimon/pull/60
[d425e2d]: https://github.com/matrix-org/pantalaimon/commit/d425e2d188aed32c3fe87cac210c0943fd51b085
## 0.6.5 2020-07-02
### Fixed
- [[a1ce950]] Allow to send messages using a POST request since Synapse seems to
allow it.
[a1ce950]: https://github.com/matrix-org/pantalaimon/commit/a1ce95076ecd80c880028691feeced8d28cacad9
## 0.6.4 2020-06-21
### Changed
- Bump the maximal supported nio version.
## 0.6.3 2020-05-28
### Fixed
- Fix our dep requirements to avoid incompatibilities between nio and pantalaimon.
## 0.6.2 2020-05-27
### Fixed
- Don't require exact patch versions for our deps.
## 0.6.1 2020-05-12
### Fixed
- Bump the version to trigger a docker hub build with the latest nio release.
## 0.6.0 2020-05-10
### Added
- Add support for Janus 0.5.0.
- Added media endpoint handling to the /media/v1 path.
### Fixed
- Modify media events so they contain the unencrypted URL fields as well.

View File

@ -25,19 +25,11 @@ Installing pantalaimon works like usually with python packages:
python setup.py install python setup.py install
or you can use `pip` and install it with:
```
pip install .[ui]
```
It is recommended that you create a virtual environment first or install dependencies
via your package manager. They are usually found with `python-<package-name>`.
Pantalaimon can also be found on pypi: Pantalaimon can also be found on pypi:
pip install pantalaimon pip install pantalaimon
Pantalaimon contains a dbus based UI that can be used to control the daemon. Pantalaimon contains a dbus based UI that can be used to controll the daemon.
The dbus based UI is completely optional and needs to be installed with the The dbus based UI is completely optional and needs to be installed with the
daemon: daemon:
@ -85,10 +77,6 @@ docker build -t pantalaimon .
# volume below is for where Pantalaimon should dump some data. # volume below is for where Pantalaimon should dump some data.
docker run -it --rm -v /path/to/pantalaimon/dir:/data -p 8008:8008 pantalaimon docker run -it --rm -v /path/to/pantalaimon/dir:/data -p 8008:8008 pantalaimon
``` ```
The Docker image in the above example can alternatively be built straight from any branch or tag without the need to clone the repo, just by using this syntax:
```bash
docker build -t pantalaimon github.com/matrix-org/pantalaimon#master
```
An example `pantalaimon.conf` for Docker is: An example `pantalaimon.conf` for Docker is:
```conf ```conf
@ -108,10 +96,9 @@ IgnoreVerification = True
Usage Usage
===== =====
While pantalaimon is a daemon, it is meant to be run as the same user as the app it is proxying for. It won't While pantalaimon is a daemon, it is meant to be run as your own user. It won't
verify devices for you automatically, unless configured to do so, and requires verify devices for you automatically, unless configured to do so, and requires
user interaction to verify, ignore or blacklist devices. A more complete user interaction to verify, ignore or blacklist devices.
description of Pantalaimon can be found in the [man page](docs/man/pantalaimon.8.md).
Pantalaimon requires a configuration file to run. The configuration file Pantalaimon requires a configuration file to run. The configuration file
specifies one or more homeservers for pantalaimon to connect to. specifies one or more homeservers for pantalaimon to connect to.
@ -119,7 +106,7 @@ specifies one or more homeservers for pantalaimon to connect to.
A minimal pantalaimon configuration looks like this: A minimal pantalaimon configuration looks like this:
```dosini ```dosini
[local-matrix] [local-matrix]
Homeserver = https://localhost:443 Homeserver = https://localhost:8448
ListenAddress = localhost ListenAddress = localhost
ListenPort = 8009 ListenPort = 8009
``` ```
@ -127,7 +114,7 @@ ListenPort = 8009
The configuration file should be placed in `~/.config/pantalaimon/pantalaimon.conf`. The configuration file should be placed in `~/.config/pantalaimon/pantalaimon.conf`.
The full documentation for the pantalaimons configuration can be found in The full documentation for the pantalaimons configuration can be found in
the [man page](docs/man/pantalaimon.5.md) `pantalaimon(5)`. the man page `pantalaimon(5)`.
Now that pantalaimon is configured it can be run: Now that pantalaimon is configured it can be run:
@ -148,41 +135,3 @@ To control the daemon an interactive utility is provided in the form of
`panctl` can be used to verify, blacklist or ignore devices, import or export `panctl` can be used to verify, blacklist or ignore devices, import or export
session keys, or to introspect devices of users that we share encrypted rooms session keys, or to introspect devices of users that we share encrypted rooms
with. with.
### Setup
This is all coming from an excellent comment that you can find [here](https://github.com/matrix-org/pantalaimon/issues/154#issuecomment-1951591191).
1) Ensure you have an OS keyring installed. In my case I installed `gnome-keyring`. You may also want a GUI like `seahorse` to inspect the keyring. (pantalaimon will work without a keyring but your client will have to log in with the password every time `pantalaimon` is restarted, instead of being able to reuse the access token from the previous successful login.)
2) In case you have prior attempts, clean the slate by deleting the `~/.local/share/pantalaimon` directory.
3) Start `pantalaimon`.
4) Connect a client to the `ListenAddress:ListenPort` you specified in `pantalaimon.conf`, eg to `127.0.0.1:8009`, using the same username and password you would've used to login to your homeserver directly.
5) The login should succeed, but at this point all encrypted messages will fail to decrypt. This is fine.
6) Start another client that you were already using for your encrypted chats previously. In my case this was `app.element.io`, so the rest of the steps here assume that.
7) Run `panctl`. At the prompt, run `start-verification <user ID> <user ID> <Element's device ID>`. `<user ID>` here is the full user ID like `@arnavion:arnavion.dev`. If you only have the one Element session, `panctl` will show you the device ID as an autocomplete hint so you don't have to look it up. If you do need to look it up, go to Element -> profile icon -> All Settings -> Sessions, expand the "Current session" item, and the "Session ID" is the device ID.
8) In Element you will see a popup "Incoming Verification Request". Click "Continue". It will change to a popup containing some emojis, and `panctl` will print the same emojis. Click the "They match" button. It will now change to a popup like "Waiting for other client to confirm..."
9) In `panctl`, run `confirm-verification <user ID> <user ID> <Element's device ID>`, ie the same command as before but with `confirm-verification` instead of `start-verification`.
10) At this point, if you look at all your sessions in Element (profile icon -> All Settings -> Sessions), you should see "pantalaimon" in the "Other sessions" list as a "Verified" session.
11) Export the E2E room keys that Element was using via profile icon -> Security & Privacy -> Export E2E room keys. Pick any password and then save the file to some path.
12) Back in `panctl`, run `import-keys <user ID> <path of file> <password you used to encrypt the file>`. After a few seconds, in the output of `pantalaimon`, you should see a log like `INFO: pantalaimon: Successfully imported keys for <user ID> from <path of file>`.
13) Close and restart the client you had used in step 5, ie the one you want to connect to `pantalaimon`. Now, finally, you should be able to see the encrypted chats be decrypted.
14) Delete the E2E room keys backup file from step 12. You don't need it any more.
15) If in step 11 you had other unverified sessions from pantalaimon from your prior attempts, you can sign out of them too.
You will probably have to repeat steps 11-14 any time you start a new encrypted chat in Element.

View File

@ -12,4 +12,3 @@ Proxy = http://localhost:8080
SSL = False SSL = False
IgnoreVerification = False IgnoreVerification = False
UseKeyring = True UseKeyring = True
DropOldKeys = False

View File

@ -51,7 +51,7 @@ The message will be sent away after all devices are marked as ignored.
In contrast to the In contrast to the
.Cm send-anyways .Cm send-anyways
command this command cancels the sending of a message to an encrypted room with command this command cancels the sending of a message to an encrypted room with
unverified devices and gives the user the opportunity to verify or blacklist unverified devices and gives the user the oportunity to verify or blacklist
devices as they see fit. devices as they see fit.
.It Cm import-keys Ar pan-user Ar file Ar passphrase .It Cm import-keys Ar pan-user Ar file Ar passphrase
Import end-to-end encryption keys from the given file for the given pan-user. Import end-to-end encryption keys from the given file for the given pan-user.

View File

@ -74,7 +74,7 @@ are as follows:
> In contrast to the > In contrast to the
> **send-anyways** > **send-anyways**
> command this command cancels the sending of a message to an encrypted room with > command this command cancels the sending of a message to an encrypted room with
> unverified devices and gives the user the opportunity to verify or blacklist > unverified devices and gives the user the oportunity to verify or blacklist
> devices as they see fit. > devices as they see fit.
**import-keys** *pan-user* *file* *passphrase* **import-keys** *pan-user* *file* *passphrase*

View File

@ -51,11 +51,6 @@ This option configures if a proxy instance should use the OS keyring to store
its own access tokens. The access tokens are required for the daemon to resume its own access tokens. The access tokens are required for the daemon to resume
operation. If this is set to "No", access tokens are stored in the pantalaimon operation. If this is set to "No", access tokens are stored in the pantalaimon
database in plaintext. Defaults to "Yes". database in plaintext. Defaults to "Yes".
.It Cm DropOldKeys
This option configures if a proxy instance should only keep the latest version
of a room key from a certain user around. This effectively means that only newly
incoming messages will be decryptable, the proxy will be unable to decrypt the
room history. Defaults to "No".
.It Cm SearchRequests .It Cm SearchRequests
This option configures if the proxy should make additional HTTP requests to the This option configures if the proxy should make additional HTTP requests to the
server when clients use the search API endpoint. Some data that is required to server when clients use the search API endpoint. Some data that is required to
@ -86,7 +81,7 @@ The amount of time to wait between room message history requests to the
Homeserver in ms. Defaults to 3000. Homeserver in ms. Defaults to 3000.
.El .El
.Pp .Pp
Additional to the homeserver section a special section with the name Aditional to the homeserver section a special section with the name
.Cm Default .Cm Default
can be used to configure the following values for all homeservers: can be used to configure the following values for all homeservers:
.Cm ListenAddress , .Cm ListenAddress ,
@ -133,7 +128,7 @@ Default location of the configuration file.
The following example shows a configured pantalaimon proxy with the name The following example shows a configured pantalaimon proxy with the name
.Em Clocktown , .Em Clocktown ,
the homeserver URL is set to the homeserver URL is set to
.Em https://localhost:8448 , .Em https://example.org ,
the pantalaimon proxy is listening for client connections on the address the pantalaimon proxy is listening for client connections on the address
.Em localhost , .Em localhost ,
and port and port

View File

@ -62,14 +62,7 @@ The following keys are optional in the proxy instance sections:
> operation. If this is set to "No", access tokens are stored in the pantalaimon > operation. If this is set to "No", access tokens are stored in the pantalaimon
> database in plaintext. Defaults to "Yes". > database in plaintext. Defaults to "Yes".
**DropOldKeys** Aditional to the homeserver section a special section with the name
> This option configures if a proxy instance should only keep the latest version
> of a room key from a certain user around. This effectively means that only newly
> incoming messages will be decryptable, the proxy will be unable to decrypt the
> room history. Defaults to "No".
Additional to the homeserver section a special section with the name
**Default** **Default**
can be used to configure the following values for all homeservers: can be used to configure the following values for all homeservers:
**ListenAddress**, **ListenAddress**,
@ -118,7 +111,7 @@ overridden using appropriate environment variables.
The following example shows a configured pantalaimon proxy with the name The following example shows a configured pantalaimon proxy with the name
*Clocktown*, *Clocktown*,
the homeserver URL is set to the homeserver URL is set to
*https://localhost:8448*, *https://example.org*,
the pantalaimon proxy is listening for client connections on the address the pantalaimon proxy is listening for client connections on the address
*localhost*, *localhost*,
and port and port
@ -157,4 +150,4 @@ pantalaimon(8)
was written by was written by
Damir Jeli&#263; &lt;[poljar@termina.org.uk](mailto:poljar@termina.org.uk)&gt;. Damir Jeli&#263; &lt;[poljar@termina.org.uk](mailto:poljar@termina.org.uk)&gt;.
Linux 5.11.16-arch1-1 - May 8, 2019 Linux 5.1.3-arch2-1-ARCH - May 8, 2019

View File

@ -24,7 +24,7 @@ behalf of the client.
is supposed to run as your own user and listen to connections on a is supposed to run as your own user and listen to connections on a
non-privileged port. A client needs to log in using the standard Matrix HTTP non-privileged port. A client needs to log in using the standard Matrix HTTP
calls to register itself to the daemon, such a registered user is called a pan calls to register itself to the daemon, such a registered user is called a pan
user and will have its own sync loop to keep up with the server. Multiple matrix user and will have it's own sync loop to keep up with the server. Multiple matrix
clients can connect and use the same pan user. clients can connect and use the same pan user.
If user interaction is required If user interaction is required

View File

@ -16,6 +16,7 @@ import asyncio
import os import os
from collections import defaultdict from collections import defaultdict
from pprint import pformat from pprint import pformat
from typing import Any, Dict, Optional
from urllib.parse import urlparse from urllib.parse import urlparse
from aiohttp.client_exceptions import ClientConnectionError from aiohttp.client_exceptions import ClientConnectionError
@ -134,7 +135,7 @@ class InvalidLimit(Exception):
class SqliteQStore(SqliteStore): class SqliteQStore(SqliteStore):
def _create_database(self): def _create_database(self):
return SqliteQueueDatabase( return SqliteQueueDatabase(
self.database_path, pragmas=(("foreign_keys", 1), ("secure_delete", 1)) self.database_path, pragmas=(("foregign_keys", 1), ("secure_delete", 1))
) )
def close(self): def close(self):
@ -207,6 +208,7 @@ class PanClient(AsyncClient):
self.key_request_cb, (RoomKeyRequest, RoomKeyRequestCancellation) self.key_request_cb, (RoomKeyRequest, RoomKeyRequestCancellation)
) )
self.add_event_callback(self.undecrypted_event_cb, MegolmEvent) self.add_event_callback(self.undecrypted_event_cb, MegolmEvent)
self.add_event_callback(self.store_media_cb, RoomEncryptedMedia)
self.add_event_callback( self.add_event_callback(
self.store_thumbnail_cb, self.store_thumbnail_cb,
(RoomEncryptedImage, RoomEncryptedVideo, RoomEncryptedFile), (RoomEncryptedImage, RoomEncryptedVideo, RoomEncryptedFile),
@ -268,7 +270,7 @@ class PanClient(AsyncClient):
self.media_info[(mxc_server, mxc_path)] = media self.media_info[(mxc_server, mxc_path)] = media
self.pan_store.save_media(self.server_name, media) self.pan_store.save_media(self.server_name, media)
def store_event_media(self, event): def store_media_cb(self, room, event):
try: try:
mxc = urlparse(event.url) mxc = urlparse(event.url)
except ValueError: except ValueError:
@ -409,10 +411,6 @@ class PanClient(AsyncClient):
except (asyncio.CancelledError, KeyboardInterrupt): except (asyncio.CancelledError, KeyboardInterrupt):
return return
@property
def has_been_synced(self) -> bool:
self.last_sync_token is not None
async def sync_tasks(self, response): async def sync_tasks(self, response):
if self.index: if self.index:
await self.index.commit_events() await self.index.commit_events()
@ -543,6 +541,7 @@ class PanClient(AsyncClient):
timeout = 30000 timeout = 30000
sync_filter = {"room": {"state": {"lazy_load_members": True}}} sync_filter = {"room": {"state": {"lazy_load_members": True}}}
next_batch = self.pan_store.load_token(self.server_name, self.user_id) next_batch = self.pan_store.load_token(self.server_name, self.user_id)
self.last_sync_token = next_batch
# We don't store any room state so initial sync needs to be with the # We don't store any room state so initial sync needs to be with the
# full_state parameter. Subsequent ones are normal. # full_state parameter. Subsequent ones are normal.
@ -553,7 +552,6 @@ class PanClient(AsyncClient):
full_state=True, full_state=True,
since=next_batch, since=next_batch,
loop_sleep_time=loop_sleep_time, loop_sleep_time=loop_sleep_time,
set_presence="offline",
) )
) )
self.task = task self.task = task
@ -708,6 +706,7 @@ class PanClient(AsyncClient):
for share in self.get_active_key_requests( for share in self.get_active_key_requests(
message.user_id, message.device_id message.user_id, message.device_id
): ):
continued = True continued = True
if not self.continue_key_share(share): if not self.continue_key_share(share):
@ -734,7 +733,7 @@ class PanClient(AsyncClient):
pass pass
response = ( response = (
f"Successfully continued the key requests from " f"Succesfully continued the key requests from "
f"{message.user_id} via {message.device_id}" f"{message.user_id} via {message.device_id}"
) )
ret = "m.ok" ret = "m.ok"
@ -759,7 +758,7 @@ class PanClient(AsyncClient):
if cancelled: if cancelled:
response = ( response = (
f"Successfully cancelled key requests from " f"Succesfully cancelled key requests from "
f"{message.user_id} via {message.device_id}" f"{message.user_id} via {message.device_id}"
) )
ret = "m.ok" ret = "m.ok"
@ -809,9 +808,8 @@ class PanClient(AsyncClient):
if not isinstance(event, MegolmEvent): if not isinstance(event, MegolmEvent):
logger.warn( logger.warn(
"Encrypted event is not a megolm event:" "\n{}".format( "Encrypted event is not a megolm event:"
pformat(event_dict) "\n{}".format(pformat(event_dict))
)
) )
return False return False
@ -821,23 +819,6 @@ class PanClient(AsyncClient):
try: try:
decrypted_event = self.decrypt_event(event) decrypted_event = self.decrypt_event(event)
logger.debug("Decrypted event: {}".format(decrypted_event)) logger.debug("Decrypted event: {}".format(decrypted_event))
logger.info(
"Decrypted event from {} in {}, event id: {}".format(
decrypted_event.sender,
decrypted_event.room_id,
decrypted_event.event_id,
)
)
if isinstance(decrypted_event, RoomEncryptedMedia):
self.store_event_media(decrypted_event)
decrypted_event.source["content"]["url"] = decrypted_event.url
if decrypted_event.thumbnail_url:
decrypted_event.source["content"]["info"]["thumbnail_url"] = (
decrypted_event.thumbnail_url
)
event_dict.update(decrypted_event.source) event_dict.update(decrypted_event.source)
event_dict["decrypted"] = True event_dict["decrypted"] = True
@ -908,7 +889,7 @@ class PanClient(AsyncClient):
self.handle_to_device_from_sync_body(body) self.handle_to_device_from_sync_body(body)
for room_id, room_dict in body.get("rooms", {}).get("join", {}).items(): for room_id, room_dict in body["rooms"]["join"].items():
try: try:
if not self.rooms[room_id].encrypted: if not self.rooms[room_id].encrypted:
logger.info( logger.info(
@ -923,7 +904,7 @@ class PanClient(AsyncClient):
# pan sync stream did. Let's assume that the room is encrypted. # pan sync stream did. Let's assume that the room is encrypted.
pass pass
for event in room_dict.get("timeline", {}).get("events", []): for event in room_dict["timeline"]["events"]:
if "type" not in event: if "type" not in event:
continue continue

View File

@ -31,7 +31,7 @@ class PanConfigParser(configparser.ConfigParser):
"IgnoreVerification": "False", "IgnoreVerification": "False",
"ListenAddress": "localhost", "ListenAddress": "localhost",
"ListenPort": "8009", "ListenPort": "8009",
"LogLevel": "warning", "LogLevel": "warnig",
"Notifications": "on", "Notifications": "on",
"UseKeyring": "yes", "UseKeyring": "yes",
"SearchRequests": "off", "SearchRequests": "off",
@ -39,7 +39,6 @@ class PanConfigParser(configparser.ConfigParser):
"IndexingBatchSize": "100", "IndexingBatchSize": "100",
"HistoryFetchDelay": "3000", "HistoryFetchDelay": "3000",
"DebugEncryption": "False", "DebugEncryption": "False",
"DropOldKeys": "False",
}, },
converters={ converters={
"address": parse_address, "address": parse_address,
@ -113,7 +112,7 @@ class ServerConfig:
E2E encrypted messages. E2E encrypted messages.
keyring (bool): Enable or disable the OS keyring for the storage of keyring (bool): Enable or disable the OS keyring for the storage of
access tokens. access tokens.
search_requests (bool): Enable or disable additional Homeserver requests search_requests (bool): Enable or disable aditional Homeserver requests
for the search API endpoint. for the search API endpoint.
index_encrypted_only (bool): Enable or disable message indexing fro index_encrypted_only (bool): Enable or disable message indexing fro
non-encrypted rooms. non-encrypted rooms.
@ -122,8 +121,6 @@ class ServerConfig:
the room history. the room history.
history_fetch_delay (int): The delay between room history fetching history_fetch_delay (int): The delay between room history fetching
requests in seconds. requests in seconds.
drop_old_keys (bool): Should Pantalaimon only keep the most recent
decryption key around.
""" """
name = attr.ib(type=str) name = attr.ib(type=str)
@ -140,7 +137,6 @@ class ServerConfig:
index_encrypted_only = attr.ib(type=bool, default=True) index_encrypted_only = attr.ib(type=bool, default=True)
indexing_batch_size = attr.ib(type=int, default=100) indexing_batch_size = attr.ib(type=int, default=100)
history_fetch_delay = attr.ib(type=int, default=3) history_fetch_delay = attr.ib(type=int, default=3)
drop_old_keys = attr.ib(type=bool, default=False)
@attr.s @attr.s
@ -186,6 +182,7 @@ class PanConfig:
try: try:
for section_name, section in config.items(): for section_name, section in config.items():
if section_name == "Default": if section_name == "Default":
continue continue
@ -232,7 +229,6 @@ class PanConfig:
f"already defined before." f"already defined before."
) )
listen_set.add(listen_tuple) listen_set.add(listen_tuple)
drop_old_keys = section.getboolean("DropOldKeys")
server_conf = ServerConfig( server_conf = ServerConfig(
section_name, section_name,
@ -247,7 +243,6 @@ class PanConfig:
index_encrypted_only, index_encrypted_only,
indexing_batch_size, indexing_batch_size,
history_fetch_delay / 1000, history_fetch_delay / 1000,
drop_old_keys,
) )
self.servers[section_name] = server_conf self.servers[section_name] = server_conf

View File

@ -17,11 +17,8 @@ import json
import os import os
import urllib.parse import urllib.parse
import concurrent.futures import concurrent.futures
from io import BufferedReader, BytesIO
from json import JSONDecodeError from json import JSONDecodeError
from typing import Any, Dict from typing import Any, Dict
from urllib.parse import urlparse
from uuid import uuid4
import aiohttp import aiohttp
import attr import attr
@ -37,7 +34,6 @@ from nio import (
OlmTrustError, OlmTrustError,
SendRetryError, SendRetryError,
DownloadResponse, DownloadResponse,
UploadResponse,
) )
from nio.crypto import decrypt_attachment from nio.crypto import decrypt_attachment
@ -51,7 +47,7 @@ from pantalaimon.client import (
) )
from pantalaimon.index import INDEXING_ENABLED, InvalidQueryError from pantalaimon.index import INDEXING_ENABLED, InvalidQueryError
from pantalaimon.log import logger from pantalaimon.log import logger
from pantalaimon.store import ClientInfo, PanStore, MediaInfo from pantalaimon.store import ClientInfo, PanStore
from pantalaimon.thread_messages import ( from pantalaimon.thread_messages import (
AcceptSasMessage, AcceptSasMessage,
CancelSasMessage, CancelSasMessage,
@ -83,12 +79,6 @@ CORS_HEADERS = {
} }
class NotDecryptedAvailableError(Exception):
"""Exception that signals that no decrypted upload is available"""
pass
@attr.s @attr.s
class ProxyDaemon: class ProxyDaemon:
name = attr.ib() name = attr.ib()
@ -111,7 +101,6 @@ class ProxyDaemon:
client_info = attr.ib(init=False, default=attr.Factory(dict), type=dict) client_info = attr.ib(init=False, default=attr.Factory(dict), type=dict)
default_session = attr.ib(init=False, default=None) default_session = attr.ib(init=False, default=None)
media_info = attr.ib(init=False, default=None) media_info = attr.ib(init=False, default=None)
upload_info = attr.ib(init=False, default=None)
database_name = "pan.db" database_name = "pan.db"
def __attrs_post_init__(self): def __attrs_post_init__(self):
@ -121,12 +110,9 @@ class ProxyDaemon:
self.hostname = self.homeserver.hostname self.hostname = self.homeserver.hostname
self.store = PanStore(self.data_dir) self.store = PanStore(self.data_dir)
accounts = self.store.load_users(self.name) accounts = self.store.load_users(self.name)
self.media_info = self.store.load_media_cache(self.name) self.media_info = self.store.load_media(self.name)
self.upload_info = self.store.load_upload(self.name)
for user_id, device_id in accounts: for user_id, device_id in accounts:
token = None
if self.conf.keyring: if self.conf.keyring:
try: try:
token = keyring.get_password( token = keyring.get_password(
@ -227,8 +213,7 @@ class ProxyDaemon:
if ret: if ret:
msg = ( msg = (
f"Device {device.id} of user " f"Device {device.id} of user " f"{device.user_id} succesfully verified."
f"{device.user_id} successfully verified."
) )
await client.send_update_device(device) await client.send_update_device(device)
else: else:
@ -243,7 +228,7 @@ class ProxyDaemon:
if ret: if ret:
msg = ( msg = (
f"Device {device.id} of user " f"Device {device.id} of user "
f"{device.user_id} successfully unverified." f"{device.user_id} succesfully unverified."
) )
await client.send_update_device(device) await client.send_update_device(device)
else: else:
@ -258,7 +243,7 @@ class ProxyDaemon:
if ret: if ret:
msg = ( msg = (
f"Device {device.id} of user " f"Device {device.id} of user "
f"{device.user_id} successfully blacklisted." f"{device.user_id} succesfully blacklisted."
) )
await client.send_update_device(device) await client.send_update_device(device)
else: else:
@ -275,7 +260,7 @@ class ProxyDaemon:
if ret: if ret:
msg = ( msg = (
f"Device {device.id} of user " f"Device {device.id} of user "
f"{device.user_id} successfully unblacklisted." f"{device.user_id} succesfully unblacklisted."
) )
await client.send_update_device(device) await client.send_update_device(device)
else: else:
@ -310,6 +295,7 @@ class ProxyDaemon:
DeviceUnblacklistMessage, DeviceUnblacklistMessage,
), ),
): ):
device = client.device_store[message.user_id].get(message.device_id, None) device = client.device_store[message.user_id].get(message.device_id, None)
if not device: if not device:
@ -358,7 +344,7 @@ class ProxyDaemon:
else: else:
info_msg = ( info_msg = (
f"Successfully exported keys for {client.user_id} " f"to {path}" f"Succesfully exported keys for {client.user_id} " f"to {path}"
) )
logger.info(info_msg) logger.info(info_msg)
await self.send_response( await self.send_response(
@ -381,7 +367,7 @@ class ProxyDaemon:
) )
else: else:
info_msg = ( info_msg = (
f"Successfully imported keys for {client.user_id} " f"from {path}" f"Succesfully imported keys for {client.user_id} " f"from {path}"
) )
logger.info(info_msg) logger.info(info_msg)
await self.send_response( await self.send_response(
@ -420,40 +406,33 @@ class ProxyDaemon:
access_token = request.query.get("access_token", "") access_token = request.query.get("access_token", "")
if not access_token: if not access_token:
access_token = request.headers.get("Authorization", "").replace( access_token = request.headers.get("Authorization", "").strip("Bearer ")
"Bearer ", "", 1
)
return access_token return access_token
def sanitize_subfilter(self, request_filter: Dict[Any, Any]):
types_filter = request_filter.get("types", None)
if types_filter:
if "m.room.encrypted" not in types_filter:
types_filter.append("m.room.encrypted")
not_types_filter = request_filter.get("not_types", None)
if not_types_filter:
try:
not_types_filter.remove("m.room.encrypted")
except ValueError:
pass
def sanitize_filter(self, sync_filter): def sanitize_filter(self, sync_filter):
# type: (Dict[Any, Any]) -> Dict[Any, Any] # type: (Dict[Any, Any]) -> Dict[Any, Any]
"""Make sure that a filter isn't filtering encrypted messages.""" """Make sure that a filter isn't filtering encrypted messages."""
sync_filter = dict(sync_filter) sync_filter = dict(sync_filter)
room_filter = sync_filter.get("room", None) room_filter = sync_filter.get("room", None)
self.sanitize_subfilter(sync_filter)
if room_filter: if room_filter:
timeline_filter = room_filter.get("timeline", None) timeline_filter = room_filter.get("timeline", None)
if timeline_filter: if timeline_filter:
self.sanitize_subfilter(timeline_filter) types_filter = timeline_filter.get("types", None)
if types_filter:
if "m.room.encrypted" not in types_filter:
types_filter.append("m.room.encrypted")
not_types_filter = timeline_filter.get("not_types", None)
if not_types_filter:
try:
not_types_filter.remove("m.room.encrypted")
except ValueError:
pass
return sync_filter return sync_filter
@ -464,7 +443,6 @@ class ProxyDaemon:
data=None, # type: bytes data=None, # type: bytes
session=None, # type: aiohttp.ClientSession session=None, # type: aiohttp.ClientSession
token=None, # type: str token=None, # type: str
use_raw_path=True, # type: bool
): ):
# type: (...) -> aiohttp.ClientResponse # type: (...) -> aiohttp.ClientResponse
"""Forward the given request to our configured homeserver. """Forward the given request to our configured homeserver.
@ -479,10 +457,6 @@ class ProxyDaemon:
should be used to forward the request. should be used to forward the request.
token (str, optional): The access token that should be used for the token (str, optional): The access token that should be used for the
request. request.
use_raw_path (str, optional): Should the raw path be used from the
request or should we use the path and re-encode it. Some may need
their filters to be sanitized, this requires the parsed version of
the path, otherwise we leave the path as is.
""" """
if not session: if not session:
if not self.default_session: if not self.default_session:
@ -491,7 +465,9 @@ class ProxyDaemon:
assert session assert session
path = request.raw_path if use_raw_path else urllib.parse.quote(request.path) path = urllib.parse.quote(
request.path
) # re-encode path stuff like room aliases
method = request.method method = request.method
headers = CIMultiDict(request.headers) headers = CIMultiDict(request.headers)
@ -571,9 +547,7 @@ class ProxyDaemon:
return user return user
async def start_pan_client( async def start_pan_client(self, access_token, user, user_id, password):
self, access_token, user, user_id, password, device_id=None
):
client = ClientInfo(user_id, access_token) client = ClientInfo(user_id, access_token)
self.client_info[access_token] = client self.client_info[access_token] = client
self.store.save_server_user(self.name, user_id) self.store.save_server_user(self.name, user_id)
@ -598,27 +572,13 @@ class ProxyDaemon:
store_class=self.client_store_class, store_class=self.client_store_class,
media_info=self.media_info, media_info=self.media_info,
) )
if password == "":
if device_id is None:
logger.warn(
"Empty password provided and device_id was also None, not "
"starting background sync client "
)
return
# If password is blank, we cannot login normally and must
# fall back to using the provided device_id.
pan_client.restore_login(user_id, device_id, access_token)
else:
response = await pan_client.login(password, "pantalaimon") response = await pan_client.login(password, "pantalaimon")
if not isinstance(response, LoginResponse): if not isinstance(response, LoginResponse):
await pan_client.close() await pan_client.close()
return return
logger.info( logger.info(f"Succesfully started new background sync client for " f"{user_id}")
f"Successfully started new background sync client for " f"{user_id}"
)
await self.send_ui_message( await self.send_ui_message(
UpdateUsersMessage(self.name, user_id, pan_client.device_id) UpdateUsersMessage(self.name, user_id, pan_client.device_id)
@ -680,16 +640,13 @@ class ProxyDaemon:
if response.status == 200 and json_response: if response.status == 200 and json_response:
user_id = json_response.get("user_id", None) user_id = json_response.get("user_id", None)
access_token = json_response.get("access_token", None) access_token = json_response.get("access_token", None)
device_id = json_response.get("device_id", None)
if user_id and access_token: if user_id and access_token:
logger.info( logger.info(
f"User: {user} successfully logged in, starting " f"User: {user} succesfully logged in, starting "
f"a background sync client." f"a background sync client."
) )
await self.start_pan_client( await self.start_pan_client(access_token, user, user_id, password)
access_token, user, user_id, password, device_id
)
return web.Response( return web.Response(
status=response.status, status=response.status,
@ -735,7 +692,7 @@ class ProxyDaemon:
return decryption_method(body, ignore_failures=False) return decryption_method(body, ignore_failures=False)
except EncryptionError: except EncryptionError:
logger.info("Error decrypting sync, waiting for next pan " "sync") logger.info("Error decrypting sync, waiting for next pan " "sync")
(await client.synced.wait(),) await client.synced.wait(),
logger.info("Pan synced, retrying decryption.") logger.info("Pan synced, retrying decryption.")
try: try:
@ -772,7 +729,7 @@ class ProxyDaemon:
try: try:
response = await self.forward_request( response = await self.forward_request(
request, params=query, token=client.access_token, use_raw_path=False request, params=query, token=client.access_token
) )
except ClientConnectionError as e: except ClientConnectionError as e:
return web.Response(status=500, text=str(e)) return web.Response(status=500, text=str(e))
@ -795,27 +752,6 @@ class ProxyDaemon:
body=await response.read(), body=await response.read(),
) )
async def createRoom(self, request):
try:
content = await request.json()
except (JSONDecodeError, ContentTypeError):
return self._not_json
invite = content.get("invite", ())
if invite:
access_token = self.get_access_token(request)
if not access_token:
return self._missing_token
client = await self._find_client(access_token)
if not client:
return self._unknown_token
client.users_for_key_query.update(invite)
return await self.forward_to_web(request)
async def messages(self, request): async def messages(self, request):
access_token = self.get_access_token(request) access_token = self.get_access_token(request)
@ -826,24 +762,8 @@ class ProxyDaemon:
if not client: if not client:
return self._unknown_token return self._unknown_token
request_filter = request.query.get("filter", None)
query = CIMultiDict(request.query)
if request_filter:
try: try:
request_filter = json.loads(request_filter) response = await self.forward_request(request)
except (JSONDecodeError, TypeError):
pass
if isinstance(request_filter, dict):
request_filter = json.dumps(self.sanitize_filter(request_filter))
query["filter"] = request_filter
try:
response = await self.forward_request(
request, params=query, use_raw_path=False
)
except ClientConnectionError as e: except ClientConnectionError as e:
return web.Response(status=500, text=str(e)) return web.Response(status=500, text=str(e))
@ -867,56 +787,6 @@ class ProxyDaemon:
body=await response.read(), body=await response.read(),
) )
def _get_upload_and_media_info(self, content_uri: str):
try:
upload_info = self.upload_info[content_uri]
except KeyError:
upload_info = self.store.load_upload(self.name, content_uri)
if not upload_info:
return None, None
self.upload_info[content_uri] = upload_info
mxc = urlparse(content_uri)
mxc_server = mxc.netloc.strip("/")
mxc_path = mxc.path.strip("/")
media_info = self.store.load_media(self.name, mxc_server, mxc_path)
if not media_info:
return None, None
self.media_info[(mxc_server, mxc_path)] = media_info
return upload_info, media_info
async def _decrypt_uri(self, content_uri, client):
upload_info, media_info = self._get_upload_and_media_info(content_uri)
if not upload_info or not media_info:
raise NotDecryptedAvailableError
response, decrypted_file = await self._load_decrypted_file(
media_info.mxc_server, media_info.mxc_path, upload_info.filename
)
if response is None and decrypted_file is None:
raise NotDecryptedAvailableError
if not isinstance(response, DownloadResponse):
raise NotDecryptedAvailableError
decrypted_upload, _ = await client.upload(
data_provider=BufferedReader(BytesIO(decrypted_file)),
content_type=upload_info.mimetype,
filename=upload_info.filename,
encrypt=False,
filesize=len(decrypted_file),
)
if not isinstance(decrypted_upload, UploadResponse):
raise NotDecryptedAvailableError
return decrypted_upload.content_uri
async def send_message(self, request): async def send_message(self, request):
access_token = self.get_access_token(request) access_token = self.get_access_token(request)
@ -929,36 +799,12 @@ class ProxyDaemon:
room_id = request.match_info["room_id"] room_id = request.match_info["room_id"]
# The room is not in the joined rooms list, just forward it.
try: try:
room = client.rooms[room_id] room = client.rooms[room_id]
encrypt = room.encrypted encrypt = room.encrypted
except KeyError: except KeyError:
# The room is not in the joined rooms list, either the pan client return await self.forward_to_web(request, token=client.access_token)
# didn't manage to sync the state or we're not joined, in either
# case send an error response.
if client.has_been_synced:
return web.json_response(
{
"errcode": "M_FORBIDDEN",
"error": "You do not have permission to send the event.",
},
headers=CORS_HEADERS,
status=403,
)
else:
logger.error(
"The internal Pantalaimon client did not manage "
"to sync with the server."
)
return web.json_response(
{
"errcode": "M_UNKNOWN",
"error": "The pantalaimon client did not manage to sync with "
"the server",
},
headers=CORS_HEADERS,
status=500,
)
# Don't encrypt reactions for now - they are weird and clients # Don't encrypt reactions for now - they are weird and clients
# need to support them like this. # need to support them like this.
@ -966,81 +812,20 @@ class ProxyDaemon:
if request.match_info["event_type"] == "m.reaction": if request.match_info["event_type"] == "m.reaction":
encrypt = False encrypt = False
# The room isn't encrypted just forward the message.
if not encrypt:
return await self.forward_to_web(request, token=client.access_token)
msgtype = request.match_info["event_type"] msgtype = request.match_info["event_type"]
txnid = request.match_info["txnid"]
try: try:
content = await request.json() content = await request.json()
except (JSONDecodeError, ContentTypeError): except (JSONDecodeError, ContentTypeError):
return self._not_json return self._not_json
# The room isn't encrypted just forward the message.
if not encrypt:
content_msgtype = content.get("msgtype")
if (
content_msgtype in ["m.image", "m.video", "m.audio", "m.file"]
or msgtype == "m.room.avatar"
):
try:
content["url"] = await self._decrypt_uri(content["url"], client)
if (
"info" in content
and "thumbnail_url" in content["info"]
and content["info"]["thumbnail_url"] is not None
):
content["info"]["thumbnail_url"] = await self._decrypt_uri(
content["info"]["thumbnail_url"], client
)
return await self.forward_to_web(
request, data=json.dumps(content), token=client.access_token
)
except ClientConnectionError as e:
return web.Response(status=500, text=str(e))
except (KeyError, NotDecryptedAvailableError):
return await self.forward_to_web(request, token=client.access_token)
return await self.forward_to_web(request, token=client.access_token)
txnid = request.match_info.get("txnid", uuid4())
async def _send(ignore_unverified=False): async def _send(ignore_unverified=False):
try: try:
content_msgtype = content.get("msgtype")
if (
content_msgtype in ["m.image", "m.video", "m.audio", "m.file"]
or msgtype == "m.room.avatar"
):
upload_info, media_info = self._get_upload_and_media_info(
content["url"]
)
if not upload_info or not media_info:
response = await client.room_send(
room_id, msgtype, content, txnid, ignore_unverified
)
return web.Response(
status=response.transport_response.status,
content_type=response.transport_response.content_type,
headers=CORS_HEADERS,
body=await response.transport_response.read(),
)
media_info.to_content(content, upload_info.mimetype)
if content["info"].get("thumbnail_url", False):
(
thumb_upload_info,
thumb_media_info,
) = self._get_upload_and_media_info(
content["info"]["thumbnail_url"]
)
if thumb_upload_info and thumb_media_info:
thumb_media_info.to_thumbnail(
content, thumb_upload_info.mimetype
)
response = await client.room_send(
room_id, msgtype, content, txnid, ignore_unverified
)
else:
response = await client.room_send( response = await client.room_send(
room_id, msgtype, content, txnid, ignore_unverified room_id, msgtype, content, txnid, ignore_unverified
) )
@ -1056,7 +841,7 @@ class ProxyDaemon:
except SendRetryError as e: except SendRetryError as e:
return web.Response(status=503, text=str(e)) return web.Response(status=503, text=str(e))
# Acquire a semaphore here so we only send out one # Aquire a semaphore here so we only send out one
# UnverifiedDevicesSignal # UnverifiedDevicesSignal
sem = client.send_semaphores[room_id] sem = client.send_semaphores[room_id]
@ -1217,60 +1002,11 @@ class ProxyDaemon:
return web.json_response(result, headers=CORS_HEADERS, status=200) return web.json_response(result, headers=CORS_HEADERS, status=200)
async def upload(self, request): async def download(self, request):
file_name = request.query.get("filename", "") server_name = request.match_info["server_name"]
content_type = request.headers.get("Content-Type", "application/octet-stream") media_id = request.match_info["media_id"]
client = next(iter(self.pan_clients.values())) file_name = request.match_info.get("file_name")
body = await request.read()
try:
response, maybe_keys = await client.upload(
data_provider=BufferedReader(BytesIO(body)),
content_type=content_type,
filename=file_name,
encrypt=True,
filesize=len(body),
)
if not isinstance(response, UploadResponse):
return web.Response(
status=response.transport_response.status,
content_type=response.transport_response.content_type,
headers=CORS_HEADERS,
body=await response.transport_response.read(),
)
self.store.save_upload(
self.name, response.content_uri, file_name, content_type
)
mxc = urlparse(response.content_uri)
mxc_server = mxc.netloc.strip("/")
mxc_path = mxc.path.strip("/")
logger.info(f"Adding media info for {mxc_server}/{mxc_path} to the store")
media_info = MediaInfo(
mxc_server,
mxc_path,
maybe_keys["key"],
maybe_keys["iv"],
maybe_keys["hashes"],
)
self.store.save_media(self.name, media_info)
return web.Response(
status=response.transport_response.status,
content_type=response.transport_response.content_type,
headers=CORS_HEADERS,
body=await response.transport_response.read(),
)
except ClientConnectionError as e:
return web.Response(status=500, text=str(e))
except SendRetryError as e:
return web.Response(status=503, text=str(e))
async def _load_decrypted_file(self, server_name, media_id, file_name):
try: try:
media_info = self.media_info[(server_name, media_id)] media_info = self.media_info[(server_name, media_id)]
except KeyError: except KeyError:
@ -1278,86 +1014,28 @@ class ProxyDaemon:
if not media_info: if not media_info:
logger.info(f"No media info found for {server_name}/{media_id}") logger.info(f"No media info found for {server_name}/{media_id}")
return None, None return await self.forward_to_web(request)
self.media_info[(server_name, media_id)] = media_info self.media_info[(server_name, media_id)] = media_info
try: try:
key = media_info.key["k"] key = media_info.key["k"]
hash = media_info.hashes["sha256"] hash = media_info.hashes["sha256"]
except KeyError as e: except KeyError:
logger.warn( logger.warn(
f"Media info for {server_name}/{media_id} doesn't contain a key or hash." f"Media info for {server_name}/{media_id} doesn't contain a key or hash."
) )
raise e return await self.forward_to_web(request)
if not self.pan_clients: if not self.pan_clients:
return None, None return await self.forward_to_web(request)
client = next(iter(self.pan_clients.values())) client = next(iter(self.pan_clients.values()))
try: try:
response = await client.download( response = await client.download(server_name, media_id, file_name)
server_name=server_name, media_id=media_id, filename=file_name
)
except ClientConnectionError as e:
raise e
if not isinstance(response, DownloadResponse):
return response, None
logger.info(f"Decrypting media {server_name}/{media_id}")
loop = asyncio.get_running_loop()
with concurrent.futures.ProcessPoolExecutor() as pool:
decrypted_file = await loop.run_in_executor(
pool, decrypt_attachment, response.body, key, hash, media_info.iv
)
return response, decrypted_file
async def profile(self, request):
access_token = self.get_access_token(request)
if not access_token:
return self._missing_token
client = await self._find_client(access_token)
if not client:
return self._unknown_token
try:
content = await request.json()
except (JSONDecodeError, ContentTypeError):
return self._not_json
try:
content["avatar_url"] = await self._decrypt_uri(
content["avatar_url"], client
)
return await self.forward_to_web(
request, data=json.dumps(content), token=client.access_token
)
except ClientConnectionError as e: except ClientConnectionError as e:
return web.Response(status=500, text=str(e)) return web.Response(status=500, text=str(e))
except (KeyError, NotDecryptedAvailableError):
return await self.forward_to_web(request, token=client.access_token)
async def download(self, request):
server_name = request.match_info["server_name"]
media_id = request.match_info["media_id"]
file_name = request.match_info.get("file_name")
try:
response, decrypted_file = await self._load_decrypted_file(
server_name, media_id, file_name
)
if response is None and decrypted_file is None:
return await self.forward_to_web(request)
except ClientConnectionError as e:
return web.Response(status=500, text=str(e))
except KeyError:
return await self.forward_to_web(request)
if not isinstance(response, DownloadResponse): if not isinstance(response, DownloadResponse):
return web.Response( return web.Response(
@ -1367,6 +1045,14 @@ class ProxyDaemon:
body=await response.transport_response.read(), body=await response.transport_response.read(),
) )
logger.info(f"Decrypting media {server_name}/{media_id}")
loop = asyncio.get_running_loop()
with concurrent.futures.ProcessPoolExecutor() as pool:
decrypted_file = await loop.run_in_executor(
pool, decrypt_attachment, response.body, key, hash, media_info.iv
)
return web.Response( return web.Response(
status=response.transport_response.status, status=response.transport_response.status,
content_type=response.transport_response.content_type, content_type=response.transport_response.content_type,

View File

@ -12,6 +12,8 @@
# See the License for the specific language governing permissions and # See the License for the specific language governing permissions and
# limitations under the License. # limitations under the License.
from importlib import util
class InvalidQueryError(Exception): class InvalidQueryError(Exception):
pass pass
@ -23,6 +25,7 @@ if False:
import json import json
import os import os
from functools import partial from functools import partial
from typing import Any, Dict, List, Optional, Tuple
import attr import attr
import tantivy import tantivy
@ -229,6 +232,7 @@ if False:
) )
for message in query: for message in query:
event = message.event event = message.event
event_dict = { event_dict = {
@ -499,5 +503,6 @@ if False:
return search_result return search_result
else: else:
INDEXING_ENABLED = False INDEXING_ENABLED = False

View File

@ -15,6 +15,7 @@
import asyncio import asyncio
import os import os
import signal import signal
from typing import Optional
import click import click
import janus import janus
@ -22,13 +23,12 @@ import keyring
import logbook import logbook
import nio import nio
from aiohttp import web from aiohttp import web
from platformdirs import user_config_dir, user_data_dir from appdirs import user_config_dir, user_data_dir
from logbook import StderrHandler from logbook import StderrHandler
from pantalaimon.config import PanConfig, PanConfigError, parse_log_level from pantalaimon.config import PanConfig, PanConfigError, parse_log_level
from pantalaimon.daemon import ProxyDaemon from pantalaimon.daemon import ProxyDaemon
from pantalaimon.log import logger from pantalaimon.log import logger
from pantalaimon.store import KeyDroppingSqliteStore
from pantalaimon.thread_messages import DaemonResponse from pantalaimon.thread_messages import DaemonResponse
from pantalaimon.ui import UI_ENABLED from pantalaimon.ui import UI_ENABLED
@ -47,8 +47,6 @@ def create_dirs(data_dir, conf_dir):
async def init(data_dir, server_conf, send_queue, recv_queue): async def init(data_dir, server_conf, send_queue, recv_queue):
"""Initialize the proxy and the http server.""" """Initialize the proxy and the http server."""
store_class = KeyDroppingSqliteStore if server_conf.drop_old_keys else None
proxy = ProxyDaemon( proxy = ProxyDaemon(
server_conf.name, server_conf.name,
server_conf.homeserver, server_conf.homeserver,
@ -58,56 +56,24 @@ async def init(data_dir, server_conf, send_queue, recv_queue):
recv_queue=recv_queue.async_q if recv_queue else None, recv_queue=recv_queue.async_q if recv_queue else None,
proxy=server_conf.proxy.geturl() if server_conf.proxy else None, proxy=server_conf.proxy.geturl() if server_conf.proxy else None,
ssl=None if server_conf.ssl is True else False, ssl=None if server_conf.ssl is True else False,
client_store_class=store_class,
) )
# 100 MB max POST size app = web.Application()
app = web.Application(client_max_size=1024**2 * 100)
app.add_routes( app.add_routes(
[ [
web.post("/_matrix/client/r0/login", proxy.login), web.post("/_matrix/client/r0/login", proxy.login),
web.post("/_matrix/client/v3/login", proxy.login),
web.get("/_matrix/client/r0/sync", proxy.sync), web.get("/_matrix/client/r0/sync", proxy.sync),
web.get("/_matrix/client/v3/sync", proxy.sync),
web.post("/_matrix/client/r0/createRoom", proxy.createRoom),
web.post("/_matrix/client/v3/createRoom", proxy.createRoom),
web.get("/_matrix/client/r0/rooms/{room_id}/messages", proxy.messages), web.get("/_matrix/client/r0/rooms/{room_id}/messages", proxy.messages),
web.get("/_matrix/client/v3/rooms/{room_id}/messages", proxy.messages),
web.put( web.put(
r"/_matrix/client/r0/rooms/{room_id}/send/{event_type}/{txnid}", r"/_matrix/client/r0/rooms/{room_id}/send/{event_type}/{txnid}",
proxy.send_message, proxy.send_message,
), ),
web.put(
r"/_matrix/client/v3/rooms/{room_id}/send/{event_type}/{txnid}",
proxy.send_message,
),
web.post(
r"/_matrix/client/r0/rooms/{room_id}/send/{event_type}",
proxy.send_message,
),
web.post("/_matrix/client/r0/user/{user_id}/filter", proxy.filter), web.post("/_matrix/client/r0/user/{user_id}/filter", proxy.filter),
web.post("/_matrix/client/v3/user/{user_id}/filter", proxy.filter),
web.post("/.well-known/matrix/client", proxy.well_known), web.post("/.well-known/matrix/client", proxy.well_known),
web.get("/.well-known/matrix/client", proxy.well_known), web.get("/.well-known/matrix/client", proxy.well_known),
web.post("/_matrix/client/r0/search", proxy.search), web.post("/_matrix/client/r0/search", proxy.search),
web.post("/_matrix/client/v3/search", proxy.search),
web.options("/_matrix/client/r0/search", proxy.search_opts), web.options("/_matrix/client/r0/search", proxy.search_opts),
web.options("/_matrix/client/v3/search", proxy.search_opts),
web.get(
"/_matrix/media/v1/download/{server_name}/{media_id}", proxy.download
),
web.get(
"/_matrix/media/v3/download/{server_name}/{media_id}", proxy.download
),
web.get(
"/_matrix/media/v1/download/{server_name}/{media_id}/{file_name}",
proxy.download,
),
web.get(
"/_matrix/media/v3/download/{server_name}/{media_id}/{file_name}",
proxy.download,
),
web.get( web.get(
"/_matrix/media/r0/download/{server_name}/{media_id}", proxy.download "/_matrix/media/r0/download/{server_name}/{media_id}", proxy.download
), ),
@ -115,22 +81,6 @@ async def init(data_dir, server_conf, send_queue, recv_queue):
"/_matrix/media/r0/download/{server_name}/{media_id}/{file_name}", "/_matrix/media/r0/download/{server_name}/{media_id}/{file_name}",
proxy.download, proxy.download,
), ),
web.post(
r"/_matrix/media/r0/upload",
proxy.upload,
),
web.post(
r"/_matrix/media/v3/upload",
proxy.upload,
),
web.put(
r"/_matrix/client/r0/profile/{userId}/avatar_url",
proxy.profile,
),
web.put(
r"/_matrix/client/v3/profile/{userId}/avatar_url",
proxy.profile,
),
] ]
) )
app.router.add_route("*", "/" + "{proxyPath:.*}", proxy.router) app.router.add_route("*", "/" + "{proxyPath:.*}", proxy.router)
@ -175,7 +125,24 @@ async def message_router(receive_queue, send_queue, proxies):
await proxy.receive_message(message) await proxy.receive_message(message)
async def daemon(context, log_level, debug_encryption, config, data_path): @click.command(
help=(
"pantalaimon is a reverse proxy for matrix homeservers that "
"transparently encrypts and decrypts messages for clients that "
"connect to pantalaimon."
)
)
@click.version_option(version="0.5.1", prog_name="pantalaimon")
@click.option(
"--log-level",
type=click.Choice(["error", "warning", "info", "debug"]),
default=None,
)
@click.option("--debug-encryption", is_flag=True)
@click.option("-c", "--config", type=click.Path(exists=True))
@click.option("--data-path", type=click.Path(exists=True))
@click.pass_context
def main(context, log_level, debug_encryption, config, data_path):
loop = asyncio.get_event_loop() loop = asyncio.get_event_loop()
conf_dir = user_config_dir("pantalaimon", "") conf_dir = user_config_dir("pantalaimon", "")
@ -211,8 +178,8 @@ async def daemon(context, log_level, debug_encryption, config, data_path):
if UI_ENABLED: if UI_ENABLED:
from pantalaimon.ui import GlibT from pantalaimon.ui import GlibT
pan_queue = janus.Queue() pan_queue = janus.Queue(loop=loop)
ui_queue = janus.Queue() ui_queue = janus.Queue(loop=loop)
glib_thread = GlibT( glib_thread = GlibT(
pan_queue.sync_q, pan_queue.sync_q,
@ -223,7 +190,7 @@ async def daemon(context, log_level, debug_encryption, config, data_path):
) )
glib_fut = loop.run_in_executor(None, glib_thread.run) glib_fut = loop.run_in_executor(None, glib_thread.run)
message_router_task = asyncio.create_task( message_router_task = loop.create_task(
message_router(ui_queue.async_q, pan_queue.async_q, proxies) message_router(ui_queue.async_q, pan_queue.async_q, proxies)
) )
@ -235,8 +202,11 @@ async def daemon(context, log_level, debug_encryption, config, data_path):
message_router_task = None message_router_task = None
try: try:
for server_conf in pan_conf.servers.values(): for server_conf in pan_conf.servers.values():
proxy, runner, site = await init(data_dir, server_conf, pan_queue, ui_queue) proxy, runner, site = loop.run_until_complete(
init(data_dir, server_conf, pan_queue, ui_queue)
)
servers.append((proxy, runner, site)) servers.append((proxy, runner, site))
proxies.append(proxy) proxies.append(proxy)
@ -250,8 +220,6 @@ async def daemon(context, log_level, debug_encryption, config, data_path):
home = os.path.expanduser("~") home = os.path.expanduser("~")
os.chdir(home) os.chdir(home)
event = asyncio.Event()
def handler(signum, frame): def handler(signum, frame):
raise KeyboardInterrupt raise KeyboardInterrupt
@ -263,48 +231,22 @@ async def daemon(context, log_level, debug_encryption, config, data_path):
f"======== Starting daemon for homeserver " f"======== Starting daemon for homeserver "
f"{proxy.name} on {site.name} ========" f"{proxy.name} on {site.name} ========"
) )
await site.start() loop.run_until_complete(site.start())
click.echo("(Press CTRL+C to quit)") click.echo("(Press CTRL+C to quit)")
await event.wait() loop.run_forever()
except (KeyboardInterrupt, asyncio.CancelledError): except KeyboardInterrupt:
for _, runner, _ in servers: for _, runner, _ in servers:
await runner.cleanup() loop.run_until_complete(runner.cleanup())
if glib_fut: if glib_fut:
await wait_for_glib(glib_thread, glib_fut) loop.run_until_complete(wait_for_glib(glib_thread, glib_fut))
if message_router_task: if message_router_task:
message_router_task.cancel() message_router_task.cancel()
await asyncio.wait({message_router_task}) loop.run_until_complete(asyncio.wait({message_router_task}))
raise loop.close()
@click.command(
help=(
"pantalaimon is a reverse proxy for matrix homeservers that "
"transparently encrypts and decrypts messages for clients that "
"connect to pantalaimon."
)
)
@click.version_option(version="0.10.5", prog_name="pantalaimon")
@click.option(
"--log-level",
type=click.Choice(["error", "warning", "info", "debug"]),
default=None,
)
@click.option("--debug-encryption", is_flag=True)
@click.option("-c", "--config", type=click.Path(exists=True))
@click.option("--data-path", type=click.Path(exists=True))
@click.pass_context
def main(context, log_level, debug_encryption, config, data_path):
try:
asyncio.run(daemon(context, log_level, debug_encryption, config, data_path))
except (KeyboardInterrupt, asyncio.CancelledError):
pass
return
if __name__ == "__main__": if __name__ == "__main__":

View File

@ -20,16 +20,10 @@ import sys
from collections import defaultdict from collections import defaultdict
from itertools import zip_longest from itertools import zip_longest
from typing import List from typing import List
from shlex import split
import attr import attr
import click import click
try:
from gi.repository import GLib from gi.repository import GLib
except ModuleNotFoundError:
from pgi.repository import GLib
from prompt_toolkit import __version__ as ptk_version from prompt_toolkit import __version__ as ptk_version
from prompt_toolkit import HTML, PromptSession, print_formatted_text from prompt_toolkit import HTML, PromptSession, print_formatted_text
from prompt_toolkit.completion import Completer, Completion, PathCompleter from prompt_toolkit.completion import Completer, Completion, PathCompleter
@ -465,7 +459,7 @@ class PanCtl:
def sas_done(self, pan_user, user_id, device_id, _): def sas_done(self, pan_user, user_id, device_id, _):
print( print(
f"Device {device_id} of user {user_id}" f"Device {device_id} of user {user_id}"
f" successfully verified for pan user {pan_user}." f" succesfully verified for pan user {pan_user}."
) )
def show_sas_invite(self, pan_user, user_id, device_id, _): def show_sas_invite(self, pan_user, user_id, device_id, _):
@ -536,7 +530,7 @@ class PanCtl:
) )
if user_list: if user_list:
print(" - Pan users:") print(f" - Pan users:")
user_string = "\n".join(user_list) user_string = "\n".join(user_list)
print_formatted_text(HTML(user_string)) print_formatted_text(HTML(user_string))
@ -547,11 +541,11 @@ class PanCtl:
for device in devices: for device in devices:
if device["trust_state"] == "verified": if device["trust_state"] == "verified":
trust_state = "<ansigreen>Verified</ansigreen>" trust_state = f"<ansigreen>Verified</ansigreen>"
elif device["trust_state"] == "blacklisted": elif device["trust_state"] == "blacklisted":
trust_state = "<ansired>Blacklisted</ansired>" trust_state = f"<ansired>Blacklisted</ansired>"
elif device["trust_state"] == "ignored": elif device["trust_state"] == "ignored":
trust_state = "Ignored" trust_state = f"Ignored"
else: else:
trust_state = "Unset" trust_state = "Unset"
@ -590,7 +584,7 @@ class PanCtl:
parser = PanctlParser(self.commands) parser = PanctlParser(self.commands)
try: try:
args = parser.parse_args(split(result, posix=False)) args = parser.parse_args(result.split())
except ParseError: except ParseError:
continue continue
@ -696,9 +690,9 @@ class PanCtl:
"the pantalaimon daemon." "the pantalaimon daemon."
) )
) )
@click.version_option(version="0.10.5", prog_name="panctl") @click.version_option(version="0.5.1", prog_name="panctl")
def main(): def main():
loop = asyncio.new_event_loop() loop = asyncio.get_event_loop()
glib_loop = GLib.MainLoop() glib_loop = GLib.MainLoop()
try: try:

View File

@ -15,15 +15,13 @@
import json import json
import os import os
from collections import defaultdict from collections import defaultdict
from typing import Any, Dict from typing import List, Optional, Tuple
import attr import attr
from nio.crypto import TrustState, GroupSessionStore from nio.crypto import TrustState
from nio.store import ( from nio.store import (
Accounts, Accounts,
MegolmInboundSessions,
DeviceKeys, DeviceKeys,
SqliteStore,
DeviceTrustState, DeviceTrustState,
use_database, use_database,
use_database_atomic, use_database_atomic,
@ -31,8 +29,8 @@ from nio.store import (
from peewee import SQL, DoesNotExist, ForeignKeyField, Model, SqliteDatabase, TextField from peewee import SQL, DoesNotExist, ForeignKeyField, Model, SqliteDatabase, TextField
from cachetools import LRUCache from cachetools import LRUCache
MAX_LOADED_MEDIA = 10000 MAX_LOADED_MEDIA = 10000
MAX_LOADED_UPLOAD = 10000
@attr.s @attr.s
@ -49,33 +47,6 @@ class MediaInfo:
iv = attr.ib(type=str) iv = attr.ib(type=str)
hashes = attr.ib(type=dict) hashes = attr.ib(type=dict)
def to_content(self, content: Dict, mime_type: str) -> Dict[Any, Any]:
content["file"] = {
"v": "v2",
"key": self.key,
"iv": self.iv,
"hashes": self.hashes,
"url": content["url"],
"mimetype": mime_type,
}
def to_thumbnail(self, content: Dict, mime_type: str) -> Dict[Any, Any]:
content["info"]["thumbnail_file"] = {
"v": "v2",
"key": self.key,
"iv": self.iv,
"hashes": self.hashes,
"url": content["info"]["thumbnail_url"],
"mimetype": mime_type,
}
@attr.s
class UploadInfo:
content_uri = attr.ib(type=str)
filename = attr.ib(type=str)
mimetype = attr.ib(type=str)
class DictField(TextField): class DictField(TextField):
def python_value(self, value): # pragma: no cover def python_value(self, value): # pragma: no cover
@ -142,18 +113,6 @@ class PanMediaInfo(Model):
constraints = [SQL("UNIQUE(server_id, mxc_server, mxc_path)")] constraints = [SQL("UNIQUE(server_id, mxc_server, mxc_path)")]
class PanUploadInfo(Model):
server = ForeignKeyField(
model=Servers, column_name="server_id", backref="upload", on_delete="CASCADE"
)
content_uri = TextField()
filename = TextField()
mimetype = TextField()
class Meta:
constraints = [SQL("UNIQUE(server_id, content_uri)")]
@attr.s @attr.s
class ClientInfo: class ClientInfo:
user_id = attr.ib(type=str) user_id = attr.ib(type=str)
@ -176,7 +135,6 @@ class PanStore:
PanSyncTokens, PanSyncTokens,
PanFetcherTasks, PanFetcherTasks,
PanMediaInfo, PanMediaInfo,
PanUploadInfo,
] ]
def __attrs_post_init__(self): def __attrs_post_init__(self):
@ -204,43 +162,6 @@ class PanStore:
except DoesNotExist: except DoesNotExist:
return None return None
@use_database
def save_upload(self, server, content_uri, filename, mimetype):
server = Servers.get(name=server)
PanUploadInfo.insert(
server=server,
content_uri=content_uri,
filename=filename,
mimetype=mimetype,
).on_conflict_ignore().execute()
@use_database
def load_upload(self, server, content_uri=None):
server, _ = Servers.get_or_create(name=server)
if not content_uri:
upload_cache = LRUCache(maxsize=MAX_LOADED_UPLOAD)
for i, u in enumerate(server.upload):
if i > MAX_LOADED_UPLOAD:
break
upload = UploadInfo(u.content_uri, u.filename, u.mimetype)
upload_cache[u.content_uri] = upload
return upload_cache
else:
u = PanUploadInfo.get_or_none(
PanUploadInfo.server == server,
PanUploadInfo.content_uri == content_uri,
)
if not u:
return None
return UploadInfo(u.content_uri, u.filename, u.mimetype)
@use_database @use_database
def save_media(self, server, media): def save_media(self, server, media):
server = Servers.get(name=server) server = Servers.get(name=server)
@ -255,8 +176,10 @@ class PanStore:
).on_conflict_ignore().execute() ).on_conflict_ignore().execute()
@use_database @use_database
def load_media_cache(self, server): def load_media(self, server, mxc_server=None, mxc_path=None):
server, _ = Servers.get_or_create(name=server) server, _ = Servers.get_or_create(name=server)
if not mxc_path:
media_cache = LRUCache(maxsize=MAX_LOADED_MEDIA) media_cache = LRUCache(maxsize=MAX_LOADED_MEDIA)
for i, m in enumerate(server.media): for i, m in enumerate(server.media):
@ -267,11 +190,7 @@ class PanStore:
media_cache[(m.mxc_server, m.mxc_path)] = media media_cache[(m.mxc_server, m.mxc_path)] = media
return media_cache return media_cache
else:
@use_database
def load_media(self, server, mxc_server=None, mxc_path=None):
server, _ = Servers.get_or_create(name=server)
m = PanMediaInfo.get_or_none( m = PanMediaInfo.get_or_none(
PanMediaInfo.server == server, PanMediaInfo.server == server,
PanMediaInfo.mxc_server == mxc_server, PanMediaInfo.mxc_server == mxc_server,
@ -307,7 +226,6 @@ class PanStore:
user=user, room_id=task.room_id, token=task.token user=user, room_id=task.room_id, token=task.token
).execute() ).execute()
@use_database
def load_fetcher_tasks(self, server, pan_user): def load_fetcher_tasks(self, server, pan_user):
server = Servers.get(name=server) server = Servers.get(name=server)
user = ServerUsers.get(server=server, user_id=pan_user) user = ServerUsers.get(server=server, user_id=pan_user)
@ -431,6 +349,7 @@ class PanStore:
device_store = defaultdict(dict) device_store = defaultdict(dict)
for d in account.device_keys: for d in account.device_keys:
if d.deleted: if d.deleted:
continue continue
@ -453,47 +372,3 @@ class PanStore:
store[account.user_id] = device_store store[account.user_id] = device_store
return store return store
class KeyDroppingSqliteStore(SqliteStore):
@use_database
def save_inbound_group_session(self, session):
"""Save the provided Megolm inbound group session to the database.
Args:
session (InboundGroupSession): The session to save.
"""
account = self._get_account()
assert account
MegolmInboundSessions.delete().where(
MegolmInboundSessions.sender_key == session.sender_key,
MegolmInboundSessions.account == account,
MegolmInboundSessions.room_id == session.room_id,
).execute()
super().save_inbound_group_session(session)
@use_database
def load_inbound_group_sessions(self):
store = super().load_inbound_group_sessions()
return KeyDroppingGroupSessionStore.from_group_session_store(store)
class KeyDroppingGroupSessionStore(GroupSessionStore):
def from_group_session_store(store):
new_store = KeyDroppingGroupSessionStore()
new_store._entries = store._entries
return new_store
def add(self, session) -> bool:
room_id = session.room_id
sender_key = session.sender_key
if session in self._entries[room_id][sender_key].values():
return False
self._entries[room_id][sender_key].clear()
self._entries[room_id][sender_key][session.id] = session
return True

View File

@ -30,7 +30,6 @@ if UI_ENABLED:
from gi.repository import GLib from gi.repository import GLib
from pydbus import SessionBus from pydbus import SessionBus
from pydbus.generic import signal from pydbus.generic import signal
from dbus.mainloop.glib import DBusGMainLoop
from nio import RoomKeyRequest, RoomKeyRequestCancellation from nio import RoomKeyRequest, RoomKeyRequestCancellation
@ -448,7 +447,6 @@ if UI_ENABLED:
config = attr.ib() config = attr.ib()
loop = attr.ib(init=False) loop = attr.ib(init=False)
dbus_loop = attr.ib(init=False)
store = attr.ib(init=False) store = attr.ib(init=False)
users = attr.ib(init=False) users = attr.ib(init=False)
devices = attr.ib(init=False) devices = attr.ib(init=False)
@ -459,7 +457,6 @@ if UI_ENABLED:
def __attrs_post_init__(self): def __attrs_post_init__(self):
self.loop = None self.loop = None
self.dbus_loop = None
id_counter = IdCounter() id_counter = IdCounter()
@ -470,14 +467,14 @@ if UI_ENABLED:
self.bus.publish("org.pantalaimon1", self.control_if, self.device_if) self.bus.publish("org.pantalaimon1", self.control_if, self.device_if)
def unverified_notification(self, message): def unverified_notification(self, message):
notification = notify2.Notification( notificaton = notify2.Notification(
"Unverified devices.", "Unverified devices.",
message=( message=(
f"There are unverified devices in the room " f"There are unverified devices in the room "
f"{message.room_display_name}." f"{message.room_display_name}."
), ),
) )
notification.set_category("im") notificaton.set_category("im")
def send_cb(notification, action_key, user_data): def send_cb(notification, action_key, user_data):
message = user_data message = user_data
@ -488,20 +485,20 @@ if UI_ENABLED:
self.control_if.CancelSending(message.pan_user, message.room_id) self.control_if.CancelSending(message.pan_user, message.room_id)
if "actions" in notify2.get_server_caps(): if "actions" in notify2.get_server_caps():
notification.add_action("send", "Send anyways", send_cb, message) notificaton.add_action("send", "Send anyways", send_cb, message)
notification.add_action("cancel", "Cancel sending", cancel_cb, message) notificaton.add_action("cancel", "Cancel sending", cancel_cb, message)
notification.show() notificaton.show()
def sas_invite_notification(self, message): def sas_invite_notification(self, message):
notification = notify2.Notification( notificaton = notify2.Notification(
"Key verification invite", "Key verification invite",
message=( message=(
f"{message.user_id} via {message.device_id} has started " f"{message.user_id} via {message.device_id} has started "
f"a key verification process." f"a key verification process."
), ),
) )
notification.set_category("im") notificaton.set_category("im")
def accept_cb(notification, action_key, user_data): def accept_cb(notification, action_key, user_data):
message = user_data message = user_data
@ -516,17 +513,17 @@ if UI_ENABLED:
) )
if "actions" in notify2.get_server_caps(): if "actions" in notify2.get_server_caps():
notification.add_action("accept", "Accept", accept_cb, message) notificaton.add_action("accept", "Accept", accept_cb, message)
notification.add_action("cancel", "Cancel", cancel_cb, message) notificaton.add_action("cancel", "Cancel", cancel_cb, message)
notification.show() notificaton.show()
def sas_show_notification(self, message): def sas_show_notification(self, message):
emojis = [x[0] for x in message.emoji] emojis = [x[0] for x in message.emoji]
emoji_str = " ".join(emojis) emoji_str = " ".join(emojis)
notification = notify2.Notification( notificaton = notify2.Notification(
"Short authentication string", "Short authentication string",
message=( message=(
f"Short authentication string for the key verification of" f"Short authentication string for the key verification of"
@ -534,7 +531,7 @@ if UI_ENABLED:
f"{emoji_str}" f"{emoji_str}"
), ),
) )
notification.set_category("im") notificaton.set_category("im")
def confirm_cb(notification, action_key, user_data): def confirm_cb(notification, action_key, user_data):
message = user_data message = user_data
@ -549,21 +546,21 @@ if UI_ENABLED:
) )
if "actions" in notify2.get_server_caps(): if "actions" in notify2.get_server_caps():
notification.add_action("confirm", "Confirm", confirm_cb, message) notificaton.add_action("confirm", "Confirm", confirm_cb, message)
notification.add_action("cancel", "Cancel", cancel_cb, message) notificaton.add_action("cancel", "Cancel", cancel_cb, message)
notification.show() notificaton.show()
def sas_done_notification(self, message): def sas_done_notification(self, message):
notification = notify2.Notification( notificaton = notify2.Notification(
"Device successfully verified.", "Device successfully verified.",
message=( message=(
f"Device {message.device_id} of user {message.user_id} " f"Device {message.device_id} of user {message.user_id} "
f"successfully verified." f"successfully verified."
), ),
) )
notification.set_category("im") notificaton.set_category("im")
notification.show() notificaton.show()
def message_callback(self): def message_callback(self):
try: try:
@ -635,12 +632,11 @@ if UI_ENABLED:
return True return True
def run(self): def run(self):
self.dbus_loop = DBusGMainLoop()
self.loop = GLib.MainLoop() self.loop = GLib.MainLoop()
if self.config.notifications: if self.config.notifications:
try: try:
notify2.init("pantalaimon", mainloop=self.dbus_loop) notify2.init("pantalaimon", mainloop=self.loop)
self.notifications = True self.notifications = True
except dbus.DBusException: except dbus.DBusException:
logger.error( logger.error(
@ -650,10 +646,6 @@ if UI_ENABLED:
self.notifications = False self.notifications = False
GLib.timeout_add(100, self.message_callback) GLib.timeout_add(100, self.message_callback)
if not self.loop:
return
self.loop.run() self.loop.run()
def stop(self): def stop(self):

View File

@ -7,42 +7,41 @@ with open("README.md", encoding="utf-8") as f:
setup( setup(
name="pantalaimon", name="pantalaimon",
version="0.10.5", version="0.5.1",
url="https://github.com/matrix-org/pantalaimon", url="https://github.com/matrix-org/pantalaimon",
author="The Matrix.org Team", author="The Matrix.org Team",
author_email="poljar@termina.org.uk", author_email="poljar@termina.org.uk",
description=("A Matrix proxy daemon that adds E2E encryption " "capabilities."), description=("A Matrix proxy daemon that adds E2E encryption "
"capabilities."),
long_description=long_description, long_description=long_description,
long_description_content_type="text/markdown", long_description_content_type="text/markdown",
license="Apache License, Version 2.0", license="Apache License, Version 2.0",
packages=find_packages(), packages=find_packages(),
install_requires=[ install_requires=[
"attrs >= 19.3.0", "attrs",
"aiohttp >= 3.6, < 4.0", "aiohttp",
"platformdirs >= 4.3.6", "appdirs",
"click >= 7.1.2", "click",
"keyring >= 21.2.1", "keyring",
"logbook >= 1.5.3", "logbook",
"peewee >= 3.13.1", "peewee",
"janus >= 0.5", "janus",
"cachetools >= 3.0.0", "cachetools >= 3.0.0"
"prompt_toolkit > 2, < 4", "prompt_toolkit>2<4",
"typing;python_version<'3.5'", "typing;python_version<'3.5'",
"matrix-nio[e2e] >= 0.24, < 0.25.2", "matrix-nio[e2e] >= 0.8.0"
], ],
extras_require={ extras_require={
"ui": [ "ui": [
"dbus-python >= 1.2, < 1.3", "dbus-python",
"PyGObject >= 3.46, < 3.50", "PyGObject",
"pydbus >= 0.6, < 0.7", "pydbus",
"notify2 >= 0.3, < 0.4", "notify2",
] ]
}, },
entry_points={ entry_points={
"console_scripts": [ "console_scripts": ["pantalaimon=pantalaimon.main:main",
"pantalaimon=pantalaimon.main:main", "panctl=pantalaimon.panctl:main"],
"panctl=pantalaimon.panctl:main",
],
}, },
zip_safe=False, zip_safe=False
) )

View File

@ -34,9 +34,11 @@ class Provider(BaseProvider):
def client(self): def client(self):
return ClientInfo(faker.mx_id(), faker.access_token()) return ClientInfo(faker.mx_id(), faker.access_token())
def avatar_url(self): def avatar_url(self):
return "mxc://{}/{}#auto".format( return "mxc://{}/{}#auto".format(
faker.hostname(), "".join(choices(ascii_letters) for i in range(24)) faker.hostname(),
"".join(choices(ascii_letters) for i in range(24))
) )
def olm_key_pair(self): def olm_key_pair(self):
@ -54,6 +56,7 @@ class Provider(BaseProvider):
) )
faker.add_provider(Provider) faker.add_provider(Provider)
@ -77,7 +80,13 @@ def tempdir():
@pytest.fixture @pytest.fixture
def panstore(tempdir): def panstore(tempdir):
for _ in range(10): for _ in range(10):
store = SqliteStore(faker.mx_id(), faker.device_id(), tempdir, "", "pan.db") store = SqliteStore(
faker.mx_id(),
faker.device_id(),
tempdir,
"",
"pan.db"
)
account = OlmAccount() account = OlmAccount()
store.save_account(account) store.save_account(account)
@ -107,10 +116,10 @@ async def pan_proxy_server(tempdir, aiohttp_server):
server_name = faker.hostname() server_name = faker.hostname()
config = ServerConfig(server_name, urlparse("https://example.org"), keyring=False) config = ServerConfig(server_name, urlparse("https://example.org"))
pan_queue = janus.Queue() pan_queue = janus.Queue(loop=loop)
ui_queue = janus.Queue() ui_queue = janus.Queue(loop=loop)
proxy = ProxyDaemon( proxy = ProxyDaemon(
config.name, config.name,
@ -121,23 +130,21 @@ async def pan_proxy_server(tempdir, aiohttp_server):
recv_queue=ui_queue.async_q, recv_queue=ui_queue.async_q,
proxy=None, proxy=None,
ssl=False, ssl=False,
client_store_class=SqliteStore, client_store_class=SqliteStore
) )
app.add_routes( app.add_routes([
[
web.post("/_matrix/client/r0/login", proxy.login), web.post("/_matrix/client/r0/login", proxy.login),
web.get("/_matrix/client/r0/sync", proxy.sync), web.get("/_matrix/client/r0/sync", proxy.sync),
web.get("/_matrix/client/r0/rooms/{room_id}/messages", proxy.messages), web.get("/_matrix/client/r0/rooms/{room_id}/messages", proxy.messages),
web.put( web.put(
r"/_matrix/client/r0/rooms/{room_id}/send/{event_type}/{txnid}", r"/_matrix/client/r0/rooms/{room_id}/send/{event_type}/{txnid}",
proxy.send_message, proxy.send_message
), ),
web.post("/_matrix/client/r0/user/{user_id}/filter", proxy.filter), web.post("/_matrix/client/r0/user/{user_id}/filter", proxy.filter),
web.post("/_matrix/client/r0/search", proxy.search), web.post("/_matrix/client/r0/search", proxy.search),
web.options("/_matrix/client/r0/search", proxy.search_opts), web.options("/_matrix/client/r0/search", proxy.search_opts),
] ])
)
server = await aiohttp_server(app) server = await aiohttp_server(app)
@ -154,7 +161,7 @@ async def running_proxy(pan_proxy_server, aioresponse, aiohttp_client):
"access_token": "abc123", "access_token": "abc123",
"device_id": "GHTYAJCE", "device_id": "GHTYAJCE",
"home_server": "example.org", "home_server": "example.org",
"user_id": "@example:example.org", "user_id": "@example:example.org"
} }
aioclient = await aiohttp_client(server) aioclient = await aiohttp_client(server)
@ -163,7 +170,7 @@ async def running_proxy(pan_proxy_server, aioresponse, aiohttp_client):
"https://example.org/_matrix/client/r0/login", "https://example.org/_matrix/client/r0/login",
status=200, status=200,
payload=login_response, payload=login_response,
repeat=True, repeat=True
) )
await aioclient.post( await aioclient.post(
@ -172,7 +179,7 @@ async def running_proxy(pan_proxy_server, aioresponse, aiohttp_client):
"type": "m.login.password", "type": "m.login.password",
"user": "example", "user": "example",
"password": "wordpass", "password": "wordpass",
}, }
) )
yield server, aioclient, proxy, queues yield server, aioclient, proxy, queues

View File

@ -25,10 +25,10 @@ ALICE_ID = "@alice:example.org"
@pytest.fixture @pytest.fixture
async def client(tmpdir): async def client(tmpdir, loop):
store = PanStore(tmpdir) store = PanStore(tmpdir)
queue = janus.Queue() queue = janus.Queue(loop=loop)
conf = ServerConfig("example", "https://example.org") conf = ServerConfig("example", "https://exapmle.org")
conf.history_fetch_delay = 0.1 conf.history_fetch_delay = 0.1
store.save_server_user("example", "@example:example.org") store.save_server_user("example", "@example:example.org")
@ -371,7 +371,7 @@ class TestClass(object):
await client.loop_stop() await client.loop_stop()
async def test_history_fetching_tasks(self, client, aioresponse): async def test_history_fetching_tasks(self, client, aioresponse, loop):
if not INDEXING_ENABLED: if not INDEXING_ENABLED:
pytest.skip("Indexing needs to be enabled to test this") pytest.skip("Indexing needs to be enabled to test this")
@ -380,9 +380,7 @@ class TestClass(object):
) )
aioresponse.get( aioresponse.get(
sync_url, sync_url, status=200, payload=self.initial_sync_response,
status=200,
payload=self.initial_sync_response,
) )
aioresponse.get(sync_url, status=200, payload=self.empty_sync, repeat=True) aioresponse.get(sync_url, status=200, payload=self.empty_sync, repeat=True)
@ -423,7 +421,7 @@ class TestClass(object):
tasks = client.pan_store.load_fetcher_tasks(client.server_name, client.user_id) tasks = client.pan_store.load_fetcher_tasks(client.server_name, client.user_id)
assert len(tasks) == 1 assert len(tasks) == 1
# Check that the task is our prev_batch from the sync response # Check that the task is our prev_batch from the sync resposne
assert tasks[0].room_id == TEST_ROOM_ID assert tasks[0].room_id == TEST_ROOM_ID
assert tasks[0].token == "t392-516_47314_0_7_1_1_1_11444_1" assert tasks[0].token == "t392-516_47314_0_7_1_1_1_11444_1"
@ -433,7 +431,7 @@ class TestClass(object):
tasks = client.pan_store.load_fetcher_tasks(client.server_name, client.user_id) tasks = client.pan_store.load_fetcher_tasks(client.server_name, client.user_id)
assert len(tasks) == 1 assert len(tasks) == 1
# Check that the task is our end token from the messages response # Check that the task is our end token from the messages resposne
assert tasks[0].room_id == TEST_ROOM_ID assert tasks[0].room_id == TEST_ROOM_ID
assert tasks[0].token == "t47409-4357353_219380_26003_2265" assert tasks[0].token == "t47409-4357353_219380_26003_2265"
@ -447,7 +445,7 @@ class TestClass(object):
await client.loop_stop() await client.loop_stop()
async def test_history_fetching_resume(self, client, aioresponse): async def test_history_fetching_resume(self, client, aioresponse, loop):
if not INDEXING_ENABLED: if not INDEXING_ENABLED:
pytest.skip("Indexing needs to be enabled to test this") pytest.skip("Indexing needs to be enabled to test this")
@ -456,9 +454,7 @@ class TestClass(object):
) )
aioresponse.get( aioresponse.get(
sync_url, sync_url, status=200, payload=self.initial_sync_response,
status=200,
payload=self.initial_sync_response,
) )
aioresponse.get(sync_url, status=200, payload=self.empty_sync, repeat=True) aioresponse.get(sync_url, status=200, payload=self.empty_sync, repeat=True)
@ -523,7 +519,7 @@ class TestClass(object):
) )
assert len(tasks) == 1 assert len(tasks) == 1
# Check that the task is our end token from the messages response # Check that the task is our end token from the messages resposne
assert tasks[0].room_id == TEST_ROOM_ID assert tasks[0].room_id == TEST_ROOM_ID
assert tasks[0].token == "t47409-4357353_219380_26003_2265" assert tasks[0].token == "t47409-4357353_219380_26003_2265"

View File

@ -1,7 +1,9 @@
import asyncio
import json import json
import re import re
from collections import defaultdict from collections import defaultdict
from aiohttp import web
from nio.crypto import OlmDevice from nio.crypto import OlmDevice
from conftest import faker from conftest import faker
@ -25,7 +27,7 @@ class TestClass(object):
"access_token": "abc123", "access_token": "abc123",
"device_id": "GHTYAJCE", "device_id": "GHTYAJCE",
"home_server": "example.org", "home_server": "example.org",
"user_id": "@example:example.org", "user_id": "@example:example.org"
} }
@property @property
@ -34,7 +36,12 @@ class TestClass(object):
@property @property
def keys_upload_response(self): def keys_upload_response(self):
return {"one_time_key_counts": {"curve25519": 10, "signed_curve25519": 20}} return {
"one_time_key_counts": {
"curve25519": 10,
"signed_curve25519": 20
}
}
@property @property
def example_devices(self): def example_devices(self):
@ -45,7 +52,10 @@ class TestClass(object):
devices[device.user_id][device.id] = device devices[device.user_id][device.id] = device
bob_device = OlmDevice( bob_device = OlmDevice(
BOB_ID, BOB_DEVICE, {"ed25519": BOB_ONETIME, "curve25519": BOB_CURVE} BOB_ID,
BOB_DEVICE,
{"ed25519": BOB_ONETIME,
"curve25519": BOB_CURVE}
) )
devices[BOB_ID][BOB_DEVICE] = bob_device devices[BOB_ID][BOB_DEVICE] = bob_device
@ -61,7 +71,7 @@ class TestClass(object):
"https://example.org/_matrix/client/r0/login", "https://example.org/_matrix/client/r0/login",
status=200, status=200,
payload=self.login_response, payload=self.login_response,
repeat=True, repeat=True
) )
assert not daemon.pan_clients assert not daemon.pan_clients
@ -72,7 +82,7 @@ class TestClass(object):
"type": "m.login.password", "type": "m.login.password",
"user": "example", "user": "example",
"password": "wordpass", "password": "wordpass",
}, }
) )
assert resp.status == 200 assert resp.status == 200
@ -95,11 +105,11 @@ class TestClass(object):
"https://example.org/_matrix/client/r0/login", "https://example.org/_matrix/client/r0/login",
status=200, status=200,
payload=self.login_response, payload=self.login_response,
repeat=True, repeat=True
) )
sync_url = re.compile( sync_url = re.compile(
r"^https://example\.org/_matrix/client/r0/sync\?access_token=.*" r'^https://example\.org/_matrix/client/r0/sync\?access_token=.*'
) )
aioresponse.get( aioresponse.get(
@ -114,16 +124,14 @@ class TestClass(object):
"type": "m.login.password", "type": "m.login.password",
"user": "example", "user": "example",
"password": "wordpass", "password": "wordpass",
}, }
) )
# Check that the pan client started to sync after logging in. # Check that the pan client started to sync after logging in.
pan_client = list(daemon.pan_clients.values())[0] pan_client = list(daemon.pan_clients.values())[0]
assert len(pan_client.rooms) == 1 assert len(pan_client.rooms) == 1
async def test_pan_client_keys_upload( async def test_pan_client_keys_upload(self, pan_proxy_server, aiohttp_client, aioresponse):
self, pan_proxy_server, aiohttp_client, aioresponse
):
server, daemon, _ = pan_proxy_server server, daemon, _ = pan_proxy_server
client = await aiohttp_client(server) client = await aiohttp_client(server)
@ -132,11 +140,11 @@ class TestClass(object):
"https://example.org/_matrix/client/r0/login", "https://example.org/_matrix/client/r0/login",
status=200, status=200,
payload=self.login_response, payload=self.login_response,
repeat=True, repeat=True
) )
sync_url = re.compile( sync_url = re.compile(
r"^https://example\.org/_matrix/client/r0/sync\?access_token=.*" r'^https://example\.org/_matrix/client/r0/sync\?access_token=.*'
) )
aioresponse.get( aioresponse.get(
@ -161,7 +169,7 @@ class TestClass(object):
"type": "m.login.password", "type": "m.login.password",
"user": "example", "user": "example",
"password": "wordpass", "password": "wordpass",
}, }
) )
pan_client = list(daemon.pan_clients.values())[0] pan_client = list(daemon.pan_clients.values())[0]

View File

@ -1,12 +1,14 @@
import asyncio import asyncio
import pdb
import pprint import pprint
import pytest import pytest
from nio import RoomMessage, RoomEncryptedMedia from nio import RoomMessage, RoomEncryptedMedia
from urllib.parse import urlparse from urllib.parse import urlparse
from conftest import faker
from pantalaimon.index import INDEXING_ENABLED from pantalaimon.index import INDEXING_ENABLED
from pantalaimon.store import FetchTask, MediaInfo, UploadInfo from pantalaimon.store import FetchTask, MediaInfo
TEST_ROOM = "!SVkFJHzfwvuaIEawgC:localhost" TEST_ROOM = "!SVkFJHzfwvuaIEawgC:localhost"
TEST_ROOM2 = "!testroom:localhost" TEST_ROOM2 = "!testroom:localhost"
@ -25,7 +27,7 @@ class TestClass(object):
"type": "m.room.message", "type": "m.room.message",
"unsigned": {"age": 43289803095}, "unsigned": {"age": 43289803095},
"user_id": "@example2:localhost", "user_id": "@example2:localhost",
"age": 43289803095, "age": 43289803095
} }
) )
@ -41,14 +43,13 @@ class TestClass(object):
"type": "m.room.message", "type": "m.room.message",
"unsigned": {"age": 43289803095}, "unsigned": {"age": 43289803095},
"user_id": "@example2:localhost", "user_id": "@example2:localhost",
"age": 43289803095, "age": 43289803095
} }
) )
@property @property
def encrypted_media_event(self): def encrypted_media_event(self):
return RoomEncryptedMedia.from_dict( return RoomEncryptedMedia.from_dict({
{
"room_id": "!testroom:localhost", "room_id": "!testroom:localhost",
"event_id": "$15163622445EBvZK:localhost", "event_id": "$15163622445EBvZK:localhost",
"origin_server_ts": 1516362244030, "origin_server_ts": 1516362244030,
@ -64,21 +65,21 @@ class TestClass(object):
"ext": True, "ext": True,
"k": "yx0QvkgYlasdWEsdalkejaHBzCkKEBAp3tB7dGtWgrs", "k": "yx0QvkgYlasdWEsdalkejaHBzCkKEBAp3tB7dGtWgrs",
"key_ops": ["encrypt", "decrypt"], "key_ops": ["encrypt", "decrypt"],
"kty": "oct", "kty": "oct"
}, },
"iv": "0pglXX7fspIBBBBAEERLFd", "iv": "0pglXX7fspIBBBBAEERLFd",
"hashes": { "hashes": {
"sha256": "eXRDFvh+aXsQRj8a+5ZVVWUQ9Y6u9DYiz4tq1NvbLu8" "sha256": "eXRDFvh+aXsQRj8a+5ZVVWUQ9Y6u9DYiz4tq1NvbLu8"
}, },
"url": "mxc://localhost/maDtasSiPFjROFMnlwxIhhyW", "url": "mxc://localhost/maDtasSiPFjROFMnlwxIhhyW",
"mimetype": "image/jpeg", "mimetype": "image/jpeg"
},
},
} }
) }
})
def test_account_loading(self, panstore): def test_account_loading(self, panstore):
accounts = panstore.load_all_users() accounts = panstore.load_all_users()
# pdb.set_trace()
assert len(accounts) == 10 assert len(accounts) == 10
def test_token_saving(self, panstore, access_token): def test_token_saving(self, panstore, access_token):
@ -129,8 +130,7 @@ class TestClass(object):
if not INDEXING_ENABLED: if not INDEXING_ENABLED:
pytest.skip("Indexing needs to be enabled to test this") pytest.skip("Indexing needs to be enabled to test this")
from pantalaimon.index import IndexStore from pantalaimon.index import Index, IndexStore
loop = asyncio.get_event_loop() loop = asyncio.get_event_loop()
store = IndexStore("example", tempdir) store = IndexStore("example", tempdir)
@ -148,14 +148,12 @@ class TestClass(object):
assert len(result["results"]) == 1 assert len(result["results"]) == 1
assert result["count"] == 1 assert result["count"] == 1
assert result["results"][0]["result"] == self.test_event.source assert result["results"][0]["result"] == self.test_event.source
assert ( assert (result["results"][0]["context"]["events_after"][0]
result["results"][0]["context"]["events_after"][0] == self.another_event.source)
== self.another_event.source
)
def test_media_storage(self, panstore): def test_media_storage(self, panstore):
server_name = "test" server_name = "test"
media_cache = panstore.load_media_cache(server_name) media_cache = panstore.load_media(server_name)
assert not media_cache assert not media_cache
event = self.encrypted_media_event event = self.encrypted_media_event
@ -173,31 +171,9 @@ class TestClass(object):
panstore.save_media(server_name, media) panstore.save_media(server_name, media)
media_cache = panstore.load_media_cache(server_name) media_cache = panstore.load_media(server_name)
assert (mxc_server, mxc_path) in media_cache assert (mxc_server, mxc_path) in media_cache
media_info = media_cache[(mxc_server, mxc_path)] media_info = media_cache[(mxc_server, mxc_path)]
assert media_info == media assert media_info == media
assert media_info == panstore.load_media(server_name, mxc_server, mxc_path) assert media_info == panstore.load_media(server_name, mxc_server, mxc_path)
def test_upload_storage(self, panstore):
server_name = "test"
upload_cache = panstore.load_upload(server_name)
assert not upload_cache
filename = "orange_cat.jpg"
mimetype = "image/jpeg"
event = self.encrypted_media_event
assert not panstore.load_upload(server_name, event.url)
upload = UploadInfo(event.url, filename, mimetype)
panstore.save_upload(server_name, event.url, filename, mimetype)
upload_cache = panstore.load_upload(server_name)
assert (event.url) in upload_cache
upload_info = upload_cache[event.url]
assert upload_info == upload
assert upload_info == panstore.load_upload(server_name, event.url)

13
tox.ini
View File

@ -1,14 +1,21 @@
# content of: tox.ini , put in same dir as setup.py
[tox] [tox]
envlist = coverage envlist = py36,py37,coverage
[testenv] [testenv]
basepython =
py36: python3.6
py37: python3.7
py3: python3.7
deps = -rtest-requirements.txt deps = -rtest-requirements.txt
install_command = pip install {opts} {packages} install_command = pip install {opts} {packages}
passenv = TOXENV,CI passenv = TOXENV CI TRAVIS TRAVIS_*
commands = pytest commands = pytest
usedevelop = True
[testenv:coverage] [testenv:coverage]
basepython = python3.7
commands = commands =
pytest --cov=pantalaimon --cov-report term-missing pytest --cov=pantalaimon --cov-report term-missing
coverage xml coverage xml