Queer European MD passionate about IT

client.py 18 KB

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