反向代理和 HTTPS 背后的讨论

我尝试在 Apache 反向代理后面部署 Discourse,但无法正确配置 HTTPS。

在达到目前状态的过程中,我遇到了很多问题。目前,Discourse 运行在一台服务器上,前面有一台 Apache 服务器作为反向代理。最初,让 Discourse 在反向代理后运行就遇到了很多困难,因为 Discourse 总是试图重定向到 app.yaml 中设置的 hostname。

不过,不知怎么的,我现在让它运行起来了,但在浏览器中却出现了混合内容警告。

我在 Apache 中配置了从 HTTP 到 HTTPS 的重定向,这部分工作正常。但 Discourse 仍然通过 HTTP 提供部分内容,我似乎无法找到方法强制其改为 HTTPS。

例如,favicon 是通过 HTTP 提供的,而我不知道如何更改这一点。

我能否让 Discourse 将所有链接更改为 HTTPS,而无需让 Discourse 直接处理 HTTPS 流量?

我尝试在 Apache 中设置:

Header always set Strict-Transport-Security "max-age=63072000; includeSubDomains"

但这似乎没有帮助。

在 Discourse 中勾选“强制 HTTPS”选项也无济于事,因为这会导致网站无法正常工作,因为它会忽略所有 HTTP 请求。

我该如何解决混合内容问题?

Apache2 会带来很多麻烦。建议切换到 nginx、caddy、traefik 或 haproxy。

我在测试环境中成功部署了 Apache2,将其作为反向代理连接到容器内的 Unix 套接字,过程“毫无问题”:

我找到的唯一区别(注意:仅测试了几个小时,尚不完整)是:

  • Apache2 无法与容器共享卷中指向 Unix 套接字的符号链接配合使用;
  • 在粗略测试中,Apache2 的速度稍慢,但差异不大。

就个人而言,我不喜欢针对技术的无谓争论;因此,我不同意“Apache2 会带来很多问题”的说法。在我的测试过程中,Apache2 并未出现任何负面问题。

以下是我使用 Apache2 的核心配置(HTTP 工作正常,顺便提一下,LETSENCRYPT 也完全兼容):

# cat discourse.example.conf
<VirtualHost *:80>
  ServerAdmin webmaster@localhost
  ServerName  discourse.example.com
  DocumentRoot /website/discourse

  RewriteEngine On
  ProxyPreserveHost On
  ProxyRequests Off
  ProxyPass / unix:/var/discourse/shared/socket-only/nginx.http.sock|http://localhost/
  ProxyPassReverse  / unix:/var/discourse/shared/socket-only/nginx.http.sock|http://localhost/
  ErrorLog /var/log/apache2/discourse.error.log
  LogLevel warn
  CustomLog /var/log/apache2/discourse.access.log combined

  RewriteCond %{SERVER_NAME} =discourse.example.com
  RewriteRule ^ https://%{SERVER_NAME}%{REQUEST_URI} [END,NE,R=permanent]
</VirtualHost>

注意:我们唯一遇到问题的情况是,即使设置了 force_https,HTTP 仍然被提供,那是因为 /uploads 目录中缺少文件,但这(当然)与使用 Apache2 还是 nginx 作为反向代理无关。

谢谢您的回复,但我并没有在运行 Discourse 的同一台服务器上安装 Apache。我可能之前表达得不够清楚。

我有一台现有的 Apache 服务器,上面托管着多个网站。我需要它作为反向代理来转发位于另一台服务器上的 Discourse,因此无法使用套接字(sockets)。

谢谢。

我尚未尝试过这种方法,但你可以考虑挂载远程文件系统,看看是否能通过这种方式访问 Unix 套接字,特别是如果这些服务器位于同一数据中心,且广域网网络性能不是问题的话。

如果不提供具体的架构、操作系统详情、网络配置等信息,很难给出回复,甚至可能超出了 meta discourse 的讨论范围。

