diff --git a/baseTemplate/templates/baseTemplate/index.html b/baseTemplate/templates/baseTemplate/index.html index 0ea106fb6..fbb110020 100644 --- a/baseTemplate/templates/baseTemplate/index.html +++ b/baseTemplate/templates/baseTemplate/index.html @@ -2559,7 +2559,7 @@ - + diff --git a/cyberpanel-mods/user-management/Test/test-ols-apache-backend-setup.sh b/cyberpanel-mods/user-management/Test/test-ols-apache-backend-setup.sh new file mode 100755 index 000000000..75014cf21 --- /dev/null +++ b/cyberpanel-mods/user-management/Test/test-ols-apache-backend-setup.sh @@ -0,0 +1,47 @@ +#!/bin/bash + +set -euo pipefail + +BASE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +WEBSITE_FUNCTIONS="${BASE_DIR}/website-functions.sh" +HELPER="${BASE_DIR}/ols-apache-backend-setup.sh" + +pass() { echo "[PASS] $1"; } +fail() { echo "[FAIL] $1"; exit 1; } + +[[ -f "$WEBSITE_FUNCTIONS" ]] || fail "website-functions.sh missing" +[[ -f "$HELPER" ]] || fail "ols-apache-backend-setup.sh missing" +[[ -x "$HELPER" ]] || fail "helper is not executable" + +bash -n "$WEBSITE_FUNCTIONS" || fail "website-functions.sh syntax invalid" +pass "website-functions syntax" + +bash -n "$HELPER" || fail "helper syntax invalid" +pass "helper syntax" + +if ! rg -q "Enable Additional Feature: OpenLiteSpeed \+ Apache backend" "$WEBSITE_FUNCTIONS"; then + fail "create flow prompt for OLS+Apache backend missing" +fi +pass "create flow prompt exists" + +if ! rg -q "setup_ols_apache_backend_if_enabled" "$WEBSITE_FUNCTIONS"; then + fail "setup function wiring missing from website flow" +fi +pass "setup hook exists" + +if ! rg -q "cyberpanel createChild" "$WEBSITE_FUNCTIONS"; then + fail "child domain create function not wired" +fi +pass "child domain flow exists" + +if ! rg -q "httpd -t" "$HELPER"; then + fail "apache validation gate missing" +fi +pass "apache validation gate exists" + +if ! rg -q "health_check_domain" "$HELPER"; then + fail "domain health-check gate missing" +fi +pass "domain health-check gate exists" + +echo "All OLS+Apache backend automation checks passed." diff --git a/cyberpanel-mods/user-management/ols-apache-backend-setup.sh b/cyberpanel-mods/user-management/ols-apache-backend-setup.sh new file mode 100755 index 000000000..430869b3f --- /dev/null +++ b/cyberpanel-mods/user-management/ols-apache-backend-setup.sh @@ -0,0 +1,291 @@ +#!/bin/bash + +# OLS + Apache backend auto-config for CyberPanel domains/sub-domains. +# Idempotent: safe to run repeatedly for same domain. + +set -u + +LOG_FILE="/var/log/cyberpanel_ols_apache_backend.log" +MAX_RETRIES=3 + +log_msg() { + local level="$1" + local module="$2" + local message="$3" + local retry="${4:-0}" + local ts + ts="$(date '+%Y-%m-%d %H:%M:%S')" + echo "[$ts] [$level] [$module] retry=$retry domain=${TARGET_DOMAIN:-unknown} - $message" | tee -a "$LOG_FILE" +} + +fail() { + log_msg "ERROR" "setup" "$1" "${2:-0}" + exit 1 +} + +require_root() { + if [[ "${EUID:-$(id -u)}" -ne 0 ]]; then + fail "This script must run as root" + fi +} + +domain_valid() { + local d="$1" + [[ "$d" =~ ^[A-Za-z0-9.-]+\.[A-Za-z]{2,}$ ]] +} + +detect_docroot() { + local domain="$1" + local vhost_conf="/usr/local/lsws/conf/vhosts/${domain}/vhost.conf" + local docroot="" + + if [[ -f "$vhost_conf" ]]; then + docroot="$(awk '$1=="docRoot"{print $2; exit}' "$vhost_conf" 2>/dev/null)" + fi + if [[ -z "$docroot" ]]; then + docroot="/home/${domain}" + fi + echo "$docroot" +} + +ensure_apache_conf() { + local domain="$1" + local docroot="$2" + local owner="${3:-newst3922}" + local group="${4:-newst3922}" + local apache_conf="/etc/httpd/conf.d/${domain}.ols-apache-backend.conf" + local cert_dir="/etc/letsencrypt/live/${domain}" + local cert_file="${cert_dir}/fullchain.pem" + local key_file="${cert_dir}/privkey.pem" + + if [[ ! -f "$cert_file" || ! -f "$key_file" ]]; then + cert_file="/etc/httpd/conf.d/ssl/.fullchain.pem" + key_file="/etc/httpd/conf.d/ssl/.privkey.pem" + fi + + mkdir -p "$(dirname "$apache_conf")" || return 1 + cat > "$apache_conf" < + ServerName ${domain} + ServerAlias www.${domain} + ServerAdmin root@localhost + SuexecUserGroup ${owner} ${group} + DocumentRoot ${docroot} + + + SetHandler "proxy:unix:/var/run/php-fpm/${domain}.sock|fcgi://localhost" + + + + Options Indexes FollowSymLinks + AllowOverride all + Require all granted + DirectoryIndex index.php index.html + + + + + ServerName ${domain} + ServerAlias www.${domain} + ServerAdmin root@localhost + SuexecUserGroup ${owner} ${group} + DocumentRoot ${docroot} + + + SetHandler "proxy:unix:/var/run/php-fpm/${domain}.sock|fcgi://localhost" + + + + Options Indexes FollowSymLinks + AllowOverride all + Require all granted + DirectoryIndex index.php index.html + + + SSLEngine on + SSLVerifyClient none + SSLCertificateFile ${cert_file} + SSLCertificateKeyFile ${key_file} + +EOF + return 0 +} + +ensure_ols_proxy_rewrite() { + local domain="$1" + local vhost_conf="/usr/local/lsws/conf/vhosts/${domain}/vhost.conf" + local tmp_file="${vhost_conf}.tmp.$$" + local marker="AUTO_OLS_APACHE_BACKEND" + + [[ -f "$vhost_conf" ]] || return 1 + + awk ' + BEGIN { in_rewrite=0; depth=0 } + { + if ($1=="rewrite" && $2=="{") { + in_rewrite=1; + depth=1; + next; + } + if (in_rewrite==1) { + if (index($0, "{")>0) depth++; + if (index($0, "}")>0) depth--; + if (depth<=0) { in_rewrite=0; next; } + next; + } + print $0; + } + ' "$vhost_conf" > "$tmp_file" || return 1 + + cat >> "$tmp_file" <<'EOF' +rewrite { + enable 1 + autoLoadHtaccess 0 + rules <</dev/null 2>&1 || true + return 0 +} + +check_ports() { + local has_8082 + local has_8083 + has_8082="$(ss -tln 2>/dev/null | awk '$4 ~ /:8082$/ {print "1"; exit}')" + has_8083="$(ss -tln 2>/dev/null | awk '$4 ~ /:8083$/ {print "1"; exit}')" + [[ "$has_8082" == "1" && "$has_8083" == "1" ]] || return 1 + return 0 +} + +health_check_domain() { + local domain="$1" + local code + code="$(curl -k -sS -o /dev/null -w "%{http_code}" "https://${domain}/" 2>/dev/null || true)" + [[ "$code" != "503" && "$code" != "000" && "$code" != "" ]] +} + +rollback_file_if_needed() { + local src="$1" + local backup="$2" + if [[ -f "$backup" ]]; then + cp -f "$backup" "$src" >/dev/null 2>&1 || true + fi +} + +main() { + require_root + + if [[ "${1:-}" == "--help" || "${1:-}" == "-h" ]]; then + echo "Usage: $0 --domain [--owner ] [--group ] [--docroot ]" + exit 0 + fi + + local owner="newst3922" + local group="newst3922" + local docroot="" + local domain="" + + while [[ $# -gt 0 ]]; do + case "$1" in + --domain) domain="${2:-}"; shift 2 ;; + --owner) owner="${2:-}"; shift 2 ;; + --group) group="${2:-}"; shift 2 ;; + --docroot) docroot="${2:-}"; shift 2 ;; + *) fail "Unknown argument: $1" ;; + esac + done + + TARGET_DOMAIN="$domain" + domain_valid "$domain" || fail "Invalid or missing domain" + [[ -n "$docroot" ]] || docroot="$(detect_docroot "$domain")" + [[ -d "$docroot" ]] || fail "Docroot does not exist: $docroot" + + local vhost_conf="/usr/local/lsws/conf/vhosts/${domain}/vhost.conf" + local apache_conf="/etc/httpd/conf.d/${domain}.ols-apache-backend.conf" + [[ -f "$vhost_conf" ]] || fail "OLS vhost config not found: $vhost_conf" + + local vhost_bak="${vhost_conf}.bak.$(date +%s)" + local apache_bak="${apache_conf}.bak.$(date +%s)" + cp -f "$vhost_conf" "$vhost_bak" || fail "Failed to backup OLS vhost" + [[ -f "$apache_conf" ]] && cp -f "$apache_conf" "$apache_bak" || true + + log_msg "INFO" "setup" "Starting OLS+Apache backend setup" + + local attempt=1 + while [[ $attempt -le $MAX_RETRIES ]]; do + if ! ensure_apache_conf "$domain" "$docroot" "$owner" "$group"; then + log_msg "WARN" "apache" "Failed writing Apache config" "$attempt" + attempt=$((attempt + 1)) + sleep 1 + continue + fi + + if ! ensure_ols_proxy_rewrite "$domain"; then + log_msg "WARN" "ols" "Failed writing OLS proxy rewrite" "$attempt" + attempt=$((attempt + 1)) + sleep 1 + continue + fi + + if ! httpd -t >/dev/null 2>&1; then + log_msg "WARN" "validate" "Apache syntax validation failed" "$attempt" + rollback_file_if_needed "$apache_conf" "$apache_bak" + attempt=$((attempt + 1)) + sleep 1 + continue + fi + + if ! ensure_httpd_running; then + log_msg "WARN" "service" "Could not start/enable httpd" "$attempt" + attempt=$((attempt + 1)) + sleep 1 + continue + fi + + if ! systemctl restart lsws >/dev/null 2>&1; then + log_msg "WARN" "service" "LSWS restart failed" "$attempt" + rollback_file_if_needed "$vhost_conf" "$vhost_bak" + attempt=$((attempt + 1)) + sleep 1 + continue + fi + + if ! check_ports; then + log_msg "WARN" "validate" "Ports 8082/8083 are not listening" "$attempt" + attempt=$((attempt + 1)) + sleep 1 + continue + fi + + if ! health_check_domain "$domain"; then + log_msg "WARN" "health" "Domain health check failed (503/000)" "$attempt" + attempt=$((attempt + 1)) + sleep 1 + continue + fi + + log_msg "INFO" "setup" "Apache backend configured, ports verified, vhost health check passed" + exit 0 + done + + rollback_file_if_needed "$vhost_conf" "$vhost_bak" + rollback_file_if_needed "$apache_conf" "$apache_bak" + fail "Failed to configure OLS+Apache backend after ${MAX_RETRIES} retries" "$MAX_RETRIES" +} + +main "$@" diff --git a/cyberpanel-mods/user-management/to-do/OLS-APACHE-BACKEND-AUTO-SETUP.md b/cyberpanel-mods/user-management/to-do/OLS-APACHE-BACKEND-AUTO-SETUP.md new file mode 100644 index 000000000..e7269b846 --- /dev/null +++ b/cyberpanel-mods/user-management/to-do/OLS-APACHE-BACKEND-AUTO-SETUP.md @@ -0,0 +1,49 @@ +# OLS + Apache Backend Auto-Setup + +## What this does + +When creating a website or child domain via `website-functions.sh`, users can now select: + +- `Enable Additional Feature: OpenLiteSpeed + Apache backend (YES/NO)` + +If set to `YES`, the script automatically runs: + +- `ols-apache-backend-setup.sh --domain ` + +## Automated actions + +1. Detects docroot from LiteSpeed vhost config. +2. Writes Apache backend vhost config at: + - `/etc/httpd/conf.d/.ols-apache-backend.conf` + - Includes both `:8083` (HTTP backend) and `:8082` (HTTPS backend). +3. Rewrites LiteSpeed vhost config to proxy through: + - `apachebackend` (`127.0.0.1:8083`) + - `proxyApacheBackendSSL` (`127.0.0.1:8082`) +4. Runs validation and service gates: + - `httpd -t` + - ensures `httpd` is running/enabled + - restarts `lsws` + - verifies ports `8082/8083` are listening + - verifies site health is not 503 +5. Rolls back to backups if setup fails after retries. + +## Logs + +- Main setup log: `/var/log/cyberpanel_ols_apache_backend.log` +- Existing user CLI logs are still used for create/delete actions. + +## Failure handling + +- Retries up to 3 times. +- Every error line includes timestamp, module, domain, and retry count. +- On hard failure, prior config snapshots are restored. + +## Test + +Run: + +```bash +/home/cyberpanel-mods/user-management/Test/test-ols-apache-backend-setup.sh +``` + +This validates wiring and safety gates for the new feature. diff --git a/cyberpanel-mods/user-management/website-functions.sh b/cyberpanel-mods/user-management/website-functions.sh new file mode 100644 index 000000000..be68dedbf --- /dev/null +++ b/cyberpanel-mods/user-management/website-functions.sh @@ -0,0 +1,413 @@ +#!/bin/bash + +# Website Management Functions for CyberPanel +# Part of CyberPanel Mods - Enhanced Repository + +## Website Functions + +setup_ols_apache_backend_if_enabled() { + local domain="$1" + local enabled="$2" + local helper + local yn + + yn="$(echo "${enabled:-NO}" | tr '[:lower:]' '[:upper:]')" + if [[ "$yn" != "YES" && "$yn" != "Y" && "$yn" != "1" && "$yn" != "ON" && "$yn" != "TRUE" ]]; then + return 0 + fi + + helper="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/ols-apache-backend-setup.sh" + if [[ ! -x "$helper" ]]; then + echo "[!] OLS+Apache backend helper not found or not executable: $helper" + if command -v log_message >/dev/null 2>&1; then + log_message "OLS+Apache backend helper missing for domain: $domain" + fi + return 1 + fi + + echo "[*] Applying OpenLiteSpeed + Apache backend auto-setup for: $domain" + if ! "$helper" --domain "$domain"; then + echo "[!] OLS+Apache backend setup failed for: $domain" + if command -v log_message >/dev/null 2>&1; then + log_message "OLS+Apache backend setup failed for domain: $domain" + fi + return 1 + fi + echo "[*] OLS+Apache backend setup completed for: $domain" + if command -v log_message >/dev/null 2>&1; then + log_message "OLS+Apache backend setup completed for domain: $domain" + fi + return 0 +} + +function createWebsite() +{ + echo -e $YELLOW + echo "***********************************************************************" + echo "* Website Functions - Create Website *" + echo "***********************************************************************" + echo -e $TEXTRESET + read -p "Enter Package (Default | admin_Standard | admin_Professional | admin_Enterprise): " Package + echo $Package | grep -w $package_pattern >> /dev/null && is_package="true" + if [[ $is_package != "true" ]]; then + echo "[!] Package Choice Cannot be found!" + echo "[*] Reverting...." + sleep 2 + clear + createWebsite + fi + unset is_package + + read -p "Enter Username of the Web Owner (No Special Chars Except Email Pattern): " userName + echo $userName | grep -P $username_special_chars >> /dev/null || is_special="true" + if [[ $is_special != "true" ]]; then + echo "[!] Not a valid username!" + echo "[*] Reverting...." + sleep 2 + clear + createWebsite + fi + unset is_special + + read -p "Enter the website domain to be use: " Domain + echo $Domain | egrep -e $domain_pattern >> /dev/null && is_domain="true" + if [[ $is_domain != "true" ]]; then + echo "[!] Not a valid domain name!" + echo "[*] Reverting...." + sleep 2 + clear + createWebsite + fi + unset is_domain + + read -p "Enter Email Address: " email + echo $email | egrep $email_pattern >> /dev/null && is_email="true" + if [[ $is_email != "true" ]]; then + echo "[!] Not a valid email address!" + echo "[*] Reverting...." + sleep 2 + clear + createWebsite + fi + unset is_email + + echo -e "Available PHP Versions: 5.3 | 5.4 | 5.5 | 5.6 | 7.0 | 7.1 | 7.2 | 7.3 | 7.4 | 8.0 | 8.1 | 8.2 | 8.3" + read -p "Enter PHP Version: " php_version + echo $php_version | grep -w $php_version_pattern >> /dev/null && is_php="true" + if [[ $is_php != "true" ]]; then + echo "[!] Not a valid PHP Version!" + echo "[*] Reverting...." + sleep 2 + clear + createWebsite + fi + unset is_php + + read -p "Do you want to enable SSL (YES/NO)?: " Ssl + Ssl=`echo $Ssl | tr [:lower:] [:upper:]` + if [[ $Ssl = "YES" ]]; then + ssl_support=1 + else + ssl_support=0 + fi + + read -p "Do you want to enable DKIM (YES/NO)?: " Dkim + Dkim=`echo $Dkim | tr [:lower:] [:upper:]` + if [[ $Dkim = "YES" ]]; then + dkim_support=1 + else + dkim_support=0 + fi + + read -p "Do you want to enable openBasedir Protection (YES/NO)?: " Openbase + Openbase=`echo $Openbase | tr [:lower:] [:upper:]` + if [[ $Openbase = "YES" ]]; then + openbase_support=1 + else + openbase_support=0 + fi + + read -p "Enable Additional Feature: OpenLiteSpeed + Apache backend (YES/NO)?: " OlsApacheBackend + OlsApacheBackend=`echo $OlsApacheBackend | tr [:lower:] [:upper:]` + if [[ -z "$OlsApacheBackend" ]]; then + OlsApacheBackend="NO" + fi + + echo -e $YELLOW + echo "***********************************************************************" + echo "* Details You Entered *" + echo "***********************************************************************" + echo "Package: $Package" + echo "Username (Owner): $userName" + echo "Domain: $Domain" + echo "Email: $email" + echo "PHP Version: $php_version" + echo "SSL Support: $Ssl" + echo "DKIM Support: $Dkim" + echo "Open Base Directory Protection Enable: $Openbase" + echo "OLS + Apache Backend Feature: $OlsApacheBackend" + read -p "Are you sure about this? [Y/N]: " choice + choice=`echo $choice | tr [:lower:] [:upper:]` + if [ $choice = "Y" ] ; then + cyberpanel createWebsite --package $Package --owner $userName --domainName $Domain --email $email --php $php_version --ssl $ssl_support --dkim $dkim_support --openBasedir $openbase_support + if [[ $? -eq 0 ]]; then + setup_ols_apache_backend_if_enabled "$Domain" "$OlsApacheBackend" + fi + log_message "Created website: $Domain for user: $userName" + unset Package userName Domain email php_version Ssl Dkim Openbase OlsApacheBackend choice + exit 0 + else + echo -e "[*] Reverting..." + unset Package userName Domain email php_version Ssl Dkim Openbase OlsApacheBackend choice + sleep 2 + clear + createWebsite + fi +} + +function createChildDomain() +{ + echo -e $YELLOW + echo "***********************************************************************" + echo "* Website Functions - Create Child Domain *" + echo "***********************************************************************" + echo -e $TEXTRESET + + read -p "Enter Master Domain: " masterDomain + echo $masterDomain | egrep -e $domain_pattern >> /dev/null && is_master_domain="true" + if [[ $is_master_domain != "true" ]]; then + echo "[!] Not a valid master domain name!" + echo "[*] Reverting...." + sleep 2 + clear + createChildDomain + fi + unset is_master_domain + + read -p "Enter Child Domain (full): " childDomain + echo $childDomain | egrep -e $domain_pattern >> /dev/null && is_child_domain="true" + if [[ $is_child_domain != "true" ]]; then + echo "[!] Not a valid child domain name!" + echo "[*] Reverting...." + sleep 2 + clear + createChildDomain + fi + unset is_child_domain + + read -p "Enter Username of the Web Owner: " userName + echo $userName | grep -P $username_special_chars >> /dev/null || is_special="true" + if [[ $is_special != "true" ]]; then + echo "[!] Not a valid username!" + echo "[*] Reverting...." + sleep 2 + clear + createChildDomain + fi + unset is_special + + echo -e "Available PHP Versions: 5.3 | 5.4 | 5.5 | 5.6 | 7.0 | 7.1 | 7.2 | 7.3 | 7.4 | 8.0 | 8.1 | 8.2 | 8.3" + read -p "Enter PHP Version: " php_version + echo $php_version | grep -w $php_version_pattern >> /dev/null && is_php="true" + if [[ $is_php != "true" ]]; then + echo "[!] Not a valid PHP Version!" + echo "[*] Reverting...." + sleep 2 + clear + createChildDomain + fi + unset is_php + + read -p "Do you want to enable SSL (YES/NO)?: " Ssl + Ssl=`echo $Ssl | tr [:lower:] [:upper:]` + if [[ $Ssl = "YES" ]]; then + ssl_support=1 + else + ssl_support=0 + fi + + read -p "Do you want to enable DKIM (YES/NO)?: " Dkim + Dkim=`echo $Dkim | tr [:lower:] [:upper:]` + if [[ $Dkim = "YES" ]]; then + dkim_support=1 + else + dkim_support=0 + fi + + read -p "Do you want to enable openBasedir Protection (YES/NO)?: " Openbase + Openbase=`echo $Openbase | tr [:lower:] [:upper:]` + if [[ $Openbase = "YES" ]]; then + openbase_support=1 + else + openbase_support=0 + fi + + read -p "Enable Additional Feature: OpenLiteSpeed + Apache backend (YES/NO)?: " OlsApacheBackend + OlsApacheBackend=`echo $OlsApacheBackend | tr [:lower:] [:upper:]` + if [[ -z "$OlsApacheBackend" ]]; then + OlsApacheBackend="NO" + fi + + echo -e $YELLOW + echo "***********************************************************************" + echo "* Details You Entered *" + echo "***********************************************************************" + echo "Master Domain: $masterDomain" + echo "Child Domain: $childDomain" + echo "Owner: $userName" + echo "PHP Version: $php_version" + echo "SSL Support: $Ssl" + echo "DKIM Support: $Dkim" + echo "Open Base Directory Protection Enable: $Openbase" + echo "OLS + Apache Backend Feature: $OlsApacheBackend" + read -p "Are you sure about this? [Y/N]: " choice + choice=`echo $choice | tr [:lower:] [:upper:]` + if [ "$choice" = "Y" ] ; then + cyberpanel createChild --masterDomain "$masterDomain" --childDomain "$childDomain" --owner "$userName" --php "$php_version" --ssl "$ssl_support" --dkim "$dkim_support" --openBasedir "$openbase_support" + if [[ $? -eq 0 ]]; then + setup_ols_apache_backend_if_enabled "$childDomain" "$OlsApacheBackend" + log_message "Created child domain: $childDomain under: $masterDomain" + else + log_message "Failed to create child domain: $childDomain under: $masterDomain" + fi + unset masterDomain childDomain userName php_version Ssl Dkim Openbase OlsApacheBackend choice + exit 0 + else + echo -e "[*] Reverting..." + unset masterDomain childDomain userName php_version Ssl Dkim Openbase OlsApacheBackend choice + sleep 2 + clear + createChildDomain + fi +} + +function deleteChildDomain() +{ + echo -e $YELLOW + echo "***********************************************************************" + echo "* Website Functions - Delete Child Domain *" + echo "***********************************************************************" + echo -e $TEXTRESET + read -p "Enter Child Domain to delete: " childDomain + echo $childDomain | egrep -e $domain_pattern >> /dev/null && is_child_domain="true" + if [[ $is_child_domain != "true" ]]; then + echo "[!] Not a valid child domain name!" + echo "[*] Reverting...." + sleep 2 + clear + deleteChildDomain + fi + unset is_child_domain + + read -p "Are you sure about this? [Y/N]: " choice + choice=`echo $choice | tr [:lower:] [:upper:]` + if [ "$choice" = "Y" ] ; then + cyberpanel deleteChild --childDomain "$childDomain" + log_message "Deleted child domain: $childDomain" + unset childDomain choice + exit 0 + else + unset childDomain choice + deleteChildDomain + fi +} + +function deleteWebsite() +{ + echo -e $YELLOW + echo "***********************************************************************" + echo "* Website Functions - Delete Website *" + echo "***********************************************************************" + echo -e $TEXTRESET + echo "[*] Do not enter a child domain" + read -p "Enter the website domain to be deleted: " Domain + echo $Domain | egrep -e $domain_pattern >> /dev/null && is_domain="true" + if [[ $is_domain != "true" ]]; then + echo "[!] Not a valid domain name!" + echo "[*] Reverting...." + sleep 2 + clear + deleteWebsite + fi + unset is_domain + + echo -e $YELLOW + echo "***********************************************************************" + echo "* Details You Entered *" + echo "***********************************************************************" + echo "Domain: $Domain" + read -p "Are you sure about this? [Y/N]: " choice + choice=`echo $choice | tr [:lower:] [:upper:]` + if [ $choice = "Y" ] ; then + cyberpanel deleteWebsite --domainName $Domain + log_message "Deleted website: $Domain" + unset Domain choice + echo "Done..." + exit 0 + else + echo -e "[*] Reverting..." + unset Domain choice + deleteWebsite + fi +} + +function listWebsite() +{ + echo -e $YELLOW + echo "***********************************************************************" + echo "* Website Functions - List Website *" + echo "***********************************************************************" + echo -e $TEXTRESET + cyberpanel listWebsitesPretty +} + +function changePHP() +{ + echo -e $YELLOW + echo "***********************************************************************" + echo "* Website Functions - Change PHP Version *" + echo "***********************************************************************" + echo -e $TEXTRESET + echo "[*] Do not enter a child domain" + read -p "Enter the website master domain to be use: " Domain + echo $Domain | egrep -e $domain_pattern >> /dev/null && is_domain="true" + if [[ $is_domain != "true" ]]; then + echo "[!] Not a valid domain name!" + echo "[*] Reverting...." + sleep 2 + clear + changePHP + fi + unset is_domain + + echo -e "Available PHP Versions: 5.3 | 5.4 | 5.5 | 5.6 | 7.0 | 7.1 | 7.2 | 7.3 | 7.4 | 8.0 | 8.1 | 8.2 | 8.3" + read -p "Enter PHP Version: " php_version + echo $php_version | grep -w $php_version_pattern >> /dev/null && is_php="true" + if [[ $is_php != "true" ]]; then + echo "[!] Not a valid PHP Version!" + echo "[*] Reverting...." + sleep 2 + clear + changePHP + fi + unset is_php + echo -e $YELLOW + echo "***********************************************************************" + echo "* Details You Entered *" + echo "***********************************************************************" + echo "Domain: $Domain" + echo "PHP Version: $php_version" + read -p "Are you sure about this? [Y/N]: " choice + choice=`echo $choice | tr [:lower:] [:upper:]` + if [ $choice = "Y" ] ; then + cyberpanel changePHP --domainName $Domain --php $php_version + log_message "Changed PHP version for $Domain to $php_version" + unset Domain php_version choice + echo "Done..." + exit 0 + else + echo -e "[*] Reverting..." + unset Domain php_version choice + changePHP + fi +} diff --git a/emailPremium/templates/emailPremium/Rspamd.html b/emailPremium/templates/emailPremium/Rspamd.html index e04d5366b..1769eb3bc 100644 --- a/emailPremium/templates/emailPremium/Rspamd.html +++ b/emailPremium/templates/emailPremium/Rspamd.html @@ -89,6 +89,15 @@ flex-wrap: wrap; margin-bottom: 25px; } + + /* Anchor styled as button (Open Web UI link) — same visual weight as @@ -649,6 +662,23 @@ {% trans "Uninstall Rspamd" %} +

