Initial commit

This commit is contained in:
codeking 2024-09-11 19:39:33 +02:00
commit d6c9664345
46 changed files with 2747 additions and 0 deletions

1
.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/.idea

115
README.md Normal file
View file

@ -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
```

29
core/Constants.py Normal file
View file

@ -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'

38
core/Errors.py Normal file
View file

@ -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

View file

@ -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'

View file

@ -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.')

View file

@ -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()

View file

@ -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)

View file

@ -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)

View file

@ -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.')

View file

@ -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

View file

@ -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)

View file

@ -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()

View file

@ -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)

View file

@ -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)

View file

@ -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)

View file

@ -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)

View file

@ -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'

149
core/models/BaseProfile.py Normal file
View file

@ -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)

View file

@ -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

View file

@ -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)

6
core/models/Event.py Normal file
View file

@ -0,0 +1,6 @@
class Event:
def __init__(self, subject = None, meta = None):
self.subject = subject
self.meta = meta or {}

76
core/models/Location.py Normal file
View file

@ -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

73
core/models/Model.py Normal file
View file

@ -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()

View file

@ -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)

View file

@ -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

View file

@ -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'

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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')

View file

@ -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)

View file

@ -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

View file

@ -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]

View file

@ -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

View file

@ -0,0 +1,7 @@
from core.observers.BaseObserver import BaseObserver
class ApplicationVersionObserver(BaseObserver):
def __init__(self):
self.on_downloading = []

View file

@ -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)

View file

@ -0,0 +1,7 @@
from core.observers.BaseObserver import BaseObserver
class ClientObserver(BaseObserver):
def __init__(self):
self.on_synchronizing = []

View file

@ -0,0 +1,7 @@
from core.observers.BaseObserver import BaseObserver
class ConnectionObserver(BaseObserver):
def __init__(self):
self.on_connecting = []

View file

@ -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 = []

View file

@ -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 = []

View file

@ -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)

259
main.py Normal file
View file

@ -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)

7
requirements.txt Normal file
View file

@ -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