diff --git a/changelog b/changelog index 246d610..3a2db3c 100644 --- a/changelog +++ b/changelog @@ -1,19 +1,26 @@ -### 🚀 **[2.3.5] – Dependency Updates & Minor Fixes** +### 🚀 **[2.4.0] – Advanced Node Management & Paginated User List** -*Released: 2025-10-13* +*Released: 2025-10-27* -#### 🧩 Dependency Updates +#### 🌐 **API & Backend** -* ⬆️ **pydantic-core** → 2.41.1 -* ⬆️ **pydantic** → 2.12.0 -* ⬆️ **fastapi** → 0.118.0 -* ⬆️ **pymongo** → 4.15.2 -* ⬆️ **certifi** → 2025.10.5 -* ⬆️ **frozenlist** → 1.8.0 -* ⬆️ **markupsafe** → 3.0.3 -* ⬆️ **propcache** → 0.4.1 +* 🚀 **feat(api):** Added endpoint for receiving node traffic data +* 🧩 **refactor(api):** Applied node-specific parameters to wrapper URI script +* ⚙️ **refactor(uri):** Enhanced URI generation with node-specific parameters +* 🧠 **refactor:** Cleaned up API documentation tags +* 🧱 **feat(api):** Added full node configuration to web panel API -#### 🛠 Fixes +#### 🧮 **Node Management** -* 🐛 Fixed **path handling bug** in Core module -* ✏️ Fixed **typo** in CLI messages +* 🧠 **feat(nodes):** Implemented advanced node management and automatic certificate generation +* 🔐 **feat(nodes):** Added *insecure TLS* option for external nodes +* 🌀 **feat(nodes):** Added optional **OBFS** and **port** parameters for flexible node setups + +#### 💻 **Frontend / Web Panel** + +* 🖥️ **feat(web):** Implemented full node configuration UI in the settings page +* 📄 **feat(ui):** Added **paginated user list** with selectable limit for improved performance + +#### 🤖 **Bot & Scripts** + +* 🧰 **fix(bot):** Removed unnecessary `-u` flag from `remove-user` command diff --git a/core/cli.py b/core/cli.py index 37ca6fa..9522b49 100644 --- a/core/cli.py +++ b/core/cli.py @@ -329,12 +329,17 @@ def node(): 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): +@click.option('--name', required=True, type=str, help='Unique name for the node.') +@click.option('--ip', required=True, type=str, help='Public IP address of the node.') +@click.option('--port', required=False, type=int, help='Optional: Port of the node.') +@click.option('--sni', required=False, type=str, help='Optional: Server Name Indication.') +@click.option('--pinSHA256', required=False, type=str, help='Optional: Public key SHA256 pin.') +@click.option('--obfs', required=False, type=str, help='Optional: Obfuscation key.') +@click.option('--insecure', is_flag=True, default=False, help='Optional: Skip certificate verification.') +def add_node(name, ip, port, sni, pinsha256, obfs, insecure): """Add a new external node.""" try: - output = cli_api.add_node(name, ip) + output = cli_api.add_node(name, ip, sni, pinSHA256=pinsha256, port=port, obfs=obfs, insecure=insecure) click.echo(output.strip()) except Exception as e: click.echo(f'{e}', err=True) @@ -358,6 +363,15 @@ def list_nodes(): except Exception as e: click.echo(f'{e}', err=True) +@node.command('generate-cert') +def generate_cert(): + """Generate a self-signed certificate for nodes.""" + try: + output = cli_api.generate_node_cert() + 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), diff --git a/core/cli_api.py b/core/cli_api.py index 01856f0..5641d22 100644 --- a/core/cli_api.py +++ b/core/cli_api.py @@ -35,7 +35,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') + NODE_MANAGER = os.path.join(SCRIPT_DIR, 'nodes', 'node.py') MANAGE_OBFS = os.path.join(SCRIPT_DIR, 'hysteria2', 'manage_obfs.py') MASQUERADE_SCRIPT = os.path.join(SCRIPT_DIR, 'hysteria2', 'masquerade.py') EXTRA_CONFIG_SCRIPT = os.path.join(SCRIPT_DIR, 'hysteria2', 'extra_config.py') @@ -464,11 +464,22 @@ 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): +def add_node(name: str, ip: str, sni: Optional[str] = None, pinSHA256: Optional[str] = None, port: Optional[int] = None, obfs: Optional[str] = None, insecure: Optional[bool] = None): """ Adds a new external node. """ - return run_cmd(['python3', Command.NODE_MANAGER.value, 'add', '--name', name, '--ip', ip]) + command = ['python3', Command.NODE_MANAGER.value, 'add', '--name', name, '--ip', ip] + if port: + command.extend(['--port', str(port)]) + if sni: + command.extend(['--sni', sni]) + if pinSHA256: + command.extend(['--pinSHA256', pinSHA256]) + if obfs: + command.extend(['--obfs', obfs]) + if insecure: + command.append('--insecure') + return run_cmd(command) def delete_node(name: str): """ @@ -482,6 +493,12 @@ def list_nodes(): """ return run_cmd(['python3', Command.NODE_MANAGER.value, 'list']) +def generate_node_cert(): + """ + Generates a self-signed certificate for nodes. + """ + return run_cmd(['python3', Command.NODE_MANAGER.value, 'generate-cert']) + def update_geo(country: str): ''' Updates geographic data files based on the specified country. @@ -775,4 +792,4 @@ def get_ip_limiter_config() -> dict[str, int | None]: except Exception as e: print(f"Error reading IP Limiter config from .configs.env: {e}") return {"block_duration": None, "max_ips": None} -# endregion +# endregion \ No newline at end of file diff --git a/core/scripts/hysteria2/node.py b/core/scripts/hysteria2/node.py deleted file mode 100644 index e8c6150..0000000 --- a/core/scripts/hysteria2/node.py +++ /dev/null @@ -1,110 +0,0 @@ -#!/usr/bin/env python3 - -import sys -import json -import argparse -from pathlib import Path -import re -from ipaddress import ip_address -from init_paths import * -from paths import NODES_JSON_PATH - -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 8fd9661..1d6a781 100644 --- a/core/scripts/hysteria2/show_user_uri.py +++ b/core/scripts/hysteria2/show_user_uri.py @@ -24,7 +24,7 @@ def load_env_file(env_file: str) -> Dict[str, str]: env_vars[key] = value return env_vars -def load_nodes() -> List[Dict[str, str]]: +def load_nodes() -> List[Dict[str, Any]]: if NODES_JSON_PATH.exists(): try: with NODES_JSON_PATH.open("r") as f: @@ -147,25 +147,26 @@ def show_uri(args: argparse.Namespace) -> None: return auth_password = user_doc["password"] - port = config["listen"].split(":")[-1] - sha256 = config.get("tls", {}).get("pinSHA256", "") - obfs_password = config.get("obfs", {}).get("salamander", {}).get("password", "") - insecure = config.get("tls", {}).get("insecure", True) + + local_port = config["listen"].split(":")[-1] + local_sha256 = config.get("tls", {}).get("pinSHA256", "") + local_obfs_password = config.get("obfs", {}).get("salamander", {}).get("password", "") + local_insecure = config.get("tls", {}).get("insecure", True) - ip4, ip6, sni = load_hysteria2_ips() + ip4, ip6, local_sni = load_hysteria2_ips() 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") + uri = generate_uri(args.username, auth_password, ip4, local_port, + local_obfs_password, local_sha256, local_sni, 4, local_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") + uri = generate_uri(args.username, auth_password, ip6, local_port, + local_obfs_password, local_sha256, local_sni, 6, local_insecure, f"{args.username}-IPv6") display_uri_and_qr(uri, "IPv6", args, terminal_width) for node in nodes: @@ -177,8 +178,24 @@ def show_uri(args: argparse.Namespace) -> None: ip_v = 4 if '.' in node_ip else 6 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}") + node_port = node.get("port", local_port) + node_sni = node.get("sni", local_sni) + node_obfs = node.get("obfs", local_obfs_password) + node_pin = node.get("pinSHA256", local_sha256) + node_insecure = node.get("insecure", local_insecure) + + uri = generate_uri( + username=args.username, + auth_password=auth_password, + ip=node_ip, + port=str(node_port), + obfs_password=node_obfs, + sha256=node_pin, + sni=node_sni, + ip_version=ip_v, + insecure=node_insecure, + fragment_tag=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"): diff --git a/core/scripts/hysteria2/wrapper_uri.py b/core/scripts/hysteria2/wrapper_uri.py index 8d1b71e..6f50542 100644 --- a/core/scripts/hysteria2/wrapper_uri.py +++ b/core/scripts/hysteria2/wrapper_uri.py @@ -37,7 +37,10 @@ def generate_uri(username: str, auth_password: str, ip: str, port: str, uri_params: Dict[str, str], ip_version: int, fragment_tag: str) -> str: ip_part = f"[{ip}]" if ip_version == 6 and ':' in ip else ip uri_base = f"hy2://{username}:{auth_password}@{ip_part}:{port}" - query_string = "&".join([f"{k}={v}" for k, v in uri_params.items()]) + + query_params = [f"{k}={v}" for k, v in uri_params.items() if v is not None and v != ''] + query_string = "&".join(query_params) + return f"{uri_base}?{query_string}#{fragment_tag}" def process_users(target_usernames: List[str]) -> List[Dict[str, Any]]: @@ -51,23 +54,23 @@ def process_users(target_usernames: List[str]) -> List[Dict[str, Any]]: sys.exit(1) nodes = load_json_file(NODES_JSON_PATH) or [] - port = config.get("listen", "").split(":")[-1] + + default_port = config.get("listen", "").split(":")[-1] tls_config = config.get("tls", {}) hy2_env = load_env_file(CONFIG_ENV) ns_env = load_env_file(NORMALSUB_ENV) - base_uri_params = { - "insecure": "1" if tls_config.get("insecure", True) else "0", - "sni": hy2_env.get('SNI', '') - } - obfs_password = config.get("obfs", {}).get("salamander", {}).get("password") - if obfs_password: - base_uri_params["obfs"] = "salamander" - base_uri_params["obfs-password"] = obfs_password + default_sni = hy2_env.get('SNI', '') + default_obfs = config.get("obfs", {}).get("salamander", {}).get("password") + default_pin = tls_config.get("pinSHA256") + default_insecure = tls_config.get("insecure", True) - sha256 = tls_config.get("pinSHA256") - if sha256: - base_uri_params["pinSHA256"] = sha256 + base_uri_params = {"insecure": "1" if default_insecure else "0"} + if default_sni: base_uri_params["sni"] = default_sni + if default_obfs: + base_uri_params["obfs"] = "salamander" + base_uri_params["obfs-password"] = default_obfs + if default_pin: base_uri_params["pinSHA256"] = default_pin ip4 = hy2_env.get('IP4') ip6 = hy2_env.get('IP6') @@ -84,17 +87,34 @@ def process_users(target_usernames: List[str]) -> List[Dict[str, Any]]: user_output = {"username": username, "ipv4": None, "ipv6": None, "nodes": [], "normal_sub": None} if ip4 and ip4 != "None": - user_output["ipv4"] = generate_uri(username, auth_password, ip4, port, base_uri_params, 4, f"{username}-IPv4") + user_output["ipv4"] = generate_uri(username, auth_password, ip4, default_port, base_uri_params, 4, f"{username}-IPv4") if ip6 and ip6 != "None": - user_output["ipv6"] = generate_uri(username, auth_password, ip6, port, base_uri_params, 6, f"{username}-IPv6") + user_output["ipv6"] = generate_uri(username, auth_password, ip6, default_port, base_uri_params, 6, f"{username}-IPv6") for node in nodes: - if node_name := node.get("name"): - if node_ip := node.get("ip"): - ip_v = 6 if ':' in node_ip else 4 - tag = f"{username}-{node_name}" - uri = generate_uri(username, auth_password, node_ip, port, base_uri_params, ip_v, tag) - user_output["nodes"].append({"name": node_name, "uri": uri}) + node_name = node.get("name") + node_ip = node.get("ip") + if not node_name or not node_ip: + continue + + ip_v = 6 if ':' in node_ip else 4 + tag = f"{username}-{node_name}" + + node_port = str(node.get("port", default_port)) + node_sni = node.get("sni", default_sni) + node_obfs = node.get("obfs", default_obfs) + node_pin = node.get("pinSHA256", default_pin) + node_insecure = node.get("insecure", default_insecure) + + node_params = {"insecure": "1" if node_insecure else "0"} + if node_sni: node_params["sni"] = node_sni + if node_obfs: + node_params["obfs"] = "salamander" + node_params["obfs-password"] = node_obfs + if node_pin: node_params["pinSHA256"] = node_pin + + uri = generate_uri(username, auth_password, node_ip, node_port, node_params, ip_v, tag) + user_output["nodes"].append({"name": node_name, "uri": uri}) if ns_domain and ns_port and ns_subpath: user_output["normal_sub"] = f"https://{ns_domain}:{ns_port}/{ns_subpath}/sub/normal/{auth_password}#{username}" diff --git a/core/scripts/nodes/init_paths.py b/core/scripts/nodes/init_paths.py new file mode 100644 index 0000000..c9c4648 --- /dev/null +++ b/core/scripts/nodes/init_paths.py @@ -0,0 +1,7 @@ +import sys +from pathlib import Path + +core_scripts_dir = Path(__file__).resolve().parents[1] + +if str(core_scripts_dir) not in sys.path: + sys.path.append(str(core_scripts_dir)) diff --git a/core/scripts/nodes/node.py b/core/scripts/nodes/node.py new file mode 100644 index 0000000..c735793 --- /dev/null +++ b/core/scripts/nodes/node.py @@ -0,0 +1,229 @@ +#!/usr/bin/env python3 + +import sys +import json +import argparse +from pathlib import Path +import re +from ipaddress import ip_address +import subprocess +from datetime import datetime, timedelta +from init_paths import * +from paths import NODES_JSON_PATH + + +def is_valid_ip_or_domain(value: str) -> bool: + 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 is_valid_sni(value: str) -> bool: + if not value or not value.strip(): + return False + value = value.strip() + try: + ip_address(value) + return False + except ValueError: + if "https://" in value or "http://" in value or "//" in value: + return False + 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 is_valid_sha256_pin(value: str) -> bool: + if not value or not value.strip(): + return False + value = value.strip().upper() + pin_regex = re.compile(r'^([0-9A-F]{2}:){31}[0-9A-F]{2}$') + return re.match(pin_regex, value) is not None + +def is_valid_port(port: int) -> bool: + return 1 <= port <= 65535 + +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, sni: str | None = None, pinSHA256: str | None = None, port: int | None = None, obfs: str | None = None, insecure: bool = False): + 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) + + if sni and not is_valid_sni(sni): + print(f"Error: '{sni}' is not a valid domain name for SNI.", file=sys.stderr) + sys.exit(1) + + if pinSHA256 and not is_valid_sha256_pin(pinSHA256): + print(f"Error: '{pinSHA256}' is not a valid SHA256 pin format.", file=sys.stderr) + sys.exit(1) + + if port and not is_valid_port(port): + print(f"Error: Port '{port}' must be between 1 and 65535.", 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) + + new_node = {"name": name, "ip": ip} + if sni: + new_node["sni"] = sni.strip() + if pinSHA256: + new_node["pinSHA256"] = pinSHA256.strip().upper() + if port: + new_node["port"] = port + if obfs: + new_node["obfs"] = obfs.strip() + if insecure: + new_node["insecure"] = insecure + + nodes.append(new_node) + write_nodes(nodes) + print(f"Successfully added node '{name}'.") + +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':<15} {'IP / Domain':<25} {'Port':<8} {'SNI':<20} {'Insecure':<10} {'OBFS':<20} {'Pin SHA256'}") + print(f"{'-'*15} {'-'*25} {'-'*8} {'-'*20} {'-'*10} {'-'*20} {'-'*30}") + for node in sorted(nodes, key=lambda x: x['name']): + name = node['name'] + ip = node['ip'] + port = node.get('port', 'N/A') + sni = node.get('sni', 'N/A') + insecure = str(node.get('insecure', 'False')) + obfs = node.get('obfs', 'N/A') + pin = node.get('pinSHA256', 'N/A') + print(f"{name:<15} {ip:<25} {str(port):<8} {sni:<20} {insecure:<10} {obfs:<20} {pin}") + +def generate_cert(): + try: + script_dir = Path(__file__).parent.resolve() + key_filepath = script_dir / "blitz.key" + cert_filepath = script_dir / "blitz.crt" + + if cert_filepath.exists(): + try: + check_cmd = ['openssl', 'x509', '-in', str(cert_filepath), '-noout', '-enddate'] + result = subprocess.run(check_cmd, capture_output=True, text=True, check=True) + + end_date_str = result.stdout.strip().split('=')[1] + end_date = datetime.strptime(end_date_str, '%b %d %H:%M:%S %Y %Z') + + if end_date > datetime.now() + timedelta(days=30): + print("Existing certificate is valid for more than 30 days.") + print("\n") + print(cert_filepath.read_text().strip()) + return + else: + print("Existing certificate is expiring in less than 30 days. Generating a new one.") + except (subprocess.CalledProcessError, FileNotFoundError, IndexError, ValueError) as e: + print(f"Could not validate existing certificate: {e}. Generating a new one.") + + print("Generating new certificate and key...") + openssl_command = [ + 'openssl', 'req', '-x509', + '-newkey', 'ec', + '-pkeyopt', 'ec_paramgen_curve:prime256v1', + '-keyout', str(key_filepath), + '-out', str(cert_filepath), + '-sha256', '-days', '3650', '-nodes', + '-subj', '/CN=Blitz' + ] + + result = subprocess.run(openssl_command, capture_output=True, text=True, check=False) + + if result.returncode != 0: + sys.exit(f"Error generating certificate with OpenSSL:\n{result.stderr}") + + cert_content = cert_filepath.read_text() + + print("Successfully generated certificate and key:") + print("\n") + print(cert_content.strip()) + + except FileNotFoundError: + sys.exit("Error: 'openssl' command not found. Please ensure OpenSSL is installed and in your PATH.") + except Exception as e: + sys.exit(f"An unexpected error occurred: {e}") + +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.') + add_parser.add_argument('--port', type=int, help='Optional: The port of the node.') + add_parser.add_argument('--sni', type=str, help='Optional: The Server Name Indication.') + add_parser.add_argument('--pinSHA256', type=str, help='Optional: The public key SHA256 pin.') + add_parser.add_argument('--obfs', type=str, help='Optional: The obfuscation key.') + add_parser.add_argument('--insecure', action='store_true', help='Optional: Skip certificate verification.') + + 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.') + + subparsers.add_parser('generate-cert', help="Generate blitz.crt and blitz.key.") + + args = parser.parse_args() + + if args.command == 'add': + add_node(args.name, args.ip, args.sni, args.pinSHA256, args.port, args.obfs, args.insecure) + elif args.command == 'delete': + delete_node(args.name) + elif args.command == 'list': + list_nodes() + elif args.command == 'generate-cert': + generate_cert() + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/core/scripts/telegrambot/utils/deleteuser.py b/core/scripts/telegrambot/utils/deleteuser.py index 8fd2c76..327e2ff 100644 --- a/core/scripts/telegrambot/utils/deleteuser.py +++ b/core/scripts/telegrambot/utils/deleteuser.py @@ -20,6 +20,6 @@ def delete_user(message): def process_delete_user(message): username = message.text.strip().lower() - command = f"python3 {CLI_PATH} remove-user -u {username}" + command = f"python3 {CLI_PATH} remove-user {username}" result = run_cli_command(command) bot.reply_to(message, result) \ No newline at end of file diff --git a/core/scripts/webpanel/app.py b/core/scripts/webpanel/app.py index 0ac88ef..3b957af 100644 --- a/core/scripts/webpanel/app.py +++ b/core/scripts/webpanel/app.py @@ -5,18 +5,17 @@ import asyncio from fastapi import FastAPI from starlette.staticfiles import StaticFiles -from config import CONFIGS # Loads the configuration from .env -from middleware import AuthMiddleware # Defines authentication middleware -from middleware import AfterRequestMiddleware # Defines after request middleware -from dependency import get_session_manager # Defines dependencies across routers -from openapi import setup_openapi_schema # Adds authorization header to openapi schema -from exception_handler import setup_exception_handler # Defines exception handlers +from config import CONFIGS +from middleware import AuthMiddleware +from middleware import AfterRequestMiddleware +from dependency import get_session_manager +from openapi import setup_openapi_schema +from exception_handler import setup_exception_handler -# Append directory of cli_api.py to be able to import it HYSTERIA_CORE_DIR = '/etc/hysteria/core/' sys.path.append(HYSTERIA_CORE_DIR) -import routers # noqa: This import should be after the sys.path modification, because it imports cli_api +import routers def create_app() -> FastAPI: @@ -24,11 +23,10 @@ def create_app() -> FastAPI: Create FastAPI app. ''' - # Set up FastAPI app = FastAPI( - title='Hysteria Webpanel', - description='Webpanel for Hysteria', - version='0.1.0', + title='Blitz API', + description='Webpanel for Hysteria2', + version='0.2.0', contact={ 'github': 'https://github.com/ReturnFI/Blitz' }, @@ -36,26 +34,19 @@ def create_app() -> FastAPI: root_path=f'/{CONFIGS.ROOT_PATH}', ) - # Set up static files app.mount('/assets', StaticFiles(directory='assets'), name='assets') - # Set up exception handlers setup_exception_handler(app) - # Set up authentication middleware - app.add_middleware(AuthMiddleware, session_manager=get_session_manager(), api_token=CONFIGS.API_TOKEN) - # Set up after request middleware app.add_middleware(AfterRequestMiddleware) - # Set up Routers - app.include_router(routers.basic.router, prefix='', tags=['Basic Routes[Web]']) # Add basic router - app.include_router(routers.login.router, prefix='', tags=['Authentication[Web]']) # Add authentication router - app.include_router(routers.settings.router, prefix='/settings', tags=['Settings[Web]']) # Add settings router - app.include_router(routers.user.router, prefix='/users', tags=['User Management[Web]']) # Add user router - app.include_router(routers.api.v1.api_v1_router, prefix='/api/v1', tags=['API Version 1']) # Add API version 1 router # type: ignore + app.include_router(routers.basic.router, prefix='', tags=['Web - Basic']) + app.include_router(routers.login.router, prefix='', tags=['Web - Authentication']) + app.include_router(routers.settings.router, prefix='/settings', tags=['Web - Settings']) + app.include_router(routers.user.router, prefix='/users', tags=['Web - User Management']) + app.include_router(routers.api.v1.api_v1_router, prefix='/api/v1') - # Document that the API requires an API key setup_openapi_schema(app) return app @@ -66,7 +57,7 @@ app: FastAPI = create_app() if __name__ == '__main__': from hypercorn.config import Config - from hypercorn.asyncio import serve # type: ignore + from hypercorn.asyncio import serve from hypercorn.middleware import ProxyFixMiddleware config = Config() @@ -75,6 +66,5 @@ if __name__ == '__main__': config.accesslog = '-' config.errorlog = '-' - # Fix proxy headers - app = ProxyFixMiddleware(app, 'legacy') # type: ignore - asyncio.run(serve(app, config)) # type: ignore + app = ProxyFixMiddleware(app, 'legacy') + asyncio.run(serve(app, config)) \ No newline at end of file diff --git a/core/scripts/webpanel/assets/js/settings.js b/core/scripts/webpanel/assets/js/settings.js index 79567d6..b818fa9 100644 --- a/core/scripts/webpanel/assets/js/settings.js +++ b/core/scripts/webpanel/assets/js/settings.js @@ -67,7 +67,11 @@ $(document).ready(function () { function isValidDomain(domain) { if (!domain) return false; const lowerDomain = domain.toLowerCase(); - return !lowerDomain.startsWith("http://") && !lowerDomain.startsWith("https://"); + if (lowerDomain.startsWith("http://") || lowerDomain.startsWith("https://")) return false; + const ipV4Regex = /^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/; + if(ipV4Regex.test(domain)) return false; + const domainRegex = /^(?!-)(?:[a-zA-Z\d-]{0,62}[a-zA-Z\d]\.){1,126}(?!\d+$)[a-zA-Z\d]{1,63}$/; + return domainRegex.test(lowerDomain); } function isValidPort(port) { @@ -75,6 +79,12 @@ $(document).ready(function () { return /^[0-9]+$/.test(port) && parseInt(port) > 0 && parseInt(port) <= 65535; } + function isValidSha256Pin(pin) { + if (!pin) return false; + const pinRegex = /^([0-9A-F]{2}:){31}[0-9A-F]{2}$/i; + return pinRegex.test(pin.trim()); + } + function isValidSubPath(subpath) { if (!subpath) return false; return /^[a-zA-Z0-9]+$/.test(subpath); @@ -198,6 +208,14 @@ $(document).ready(function () { } } else if (id === 'decoy_path') { fieldValid = isValidPath(input.val()); + } else if (id === 'node_port') { + fieldValid = (input.val().trim() === '') ? true : isValidPort(input.val()); + } else if (id === 'node_sni') { + fieldValid = (input.val().trim() === '') ? true : isValidDomain(input.val()); + } else if (id === 'node_pin') { + fieldValid = (input.val().trim() === '') ? true : isValidSha256Pin(input.val()); + } else if (id === 'node_obfs') { + fieldValid = true; } else { if (input.attr('placeholder') && input.attr('placeholder').includes('Enter') && !input.attr('id').startsWith('ipv')) { fieldValid = input.val().trim() !== ""; @@ -265,6 +283,11 @@ $(document).ready(function () { const row = `