Merge pull request #307 from ReturnFI/beta

Advanced Node Management & UI Upgrades
This commit is contained in:
Whispering Wind
2025-10-27 13:46:41 +01:00
committed by GitHub
19 changed files with 781 additions and 237 deletions

View File

@ -1,19 +1,26 @@
### 🚀 **[2.3.5] Dependency Updates & Minor Fixes** ### 🚀 **[2.4.0] Advanced Node Management & Paginated User List**
*Released: 2025-10-13* *Released: 2025-10-27*
#### 🧩 Dependency Updates #### 🌐 **API & Backend**
* ⬆️ **pydantic-core** → 2.41.1 * 🚀 **feat(api):** Added endpoint for receiving node traffic data
* ⬆️ **pydantic** → 2.12.0 * 🧩 **refactor(api):** Applied node-specific parameters to wrapper URI script
* **fastapi** → 0.118.0 * **refactor(uri):** Enhanced URI generation with node-specific parameters
* ⬆️ **pymongo** → 4.15.2 * 🧠 **refactor:** Cleaned up API documentation tags
* ⬆️ **certifi** → 2025.10.5 * 🧱 **feat(api):** Added full node configuration to web panel API
* ⬆️ **frozenlist** → 1.8.0
* ⬆️ **markupsafe** → 3.0.3
* ⬆️ **propcache** → 0.4.1
#### 🛠 Fixes #### 🧮 **Node Management**
* 🐛 Fixed **path handling bug** in Core module * 🧠 **feat(nodes):** Implemented advanced node management and automatic certificate generation
* ✏️ Fixed **typo** in CLI messages * 🔐 **feat(nodes):** Added *insecure TLS* option for external nodes
* 🌀 **feat(nodes):** Added optional **OBFS** and **port** parameters for flexible node setups
#### 💻 **Frontend / Web Panel**
* 🖥️ **feat(web):** Implemented full node configuration UI in the settings page
* 📄 **feat(ui):** Added **paginated user list** with selectable limit for improved performance
#### 🤖 **Bot & Scripts**
* 🧰 **fix(bot):** Removed unnecessary `-u` flag from `remove-user` command

View File

@ -329,12 +329,17 @@ def node():
pass pass
@node.command('add') @node.command('add')
@click.option('--name', required=True, type=str, help='A unique name for the node (e.g., "Node-DE").') @click.option('--name', required=True, type=str, help='Unique name for the node.')
@click.option('--ip', required=True, type=str, help='The public IP address of the node.') @click.option('--ip', required=True, type=str, help='Public IP address of the node.')
def add_node(name, ip): @click.option('--port', required=False, type=int, help='Optional: Port of the node.')
@click.option('--sni', required=False, type=str, help='Optional: Server Name Indication.')
@click.option('--pinSHA256', required=False, type=str, help='Optional: Public key SHA256 pin.')
@click.option('--obfs', required=False, type=str, help='Optional: Obfuscation key.')
@click.option('--insecure', is_flag=True, default=False, help='Optional: Skip certificate verification.')
def add_node(name, ip, port, sni, pinsha256, obfs, insecure):
"""Add a new external node.""" """Add a new external node."""
try: try:
output = cli_api.add_node(name, ip) output = cli_api.add_node(name, ip, sni, pinSHA256=pinsha256, port=port, obfs=obfs, insecure=insecure)
click.echo(output.strip()) click.echo(output.strip())
except Exception as e: except Exception as e:
click.echo(f'{e}', err=True) click.echo(f'{e}', err=True)
@ -358,6 +363,15 @@ def list_nodes():
except Exception as e: except Exception as e:
click.echo(f'{e}', err=True) click.echo(f'{e}', err=True)
@node.command('generate-cert')
def generate_cert():
"""Generate a self-signed certificate for nodes."""
try:
output = cli_api.generate_node_cert()
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),

View File

@ -35,7 +35,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') NODE_MANAGER = os.path.join(SCRIPT_DIR, 'nodes', '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')
EXTRA_CONFIG_SCRIPT = os.path.join(SCRIPT_DIR, 'hysteria2', 'extra_config.py') EXTRA_CONFIG_SCRIPT = os.path.join(SCRIPT_DIR, 'hysteria2', 'extra_config.py')
@ -464,11 +464,22 @@ 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): def add_node(name: str, ip: str, sni: Optional[str] = None, pinSHA256: Optional[str] = None, port: Optional[int] = None, obfs: Optional[str] = None, insecure: Optional[bool] = None):
""" """
Adds a new external node. Adds a new external node.
""" """
return run_cmd(['python3', Command.NODE_MANAGER.value, 'add', '--name', name, '--ip', ip]) command = ['python3', Command.NODE_MANAGER.value, 'add', '--name', name, '--ip', ip]
if port:
command.extend(['--port', str(port)])
if sni:
command.extend(['--sni', sni])
if pinSHA256:
command.extend(['--pinSHA256', pinSHA256])
if obfs:
command.extend(['--obfs', obfs])
if insecure:
command.append('--insecure')
return run_cmd(command)
def delete_node(name: str): def delete_node(name: str):
""" """
@ -482,6 +493,12 @@ def list_nodes():
""" """
return run_cmd(['python3', Command.NODE_MANAGER.value, 'list']) return run_cmd(['python3', Command.NODE_MANAGER.value, 'list'])
def generate_node_cert():
"""
Generates a self-signed certificate for nodes.
"""
return run_cmd(['python3', Command.NODE_MANAGER.value, 'generate-cert'])
def update_geo(country: str): def update_geo(country: str):
''' '''
Updates geographic data files based on the specified country. Updates geographic data files based on the specified country.

View File

