Files
tglock/src/cli_settings.rs
T
Никита Sonic 9429975403 feat(cli): файл настроек tglock.toml (#35)
* fix(bundle): спрятать CLI за фичей — beta.2 и beta.3 упаковывали не тот бинарь

Мой предыдущий фикс (mainBinaryName в #31) фиксом не был. Он изменил только
имя выходного файла: бандлер по-прежнему брал headless CLI и просто
переименовывал его в tglock. Проверено по содержимому опубликованных
артефактов, а не по имени:

  версия  файл в MacOS/  размер  признаки CLI       признаки GUI
  beta.1  tglock         17.8 МБ  нет               ipc.localhost, wry×3206
  beta.2  tglock-cli      4.3 МБ  tglock-cli×6      нет
  beta.3  tglock          4.3 МБ  tglock-cli×6      нет

То есть beta.3 тоже не запускается, и моя же проверка CFBundleExecutable это
пропустила, потому что сверяла имя.

Настоящая причина найдена воспроизведением локально. Ломается только при явном
--target: без него бандлер выбирает GUI, с ним — CLI. Локальная сборка, на
которой я объявил фикс подтверждённым, шла без --target, а обе сборки в CI — с
ним. Обобщение было неправомерным.

Исправление убирает саму возможность выбора: CLI спрятан за фичей cli, которой
нет в default. При сборке приложения второго бинаря просто не существует.

Проверено против воспроизведённой поломки:
  до : tglock.exe 1.7 МБ, признаки CLI, GUI нет
  после: tglock.exe 9.0 МБ, признаки GUI, CLI нет, tglock-cli.exe не собран

Проверки переписаны на содержимое:
- release.yml распаковывает .app и .deb и ищет ipc.localhost (есть только в
  GUI) и allow-direct (есть только в CLI). Поймала бы и beta.2, и beta.3;
- новая задача CI bundle собирает бандл с явным --target aarch64-apple-darwin
  и проверяет его так же. Ловит до публикации, а не после;
- заодно исправлен шаблон grep для .deb: dpkg-deb -c выводит путь без ./, из-за
  чего проверка ложно падала на исправной сборке.

Команда сборки CLI теперь требует --features cli; обновлены README, ci.yml и
release.yml.

* fix(ci): одна проверка бандла на Python вместо трёх копий grep

Проверка содержимого бандла, добавленная в #33, работала на Linux и давала
ложное «в бандле не GUI» на macOS. Причина в BSD grep: в UTF-8-локали он молча
не находит строки в бинарных данных там, где GNU grep находит. Из-за этого
задача macOS в релизе beta.4 упала уже после загрузки артефактов, а вместе с
ней снова пропустилась задача с CLI-бинарями.

Сами артефакты beta.4 при этом корректны — проверено скачиванием: в бандле
tglock на 20.3 МБ, universal/fat, признаки GUI на месте, признаков CLI нет.
Не хватает только tglock-cli-*.

Проверка вынесена в scripts/verify_bundle_binary.py и вызывается из всех трёх
мест: задачи CI bundle, проверки macOS и проверки Linux в релизе. Один скрипт
вместо трёх копий шелл-кода исключает и платформенные различия grep, и
расхождение копий между собой.

Скрипт проверен на реальных исторических артефактах:
  beta.4 (исправный)  → код 0
  beta.3 (сломанный)  → код 1, найдены allow-direct и secret-file
  beta.2 (сломанный)  → код 1, то же

* feat(cli): файл настроек tglock.toml

Запрошено в #32: держать все параметры и секрет в одном месте, чтобы не
собирать батник с ключами при каждом запуске.

Файл ищется рядом с бинарём под именем tglock.toml — как и просили в ишью, —
либо указывается через --config. Приоритеты: значения по умолчанию → файл →
флаги. Флаги-переключатели могут только включать: отсутствие --quiet не
отменяет quiet = true из файла, иначе файлом нельзя было бы ничего включить.

Секрет можно вписать прямо в конфиг, в том числе в форме с префиксом dd — то
есть скопировав из напечатанной ссылки tg://proxy. Отдельный secret_file
остаётся, inline-секрет важнее.

deny_unknown_fields намеренно: опечатка вроде porrt = 1443 останавливает старт
с перечислением допустимых полей. Сервис, который из-за опечатки слушает 1080
вместо 1443, хуже сервиса, который не запустился. Явный --config к
несуществующему файлу тоже ошибка, а не тихий откат к настройкам по умолчанию.

Если секрет не закреплён ни одним из способов, CLI печатает предупреждение:
после перезапуска ссылка станет другой и настроенные клиенты отвалятся.

Найдено при живом прогоне и исправлено здесь же: демон падал, если stdout
закрывался. println! при ошибке записи паникует, а канал закрывается штатно —
`| head`, закрытый терминал, перезапуск сборщика логов. Воспроизводилось
одной командой:

  tglock-cli --port 18101 | head -2
  thread 'tokio-rt-worker' panicked at stdio.rs: failed printing to stdout

Печать переведена на say(), который возвращает признак успеха; наблюдатель
статуса при закрытом stdout просто прекращает печатать, туннель продолжает
работать.

Тестов в CLI стало 26 вместо 12: приоритеты, конфликт lan и bind из разных
источников, обе формы записи секрета, отказ на опечатке и на битых значениях,
комментарии в файле. Плюс parse_secret в ядре с проверкой того, что секрет,
сам начинающийся с dd, не теряет первый байт.

В CI добавлены три шага: чтение настроек из файла, перекрытие флагом и отказ
на опечатке; отдельно — проверка, что закрытый stdout не роняет демон.

Документация: секция про файл настроек в README, полный пример с пояснениями в
tglock.example.toml, юнит systemd переведён на --config.

* fix: вернуть плоский путь бинаря, модуль настроек — в библиотеку

Проверка бандла в CI упала на этом PR:

  failed to rename app binary .../release/cli: No such file or directory

Причина моя. Я перенёс CLI в каталожную форму src/bin/cli/main.rs, и
перечисление бинарей в Tauri вывело имя приложения из имени каталога —
получилось "cli", которого не существует. Явное name = "tglock-cli" в
[[bin]] при этом игнорируется. До переноса, при плоском src/bin/cli.rs,
всё собиралось.

Путь бинаря вернулся к src/bin/cli.rs, а модуль настроек переехал в
библиотеку как cli_settings под фичей cli. Ему там и место: он работает с
ListenConfig, mtproto и proxy, а clap не использует вовсе.

Проверено воспроизведением того же условия локально — сборка бандла с явным
--target: приложение собирается, скрипт проверки подтверждает GUI-бинарь
(9.0 МБ, ipc.localhost и wry на месте, признаков CLI нет).

Тестов: 66 в библиотеке с фичей cli, 12 в бинаре, clippy чист в обоих
вариантах сборки. Живой прогон с конфигом и отказ на опечатке сохранились.

---------

Co-authored-by: by-sonic <171230345+by-sonic@users.noreply.github.com>
2026-07-30 20:19:31 +03:00

361 lines
13 KiB
Rust

//! Файл конфигурации для `tglock-cli`.
//!
//! Живёт в библиотеке, а не рядом с бинарём: каталожная форма `src/bin/cli/`
//! ломает перечисление бинарей в Tauri — он выводит имя приложения из имени
//! каталога и пытается упаковать несуществующий `cli`.
//!
//! Запрошен в by-sonic/tglock#32: держать все параметры и секрет в одном месте,
//! чтобы не собирать батник с ключами при каждом запуске.
//!
//! Порядок приоритетов: значения по умолчанию → файл → флаги командной строки.
//! Флаги-переключатели (`--lan`, `--allow-direct`, `--quiet`) могут только
//! включать: их отсутствие означает «взять из файла», а не «выключить».
use crate::config::ListenConfig;
use crate::{mtproto, proxy};
use serde::Deserialize;
use std::net::IpAddr;
use std::path::{Path, PathBuf};
/// Имя файла, который ищется рядом с бинарём, если `--config` не задан.
pub const DEFAULT_FILE_NAME: &str = "tglock.toml";
/// Содержимое файла конфигурации.
///
/// `deny_unknown_fields` намеренно: опечатка вроде `porrt = 1080` должна быть
/// ошибкой при старте, а не молча проигнорированной строкой, из-за которой
/// сервис слушает не тот порт.
#[derive(Debug, Default, Deserialize, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct FileConfig {
pub bind: Option<IpAddr>,
pub port: Option<u16>,
pub lan: Option<bool>,
pub allow_direct: Option<bool>,
pub worker: Option<Vec<String>>,
pub secret: Option<String>,
pub secret_file: Option<PathBuf>,
pub quiet: Option<bool>,
}
impl FileConfig {
pub fn parse(text: &str) -> Result<Self, String> {
toml::from_str(text).map_err(|error| format!("конфиг разобрать не удалось: {error}"))
}
pub fn load(path: &Path) -> Result<Self, String> {
let text = std::fs::read_to_string(path)
.map_err(|error| format!("не удалось прочитать {}: {error}", path.display()))?;
Self::parse(&text)
}
}
/// Значения, пришедшие из командной строки.
#[derive(Debug, Default)]
pub struct Overrides {
pub bind: Option<IpAddr>,
pub port: Option<u16>,
pub lan: bool,
pub allow_direct: bool,
pub worker: Vec<String>,
pub secret_file: Option<PathBuf>,
pub quiet: bool,
}
/// Откуда взять секрет прокси.
#[derive(Debug, PartialEq)]
pub enum SecretSource {
/// Записан прямо в конфиге.
Inline([u8; 16]),
/// Лежит в отдельном файле, создаётся при первом запуске.
File(PathBuf),
/// Ни того, ни другого: секрет будет новым при каждом старте.
Ephemeral,
}
/// Итоговые настройки запуска.
#[derive(Debug, PartialEq)]
pub struct Resolved {
pub listen: ListenConfig,
/// Домены Worker'ов в том виде, в каком их ждёт `Stats::set_worker_domain`.
pub workers: String,
pub secret: SecretSource,
pub quiet: bool,
}
impl Resolved {
pub fn stats(&self) -> std::sync::Arc<proxy::Stats> {
let stats = match &self.secret {
SecretSource::Inline(secret) => proxy::Stats::with_secret(*secret),
SecretSource::File(path) => {
proxy::Stats::with_secret(mtproto::load_or_create_secret_at(path))
}
SecretSource::Ephemeral => proxy::Stats::new(),
};
stats.set_worker_domain(&self.workers);
stats
}
}
/// Свести файл и флаги в одни настройки.
pub fn resolve(file: FileConfig, cli: Overrides) -> Result<Resolved, String> {
let port = cli.port.or(file.port).unwrap_or(proxy::DEFAULT_PORT);
if port == 0 {
return Err("порт должен быть от 1 до 65535".to_owned());
}
let lan = cli.lan || file.lan.unwrap_or(false);
let bind = cli.bind.or(file.bind);
if lan && bind.is_some() {
return Err("нельзя задать одновременно lan и bind: выберите одно".to_owned());
}
let listen = match (lan, bind) {
(true, _) => ListenConfig::lan(port),
(false, Some(ip)) => ListenConfig::new(ip, port),
(false, None) => ListenConfig::loopback(port),
};
let allow_direct = cli.allow_direct || file.allow_direct.unwrap_or(false);
let listen = if allow_direct {
listen.with_allow_direct(true)
} else {
listen
};
let workers = if cli.worker.is_empty() {
file.worker.unwrap_or_default()
} else {
cli.worker
};
// Секрет из файла конфигурации важнее отдельного файла: если человек вписал
// его сюда, значит хотел держать всё в одном месте.
let secret = match (&file.secret, cli.secret_file.or(file.secret_file)) {
(Some(value), _) => SecretSource::Inline(
mtproto::parse_secret(value)
.ok_or("секрет в конфиге неверный: нужны 32 hex-символа, можно с префиксом dd")?,
),
(None, Some(path)) => SecretSource::File(path),
(None, None) => SecretSource::Ephemeral,
};
Ok(Resolved {
listen,
workers: workers.join(","),
secret,
quiet: cli.quiet || file.quiet.unwrap_or(false),
})
}
/// Путь к конфигу рядом с исполняемым файлом.
///
/// Именно этого просили в #32: «размещение рядом с бинарником частично решает
/// вопрос». Текущий каталог не используется, чтобы сервис не зависел от того,
/// откуда его запустили.
pub fn path_next_to_executable() -> Option<PathBuf> {
let executable = std::env::current_exe().ok()?;
Some(executable.parent()?.join(DEFAULT_FILE_NAME))
}
#[cfg(test)]
mod tests {
use super::*;
const SECRET: &str = "00112233445566778899aabbccddeeff";
fn cli() -> Overrides {
Overrides::default()
}
#[test]
fn empty_config_and_no_flags_give_the_documented_defaults() {
let resolved = resolve(FileConfig::default(), cli()).unwrap();
assert_eq!(resolved.listen.addr.to_string(), "127.0.0.1:1080");
assert!(resolved.listen.allow_direct);
assert_eq!(resolved.workers, "");
assert_eq!(resolved.secret, SecretSource::Ephemeral);
assert!(!resolved.quiet);
}
#[test]
fn file_values_are_used_when_no_flags_are_given() {
let file = FileConfig::parse(
r#"
bind = "10.0.0.5"
port = 1443
allow_direct = true
worker = ["a.workers.dev", "b.workers.dev"]
quiet = true
"#,
)
.unwrap();
let resolved = resolve(file, cli()).unwrap();
assert_eq!(resolved.listen.addr.to_string(), "10.0.0.5:1443");
assert!(resolved.listen.allow_direct);
assert_eq!(resolved.workers, "a.workers.dev,b.workers.dev");
assert!(resolved.quiet);
}
#[test]
fn flags_win_over_the_file() {
let file = FileConfig::parse(
r#"
bind = "10.0.0.5"
port = 1443
worker = ["from-file.workers.dev"]
"#,
)
.unwrap();
let resolved = resolve(
file,
Overrides {
bind: Some("192.168.1.7".parse().unwrap()),
port: Some(2080),
worker: vec!["from-flag.workers.dev".to_owned()],
..Overrides::default()
},
)
.unwrap();
assert_eq!(resolved.listen.addr.to_string(), "192.168.1.7:2080");
assert_eq!(resolved.workers, "from-flag.workers.dev");
}
#[test]
fn a_switch_flag_can_only_turn_things_on() {
// Отсутствие --quiet не должно отменять quiet = true из файла: иначе
// файл нельзя было бы использовать для включения ничего.
let file = FileConfig::parse("quiet = true\nallow_direct = true").unwrap();
let resolved = resolve(file, cli()).unwrap();
assert!(resolved.quiet);
assert!(resolved.listen.allow_direct);
}
#[test]
fn lan_from_the_file_restricts_to_telegram() {
let resolved = resolve(FileConfig::parse("lan = true").unwrap(), cli()).unwrap();
assert_eq!(resolved.listen.addr.to_string(), "0.0.0.0:1080");
assert!(
!resolved.listen.allow_direct,
"сетевой слушатель не должен релеить произвольные адреса без явного разрешения"
);
}
#[test]
fn lan_and_bind_together_are_rejected_wherever_they_come_from() {
let both_in_file = FileConfig::parse("lan = true\nbind = \"10.0.0.5\"").unwrap();
assert!(resolve(both_in_file, cli()).is_err());
let file = FileConfig::parse("bind = \"10.0.0.5\"").unwrap();
let flag_lan = Overrides {
lan: true,
..Overrides::default()
};
assert!(
resolve(file, flag_lan).is_err(),
"конфликт должен ловиться и когда стороны пришли из разных мест"
);
}
#[test]
fn inline_secret_is_accepted_in_both_written_forms() {
for value in [SECRET.to_owned(), format!("dd{SECRET}")] {
let file = FileConfig::parse(&format!("secret = \"{value}\"")).unwrap();
let resolved = resolve(file, cli()).unwrap();
assert_eq!(
resolved.secret,
SecretSource::Inline([
0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xaa, 0xbb, 0xcc,
0xdd, 0xee, 0xff,
]),
"форма {value} должна приниматься"
);
}
}
#[test]
fn a_broken_inline_secret_stops_the_start() {
let file = FileConfig::parse("secret = \"явно не секрет\"").unwrap();
let error = resolve(file, cli()).unwrap_err();
assert!(
error.contains("32"),
"ошибка должна объяснять формат, получено: {error}"
);
}
#[test]
fn inline_secret_wins_over_a_secret_file() {
let file =
FileConfig::parse(&format!("secret = \"{SECRET}\"\nsecret_file = \"s.bin\"")).unwrap();
let resolved = resolve(file, cli()).unwrap();
assert!(matches!(resolved.secret, SecretSource::Inline(_)));
}
#[test]
fn secret_file_from_the_flag_wins_over_the_file() {
let file = FileConfig::parse("secret_file = \"from-file.bin\"").unwrap();
let resolved = resolve(
file,
Overrides {
secret_file: Some(PathBuf::from("from-flag.bin")),
..Overrides::default()
},
)
.unwrap();
assert_eq!(
resolved.secret,
SecretSource::File(PathBuf::from("from-flag.bin"))
);
}
#[test]
fn a_typo_in_the_config_is_an_error_not_a_silent_default() {
// Самая опасная поломка конфига — та, которую не видно. Сервис не должен
// слушать 1080, если человек написал porrt = 1443.
let error = FileConfig::parse("porrt = 1443").unwrap_err();
assert!(
error.contains("porrt"),
"ошибка должна называть неизвестное поле, получено: {error}"
);
}
#[test]
fn malformed_values_are_rejected() {
for text in [
"bind = \"не адрес\"",
"port = \"1080\"",
"port = 70000",
"worker = \"строка вместо списка\"",
"lan = \"да\"",
] {
assert!(
FileConfig::parse(text).is_err(),
"{text:?} должен быть отвергнут"
);
}
}
#[test]
fn zero_port_is_rejected() {
assert!(resolve(FileConfig::parse("port = 0").unwrap(), cli()).is_err());
}
#[test]
fn comments_and_blank_lines_are_fine() {
let file = FileConfig::parse(
r#"
# порт для второго экземпляра
port = 1081
# свой воркер как резерв
worker = ["backup.workers.dev"]
"#,
)
.unwrap();
assert_eq!(file.port, Some(1081));
assert_eq!(
file.worker.as_deref(),
Some(&["backup.workers.dev".to_owned()][..])
);
}
}