config: включение Telegram разведено с ключом доступа
- в секции [telegram] заведён обязательный ключ enabled: умолчания у него нет, файл без него негоден; bot_token стал только ключом доступа и при enabled = false не читается вовсе, а пустой при enabled = true роняет старт - выключенный вход даёт подъём одним входом без единого обращения к Telegram и записью INFO вместо прежнего WARN: это выбор владельца, а не отклонение - отказ разбора файла настроек больше не пересказывает toml — её ParseError несёт в тексте разбираемое значение, и оборванная строка секретного ключа уносила его в журнал; теперь называются путь, строка, столбец и последний ключ
This commit is contained in:
@@ -4,9 +4,10 @@ import (
|
||||
"git.vakhrushev.me/av/transcriber/internal/contract"
|
||||
)
|
||||
|
||||
// AbsentMessageSender подставляется вместо отправителя Telegram, когда токен
|
||||
// бота не задан и клиента заводить не из чего. Он ничего не отправляет и на
|
||||
// всякий ответ отдаёт `contract.ErrDeliveryChannelDown`.
|
||||
// AbsentMessageSender подставляется вместо отправителя Telegram, когда вход
|
||||
// выключен признаком `telegram.enabled` либо Telegram оказался недоступен, и
|
||||
// клиента заводить не из чего. Он ничего не отправляет и на всякий ответ отдаёт
|
||||
// `contract.ErrDeliveryChannelDown`.
|
||||
//
|
||||
// Заглушка, а не пустой отправитель: необязательная зависимость, доехавшая до
|
||||
// ядра нулём, роняет процесс на первой же задаче из Telegram, а проверка на
|
||||
|
||||
@@ -12,8 +12,14 @@ import (
|
||||
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
|
||||
)
|
||||
|
||||
// ErrEmptyToken — токен бота не задан. Отдельным значением, потому что подъём
|
||||
// без Telegram — законный исход: сервис продолжает работать с HTTP API.
|
||||
// ErrEmptyToken — ключ доступа пуст при включённом входе, то есть **ошибка
|
||||
// настройки**: старт роняется. Отдельным значением, чтобы отличаться от
|
||||
// недоступности Telegram, у которой исход обратный — подъём без бота.
|
||||
//
|
||||
// Отказ от входа Telegram этим значением больше не выражается: намерение
|
||||
// объявляет признак включения `telegram.enabled`, и выключенный вход отсеивается
|
||||
// до всякого обращения сюда. Пустой ключ ловит проверка настроек ещё раньше,
|
||||
// поэтому сюда он доходит только в обход проверки.
|
||||
var ErrEmptyToken = errors.New("telegram bot token is empty")
|
||||
|
||||
// NewBot заводит клиента Bot API — и это **единая точка**, через которую с
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
@@ -41,11 +42,33 @@ type YandexConfig struct {
|
||||
ObjStorageEndpoint string `toml:"object_storage_endpoint"`
|
||||
}
|
||||
|
||||
// TelegramConfig — вход Telegram. Признак включения объявляет намерение
|
||||
// владельца, `BotToken` означает только доступ. Пока два значения жили в одном
|
||||
// поле, пустой токен читался разом как «вход выключен» и как «ключ не доехал»,
|
||||
// и сервис поднимался без бота в обоих случаях.
|
||||
type TelegramConfig struct {
|
||||
// Enabled — умолчания у него нет **намеренно**, и потому его нет в
|
||||
// `defaultConfig()`: умолчание было бы угаданным намерением, а признак
|
||||
// заведён затем, чтобы намерение объявляли. Отсутствие ключа в файле ловит
|
||||
// `LoadConfig` — нулевое значение `bool` режима не выбирает.
|
||||
Enabled bool `toml:"enabled"`
|
||||
BotToken string `toml:"bot_token"`
|
||||
UpdateTimeout int `toml:"update_timeout"`
|
||||
}
|
||||
|
||||
// Validate проверяет ключ доступа против объявленного намерения. Пустой ключ
|
||||
// при включённом входе — ошибка настройки: бот по нему не появится, а тихий
|
||||
// подъём без бота оставил бы отправителей без ответов.
|
||||
//
|
||||
// Названо имя ключа, а не значение: значение `bot_token` в журнал попасть не
|
||||
// должно.
|
||||
func (c TelegramConfig) Validate() error {
|
||||
if c.Enabled && c.BotToken == "" {
|
||||
return errors.New("telegram: не заполнен ключ bot_token при enabled = true")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// AuthConfig — вход через внешнего провайдера OIDC. Адреса, идентификатор
|
||||
// клиента и секрет приезжают сюда и приводятся к настройкам коллекции
|
||||
// пользователей при каждом подъёме: применённый шаг схемы не переписывается, и
|
||||
@@ -129,6 +152,7 @@ func defaultConfig() *Config {
|
||||
ObjStorageRegion: "ru-central1",
|
||||
ObjStorageEndpoint: "https://storage.yandexcloud.net/",
|
||||
},
|
||||
// Умолчания у `Enabled` здесь нет намеренно — причина у поля.
|
||||
Telegram: TelegramConfig{
|
||||
BotToken: "",
|
||||
UpdateTimeout: 10,
|
||||
@@ -149,9 +173,48 @@ func LoadConfig(path string) (*Config, error) {
|
||||
config := defaultConfig()
|
||||
|
||||
// Load configuration from file
|
||||
if _, err := toml.DecodeFile(path, &config); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode config file: %w", err)
|
||||
meta, err := toml.DecodeFile(path, &config)
|
||||
if err != nil {
|
||||
return nil, decodeError(path, err)
|
||||
}
|
||||
|
||||
// Признак включения входа Telegram обязателен: умолчания у него нет, и
|
||||
// отличить «не задан» от «задан ложным» умеет только разбор — нулевое
|
||||
// значение `bool` в структуре у обоих одинаковое. Отсюда и `meta`: наружу
|
||||
// она не отдаётся, приговор выносится здесь.
|
||||
if !meta.IsDefined("telegram", "enabled") {
|
||||
return nil, errors.New("telegram: не задан ключ enabled; он объявляет, нужен ли сервису вход Telegram")
|
||||
}
|
||||
|
||||
return config, nil
|
||||
}
|
||||
|
||||
// decodeError переводит отказ разбора на свои слова. Пересказывать библиотеку
|
||||
// нельзя: она собирает текст отказа из разбираемого куска файла, и оборванная
|
||||
// строка секретного ключа уехала бы в журнал вместе со значением.
|
||||
//
|
||||
// Разрез идёт по семейству отказа, и значения несёт только одно:
|
||||
//
|
||||
// - `toml.ParseError` — сюда сведены отказы лексера и разбора значения, а его
|
||||
// `Message` собран из разбираемого куска («Invalid float value: %q»,
|
||||
// «invalid duration: %q»). Берём строку, столбец и последний ключ — они
|
||||
// безопасны, — а `Message` не берём;
|
||||
// - прочие отказы декодера собраны из имён ключей и имён типов, значений в них
|
||||
// нет вовсе. Их текст берём как есть: выбросив его, мы заплатили бы
|
||||
// разборчивостью отказа там, где платить не за что.
|
||||
//
|
||||
// Две ветки не сводятся в одну намеренно. Сведённая к общему знаменателю, она
|
||||
// либо вернёт утечку, либо оставит несовпадение типов без единого намёка.
|
||||
func decodeError(path string, err error) error {
|
||||
var parseErr toml.ParseError
|
||||
if errors.As(err, &parseErr) {
|
||||
if parseErr.LastKey != "" {
|
||||
return fmt.Errorf("config file %s: разбор оборвался на строке %d, столбце %d, последний ключ %q",
|
||||
path, parseErr.Position.Line, parseErr.Position.Col, parseErr.LastKey)
|
||||
}
|
||||
return fmt.Errorf("config file %s: разбор оборвался на строке %d, столбце %d",
|
||||
path, parseErr.Position.Line, parseErr.Position.Col)
|
||||
}
|
||||
|
||||
return fmt.Errorf("failed to decode config file %s: %w", path, err)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
@@ -95,3 +98,179 @@ func TestAuthConfigValidateRejectsMalformedURL(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Признак включения объявляет намерение, ключ доступа означает только доступ.
|
||||
// Пока эти два значения жили в одном поле, пустой токен читался разом как
|
||||
// «вход выключен» и как «ключ не доехал».
|
||||
|
||||
func TestTelegramConfigValidateAcceptsEnabledWithToken(t *testing.T) {
|
||||
cfg := TelegramConfig{Enabled: true, BotToken: "123456:AA-fake"}
|
||||
|
||||
if err := cfg.Validate(); err != nil {
|
||||
t.Fatalf("включённый вход с ключом отвергнут: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTelegramConfigValidateRejectsEnabledWithoutToken(t *testing.T) {
|
||||
cfg := TelegramConfig{Enabled: true, BotToken: ""}
|
||||
|
||||
err := cfg.Validate()
|
||||
if err == nil {
|
||||
t.Fatal("включённый вход без ключа доступа пропущен")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "bot_token") {
|
||||
t.Fatalf("имя ключа не названо: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Выключенный вход на ключ доступа не смотрит вовсе: пустой ключ при нём —
|
||||
// обычное состояние локального прогона, а не ошибка настройки.
|
||||
func TestTelegramConfigValidateIgnoresTokenWhenDisabled(t *testing.T) {
|
||||
cfg := TelegramConfig{Enabled: false, BotToken: ""}
|
||||
|
||||
if err := cfg.Validate(); err != nil {
|
||||
t.Fatalf("выключенный вход без ключа отвергнут: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Половина требования «сообщение не несёт значения ключа» на этой проверке
|
||||
// **вакуумна**, и честнее это назвать, чем изображать сторожа.
|
||||
//
|
||||
// `Validate()` отказывает ровно на пустом ключе — значения, которым можно
|
||||
// проговориться, на этом пути не существует. Прежняя редакция сторожа искала
|
||||
// подстроку, которой в сообщении нет ни при каком входе, и потому не могла
|
||||
// упасть вовсе: правка на `%q` от токена оставила бы её зелёной. В проекте это
|
||||
// третий пойманный случай проверки, не способной упасть.
|
||||
//
|
||||
// Настоящий сторож той же нормы живёт там, где непустой ключ в отказ попасть
|
||||
// действительно может, — `TestLoadConfigMalformedSecretLineHidesValue` и
|
||||
// `TestLoadConfigMalformedBeforeAnyKeyHidesValue`. Здесь проверяется то, что
|
||||
// проверяемо: заполненный ключ проходит, пустой отвергается с именем ключа.
|
||||
func TestTelegramConfigValidateNamesKeyWithoutValue(t *testing.T) {
|
||||
filled := TelegramConfig{Enabled: true, BotToken: "123456:AAHfake-secret-token-value"}
|
||||
if err := filled.Validate(); err != nil {
|
||||
t.Fatalf("включённый вход с заполненным ключом отвергнут: %v", err)
|
||||
}
|
||||
|
||||
err := TelegramConfig{Enabled: true, BotToken: ""}.Validate()
|
||||
if err == nil {
|
||||
t.Fatal("включённый вход без ключа доступа пропущен")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "bot_token") {
|
||||
t.Fatalf("имя ключа не названо: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func writeConfig(t *testing.T, body string) string {
|
||||
t.Helper()
|
||||
|
||||
path := filepath.Join(t.TempDir(), "config.toml")
|
||||
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
|
||||
t.Fatalf("не удалось записать файл настроек: %v", err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
const validConfigBody = `
|
||||
[telegram]
|
||||
enabled = false
|
||||
bot_token = ""
|
||||
`
|
||||
|
||||
// Признак обязателен: файл без него негоден. Умолчание было бы угаданным
|
||||
// намерением, а отличить «не задан» от «задан ложным» умеет только разбор —
|
||||
// нулевое значение bool у обоих одинаковое.
|
||||
func TestLoadConfigRejectsMissingTelegramEnabled(t *testing.T) {
|
||||
path := writeConfig(t, "[telegram]\nbot_token = \"123456:AA-fake\"\n")
|
||||
|
||||
_, err := LoadConfig(path)
|
||||
if err == nil {
|
||||
t.Fatal("файл без признака включения принят")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "enabled") {
|
||||
t.Fatalf("имя недостающего ключа не названо: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConfigReadsBothValuesOfTelegramEnabled(t *testing.T) {
|
||||
for _, enabled := range []bool{true, false} {
|
||||
t.Run(fmt.Sprintf("%t", enabled), func(t *testing.T) {
|
||||
body := fmt.Sprintf("[telegram]\nenabled = %t\nbot_token = \"123456:AA-fake\"\n", enabled)
|
||||
|
||||
cfg, err := LoadConfig(writeConfig(t, body))
|
||||
if err != nil {
|
||||
t.Fatalf("годный файл отвергнут: %v", err)
|
||||
}
|
||||
if cfg.Telegram.Enabled != enabled {
|
||||
t.Fatalf("признак доехал как %t, а в файле %t", cfg.Telegram.Enabled, enabled)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Инвариант «секрет не покидает конфиг»: текст отказа разбора собирает чужая
|
||||
// библиотека из разбираемого куска файла, и оборванная строка ключа доступа
|
||||
// уехала бы в журнал вместе со значением. Отсюда собственное сообщение.
|
||||
func TestLoadConfigMalformedSecretLineHidesValue(t *testing.T) {
|
||||
const secret = "123456:AAHfake-secret-token-value"
|
||||
// Кавычка не закрыта: разбор оборвётся на значении.
|
||||
path := writeConfig(t, "[telegram]\nenabled = true\nbot_token = \""+secret+"\n")
|
||||
|
||||
_, err := LoadConfig(path)
|
||||
if err == nil {
|
||||
t.Fatal("поломанный файл настроек принят")
|
||||
}
|
||||
|
||||
message := err.Error()
|
||||
for _, part := range []string{secret, "AAHfake", "secret-token-value", "123456"} {
|
||||
if strings.Contains(message, part) {
|
||||
t.Fatalf("значение ключа доступа уехало в отказ: %v", err)
|
||||
}
|
||||
}
|
||||
// Без места и ключа отказ нечинибелен: скрыть значение мало.
|
||||
if !strings.Contains(message, "строке 3") {
|
||||
t.Fatalf("номер строки не назван, чинить нечего: %v", err)
|
||||
}
|
||||
if !strings.Contains(message, "bot_token") {
|
||||
t.Fatalf("ключ не назван, чинить нечего: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Обратная сторона того же разреза: отказ несовпадения типов собран из имён
|
||||
// ключей и типов, значений в нём нет, и выбрасывать его текст незачем.
|
||||
func TestLoadConfigTypeMismatchKeepsDiagnostics(t *testing.T) {
|
||||
path := writeConfig(t, "[server]\nport = \"8080\"\n"+validConfigBody)
|
||||
|
||||
_, err := LoadConfig(path)
|
||||
if err == nil {
|
||||
t.Fatal("строка вместо числа принята")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "port") {
|
||||
t.Fatalf("имя ключа не названо, чинить нечего: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Вторая ветка разреза: разбор оборвался до всякого ключа, и последнего ключа
|
||||
// нет вовсе. Пропущенная скобка секции — обычная опечатка, а ветка эта самая
|
||||
// уязвимая: именно в ней будущая правка легче всего протащит текст библиотеки
|
||||
// обратно.
|
||||
func TestLoadConfigMalformedBeforeAnyKeyHidesValue(t *testing.T) {
|
||||
const secret = "123456:AAHsecret-token-value"
|
||||
// У секции не закрыта скобка: разбор оборвётся, не назвав ни одного ключа.
|
||||
path := writeConfig(t, "[telegram\nenabled = true\nbot_token = \""+secret+"\"\n")
|
||||
|
||||
_, err := LoadConfig(path)
|
||||
if err == nil {
|
||||
t.Fatal("поломанный файл настроек принят")
|
||||
}
|
||||
|
||||
message := err.Error()
|
||||
for _, part := range []string{secret, "AAHsecret", "secret-token-value"} {
|
||||
if strings.Contains(message, part) {
|
||||
t.Fatalf("значение ключа доступа уехало в отказ: %v", err)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(message, "строке") {
|
||||
t.Fatalf("место отказа не названо, чинить нечего: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user