byafnctl.py 7.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393
  1. import re
  2. import os
  3. import os.path
  4. import sys
  5. import time
  6. import struct
  7. import socket
  8. import pathlib
  9. import logging
  10. import argparse
  11. import tqdm
  12. import cbor2
  13. from Crypto.Hash import SHA256
  14. from Crypto.Cipher import Salsa20
  15. from Crypto.Random import get_random_bytes
  16. BYAFN_ADMINSOCKET = os.getenv(
  17. 'BYAFN_ADMINSOCKET',
  18. default='./adminsocket'
  19. )
  20. logging.basicConfig(
  21. format='%(asctime)s %(levelname)s: %(message)s',
  22. level=logging.INFO
  23. )
  24. class Metafile:
  25. HEADER = b'\x80BYAFN-METAFILE\x00\x00'
  26. HEADER_LEN = len(HEADER)
  27. def __init__(self, filename, size, checksum, pieces, key=None):
  28. self.filename = filename
  29. self.size = size
  30. self.checksum = checksum
  31. self.pieces = pieces
  32. self.key = key
  33. def save(self, f):
  34. f.write(Metafile.HEADER)
  35. filename = self.filename.encode('UTF-8')
  36. filename_len = len(filename)
  37. f.write(struct.pack('!I', filename_len))
  38. f.write(filename)
  39. f.write(struct.pack('!L', self.size))
  40. f.write(self.checksum)
  41. if self.key:
  42. f.write(b'Y')
  43. f.write(self.key)
  44. else:
  45. f.write(b'N')
  46. pieces_count = len(self.pieces)
  47. f.write(struct.pack('!L', pieces_count))
  48. for hash in self.pieces:
  49. f.write(hash)
  50. @staticmethod
  51. def load(f):
  52. header = f.read(Metafile.HEADER_LEN)
  53. if header != Metafile.HEADER:
  54. raise ValueError
  55. filename_len = f.read(4)
  56. filename_len = struct.unpack('!I', filename_len)[0]
  57. filename = f.read(filename_len)
  58. if len(filename) != filename_len:
  59. raise ValueError
  60. filename = filename.decode('UTF-8')
  61. filename = pathlib.Path(filename).name
  62. size = f.read(4)
  63. size = struct.unpack('!I', size)[0]
  64. checksum = f.read(32)
  65. if len(checksum) != 32:
  66. raise ValueError
  67. is_encrypted = f.read(1)
  68. if is_encrypted == b'Y':
  69. key = f.read(40)
  70. if len(key) != 40:
  71. raise ValueError
  72. elif is_encrypted == b'N':
  73. key = None
  74. else:
  75. raise ValueError
  76. pieces_count = f.read(4)
  77. pieces_count = struct.unpack('!L', pieces_count)[0]
  78. if pieces_count < 1:
  79. raise ValueError
  80. pieces = []
  81. while pieces_count > 0:
  82. piece_hash = f.read(32)
  83. if len(piece_hash) != 32:
  84. raise ValueError
  85. pieces.append(piece_hash)
  86. pieces_count -= 1
  87. return Metafile(filename, size, checksum, pieces, key)
  88. def sha256(data):
  89. hash = SHA256.new()
  90. hash.update(data)
  91. return hash.digest()
  92. def salsa20_create_encryptor():
  93. key = get_random_bytes(32)
  94. cipher = Salsa20.new(key=key)
  95. return (cipher.nonce + key, cipher)
  96. def salsa20_create_decryptor(key):
  97. if len(key) != 40:
  98. raise ValueError
  99. cipher = Salsa20.new(key=key[8:], nonce=key[:8])
  100. return cipher
  101. def parse_file_size(size):
  102. units = {
  103. 'b': 1,
  104. 'kb': 10e3,
  105. 'mb': 10e6
  106. }
  107. match = re.match(r'(\d+)([km]b)', size.strip().lower())
  108. if not match:
  109. raise ValueError
  110. size, unit = int(match.group(1)), units[match.group(2)]
  111. return int(size * unit)
  112. def send_command(**command):
  113. try:
  114. conn = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
  115. conn.connect(args.admin_socket_path)
  116. command = cbor2.dumps(command)
  117. conn.send(struct.pack('<I', len(command)))
  118. conn.send(command)
  119. length = struct.unpack('<I', conn.recv(4))[0]
  120. response = conn.recv(length)
  121. response = cbor2.loads(response)
  122. conn.close()
  123. except Exception as e:
  124. logging.error(f'Contacting the admin socket `{args.admin_socket_path}\': {e}')
  125. sys.exit(1)
  126. return response
  127. parser = argparse.ArgumentParser()
  128. parser.add_argument(
  129. '-G', '--genconf',
  130. help='Generate configuration file',
  131. action='store_true'
  132. )
  133. parser.add_argument(
  134. '--listen-address',
  135. help='Set ListenAddress (use together with --genconf)',
  136. type=str, default='0.0.0.0'
  137. )
  138. parser.add_argument(
  139. '-p', '--listen-port',
  140. help='Set ListenPort (use together with --genconf)',
  141. type=int, default=42424
  142. )
  143. parser.add_argument(
  144. '-s', '--storage-path',
  145. help='Set StoragePath (use together with --genconf)',
  146. type=str, default='./storage'
  147. )
  148. parser.add_argument(
  149. '-a', '--admin-socket-path',
  150. help='Set AdminSocketPath (use together with --genconf, --share or --query)',
  151. type=str, default=BYAFN_ADMINSOCKET
  152. )
  153. parser.add_argument(
  154. '-S', '--share',
  155. help='Share a file',
  156. type=argparse.FileType('rb')
  157. )
  158. parser.add_argument(
  159. '-e', '--encrypt',
  160. help='Encrypt file before sharing (use together with --share)',
  161. action='store_true'
  162. )
  163. parser.add_argument(
  164. '-Q', '--query',
  165. help='Query a file',
  166. type=argparse.FileType('rb')
  167. )
  168. parser.add_argument(
  169. '-o', '--output',
  170. help='Set output file',
  171. type=argparse.FileType('wb')
  172. )
  173. parser.add_argument(
  174. '-P', '--piece-size',
  175. help='Set piece size',
  176. type=parse_file_size, default='16kb'
  177. )
  178. parser.add_argument(
  179. '--shutdown',
  180. help='Request graceful shutdown of the peer',
  181. type=int
  182. )
  183. parser.add_argument(
  184. '--cleanup',
  185. help='Remove every piece from the storage if they weren\'t accessed more than for specified amount of seconds',
  186. type=int
  187. )
  188. args = parser.parse_args()
  189. if args.genconf:
  190. import ecies
  191. key = ecies.utils.generate_eth_key()
  192. pk = key.public_key.to_hex()
  193. sk = key.to_hex()
  194. if not args.output:
  195. output = sys.stdout.buffer
  196. else:
  197. output = args.output
  198. output.write(bytes(f'''{{
  199. Peers: []
  200. ListenAddress: {args.listen_address}
  201. ListenPort: {args.listen_port}
  202. Key: {pk}
  203. Secret: {sk}
  204. StoragePath: {os.path.abspath(args.storage_path)}
  205. AdminSocketPath: {os.path.abspath(args.admin_socket_path)}
  206. }}''', 'UTF-8'))
  207. if args.share:
  208. with args.share as f:
  209. filename = pathlib.Path(f.name).name
  210. f.seek(0, os.SEEK_END)
  211. file_size = f.tell()
  212. f.seek(0)
  213. checksum = SHA256.new()
  214. if args.encrypt:
  215. key, cipher = salsa20_create_encryptor()
  216. pieces_count = max(1, file_size // args.piece_size)
  217. progress = tqdm.trange(pieces_count)
  218. pieces = []
  219. while True:
  220. piece = f.read(args.piece_size)
  221. if not piece:
  222. break
  223. checksum.update(piece)
  224. if args.encrypt:
  225. piece = cipher.encrypt(piece)
  226. hash = send_command(store={
  227. 'piece': piece
  228. })['hash']
  229. pieces.append(hash)
  230. progress.update(1)
  231. metafile = Metafile(
  232. filename,
  233. file_size,
  234. checksum.digest(),
  235. pieces,
  236. key=key if args.encrypt else None
  237. )
  238. if not args.output:
  239. output = open(f'{filename}.byafn', 'wb')
  240. else:
  241. output = args.output
  242. metafile.save(output)
  243. output.close()
  244. if args.query:
  245. with args.query as f:
  246. metafile = Metafile.load(f)
  247. if not args.output:
  248. output = open(metafile.filename, 'wb')
  249. else:
  250. output = args.output
  251. if metafile.key:
  252. cipher = salsa20_create_decryptor(metafile.key)
  253. checksum = SHA256.new()
  254. progress = tqdm.trange(len(metafile.pieces))
  255. for piece in metafile.pieces:
  256. interval = 10
  257. while True:
  258. data = send_command(query={
  259. 'hash': piece
  260. })
  261. if 'piece' not in data:
  262. logging.error(f'Piece `{piece.hex()}\' is not available')
  263. logging.info(f'Retrying in {interval}sec.')
  264. time.sleep(interval)
  265. if interval < 120:
  266. interval += 10
  267. continue
  268. break
  269. piece = data['piece']
  270. if metafile.key:
  271. piece = cipher.decrypt(piece)
  272. checksum.update(piece)
  273. if checksum.digest() != metafile.checksum:
  274. logging.error('Integrity check failed')
  275. sys.exit(1)
  276. output.write(piece)
  277. progress.update(1)
  278. output.close()
  279. if args.shutdown is not None:
  280. send_command(shutdown={
  281. 'delay': args.shutdown
  282. })
  283. logging.info('Shutdown command sent')
  284. if args.cleanup is not None:
  285. response = send_command(cleanup={
  286. 'age': args.cleanup
  287. })
  288. logging.info(f'Removed {response["removed_count"]} piece(s).')