Compare commits
10 Commits
63626b329f
...
0e17e72283
Author | SHA1 | Date | |
---|---|---|---|
0e17e72283 | |||
63d65cf786 | |||
063cc375df | |||
dc596cfac6 | |||
5eb3d9d874 | |||
d131688794 | |||
8063d3c4df | |||
54e5e0fc3b | |||
57d3efc3b5 | |||
942536d66a |
1
.gitignore
vendored
1
.gitignore
vendored
@ -5,6 +5,7 @@
|
|||||||
|
|
||||||
# Data folder
|
# Data folder
|
||||||
data/
|
data/
|
||||||
|
data
|
||||||
|
|
||||||
# Byte-compiled / optimized / DLL files
|
# Byte-compiled / optimized / DLL files
|
||||||
__pycache__/
|
__pycache__/
|
||||||
|
36
README.md
36
README.md
@ -4,6 +4,10 @@ Share files via a bridge server using TCP over SSL and end-to-end encryption.
|
|||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
Python3.8+ is needed for this package.
|
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
|
## Usage
|
||||||
If you need a virtual environment, create it.
|
If you need a virtual environment, create it.
|
||||||
@ -29,6 +33,7 @@ python -m filebridging.client --help
|
|||||||
python -m filebridging.client r --host localhost --port 5000 --certificate ~/.ssh/server.crt --token 12345678 --password supersecretpasswordhere --path ~/Downloads
|
python -m filebridging.client r --host localhost --port 5000 --certificate ~/.ssh/server.crt --token 12345678 --password supersecretpasswordhere --path ~/Downloads
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
* Client-client example
|
* Client-client example
|
||||||
```bash
|
```bash
|
||||||
# 2 distinct tabs
|
# 2 distinct tabs
|
||||||
@ -37,6 +42,7 @@ python -m filebridging.client --help
|
|||||||
```
|
```
|
||||||
The receiver client may be standalone as well: just add the `--key` parameter (for SSL-secured sessions) and the `--standalone` flag.
|
The receiver client may be standalone as well: just add the `--key` parameter (for SSL-secured sessions) and the `--standalone` flag.
|
||||||
|
|
||||||
|
|
||||||
* Configuration file example
|
* Configuration file example
|
||||||
```python
|
```python
|
||||||
#!/bin/python
|
#!/bin/python
|
||||||
@ -51,3 +57,33 @@ python -m filebridging.client --help
|
|||||||
token = 'sessiontok'
|
token = 'sessiontok'
|
||||||
file_path = '.'
|
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
|
||||||
|
```
|
@ -13,6 +13,6 @@ __author__ = "Davide Testa"
|
|||||||
__email__ = "davide@davte.it"
|
__email__ = "davide@davte.it"
|
||||||
__credits__ = []
|
__credits__ = []
|
||||||
__license__ = "GNU General Public License v3.0"
|
__license__ = "GNU General Public License v3.0"
|
||||||
__version__ = "0.0.1"
|
__version__ = "0.0.10"
|
||||||
__maintainer__ = "Davide Testa"
|
__maintainer__ = "Davide Testa"
|
||||||
__contact__ = "t.me/davte"
|
__contact__ = "t.me/davte"
|
||||||
|
6
filebridging/__main__.py
Normal file
6
filebridging/__main__.py
Normal 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()
|
@ -40,26 +40,27 @@ class Client:
|
|||||||
buffer_length_limit=10 ** 4,
|
buffer_length_limit=10 ** 4,
|
||||||
file_path=None,
|
file_path=None,
|
||||||
password=None,
|
password=None,
|
||||||
token=None):
|
token=None,
|
||||||
|
ssl_handshake_timeout=None):
|
||||||
self._host = host
|
self._host = host
|
||||||
self._port = port
|
self._port = port
|
||||||
self._ssl_context = ssl_context
|
self._ssl_context = ssl_context
|
||||||
self._action = action
|
self._action = action
|
||||||
self._standalone = standalone
|
self._standalone = standalone
|
||||||
self._stopping = False
|
self._file_path = file_path
|
||||||
self._reader = None
|
self._password = password
|
||||||
self._writer = None
|
self._token = token
|
||||||
# Shared queue of bytes
|
self._ssl_handshake_timeout = ssl_handshake_timeout
|
||||||
self.buffer = collections.deque()
|
|
||||||
# How many bytes per chunk
|
# How many bytes per chunk
|
||||||
self._buffer_chunk_size = buffer_chunk_size
|
self._buffer_chunk_size = buffer_chunk_size
|
||||||
# How many chunks in buffer
|
# How many chunks in buffer
|
||||||
self._buffer_length_limit = buffer_length_limit
|
self._buffer_length_limit = buffer_length_limit
|
||||||
self._file_path = file_path
|
# Shared queue of bytes
|
||||||
|
self.buffer = collections.deque()
|
||||||
self._working = False
|
self._working = False
|
||||||
self._token = token
|
self._stopping = False
|
||||||
self._password = password
|
self._reader = None
|
||||||
self._ssl_context = None
|
self._writer = None
|
||||||
self._encryption_complete = False
|
self._encryption_complete = False
|
||||||
self._file_name = None
|
self._file_name = None
|
||||||
self._file_size = None
|
self._file_size = None
|
||||||
@ -139,6 +140,16 @@ class Client:
|
|||||||
def set_ssl_context(self, ssl_context: ssl.SSLContext):
|
def set_ssl_context(self, ssl_context: ssl.SSLContext):
|
||||||
self._ssl_context = ssl_context
|
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
|
@property
|
||||||
def token(self):
|
def token(self):
|
||||||
"""Session token.
|
"""Session token.
|
||||||
@ -199,11 +210,16 @@ class Client:
|
|||||||
reader, writer = await asyncio.open_connection(
|
reader, writer = await asyncio.open_connection(
|
||||||
host=self.host,
|
host=self.host,
|
||||||
port=self.port,
|
port=self.port,
|
||||||
ssl=self.ssl_context
|
ssl=self.ssl_context,
|
||||||
|
ssl_handshake_timeout=self.ssl_handshake_timeout
|
||||||
)
|
)
|
||||||
except (ConnectionRefusedError, ConnectionResetError) as exception:
|
except (ConnectionRefusedError, ConnectionResetError,
|
||||||
|
ConnectionAbortedError) as exception:
|
||||||
logging.error(f"Connection error: {exception}")
|
logging.error(f"Connection error: {exception}")
|
||||||
return
|
return
|
||||||
|
except ssl.SSLError as exception:
|
||||||
|
logging.error(f"SSL error: {exception}")
|
||||||
|
return
|
||||||
await self.connect(reader=reader, writer=writer)
|
await self.connect(reader=reader, writer=writer)
|
||||||
|
|
||||||
async def _connect(self, reader: asyncio.StreamReader,
|
async def _connect(self, reader: asyncio.StreamReader,
|
||||||
@ -272,7 +288,7 @@ class Client:
|
|||||||
while 1:
|
while 1:
|
||||||
server_hello = await self.reader.readline()
|
server_hello = await self.reader.readline()
|
||||||
if not server_hello:
|
if not server_hello:
|
||||||
logging.error("Server disconnected.")
|
logging.error("Server refused connection.")
|
||||||
return
|
return
|
||||||
server_hello = server_hello.decode('utf-8').strip('\n').split('|')
|
server_hello = server_hello.decode('utf-8').strip('\n').split('|')
|
||||||
if self.action == 'receive' and server_hello[0] == 's':
|
if self.action == 'receive' and server_hello[0] == 's':
|
||||||
@ -383,6 +399,7 @@ class Client:
|
|||||||
self.print_progress_bar(
|
self.print_progress_bar(
|
||||||
progress=new_progress,
|
progress=new_progress,
|
||||||
bytes_=bytes_sent,
|
bytes_=bytes_sent,
|
||||||
|
force=(new_progress == 100)
|
||||||
)
|
)
|
||||||
print() # New line after progress_bar
|
print() # New line after progress_bar
|
||||||
writer.close()
|
writer.close()
|
||||||
@ -415,7 +432,8 @@ class Client:
|
|||||||
)
|
)
|
||||||
self.print_progress_bar(
|
self.print_progress_bar(
|
||||||
progress=new_progress,
|
progress=new_progress,
|
||||||
bytes_=bytes_received
|
bytes_=bytes_received,
|
||||||
|
force=(new_progress == 100)
|
||||||
)
|
)
|
||||||
if not input_data:
|
if not input_data:
|
||||||
break
|
break
|
||||||
|
126
filebridging/create_certificate.py
Normal file
126
filebridging/create_certificate.py
Normal 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()
|
@ -33,7 +33,6 @@ class Server:
|
|||||||
self._buffer_length_limit = buffer_length_limit
|
self._buffer_length_limit = buffer_length_limit
|
||||||
self._working = False
|
self._working = False
|
||||||
self._server = None
|
self._server = None
|
||||||
self._ssl_context = None
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def host(self) -> str:
|
def host(self) -> str:
|
||||||
@ -74,7 +73,7 @@ class Server:
|
|||||||
def set_ssl_context(self, ssl_context: ssl.SSLContext):
|
def set_ssl_context(self, ssl_context: ssl.SSLContext):
|
||||||
self._ssl_context = ssl_context
|
self._ssl_context = ssl_context
|
||||||
|
|
||||||
async def run_reader(self, reader, connection_token):
|
async def run_reader(self, reader: asyncio.StreamReader, connection_token):
|
||||||
while 1:
|
while 1:
|
||||||
try:
|
try:
|
||||||
# Wait one second if buffer is full
|
# Wait one second if buffer is full
|
||||||
@ -91,7 +90,7 @@ class Server:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.error(f"Unexpected exception:\n{e}", exc_info=True)
|
logging.error(f"Unexpected exception:\n{e}", exc_info=True)
|
||||||
|
|
||||||
async def run_writer(self, writer, connection_token):
|
async def run_writer(self, writer: asyncio.StreamWriter, connection_token):
|
||||||
consecutive_interruptions = 0
|
consecutive_interruptions = 0
|
||||||
errors = 0
|
errors = 0
|
||||||
while connection_token in self.buffers:
|
while connection_token in self.buffers:
|
||||||
@ -101,6 +100,7 @@ class Server:
|
|||||||
# Slow down if buffer is empty; after 1.5 s of silence, break
|
# Slow down if buffer is empty; after 1.5 s of silence, break
|
||||||
consecutive_interruptions += 1
|
consecutive_interruptions += 1
|
||||||
if consecutive_interruptions > 3:
|
if consecutive_interruptions > 3:
|
||||||
|
logging.error("Too many interruptions...")
|
||||||
break
|
break
|
||||||
await asyncio.sleep(.5)
|
await asyncio.sleep(.5)
|
||||||
continue
|
continue
|
||||||
@ -130,7 +130,11 @@ class Server:
|
|||||||
Decide whether client is sender or receiver and start transmission.
|
Decide whether client is sender or receiver and start transmission.
|
||||||
"""
|
"""
|
||||||
client_hello = await reader.readline()
|
client_hello = await reader.readline()
|
||||||
|
try:
|
||||||
client_hello = client_hello.decode('utf-8').strip('\n').split('|')
|
client_hello = client_hello.decode('utf-8').strip('\n').split('|')
|
||||||
|
except UnicodeDecodeError:
|
||||||
|
logging.error("Invalid client hello.")
|
||||||
|
return
|
||||||
if len(client_hello) != 4:
|
if len(client_hello) != 4:
|
||||||
await self.refuse_connection(writer=writer,
|
await self.refuse_connection(writer=writer,
|
||||||
message="Invalid client_hello!")
|
message="Invalid client_hello!")
|
||||||
@ -163,6 +167,7 @@ class Server:
|
|||||||
else:
|
else:
|
||||||
return 0 # On success, return 0
|
return 0 # On success, return 0
|
||||||
# On exception, disconnect and return 1
|
# On exception, disconnect and return 1
|
||||||
|
logging.error("Disconnecting...")
|
||||||
self.disconnect(connection_token=connection_token)
|
self.disconnect(connection_token=connection_token)
|
||||||
return 1
|
return 1
|
||||||
|
|
||||||
@ -234,7 +239,9 @@ class Server:
|
|||||||
return
|
return
|
||||||
|
|
||||||
def disconnect(self, connection_token: str) -> None:
|
def disconnect(self, connection_token: str) -> None:
|
||||||
|
if connection_token in self.buffers:
|
||||||
del self.buffers[connection_token]
|
del self.buffers[connection_token]
|
||||||
|
if connection_token in self.connections:
|
||||||
del self.connections[connection_token]
|
del self.connections[connection_token]
|
||||||
|
|
||||||
def run(self):
|
def run(self):
|
||||||
|
@ -4,6 +4,8 @@ import logging
|
|||||||
import shutil
|
import shutil
|
||||||
import signal
|
import signal
|
||||||
import sys
|
import sys
|
||||||
|
import time
|
||||||
|
|
||||||
from typing import Union
|
from typing import Union
|
||||||
|
|
||||||
units_of_measurements = {
|
units_of_measurements = {
|
||||||
@ -75,9 +77,9 @@ def timed_action(interval: Union[int, float, datetime.timedelta] = None):
|
|||||||
timedelta = interval
|
timedelta = interval
|
||||||
|
|
||||||
def timer(function_to_time):
|
def timer(function_to_time):
|
||||||
def timed_function(*args, **kwargs):
|
def timed_function(*args, force: bool = False, **kwargs):
|
||||||
nonlocal last_call
|
nonlocal last_call
|
||||||
if now() > last_call + timedelta:
|
if force or now() > last_call + timedelta:
|
||||||
last_call = now()
|
last_call = now()
|
||||||
return function_to_time(*args, **kwargs)
|
return function_to_time(*args, **kwargs)
|
||||||
return
|
return
|
||||||
@ -86,11 +88,17 @@ def timed_action(interval: Union[int, float, datetime.timedelta] = None):
|
|||||||
return timer
|
return timer
|
||||||
|
|
||||||
|
|
||||||
def timed_input(message: str = None,
|
def unix_timed_input(message: str = None,
|
||||||
timeout: int = 5):
|
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):
|
class TimeoutExpired(Exception):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
# noinspection PyUnusedLocal
|
||||||
def interrupted(signal_number, stack_frame):
|
def interrupted(signal_number, stack_frame):
|
||||||
"""Called when read times out."""
|
"""Called when read times out."""
|
||||||
raise TimeoutExpired
|
raise TimeoutExpired
|
||||||
@ -108,3 +116,27 @@ def timed_input(message: str = None,
|
|||||||
logging.info("Timeout!")
|
logging.info("Timeout!")
|
||||||
signal.alarm(0)
|
signal.alarm(0)
|
||||||
return given_input
|
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()
|
||||||
|
)
|
||||||
|
Loading…
x
Reference in New Issue
Block a user