Enabled support for editing systemwide profile protocols including vless/hysteria2

This commit is contained in:
SimplifiedPrivacy 2026-10-03 11:39:57 -04:00
parent dba8d79db8
commit f2922b02a0
6 changed files with 157 additions and 70 deletions

View file

@ -5,6 +5,7 @@ from core.services.networking.systemwide.general_tools.manage_sudo_configs impor
# JSON Models # JSON Models
from core.models.BaseProfile import BaseProfile as Profile from core.models.BaseProfile import BaseProfile as Profile
from core.models.BaseProfile import ProfileType
from core.models.Subscription import Subscription from core.models.Subscription import Subscription
from core.models.session.SessionProfile import SessionProfile from core.models.session.SessionProfile import SessionProfile
from core.models.system.SystemProfile import SystemProfile from core.models.system.SystemProfile import SystemProfile
@ -41,6 +42,8 @@ def update_profile(
key: str, key: str,
new_value: str) -> Result: new_value: str) -> Result:
return Result(valid=False, error_type=ResultError.UNKNOWN, message="This is a test fail")
final_data = None final_data = None
logger.info(f"[UPDATE PROFILE] Recieved profile_id {profile_id}, key {key}, and new_value {new_value}.") logger.info(f"[UPDATE PROFILE] Recieved profile_id {profile_id}, key {key}, and new_value {new_value}.")
@ -83,33 +86,12 @@ def update_profile(
profile.application_version = application_version profile.application_version = application_version
elif key == 'protocol': elif key == 'protocol':
# ============= SAME VALUE ============= logger.info("Changing the protocol..")
if profile.connection.code == new_value: changed_result = change_protocol(profile, new_value)
error_msg = f"This is NOT changing the protocol, it already was {new_value}" if not changed_result.valid:
return Result(valid=False, message=error_msg, error_type=ResultError.INVALID_INPUT) logger.error("Returning a failed change.")
return changed_result
# ============= SYSTEMWIDE ============= profile = changed_result.data
if profile.connection == 'system-wide':
if new_value in SYSTEMWIDE_CHOICES:
# REMOVE PAST CONFIG:
removed = remove_sudo_config(profile)
if not removed.valid:
error_msg = "You must give permission to delete the previous protocol's config file."
return Result(valid=False, message=error_msg, error_type=ResultError.PERMISSION)
# UPDATE PROFILE
profile.connection.code = new_value
final_data = "edit_session"
else:
error_msg = f"{new_value} is not a systemwide choice"
return Result(valid=False, message=error_msg, error_type=ResultError.NOT_SUPPORTED)
# ============= SESSION =============
else:
if new_value in SESSION_CHOICES:
profile.connection.code = new_value
if new_value == 'wireguard':
profile.connection.masked = False
# ============= LOCATION ============= # ============= LOCATION =============
elif key == "location": elif key == "location":
@ -127,10 +109,66 @@ def update_profile(
return Result(valid=False, message="Invalid Value to Edit", error_type=ResultError.INVALID_INPUT) return Result(valid=False, message="Invalid Value to Edit", error_type=ResultError.INVALID_INPUT)
logger.info("[UPDATE PROFILE] Passing to Profile model to save..") logger.info("[UPDATE PROFILE] Passing to Profile model to save..")
return _literally_save(profile)
def _literally_save(profile: Profile) -> Result:
try: try:
profile.save() profile.save()
logger.info("[UPDATE PROFILE] Save worked, passing to the GUI the Result..") logger.info("[UPDATE PROFILE] Save worked, passing to the GUI the Result..")
return Result(valid=True, data=final_data) return Result(valid=True, data=final_data)
except: except Exception as e:
error_msg = "Profile save failed" error_msg = f"Profile save failed because {str(e)}"
return Result(valid=False, message=error_msg, error_type=ResultError.UNKNOWN) return Result(valid=False, message=error_msg, error_type=ResultError.UNKNOWN)
def change_protocol(profile: Profile, new_value: str) -> Result:
logger.info(f"changing the protcol to {new_value}")
# ============= SAME VALUE =============
if profile.connection.code == new_value:
error_msg = f"This is NOT changing the protocol, it already was {new_value}"
logger.error(error_msg)
return Result(valid=False, message=error_msg, error_type=ResultError.INVALID_INPUT)
# ============= SYSTEMWIDE =============
logger.info(f"Evaluating the profile's connection type of {profile.type}")
if profile.type == ProfileType.SYSTEM:
return change_systemwide_connection(profile, new_value)
else:
return change_session_connection(profile, new_value)
def change_session_connection(profile: Profile, new_value: str) -> Result:
logger.info("This is a session profile")
if new_value not in SESSION_CHOICES:
error_msg = f"{new_value} is not a session choice"
logger.error(error_msg)
return Result(valid=False, message=error_msg, error_type=ResultError.NOT_SUPPORTED)
profile.connection.code = new_value
if new_value == 'wireguard':
profile.connection.masked = False
return Result(valid=True, data=profile)
def change_systemwide_connection(profile: Profile, new_value: str) -> Result:
logger.info("This is a systemwide profile")
if not new_value in SYSTEMWIDE_CHOICES:
error_msg = f"{new_value} is not a systemwide choice"
logger.error(error_msg)
return Result(valid=False, message=error_msg, error_type=ResultError.NOT_SUPPORTED)
# REMOVE PAST CONFIG:
logger.info("removing the config from sudo slot..")
removed = remove_sudo_config(profile)
if not removed.valid:
error_msg = "You must give permission to delete the previous protocol's config file."
return Result(valid=False, message=error_msg, error_type=ResultError.PERMISSION)
# UPDATE PROFILE
logger.info("Literally updating the value..")
profile.connection.code = new_value
final_data = "edit_session"
return Result(valid=True, data=profile)

View file

@ -14,20 +14,15 @@ class HysteriaData(BaseModel):
password: str password: str
hysteria2_host: str hysteria2_host: str
server_ip: IPv4Address server_ip: IPv4Address
location_country_code: str
location_city_code: str
operator_id: int
# operator_name: str = None
operator_domain: str = None
@model_validator(mode='before') @model_validator(mode='before')
@classmethod @classmethod
def denormalize(cls, data): def denormalize(cls, data):
"""This is IF its normalized, which it may not be if the server has the same model."""
if isinstance(data, dict) and 'operator' in data: if isinstance(data, dict) and 'operator' in data:
return { return {
**data, **data,
'operator_id': data['operator'].get('id'),
'operator_domain': data['operator'].get('domain'),
'hysteria2_host': data['operator'].get('hysteria2_host'), 'hysteria2_host': data['operator'].get('hysteria2_host'),
} }
return data return data
@ -41,6 +36,16 @@ class HysteriaData(BaseModel):
return v return v
# 'operator_id': data['operator'].get('id'),
# 'operator_domain': data['operator'].get('domain'),
# location_country_code: str
# location_city_code: str
# operator_id: int
# # operator_name: str = None
# operator_domain: str = None
# likely future addition: # likely future addition:
# # @field_validator('operator_id') # # @field_validator('operator_id')
# # @classmethod # # @classmethod

View file

@ -1,4 +1,3 @@
# generic
from pydantic import BaseModel, field_validator, ValidationError, HttpUrl, ConfigDict, AnyUrl from pydantic import BaseModel, field_validator, ValidationError, HttpUrl, ConfigDict, AnyUrl
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from pydantic import model_validator, ValidationInfo from pydantic import model_validator, ValidationInfo
@ -10,27 +9,8 @@ import validators
class VlessData(BaseModel): class VlessData(BaseModel):
model_config = ConfigDict(extra="ignore") model_config = ConfigDict(extra="ignore")
username: str = None links: str
links: str # vless link
subscription_url: HttpUrl
server_ip: IPv4Address server_ip: IPv4Address
location_country_code: str
location_city_code: str
operator_id: int
operator_domain: str = None # redundant
vless_host: str = None # redundant
@model_validator(mode='before')
@classmethod
def denormalize(cls, data):
if isinstance(data, dict) and 'operator' in data:
return {
**data,
'operator_id': data['operator'].get('id'),
'operator_domain': data['operator'].get('domain'),
'vless_host': data['operator'].get('vless_host'),
}
return data
@field_validator('links', mode='before') @field_validator('links', mode='before')
@classmethod @classmethod
@ -41,4 +21,25 @@ class VlessData(BaseModel):
if not isinstance(v, str) or not v.startswith('vless://'): if not isinstance(v, str) or not v.startswith('vless://'):
raise ValueError('links must be a string starting with "vless://"') raise ValueError('links must be a string starting with "vless://"')
return v return v
# username: str = None
# subscription_url: HttpUrl
# location_country_code: str
# location_city_code: str
# operator_id: int
# operator_domain: str = None # redundant
# vless_host: str = None # redundant
# @model_validator(mode='before')
# @classmethod
# def denormalize(cls, data):
# if isinstance(data, dict) and 'operator' in data:
# return {
# **data,
# # 'operator_id': data['operator'].get('id'),
# # 'operator_domain': data['operator'].get('domain'),
# # 'vless_host': data['operator'].get('vless_host'),
# }
# return data

View file

@ -30,6 +30,8 @@ from core.observers.TicketObserver import TicketObserver
import os import os
QUANTITY_OF_TRIES = 2
def establish_connection( def establish_connection(
profile: Union[SessionProfile, SystemProfile], profile: Union[SessionProfile, SystemProfile],
ignore: tuple[type[Exception]] = (), ignore: tuple[type[Exception]] = (),
@ -82,24 +84,40 @@ def launch_encrypted_proxy(
""" """
# do they have a proxy config already? # do they have a proxy config already?
config_path = f'{profile.get_system_config_path()}/proxy.json' config_path = f'{profile.get_system_config_path()}/proxy.json'
tries = 0
exists_already = os.path.isfile(config_path) exists_already = os.path.isfile(config_path)
if exists_already: while tries < QUANTITY_OF_TRIES:
server_ip = get_value_from_json_file(filepath=config_path, category="outbounds", key="server") tries = tries + 1
else:
server_ip = setup_config_and_return_ip( if exists_already:
profile=profile, server_ip = get_value_from_json_file(filepath=config_path, category="outbounds", key="server")
else:
server_ip = setup_config_and_return_ip(
profile=profile,
connection_observer=connection_observer
)
print(f"We will be aiming to establish a connection to {server_ip}")
connection_result = start_singbox(
profile_id=profile.id,
server_ip=server_ip,
protocol_choice=profile.connection.code,
connection_observer=connection_observer connection_observer=connection_observer
) )
logger.info(f"We have a server IP now of {server_ip}") if connection_result.valid:
return connection_result
else:
logger.info("The existing config was invalid, trying a new config..")
exists_already = False
return start_singbox(
profile_id=profile.id, # removed = delete_file(config_path)
server_ip=server_ip, # if not removed:
protocol_choice=profile.connection.code, # return Result(valid=False, error_type=ResultError.FILE_SYSTEM, message="Can't delete an invalid config.")
connection_observer=connection_observer
)
def setup_config_and_return_ip( def setup_config_and_return_ip(

View file

@ -38,6 +38,13 @@ def configure_singbox(
profile: Union[SessionProfile, SystemProfile], profile: Union[SessionProfile, SystemProfile],
connection_observer: Optional[ConnectionObserver] = None, connection_observer: Optional[ConnectionObserver] = None,
) -> Result: ) -> Result:
"""
Orchestrates the setup of the config:
1. Networking to get data
2. Validate and save the data
3. Create a config with the server's data & client-side choices
4. Save the config
"""
################################### ###################################
# PREP PAYLOADS # PREP PAYLOADS

View file

@ -0,0 +1,18 @@
from core.errors.logger import logger
from pathlib import Path
def delete_file(file_path: str | Path) -> bool:
"""
Deletes a file at the specified path.
"""
try:
# Path(file_path) handles both string and Path object inputs
# missing_ok=True prevents FileNotFoundError from being raised
Path(file_path).unlink(missing_ok=True)
return True
except (PermissionError, OSError) as e:
# Log the error for debugging purposes
logging.error(f"Failed to delete {file_path}: {e}")
return False