説明なし

updater.py 20KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616
  1. # IRIS Core Code
  2. # contact@dfir-iris.org
  3. #
  4. # This program is free software; you can redistribute it and/or
  5. # modify it under the terms of the GNU Lesser General Public
  6. # License as published by the Free Software Foundation; either
  7. # version 3 of the License, or (at your option) any later version.
  8. #
  9. # This program is distributed in the hope that it will be useful,
  10. # but WITHOUT ANY WARRANTY; without even the implied warranty of
  11. # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
  12. # Lesser General Public License for more details.
  13. #
  14. # You should have received a copy of the GNU Lesser General Public License
  15. # along with this program; if not, write to the Free Software Foundation,
  16. # Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
  17. import gnupg
  18. import hashlib
  19. import json
  20. import os
  21. import requests
  22. import shutil
  23. import subprocess
  24. import tempfile
  25. import time
  26. from celery.schedules import crontab
  27. from datetime import datetime
  28. from flask_login import current_user
  29. from flask_socketio import emit
  30. from flask_socketio import join_room
  31. from packaging import version
  32. from pathlib import Path
  33. from app import app
  34. from app import cache
  35. from app import celery
  36. from app import db
  37. from app import socket_io
  38. from app.datamgmt.manage.manage_srv_settings_db import get_server_settings_as_dict
  39. from app.iris_engine.backup.backup import backup_iris_db
  40. from app.models.models import ServerSettings
  41. from iris_interface import IrisInterfaceStatus as IStatus
  42. log = app.logger
  43. def update_log_to_socket(status, is_error=False):
  44. log.info(status)
  45. data = {
  46. "message": status,
  47. "is_error": is_error
  48. }
  49. socket_io.emit('update_status', data, to='iris_update_status', namespace='/server-updates')
  50. def notify_server_off():
  51. socket_io.emit('server_has_turned_off', {}, to='iris_update_status', namespace='/server-updates')
  52. def notify_update_failed():
  53. socket_io.emit('update_has_fail', {}, to='iris_update_status', namespace='/server-updates')
  54. def update_log(status):
  55. update_log_to_socket(status)
  56. def update_log_error(status):
  57. update_log_to_socket(status, is_error=True)
  58. @socket_io.on('join-update', namespace='/server-updates')
  59. def get_message(data):
  60. room = data['channel']
  61. join_room(room=room)
  62. emit('join', {'message': f"{current_user.user} just joined", 'is_error': False}, room=room,
  63. namespace='/server-updates')
  64. @socket_io.on('update_ping', namespace='/server-updates')
  65. def socket_on_update_ping(msg):
  66. emit('update_ping', {'message': "Server connected", 'is_error': False},
  67. namespace='/server-updates')
  68. @socket_io.on('update_get_current_version', namespace='/server-updates')
  69. def socket_on_update_do_reboot(msg):
  70. socket_io.emit('update_current_version', {"version": app.config.get('IRIS_VERSION')}, to='iris_update_status',
  71. namespace='/server-updates')
  72. def notify_server_ready_to_reboot():
  73. socket_io.emit('server_ready_to_reboot', {}, to='iris_update_status', namespace='/server-updates')
  74. def notify_server_has_updated():
  75. socket_io.emit('server_has_updated', {}, to='iris_update_status', namespace='/server-updates')
  76. def inner_init_server_update():
  77. has_updates, updates_content, release_config = is_updates_available()
  78. init_server_update(release_config)
  79. def get_external_url(url):
  80. server_settings = get_server_settings_as_dict()
  81. proxies = server_settings.get('proxies')
  82. try:
  83. request = requests.get(url, proxies=proxies)
  84. except Exception as e:
  85. app.logger.error(e)
  86. return None
  87. return request
  88. def get_latest_release():
  89. try:
  90. releases = get_external_url(app.config.get('RELEASE_URL'))
  91. except Exception as e:
  92. app.logger.error(e)
  93. return True, {'message': f"Unexpected error. {str(e)}"}
  94. if not releases:
  95. return True, {'message': "Unexpected error"}
  96. if releases.status_code == 200:
  97. releases_j = releases.json()
  98. return False, releases_j[0]
  99. if releases.status_code == 403:
  100. return True, releases.json()
  101. return True, {'msg': "Unexpected error"}
  102. def get_release_assets(assets_url):
  103. try:
  104. release_assets = get_external_url(assets_url)
  105. except Exception as e:
  106. app.logger.error(e)
  107. return None
  108. if release_assets:
  109. return release_assets.json()
  110. return None
  111. def is_updates_available():
  112. has_error, release = get_latest_release()
  113. current_version = app.config.get('IRIS_VERSION')
  114. if has_error:
  115. return False, release.get('message'), None
  116. release_version = release.get('name')
  117. cache.delete('iris_has_updates')
  118. srv_settings = ServerSettings.query.first()
  119. if not srv_settings:
  120. raise Exception('Unable to fetch server settings. Please reach out for help')
  121. if version.parse(current_version) < version.parse(release_version):
  122. srv_settings.has_updates_available = True
  123. db.session.commit()
  124. return True, f'# New version {release_version} available\n\n{release.get("body")}', release
  125. else:
  126. srv_settings.has_updates_available = False
  127. db.session.commit()
  128. return False, f'**Current server is up-to-date with {release_version}**', None
  129. def init_server_update(release_config):
  130. if not release_config:
  131. update_log_error('Release config is empty. Please contact IRIS team')
  132. notify_update_failed()
  133. return False
  134. update_log('Fetching release assets info')
  135. has_error, temp_dir = download_release_assets(release_config.get('assets'))
  136. if has_error:
  137. update_log_error('Aborting upgrades - see previous errors')
  138. notify_update_failed()
  139. shutil.rmtree(temp_dir)
  140. return False
  141. has_error = verify_assets_signatures(temp_dir, release_config.get('assets'))
  142. if has_error:
  143. update_log_error('Aborting upgrades - see previous errors')
  144. notify_update_failed()
  145. shutil.rmtree(temp_dir)
  146. return False
  147. updates_config = verify_compatibility(temp_dir, release_config.get('assets'))
  148. if updates_config is None:
  149. update_log_error('Aborting upgrades - see previous errors')
  150. notify_update_failed()
  151. shutil.rmtree(temp_dir)
  152. return False
  153. update_archive = Path(temp_dir) / updates_config.get('app_archive')
  154. has_error = verify_archive_fingerprint(update_archive, updates_config.get('app_fingerprint'))
  155. if not has_error:
  156. update_log_error('Aborting upgrades - see previous errors')
  157. notify_update_failed()
  158. shutil.rmtree(temp_dir)
  159. return False
  160. update_log('Backing up current version')
  161. has_error = update_backup_current_version()
  162. if has_error:
  163. update_log_error('Aborting upgrades - see previous errors')
  164. notify_update_failed()
  165. shutil.rmtree(temp_dir)
  166. return False
  167. update_log('Backing up database')
  168. has_error, logs = backup_iris_db()
  169. if has_error:
  170. for log_entry in logs:
  171. update_log_error(log_entry)
  172. update_log_error('Aborting upgrades - see previous errors')
  173. notify_update_failed()
  174. shutil.rmtree(temp_dir)
  175. return False
  176. if 'worker' in updates_config.get('scope'):
  177. update_log('Worker needs to be updated. Scheduling updates task')
  178. async_update = task_update_worker.delay(update_archive.as_posix(), updates_config)
  179. update_log('Scheduled. Waiting for worker to finish updating')
  180. result_output = async_update.get(interval=1)
  181. if result_output.is_failure():
  182. update_log_error('Worker failed to updates')
  183. update_log_error(result_output.logs)
  184. update_log_error('Aborting upgrades - see previous errors')
  185. notify_update_failed()
  186. shutil.rmtree(temp_dir)
  187. return False
  188. time.sleep(5)
  189. async_update_version = task_update_get_version.delay()
  190. result_output = async_update_version.get(interval=1)
  191. if result_output.is_failure():
  192. update_log_error('Worker failed to updates')
  193. update_log_error(result_output.data)
  194. update_log_error('Aborting upgrades - see previous errors')
  195. notify_update_failed()
  196. shutil.rmtree(temp_dir)
  197. return False
  198. worker_update_version = result_output.get_data()
  199. if worker_update_version != updates_config.get('target_version'):
  200. update_log_error('Worker failed to updates')
  201. update_log_error(f'Expected version {updates_config.get("target_version")} but worker '
  202. f'is in {worker_update_version}')
  203. update_log_error('Aborting upgrades - see previous errors')
  204. notify_update_failed()
  205. shutil.rmtree(temp_dir)
  206. return False
  207. update_log(f'Worker updated to {updates_config.get("target_version")}')
  208. if updates_config.get('need_app_reboot') is True:
  209. update_log('Closing all database connections. Unsaved work will be lost.')
  210. from sqlalchemy.orm import close_all_sessions
  211. close_all_sessions()
  212. update_log('All checks passed. IRIS will turn off shortly and updates')
  213. update_log('Please don\'t leave the page - logging will resume here')
  214. update_log('Handing off to updater')
  215. notify_server_off()
  216. time.sleep(0.5)
  217. if 'iriswebapp' in updates_config.get('scope'):
  218. if call_ext_updater(update_archive=update_archive, scope="iriswebapp",
  219. need_reboot=updates_config.get('need_app_reboot')):
  220. socket_io.stop()
  221. return True
  222. def verify_archive_fingerprint(update_archive, archive_sha256):
  223. update_log('Verifying updates archive')
  224. if update_archive.is_file():
  225. sha256_hash = hashlib.sha256()
  226. with open(update_archive, "rb") as f:
  227. for byte_block in iter(lambda: f.read(4096), b""):
  228. sha256_hash.update(byte_block)
  229. current_sha256 = sha256_hash.hexdigest().upper()
  230. if current_sha256 == archive_sha256:
  231. return True
  232. update_log_error(f'Fingerprint mismatch. Expected {archive_sha256} but got {current_sha256}')
  233. else:
  234. update_log_error(f'Archive {update_archive} not found')
  235. return False
  236. def call_ext_updater(update_archive, scope, need_reboot):
  237. if not isinstance(update_archive, Path):
  238. update_archive = Path(update_archive)
  239. archive_name = Path(update_archive).stem
  240. if os.getenv("DOCKERIZED"):
  241. source_dir = Path.cwd() / 'scripts'
  242. target_dir = Path.cwd()
  243. docker = 1
  244. else:
  245. source_dir = Path.cwd().absolute() / 'scripts'
  246. if app.config["DEVELOPMENT"]:
  247. target_dir = Path('../../update_server/test_update')
  248. else:
  249. target_dir = Path.cwd()
  250. docker = 0
  251. try:
  252. subprocess.Popen(["/bin/bash", f"{source_dir}/iris_updater.sh",
  253. update_archive.as_posix(), # Update archive to unpack
  254. target_dir.as_posix(), # Target directory of update
  255. archive_name, # Root directory of the archive
  256. scope[0], # Scope of the update
  257. '1' if docker else '0', # Are we in docker ?
  258. '1' if need_reboot else '0', # Do we need to restart the app
  259. '&'])
  260. except Exception as e :
  261. log.error(str(e))
  262. return False
  263. return True
  264. def update_backup_current_version():
  265. date_time = datetime.now()
  266. root_backup = Path(app.config.get("BACKUP_PATH"))
  267. root_backup.mkdir(exist_ok=True)
  268. backup_dir = root_backup / f"server_backup_{date_time.timestamp()}"
  269. backup_dir.mkdir(exist_ok=True)
  270. if not backup_dir.is_dir():
  271. update_log_error(f"Unable to create directory {backup_dir} for backup. Aborting")
  272. return True
  273. if os.getenv("DOCKERIZED"):
  274. source_dir = Path.cwd()
  275. else:
  276. source_dir = Path.cwd().parent.absolute()
  277. try:
  278. update_log(f'Copying {source_dir} to {backup_dir}')
  279. shutil.copytree(source_dir, backup_dir, dirs_exist_ok=True)
  280. except Exception as e:
  281. update_log_error('Unable to backup current version')
  282. update_log_error(str(e))
  283. return False, ['Unable to backup current version', str(e)]
  284. update_log('Current version backed up')
  285. has_error = generate_backup_config_file(backup_dir)
  286. if has_error:
  287. return True
  288. return False
  289. def generate_backup_config_file(backup_dir):
  290. backup_config = {
  291. "backup_date": datetime.now().timestamp(),
  292. "backup_version": app.config.get('IRIS_VERSION')
  293. }
  294. hashes_map = {}
  295. for entry in backup_dir.rglob('*'):
  296. if entry.is_file():
  297. sha256_hash = hashlib.sha256()
  298. with open(entry, "rb") as f:
  299. for byte_block in iter(lambda: f.read(4096), b""):
  300. sha256_hash.update(byte_block)
  301. hashes_map[entry.as_posix()] = sha256_hash.hexdigest()
  302. backup_config["hashes_map"] = hashes_map
  303. try:
  304. with open(backup_dir / "backup_config.json", 'w') as fconfig:
  305. json.dump(backup_config, fconfig, indent=4)
  306. except Exception as e:
  307. update_log_error('Unable to save configuration file')
  308. update_log_error(str(e))
  309. return True
  310. update_log('Backup configuration file generated')
  311. return False
  312. def verify_compatibility(target_directory, release_assets_info):
  313. release_updates = None
  314. update_log('Verifying updates compatibilities')
  315. for release_asset in release_assets_info:
  316. asset_name = release_asset.get('name')
  317. if asset_name != 'release_updates.json':
  318. continue
  319. if (Path(target_directory) / asset_name).is_file():
  320. release_updates = Path(target_directory) / asset_name
  321. break
  322. if not release_updates:
  323. update_log_error('Unable to find release updates configuration file')
  324. return None
  325. try:
  326. with open(file=release_updates) as fin:
  327. updates_info = json.load(fin)
  328. except Exception as e:
  329. update_log_error('Unable to read release updates configuration file')
  330. update_log_error(str(e))
  331. update_log_error('Please contact DFIR-IRIS team')
  332. return None
  333. can_update = False
  334. accepted_versions = updates_info.get('accepted_versions')
  335. for av in accepted_versions:
  336. if version.parse(app.config.get('IRIS_VERSION')) == version.parse(av):
  337. can_update = True
  338. break
  339. if not can_update:
  340. update_log_error(f'Current version {app.config.get("IRIS_VERSION")} cannot '
  341. f'be updated to {updates_info.get("target_version")} automatically')
  342. update_log_error(f'Supported versions are {updates_info.get("accepted_versions")}')
  343. return None
  344. if not updates_info.get('support_auto'):
  345. update_log_error('This updates does not support automatic handling. Please read the upgrades instructions.')
  346. return None
  347. if 'worker' not in updates_info.get('scope') and 'iriswebapp' not in updates_info.get('scope'):
  348. update_log_error('Something is wrong, updates configuration does not have any valid scope')
  349. update_log_error('Please contact DFIR-IRIS team')
  350. return None
  351. update_log('Compatibly checks done. Good to go')
  352. return updates_info
  353. def verify_assets_signatures(target_directory, release_assets_info):
  354. # Expects a signature for every assets
  355. has_error = False
  356. assets_check = {}
  357. for release_asset in release_assets_info:
  358. asset_name = release_asset.get('name')
  359. if not asset_name.endswith('.sig'):
  360. if (Path(target_directory) / asset_name).is_file():
  361. if (Path(target_directory) / f"{asset_name}.sig").is_file():
  362. assets_check[Path(target_directory) / asset_name] = Path(target_directory) / f"{asset_name}.sig"
  363. else:
  364. update_log_error(f"{asset_name} does not have a signature file")
  365. has_error = True
  366. else:
  367. update_log_error(f"Could not find {Path(target_directory) / asset_name}")
  368. has_error = True
  369. if has_error:
  370. return has_error
  371. update_log("Importing DFIR-IRIS GPG key")
  372. gpg = gnupg.GPG()
  373. with open(app.config.get("RELEASE_SIGNATURE_KEY"), 'rb') as pkey:
  374. import_result = gpg.import_keys(pkey.read())
  375. if import_result.count < 1:
  376. update_log_error(f'Unable to fetch {app.config.get("RELEASE_SIGNATURE_KEY")}')
  377. has_error = True
  378. for asset in assets_check:
  379. with open(assets_check[asset], 'rb') as fin:
  380. verified = gpg.verify_file(fin, data_filename=asset)
  381. if not verified.valid:
  382. update_log_error(f'{asset.name} does not have a valid signature (checked '
  383. f'against {assets_check[asset].name}). '
  384. f'Contact DFIR-IRIS team')
  385. update_log_error(f"Signature status : {verified.status}")
  386. has_error = True
  387. continue
  388. update_log(f"{asset.name} : signature validated")
  389. return has_error
  390. def download_release_assets(release_assets_info):
  391. has_error = False
  392. if not Path(app.config.get("UPDATES_PATH")).is_dir():
  393. Path(app.config.get("UPDATES_PATH")).mkdir(exist_ok=True)
  394. temp_dir = tempfile.mkdtemp(dir=app.config.get("UPDATES_PATH"))
  395. for release_asset in release_assets_info:
  396. asset_name = release_asset.get('name')
  397. asset_url = release_asset.get('browser_download_url')
  398. # TODO: Check for available FS free space before downloading
  399. update_log(f'Downloading from {asset_url} to {temp_dir}')
  400. if not download_from_url(asset_url, Path(temp_dir) / asset_name):
  401. update_log_error('ERROR - Unable to save asset file to FS')
  402. has_error = True
  403. if has_error:
  404. update_log_error('Aborting upgrades - see previous errors')
  405. return has_error, temp_dir
  406. def download_from_url(asset_url, target_file):
  407. with open(target_file, "wb") as file:
  408. response = get_external_url(asset_url)
  409. file.write(response.content)
  410. return Path(target_file).is_file()
  411. @celery.task(bind=True)
  412. def task_update_worker(self, update_archive, updates_config):
  413. if not call_ext_updater(update_archive=update_archive, scope="worker",
  414. need_reboot=updates_config.get('need_worker_reboot')):
  415. return IStatus.I2Success(message="Unable to spawn updater")
  416. return IStatus.I2Success(message="Worker updater called")
  417. @celery.task(bind=True)
  418. def task_update_get_version(self):
  419. return IStatus.I2Success(data=app.config.get('IRIS_VERSION'))
  420. @celery.on_after_finalize.connect
  421. def setup_periodic_update_checks(self, **kwargs):
  422. self.add_periodic_task(
  423. crontab(hour=0, minute=0),
  424. task_check_available_updates.s(),
  425. name='iris_auto_check_updates'
  426. )
  427. def remove_periodic_update_checks():
  428. if 'iris_auto_check_updates' in celery.conf['beat_schedule']:
  429. del celery.conf['beat_schedule']['iris_auto_check_updates']
  430. @celery.task
  431. def task_check_available_updates():
  432. log.info('Cron - Checking if updates are available')
  433. has_updates, _, _ = is_updates_available()
  434. srv_settings = ServerSettings.query.first()
  435. if not srv_settings:
  436. return IStatus.I2Error('Unable to fetch server settings. Please reach out for help')
  437. srv_settings.has_updates_available = has_updates
  438. db.session.commit()
  439. if srv_settings.has_updates_available:
  440. log.info('Updates are available for this server')
  441. return IStatus.I2Success(f'Successfully checked updates. Available : {srv_settings.has_updates_available}')