From 3b58a0273fe9350eb97baaa8b679a529c8f91240 Mon Sep 17 00:00:00 2001 From: ReturnFI <151555003+ReturnFI@users.noreply.github.com> Date: Mon, 13 Oct 2025 12:39:16 +0000 Subject: [PATCH] feat(nodes): Implement advanced node management and certificate generation --- core/cli.py | 15 ++- core/cli_api.py | 19 ++- core/scripts/hysteria2/node.py | 110 ---------------- core/scripts/nodes/init_paths.py | 7 ++ core/scripts/nodes/node.py | 210 +++++++++++++++++++++++++++++++ 5 files changed, 245 insertions(+), 116 deletions(-) delete mode 100644 core/scripts/hysteria2/node.py create mode 100644 core/scripts/nodes/init_paths.py create mode 100644 core/scripts/nodes/node.py diff --git a/core/cli.py b/core/cli.py index 37ca6fa..72c0531 100644 --- a/core/cli.py +++ b/core/cli.py @@ -331,10 +331,12 @@ def node(): @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('--sni', required=False, type=str, help='Optional: The Server Name Indication (e.g., yourdomain.com).') +@click.option('--pinSHA256', required=False, type=str, help='Optional: The public key SHA256 pin.') +def add_node(name, ip, sni, pinsha256): """Add a new external node.""" try: - output = cli_api.add_node(name, ip) + output = cli_api.add_node(name, ip, sni, pinSHA256=pinsha256) click.echo(output.strip()) except Exception as e: click.echo(f'{e}', err=True) @@ -358,6 +360,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..d59cbe6 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,16 @@ 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): """ 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 sni: + command.extend(['--sni', sni]) + if pinSHA256: + command.extend(['--pinSHA256', pinSHA256]) + return run_cmd(command) def delete_node(name: str): """ @@ -482,6 +487,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 +786,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/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..b19644d --- /dev/null +++ b/core/scripts/nodes/node.py @@ -0,0 +1,210 @@ +#!/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 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): + 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. Do not include http/https and ensure it's not an IP.", 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) + + 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() + + 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':<20} {'IP / Domain':<25} {'SNI':<25} {'Pin SHA256'}") + print(f"{'-'*20} {'-'*25} {'-'*25} {'-'*30}") + for node in sorted(nodes, key=lambda x: x['name']): + name = node['name'] + ip = node['ip'] + sni = node.get('sni', 'N/A') + pin = node.get('pinSHA256', 'N/A') + print(f"{name:<20} {ip:<25} {sni:<25} {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('--sni', type=str, help='Optional: The Server Name Indication (e.g., yourdomain.com).') + add_parser.add_argument('--pinSHA256', type=str, help='Optional: The public key SHA256 pin.') + + 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 if they don't exist or are expiring soon.") + + args = parser.parse_args() + + if args.command == 'add': + add_node(args.name, args.ip, args.sni, args.pinSHA256) + 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