./discourse-setup が SMTP ユーザー名を SMTP パスワードの先頭に配置する

環境

  • Discourse: latest tests-passed
  • ホストOS: [Ubuntu 24.04 LTS / 24.04.3 LTS]
  • インストール方法: 公式Dockerインストール、./discourse-setup を実行
  • プラットフォーム: VPS (Digital Ocean)

再現手順

  1. /var/discourse から ./discourse-setup を実行します。

  2. プロンプトでSMTP詳細を入力します。

  3. セットアップを完了し、containers/app.yml を確認します。

期待される結果

app.yml には、たとえば次のような2つの異なるフィールドが含まれるはずです。

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

パスワードの特定の文字(@や!など)がバグの原因になっているかテストできますか?

Discourse-setupのsed/awkや高度なbashスクリプトは、メンテナンスを非常に難しくしています。@pfaffmanさんには何かアイデアがあるでしょうか

「いいね!」 1

はい、それは良い推測です。

@Ethsim2 パスワードを数字と文字のみのものに変更し、ユーザー名の一部と同じにしない場合、この問題は発生しますか?

使用している値を共有していただけますか?

本日中に確認してみます。

パスワードの「83f…com」の部分を削除して、パスワード(5AH…)だけにした場合、機能しますか?

「いいね!」 2

はい、その後 .\\discourse-setup で SWAP が既に設定されていたので .\\launcher rebuild app を実行します。

はい。それが問題です。bashが値を受け取るとき、bashがsedに値を受け渡すとき、sedがそれを置換するとき、そしておそらく、ymlファイルがそれを受け取るときなど、複数のレベルのエスケープが必要です。これは既知の問題です。

これを#supportに再分類しました。

SMTPユーザー名には常に「@」が含まれているのではないでしょうか?

@ は問題を引き起こさないと思います。

「いいね!」 1

連結が発生するスクリーンショットに「!」はありません。

入力しようとしているパスワードは何ですか?

スクリーンショットのパスワードは 5AHQXrf4LDUmRB1J です :slightly_smiling_face:

編集:このパスワードは、その後削除されたアカウントで作成されたBrevoのデフォルトパスワードです。

再現できません。どのOSを使用していますか?

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)
「いいね!」 1

Digital Ocean Ubuntu 24.04

「LLM/AI生成の推奨事項」

以下は、以下の機能を持つタイトなパッチです。

  • sedの使用を停止します
  • パーセントエンコードされたSMTP_URLを構築します
  • RubyのYAML(Psych)を使用してcontainers/app.ymlを編集します。これにより、YAMLの引用/エスケープは実際のパーサーによって処理されます。
  • 一貫性を避けるために、キーごとのSMTP変数を削除します。

discourse_dockerリポジトリでgit apply -p0を使用して適用してください。


パッチ1 -

discourse-setup

(sedではなく、Ruby YAMLを使用してSMTPを書き込みます)

--- a/discourse-setup
+++ b/discourse-setup
@@ -867,6 +867,77 @@ write_smtp_settings() {
   local app_yml="containers/app.yml"
   [[ -f "$app_yml" ]] || die "Cannot find $app_yml. Did you run bootstrap?"

+  # Python標準ライブラリを使用してURLエンコードされたSMTP_URLを構築します(シェルエスケープゲームなし)
+  urlencode() {
+    python3 - <<'PY'
+import sys, urllib.parse
+print(urllib.parse.quote(sys.stdin.read().strip(), safe='._~-'))
+PY
+  }
+
+  # IMPORTANT: バックスラッシュの誤変換なしで変数を読み取ります
+  # (これらは以前のプロンプトから来ています。プロンプト時に-rを使用するだけで十分です)
+  local addr="$smtp_address"
+  local port="$smtp_port"
+  local user_enc pass_enc
+  user_enc="$(printf '%s' "$smtp_user"     | urlencode)"
+  pass_enc="$(printf '%s' "$smtp_password" | urlencode)"
+  local smtp_url="smtp://${user_enc}:${pass_enc}@${addr}:${port}"
+
+  # 安全にYAMLをロード/変更/ダンプするためにRubyを使用します(3層のエスケープを削除します)
+  ruby - <<'RUBY' "$app_yml" "$smtp_url"
+require "yaml"
+require "psych"
+path, url = ARGV
+doc = YAML.safe_load(File.read(path), permitted_classes: [], aliases: true) || {}
+
+# トップレベル構造がHashであり、envを持つことを確認します
+unless doc.is_a?(Hash)
+  abort "containers/app.yml does not parse to a Hash"
+end
+doc["env"] ||= {}
+env = doc["env"]
+
+# 単一行のSMTP_URLを書き込みます。競合を避けるためにキーごとの変数を削除します。
+env["SMTP_URL"] = url
+%w[DISCOURSE_SMTP_ADDRESS DISCOURSE_SMTP_PORT DISCOURSE_SMTP_USER_NAME DISCOURSE_SMTP_PASSWORD].each { |k| env.delete(k) }
+
+# 書き戻します。(Psychは必要に応じて安全に引用符で囲まれた文字列を保持します。)
+File.write(path, Psych.dump(doc))
+RUBY
+
+  # 古典的な「ユーザー名の前にパスワードがプレフィックスされている」失敗のための簡単な健全性チェック
+  python3 - <<'PY'
+import re, sys
+y = open("containers/app.yml","r",encoding="utf-8").read()
+m = re.search(r'^\\s*SMTP_URL:\\s*(?:\"|\\')?([^\\r\\n\"\\']+)', y, re.M)
+assert m, "SMTP_URL missing after write"
+creds = m.group(1).split('@',1)[0].split('://',1)[-1]
+assert ":" in creds, "SMTP_URL creds missing ':'"
+u, p = creds.split(':',1)
+assert not p.startswith(u), "Password appears prefixed by username"
+print("SMTP_URL looks sane.")
+PY
+}
+
-  # 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"
-}
+  # (レガシーなキーごとの書き込みは、YAML経由のSMTP_URLに置き換えられました)
+}

