Merge pull request #247 from ReturnFI/beta
Per-user unlimited IP support & web panel integration
This commit is contained in:
38
changelog
38
changelog
@ -1,10 +1,32 @@
|
||||
# [1.13.0] - 2025-08-10
|
||||
# [1.14.0] - 2025-08-13
|
||||
|
||||
#### ✨ UI Enhancements
|
||||
#### ✨ New Features
|
||||
|
||||
* ✨ feat(core): Implement external node management system
|
||||
* 🌐 feat(api): Add external node management endpoints
|
||||
* 🔗 feat(normalsub): Add external node URIs to subscriptions
|
||||
* 💾 feat: Backup nodes.json
|
||||
* 🛠️ fix: Robust parsing, unlimited user handling, and various script/UI issues
|
||||
* 📦 chore: Dependency updates (pytelegrambotapi, aiohttp)
|
||||
* 🌐 **Per-User Unlimited IP Option**:
|
||||
|
||||
* Added `unlimited_user` flag to exempt specific users from concurrent IP limits
|
||||
* Works in both CLI and web panel
|
||||
* Integrated into `limit.sh` for enforcement bypass
|
||||
* 🖥️ **Web Panel Enhancements**:
|
||||
|
||||
* Unlimited IP control added to **Users** page
|
||||
* IP limit UI now conditionally shown based on service status
|
||||
* 🛠️ **CLI & Scripts**:
|
||||
|
||||
* Add unlimited IP option to user creation and editing
|
||||
* Menu script now supports unlimited IP configuration
|
||||
|
||||
#### 🔄 Improvements
|
||||
|
||||
* 📜 **API**:
|
||||
|
||||
* Updated user schemas to match CLI output and new unlimited IP feature
|
||||
* 🐛 **Fixes**:
|
||||
|
||||
* Corrected type hints for optional parameters in `config_ip_limiter`
|
||||
|
||||
#### 📦 Dependency Updates
|
||||
|
||||
* ⬆️ `anyio` → 4.10.0
|
||||
* ⬆️ `certbot` → 4.2.0
|
||||
* ⬆️ `charset-normalizer` → 3.4.3
|
||||
|
||||
12
core/cli.py
12
core/cli.py
@ -130,9 +130,10 @@ def get_user(username: str):
|
||||
@click.option('--expiration-days', '-e', required=True, help='Expiration days for the new user', type=int)
|
||||
@click.option('--password', '-p', required=False, help='Password for the user', type=str)
|
||||
@click.option('--creation-date', '-c', required=False, help='Creation date for the user (YYYY-MM-DD)', type=str)
|
||||
def add_user(username: str, traffic_limit: int, expiration_days: int, password: str, creation_date: str):
|
||||
@click.option('--unlimited', is_flag=True, default=False, help='Exempt user from IP limit checks.')
|
||||
def add_user(username: str, traffic_limit: int, expiration_days: int, password: str, creation_date: str, unlimited: bool):
|
||||
try:
|
||||
cli_api.add_user(username, traffic_limit, expiration_days, password, creation_date)
|
||||
cli_api.add_user(username, traffic_limit, expiration_days, password, creation_date, unlimited)
|
||||
click.echo(f"User '{username}' added successfully.")
|
||||
except Exception as e:
|
||||
click.echo(f'{e}', err=True)
|
||||
@ -145,13 +146,14 @@ def add_user(username: str, traffic_limit: int, expiration_days: int, password:
|
||||
@click.option('--new-expiration-days', '-ne', required=False, help='Expiration days for the new user', type=int)
|
||||
@click.option('--renew-password', '-rp', is_flag=True, help='Renew password for the user')
|
||||
@click.option('--renew-creation-date', '-rc', is_flag=True, help='Renew creation date for 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):
|
||||
@click.option('--blocked/--unblocked', 'blocked', default=None, help='Block or unblock the user.')
|
||||
@click.option('--unlimited-ip/--limited-ip', 'unlimited_ip', default=None, help='Set user to be exempt from or subject to IP limits.')
|
||||
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 | None, unlimited_ip: bool | None):
|
||||
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)
|
||||
renew_password, renew_creation_date, blocked, unlimited_ip)
|
||||
click.echo(f"User '{username}' updated successfully.")
|
||||
except Exception as e:
|
||||
click.echo(f'{e}', err=True)
|
||||
|
||||
@ -3,7 +3,7 @@ import subprocess
|
||||
from enum import Enum
|
||||
from datetime import datetime
|
||||
import json
|
||||
from typing import Any
|
||||
from typing import Any, Optional
|
||||
from dotenv import dotenv_values
|
||||
|
||||
import traffic
|
||||
@ -266,18 +266,26 @@ def get_user(username: str) -> dict[str, Any] | None:
|
||||
return json.loads(res)
|
||||
|
||||
|
||||
def add_user(username: str, traffic_limit: int, expiration_days: int, password: str | None, creation_date: str | None):
|
||||
def add_user(username: str, traffic_limit: int, expiration_days: int, password: str | None, creation_date: str | None, unlimited: bool):
|
||||
'''
|
||||
Adds a new user with the given parameters.
|
||||
Adds a new user with the given parameters, respecting positional argument requirements.
|
||||
'''
|
||||
if not password:
|
||||
password = generate_password()
|
||||
if not creation_date:
|
||||
creation_date = datetime.now().strftime('%Y-%m-%d')
|
||||
run_cmd(['python3', Command.ADD_USER.value, username, str(traffic_limit), str(expiration_days), password, creation_date])
|
||||
command = ['python3', Command.ADD_USER.value, username, str(traffic_limit), str(expiration_days)]
|
||||
|
||||
if unlimited:
|
||||
final_password = password if password else generate_password()
|
||||
final_creation_date = creation_date if creation_date else datetime.now().strftime('%Y-%m-%d')
|
||||
command.extend([final_password, final_creation_date, 'true'])
|
||||
elif creation_date:
|
||||
final_password = password if password else generate_password()
|
||||
command.extend([final_password, creation_date])
|
||||
elif password:
|
||||
command.append(password)
|
||||
|
||||
run_cmd(command)
|
||||
|
||||
|
||||
def edit_user(username: str, new_username: str | None, new_traffic_limit: int | None, new_expiration_days: int | None, renew_password: bool, renew_creation_date: bool, blocked: bool):
|
||||
def edit_user(username: str, new_username: str | None, new_traffic_limit: int | None, new_expiration_days: int | None, renew_password: bool, renew_creation_date: bool, blocked: bool | None, unlimited_ip: bool | None):
|
||||
'''
|
||||
Edits an existing user's details.
|
||||
'''
|
||||
@ -289,15 +297,20 @@ def edit_user(username: str, new_username: str | None, new_traffic_limit: int |
|
||||
if new_expiration_days is not None and new_expiration_days < 0:
|
||||
raise InvalidInputError('Error: expiration days must be a non-negative number.')
|
||||
|
||||
if renew_password:
|
||||
password = generate_password()
|
||||
else:
|
||||
password = ''
|
||||
password = generate_password() if renew_password else ''
|
||||
creation_date = datetime.now().strftime('%Y-%m-%d') if renew_creation_date else ''
|
||||
|
||||
if renew_creation_date:
|
||||
creation_date = datetime.now().strftime('%Y-%m-%d')
|
||||
else:
|
||||
creation_date = ''
|
||||
blocked_str = ''
|
||||
if blocked is True:
|
||||
blocked_str = 'true'
|
||||
elif blocked is False:
|
||||
blocked_str = 'false'
|
||||
|
||||
unlimited_str = ''
|
||||
if unlimited_ip is True:
|
||||
unlimited_str = 'true'
|
||||
elif unlimited_ip is False:
|
||||
unlimited_str = 'false'
|
||||
|
||||
command_args = [
|
||||
'bash',
|
||||
@ -308,7 +321,8 @@ def edit_user(username: str, new_username: str | None, new_traffic_limit: int |
|
||||
str(new_expiration_days) if new_expiration_days is not None else '',
|
||||
password,
|
||||
creation_date,
|
||||
'true' if blocked else 'false'
|
||||
blocked_str,
|
||||
unlimited_str
|
||||
]
|
||||
run_cmd(command_args)
|
||||
|
||||
@ -655,7 +669,7 @@ def stop_ip_limiter():
|
||||
'''Stops the IP limiter service.'''
|
||||
run_cmd(['bash', Command.LIMIT_SCRIPT.value, 'stop'])
|
||||
|
||||
def config_ip_limiter(block_duration: int = None, max_ips: int = None):
|
||||
def config_ip_limiter(block_duration: Optional[int] = None, max_ips: Optional[int] = None):
|
||||
'''Configures the IP limiter service.'''
|
||||
if block_duration is not None and block_duration <= 0:
|
||||
raise InvalidInputError("Block duration must be greater than 0.")
|
||||
|
||||
@ -9,7 +9,7 @@ from datetime import datetime
|
||||
from init_paths import *
|
||||
from paths import *
|
||||
|
||||
def add_user(username, traffic_gb, expiration_days, password=None, creation_date=None):
|
||||
def add_user(username, traffic_gb, expiration_days, password=None, creation_date=None, unlimited_user=False):
|
||||
"""
|
||||
Adds a new user to the USERS_FILE.
|
||||
|
||||
@ -19,12 +19,13 @@ def add_user(username, traffic_gb, expiration_days, password=None, creation_date
|
||||
expiration_days (str): The number of days until the account expires.
|
||||
password (str, optional): The user's password. If None, a random one is generated.
|
||||
creation_date (str, optional): The account creation date in YYYY-MM-DD format. If None, the current date is used.
|
||||
unlimited_user (bool, optional): If True, user is exempt from IP limits. Defaults to False.
|
||||
|
||||
Returns:
|
||||
int: 0 on success, 1 on failure.
|
||||
"""
|
||||
if not username or not traffic_gb or not expiration_days:
|
||||
print(f"Usage: {sys.argv[0]} <username> <traffic_limit_GB> <expiration_days> [password] [creation_date]")
|
||||
print(f"Usage: {sys.argv[0]} <username> <traffic_limit_GB> <expiration_days> [password] [creation_date] [unlimited_user (true/false)]")
|
||||
return 1
|
||||
|
||||
try:
|
||||
@ -45,6 +46,7 @@ def add_user(username, traffic_gb, expiration_days, password=None, creation_date
|
||||
password = subprocess.check_output(['cat', '/proc/sys/kernel/random/uuid'], text=True).strip()
|
||||
except Exception:
|
||||
print("Error: Failed to generate password. Please install 'pwgen' or ensure /proc access.")
|
||||
return 1
|
||||
|
||||
if not creation_date:
|
||||
creation_date = datetime.now().strftime("%Y-%m-%d")
|
||||
@ -88,7 +90,8 @@ def add_user(username, traffic_gb, expiration_days, password=None, creation_date
|
||||
"max_download_bytes": traffic_bytes,
|
||||
"expiration_days": expiration_days,
|
||||
"account_creation_date": creation_date,
|
||||
"blocked": False
|
||||
"blocked": False,
|
||||
"unlimited_user": unlimited_user
|
||||
}
|
||||
|
||||
f.seek(0)
|
||||
@ -103,8 +106,8 @@ def add_user(username, traffic_gb, expiration_days, password=None, creation_date
|
||||
return 1
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) not in [4, 6]:
|
||||
print(f"Usage: {sys.argv[0]} <username> <traffic_limit_GB> <expiration_days> [password] [creation_date]")
|
||||
if len(sys.argv) < 4 or len(sys.argv) > 7:
|
||||
print(f"Usage: {sys.argv[0]} <username> <traffic_limit_GB> <expiration_days> [password] [creation_date] [unlimited_user (true/false)]")
|
||||
sys.exit(1)
|
||||
|
||||
username = sys.argv[1]
|
||||
@ -112,6 +115,8 @@ if __name__ == "__main__":
|
||||
expiration_days = sys.argv[3]
|
||||
password = sys.argv[4] if len(sys.argv) > 4 else None
|
||||
creation_date = sys.argv[5] if len(sys.argv) > 5 else None
|
||||
unlimited_user_str = sys.argv[6] if len(sys.argv) > 6 else "false"
|
||||
unlimited_user = unlimited_user_str.lower() == 'true'
|
||||
|
||||
exit_code = add_user(username, traffic_gb, expiration_days, password, creation_date)
|
||||
exit_code = add_user(username, traffic_gb, expiration_days, password, creation_date, unlimited_user)
|
||||
sys.exit(exit_code)
|
||||
@ -8,7 +8,7 @@ readonly GB_TO_BYTES=$((1024 * 1024 * 1024))
|
||||
validate_username() {
|
||||
local username=$1
|
||||
if [ -z "$username" ]; then
|
||||
return 0 # Optional value is valid
|
||||
return 0
|
||||
fi
|
||||
if ! [[ "$username" =~ ^[a-zA-Z0-9]+$ ]]; then
|
||||
echo "Username can only contain letters and numbers."
|
||||
@ -21,7 +21,7 @@ validate_username() {
|
||||
validate_traffic_limit() {
|
||||
local traffic_limit=$1
|
||||
if [ -z "$traffic_limit" ]; then
|
||||
return 0 # Optional value is valid
|
||||
return 0
|
||||
fi
|
||||
if ! [[ "$traffic_limit" =~ ^[0-9]+$ ]]; then
|
||||
echo "Error: Traffic limit must be a valid non-negative number (use 0 for unlimited)."
|
||||
@ -33,7 +33,7 @@ validate_traffic_limit() {
|
||||
validate_expiration_days() {
|
||||
local expiration_days=$1
|
||||
if [ -z "$expiration_days" ]; then
|
||||
return 0 # Optional value is valid
|
||||
return 0
|
||||
fi
|
||||
if ! [[ "$expiration_days" =~ ^[0-9]+$ ]]; then
|
||||
echo "Error: Expiration days must be a valid non-negative number (use 0 for unlimited)."
|
||||
@ -45,7 +45,7 @@ validate_expiration_days() {
|
||||
validate_date() {
|
||||
local date_str=$1
|
||||
if [ -z "$date_str" ]; then
|
||||
return 0 # Optional value is valid
|
||||
return 0
|
||||
fi
|
||||
if ! [[ "$date_str" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}$ ]]; then
|
||||
echo "Invalid date format. Expected YYYY-MM-DD."
|
||||
@ -60,7 +60,7 @@ validate_date() {
|
||||
validate_blocked_status() {
|
||||
local status=$1
|
||||
if [ -z "$status" ]; then
|
||||
return 0 # Optional value is valid
|
||||
return 0
|
||||
fi
|
||||
if [ "$status" != "true" ] && [ "$status" != "false" ]; then
|
||||
echo "Blocked status must be 'true' or 'false'."
|
||||
@ -69,14 +69,26 @@ validate_blocked_status() {
|
||||
return 0
|
||||
}
|
||||
|
||||
validate_unlimited_status() {
|
||||
local status=$1
|
||||
if [ -z "$status" ]; then
|
||||
return 0
|
||||
fi
|
||||
if [ "$status" != "true" ] && [ "$status" != "false" ]; then
|
||||
echo "Unlimited status must be 'true' or 'false'."
|
||||
return 1
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
convert_blocked_status() {
|
||||
|
||||
convert_boolean_status() {
|
||||
local status=$1
|
||||
case "$status" in
|
||||
y|Y) echo "true" ;;
|
||||
n|N) echo "false" ;;
|
||||
true|false) echo "$status" ;;
|
||||
*) echo "false" ;; # Default to false for safety
|
||||
*) echo "false" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
@ -93,6 +105,7 @@ update_user_info() {
|
||||
local new_expiration_days=$5
|
||||
local new_account_creation_date=$6
|
||||
local new_blocked=$7
|
||||
local new_unlimited=$8
|
||||
|
||||
if [ ! -f "$USERS_FILE" ]; then
|
||||
echo -e "${red}Error:${NC} File '$USERS_FILE' not found."
|
||||
@ -120,9 +133,9 @@ update_user_info() {
|
||||
echo "Expiration Days: ${new_expiration_days:-(not changed)}"
|
||||
echo "Creation Date: ${new_account_creation_date:-(not changed)}"
|
||||
echo "Blocked: $new_blocked"
|
||||
echo "Unlimited IP: $new_unlimited"
|
||||
|
||||
|
||||
# Update user fields, only if new values are provided
|
||||
jq \
|
||||
--arg old_username "$old_username" \
|
||||
--arg new_username "$new_username" \
|
||||
@ -130,19 +143,21 @@ update_user_info() {
|
||||
--argjson max_download_bytes "${new_max_download_bytes:-null}" \
|
||||
--argjson expiration_days "${new_expiration_days:-null}" \
|
||||
--arg account_creation_date "${new_account_creation_date:-null}" \
|
||||
--argjson blocked "$(convert_blocked_status "${new_blocked:-false}")" \
|
||||
--argjson blocked "$new_blocked" \
|
||||
--argjson unlimited "$new_unlimited" \
|
||||
--argjson upload_bytes "$upload_bytes" \
|
||||
--argjson download_bytes "$download_bytes" \
|
||||
--arg status "$status" \
|
||||
'
|
||||
.[$new_username] = .[$old_username] |
|
||||
del(.[$old_username]) |
|
||||
(if $old_username != $new_username then del(.[$old_username]) else . end) |
|
||||
.[$new_username] |= (
|
||||
.password = ($password // .password) |
|
||||
.max_download_bytes = ($max_download_bytes // .max_download_bytes) |
|
||||
.expiration_days = ($expiration_days // .expiration_days) |
|
||||
.account_creation_date = ($account_creation_date // .account_creation_date) |
|
||||
.blocked = $blocked |
|
||||
.unlimited_user = $unlimited |
|
||||
.upload_bytes = $upload_bytes |
|
||||
.download_bytes = $download_bytes |
|
||||
.status = $status
|
||||
@ -166,6 +181,7 @@ edit_user() {
|
||||
local new_password=$5
|
||||
local new_creation_date=$6
|
||||
local new_blocked=$7
|
||||
local new_unlimited=$8
|
||||
|
||||
|
||||
local user_info=$(get_user_info "$username")
|
||||
@ -180,6 +196,7 @@ edit_user() {
|
||||
local expiration_days=$(echo "$user_info" | jq -r '.expiration_days')
|
||||
local creation_date=$(echo "$user_info" | jq -r '.account_creation_date')
|
||||
local blocked=$(echo "$user_info" | jq -r '.blocked')
|
||||
local unlimited_user=$(echo "$user_info" | jq -r '.unlimited_user // false')
|
||||
|
||||
if ! validate_username "$new_username"; then
|
||||
echo "Invalid username: $new_username"
|
||||
@ -208,6 +225,10 @@ edit_user() {
|
||||
return 1
|
||||
fi
|
||||
|
||||
if ! validate_unlimited_status "$new_unlimited"; then
|
||||
echo "Invalid unlimited status: $new_unlimited"
|
||||
return 1
|
||||
fi
|
||||
|
||||
|
||||
new_username=${new_username:-$username}
|
||||
@ -222,19 +243,18 @@ edit_user() {
|
||||
|
||||
new_expiration_days=${new_expiration_days:-$expiration_days}
|
||||
new_creation_date=${new_creation_date:-$creation_date}
|
||||
new_blocked=$(convert_blocked_status "${new_blocked:-$blocked}")
|
||||
new_blocked=$(convert_boolean_status "${new_blocked:-$blocked}")
|
||||
new_unlimited=$(convert_boolean_status "${new_unlimited:-$unlimited_user}")
|
||||
|
||||
# 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
|
||||
if ! update_user_info "$username" "$new_username" "$new_password" "$new_traffic_limit" "$new_expiration_days" "$new_creation_date" "$new_blocked" "$new_unlimited"; then
|
||||
return 1
|
||||
fi
|
||||
|
||||
echo "User updated successfully."
|
||||
return 0 # Operation complete without error.
|
||||
return 0
|
||||
|
||||
}
|
||||
|
||||
|
||||
# Run the script
|
||||
edit_user "$1" "$2" "$3" "$4" "$5" "$6" "$7"
|
||||
edit_user "$1" "$2" "$3" "$4" "$5" "$6" "$7" "$8"
|
||||
@ -183,6 +183,23 @@ check_ip_limit() {
|
||||
local username="$1"
|
||||
local ips=()
|
||||
|
||||
local is_unlimited="false"
|
||||
if [ -f "$USERS_FILE" ]; then
|
||||
if command -v jq &>/dev/null; then
|
||||
is_unlimited=$(jq -r --arg user "$username" '.[$user].unlimited_user // "false"' "$USERS_FILE" 2>/dev/null)
|
||||
else
|
||||
if grep -q "\"$username\"" "$USERS_FILE" && \
|
||||
grep -A 5 "\"$username\"" "$USERS_FILE" | grep -q '"unlimited_user": true'; then
|
||||
is_unlimited="true"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ "$is_unlimited" = "true" ]; then
|
||||
log_message "INFO" "User $username is exempt from IP limit. Skipping check."
|
||||
return
|
||||
fi
|
||||
|
||||
# Get all IPs for this user
|
||||
if command -v jq &>/dev/null; then
|
||||
readarray -t ips < <(jq -r --arg user "$username" '.[$user][]' "$CONNECTIONS_FILE" 2>/dev/null)
|
||||
|
||||
@ -1,23 +1,20 @@
|
||||
from typing import Optional, List
|
||||
from pydantic import BaseModel, RootModel
|
||||
from pydantic import BaseModel, RootModel, Field
|
||||
|
||||
|
||||
# WE CAN'T USE SHARED SCHEMA BECAUSE THE CLI IS RETURNING SAME FIELD IN DIFFERENT NAMES SOMETIMES
|
||||
# WHAT I'M SAYING IS THAT OUR CODE IN HERE WILL BE SPAGHETTI CODE IF WE WANT TO HAVE CONSISTENCY IN ALL RESPONSES OF THE APIs
|
||||
# THE MAIN PROBLEM IS IN THE CLI CODE NOT IN THE WEB PANEL CODE (HERE)
|
||||
|
||||
class UserInfoResponse(BaseModel):
|
||||
password: str
|
||||
max_download_bytes: int
|
||||
expiration_days: int
|
||||
account_creation_date: str
|
||||
blocked: bool
|
||||
status: str | None = None
|
||||
upload_bytes: int | None = None
|
||||
download_bytes: int | None = None
|
||||
unlimited_ip: bool = Field(False, alias='unlimited_user')
|
||||
status: Optional[str] = None
|
||||
upload_bytes: Optional[int] = None
|
||||
download_bytes: Optional[int] = None
|
||||
|
||||
|
||||
class UserListResponse(RootModel): # type: ignore
|
||||
class UserListResponse(RootModel):
|
||||
root: dict[str, UserInfoResponse]
|
||||
|
||||
|
||||
@ -25,18 +22,19 @@ class AddUserInputBody(BaseModel):
|
||||
username: str
|
||||
traffic_limit: int
|
||||
expiration_days: int
|
||||
password: str | None = None
|
||||
creation_date: str | None = None
|
||||
password: Optional[str] = None
|
||||
creation_date: Optional[str] = None
|
||||
unlimited: bool = False
|
||||
|
||||
|
||||
class EditUserInputBody(BaseModel):
|
||||
# username: str
|
||||
new_username: str | None = None
|
||||
new_traffic_limit: int | None = None
|
||||
new_expiration_days: int | None = None
|
||||
new_username: Optional[str] = None
|
||||
new_traffic_limit: Optional[int] = None
|
||||
new_expiration_days: Optional[int] = None
|
||||
renew_password: bool = False
|
||||
renew_creation_date: bool = False
|
||||
blocked: bool = False
|
||||
blocked: Optional[bool] = None
|
||||
unlimited_ip: Optional[bool] = None
|
||||
|
||||
class NodeUri(BaseModel):
|
||||
name: str
|
||||
|
||||
@ -39,7 +39,7 @@ async def add_user_api(body: AddUserInputBody):
|
||||
detail=f"{str(e)}")
|
||||
|
||||
try:
|
||||
cli_api.add_user(body.username, body.traffic_limit, body.expiration_days, body.password, body.creation_date)
|
||||
cli_api.add_user(body.username, body.traffic_limit, body.expiration_days, body.password, body.creation_date, body.unlimited)
|
||||
return DetailResponse(detail=f'User {body.username} has been added.')
|
||||
except cli_api.CommandExecutionError as e:
|
||||
if "User already exists" in str(e):
|
||||
@ -98,7 +98,7 @@ async def edit_user_api(username: str, body: EditUserInputBody):
|
||||
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)
|
||||
body.renew_password, body.renew_creation_date, body.blocked, body.unlimited_ip)
|
||||
return DetailResponse(detail=f'User {username} has been edited.')
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=f'Error: {str(e)}')
|
||||
|
||||
@ -10,6 +10,7 @@ class User(BaseModel):
|
||||
expiry_date: str
|
||||
expiry_days: str
|
||||
enable: bool
|
||||
unlimited_ip: bool
|
||||
|
||||
@staticmethod
|
||||
def from_dict(username: str, user_data: dict):
|
||||
@ -58,6 +59,7 @@ class User(BaseModel):
|
||||
'expiry_date': display_expiry_date,
|
||||
'expiry_days': display_expiry_days,
|
||||
'enable': not user_data.get('blocked', False),
|
||||
'unlimited_ip': user_data.get('unlimited_user', False)
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
|
||||
@ -76,6 +76,7 @@
|
||||
<th class="text-nowrap">Expiry Date</th>
|
||||
<th class="text-nowrap">Expiry Days</th>
|
||||
<th>Enable</th>
|
||||
<th class="text-nowrap requires-iplimit-service" style="display: none;">Unlimited IP</th>
|
||||
<th class="text-nowrap">Configs</th>
|
||||
<th class="text-nowrap">Actions</th>
|
||||
</tr>
|
||||
@ -84,7 +85,7 @@
|
||||
{% for user in users|sort(attribute='username', case_sensitive=false) %}
|
||||
<tr>
|
||||
<td>
|
||||
<input type="checkbox" class="user-checkbox" value="{{ user.username }}">
|
||||
<input type="checkbox" class="user-checkbox" value="{{ user['username'] }}">
|
||||
</td>
|
||||
<td>{{ loop.index }}</td>
|
||||
<td>
|
||||
@ -96,35 +97,42 @@
|
||||
<i class="fas fa-circle text-danger"></i> {{ user['status'] }}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td data-username="{{ user.username }}">{{ user.username }}</td>
|
||||
<td>{{ user.traffic_used }}</td>
|
||||
<td>{{ user.expiry_date }}</td>
|
||||
<td>{{ user.expiry_days }}</td>
|
||||
<td data-username="{{ user['username'] }}">{{ user['username'] }}</td>
|
||||
<td>{{ user['traffic_used'] }}</td>
|
||||
<td>{{ user['expiry_date'] }}</td>
|
||||
<td>{{ user['expiry_days'] }}</td>
|
||||
<td>
|
||||
{% if user.enable %}
|
||||
{% if user['enable'] %}
|
||||
<i class="fas fa-check-circle text-success"></i>
|
||||
{% else %}
|
||||
<i class="fas fa-times-circle text-danger"></i>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="unlimited-ip-cell requires-iplimit-service" style="display: none;">
|
||||
{% if user['unlimited_ip'] %}
|
||||
<i class="fas fa-shield-alt text-primary"></i>
|
||||
{% else %}
|
||||
<i class="fas fa-times-circle text-muted"></i>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="text-nowrap">
|
||||
<a href="#" class="config-link" data-toggle="modal" data-target="#qrcodeModal"
|
||||
data-username="{{ user.username }}">
|
||||
data-username="{{ user['username'] }}">
|
||||
<i class="fas fa-qrcode"></i>
|
||||
</a>
|
||||
</td>
|
||||
<td class="text-nowrap">
|
||||
<button type="button" class="btn btn-sm btn-info edit-user"
|
||||
data-user='{{ user.username }}' data-toggle="modal"
|
||||
data-user="{{ user['username'] }}" data-toggle="modal"
|
||||
data-target="#editUserModal">
|
||||
<i class="fas fa-edit"></i>
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm btn-warning reset-user"
|
||||
data-user='{{ user.username }}'>
|
||||
data-user="{{ user['username'] }}">
|
||||
<i class="fas fa-undo"></i>
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm btn-danger delete-user"
|
||||
data-user='{{ user.username }}'>
|
||||
data-user="{{ user['username'] }}">
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
</td>
|
||||
@ -173,6 +181,10 @@
|
||||
<input type="number" class="form-control" id="addExpirationDays" name="expiration_days"
|
||||
required>
|
||||
</div>
|
||||
<div class="form-check mb-3 requires-iplimit-service" style="display: none;">
|
||||
<input type="checkbox" class="form-check-input" id="addUnlimited" name="unlimited">
|
||||
<label class="form-check-label" for="addUnlimited">Unlimited IP (Exempt from IP limit checks)</label>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary" id="addSubmitButton">Add User</button>
|
||||
</form>
|
||||
</div>
|
||||
@ -206,9 +218,13 @@
|
||||
<input type="number" class="form-control" id="editExpirationDays" name="new_expiration_days">
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input type="checkbox" class="form-check-input" id="editBlocked" name="blocked" value="true">
|
||||
<input type="checkbox" class="form-check-input" id="editBlocked" name="blocked">
|
||||
<label class="form-check-label" for="editBlocked">Blocked</label>
|
||||
</div>
|
||||
<div class="form-check mb-3 requires-iplimit-service" style="display: none;">
|
||||
<input type="checkbox" class="form-check-input" id="editUnlimitedIp" name="unlimited_ip">
|
||||
<label class="form-check-label" for="editUnlimitedIp">Unlimited IP (Exempt from IP limit checks)</label>
|
||||
</div>
|
||||
<input type="hidden" id="originalUsername" name="username">
|
||||
<button type="submit" class="btn btn-primary" id="editSubmitButton">Save Changes</button>
|
||||
</form>
|
||||
@ -243,6 +259,19 @@
|
||||
<script>
|
||||
$(function () {
|
||||
|
||||
function checkIpLimitServiceStatus() {
|
||||
fetch('{{ url_for("server_services_status_api") }}')
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.hysteria_iplimit === true) {
|
||||
$('.requires-iplimit-service').show();
|
||||
}
|
||||
})
|
||||
.catch(error => console.error('Error fetching IP limit service status:', error));
|
||||
}
|
||||
|
||||
checkIpLimitServiceStatus();
|
||||
|
||||
const usernameRegex = /^[a-zA-Z0-9]+$/;
|
||||
|
||||
function validateUsername(username, errorElementId) {
|
||||
@ -259,7 +288,6 @@
|
||||
}
|
||||
|
||||
$("#addSubmitButton").prop("disabled", true);
|
||||
// $("#editSubmitButton").prop("disabled", true);
|
||||
|
||||
$("#addUsername").on("input", function () {
|
||||
const username = $(this).val();
|
||||
@ -387,11 +415,12 @@
|
||||
}
|
||||
$("#addSubmitButton").prop("disabled", true);
|
||||
|
||||
const formData = $(this).serializeArray();
|
||||
const jsonData = {};
|
||||
formData.forEach(field => {
|
||||
jsonData[field.name] = field.value;
|
||||
});
|
||||
const jsonData = {
|
||||
username: $("#addUsername").val(),
|
||||
traffic_limit: $("#addTrafficLimit").val(),
|
||||
expiration_days: $("#addExpirationDays").val(),
|
||||
unlimited: $("#addUnlimited").is(":checked")
|
||||
};
|
||||
|
||||
$.ajax({
|
||||
url: " {{ url_for('add_user_api') }} ",
|
||||
@ -435,6 +464,7 @@
|
||||
const trafficUsageText = row.find("td:eq(4)").text().trim();
|
||||
const expiryDaysText = row.find("td:eq(6)").text().trim();
|
||||
const blocked = row.find("td:eq(7) i").hasClass("text-danger");
|
||||
const unlimited_ip = row.find(".unlimited-ip-cell i").hasClass("text-primary");
|
||||
|
||||
const expiryDaysValue = (expiryDaysText.toLowerCase() === 'unlimited') ? 0 : parseInt(expiryDaysText, 10);
|
||||
|
||||
@ -455,6 +485,7 @@
|
||||
$("#editTrafficLimit").val(trafficLimitValue);
|
||||
$("#editExpirationDays").val(expiryDaysValue);
|
||||
$("#editBlocked").prop("checked", blocked);
|
||||
$("#editUnlimitedIp").prop("checked", unlimited_ip);
|
||||
|
||||
const isValid = validateUsername(username, "editUsernameError");
|
||||
$("#editUserForm button[type='submit']").prop("disabled", !isValid);
|
||||
@ -465,12 +496,19 @@
|
||||
if (!validateUsername($("#editUsername").val(), "editUsernameError")) {
|
||||
return;
|
||||
}
|
||||
$("#editSubmitButton").prop("disabled", true);
|
||||
|
||||
const formData = $(this).serializeArray();
|
||||
const jsonData = {};
|
||||
formData.forEach(field => {
|
||||
jsonData[field.name] = field.value;
|
||||
});
|
||||
const jsonData = {
|
||||
new_username: $("#editUsername").val(),
|
||||
new_traffic_limit: $("#editTrafficLimit").val() || null,
|
||||
new_expiration_days: $("#editExpirationDays").val() || null,
|
||||
blocked: $("#editBlocked").is(":checked"),
|
||||
unlimited_ip: $("#editUnlimitedIp").is(":checked")
|
||||
};
|
||||
|
||||
if (jsonData.new_username === $("#originalUsername").val()) {
|
||||
delete jsonData.new_username;
|
||||
}
|
||||
|
||||
const editUserUrl = "{{ url_for('edit_user_api', username='USERNAME_PLACEHOLDER') }}";
|
||||
const url = editUserUrl.replace("USERNAME_PLACEHOLDER", encodeURIComponent($("#originalUsername").val()));
|
||||
@ -481,22 +519,8 @@
|
||||
contentType: "application/json",
|
||||
data: JSON.stringify(jsonData),
|
||||
success: function (response) {
|
||||
if (typeof response === 'string' && response.includes("User updated successfully")) {
|
||||
if (response && response.detail) {
|
||||
$("#editUserModal").modal("hide");
|
||||
Swal.fire({
|
||||
title: "Success!",
|
||||
text: "User updated successfully!",
|
||||
icon: "success",
|
||||
confirmButtonText: "OK",
|
||||
}).then(() => {
|
||||
location.reload();
|
||||
});
|
||||
}
|
||||
else if (response && response.detail) {
|
||||
// Hide the modal
|
||||
$("#editUserModal").modal("hide");
|
||||
|
||||
// Show a success message
|
||||
Swal.fire({
|
||||
title: "Success!",
|
||||
text: response.detail,
|
||||
@ -509,29 +533,29 @@
|
||||
$("#editUserModal").modal("hide");
|
||||
Swal.fire({
|
||||
title: "Error!",
|
||||
text: response.error || "An error occurred.",
|
||||
text: (response && response.error) || "An unknown error occurred.",
|
||||
icon: "error",
|
||||
confirmButtonText: "OK",
|
||||
});
|
||||
$("#editSubmitButton").prop("disabled", false);
|
||||
}
|
||||
},
|
||||
error: function (error) {
|
||||
console.error(error);
|
||||
error: function (jqXHR) {
|
||||
let errorMessage = "An error occurred while updating user.";
|
||||
if (jqXHR.responseJSON && jqXHR.responseJSON.detail) {
|
||||
errorMessage = jqXHR.responseJSON.detail;
|
||||
}
|
||||
Swal.fire({
|
||||
title: "Error!",
|
||||
text: "An error occurred while updating user",
|
||||
text: errorMessage,
|
||||
icon: "error",
|
||||
confirmButtonText: "OK",
|
||||
});
|
||||
$("#editSubmitButton").prop("disabled", false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
$("#editUserForm button[type='submit']").on("click", function (e) {
|
||||
e.preventDefault();
|
||||
$(this).closest("form").submit();
|
||||
});
|
||||
|
||||
// Reset User Button Click
|
||||
$("#userTable").on("click", ".reset-user", function () {
|
||||
const username = $(this).data("user");
|
||||
@ -653,15 +677,12 @@
|
||||
method: "GET",
|
||||
dataType: 'json',
|
||||
success: function (response) {
|
||||
// console.log("API Response:", response);
|
||||
|
||||
const configs = [
|
||||
{ type: "IPv4", link: response.ipv4 },
|
||||
{ type: "IPv6", link: response.ipv6 },
|
||||
{ type: "Normal-SUB", link: response.normal_sub }
|
||||
];
|
||||
|
||||
|
||||
configs.forEach(config => {
|
||||
if (config.link) {
|
||||
const displayType = config.type;
|
||||
@ -765,10 +786,10 @@
|
||||
}
|
||||
|
||||
$('#addUserModal').on('show.bs.modal', function (event) {
|
||||
$('#addUsername').val('');
|
||||
$('#addUserForm')[0].reset();
|
||||
$('#addUsernameError').text('');
|
||||
$('#addTrafficLimit').val('30');
|
||||
$('#addExpirationDays').val('30');
|
||||
$('#addUsernameError').text('');
|
||||
$('#addSubmitButton').prop('disabled', true);
|
||||
});
|
||||
|
||||
|
||||
58
menu.sh
58
menu.sh
@ -52,7 +52,7 @@ hysteria2_add_user_handler() {
|
||||
read -p "Enter the username: " username
|
||||
|
||||
if [[ "$username" =~ ^[a-zA-Z0-9]+$ ]]; then
|
||||
if [[ -n $(python3 $CLI_PATH get-user -u "$username") ]]; then
|
||||
if [[ -n $(python3 $CLI_PATH get-user -u "$username" 2>/dev/null) ]]; then
|
||||
echo -e "${red}Error:${NC} Username already exists. Please choose another username."
|
||||
else
|
||||
break
|
||||
@ -65,14 +65,24 @@ hysteria2_add_user_handler() {
|
||||
read -p "Enter the traffic limit (in GB): " traffic_limit_GB
|
||||
|
||||
read -p "Enter the expiration days: " expiration_days
|
||||
|
||||
local unlimited_arg=""
|
||||
while true; do
|
||||
read -p "Exempt user from IP limit checks (unlimited IP)? (y/n) [n]: " unlimited_choice
|
||||
case "$unlimited_choice" in
|
||||
y|Y) unlimited_arg="--unlimited"; break ;;
|
||||
n|N|"") break ;;
|
||||
*) echo -e "${red}Error:${NC} Please answer 'y' or 'n'." ;;
|
||||
esac
|
||||
done
|
||||
|
||||
password=$(pwgen -s 32 1)
|
||||
creation_date=$(date +%Y-%m-%d)
|
||||
|
||||
python3 $CLI_PATH add-user --username "$username" --traffic-limit "$traffic_limit_GB" --expiration-days "$expiration_days" --password "$password" --creation-date "$creation_date"
|
||||
python3 $CLI_PATH add-user --username "$username" --traffic-limit "$traffic_limit_GB" --expiration-days "$expiration_days" --password "$password" --creation-date "$creation_date" $unlimited_arg
|
||||
}
|
||||
|
||||
hysteria2_edit_user_handler() {
|
||||
# Function to prompt for user input with validation
|
||||
prompt_for_input() {
|
||||
local prompt_message="$1"
|
||||
local validation_regex="$2"
|
||||
@ -93,65 +103,69 @@ hysteria2_edit_user_handler() {
|
||||
done
|
||||
}
|
||||
|
||||
# Prompt for username
|
||||
prompt_for_input "Enter the username you want to edit: " '^[a-zA-Z0-9]+$' '' username
|
||||
|
||||
# Check if user exists
|
||||
user_exists_output=$(python3 $CLI_PATH get-user -u "$username" 2>&1)
|
||||
if [[ -z "$user_exists_output" ]]; then
|
||||
echo -e "${red}Error:${NC} User '$username' not found or an error occurred."
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Prompt for new username
|
||||
prompt_for_input "Enter the new username (leave empty to keep the current username): " '^[a-zA-Z0-9]*$' '' new_username
|
||||
|
||||
# Prompt for new traffic limit
|
||||
prompt_for_input "Enter the new traffic limit (in GB) (leave empty to keep the current limit): " '^[0-9]*$' '' new_traffic_limit_GB
|
||||
|
||||
# Prompt for new expiration days
|
||||
prompt_for_input "Enter the new expiration days (leave empty to keep the current expiration days): " '^[0-9]*$' '' new_expiration_days
|
||||
|
||||
# Determine if we need to renew password
|
||||
while true; do
|
||||
read -p "Do you want to generate a new password? (y/n): " renew_password
|
||||
read -p "Do you want to generate a new password? (y/n) [n]: " renew_password
|
||||
case "$renew_password" in
|
||||
y|Y) renew_password=true; break ;;
|
||||
n|N) renew_password=false; break ;;
|
||||
n|N|"") renew_password=false; break ;;
|
||||
*) echo -e "${red}Error:${NC} Please answer 'y' or 'n'." ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Determine if we need to renew creation date
|
||||
while true; do
|
||||
read -p "Do you want to generate a new creation date? (y/n): " renew_creation_date
|
||||
read -p "Do you want to generate a new creation date? (y/n) [n]: " renew_creation_date
|
||||
case "$renew_creation_date" in
|
||||
y|Y) renew_creation_date=true; break ;;
|
||||
n|N) renew_creation_date=false; break ;;
|
||||
n|N|"") renew_creation_date=false; break ;;
|
||||
*) echo -e "${red}Error:${NC} Please answer 'y' or 'n'." ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Determine if user should be blocked
|
||||
local blocked_arg=""
|
||||
while true; do
|
||||
read -p "Do you want to block the user? (y/n): " block_user
|
||||
read -p "Change user block status? ([b]lock/[u]nblock/[s]kip) [s]: " block_user
|
||||
case "$block_user" in
|
||||
y|Y) blocked=true; break ;;
|
||||
n|N) blocked=false; break ;;
|
||||
*) echo -e "${red}Error:${NC} Please answer 'y' or 'n'." ;;
|
||||
b|B) blocked_arg="--blocked"; break ;;
|
||||
u|U) blocked_arg="--unblocked"; break ;;
|
||||
s|S|"") break ;;
|
||||
*) echo -e "${red}Error:${NC} Please answer 'b', 'u', or 's'." ;;
|
||||
esac
|
||||
done
|
||||
|
||||
local ip_limit_arg=""
|
||||
while true; do
|
||||
read -p "Change IP limit status? ([u]nlimited/[l]imited/[s]kip) [s]: " ip_limit_status
|
||||
case "$ip_limit_status" in
|
||||
u|U) ip_limit_arg="--unlimited-ip"; break ;;
|
||||
l|L) ip_limit_arg="--limited-ip"; break ;;
|
||||
s|S|"") break ;;
|
||||
*) echo -e "${red}Error:${NC} Please answer 'u', 'l', or 's'." ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Construct the arguments for the edit-user command
|
||||
args=()
|
||||
if [[ -n "$new_username" ]]; then args+=("--new-username" "$new_username"); fi
|
||||
if [[ -n "$new_traffic_limit_GB" ]]; then args+=("--new-traffic-limit" "$new_traffic_limit_GB"); fi
|
||||
if [[ -n "$new_expiration_days" ]]; then args+=("--new-expiration-days" "$new_expiration_days"); fi
|
||||
if [[ "$renew_password" == "true" ]]; then args+=("--renew-password"); fi
|
||||
if [[ "$renew_creation_date" == "true" ]]; then args+=("--renew-creation-date"); fi
|
||||
if [[ "$blocked" == "true" ]]; then args+=("--blocked"); fi
|
||||
if [[ -n "$blocked_arg" ]]; then args+=("$blocked_arg"); fi
|
||||
if [[ -n "$ip_limit_arg" ]]; then args+=("$ip_limit_arg"); fi
|
||||
|
||||
# Call the edit-user script with the constructed arguments
|
||||
python3 $CLI_PATH edit-user --username "$username" "${args[@]}"
|
||||
}
|
||||
|
||||
|
||||
@ -4,7 +4,7 @@ aiosignal==1.4.0
|
||||
async-timeout==5.0.1
|
||||
attrs==25.3.0
|
||||
certifi==2025.7.14
|
||||
charset-normalizer==3.4.2
|
||||
charset-normalizer==3.4.3
|
||||
click==8.1.8
|
||||
frozenlist==1.7.0
|
||||
idna==3.10
|
||||
@ -25,7 +25,7 @@ schedule==1.2.2
|
||||
|
||||
# webpanel
|
||||
annotated-types==0.7.0
|
||||
anyio==4.9.0
|
||||
anyio==4.10.0
|
||||
fastapi==0.116.1
|
||||
h11==0.16.0
|
||||
itsdangerous==2.2.0
|
||||
@ -40,4 +40,4 @@ hypercorn==0.17.3
|
||||
pydantic-settings==2.10.1
|
||||
|
||||
# subs
|
||||
certbot==4.1.1
|
||||
certbot==4.2.0
|
||||
|
||||
Reference in New Issue
Block a user