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
|
### Changed
|
||||||
- Deprecated SingBox SubLink
|
- 🚀 **Optimization:** Improved user edit/remove functionality and eliminated unnecessary service restart delay.
|
||||||
- Change pinSHA256 to Hex
|
- 👥 **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')
|
@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):
|
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:
|
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,
|
cli_api.edit_user(username, new_username, new_traffic_limit, new_expiration_days,
|
||||||
renew_password, renew_creation_date, blocked)
|
renew_password, renew_creation_date, blocked)
|
||||||
click.echo(f"User '{username}' updated successfully.")
|
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)
|
@click.option('--username', '-u', required=True, help='Username for the user to remove', type=str)
|
||||||
def remove_user(username: str):
|
def remove_user(username: str):
|
||||||
try:
|
try:
|
||||||
|
cli_api.kick_user_by_name(username)
|
||||||
|
cli_api.traffic_status(display_output=False)
|
||||||
cli_api.remove_user(username)
|
cli_api.remove_user(username)
|
||||||
click.echo(f"User '{username}' removed successfully.")
|
click.echo(f"User '{username}' removed successfully.")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
click.echo(f'{e}', err=True)
|
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')
|
@cli.command('show-user-uri')
|
||||||
@click.option('--username', '-u', required=True, help='Username for the user to show the URI', type=str)
|
@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')
|
@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:
|
try:
|
||||||
cli_api.traffic_status()
|
cli_api.traffic_status(no_gui=no_gui)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
click.echo(f'{e}', err=True)
|
click.echo(f'{e}', err=True)
|
||||||
|
|
||||||
|
|||||||
@ -49,6 +49,8 @@ class Command(Enum):
|
|||||||
SERVICES_STATUS = os.path.join(SCRIPT_DIR, 'services_status.sh')
|
SERVICES_STATUS = os.path.join(SCRIPT_DIR, 'services_status.sh')
|
||||||
VERSION = os.path.join(SCRIPT_DIR, 'hysteria2', 'version.py')
|
VERSION = os.path.join(SCRIPT_DIR, 'hysteria2', 'version.py')
|
||||||
LIMIT_SCRIPT = os.path.join(SCRIPT_DIR, 'hysteria2', 'limit.sh')
|
LIMIT_SCRIPT = os.path.join(SCRIPT_DIR, 'hysteria2', 'limit.sh')
|
||||||
|
KICK_USER_SCRIPT = os.path.join(SCRIPT_DIR, 'hysteria2', 'kickuser.py')
|
||||||
|
|
||||||
|
|
||||||
# region Custom Exceptions
|
# region Custom Exceptions
|
||||||
|
|
||||||
@ -302,6 +304,17 @@ def remove_user(username: str):
|
|||||||
'''
|
'''
|
||||||
run_cmd(['bash', Command.REMOVE_USER.value, username])
|
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
|
# 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:
|
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
|
# region Server
|
||||||
|
|
||||||
|
|
||||||
def traffic_status():
|
def traffic_status(no_gui=False, display_output=False):
|
||||||
'''Fetches traffic status.'''
|
'''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
|
# TODO: it's better to return json
|
||||||
|
|||||||
@ -224,7 +224,7 @@ edit_user() {
|
|||||||
new_creation_date=${new_creation_date:-$creation_date}
|
new_creation_date=${new_creation_date:-$creation_date}
|
||||||
new_blocked=$(convert_blocked_status "${new_blocked:-$blocked}")
|
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
|
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
|
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."
|
echo -e "${red}Error:${NC} Config file $USERS_FILE not found."
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
python3 "$CLI_PATH" restart-hysteria2 > /dev/null 2>&1
|
# python3 "$CLI_PATH" restart-hysteria2 > /dev/null 2>&1
|
||||||
remove_user "$1"
|
remove_user "$1"
|
||||||
|
|||||||
@ -12,6 +12,9 @@ class UserInfoResponse(BaseModel):
|
|||||||
expiration_days: int
|
expiration_days: int
|
||||||
account_creation_date: str
|
account_creation_date: str
|
||||||
blocked: bool
|
blocked: bool
|
||||||
|
status: str | None = None
|
||||||
|
upload_bytes: int | None = None
|
||||||
|
download_bytes: int | None = None
|
||||||
|
|
||||||
|
|
||||||
class UserListResponse(RootModel): # type: ignore
|
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.
|
HTTPException: if an error occurs while editing the user.
|
||||||
"""
|
"""
|
||||||
try:
|
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,
|
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)
|
body.renew_password, body.renew_creation_date, body.blocked)
|
||||||
return DetailResponse(detail=f'User {username} has been edited.')
|
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.
|
A DetailResponse with a message indicating the user has been removed.
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
HTTPException: if an error occurs while removing the user.
|
HTTPException: 404 if the user is not found, 400 if another error occurs.
|
||||||
"""
|
"""
|
||||||
try:
|
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)
|
cli_api.remove_user(username)
|
||||||
return DetailResponse(detail=f'User {username} has been removed.')
|
return DetailResponse(detail=f'User {username} has been removed.')
|
||||||
|
except HTTPException:
|
||||||
|
|
||||||
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(status_code=400, detail=f'Error: {str(e)}')
|
raise HTTPException(status_code=400, detail=f'Error: {str(e)}')
|
||||||
|
|
||||||
@ -128,8 +139,14 @@ async def reset_user_api(username: str):
|
|||||||
HTTPException: if an error occurs while resetting the user.
|
HTTPException: if an error occurs while resetting the user.
|
||||||
"""
|
"""
|
||||||
try:
|
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)
|
cli_api.reset_user(username)
|
||||||
return DetailResponse(detail=f'User {username} has been reset.')
|
return DetailResponse(detail=f'User {username} has been reset.')
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(status_code=400, detail=f'Error: {str(e)}')
|
raise HTTPException(status_code=400, detail=f'Error: {str(e)}')
|
||||||
|
|
||||||
|
|||||||
@ -9,7 +9,7 @@ CONFIG_FILE = '/etc/hysteria/config.json'
|
|||||||
USERS_FILE = '/etc/hysteria/users.json'
|
USERS_FILE = '/etc/hysteria/users.json'
|
||||||
API_BASE_URL = 'http://127.0.0.1:25413'
|
API_BASE_URL = 'http://127.0.0.1:25413'
|
||||||
|
|
||||||
def traffic_status():
|
def traffic_status(no_gui=False):
|
||||||
green = '\033[0;32m'
|
green = '\033[0;32m'
|
||||||
cyan = '\033[0;36m'
|
cyan = '\033[0;36m'
|
||||||
NC = '\033[0m'
|
NC = '\033[0m'
|
||||||
@ -73,7 +73,10 @@ def traffic_status():
|
|||||||
with open(USERS_FILE, 'w') as users_file:
|
with open(USERS_FILE, 'w') as users_file:
|
||||||
json.dump(users_data, users_file, indent=4)
|
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):
|
def display_traffic_data(data, green, cyan, NC):
|
||||||
if not data:
|
if not data:
|
||||||
|
|||||||
Reference in New Issue
Block a user