发挥创意吧!

上次我尝试用该配置配合 Apache2 时,连接 Discourse 消息总线失败。不过那已经是去年以前的事了。看起来站点加载正常,但在 F12 开发者控制台中明确显示,WSS 连接在几次初始尝试后超时。

在我发布的示例配置中可以清楚地看到,我们并未使用 wss

wss 并不等同于 apache2 反向代理(这只是其中一种实现方式,而我们并未使用 wss)。

事实上,我们仅使用基于 unix 域套接字nginxapache2 反向代理配置,原因如下:

  • 我比较懒,喜欢简单且易于调试的配置。
  • unix 域套接字 简单且易于调试。
  • nginx 中,我们可以通过符号链接在反向代理和任意容器之间切换。
  • apache2(反向代理到容器)不支持符号链接,因此需要重启 Web 服务器。

不过,@Grunskin 询问的是我们尚未配置的内容:在一台主机上进行反向代理,而在另一台主机上运行容器。

等我有空时,我会在同一数据中心内对 nginxapache2 进行测试,看看是否可以通过挂载远程文件系统并使用 unix 套接字 来实现这一功能。

在那之前……

注意:依我看,此问题与 nginxapache2 无关,因为它们仅充当反向代理(但如前所述,我们尚未测试远程访问配置,因此无法进一步评论)。

为什么这是必要的?

Discourse 是一个应用程序,而非静态网站。一旦初始的 JavaScript 负载已传输至您的浏览器,许多功能便依赖于与 Discourse 服务器的快速连接。通过其他系统进行代理会引入延迟,严重损害用户体验。

您能否说明一下您的需求背后的原因?

使用反向代理有很多理由。
例如,如果我只拥有一个公网 IP,却需要让多个 Web 服务器在 80/443 端口对外提供服务,而且我无法在这个特定的 Web 服务器上运行 Discourse(以此为例)。
将其用作 SSL 卸载,这样后端服务器就不必处理加密问题。
通过将后端服务器置于反向代理之后,可以减少其暴露的攻击面。
这有很多正当的理由,我实在想不通为什么这不可能实现。

过了一段时间,我想起自己已经有另一台服务器在 Apache 后面运行 Discourse,并且至少两年一直运行正常。我用同样的方式配置了新的 Discourse,但无法让它停止通过 HTTP 提供某些内容。这两台 Discourse 服务器唯一的区别是旧版运行的是 2.4 版本,而新版是 2.5 版本,因此我不确定这之间是否存在差异。

正如我在第一篇文章中提到的,force_https 会导致网站无法正常使用,使得无法登录、接受邀请等。看起来某些 JavaScript 无法运行,因为它们可能是通过 HTTP 提供的。
force_https 改为重写所有 HTTP 链接为 HTTPS,而不是直接丢弃它们,这样不是更合理吗?至少应该将其作为一个可选项。

公开部署 Discourse 的推荐方式是什么?是否应该在 DMZ 中部署一台拥有独立外部 IP 的服务器?

我建议看看 Traefik。它运行出色,并能自动管理 SSL 证书。

有很多理由需要在反向代理后面运行。我相当确定 Discourse.org 的基础设施就是在 HAproxy 后面运行的。

我使用 Traefik 和 Caddy Server,过去也成功配置过 nginx、HAproxy 甚至 Apache(用于子目录安装 WordPress 的查询)。

这就是你的问题所在。你需要启用 force_https 并找出它导致问题的原因。关闭它并不是一个可行的选项。你寻求的是免费支持,而回复你的人没有针对 Apache 的解决方案,因此你必须自己成为 Apache 团队的领头人。

是的,我们完全同意。

目前,除了用于主要迁移测试的服务器外,我们在所有站点(生产、测试和预发布环境)都采用“双容器加反向代理”的架构。

