mirror of
https://github.com/by-sonic/tglock.git
synced 2026-09-18 08:18:34 +03:00
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>
This commit is contained in:
+107
-63
@@ -12,11 +12,22 @@ use std::process::ExitCode;
|
||||
use std::sync::atomic::Ordering;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tglock::config::ListenConfig;
|
||||
use tglock::{mtproto, proxy, transport};
|
||||
use tglock::cli_settings as config;
|
||||
use tglock::{proxy, transport};
|
||||
|
||||
const STATUS_POLL: Duration = Duration::from_secs(1);
|
||||
|
||||
/// Напечатать строку, вернув `false`, если stdout больше не принимает данные.
|
||||
///
|
||||
/// `println!` при ошибке записи паникует, а у демона stdout исчезает штатно: его
|
||||
/// пускают в `head`, закрывают терминал, перезапускают сборщик логов. Падать из
|
||||
/// за этого туннель не должен — он продолжает работать молча.
|
||||
fn say(text: &str) -> bool {
|
||||
use std::io::Write;
|
||||
let mut out = std::io::stdout().lock();
|
||||
writeln!(out, "{text}").and_then(|()| out.flush()).is_ok()
|
||||
}
|
||||
|
||||
#[derive(Debug, Parser)]
|
||||
#[command(
|
||||
name = "tglock-cli",
|
||||
@@ -24,13 +35,17 @@ const STATUS_POLL: Duration = Duration::from_secs(1);
|
||||
about = "TGLock без графического интерфейса: локальный MTProto-прокси через WebSocket"
|
||||
)]
|
||||
struct Args {
|
||||
/// Адрес для прослушивания. 127.0.0.1 — только этот компьютер
|
||||
#[arg(short, long, value_name = "IP", default_value = "127.0.0.1")]
|
||||
bind: IpAddr,
|
||||
/// Файл настроек. Если не задан, ищется tglock.toml рядом с бинарём
|
||||
#[arg(short, long, value_name = "PATH")]
|
||||
config: Option<PathBuf>,
|
||||
|
||||
/// Порт локального прокси
|
||||
#[arg(short, long, value_name = "PORT", default_value_t = proxy::DEFAULT_PORT)]
|
||||
port: u16,
|
||||
/// Адрес для прослушивания. По умолчанию 127.0.0.1 — только этот компьютер
|
||||
#[arg(short, long, value_name = "IP")]
|
||||
bind: Option<IpAddr>,
|
||||
|
||||
/// Порт локального прокси. По умолчанию 1080
|
||||
#[arg(short, long, value_name = "PORT")]
|
||||
port: Option<u16>,
|
||||
|
||||
/// То же, что --bind 0.0.0.0: доступ с других устройств в локальной сети
|
||||
#[arg(long, conflicts_with = "bind")]
|
||||
@@ -44,8 +59,8 @@ struct Args {
|
||||
#[arg(long)]
|
||||
allow_direct: bool,
|
||||
|
||||
/// Файл с секретом прокси. Обязателен для сервиса: иначе после перезапуска
|
||||
/// секрет будет новым и уже настроенные клиенты перестанут подключаться
|
||||
/// Файл с секретом прокси. Для сервиса нужен он или secret в настройках:
|
||||
/// иначе после перезапуска секрет будет новым и настроенные клиенты отвалятся
|
||||
#[arg(long, value_name = "PATH")]
|
||||
secret_file: Option<PathBuf>,
|
||||
|
||||
@@ -55,28 +70,31 @@ struct Args {
|
||||
}
|
||||
|
||||
impl Args {
|
||||
fn stats(&self) -> Arc<proxy::Stats> {
|
||||
match &self.secret_file {
|
||||
Some(path) => proxy::Stats::with_secret(mtproto::load_or_create_secret_at(path)),
|
||||
None => proxy::Stats::new(),
|
||||
fn overrides(&self) -> config::Overrides {
|
||||
config::Overrides {
|
||||
bind: self.bind,
|
||||
port: self.port,
|
||||
lan: self.lan,
|
||||
allow_direct: self.allow_direct,
|
||||
worker: self.worker.clone(),
|
||||
secret_file: self.secret_file.clone(),
|
||||
quiet: self.quiet,
|
||||
}
|
||||
}
|
||||
|
||||
fn listen(&self) -> ListenConfig {
|
||||
let base = if self.lan {
|
||||
ListenConfig::lan(self.port)
|
||||
} else {
|
||||
ListenConfig::new(self.bind, self.port)
|
||||
};
|
||||
if self.allow_direct {
|
||||
base.with_allow_direct(true)
|
||||
} else {
|
||||
base
|
||||
/// Файл настроек и путь, по которому он найден.
|
||||
///
|
||||
/// Явный `--config` обязателен к существованию: если человек указал путь и
|
||||
/// опечатался, молча стартовать с настройками по умолчанию — худшее из
|
||||
/// возможных поведений.
|
||||
fn load_file(&self) -> Result<(config::FileConfig, Option<PathBuf>), String> {
|
||||
if let Some(path) = &self.config {
|
||||
return Ok((config::FileConfig::load(path)?, Some(path.clone())));
|
||||
}
|
||||
match config::path_next_to_executable() {
|
||||
Some(path) if path.is_file() => Ok((config::FileConfig::load(&path)?, Some(path))),
|
||||
_ => Ok((config::FileConfig::default(), None)),
|
||||
}
|
||||
}
|
||||
|
||||
fn worker_domains(&self) -> String {
|
||||
self.worker.join(",")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,30 +118,46 @@ fn main() -> ExitCode {
|
||||
}
|
||||
|
||||
async fn serve(args: Args) -> Result<(), String> {
|
||||
let listen = args.listen();
|
||||
let stats = args.stats();
|
||||
stats.set_worker_domain(&args.worker_domains());
|
||||
let (file, file_path) = args.load_file()?;
|
||||
let settings = config::resolve(file, args.overrides())?;
|
||||
let listen = settings.listen;
|
||||
let stats = settings.stats();
|
||||
let quiet = settings.quiet;
|
||||
|
||||
// Bind before printing anything: a busy port must be an error, not a
|
||||
// daemon that reports success and silently does nothing.
|
||||
let listener = proxy::bind(listen).await?;
|
||||
|
||||
if !args.quiet {
|
||||
println!("Слушаю {}", listen.addr);
|
||||
println!(
|
||||
if !quiet {
|
||||
let intro = match &file_path {
|
||||
Some(path) => format!("Настройки: {}", path.display()),
|
||||
None => format!(
|
||||
"Настройки: только флаги ({} рядом с бинарём не найден)",
|
||||
config::DEFAULT_FILE_NAME
|
||||
),
|
||||
};
|
||||
say(&intro);
|
||||
say(&format!("Слушаю {}", listen.addr));
|
||||
say(&format!(
|
||||
"Ссылка для Telegram: {}",
|
||||
listen.telegram_link(&stats.telegram_secret())
|
||||
);
|
||||
if listen.allow_direct && !listen.addr.ip().is_loopback() {
|
||||
println!(
|
||||
"Внимание: --allow-direct на адресе {} превращает TGLock в открытый SOCKS5-прокси",
|
||||
listen.addr.ip()
|
||||
));
|
||||
if matches!(settings.secret, config::SecretSource::Ephemeral) {
|
||||
say(
|
||||
"Внимание: секрет не закреплён и будет новым после перезапуска — \
|
||||
задайте secret в настройках или --secret-file",
|
||||
);
|
||||
} else if !listen.allow_direct {
|
||||
println!("Пропускаю только адреса Telegram");
|
||||
}
|
||||
if !args.worker.is_empty() {
|
||||
println!("Резервные Worker-домены: {}", args.worker_domains());
|
||||
if listen.allow_direct && !listen.addr.ip().is_loopback() {
|
||||
say(&format!(
|
||||
"Внимание: allow_direct на адресе {} превращает TGLock в открытый SOCKS5-прокси",
|
||||
listen.addr.ip()
|
||||
));
|
||||
} else if !listen.allow_direct {
|
||||
say("Пропускаю только адреса Telegram");
|
||||
}
|
||||
if !settings.workers.is_empty() {
|
||||
say(&format!("Резервные Worker-домены: {}", settings.workers));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -132,14 +166,14 @@ async fn serve(args: Args) -> Result<(), String> {
|
||||
tokio::spawn(
|
||||
async move { proxy::serve(server_stats, listener, listen.allow_direct).await },
|
||||
);
|
||||
let watcher = (!args.quiet).then(|| tokio::spawn(watch_status(stats.clone())));
|
||||
let watcher = (!quiet).then(|| tokio::spawn(watch_status(stats.clone())));
|
||||
|
||||
let outcome = tokio::select! {
|
||||
joined = &mut server => joined.map_err(|error| format!("рабочая задача упала: {error}"))?,
|
||||
signal = shutdown_signal() => {
|
||||
signal.map_err(|error| format!("обработчик сигналов: {error}"))?;
|
||||
if !args.quiet {
|
||||
println!("Получен сигнал остановки, закрываю соединения…");
|
||||
if !quiet {
|
||||
say("Получен сигнал остановки, закрываю соединения…");
|
||||
}
|
||||
stats.stop();
|
||||
server
|
||||
@@ -173,7 +207,7 @@ async fn watch_status(stats: Arc<proxy::Stats>) {
|
||||
continue;
|
||||
}
|
||||
let (active, tunnels, dc, route, failures) = current;
|
||||
println!(
|
||||
let line = format!(
|
||||
"соединений {active} · туннелей {tunnels} · {} · {} · сбоев {failures}",
|
||||
if dc > 0 {
|
||||
format!("DC{dc}")
|
||||
@@ -182,6 +216,11 @@ async fn watch_status(stats: Arc<proxy::Stats>) {
|
||||
},
|
||||
transport::route_label(route)
|
||||
);
|
||||
// Закрытый stdout — не ошибка: печатать больше некому, туннель работает
|
||||
// дальше без наблюдателя.
|
||||
if !say(&line) {
|
||||
return;
|
||||
}
|
||||
previous = Some(current);
|
||||
}
|
||||
}
|
||||
@@ -213,6 +252,11 @@ mod tests {
|
||||
Args::try_parse_from(std::iter::once("tglock-cli").chain(args.iter().copied())).unwrap()
|
||||
}
|
||||
|
||||
/// Итоговые настройки только из флагов, без файла.
|
||||
fn from_flags(args: &[&str]) -> config::Resolved {
|
||||
config::resolve(config::FileConfig::default(), parse(args).overrides()).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_definition_is_valid() {
|
||||
Args::command().debug_assert();
|
||||
@@ -220,7 +264,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn defaults_to_loopback_on_the_default_port() {
|
||||
let listen = parse(&[]).listen();
|
||||
let listen = from_flags(&[]).listen;
|
||||
assert_eq!(listen.addr.to_string(), "127.0.0.1:1080");
|
||||
assert!(listen.allow_direct);
|
||||
}
|
||||
@@ -228,50 +272,50 @@ mod tests {
|
||||
#[test]
|
||||
fn lan_flag_matches_explicit_wildcard_bind() {
|
||||
assert_eq!(
|
||||
parse(&["--lan"]).listen(),
|
||||
parse(&["-b", "0.0.0.0"]).listen()
|
||||
from_flags(&["--lan"]).listen,
|
||||
from_flags(&["-b", "0.0.0.0"]).listen
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lan_does_not_relay_non_telegram_traffic() {
|
||||
let listen = parse(&["--lan"]).listen();
|
||||
let listen = from_flags(&["--lan"]).listen;
|
||||
assert_eq!(listen.addr.to_string(), "0.0.0.0:1080");
|
||||
assert!(!listen.allow_direct);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn allow_direct_is_the_only_way_to_open_a_network_listener() {
|
||||
assert!(!parse(&["-b", "192.168.1.10"]).listen().allow_direct);
|
||||
assert!(!from_flags(&["-b", "192.168.1.10"]).listen.allow_direct);
|
||||
assert!(
|
||||
parse(&["-b", "192.168.1.10", "--allow-direct"])
|
||||
.listen()
|
||||
from_flags(&["-b", "192.168.1.10", "--allow-direct"])
|
||||
.listen
|
||||
.allow_direct
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bind_and_port_are_honoured() {
|
||||
let listen = parse(&["--bind", "10.0.0.7", "--port", "1443"]).listen();
|
||||
let listen = from_flags(&["--bind", "10.0.0.7", "--port", "1443"]).listen;
|
||||
assert_eq!(listen.addr.to_string(), "10.0.0.7:1443");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ipv6_bind_is_accepted() {
|
||||
let listen = parse(&["-b", "::1", "-p", "2080"]).listen();
|
||||
let listen = from_flags(&["-b", "::1", "-p", "2080"]).listen;
|
||||
assert_eq!(listen.addr.to_string(), "[::1]:2080");
|
||||
assert!(listen.allow_direct);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeated_worker_flags_collapse_into_one_list() {
|
||||
let args = parse(&["--worker", "a.workers.dev", "--worker", "b.workers.dev"]);
|
||||
assert_eq!(args.worker_domains(), "a.workers.dev,b.workers.dev");
|
||||
let settings = from_flags(&["--worker", "a.workers.dev", "--worker", "b.workers.dev"]);
|
||||
assert_eq!(settings.workers, "a.workers.dev,b.workers.dev");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_worker_flag_means_no_domains() {
|
||||
assert!(parse(&[]).worker_domains().is_empty());
|
||||
assert!(from_flags(&[]).workers.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -288,10 +332,10 @@ mod tests {
|
||||
));
|
||||
let _ = std::fs::remove_file(&path);
|
||||
|
||||
let first = parse(&["--secret-file", path.to_str().unwrap()])
|
||||
let first = from_flags(&["--secret-file", path.to_str().unwrap()])
|
||||
.stats()
|
||||
.telegram_secret();
|
||||
let second = parse(&["--secret-file", path.to_str().unwrap()])
|
||||
let second = from_flags(&["--secret-file", path.to_str().unwrap()])
|
||||
.stats()
|
||||
.telegram_secret();
|
||||
|
||||
@@ -303,11 +347,11 @@ mod tests {
|
||||
|
||||
// A corrupted file must not wedge the daemon: it is replaced.
|
||||
std::fs::write(&path, "garbage").unwrap();
|
||||
let third = parse(&["--secret-file", path.to_str().unwrap()])
|
||||
let third = from_flags(&["--secret-file", path.to_str().unwrap()])
|
||||
.stats()
|
||||
.telegram_secret();
|
||||
assert_ne!(third, first);
|
||||
let fourth = parse(&["--secret-file", path.to_str().unwrap()])
|
||||
let fourth = from_flags(&["--secret-file", path.to_str().unwrap()])
|
||||
.stats()
|
||||
.telegram_secret();
|
||||
assert_eq!(third, fourth, "the replacement must be persisted in turn");
|
||||
|
||||
Reference in New Issue
Block a user