diff --git a/core/controllers/ApplicationController.py b/core/controllers/ApplicationController.py index 0dcce7b..1bc7fd6 100644 --- a/core/controllers/ApplicationController.py +++ b/core/controllers/ApplicationController.py @@ -35,6 +35,7 @@ class ApplicationController: @staticmethod def launch(version: ApplicationVersion, profile: SessionProfile, port_number: int = None, asynchronous: bool = False, profile_observer: Optional[ProfileObserver] = None, connection_observer: ConnectionObserver = None, ticket_observer: TicketObserver = None): + from core.services.profiles.profile_connections import enable_profile, disable_profile from core.controllers.ProfileController import ProfileController persistent_state_path = f'{profile.get_data_path()}/persistent-state' @@ -99,7 +100,8 @@ class ApplicationController: if not fork_process_id: ApplicationController.__run_process(initialization_file_path, profile, display, session_state) - ProfileController.disable(profile, False, profile_observer=profile_observer, ticket_observer=ticket_observer, connection_observer=connection_observer) + # ProfileController.disable(profile, False, profile_observer=profile_observer, ticket_observer=ticket_observer, connection_observer=connection_observer) + disable_profile(profile, False, profile_observer=profile_observer, ticket_observer=ticket_observer, connection_observer=connection_observer) time.sleep(1.0) sys.exit() @@ -107,7 +109,8 @@ class ApplicationController: else: ApplicationController.__run_process(initialization_file_path, profile, display, session_state) - ProfileController.disable(profile, False, profile_observer=profile_observer, ticket_observer=ticket_observer, connection_observer=connection_observer) + # ProfileController.disable(profile, False, profile_observer=profile_observer, ticket_observer=ticket_observer, connection_observer=connection_observer) + disable_profile(profile, False, profile_observer=profile_observer, ticket_observer=ticket_observer, connection_observer=connection_observer) @staticmethod def _sync(proxies: Optional[dict] = None): diff --git a/core/controllers/ConnectionController.py b/core/controllers/ConnectionController.py index 985a5fb..086829d 100644 --- a/core/controllers/ConnectionController.py +++ b/core/controllers/ConnectionController.py @@ -77,8 +77,11 @@ class ConnectionController: if not ConnectionUnprotectedError in ignore: raise ConnectionUnprotectedError('Connection unprotected while the system is not using a WireGuard interface.') else: - from core.controllers.ProfileController import ProfileController - ProfileController.disable(profile=profile, connection_observer=connection_observer, ticket_observer=ticket_observer) + from core.services.profiles.profile_connections import disable_profile + disable_profile(profile=profile, connection_observer=connection_observer, ticket_observer=ticket_observer) + # from core.controllers.ProfileController import ProfileController + # ProfileController.disable(profile=profile, connection_observer=connection_observer, ticket_observer=ticket_observer) + if profile.connection.code == 'tor': diff --git a/core/controllers/ProfileController.py b/core/controllers/ProfileController.py index 8e36f67..d02e255 100644 --- a/core/controllers/ProfileController.py +++ b/core/controllers/ProfileController.py @@ -1,3 +1,4 @@ +from core.services.profiles.ProfileRepository import ProfileRepository from core.services.networking.systemwide.systemwide_wireguard import terminate_system_connection from core.services.networking.general_connection_tools.testing_evaluating import system_uses_wireguard_interface from core.services.networking.general_connection_tools.connection_enable import establish_connection @@ -8,8 +9,8 @@ from core.services.subscriptions import subscriptions from core.errors.exceptions import FirewallError from core.models.Result import Result, ResultError from core.observers.TicketObserver import TicketObserver -from core.services.assassin.ticket_respawn import respawn_profile -from core.services.assassin import assassin_tools +# from core.services.assassin.ticket_respawn import respawn_profile +# from core.services.assassin import assassin_tools from core.errors.logger import logger from core.Errors import InvalidSubscriptionError, MissingSubscriptionError, ConnectionTerminationError, ProfileActivationError, ProfileDeactivationError, MissingLocationError, ConnectionUnprotectedError, EndpointVerificationError, ProfileStateConflictError @@ -32,230 +33,55 @@ import time class ProfileController: + # Initialize the singleton repository + _repo = ProfileRepository() @staticmethod def get(id: int) -> Union[SessionProfile, SystemProfile, None]: - return Profile.find_by_id(id) + # Controller no longer talks to the Model; it talks to the Repo + return ProfileController._repo.get(id) @staticmethod def get_all(): - return Profile.all() + return ProfileController._repo.get_all() @staticmethod def create(profile: Union[SessionProfile, SystemProfile], profile_observer: ProfileObserver = None): + # Call the helper with the 'created' event + ProfileController._save_and_sync(profile, 'created', profile_observer) + @staticmethod + def update(profile: Union[SessionProfile, SystemProfile], profile_observer: ProfileObserver = None): + # Call the helper with the 'updated' event + ProfileController._save_and_sync(profile, 'updated', profile_observer) + + @staticmethod + def _save_and_sync(profile, event_type: str, observer: ProfileObserver = None): + """Private helper to handle the actual persistence and cache synchronization.""" + # 1. Persist to disk profile.save() - if profile_observer is not None: - profile_observer.notify('created', profile) + # 2. update cache to prevent drift + ProfileController._repo.update_cache(profile) + + # 3. Notify observer + if observer is not None: + observer.notify(event_type, profile) @staticmethod - def enable( - profile: Union[SessionProfile, SystemProfile], - ignore: tuple[type[Exception]] = (), - pristine: bool = False, - asynchronous: bool = False, - profile_observer: ProfileObserver = None, - application_version_observer: ApplicationVersionObserver = None, - connection_observer: ConnectionObserver = None, - ticket_observer: TicketObserver = None, - max_resolution: Optional[str] = None - ) -> Result: + def delete(profile: Union[SessionProfile, SystemProfile], profile_observer: ProfileObserver = None): + """Remove the profile from both disk and cache""" - from core.controllers.ConnectionController import ConnectionController + # 1. Save to temp variable: + profile_id = profile.id - # =========== ALREADY ENABLED ============ - if ProfileController.is_enabled(profile): - if not ProfileStateConflictError in ignore: - raise ProfileStateConflictError('The profile is already enabled or its session was not properly terminated.') - else: - ProfileController.disable(profile) - - # =========== PRISTINE ============ - if pristine: - profile.delete_data() - - # ============================================================================ - # SESSION - # ============================================================================ - if profile.is_session_profile(): - - # ASSASSIN MODE - if profile.assassin: - assassin_result = assassin_tools.create( - profile=profile, - max_resolution=max_resolution, - ticket_observer=ticket_observer, - connection_observer=connection_observer - ) - if not assassin_result.valid: - error_msg = f"Error with setting up Assassin: {assassin_result.error_type} & {assassin_result.message}" - logger.error(error_msg) - raise ProfileActivationError(f'Assassin NOT enabled: {assassin_result.message}') - # random assassin values become the main profile: - profile = assassin_result.data - # CONCLUSION OF ASSASSIN CODE - - application_version = profile.application_version - - if not application_version.is_installed(): - ApplicationVersionController.install(application_version, application_version_observer=application_version_observer, connection_observer=connection_observer) - - try: - port_number = establish_connection(profile, ignore=ignore, connection_observer=connection_observer, ticket_observer=ticket_observer) - except ConnectionError: - raise ProfileActivationError('The profile could not be enabled.') - except ValueError: - raise ProfileActivationError('The profile could not be enabled.') - - if profile_observer is not None: - profile_observer.notify('enabled', profile) - - ApplicationController.launch(application_version, profile, port_number, asynchronous=asynchronous, profile_observer=profile_observer, ticket_observer=ticket_observer, connection_observer=connection_observer) - return Result(valid=True) - - # ============================================================================ - # SYSTEMWIDE - # ============================================================================ - if profile.is_system_profile(): - try: - connection_result = establish_connection(profile, ignore=ignore, connection_observer=connection_observer) - if connection_result.valid: - if profile_observer is not None: - profile_observer.notify('enabled', profile) - return Result(valid=True) - else: - logger.error(f"Couldn't enable the profile: {connection_result.error_type}") - error_msg = connection_result.message - raise ProfileActivationError(error_msg) - except FirewallError: - raise - except ConnectionError: - raise ProfileActivationError('The profile could not be enabled.') - except ValueError: - raise ProfileActivationError('The profile could not be enabled.') - - - @staticmethod - def disable( - profile: Union[SessionProfile, SystemProfile], - explicitly: bool = True, - ignore: tuple[type[Exception]] = (), - profile_observer: ProfileObserver = None, - ticket_observer: TicketObserver = None, - connection_observer: ConnectionObserver = None, - wipe_assassin: bool = False - ): - - from core.controllers.ConnectionController import ConnectionController - - if profile.is_session_profile(): - - # START ASSASSIN SECTION - if profile.assassin and wipe_assassin: - print("triggering to WIPE assassin") - assassin_result = assassin_tools.wipe( - profile=profile, - ticket_observer=ticket_observer, - connection_observer=connection_observer - ) - if not assassin_result.valid: - error_msg = f"Error with wiping respawn of Assassin: {assassin_result.error_type} & {assassin_result.message}" - logger.error(error_msg) - raise ProfileDeactivationError(f'Assassin NOT disabled: {assassin_result.message}') - # END ASSASSIN SECTION - - if SessionStateController.exists(profile.id): - - session_state = SessionStateController.get(profile.id) - - if session_state is not None: - - for port_number in session_state.network_port_numbers.tor: - ConnectionController.terminate_tor_session_connection(port_number) - - session_state.dissolve(session_state.id) - - if profile_observer is not None: - - profile_observer.notify('disabled', profile, dict( - explicitly=explicitly, - )) - - # ============================================================================ - # SYSTEMWIDE - # ============================================================================ - if profile.is_system_profile(): - - subjects = ProfileController.get_all().values() - - for subject in subjects: - - if subject.is_session_profile(): - - if subject.connection.is_unprotected() and ProfileController.is_enabled(subject) and not ConnectionUnprotectedError in ignore: - raise ConnectionUnprotectedError('Disabling this system connection would leave one or more sessions exposed.') - - if SystemStateController.exists(): - - system_state = SystemStateController.get() - - if profile.id != system_state.profile_id: - raise ProfileDeactivationError('The profile could not be disabled.') - - try: - # ================= SETTINGS ================= - firewall_setting = get_firewall_setting() - dns_setting = get_dns_setting() - # ======= KILL SYSTEMWIDE ============= - if profile.connection.code == "wireguard": - terminate_system_connection( - firewall_setting=firewall_setting, - dns_setting=dns_setting - ) - elif profile.connection.code in ("hysteria2", "vless"): - end_singbox() - else: - raise ProfileDeactivationError('Unsupported protocol.') - # ================= UPDATE UI ================ - # if it made it this far, it worked in theory. - if profile_observer is not None: - profile_observer.notify('disabled', profile, dict( - explicitly=explicitly, - )) - except ConnectionTerminationError: - raise ProfileDeactivationError('The profile could not be disabled.') - except ValueError: - raise ProfileDeactivationError('The profile could not be disabled.') - except FirewallError: - raise - - time.sleep(1.0) - - @staticmethod - def destroy(profile: Union[SessionProfile, SystemProfile], profile_observer: ProfileObserver = None, ticket_observer: TicketObserver = None, connection_observer: ConnectionObserver = None): - - #################################### - # DESTROY TICKET - #################################### - which_ticket = profile.ticket - respawned = respawn_profile( - profile=profile, - ticket_observer=ticket_observer, - connection_observer=connection_observer - ) - if which_ticket and not respawned: - logger.error("Serious issue with respawning the ticket. We might raise an error here.") - - #################################### - # DESTROY PROFILE - #################################### - ProfileController.disable(profile) + # 2. Permanently delete from disk profile.delete() - if profile_observer is not None: - profile_observer.notify('destroyed', profile) + # 3. update cache with the temp variable to prevent drift + ProfileController._repo.invalidate(profile_id=profile_id) + @staticmethod def attach_subscription(profile: Union[SessionProfile, SystemProfile], subscription: Subscription): @@ -289,24 +115,6 @@ class ProfileController: else: raise MissingSubscriptionError() - @staticmethod - def is_enabled(profile: Union[SessionProfile, SystemProfile]): - - from core.controllers.ConnectionController import ConnectionController - - if profile.is_session_profile(): - - session_state = SessionStateController.get_or_new(profile.id) - return len(session_state.network_port_numbers.all) > 0 or len(session_state.process_ids) > 0 - - if profile.is_system_profile(): - - system_state = SystemStateController.get() - - if system_state is not None and system_state.profile_id is profile.id: - return system_uses_wireguard_interface() - - return False @staticmethod def get_invoice(profile: Union[SessionProfile, SystemProfile]): @@ -347,12 +155,3 @@ class ProfileController: def has_wireguard_configuration(profile: Union[SessionProfile, SystemProfile]): return profile.has_wireguard_configuration() - @staticmethod - def turn_on_assassin(profile: SessionProfile): - profile.assassin = True - profile.save() - - @staticmethod - def turn_off_assassin(profile: SessionProfile): - profile.assassin = False - profile.save() diff --git a/core/controllers/profile_state/update_profile.py b/core/controllers/profile_state/update_profile.py index ac75a1d..0e507bf 100644 --- a/core/controllers/profile_state/update_profile.py +++ b/core/controllers/profile_state/update_profile.py @@ -3,6 +3,7 @@ from core.errors.logger import logger from core.models.Result import Result, ResultError from core.services.networking.systemwide.general_tools.manage_sudo_configs import remove_sudo_config +from core.controllers.ProfileController import ProfileController from core.models.BaseProfile import BaseProfile as Profile from core.models.BaseProfile import ProfileType @@ -110,7 +111,7 @@ def update_profile( logger.info("[UPDATE PROFILE] Passing to Profile model to save..") try: - profile.save() + ProfileController.update(profile) logger.info("[UPDATE PROFILE] Save worked, passing to the GUI the Result..") return Result(valid=True) except Exception as e: @@ -181,5 +182,5 @@ def change_systemwide_connection(profile: Profile, new_value: str) -> Result: return Result(valid=True, data=profile) -def save_billing_code(profile: Profile, billing_code: str) -> bool: +# def save_billing_code(profile: Profile, billing_code: str) -> bool: \ No newline at end of file diff --git a/core/models/BaseProfile.py b/core/models/BaseProfile.py index 19e71f4..5243ba1 100644 --- a/core/models/BaseProfile.py +++ b/core/models/BaseProfile.py @@ -109,6 +109,37 @@ class BaseProfile(ABC): 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() @@ -337,3 +368,8 @@ class BaseProfile(ABC): # if profile['location'].get('time_zone') is not None: # location.time_zone = profile['location']['time_zone'] # profile['location'] = location + + + + + diff --git a/core/services/assassin/assassin_tools.py b/core/services/assassin/assassin_tools.py index 24e409d..6d3968f 100644 --- a/core/services/assassin/assassin_tools.py +++ b/core/services/assassin/assassin_tools.py @@ -129,7 +129,8 @@ def use_ticket_get_sub( assassins_subscription = Subscription(billing_code=billing_code) profile.subscription = assassins_subscription profile.ticket = which_ticket - profile.save() + from core.controllers.ProfileController import ProfileController + ProfileController.update(profile) return Result(valid=True, data=profile) @@ -157,7 +158,7 @@ def wipe( profile.name = "assassin" profile.subscription = None profile.ticket = None - profile.save() + ProfileController.update(profile) # DELETE the pre-existing WG config: profile.delete_wireguard_configuration() diff --git a/core/services/assassin/ticket_respawn.py b/core/services/assassin/ticket_respawn.py index 0427682..8f14f62 100644 --- a/core/services/assassin/ticket_respawn.py +++ b/core/services/assassin/ticket_respawn.py @@ -1,3 +1,4 @@ +# from core.controllers.ProfileController import ProfileController from core.controllers.tickets.TicketPrepController import respawn_billing_code_into_ticket from core.services.prepare_tickets.ticket_tracker import find_ticket_by_sub from core.services.prepare_tickets.ticket_tracker import get_all_unused_tickets @@ -46,7 +47,9 @@ def respawn_profile( # Regardless of how it was acquired, if not which_ticket: - return Result(valid=False, error_type=ResultError.TICKET, message="This profile is not associated with a ticket. And/or you lack a ticket slot to respawn.") + error_msg = "This profile is not associated with a ticket. And/or you lack a ticket slot to respawn." + logger.error(error_msg) + return Result(valid=False, error_type=ResultError.TICKET, message=error_msg) notification = f"Respawn Started for Ticket {which_ticket}" logger.info(notification) @@ -78,7 +81,8 @@ def respawn_profile( # save the ticket, profile.ticket = which_ticket - profile.save() + from core.controllers.ProfileController import ProfileController + ProfileController.update(profile) return Result(valid=True, message="This profile fully respawned and had it's billing code wiped.") diff --git a/core/services/profiles/ProfileRepository.py b/core/services/profiles/ProfileRepository.py new file mode 100644 index 0000000..729a50b --- /dev/null +++ b/core/services/profiles/ProfileRepository.py @@ -0,0 +1,54 @@ +from core.models.BaseProfile import BaseProfile + +# generic +from typing import Dict, List, Union, Optional +from threading import Lock + +class ProfileRepository: + """ + Singleton Repository that mediates between the Controller and the Model. + Handles caching logic to prevent redundant file system hits. + """ + _instance = None + _lock = Lock() # Ensure singleton thread-safety + + def __new__(cls): + with cls._lock: + if cls._instance is None: + cls._instance = super(ProfileRepository, cls).__new__(cls) + # The actual cache: { profile_id: profile_object } + cls._instance._cache: Dict[int, any] = {} + return cls._instance + + def get(self, profile_id: int): + # 1. Check cache first + if profile_id in self._cache: + return self._cache[profile_id] + + # 2. Fallback to disk via the Model + profile = BaseProfile.find_by_id(profile_id) + + # 3. Update cache for next time + if profile: + self._cache[profile_id] = profile + + return profile + + def get_all(self): + # First, get the current list of IDs from the model, to avoid drift, from new files added to disk + all_profiles = BaseProfile.all() + + # Update our internal cache with everything currently on disk + for p in all_profiles: + self._cache[id] = p + + return all_profiles + + def update_cache(self, profile): + """Explicitly updates the cache to prevent drift after a save.""" + self._cache[profile.id] = profile + + def invalidate(self, profile_id: int): + """Removes an item from cache if it's deleted from disk.""" + self._cache.pop(profile_id, None) + diff --git a/core/services/profiles/profile_connections.py b/core/services/profiles/profile_connections.py new file mode 100644 index 0000000..796b769 --- /dev/null +++ b/core/services/profiles/profile_connections.py @@ -0,0 +1,314 @@ +from core.controllers.ConnectionController import ConnectionController +from core.controllers.ProfileController import ProfileController +from core.services.networking.systemwide.systemwide_wireguard import terminate_system_connection +from core.services.networking.general_connection_tools.testing_evaluating import system_uses_wireguard_interface +from core.services.networking.general_connection_tools.connection_enable import establish_connection +from core.services.networking.systemwide.systemwide_utils import get_firewall_setting, get_dns_setting +from core.services.networking.systemwide.encrypted_proxy.singbox_runner import end_singbox +from core.services.networking.systemwide import killswitch +from core.services.subscriptions import subscriptions +from core.errors.exceptions import FirewallError +from core.models.Result import Result, ResultError +from core.observers.TicketObserver import TicketObserver +from core.services.assassin.ticket_respawn import respawn_profile +from core.services.assassin import assassin_tools +from core.errors.logger import logger + +from core.Errors import InvalidSubscriptionError, MissingSubscriptionError, ConnectionTerminationError, ProfileActivationError, ProfileDeactivationError, MissingLocationError, ConnectionUnprotectedError, EndpointVerificationError, ProfileStateConflictError +from core.controllers.ApplicationController import ApplicationController +from core.controllers.ApplicationVersionController import ApplicationVersionController +from core.controllers.SessionStateController import SessionStateController +from core.controllers.SystemStateController import SystemStateController +from core.models.BaseProfile import BaseProfile as Profile +from core.models.Subscription import Subscription +from core.models.session.SessionProfile import SessionProfile +from core.models.system.SystemProfile import SystemProfile +from core.observers.ApplicationVersionObserver import ApplicationVersionObserver +from core.observers.ConnectionObserver import ConnectionObserver +from core.observers.ProfileObserver import ProfileObserver +from core.services.WebServiceApiService import WebServiceApiService + +# generic +from typing import Union, Optional +import base64 +import re +import time + + +def is_enabled(profile: Union[SessionProfile, SystemProfile]) -> int | None: + """Return the quantity of profiles enabled, or False if none are.""" + + if profile.is_session_profile(): + + session_state = SessionStateController.get_or_new(profile.id) + return len(session_state.network_port_numbers.all) > 0 or len(session_state.process_ids) > 0 + + if profile.is_system_profile(): + + system_state = SystemStateController.get() + + if system_state is not None and system_state.profile_id is profile.id: + return system_uses_wireguard_interface() + + return False + + +def enable_session_profile( + profile: Union[SessionProfile, SystemProfile], + ignore: tuple[type[Exception]] = (), + asynchronous: bool = False, + profile_observer: ProfileObserver = None, + application_version_observer: ApplicationVersionObserver = None, + connection_observer: ConnectionObserver = None, + ticket_observer: TicketObserver = None, + max_resolution: Optional[str] = None +) -> Result: + """ + Launch a session profile. + + 1) by starting a connnection on a port, + 2) and then starting the application using that networking port + """ + + # ASSASSIN MODE + if profile.assassin: + assassin_result = assassin_tools.create( + profile=profile, + max_resolution=max_resolution, + ticket_observer=ticket_observer, + connection_observer=connection_observer + ) + if not assassin_result.valid: + error_msg = f"Error with setting up Assassin: {assassin_result.error_type} & {assassin_result.message}" + logger.error(error_msg) + raise ProfileActivationError(f'Assassin NOT enabled: {assassin_result.message}') + # random assassin values become the main profile: + profile = assassin_result.data + # CONCLUSION OF ASSASSIN CODE + + application_version = profile.application_version + + if not application_version.is_installed(): + ApplicationVersionController.install(application_version, application_version_observer=application_version_observer, connection_observer=connection_observer) + + try: + port_number = establish_connection(profile, ignore=ignore, connection_observer=connection_observer, ticket_observer=ticket_observer) + except ConnectionError: + raise ProfileActivationError('The profile could not be enabled.') + except ValueError: + raise ProfileActivationError('The profile could not be enabled.') + + if profile_observer is not None: + profile_observer.notify('enabled', profile) + + ApplicationController.launch(application_version, profile, port_number, asynchronous=asynchronous, profile_observer=profile_observer, ticket_observer=ticket_observer, connection_observer=connection_observer) + return Result(valid=True) + + + +def enable_profile( + profile: Union[SessionProfile, SystemProfile], + ignore: tuple[type[Exception]] = (), + pristine: bool = False, + asynchronous: bool = False, + profile_observer: ProfileObserver = None, + application_version_observer: ApplicationVersionObserver = None, + connection_observer: ConnectionObserver = None, + ticket_observer: TicketObserver = None, + max_resolution: Optional[str] = None +) -> Result: + + """Orchestrate Enabling both types of profiles""" + + # =========== ALREADY ENABLED ============ + if is_enabled(profile): + if not ProfileStateConflictError in ignore: + raise ProfileStateConflictError('The profile is already enabled or its session was not properly terminated.') + else: + ProfileController.disable(profile) + + # =========== PRISTINE ============ + if pristine: + profile.delete_data() + + # ============================================================================ + # SESSION + # ============================================================================ + if profile.is_session_profile(): + return enable_session_profile( + profile=profile, + ignore=ignore, + asynchronous=asynchronous, + profile_observer=profile_observer, + application_version_observer=application_version_observer, + connection_observer=connection_observer, + ticket_observer=ticket_observer, + max_resolution=max_resolution + ) + + # ============================================================================ + # SYSTEMWIDE + # ============================================================================ + if profile.is_system_profile(): + try: + connection_result = establish_connection(profile, ignore=ignore, connection_observer=connection_observer) + if connection_result.valid: + if profile_observer is not None: + profile_observer.notify('enabled', profile) + return Result(valid=True) + else: + logger.error(f"Couldn't enable the profile: {connection_result.error_type}") + error_msg = connection_result.message + raise ProfileActivationError(error_msg) + except FirewallError: + raise + except ConnectionError: + raise ProfileActivationError('The profile could not be enabled.') + except ValueError: + raise ProfileActivationError('The profile could not be enabled.') + + +def disable_profile( + profile: Union[SessionProfile, SystemProfile], + explicitly: bool = True, + ignore: tuple[type[Exception]] = (), + profile_observer: ProfileObserver = None, + ticket_observer: TicketObserver = None, + connection_observer: ConnectionObserver = None, + wipe_assassin: bool = False +) -> Result: + + """Orchestrate disabling a profile.""" + + if profile.is_session_profile(): + + # START ASSASSIN SECTION + if profile.assassin and wipe_assassin: + print("triggering to WIPE assassin") + assassin_result = assassin_tools.wipe( + profile=profile, + ticket_observer=ticket_observer, + connection_observer=connection_observer + ) + if not assassin_result.valid: + error_msg = f"Error with wiping respawn of Assassin: {assassin_result.error_type} & {assassin_result.message}" + logger.error(error_msg) + raise ProfileDeactivationError(f'Assassin NOT disabled: {assassin_result.message}') + # END ASSASSIN SECTION + + if SessionStateController.exists(profile.id): + + session_state = SessionStateController.get(profile.id) + + if session_state is not None: + + for port_number in session_state.network_port_numbers.tor: + ConnectionController.terminate_tor_session_connection(port_number) + + session_state.dissolve(session_state.id) + + if profile_observer is not None: + + profile_observer.notify('disabled', profile, dict( + explicitly=explicitly, + )) + + # ============================================================================ + # SYSTEMWIDE + # ============================================================================ + if profile.is_system_profile(): + + subjects = ProfileController.get_all().values() + + for subject in subjects: + + if subject.is_session_profile(): + + if subject.connection.is_unprotected() and is_enabled(subject) and not ConnectionUnprotectedError in ignore: + raise ConnectionUnprotectedError('Disabling this system connection would leave one or more sessions exposed.') + + if SystemStateController.exists(): + + system_state = SystemStateController.get() + + if profile.id != system_state.profile_id: + raise ProfileDeactivationError('The profile could not be disabled.') + + try: + # ================= SETTINGS ================= + firewall_setting = get_firewall_setting() + dns_setting = get_dns_setting() + # ======= KILL SYSTEMWIDE ============= + if profile.connection.code == "wireguard": + terminate_system_connection( + firewall_setting=firewall_setting, + dns_setting=dns_setting + ) + elif profile.connection.code in ("hysteria2", "vless"): + end_singbox() + else: + raise ProfileDeactivationError('Unsupported protocol.') + # ================= UPDATE UI ================ + # if it made it this far, it worked in theory. + if profile_observer is not None: + profile_observer.notify('disabled', profile, dict( + explicitly=explicitly, + )) + except ConnectionTerminationError: + raise ProfileDeactivationError('The profile could not be disabled.') + except ValueError: + raise ProfileDeactivationError('The profile could not be disabled.') + except FirewallError: + raise + + time.sleep(1.0) + + +def turn_on_assassin(profile: SessionProfile): + profile.assassin = True + ProfileController.update(profile) + + +def turn_off_assassin(profile: SessionProfile): + profile.assassin = False + ProfileController.update(profile) + + +def destroy_profile( + profile: Union[SessionProfile, SystemProfile], + profile_observer: ProfileObserver = None, + ticket_observer: TicketObserver = None, + connection_observer: ConnectionObserver = None +) -> Result: + """ + Permanently end a profile, by: + + 1) Respawning it's ticket if it has one + 2) Disabling it, + 3) Destroying it on disk & cache + """ + #################################### + # RESPAWN TICKET + #################################### + which_ticket = profile.ticket + respawn_result = respawn_profile( + profile=profile, + ticket_observer=ticket_observer, + connection_observer=connection_observer + ) + if which_ticket and not respawn_result.valid: + logger.error(f"Unable to respawn the ticket. {respawn_result.message} {respawn_result.error_type}") + + #################################### + # DESTROY PROFILE + #################################### + logger.info("Disabling profile") + disable_profile(profile) + + logger.info("Wiping profile from disk..") + ProfileController.delete(profile) + + if profile_observer is not None: + profile_observer.notify('destroyed', profile) + + return Result(valid=True, message="Deleted Profile") \ No newline at end of file diff --git a/core/services/subscriptions/subscriptions.py b/core/services/subscriptions/subscriptions.py index 2cd1fa2..5062e2e 100644 --- a/core/services/subscriptions/subscriptions.py +++ b/core/services/subscriptions/subscriptions.py @@ -88,7 +88,8 @@ def activate_subscription( raise InvalidSubscriptionError() profile.subscription = subscription - profile.save() + from core.controllers.ProfileController import ProfileController + ProfileController.update(profile) return True diff --git a/core/services/subscriptions/subscriptions_by_protocol.py b/core/services/subscriptions/subscriptions_by_protocol.py index 5d89d79..409a09b 100644 --- a/core/services/subscriptions/subscriptions_by_protocol.py +++ b/core/services/subscriptions/subscriptions_by_protocol.py @@ -1,4 +1,5 @@ from core.models.manage.server_reply_tools import api_call_and_extraction, ApiCall +from core.controllers.ProfileController import ProfileController from core.models.BaseProfile import BaseProfile as Profile from core.services.payment_phase.payment_state import OverallPaymentCycle, PaymentData, TicketPaymentData from core.models.enum_models import ProtocolChoice, PaymentCategory @@ -242,7 +243,7 @@ def create_individual_invoice(payment_cycle: OverallPaymentCycle, connection_obs logger.info(f"creating an individual invoice for profile {profile_id}") # Get profile object: - profile = Profile.find_by_id(profile_id) + profile = ProfileController.get(profile_id) if not profile: error_msg = f"Invalid profile id of {profile_id}" logger.error(error_msg) @@ -315,7 +316,7 @@ def create_individual_invoice(payment_cycle: OverallPaymentCycle, connection_obs expires_at=expires_at, billing_pending=True ) - profile.save() + ProfileController.update(profile) payment_cycle.got_invoice = True return Result(valid=True, data=final_invoice) @@ -430,7 +431,7 @@ def get_profile_subscription(profile_id: int, save_pending: Optional[bool] = Fal Optional flag to SAVE that the billing is pending. (metaphor of a POST request) """ print(f"starting lookup of profile id {profile_id}") - profile = Profile.find_by_id(profile_id) + profile = ProfileController.get(profile_id) if not profile: error_msg = f"Invalid profile id of {profile_id}" logger.error(error_msg) @@ -440,7 +441,7 @@ def get_profile_subscription(profile_id: int, save_pending: Optional[bool] = Fal # Optional save pending flag: if save_pending: profile.billing_pending = True - profile.save() + ProfileController.update(profile) print(f"returning profile {profile}") return profile.subscription @@ -490,14 +491,14 @@ def mark_profile_as_paid(profile_id: int): logger.info(f"Marking profile_id {str(profile_id)} as paid..") # Get real profile object: - profile = Profile.find_by_id(profile_id) + profile = ProfileController.get(profile_id) if not profile: error_msg = f"Invalid profile id of {profile_id}" logger.error(error_msg) return Result(valid=False, message=error_msg, error_type=ResultError.INVALID_INPUT) profile.subscription.billing_pending = False - profile.save() + ProfileController.update(profile) return Result(valid=True) @@ -513,7 +514,7 @@ def save_billing_code(profile_id: int, billing_code: str, expires_at: datetime) return Result(valid=False, message=error_msg, error_type=ResultError.MISSING_DATA) # Get profile object: - profile = Profile.find_by_id(profile_id) + profile = ProfileController.get(profile_id) if not profile: error_msg = f"Invalid profile id of {profile_id}" logger.error(error_msg) @@ -525,7 +526,7 @@ def save_billing_code(profile_id: int, billing_code: str, expires_at: datetime) expires_at=expires_at, billing_pending=True ) - profile.save() + ProfileController.update(profile) return Result(valid=True) except Exception as e: error_msg = f"Legacy system wouldn't create or save Subscription object with {str(billing_code)} and {str(expires_at)} or type {type(expires_at)}, with error: {str(e)}" @@ -542,7 +543,7 @@ def save_subscription(profile: Profile, subscription: Subscription) -> bool: return False profile.subscription = subscription - profile.save() + ProfileController.update(profile) return True