hcornet 506716e703
Some checks failed
Deployment Verification / deploy-and-test (push) Failing after 29s
first sync
2025-03-04 07:59:21 +01:00

618 lines
20 KiB
Python

#!/usr/bin/env python3
#
# IRIS Core Code
# contact@dfir-iris.org
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU Lesser General Public
# License as published by the Free Software Foundation; either
# version 3 of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
# Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public License
# along with this program; if not, write to the Free Software Foundation,
# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
import gnupg
import hashlib
import json
import os
import requests
import shutil
import subprocess
import tempfile
import time
from celery.schedules import crontab
from datetime import datetime
from flask_login import current_user
from flask_socketio import emit
from flask_socketio import join_room
from packaging import version
from pathlib import Path
from app import app
from app import cache
from app import celery
from app import db
from app import socket_io
from app.datamgmt.manage.manage_srv_settings_db import get_server_settings_as_dict
from app.iris_engine.backup.backup import backup_iris_db
from app.models import ServerSettings
from iris_interface import IrisInterfaceStatus as IStatus
log = app.logger
def update_log_to_socket(status, is_error=False):
log.info(status)
data = {
"message": status,
"is_error": is_error
}
socket_io.emit('update_status', data, to='iris_update_status', namespace='/server-updates')
def notify_server_off():
socket_io.emit('server_has_turned_off', {}, to='iris_update_status', namespace='/server-updates')
def notify_update_failed():
socket_io.emit('update_has_fail', {}, to='iris_update_status', namespace='/server-updates')
def update_log(status):
update_log_to_socket(status)
def update_log_error(status):
update_log_to_socket(status, is_error=True)
@socket_io.on('join-update', namespace='/server-updates')
def get_message(data):
room = data['channel']
join_room(room=room)
emit('join', {'message': f"{current_user.user} just joined", 'is_error': False}, room=room,
namespace='/server-updates')
@socket_io.on('update_ping', namespace='/server-updates')
def socket_on_update_ping(msg):
emit('update_ping', {'message': f"Server connected", 'is_error': False},
namespace='/server-updates')
@socket_io.on('update_get_current_version', namespace='/server-updates')
def socket_on_update_do_reboot(msg):
socket_io.emit('update_current_version', {"version": app.config.get('IRIS_VERSION')}, to='iris_update_status',
namespace='/server-updates')
def notify_server_ready_to_reboot():
socket_io.emit('server_ready_to_reboot', {}, to='iris_update_status', namespace='/server-updates')
def notify_server_has_updated():
socket_io.emit('server_has_updated', {}, to='iris_update_status', namespace='/server-updates')
def inner_init_server_update():
has_updates, updates_content, release_config = is_updates_available()
init_server_update(release_config)
def get_external_url(url):
server_settings = get_server_settings_as_dict()
proxies = server_settings.get('proxies')
try:
request = requests.get(url, proxies=proxies)
except Exception as e:
app.logger.error(e)
return None
return request
def get_latest_release():
try:
releases = get_external_url(app.config.get('RELEASE_URL'))
except Exception as e:
app.logger.error(e)
return True, {'message': f"Unexpected error. {str(e)}"}
if not releases:
return True, {'message': "Unexpected error"}
if releases.status_code == 200:
releases_j = releases.json()
return False, releases_j[0]
if releases.status_code == 403:
return True, releases.json()
return True, {'msg': "Unexpected error"}
def get_release_assets(assets_url):
try:
release_assets = get_external_url(assets_url)
except Exception as e:
app.logger.error(e)
return None
if release_assets:
return release_assets.json()
return None
def is_updates_available():
has_error, release = get_latest_release()
current_version = app.config.get('IRIS_VERSION')
if has_error:
return False, release.get('message'), None
release_version = release.get('name')
cache.delete('iris_has_updates')
srv_settings = ServerSettings.query.first()
if not srv_settings:
raise Exception('Unable to fetch server settings. Please reach out for help')
if version.parse(current_version) < version.parse(release_version):
srv_settings.has_updates_available = True
db.session.commit()
return True, f'# New version {release_version} available\n\n{release.get("body")}', release
else:
srv_settings.has_updates_available = False
db.session.commit()
return False, f'**Current server is up-to-date with {release_version}**', None
def init_server_update(release_config):
if not release_config:
update_log_error('Release config is empty. Please contact IRIS team')
notify_update_failed()
return False
update_log('Fetching release assets info')
has_error, temp_dir = download_release_assets(release_config.get('assets'))
if has_error:
update_log_error('Aborting upgrades - see previous errors')
notify_update_failed()
shutil.rmtree(temp_dir)
return False
has_error = verify_assets_signatures(temp_dir, release_config.get('assets'))
if has_error:
update_log_error('Aborting upgrades - see previous errors')
notify_update_failed()
shutil.rmtree(temp_dir)
return False
updates_config = verify_compatibility(temp_dir, release_config.get('assets'))
if updates_config is None:
update_log_error('Aborting upgrades - see previous errors')
notify_update_failed()
shutil.rmtree(temp_dir)
return False
update_archive = Path(temp_dir) / updates_config.get('app_archive')
has_error = verify_archive_fingerprint(update_archive, updates_config.get('app_fingerprint'))
if not has_error:
update_log_error('Aborting upgrades - see previous errors')
notify_update_failed()
shutil.rmtree(temp_dir)
return False
update_log('Backing up current version')
has_error = update_backup_current_version()
if has_error:
update_log_error('Aborting upgrades - see previous errors')
notify_update_failed()
shutil.rmtree(temp_dir)
return False
update_log('Backing up database')
has_error, logs = backup_iris_db()
if has_error:
for log_entry in logs:
update_log_error(log_entry)
update_log_error('Aborting upgrades - see previous errors')
notify_update_failed()
shutil.rmtree(temp_dir)
return False
if 'worker' in updates_config.get('scope'):
update_log('Worker needs to be updated. Scheduling updates task')
async_update = task_update_worker.delay(update_archive.as_posix(), updates_config)
update_log('Scheduled. Waiting for worker to finish updating')
result_output = async_update.get(interval=1)
if result_output.is_failure():
update_log_error('Worker failed to updates')
update_log_error(result_output.logs)
update_log_error('Aborting upgrades - see previous errors')
notify_update_failed()
shutil.rmtree(temp_dir)
return False
time.sleep(5)
async_update_version = task_update_get_version.delay()
result_output = async_update_version.get(interval=1)
if result_output.is_failure():
update_log_error('Worker failed to updates')
update_log_error(result_output.data)
update_log_error('Aborting upgrades - see previous errors')
notify_update_failed()
shutil.rmtree(temp_dir)
return False
worker_update_version = result_output.get_data()
if worker_update_version != updates_config.get('target_version'):
update_log_error('Worker failed to updates')
update_log_error(f'Expected version {updates_config.get("target_version")} but worker '
f'is in {worker_update_version}')
update_log_error('Aborting upgrades - see previous errors')
notify_update_failed()
shutil.rmtree(temp_dir)
return False
update_log(f'Worker updated to {updates_config.get("target_version")}')
if updates_config.get('need_app_reboot') is True:
update_log('Closing all database connections. Unsaved work will be lost.')
from sqlalchemy.orm import close_all_sessions
close_all_sessions()
update_log('All checks passed. IRIS will turn off shortly and updates')
update_log('Please don\'t leave the page - logging will resume here')
update_log('Handing off to updater')
notify_server_off()
time.sleep(0.5)
if 'iriswebapp' in updates_config.get('scope'):
if call_ext_updater(update_archive=update_archive, scope="iriswebapp",
need_reboot=updates_config.get('need_app_reboot')):
socket_io.stop()
return True
def verify_archive_fingerprint(update_archive, archive_sha256):
update_log('Verifying updates archive')
if update_archive.is_file():
sha256_hash = hashlib.sha256()
with open(update_archive, "rb") as f:
for byte_block in iter(lambda: f.read(4096), b""):
sha256_hash.update(byte_block)
current_sha256 = sha256_hash.hexdigest().upper()
if current_sha256 == archive_sha256:
return True
update_log_error(f'Fingerprint mismatch. Expected {archive_sha256} but got {current_sha256}')
else:
update_log_error(f'Archive {update_archive} not found')
return False
def call_ext_updater(update_archive, scope, need_reboot):
if not isinstance(update_archive, Path):
update_archive = Path(update_archive)
archive_name = Path(update_archive).stem
if os.getenv("DOCKERIZED"):
source_dir = Path.cwd() / 'scripts'
target_dir = Path.cwd()
docker = 1
else:
source_dir = Path.cwd().absolute() / 'scripts'
if app.config["DEVELOPMENT"]:
target_dir = Path('../../update_server/test_update')
else:
target_dir = Path.cwd()
docker = 0
try:
subprocess.Popen(["/bin/bash", f"{source_dir}/iris_updater.sh",
update_archive.as_posix(), # Update archive to unpack
target_dir.as_posix(), # Target directory of update
archive_name, # Root directory of the archive
scope[0], # Scope of the update
'1' if docker else '0', # Are we in docker ?
'1' if need_reboot else '0', # Do we need to restart the app
'&'])
except Exception as e :
log.error(str(e))
return False
return True
def update_backup_current_version():
date_time = datetime.now()
root_backup = Path(app.config.get("BACKUP_PATH"))
root_backup.mkdir(exist_ok=True)
backup_dir = root_backup / f"server_backup_{date_time.timestamp()}"
backup_dir.mkdir(exist_ok=True)
if not backup_dir.is_dir():
update_log_error(f"Unable to create directory {backup_dir} for backup. Aborting")
return True
if os.getenv("DOCKERIZED"):
source_dir = Path.cwd()
else:
source_dir = Path.cwd().parent.absolute()
try:
update_log(f'Copying {source_dir} to {backup_dir}')
shutil.copytree(source_dir, backup_dir, dirs_exist_ok=True)
except Exception as e:
update_log_error('Unable to backup current version')
update_log_error(str(e))
return False, ['Unable to backup current version', str(e)]
update_log('Current version backed up')
has_error = generate_backup_config_file(backup_dir)
if has_error:
return True
return False
def generate_backup_config_file(backup_dir):
backup_config = {
"backup_date": datetime.now().timestamp(),
"backup_version": app.config.get('IRIS_VERSION')
}
hashes_map = {}
for entry in backup_dir.rglob('*'):
if entry.is_file():
sha256_hash = hashlib.sha256()
with open(entry, "rb") as f:
for byte_block in iter(lambda: f.read(4096), b""):
sha256_hash.update(byte_block)
hashes_map[entry.as_posix()] = sha256_hash.hexdigest()
backup_config["hashes_map"] = hashes_map
try:
with open(backup_dir / "backup_config.json", 'w') as fconfig:
json.dump(backup_config, fconfig, indent=4)
except Exception as e:
update_log_error('Unable to save configuration file')
update_log_error(str(e))
return True
update_log('Backup configuration file generated')
return False
def verify_compatibility(target_directory, release_assets_info):
release_updates = None
update_log('Verifying updates compatibilities')
for release_asset in release_assets_info:
asset_name = release_asset.get('name')
if asset_name != 'release_updates.json':
continue
if (Path(target_directory) / asset_name).is_file():
release_updates = Path(target_directory) / asset_name
break
if not release_updates:
update_log_error('Unable to find release updates configuration file')
return None
try:
with open(file=release_updates) as fin:
updates_info = json.load(fin)
except Exception as e:
update_log_error('Unable to read release updates configuration file')
update_log_error(str(e))
update_log_error('Please contact DFIR-IRIS team')
return None
can_update = False
accepted_versions = updates_info.get('accepted_versions')
for av in accepted_versions:
if version.parse(app.config.get('IRIS_VERSION')) == version.parse(av):
can_update = True
break
if not can_update:
update_log_error(f'Current version {app.config.get("IRIS_VERSION")} cannot '
f'be updated to {updates_info.get("target_version")} automatically')
update_log_error(f'Supported versions are {updates_info.get("accepted_versions")}')
return None
if not updates_info.get('support_auto'):
update_log_error(f'This updates does not support automatic handling. Please read the upgrades instructions.')
return None
if 'worker' not in updates_info.get('scope') and 'iriswebapp' not in updates_info.get('scope'):
update_log_error(f'Something is wrong, updates configuration does not have any valid scope')
update_log_error('Please contact DFIR-IRIS team')
return None
update_log('Compatibly checks done. Good to go')
return updates_info
def verify_assets_signatures(target_directory, release_assets_info):
# Expects a signature for every assets
has_error = False
assets_check = {}
for release_asset in release_assets_info:
asset_name = release_asset.get('name')
if not asset_name.endswith('.sig'):
if (Path(target_directory) / asset_name).is_file():
if (Path(target_directory) / f"{asset_name}.sig").is_file():
assets_check[Path(target_directory) / asset_name] = Path(target_directory) / f"{asset_name}.sig"
else:
update_log_error(f"{asset_name} does not have a signature file")
has_error = True
else:
update_log_error(f"Could not find {Path(target_directory) / asset_name}")
has_error = True
if has_error:
return has_error
update_log("Importing DFIR-IRIS GPG key")
gpg = gnupg.GPG()
with open(app.config.get("RELEASE_SIGNATURE_KEY"), 'rb') as pkey:
import_result = gpg.import_keys(pkey.read())
if import_result.count < 1:
update_log_error(f'Unable to fetch {app.config.get("RELEASE_SIGNATURE_KEY")}')
has_error = True
for asset in assets_check:
with open(assets_check[asset], 'rb') as fin:
verified = gpg.verify_file(fin, data_filename=asset)
if not verified.valid:
update_log_error(f'{asset.name} does not have a valid signature (checked '
f'against {assets_check[asset].name}). '
f'Contact DFIR-IRIS team')
update_log_error(f"Signature status : {verified.status}")
has_error = True
continue
update_log(f"{asset.name} : signature validated")
return has_error
def download_release_assets(release_assets_info):
has_error = False
if not Path(app.config.get("UPDATES_PATH")).is_dir():
Path(app.config.get("UPDATES_PATH")).mkdir(exist_ok=True)
temp_dir = tempfile.mkdtemp(dir=app.config.get("UPDATES_PATH"))
for release_asset in release_assets_info:
asset_name = release_asset.get('name')
asset_url = release_asset.get('browser_download_url')
# TODO: Check for available FS free space before downloading
update_log(f'Downloading from {asset_url} to {temp_dir}')
if not download_from_url(asset_url, Path(temp_dir) / asset_name):
update_log_error('ERROR - Unable to save asset file to FS')
has_error = True
if has_error:
update_log_error('Aborting upgrades - see previous errors')
return has_error, temp_dir
def download_from_url(asset_url, target_file):
with open(target_file, "wb") as file:
response = get_external_url(asset_url)
file.write(response.content)
return Path(target_file).is_file()
@celery.task(bind=True)
def task_update_worker(self, update_archive, updates_config):
if not call_ext_updater(update_archive=update_archive, scope="worker",
need_reboot=updates_config.get('need_worker_reboot')):
return IStatus.I2Success(message="Unable to spawn updater")
return IStatus.I2Success(message="Worker updater called")
@celery.task(bind=True)
def task_update_get_version(self):
return IStatus.I2Success(data=app.config.get('IRIS_VERSION'))
@celery.on_after_finalize.connect
def setup_periodic_update_checks(self, **kwargs):
self.add_periodic_task(
crontab(hour=0, minute=0),
task_check_available_updates.s(),
name='iris_auto_check_updates'
)
def remove_periodic_update_checks():
if 'iris_auto_check_updates' in celery.conf['beat_schedule']:
del celery.conf['beat_schedule']['iris_auto_check_updates']
@celery.task
def task_check_available_updates():
log.info('Cron - Checking if updates are available')
has_updates, _, _ = is_updates_available()
srv_settings = ServerSettings.query.first()
if not srv_settings:
return IStatus.I2Error('Unable to fetch server settings. Please reach out for help')
srv_settings.has_updates_available = has_updates
db.session.commit()
if srv_settings.has_updates_available:
log.info('Updates are available for this server')
return IStatus.I2Success(f'Successfully checked updates. Available : {srv_settings.has_updates_available}')