Merge pull request #128 from ReturnFI/beta

feat(api): optimize user management and enhance CLI functionality 🚀👥
This commit is contained in:
Whispering Wind
2025-04-14 21:40:32 +03:30
committed by GitHub
9 changed files with 147 additions and 12 deletions

View File

@ -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.

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -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 <username>"
)
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()

View File

@ -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"

View File

@ -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

View File

@ -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)}')

View File

@ -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: