From d6c96643453273fc8a0ea1e0f71c316c7fc03c0d Mon Sep 17 00:00:00 2001 From: codeking Date: Wed, 11 Sep 2024 19:39:33 +0200 Subject: [PATCH] Initial commit --- .gitignore | 1 + README.md | 115 +++++++ core/Constants.py | 29 ++ core/Errors.py | 38 +++ core/controllers/ApplicationController.py | 99 ++++++ .../ApplicationVersionController.py | 59 ++++ core/controllers/ClientController.py | 48 +++ core/controllers/ClientVersionController.py | 30 ++ core/controllers/ConfigurationController.py | 76 +++++ core/controllers/ConnectionController.py | 293 ++++++++++++++++++ core/controllers/InvoiceController.py | 79 +++++ core/controllers/LocationController.py | 22 ++ core/controllers/ProfileController.py | 222 +++++++++++++ core/controllers/SessionStateController.py | 26 ++ core/controllers/SubscriptionController.py | 21 ++ .../controllers/SubscriptionPlanController.py | 24 ++ core/controllers/SystemStateController.py | 26 ++ core/models/BaseConnection.py | 11 + core/models/BaseProfile.py | 149 +++++++++ core/models/ClientVersion.py | 80 +++++ core/models/Configuration.py | 77 +++++ core/models/Event.py | 6 + core/models/Location.py | 76 +++++ core/models/Model.py | 73 +++++ core/models/Subscription.py | 31 ++ core/models/SubscriptionPlan.py | 89 ++++++ core/models/invoice/Invoice.py | 23 ++ core/models/invoice/PaymentMethod.py | 12 + core/models/session/Application.py | 53 ++++ core/models/session/ApplicationVersion.py | 98 ++++++ core/models/session/ProxyConfiguration.py | 11 + core/models/session/SessionConnection.py | 18 ++ core/models/session/SessionProfile.py | 47 +++ core/models/session/SessionState.py | 102 ++++++ core/models/system/SystemConnection.py | 15 + core/models/system/SystemProfile.py | 9 + core/models/system/SystemState.py | 59 ++++ core/observers/ApplicationVersionObserver.py | 7 + core/observers/BaseObserver.py | 25 ++ core/observers/ClientObserver.py | 7 + core/observers/ConnectionObserver.py | 7 + core/observers/InvoiceObserver.py | 10 + core/observers/ProfileObserver.py | 11 + core/services/WebServiceApiService.py | 167 ++++++++++ main.py | 259 ++++++++++++++++ requirements.txt | 7 + 46 files changed, 2747 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 core/Constants.py create mode 100644 core/Errors.py create mode 100644 core/controllers/ApplicationController.py create mode 100644 core/controllers/ApplicationVersionController.py create mode 100644 core/controllers/ClientController.py create mode 100644 core/controllers/ClientVersionController.py create mode 100644 core/controllers/ConfigurationController.py create mode 100644 core/controllers/ConnectionController.py create mode 100644 core/controllers/InvoiceController.py create mode 100644 core/controllers/LocationController.py create mode 100644 core/controllers/ProfileController.py create mode 100644 core/controllers/SessionStateController.py create mode 100644 core/controllers/SubscriptionController.py create mode 100644 core/controllers/SubscriptionPlanController.py create mode 100644 core/controllers/SystemStateController.py create mode 100644 core/models/BaseConnection.py create mode 100644 core/models/BaseProfile.py create mode 100644 core/models/ClientVersion.py create mode 100644 core/models/Configuration.py create mode 100644 core/models/Event.py create mode 100644 core/models/Location.py create mode 100644 core/models/Model.py create mode 100644 core/models/Subscription.py create mode 100644 core/models/SubscriptionPlan.py create mode 100644 core/models/invoice/Invoice.py create mode 100644 core/models/invoice/PaymentMethod.py create mode 100644 core/models/session/Application.py create mode 100644 core/models/session/ApplicationVersion.py create mode 100644 core/models/session/ProxyConfiguration.py create mode 100644 core/models/session/SessionConnection.py create mode 100644 core/models/session/SessionProfile.py create mode 100644 core/models/session/SessionState.py create mode 100644 core/models/system/SystemConnection.py create mode 100644 core/models/system/SystemProfile.py create mode 100644 core/models/system/SystemState.py create mode 100644 core/observers/ApplicationVersionObserver.py create mode 100644 core/observers/BaseObserver.py create mode 100644 core/observers/ClientObserver.py create mode 100644 core/observers/ConnectionObserver.py create mode 100644 core/observers/InvoiceObserver.py create mode 100644 core/observers/ProfileObserver.py create mode 100644 core/services/WebServiceApiService.py create mode 100644 main.py create mode 100644 requirements.txt diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a09c56d --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/.idea diff --git a/README.md b/README.md new file mode 100644 index 0000000..851bb15 --- /dev/null +++ b/README.md @@ -0,0 +1,115 @@ +# sp-client-core (`v0.1.0`) + +## Prerequisites + +### Application Data + +* `xdg-data-resources.tar.gz` + * `~/.local/share/simplified-privacy` + +### System Packages + +```bash +sudo apt install bubblewrap iproute2 microsocks proxychains4 ratpoison tor wireguard xserver-xephyr +``` + +### Python Packages + +All external Python dependencies can be found in `requirements.txt`. + +## Command Line Interface + +For testing purposes, this version contains a command line interface that will eventually be extracted. + +> **Important:** At the same time, said `cli` serves as a reference implementation of the library. Do not rely on `main.py` when writing your own implementation. In addition, please do not interact with logic outside the controllers. + +All commands and subcommands feature help messages (accessible through `-h` or `--help`). + +### Configuration + +#### Get Connection + +```bash +python3 main.py get connection +``` + +#### Set Connection + +```bash +# Example 1 +python3 main.py set connection system + +# Example 2 +python3 main.py set connection tor +``` + +### Sync + +```bash +python3 main.py sync +``` + +### Profiles + +#### Create System Profile + +```bash +python3 main.py profile create system -i 1 -n 'Primary' -l 'md' -c 'wireguard' +``` + +#### Create Session Profile + +```bash +# Example 1 +python3 main.py profile create session -i 2 -n 'Research' -l 'md' -a 'brave:1.63.165' -c 'tor' -r '1024x768' + +# Example 2 +python3 main.py profile create session -i 3 -n 'Entertainment' -l 'md' -a 'chromium:122.0.6261.94-1' -c 'system' -r '1600x900' + +# Example 3 +python3 main.py profile create session -i 4 -n 'Banking' -l 'md' -a 'firefox:123.0' -c 'wireguard' -m +``` + +#### List Profiles + +```bash +python3 main.py profile list +``` + +#### Show Profile + +```bash +python3 main.py profile show -i 4 +``` + +#### Destroy Profile + +```bash +python3 main.py profile destroy -i 4 +``` + +#### Enable Profile + +```bash +# Enable (halt in case of state conflicts and/or potential security issues). +python3 main.py profile enable -i 4 + +# Enable (ignore state conflicts and/or potential security issues). +python3 main.py profile enable -i 4 -f +``` + +#### Disable Profile + +```bash +# Disable (halt in case of state conflicts and/or potential security issues). +python3 main.py profile disable -i 4 + +# Disable (ignore state conflicts and/or potential security issues). +python3 main.py profile disable -i 4 -f +``` + +### Version Information + +```bash +python3 main.py --version +``` diff --git a/core/Constants.py b/core/Constants.py new file mode 100644 index 0000000..db34024 --- /dev/null +++ b/core/Constants.py @@ -0,0 +1,29 @@ +from dataclasses import dataclass +from typing import Final +import os + + +@dataclass(frozen=True) +class Constants: + + CLIENT_VERSION: Final[str] = '1.0.0' + HOME: Final[str] = os.path.expanduser('~') + + CONFIG_HOME: Final[str] = os.environ.get('XDG_CONFIG_HOME') or os.path.join(HOME, '.config') + DATA_HOME: Final[str] = os.environ.get('XDG_DATA_HOME') or os.path.join(HOME, '.local/share') + STATE_HOME: Final[str] = os.environ.get('XDG_STATE_HOME') or os.path.join(HOME, '.local/state') + + SP_CONFIG_HOME: Final[str] = CONFIG_HOME + '/simplified-privacy' + SP_DATA_HOME: Final[str] = DATA_HOME + '/simplified-privacy' + SP_STATE_HOME: Final[str] = STATE_HOME + '/simplified-privacy' + + SP_PROFILE_CONFIG_HOME: Final[str] = SP_CONFIG_HOME + '/profiles' + SP_PROFILE_DATA_HOME: Final[str] = SP_DATA_HOME + '/profiles' + + SP_APPLICATION_DATA_HOME: Final[str] = SP_DATA_HOME + '/applications' + + SP_SESSION_STATE_HOME: Final[str] = SP_STATE_HOME + '/sessions' + + SP_STORAGE_DATABASE: Final[str] = SP_DATA_HOME + '/storage.db' + + SP_API_BASE_URL: Final[str] = 'https://api.simplifiedprivacy.is/api/v1' diff --git a/core/Errors.py b/core/Errors.py new file mode 100644 index 0000000..6849f69 --- /dev/null +++ b/core/Errors.py @@ -0,0 +1,38 @@ +class UnknownConnectionTypeError(Exception): + pass + + +class ProfileStateConflictError(Exception): + pass + + +class ProfileActivationError(Exception): + pass + + +class ProfileDeactivationError(Exception): + pass + + +class MissingSubscriptionError(Exception): + pass + + +class InvalidSubscriptionError(Exception): + pass + + +class InvoiceNotFoundError(Exception): + pass + + +class InvoiceExpiredError(Exception): + pass + + +class InvoicePaymentFailedError(Exception): + pass + + +class ConnectionUnprotectedError(Exception): + pass diff --git a/core/controllers/ApplicationController.py b/core/controllers/ApplicationController.py new file mode 100644 index 0000000..b584639 --- /dev/null +++ b/core/controllers/ApplicationController.py @@ -0,0 +1,99 @@ +from core.Constants import Constants +from core.controllers.SessionStateController import SessionStateController +from core.models.session.Application import Application +from core.models.session.ApplicationVersion import ApplicationVersion +from core.models.session.SessionProfile import SessionProfile +from core.models.session.SessionState import SessionState +from core.observers.ProfileObserver import ProfileObserver +from core.services.WebServiceApiService import WebServiceApiService +from pathlib import Path +from typing import Optional +import os +import re +import shutil +import stat +import subprocess + + +class ApplicationController: + + @staticmethod + def get(code: str): + return Application.find(code) + + @staticmethod + def launch(version: ApplicationVersion, profile: SessionProfile, port_number: int = None, profile_observer: Optional[ProfileObserver] = None): + + persistent_state_path = f'{profile.get_data_path()}/persistent-state' + + if not os.path.isdir(persistent_state_path) or len(os.listdir(persistent_state_path)) == 0: + shutil.copytree(f'{version.installation_path}/resources/initial-state', persistent_state_path) + + display = ApplicationController._find_unused_display() + + base_initialization_file_template = open(f'/{Constants.SP_DATA_HOME}/.init.ptpl', 'r').read() + base_initialization_file_contents = base_initialization_file_template.format(display=display, time_zone=profile.location.time_zone, sp_data_home=Constants.SP_DATA_HOME) + + application_initialization_file_template = open(f'/{version.installation_path}/.init.ptpl', 'r').read() + application_initialization_file_contents = application_initialization_file_template.format(application_version_home=version.installation_path, port_number=port_number or -1, home=Constants.HOME, profile_data_path=profile.get_data_path(), config_home=Constants.CONFIG_HOME, application_version_number=version.version_number) + + session_state = SessionStateController.get_or_new(profile.id) + SessionStateController.update_or_create(session_state) + + initialization_file_contents = base_initialization_file_contents + application_initialization_file_contents + initialization_file_path = f'{session_state.get_state_path()}/.init' + + Path(initialization_file_path).touch(exist_ok=True, mode=0o600 | stat.S_IEXEC) + open(initialization_file_path, 'w').write(initialization_file_contents) + + fork_process_id = os.fork() + + if not fork_process_id: + + process = subprocess.Popen(('xinit', initialization_file_path, '--', '/usr/bin/Xephyr', '-ac', '-title', f'Simplified Privacy - {profile.name or "Unnamed Profile"}', '-screen', profile.resolution, '-no-host-grab', display), stdout=subprocess.DEVNULL, stderr=subprocess.STDOUT) + + session_state = SessionState(session_state.id, session_state.network_port_numbers, [process.pid]) + SessionStateController.update_or_create(session_state) + + os.waitpid(process.pid, 0) + + from core.controllers.ProfileController import ProfileController + ProfileController.disable(profile, False, profile_observer=profile_observer) + + @staticmethod + def fetch(proxies: Optional[dict] = None): + + applications = WebServiceApiService.get_applications(proxies) + supported_applications = [] + + for application in applications: + if application.code in ('firefox', 'brave', 'chromium'): + supported_applications.append(application) + + Application.truncate() + Application.save_many(applications) + + @staticmethod + def _find_unused_display(): + + file_names = os.listdir('/tmp/.X11-unix') + active_displays = [] + + for file_name in file_names: + + match_object = re.search(r'X(\d+)', file_name) + + if match_object: + + detected_display = int(match_object.group(1)) + + if 170 <= detected_display <= 198: + active_displays.append(detected_display) + + if len(active_displays) > 0: + + unused_display = sorted(active_displays)[-1] + 1 + return ':' + str(unused_display) + + else: + return ':170' diff --git a/core/controllers/ApplicationVersionController.py b/core/controllers/ApplicationVersionController.py new file mode 100644 index 0000000..9c9558f --- /dev/null +++ b/core/controllers/ApplicationVersionController.py @@ -0,0 +1,59 @@ +from core.models.session.ApplicationVersion import ApplicationVersion +from core.observers.ApplicationVersionObserver import ApplicationVersionObserver +from core.observers.ConnectionObserver import ConnectionObserver +from core.services.WebServiceApiService import WebServiceApiService +from io import BytesIO +from typing import Optional +import requests +import shutil +import tarfile + + +class ApplicationVersionController: + + @staticmethod + def get(application_code: str, version_number: str): + return ApplicationVersion.find(application_code, version_number) + + @staticmethod + def fetch(proxies: Optional[dict] = None): + + application_versions = [] + + for application_code in 'firefox', 'brave', 'chromium': + + application_version_subset = WebServiceApiService.get_application_versions(application_code, proxies) + + for application_version in application_version_subset: + application_versions.append(application_version) + + ApplicationVersion.truncate() + ApplicationVersion.save_many(application_versions) + + @staticmethod + def install(application_version: ApplicationVersion, application_version_observer: Optional[ApplicationVersionObserver] = None, connection_observer: Optional[ConnectionObserver] = None): + + from core.controllers.ConnectionController import ConnectionController + ConnectionController.with_preferred_connection(application_version, task=ApplicationVersionController._install, application_version_observer=application_version_observer, connection_observer=connection_observer) + + @staticmethod + def uninstall(application_version: ApplicationVersion): + shutil.rmtree(application_version.installation_path, ignore_errors=True) + + @staticmethod + def _install(application_version: ApplicationVersion, application_version_observer: Optional[ApplicationVersionObserver] = None, proxies: Optional[dict] = None): + + if application_version_observer is not None: + application_version_observer.notify('downloading', application_version) + + if proxies is not None: + response = requests.get(application_version.download_path, stream=True, proxies=proxies) + else: + response = requests.get(application_version.download_path, stream=True) + + if response.status_code == 200: + with tarfile.open(fileobj=BytesIO(response.content), mode='r:gz') as file: + file.extractall(application_version.installation_path) + + else: + raise ConnectionError('The application version could not be downloaded.') diff --git a/core/controllers/ClientController.py b/core/controllers/ClientController.py new file mode 100644 index 0000000..95d1a04 --- /dev/null +++ b/core/controllers/ClientController.py @@ -0,0 +1,48 @@ +from core.Constants import Constants +from core.controllers.ApplicationController import ApplicationController +from core.controllers.ApplicationVersionController import ApplicationVersionController +from core.controllers.ClientVersionController import ClientVersionController +from core.controllers.ConfigurationController import ConfigurationController +from core.controllers.LocationController import LocationController +from core.controllers.SubscriptionPlanController import SubscriptionPlanController +from core.observers import ClientObserver +from core.observers.ConnectionObserver import ConnectionObserver +from os import path +from typing import Optional + + +class ClientController: + + @staticmethod + def get_working_directory(): + + import main + return str(path.dirname(path.abspath(main.__file__))) + + @staticmethod + def get_version(): + return ClientVersionController.get(Constants.CLIENT_VERSION) + + @staticmethod + def can_be_updated(): + return not ClientVersionController.is_latest(ClientController.get_version()) + + @staticmethod + def sync(client_observer: ClientObserver = None, connection_observer: ConnectionObserver = None): + + from core.controllers.ConnectionController import ConnectionController + ConnectionController.with_preferred_connection(task=ClientController._sync, client_observer=client_observer, connection_observer=connection_observer) + + @staticmethod + def _sync(client_observer: Optional[ClientObserver] = None, proxies: Optional[dict] = None): + + if client_observer is not None: + client_observer.notify('synchronizing') + + ApplicationController.fetch(proxies) + ApplicationVersionController.fetch(proxies) + ClientVersionController.fetch(proxies) + LocationController.fetch(proxies) + SubscriptionPlanController.fetch(proxies) + + ConfigurationController.update_last_synced_at() diff --git a/core/controllers/ClientVersionController.py b/core/controllers/ClientVersionController.py new file mode 100644 index 0000000..be5df75 --- /dev/null +++ b/core/controllers/ClientVersionController.py @@ -0,0 +1,30 @@ +from core.models.ClientVersion import ClientVersion +from core.services.WebServiceApiService import WebServiceApiService +from typing import Optional + + +class ClientVersionController: + + @staticmethod + def get_latest(): + return ClientVersion.latest() + + @staticmethod + def is_latest(client_version): + return client_version.version_number == ClientVersion.latest().version_number + + @staticmethod + def get(version_number: str): + return ClientVersion.find(version_number) + + @staticmethod + def get_all(): + return ClientVersion.all() + + @staticmethod + def fetch(proxies: Optional[dict] = None): + + client_versions = WebServiceApiService.get_client_versions(proxies) + + ClientVersion.truncate() + ClientVersion.save_many(client_versions) diff --git a/core/controllers/ConfigurationController.py b/core/controllers/ConfigurationController.py new file mode 100644 index 0000000..27c7eb1 --- /dev/null +++ b/core/controllers/ConfigurationController.py @@ -0,0 +1,76 @@ +from core.Errors import UnknownConnectionTypeError +from core.models.Configuration import Configuration +from datetime import datetime, timezone +from typing import Optional + + +class ConfigurationController: + + @staticmethod + def get(): + return Configuration.get() + + @staticmethod + def get_or_new(): + + configuration = ConfigurationController.get() + + if configuration is None: + return Configuration() + + return configuration + + @staticmethod + def get_connection(): + + configuration = ConfigurationController.get() + + if configuration is None or configuration.connection not in ['system', 'tor']: + raise UnknownConnectionTypeError('The preferred connection type could not be determined.') + + return configuration.connection + + @staticmethod + def set_connection(connection: Optional[str] = None): + + configuration = ConfigurationController.get_or_new() + configuration.connection = connection + configuration.save(configuration) + + @staticmethod + def get_auto_sync_enabled(): + + configuration = ConfigurationController.get() + + if configuration is None: + return False + + return configuration.auto_sync_enabled + + @staticmethod + def set_auto_sync_enabled(enable_auto_sync: Optional[bool] = None): + + configuration = ConfigurationController.get_or_new() + configuration.auto_sync_enabled = enable_auto_sync + configuration.save(configuration) + + @staticmethod + def get_last_synced_at(): + + configuration = ConfigurationController.get() + + if configuration is None: + return None + + return configuration.last_synced_at + + @staticmethod + def update_last_synced_at(): + + configuration = ConfigurationController.get_or_new() + configuration.last_synced_at = datetime.now(timezone.utc) + Configuration.save(configuration) + + @staticmethod + def update_or_create(configuration): + Configuration.save(configuration) diff --git a/core/controllers/ConnectionController.py b/core/controllers/ConnectionController.py new file mode 100644 index 0000000..35c16b5 --- /dev/null +++ b/core/controllers/ConnectionController.py @@ -0,0 +1,293 @@ +from core.Constants import Constants +from core.Errors import InvalidSubscriptionError, MissingSubscriptionError, ConnectionUnprotectedError, ProfileActivationError +from core.controllers.ConfigurationController import ConfigurationController +from core.controllers.ProfileController import ProfileController +from core.controllers.SessionStateController import SessionStateController +from core.controllers.SystemStateController import SystemStateController +from core.models.session.SessionProfile import SessionProfile +from core.models.system.SystemProfile import SystemProfile +from core.models.system.SystemState import SystemState +from core.observers import ConnectionObserver +from core.services.WebServiceApiService import WebServiceApiService +from pathlib import Path +from typing import Union, Optional +import os +import re +import requests +import socket +import subprocess +import tempfile +import time + + +class ConnectionController: + + @staticmethod + def with_preferred_connection(*args, task: callable, connection_observer: Optional[ConnectionObserver] = None, **kwargs): + + connection = ConfigurationController.get_connection() + + if connection == 'system': + return task(*args, **kwargs) + + elif connection == 'tor': + return ConnectionController._with_tor_connection(*args, task=task, connection_observer=connection_observer, **kwargs) + + @staticmethod + def _with_tor_connection(*args, task: callable, connection_observer: Optional[ConnectionObserver] = None, **kwargs): + + session_directory = tempfile.mkdtemp(prefix='sp-') + port_number = ConnectionController.get_random_available_port_number() + process = ConnectionController.establish_tor_session_connection(session_directory, port_number) + + ConnectionController.await_connection(port_number, connection_observer=connection_observer) + task_output = task(*args, proxies=ConnectionController.get_proxies(port_number), **kwargs) + process.terminate() + + return task_output + + @staticmethod + def establish_connection(profile: Union[SessionProfile, SystemProfile], force: bool = False, connection_observer: Optional[ConnectionObserver] = None): + + connection = profile.connection + + if connection.needs_proxy_configuration() and not profile.has_proxy_configuration(): + + if profile.has_subscription(): + + if profile.subscription.expires_at is None: + ProfileController.activate_subscription(profile, connection_observer=connection_observer) + + proxy_configuration = ConnectionController.with_preferred_connection(profile.subscription.billing_code, task=WebServiceApiService.get_proxy_configuration, connection_observer=connection_observer) + + if proxy_configuration is None: + raise InvalidSubscriptionError() + + profile.attach_proxy_configuration(proxy_configuration) + + else: + raise MissingSubscriptionError() + + if connection.needs_wireguard_configuration() and not profile.has_wireguard_configuration(): + + if profile.has_subscription(): + + if profile.subscription.expires_at is None: + ProfileController.activate_subscription(profile, connection_observer=connection_observer) + + wireguard_configuration = ConnectionController.with_preferred_connection(profile.location.code, profile.subscription.billing_code, task=WebServiceApiService.post_wireguard_session, connection_observer=connection_observer) + + if wireguard_configuration is None: + raise InvalidSubscriptionError() + + profile.attach_wireguard_configuration(wireguard_configuration) + + else: + raise MissingSubscriptionError() + + if profile.is_session_profile(): + return ConnectionController.establish_session_connection(profile, force=force, connection_observer=connection_observer) + + if profile.is_system_profile(): + return ConnectionController.establish_system_connection(profile) + + @staticmethod + def establish_session_connection(profile: SessionProfile, force: Optional[bool] = False, connection_observer: Optional[ConnectionObserver] = None): + + session_directory = tempfile.mkdtemp(prefix='sp-') + session_state = SessionStateController.get_or_new(profile.id) + + port_number = None + proxy_port_number = None + + if profile.connection.is_unprotected(): + + if not ConnectionController.system_uses_wireguard_interface(): + + if not force: + raise ConnectionUnprotectedError('Connection unprotected while the system is not using a WireGuard interface.') + else: + ProfileController.disable(profile) + + if profile.connection.code == 'tor': + + port_number = ConnectionController.get_random_available_port_number() + ConnectionController.establish_tor_session_connection(session_directory, port_number) + session_state.network_port_numbers.append(port_number) + + elif profile.connection.code == 'wireguard': + + port_number = ConnectionController.get_random_available_port_number() + ConnectionController.establish_wireguard_session_connection(profile, session_directory, port_number) + session_state.network_port_numbers.append(port_number) + + if profile.connection.masked: + + while proxy_port_number is None or proxy_port_number == port_number: + proxy_port_number = ConnectionController.get_random_available_port_number() + + ConnectionController.establish_proxy_session_connection(profile, session_directory, port_number, proxy_port_number) + session_state.network_port_numbers.append(proxy_port_number) + + if not profile.connection.is_unprotected(): + ConnectionController.await_connection(proxy_port_number or port_number, connection_observer=connection_observer) + + SessionStateController.update_or_create(session_state) + + return proxy_port_number or port_number + + @staticmethod + def establish_system_connection(profile: SystemProfile): + + process = subprocess.Popen(('pkexec', 'wg-quick', 'up', profile.get_wireguard_configuration_path()), stdout=subprocess.DEVNULL, stderr=subprocess.STDOUT) + completed_successfully = not bool(os.waitpid(process.pid, 0)[1] >> 8) + + if completed_successfully: + SystemStateController.update_or_create(SystemState(profile.id)) + + else: + + ProfileController.disable(profile) + + process_1 = subprocess.Popen(('pkexec', 'wg-quick', 'down', profile.get_wireguard_configuration_path()), stdout=subprocess.PIPE, stderr=subprocess.DEVNULL) + process_2 = subprocess.Popen(('pkexec', 'wg-quick', 'up', profile.get_wireguard_configuration_path()), stdin=process_1.stdout, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL) + + completed_successfully = not bool(os.waitpid(process_2.pid, 0)[1] >> 8) + + if completed_successfully: + SystemStateController.update_or_create(SystemState(profile.id)) + + else: + raise ProfileActivationError('The profile could not be enabled.') + + time.sleep(1.0) + + @staticmethod + def establish_tor_session_connection(session_directory: str, port_number: int): + + if subprocess.getstatusoutput('tor --help')[0] == 127: + raise OSError('Tor does not appear to be installed.') + + tor_session_directory = session_directory + '/tor' + Path(tor_session_directory).mkdir(exist_ok=True, mode=0o700) + + process = subprocess.Popen(('echo', f'DataDirectory {tor_session_directory}/tor\nSocksPort {port_number}'), stdout=subprocess.PIPE) + return subprocess.Popen(('tor', '-f', '-'), stdin=process.stdout, stdout=subprocess.DEVNULL, stderr=subprocess.STDOUT) + + @staticmethod + def establish_wireguard_session_connection(profile: SessionProfile, session_directory: str, port_number: int): + + if not profile.has_wireguard_configuration(): + raise FileNotFoundError('No valid WireGuard configuration file detected.') + + wireguard_session_directory = session_directory + '/wireguard' + Path(wireguard_session_directory).mkdir(exist_ok=True, mode=0o700) + + wireproxy_configuration_file_path = wireguard_session_directory + '/wireproxy.conf' + Path(wireproxy_configuration_file_path).touch(exist_ok=True, mode=0o600) + + with open(wireproxy_configuration_file_path, 'w') as output_file: + print(f'WGConfig = {profile.get_wireguard_configuration_path()}\n\n[Socks5]\nBindAddress = 127.0.0.1:{str(port_number)}\n', file=output_file) + + return subprocess.Popen((Constants.SP_DATA_HOME + '/wireproxy/wireproxy', '-c', wireproxy_configuration_file_path), stdout=subprocess.DEVNULL, stderr=subprocess.STDOUT) + + @staticmethod + def establish_proxy_session_connection(profile: SessionProfile, session_directory: str, port_number: int, proxy_port_number: int): + + if subprocess.getstatusoutput('proxychains4 --help')[0] == 127: + raise OSError('ProxyChains does not appear to be installed.') + + if subprocess.getstatusoutput('microsocks --help')[0] == 127: + raise OSError('MicroSocks does not appear to be installed.') + + if profile.has_proxy_configuration(): + proxy_configuration = profile.get_proxy_configuration() + else: + raise FileNotFoundError('No valid proxy configuration file detected.') + + proxy_session_directory = session_directory + '/proxy' + Path(proxy_session_directory).mkdir(parents=True, exist_ok=True, mode=0o700) + + proxychains_proxy_list = '' + + if port_number is not None: + proxychains_proxy_list = proxychains_proxy_list + f'socks5 127.0.0.1 {port_number}\n' + + proxychains_proxy_list = proxychains_proxy_list + f'socks5 {proxy_configuration.ip_address} {proxy_configuration.port_number} {proxy_configuration.username} {proxy_configuration.password}' + + with open(Constants.SP_DATA_HOME + '/proxychains.ptpl', 'r') as proxychains_template: + + proxychains_configuration_file_path = proxy_session_directory + '/proxychains.conf' + Path(proxychains_configuration_file_path).touch(exist_ok=True, mode=0o600) + + with open(proxychains_configuration_file_path, 'w') as output_file: + + output_file_contents = proxychains_template.read().format(proxy_list=proxychains_proxy_list) + output_file.write(output_file_contents) + + return subprocess.Popen(('proxychains4', '-f', proxychains_configuration_file_path, 'microsocks', '-p', str(proxy_port_number)), stdout=subprocess.DEVNULL, stderr=subprocess.STDOUT) + + @staticmethod + def get_proxies(port_number: int): + + return dict( + http=f'socks5h://127.0.0.1:{port_number}', + https=f'socks5h://127.0.0.1:{port_number}' + ) + + @staticmethod + def get_random_available_port_number(): + + socket_instance = socket.socket() + socket_instance.bind(('', 0)) + port_number = socket_instance.getsockname()[1] + socket_instance.close() + + return port_number + + @staticmethod + def await_connection(port_number: int, connection_observer: Optional[ConnectionObserver] = None): + + maximum_amount_of_retries = 10 + retry_interval = 5.0 + + for retry_count in range(maximum_amount_of_retries): + + if connection_observer is not None: + + connection_observer.notify('connecting', dict( + retry_interval=retry_interval, + maximum_amount_of_retries=maximum_amount_of_retries, + attempt_count=retry_count + 1 + )) + + try: + + ConnectionController._test_proxy_connection(port_number) + return + + except ConnectionError: + + time.sleep(retry_interval) + retry_count += 1 + + raise ConnectionError('The connection could not be established.') + + @staticmethod + def system_uses_wireguard_interface(): + + process = subprocess.Popen(('ip', 'route', 'get', '192.0.2.1'), stdout=subprocess.PIPE) + process_output = str(process.stdout.read()) + + return bool(re.search('dev wg', str(process_output))) + + @staticmethod + def _test_proxy_connection(port_number: int, timeout: float = 10.0): + + timeout = float(timeout) + + try: + requests.get(f'{Constants.SP_API_BASE_URL}/health', timeout=timeout, proxies=ConnectionController.get_proxies(port_number)) + + except requests.exceptions.RequestException: + raise ConnectionError('The connection could not be established.') diff --git a/core/controllers/InvoiceController.py b/core/controllers/InvoiceController.py new file mode 100644 index 0000000..d57883d --- /dev/null +++ b/core/controllers/InvoiceController.py @@ -0,0 +1,79 @@ +from core.Errors import InvoiceExpiredError, InvoicePaymentFailedError, InvoiceNotFoundError +from core.observers.ConnectionObserver import ConnectionObserver +from core.observers.InvoiceObserver import InvoiceObserver +from core.services.WebServiceApiService import WebServiceApiService +from typing import Optional +import time + + +class InvoiceController: + + @staticmethod + def get(billing_code: str, proxies: Optional[dict] = None): + return WebServiceApiService.get_invoice(billing_code, proxies) + + @staticmethod + def handle_payment(billing_code: str, invoice_observer: InvoiceObserver = None, connection_observer: ConnectionObserver = None): + + from core.controllers.ConnectionController import ConnectionController + return ConnectionController.with_preferred_connection(billing_code, task=InvoiceController._handle_payment, invoice_observer=invoice_observer, connection_observer=connection_observer) + + @staticmethod + def _handle_payment(billing_code: str, invoice_observer: Optional[InvoiceObserver] = None, proxies: Optional[dict] = None): + + invoice = None + + for index in range(1, 10): + + invoice = WebServiceApiService.get_invoice(billing_code, proxies) + + if invoice is None: + time.sleep(5.0) + else: + break + + if invoice is None: + raise InvoiceNotFoundError('The invoice in question could not be found.') + + invoice.status = 'new' + + if invoice_observer is not None: + invoice_observer.notify('retrieved', invoice) + + while invoice.status == 'new': + + invoice = WebServiceApiService.get_invoice(billing_code, proxies) + time.sleep(15.0) + + if invoice.status == 'expired': + raise InvoiceExpiredError('The invoice in question has expired.') + + if invoice.status == 'processing': + + if invoice_observer is not None: + invoice_observer.notify('processing', invoice) + + while invoice.status == 'processing': + + invoice = WebServiceApiService.get_invoice(billing_code, proxies) + time.sleep(15.0) + + if invoice.status != 'settled': + raise InvoicePaymentFailedError('The invoice payment has failed. Please contact support.') + + else: + + if invoice_observer is not None: + invoice_observer.notify('settled', invoice) + + for attempt in range(1, 10): + + subscription = WebServiceApiService.get_subscription(billing_code, proxies) + + if subscription is not None: + return subscription + + else: + time.sleep(15.0) + + return None diff --git a/core/controllers/LocationController.py b/core/controllers/LocationController.py new file mode 100644 index 0000000..4256021 --- /dev/null +++ b/core/controllers/LocationController.py @@ -0,0 +1,22 @@ +from core.models.Location import Location +from core.services.WebServiceApiService import WebServiceApiService +from typing import Optional + + +class LocationController: + + @staticmethod + def get(code: str): + return Location.find(code) + + @staticmethod + def get_all(): + return Location.all() + + @staticmethod + def fetch(proxies: Optional[dict] = None): + + locations = WebServiceApiService.get_locations(proxies) + + Location.truncate() + Location.save_many(locations) diff --git a/core/controllers/ProfileController.py b/core/controllers/ProfileController.py new file mode 100644 index 0000000..c64b6f2 --- /dev/null +++ b/core/controllers/ProfileController.py @@ -0,0 +1,222 @@ +from core.Errors import ProfileStateConflictError, InvalidSubscriptionError, MissingSubscriptionError, ConnectionUnprotectedError, ProfileDeactivationError +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 +from typing import Union, Optional +import os +import subprocess +import time + + +class ProfileController: + + @staticmethod + def get(id: int) -> Union[SessionProfile, SystemProfile, None]: + return Profile.find_by_id(id) + + @staticmethod + def get_all(): + return Profile.all() + + @staticmethod + def create(profile: Union[SessionProfile, SystemProfile], profile_observer: ProfileObserver = None): + + Profile.save(profile) + + if profile_observer is not None: + profile_observer.notify('created', profile) + + @staticmethod + def enable(profile: Union[SessionProfile, SystemProfile], force: bool = False, profile_observer: ProfileObserver = None, application_version_observer: ApplicationVersionObserver = None, connection_observer: ConnectionObserver = None): + + from core.controllers.ConnectionController import ConnectionController + + if ProfileController.is_enabled(profile): + + if not force: + raise ProfileStateConflictError('The profile is already enabled or its session was not properly terminated.') + else: + ProfileController.disable(profile) + + if profile.is_session_profile(): + + application_version = profile.application_version + + if not application_version.installed: + ApplicationVersionController.install(application_version, application_version_observer=application_version_observer, connection_observer=connection_observer) + + port_number = ConnectionController.establish_connection(profile, force=force, connection_observer=connection_observer) + + if profile_observer is not None: + profile_observer.notify('enabled', profile) + + ApplicationController.launch(application_version, profile, port_number, profile_observer=profile_observer) + + if profile.is_system_profile(): + + ConnectionController.establish_connection(profile, force=force, connection_observer=connection_observer) + + if profile_observer is not None: + profile_observer.notify('enabled', profile) + + @staticmethod + def disable(profile: Union[SessionProfile, SystemProfile], explicitly: bool = True, force: bool = False, profile_observer: ProfileObserver = None): + + from core.controllers.ConnectionController import ConnectionController + + if profile.is_session_profile(): + + if SessionStateController.exists(profile.id): + + session_state = SessionStateController.get(profile.id) + session_state.dissolve(session_state.id) + + if profile.is_system_profile(): + + profiles = ProfileController.get_all() + + for key, value in profiles.items(): + + if type(value).__name__ == 'SessionProfile': + + if value.connection.is_unprotected() and ProfileController.is_enabled(value) and not force: + raise ConnectionUnprotectedError('Disabling this system connection would leave one or more sessions exposed.') + + if SystemStateController.exists(): + + process = subprocess.Popen(('pkexec', 'wg-quick', 'down', profile.get_wireguard_configuration_path()), stdout=subprocess.DEVNULL, stderr=subprocess.STDOUT) + completed_successfully = not bool(os.waitpid(process.pid, 0)[1] >> 8) + + if completed_successfully or not ConnectionController.system_uses_wireguard_interface(): + + system_state = SystemStateController.get() + system_state.dissolve() + + else: + raise ProfileDeactivationError('The profile could not be disabled.') + + if profile_observer is not None: + + profile_observer.notify('disabled', profile, dict( + explicitly=explicitly, + )) + + time.sleep(1.0) + + @staticmethod + def destroy(profile: Union[SessionProfile, SystemProfile], profile_observer: ProfileObserver = None): + + ProfileController.disable(profile) + Profile.delete(profile) + + if profile_observer is not None: + profile_observer.notify('destroyed', profile) + + @staticmethod + def attach_subscription(profile: Union[SessionProfile, SystemProfile], subscription: Subscription): + + profile.subscription = subscription + profile.save(profile) + + @staticmethod + def activate_subscription(profile: Union[SessionProfile, SystemProfile], connection_observer: Optional[ConnectionObserver] = None): + + from core.controllers.ConnectionController import ConnectionController + + if profile.subscription is not None: + + subscription = ConnectionController.with_preferred_connection(profile.subscription.billing_code, task=WebServiceApiService.get_subscription, connection_observer=connection_observer) + + if subscription is not None: + + profile.subscription = subscription + profile.save(profile) + + else: + raise InvalidSubscriptionError() + + 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) > 0 or len(session_state.process_ids) > 0 + + if profile.is_system_profile(): + + if SystemStateController.get() is not None: + return ConnectionController.system_uses_wireguard_interface() + + else: + return False + + @staticmethod + def get_invoice(profile: Union[SessionProfile, SystemProfile]): + + if profile.subscription is None: + return None + + return WebServiceApiService.get_invoice(profile.subscription.billing_code) + + @staticmethod + def attach_proxy_configuration(profile: Union[SessionProfile, SystemProfile]): + + if type(profile).__name__ != 'SessionProfile': + return None + + if profile.subscription is None: + return None + + proxy_configuration = WebServiceApiService.get_proxy_configuration(profile.subscription.billing_code) + + if proxy_configuration is not None: + profile.attach_proxy_configuration(proxy_configuration) + + @staticmethod + def get_proxy_configuration(profile: Union[SessionProfile, SystemProfile]): + + if type(profile).__name__ != 'SessionProfile': + return None + + profile.get_proxy_configuration() + + @staticmethod + def has_proxy_configuration(profile: Union[SessionProfile, SystemProfile]): + profile.has_proxy_configuration() + + @staticmethod + def register_wireguard_session(profile: Union[SessionProfile, SystemProfile]): + + if profile.location is None: + return None + + if profile.subscription is None: + return None + + wireguard_configuration = WebServiceApiService.post_wireguard_session(profile.location.code, profile.subscription.billing_code) + + if wireguard_configuration is not None: + profile.attach_wireguard_configuration(wireguard_configuration) + + @staticmethod + def get_wireguard_configuration_path(profile: Union[SessionProfile, SystemProfile]): + return profile.get_wireguard_configuration_path() + + @staticmethod + def has_wireguard_configuration(profile: Union[SessionProfile, SystemProfile]): + return profile.has_wireguard_configuration() diff --git a/core/controllers/SessionStateController.py b/core/controllers/SessionStateController.py new file mode 100644 index 0000000..415ff76 --- /dev/null +++ b/core/controllers/SessionStateController.py @@ -0,0 +1,26 @@ +from core.models.session.SessionState import SessionState + + +class SessionStateController: + + @staticmethod + def get(id: int): + return SessionState.find_by_id(id) + + @staticmethod + def get_or_new(id: int): + + session_state = SessionStateController.get(id) + + if session_state is None: + return SessionState(id) + + return session_state + + @staticmethod + def exists(id: int): + return SessionState.exists(id) + + @staticmethod + def update_or_create(session_state): + SessionState.save(session_state) diff --git a/core/controllers/SubscriptionController.py b/core/controllers/SubscriptionController.py new file mode 100644 index 0000000..79b75bc --- /dev/null +++ b/core/controllers/SubscriptionController.py @@ -0,0 +1,21 @@ +from core.models.SubscriptionPlan import SubscriptionPlan +from core.models.session.SessionProfile import SessionProfile +from core.models.system.SystemProfile import SystemProfile +from core.observers.ConnectionObserver import ConnectionObserver +from core.services.WebServiceApiService import WebServiceApiService +from typing import Union + + +class SubscriptionController: + + @staticmethod + def get(billing_code: str, connection_observer: ConnectionObserver = None): + + from core.controllers.ConnectionController import ConnectionController + return ConnectionController.with_preferred_connection(billing_code, task=WebServiceApiService.get_subscription, connection_observer=connection_observer) + + @staticmethod + def create(subscription_plan: SubscriptionPlan, profile: Union[SessionProfile, SystemProfile], connection_observer: ConnectionObserver = None): + + from core.controllers.ConnectionController import ConnectionController + return ConnectionController.with_preferred_connection(subscription_plan.id, profile.location.id, task=WebServiceApiService.post_subscription, connection_observer=connection_observer) diff --git a/core/controllers/SubscriptionPlanController.py b/core/controllers/SubscriptionPlanController.py new file mode 100644 index 0000000..50b3b57 --- /dev/null +++ b/core/controllers/SubscriptionPlanController.py @@ -0,0 +1,24 @@ +from core.models.SubscriptionPlan import SubscriptionPlan +from core.models.session.SessionConnection import SessionConnection +from core.models.system.SystemConnection import SystemConnection +from core.services.WebServiceApiService import WebServiceApiService +from typing import Union, Optional + + +class SubscriptionPlanController: + + @staticmethod + def get(connection: Union[SessionConnection, SystemConnection], duration: int): + return SubscriptionPlan.find(connection, duration) + + @staticmethod + def get_all(connection: Optional[Union[SessionConnection, SystemConnection]] = None): + return SubscriptionPlan.all(connection) + + @staticmethod + def fetch(proxies: Optional[dict] = None): + + subscription_plans = WebServiceApiService.get_subscription_plans(proxies) + + SubscriptionPlan.truncate() + SubscriptionPlan.save_many(subscription_plans) diff --git a/core/controllers/SystemStateController.py b/core/controllers/SystemStateController.py new file mode 100644 index 0000000..bab3104 --- /dev/null +++ b/core/controllers/SystemStateController.py @@ -0,0 +1,26 @@ +from core.models.system.SystemState import SystemState + + +class SystemStateController: + + @staticmethod + def get(): + return SystemState.get() + + @staticmethod + def get_or_new(profile_id: int): + + system_state = SystemStateController.get() + + if system_state is None: + return SystemState(profile_id) + + return system_state + + @staticmethod + def exists(): + return SystemState.exists() + + @staticmethod + def update_or_create(system_state): + SystemState.save(system_state) diff --git a/core/models/BaseConnection.py b/core/models/BaseConnection.py new file mode 100644 index 0000000..b99e4e2 --- /dev/null +++ b/core/models/BaseConnection.py @@ -0,0 +1,11 @@ +from dataclasses import dataclass +from dataclasses_json import dataclass_json + + +@dataclass_json +@dataclass +class BaseConnection: + code: str + + def needs_wireguard_configuration(self): + return self.code == 'wireguard' diff --git a/core/models/BaseProfile.py b/core/models/BaseProfile.py new file mode 100644 index 0000000..07b6690 --- /dev/null +++ b/core/models/BaseProfile.py @@ -0,0 +1,149 @@ +from core.Constants import Constants +from core.models.Location import Location +from core.models.Subscription import Subscription +from core.models.session.ApplicationVersion import ApplicationVersion +from dataclasses import dataclass, field +from dataclasses_json import config, Exclude, dataclass_json +from json import JSONDecodeError +from typing import Optional +import json +import os +import re +import shutil + + +@dataclass_json +@dataclass +class BaseProfile: + id: int = field( + metadata=config(exclude=Exclude.ALWAYS) + ) + name: str + subscription: Optional[Subscription] + location: Optional[Location] + + 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 attach_wireguard_configuration(self, wireguard_configuration): + + with open(self.get_wireguard_configuration_path(), 'w') as output_file: + output_file.write(wireguard_configuration) + + def get_wireguard_configuration_path(self): + return self.get_config_path() + '/wg.conf' + + def has_wireguard_configuration(self): + return os.path.exists(self.get_config_path() + '/wg.conf') + + def is_session_profile(self): + return type(self).__name__ == 'SessionProfile' + + def is_system_profile(self): + return type(self).__name__ == 'SystemProfile' + + @staticmethod + def find_by_id(id: int): + + try: + config_file_contents = open(BaseProfile._get_config_path(id) + '/config.json', 'r').read() + except FileNotFoundError: + return None + + try: + profile = json.loads(config_file_contents) + except JSONDecodeError: + return None + + profile['id'] = id + + if profile['location'] is not None: + + location = Location.find(profile['location']['code'] or None) + + if location is not None: + + if profile['location']['time_zone'] is not None: + location.time_zone = profile['location']['time_zone'] + + profile['location'] = location + + if 'application_version' in profile: + + 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) + + else: + + from core.models.system.SystemProfile import SystemProfile + # noinspection PyUnresolvedReferences + profile = SystemProfile.from_dict(profile) + + return profile + + @staticmethod + def exists(id: int): + return os.path.isdir(BaseProfile._get_config_path(id)) and re.match(r'^\d+$', str(id)) + + @staticmethod + def all(): + + profiles = {} + + for id in map(int, sorted(os.listdir(Constants.SP_PROFILE_CONFIG_HOME))): + + if BaseProfile.exists(id): + + if os.path.exists(BaseProfile._get_config_path(id) + '/config.json'): + + profile = BaseProfile.find_by_id(id) + + if profile is not None: + profiles[id] = profile + + return profiles + + @staticmethod + def save(profile): + + if profile.is_session_profile() and profile.application_version is not None: + + persistent_state_path = f'{BaseProfile._get_data_path(profile.id)}/persistent-state' + + if os.path.isdir(persistent_state_path): + shutil.rmtree(persistent_state_path) + + config_file_contents = profile.to_json(indent=4) + '\n' + + os.makedirs(BaseProfile._get_config_path(profile.id), exist_ok=True) + os.makedirs(BaseProfile._get_data_path(profile.id), exist_ok=True) + + text_io_wrapper = open(BaseProfile._get_config_path(profile.id) + '/config.json', 'w') + text_io_wrapper.write(config_file_contents) + + @staticmethod + def delete(profile): + + shutil.rmtree(BaseProfile._get_config_path(profile.id)) + shutil.rmtree(BaseProfile._get_data_path(profile.id)) + + @staticmethod + def _get_config_path(id: int): + return Constants.SP_PROFILE_CONFIG_HOME + '/' + str(id) + + @staticmethod + def _get_data_path(id: int): + return Constants.SP_PROFILE_DATA_HOME + '/' + str(id) diff --git a/core/models/ClientVersion.py b/core/models/ClientVersion.py new file mode 100644 index 0000000..652a11b --- /dev/null +++ b/core/models/ClientVersion.py @@ -0,0 +1,80 @@ +from core.models.Model import Model +from dataclasses import dataclass, field +from dataclasses_json import config, dataclass_json, Exclude +from datetime import datetime +from dateutil.parser import isoparse +from marshmallow import fields +from typing import Optional + +_table_name: str = 'client_versions' + +_table_definition: str = """ + 'id' int UNIQUE, + 'version_number' varchar UNIQUE, + 'released_at' varchar, + 'download_path' varchar UNIQUE +""" + + +@dataclass_json +@dataclass +class ClientVersion(Model): + version_number: str + released_at: datetime = field( + metadata=config( + encoder=datetime.isoformat, + decoder=datetime.fromisoformat, + mm_field=fields.DateTime(format='iso') + ) + ) + id: Optional[int] = field( + default=None, + metadata=config(exclude=Exclude.ALWAYS) + ) + download_path: Optional[str] = field( + default=None, + metadata=config(exclude=Exclude.ALWAYS) + ) + + @staticmethod + def find_by_id(id: int): + Model._create_table_if_not_exists(table_name=_table_name, table_definition=_table_definition) + return Model._query_one('SELECT * FROM client_versions WHERE id = ? LIMIT 1', ClientVersion.factory, [id]) + + @staticmethod + def find(version_number: str): + Model._create_table_if_not_exists(table_name=_table_name, table_definition=_table_definition) + return Model._query_one('SELECT * FROM client_versions WHERE version_number = ? LIMIT 1', ClientVersion.factory, [version_number]) + + @staticmethod + def latest(): + Model._create_table_if_not_exists(table_name=_table_name, table_definition=_table_definition) + return Model._query_one('SELECT * FROM client_versions ORDER BY CAST(released_at as datetime) DESC LIMIT 1', ClientVersion.factory) + + @staticmethod + def all(): + Model._create_table_if_not_exists(table_name=_table_name, table_definition=_table_definition) + return Model._query_all('SELECT * FROM client_versions', ClientVersion.factory) + + @staticmethod + def truncate(): + Model._create_table_if_not_exists(table_name=_table_name, table_definition=_table_definition, drop_existing=True) + + @staticmethod + def save_many(client_versions): + Model._create_table_if_not_exists(table_name=_table_name, table_definition=_table_definition) + Model._insert_many('INSERT INTO client_versions VALUES(?, ?, ?, ?)', ClientVersion.tuple_factory, client_versions) + + @staticmethod + def factory(cursor, row): + + database_fields = [column[0] for column in cursor.description] + + client_version = ClientVersion(**{key: value for key, value in zip(database_fields, row)}) + client_version.released_at = isoparse(str(client_version.released_at)) + + return client_version + + @staticmethod + def tuple_factory(client_version): + return client_version.id, client_version.version_number, client_version.released_at, client_version.download_path diff --git a/core/models/Configuration.py b/core/models/Configuration.py new file mode 100644 index 0000000..cd981cb --- /dev/null +++ b/core/models/Configuration.py @@ -0,0 +1,77 @@ +from core.Constants import Constants +from dataclasses import dataclass, field +from dataclasses_json import dataclass_json, config +from datetime import datetime +from json import JSONDecodeError +from marshmallow import fields +from typing import Optional +from zoneinfo import ZoneInfo +import dataclasses_json +import json +import os + + +@dataclass_json +@dataclass +class Configuration: + connection: Optional[str] = field( + default=None, + metadata=config( + undefined=dataclasses_json.Undefined.EXCLUDE, + exclude=lambda value: value is None + ) + ) + auto_sync_enabled: Optional[bool] = field( + default=None, + metadata=config( + undefined=dataclasses_json.Undefined.EXCLUDE, + exclude=lambda value: value is None + ) + ) + last_synced_at: Optional[datetime] = field( + default=None, + metadata=config( + encoder=lambda datetime_instance: Configuration._iso_format(datetime_instance), + decoder=lambda datetime_string: Configuration._from_iso_format(datetime_string), + mm_field=fields.DateTime(format='iso'), + undefined=dataclasses_json.Undefined.EXCLUDE, + exclude=lambda value: value is None + ) + ) + + @staticmethod + def get(): + + try: + config_file_contents = open(Constants.SP_CONFIG_HOME + '/config.json', 'r').read() + except FileNotFoundError: + return None + + try: + configuration = json.loads(config_file_contents) + except JSONDecodeError: + exit(1) + + # noinspection PyUnresolvedReferences + configuration = Configuration.from_dict(configuration) + + return configuration + + @staticmethod + def save(configuration): + + config_file_contents = configuration.to_json(indent=4) + '\n' + os.makedirs(Constants.SP_CONFIG_HOME, exist_ok=True) + + text_io_wrapper = open(Constants.SP_CONFIG_HOME + '/config.json', 'w') + text_io_wrapper.write(config_file_contents) + + @staticmethod + def _iso_format(datetime_instance: datetime): + datetime_instance = datetime_instance.replace(tzinfo=ZoneInfo('UTC')) + return datetime.isoformat(datetime_instance).replace('+00:00', 'Z') + + @staticmethod + def _from_iso_format(datetime_string: str): + date_string = datetime_string.replace('Z', '+00:00') + return datetime.fromisoformat(date_string) diff --git a/core/models/Event.py b/core/models/Event.py new file mode 100644 index 0000000..5e254cf --- /dev/null +++ b/core/models/Event.py @@ -0,0 +1,6 @@ +class Event: + + def __init__(self, subject = None, meta = None): + + self.subject = subject + self.meta = meta or {} diff --git a/core/models/Location.py b/core/models/Location.py new file mode 100644 index 0000000..7627e2a --- /dev/null +++ b/core/models/Location.py @@ -0,0 +1,76 @@ +from core.models.Model import Model +from dataclasses import dataclass, field +from dataclasses_json import config, Exclude +from pytz import country_timezones +from typing import Optional + +_table_name: str = 'locations' + +_table_definition: str = """ + 'id' int UNIQUE, + 'code' varchar UNIQUE, + 'name' varchar UNIQUE +""" + + +@dataclass +class Location(Model): + code: str + id: Optional[int] = field( + default=None, + metadata=config(exclude=Exclude.ALWAYS) + ) + name: Optional[str] = field( + default=None, + metadata=config(exclude=Exclude.ALWAYS) + ) + time_zone: str = None + available: Optional[bool] = field( + default=False, + metadata=config(exclude=Exclude.ALWAYS) + ) + + def __post_init__(self): + + if self.time_zone is None: + self.time_zone = country_timezones[self.code][0] + + self.available = self.exists(self.code) + + @staticmethod + def find_by_id(id: int): + Model._create_table_if_not_exists(table_name=_table_name, table_definition=_table_definition) + return Model._query_one('SELECT * FROM locations WHERE id = ? LIMIT 1', Location.factory, [id]) + + @staticmethod + def find(code: str): + Model._create_table_if_not_exists(table_name=_table_name, table_definition=_table_definition) + return Model._query_one('SELECT * FROM locations WHERE code = ? LIMIT 1', Location.factory, [code]) + + @staticmethod + def exists(code: str): + Model._create_table_if_not_exists(table_name=_table_name, table_definition=_table_definition) + return Model._query_exists('SELECT * FROM locations WHERE code = ?', [code]) + + @staticmethod + def all(): + Model._create_table_if_not_exists(table_name=_table_name, table_definition=_table_definition) + return Model._query_all('SELECT * FROM locations', Location.factory) + + @staticmethod + def truncate(): + Model._create_table_if_not_exists(table_name=_table_name, table_definition=_table_definition, drop_existing=True) + + @staticmethod + def save_many(locations): + Model._create_table_if_not_exists(table_name=_table_name, table_definition=_table_definition) + Model._insert_many('INSERT INTO locations VALUES(?, ?, ?)', Location.tuple_factory, locations) + + @staticmethod + def factory(cursor, row): + local_fields = [column[0] for column in cursor.description] + return Location(**{key: value for key, value in zip(local_fields, row)}) + + @staticmethod + def tuple_factory(location): + return location.id, location.code, location.name diff --git a/core/models/Model.py b/core/models/Model.py new file mode 100644 index 0000000..69f6af5 --- /dev/null +++ b/core/models/Model.py @@ -0,0 +1,73 @@ +from core.Constants import Constants +import sqlite3 + + +class Model: + + @staticmethod + def _create_table_if_not_exists(table_name: str, table_definition: str, drop_existing: bool = False): + + connection = sqlite3.connect(Constants.SP_STORAGE_DATABASE) + cursor = connection.cursor() + + if drop_existing: + cursor.execute('DROP TABLE IF EXISTS ' + table_name) + + cursor.execute('CREATE TABLE IF NOT EXISTS ' + table_name + '(' + table_definition + ')') + + connection.commit() + connection.close() + + @staticmethod + def _query_one(query: str, row_factory, parameters=None): + + if parameters is None: + parameters = [] + + connection = sqlite3.connect(Constants.SP_STORAGE_DATABASE) + cursor = connection.cursor() + + cursor.row_factory = row_factory + + results = cursor.execute(query, parameters).fetchone() + connection.close() + + return results + + @staticmethod + def _query_exists(query: str, parameters): + + connection = sqlite3.connect(Constants.SP_STORAGE_DATABASE) + cursor = connection.cursor() + + response = cursor.execute('SELECT EXISTS(' + query + ')', parameters).fetchone() + connection.close() + + return response[0] == 1 + + @staticmethod + def _query_all(query: str, row_factory, parameters=None): + + if parameters is None: + parameters = [] + + connection = sqlite3.connect(Constants.SP_STORAGE_DATABASE) + cursor = connection.cursor() + + cursor.row_factory = row_factory + + results = cursor.execute(query, parameters).fetchall() + connection.close() + + return results + + @staticmethod + def _insert_many(query: str, tuple_factory, items): + + connection = sqlite3.connect(Constants.SP_STORAGE_DATABASE) + cursor = connection.cursor() + + cursor.executemany(query, map(tuple_factory, items)) + + connection.commit() + connection.close() diff --git a/core/models/Subscription.py b/core/models/Subscription.py new file mode 100644 index 0000000..983b84a --- /dev/null +++ b/core/models/Subscription.py @@ -0,0 +1,31 @@ +from dataclasses import dataclass, field +from dataclasses_json import config, dataclass_json +from datetime import datetime +from marshmallow import fields +from typing import Optional +import dataclasses_json + + +@dataclass_json +@dataclass +class Subscription: + billing_code: str + expires_at: Optional[datetime] = field( + default=None, + metadata=config( + encoder=lambda datetime_instance: Subscription._iso_format(datetime_instance), + decoder=lambda datetime_string: Subscription.from_iso_format(datetime_string), + mm_field=fields.DateTime(format='iso'), + undefined=dataclasses_json.Undefined.EXCLUDE, + exclude=lambda value: value is None + ) + ) + + @staticmethod + def _iso_format(datetime_instance: datetime): + return datetime.isoformat(datetime_instance).replace('+00:00', 'Z') + + @staticmethod + def from_iso_format(datetime_string: str): + date_string = datetime_string.replace('Z', '+00:00') + return datetime.fromisoformat(date_string) diff --git a/core/models/SubscriptionPlan.py b/core/models/SubscriptionPlan.py new file mode 100644 index 0000000..1281770 --- /dev/null +++ b/core/models/SubscriptionPlan.py @@ -0,0 +1,89 @@ +from core.models.Model import Model +from core.models.session.SessionConnection import SessionConnection +from core.models.system.SystemConnection import SystemConnection +from dataclasses import dataclass +from typing import Union, Optional + +_table_name: str = 'subscription_plans' + +_table_definition: str = """ + 'id' int UNIQUE, + 'code' varchar UNIQUE, + 'wireguard_session_limit' int, + 'duration' int, + 'price' int, + 'features_proxy' bool, + 'features_wireguard' bool +""" + + +@dataclass +class SubscriptionPlan(Model): + id: int + code: str + wireguard_session_limit: int + duration: int + price: int + features_proxy: bool + features_wireguard: bool + + @staticmethod + def find_by_id(id: int): + Model._create_table_if_not_exists(table_name=_table_name, table_definition=_table_definition) + return Model._query_one('SELECT * FROM subscription_plans WHERE id = ? LIMIT 1', SubscriptionPlan.factory, [id]) + + @staticmethod + def find(connection: Union[SessionConnection, SystemConnection], duration: int): + + features_proxy = False + features_wireguard = False + + if type(connection).__name__ == 'SessionConnection': + if connection.masked: + features_proxy = True + + if connection.code == 'wireguard': + features_wireguard = True + + Model._create_table_if_not_exists(table_name=_table_name, table_definition=_table_definition) + return Model._query_one('SELECT * FROM subscription_plans WHERE features_proxy = ? AND features_wireguard = ? AND duration = ? LIMIT 1', SubscriptionPlan.factory, [features_proxy, features_wireguard, duration]) + + @staticmethod + def all(connection: Optional[Union[SessionConnection, SystemConnection]] = None): + + Model._create_table_if_not_exists(table_name=_table_name, table_definition=_table_definition) + + if connection is None: + return Model._query_all('SELECT * FROM subscription_plans', SubscriptionPlan.factory) + + else: + + features_proxy = False + features_wireguard = False + + if type(connection).__name__ == 'SessionConnection': + if connection.masked: + features_proxy = True + + if connection.code == 'wireguard': + features_wireguard = True + + return Model._query_all('SELECT * FROM subscription_plans WHERE features_proxy = ? AND features_wireguard = ?', SubscriptionPlan.factory, [features_proxy, features_wireguard]) + + @staticmethod + def truncate(): + Model._create_table_if_not_exists(table_name=_table_name, table_definition=_table_definition, drop_existing=True) + + @staticmethod + def save_many(subscription_plans): + Model._create_table_if_not_exists(table_name=_table_name, table_definition=_table_definition) + Model._insert_many('INSERT INTO subscription_plans VALUES(?, ?, ?, ?, ?, ?, ?)', SubscriptionPlan.tuple_factory, subscription_plans) + + @staticmethod + def factory(cursor, row): + local_fields = [column[0] for column in cursor.description] + return SubscriptionPlan(**{key: value for key, value in zip(local_fields, row)}) + + @staticmethod + def tuple_factory(subscription_plan): + return subscription_plan.id, subscription_plan.code, subscription_plan.wireguard_session_limit, subscription_plan.duration, subscription_plan.price, subscription_plan.features_proxy, subscription_plan.features_wireguard diff --git a/core/models/invoice/Invoice.py b/core/models/invoice/Invoice.py new file mode 100644 index 0000000..111a729 --- /dev/null +++ b/core/models/invoice/Invoice.py @@ -0,0 +1,23 @@ +from core.models.invoice import PaymentMethod +from dataclasses import dataclass +from datetime import datetime + + +@dataclass +class Invoice: + billing_code: str + status: str + expires_at: datetime + payment_methods: tuple[PaymentMethod] + + def is_new(self): + return self.status == 'new' + + def is_rejected(self): + return self.status == 'invalid' or self.status == 'expired' + + def is_processing(self): + return self.status == 'processing' + + def is_settled(self): + return self.status == 'settled' diff --git a/core/models/invoice/PaymentMethod.py b/core/models/invoice/PaymentMethod.py new file mode 100644 index 0000000..ebf6b5c --- /dev/null +++ b/core/models/invoice/PaymentMethod.py @@ -0,0 +1,12 @@ +from dataclasses import dataclass + + +@dataclass +class PaymentMethod: + code: str + name: str + address: str + payment_link: str + rate: float + amount: float + due: float diff --git a/core/models/session/Application.py b/core/models/session/Application.py new file mode 100644 index 0000000..35755bd --- /dev/null +++ b/core/models/session/Application.py @@ -0,0 +1,53 @@ +from core.models.Model import Model +from dataclasses import dataclass, field +from dataclasses_json import config, Exclude +from typing import Optional + +_table_name: str = 'applications' + +_table_definition: str = """ + 'id' int UNIQUE, + 'code' varchar UNIQUE, + 'name' varchar UNIQUE +""" + + +@dataclass +class Application(Model): + code: str + name: Optional[str] = field( + default=None, + metadata=config(exclude=Exclude.ALWAYS) + ) + id: Optional[int] = field( + default=None, + metadata=config(exclude=Exclude.ALWAYS) + ) + + @staticmethod + def find_by_id(id: int): + Model._create_table_if_not_exists(table_name=_table_name, table_definition=_table_definition) + return Model._query_one('SELECT * FROM applications WHERE id = ? LIMIT 1', Application.factory, [id]) + + @staticmethod + def find(code: str): + Model._create_table_if_not_exists(table_name=_table_name, table_definition=_table_definition) + return Model._query_one('SELECT * FROM applications WHERE code = ? LIMIT 1', Application.factory, [code]) + + @staticmethod + def truncate(): + Model._create_table_if_not_exists(table_name=_table_name, table_definition=_table_definition, drop_existing=True) + + @staticmethod + def save_many(applications): + Model._create_table_if_not_exists(table_name=_table_name, table_definition=_table_definition) + Model._insert_many('INSERT INTO applications VALUES(?, ?, ?)', Application.tuple_factory, applications) + + @staticmethod + def factory(cursor, row): + database_fields = [column[0] for column in cursor.description] + return Application(**{key: value for key, value in zip(database_fields, row)}) + + @staticmethod + def tuple_factory(application): + return application.id, application.code, application.name diff --git a/core/models/session/ApplicationVersion.py b/core/models/session/ApplicationVersion.py new file mode 100644 index 0000000..bc1be65 --- /dev/null +++ b/core/models/session/ApplicationVersion.py @@ -0,0 +1,98 @@ +from core.Constants import Constants +from core.models.Model import Model +from dataclasses import dataclass, field +from dataclasses_json import config, Exclude +from datetime import datetime +from dateutil.parser import isoparse +from marshmallow import fields +from typing import Optional +import os + +_table_name: str = 'application_versions' + +_table_definition: str = """ + 'id' int UNIQUE, + 'application_code' varchar, + 'version_number' varchar, + 'download_path' varchar UNIQUE, + 'released_at' varchar, + UNIQUE(application_code, version_number) +""" + + +@dataclass +class ApplicationVersion(Model): + application_code: str + version_number: str + id: Optional[int] = field( + default=None, + metadata=config(exclude=Exclude.ALWAYS) + ) + download_path: Optional[str] = field( + default=None, + metadata=config(exclude=Exclude.ALWAYS) + ) + released_at: Optional[datetime] = field( + default=None, + metadata=config( + encoder=datetime.isoformat, + decoder=datetime.fromisoformat, + mm_field=fields.DateTime(format='iso'), + exclude=Exclude.ALWAYS + ) + ) + installation_path: Optional[str] = field( + default=None, + metadata=config(exclude=Exclude.ALWAYS) + ) + installed: Optional[bool] = field( + default=False, + metadata=config(exclude=Exclude.ALWAYS) + ) + supported: Optional[bool] = field( + default=False, + metadata=config(exclude=Exclude.ALWAYS) + ) + + def __post_init__(self): + self.installation_path = Constants.SP_APPLICATION_DATA_HOME + '/' + self.application_code + '/' + self.version_number + self.installed = os.path.isdir(self.installation_path) and len(os.listdir(self.installation_path)) > 0 + self.supported = self.exists(self.application_code, self.version_number) + + @staticmethod + def find_by_id(id: int): + Model._create_table_if_not_exists(table_name=_table_name, table_definition=_table_definition) + return Model._query_one('SELECT * FROM application_versions WHERE id = ? LIMIT 1', ApplicationVersion.factory, [id]) + + @staticmethod + def find(application_code: str, version_number: str): + Model._create_table_if_not_exists(table_name=_table_name, table_definition=_table_definition) + return Model._query_one('SELECT * FROM application_versions WHERE application_code = ? AND version_number = ? LIMIT 1', ApplicationVersion.factory, [application_code, version_number]) + + @staticmethod + def exists(application_code: str, version_number: str): + Model._create_table_if_not_exists(table_name=_table_name, table_definition=_table_definition) + return Model._query_exists('SELECT * FROM application_versions WHERE application_code = ? AND version_number = ?', [application_code, version_number]) + + @staticmethod + def truncate(): + Model._create_table_if_not_exists(table_name=_table_name, table_definition=_table_definition, drop_existing=True) + + @staticmethod + def save_many(application_versions): + Model._create_table_if_not_exists(table_name=_table_name, table_definition=_table_definition) + Model._insert_many('INSERT INTO application_versions VALUES(?, ?, ?, ?, ?)', ApplicationVersion.tuple_factory, application_versions) + + @staticmethod + def factory(cursor, row): + + database_fields = [column[0] for column in cursor.description] + + application_version = ApplicationVersion(**{key: value for key, value in zip(database_fields, row)}) + application_version.released_at = isoparse(str(application_version.released_at)) + + return application_version + + @staticmethod + def tuple_factory(application_version): + return application_version.id, application_version.application_code, application_version.version_number, application_version.download_path, application_version.released_at diff --git a/core/models/session/ProxyConfiguration.py b/core/models/session/ProxyConfiguration.py new file mode 100644 index 0000000..745617b --- /dev/null +++ b/core/models/session/ProxyConfiguration.py @@ -0,0 +1,11 @@ +from dataclasses import dataclass +from dataclasses_json import dataclass_json + + +@dataclass_json +@dataclass +class ProxyConfiguration: + ip_address: str + port_number: int + username: str + password: str diff --git a/core/models/session/SessionConnection.py b/core/models/session/SessionConnection.py new file mode 100644 index 0000000..026a9d2 --- /dev/null +++ b/core/models/session/SessionConnection.py @@ -0,0 +1,18 @@ +from core.models.BaseConnection import BaseConnection +from dataclasses import dataclass + + +@dataclass +class SessionConnection(BaseConnection): + masked: bool = False + + def __post_init__(self): + + if self.code not in ('system', 'tor', 'wireguard'): + raise ValueError('Invalid connection code.') + + def is_unprotected(self): + return self.code == 'system' and self.masked is False + + def needs_proxy_configuration(self): + return self.masked is True diff --git a/core/models/session/SessionProfile.py b/core/models/session/SessionProfile.py new file mode 100644 index 0000000..779c647 --- /dev/null +++ b/core/models/session/SessionProfile.py @@ -0,0 +1,47 @@ +from core.Constants import Constants +from core.models.BaseProfile import BaseProfile +from core.models.session.ApplicationVersion import ApplicationVersion +from core.models.session.ProxyConfiguration import ProxyConfiguration +from core.models.session.SessionConnection import SessionConnection +from dataclasses import dataclass +from json import JSONDecodeError +from typing import Optional +import json +import os + + +@dataclass +class SessionProfile(BaseProfile): + resolution: str + application_version: Optional[ApplicationVersion] + connection: Optional[SessionConnection] + + def attach_proxy_configuration(self, proxy_configuration): + + config_file_contents = proxy_configuration.to_json(indent=4) + '\n' + os.makedirs(Constants.SP_CONFIG_HOME, exist_ok=True) + + text_io_wrapper = open(self.get_proxy_configuration_path(), 'w') + text_io_wrapper.write(config_file_contents) + + def get_proxy_configuration_path(self): + return self.get_config_path() + '/proxy.json' + + def get_proxy_configuration(self): + + try: + config_file_contents = open(self.get_proxy_configuration_path(), 'r').read() + except FileNotFoundError: + return None + + try: + proxy_configuration = json.loads(config_file_contents) + except JSONDecodeError: + return None + + proxy_configuration = ProxyConfiguration.from_dict(proxy_configuration) + + return proxy_configuration + + def has_proxy_configuration(self): + return os.path.exists(self.get_config_path() + '/proxy.json') diff --git a/core/models/session/SessionState.py b/core/models/session/SessionState.py new file mode 100644 index 0000000..adbe188 --- /dev/null +++ b/core/models/session/SessionState.py @@ -0,0 +1,102 @@ +from core.Constants import Constants +from dataclasses import dataclass, field +from dataclasses_json import config, Exclude, dataclass_json +from json import JSONDecodeError +from pathlib import Path +import json +import os +import psutil +import re +import shutil + + +@dataclass_json +@dataclass +class SessionState: + id: int = field( + metadata=config(exclude=Exclude.ALWAYS) + ) + network_port_numbers: list[int] = field(default_factory=list) + process_ids: list[int] = field(default_factory=list) + + def get_state_path(self): + return SessionState._get_state_path(self.id) + + @staticmethod + def find_by_id(id: int): + + try: + session_state_file_contents = open(SessionState._get_state_path(id) + '/state.json', 'r').read() + except FileNotFoundError: + return None + + try: + session_state = json.loads(session_state_file_contents) + except JSONDecodeError: + return None + + session_state['id'] = id + + # noinspection PyUnresolvedReferences + return SessionState.from_dict(session_state) + + @staticmethod + def exists(id: int): + return os.path.isdir(SessionState._get_state_path(id)) and re.match(r'^\d+$', str(id)) + + @staticmethod + def save(session_state): + + session_state_file_contents = session_state.to_json(indent=4) + '\n' + os.makedirs(SessionState._get_state_path(session_state.id), exist_ok=True, mode=0o700) + + session_state_file_path = SessionState._get_state_path(session_state.id) + '/state.json' + Path(session_state_file_path).touch(exist_ok=True, mode=0o600) + + text_io_wrapper = open(session_state_file_path, 'w') + text_io_wrapper.write(session_state_file_contents) + + @staticmethod + def dissolve(id: int): + + session_state = SessionState.find_by_id(id) + + if session_state is not None: + + session_state_path = session_state.get_state_path() + + SessionState._kill_associated_processes(session_state) + shutil.rmtree(session_state_path, ignore_errors=True) + + @staticmethod + def _kill_associated_processes(session_state): + + associated_process_ids = list(session_state.process_ids) + network_connections = psutil.net_connections() + + for network_port_number in session_state.network_port_numbers: + + for network_connection in network_connections: + + if network_connection.laddr != tuple() and network_connection.laddr.port == network_port_number: + + if network_connection.pid is not None: + associated_process_ids.append(network_connection.pid) + + if network_connection.raddr != tuple() and network_connection.raddr.port == network_port_number: + + if network_connection.pid is not None: + associated_process_ids.append(network_connection.pid) + + for process in psutil.process_iter(): + + if process.pid in associated_process_ids and process.is_running(): + + for child_process in process.children(True): + child_process.kill() + + process.kill() + + @staticmethod + def _get_state_path(id: int): + return Constants.SP_SESSION_STATE_HOME + '/' + str(id) diff --git a/core/models/system/SystemConnection.py b/core/models/system/SystemConnection.py new file mode 100644 index 0000000..8122b76 --- /dev/null +++ b/core/models/system/SystemConnection.py @@ -0,0 +1,15 @@ +from core.models.BaseConnection import BaseConnection +from dataclasses import dataclass + + +@dataclass +class SystemConnection (BaseConnection): + + def __post_init__(self): + + if self.code != 'wireguard': + raise ValueError('Invalid connection code.') + + @staticmethod + def needs_proxy_configuration(): + return False diff --git a/core/models/system/SystemProfile.py b/core/models/system/SystemProfile.py new file mode 100644 index 0000000..7d9e2e3 --- /dev/null +++ b/core/models/system/SystemProfile.py @@ -0,0 +1,9 @@ +from core.models.BaseProfile import BaseProfile +from core.models.system.SystemConnection import SystemConnection +from dataclasses import dataclass +from typing import Optional + + +@dataclass +class SystemProfile(BaseProfile): + connection: Optional[SystemConnection] diff --git a/core/models/system/SystemState.py b/core/models/system/SystemState.py new file mode 100644 index 0000000..e3218c6 --- /dev/null +++ b/core/models/system/SystemState.py @@ -0,0 +1,59 @@ +from core.Constants import Constants +from dataclasses import dataclass +from dataclasses_json import dataclass_json +from json import JSONDecodeError +from pathlib import Path +import json +import os +import pathlib + +@dataclass_json +@dataclass +class SystemState: + profile_id: int + + @staticmethod + def get(): + + try: + system_state_file_contents = open(SystemState._get_state_path() + '/system.json', 'r').read() + except FileNotFoundError: + return None + + try: + system_state = json.loads(system_state_file_contents) + except JSONDecodeError: + return None + + # noinspection PyUnresolvedReferences + return SystemState.from_dict(system_state) + + @staticmethod + def exists(): + return os.path.isfile(SystemState._get_state_path() + '/system.json') + + @staticmethod + def save(system_state): + + system_state_file_contents = system_state.to_json(indent=4) + '\n' + os.makedirs(SystemState._get_state_path(), exist_ok=True, mode=0o700) + + system_state_file_path = SystemState._get_state_path() + '/system.json' + Path(system_state_file_path).touch(exist_ok=True, mode=0o600) + + text_io_wrapper = open(system_state_file_path, 'w') + text_io_wrapper.write(system_state_file_contents) + + @staticmethod + def dissolve(): + + system_state = SystemState.get() + + if system_state is not None: + + system_state_path = SystemState._get_state_path() + '/system.json' + pathlib.Path.unlink(Path(system_state_path), missing_ok=True) + + @staticmethod + def _get_state_path(): + return Constants.SP_STATE_HOME diff --git a/core/observers/ApplicationVersionObserver.py b/core/observers/ApplicationVersionObserver.py new file mode 100644 index 0000000..060beab --- /dev/null +++ b/core/observers/ApplicationVersionObserver.py @@ -0,0 +1,7 @@ +from core.observers.BaseObserver import BaseObserver + + +class ApplicationVersionObserver(BaseObserver): + + def __init__(self): + self.on_downloading = [] diff --git a/core/observers/BaseObserver.py b/core/observers/BaseObserver.py new file mode 100644 index 0000000..67cf232 --- /dev/null +++ b/core/observers/BaseObserver.py @@ -0,0 +1,25 @@ +from core.models.Event import Event + + +class BaseObserver: + + def subscribe(self, topic, callback): + + callbacks = getattr(self, 'on_' + topic, None) + + if callbacks is None: + return + + callbacks.append(callback) + + def notify(self, topic, subject = None, meta = None): + + callbacks = getattr(self, 'on_' + topic, None) + + if callbacks is None: + return + + event = Event(subject, meta) + + for callback in callbacks: + callback(event) diff --git a/core/observers/ClientObserver.py b/core/observers/ClientObserver.py new file mode 100644 index 0000000..1d6cf92 --- /dev/null +++ b/core/observers/ClientObserver.py @@ -0,0 +1,7 @@ +from core.observers.BaseObserver import BaseObserver + + +class ClientObserver(BaseObserver): + + def __init__(self): + self.on_synchronizing = [] diff --git a/core/observers/ConnectionObserver.py b/core/observers/ConnectionObserver.py new file mode 100644 index 0000000..680f613 --- /dev/null +++ b/core/observers/ConnectionObserver.py @@ -0,0 +1,7 @@ +from core.observers.BaseObserver import BaseObserver + + +class ConnectionObserver(BaseObserver): + + def __init__(self): + self.on_connecting = [] diff --git a/core/observers/InvoiceObserver.py b/core/observers/InvoiceObserver.py new file mode 100644 index 0000000..6a7106f --- /dev/null +++ b/core/observers/InvoiceObserver.py @@ -0,0 +1,10 @@ +from core.observers.BaseObserver import BaseObserver + + +class InvoiceObserver(BaseObserver): + + def __init__(self): + + self.on_retrieved = [] + self.on_processing = [] + self.on_settled = [] diff --git a/core/observers/ProfileObserver.py b/core/observers/ProfileObserver.py new file mode 100644 index 0000000..bcfc336 --- /dev/null +++ b/core/observers/ProfileObserver.py @@ -0,0 +1,11 @@ +from core.observers.BaseObserver import BaseObserver + + +class ProfileObserver(BaseObserver): + + def __init__(self): + + self.on_created = [] + self.on_destroyed = [] + self.on_disabled = [] + self.on_enabled = [] diff --git a/core/services/WebServiceApiService.py b/core/services/WebServiceApiService.py new file mode 100644 index 0000000..3ac6186 --- /dev/null +++ b/core/services/WebServiceApiService.py @@ -0,0 +1,167 @@ +from core.Constants import Constants +from core.models.ClientVersion import ClientVersion +from core.models.Location import Location +from core.models.Subscription import Subscription +from core.models.SubscriptionPlan import SubscriptionPlan +from core.models.invoice.Invoice import Invoice +from core.models.invoice.PaymentMethod import PaymentMethod +from core.models.session.Application import Application +from core.models.session.ApplicationVersion import ApplicationVersion +from core.models.session.ProxyConfiguration import ProxyConfiguration +from typing import Optional +import re +import requests + + +class WebServiceApiService: + + @staticmethod + def get_applications(proxies: Optional[dict] = None): + + response = WebServiceApiService._get('/platforms/linux-x86_64/applications', None, proxies) + applications = [] + + if response.status_code == requests.codes.ok: + for application in response.json()['data']: + applications.append(Application(application['code'], application['name'], application['id'])) + + return applications + + @staticmethod + def get_application_versions(code: str, proxies: Optional[dict] = None): + + response = WebServiceApiService._get('/platforms/linux-x86_64/applications/' + code + '/application-versions', None, proxies) + application_versions = [] + + if response.status_code == requests.codes.ok: + for application_version in response.json()['data']: + application_versions.append(ApplicationVersion(code, application_version['version_number'], application_version['id'], application_version['download_path'], application_version['released_at'])) + + return application_versions + + @staticmethod + def get_client_versions(proxies: Optional[dict] = None): + + response = WebServiceApiService._get('/platforms/linux-x86_64/client-versions', None, proxies) + client_versions = [] + + if response.status_code == requests.codes.ok: + for client_version in response.json()['data']: + client_versions.append(ClientVersion(client_version['version_number'], client_version['released_at'], client_version['id'], client_version['download_path'])) + + return client_versions + + @staticmethod + def get_locations(proxies: Optional[dict] = None): + + response = WebServiceApiService._get('/locations', None, proxies) + locations = [] + + if response.status_code == requests.codes.ok: + for location in response.json()['data']: + locations.append(Location(location['code'], location['id'], location['name'])) + + return locations + + @staticmethod + def get_subscription_plans(proxies: Optional[dict] = None): + + response = WebServiceApiService._get('/subscription-plans', None, proxies) + subscription_plans = [] + + if response.status_code == requests.codes.ok: + for subscription_plan in response.json()['data']: + subscription_plans.append(SubscriptionPlan(subscription_plan['id'], subscription_plan['code'], subscription_plan['wireguard_session_limit'], subscription_plan['duration'], subscription_plan['price'], subscription_plan['features_proxy'], subscription_plan['features_wireguard'])) + + return subscription_plans + + @staticmethod + def post_subscription(subscription_plan_id, location_id, proxies: Optional[dict] = None): + + response = WebServiceApiService._post('/subscriptions', None, { + 'subscription_plan_id': subscription_plan_id, + 'location_id': location_id + }, proxies) + + if response.status_code == requests.codes.created: + return Subscription(response.headers['X-Billing-Code']) + + @staticmethod + def get_subscription(billing_code: str, proxies: Optional[dict] = None): + + if billing_code is not None: + + billing_code = billing_code.replace('-', '').upper() + billing_code_fragments = re.findall('....?', billing_code) + billing_code = '-'.join(billing_code_fragments) + + response = WebServiceApiService._get('/subscriptions/current', billing_code, proxies) + + if response.status_code == requests.codes.ok: + + subscription = response.json()['data'] + return Subscription(billing_code, Subscription.from_iso_format(subscription['expires_at'])) + + @staticmethod + def get_invoice(billing_code: str, proxies: Optional[dict] = None): + + response = WebServiceApiService._get('/invoices/current', billing_code, proxies) + + if response.status_code == requests.codes.ok: + + response_data = response.json()['data'] + + invoice = { + 'status': response_data['status'], + 'expires_at': response_data['expires_at'] + } + + payment_methods = [] + + for payment_method in response_data['payment_methods']: + payment_methods.append(PaymentMethod(payment_method['code'], payment_method['name'], payment_method['address'], payment_method['payment_link'], payment_method['rate'], payment_method['amount'], payment_method['due'])) + + return Invoice(billing_code, invoice['status'], invoice['expires_at'], tuple[PaymentMethod](payment_methods)) + + @staticmethod + def get_proxy_configuration(billing_code: str, proxies: Optional[dict] = None): + + response = WebServiceApiService._get('/proxy-configurations/current', billing_code, proxies) + + if response.status_code == requests.codes.ok: + + proxy_configuration = response.json()['data'] + return ProxyConfiguration(proxy_configuration['ip_address'], proxy_configuration['port'], proxy_configuration['username'], proxy_configuration['password']) + + else: + return None + + @staticmethod + def post_wireguard_session(location_code: str, billing_code: str, proxies: Optional[dict] = None): + + response = WebServiceApiService._post('/locations/' + location_code + '/wireguard-sessions', billing_code, proxies) + + if response.status_code == requests.codes.created: + return response.text + else: + return None + + @staticmethod + def _get(path, billing_code: Optional[str] = None, proxies: Optional[dict] = None): + + if billing_code is not None: + headers = {'X-Billing-Code': billing_code} + else: + headers = None + + return requests.get(Constants.SP_API_BASE_URL + path, headers=headers, proxies=proxies) + + @staticmethod + def _post(path, billing_code: Optional[str] = None, body: Optional[dict] = None, proxies: Optional[dict] = None): + + if billing_code is not None: + headers = {'X-Billing-Code': billing_code} + else: + headers = None + + return requests.post(Constants.SP_API_BASE_URL + path, headers=headers, json=body, proxies=proxies) diff --git a/main.py b/main.py new file mode 100644 index 0000000..0642f85 --- /dev/null +++ b/main.py @@ -0,0 +1,259 @@ +from core.Constants import Constants +from core.Errors import MissingSubscriptionError, InvalidSubscriptionError +from core.controllers.ApplicationController import ApplicationController +from core.controllers.ApplicationVersionController import ApplicationVersionController +from core.controllers.ClientController import ClientController +from core.controllers.ConfigurationController import ConfigurationController +from core.controllers.InvoiceController import InvoiceController +from core.controllers.LocationController import LocationController +from core.controllers.ProfileController import ProfileController +from core.controllers.SubscriptionController import SubscriptionController +from core.controllers.SubscriptionPlanController import SubscriptionPlanController +from core.models.session.SessionConnection import SessionConnection +from core.models.session.SessionProfile import SessionProfile +from core.models.system.SystemConnection import SystemConnection +from core.models.system.SystemProfile import SystemProfile +from core.observers.ApplicationVersionObserver import ApplicationVersionObserver +from core.observers.ClientObserver import ClientObserver +from core.observers.ConnectionObserver import ConnectionObserver +from core.observers.InvoiceObserver import InvoiceObserver +from core.observers.ProfileObserver import ProfileObserver +from pathlib import Path +import argparse +import pprint + +if __name__ == '__main__': + + Path(Constants.SP_CONFIG_HOME).mkdir(parents=True, exist_ok=True) + Path(Constants.SP_DATA_HOME).mkdir(parents=True, exist_ok=True) + + application_version_observer = ApplicationVersionObserver() + client_observer = ClientObserver() + connection_observer = ConnectionObserver() + invoice_observer = InvoiceObserver() + profile_observer = ProfileObserver() + + application_version_observer.subscribe('downloading', lambda event: print(f'Downloading {ApplicationController.get(event.subject.application_code).name}, version {event.subject.version_number}...\n')) + client_observer.subscribe('synchronizing', lambda event: print('Synchronizing...\n')) + connection_observer.subscribe('connecting', lambda event: print(f'[{event.subject.get("attempt_count")}/{event.subject.get("maximum_amount_of_retries")}] Performing connection attempt...\n')) + + invoice_observer.subscribe('retrieved', lambda event: print(f'\n{pprint.pp(event.subject)}\n')) + invoice_observer.subscribe('processing', lambda event: print('A payment has been detected and is being verified...\n')) + invoice_observer.subscribe('settled', lambda event: print('The payment has been successfully verified.\n')) + + profile_observer.subscribe('created', lambda event: pprint.pp((event.subject, 'Created'))) + profile_observer.subscribe('destroyed', lambda event: pprint.pp((event.subject, 'Destroyed'))) + profile_observer.subscribe('disabled', lambda event: pprint.pp((event.subject, 'Disabled')) if event.meta.get('explicitly') else None) + profile_observer.subscribe('enabled', lambda event: pprint.pp((event.subject, 'Enabled'))) + + safe_parser = argparse.ArgumentParser(add_help=False) + safe_parser.add_argument('--force', '-f', action='store_true') + + main_parser = argparse.ArgumentParser(prog='simplified-privacy') + main_parser.add_argument('--version', '-v', action='version', version='simplified-privacy v0.1.0') + main_subparsers = main_parser.add_subparsers(title='commands', dest='command') + + profile_parser = main_subparsers.add_parser('profile') + profile_subparsers = profile_parser.add_subparsers(title='subcommands', dest='subcommand') + + profile_base_parser = argparse.ArgumentParser(add_help=False) + profile_base_parser.add_argument('--id', '-i', type=int, required=True) + + profile_subparsers.add_parser('list') + + profile_subparsers.add_parser('show', parents=[profile_base_parser]) + + profile_create_parser = profile_subparsers.add_parser('create') + profile_create_subparsers = profile_create_parser.add_subparsers(title='profile_types', dest='profile_type') + + session_profile_create_parser = profile_create_subparsers.add_parser('session', parents=[profile_base_parser, safe_parser]) + + session_profile_create_parser.add_argument('--name', '-n', default='') + session_profile_create_parser.add_argument('--location', '-l', dest='location_code', default=None) + session_profile_create_parser.add_argument('--application', '-a', required=True) + session_profile_create_parser.add_argument('--connection', '-c', dest='connection_type', choices=['system', 'tor', 'wireguard'], default='system') + session_profile_create_parser.add_argument('--mask-connection', '-m', action='store_true') + session_profile_create_parser.add_argument('--resolution', '-r', default='1280x720') + + system_profile_create_parser = profile_create_subparsers.add_parser('system', parents=[profile_base_parser, safe_parser]) + + system_profile_create_parser.add_argument('--name', '-n', default='') + system_profile_create_parser.add_argument('--location', '-l', dest='location_code', default=None) + system_profile_create_parser.add_argument('--connection', '-c', dest='connection_type', choices=['wireguard'], default='wireguard') + + profile_subparsers.add_parser('destroy', parents=[profile_base_parser, safe_parser]) + + profile_subparsers.add_parser('enable', parents=[profile_base_parser, safe_parser]) + profile_subparsers.add_parser('disable', parents=[profile_base_parser, safe_parser]) + + get_parser = main_subparsers.add_parser('get') + get_subparsers = get_parser.add_subparsers(title='subcommands', dest='subcommand') + + get_connection_parser = get_subparsers.add_parser('connection') + + set_parser = main_subparsers.add_parser('set') + set_subparsers = set_parser.add_subparsers(title='subcommands', dest='subcommand') + + set_connection_parser = set_subparsers.add_parser('connection') + set_connection_parser.add_argument('connection_type', choices=['system', 'tor']) + + sync_parser = main_subparsers.add_parser('sync') + + arguments = main_parser.parse_args() + + if arguments.command is None: + main_parser.print_help() + + elif arguments.command == 'profile': + + if arguments.subcommand is None: + profile_parser.print_help() + + elif arguments.subcommand == 'list': + pprint.pp(ProfileController.get_all()) + + elif arguments.subcommand == 'show': + pprint.pp(ProfileController.get(arguments.id)) + + elif arguments.subcommand == 'create': + + location = LocationController.get(arguments.location_code) + + if location is None: + main_parser.error('the following argument should be a valid reference: --location/-l') + + if arguments.profile_type == 'session': + + if not arguments.application: + arguments.application = ':' + + application_details = arguments.application.split(':', 1) + application_version = ApplicationVersionController.get(application_details[0], application_details[1]) + + if application_version is None: + main_parser.error('the following argument should be a valid reference: --application/-a') + + connection = SessionConnection(arguments.connection_type, arguments.mask_connection) + profile = SessionProfile(arguments.id, arguments.name, None, location, arguments.resolution, application_version, connection) + ProfileController.create(profile, profile_observer=profile_observer) + + else: + + connection = SystemConnection(arguments.connection_type) + profile = SystemProfile(arguments.id, arguments.name, None, location, connection) + ProfileController.create(profile, profile_observer=profile_observer) + + elif arguments.subcommand == 'destroy': + + profile = ProfileController.get(arguments.id) + + if profile is not None: + ProfileController.destroy(profile, profile_observer=profile_observer) + + else: + main_parser.error('the following argument should be a valid reference: --id/-i') + + elif arguments.subcommand == 'enable': + + profile = ProfileController.get(arguments.id) + + if profile is not None: + + try: + ProfileController.enable(profile, arguments.force, profile_observer=profile_observer, application_version_observer=application_version_observer, connection_observer=connection_observer) + + except (InvalidSubscriptionError, MissingSubscriptionError) as exception: + + if type(exception).__name__ == 'InvalidSubscriptionError': + print('The profile\'s subscription appears to be invalid.\n') + + if type(exception).__name__ == 'MissingSubscriptionError': + print('The profile is not tied to a subscription.\n') + + manage_subscription_input = None + + while manage_subscription_input not in ['1', '2', '3', '']: + + print('Please select from the following:\n') + + print(' 1) Request new subscription') + print(' 2) Enter billing code') + + print('\n 3) Exit') + + manage_subscription_input = input('\nEnter your choice [1]: ') + + if manage_subscription_input == '1' or manage_subscription_input == '': + + print('\nCreating subscription...\n') + + subscription_plan = SubscriptionPlanController.get(profile.connection, 720) + + if subscription_plan is None: + raise RuntimeError('No compatible subscription plan was found. Please contact support.') + + potential_subscription = SubscriptionController.create(subscription_plan, profile, connection_observer=connection_observer) + + if potential_subscription is not None: + ProfileController.attach_subscription(profile, potential_subscription) + + else: + raise RuntimeError('The subscription could not be created. Please try again later.') + + subscription = InvoiceController.handle_payment(potential_subscription.billing_code, invoice_observer=invoice_observer, connection_observer=connection_observer) + + if subscription is not None: + ProfileController.attach_subscription(profile, subscription) + + else: + raise RuntimeError('The subscription could not be activated. Please try again later.') + + ProfileController.enable(profile, force=arguments.force, profile_observer=profile_observer, application_version_observer=application_version_observer, connection_observer=connection_observer) + + elif manage_subscription_input == '2': + + billing_code = input('\nEnter your billing code: ') + print() + + subscription = SubscriptionController.get(billing_code, connection_observer=connection_observer) + + if subscription is not None: + + ProfileController.attach_subscription(profile, subscription) + ProfileController.enable(profile, arguments.force, profile_observer=profile_observer, application_version_observer=application_version_observer, connection_observer=connection_observer) + + else: + + print('\nThe billing code appears to be invalid.\n') + manage_subscription_input = None + + elif manage_subscription_input == '3': + pass + + else: + print('\nInput appears to be invalid. Please try again.\n') + + else: + main_parser.error('the following argument should be a valid reference: --id/-i') + + elif arguments.subcommand == 'disable': + + profile = ProfileController.get(arguments.id) + + if profile is not None: + ProfileController.disable(profile, force=arguments.force, profile_observer=profile_observer) + else: + main_parser.error('the following argument should be a valid reference: --id/-i') + + elif arguments.command == 'sync': + ClientController.sync(client_observer=client_observer, connection_observer=connection_observer) + + elif arguments.command == 'get': + + if arguments.subcommand == 'connection': + print(ConfigurationController.get_connection()) + + elif arguments.command == 'set': + + if arguments.subcommand == 'connection': + ConfigurationController.set_connection(arguments.connection_type) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..05ba0b8 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,7 @@ +dataclasses-json~=0.6.4 +marshmallow~=3.21.1 +psutil~=5.9.8 +pysocks~=1.7.1 +python-dateutil~=2.9.0.post0 +pytz~=2024.1 +requests~=2.31.0