Merge pull request #174 from ReturnFI/beta

Add credential reset options, IP limiter API, and RAM fix
This commit is contained in:
Whispering Wind
2025-05-24 20:41:27 +03:30
committed by GitHub
10 changed files with 270 additions and 68 deletions

View File

@ -1,23 +1,15 @@
# [1.10.0] - 2025-05-18 # [1.10.1] - 2025-05-24
## ✨ New Features ## ✨ New Features
### ⚙️ NormalSub Configuration Enhancements * 🔐 **feat:** Add option to reset Web Panel admin credentials via CLI
* 🧩 **feat:** Add "Reset Web Panel credentials" option to Bash menu
* 🌐 **feat:** Add API endpoint to fetch IP Limiter configuration
* 🛠️ **feat:** Add NormalSub subpath editing via Settings UI ## 🖌️ UI Improvements
- New 'Configure' tab in the Settings panel (visible only if NormalSub is active)
- Real-time client-side validation and live subpath editing
* 🔌 **feat:** Add API endpoints * 🧼 **clean:** Removed shield icons from footer for a cleaner look
- `GET /api/v1/config/normalsub/subpath`: Fetch current subpath
- `PUT /api/v1/config/normalsub/edit_subpath`: Update the subpath securely
* 🖥️ **feat:** Add CLI command support ## 🐛 Fixes
- `edit_subpath` option added to CLI and `normal-sub` command
- Automatically restarts the service after applying changes
* 🔧 **feat:** Add backend CLI + shell logic to update `.env` subpath for NormalSub * 📊 **fix:** Align memory usage reporting with `free -m` in `server_info.py`
## 📄 Documentation
* 📚 **docs:** Add sponsorship section with referral link to README

View File

@ -523,6 +523,28 @@ def get_web_panel_api_token():
except Exception as e: except Exception as e:
click.echo(f'{e}', err=True) click.echo(f'{e}', err=True)
@cli.command('reset-webpanel-creds')
@click.option('--new-username', '-u', required=False, help='New admin username for WebPanel', type=str)
@click.option('--new-password', '-p', required=False, help='New admin password for WebPanel', type=str)
def reset_webpanel_creds(new_username: str | None, new_password: str | None):
"""Resets the WebPanel admin username and/or password."""
try:
if not new_username and not new_password:
raise click.UsageError('Error: You must provide either --new-username or --new-password, or both.')
cli_api.reset_webpanel_credentials(new_username, new_password)
message_parts = []
if new_username:
message_parts.append(f"username to '{new_username}'")
if new_password:
message_parts.append("password")
click.echo(f'WebPanel admin {" and ".join(message_parts)} updated successfully.')
click.echo('WebPanel service has been restarted.')
except Exception as e:
click.echo(f'{e}', err=True)
@cli.command('get-webpanel-services-status') @cli.command('get-webpanel-services-status')
def get_web_panel_services_status(): def get_web_panel_services_status():

View File

@ -588,6 +588,18 @@ def get_webpanel_api_token() -> str | None:
'''Gets the API token of WebPanel.''' '''Gets the API token of WebPanel.'''
return run_cmd(['bash', Command.SHELL_WEBPANEL.value, 'api-token']) return run_cmd(['bash', Command.SHELL_WEBPANEL.value, 'api-token'])
def reset_webpanel_credentials(new_username: str | None = None, new_password: str | None = None):
'''Resets the WebPanel admin username and/or password.'''
if not new_username and not new_password:
raise InvalidInputError('Error: At least new username or new password must be provided.')
cmd_args = ['bash', Command.SHELL_WEBPANEL.value, 'resetcreds']
if new_username:
cmd_args.extend(['-u', new_username])
if new_password:
cmd_args.extend(['-p', new_password])
run_cmd(cmd_args)
def get_services_status() -> dict[str, bool] | None: def get_services_status() -> dict[str, bool] | None:
'''Gets the status of all project services.''' '''Gets the status of all project services.'''
@ -630,4 +642,22 @@ def config_ip_limiter(block_duration: int = None, max_ips: int = None):
cmd_args.append('') cmd_args.append('')
run_cmd(cmd_args) run_cmd(cmd_args)
def get_ip_limiter_config() -> dict[str, int | None]:
'''Retrieves the current IP Limiter configuration from .configs.env.'''
try:
if not os.path.exists(CONFIG_ENV_FILE):
return {"block_duration": None, "max_ips": None}
env_vars = dotenv_values(CONFIG_ENV_FILE)
block_duration_str = env_vars.get('BLOCK_DURATION')
max_ips_str = env_vars.get('MAX_IPS')
block_duration = int(block_duration_str) if block_duration_str and block_duration_str.isdigit() else None
max_ips = int(max_ips_str) if max_ips_str and max_ips_str.isdigit() else None
return {"block_duration": block_duration, "max_ips": max_ips}
except Exception as e:
print(f"Error reading IP Limiter config from .configs.env: {e}")
return {"block_duration": None, "max_ips": None}
# endregion # endregion