次にパッチ2 -

templates/web.template.yml

(より安全なパスを文書化するため)

--- a/templates/web.template.yml
+++ b/templates/web.template.yml
@@ -68,6 +68,14 @@ params:
   DISCOURSE_SMTP_ENABLE_START_TLS: true
   #DISCOURSE_NOTIFICATION_EMAIL: noreply@example.com
 
+  ## 推奨される単一行SMTP設定(discourse-setupによって設定されます):
+  ## ユーザー名とパスワードをURLエンコードします。例:
+  ##   SMTP_URL: "smtp://user%40example.com:p%40ss%3Aword@smtp.example.com:587"
+  ##
+  #SMTP_URL:
+
   ## SMTP_URLを使用できない場合は、代わりにキーごとの変数を使用できます。
   ## 値に@、:、/、"、\、または改行などの文字が含まれている場合、
   ## これらの行をシェルツールで編集するのは脆弱になる可能性があることに注意してください。

これが機能する理由(および回避されること)

  • bashレイヤー:単純な変数をのみ補間します。秘密はsed正規表現やシェルevalを介してではなく、stdin/argvを介してPython/Rubyに渡されます。
  • sedレイヤー:完全に削除されました。
  • YAMLレイヤー:Ruby/Psychが引用符とエスケープを正しく処理します。手作業での引用はありません。
  • SMTP認証情報:SMTP_URLでの%エンコーディングは、認証のための特殊文字をエンコードするのに適した場所です。

キーごとの変数を維持したい場合は、DISCOURSE_SMTP_ *を直接設定するために同じRuby-YAMLアプローチを使用する姉妹パッチを提供できます(ただし、sedは使用しません)。ただし、SMTP_URLルートは、キーが1つ、書き込みが1つ、エンコードステップが1つであるため、最もクリーンです。

ディスコースのインストールでは、ホストシステムに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 #
   ##############################################
@@ -977,17 +1101,4 @@
   # Legacy per-key writes removed in favor of atomic SMTP_URL write above.
 }
  • 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”
    -}

この要件を満たす理由
	•	ホスト上のRuby/Pythonはゼロ:Bash + awk + od/printf/grepのみ(すべて標準)。
	•	秘密情報のsed置換なし:「Bash → sed → YAML」の複数エスケープの落とし穴を回避します。
	•	アトミックな書き込み:一時ファイルに編集し、次にcontainers/app.ymlに上書きします。
	•	後方互換性:env:が存在しない場合は最小限で作成します。存在する場合は、インプレースで更新し、競合するDISCOURSE_SMTP_*行を削除します。
	•	認証情報はURLエンコードされて挿入されるため、ユーザー/パスの特殊文字がRails/Net::SMTPのURLパーサーを壊すことはありません。

SMTP_URLの代わりにキーごとの変数を使用したい場合は、同じawk戦略を使用して以下を設定する兄弟パッチを送信できます。

DISCOURSE_SMTP_ADDRESS
DISCOURSE_SMTP_PORT
DISCOURSE_SMTP_USER_NAME
DISCOURSE_SMTP_PASSWORD


YAMLセーフな二重引用符付きの値とアンカー付き置換を使用します。

では、それがどのように起こったのか想像もつきません。私の例のように再現できますか?

バッファが貼り付けの速さに追いつけないほど、すべてのテキストを一度にコピー/貼り付けしましたか?

うーん、launcher2 にロジックをバンドルして、それを使ってセットアップを実行できるかどうか疑問に思っています。

discourse-setup が飛び越えているホープの量は壮観です。

ランチャーをリリース用にバイナリ実行可能ファイルにコンパイルすることは可能ですか?このようにすれば、ランチャー(bash)はシステムに基づいて対応する実行可能ファイル(バイナリ)をダウンロードするだけで済みます。

「いいね!」 1