diff --git a/changelog b/changelog index 7e343a7..0ff56ed 100644 --- a/changelog +++ b/changelog @@ -1,5 +1,11 @@ -## [1.5.1] - 2025-04-11 +## [1.6.0] - 2025-04-14 ### Changed -- Deprecated SingBox SubLink -- Change pinSHA256 to Hex +- 🚀 **Optimization:** Improved user edit/remove functionality and eliminated unnecessary service restart delay. +- 👥 **User Management:** Enhanced kick functionality with the ability to target specific users and integrated advanced handling. +- 🔍 **API Enhancement:** Expanded `UserInfoResponse` class with additional details for a more comprehensive output. +- ❗ **Bug Fix:** Improved user removal/reset API to return proper 404 errors when applicable. +- ⚙️ **CLI Update:** Added a new `--no-gui` flag to the `traffic-status` command for enhanced flexibility. + +### Notes +- These updates aim to streamline user operations and improve overall system responsiveness. diff --git a/core/cli.py b/core/cli.py index 12d633e..8b720ba 100644 --- a/core/cli.py +++ b/core/cli.py @@ -148,6 +148,8 @@ def add_user(username: str, traffic_limit: int, expiration_days: int, password: @click.option('--blocked', '-b', is_flag=True, help='Block the user') def edit_user(username: str, new_username: str, new_traffic_limit: int, new_expiration_days: int, renew_password: bool, renew_creation_date: bool, blocked: bool): try: + cli_api.kick_user_by_name(username) + cli_api.traffic_status(display_output=False) cli_api.edit_user(username, new_username, new_traffic_limit, new_expiration_days, renew_password, renew_creation_date, blocked) click.echo(f"User '{username}' updated successfully.") @@ -169,11 +171,23 @@ def reset_user(username: str): @click.option('--username', '-u', required=True, help='Username for the user to remove', type=str) def remove_user(username: str): try: + cli_api.kick_user_by_name(username) + cli_api.traffic_status(display_output=False) cli_api.remove_user(username) click.echo(f"User '{username}' removed successfully.") except Exception as e: click.echo(f'{e}', err=True) +@cli.command('kick-user') +@click.option('--username', '-u', required=True, help='Username of the user to kick') +def kick_user(username: str): + """Kicks a specific user by username.""" + try: + cli_api.kick_user_by_name(username) + # click.echo(f"User '{username}' kicked successfully.") + except Exception as e: + click.echo(f'{e}', err=True) + @cli.command('show-user-uri') @click.option('--username', '-u', required=True, help='Username for the user to show the URI', type=str) @@ -197,9 +211,10 @@ def show_user_uri(username: str, qrcode: bool, ipv: int, all: bool, singbox: boo @cli.command('traffic-status') -def traffic_status(): +@click.option('--no-gui', is_flag=True, help='Retrieve traffic data without displaying output') +def traffic_status(no_gui): try: - cli_api.traffic_status() + cli_api.traffic_status(no_gui=no_gui) except Exception as e: click.echo(f'{e}', err=True) diff --git a/core/cli_api.py b/core/cli_api.py index e072bb0..70403ec 100644 --- a/core/cli_api.py +++ b/core/cli_api.py @@ -49,6 +49,8 @@ class Command(Enum): SERVICES_STATUS = os.path.join(SCRIPT_DIR, 'services_status.sh') VERSION = os.path.join(SCRIPT_DIR, 'hysteria2', 'version.py') LIMIT_SCRIPT = os.path.join(SCRIPT_DIR, 'hysteria2', 'limit.sh') + KICK_USER_SCRIPT = os.path.join(SCRIPT_DIR, 'hysteria2', 'kickuser.py') + # region Custom Exceptions @@ -302,6 +304,17 @@ def remove_user(username: str): ''' run_cmd(['bash', Command.REMOVE_USER.value, username]) +def kick_user_by_name(username: str): + '''Kicks a specific user by username.''' + if not username: + raise InvalidInputError('Username must be provided to kick a specific user.') + script_path = Command.KICK_USER_SCRIPT.value + if not os.path.exists(script_path): + raise ScriptNotFoundError(f"Kick user script not found at: {script_path}") + try: + subprocess.run(['python3', script_path, username], check=True) + except subprocess.CalledProcessError as e: + raise CommandExecutionError(f"Failed to execute kick user script: {e}") # TODO: it's better to return json def show_user_uri(username: str, qrcode: bool, ipv: int, all: bool, singbox: bool, normalsub: bool) -> str | None: @@ -326,9 +339,10 @@ def show_user_uri(username: str, qrcode: bool, ipv: int, all: bool, singbox: boo # region Server -def traffic_status(): +def traffic_status(no_gui=False, display_output=False): '''Fetches traffic status.''' - traffic.traffic_status() + data = traffic.traffic_status(no_gui=True if not display_output else no_gui) + return data # TODO: it's better to return json diff --git a/core/scripts/hysteria2/edit_user.sh b/core/scripts/hysteria2/edit_user.sh index 5deee56..d409d3d 100644 --- a/core/scripts/hysteria2/edit_user.sh +++ b/core/scripts/hysteria2/edit_user.sh @@ -224,7 +224,7 @@ edit_user() { new_creation_date=${new_creation_date:-$creation_date} new_blocked=$(convert_blocked_status "${new_blocked:-$blocked}") - python3 $CLI_PATH restart-hysteria2 +# python3 $CLI_PATH restart-hysteria2 if ! update_user_info "$username" "$new_username" "$new_password" "$new_traffic_limit" "$new_expiration_days" "$new_creation_date" "$new_blocked"; then return 1 # Update user failed diff --git a/core/scripts/hysteria2/kickuser.py b/core/scripts/hysteria2/kickuser.py new file mode 100644 index 0000000..465e86c --- /dev/null +++ b/core/scripts/hysteria2/kickuser.py @@ -0,0 +1,77 @@ +#!/usr/bin/env python3 + +import argparse +import json +import sys +import os +from hysteria2_api import Hysteria2Client, Hysteria2Error + +CONFIG_FILE = '/etc/hysteria/config.json' +API_BASE_URL = 'http://127.0.0.1:25413' + +def get_api_secret(config_path: str) -> str: + if not os.path.exists(config_path): + raise FileNotFoundError(f"Configuration file not found: {config_path}") + + try: + with open(config_path, 'r', encoding='utf-8') as f: + config_data = json.load(f) + + traffic_stats = config_data.get('trafficStats') + if not isinstance(traffic_stats, dict): + raise KeyError("Key 'trafficStats' not found or is not a dictionary in config") + + secret = traffic_stats.get('secret') + if not secret: + raise ValueError("Value for 'trafficStats.secret' not found or is empty in config") + + return secret + + except json.JSONDecodeError as e: + raise json.JSONDecodeError(f"Error parsing JSON file: {config_path} - {e.msg}", e.doc, e.pos) + except KeyError as e: + raise KeyError(f"Missing expected key {e} in {config_path}") + + +def main(): + parser = argparse.ArgumentParser( + description="Kick a Hysteria2 user via the API.", + usage="%(prog)s " + ) + parser.add_argument( + "username", + help="The username (Auth identity) to kick." + ) + args = parser.parse_args() + username_to_kick = args.username + + try: + api_secret = get_api_secret(CONFIG_FILE) + # print(api_secret) + # print(f"Kicking user: {username_to_kick}") + + client = Hysteria2Client( + base_url=API_BASE_URL, + secret=api_secret + ) + + client.kick_clients([username_to_kick]) + + # print(f"User '{username_to_kick}' kicked successfully.") + sys.exit(0) + + except (FileNotFoundError, KeyError, ValueError, json.JSONDecodeError) as e: + print(f"Configuration Error: {e}", file=sys.stderr) + sys.exit(1) + except Hysteria2Error as e: + print(f"API Error kicking user '{username_to_kick}': {e}", file=sys.stderr) + sys.exit(1) + except ConnectionError as e: + print(f"Connection Error: Could not connect to API at {API_BASE_URL}. Is it running? Details: {e}", file=sys.stderr) + sys.exit(1) + except Exception as e: + print(f"An unexpected error occurred: {e}", file=sys.stderr) + sys.exit(1) + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/core/scripts/hysteria2/remove_user.sh b/core/scripts/hysteria2/remove_user.sh index 1a396d2..361f1d7 100644 --- a/core/scripts/hysteria2/remove_user.sh +++ b/core/scripts/hysteria2/remove_user.sh @@ -24,5 +24,5 @@ remove_user() { echo -e "${red}Error:${NC} Config file $USERS_FILE not found." fi } -python3 "$CLI_PATH" restart-hysteria2 > /dev/null 2>&1 +# python3 "$CLI_PATH" restart-hysteria2 > /dev/null 2>&1 remove_user "$1" diff --git a/core/scripts/webpanel/routers/api/v1/schema/user.py b/core/scripts/webpanel/routers/api/v1/schema/user.py index 7b47b82..1db0af8 100644 --- a/core/scripts/webpanel/routers/api/v1/schema/user.py +++ b/core/scripts/webpanel/routers/api/v1/schema/user.py @@ -12,6 +12,9 @@ class UserInfoResponse(BaseModel): expiration_days: int account_creation_date: str blocked: bool + status: str | None = None + upload_bytes: int | None = None + download_bytes: int | None = None class UserListResponse(RootModel): # type: ignore diff --git a/core/scripts/webpanel/routers/api/v1/user.py b/core/scripts/webpanel/routers/api/v1/user.py index 4bcd7bb..a826bfd 100644 --- a/core/scripts/webpanel/routers/api/v1/user.py +++ b/core/scripts/webpanel/routers/api/v1/user.py @@ -85,6 +85,8 @@ async def edit_user_api(username: str, body: EditUserInputBody): HTTPException: if an error occurs while editing the user. """ try: + cli_api.kick_user_by_name(username) + cli_api.traffic_status(display_output=False) cli_api.edit_user(username, body.new_username, body.new_traffic_limit, body.new_expiration_days, body.renew_password, body.renew_creation_date, body.blocked) return DetailResponse(detail=f'User {username} has been edited.') @@ -104,11 +106,20 @@ async def remove_user_api(username: str): A DetailResponse with a message indicating the user has been removed. Raises: - HTTPException: if an error occurs while removing the user. + HTTPException: 404 if the user is not found, 400 if another error occurs. """ try: + user = cli_api.get_user(username) + if not user: + raise HTTPException(status_code=404, detail=f'User {username} not found.') + + cli_api.kick_user_by_name(username) + cli_api.traffic_status(display_output=False) cli_api.remove_user(username) return DetailResponse(detail=f'User {username} has been removed.') + except HTTPException: + + raise except Exception as e: raise HTTPException(status_code=400, detail=f'Error: {str(e)}') @@ -128,8 +139,14 @@ async def reset_user_api(username: str): HTTPException: if an error occurs while resetting the user. """ try: + user = cli_api.get_user(username) + if not user: + raise HTTPException(status_code=404, detail=f'User {username} not found.') + cli_api.reset_user(username) return DetailResponse(detail=f'User {username} has been reset.') + except HTTPException: + raise except Exception as e: raise HTTPException(status_code=400, detail=f'Error: {str(e)}') diff --git a/core/traffic.py b/core/traffic.py index 8a80fa3..251b14c 100644 --- a/core/traffic.py +++ b/core/traffic.py @@ -9,7 +9,7 @@ CONFIG_FILE = '/etc/hysteria/config.json' USERS_FILE = '/etc/hysteria/users.json' API_BASE_URL = 'http://127.0.0.1:25413' -def traffic_status(): +def traffic_status(no_gui=False): green = '\033[0;32m' cyan = '\033[0;36m' NC = '\033[0m' @@ -73,7 +73,10 @@ def traffic_status(): with open(USERS_FILE, 'w') as users_file: json.dump(users_data, users_file, indent=4) - display_traffic_data(users_data, green, cyan, NC) + if not no_gui: + display_traffic_data(users_data, green, cyan, NC) + + return users_data def display_traffic_data(data, green, cyan, NC): if not data: