First working version

This commit is contained in:
Davte 2020-04-13 21:00:00 +02:00
parent 5b17f024b7
commit c4b4e14dc0
8 changed files with 1393 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,53 @@
# 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.
## 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 = '.'
```

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.1"
__maintainer__ = "Davide Testa"
__contact__ = "t.me/davte"

795
filebridging/client.py Normal file
View File

@ -0,0 +1,795 @@
"""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):
self._host = host
self._port = port
self._ssl_context = ssl_context
self._action = action
self._standalone = standalone
self._stopping = False
self._reader = None
self._writer = None
# Shared queue of bytes
self.buffer = collections.deque()
# 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._file_path = file_path
self._working = False
self._token = token
self._password = password
self._ssl_context = 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 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
)
except (ConnectionRefusedError, ConnectionResetError) as exception:
logging.error(f"Connection 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 disconnected.")
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,
)
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
)
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()
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()

378
filebridging/server.py Normal file
View File

@ -0,0 +1,378 @@
"""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
self._ssl_context = 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, 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, 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:
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()
client_hello = client_hello.decode('utf-8').strip('\n').split('|')
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
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:
del self.buffers[connection_token]
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()

69
filebridging/utilities.py Normal file
View File

@ -0,0 +1,69 @@
"""Useful functions."""
import logging
import signal
import sys
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
sys.stdout.write(
f"{prefix}"
f"{done_symbol * (progress_showed // scale)}"
f"{pending_symbol * ((100 - progress_showed) // scale)}\t"
f"{progress}%"
f"{suffix} \r"
)
sys.stdout.flush()
def timed_input(message: str = None,
timeout: int = 5):
class TimeoutExpired(Exception):
pass
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

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,
)