From 94299754032628b449b5764f75d233a0ef508d00 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9D=D0=B8=D0=BA=D0=B8=D1=82=D0=B0=20Sonic?= Date: Thu, 30 Jul 2026 20:19:31 +0300 Subject: [PATCH] =?UTF-8?q?feat(cli):=20=D1=84=D0=B0=D0=B9=D0=BB=20=D0=BD?= =?UTF-8?q?=D0=B0=D1=81=D1=82=D1=80=D0=BE=D0=B5=D0=BA=20tglock.toml=20(#35?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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> --- .github/workflows/ci.yml | 37 ++++ Cargo.lock | 1 + Cargo.toml | 5 +- README.md | 31 +++- src/bin/cli.rs | 170 +++++++++++------- src/cli_settings.rs | 360 +++++++++++++++++++++++++++++++++++++++ src/lib.rs | 4 + src/mtproto.rs | 59 +++++++ tglock.example.toml | 42 +++++ 9 files changed, 643 insertions(+), 66 deletions(-) create mode 100644 src/cli_settings.rs create mode 100644 tglock.example.toml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3cbb6f5..ba5aa66 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -135,6 +135,43 @@ jobs: ./target/release/tglock-cli --port 18080 --secret-file "$PWD/secret" > restart.log 2>&1 test "$first" = "$(grep -o 'secret=[0-9a-f]*' restart.log)" + # Настройки из файла и приоритет флага над файлом. + - name: Read settings from a file + run: | + cat > tglock.toml <<'CONF' + port = 18090 + secret = "dd00112233445566778899aabbccddeeff" + worker = ["example.workers.dev"] + CONF + timeout --preserve-status --signal=TERM 5 \ + ./target/release/tglock-cli --config tglock.toml > conf.log 2>&1 + cat conf.log + grep -q '127.0.0.1:18090' conf.log + grep -q 'secret=dd00112233445566778899aabbccddeeff' conf.log + grep -q 'example.workers.dev' conf.log + # Флаг должен перекрывать файл. + timeout --preserve-status --signal=TERM 5 \ + ./target/release/tglock-cli --config tglock.toml --port 18091 > over.log 2>&1 + grep -q '127.0.0.1:18091' over.log + # Опечатка в файле обязана быть ошибкой, а не молчаливым дефолтом. + echo 'porrt = 1443' > bad.toml + if ./target/release/tglock-cli --config bad.toml; then + echo "опечатка в конфиге прошла незамеченной"; exit 1 + fi + + # Закрытый stdout не должен ронять демон: println! при ошибке записи + # паникует, а канал закрывается штатно — `| head`, закрытый терминал, + # перезапуск сборщика логов. + - name: Survive a closed stdout + run: | + set +e + timeout --signal=TERM 5 ./target/release/tglock-cli --port 18092 2> pipe-err.log | head -2 + set -e + cat pipe-err.log + if grep -qi 'panic' pipe-err.log; then + echo "демон упал из-за закрытого stdout"; exit 1 + fi + msrv: name: Rust 1.88 compatibility runs-on: macos-latest diff --git a/Cargo.lock b/Cargo.lock index 450083a..eccb6b5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3570,6 +3570,7 @@ dependencies = [ "tauri-build", "tokio", "tokio-tungstenite", + "toml 0.8.2", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 98df921..14a6bed 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,7 +20,7 @@ gui = ["dep:tauri", "dep:tauri-build", "dep:open"] # shipped it as the app in v2.0.0-beta.2 and v2.0.0-beta.3. Keeping the CLI # behind its own non-default feature means it simply does not exist during an # application build, so there is nothing to pick wrongly. -cli = ["dep:clap"] +cli = ["dep:clap", "dep:toml"] [lib] name = "tglock" @@ -40,6 +40,9 @@ required-features = ["cli"] tauri = { version = "2", features = [], optional = true } open = { version = "5", optional = true } clap = { version = "4", features = ["derive"], optional = true } +# Файл конфигурации CLI. TOML, а не JSON, потому что файл правят руками и в нём +# нужны комментарии. +toml = { version = "0.8", optional = true } serde = { version = "1", features = ["derive"] } serde_json = "1" tokio = { version = "1", features = [ diff --git a/README.md b/README.md index 1211388..48e224b 100644 --- a/README.md +++ b/README.md @@ -173,6 +173,30 @@ tglock-cli --worker my-name.workers.dev # резервный маршрут, tglock-cli --help # все флаги ``` +#### Файл настроек + +Чтобы не собирать батник с ключами при каждом запуске ([#32](https://github.com/by-sonic/tglock/issues/32)), положите рядом с бинарём `tglock.toml` — он подхватится сам. Или укажите путь: `tglock-cli --config /etc/tglock.toml`. + +```toml +port = 1080 +lan = true + +# Секрет — вторая половина ссылки tg://proxy. Для сервиса его нужно закрепить, +# иначе после перезапуска все настроенные клиенты отвалятся. Запустите один +# раз, скопируйте из напечатанной ссылки и вставьте сюда. +secret = "dd00112233445566778899aabbccddeeff" + +worker = ["my-name.workers.dev"] +``` + +Полный пример со всеми полями и пояснениями — [`tglock.example.toml`](tglock.example.toml). + +Приоритеты: значения по умолчанию → файл → флаги. Флаг всегда сильнее файла. Флаги-переключатели (`--lan`, `--allow-direct`, `--quiet`) могут только включать: их отсутствие означает «взять из файла», а не «выключить». + +Опечатка в файле — ошибка при старте с перечислением допустимых полей, а не молча проигнорированная строка. Сервис, который из-за `porrt = 1443` слушает 1080, хуже сервиса, который не запустился. + +Файл с секретом внутри держите с правами `600`: это доступ к вашему прокси. + При запуске печатается готовая `tg://proxy`-ссылка — её можно открыть на любом устройстве в сети, чтобы Telegram настроился сам. Дальше в лог идёт по строке на каждое изменение состояния: сколько соединений, какой дата-центр, какой маршрут живой, сколько сбоев. Прав администратора не нужно: TGLock не правит ни системный DNS, ни файл `hosts` — нужные адреса Telegram зашиты в маршрутах, а TLS SNI остаётся настоящим. @@ -189,7 +213,7 @@ Wants=network-online.target [Service] Type=exec -ExecStart=/usr/local/bin/tglock-cli --lan --secret-file /var/lib/tglock/secret +ExecStart=/usr/local/bin/tglock-cli --config /etc/tglock.toml Restart=on-failure RestartSec=5s StateDirectory=tglock @@ -206,11 +230,14 @@ WantedBy=multi-user.target ```bash sudo install -m755 tglock-cli-x86_64-unknown-linux-gnu /usr/local/bin/tglock-cli +sudo install -m600 tglock.example.toml /etc/tglock.toml # и отредактировать sudo systemctl enable --now tglock journalctl -u tglock -f ``` -`--secret-file` здесь обязателен, и это не украшение: секрет — половина `tg://proxy`-ссылки. Без файла он генерируется заново при каждом старте, и после первого же `systemctl restart` все настроенные клиенты перестанут подключаться. `StateDirectory=tglock` создаёт `/var/lib/tglock` с нужными правами, а сам файл пишется с режимом `600`. +**Закреплённый секрет здесь обязателен**, и это не украшение: секрет — половина `tg://proxy`-ссылки. Без него он генерируется заново при каждом старте, и после первого же `systemctl restart` все настроенные клиенты перестанут подключаться. Задайте `secret` в `/etc/tglock.toml` (файл с правами `600`) либо `secret_file = "/var/lib/tglock/secret"` — тогда `StateDirectory=tglock` создаст каталог с нужными правами, а файл запишется с режимом `600`. + +При старте без закреплённого секрета CLI печатает предупреждение — молча уйти в такое состояние он не даст. Остановка по `systemctl stop` приходит как `SIGTERM` — CLI закрывает туннели и выходит с нулевым кодом, а не умирает по `SIGKILL`. diff --git a/src/bin/cli.rs b/src/bin/cli.rs index 94d6038..c850832 100644 --- a/src/bin/cli.rs +++ b/src/bin/cli.rs @@ -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, - /// Порт локального прокси - #[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, + + /// Порт локального прокси. По умолчанию 1080 + #[arg(short, long, value_name = "PORT")] + port: Option, /// То же, что --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, @@ -55,28 +70,31 @@ struct Args { } impl Args { - fn stats(&self) -> Arc { - 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), 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) { 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) { }, 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"); diff --git a/src/cli_settings.rs b/src/cli_settings.rs new file mode 100644 index 0000000..85e84c6 --- /dev/null +++ b/src/cli_settings.rs @@ -0,0 +1,360 @@ +//! Файл конфигурации для `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, + pub port: Option, + pub lan: Option, + pub allow_direct: Option, + pub worker: Option>, + pub secret: Option, + pub secret_file: Option, + pub quiet: Option, +} + +impl FileConfig { + pub fn parse(text: &str) -> Result { + toml::from_str(text).map_err(|error| format!("конфиг разобрать не удалось: {error}")) + } + + pub fn load(path: &Path) -> Result { + 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, + pub port: Option, + pub lan: bool, + pub allow_direct: bool, + pub worker: Vec, + pub secret_file: Option, + 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 { + 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 { + 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 { + 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()][..]) + ); + } +} diff --git a/src/lib.rs b/src/lib.rs index 0cd939a..7bde19c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -9,3 +9,7 @@ pub mod config; pub mod mtproto; pub mod proxy; pub mod transport; + +/// Настройки headless-версии: файл конфигурации и сведение с флагами. +#[cfg(feature = "cli")] +pub mod cli_settings; diff --git a/src/mtproto.rs b/src/mtproto.rs index 8f3bed3..0ef6868 100644 --- a/src/mtproto.rs +++ b/src/mtproto.rs @@ -133,6 +133,19 @@ pub fn secret_hex(secret: &[u8; 16]) -> String { output } +/// Разобрать секрет, записанный человеком. +/// +/// Принимает и 32 hex-символа, и форму с префиксом `dd` — именно так секрет +/// выглядит в ссылке `tg://proxy`, откуда его и копируют. +pub fn parse_secret(value: &str) -> Option<[u8; 16]> { + let trimmed = value.trim(); + let hex = trimmed + .strip_prefix("dd") + .filter(|rest| rest.len() == 32) + .unwrap_or(trimmed); + parse_secret_hex(hex) +} + fn parse_secret_hex(value: &str) -> Option<[u8; 16]> { if value.len() != 32 { return None; @@ -501,6 +514,52 @@ mod tests { } } + #[test] + fn accepts_a_secret_copied_from_a_tg_link() { + let expected = [ + 0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xaa, 0xbb, 0xcc, 0xdd, + 0xee, 0xff, + ]; + // Обе формы: как в файле и как в ссылке tg://proxy. + assert_eq!( + parse_secret("00112233445566778899aabbccddeeff"), + Some(expected) + ); + assert_eq!( + parse_secret("dd00112233445566778899aabbccddeeff"), + Some(expected) + ); + // Пробелы по краям — обычное дело при копировании. + assert_eq!( + parse_secret(" dd00112233445566778899aabbccddeeff\n"), + Some(expected) + ); + // Секрет, который сам начинается с dd и уже имеет полную длину, не + // должен потерять первый байт: префикс снимается только если после него + // остаётся ровно 32 символа. + assert_eq!( + parse_secret("dd112233445566778899aabbccddeeff"), + Some([ + 0xdd, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xaa, 0xbb, 0xcc, 0xdd, + 0xee, 0xff, + ]) + ); + } + + #[test] + fn rejects_malformed_secrets() { + for bad in [ + "", + "dd", + "слишком коротко", + "00112233445566778899aabbccddee", // 30 символов + "00112233445566778899aabbccddeeffff", // 34 символа + "zz112233445566778899aabbccddeeff", // не hex + ] { + assert!(parse_secret(bad).is_none(), "{bad:?} должен быть отвергнут"); + } + } + #[test] fn parses_persisted_secret() { assert_eq!( diff --git a/tglock.example.toml b/tglock.example.toml new file mode 100644 index 0000000..92616a9 --- /dev/null +++ b/tglock.example.toml @@ -0,0 +1,42 @@ +# Настройки tglock-cli. +# +# Положите этот файл рядом с бинарём под именем tglock.toml — он подхватится +# сам. Либо укажите путь явно: tglock-cli --config /etc/tglock.toml +# +# Все поля необязательны. Флаги командной строки перекрывают то, что здесь. + +# Адрес и порт локального прокси. +# По умолчанию 127.0.0.1:1080 — доступ только с этого компьютера. +# bind = "127.0.0.1" +# port = 1080 + +# Слушать 0.0.0.0, чтобы подключались другие устройства в сети. +# Нельзя задавать одновременно с bind. +# lan = true + +# Секрет прокси — вторая половина ссылки tg://proxy. +# +# Для сервиса его нужно закрепить, иначе после перезапуска секрет будет новым и +# все настроенные клиенты перестанут подключаться. Достаточно один раз +# запустить tglock-cli, скопировать секрет из напечатанной ссылки и вписать +# сюда. Принимается и с префиксом dd, и без него. +# +# Файл с секретом внутри держите с правами 600: это доступ к вашему прокси. +# secret = "dd00112233445566778899aabbccddeeff" + +# Либо хранить секрет в отдельном файле — он создастся при первом запуске. +# Игнорируется, если задан secret выше. +# secret_file = "secret" + +# Свои Cloudflare Worker'ы как резервный маршрут — на случай, когда у +# провайдера легли все обычные маршруты. Установка описана в +# docs/CLOUDFLARE_WORKER.md +# worker = ["my-name.workers.dev"] + +# Пропускать и не-Telegram адреса. +# На loopback это включено само. На сетевом адресе означает открытый +# SOCKS5-прокси для всего интернета — включайте осознанно. +# allow_direct = false + +# Печатать только ошибки. +# quiet = false