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
* 📌 **Sticky Sidebar:** Sidebar now stays fixed when scrolling through long pages for easier navigation
* 🃏 **Sticky Headers:** Card headers are now sticky with a sleek **bokeh blur** effect better usability on settings and user lists
* ⏎ **Login UX:** Pressing **Enter** now submits the login form properly for faster access
* ✨ feat(core): Implement external node management system
* 🌐 feat(api): Add external node management endpoints
* 🔗 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)
@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:

View File

@ -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):
@ -32,6 +33,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)
@ -281,20 +283,22 @@ def edit_user(username: str, new_username: str | None, new_traffic_limit: int |
'''
if not username:
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:
raise InvalidInputError('Error: traffic limit must be greater than 0')
if new_expiration_days is not None and new_expiration_days <= 0:
raise InvalidInputError('Error: expiration days must be greater than 0')
if new_traffic_limit is not None and new_traffic_limit < 0:
raise InvalidInputError('Error: traffic limit must be a non-negative number.')
if new_expiration_days is not None and new_expiration_days < 0:
raise InvalidInputError('Error: expiration days must be a non-negative number.')
if renew_password:
password = generate_password()
else:
password = ''
if renew_creation_date:
creation_date = datetime.now().strftime('%Y-%m-%d')
else:
creation_date = ''
command_args = [
'bash',
Command.EDIT_USER.value,
@ -426,6 +430,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):
'''

View File

@ -24,7 +24,7 @@ validate_traffic_limit() {
return 0 # Optional value is valid
fi
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
fi
return 0
@ -36,7 +36,7 @@ validate_expiration_days() {
return 0 # Optional value is valid
fi
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
fi
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
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,53 +163,36 @@ 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")
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:
nodes = load_nodes()
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 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 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 == 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 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()

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")
ACCOUNT_CREATION_DATE=$(jq -r --arg user "$USERNAME" '.[$user].account_creation_date' "$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_UPLOAD_BYTES=$(jq -r --arg user "$USERNAME" '.[$user].upload_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 // 0' "$USERS_FILE")
TOTAL_BYTES=$((CURRENT_DOWNLOAD_BYTES + CURRENT_UPLOAD_BYTES))
@ -28,6 +28,7 @@ if [ "$STORED_PASSWORD" != "$PASSWORD" ]; then
exit 1
fi
if [ "$EXPIRATION_DAYS" -ne 0 ]; then
CURRENT_DATE=$(date +%s)
EXPIRATION_DATE=$(date -d "$ACCOUNT_CREATION_DATE + $EXPIRATION_DAYS days" +%s)
@ -35,7 +36,9 @@ if [ "$CURRENT_DATE" -ge "$EXPIRATION_DATE" ]; then
jq --arg user "$USERNAME" '.[$user].blocked = true' "$USERS_FILE" > temp.json && mv temp.json "$USERS_FILE"
exit 1
fi
fi
if [ "$MAX_DOWNLOAD_BYTES" -ne 0 ]; then
if [ "$TOTAL_BYTES" -ge "$MAX_DOWNLOAD_BYTES" ]; then
SECRET=$(jq -r '.trafficStats.secret' "$CONFIG_FILE")
KICK_ENDPOINT="http://127.0.0.1:25413/kick"
@ -44,6 +47,7 @@ if [ "$TOTAL_BYTES" -ge "$MAX_DOWNLOAD_BYTES" ]; then
jq --arg user "$USERNAME" '.[$user].blocked = true' "$USERS_FILE" > temp.json && mv temp.json "$USERS_FILE"
exit 1
fi
fi
echo "$USERNAME"
exit 0

View File

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

View File

@ -6,7 +6,7 @@ import time
import shlex
import base64
from typing import Dict, List, Optional, Tuple, Any, Union
from dataclasses import dataclass
from dataclasses import dataclass, field
from io import BytesIO
from aiohttp import web
@ -29,6 +29,7 @@ class AppConfig:
singbox_template_path: str
hysteria_cli_path: str
users_json_path: str
nodes_json_path: str
rate_limit: int
rate_limit_window: int
sni: str
@ -106,6 +107,13 @@ class UserInfo:
return f"Upload: {upload}, Download: {download}, Total: {total}"
@dataclass
class NodeURI:
label: str
uri: str
qrcode: Optional[str] = None
@dataclass
class TemplateContext:
username: str
@ -113,11 +121,9 @@ class TemplateContext:
usage_raw: str
expiration_date: str
sublink_qrcode: str
ipv4_qrcode: Optional[str]
ipv6_qrcode: Optional[str]
sub_link: str
ipv4_uri: Optional[str]
ipv6_uri: Optional[str]
local_uris: List[NodeURI] = field(default_factory=list)
node_uris: List[NodeURI] = field(default_factory=list)
class Utils:
@ -244,17 +250,21 @@ class HysteriaCLI:
print(f"JSONDecodeError: {e}, Raw output: {raw_info_str}")
return None
def get_user_uri(self, username: str, ip_version: Optional[str] = None) -> str:
if ip_version:
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]]:
def get_all_uris(self, username: str) -> List[str]:
"""Fetches all available URIs (local and nodes) for a user."""
output = self._run_command(['show-user-uri', '-u', username, '-a'])
ipv4_uri = re.search(r'IPv4:\s*(.*)', output)
ipv6_uri = re.search(r'IPv6:\s*(.*)', output)
return (ipv4_uri.group(1).strip() if ipv4_uri else None, ipv6_uri.group(1).strip() if ipv6_uri else None)
if not output:
return []
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:
@ -303,74 +313,74 @@ class SingboxConfigGenerator:
raise RuntimeError(f"Error loading Singbox template: {e}") from e
return self._template_cache.copy()
def generate_config(self, username: str, ip_version: str, fragment: str) -> Optional[Dict[str, Any]]:
try:
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
def generate_config_from_uri(self, uri: str, username: str, fragment: str) -> Optional[Dict[str, Any]]:
"""Generates a Singbox outbound config from a single Hysteria URI."""
if not uri:
print(f"No URI found for {username} with IP version {ip_version}. Skipping.")
return None
components = UriParser.extract_uri_components(uri, f'IPv{ip_version}:')
if components is None or components.port is None:
print(f"Invalid URI components for {username} with IP version {ip_version}. Skipping.")
try:
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 {
"outbounds": [{
"type": "hysteria2",
"tag": f"{username}-Hysteria2",
"server": components.ip,
"server_port": components.port,
"tag": unquote(parsed_url.fragment),
"server": server,
"server_port": server_port,
"obfs": {
"type": "salamander",
"password": components.obfs_password
"password": obfs_password
},
"password": f"{username}:{components.password}",
"password": final_password,
"tls": {
"enabled": True,
"server_name": fragment if fragment else self.default_sni,
"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['outbounds'] = [outbound for outbound in combined_config['outbounds']
if outbound.get('type') != 'hysteria2']
combined_config['outbounds'] = [out for out in combined_config['outbounds'] if out.get('type') != 'hysteria2']
modified_v4_outbounds = []
if config_v4:
v4_outbound = config_v4['outbounds'][0]
v4_outbound['tag'] = f"{username}-IPv4"
modified_v4_outbounds.append(v4_outbound)
hysteria_outbounds = []
for uri in all_uris:
outbound = self.generate_config_from_uri(uri, username, fragment)
if outbound:
hysteria_outbounds.append(outbound)
modified_v6_outbounds = []
if config_v6:
v6_outbound = config_v6['outbounds'][0]
v6_outbound['tag'] = f"{username}-IPv6"
modified_v6_outbounds.append(v6_outbound)
if not hysteria_outbounds:
return None
select_outbounds = ["auto"]
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")
all_tags = [out['tag'] for out in hysteria_outbounds]
for outbound in combined_config['outbounds']:
if outbound.get('tag') == 'select':
outbound['outbounds'] = select_outbounds
outbound['outbounds'] = ["auto"] + all_tags
elif outbound.get('tag') == 'auto':
outbound['outbounds'] = auto_outbounds
combined_config['outbounds'].extend(modified_v4_outbounds + modified_v6_outbounds)
outbound['outbounds'] = all_tags
combined_config['outbounds'].extend(hysteria_outbounds)
return combined_config
@ -383,13 +393,13 @@ class SubscriptionManager:
user_info = self.hysteria_cli.get_user_info(username)
if user_info is None:
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]
if not output_lines:
all_uris = self.hysteria_cli.get_all_uris(username)
if not all_uris:
return "No URI available"
processed_uris = []
for uri in output_lines:
for uri in all_uris:
if "v2ray" in user_agent and "ng" in user_agent:
match = re.search(r'pinSHA256=sha256/([^&]+)', uri)
if match:
@ -455,6 +465,7 @@ class HysteriaServer:
singbox_template_path = '/etc/hysteria/core/scripts/normalsub/singbox.json'
hysteria_cli_path = '/etc/hysteria/core/cli.py'
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_window = 60
template_dir = os.path.dirname(__file__)
@ -467,6 +478,7 @@ class HysteriaServer:
singbox_template_path=singbox_template_path,
hysteria_cli_path=hysteria_cli_path,
users_json_path=users_json_path,
nodes_json_path=nodes_json_path,
rate_limit=rate_limit, rate_limit_window=rate_limit_window,
sni=sni, template_dir=template_dir,
subpath=subpath)
@ -548,22 +560,21 @@ class HysteriaServer:
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:
config_v4 = self.singbox_generator.generate_config(username, '4', fragment)
config_v6 = self.singbox_generator.generate_config(username, '6', fragment)
if config_v4 is None and config_v6 is None:
all_uris = self.hysteria_cli.get_all_uris(username)
if not all_uris:
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')
async def _handle_normalsub(self, request: web.Request, username: str, user_info: UserInfo) -> web.Response:
user_agent = request.headers.get('User-Agent', '').lower()
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(text=subscription, content_type='text/plain')
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 ""
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.")
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)
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(
username=username,
usage=user_info.usage_human_readable,
usage_raw=user_info.usage_detailed,
expiration_date=user_info.expiration_date,
sublink_qrcode=sublink_qrcode,
ipv4_qrcode=ipv4_qrcode,
ipv6_qrcode=ipv6_qrcode,
sub_link=sub_link,
ipv4_uri=ipv4_uri,
ipv6_uri=ipv6_uri
local_uris=local_uris,
node_uris=node_uris
)
async def robots_handler(self, request: web.Request) -> web.Response:
@ -605,7 +625,6 @@ class HysteriaServer:
port=self.config.aiohttp_listen_port
)
if __name__ == '__main__':
server = HysteriaServer()
server.run()

View File

@ -1,154 +1,158 @@
{
"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
"action": "route",
"clash_mode": "Direct",
"outbound": "direct"
},
{
"action": "route",
"clash_mode": "Global",
"outbound": "proxy"
},
{
"action": "hijack-dns",
"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"
},
{
"action": "route",
"rule_set": "geosite-ir",
"outbound": "direct"
},
{
"action": "route",
"rule_set": "geoip-ir",
"outbound": "direct"
}
],
"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"
"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"
]
}
]
}