View File

@ -52,14 +52,39 @@ def get_cpu_usage(interval: float = 0.1) -> float:
def get_memory_usage() -> tuple[int, int]: def get_memory_usage() -> tuple[int, int]:
with open("/proc/meminfo") as f: mem_info = {}
lines = f.readlines() try:
with open("/proc/meminfo", "r") as f:
for line in f:
parts = line.split()
if len(parts) >= 2:
key = parts[0].rstrip(':')
if parts[1].isdigit():
mem_info[key] = int(parts[1])
except FileNotFoundError:
print("Error: /proc/meminfo not found.", file=sys.stderr)
return 0, 0
except Exception as e:
print(f"Error reading /proc/meminfo: {e}", file=sys.stderr)
return 0, 0
mem_total = int(next(line for line in lines if "MemTotal" in line).split()[1]) // 1024 mem_total_kb = mem_info.get("MemTotal", 0)
mem_available = int(next(line for line in lines if "MemAvailable" in line).split()[1]) // 1024 mem_free_kb = mem_info.get("MemFree", 0)
mem_used = mem_total - mem_available buffers_kb = mem_info.get("Buffers", 0)
cached_kb = mem_info.get("Cached", 0)
sreclaimable_kb = mem_info.get("SReclaimable", 0)
return mem_total, mem_used used_kb = mem_total_kb - mem_free_kb - buffers_kb - cached_kb - sreclaimable_kb
if used_kb < 0:
used_kb = mem_total_kb - mem_info.get("MemAvailable", mem_total_kb)
used_kb = max(0, used_kb)
total_mb = mem_total_kb // 1024
used_mb = used_kb // 1024
return total_mb, used_mb

View File

@ -1,6 +1,6 @@
from fastapi import APIRouter, BackgroundTasks, HTTPException, UploadFile, File from fastapi import APIRouter, BackgroundTasks, HTTPException, UploadFile, File
from ..schema.config.hysteria import ConfigFile, GetPortResponse, GetSniResponse from ..schema.config.hysteria import ConfigFile, GetPortResponse, GetSniResponse
from ..schema.response import DetailResponse, IPLimitConfig, SetupDecoyRequest, DecoyStatusResponse from ..schema.response import DetailResponse, IPLimitConfig, SetupDecoyRequest, DecoyStatusResponse, IPLimitConfigResponse
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
import shutil import shutil
import zipfile import zipfile
@ -335,6 +335,14 @@ async def config_ip_limit_api(config: IPLimitConfig):
except Exception as e: except Exception as e:
raise HTTPException(status_code=400, detail=f'Error configuring IP Limiter: {str(e)}') raise HTTPException(status_code=400, detail=f'Error configuring IP Limiter: {str(e)}')
@router.get('/ip-limit/config', response_model=IPLimitConfigResponse, summary='Get IP Limiter Configuration')
async def get_ip_limit_config_api():
"""Retrieves the current IP Limiter configuration."""
try:
config = cli_api.get_ip_limiter_config()
return IPLimitConfigResponse(**config)
except Exception as e:
raise HTTPException(status_code=500, detail=f'Error retrieving IP Limiter configuration: {str(e)}')
def run_setup_decoy_background(domain: str, decoy_path: str): def run_setup_decoy_background(domain: str, decoy_path: str):
"""Function to run decoy setup in the background.""" """Function to run decoy setup in the background."""

View File