@ -1,110 +0,0 @@
#!/usr/bin/env python3
import sys
import json
import argparse
from pathlib import Path
import re
from ipaddress import ip_address
from init_paths import *
from paths import NODES_JSON_PATH
def is_valid_ip_or_domain(value: str) -> bool:
"""Check if the value is a valid IP address or domain name."""
if not value or not value.strip():
return False
value = value.strip()
try:
ip_address(value)
return True
except ValueError:
domain_regex = re.compile(
r'^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z0-9][a-z0-9-]{0,61}[a-z0-9]$',
re.IGNORECASE
)
return re.match(domain_regex, value) is not None
def read_nodes():
if not NODES_JSON_PATH.exists():
return []
try:
with NODES_JSON_PATH.open("r") as f:
content = f.read()
if not content:
return []
return json.loads(content)
except (json.JSONDecodeError, IOError, OSError) as e:
sys.exit(f"Error reading or parsing {NODES_JSON_PATH}: {e}")
def write_nodes(nodes):
try:
NODES_JSON_PATH.parent.mkdir(parents=True, exist_ok=True)
with NODES_JSON_PATH.open("w") as f:
json.dump(nodes, f, indent=4)
except (IOError, OSError) as e:
sys.exit(f"Error writing to {NODES_JSON_PATH}: {e}")
def add_node(name: str, ip: str):
if not is_valid_ip_or_domain(ip):
print(f"Error: '{ip}' is not a valid IP address or domain name.", file=sys.stderr)
sys.exit(1)
nodes = read_nodes()
if any(node['name'] == name for node in nodes):
print(f"Error: A node with the name '{name}' already exists.", file=sys.stderr)
sys.exit(1)
if any(node['ip'] == ip for node in nodes):
print(f"Error: A node with the IP/domain '{ip}' already exists.", file=sys.stderr)
sys.exit(1)
nodes.append({"name": name, "ip": ip})
write_nodes(nodes)
print(f"Successfully added node '{name}' with IP/domain '{ip}'.")
def delete_node(name: str):
nodes = read_nodes()
original_count = len(nodes)
nodes = [node for node in nodes if node['name'] != name]
if len(nodes) == original_count:
print(f"Error: No node with the name '{name}' found.", file=sys.stderr)
sys.exit(1)
write_nodes(nodes)
print(f"Successfully deleted node '{name}'.")
def list_nodes():
nodes = read_nodes()
if not nodes:
print("No nodes configured.")
return
print(f"{'Name':<30} {'IP Address / Domain'}")
print(f"{'-'*30} {'-'*25}")
for node in sorted(nodes, key=lambda x: x['name']):
print(f"{node['name']:<30} {node['ip']}")
def main():
parser = argparse.ArgumentParser(description="Manage external node configurations.")
subparsers = parser.add_subparsers(dest='command', required=True)
add_parser = subparsers.add_parser('add', help='Add a new node.')
add_parser.add_argument('--name', type=str, required=True, help='The unique name of the node.')
add_parser.add_argument('--ip', type=str, required=True, help='The IP address or domain of the node.')
delete_parser = subparsers.add_parser('delete', help='Delete a node by name.')
delete_parser.add_argument('--name', type=str, required=True, help='The name of the node to delete.')
subparsers.add_parser('list', help='List all configured nodes.')
args = parser.parse_args()
if args.command == 'add':
add_node(args.name, args.ip)
elif args.command == 'delete':
delete_node(args.name)
elif args.command == 'list':
list_nodes()
if __name__ == "__main__":
main()

View File

@ -24,7 +24,7 @@ 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]]: def load_nodes() -> List[Dict[str, Any]]:
if NODES_JSON_PATH.exists(): if NODES_JSON_PATH.exists():
try: try:
with NODES_JSON_PATH.open("r") as f: with NODES_JSON_PATH.open("r") as f:
@ -147,25 +147,26 @@ def show_uri(args: argparse.Namespace) -> None:
return return
auth_password = user_doc["password"] auth_password = user_doc["password"]
port = config["listen"].split(":")[-1]
sha256 = config.get("tls", {}).get("pinSHA256", "")
obfs_password = config.get("obfs", {}).get("salamander", {}).get("password", "")
insecure = config.get("tls", {}).get("insecure", True)
ip4, ip6, sni = load_hysteria2_ips() local_port = config["listen"].split(":")[-1]
local_sha256 = config.get("tls", {}).get("pinSHA256", "")
local_obfs_password = config.get("obfs", {}).get("salamander", {}).get("password", "")
local_insecure = config.get("tls", {}).get("insecure", True)
ip4, ip6, local_sni = load_hysteria2_ips()
nodes = load_nodes() nodes = load_nodes()
terminal_width = get_terminal_width() terminal_width = get_terminal_width()
if args.all or args.ip_version == 4: if args.all or args.ip_version == 4:
if ip4 and ip4 != "None": if ip4 and ip4 != "None":
uri = generate_uri(args.username, auth_password, ip4, port, uri = generate_uri(args.username, auth_password, ip4, local_port,
obfs_password, sha256, sni, 4, insecure, f"{args.username}-IPv4") local_obfs_password, local_sha256, local_sni, 4, local_insecure, f"{args.username}-IPv4")
display_uri_and_qr(uri, "IPv4", args, terminal_width) display_uri_and_qr(uri, "IPv4", args, terminal_width)
if args.all or args.ip_version == 6: if args.all or args.ip_version == 6:
if ip6 and ip6 != "None": if ip6 and ip6 != "None":
uri = generate_uri(args.username, auth_password, ip6, port, uri = generate_uri(args.username, auth_password, ip6, local_port,
obfs_password, sha256, sni, 6, insecure, f"{args.username}-IPv6") local_obfs_password, local_sha256, local_sni, 6, local_insecure, f"{args.username}-IPv6")
display_uri_and_qr(uri, "IPv6", args, terminal_width) display_uri_and_qr(uri, "IPv6", args, terminal_width)
for node in nodes: for node in nodes:
@ -177,8 +178,24 @@ def show_uri(args: argparse.Namespace) -> None:
ip_v = 4 if '.' in node_ip else 6 ip_v = 4 if '.' in node_ip else 6
if args.all or args.ip_version == ip_v: if args.all or args.ip_version == ip_v:
uri = generate_uri(args.username, auth_password, node_ip, port, node_port = node.get("port", local_port)
obfs_password, sha256, sni, ip_v, insecure, f"{args.username}-{node_name}") node_sni = node.get("sni", local_sni)
node_obfs = node.get("obfs", local_obfs_password)
node_pin = node.get("pinSHA256", local_sha256)
node_insecure = node.get("insecure", local_insecure)
uri = generate_uri(
username=args.username,
auth_password=auth_password,
ip=node_ip,
port=str(node_port),
obfs_password=node_obfs,
sha256=node_pin,
sni=node_sni,
ip_version=ip_v,
insecure=node_insecure,
fragment_tag=f"{args.username}-{node_name}"
)
display_uri_and_qr(uri, f"Node: {node_name} (IPv{ip_v})", args, terminal_width) 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"):

View File

