Merge pull request #247 from ReturnFI/beta

Per-user unlimited IP support & web panel integration
This commit is contained in:
Whispering Wind
2025-08-13 23:36:18 +03:30
committed by GitHub
12 changed files with 272 additions and 157 deletions

View File

@ -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 * 🌐 **Per-User Unlimited IP Option**:
* 🌐 feat(api): Add external node management endpoints
* 🔗 feat(normalsub): Add external node URIs to subscriptions * Added `unlimited_user` flag to exempt specific users from concurrent IP limits
* 💾 feat: Backup nodes.json * Works in both CLI and web panel
* 🛠️ fix: Robust parsing, unlimited user handling, and various script/UI issues * Integrated into `limit.sh` for enforcement bypass
* 📦 chore: Dependency updates (pytelegrambotapi, aiohttp) * 🖥️ **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

View File

@ -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('--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('--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) @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: 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.") click.echo(f"User '{username}' added successfully.")
except Exception as e: except Exception as e:
click.echo(f'{e}', err=True) 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('--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-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('--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') @click.option('--blocked/--unblocked', 'blocked', default=None, help='Block or unblock 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('--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: try:
cli_api.kick_user_by_name(username) cli_api.kick_user_by_name(username)
cli_api.traffic_status(display_output=False) 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, unlimited_ip)
click.echo(f"User '{username}' updated successfully.") click.echo(f"User '{username}' updated successfully.")
except Exception as e: except Exception as e:
click.echo(f'{e}', err=True) click.echo(f'{e}', err=True)

View File

@ -3,7 +3,7 @@ import subprocess
from enum import Enum from enum import Enum
from datetime import datetime from datetime import datetime
import json import json
from typing import Any from typing import Any, Optional
from dotenv import dotenv_values from dotenv import dotenv_values
import traffic import traffic
@ -266,18 +266,26 @@ def get_user(username: str) -> dict[str, Any] | None:
return json.loads(res) 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: command = ['python3', Command.ADD_USER.value, username, str(traffic_limit), str(expiration_days)]
password = generate_password()
if not creation_date: if unlimited:
creation_date = datetime.now().strftime('%Y-%m-%d') final_password = password if password else generate_password()
run_cmd(['python3', Command.ADD_USER.value, username, str(traffic_limit), str(expiration_days), password, creation_date]) 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. 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: if new_expiration_days is not None and new_expiration_days < 0:
raise InvalidInputError('Error: expiration days must be a non-negative number.') raise InvalidInputError('Error: expiration days must be a non-negative number.')
if renew_password: password = generate_password() if renew_password else ''
password = generate_password() creation_date = datetime.now().strftime('%Y-%m-%d') if renew_creation_date else ''
else:
password = ''
if renew_creation_date: blocked_str = ''
creation_date = datetime.now().strftime('%Y-%m-%d') if blocked is True:
else: blocked_str = 'true'
creation_date = '' 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 = [ command_args = [
'bash', '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 '', str(new_expiration_days) if new_expiration_days is not None else '',
password, password,
creation_date, creation_date,
'true' if blocked else 'false' blocked_str,
unlimited_str
] ]
run_cmd(command_args) run_cmd(command_args)
@ -655,7 +669,7 @@ def stop_ip_limiter():
'''Stops the IP limiter service.''' '''Stops the IP limiter service.'''
run_cmd(['bash', Command.LIMIT_SCRIPT.value, 'stop']) 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.''' '''Configures the IP limiter service.'''
if block_duration is not None and block_duration <= 0: if block_duration is not None and block_duration <= 0:
raise InvalidInputError("Block duration must be greater than 0.") raise InvalidInputError("Block duration must be greater than 0.")

View File

@ -9,7 +9,7 @@ from datetime import datetime
from init_paths import * from init_paths import *
from 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. 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. 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. 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. 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: Returns:
int: 0 on success, 1 on failure. int: 0 on success, 1 on failure.
""" """
if not username or not traffic_gb or not expiration_days: 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 return 1
try: 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() password = subprocess.check_output(['cat', '/proc/sys/kernel/random/uuid'], text=True).strip()
except Exception: except Exception:
print("Error: Failed to generate password. Please install 'pwgen' or ensure /proc access.") print("Error: Failed to generate password. Please install 'pwgen' or ensure /proc access.")
return 1
if not creation_date: if not creation_date:
creation_date = datetime.now().strftime("%Y-%m-%d") 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, "max_download_bytes": traffic_bytes,
"expiration_days": expiration_days, "expiration_days": expiration_days,
"account_creation_date": creation_date, "account_creation_date": creation_date,
"blocked": False "blocked": False,
"unlimited_user": unlimited_user
} }
f.seek(0) f.seek(0)
@ -103,8 +106,8 @@ def add_user(username, traffic_gb, expiration_days, password=None, creation_date
return 1 return 1
if __name__ == "__main__": if __name__ == "__main__":
if len(sys.argv) not in [4, 6]: 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]") print(f"Usage: {sys.argv[0]} <username> <traffic_limit_GB> <expiration_days> [password] [creation_date] [unlimited_user (true/false)]")
sys.exit(1) sys.exit(1)
username = sys.argv[1] username = sys.argv[1]
@ -112,6 +115,8 @@ if __name__ == "__main__":
expiration_days = sys.argv[3] expiration_days = sys.argv[3]
password = sys.argv[4] if len(sys.argv) > 4 else None password = sys.argv[4] if len(sys.argv) > 4 else None
creation_date = sys.argv[5] if len(sys.argv) > 5 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) sys.exit(exit_code)

View File

@ -8,7 +8,7 @@ readonly GB_TO_BYTES=$((1024 * 1024 * 1024))
validate_username() { validate_username() {
local username=$1 local username=$1
if [ -z "$username" ]; then if [ -z "$username" ]; then
return 0 # Optional value is valid return 0
fi fi
if ! [[ "$username" =~ ^[a-zA-Z0-9]+$ ]]; then if ! [[ "$username" =~ ^[a-zA-Z0-9]+$ ]]; then
echo "Username can only contain letters and numbers." echo "Username can only contain letters and numbers."
@ -21,7 +21,7 @@ validate_username() {
validate_traffic_limit() { validate_traffic_limit() {
local traffic_limit=$1 local traffic_limit=$1
if [ -z "$traffic_limit" ]; then if [ -z "$traffic_limit" ]; then
return 0 # Optional value is valid return 0
fi fi
if ! [[ "$traffic_limit" =~ ^[0-9]+$ ]]; then if ! [[ "$traffic_limit" =~ ^[0-9]+$ ]]; then
echo "Error: Traffic limit must be a valid non-negative number (use 0 for unlimited)." 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() { validate_expiration_days() {
local expiration_days=$1 local expiration_days=$1
if [ -z "$expiration_days" ]; then if [ -z "$expiration_days" ]; then
return 0 # Optional value is valid return 0
fi fi
if ! [[ "$expiration_days" =~ ^[0-9]+$ ]]; then if ! [[ "$expiration_days" =~ ^[0-9]+$ ]]; then
echo "Error: Expiration days must be a valid non-negative number (use 0 for unlimited)." echo "Error: Expiration days must be a valid non-negative number (use 0 for unlimited)."
@ -45,7 +45,7 @@ validate_expiration_days() {
validate_date() { validate_date() {
local date_str=$1 local date_str=$1
if [ -z "$date_str" ]; then if [ -z "$date_str" ]; then
return 0 # Optional value is valid return 0
fi fi
if ! [[ "$date_str" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}$ ]]; then if ! [[ "$date_str" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}$ ]]; then
echo "Invalid date format. Expected YYYY-MM-DD." echo "Invalid date format. Expected YYYY-MM-DD."
@ -59,8 +59,8 @@ validate_date() {
validate_blocked_status() { validate_blocked_status() {
local status=$1 local status=$1
if [ -z "$status" ]; then if [ -z "$status" ]; then
return 0 # Optional value is valid return 0
fi fi
if [ "$status" != "true" ] && [ "$status" != "false" ]; then if [ "$status" != "true" ] && [ "$status" != "false" ]; then
echo "Blocked status must be 'true' or 'false'." echo "Blocked status must be 'true' or 'false'."
@ -69,14 +69,26 @@ validate_blocked_status() {
return 0 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 local status=$1
case "$status" in case "$status" in
y|Y) echo "true" ;; y|Y) echo "true" ;;
n|N) echo "false" ;; n|N) echo "false" ;;
true|false) echo "$status" ;; true|false) echo "$status" ;;
*) echo "false" ;; # Default to false for safety *) echo "false" ;;
esac esac
} }
@ -93,6 +105,7 @@ update_user_info() {
local new_expiration_days=$5 local new_expiration_days=$5
local new_account_creation_date=$6 local new_account_creation_date=$6
local new_blocked=$7 local new_blocked=$7
local new_unlimited=$8
if [ ! -f "$USERS_FILE" ]; then if [ ! -f "$USERS_FILE" ]; then
echo -e "${red}Error:${NC} File '$USERS_FILE' not found." echo -e "${red}Error:${NC} File '$USERS_FILE' not found."
@ -120,30 +133,32 @@ update_user_info() {
echo "Expiration Days: ${new_expiration_days:-(not changed)}" echo "Expiration Days: ${new_expiration_days:-(not changed)}"
echo "Creation Date: ${new_account_creation_date:-(not changed)}" echo "Creation Date: ${new_account_creation_date:-(not changed)}"
echo "Blocked: $new_blocked" echo "Blocked: $new_blocked"
echo "Unlimited IP: $new_unlimited"
# Update user fields, only if new values are provided
jq \ jq \
--arg old_username "$old_username" \ --arg old_username "$old_username" \
--arg new_username "$new_username" \ --arg new_username "$new_username" \
--arg password "${new_password:-null}" \ --arg password "${new_password:-null}" \
--argjson max_download_bytes "${new_max_download_bytes:-null}" \ --argjson max_download_bytes "${new_max_download_bytes:-null}" \
--argjson expiration_days "${new_expiration_days:-null}" \ --argjson expiration_days "${new_expiration_days:-null}" \
--arg account_creation_date "${new_account_creation_date:-null}" \ --arg account_creation_date "${new_account_creation_date:-null}" \
--argjson blocked "$(convert_blocked_status "${new_blocked:-false}")" \ --argjson blocked "$new_blocked" \
--argjson upload_bytes "$upload_bytes" \ --argjson unlimited "$new_unlimited" \
--argjson upload_bytes "$upload_bytes" \
--argjson download_bytes "$download_bytes" \ --argjson download_bytes "$download_bytes" \
--arg status "$status" \ --arg status "$status" \
' '
.[$new_username] = .[$old_username] | .[$new_username] = .[$old_username] |
del(.[$old_username]) | (if $old_username != $new_username then del(.[$old_username]) else . end) |
.[$new_username] |= ( .[$new_username] |= (
.password = ($password // .password) | .password = ($password // .password) |
.max_download_bytes = ($max_download_bytes // .max_download_bytes) | .max_download_bytes = ($max_download_bytes // .max_download_bytes) |
.expiration_days = ($expiration_days // .expiration_days) | .expiration_days = ($expiration_days // .expiration_days) |
.account_creation_date = ($account_creation_date // .account_creation_date) | .account_creation_date = ($account_creation_date // .account_creation_date) |
.blocked = $blocked | .blocked = $blocked |
.upload_bytes = $upload_bytes | .unlimited_user = $unlimited |
.upload_bytes = $upload_bytes |
.download_bytes = $download_bytes | .download_bytes = $download_bytes |
.status = $status .status = $status
) )
@ -166,6 +181,7 @@ edit_user() {
local new_password=$5 local new_password=$5
local new_creation_date=$6 local new_creation_date=$6
local new_blocked=$7 local new_blocked=$7
local new_unlimited=$8
local user_info=$(get_user_info "$username") local user_info=$(get_user_info "$username")
@ -180,6 +196,7 @@ edit_user() {
local expiration_days=$(echo "$user_info" | jq -r '.expiration_days') local expiration_days=$(echo "$user_info" | jq -r '.expiration_days')
local creation_date=$(echo "$user_info" | jq -r '.account_creation_date') local creation_date=$(echo "$user_info" | jq -r '.account_creation_date')
local blocked=$(echo "$user_info" | jq -r '.blocked') 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 if ! validate_username "$new_username"; then
echo "Invalid username: $new_username" echo "Invalid username: $new_username"
@ -208,6 +225,10 @@ edit_user() {
return 1 return 1
fi fi
if ! validate_unlimited_status "$new_unlimited"; then
echo "Invalid unlimited status: $new_unlimited"
return 1
fi
new_username=${new_username:-$username} new_username=${new_username:-$username}
@ -222,19 +243,18 @@ edit_user() {
new_expiration_days=${new_expiration_days:-$expiration_days} new_expiration_days=${new_expiration_days:-$expiration_days}
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_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 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 # Update user failed return 1
fi fi
echo "User updated successfully." echo "User updated successfully."
return 0 # Operation complete without error. return 0
} }
# Run the script edit_user "$1" "$2" "$3" "$4" "$5" "$6" "$7" "$8"
edit_user "$1" "$2" "$3" "$4" "$5" "$6" "$7"

View File

@ -183,6 +183,23 @@ check_ip_limit() {
local username="$1" local username="$1"
local ips=() 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 # Get all IPs for this user
if command -v jq &>/dev/null; then if command -v jq &>/dev/null; then
readarray -t ips < <(jq -r --arg user "$username" '.[$user][]' "$CONNECTIONS_FILE" 2>/dev/null) readarray -t ips < <(jq -r --arg user "$username" '.[$user][]' "$CONNECTIONS_FILE" 2>/dev/null)

View File

@ -1,23 +1,20 @@
from typing import Optional, List 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): class UserInfoResponse(BaseModel):
password: str password: str
max_download_bytes: int max_download_bytes: int
expiration_days: int expiration_days: int
account_creation_date: str account_creation_date: str
blocked: bool blocked: bool
status: str | None = None unlimited_ip: bool = Field(False, alias='unlimited_user')
upload_bytes: int | None = None status: Optional[str] = None
download_bytes: int | None = None upload_bytes: Optional[int] = None
download_bytes: Optional[int] = None
class UserListResponse(RootModel): # type: ignore class UserListResponse(RootModel):
root: dict[str, UserInfoResponse] root: dict[str, UserInfoResponse]
@ -25,18 +22,19 @@ class AddUserInputBody(BaseModel):
username: str username: str
traffic_limit: int traffic_limit: int
expiration_days: int expiration_days: int
password: str | None = None password: Optional[str] = None
creation_date: str | None = None creation_date: Optional[str] = None
unlimited: bool = False
class EditUserInputBody(BaseModel): class EditUserInputBody(BaseModel):
# username: str new_username: Optional[str] = None
new_username: str | None = None new_traffic_limit: Optional[int] = None
new_traffic_limit: int | None = None new_expiration_days: Optional[int] = None
new_expiration_days: int | None = None
renew_password: bool = False renew_password: bool = False
renew_creation_date: bool = False renew_creation_date: bool = False
blocked: bool = False blocked: Optional[bool] = None
unlimited_ip: Optional[bool] = None
class NodeUri(BaseModel): class NodeUri(BaseModel):
name: str name: str

View File

@ -39,7 +39,7 @@ async def add_user_api(body: AddUserInputBody):
detail=f"{str(e)}") detail=f"{str(e)}")
try: 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.') return DetailResponse(detail=f'User {body.username} has been added.')
except cli_api.CommandExecutionError as e: except cli_api.CommandExecutionError as e:
if "User already exists" in str(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.kick_user_by_name(username)
cli_api.traffic_status(display_output=False) 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, body.unlimited_ip)
return DetailResponse(detail=f'User {username} has been edited.') return DetailResponse(detail=f'User {username} has been edited.')
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)}')

View File

@ -10,6 +10,7 @@ class User(BaseModel):
expiry_date: str expiry_date: str
expiry_days: str expiry_days: str
enable: bool enable: bool
unlimited_ip: bool
@staticmethod @staticmethod
def from_dict(username: str, user_data: dict): def from_dict(username: str, user_data: dict):
@ -58,6 +59,7 @@ class User(BaseModel):
'expiry_date': display_expiry_date, 'expiry_date': display_expiry_date,
'expiry_days': display_expiry_days, 'expiry_days': display_expiry_days,
'enable': not user_data.get('blocked', False), 'enable': not user_data.get('blocked', False),
'unlimited_ip': user_data.get('unlimited_user', False)
} }
@staticmethod @staticmethod

View File

@ -76,6 +76,7 @@
<th class="text-nowrap">Expiry Date</th> <th class="text-nowrap">Expiry Date</th>
<th class="text-nowrap">Expiry Days</th> <th class="text-nowrap">Expiry Days</th>
<th>Enable</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">Configs</th>
<th class="text-nowrap">Actions</th> <th class="text-nowrap">Actions</th>
</tr> </tr>
@ -84,7 +85,7 @@
{% for user in users|sort(attribute='username', case_sensitive=false) %} {% for user in users|sort(attribute='username', case_sensitive=false) %}
<tr> <tr>
<td> <td>
<input type="checkbox" class="user-checkbox" value="{{ user.username }}"> <input type="checkbox" class="user-checkbox" value="{{ user['username'] }}">
</td> </td>
<td>{{ loop.index }}</td> <td>{{ loop.index }}</td>
<td> <td>
@ -96,35 +97,42 @@
<i class="fas fa-circle text-danger"></i> {{ user['status'] }} <i class="fas fa-circle text-danger"></i> {{ user['status'] }}
{% endif %} {% endif %}
</td> </td>
<td data-username="{{ user.username }}">{{ user.username }}</td> <td data-username="{{ user['username'] }}">{{ user['username'] }}</td>
<td>{{ user.traffic_used }}</td> <td>{{ user['traffic_used'] }}</td>
<td>{{ user.expiry_date }}</td> <td>{{ user['expiry_date'] }}</td>
<td>{{ user.expiry_days }}</td> <td>{{ user['expiry_days'] }}</td>
<td> <td>
{% if user.enable %} {% if user['enable'] %}
<i class="fas fa-check-circle text-success"></i> <i class="fas fa-check-circle text-success"></i>
{% else %} {% else %}
<i class="fas fa-times-circle text-danger"></i> <i class="fas fa-times-circle text-danger"></i>
{% endif %} {% endif %}
</td> </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"> <td class="text-nowrap">
<a href="#" class="config-link" data-toggle="modal" data-target="#qrcodeModal" <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> <i class="fas fa-qrcode"></i>
</a> </a>
</td> </td>
<td class="text-nowrap"> <td class="text-nowrap">
<button type="button" class="btn btn-sm btn-info edit-user" <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"> data-target="#editUserModal">
<i class="fas fa-edit"></i> <i class="fas fa-edit"></i>
</button> </button>
<button type="button" class="btn btn-sm btn-warning reset-user" <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> <i class="fas fa-undo"></i>
</button> </button>
<button type="button" class="btn btn-sm btn-danger delete-user" <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> <i class="fas fa-trash"></i>
</button> </button>
</td> </td>
@ -173,6 +181,10 @@
<input type="number" class="form-control" id="addExpirationDays" name="expiration_days" <input type="number" class="form-control" id="addExpirationDays" name="expiration_days"
required> required>
</div> </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> <button type="submit" class="btn btn-primary" id="addSubmitButton">Add User</button>
</form> </form>
</div> </div>
@ -206,9 +218,13 @@
<input type="number" class="form-control" id="editExpirationDays" name="new_expiration_days"> <input type="number" class="form-control" id="editExpirationDays" name="new_expiration_days">
</div> </div>
<div class="form-check"> <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> <label class="form-check-label" for="editBlocked">Blocked</label>
</div> </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"> <input type="hidden" id="originalUsername" name="username">
<button type="submit" class="btn btn-primary" id="editSubmitButton">Save Changes</button> <button type="submit" class="btn btn-primary" id="editSubmitButton">Save Changes</button>
</form> </form>
@ -243,6 +259,19 @@
<script> <script>
$(function () { $(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]+$/; const usernameRegex = /^[a-zA-Z0-9]+$/;
function validateUsername(username, errorElementId) { function validateUsername(username, errorElementId) {
@ -259,7 +288,6 @@
} }
$("#addSubmitButton").prop("disabled", true); $("#addSubmitButton").prop("disabled", true);
// $("#editSubmitButton").prop("disabled", true);
$("#addUsername").on("input", function () { $("#addUsername").on("input", function () {
const username = $(this).val(); const username = $(this).val();
@ -387,11 +415,12 @@
} }
$("#addSubmitButton").prop("disabled", true); $("#addSubmitButton").prop("disabled", true);
const formData = $(this).serializeArray(); const jsonData = {
const jsonData = {}; username: $("#addUsername").val(),
formData.forEach(field => { traffic_limit: $("#addTrafficLimit").val(),
jsonData[field.name] = field.value; expiration_days: $("#addExpirationDays").val(),
}); unlimited: $("#addUnlimited").is(":checked")
};
$.ajax({ $.ajax({
url: " {{ url_for('add_user_api') }} ", url: " {{ url_for('add_user_api') }} ",
@ -435,6 +464,7 @@
const trafficUsageText = row.find("td:eq(4)").text().trim(); const trafficUsageText = row.find("td:eq(4)").text().trim();
const expiryDaysText = row.find("td:eq(6)").text().trim(); const expiryDaysText = row.find("td:eq(6)").text().trim();
const blocked = row.find("td:eq(7) i").hasClass("text-danger"); 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); const expiryDaysValue = (expiryDaysText.toLowerCase() === 'unlimited') ? 0 : parseInt(expiryDaysText, 10);
@ -455,6 +485,7 @@
$("#editTrafficLimit").val(trafficLimitValue); $("#editTrafficLimit").val(trafficLimitValue);
$("#editExpirationDays").val(expiryDaysValue); $("#editExpirationDays").val(expiryDaysValue);
$("#editBlocked").prop("checked", blocked); $("#editBlocked").prop("checked", blocked);
$("#editUnlimitedIp").prop("checked", unlimited_ip);
const isValid = validateUsername(username, "editUsernameError"); const isValid = validateUsername(username, "editUsernameError");
$("#editUserForm button[type='submit']").prop("disabled", !isValid); $("#editUserForm button[type='submit']").prop("disabled", !isValid);
@ -465,12 +496,19 @@
if (!validateUsername($("#editUsername").val(), "editUsernameError")) { if (!validateUsername($("#editUsername").val(), "editUsernameError")) {
return; return;
} }
$("#editSubmitButton").prop("disabled", true);
const formData = $(this).serializeArray(); const jsonData = {
const jsonData = {}; new_username: $("#editUsername").val(),
formData.forEach(field => { new_traffic_limit: $("#editTrafficLimit").val() || null,
jsonData[field.name] = field.value; 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 editUserUrl = "{{ url_for('edit_user_api', username='USERNAME_PLACEHOLDER') }}";
const url = editUserUrl.replace("USERNAME_PLACEHOLDER", encodeURIComponent($("#originalUsername").val())); const url = editUserUrl.replace("USERNAME_PLACEHOLDER", encodeURIComponent($("#originalUsername").val()));
@ -481,22 +519,8 @@
contentType: "application/json", contentType: "application/json",
data: JSON.stringify(jsonData), data: JSON.stringify(jsonData),
success: function (response) { success: function (response) {
if (typeof response === 'string' && response.includes("User updated successfully")) { if (response && response.detail) {
$("#editUserModal").modal("hide"); $("#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({ Swal.fire({
title: "Success!", title: "Success!",
text: response.detail, text: response.detail,
@ -509,29 +533,29 @@
$("#editUserModal").modal("hide"); $("#editUserModal").modal("hide");
Swal.fire({ Swal.fire({
title: "Error!", title: "Error!",
text: response.error || "An error occurred.", text: (response && response.error) || "An unknown error occurred.",
icon: "error", icon: "error",
confirmButtonText: "OK", confirmButtonText: "OK",
}); });
$("#editSubmitButton").prop("disabled", false);
} }
}, },
error: function (error) { error: function (jqXHR) {
console.error(error); let errorMessage = "An error occurred while updating user.";
if (jqXHR.responseJSON && jqXHR.responseJSON.detail) {
errorMessage = jqXHR.responseJSON.detail;
}
Swal.fire({ Swal.fire({
title: "Error!", title: "Error!",
text: "An error occurred while updating user", text: errorMessage,
icon: "error", icon: "error",
confirmButtonText: "OK", confirmButtonText: "OK",
}); });
$("#editSubmitButton").prop("disabled", false);
} }
}); });
}); });
$("#editUserForm button[type='submit']").on("click", function (e) {
e.preventDefault();
$(this).closest("form").submit();
});
// Reset User Button Click // Reset User Button Click
$("#userTable").on("click", ".reset-user", function () { $("#userTable").on("click", ".reset-user", function () {
const username = $(this).data("user"); const username = $(this).data("user");
@ -653,15 +677,12 @@
method: "GET", method: "GET",
dataType: 'json', dataType: 'json',
success: function (response) { success: function (response) {
// console.log("API Response:", response);
const configs = [ const configs = [
{ type: "IPv4", link: response.ipv4 }, { type: "IPv4", link: response.ipv4 },
{ type: "IPv6", link: response.ipv6 }, { type: "IPv6", link: response.ipv6 },
{ type: "Normal-SUB", link: response.normal_sub } { type: "Normal-SUB", link: response.normal_sub }
]; ];
configs.forEach(config => { configs.forEach(config => {
if (config.link) { if (config.link) {
const displayType = config.type; const displayType = config.type;
@ -765,10 +786,10 @@
} }
$('#addUserModal').on('show.bs.modal', function (event) { $('#addUserModal').on('show.bs.modal', function (event) {
$('#addUsername').val(''); $('#addUserForm')[0].reset();
$('#addUsernameError').text('');
$('#addTrafficLimit').val('30'); $('#addTrafficLimit').val('30');
$('#addExpirationDays').val('30'); $('#addExpirationDays').val('30');
$('#addUsernameError').text('');
$('#addSubmitButton').prop('disabled', true); $('#addSubmitButton').prop('disabled', true);
}); });

58
menu.sh
View File

@ -52,7 +52,7 @@ hysteria2_add_user_handler() {
read -p "Enter the username: " username read -p "Enter the username: " username
if [[ "$username" =~ ^[a-zA-Z0-9]+$ ]]; then 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." echo -e "${red}Error:${NC} Username already exists. Please choose another username."
else else
break break
@ -65,14 +65,24 @@ hysteria2_add_user_handler() {
read -p "Enter the traffic limit (in GB): " traffic_limit_GB read -p "Enter the traffic limit (in GB): " traffic_limit_GB
read -p "Enter the expiration days: " expiration_days 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) password=$(pwgen -s 32 1)
creation_date=$(date +%Y-%m-%d) 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() { hysteria2_edit_user_handler() {
# Function to prompt for user input with validation
prompt_for_input() { prompt_for_input() {
local prompt_message="$1" local prompt_message="$1"
local validation_regex="$2" local validation_regex="$2"
@ -93,65 +103,69 @@ hysteria2_edit_user_handler() {
done done
} }
# Prompt for username
prompt_for_input "Enter the username you want to edit: " '^[a-zA-Z0-9]+$' '' 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) user_exists_output=$(python3 $CLI_PATH get-user -u "$username" 2>&1)
if [[ -z "$user_exists_output" ]]; then if [[ -z "$user_exists_output" ]]; then
echo -e "${red}Error:${NC} User '$username' not found or an error occurred." echo -e "${red}Error:${NC} User '$username' not found or an error occurred."
return 1 return 1
fi 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_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_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 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 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 case "$renew_password" in
y|Y) renew_password=true; break ;; 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'." ;; *) echo -e "${red}Error:${NC} Please answer 'y' or 'n'." ;;
esac esac
done done
# Determine if we need to renew creation date
while true; do 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 case "$renew_creation_date" in
y|Y) renew_creation_date=true; break ;; 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'." ;; *) echo -e "${red}Error:${NC} Please answer 'y' or 'n'." ;;
esac esac
done done
# Determine if user should be blocked local blocked_arg=""
while true; do 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 case "$block_user" in
y|Y) blocked=true; break ;; b|B) blocked_arg="--blocked"; break ;;
n|N) blocked=false; break ;; u|U) blocked_arg="--unblocked"; break ;;
*) echo -e "${red}Error:${NC} Please answer 'y' or 'n'." ;; 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 esac
done done
# Construct the arguments for the edit-user command
args=() args=()
if [[ -n "$new_username" ]]; then args+=("--new-username" "$new_username"); fi 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_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 [[ -n "$new_expiration_days" ]]; then args+=("--new-expiration-days" "$new_expiration_days"); fi
if [[ "$renew_password" == "true" ]]; then args+=("--renew-password"); fi if [[ "$renew_password" == "true" ]]; then args+=("--renew-password"); fi
if [[ "$renew_creation_date" == "true" ]]; then args+=("--renew-creation-date"); 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[@]}" python3 $CLI_PATH edit-user --username "$username" "${args[@]}"
} }

View File

@ -4,7 +4,7 @@ aiosignal==1.4.0
async-timeout==5.0.1 async-timeout==5.0.1
attrs==25.3.0 attrs==25.3.0
certifi==2025.7.14 certifi==2025.7.14
charset-normalizer==3.4.2 charset-normalizer==3.4.3
click==8.1.8 click==8.1.8
frozenlist==1.7.0 frozenlist==1.7.0
idna==3.10 idna==3.10
@ -25,7 +25,7 @@ schedule==1.2.2
# webpanel # webpanel
annotated-types==0.7.0 annotated-types==0.7.0
anyio==4.9.0 anyio==4.10.0
fastapi==0.116.1 fastapi==0.116.1
h11==0.16.0 h11==0.16.0
itsdangerous==2.2.0 itsdangerous==2.2.0
@ -40,4 +40,4 @@ hypercorn==0.17.3
pydantic-settings==2.10.1 pydantic-settings==2.10.1
# subs # subs
certbot==4.1.1 certbot==4.2.0