View File

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

View File

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

View File

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

View File

@ -1,24 +1,50 @@
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
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
@ -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
ipv4: Optional[str] = None
ipv6: Optional[str] = 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])
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:

View File

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

View File

@ -49,7 +49,7 @@
<li class='nav-item'>
<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>
Change IP</a>
IP Management</a>
</li>
<li class='nav-item'>
<a class='nav-link' id='backup-tab' data-toggle='pill' href='#backup' role='tab'
@ -208,28 +208,71 @@
</button>
</div>
<!-- Change IP Tab -->
<!-- IP Management Tab -->
<div class='tab-pane fade' id='change_ip' role='tabpanel' aria-labelledby='ip-tab'>
<div class="card card-outline card-primary">
<div class="card-header">
<h3 class="card-title">Local Server IP / Domain</h3>
</div>
<div class="card-body">
<form id="change_ip_form">
<div class='form-group'>
<label for='ipv4'>IPv4:</label>
<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>
<label for='ipv4'>IPv4 / Domain:</label>
<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 class='form-group'>
<label for='ipv6'>IPv6:</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>
<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>
<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>
<!-- Backup Tab -->
<div class='tab-pane fade' id='backup' role='tabpanel' aria-labelledby='backup-tab'>
@ -414,7 +457,6 @@
</div>
</div>
<!-- /.card -->
</div>
</div>
</div>
@ -436,6 +478,7 @@
initUI();
fetchDecoyStatus();
fetchObfsStatus();
fetchNodes();
function isValidPath(path) {
if (!path) return false;
@ -458,20 +501,17 @@
return /^[a-zA-Z0-9]+$/.test(subpath);
}
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 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 lowerInput = input.toLowerCase();
if (ipV4Regex.test(input)) return true;
if (ipV6Regex.test(input)) return true;
if (domainRegex.test(lowerInput) && !lowerInput.startsWith("http://") && !lowerInput.startsWith("https://")) return true;
return false;
return ipV4Regex.test(input) || ipV6Regex.test(input) || domainRegex.test(lowerInput);
}
function isValidPositiveNumber(value) {
@ -518,12 +558,16 @@
}
}
});
console.log("Success Response:", response);
},
error: function (xhr, status, error) {
let errorMessage = "Something went wrong.";
let errorMessage = "An unexpected error occurred.";
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");
console.error("AJAX Error:", status, error, xhr.responseText);
@ -552,6 +596,10 @@
fieldValid = isValidSubPath(input.val());
} else if (id === 'ipv4' || id === 'ipv6') {
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') {
fieldValid = isValidPositiveNumber(input.val());
} else if (id === 'decoy_path') {
@ -572,7 +620,6 @@
return isValid;
}
function initUI() {
$.ajax({
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) {
const servicesMap = {
"hysteria_telegram_bot": "#telegram_form",
@ -633,7 +757,6 @@
let targetSelector = servicesMap[serviceKey];
let isRunning = data[serviceKey];
if (serviceKey === "hysteria_normal_sub") {
const $normalForm = $("#normal_sub_service_form");
const $normalFormGroups = $normalForm.find(".form-group");
@ -771,7 +894,6 @@
});
}
function setupDecoy() {
if (!validateForm('decoy_form')) return;
const domain = $("#decoy_domain").val();
@ -846,7 +968,6 @@
}
}
function fetchObfsStatus() {
$.ajax({
url: "{{ url_for('check_obfs') }}",
@ -908,7 +1029,6 @@
});
}
function startTelegram() {
if (!validateForm('telegram_form')) return;
const apiToken = $("#telegram_api_token").val();
@ -1197,7 +1317,6 @@
});
});
$("#telegram_start").on("click", startTelegram);
$("#telegram_stop").on("click", stopTelegram);
$("#normal_start").on("click", startNormal);
@ -1215,7 +1334,11 @@
$("#decoy_stop").on("click", stopDecoy);
$("#obfs_enable_btn").on("click", enableObfs);
$("#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 () {
if (isValidDomain($(this).val())) {
@ -1247,8 +1370,19 @@
}
});
$('#ipv4, #ipv6').on('input', function () {
if (isValidIPorDomain($(this).val()) || $(this).val().trim() === '') {
$('#ipv4, #ipv6, #node_ip').on('input', function () {
const isLocalIpField = $(this).attr('id') === 'ipv4' || $(this).attr('id') === 'ipv6';
if (isLocalIpField && $(this).val().trim() === '') {
$(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 {
$(this).addClass('is-invalid');

View File

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

View File

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

View File

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