From dbee150b336ceb992026d2ede7f415946a391765 Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Fri, 12 Jun 2026 01:22:30 +0200 Subject: [PATCH] fix(script): SSL management fixes (#4994, #5010, #5070) - Issue acme.sh HTTP-01 over IPv4 unless the host has no global IPv4 address: the hardcoded --listen-v6 started a v6-only standalone listener, so validation of a domain whose A record points at this host always failed (#4994). - Add a custom cert/key path option to the "Set Cert paths" menu so certificates living outside /root/cert (e.g. certbot under /etc/letsencrypt) can be wired to the panel from the CLI (#5010). - Derive the displayed Access URL from the certificate's actual SAN list instead of the cert folder name, list the other covered names, and show the panel's custom-path certificate in "Show Existing Domains" (#5070). Also silence find when /root/cert doesn't exist. --- install.sh | 16 +++++++++++-- update.sh | 16 +++++++++++-- x-ui.sh | 67 +++++++++++++++++++++++++++++++++++++++++++++++++----- 3 files changed, 89 insertions(+), 10 deletions(-) diff --git a/install.sh b/install.sh index 24d90c9a8..6e0d4b909 100644 --- a/install.sh +++ b/install.sh @@ -56,6 +56,18 @@ is_domain() { [[ "$1" =~ ^([A-Za-z0-9](-*[A-Za-z0-9])*\.)+(xn--[a-z0-9]{2,}|[A-Za-z]{2,})$ ]] && return 0 || return 1 } +# acme.sh's standalone server binds IPv4 by default; --listen-v6 makes it +# v6-only, which breaks HTTP-01 validation when the domain's A record points +# at this host's IPv4 (#4994). Only force IPv6 when the host has no global +# IPv4 address at all. +acme_listen_flag() { + if ip -4 addr show scope global 2> /dev/null | grep -q "inet "; then + echo "" + else + echo "--listen-v6" + fi +} + # Port helpers is_port_in_use() { local port="$1" @@ -292,7 +304,7 @@ setup_ssl_certificate() { echo -e "${yellow}Note: Port 80 must be open and accessible from the internet${plain}" ~/.acme.sh/acme.sh --set-default-ca --server letsencrypt --force > /dev/null 2>&1 - ~/.acme.sh/acme.sh --issue -d ${domain} --listen-v6 --standalone --httpport 80 --force + ~/.acme.sh/acme.sh --issue -d ${domain} $(acme_listen_flag) --standalone --httpport 80 --force if [ $? -ne 0 ]; then echo -e "${yellow}Failed to issue certificate for ${domain}${plain}" @@ -576,7 +588,7 @@ ssl_cert_issue() { if [[ ${cert_exists} -eq 0 ]]; then # issue the certificate ~/.acme.sh/acme.sh --set-default-ca --server letsencrypt --force - ~/.acme.sh/acme.sh --issue -d ${domain} --listen-v6 --standalone --httpport ${WebPort} --force + ~/.acme.sh/acme.sh --issue -d ${domain} $(acme_listen_flag) --standalone --httpport ${WebPort} --force if [ $? -ne 0 ]; then echo -e "${red}Issuing certificate failed, please check logs.${plain}" rm -rf ~/.acme.sh/${domain} ~/.acme.sh/${domain}_ecc diff --git a/update.sh b/update.sh index f801d0134..0a590f2f7 100755 --- a/update.sh +++ b/update.sh @@ -81,6 +81,18 @@ is_domain() { [[ "$1" =~ ^([A-Za-z0-9](-*[A-Za-z0-9])*\.)+(xn--[a-z0-9]{2,}|[A-Za-z]{2,})$ ]] && return 0 || return 1 } +# acme.sh's standalone server binds IPv4 by default; --listen-v6 makes it +# v6-only, which breaks HTTP-01 validation when the domain's A record points +# at this host's IPv4 (#4994). Only force IPv6 when the host has no global +# IPv4 address at all. +acme_listen_flag() { + if ip -4 addr show scope global 2> /dev/null | grep -q "inet "; then + echo "" + else + echo "--listen-v6" + fi +} + # Port helpers is_port_in_use() { local port="$1" @@ -200,7 +212,7 @@ setup_ssl_certificate() { echo -e "${yellow}Note: Port 80 must be open and accessible from the internet${plain}" ~/.acme.sh/acme.sh --set-default-ca --server letsencrypt --force > /dev/null 2>&1 - ~/.acme.sh/acme.sh --issue -d ${domain} --listen-v6 --standalone --httpport 80 --force + ~/.acme.sh/acme.sh --issue -d ${domain} $(acme_listen_flag) --standalone --httpport 80 --force if [ $? -ne 0 ]; then echo -e "${yellow}Failed to issue certificate for ${domain}${plain}" @@ -465,7 +477,7 @@ ssl_cert_issue() { if [[ ${cert_exists} -eq 0 ]]; then # issue the certificate ~/.acme.sh/acme.sh --set-default-ca --server letsencrypt --force - ~/.acme.sh/acme.sh --issue -d ${domain} --listen-v6 --standalone --httpport ${WebPort} --force + ~/.acme.sh/acme.sh --issue -d ${domain} $(acme_listen_flag) --standalone --httpport ${WebPort} --force if [ $? -ne 0 ]; then echo -e "${red}Issuing certificate failed, please check logs.${plain}" rm -rf ~/.acme.sh/${domain} diff --git a/x-ui.sh b/x-ui.sh index b527b5a5f..0263d1abe 100644 --- a/x-ui.sh +++ b/x-ui.sh @@ -50,6 +50,18 @@ is_domain() { [[ "$1" =~ ^([A-Za-z0-9](-*[A-Za-z0-9])*\.)+(xn--[a-z0-9]{2,}|[A-Za-z]{2,})$ ]] && return 0 || return 1 } +# acme.sh's standalone server binds IPv4 by default; --listen-v6 makes it +# v6-only, which breaks HTTP-01 validation when the domain's A record points +# at this host's IPv4 (#4994). Only force IPv6 when the host has no global +# IPv4 address at all. +acme_listen_flag() { + if ip -4 addr show scope global 2> /dev/null | grep -q "inet "; then + echo "" + else + echo "--listen-v6" + fi +} + # check root [[ $EUID -ne 0 ]] && LOGE "ERROR: You must be root to run this script! \n" && exit 1 @@ -361,12 +373,26 @@ check_config() { if [[ -n "$existing_cert" ]]; then local domain=$(basename "$(dirname "$existing_cert")") + # The cert folder name is only the certificate's first domain. A + # multidomain (SAN) certificate may be served under any name it covers, + # so read the real names from the certificate itself (#5070). + local cert_sans="" + if [[ -f "$existing_cert" ]] && command -v openssl > /dev/null 2>&1; then + cert_sans=$(openssl x509 -in "$existing_cert" -noout -ext subjectAltName 2> /dev/null \ + | grep -Eo 'DNS:[^,[:space:]]+' | cut -d: -f2) + if [[ -n "$cert_sans" ]] && ! echo "$cert_sans" | grep -qx "$domain"; then + domain=$(echo "$cert_sans" | head -n1) + fi + fi if [[ "$domain" =~ ^[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$ ]]; then echo -e "${green}Access URL: https://${domain}:${existing_port}${existing_webBasePath}${plain}" else echo -e "${green}Access URL: https://${server_ip}:${existing_port}${existing_webBasePath}${plain}" fi + if [[ -n "$cert_sans" && $(echo "$cert_sans" | wc -l) -gt 1 ]]; then + echo -e "${yellow}The certificate also covers:${plain} $(echo "$cert_sans" | grep -vx "$domain" | tr '\n' ' ')" + fi else echo -e "${red}⚠ WARNING: No SSL certificate configured!${plain}" echo -e "${yellow}You can get a Let's Encrypt certificate for your IP address (valid ~6 days, auto-renews).${plain}" @@ -1231,7 +1257,7 @@ ssl_cert_issue_main() { ssl_cert_issue_main ;; 2) - local domains=$(find /root/cert/ -mindepth 1 -maxdepth 1 -type d -exec basename {} \;) + local domains=$(find /root/cert/ -mindepth 1 -maxdepth 1 -type d -exec basename {} \; 2> /dev/null) if [ -z "$domains" ]; then echo "No certificates found to revoke." else @@ -1272,7 +1298,7 @@ ssl_cert_issue_main() { ssl_cert_issue_main ;; 3) - local domains=$(find /root/cert/ -mindepth 1 -maxdepth 1 -type d -exec basename {} \;) + local domains=$(find /root/cert/ -mindepth 1 -maxdepth 1 -type d -exec basename {} \; 2> /dev/null) if [ -z "$domains" ]; then echo "No certificates found to renew." else @@ -1289,9 +1315,9 @@ ssl_cert_issue_main() { ssl_cert_issue_main ;; 4) - local domains=$(find /root/cert/ -mindepth 1 -maxdepth 1 -type d -exec basename {} \;) + local domains=$(find /root/cert/ -mindepth 1 -maxdepth 1 -type d -exec basename {} \; 2> /dev/null) if [ -z "$domains" ]; then - echo "No certificates found." + echo "No certificates found under /root/cert." else echo "Existing domains and their paths:" for domain in $domains; do @@ -1306,10 +1332,39 @@ ssl_cert_issue_main() { fi done fi + # The panel's configured certificate may live outside /root/cert + # (e.g. certbot under /etc/letsencrypt) — show it too (#5070). + local panel_cert=$(${xui_folder}/x-ui setting -getCert true | grep 'cert:' | awk -F': ' '{print $2}' | tr -d '[:space:]') + if [[ -n "${panel_cert}" && "${panel_cert}" != /root/cert/* ]]; then + echo -e "Panel certificate (custom path): ${panel_cert}" + if [[ -f "${panel_cert}" ]] && command -v openssl > /dev/null 2>&1; then + local panel_sans=$(openssl x509 -in "${panel_cert}" -noout -ext subjectAltName 2> /dev/null \ + | grep -Eo 'DNS:[^,[:space:]]+' | cut -d: -f2 | tr '\n' ' ') + [[ -n "${panel_sans}" ]] && echo -e "\tCovers: ${panel_sans}" + fi + fi ssl_cert_issue_main ;; 5) - local domains=$(find /root/cert/ -mindepth 1 -maxdepth 1 -type d -exec basename {} \;) + echo -e "${green}\t1.${plain} Use a certificate from /root/cert" + echo -e "${green}\t2.${plain} Enter custom certificate file paths (e.g. certbot, /etc/letsencrypt/...)" + read -rp "Choose an option: " pathChoice + if [[ "$pathChoice" == "2" ]]; then + read -rp "Certificate file path (fullchain): " webCertFile + read -rp "Private key file path: " webKeyFile + if [[ -f "${webCertFile}" && -f "${webKeyFile}" ]]; then + ${xui_folder}/x-ui cert -webCert "$webCertFile" -webCertKey "$webKeyFile" + echo "Panel certificate paths set:" + echo " - Certificate File: $webCertFile" + echo " - Private Key File: $webKeyFile" + restart + else + echo "Certificate or private key file not found." + fi + ssl_cert_issue_main + return + fi + local domains=$(find /root/cert/ -mindepth 1 -maxdepth 1 -type d -exec basename {} \; 2> /dev/null) if [ -z "$domains" ]; then echo "No certificates found." else @@ -1691,7 +1746,7 @@ ssl_cert_issue() { if [[ ${cert_exists} -eq 0 ]]; then # issue the certificate ~/.acme.sh/acme.sh --set-default-ca --server letsencrypt --force - ~/.acme.sh/acme.sh --issue -d ${domain} --listen-v6 --standalone --httpport ${WebPort} --force + ~/.acme.sh/acme.sh --issue -d ${domain} $(acme_listen_flag) --standalone --httpport ${WebPort} --force if [ $? -ne 0 ]; then LOGE "Issuing certificate failed, please check logs." rm -rf ~/.acme.sh/${domain} ~/.acme.sh/${domain}_ecc