./discourse-setup 将 SMTP 用户名放在 SMTP 密码的开头

环境

  • Discourse:latest tests-passed
  • 主机操作系统:[Ubuntu 24.04 LTS / 24.04.3 LTS]
  • 安装方法:官方 Docker 安装,运行 ./discourse-setup
  • 平台:VPS (Digital Ocean)

重现步骤

  1. 从 /var/discourse 运行 ./discourse-setup
  2. 在提示时输入 SMTP 详细信息:
  • SMTP 服务器:smtp.example.com
  • SMTP 端口:2525
  • SMTP 用户名:user@example.com
  • SMTP 密码:p@ssw0rd!
  1. 完成设置并检查 containers/app.yml

预期结果

app.yml 应包含两个独立的字段,例如:

DISCOURSE_SMTP_USER_NAME: "user@example.com"
DISCOURSE_SMTP_PASSWORD: "p@ssw0rd!"

实际结果

密码字段被写入,并且用户名被预置:

DISCOURSE_SMTP_USER_NAME: "user@example.com"
DISCOURSE_SMTP_PASSWORD: "user@brevo.comp@ssw0rd!"

这会导致邮件发送失败,并且 discourse-doctor 确认密码被篡改。

备注
• 在提示时明确引用密码不会改变结果。
• 密码包含特殊字符(@、!),但问题不仅仅是 YAML 引用:用户名字符串被字面连接到密码的开头。
• 在多次运行 ./discourse-setup 时可重现。

1 个赞

你能测试一下这是否与密码中的特定字母有关,比如 @ / ! 会暴露这个 bug 吗?

Discourse 设置中的所有 sed/awk 和花哨的 bash 脚本都使得维护起来相当困难。也许 @pfaffman 有什么想法?

1 个赞

好的,这很有道理。

@Ethsim2 如果您将密码更改为仅包含数字和字母,并且与用户名的一部分不同,您还会遇到此问题吗?

您能否分享您正在使用的值?

我今天晚些时候会尝试看一下。

如果您删除了密码中的 83f…com 部分,只留下密码(5AH…),它是否可用?

2 个赞

是的,然后我运行 .\launcher rebuild app,因为 .\discourse-setup 已经配置了 SWAP。

是的。这就是问题所在。需要发生多个级别的转义,例如当 bash 读取值时,当 bash 将值传递给 sed 时,当 sed 替换它时,然后,也许,当 yml 文件获取它时。这是一个已知问题:

我已将此重新分类为 #support。

SMTP 用户名总是包含 @,不是吗?

我认为@不会引起问题。

1 个赞

我的截图中连接处没有 !

您正尝试输入什么密码?

截图上的密码是 5AHQXrf4LDUmRB1J :slightly_smiling_face:

编辑:此密码是 Brevo 在一个已被删除的账户上创建的默认密码

我无法复现。您使用的是什么操作系统?

root@bro:/var/discourse# ./discourse-setup --skip-connection-test --skip-rebuild
skipping connection test
'samples/standalone.yml' -> 'containers/app.yml'
Found 29GB of memory and 32 physical CPU cores
setting db_shared_buffers = 4096MB
setting UNICORN_WORKERS = 8
containers/app.yml memory parameters updated.

Hostname for your Discourse? [discourse.example.com]: forum.phsics.site

Setting EC to 2
Skipping port check.
Email address for admin account(s)? [me@example.com,you@example.com]: jay@literatecomputing.com
SMTP server address? [smtp.example.com]: smtp-relay.brevo.com
SMTP port? [587]: 2525
SMTP user name? [user@example.com]: 83fca0012@smtp-brevo.com
SMTP password? []: 5AHQXrf4LDUmRB1J
notification email address? [noreply@forum.phsics.site]:
Optional email address for Let's Encrypt warnings? (ENTER to skip) [me@example.com]:
Optional MaxMind Account ID (ENTER to continue without MAXMIND GeoLite2 geolocation database) [123456]:

Does this look right?

Hostname          : forum.phsics.site
Email             : jay@literatecomputing.com
SMTP address      : smtp-relay.brevo.com
SMTP port         : 2525
SMTP username     : 83fca0012@smtp-brevo.com
SMTP password     : 5AHQXrf4LDUmRB1J
Notification email: noreply@forum.phsics.site
MaxMind account ID: (unset)
MaxMind license key: (unset)

