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 01/13] 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 From db82da6e62b406e7e185802f00927f03a7441ca7 Mon Sep 17 00:00:00 2001 From: ReturnFI <151555003+ReturnFI@users.noreply.github.com> Date: Thu, 16 Oct 2025 19:22:49 +0000 Subject: [PATCH 02/13] feat(nodes): Add optional port parameter for node management --- core/cli.py | 7 ++++--- core/cli_api.py | 4 +++- core/scripts/nodes/node.py | 23 +++++++++++++++++------ 3 files changed, 24 insertions(+), 10 deletions(-) diff --git a/core/cli.py b/core/cli.py index 72c0531..407ae19 100644 --- a/core/cli.py +++ b/core/cli.py @@ -331,12 +331,13 @@ 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.') -@click.option('--sni', required=False, type=str, help='Optional: The Server Name Indication (e.g., yourdomain.com).') +@click.option('--port', required=False, type=int, help='Optional: The port of the node.') +@click.option('--sni', required=False, type=str, help='Optional: The Server Name Indication.') @click.option('--pinSHA256', required=False, type=str, help='Optional: The public key SHA256 pin.') -def add_node(name, ip, sni, pinsha256): +def add_node(name, ip, port, sni, pinsha256): """Add a new external node.""" try: - output = cli_api.add_node(name, ip, sni, pinSHA256=pinsha256) + output = cli_api.add_node(name, ip, sni, pinSHA256=pinsha256, port=port) click.echo(output.strip()) except Exception as e: click.echo(f'{e}', err=True) diff --git a/core/cli_api.py b/core/cli_api.py index d59cbe6..87b86bf 100644 --- a/core/cli_api.py +++ b/core/cli_api.py @@ -464,11 +464,13 @@ 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, sni: Optional[str] = None, pinSHA256: Optional[str] = None): +def add_node(name: str, ip: str, sni: Optional[str] = None, pinSHA256: Optional[str] = None, port: Optional[int] = None): """ Adds a new external node. """ 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: diff --git a/core/scripts/nodes/node.py b/core/scripts/nodes/node.py index b19644d..050bb43 100644 --- a/core/scripts/nodes/node.py +++ b/core/scripts/nodes/node.py @@ -49,6 +49,9 @@ def is_valid_sha256_pin(value: str) -> bool: 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 [] @@ -69,19 +72,23 @@ def write_nodes(nodes): 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): +def add_node(name: str, ip: str, sni: str | None = None, pinSHA256: str | None = None, port: int | 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) + 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) @@ -95,6 +102,8 @@ def add_node(name: str, ip: str, sni: str | None = None, pinSHA256: str | None = new_node["sni"] = sni.strip() if pinSHA256: new_node["pinSHA256"] = pinSHA256.strip().upper() + if port: + new_node["port"] = port nodes.append(new_node) write_nodes(nodes) @@ -118,14 +127,15 @@ def list_nodes(): print("No nodes configured.") return - print(f"{'Name':<20} {'IP / Domain':<25} {'SNI':<25} {'Pin SHA256'}") - print(f"{'-'*20} {'-'*25} {'-'*25} {'-'*30}") + print(f"{'Name':<20} {'IP / Domain':<25} {'Port':<10} {'SNI':<25} {'Pin SHA256'}") + print(f"{'-'*20} {'-'*25} {'-'*10} {'-'*25} {'-'*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') pin = node.get('pinSHA256', 'N/A') - print(f"{name:<20} {ip:<25} {sni:<25} {pin}") + print(f"{name:<20} {ip:<25} {str(port):<10} {sni:<25} {pin}") def generate_cert(): try: @@ -185,6 +195,7 @@ def main(): 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 (e.g., yourdomain.com).') add_parser.add_argument('--pinSHA256', type=str, help='Optional: The public key SHA256 pin.') @@ -198,7 +209,7 @@ def main(): args = parser.parse_args() if args.command == 'add': - add_node(args.name, args.ip, args.sni, args.pinSHA256) + add_node(args.name, args.ip, args.sni, args.pinSHA256, args.port) elif args.command == 'delete': delete_node(args.name) elif args.command == 'list': From dd9f2a3e578c26fd0182642f7450c121391bd6f2 Mon Sep 17 00:00:00 2001 From: ReturnFI <151555003+ReturnFI@users.noreply.github.com> Date: Thu, 16 Oct 2025 19:51:58 +0000 Subject: [PATCH 03/13] feat(nodes): Add optional obfs parameter for node management --- core/cli.py | 5 +++-- core/cli_api.py | 4 +++- core/scripts/nodes/node.py | 22 +++++++++++++++------- 3 files changed, 21 insertions(+), 10 deletions(-) diff --git a/core/cli.py b/core/cli.py index 407ae19..4b1f0ab 100644 --- a/core/cli.py +++ b/core/cli.py @@ -334,10 +334,11 @@ def node(): @click.option('--port', required=False, type=int, help='Optional: The port of the node.') @click.option('--sni', required=False, type=str, help='Optional: The Server Name Indication.') @click.option('--pinSHA256', required=False, type=str, help='Optional: The public key SHA256 pin.') -def add_node(name, ip, port, sni, pinsha256): +@click.option('--obfs', required=False, type=str, help='Optional: The obfuscation key/password.') +def add_node(name, ip, port, sni, pinsha256, obfs): """Add a new external node.""" try: - output = cli_api.add_node(name, ip, sni, pinSHA256=pinsha256, port=port) + output = cli_api.add_node(name, ip, sni, pinSHA256=pinsha256, port=port, obfs=obfs) click.echo(output.strip()) except Exception as e: click.echo(f'{e}', err=True) diff --git a/core/cli_api.py b/core/cli_api.py index 87b86bf..045b2ef 100644 --- a/core/cli_api.py +++ b/core/cli_api.py @@ -464,7 +464,7 @@ 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, sni: Optional[str] = None, pinSHA256: Optional[str] = None, port: Optional[int] = None): +def add_node(name: str, ip: str, sni: Optional[str] = None, pinSHA256: Optional[str] = None, port: Optional[int] = None, obfs: Optional[str] = None): """ Adds a new external node. """ @@ -475,6 +475,8 @@ def add_node(name: str, ip: str, sni: Optional[str] = None, pinSHA256: Optional[ command.extend(['--sni', sni]) if pinSHA256: command.extend(['--pinSHA256', pinSHA256]) + if obfs: + command.extend(['--obfs', obfs]) return run_cmd(command) def delete_node(name: str): diff --git a/core/scripts/nodes/node.py b/core/scripts/nodes/node.py index 050bb43..7f697c8 100644 --- a/core/scripts/nodes/node.py +++ b/core/scripts/nodes/node.py @@ -72,7 +72,7 @@ def write_nodes(nodes): 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): +def add_node(name: str, ip: str, sni: str | None = None, pinSHA256: str | None = None, port: int | None = None, obfs: 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) @@ -89,6 +89,10 @@ def add_node(name: str, ip: str, sni: str | None = None, pinSHA256: str | None = print(f"Error: Port '{port}' must be between 1 and 65535.", file=sys.stderr) sys.exit(1) + if obfs and not obfs.strip(): + print(f"Error: OBFS value cannot be empty or just whitespace.", 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) @@ -98,12 +102,14 @@ def add_node(name: str, ip: str, sni: str | None = None, pinSHA256: str | None = sys.exit(1) new_node = {"name": name, "ip": ip} + if port: + new_node["port"] = port 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() nodes.append(new_node) write_nodes(nodes) @@ -127,15 +133,16 @@ def list_nodes(): print("No nodes configured.") return - print(f"{'Name':<20} {'IP / Domain':<25} {'Port':<10} {'SNI':<25} {'Pin SHA256'}") - print(f"{'-'*20} {'-'*25} {'-'*10} {'-'*25} {'-'*30}") + print(f"{'Name':<15} {'IP / Domain':<25} {'Port':<8} {'OBFS':<15} {'SNI':<20} {'Pin SHA256'}") + print(f"{'-'*15} {'-'*25} {'-'*8} {'-'*15} {'-'*20} {'-'*30}") for node in sorted(nodes, key=lambda x: x['name']): name = node['name'] ip = node['ip'] port = node.get('port', 'N/A') + obfs = node.get('obfs', 'N/A') sni = node.get('sni', 'N/A') pin = node.get('pinSHA256', 'N/A') - print(f"{name:<20} {ip:<25} {str(port):<10} {sni:<25} {pin}") + print(f"{name:<15} {ip:<25} {str(port):<8} {obfs:<15} {sni:<20} {pin}") def generate_cert(): try: @@ -198,6 +205,7 @@ def main(): 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 (e.g., yourdomain.com).') 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/password.') 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.') @@ -209,7 +217,7 @@ def main(): args = parser.parse_args() if args.command == 'add': - add_node(args.name, args.ip, args.sni, args.pinSHA256, args.port) + add_node(args.name, args.ip, args.sni, args.pinSHA256, args.port, args.obfs) elif args.command == 'delete': delete_node(args.name) elif args.command == 'list': From 70fab7169ef1cd413aa699398869b9bb8fe318e0 Mon Sep 17 00:00:00 2001 From: ReturnFI <151555003+ReturnFI@users.noreply.github.com> Date: Thu, 16 Oct 2025 20:33:22 +0000 Subject: [PATCH 04/13] feat(api): Add full node configuration to web panel API --- .../webpanel/routers/api/v1/config/ip.py | 10 +++- .../routers/api/v1/schema/config/ip.py | 48 ++++++++++++++++++- 2 files changed, 54 insertions(+), 4 deletions(-) diff --git a/core/scripts/webpanel/routers/api/v1/config/ip.py b/core/scripts/webpanel/routers/api/v1/config/ip.py index bcd8398..f981673 100644 --- a/core/scripts/webpanel/routers/api/v1/config/ip.py +++ b/core/scripts/webpanel/routers/api/v1/config/ip.py @@ -93,7 +93,14 @@ async def add_node(body: AddNodeBody): body: Request body containing the name and IP of the node. """ try: - cli_api.add_node(body.name, body.ip) + cli_api.add_node( + name=body.name, + ip=body.ip, + port=body.port, + sni=body.sni, + pinSHA256=body.pinSHA256, + obfs=body.obfs + ) return DetailResponse(detail=f"Node '{body.name}' added successfully.") except Exception as e: raise HTTPException(status_code=400, detail=str(e)) @@ -102,7 +109,6 @@ async def add_node(body: AddNodeBody): @router.post('/nodes/delete', response_model=DetailResponse, summary='Delete External Node') async def delete_node(body: DeleteNodeBody): """ - Deletes an external node from the configuration by its name. Args: diff --git a/core/scripts/webpanel/routers/api/v1/schema/config/ip.py b/core/scripts/webpanel/routers/api/v1/schema/config/ip.py index 538d2e7..73f293c 100644 --- a/core/scripts/webpanel/routers/api/v1/schema/config/ip.py +++ b/core/scripts/webpanel/routers/api/v1/schema/config/ip.py @@ -1,6 +1,7 @@ -from pydantic import BaseModel, field_validator, ValidationInfo -from ipaddress import IPv4Address, IPv6Address, ip_address +from pydantic import BaseModel, field_validator, Field +from ipaddress import ip_address import re +from typing import Optional def validate_ip_or_domain(v: str) -> str | None: if v is None or v.strip() in ['', 'None']: @@ -34,6 +35,10 @@ class EditInputBody(StatusResponse): class Node(BaseModel): name: str ip: str + port: Optional[int] = Field(default=None, ge=1, le=65535) + sni: Optional[str] = None + pinSHA256: Optional[str] = None + obfs: Optional[str] = None @field_validator('ip', mode='before') def check_node_ip(cls, v: str | None): @@ -41,6 +46,45 @@ class Node(BaseModel): raise ValueError("IP or Domain field cannot be empty.") return validate_ip_or_domain(v) + @field_validator('sni', mode='before') + def validate_sni_format(cls, v: str | None): + if v is None or not v.strip(): + return None + + v_stripped = v.strip() + + if "://" in v_stripped: + raise ValueError("SNI must not contain a protocol (e.g., http://).") + + try: + ip_address(v_stripped) + raise ValueError("SNI cannot be an IP address.") + except ValueError as e: + if "SNI cannot be an IP address" in str(e): + raise e + + 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 + ) + if not domain_regex.match(v_stripped): + raise ValueError(f"'{v_stripped}' is not a valid domain name for SNI.") + + return v_stripped + + @field_validator('pinSHA256', mode='before') + def validate_pin_format(cls, v: str | None): + if v is None or not v.strip(): + return None + + v_stripped = v.strip().upper() + pin_regex = re.compile(r'^([0-9A-F]{2}:){31}[0-9A-F]{2}$') + + if not pin_regex.match(v_stripped): + raise ValueError("Invalid SHA256 pin format.") + + return v_stripped + class AddNodeBody(Node): pass From 5b7a1cf5c30fac19cecd15195a0e194740a950ef Mon Sep 17 00:00:00 2001 From: ReturnFI <151555003+ReturnFI@users.noreply.github.com> Date: Thu, 16 Oct 2025 20:34:22 +0000 Subject: [PATCH 05/13] feat(web): Implement full node configuration in settings UI --- core/scripts/webpanel/assets/js/settings.js | 67 ++++++++++++++++++- core/scripts/webpanel/templates/settings.html | 52 ++++++++++---- 2 files changed, 105 insertions(+), 14 deletions(-) diff --git a/core/scripts/webpanel/assets/js/settings.js b/core/scripts/webpanel/assets/js/settings.js index 79567d6..d97c2a4 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,10 @@ $(document).ready(function () { const row = ` ${escapeHtml(node.name)} ${escapeHtml(node.ip)} + ${escapeHtml(node.port || 'N/A')} + ${escapeHtml(node.sni || 'N/A')} + ${escapeHtml(node.obfs || 'N/A')} + ${escapeHtml(node.pinSHA256 || 'N/A')}