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
|
#### ✨ UI Enhancements
|
||||||
|
|
||||||
* 📌 **Sticky Sidebar:** Sidebar now stays fixed when scrolling through long pages for easier navigation
|
* ✨ feat(core): Implement external node management system
|
||||||
* 🃏 **Sticky Headers:** Card headers are now sticky with a sleek **bokeh blur** effect – better usability on settings and user lists
|
* 🌐 feat(api): Add external node management endpoints
|
||||||
* ⏎ **Login UX:** Pressing **Enter** now submits the login form properly for faster access
|
* 🔗 feat(normalsub): Add external node URIs to subscriptions
|
||||||
|
* 💾 feat: Backup nodes.json
|
||||||
|
* 🛠️ fix: Robust parsing, unlimited user handling, and various script/UI issues
|
||||||
|
* 📦 chore: Dependency updates (pytelegrambotapi, aiohttp)
|
||||||
|
|||||||
38
core/cli.py
38
core/cli.py
@ -298,6 +298,41 @@ def ip_address(edit: bool, ipv4: str, ipv6: str):
|
|||||||
click.echo(f'{e}', err=True)
|
click.echo(f'{e}', err=True)
|
||||||
|
|
||||||
|
|
||||||
|
@cli.group()
|
||||||
|
def node():
|
||||||
|
"""Manage external node IPs for multi-server setups."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@node.command('add')
|
||||||
|
@click.option('--name', required=True, type=str, help='A unique name for the node (e.g., "Node-DE").')
|
||||||
|
@click.option('--ip', required=True, type=str, help='The public IP address of the node.')
|
||||||
|
def add_node(name, ip):
|
||||||
|
"""Add a new external node."""
|
||||||
|
try:
|
||||||
|
output = cli_api.add_node(name, ip)
|
||||||
|
click.echo(output.strip())
|
||||||
|
except Exception as e:
|
||||||
|
click.echo(f'{e}', err=True)
|
||||||
|
|
||||||
|
@node.command('delete')
|
||||||
|
@click.option('--name', required=True, type=str, help='The name of the node to delete.')
|
||||||
|
def delete_node(name):
|
||||||
|
"""Delete an external node by its name."""
|
||||||
|
try:
|
||||||
|
output = cli_api.delete_node(name)
|
||||||
|
click.echo(output.strip())
|
||||||
|
except Exception as e:
|
||||||
|
click.echo(f'{e}', err=True)
|
||||||
|
|
||||||
|
@node.command('list')
|
||||||
|
def list_nodes():
|
||||||
|
"""List all configured external nodes."""
|
||||||
|
try:
|
||||||
|
output = cli_api.list_nodes()
|
||||||
|
click.echo(output.strip())
|
||||||
|
except Exception as e:
|
||||||
|
click.echo(f'{e}', err=True)
|
||||||
|
|
||||||
@cli.command('update-geo')
|
@cli.command('update-geo')
|
||||||
@click.option('--country', '-c',
|
@click.option('--country', '-c',
|
||||||
type=click.Choice(['iran', 'china', 'russia'], case_sensitive=False),
|
type=click.Choice(['iran', 'china', 'russia'], case_sensitive=False),
|
||||||
@ -323,9 +358,6 @@ def masquerade(remove: bool, enable: str):
|
|||||||
raise click.UsageError('Error: You cannot use both --remove and --enable at the same time')
|
raise click.UsageError('Error: You cannot use both --remove and --enable at the same time')
|
||||||
|
|
||||||
if enable:
|
if enable:
|
||||||
# NOT SURE THIS IS NEEDED
|
|
||||||
# if not enable.startswith('http://') and not enable.startswith('https://'):
|
|
||||||
# enable = 'https://' + enable
|
|
||||||
cli_api.enable_hysteria2_masquerade(enable)
|
cli_api.enable_hysteria2_masquerade(enable)
|
||||||
click.echo('Masquerade enabled successfully.')
|
click.echo('Masquerade enabled successfully.')
|
||||||
elif remove:
|
elif remove:
|
||||||
|
|||||||
@ -14,6 +14,7 @@ CONFIG_FILE = '/etc/hysteria/config.json'
|
|||||||
CONFIG_ENV_FILE = '/etc/hysteria/.configs.env'
|
CONFIG_ENV_FILE = '/etc/hysteria/.configs.env'
|
||||||
WEBPANEL_ENV_FILE = '/etc/hysteria/core/scripts/webpanel/.env'
|
WEBPANEL_ENV_FILE = '/etc/hysteria/core/scripts/webpanel/.env'
|
||||||
NORMALSUB_ENV_FILE = '/etc/hysteria/core/scripts/normalsub/.env'
|
NORMALSUB_ENV_FILE = '/etc/hysteria/core/scripts/normalsub/.env'
|
||||||
|
NODES_JSON_PATH = "/etc/hysteria/nodes.json"
|
||||||
|
|
||||||
|
|
||||||
class Command(Enum):
|
class Command(Enum):
|
||||||
@ -32,6 +33,7 @@ class Command(Enum):
|
|||||||
SHOW_USER_URI = os.path.join(SCRIPT_DIR, 'hysteria2', 'show_user_uri.py')
|
SHOW_USER_URI = os.path.join(SCRIPT_DIR, 'hysteria2', 'show_user_uri.py')
|
||||||
WRAPPER_URI = os.path.join(SCRIPT_DIR, 'hysteria2', 'wrapper_uri.py')
|
WRAPPER_URI = os.path.join(SCRIPT_DIR, 'hysteria2', 'wrapper_uri.py')
|
||||||
IP_ADD = os.path.join(SCRIPT_DIR, 'hysteria2', 'ip.py')
|
IP_ADD = os.path.join(SCRIPT_DIR, 'hysteria2', 'ip.py')
|
||||||
|
NODE_MANAGER = os.path.join(SCRIPT_DIR, 'hysteria2', 'node.py')
|
||||||
MANAGE_OBFS = os.path.join(SCRIPT_DIR, 'hysteria2', 'manage_obfs.py')
|
MANAGE_OBFS = os.path.join(SCRIPT_DIR, 'hysteria2', 'manage_obfs.py')
|
||||||
MASQUERADE_SCRIPT = os.path.join(SCRIPT_DIR, 'hysteria2', 'masquerade.py')
|
MASQUERADE_SCRIPT = os.path.join(SCRIPT_DIR, 'hysteria2', 'masquerade.py')
|
||||||
TRAFFIC_STATUS = 'traffic.py' # won't be called directly (it's a python module)
|
TRAFFIC_STATUS = 'traffic.py' # won't be called directly (it's a python module)
|
||||||
@ -281,20 +283,22 @@ def edit_user(username: str, new_username: str | None, new_traffic_limit: int |
|
|||||||
'''
|
'''
|
||||||
if not username:
|
if not username:
|
||||||
raise InvalidInputError('Error: username is required')
|
raise InvalidInputError('Error: username is required')
|
||||||
if not any([new_username, new_traffic_limit, new_expiration_days, renew_password, renew_creation_date, blocked is not None]): # type: ignore
|
|
||||||
raise InvalidInputError('Error: at least one option is required')
|
if new_traffic_limit is not None and new_traffic_limit < 0:
|
||||||
if new_traffic_limit is not None and new_traffic_limit <= 0:
|
raise InvalidInputError('Error: traffic limit must be a non-negative number.')
|
||||||
raise InvalidInputError('Error: traffic limit must be greater than 0')
|
if new_expiration_days is not None and new_expiration_days < 0:
|
||||||
if new_expiration_days is not None and new_expiration_days <= 0:
|
raise InvalidInputError('Error: expiration days must be a non-negative number.')
|
||||||
raise InvalidInputError('Error: expiration days must be greater than 0')
|
|
||||||
if renew_password:
|
if renew_password:
|
||||||
password = generate_password()
|
password = generate_password()
|
||||||
else:
|
else:
|
||||||
password = ''
|
password = ''
|
||||||
|
|
||||||
if renew_creation_date:
|
if renew_creation_date:
|
||||||
creation_date = datetime.now().strftime('%Y-%m-%d')
|
creation_date = datetime.now().strftime('%Y-%m-%d')
|
||||||
else:
|
else:
|
||||||
creation_date = ''
|
creation_date = ''
|
||||||
|
|
||||||
command_args = [
|
command_args = [
|
||||||
'bash',
|
'bash',
|
||||||
Command.EDIT_USER.value,
|
Command.EDIT_USER.value,
|
||||||
@ -426,6 +430,23 @@ def edit_ip_address(ipv4: str, ipv6: str):
|
|||||||
if ipv6:
|
if ipv6:
|
||||||
run_cmd(['python3', Command.IP_ADD.value, 'edit', '-6', ipv6])
|
run_cmd(['python3', Command.IP_ADD.value, 'edit', '-6', ipv6])
|
||||||
|
|
||||||
|
def add_node(name: str, ip: str):
|
||||||
|
"""
|
||||||
|
Adds a new external node.
|
||||||
|
"""
|
||||||
|
return run_cmd(['python3', Command.NODE_MANAGER.value, 'add', '--name', name, '--ip', ip])
|
||||||
|
|
||||||
|
def delete_node(name: str):
|
||||||
|
"""
|
||||||
|
Deletes an external node by name.
|
||||||
|
"""
|
||||||
|
return run_cmd(['python3', Command.NODE_MANAGER.value, 'delete', '--name', name])
|
||||||
|
|
||||||
|
def list_nodes():
|
||||||
|
"""
|
||||||
|
Lists all configured external nodes.
|
||||||
|
"""
|
||||||
|
return run_cmd(['python3', Command.NODE_MANAGER.value, 'list'])
|
||||||
|
|
||||||
def update_geo(country: str):
|
def update_geo(country: str):
|
||||||
'''
|
'''
|
||||||
|
|||||||
@ -24,7 +24,7 @@ validate_traffic_limit() {
|
|||||||
return 0 # Optional value is valid
|
return 0 # Optional value is valid
|
||||||
fi
|
fi
|
||||||
if ! [[ "$traffic_limit" =~ ^[0-9]+$ ]]; then
|
if ! [[ "$traffic_limit" =~ ^[0-9]+$ ]]; then
|
||||||
echo "Traffic limit must be a valid integer."
|
echo "Error: Traffic limit must be a valid non-negative number (use 0 for unlimited)."
|
||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
return 0
|
return 0
|
||||||
@ -36,7 +36,7 @@ validate_expiration_days() {
|
|||||||
return 0 # Optional value is valid
|
return 0 # Optional value is valid
|
||||||
fi
|
fi
|
||||||
if ! [[ "$expiration_days" =~ ^[0-9]+$ ]]; then
|
if ! [[ "$expiration_days" =~ ^[0-9]+$ ]]; then
|
||||||
echo "Expiration days must be a valid integer."
|
echo "Error: Expiration days must be a valid non-negative number (use 0 for unlimited)."
|
||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
return 0
|
return 0
|
||||||
|
|||||||
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
|
env_vars[key] = value
|
||||||
return env_vars
|
return env_vars
|
||||||
|
|
||||||
|
def load_nodes() -> List[Dict[str, str]]:
|
||||||
|
"""Load external node information from the nodes JSON file."""
|
||||||
|
if NODES_JSON_PATH.exists():
|
||||||
|
try:
|
||||||
|
with NODES_JSON_PATH.open("r") as f:
|
||||||
|
content = f.read()
|
||||||
|
if content:
|
||||||
|
return json.loads(content)
|
||||||
|
except (json.JSONDecodeError, IOError):
|
||||||
|
pass
|
||||||
|
return []
|
||||||
|
|
||||||
def load_hysteria2_env() -> Dict[str, str]:
|
def load_hysteria2_env() -> Dict[str, str]:
|
||||||
"""Load Hysteria2 environment variables."""
|
"""Load Hysteria2 environment variables."""
|
||||||
return load_env_file(CONFIG_ENV)
|
return load_env_file(CONFIG_ENV)
|
||||||
@ -63,7 +75,8 @@ def is_service_active(service_name: str) -> bool:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
def generate_uri(username: str, auth_password: str, ip: str, port: str,
|
def generate_uri(username: str, auth_password: str, ip: str, port: str,
|
||||||
obfs_password: str, sha256: str, sni: str, ip_version: int, insecure: bool) -> str:
|
obfs_password: str, sha256: str, sni: str, ip_version: int,
|
||||||
|
insecure: bool, fragment_tag: str) -> str:
|
||||||
"""Generate Hysteria2 URI for the given parameters."""
|
"""Generate Hysteria2 URI for the given parameters."""
|
||||||
uri_base = f"hy2://{username}%3A{auth_password}@{ip}:{port}"
|
uri_base = f"hy2://{username}%3A{auth_password}@{ip}:{port}"
|
||||||
|
|
||||||
@ -82,7 +95,7 @@ def generate_uri(username: str, auth_password: str, ip: str, port: str,
|
|||||||
params.append(f"insecure={insecure_value}&sni={sni}")
|
params.append(f"insecure={insecure_value}&sni={sni}")
|
||||||
|
|
||||||
params_str = "&".join(params)
|
params_str = "&".join(params)
|
||||||
return f"{uri_base}?{params_str}#{username}-IPv{ip_version}"
|
return f"{uri_base}?{params_str}#{fragment_tag}"
|
||||||
|
|
||||||
def generate_qr_code(uri: str) -> List[str]:
|
def generate_qr_code(uri: str) -> List[str]:
|
||||||
"""Generate terminal-friendly ASCII QR code using pure Python."""
|
"""Generate terminal-friendly ASCII QR code using pure Python."""
|
||||||
@ -113,8 +126,21 @@ def get_terminal_width() -> int:
|
|||||||
except (AttributeError, OSError):
|
except (AttributeError, OSError):
|
||||||
return 80
|
return 80
|
||||||
|
|
||||||
|
def display_uri_and_qr(uri: str, label: str, args: argparse.Namespace, terminal_width: int):
|
||||||
|
"""Helper function to print URI and its QR code."""
|
||||||
|
if not uri:
|
||||||
|
return
|
||||||
|
|
||||||
|
print(f"\n{label}:\n{uri}\n")
|
||||||
|
|
||||||
|
if args.qrcode:
|
||||||
|
print(f"{label} QR Code:\n")
|
||||||
|
qr_code = generate_qr_code(uri)
|
||||||
|
for line in qr_code:
|
||||||
|
print(center_text(line, terminal_width))
|
||||||
|
|
||||||
def show_uri(args: argparse.Namespace) -> None:
|
def show_uri(args: argparse.Namespace) -> None:
|
||||||
"""Show URI and optional QR codes for the given username."""
|
"""Show URI and optional QR codes for the given username and nodes."""
|
||||||
if not os.path.exists(USERS_FILE):
|
if not os.path.exists(USERS_FILE):
|
||||||
print(f"\033[0;31mError:\033[0m Config file {USERS_FILE} not found.")
|
print(f"\033[0;31mError:\033[0m Config file {USERS_FILE} not found.")
|
||||||
return
|
return
|
||||||
@ -137,53 +163,36 @@ def show_uri(args: argparse.Namespace) -> None:
|
|||||||
port = config["listen"].split(":")[1] if ":" in config["listen"] else config["listen"]
|
port = config["listen"].split(":")[1] if ":" in config["listen"] else config["listen"]
|
||||||
sha256 = config.get("tls", {}).get("pinSHA256", "")
|
sha256 = config.get("tls", {}).get("pinSHA256", "")
|
||||||
obfs_password = config.get("obfs", {}).get("salamander", {}).get("password", "")
|
obfs_password = config.get("obfs", {}).get("salamander", {}).get("password", "")
|
||||||
|
|
||||||
insecure = config.get("tls", {}).get("insecure", True)
|
insecure = config.get("tls", {}).get("insecure", True)
|
||||||
|
|
||||||
ip4, ip6, sni = load_hysteria2_ips()
|
ip4, ip6, sni = load_hysteria2_ips()
|
||||||
available_ip4 = ip4 and ip4 != "None"
|
nodes = load_nodes()
|
||||||
available_ip6 = ip6 and ip6 != "None"
|
|
||||||
|
|
||||||
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:
|
|
||||||
terminal_width = get_terminal_width()
|
terminal_width = get_terminal_width()
|
||||||
|
|
||||||
if uri_ipv4:
|
if args.all or args.ip_version == 4:
|
||||||
qr_code = generate_qr_code(uri_ipv4)
|
if ip4 and ip4 != "None":
|
||||||
print("\nIPv4 QR Code:\n")
|
uri = generate_uri(args.username, auth_password, ip4, port,
|
||||||
for line in qr_code:
|
obfs_password, sha256, sni, 4, insecure, f"{args.username}-IPv4")
|
||||||
print(center_text(line, terminal_width))
|
display_uri_and_qr(uri, "IPv4", args, terminal_width)
|
||||||
|
|
||||||
if uri_ipv6:
|
if args.all or args.ip_version == 6:
|
||||||
qr_code = generate_qr_code(uri_ipv6)
|
if ip6 and ip6 != "None":
|
||||||
print("\nIPv6 QR Code:\n")
|
uri = generate_uri(args.username, auth_password, ip6, port,
|
||||||
for line in qr_code:
|
obfs_password, sha256, sni, 6, insecure, f"{args.username}-IPv6")
|
||||||
print(center_text(line, terminal_width))
|
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"):
|
if args.singbox and is_service_active("hysteria-singbox.service"):
|
||||||
domain, port = get_singbox_domain_and_port()
|
domain, port = get_singbox_domain_and_port()
|
||||||
|
|||||||
@ -13,8 +13,8 @@ MAX_DOWNLOAD_BYTES=$(jq -r --arg user "$USERNAME" '.[$user].max_download_bytes'
|
|||||||
EXPIRATION_DAYS=$(jq -r --arg user "$USERNAME" '.[$user].expiration_days' "$USERS_FILE")
|
EXPIRATION_DAYS=$(jq -r --arg user "$USERNAME" '.[$user].expiration_days' "$USERS_FILE")
|
||||||
ACCOUNT_CREATION_DATE=$(jq -r --arg user "$USERNAME" '.[$user].account_creation_date' "$USERS_FILE")
|
ACCOUNT_CREATION_DATE=$(jq -r --arg user "$USERNAME" '.[$user].account_creation_date' "$USERS_FILE")
|
||||||
BLOCKED=$(jq -r --arg user "$USERNAME" '.[$user].blocked' "$USERS_FILE")
|
BLOCKED=$(jq -r --arg user "$USERNAME" '.[$user].blocked' "$USERS_FILE")
|
||||||
CURRENT_DOWNLOAD_BYTES=$(jq -r --arg user "$USERNAME" '.[$user].download_bytes' "$USERS_FILE")
|
CURRENT_DOWNLOAD_BYTES=$(jq -r --arg user "$USERNAME" '.[$user].download_bytes // 0' "$USERS_FILE")
|
||||||
CURRENT_UPLOAD_BYTES=$(jq -r --arg user "$USERNAME" '.[$user].upload_bytes' "$USERS_FILE")
|
CURRENT_UPLOAD_BYTES=$(jq -r --arg user "$USERNAME" '.[$user].upload_bytes // 0' "$USERS_FILE")
|
||||||
|
|
||||||
TOTAL_BYTES=$((CURRENT_DOWNLOAD_BYTES + CURRENT_UPLOAD_BYTES))
|
TOTAL_BYTES=$((CURRENT_DOWNLOAD_BYTES + CURRENT_UPLOAD_BYTES))
|
||||||
|
|
||||||
@ -28,21 +28,25 @@ if [ "$STORED_PASSWORD" != "$PASSWORD" ]; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
CURRENT_DATE=$(date +%s)
|
if [ "$EXPIRATION_DAYS" -ne 0 ]; then
|
||||||
EXPIRATION_DATE=$(date -d "$ACCOUNT_CREATION_DATE + $EXPIRATION_DAYS days" +%s)
|
CURRENT_DATE=$(date +%s)
|
||||||
|
EXPIRATION_DATE=$(date -d "$ACCOUNT_CREATION_DATE + $EXPIRATION_DAYS days" +%s)
|
||||||
|
|
||||||
if [ "$CURRENT_DATE" -ge "$EXPIRATION_DATE" ]; then
|
if [ "$CURRENT_DATE" -ge "$EXPIRATION_DATE" ]; then
|
||||||
jq --arg user "$USERNAME" '.[$user].blocked = true' "$USERS_FILE" > temp.json && mv temp.json "$USERS_FILE"
|
jq --arg user "$USERNAME" '.[$user].blocked = true' "$USERS_FILE" > temp.json && mv temp.json "$USERS_FILE"
|
||||||
exit 1
|
exit 1
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [ "$TOTAL_BYTES" -ge "$MAX_DOWNLOAD_BYTES" ]; then
|
if [ "$MAX_DOWNLOAD_BYTES" -ne 0 ]; then
|
||||||
|
if [ "$TOTAL_BYTES" -ge "$MAX_DOWNLOAD_BYTES" ]; then
|
||||||
SECRET=$(jq -r '.trafficStats.secret' "$CONFIG_FILE")
|
SECRET=$(jq -r '.trafficStats.secret' "$CONFIG_FILE")
|
||||||
KICK_ENDPOINT="http://127.0.0.1:25413/kick"
|
KICK_ENDPOINT="http://127.0.0.1:25413/kick"
|
||||||
curl -s -H "Authorization: $SECRET" -X POST -d "[\"$USERNAME\"]" "$KICK_ENDPOINT"
|
curl -s -H "Authorization: $SECRET" -X POST -d "[\"$USERNAME\"]" "$KICK_ENDPOINT"
|
||||||
|
|
||||||
jq --arg user "$USERNAME" '.[$user].blocked = true' "$USERS_FILE" > temp.json && mv temp.json "$USERS_FILE"
|
jq --arg user "$USERNAME" '.[$user].blocked = true' "$USERS_FILE" > temp.json && mv temp.json "$USERS_FILE"
|
||||||
exit 1
|
exit 1
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "$USERNAME"
|
echo "$USERNAME"
|
||||||
|
|||||||
@ -24,8 +24,8 @@ def parse_output(username, output):
|
|||||||
ipv4 = None
|
ipv4 = None
|
||||||
ipv6 = None
|
ipv6 = None
|
||||||
normal_sub = None
|
normal_sub = None
|
||||||
|
nodes = []
|
||||||
|
|
||||||
# Match links
|
|
||||||
ipv4_match = re.search(r"IPv4:\s*(hy2://[^\s]+)", output)
|
ipv4_match = re.search(r"IPv4:\s*(hy2://[^\s]+)", output)
|
||||||
ipv6_match = re.search(r"IPv6:\s*(hy2://[^\s]+)", output)
|
ipv6_match = re.search(r"IPv6:\s*(hy2://[^\s]+)", output)
|
||||||
normal_sub_match = re.search(r"Normal-SUB Sublink:\s*(https?://[^\s]+)", output)
|
normal_sub_match = re.search(r"Normal-SUB Sublink:\s*(https?://[^\s]+)", output)
|
||||||
@ -37,10 +37,16 @@ def parse_output(username, output):
|
|||||||
if normal_sub_match:
|
if normal_sub_match:
|
||||||
normal_sub = normal_sub_match.group(1)
|
normal_sub = normal_sub_match.group(1)
|
||||||
|
|
||||||
|
node_matches = re.findall(r"Node: (.+?) \(IPv[46]\):\s*(hy2://[^\s]+)", output)
|
||||||
|
for name, uri in node_matches:
|
||||||
|
nodes.append({"name": name.strip(), "uri": uri})
|
||||||
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"username": username,
|
"username": username,
|
||||||
"ipv4": ipv4,
|
"ipv4": ipv4,
|
||||||
"ipv6": ipv6,
|
"ipv6": ipv6,
|
||||||
|
"nodes": nodes,
|
||||||
"normal_sub": normal_sub
|
"normal_sub": normal_sub
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -6,7 +6,7 @@ import time
|
|||||||
import shlex
|
import shlex
|
||||||
import base64
|
import base64
|
||||||
from typing import Dict, List, Optional, Tuple, Any, Union
|
from typing import Dict, List, Optional, Tuple, Any, Union
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass, field
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
|
|
||||||
from aiohttp import web
|
from aiohttp import web
|
||||||
@ -29,6 +29,7 @@ class AppConfig:
|
|||||||
singbox_template_path: str
|
singbox_template_path: str
|
||||||
hysteria_cli_path: str
|
hysteria_cli_path: str
|
||||||
users_json_path: str
|
users_json_path: str
|
||||||
|
nodes_json_path: str
|
||||||
rate_limit: int
|
rate_limit: int
|
||||||
rate_limit_window: int
|
rate_limit_window: int
|
||||||
sni: str
|
sni: str
|
||||||
@ -106,6 +107,13 @@ class UserInfo:
|
|||||||
return f"Upload: {upload}, Download: {download}, Total: {total}"
|
return f"Upload: {upload}, Download: {download}, Total: {total}"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class NodeURI:
|
||||||
|
label: str
|
||||||
|
uri: str
|
||||||
|
qrcode: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class TemplateContext:
|
class TemplateContext:
|
||||||
username: str
|
username: str
|
||||||
@ -113,11 +121,9 @@ class TemplateContext:
|
|||||||
usage_raw: str
|
usage_raw: str
|
||||||
expiration_date: str
|
expiration_date: str
|
||||||
sublink_qrcode: str
|
sublink_qrcode: str
|
||||||
ipv4_qrcode: Optional[str]
|
|
||||||
ipv6_qrcode: Optional[str]
|
|
||||||
sub_link: str
|
sub_link: str
|
||||||
ipv4_uri: Optional[str]
|
local_uris: List[NodeURI] = field(default_factory=list)
|
||||||
ipv6_uri: Optional[str]
|
node_uris: List[NodeURI] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
class Utils:
|
class Utils:
|
||||||
@ -244,17 +250,21 @@ class HysteriaCLI:
|
|||||||
print(f"JSONDecodeError: {e}, Raw output: {raw_info_str}")
|
print(f"JSONDecodeError: {e}, Raw output: {raw_info_str}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def get_user_uri(self, username: str, ip_version: Optional[str] = None) -> str:
|
def get_all_uris(self, username: str) -> List[str]:
|
||||||
if ip_version:
|
"""Fetches all available URIs (local and nodes) for a user."""
|
||||||
return self._run_command(['show-user-uri', '-u', username, '-ip', ip_version])
|
|
||||||
else:
|
|
||||||
return self._run_command(['show-user-uri', '-u', username, '-a'])
|
|
||||||
|
|
||||||
def get_uris(self, username: str) -> Tuple[Optional[str], Optional[str]]:
|
|
||||||
output = self._run_command(['show-user-uri', '-u', username, '-a'])
|
output = self._run_command(['show-user-uri', '-u', username, '-a'])
|
||||||
ipv4_uri = re.search(r'IPv4:\s*(.*)', output)
|
if not output:
|
||||||
ipv6_uri = re.search(r'IPv6:\s*(.*)', output)
|
return []
|
||||||
return (ipv4_uri.group(1).strip() if ipv4_uri else None, ipv6_uri.group(1).strip() if ipv6_uri else None)
|
return re.findall(r'hy2://.*', output)
|
||||||
|
|
||||||
|
def get_all_labeled_uris(self, username: str) -> List[Dict[str, str]]:
|
||||||
|
"""Fetches all URIs and their labels."""
|
||||||
|
output = self._run_command(['show-user-uri', '-u', username, '-a'])
|
||||||
|
if not output:
|
||||||
|
return []
|
||||||
|
|
||||||
|
matches = re.findall(r"^(.*?):\s*(hy2://.*)$", output, re.MULTILINE)
|
||||||
|
return [{'label': label.strip(), 'uri': uri} for label, uri in matches]
|
||||||
|
|
||||||
|
|
||||||
class UriParser:
|
class UriParser:
|
||||||
@ -303,74 +313,74 @@ class SingboxConfigGenerator:
|
|||||||
raise RuntimeError(f"Error loading Singbox template: {e}") from e
|
raise RuntimeError(f"Error loading Singbox template: {e}") from e
|
||||||
return self._template_cache.copy()
|
return self._template_cache.copy()
|
||||||
|
|
||||||
def generate_config(self, username: str, ip_version: str, fragment: str) -> Optional[Dict[str, Any]]:
|
def generate_config_from_uri(self, uri: str, username: str, fragment: str) -> Optional[Dict[str, Any]]:
|
||||||
try:
|
"""Generates a Singbox outbound config from a single Hysteria URI."""
|
||||||
uri = self.hysteria_cli.get_user_uri(username, ip_version)
|
|
||||||
except Exception:
|
|
||||||
print(f"Failed to get URI for {username} with IP version {ip_version}. Skipping.")
|
|
||||||
return None
|
|
||||||
if not uri:
|
if not uri:
|
||||||
print(f"No URI found for {username} with IP version {ip_version}. Skipping.")
|
|
||||||
return None
|
return None
|
||||||
components = UriParser.extract_uri_components(uri, f'IPv{ip_version}:')
|
|
||||||
if components is None or components.port is None:
|
try:
|
||||||
print(f"Invalid URI components for {username} with IP version {ip_version}. Skipping.")
|
parsed_url = urlparse(uri)
|
||||||
|
server = parsed_url.hostname
|
||||||
|
server_port = parsed_url.port
|
||||||
|
auth_password = parsed_url.password
|
||||||
|
auth_user = unquote(parsed_url.username or '')
|
||||||
|
obfs_password = parse_qs(parsed_url.query).get('obfs-password', [''])[0]
|
||||||
|
|
||||||
|
if auth_password:
|
||||||
|
if auth_user:
|
||||||
|
final_password = f"{auth_user}:{auth_password}"
|
||||||
|
else:
|
||||||
|
final_password = auth_password
|
||||||
|
else:
|
||||||
|
final_password = auth_user
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error during Singbox config generation from URI: {e}, URI: {uri}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"outbounds": [{
|
|
||||||
"type": "hysteria2",
|
"type": "hysteria2",
|
||||||
"tag": f"{username}-Hysteria2",
|
"tag": unquote(parsed_url.fragment),
|
||||||
"server": components.ip,
|
"server": server,
|
||||||
"server_port": components.port,
|
"server_port": server_port,
|
||||||
"obfs": {
|
"obfs": {
|
||||||
"type": "salamander",
|
"type": "salamander",
|
||||||
"password": components.obfs_password
|
"password": obfs_password
|
||||||
},
|
},
|
||||||
"password": f"{username}:{components.password}",
|
"password": final_password,
|
||||||
"tls": {
|
"tls": {
|
||||||
"enabled": True,
|
"enabled": True,
|
||||||
"server_name": fragment if fragment else self.default_sni,
|
"server_name": fragment if fragment else self.default_sni,
|
||||||
"insecure": True
|
"insecure": True
|
||||||
}
|
}
|
||||||
}]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
def combine_configs(self, username: str, config_v4: Optional[Dict[str, Any]], config_v6: Optional[Dict[str, Any]]) -> Dict[str, Any]:
|
def combine_configs(self, all_uris: List[str], username: str, fragment: str) -> Optional[Dict[str, Any]]:
|
||||||
|
"""Generates a combined Singbox config from a list of URIs."""
|
||||||
|
if not all_uris:
|
||||||
|
return None
|
||||||
|
|
||||||
combined_config = self.get_template()
|
combined_config = self.get_template()
|
||||||
combined_config['outbounds'] = [outbound for outbound in combined_config['outbounds']
|
combined_config['outbounds'] = [out for out in combined_config['outbounds'] if out.get('type') != 'hysteria2']
|
||||||
if outbound.get('type') != 'hysteria2']
|
|
||||||
|
|
||||||
modified_v4_outbounds = []
|
hysteria_outbounds = []
|
||||||
if config_v4:
|
for uri in all_uris:
|
||||||
v4_outbound = config_v4['outbounds'][0]
|
outbound = self.generate_config_from_uri(uri, username, fragment)
|
||||||
v4_outbound['tag'] = f"{username}-IPv4"
|
if outbound:
|
||||||
modified_v4_outbounds.append(v4_outbound)
|
hysteria_outbounds.append(outbound)
|
||||||
|
|
||||||
modified_v6_outbounds = []
|
if not hysteria_outbounds:
|
||||||
if config_v6:
|
return None
|
||||||
v6_outbound = config_v6['outbounds'][0]
|
|
||||||
v6_outbound['tag'] = f"{username}-IPv6"
|
|
||||||
modified_v6_outbounds.append(v6_outbound)
|
|
||||||
|
|
||||||
select_outbounds = ["auto"]
|
all_tags = [out['tag'] for out in hysteria_outbounds]
|
||||||
if config_v4:
|
|
||||||
select_outbounds.append(f"{username}-IPv4")
|
|
||||||
if config_v6:
|
|
||||||
select_outbounds.append(f"{username}-IPv6")
|
|
||||||
|
|
||||||
auto_outbounds = []
|
|
||||||
if config_v4:
|
|
||||||
auto_outbounds.append(f"{username}-IPv4")
|
|
||||||
if config_v6:
|
|
||||||
auto_outbounds.append(f"{username}-IPv6")
|
|
||||||
|
|
||||||
for outbound in combined_config['outbounds']:
|
for outbound in combined_config['outbounds']:
|
||||||
if outbound.get('tag') == 'select':
|
if outbound.get('tag') == 'select':
|
||||||
outbound['outbounds'] = select_outbounds
|
outbound['outbounds'] = ["auto"] + all_tags
|
||||||
elif outbound.get('tag') == 'auto':
|
elif outbound.get('tag') == 'auto':
|
||||||
outbound['outbounds'] = auto_outbounds
|
outbound['outbounds'] = all_tags
|
||||||
combined_config['outbounds'].extend(modified_v4_outbounds + modified_v6_outbounds)
|
|
||||||
|
combined_config['outbounds'].extend(hysteria_outbounds)
|
||||||
return combined_config
|
return combined_config
|
||||||
|
|
||||||
|
|
||||||
@ -383,13 +393,13 @@ class SubscriptionManager:
|
|||||||
user_info = self.hysteria_cli.get_user_info(username)
|
user_info = self.hysteria_cli.get_user_info(username)
|
||||||
if user_info is None:
|
if user_info is None:
|
||||||
return "User not found"
|
return "User not found"
|
||||||
ipv4_uri, ipv6_uri = self.hysteria_cli.get_uris(username)
|
|
||||||
output_lines = [uri for uri in [ipv4_uri, ipv6_uri] if uri]
|
all_uris = self.hysteria_cli.get_all_uris(username)
|
||||||
if not output_lines:
|
if not all_uris:
|
||||||
return "No URI available"
|
return "No URI available"
|
||||||
|
|
||||||
processed_uris = []
|
processed_uris = []
|
||||||
for uri in output_lines:
|
for uri in all_uris:
|
||||||
if "v2ray" in user_agent and "ng" in user_agent:
|
if "v2ray" in user_agent and "ng" in user_agent:
|
||||||
match = re.search(r'pinSHA256=sha256/([^&]+)', uri)
|
match = re.search(r'pinSHA256=sha256/([^&]+)', uri)
|
||||||
if match:
|
if match:
|
||||||
@ -455,6 +465,7 @@ class HysteriaServer:
|
|||||||
singbox_template_path = '/etc/hysteria/core/scripts/normalsub/singbox.json'
|
singbox_template_path = '/etc/hysteria/core/scripts/normalsub/singbox.json'
|
||||||
hysteria_cli_path = '/etc/hysteria/core/cli.py'
|
hysteria_cli_path = '/etc/hysteria/core/cli.py'
|
||||||
users_json_path = os.getenv('HYSTERIA_USERS_JSON_PATH', '/etc/hysteria/users.json')
|
users_json_path = os.getenv('HYSTERIA_USERS_JSON_PATH', '/etc/hysteria/users.json')
|
||||||
|
nodes_json_path = '/etc/hysteria/nodes.json'
|
||||||
rate_limit = 100
|
rate_limit = 100
|
||||||
rate_limit_window = 60
|
rate_limit_window = 60
|
||||||
template_dir = os.path.dirname(__file__)
|
template_dir = os.path.dirname(__file__)
|
||||||
@ -467,6 +478,7 @@ class HysteriaServer:
|
|||||||
singbox_template_path=singbox_template_path,
|
singbox_template_path=singbox_template_path,
|
||||||
hysteria_cli_path=hysteria_cli_path,
|
hysteria_cli_path=hysteria_cli_path,
|
||||||
users_json_path=users_json_path,
|
users_json_path=users_json_path,
|
||||||
|
nodes_json_path=nodes_json_path,
|
||||||
rate_limit=rate_limit, rate_limit_window=rate_limit_window,
|
rate_limit=rate_limit, rate_limit_window=rate_limit_window,
|
||||||
sni=sni, template_dir=template_dir,
|
sni=sni, template_dir=template_dir,
|
||||||
subpath=subpath)
|
subpath=subpath)
|
||||||
@ -548,22 +560,21 @@ class HysteriaServer:
|
|||||||
return web.Response(text=self.template_renderer.render(context), content_type='text/html')
|
return web.Response(text=self.template_renderer.render(context), content_type='text/html')
|
||||||
|
|
||||||
async def _handle_singbox(self, username: str, fragment: str, user_info: UserInfo) -> web.Response:
|
async def _handle_singbox(self, username: str, fragment: str, user_info: UserInfo) -> web.Response:
|
||||||
config_v4 = self.singbox_generator.generate_config(username, '4', fragment)
|
all_uris = self.hysteria_cli.get_all_uris(username)
|
||||||
config_v6 = self.singbox_generator.generate_config(username, '6', fragment)
|
if not all_uris:
|
||||||
if config_v4 is None and config_v6 is None:
|
|
||||||
return web.Response(status=404, text=f"Error: No valid URIs found for user {username}.")
|
return web.Response(status=404, text=f"Error: No valid URIs found for user {username}.")
|
||||||
combined_config = self.singbox_generator.combine_configs(username, config_v4, config_v6)
|
combined_config = self.singbox_generator.combine_configs(all_uris, username, fragment)
|
||||||
return web.Response(text=json.dumps(combined_config, indent=4, sort_keys=True), content_type='application/json')
|
return web.Response(text=json.dumps(combined_config, indent=4, sort_keys=True), content_type='application/json')
|
||||||
|
|
||||||
async def _handle_normalsub(self, request: web.Request, username: str, user_info: UserInfo) -> web.Response:
|
async def _handle_normalsub(self, request: web.Request, username: str, user_info: UserInfo) -> web.Response:
|
||||||
user_agent = request.headers.get('User-Agent', '').lower()
|
user_agent = request.headers.get('User-Agent', '').lower()
|
||||||
subscription = self.subscription_manager.get_normal_subscription(username, user_agent)
|
subscription = self.subscription_manager.get_normal_subscription(username, user_agent)
|
||||||
if subscription == "User not found": # Should be caught earlier by user_info check
|
if subscription == "User not found":
|
||||||
return web.Response(status=404, text=f"User '{username}' not found.")
|
return web.Response(status=404, text=f"User '{username}' not found.")
|
||||||
return web.Response(text=subscription, content_type='text/plain')
|
return web.Response(text=subscription, content_type='text/plain')
|
||||||
|
|
||||||
async def _get_template_context(self, username: str, user_info: UserInfo) -> TemplateContext:
|
async def _get_template_context(self, username: str, user_info: UserInfo) -> TemplateContext:
|
||||||
ipv4_uri, ipv6_uri = self.hysteria_cli.get_uris(username)
|
labeled_uris = self.hysteria_cli.get_all_labeled_uris(username)
|
||||||
port_str = f":{self.config.external_port}" if self.config.external_port not in [80, 443, 0] else ""
|
port_str = f":{self.config.external_port}" if self.config.external_port not in [80, 443, 0] else ""
|
||||||
base_url = f"https://{self.config.domain}{port_str}"
|
base_url = f"https://{self.config.domain}{port_str}"
|
||||||
|
|
||||||
@ -571,22 +582,31 @@ class HysteriaServer:
|
|||||||
print(f"Warning: Constructed base URL '{base_url}' might be invalid. Check domain and port config.")
|
print(f"Warning: Constructed base URL '{base_url}' might be invalid. Check domain and port config.")
|
||||||
|
|
||||||
sub_link = f"{base_url}/{self.config.subpath}/sub/normal/{user_info.password}"
|
sub_link = f"{base_url}/{self.config.subpath}/sub/normal/{user_info.password}"
|
||||||
|
|
||||||
ipv4_qrcode = Utils.generate_qrcode_base64(ipv4_uri)
|
|
||||||
ipv6_qrcode = Utils.generate_qrcode_base64(ipv6_uri)
|
|
||||||
sublink_qrcode = Utils.generate_qrcode_base64(sub_link)
|
sublink_qrcode = Utils.generate_qrcode_base64(sub_link)
|
||||||
|
|
||||||
|
local_uris = []
|
||||||
|
node_uris = []
|
||||||
|
|
||||||
|
for item in labeled_uris:
|
||||||
|
node_uri = NodeURI(
|
||||||
|
label=item['label'],
|
||||||
|
uri=item['uri'],
|
||||||
|
qrcode=Utils.generate_qrcode_base64(item['uri'])
|
||||||
|
)
|
||||||
|
if item['label'].startswith('Node:'):
|
||||||
|
node_uris.append(node_uri)
|
||||||
|
else:
|
||||||
|
local_uris.append(node_uri)
|
||||||
|
|
||||||
return TemplateContext(
|
return TemplateContext(
|
||||||
username=username,
|
username=username,
|
||||||
usage=user_info.usage_human_readable,
|
usage=user_info.usage_human_readable,
|
||||||
usage_raw=user_info.usage_detailed,
|
usage_raw=user_info.usage_detailed,
|
||||||
expiration_date=user_info.expiration_date,
|
expiration_date=user_info.expiration_date,
|
||||||
sublink_qrcode=sublink_qrcode,
|
sublink_qrcode=sublink_qrcode,
|
||||||
ipv4_qrcode=ipv4_qrcode,
|
|
||||||
ipv6_qrcode=ipv6_qrcode,
|
|
||||||
sub_link=sub_link,
|
sub_link=sub_link,
|
||||||
ipv4_uri=ipv4_uri,
|
local_uris=local_uris,
|
||||||
ipv6_uri=ipv6_uri
|
node_uris=node_uris
|
||||||
)
|
)
|
||||||
|
|
||||||
async def robots_handler(self, request: web.Request) -> web.Response:
|
async def robots_handler(self, request: web.Request) -> web.Response:
|
||||||
@ -605,7 +625,6 @@ class HysteriaServer:
|
|||||||
port=self.config.aiohttp_listen_port
|
port=self.config.aiohttp_listen_port
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
server = HysteriaServer()
|
server = HysteriaServer()
|
||||||
server.run()
|
server.run()
|
||||||
@ -1,154 +1,158 @@
|
|||||||
{
|
{
|
||||||
"log": {
|
|
||||||
"level": "info",
|
|
||||||
"timestamp": true
|
|
||||||
},
|
|
||||||
"dns": {
|
"dns": {
|
||||||
"servers": [
|
"final": "local-dns",
|
||||||
{
|
|
||||||
"tag": "proxyDns",
|
|
||||||
"address": "tls://8.8.8.8",
|
|
||||||
"detour": "Proxy"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"tag": "localDns",
|
|
||||||
"address": "8.8.8.8",
|
|
||||||
"detour": "direct"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"rules": [
|
"rules": [
|
||||||
{
|
{
|
||||||
"outbound": "any",
|
"action": "route",
|
||||||
"server": "localDns"
|
"clash_mode": "Global",
|
||||||
|
"server": "proxy-dns",
|
||||||
|
"source_ip_cidr": [
|
||||||
|
"172.19.0.0/30",
|
||||||
|
"fdfe:dcba:9876::1/126"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"rule_set": "geosite-ir",
|
"action": "route",
|
||||||
"server": "proxyDns"
|
"server": "proxy-dns",
|
||||||
},
|
"source_ip_cidr": [
|
||||||
{
|
"172.19.0.0/30",
|
||||||
"clash_mode": "direct",
|
"fdfe:dcba:9876::1/126"
|
||||||
"server": "localDns"
|
]
|
||||||
},
|
|
||||||
{
|
|
||||||
"clash_mode": "global",
|
|
||||||
"server": "proxyDns"
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"final": "localDns",
|
"servers": [
|
||||||
"strategy": "ipv4_only"
|
{
|
||||||
|
"type": "https",
|
||||||
|
"server": "1.1.1.1",
|
||||||
|
"detour": "proxy",
|
||||||
|
"tag": "proxy-dns"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "local",
|
||||||
|
"detour": "direct",
|
||||||
|
"tag": "local-dns"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"strategy": "prefer_ipv4"
|
||||||
},
|
},
|
||||||
"inbounds": [
|
"inbounds": [
|
||||||
{
|
{
|
||||||
"type": "tun",
|
"address": [
|
||||||
"tag": "tun-in",
|
"172.19.0.1/30",
|
||||||
"address": "172.19.0.1/30",
|
"fdfe:dcba:9876::1/126"
|
||||||
|
],
|
||||||
"auto_route": true,
|
"auto_route": true,
|
||||||
"strict_route": true
|
"endpoint_independent_nat": false,
|
||||||
|
"mtu": 9000,
|
||||||
|
"platform": {
|
||||||
|
"http_proxy": {
|
||||||
|
"enabled": true,
|
||||||
|
"server": "127.0.0.1",
|
||||||
|
"server_port": 2080
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"stack": "system",
|
||||||
|
"strict_route": false,
|
||||||
|
"type": "tun"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"listen": "127.0.0.1",
|
||||||
|
"listen_port": 2080,
|
||||||
|
"type": "mixed",
|
||||||
|
"users": []
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"log": {
|
||||||
|
"level": "warn",
|
||||||
|
"timestamp": true
|
||||||
|
},
|
||||||
"outbounds": [
|
"outbounds": [
|
||||||
{
|
{
|
||||||
"tag": "Proxy",
|
|
||||||
"type": "selector",
|
|
||||||
"outbounds": [
|
"outbounds": [
|
||||||
"auto",
|
"auto",
|
||||||
"direct"
|
"direct"
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"tag": "Global",
|
|
||||||
"type": "selector",
|
|
||||||
"outbounds": [
|
|
||||||
"direct"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"tag": "auto",
|
|
||||||
"type": "urltest",
|
|
||||||
"outbounds": [
|
|
||||||
"Proxy"
|
|
||||||
],
|
],
|
||||||
"url": "http://www.gstatic.com/generate_204",
|
"tag": "proxy",
|
||||||
|
"type": "selector"
|
||||||
|
},
|
||||||
|
{
|
||||||
"interval": "10m",
|
"interval": "10m",
|
||||||
"tolerance": 50
|
"outbounds": [],
|
||||||
|
"tag": "auto",
|
||||||
|
"tolerance": 50,
|
||||||
|
"type": "urltest",
|
||||||
|
"url": "http://www.gstatic.com/generate_204"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "direct",
|
"tag": "direct",
|
||||||
"tag": "direct"
|
"type": "direct"
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "direct",
|
|
||||||
"tag": "local"
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"route": {
|
"route": {
|
||||||
"auto_detect_interface": true,
|
"auto_detect_interface": true,
|
||||||
"final": "Proxy",
|
"final": "proxy",
|
||||||
|
"rule_set": [
|
||||||
|
{
|
||||||
|
"download_detour": "direct",
|
||||||
|
"format": "binary",
|
||||||
|
"tag": "geosite-ads",
|
||||||
|
"type": "remote",
|
||||||
|
"url": "https://raw.githubusercontent.com/Chocolate4U/Iran-sing-box-rules/rule-set/geosite-category-ads-all.srs"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"download_detour": "direct",
|
||||||
|
"format": "binary",
|
||||||
|
"tag": "geoip-private",
|
||||||
|
"type": "remote",
|
||||||
|
"url": "https://raw.githubusercontent.com/Chocolate4U/Iran-sing-box-rules/rule-set/geoip-private.srs"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"download_detour": "direct",
|
||||||
|
"format": "binary",
|
||||||
|
"tag": "geosite-ir",
|
||||||
|
"type": "remote",
|
||||||
|
"url": "https://raw.githubusercontent.com/Chocolate4U/Iran-sing-box-rules/rule-set/geosite-ir.srs"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"download_detour": "direct",
|
||||||
|
"format": "binary",
|
||||||
|
"tag": "geoip-ir",
|
||||||
|
"type": "remote",
|
||||||
|
"url": "https://raw.githubusercontent.com/Chocolate4U/Iran-sing-box-rules/rule-set/geoip-ir.srs"
|
||||||
|
}
|
||||||
|
],
|
||||||
"rules": [
|
"rules": [
|
||||||
{
|
{
|
||||||
"inbound": [
|
|
||||||
"tun-in",
|
|
||||||
"mixed-in"
|
|
||||||
],
|
|
||||||
"action": "sniff"
|
"action": "sniff"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "logical",
|
"action": "route",
|
||||||
"mode": "or",
|
"clash_mode": "Direct",
|
||||||
"rules": [
|
"outbound": "direct"
|
||||||
{
|
|
||||||
"port": 53
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"action": "route",
|
||||||
|
"clash_mode": "Global",
|
||||||
|
"outbound": "proxy"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"action": "hijack-dns",
|
||||||
"protocol": "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",
|
"action": "route",
|
||||||
"rule_set": "geosite-ir",
|
"outbound": "direct",
|
||||||
"outbound": "direct"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"action": "route",
|
|
||||||
"rule_set": "geoip-ir",
|
|
||||||
"outbound": "direct"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"rule_set": [
|
"rule_set": [
|
||||||
{
|
"geosite-ir",
|
||||||
"tag": "geosite-category-ads-all",
|
"geoip-ir",
|
||||||
"type": "remote",
|
"geoip-private"
|
||||||
"format": "binary",
|
]
|
||||||
"url": "https://raw.githubusercontent.com/Chocolate4U/Iran-sing-box-rules/rule-set/geosite-category-ads-all.srs",
|
|
||||||
"download_detour": "direct"
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "remote",
|
"action": "reject",
|
||||||
"tag": "geoip-ir",
|
"rule_set": [
|
||||||
"format": "binary",
|
"geosite-ads"
|
||||||
"url": "https://raw.githubusercontent.com/Chocolate4U/Iran-sing-box-rules/rule-set/geoip-ir.srs",
|
]
|
||||||
"update_interval": "120h0m0s"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "remote",
|
|
||||||
"tag": "geosite-ir",
|
|
||||||
"format": "binary",
|
|
||||||
"url": "https://raw.githubusercontent.com/Chocolate4U/Iran-sing-box-rules/rule-set/geosite-ir.srs",
|
|
||||||
"update_interval": "120h0m0s"
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@ -41,7 +41,6 @@
|
|||||||
color: var(--text-dark);
|
color: var(--text-dark);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Animated background elements */
|
|
||||||
.background-animation {
|
.background-animation {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 0;
|
top: 0;
|
||||||
@ -78,17 +77,14 @@
|
|||||||
0% {
|
0% {
|
||||||
transform: translate(0, 0) rotate(0deg);
|
transform: translate(0, 0) rotate(0deg);
|
||||||
}
|
}
|
||||||
|
|
||||||
50% {
|
50% {
|
||||||
transform: translate(100px, 100px) rotate(180deg);
|
transform: translate(100px, 100px) rotate(180deg);
|
||||||
}
|
}
|
||||||
|
|
||||||
100% {
|
100% {
|
||||||
transform: translate(0, 0) rotate(360deg);
|
transform: translate(0, 0) rotate(360deg);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Rest of the styles remain the same */
|
|
||||||
.container {
|
.container {
|
||||||
max-width: 1000px;
|
max-width: 1000px;
|
||||||
margin: 2rem auto;
|
margin: 2rem auto;
|
||||||
@ -175,10 +171,10 @@
|
|||||||
background: var(--card-bg-light);
|
background: var(--card-bg-light);
|
||||||
border-radius: 1rem;
|
border-radius: 1rem;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
/* Add this to contain the header */
|
|
||||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
||||||
backdrop-filter: blur(10px);
|
backdrop-filter: blur(10px);
|
||||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.qr-header {
|
.qr-header {
|
||||||
@ -196,7 +192,6 @@
|
|||||||
padding: 2rem;
|
padding: 2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
.dark-mode .qr-section {
|
.dark-mode .qr-section {
|
||||||
background: rgba(31, 41, 55, 0.8);
|
background: rgba(31, 41, 55, 0.8);
|
||||||
border-color: rgba(255, 255, 255, 0.05);
|
border-color: rgba(255, 255, 255, 0.05);
|
||||||
@ -230,6 +225,14 @@
|
|||||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.uri-unavailable {
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark-mode .uri-unavailable {
|
||||||
|
color: #4b5563;
|
||||||
|
}
|
||||||
|
|
||||||
.btn-group {
|
.btn-group {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
@ -278,6 +281,7 @@
|
|||||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
||||||
display: none;
|
display: none;
|
||||||
backdrop-filter: blur(10px);
|
backdrop-filter: blur(10px);
|
||||||
|
z-index: 100;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark-mode .loading-indicator {
|
.dark-mode .loading-indicator {
|
||||||
@ -358,60 +362,78 @@
|
|||||||
|
|
||||||
<div class="qr-section">
|
<div class="qr-section">
|
||||||
<div class="qr-header">
|
<div class="qr-header">
|
||||||
<i class="fas fa-qrcode"></i>
|
<i class="fas fa-link"></i>
|
||||||
QR Codes
|
Subscription Link
|
||||||
</div>
|
</div>
|
||||||
<div class="qr-content">
|
<div class="qr-content">
|
||||||
<div class="qr-grid">
|
<div class="qr-grid">
|
||||||
<div class="qr-item">
|
<div class="qr-item" style="grid-column: 1 / -1;">
|
||||||
<h3 class="qr-title">Subscription Link</h3>
|
<h3 class="qr-title">Universal Subscription Link</h3>
|
||||||
<img src="{{ sublink_qrcode }}" alt="Subscription QR Code" class="qrcode">
|
<img src="{{ sublink_qrcode }}" alt="Subscription QR Code" class="qrcode">
|
||||||
<div class="btn-group">
|
<div class="btn-group">
|
||||||
<button class="btn btn-primary" onclick="copyToClipboard('{{ sub_link }}')">
|
<button class="btn btn-primary" onclick="copyToClipboard('{{ sub_link }}')">
|
||||||
<i class="fas fa-copy"></i> Copy
|
<i class="fas fa-copy"></i> Copy
|
||||||
</button>
|
</button>
|
||||||
<!-- <a href="{{ sub_link }}" class="btn btn-secondary" target="_blank" rel="noopener noreferrer">
|
</div>
|
||||||
<i class="fas fa-external-link-alt"></i> Open
|
</div>
|
||||||
</a> -->
|
</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>
|
||||||
</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">
|
<div class="qr-item">
|
||||||
<h3 class="qr-title">IPv4 URI</h3>
|
<h3 class="qr-title">{{ item.label }}</h3>
|
||||||
{% if ipv4_qrcode %}
|
{% if item.qrcode %}
|
||||||
<img src="{{ ipv4_qrcode }}" alt="IPv4 QR Code" class="qrcode">
|
<img src="{{ item.qrcode }}" alt="{{ item.label }} QR Code" class="qrcode">
|
||||||
<div class="btn-group">
|
<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
|
<i class="fas fa-copy"></i> Copy
|
||||||
</button>
|
</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>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<p class="uri-unavailable">IPv4 URI not available</p>
|
<p class="uri-unavailable">{{ item.label }} URI not available</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
|||||||
@ -7,6 +7,7 @@ USERS_FILE = BASE_DIR / "users.json"
|
|||||||
TRAFFIC_FILE = BASE_DIR / "traffic_data.json"
|
TRAFFIC_FILE = BASE_DIR / "traffic_data.json"
|
||||||
CONFIG_FILE = BASE_DIR / "config.json"
|
CONFIG_FILE = BASE_DIR / "config.json"
|
||||||
CONFIG_ENV = BASE_DIR / ".configs.env"
|
CONFIG_ENV = BASE_DIR / ".configs.env"
|
||||||
|
NODES_JSON_PATH = BASE_DIR / "nodes.json"
|
||||||
TELEGRAM_ENV = BASE_DIR / "core/scripts/telegrambot/.env"
|
TELEGRAM_ENV = BASE_DIR / "core/scripts/telegrambot/.env"
|
||||||
SINGBOX_ENV = BASE_DIR / "core/scripts/singbox/.env"
|
SINGBOX_ENV = BASE_DIR / "core/scripts/singbox/.env"
|
||||||
NORMALSUB_ENV = BASE_DIR / "core/scripts/normalsub/.env"
|
NORMALSUB_ENV = BASE_DIR / "core/scripts/normalsub/.env"
|
||||||
|
|||||||
@ -1,44 +1,43 @@
|
|||||||
from fastapi import APIRouter, HTTPException
|
from fastapi import APIRouter, HTTPException
|
||||||
from ..schema.response import DetailResponse
|
from ..schema.response import DetailResponse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
|
||||||
|
from ..schema.config.ip import (
|
||||||
from ..schema.config.ip import EditInputBody, StatusResponse
|
EditInputBody,
|
||||||
|
StatusResponse,
|
||||||
|
AddNodeBody,
|
||||||
|
DeleteNodeBody,
|
||||||
|
NodeListResponse
|
||||||
|
)
|
||||||
import cli_api
|
import cli_api
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
@router.get('/get', response_model=StatusResponse, summary='Get IP Status')
|
@router.get('/get', response_model=StatusResponse, summary='Get Local Server IP Status')
|
||||||
async def get_ip_api():
|
async def get_ip_api():
|
||||||
"""
|
"""
|
||||||
Retrieves the current status of IP addresses.
|
Retrieves the current status of the main server's IP addresses.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
StatusResponse: A response model containing the current IP address details.
|
StatusResponse: A response model containing the current IP address details.
|
||||||
|
|
||||||
Raises:
|
|
||||||
HTTPException: If the IP status is not available (404) or if there is an error processing the request (400).
|
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
|
|
||||||
ipv4, ipv6 = cli_api.get_ip_address()
|
ipv4, ipv6 = cli_api.get_ip_address()
|
||||||
if ipv4 or ipv6:
|
return StatusResponse(ipv4=ipv4, ipv6=ipv6)
|
||||||
return StatusResponse(ipv4=ipv4, ipv6=ipv6) # type: ignore
|
|
||||||
raise HTTPException(status_code=404, detail='IP status not available.')
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(status_code=400, detail=f'Error: {str(e)}')
|
raise HTTPException(status_code=400, detail=f'Error: {str(e)}')
|
||||||
|
|
||||||
|
|
||||||
@router.get('/add', response_model=DetailResponse, summary='Add IP')
|
@router.get('/add', response_model=DetailResponse, summary='Detect and Add Local Server IP')
|
||||||
async def add_ip_api():
|
async def add_ip_api():
|
||||||
"""
|
"""
|
||||||
Adds the auto-detected IP addresses to the .configs.env file.
|
Adds the auto-detected IP addresses to the .configs.env file.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
A DetailResponse with a message indicating the IP addresses were added successfully.
|
A DetailResponse with a message indicating the IP addresses were added successfully.
|
||||||
|
|
||||||
Raises:
|
|
||||||
HTTPException: if an error occurs while adding the IP addresses.
|
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
cli_api.add_ip_address()
|
cli_api.add_ip_address()
|
||||||
@ -47,19 +46,13 @@ async def add_ip_api():
|
|||||||
raise HTTPException(status_code=400, detail=f'Error: {str(e)}')
|
raise HTTPException(status_code=400, detail=f'Error: {str(e)}')
|
||||||
|
|
||||||
|
|
||||||
@router.post('/edit', response_model=DetailResponse, summary='Edit IP')
|
@router.post('/edit', response_model=DetailResponse, summary='Edit Local Server IP')
|
||||||
async def edit_ip_api(body: EditInputBody):
|
async def edit_ip_api(body: EditInputBody):
|
||||||
"""
|
"""
|
||||||
Edits the IP addresses in the .configs.env file.
|
Edits the main server's IP addresses in the .configs.env file.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
body: An instance of EditInputBody containing the new IPv4 and/or IPv6 addresses.
|
body: An instance of EditInputBody containing the new IPv4 and/or IPv6 addresses.
|
||||||
|
|
||||||
Returns:
|
|
||||||
A DetailResponse with a message indicating the IP addresses were edited successfully.
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
HTTPException: if an error occurs while editing the IP addresses.
|
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
if not body.ipv4 and not body.ipv6:
|
if not body.ipv4 and not body.ipv6:
|
||||||
@ -69,3 +62,54 @@ async def edit_ip_api(body: EditInputBody):
|
|||||||
return DetailResponse(detail='IP address edited successfully.')
|
return DetailResponse(detail='IP address edited successfully.')
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(status_code=400, detail=f'Error: {str(e)}')
|
raise HTTPException(status_code=400, detail=f'Error: {str(e)}')
|
||||||
|
|
||||||
|
|
||||||
|
@router.get('/nodes', response_model=NodeListResponse, summary='Get All External Nodes')
|
||||||
|
async def get_all_nodes():
|
||||||
|
"""
|
||||||
|
Retrieves the list of all configured external nodes.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A list of node objects, each containing a name and an IP.
|
||||||
|
"""
|
||||||
|
if not os.path.exists(cli_api.NODES_JSON_PATH):
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
with open(cli_api.NODES_JSON_PATH, 'r') as f:
|
||||||
|
content = f.read()
|
||||||
|
if not content:
|
||||||
|
return []
|
||||||
|
return json.loads(content)
|
||||||
|
except (json.JSONDecodeError, IOError) as e:
|
||||||
|
raise HTTPException(status_code=500, detail=f"Failed to read or parse nodes file: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post('/nodes/add', response_model=DetailResponse, summary='Add External Node')
|
||||||
|
async def add_node(body: AddNodeBody):
|
||||||
|
"""
|
||||||
|
Adds a new external node to the configuration.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
body: Request body containing the name and IP of the node.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
cli_api.add_node(body.name, body.ip)
|
||||||
|
return DetailResponse(detail=f"Node '{body.name}' added successfully.")
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post('/nodes/delete', response_model=DetailResponse, summary='Delete External Node')
|
||||||
|
async def delete_node(body: DeleteNodeBody):
|
||||||
|
"""
|
||||||
|
|
||||||
|
Deletes an external node from the configuration by its name.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
body: Request body containing the name of the node to delete.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
cli_api.delete_node(body.name)
|
||||||
|
return DetailResponse(detail=f"Node '{body.name}' deleted successfully.")
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
@ -1,24 +1,50 @@
|
|||||||
from pydantic import BaseModel, field_validator, ValidationInfo
|
from pydantic import BaseModel, field_validator, ValidationInfo
|
||||||
from ipaddress import IPv4Address, IPv6Address, ip_address
|
from ipaddress import IPv4Address, IPv6Address, ip_address
|
||||||
import socket
|
import re
|
||||||
|
|
||||||
|
def validate_ip_or_domain(v: str) -> str | None:
|
||||||
|
if v is None or v.strip() in ['', 'None']:
|
||||||
|
return None
|
||||||
|
|
||||||
|
v_stripped = v.strip()
|
||||||
|
|
||||||
|
try:
|
||||||
|
ip_address(v_stripped)
|
||||||
|
return v_stripped
|
||||||
|
except ValueError:
|
||||||
|
domain_regex = re.compile(
|
||||||
|
r'^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z0-9][a-z0-9-]{0,61}[a-z0-9]$',
|
||||||
|
re.IGNORECASE
|
||||||
|
)
|
||||||
|
if domain_regex.match(v_stripped):
|
||||||
|
return v_stripped
|
||||||
|
raise ValueError(f"'{v_stripped}' is not a valid IP address or domain name.")
|
||||||
|
|
||||||
class StatusResponse(BaseModel):
|
class StatusResponse(BaseModel):
|
||||||
ipv4: str | None = None
|
ipv4: str | None = None
|
||||||
ipv6: str | None = None
|
ipv6: str | None = None
|
||||||
|
|
||||||
@field_validator('ipv4', 'ipv6', mode='before')
|
@field_validator('ipv4', 'ipv6', mode='before')
|
||||||
def check_ip_or_domain(cls, v: str, info: ValidationInfo):
|
def check_local_server_ip(cls, v: str | None):
|
||||||
if v is None:
|
return validate_ip_or_domain(v)
|
||||||
return v
|
|
||||||
try:
|
|
||||||
ip_address(v)
|
|
||||||
return v
|
|
||||||
except ValueError:
|
|
||||||
try:
|
|
||||||
socket.getaddrinfo(v, None)
|
|
||||||
return v
|
|
||||||
except socket.gaierror:
|
|
||||||
raise ValueError(f"'{v}' is not a valid IPv4 or IPv6 address or domain name")
|
|
||||||
|
|
||||||
class EditInputBody(StatusResponse):
|
class EditInputBody(StatusResponse):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
class Node(BaseModel):
|
||||||
|
name: str
|
||||||
|
ip: str
|
||||||
|
|
||||||
|
@field_validator('ip', mode='before')
|
||||||
|
def check_node_ip(cls, v: str | None):
|
||||||
|
if not v or not v.strip():
|
||||||
|
raise ValueError("IP or Domain field cannot be empty.")
|
||||||
|
return validate_ip_or_domain(v)
|
||||||
|
|
||||||
|
class AddNodeBody(Node):
|
||||||
|
pass
|
||||||
|
|
||||||
|
class DeleteNodeBody(BaseModel):
|
||||||
|
name: str
|
||||||
|
|
||||||
|
NodeListResponse = list[Node]
|
||||||
@ -1,4 +1,4 @@
|
|||||||
from typing import Optional
|
from typing import Optional, List
|
||||||
from pydantic import BaseModel, RootModel
|
from pydantic import BaseModel, RootModel
|
||||||
|
|
||||||
|
|
||||||
@ -38,8 +38,14 @@ class EditUserInputBody(BaseModel):
|
|||||||
renew_creation_date: bool = False
|
renew_creation_date: bool = False
|
||||||
blocked: bool = False
|
blocked: bool = False
|
||||||
|
|
||||||
|
class NodeUri(BaseModel):
|
||||||
|
name: str
|
||||||
|
uri: str
|
||||||
|
|
||||||
class UserUriResponse(BaseModel):
|
class UserUriResponse(BaseModel):
|
||||||
username: str
|
username: str
|
||||||
ipv4: str | None = None
|
ipv4: Optional[str] = None
|
||||||
ipv6: str | None = None
|
ipv6: Optional[str] = None
|
||||||
normal_sub: str | None = None
|
nodes: Optional[List[NodeUri]] = []
|
||||||
|
normal_sub: Optional[str] = None
|
||||||
|
error: Optional[str] = None
|
||||||
@ -178,10 +178,12 @@ async def show_user_uri_api(username: str):
|
|||||||
uri_data_list = cli_api.show_user_uri_json([username])
|
uri_data_list = cli_api.show_user_uri_json([username])
|
||||||
if not uri_data_list:
|
if not uri_data_list:
|
||||||
raise HTTPException(status_code=404, detail=f'URI for user {username} not found.')
|
raise HTTPException(status_code=404, detail=f'URI for user {username} not found.')
|
||||||
|
|
||||||
uri_data = uri_data_list[0]
|
uri_data = uri_data_list[0]
|
||||||
if uri_data.get('error'):
|
if uri_data.get('error'):
|
||||||
raise HTTPException(status_code=404, detail=f"{uri_data['error']}")
|
raise HTTPException(status_code=404, detail=f"{uri_data['error']}")
|
||||||
return uri_data
|
|
||||||
|
return UserUriResponse(**uri_data)
|
||||||
except cli_api.ScriptNotFoundError as e:
|
except cli_api.ScriptNotFoundError as e:
|
||||||
raise HTTPException(status_code=500, detail=f'Server script error: {str(e)}')
|
raise HTTPException(status_code=500, detail=f'Server script error: {str(e)}')
|
||||||
except cli_api.CommandExecutionError as e:
|
except cli_api.CommandExecutionError as e:
|
||||||
|
|||||||
@ -1,16 +1,14 @@
|
|||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
|
from typing import Optional
|
||||||
import cli_api
|
|
||||||
|
|
||||||
|
|
||||||
class User(BaseModel):
|
class User(BaseModel):
|
||||||
username: str
|
username: str
|
||||||
status: str
|
status: str
|
||||||
quota: str
|
quota: str
|
||||||
traffic_used: str
|
traffic_used: str
|
||||||
expiry_date: datetime
|
expiry_date: str
|
||||||
expiry_days: int
|
expiry_days: str
|
||||||
enable: bool
|
enable: bool
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@ -21,44 +19,51 @@ class User(BaseModel):
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def __parse_user_data(user_data: dict) -> dict:
|
def __parse_user_data(user_data: dict) -> dict:
|
||||||
expiry_date = 'N/A'
|
|
||||||
creation_date_str = user_data.get("account_creation_date")
|
|
||||||
expiration_days = user_data.get('expiration_days', 0)
|
expiration_days = user_data.get('expiration_days', 0)
|
||||||
if creation_date_str and expiration_days:
|
|
||||||
|
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:
|
try:
|
||||||
creation_date = datetime.strptime(creation_date_str, "%Y-%m-%d")
|
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:
|
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)
|
used_bytes = user_data.get("download_bytes", 0) + user_data.get("upload_bytes", 0)
|
||||||
quota_bytes = user_data.get('max_download_bytes', 0)
|
quota_bytes = user_data.get('max_download_bytes', 0)
|
||||||
|
|
||||||
# Format individual values for combining
|
|
||||||
used_formatted = User.__format_traffic(used_bytes)
|
used_formatted = User.__format_traffic(used_bytes)
|
||||||
quota_formatted = User.__format_traffic(quota_bytes)
|
quota_formatted = "Unlimited" if quota_bytes == 0 else User.__format_traffic(quota_bytes)
|
||||||
|
|
||||||
# Calculate percentage if quota is not zero
|
|
||||||
percentage = 0
|
percentage = 0
|
||||||
if quota_bytes > 0:
|
if quota_bytes > 0:
|
||||||
percentage = (used_bytes / quota_bytes) * 100
|
percentage = (used_bytes / quota_bytes) * 100
|
||||||
|
|
||||||
# Combine the values with percentage
|
traffic_used_display = f"{used_formatted}/{quota_formatted} ({percentage:.1f}%)"
|
||||||
traffic_used = f"{used_formatted}/{quota_formatted} ({percentage:.1f}%)"
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'username': user_data['username'],
|
'username': user_data['username'],
|
||||||
'status': user_data.get('status', 'Not Active'),
|
'status': user_data.get('status', 'Not Active'),
|
||||||
'quota': User.__format_traffic(quota_bytes),
|
'quota': quota_formatted,
|
||||||
'traffic_used': traffic_used,
|
'traffic_used': traffic_used_display,
|
||||||
'expiry_date': expiry_date,
|
'expiry_date': display_expiry_date,
|
||||||
'expiry_days': expiration_days,
|
'expiry_days': display_expiry_days,
|
||||||
'enable': False if user_data.get('blocked', False) else True,
|
'enable': not user_data.get('blocked', False),
|
||||||
}
|
}
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def __format_traffic(traffic_bytes) -> str:
|
def __format_traffic(traffic_bytes) -> str:
|
||||||
|
if traffic_bytes == 0:
|
||||||
|
return "0 B"
|
||||||
if traffic_bytes < 1024:
|
if traffic_bytes < 1024:
|
||||||
return f'{traffic_bytes} B'
|
return f'{traffic_bytes} B'
|
||||||
elif traffic_bytes < 1024**2:
|
elif traffic_bytes < 1024**2:
|
||||||
|
|||||||
@ -49,7 +49,7 @@
|
|||||||
<li class='nav-item'>
|
<li class='nav-item'>
|
||||||
<a class='nav-link' id='ip-tab' data-toggle='pill' href='#change_ip' role='tab'
|
<a class='nav-link' id='ip-tab' data-toggle='pill' href='#change_ip' role='tab'
|
||||||
aria-controls='change_ip' aria-selected='false'><i class="fas fa-network-wired"></i>
|
aria-controls='change_ip' aria-selected='false'><i class="fas fa-network-wired"></i>
|
||||||
Change IP</a>
|
IP Management</a>
|
||||||
</li>
|
</li>
|
||||||
<li class='nav-item'>
|
<li class='nav-item'>
|
||||||
<a class='nav-link' id='backup-tab' data-toggle='pill' href='#backup' role='tab'
|
<a class='nav-link' id='backup-tab' data-toggle='pill' href='#backup' role='tab'
|
||||||
@ -208,28 +208,71 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Change IP Tab -->
|
<!-- IP Management Tab -->
|
||||||
<div class='tab-pane fade' id='change_ip' role='tabpanel' aria-labelledby='ip-tab'>
|
<div class='tab-pane fade' id='change_ip' role='tabpanel' aria-labelledby='ip-tab'>
|
||||||
|
<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">
|
<form id="change_ip_form">
|
||||||
<div class='form-group'>
|
<div class='form-group'>
|
||||||
<label for='ipv4'>IPv4:</label>
|
<label for='ipv4'>IPv4 / Domain:</label>
|
||||||
<input type='text' class='form-control' id='ipv4' placeholder='Enter IPv4 or Domain'
|
<input type='text' class='form-control' id='ipv4' placeholder='Enter IPv4 or Domain' value="{{ ipv4 or '' }}">
|
||||||
value="{{ ipv4 or '' }}">
|
<div class="invalid-feedback">Please enter a valid IPv4 address or Domain.</div>
|
||||||
<div class="invalid-feedback">
|
|
||||||
Please enter a valid IPv4 address or Domain.
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class='form-group'>
|
<div class='form-group'>
|
||||||
<label for='ipv6'>IPv6:</label>
|
<label for='ipv6'>IPv6 / Domain:</label>
|
||||||
<input type='text' class='form-control' id='ipv6' placeholder='Enter IPv6 or Domain'
|
<input type='text' class='form-control' id='ipv6' placeholder='Enter IPv6 or Domain' value="{{ ipv6 or '' }}">
|
||||||
value="{{ ipv6 or '' }}">
|
<div class="invalid-feedback">Please enter a valid IPv6 address or Domain.</div>
|
||||||
<div class="invalid-feedback">
|
|
||||||
Please enter a valid IPv6 address or Domain.
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<button id="ip_change" type='button' class='btn btn-primary'>Save</button>
|
<button id="ip_change" type='button' class='btn btn-primary'>Save</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</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 -->
|
<!-- Backup Tab -->
|
||||||
<div class='tab-pane fade' id='backup' role='tabpanel' aria-labelledby='backup-tab'>
|
<div class='tab-pane fade' id='backup' role='tabpanel' aria-labelledby='backup-tab'>
|
||||||
@ -414,7 +457,6 @@
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- /.card -->
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -436,6 +478,7 @@
|
|||||||
initUI();
|
initUI();
|
||||||
fetchDecoyStatus();
|
fetchDecoyStatus();
|
||||||
fetchObfsStatus();
|
fetchObfsStatus();
|
||||||
|
fetchNodes();
|
||||||
|
|
||||||
function isValidPath(path) {
|
function isValidPath(path) {
|
||||||
if (!path) return false;
|
if (!path) return false;
|
||||||
@ -458,20 +501,17 @@
|
|||||||
return /^[a-zA-Z0-9]+$/.test(subpath);
|
return /^[a-zA-Z0-9]+$/.test(subpath);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
function isValidIPorDomain(input) {
|
function isValidIPorDomain(input) {
|
||||||
if (!input) return true;
|
if (input === null || typeof input === 'undefined') return false;
|
||||||
|
input = input.trim();
|
||||||
|
if (input === '') return false;
|
||||||
|
|
||||||
const ipV4Regex = /^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/;
|
const ipV4Regex = /^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/;
|
||||||
const ipV6Regex = /^(([0-9a-fA-F]{1,4}:){7,7}([0-9a-fA-F]{1,4}|:)|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:))$/;
|
const ipV6Regex = /^(([0-9a-fA-F]{1,4}:){7,7}([0-9a-fA-F]{1,4}|:)|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:))$/;
|
||||||
const domainRegex = /^(?!-)(?:[a-zA-Z\d-]{0,62}[a-zA-Z\d]\.){1,126}(?!\d+$)[a-zA-Z\d]{1,63}$/;
|
const domainRegex = /^(?!-)(?:[a-zA-Z\d-]{0,62}[a-zA-Z\d]\.){1,126}(?!\d+$)[a-zA-Z\d]{1,63}$/;
|
||||||
const lowerInput = input.toLowerCase();
|
const lowerInput = input.toLowerCase();
|
||||||
|
|
||||||
if (ipV4Regex.test(input)) return true;
|
return ipV4Regex.test(input) || ipV6Regex.test(input) || domainRegex.test(lowerInput);
|
||||||
if (ipV6Regex.test(input)) return true;
|
|
||||||
if (domainRegex.test(lowerInput) && !lowerInput.startsWith("http://") && !lowerInput.startsWith("https://")) return true;
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function isValidPositiveNumber(value) {
|
function isValidPositiveNumber(value) {
|
||||||
@ -518,12 +558,16 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
console.log("Success Response:", response);
|
|
||||||
},
|
},
|
||||||
error: function (xhr, status, error) {
|
error: function (xhr, status, error) {
|
||||||
let errorMessage = "Something went wrong.";
|
let errorMessage = "An unexpected error occurred.";
|
||||||
if (xhr.responseJSON && xhr.responseJSON.detail) {
|
if (xhr.responseJSON && xhr.responseJSON.detail) {
|
||||||
errorMessage = xhr.responseJSON.detail;
|
const detail = xhr.responseJSON.detail;
|
||||||
|
if (Array.isArray(detail)) {
|
||||||
|
errorMessage = detail.map(err => `Error in '${err.loc[1]}': ${err.msg}`).join('\n');
|
||||||
|
} else if (typeof detail === 'string') {
|
||||||
|
errorMessage = detail;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Swal.fire("Error!", errorMessage, "error");
|
Swal.fire("Error!", errorMessage, "error");
|
||||||
console.error("AJAX Error:", status, error, xhr.responseText);
|
console.error("AJAX Error:", status, error, xhr.responseText);
|
||||||
@ -552,6 +596,10 @@
|
|||||||
fieldValid = isValidSubPath(input.val());
|
fieldValid = isValidSubPath(input.val());
|
||||||
} else if (id === 'ipv4' || id === 'ipv6') {
|
} else if (id === 'ipv4' || id === 'ipv6') {
|
||||||
fieldValid = (input.val().trim() === '') ? true : isValidIPorDomain(input.val());
|
fieldValid = (input.val().trim() === '') ? true : isValidIPorDomain(input.val());
|
||||||
|
} else if (id === 'node_ip') {
|
||||||
|
fieldValid = isValidIPorDomain(input.val());
|
||||||
|
} else if (id === 'node_name') {
|
||||||
|
fieldValid = input.val().trim() !== "";
|
||||||
} else if (id === 'block_duration' || id === 'max_ips') {
|
} else if (id === 'block_duration' || id === 'max_ips') {
|
||||||
fieldValid = isValidPositiveNumber(input.val());
|
fieldValid = isValidPositiveNumber(input.val());
|
||||||
} else if (id === 'decoy_path') {
|
} else if (id === 'decoy_path') {
|
||||||
@ -572,7 +620,6 @@
|
|||||||
return isValid;
|
return isValid;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
function initUI() {
|
function initUI() {
|
||||||
$.ajax({
|
$.ajax({
|
||||||
url: "{{ url_for('server_services_status_api') }}",
|
url: "{{ url_for('server_services_status_api') }}",
|
||||||
@ -621,6 +668,83 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function fetchNodes() {
|
||||||
|
$.ajax({
|
||||||
|
url: "{{ url_for('get_all_nodes') }}",
|
||||||
|
type: "GET",
|
||||||
|
success: function (nodes) {
|
||||||
|
renderNodes(nodes);
|
||||||
|
},
|
||||||
|
error: function(xhr) {
|
||||||
|
Swal.fire("Error!", "Failed to fetch external nodes list.", "error");
|
||||||
|
console.error("Error fetching nodes:", xhr.responseText);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderNodes(nodes) {
|
||||||
|
const tableBody = $("#nodes_table tbody");
|
||||||
|
tableBody.empty();
|
||||||
|
|
||||||
|
if (nodes && nodes.length > 0) {
|
||||||
|
$("#nodes_table").show();
|
||||||
|
$("#no_nodes_message").hide();
|
||||||
|
nodes.forEach(node => {
|
||||||
|
const row = `<tr>
|
||||||
|
<td>${node.name}</td>
|
||||||
|
<td>${node.ip}</td>
|
||||||
|
<td>
|
||||||
|
<button class="btn btn-xs btn-danger delete-node-btn" data-name="${node.name}">
|
||||||
|
<i class="fas fa-trash"></i> Delete
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>`;
|
||||||
|
tableBody.append(row);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
$("#nodes_table").hide();
|
||||||
|
$("#no_nodes_message").show();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addNode() {
|
||||||
|
if (!validateForm('add_node_form')) return;
|
||||||
|
|
||||||
|
const name = $("#node_name").val().trim();
|
||||||
|
const ip = $("#node_ip").val().trim();
|
||||||
|
|
||||||
|
confirmAction(`add the node '${name}'`, function () {
|
||||||
|
sendRequest(
|
||||||
|
"{{ url_for('add_node') }}",
|
||||||
|
"POST",
|
||||||
|
{ name: name, ip: ip },
|
||||||
|
`Node '${name}' added successfully!`,
|
||||||
|
"#add_node_btn",
|
||||||
|
false,
|
||||||
|
function() {
|
||||||
|
$("#node_name").val('');
|
||||||
|
$("#node_ip").val('');
|
||||||
|
$("#add_node_form .form-control").removeClass('is-invalid');
|
||||||
|
fetchNodes();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteNode(nodeName) {
|
||||||
|
confirmAction(`delete the node '${nodeName}'`, function () {
|
||||||
|
sendRequest(
|
||||||
|
"{{ url_for('delete_node') }}",
|
||||||
|
"POST",
|
||||||
|
{ name: nodeName },
|
||||||
|
`Node '${nodeName}' deleted successfully!`,
|
||||||
|
null,
|
||||||
|
false,
|
||||||
|
fetchNodes
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function updateServiceUI(data) {
|
function updateServiceUI(data) {
|
||||||
const servicesMap = {
|
const servicesMap = {
|
||||||
"hysteria_telegram_bot": "#telegram_form",
|
"hysteria_telegram_bot": "#telegram_form",
|
||||||
@ -633,7 +757,6 @@
|
|||||||
let targetSelector = servicesMap[serviceKey];
|
let targetSelector = servicesMap[serviceKey];
|
||||||
let isRunning = data[serviceKey];
|
let isRunning = data[serviceKey];
|
||||||
|
|
||||||
|
|
||||||
if (serviceKey === "hysteria_normal_sub") {
|
if (serviceKey === "hysteria_normal_sub") {
|
||||||
const $normalForm = $("#normal_sub_service_form");
|
const $normalForm = $("#normal_sub_service_form");
|
||||||
const $normalFormGroups = $normalForm.find(".form-group");
|
const $normalFormGroups = $normalForm.find(".form-group");
|
||||||
@ -771,7 +894,6 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
function setupDecoy() {
|
function setupDecoy() {
|
||||||
if (!validateForm('decoy_form')) return;
|
if (!validateForm('decoy_form')) return;
|
||||||
const domain = $("#decoy_domain").val();
|
const domain = $("#decoy_domain").val();
|
||||||
@ -846,7 +968,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
function fetchObfsStatus() {
|
function fetchObfsStatus() {
|
||||||
$.ajax({
|
$.ajax({
|
||||||
url: "{{ url_for('check_obfs') }}",
|
url: "{{ url_for('check_obfs') }}",
|
||||||
@ -908,7 +1029,6 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
function startTelegram() {
|
function startTelegram() {
|
||||||
if (!validateForm('telegram_form')) return;
|
if (!validateForm('telegram_form')) return;
|
||||||
const apiToken = $("#telegram_api_token").val();
|
const apiToken = $("#telegram_api_token").val();
|
||||||
@ -1197,7 +1317,6 @@
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
$("#telegram_start").on("click", startTelegram);
|
$("#telegram_start").on("click", startTelegram);
|
||||||
$("#telegram_stop").on("click", stopTelegram);
|
$("#telegram_stop").on("click", stopTelegram);
|
||||||
$("#normal_start").on("click", startNormal);
|
$("#normal_start").on("click", startNormal);
|
||||||
@ -1215,7 +1334,11 @@
|
|||||||
$("#decoy_stop").on("click", stopDecoy);
|
$("#decoy_stop").on("click", stopDecoy);
|
||||||
$("#obfs_enable_btn").on("click", enableObfs);
|
$("#obfs_enable_btn").on("click", enableObfs);
|
||||||
$("#obfs_disable_btn").on("click", disableObfs);
|
$("#obfs_disable_btn").on("click", disableObfs);
|
||||||
|
$("#add_node_btn").on("click", addNode);
|
||||||
|
$("#nodes_table").on("click", ".delete-node-btn", function() {
|
||||||
|
const nodeName = $(this).data("name");
|
||||||
|
deleteNode(nodeName);
|
||||||
|
});
|
||||||
|
|
||||||
$('#normal_domain, #sni_domain, #decoy_domain').on('input', function () {
|
$('#normal_domain, #sni_domain, #decoy_domain').on('input', function () {
|
||||||
if (isValidDomain($(this).val())) {
|
if (isValidDomain($(this).val())) {
|
||||||
@ -1247,8 +1370,19 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
$('#ipv4, #ipv6').on('input', function () {
|
$('#ipv4, #ipv6, #node_ip').on('input', function () {
|
||||||
if (isValidIPorDomain($(this).val()) || $(this).val().trim() === '') {
|
const isLocalIpField = $(this).attr('id') === 'ipv4' || $(this).attr('id') === 'ipv6';
|
||||||
|
if (isLocalIpField && $(this).val().trim() === '') {
|
||||||
|
$(this).removeClass('is-invalid');
|
||||||
|
} 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');
|
$(this).removeClass('is-invalid');
|
||||||
} else {
|
} else {
|
||||||
$(this).addClass('is-invalid');
|
$(this).addClass('is-invalid');
|
||||||
|
|||||||
@ -243,7 +243,6 @@
|
|||||||
<script>
|
<script>
|
||||||
$(function () {
|
$(function () {
|
||||||
|
|
||||||
//** Username validation */
|
|
||||||
const usernameRegex = /^[a-zA-Z0-9]+$/;
|
const usernameRegex = /^[a-zA-Z0-9]+$/;
|
||||||
|
|
||||||
function validateUsername(username, errorElementId) {
|
function validateUsername(username, errorElementId) {
|
||||||
@ -254,16 +253,14 @@
|
|||||||
errorElement.text("Usernames can only contain letters and numbers.");
|
errorElement.text("Usernames can only contain letters and numbers.");
|
||||||
return false;
|
return false;
|
||||||
} else {
|
} else {
|
||||||
errorElement.text(""); // Clear any previous error
|
errorElement.text("");
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Disable submit buttons by default
|
|
||||||
$("#addSubmitButton").prop("disabled", true);
|
$("#addSubmitButton").prop("disabled", true);
|
||||||
// $("#editSubmitButton").prop("disabled", true);
|
// $("#editSubmitButton").prop("disabled", true);
|
||||||
|
|
||||||
//** Add Username validation on Add User Modal */
|
|
||||||
$("#addUsername").on("input", function () {
|
$("#addUsername").on("input", function () {
|
||||||
const username = $(this).val();
|
const username = $(this).val();
|
||||||
const isValid = validateUsername(username, "addUsernameError");
|
const isValid = validateUsername(username, "addUsernameError");
|
||||||
@ -271,7 +268,6 @@
|
|||||||
$("#addSubmitButton").prop("disabled", !isValid);
|
$("#addSubmitButton").prop("disabled", !isValid);
|
||||||
});
|
});
|
||||||
|
|
||||||
//** Add Username validation on Edit User Modal */
|
|
||||||
$("#editUsername").on("input", function () {
|
$("#editUsername").on("input", function () {
|
||||||
const username = $(this).val();
|
const username = $(this).val();
|
||||||
const isValid = validateUsername(username, "editUsernameError");
|
const isValid = validateUsername(username, "editUsernameError");
|
||||||
@ -279,11 +275,9 @@
|
|||||||
$("#editSubmitButton").prop("disabled", !isValid);
|
$("#editSubmitButton").prop("disabled", !isValid);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Filter Buttons Functionality
|
|
||||||
$(".filter-button").on("click", function () {
|
$(".filter-button").on("click", function () {
|
||||||
const filter = $(this).data("filter");
|
const filter = $(this).data("filter");
|
||||||
|
|
||||||
// Deselect "Select All" checkbox when a filter is applied
|
|
||||||
$("#selectAll").prop("checked", false);
|
$("#selectAll").prop("checked", false);
|
||||||
|
|
||||||
$("#userTable tbody tr").each(function () {
|
$("#userTable tbody tr").each(function () {
|
||||||
@ -342,7 +336,6 @@
|
|||||||
confirmButtonText: "Yes, delete them!",
|
confirmButtonText: "Yes, delete them!",
|
||||||
}).then((result) => {
|
}).then((result) => {
|
||||||
if (result.isConfirmed) {
|
if (result.isConfirmed) {
|
||||||
//AJAX request for each user selected
|
|
||||||
Promise.all(selectedUsers.map(username => {
|
Promise.all(selectedUsers.map(username => {
|
||||||
const removeUserUrl = "{{ url_for('remove_user_api', username='USERNAME_PLACEHOLDER') }}";
|
const removeUserUrl = "{{ url_for('remove_user_api', username='USERNAME_PLACEHOLDER') }}";
|
||||||
const url = removeUserUrl.replace("USERNAME_PLACEHOLDER", encodeURIComponent(username));
|
const url = removeUserUrl.replace("USERNAME_PLACEHOLDER", encodeURIComponent(username));
|
||||||
@ -386,7 +379,6 @@
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Add User Form Submit
|
|
||||||
$("#addUserForm").on("submit", function (e) {
|
$("#addUserForm").on("submit", function (e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!validateUsername($("#addUsername").val(), "addUsernameError")) {
|
if (!validateUsername($("#addUsername").val(), "addUsernameError")) {
|
||||||
@ -437,21 +429,31 @@
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Edit User Form Populate and Submit
|
|
||||||
$(document).on("click", ".edit-user", function () {
|
$(document).on("click", ".edit-user", function () {
|
||||||
const username = $(this).data("user");
|
const username = $(this).data("user");
|
||||||
const row = $(this).closest("tr");
|
const row = $(this).closest("tr");
|
||||||
const quota = row.find("td:eq(4)").text().trim();
|
const trafficUsageText = row.find("td:eq(4)").text().trim();
|
||||||
const expiry_days = row.find("td:eq(6)").text().trim();
|
const expiryDaysText = row.find("td:eq(6)").text().trim();
|
||||||
const blocked = row.find("td:eq(7) i").hasClass("text-danger");
|
const blocked = row.find("td:eq(7) i").hasClass("text-danger");
|
||||||
|
|
||||||
const quotaMatch = quota.match(/\/\s*([\d.]+)/);
|
const expiryDaysValue = (expiryDaysText.toLowerCase() === 'unlimited') ? 0 : parseInt(expiryDaysText, 10);
|
||||||
const quotaValue = quotaMatch ? parseFloat(quotaMatch[1]) : 0;
|
|
||||||
|
let trafficLimitValue = 0;
|
||||||
|
if (!trafficUsageText.toLowerCase().includes('/unlimited')) {
|
||||||
|
const parts = trafficUsageText.split('/');
|
||||||
|
if (parts.length > 1) {
|
||||||
|
const limitPart = parts[1].trim();
|
||||||
|
const match = limitPart.match(/^[\d.]+/);
|
||||||
|
if (match) {
|
||||||
|
trafficLimitValue = parseFloat(match[0]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
$("#originalUsername").val(username);
|
$("#originalUsername").val(username);
|
||||||
$("#editUsername").val(username);
|
$("#editUsername").val(username);
|
||||||
$("#editTrafficLimit").val(quotaValue);
|
$("#editTrafficLimit").val(trafficLimitValue);
|
||||||
$("#editExpirationDays").val(expiry_days);
|
$("#editExpirationDays").val(expiryDaysValue);
|
||||||
$("#editBlocked").prop("checked", blocked);
|
$("#editBlocked").prop("checked", blocked);
|
||||||
|
|
||||||
const isValid = validateUsername(username, "editUsernameError");
|
const isValid = validateUsername(username, "editUsernameError");
|
||||||
@ -480,7 +482,6 @@
|
|||||||
data: JSON.stringify(jsonData),
|
data: JSON.stringify(jsonData),
|
||||||
success: function (response) {
|
success: function (response) {
|
||||||
if (typeof response === 'string' && response.includes("User updated successfully")) {
|
if (typeof response === 'string' && response.includes("User updated successfully")) {
|
||||||
// Hide the modal
|
|
||||||
$("#editUserModal").modal("hide");
|
$("#editUserModal").modal("hide");
|
||||||
Swal.fire({
|
Swal.fire({
|
||||||
title: "Success!",
|
title: "Success!",
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
aiohappyeyeballs==2.6.1
|
aiohappyeyeballs==2.6.1
|
||||||
aiohttp==3.12.14
|
aiohttp==3.12.15
|
||||||
aiosignal==1.4.0
|
aiosignal==1.4.0
|
||||||
async-timeout==5.0.1
|
async-timeout==5.0.1
|
||||||
attrs==25.3.0
|
attrs==25.3.0
|
||||||
@ -13,7 +13,7 @@ pillow==11.3.0
|
|||||||
propcache==0.3.2
|
propcache==0.3.2
|
||||||
psutil==7.0.0
|
psutil==7.0.0
|
||||||
pypng==0.20220715.0
|
pypng==0.20220715.0
|
||||||
pyTelegramBotAPI==4.27.0
|
pyTelegramBotAPI==4.28.0
|
||||||
python-dotenv==1.1.1
|
python-dotenv==1.1.1
|
||||||
qrcode==8.2
|
qrcode==8.2
|
||||||
requests==2.32.4
|
requests==2.32.4
|
||||||
|
|||||||
@ -32,6 +32,7 @@ FILES=(
|
|||||||
"$HYSTERIA_INSTALL_DIR/users.json"
|
"$HYSTERIA_INSTALL_DIR/users.json"
|
||||||
"$HYSTERIA_INSTALL_DIR/config.json"
|
"$HYSTERIA_INSTALL_DIR/config.json"
|
||||||
"$HYSTERIA_INSTALL_DIR/.configs.env"
|
"$HYSTERIA_INSTALL_DIR/.configs.env"
|
||||||
|
"$HYSTERIA_INSTALL_DIR/nodes.json"
|
||||||
"$HYSTERIA_INSTALL_DIR/core/scripts/telegrambot/.env"
|
"$HYSTERIA_INSTALL_DIR/core/scripts/telegrambot/.env"
|
||||||
# "$HYSTERIA_INSTALL_DIR/core/scripts/singbox/.env"
|
# "$HYSTERIA_INSTALL_DIR/core/scripts/singbox/.env"
|
||||||
"$HYSTERIA_INSTALL_DIR/core/scripts/normalsub/.env"
|
"$HYSTERIA_INSTALL_DIR/core/scripts/normalsub/.env"
|
||||||
|
|||||||
Reference in New Issue
Block a user