feat(normalsub): use user password in subscription link path

Refactored normalsub.py to use the user's password as the identifier in the subscription URL path instead of the username, enhancing privacy by making user enumeration harder.
This commit is contained in:
Whispering Wind
2025-05-27 20:59:37 +03:30
committed by GitHub
parent 2168080843
commit 475d511345
2 changed files with 84 additions and 32 deletions

View File

@ -193,7 +193,7 @@ def show_uri(args: argparse.Namespace) -> None:
if args.normalsub and is_service_active("hysteria-normal-sub.service"): if args.normalsub and is_service_active("hysteria-normal-sub.service"):
domain, port, subpath = get_normalsub_domain_and_port() domain, port, subpath = get_normalsub_domain_and_port()
if domain and port: if domain and port:
print(f"\nNormal-SUB Sublink:\nhttps://{domain}:{port}/{subpath}/sub/normal/{args.username}#Hysteria2\n") print(f"\nNormal-SUB Sublink:\nhttps://{domain}:{port}/{subpath}/sub/normal/{auth_password}#Hysteria2\n")
def main(): def main():
"""Main function to parse arguments and show URIs.""" """Main function to parse arguments and show URIs."""

View File

@ -28,6 +28,7 @@ class AppConfig:
sni_file: str sni_file: str
singbox_template_path: str singbox_template_path: str
hysteria_cli_path: str hysteria_cli_path: str
users_json_path: str
rate_limit: int rate_limit: int
rate_limit_window: int rate_limit_window: int
sni: str sni: str
@ -65,6 +66,7 @@ class UriComponents:
@dataclass @dataclass
class UserInfo: class UserInfo:
username: str username: str
password: str
upload_bytes: int upload_bytes: int
download_bytes: int download_bytes: int
max_download_bytes: int max_download_bytes: int
@ -161,8 +163,9 @@ class Utils:
class HysteriaCLI: class HysteriaCLI:
def __init__(self, cli_path: str): def __init__(self, cli_path: str, users_json_path: str):
self.cli_path = cli_path self.cli_path = cli_path
self.users_json_path = users_json_path
def _run_command(self, args: List[str]) -> str: def _run_command(self, args: List[str]) -> str:
try: try:
@ -180,14 +183,57 @@ class HysteriaCLI:
print(f"Hysteria CLI error: {e}") print(f"Hysteria CLI error: {e}")
raise raise
def get_user_password(self, username: str) -> Optional[str]:
try:
with open(self.users_json_path, 'r') as f:
users_data = json.load(f)
user_details = users_data.get(username)
if user_details and 'password' in user_details:
return user_details['password']
return None
except FileNotFoundError:
print(f"Error: Users file not found at {self.users_json_path}")
return None
except json.JSONDecodeError:
print(f"Error: Could not decode JSON from {self.users_json_path}")
return None
except Exception as e:
print(f"An unexpected error occurred while reading users file for password: {e}")
return None
def get_username_by_password(self, password_token: str) -> Optional[str]:
try:
with open(self.users_json_path, 'r') as f:
users_data = json.load(f)
for username, details in users_data.items():
if details.get('password') == password_token:
return username
return None
except FileNotFoundError:
print(f"Error: Users file not found at {self.users_json_path}")
return None
except json.JSONDecodeError:
print(f"Error: Could not decode JSON from {self.users_json_path}")
return None
except Exception as e:
print(f"An unexpected error occurred while reading users file: {e}")
return None
def get_user_info(self, username: str) -> Optional[UserInfo]: def get_user_info(self, username: str) -> Optional[UserInfo]:
raw_info_str = self._run_command(['get-user', '-u', username]) raw_info_str = self._run_command(['get-user', '-u', username])
if raw_info_str is None: if raw_info_str is None:
return None return None
user_password = self.get_user_password(username)
if user_password is None:
print(f"Warning: Password for user '{username}' could not be fetched from {self.users_json_path}. Cannot create UserInfo.")
return None
try: try:
raw_info = json.loads(raw_info_str) raw_info = json.loads(raw_info_str)
return UserInfo( return UserInfo(
username=username, username=username,
password=user_password,
upload_bytes=raw_info.get('upload_bytes', 0), upload_bytes=raw_info.get('upload_bytes', 0),
download_bytes=raw_info.get('download_bytes', 0), download_bytes=raw_info.get('download_bytes', 0),
max_download_bytes=raw_info.get('max_download_bytes', 0), max_download_bytes=raw_info.get('max_download_bytes', 0),
@ -376,7 +422,7 @@ class HysteriaServer:
def __init__(self): def __init__(self):
self.config = self._load_config() self.config = self._load_config()
self.rate_limiter = RateLimiter(self.config.rate_limit, self.config.rate_limit_window) self.rate_limiter = RateLimiter(self.config.rate_limit, self.config.rate_limit_window)
self.hysteria_cli = HysteriaCLI(self.config.hysteria_cli_path) self.hysteria_cli = HysteriaCLI(self.config.hysteria_cli_path, self.config.users_json_path)
self.singbox_generator = SingboxConfigGenerator(self.hysteria_cli, self.config.sni) self.singbox_generator = SingboxConfigGenerator(self.hysteria_cli, self.config.sni)
self.singbox_generator.set_template_path(self.config.singbox_template_path) self.singbox_generator.set_template_path(self.config.singbox_template_path)
self.subscription_manager = SubscriptionManager(self.hysteria_cli, self.config) self.subscription_manager = SubscriptionManager(self.hysteria_cli, self.config)
@ -390,16 +436,15 @@ class HysteriaServer:
safe_subpath = self.validate_and_escape_subpath(self.config.subpath) safe_subpath = self.validate_and_escape_subpath(self.config.subpath)
base_path = f'/{safe_subpath}' base_path = f'/{safe_subpath}'
self.app.router.add_get(f'{base_path}/sub/normal/{{username}}', self.handle) self.app.router.add_get(f'{base_path}/sub/normal/{{password_token}}', self.handle)
self.app.router.add_get(f'{base_path}/robots.txt', self.robots_handler) self.app.router.add_get(f'{base_path}/robots.txt', self.robots_handler)
self.app.router.add_route('*', f'{base_path}/{{tail:.*}}', self.handle_404_subpath) self.app.router.add_route('*', f'{base_path}/{{tail:.*}}', self.handle_404_subpath)
def _load_config(self) -> AppConfig: def _load_config(self) -> AppConfig:
domain = os.getenv('HYSTERIA_DOMAIN', 'localhost') domain = os.getenv('HYSTERIA_DOMAIN', 'localhost')
external_port = int(os.getenv('HYSTERIA_PORT', '443')) external_port = int(os.getenv('HYSTERIA_PORT', '443'))
aiohttp_listen_address = os.getenv('AIOHTTP_LISTEN_ADDRESS', '127.0.0.1') aiohttp_listen_address = os.getenv('AIOHTTP_LISTEN_ADDRESS', '127.0.0.1')
aiohttp_listen_port = int(os.getenv('AIOHTTP_LISTEN_PORT', '28261')) aiohttp_listen_port = int(os.getenv('AIOHTTP_LISTEN_PORT', '33261'))
subpath = os.getenv('SUBPATH', '').strip().strip("/") subpath = os.getenv('SUBPATH', '').strip().strip("/")
if not subpath or not self.is_valid_subpath(subpath): if not subpath or not self.is_valid_subpath(subpath):
@ -409,6 +454,7 @@ class HysteriaServer:
sni_file = '/etc/hysteria/.configs.env' sni_file = '/etc/hysteria/.configs.env'
singbox_template_path = '/etc/hysteria/core/scripts/normalsub/singbox.json' singbox_template_path = '/etc/hysteria/core/scripts/normalsub/singbox.json'
hysteria_cli_path = '/etc/hysteria/core/cli.py' hysteria_cli_path = '/etc/hysteria/core/cli.py'
users_json_path = os.getenv('HYSTERIA_USERS_JSON_PATH', '/etc/hysteria/users.json')
rate_limit = 100 rate_limit = 100
rate_limit_window = 60 rate_limit_window = 60
template_dir = os.path.dirname(__file__) template_dir = os.path.dirname(__file__)
@ -420,6 +466,7 @@ class HysteriaServer:
sni_file=sni_file, sni_file=sni_file,
singbox_template_path=singbox_template_path, singbox_template_path=singbox_template_path,
hysteria_cli_path=hysteria_cli_path, hysteria_cli_path=hysteria_cli_path,
users_json_path=users_json_path,
rate_limit=rate_limit, rate_limit_window=rate_limit_window, rate_limit=rate_limit, rate_limit_window=rate_limit_window,
sni=sni, template_dir=template_dir, sni=sni, template_dir=template_dir,
subpath=subpath) subpath=subpath)
@ -469,15 +516,20 @@ class HysteriaServer:
async def handle(self, request: web.Request) -> web.Response: async def handle(self, request: web.Request) -> web.Response:
try: try:
username_raw = request.match_info.get('username', '') password_token_raw = request.match_info.get('password_token', '')
if not username_raw: # Should not happen due to route def if not password_token_raw:
return web.Response(status=400, text="Error: Missing 'username' parameter.") return web.Response(status=400, text="Error: Missing 'password_token' parameter.")
username = Utils.sanitize_input(username_raw, r'^[a-zA-Z0-9_-]+$')
password_token = Utils.sanitize_input(password_token_raw, r'^[a-zA-Z0-9]+$')
username = self.hysteria_cli.get_username_by_password(password_token)
if username is None:
return web.Response(status=404, text="User not found for the provided token.")
user_agent = request.headers.get('User-Agent', '').lower() user_agent = request.headers.get('User-Agent', '').lower()
user_info = self.hysteria_cli.get_user_info(username) user_info = self.hysteria_cli.get_user_info(username)
if user_info is None: if user_info is None:
return web.Response(status=404, text=f"User '{username}' not found.") return web.Response(status=404, text=f"User '{username}' details not found.")
if any(browser in user_agent for browser in ['chrome', 'firefox', 'safari', 'edge', 'opera']): if any(browser in user_agent for browser in ['chrome', 'firefox', 'safari', 'edge', 'opera']):
return await self._handle_html(request, username, user_info) return await self._handle_html(request, username, user_info)
@ -506,7 +558,7 @@ class HysteriaServer:
async def _handle_normalsub(self, request: web.Request, username: str, user_info: UserInfo) -> web.Response: async def _handle_normalsub(self, request: web.Request, username: str, user_info: UserInfo) -> web.Response:
user_agent = request.headers.get('User-Agent', '').lower() user_agent = request.headers.get('User-Agent', '').lower()
subscription = self.subscription_manager.get_normal_subscription(username, user_agent) subscription = self.subscription_manager.get_normal_subscription(username, user_agent)
if subscription == "User not found": if subscription == "User not found": # Should be caught earlier by user_info check
return web.Response(status=404, text=f"User '{username}' not found.") return web.Response(status=404, text=f"User '{username}' not found.")
return web.Response(text=subscription, content_type='text/plain') return web.Response(text=subscription, content_type='text/plain')
@ -518,7 +570,7 @@ class HysteriaServer:
if not Utils.is_valid_url(base_url): if not Utils.is_valid_url(base_url):
print(f"Warning: Constructed base URL '{base_url}' might be invalid. Check domain and port config.") print(f"Warning: Constructed base URL '{base_url}' might be invalid. Check domain and port config.")
sub_link = f"{base_url}/{self.config.subpath}/sub/normal/{username}" sub_link = f"{base_url}/{self.config.subpath}/sub/normal/{user_info.password}"
ipv4_qrcode = Utils.generate_qrcode_base64(ipv4_uri) ipv4_qrcode = Utils.generate_qrcode_base64(ipv4_uri)
ipv6_qrcode = Utils.generate_qrcode_base64(ipv6_uri) ipv6_qrcode = Utils.generate_qrcode_base64(ipv6_uri)
@ -546,7 +598,7 @@ class HysteriaServer:
def run(self): def run(self):
print(f"Starting Hysteria Normalsub server on {self.config.aiohttp_listen_address}:{self.config.aiohttp_listen_port}") print(f"Starting Hysteria Normalsub server on {self.config.aiohttp_listen_address}:{self.config.aiohttp_listen_port}")
print(f"External access via Caddy should be at https://{self.config.domain}:{self.config.external_port}/{self.config.subpath}/") print(f"External access via Caddy should be at https://{self.config.domain}:{self.config.external_port}/{self.config.subpath}/sub/normal/<USER_PASSWORD>")
web.run_app( web.run_app(
self.app, self.app,
host=self.config.aiohttp_listen_address, host=self.config.aiohttp_listen_address,