./discourse-setup добавляет имя пользователя SMTP в начало пароля SMTP

Окружение

  • Discourse: последние тесты пройдены
  • Хост-ОС: [Ubuntu 24.04 LTS / 24.04.3 LTS]
  • Метод установки: официальная установка через Docker, запуск ./discourse-setup
  • Платформа: VPS (Digital Ocean)

Шаги для воспроизведения

  1. Запустите ./discourse-setup из каталога /var/discourse
  2. Введите данные SMTP при запросе:
  1. Завершите настройку и проверьте контейнеры/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.

Можешь проверить, связана ли эта ошибка с определёнными символами в пароле, например @, / или !?

Весь скрипт sed/awk и продвинутые bash-скрипты в discourse-setup действительно усложняют его поддержку. Возможно, у @pfaffman есть какие-то идеи по этому поводу?

Верно. Это неплохая ставка.

@Ethsim2, если вы измените пароль на такой, который содержит только цифры и буквы и не совпадает с частью имени пользователя, у вас возникнет эта проблема?

Можете ли вы поделиться значениями, которые вы используете?

Я постараюсь посмотреть на это позже сегодня.

Если вы уберете часть пароля 83f…com и оставите только сам пароль (5AH…), будет ли он работать?

да, затем я запускаю ./launcher rebuild app, так как ./discourse-setup уже настроил SWAP и создал файл app.yml

Да, именно в этом проблема. Необходимо выполнить несколько уровней экранирования: например, когда bash считывает значение, когда bash передаёт его sed, когда sed выполняет замену, и, возможно, когда файл yml получает его. Это известная проблема:

Я изменил категорию этой темы на Support.

Имена пользователей SMTP всегда содержат @, не так ли?

Да. Я не думаю, что @ должно вызывать проблему.

на моем скриншоте в месте конкатенации нет !

Каким паролем вы пытались воспользоваться? (И, presumably, вы сейчас используете другой!)

Пароль на скриншоте — 5AHQXrf4LDUmRB1J :slightly_smiling_face:

Редактирование: этот пароль является паролем по умолчанию от Brevo, созданным на аккаунте, который впоследствии был удалён.

Я не могу воспроизвести это. Какая у вас операционная система?

root@bro:/var/discourse# ./discourse-setup --skip-connection-test --skip-rebuild
пропуск проверки подключения
'samples/standalone.yml' -> 'containers/app.yml'
Обнаружено 29 ГБ памяти и 32 физических ядра ЦП
установка db_shared_buffers = 4096MB
установка UNICORN_WORKERS = 8
параметры памяти containers/app.yml обновлены.

Имя хоста для вашего Discourse? [discourse.example.com]: forum.phsics.site

Установка EC в 2
Пропуск проверки порта.
Адрес электронной почты для учетной(ых) записи(ей) администратора? [me@example.com,you@example.com]: jay@literatecomputing.com
Адрес SMTP-сервера? [smtp.example.com]: smtp-relay.brevo.com
Порт SMTP? [587]: 2525
Имя пользователя SMTP? [user@example.com]: 83fca0012@smtp-brevo.com
Пароль SMTP? []: 5AHQXrf4LDUmRB1J
Адрес электронной почты для уведомлений? [noreply@forum.phsics.site]:
Опциональный адрес электронной почты для предупреждений Let's Encrypt? (нажмите ENTER, чтобы пропустить) [me@example.com]:
Опциональный ID учетной записи MaxMind (нажмите ENTER, чтобы продолжить без базы геолокации MAXMIND GeoLite2) [123456]:

Выглядит ли это правильно?

Имя хоста          : forum.phsics.site
Электронная почта             : jay@literatecomputing.com
Адрес SMTP      : smtp-relay.brevo.com
Порт SMTP         : 2525
Имя пользователя SMTP     : 83fca0012@smtp-brevo.com
Пароль SMTP     : 5AHQXrf4LDUmRB1J
Электронная почта для уведомлений: noreply@forum.phsics.site
ID учетной записи MaxMind: (не установлено)
Лицензионный ключ MaxMind: (не установлен)

Нажмите ENTER для продолжения, 'n' для повторной попытки, Ctrl+C для выхода:
letsencrypt.ssl.template.yml включен


Файл конфигурации в containers/app.yml успешно обновлен!

Обновления успешны. Запрошено --skip-rebuild. Выход.
root@bro:/var/discourse# grep SMTP containers/app.yml
  ## TODO: SMTP-сервер, используемый для проверки новых учетных записей и отправки уведомлений
  # SMTP ADDRESS обязателен
  # ВНИМАНИЕ: пароль SMTP должен быть заключен в кавычки, чтобы избежать проблем
  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           # (опционально, по умолчанию: true)
  DISCOURSE_SMTP_DOMAIN: discourse.example.com # (требуется некоторыми провайдерами)
  #DISCOURSE_SMTP_OPENSSL_VERIFY_MODE: peer        # (опционально, по умолчанию: peer, допустимые значения: none, peer, client_once, fail_if_no_peer_cert)
  #DISCOURSE_SMTP_AUTHENTICATION: plain            # (по умолчанию: plain, допустимые значения: plain, login, cram_md5)

Digital Ocean с Ubuntu 24.04 LTS

Рекомендации, сгенерированные LLM/ИИ

Ниже представлен компактный патч, который:

  • отказывается от использования sed
  • формирует SMTP_URL с кодировкой процентов
  • редактирует containers/app.yml с помощью Ruby YAML (Psych), так что кавычки и экранирование YAML обрабатываются настоящим парсером
  • удаляет переменные SMTP для каждого ключа, чтобы избежать противоречий