这是因为当 PostgreSQL、MySQL 和 Discourse 应用代码全部位于单个容器中时,运行各种迁移脚本会更加方便。这属于非生产环境,更易于调试。但当我们确认无误后,会将备份迁移到生产环境并进行恢复。

这样做存在一个问题:即使是超级完善的双容器加反向代理架构,也无法弥补数据库恢复期间的停机时间,因为目前只有一个数据库。

也许未来我们会尝试采用两个数据库容器的架构,这样我们就可以恢复其中一个,并在数据层面来回切换……

或者更好的方案是,我们将数据恢复到另一个名称的数据库中,并实时切换,但我们目前还不知道如何实现。如果您知道在代码的哪个位置可以将生产数据库的名称从 discourse 更改为 discourse2 而无需重新构建整个应用,那将非常有帮助 :slight_smile: 也许富有创意且创新的 @pfaffman 超级顾问知道?

更新:答案就在 templates/postgres.template.yml:slight_smile:

(我确实看到里面有一些有趣的 mv PostgreSQL 文件夹操作 :slight_smile: :)。)

独立部署和多容器部署各有优缺点;但对于生产环境,我们完全倾向于采用双容器加反向代理的配置。而在迁移测试和预发布环境中,独立部署对我们来说是最好的选择。

关于 Apache2,我希望能有时间在一台服务器上设置并测试反向代理,而在另一台服务器上运行容器。对此非常抱歉……

此外,我还需要想出一个办法,确保在双容器配置下进行数据库恢复时站点仍能保持在线。我现在注意到,templates/postgres.template.yml 模板主要是针对单个独立容器配置设计的。

只想补充一点,Discourse 并不使用 WSS。Message Bus 使用的是带有分块编码(流式传输)的长轮询。

你必须将该主机名设置为访问 Discourse 时使用的主机名。如果 app.yml 中的域名不是用户在浏览器中输入以访问你网站的那个域名,它将无法正常工作。

你的 app.yml 中没有 ssl 或 letsencrypt 模板,对吧?

你确实需要开启 force_https

此外,你还需要费一番周折才能让长轮询正常工作。我记不清具体步骤了。

你好 @Grunskin

我们理解你的沮丧。然而,当你设置:

force_https = true

这本身并不是 问题。正如 @pfaffman 在上面提到的(至少两次,他是 Meta 社区顶级的迁移专家之一):

你“必须”保持 force_https = true,然后找出真正的 问题

如果我是你,基于我所读到的内容:

首先,我会将反向代理设置在运行 Discourse 容器(或容器组)的同一台服务器上,以简化问题,这仅用于测试和故障排查。确保这个简单的测试用例能完美运行。然后,当它在同一主机上正常工作后,关闭该测试用的反向代理,再迁移到你期望的双服务器架构。

这是一个有趣的问题。只要你保持 force_https = true,并采用结构化、循序渐进的故障排查方法,你就能解决这个问题。这类问题不过是等待被破解的 IT 谜题。

你可以做到的。


附言:如果你对这个谜题感到沮丧或厌倦,也可以向 @pfaffman 或其他经验丰富的 Meta 社区成员支付一些费用,请他们帮你跨过这个障碍,迎接更广阔的前景。


备注:我们在同一台服务器上配置 Apache2 作为通过 Unix 域套接字通信的反向代理时,完全没有遇到任何问题。很抱歉,我目前没有个人时间去搭建双服务器环境,并让 Apache2 反向代理方案在两台不同的服务器上运行成功。我们正忙于最终的迁移任务,清理那些“疯狂的 BBCode 滥用”导致的 Markdown 问题,这比我们最初预想的要花费更多时间。

我决定采用在本地主机 8443 端口上通过 SSL 部署 Discourse,使用 UFW(防火墙)锁定 8443 端口,并通过 apache2 将 443 端口代理到 8443 端口的方案。正在尝试中。

                ProxyPass / "https://localhost:8443"
                ProxyPassReverse  / "https://localhost:8443"