@ -6,8 +6,12 @@ class DetailResponse(BaseModel):
detail: str detail: str
class IPLimitConfig(BaseModel): class IPLimitConfig(BaseModel):
block_duration: Optional[int] = None block_duration: Optional[int] = Field(None, example=60)
max_ips: Optional[int] = None max_ips: Optional[int] = Field(None, example=1)
class IPLimitConfigResponse(BaseModel):
block_duration: Optional[int] = Field(None, description="Current block duration in seconds for IP Limiter")
max_ips: Optional[int] = Field(None, description="Current maximum IPs per user for IP Limiter")
class SetupDecoyRequest(BaseModel): class SetupDecoyRequest(BaseModel):
domain: str = Field(..., description="Domain name associated with the web panel") domain: str = Field(..., description="Domain name associated with the web panel")

View File

@ -102,7 +102,7 @@
</a> </a>
</li> </li>
<li class="nav-item"> <li class="nav-item">
<a href="https://returnfi.github.io/Hys2-docs/" class="nav-link"> <a href="https://returnfi.github.io/Hys2-docs/" target="_blank" rel="noopener noreferrer" class="nav-link">
<i class="nav-icon fas fa-lightbulb"></i> <i class="nav-icon fas fa-lightbulb"></i>
<p>Guides & Tutorials</p> <p>Guides & Tutorials</p>
</a> </a>
@ -121,23 +121,21 @@
<!-- Footer --> <!-- Footer -->
<footer class="main-footer"> <footer class="main-footer">
<div class="d-flex justify-content-between align-items-center flex-wrap"> <div class="d-flex align-items-center">
<div> <a href="https://github.com/ReturnFI/Blitz" target="_blank" rel="noopener noreferrer" class="text-decoration-none mr-3">
<a href="https://github.com/ReturnFI/Blitz" target="_blank" class="text-decoration-none mr-2 mb-1 d-inline-block"> <i class="fab fa-github"></i> GitHub
<img src="https://img.shields.io/badge/GitHub-Repository-181717?logo=github" alt="GitHub">
</a> </a>
<a href="https://t.me/hysteria2_panel" target="_blank" class="text-decoration-none mr-2 mb-1 d-inline-block"> <a href="https://t.me/hysteria2_panel" target="_blank" rel="noopener noreferrer" class="text-decoration-none mr-3">
<img src="https://img.shields.io/badge/Telegram-Channel-0088cc?logo=telegram" alt="Telegram"> <i class="fab fa-telegram-plane"></i> Telegram
</a> </a>
<a href="https://github.com/ReturnFI/Blitz/releases" target="_blank" class="text-decoration-none mr-2 mb-1 d-inline-block"> <!-- <a href="https://github.com/ReturnFI/Blitz/releases" target="_blank" rel="noopener noreferrer" class="text-decoration-none">
<img id="panel-version" src="https://img.shields.io/github/v/release/ReturnFI/Blitz?label=Release&logo=github" alt="Release Version"> <i class="fas fa-code-branch"></i> <span id="panel-version">Loading version...</span>
</a> -->
<div class="ml-auto">
<a href="https://github.com/ReturnFI/Blitz/releases" target="_blank" rel="noopener noreferrer" class="text-decoration-none">
<i class="fas fa-code-branch"></i> <span id="panel-version">Loading version...</span>
</a> </a>
</div> </div>
<div class="mb-1">
<strong>
<img src="https://img.shields.io/badge/Made%20with-%E2%9D%A4-red" alt="Made with Love">
</strong>
</div>
</div> </div>
</footer> </footer>
</div> </div>

View File