@ -37,7 +37,10 @@ def generate_uri(username: str, auth_password: str, ip: str, port: str,
uri_params: Dict[str, str], ip_version: int, fragment_tag: str) -> str: uri_params: Dict[str, str], ip_version: int, fragment_tag: str) -> str:
ip_part = f"[{ip}]" if ip_version == 6 and ':' in ip else ip ip_part = f"[{ip}]" if ip_version == 6 and ':' in ip else ip
uri_base = f"hy2://{username}:{auth_password}@{ip_part}:{port}" uri_base = f"hy2://{username}:{auth_password}@{ip_part}:{port}"
query_string = "&".join([f"{k}={v}" for k, v in uri_params.items()])
query_params = [f"{k}={v}" for k, v in uri_params.items() if v is not None and v != '']
query_string = "&".join(query_params)
return f"{uri_base}?{query_string}#{fragment_tag}" return f"{uri_base}?{query_string}#{fragment_tag}"
def process_users(target_usernames: List[str]) -> List[Dict[str, Any]]: def process_users(target_usernames: List[str]) -> List[Dict[str, Any]]:
@ -51,23 +54,23 @@ def process_users(target_usernames: List[str]) -> List[Dict[str, Any]]:
sys.exit(1) sys.exit(1)
nodes = load_json_file(NODES_JSON_PATH) or [] nodes = load_json_file(NODES_JSON_PATH) or []
port = config.get("listen", "").split(":")[-1]
default_port = config.get("listen", "").split(":")[-1]
tls_config = config.get("tls", {}) tls_config = config.get("tls", {})
hy2_env = load_env_file(CONFIG_ENV) hy2_env = load_env_file(CONFIG_ENV)
ns_env = load_env_file(NORMALSUB_ENV) ns_env = load_env_file(NORMALSUB_ENV)
base_uri_params = { default_sni = hy2_env.get('SNI', '')
"insecure": "1" if tls_config.get("insecure", True) else "0", default_obfs = config.get("obfs", {}).get("salamander", {}).get("password")
"sni": hy2_env.get('SNI', '') default_pin = tls_config.get("pinSHA256")
} default_insecure = tls_config.get("insecure", True)
obfs_password = config.get("obfs", {}).get("salamander", {}).get("password")
if obfs_password:
base_uri_params["obfs"] = "salamander"
base_uri_params["obfs-password"] = obfs_password
sha256 = tls_config.get("pinSHA256") base_uri_params = {"insecure": "1" if default_insecure else "0"}
if sha256: if default_sni: base_uri_params["sni"] = default_sni
base_uri_params["pinSHA256"] = sha256 if default_obfs:
base_uri_params["obfs"] = "salamander"
base_uri_params["obfs-password"] = default_obfs
if default_pin: base_uri_params["pinSHA256"] = default_pin
ip4 = hy2_env.get('IP4') ip4 = hy2_env.get('IP4')
ip6 = hy2_env.get('IP6') ip6 = hy2_env.get('IP6')
@ -84,17 +87,34 @@ def process_users(target_usernames: List[str]) -> List[Dict[str, Any]]:
user_output = {"username": username, "ipv4": None, "ipv6": None, "nodes": [], "normal_sub": None} user_output = {"username": username, "ipv4": None, "ipv6": None, "nodes": [], "normal_sub": None}
if ip4 and ip4 != "None": if ip4 and ip4 != "None":
user_output["ipv4"] = generate_uri(username, auth_password, ip4, port, base_uri_params, 4, f"{username}-IPv4") user_output["ipv4"] = generate_uri(username, auth_password, ip4, default_port, base_uri_params, 4, f"{username}-IPv4")
if ip6 and ip6 != "None": if ip6 and ip6 != "None":
user_output["ipv6"] = generate_uri(username, auth_password, ip6, port, base_uri_params, 6, f"{username}-IPv6") user_output["ipv6"] = generate_uri(username, auth_password, ip6, default_port, base_uri_params, 6, f"{username}-IPv6")
for node in nodes: for node in nodes:
if node_name := node.get("name"): node_name = node.get("name")
if node_ip := node.get("ip"): node_ip = node.get("ip")
ip_v = 6 if ':' in node_ip else 4 if not node_name or not node_ip:
tag = f"{username}-{node_name}" continue
uri = generate_uri(username, auth_password, node_ip, port, base_uri_params, ip_v, tag)
user_output["nodes"].append({"name": node_name, "uri": uri}) ip_v = 6 if ':' in node_ip else 4
tag = f"{username}-{node_name}"
node_port = str(node.get("port", default_port))
node_sni = node.get("sni", default_sni)
node_obfs = node.get("obfs", default_obfs)
node_pin = node.get("pinSHA256", default_pin)
node_insecure = node.get("insecure", default_insecure)
node_params = {"insecure": "1" if node_insecure else "0"}
if node_sni: node_params["sni"] = node_sni
if node_obfs:
node_params["obfs"] = "salamander"
node_params["obfs-password"] = node_obfs
if node_pin: node_params["pinSHA256"] = node_pin
uri = generate_uri(username, auth_password, node_ip, node_port, node_params, ip_v, tag)
user_output["nodes"].append({"name": node_name, "uri": uri})
if ns_domain and ns_port and ns_subpath: if ns_domain and ns_port and ns_subpath:
user_output["normal_sub"] = f"https://{ns_domain}:{ns_port}/{ns_subpath}/sub/normal/{auth_password}#{username}" user_output["normal_sub"] = f"https://{ns_domain}:{ns_port}/{ns_subpath}/sub/normal/{auth_password}#{username}"

View File

@ -0,0 +1,7 @@
import sys
from pathlib import Path
core_scripts_dir = Path(__file__).resolve().parents[1]
if str(core_scripts_dir) not in sys.path:
sys.path.append(str(core_scripts_dir))

229
core/scripts/nodes/node.py Normal file
View File

