This commit is contained in:
by-sonic
2026-07-30 20:19:34 +03:00
9 changed files with 643 additions and 66 deletions
+37
View File
@@ -135,6 +135,43 @@ jobs:
./target/release/tglock-cli --port 18080 --secret-file "$PWD/secret" > restart.log 2>&1 ./target/release/tglock-cli --port 18080 --secret-file "$PWD/secret" > restart.log 2>&1
test "$first" = "$(grep -o 'secret=[0-9a-f]*' restart.log)" 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: msrv:
name: Rust 1.88 compatibility name: Rust 1.88 compatibility
runs-on: macos-latest runs-on: macos-latest
Generated
+1
View File
@@ -3570,6 +3570,7 @@ dependencies = [
"tauri-build", "tauri-build",
"tokio", "tokio",
"tokio-tungstenite", "tokio-tungstenite",
"toml 0.8.2",
] ]
[[package]] [[package]]
+4 -1
View File
@@ -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 # 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 # behind its own non-default feature means it simply does not exist during an
# application build, so there is nothing to pick wrongly. # application build, so there is nothing to pick wrongly.
cli = ["dep:clap"] cli = ["dep:clap", "dep:toml"]
[lib] [lib]
name = "tglock" name = "tglock"
@@ -40,6 +40,9 @@ required-features = ["cli"]
tauri = { version = "2", features = [], optional = true } tauri = { version = "2", features = [], optional = true }
open = { version = "5", optional = true } open = { version = "5", optional = true }
clap = { version = "4", features = ["derive"], optional = true } clap = { version = "4", features = ["derive"], optional = true }
# Файл конфигурации CLI. TOML, а не JSON, потому что файл правят руками и в нём
# нужны комментарии.
toml = { version = "0.8", optional = true }
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
tokio = { version = "1", features = [ tokio = { version = "1", features = [
+29 -2
View File
@@ -173,6 +173,30 @@ tglock-cli --worker my-name.workers.dev # резервный маршрут,
tglock-cli --help # все флаги 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 настроился сам. Дальше в лог идёт по строке на каждое изменение состояния: сколько соединений, какой дата-центр, какой маршрут живой, сколько сбоев. При запуске печатается готовая `tg://proxy`-ссылка — её можно открыть на любом устройстве в сети, чтобы Telegram настроился сам. Дальше в лог идёт по строке на каждое изменение состояния: сколько соединений, какой дата-центр, какой маршрут живой, сколько сбоев.
Прав администратора не нужно: TGLock не правит ни системный DNS, ни файл `hosts` — нужные адреса Telegram зашиты в маршрутах, а TLS SNI остаётся настоящим. Прав администратора не нужно: TGLock не правит ни системный DNS, ни файл `hosts` — нужные адреса Telegram зашиты в маршрутах, а TLS SNI остаётся настоящим.
@@ -189,7 +213,7 @@ Wants=network-online.target
[Service] [Service]
Type=exec 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 Restart=on-failure
RestartSec=5s RestartSec=5s
StateDirectory=tglock StateDirectory=tglock
@@ -206,11 +230,14 @@ WantedBy=multi-user.target
```bash ```bash
sudo install -m755 tglock-cli-x86_64-unknown-linux-gnu /usr/local/bin/tglock-cli 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 sudo systemctl enable --now tglock
journalctl -u tglock -f 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`. Остановка по `systemctl stop` приходит как `SIGTERM` — CLI закрывает туннели и выходит с нулевым кодом, а не умирает по `SIGKILL`.
+106 -62
View File
@@ -12,11 +12,22 @@ use std::process::ExitCode;
use std::sync::atomic::Ordering; use std::sync::atomic::Ordering;
use std::sync::Arc; use std::sync::Arc;
use std::time::Duration; use std::time::Duration;
use tglock::config::ListenConfig; use tglock::cli_settings as config;
use tglock::{mtproto, proxy, transport}; use tglock::{proxy, transport};
const STATUS_POLL: Duration = Duration::from_secs(1); 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)] #[derive(Debug, Parser)]
#[command( #[command(
name = "tglock-cli", name = "tglock-cli",
@@ -24,13 +35,17 @@ const STATUS_POLL: Duration = Duration::from_secs(1);
about = "TGLock без графического интерфейса: локальный MTProto-прокси через WebSocket" about = "TGLock без графического интерфейса: локальный MTProto-прокси через WebSocket"
)] )]
struct Args { struct Args {
/// Адрес для прослушивания. 127.0.0.1 — только этот компьютер /// Файл настроек. Если не задан, ищется tglock.toml рядом с бинарём
#[arg(short, long, value_name = "IP", default_value = "127.0.0.1")] #[arg(short, long, value_name = "PATH")]
bind: IpAddr, config: Option<PathBuf>,
/// Порт локального прокси /// Адрес для прослушивания. По умолчанию 127.0.0.1 — только этот компьютер
#[arg(short, long, value_name = "PORT", default_value_t = proxy::DEFAULT_PORT)] #[arg(short, long, value_name = "IP")]
port: u16, bind: Option<IpAddr>,
/// Порт локального прокси. По умолчанию 1080
#[arg(short, long, value_name = "PORT")]
port: Option<u16>,
/// То же, что --bind 0.0.0.0: доступ с других устройств в локальной сети /// То же, что --bind 0.0.0.0: доступ с других устройств в локальной сети
#[arg(long, conflicts_with = "bind")] #[arg(long, conflicts_with = "bind")]
@@ -44,8 +59,8 @@ struct Args {
#[arg(long)] #[arg(long)]
allow_direct: bool, allow_direct: bool,
/// Файл с секретом прокси. Обязателен для сервиса: иначе после перезапуска /// Файл с секретом прокси. Для сервиса нужен он или secret в настройках:
/// секрет будет новым и уже настроенные клиенты перестанут подключаться /// иначе после перезапуска секрет будет новым и настроенные клиенты отвалятся
#[arg(long, value_name = "PATH")] #[arg(long, value_name = "PATH")]
secret_file: Option<PathBuf>, secret_file: Option<PathBuf>,
@@ -55,28 +70,31 @@ struct Args {
} }
impl Args { impl Args {
fn stats(&self) -> Arc<proxy::Stats> { fn overrides(&self) -> config::Overrides {
match &self.secret_file { config::Overrides {
Some(path) => proxy::Stats::with_secret(mtproto::load_or_create_secret_at(path)), bind: self.bind,
None => proxy::Stats::new(), 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) /// Явный `--config` обязателен к существованию: если человек указал путь и
} else { /// опечатался, молча стартовать с настройками по умолчанию — худшее из
ListenConfig::new(self.bind, self.port) /// возможных поведений.
}; fn load_file(&self) -> Result<(config::FileConfig, Option<PathBuf>), String> {
if self.allow_direct { if let Some(path) = &self.config {
base.with_allow_direct(true) return Ok((config::FileConfig::load(path)?, Some(path.clone())));
} else {
base
} }
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> { async fn serve(args: Args) -> Result<(), String> {
let listen = args.listen(); let (file, file_path) = args.load_file()?;
let stats = args.stats(); let settings = config::resolve(file, args.overrides())?;
stats.set_worker_domain(&args.worker_domains()); 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 // Bind before printing anything: a busy port must be an error, not a
// daemon that reports success and silently does nothing. // daemon that reports success and silently does nothing.
let listener = proxy::bind(listen).await?; let listener = proxy::bind(listen).await?;
if !args.quiet { if !quiet {
println!("Слушаю {}", listen.addr); let intro = match &file_path {
println!( Some(path) => format!("Настройки: {}", path.display()),
None => format!(
"Настройки: только флаги ({} рядом с бинарём не найден)",
config::DEFAULT_FILE_NAME
),
};
say(&intro);
say(&format!("Слушаю {}", listen.addr));
say(&format!(
"Ссылка для Telegram: {}", "Ссылка для Telegram: {}",
listen.telegram_link(&stats.telegram_secret()) listen.telegram_link(&stats.telegram_secret())
));
if matches!(settings.secret, config::SecretSource::Ephemeral) {
say(
"Внимание: секрет не закреплён и будет новым после перезапуска — \
задайте secret в настройках или --secret-file",
); );
if listen.allow_direct && !listen.addr.ip().is_loopback() {
println!(
"Внимание: --allow-direct на адресе {} превращает TGLock в открытый SOCKS5-прокси",
listen.addr.ip()
);
} else if !listen.allow_direct {
println!("Пропускаю только адреса Telegram");
} }
if !args.worker.is_empty() { if listen.allow_direct && !listen.addr.ip().is_loopback() {
println!("Резервные Worker-домены: {}", args.worker_domains()); 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( tokio::spawn(
async move { proxy::serve(server_stats, listener, listen.allow_direct).await }, 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! { let outcome = tokio::select! {
joined = &mut server => joined.map_err(|error| format!("рабочая задача упала: {error}"))?, joined = &mut server => joined.map_err(|error| format!("рабочая задача упала: {error}"))?,
signal = shutdown_signal() => { signal = shutdown_signal() => {
signal.map_err(|error| format!("обработчик сигналов: {error}"))?; signal.map_err(|error| format!("обработчик сигналов: {error}"))?;
if !args.quiet { if !quiet {
println!("Получен сигнал остановки, закрываю соединения…"); say("Получен сигнал остановки, закрываю соединения…");
} }
stats.stop(); stats.stop();
server server
@@ -173,7 +207,7 @@ async fn watch_status(stats: Arc<proxy::Stats>) {
continue; continue;
} }
let (active, tunnels, dc, route, failures) = current; let (active, tunnels, dc, route, failures) = current;
println!( let line = format!(
"соединений {active} · туннелей {tunnels} · {} · {} · сбоев {failures}", "соединений {active} · туннелей {tunnels} · {} · {} · сбоев {failures}",
if dc > 0 { if dc > 0 {
format!("DC{dc}") format!("DC{dc}")
@@ -182,6 +216,11 @@ async fn watch_status(stats: Arc<proxy::Stats>) {
}, },
transport::route_label(route) transport::route_label(route)
); );
// Закрытый stdout — не ошибка: печатать больше некому, туннель работает
// дальше без наблюдателя.
if !say(&line) {
return;
}
previous = Some(current); previous = Some(current);
} }
} }
@@ -213,6 +252,11 @@ mod tests {
Args::try_parse_from(std::iter::once("tglock-cli").chain(args.iter().copied())).unwrap() 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] #[test]
fn command_definition_is_valid() { fn command_definition_is_valid() {
Args::command().debug_assert(); Args::command().debug_assert();
@@ -220,7 +264,7 @@ mod tests {
#[test] #[test]
fn defaults_to_loopback_on_the_default_port() { 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_eq!(listen.addr.to_string(), "127.0.0.1:1080");
assert!(listen.allow_direct); assert!(listen.allow_direct);
} }
@@ -228,50 +272,50 @@ mod tests {
#[test] #[test]
fn lan_flag_matches_explicit_wildcard_bind() { fn lan_flag_matches_explicit_wildcard_bind() {
assert_eq!( assert_eq!(
parse(&["--lan"]).listen(), from_flags(&["--lan"]).listen,
parse(&["-b", "0.0.0.0"]).listen() from_flags(&["-b", "0.0.0.0"]).listen
); );
} }
#[test] #[test]
fn lan_does_not_relay_non_telegram_traffic() { 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_eq!(listen.addr.to_string(), "0.0.0.0:1080");
assert!(!listen.allow_direct); assert!(!listen.allow_direct);
} }
#[test] #[test]
fn allow_direct_is_the_only_way_to_open_a_network_listener() { 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!( assert!(
parse(&["-b", "192.168.1.10", "--allow-direct"]) from_flags(&["-b", "192.168.1.10", "--allow-direct"])
.listen() .listen
.allow_direct .allow_direct
); );
} }
#[test] #[test]
fn bind_and_port_are_honoured() { 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"); assert_eq!(listen.addr.to_string(), "10.0.0.7:1443");
} }
#[test] #[test]
fn ipv6_bind_is_accepted() { 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_eq!(listen.addr.to_string(), "[::1]:2080");
assert!(listen.allow_direct); assert!(listen.allow_direct);
} }
#[test] #[test]
fn repeated_worker_flags_collapse_into_one_list() { fn repeated_worker_flags_collapse_into_one_list() {
let args = parse(&["--worker", "a.workers.dev", "--worker", "b.workers.dev"]); let settings = from_flags(&["--worker", "a.workers.dev", "--worker", "b.workers.dev"]);
assert_eq!(args.worker_domains(), "a.workers.dev,b.workers.dev"); assert_eq!(settings.workers, "a.workers.dev,b.workers.dev");
} }
#[test] #[test]
fn no_worker_flag_means_no_domains() { fn no_worker_flag_means_no_domains() {
assert!(parse(&[]).worker_domains().is_empty()); assert!(from_flags(&[]).workers.is_empty());
} }
#[test] #[test]
@@ -288,10 +332,10 @@ mod tests {
)); ));
let _ = std::fs::remove_file(&path); 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() .stats()
.telegram_secret(); .telegram_secret();
let second = parse(&["--secret-file", path.to_str().unwrap()]) let second = from_flags(&["--secret-file", path.to_str().unwrap()])
.stats() .stats()
.telegram_secret(); .telegram_secret();
@@ -303,11 +347,11 @@ mod tests {
// A corrupted file must not wedge the daemon: it is replaced. // A corrupted file must not wedge the daemon: it is replaced.
std::fs::write(&path, "garbage").unwrap(); 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() .stats()
.telegram_secret(); .telegram_secret();
assert_ne!(third, first); assert_ne!(third, first);
let fourth = parse(&["--secret-file", path.to_str().unwrap()]) let fourth = from_flags(&["--secret-file", path.to_str().unwrap()])
.stats() .stats()
.telegram_secret(); .telegram_secret();
assert_eq!(third, fourth, "the replacement must be persisted in turn"); assert_eq!(third, fourth, "the replacement must be persisted in turn");
+360
View File
@@ -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<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()][..])
);
}
}
+4
View File
@@ -9,3 +9,7 @@ pub mod config;
pub mod mtproto; pub mod mtproto;
pub mod proxy; pub mod proxy;
pub mod transport; pub mod transport;
/// Настройки headless-версии: файл конфигурации и сведение с флагами.
#[cfg(feature = "cli")]
pub mod cli_settings;
+59
View File
@@ -133,6 +133,19 @@ pub fn secret_hex(secret: &[u8; 16]) -> String {
output 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]> { fn parse_secret_hex(value: &str) -> Option<[u8; 16]> {
if value.len() != 32 { if value.len() != 32 {
return None; 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] #[test]
fn parses_persisted_secret() { fn parses_persisted_secret() {
assert_eq!( assert_eq!(
+42
View File
@@ -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