我迁移了我们的 discourse 到新的设置,现在在登录时遇到 http 403 POST session 问题。我猜测是 CSRF 问题,但目前我不知道从哪里开始调试,而且 /var/log/discourse-var-log/production.log 和 production_error.log 文件大小都是 0 字节……

有什么办法可以正确调试这个问题吗?

当前设置:
1. haproxy 作为中心 https 负载均衡器/加速器(用于 discourse 和其他服务)

forum >> develd apache rev. proxy p.82
backend forum-backend
   mode http
   server forum.netzwissen.de 10.10.10.14:83 cookie A check
   http-request set-header X-Forwarded-Port %[dst_port]
   http-request add-header X-Forwarded-Proto https if { ssl_fc }
   # HSTS header, 16000000 秒:略多于 6 个月
   http-response set-header Strict-Transport-Security "max-age=16000000; includeSubDomains; preload;"

2. 本地 apache 作为反向代理

   <IfModule proxy_module>
    ## <https://meta.discourse.org/t/running-other-websites-on-the-same-machine-as-discourse/17247>
    ProxyPreserveHost On
    # ProxyRequests Off     
    RequestHeader set X-Forwarded-Proto expr=%{REQUEST_SCHEME}
    RequestHeader set X-Real-IP expr=%{REMOTE_ADDR}
    ProxyPass /  unix:/var/discourse/shared/web-only/apache.http.sock|http://localhost/
    ProxyPassReverse  / unix:/var/discourse/shared/web-only/apache.http.sock|http://localhost/
    </IfModule>

3) Discourse 使用独立的 web_only 和 data 容器运行,web_only 使用 - “templates/web.socketed.template.yml” 部署

这是登录时失败的会话请求:

POST /session HTTP/1.1
Host: forum.netzwissen.de
User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:97.0) Gecko/20100101 Firefox/97.0
Accept: */*
Accept-Language: de,en-US;q=0.7,en;q=0.3
Accept-Encoding: gzip, deflate, br
Content-Type: application/x-www-form-urlencoded; charset=UTF-8
Content-Length: 89
X-CSRF-Token: dtV0N6faVQSWZsg6z9ZGOxQBjuTpBZk6tAMRxaXJdwozF1kObw9UuiFnxbLf5OGDeL1DWDgZ5W3oJP7CY+LwRw==
Discourse-Present: true
X-Requested-With: XMLHttpRequest
Origin: https://forum.netzwissen.de
Sec-Fetch-Dest: empty
Sec-Fetch-Mode: cors
Sec-Fetch-Site: same-origin
Referer: https://forum.netzwissen.de/
Connection: keep-alive

来自 web_only 容器的 webserver 的回复:

HTTP/1.1 403 Forbidden
date: Sun, 13 Mar 2022 16:41:56 GMT
server: nginx
content-type: text/plain; charset=utf-8
x-frame-options: SAMEORIGIN
x-xss-protection: 1; mode=block
x-content-type-options: nosniff
x-download-options: noopen
x-permitted-cross-domain-policies: none
referrer-policy: strict-origin-when-cross-origin
vary: Accept
x-request-id: 778da942-3c1c-493b-946b-478984f53a8c
x-runtime: 0.003623
transfer-encoding: chunked
strict-transport-security: max-age=16000000; includeSubDomains; preload;

我对 csrf 和 nginx(外部 web 服务器是 apache 2.4)都不熟悉,但我很确定 CSRF 是我的问题,因为 discourse 在不登录的情况下运行正常,只有用于登录的 POST 请求在这里失败。我的中心 haproxy 的内部 IP 是 10.10.10.21,因此我在 web_only 容器的 discourse.conf 的 yml 中设置了

set_real_ip_from 10.10.10.21/24;

我也尝试使用默认的 127.0.0.1/24;,但两者在登录时都会导致相同的 403 错误。