From b8e6a5475bab291e1d4df89a4ab3eaf27bdee0b0 Mon Sep 17 00:00:00 2001 From: Whispering Wind <151555003+ReturnFI@users.noreply.github.com> Date: Sat, 3 May 2025 21:49:26 +0330 Subject: [PATCH 01/21] refactor: masquerade management in Python3 --- core/cli_api.py | 6 +- core/scripts/hysteria2/masquerade.py | 93 ++++++++++++++++++++++++++++ core/scripts/hysteria2/masquerade.sh | 40 ------------ 3 files changed, 96 insertions(+), 43 deletions(-) create mode 100644 core/scripts/hysteria2/masquerade.py delete mode 100644 core/scripts/hysteria2/masquerade.sh diff --git a/core/cli_api.py b/core/cli_api.py index 9198aa4..a84262c 100644 --- a/core/cli_api.py +++ b/core/cli_api.py @@ -31,7 +31,7 @@ class Command(Enum): WRAPPER_URI = os.path.join(SCRIPT_DIR, 'hysteria2', 'wrapper_uri.py') IP_ADD = os.path.join(SCRIPT_DIR, 'hysteria2', 'ip.py') MANAGE_OBFS = os.path.join(SCRIPT_DIR, 'hysteria2', 'manage_obfs.py') - MASQUERADE_SCRIPT = os.path.join(SCRIPT_DIR, 'hysteria2', 'masquerade.sh') + MASQUERADE_SCRIPT = os.path.join(SCRIPT_DIR, 'hysteria2', 'masquerade.py') TRAFFIC_STATUS = 'traffic.py' # won't be called directly (it's a python module) UPDATE_GEO = os.path.join(SCRIPT_DIR, 'hysteria2', 'update_geo.py') LIST_USERS = os.path.join(SCRIPT_DIR, 'hysteria2', 'list_users.sh') @@ -211,12 +211,12 @@ def disable_hysteria2_obfs(): def enable_hysteria2_masquerade(domain: str): '''Enables masquerade for Hysteria2.''' - run_cmd(['bash', Command.MASQUERADE_SCRIPT.value, '1', domain]) + run_cmd(['python3', Command.MASQUERADE_SCRIPT.value, '1', domain]) def disable_hysteria2_masquerade(): '''Disables masquerade for Hysteria2.''' - run_cmd(['bash', Command.MASQUERADE_SCRIPT.value, '2']) + run_cmd(['python3', Command.MASQUERADE_SCRIPT.value, '2']) def get_hysteria2_config_file() -> dict[str, Any]: diff --git a/core/scripts/hysteria2/masquerade.py b/core/scripts/hysteria2/masquerade.py new file mode 100644 index 0000000..21155c0 --- /dev/null +++ b/core/scripts/hysteria2/masquerade.py @@ -0,0 +1,93 @@ +import json +import subprocess +import sys +from init_paths import * +from paths import * + + +def is_masquerade_enabled(): + try: + with open(CONFIG_FILE, 'r') as f: + config = json.load(f) + return "masquerade" in config + except Exception as e: + print(f"Error reading config: {e}") + return False + +def enable_masquerade(domain: str): + if is_masquerade_enabled(): + print("Masquerade is already enabled.") + sys.exit(0) + + url = f"https://{domain}" + try: + with open(CONFIG_FILE, 'r') as f: + config = json.load(f) + + config["masquerade"] = { + "type": "proxy", + "proxy": { + "url": url, + "rewriteHost": True + }, + "listenHTTP": ":80", + "listenHTTPS": ":443", + "forceHTTPS": True + } + + with open(CONFIG_FILE, 'w') as f: + json.dump(config, f, indent=2) + + print(f"Masquerade enabled with URL: {url}") + subprocess.run(["python3", CLI_PATH, "restart-hysteria2"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + + except Exception as e: + print(f"Failed to enable masquerade: {e}") + sys.exit(1) + +def remove_masquerade(): + if not is_masquerade_enabled(): + print("Masquerade is not enabled.") + sys.exit(0) + + try: + with open(CONFIG_FILE, 'r') as f: + config = json.load(f) + + config.pop("masquerade", None) + + with open(CONFIG_FILE, 'w') as f: + json.dump(config, f, indent=2) + + print("Masquerade removed from config.json") + subprocess.run(["python3", CLI_PATH, "restart-hysteria2"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + + except Exception as e: + print(f"Failed to remove masquerade: {e}") + sys.exit(1) + +def main(): + if len(sys.argv) < 2: + print("Usage: python3 masquerade.py {1|2} [domain]") + print("1: Enable Masquerade [domain]") + print("2: Remove Masquerade") + sys.exit(1) + + action = sys.argv[1] + + if action == "1": + if len(sys.argv) < 3: + print("Error: Missing domain argument for enabling masquerade.") + sys.exit(1) + domain = sys.argv[2] + print(f"Enabling 'masquerade' with URL: {domain}...") + enable_masquerade(domain) + elif action == "2": + print("Removing 'masquerade' from config.json...") + remove_masquerade() + else: + print("Invalid option. Use 1 to enable or 2 to disable masquerade.") + sys.exit(1) + +if __name__ == "__main__": + main() diff --git a/core/scripts/hysteria2/masquerade.sh b/core/scripts/hysteria2/masquerade.sh deleted file mode 100644 index d6ed54c..0000000 --- a/core/scripts/hysteria2/masquerade.sh +++ /dev/null @@ -1,40 +0,0 @@ -#!/bin/bash -source /etc/hysteria/core/scripts/path.sh - -function is_masquerade_enabled() { - jq -e '.masquerade' $CONFIG_FILE > /dev/null 2>&1 -} - -function enable_masquerade() { - if is_masquerade_enabled; then - echo "Masquerade is already enabled." - exit 0 - fi - url="https://$1" - jq --arg url "$url" '. + {masquerade: {type: "proxy", proxy: {url: $url, rewriteHost: true}, listenHTTP: ":80", listenHTTPS: ":443", forceHTTPS: true}}' $CONFIG_FILE > tmp.json && mv tmp.json $CONFIG_FILE - echo "Masquerade enabled with URL: $url" - python3 "$CLI_PATH" restart-hysteria2 > /dev/null 2>&1 -} - -function remove_masquerade() { - if ! is_masquerade_enabled; then - echo "Masquerade is not enabled." - exit 0 - fi - jq 'del(.masquerade)' $CONFIG_FILE > tmp.json && mv tmp.json $CONFIG_FILE - echo "Masquerade removed from config.json" - python3 "$CLI_PATH" restart-hysteria2 > /dev/null 2>&1 -} - -if [[ "$1" == "1" ]]; then - echo "Enabling 'masquerade' with URL: $2..." - enable_masquerade "$2" -elif [[ "$1" == "2" ]]; then - echo "Removing 'masquerade' from config.json..." - remove_masquerade -else - echo "Usage: $0 {1|2} [domain]" - echo "1: Enable Masquerade [domain]" - echo "2: Remove Masquerade" - exit 1 -fi From ce4d23d18ea1ed4a0435e818ca92667a9a1343df Mon Sep 17 00:00:00 2001 From: Whispering Wind <151555003+ReturnFI@users.noreply.github.com> Date: Mon, 5 May 2025 23:31:14 +0330 Subject: [PATCH 02/21] Refactor: Implement TCP Brutal installation in Python --- core/cli_api.py | 4 ++-- core/scripts/tcp-brutal/install.py | 15 +++++++++++++++ core/scripts/tcp-brutal/install.sh | 6 ------ 3 files changed, 17 insertions(+), 8 deletions(-) create mode 100644 core/scripts/tcp-brutal/install.py delete mode 100644 core/scripts/tcp-brutal/install.sh diff --git a/core/cli_api.py b/core/cli_api.py index a84262c..c415d4d 100644 --- a/core/cli_api.py +++ b/core/cli_api.py @@ -42,7 +42,7 @@ class Command(Enum): SHELL_SINGBOX = os.path.join(SCRIPT_DIR, 'singbox', 'singbox_shell.sh') SHELL_WEBPANEL = os.path.join(SCRIPT_DIR, 'webpanel', 'webpanel_shell.sh') INSTALL_NORMALSUB = os.path.join(SCRIPT_DIR, 'normalsub', 'normalsub.sh') - INSTALL_TCP_BRUTAL = os.path.join(SCRIPT_DIR, 'tcp-brutal', 'install.sh') + INSTALL_TCP_BRUTAL = os.path.join(SCRIPT_DIR, 'tcp-brutal', 'install.py') INSTALL_WARP = os.path.join(SCRIPT_DIR, 'warp', 'install.py') UNINSTALL_WARP = os.path.join(SCRIPT_DIR, 'warp', 'uninstall.py') CONFIGURE_WARP = os.path.join(SCRIPT_DIR, 'warp', 'configure.py') @@ -431,7 +431,7 @@ def update_geo(country: str): def install_tcp_brutal(): '''Installs TCP Brutal.''' - run_cmd(['bash', Command.INSTALL_TCP_BRUTAL.value]) + run_cmd(['python3', Command.INSTALL_TCP_BRUTAL.value]) def install_warp(): diff --git a/core/scripts/tcp-brutal/install.py b/core/scripts/tcp-brutal/install.py new file mode 100644 index 0000000..65785b0 --- /dev/null +++ b/core/scripts/tcp-brutal/install.py @@ -0,0 +1,15 @@ +#!/usr/bin/env python3 + +import os +import subprocess + +def main(): + print("Installing TCP Brutal...") + + subprocess.run("bash -c \"$(curl -fsSL https://tcp.hy2.sh/)\"", shell=True) + os.system('clear') + + print("TCP Brutal installation complete.") + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/core/scripts/tcp-brutal/install.sh b/core/scripts/tcp-brutal/install.sh deleted file mode 100644 index 356fd70..0000000 --- a/core/scripts/tcp-brutal/install.sh +++ /dev/null @@ -1,6 +0,0 @@ -#!/bin/bash -echo "Installing TCP Brutal..." -bash <(curl -fsSL https://tcp.hy2.sh/) -sleep 3 -clear -echo "TCP Brutal installation complete." \ No newline at end of file From 24338dfe1f4179a37ffd3d67374c93d30c0cbaec Mon Sep 17 00:00:00 2001 From: Whispering Wind <151555003+ReturnFI@users.noreply.github.com> Date: Mon, 5 May 2025 23:55:36 +0330 Subject: [PATCH 03/21] Refactor: Implement Change SNI in Python --- core/cli_api.py | 4 +- core/scripts/hysteria2/change_sni.py | 212 +++++++++++++++++++++++++++ core/scripts/hysteria2/change_sni.sh | 134 ----------------- 3 files changed, 214 insertions(+), 136 deletions(-) create mode 100644 core/scripts/hysteria2/change_sni.py delete mode 100644 core/scripts/hysteria2/change_sni.sh diff --git a/core/cli_api.py b/core/cli_api.py index c415d4d..1d61c66 100644 --- a/core/cli_api.py +++ b/core/cli_api.py @@ -21,7 +21,7 @@ class Command(Enum): UPDATE_HYSTERIA2 = os.path.join(SCRIPT_DIR, 'hysteria2', 'update.py') RESTART_HYSTERIA2 = os.path.join(SCRIPT_DIR, 'hysteria2', 'restart.py') CHANGE_PORT_HYSTERIA2 = os.path.join(SCRIPT_DIR, 'hysteria2', 'change_port.py') - CHANGE_SNI_HYSTERIA2 = os.path.join(SCRIPT_DIR, 'hysteria2', 'change_sni.sh') + CHANGE_SNI_HYSTERIA2 = os.path.join(SCRIPT_DIR, 'hysteria2', 'change_sni.py') GET_USER = os.path.join(SCRIPT_DIR, 'hysteria2', 'get_user.py') ADD_USER = os.path.join(SCRIPT_DIR, 'hysteria2', 'add_user.py') EDIT_USER = os.path.join(SCRIPT_DIR, 'hysteria2', 'edit_user.sh') @@ -176,7 +176,7 @@ def change_hysteria2_sni(sni: str): ''' Changes the SNI for Hysteria2. ''' - run_cmd(['bash', Command.CHANGE_SNI_HYSTERIA2.value, sni]) + run_cmd(['python3', Command.CHANGE_SNI_HYSTERIA2.value, sni]) def backup_hysteria2(): diff --git a/core/scripts/hysteria2/change_sni.py b/core/scripts/hysteria2/change_sni.py new file mode 100644 index 0000000..5b929a4 --- /dev/null +++ b/core/scripts/hysteria2/change_sni.py @@ -0,0 +1,212 @@ +#!/usr/bin/env python3 + +import os +import sys +import json +import time +import subprocess +import socket +from pathlib import Path +from init_paths import * +from paths import * + +def run_command(command, capture_output=True, shell=True): + """Run a shell command and return its output""" + result = subprocess.run( + command, + shell=shell, + capture_output=capture_output, + text=True + ) + if capture_output: + return result.stdout.strip() + return None + +def get_ip_from_domain(domain): + """Get the first IPv4 address from a domain using dig""" + try: + output = run_command(f"dig +short {domain} A | head -n 1") + if output and is_valid_ipv4(output): + return output + except: + pass + return None + +def is_valid_ipv4(ip): + """Check if a string is a valid IPv4 address""" + try: + socket.inet_pton(socket.AF_INET, ip) + return True + except (socket.error, ValueError): + return False + +def get_server_ip(): + """Get the server's public IP address""" + return run_command("curl -s -4 ifconfig.me") + +def update_sni(sni): + if not sni: + print("Invalid SNI. Please provide a valid SNI.") + print(f"Example: {sys.argv[0]} yourdomain.com") + return 1 + + if os.path.isfile(CONFIG_ENV): + env_vars = {} + with open(CONFIG_ENV, 'r') as f: + for line in f: + if '=' in line: + name, value = line.strip().split('=', 1) + env_vars[name] = value + else: + print(f"Error: Config file {CONFIG_ENV} not found.") + return 1 + + server_ip = None + if 'IP4' in env_vars: + ip4 = env_vars['IP4'] + if is_valid_ipv4(ip4): + server_ip = ip4 + print(f"Using server IP from config: {server_ip}") + else: + domain_ip = get_ip_from_domain(ip4) + if domain_ip: + server_ip = domain_ip + print(f"Resolved domain {ip4} to IP: {server_ip}") + else: + server_ip = get_server_ip() + print(f"Could not resolve domain {ip4}. Using auto-detected server IP: {server_ip}") + else: + server_ip = get_server_ip() + print(f"Using auto-detected server IP: {server_ip}") + + print(f"Checking if {sni} points to this server ({server_ip})...") + domain_ip = get_ip_from_domain(sni) + + use_certbot = False + if not domain_ip: + print(f"Warning: Could not resolve {sni} to an IPv4 address.") + elif domain_ip == server_ip: + print(f"Success: {sni} correctly points to this server ({server_ip}).") + use_certbot = True + else: + print(f"Notice: {sni} points to {domain_ip}, not to this server ({server_ip}).") + + os.chdir('/etc/hysteria/') + + if use_certbot: + print(f"Using certbot to obtain a valid certificate for {sni}...") + + certbot_output = run_command(f"certbot certificates") + if sni in certbot_output: + print(f"Certificate for {sni} already exists. Renewing...") + run_command(f"certbot renew --cert-name {sni}", capture_output=False) + else: + print(f"Requesting new certificate for {sni}...") + run_command(f"certbot certonly --standalone -d {sni} --non-interactive --agree-tos --email admin@{sni}", + capture_output=False) + + run_command(f"cp /etc/letsencrypt/live/{sni}/fullchain.pem /etc/hysteria/ca.crt", capture_output=False) + run_command(f"cp /etc/letsencrypt/live/{sni}/privkey.pem /etc/hysteria/ca.key", capture_output=False) + + print("Certificates successfully installed from Let's Encrypt.") + + if os.path.isfile(CONFIG_FILE): + with open(CONFIG_FILE, 'r') as f: + config = json.load(f) + + config['tls']['insecure'] = False + + with open(CONFIG_FILE, 'w') as f: + json.dump(config, f, indent=2) + + print(f"TLS insecure flag set to false in {CONFIG_FILE}") + else: + print(f"Using self-signed certificate with openssl for {sni}...") + + if os.path.exists("ca.key"): + os.remove("ca.key") + if os.path.exists("ca.crt"): + os.remove("ca.crt") + + print(f"Generating CA key and certificate for SNI: {sni} ...") + run_command("openssl ecparam -genkey -name prime256v1 -out ca.key > /dev/null 2>&1", capture_output=False) + run_command(f"openssl req -new -x509 -days 36500 -key ca.key -out ca.crt -subj '/CN={sni}' > /dev/null 2>&1", + capture_output=False) + print(f"Self-signed certificate generated for {sni}") + + if os.path.isfile(CONFIG_FILE): + with open(CONFIG_FILE, 'r') as f: + config = json.load(f) + + config['tls']['insecure'] = True + + with open(CONFIG_FILE, 'w') as f: + json.dump(config, f, indent=2) + + print(f"TLS insecure flag set to true in {CONFIG_FILE}") + + run_command("chown hysteria:hysteria /etc/hysteria/ca.key /etc/hysteria/ca.crt", capture_output=False) + run_command("chmod 640 /etc/hysteria/ca.key /etc/hysteria/ca.crt", capture_output=False) + + sha256 = run_command( + "openssl x509 -noout -fingerprint -sha256 -inform pem -in ca.crt | sed 's/.*=//;s///g'" + ) + print(f"SHA-256 fingerprint generated: {sha256}") + + if os.path.isfile(CONFIG_FILE): + with open(CONFIG_FILE, 'r') as f: + config = json.load(f) + + config['tls']['pinSHA256'] = sha256 + + with open(CONFIG_FILE, 'w') as f: + json.dump(config, f, indent=2) + + print(f"SHA-256 updated successfully in {CONFIG_FILE}") + else: + print(f"Error: Config file {CONFIG_FILE} not found.") + return 1 + + sni_found = False + if os.path.isfile(CONFIG_ENV): + with open(CONFIG_ENV, 'r') as f: + lines = f.readlines() + + with open(CONFIG_ENV, 'w') as f: + for line in lines: + if line.startswith('SNI='): + f.write(f'SNI={sni}\n') + sni_found = True + else: + f.write(line) + + if not sni_found: + f.write(f'SNI={sni}\n') + print(f"Added new SNI entry to {CONFIG_ENV}") + else: + print(f"SNI updated successfully in {CONFIG_ENV}") + else: + with open(CONFIG_ENV, 'w') as f: + f.write(f'SNI={sni}\n') + print(f"Created {CONFIG_ENV} with new SNI.") + + run_command(f"python3 {CLI_PATH} restart-hysteria2 > /dev/null 2>&1", capture_output=False) + print(f"Hysteria2 restarted successfully with new SNI: {sni}.") + + if use_certbot: + print(f"✅ Valid Let's Encrypt certificate installed for {sni}") + print(" TLS insecure mode is now DISABLED") + else: + print(f"⚠️ Self-signed certificate installed for {sni}") + print(" TLS insecure mode is now ENABLED") + print(" (This certificate won't be trusted by browsers)") + + return 0 + +if __name__ == "__main__": + if len(sys.argv) < 2: + print(f"Usage: {sys.argv[0]} ") + sys.exit(1) + + sni = sys.argv[1] + sys.exit(update_sni(sni)) \ No newline at end of file diff --git a/core/scripts/hysteria2/change_sni.sh b/core/scripts/hysteria2/change_sni.sh deleted file mode 100644 index 6f835d1..0000000 --- a/core/scripts/hysteria2/change_sni.sh +++ /dev/null @@ -1,134 +0,0 @@ -#!/bin/bash - -source /etc/hysteria/core/scripts/path.sh - -sni="$1" - -if [ -f "$CONFIG_ENV" ]; then - source "$CONFIG_ENV" -else - echo "Error: Config file $CONFIG_ENV not found." - exit 1 -fi - -update_sni() { - local sni=$1 - local server_ip - - if [ -z "$sni" ]; then - echo "Invalid SNI. Please provide a valid SNI." - echo "Example: $0 yourdomain.com" - return 1 - fi - - if [ -n "$IP4" ]; then - if [[ $IP4 =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]]; then - server_ip="$IP4" - echo "Using server IP from config: $server_ip" - else - domain_ip=$(dig +short "$IP4" A | head -n 1) - if [ -n "$domain_ip" ]; then - server_ip="$domain_ip" - echo "Resolved domain $IP4 to IP: $server_ip" - else - server_ip=$(curl -s -4 ifconfig.me) - echo "Could not resolve domain $IP4. Using auto-detected server IP: $server_ip" - fi - fi - else - server_ip=$(curl -s -4 ifconfig.me) - echo "Using auto-detected server IP: $server_ip" - fi - - echo "Checking if $sni points to this server ($server_ip)..." - domain_ip=$(dig +short "$sni" A | head -n 1) - - if [ -z "$domain_ip" ]; then - echo "Warning: Could not resolve $sni to an IPv4 address." - use_certbot=false - elif [ "$domain_ip" = "$server_ip" ]; then - echo "Success: $sni correctly points to this server ($server_ip)." - use_certbot=true - else - echo "Notice: $sni points to $domain_ip, not to this server ($server_ip)." - use_certbot=false - fi - - cd /etc/hysteria/ || exit - - if [ "$use_certbot" = true ]; then - echo "Using certbot to obtain a valid certificate for $sni..." - - if certbot certificates | grep -q "$sni"; then - echo "Certificate for $sni already exists. Renewing..." - certbot renew --cert-name "$sni" - else - echo "Requesting new certificate for $sni..." - certbot certonly --standalone -d "$sni" --non-interactive --agree-tos --email admin@"$sni" - fi - - cp /etc/letsencrypt/live/"$sni"/fullchain.pem /etc/hysteria/ca.crt - cp /etc/letsencrypt/live/"$sni"/privkey.pem /etc/hysteria/ca.key - - echo "Certificates successfully installed from Let's Encrypt." - - if [ -f "$CONFIG_FILE" ]; then - jq '.tls.insecure = false' "$CONFIG_FILE" > "${CONFIG_FILE}.temp" && mv "${CONFIG_FILE}.temp" "$CONFIG_FILE" - echo "TLS insecure flag set to false in $CONFIG_FILE" - fi - else - echo "Using self-signed certificate with openssl for $sni..." - rm -f ca.key ca.crt - - echo "Generating CA key and certificate for SNI: $sni ..." - openssl ecparam -genkey -name prime256v1 -out ca.key >/dev/null 2>&1 - openssl req -new -x509 -days 36500 -key ca.key -out ca.crt -subj "/CN=$sni" >/dev/null 2>&1 - echo "Self-signed certificate generated for $sni" - - if [ -f "$CONFIG_FILE" ]; then - jq '.tls.insecure = true' "$CONFIG_FILE" > "${CONFIG_FILE}.temp" && mv "${CONFIG_FILE}.temp" "$CONFIG_FILE" - echo "TLS insecure flag set to true in $CONFIG_FILE" - fi - fi - - chown hysteria:hysteria /etc/hysteria/ca.key /etc/hysteria/ca.crt - chmod 640 /etc/hysteria/ca.key /etc/hysteria/ca.crt - - sha256=$(openssl x509 -noout -fingerprint -sha256 -inform pem -in ca.crt | sed 's/.*=//;s///g') - echo "SHA-256 fingerprint generated: $sha256" - - if [ -f "$CONFIG_FILE" ]; then - jq --arg sha256 "$sha256" '.tls.pinSHA256 = $sha256' "$CONFIG_FILE" > "${CONFIG_FILE}.temp" && mv "${CONFIG_FILE}.temp" "$CONFIG_FILE" - echo "SHA-256 updated successfully in $CONFIG_FILE" - else - echo "Error: Config file $CONFIG_FILE not found." - return 1 - fi - - if [ -f "$CONFIG_ENV" ]; then - if grep -q "^SNI=" "$CONFIG_ENV"; then - sed -i "s/^SNI=.*$/SNI=$sni/" "$CONFIG_ENV" - echo "SNI updated successfully in $CONFIG_ENV" - else - echo "SNI=$sni" >> "$CONFIG_ENV" - echo "Added new SNI entry to $CONFIG_ENV" - fi - else - echo "SNI=$sni" > "$CONFIG_ENV" - echo "Created $CONFIG_ENV with new SNI." - fi - - python3 "$CLI_PATH" restart-hysteria2 > /dev/null 2>&1 - echo "Hysteria2 restarted successfully with new SNI: $sni." - - if [ "$use_certbot" = true ]; then - echo "✅ Valid Let's Encrypt certificate installed for $sni" - echo " TLS insecure mode is now DISABLED" - else - echo "⚠️ Self-signed certificate installed for $sni" - echo " TLS insecure mode is now ENABLED" - echo " (This certificate won't be trusted by browsers)" - fi -} - -update_sni "$sni" \ No newline at end of file From 1b3f9ee351a5e0a65772148ae7d19f1231fe0974 Mon Sep 17 00:00:00 2001 From: Whispering Wind <151555003+ReturnFI@users.noreply.github.com> Date: Tue, 6 May 2025 00:23:13 +0330 Subject: [PATCH 04/21] Refactor: Implement Hysteria Restore in Python --- core/cli_api.py | 4 +- core/scripts/hysteria2/restore.py | 164 ++++++++++++++++++++++++++++++ core/scripts/hysteria2/restore.sh | 150 --------------------------- 3 files changed, 166 insertions(+), 152 deletions(-) create mode 100644 core/scripts/hysteria2/restore.py delete mode 100644 core/scripts/hysteria2/restore.sh diff --git a/core/cli_api.py b/core/cli_api.py index 1d61c66..44cfd6f 100644 --- a/core/cli_api.py +++ b/core/cli_api.py @@ -37,7 +37,7 @@ class Command(Enum): LIST_USERS = os.path.join(SCRIPT_DIR, 'hysteria2', 'list_users.sh') SERVER_INFO = os.path.join(SCRIPT_DIR, 'hysteria2', 'server_info.py') BACKUP_HYSTERIA2 = os.path.join(SCRIPT_DIR, 'hysteria2', 'backup.py') - RESTORE_HYSTERIA2 = os.path.join(SCRIPT_DIR, 'hysteria2', 'restore.sh') + RESTORE_HYSTERIA2 = os.path.join(SCRIPT_DIR, 'hysteria2', 'restore.py') INSTALL_TELEGRAMBOT = os.path.join(SCRIPT_DIR, 'telegrambot', 'runbot.py') SHELL_SINGBOX = os.path.join(SCRIPT_DIR, 'singbox', 'singbox_shell.sh') SHELL_WEBPANEL = os.path.join(SCRIPT_DIR, 'webpanel', 'webpanel_shell.sh') @@ -192,7 +192,7 @@ def backup_hysteria2(): def restore_hysteria2(backup_file_path: str): '''Restores Hysteria configuration from the given backup file.''' try: - run_cmd(['bash', Command.RESTORE_HYSTERIA2.value, backup_file_path]) + run_cmd(['python3', Command.RESTORE_HYSTERIA2.value, backup_file_path]) except subprocess.CalledProcessError as e: raise Exception(f"Restore failed: {e}") except Exception as ex: diff --git a/core/scripts/hysteria2/restore.py b/core/scripts/hysteria2/restore.py new file mode 100644 index 0000000..a8a85b7 --- /dev/null +++ b/core/scripts/hysteria2/restore.py @@ -0,0 +1,164 @@ +#!/usr/bin/env python3 + +import os +import sys +import json +import shutil +import zipfile +import tempfile +import subprocess +import datetime +from pathlib import Path +from init_paths import * +from paths import * + +def run_command(command, capture_output=True, check=False): + """Run a shell command and return its output""" + result = subprocess.run( + command, + shell=True, + capture_output=capture_output, + text=True, + check=check + ) + if capture_output: + return result.returncode, result.stdout.strip() + return result.returncode, None + +def main(): + if len(sys.argv) < 2: + print("Error: Backup file path is required.") + return 1 + + backup_zip_file = sys.argv[1] + + if not os.path.isfile(backup_zip_file): + print(f"Error: Backup file not found: {backup_zip_file}") + return 1 + + if not backup_zip_file.lower().endswith('.zip'): + print("Error: Backup file must be a .zip file.") + return 1 + + timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S") + restore_dir = f"/tmp/hysteria_restore_{timestamp}" + target_dir = "/etc/hysteria" + + try: + os.makedirs(restore_dir, exist_ok=True) + + try: + with zipfile.ZipFile(backup_zip_file) as zf: + zf.testzip() + zf.extractall(restore_dir) + except zipfile.BadZipFile: + print("Error: Invalid ZIP file.") + return 1 + except Exception as e: + print(f"Error: Could not extract the ZIP file: {e}") + return 1 + + expected_files = [ + "ca.key", + "ca.crt", + "users.json", + "config.json", + ".configs.env" + ] + + for file in expected_files: + file_path = os.path.join(restore_dir, file) + if not os.path.isfile(file_path): + print(f"Error: Required file '{file}' is missing from the backup.") + return 1 + + existing_backup_dir = f"/opt/hysbackup/restore_pre_backup_{timestamp}" + os.makedirs(existing_backup_dir, exist_ok=True) + + for file in expected_files: + source_file = os.path.join(target_dir, file) + dest_file = os.path.join(existing_backup_dir, file) + + if os.path.isfile(source_file): + try: + shutil.copy2(source_file, dest_file) + except Exception as e: + print(f"Error creating backup file before restore from '{source_file}': {e}") + return 1 + + for file in expected_files: + source_file = os.path.join(restore_dir, file) + dest_file = os.path.join(target_dir, file) + + try: + shutil.copy2(source_file, dest_file) + except Exception as e: + print(f"Error: replace Configuration Files '{file}': {e}") + shutil.rmtree(existing_backup_dir, ignore_errors=True) + return 1 + + config_file = os.path.join(target_dir, "config.json") + + if os.path.isfile(config_file): + print("Checking and adjusting config.json based on system state...") + + ret_code, networkdef = run_command("ip route | grep '^default' | awk '{print $5}'") + networkdef = networkdef.strip() + + if networkdef: + with open(config_file, 'r') as f: + config = json.load(f) + + for outbound in config.get('outbounds', []): + if outbound.get('name') == 'v4' and 'direct' in outbound: + current_v4_device = outbound['direct'].get('bindDevice', '') + + if current_v4_device != networkdef: + print(f"Updating v4 outbound bindDevice from '{current_v4_device}' to '{networkdef}'...") + outbound['direct']['bindDevice'] = networkdef + + with open(config_file, 'w') as f: + json.dump(config, f, indent=2) + + ret_code, _ = run_command("systemctl is-active --quiet wg-quick@wgcf.service", capture_output=False) + + if ret_code != 0: + print("wgcf service is NOT active. Removing warps outbound and any ACL rules...") + + with open(config_file, 'r') as f: + config = json.load(f) + + config['outbounds'] = [outbound for outbound in config.get('outbounds', []) + if outbound.get('name') != 'warps'] + + if 'acl' in config and 'inline' in config['acl']: + config['acl']['inline'] = [rule for rule in config['acl']['inline'] + if not rule.startswith('warps(')] + + with open(config_file, 'w') as f: + json.dump(config, f, indent=2) + + run_command("chown hysteria:hysteria /etc/hysteria/ca.key /etc/hysteria/ca.crt", + capture_output=False) + run_command("chmod 640 /etc/hysteria/ca.key /etc/hysteria/ca.crt", + capture_output=False) + + ret_code, _ = run_command(f"python3 {CLI_PATH} restart-hysteria2", capture_output=False) + + if ret_code != 0: + print("Error: Restart service failed.") + return 1 + + print("Hysteria configuration restored and updated successfully.") + return 0 + + except Exception as e: + print(f"An unexpected error occurred: {e}") + return 1 + finally: + shutil.rmtree(restore_dir, ignore_errors=True) + if 'existing_backup_dir' in locals(): + shutil.rmtree(existing_backup_dir, ignore_errors=True) + +if __name__ == "__main__": + sys.exit(main()) \ No newline at end of file diff --git a/core/scripts/hysteria2/restore.sh b/core/scripts/hysteria2/restore.sh deleted file mode 100644 index c581103..0000000 --- a/core/scripts/hysteria2/restore.sh +++ /dev/null @@ -1,150 +0,0 @@ -#!/bin/bash -source /etc/hysteria/core/scripts/path.sh - -# Usage: ./restore.sh - -set -e - -BACKUP_ZIP_FILE="$1" -RESTORE_DIR="/tmp/hysteria_restore_$(date +%Y%m%d_%H%M%S)" -TARGET_DIR="/etc/hysteria" - -if [ -z "$BACKUP_ZIP_FILE" ]; then - echo "Error: Backup file path is required." - exit 1 -fi - -if [ ! -f "$BACKUP_ZIP_FILE" ]; then - echo "Error: Backup file not found: $BACKUP_ZIP_FILE" - exit 1 -fi - -if [[ "$BACKUP_ZIP_FILE" != *.zip ]]; then - echo "Error: Backup file must be a .zip file." - exit 1 -fi - -mkdir -p "$RESTORE_DIR" - -unzip -l "$BACKUP_ZIP_FILE" >/dev/null 2>&1 -if [ $? -ne 0 ]; then - echo "Error: Invalid ZIP file." - rm -rf "$RESTORE_DIR" - exit 1 -fi - -unzip -o "$BACKUP_ZIP_FILE" -d "$RESTORE_DIR" >/dev/null 2>&1 -if [ $? -ne 0 ]; then - echo "Error: Could not extract the ZIP file." - rm -rf "$RESTORE_DIR" - exit 1 -fi - -expected_files=( - "ca.key" - "ca.crt" - "users.json" - "config.json" - ".configs.env" -) - -for file in "${expected_files[@]}"; do - if [ ! -f "$RESTORE_DIR/$file" ]; then - echo "Error: Required file '$file' is missing from the backup." - rm -rf "$RESTORE_DIR" - exit 1 - fi - if [ ! -f "$RESTORE_DIR/$file" ]; then - echo "Error: '$file' in the backup is not a regular file." - rm -rf "$RESTORE_DIR" - exit 1 - fi -done - -timestamp=$(date +%Y%m%d_%H%M%S) -existing_backup_dir="/opt/hysbackup/restore_pre_backup_$timestamp" -mkdir -p "$existing_backup_dir" -for file in "${expected_files[@]}"; do - if [ -f "$TARGET_DIR/$file" ]; then - cp -p "$TARGET_DIR/$file" "$existing_backup_dir/$file" - if [ $? -ne 0 ]; then - echo "Error creating backup file before restore from '$TARGET_DIR/$file'." - exit 1 - fi - fi -done - -for file in "${expected_files[@]}"; do - cp -p "$RESTORE_DIR/$file" "$TARGET_DIR/$file" - if [ $? -ne 0 ]; then - echo "Error: replace Configuration Files '$file'." - rm -rf "$existing_backup_dir" - rm -rf "$RESTORE_DIR" - exit 1 - fi -done - - -CONFIG_FILE="$TARGET_DIR/config.json" - -if [ -f "$CONFIG_FILE" ]; then - echo "Checking and adjusting config.json based on system state..." - - networkdef=$(ip route | grep "^default" | awk '{print $5}') - - if [ -n "$networkdef" ]; then - current_v4_device=$(jq -r '.outbounds[] | select(.name=="v4") | .direct.bindDevice' "$CONFIG_FILE") - - if [ "$current_v4_device" != "$networkdef" ]; then - echo "Updating v4 outbound bindDevice from '$current_v4_device' to '$networkdef'..." - - tmpfile=$(mktemp) - jq --arg newdev "$networkdef" ' - .outbounds = (.outbounds | map( - if .name == "v4" then - .direct.bindDevice = $newdev - else - . - end - )) - ' "$CONFIG_FILE" > "$tmpfile" - - cat "$tmpfile" > "$CONFIG_FILE" - rm -f "$tmpfile" - fi - fi - - if ! systemctl is-active --quiet wg-quick@wgcf.service; then - echo "wgcf service is NOT active. Removing warps outbound and any ACL rules..." - - tmpfile=$(mktemp) - jq ' - .outbounds = (.outbounds | map(select(.name != "warps"))) | - .acl.inline = (.acl.inline | map( - select(test("^warps\\(") | not) - )) - ' "$CONFIG_FILE" > "$tmpfile" - - cat "$tmpfile" > "$CONFIG_FILE" - rm -f "$tmpfile" - fi -fi - -rm -rf "$RESTORE_DIR" -echo "Hysteria configuration restored and updated successfully." - -chown hysteria:hysteria /etc/hysteria/ca.key /etc/hysteria/ca.crt -chmod 640 /etc/hysteria/ca.key /etc/hysteria/ca.crt - -python3 "$CLI_PATH" restart-hysteria2 > /dev/null 2>&1 -if [ $? -ne 0 ]; then - echo "Error: Restart service failed'." - rm -rf "$existing_backup_dir" - exit 1 -fi - -if [[ "$existing_backup_dir" != "" ]]; then - rm -rf "$existing_backup_dir" -fi - -exit 0 \ No newline at end of file From 5364b29fb2843b20f0599d30d3f559fa1e68d9f9 Mon Sep 17 00:00:00 2001 From: Whispering Wind <151555003+ReturnFI@users.noreply.github.com> Date: Tue, 6 May 2025 19:06:34 +0330 Subject: [PATCH 05/21] Update OS --- install.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/install.sh b/install.sh index 64f9628..07f402d 100644 --- a/install.sh +++ b/install.sh @@ -16,10 +16,10 @@ check_os_version() { fi if [[ "$os_name" == "ubuntu" && $(echo "$os_version >= 22" | bc) -eq 1 ]] || - [[ "$os_name" == "debian" && $(echo "$os_version >= 11" | bc) -eq 1 ]]; then + [[ "$os_name" == "debian" && $(echo "$os_version >= 12" | bc) -eq 1 ]]; then return 0 else - echo "This script is only supported on Ubuntu 22+ or Debian 11+." + echo "This script is only supported on Ubuntu 22+ or Debian 12+." exit 1 fi } From 0a05c0897cbfab7d5bbb503e0f9bd20dc4a60f76 Mon Sep 17 00:00:00 2001 From: Whispering Wind <151555003+ReturnFI@users.noreply.github.com> Date: Tue, 6 May 2025 19:34:32 +0330 Subject: [PATCH 06/21] Enhance setup script with robust error handling and improved UX - Add comprehensive error handling with detailed failure reporting - Implement color-coded terminal output for better readability - Replace emoji with terminal-compatible UTF-8 symbols - Redirect verbose output to /dev/null for cleaner installation experience - Organize code into logical functions with proper error checking - Add interactive prompts for existing installations - Improve OS version detection and dependency management - Include proper exit codes and status reporting throughout - Add short pause before menu launch for better UX flow --- install.sh | 213 ++++++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 170 insertions(+), 43 deletions(-) diff --git a/install.sh b/install.sh index 07f402d..b1497a4 100644 --- a/install.sh +++ b/install.sh @@ -1,70 +1,197 @@ #!/bin/bash +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[0;33m' +BLUE='\033[1;94m' +NC='\033[0m' +BOLD='\033[1m' + +CHECK_MARK="[✓]" +CROSS_MARK="[✗]" +INFO_MARK="[i]" +WARNING_MARK="[!]" + +log_info() { + echo -e "${BLUE}${INFO_MARK} ${1}${NC}" +} + +log_success() { + echo -e "${GREEN}${CHECK_MARK} ${1}${NC}" +} + +log_warning() { + echo -e "${YELLOW}${WARNING_MARK} ${1}${NC}" +} + +log_error() { + echo -e "${RED}${CROSS_MARK} ${1}${NC}" >&2 +} + +handle_error() { + log_error "Error occurred at line $1" + exit 1 +} + +trap 'handle_error $LINENO' ERR + +check_root() { + if [ "$(id -u)" -ne 0 ]; then + log_error "This script must be run as root." + exit 1 + fi + log_info "Running with root privileges" +} + check_os_version() { local os_name os_version + log_info "Checking OS compatibility..." + if [ -f /etc/os-release ]; then os_name=$(grep '^ID=' /etc/os-release | cut -d= -f2) os_version=$(grep '^VERSION_ID=' /etc/os-release | cut -d= -f2 | tr -d '"') else - echo "Unsupported OS or unable to determine OS version." + log_error "Unsupported OS or unable to determine OS version." exit 1 fi if ! command -v bc &> /dev/null; then - apt update && apt install -y bc + log_info "Installing bc package..." + apt update -qq &> /dev/null && apt install -y -qq bc &> /dev/null + if [ $? -ne 0 ]; then + log_error "Failed to install bc package." + exit 1 + fi fi if [[ "$os_name" == "ubuntu" && $(echo "$os_version >= 22" | bc) -eq 1 ]] || [[ "$os_name" == "debian" && $(echo "$os_version >= 12" | bc) -eq 1 ]]; then + log_success "OS check passed: $os_name $os_version" return 0 else - echo "This script is only supported on Ubuntu 22+ or Debian 12+." + log_error "This script is only supported on Ubuntu 22+ or Debian 12+." exit 1 fi } -if [ "$(id -u)" -ne 0 ]; then - echo "This script must be run as root." - exit 1 -fi - -check_os_version - -REQUIRED_PACKAGES=("jq" "qrencode" "curl" "pwgen" "python3" "python3-pip" "python3-venv" "git" "bc" "zip" "cron" "lsof") -MISSING_PACKAGES=() -heavy_checkmark=$(printf "\xE2\x9C\x85") - -for package in "${REQUIRED_PACKAGES[@]}"; do - if ! command -v "$package" &> /dev/null; then - MISSING_PACKAGES+=("$package") - else - echo "Install $package $heavy_checkmark" - fi -done - -if [ ${#MISSING_PACKAGES[@]} -ne 0 ]; then - echo "The following packages are missing and will be installed: ${MISSING_PACKAGES[@]}" - apt update -qq && apt upgrade -y -qq - for package in "${MISSING_PACKAGES[@]}"; do - apt install -y -qq "$package" &> /dev/null && echo "Install $package $heavy_checkmark" +install_packages() { + local REQUIRED_PACKAGES=("jq" "qrencode" "curl" "pwgen" "python3" "python3-pip" "python3-venv" "git" "bc" "zip" "cron" "lsof") + local MISSING_PACKAGES=() + + log_info "Checking required packages..." + + for package in "${REQUIRED_PACKAGES[@]}"; do + if ! command -v "$package" &> /dev/null; then + MISSING_PACKAGES+=("$package") + else + log_success "Package $package is already installed" + fi done -else - echo "All required packages are already installed." -fi -git clone https://github.com/ReturnFI/Blitz /etc/hysteria + if [ ${#MISSING_PACKAGES[@]} -ne 0 ]; then + log_info "Installing missing packages: ${MISSING_PACKAGES[*]}" + apt update -qq &> /dev/null || { log_error "Failed to update apt repositories"; exit 1; } + apt upgrade -y -qq &> /dev/null || { log_warning "Failed to upgrade packages, continuing..."; } + + for package in "${MISSING_PACKAGES[@]}"; do + log_info "Installing $package..." + if apt install -y -qq "$package" &> /dev/null; then + log_success "Installed $package" + else + log_error "Failed to install $package" + exit 1 + fi + done + else + log_success "All required packages are already installed." + fi +} -cd /etc/hysteria -python3 -m venv hysteria2_venv -source /etc/hysteria/hysteria2_venv/bin/activate -pip install -r requirements.txt &> /dev/null && echo "Install Python requirements ✅" +clone_repository() { + log_info "Cloning Hysteria repository..." + + if [ -d "/etc/hysteria" ]; then + log_warning "Directory /etc/hysteria already exists." + read -p "Do you want to remove it and clone again? (y/n): " -n 1 -r + echo + if [[ $REPLY =~ ^[Yy]$ ]]; then + rm -rf /etc/hysteria + else + log_info "Using existing directory." + return 0 + fi + fi + + if git clone https://github.com/ReturnFI/Blitz /etc/hysteria &> /dev/null; then + log_success "Repository cloned successfully" + else + log_error "Failed to clone repository" + exit 1 + fi +} -if ! grep -q "alias hys2='source /etc/hysteria/hysteria2_venv/bin/activate && /etc/hysteria/menu.sh'" ~/.bashrc; then - echo "alias hys2='source /etc/hysteria/hysteria2_venv/bin/activate && /etc/hysteria/menu.sh'" >> ~/.bashrc - source ~/.bashrc -fi -sleep 5 -cd /etc/hysteria -chmod +x menu.sh -./menu.sh +setup_python_env() { + log_info "Setting up Python virtual environment..." + + cd /etc/hysteria || { log_error "Failed to change to /etc/hysteria directory"; exit 1; } + + if python3 -m venv hysteria2_venv &> /dev/null; then + log_success "Created Python virtual environment" + else + log_error "Failed to create Python virtual environment" + exit 1 + fi + + source /etc/hysteria/hysteria2_venv/bin/activate || { log_error "Failed to activate virtual environment"; exit 1; } + + log_info "Installing Python requirements..." + if pip install -r requirements.txt &> /dev/null; then + log_success "Installed Python requirements" + else + log_error "Failed to install Python requirements" + exit 1 + fi +} + +add_alias() { + log_info "Adding 'hys2' alias to .bashrc..." + + if ! grep -q "alias hys2='source /etc/hysteria/hysteria2_venv/bin/activate && /etc/hysteria/menu.sh'" ~/.bashrc; then + echo "alias hys2='source /etc/hysteria/hysteria2_venv/bin/activate && /etc/hysteria/menu.sh'" >> ~/.bashrc + log_success "Added 'hys2' alias to .bashrc" + else + log_info "Alias 'hys2' already exists in .bashrc" + fi +} + +run_menu() { + log_info "Preparing to run setup menu..." + + cd /etc/hysteria || { log_error "Failed to change to /etc/hysteria directory"; exit 1; } + chmod +x menu.sh || { log_error "Failed to make menu.sh executable"; exit 1; } + + log_info "Starting setup menu..." + echo -e "\n${BOLD}${GREEN}======== Launching Hysteria Setup Menu ========${NC}\n" + ./menu.sh +} + +main() { + echo -e "\n${BOLD}${BLUE}======== Hysteria Setup Script ========${NC}\n" + + check_root + check_os_version + install_packages + clone_repository + setup_python_env + add_alias + + source ~/.bashrc &> /dev/null || true + + echo -e "\n${YELLOW}Starting Hysteria setup in 3 seconds...${NC}" + sleep 3 + + run_menu +} + +main From a25f55bc6759c1066519ae3c4f3ad3f1f6300751 Mon Sep 17 00:00:00 2001 From: Whispering Wind <151555003+ReturnFI@users.noreply.github.com> Date: Tue, 6 May 2025 19:38:38 +0330 Subject: [PATCH 07/21] typo --- install.sh | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/install.sh b/install.sh index b1497a4..c784024 100644 --- a/install.sh +++ b/install.sh @@ -109,7 +109,7 @@ install_packages() { } clone_repository() { - log_info "Cloning Hysteria repository..." + log_info "Cloning Blitz repository..." if [ -d "/etc/hysteria" ]; then log_warning "Directory /etc/hysteria already exists." @@ -166,18 +166,18 @@ add_alias() { } run_menu() { - log_info "Preparing to run setup menu..." + log_info "Preparing to run menu..." cd /etc/hysteria || { log_error "Failed to change to /etc/hysteria directory"; exit 1; } chmod +x menu.sh || { log_error "Failed to make menu.sh executable"; exit 1; } - log_info "Starting setup menu..." - echo -e "\n${BOLD}${GREEN}======== Launching Hysteria Setup Menu ========${NC}\n" + log_info "Starting menu..." + echo -e "\n${BOLD}${GREEN}======== Launching Blitz Menu ========${NC}\n" ./menu.sh } main() { - echo -e "\n${BOLD}${BLUE}======== Hysteria Setup Script ========${NC}\n" + echo -e "\n${BOLD}${BLUE}======== Blitz Setup Script ========${NC}\n" check_root check_os_version @@ -188,7 +188,7 @@ main() { source ~/.bashrc &> /dev/null || true - echo -e "\n${YELLOW}Starting Hysteria setup in 3 seconds...${NC}" + echo -e "\n${YELLOW}Starting Blitz in 3 seconds...${NC}" sleep 3 run_menu From a5dac822551bfc5e236c87e709da8d038af175e1 Mon Sep 17 00:00:00 2001 From: Whispering Wind <151555003+ReturnFI@users.noreply.github.com> Date: Thu, 8 May 2025 20:25:49 +0330 Subject: [PATCH 08/21] Refactor: Implement kick users in Python --- core/scripts/hysteria2/install.sh | 4 +- core/scripts/hysteria2/kick.py | 178 ++++++++++++++++++++++++++++++ core/scripts/hysteria2/kick.sh | 76 ------------- upgrade.sh | 43 +------- 4 files changed, 182 insertions(+), 119 deletions(-) create mode 100644 core/scripts/hysteria2/kick.py delete mode 100644 core/scripts/hysteria2/kick.sh diff --git a/core/scripts/hysteria2/install.sh b/core/scripts/hysteria2/install.sh index b64730b..767f1cd 100644 --- a/core/scripts/hysteria2/install.sh +++ b/core/scripts/hysteria2/install.sh @@ -80,12 +80,12 @@ install_hysteria() { fi chmod +x /etc/hysteria/core/scripts/hysteria2/user.sh - chmod +x /etc/hysteria/core/scripts/hysteria2/kick.sh + chmod +x /etc/hysteria/core/scripts/hysteria2/kick.py (crontab -l ; echo "*/1 * * * * /bin/bash -c 'source /etc/hysteria/hysteria2_venv/bin/activate && python3 /etc/hysteria/core/cli.py traffic-status' >/dev/null 2>&1") | crontab - (crontab -l ; echo "0 3 */3 * * /bin/bash -c 'source /etc/hysteria/hysteria2_venv/bin/activate && python3 /etc/hysteria/core/cli.py restart-hysteria2' >/dev/null 2>&1") | crontab - (crontab -l ; echo "0 */6 * * * /bin/bash -c 'source /etc/hysteria/hysteria2_venv/bin/activate && python3 /etc/hysteria/core/cli.py backup-hysteria' >/dev/null 2>&1") | crontab - - (crontab -l ; echo "*/1 * * * * /etc/hysteria/core/scripts/hysteria2/kick.sh >/dev/null 2>&1") | crontab - + (crontab -l ; echo "*/1 * * * * /bin/bash -c 'source /etc/hysteria/hysteria2_venv/bin/activate && python3 /etc/hysteria/core/scripts/hysteria2/kick.py' >/dev/null 2>&1") | crontab - } diff --git a/core/scripts/hysteria2/kick.py b/core/scripts/hysteria2/kick.py new file mode 100644 index 0000000..ade55f7 --- /dev/null +++ b/core/scripts/hysteria2/kick.py @@ -0,0 +1,178 @@ +#!/usr/bin/env python3 + +import os +import sys +import json +import time +import fcntl +import shutil +import datetime +from concurrent.futures import ThreadPoolExecutor +from init_paths import * +from paths import * +from hysteria2_api import Hysteria2Client + +import logging +logging.basicConfig( + stream=sys.stdout, + level=logging.INFO, + format='%(asctime)s: [%(levelname)s] %(message)s', + datefmt='%Y-%m-%d %H:%M:%S' +) +logger = logging.getLogger() + +LOCKFILE = "/tmp/kick.lock" +BACKUP_FILE = f"{USERS_FILE}.bak" +MAX_WORKERS = 8 + +def acquire_lock(): + try: + lock_file = open(LOCKFILE, 'w') + fcntl.flock(lock_file, fcntl.LOCK_EX | fcntl.LOCK_NB) + return lock_file + except IOError: + logger.warning("Another instance is already running. Exiting.") + sys.exit(1) + +def kick_users(usernames, secret): + try: + client = Hysteria2Client( + base_url="http://127.0.0.1:25413", + secret=secret + ) + + client.kick_clients(usernames) + logger.info(f"Successfully kicked {len(usernames)} users: {', '.join(usernames)}") + return True + except Exception as e: + logger.error(f"Error kicking users: {str(e)}") + return False + +def process_user(username, user_data, config_secret, users_data): + blocked = user_data.get('blocked', False) + + if blocked: + logger.info(f"Skipping {username} as they are already blocked.") + return None + + max_download_bytes = user_data.get('max_download_bytes', 0) + expiration_days = user_data.get('expiration_days', 0) + account_creation_date = user_data.get('account_creation_date') + current_download_bytes = user_data.get('download_bytes', 0) + current_upload_bytes = user_data.get('upload_bytes', 0) + + total_bytes = current_download_bytes + current_upload_bytes + + if not account_creation_date: + logger.info(f"Skipping {username} due to missing account creation date.") + return None + + try: + current_date = datetime.datetime.now().timestamp() + creation_date = datetime.datetime.fromisoformat(account_creation_date.replace('Z', '+00:00')) + expiration_date = (creation_date + datetime.timedelta(days=expiration_days)).timestamp() + + should_block = False + + if max_download_bytes > 0 and total_bytes >= 0 and expiration_days > 0: + if total_bytes >= max_download_bytes or current_date >= expiration_date: + should_block = True + + if should_block: + logger.info(f"Setting blocked=True for user {username}") + users_data[username]['blocked'] = True + return username + else: + logger.info(f"Skipping {username} due to invalid or missing data.") + return None + + except Exception as e: + logger.error(f"Error processing user {username}: {str(e)}") + return None + + return None + +def main(): + lock_file = acquire_lock() + + try: + shutil.copy2(USERS_FILE, BACKUP_FILE) + logger.info(f"Created backup of users file at {BACKUP_FILE}") + + try: + with open(CONFIG_FILE, 'r') as f: + config = json.load(f) + secret = config.get('trafficStats', {}).get('secret', '') + if not secret: + logger.error("No secret found in config file") + sys.exit(1) + except Exception as e: + logger.error(f"Failed to load config file: {str(e)}") + shutil.copy2(BACKUP_FILE, USERS_FILE) + sys.exit(1) + + try: + with open(USERS_FILE, 'r') as f: + users_data = json.load(f) + logger.info(f"Loaded data for {len(users_data)} users") + except json.JSONDecodeError: + logger.error("Invalid users.json. Restoring backup.") + shutil.copy2(BACKUP_FILE, USERS_FILE) + sys.exit(1) + except Exception as e: + logger.error(f"Failed to load users file: {str(e)}") + shutil.copy2(BACKUP_FILE, USERS_FILE) + sys.exit(1) + + users_to_kick = [] + logger.info(f"Processing {len(users_data)} users in parallel with {MAX_WORKERS} workers") + with ThreadPoolExecutor(max_workers=MAX_WORKERS) as executor: + future_to_user = { + executor.submit(process_user, username, user_data, secret, users_data): username + for username, user_data in users_data.items() + } + + for future in future_to_user: + username = future.result() + if username: + users_to_kick.append(username) + logger.info(f"User {username} added to kick list") + + if users_to_kick: + logger.info(f"Saving changes to users file for {len(users_to_kick)} blocked users") + for retry in range(3): + try: + with open(USERS_FILE, 'w') as f: + json.dump(users_data, f, indent=2) + break + except Exception as e: + logger.error(f"Failed to save users file (attempt {retry+1}): {str(e)}") + time.sleep(1) + if retry == 2: + raise + + if users_to_kick: + logger.info(f"Kicking {len(users_to_kick)} users") + batch_size = 50 + for i in range(0, len(users_to_kick), batch_size): + batch = users_to_kick[i:i+batch_size] + logger.info(f"Processing batch of {len(batch)} users") + kick_users(batch, secret) + for username in batch: + logger.info(f"Blocked and kicked user {username}") + else: + logger.info("No users to kick") + + except Exception as e: + logger.error(f"An error occurred: {str(e)}") + logger.info("Restoring users file from backup") + shutil.copy2(BACKUP_FILE, USERS_FILE) + sys.exit(1) + finally: + fcntl.flock(lock_file, fcntl.LOCK_UN) + lock_file.close() + logger.info("Script completed") + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/core/scripts/hysteria2/kick.sh b/core/scripts/hysteria2/kick.sh deleted file mode 100644 index 2b9f08b..0000000 --- a/core/scripts/hysteria2/kick.sh +++ /dev/null @@ -1,76 +0,0 @@ -#!/bin/bash - -source /etc/hysteria/core/scripts/path.sh - -LOCKFILE="/tmp/kick.lock" -exec 200>$LOCKFILE -flock -n 200 || exit 1 - -LOGFILE="/var/log/kick.log" -BACKUP_FILE="${USERS_FILE}.bak" - -cp "$USERS_FILE" "$BACKUP_FILE" - -kick_user() { - local username=$1 - local secret=$2 - local kick_endpoint="http://127.0.0.1:25413/kick" - curl -s -H "Authorization: $secret" -X POST -d "[\"$username\"]" "$kick_endpoint" -} - -SECRET=$(jq -r '.trafficStats.secret' "$CONFIG_FILE") - -if ! jq empty "$USERS_FILE"; then - echo "$(date): [ERROR] Invalid users.json. Restoring backup." >> $LOGFILE - cp "$BACKUP_FILE" "$USERS_FILE" - exit 1 -fi - -handle_error() { - echo "$(date): [ERROR] An error occurred. Restoring backup." >> $LOGFILE - cp "$BACKUP_FILE" "$USERS_FILE" - exit 1 -} - -trap handle_error ERR - -for USERNAME in $(jq -r 'keys[]' "$USERS_FILE"); do - BLOCKED=$(jq -r --arg user "$USERNAME" '.[$user].blocked // false' "$USERS_FILE") - - if [ "$BLOCKED" == "true" ]; then - echo "$(date): [INFO] Skipping $USERNAME as they are already blocked." >> $LOGFILE - continue - fi - - MAX_DOWNLOAD_BYTES=$(jq -r --arg user "$USERNAME" '.[$user].max_download_bytes // 0' "$USERS_FILE") - EXPIRATION_DAYS=$(jq -r --arg user "$USERNAME" '.[$user].expiration_days // 0' "$USERS_FILE") - ACCOUNT_CREATION_DATE=$(jq -r --arg user "$USERNAME" '.[$user].account_creation_date' "$USERS_FILE") - CURRENT_DOWNLOAD_BYTES=$(jq -r --arg user "$USERNAME" '.[$user].download_bytes // 0' "$USERS_FILE") - CURRENT_UPLOAD_BYTES=$(jq -r --arg user "$USERNAME" '.[$user].upload_bytes // 0' "$USERS_FILE") - - TOTAL_BYTES=$((CURRENT_DOWNLOAD_BYTES + CURRENT_UPLOAD_BYTES)) - - if [ -z "$ACCOUNT_CREATION_DATE" ]; then - echo "$(date): [INFO] Skipping $USERNAME due to missing account creation date." >> $LOGFILE - continue - fi - - CURRENT_DATE=$(date +%s) - EXPIRATION_DATE=$(date -d "$ACCOUNT_CREATION_DATE + $EXPIRATION_DAYS days" +%s) - - if [ "$MAX_DOWNLOAD_BYTES" -gt 0 ] && [ "$TOTAL_BYTES" -ge 0 ] && [ "$EXPIRATION_DAYS" -gt 0 ]; then - if [ "$TOTAL_BYTES" -ge "$MAX_DOWNLOAD_BYTES" ] || [ "$CURRENT_DATE" -ge "$EXPIRATION_DATE" ]; then - for i in {1..3}; do - jq --arg user "$USERNAME" '.[$user].blocked = true' "$USERS_FILE" > temp.json && mv temp.json "$USERS_FILE" && break - sleep 1 - done - kick_user "$USERNAME" "$SECRET" - echo "$(date): [INFO] Blocked and kicked user $USERNAME." >> $LOGFILE - fi - else - echo "$(date): [INFO] Skipping $USERNAME due to invalid or missing data." >> $LOGFILE - fi -done - -# echo "$(date): [INFO] Kick script completed successfully." >> $LOGFILE -# exit 0 diff --git a/upgrade.sh b/upgrade.sh index fd44caa..38215d2 100644 --- a/upgrade.sh +++ b/upgrade.sh @@ -26,28 +26,6 @@ for FILE in "${FILES[@]}"; do cp "$FILE" "$TEMP_DIR/$FILE" done -# echo "Checking and renaming old systemd service files" -# declare -A SERVICE_MAP=( -# ["/etc/systemd/system/hysteria-bot.service"]="hysteria-telegram-bot.service" -# ["/etc/systemd/system/singbox.service"]="hysteria-singbox.service" -# ["/etc/systemd/system/normalsub.service"]="hysteria-normal-sub.service" -# ) - -# for OLD_SERVICE in "${!SERVICE_MAP[@]}"; do -# NEW_SERVICE="/etc/systemd/system/${SERVICE_MAP[$OLD_SERVICE]}" - -# if [[ -f "$OLD_SERVICE" ]]; then -# echo "Stopping old service: $(basename "$OLD_SERVICE")" -# systemctl stop "$(basename "$OLD_SERVICE")" 2>/dev/null - -# echo "Renaming $OLD_SERVICE to $NEW_SERVICE" -# mv "$OLD_SERVICE" "$NEW_SERVICE" - -# echo "Reloading systemd daemon" -# systemctl daemon-reload -# fi -# done - echo "Removing /etc/hysteria directory" rm -rf /etc/hysteria/ @@ -63,24 +41,6 @@ for FILE in "${FILES[@]}"; do cp "$TEMP_DIR/$FILE" "$FILE" done -# CADDYFILE="/etc/hysteria/core/scripts/webpanel/Caddyfile" - -# if [ -f "$CADDYFILE" ]; then -# echo "Updating Caddyfile port from 8080 to 28260" - -# sed -i 's/\(:[[:space:]]*\)8080/\128260/g' "$CADDYFILE" -# sed -i 's/0\.0\.0\.0:8080/0.0.0.0:28260/g' "$CADDYFILE" -# sed -i 's/127\.0\.0\.1:8080/127.0.0.1:28260/g' "$CADDYFILE" - - -# if ! grep -q ':28260' "$CADDYFILE"; then -# echo "Warning: Caddyfile does not contain port 8080 in expected formats. Port replacement may have already been done." -# fi -# else -# echo "Error: Caddyfile not found at $CADDYFILE. Cannot update port." -# fi - - CONFIG_ENV="/etc/hysteria/.configs.env" if [ ! -f "$CONFIG_ENV" ]; then echo ".configs.env not found, creating it with default values." @@ -155,7 +115,6 @@ systemctl restart hysteria-caddy.service echo "Restarting other hysteria services" systemctl restart hysteria-server.service systemctl restart hysteria-telegram-bot.service -# systemctl restart hysteria-singbox.service systemctl restart hysteria-normal-sub.service systemctl restart hysteria-webpanel.service @@ -169,6 +128,8 @@ fi echo "Restoring cron jobs" crontab /tmp/crontab_backup +echo "Updating kick.sh cron job to kick.py" +( crontab -l | sed "s|/etc/hysteria/core/scripts/hysteria2/kick.sh|/bin/bash -c 'source /etc/hysteria/hysteria2_venv/bin/activate && python3 /etc/hysteria/core/scripts/hysteria2/kick.py'|g" ) | crontab - rm /tmp/crontab_backup chmod +x menu.sh From f3d3e16a0d9dc02b061c80f1f9c6e94cedf6a82c Mon Sep 17 00:00:00 2001 From: Whispering Wind <151555003+ReturnFI@users.noreply.github.com> Date: Thu, 8 May 2025 20:26:41 +0330 Subject: [PATCH 09/21] Clone Beta --- upgrade.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/upgrade.sh b/upgrade.sh index 38215d2..72d174f 100644 --- a/upgrade.sh +++ b/upgrade.sh @@ -30,7 +30,7 @@ echo "Removing /etc/hysteria directory" rm -rf /etc/hysteria/ echo "Cloning Blitz repository" -git clone https://github.com/ReturnFI/Blitz /etc/hysteria +git clone -b beta https://github.com/ReturnFI/Blitz /etc/hysteria echo "Downloading geosite.dat and geoip.dat" wget -O /etc/hysteria/geosite.dat https://raw.githubusercontent.com/Chocolate4U/Iran-v2ray-rules/release/geosite.dat >/dev/null 2>&1 From f656ec7e4ec60326fbef5dcc5d4b542f478959cd Mon Sep 17 00:00:00 2001 From: Whispering Wind <151555003+ReturnFI@users.noreply.github.com> Date: Thu, 8 May 2025 20:28:39 +0330 Subject: [PATCH 10/21] execute permissions for kick.py --- upgrade.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/upgrade.sh b/upgrade.sh index 72d174f..6f56e29 100644 --- a/upgrade.sh +++ b/upgrade.sh @@ -100,9 +100,9 @@ chmod 640 /etc/hysteria/ca.key /etc/hysteria/ca.crt chown -R hysteria:hysteria /etc/hysteria/core/scripts/singbox chown -R hysteria:hysteria /etc/hysteria/core/scripts/telegrambot -echo "Setting execute permissions for user.sh and kick.sh" +echo "Setting execute permissions for user.sh and kick.py" chmod +x /etc/hysteria/core/scripts/hysteria2/user.sh -chmod +x /etc/hysteria/core/scripts/hysteria2/kick.sh +chmod +x /etc/hysteria/core/scripts/hysteria2/kick.py cd /etc/hysteria python3 -m venv hysteria2_venv From 4f47edc3bae1b4cc52ba57db741266525812b91f Mon Sep 17 00:00:00 2001 From: Whispering Wind <151555003+ReturnFI@users.noreply.github.com> Date: Thu, 8 May 2025 20:53:27 +0330 Subject: [PATCH 11/21] fix: replace old kick.sh cron job with new kick.py --- upgrade.sh | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/upgrade.sh b/upgrade.sh index 6f56e29..b87b47c 100644 --- a/upgrade.sh +++ b/upgrade.sh @@ -129,8 +129,13 @@ fi echo "Restoring cron jobs" crontab /tmp/crontab_backup echo "Updating kick.sh cron job to kick.py" -( crontab -l | sed "s|/etc/hysteria/core/scripts/hysteria2/kick.sh|/bin/bash -c 'source /etc/hysteria/hysteria2_venv/bin/activate && python3 /etc/hysteria/core/scripts/hysteria2/kick.py'|g" ) | crontab - -rm /tmp/crontab_backup +if crontab -l | grep -Fq '*/1 * * * * /etc/hysteria/core/scripts/hysteria2/kick.sh >/dev/null 2>&1'; then + crontab -l | grep -vF '*/1 * * * * /etc/hysteria/core/scripts/hysteria2/kick.sh >/dev/null 2>&1' | \ + { cat; echo "*/1 * * * * /bin/bash -c 'source /etc/hysteria/hysteria2_venv/bin/activate && python3 /etc/hysteria/core/scripts/hysteria2/kick.py' >/dev/null 2>&1"; } | crontab - + echo "Cron job updated." +else + echo "Old cron job not found. No need change." +fi chmod +x menu.sh ./menu.sh From 049e3f7601392e7b065e720ed80febd37056d969 Mon Sep 17 00:00:00 2001 From: Whispering Wind <151555003+ReturnFI@users.noreply.github.com> Date: Fri, 9 May 2025 00:07:17 +0330 Subject: [PATCH 12/21] refactor(cli): Improve script error propagation and handling --- core/cli_api.py | 40 +++++++++++++++++++++++++--------------- 1 file changed, 25 insertions(+), 15 deletions(-) diff --git a/core/cli_api.py b/core/cli_api.py index 44cfd6f..0ebf894 100644 --- a/core/cli_api.py +++ b/core/cli_api.py @@ -83,24 +83,34 @@ class ScriptNotFoundError(HysteriaError): # region Utils -def run_cmd(command: list[str]) -> str | None: +def run_cmd(command: list[str]) -> str: ''' - Runs a command and returns the output. - Could raise subprocess.CalledProcessError + Runs a command and returns its stdout if successful. + Raises CommandExecutionError if the command fails (non-zero exit code) or cannot be found. ''' - if (DEBUG) and not (Command.GET_USER.value in command or Command.LIST_USERS.value in command): - print(' '.join(command)) + if DEBUG: + print(f"Executing command: {' '.join(command)}") try: - result = subprocess.check_output(command, stderr=subprocess.STDOUT, shell=False) - if result: - result = result.decode().strip() - return result - except subprocess.CalledProcessError as e: - if DEBUG: - raise CommandExecutionError(f'Command execution failed: {e}\nOutput: {e.output.decode()}') - else: - return None - return None + process = subprocess.run(command, capture_output=True, text=True, shell=False, check=False) + + if process.returncode != 0: + error_output = process.stderr.strip() if process.stderr.strip() else process.stdout.strip() + if not error_output: + error_output = f"Command exited with status {process.returncode} without specific error message." + + detailed_error_message = f"Command '{' '.join(command)}' failed with exit code {process.returncode}: {error_output}" + raise CommandExecutionError(detailed_error_message) + + return process.stdout.strip() if process.stdout else "" + + except FileNotFoundError as e: + raise ScriptNotFoundError(f"Script or command not found: {command[0]}. Original error: {e}") + except subprocess.TimeoutExpired as e: + raise CommandExecutionError(f"Command '{' '.join(command)}' timed out. Original error: {e}") + except OSError as e: + raise CommandExecutionError(f"OS error while trying to run command '{' '.join(command)}': {e}") + + def generate_password() -> str: From 69e96054141c359c63453679f464f5d51d713a0c Mon Sep 17 00:00:00 2001 From: Whispering Wind <151555003+ReturnFI@users.noreply.github.com> Date: Fri, 9 May 2025 00:11:10 +0330 Subject: [PATCH 13/21] feat(api): Implement user existence checks in add user API endpoint --- core/scripts/webpanel/routers/api/v1/user.py | 38 ++++++++++++-------- 1 file changed, 24 insertions(+), 14 deletions(-) diff --git a/core/scripts/webpanel/routers/api/v1/user.py b/core/scripts/webpanel/routers/api/v1/user.py index 9db4deb..550ed42 100644 --- a/core/scripts/webpanel/routers/api/v1/user.py +++ b/core/scripts/webpanel/routers/api/v1/user.py @@ -1,3 +1,4 @@ +import json from fastapi import APIRouter, HTTPException from .schema.user import UserListResponse, UserInfoResponse, AddUserInputBody, EditUserInputBody, UserUriResponse @@ -25,26 +26,35 @@ async def list_users_api(): raise HTTPException(status_code=400, detail=f'Error: {str(e)}') -@router.post('/', response_model=DetailResponse) +@router.post('/', response_model=DetailResponse, status_code=201) async def add_user_api(body: AddUserInputBody): - """ - Add a new user to the system. - - Args: - body: An instance of AddUserInputBody containing the user's details. - - Returns: - A DetailResponse with a message indicating the user has been added. - - Raises: - HTTPException: if an error occurs while adding the user. - """ + try: + cli_api.get_user(body.username) + raise HTTPException(status_code=409, + detail=f"User '{body.username}' already exists.") + except cli_api.CommandExecutionError: + pass + except json.JSONDecodeError as e: + raise HTTPException(status_code=500, + detail=f"{str(e)}") try: cli_api.add_user(body.username, body.traffic_limit, body.expiration_days, body.password, body.creation_date) return DetailResponse(detail=f'User {body.username} has been added.') + except cli_api.CommandExecutionError as e: + if "User already exists" in str(e): + raise HTTPException(status_code=409, + detail=f"User '{body.username}' already exists.") + raise HTTPException(status_code=400, + detail=f'Failed to add user {body.username}: {str(e)}') + except cli_api.PasswordGenerationError as e: + raise HTTPException(status_code=500, + detail=f"Failed to generate password for user '{body.username}': {str(e)}") + except cli_api.InvalidInputError as e: + raise HTTPException(status_code=422, detail=str(e)) except Exception as e: - raise HTTPException(status_code=400, detail=f'Error: {str(e)}') + raise HTTPException(status_code=500, + detail=f"An unexpected error occurred while adding user '{body.username}': {str(e)}") @router.get('/{username}', response_model=UserInfoResponse) From 5b04b47e609f0195a53a5c9fcb912b388bf561d8 Mon Sep 17 00:00:00 2001 From: Whispering Wind <151555003+ReturnFI@users.noreply.github.com> Date: Fri, 9 May 2025 00:12:42 +0330 Subject: [PATCH 14/21] feat(frontend): Enhance user management UI and error handling in users.html - Added default values for "Traffic Limit" and "Expiration Days" in the "Add User" modal. --- core/scripts/webpanel/templates/users.html | 50 +++++++++++++--------- 1 file changed, 30 insertions(+), 20 deletions(-) diff --git a/core/scripts/webpanel/templates/users.html b/core/scripts/webpanel/templates/users.html index 57070fd..a55725d 100644 --- a/core/scripts/webpanel/templates/users.html +++ b/core/scripts/webpanel/templates/users.html @@ -389,10 +389,11 @@ // Add User Form Submit $("#addUserForm").on("submit", function (e) { e.preventDefault(); - // Additional check before submitting (in case JS is disabled briefly) if (!validateUsername($("#addUsername").val(), "addUsernameError")) { + $("#addSubmitButton").prop("disabled", true); return; } + $("#addSubmitButton").prop("disabled", true); const formData = $(this).serializeArray(); const jsonData = {}; @@ -406,31 +407,32 @@ contentType: "application/json", data: JSON.stringify(jsonData), success: function (response) { - if (response.detail) { - Swal.fire({ - title: "Success!", - text: response.detail, - icon: "success", - confirmButtonText: "OK", - }).then(() => { - location.reload(); - }); - } else { - Swal.fire({ - title: "Error!", - text: response.error || "Failed to add user", - icon: "error", - confirmButtonText: "OK", - }); - } + Swal.fire({ + title: "Success!", + text: response.detail || "User added successfully!", + icon: "success", + confirmButtonText: "OK", + }).then(() => { + location.reload(); + }); }, - error: function () { + error: function (jqXHR, textStatus, errorThrown) { + let errorMessage = "An error occurred while adding user."; + if (jqXHR.responseJSON && jqXHR.responseJSON.detail) { + errorMessage = jqXHR.responseJSON.detail; + } else if (jqXHR.status === 409) { + errorMessage = "User '" + jsonData.username + "' already exists."; + $("#addUsernameError").text(errorMessage); + } else if (jqXHR.status === 422) { + errorMessage = jqXHR.responseJSON.detail || "Invalid input provided."; + } Swal.fire({ title: "Error!", - text: "An error occurred while adding user", + text: errorMessage, icon: "error", confirmButtonText: "OK", }); + $("#addSubmitButton").prop("disabled", false); } }); }); @@ -761,6 +763,14 @@ }); } + $('#addUserModal').on('show.bs.modal', function (event) { + $('#addUsername').val(''); + $('#addTrafficLimit').val('30'); + $('#addExpirationDays').val('30'); + $('#addUsernameError').text(''); + $('#addSubmitButton').prop('disabled', true); + }); + $("#searchButton").on("click", filterUsers); $("#searchInput").on("keyup", filterUsers); From 9d58ce49db6de6dca4da22661f975dca568c6a55 Mon Sep 17 00:00:00 2001 From: Whispering Wind <151555003+ReturnFI@users.noreply.github.com> Date: Fri, 9 May 2025 01:04:05 +0330 Subject: [PATCH 15/21] Revert CLI Change --- core/cli_api.py | 40 +++++++++++++++------------------------- 1 file changed, 15 insertions(+), 25 deletions(-) diff --git a/core/cli_api.py b/core/cli_api.py index 0ebf894..44cfd6f 100644 --- a/core/cli_api.py +++ b/core/cli_api.py @@ -83,34 +83,24 @@ class ScriptNotFoundError(HysteriaError): # region Utils -def run_cmd(command: list[str]) -> str: +def run_cmd(command: list[str]) -> str | None: ''' - Runs a command and returns its stdout if successful. - Raises CommandExecutionError if the command fails (non-zero exit code) or cannot be found. + Runs a command and returns the output. + Could raise subprocess.CalledProcessError ''' - if DEBUG: - print(f"Executing command: {' '.join(command)}") + if (DEBUG) and not (Command.GET_USER.value in command or Command.LIST_USERS.value in command): + print(' '.join(command)) try: - process = subprocess.run(command, capture_output=True, text=True, shell=False, check=False) - - if process.returncode != 0: - error_output = process.stderr.strip() if process.stderr.strip() else process.stdout.strip() - if not error_output: - error_output = f"Command exited with status {process.returncode} without specific error message." - - detailed_error_message = f"Command '{' '.join(command)}' failed with exit code {process.returncode}: {error_output}" - raise CommandExecutionError(detailed_error_message) - - return process.stdout.strip() if process.stdout else "" - - except FileNotFoundError as e: - raise ScriptNotFoundError(f"Script or command not found: {command[0]}. Original error: {e}") - except subprocess.TimeoutExpired as e: - raise CommandExecutionError(f"Command '{' '.join(command)}' timed out. Original error: {e}") - except OSError as e: - raise CommandExecutionError(f"OS error while trying to run command '{' '.join(command)}': {e}") - - + result = subprocess.check_output(command, stderr=subprocess.STDOUT, shell=False) + if result: + result = result.decode().strip() + return result + except subprocess.CalledProcessError as e: + if DEBUG: + raise CommandExecutionError(f'Command execution failed: {e}\nOutput: {e.output.decode()}') + else: + return None + return None def generate_password() -> str: From e581a92b60e29aa60fd3f57ebab31aa6421b1a37 Mon Sep 17 00:00:00 2001 From: Whispering Wind <151555003+ReturnFI@users.noreply.github.com> Date: Fri, 9 May 2025 01:22:03 +0330 Subject: [PATCH 16/21] Refactored cli_api.run_cmd --- core/cli_api.py | 38 +++++++++++++++++++++++--------------- 1 file changed, 23 insertions(+), 15 deletions(-) diff --git a/core/cli_api.py b/core/cli_api.py index 44cfd6f..219f050 100644 --- a/core/cli_api.py +++ b/core/cli_api.py @@ -83,24 +83,32 @@ class ScriptNotFoundError(HysteriaError): # region Utils -def run_cmd(command: list[str]) -> str | None: +def run_cmd(command: list[str]) -> str: ''' - Runs a command and returns the output. - Could raise subprocess.CalledProcessError + Runs a command and returns its stdout if successful. + Raises CommandExecutionError if the command fails (non-zero exit code) or cannot be found. ''' - if (DEBUG) and not (Command.GET_USER.value in command or Command.LIST_USERS.value in command): - print(' '.join(command)) + if DEBUG: + print(f"Executing command: {' '.join(command)}") try: - result = subprocess.check_output(command, stderr=subprocess.STDOUT, shell=False) - if result: - result = result.decode().strip() - return result - except subprocess.CalledProcessError as e: - if DEBUG: - raise CommandExecutionError(f'Command execution failed: {e}\nOutput: {e.output.decode()}') - else: - return None - return None + process = subprocess.run(command, capture_output=True, text=True, shell=False, check=False) + + if process.returncode != 0: + error_output = process.stderr.strip() if process.stderr.strip() else process.stdout.strip() + if not error_output: + error_output = f"Command exited with status {process.returncode} without specific error message." + + detailed_error_message = f"Command '{' '.join(command)}' failed with exit code {process.returncode}: {error_output}" + raise CommandExecutionError(detailed_error_message) + + return process.stdout.strip() if process.stdout else "" + + except FileNotFoundError as e: + raise ScriptNotFoundError(f"Script or command not found: {command[0]}. Original error: {e}") + except subprocess.TimeoutExpired as e: + raise CommandExecutionError(f"Command '{' '.join(command)}' timed out. Original error: {e}") + except OSError as e: + raise CommandExecutionError(f"OS error while trying to run command '{' '.join(command)}': {e}") def generate_password() -> str: From dc49f5d74f8f152dd05caf82ebe975bc99dc42cb Mon Sep 17 00:00:00 2001 From: Whispering Wind <151555003+ReturnFI@users.noreply.github.com> Date: Sat, 10 May 2025 14:06:55 +0330 Subject: [PATCH 17/21] feat: Merge traffic collection and user kicking --- core/cli.py | 2 +- core/cli_api.py | 8 +- core/scripts/hysteria2/install.sh | 6 +- core/traffic.py | 187 ++++++++++++++++++++++++++++-- 4 files changed, 185 insertions(+), 18 deletions(-) diff --git a/core/cli.py b/core/cli.py index ea964d1..8c684f6 100644 --- a/core/cli.py +++ b/core/cli.py @@ -227,7 +227,7 @@ def show_user_uri_json(usernames: list[str]): @cli.command('traffic-status') -@click.option('--no-gui', is_flag=True, help='Retrieve traffic data without displaying output') +@click.option('--no-gui', is_flag=True, help='Retrieve traffic data without displaying output and kick expired users') def traffic_status(no_gui): try: cli_api.traffic_status(no_gui=no_gui) diff --git a/core/cli_api.py b/core/cli_api.py index 219f050..4bbc44e 100644 --- a/core/cli_api.py +++ b/core/cli_api.py @@ -371,8 +371,12 @@ def show_user_uri_json(usernames: list[str]) -> list[dict[str, Any]] | None: def traffic_status(no_gui=False, display_output=True): - '''Fetches traffic status.''' - data = traffic.traffic_status(no_gui=True if not display_output else no_gui) + if no_gui: + data = traffic.traffic_status(no_gui=True) + traffic.kick_expired_users() + else: + data = traffic.traffic_status(no_gui=True if not display_output else no_gui) + return data diff --git a/core/scripts/hysteria2/install.sh b/core/scripts/hysteria2/install.sh index 767f1cd..761ed4d 100644 --- a/core/scripts/hysteria2/install.sh +++ b/core/scripts/hysteria2/install.sh @@ -82,10 +82,10 @@ install_hysteria() { chmod +x /etc/hysteria/core/scripts/hysteria2/user.sh chmod +x /etc/hysteria/core/scripts/hysteria2/kick.py - (crontab -l ; echo "*/1 * * * * /bin/bash -c 'source /etc/hysteria/hysteria2_venv/bin/activate && python3 /etc/hysteria/core/cli.py traffic-status' >/dev/null 2>&1") | crontab - - (crontab -l ; echo "0 3 */3 * * /bin/bash -c 'source /etc/hysteria/hysteria2_venv/bin/activate && python3 /etc/hysteria/core/cli.py restart-hysteria2' >/dev/null 2>&1") | crontab - + (crontab -l ; echo "*/1 * * * * /bin/bash -c 'source /etc/hysteria/hysteria2_venv/bin/activate && python3 /etc/hysteria/core/cli.py traffic-status --no-gui'") | crontab - + # (crontab -l ; echo "0 3 */3 * * /bin/bash -c 'source /etc/hysteria/hysteria2_venv/bin/activate && python3 /etc/hysteria/core/cli.py restart-hysteria2' >/dev/null 2>&1") | crontab - (crontab -l ; echo "0 */6 * * * /bin/bash -c 'source /etc/hysteria/hysteria2_venv/bin/activate && python3 /etc/hysteria/core/cli.py backup-hysteria' >/dev/null 2>&1") | crontab - - (crontab -l ; echo "*/1 * * * * /bin/bash -c 'source /etc/hysteria/hysteria2_venv/bin/activate && python3 /etc/hysteria/core/scripts/hysteria2/kick.py' >/dev/null 2>&1") | crontab - + # (crontab -l ; echo "*/1 * * * * /bin/bash -c 'source /etc/hysteria/hysteria2_venv/bin/activate && python3 /etc/hysteria/core/scripts/hysteria2/kick.py' >/dev/null 2>&1") | crontab - } diff --git a/core/traffic.py b/core/traffic.py index 251b14c..3ec1363 100644 --- a/core/traffic.py +++ b/core/traffic.py @@ -1,15 +1,49 @@ #!/usr/bin/env python3 import json import os -import json +import sys +import time +import fcntl +import shutil +import datetime +from concurrent.futures import ThreadPoolExecutor from hysteria2_api import Hysteria2Client -# Define static variables for paths and URLs CONFIG_FILE = '/etc/hysteria/config.json' USERS_FILE = '/etc/hysteria/users.json' API_BASE_URL = 'http://127.0.0.1:25413' +LOCKFILE = "/tmp/kick.lock" +BACKUP_FILE = f"{USERS_FILE}.bak" +MAX_WORKERS = 8 + +# import logging +# logging.basicConfig( +# level=logging.INFO, +# format='%(asctime)s: [%(levelname)s] %(message)s', +# datefmt='%Y-%m-%d %H:%M:%S' +# ) +# logger = logging.getLogger() +# null_handler = logging.NullHandler() +# logger.handlers = [null_handler] + +def acquire_lock(): + """Acquires a lock file to prevent concurrent execution""" + try: + lock_file = open(LOCKFILE, 'w') + fcntl.flock(lock_file, fcntl.LOCK_EX | fcntl.LOCK_NB) + return lock_file + except IOError: + sys.exit(1) def traffic_status(no_gui=False): + """Updates and retrieves traffic statistics for all users. + + Args: + no_gui (bool): If True, suppresses output to console + + Returns: + dict: User data including upload/download bytes and status + """ green = '\033[0;32m' cyan = '\033[0;36m' NC = '\033[0m' @@ -19,22 +53,24 @@ def traffic_status(no_gui=False): config = json.load(config_file) secret = config.get('trafficStats', {}).get('secret') except (json.JSONDecodeError, FileNotFoundError) as e: - print(f"Error: Failed to read secret from {CONFIG_FILE}. Details: {e}") - return + if not no_gui: + print(f"Error: Failed to read secret from {CONFIG_FILE}. Details: {e}") + return None if not secret: - print("Error: Secret not found in config.json") - return + if not no_gui: + print("Error: Secret not found in config.json") + return None client = Hysteria2Client(base_url=API_BASE_URL, secret=secret) try: traffic_stats = client.get_traffic_stats(clear=True) - online_status = client.get_online_clients() except Exception as e: - print(f"Error communicating with Hysteria2 API: {e}") - return + if not no_gui: + print(f"Error communicating with Hysteria2 API: {e}") + return None users_data = {} if os.path.exists(USERS_FILE): @@ -42,8 +78,9 @@ def traffic_status(no_gui=False): with open(USERS_FILE, 'r') as users_file: users_data = json.load(users_file) except json.JSONDecodeError: - print("Error: Failed to parse existing users data JSON file.") - return + if not no_gui: + print("Error: Failed to parse existing users data JSON file.") + return None for user in users_data: users_data[user]["status"] = "Offline" @@ -79,6 +116,7 @@ def traffic_status(no_gui=False): return users_data def display_traffic_data(data, green, cyan, NC): + """Displays traffic data in a formatted table""" if not data: print("No traffic data to display.") return @@ -100,6 +138,7 @@ def display_traffic_data(data, green, cyan, NC): print("-------------------------------------------------") def format_bytes(bytes): + """Format bytes as human-readable string""" if bytes < 1024: return f"{bytes}B" elif bytes < 1048576: @@ -111,5 +150,129 @@ def format_bytes(bytes): else: return f"{bytes / 1099511627776:.2f}TB" +def kick_users(usernames, secret): + """Kicks specified users from the server""" + try: + client = Hysteria2Client( + base_url=API_BASE_URL, + secret=secret + ) + + client.kick_clients(usernames) + return True + except Exception: + return False + +def process_user(username, user_data, config_secret, users_data): + """Process a single user to check if they should be kicked""" + blocked = user_data.get('blocked', False) + + if blocked: + return None + + max_download_bytes = user_data.get('max_download_bytes', 0) + expiration_days = user_data.get('expiration_days', 0) + account_creation_date = user_data.get('account_creation_date') + current_download_bytes = user_data.get('download_bytes', 0) + current_upload_bytes = user_data.get('upload_bytes', 0) + + total_bytes = current_download_bytes + current_upload_bytes + + if not account_creation_date: + return None + + try: + current_date = datetime.datetime.now().timestamp() + creation_date = datetime.datetime.fromisoformat(account_creation_date.replace('Z', '+00:00')) + expiration_date = (creation_date + datetime.timedelta(days=expiration_days)).timestamp() + + should_block = False + + if max_download_bytes > 0 and total_bytes >= 0 and expiration_days > 0: + if total_bytes >= max_download_bytes or current_date >= expiration_date: + should_block = True + + if should_block: + users_data[username]['blocked'] = True + return username + + except Exception: + return None + + return None + +def kick_expired_users(): + """Kicks users who have exceeded their data limits or whose accounts have expired""" + lock_file = acquire_lock() + + try: + shutil.copy2(USERS_FILE, BACKUP_FILE) + + try: + with open(CONFIG_FILE, 'r') as f: + config = json.load(f) + secret = config.get('trafficStats', {}).get('secret', '') + if not secret: + sys.exit(1) + except Exception: + shutil.copy2(BACKUP_FILE, USERS_FILE) + sys.exit(1) + + try: + with open(USERS_FILE, 'r') as f: + users_data = json.load(f) + except json.JSONDecodeError: + shutil.copy2(BACKUP_FILE, USERS_FILE) + sys.exit(1) + except Exception: + shutil.copy2(BACKUP_FILE, USERS_FILE) + sys.exit(1) + + users_to_kick = [] + with ThreadPoolExecutor(max_workers=MAX_WORKERS) as executor: + future_to_user = { + executor.submit(process_user, username, user_data, secret, users_data): username + for username, user_data in users_data.items() + } + + for future in future_to_user: + username = future.result() + if username: + users_to_kick.append(username) + + if users_to_kick: + for retry in range(3): + try: + with open(USERS_FILE, 'w') as f: + json.dump(users_data, f, indent=2) + break + except Exception: + time.sleep(1) + if retry == 2: + raise + + if users_to_kick: + batch_size = 50 + for i in range(0, len(users_to_kick), batch_size): + batch = users_to_kick[i:i+batch_size] + kick_users(batch, secret) + + except Exception: + shutil.copy2(BACKUP_FILE, USERS_FILE) + sys.exit(1) + finally: + fcntl.flock(lock_file, fcntl.LOCK_UN) + lock_file.close() + if __name__ == "__main__": - traffic_status() + if len(sys.argv) > 1: + if sys.argv[1] == "kick": + kick_expired_users() + elif sys.argv[1] == "--no-gui": + traffic_status(no_gui=True) + kick_expired_users() + else: + print(f"Unknown argument: {sys.argv[1]}") + print("Usage: python traffic.py [kick|--no-gui]") + else: + traffic_status(no_gui=False) \ No newline at end of file From 1b3b96bf164c0e58cb2ff4332c993832d2cfb9b5 Mon Sep 17 00:00:00 2001 From: Whispering Wind <151555003+ReturnFI@users.noreply.github.com> Date: Sat, 10 May 2025 14:20:54 +0330 Subject: [PATCH 18/21] Update upgrade script with improved cronjob management --- upgrade.sh | 37 ++++++++++++++++++++++++++----------- 1 file changed, 26 insertions(+), 11 deletions(-) diff --git a/upgrade.sh b/upgrade.sh index b87b47c..a0963a0 100644 --- a/upgrade.sh +++ b/upgrade.sh @@ -16,9 +16,20 @@ FILES=( "/etc/hysteria/core/scripts/webpanel/Caddyfile" ) -echo "Backing up and stopping all cron jobs" -crontab -l > /tmp/crontab_backup -crontab -r +if crontab -l 2>/dev/null | grep -q "source /etc/hysteria/hysteria2_venv/bin/activate && python3 /etc/hysteria/core/cli.py" || \ + crontab -l 2>/dev/null | grep -q "/etc/hysteria/core/scripts/hysteria2/kick.sh"; then + + echo "Removing existing Hysteria cronjobs..." + crontab -l | grep -v "source /etc/hysteria/hysteria2_venv/bin/activate && python3 /etc/hysteria/core/cli.py traffic-status" | \ + grep -v "source /etc/hysteria/hysteria2_venv/bin/activate && python3 /etc/hysteria/core/cli.py restart-hysteria2" | \ + grep -v "source /etc/hysteria/hysteria2_venv/bin/activate && python3 /etc/hysteria/core/cli.py backup-hysteria" | \ + grep -v "/etc/hysteria/core/scripts/hysteria2/kick.sh" | \ + crontab - + echo "Old Hysteria cronjobs removed successfully." +else + echo "No existing Hysteria cronjobs found. Skipping removal." +fi + echo "Backing up files to $TEMP_DIR" for FILE in "${FILES[@]}"; do @@ -126,15 +137,19 @@ else echo "Upgrade failed: hysteria-server.service is not active" fi -echo "Restoring cron jobs" -crontab /tmp/crontab_backup -echo "Updating kick.sh cron job to kick.py" -if crontab -l | grep -Fq '*/1 * * * * /etc/hysteria/core/scripts/hysteria2/kick.sh >/dev/null 2>&1'; then - crontab -l | grep -vF '*/1 * * * * /etc/hysteria/core/scripts/hysteria2/kick.sh >/dev/null 2>&1' | \ - { cat; echo "*/1 * * * * /bin/bash -c 'source /etc/hysteria/hysteria2_venv/bin/activate && python3 /etc/hysteria/core/scripts/hysteria2/kick.py' >/dev/null 2>&1"; } | crontab - - echo "Cron job updated." +echo "Adding new Hysteria cronjobs..." +if ! crontab -l 2>/dev/null | grep -q "python3 /etc/hysteria/core/cli.py traffic-status --no-gui"; then + echo "Adding traffic-status cronjob..." + (crontab -l ; echo "*/1 * * * * /bin/bash -c 'source /etc/hysteria/hysteria2_venv/bin/activate && python3 /etc/hysteria/core/cli.py traffic-status --no-gui'") | crontab - else - echo "Old cron job not found. No need change." + echo "Traffic-status cronjob already exists. Skipping." +fi + +if ! crontab -l 2>/dev/null | grep -q "python3 /etc/hysteria/core/cli.py backup-hysteria"; then + echo "Adding backup-hysteria cronjob..." + (crontab -l ; echo "0 */6 * * * /bin/bash -c 'source /etc/hysteria/hysteria2_venv/bin/activate && python3 /etc/hysteria/core/cli.py backup-hysteria' >/dev/null 2>&1") | crontab - +else + echo "Backup-hysteria cronjob already exists. Skipping." fi chmod +x menu.sh From e28ade4867cc272c8e39715847121391b1134b7b Mon Sep 17 00:00:00 2001 From: Whispering Wind <151555003+ReturnFI@users.noreply.github.com> Date: Sat, 10 May 2025 14:39:09 +0330 Subject: [PATCH 19/21] Clone Main --- upgrade.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/upgrade.sh b/upgrade.sh index a0963a0..908838b 100644 --- a/upgrade.sh +++ b/upgrade.sh @@ -41,7 +41,7 @@ echo "Removing /etc/hysteria directory" rm -rf /etc/hysteria/ echo "Cloning Blitz repository" -git clone -b beta https://github.com/ReturnFI/Blitz /etc/hysteria +git clone https://github.com/ReturnFI/Blitz /etc/hysteria echo "Downloading geosite.dat and geoip.dat" wget -O /etc/hysteria/geosite.dat https://raw.githubusercontent.com/Chocolate4U/Iran-v2ray-rules/release/geosite.dat >/dev/null 2>&1 From 9b2528235de62b298d0002e1749c265c8cadb78e Mon Sep 17 00:00:00 2001 From: Whispering Wind <151555003+ReturnFI@users.noreply.github.com> Date: Sat, 10 May 2025 14:42:18 +0330 Subject: [PATCH 20/21] Update Change log --- changelog | 26 ++++++++++++++++++-------- 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/changelog b/changelog index 59e8e63..ed61a36 100644 --- a/changelog +++ b/changelog @@ -1,10 +1,20 @@ -## [1.9.1] - 2025-05-03 +## [1.9.2] - 2025-05-10 ### ✨ Changed -🧠 **refactor:**: Implement server stats manager in Python -🧠 **refactor:**: Migrate IP address config management to Python -🧠 **refactor:**: Implement Hysteria backup functionality in Python -🧠 **refactor:**: Transition Telegram bot service management to Python -🧠 **refactor:**: Migrate WARP ACL configuration handling to Python -🧠 **refactor:**: Implement WARP status management in Python -🧠 **refactor:**: Rewrite WARP setup and uninstallation scripts in Python3 \ No newline at end of file + +✨ Features +🔄 feat: Merge traffic collection with user kicking for efficient enforcement +🧪 feat(api): Add user existence validation to `add-user` endpoint +🖥️ feat(frontend): Improve user management UI and error feedback in `users.html` + +🧠 Refactors +📦 refactor: Migrate Hysteria restore functionality to Python +🌐 refactor: Implement SNI changer in Python +🛠️ refactor: Port TCP Brutal installation to Python +🎭 refactor: Rewrite masquerade management in Python3 +⚙️ refactor(cli): Enhance error propagation and handling in CLI scripts + +🛠️ System Enhancements +🧩 update: Improve upgrade script with better cronjob management +🛠️ enhance: Setup script with robust error handling and cleaner UX +📦 update: Drop Debian 11 support to focus on supported OS versions \ No newline at end of file From e69836312fe7722caff7e84c6293dbd8eca308a9 Mon Sep 17 00:00:00 2001 From: Whispering Wind <151555003+ReturnFI@users.noreply.github.com> Date: Sat, 10 May 2025 14:42:58 +0330 Subject: [PATCH 21/21] Update Changelog --- changelog | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/changelog b/changelog index ed61a36..ae2988a 100644 --- a/changelog +++ b/changelog @@ -1,20 +1,20 @@ -## [1.9.2] - 2025-05-10 +# [1.9.2] - 2025-05-10 -### ✨ Changed +## ✨ Changed -✨ Features +### ✨ Features 🔄 feat: Merge traffic collection with user kicking for efficient enforcement 🧪 feat(api): Add user existence validation to `add-user` endpoint 🖥️ feat(frontend): Improve user management UI and error feedback in `users.html` -🧠 Refactors +### 🧠 Refactors 📦 refactor: Migrate Hysteria restore functionality to Python 🌐 refactor: Implement SNI changer in Python 🛠️ refactor: Port TCP Brutal installation to Python 🎭 refactor: Rewrite masquerade management in Python3 ⚙️ refactor(cli): Enhance error propagation and handling in CLI scripts -🛠️ System Enhancements +### 🛠️ System Enhancements 🧩 update: Improve upgrade script with better cronjob management 🛠️ enhance: Setup script with robust error handling and cleaner UX 📦 update: Drop Debian 11 support to focus on supported OS versions \ No newline at end of file