Queer European MD passionate about IT

client.py 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548
  1. import argparse
  2. import asyncio
  3. import collections
  4. import logging
  5. import os
  6. import random
  7. import ssl
  8. import string
  9. import sys
  10. import utilities
  11. class Client:
  12. def __init__(self, host='localhost', port=3001,
  13. buffer_chunk_size=10**4, buffer_length_limit=10**4,
  14. password=None, token=None):
  15. self._host = host
  16. self._port = port
  17. self._stopping = False
  18. # Shared queue of bytes
  19. self.buffer = collections.deque()
  20. # How many bytes per chunk
  21. self._buffer_chunk_size = buffer_chunk_size
  22. # How many chunks in buffer
  23. self._buffer_length_limit = buffer_length_limit
  24. self._file_path = None
  25. self._working = False
  26. self._token = token
  27. self._password = password
  28. self._ssl_context = None
  29. self._encryption_complete = False
  30. self._file_name = None
  31. self._file_size = None
  32. @property
  33. def host(self) -> str:
  34. return self._host
  35. @property
  36. def port(self) -> int:
  37. return self._port
  38. @property
  39. def stopping(self) -> bool:
  40. return self._stopping
  41. @property
  42. def buffer_length_limit(self) -> int:
  43. return self._buffer_length_limit
  44. @property
  45. def buffer_chunk_size(self) -> int:
  46. return self._buffer_chunk_size
  47. @property
  48. def file_path(self) -> str:
  49. return self._file_path
  50. @property
  51. def working(self) -> bool:
  52. return self._working
  53. @property
  54. def ssl_context(self) -> ssl.SSLContext:
  55. return self._ssl_context
  56. def set_ssl_context(self, ssl_context: ssl.SSLContext):
  57. self._ssl_context = ssl_context
  58. @property
  59. def token(self):
  60. return self._token
  61. @property
  62. def password(self):
  63. """Password for file encryption or decryption."""
  64. return self._password
  65. @property
  66. def encryption_complete(self):
  67. return self._encryption_complete
  68. @property
  69. def file_name(self):
  70. return self._file_name
  71. @property
  72. def file_size(self):
  73. return self._file_size
  74. async def run_sending_client(self, file_path='~/output.txt'):
  75. self._file_path = file_path
  76. file_name = os.path.basename(os.path.abspath(file_path))
  77. file_size = os.path.getsize(os.path.abspath(file_path))
  78. try:
  79. reader, writer = await asyncio.open_connection(
  80. host=self.host,
  81. port=self.port,
  82. ssl=self.ssl_context
  83. )
  84. except ConnectionRefusedError as exception:
  85. logging.error(exception)
  86. return
  87. writer.write(
  88. f"s|{self.token}|{file_name}|{file_size}\n".encode('utf-8')
  89. )
  90. self.set_file_information(file_name=file_name,
  91. file_size=file_size)
  92. await writer.drain()
  93. # Wait for server start signal
  94. while 1:
  95. server_hello = await reader.readline()
  96. if not server_hello:
  97. logging.info("Server disconnected.")
  98. return
  99. server_hello = server_hello.decode('utf-8').strip('\n')
  100. if server_hello == 'start!':
  101. break
  102. logging.info(f"Server said: {server_hello}")
  103. await self.send(writer=writer)
  104. async def encrypt_file(self, input_file, output_file):
  105. self._encryption_complete = False
  106. logging.info("Encrypting file...")
  107. stdout, stderr = ''.encode(), ''.encode()
  108. try:
  109. _subprocess = await asyncio.create_subprocess_shell(
  110. "openssl enc -aes-256-cbc "
  111. "-md sha512 -pbkdf2 -iter 100000 -salt "
  112. f"-in \"{input_file}\" -out \"{output_file}\" "
  113. f"-pass pass:{self.password}"
  114. )
  115. stdout, stderr = await _subprocess.communicate()
  116. except Exception as e:
  117. logging.error(
  118. "Exception {e}:\n{o}\n{er}".format(
  119. e=e,
  120. o=stdout.decode().strip(),
  121. er=stderr.decode().strip()
  122. )
  123. )
  124. logging.info("Encryption completed.")
  125. self._encryption_complete = True
  126. async def send(self, writer: asyncio.StreamWriter):
  127. self._working = True
  128. file_path = self.file_path
  129. if self.password:
  130. file_path = self.file_path + '.enc'
  131. # Remove already-encrypted file if present (salt would differ)
  132. if os.path.isfile(file_path):
  133. os.remove(file_path)
  134. asyncio.ensure_future(
  135. self.encrypt_file(
  136. input_file=self.file_path,
  137. output_file=file_path
  138. )
  139. )
  140. # Give encryption an edge
  141. while not os.path.isfile(file_path):
  142. await asyncio.sleep(.5)
  143. logging.info("Sending file...")
  144. bytes_sent = 0
  145. with open(file_path, 'rb') as file_to_send:
  146. while not self.stopping:
  147. output_data = file_to_send.read(self.buffer_chunk_size)
  148. if not output_data:
  149. # If encryption is in progress, wait and read again later
  150. if self.password and not self.encryption_complete:
  151. await asyncio.sleep(1)
  152. continue
  153. break
  154. try:
  155. writer.write(output_data)
  156. await writer.drain()
  157. except ConnectionResetError:
  158. logging.info('Server closed the connection.')
  159. self.stop()
  160. break
  161. bytes_sent += self.buffer_chunk_size
  162. new_progress = min(
  163. int(bytes_sent / self.file_size * 100),
  164. 100
  165. )
  166. progress_showed = (new_progress // 10) * 10
  167. sys.stdout.write(
  168. f"\t\t\tSending `{self.file_name}`: "
  169. f"{'#' * (progress_showed // 10)}"
  170. f"{'.' * ((100 - progress_showed) // 10)}\t"
  171. f"{new_progress}% completed "
  172. f"({min(bytes_sent, self.file_size) // 1000} "
  173. f"of {self.file_size // 1000} KB)\r"
  174. )
  175. sys.stdout.flush()
  176. sys.stdout.write('\n')
  177. sys.stdout.flush()
  178. writer.close()
  179. return
  180. async def run_receiving_client(self, file_path='~/input.txt'):
  181. self._file_path = file_path
  182. try:
  183. reader, writer = await asyncio.open_connection(
  184. host=self.host,
  185. port=self.port,
  186. ssl=self.ssl_context
  187. )
  188. except ConnectionRefusedError as exception:
  189. logging.error(exception)
  190. return
  191. writer.write(f"r|{self.token}\n".encode('utf-8'))
  192. await writer.drain()
  193. # Wait for server start signal
  194. while 1:
  195. server_hello = await reader.readline()
  196. if not server_hello:
  197. logging.info("Server disconnected.")
  198. return
  199. server_hello = server_hello.decode('utf-8').strip('\n')
  200. if server_hello.startswith('info'):
  201. _, file_name, file_size = server_hello.split('|')
  202. self.set_file_information(file_name=file_name,
  203. file_size=file_size)
  204. elif server_hello == 'start!':
  205. break
  206. else:
  207. logging.info(f"Server said: {server_hello}")
  208. await self.receive(reader=reader)
  209. async def receive(self, reader: asyncio.StreamReader):
  210. self._working = True
  211. file_path = os.path.join(
  212. os.path.abspath(
  213. self.file_path
  214. ),
  215. self.file_name
  216. )
  217. original_file_path = file_path
  218. if self.password:
  219. file_path += '.enc'
  220. logging.info("Receiving file...")
  221. with open(file_path, 'wb') as file_to_receive:
  222. bytes_received = 0
  223. while not self.stopping:
  224. input_data = await reader.read(self.buffer_chunk_size)
  225. bytes_received += self.buffer_chunk_size
  226. new_progress = min(
  227. int(bytes_received / self.file_size * 100),
  228. 100
  229. )
  230. progress_showed = (new_progress // 10) * 10
  231. sys.stdout.write(
  232. f"\t\t\tReceiving `{self.file_name}`: "
  233. f"{'#' * (progress_showed // 10)}"
  234. f"{'.' * ((100 - progress_showed) // 10)}\t"
  235. f"{new_progress}% completed "
  236. f"({min(bytes_received, self.file_size) // 1000} "
  237. f"of {self.file_size // 1000} KB)\r"
  238. )
  239. sys.stdout.flush()
  240. if not input_data:
  241. break
  242. file_to_receive.write(input_data)
  243. sys.stdout.write('\n')
  244. sys.stdout.flush()
  245. logging.info("File received.")
  246. if self.password:
  247. logging.info("Decrypting file...")
  248. stdout, stderr = ''.encode(), ''.encode()
  249. try:
  250. _subprocess = await asyncio.create_subprocess_shell(
  251. "openssl enc -aes-256-cbc "
  252. "-md sha512 -pbkdf2 -iter 100000 -salt -d "
  253. f"-in \"{file_path}\" -out \"{original_file_path}\" "
  254. f"-pass pass:{self.password}"
  255. )
  256. stdout, stderr = await _subprocess.communicate()
  257. logging.info("Decryption completed.")
  258. except Exception as e:
  259. logging.error(
  260. "Exception {e}:\n{o}\n{er}".format(
  261. e=e,
  262. o=stdout.decode().strip(),
  263. er=stderr.decode().strip()
  264. )
  265. )
  266. logging.info("Decryption failed", exc_info=True)
  267. def stop(self, *_):
  268. if self.working:
  269. logging.info("Received interruption signal, stopping...")
  270. self._stopping = True
  271. else:
  272. raise KeyboardInterrupt("Not working yet...")
  273. def set_file_information(self, file_name=None, file_size=None):
  274. if file_name is not None:
  275. self._file_name = file_name
  276. if file_size is not None:
  277. self._file_size = int(file_size)
  278. def get_action(action):
  279. """Parse abbreviations for `action`."""
  280. if not isinstance(action, str):
  281. return
  282. elif action.lower().startswith('r'):
  283. return 'receive'
  284. elif action.lower().startswith('s'):
  285. return 'send'
  286. def get_file_path(path, action='receive'):
  287. """Check that file `path` is correct and return it."""
  288. if (
  289. isinstance(path, str)
  290. and action == 'send'
  291. and os.path.isfile(path)
  292. ):
  293. return path
  294. elif (
  295. isinstance(path, str)
  296. and action == 'receive'
  297. and os.access(os.path.dirname(os.path.abspath(path)), os.W_OK)
  298. ):
  299. return path
  300. elif path is not None:
  301. logging.error(f"Invalid file: `{path}`")
  302. def main():
  303. # noinspection SpellCheckingInspection
  304. log_formatter = logging.Formatter(
  305. "%(asctime)s [%(module)-15s %(levelname)-8s] %(message)s",
  306. style='%'
  307. )
  308. root_logger = logging.getLogger()
  309. root_logger.setLevel(logging.DEBUG)
  310. # noinspection PyUnresolvedReferences
  311. asyncio.selector_events.logger.setLevel(logging.ERROR)
  312. console_handler = logging.StreamHandler()
  313. console_handler.setFormatter(log_formatter)
  314. console_handler.setLevel(logging.DEBUG)
  315. root_logger.addHandler(console_handler)
  316. # Parse command-line arguments
  317. cli_parser = argparse.ArgumentParser(description='Run client',
  318. allow_abbrev=False)
  319. cli_parser.add_argument('--host', type=str,
  320. default=None,
  321. required=False,
  322. help='server address')
  323. cli_parser.add_argument('--port', type=int,
  324. default=None,
  325. required=False,
  326. help='server port')
  327. cli_parser.add_argument('--action', type=str,
  328. default=None,
  329. required=False,
  330. help='[S]end or [R]eceive')
  331. cli_parser.add_argument('--path', type=str,
  332. default=None,
  333. required=False,
  334. help='File path to send / folder path to receive')
  335. cli_parser.add_argument('--password', '--p', '--pass', type=str,
  336. default=None,
  337. required=False,
  338. help='Password for file encryption or decryption')
  339. cli_parser.add_argument('--token', '--t', '--session_token', type=str,
  340. default=None,
  341. required=False,
  342. help='Session token '
  343. '(must be the same for both clients)')
  344. cli_parser.add_argument('others',
  345. metavar='R or S',
  346. nargs='*',
  347. help='[S]end or [R]eceive (see `action`)')
  348. args = vars(cli_parser.parse_args())
  349. host = args['host']
  350. port = args['port']
  351. action = get_action(args['action'])
  352. file_path = args['path']
  353. password = args['password']
  354. token = args['token']
  355. # If host and port are not provided from command-line, try to import them
  356. if host is None:
  357. try:
  358. from config import host
  359. except ImportError:
  360. host = None
  361. if port is None:
  362. try:
  363. from config import port
  364. except ImportError:
  365. port = None
  366. # Take `s`, `r` etc. from command line as `action`
  367. if action is None:
  368. for arg in args['others']:
  369. action = get_action(arg)
  370. if action:
  371. break
  372. if action is None:
  373. try:
  374. from config import action
  375. action = get_action(action)
  376. except ImportError:
  377. action = None
  378. if file_path is None:
  379. try:
  380. from config import file_path
  381. file_path = get_action(file_path)
  382. except ImportError:
  383. file_path = None
  384. if password is None:
  385. try:
  386. from config import password
  387. except ImportError:
  388. password = None
  389. if token is None:
  390. try:
  391. from config import token
  392. except ImportError:
  393. token = None
  394. # If import fails, prompt user for host or port
  395. new_settings = {} # After getting these settings, offer to store them
  396. while host is None:
  397. host = input("Enter host:\t\t\t\t\t\t")
  398. new_settings['host'] = host
  399. while port is None:
  400. try:
  401. port = int(input("Enter port:\t\t\t\t\t\t"))
  402. except ValueError:
  403. logging.info("Invalid port. Enter a valid port number!")
  404. port = None
  405. new_settings['port'] = port
  406. while action is None:
  407. action = get_action(
  408. input("Do you want to (R)eceive or (S)end a file?\t\t")
  409. )
  410. if file_path is not None and (
  411. (action == 'send'
  412. and not os.path.isfile(os.path.abspath(file_path)))
  413. or (action == 'receive'
  414. and not os.path.isdir(os.path.abspath(file_path)))
  415. ):
  416. file_path = None
  417. while file_path is None:
  418. if action == 'send':
  419. file_path = get_file_path(
  420. path=input(f"Enter file to send:\t\t\t\t\t\t"),
  421. action=action
  422. )
  423. if file_path and not os.path.isfile(os.path.abspath(file_path)):
  424. file_path = None
  425. elif action == 'receive':
  426. file_path = get_file_path(
  427. path=input(f"Enter destination folder:\t\t\t\t\t\t"),
  428. action=action
  429. )
  430. if file_path and not os.path.isdir(os.path.abspath(file_path)):
  431. file_path = None
  432. new_settings['file_path'] = file_path
  433. if password is None:
  434. logging.warning(
  435. "You have provided no password for file encryption.\n"
  436. "Your file will be unencoded unless you provide a password in "
  437. "config file."
  438. )
  439. if token is None and action == 'send':
  440. # Generate a random [6-10] chars-long alphanumerical token
  441. token = ''.join(
  442. random.SystemRandom().choice(
  443. string.ascii_uppercase + string.digits
  444. )
  445. for _ in range(random.SystemRandom().randint(6, 10))
  446. )
  447. logging.info(
  448. "You have not provided a token for this connection.\n"
  449. f"A token has been generated for you:\t\t{token}\n"
  450. "Your peer must be informed of this token.\n"
  451. "For future connections, you may provide a custom token writing "
  452. "it in config file."
  453. )
  454. while token is None or not (6 <= len(token) <= 10):
  455. token = input("Please enter a 6-10 chars token.\t\t\t\t")
  456. if new_settings:
  457. answer = utilities.timed_input(
  458. "Do you want to store the following configuration values in "
  459. "`config.py`?\n\n" + '\n'.join(
  460. '\t\t'.join(map(str, item))
  461. for item in new_settings.items()
  462. ) + '\n\t\t\t',
  463. timeout=3
  464. )
  465. if answer:
  466. with open('config.py', 'a') as configuration_file:
  467. configuration_file.writelines(
  468. [
  469. f'{name} = "{value}"'
  470. if type(value) is str
  471. else f'{name} = {value}'
  472. for name, value in new_settings.items()
  473. ]
  474. )
  475. logging.info("Configuration values stored.")
  476. else:
  477. logging.info("Proceeding without storing values...")
  478. loop = asyncio.get_event_loop()
  479. client = Client(
  480. host=host,
  481. port=port,
  482. password=password,
  483. token=token
  484. )
  485. try:
  486. from config import certificate
  487. _ssl_context = ssl.create_default_context(ssl.Purpose.SERVER_AUTH)
  488. _ssl_context.check_hostname = False
  489. _ssl_context.load_verify_locations(certificate)
  490. client.set_ssl_context(_ssl_context)
  491. except ImportError:
  492. logging.warning("Please consider using SSL.")
  493. # noinspection PyUnusedLocal
  494. certificate = None
  495. logging.info("Starting client...")
  496. if action == 'send':
  497. loop.run_until_complete(
  498. client.run_sending_client(
  499. file_path=file_path
  500. )
  501. )
  502. else:
  503. loop.run_until_complete(
  504. client.run_receiving_client(
  505. file_path=file_path
  506. )
  507. )
  508. loop.close()
  509. logging.info("Stopped client")
  510. if __name__ == '__main__':
  511. main()