From 6f7d095bad889ff4f89132c06ebfeb1d4a5db6ed Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 28 Jul 2025 16:08:42 +0000 Subject: [PATCH 01/20] chore(deps): Bump pytelegrambotapi from 4.27.0 to 4.28.0 Bumps [pytelegrambotapi](https://github.com/eternnoir/pyTelegramBotAPI) from 4.27.0 to 4.28.0. - [Release notes](https://github.com/eternnoir/pyTelegramBotAPI/releases) - [Commits](https://github.com/eternnoir/pyTelegramBotAPI/compare/4.27.0...4.28.0) --- updated-dependencies: - dependency-name: pytelegrambotapi dependency-version: 4.28.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 0aee3c1..83c3863 100644 --- a/requirements.txt +++ b/requirements.txt @@ -13,7 +13,7 @@ pillow==11.3.0 propcache==0.3.2 psutil==7.0.0 pypng==0.20220715.0 -pyTelegramBotAPI==4.27.0 +pyTelegramBotAPI==4.28.0 python-dotenv==1.1.1 qrcode==8.2 requests==2.32.4 From 9a34df8cd80879e2d2f6df34b721f0bda961099a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 29 Jul 2025 13:29:33 +0000 Subject: [PATCH 02/20] chore(deps): Bump aiohttp from 3.12.14 to 3.12.15 --- updated-dependencies: - dependency-name: aiohttp dependency-version: 3.12.15 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 0aee3c1..8824964 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ aiohappyeyeballs==2.6.1 -aiohttp==3.12.14 +aiohttp==3.12.15 aiosignal==1.4.0 async-timeout==5.0.1 attrs==25.3.0 From c5f1b6d447a38aa6086cfb33189b775826f87eda Mon Sep 17 00:00:00 2001 From: Whispering Wind <151555003+ReturnFI@users.noreply.github.com> Date: Mon, 4 Aug 2025 13:49:45 +0200 Subject: [PATCH 03/20] feat(core): Implement external node management Introduces a dedicated system for managing a list of external nodes, each with a unique name and IP address. This feature is designed for multi-node deployments. --- core/cli.py | 40 ++++++++++++-- core/cli_api.py | 18 +++++++ core/scripts/hysteria2/node.py | 97 ++++++++++++++++++++++++++++++++++ 3 files changed, 151 insertions(+), 4 deletions(-) create mode 100644 core/scripts/hysteria2/node.py 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..5df4125 100644 --- a/core/cli_api.py +++ b/core/cli_api.py @@ -32,6 +32,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) @@ -426,6 +427,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/node.py b/core/scripts/hysteria2/node.py new file mode 100644 index 0000000..2c7921b --- /dev/null +++ b/core/scripts/hysteria2/node.py @@ -0,0 +1,97 @@ +#!/usr/bin/env python3 + +import sys +import json +import argparse +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)) + + from paths import NODES_JSON_PATH + + + +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: + sys.exit(f"Error: Could not decode JSON from {NODES_JSON_PATH}") + except (IOError, OSError) as e: + sys.exit(f"Error reading from {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): + 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 '{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 '{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':<20} {'IP Address'}") + print(f"{'-'*20} {'-'*15}") + for node in sorted(nodes, key=lambda x: x['name']): + print(f"{node['name']:<20} {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 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 From 57f8229fac85d3a75250b509946d9b01bfb1dd08 Mon Sep 17 00:00:00 2001 From: Whispering Wind <151555003+ReturnFI@users.noreply.github.com> Date: Mon, 4 Aug 2025 13:52:44 +0200 Subject: [PATCH 04/20] feat(core): Generate URIs for external nodes --- core/scripts/hysteria2/show_user_uri.py | 103 +++++++++++++----------- core/scripts/paths.py | 1 + 2 files changed, 57 insertions(+), 47 deletions(-) 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/paths.py b/core/scripts/paths.py index 850499e..d59c6ec 100644 --- a/core/scripts/paths.py +++ b/core/scripts/paths.py @@ -7,6 +7,7 @@ USERS_FILE = BASE_DIR / "users.json" TRAFFIC_FILE = BASE_DIR / "traffic_data.json" CONFIG_FILE = BASE_DIR / "config.json" CONFIG_ENV = BASE_DIR / ".configs.env" +NODES_JSON_PATH = BASE_DIR / "nodes.json" TELEGRAM_ENV = BASE_DIR / "core/scripts/telegrambot/.env" SINGBOX_ENV = BASE_DIR / "core/scripts/singbox/.env" NORMALSUB_ENV = BASE_DIR / "core/scripts/normalsub/.env" From 4fe3d211d5b1867e65b95141c9d57e3dd3e80f75 Mon Sep 17 00:00:00 2001 From: Whispering Wind <151555003+ReturnFI@users.noreply.github.com> Date: Mon, 4 Aug 2025 13:54:27 +0200 Subject: [PATCH 05/20] feat(core): Update wrapper_uri to parse external node links --- core/scripts/hysteria2/wrapper_uri.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) 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 } From a16467faa10755a2ea7ea06d107b3ce7b3aebd89 Mon Sep 17 00:00:00 2001 From: Seyed Mahdi <39972836+SeyedHashtag@users.noreply.github.com> Date: Mon, 4 Aug 2025 22:14:51 +0330 Subject: [PATCH 06/20] Refactor DNS configuration in singbox.json for improved routing and logging. --- core/scripts/normalsub/singbox.json | 226 ++++++++++++++-------------- 1 file changed, 115 insertions(+), 111 deletions(-) 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 From 05993d013da18d0fc2d8a904a3e14d524bcd049b Mon Sep 17 00:00:00 2001 From: Whispering Wind <151555003+ReturnFI@users.noreply.github.com> Date: Wed, 6 Aug 2025 14:45:35 +0330 Subject: [PATCH 07/20] feat(api): Add external node management endpoints --- core/cli_api.py | 1 + .../webpanel/routers/api/v1/config/ip.py | 88 ++++++++++++++----- .../routers/api/v1/schema/config/ip.py | 24 ++++- .../webpanel/routers/api/v1/schema/user.py | 14 ++- core/scripts/webpanel/routers/api/v1/user.py | 4 +- 5 files changed, 103 insertions(+), 28 deletions(-) diff --git a/core/cli_api.py b/core/cli_api.py index 5df4125..976bf2f 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): diff --git a/core/scripts/webpanel/routers/api/v1/config/ip.py b/core/scripts/webpanel/routers/api/v1/config/ip.py index 211e03d..bcd8398 100644 --- a/core/scripts/webpanel/routers/api/v1/config/ip.py +++ b/core/scripts/webpanel/routers/api/v1/config/ip.py @@ -1,44 +1,43 @@ from fastapi import APIRouter, HTTPException from ..schema.response import DetailResponse +import json +import os - -from ..schema.config.ip import EditInputBody, StatusResponse +from ..schema.config.ip import ( + EditInputBody, + StatusResponse, + AddNodeBody, + DeleteNodeBody, + NodeListResponse +) import cli_api router = APIRouter() -@router.get('/get', response_model=StatusResponse, summary='Get IP Status') +@router.get('/get', response_model=StatusResponse, summary='Get Local Server IP Status') async def get_ip_api(): """ - Retrieves the current status of IP addresses. + Retrieves the current status of the main server's IP addresses. Returns: StatusResponse: A response model containing the current IP address details. - - Raises: - HTTPException: If the IP status is not available (404) or if there is an error processing the request (400). """ try: ipv4, ipv6 = cli_api.get_ip_address() - if ipv4 or ipv6: - return StatusResponse(ipv4=ipv4, ipv6=ipv6) # type: ignore - raise HTTPException(status_code=404, detail='IP status not available.') + return StatusResponse(ipv4=ipv4, ipv6=ipv6) except Exception as e: raise HTTPException(status_code=400, detail=f'Error: {str(e)}') -@router.get('/add', response_model=DetailResponse, summary='Add IP') +@router.get('/add', response_model=DetailResponse, summary='Detect and Add Local Server IP') async def add_ip_api(): """ Adds the auto-detected IP addresses to the .configs.env file. Returns: A DetailResponse with a message indicating the IP addresses were added successfully. - - Raises: - HTTPException: if an error occurs while adding the IP addresses. """ try: cli_api.add_ip_address() @@ -47,19 +46,13 @@ async def add_ip_api(): raise HTTPException(status_code=400, detail=f'Error: {str(e)}') -@router.post('/edit', response_model=DetailResponse, summary='Edit IP') +@router.post('/edit', response_model=DetailResponse, summary='Edit Local Server IP') async def edit_ip_api(body: EditInputBody): """ - Edits the IP addresses in the .configs.env file. + Edits the main server's IP addresses in the .configs.env file. Args: body: An instance of EditInputBody containing the new IPv4 and/or IPv6 addresses. - - Returns: - A DetailResponse with a message indicating the IP addresses were edited successfully. - - Raises: - HTTPException: if an error occurs while editing the IP addresses. """ try: if not body.ipv4 and not body.ipv6: @@ -69,3 +62,54 @@ async def edit_ip_api(body: EditInputBody): return DetailResponse(detail='IP address edited successfully.') except Exception as e: raise HTTPException(status_code=400, detail=f'Error: {str(e)}') + + +@router.get('/nodes', response_model=NodeListResponse, summary='Get All External Nodes') +async def get_all_nodes(): + """ + Retrieves the list of all configured external nodes. + + Returns: + A list of node objects, each containing a name and an IP. + """ + if not os.path.exists(cli_api.NODES_JSON_PATH): + return [] + try: + with open(cli_api.NODES_JSON_PATH, 'r') as f: + content = f.read() + if not content: + return [] + return json.loads(content) + except (json.JSONDecodeError, IOError) as e: + raise HTTPException(status_code=500, detail=f"Failed to read or parse nodes file: {e}") + + +@router.post('/nodes/add', response_model=DetailResponse, summary='Add External Node') +async def add_node(body: AddNodeBody): + """ + Adds a new external node to the configuration. + + Args: + body: Request body containing the name and IP of the node. + """ + try: + cli_api.add_node(body.name, body.ip) + return DetailResponse(detail=f"Node '{body.name}' added successfully.") + except Exception as e: + raise HTTPException(status_code=400, detail=str(e)) + + +@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: + body: Request body containing the name of the node to delete. + """ + try: + cli_api.delete_node(body.name) + return DetailResponse(detail=f"Node '{body.name}' deleted successfully.") + except Exception as e: + raise HTTPException(status_code=400, detail=str(e)) \ No newline at end of file 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 adfa1ad..31589f9 100644 --- a/core/scripts/webpanel/routers/api/v1/schema/config/ip.py +++ b/core/scripts/webpanel/routers/api/v1/schema/config/ip.py @@ -21,4 +21,26 @@ class StatusResponse(BaseModel): raise ValueError(f"'{v}' is not a valid IPv4 or IPv6 address or domain name") class EditInputBody(StatusResponse): - pass \ No newline at end of file + pass + +class Node(BaseModel): + name: str + ip: str + + @field_validator('ip', mode='before') + def check_ip(cls, v: str, info: ValidationInfo): + if v is None: + raise ValueError("IP cannot be None") + try: + ip_address(v) + return v + except ValueError: + raise ValueError(f"'{v}' is not a valid IPv4 or IPv6 address") + +class AddNodeBody(Node): + pass + +class DeleteNodeBody(BaseModel): + name: str + +NodeListResponse = list[Node] \ No newline at end of file diff --git a/core/scripts/webpanel/routers/api/v1/schema/user.py b/core/scripts/webpanel/routers/api/v1/schema/user.py index 7cc2629..25c9f50 100644 --- a/core/scripts/webpanel/routers/api/v1/schema/user.py +++ b/core/scripts/webpanel/routers/api/v1/schema/user.py @@ -1,4 +1,4 @@ -from typing import Optional +from typing import Optional, List from pydantic import BaseModel, RootModel @@ -38,8 +38,14 @@ class EditUserInputBody(BaseModel): renew_creation_date: bool = False blocked: bool = False +class NodeUri(BaseModel): + name: str + uri: str + class UserUriResponse(BaseModel): username: str - ipv4: str | None = None - ipv6: str | None = None - normal_sub: str | None = None \ No newline at end of file + ipv4: Optional[str] = None + ipv6: Optional[str] = None + nodes: Optional[List[NodeUri]] = [] + normal_sub: Optional[str] = None + error: Optional[str] = None \ No newline at end of file diff --git a/core/scripts/webpanel/routers/api/v1/user.py b/core/scripts/webpanel/routers/api/v1/user.py index 550ed42..e55e595 100644 --- a/core/scripts/webpanel/routers/api/v1/user.py +++ b/core/scripts/webpanel/routers/api/v1/user.py @@ -178,10 +178,12 @@ async def show_user_uri_api(username: str): uri_data_list = cli_api.show_user_uri_json([username]) if not uri_data_list: raise HTTPException(status_code=404, detail=f'URI for user {username} not found.') + uri_data = uri_data_list[0] if uri_data.get('error'): raise HTTPException(status_code=404, detail=f"{uri_data['error']}") - return uri_data + + return UserUriResponse(**uri_data) except cli_api.ScriptNotFoundError as e: raise HTTPException(status_code=500, detail=f'Server script error: {str(e)}') except cli_api.CommandExecutionError as e: From 133684f4fe19fe0d9bddc075d286ae6fd0c0f40e Mon Sep 17 00:00:00 2001 From: Whispering Wind <151555003+ReturnFI@users.noreply.github.com> Date: Wed, 6 Aug 2025 15:16:27 +0330 Subject: [PATCH 08/20] fix: Resolve IP/domain validation --- core/scripts/hysteria2/node.py | 41 +++- .../routers/api/v1/schema/config/ip.py | 46 ++-- core/scripts/webpanel/templates/settings.html | 214 ++++++++++++++---- 3 files changed, 229 insertions(+), 72 deletions(-) diff --git a/core/scripts/hysteria2/node.py b/core/scripts/hysteria2/node.py index 2c7921b..1de692f 100644 --- a/core/scripts/hysteria2/node.py +++ b/core/scripts/hysteria2/node.py @@ -4,14 +4,33 @@ 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(): @@ -22,10 +41,8 @@ def read_nodes(): if not content: return [] return json.loads(content) - except json.JSONDecodeError: - sys.exit(f"Error: Could not decode JSON from {NODES_JSON_PATH}") - except (IOError, OSError) as e: - sys.exit(f"Error reading from {NODES_JSON_PATH}: {e}") + except (json.JSONDecodeError, IOError, OSError) as e: + sys.exit(f"Error reading or parsing {NODES_JSON_PATH}: {e}") def write_nodes(nodes): try: @@ -36,17 +53,21 @@ def write_nodes(nodes): 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 '{ip}' already exists.", file=sys.stderr) + 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 '{ip}'.") + print(f"Successfully added node '{name}' with IP/domain '{ip}'.") def delete_node(name: str): nodes = read_nodes() @@ -66,10 +87,10 @@ def list_nodes(): print("No nodes configured.") return - print(f"{'Name':<20} {'IP Address'}") - print(f"{'-'*20} {'-'*15}") + 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']:<20} {node['ip']}") + print(f"{node['name']:<30} {node['ip']}") def main(): parser = argparse.ArgumentParser(description="Manage external node configurations.") @@ -77,7 +98,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 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.') 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 31589f9..538d2e7 100644 --- a/core/scripts/webpanel/routers/api/v1/schema/config/ip.py +++ b/core/scripts/webpanel/routers/api/v1/schema/config/ip.py @@ -1,24 +1,32 @@ from pydantic import BaseModel, field_validator, ValidationInfo from ipaddress import IPv4Address, IPv6Address, ip_address -import socket +import re + +def validate_ip_or_domain(v: str) -> str | None: + if v is None or v.strip() in ['', 'None']: + return None + + v_stripped = v.strip() + + try: + ip_address(v_stripped) + return v_stripped + 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 + ) + if domain_regex.match(v_stripped): + return v_stripped + raise ValueError(f"'{v_stripped}' is not a valid IP address or domain name.") class StatusResponse(BaseModel): ipv4: str | None = None ipv6: str | None = None @field_validator('ipv4', 'ipv6', mode='before') - def check_ip_or_domain(cls, v: str, info: ValidationInfo): - if v is None: - return v - try: - ip_address(v) - return v - except ValueError: - try: - socket.getaddrinfo(v, None) - return v - except socket.gaierror: - raise ValueError(f"'{v}' is not a valid IPv4 or IPv6 address or domain name") + def check_local_server_ip(cls, v: str | None): + return validate_ip_or_domain(v) class EditInputBody(StatusResponse): pass @@ -28,14 +36,10 @@ class Node(BaseModel): ip: str @field_validator('ip', mode='before') - def check_ip(cls, v: str, info: ValidationInfo): - if v is None: - raise ValueError("IP cannot be None") - try: - ip_address(v) - return v - except ValueError: - raise ValueError(f"'{v}' is not a valid IPv4 or IPv6 address") + def check_node_ip(cls, v: str | None): + if not v or not v.strip(): + raise ValueError("IP or Domain field cannot be empty.") + return validate_ip_or_domain(v) class AddNodeBody(Node): pass diff --git a/core/scripts/webpanel/templates/settings.html b/core/scripts/webpanel/templates/settings.html index 198a0e0..b9f78eb 100644 --- a/core/scripts/webpanel/templates/settings.html +++ b/core/scripts/webpanel/templates/settings.html @@ -49,7 +49,7 @@