@ -451,8 +451,10 @@
} else if (id === 'decoy_path') { } else if (id === 'decoy_path') {
fieldValid = isValidPath(input.val()); fieldValid = isValidPath(input.val());
} else { } else {
if (input.attr('placeholder') && input.attr('placeholder').includes('Enter') && !input.attr('id').startsWith('ipv')) {
fieldValid = input.val().trim() !== ""; fieldValid = input.val().trim() !== "";
} }
}
if (!fieldValid) { if (!fieldValid) {
input.addClass('is-invalid'); input.addClass('is-invalid');
@ -521,22 +523,22 @@
}; };
Object.keys(servicesMap).forEach(service => { Object.keys(servicesMap).forEach(service => {
let formSelector = servicesMap[service]; let targetSelector = servicesMap[service];
let isRunning = data[service]; let isRunning = data[service];
if (service === "hysteria_normal_sub") { if (service === "hysteria_normal_sub") {
const $normalFormGroups = $("#normal_sub_service_form .form-group"); const $normalForm = $("#normal_sub_service_form");
const $normalFormGroups = $normalForm.find(".form-group");
const $normalStartBtn = $("#normal_start"); const $normalStartBtn = $("#normal_start");
const $normalStopBtn = $("#normal_stop"); const $normalStopBtn = $("#normal_stop");
const $normalAlert = $("#normal_sub_service_form .alert-info");
const $normalSubConfigTabLi = $(".normal-sub-config-tab-li"); const $normalSubConfigTabLi = $(".normal-sub-config-tab-li");
if (isRunning) { if (isRunning) {
$normalFormGroups.hide(); $normalFormGroups.hide();
$normalStartBtn.hide(); $normalStartBtn.hide();
$normalStopBtn.show(); $normalStopBtn.show();
if ($normalAlert.length === 0) { if ($normalForm.find(".alert-info").length === 0) {
$("#normal_sub_service_form").prepend(`<div class='alert alert-info'>NormalSub service is running. You can stop it or configure its subpath.</div>`); $normalForm.prepend(`<div class='alert alert-info'>NormalSub service is running. You can stop it or configure its subpath.</div>`);
} }
$normalSubConfigTabLi.show(); $normalSubConfigTabLi.show();
fetchNormalSubPath(); fetchNormalSubPath();
@ -544,7 +546,7 @@
$normalFormGroups.show(); $normalFormGroups.show();
$normalStartBtn.show(); $normalStartBtn.show();
$normalStopBtn.hide(); $normalStopBtn.hide();
$("#normal_sub_service_form .alert-info").remove(); $normalForm.find(".alert-info").remove();
$normalSubConfigTabLi.hide(); $normalSubConfigTabLi.hide();
if ($('#normal-sub-config-link-tab').hasClass('active')) { if ($('#normal-sub-config-link-tab').hasClass('active')) {
$('#normal-tab').tab('show'); $('#normal-tab').tab('show');
@ -553,31 +555,40 @@
$("#normal_subpath_input").removeClass('is-invalid'); $("#normal_subpath_input").removeClass('is-invalid');
} }
} else if (service === "hysteria_iplimit") { } else if (service === "hysteria_iplimit") {
const $ipLimitServiceForm = $("#ip_limit_service_form");
const $configTabLi = $(".ip-limit-config-tab-li");
if (isRunning) { if (isRunning) {
$("#ip_limit_start").hide(); $("#ip_limit_start").hide();
$("#ip_limit_stop").show(); $("#ip_limit_stop").show();
$(".ip-limit-config-tab-li").show(); $configTabLi.show();
// TODO: Fetch IP Limit Config and populate fields fetchIpLimitConfig();
if ($ipLimitServiceForm.find(".alert-info").length === 0) {
$ipLimitServiceForm.prepend(`<div class='alert alert-info'>IP-Limit service is running. You can stop it if needed.</div>`);
}
} else { } else {
$("#ip_limit_start").show(); $("#ip_limit_start").show();
$("#ip_limit_stop").hide(); $("#ip_limit_stop").hide();
$(".ip-limit-config-tab-li").hide(); $configTabLi.hide();
$('#ip-limit-service-tab').tab('show'); $('#ip-limit-service-tab').tab('show');
// TODO: Clear IP Limit Config fields $ipLimitServiceForm.find(".alert-info").remove();
$("#block_duration").val("");
$("#max_ips").val("");
$("#block_duration, #max_ips").removeClass('is-invalid');
} }
} else { } else {
const $formSelector = $(targetSelector);
if (isRunning) { if (isRunning) {
$(formSelector + " .form-group").hide(); $formSelector.find(".form-group").hide();
$(formSelector + " .btn-success").hide(); $formSelector.find(".btn-success").hide();
$(formSelector + " .btn-danger").show(); $formSelector.find(".btn-danger").show();
if ($(formSelector + " .alert-info").length === 0) { if ($formSelector.find(".alert-info").length === 0) {
$(formSelector).prepend(`<div class='alert alert-info'>Service is running. You can stop it if needed.</div>`); $formSelector.prepend(`<div class='alert alert-info'>Service is running. You can stop it if needed.</div>`);
} }
} else { } else {
$(formSelector + " .form-group").show(); $formSelector.find(".form-group").show();
$(formSelector + " .btn-success").show(); $formSelector.find(".btn-success").show();
$(formSelector + " .btn-danger").hide(); $formSelector.find(".btn-danger").hide();
$(formSelector + " .alert-info").remove(); $formSelector.find(".alert-info").remove();
} }
} }
}); });
@ -596,7 +607,24 @@
error: function (xhr, status, error) { error: function (xhr, status, error) {
console.error("Failed to fetch NormalSub subpath:", error, xhr.responseText); console.error("Failed to fetch NormalSub subpath:", error, xhr.responseText);
$("#normal_subpath_input").val(""); $("#normal_subpath_input").val("");
// Swal.fire("Error!", "Could not fetch NormalSub subpath.", "error"); // Avoid too many popups during init }
});
}
function fetchIpLimitConfig() {
$.ajax({
url: "{{ url_for('get_ip_limit_config_api') }}",
type: "GET",
success: function (data) {
$("#block_duration").val(data.block_duration || "");
$("#max_ips").val(data.max_ips || "");
if (data.block_duration) $("#block_duration").removeClass('is-invalid');
if (data.max_ips) $("#max_ips").removeClass('is-invalid');
},
error: function (xhr, status, error) {
console.error("Failed to fetch IP Limit config:", error, xhr.responseText);
$("#block_duration").val("");
$("#max_ips").val("");
} }
}); });
} }
@ -883,7 +911,7 @@
} }
function configIPLimit() { function configIPLimit() {
if (!validateForm('ip_limit_config_form')) return; // Ensure correct form ID if (!validateForm('ip_limit_config_form')) return;
const blockDuration = $("#block_duration").val(); const blockDuration = $("#block_duration").val();
const maxIps = $("#max_ips").val(); const maxIps = $("#max_ips").val();
confirmAction("save the IP Limit configuration", function () { confirmAction("save the IP Limit configuration", function () {
@ -893,7 +921,8 @@
{ block_duration: parseInt(blockDuration), max_ips: parseInt(maxIps) }, { block_duration: parseInt(blockDuration), max_ips: parseInt(maxIps) },
"IP Limit configuration saved successfully!", "IP Limit configuration saved successfully!",
"#ip_limit_change_config", "#ip_limit_change_config",
false false,
fetchIpLimitConfig
); );
}); });
} }
@ -978,7 +1007,7 @@
} else if ($(this).val().trim() !== "") { } else if ($(this).val().trim() !== "") {
$(this).addClass('is-invalid'); $(this).addClass('is-invalid');
} else { } else {
$(this).removeClass('is-invalid'); $(this).addClass('is-invalid');
} }
}); });

