./discourse-setup가 SMTP 비밀번호 앞에 SMTP 사용자 이름을 붙입니다

환경

  • Discourse: 최신 tests-passed
  • 호스트 OS: [Ubuntu 24.04 LTS / 24.04.3 LTS]
  • 설치 방법: 공식 Docker 설치, ./discourse-setup 실행 중
  • 플랫폼: VPS (Digital Ocean)

재현 단계

  1. /var/discourse에서 ./discourse-setup 실행
  2. 프롬프트가 표시되면 SMTP 정보 입력:
  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을 여러 번 실행해도 재현 가능합니다.

비밀번호의 특정 문자, 예를 들어 @ / ! 등이 관련되어 버그가 발생하는지 테스트해 볼 수 있을까요?

discourse-setup에 있는 모든 sed/awk 및 복잡한 bash 스크립팅은 유지보수를 상당히 어렵게 만듭니다. @pfaffman님이 여기에 대해 몇 가지 아이디어를 가지고 계실까요?

맞아요. 그 방법이 좋겠네요.

@Ethsim2 비밀번호를 숫자와 문자만 사용하고, 사용자 이름의 일부와 같지 않도록 변경하면 이 문제가 해결될까요?

사용 중인 값을 공유해 주실 수 있나요?

오늘 늦게 확인해 보겠습니다.

비밀번호에서 83f…com 부분을 제거하고 비밀번호(5AH…)만 남겼을 때 작동하나요?

네, 그러면 ./discourse-setup가 이미 SWAP을 설정하고 app.yml을 생성했기 때문에 ./launcher rebuild app을 실행합니다.

네. 그게 문제입니다. bash가 값을 읽을 때, bash가 값을 sed에 전달할 때, sed가 값을 치환할 때, 그리고 아마도 yml 파일이 값을 받을 때와 같이 여러 단계의 이스케이프가 필요합니다. 이것은 알려진 문제입니다:

이 게시글을 Support 카테고리로 재분류했습니다.

SMTP 사용자 이름에는 항상 @가 포함되어 있지 않나요?

그래. @가 문제가 될 것 같지 않아.

연결이 발생하는 부분의 스크린샷에는 !가 없습니다.

입력하려고 했던 비밀번호가 무엇인가요? (물론 지금은 다른 비밀번호를 사용 중이겠죠!)

스크린샷에 있는 비밀번호는 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 LTS)

LLM/AI -generated recommendations

아래는 다음과 같은 작업을 수행하는 간결한 패치입니다:

  • sed 사용 중단
  • percent-encoded 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
+  }
+
+  # 중요: 역슬래시 변형 없이 변수 읽기
+  # (이것들은 이전 프롬프트에서 온 것입니다; 프롬프트 시점에 -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}"
+
+  # Ruby를 사용하여 YAML을 안전하게 로드/수정/덤프 (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
+}
+
-  # 키별 SMTP 항목 기록 (주소/포트/사용자 이름/비밀번호)
-  # (레거시: sed 치환을 통해 수행됨)
-  # 참고: 특수 문자와 함께 역사적으로 취약함
-  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"
-}
+  # (레거시 키별 기록은 SMTP_URL을 통한 YAML로 대체되어 제거됨)
+}

그리고 패치 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을 통해 Python/Ruby로 전달되지 않고 stdin/argv를 통해 전달됩니다.
  • sed 레이어: 완전히 제거됨.
  • YAML 레이어: Ruby/Psych가 인용부호와 이스케이프를 적절히 처리합니다; 수동 인용부호 처리 없음.
  • SMTP 자격 증명: SMTP_URL의 %-인코딩은 인증을 위한 특수 문자를 인코딩하는 올바른 위치입니다.

키별 변수를 유지하기를 선호하는 경우, 동일한 Ruby-YAML 접근 방식을 사용하여 DISCOURSE_SMTP_*를 직접 설정하는 자매 패치를 제공할 수 있습니다(여전히 sed 없음), 하지만 SMTP_URL 경로는 가장 깔끔합니다. 왜냐하면 하나의 키, 하나의 기록, 하나의 인코딩 단계이기 때문입니다.
[/details]

불행히도 discourse 설치 환경에서는 호스트 시스템에 Ruby(또는 Python)가 설치되어 있을 것이라고 가정할 수 없습니다. 가능하다면 전체 스크립트를 Ruby로 작성하는 것은 비교적 간단할 것인데, 그렇게 할 수 있다는 보장은 없습니다.

LLM/AI 생성 내용 포함

알겠습니다 — 호스트에 Ruby/Python이 없군요. 다음은 순수 Bash + awk를 사용한 패치입니다:
• Python 없이 Bash만으로(바이트 단위 루프) percent-encoded SMTP_URL을 생성합니다.
• awk를 사용하여 env: 블록 아래에 SMTP_URL을 삽입하거나 교체합니다 (sed 사용 안 함).
• 키별 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"
 }
 
-  # 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” 다중 이스케이프 함정을 피합니다.
• 원자적(atomic-ish) 쓰기: 임시 파일로 편집한 후 containers/app.yml로 mv합니다.
• 하위 호환 안전: 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)가 시스템에 따라 해당 실행 파일(binary)만 다운로드할 수 있습니다.