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
### ⚙️ 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
- New 'Configure' tab in the Settings panel (visible only if NormalSub is active)
- Real-time client-side validation and live subpath editing
## 🖌️ UI Improvements
* 🔌 **feat:** Add API endpoints
- `GET /api/v1/config/normalsub/subpath`: Fetch current subpath
- `PUT /api/v1/config/normalsub/edit_subpath`: Update the subpath securely
* 🧼 **clean:** Removed shield icons from footer for a cleaner look
* 🖥️ **feat:** Add CLI command support
- `edit_subpath` option added to CLI and `normal-sub` command
- Automatically restarts the service after applying changes
## 🐛 Fixes
* 🔧 **feat:** Add backend CLI + shell logic to update `.env` subpath for NormalSub
## 📄 Documentation
* 📚 **docs:** Add sponsorship section with referral link to README
* 📊 **fix:** Align memory usage reporting with `free -m` in `server_info.py`

View File

@ -523,6 +523,28 @@ def get_web_panel_api_token():
except Exception as e:
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')
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.'''
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:
'''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('')
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

View File

@ -52,14 +52,39 @@ def get_cpu_usage(interval: float = 0.1) -> float:
def get_memory_usage() -> tuple[int, int]:
with open("/proc/meminfo") as f:
lines = f.readlines()
mem_info = {}
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_available = int(next(line for line in lines if "MemAvailable" in line).split()[1]) // 1024
mem_used = mem_total - mem_available
mem_total_kb = mem_info.get("MemTotal", 0)
mem_free_kb = mem_info.get("MemFree", 0)
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 ..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
import shutil
import zipfile
@ -335,6 +335,14 @@ async def config_ip_limit_api(config: IPLimitConfig):
except Exception as 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):
"""Function to run decoy setup in the background."""

View File

@ -6,8 +6,12 @@ class DetailResponse(BaseModel):
detail: str
class IPLimitConfig(BaseModel):
block_duration: Optional[int] = None
max_ips: Optional[int] = None
block_duration: Optional[int] = Field(None, example=60)
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):
domain: str = Field(..., description="Domain name associated with the web panel")
@ -15,4 +19,4 @@ class SetupDecoyRequest(BaseModel):
class DecoyStatusResponse(BaseModel):
active: bool = Field(..., description="Whether the decoy site is currently configured and active")
path: Optional[str] = Field(None, description="The configured path for the decoy site, if active")
path: Optional[str] = Field(None, description="The configured path for the decoy site, if active")

View File

@ -102,7 +102,7 @@
</a>
</li>
<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>
<p>Guides & Tutorials</p>
</a>
@ -121,22 +121,20 @@
<!-- Footer -->
<footer class="main-footer">
<div class="d-flex justify-content-between align-items-center flex-wrap">
<div>
<a href="https://github.com/ReturnFI/Blitz" target="_blank" class="text-decoration-none mr-2 mb-1 d-inline-block">
<img src="https://img.shields.io/badge/GitHub-Repository-181717?logo=github" alt="GitHub">
<div class="d-flex align-items-center">
<a href="https://github.com/ReturnFI/Blitz" target="_blank" rel="noopener noreferrer" class="text-decoration-none mr-3">
<i class="fab fa-github"></i> GitHub
</a>
<a href="https://t.me/hysteria2_panel" target="_blank" rel="noopener noreferrer" class="text-decoration-none mr-3">
<i class="fab fa-telegram-plane"></i> Telegram
</a>
<!-- <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> -->
<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 href="https://t.me/hysteria2_panel" target="_blank" class="text-decoration-none mr-2 mb-1 d-inline-block">
<img src="https://img.shields.io/badge/Telegram-Channel-0088cc?logo=telegram" alt="Telegram">
</a>
<a href="https://github.com/ReturnFI/Blitz/releases" target="_blank" class="text-decoration-none mr-2 mb-1 d-inline-block">
<img id="panel-version" src="https://img.shields.io/github/v/release/ReturnFI/Blitz?label=Release&logo=github" alt="Release Version">
</a>
</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>
</footer>

View File

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

View File

@ -3,6 +3,7 @@ source /etc/hysteria/core/scripts/utils.sh
define_colors
CADDY_CONFIG_FILE="/etc/hysteria/core/scripts/webpanel/Caddyfile"
WEBPANEL_ENV_FILE="/etc/hysteria/core/scripts/webpanel/.env"
install_dependencies() {
# Update system
@ -365,6 +366,65 @@ EOL
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() {
source /etc/hysteria/core/scripts/webpanel/.env
local webpanel_url="https://$DOMAIN:$PORT/$ROOT_PATH/"
@ -413,6 +473,10 @@ case "$1" in
stopdecoy)
stop_decoy_site
;;
resetcreds)
shift
reset_credentials "$@"
;;
url)
show_webpanel_url
;;
@ -425,6 +489,7 @@ case "$1" in
echo -e "${yellow}stop${NC}"
echo -e "${yellow}decoy <DOMAIN> <PATH_TO_DECOY_SITE>${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}api-token${NC}"
exit 1

31
menu.sh
View File

@ -598,7 +598,7 @@ normalsub_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 "$service_status"
echo ""
@ -608,6 +608,7 @@ webpanel_handler() {
echo -e "${red}2.${NC} Stop WebPanel service"
echo -e "${cyan}3.${NC} Get WebPanel URL"
echo -e "${cyan}4.${NC} Show API Token"
echo -e "${yellow}5.${NC} Reset WebPanel Credentials"
echo "0. Back"
read -p "Choose an option: " option
@ -676,6 +677,34 @@ webpanel_handler() {
echo "$api_token"
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)
break
;;