ENTER to continue, 'n' to try again, Ctrl+C to exit:
letsencrypt.ssl.template.yml enabled


Configuration file at containers/app.yml updated successfully!

Updates successful. --skip-rebuild requested. Exiting.
root@bro:/var/discourse# grep SMTP containers/app.yml
  ## TODO: The SMTP mail server used to validate new accounts and send notifications
  # SMTP ADDRESS is required
  # WARNING: SMTP password should be wrapped in quotes to avoid problems
  DISCOURSE_SMTP_ADDRESS: smtp-relay.brevo.com
  DISCOURSE_SMTP_PORT: 2525
  DISCOURSE_SMTP_USER_NAME: 83fca0012@smtp-brevo.com
  DISCOURSE_SMTP_PASSWORD: "5AHQXrf4LDUmRB1J"
  #DISCOURSE_SMTP_ENABLE_START_TLS: true           # (optional, default: true)
  DISCOURSE_SMTP_DOMAIN: discourse.example.com # (required by some providers)
  #DISCOURSE_SMTP_OPENSSL_VERIFY_MODE: peer        # (optional, default: peer, valid values: none, peer, client_once, fail_if_no_peer_cert)
  #DISCOURSE_SMTP_AUTHENTICATION: plain            # (default: plain, valid values: plain, login, cram_md5)

Digital Ocean Ubuntu 24.04

LLM/AI 生成的建议

