diff --git a/change_log.md b/change_log.md index 5b4ecc1..bbfd81c 100644 --- a/change_log.md +++ b/change_log.md @@ -1,5 +1,10 @@ # Major Change Log: +# Singbox Setup +### Aug 12, 2026 +Prepared Singbox setup modules, which includes installation, download, move to sudo folder, and sudo setup scripts. Added a `Dependency` model, endpoint, and the ability to sync that model. (Related note: Server-side prepared the endpoint, and stocked with real data.) And also the sync service modules were adjusted to handle new data types more smoothly, before they had errors. As part of that sync flow change, the CachedSync metadata model was transitioned to ints instead of strings, with default 0 values. This should in theory migrate all clients without further changes needed. +
+ # Singbox Orchestration ### Aug 10, 2026 Significant progress made on singbox flow, we now have a working: diff --git a/core/Constants.py b/core/Constants.py index 92bda10..8b4255a 100644 --- a/core/Constants.py +++ b/core/Constants.py @@ -6,15 +6,16 @@ import os @dataclass(frozen=True) class Constants: - DB_VERSION_THIS_APP_WANTS = 1 + DB_VERSION_THIS_APP_WANTS = 2 # Fallback for development (running outside AppImage) fallback_non_appimage = os.path.dirname(os.path.abspath(__file__)) # appimage home APPDIR_HOME: Final[str] = os.environ.get('APPDIR', fallback_non_appimage) - print(f"APPDIR_HOME is {APPDIR_HOME}") + # ── API ───────────────────────────────────────────── + # ticketing group: TICKET_API_BASE_URL: Final[str] = os.environ.get( "TICKET_API_BASE_URL", "https://ticket.hydraveil.net" @@ -79,7 +80,8 @@ class Constants: SINGBOX_INTERNAL_SUBNET: Final[str] = os.environ.get('SINGBOX_INTERNAL_SUBNET', '172.19.0.0/30') SINGBOX_INTERNAL_ADDR: Final[str] = os.environ.get('SINGBOX_INTERNAL_ADDR', '172.19.0.1/30') SINGBOX_DEFAULT_DNS: Final[str] = "9.9.9.9" - + SINGBOX_OUTPUT: Final[str] = f"{HV_DATA_HOME}/sing-box-output.txt" + SUDO_TARGET_FOLDER: Final[str] = "/opt/hydra-veil" # ── Tor ───────────────────────────────────────────── DEFAULT_TOR_PORT = 9050 diff --git a/core/assets/sudo_scripts/setup.sh b/core/assets/sudo_scripts/setup.sh index 28be6b3..e92e792 100644 --- a/core/assets/sudo_scripts/setup.sh +++ b/core/assets/sudo_scripts/setup.sh @@ -8,6 +8,7 @@ add_sudoers_rule() { sudo tee /etc/sudoers.d/zzzzzzzzzzzzzzzzzzz > /dev/null < bool: + configuration = ConfigurationController.get() + configuration.singbox = new_value + configuration.save() + return True diff --git a/core/controllers/SyncController.py b/core/controllers/SyncController.py index 6d0719d..45f9898 100644 --- a/core/controllers/SyncController.py +++ b/core/controllers/SyncController.py @@ -15,6 +15,7 @@ from core.errors.logger import logger # ORM models that can be sync'ed: from core.models.orm_models.Location import Location from core.models.orm_models.Operator import Operator +from core.models.orm_models.Dependency import Dependency from core.Constants import Constants from core.controllers.ApplicationController import ApplicationController @@ -33,7 +34,8 @@ import json ORM_TABLES = { "locations": Location, "operators": Operator, - "application_versions": ApplicationVersion + "application_versions": ApplicationVersion, + "dependencies": Dependency } LEGACY_SQL_FUNCT_DICT = { @@ -84,6 +86,20 @@ def new_sync(client_observer: ClientObserver, connection_observer: ConnectionObs # Nothing changed if the 'changed_tables' variable does NOT exist changed_tables = metadata_result["changed_tables"] + + # NEW MODELS: + new_model_types = metadata_result.get("new_model_types", []) + if new_model_types: + for each_new_model in new_model_types: + if each_new_model in ORM_TABLES: + # Then we know how to handle this, let's sync it: + logger.info(f"Adding the brand new {each_new_model} to the changed tables.") + changed_tables.append(each_new_model) + else: + logger.info(f"Skipping {each_new_model} because we don't know how to handle it yet.") + + + # CHECK ON CHANGED TABLES if not changed_tables: client_observer.notify('synchronized') return Result(valid=True) diff --git a/core/models/Configuration.py b/core/models/Configuration.py index a2808a9..412a407 100644 --- a/core/models/Configuration.py +++ b/core/models/Configuration.py @@ -26,7 +26,7 @@ class Configuration(BaseModel): firewall: Optional[bool] = False dns: Optional[bool] = False did_sudo_setup: Optional[bool] = False - + singbox: Optional[str] = None model_config = ConfigDict( extra='ignore', # Ignore unknown fields in JSON diff --git a/core/models/Result.py b/core/models/Result.py index 41ca3d6..64dbc6f 100644 --- a/core/models/Result.py +++ b/core/models/Result.py @@ -28,6 +28,7 @@ class ResultError(Enum): TIMEOUT = "timeout" INVALID_API_REPLY = "invalid_api_reply" LEAK_ISSUE = "leak_issue" + NEED_SYNC = "need_sync" UNKNOWN = "unknown" @dataclass diff --git a/core/models/manage/insert.py b/core/models/manage/insert.py index 6f10790..ebdbedf 100644 --- a/core/models/manage/insert.py +++ b/core/models/manage/insert.py @@ -16,6 +16,8 @@ from core.models.DatabaseOperation import DatabaseOperation, DBErrorType from core.models.orm_models.CachedSync import CachedSync from core.models.orm_models.Location import Location from core.models.orm_models.Operator import Operator +from core.models.orm_models.Dependency import Dependency + from core.models.orm_models.EncryptedProxy import EncryptedProxy # This is the public interface, diff --git a/core/models/manage/migrations.py b/core/models/manage/migrations.py index eda6477..72c92ab 100644 --- a/core/models/manage/migrations.py +++ b/core/models/manage/migrations.py @@ -66,7 +66,8 @@ def migrate_sql() -> DatabaseOperation: from core.models.orm_models.Operator import Operator from core.models.orm_models.CachedSync import CachedSync from core.models.orm_models.EncryptedProxy import EncryptedProxy - MODELS = [Location, Operator, CachedSync, EncryptedProxy] + from core.models.orm_models.Dependency import Dependency + MODELS = [Location, Operator, CachedSync, EncryptedProxy, Dependency] logger.info(f"[MIGRATION] Tables loaded") except: logger.error(f"[MIGRATION] Could not load models. Critical failure.") diff --git a/core/models/manage/session_management.py b/core/models/manage/session_management.py index e8134f0..4aabeb7 100644 --- a/core/models/manage/session_management.py +++ b/core/models/manage/session_management.py @@ -147,8 +147,9 @@ def create_ALL_tables(): from core.models.orm_models.CachedSync import CachedSync from core.models.orm_models.EncryptedProxy import EncryptedProxy from core.models.SubscriptionPlan import SubscriptionPlan - # from core.models.session.ApplicationVersion import ApplicationVersion from core.models.orm_models.ApplicationVersion import ApplicationVersion + from core.models.orm_models.Dependency import Dependency + try: BaseModel.metadata.create_all(engine, checkfirst=True) logger.info("[DB MANAGEMENT] All Tables have been successfully created.") diff --git a/core/models/orm_calls/dependency_calls.py b/core/models/orm_calls/dependency_calls.py new file mode 100644 index 0000000..6ccd67e --- /dev/null +++ b/core/models/orm_calls/dependency_calls.py @@ -0,0 +1,24 @@ +from core.models.orm_models.Dependency import Dependency +from core.models.manage.wrapper import safe_db_operation, WrapperRollback +from core.models.DatabaseOperation import DatabaseOperation, DBErrorType +from core.errors.logger import logger + +from sqlalchemy.orm import Session +from sqlalchemy import select +from typing import Optional + +@safe_db_operation +def execute_dependency_sql(name: str, session: Session) -> DatabaseOperation: + data = session.query(Dependency).filter( + Dependency.name == name + ).first() + return DatabaseOperation(valid=True, data=data) + + +def get_dependency_version(name: str) -> Optional[Dependency]: + database_object = execute_dependency_sql(name) + if database_object.valid: + return database_object.data + else: + logger.error(f"Critical Database error with fetching data for {name} with {database_object.message}") + return None diff --git a/core/models/orm_models/CachedSync.py b/core/models/orm_models/CachedSync.py index 1d626cf..f146a8b 100644 --- a/core/models/orm_models/CachedSync.py +++ b/core/models/orm_models/CachedSync.py @@ -16,9 +16,10 @@ class CachedSync(Base): # version of the cached sync itself is the primary key version: Mapped[int] = mapped_column(Integer, primary_key=True) - applications: Mapped[Optional[str]] = mapped_column(Integer, nullable=True, default=None) - application_versions: Mapped[Optional[str]] = mapped_column(Integer, nullable=True, default=None) - client_version: Mapped[Optional[str]] = mapped_column(Integer, nullable=True, default=None) - operators: Mapped[Optional[str]] = mapped_column(Integer, nullable=True, default=None) - locations: Mapped[Optional[str]] = mapped_column(Integer, nullable=True, default=None) - subscriptions: Mapped[Optional[str]] = mapped_column(Integer, nullable=True, default=None) + applications: Mapped[Optional[int]] = mapped_column(Integer, nullable=False, default=0) + application_versions: Mapped[Optional[int]] = mapped_column(Integer, nullable=False, default=0) + client_version: Mapped[Optional[int]] = mapped_column(Integer, nullable=False, default=0) + operators: Mapped[Optional[int]] = mapped_column(Integer, nullable=False, default=0) + locations: Mapped[Optional[int]] = mapped_column(Integer, nullable=False, default=0) + subscriptions: Mapped[Optional[int]] = mapped_column(Integer, nullable=False, default=0) + dependencies: Mapped[Optional[int]] = mapped_column(Integer, nullable=False, default=0) diff --git a/core/models/orm_models/Dependency.py b/core/models/orm_models/Dependency.py new file mode 100644 index 0000000..f690414 --- /dev/null +++ b/core/models/orm_models/Dependency.py @@ -0,0 +1,15 @@ +from sqlalchemy.orm import Mapped +from core.models.orm_models.Base import BaseModel +from core.Constants import Constants + +from sqlalchemy import Column, Integer, String, Boolean, UniqueConstraint, TypeDecorator +from sqlalchemy.orm import declarative_base + +class Dependency(BaseModel): + __tablename__ = 'dependencies' + + id = Column(Integer, primary_key=True) + name = Column(String, unique=True, nullable=False) + version_number = Column(String, unique=False, nullable=False) + download_path = Column(String, unique=True, nullable=False) + file_hash = Column(String, nullable=False) diff --git a/core/services/helpers/install_dependencies.py b/core/services/helpers/install_dependencies.py new file mode 100644 index 0000000..6913cb3 --- /dev/null +++ b/core/services/helpers/install_dependencies.py @@ -0,0 +1,350 @@ +from core.services.networking.httpx.httpx_client import get_http_session, init_session +from core.utils.basic_operations.folder_tools import validate_folder_structure +from core.utils.basic_operations.does_file_exist import does_file_exist +from core.models.Result import Result, ResultError +from core.Constants import Constants +from core.errors.logger import logger +from core.utils.run_commands import run_generic_command +from core.observers.ApplicationVersionObserver import ApplicationVersionObserver +from core.Errors import FileIntegrityError +from core.services.networking.api_requests.subtools.extract_domain import extract_domain +from core.models.orm_models.Dependency import Dependency +from core.models.orm_calls.dependency_calls import get_dependency_version +from core.controllers.ConfigurationController import ConfigurationController +from core.utils.basic_operations.compare_versions import version_update_required + +import httpx +from io import BytesIO +from typing import Optional +import hashlib +import shutil +import tarfile +import os + +SUDO_SINGBOX_LOCATION = f"{Constants.SUDO_TARGET_FOLDER}/sing-box" + +def singbox_setup(application_version_observer: Optional[ApplicationVersionObserver]) -> Result: + """ + Rank: + Module's Main Orchestrator + + Purpose: + Other modules can call upon this before approving the use of singbox, + it checks if it's ready, updated, and if required downloads and sets it up. + + Method: + 1) Checks if it's already installed & updated + If not: + 2) Gets the new version if required + 3) Moves the binary to the sudo protected folder + 4) Updates the config JSON with the new version. + + Returns On Success: + Return Object with valid=True + + Returns On Failure: + No error raises, but caller needs to deal with: + ResultError.NEED_SYNC = needs sync + ResultError.MISSING_FILE = incomplete downloads, operations, or filesystem errors + ResultError.CONNECTION = failed to fetch from the API the new version + ResultError.INVALID_INPUT = rare bugs + """ + already_in_sudo_folder = does_file_exist(SUDO_SINGBOX_LOCATION) + + update_result = update_needed() + + # new version sql could not be done, so we can't even update without the new version info: + if not update_result.valid: + logger.info("Sync is required. We could not access the information on the most current version locally.") + return update_result # (user MUST sync) + + # this is blank if the checks worked, but nothing is required, + required_update = update_result.data + + # no update required: + if required_update is None and already_in_sudo_folder: + logger.info("No update for singbox is required.") + return update_result + + # UPDATE FROM HERE ON + logger.info("Updating Singbox..") + + # PREP VARIABLES + target_version = str(required_update.version_number) + download_path = required_update.download_path + target_file_hash = required_update.file_hash + target_folder = f"{Constants.HV_APPLICATION_DATA_HOME}/singbox/" + original_file = f"{target_folder}/{target_version}/sing-box" + + # CHECK IF WE ALREADY HAVE IT LOCALLY: + if already_downloaded(which_version=target_version): + logger.info("Using already downloaded version from local files..") + return move_and_update_config( + original_file=original_file, + target_version=target_version + ) + + # DOWNLOAD FROM API: + file_result = download_and_verify( + download_path=download_path, + target_folder=target_folder, + target_file_hash=target_file_hash, + target_app_name="sing-box", + target_version=target_version, + application_version_observer=application_version_observer + ) + + if not file_result.valid: + logger.error(f"Download or verification failed: {file_result.error_type}") + return file_result + + # FINALLY MOVE FILE & UPDATE STATE: + return move_and_update_config( + original_file=original_file, + target_version=target_version + ) + + +def move_and_update_config(original_file: str, target_version: str) -> Result: + """ + Rank: + Coordinator + Purpose: + Move the binary to the sudo protected folder. + Update the configuration that it's installed the "target_version" + Returns: + Result Object. + Wrapper should in theory handle external file errors. + """ + # SETUP/MOVE: + logger.info("Requesting permission to move to a sudo folder...") + moved_results = _move_singbox_to_sudo_folder( + original_file=original_file + ) + + if not moved_results.valid: + return moved_results + + # TEST IT'S THERE: + setup_in_sudo_folder = does_file_exist(SUDO_SINGBOX_LOCATION) + + if setup_in_sudo_folder: + # UPDATE CONFIG TO REFLECT IT: + logger.info("The move to sudo folder was successfull. We're now updating the Configuration with the newest version installed.") + updated = ConfigurationController.update_singbox_version(target_version) + if updated: + logger.info("Configuration is updated. Singbox is ready. Complete.") + return Result(valid=True, message="Ready to proceed with singbox installed properly") + + else: + # ConfigurationController doesn't even return False ever, so this is essentially a full-blown breakdown + error_msg = "RARE FILESYSTEM CRISIS CAUSES INFINITE LOOP! User actually updated the singbox binary into the sudo folder, but it's not reflecting that. Please evaluate the permissions on the config, and check if you have enough space. Check the config is even there." + logger.error(error_msg) + return Result(valid=False, error_type=ResultError.FILE_SYSTEM, message=error_msg) + + else: + error_msg = "If you denied the sudo request, it should have already given a permission error before reaching this point. So now it's possibly a corrupt filesystem or file issue." + logger.error(error_msg) + return Result(valid=False, error_type=ResultError.FILE_SYSTEM, message=error_msg) + + +def _move_singbox_to_sudo_folder(original_file: str) -> Result: + if not does_file_exist(original_file): + return Result(valid=False, error_type=ResultError.MISSING_FILE) + + target_file = SUDO_SINGBOX_LOCATION + command = ['pkexec', 'install', '-D', original_file, target_file, '-o', 'root', '-m', '755'] + human_readable_goal = "Move Singbox to an elevated sudo folder." + return run_generic_command(command, human_readable_goal, timeout=40) + + +def update_needed() -> Result: + """ + Rank: + Coordinator + + Purpose: + Evaluates if an update is needed. + + Returns: + Always a Result Object. + + ** IMPORTANT** + If an update is NOT needed, but it can complete the check, it returns valid=True, but None for Data. + + If an update IS needed, it returns data with the new version. + + If it can't complete the checks, it returns False for valid. + """ + app_name = "singbox" + rejected_values = [None, False, ""] + + ################################################ + # WHAT YOU NEED + ################################################ + version_sql_query = get_dependency_version(app_name) + if not isinstance(version_sql_query, Dependency): + return Result(valid=False, error_type=ResultError.NEED_SYNC, message="You need to Sync, you lack the dependency data for singbox.") + + new_version = version_sql_query.version_number + + if not new_version or new_version in rejected_values: + return Result(valid=False, error_type=ResultError.NEED_SYNC, message="You need to Sync, you lack the dependency data for singbox.") + logger.info(f"The new version for {app_name} from public sync data that we need is: {new_version}") + + + ################################################ + # WHAT YOU GOT + ################################################ + version_installed = ConfigurationController.get_singbox_version() + logger.info(f"While the version of {app_name} we installed in our configuration is {version_installed}") + if version_installed in rejected_values: + logger.info(f"We need to update to the new {new_version}") + return Result(valid=True, data=version_sql_query) + + ################################################ + # EVALUATE + ################################################ + try: + need_update = version_update_required( + new_version=new_version, + version_installed=version_installed + ) + except ValueError as e: + if "new_version" in str(e): + logger.error(f"The new_version is in the wrong format: {str(e)}") + return Result(valid=False, error_type=ResultError.NEED_SYNC, message="You need to Sync, the data is corrupt. Also wipe the version in the config.") + elif "version_installed" in str(e): + logger.error(f"The version_installed is in the wrong format: {str(e)}. But we can still update to the new version..") + # wipe config: + changed_config = ConfigurationController.update_singbox_version(None) # can make this dynamic if more apps are added. + need_update = True # the version_sql_query data is still valid. + else: + error_msg = f"Corrupt data, corrupt filesystem, or outright developer bug. Please contact customer support with new_version: {new_version} and version_installed {version_installed} tried to see if it should update but {str(e)}" + logger.error(error_msg) + return Result(valid=False, error_type=ResultError.INVALID_INPUT, message=error_msg) + + if need_update: + return Result(valid=True, data=version_sql_query, message="update") + else: + return Result(valid=True, data=None, message="Not needed.") # BLANK DATA, BUT TRUE + + +def already_downloaded(which_version: str) -> bool: + """ + Prior Context: + Downloads first go to a non-sudo folder, then are moved to the sudo folder. + + Purpose: + This function evaluates if we need to download it again to that non-sudo folder + """ + non_sudo_file = f"{Constants.HV_APPLICATION_DATA_HOME}/singbox/{which_version}/sing-box" + return does_file_exist(non_sudo_file) + + +def download_and_verify( + download_path: str, + target_folder: str, + target_file_hash: str, + target_app_name: str, + target_version: str, + application_version_observer: Optional[ApplicationVersionObserver] = None, +) -> Result: + """ + Purpose: + Stream download file reusing pre-existing HTTPx client, then verify the hash. + + Result object error types: + ResultError.CONNECTION = couldn't connect + ResultError.INVALID_INPUT = verification hash doesn't match + """ + if application_version_observer is not None: + application_version_observer.notify('downloading', "singbox") + + ################################################ + # SETUP HTTP CLIENT + ################################################ + init_session() + client = get_http_session() + if client is None: + init_session + client = get_http_session() + + + ################################################ + # GET THE DATA + ################################################ + with client.stream('GET', download_path) as response: + if response.status_code == 200: + response_size = int(response.headers.get('Content-Length', 0)) + response_buffer = BytesIO() + + block_size = 1024 + bytes_written = 0 + for data in response.iter_bytes(block_size): + + bytes_written += len(data) + response_buffer.write(data) + progress = (bytes_written / response_size) * 100 if response_size > 0 else 0 + + if application_version_observer is not None: + application_version_observer.notify('download_progressing', f"Downloading {target_app_name} {progress:.2f}%") + else: + error_msg = f"Could not download {target_app_name} because of a Connection Error." + logger.error(error_msg) + return Return(valid=False, error_type=ResultError.CONNECTION, message=error_msg) + + application_version_observer.notify('downloaded', f"Downloaded {target_app_name}") + response_buffer.seek(0) + + ################################################ + # VERIFY THE HASH + ################################################ + real_file_hash = __calculate_file_hash(response_buffer) + + if real_file_hash != target_file_hash: + error_msg = f'Application version file integrity could not be verified. We are targeting {target_file_hash}, but got {real_file_hash}' + logger.error(error_msg) + return Return(valid=False, error_type=ResultError.INVALID_INPUT, message=error_msg) + + ################################################ + # SAVE IT IN CORRECT STRUCTURE + ################################################ + temp_dir = f"{target_folder}/temp_dir" + final_target_folder = f"{target_folder}/{target_version}" + + # Create the temp folder (if it doesn't exist) + os.makedirs(temp_dir, exist_ok=True) + + # Save buffer to a temp directory: + with tarfile.open(fileobj=response_buffer, mode = 'r:gz') as tar_file: + tar_file.extractall(temp_dir) + + # make sure it has the file in the temp, and move it to the correct structure, + target_file_is_in_payload = validate_folder_structure( + temp_dir=temp_dir, + target_folder=final_target_folder, + target_file=target_app_name + ) + + if target_file_is_in_payload: + return Result(valid=True) + else: + return Result(valid=False, error_type=ResultError.MISSING_FILE) + + +# Repeat function outside ApplicationController, +# because we are moving away from fake static object structure. +def __calculate_file_hash(file): + + hasher = hashlib.sha3_512() + buffer = file.read(65536) + + while len(buffer) > 0: + + hasher.update(buffer) + buffer = file.read(65536) + + file.seek(0) + + return hasher.hexdigest() diff --git a/core/services/helpers/manage_assets.py b/core/services/helpers/manage_assets.py index 0c6f092..8fb5b91 100644 --- a/core/services/helpers/manage_assets.py +++ b/core/services/helpers/manage_assets.py @@ -2,16 +2,27 @@ from core.Constants import Constants from core.errors.logger import logger from core.services.helpers.assets_as_strings.sudo_scripts.dns import dns_script from core.services.helpers.assets_as_strings.sudo_scripts.firewall import firewall_script -from core.services.helpers.assets_as_strings.sudo_scripts.setup_script import setup_script +# from core.services.helpers.assets_as_strings.sudo_scripts.setup_script import setup_script # generic from pathlib import Path import shutil import os +from importlib import resources +from string import Template initial_appimage_assets = f"{Constants.APPDIR_HOME}/assets" current_assets_folder = f"{Constants.HV_DATA_HOME}/assets" + +def prep_singbox_wrapper(): + script_content = resources.files('core.assets.sudo_scripts').joinpath('singbox_wrapper').read_text() + template = Template(script_content) + return template.safe_substitute(VAR1=Constants.SINGBOX_OUTPUT) + +def setup_script(): + return resources.files('core.assets.sudo_scripts').joinpath('setup.sh').read_text() + def assets_folder_setup(): if os.path.exists(current_assets_folder): return True @@ -22,12 +33,13 @@ def updated_assets_folder_changes(): def sudo_assets_folder_setup() -> bool: current_assets_folder = f"{Constants.HOME}/Downloads/hydraveil_sudo_scripts" + singbox_wrapper = prep_singbox_wrapper() data = { "dns": dns_script, "firewall": firewall_script, - "setup.sh": setup_script - + "singbox_wrapper": singbox_wrapper, + "setup.sh": setup_script() } # Create the folder (if it doesn't exist) diff --git a/core/services/helpers/setup_sudo_scripts.py b/core/services/helpers/setup_sudo_scripts.py index 0fa8783..0221401 100644 --- a/core/services/helpers/setup_sudo_scripts.py +++ b/core/services/helpers/setup_sudo_scripts.py @@ -11,8 +11,7 @@ import os from subprocess import CalledProcessError import copy -FILES_TO_CHECK = ["setup.sh", "firewall", "dns"] -TARGET_FOLDER = "/opt/hydra-veil" +FILES_TO_CHECK = ["setup.sh", "firewall", "dns", "singbox_wrapper"] def auto_install_sudo_script() -> Result: setup_dir = Path(f"{Constants.HOME}/Downloads/hydraveil_sudo_scripts") @@ -35,7 +34,7 @@ def auto_install_sudo_script() -> Result: try: result = subprocess.run( - ['pkexec', 'env', f'ORIGINAL_FOLDER={setup_dir}', f'TARGET_FOLDER={TARGET_FOLDER}', f'LINUX_USER={original_linux_user}', 'bash', setup_script], + ['pkexec', 'env', f'ORIGINAL_FOLDER={setup_dir}', f'TARGET_FOLDER={Constants.SUDO_TARGET_FOLDER}', f'LINUX_USER={original_linux_user}', 'bash', setup_script], capture_output=True, text=True, ) diff --git a/core/services/networking/httpx/endpoints.py b/core/services/networking/httpx/endpoints.py index 711c610..baa57aa 100644 --- a/core/services/networking/httpx/endpoints.py +++ b/core/services/networking/httpx/endpoints.py @@ -10,6 +10,7 @@ def switch_endpoint_domain(domain: str) -> dict: "subscriptions": f"https://{domain}/api/v1/subscription-plans", "applications": f"https://{domain}/api/v1/platforms/linux-x86_64/applications", "application_versions": f"https://{domain}/api/v1/platforms/linux-x86_64/application-versions", + "dependencies": f"https://{domain}/api/v1/dependencies" } diff --git a/core/services/networking/systemwide/encrypted_proxy/singbox.py b/core/services/networking/systemwide/encrypted_proxy/singbox.py index 01b6b01..84f3185 100644 --- a/core/services/networking/systemwide/encrypted_proxy/singbox.py +++ b/core/services/networking/systemwide/encrypted_proxy/singbox.py @@ -9,8 +9,7 @@ from core.Constants import Constants from pathlib import Path import subprocess -SINGBOX_WRAPPER = "/opt/hydra-veil/singbox-wrapper" # needs chmod 755 -OUTPUT = f"{Constants.HV_DATA_HOME}/sing-box-output.txt" +SINGBOX_WRAPPER = "/opt/hydra-veil/singbox_wrapper" # needs chmod 755 @wrap_with(systemwide_hell_raiser) def start(profile_id: int) -> Result: @@ -19,14 +18,14 @@ def start(profile_id: int) -> Result: output_folder.parent.mkdir(parents=True, exist_ok=True) # make sure text file exists: - command = ["touch", OUTPUT] + command = ["touch", Constants.SINGBOX_OUTPUT] human_readable_goal = "Make output text file" made_output_file = run_generic_command(command, human_readable_goal, timeout=5) if not made_output_file.valid: logger.error("There was an issue with making the blank file for singbox's output.") - if not write_string_to_text_file(content_to_write="hello world", file_path=OUTPUT): - return Result(valid=False, error_type=MISSING_FILE, message=f"Could not create the file to output the singbox content at {OUTPUT}") + if not write_string_to_text_file(content_to_write="hello world", file_path=Constants.SINGBOX_OUTPUT): + return Result(valid=False, error_type=MISSING_FILE, message=f"Could not create the file to output the singbox content at {Constants.SINGBOX_OUTPUT}") # start singbox: command = ["sudo", SINGBOX_WRAPPER, "arm", str(profile_id)] diff --git a/core/services/sync/insert_for_orm.py b/core/services/sync/insert_for_orm.py index 4eaf5c6..8ee2ba0 100644 --- a/core/services/sync/insert_for_orm.py +++ b/core/services/sync/insert_for_orm.py @@ -59,7 +59,13 @@ def insert_one_orm_model( # otherwise: logger.info(f"Skipping denormalization for {which_key} because there was no assets folder yaml at path {yaml_file}") - extracted_data = new_data.get('data', new_data) + + # The old version of the API wrapped stuff in 'data', while the new endpoints don't: + if isinstance(new_data, dict): + extracted_data = new_data.get('data', new_data) + else: + extracted_data = new_data + return insert_into_model(which_model, extracted_data, override) diff --git a/core/services/sync/sync_service.py b/core/services/sync/sync_service.py index 6d07a45..6ef3e75 100644 --- a/core/services/sync/sync_service.py +++ b/core/services/sync/sync_service.py @@ -14,6 +14,7 @@ from core.models.manage.session_management import get_session from core.models.manage.get_from_model import get_from_model from core.models.manage.insert import insert_into_model from core.models.orm_models.CachedSync import CachedSync +from core.models.manage.migrations import migrate_sql # usual core infrastructure: from core.Constants import Constants @@ -26,6 +27,12 @@ from core.observers.ConnectionObserver import ConnectionObserver from sqlalchemy import func from typing import Optional +def _update_nones_with_zero(any_dict: dict) -> dict: + for key, value in any_dict.items(): + if any_dict[key] is None: + any_dict[key] = 0 + return any_dict + def _get_cached_metadata() -> tuple: """Retrieve cached metadata from the database. @@ -48,6 +55,7 @@ def _get_cached_metadata() -> tuple: return {}, 0 old_data = old_data_as_list[0] + previous_highest = old_data.get("version", 0) logger.debug(f"Cached metadata version: {previous_highest}") @@ -74,6 +82,9 @@ def _get_changed_models(new_data: dict, old_data: dict) -> tuple: new_model_types = [] logger.info(f"First sync: fetching all models: {changed_tables}") else: + # Make sure the old data actually has values: + old_data = _update_nones_with_zero(old_data) + # Subsequent sync: compare against baseline changed_tables, new_model_types = compare_tables(new_data, old_data) @@ -225,12 +236,29 @@ def coordinate_cache_sync( except Exception as e: error_msg = f"Database query failed: {str(e)}" logger.error(error_msg, exc_info=True) - return { - "success": False, - "changed_tables": [], - "new_model_types": [], - "error": error_msg, - } + + # MIGRATE: + migration = migrate_sql() + + # MIGRATION FAILED: + if not migration.valid: + return { + "success": False, + "changed_tables": [], + "new_model_types": [], + "error": error_msg, + } + + # MIGRATION WORKED: + try: + old_data, previous_highest = _get_cached_metadata() + except Exception as e: + return { + "success": False, + "changed_tables": [], + "new_model_types": [], + "error": error_msg, + } # Version hasn't changed if new_highest == previous_highest: diff --git a/core/utils/basic_operations/compare_versions.py b/core/utils/basic_operations/compare_versions.py new file mode 100644 index 0000000..0071756 --- /dev/null +++ b/core/utils/basic_operations/compare_versions.py @@ -0,0 +1,36 @@ + +def version_update_required(new_version: str, version_installed: str) -> bool: + # step 1) validate new_version + try: + new_parts = new_version.split('.') + + if len(new_parts) != 3: + raise ValueError("Version must have exactly 3 parts (X.Y.Z)") + + new_major, new_minor, new_patch = map(int, new_parts) + except ValueError: + raise ValueError(f"Invalid new_version format: '{new_version}'") + + # step 1b) validate version_installed + try: + installed_parts = version_installed.split('.') + + if len(installed_parts) != 3: + raise ValueError("Version must have exactly 3 parts (X.Y.Z)") + + installed_major, installed_minor, installed_patch = map(int, installed_parts) + except ValueError: + raise ValueError(f"Invalid version_installed format: '{version_installed}'") + + # step 2) isolate the first two parts into a float + new_major_minor = float(f"{new_major}.{new_minor}") + installed_major_minor = float(f"{installed_major}.{installed_minor}") + + # step 3) compare if new_version > version_installed + if new_major_minor > installed_major_minor: + return True + elif new_major_minor < installed_major_minor: + return False + + # step 4) if equal, go into the last digit + return new_patch > installed_patch diff --git a/core/utils/basic_operations/folder_tools.py b/core/utils/basic_operations/folder_tools.py new file mode 100644 index 0000000..9286c0b --- /dev/null +++ b/core/utils/basic_operations/folder_tools.py @@ -0,0 +1,56 @@ + +import shutil +from pathlib import Path + +def validate_folder_structure(temp_dir: str, target_folder: str, target_file: str): + """ + Move contents from temp_dir to target_folder, recursively find target_file, + move it to root if nested, and validate it exists. + + Args: + temp_dir: Temporary directory containing extracted tar contents + target_folder: Directory to move contents into + target_file: Filename to find and move to root + + Returns: + True if target_file exists in target_folder root, False otherwise. + """ + needed_strings = [temp_dir, target_folder, target_file] + if None in needed_strings: + return False + + temp_path = Path(temp_dir) + target_folder = Path(target_folder) + target_file_path = target_folder / target_file + + # Get what was extracted + extracted_items = [p for p in temp_path.iterdir()] + + # If a single top-level directory, move its contents + if len(extracted_items) == 1 and extracted_items[0].is_dir(): + source_dir = extracted_items[0] + target_folder.mkdir(parents=True, exist_ok=True) + for item in source_dir.iterdir(): + dest = target_folder / item.name + shutil.move(str(item), str(dest)) + else: + # Multiple items at root level - move them all + target_folder.mkdir(parents=True, exist_ok=True) + for item in extracted_items: + dest = target_folder / item.name + shutil.move(str(item), str(dest)) + + # Recursively search for target_file + found_file = None + for file_path in target_folder.rglob(target_file): + found_file = file_path + break + + # If found and not already at root, move it there + if found_file: + if found_file.parent != target_folder: + shutil.move(str(found_file), str(target_file_path)) + return True + else: + return False +