Merge pull request #128 from ReturnFI/beta
feat(api): optimize user management and enhance CLI functionality 🚀👥
This commit is contained in:
12
changelog
12
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.
|
||||
|
||||
19
core/cli.py
19
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)
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
77
core/scripts/hysteria2/kickuser.py
Normal file
77
core/scripts/hysteria2/kickuser.py
Normal 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()
|
||||
@ -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"
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)}')
|
||||
|
||||
|
||||
@ -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:
|
||||
|
||||
Reference in New Issue
Block a user