Wikipedia:反向代理进阶

一、概述

对维基百科做反向代理是比较有挑战的。一方面,维基百科的服务并非由单一域名提供,而是一组域名,例如中文维基百科的网址是zh.wikipedia.org,日文的则是jp.wikipedia.org;手机访问维基百科,还会自动切换到m.wikipedia.org。页面的插图等,还会用到upload.wikimedia.org。另一方面,维基百科的页面大量依赖javascript、css,有一些硬编码为wikipedia.org域名的地址,需要对页面的内容进行替换。

为此需要解决两个关键问题:

1、通配符DNS解析,以及为通配符域名申请SSL证书。

2、需要对nginx进行手动编译,以添加第三方模块ngx_http_substitutions_filter_module,用于实现高级的页面内容替换功能。

二、手动编译nginx

在手动编译nginx之前,强烈建议首先卸载已经安装的nginx,否则在加载模块上会出现混乱和版本问题。当然,有条件的话,在全新系统上安装是最干净的。

1、第三方模块的配置参数

截至2023年12月3日,ubuntu的官方仓库中的nginx版本仍然停留在1.18.0。如果我们还是用1.18.0的源代码来编译nginx,会发现与OpenSSL 3.0存在兼容问题,会影响后面的编译(虽然只是警告信息,但觉的不爽)。所以建议还是用nginx官方的最新版本(目前是1.24.0)。此外,ubuntu官方仓库中的nginx默认附加了第三方模块“http-geoip2”,而nginx官方的源代码里没有这个模块。这个模块用于根据IP识别地理信息,如果暂时用不到,可以不附加这个模块即可。

ubuntu官方仓库中的nginx默认安装的模块,在已经安装nginx的机器上执行nginx -V可以看到:

--with-cc-opt='-g -O2 -ffile-prefix-map=/build/nginx-zctdR4/nginx-1.18.0=. -flto=auto -ffat-lto-objects -flto=auto -ffat-lto-objects -fstack-protector-strong -Wformat -Werror=format-security -fPIC -Wdate-time -D_FORTIFY_SOURCE=2' --with-ld-opt='-Wl,-Bsymbolic-functions -flto=auto -ffat-lto-objects -flto=auto -Wl,-z,relro -Wl,-z,now -fPIC' --prefix=/usr/share/nginx --conf-path=/etc/nginx/nginx.conf --http-log-path=/var/log/nginx/access.log --error-log-path=/var/log/nginx/error.log --lock-path=/var/lock/nginx.lock --pid-path=/run/nginx.pid --modules-path=/usr/lib/nginx/modules --http-client-body-temp-path=/var/lib/nginx/body --http-fastcgi-temp-path=/var/lib/nginx/fastcgi --http-proxy-temp-path=/var/lib/nginx/proxy --http-scgi-temp-path=/var/lib/nginx/scgi --http-uwsgi-temp-path=/var/lib/nginx/uwsgi --with-compat --with-debug --with-pcre-jit --with-http_ssl_module --with-http_stub_status_module --with-http_realip_module --with-http_auth_request_module --with-http_v2_module --with-http_dav_module --with-http_slice_module --with-threads --add-dynamic-module=/build/nginx-zctdR4/nginx-1.18.0/debian/modules/http-geoip2 --with-http_addition_module --with-http_gunzip_module --with-http_gzip_static_module --with-http_sub_module

红色部分,就是附加的第三方模块http-geoip2。我们要做的,就是去掉它,并且再添加一个ngx_http_substitutions_filter_module。

言归正传,下面正式开始。

2、编译nginx

首先卸载已经存在的nginx(从ubuntu官方仓库先安装一遍可以查看它的默认安装配置,就是上面那一大串)。

安装编译需要的工具:

sudo apt update
sudo apt install build-essential libpcre3 libpcre3-dev zlib1g zlib1g-dev libssl-dev

到nginx官方页面查看当前最新的nginx版本,目前是1.24.0。下载nginx源代码并解压缩它:

wget http://nginx.org/download/nginx-1.24.0.tar.gz
tar -zxvf nginx-1.24.0.tar.gz

