Compare commits

...

72 Commits

Author SHA1 Message Date
ca49fb3037
Main file added 2020-11-15 20:46:34 +01:00
60c87ecb19 Merge master branch 2020-04-19 20:43:35 +02:00
25cf7271f4 Test 10 2020-04-19 20:00:29 +02:00
8b07741c59 Test 9 2020-04-19 19:58:37 +02:00
14992dd19b Test 8 2020-04-19 19:56:43 +02:00
f52429fc4d Test 7 2020-04-19 19:54:52 +02:00
bd8eb7da4c Test 6 2020-04-19 19:45:48 +02:00
f56fc6d60c Test 5 2020-04-19 19:42:55 +02:00
f6e65d5495 Test 4 2020-04-19 19:42:18 +02:00
05cc3cb8e6 Test 3 2020-04-19 19:41:06 +02:00
a381805c3c Test 2 2020-04-19 19:37:19 +02:00
1f200b30ab Test 2020-04-19 19:33:03 +02:00
60761ebc5e Typo 2020-04-19 16:28:35 +02:00
b4e6be4bfa create_certificate script provided 2020-04-19 16:28:24 +02:00
557363d3de Working on SSL certificate generation 2020-04-19 00:04:48 +02:00
7311ef3e72 Working on SSL certificate generation 2020-04-18 19:17:25 +02:00
22a20b98fc Handle SSL exceptions 2020-04-17 21:39:59 +02:00
f21a7fdfb9 Test 2020-04-17 21:09:46 +02:00
2ff847b44b Simplify timed_input definition 2020-04-17 20:13:57 +02:00
90df61d3a6 Serious bug silently bypassing SSL context fixed. Previous versions do not really support SSL! 2020-04-17 16:20:55 +02:00
e40989e304 Implemented non-unix timed_input function 2020-04-15 16:27:45 +02:00
4ba5065271 New version 2020-04-13 23:11:43 +02:00
e77d4b4146 Prevent bad behaviour when terminal windows is too small 2020-04-13 23:10:41 +02:00
d703054c58 Documentation 2020-04-13 20:57:39 +02:00
3f5384f9e9 Refactoring 2020-04-13 19:45:05 +02:00
3b7aa265ab Refactoring 2020-04-13 12:58:37 +02:00
932760bcb6 Log server disconnection as error, not info 2020-04-12 19:26:46 +02:00
5f0ed1295f Organize as package 2020-04-12 19:15:06 +02:00
4c56ff7723 Whitespace again 2020-04-12 11:54:30 +02:00
be6f8bbafc Whitespace again 2020-04-12 11:53:50 +02:00
0877a08713 Whitespace 2020-04-12 11:50:38 +02:00
31ab51ec52 Whitespace 2020-04-12 11:49:54 +02:00
e203e35f8e Newline after each variable 2020-04-12 11:43:18 +02:00
bf57baad45 Typos 2020-04-12 11:39:30 +02:00
4fc9ebc38a Offer to store new settings in config file 2020-04-12 11:35:44 +02:00
a95d0aaa91 Do not check file_path if it is None (again) 2020-04-12 10:51:50 +02:00
ae41102d4c False requirement 2020-04-12 01:24:49 +02:00
2aead918bf Do not check file_path if it is None 2020-04-12 00:29:39 +02:00
4a05b05ace Show progress bar in client 2020-04-12 00:25:04 +02:00
8bd0ac76f2 Use original file name 2020-04-11 21:48:37 +02:00
b77de07d6e Use main function, avoid unnecessary trailing underscores 2020-04-11 20:55:26 +02:00
4f01831169 Pass file information to receiver client 2020-04-11 20:35:18 +02:00
e68ab4282c Allow multiple client connections 2020-04-11 19:59:09 +02:00
c0dd046670 Misplacement of writer.drain() 2020-04-10 16:09:39 +02:00
f1d54861ee Whitespace 2020-04-10 16:08:31 +02:00
4352f2908b Quotes prevent variable splitting if file name has spaces 2020-04-10 16:06:45 +02:00
5f1c8fdf47 Send file while encrypting it 2020-04-10 15:15:45 +02:00
0069f9e5ea Allow end-to-end encryption 2020-04-10 13:41:36 +02:00
685b4e6756 Fixed ssl transmission 2020-04-10 10:18:24 +02:00
f7a9f76aad TODO: adjust to ssl 2020-04-09 23:44:01 +02:00
db0da8b24b Implemented SSL 2020-04-09 23:34:04 +02:00
1ec3a4b5e2 Set self.working 2020-04-09 23:06:55 +02:00
ca3aa5857b Get client role as first line 2020-04-09 23:05:03 +02:00
1853d85a79 Typo 2020-04-09 22:33:01 +02:00
40f48e3b95 Cancel pending tasks although they are done 2020-04-09 22:16:46 +02:00
458cc0f5e7 Prevent shadowing of variables 2020-04-09 22:01:50 +02:00
25750736f4 Allow multiple sessions 2020-04-09 19:51:10 +02:00
d2522b3e08 Command Line Interface 2020-04-09 19:46:52 +02:00
d4abc1f2a5 TODO: ask peer its role 2020-04-09 14:51:02 +02:00
9dba9673a3 Use DNS name 2020-04-09 14:45:38 +02:00
018bea5606 Use the same port 2020-04-08 14:53:59 +02:00
7549977fc9 Use aruba as host 2020-03-30 18:01:49 +02:00
248d4ccb88 IT WORKS! 2020-03-28 20:54:41 +01:00
69c06dc4dc almos exhausted 2020-03-28 19:53:06 +01:00
c8409e12ef Works with 2 ports 2020-03-28 19:37:20 +01:00
70f2cd64d7 A lot of work for nothing? 2020-03-28 19:02:03 +01:00
bc287833cc May have found a similar idea, but too tired to give a look
See you next time
2020-03-26 15:02:12 +01:00
cab7527c63 Working on server but getting errors
ConnectionRefusedError: [Errno 111] Connect call failed ('127.0.0.1', 5000)
2020-03-26 14:49:17 +01:00
40dba845fd Working on server 2020-03-26 12:32:01 +01:00
971927a798 Working on server 2020-03-26 10:54:01 +01:00
add5b42a3b asyncore is deprecated 2020-03-25 22:34:39 +01:00
a932184757 test 2020-03-25 22:34:05 +01:00
10 changed files with 1660 additions and 1 deletions