Примените его с помощью команды git apply -p0 в репозитории discourse_docker.


Патч 1 —

discourse-setup

(запись SMTP с помощью Ruby YAML, а не sed)

--- 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?"

+  # Build a URL-encoded SMTP_URL using Python stdlib (no shell escaping games)
+  urlencode() {
+    python3 - <<'PY'
+import sys, urllib.parse
+print(urllib.parse.quote(sys.stdin.read().strip(), safe='._~-'))
+PY
+  }
+
+  # IMPORTANT: read vars without backslash mangling
+  # (these come from earlier prompts; just ensure -r is used at prompt time)
+  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}"
+
+  # Use Ruby to safely load/modify/dump YAML (kills 3 layers of escaping)
+  ruby - <<'RUBY' "$app_yml" "$smtp_url"
+require "yaml"
+require "psych"
+path, url = ARGV
+doc = YAML.safe_load(File.read(path), permitted_classes: [], aliases: true) || {}
+
+# Ensure top-level structure is a Hash and has env
+unless doc.is_a?(Hash)
+  abort "containers/app.yml does not parse to a Hash"
+end
+doc["env"] ||= {}
+env = doc["env"]
+
+# Write single-line SMTP_URL; remove per-key vars to avoid conflicts
+env["SMTP_URL"] = url
+%w[DISCOURSE_SMTP_ADDRESS DISCOURSE_SMTP_PORT DISCOURSE_SMTP_USER_NAME DISCOURSE_SMTP_PASSWORD].each { |k| env.delete(k) }
+
+# Dump back. (Psych preserves strings safely quoted as needed.)
+File.write(path, Psych.dump(doc))
+RUBY
+
+  # quick sanity check for the classic "password prefixed by username" failure
+  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"
-}
+  # (legacy per-key writes removed in favor of SMTP_URL via 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

+  ## Preferred single-line SMTP configuration (set by discourse-setup):
+  ## URL-encode username & password; example:
+  ##   SMTP_URL: "smtp://user%40example.com:p%40ss%3Aword@smtp.example.com:587"
+  ##
+  #SMTP_URL:
+
   ## If you cannot use SMTP_URL, you may set per-key variables instead.
   ## Beware that editing those lines with shell tools can be fragile if values include
   ## characters like @, :, /, ", \, or newlines.

почему это работает (и чего это избегает)

  • слой bash: мы интерполируем только простые переменные; секреты передаются в Python/Ruby через stdin/argv, а не через регулярные выражения sed или shell eval.
  • слой sed: полностью удалён.
  • слой YAML: Ruby/Psych корректно обрабатывает кавычки и экранирование; нет ручного написания кавычек.
  • учётные данные SMTP: %-кодирование в SMTP_URL — правильное место для кодирования специальных символов в аутентификации.

Если вы предпочитаете оставить переменные для каждого ключа, я могу предоставить вам сестринский патч, который использует тот же подход Ruby-YAML для прямой установки DISCOURSE_SMTP_* (всё ещё без sed), но путь через SMTP_URL является самым чистым, поскольку это один ключ, одна запись, один шаг кодирования.

К сожалению, мы не можем полагаться на то, что Ruby (или Python) установлен на хост-системе при установке Discourse. Было бы относительно просто сделать весь скрипт на Ruby, если бы это было возможно, но гарантий нет.

также сгенерировано LLM/ИИ

Понял — на хосте нет Ruby/Python. Вот патч на чистом Bash + awk, который:

строит SMTP_URL с кодировкой процентов, используя только Bash (побайтовый цикл; без Python),

вставляет или заменяет SMTP_URL в блоке env с помощью awk (без sed),

удаляет строки DISCOURSE_SMTP_* для каждого ключа (безопасное удаление по якорю),

добавляет небольшую проверку целостности, используя только grep/awk.

Примените с помощью git apply -p0 в репозитории discourse_docker.

--- 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».

Атомарная запись: редактирование во временный файл, затем mv поверх containers/app.yml.

Обратная совместимость: если env: не существует, мы создаём его минимально; если существует — обновляем на месте и удаляем конфликтующие строки DISCOURSE_SMTP_*.

Учётные данные кодируются в URL перед вставкой, поэтому специальные символы в имени пользователя/пароле не сломают парсер URL в Rails/Net::SMTP.

Если вы предпочитаете сохранить переменные для каждого ключа вместо SMTP_URL, я могу отправить сопутствующий патч, который (также без sed) использует ту же стратегию awk для установки:

DISCOURSE_SMTP_ADDRESS
DISCOURSE_SMTP_PORT
DISCOURSE_SMTP_USER_NAME
DISCOURSE_SMTP_PASSWORD

…с безопасными для YAML значениями в двойных кавычках и заменой по якорю.

Тогда я не могу представить, как это произошло. Можете ли вы воспроизвести это так же, как в моём примере?

Вы скопировали и вставили весь текст за один раз, из-за чего буфер не успевал за скоростью вставки?

Хм, интересно, не можно ли просто объединить логику в launcher2, чтобы затем использовать её для запуска установки.

Количество препятствий, через которые прыгает discourse-setup, просто впечатляет.

Возможно ли скомпилировать лаунчер в исполняемый двоичный файл для релиза? Таким образом, лаунчер (bash) сможет скачивать соответствующий исполняемый файл (binary) только для вашей системы.