package config import ( "errors" "fmt" "net/netip" "os" "strings" "time" "github.com/BurntSushi/toml" "git.vakhrushev.me/av/transcriber/internal/entity" ) type Config struct { Server ServerConfig `toml:"server"` Storage StorageConfig `toml:"storage"` Pipeline PipelineConfig `toml:"pipeline"` Yandex YandexConfig `toml:"yandex"` Auth AuthConfig `toml:"auth"` } // PipelineConfig — настройки конвейера расшифровки. type PipelineConfig struct { // Workers — число одинаковых воркеров. Ноль — законное значение: сервис // поднимается, записи принимаются и не двигаются. Это режим, а не поломка. Workers int `toml:"workers"` // OwnWorkLimitMinutes — предел простоя записи там, где работу делаем мы // сами. Сторож ловит **зависание**, а не долгую работу: живой шаг // наблюдается по самому процессу, а остановка обратима — снятие признака // возвращает запись на её рубеж. OwnWorkLimitMinutes int `toml:"own_work_limit_minutes"` // ForeignWorkLimitMinutes — предел простоя там, где ждём чужую операцию. // Сколько идёт распознавание долгой записи, никто не мерил, поэтому ошибка // идёт в сторону долгого: ложная остановка хуже поздней. ForeignWorkLimitMinutes int `toml:"foreign_work_limit_minutes"` } // StuckLimits переводит настройки в пределы простоя. func (c PipelineConfig) StuckLimits() entity.StuckLimits { return entity.StuckLimits{ Own: time.Duration(c.OwnWorkLimitMinutes) * time.Minute, Foreign: time.Duration(c.ForeignWorkLimitMinutes) * time.Minute, } } // Validate проверяет числа конвейера. Отрицательное число воркеров — ошибка // настройки, а не режим: ноль объявлен законным значением, и отличать его от // опечатки обязан старт. func (c PipelineConfig) Validate() error { if c.Workers < 0 { return errors.New("pipeline: число воркеров не может быть отрицательным") } if c.OwnWorkLimitMinutes <= 0 || c.ForeignWorkLimitMinutes <= 0 { return errors.New("pipeline: пределы простоя задаются положительным числом минут") } return nil } type ServerConfig struct { Port int `toml:"port"` ShutdownTimeout int `toml:"shutdown_timeout"` ForceShutdownTimeout int `toml:"force_shutdown_timeout"` } // StorageConfig — единственный каталог данных: под ним лежат и база, и файлы // записей. Двух путей, как было раньше, у хранилища не бывает. type StorageConfig struct { DataDir string `toml:"data_dir"` } type YandexConfig struct { FolderID string `toml:"folder_id"` SpeechKitAPIKey string `toml:"speech_kit_api_key"` ObjStorageAccessKey string `toml:"object_storage_access_key_id"` ObjStorageSecretKey string `toml:"object_storage_secret_access_key"` ObjStorageBucketName string `toml:"object_storage_bucket_name"` ObjStorageRegion string `toml:"object_storage_region"` ObjStorageEndpoint string `toml:"object_storage_endpoint"` } // AuthConfig — кому сервис верит на входе. // // Своего входа у сервиса нет: кто пришёл, называет обратный прокси заголовком, // сходив к провайдеру. Секция поэтому свелась к одному ключу — перечню адресов, // чьему заголовку верить. Ни адресов провайдера, ни идентификатора клиента, ни // его секрета здесь больше нет: обменивать код не на что, и секрет ушёл из // конфига вместе с протоколом. type AuthConfig struct { // TrustedProxies — адреса и подсети, чьему заголовку верят. Значение // сверяется с адресом самого соединения, а не с пересылаемым заголовком: // пересылаемым распоряжается тот, кто шлёт запрос, и барьер, подделываемый // той же строкой, которой он обходится, не барьер вовсе. TrustedProxies []string `toml:"trusted_proxies"` } // TrustedNetworks разбирает перечень доверенных адресов. // // Одиночный адрес принимается наравне с подсетью и превращается в подсеть на // один адрес: писать `/32` руками значит помнить разрядность, а перечень читает // человек. func (c AuthConfig) TrustedNetworks() ([]netip.Prefix, error) { networks := make([]netip.Prefix, 0, len(c.TrustedProxies)) for _, raw := range c.TrustedProxies { value := strings.TrimSpace(raw) if prefix, err := netip.ParsePrefix(value); err == nil { networks = append(networks, prefix.Masked()) continue } addr, err := netip.ParseAddr(value) if err != nil { return nil, fmt.Errorf("auth: %s не читается как адрес или подсеть: %q", trustedProxiesKey, value) } networks = append(networks, netip.PrefixFrom(addr, addr.BitLen())) } return networks, nil } // trustedProxiesKey — имя ключа в отказах старта. Литерал один на файл: два // разошлись бы молча, и владелец искал бы в конфиге ключ, которого там нет. const trustedProxiesKey = "trusted_proxies" // Validate проверяет, что сервису есть кому верить. // // Пустой перечень роняет старт. Он значит «не верить никому», то есть сервис, // поднявшийся никого не узнающим, — и молчать об этом старт не вправе: узнать о // такой поломке было бы неоткуда, все адреса приложения просто отвечали бы // отказом. // // Нечитаемая строка роняет старт по той же причине: перечень с опечаткой // проверяется только тем, что кто-то не смог войти. func (c AuthConfig) Validate() error { if len(c.TrustedProxies) == 0 { return fmt.Errorf("auth: не заполнен ключ %s: сервису некому верить, и узнать он никого не сможет", trustedProxiesKey) } if _, err := c.TrustedNetworks(); err != nil { return err } return nil } // DefaultConfig returns a Config with default values func defaultConfig() *Config { return &Config{ Server: ServerConfig{ Port: 8080, ShutdownTimeout: 5, ForceShutdownTimeout: 20, }, Storage: StorageConfig{ DataDir: "data", }, Pipeline: PipelineConfig{ Workers: 3, // Час на свою работу и сутки на чужую. Час меньше времени, которое // многочасовая запись занимает на приведении, и это принято // сознательно: сторож ловит зависание, живой шаг наблюдается по // самому процессу, а остановка обратима. OwnWorkLimitMinutes: 60, ForeignWorkLimitMinutes: 24 * 60, }, Yandex: YandexConfig{ FolderID: "", SpeechKitAPIKey: "", ObjStorageAccessKey: "", ObjStorageSecretKey: "", ObjStorageBucketName: "", ObjStorageRegion: "ru-central1", ObjStorageEndpoint: "https://storage.yandexcloud.net/", }, // Умолчания у перечня доверенных адресов нет намеренно: подставленное // значение соврало бы ровно там, где по нему решают, кого пускать. Auth: AuthConfig{}, } } // LoadConfig loads configuration from a TOML file func LoadConfig(path string) (*Config, error) { // Check if file exists if _, err := os.Stat(path); os.IsNotExist(err) { return nil, fmt.Errorf("config file not found: %s", path) } config := defaultConfig() // Load configuration from file if _, err := toml.DecodeFile(path, &config); err != nil { return nil, decodeError(path, err) } 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) }