mirror of
https://github.com/by-sonic/tglock.git
synced 2026-07-31 07:45:13 +03:00
9429975403
* 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>
361 lines
13 KiB
Rust
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()][..])
|
|
);
|
|
}
|
|
}
|