View File

@ -3,6 +3,7 @@ source /etc/hysteria/core/scripts/utils.sh
define_colors define_colors
CADDY_CONFIG_FILE="/etc/hysteria/core/scripts/webpanel/Caddyfile" CADDY_CONFIG_FILE="/etc/hysteria/core/scripts/webpanel/Caddyfile"
WEBPANEL_ENV_FILE="/etc/hysteria/core/scripts/webpanel/.env"
install_dependencies() { install_dependencies() {
# Update system # Update system
@ -365,6 +366,65 @@ EOL
fi fi
} }
reset_credentials() {
local new_username_val=""
local new_password_val=""
local changes_made=false
if [ ! -f "$WEBPANEL_ENV_FILE" ]; then
echo -e "${red}Error: Web panel .env file not found. Is the web panel configured?${NC}"
exit 1
fi
OPTIND=1
while getopts ":u:p:" opt; do
case $opt in
u) new_username_val="$OPTARG" ;;
p) new_password_val="$OPTARG" ;;
\?) echo -e "${red}Invalid option: -$OPTARG${NC}" >&2; exit 1 ;;
:) echo -e "${red}Option -$OPTARG requires an argument.${NC}" >&2; exit 1 ;;
esac
done
if [ -z "$new_username_val" ] && [ -z "$new_password_val" ]; then
echo -e "${red}Error: At least one option (-u <new_username> or -p <new_password>) must be provided.${NC}"
echo -e "${yellow}Usage: $0 resetcreds [-u new_username] [-p new_password]${NC}"
exit 1
fi
if [ -n "$new_username_val" ]; then
echo "Updating username to: $new_username_val"
if sudo sed -i "s|^ADMIN_USERNAME=.*|ADMIN_USERNAME=$new_username_val|" "$WEBPANEL_ENV_FILE"; then
changes_made=true
else
echo -e "${red}Failed to update username in $WEBPANEL_ENV_FILE${NC}"
exit 1
fi
fi
if [ -n "$new_password_val" ]; then
echo "Updating password..."
local new_password_hash=$(echo -n "$new_password_val" | sha256sum | cut -d' ' -f1)
if sudo sed -i "s|^ADMIN_PASSWORD=.*|ADMIN_PASSWORD=$new_password_hash|" "$WEBPANEL_ENV_FILE"; then
changes_made=true
else
echo -e "${red}Failed to update password in $WEBPANEL_ENV_FILE${NC}"
exit 1
fi
fi
if [ "$changes_made" = true ]; then
echo "Restarting web panel service to apply changes..."
if systemctl restart hysteria-webpanel.service; then
echo -e "${green}Web panel credentials updated successfully.${NC}"
else
echo -e "${red}Failed to restart hysteria-webpanel service. Please restart it manually.${NC}"
fi
else
echo -e "${yellow}No changes were specified.${NC}"
fi
}
show_webpanel_url() { show_webpanel_url() {
source /etc/hysteria/core/scripts/webpanel/.env source /etc/hysteria/core/scripts/webpanel/.env
local webpanel_url="https://$DOMAIN:$PORT/$ROOT_PATH/" local webpanel_url="https://$DOMAIN:$PORT/$ROOT_PATH/"
@ -413,6 +473,10 @@ case "$1" in
stopdecoy) stopdecoy)
stop_decoy_site stop_decoy_site
;; ;;
resetcreds)
shift
reset_credentials "$@"
;;
url) url)
show_webpanel_url show_webpanel_url
;; ;;
@ -425,6 +489,7 @@ case "$1" in
echo -e "${yellow}stop${NC}" echo -e "${yellow}stop${NC}"
echo -e "${yellow}decoy <DOMAIN> <PATH_TO_DECOY_SITE>${NC}" echo -e "${yellow}decoy <DOMAIN> <PATH_TO_DECOY_SITE>${NC}"
echo -e "${yellow}stopdecoy${NC}" echo -e "${yellow}stopdecoy${NC}"
echo -e "${yellow} resetcreds [-u new_username] [-p new_password]${NC}"
echo -e "${yellow}url${NC}" echo -e "${yellow}url${NC}"
echo -e "${yellow}api-token${NC}" echo -e "${yellow}api-token${NC}"
exit 1 exit 1

