Merge pull request #239 from ReturnFI/beta
External Node Management & Fixes
This commit is contained in:
11
changelog
11
changelog
@ -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)
|
||||
|
||||
40
core/cli.py
40
core/cli.py
@ -298,6 +298,41 @@ def ip_address(edit: bool, ipv4: str, ipv6: str):
|
||||
click.echo(f'{e}', err=True)
|
||||
|
||||
|
||||
@cli.group()
|
||||
def node():
|
||||
"""Manage external node IPs for multi-server setups."""
|
||||
pass
|
||||
|
||||
@node.command('add')
|
||||
@click.option('--name', required=True, type=str, help='A unique name for the node (e.g., "Node-DE").')
|
||||
@click.option('--ip', required=True, type=str, help='The public IP address of the node.')
|
||||
def add_node(name, ip):
|
||||
"""Add a new external node."""
|
||||
try:
|
||||
output = cli_api.add_node(name, ip)
|
||||
click.echo(output.strip())
|
||||
except Exception as e:
|
||||
click.echo(f'{e}', err=True)
|
||||
|
||||
@node.command('delete')
|
||||
@click.option('--name', required=True, type=str, help='The name of the node to delete.')
|
||||
def delete_node(name):
|
||||
"""Delete an external node by its name."""
|
||||
try:
|
||||
output = cli_api.delete_node(name)
|
||||
click.echo(output.strip())
|
||||
except Exception as e:
|
||||
click.echo(f'{e}', err=True)
|
||||
|
||||
@node.command('list')
|
||||
def list_nodes():
|
||||
"""List all configured external nodes."""
|
||||
try:
|
||||
output = cli_api.list_nodes()
|
||||
click.echo(output.strip())
|
||||
except Exception as e:
|
||||
click.echo(f'{e}', err=True)
|
||||
|
||||
@cli.command('update-geo')
|
||||
@click.option('--country', '-c',
|
||||
type=click.Choice(['iran', 'china', 'russia'], case_sensitive=False),
|
||||
@ -323,9 +358,6 @@ def masquerade(remove: bool, enable: str):
|
||||
raise click.UsageError('Error: You cannot use both --remove and --enable at the same time')
|
||||
|
||||
if enable:
|
||||
# NOT SURE THIS IS NEEDED
|
||||
# if not enable.startswith('http://') and not enable.startswith('https://'):
|
||||
# enable = 'https://' + enable
|
||||
cli_api.enable_hysteria2_masquerade(enable)
|
||||
click.echo('Masquerade enabled successfully.')
|
||||
elif remove:
|
||||
@ -651,4 +683,4 @@ def config_ip_limit(block_duration: int, max_ips: int):
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
cli()
|
||||
cli()
|
||||
@ -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):
|
||||
'''
|
||||
|
||||
@ -20,11 +20,11 @@ validate_username() {
|
||||
|
||||
validate_traffic_limit() {
|
||||
local traffic_limit=$1
|
||||
if [ -z "$traffic_limit" ]; then
|
||||
return 0 # Optional value is valid
|
||||
if [ -z "$traffic_limit" ]; then
|
||||
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
|
||||
@ -32,11 +32,11 @@ validate_traffic_limit() {
|
||||
|
||||
validate_expiration_days() {
|
||||
local expiration_days=$1
|
||||
if [ -z "$expiration_days" ]; then
|
||||
if [ -z "$expiration_days" ]; then
|
||||
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
|
||||
@ -237,4 +237,4 @@ edit_user() {
|
||||
|
||||
|
||||
# Run the script
|
||||
edit_user "$1" "$2" "$3" "$4" "$5" "$6" "$7"
|
||||
edit_user "$1" "$2" "$3" "$4" "$5" "$6" "$7"
|
||||
118
core/scripts/hysteria2/node.py
Normal file
118
core/scripts/hysteria2/node.py
Normal 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()
|
||||
@ -24,6 +24,18 @@ def load_env_file(env_file: str) -> Dict[str, str]:
|
||||
env_vars[key] = value
|
||||
return env_vars
|
||||
|
||||
def load_nodes() -> List[Dict[str, str]]:
|
||||
"""Load external node information from the nodes JSON file."""
|
||||
if NODES_JSON_PATH.exists():
|
||||
try:
|
||||
with NODES_JSON_PATH.open("r") as f:
|
||||
content = f.read()
|
||||
if content:
|
||||
return json.loads(content)
|
||||
except (json.JSONDecodeError, IOError):
|
||||
pass
|
||||
return []
|
||||
|
||||
def load_hysteria2_env() -> Dict[str, str]:
|
||||
"""Load Hysteria2 environment variables."""
|
||||
return load_env_file(CONFIG_ENV)
|
||||
@ -63,7 +75,8 @@ def is_service_active(service_name: str) -> bool:
|
||||
return False
|
||||
|
||||
def generate_uri(username: str, auth_password: str, ip: str, port: str,
|
||||
obfs_password: str, sha256: str, sni: str, ip_version: int, insecure: bool) -> str:
|
||||
obfs_password: str, sha256: str, sni: str, ip_version: int,
|
||||
insecure: bool, fragment_tag: str) -> str:
|
||||
"""Generate Hysteria2 URI for the given parameters."""
|
||||
uri_base = f"hy2://{username}%3A{auth_password}@{ip}:{port}"
|
||||
|
||||
@ -82,7 +95,7 @@ def generate_uri(username: str, auth_password: str, ip: str, port: str,
|
||||
params.append(f"insecure={insecure_value}&sni={sni}")
|
||||
|
||||
params_str = "&".join(params)
|
||||
return f"{uri_base}?{params_str}#{username}-IPv{ip_version}"
|
||||
return f"{uri_base}?{params_str}#{fragment_tag}"
|
||||
|
||||
def generate_qr_code(uri: str) -> List[str]:
|
||||
"""Generate terminal-friendly ASCII QR code using pure Python."""
|
||||
@ -113,8 +126,21 @@ def get_terminal_width() -> int:
|
||||
except (AttributeError, OSError):
|
||||
return 80
|
||||
|
||||
def display_uri_and_qr(uri: str, label: str, args: argparse.Namespace, terminal_width: int):
|
||||
"""Helper function to print URI and its QR code."""
|
||||
if not uri:
|
||||
return
|
||||
|
||||
print(f"\n{label}:\n{uri}\n")
|
||||
|
||||
if args.qrcode:
|
||||
print(f"{label} QR Code:\n")
|
||||
qr_code = generate_qr_code(uri)
|
||||
for line in qr_code:
|
||||
print(center_text(line, terminal_width))
|
||||
|
||||
def show_uri(args: argparse.Namespace) -> None:
|
||||
"""Show URI and optional QR codes for the given username."""
|
||||
"""Show URI and optional QR codes for the given username and nodes."""
|
||||
if not os.path.exists(USERS_FILE):
|
||||
print(f"\033[0;31mError:\033[0m Config file {USERS_FILE} not found.")
|
||||
return
|
||||
@ -137,54 +163,37 @@ def show_uri(args: argparse.Namespace) -> None:
|
||||
port = config["listen"].split(":")[1] if ":" in config["listen"] else config["listen"]
|
||||
sha256 = config.get("tls", {}).get("pinSHA256", "")
|
||||
obfs_password = config.get("obfs", {}).get("salamander", {}).get("password", "")
|
||||
|
||||
insecure = config.get("tls", {}).get("insecure", True)
|
||||
|
||||
ip4, ip6, sni = load_hysteria2_ips()
|
||||
available_ip4 = ip4 and ip4 != "None"
|
||||
available_ip6 = ip6 and ip6 != "None"
|
||||
|
||||
uri_ipv4 = None
|
||||
uri_ipv6 = None
|
||||
|
||||
if args.all:
|
||||
if available_ip4:
|
||||
uri_ipv4 = generate_uri(args.username, auth_password, ip4, port,
|
||||
obfs_password, sha256, sni, 4, insecure)
|
||||
print(f"\nIPv4:\n{uri_ipv4}\n")
|
||||
nodes = load_nodes()
|
||||
terminal_width = get_terminal_width()
|
||||
|
||||
if args.all or args.ip_version == 4:
|
||||
if ip4 and ip4 != "None":
|
||||
uri = generate_uri(args.username, auth_password, ip4, port,
|
||||
obfs_password, sha256, sni, 4, insecure, f"{args.username}-IPv4")
|
||||
display_uri_and_qr(uri, "IPv4", args, terminal_width)
|
||||
|
||||
if args.all or args.ip_version == 6:
|
||||
if ip6 and ip6 != "None":
|
||||
uri = generate_uri(args.username, auth_password, ip6, port,
|
||||
obfs_password, sha256, sni, 6, insecure, f"{args.username}-IPv6")
|
||||
display_uri_and_qr(uri, "IPv6", args, terminal_width)
|
||||
|
||||
for node in nodes:
|
||||
node_name = node.get("name")
|
||||
node_ip = node.get("ip")
|
||||
if not node_name or not node_ip:
|
||||
continue
|
||||
|
||||
ip_v = 4 if '.' in node_ip else 6
|
||||
|
||||
if available_ip6:
|
||||
uri_ipv6 = generate_uri(args.username, auth_password, ip6, port,
|
||||
obfs_password, sha256, sni, 6, insecure)
|
||||
print(f"\nIPv6:\n{uri_ipv6}\n")
|
||||
else:
|
||||
if args.ip_version == 4 and available_ip4:
|
||||
uri_ipv4 = generate_uri(args.username, auth_password, ip4, port,
|
||||
obfs_password, sha256, sni, 4, insecure)
|
||||
print(f"\nIPv4:\n{uri_ipv4}\n")
|
||||
elif args.ip_version == 6 and available_ip6:
|
||||
uri_ipv6 = generate_uri(args.username, auth_password, ip6, port,
|
||||
obfs_password, sha256, sni, 6, insecure)
|
||||
print(f"\nIPv6:\n{uri_ipv6}\n")
|
||||
else:
|
||||
print("Invalid IP version or no available IP for the requested version.")
|
||||
return
|
||||
|
||||
if args.qrcode:
|
||||
terminal_width = get_terminal_width()
|
||||
|
||||
if uri_ipv4:
|
||||
qr_code = generate_qr_code(uri_ipv4)
|
||||
print("\nIPv4 QR Code:\n")
|
||||
for line in qr_code:
|
||||
print(center_text(line, terminal_width))
|
||||
|
||||
if uri_ipv6:
|
||||
qr_code = generate_qr_code(uri_ipv6)
|
||||
print("\nIPv6 QR Code:\n")
|
||||
for line in qr_code:
|
||||
print(center_text(line, terminal_width))
|
||||
|
||||
if args.all or args.ip_version == ip_v:
|
||||
uri = generate_uri(args.username, auth_password, node_ip, port,
|
||||
obfs_password, sha256, sni, ip_v, insecure, f"{args.username}-{node_name}")
|
||||
display_uri_and_qr(uri, f"Node: {node_name} (IPv{ip_v})", args, terminal_width)
|
||||
|
||||
if args.singbox and is_service_active("hysteria-singbox.service"):
|
||||
domain, port = get_singbox_domain_and_port()
|
||||
if domain and port:
|
||||
|
||||
@ -13,13 +13,13 @@ 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))
|
||||
|
||||
if [ "$BLOCKED" == "true" ]; then
|
||||
sleep 20
|
||||
sleep 20
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@ -28,22 +28,26 @@ if [ "$STORED_PASSWORD" != "$PASSWORD" ]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
CURRENT_DATE=$(date +%s)
|
||||
EXPIRATION_DATE=$(date -d "$ACCOUNT_CREATION_DATE + $EXPIRATION_DAYS days" +%s)
|
||||
if [ "$EXPIRATION_DAYS" -ne 0 ]; then
|
||||
CURRENT_DATE=$(date +%s)
|
||||
EXPIRATION_DATE=$(date -d "$ACCOUNT_CREATION_DATE + $EXPIRATION_DAYS days" +%s)
|
||||
|
||||
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
|
||||
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 [ "$TOTAL_BYTES" -ge "$MAX_DOWNLOAD_BYTES" ]; then
|
||||
SECRET=$(jq -r '.trafficStats.secret' "$CONFIG_FILE")
|
||||
KICK_ENDPOINT="http://127.0.0.1:25413/kick"
|
||||
curl -s -H "Authorization: $SECRET" -X POST -d "[\"$USERNAME\"]" "$KICK_ENDPOINT"
|
||||
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"
|
||||
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"
|
||||
exit 1
|
||||
jq --arg user "$USERNAME" '.[$user].blocked = true' "$USERS_FILE" > temp.json && mv temp.json "$USERS_FILE"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "$USERNAME"
|
||||
exit 0
|
||||
exit 0
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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,
|
||||
"obfs": {
|
||||
"type": "salamander",
|
||||
"password": components.obfs_password
|
||||
},
|
||||
"password": f"{username}:{components.password}",
|
||||
"tls": {
|
||||
"enabled": True,
|
||||
"server_name": fragment if fragment else self.default_sni,
|
||||
"insecure": True
|
||||
}
|
||||
}]
|
||||
"type": "hysteria2",
|
||||
"tag": unquote(parsed_url.fragment),
|
||||
"server": server,
|
||||
"server_port": server_port,
|
||||
"obfs": {
|
||||
"type": "salamander",
|
||||
"password": obfs_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,10 +582,21 @@ 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,
|
||||
@ -582,11 +604,9 @@ class HysteriaServer:
|
||||
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()
|
||||
server.run()
|
||||
|
||||
@ -1,155 +1,159 @@
|
||||
{
|
||||
"log": {
|
||||
"level": "info",
|
||||
"timestamp": true
|
||||
},
|
||||
"dns": {
|
||||
"servers": [
|
||||
{
|
||||
"tag": "proxyDns",
|
||||
"address": "tls://8.8.8.8",
|
||||
"detour": "Proxy"
|
||||
},
|
||||
{
|
||||
"tag": "localDns",
|
||||
"address": "8.8.8.8",
|
||||
"detour": "direct"
|
||||
}
|
||||
],
|
||||
"final": "local-dns",
|
||||
"rules": [
|
||||
{
|
||||
"outbound": "any",
|
||||
"server": "localDns"
|
||||
"action": "route",
|
||||
"clash_mode": "Global",
|
||||
"server": "proxy-dns",
|
||||
"source_ip_cidr": [
|
||||
"172.19.0.0/30",
|
||||
"fdfe:dcba:9876::1/126"
|
||||
]
|
||||
},
|
||||
{
|
||||
"rule_set": "geosite-ir",
|
||||
"server": "proxyDns"
|
||||
},
|
||||
{
|
||||
"clash_mode": "direct",
|
||||
"server": "localDns"
|
||||
},
|
||||
{
|
||||
"clash_mode": "global",
|
||||
"server": "proxyDns"
|
||||
"action": "route",
|
||||
"server": "proxy-dns",
|
||||
"source_ip_cidr": [
|
||||
"172.19.0.0/30",
|
||||
"fdfe:dcba:9876::1/126"
|
||||
]
|
||||
}
|
||||
],
|
||||
"final": "localDns",
|
||||
"strategy": "ipv4_only"
|
||||
"servers": [
|
||||
{
|
||||
"type": "https",
|
||||
"server": "1.1.1.1",
|
||||
"detour": "proxy",
|
||||
"tag": "proxy-dns"
|
||||
},
|
||||
{
|
||||
"type": "local",
|
||||
"detour": "direct",
|
||||
"tag": "local-dns"
|
||||
}
|
||||
],
|
||||
"strategy": "prefer_ipv4"
|
||||
},
|
||||
"inbounds": [
|
||||
{
|
||||
"type": "tun",
|
||||
"tag": "tun-in",
|
||||
"address": "172.19.0.1/30",
|
||||
"address": [
|
||||
"172.19.0.1/30",
|
||||
"fdfe:dcba:9876::1/126"
|
||||
],
|
||||
"auto_route": true,
|
||||
"strict_route": true
|
||||
"endpoint_independent_nat": false,
|
||||
"mtu": 9000,
|
||||
"platform": {
|
||||
"http_proxy": {
|
||||
"enabled": true,
|
||||
"server": "127.0.0.1",
|
||||
"server_port": 2080
|
||||
}
|
||||
},
|
||||
"stack": "system",
|
||||
"strict_route": false,
|
||||
"type": "tun"
|
||||
},
|
||||
{
|
||||
"listen": "127.0.0.1",
|
||||
"listen_port": 2080,
|
||||
"type": "mixed",
|
||||
"users": []
|
||||
}
|
||||
],
|
||||
"log": {
|
||||
"level": "warn",
|
||||
"timestamp": true
|
||||
},
|
||||
"outbounds": [
|
||||
{
|
||||
"tag": "Proxy",
|
||||
"type": "selector",
|
||||
"outbounds": [
|
||||
"auto",
|
||||
"direct"
|
||||
]
|
||||
},
|
||||
{
|
||||
"tag": "Global",
|
||||
"type": "selector",
|
||||
"outbounds": [
|
||||
"direct"
|
||||
]
|
||||
},
|
||||
{
|
||||
"tag": "auto",
|
||||
"type": "urltest",
|
||||
"outbounds": [
|
||||
"Proxy"
|
||||
],
|
||||
"url": "http://www.gstatic.com/generate_204",
|
||||
"tag": "proxy",
|
||||
"type": "selector"
|
||||
},
|
||||
{
|
||||
"interval": "10m",
|
||||
"tolerance": 50
|
||||
"outbounds": [],
|
||||
"tag": "auto",
|
||||
"tolerance": 50,
|
||||
"type": "urltest",
|
||||
"url": "http://www.gstatic.com/generate_204"
|
||||
},
|
||||
{
|
||||
"type": "direct",
|
||||
"tag": "direct"
|
||||
},
|
||||
{
|
||||
"type": "direct",
|
||||
"tag": "local"
|
||||
"tag": "direct",
|
||||
"type": "direct"
|
||||
}
|
||||
],
|
||||
"route": {
|
||||
"auto_detect_interface": true,
|
||||
"final": "Proxy",
|
||||
"final": "proxy",
|
||||
"rule_set": [
|
||||
{
|
||||
"download_detour": "direct",
|
||||
"format": "binary",
|
||||
"tag": "geosite-ads",
|
||||
"type": "remote",
|
||||
"url": "https://raw.githubusercontent.com/Chocolate4U/Iran-sing-box-rules/rule-set/geosite-category-ads-all.srs"
|
||||
},
|
||||
{
|
||||
"download_detour": "direct",
|
||||
"format": "binary",
|
||||
"tag": "geoip-private",
|
||||
"type": "remote",
|
||||
"url": "https://raw.githubusercontent.com/Chocolate4U/Iran-sing-box-rules/rule-set/geoip-private.srs"
|
||||
},
|
||||
{
|
||||
"download_detour": "direct",
|
||||
"format": "binary",
|
||||
"tag": "geosite-ir",
|
||||
"type": "remote",
|
||||
"url": "https://raw.githubusercontent.com/Chocolate4U/Iran-sing-box-rules/rule-set/geosite-ir.srs"
|
||||
},
|
||||
{
|
||||
"download_detour": "direct",
|
||||
"format": "binary",
|
||||
"tag": "geoip-ir",
|
||||
"type": "remote",
|
||||
"url": "https://raw.githubusercontent.com/Chocolate4U/Iran-sing-box-rules/rule-set/geoip-ir.srs"
|
||||
}
|
||||
],
|
||||
"rules": [
|
||||
{
|
||||
"inbound": [
|
||||
"tun-in",
|
||||
"mixed-in"
|
||||
],
|
||||
"action": "sniff"
|
||||
},
|
||||
{
|
||||
"type": "logical",
|
||||
"mode": "or",
|
||||
"rules": [
|
||||
{
|
||||
"port": 53
|
||||
},
|
||||
{
|
||||
"protocol": "dns"
|
||||
}
|
||||
],
|
||||
"action": "hijack-dns"
|
||||
},
|
||||
{
|
||||
"rule_set": "geosite-category-ads-all",
|
||||
"action": "reject"
|
||||
},
|
||||
{
|
||||
"rule_set": "geosite-category-ads-all",
|
||||
"outbound": "Proxy"
|
||||
},
|
||||
{
|
||||
"ip_is_private": true,
|
||||
"action": "route",
|
||||
"clash_mode": "Direct",
|
||||
"outbound": "direct"
|
||||
},
|
||||
{
|
||||
"action": "route",
|
||||
"rule_set": "geosite-ir",
|
||||
"outbound": "direct"
|
||||
"clash_mode": "Global",
|
||||
"outbound": "proxy"
|
||||
},
|
||||
{
|
||||
"action": "hijack-dns",
|
||||
"protocol": "dns"
|
||||
},
|
||||
{
|
||||
"action": "route",
|
||||
"rule_set": "geoip-ir",
|
||||
"outbound": "direct"
|
||||
}
|
||||
],
|
||||
"rule_set": [
|
||||
{
|
||||
"tag": "geosite-category-ads-all",
|
||||
"type": "remote",
|
||||
"format": "binary",
|
||||
"url": "https://raw.githubusercontent.com/Chocolate4U/Iran-sing-box-rules/rule-set/geosite-category-ads-all.srs",
|
||||
"download_detour": "direct"
|
||||
"outbound": "direct",
|
||||
"rule_set": [
|
||||
"geosite-ir",
|
||||
"geoip-ir",
|
||||
"geoip-private"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "remote",
|
||||
"tag": "geoip-ir",
|
||||
"format": "binary",
|
||||
"url": "https://raw.githubusercontent.com/Chocolate4U/Iran-sing-box-rules/rule-set/geoip-ir.srs",
|
||||
"update_interval": "120h0m0s"
|
||||
},
|
||||
{
|
||||
"type": "remote",
|
||||
"tag": "geosite-ir",
|
||||
"format": "binary",
|
||||
"url": "https://raw.githubusercontent.com/Chocolate4U/Iran-sing-box-rules/rule-set/geosite-ir.srs",
|
||||
"update_interval": "120h0m0s"
|
||||
"action": "reject",
|
||||
"rule_set": [
|
||||
"geosite-ads"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
@ -229,6 +224,14 @@
|
||||
border-radius: 0.5rem;
|
||||
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;
|
||||
@ -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 {
|
||||
@ -355,63 +359,81 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<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-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 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 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>
|
||||
|
||||
<script>
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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))
|
||||
@ -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
|
||||
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]
|
||||
@ -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
|
||||
@ -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:
|
||||
|
||||
@ -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:
|
||||
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)
|
||||
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:
|
||||
|
||||
@ -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,27 +208,70 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Change IP Tab -->
|
||||
<!-- IP Management Tab -->
|
||||
<div class='tab-pane fade' id='change_ip' role='tabpanel' aria-labelledby='ip-tab'>
|
||||
<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>
|
||||
<div class="card card-outline card-primary">
|
||||
<div class="card-header">
|
||||
<h3 class="card-title">Local Server IP / Domain</h3>
|
||||
</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>
|
||||
<div class="card-body">
|
||||
<form id="change_ip_form">
|
||||
<div class='form-group'>
|
||||
<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 / 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>
|
||||
<button id="ip_change" type='button' class='btn btn-primary'>Save</button>
|
||||
</form>
|
||||
</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 -->
|
||||
@ -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') }}",
|
||||
@ -620,6 +667,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 = {
|
||||
@ -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,9 +1370,20 @@
|
||||
}
|
||||
});
|
||||
|
||||
$('#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');
|
||||
}
|
||||
|
||||
@ -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,23 +429,33 @@
|
||||
});
|
||||
});
|
||||
|
||||
// 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");
|
||||
$("#editUserForm button[type='submit']").prop("disabled", !isValid);
|
||||
});
|
||||
@ -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!",
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"
|
||||
|
||||
Reference in New Issue
Block a user