@ -0,0 +1,229 @@
#!/usr/bin/env python3
import sys
import json
import argparse
from pathlib import Path
import re
from ipaddress import ip_address
import subprocess
from datetime import datetime, timedelta
from init_paths import *
from paths import NODES_JSON_PATH
def is_valid_ip_or_domain(value: str) -> bool:
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 is_valid_sni(value: str) -> bool:
if not value or not value.strip():
return False
value = value.strip()
try:
ip_address(value)
return False
except ValueError:
if "https://" in value or "http://" in value or "//" in value:
return False
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 is_valid_sha256_pin(value: str) -> bool:
if not value or not value.strip():
return False
value = value.strip().upper()
pin_regex = re.compile(r'^([0-9A-F]{2}:){31}[0-9A-F]{2}$')
return re.match(pin_regex, value) is not None
def is_valid_port(port: int) -> bool:
return 1 <= port <= 65535
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, sni: str | None = None, pinSHA256: str | None = None, port: int | None = None, obfs: str | None = None, insecure: bool = False):
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)
if sni and not is_valid_sni(sni):
print(f"Error: '{sni}' is not a valid domain name for SNI.", file=sys.stderr)
sys.exit(1)
if pinSHA256 and not is_valid_sha256_pin(pinSHA256):
print(f"Error: '{pinSHA256}' is not a valid SHA256 pin format.", file=sys.stderr)
sys.exit(1)
if port and not is_valid_port(port):
print(f"Error: Port '{port}' must be between 1 and 65535.", 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)
new_node = {"name": name, "ip": ip}
if sni:
new_node["sni"] = sni.strip()
if pinSHA256:
new_node["pinSHA256"] = pinSHA256.strip().upper()
if port:
new_node["port"] = port
if obfs:
new_node["obfs"] = obfs.strip()
if insecure:
new_node["insecure"] = insecure
nodes.append(new_node)
write_nodes(nodes)
print(f"Successfully added node '{name}'.")
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':<15} {'IP / Domain':<25} {'Port':<8} {'SNI':<20} {'Insecure':<10} {'OBFS':<20} {'Pin SHA256'}")
print(f"{'-'*15} {'-'*25} {'-'*8} {'-'*20} {'-'*10} {'-'*20} {'-'*30}")
for node in sorted(nodes, key=lambda x: x['name']):
name = node['name']
ip = node['ip']
port = node.get('port', 'N/A')
sni = node.get('sni', 'N/A')
insecure = str(node.get('insecure', 'False'))
obfs = node.get('obfs', 'N/A')
pin = node.get('pinSHA256', 'N/A')
print(f"{name:<15} {ip:<25} {str(port):<8} {sni:<20} {insecure:<10} {obfs:<20} {pin}")
def generate_cert():
try:
script_dir = Path(__file__).parent.resolve()
key_filepath = script_dir / "blitz.key"
cert_filepath = script_dir / "blitz.crt"
if cert_filepath.exists():
try:
check_cmd = ['openssl', 'x509', '-in', str(cert_filepath), '-noout', '-enddate']
result = subprocess.run(check_cmd, capture_output=True, text=True, check=True)
end_date_str = result.stdout.strip().split('=')[1]
end_date = datetime.strptime(end_date_str, '%b %d %H:%M:%S %Y %Z')
if end_date > datetime.now() + timedelta(days=30):
print("Existing certificate is valid for more than 30 days.")
print("\n")
print(cert_filepath.read_text().strip())
return
else:
print("Existing certificate is expiring in less than 30 days. Generating a new one.")
except (subprocess.CalledProcessError, FileNotFoundError, IndexError, ValueError) as e:
print(f"Could not validate existing certificate: {e}. Generating a new one.")
print("Generating new certificate and key...")
openssl_command = [
'openssl', 'req', '-x509',
'-newkey', 'ec',
'-pkeyopt', 'ec_paramgen_curve:prime256v1',
'-keyout', str(key_filepath),
'-out', str(cert_filepath),
'-sha256', '-days', '3650', '-nodes',
'-subj', '/CN=Blitz'
]
result = subprocess.run(openssl_command, capture_output=True, text=True, check=False)
if result.returncode != 0:
sys.exit(f"Error generating certificate with OpenSSL:\n{result.stderr}")
cert_content = cert_filepath.read_text()
print("Successfully generated certificate and key:")
print("\n")
print(cert_content.strip())
except FileNotFoundError:
sys.exit("Error: 'openssl' command not found. Please ensure OpenSSL is installed and in your PATH.")
except Exception as e:
sys.exit(f"An unexpected error occurred: {e}")
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.')
add_parser.add_argument('--port', type=int, help='Optional: The port of the node.')
add_parser.add_argument('--sni', type=str, help='Optional: The Server Name Indication.')
add_parser.add_argument('--pinSHA256', type=str, help='Optional: The public key SHA256 pin.')
add_parser.add_argument('--obfs', type=str, help='Optional: The obfuscation key.')
add_parser.add_argument('--insecure', action='store_true', help='Optional: Skip certificate verification.')
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.')
subparsers.add_parser('generate-cert', help="Generate blitz.crt and blitz.key.")
args = parser.parse_args()
if args.command == 'add':
add_node(args.name, args.ip, args.sni, args.pinSHA256, args.port, args.obfs, args.insecure)
elif args.command == 'delete':
delete_node(args.name)
elif args.command == 'list':
list_nodes()
elif args.command == 'generate-cert':
generate_cert()
if __name__ == "__main__":
main()

View File

@ -20,6 +20,6 @@ def delete_user(message):
def process_delete_user(message): def process_delete_user(message):
username = message.text.strip().lower() username = message.text.strip().lower()
command = f"python3 {CLI_PATH} remove-user -u {username}" command = f"python3 {CLI_PATH} remove-user {username}"
result = run_cli_command(command) result = run_cli_command(command)
bot.reply_to(message, result) bot.reply_to(message, result)

View File

@ -5,18 +5,17 @@ import asyncio
from fastapi import FastAPI from fastapi import FastAPI
from starlette.staticfiles import StaticFiles from starlette.staticfiles import StaticFiles
from config import CONFIGS # Loads the configuration from .env from config import CONFIGS
from middleware import AuthMiddleware # Defines authentication middleware from middleware import AuthMiddleware
from middleware import AfterRequestMiddleware # Defines after request middleware from middleware import AfterRequestMiddleware
from dependency import get_session_manager # Defines dependencies across routers from dependency import get_session_manager
from openapi import setup_openapi_schema # Adds authorization header to openapi schema from openapi import setup_openapi_schema
from exception_handler import setup_exception_handler # Defines exception handlers from exception_handler import setup_exception_handler
# Append directory of cli_api.py to be able to import it
HYSTERIA_CORE_DIR = '/etc/hysteria/core/' HYSTERIA_CORE_DIR = '/etc/hysteria/core/'
sys.path.append(HYSTERIA_CORE_DIR) sys.path.append(HYSTERIA_CORE_DIR)
import routers # noqa: This import should be after the sys.path modification, because it imports cli_api import routers
def create_app() -> FastAPI: def create_app() -> FastAPI:
@ -24,11 +23,10 @@ def create_app() -> FastAPI:
Create FastAPI app. Create FastAPI app.
''' '''
# Set up FastAPI
app = FastAPI( app = FastAPI(
title='Hysteria Webpanel', title='Blitz API',
description='Webpanel for Hysteria', description='Webpanel for Hysteria2',
version='0.1.0', version='0.2.0',
contact={ contact={
'github': 'https://github.com/ReturnFI/Blitz' 'github': 'https://github.com/ReturnFI/Blitz'
}, },
@ -36,26 +34,19 @@ def create_app() -> FastAPI:
root_path=f'/{CONFIGS.ROOT_PATH}', root_path=f'/{CONFIGS.ROOT_PATH}',
) )
# Set up static files
app.mount('/assets', StaticFiles(directory='assets'), name='assets') app.mount('/assets', StaticFiles(directory='assets'), name='assets')
# Set up exception handlers
setup_exception_handler(app) setup_exception_handler(app)
# Set up authentication middleware
app.add_middleware(AuthMiddleware, session_manager=get_session_manager(), api_token=CONFIGS.API_TOKEN) app.add_middleware(AuthMiddleware, session_manager=get_session_manager(), api_token=CONFIGS.API_TOKEN)
# Set up after request middleware
app.add_middleware(AfterRequestMiddleware) app.add_middleware(AfterRequestMiddleware)
# Set up Routers app.include_router(routers.basic.router, prefix='', tags=['Web - Basic'])
app.include_router(routers.basic.router, prefix='', tags=['Basic Routes[Web]']) # Add basic router app.include_router(routers.login.router, prefix='', tags=['Web - Authentication'])
app.include_router(routers.login.router, prefix='', tags=['Authentication[Web]']) # Add authentication router app.include_router(routers.settings.router, prefix='/settings', tags=['Web - Settings'])
app.include_router(routers.settings.router, prefix='/settings', tags=['Settings[Web]']) # Add settings router app.include_router(routers.user.router, prefix='/users', tags=['Web - User Management'])
app.include_router(routers.user.router, prefix='/users', tags=['User Management[Web]']) # Add user router app.include_router(routers.api.v1.api_v1_router, prefix='/api/v1')
app.include_router(routers.api.v1.api_v1_router, prefix='/api/v1', tags=['API Version 1']) # Add API version 1 router # type: ignore
# Document that the API requires an API key
setup_openapi_schema(app) setup_openapi_schema(app)
return app return app
@ -66,7 +57,7 @@ app: FastAPI = create_app()
if __name__ == '__main__': if __name__ == '__main__':
from hypercorn.config import Config from hypercorn.config import Config
from hypercorn.asyncio import serve # type: ignore from hypercorn.asyncio import serve
from hypercorn.middleware import ProxyFixMiddleware from hypercorn.middleware import ProxyFixMiddleware
config = Config() config = Config()
@ -75,6 +66,5 @@ if __name__ == '__main__':
config.accesslog = '-' config.accesslog = '-'
config.errorlog = '-' config.errorlog = '-'
# Fix proxy headers app = ProxyFixMiddleware(app, 'legacy')
app = ProxyFixMiddleware(app, 'legacy') # type: ignore asyncio.run(serve(app, config))
asyncio.run(serve(app, config)) # type: ignore

