mirror of
https://github.com/by-sonic/tglock.git
synced 2026-07-31 15:55:11 +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:
@@ -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
@@ -3570,6 +3570,7 @@ dependencies = [
|
|||||||
"tauri-build",
|
"tauri-build",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-tungstenite",
|
"tokio-tungstenite",
|
||||||
|
"toml 0.8.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
+4
-1
@@ -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 = [
|
||||||
|
|||||||
@@ -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`.
|
||||||
|
|
||||||
|
|||||||
+107
-63
@@ -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 listen.allow_direct && !listen.addr.ip().is_loopback() {
|
if matches!(settings.secret, config::SecretSource::Ephemeral) {
|
||||||
println!(
|
say(
|
||||||
"Внимание: --allow-direct на адресе {} превращает TGLock в открытый SOCKS5-прокси",
|
"Внимание: секрет не закреплён и будет новым после перезапуска — \
|
||||||
listen.addr.ip()
|
задайте secret в настройках или --secret-file",
|
||||||
);
|
);
|
||||||
} 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");
|
||||||
|
|||||||
@@ -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()][..])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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!(
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user