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 @@