Merge pull request #307 from ReturnFI/beta
Advanced Node Management & UI Upgrades
This commit is contained in:
35
changelog
35
changelog
@ -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
|
||||||
|
|||||||
22
core/cli.py
22
core/cli.py
@ -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),
|
||||||
|
|||||||
@ -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.
|
||||||
@ -775,4 +792,4 @@ def get_ip_limiter_config() -> dict[str, int | None]:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error reading IP Limiter config from .configs.env: {e}")
|
print(f"Error reading IP Limiter config from .configs.env: {e}")
|
||||||
return {"block_duration": None, "max_ips": None}
|
return {"block_duration": None, "max_ips": None}
|
||||||
# endregion
|
# endregion
|
||||||
@ -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()
|
|
||||||
@ -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", "")
|
local_port = config["listen"].split(":")[-1]
|
||||||
obfs_password = config.get("obfs", {}).get("salamander", {}).get("password", "")
|
local_sha256 = config.get("tls", {}).get("pinSHA256", "")
|
||||||
insecure = config.get("tls", {}).get("insecure", True)
|
local_obfs_password = config.get("obfs", {}).get("salamander", {}).get("password", "")
|
||||||
|
local_insecure = config.get("tls", {}).get("insecure", True)
|
||||||
|
|
||||||
ip4, ip6, sni = load_hysteria2_ips()
|
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"):
|
||||||
|
|||||||
@ -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}"
|
||||||
|
|||||||
7
core/scripts/nodes/init_paths.py
Normal file
7
core/scripts/nodes/init_paths.py
Normal 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
229
core/scripts/nodes/node.py
Normal 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()
|
||||||
@ -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)
|
||||||
@ -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
|
|
||||||
@ -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');
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@ -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 => {
|
||||||
@ -324,6 +346,18 @@ $(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();
|
||||||
});
|
});
|
||||||
@ -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')
|
||||||
@ -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'])
|
||||||
@ -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:
|
||||||
@ -112,4 +121,41 @@ async def delete_node(body: DeleteNodeBody):
|
|||||||
cli_api.delete_node(body.name)
|
cli_api.delete_node(body.name)
|
||||||
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.")
|
||||||
@ -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,10 +48,66 @@ 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
|
||||||
|
|
||||||
class DeleteNodeBody(BaseModel):
|
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]
|
||||||
@ -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)
|
||||||
@ -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>
|
||||||
|
|||||||
@ -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">«</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">»</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
Reference in New Issue
Block a user