+ {% trans "Opens the official Rspamd web interface in a new tab (path /emailPremium/Rspamd/ui/ — proxied through CyberPanel, admin session required)." %} +

+ + +
+

{% trans "Alternative: SSH tunnel" %}

+

+ {% trans "If the proxied UI misbehaves, connect to port 11334 on the server via SSH and use your local browser." %} +

+
ssh -N -L 11334:127.0.0.1:11334 root@{{ ipAddress }}
+

+ + + {% trans "Open Rspamd UI (when tunnel is active)" %} + +

diff --git a/emailPremium/urls.py b/emailPremium/urls.py index 1141e4558..03c17d158 100644 --- a/emailPremium/urls.py +++ b/emailPremium/urls.py @@ -33,7 +33,8 @@ urlpatterns = [ path('installMailScanner', views.installMailScanner, name='installMailScanner'), path('installStatusMailScanner', views.installStatusMailScanner, name='installStatusMailScanner'), - # Rspamd + # Rspamd (proxied controller UI — must stay above catch-all domain route) + re_path(r'^Rspamd/ui(?:/(?P.*))?$', views.rspamd_ui_proxy, name='RspamdUI'), path('Rspamd', views.Rspamd, name='Rspamd'), path('installRspamd', views.installRspamd, name='installRspamd'), path('installStatusRspamd', views.installStatusRspamd, name='installStatusRspamd'), diff --git a/firewall/firewallManager.py b/firewall/firewallManager.py index 126a9c35e..566eac042 100644 --- a/firewall/firewallManager.py +++ b/firewall/firewallManager.py @@ -2337,6 +2337,18 @@ class FirewallManager: final_dic = {'status': 0, 'error_message': 'Invalid IP address format', 'error': 'Invalid IP address format'} return HttpResponse(json.dumps(final_dic), content_type='application/json') + try: + from plogical.sshSecurityWhitelistUtilities import SSHSecurityWhitelistUtilities + if SSHSecurityWhitelistUtilities.is_whitelisted(ip): + final_dic = { + 'status': 0, + 'error_message': 'This IP is on the SSH Security trusted list and cannot be banned. Remove it from Trusted IPs first.', + 'error': 'SSH Security whitelist', + } + return HttpResponse(json.dumps(final_dic), content_type='application/json') + except Exception: + pass + current_time = time.time() duration_map = { '1h': 3600, diff --git a/firewall/static/firewall/firewall.js b/firewall/static/firewall/firewall.js index 3e11e504c..3a0445213 100644 --- a/firewall/static/firewall/firewall.js +++ b/firewall/static/firewall/firewall.js @@ -23,6 +23,8 @@ function getCookie(name) { app.controller('firewallController', function ($scope, $http, $timeout) { $scope.rulesLoading = true; + /** Incremented on each rules fetch; stale HTTP responses must not touch rulesLoading. */ + var rulesFetchGen = 0; $scope.actionFailed = true; $scope.actionSuccess = true; $scope.showExportFormatModal = false; @@ -40,9 +42,12 @@ app.controller('firewallController', function ($scope, $http, $timeout) { // Initialize rules array - prevents "Cannot read 'length' of undefined" when template evaluates rules.length before API loads $scope.rules = []; // Banned IPs variables – tab from hash so we stay on /firewall/ (avoids 404 on servers without /firewall/firewall-rules/) + /* Use window.location.hash only. Angular $location can disagree with the fragment on /firewall/#… and wrongly map to "rules". */ function tabFromHash() { - var h = (window.location.hash || '').replace(/^#/, ''); - return (h === 'banned-ips') ? 'banned' : 'rules'; + var h = String(window.location.hash || '').replace(/^#/, '').toLowerCase(); + if (h === 'banned-ips' || h === 'banned') return 'banned'; + if (h === 'trusted-ips' || h === 'ssh-whitelist') return 'trusted'; + return 'rules'; } $scope.activeTab = tabFromHash(); $scope.bannedIPs = []; // Initialize as empty array @@ -52,7 +57,9 @@ app.controller('firewallController', function ($scope, $http, $timeout) { var tab = tabFromHash(); if ($scope.activeTab !== tab) { $scope.activeTab = tab; - if (tab === 'banned') { populateBannedIPs(); } else { populateCurrentRecords(); } + if (tab === 'banned') { populateBannedIPs(); } + else if (tab === 'trusted') { populateTrustedSSHWhitelist(); } + else { populateCurrentRecords(); } if (!$scope.$$phase && !$scope.$root.$$phase) { $scope.$apply(); } } } @@ -63,23 +70,51 @@ app.controller('firewallController', function ($scope, $http, $timeout) { window.addEventListener('load', function() { $timeout(applyTabFromHash, 0); }); } - // Sync tab with hash and load that tab's data on switch - $scope.setFirewallTab = function(tab) { + // Sync tab with hash and load that tab's data on switch (single source of truth from ng-click). + $scope.setFirewallTab = function(tab, $event) { + if ($event) { + try { + $event.stopPropagation(); + } catch (ignoreErr) {} + } $timeout(function() { $scope.activeTab = tab; - window.location.hash = (tab === 'banned') ? '#banned-ips' : '#rules'; - if (tab === 'banned') { populateBannedIPs(); } else { populateCurrentRecords(); } + function setHashIfNeeded(frag) { + try { + if ((window.location.hash || '') === frag) { + return; + } + var path = window.location.pathname + window.location.search + frag; + if (window.history && typeof window.history.replaceState === 'function') { + window.history.replaceState(null, '', path); + } else { + window.location.hash = frag; + } + } catch (ignoreHash) {} + } + if (tab === 'banned') { + setHashIfNeeded('#banned-ips'); + populateBannedIPs(); + } else if (tab === 'trusted') { + setHashIfNeeded('#trusted-ips'); + populateTrustedSSHWhitelist(); + } else { + setHashIfNeeded('#rules'); + populateCurrentRecords(); + } }, 0); }; - // Back/forward or direct hash change: sync tab and load its data function syncTabFromHash() { var tab = tabFromHash(); - if ($scope.activeTab !== tab) { - $scope.activeTab = tab; - if (tab === 'banned') { populateBannedIPs(); } else { populateCurrentRecords(); } - if (!$scope.$$phase && !$scope.$root.$$phase) { $scope.$apply(); } - } + $scope.$evalAsync(function() { + if ($scope.activeTab !== tab) { + $scope.activeTab = tab; + if (tab === 'banned') { populateBannedIPs(); } + else if (tab === 'trusted') { populateTrustedSSHWhitelist(); } + else { populateCurrentRecords(); } + } + }); } window.addEventListener('hashchange', syncTabFromHash); @@ -108,6 +143,9 @@ app.controller('firewallController', function ($scope, $http, $timeout) { $scope.banIP = ''; $scope.banReason = ''; $scope.banDuration = '24h'; + $scope.trustedSSHWhitelist = []; + $scope.trustedForm = { ip: '', label: '' }; + $scope.trustedSSHLoading = false; $scope.bannedIPSearch = ''; $scope.searchBannedIPFilter = function(item) { var q = ($scope.bannedIPSearch || '').toLowerCase().trim(); @@ -142,6 +180,7 @@ app.controller('firewallController', function ($scope, $http, $timeout) { $timeout(function() { try { if (newVal === 'banned' && typeof populateBannedIPs === 'function') populateBannedIPs(); + else if (newVal === 'trusted' && typeof populateTrustedSSHWhitelist === 'function') populateTrustedSSHWhitelist(); else if (newVal === 'rules' && typeof populateCurrentRecords === 'function') populateCurrentRecords(); } catch (e) {} }, 0); @@ -246,6 +285,156 @@ app.controller('firewallController', function ($scope, $http, $timeout) { } ); } + + function populateTrustedSSHWhitelist() { + $scope.trustedSSHLoading = true; + var config = { headers: { 'X-CSRFToken': getCookie('csrftoken') || '' } }; + $http.post('/base/sshSecurityWhitelistList', {}, config).then( + function(response) { + $scope.trustedSSHLoading = false; + var res = response.data; + if (typeof res === 'string') { + try { res = JSON.parse(res); } catch (e) { res = {}; } + } + if (res && res.status === 1) { + var ent = res.entries || []; + $scope.trustedSSHWhitelist = ent.map(function(e) { + return { + ip: e.ip, + label: e.label || '', + updated: e.updated || 0, + _l: e.label || '', + _nip: '' + }; + }); + } else { + $scope.trustedSSHWhitelist = []; + var errMsg = (res && res.error) ? res.error : 'Could not load trusted IPs'; + if (typeof PNotify !== 'undefined') { + new PNotify({ title: 'Trusted IPs', text: errMsg, type: 'error', delay: 6000 }); + } + } + }, + function(error) { + $scope.trustedSSHLoading = false; + $scope.trustedSSHWhitelist = []; + var msg = (error.data && error.data.error) ? error.data.error : 'Request failed'; + if (typeof PNotify !== 'undefined') { + new PNotify({ title: 'Trusted IPs', text: msg, type: 'error', delay: 6000 }); + } + } + ); + } + + $scope.populateTrustedSSHWhitelist = function() { + populateTrustedSSHWhitelist(); + }; + + $scope.addTrustedSSHWhitelist = function() { + var ip = ($scope.trustedForm.ip || '').trim(); + var label = ($scope.trustedForm.label || '').trim(); + if (!ip) { + if (typeof PNotify !== 'undefined') { + new PNotify({ title: 'Trusted IPs', text: 'Enter an IP address', type: 'warning', delay: 5000 }); + } + return; + } + var config = { headers: { 'X-CSRFToken': getCookie('csrftoken') || '' } }; + $http.post('/base/sshSecurityWhitelistAdd', { ip: ip, label: label }, config).then( + function(response) { + var res = response.data; + if (typeof res === 'string') { + try { res = JSON.parse(res); } catch (e) { res = {}; } + } + if (res && res.status === 1) { + $scope.trustedForm.ip = ''; + $scope.trustedForm.label = ''; + populateTrustedSSHWhitelist(); + if (typeof PNotify !== 'undefined') { + new PNotify({ title: 'Trusted IPs', text: 'IP added to trusted list', type: 'success', delay: 4000 }); + } + } else { + var errAdd = (res && res.error) ? res.error : 'Failed to add'; + if (typeof PNotify !== 'undefined') { + new PNotify({ title: 'Trusted IPs', text: errAdd, type: 'error', delay: 6000 }); + } + } + }, + function(err) { + var em = (err.data && err.data.error) ? err.data.error : 'Request failed'; + if (typeof PNotify !== 'undefined') { + new PNotify({ title: 'Trusted IPs', text: em, type: 'error', delay: 6000 }); + } + } + ); + }; + + $scope.removeTrustedSSHWhitelist = function(ip) { + if (!ip) return; + var config = { headers: { 'X-CSRFToken': getCookie('csrftoken') || '' } }; + $http.post('/base/sshSecurityWhitelistRemove', { ip: ip }, config).then( + function(response) { + var res = response.data; + if (typeof res === 'string') { + try { res = JSON.parse(res); } catch (e) { res = {}; } + } + if (res && res.status === 1) { + populateTrustedSSHWhitelist(); + if (typeof PNotify !== 'undefined') { + new PNotify({ title: 'Trusted IPs', text: 'IP removed', type: 'success', delay: 4000 }); + } + } else { + var errRm = (res && res.error) ? res.error : 'Failed to remove'; + if (typeof PNotify !== 'undefined') { + new PNotify({ title: 'Trusted IPs', text: errRm, type: 'error', delay: 6000 }); + } + } + }, + function(err) { + var em2 = (err.data && err.data.error) ? err.data.error : 'Request failed'; + if (typeof PNotify !== 'undefined') { + new PNotify({ title: 'Trusted IPs', text: em2, type: 'error', delay: 6000 }); + } + } + ); + }; + + $scope.saveTrustedSSHWhitelistRow = function(row) { + if (!row || !row.ip) return; + var payload = { ip: row.ip, label: row._l }; + if (row._nip && String(row._nip).trim()) { + payload.new_ip = String(row._nip).trim(); + } + var config = { headers: { 'X-CSRFToken': getCookie('csrftoken') || '' } }; + $http.post('/base/sshSecurityWhitelistUpdate', payload, config).then( + function(response) { + var res = response.data; + if (typeof res === 'string') { + try { res = JSON.parse(res); } catch (e) { res = {}; } + } + var ok = res && (res.status === 1 || res.status === '1'); + if (ok) { + populateTrustedSSHWhitelist(); + if (typeof PNotify !== 'undefined') { + var unchanged = res.unchanged === true || res.unchanged === 'true' || res.unchanged === 1; + var msgOk = (res.message && String(res.message).length) ? res.message : (unchanged ? 'No changes to save.' : 'Entry updated'); + new PNotify({ title: 'Trusted IPs', text: msgOk, type: unchanged ? 'info' : 'success', delay: 4000 }); + } + } else { + var errUp = (res && res.error) ? res.error : 'Failed to update'; + if (typeof PNotify !== 'undefined') { + new PNotify({ title: 'Trusted IPs', text: errUp, type: 'error', delay: 6000 }); + } + } + }, + function(err) { + var em3 = (err.data && err.data.error) ? err.data.error : 'Request failed'; + if (typeof PNotify !== 'undefined') { + new PNotify({ title: 'Trusted IPs', text: em3, type: 'error', delay: 6000 }); + } + } + ); + }; // Expose to scope for template access $scope.populateBannedIPs = function() { @@ -301,7 +490,9 @@ app.controller('firewallController', function ($scope, $http, $timeout) { window.__firewallLoadTab = function(tab) { $scope.$evalAsync(function() { $scope.activeTab = tab; - if (tab === 'banned') { populateBannedIPs(); } else { populateCurrentRecords(); } + if (tab === 'banned') { populateBannedIPs(); } + else if (tab === 'trusted') { populateTrustedSSHWhitelist(); } + else { populateCurrentRecords(); } }); }; } @@ -365,7 +556,6 @@ app.controller('firewallController', function ($scope, $http, $timeout) { populateCurrentRecords(); - $scope.rulesLoading = true; $scope.actionFailed = true; $scope.actionSuccess = true; @@ -377,7 +567,7 @@ app.controller('firewallController', function ($scope, $http, $timeout) { } else { - $scope.rulesLoading = true; + $scope.rulesLoading = false; $scope.actionFailed = true; $scope.actionSuccess = true; @@ -393,7 +583,7 @@ app.controller('firewallController', function ($scope, $http, $timeout) { function cantLoadInitialDatas(response) { - $scope.rulesLoading = true; + $scope.rulesLoading = false; $scope.actionFailed = true; $scope.actionSuccess = true; @@ -407,8 +597,8 @@ app.controller('firewallController', function ($scope, $http, $timeout) { }; function populateCurrentRecords() { - - $scope.rulesLoading = false; + var gen = ++rulesFetchGen; + $scope.rulesLoading = true; $scope.actionFailed = true; $scope.actionSuccess = true; @@ -426,21 +616,44 @@ app.controller('firewallController', function ($scope, $http, $timeout) { $http.post(url, data, config).then(ListInitialDatas, cantLoadInitialDatas); function ListInitialDatas(response) { - var res = (typeof response.data === 'string') ? (function() { try { return JSON.parse(response.data); } catch (e) { return {}; } })() : response.data; - if (res && res.fetchStatus === 1) { - $scope.rules = typeof res.data === 'string' ? JSON.parse(res.data) : (res.data || []); - $scope.rulesTotalCount = res.total_count != null ? res.total_count : ($scope.rules ? $scope.rules.length : 0); - $scope.rulesPage = Math.max(1, res.page != null ? res.page : 1); - $scope.rulesPageSize = res.page_size != null ? res.page_size : 10; - $scope.rulesLoading = true; + if (gen !== rulesFetchGen) { + return; } - else { - $scope.rulesLoading = true; - $scope.errorMessage = (res && res.error_message) ? res.error_message : ''; + try { + var res = (typeof response.data === 'string') ? (function() { try { return JSON.parse(response.data); } catch (e) { return {}; } })() : response.data; + if (res && res.fetchStatus == 1) { + var parsedRules = []; + if (typeof res.data === 'string') { + try { + parsedRules = JSON.parse(res.data); + } catch (parseErr) { + parsedRules = []; + $scope.errorMessage = (res && res.error_message) ? res.error_message : 'Invalid rules data'; + } + } else { + parsedRules = res.data || []; + } + $scope.rules = parsedRules; + $scope.rulesTotalCount = res.total_count != null ? res.total_count : ($scope.rules ? $scope.rules.length : 0); + $scope.rulesPage = Math.max(1, res.page != null ? res.page : 1); + $scope.rulesPageSize = res.page_size != null ? res.page_size : 10; + } else { + $scope.errorMessage = (res && res.error_message) ? res.error_message : ''; + } + } catch (e) { + $scope.errorMessage = 'Could not load firewall rules.'; + } finally { + if (gen === rulesFetchGen) { + $scope.rulesLoading = false; + } } } function cantLoadInitialDatas(response) { + if (gen !== rulesFetchGen) { + return; + } + $scope.rulesLoading = false; $scope.couldNotConnect = false; } } @@ -519,7 +732,7 @@ app.controller('firewallController', function ($scope, $http, $timeout) { $scope.errorMessage = 'Port is required'; return; } - $scope.rulesLoading = false; + $scope.rulesLoading = true; var url = '/firewall/modifyRule'; var data = { id: d.id, @@ -530,19 +743,19 @@ app.controller('firewallController', function ($scope, $http, $timeout) { }; var config = { headers: { 'X-CSRFToken': getCookie('csrftoken') } }; $http.post(url, data, config).then(function(response) { - $scope.rulesLoading = true; if (response.data && response.data.status === 1) { $scope.closeModifyRuleModal(); $scope.actionFailed = true; $scope.actionSuccess = false; populateCurrentRecords(); } else { + $scope.rulesLoading = false; $scope.actionFailed = false; $scope.actionSuccess = true; $scope.errorMessage = (response.data && response.data.error_message) || 'Modify failed'; } }, function() { - $scope.rulesLoading = true; + $scope.rulesLoading = false; $scope.actionFailed = false; $scope.actionSuccess = true; $scope.errorMessage = 'Could not connect to server. Please refresh this page.'; @@ -551,7 +764,7 @@ app.controller('firewallController', function ($scope, $http, $timeout) { $scope.deleteRule = function (id, proto, port, ruleIP) { - $scope.rulesLoading = false; + $scope.rulesLoading = true; url = "/firewall/deleteRule"; @@ -579,7 +792,6 @@ app.controller('firewallController', function ($scope, $http, $timeout) { populateCurrentRecords(); - $scope.rulesLoading = true; $scope.actionFailed = true; $scope.actionSuccess = true; @@ -591,7 +803,7 @@ app.controller('firewallController', function ($scope, $http, $timeout) { } else { - $scope.rulesLoading = true; + $scope.rulesLoading = false; $scope.actionFailed = true; $scope.actionSuccess = true; @@ -599,7 +811,6 @@ app.controller('firewallController', function ($scope, $http, $timeout) { $scope.ruleAdded = true; $scope.couldNotConnect = true; - $scope.rulesLoading = true; $scope.errorMessage = response.data.error_message; @@ -609,7 +820,7 @@ app.controller('firewallController', function ($scope, $http, $timeout) { function cantLoadInitialDatas(response) { - $scope.rulesLoading = true; + $scope.rulesLoading = false; $scope.actionFailed = true; $scope.actionSuccess = true; @@ -656,7 +867,7 @@ app.controller('firewallController', function ($scope, $http, $timeout) { if (response.data.reload_status == 1) { - $scope.rulesLoading = true; + $scope.rulesLoading = false; $scope.actionFailed = true; $scope.actionSuccess = false; @@ -668,7 +879,7 @@ app.controller('firewallController', function ($scope, $http, $timeout) { } else { - $scope.rulesLoading = true; + $scope.rulesLoading = false; $scope.actionFailed = false; $scope.actionSuccess = true; @@ -685,7 +896,7 @@ app.controller('firewallController', function ($scope, $http, $timeout) { function cantLoadInitialDatas(response) { - $scope.rulesLoading = true; + $scope.rulesLoading = false; $scope.actionFailed = true; $scope.actionSuccess = true; @@ -731,7 +942,7 @@ app.controller('firewallController', function ($scope, $http, $timeout) { if (response.data.start_status == 1) { - $scope.rulesLoading = true; + $scope.rulesLoading = false; $scope.actionFailed = true; $scope.actionSuccess = false; @@ -747,7 +958,7 @@ app.controller('firewallController', function ($scope, $http, $timeout) { } else { - $scope.rulesLoading = true; + $scope.rulesLoading = false; $scope.actionFailed = false; $scope.actionSuccess = true; @@ -764,7 +975,7 @@ app.controller('firewallController', function ($scope, $http, $timeout) { function cantLoadInitialDatas(response) { - $scope.rulesLoading = true; + $scope.rulesLoading = false; $scope.actionFailed = true; $scope.actionSuccess = true; @@ -811,7 +1022,7 @@ app.controller('firewallController', function ($scope, $http, $timeout) { if (response.data.stop_status == 1) { - $scope.rulesLoading = true; + $scope.rulesLoading = false; $scope.actionFailed = true; $scope.actionSuccess = false; @@ -827,7 +1038,7 @@ app.controller('firewallController', function ($scope, $http, $timeout) { } else { - $scope.rulesLoading = true; + $scope.rulesLoading = false; $scope.actionFailed = false; $scope.actionSuccess = true; @@ -844,7 +1055,7 @@ app.controller('firewallController', function ($scope, $http, $timeout) { function cantLoadInitialDatas(response) { - $scope.rulesLoading = true; + $scope.rulesLoading = false; $scope.actionFailed = true; $scope.actionSuccess = true; @@ -1309,6 +1520,8 @@ app.controller('firewallController', function ($scope, $http, $timeout) { } } + $scope.populateCurrentRecords = populateCurrentRecords; + }); @@ -3254,18 +3467,20 @@ app.controller('litespeed_ent_conf', function ($scope, $http, $timeout, $window) function syncFirewallTabFromHash() { var nav = document.getElementById('firewall-tab-nav'); if (!nav) return; - var h = (window.location.hash || '').replace(/^#/, ''); - var tab = (h === 'banned-ips') ? 'banned' : 'rules'; + var h = (window.location.hash || '').replace(/^#/, '').toLowerCase(); + var tab = 'rules'; + if (h === 'banned-ips' || h === 'banned') tab = 'banned'; + else if (h === 'trusted-ips' || h === 'ssh-whitelist') tab = 'trusted'; if (window.__firewallLoadTab) { try { window.__firewallLoadTab(tab); } catch (e) {} } } + /* Initial sync only — hashchange is handled by Angular syncTabFromHash in firewallController + (multiple listeners were racing and could reset #trusted-ips to #rules). */ if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', syncFirewallTabFromHash); } else { syncFirewallTabFromHash(); } - setTimeout(syncFirewallTabFromHash, 100); - window.addEventListener('hashchange', syncFirewallTabFromHash); })(); \ No newline at end of file diff --git a/firewall/templates/firewall/firewall.html b/firewall/templates/firewall/firewall.html index e1983fa70..334062ef5 100644 --- a/firewall/templates/firewall/firewall.html +++ b/firewall/templates/firewall/firewall.html @@ -1257,15 +1257,19 @@ -
- - +
@@ -1768,13 +1772,75 @@ + + +
+
+
+
+ +
+ {% trans "SSH trusted IPs (never block)" %} +
+
+
+
+ {% trans "These addresses are skipped in SSH Security Analysis and cannot be added under Banned IPs. Use your home or office public IP to avoid accidental lockouts." %} +
+
+
+
+ + +
+
+ + +
+ +
+
+
+ + + + + + + + + + + + + + + + + +
{% trans "IP" %}{% trans "Label" %}{% trans "Replace IP (optional)" %}{% trans "Actions" %}
{$ row.ip $} + + +
+
+ +

{% trans "No trusted IPs yet" %}

+

{% trans "Add at least the public IP you use to manage this server." %}

+
+
+
diff --git a/loginSystem/views.py b/loginSystem/views.py index 297fbdf7e..089d9c96b 100644 --- a/loginSystem/views.py +++ b/loginSystem/views.py @@ -145,6 +145,11 @@ def verifyLogin(request): request.session['ipAddr'] = ipAddr request.session.set_expiry(43200) + try: + from plogical.sshSecurityWhitelistUtilities import SSHSecurityWhitelistUtilities + SSHSecurityWhitelistUtilities.on_successful_panel_login(request, admin) + except Exception: + pass data = {'userID': admin.pk, 'loginStatus': 1, 'error_message': "None"} json_data = json.dumps(data) response.write(json_data) diff --git a/plogical/sshSecurityWhitelistUtilities.py b/plogical/sshSecurityWhitelistUtilities.py new file mode 100644 index 000000000..5e46bda89 --- /dev/null +++ b/plogical/sshSecurityWhitelistUtilities.py @@ -0,0 +1,377 @@ +#!/usr/local/CyberCP/bin/python +# -*- coding: utf-8 -*- +""" +SSH Security Analysis — IPs that must never be blocked (firewalld drop / Banned IPs). + +Stored in /usr/local/CyberCP/data/ssh_security_whitelist.json as a JSON array of objects: + [{"ip": "203.0.113.10", "label": "Office", "updated": 1710000000}, ...] +""" +from __future__ import annotations + +import ipaddress +import json +import os +import time +from typing import Any, Dict, List, Optional, Set + +WHITELIST_PATH = '/usr/local/CyberCP/data/ssh_security_whitelist.json' +PUBLIC_IP_CACHE_PATH = '/usr/local/CyberCP/data/ssh_whitelist_public_ipv4.cache.json' +FIRST_ADMIN_LOGIN_FLAG_PATH = '/usr/local/CyberCP/data/ssh_whitelist_first_admin_login.recorded' +LABEL_SERVER_AUTO = 'CyberPanel server public IPv4 (auto)' +LABEL_FIRST_ADMIN_AUTO = 'First CyberPanel admin login (auto)' + + +class SSHSecurityWhitelistUtilities: + """Load/save trusted IPs for SSH security analysis and firewall ban protection.""" + + @staticmethod + def _ensure_data_dir() -> None: + d = os.path.dirname(WHITELIST_PATH) + if d: + try: + os.makedirs(d, mode=0o750, exist_ok=True) + except OSError: + pass + + @staticmethod + def normalize_ip(ip: str) -> str: + raw = (ip or '').strip() + if not raw: + return '' + host = raw.split('/')[0].strip() + if '%' in host: + host = host.split('%', 1)[0] + if host.startswith('[') and host.endswith(']'): + host = host[1:-1] + try: + return str(ipaddress.ip_address(host)) + except ValueError: + return '' + + @staticmethod + def normalized_ip_in_whitelist(raw_ip: str, wl_set: Set[str]) -> bool: + """True if raw IP from a log line normalizes to an address in wl_set.""" + if not raw_ip or not wl_set: + return False + norm = SSHSecurityWhitelistUtilities.normalize_ip(raw_ip) + return bool(norm and norm in wl_set) + + @staticmethod + def validate_ip(ip: str) -> Optional[str]: + norm = SSHSecurityWhitelistUtilities.normalize_ip(ip) + if not norm: + return None + try: + obj = ipaddress.ip_address(norm) + except ValueError: + return None + if ( + obj.is_private + or obj.is_loopback + or obj.is_link_local + or obj.is_multicast + or obj.is_reserved + ): + return None + return norm + + @staticmethod + def load_entries() -> List[Dict[str, Any]]: + SSHSecurityWhitelistUtilities._ensure_data_dir() + if not os.path.isfile(WHITELIST_PATH): + return [] + try: + with open(WHITELIST_PATH, 'r', encoding='utf-8', errors='replace') as f: + data = json.load(f) + if not isinstance(data, list): + return [] + out: List[Dict[str, Any]] = [] + for item in data: + if not isinstance(item, dict): + continue + ip = item.get('ip') or item.get('address') + norm = SSHSecurityWhitelistUtilities.normalize_ip(str(ip) if ip else '') + if not norm: + continue + label = (item.get('label') or item.get('note') or '').strip() + updated = item.get('updated') or item.get('modified') or 0 + try: + updated = int(updated) + except (TypeError, ValueError): + updated = 0 + out.append({'ip': norm, 'label': label, 'updated': updated}) + return out + except (OSError, json.JSONDecodeError): + return [] + + @staticmethod + def save_entries(entries: List[Dict[str, Any]]) -> bool: + SSHSecurityWhitelistUtilities._ensure_data_dir() + try: + serializable = [] + for e in entries: + serializable.append({ + 'ip': e['ip'], + 'label': e.get('label') or '', + 'updated': int(e.get('updated') or 0), + }) + tmp = WHITELIST_PATH + '.tmp' + with open(tmp, 'w', encoding='utf-8') as f: + json.dump(serializable, f, indent=2, ensure_ascii=False) + f.write('\n') + os.replace(tmp, WHITELIST_PATH) + try: + os.chmod(WHITELIST_PATH, 0o640) + except OSError: + pass + try: + import pwd + import grp + uid = pwd.getpwnam('cyberpanel').pw_uid + gid = grp.getgrnam('cyberpanel').gr_gid + os.chown(WHITELIST_PATH, uid, gid) + except (OSError, KeyError, ImportError): + pass + return True + except OSError: + return False + + @staticmethod + def ip_set() -> Set[str]: + return {e['ip'] for e in SSHSecurityWhitelistUtilities.load_entries()} + + @staticmethod + def is_whitelisted(ip: str) -> bool: + norm = SSHSecurityWhitelistUtilities.normalize_ip(ip) + if not norm: + return False + return norm in SSHSecurityWhitelistUtilities.ip_set() + + @staticmethod + def add_entry(ip: str, label: str = '') -> tuple: + v = SSHSecurityWhitelistUtilities.validate_ip(ip) + if not v: + return False, 'Invalid or non-public IP address' + entries = SSHSecurityWhitelistUtilities.load_entries() + label = (label or '').strip()[:200] + now = int(time.time()) + for e in entries: + if e['ip'] == v: + e['label'] = label + e['updated'] = now + if not SSHSecurityWhitelistUtilities.save_entries(entries): + return False, 'Failed to save whitelist' + return True, v + entries.append({'ip': v, 'label': label, 'updated': now}) + if not SSHSecurityWhitelistUtilities.save_entries(entries): + return False, 'Failed to save whitelist' + return True, v + + @staticmethod + def remove_entry(ip: str) -> tuple: + norm = SSHSecurityWhitelistUtilities.normalize_ip(ip) + if not norm: + return False, 'Invalid IP address' + entries = SSHSecurityWhitelistUtilities.load_entries() + new_list = [e for e in entries if e['ip'] != norm] + if len(new_list) == len(entries): + return False, 'IP not found in whitelist' + if not SSHSecurityWhitelistUtilities.save_entries(new_list): + return False, 'Failed to save whitelist' + return True, norm + + @staticmethod + def update_entry(ip: str, new_ip: Optional[str] = None, label: Optional[str] = None) -> tuple: + """ + Update whitelist row. Returns (ok, ip_or_error_message, unchanged). + unchanged is True when nothing differed from stored values (still success). + """ + norm = SSHSecurityWhitelistUtilities.normalize_ip(ip) + if not norm: + return False, 'Invalid IP address', False + entries = SSHSecurityWhitelistUtilities.load_entries() + idx = next((i for i, e in enumerate(entries) if e['ip'] == norm), -1) + if idx < 0: + return False, 'IP not found in whitelist', False + now = int(time.time()) + target_ip = norm + changed = False + current_label = str(entries[idx].get('label') or '').strip()[:200] + + if new_ip is not None and str(new_ip).strip(): + v = SSHSecurityWhitelistUtilities.validate_ip(str(new_ip).strip()) + if not v: + return False, 'Invalid or non-public new IP address', False + if any(e['ip'] == v for e in entries if e['ip'] != norm): + return False, 'New IP already listed', False + if v != norm: + entries[idx]['ip'] = v + target_ip = v + changed = True + + if label is not None: + new_l = str(label).strip()[:200] + if new_l != current_label: + entries[idx]['label'] = new_l + changed = True + + if not changed: + return True, norm, True + + entries[idx]['updated'] = now + if not SSHSecurityWhitelistUtilities.save_entries(entries): + return False, 'Failed to save whitelist', False + return True, target_ip, False + + @staticmethod + def client_ip_from_request(request: Any) -> str: + """Best-effort client IP for whitelisting (CF header or REMOTE_ADDR).""" + if request is None: + return '' + try: + meta = getattr(request, 'META', None) or {} + raw = meta.get('HTTP_CF_CONNECTING_IP') or meta.get('REMOTE_ADDR') or '' + raw = str(raw).split(',')[0].strip() + if '%' in raw: + raw = raw.split('%')[0] + return raw + except Exception: + return '' + + @staticmethod + def upsert_whitelist_ip_if_absent(ip: str, label: str) -> bool: + """ + Add public IP to whitelist only if not already listed (does not overwrite labels). + """ + v = SSHSecurityWhitelistUtilities.validate_ip(ip) + if not v: + return False + entries = SSHSecurityWhitelistUtilities.load_entries() + label = (label or '').strip()[:200] + now = int(time.time()) + for e in entries: + if e['ip'] == v: + return True + entries.append({'ip': v, 'label': label, 'updated': now}) + return SSHSecurityWhitelistUtilities.save_entries(entries) + + @staticmethod + def _fetch_ipv4_public_ip() -> str: + import re + import urllib.request + + urls = ( + 'https://ipv4.icanhazip.com', + 'https://api.ipify.org', + 'https://checkip.amazonaws.com', + ) + for url in urls: + try: + with urllib.request.urlopen(url, timeout=8) as resp: + ip = resp.read().decode('utf-8', errors='replace').strip() + if re.match(r'^(\d{1,3}\.){3}\d{1,3}$', ip): + return ip + except Exception: + continue + return '' + + @staticmethod + def ensure_cyberpanel_public_ip_whitelisted(max_cache_age: int = 3600) -> None: + """ + Ensure this machine's outbound/public IPv4 is on the whitelist (SSH + ban protection). + Uses a short-lived cache to avoid HTTP self-lookups on every request. + """ + SSHSecurityWhitelistUtilities._ensure_data_dir() + now = int(time.time()) + cached_ip = '' + cache_ts = 0 + try: + if os.path.isfile(PUBLIC_IP_CACHE_PATH): + with open(PUBLIC_IP_CACHE_PATH, 'r', encoding='utf-8', errors='replace') as fc: + cj = json.load(fc) + if isinstance(cj, dict): + cached_ip = str(cj.get('ip') or '').strip() + try: + cache_ts = int(cj.get('ts') or 0) + except (TypeError, ValueError): + cache_ts = 0 + except (OSError, json.JSONDecodeError): + pass + + ip_to_try = '' + if cached_ip and (now - cache_ts) < max_cache_age: + ip_to_try = cached_ip + else: + detected = SSHSecurityWhitelistUtilities._fetch_ipv4_public_ip() + if detected: + ip_to_try = detected + try: + tmp = PUBLIC_IP_CACHE_PATH + '.tmp' + with open(tmp, 'w', encoding='utf-8') as ft: + json.dump({'ip': detected, 'ts': now}, ft, indent=2) + ft.write('\n') + os.replace(tmp, PUBLIC_IP_CACHE_PATH) + try: + os.chmod(PUBLIC_IP_CACHE_PATH, 0o640) + except OSError: + pass + except OSError: + pass + elif cached_ip: + ip_to_try = cached_ip + + if not ip_to_try: + return + SSHSecurityWhitelistUtilities.upsert_whitelist_ip_if_absent(ip_to_try, LABEL_SERVER_AUTO) + + @staticmethod + def maybe_whitelist_first_admin_login(client_ip: str) -> None: + """ + Once per installation: whitelist the client IP of the first successful login + by a user with admin ACL (adminStatus == 1). + """ + if os.path.isfile(FIRST_ADMIN_LOGIN_FLAG_PATH): + return + v = SSHSecurityWhitelistUtilities.validate_ip(client_ip) + if not v: + return + SSHSecurityWhitelistUtilities.upsert_whitelist_ip_if_absent(v, LABEL_FIRST_ADMIN_AUTO) + try: + tmp = FIRST_ADMIN_LOGIN_FLAG_PATH + '.tmp' + with open(tmp, 'w', encoding='utf-8') as tf: + tf.write(v + '\n') + os.replace(tmp, FIRST_ADMIN_LOGIN_FLAG_PATH) + try: + os.chmod(FIRST_ADMIN_LOGIN_FLAG_PATH, 0o640) + except OSError: + pass + try: + import grp + import pwd + uid = pwd.getpwnam('cyberpanel').pw_uid + gid = grp.getgrnam('cyberpanel').gr_gid + os.chown(FIRST_ADMIN_LOGIN_FLAG_PATH, uid, gid) + except (OSError, KeyError, ImportError): + pass + except OSError: + pass + + @staticmethod + def on_successful_panel_login(request: Any, admin: Any) -> None: + """ + Called after a successful CyberPanel login. Safe to call on every login; errors swallowed. + """ + try: + SSHSecurityWhitelistUtilities.ensure_cyberpanel_public_ip_whitelisted() + except Exception: + pass + try: + acl = getattr(admin, 'acl', None) + if acl is None: + return + if int(getattr(acl, 'adminStatus', 0) or 0) != 1: + return + ip = SSHSecurityWhitelistUtilities.client_ip_from_request(request) + SSHSecurityWhitelistUtilities.maybe_whitelist_first_admin_login(ip) + except Exception: + pass diff --git a/public/static/firewall/firewall.js b/public/static/firewall/firewall.js index 3e11e504c..3a0445213 100644 --- a/public/static/firewall/firewall.js +++ b/public/static/firewall/firewall.js @@ -23,6 +23,8 @@ function getCookie(name) { app.controller('firewallController', function ($scope, $http, $timeout) { $scope.rulesLoading = true; + /** Incremented on each rules fetch; stale HTTP responses must not touch rulesLoading. */ + var rulesFetchGen = 0; $scope.actionFailed = true; $scope.actionSuccess = true; $scope.showExportFormatModal = false; @@ -40,9 +42,12 @@ app.controller('firewallController', function ($scope, $http, $timeout) { // Initialize rules array - prevents "Cannot read 'length' of undefined" when template evaluates rules.length before API loads $scope.rules = []; // Banned IPs variables – tab from hash so we stay on /firewall/ (avoids 404 on servers without /firewall/firewall-rules/) + /* Use window.location.hash only. Angular $location can disagree with the fragment on /firewall/#… and wrongly map to "rules". */ function tabFromHash() { - var h = (window.location.hash || '').replace(/^#/, ''); - return (h === 'banned-ips') ? 'banned' : 'rules'; + var h = String(window.location.hash || '').replace(/^#/, '').toLowerCase(); + if (h === 'banned-ips' || h === 'banned') return 'banned'; + if (h === 'trusted-ips' || h === 'ssh-whitelist') return 'trusted'; + return 'rules'; } $scope.activeTab = tabFromHash(); $scope.bannedIPs = []; // Initialize as empty array @@ -52,7 +57,9 @@ app.controller('firewallController', function ($scope, $http, $timeout) { var tab = tabFromHash(); if ($scope.activeTab !== tab) { $scope.activeTab = tab; - if (tab === 'banned') { populateBannedIPs(); } else { populateCurrentRecords(); } + if (tab === 'banned') { populateBannedIPs(); } + else if (tab === 'trusted') { populateTrustedSSHWhitelist(); } + else { populateCurrentRecords(); } if (!$scope.$$phase && !$scope.$root.$$phase) { $scope.$apply(); } } } @@ -63,23 +70,51 @@ app.controller('firewallController', function ($scope, $http, $timeout) { window.addEventListener('load', function() { $timeout(applyTabFromHash, 0); }); } - // Sync tab with hash and load that tab's data on switch - $scope.setFirewallTab = function(tab) { + // Sync tab with hash and load that tab's data on switch (single source of truth from ng-click). + $scope.setFirewallTab = function(tab, $event) { + if ($event) { + try { + $event.stopPropagation(); + } catch (ignoreErr) {} + } $timeout(function() { $scope.activeTab = tab; - window.location.hash = (tab === 'banned') ? '#banned-ips' : '#rules'; - if (tab === 'banned') { populateBannedIPs(); } else { populateCurrentRecords(); } + function setHashIfNeeded(frag) { + try { + if ((window.location.hash || '') === frag) { + return; + } + var path = window.location.pathname + window.location.search + frag; + if (window.history && typeof window.history.replaceState === 'function') { + window.history.replaceState(null, '', path); + } else { + window.location.hash = frag; + } + } catch (ignoreHash) {} + } + if (tab === 'banned') { + setHashIfNeeded('#banned-ips'); + populateBannedIPs(); + } else if (tab === 'trusted') { + setHashIfNeeded('#trusted-ips'); + populateTrustedSSHWhitelist(); + } else { + setHashIfNeeded('#rules'); + populateCurrentRecords(); + } }, 0); }; - // Back/forward or direct hash change: sync tab and load its data function syncTabFromHash() { var tab = tabFromHash(); - if ($scope.activeTab !== tab) { - $scope.activeTab = tab; - if (tab === 'banned') { populateBannedIPs(); } else { populateCurrentRecords(); } - if (!$scope.$$phase && !$scope.$root.$$phase) { $scope.$apply(); } - } + $scope.$evalAsync(function() { + if ($scope.activeTab !== tab) { + $scope.activeTab = tab; + if (tab === 'banned') { populateBannedIPs(); } + else if (tab === 'trusted') { populateTrustedSSHWhitelist(); } + else { populateCurrentRecords(); } + } + }); } window.addEventListener('hashchange', syncTabFromHash); @@ -108,6 +143,9 @@ app.controller('firewallController', function ($scope, $http, $timeout) { $scope.banIP = ''; $scope.banReason = ''; $scope.banDuration = '24h'; + $scope.trustedSSHWhitelist = []; + $scope.trustedForm = { ip: '', label: '' }; + $scope.trustedSSHLoading = false; $scope.bannedIPSearch = ''; $scope.searchBannedIPFilter = function(item) { var q = ($scope.bannedIPSearch || '').toLowerCase().trim(); @@ -142,6 +180,7 @@ app.controller('firewallController', function ($scope, $http, $timeout) { $timeout(function() { try { if (newVal === 'banned' && typeof populateBannedIPs === 'function') populateBannedIPs(); + else if (newVal === 'trusted' && typeof populateTrustedSSHWhitelist === 'function') populateTrustedSSHWhitelist(); else if (newVal === 'rules' && typeof populateCurrentRecords === 'function') populateCurrentRecords(); } catch (e) {} }, 0); @@ -246,6 +285,156 @@ app.controller('firewallController', function ($scope, $http, $timeout) { } ); } + + function populateTrustedSSHWhitelist() { + $scope.trustedSSHLoading = true; + var config = { headers: { 'X-CSRFToken': getCookie('csrftoken') || '' } }; + $http.post('/base/sshSecurityWhitelistList', {}, config).then( + function(response) { + $scope.trustedSSHLoading = false; + var res = response.data; + if (typeof res === 'string') { + try { res = JSON.parse(res); } catch (e) { res = {}; } + } + if (res && res.status === 1) { + var ent = res.entries || []; + $scope.trustedSSHWhitelist = ent.map(function(e) { + return { + ip: e.ip, + label: e.label || '', + updated: e.updated || 0, + _l: e.label || '', + _nip: '' + }; + }); + } else { + $scope.trustedSSHWhitelist = []; + var errMsg = (res && res.error) ? res.error : 'Could not load trusted IPs'; + if (typeof PNotify !== 'undefined') { + new PNotify({ title: 'Trusted IPs', text: errMsg, type: 'error', delay: 6000 }); + } + } + }, + function(error) { + $scope.trustedSSHLoading = false; + $scope.trustedSSHWhitelist = []; + var msg = (error.data && error.data.error) ? error.data.error : 'Request failed'; + if (typeof PNotify !== 'undefined') { + new PNotify({ title: 'Trusted IPs', text: msg, type: 'error', delay: 6000 }); + } + } + ); + } + + $scope.populateTrustedSSHWhitelist = function() { + populateTrustedSSHWhitelist(); + }; + + $scope.addTrustedSSHWhitelist = function() { + var ip = ($scope.trustedForm.ip || '').trim(); + var label = ($scope.trustedForm.label || '').trim(); + if (!ip) { + if (typeof PNotify !== 'undefined') { + new PNotify({ title: 'Trusted IPs', text: 'Enter an IP address', type: 'warning', delay: 5000 }); + } + return; + } + var config = { headers: { 'X-CSRFToken': getCookie('csrftoken') || '' } }; + $http.post('/base/sshSecurityWhitelistAdd', { ip: ip, label: label }, config).then( + function(response) { + var res = response.data; + if (typeof res === 'string') { + try { res = JSON.parse(res); } catch (e) { res = {}; } + } + if (res && res.status === 1) { + $scope.trustedForm.ip = ''; + $scope.trustedForm.label = ''; + populateTrustedSSHWhitelist(); + if (typeof PNotify !== 'undefined') { + new PNotify({ title: 'Trusted IPs', text: 'IP added to trusted list', type: 'success', delay: 4000 }); + } + } else { + var errAdd = (res && res.error) ? res.error : 'Failed to add'; + if (typeof PNotify !== 'undefined') { + new PNotify({ title: 'Trusted IPs', text: errAdd, type: 'error', delay: 6000 }); + } + } + }, + function(err) { + var em = (err.data && err.data.error) ? err.data.error : 'Request failed'; + if (typeof PNotify !== 'undefined') { + new PNotify({ title: 'Trusted IPs', text: em, type: 'error', delay: 6000 }); + } + } + ); + }; + + $scope.removeTrustedSSHWhitelist = function(ip) { + if (!ip) return; + var config = { headers: { 'X-CSRFToken': getCookie('csrftoken') || '' } }; + $http.post('/base/sshSecurityWhitelistRemove', { ip: ip }, config).then( + function(response) { + var res = response.data; + if (typeof res === 'string') { + try { res = JSON.parse(res); } catch (e) { res = {}; } + } + if (res && res.status === 1) { + populateTrustedSSHWhitelist(); + if (typeof PNotify !== 'undefined') { + new PNotify({ title: 'Trusted IPs', text: 'IP removed', type: 'success', delay: 4000 }); + } + } else { + var errRm = (res && res.error) ? res.error : 'Failed to remove'; + if (typeof PNotify !== 'undefined') { + new PNotify({ title: 'Trusted IPs', text: errRm, type: 'error', delay: 6000 }); + } + } + }, + function(err) { + var em2 = (err.data && err.data.error) ? err.data.error : 'Request failed'; + if (typeof PNotify !== 'undefined') { + new PNotify({ title: 'Trusted IPs', text: em2, type: 'error', delay: 6000 }); + } + } + ); + }; + + $scope.saveTrustedSSHWhitelistRow = function(row) { + if (!row || !row.ip) return; + var payload = { ip: row.ip, label: row._l }; + if (row._nip && String(row._nip).trim()) { + payload.new_ip = String(row._nip).trim(); + } + var config = { headers: { 'X-CSRFToken': getCookie('csrftoken') || '' } }; + $http.post('/base/sshSecurityWhitelistUpdate', payload, config).then( + function(response) { + var res = response.data; + if (typeof res === 'string') { + try { res = JSON.parse(res); } catch (e) { res = {}; } + } + var ok = res && (res.status === 1 || res.status === '1'); + if (ok) { + populateTrustedSSHWhitelist(); + if (typeof PNotify !== 'undefined') { + var unchanged = res.unchanged === true || res.unchanged === 'true' || res.unchanged === 1; + var msgOk = (res.message && String(res.message).length) ? res.message : (unchanged ? 'No changes to save.' : 'Entry updated'); + new PNotify({ title: 'Trusted IPs', text: msgOk, type: unchanged ? 'info' : 'success', delay: 4000 }); + } + } else { + var errUp = (res && res.error) ? res.error : 'Failed to update'; + if (typeof PNotify !== 'undefined') { + new PNotify({ title: 'Trusted IPs', text: errUp, type: 'error', delay: 6000 }); + } + } + }, + function(err) { + var em3 = (err.data && err.data.error) ? err.data.error : 'Request failed'; + if (typeof PNotify !== 'undefined') { + new PNotify({ title: 'Trusted IPs', text: em3, type: 'error', delay: 6000 }); + } + } + ); + }; // Expose to scope for template access $scope.populateBannedIPs = function() { @@ -301,7 +490,9 @@ app.controller('firewallController', function ($scope, $http, $timeout) { window.__firewallLoadTab = function(tab) { $scope.$evalAsync(function() { $scope.activeTab = tab; - if (tab === 'banned') { populateBannedIPs(); } else { populateCurrentRecords(); } + if (tab === 'banned') { populateBannedIPs(); } + else if (tab === 'trusted') { populateTrustedSSHWhitelist(); } + else { populateCurrentRecords(); } }); }; } @@ -365,7 +556,6 @@ app.controller('firewallController', function ($scope, $http, $timeout) { populateCurrentRecords(); - $scope.rulesLoading = true; $scope.actionFailed = true; $scope.actionSuccess = true; @@ -377,7 +567,7 @@ app.controller('firewallController', function ($scope, $http, $timeout) { } else { - $scope.rulesLoading = true; + $scope.rulesLoading = false; $scope.actionFailed = true; $scope.actionSuccess = true; @@ -393,7 +583,7 @@ app.controller('firewallController', function ($scope, $http, $timeout) { function cantLoadInitialDatas(response) { - $scope.rulesLoading = true; + $scope.rulesLoading = false; $scope.actionFailed = true; $scope.actionSuccess = true; @@ -407,8 +597,8 @@ app.controller('firewallController', function ($scope, $http, $timeout) { }; function populateCurrentRecords() { - - $scope.rulesLoading = false; + var gen = ++rulesFetchGen; + $scope.rulesLoading = true; $scope.actionFailed = true; $scope.actionSuccess = true; @@ -426,21 +616,44 @@ app.controller('firewallController', function ($scope, $http, $timeout) { $http.post(url, data, config).then(ListInitialDatas, cantLoadInitialDatas); function ListInitialDatas(response) { - var res = (typeof response.data === 'string') ? (function() { try { return JSON.parse(response.data); } catch (e) { return {}; } })() : response.data; - if (res && res.fetchStatus === 1) { - $scope.rules = typeof res.data === 'string' ? JSON.parse(res.data) : (res.data || []); - $scope.rulesTotalCount = res.total_count != null ? res.total_count : ($scope.rules ? $scope.rules.length : 0); - $scope.rulesPage = Math.max(1, res.page != null ? res.page : 1); - $scope.rulesPageSize = res.page_size != null ? res.page_size : 10; - $scope.rulesLoading = true; + if (gen !== rulesFetchGen) { + return; } - else { - $scope.rulesLoading = true; - $scope.errorMessage = (res && res.error_message) ? res.error_message : ''; + try { + var res = (typeof response.data === 'string') ? (function() { try { return JSON.parse(response.data); } catch (e) { return {}; } })() : response.data; + if (res && res.fetchStatus == 1) { + var parsedRules = []; + if (typeof res.data === 'string') { + try { + parsedRules = JSON.parse(res.data); + } catch (parseErr) { + parsedRules = []; + $scope.errorMessage = (res && res.error_message) ? res.error_message : 'Invalid rules data'; + } + } else { + parsedRules = res.data || []; + } + $scope.rules = parsedRules; + $scope.rulesTotalCount = res.total_count != null ? res.total_count : ($scope.rules ? $scope.rules.length : 0); + $scope.rulesPage = Math.max(1, res.page != null ? res.page : 1); + $scope.rulesPageSize = res.page_size != null ? res.page_size : 10; + } else { + $scope.errorMessage = (res && res.error_message) ? res.error_message : ''; + } + } catch (e) { + $scope.errorMessage = 'Could not load firewall rules.'; + } finally { + if (gen === rulesFetchGen) { + $scope.rulesLoading = false; + } } } function cantLoadInitialDatas(response) { + if (gen !== rulesFetchGen) { + return; + } + $scope.rulesLoading = false; $scope.couldNotConnect = false; } } @@ -519,7 +732,7 @@ app.controller('firewallController', function ($scope, $http, $timeout) { $scope.errorMessage = 'Port is required'; return; } - $scope.rulesLoading = false; + $scope.rulesLoading = true; var url = '/firewall/modifyRule'; var data = { id: d.id, @@ -530,19 +743,19 @@ app.controller('firewallController', function ($scope, $http, $timeout) { }; var config = { headers: { 'X-CSRFToken': getCookie('csrftoken') } }; $http.post(url, data, config).then(function(response) { - $scope.rulesLoading = true; if (response.data && response.data.status === 1) { $scope.closeModifyRuleModal(); $scope.actionFailed = true; $scope.actionSuccess = false; populateCurrentRecords(); } else { + $scope.rulesLoading = false; $scope.actionFailed = false; $scope.actionSuccess = true; $scope.errorMessage = (response.data && response.data.error_message) || 'Modify failed'; } }, function() { - $scope.rulesLoading = true; + $scope.rulesLoading = false; $scope.actionFailed = false; $scope.actionSuccess = true; $scope.errorMessage = 'Could not connect to server. Please refresh this page.'; @@ -551,7 +764,7 @@ app.controller('firewallController', function ($scope, $http, $timeout) { $scope.deleteRule = function (id, proto, port, ruleIP) { - $scope.rulesLoading = false; + $scope.rulesLoading = true; url = "/firewall/deleteRule"; @@ -579,7 +792,6 @@ app.controller('firewallController', function ($scope, $http, $timeout) { populateCurrentRecords(); - $scope.rulesLoading = true; $scope.actionFailed = true; $scope.actionSuccess = true; @@ -591,7 +803,7 @@ app.controller('firewallController', function ($scope, $http, $timeout) { } else { - $scope.rulesLoading = true; + $scope.rulesLoading = false; $scope.actionFailed = true; $scope.actionSuccess = true; @@ -599,7 +811,6 @@ app.controller('firewallController', function ($scope, $http, $timeout) { $scope.ruleAdded = true; $scope.couldNotConnect = true; - $scope.rulesLoading = true; $scope.errorMessage = response.data.error_message; @@ -609,7 +820,7 @@ app.controller('firewallController', function ($scope, $http, $timeout) { function cantLoadInitialDatas(response) { - $scope.rulesLoading = true; + $scope.rulesLoading = false; $scope.actionFailed = true; $scope.actionSuccess = true; @@ -656,7 +867,7 @@ app.controller('firewallController', function ($scope, $http, $timeout) { if (response.data.reload_status == 1) { - $scope.rulesLoading = true; + $scope.rulesLoading = false; $scope.actionFailed = true; $scope.actionSuccess = false; @@ -668,7 +879,7 @@ app.controller('firewallController', function ($scope, $http, $timeout) { } else { - $scope.rulesLoading = true; + $scope.rulesLoading = false; $scope.actionFailed = false; $scope.actionSuccess = true; @@ -685,7 +896,7 @@ app.controller('firewallController', function ($scope, $http, $timeout) { function cantLoadInitialDatas(response) { - $scope.rulesLoading = true; + $scope.rulesLoading = false; $scope.actionFailed = true; $scope.actionSuccess = true; @@ -731,7 +942,7 @@ app.controller('firewallController', function ($scope, $http, $timeout) { if (response.data.start_status == 1) { - $scope.rulesLoading = true; + $scope.rulesLoading = false; $scope.actionFailed = true; $scope.actionSuccess = false; @@ -747,7 +958,7 @@ app.controller('firewallController', function ($scope, $http, $timeout) { } else { - $scope.rulesLoading = true; + $scope.rulesLoading = false; $scope.actionFailed = false; $scope.actionSuccess = true; @@ -764,7 +975,7 @@ app.controller('firewallController', function ($scope, $http, $timeout) { function cantLoadInitialDatas(response) { - $scope.rulesLoading = true; + $scope.rulesLoading = false; $scope.actionFailed = true; $scope.actionSuccess = true; @@ -811,7 +1022,7 @@ app.controller('firewallController', function ($scope, $http, $timeout) { if (response.data.stop_status == 1) { - $scope.rulesLoading = true; + $scope.rulesLoading = false; $scope.actionFailed = true; $scope.actionSuccess = false; @@ -827,7 +1038,7 @@ app.controller('firewallController', function ($scope, $http, $timeout) { } else { - $scope.rulesLoading = true; + $scope.rulesLoading = false; $scope.actionFailed = false; $scope.actionSuccess = true; @@ -844,7 +1055,7 @@ app.controller('firewallController', function ($scope, $http, $timeout) { function cantLoadInitialDatas(response) { - $scope.rulesLoading = true; + $scope.rulesLoading = false; $scope.actionFailed = true; $scope.actionSuccess = true; @@ -1309,6 +1520,8 @@ app.controller('firewallController', function ($scope, $http, $timeout) { } } + $scope.populateCurrentRecords = populateCurrentRecords; + }); @@ -3254,18 +3467,20 @@ app.controller('litespeed_ent_conf', function ($scope, $http, $timeout, $window) function syncFirewallTabFromHash() { var nav = document.getElementById('firewall-tab-nav'); if (!nav) return; - var h = (window.location.hash || '').replace(/^#/, ''); - var tab = (h === 'banned-ips') ? 'banned' : 'rules'; + var h = (window.location.hash || '').replace(/^#/, '').toLowerCase(); + var tab = 'rules'; + if (h === 'banned-ips' || h === 'banned') tab = 'banned'; + else if (h === 'trusted-ips' || h === 'ssh-whitelist') tab = 'trusted'; if (window.__firewallLoadTab) { try { window.__firewallLoadTab(tab); } catch (e) {} } } + /* Initial sync only — hashchange is handled by Angular syncTabFromHash in firewallController + (multiple listeners were racing and could reset #trusted-ips to #rules). */ if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', syncFirewallTabFromHash); } else { syncFirewallTabFromHash(); } - setTimeout(syncFirewallTabFromHash, 100); - window.addEventListener('hashchange', syncFirewallTabFromHash); })(); \ No newline at end of file diff --git a/static/firewall/firewall.js b/static/firewall/firewall.js index 3e11e504c..3a0445213 100644 --- a/static/firewall/firewall.js +++ b/static/firewall/firewall.js @@ -23,6 +23,8 @@ function getCookie(name) { app.controller('firewallController', function ($scope, $http, $timeout) { $scope.rulesLoading = true; + /** Incremented on each rules fetch; stale HTTP responses must not touch rulesLoading. */ + var rulesFetchGen = 0; $scope.actionFailed = true; $scope.actionSuccess = true; $scope.showExportFormatModal = false; @@ -40,9 +42,12 @@ app.controller('firewallController', function ($scope, $http, $timeout) { // Initialize rules array - prevents "Cannot read 'length' of undefined" when template evaluates rules.length before API loads $scope.rules = []; // Banned IPs variables – tab from hash so we stay on /firewall/ (avoids 404 on servers without /firewall/firewall-rules/) + /* Use window.location.hash only. Angular $location can disagree with the fragment on /firewall/#… and wrongly map to "rules". */ function tabFromHash() { - var h = (window.location.hash || '').replace(/^#/, ''); - return (h === 'banned-ips') ? 'banned' : 'rules'; + var h = String(window.location.hash || '').replace(/^#/, '').toLowerCase(); + if (h === 'banned-ips' || h === 'banned') return 'banned'; + if (h === 'trusted-ips' || h === 'ssh-whitelist') return 'trusted'; + return 'rules'; } $scope.activeTab = tabFromHash(); $scope.bannedIPs = []; // Initialize as empty array @@ -52,7 +57,9 @@ app.controller('firewallController', function ($scope, $http, $timeout) { var tab = tabFromHash(); if ($scope.activeTab !== tab) { $scope.activeTab = tab; - if (tab === 'banned') { populateBannedIPs(); } else { populateCurrentRecords(); } + if (tab === 'banned') { populateBannedIPs(); } + else if (tab === 'trusted') { populateTrustedSSHWhitelist(); } + else { populateCurrentRecords(); } if (!$scope.$$phase && !$scope.$root.$$phase) { $scope.$apply(); } } } @@ -63,23 +70,51 @@ app.controller('firewallController', function ($scope, $http, $timeout) { window.addEventListener('load', function() { $timeout(applyTabFromHash, 0); }); } - // Sync tab with hash and load that tab's data on switch - $scope.setFirewallTab = function(tab) { + // Sync tab with hash and load that tab's data on switch (single source of truth from ng-click). + $scope.setFirewallTab = function(tab, $event) { + if ($event) { + try { + $event.stopPropagation(); + } catch (ignoreErr) {} + } $timeout(function() { $scope.activeTab = tab; - window.location.hash = (tab === 'banned') ? '#banned-ips' : '#rules'; - if (tab === 'banned') { populateBannedIPs(); } else { populateCurrentRecords(); } + function setHashIfNeeded(frag) { + try { + if ((window.location.hash || '') === frag) { + return; + } + var path = window.location.pathname + window.location.search + frag; + if (window.history && typeof window.history.replaceState === 'function') { + window.history.replaceState(null, '', path); + } else { + window.location.hash = frag; + } + } catch (ignoreHash) {} + } + if (tab === 'banned') { + setHashIfNeeded('#banned-ips'); + populateBannedIPs(); + } else if (tab === 'trusted') { + setHashIfNeeded('#trusted-ips'); + populateTrustedSSHWhitelist(); + } else { + setHashIfNeeded('#rules'); + populateCurrentRecords(); + } }, 0); }; - // Back/forward or direct hash change: sync tab and load its data function syncTabFromHash() { var tab = tabFromHash(); - if ($scope.activeTab !== tab) { - $scope.activeTab = tab; - if (tab === 'banned') { populateBannedIPs(); } else { populateCurrentRecords(); } - if (!$scope.$$phase && !$scope.$root.$$phase) { $scope.$apply(); } - } + $scope.$evalAsync(function() { + if ($scope.activeTab !== tab) { + $scope.activeTab = tab; + if (tab === 'banned') { populateBannedIPs(); } + else if (tab === 'trusted') { populateTrustedSSHWhitelist(); } + else { populateCurrentRecords(); } + } + }); } window.addEventListener('hashchange', syncTabFromHash); @@ -108,6 +143,9 @@ app.controller('firewallController', function ($scope, $http, $timeout) { $scope.banIP = ''; $scope.banReason = ''; $scope.banDuration = '24h'; + $scope.trustedSSHWhitelist = []; + $scope.trustedForm = { ip: '', label: '' }; + $scope.trustedSSHLoading = false; $scope.bannedIPSearch = ''; $scope.searchBannedIPFilter = function(item) { var q = ($scope.bannedIPSearch || '').toLowerCase().trim(); @@ -142,6 +180,7 @@ app.controller('firewallController', function ($scope, $http, $timeout) { $timeout(function() { try { if (newVal === 'banned' && typeof populateBannedIPs === 'function') populateBannedIPs(); + else if (newVal === 'trusted' && typeof populateTrustedSSHWhitelist === 'function') populateTrustedSSHWhitelist(); else if (newVal === 'rules' && typeof populateCurrentRecords === 'function') populateCurrentRecords(); } catch (e) {} }, 0); @@ -246,6 +285,156 @@ app.controller('firewallController', function ($scope, $http, $timeout) { } ); } + + function populateTrustedSSHWhitelist() { + $scope.trustedSSHLoading = true; + var config = { headers: { 'X-CSRFToken': getCookie('csrftoken') || '' } }; + $http.post('/base/sshSecurityWhitelistList', {}, config).then( + function(response) { + $scope.trustedSSHLoading = false; + var res = response.data; + if (typeof res === 'string') { + try { res = JSON.parse(res); } catch (e) { res = {}; } + } + if (res && res.status === 1) { + var ent = res.entries || []; + $scope.trustedSSHWhitelist = ent.map(function(e) { + return { + ip: e.ip, + label: e.label || '', + updated: e.updated || 0, + _l: e.label || '', + _nip: '' + }; + }); + } else { + $scope.trustedSSHWhitelist = []; + var errMsg = (res && res.error) ? res.error : 'Could not load trusted IPs'; + if (typeof PNotify !== 'undefined') { + new PNotify({ title: 'Trusted IPs', text: errMsg, type: 'error', delay: 6000 }); + } + } + }, + function(error) { + $scope.trustedSSHLoading = false; + $scope.trustedSSHWhitelist = []; + var msg = (error.data && error.data.error) ? error.data.error : 'Request failed'; + if (typeof PNotify !== 'undefined') { + new PNotify({ title: 'Trusted IPs', text: msg, type: 'error', delay: 6000 }); + } + } + ); + } + + $scope.populateTrustedSSHWhitelist = function() { + populateTrustedSSHWhitelist(); + }; + + $scope.addTrustedSSHWhitelist = function() { + var ip = ($scope.trustedForm.ip || '').trim(); + var label = ($scope.trustedForm.label || '').trim(); + if (!ip) { + if (typeof PNotify !== 'undefined') { + new PNotify({ title: 'Trusted IPs', text: 'Enter an IP address', type: 'warning', delay: 5000 }); + } + return; + } + var config = { headers: { 'X-CSRFToken': getCookie('csrftoken') || '' } }; + $http.post('/base/sshSecurityWhitelistAdd', { ip: ip, label: label }, config).then( + function(response) { + var res = response.data; + if (typeof res === 'string') { + try { res = JSON.parse(res); } catch (e) { res = {}; } + } + if (res && res.status === 1) { + $scope.trustedForm.ip = ''; + $scope.trustedForm.label = ''; + populateTrustedSSHWhitelist(); + if (typeof PNotify !== 'undefined') { + new PNotify({ title: 'Trusted IPs', text: 'IP added to trusted list', type: 'success', delay: 4000 }); + } + } else { + var errAdd = (res && res.error) ? res.error : 'Failed to add'; + if (typeof PNotify !== 'undefined') { + new PNotify({ title: 'Trusted IPs', text: errAdd, type: 'error', delay: 6000 }); + } + } + }, + function(err) { + var em = (err.data && err.data.error) ? err.data.error : 'Request failed'; + if (typeof PNotify !== 'undefined') { + new PNotify({ title: 'Trusted IPs', text: em, type: 'error', delay: 6000 }); + } + } + ); + }; + + $scope.removeTrustedSSHWhitelist = function(ip) { + if (!ip) return; + var config = { headers: { 'X-CSRFToken': getCookie('csrftoken') || '' } }; + $http.post('/base/sshSecurityWhitelistRemove', { ip: ip }, config).then( + function(response) { + var res = response.data; + if (typeof res === 'string') { + try { res = JSON.parse(res); } catch (e) { res = {}; } + } + if (res && res.status === 1) { + populateTrustedSSHWhitelist(); + if (typeof PNotify !== 'undefined') { + new PNotify({ title: 'Trusted IPs', text: 'IP removed', type: 'success', delay: 4000 }); + } + } else { + var errRm = (res && res.error) ? res.error : 'Failed to remove'; + if (typeof PNotify !== 'undefined') { + new PNotify({ title: 'Trusted IPs', text: errRm, type: 'error', delay: 6000 }); + } + } + }, + function(err) { + var em2 = (err.data && err.data.error) ? err.data.error : 'Request failed'; + if (typeof PNotify !== 'undefined') { + new PNotify({ title: 'Trusted IPs', text: em2, type: 'error', delay: 6000 }); + } + } + ); + }; + + $scope.saveTrustedSSHWhitelistRow = function(row) { + if (!row || !row.ip) return; + var payload = { ip: row.ip, label: row._l }; + if (row._nip && String(row._nip).trim()) { + payload.new_ip = String(row._nip).trim(); + } + var config = { headers: { 'X-CSRFToken': getCookie('csrftoken') || '' } }; + $http.post('/base/sshSecurityWhitelistUpdate', payload, config).then( + function(response) { + var res = response.data; + if (typeof res === 'string') { + try { res = JSON.parse(res); } catch (e) { res = {}; } + } + var ok = res && (res.status === 1 || res.status === '1'); + if (ok) { + populateTrustedSSHWhitelist(); + if (typeof PNotify !== 'undefined') { + var unchanged = res.unchanged === true || res.unchanged === 'true' || res.unchanged === 1; + var msgOk = (res.message && String(res.message).length) ? res.message : (unchanged ? 'No changes to save.' : 'Entry updated'); + new PNotify({ title: 'Trusted IPs', text: msgOk, type: unchanged ? 'info' : 'success', delay: 4000 }); + } + } else { + var errUp = (res && res.error) ? res.error : 'Failed to update'; + if (typeof PNotify !== 'undefined') { + new PNotify({ title: 'Trusted IPs', text: errUp, type: 'error', delay: 6000 }); + } + } + }, + function(err) { + var em3 = (err.data && err.data.error) ? err.data.error : 'Request failed'; + if (typeof PNotify !== 'undefined') { + new PNotify({ title: 'Trusted IPs', text: em3, type: 'error', delay: 6000 }); + } + } + ); + }; // Expose to scope for template access $scope.populateBannedIPs = function() { @@ -301,7 +490,9 @@ app.controller('firewallController', function ($scope, $http, $timeout) { window.__firewallLoadTab = function(tab) { $scope.$evalAsync(function() { $scope.activeTab = tab; - if (tab === 'banned') { populateBannedIPs(); } else { populateCurrentRecords(); } + if (tab === 'banned') { populateBannedIPs(); } + else if (tab === 'trusted') { populateTrustedSSHWhitelist(); } + else { populateCurrentRecords(); } }); }; } @@ -365,7 +556,6 @@ app.controller('firewallController', function ($scope, $http, $timeout) { populateCurrentRecords(); - $scope.rulesLoading = true; $scope.actionFailed = true; $scope.actionSuccess = true; @@ -377,7 +567,7 @@ app.controller('firewallController', function ($scope, $http, $timeout) { } else { - $scope.rulesLoading = true; + $scope.rulesLoading = false; $scope.actionFailed = true; $scope.actionSuccess = true; @@ -393,7 +583,7 @@ app.controller('firewallController', function ($scope, $http, $timeout) { function cantLoadInitialDatas(response) { - $scope.rulesLoading = true; + $scope.rulesLoading = false; $scope.actionFailed = true; $scope.actionSuccess = true; @@ -407,8 +597,8 @@ app.controller('firewallController', function ($scope, $http, $timeout) { }; function populateCurrentRecords() { - - $scope.rulesLoading = false; + var gen = ++rulesFetchGen; + $scope.rulesLoading = true; $scope.actionFailed = true; $scope.actionSuccess = true; @@ -426,21 +616,44 @@ app.controller('firewallController', function ($scope, $http, $timeout) { $http.post(url, data, config).then(ListInitialDatas, cantLoadInitialDatas); function ListInitialDatas(response) { - var res = (typeof response.data === 'string') ? (function() { try { return JSON.parse(response.data); } catch (e) { return {}; } })() : response.data; - if (res && res.fetchStatus === 1) { - $scope.rules = typeof res.data === 'string' ? JSON.parse(res.data) : (res.data || []); - $scope.rulesTotalCount = res.total_count != null ? res.total_count : ($scope.rules ? $scope.rules.length : 0); - $scope.rulesPage = Math.max(1, res.page != null ? res.page : 1); - $scope.rulesPageSize = res.page_size != null ? res.page_size : 10; - $scope.rulesLoading = true; + if (gen !== rulesFetchGen) { + return; } - else { - $scope.rulesLoading = true; - $scope.errorMessage = (res && res.error_message) ? res.error_message : ''; + try { + var res = (typeof response.data === 'string') ? (function() { try { return JSON.parse(response.data); } catch (e) { return {}; } })() : response.data; + if (res && res.fetchStatus == 1) { + var parsedRules = []; + if (typeof res.data === 'string') { + try { + parsedRules = JSON.parse(res.data); + } catch (parseErr) { + parsedRules = []; + $scope.errorMessage = (res && res.error_message) ? res.error_message : 'Invalid rules data'; + } + } else { + parsedRules = res.data || []; + } + $scope.rules = parsedRules; + $scope.rulesTotalCount = res.total_count != null ? res.total_count : ($scope.rules ? $scope.rules.length : 0); + $scope.rulesPage = Math.max(1, res.page != null ? res.page : 1); + $scope.rulesPageSize = res.page_size != null ? res.page_size : 10; + } else { + $scope.errorMessage = (res && res.error_message) ? res.error_message : ''; + } + } catch (e) { + $scope.errorMessage = 'Could not load firewall rules.'; + } finally { + if (gen === rulesFetchGen) { + $scope.rulesLoading = false; + } } } function cantLoadInitialDatas(response) { + if (gen !== rulesFetchGen) { + return; + } + $scope.rulesLoading = false; $scope.couldNotConnect = false; } } @@ -519,7 +732,7 @@ app.controller('firewallController', function ($scope, $http, $timeout) { $scope.errorMessage = 'Port is required'; return; } - $scope.rulesLoading = false; + $scope.rulesLoading = true; var url = '/firewall/modifyRule'; var data = { id: d.id, @@ -530,19 +743,19 @@ app.controller('firewallController', function ($scope, $http, $timeout) { }; var config = { headers: { 'X-CSRFToken': getCookie('csrftoken') } }; $http.post(url, data, config).then(function(response) { - $scope.rulesLoading = true; if (response.data && response.data.status === 1) { $scope.closeModifyRuleModal(); $scope.actionFailed = true; $scope.actionSuccess = false; populateCurrentRecords(); } else { + $scope.rulesLoading = false; $scope.actionFailed = false; $scope.actionSuccess = true; $scope.errorMessage = (response.data && response.data.error_message) || 'Modify failed'; } }, function() { - $scope.rulesLoading = true; + $scope.rulesLoading = false; $scope.actionFailed = false; $scope.actionSuccess = true; $scope.errorMessage = 'Could not connect to server. Please refresh this page.'; @@ -551,7 +764,7 @@ app.controller('firewallController', function ($scope, $http, $timeout) { $scope.deleteRule = function (id, proto, port, ruleIP) { - $scope.rulesLoading = false; + $scope.rulesLoading = true; url = "/firewall/deleteRule"; @@ -579,7 +792,6 @@ app.controller('firewallController', function ($scope, $http, $timeout) { populateCurrentRecords(); - $scope.rulesLoading = true; $scope.actionFailed = true; $scope.actionSuccess = true; @@ -591,7 +803,7 @@ app.controller('firewallController', function ($scope, $http, $timeout) { } else { - $scope.rulesLoading = true; + $scope.rulesLoading = false; $scope.actionFailed = true; $scope.actionSuccess = true; @@ -599,7 +811,6 @@ app.controller('firewallController', function ($scope, $http, $timeout) { $scope.ruleAdded = true; $scope.couldNotConnect = true; - $scope.rulesLoading = true; $scope.errorMessage = response.data.error_message; @@ -609,7 +820,7 @@ app.controller('firewallController', function ($scope, $http, $timeout) { function cantLoadInitialDatas(response) { - $scope.rulesLoading = true; + $scope.rulesLoading = false; $scope.actionFailed = true; $scope.actionSuccess = true; @@ -656,7 +867,7 @@ app.controller('firewallController', function ($scope, $http, $timeout) { if (response.data.reload_status == 1) { - $scope.rulesLoading = true; + $scope.rulesLoading = false; $scope.actionFailed = true; $scope.actionSuccess = false; @@ -668,7 +879,7 @@ app.controller('firewallController', function ($scope, $http, $timeout) { } else { - $scope.rulesLoading = true; + $scope.rulesLoading = false; $scope.actionFailed = false; $scope.actionSuccess = true; @@ -685,7 +896,7 @@ app.controller('firewallController', function ($scope, $http, $timeout) { function cantLoadInitialDatas(response) { - $scope.rulesLoading = true; + $scope.rulesLoading = false; $scope.actionFailed = true; $scope.actionSuccess = true; @@ -731,7 +942,7 @@ app.controller('firewallController', function ($scope, $http, $timeout) { if (response.data.start_status == 1) { - $scope.rulesLoading = true; + $scope.rulesLoading = false; $scope.actionFailed = true; $scope.actionSuccess = false; @@ -747,7 +958,7 @@ app.controller('firewallController', function ($scope, $http, $timeout) { } else { - $scope.rulesLoading = true; + $scope.rulesLoading = false; $scope.actionFailed = false; $scope.actionSuccess = true; @@ -764,7 +975,7 @@ app.controller('firewallController', function ($scope, $http, $timeout) { function cantLoadInitialDatas(response) { - $scope.rulesLoading = true; + $scope.rulesLoading = false; $scope.actionFailed = true; $scope.actionSuccess = true; @@ -811,7 +1022,7 @@ app.controller('firewallController', function ($scope, $http, $timeout) { if (response.data.stop_status == 1) { - $scope.rulesLoading = true; + $scope.rulesLoading = false; $scope.actionFailed = true; $scope.actionSuccess = false; @@ -827,7 +1038,7 @@ app.controller('firewallController', function ($scope, $http, $timeout) { } else { - $scope.rulesLoading = true; + $scope.rulesLoading = false; $scope.actionFailed = false; $scope.actionSuccess = true; @@ -844,7 +1055,7 @@ app.controller('firewallController', function ($scope, $http, $timeout) { function cantLoadInitialDatas(response) { - $scope.rulesLoading = true; + $scope.rulesLoading = false; $scope.actionFailed = true; $scope.actionSuccess = true; @@ -1309,6 +1520,8 @@ app.controller('firewallController', function ($scope, $http, $timeout) { } } + $scope.populateCurrentRecords = populateCurrentRecords; + }); @@ -3254,18 +3467,20 @@ app.controller('litespeed_ent_conf', function ($scope, $http, $timeout, $window) function syncFirewallTabFromHash() { var nav = document.getElementById('firewall-tab-nav'); if (!nav) return; - var h = (window.location.hash || '').replace(/^#/, ''); - var tab = (h === 'banned-ips') ? 'banned' : 'rules'; + var h = (window.location.hash || '').replace(/^#/, '').toLowerCase(); + var tab = 'rules'; + if (h === 'banned-ips' || h === 'banned') tab = 'banned'; + else if (h === 'trusted-ips' || h === 'ssh-whitelist') tab = 'trusted'; if (window.__firewallLoadTab) { try { window.__firewallLoadTab(tab); } catch (e) {} } } + /* Initial sync only — hashchange is handled by Angular syncTabFromHash in firewallController + (multiple listeners were racing and could reset #trusted-ips to #rules). */ if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', syncFirewallTabFromHash); } else { syncFirewallTabFromHash(); } - setTimeout(syncFirewallTabFromHash, 100); - window.addEventListener('hashchange', syncFirewallTabFromHash); })(); \ No newline at end of file