\n以下是一个精简的补丁,它:\n\n- 停止使用 sed\n- 构建一个经过百分比编码的 SMTP_URL\n- 通过 Ruby 的 YAML (Psych) 编辑 containers/app.yml,因此 YAML 的引用/转义由真正的解析器处理\n- 删除每个键的 SMTP 变量以避免冲突\n\n在 discourse_docker 仓库中使用 git apply -p0 应用。\n\n- - -\n\n补丁 1 -\n\ndiscourse-setup\n\n(使用 Ruby YAML 编写 SMTP,而不是 sed)\n\ndiff\n--- a/discourse-setup\n+++ b/discourse-setup\n@@ -867,6 +867,77 @@ write_smtp_settings() {\n local app_yml=\"containers/app.yml\"\n [[ -f \"$app_yml\" ]] || die \"Cannot find $app_yml. Did you run bootstrap?\"\n\n+ # 使用 Python 标准库构建 URL 编码的 SMTP_URL(无 shell 转义技巧)\n+ urlencode() {\n+ python3 - \u003c\u003c'PY'\n+import sys, urllib.parse\n+print(urllib.parse.quote(sys.stdin.read().strip(), safe='._~-'))\n+PY\n+ }\n+\n+ # 重要提示:读取变量时不要进行反斜杠处理\n+ # (这些来自之前的提示;只需确保在提示时使用 -r)\n+ local addr=\"$smtp_address\"\n+ local port=\"$smtp_port\"\n+ local user_enc pass_enc\n+ user_enc=\"$(printf '%s' \"$smtp_user\" | urlencode)\"\n+ pass_enc=\"$(printf '%s' \"$smtp_password\" | urlencode)\"\n+ local smtp_url=\"smtp://${user_enc}:${pass_enc}@${addr}:${port}\"\n+\n+ # 使用 Ruby 安全地加载/修改/转储 YAML(消除 3 层转义)\n+ ruby - \u003c\u003c'RUBY' \"$app_yml\" \"$smtp_url\"\n+require \"yaml\"\n+require \"psych\"\n+path, url = ARGV\n+doc = YAML.safe_load(File.read(path), permitted_classes: [], aliases: true) || {}\n+\n+# 确保顶层结构是 Hash 并且有 env\n+unless doc.is_a?(Hash)\n+ abort \"containers/app.yml 未解析为 Hash\"\n+end\n+doc[\"env\"] ||= {}\n+env = doc[\"env\"]\n+\n+# 写入单行 SMTP_URL;删除每个键的变量以避免冲突\n+env[\"SMTP_URL\"] = url\n+%w[DISCOURSE_SMTP_ADDRESS DISCOURSE_SMTP_PORT DISCOURSE_SMTP_USER_NAME DISCOURSE_SMTP_PASSWORD].each { |k| env.delete(k) }\n+\n+# 转储回。 (Psych 安全地引用所需的字符串。)\n+File.write(path, Psych.dump(doc))\n+RUBY\n+\n+ # 对经典的“密码前缀为用户名”失败进行快速健全性检查\n+ python3 - \u003c\u003c'PY'\n+import re, sys\n+y = open(\"containers/app.yml\",\"r\",encoding=\"utf-8\").read()\n+m = re.search(r'^\\s*SMTP_URL:\\s*(?:\"|\\')?([^\\r\\n\"\\']+)', y, re.M)\n+assert m, \"写入后缺少 SMTP_URL\"\n+creds = m.group(1).split('@',1)[0].split('://',1)[-1]\n+assert \":\" in creds, \"SMTP_URL 凭据缺少 ':'\"\n+u, p = creds.split(':',1)\n+assert not p.startswith(u), \"密码似乎以用户名作为前缀\"\n+print(\"SMTP_URL 看起来正常。\")\n+PY\n+}\n+\n- # 写入每个键的 SMTP 条目(地址/端口/用户名/密码)\n- # (旧版:通过 sed 替换执行)\n- # 注意:历史上对于特殊字符来说很脆弱\n- update_setting_yaml \"DISCOURSE_SMTP_ADDRESS\" \"$smtp_address\"\n- update_setting_yaml \"DISCOURSE_SMTP_PORT\" \"$smtp_port\"\n- update_setting_yaml \"DISCOURSE_SMTP_USER_NAME\" \"$smtp_user\"\n- update_setting_yaml \"DISCOURSE_SMTP_PASSWORD\" \"$smtp_password\"\n-}\n+ # (旧版的每个键写入已移除,转而使用通过 YAML 设置的 SMTP_URL)\n+}\n\n\n然后是补丁 2 -\n\ntemplates/web.template.yml\n\n(用于记录更安全的路径)\n\ndiff\n--- a/templates/web.template.yml\n+++ b/templates/web.template.yml\n@@ -68,6 +68,14 @@ params:\n DISCOURSE_SMTP_ENABLE_START_TLS: true\n #DISCOURSE_NOTIFICATION_EMAIL: noreply@example.com\n\n+ ## 首选的单行 SMTP 配置(由 discourse-setup 设置):\n+ ## 对用户名和密码进行 URL 编码;示例:\n+ ## SMTP_URL: \"smtp://user%40example.com:p%40ss%3Aword@smtp.example.com:587\"\n+ ##\n+ #SMTP_URL:\n+\n ## 如果您无法使用 SMTP_URL,则可以改用每个键的变量。\n ## 请注意,使用 shell 工具编辑这些行可能会很脆弱,如果值包含\n ## 字符如 @、:、/、\"、\\ 或换行符。\n\n\n为什么这样做有效(以及它避免了什么)\n\t•\tbash 层:我们只插入简单的变量;通过 stdin/argv 将秘密信息传递给 Python/Ruby,而不是通过 sed 正则表达式或 shell 求值。\n\t•\tsed 层:完全移除。\n\t•\tYAML 层:Ruby/Psych 正确处理引用和转义;无需手动处理引用。\n\t•\tSMTP 凭据:SMTP_URL 中的百分比编码是在正确的位置对特殊字符进行编码以进行身份验证。\n\n如果您更喜欢保留每个键的变量,我可以提供一个姊妹补丁,它使用相同的 Ruby-YAML 方法直接设置 DISCOURSE_SMTP_*(仍然没有 sed),但 SMTP_URL 路线是最简洁的,因为它是一个键、一次写入、一个编码步骤。\n

我们很遗憾无法依赖 discourse 安装中的宿主系统已安装 Ruby - 如果可以的话,让整个脚本成为一个 Ruby 脚本会相对简单,但无法保证。

2 个赞
LLM/AI 生成

明白了——主机上没有 Ruby/Python。这是一个纯 Bash + awk 的补丁,它:
• 仅使用 Bash(逐字节循环;无 Python)构建一个百分比编码的 SMTP_URL,
• 使用 awk(无 sed)在 env: 块下插入或替换 SMTP_URL,
• 删除每个键的 DISCOURSE_SMTP_* 行(安全的锚定删除),
• 仅使用 grep/awk 添加一个简单的健全性检查。

在 discourse_docker 仓库中使用 git apply -p0 应用。

--- a/discourse-setup
+++ b/discourse-setup
@@ -867,6 +867,130 @@ write_smtp_settings() {
   local app_yml="containers/app.yml"
   [[ -f "$app_yml" ]] || die "Cannot find $app_yml. Did you run bootstrap?"
 
+  ##############################################
+  # Pure-Bash URL encoder for SMTP credentials #
+  ##############################################
+  # Encodes everything except A-Z a-z 0-9 . _ ~ -
+  # Works byte-by-byte; requires bash and printf.
+  urlencode_cred() {
+    local s="$1" out= i ch o
+    # set C locale to get byte semantics
+    LC_ALL=C
+    for ((i=0; i<${#s}; i++)); do
+      ch="${s:i:1}"
+      case "$ch" in
+        [A-Za-z0-9._~-])
+          out+="$ch"
+          ;;
+        *)
+          # Get byte value: print char, read with od, then format %HH
+          # Avoid external heavy deps; od is in coreutils / busybox.
+          o=$(printf '%s' "$ch" | od -An -tu1 | awk '{$1=$1;print $1}')
+          # If od somehow failed (empty), fall back to hex via printf %02X of first byte
+          if [ -z "$o" ]; then
+            o=$(printf '%s' "$ch" | head -c1 | od -An -tu1 | awk '{$1=$1;print $1}')
+          fi
+          printf -v o '%%%02X' "$o"
+          out+="$o"
+          ;;
+      esac
+    done
+    printf '%s' "$out"
+  }
+
+  # Build SMTP_URL (single line) from collected answers
+  # These vars are gathered earlier in discourse-setup:
+  #   $smtp_address  $smtp_port  $smtp_user  $smtp_password
+  local addr="$smtp_address"
+  local port="$smtp_port"
+  local user_enc pass_enc
+  user_enc="$(urlencode_cred "$smtp_user")"
+  pass_enc="$(urlencode_cred "$smtp_password")"
+  local smtp_url="smtp://${user_enc}:${pass_enc}@${addr}:${port}"
+
+  ########################################################
+  # YAML-safe edit via awk (no sed / no external runtimes)
+  # - ensure env: exists
+  # - insert or replace SMTP_URL under env:
+  # - remove DISCOURSE_SMTP_* keys
+  ########################################################
+  awk -v NEWVAL="$smtp_url" '
+    BEGIN{
+      have_env=0; in_env=0; inserted=0
+    }
+    # detect env: line at top level (start of line, possibly indented 0..)
+    # we’ll consider two-space indentation for children.
+    /^[[:space:]]*env:[[:space:]]*$/ {
+      print; have_env=1; in_env=1; next
+    }
+    # leaving env: block when indentation returns to 0 or next top-level key
+    in_env && /^[^[:space:]]/ {
+      if (!inserted) {
+        print "  SMTP_URL: \"' NEWVAL "\""
+        inserted=1
+      }
+      in_env=0
+    }
+    # while in env:, handle replacements and deletions
+    in_env {
+      # drop per-key DISCOURSE_SMTP_* lines entirely
+      if ($0 ~ /^[[:space:]]*DISCOURSE_SMTP_(ADDRESS|PORT|USER_NAME|PASSWORD):/) next
+      # replace existing SMTP_URL line
+      if ($0 ~ /^[[:space:]]*SMTP_URL:[[:space:]]*/) {
+        print "  SMTP_URL: \"' NEWVAL "\""
+        inserted=1
+        next
+      }
+      print
+      next
+    }
+    { print }
+    END{
+      # If env: never existed, append it with the key
+      if (!have_env) {
+        print ""
+        print "env:"
+        print "  SMTP_URL: \"' NEWVAL "\""
+      } else if (in_env && !inserted) {
+        # env: existed and we were still in it at EOF
+        print "  SMTP_URL: \"' NEWVAL "\""
+      }
+    }
+  ' "$app_yml" > "$app_yml.tmp.$$" && mv "$app_yml.tmp.$$" "$app_yml"
+
+  ##############################################
+  # Sanity check: basic guard against mangling #
+  ##############################################
+  # 1) SMTP_URL present
+  grep -q '^[[:space:]]*SMTP_URL:' "$app_yml" || die "SMTP_URL not written to $app_yml"
+  # 2) password not prefixed by username (classic failure signature)
+  awk '
+    BEGIN{ok=1}
+    /^[[:space:]]*SMTP_URL:[[:space:]]*"/ {
+      line=$0
+      gsub(/^[[:space:]]*SMTP_URL:[[:space:]]*"/,"",line)
+      gsub(/".*$/,"",line)
+      # Extract creds before @ and after scheme
+      # e.g. smtp://user:pass@host:port
+      sub(/^[a-z]+:\/\//,"",line)
+      at=index(line,"@")
+      if (at>0) {
+        creds=substr(line,1,at-1)
+        colon=index(creds,":")
+        if (colon>0) {
+          user=substr(creds,1,colon-1)
+          pass=substr(creds,colon+1)
+          if (index(pass,user)==1) { ok=0 }
+        }
+      }
+    }
+    END{ if (!ok) { print "Password appears prefixed by username"; exit 1 } }
+  ' "$app_yml"
+ }
+
   ##############################################
   # Pure-Bash URL encoder for SMTP credentials #
   ##############################################
@@ -996,16 +1120,6 @@
   # NOTE: historically fragile with special chars
   update_setting_yaml "DISCOURSE_SMTP_ADDRESS"  "$smtp_address"
   update_setting_yaml "DISCOURSE_SMTP_PORT"     "$smtp_port"
-  update_setting_yaml "DISCOURSE_SMTP_USER_NAME" "$smtp_user"
-  update_setting_yaml "DISCOURSE_SMTP_PASSWORD" "$smtp_password"
-}
-
-
--  # Write per-key SMTP entries (address/port/username/password)
--  # (legacy: performed via sed substitutions)
--  # NOTE: historically fragile with special chars
--  update_setting_yaml "DISCOURSE_SMTP_ADDRESS"  "$smtp_address"
--  update_setting_yaml "DISCOURSE_SMTP_PORT"     "$smtp_port"
-  update_setting_yaml "DISCOURSE_SMTP_USER_NAME" "$smtp_user"
-  update_setting_yaml "DISCOURSE_SMTP_PASSWORD" "$smtp_password"
-}
+  # Legacy per-key writes removed in favor of atomic SMTP_URL write above.
+}
+

为什么这符合您的要求
• 主机上没有 Ruby/Python:只有 Bash + awk + od/printf/grep(全部是标准的)。
• 没有 sed 替换敏感信息:我们避免了“Bash → sed → YAML”的多重转义陷阱。
• 原子性写入:编辑到临时文件,然后 mv 到 containers/app.yml。
• 向后兼容:如果 env: 不存在,我们会最小化地创建它;如果存在,我们会就地更新并删除冲突的 DISCOURSE_SMTP_* 行。
• 凭据在插入前会进行 URL 编码,因此用户/密码中的特殊字符不会破坏 Rails/Net::SMTP 中的 URL 解析器。

如果您希望保留每个键的变量而不是 SMTP_URL,我可以发送一个同级补丁,该补丁(仍然没有 sed)使用相同的 awk 策略来设置:

DISCOURSE_SMTP_ADDRESS
DISCOURSE_SMTP_PORT
DISCOURSE_SMTP_USER_NAME
DISCOURSE_SMTP_PASSWORD

…使用 YAML 安全的双引号值和锚定替换。

那我想不通怎么会发生这种情况。你能像我举的例子那样重现它吗?

你是一次性复制粘贴所有文本的吗,以至于缓冲区跟不上你粘贴的速度?

嗯,我想知道我们是否可以将逻辑捆绑到 launcher2 中,然后用它来运行设置。
discourse-setup 跳过的障碍数量真是太壮观了。

是否可以将启动器编译成二进制可执行文件以供发布?这样,启动器(bash)就可以只下载与系统对应的可执行文件(二进制)。

1 个赞