Merge pull request #239 from ReturnFI/beta

External Node Management & Fixes
This commit is contained in:
Whispering Wind
2025-08-10 00:23:07 +03:30
committed by GitHub
21 changed files with 915 additions and 457 deletions

View File

@ -1,7 +1,10 @@
# [1.12.1] - 2025-07-09 # [1.13.0] - 2025-08-10
#### ✨ UI Enhancements #### ✨ UI Enhancements
* 📌 **Sticky Sidebar:** Sidebar now stays fixed when scrolling through long pages for easier navigation * ✨ feat(core): Implement external node management system
* 🃏 **Sticky Headers:** Card headers are now sticky with a sleek **bokeh blur** effect better usability on settings and user lists * 🌐 feat(api): Add external node management endpoints
* ⏎ **Login UX:** Pressing **Enter** now submits the login form properly for faster access * 🔗 feat(normalsub): Add external node URIs to subscriptions
* 💾 feat: Backup nodes.json
* 🛠️ fix: Robust parsing, unlimited user handling, and various script/UI issues
* 📦 chore: Dependency updates (pytelegrambotapi, aiohttp)

View File

@ -298,6 +298,41 @@ def ip_address(edit: bool, ipv4: str, ipv6: str):
click.echo(f'{e}', err=True) 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') @cli.command('update-geo')
@click.option('--country', '-c', @click.option('--country', '-c',
type=click.Choice(['iran', 'china', 'russia'], case_sensitive=False), 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') raise click.UsageError('Error: You cannot use both --remove and --enable at the same time')
if enable: 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) cli_api.enable_hysteria2_masquerade(enable)
click.echo('Masquerade enabled successfully.') click.echo('Masquerade enabled successfully.')
elif remove: elif remove:

View File

@ -14,6 +14,7 @@ CONFIG_FILE = '/etc/hysteria/config.json'
CONFIG_ENV_FILE = '/etc/hysteria/.configs.env' CONFIG_ENV_FILE = '/etc/hysteria/.configs.env'
WEBPANEL_ENV_FILE = '/etc/hysteria/core/scripts/webpanel/.env' WEBPANEL_ENV_FILE = '/etc/hysteria/core/scripts/webpanel/.env'
NORMALSUB_ENV_FILE = '/etc/hysteria/core/scripts/normalsub/.env' NORMALSUB_ENV_FILE = '/etc/hysteria/core/scripts/normalsub/.env'
NODES_JSON_PATH = "/etc/hysteria/nodes.json"
class Command(Enum): class Command(Enum):
@ -32,6 +33,7 @@ class Command(Enum):
SHOW_USER_URI = os.path.join(SCRIPT_DIR, 'hysteria2', 'show_user_uri.py') SHOW_USER_URI = os.path.join(SCRIPT_DIR, 'hysteria2', 'show_user_uri.py')
WRAPPER_URI = os.path.join(SCRIPT_DIR, 'hysteria2', 'wrapper_uri.py') WRAPPER_URI = os.path.join(SCRIPT_DIR, 'hysteria2', 'wrapper_uri.py')
IP_ADD = os.path.join(SCRIPT_DIR, 'hysteria2', 'ip.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') MANAGE_OBFS = os.path.join(SCRIPT_DIR, 'hysteria2', 'manage_obfs.py')
MASQUERADE_SCRIPT = os.path.join(SCRIPT_DIR, 'hysteria2', 'masquerade.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) TRAFFIC_STATUS = 'traffic.py' # won't be called directly (it's a python module)
@ -281,20 +283,22 @@ def edit_user(username: str, new_username: str | None, new_traffic_limit: int |
''' '''
if not username: if not username:
raise InvalidInputError('Error: username is required') raise InvalidInputError('Error: username is required')
if not any([new_username, new_traffic_limit, new_expiration_days, renew_password, renew_creation_date, blocked is not None]): # type: ignore
raise InvalidInputError('Error: at least one option is required') if new_traffic_limit is not None and new_traffic_limit < 0:
if new_traffic_limit is not None and new_traffic_limit <= 0: raise InvalidInputError('Error: traffic limit must be a non-negative number.')
raise InvalidInputError('Error: traffic limit must be greater than 0') if new_expiration_days is not None and new_expiration_days < 0:
if new_expiration_days is not None and new_expiration_days <= 0: raise InvalidInputError('Error: expiration days must be a non-negative number.')
raise InvalidInputError('Error: expiration days must be greater than 0')
if renew_password: if renew_password:
password = generate_password() password = generate_password()
else: else:
password = '' password = ''
if renew_creation_date: if renew_creation_date:
creation_date = datetime.now().strftime('%Y-%m-%d') creation_date = datetime.now().strftime('%Y-%m-%d')
else: else:
creation_date = '' creation_date = ''
command_args = [ command_args = [
'bash', 'bash',
Command.EDIT_USER.value, Command.EDIT_USER.value,
@ -426,6 +430,23 @@ def edit_ip_address(ipv4: str, ipv6: str):
if ipv6: if ipv6:
run_cmd(['python3', Command.IP_ADD.value, 'edit', '-6', 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): def update_geo(country: str):
''' '''

View File

@ -20,11 +20,11 @@ validate_username() {
validate_traffic_limit() { validate_traffic_limit() {
local traffic_limit=$1 local traffic_limit=$1
if [ -z "$traffic_limit" ]; then if [ -z "$traffic_limit" ]; then
return 0 # Optional value is valid return 0 # Optional value is valid
fi fi
if ! [[ "$traffic_limit" =~ ^[0-9]+$ ]]; then if ! [[ "$traffic_limit" =~ ^[0-9]+$ ]]; then
echo "Traffic limit must be a valid integer." echo "Error: Traffic limit must be a valid non-negative number (use 0 for unlimited)."
return 1 return 1
fi fi
return 0 return 0
@ -32,11 +32,11 @@ validate_traffic_limit() {
validate_expiration_days() { validate_expiration_days() {
local expiration_days=$1 local expiration_days=$1
if [ -z "$expiration_days" ]; then if [ -z "$expiration_days" ]; then
return 0 # Optional value is valid return 0 # Optional value is valid
fi fi
if ! [[ "$expiration_days" =~ ^[0-9]+$ ]]; then if ! [[ "$expiration_days" =~ ^[0-9]+$ ]]; then
echo "Expiration days must be a valid integer." echo "Error: Expiration days must be a valid non-negative number (use 0 for unlimited)."
return 1 return 1
fi fi
return 0 return 0

View File

@ -0,0 +1,118 @@
#!/usr/bin/env python3
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():
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()

View File

@ -24,6 +24,18 @@ def load_env_file(env_file: str) -> Dict[str, str]:
env_vars[key] = value env_vars[key] = value
return env_vars 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]: def load_hysteria2_env() -> Dict[str, str]:
"""Load Hysteria2 environment variables.""" """Load Hysteria2 environment variables."""
return load_env_file(CONFIG_ENV) return load_env_file(CONFIG_ENV)
@ -63,7 +75,8 @@ def is_service_active(service_name: str) -> bool:
return False return False
def generate_uri(username: str, auth_password: str, ip: str, port: str, 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.""" """Generate Hysteria2 URI for the given parameters."""
uri_base = f"hy2://{username}%3A{auth_password}@{ip}:{port}" 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.append(f"insecure={insecure_value}&sni={sni}")
params_str = "&".join(params) 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]: def generate_qr_code(uri: str) -> List[str]:
"""Generate terminal-friendly ASCII QR code using pure Python.""" """Generate terminal-friendly ASCII QR code using pure Python."""
@ -113,8 +126,21 @@ def get_terminal_width() -> int:
except (AttributeError, OSError): except (AttributeError, OSError):
return 80 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: 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): if not os.path.exists(USERS_FILE):
print(f"\033[0;31mError:\033[0m Config file {USERS_FILE} not found.") print(f"\033[0;31mError:\033[0m Config file {USERS_FILE} not found.")
return return
@ -137,53 +163,36 @@ def show_uri(args: argparse.Namespace) -> None:
port = config["listen"].split(":")[1] if ":" in config["listen"] else config["listen"] port = config["listen"].split(":")[1] if ":" in config["listen"] else config["listen"]
sha256 = config.get("tls", {}).get("pinSHA256", "") sha256 = config.get("tls", {}).get("pinSHA256", "")
obfs_password = config.get("obfs", {}).get("salamander", {}).get("password", "") obfs_password = config.get("obfs", {}).get("salamander", {}).get("password", "")
insecure = config.get("tls", {}).get("insecure", True) insecure = config.get("tls", {}).get("insecure", True)
ip4, ip6, sni = load_hysteria2_ips() ip4, ip6, sni = load_hysteria2_ips()
available_ip4 = ip4 and ip4 != "None" nodes = load_nodes()
available_ip6 = ip6 and ip6 != "None" terminal_width = get_terminal_width()
uri_ipv4 = None if args.all or args.ip_version == 4:
uri_ipv6 = None 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: if args.all or args.ip_version == 6:
if available_ip4: if ip6 and ip6 != "None":
uri_ipv4 = generate_uri(args.username, auth_password, ip4, port, uri = generate_uri(args.username, auth_password, ip6, port,
obfs_password, sha256, sni, 4, insecure) obfs_password, sha256, sni, 6, insecure, f"{args.username}-IPv6")
print(f"\nIPv4:\n{uri_ipv4}\n") display_uri_and_qr(uri, "IPv6", args, terminal_width)
if available_ip6: for node in nodes:
uri_ipv6 = generate_uri(args.username, auth_password, ip6, port, node_name = node.get("name")
obfs_password, sha256, sni, 6, insecure) node_ip = node.get("ip")
print(f"\nIPv6:\n{uri_ipv6}\n") if not node_name or not node_ip:
else: continue
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: ip_v = 4 if '.' in node_ip else 6
terminal_width = get_terminal_width()
if uri_ipv4: if args.all or args.ip_version == ip_v:
qr_code = generate_qr_code(uri_ipv4) uri = generate_uri(args.username, auth_password, node_ip, port,
print("\nIPv4 QR Code:\n") obfs_password, sha256, sni, ip_v, insecure, f"{args.username}-{node_name}")
for line in qr_code: display_uri_and_qr(uri, f"Node: {node_name} (IPv{ip_v})", args, terminal_width)
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.singbox and is_service_active("hysteria-singbox.service"): if args.singbox and is_service_active("hysteria-singbox.service"):
domain, port = get_singbox_domain_and_port() domain, port = get_singbox_domain_and_port()

View File

@ -13,8 +13,8 @@ MAX_DOWNLOAD_BYTES=$(jq -r --arg user "$USERNAME" '.[$user].max_download_bytes'
EXPIRATION_DAYS=$(jq -r --arg user "$USERNAME" '.[$user].expiration_days' "$USERS_FILE") EXPIRATION_DAYS=$(jq -r --arg user "$USERNAME" '.[$user].expiration_days' "$USERS_FILE")
ACCOUNT_CREATION_DATE=$(jq -r --arg user "$USERNAME" '.[$user].account_creation_date' "$USERS_FILE") ACCOUNT_CREATION_DATE=$(jq -r --arg user "$USERNAME" '.[$user].account_creation_date' "$USERS_FILE")
BLOCKED=$(jq -r --arg user "$USERNAME" '.[$user].blocked' "$USERS_FILE") BLOCKED=$(jq -r --arg user "$USERNAME" '.[$user].blocked' "$USERS_FILE")
CURRENT_DOWNLOAD_BYTES=$(jq -r --arg user "$USERNAME" '.[$user].download_bytes' "$USERS_FILE") CURRENT_DOWNLOAD_BYTES=$(jq -r --arg user "$USERNAME" '.[$user].download_bytes // 0' "$USERS_FILE")
CURRENT_UPLOAD_BYTES=$(jq -r --arg user "$USERNAME" '.[$user].upload_bytes' "$USERS_FILE") CURRENT_UPLOAD_BYTES=$(jq -r --arg user "$USERNAME" '.[$user].upload_bytes // 0' "$USERS_FILE")
TOTAL_BYTES=$((CURRENT_DOWNLOAD_BYTES + CURRENT_UPLOAD_BYTES)) TOTAL_BYTES=$((CURRENT_DOWNLOAD_BYTES + CURRENT_UPLOAD_BYTES))
@ -28,21 +28,25 @@ if [ "$STORED_PASSWORD" != "$PASSWORD" ]; then
exit 1 exit 1
fi fi
CURRENT_DATE=$(date +%s) if [ "$EXPIRATION_DAYS" -ne 0 ]; then
EXPIRATION_DATE=$(date -d "$ACCOUNT_CREATION_DATE + $EXPIRATION_DAYS days" +%s) CURRENT_DATE=$(date +%s)
EXPIRATION_DATE=$(date -d "$ACCOUNT_CREATION_DATE + $EXPIRATION_DAYS days" +%s)
if [ "$CURRENT_DATE" -ge "$EXPIRATION_DATE" ]; then if [ "$CURRENT_DATE" -ge "$EXPIRATION_DATE" ]; then
jq --arg user "$USERNAME" '.[$user].blocked = true' "$USERS_FILE" > temp.json && mv temp.json "$USERS_FILE" jq --arg user "$USERNAME" '.[$user].blocked = true' "$USERS_FILE" > temp.json && mv temp.json "$USERS_FILE"
exit 1 exit 1
fi
fi fi
if [ "$TOTAL_BYTES" -ge "$MAX_DOWNLOAD_BYTES" ]; then if [ "$MAX_DOWNLOAD_BYTES" -ne 0 ]; then
SECRET=$(jq -r '.trafficStats.secret' "$CONFIG_FILE") if [ "$TOTAL_BYTES" -ge "$MAX_DOWNLOAD_BYTES" ]; then
KICK_ENDPOINT="http://127.0.0.1:25413/kick" SECRET=$(jq -r '.trafficStats.secret' "$CONFIG_FILE")
curl -s -H "Authorization: $SECRET" -X POST -d "[\"$USERNAME\"]" "$KICK_ENDPOINT" KICK_ENDPOINT="http://127.0.0.1:25413/kick"
curl -s -H "Authorization: $SECRET" -X POST -d "[\"$USERNAME\"]" "$KICK_ENDPOINT"
jq --arg user "$USERNAME" '.[$user].blocked = true' "$USERS_FILE" > temp.json && mv temp.json "$USERS_FILE" jq --arg user "$USERNAME" '.[$user].blocked = true' "$USERS_FILE" > temp.json && mv temp.json "$USERS_FILE"
exit 1 exit 1
fi
fi fi
echo "$USERNAME" echo "$USERNAME"

View File

@ -24,8 +24,8 @@ def parse_output(username, output):
ipv4 = None ipv4 = None
ipv6 = None ipv6 = None
normal_sub = None normal_sub = None
nodes = []
# Match links
ipv4_match = re.search(r"IPv4:\s*(hy2://[^\s]+)", output) ipv4_match = re.search(r"IPv4:\s*(hy2://[^\s]+)", output)
ipv6_match = re.search(r"IPv6:\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) 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: if normal_sub_match:
normal_sub = normal_sub_match.group(1) 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 { return {
"username": username, "username": username,
"ipv4": ipv4, "ipv4": ipv4,
"ipv6": ipv6, "ipv6": ipv6,
"nodes": nodes,
"normal_sub": normal_sub "normal_sub": normal_sub
} }

View File

@ -6,7 +6,7 @@ import time
import shlex import shlex
import base64 import base64
from typing import Dict, List, Optional, Tuple, Any, Union from typing import Dict, List, Optional, Tuple, Any, Union
from dataclasses import dataclass from dataclasses import dataclass, field
from io import BytesIO from io import BytesIO
from aiohttp import web from aiohttp import web
@ -29,6 +29,7 @@ class AppConfig:
singbox_template_path: str singbox_template_path: str
hysteria_cli_path: str hysteria_cli_path: str
users_json_path: str users_json_path: str
nodes_json_path: str
rate_limit: int rate_limit: int
rate_limit_window: int rate_limit_window: int
sni: str sni: str
@ -106,6 +107,13 @@ class UserInfo:
return f"Upload: {upload}, Download: {download}, Total: {total}" return f"Upload: {upload}, Download: {download}, Total: {total}"
@dataclass
class NodeURI:
label: str
uri: str
qrcode: Optional[str] = None
@dataclass @dataclass
class TemplateContext: class TemplateContext:
username: str username: str
@ -113,11 +121,9 @@ class TemplateContext:
usage_raw: str usage_raw: str
expiration_date: str expiration_date: str
sublink_qrcode: str sublink_qrcode: str
ipv4_qrcode: Optional[str]
ipv6_qrcode: Optional[str]
sub_link: str sub_link: str
ipv4_uri: Optional[str] local_uris: List[NodeURI] = field(default_factory=list)
ipv6_uri: Optional[str] node_uris: List[NodeURI] = field(default_factory=list)
class Utils: class Utils:
@ -244,17 +250,21 @@ class HysteriaCLI:
print(f"JSONDecodeError: {e}, Raw output: {raw_info_str}") print(f"JSONDecodeError: {e}, Raw output: {raw_info_str}")
return None return None
def get_user_uri(self, username: str, ip_version: Optional[str] = None) -> str: def get_all_uris(self, username: str) -> List[str]:
if ip_version: """Fetches all available URIs (local and nodes) for a user."""
return self._run_command(['show-user-uri', '-u', username, '-ip', ip_version])
else:
return self._run_command(['show-user-uri', '-u', username, '-a'])
def get_uris(self, username: str) -> Tuple[Optional[str], Optional[str]]:
output = self._run_command(['show-user-uri', '-u', username, '-a']) output = self._run_command(['show-user-uri', '-u', username, '-a'])
ipv4_uri = re.search(r'IPv4:\s*(.*)', output) if not output:
ipv6_uri = re.search(r'IPv6:\s*(.*)', output) return []
return (ipv4_uri.group(1).strip() if ipv4_uri else None, ipv6_uri.group(1).strip() if ipv6_uri else None) return re.findall(r'hy2://.*', output)
def get_all_labeled_uris(self, username: str) -> List[Dict[str, str]]:
"""Fetches all URIs and their labels."""
output = self._run_command(['show-user-uri', '-u', username, '-a'])
if not output:
return []
matches = re.findall(r"^(.*?):\s*(hy2://.*)$", output, re.MULTILINE)
return [{'label': label.strip(), 'uri': uri} for label, uri in matches]
class UriParser: class UriParser:
@ -303,74 +313,74 @@ class SingboxConfigGenerator:
raise RuntimeError(f"Error loading Singbox template: {e}") from e raise RuntimeError(f"Error loading Singbox template: {e}") from e
return self._template_cache.copy() return self._template_cache.copy()
def generate_config(self, username: str, ip_version: str, fragment: str) -> Optional[Dict[str, Any]]: def generate_config_from_uri(self, uri: str, username: str, fragment: str) -> Optional[Dict[str, Any]]:
try: """Generates a Singbox outbound config from a single Hysteria URI."""
uri = self.hysteria_cli.get_user_uri(username, ip_version)
except Exception:
print(f"Failed to get URI for {username} with IP version {ip_version}. Skipping.")
return None
if not uri: if not uri:
print(f"No URI found for {username} with IP version {ip_version}. Skipping.")
return None return None
components = UriParser.extract_uri_components(uri, f'IPv{ip_version}:')
if components is None or components.port is None: try:
print(f"Invalid URI components for {username} with IP version {ip_version}. Skipping.") parsed_url = urlparse(uri)
server = parsed_url.hostname
server_port = parsed_url.port
auth_password = parsed_url.password
auth_user = unquote(parsed_url.username or '')
obfs_password = parse_qs(parsed_url.query).get('obfs-password', [''])[0]
if auth_password:
if auth_user:
final_password = f"{auth_user}:{auth_password}"
else:
final_password = auth_password
else:
final_password = auth_user
except Exception as e:
print(f"Error during Singbox config generation from URI: {e}, URI: {uri}")
return None return None
return { return {
"outbounds": [{ "type": "hysteria2",
"type": "hysteria2", "tag": unquote(parsed_url.fragment),
"tag": f"{username}-Hysteria2", "server": server,
"server": components.ip, "server_port": server_port,
"server_port": components.port, "obfs": {
"obfs": { "type": "salamander",
"type": "salamander", "password": obfs_password
"password": components.obfs_password },
}, "password": final_password,
"password": f"{username}:{components.password}", "tls": {
"tls": { "enabled": True,
"enabled": True, "server_name": fragment if fragment else self.default_sni,
"server_name": fragment if fragment else self.default_sni, "insecure": True
"insecure": True }
}
}]
} }
def combine_configs(self, username: str, config_v4: Optional[Dict[str, Any]], config_v6: Optional[Dict[str, Any]]) -> Dict[str, Any]: def combine_configs(self, all_uris: List[str], username: str, fragment: str) -> Optional[Dict[str, Any]]:
"""Generates a combined Singbox config from a list of URIs."""
if not all_uris:
return None
combined_config = self.get_template() combined_config = self.get_template()
combined_config['outbounds'] = [outbound for outbound in combined_config['outbounds'] combined_config['outbounds'] = [out for out in combined_config['outbounds'] if out.get('type') != 'hysteria2']
if outbound.get('type') != 'hysteria2']
modified_v4_outbounds = [] hysteria_outbounds = []
if config_v4: for uri in all_uris:
v4_outbound = config_v4['outbounds'][0] outbound = self.generate_config_from_uri(uri, username, fragment)
v4_outbound['tag'] = f"{username}-IPv4" if outbound:
modified_v4_outbounds.append(v4_outbound) hysteria_outbounds.append(outbound)
modified_v6_outbounds = [] if not hysteria_outbounds:
if config_v6: return None
v6_outbound = config_v6['outbounds'][0]
v6_outbound['tag'] = f"{username}-IPv6"
modified_v6_outbounds.append(v6_outbound)
select_outbounds = ["auto"] all_tags = [out['tag'] for out in hysteria_outbounds]
if config_v4:
select_outbounds.append(f"{username}-IPv4")
if config_v6:
select_outbounds.append(f"{username}-IPv6")
auto_outbounds = []
if config_v4:
auto_outbounds.append(f"{username}-IPv4")
if config_v6:
auto_outbounds.append(f"{username}-IPv6")
for outbound in combined_config['outbounds']: for outbound in combined_config['outbounds']:
if outbound.get('tag') == 'select': if outbound.get('tag') == 'select':
outbound['outbounds'] = select_outbounds outbound['outbounds'] = ["auto"] + all_tags
elif outbound.get('tag') == 'auto': elif outbound.get('tag') == 'auto':
outbound['outbounds'] = auto_outbounds outbound['outbounds'] = all_tags
combined_config['outbounds'].extend(modified_v4_outbounds + modified_v6_outbounds)
combined_config['outbounds'].extend(hysteria_outbounds)
return combined_config return combined_config
@ -383,13 +393,13 @@ class SubscriptionManager:
user_info = self.hysteria_cli.get_user_info(username) user_info = self.hysteria_cli.get_user_info(username)
if user_info is None: if user_info is None:
return "User not found" return "User not found"
ipv4_uri, ipv6_uri = self.hysteria_cli.get_uris(username)
output_lines = [uri for uri in [ipv4_uri, ipv6_uri] if uri] all_uris = self.hysteria_cli.get_all_uris(username)
if not output_lines: if not all_uris:
return "No URI available" return "No URI available"
processed_uris = [] processed_uris = []
for uri in output_lines: for uri in all_uris:
if "v2ray" in user_agent and "ng" in user_agent: if "v2ray" in user_agent and "ng" in user_agent:
match = re.search(r'pinSHA256=sha256/([^&]+)', uri) match = re.search(r'pinSHA256=sha256/([^&]+)', uri)
if match: if match:
@ -455,6 +465,7 @@ class HysteriaServer:
singbox_template_path = '/etc/hysteria/core/scripts/normalsub/singbox.json' singbox_template_path = '/etc/hysteria/core/scripts/normalsub/singbox.json'
hysteria_cli_path = '/etc/hysteria/core/cli.py' hysteria_cli_path = '/etc/hysteria/core/cli.py'
users_json_path = os.getenv('HYSTERIA_USERS_JSON_PATH', '/etc/hysteria/users.json') users_json_path = os.getenv('HYSTERIA_USERS_JSON_PATH', '/etc/hysteria/users.json')
nodes_json_path = '/etc/hysteria/nodes.json'
rate_limit = 100 rate_limit = 100
rate_limit_window = 60 rate_limit_window = 60
template_dir = os.path.dirname(__file__) template_dir = os.path.dirname(__file__)
@ -467,6 +478,7 @@ class HysteriaServer:
singbox_template_path=singbox_template_path, singbox_template_path=singbox_template_path,
hysteria_cli_path=hysteria_cli_path, hysteria_cli_path=hysteria_cli_path,
users_json_path=users_json_path, users_json_path=users_json_path,
nodes_json_path=nodes_json_path,
rate_limit=rate_limit, rate_limit_window=rate_limit_window, rate_limit=rate_limit, rate_limit_window=rate_limit_window,
sni=sni, template_dir=template_dir, sni=sni, template_dir=template_dir,
subpath=subpath) subpath=subpath)
@ -548,22 +560,21 @@ class HysteriaServer:
return web.Response(text=self.template_renderer.render(context), content_type='text/html') return web.Response(text=self.template_renderer.render(context), content_type='text/html')
async def _handle_singbox(self, username: str, fragment: str, user_info: UserInfo) -> web.Response: async def _handle_singbox(self, username: str, fragment: str, user_info: UserInfo) -> web.Response:
config_v4 = self.singbox_generator.generate_config(username, '4', fragment) all_uris = self.hysteria_cli.get_all_uris(username)
config_v6 = self.singbox_generator.generate_config(username, '6', fragment) if not all_uris:
if config_v4 is None and config_v6 is None:
return web.Response(status=404, text=f"Error: No valid URIs found for user {username}.") return web.Response(status=404, text=f"Error: No valid URIs found for user {username}.")
combined_config = self.singbox_generator.combine_configs(username, config_v4, config_v6) combined_config = self.singbox_generator.combine_configs(all_uris, username, fragment)
return web.Response(text=json.dumps(combined_config, indent=4, sort_keys=True), content_type='application/json') return web.Response(text=json.dumps(combined_config, indent=4, sort_keys=True), content_type='application/json')
async def _handle_normalsub(self, request: web.Request, username: str, user_info: UserInfo) -> web.Response: async def _handle_normalsub(self, request: web.Request, username: str, user_info: UserInfo) -> web.Response:
user_agent = request.headers.get('User-Agent', '').lower() user_agent = request.headers.get('User-Agent', '').lower()
subscription = self.subscription_manager.get_normal_subscription(username, user_agent) subscription = self.subscription_manager.get_normal_subscription(username, user_agent)
if subscription == "User not found": # Should be caught earlier by user_info check if subscription == "User not found":
return web.Response(status=404, text=f"User '{username}' not found.") return web.Response(status=404, text=f"User '{username}' not found.")
return web.Response(text=subscription, content_type='text/plain') return web.Response(text=subscription, content_type='text/plain')
async def _get_template_context(self, username: str, user_info: UserInfo) -> TemplateContext: async def _get_template_context(self, username: str, user_info: UserInfo) -> TemplateContext:
ipv4_uri, ipv6_uri = self.hysteria_cli.get_uris(username) labeled_uris = self.hysteria_cli.get_all_labeled_uris(username)
port_str = f":{self.config.external_port}" if self.config.external_port not in [80, 443, 0] else "" port_str = f":{self.config.external_port}" if self.config.external_port not in [80, 443, 0] else ""
base_url = f"https://{self.config.domain}{port_str}" base_url = f"https://{self.config.domain}{port_str}"
@ -571,22 +582,31 @@ class HysteriaServer:
print(f"Warning: Constructed base URL '{base_url}' might be invalid. Check domain and port config.") print(f"Warning: Constructed base URL '{base_url}' might be invalid. Check domain and port config.")
sub_link = f"{base_url}/{self.config.subpath}/sub/normal/{user_info.password}" sub_link = f"{base_url}/{self.config.subpath}/sub/normal/{user_info.password}"
ipv4_qrcode = Utils.generate_qrcode_base64(ipv4_uri)
ipv6_qrcode = Utils.generate_qrcode_base64(ipv6_uri)
sublink_qrcode = Utils.generate_qrcode_base64(sub_link) sublink_qrcode = Utils.generate_qrcode_base64(sub_link)
local_uris = []
node_uris = []
for item in labeled_uris:
node_uri = NodeURI(
label=item['label'],
uri=item['uri'],
qrcode=Utils.generate_qrcode_base64(item['uri'])
)
if item['label'].startswith('Node:'):
node_uris.append(node_uri)
else:
local_uris.append(node_uri)
return TemplateContext( return TemplateContext(
username=username, username=username,
usage=user_info.usage_human_readable, usage=user_info.usage_human_readable,
usage_raw=user_info.usage_detailed, usage_raw=user_info.usage_detailed,
expiration_date=user_info.expiration_date, expiration_date=user_info.expiration_date,
sublink_qrcode=sublink_qrcode, sublink_qrcode=sublink_qrcode,
ipv4_qrcode=ipv4_qrcode,
ipv6_qrcode=ipv6_qrcode,
sub_link=sub_link, sub_link=sub_link,
ipv4_uri=ipv4_uri, local_uris=local_uris,
ipv6_uri=ipv6_uri node_uris=node_uris
) )
async def robots_handler(self, request: web.Request) -> web.Response: async def robots_handler(self, request: web.Request) -> web.Response:
@ -605,7 +625,6 @@ class HysteriaServer:
port=self.config.aiohttp_listen_port port=self.config.aiohttp_listen_port
) )
if __name__ == '__main__': if __name__ == '__main__':
server = HysteriaServer() server = HysteriaServer()
server.run() server.run()

View File

@ -1,154 +1,158 @@
{ {
"log": {
"level": "info",
"timestamp": true
},
"dns": { "dns": {
"servers": [ "final": "local-dns",
{
"tag": "proxyDns",
"address": "tls://8.8.8.8",
"detour": "Proxy"
},
{
"tag": "localDns",
"address": "8.8.8.8",
"detour": "direct"
}
],
"rules": [ "rules": [
{ {
"outbound": "any", "action": "route",
"server": "localDns" "clash_mode": "Global",
"server": "proxy-dns",
"source_ip_cidr": [
"172.19.0.0/30",
"fdfe:dcba:9876::1/126"
]
}, },
{ {
"rule_set": "geosite-ir", "action": "route",
"server": "proxyDns" "server": "proxy-dns",
}, "source_ip_cidr": [
{ "172.19.0.0/30",
"clash_mode": "direct", "fdfe:dcba:9876::1/126"
"server": "localDns" ]
},
{
"clash_mode": "global",
"server": "proxyDns"
} }
], ],
"final": "localDns", "servers": [
"strategy": "ipv4_only" {
"type": "https",
"server": "1.1.1.1",
"detour": "proxy",
"tag": "proxy-dns"
},
{
"type": "local",
"detour": "direct",
"tag": "local-dns"
}
],
"strategy": "prefer_ipv4"
}, },
"inbounds": [ "inbounds": [
{ {
"type": "tun", "address": [
"tag": "tun-in", "172.19.0.1/30",
"address": "172.19.0.1/30", "fdfe:dcba:9876::1/126"
],
"auto_route": true, "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": [ "outbounds": [
{ {
"tag": "Proxy",
"type": "selector",
"outbounds": [ "outbounds": [
"auto", "auto",
"direct" "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", "interval": "10m",
"tolerance": 50 "outbounds": [],
"tag": "auto",
"tolerance": 50,
"type": "urltest",
"url": "http://www.gstatic.com/generate_204"
}, },
{ {
"type": "direct", "tag": "direct",
"tag": "direct" "type": "direct"
},
{
"type": "direct",
"tag": "local"
} }
], ],
"route": { "route": {
"auto_detect_interface": true, "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": [ "rules": [
{ {
"inbound": [
"tun-in",
"mixed-in"
],
"action": "sniff" "action": "sniff"
}, },
{ {
"type": "logical", "action": "route",
"mode": "or", "clash_mode": "Direct",
"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,
"outbound": "direct" "outbound": "direct"
}, },
{ {
"action": "route", "action": "route",
"rule_set": "geosite-ir", "clash_mode": "Global",
"outbound": "direct" "outbound": "proxy"
},
{
"action": "hijack-dns",
"protocol": "dns"
}, },
{ {
"action": "route", "action": "route",
"rule_set": "geoip-ir", "outbound": "direct",
"outbound": "direct" "rule_set": [
} "geosite-ir",
], "geoip-ir",
"rule_set": [ "geoip-private"
{ ]
"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"
}, },
{ {
"type": "remote", "action": "reject",
"tag": "geoip-ir", "rule_set": [
"format": "binary", "geosite-ads"
"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"
} }
] ]
} }

View File

@ -41,7 +41,6 @@
color: var(--text-dark); color: var(--text-dark);
} }
/* Animated background elements */
.background-animation { .background-animation {
position: fixed; position: fixed;
top: 0; top: 0;
@ -78,17 +77,14 @@
0% { 0% {
transform: translate(0, 0) rotate(0deg); transform: translate(0, 0) rotate(0deg);
} }
50% { 50% {
transform: translate(100px, 100px) rotate(180deg); transform: translate(100px, 100px) rotate(180deg);
} }
100% { 100% {
transform: translate(0, 0) rotate(360deg); transform: translate(0, 0) rotate(360deg);
} }
} }
/* Rest of the styles remain the same */
.container { .container {
max-width: 1000px; max-width: 1000px;
margin: 2rem auto; margin: 2rem auto;
@ -175,10 +171,10 @@
background: var(--card-bg-light); background: var(--card-bg-light);
border-radius: 1rem; border-radius: 1rem;
overflow: hidden; overflow: hidden;
/* Add this to contain the header */
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1); box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
backdrop-filter: blur(10px); backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.1); border: 1px solid rgba(255, 255, 255, 0.1);
margin-bottom: 1.5rem;
} }
.qr-header { .qr-header {
@ -196,7 +192,6 @@
padding: 2rem; padding: 2rem;
} }
.dark-mode .qr-section { .dark-mode .qr-section {
background: rgba(31, 41, 55, 0.8); background: rgba(31, 41, 55, 0.8);
border-color: rgba(255, 255, 255, 0.05); border-color: rgba(255, 255, 255, 0.05);
@ -230,6 +225,14 @@
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
} }
.uri-unavailable {
color: #9ca3af;
}
.dark-mode .uri-unavailable {
color: #4b5563;
}
.btn-group { .btn-group {
display: flex; display: flex;
gap: 0.5rem; gap: 0.5rem;
@ -278,6 +281,7 @@
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1); box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
display: none; display: none;
backdrop-filter: blur(10px); backdrop-filter: blur(10px);
z-index: 100;
} }
.dark-mode .loading-indicator { .dark-mode .loading-indicator {
@ -358,60 +362,78 @@
<div class="qr-section"> <div class="qr-section">
<div class="qr-header"> <div class="qr-header">
<i class="fas fa-qrcode"></i> <i class="fas fa-link"></i>
QR Codes Subscription Link
</div> </div>
<div class="qr-content"> <div class="qr-content">
<div class="qr-grid"> <div class="qr-grid">
<div class="qr-item"> <div class="qr-item" style="grid-column: 1 / -1;">
<h3 class="qr-title">Subscription Link</h3> <h3 class="qr-title">Universal Subscription Link</h3>
<img src="{{ sublink_qrcode }}" alt="Subscription QR Code" class="qrcode"> <img src="{{ sublink_qrcode }}" alt="Subscription QR Code" class="qrcode">
<div class="btn-group"> <div class="btn-group">
<button class="btn btn-primary" onclick="copyToClipboard('{{ sub_link }}')"> <button class="btn btn-primary" onclick="copyToClipboard('{{ sub_link }}')">
<i class="fas fa-copy"></i> Copy <i class="fas fa-copy"></i> Copy
</button> </button>
<!-- <a href="{{ sub_link }}" class="btn btn-secondary" target="_blank" rel="noopener noreferrer">
<i class="fas fa-external-link-alt"></i> Open
</a> -->
</div> </div>
</div> </div>
<div class="qr-item">
<h3 class="qr-title">IPv4 URI</h3>
{% if ipv4_qrcode %}
<img src="{{ ipv4_qrcode }}" alt="IPv4 QR Code" class="qrcode">
<div class="btn-group">
<button class="btn btn-primary" onclick="copyToClipboard('{{ ipv4_uri }}')">
<i class="fas fa-copy"></i> Copy
</button>
<!-- <a href="{{ ipv4_uri }}" class="btn btn-secondary" target="_blank" rel="noopener noreferrer">
<i class="fas fa-external-link-alt"></i> Open
</a> -->
</div>
{% else %}
<p class="uri-unavailable">IPv4 URI not available</p>
{% endif %}
</div>
<div class="qr-item">
<h3 class="qr-title">IPv6 URI</h3>
{% if ipv6_qrcode %}
<img src="{{ ipv6_qrcode }}" alt="IPv6 QR Code" class="qrcode">
<div class="btn-group">
<button class="btn btn-primary" onclick="copyToClipboard('{{ ipv6_uri }}')">
<i class="fas fa-copy"></i> Copy
</button>
<!-- <a href="{{ ipv6_uri }}" class="btn btn-secondary" target="_blank" rel="noopener noreferrer">
<i class="fas fa-external-link-alt"></i> Open
</a> -->
</div>
{% else %}
<p class="uri-unavailable">IPv6 URI not available</p>
{% endif %}
</div>
</div> </div>
</div> </div>
</div> </div>
<div class="qr-section">
<div class="qr-header">
<i class="fas fa-server"></i>
Local Server Connections
</div>
<div class="qr-content">
<div class="qr-grid">
{% for item in local_uris %}
<div class="qr-item">
<h3 class="qr-title">{{ item.label }} URI</h3>
{% if item.qrcode %}
<img src="{{ item.qrcode }}" alt="{{ item.label }} QR Code" class="qrcode">
<div class="btn-group">
<button class="btn btn-primary" onclick="copyToClipboard('{{ item.uri }}')">
<i class="fas fa-copy"></i> Copy
</button>
</div>
{% else %}
<p class="uri-unavailable">{{ item.label }} URI not available</p>
{% endif %}
</div>
{% endfor %}
</div>
</div>
</div>
{% if node_uris %}
<div class="qr-section">
<div class="qr-header">
<i class="fas fa-globe-americas"></i>
External Nodes
</div>
<div class="qr-content">
<div class="qr-grid">
{% for item in node_uris %}
<div class="qr-item">
<h3 class="qr-title">{{ item.label }}</h3>
{% if item.qrcode %}
<img src="{{ item.qrcode }}" alt="{{ item.label }} QR Code" class="qrcode">
<div class="btn-group">
<button class="btn btn-primary" onclick="copyToClipboard('{{ item.uri }}')">
<i class="fas fa-copy"></i> Copy
</button>
</div>
{% else %}
<p class="uri-unavailable">{{ item.label }} URI not available</p>
{% endif %}
</div>
{% endfor %}
</div>
</div>
</div>
{% endif %}
</div> </div>
<script> <script>

View File

@ -7,6 +7,7 @@ USERS_FILE = BASE_DIR / "users.json"
TRAFFIC_FILE = BASE_DIR / "traffic_data.json" TRAFFIC_FILE = BASE_DIR / "traffic_data.json"
CONFIG_FILE = BASE_DIR / "config.json" CONFIG_FILE = BASE_DIR / "config.json"
CONFIG_ENV = BASE_DIR / ".configs.env" CONFIG_ENV = BASE_DIR / ".configs.env"
NODES_JSON_PATH = BASE_DIR / "nodes.json"
TELEGRAM_ENV = BASE_DIR / "core/scripts/telegrambot/.env" TELEGRAM_ENV = BASE_DIR / "core/scripts/telegrambot/.env"
SINGBOX_ENV = BASE_DIR / "core/scripts/singbox/.env" SINGBOX_ENV = BASE_DIR / "core/scripts/singbox/.env"
NORMALSUB_ENV = BASE_DIR / "core/scripts/normalsub/.env" NORMALSUB_ENV = BASE_DIR / "core/scripts/normalsub/.env"

View File

@ -1,44 +1,43 @@
from fastapi import APIRouter, HTTPException from fastapi import APIRouter, HTTPException
from ..schema.response import DetailResponse from ..schema.response import DetailResponse
import json
import os
from ..schema.config.ip import (
from ..schema.config.ip import EditInputBody, StatusResponse EditInputBody,
StatusResponse,
AddNodeBody,
DeleteNodeBody,
NodeListResponse
)
import cli_api import cli_api
router = APIRouter() 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(): async def get_ip_api():
""" """
Retrieves the current status of IP addresses. Retrieves the current status of the main server's IP addresses.
Returns: Returns:
StatusResponse: A response model containing the current IP address details. 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: try:
ipv4, ipv6 = cli_api.get_ip_address() ipv4, ipv6 = cli_api.get_ip_address()
if ipv4 or ipv6: return StatusResponse(ipv4=ipv4, ipv6=ipv6)
return StatusResponse(ipv4=ipv4, ipv6=ipv6) # type: ignore
raise HTTPException(status_code=404, detail='IP status not available.')
except Exception as e: except Exception as e:
raise HTTPException(status_code=400, detail=f'Error: {str(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(): async def add_ip_api():
""" """
Adds the auto-detected IP addresses to the .configs.env file. Adds the auto-detected IP addresses to the .configs.env file.
Returns: Returns:
A DetailResponse with a message indicating the IP addresses were added successfully. A DetailResponse with a message indicating the IP addresses were added successfully.
Raises:
HTTPException: if an error occurs while adding the IP addresses.
""" """
try: try:
cli_api.add_ip_address() cli_api.add_ip_address()
@ -47,19 +46,13 @@ async def add_ip_api():
raise HTTPException(status_code=400, detail=f'Error: {str(e)}') 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): 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: Args:
body: An instance of EditInputBody containing the new IPv4 and/or IPv6 addresses. 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: try:
if not body.ipv4 and not body.ipv6: 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.') return DetailResponse(detail='IP address edited successfully.')
except Exception as e: except Exception as e:
raise HTTPException(status_code=400, detail=f'Error: {str(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))

View File

@ -1,24 +1,50 @@
from pydantic import BaseModel, field_validator, ValidationInfo from pydantic import BaseModel, field_validator, ValidationInfo
from ipaddress import IPv4Address, IPv6Address, ip_address 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): class StatusResponse(BaseModel):
ipv4: str | None = None ipv4: str | None = None
ipv6: str | None = None ipv6: str | None = None
@field_validator('ipv4', 'ipv6', mode='before') @field_validator('ipv4', 'ipv6', mode='before')
def check_ip_or_domain(cls, v: str, info: ValidationInfo): def check_local_server_ip(cls, v: str | None):
if v is None: return validate_ip_or_domain(v)
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")
class EditInputBody(StatusResponse): class EditInputBody(StatusResponse):
pass pass
class Node(BaseModel):
name: str
ip: str
@field_validator('ip', mode='before')
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
class DeleteNodeBody(BaseModel):
name: str
NodeListResponse = list[Node]

View File

@ -1,4 +1,4 @@
from typing import Optional from typing import Optional, List
from pydantic import BaseModel, RootModel from pydantic import BaseModel, RootModel
@ -38,8 +38,14 @@ class EditUserInputBody(BaseModel):
renew_creation_date: bool = False renew_creation_date: bool = False
blocked: bool = False blocked: bool = False
class NodeUri(BaseModel):
name: str
uri: str
class UserUriResponse(BaseModel): class UserUriResponse(BaseModel):
username: str username: str
ipv4: str | None = None ipv4: Optional[str] = None
ipv6: str | None = None ipv6: Optional[str] = None
normal_sub: str | None = None nodes: Optional[List[NodeUri]] = []
normal_sub: Optional[str] = None
error: Optional[str] = None

View File

@ -178,10 +178,12 @@ async def show_user_uri_api(username: str):
uri_data_list = cli_api.show_user_uri_json([username]) uri_data_list = cli_api.show_user_uri_json([username])
if not uri_data_list: if not uri_data_list:
raise HTTPException(status_code=404, detail=f'URI for user {username} not found.') raise HTTPException(status_code=404, detail=f'URI for user {username} not found.')
uri_data = uri_data_list[0] uri_data = uri_data_list[0]
if uri_data.get('error'): if uri_data.get('error'):
raise HTTPException(status_code=404, detail=f"{uri_data['error']}") raise HTTPException(status_code=404, detail=f"{uri_data['error']}")
return uri_data
return UserUriResponse(**uri_data)
except cli_api.ScriptNotFoundError as e: except cli_api.ScriptNotFoundError as e:
raise HTTPException(status_code=500, detail=f'Server script error: {str(e)}') raise HTTPException(status_code=500, detail=f'Server script error: {str(e)}')
except cli_api.CommandExecutionError as e: except cli_api.CommandExecutionError as e:

View File

@ -1,16 +1,14 @@
from pydantic import BaseModel from pydantic import BaseModel
from datetime import datetime, timedelta from datetime import datetime, timedelta
from typing import Optional
import cli_api
class User(BaseModel): class User(BaseModel):
username: str username: str
status: str status: str
quota: str quota: str
traffic_used: str traffic_used: str
expiry_date: datetime expiry_date: str
expiry_days: int expiry_days: str
enable: bool enable: bool
@staticmethod @staticmethod
@ -21,44 +19,51 @@ class User(BaseModel):
@staticmethod @staticmethod
def __parse_user_data(user_data: dict) -> dict: def __parse_user_data(user_data: dict) -> dict:
expiry_date = 'N/A'
creation_date_str = user_data.get("account_creation_date")
expiration_days = user_data.get('expiration_days', 0) expiration_days = user_data.get('expiration_days', 0)
if creation_date_str and expiration_days:
try:
creation_date = datetime.strptime(creation_date_str, "%Y-%m-%d")
expiry_date = creation_date + timedelta(days=expiration_days)
except ValueError:
pass
# Calculate traffic values and percentage if expiration_days > 0:
creation_date_str = user_data.get("account_creation_date")
display_expiry_days = str(expiration_days)
if isinstance(creation_date_str, str):
try:
creation_date = datetime.strptime(creation_date_str, "%Y-%m-%d")
expiry_dt_obj = creation_date + timedelta(days=expiration_days)
display_expiry_date = expiry_dt_obj.strftime("%Y-%m-%d")
except ValueError:
display_expiry_date = "Error"
else:
display_expiry_date = "Error"
else:
display_expiry_days = "Unlimited"
display_expiry_date = "Unlimited"
used_bytes = user_data.get("download_bytes", 0) + user_data.get("upload_bytes", 0) used_bytes = user_data.get("download_bytes", 0) + user_data.get("upload_bytes", 0)
quota_bytes = user_data.get('max_download_bytes', 0) quota_bytes = user_data.get('max_download_bytes', 0)
# Format individual values for combining
used_formatted = User.__format_traffic(used_bytes) used_formatted = User.__format_traffic(used_bytes)
quota_formatted = User.__format_traffic(quota_bytes) quota_formatted = "Unlimited" if quota_bytes == 0 else User.__format_traffic(quota_bytes)
# Calculate percentage if quota is not zero
percentage = 0 percentage = 0
if quota_bytes > 0: if quota_bytes > 0:
percentage = (used_bytes / quota_bytes) * 100 percentage = (used_bytes / quota_bytes) * 100
# Combine the values with percentage traffic_used_display = f"{used_formatted}/{quota_formatted} ({percentage:.1f}%)"
traffic_used = f"{used_formatted}/{quota_formatted} ({percentage:.1f}%)"
return { return {
'username': user_data['username'], 'username': user_data['username'],
'status': user_data.get('status', 'Not Active'), 'status': user_data.get('status', 'Not Active'),
'quota': User.__format_traffic(quota_bytes), 'quota': quota_formatted,
'traffic_used': traffic_used, 'traffic_used': traffic_used_display,
'expiry_date': expiry_date, 'expiry_date': display_expiry_date,
'expiry_days': expiration_days, 'expiry_days': display_expiry_days,
'enable': False if user_data.get('blocked', False) else True, 'enable': not user_data.get('blocked', False),
} }
@staticmethod @staticmethod
def __format_traffic(traffic_bytes) -> str: def __format_traffic(traffic_bytes) -> str:
if traffic_bytes == 0:
return "0 B"
if traffic_bytes < 1024: if traffic_bytes < 1024:
return f'{traffic_bytes} B' return f'{traffic_bytes} B'
elif traffic_bytes < 1024**2: elif traffic_bytes < 1024**2:

View File

@ -49,7 +49,7 @@
<li class='nav-item'> <li class='nav-item'>
<a class='nav-link' id='ip-tab' data-toggle='pill' href='#change_ip' role='tab' <a class='nav-link' id='ip-tab' data-toggle='pill' href='#change_ip' role='tab'
aria-controls='change_ip' aria-selected='false'><i class="fas fa-network-wired"></i> aria-controls='change_ip' aria-selected='false'><i class="fas fa-network-wired"></i>
Change IP</a> IP Management</a>
</li> </li>
<li class='nav-item'> <li class='nav-item'>
<a class='nav-link' id='backup-tab' data-toggle='pill' href='#backup' role='tab' <a class='nav-link' id='backup-tab' data-toggle='pill' href='#backup' role='tab'
@ -208,27 +208,70 @@
</button> </button>
</div> </div>
<!-- Change IP Tab --> <!-- IP Management Tab -->
<div class='tab-pane fade' id='change_ip' role='tabpanel' aria-labelledby='ip-tab'> <div class='tab-pane fade' id='change_ip' role='tabpanel' aria-labelledby='ip-tab'>
<form id="change_ip_form"> <div class="card card-outline card-primary">
<div class='form-group'> <div class="card-header">
<label for='ipv4'>IPv4:</label> <h3 class="card-title">Local Server IP / Domain</h3>
<input type='text' class='form-control' id='ipv4' placeholder='Enter IPv4 or Domain'
value="{{ ipv4 or '' }}">
<div class="invalid-feedback">
Please enter a valid IPv4 address or Domain.
</div>
</div> </div>
<div class='form-group'> <div class="card-body">
<label for='ipv6'>IPv6:</label> <form id="change_ip_form">
<input type='text' class='form-control' id='ipv6' placeholder='Enter IPv6 or Domain' <div class='form-group'>
value="{{ ipv6 or '' }}"> <label for='ipv4'>IPv4 / Domain:</label>
<div class="invalid-feedback"> <input type='text' class='form-control' id='ipv4' placeholder='Enter IPv4 or Domain' value="{{ ipv4 or '' }}">
Please enter a valid IPv6 address or Domain. <div class="invalid-feedback">Please enter a valid IPv4 address or Domain.</div>
</div> </div>
<div class='form-group'>
<label for='ipv6'>IPv6 / Domain:</label>
<input type='text' class='form-control' id='ipv6' placeholder='Enter IPv6 or Domain' value="{{ ipv6 or '' }}">
<div class="invalid-feedback">Please enter a valid IPv6 address or Domain.</div>
</div>
<button id="ip_change" type='button' class='btn btn-primary'>Save</button>
</form>
</div> </div>
<button id="ip_change" type='button' class='btn btn-primary'>Save</button> </div>
</form>
<div class="card card-outline card-secondary mt-4">
<div class="card-header">
<h3 class="card-title">External Nodes</h3>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-bordered table-striped" id="nodes_table">
<thead>
<tr>
<th>Node Name</th>
<th>IP Address / Domain</th>
<th>Action</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
<div id="no_nodes_message" class="alert alert-info" style="display: none;">
No external nodes have been configured.
</div>
<hr>
<h5>Add New Node</h5>
<form id="add_node_form" class="form-inline">
<div class="form-group mb-2 mr-sm-2">
<label for="node_name" class="sr-only">Node Name</label>
<input type="text" class="form-control" id="node_name" placeholder="e.g., Node-US">
<div class="invalid-feedback">Please enter a name.</div>
</div>
<div class="form-group mb-2 mr-sm-2">
<label for="node_ip" class="sr-only">IP Address / Domain</label>
<input type="text" class="form-control" id="node_ip" placeholder="e.g., node.example.com">
<div class="invalid-feedback">Please enter a valid IP or domain.</div>
</div>
<button type="button" id="add_node_btn" class="btn btn-success mb-2">
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true" style="display: none;"></span>
Add Node
</button>
</form>
</div>
</div>
</div> </div>
<!-- Backup Tab --> <!-- Backup Tab -->
@ -414,7 +457,6 @@
</div> </div>
</div> </div>
<!-- /.card -->
</div> </div>
</div> </div>
</div> </div>
@ -436,6 +478,7 @@
initUI(); initUI();
fetchDecoyStatus(); fetchDecoyStatus();
fetchObfsStatus(); fetchObfsStatus();
fetchNodes();
function isValidPath(path) { function isValidPath(path) {
if (!path) return false; if (!path) return false;
@ -458,20 +501,17 @@
return /^[a-zA-Z0-9]+$/.test(subpath); return /^[a-zA-Z0-9]+$/.test(subpath);
} }
function isValidIPorDomain(input) { function isValidIPorDomain(input) {
if (!input) return true; if (input === null || typeof input === 'undefined') return false;
input = input.trim();
if (input === '') 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]?)$/; 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]?)$/;
const ipV6Regex = /^(([0-9a-fA-F]{1,4}:){7,7}([0-9a-fA-F]{1,4}|:)|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:))$/; const ipV6Regex = /^(([0-9a-fA-F]{1,4}:){7,7}([0-9a-fA-F]{1,4}|:)|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:))$/;
const domainRegex = /^(?!-)(?:[a-zA-Z\d-]{0,62}[a-zA-Z\d]\.){1,126}(?!\d+$)[a-zA-Z\d]{1,63}$/; const domainRegex = /^(?!-)(?:[a-zA-Z\d-]{0,62}[a-zA-Z\d]\.){1,126}(?!\d+$)[a-zA-Z\d]{1,63}$/;
const lowerInput = input.toLowerCase(); const lowerInput = input.toLowerCase();
if (ipV4Regex.test(input)) return true; return ipV4Regex.test(input) || ipV6Regex.test(input) || domainRegex.test(lowerInput);
if (ipV6Regex.test(input)) return true;
if (domainRegex.test(lowerInput) && !lowerInput.startsWith("http://") && !lowerInput.startsWith("https://")) return true;
return false;
} }
function isValidPositiveNumber(value) { function isValidPositiveNumber(value) {
@ -518,12 +558,16 @@
} }
} }
}); });
console.log("Success Response:", response);
}, },
error: function (xhr, status, error) { error: function (xhr, status, error) {
let errorMessage = "Something went wrong."; let errorMessage = "An unexpected error occurred.";
if (xhr.responseJSON && xhr.responseJSON.detail) { if (xhr.responseJSON && xhr.responseJSON.detail) {
errorMessage = xhr.responseJSON.detail; const detail = xhr.responseJSON.detail;
if (Array.isArray(detail)) {
errorMessage = detail.map(err => `Error in '${err.loc[1]}': ${err.msg}`).join('\n');
} else if (typeof detail === 'string') {
errorMessage = detail;
}
} }
Swal.fire("Error!", errorMessage, "error"); Swal.fire("Error!", errorMessage, "error");
console.error("AJAX Error:", status, error, xhr.responseText); console.error("AJAX Error:", status, error, xhr.responseText);
@ -552,6 +596,10 @@
fieldValid = isValidSubPath(input.val()); fieldValid = isValidSubPath(input.val());
} else if (id === 'ipv4' || id === 'ipv6') { } else if (id === 'ipv4' || id === 'ipv6') {
fieldValid = (input.val().trim() === '') ? true : isValidIPorDomain(input.val()); fieldValid = (input.val().trim() === '') ? true : isValidIPorDomain(input.val());
} else if (id === 'node_ip') {
fieldValid = isValidIPorDomain(input.val());
} else if (id === 'node_name') {
fieldValid = input.val().trim() !== "";
} else if (id === 'block_duration' || id === 'max_ips') { } else if (id === 'block_duration' || id === 'max_ips') {
fieldValid = isValidPositiveNumber(input.val()); fieldValid = isValidPositiveNumber(input.val());
} else if (id === 'decoy_path') { } else if (id === 'decoy_path') {
@ -572,7 +620,6 @@
return isValid; return isValid;
} }
function initUI() { function initUI() {
$.ajax({ $.ajax({
url: "{{ url_for('server_services_status_api') }}", url: "{{ url_for('server_services_status_api') }}",
@ -621,6 +668,83 @@
}); });
} }
function fetchNodes() {
$.ajax({
url: "{{ url_for('get_all_nodes') }}",
type: "GET",
success: function (nodes) {
renderNodes(nodes);
},
error: function(xhr) {
Swal.fire("Error!", "Failed to fetch external nodes list.", "error");
console.error("Error fetching nodes:", xhr.responseText);
}
});
}
function renderNodes(nodes) {
const tableBody = $("#nodes_table tbody");
tableBody.empty();
if (nodes && nodes.length > 0) {
$("#nodes_table").show();
$("#no_nodes_message").hide();
nodes.forEach(node => {
const row = `<tr>
<td>${node.name}</td>
<td>${node.ip}</td>
<td>
<button class="btn btn-xs btn-danger delete-node-btn" data-name="${node.name}">
<i class="fas fa-trash"></i> Delete
</button>
</td>
</tr>`;
tableBody.append(row);
});
} else {
$("#nodes_table").hide();
$("#no_nodes_message").show();
}
}
function addNode() {
if (!validateForm('add_node_form')) return;
const name = $("#node_name").val().trim();
const ip = $("#node_ip").val().trim();
confirmAction(`add the node '${name}'`, function () {
sendRequest(
"{{ url_for('add_node') }}",
"POST",
{ name: name, ip: ip },
`Node '${name}' added successfully!`,
"#add_node_btn",
false,
function() {
$("#node_name").val('');
$("#node_ip").val('');
$("#add_node_form .form-control").removeClass('is-invalid');
fetchNodes();
}
);
});
}
function deleteNode(nodeName) {
confirmAction(`delete the node '${nodeName}'`, function () {
sendRequest(
"{{ url_for('delete_node') }}",
"POST",
{ name: nodeName },
`Node '${nodeName}' deleted successfully!`,
null,
false,
fetchNodes
);
});
}
function updateServiceUI(data) { function updateServiceUI(data) {
const servicesMap = { const servicesMap = {
"hysteria_telegram_bot": "#telegram_form", "hysteria_telegram_bot": "#telegram_form",
@ -633,7 +757,6 @@
let targetSelector = servicesMap[serviceKey]; let targetSelector = servicesMap[serviceKey];
let isRunning = data[serviceKey]; let isRunning = data[serviceKey];
if (serviceKey === "hysteria_normal_sub") { if (serviceKey === "hysteria_normal_sub") {
const $normalForm = $("#normal_sub_service_form"); const $normalForm = $("#normal_sub_service_form");
const $normalFormGroups = $normalForm.find(".form-group"); const $normalFormGroups = $normalForm.find(".form-group");
@ -771,7 +894,6 @@
}); });
} }
function setupDecoy() { function setupDecoy() {
if (!validateForm('decoy_form')) return; if (!validateForm('decoy_form')) return;
const domain = $("#decoy_domain").val(); const domain = $("#decoy_domain").val();
@ -846,7 +968,6 @@
} }
} }
function fetchObfsStatus() { function fetchObfsStatus() {
$.ajax({ $.ajax({
url: "{{ url_for('check_obfs') }}", url: "{{ url_for('check_obfs') }}",
@ -908,7 +1029,6 @@
}); });
} }
function startTelegram() { function startTelegram() {
if (!validateForm('telegram_form')) return; if (!validateForm('telegram_form')) return;
const apiToken = $("#telegram_api_token").val(); const apiToken = $("#telegram_api_token").val();
@ -1197,7 +1317,6 @@
}); });
}); });
$("#telegram_start").on("click", startTelegram); $("#telegram_start").on("click", startTelegram);
$("#telegram_stop").on("click", stopTelegram); $("#telegram_stop").on("click", stopTelegram);
$("#normal_start").on("click", startNormal); $("#normal_start").on("click", startNormal);
@ -1215,7 +1334,11 @@
$("#decoy_stop").on("click", stopDecoy); $("#decoy_stop").on("click", stopDecoy);
$("#obfs_enable_btn").on("click", enableObfs); $("#obfs_enable_btn").on("click", enableObfs);
$("#obfs_disable_btn").on("click", disableObfs); $("#obfs_disable_btn").on("click", disableObfs);
$("#add_node_btn").on("click", addNode);
$("#nodes_table").on("click", ".delete-node-btn", function() {
const nodeName = $(this).data("name");
deleteNode(nodeName);
});
$('#normal_domain, #sni_domain, #decoy_domain').on('input', function () { $('#normal_domain, #sni_domain, #decoy_domain').on('input', function () {
if (isValidDomain($(this).val())) { if (isValidDomain($(this).val())) {
@ -1247,9 +1370,20 @@
} }
}); });
$('#ipv4, #ipv6').on('input', function () { $('#ipv4, #ipv6, #node_ip').on('input', function () {
if (isValidIPorDomain($(this).val()) || $(this).val().trim() === '') { const isLocalIpField = $(this).attr('id') === 'ipv4' || $(this).attr('id') === 'ipv6';
if (isLocalIpField && $(this).val().trim() === '') {
$(this).removeClass('is-invalid'); $(this).removeClass('is-invalid');
} else if (isValidIPorDomain($(this).val())) {
$(this).removeClass('is-invalid');
} else {
$(this).addClass('is-invalid');
}
});
$('#node_name').on('input', function() {
if ($(this).val().trim() !== "") {
$(this).removeClass('is-invalid');
} else { } else {
$(this).addClass('is-invalid'); $(this).addClass('is-invalid');
} }

View File

@ -243,7 +243,6 @@
<script> <script>
$(function () { $(function () {
//** Username validation */
const usernameRegex = /^[a-zA-Z0-9]+$/; const usernameRegex = /^[a-zA-Z0-9]+$/;
function validateUsername(username, errorElementId) { function validateUsername(username, errorElementId) {
@ -254,16 +253,14 @@
errorElement.text("Usernames can only contain letters and numbers."); errorElement.text("Usernames can only contain letters and numbers.");
return false; return false;
} else { } else {
errorElement.text(""); // Clear any previous error errorElement.text("");
return true; return true;
} }
} }
// Disable submit buttons by default
$("#addSubmitButton").prop("disabled", true); $("#addSubmitButton").prop("disabled", true);
// $("#editSubmitButton").prop("disabled", true); // $("#editSubmitButton").prop("disabled", true);
//** Add Username validation on Add User Modal */
$("#addUsername").on("input", function () { $("#addUsername").on("input", function () {
const username = $(this).val(); const username = $(this).val();
const isValid = validateUsername(username, "addUsernameError"); const isValid = validateUsername(username, "addUsernameError");
@ -271,7 +268,6 @@
$("#addSubmitButton").prop("disabled", !isValid); $("#addSubmitButton").prop("disabled", !isValid);
}); });
//** Add Username validation on Edit User Modal */
$("#editUsername").on("input", function () { $("#editUsername").on("input", function () {
const username = $(this).val(); const username = $(this).val();
const isValid = validateUsername(username, "editUsernameError"); const isValid = validateUsername(username, "editUsernameError");
@ -279,11 +275,9 @@
$("#editSubmitButton").prop("disabled", !isValid); $("#editSubmitButton").prop("disabled", !isValid);
}); });
// Filter Buttons Functionality
$(".filter-button").on("click", function () { $(".filter-button").on("click", function () {
const filter = $(this).data("filter"); const filter = $(this).data("filter");
// Deselect "Select All" checkbox when a filter is applied
$("#selectAll").prop("checked", false); $("#selectAll").prop("checked", false);
$("#userTable tbody tr").each(function () { $("#userTable tbody tr").each(function () {
@ -342,7 +336,6 @@
confirmButtonText: "Yes, delete them!", confirmButtonText: "Yes, delete them!",
}).then((result) => { }).then((result) => {
if (result.isConfirmed) { if (result.isConfirmed) {
//AJAX request for each user selected
Promise.all(selectedUsers.map(username => { Promise.all(selectedUsers.map(username => {
const removeUserUrl = "{{ url_for('remove_user_api', username='USERNAME_PLACEHOLDER') }}"; const removeUserUrl = "{{ url_for('remove_user_api', username='USERNAME_PLACEHOLDER') }}";
const url = removeUserUrl.replace("USERNAME_PLACEHOLDER", encodeURIComponent(username)); const url = removeUserUrl.replace("USERNAME_PLACEHOLDER", encodeURIComponent(username));
@ -386,7 +379,6 @@
}); });
}); });
// Add User Form Submit
$("#addUserForm").on("submit", function (e) { $("#addUserForm").on("submit", function (e) {
e.preventDefault(); e.preventDefault();
if (!validateUsername($("#addUsername").val(), "addUsernameError")) { if (!validateUsername($("#addUsername").val(), "addUsernameError")) {
@ -437,21 +429,31 @@
}); });
}); });
// Edit User Form Populate and Submit
$(document).on("click", ".edit-user", function () { $(document).on("click", ".edit-user", function () {
const username = $(this).data("user"); const username = $(this).data("user");
const row = $(this).closest("tr"); const row = $(this).closest("tr");
const quota = row.find("td:eq(4)").text().trim(); const trafficUsageText = row.find("td:eq(4)").text().trim();
const expiry_days = row.find("td:eq(6)").text().trim(); const expiryDaysText = row.find("td:eq(6)").text().trim();
const blocked = row.find("td:eq(7) i").hasClass("text-danger"); const blocked = row.find("td:eq(7) i").hasClass("text-danger");
const quotaMatch = quota.match(/\/\s*([\d.]+)/); const expiryDaysValue = (expiryDaysText.toLowerCase() === 'unlimited') ? 0 : parseInt(expiryDaysText, 10);
const quotaValue = quotaMatch ? parseFloat(quotaMatch[1]) : 0;
let trafficLimitValue = 0;
if (!trafficUsageText.toLowerCase().includes('/unlimited')) {
const parts = trafficUsageText.split('/');
if (parts.length > 1) {
const limitPart = parts[1].trim();
const match = limitPart.match(/^[\d.]+/);
if (match) {
trafficLimitValue = parseFloat(match[0]);
}
}
}
$("#originalUsername").val(username); $("#originalUsername").val(username);
$("#editUsername").val(username); $("#editUsername").val(username);
$("#editTrafficLimit").val(quotaValue); $("#editTrafficLimit").val(trafficLimitValue);
$("#editExpirationDays").val(expiry_days); $("#editExpirationDays").val(expiryDaysValue);
$("#editBlocked").prop("checked", blocked); $("#editBlocked").prop("checked", blocked);
const isValid = validateUsername(username, "editUsernameError"); const isValid = validateUsername(username, "editUsernameError");
@ -480,7 +482,6 @@
data: JSON.stringify(jsonData), data: JSON.stringify(jsonData),
success: function (response) { success: function (response) {
if (typeof response === 'string' && response.includes("User updated successfully")) { if (typeof response === 'string' && response.includes("User updated successfully")) {
// Hide the modal
$("#editUserModal").modal("hide"); $("#editUserModal").modal("hide");
Swal.fire({ Swal.fire({
title: "Success!", title: "Success!",

View File

@ -1,5 +1,5 @@
aiohappyeyeballs==2.6.1 aiohappyeyeballs==2.6.1
aiohttp==3.12.14 aiohttp==3.12.15
aiosignal==1.4.0 aiosignal==1.4.0
async-timeout==5.0.1 async-timeout==5.0.1
attrs==25.3.0 attrs==25.3.0
@ -13,7 +13,7 @@ pillow==11.3.0
propcache==0.3.2 propcache==0.3.2
psutil==7.0.0 psutil==7.0.0
pypng==0.20220715.0 pypng==0.20220715.0
pyTelegramBotAPI==4.27.0 pyTelegramBotAPI==4.28.0
python-dotenv==1.1.1 python-dotenv==1.1.1
qrcode==8.2 qrcode==8.2
requests==2.32.4 requests==2.32.4

View File

@ -32,6 +32,7 @@ FILES=(
"$HYSTERIA_INSTALL_DIR/users.json" "$HYSTERIA_INSTALL_DIR/users.json"
"$HYSTERIA_INSTALL_DIR/config.json" "$HYSTERIA_INSTALL_DIR/config.json"
"$HYSTERIA_INSTALL_DIR/.configs.env" "$HYSTERIA_INSTALL_DIR/.configs.env"
"$HYSTERIA_INSTALL_DIR/nodes.json"
"$HYSTERIA_INSTALL_DIR/core/scripts/telegrambot/.env" "$HYSTERIA_INSTALL_DIR/core/scripts/telegrambot/.env"
# "$HYSTERIA_INSTALL_DIR/core/scripts/singbox/.env" # "$HYSTERIA_INSTALL_DIR/core/scripts/singbox/.env"
"$HYSTERIA_INSTALL_DIR/core/scripts/normalsub/.env" "$HYSTERIA_INSTALL_DIR/core/scripts/normalsub/.env"