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
* 🌐 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

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('--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)

View File

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

View File

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

View File

@ -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."
@ -59,8 +59,8 @@ validate_date() {
validate_blocked_status() {
local status=$1
if [ -z "$status" ]; then
return 0 # Optional value is valid
if [ -z "$status" ]; then
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,30 +133,32 @@ 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" \
--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}" \
--arg account_creation_date "${new_account_creation_date:-null}" \
--argjson blocked "$(convert_blocked_status "${new_blocked:-false}")" \
--argjson upload_bytes "$upload_bytes" \
--arg account_creation_date "${new_account_creation_date:-null}" \
--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 |
.upload_bytes = $upload_bytes |
.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"

View File

@ -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)
@ -364,4 +381,4 @@ case "$1" in
;;
esac
exit 0
exit 0

View File

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

View File

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

View File

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

View File

@ -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>
@ -242,6 +258,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]+$/;
@ -259,7 +288,6 @@
}
$("#addSubmitButton").prop("disabled", true);
// $("#editSubmitButton").prop("disabled", true);
$("#addUsername").on("input", function () {
const username = $(this).val();
@ -282,7 +310,7 @@
$("#userTable tbody tr").each(function () {
let showRow = true;
switch (filter) {
case "all":
showRow = true;
@ -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 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")
};
const formData = $(this).serializeArray();
const jsonData = {};
formData.forEach(field => {
jsonData[field.name] = field.value;
});
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
View File

@ -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[@]}"
}

View File

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