前阵子我们在用的Newsletter服务Revue宣布要关闭了:
我们只能寻找其他替代品。在平台还是自建两者之间,最后还是选择了自建,我曾经试过用Wordpress,但确实这套古老的系统多年没有特别大的进化,后来Newsletter编辑部的伙伴提议用Ghost,看上去确实不错。
Ghost项目是由前Wordpress UI团队负责人John O'Nolan离职后创办的。2012年他在启动项目的Blog上说道:
WordPress is so much more than just a blogging platform
我只能说Can't agree no more.
Ghost作为一个平台使用起来非常简单,设计感非常棒,当然官方host的价格也很喜人。好在Ghost是开源的项目,愿意动手折腾的也可以鼓捣起来。
周末鼓捣Ghost的时候就发现,它在setup完https证书之后就会无限301 redirect loop,最终问题是解决了但我想搞清楚到底是怎么回事。
- 怀疑是Nginx开启了无限循环
先从Nginx配置下手。一个完整的Nginx Conf实例可以参考官网这里,理论上我们配置多个不同的server时,listen在80端口(http)和443端口(https),然后对80的server配置做host判断,直接全量转发到https。
server {
if ($host = example.com) {
return 301 https://$host$request_uri;
} # managed by Certbot
listen 80;
listen [::]:80;
server_name example.com;
return 404; # managed by Certbot
}
现在有certbot配置起来很方便。我看了半天没发现Nginx有错配的情况。
最后看这篇文章提到需要增加这个配置:
proxy_set_header X-Forwarded-Proto https;
文章还提到Cloudflare的SSL规则也有可能导致无限301但我那时候域名已经从Cloudflare DNS转出了,所以跟Cloudflare无关。
Ghost使用NodeJS写的,默认运行端口是2368,我们在Nginx接入之后肯定要把请求转发的,官方的Ghost-CLI 在setup后却没有这一条:
location / {
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme; #自己补上
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header Host $http_host;
proxy_pass http://127.0.0.1:2368;
}
参考Nginx官方文档相当于我们对入站的请求重定义了HTTP HEADER,其中 X-Forwarded-Proto 也是一个“事实标准”Header,可见Mozilla文档。文档称该Header主要用于客户端告知用于连接到代理的协议是用HTTP还是HTTPS。真正的标准详见Forwarded字段。
- 为什么必须带X-Forwarded-Proto Header?
既然不是Nginx导致的301 redirect,那就是Ghost咯?
让我们翻一下Ghost的代码看看。原来Ghost用的ExpressJS做Server,其中 ghost/core/core/shared/express.js 的代码有这样一段:
// Make sure 'req.secure' is valid for proxied requests
// (X-Forwarded-Proto header will be checked, if present)
app.enable('trust proxy');
好的现在压力给到了Express.js这边,我们看看express的代码,这个'trust proxy'都干了些啥。
在lib/application.js里面,app.set这个方法针对trust proxy写入配置:
switch (setting) {
case 'etag':
this.set('etag fn', compileETag(val));
break;
case 'query parser':
this.set('query parser fn', compileQueryParser(val));
break;
case 'trust proxy':
this.set('trust proxy fn', compileTrust(val));
// trust proxy inherit back-compat
Object.defineProperty(this.settings, trustProxyDefaultSymbol, {
configurable: true,
value: false
});
break;
}
其中compileTrust()方法里面又是通过proxy-addr完成的,项目在这里,
这一步是把IP地址描述进行翻译,比如:
app.set('trust proxy', 'loopback') // 一个子网描述
app.set('trust proxy', 'loopback, 123.123.123.123') // 一个子网加一个IP
app.set('trust proxy', 'loopback, linklocal, uniquelocal') // 多个子网
app.set('trust proxy', ['loopback', 'linklocal', 'uniquelocal']) // 多个子网
后面这部分参数就会被compile()函数处理。Ghost的代码里:
app.enable('trust proxy');
// 等价于如下代码
app.set('trust proxy', true);
所以后面的参数为空,说明 trust everything。因为express是被代理过来的,所以后续获得客户端请求的真实IP之类的信息还得靠proxyaddr来解析。
所以不仅需要X-Forwarded-Proto,事实上,Ghost-Cli的Nginx配置模板是配齐了几个关键字段的:
location {
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header Host $http_host;
proxy_pass http://127.0.0.1:;
proxy_redirect off;
}
- Ghost是怎么把http请求转发给https的?
Ghost的配置文件使用nconf管理,当我们在Ghost目录下配置http协议的URL时不会有无限redirect的问题,但是https的会。
即:如果你配置了网站url为https://example.com,但是访问了http://example.com,Ghost会自动帮你redirect到https。
具体实现在ghost/core/core/server/web/shared/middleware/url-redirects.js,核心函数是这个:
/**
* Takes care of
*
* 1. required SSL redirects
*/
_private.getFrontendRedirectUrl = ({requestedHost, requestedUrl, queryParameters, secure}) => {
const siteUrl = urlUtils.urlFor('home', true);
debug('getFrontendRedirectUrl', requestedHost, requestedUrl, siteUrl);
// CASE: configured canonical url is HTTPS, but request is HTTP, redirect to requested host + SSL
if (urlUtils.isSSL(siteUrl) && !secure) {
debug('redirect because protocol does not match');
return _private.redirectUrl({
redirectTo: https://${requestedHost},
pathname: requestedUrl,
query: queryParameters
});
}
};
Ghost有给用户看的前端页面(FrontendApp)和给管理员用的后端管理页面(AdminApp),前端页面启动的时候也是启动一个Express Server,然后使用上述middleware,检查每一次请求是不是secure。这个secure与否来自req.secure,而这个req.secure在启动了trust proxy的情况下,会取Header里的X-Forwarded-Proto作为依据。代码在Express项目的lib/request.js文件里,defineGettter里关于protocol和secure这两个函数。
defineGetter(req, 'protocol', function protocol(){
var proto = this.connection.encrypted
? 'https'
: 'http';
var trust = this.app.get('trust proxy fn');
if (!trust(this.connection.remoteAddress, 0)) {
return proto;
}
// Note: X-Forwarded-Proto is normally only ever a
// single value, but this is to be safe.
var header = this.get('X-Forwarded-Proto') || proto
var index = header.indexOf(',')
return index !== -1
? header.substring(0, index).trim()
: header.trim()
});
从上述代码可知,express会先检查this.connection.encrypted
属性,我猜Nginx的转发是不加密的,因为本地express server只是打开了一个http服务,Nginx转发给它确实也不需要加密。于是就会跳到取Header这一步。所以如果我们的Nginx配置不带X-Forwarded-Proto,express就会认为客户端真实请求不是https,于是redirect给https,结果就是无限循环。
- What's next?
虽然Ghost这个问题解决起来很简单,“改个配置就好啦”。但是经过层层追踪,查看各个项目代码的过程也很有意思。在这篇文章里,作者有提到x-forwarded-proto会被Ghost检查,但是既然整个服务涉及Nginx,Ghost,Express,我就想弄明白到底问题出在什么地方。
通过这次追查也初窥了Ghost项目的大致架构,过程还是挺有趣的,不过看起来Ghost还不是特别开放,毕竟平台hosting才是这个项目最赚钱的部分。Wordpress这么多年,现在已经变成一个颇具历史包袱的巨无霸,这些年各种“替代开源CMS”也层出不穷,但是也起起落落落,消失了不少。看到Ghost现在能赚钱我感觉还是很棒的,唯有持续盈利,才能一直走下去。
Ghost的设计感确实在目前的开源CMS中无出其右,希望Ghost能一直长青。