7
.gitignore vendored
View File

@ -1,4 +1,11 @@
# ---> Python
# Configuration file
*config.py
# Data folder
data/
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]

View File

@ -1,3 +1,89 @@
# filebridging
Share files via a bridge server.
Share files via a bridge server using TCP over SSL and end-to-end encryption.
## Requirements
Python3.8+ is needed for this package.
You may find it [here](https://www.python.org/downloads/).
OpenSSL 1.1.1+ is required as well to handle SSL connection and end-to-end cryptography.
On Windows, installing [git for Windows](https://gitforwindows.org/) will install OpenSSL as well.
## Usage
If you need a virtual environment, create it.
```bash
python3.8 -m venv env;
alias pip="env/bin/pip";
alias python="env/bin/python";
```
Install filebridging and read the help.
```bash
pip install filebridging
python -m filebridging.server --help
python -m filebridging.client --help
```
## Examples
* Client-server example
```bash
# 3 distinct tabs
python -m filebridging.server --host localhost --port 5000 --certificate ~/.ssh/server.crt --key ~/.ssh/server.key
python -m filebridging.client s --host localhost --port 5000 --certificate ~/.ssh/server.crt --token 12345678 --password supersecretpasswordhere --path ~/file_to_send
python -m filebridging.client r --host localhost --port 5000 --certificate ~/.ssh/server.crt --token 12345678 --password supersecretpasswordhere --path ~/Downloads
```
* Client-client example
```bash
# 2 distinct tabs
python -m filebridging.client s --host localhost --port 5000 --certificate ~/.ssh/server.crt --key ~/.ssh/private.key --token 12345678 --password supersecretpasswordhere --path ~/file_to_send --standalone
python -m filebridging.client r --host localhost --port 5000 --certificate ~/.ssh/server.crt --token 12345678 --password supersecretpasswordhere --path ~/Downloads
```
The receiver client may be standalone as well: just add the `--key` parameter (for SSL-secured sessions) and the `--standalone` flag.
* Configuration file example
```python
#!/bin/python
host = "www.example.com"
port = 5000
certificate = "/path/to/public.crt"
key = "/path/to/private.key"
action = 'r'
password = 'verysecretpassword'
token = 'sessiontok'
file_path = '.'
```
## Generating SSL certificates
You may use `filebridging.create_certificate.py` script or use openssl from the command line.
### Via script
```bash
python -m filebridging.create_certificate --name example --domain example.com --force
```
### Via command line
Store configuration in file `mycert.csr.cnf` and run the following command to generate a self-signed SSL certificate.
```bash
openssl req -newkey rsa:4096 -nodes -keyout ./mycert.key \
-x509 -days 365 -out ./mycert.crt \
-config mycert.csr.cnf
```
**mycert.csr.cnf**
```text
[ req ]
default_bits = 4096
prompt = no
default_md = sha256
distinguished_name = dn
[ dn ]
CN = yourdomain.com
```

18
filebridging/__init__.py Normal file
View File

@ -0,0 +1,18 @@
"""General information about this package.
Python 3.8+ is needed to use this package.
```python3.8+
from filebridging.client import Client
from filebridging.server import Server
help(Client)
help(Server)
```
"""
__author__ = "Davide Testa"
__email__ = "davide@davte.it"
__credits__ = []
__license__ = "GNU General Public License v3.0"
__version__ = "0.0.10"
__maintainer__ = "Davide Testa"
__contact__ = "t.me/davte"

6
filebridging/__main__.py Normal file
View File

@ -0,0 +1,6 @@
mode = input("Do you want to run a filebridging (S)erver or (C)lient?\t\t")
if mode.lower().startswith('s'):
from .server import main
else:
from .client import main
main()

814
filebridging/client.py Normal file
View File

@ -0,0 +1,814 @@
"""Receiver and sender client class.
Arguments
- host: localhost, IPv4 address or domain (e.g. www.example.com)
- port: port to reach (must be enabled)
- action: either [S]end or [R]eceive
- file_path: file to send / destination folder
- token: session token (6-10 alphanumerical characters)
- certificate [optional]: server certificate for SSL
- key [optional]: needed only for standalone clients
- password [optional]: necessary to end-to-end encryption
- standalone [optional]: allow client-to-client communication (the host
must be reachable by both clients)
"""
import argparse
import asyncio
import collections
import logging
import os
import random
import ssl
import string
import sys
from typing import Union
from . import utilities
class Client:
"""Sender or receiver client.
Create a Client object providing host, port and other optional parameters.
Then, run it with `Client().run()` method
"""
def __init__(self, host='localhost', port=5000, ssl_context=None,
action=None,
standalone=False,
buffer_chunk_size=10 ** 4,
buffer_length_limit=10 ** 4,
file_path=None,
password=None,
token=None,
ssl_handshake_timeout=None):
self._host = host
self._port = port
self._ssl_context = ssl_context
self._action = action
self._standalone = standalone
self._file_path = file_path
self._password = password
self._token = token
self._ssl_handshake_timeout = ssl_handshake_timeout
# How many bytes per chunk
self._buffer_chunk_size = buffer_chunk_size
# How many chunks in buffer
self._buffer_length_limit = buffer_length_limit
# Shared queue of bytes
self.buffer = collections.deque()
self._working = False
self._stopping = False
self._reader = None
self._writer = None
self._encryption_complete = False
self._file_name = None
self._file_size = None
self._file_size_string = None
@property
def host(self) -> str:
"""Host to reach.
For standalone clients, you must be able to listen this host.
"""
return self._host
@property
def port(self) -> int:
"""Port number."""
return self._port
@property
def action(self) -> str:
"""Client role.
Possible values:
- `send`
- `receive`
"""
return self._action
@property
def standalone(self) -> bool:
"""Tell whether client should run as server as well."""
return self._standalone
@property
def stopping(self) -> bool:
return self._stopping
@property
def reader(self) -> asyncio.StreamReader:
return self._reader
@property
def writer(self) -> asyncio.StreamWriter:
return self._writer
@property
def buffer_length_limit(self) -> int:
"""Max number of buffer chunks in memory.
You may want to reduce this limit to allocate less memory, or increase
it to boost performance.
"""
return self._buffer_length_limit
@property
def buffer_chunk_size(self) -> int:
"""Length (bytes) of buffer chunks in memory.
You may want to reduce this limit to allocate less memory, or increase
it to boost performance.
"""
return self._buffer_chunk_size
@property
def file_path(self) -> str:
"""Path of file to send or destination folder."""
return self._file_path
@property
def working(self) -> bool:
return self._working
@property
def ssl_context(self) -> ssl.SSLContext:
return self._ssl_context
def set_ssl_context(self, ssl_context: ssl.SSLContext):
self._ssl_context = ssl_context
@property
def ssl_handshake_timeout(self) -> Union[int, None]:
"""Return SSL handshake timeout.
If SSL context is not set, return None.
Otherwise, return seconds to wait before considering handshake failed.
"""
if self.ssl_context:
return self._ssl_handshake_timeout
@property
def token(self):
"""Session token.
6-10 alphanumerical characters to provide to server to link sender and
receiver.
"""
return self._token
@property
def password(self):
"""Password for file encryption or decryption."""
return self._password
@property
def encryption_complete(self):
return self._encryption_complete
@property
def file_name(self):
return self._file_name
@property
def file_size(self):
return self._file_size
@property
def file_size_string(self):
"""Formatted file size (e.g. 64.22 MB)."""
return self._file_size_string
async def run_client(self) -> None:
if self.action == 'send':
file_name = os.path.basename(os.path.abspath(self.file_path))
file_size = os.path.getsize(os.path.abspath(self.file_path))
# File size increases after encryption
# "Salted_" (8 bytes) + salt (8 bytes)
# Then, 1-16 bytes are added to make file_size a multiple of 16
# i.e., (32 - file_size mod 16) bytes are added to original size
if self.password:
file_size += 32 - (file_size % 16)
self.set_file_information(
file_name=file_name,
file_size=file_size
)
if self.standalone:
server = await asyncio.start_server(
ssl=self.ssl_context,
client_connected_cb=self._connect,
host=self.host,
port=self.port,
)
async with server:
logging.info("Running at `{s.host}:{s.port}`".format(s=self))
await server.serve_forever()
else:
try:
reader, writer = await asyncio.open_connection(
host=self.host,
port=self.port,
ssl=self.ssl_context,
ssl_handshake_timeout=self.ssl_handshake_timeout
)
except (ConnectionRefusedError, ConnectionResetError,
ConnectionAbortedError) as exception:
logging.error(f"Connection error: {exception}")
return
except ssl.SSLError as exception:
logging.error(f"SSL error: {exception}")
return
await self.connect(reader=reader, writer=writer)
async def _connect(self, reader: asyncio.StreamReader,
writer: asyncio.StreamWriter):
"""Wrap connect method to catch exceptions.
This is required since callbacks are never awaited and potential
exception would be logged at loop.close().
Only standalone clients need this wrapper, regular clients might use
connect method directly.
"""
try:
return await self.connect(reader, writer)
except KeyboardInterrupt:
print()
except Exception as e:
logging.error(e)
async def connect(self,
reader: asyncio.StreamReader,
writer: asyncio.StreamWriter):
"""Communicate with the server or the other client.
Send information about the client (connection token, role, file name
and size), get information from the server (file name and size), wait
for start signal and then send or receive the file.
"""
self._reader = reader
self._writer = writer
async def _write(message: Union[list, str, bytes],
terminate_line=True) -> int:
"""Framework for `asyncio.StreamWriter.write` method.
Create string from list, encode it, send and drain writer.
Return 0 on success, 1 on error.
"""
# Adapt
if type(message) is list:
message = '|'.join(map(str, message))
if type(message) is str:
if terminate_line:
message += '\n'
message = message.encode('utf-8')
if type(message) is not bytes:
return 1
try:
writer.write(message)
await writer.drain()
except ConnectionResetError:
logging.error("Client disconnected.")
except Exception as e:
logging.error(f"Unexpected exception:\n{e}", exc_info=True)
else:
return 0 # On success, return 0
# On exception, return 1
return 1
if self.action == 'send' or not self.standalone:
if await _write(
[self.action[0], self.token,
self.file_name, self.file_size]
):
return
# Wait for server start signal
while 1:
server_hello = await self.reader.readline()
if not server_hello:
logging.error("Server refused connection.")
return
server_hello = server_hello.decode('utf-8').strip('\n').split('|')
if self.action == 'receive' and server_hello[0] == 's':
self.set_file_information(file_name=server_hello[2],
file_size=server_hello[3])
elif (
self.standalone
and self.action == 'send'
and server_hello[0] == 'r'
):
# Check token
if server_hello[1] != self.token:
if await _write("Invalid session token!"):
return
return
elif server_hello[0] == 'start!':
break
else:
logging.info(f"Server said: {'|'.join(server_hello)}")
if self.standalone:
if await _write("start!"):
return
break
if self.action == 'send':
await self.send(writer=self.writer)
else:
await self.receive(reader=self.reader)
async def encrypt_file(self, input_file, output_file):
"""Use openssl to encrypt the input_file.
The encrypted file will overwrite `output_file` if it exists.
"""
self._encryption_complete = False
logging.info("Encrypting file...")
stdout, stderr = ''.encode(), ''.encode()
try:
_subprocess = await asyncio.create_subprocess_shell(
"openssl enc -aes-256-cbc "
"-md sha512 -pbkdf2 -iter 100000 -salt "
f"-in \"{input_file}\" -out \"{output_file}\" "
f"-pass pass:{self.password}"
)
stdout, stderr = await _subprocess.communicate()
except Exception as e:
logging.error(
"Exception {e}:\n{o}\n{er}".format(
e=e,
o=stdout.decode().strip(),
er=stderr.decode().strip()
)
)
logging.info("Encryption completed.")
self._encryption_complete = True
async def send(self, writer: asyncio.StreamWriter):
"""Encrypt and send the file.
Caution: if no password is provided, the file will be sent as clear
text.
"""
self._working = True
file_path = self.file_path
if self.password:
file_path = self.file_path + '.enc'
# Remove already-encrypted file if present (salt would differ)
if os.path.isfile(file_path):
os.remove(file_path)
asyncio.ensure_future(
self.encrypt_file(
input_file=self.file_path,
output_file=file_path
)
)
# Give encryption an edge
while not os.path.isfile(file_path):
await asyncio.sleep(.5)
logging.info("Sending file...")
bytes_sent = 0
with open(file_path, 'rb') as file_to_send:
while not self.stopping:
output_data = file_to_send.read(self.buffer_chunk_size)
if not output_data:
# If encryption is in progress, wait and read again later
if self.password and not self.encryption_complete:
await asyncio.sleep(1)
continue
break
try:
writer.write(output_data)
await asyncio.wait_for(writer.drain(), timeout=3.0)
except ConnectionResetError:
print() # New line after progress_bar
logging.error('Server closed the connection.')
self.stop()
break
except asyncio.exceptions.TimeoutError:
print() # New line after progress_bar
logging.error('Server closed the connection.')
self.stop()
break
bytes_sent += len(output_data)
new_progress = min(
int(bytes_sent / self.file_size * 100),
100
)
self.print_progress_bar(
progress=new_progress,
bytes_=bytes_sent,
force=(new_progress == 100)
)
print() # New line after progress_bar
writer.close()
return
async def receive(self, reader: asyncio.StreamReader):
"""Download the file and decrypt it.
If no password is provided, the file cannot be decrypted.
"""
self._working = True
file_path = os.path.join(
os.path.abspath(
self.file_path
),
self.file_name
)
original_file_path = file_path
if self.password:
file_path += '.enc'
logging.info("Receiving file...")
with open(file_path, 'wb') as file_to_receive:
bytes_received = 0
while not self.stopping:
input_data = await reader.read(self.buffer_chunk_size)
bytes_received += len(input_data)
new_progress = min(
int(bytes_received / self.file_size * 100),
100
)
self.print_progress_bar(
progress=new_progress,
bytes_=bytes_received,
force=(new_progress == 100)
)
if not input_data:
break
file_to_receive.write(input_data)
print() # New line after sys.stdout.write
if bytes_received < self.file_size:
logging.warning("Transmission terminated too soon!")
if self.password:
logging.error("Partial files can not be decrypted!")
return
logging.info("File received.")
if self.password:
logging.info("Decrypting file...")
stdout, stderr = ''.encode(), ''.encode()
try:
_subprocess = await asyncio.create_subprocess_shell(
"openssl enc -aes-256-cbc "
"-md sha512 -pbkdf2 -iter 100000 -salt -d "
f"-in \"{file_path}\" -out \"{original_file_path}\" "
f"-pass pass:{self.password}"
)
stdout, stderr = await _subprocess.communicate()
logging.info("Decryption completed.")
except Exception as e:
logging.error(
"Exception {e}:\n{o}\n{er}".format(
e=e,
o=stdout.decode().strip(),
er=stderr.decode().strip()
)
)
logging.info("Decryption failed", exc_info=True)
def stop(self, *_):
if self.working:
logging.info("Received interruption signal, stopping...")
self._stopping = True
if self.writer:
self.writer.close()
else:
raise KeyboardInterrupt("Not working yet...")
def set_file_information(self, file_name=None, file_size=None):
if file_name is not None:
self._file_name = file_name
if file_size is not None:
self._file_size = int(file_size)
self._file_size_string = utilities.get_file_size_representation(
self.file_size
)
def run(self):
loop = asyncio.get_event_loop()
try:
loop.run_until_complete(
self.run_client()
)
except KeyboardInterrupt:
print()
logging.error("Interrupted")
for task in asyncio.all_tasks(loop):
task.cancel()
if self.writer:
self.writer.close()
loop.run_until_complete(
self.wait_closed()
)
loop.close()
@utilities.timed_action(interval=0.4)
def print_progress_bar(self, progress: int, bytes_: int):
"""Print client progress bar.
`progress` % = `bytes_string` transferred
out of `self.file_size_string`.
"""
action = {
'send': "Sending",
'receive': "Receiving"
}[self.action]
bytes_string = utilities.get_file_size_representation(
bytes_
)
utilities.print_progress_bar(
prefix=f"\t\t\t{action} `{self.file_name}`: ",
done_symbol='#',
pending_symbol='.',
progress=progress,
scale=5,
suffix=(
" completed "
f"({bytes_string} "
f"of {self.file_size_string})"
)
)
@staticmethod
async def wait_closed() -> None:
"""Give time to cancelled tasks to end properly.
Sleep .1 second and return.
"""
await asyncio.sleep(.1)
def get_action(action):
"""Parse abbreviations for `action`."""
if not isinstance(action, str):
return
elif action.lower().startswith('r'):
return 'receive'
elif action.lower().startswith('s'):
return 'send'
def get_file_path(path, action='receive'):
"""Check that file `path` is correct and return it."""
path = os.path.abspath(
os.path.expanduser(path)
)
if (
isinstance(path, str)
and action == 'send'
and os.path.isfile(path)
):
return path
elif (
isinstance(path, str)
and action == 'receive'
and os.access(os.path.dirname(path), os.W_OK)
):
return path
elif path is not None:
logging.error(f"Invalid file: `{path}`")
def main():
# noinspection SpellCheckingInspection
log_formatter = logging.Formatter(
"%(asctime)s [%(module)-15s %(levelname)-8s] %(message)s",
style='%'
)
root_logger = logging.getLogger()
root_logger.setLevel(logging.DEBUG)
# noinspection PyUnresolvedReferences
asyncio.selector_events.logger.setLevel(logging.ERROR)
console_handler = logging.StreamHandler()
console_handler.setFormatter(log_formatter)
console_handler.setLevel(logging.DEBUG)
root_logger.addHandler(console_handler)
# Parse command-line arguments
cli_parser = argparse.ArgumentParser(description='Run client',
allow_abbrev=False)
cli_parser.add_argument('--host', type=str,
default=None,
required=False,
help='server address')
cli_parser.add_argument('--port', type=int,
default=None,
required=False,
help='server port')
cli_parser.add_argument('--certificate', type=str,
default=None,
required=False,
help='server SSL certificate')
cli_parser.add_argument('--key', type=str,
default=None,
required=False,
help='server SSL key (required only for '
'SSL-secured standalone client)')
cli_parser.add_argument('--action', type=str,
default=None,
required=False,
help='[S]end or [R]eceive')
cli_parser.add_argument('--path', type=str,
default=None,
required=False,
help='File path to send / folder path to receive')
cli_parser.add_argument('--password', '--p', '--pass', type=str,
default=None,
required=False,
help='Password for file encryption or decryption')
cli_parser.add_argument('--token', '--t', '--session_token', type=str,
default=None,
required=False,
help='Session token '
'(must be the same for both clients)')
cli_parser.add_argument('--standalone',
action='store_true',
help='Run both as client and server')
cli_parser.add_argument('others',
metavar='R or S',
nargs='*',
help='[S]end or [R]eceive (see `action`)')
args = vars(cli_parser.parse_args())
host = args['host']
port = args['port']
certificate = args['certificate']
key = args['key']
action = get_action(args['action'])
file_path = args['path']
password = args['password']
token = args['token']
standalone = args['standalone']
# If host and port are not provided from command-line, try to import them
sys.path.append(os.path.abspath('.'))
if host is None:
try:
from config import host
except ImportError:
host = None
if port is None:
try:
from config import port
except ImportError:
port = None
# Take `s`, `r` etc. from command line as `action`
if action is None:
for arg in args['others']:
action = get_action(arg)
if action:
break
if action is None:
try:
from config import action
action = get_action(action)
except ImportError:
action = None
if file_path is None:
try:
from config import file_path
file_path = get_action(file_path)
except ImportError:
file_path = None
if password is None:
try:
from config import password
except ImportError:
password = None
if token is None:
try:
from config import token
except ImportError:
token = None
if certificate is None or not os.path.isfile(certificate):
try:
from config import certificate
except ImportError:
certificate = None
if key is None or not os.path.isfile(key):
try:
from config import key
except ImportError:
key = None
# If import fails, prompt user for host or port
new_settings = {} # After getting these settings, offer to store them
while host is None:
host = input("Enter host:\t\t\t\t\t\t")
new_settings['host'] = host
while port is None:
try:
port = int(input("Enter port:\t\t\t\t\t\t"))
except ValueError:
logging.info("Invalid port. Enter a valid port number!")
port = None
new_settings['port'] = port
while action is None:
action = get_action(
input("Do you want to (R)eceive or (S)end a file?\t\t")
)
if file_path is not None and (
(action == 'send'
and not os.path.isfile(os.path.abspath(file_path)))
or (action == 'receive'
and not os.path.isdir(os.path.abspath(file_path)))
):
file_path = None
while file_path is None:
if action == 'send':
file_path = get_file_path(
path=input(f"Enter file to send:\t\t\t\t\t"),
action=action
)
if file_path and not os.path.isfile(os.path.abspath(file_path)):
file_path = None
elif action == 'receive':
file_path = get_file_path(
path=input(f"Enter destination folder:\t\t\t\t"),
action=action
)
if file_path and not os.path.isdir(os.path.abspath(file_path)):
file_path = None
if password is None:
logging.warning(
"You have provided no password for file encryption.\n"
"Your file will be unencoded unless you provide a password in "
"config file."
)
if token is None and action == 'send':
# Generate a random [6-10] chars-long alphanumerical token
token = ''.join(
random.SystemRandom().choice(
string.ascii_uppercase + string.digits
)
for _ in range(random.SystemRandom().randint(6, 10))
)
logging.info(
"You have not provided a token for this connection.\n"
f"A token has been generated for you:\t\t\t{token}\n"
"Your peer must be informed of this token.\n"
"For future connections, you may provide a custom token writing "
"it in config file."
)
while token is None or not (6 <= len(token) <= 10):
token = input("Please enter a 6-10 chars token.\t\t\t")
if new_settings:
answer = utilities.timed_input(
"You may store the following configuration values in "
"`config.py`.\n\n" + '\n'.join(
'\t\t'.join(map(str, item))
for item in new_settings.items()
) + '\n\n'
'Do you want to store them?\t\t\t\t',
timeout=3
)
if answer:
with open('config.py', 'a') as configuration_file:
configuration_file.writelines(
[
f'{name} = "{value}"\n'
if type(value) is str
else f'{name} = {value}\n'
for name, value in new_settings.items()
]
)
logging.info("Configuration values stored.")
else:
logging.info("Proceeding without storing values...")
ssl_context = None
if certificate and key and standalone: # Standalone client
ssl_context = ssl.create_default_context(
purpose=ssl.Purpose.CLIENT_AUTH
)
ssl_context.load_cert_chain(certificate, key)
elif certificate: # Server-dependent client
ssl_context = ssl.create_default_context(
purpose=ssl.Purpose.SERVER_AUTH
)
ssl_context.load_verify_locations(certificate)
else:
logging.warning(
"Please consider using SSL. To do so, add in `config.py` or "
"provide via Command Line Interface the path to a valid SSL "
"certificate. Example:\n\n"
"certificate = 'path/to/certificate.crt'"
)
logging.info("Starting client...")
client = Client(
host=host,
port=port,
ssl_context=ssl_context,
action=action,
standalone=standalone,
file_path=file_path,
password=password,
token=token
)
client.run()
logging.info("Stopped client")
if __name__ == '__main__':
main()

View File

@ -0,0 +1,126 @@
"""Create a SSL certificate.
Requirements: OpenSSL.
"""
import argparse
import logging
import os
import subprocess
def get_paths(path):
""""""
return [
os.path.abspath(path) + string
for string in (".crt", ".key", "csr.cnf")
]
def main():
# noinspection SpellCheckingInspection
log_formatter = logging.Formatter(
"%(asctime)s [%(module)-15s %(levelname)-8s] %(message)s",
style='%'
)
root_logger = logging.getLogger()
root_logger.setLevel(logging.DEBUG)
console_handler = logging.StreamHandler()
console_handler.setFormatter(log_formatter)
console_handler.setLevel(logging.DEBUG)
root_logger.addHandler(console_handler)
cli_parser = argparse.ArgumentParser(description='Create SSL certificate',
allow_abbrev=False)
cli_parser.add_argument('-n', '--name',
type=str,
default=None,
required=False,
help='Certificate, key and configuration file name')
cli_parser.add_argument('-d', '--domain',
type=str,
default=None,
required=False,
help='Server domain (e.g. example.com)')
cli_parser.add_argument('-f', '--force', '--overwrite',
action='store_true',
help='Overwrite certificate and key if they exist')
arguments = vars(cli_parser.parse_args())
name = arguments['name']
if name is None:
try:
from config import name
except ImportError:
name = None
while not name or not os.access(os.path.dirname(os.path.abspath(name)),
os.W_OK):
try:
name = input(
"Enter a valid file name for certificate, key and "
"configuration file. Directory must be writeable.\n"
"\t\t"
)
except KeyboardInterrupt:
print()
logging.error("Aborting...")
return
certificate_path, key_path, configuration_path = get_paths(
name
)
if not os.access(os.path.dirname(certificate_path), os.W_OK):
logging.error(f"Invalid path `{certificate_path}`!")
return
if any(
os.path.isfile(path)
for path in (certificate_path, key_path, configuration_path)
) and not arguments['force'] and not input(
"Do you want to overwrite existing certificate, key and "
"configuration file?"
"\n[Y]es or [N]o\t\t\t\t"
).lower().startswith('y'):
logging.error("Interrupted. Provide a different --name.")
return
domain = arguments['domain']
if domain is None:
try:
from config import domain
except ImportError:
domain = None
while not domain:
domain = input("Enter server domain (e.g. example.com)\n\t\t")
with open(configuration_path, 'w') as configuration_file:
logging.info("Writing configuration file...")
configuration_file.write(
"[req]\n"
"default_bits = 4096\n"
"prompt = no\n"
"default_md = sha256\n"
"distinguished_name = dn\n"
"\n"
"[dn]\n"
f"CN = {domain}\n"
)
logging.info("Generating certificate and key...")
subprocess.run(
[
f"openssl req -newkey rsa:4096 -nodes "
f"-keyout \"{key_path}\" -x509 -days 365 "
f"-out \"{certificate_path}\" "
f"-config \"{configuration_path}\""
],
capture_output=True,
text=True,
shell=True
)
with open(certificate_path, 'r') as certificate_file:
logging.info(
"Certificate:\n\n{certificate}".format(
certificate=''.join(certificate_file.readlines())
),
)
logging.info("Done!")
if __name__ == '__main__':
main()

385
filebridging/server.py Normal file
View File

@ -0,0 +1,385 @@
"""Server class.
May be a local server or a publicly reachable server.
Arguments
- host: localhost, IPv4 address or domain (e.g. www.example.com)
- port: port to reach (must be enabled)
- certificate [optional]: server certificate for SSL
- key [optional]: needed only for standalone clients
"""
import argparse
import asyncio
import collections
import logging
import os
import ssl
from typing import Union
class Server:
def __init__(self, host='localhost', port=5000, ssl_context=None,
buffer_chunk_size=10 ** 4, buffer_length_limit=10 ** 4):
self._host = host
self._port = port
self._ssl_context = ssl_context
self.connections = collections.OrderedDict()
# Dict of queues of bytes
self.buffers = collections.OrderedDict()
# How many bytes per chunk
self._buffer_chunk_size = buffer_chunk_size
# How many chunks in buffer
self._buffer_length_limit = buffer_length_limit
self._working = False
self._server = None
@property
def host(self) -> str:
return self._host
@property
def port(self) -> int:
return self._port
@property
def buffer_length_limit(self) -> int:
return self._buffer_length_limit
@property
def buffer_chunk_size(self) -> int:
return self._buffer_chunk_size
@property
def working(self) -> bool:
return self._working
@property
def server(self) -> asyncio.base_events.Server:
return self._server
@property
def ssl_context(self) -> ssl.SSLContext:
return self._ssl_context
@property
def buffer_is_full(self):
return (
sum(len(buffer)
for buffer in self.buffers.values())
>= self.buffer_length_limit
)
def set_ssl_context(self, ssl_context: ssl.SSLContext):
self._ssl_context = ssl_context
async def run_reader(self, reader: asyncio.StreamReader, connection_token):
while 1:
try:
# Wait one second if buffer is full
while self.buffer_is_full:
await asyncio.sleep(1)
continue
input_data = await reader.read(self.buffer_chunk_size)
if connection_token not in self.buffers:
break
self.buffers[connection_token].append(input_data)
except ConnectionResetError as e:
logging.error(e)
break
except Exception as e:
logging.error(f"Unexpected exception:\n{e}", exc_info=True)
async def run_writer(self, writer: asyncio.StreamWriter, connection_token):
consecutive_interruptions = 0
errors = 0
while connection_token in self.buffers:
try:
input_data = self.buffers[connection_token].popleft()
except IndexError:
# Slow down if buffer is empty; after 1.5 s of silence, break
consecutive_interruptions += 1
if consecutive_interruptions > 3:
logging.error("Too many interruptions...")
break
await asyncio.sleep(.5)
continue
else:
consecutive_interruptions = 0
if not input_data:
break
try:
writer.write(input_data)
await writer.drain()
except ConnectionResetError as e:
logging.error(e)
break
except Exception as e:
logging.error(e, exc_info=True)
errors += 1
if errors > 3:
break
await asyncio.sleep(0.5)
writer.close()
async def connect(self,
reader: asyncio.StreamReader,
writer: asyncio.StreamWriter):
"""Connect with client.
Decide whether client is sender or receiver and start transmission.
"""
client_hello = await reader.readline()
try:
client_hello = client_hello.decode('utf-8').strip('\n').split('|')
except UnicodeDecodeError:
logging.error("Invalid client hello.")
return
if len(client_hello) != 4:
await self.refuse_connection(writer=writer,
message="Invalid client_hello!")
return
connection_token = client_hello[1]
if connection_token not in self.connections:
self.connections[connection_token] = dict(
sender=False,
receiver=False
)
async def _write(message: Union[list, str, bytes],
terminate_line=True) -> int:
# Adapt
if type(message) is list:
message = '|'.join(map(str, message))
if type(message) is str:
if terminate_line:
message += '\n'
message = message.encode('utf-8')
if type(message) is not bytes:
return 1
try:
writer.write(message)
await writer.drain()
except ConnectionResetError:
logging.error("Client disconnected.")
except Exception as e:
logging.error(f"Unexpected exception:\n{e}", exc_info=True)
else:
return 0 # On success, return 0
# On exception, disconnect and return 1
logging.error("Disconnecting...")
self.disconnect(connection_token=connection_token)
return 1
if client_hello[0] == 's': # Sender client connection
if self.connections[connection_token]['sender']:
await self.refuse_connection(
writer=writer,
message="Invalid token! "
"A sender client is already connected!\n"
)
return
self.connections[connection_token]['sender'] = True
self.connections[connection_token]['file_name'] = client_hello[2]
self.connections[connection_token]['file_size'] = client_hello[3]
self.buffers[connection_token] = collections.deque()
logging.info("Sender is connecting...")
index, step = 0, 1
while not self.connections[connection_token]['receiver']:
index += 1
if index >= step:
if await _write("Waiting for receiver..."):
return
step += 1
index = 0
await asyncio.sleep(.5)
# Send start signal to client
if await _write("start!"):
return
logging.info("Incoming transmission starting...")
await self.run_reader(reader=reader,
connection_token=connection_token)
logging.info("Incoming transmission ended")
elif client_hello[0] == 'r': # Receiver client connection
if self.connections[connection_token]['receiver']:
await self.refuse_connection(
writer=writer,
message="Invalid token! "
"A receiver client is already connected!\n"
)
return
self.connections[connection_token]['receiver'] = True
logging.info("Receiver is connecting...")
index, step = 0, 1
while not self.connections[connection_token]['sender']:
index += 1
if index >= step:
if await _write("Waiting for sender..."):
return
step += 1
index = 0
await asyncio.sleep(.5)
# Send file information and start signal to client
if await _write(
['s',
'hidden_token',
self.connections[connection_token]['file_name'],
self.connections[connection_token]['file_size']]
):
return
if await _write("start!"):
return
await self.run_writer(writer=writer,
connection_token=connection_token)
logging.info("Outgoing transmission ended")
self.disconnect(connection_token=connection_token)
else:
await self.refuse_connection(writer=writer,
message="Invalid client_hello!")
return
def disconnect(self, connection_token: str) -> None:
if connection_token in self.buffers:
del self.buffers[connection_token]
if connection_token in self.connections:
del self.connections[connection_token]
def run(self):
loop = asyncio.get_event_loop()
logging.info("Starting file bridging server...")
try:
loop.run_until_complete(self.run_server())
except KeyboardInterrupt:
print()
logging.info("Stopping...")
# Cancel connection tasks (they should be done but are pending)
for task in asyncio.all_tasks(loop):
task.cancel()
loop.run_until_complete(
self.server.wait_closed()
)
loop.close()
logging.info("Stopped.")
async def run_server(self):
self._server = await asyncio.start_server(
ssl=self.ssl_context,
client_connected_cb=self.connect,
host=self.host,
port=self.port,
)
async with self.server:
logging.info("Running at `{s.host}:{s.port}`".format(s=self))
await self.server.serve_forever()
@staticmethod
async def refuse_connection(writer: asyncio.StreamWriter,
message: str = None):
"""Send a `message` via writer and close it."""
if message is None:
message = "Connection refused!\n"
writer.write(
message.encode('utf-8')
)
await writer.drain()
writer.close()
def main():
# noinspection SpellCheckingInspection
log_formatter = logging.Formatter(
"%(asctime)s [%(module)-15s %(levelname)-8s] %(message)s",
style='%'
)
root_logger = logging.getLogger()
root_logger.setLevel(logging.DEBUG)
# noinspection PyUnresolvedReferences
asyncio.selector_events.logger.setLevel(logging.ERROR)
console_handler = logging.StreamHandler()
console_handler.setFormatter(log_formatter)
console_handler.setLevel(logging.DEBUG)
root_logger.addHandler(console_handler)
# Parse command-line arguments
cli_parser = argparse.ArgumentParser(description='Run server',
allow_abbrev=False)
cli_parser.add_argument('--host', type=str,
default=None,
required=False,
help='server address')
cli_parser.add_argument('--port', type=int,
default=None,
required=False,
help='server port')
cli_parser.add_argument('--certificate', type=str,
default=None,
required=False,
help='server SSL certificate')
cli_parser.add_argument('--key', type=str,
default=None,
required=False,
help='server SSL key')
args = vars(cli_parser.parse_args())
host = args['host']
port = args['port']
certificate = args['certificate']
key = args['key']
# If host and port are not provided from command-line, try to import them
if host is None:
try:
from config import host
except ImportError:
host = None
if port is None:
try:
from config import port
except ImportError:
port = None
# If import fails, prompt user for host or port
while host is None:
host = input("Enter host:\t\t\t\t\t\t")
while port is None:
try:
port = int(input("Enter port:\t\t\t\t\t\t"))
except ValueError:
logging.info("Invalid port. Enter a valid port number!")
port = None
try:
if certificate is None or not os.path.isfile(certificate):
from config import certificate
if key is None or not os.path.isfile(key):
from config import key
if not os.path.isfile(certificate):
certificate = None
if not os.path.isfile(key):
key = None
except ImportError:
certificate = None
key = None
ssl_context = None
if certificate and key:
ssl_context = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH)
ssl_context.load_cert_chain(certificate, key)
else:
logging.warning(
"Please consider using SSL. To do so, add in `config.py` or "
"provide via Command Line Interface the path to a valid SSL "
"key and certificate. Example:\n\n"
"key = 'path/to/secret.key'\n"
"certificate = 'path/to/certificate.crt'"
)
server = Server(
host=host,
port=port,
ssl_context=ssl_context
)
server.run()
if __name__ == '__main__':
main()

142
filebridging/utilities.py Normal file
View File

@ -0,0 +1,142 @@
"""Useful functions."""
import datetime
import logging
import shutil
import signal
import sys
import time
from typing import Union
units_of_measurements = {
1: 'bytes',
1000: 'KB',
1000 * 1000: 'MB',
1000 * 1000 * 1000: 'GB',
1000 * 1000 * 1000 * 1000: 'TB',
}
def get_file_size_representation(file_size):
scale, unit = get_scale_and_unit(file_size=file_size)
if scale < 10:
return f"{file_size} {unit}"
return f"{(file_size // (scale / 100)) / 100:.2f} {unit}"
def get_scale_and_unit(file_size):
scale, unit = min(units_of_measurements.items())
for scale, unit in sorted(units_of_measurements.items(), reverse=True):
if file_size > scale:
break
return scale, unit
def print_progress_bar(prefix='',
suffix='',
done_symbol="#",
pending_symbol=".",
progress=0,
scale=10):
progress_showed = (progress // scale) * scale
line_width, _ = shutil.get_terminal_size()
line = (f"{prefix}"
f"{done_symbol * (progress_showed // scale)}"
f"{pending_symbol * ((100 - progress_showed) // scale)}\t"
f"{progress}%"
f"{suffix} ")
line = line.replace('\t', ' ' * 4)
if line_width < 5:
line = '.' * line_width
elif len(line) > line_width:
line = line[:line_width-5] + '[...]'
sys.stdout.write(
line + '\r'
)
sys.stdout.flush()
def timed_action(interval: Union[int, float, datetime.timedelta] = None):
"""Do not perform decorated action before `interval`.
`interval` may be an int number of seconds or a datetime.timedelta object.
Usage:
@timed_action(1)
def print_sum(a, b):
print(a + b)
for i, j in enumerate(range(1000, 10000, 10)):
print_sum(i, j)
time.sleep(0.1)
"""
now = datetime.datetime.now
last_call = now()
if type(interval) in (int, float):
timedelta = datetime.timedelta(seconds=interval)
elif isinstance(interval, datetime.timedelta):
timedelta = interval
def timer(function_to_time):
def timed_function(*args, force: bool = False, **kwargs):
nonlocal last_call
if force or now() > last_call + timedelta:
last_call = now()
return function_to_time(*args, **kwargs)
return
return timed_function
return timer
def unix_timed_input(message: str = None,
timeout: int = 5):
"""Print `message` and return input within `timeout` seconds.
If nothing was entered in time, return None.
This works only on unix systems, since `signal.alarm` is needed.
"""
class TimeoutExpired(Exception):
pass
# noinspection PyUnusedLocal
def interrupted(signal_number, stack_frame):
"""Called when read times out."""
raise TimeoutExpired
if message is None:
message = f"Enter something within {timeout} seconds"
signal.alarm(timeout)
signal.signal(signal.SIGALRM, interrupted)
try:
given_input = input(message)
except TimeoutExpired:
given_input = None
print() # Print end of line
logging.info("Timeout!")
signal.alarm(0)
return given_input
def non_unix_timed_input(message: str = None,
timeout: int = 5):
"""Print message and wait `timeout` seconds before reading standard input.
This works on all systems, but cannot last less then `timeout` even if
user presses enter.
"""
print(message, end='')
time.sleep(timeout)
input_ = sys.stdin.readline()
if not input_.endswith("\n"):
print() # Print end of line
if input_:
return input_
return
timed_input = (
unix_timed_input
if sys.platform.startswith('linux')
else non_unix_timed_input()
)

0
requirements.txt Normal file
View File

75
setup.py Normal file
View File

@ -0,0 +1,75 @@
"""Setup."""
import codecs
import os
import re
import setuptools
import sys
if sys.version_info < (3, 8):
raise RuntimeError("Python3.8+ is needed to use this library")
here = os.path.abspath(os.path.dirname(__file__))
def read(*parts):
"""Read file in `part.part.part.part.ext`.
Start from `here` and follow the path given by `*parts`
"""
with codecs.open(os.path.join(here, *parts), 'r') as fp:
return fp.read()
def find_information(info, *file_path_parts):
"""Read information in file."""
version_file = read(*file_path_parts)
version_match = re.search(
r"^__{info}__ = ['\"]([^'\"]*)['\"]".format(
info=info
),
version_file,
re.M
)
if version_match:
return version_match.group(1)
raise RuntimeError("Unable to find version string.")
with open("README.md", "r") as readme_file:
long_description = readme_file.read()
setuptools.setup(
name='filebridging',
version=find_information("version", "filebridging", "__init__.py"),
author=find_information("author", "filebridging", "__init__.py"),
author_email=find_information("email", "filebridging", "__init__.py"),
description=(
"Share files via a bridge server using TCP over SSL and end-to-end "
"encryption."
),
license=find_information("license", "filebridging", "__init__.py"),
long_description=long_description,
long_description_content_type="text/markdown",
url="https://gogs.davte.it/davte/filebridging",
packages=setuptools.find_packages(),
platforms=['any'],
install_requires=[],
classifiers=[
"Development Status :: 5 - Production/Stable",
"Environment :: Console",
"Framework :: AsyncIO",
"Intended Audience :: End Users/Desktop",
"License :: OSI Approved :: GNU General Public License v3 (GPLv3)",
"Natural Language :: English",
"Operating System :: OS Independent",
"Programming Language :: Python :: 3 :: Only",
"Topic :: Communications :: File Sharing",
],
keywords=(
'file share '
'tcp ssl tls end-to-end encryption '
'python asyncio async'
),
include_package_data=True,
)