sp-hydra-veil-core/core/models/BaseProfile.py

376 lines
12 KiB
Python

from core.models.manage.session_management import get_session
from core.errors.logger import logger
from sqlalchemy import select
from sqlalchemy.orm import joinedload
from abc import ABC, abstractmethod
from core.Constants import Constants
from core.Helpers import write_atomically
from core.models.manage.wrapper import safe_db_operation, WrapperRollback
from core.models.DatabaseOperation import DatabaseOperation, DBErrorType
# If switching to enums for Connection:
# from core.models.system.SystemConnection import SystemConnection, SystemConnectionTypes
# from core.models.session.SessionConnection import SessionConnection, SessionConnectionTypes
from core.controllers.LocationController import LocationController
from core.models.orm_models.Location import Location
from core.models.orm_models.Operator import Operator
from core.models.Subscription import Subscription
# from core.models.session.ApplicationVersion import ApplicationVersion
from core.models.orm_models.ApplicationVersion import ApplicationVersion
from core.models.orm_calls.application_version_calls import get_application_version
from dataclasses import dataclass, field, asdict
from dataclasses_json import config, Exclude, dataclass_json
from pathlib import Path
from typing import Optional, Self
import json
import os
import re
import shutil
import tempfile
from sqlalchemy.orm import Session
from enum import Enum
import inspect
from core.models.manage.session_management import init_session, get_session
@safe_db_operation
def execute_location_sql(country_code: str, city_code: str, session: Session) -> DatabaseOperation:
location_object = session.execute(
select(Location)
.where((Location.country_code == country_code) & (Location.code == city_code))
.options(joinedload(Location.operator))
).scalar_one_or_none()
return location_object
def get_profile_location_data(country_code: str, city_code: str) -> Location:
# with get_session() as session:
api_reply_object = execute_location_sql(country_code, city_code)
if api_reply_object.valid:
data = api_reply_object.data
location_obj = api_reply_object.data
return location_obj
else:
logger.error(f"[get_profile_location_data] Got invalid SQL Query")
return None
class ProfileType(str, Enum):
SESSION = "session"
SYSTEM = "system"
@dataclass_json
@dataclass
class BaseProfile(ABC):
id: int = field(
metadata=config(exclude=Exclude.ALWAYS)
)
name: str
subscription: Optional[Subscription]
type: ProfileType
location: Optional[Location] = field(metadata=config(exclude=Exclude.ALWAYS)) # SQLAlchemy object
# legacy version included it to be serialized, but now it's an SQLAlchemy object.
# location: Optional[Location]
@abstractmethod
def get_wireguard_configuration_path(self):
pass
@abstractmethod
def has_wireguard_configuration(self):
pass
def get_config_path(self):
return BaseProfile.__get_config_path(self.id)
def get_data_path(self):
return BaseProfile.__get_data_path(self.id)
def has_subscription(self):
return self.subscription is not None
def has_location(self):
return self.location is not None
def is_session_profile(self):
return type(self).__name__ == 'SessionProfile'
def is_system_profile(self):
return type(self).__name__ == 'SystemProfile'
def save(self: Self, app_version_dict: dict = None):
"""
First warn via the logs, if the caller is bypassing the Cache and accessing it directly.
Then persist changes in the profile/model to disk.
"""
#--------------------------
# Cache Drift Protection
#--------------------------
# 1. Get the call stack
# stack[0] is this current 'save' method
# stack[1] is the immediate caller
stack = inspect.stack()
caller_frame = stack[1]
caller_function_name = caller_frame.function
caller_module_name = caller_frame.filename # Full path to the file
# 2. Only the Repo or Controller is allowed to save
allowed_callers = ['create', 'update_cache']
is_allowed_file = "core/controllers/ProfileController.py" in caller_module_name or "core/services/profiles/ProfileRepository.py" in caller_module_name
if caller_function_name not in allowed_callers and not is_allowed_file:
logger.warning(
f"ARCHITECTURE VIOLATION: .save() called directly from "
f"function '{caller_function_name}' in {caller_module_name}. "
f"Please route this through ProfileController to ensure cache integrity."
)
#--------------------------
# Actual Save
#--------------------------
# === SERIALIZATION ===
config_dict = self.to_dict()
# === LOCATION ===
location_dict = self.location.convert_to_dict() # this is from SQLAlchemy, and not JSON-models, that's why it's separate.
if self.location:
config_dict["location"] = location_dict
# === APPLICATION ===
if app_version_dict:
config_dict["application_version"] = app_version_dict
# === FILE I/O ===
config_file_contents = json.dumps(config_dict, indent=4) + '\n'
# legacy version:
# config_file_contents = f'{self.to_json(indent=4)}\n'
os.makedirs(self.get_config_path(), exist_ok=True)
os.makedirs(self.get_data_path(), exist_ok=True)
config_file_path = f'{self.get_config_path()}/config.json'
write_atomically(config_file_path, config_file_contents)
def delete_data(self):
shutil.rmtree(self.get_data_path(), ignore_errors=True)
def delete(self):
shutil.rmtree(self.get_config_path(), ignore_errors=True)
self.delete_data()
def get_wireguard_configuration_metadata(self, key):
configuration = self.get_wireguard_configuration()
if configuration is not None:
for line in configuration.splitlines():
match = re.match(r'^# {} = (.*)$'.format(re.escape(key)), line)
if match:
return re.sub(r'[^a-zA-Z0-9+=\-_ /]', '', match.group(1).strip())
return None
def get_wireguard_public_keys(self):
import configparser
wireguard_public_keys = set()
configuration = self.get_wireguard_configuration()
parsed_configuration = configparser.ConfigParser()
if configuration is not None:
parsed_configuration.read_string(configuration)
for section in parsed_configuration.sections():
if parsed_configuration.has_option(section, 'PublicKey'):
wireguard_public_keys.add(parsed_configuration.get(section, 'PublicKey'))
return tuple(wireguard_public_keys)
def get_wireguard_configuration(self):
try:
with open(self.get_wireguard_configuration_path(), 'r') as file:
return file.read()
except (FileNotFoundError, PermissionError):
return None
def address_security_incident(self):
if self.has_wireguard_configuration():
wireguard_configuration_path = Path(self.get_wireguard_configuration_path())
incident_data_path = Path(Constants.HV_INCIDENT_DATA_HOME)
incident_data_path.mkdir(parents=True, exist_ok=True)
incident_path = Path(tempfile.mkdtemp(dir=incident_data_path, prefix=''))
incident_wireguard_configuration_path = f'{incident_path}/{wireguard_configuration_path.name}'
try:
shutil.copy2(wireguard_configuration_path, incident_wireguard_configuration_path)
os.chmod(incident_wireguard_configuration_path, 0o644)
except (FileNotFoundError, PermissionError):
if incident_path.is_dir():
incident_path_contents = incident_path.iterdir()
if not any(incident_path_contents):
incident_path.rmdir()
def _get_dirty_keys(self: Self):
reference = BaseProfile.find_by_id(self.id)
if type(reference) != type(self):
return list(self.__dataclass_fields__.keys())
return list([key for key, value in asdict(self).items() if value != asdict(reference).get(key)])
@staticmethod
def find_by_id(id: int):
try:
config_file_contents = open(f'{BaseProfile.__get_config_path(id)}/config.json', 'r').read()
except FileNotFoundError:
return None
try:
profile = json.loads(config_file_contents)
except ValueError:
return None
profile['id'] = id
profiles_location = profile['location']
if profile['location'] is not None:
# =========== GET COUNTRY & LOCATION ===========
if isinstance(profiles_location, dict):
try:
country_code = profile['location']['country_code']
city_code = profile['location']['code']
except:
logger.error(f"CRITICAL ERROR! Can't find country code or city for profile id {profile['id']} inside BaseProfile")
return
else:
# potentially coming from SQLAlchemy ALREADY:
country_code = profile.location.country_code
city_code = profile.location.code
# =========== GET DATA USING THAT COUNTRY & LOCATION ===========
location_dict = get_profile_location_data(country_code, city_code)
if location_dict:
profile['location'] = location_dict
# this needs error handling if there's no location or malconformed config.
# =========== SESSION ===========
if 'application_version' in profile:
profile['type'] = ProfileType.SESSION
if profile['application_version'] is not None:
application_version = get_application_version(
profile['application_version']['application_code'] or None,
profile['application_version']['version_number'] or None
)
if application_version is not None:
profile['application_version'] = application_version
if application_version is None:
profile['application_version'] = None
# legacy
# if profile['application_version'] is not None:
# application_version = ApplicationVersion.find(profile['application_version']['application_code'] or None, profile['application_version']['version_number'] or None)
# if application_version is not None:
# profile['application_version'] = application_version
from core.models.session.SessionProfile import SessionProfile
# noinspection PyUnresolvedReferences
profile = SessionProfile.from_dict(profile)
# =========== SYSTEM ===========
else:
profile['type'] = ProfileType.SYSTEM
from core.models.system.SystemProfile import SystemProfile
# noinspection PyUnresolvedReferences
profile = SystemProfile.from_dict(profile)
return profile
@staticmethod
def exists(id: int):
return re.match(r'^\d{1,2}$', str(id)) and os.path.isfile(f'{BaseProfile.__get_config_path(id)}/config.json')
@staticmethod
def all():
profiles = {}
for directory_entry in os.listdir(Constants.HV_PROFILE_CONFIG_HOME):
try:
id = int(directory_entry)
except ValueError:
continue
if BaseProfile.exists(id):
profile = BaseProfile.find_by_id(id)
if profile is not None:
profiles[id] = profile
return dict(sorted(profiles.items()))
@staticmethod
def __get_config_path(id: int):
return f'{Constants.HV_PROFILE_CONFIG_HOME}/{str(id)}'
@staticmethod
def __get_data_path(id: int):
return f'{Constants.HV_PROFILE_DATA_HOME}/{str(id)}'
# legacy phased out since SQLAlchemy handles the time_zone.
# if location is not None:
# if profile['location'].get('time_zone') is not None:
# location.time_zone = profile['location']['time_zone']
# profile['location'] = location