31
menu.sh
View File

@ -598,7 +598,7 @@ normalsub_handler() {
} }
webpanel_handler() { webpanel_handler() {
service_status=$(python3 $CLI_PATH get-webpanel-services-status) service_status=$(python3 "$CLI_PATH" get-webpanel-services-status)
echo -e "${cyan}Services Status:${NC}" echo -e "${cyan}Services Status:${NC}"
echo "$service_status" echo "$service_status"
echo "" echo ""
@ -608,6 +608,7 @@ webpanel_handler() {
echo -e "${red}2.${NC} Stop WebPanel service" echo -e "${red}2.${NC} Stop WebPanel service"
echo -e "${cyan}3.${NC} Get WebPanel URL" echo -e "${cyan}3.${NC} Get WebPanel URL"
echo -e "${cyan}4.${NC} Show API Token" echo -e "${cyan}4.${NC} Show API Token"
echo -e "${yellow}5.${NC} Reset WebPanel Credentials"
echo "0. Back" echo "0. Back"
read -p "Choose an option: " option read -p "Choose an option: " option
@ -676,6 +677,34 @@ webpanel_handler() {
echo "$api_token" echo "$api_token"
echo "-------------------------------" echo "-------------------------------"
;; ;;
5)
if ! systemctl is-active --quiet hysteria-webpanel.service; then
echo -e "${red}WebPanel service is not running. Cannot reset credentials.${NC}"
else
read -e -p "Enter new admin username (leave blank to keep current): " new_username
read -e -p "Enter new admin password (leave blank to keep current): " new_password
echo
if [ -z "$new_username" ] && [ -z "$new_password" ]; then
echo -e "${yellow}No changes specified. Aborting.${NC}"
else
local cmd_args=("-u" "$new_username")
if [ -n "$new_password" ]; then
cmd_args+=("-p" "$new_password")
fi
if [ -z "$new_username" ]; then
cmd_args=()
if [ -n "$new_password" ]; then
cmd_args+=("-p" "$new_password")
fi
fi
echo "Attempting to reset credentials..."
python3 "$CLI_PATH" reset-webpanel-creds "${cmd_args[@]}"
fi
fi
;;
0) 0)
break break
;; ;;