diff --git a/changelog b/changelog index 530883e..091d31b 100644 --- a/changelog +++ b/changelog @@ -1,7 +1,10 @@ -# [1.12.1] - 2025-07-09 +# [1.13.0] - 2025-08-10 #### ✨ UI Enhancements -* 📌 **Sticky Sidebar:** Sidebar now stays fixed when scrolling through long pages for easier navigation -* 🃏 **Sticky Headers:** Card headers are now sticky with a sleek **bokeh blur** effect – better usability on settings and user lists -* ⏎ **Login UX:** Pressing **Enter** now submits the login form properly for faster access +* ✨ feat(core): Implement external node management system +* 🌐 feat(api): Add external node management endpoints +* 🔗 feat(normalsub): Add external node URIs to subscriptions +* 💾 feat: Backup nodes.json +* 🛠️ fix: Robust parsing, unlimited user handling, and various script/UI issues +* 📦 chore: Dependency updates (pytelegrambotapi, aiohttp) diff --git a/core/cli.py b/core/cli.py index 320ca19..ece85fd 100644 --- a/core/cli.py +++ b/core/cli.py @@ -298,6 +298,41 @@ def ip_address(edit: bool, ipv4: str, ipv6: str): click.echo(f'{e}', err=True) +@cli.group() +def node(): + """Manage external node IPs for multi-server setups.""" + pass + +@node.command('add') +@click.option('--name', required=True, type=str, help='A unique name for the node (e.g., "Node-DE").') +@click.option('--ip', required=True, type=str, help='The public IP address of the node.') +def add_node(name, ip): + """Add a new external node.""" + try: + output = cli_api.add_node(name, ip) + click.echo(output.strip()) + except Exception as e: + click.echo(f'{e}', err=True) + +@node.command('delete') +@click.option('--name', required=True, type=str, help='The name of the node to delete.') +def delete_node(name): + """Delete an external node by its name.""" + try: + output = cli_api.delete_node(name) + click.echo(output.strip()) + except Exception as e: + click.echo(f'{e}', err=True) + +@node.command('list') +def list_nodes(): + """List all configured external nodes.""" + try: + output = cli_api.list_nodes() + click.echo(output.strip()) + except Exception as e: + click.echo(f'{e}', err=True) + @cli.command('update-geo') @click.option('--country', '-c', type=click.Choice(['iran', 'china', 'russia'], case_sensitive=False), @@ -323,9 +358,6 @@ def masquerade(remove: bool, enable: str): raise click.UsageError('Error: You cannot use both --remove and --enable at the same time') if enable: - # NOT SURE THIS IS NEEDED - # if not enable.startswith('http://') and not enable.startswith('https://'): - # enable = 'https://' + enable cli_api.enable_hysteria2_masquerade(enable) click.echo('Masquerade enabled successfully.') elif remove: @@ -651,4 +683,4 @@ def config_ip_limit(block_duration: int, max_ips: int): if __name__ == '__main__': - cli() + cli() \ No newline at end of file diff --git a/core/cli_api.py b/core/cli_api.py index 28c998b..9eb6fc8 100644 --- a/core/cli_api.py +++ b/core/cli_api.py @@ -14,6 +14,7 @@ CONFIG_FILE = '/etc/hysteria/config.json' CONFIG_ENV_FILE = '/etc/hysteria/.configs.env' WEBPANEL_ENV_FILE = '/etc/hysteria/core/scripts/webpanel/.env' NORMALSUB_ENV_FILE = '/etc/hysteria/core/scripts/normalsub/.env' +NODES_JSON_PATH = "/etc/hysteria/nodes.json" class Command(Enum): @@ -32,6 +33,7 @@ class Command(Enum): SHOW_USER_URI = os.path.join(SCRIPT_DIR, 'hysteria2', 'show_user_uri.py') WRAPPER_URI = os.path.join(SCRIPT_DIR, 'hysteria2', 'wrapper_uri.py') IP_ADD = os.path.join(SCRIPT_DIR, 'hysteria2', 'ip.py') + NODE_MANAGER = os.path.join(SCRIPT_DIR, 'hysteria2', 'node.py') MANAGE_OBFS = os.path.join(SCRIPT_DIR, 'hysteria2', 'manage_obfs.py') MASQUERADE_SCRIPT = os.path.join(SCRIPT_DIR, 'hysteria2', 'masquerade.py') TRAFFIC_STATUS = 'traffic.py' # won't be called directly (it's a python module) @@ -281,20 +283,22 @@ def edit_user(username: str, new_username: str | None, new_traffic_limit: int | ''' if not username: raise InvalidInputError('Error: username is required') - if not any([new_username, new_traffic_limit, new_expiration_days, renew_password, renew_creation_date, blocked is not None]): # type: ignore - raise InvalidInputError('Error: at least one option is required') - if new_traffic_limit is not None and new_traffic_limit <= 0: - raise InvalidInputError('Error: traffic limit must be greater than 0') - if new_expiration_days is not None and new_expiration_days <= 0: - raise InvalidInputError('Error: expiration days must be greater than 0') + + if new_traffic_limit is not None and new_traffic_limit < 0: + raise InvalidInputError('Error: traffic limit must be a non-negative number.') + if new_expiration_days is not None and new_expiration_days < 0: + raise InvalidInputError('Error: expiration days must be a non-negative number.') + if renew_password: password = generate_password() else: password = '' + if renew_creation_date: creation_date = datetime.now().strftime('%Y-%m-%d') else: creation_date = '' + command_args = [ 'bash', Command.EDIT_USER.value, @@ -426,6 +430,23 @@ def edit_ip_address(ipv4: str, ipv6: str): if ipv6: run_cmd(['python3', Command.IP_ADD.value, 'edit', '-6', ipv6]) +def add_node(name: str, ip: str): + """ + Adds a new external node. + """ + return run_cmd(['python3', Command.NODE_MANAGER.value, 'add', '--name', name, '--ip', ip]) + +def delete_node(name: str): + """ + Deletes an external node by name. + """ + return run_cmd(['python3', Command.NODE_MANAGER.value, 'delete', '--name', name]) + +def list_nodes(): + """ + Lists all configured external nodes. + """ + return run_cmd(['python3', Command.NODE_MANAGER.value, 'list']) def update_geo(country: str): ''' diff --git a/core/scripts/hysteria2/edit_user.sh b/core/scripts/hysteria2/edit_user.sh index d409d3d..e81ed45 100644 --- a/core/scripts/hysteria2/edit_user.sh +++ b/core/scripts/hysteria2/edit_user.sh @@ -20,11 +20,11 @@ validate_username() { validate_traffic_limit() { local traffic_limit=$1 - if [ -z "$traffic_limit" ]; then - return 0 # Optional value is valid + if [ -z "$traffic_limit" ]; then + return 0 # Optional value is valid fi if ! [[ "$traffic_limit" =~ ^[0-9]+$ ]]; then - echo "Traffic limit must be a valid integer." + echo "Error: Traffic limit must be a valid non-negative number (use 0 for unlimited)." return 1 fi return 0 @@ -32,11 +32,11 @@ validate_traffic_limit() { validate_expiration_days() { local expiration_days=$1 - if [ -z "$expiration_days" ]; then + if [ -z "$expiration_days" ]; then return 0 # Optional value is valid fi if ! [[ "$expiration_days" =~ ^[0-9]+$ ]]; then - echo "Expiration days must be a valid integer." + echo "Error: Expiration days must be a valid non-negative number (use 0 for unlimited)." return 1 fi return 0 @@ -237,4 +237,4 @@ edit_user() { # Run the script -edit_user "$1" "$2" "$3" "$4" "$5" "$6" "$7" +edit_user "$1" "$2" "$3" "$4" "$5" "$6" "$7" \ No newline at end of file diff --git a/core/scripts/hysteria2/node.py b/core/scripts/hysteria2/node.py new file mode 100644 index 0000000..1de692f --- /dev/null +++ b/core/scripts/hysteria2/node.py @@ -0,0 +1,118 @@ +#!/usr/bin/env python3 + +import sys +import json +import argparse +from pathlib import Path +import re +from ipaddress import ip_address + +core_scripts_dir = Path(__file__).resolve().parents[1] +if str(core_scripts_dir) not in sys.path: + sys.path.append(str(core_scripts_dir)) + +try: + from paths import NODES_JSON_PATH +except ImportError: + NODES_JSON_PATH = Path("/etc/hysteria/nodes.json") + + +def is_valid_ip_or_domain(value: str) -> bool: + """Check if the value is a valid IP address or domain name.""" + if not value or not value.strip(): + return False + value = value.strip() + try: + ip_address(value) + return True + except ValueError: + domain_regex = re.compile( + r'^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z0-9][a-z0-9-]{0,61}[a-z0-9]$', + re.IGNORECASE + ) + return re.match(domain_regex, value) is not None + +def read_nodes(): + if not NODES_JSON_PATH.exists(): + return [] + try: + with NODES_JSON_PATH.open("r") as f: + content = f.read() + if not content: + return [] + return json.loads(content) + except (json.JSONDecodeError, IOError, OSError) as e: + sys.exit(f"Error reading or parsing {NODES_JSON_PATH}: {e}") + +def write_nodes(nodes): + try: + NODES_JSON_PATH.parent.mkdir(parents=True, exist_ok=True) + with NODES_JSON_PATH.open("w") as f: + json.dump(nodes, f, indent=4) + except (IOError, OSError) as e: + sys.exit(f"Error writing to {NODES_JSON_PATH}: {e}") + +def add_node(name: str, ip: str): + if not is_valid_ip_or_domain(ip): + print(f"Error: '{ip}' is not a valid IP address or domain name.", file=sys.stderr) + sys.exit(1) + + nodes = read_nodes() + if any(node['name'] == name for node in nodes): + print(f"Error: A node with the name '{name}' already exists.", file=sys.stderr) + sys.exit(1) + if any(node['ip'] == ip for node in nodes): + print(f"Error: A node with the IP/domain '{ip}' already exists.", file=sys.stderr) + sys.exit(1) + + nodes.append({"name": name, "ip": ip}) + write_nodes(nodes) + print(f"Successfully added node '{name}' with IP/domain '{ip}'.") + +def delete_node(name: str): + nodes = read_nodes() + original_count = len(nodes) + nodes = [node for node in nodes if node['name'] != name] + + if len(nodes) == original_count: + print(f"Error: No node with the name '{name}' found.", file=sys.stderr) + sys.exit(1) + + write_nodes(nodes) + print(f"Successfully deleted node '{name}'.") + +def list_nodes(): + nodes = read_nodes() + if not nodes: + print("No nodes configured.") + return + + print(f"{'Name':<30} {'IP Address / Domain'}") + print(f"{'-'*30} {'-'*25}") + for node in sorted(nodes, key=lambda x: x['name']): + print(f"{node['name']:<30} {node['ip']}") + +def main(): + parser = argparse.ArgumentParser(description="Manage external node configurations.") + subparsers = parser.add_subparsers(dest='command', required=True) + + add_parser = subparsers.add_parser('add', help='Add a new node.') + add_parser.add_argument('--name', type=str, required=True, help='The unique name of the node.') + add_parser.add_argument('--ip', type=str, required=True, help='The IP address or domain of the node.') + + delete_parser = subparsers.add_parser('delete', help='Delete a node by name.') + delete_parser.add_argument('--name', type=str, required=True, help='The name of the node to delete.') + + subparsers.add_parser('list', help='List all configured nodes.') + + args = parser.parse_args() + + if args.command == 'add': + add_node(args.name, args.ip) + elif args.command == 'delete': + delete_node(args.name) + elif args.command == 'list': + list_nodes() + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/core/scripts/hysteria2/show_user_uri.py b/core/scripts/hysteria2/show_user_uri.py index ffe3f0e..d841996 100644 --- a/core/scripts/hysteria2/show_user_uri.py +++ b/core/scripts/hysteria2/show_user_uri.py @@ -24,6 +24,18 @@ def load_env_file(env_file: str) -> Dict[str, str]: env_vars[key] = value return env_vars +def load_nodes() -> List[Dict[str, str]]: + """Load external node information from the nodes JSON file.""" + if NODES_JSON_PATH.exists(): + try: + with NODES_JSON_PATH.open("r") as f: + content = f.read() + if content: + return json.loads(content) + except (json.JSONDecodeError, IOError): + pass + return [] + def load_hysteria2_env() -> Dict[str, str]: """Load Hysteria2 environment variables.""" return load_env_file(CONFIG_ENV) @@ -63,7 +75,8 @@ def is_service_active(service_name: str) -> bool: return False def generate_uri(username: str, auth_password: str, ip: str, port: str, - obfs_password: str, sha256: str, sni: str, ip_version: int, insecure: bool) -> str: + obfs_password: str, sha256: str, sni: str, ip_version: int, + insecure: bool, fragment_tag: str) -> str: """Generate Hysteria2 URI for the given parameters.""" uri_base = f"hy2://{username}%3A{auth_password}@{ip}:{port}" @@ -82,7 +95,7 @@ def generate_uri(username: str, auth_password: str, ip: str, port: str, params.append(f"insecure={insecure_value}&sni={sni}") params_str = "&".join(params) - return f"{uri_base}?{params_str}#{username}-IPv{ip_version}" + return f"{uri_base}?{params_str}#{fragment_tag}" def generate_qr_code(uri: str) -> List[str]: """Generate terminal-friendly ASCII QR code using pure Python.""" @@ -113,8 +126,21 @@ def get_terminal_width() -> int: except (AttributeError, OSError): return 80 +def display_uri_and_qr(uri: str, label: str, args: argparse.Namespace, terminal_width: int): + """Helper function to print URI and its QR code.""" + if not uri: + return + + print(f"\n{label}:\n{uri}\n") + + if args.qrcode: + print(f"{label} QR Code:\n") + qr_code = generate_qr_code(uri) + for line in qr_code: + print(center_text(line, terminal_width)) + def show_uri(args: argparse.Namespace) -> None: - """Show URI and optional QR codes for the given username.""" + """Show URI and optional QR codes for the given username and nodes.""" if not os.path.exists(USERS_FILE): print(f"\033[0;31mError:\033[0m Config file {USERS_FILE} not found.") return @@ -137,54 +163,37 @@ def show_uri(args: argparse.Namespace) -> None: port = config["listen"].split(":")[1] if ":" in config["listen"] else config["listen"] sha256 = config.get("tls", {}).get("pinSHA256", "") obfs_password = config.get("obfs", {}).get("salamander", {}).get("password", "") - insecure = config.get("tls", {}).get("insecure", True) ip4, ip6, sni = load_hysteria2_ips() - available_ip4 = ip4 and ip4 != "None" - available_ip6 = ip6 and ip6 != "None" - - uri_ipv4 = None - uri_ipv6 = None - - if args.all: - if available_ip4: - uri_ipv4 = generate_uri(args.username, auth_password, ip4, port, - obfs_password, sha256, sni, 4, insecure) - print(f"\nIPv4:\n{uri_ipv4}\n") + nodes = load_nodes() + terminal_width = get_terminal_width() + + if args.all or args.ip_version == 4: + if ip4 and ip4 != "None": + uri = generate_uri(args.username, auth_password, ip4, port, + obfs_password, sha256, sni, 4, insecure, f"{args.username}-IPv4") + display_uri_and_qr(uri, "IPv4", args, terminal_width) + + if args.all or args.ip_version == 6: + if ip6 and ip6 != "None": + uri = generate_uri(args.username, auth_password, ip6, port, + obfs_password, sha256, sni, 6, insecure, f"{args.username}-IPv6") + display_uri_and_qr(uri, "IPv6", args, terminal_width) + + for node in nodes: + node_name = node.get("name") + node_ip = node.get("ip") + if not node_name or not node_ip: + continue + + ip_v = 4 if '.' in node_ip else 6 - if available_ip6: - uri_ipv6 = generate_uri(args.username, auth_password, ip6, port, - obfs_password, sha256, sni, 6, insecure) - print(f"\nIPv6:\n{uri_ipv6}\n") - else: - if args.ip_version == 4 and available_ip4: - uri_ipv4 = generate_uri(args.username, auth_password, ip4, port, - obfs_password, sha256, sni, 4, insecure) - print(f"\nIPv4:\n{uri_ipv4}\n") - elif args.ip_version == 6 and available_ip6: - uri_ipv6 = generate_uri(args.username, auth_password, ip6, port, - obfs_password, sha256, sni, 6, insecure) - print(f"\nIPv6:\n{uri_ipv6}\n") - else: - print("Invalid IP version or no available IP for the requested version.") - return - - if args.qrcode: - terminal_width = get_terminal_width() - - if uri_ipv4: - qr_code = generate_qr_code(uri_ipv4) - print("\nIPv4 QR Code:\n") - for line in qr_code: - print(center_text(line, terminal_width)) - - if uri_ipv6: - qr_code = generate_qr_code(uri_ipv6) - print("\nIPv6 QR Code:\n") - for line in qr_code: - print(center_text(line, terminal_width)) - + if args.all or args.ip_version == ip_v: + uri = generate_uri(args.username, auth_password, node_ip, port, + obfs_password, sha256, sni, ip_v, insecure, f"{args.username}-{node_name}") + display_uri_and_qr(uri, f"Node: {node_name} (IPv{ip_v})", args, terminal_width) + if args.singbox and is_service_active("hysteria-singbox.service"): domain, port = get_singbox_domain_and_port() if domain and port: diff --git a/core/scripts/hysteria2/user.sh b/core/scripts/hysteria2/user.sh index 67cf08e..38baec2 100644 --- a/core/scripts/hysteria2/user.sh +++ b/core/scripts/hysteria2/user.sh @@ -13,13 +13,13 @@ MAX_DOWNLOAD_BYTES=$(jq -r --arg user "$USERNAME" '.[$user].max_download_bytes' EXPIRATION_DAYS=$(jq -r --arg user "$USERNAME" '.[$user].expiration_days' "$USERS_FILE") ACCOUNT_CREATION_DATE=$(jq -r --arg user "$USERNAME" '.[$user].account_creation_date' "$USERS_FILE") BLOCKED=$(jq -r --arg user "$USERNAME" '.[$user].blocked' "$USERS_FILE") -CURRENT_DOWNLOAD_BYTES=$(jq -r --arg user "$USERNAME" '.[$user].download_bytes' "$USERS_FILE") -CURRENT_UPLOAD_BYTES=$(jq -r --arg user "$USERNAME" '.[$user].upload_bytes' "$USERS_FILE") +CURRENT_DOWNLOAD_BYTES=$(jq -r --arg user "$USERNAME" '.[$user].download_bytes // 0' "$USERS_FILE") +CURRENT_UPLOAD_BYTES=$(jq -r --arg user "$USERNAME" '.[$user].upload_bytes // 0' "$USERS_FILE") TOTAL_BYTES=$((CURRENT_DOWNLOAD_BYTES + CURRENT_UPLOAD_BYTES)) if [ "$BLOCKED" == "true" ]; then - sleep 20 + sleep 20 exit 1 fi @@ -28,22 +28,26 @@ if [ "$STORED_PASSWORD" != "$PASSWORD" ]; then exit 1 fi -CURRENT_DATE=$(date +%s) -EXPIRATION_DATE=$(date -d "$ACCOUNT_CREATION_DATE + $EXPIRATION_DAYS days" +%s) +if [ "$EXPIRATION_DAYS" -ne 0 ]; then + CURRENT_DATE=$(date +%s) + EXPIRATION_DATE=$(date -d "$ACCOUNT_CREATION_DATE + $EXPIRATION_DAYS days" +%s) -if [ "$CURRENT_DATE" -ge "$EXPIRATION_DATE" ]; then - jq --arg user "$USERNAME" '.[$user].blocked = true' "$USERS_FILE" > temp.json && mv temp.json "$USERS_FILE" - exit 1 + if [ "$CURRENT_DATE" -ge "$EXPIRATION_DATE" ]; then + jq --arg user "$USERNAME" '.[$user].blocked = true' "$USERS_FILE" > temp.json && mv temp.json "$USERS_FILE" + exit 1 + fi fi -if [ "$TOTAL_BYTES" -ge "$MAX_DOWNLOAD_BYTES" ]; then - SECRET=$(jq -r '.trafficStats.secret' "$CONFIG_FILE") - KICK_ENDPOINT="http://127.0.0.1:25413/kick" - curl -s -H "Authorization: $SECRET" -X POST -d "[\"$USERNAME\"]" "$KICK_ENDPOINT" +if [ "$MAX_DOWNLOAD_BYTES" -ne 0 ]; then + if [ "$TOTAL_BYTES" -ge "$MAX_DOWNLOAD_BYTES" ]; then + SECRET=$(jq -r '.trafficStats.secret' "$CONFIG_FILE") + KICK_ENDPOINT="http://127.0.0.1:25413/kick" + curl -s -H "Authorization: $SECRET" -X POST -d "[\"$USERNAME\"]" "$KICK_ENDPOINT" - jq --arg user "$USERNAME" '.[$user].blocked = true' "$USERS_FILE" > temp.json && mv temp.json "$USERS_FILE" - exit 1 + jq --arg user "$USERNAME" '.[$user].blocked = true' "$USERS_FILE" > temp.json && mv temp.json "$USERS_FILE" + exit 1 + fi fi echo "$USERNAME" -exit 0 +exit 0 \ No newline at end of file diff --git a/core/scripts/hysteria2/wrapper_uri.py b/core/scripts/hysteria2/wrapper_uri.py index f898f9d..86ae88b 100644 --- a/core/scripts/hysteria2/wrapper_uri.py +++ b/core/scripts/hysteria2/wrapper_uri.py @@ -24,8 +24,8 @@ def parse_output(username, output): ipv4 = None ipv6 = None normal_sub = None + nodes = [] - # Match links ipv4_match = re.search(r"IPv4:\s*(hy2://[^\s]+)", output) ipv6_match = re.search(r"IPv6:\s*(hy2://[^\s]+)", output) normal_sub_match = re.search(r"Normal-SUB Sublink:\s*(https?://[^\s]+)", output) @@ -37,10 +37,16 @@ def parse_output(username, output): if normal_sub_match: normal_sub = normal_sub_match.group(1) + node_matches = re.findall(r"Node: (.+?) \(IPv[46]\):\s*(hy2://[^\s]+)", output) + for name, uri in node_matches: + nodes.append({"name": name.strip(), "uri": uri}) + + return { "username": username, "ipv4": ipv4, "ipv6": ipv6, + "nodes": nodes, "normal_sub": normal_sub } diff --git a/core/scripts/normalsub/normalsub.py b/core/scripts/normalsub/normalsub.py index 09cb8d0..a3bddba 100644 --- a/core/scripts/normalsub/normalsub.py +++ b/core/scripts/normalsub/normalsub.py @@ -6,7 +6,7 @@ import time import shlex import base64 from typing import Dict, List, Optional, Tuple, Any, Union -from dataclasses import dataclass +from dataclasses import dataclass, field from io import BytesIO from aiohttp import web @@ -29,6 +29,7 @@ class AppConfig: singbox_template_path: str hysteria_cli_path: str users_json_path: str + nodes_json_path: str rate_limit: int rate_limit_window: int sni: str @@ -106,6 +107,13 @@ class UserInfo: return f"Upload: {upload}, Download: {download}, Total: {total}" +@dataclass +class NodeURI: + label: str + uri: str + qrcode: Optional[str] = None + + @dataclass class TemplateContext: username: str @@ -113,11 +121,9 @@ class TemplateContext: usage_raw: str expiration_date: str sublink_qrcode: str - ipv4_qrcode: Optional[str] - ipv6_qrcode: Optional[str] sub_link: str - ipv4_uri: Optional[str] - ipv6_uri: Optional[str] + local_uris: List[NodeURI] = field(default_factory=list) + node_uris: List[NodeURI] = field(default_factory=list) class Utils: @@ -244,17 +250,21 @@ class HysteriaCLI: print(f"JSONDecodeError: {e}, Raw output: {raw_info_str}") return None - def get_user_uri(self, username: str, ip_version: Optional[str] = None) -> str: - if ip_version: - return self._run_command(['show-user-uri', '-u', username, '-ip', ip_version]) - else: - return self._run_command(['show-user-uri', '-u', username, '-a']) - - def get_uris(self, username: str) -> Tuple[Optional[str], Optional[str]]: + def get_all_uris(self, username: str) -> List[str]: + """Fetches all available URIs (local and nodes) for a user.""" output = self._run_command(['show-user-uri', '-u', username, '-a']) - ipv4_uri = re.search(r'IPv4:\s*(.*)', output) - ipv6_uri = re.search(r'IPv6:\s*(.*)', output) - return (ipv4_uri.group(1).strip() if ipv4_uri else None, ipv6_uri.group(1).strip() if ipv6_uri else None) + if not output: + return [] + return re.findall(r'hy2://.*', output) + + def get_all_labeled_uris(self, username: str) -> List[Dict[str, str]]: + """Fetches all URIs and their labels.""" + output = self._run_command(['show-user-uri', '-u', username, '-a']) + if not output: + return [] + + matches = re.findall(r"^(.*?):\s*(hy2://.*)$", output, re.MULTILINE) + return [{'label': label.strip(), 'uri': uri} for label, uri in matches] class UriParser: @@ -303,74 +313,74 @@ class SingboxConfigGenerator: raise RuntimeError(f"Error loading Singbox template: {e}") from e return self._template_cache.copy() - def generate_config(self, username: str, ip_version: str, fragment: str) -> Optional[Dict[str, Any]]: - try: - uri = self.hysteria_cli.get_user_uri(username, ip_version) - except Exception: - print(f"Failed to get URI for {username} with IP version {ip_version}. Skipping.") - return None + def generate_config_from_uri(self, uri: str, username: str, fragment: str) -> Optional[Dict[str, Any]]: + """Generates a Singbox outbound config from a single Hysteria URI.""" if not uri: - print(f"No URI found for {username} with IP version {ip_version}. Skipping.") return None - components = UriParser.extract_uri_components(uri, f'IPv{ip_version}:') - if components is None or components.port is None: - print(f"Invalid URI components for {username} with IP version {ip_version}. Skipping.") + + try: + parsed_url = urlparse(uri) + server = parsed_url.hostname + server_port = parsed_url.port + auth_password = parsed_url.password + auth_user = unquote(parsed_url.username or '') + obfs_password = parse_qs(parsed_url.query).get('obfs-password', [''])[0] + + if auth_password: + if auth_user: + final_password = f"{auth_user}:{auth_password}" + else: + final_password = auth_password + else: + final_password = auth_user + + except Exception as e: + print(f"Error during Singbox config generation from URI: {e}, URI: {uri}") return None return { - "outbounds": [{ - "type": "hysteria2", - "tag": f"{username}-Hysteria2", - "server": components.ip, - "server_port": components.port, - "obfs": { - "type": "salamander", - "password": components.obfs_password - }, - "password": f"{username}:{components.password}", - "tls": { - "enabled": True, - "server_name": fragment if fragment else self.default_sni, - "insecure": True - } - }] + "type": "hysteria2", + "tag": unquote(parsed_url.fragment), + "server": server, + "server_port": server_port, + "obfs": { + "type": "salamander", + "password": obfs_password + }, + "password": final_password, + "tls": { + "enabled": True, + "server_name": fragment if fragment else self.default_sni, + "insecure": True + } } - def combine_configs(self, username: str, config_v4: Optional[Dict[str, Any]], config_v6: Optional[Dict[str, Any]]) -> Dict[str, Any]: + def combine_configs(self, all_uris: List[str], username: str, fragment: str) -> Optional[Dict[str, Any]]: + """Generates a combined Singbox config from a list of URIs.""" + if not all_uris: + return None + combined_config = self.get_template() - combined_config['outbounds'] = [outbound for outbound in combined_config['outbounds'] - if outbound.get('type') != 'hysteria2'] + combined_config['outbounds'] = [out for out in combined_config['outbounds'] if out.get('type') != 'hysteria2'] - modified_v4_outbounds = [] - if config_v4: - v4_outbound = config_v4['outbounds'][0] - v4_outbound['tag'] = f"{username}-IPv4" - modified_v4_outbounds.append(v4_outbound) + hysteria_outbounds = [] + for uri in all_uris: + outbound = self.generate_config_from_uri(uri, username, fragment) + if outbound: + hysteria_outbounds.append(outbound) - modified_v6_outbounds = [] - if config_v6: - v6_outbound = config_v6['outbounds'][0] - v6_outbound['tag'] = f"{username}-IPv6" - modified_v6_outbounds.append(v6_outbound) + if not hysteria_outbounds: + return None - select_outbounds = ["auto"] - if config_v4: - select_outbounds.append(f"{username}-IPv4") - if config_v6: - select_outbounds.append(f"{username}-IPv6") - - auto_outbounds = [] - if config_v4: - auto_outbounds.append(f"{username}-IPv4") - if config_v6: - auto_outbounds.append(f"{username}-IPv6") + all_tags = [out['tag'] for out in hysteria_outbounds] for outbound in combined_config['outbounds']: if outbound.get('tag') == 'select': - outbound['outbounds'] = select_outbounds + outbound['outbounds'] = ["auto"] + all_tags elif outbound.get('tag') == 'auto': - outbound['outbounds'] = auto_outbounds - combined_config['outbounds'].extend(modified_v4_outbounds + modified_v6_outbounds) + outbound['outbounds'] = all_tags + + combined_config['outbounds'].extend(hysteria_outbounds) return combined_config @@ -383,13 +393,13 @@ class SubscriptionManager: user_info = self.hysteria_cli.get_user_info(username) if user_info is None: return "User not found" - ipv4_uri, ipv6_uri = self.hysteria_cli.get_uris(username) - output_lines = [uri for uri in [ipv4_uri, ipv6_uri] if uri] - if not output_lines: + + all_uris = self.hysteria_cli.get_all_uris(username) + if not all_uris: return "No URI available" processed_uris = [] - for uri in output_lines: + for uri in all_uris: if "v2ray" in user_agent and "ng" in user_agent: match = re.search(r'pinSHA256=sha256/([^&]+)', uri) if match: @@ -455,6 +465,7 @@ class HysteriaServer: 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') + nodes_json_path = '/etc/hysteria/nodes.json' rate_limit = 100 rate_limit_window = 60 template_dir = os.path.dirname(__file__) @@ -467,6 +478,7 @@ class HysteriaServer: singbox_template_path=singbox_template_path, hysteria_cli_path=hysteria_cli_path, users_json_path=users_json_path, + nodes_json_path=nodes_json_path, rate_limit=rate_limit, rate_limit_window=rate_limit_window, sni=sni, template_dir=template_dir, subpath=subpath) @@ -548,22 +560,21 @@ class HysteriaServer: 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: - config_v4 = self.singbox_generator.generate_config(username, '4', fragment) - config_v6 = self.singbox_generator.generate_config(username, '6', fragment) - if config_v4 is None and config_v6 is None: + all_uris = self.hysteria_cli.get_all_uris(username) + if not all_uris: return web.Response(status=404, text=f"Error: No valid URIs found for user {username}.") - combined_config = self.singbox_generator.combine_configs(username, config_v4, config_v6) + combined_config = self.singbox_generator.combine_configs(all_uris, username, fragment) return web.Response(text=json.dumps(combined_config, indent=4, sort_keys=True), content_type='application/json') 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": # Should be caught earlier by user_info check + if subscription == "User not found": return web.Response(status=404, text=f"User '{username}' not found.") return web.Response(text=subscription, content_type='text/plain') async def _get_template_context(self, username: str, user_info: UserInfo) -> TemplateContext: - ipv4_uri, ipv6_uri = self.hysteria_cli.get_uris(username) + labeled_uris = self.hysteria_cli.get_all_labeled_uris(username) port_str = f":{self.config.external_port}" if self.config.external_port not in [80, 443, 0] else "" base_url = f"https://{self.config.domain}{port_str}" @@ -571,10 +582,21 @@ class HysteriaServer: 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/{user_info.password}" - - ipv4_qrcode = Utils.generate_qrcode_base64(ipv4_uri) - ipv6_qrcode = Utils.generate_qrcode_base64(ipv6_uri) sublink_qrcode = Utils.generate_qrcode_base64(sub_link) + + local_uris = [] + node_uris = [] + + for item in labeled_uris: + node_uri = NodeURI( + label=item['label'], + uri=item['uri'], + qrcode=Utils.generate_qrcode_base64(item['uri']) + ) + if item['label'].startswith('Node:'): + node_uris.append(node_uri) + else: + local_uris.append(node_uri) return TemplateContext( username=username, @@ -582,11 +604,9 @@ class HysteriaServer: usage_raw=user_info.usage_detailed, expiration_date=user_info.expiration_date, sublink_qrcode=sublink_qrcode, - ipv4_qrcode=ipv4_qrcode, - ipv6_qrcode=ipv6_qrcode, sub_link=sub_link, - ipv4_uri=ipv4_uri, - ipv6_uri=ipv6_uri + local_uris=local_uris, + node_uris=node_uris ) async def robots_handler(self, request: web.Request) -> web.Response: @@ -605,7 +625,6 @@ class HysteriaServer: port=self.config.aiohttp_listen_port ) - if __name__ == '__main__': server = HysteriaServer() - server.run() \ No newline at end of file + server.run() diff --git a/core/scripts/normalsub/singbox.json b/core/scripts/normalsub/singbox.json index 5ee009f..e013461 100644 --- a/core/scripts/normalsub/singbox.json +++ b/core/scripts/normalsub/singbox.json @@ -1,155 +1,159 @@ { - "log": { - "level": "info", - "timestamp": true - }, "dns": { - "servers": [ - { - "tag": "proxyDns", - "address": "tls://8.8.8.8", - "detour": "Proxy" - }, - { - "tag": "localDns", - "address": "8.8.8.8", - "detour": "direct" - } - ], + "final": "local-dns", "rules": [ { - "outbound": "any", - "server": "localDns" + "action": "route", + "clash_mode": "Global", + "server": "proxy-dns", + "source_ip_cidr": [ + "172.19.0.0/30", + "fdfe:dcba:9876::1/126" + ] }, { - "rule_set": "geosite-ir", - "server": "proxyDns" - }, - { - "clash_mode": "direct", - "server": "localDns" - }, - { - "clash_mode": "global", - "server": "proxyDns" + "action": "route", + "server": "proxy-dns", + "source_ip_cidr": [ + "172.19.0.0/30", + "fdfe:dcba:9876::1/126" + ] } ], - "final": "localDns", - "strategy": "ipv4_only" + "servers": [ + { + "type": "https", + "server": "1.1.1.1", + "detour": "proxy", + "tag": "proxy-dns" + }, + { + "type": "local", + "detour": "direct", + "tag": "local-dns" + } + ], + "strategy": "prefer_ipv4" }, "inbounds": [ { - "type": "tun", - "tag": "tun-in", - "address": "172.19.0.1/30", + "address": [ + "172.19.0.1/30", + "fdfe:dcba:9876::1/126" + ], "auto_route": true, - "strict_route": true + "endpoint_independent_nat": false, + "mtu": 9000, + "platform": { + "http_proxy": { + "enabled": true, + "server": "127.0.0.1", + "server_port": 2080 + } + }, + "stack": "system", + "strict_route": false, + "type": "tun" + }, + { + "listen": "127.0.0.1", + "listen_port": 2080, + "type": "mixed", + "users": [] } ], + "log": { + "level": "warn", + "timestamp": true + }, "outbounds": [ { - "tag": "Proxy", - "type": "selector", "outbounds": [ "auto", "direct" - ] - }, - { - "tag": "Global", - "type": "selector", - "outbounds": [ - "direct" - ] - }, - { - "tag": "auto", - "type": "urltest", - "outbounds": [ - "Proxy" ], - "url": "http://www.gstatic.com/generate_204", + "tag": "proxy", + "type": "selector" + }, + { "interval": "10m", - "tolerance": 50 + "outbounds": [], + "tag": "auto", + "tolerance": 50, + "type": "urltest", + "url": "http://www.gstatic.com/generate_204" }, { - "type": "direct", - "tag": "direct" - }, - { - "type": "direct", - "tag": "local" + "tag": "direct", + "type": "direct" } ], "route": { "auto_detect_interface": true, - "final": "Proxy", + "final": "proxy", + "rule_set": [ + { + "download_detour": "direct", + "format": "binary", + "tag": "geosite-ads", + "type": "remote", + "url": "https://raw.githubusercontent.com/Chocolate4U/Iran-sing-box-rules/rule-set/geosite-category-ads-all.srs" + }, + { + "download_detour": "direct", + "format": "binary", + "tag": "geoip-private", + "type": "remote", + "url": "https://raw.githubusercontent.com/Chocolate4U/Iran-sing-box-rules/rule-set/geoip-private.srs" + }, + { + "download_detour": "direct", + "format": "binary", + "tag": "geosite-ir", + "type": "remote", + "url": "https://raw.githubusercontent.com/Chocolate4U/Iran-sing-box-rules/rule-set/geosite-ir.srs" + }, + { + "download_detour": "direct", + "format": "binary", + "tag": "geoip-ir", + "type": "remote", + "url": "https://raw.githubusercontent.com/Chocolate4U/Iran-sing-box-rules/rule-set/geoip-ir.srs" + } + ], "rules": [ { - "inbound": [ - "tun-in", - "mixed-in" - ], "action": "sniff" }, { - "type": "logical", - "mode": "or", - "rules": [ - { - "port": 53 - }, - { - "protocol": "dns" - } - ], - "action": "hijack-dns" - }, - { - "rule_set": "geosite-category-ads-all", - "action": "reject" - }, - { - "rule_set": "geosite-category-ads-all", - "outbound": "Proxy" - }, - { - "ip_is_private": true, + "action": "route", + "clash_mode": "Direct", "outbound": "direct" }, { "action": "route", - "rule_set": "geosite-ir", - "outbound": "direct" + "clash_mode": "Global", + "outbound": "proxy" + }, + { + "action": "hijack-dns", + "protocol": "dns" }, { "action": "route", - "rule_set": "geoip-ir", - "outbound": "direct" - } - ], - "rule_set": [ - { - "tag": "geosite-category-ads-all", - "type": "remote", - "format": "binary", - "url": "https://raw.githubusercontent.com/Chocolate4U/Iran-sing-box-rules/rule-set/geosite-category-ads-all.srs", - "download_detour": "direct" + "outbound": "direct", + "rule_set": [ + "geosite-ir", + "geoip-ir", + "geoip-private" + ] }, { - "type": "remote", - "tag": "geoip-ir", - "format": "binary", - "url": "https://raw.githubusercontent.com/Chocolate4U/Iran-sing-box-rules/rule-set/geoip-ir.srs", - "update_interval": "120h0m0s" - }, - { - "type": "remote", - "tag": "geosite-ir", - "format": "binary", - "url": "https://raw.githubusercontent.com/Chocolate4U/Iran-sing-box-rules/rule-set/geosite-ir.srs", - "update_interval": "120h0m0s" + "action": "reject", + "rule_set": [ + "geosite-ads" + ] } ] } -} +} \ No newline at end of file diff --git a/core/scripts/normalsub/template.html b/core/scripts/normalsub/template.html index 8c2fa26..59922a7 100644 --- a/core/scripts/normalsub/template.html +++ b/core/scripts/normalsub/template.html @@ -41,7 +41,6 @@ color: var(--text-dark); } - /* Animated background elements */ .background-animation { position: fixed; top: 0; @@ -78,17 +77,14 @@ 0% { transform: translate(0, 0) rotate(0deg); } - 50% { transform: translate(100px, 100px) rotate(180deg); } - 100% { transform: translate(0, 0) rotate(360deg); } } - /* Rest of the styles remain the same */ .container { max-width: 1000px; margin: 2rem auto; @@ -175,10 +171,10 @@ background: var(--card-bg-light); border-radius: 1rem; overflow: hidden; - /* Add this to contain the header */ box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1); backdrop-filter: blur(10px); border: 1px solid rgba(255, 255, 255, 0.1); + margin-bottom: 1.5rem; } .qr-header { @@ -196,7 +192,6 @@ padding: 2rem; } - .dark-mode .qr-section { background: rgba(31, 41, 55, 0.8); border-color: rgba(255, 255, 255, 0.05); @@ -229,6 +224,14 @@ border-radius: 0.5rem; box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); } + + .uri-unavailable { + color: #9ca3af; + } + + .dark-mode .uri-unavailable { + color: #4b5563; + } .btn-group { display: flex; @@ -278,6 +281,7 @@ box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1); display: none; backdrop-filter: blur(10px); + z-index: 100; } .dark-mode .loading-indicator { @@ -355,63 +359,81 @@ - +
IPv4 URI not available
- {% endif %} -IPv6 URI not available
- {% endif %} -{{ item.label }} URI not available
+ {% endif %} +{{ item.label }} URI not available
+ {% endif %} +