View File

@ -67,7 +67,11 @@ $(document).ready(function () {
function isValidDomain(domain) { function isValidDomain(domain) {
if (!domain) return false; if (!domain) return false;
const lowerDomain = domain.toLowerCase(); const lowerDomain = domain.toLowerCase();
return !lowerDomain.startsWith("http://") && !lowerDomain.startsWith("https://"); if (lowerDomain.startsWith("http://") || lowerDomain.startsWith("https://")) 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]?)$/;
if(ipV4Regex.test(domain)) return false;
const domainRegex = /^(?!-)(?:[a-zA-Z\d-]{0,62}[a-zA-Z\d]\.){1,126}(?!\d+$)[a-zA-Z\d]{1,63}$/;
return domainRegex.test(lowerDomain);
} }
function isValidPort(port) { function isValidPort(port) {
@ -75,6 +79,12 @@ $(document).ready(function () {
return /^[0-9]+$/.test(port) && parseInt(port) > 0 && parseInt(port) <= 65535; return /^[0-9]+$/.test(port) && parseInt(port) > 0 && parseInt(port) <= 65535;
} }
function isValidSha256Pin(pin) {
if (!pin) return false;
const pinRegex = /^([0-9A-F]{2}:){31}[0-9A-F]{2}$/i;
return pinRegex.test(pin.trim());
}
function isValidSubPath(subpath) { function isValidSubPath(subpath) {
if (!subpath) return false; if (!subpath) return false;
return /^[a-zA-Z0-9]+$/.test(subpath); return /^[a-zA-Z0-9]+$/.test(subpath);
@ -198,6 +208,14 @@ $(document).ready(function () {
} }
} else if (id === 'decoy_path') { } else if (id === 'decoy_path') {
fieldValid = isValidPath(input.val()); fieldValid = isValidPath(input.val());
} else if (id === 'node_port') {
fieldValid = (input.val().trim() === '') ? true : isValidPort(input.val());
} else if (id === 'node_sni') {
fieldValid = (input.val().trim() === '') ? true : isValidDomain(input.val());
} else if (id === 'node_pin') {
fieldValid = (input.val().trim() === '') ? true : isValidSha256Pin(input.val());
} else if (id === 'node_obfs') {
fieldValid = true;
} else { } else {
if (input.attr('placeholder') && input.attr('placeholder').includes('Enter') && !input.attr('id').startsWith('ipv')) { if (input.attr('placeholder') && input.attr('placeholder').includes('Enter') && !input.attr('id').startsWith('ipv')) {
fieldValid = input.val().trim() !== ""; fieldValid = input.val().trim() !== "";
@ -265,6 +283,11 @@ $(document).ready(function () {
const row = `<tr> const row = `<tr>
<td>${escapeHtml(node.name)}</td> <td>${escapeHtml(node.name)}</td>
<td>${escapeHtml(node.ip)}</td> <td>${escapeHtml(node.ip)}</td>
<td>${escapeHtml(node.port || 'N/A')}</td>
<td>${escapeHtml(node.sni || 'N/A')}</td>
<td>${escapeHtml(node.obfs || 'N/A')}</td>
<td>${escapeHtml(node.insecure ? 'True' : 'False')}</td>
<td>${escapeHtml(node.pinSHA256 || 'N/A')}</td>
<td> <td>
<button class="btn btn-xs btn-danger delete-node-btn" data-name="${escapeHtml(node.name)}"> <button class="btn btn-xs btn-danger delete-node-btn" data-name="${escapeHtml(node.name)}">
<i class="fas fa-trash"></i> Delete <i class="fas fa-trash"></i> Delete
@ -284,18 +307,28 @@ $(document).ready(function () {
const name = $("#node_name").val().trim(); const name = $("#node_name").val().trim();
const ip = $("#node_ip").val().trim(); const ip = $("#node_ip").val().trim();
const port = $("#node_port").val().trim();
const sni = $("#node_sni").val().trim();
const obfs = $("#node_obfs").val().trim();
const pinSHA256 = $("#node_pin").val().trim();
const insecure = $("#node_insecure").is(':checked');
const data = { name: name, ip: ip, insecure: insecure };
if (port) data.port = parseInt(port);
if (sni) data.sni = sni;
if (obfs) data.obfs = obfs;
if (pinSHA256) data.pinSHA256 = pinSHA256;
confirmAction(`add the node '${name}'`, function () { confirmAction(`add the node '${name}'`, function () {
sendRequest( sendRequest(
API_URLS.addNode, API_URLS.addNode,
"POST", "POST",
{ name: name, ip: ip }, data,
`Node '${name}' added successfully!`, `Node '${name}' added successfully!`,
"#add_node_btn", "#add_node_btn",
false, false,
function() { function() {
$("#node_name").val(''); $("#add_node_form")[0].reset();
$("#node_ip").val('');
$("#add_node_form .form-control").removeClass('is-invalid'); $("#add_node_form .form-control").removeClass('is-invalid');
fetchNodes(); fetchNodes();
} }
@ -1055,4 +1088,31 @@ $(document).ready(function () {
$(this).addClass('is-invalid'); $(this).addClass('is-invalid');
} }
}); });
$('#node_port').on('input', function () {
const val = $(this).val().trim();
if (val === '' || isValidPort(val)) {
$(this).removeClass('is-invalid');
} else {
$(this).addClass('is-invalid');
}
});
$('#node_sni').on('input', function () {
const val = $(this).val().trim();
if (val === '' || isValidDomain(val)) {
$(this).removeClass('is-invalid');
} else {
$(this).addClass('is-invalid');
}
});
$('#node_pin').on('input', function () {
const val = $(this).val().trim();
if (val === '' || isValidSha256Pin(val)) {
$(this).removeClass('is-invalid');
} else {
$(this).addClass('is-invalid');
}
});
}); });

View File

@ -9,10 +9,32 @@ $(function () {
const RESET_USER_URL_TEMPLATE = contentSection.dataset.resetUserUrlTemplate; const RESET_USER_URL_TEMPLATE = contentSection.dataset.resetUserUrlTemplate;
const USER_URI_URL_TEMPLATE = contentSection.dataset.userUriUrlTemplate; const USER_URI_URL_TEMPLATE = contentSection.dataset.userUriUrlTemplate;
const BULK_URI_URL = contentSection.dataset.bulkUriUrl; const BULK_URI_URL = contentSection.dataset.bulkUriUrl;
const USERS_BASE_URL = contentSection.dataset.usersBaseUrl;
const usernameRegex = /^[a-zA-Z0-9_]+$/; const usernameRegex = /^[a-zA-Z0-9_]+$/;
let cachedUserData = []; let cachedUserData = [];
function setCookie(name, value, days) {
let expires = "";
if (days) {
const date = new Date();
date.setTime(date.getTime() + (days * 24 * 60 * 60 * 1000));
expires = "; expires=" + date.toUTCString();
}
document.cookie = name + "=" + (value || "") + expires + "; path=/";
}
function getCookie(name) {
const nameEQ = name + "=";
const ca = document.cookie.split(';');
for (let i = 0; i < ca.length; i++) {
let c = ca[i];
while (c.charAt(0) === ' ') c = c.substring(1, c.length);
if (c.indexOf(nameEQ) === 0) return c.substring(nameEQ.length, c.length);
}
return null;
}
function checkIpLimitServiceStatus() { function checkIpLimitServiceStatus() {
$.getJSON(SERVICE_STATUS_URL) $.getJSON(SERVICE_STATUS_URL)
.done(data => { .done(data => {
@ -325,5 +347,17 @@ $(function () {
$("#searchButton").on("click", filterUsers); $("#searchButton").on("click", filterUsers);
$("#searchInput").on("keyup", filterUsers); $("#searchInput").on("keyup", filterUsers);
function initializeLimitSelector() {
const savedLimit = getCookie('limit') || '50';
$('#limit-select').val(savedLimit);
$('#limit-select').on('change', function() {
const newLimit = $(this).val();
setCookie('limit', newLimit, 365);
window.location.href = USERS_BASE_URL;
});
}
initializeLimitSelector();
checkIpLimitServiceStatus(); checkIpLimitServiceStatus();
}); });

View File

@ -5,6 +5,6 @@ from . import config
api_v1_router = APIRouter() api_v1_router = APIRouter()
api_v1_router.include_router(user.router, prefix='/users') api_v1_router.include_router(user.router, prefix='/users', tags=['API - Users'])
api_v1_router.include_router(server.router, prefix='/server') api_v1_router.include_router(server.router, prefix='/server', tags=['API - Server'])
api_v1_router.include_router(config.router, prefix='/config') api_v1_router.include_router(config.router, prefix='/config')

View File

@ -11,11 +11,11 @@ from . import extra_config
router = APIRouter() router = APIRouter()
router.include_router(hysteria.router, prefix='/hysteria') router.include_router(hysteria.router, prefix='/hysteria', tags=['API - Config - Hysteria'])
router.include_router(warp.router, prefix='/warp') router.include_router(warp.router, prefix='/warp', tags=['API - Config - Warp'])
router.include_router(telegram.router, prefix='/telegram') router.include_router(telegram.router, prefix='/telegram', tags=['API - Config - Telegram'])
router.include_router(normalsub.router, prefix='/normalsub') router.include_router(normalsub.router, prefix='/normalsub', tags=['API - Config - Normalsub'])
router.include_router(singbox.router, prefix='/singbox') router.include_router(singbox.router, prefix='/singbox', tags=['API - Config - Singbox'])
router.include_router(ip.router, prefix='/ip') router.include_router(ip.router, prefix='/ip', tags=['API - Config - IP'])
router.include_router(extra_config.router, prefix='/extra-config', tags=['Config - Extra Config']) router.include_router(extra_config.router, prefix='/extra-config', tags=['API - Config - Extra Config'])
router.include_router(misc.router) router.include_router(misc.router, tags=['API - Config - Misc'])

View File

@ -2,13 +2,15 @@ from fastapi import APIRouter, HTTPException
from ..schema.response import DetailResponse from ..schema.response import DetailResponse
import json import json
import os import os
from scripts.db.database import db
from ..schema.config.ip import ( from ..schema.config.ip import (
EditInputBody, EditInputBody,
StatusResponse, StatusResponse,
AddNodeBody, AddNodeBody,
DeleteNodeBody, DeleteNodeBody,
NodeListResponse NodeListResponse,
NodesTrafficPayload
) )
import cli_api import cli_api
@ -90,10 +92,18 @@ async def add_node(body: AddNodeBody):
Adds a new external node to the configuration. Adds a new external node to the configuration.
Args: Args:
body: Request body containing the name and IP of the node. body: Request body containing the full details of the node.
""" """
try: try:
cli_api.add_node(body.name, body.ip) cli_api.add_node(
name=body.name,
ip=body.ip,
port=body.port,
sni=body.sni,
pinSHA256=body.pinSHA256,
obfs=body.obfs,
insecure=body.insecure
)
return DetailResponse(detail=f"Node '{body.name}' added successfully.") return DetailResponse(detail=f"Node '{body.name}' added successfully.")
except Exception as e: except Exception as e:
raise HTTPException(status_code=400, detail=str(e)) raise HTTPException(status_code=400, detail=str(e))
@ -102,7 +112,6 @@ async def add_node(body: AddNodeBody):
@router.post('/nodes/delete', response_model=DetailResponse, summary='Delete External Node') @router.post('/nodes/delete', response_model=DetailResponse, summary='Delete External Node')
async def delete_node(body: DeleteNodeBody): async def delete_node(body: DeleteNodeBody):
""" """
Deletes an external node from the configuration by its name. Deletes an external node from the configuration by its name.
Args: Args:
@ -113,3 +122,40 @@ async def delete_node(body: DeleteNodeBody):
return DetailResponse(detail=f"Node '{body.name}' deleted successfully.") return DetailResponse(detail=f"Node '{body.name}' deleted successfully.")
except Exception as e: except Exception as e:
raise HTTPException(status_code=400, detail=str(e)) raise HTTPException(status_code=400, detail=str(e))
@router.post('/nodestraffic', response_model=DetailResponse, summary='Receive and Aggregate Traffic from Node')
async def receive_node_traffic(body: NodesTrafficPayload):
"""
Receives traffic delta from a node and adds it to the user's total in the database.
Authentication is handled by the AuthMiddleware.
"""
if db is None:
raise HTTPException(status_code=500, detail="Database connection is not available.")
updated_count = 0
for user_traffic in body.users:
try:
db_user = db.get_user(user_traffic.username)
if not db_user:
continue
new_upload = db_user.get('upload_bytes', 0) + user_traffic.upload_bytes
new_download = db_user.get('download_bytes', 0) + user_traffic.download_bytes
update_data = {
'upload_bytes': new_upload,
'download_bytes': new_download,
'status': user_traffic.status,
}
if not db_user.get('account_creation_date') and user_traffic.account_creation_date:
update_data['account_creation_date'] = user_traffic.account_creation_date
db.update_user(user_traffic.username, update_data)
updated_count += 1
except Exception as e:
print(f"Error updating traffic for user {user_traffic.username}: {e}")
return DetailResponse(detail=f"Successfully processed and aggregated traffic for {updated_count} users.")

View File

@ -1,6 +1,8 @@
from pydantic import BaseModel, field_validator, ValidationInfo from pydantic import BaseModel, field_validator
from ipaddress import IPv4Address, IPv6Address, ip_address from ipaddress import ip_address
import re import re
from typing import Optional, List
from datetime import datetime
def validate_ip_or_domain(v: str) -> str | None: def validate_ip_or_domain(v: str) -> str | None:
if v is None or v.strip() in ['', 'None']: if v is None or v.strip() in ['', 'None']:
@ -34,6 +36,11 @@ class EditInputBody(StatusResponse):
class Node(BaseModel): class Node(BaseModel):
name: str name: str
ip: str ip: str
port: Optional[int] = None
sni: Optional[str] = None
pinSHA256: Optional[str] = None
obfs: Optional[str] = None
insecure: Optional[bool] = False
@field_validator('ip', mode='before') @field_validator('ip', mode='before')
def check_node_ip(cls, v: str | None): def check_node_ip(cls, v: str | None):
@ -41,6 +48,42 @@ class Node(BaseModel):
raise ValueError("IP or Domain field cannot be empty.") raise ValueError("IP or Domain field cannot be empty.")
return validate_ip_or_domain(v) return validate_ip_or_domain(v)
@field_validator('port')
def check_port(cls, v: int | None):
if v is not None and not (1 <= v <= 65535):
raise ValueError('Port must be between 1 and 65535.')
return v
@field_validator('sni', mode='before')
def check_sni(cls, v: str | None):
if v is None or not v.strip():
return None
v = v.strip()
try:
ip_address(v)
raise ValueError("SNI must be a domain name, not an IP address.")
except ValueError:
pass
if "://" in v:
raise ValueError("SNI cannot contain '://'")
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 not domain_regex.match(v):
raise ValueError("Invalid domain name format for SNI.")
return v
@field_validator('pinSHA256', mode='before')
def check_pin(cls, v: str | None):
if v is None or not v.strip():
return None
v_stripped = v.strip().upper()
pin_regex = re.compile(r'^([0-9A-F]{2}:){31}[0-9A-F]{2}$')
if not pin_regex.match(v_stripped):
raise ValueError("Invalid SHA256 pin format.")
return v_stripped
class AddNodeBody(Node): class AddNodeBody(Node):
pass pass
@ -48,3 +91,23 @@ class DeleteNodeBody(BaseModel):
name: str name: str
NodeListResponse = list[Node] NodeListResponse = list[Node]
class NodeUserTraffic(BaseModel):
username: str
upload_bytes: int
download_bytes: int
status: str
account_creation_date: Optional[str] = None
@field_validator('account_creation_date')
def check_date_format(cls, v: str | None):
if v is None:
return None
try:
datetime.strptime(v, "%Y-%m-%d")
return v
except ValueError:
raise ValueError("account_creation_date must be in YYYY-MM-DD format.")
class NodesTrafficPayload(BaseModel):
users: List[NodeUserTraffic]

View File

@ -1,5 +1,8 @@
from fastapi import APIRouter, HTTPException, Request, Depends from fastapi import APIRouter, HTTPException, Request, Depends, Query, Path, Cookie
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from fastapi.responses import RedirectResponse
from starlette.status import HTTP_302_FOUND
import math
from dependency import get_templates from dependency import get_templates
from .viewmodel import User from .viewmodel import User
@ -9,12 +12,57 @@ import cli_api
router = APIRouter() router = APIRouter()
@router.get('/') async def get_users_page(
async def users(request: Request, templates: Jinja2Templates = Depends(get_templates)): request: Request,
templates: Jinja2Templates,
page: int,
limit: int
):
try: try:
users_list = cli_api.list_users() or [] users_list = cli_api.list_users() or []
users: list[User] = [User.from_dict(user_data.get('username', ''), user_data) for user_data in users_list] total_users = len(users_list)
total_pages = math.ceil(total_users / limit) if limit > 0 else 1
return templates.TemplateResponse('users.html', {'users': users, 'request': request}) if page > total_pages and total_pages > 0:
return RedirectResponse(url=f"/users/{total_pages}", status_code=HTTP_302_FOUND)
if page < 1:
return RedirectResponse(url=f"/users/1", status_code=HTTP_302_FOUND)
start_index = (page - 1) * limit
end_index = start_index + limit
paginated_list = users_list[start_index:end_index]
users: list[User] = [User.from_dict(user_data.get('username', ''), user_data) for user_data in paginated_list]
return templates.TemplateResponse(
'users.html',
{
'users': users,
'request': request,
'current_page': page,
'total_pages': total_pages,
'limit': limit,
'total_users': total_users,
}
)
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('/{page}', name="users_paginated")
async def users_paginated(
request: Request,
templates: Jinja2Templates = Depends(get_templates),
page: int = Path(..., ge=1),
limit: int = Cookie(default=50, ge=1)
):
return await get_users_page(request, templates, page, limit)
@router.get('/', name="users")
async def users_root(
request: Request,
templates: Jinja2Templates = Depends(get_templates),
limit: int = Cookie(default=50, ge=1)
):
return await get_users_page(request, templates, 1, limit)

View File

@ -223,8 +223,13 @@
<table class="table table-bordered table-striped" id="nodes_table"> <table class="table table-bordered table-striped" id="nodes_table">
<thead> <thead>
<tr> <tr>
<th>Node Name</th> <th>Name</th>
<th>IP Address / Domain</th> <th>IP/Domain</th>
<th>Port</th>
<th>SNI</th>
<th>OBFS</th>
<th>Insecure</th>
<th>Pin SHA256</th>
<th>Action</th> <th>Action</th>
</tr> </tr>
</thead> </thead>
@ -237,18 +242,50 @@
<hr> <hr>
<h5>Add New Node</h5> <h5>Add New Node</h5>
<form id="add_node_form" class="form-inline"> <form id="add_node_form">
<div class="form-group mb-2 mr-sm-2"> <div class="form-row">
<label for="node_name" class="sr-only">Node Name</label> <div class="form-group col-md-6">
<input type="text" class="form-control" id="node_name" placeholder="e.g., Node-US"> <label for="node_name">Node Name (Required)</label>
<div class="invalid-feedback">Please enter a name.</div> <input type="text" class="form-control" id="node_name" placeholder="e.g., Node-US">
<div class="invalid-feedback">Please enter a unique name.</div>
</div>
<div class="form-group col-md-6">
<label for="node_ip">IP Address / Domain (Required)</label>
<input type="text" class="form-control" id="node_ip" placeholder="e.g., node.example.com or 1.2.3.4">
<div class="invalid-feedback">Please enter a valid IP or domain.</div>
</div>
</div> </div>
<div class="form-group mb-2 mr-sm-2"> <div class="form-row">
<label for="node_ip" class="sr-only">IP Address / Domain</label> <div class="form-group col-md-3">
<input type="text" class="form-control" id="node_ip" placeholder="e.g., node.example.com"> <label for="node_port">Port (Optional)</label>
<div class="invalid-feedback">Please enter a valid IP or domain.</div> <input type="number" class="form-control" id="node_port" placeholder="e.g., 443">
<div class="invalid-feedback">Please enter a valid port (1-65535).</div>
</div>
<div class="form-group col-md-3">
<label for="node_obfs">OBFS (Optional)</label>
<input type="text" class="form-control" id="node_obfs" placeholder="obfs-password">
<div class="invalid-feedback">OBFS cannot be empty if provided.</div>
</div>
<div class="form-group col-md-6">
<label for="node_sni">SNI (Optional)</label>
<input type="text" class="form-control" id="node_sni" placeholder="e.g., yourdomain.com">
<div class="invalid-feedback">Please enter a valid domain name (not an IP).</div>
</div>
</div> </div>
<button type="button" id="add_node_btn" class="btn btn-success mb-2"> <div class="form-group">
<label for="node_pin">Pin SHA256 (Optional)</label>
<input type="text" class="form-control" id="node_pin" placeholder="5D:23:0E:E9:10:AB:96:E0:43...">
<div class="invalid-feedback">Invalid SHA256 pin format.</div>
</div>
<div class="form-group">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="node_insecure">
<label class="form-check-label" for="node_insecure">
Insecure
</label>
</div>
</div>
<button type="button" id="add_node_btn" class="btn btn-success">
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true" style="display: none;"></span> <span class="spinner-border spinner-border-sm" role="status" aria-hidden="true" style="display: none;"></span>
Add Node Add Node
</button> </button>

View File

@ -44,11 +44,12 @@
data-edit-user-url-template="{{ url_for('edit_user_api', username='U') }}" data-edit-user-url-template="{{ url_for('edit_user_api', username='U') }}"
data-reset-user-url-template="{{ url_for('reset_user_api', username='U') }}" data-reset-user-url-template="{{ url_for('reset_user_api', username='U') }}"
data-user-uri-url-template="{{ url_for('show_user_uri_api', username='U') }}" data-user-uri-url-template="{{ url_for('show_user_uri_api', username='U') }}"
data-bulk-uri-url="{{ url_for('show_multiple_user_uris_api') }}"> data-bulk-uri-url="{{ url_for('show_multiple_user_uris_api') }}"
data-users-base-url="{{ url_for('users') }}">
<div class="container-fluid"> <div class="container-fluid">
<div class="card"> <div class="card">
<div class="card-header"> <div class="card-header">
<h3 class="card-title">User List {% if users %}({{ users|length }}){% endif %}</h3> <h3 class="card-title">User List {% if total_users is defined %}({{ total_users }}){% endif %}</h3>
<div class="card-tools d-flex align-items-center flex-wrap"> <div class="card-tools d-flex align-items-center flex-wrap">
<!-- Mobile Filter Dropdown --> <!-- Mobile Filter Dropdown -->
@ -143,7 +144,7 @@
<td> <td>
<input type="checkbox" class="user-checkbox" value="{{ user.username }}"> <input type="checkbox" class="user-checkbox" value="{{ user.username }}">
</td> </td>
<td>{{ loop.index }}</td> <td>{{ loop.index + ((current_page - 1) * limit) }}</td>
<td data-username="{{ user.username }}">{{ user.username }}</td> <td data-username="{{ user.username }}">{{ user.username }}</td>
<td class="d-none d-md-table-cell"> <td class="d-none d-md-table-cell">
{% if user.status == "Online" %} {% if user.status == "Online" %}
@ -275,6 +276,70 @@
</table> </table>
{% endif %} {% endif %}
</div> </div>
<div class="card-footer clearfix">
<div class="row">
<div class="col-sm-12 col-md-5 d-flex align-items-center">
<div class="dataTables_info" role="status" aria-live="polite">
Showing {{ ((current_page - 1) * limit) + 1 }} to {{ [((current_page - 1) * limit) + limit, total_users] | min }} of {{ total_users }} users
</div>
</div>
<div class="col-sm-12 col-md-7">
<div class="d-flex justify-content-end align-items-center">
<div class="mr-3">
<form id="limit-form" method="get" class="form-inline">
<label for="limit-select" class="mr-2">Per Page:</label>
<select id="limit-select" class="form-control form-control-sm">
<option value="10">10</option>
<option value="25">25</option>
<option value="50" selected>50</option>
<option value="100">100</option>
</select>
</form>
</div>
{% if total_pages > 1 %}
<nav aria-label="Page navigation">
<ul class="pagination pagination-sm m-0">
<li class="page-item {% if current_page == 1 %}disabled{% endif %}">
<a class="page-link" href="{{ url_for('users_paginated', page=current_page - 1) }}" aria-label="Previous">
<span aria-hidden="true">&laquo;</span>
</a>
</li>
{% set page_start = [1, current_page - 2] | max %}
{% set page_end = [total_pages, current_page + 2] | min %}
{% if page_start > 1 %}
<li class="page-item"><a class="page-link" href="{{ url_for('users_paginated', page=1) }}">1</a></li>
{% if page_start > 2 %}
<li class="page-item disabled"><span class="page-link">...</span></li>
{% endif %}
{% endif %}
{% for page_num in range(page_start, page_end + 1) %}
<li class="page-item {% if page_num == current_page %}active{% endif %}">
<a class="page-link" href="{{ url_for('users_paginated', page=page_num) }}">{{ page_num }}</a>
</li>
{% endfor %}
{% if page_end < total_pages %}
{% if page_end < total_pages - 1 %}
<li class="page-item disabled"><span class="page-link">...</span></li>
{% endif %}
<li class="page-item"><a class="page-link" href="{{ url_for('users_paginated', page=total_pages) }}">{{ total_pages }}</a></li>
{% endif %}
<li class="page-item {% if current_page == total_pages %}disabled{% endif %}">
<a class="page-link" href="{{ url_for('users_paginated', page=current_page + 1) }}" aria-label="Next">
<span aria-hidden="true">&raquo;</span>
</a>
</li>
</ul>
</nav>
{% endif %}
</div>
</div>
</div>
</div>
</div> </div>
</div> </div>
</section> </section>