标签: 开发

  • 阿里云虚机 WP 发信失败?证书验证错误 SMTP Failed to connect

    给 WordPress 添加了评论回复邮件提醒功能,但是测试时无法收到邮件。查看 Web 服务器错误日志,看到 WP_Error Object: wp_mail_failed: SMTP Failed to connect to server 等报错信息。SMTP 连接信息和用户名与密码都是正确的,仅凭这一点报错信息不能确定 SMTP 无法连接到服务器的具体原因,所以单独写一个测试程序 test_mail.php 放到 WordPress 程序根目录下,打印详细的调试信息。test_mail.php 代码如下:

    <?php
    error_reporting(E_ALL);
    
    echo "importing\n";
    require_once 'wp-includes/PHPMailer/PHPMailer.php';
    require_once 'wp-includes/PHPMailer/SMTP.php';
    
    echo "creating mailer\n";
    $mail = new PHPMailer\PHPMailer\PHPMailer();
    
    echo "setting\n";
    $mail->CharSet="UTF-8";           //设定邮件编码,默认ISO-8859-1,如果发中文此项必须设置为 UTF-8
    $mail->isSMTP();                  //设定使用SMTP服务
    $mail->SMTPAuth = true;           //启用SMTP验证功能
    $mail->SMTPSecure = "ssl";        //启用SSL
    $mail->SMTPDebug = true;
    $mail->Host = "smtpdm.aliyun.com";                                //SMTP服务器
    $mail->Port = 465;                                                //SMTP服务器的端口号
    $mail->Username = "USERNAME";                                     //SMTP服务器用户名
    $mail->Password = "PASSWORD";                                     //SMTP服务器密码
    $mail->setFrom('notifications@www.eulerkey.cn', 'EulerKey');      //设置发件人地址和名称
    $mail->addReplyTo("notifications@www.eulerkey.cn","EulerKey");    //设置邮件回复人地址和名称
    $mail->Subject = '邮件标题';                                      //设置邮件标题
    $mail->AltBody = "为了查看该邮件,请切换到支持HTML的邮件客户端";     //可选项,向下兼容考虑
    $mail->msgHTML('<html>hello</html>');                            //设置邮件内容
    $mail->addAddress('admin@eulerkey.cn', 'EulerKey Admin');        //设置收件人地址和名称
    
    echo "sending\n";
    if(!$mail->Send()) {
        echo "发送失败:" . $mail->ErrorInfo;
    } else {
        echo "恭喜,邮件发送成功!";
    }Code language: PHP (php)

    运行这个 test_mail.php,得到了下面的详细信息:

    2025-12-18 10:56:38 Connection: opening to ssl://smtpdm.aliyun.com:465, timeout=300, options=array()
    2025-12-18 10:56:38 Connection: stream_socket_client not available, falling back to fsockopen
    2025-12-18 10:56:38 Connection failed. Error #2: fsockopen(): SSL operation failed with code 1. OpenSSL Error messages:error:1416F086:SSL routines:tls_process_server_certificate:certificate verify failed [/usr/home/htdocs/wp-includes/PHPMailer/SMTP.php line 439]
    2025-12-18 10:56:38 Connection failed. Error #2: fsockopen(): Failed to enable crypto [/usr/home/htdocs/wp-includes/PHPMailer/SMTP.php line 439]
    2025-12-18 10:56:38 Connection failed. Error #2: fsockopen(): Unable to connect to ssl://smtpdm.aliyun.com:465 (Unknown error) [/usr/home/htdocs/wp-includes/PHPMailer/SMTP.php line 439]
    2025-12-18 10:56:38 SMTP ERROR: Failed to connect to server: (0)Code language: Access log (accesslog)

    关键信息在这里:OpenSSL Error messages:error:1416F086:SSL routines:tls_process_server_certificate:certificate verify failed

    OpenSSL 证书验证失败,进而无法加密,导致无法连接 SMTP 服务器。

    为什么会验证证书会失败?这里有几种可能,其中一种可能是远端 SMTP 服务器配置有误,导致我们无法验证对方的证书。另一种可能是我们这边服务器配置有误。

    首先在自己的电脑上快速检查远端 SMTP 服务器证书是否有问题:

    openssl s_client -connect 服务器地址:端口号 -showcertsCode language: Shell Session (shell)
    # openssl s_client -connect smtpdm.aliyun.com:465 -showcerts
    CONNECTED(00000003)
    depth=2 OU = GlobalSign Root CA - R3, O = GlobalSign, CN = GlobalSign
    verify return:1
    depth=1 C = BE, O = GlobalSign nv-sa, CN = GlobalSign GCC R3 OV TLS CA 2024
    verify return:1
    depth=0 C = CN, ST = ZheJiang, L = HangZhou, O = "Alibaba (China) Technology Co., Ltd.", CN = mail.aliyun.com
    verify return:1
    ---
    Certificate chain
     0 s:C = CN, ST = ZheJiang, L = HangZhou, O = "Alibaba (China) Technology Co., Ltd.", CN = mail.aliyun.com
       i:C = BE, O = GlobalSign nv-sa, CN = GlobalSign GCC R3 OV TLS CA 2024
       a:PKEY: rsaEncryption, 2048 (bit); sigalg: RSA-SHA256
       v:NotBefore: Jul 30 07:21:02 2025 GMT; NotAfter: Aug 31 07:21:01 2026 GMT
    -----BEGIN CERTIFICATE-----
    MIIbVzCCGj+gAwIBAgIMNqmX8xa3aRIoP525MA0GCSqGSIb3DQEBCwUAMFMxCzAJ
    ......
    +czIqWmHPBZzUHJ3514papBAaldNzuAaMi+ggOn2AK4/LSVA2h9Q5I3Q9Q==
    -----END CERTIFICATE-----
     1 s:C = BE, O = GlobalSign nv-sa, CN = GlobalSign GCC R3 OV TLS CA 2024
       i:OU = GlobalSign Root CA - R3, O = GlobalSign, CN = GlobalSign
       a:PKEY: rsaEncryption, 2048 (bit); sigalg: RSA-SHA256
       v:NotBefore: Sep 18 03:14:38 2024 GMT; NotAfter: Mar 18 00:00:00 2029 GMT
    -----BEGIN CERTIFICATE-----
    MIIEmDCCA4CgAwIBAgIRAIHlq5jkbzW5HC/6F4cYyFowDQYJKoZIhvcNAQELBQAw
    ......
    JI6wy6Il7Dat8NzQpJmYLGcaiSTY3TZl51f4xw==
    -----END CERTIFICATE-----
     2 s:OU = GlobalSign Root CA - R3, O = GlobalSign, CN = GlobalSign
       i:C = BE, O = GlobalSign nv-sa, OU = Root CA, CN = GlobalSign Root CA
       a:PKEY: rsaEncryption, 2048 (bit); sigalg: RSA-SHA256
       v:NotBefore: Sep 19 00:00:00 2018 GMT; NotAfter: Jan 28 12:00:00 2028 GMT
    -----BEGIN CERTIFICATE-----
    MIIETjCCAzagAwIBAgINAe5fFp3/lzUrZGXWajANBgkqhkiG9w0BAQsFADBXMQsw
    ......
    4HA=
    -----END CERTIFICATE-----
    ---
    Server certificate
    subject=C = CN, ST = ZheJiang, L = HangZhou, O = "Alibaba (China) Technology Co., Ltd.", CN = mail.aliyun.com
    issuer=C = BE, O = GlobalSign nv-sa, CN = GlobalSign GCC R3 OV TLS CA 2024
    ---
    No client certificate CA names sent
    Peer signing digest: SHA256
    Peer signature type: RSA-PSS
    Server Temp Key: X25519, 253 bits
    ---
    SSL handshake has read 9855 bytes and written 399 bytes
    Verification: OK
    ---
    New, TLSv1.3, Cipher is TLS_AES_256_GCM_SHA384
    Server public key is 2048 bit
    Secure Renegotiation IS NOT supported
    Compression: NONE
    Expansion: NONE
    No ALPN negotiated
    Early data was not sent
    Verify return code: 0 (ok)
    ---
    ---
    Post-Handshake New Session Ticket arrived:
    SSL-Session:
        Protocol  : TLSv1.3
        Cipher    : TLS_AES_256_GCM_SHA384
        Session-ID: CE91F7......0E4FCB4
        Session-ID-ctx:
        Resumption PSK: 13EA0......C071
        PSK identity: None
        PSK identity hint: None
        SRP username: None
        TLS session ticket lifetime hint: 7200 (seconds)
        TLS session ticket:
        0000 - 43 07 31 f3 a5 0c 52 88-e3 fc c4 96 c1 3c 30 a3   C.1...R......<0.
        ......
        00b0 - a1 c5 12 34 1c 88 6f 05-81 4a 1b 61 67 ae 2e 1f   ...4..o..J.ag...
    
        Start Time: 17......67
        Timeout   : 7200 (sec)
        Verify return code: 0 (ok)
        Extended master secret: no
        Max Early Data: 0
    ---
    read R BLOCK
    ---
    Post-Handshake New Session Ticket arrived:
    SSL-Session:
        Protocol  : TLSv1.3
        Cipher    : TLS_AES_256_GCM_SHA384
        Session-ID: 56AF......203A
        Session-ID-ctx:
        Resumption PSK: 0A45......2AEA
        PSK identity: None
        PSK identity hint: None
        SRP username: None
        TLS session ticket lifetime hint: 7200 (seconds)
        TLS session ticket:
        0000 - 43 07 31 f3 a5 0c 52 88-e3 fc c4 96 c1 3c 30 a3   C.1...R......<0.
        ......
        00b0 - 99 06 be 74 88 48 a9 7f-09 73 6a 44 9f 1d e0 d2   ...t.H...sjD....
    
        Start Time: 1765537767
        Timeout   : 7200 (sec)
        Verify return code: 0 (ok)
        Extended master secret: no
        Max Early Data: 0
    ---
    read R BLOCK
    220 DirectMail Smtpd Server(127.0.0.1)Code language: Shell Session (shell)

    可以看到远端 SMTP 服务器配置没有问题,证书链是完整的,而且也都通过了验证,那问题应该是出在我们这边的 Web 服务器。

    WordPress 部署在阿里云虚拟主机上,推测是 CA 根证书配置有问题,导致无法验证 SMTP 服务器证书。

    首先查看 phpinfo,如下图:

    阿里云虚拟主机phpinfo 显示 openssl.cafile 和 openssl.capath 都是空的

    可以看到 openssl.cafileopenssl.capath 都是空的,这是 PHP 默认值。

    尝试添加CA证书。Mozilla 在 wiki 页面上给出了 CA 根证书 Store,我这里下载 PEM of Root Certificates in Mozilla’s Root Store with the Websites (TLS/SSL) Trust Bit Enabled (TXT),命名为 ca-certificates.crt

    把从 Mozilla 下载的根证书 Store 上传到服务器上,然后修改 php.ini.user.ini 文件,设置 openssl.cafile 的值为 CA 根证书 Store 路径。

    比如我把下载的 CA 根证书文件命名为 ca-certificates.crt,放在 /usr/home/ 目录下,那么 CA 根证书 Store 的路径就是,/usr/home/ca-certificates.crt,在 php.ini.user.ini 文件加入下面这行即可:

    openssl.cafile = "/usr/home/ca-certificates.crt"Code language: plaintext (plaintext)

    最后再次测试,邮件发送成功,邮箱也收到了邮件,问题解决。

    参考资料:

  • WordPress 修改固定链接 nginx 报 404?正确配置伪静态重写规则

    在使用 nginx 作为 Web 服务器的时候,如果 WordPress 设置了自定义链接格式,那么需要修改 nginx 的配置文件来处理链接重写,不然会出现 404 错误。要将配置文件中默认的规则:

    location / {
      try_files $uri $uri/ =404;
    }Code language: Nginx (nginx)

    修改为:

    location / {
      try_files $uri $uri/ /index.php?$args;
    }Code language: Nginx (nginx)

    解释

    原来的配置文件中,nginx 首先查找是否有 $uri 这个文件,如果没有,再查找是否有 $uri/ 目录,如果仍然没有,就返回 404 Not Found。新的配置文件将最后 =404 部分进行了修改,如果没有找到 $uri 文件也没有找到 $uri/ 目录,就把这个请求重写到 index.php 去处理,同时带上 $args 参数。当请求到 index.php 时,PHP 就开始接手处理了。

    为什么 index.php?$args 而不是 index.php?$uri 呢?这是因为 $uri 不会保留参数。那如果只传 $args,WordPress 又是怎么知道 URI 的呢?WordPress 可以通过 $_SERVER 变量获取到 URI。具体的解析处理比较复杂,但 PHP 程序是可以获取到 URI 的。所以,$args 一定要从 nginx 这里传过去,不能丢了,在这里把 $args 丢了 PHP 后面就获取不到了,而无论这里有没有显式地传 $uri,nginx 都会把 URI 传给 PHP。

  • 阿里云虚拟主机禁用 sleep 函数?fatal error 报错解决方法

    我的好几个 WordPress 插件都因为这个函数的缺失导致 fatal error。

    Fatal error: Uncaught Error: Call to undefined function sleep() in /usr/home/htdocs/wp-content/plugins/wp-super-cache/partials/easy.php:79 Stack trace: #0 /usr/home/htdocs/wp-content/plugins/wp-super-cache/wp-cache.php(4423): include() #1 /usr/home/htdocs/wp-content/plugins/wp-super-cache/wp-cache.php(1311): wpsc_render_partial('partials/easy.p...', Array) #2 /usr/home/htdocs/wp-includes/class-wp-hook.php(341): wp_cache_manager('') #3 /usr/home/htdocs/wp-includes/class-wp-hook.php(365): WP_Hook->apply_filters('', Array) #4 /usr/home/htdocs/wp-includes/plugin.php(522): WP_Hook->do_action(Array) #5 /usr/home/htdocs/wp-admin/admin.php(264): do_action('settings_page_w...') #6 /usr/home/htdocs/wp-admin/options-general.php(10): require_once('/usr/home/wh-aa...') #7 {main} thrown in /usr/home/htdocs/wp-content/plugins/wp-super-cache/partials/easy.php on line 79Code language: Access log (accesslog)

    看了 phpinfo() 发现 sleep 函数被禁用了,我没想到这个无害的函数会被禁用,估计插件作者也不会想到这个函数会被禁用吧。

    phpinfo() 显示的 disable_functions

    从图中可以看出,阿里云虚拟主机禁用的 PHP 函数如下:

    exec,system,passthru,shell_exec,escapeshellarg,escapeshellcmd,proc_close,proc_open,ini_alter,dl,popen,pcntl_exec,socket_accept,socket_bind,socket_clear_error,socket_close,socket_connect,socket_create_listen,socket_create_pair,socket_create,socket_get_option,socket_getpeername,socket_getsockname,socket_last_error,socket_listen,socket_read,socket_recv,socket_recvfrom,socket_select,socket_send,socket_sendto,socket_set_block,socket_set_nonblock,socket_set_option,socket_shutdown,socket_strerror,socket_write,stream_socket_client,stream_socket_server,pfsockopen,disk_total_space,disk_free_space,chown,diskfreespace,getrusage,get_current_user,getmyuid,getmypid,dl,leak,listen,chgrp,link,symlink,dlopen,proc_nice,proc_get_stats,proc_terminate,shell_exec,sh2_exec,posix_getpwuid,posix_getgrgid,posix_kill,ini_restore,mkfifo,dbmopen,dbase_open,filepro,filepro_rowcount,posix_mkfifo,putenv,sleepCode language: plaintext (plaintext)

    这里大部分函数被禁用是出于安全考虑,比如文件操作、系统命令执行、进程控制、网络操作等函数被禁用,这是是可以理解的,但是 sleep 函数也被禁用实在让人想不通。

    我首先想到的应对措施是能不能用 .user.ini 文件重新启用 sleep 函数。但是根据 PHP 官方文档的说明,disable_functions 只能在全局 php.ini 文件中设置,.user.ini 文件中无效。

    不过文档也说:As of PHP 8.0.0, disabling a function removes its definition, allowing userland to redefine it.

    也就是说在 PHP 8.0 及以上版本中,禁用的函数可以在用户代码中重新定义。所以我自己模拟了一个 sleep 函数,代码如下:

    <?php
    if (!function_exists('sleep')) {
        function sleep($seconds) {
            $start = time();
            while (time() - $start < $seconds);
            return 0;
        }
    }Code language: PHP (php)

    将这段代码保存为 sleep.php,然后在 .user.ini 文件中添加 auto_prepend_file 配置指向这个 sleep.php 文件路径,比如:

    auto_prepend_file = "/usr/home/sleep.php"Code language: plaintext (plaintext)

    这样每次PHP脚本运行时,都会先包含这个 sleep.php 文件,从而定义 sleep 函数,避免 fatal error。

    阿里云这种做法真是让人摸不着头脑,按理说禁用函数是为了安全性,把不安全的函数禁用掉是可以理解的,但一个 sleep 函数能造成什么安全隐患呢?这是最安全的函数之一了吧,毕竟它的功能就是等待一段时间,什么都不执行。

    我猜测,阿里云这么做是出于性能/经济考虑,禁用这个函数试图减少虚拟主机用户占用的资源,但这种做法对谁都没好处。对用户来说,用户如果要用到这个函数,只能自己模拟,不然就等着程序 fatal error。对阿里云来说,模拟函数的开销肯定比原生函数的开销大,本来想着“降本增效”,最后反倒可能浪费更多资源。

    参考资料:

  • diff 比较文件忽略换行符差异

    用 diff 比较两个文本文件时标出了大量差异,但人工检查可以看到这些标出差异的地方实际上是相同的文字,只是一个文件以 CRLF 换行另一个文件以 LF 换行。

    要忽略这种差异,可以在调用 diff 时加上 –strip-trailing-cr 参数。这个参数会让 diff 先暂时去掉行尾的 CR 字符再进行比较。

    diff --strip-trailing-cr text1.txt text2.txtCode language: Shell Session (shell)

  • Chrome 修改请求 Header

    Chrome 浏览器自身没有提供修改请求 Header 的方法,笨办法是在开发者工具->网络中找到要修改的请求,复制为 fetch,然后手动修改请求头。如果没有办法安装浏览器扩展或者不想安装扩展,只能用这个笨办法,但是这样效率太低了。

    另一个办法是安装 Chrome 扩展来实现对请求头的修改,有很多扩展可以实现这一功能,比如:Requestly

  • WordPress 输出 esc_html 显示换行

    textarea 提交的内容可能存在换行,使用 WordPress 的 sanitize_textarea_field 函数清理能保留换行符,并且正常存储到数据库中,但是输出时会遇到问题:输出到 HTML 时换行不会在页面上显示出来,因为 HTML 代码里的换行符会按照空格显示,要用 HTML 显示换行需要用
    标签。

    可以使用 PHP 的 nl2br 函数输出换行,这个函数会在 \n 前添加
    标签。和 WordPress 的 esc_html 一起使用即可将换行显示出来:

    echo nl2br( esc_html( $value ) );Code language: PHP (php)

    参考资料:

  • 使用 CDN 之后正确获取访客真实 IP 的方法

    网络上大多数教程给出的方法都是通过修改程序代码,直接取 X-Forwarded-For 等请求标头的值替换原来 REMOTE_ADDR 变量的值作为原 IP,这种做法有很大的安全隐患

    安全隐患

    根据 RFC 3875 文件,REMOTE_ADDR 变量的值必须为发送请求到服务器的客户端的网络地址。在实践中 REMOTE_ADDR 变量的值往往由服务器直接设置为发送请求的客户端网络地址,而不是由客户端提供,因此其真实性有保证。

    与 REMOTE_ADDR 变量不同,X-Forwarded-For 请求标头的值不是由服务器直接决定,可以由客户端自行设置,实践中一般由代理服务器在转发请求时设置。因为 X-Forwarded-For 请求标头的值可能被客户端伪造,所以不应该轻易相信其真实性

    由于 REMOTE_ADDR 变量的值和 X-Forwarded-For 请求标头的值具有不同的可信度,使用低可信度 X-Forwarded-For 请求标头的值去替换高可信度 REMOTE_ADDR 变量的值便会产生不可忽视的安全隐患。比如,攻击者可以通过伪造 X-Forwarded-For 请求标头来绕过基于 IP 的频率限制、访问控制。更进一步,攻击者还可以构造具有危害性 X-Forwarded-For 请求标头来对服务器进行多种攻击,例如注入恶意代码进行 XSS 攻击就是一种可能的行为。

    正确做法

    在使用 X-Forwarded-For 请求标头的值之前应该确保其内容可信。因为 X-Forwarded-For 请求标头一般由代理服务器添加,所以至少应该先确认请求来自可信的代理服务器才可使用 X-Forwarded-For 标头的值。

    对于使用 CDN 的网站,如果服务器是 nginx,那么在 ngx_http_realip_module 模块的帮助下这一过程将会十分简单。只需编辑相应的 nginx 配置文件,在其中的 http、server 或 location 段,使用 set_real_ip_from 指定可信代理服务器地址(即 CDN 节点的地址),然后使用 real_ip_header 指明一个请求标头(即 X-Forwarded-For 标头),保存并重载 nginx 使配置生效。这样 nginx 收到请求后会检查请求的 REMOTE_ADDR 是否与 set_real_ip_from 相符,如果相符,nginx 会用 real_ip_header 对应标头的值替换 REMOTE_ADDR 原有的值,后端程序只需要正常使用 REMOTE_ADDR 的值即可。这种做法一般不需要改变后端程序。

    参考资料