diff --git a/core/scripts/hysteria2/show_user_uri.py b/core/scripts/hysteria2/show_user_uri.py index 2e61ea1..ffe3f0e 100644 --- a/core/scripts/hysteria2/show_user_uri.py +++ b/core/scripts/hysteria2/show_user_uri.py @@ -193,7 +193,7 @@ def show_uri(args: argparse.Namespace) -> None: if args.normalsub and is_service_active("hysteria-normal-sub.service"): domain, port, subpath = get_normalsub_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(): """Main function to parse arguments and show URIs.""" diff --git a/core/scripts/normalsub/normalsub.py b/core/scripts/normalsub/normalsub.py index 1e375c1..09cb8d0 100644 --- a/core/scripts/normalsub/normalsub.py +++ b/core/scripts/normalsub/normalsub.py @@ -21,13 +21,14 @@ load_dotenv() @dataclass class AppConfig: - domain: str - external_port: int + domain: str + external_port: int aiohttp_listen_address: str aiohttp_listen_port: int sni_file: str singbox_template_path: str hysteria_cli_path: str + users_json_path: str rate_limit: int rate_limit_window: int sni: str @@ -65,6 +66,7 @@ class UriComponents: @dataclass class UserInfo: username: str + password: str upload_bytes: int download_bytes: int max_download_bytes: int @@ -161,8 +163,9 @@ class Utils: class HysteriaCLI: - def __init__(self, cli_path: str): + def __init__(self, cli_path: str, users_json_path: str): self.cli_path = cli_path + self.users_json_path = users_json_path def _run_command(self, args: List[str]) -> str: try: @@ -171,7 +174,7 @@ class HysteriaCLI: stdout, stderr = process.communicate() if process.returncode != 0: if "User not found" in stderr: - return None + return None else: print(f"Hysteria CLI error: {stderr}") raise subprocess.CalledProcessError(process.returncode, command, output=stdout, stderr=stderr) @@ -180,14 +183,57 @@ class HysteriaCLI: print(f"Hysteria CLI error: {e}") 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]: raw_info_str = self._run_command(['get-user', '-u', username]) 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: raw_info = json.loads(raw_info_str) return UserInfo( username=username, + password=user_password, upload_bytes=raw_info.get('upload_bytes', 0), download_bytes=raw_info.get('download_bytes', 0), max_download_bytes=raw_info.get('max_download_bytes', 0), @@ -196,7 +242,7 @@ class HysteriaCLI: ) except json.JSONDecodeError as e: print(f"JSONDecodeError: {e}, Raw output: {raw_info_str}") - return None + return None def get_user_uri(self, username: str, ip_version: Optional[str] = None) -> str: if ip_version: @@ -376,7 +422,7 @@ class HysteriaServer: def __init__(self): self.config = self._load_config() 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.set_template_path(self.config.singbox_template_path) 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) 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_route('*', f'{base_path}/{{tail:.*}}', self.handle_404_subpath) - def _load_config(self) -> AppConfig: domain = os.getenv('HYSTERIA_DOMAIN', 'localhost') external_port = int(os.getenv('HYSTERIA_PORT', '443')) 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("/") if not subpath or not self.is_valid_subpath(subpath): @@ -409,18 +454,20 @@ class HysteriaServer: sni_file = '/etc/hysteria/.configs.env' singbox_template_path = '/etc/hysteria/core/scripts/normalsub/singbox.json' 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_window = 60 template_dir = os.path.dirname(__file__) sni = self._load_sni_from_env(sni_file) - return AppConfig(domain=domain, external_port=external_port, + return AppConfig(domain=domain, external_port=external_port, aiohttp_listen_address=aiohttp_listen_address, aiohttp_listen_port=aiohttp_listen_port, sni_file=sni_file, - singbox_template_path=singbox_template_path, + singbox_template_path=singbox_template_path, hysteria_cli_path=hysteria_cli_path, - rate_limit=rate_limit, rate_limit_window=rate_limit_window, + users_json_path=users_json_path, + rate_limit=rate_limit, rate_limit_window=rate_limit_window, sni=sni, template_dir=template_dir, subpath=subpath) @@ -435,19 +482,19 @@ class HysteriaServer: return "bts.com" def is_valid_subpath(self, subpath: str) -> bool: - return bool(re.match(r"^[a-zA-Z0-9]+$", subpath)) + return bool(re.match(r"^[a-zA-Z0-9]+$", subpath)) def validate_and_escape_subpath(self, subpath: str) -> str: if not self.is_valid_subpath(subpath): raise ValueError(f"Invalid subpath: {subpath}") - return re.escape(subpath) + return re.escape(subpath) @middleware async def _rate_limit_middleware(self, request: web.Request, handler): client_ip_hdr = request.headers.get('X-Forwarded-For', request.headers.get('X-Real-IP')) client_ip = client_ip_hdr.split(',')[0].strip() if client_ip_hdr else request.remote - if client_ip and not self.rate_limiter.check_limit(client_ip): + if client_ip and not self.rate_limiter.check_limit(client_ip): return web.Response(status=429, text="Rate limit exceeded.") return await handler(request) @@ -457,7 +504,7 @@ class HysteriaServer: if not request.path.startswith(expected_prefix): print(f"Warning: Request {request.path} reached aiohttp outside expected subpath {expected_prefix}. Closing connection.") if request.transport is not None: - request.transport.close() + request.transport.close() raise web.HTTPForbidden() return await handler(request) @@ -469,22 +516,27 @@ class HysteriaServer: async def handle(self, request: web.Request) -> web.Response: try: - username_raw = request.match_info.get('username', '') - if not username_raw: # Should not happen due to route def - return web.Response(status=400, text="Error: Missing 'username' parameter.") - username = Utils.sanitize_input(username_raw, r'^[a-zA-Z0-9_-]+$') + password_token_raw = request.match_info.get('password_token', '') + if not password_token_raw: + return web.Response(status=400, text="Error: Missing 'password_token' parameter.") + + 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_info = self.hysteria_cli.get_user_info(username) 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']): - return await self._handle_html(request, username, user_info) + return await self._handle_html(request, username, user_info) fragment = request.query.get('fragment', '') if not user_agent.startswith('hiddifynext') and ('singbox' in user_agent or 'sing' in user_agent): - return await self._handle_singbox(username, fragment, user_info) - return await self._handle_normalsub(request, username, user_info) + return await self._handle_singbox(username, fragment, user_info) + return await self._handle_normalsub(request, username, user_info) except ValueError as e: return web.Response(status=400, text=f"Error: {e}") except Exception as e: @@ -492,7 +544,7 @@ class HysteriaServer: return web.Response(status=500, text="Error: Internal server error") async def _handle_html(self, request: web.Request, username: str, user_info: UserInfo) -> web.Response: - context = await self._get_template_context(username, user_info) + context = await self._get_template_context(username, user_info) return web.Response(text=self.template_renderer.render(context), content_type='text/html') async def _handle_singbox(self, username: str, fragment: str, user_info: UserInfo) -> web.Response: @@ -506,7 +558,7 @@ class HysteriaServer: async def _handle_normalsub(self, request: web.Request, username: str, user_info: UserInfo) -> web.Response: user_agent = request.headers.get('User-Agent', '').lower() 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(text=subscription, content_type='text/plain') @@ -518,7 +570,7 @@ class HysteriaServer: if not Utils.is_valid_url(base_url): 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) ipv6_qrcode = Utils.generate_qrcode_base64(ipv6_uri) @@ -546,10 +598,10 @@ class HysteriaServer: def run(self): 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/") web.run_app( - self.app, - host=self.config.aiohttp_listen_address, + self.app, + host=self.config.aiohttp_listen_address, port=self.config.aiohttp_listen_port )