然后下载ngx_http_substitutions_filter_module的源代码:

git clone https://github.com/yaoweibin/ngx_http_substitutions_filter_module.git

此时,在当前用户目录下已经出现了两个新的目录:nginx-1.24.0、ngx_http_substitutions_filter_module。进入nginx-1.24.0目录。

cd nginx-1.24.0

用./configure来配置编译选项:

./configure --with-cc-opt='-g -O2 -ffile-prefix-map=/build/nginx-zctdR4/nginx-1.18.0=. -flto=auto -ffat-lto-objects -flto=auto -ffat-lto-objects -fstack-protector-strong -Wformat -Werror=format-security -fPIC -Wdate-time -D_FORTIFY_SOURCE=2' --with-ld-opt='-Wl,-Bsymbolic-functions -flto=auto -ffat-lto-objects -flto=auto -Wl,-z,relro -Wl,-z,now -fPIC' --prefix=/usr/share/nginx --conf-path=/etc/nginx/nginx.conf --http-log-path=/var/log/nginx/access.log --error-log-path=/var/log/nginx/error.log --lock-path=/var/lock/nginx.lock --pid-path=/run/nginx.pid --modules-path=/usr/lib/nginx/modules --http-client-body-temp-path=/var/lib/nginx/body --http-fastcgi-temp-path=/var/lib/nginx/fastcgi --http-proxy-temp-path=/var/lib/nginx/proxy --http-scgi-temp-path=/var/lib/nginx/scgi --http-uwsgi-temp-path=/var/lib/nginx/uwsgi --with-compat --with-debug --with-pcre-jit --with-http_ssl_module --with-http_stub_status_module --with-http_realip_module --with-http_auth_request_module --with-http_v2_module --with-http_dav_module --with-http_slice_module --with-threads --with-http_addition_module --with-http_gunzip_module --with-http_gzip_static_module --with-http_sub_module --add-module=../ngx_http_substitutions_filter_module

注意,已经删除了http-geoip2,并添加了ngx_http_substitutions_filter_module。这里用../返回上一级目录,再进入ngx_http_substitutions_filter_module,明确告诉编译器第三方模块的地址。

configure将逐个检查编译模块的可用性,如果出现错误,建议检查配置中涉及到的路径是否正确。仅当configure命令执行没有错误,方可进行下一步。

在nginx-1.24-0目录中,依次执行:

make
sudo make install

3、配置nginx

用apt install通过ubuntu官方仓库安装nginx时,它贴心地设置了全局PATH(让你在任意目录都可以直接执行nginx),设置了开机自启,设置了site-enabled和site-avaliable目录。但手动编译安装就没有这些福利了,需要我们自己来设置。

设置开机自启

首先明确nginx的安装路径。通常来说,使用apt install安装的nginx,路径位于/usr/sbin/nginx,且会在/usr/lib/systemd/system/路径下添加一个nginx.service,以实现开机自启。而手动编译安装的nginx通常位于/usr/share/nginx/sbin/目录下。同样地,我们可以在/etc/systemd/system/路径(或/usr/lib/systemd/system/)下创建nginx.service文件,内容如下:

[Unit]
Description=Nginx
After=network.target

[Service]
Type=forking
ExecStart=/usr/share/nginx/sbin/nginx
ExecReload=/usr/share/nginx/sbin/nginx -s reload
ExecStop=/usr/share/nginx/sbin/nginx -s quit
PrivateTmp=true

[Install]
WantedBy=multi-user.target

然后,更新systemd配置,设置开机启动,运行nginx服务,并查看其运行状态:

sudo systemctl daemon-reload
sudo systemctl enable nginx
sudo systemctl start nginx
sudo systemctl status nginx

注意,此时可能会出现一个错误,指出/var/lib/nginx/body目录不存在。我们可能需要手动创建以下目录:

sudo mkdir -p /var/lib/nginx/body
sudo mkdir -p /var/lib/nginx/proxy
sudo mkdir -p /var/lib/nginx/fastcgi

设置目录权限:

sudo chown -R www-data:www-data /var/lib/nginx
sudo chmod -R 755 /var/lib/nginx

注意,www-data是nginx执行的默认用户,在安装nginx时会自动添加到系统中。

之后再尝试启动nginx。

设置全局PATH

定位到当前用户根目录,打开.bashrc文件,末尾添加一行:

export PATH=$PATH:/usr/share/nginx/sbin

保存退出,然后source一下以生效:

source ~/.bashrc

此时已经可以在任意目录执行nginx命令了。

构建配置文件存储结构

nginx的配置文件通常位于/etc/nginx/目录。手动在该目录中创建两个子目录:sites-enabled、sites-avaliable,然后编辑nginx.conf,在http块中最后添加一行:

include /etc/nginx/sites-enabled/*;

这就包含了sites-enabled中的所有配置文件。未来每个网站在sites-enabled中放一个对应的配置文件即可,临时不用的就剪切到sites-avaliable目录中。

可以用nginx -T命令查看当前nginx所有详细配置情况。用nginx -t检查配置文件是否合法。

一切顺利的情况下,nginx -s reload以重新加载配置。

三、通配符SSL证书

好了,现在已经安装最新的nginx,并且包含了第三方模块ngx_http_substitutions_filter_module。第二项准备工作是准备好域名解析和SSL证书。

假设我们用于反代的域名是site.com,并且用wiki作为二级前缀。一共需要准备3个域名解析:

wiki.site.com,用来匹配入口www.wikipedia.org

*.wiki.site.com,用来匹配不同语言站,例如zh.wikipedia.org

*.m.wiki.site.com,同上,加上m用来匹配手机访问站m.wikipedia.org。

所有解析地址均为反代服务器地址。

安装certbot:

sudo apt install --classic certbot

对于单个域名,通常可以直接使用certbox --nginx -d a.site.com来配置它,这时候certbot只需要检测a.site.com是否运行在当前服务器的80端口即可通过验证。但是,对于通配符域名的SSL证书,certbot仅支持DNS-01方式,也就是需要直接操作DNS解析才能通过ACME挑战。这就需要DNS托管商提供API了。

以DNS托管在cloudflare为例。cloudflare提供了可支持certbot的API。首先安装certbot的cloudflare插件:

apt install python3-certbot-dns-cloudflare

打开cloudflare账户的个人资料页面,点击左侧API Tokens,找到Global API key,查看它,并保存之。在服务器上任意地址(例如/var/apikeys/)新建一个cloudflare.ini文件:

dns_cloudflare_email = cloudflare帐号邮箱地址
dns_cloudflare_api_key = GLOBAL KEY

然后,就可以用certbot命令来申请SSL证书了:

certbot certonly \
 --dns-cloudflare \
 --dns-cloudflare-credentials /var/apikeys/cloudflare.ini \
-d 'wiki.site.com' \
-d '*.wiki.site.com' \
-d '*.m.wiki.site.com' 
--dns-cloudflare-propagation-seconds 30

默认情况下,certbot隐含了参数--dns-digitalocean-propagation-seconds 10,也就是传播延迟10秒。有时候10秒太短,DNS来不及传播导致证书申请失败。那么可以在certbot命令中增加设定参数:--dns-cloudflare-propagation-seconds 30即可。

证书申请成功后,会提示证书存储路径,通常位于/etc/letsencrypt/live/wiki.site.com/,关键的两个文件是fullchain.pem和privkey.pem。

小贴士1:通配符的管辖范围
必须注意到,域名通配符只能匹配一层,不能匹配多层,也不能匹配本身(上层)。
例如,*.wiki.site.com不能匹配wiki.site.com,也不能匹配a.m.wiki.site.com,只可以匹配a.wiki.site.com。
小贴士2:错误的记忆-Chrome的HSTS设置
Chrome如果以https成功访问过一个网址,后面为了安全,它就不再会用http再去访问了,即使你手动使用http也不行,颇有曾经沧海难为水的感觉。但有时候,我们可能会清除网站的ssl信息,在测试阶段又返回到http。Chrome的这个特性就会让人误以为是网络出了问题,其实是它的HSTS设置在发挥作用。
解决方案:
1、地址栏输入chrome://net-internals/#hsts
2、找到Delete domain security policies
3、填入要清除HSTS设置的域名,点击Delete

除此之外,更简便的方法是,永远使用Chrome的“无痕模式”来测试新的网站。

四、设置反向代理

有了前面的准备工作,这一步就相对轻松,唯一需要重点处理的就是页面内容替换(subs_filter)了。在nginx配置中,我们一共需要4个server块,分别对应主站、upload站、各语言站、手机站。

维基百科的搜索框有根据输入内容自动推荐关键词的功能,这个功能是AJAX异步加载的,维基百科非常贴心地把域名做成了变量(portalSearchDomain),变量值为'wikipedia.org',替换这个值时务必带上单引号。

为什么要强调包括单引号?为什么不能直接简单粗暴把wikipedia.org全部替换成wiki.site.com?因为维基百科站点存在名叫wikipedia.org的子目录,例如源代码中的“portal/wikipedia.org/assets”,它原本对应的完整的URL是:“https://www.wikipedia.org/portal/wikipedia.org/assets”,第一个域名我们用反向代理替换了;第二个wikipedia.org实际上是一个目录名称,显然不能直接替换。

这就是为什么要使用一系列复杂的subs_filter进行手术刀式的操作的原因。

1、主站

server {
    server_name  wiki.site.com;
    listen 80;
    listen 443 ssl http2;
#强制google DNS,防止域名污染
    resolver 8.8.8.8;
    ssl_certificate      /etc/letsencrypt/live/wiki.site.com/fullchain.pem;
    ssl_certificate_key  /etc/letsencrypt/live/wiki.site.com/privkey.pem;
#http转https    
    if ($http_x_forwarded_proto = 'http')
    {
        return 301 $server_name$request_uri;
    }

    location / {
        proxy_pass https://www.wikipedia.org;
        proxy_buffering off;
#让潜在的跳转跳转到正确代理地址
        proxy_redirect https://www.wikipedia.org/ https://wiki.site.com/;
#更换cookie域名
        proxy_cookie_domain www.wikipedia.org wiki.site.com;
        proxy_redirect ~^https://([\w\.]+).wikipedia.org/(.*?)$ https://$1.wiki.site.com/$2;

        proxy_set_header X-Real_IP $remote_addr;
        proxy_set_header User-Agent $http_user_agent;
#在subs_filter之前,处理压缩后的数据,否则无法进行明文替换
        proxy_set_header Accept-Encoding '';
        proxy_set_header referer "https://$proxy_host$request_uri";
#指定替换的多种类型,仅第三方模块支持
        subs_filter_types text/css text/xml text/javascript application/javascript application/json;
        subs_filter .wikipedia.org .wiki.site.com;
#替换portalSearchDomain变量,连单引号一起防止错误替换
        subs_filter \'wikipedia.org\' \'wiki.site.com\';
        subs_filter //wikipedia.org //wiki.site.com;
        subs_filter upload.wikimedia.org up.wiki.site.com;
    }
}

2、upload站

server {
    server_name up.wiki.site.com;
    listen 80;
    listen 443 ssl http2;
    
    ssl_certificate      /etc/letsencrypt/live/wiki.site.com/fullchain.pem;
    ssl_certificate_key  /etc/letsencrypt/live/wiki.site.com/privkey.pem;
    
    if ($http_x_forwarded_proto = 'http')
    {
        return 301 $server_name$request_uri;
    }

    location / {
        proxy_pass https://upload.wikimedia.org;
        proxy_cookie_domain upload.wikimedia.org up.wiki.site.com;
        proxy_buffering off;
        proxy_set_header X-Real_IP $remote_addr;
        proxy_set_header User-Agent $http_user_agent;
        proxy_set_header referer "https://upload.wikimedia.org$request_uri";
    }
}

3、各语言站

server {
#匹配*.wiki.site.com,并将前缀装入subdomain变量
    server_name  ~^(?<subdomain>[^.]+)\.wiki\.site\.com$;
    listen 80;
    listen 443 ssl http2;
    resolver 8.8.8.8;
    ssl_certificate      /etc/letsencrypt/live/wiki.site.com/fullchain.pem;
    ssl_certificate_key  /etc/letsencrypt/live/wiki.site.com/privkey.pem;
    
    if ($http_x_forwarded_proto = 'http')
    {
        return 301 $subdomain.wiki.site.com$request_uri;
    }

    location / {
        proxy_pass https://$subdomain.wikipedia.org;
        proxy_buffering off;

        proxy_redirect https://$subdomain.wikipedia.org/ https://$subdomain.wiki.site.com/;
        proxy_redirect https://$subdomain.m.wikipedia.org/ https://$subdomain.m.wiki.site.com/;
        proxy_cookie_domain $subdomain.wikipedia.org $subdomain.wiki.site.com;
        proxy_redirect ~^https://([\w\.]+).wikipedia.org/(.*?)$ https://$1.wiki.site.com/$2;

        proxy_set_header X-Real_IP $remote_addr;
        proxy_set_header User-Agent $http_user_agent;
        proxy_set_header Accept-Encoding ''; 
        proxy_set_header referer "https://$proxy_host$request_uri";

        subs_filter_types text/css text/xml text/javascript application/javascript application/json;
        subs_filter .wikipedia.org .wiki.site.com;
        subs_filter //wikipedia.org //wiki.site.com;
        subs_filter 'https://([^.]+).wiki' 'https://$1.wiki' igr; 
#避免潜在的混合内容错误,将http子站引用替换为https,如果有问题可删
	subs_filter 'http://([^.]+\.)?wikimedia\.org' 'https://$1wikimedia.org' igr;
        subs_filter upload.wikimedia.org up.wiki.site.com;
    }
}

4、手机站

server {
    server_name ~^(?<subdomain>[^.]+)\.m\.wiki\.site\.com$;
    listen 80;
    listen 443 ssl http2;
    resolver 8.8.8.8;
    ssl_certificate      /etc/letsencrypt/live/wiki.site.com/fullchain.pem;
    ssl_certificate_key  /etc/letsencrypt/live/wiki.site.com/privkey.pem;
    
    if ($http_x_forwarded_proto = 'http')
    {
        return 301 $subdomain.m.wiki.site.com$request_uri;
    }

    location / {
        proxy_pass https://$subdomain.m.wikipedia.org;
        proxy_buffering off;

        proxy_redirect https://$subdomain.m.wikipedia.org/ https://$subdomain.m.wiki.site.com/;
        proxy_cookie_domain $subdomain.m.wikipedia.org $subdomain.m.wiki.site.com;

        proxy_set_header X-Real_IP $remote_addr;
        proxy_set_header User-Agent $http_user_agent;
        proxy_set_header Accept-Encoding ''; 
        proxy_set_header referer "https://$proxy_host$request_uri";

        subs_filter_types text/css text/xml text/javascript application/javascript application/json;
        subs_filter .wikipedia.org .wiki.site.com;
#替换portalSearchDomain变量,连单引号一起防止错误替换
        subs_filter \'wikipedia.org\' \'wiki.site.com\';
        subs_filter //wikipedia.org //wiki.site.com;
        subs_filter 'https://([^.]+).m.wiki' 'https://$1.m.wiki' igr; 
#同上
	subs_filter 'http://([^.]+\.)?wikimedia\.org' 'https://$1wikimedia.org' igr;
        subs_filter upload.wikimedia.org up.wiki.site.com;
    }
}
小贴士3:简悦插件冲突
Chrome浏览器的简悦插件可能会导致反向代理站出现显示错误。具体表现为:维基百科页面可以显示,但中间的主体内容一闪而过。查看源文件内容都在,就是不显示。估计是因为subs_filter影响了简悦的javascript脚本运行。
解决办法:右键点击简悦插件图标,进入“选项”->“高级设定”,在黑名单中将wiki.site.com加进去即可。
当然,直接关闭简悦插件也行。