Release MiniVLESS 0.1.0 for Linux
This commit is contained in:
+14
@@ -0,0 +1,14 @@
|
||||
node_modules/
|
||||
dist/
|
||||
src-tauri/target/
|
||||
src-tauri/gen/
|
||||
binaries/sing-box
|
||||
*.log
|
||||
.DS_Store
|
||||
__pycache__/
|
||||
graphify-out/
|
||||
release/
|
||||
.env
|
||||
.env.*
|
||||
settings.json
|
||||
runtime-*/
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 MiniVLESS contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,167 @@
|
||||
# MiniVLESS
|
||||
|
||||
Маленький персональный Linux-клиент: Tauri 2, Rust, React/TypeScript и отдельный sing-box **1.14.x**. Подключается по VLESS-ссылке к совместимым серверам, в том числе Xray. GUI всегда работает от обычного пользователя.
|
||||
|
||||
[Скачать Linux-релиз](https://github.com/emil28092005/minivless/releases/latest)
|
||||
|
||||

|
||||
|
||||
## Установка готового релиза
|
||||
|
||||
Скачайте `MiniVLESS_0.1.0_amd64.deb` из Releases и выполните из каталога загрузки:
|
||||
|
||||
```bash
|
||||
sudo apt install ./MiniVLESS_0.1.0_amd64.deb libcap2-bin
|
||||
sudo setcap cap_net_admin,cap_net_raw+ep /usr/lib/MiniVLESS/binaries/sing-box
|
||||
```
|
||||
|
||||
Запустите MiniVLESS из меню приложений. Пакет содержит sing-box 1.14.0; capabilities назначаются отдельно и только ему. Если раньше был установлен `/usr/local/lib/minivless/sing-box`, он имеет приоритет — проверьте права именно этого файла. Релиз пока собран для Linux x86_64; это не сборка для Windows/macOS. Контрольные суммы — в `SHA256SUMS` рядом с пакетом.
|
||||
|
||||
## Использование
|
||||
|
||||
1. Вставьте `vless://…` — ссылка скрыта и сохраняется только локально.
|
||||
2. Выберите режим:
|
||||
- **Tunneling включён:** VPN только для отмеченных программ. Остальной трафик — DIRECT.
|
||||
- **Tunneling выключен:** VPN для всей системы, независимо от списка программ.
|
||||
3. `Refresh` обновляет запущенные приложения. `Add application` позволяет выбрать установленную программу или указать абсолютный путь к её настоящему ELF-бинарнику.
|
||||
4. Нажмите `Connect`. `Disconnect` останавливает core и освобождает TUN.
|
||||
|
||||
Пути сохраняются независимо от PID. Несколько процессов одного исполняемого файла объединяются. Отмеченные/добавленные приложения остаются в списке после перезапуска. Переключатель меняет **выборочную маршрутизацию**, а не отключает TUN: TUN используется в обоих режимах.
|
||||
|
||||
## Требования Linux
|
||||
|
||||
- Linux с `/dev/net/tun`, nftables и IPv4/IPv6; kernel NFQUEUE (`nfnetlink_queue`, `nft_queue`) нужен для предварительного сопоставления правил sing-box. При его отсутствии core может использовать обычный TUN fallback.
|
||||
- GTK 3, WebKitGTK 4.1, системный набор CA, libcap.
|
||||
- Для сборки: Node.js 22+, npm, актуальный Rust stable, C/C++ toolchain, pkg-config.
|
||||
- Для сетевых тестов: Python 3, OpenSSL, `ip`, `nft`, разрешённые unprivileged user namespaces.
|
||||
|
||||
Debian/Ubuntu:
|
||||
|
||||
```bash
|
||||
sudo apt install build-essential pkg-config libgtk-3-dev libwebkit2gtk-4.1-dev \
|
||||
libayatana-appindicator3-dev librsvg2-dev patchelf libcap2-bin \
|
||||
ca-certificates iproute2 nftables python3 openssl
|
||||
```
|
||||
|
||||
## sing-box и права
|
||||
|
||||
Загрузчик получает фиксированный официальный релиз 1.14.0 для x86_64/aarch64 и проверяет SHA-256 по метаданным GitHub:
|
||||
|
||||
```bash
|
||||
python3 scripts/fetch-sing-box.py
|
||||
./binaries/sing-box version
|
||||
```
|
||||
|
||||
Рекомендуется положить core в отдельный каталог с владельцем root, затем выдать capabilities **только ему**:
|
||||
|
||||
```bash
|
||||
sudo install -D -o root -g root -m 0755 binaries/sing-box /usr/local/lib/minivless/sing-box
|
||||
sudo setcap cap_net_admin,cap_net_raw+ep /usr/local/lib/minivless/sing-box
|
||||
getcap /usr/local/lib/minivless/sing-box
|
||||
```
|
||||
|
||||
Для локальной разработки допустимо:
|
||||
|
||||
```bash
|
||||
sudo setcap cap_net_admin,cap_net_raw+ep "$(realpath binaries/sing-box)"
|
||||
```
|
||||
|
||||
После замены/обновления бинарника capabilities нужно выставить заново. Файловая система должна поддерживать file capabilities и не блокировать их через `nosuid`. **Не запускайте MiniVLESS через sudo.** Приложение проверяет наличие core, его версию, TUN и capabilities; при ошибке показывает команду установки прав. Самостоятельно повышать права оно не пытается.
|
||||
|
||||
Порядок поиска core: `MINIVLESS_SING_BOX` (явное переопределение), `/usr/local/lib/minivless/sing-box`, ресурс установленного приложения, `binaries/sing-box` проекта в debug-сборке, `binaries/` рядом с исполняемым файлом, `/usr/bin/sing-box`, `/usr/local/bin/sing-box`. Переменная окружения задаёт только путь, не аргументы.
|
||||
|
||||
## Разработка и сборка
|
||||
|
||||
```bash
|
||||
npm ci
|
||||
npm run tauri -- dev
|
||||
```
|
||||
|
||||
Один `npm run dev` запускает только браузерный предпросмотр; подключение в нём отключено.
|
||||
|
||||
```bash
|
||||
npm run tauri -- build --bundles deb
|
||||
```
|
||||
|
||||
Результаты:
|
||||
|
||||
- `src-tauri/target/release/minivless` — нативное приложение.
|
||||
- `src-tauri/target/release/bundle/deb/*.deb` — Debian-пакет с core.
|
||||
|
||||
Установка пакета: `sudo apt install ./src-tauri/target/release/bundle/deb/*.deb`. Настройку capabilities выполните отдельно, как указано выше. Прямой запуск release-бинарника использует установленный `/usr/local/lib/minivless/sing-box`; можно также положить core в `binaries/` рядом с ним. AppImage не используется: файловые capabilities на смонтированном образе ненадёжны.
|
||||
|
||||
## Настройки и логи
|
||||
|
||||
Настройки: `$XDG_CONFIG_HOME/minivless/settings.json`, по умолчанию `~/.config/minivless/settings.json`.
|
||||
|
||||
Файл имеет права `0600`, каталог — `0700`; запись атомарная. Сохраняются ссылка, пути отмеченных/добавленных программ и положение переключателя. Повреждённые настройки не заменяются молча. Это локальный файл с credential, а не зашифрованное хранилище паролей.
|
||||
|
||||
Runtime-конфиг создаётся в приватном `runtime-*` под тем же каталогом и удаляется после остановки. Логи — только в памяти, максимум 300 строк по 4096 символов; credential и query-токены удаляются до показа. Полная ссылка не передаётся через аргументы процесса и не попадает в frontend logs. Раскрывающийся блок `Connection logs` показывает диагностику.
|
||||
|
||||
## Маршрутизация и DNS
|
||||
|
||||
Используется настоящий TUN sing-box, `auto_route`, Linux nftables `auto_redirect`, IPv4/IPv6 и точные правила `process_path`. В выборочном режиме TUN ограничен UID пользователя, а финальное правило — `direct`. В системном режиме ограничения UID нет, финальное правило — `proxy`. Интерфейс `minivless0`, routing table 2090; собственные routing marks/rules не совпадают со стандартными значениями sing-box.
|
||||
|
||||
DNS к внешним адресам, включая DNS в LAN, перехватывается средствами TUN/nftables. Запросы выбранных процессов идут по DoH через VLESS; остальные — по DoH напрямую. В системном режиме DNS идёт через VLESS. Используется `1.1.1.1` с проверкой TLS для `cloudflare-dns.com`; домен самого VLESS-сервера разрешается напрямую, чтобы не создавать петлю.
|
||||
|
||||
**Ограничение общего системного DNS:** при обращении программы к локальному `127.0.0.53` запрос в сеть отправляет systemd-resolved, а не исходная программа. В выборочном режиме такие запросы остаются у системного резолвера и идут DIRECT; установить исходную программу по этому сокету невозможно. Это может влиять на домены, блокируемые системным DNS. Прямые DNS/DoH-соединения выбранной программы маршрутизируются по её executable. В системном режиме исходящие DNS-запросы резолвера тоже входят в VPN. Loopback не перехватывается.
|
||||
|
||||
Настройки NetworkManager, resolv.conf и systemd-resolved не меняются. У pinned core отключён поиск внешних команд через `PATH`: это предотвращает автоматический вызов `resolvectl` в sing-tun, запросы Polkit и неявные изменения системных DNS-настроек. Используется native nftables backend; fallback через внешние iptables не поддерживается.
|
||||
|
||||
## Lifecycle
|
||||
|
||||
Перед стартом выполняется `sing-box check`. `Connected` означает, что core сообщил об успешном старте TUN; доступность конкретного удалённого сервера/credential проверяется реальным трафиком, это не результат speedtest или внешнего healthcheck. Ошибки соединений с сервером видны в логах.
|
||||
|
||||
Один backend state сериализует Connect/Disconnect. Файловые locks запрещают второй GUI и второй tunnel. Не используются shell-строки, `sh -c` или интерполяция пользовательского ввода. Аргументы передаются через `Command`.
|
||||
|
||||
GUI запускает маленький guardian в том же бинарнике. Он отслеживает открытый pipe GUI, посылает sing-box SIGTERM, ждёт до 3 секунд и при необходимости завершает его принудительно, затем удаляет runtime-файлы. Так core останавливается даже при SIGKILL GUI. Одного `PR_SET_PDEATHSIG` недостаточно: Linux сбрасывает его при exec с file capabilities. Обычное закрытие окна, SIGINT и SIGTERM ожидают остановки.
|
||||
|
||||
## Поддержка и ограничения MVP
|
||||
|
||||
- Поддерживаются обычный VLESS/TCP, TLS, REALITY, `xtls-rprx-vision`, WebSocket (path/Host, явные ed/eh), IPv4/IPv6 и XUDP. Xray на сервере совместим.
|
||||
- gRPC, XHTTP, HTTPUpgrade, TCP HTTP headers, нестандартный flow и insecure TLS отклоняются понятной ошибкой.
|
||||
- Список приложений сопоставляет `/proc` с Desktop Entries. Shell-launcher не является routing identity; укажите настоящий бинарник. Flatpak/Snap, временные AppImage mount paths, контейнеры и приложения со сторонними сетевыми helper-процессами не гарантируются. Для helper с отдельным executable его нужно отметить отдельно.
|
||||
- VPN рассчитан на TCP/UDP. MPTCP не поддерживается core. Локальный loopback и некоторые непосредственно подключённые LAN-маршруты обходят TUN. Это не kill switch.
|
||||
- Процесс, который не удалось идентифицировать в выборочном режиме, идёт DIRECT. `/proc` hidepid/ограничения безопасности могут мешать определению executable.
|
||||
- Уже открытые соединения следует переподключить после смены режима. Совместная работа с другим активным VPN не гарантируется.
|
||||
- Закрытие GUI и его отдельный SIGKILL проверены. Принудительное убийство одновременно guardian и core, сбой ядра/питания не дают гарантий cleanup. Core, который зависает и требует SIGKILL, также может оставить свои nftables-правила до ручной очистки/перезагрузки.
|
||||
- Подписки, QR, аккаунты, статистика и автообновление не реализуются.
|
||||
|
||||
## Проверки
|
||||
|
||||
```bash
|
||||
npm run format
|
||||
cargo check --manifest-path src-tauri/Cargo.toml
|
||||
cargo test --manifest-path src-tauri/Cargo.toml
|
||||
npm run build
|
||||
npm run tauri -- build --bundles deb
|
||||
```
|
||||
|
||||
Для backend без GTK:
|
||||
|
||||
```bash
|
||||
cargo test --manifest-path src-tauri/Cargo.toml --no-default-features --lib
|
||||
cargo build --manifest-path src-tauri/Cargo.toml --no-default-features --example test_driver
|
||||
src-tauri/target/debug/examples/test_driver --emit-configs /tmp/minivless-config-checks
|
||||
```
|
||||
|
||||
Восемь сгенерированных конфигураций (TCP/TLS/REALITY/WS × два режима) проверяются настоящим `sing-box check -c <file>`.
|
||||
|
||||
Интеграционный тест запускает настоящий локальный VLESS-сервер, HTTP/UDP endpoints и TLS DoH-сервер в **отдельных network namespaces**. Проверяет маршрут по source IP, DNS, штатное отключение и смерть GUI; сравнивает IPv4/IPv6 rules и nftables до/после. Реальный VPN-сервер и Интернет не нужны:
|
||||
|
||||
```bash
|
||||
unshare --user --map-root-user --net python3 tests/netns_integration.py
|
||||
```
|
||||
|
||||
Не запускайте этот тест через host root. Скрипт требует пустое изолированное network namespace и отображение одного UID. `tests/ui-preview.html` — отдельная development-only IPC fixture для проверки React-интерфейса в браузере; в production bundle не входит и не заменяет сетевой тест.
|
||||
|
||||
## Документация upstream
|
||||
|
||||
- [Tauri: Linux prerequisites](https://v2.tauri.app/start/prerequisites/)
|
||||
- [sing-box: TUN](https://sing-box.sagernet.org/configuration/inbound/tun/)
|
||||
- [sing-box: process routing](https://sing-box.sagernet.org/configuration/route/rule/)
|
||||
- [sing-box: VLESS](https://sing-box.sagernet.org/configuration/outbound/vless/)
|
||||
- [sing-box: DNS over HTTPS](https://sing-box.sagernet.org/configuration/dns/server/https/)
|
||||
- [Официальный sing-box 1.14.0](https://github.com/SagerNet/sing-box/releases/tag/v1.14.0)
|
||||
|
||||
Код MiniVLESS — MIT; sing-box распространяется отдельно по GPL-3.0-or-later. При распространении пакета с core соблюдайте его лицензию и предоставляйте соответствующий исходный код.
|
||||
@@ -0,0 +1,17 @@
|
||||
Copyright (C) 2022 by nekohasekai <contact-sagernet@sekai.icu>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
In addition, no derivative work may use the name or imply association
|
||||
with this application without prior consent.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.3 MiB |
@@ -0,0 +1,2 @@
|
||||
<!doctype html>
|
||||
<html lang="en"><head><meta charset="UTF-8"/><meta name="viewport" content="width=device-width, initial-scale=1.0"/><meta name="color-scheme" content="dark"/><title>MiniVLESS</title></head><body><div id="root"></div><script type="module" src="/src/main.tsx"></script></body></html>
|
||||
Generated
+2087
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "minivless",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host 127.0.0.1 --port 1420 --strictPort",
|
||||
"build": "tsc --noEmit && vite build",
|
||||
"preview": "vite preview --host 127.0.0.1",
|
||||
"tauri": "tauri",
|
||||
"format": "prettier --write src package.json tsconfig.json vite.config.ts && cargo fmt --manifest-path src-tauri/Cargo.toml"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.11.0",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2.11.0",
|
||||
"@types/react": "^19.2.0",
|
||||
"@types/react-dom": "^19.2.0",
|
||||
"@vitejs/plugin-react": "^5.0.0",
|
||||
"prettier": "^3.6.0",
|
||||
"typescript": "^5.9.0",
|
||||
"vite": "^7.1.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Download a pinned official release and verify its published SHA-256 digest."""
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
from pathlib import Path
|
||||
import platform
|
||||
import shutil
|
||||
import tarfile
|
||||
import urllib.request
|
||||
|
||||
VERSION = "1.14.0"
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
|
||||
def download(url):
|
||||
req = urllib.request.Request(url, headers={"User-Agent": "MiniVLESS-build"})
|
||||
with urllib.request.urlopen(req, timeout=90) as response:
|
||||
return response.read()
|
||||
|
||||
def main():
|
||||
if platform.system() != "Linux":
|
||||
raise SystemExit("MiniVLESS currently supports Linux only")
|
||||
arch = {"x86_64": "amd64", "aarch64": "arm64"}.get(platform.machine())
|
||||
if arch is None:
|
||||
raise SystemExit("Supported CPU architectures: x86_64 and aarch64")
|
||||
name = f"sing-box-{VERSION}-linux-{arch}.tar.gz"
|
||||
release = json.loads(download(f"https://api.github.com/repos/SagerNet/sing-box/releases/tags/v{VERSION}"))
|
||||
asset = next(a for a in release["assets"] if a["name"] == name)
|
||||
payload = download(asset["browser_download_url"])
|
||||
expected = asset.get("digest")
|
||||
if not expected or expected != "sha256:" + hashlib.sha256(payload).hexdigest():
|
||||
raise SystemExit("Missing or mismatched SHA-256: refusing to install this download")
|
||||
target = ROOT / "binaries/sing-box"
|
||||
target.parent.mkdir(exist_ok=True)
|
||||
with tarfile.open(fileobj=io.BytesIO(payload)) as archive:
|
||||
member = archive.getmember(f"sing-box-{VERSION}-linux-{arch}/sing-box")
|
||||
if not member.isfile():
|
||||
raise SystemExit("Invalid archive entry")
|
||||
with archive.extractfile(member) as src, target.with_suffix(".new").open("wb") as dst:
|
||||
shutil.copyfileobj(src, dst)
|
||||
license_data = archive.extractfile(f"sing-box-{VERSION}-linux-{arch}/LICENSE").read()
|
||||
(target.parent / "LICENSE.sing-box").write_bytes(license_data)
|
||||
target.with_suffix(".new").chmod(0o755)
|
||||
target.with_suffix(".new").replace(target)
|
||||
print(f"Verified sing-box {VERSION}: {target}")
|
||||
print("File capabilities must be reapplied after replacing the binary.")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Generated
+4580
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,30 @@
|
||||
[package]
|
||||
name = "minivless"
|
||||
version = "0.1.0"
|
||||
description = "A small personal VLESS client for Linux"
|
||||
edition = "2021"
|
||||
license = "MIT"
|
||||
rust-version = "1.85"
|
||||
|
||||
[features]
|
||||
default = ["desktop"]
|
||||
desktop = ["dep:tauri", "dep:tauri-build"]
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", optional = true , features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2", optional = true , features = [] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
url = "2"
|
||||
uuid = "1"
|
||||
base64 = "0.22"
|
||||
libc = "0.2"
|
||||
tempfile = "3"
|
||||
signal-hook = "0.3"
|
||||
|
||||
[profile.release]
|
||||
strip = true
|
||||
lto = "thin"
|
||||
codegen-units = 1
|
||||
@@ -0,0 +1,4 @@
|
||||
fn main() {
|
||||
#[cfg(feature = "desktop")]
|
||||
tauri_build::build();
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
//! Local test server credentials only. Never accepts a real subscription on argv.
|
||||
use minivless::{
|
||||
config, guardian,
|
||||
storage::Preferences,
|
||||
tunnel::{AppState, Phase},
|
||||
vless,
|
||||
};
|
||||
use std::{
|
||||
io::{Read, Write},
|
||||
net::{SocketAddr, TcpStream, UdpSocket},
|
||||
path::{Path, PathBuf},
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
fn main() {
|
||||
let a: Vec<String> = std::env::args().collect();
|
||||
if a.get(1).map(String::as_str) == Some("--core-guardian") {
|
||||
std::process::exit(guardian::run(Path::new(&a[2]), Path::new(&a[3])));
|
||||
}
|
||||
if a.get(1).map(String::as_str) == Some("--fetch") {
|
||||
let mut s = TcpStream::connect_timeout(
|
||||
&a[2].parse::<SocketAddr>().unwrap(),
|
||||
Duration::from_secs(4),
|
||||
)
|
||||
.unwrap();
|
||||
s.set_read_timeout(Some(Duration::from_secs(4))).unwrap();
|
||||
s.write_all(b"GET / HTTP/1.0\r\nHost: test\r\n\r\n")
|
||||
.unwrap();
|
||||
let mut text = String::new();
|
||||
s.read_to_string(&mut text).unwrap();
|
||||
println!("{}", text.split("\r\n\r\n").nth(1).unwrap());
|
||||
return;
|
||||
}
|
||||
if a.get(1).map(String::as_str) == Some("--dns") {
|
||||
let socket = UdpSocket::bind("0.0.0.0:0").unwrap();
|
||||
socket
|
||||
.set_read_timeout(Some(Duration::from_secs(5)))
|
||||
.unwrap();
|
||||
let query = b"\x12\x34\x01\x00\x00\x01\x00\x00\x00\x00\x00\x00\x04test\x07example\x00\x00\x01\x00\x01";
|
||||
socket
|
||||
.send_to(query, a.get(2).map(String::as_str).unwrap_or("8.8.8.8:53"))
|
||||
.unwrap();
|
||||
let mut reply = [0; 512];
|
||||
let (n, _) = socket.recv_from(&mut reply).unwrap();
|
||||
assert!(n >= 4);
|
||||
println!(
|
||||
"{}.{}.{}.{}",
|
||||
reply[n - 4],
|
||||
reply[n - 3],
|
||||
reply[n - 2],
|
||||
reply[n - 1]
|
||||
);
|
||||
return;
|
||||
}
|
||||
if a.get(1).map(String::as_str) == Some("--udp") {
|
||||
let addr = a[2].parse::<SocketAddr>().unwrap();
|
||||
let socket = UdpSocket::bind(if addr.is_ipv6() {
|
||||
"[::]:0"
|
||||
} else {
|
||||
"0.0.0.0:0"
|
||||
})
|
||||
.unwrap();
|
||||
socket
|
||||
.set_read_timeout(Some(Duration::from_secs(4)))
|
||||
.unwrap();
|
||||
socket.send_to(b"peer", addr).unwrap();
|
||||
let mut b = [0; 512];
|
||||
let (n, _) = socket.recv_from(&mut b).unwrap();
|
||||
println!("{}", String::from_utf8_lossy(&b[..n]));
|
||||
return;
|
||||
}
|
||||
if a.get(1).map(String::as_str) == Some("--emit-configs") {
|
||||
let dir = PathBuf::from(&a[2]);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
for (name,q) in [("plain",""),("tls","?security=tls&sni=example.com"),("reality","?security=reality&sni=example.com&pbk=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA&sid=0123&fp=chrome&flow=xtls-rprx-vision"),("ws","?type=ws&security=tls&path=%2Fsocket&host=example.com")] {
|
||||
let v=vless::parse(&format!("vless://b831381d-6324-4d53-ad4f-8cda48b30811@192.0.2.2:2443{q}")).unwrap();
|
||||
for split in [true,false] {let c=config::generate(&v,&["/usr/bin/curl".into()],split,1000);std::fs::write(dir.join(format!("{name}-{split}.json")),serde_json::to_vec_pretty(&c).unwrap()).unwrap();}
|
||||
}
|
||||
return;
|
||||
}
|
||||
assert_eq!(a.get(1).map(String::as_str), Some("--connect"));
|
||||
let state = AppState::new(
|
||||
a[3].clone().into(),
|
||||
Some(a[2].clone().into()),
|
||||
std::env::current_exe().unwrap(),
|
||||
);
|
||||
let request = Preferences {
|
||||
// An ignored query value must not interfere with internal version parsing
|
||||
// even though that value is redacted from user-visible core logs.
|
||||
vless_url: "vless://b831381d-6324-4d53-ad4f-8cda48b30811@192.0.2.2:2443?remark=1.14".into(),
|
||||
applications: vec![a[4].clone()],
|
||||
split_tunneling: a[5] == "split",
|
||||
..Default::default()
|
||||
};
|
||||
state.connect(request).unwrap();
|
||||
let until = Instant::now() + Duration::from_secs(20);
|
||||
while Instant::now() < until {
|
||||
let status = state.status();
|
||||
if status.phase == Phase::Connected {
|
||||
println!("READY");
|
||||
std::io::stdout().flush().unwrap();
|
||||
break;
|
||||
}
|
||||
if status.phase == Phase::Error {
|
||||
eprintln!(
|
||||
"{}\n{}",
|
||||
status.error.unwrap_or_default(),
|
||||
state.logs().join("\n")
|
||||
);
|
||||
std::process::exit(1);
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
}
|
||||
if state.status().phase != Phase::Connected {
|
||||
state.shutdown();
|
||||
panic!("startup timeout");
|
||||
}
|
||||
let mut b = [0; 1];
|
||||
let _ = std::io::stdin().read(&mut b);
|
||||
state.shutdown();
|
||||
println!("STOPPED");
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 971 B |
@@ -0,0 +1,84 @@
|
||||
use crate::vless::Vless;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
/// sing-box 1.14.x. No legacy DNS servers or removed outbound actions.
|
||||
pub fn generate(vless: &Vless, selected: &[String], split: bool, uid: u32) -> Value {
|
||||
let mut inbound = json!({
|
||||
"type":"tun", "tag":"tun-in", "interface_name":"minivless0",
|
||||
"address":["172.31.255.1/30", "fdfe:dcba:ffff::1/126"],
|
||||
"mtu":1500, "auto_route":true, "auto_redirect":true, "strict_route":true,
|
||||
"stack":"system", "iproute2_table_index":2090, "iproute2_rule_index":10900,
|
||||
"auto_redirect_input_mark":"0x2091", "auto_redirect_output_mark":"0x2092",
|
||||
"auto_redirect_reset_mark":"0x2093", "auto_redirect_nfqueue":109,
|
||||
"dns_mode": "hijack"
|
||||
});
|
||||
if split {
|
||||
inbound["include_uid"] = json!([uid]);
|
||||
}
|
||||
let mut route_rules = vec![json!({"port":53, "action":"hijack-dns"})];
|
||||
if split {
|
||||
route_rules.push(json!({"process_path":selected, "action":"route", "outbound":"proxy"}));
|
||||
}
|
||||
let dns_rules = if split {
|
||||
vec![json!({"process_path":selected, "action":"route", "server":"remote-dns"})]
|
||||
} else {
|
||||
vec![]
|
||||
};
|
||||
json!({
|
||||
"log":{"level":"info", "timestamp":false, "disabled":false},
|
||||
"dns":{
|
||||
"servers":[
|
||||
{"type":"https", "tag":"direct-dns", "server":"1.1.1.1", "tls":{"server_name":"cloudflare-dns.com"}},
|
||||
{"type":"https", "tag":"remote-dns", "server":"1.1.1.1", "tls":{"server_name":"cloudflare-dns.com"}, "detour":"proxy"}
|
||||
],
|
||||
"rules":dns_rules,
|
||||
"final":if split {"direct-dns"} else {"remote-dns"},
|
||||
"independent_cache":true
|
||||
},
|
||||
"inbounds":[inbound],
|
||||
"outbounds":[vless.outbound(), {"type":"direct", "tag":"direct"}],
|
||||
"route":{
|
||||
"rules":route_rules,
|
||||
"final":if split {"direct"} else {"proxy"},
|
||||
"auto_detect_interface":true,
|
||||
"default_domain_resolver":"direct-dns"
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
fn sample() -> Vless {
|
||||
crate::vless::parse("vless://b831381d-6324-4d53-ad4f-8cda48b30811@127.0.0.1:443").unwrap()
|
||||
}
|
||||
#[test]
|
||||
fn split_has_explicit_direct_fallback() {
|
||||
let c = generate(&sample(), &["/opt/discord/Discord".into()], true, 1000);
|
||||
assert_eq!(c["route"]["final"], "direct");
|
||||
assert_eq!(
|
||||
c["route"]["rules"][1]["process_path"],
|
||||
json!(["/opt/discord/Discord"])
|
||||
);
|
||||
assert_eq!(c["route"]["rules"][1]["outbound"], "proxy");
|
||||
assert_eq!(c["inbounds"][0]["include_uid"], json!([1000]));
|
||||
assert_eq!(c["dns"]["final"], "direct-dns");
|
||||
assert_eq!(c["dns"]["rules"][0]["server"], "remote-dns");
|
||||
}
|
||||
#[test]
|
||||
fn full_system_includes_other_users() {
|
||||
let c = generate(&sample(), &[], false, 1000);
|
||||
assert_eq!(c["route"]["final"], "proxy");
|
||||
assert!(c["inbounds"][0].get("include_uid").is_none());
|
||||
assert_eq!(c["dns"]["final"], "remote-dns");
|
||||
assert_eq!(c["inbounds"][0]["dns_mode"], "hijack");
|
||||
}
|
||||
#[test]
|
||||
fn avoids_recursion_and_handles_ipv6() {
|
||||
let c = generate(&sample(), &[], false, 1000);
|
||||
assert_eq!(c["route"]["auto_detect_interface"], true);
|
||||
assert_eq!(c["route"]["default_domain_resolver"], "direct-dns");
|
||||
assert_eq!(c["inbounds"][0]["address"].as_array().unwrap().len(), 2);
|
||||
assert_eq!(c["route"]["rules"][0]["action"], "hijack-dns");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
//! A pipe-lifetime guardian is necessary: exec of a capability-bearing binary clears
|
||||
//! Linux PR_SET_PDEATHSIG. Closing the GUI's pipe (even after SIGKILL) stops the core.
|
||||
use std::{
|
||||
io::Read,
|
||||
path::Path,
|
||||
process::{Child, Command, Stdio},
|
||||
sync::{
|
||||
atomic::{AtomicBool, Ordering},
|
||||
mpsc, Arc,
|
||||
},
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
pub fn terminate(child: &mut Child) {
|
||||
if matches!(child.try_wait(), Ok(Some(_))) {
|
||||
return;
|
||||
}
|
||||
// Child remains unreaped until wait below, so its PID cannot be reused here.
|
||||
unsafe {
|
||||
libc::kill(child.id() as i32, libc::SIGTERM);
|
||||
}
|
||||
let deadline = Instant::now() + Duration::from_secs(3);
|
||||
while Instant::now() < deadline {
|
||||
if matches!(child.try_wait(), Ok(Some(_))) {
|
||||
return;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(30));
|
||||
}
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
}
|
||||
|
||||
pub fn run(binary: &Path, config: &Path) -> i32 {
|
||||
let stopping = Arc::new(AtomicBool::new(false));
|
||||
for signal in [libc::SIGTERM, libc::SIGINT, libc::SIGHUP] {
|
||||
if signal_hook::flag::register(signal, stopping.clone()).is_err() {
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
// The separate lock is also held by the guardian during GUI-crash cleanup.
|
||||
let lock_dir = config.parent().and_then(Path::parent);
|
||||
let _lock = match lock_dir.and_then(|dir| crate::tunnel::core_lock(dir).ok()) {
|
||||
Some(lock) => lock,
|
||||
None => {
|
||||
eprintln!("Another tunnel is still running or stopping");
|
||||
return 1;
|
||||
}
|
||||
};
|
||||
let (tx, rx) = mpsc::channel();
|
||||
std::thread::spawn(move || {
|
||||
let mut byte = [0; 1];
|
||||
let _ = std::io::stdin().read(&mut byte);
|
||||
let _ = tx.send(());
|
||||
});
|
||||
let mut child = match Command::new(binary)
|
||||
.args(["run", "--disable-color", "-c"])
|
||||
// Pinned sing-tun uses PATH to find resolvectl. DNS interception is native
|
||||
// nftables; disable external helpers so it never changes resolved settings
|
||||
// or asks Polkit for additional privileges. All core I/O is in-process.
|
||||
.env("PATH", "")
|
||||
.arg(config)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::inherit())
|
||||
.stderr(Stdio::inherit())
|
||||
.spawn()
|
||||
{
|
||||
Ok(child) => child,
|
||||
Err(_) => {
|
||||
eprintln!("Cannot start sing-box");
|
||||
cleanup(config);
|
||||
return 1;
|
||||
}
|
||||
};
|
||||
let code = loop {
|
||||
if stopping.load(Ordering::Relaxed) || rx.try_recv().is_ok() {
|
||||
terminate(&mut child);
|
||||
break 0;
|
||||
}
|
||||
match child.try_wait() {
|
||||
Ok(Some(s)) => break s.code().unwrap_or(1),
|
||||
Err(_) => {
|
||||
terminate(&mut child);
|
||||
break 1;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(30));
|
||||
};
|
||||
cleanup(config);
|
||||
code
|
||||
}
|
||||
|
||||
pub fn cleanup(config: &Path) {
|
||||
// Only files created by our runtime writer are eligible for deletion.
|
||||
if config.file_name().is_some_and(|s| s == "config.json") {
|
||||
if let Some(dir) = config.parent().filter(|p| {
|
||||
p.file_name()
|
||||
.is_some_and(|n| n.to_string_lossy().starts_with("runtime-"))
|
||||
}) {
|
||||
let _ = std::fs::remove_file(config);
|
||||
let _ = std::fs::remove_dir(dir);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
#![cfg(target_os = "linux")]
|
||||
pub mod config;
|
||||
pub mod guardian;
|
||||
pub mod processes;
|
||||
pub mod storage;
|
||||
pub mod tunnel;
|
||||
pub mod vless;
|
||||
@@ -0,0 +1,154 @@
|
||||
use minivless::{
|
||||
guardian,
|
||||
processes::{self, ApplicationInfo},
|
||||
storage::{self, Preferences},
|
||||
tunnel::{self, AppState, Status},
|
||||
};
|
||||
use std::{path::PathBuf, sync::Arc};
|
||||
|
||||
#[cfg(feature = "desktop")]
|
||||
mod desktop {
|
||||
use super::*;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use tauri::{Manager, State};
|
||||
#[tauri::command]
|
||||
async fn list_applications() -> Result<Vec<ApplicationInfo>, String> {
|
||||
tauri::async_runtime::spawn_blocking(processes::list_running)
|
||||
.await
|
||||
.map_err(|_| "Cannot list applications".into())
|
||||
}
|
||||
#[tauri::command]
|
||||
async fn list_installed_applications() -> Result<Vec<ApplicationInfo>, String> {
|
||||
tauri::async_runtime::spawn_blocking(processes::list_installed)
|
||||
.await
|
||||
.map_err(|_| "Cannot list installed applications".into())
|
||||
}
|
||||
#[tauri::command]
|
||||
async fn add_application(path: String) -> Result<ApplicationInfo, String> {
|
||||
tauri::async_runtime::spawn_blocking(move || processes::describe(&path))
|
||||
.await
|
||||
.map_err(|_| "Cannot add application")?
|
||||
}
|
||||
#[tauri::command]
|
||||
fn connect(state: State<'_, Arc<AppState>>, request: Preferences) -> Result<(), String> {
|
||||
state.inner().connect(request)
|
||||
}
|
||||
#[tauri::command]
|
||||
fn disconnect(state: State<'_, Arc<AppState>>) {
|
||||
state.disconnect()
|
||||
}
|
||||
#[tauri::command]
|
||||
fn get_status(state: State<'_, Arc<AppState>>) -> Status {
|
||||
state.status()
|
||||
}
|
||||
#[tauri::command]
|
||||
fn get_logs(state: State<'_, Arc<AppState>>) -> Vec<String> {
|
||||
state.logs()
|
||||
}
|
||||
#[tauri::command]
|
||||
fn load_preferences(state: State<'_, Arc<AppState>>) -> Result<Preferences, String> {
|
||||
storage::load(&state.directory)
|
||||
}
|
||||
#[tauri::command]
|
||||
async fn save_preferences(
|
||||
state: State<'_, Arc<AppState>>,
|
||||
preferences: Preferences,
|
||||
) -> Result<(), String> {
|
||||
let state = state.inner().clone();
|
||||
tauri::async_runtime::spawn_blocking(move || state.save(&preferences))
|
||||
.await
|
||||
.map_err(|_| "Cannot save settings")?
|
||||
}
|
||||
pub fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||
if unsafe { libc::geteuid() } == 0 {
|
||||
return Err(
|
||||
"Run MiniVLESS as your normal user. Only sing-box needs capabilities.".into(),
|
||||
);
|
||||
}
|
||||
let directory = storage::config_dir()?;
|
||||
let _lock = storage::lock(&directory)?;
|
||||
let exiting = Arc::new(AtomicBool::new(false));
|
||||
let closed = exiting.clone();
|
||||
let app = tauri::Builder::default()
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
list_applications,
|
||||
list_installed_applications,
|
||||
add_application,
|
||||
connect,
|
||||
disconnect,
|
||||
get_status,
|
||||
get_logs,
|
||||
load_preferences,
|
||||
save_preferences
|
||||
])
|
||||
.setup(move |app| {
|
||||
let resources = app.path().resource_dir().ok();
|
||||
let state = AppState::new(
|
||||
directory.clone(),
|
||||
tunnel::find_core(resources.as_deref()),
|
||||
std::env::current_exe()?,
|
||||
);
|
||||
app.manage(state.clone());
|
||||
let signals = Arc::new(AtomicBool::new(false));
|
||||
for sig in [libc::SIGINT, libc::SIGTERM, libc::SIGHUP] {
|
||||
signal_hook::flag::register(sig, signals.clone())?;
|
||||
}
|
||||
let handle = app.handle().clone();
|
||||
std::thread::spawn(move || {
|
||||
while !exiting.load(Ordering::Relaxed) {
|
||||
if signals.load(Ordering::Relaxed) {
|
||||
exiting.store(true, Ordering::Relaxed);
|
||||
state.shutdown();
|
||||
handle.exit(0);
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(100));
|
||||
}
|
||||
});
|
||||
Ok(())
|
||||
})
|
||||
.on_window_event(move |window, event| {
|
||||
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
|
||||
api.prevent_close();
|
||||
if !closed.swap(true, Ordering::SeqCst) {
|
||||
let state = window.state::<Arc<AppState>>().inner().clone();
|
||||
let app = window.app_handle().clone();
|
||||
std::thread::spawn(move || {
|
||||
state.shutdown();
|
||||
app.exit(0);
|
||||
});
|
||||
}
|
||||
}
|
||||
})
|
||||
.build(tauri::generate_context!())?;
|
||||
app.run(|app, event| {
|
||||
if matches!(event, tauri::RunEvent::Exit) {
|
||||
app.state::<Arc<AppState>>().shutdown();
|
||||
}
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let args = std::env::args_os().collect::<Vec<_>>();
|
||||
if args.get(1).is_some_and(|v| v == "--core-guardian") {
|
||||
if args.len() != 4 {
|
||||
std::process::exit(2);
|
||||
}
|
||||
std::process::exit(guardian::run(
|
||||
&PathBuf::from(&args[2]),
|
||||
&PathBuf::from(&args[3]),
|
||||
));
|
||||
}
|
||||
#[cfg(feature = "desktop")]
|
||||
if let Err(error) = desktop::run() {
|
||||
eprintln!("MiniVLESS: {error}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
#[cfg(not(feature = "desktop"))]
|
||||
{
|
||||
eprintln!("Build with the desktop feature to open MiniVLESS.");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
use serde::Serialize;
|
||||
use std::{
|
||||
collections::{BTreeMap, HashMap},
|
||||
fs,
|
||||
io::Read,
|
||||
os::unix::fs::{MetadataExt, PermissionsExt},
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
#[derive(Clone, Serialize)]
|
||||
pub struct ApplicationInfo {
|
||||
pub name: String,
|
||||
pub executable: String,
|
||||
pub running: bool,
|
||||
pub pid: Option<u32>,
|
||||
}
|
||||
|
||||
pub fn normalize(path: &str) -> Result<String, String> {
|
||||
if path.len() > 4096 || !Path::new(path).is_absolute() || path.chars().any(char::is_control) {
|
||||
return Err("Choose an absolute executable path".into());
|
||||
}
|
||||
let p = fs::canonicalize(path).map_err(|_| "Application executable was not found")?;
|
||||
let m = fs::metadata(&p).map_err(|_| "Cannot read application executable")?;
|
||||
if !m.is_file() || m.permissions().mode() & 0o111 == 0 {
|
||||
return Err("Choose an executable file".into());
|
||||
}
|
||||
let mut magic = [0; 4];
|
||||
fs::File::open(&p)
|
||||
.and_then(|mut f| f.read_exact(&mut magic))
|
||||
.map_err(|_| "Cannot read application executable")?;
|
||||
if magic != *b"\x7fELF" {
|
||||
return Err("Choose the real application binary, not a launcher script".into());
|
||||
}
|
||||
let s = p.to_str().ok_or("Application path must be valid UTF-8")?;
|
||||
if s.chars().any(char::is_control) {
|
||||
return Err("Invalid executable path".into());
|
||||
}
|
||||
Ok(s.into())
|
||||
}
|
||||
|
||||
fn desktop_dirs() -> Vec<PathBuf> {
|
||||
let mut dirs = std::env::var_os("XDG_DATA_DIRS")
|
||||
.map(|v| std::env::split_paths(&v).collect::<Vec<_>>())
|
||||
.unwrap_or_else(|| vec!["/usr/local/share".into(), "/usr/share".into()]);
|
||||
if let Some(home) = std::env::var_os("HOME") {
|
||||
dirs.push(PathBuf::from(home).join(".local/share"));
|
||||
}
|
||||
if let Some(home) = std::env::var_os("XDG_DATA_HOME") {
|
||||
if Path::new(&home).is_absolute() {
|
||||
dirs.push(home.into());
|
||||
}
|
||||
}
|
||||
dirs.into_iter().map(|p| p.join("applications")).collect()
|
||||
}
|
||||
|
||||
// Tokenize Desktop Entry Exec without ever executing it. Field codes are never evaluated.
|
||||
fn exec_token(exec: &str) -> Option<String> {
|
||||
let mut out = String::new();
|
||||
let mut quoted = false;
|
||||
let mut escaped = false;
|
||||
for c in exec.trim().chars() {
|
||||
if escaped {
|
||||
out.push(c);
|
||||
escaped = false;
|
||||
} else if c == '\\' {
|
||||
escaped = true;
|
||||
} else if c == '"' {
|
||||
quoted = !quoted;
|
||||
} else if c.is_whitespace() && !quoted {
|
||||
break;
|
||||
} else {
|
||||
out.push(c);
|
||||
}
|
||||
}
|
||||
if out.is_empty() || quoted || escaped {
|
||||
None
|
||||
} else {
|
||||
Some(out)
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_program(token: &str) -> Option<String> {
|
||||
if token.starts_with('/') {
|
||||
return normalize(token).ok();
|
||||
}
|
||||
std::env::var_os("PATH").and_then(|paths| {
|
||||
std::env::split_paths(&paths)
|
||||
.filter(|p| p.is_absolute())
|
||||
.find_map(|dir| normalize(dir.join(token).to_str()?).ok())
|
||||
})
|
||||
}
|
||||
|
||||
struct DesktopApp {
|
||||
name: String,
|
||||
path: Option<String>,
|
||||
keys: Vec<String>,
|
||||
}
|
||||
|
||||
fn catalog() -> Vec<DesktopApp> {
|
||||
let mut result = vec![];
|
||||
for dir in desktop_dirs() {
|
||||
let Ok(entries) = fs::read_dir(dir) else {
|
||||
continue;
|
||||
};
|
||||
for entry in entries.flatten().take(4096) {
|
||||
let path = entry.path();
|
||||
if path.extension().and_then(|s| s.to_str()) != Some("desktop") {
|
||||
continue;
|
||||
}
|
||||
if entry.metadata().map(|m| m.len() > 128_000).unwrap_or(true) {
|
||||
continue;
|
||||
}
|
||||
let Ok(text) = fs::read_to_string(&path) else {
|
||||
continue;
|
||||
};
|
||||
let mut fields = HashMap::new();
|
||||
let mut main = false;
|
||||
for line in text.lines() {
|
||||
if line.starts_with('[') {
|
||||
main = line == "[Desktop Entry]";
|
||||
continue;
|
||||
}
|
||||
if main {
|
||||
if let Some((k, v)) = line.split_once('=') {
|
||||
fields.insert(k, v);
|
||||
}
|
||||
}
|
||||
}
|
||||
if fields.get("Type") != Some(&"Application")
|
||||
|| fields.get("Hidden") == Some(&"true")
|
||||
|| fields.get("NoDisplay") == Some(&"true")
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let Some(name) = fields
|
||||
.get("Name")
|
||||
.filter(|s| s.len() <= 128 && !s.chars().any(char::is_control))
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let Some(token) = fields.get("Exec").and_then(|s| exec_token(s)) else {
|
||||
continue;
|
||||
};
|
||||
let mut keys = vec![];
|
||||
let mut is_launcher = true;
|
||||
if let Some(stem) = Path::new(&token).file_name().and_then(|s| s.to_str()) {
|
||||
// Generic launchers must never group unrelated apps into a single routing rule.
|
||||
if ![
|
||||
"env", "sh", "bash", "flatpak", "snap", "python", "python3", "node",
|
||||
"electron", "java",
|
||||
]
|
||||
.contains(&stem)
|
||||
{
|
||||
keys.push(stem.to_lowercase());
|
||||
is_launcher = false;
|
||||
}
|
||||
}
|
||||
if let Some(wm) = fields.get("StartupWMClass") {
|
||||
keys.push(wm.to_lowercase());
|
||||
}
|
||||
if keys.is_empty() {
|
||||
continue;
|
||||
}
|
||||
result.push(DesktopApp {
|
||||
name: (*name).into(),
|
||||
path: if is_launcher {
|
||||
None
|
||||
} else {
|
||||
resolve_program(&token)
|
||||
},
|
||||
keys,
|
||||
});
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
pub fn list_installed() -> Vec<ApplicationInfo> {
|
||||
let mut by_path = BTreeMap::new();
|
||||
for app in catalog() {
|
||||
if let Some(path) = app.path {
|
||||
by_path.entry(path.clone()).or_insert(ApplicationInfo {
|
||||
name: app.name,
|
||||
executable: path,
|
||||
running: false,
|
||||
pid: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
sorted(by_path.into_values().collect())
|
||||
}
|
||||
|
||||
pub fn list_running() -> Vec<ApplicationInfo> {
|
||||
let apps = catalog();
|
||||
let uid = unsafe { libc::geteuid() };
|
||||
let mut by_path = BTreeMap::new();
|
||||
let Ok(entries) = fs::read_dir("/proc") else {
|
||||
return vec![];
|
||||
};
|
||||
for e in entries.flatten() {
|
||||
let Some(pid) = e.file_name().to_str().and_then(|s| s.parse::<u32>().ok()) else {
|
||||
continue;
|
||||
};
|
||||
if e.metadata().map(|m| m.uid() != uid).unwrap_or(true) {
|
||||
continue;
|
||||
}
|
||||
let Ok(exe) = fs::read_link(e.path().join("exe")) else {
|
||||
continue;
|
||||
};
|
||||
let Some(executable) = exe.to_str() else {
|
||||
continue;
|
||||
};
|
||||
if executable.ends_with(" (deleted)") {
|
||||
continue;
|
||||
}
|
||||
let basename = exe
|
||||
.file_name()
|
||||
.unwrap_or_default()
|
||||
.to_string_lossy()
|
||||
.to_lowercase();
|
||||
let app = apps
|
||||
.iter()
|
||||
.find(|a| a.path.as_deref() == Some(executable))
|
||||
.or_else(|| apps.iter().find(|a| a.keys.contains(&basename)));
|
||||
let Some(app) = app else { continue };
|
||||
by_path
|
||||
.entry(executable.to_owned())
|
||||
.or_insert(ApplicationInfo {
|
||||
name: app.name.clone(),
|
||||
executable: executable.into(),
|
||||
running: true,
|
||||
pid: Some(pid),
|
||||
});
|
||||
}
|
||||
sorted(by_path.into_values().collect())
|
||||
}
|
||||
|
||||
fn sorted(mut apps: Vec<ApplicationInfo>) -> Vec<ApplicationInfo> {
|
||||
apps.sort_by_cached_key(|a| a.name.to_lowercase());
|
||||
apps
|
||||
}
|
||||
|
||||
pub fn describe(path: &str) -> Result<ApplicationInfo, String> {
|
||||
let path = normalize(path)?;
|
||||
if let Some(app) = list_running()
|
||||
.into_iter()
|
||||
.chain(list_installed())
|
||||
.find(|a| a.executable == path)
|
||||
{
|
||||
return Ok(app);
|
||||
}
|
||||
let name = Path::new(&path)
|
||||
.file_name()
|
||||
.unwrap_or_default()
|
||||
.to_string_lossy()
|
||||
.into_owned();
|
||||
Ok(ApplicationInfo {
|
||||
name,
|
||||
executable: path,
|
||||
running: false,
|
||||
pid: None,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn quoted_desktop_exec() {
|
||||
assert_eq!(
|
||||
exec_token("\"/opt/My App/app\" %U"),
|
||||
Some("/opt/My App/app".into())
|
||||
);
|
||||
assert!(exec_token("\"unclosed").is_none());
|
||||
}
|
||||
#[test]
|
||||
fn path_validation() {
|
||||
assert!(normalize("relative").is_err());
|
||||
assert!(normalize("/etc/passwd").is_err());
|
||||
assert!(normalize("/tmp/x\n").is_err());
|
||||
assert!(normalize("/usr/bin/true").is_ok());
|
||||
}
|
||||
#[test]
|
||||
fn scripts_are_not_routing_identities() {
|
||||
let d = tempfile::tempdir().unwrap();
|
||||
let p = d.path().join("launcher");
|
||||
fs::write(&p, "#!/bin/sh\nexec true\n").unwrap();
|
||||
fs::set_permissions(&p, fs::Permissions::from_mode(0o755)).unwrap();
|
||||
assert!(normalize(p.to_str().unwrap()).is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::{
|
||||
fs::{self, File, OpenOptions},
|
||||
io::{Read, Write},
|
||||
os::{
|
||||
fd::AsRawFd,
|
||||
unix::fs::{DirBuilderExt, MetadataExt, OpenOptionsExt, PermissionsExt},
|
||||
},
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct Preferences {
|
||||
pub vless_url: String,
|
||||
pub applications: Vec<String>,
|
||||
pub custom_applications: Vec<String>,
|
||||
pub split_tunneling: bool,
|
||||
}
|
||||
impl Default for Preferences {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
vless_url: String::new(),
|
||||
applications: vec![],
|
||||
custom_applications: vec![],
|
||||
split_tunneling: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Preferences {
|
||||
pub fn validate_size(&self) -> Result<(), String> {
|
||||
if self.vless_url.len() > 16_384
|
||||
|| self.applications.len() > 256
|
||||
|| self.custom_applications.len() > 256
|
||||
|| self
|
||||
.applications
|
||||
.iter()
|
||||
.chain(&self.custom_applications)
|
||||
.any(|s| s.len() > 4096 || !s.starts_with('/') || s.chars().any(char::is_control))
|
||||
{
|
||||
return Err("Invalid saved settings".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn config_dir() -> Result<PathBuf, String> {
|
||||
let base = std::env::var_os("XDG_CONFIG_HOME")
|
||||
.map(PathBuf::from)
|
||||
.filter(|p| p.is_absolute())
|
||||
.or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".config")))
|
||||
.ok_or("Cannot locate the configuration directory")?;
|
||||
let path = base.join("minivless");
|
||||
ensure_private_dir(&path)?;
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
pub fn ensure_private_dir(path: &Path) -> Result<(), String> {
|
||||
fs::DirBuilder::new()
|
||||
.recursive(true)
|
||||
.mode(0o700)
|
||||
.create(path)
|
||||
.map_err(|_| "Cannot create the private configuration directory")?;
|
||||
let m = fs::symlink_metadata(path).map_err(|_| "Cannot read the configuration directory")?;
|
||||
if !m.is_dir() || m.uid() != unsafe { libc::geteuid() } {
|
||||
return Err("Unsafe configuration directory".into());
|
||||
}
|
||||
fs::set_permissions(path, fs::Permissions::from_mode(0o700))
|
||||
.map_err(|_| "Cannot secure the configuration directory".into())
|
||||
}
|
||||
|
||||
pub fn load(dir: &Path) -> Result<Preferences, String> {
|
||||
let file = match OpenOptions::new()
|
||||
.read(true)
|
||||
.custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC)
|
||||
.open(dir.join("settings.json"))
|
||||
{
|
||||
Ok(f) => f,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Preferences::default()),
|
||||
Err(_) => return Err("Cannot read saved settings".into()),
|
||||
};
|
||||
let m = file.metadata().map_err(|_| "Cannot read saved settings")?;
|
||||
if !m.is_file() || m.uid() != unsafe { libc::geteuid() } || m.len() > 1_048_576 {
|
||||
return Err("Unsafe saved settings file".into());
|
||||
}
|
||||
file.set_permissions(fs::Permissions::from_mode(0o600))
|
||||
.map_err(|_| "Cannot secure saved settings")?;
|
||||
let mut data = String::new();
|
||||
file.take(1_048_577)
|
||||
.read_to_string(&mut data)
|
||||
.map_err(|_| "Cannot read saved settings")?;
|
||||
let p: Preferences = serde_json::from_str(&data)
|
||||
.map_err(|_| "Saved settings are damaged. Restore or remove settings.json.")?;
|
||||
p.validate_size()?;
|
||||
Ok(p)
|
||||
}
|
||||
|
||||
pub fn save(dir: &Path, settings: &Preferences) -> Result<(), String> {
|
||||
settings.validate_size()?;
|
||||
let mut tmp = tempfile::NamedTempFile::new_in(dir).map_err(|_| "Cannot save settings")?;
|
||||
tmp.as_file()
|
||||
.set_permissions(fs::Permissions::from_mode(0o600))
|
||||
.map_err(|_| "Cannot secure settings")?;
|
||||
serde_json::to_writer(&mut tmp, settings).map_err(|_| "Cannot save settings")?;
|
||||
tmp.flush()
|
||||
.and_then(|_| tmp.as_file().sync_all())
|
||||
.map_err(|_| "Cannot save settings")?;
|
||||
tmp.persist(dir.join("settings.json"))
|
||||
.map_err(|_| "Cannot replace saved settings")?;
|
||||
File::open(dir)
|
||||
.and_then(|f| f.sync_all())
|
||||
.map_err(|_| "Cannot sync saved settings")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Keep the file open for the process lifetime. The lock survives a stale lock file.
|
||||
pub fn lock(dir: &Path) -> Result<File, String> {
|
||||
let file = OpenOptions::new()
|
||||
.read(true)
|
||||
.write(true)
|
||||
.create(true)
|
||||
.truncate(false)
|
||||
.mode(0o600)
|
||||
.custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC)
|
||||
.open(dir.join("instance.lock"))
|
||||
.map_err(|_| "Cannot acquire application lock")?;
|
||||
if unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) } != 0 {
|
||||
return Err("MiniVLESS is already running".into());
|
||||
}
|
||||
Ok(file)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn private_roundtrip_and_lock() {
|
||||
let d = tempfile::tempdir().unwrap();
|
||||
let p = Preferences {
|
||||
vless_url: "test-secret".into(),
|
||||
split_tunneling: false,
|
||||
applications: vec!["/opt/discord/Discord".into()],
|
||||
custom_applications: vec!["/opt/discord/Discord".into()],
|
||||
};
|
||||
save(d.path(), &p).unwrap();
|
||||
assert_eq!(load(d.path()).unwrap().vless_url, "test-secret");
|
||||
assert!(!load(d.path()).unwrap().split_tunneling);
|
||||
assert_eq!(load(d.path()).unwrap().applications, p.applications);
|
||||
assert_eq!(
|
||||
load(d.path()).unwrap().custom_applications,
|
||||
p.custom_applications
|
||||
);
|
||||
assert_eq!(
|
||||
fs::metadata(d.path().join("settings.json")).unwrap().mode() & 0o777,
|
||||
0o600
|
||||
);
|
||||
let first = lock(d.path()).unwrap();
|
||||
assert!(lock(d.path()).is_err());
|
||||
drop(first);
|
||||
assert!(lock(d.path()).is_ok());
|
||||
}
|
||||
#[test]
|
||||
fn never_follows_settings_symlink() {
|
||||
let d = tempfile::tempdir().unwrap();
|
||||
std::os::unix::fs::symlink("/etc/passwd", d.path().join("settings.json")).unwrap();
|
||||
assert!(load(d.path()).is_err());
|
||||
}
|
||||
#[test]
|
||||
fn corrupt_settings_are_not_silently_overwritten() {
|
||||
let d = tempfile::tempdir().unwrap();
|
||||
fs::write(d.path().join("settings.json"), "{bad").unwrap();
|
||||
assert!(load(d.path()).is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,566 @@
|
||||
use crate::{
|
||||
config, guardian, processes,
|
||||
storage::{self, Preferences},
|
||||
vless,
|
||||
};
|
||||
use serde::Serialize;
|
||||
use std::{
|
||||
collections::VecDeque,
|
||||
fs::{self, File, OpenOptions},
|
||||
io::{Read, Write},
|
||||
os::{fd::AsRawFd, unix::fs::OpenOptionsExt},
|
||||
path::{Path, PathBuf},
|
||||
process::{Command, Stdio},
|
||||
sync::{
|
||||
mpsc::{self, Receiver, SyncSender},
|
||||
Arc, Mutex,
|
||||
},
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Serialize)]
|
||||
pub enum Phase {
|
||||
Disconnected,
|
||||
Connecting,
|
||||
Connected,
|
||||
Disconnecting,
|
||||
Error,
|
||||
}
|
||||
#[derive(Clone, Serialize)]
|
||||
pub struct Status {
|
||||
pub phase: Phase,
|
||||
pub error: Option<String>,
|
||||
pub split_tunneling: bool,
|
||||
pub applications: Vec<String>,
|
||||
}
|
||||
struct Inner {
|
||||
status: Status,
|
||||
logs: VecDeque<String>,
|
||||
stop: Option<mpsc::Sender<()>>,
|
||||
}
|
||||
pub struct AppState {
|
||||
inner: Mutex<Inner>,
|
||||
pub directory: PathBuf,
|
||||
pub core: Option<PathBuf>,
|
||||
pub guardian: PathBuf,
|
||||
saving: Mutex<()>,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
pub fn new(directory: PathBuf, core: Option<PathBuf>, guardian: PathBuf) -> Arc<Self> {
|
||||
Arc::new(Self {
|
||||
directory,
|
||||
core,
|
||||
guardian,
|
||||
saving: Mutex::new(()),
|
||||
inner: Mutex::new(Inner {
|
||||
status: Status {
|
||||
phase: Phase::Disconnected,
|
||||
error: None,
|
||||
split_tunneling: true,
|
||||
applications: vec![],
|
||||
},
|
||||
logs: VecDeque::new(),
|
||||
stop: None,
|
||||
}),
|
||||
})
|
||||
}
|
||||
pub fn status(&self) -> Status {
|
||||
self.inner
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.status
|
||||
.clone()
|
||||
}
|
||||
pub fn logs(&self) -> Vec<String> {
|
||||
self.inner
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.logs
|
||||
.iter()
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
fn log(&self, line: String) {
|
||||
let mut i = self.inner.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if i.logs.len() >= 300 {
|
||||
i.logs.pop_front();
|
||||
}
|
||||
i.logs.push_back(line);
|
||||
}
|
||||
pub fn save(&self, prefs: &Preferences) -> Result<(), String> {
|
||||
let _guard = self.saving.lock().map_err(|_| "Cannot save settings")?;
|
||||
storage::save(&self.directory, prefs)
|
||||
}
|
||||
pub fn connect(self: &Arc<Self>, request: Preferences) -> Result<(), String> {
|
||||
let (tx, rx) = mpsc::channel();
|
||||
{
|
||||
let mut i = self
|
||||
.inner
|
||||
.lock()
|
||||
.map_err(|_| "Application state is unavailable")?;
|
||||
if i.stop.is_some() {
|
||||
return Err("A connection is already running or stopping".into());
|
||||
}
|
||||
i.logs.clear();
|
||||
i.stop = Some(tx);
|
||||
i.status = Status {
|
||||
phase: Phase::Connecting,
|
||||
error: None,
|
||||
split_tunneling: request.split_tunneling,
|
||||
applications: request.applications.clone(),
|
||||
};
|
||||
}
|
||||
let state = self.clone();
|
||||
std::thread::spawn(move || {
|
||||
let outcome = state.run(request, &rx);
|
||||
let mut i = state.inner.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let cancelled = i.status.phase == Phase::Disconnecting;
|
||||
i.stop = None;
|
||||
if cancelled || outcome.is_ok() {
|
||||
i.status.phase = Phase::Disconnected;
|
||||
i.status.error = None;
|
||||
} else {
|
||||
i.status.phase = Phase::Error;
|
||||
i.status.error = outcome.err();
|
||||
}
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
pub fn disconnect(&self) {
|
||||
let mut i = self.inner.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if let Some(tx) = &i.stop {
|
||||
let _ = tx.send(());
|
||||
i.status.phase = Phase::Disconnecting;
|
||||
} else {
|
||||
i.status.phase = Phase::Disconnected;
|
||||
i.status.error = None;
|
||||
}
|
||||
}
|
||||
pub fn shutdown(&self) {
|
||||
self.disconnect();
|
||||
let deadline = Instant::now() + Duration::from_secs(15);
|
||||
while Instant::now() < deadline {
|
||||
if self
|
||||
.inner
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.stop
|
||||
.is_none()
|
||||
{
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(30));
|
||||
}
|
||||
}
|
||||
|
||||
fn run(&self, mut request: Preferences, stop: &Receiver<()>) -> Result<(), String> {
|
||||
request.validate_size()?;
|
||||
let parsed = vless::parse(&request.vless_url)?;
|
||||
if request.split_tunneling {
|
||||
if request.applications.is_empty() {
|
||||
return Err("Select at least one application".into());
|
||||
}
|
||||
request.applications = request
|
||||
.applications
|
||||
.iter()
|
||||
.map(|p| processes::normalize(p))
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
request.applications.sort();
|
||||
request.applications.dedup();
|
||||
}
|
||||
self.save(&request)?;
|
||||
let binary = self.core.as_ref().ok_or(
|
||||
"sing-box was not found. Run scripts/fetch-sing-box.py and install its capabilities.",
|
||||
)?;
|
||||
let redactor = Redactor(parsed.secrets.clone());
|
||||
self.log("Checking sing-box and TUN permissions…".into());
|
||||
// This command receives no config. Do not redact its internal result with
|
||||
// link secrets: a valid two-digit REALITY short ID such as "14" would
|
||||
// otherwise corrupt "1.14.0" and falsely reject a supported core.
|
||||
let version = run_check(binary, &["version"], None, &Redactor(vec![]), stop)
|
||||
.map_err(|_| "Cannot read sing-box version")?;
|
||||
if !version.contains("sing-box version 1.14.") {
|
||||
return Err("Use sing-box 1.14.x with this version of MiniVLESS".into());
|
||||
}
|
||||
if !Path::new("/dev/net/tun").exists() {
|
||||
return Err("Linux TUN is unavailable (/dev/net/tun is missing)".into());
|
||||
}
|
||||
if !has_capabilities(binary) {
|
||||
return Err(format!("TUN permission is missing.\nRun in a terminal:\nsudo setcap cap_net_admin,cap_net_raw+ep {}",shell_quote(binary)));
|
||||
}
|
||||
if Path::new("/sys/class/net/minivless0").exists() {
|
||||
return Err(
|
||||
"The minivless0 tunnel already exists. Stop the other tunnel first.".into(),
|
||||
);
|
||||
}
|
||||
let cfg = config::generate(
|
||||
&parsed,
|
||||
&request.applications,
|
||||
request.split_tunneling,
|
||||
unsafe { libc::geteuid() },
|
||||
);
|
||||
let runtime = tempfile::Builder::new()
|
||||
.prefix("runtime-")
|
||||
.tempdir_in(&self.directory)
|
||||
.map_err(|_| "Cannot create runtime directory")?;
|
||||
let config_path = runtime.path().join("config.json");
|
||||
let mut file = OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.mode(0o600)
|
||||
.open(&config_path)
|
||||
.map_err(|_| "Cannot write runtime configuration")?;
|
||||
serde_json::to_writer(&mut file, &cfg).map_err(|_| "Cannot write runtime configuration")?;
|
||||
file.flush()
|
||||
.and_then(|_| file.sync_all())
|
||||
.map_err(|_| "Cannot save runtime configuration")?;
|
||||
let check = run_check(
|
||||
binary,
|
||||
&["check", "--disable-color", "-c"],
|
||||
Some(&config_path),
|
||||
&redactor,
|
||||
stop,
|
||||
);
|
||||
if let Err(detail) = check {
|
||||
self.log(detail);
|
||||
return Err("sing-box configuration check failed. See logs.".into());
|
||||
}
|
||||
if stop.try_recv().is_ok() {
|
||||
return Ok(());
|
||||
}
|
||||
self.log(
|
||||
if request.split_tunneling {
|
||||
"Starting VPN for selected applications…"
|
||||
} else {
|
||||
"Starting VPN for the whole system…"
|
||||
}
|
||||
.into(),
|
||||
);
|
||||
let mut child = Command::new(&self.guardian)
|
||||
.arg("--core-guardian")
|
||||
.arg(binary)
|
||||
.arg(&config_path)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.map_err(|_| "Cannot start the tunnel supervisor")?;
|
||||
let _runtime_path = runtime.keep(); // Guardian owns cleanup, including after GUI SIGKILL.
|
||||
let mut control = child.stdin.take();
|
||||
let (tx, logs) = mpsc::sync_channel(128);
|
||||
pump(child.stdout.take().unwrap(), tx.clone());
|
||||
pump(child.stderr.take().unwrap(), tx);
|
||||
let started = Instant::now();
|
||||
let mut ready = false;
|
||||
let mut error_hint = None;
|
||||
let result = loop {
|
||||
if stop.try_recv().is_ok() {
|
||||
drop(control.take());
|
||||
wait_guardian(&mut child);
|
||||
self.log("Tunnel stopped.".into());
|
||||
break Ok(());
|
||||
}
|
||||
if let Ok(line) = logs.recv_timeout(Duration::from_millis(40)) {
|
||||
if line.contains("sing-box started") {
|
||||
ready = true;
|
||||
let mut i = self.inner.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if i.status.phase == Phase::Connecting {
|
||||
i.status.phase = Phase::Connected;
|
||||
}
|
||||
}
|
||||
if line.contains("operation not permitted") || line.contains("permission denied") {
|
||||
error_hint = Some(
|
||||
"TUN permission was denied. Check capabilities and Linux security policy.",
|
||||
);
|
||||
}
|
||||
if line.contains("nfqueue")
|
||||
&& (line.contains("not supported") || line.contains("no such"))
|
||||
{
|
||||
error_hint = Some(
|
||||
"Linux NFQUEUE support is unavailable. Load nfnetlink_queue and retry.",
|
||||
);
|
||||
}
|
||||
self.log(redactor.clean(&line));
|
||||
}
|
||||
match child.try_wait() {
|
||||
Ok(Some(status)) => {
|
||||
for line in logs.try_iter() {
|
||||
self.log(redactor.clean(&line));
|
||||
}
|
||||
self.log(format!("Tunnel process exited ({status})."));
|
||||
break Err(error_hint
|
||||
.unwrap_or(if ready {
|
||||
"The tunnel stopped unexpectedly. See logs."
|
||||
} else {
|
||||
"Connection failed. See logs."
|
||||
})
|
||||
.into());
|
||||
}
|
||||
Err(_) => {
|
||||
drop(control.take());
|
||||
wait_guardian(&mut child);
|
||||
break Err("Cannot read tunnel status".into());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
if !ready && started.elapsed() > Duration::from_secs(12) {
|
||||
drop(control.take());
|
||||
wait_guardian(&mut child);
|
||||
break Err("Tunnel startup timed out. See logs.".into());
|
||||
}
|
||||
};
|
||||
drop(control);
|
||||
guardian::cleanup(&config_path);
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
fn wait_guardian(child: &mut std::process::Child) {
|
||||
let until = Instant::now() + Duration::from_secs(5);
|
||||
while Instant::now() < until {
|
||||
if matches!(child.try_wait(), Ok(Some(_))) {
|
||||
return;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(30));
|
||||
}
|
||||
// Signal its cooperative handler; never SIGKILL a live guardian before its core.
|
||||
unsafe {
|
||||
libc::kill(child.id() as i32, libc::SIGTERM);
|
||||
}
|
||||
let _ = child.wait();
|
||||
}
|
||||
|
||||
fn run_check(
|
||||
binary: &Path,
|
||||
args: &[&str],
|
||||
config: Option<&Path>,
|
||||
redactor: &Redactor,
|
||||
stop: &Receiver<()>,
|
||||
) -> Result<String, String> {
|
||||
let mut cmd = Command::new(binary);
|
||||
cmd.args(args);
|
||||
if let Some(c) = config {
|
||||
cmd.arg(c);
|
||||
}
|
||||
let mut child = cmd
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.map_err(|_| "Cannot execute sing-box")?;
|
||||
let (tx, rx) = mpsc::sync_channel(128);
|
||||
pump(child.stdout.take().unwrap(), tx.clone());
|
||||
pump(child.stderr.take().unwrap(), tx);
|
||||
let until = Instant::now() + Duration::from_secs(5);
|
||||
let mut lines = VecDeque::new();
|
||||
loop {
|
||||
if let Ok(line) = rx.recv_timeout(Duration::from_millis(20)) {
|
||||
if lines.len() >= 20 {
|
||||
lines.pop_front();
|
||||
}
|
||||
lines.push_back(redactor.clean(&line));
|
||||
}
|
||||
match child.try_wait() {
|
||||
Ok(Some(status)) => {
|
||||
// Readers can still be scheduled just after waitpid reports exit.
|
||||
// Wait briefly for EOF so short `version` output cannot be lost.
|
||||
while let Ok(line) = rx.recv_timeout(Duration::from_millis(100)) {
|
||||
if lines.len() >= 20 {
|
||||
lines.pop_front();
|
||||
}
|
||||
lines.push_back(redactor.clean(&line));
|
||||
}
|
||||
let text = lines.into_iter().collect::<Vec<_>>().join("\n");
|
||||
return if status.success() {
|
||||
Ok(text)
|
||||
} else {
|
||||
Err(text)
|
||||
};
|
||||
}
|
||||
Err(_) => {
|
||||
guardian::terminate(&mut child);
|
||||
return Err("Cannot check sing-box".into());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
if Instant::now() > until || stop.try_recv().is_ok() {
|
||||
guardian::terminate(&mut child);
|
||||
return Err("sing-box check cancelled or timed out".into());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Redactor(pub Vec<String>);
|
||||
impl Redactor {
|
||||
pub fn clean(&self, text: &str) -> String {
|
||||
if text.contains("vless://") {
|
||||
return "[Credential-bearing log line omitted]".into();
|
||||
}
|
||||
let mut line = text.to_owned();
|
||||
for secret in &self.0 {
|
||||
if !secret.is_empty() {
|
||||
line = line.replace(secret, "[redacted]");
|
||||
}
|
||||
}
|
||||
line.chars()
|
||||
.filter(|c| !c.is_control() || *c == '\t')
|
||||
.take(4096)
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// Drop whole overlong lines, rather than truncating half of a credential before redaction.
|
||||
fn pump(mut reader: impl Read + Send + 'static, tx: SyncSender<String>) {
|
||||
std::thread::spawn(move || {
|
||||
let mut chunk = [0; 1024];
|
||||
let mut line = Vec::new();
|
||||
let mut overflow = false;
|
||||
loop {
|
||||
let n = match reader.read(&mut chunk) {
|
||||
Ok(0) | Err(_) => break,
|
||||
Ok(n) => n,
|
||||
};
|
||||
for &byte in &chunk[..n] {
|
||||
if byte == b'\n' {
|
||||
let text = if overflow {
|
||||
"[Overlong log line omitted]".into()
|
||||
} else {
|
||||
String::from_utf8_lossy(&line).into_owned()
|
||||
};
|
||||
if tx.send(text).is_err() {
|
||||
return;
|
||||
}
|
||||
line.clear();
|
||||
overflow = false;
|
||||
} else if line.len() < 65_536 {
|
||||
line.push(byte);
|
||||
} else {
|
||||
overflow = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if !line.is_empty() {
|
||||
let _ = tx.send(if overflow {
|
||||
"[Overlong log line omitted]".into()
|
||||
} else {
|
||||
String::from_utf8_lossy(&line).into_owned()
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
pub fn core_lock(dir: &Path) -> Result<File, String> {
|
||||
let f = OpenOptions::new()
|
||||
.read(true)
|
||||
.write(true)
|
||||
.create(true)
|
||||
.truncate(false)
|
||||
.mode(0o600)
|
||||
.custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC)
|
||||
.open(dir.join("tunnel.lock"))
|
||||
.map_err(|_| "Cannot acquire tunnel lock")?;
|
||||
if unsafe { libc::flock(f.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) } != 0 {
|
||||
return Err("Another tunnel is still stopping".into());
|
||||
}
|
||||
Ok(f)
|
||||
}
|
||||
|
||||
pub fn find_core(resource_dir: Option<&Path>) -> Option<PathBuf> {
|
||||
if let Some(p) = std::env::var_os("MINIVLESS_SING_BOX") {
|
||||
return fs::canonicalize(p).ok();
|
||||
}
|
||||
let mut candidates = vec![PathBuf::from("/usr/local/lib/minivless/sing-box")];
|
||||
if let Some(p) = resource_dir {
|
||||
candidates.push(p.join("binaries/sing-box"));
|
||||
}
|
||||
#[cfg(debug_assertions)]
|
||||
candidates.push(Path::new(env!("CARGO_MANIFEST_DIR")).join("../binaries/sing-box"));
|
||||
if let Ok(exe) = std::env::current_exe() {
|
||||
if let Some(dir) = exe.parent() {
|
||||
candidates.push(dir.join("binaries/sing-box"));
|
||||
}
|
||||
}
|
||||
candidates.extend([
|
||||
PathBuf::from("/usr/bin/sing-box"),
|
||||
PathBuf::from("/usr/local/bin/sing-box"),
|
||||
]);
|
||||
candidates
|
||||
.into_iter()
|
||||
.find_map(|p| fs::canonicalize(p).ok().filter(|p| p.is_file()))
|
||||
}
|
||||
|
||||
fn has_capabilities(binary: &Path) -> bool {
|
||||
// Enables isolated user/network namespace tests without modifying file capabilities.
|
||||
let effective = fs::read_to_string("/proc/self/status")
|
||||
.ok()
|
||||
.and_then(|s| {
|
||||
s.lines().find_map(|l| {
|
||||
l.strip_prefix("CapEff:\t")
|
||||
.and_then(|s| u64::from_str_radix(s.trim(), 16).ok())
|
||||
})
|
||||
})
|
||||
.unwrap_or(0);
|
||||
let wanted = (1 << 12) | (1 << 13);
|
||||
if effective & wanted == wanted {
|
||||
return true;
|
||||
}
|
||||
let Some(path) = binary.to_str().and_then(|s| std::ffi::CString::new(s).ok()) else {
|
||||
return false;
|
||||
};
|
||||
let mut value = [0u8; 24];
|
||||
let size = unsafe {
|
||||
libc::getxattr(
|
||||
path.as_ptr(),
|
||||
c"security.capability".as_ptr(),
|
||||
value.as_mut_ptr().cast(),
|
||||
value.len(),
|
||||
)
|
||||
};
|
||||
if size < 12 {
|
||||
return false;
|
||||
}
|
||||
let magic = u32::from_le_bytes(value[0..4].try_into().unwrap());
|
||||
let permitted = u32::from_le_bytes(value[4..8].try_into().unwrap());
|
||||
magic & 1 != 0 && permitted & wanted as u32 == wanted as u32
|
||||
}
|
||||
fn shell_quote(path: &Path) -> String {
|
||||
format!("'{}'", path.to_string_lossy().replace('\'', "'\\''"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn credentials_are_redacted() {
|
||||
let r = Redactor(vec!["secret-uuid".into(), "secret-key".into()]);
|
||||
assert_eq!(
|
||||
r.clean("uuid=secret-uuid key=secret-key"),
|
||||
"uuid=[redacted] key=[redacted]"
|
||||
);
|
||||
assert!(!r.clean("bad vless://secret").contains("vless://"));
|
||||
}
|
||||
#[test]
|
||||
fn log_bounds() {
|
||||
let d = tempfile::tempdir().unwrap();
|
||||
let s = AppState::new(d.path().into(), None, "/bin/false".into());
|
||||
for _ in 0..400 {
|
||||
s.log("line".into());
|
||||
}
|
||||
assert_eq!(s.logs().len(), 300);
|
||||
}
|
||||
#[test]
|
||||
fn same_instance_rejects_parallel_connect() {
|
||||
let d = tempfile::tempdir().unwrap();
|
||||
let s = AppState::new(d.path().into(), None, "/bin/false".into());
|
||||
let (tx, _) = mpsc::channel();
|
||||
s.inner.lock().unwrap().stop = Some(tx);
|
||||
assert!(s.connect(Preferences::default()).is_err());
|
||||
}
|
||||
#[test]
|
||||
fn overlong_lines_never_emit_partial_secrets() {
|
||||
let (tx, rx) = mpsc::sync_channel(128);
|
||||
pump(std::io::Cursor::new(vec![b'x'; 70_000]), tx);
|
||||
assert_eq!(rx.recv().unwrap(), "[Overlong log line omitted]");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
|
||||
use serde_json::{json, Value};
|
||||
use std::collections::HashMap;
|
||||
use url::{Host, Url};
|
||||
|
||||
// Deliberately no Debug: these values contain credentials.
|
||||
pub struct Vless {
|
||||
pub server: String,
|
||||
pub port: u16,
|
||||
pub uuid: String,
|
||||
pub tls: Option<Value>,
|
||||
pub flow: String,
|
||||
pub transport: Option<Value>,
|
||||
pub secrets: Vec<String>,
|
||||
}
|
||||
|
||||
pub fn parse(input: &str) -> Result<Vless, String> {
|
||||
let input = input.trim();
|
||||
if input.len() > 16_384 || input.chars().any(char::is_control) {
|
||||
return Err("Invalid VLESS URL".into());
|
||||
}
|
||||
let u = Url::parse(input).map_err(|_| "Invalid VLESS URL")?;
|
||||
if u.scheme() != "vless" || u.password().is_some() || !matches!(u.path(), "" | "/") {
|
||||
return Err("Invalid VLESS URL".into());
|
||||
}
|
||||
let id = uuid::Uuid::parse_str(u.username()).map_err(|_| "Invalid or missing VLESS UUID")?;
|
||||
let server = match u.host().ok_or("Missing server address")? {
|
||||
Host::Domain(s) => s.to_owned(),
|
||||
Host::Ipv4(ip) => ip.to_string(),
|
||||
Host::Ipv6(ip) => ip.to_string(),
|
||||
};
|
||||
let port = u
|
||||
.port()
|
||||
.filter(|p| *p != 0)
|
||||
.ok_or("Invalid or missing server port")?;
|
||||
let mut q = HashMap::new();
|
||||
for (k, v) in u.query_pairs() {
|
||||
if v.chars().any(char::is_control) {
|
||||
return Err("Invalid VLESS parameter".into());
|
||||
}
|
||||
if q.insert(k.into_owned(), v.into_owned()).is_some() {
|
||||
return Err("Duplicate VLESS parameter".into());
|
||||
}
|
||||
}
|
||||
let get = |k: &str| q.get(k).map(String::as_str).unwrap_or("");
|
||||
let alias = |a: &str, b: &str| -> Result<String, String> {
|
||||
if !get(a).is_empty() && !get(b).is_empty() && get(a) != get(b) {
|
||||
return Err("Conflicting VLESS parameters".into());
|
||||
}
|
||||
Ok(if get(a).is_empty() { get(b) } else { get(a) }.to_owned())
|
||||
};
|
||||
if !matches!(get("encryption"), "" | "none") {
|
||||
return Err("Unsupported VLESS encryption".into());
|
||||
}
|
||||
if !matches!(get("headerType"), "" | "none") {
|
||||
return Err("Unsupported TCP header type".into());
|
||||
}
|
||||
if matches!(get("allowInsecure"), "1" | "true") || matches!(get("insecure"), "1" | "true") {
|
||||
return Err("Insecure TLS is not supported".into());
|
||||
}
|
||||
let transport_kind = get("type");
|
||||
let mut transport = match transport_kind {
|
||||
"" | "tcp" | "raw" => None,
|
||||
"ws" => {
|
||||
let path = if get("path").is_empty() {
|
||||
"/"
|
||||
} else {
|
||||
get("path")
|
||||
};
|
||||
if !path.starts_with('/') {
|
||||
return Err("Invalid WebSocket path".into());
|
||||
}
|
||||
let mut t = json!({"type":"ws", "path":path});
|
||||
if !get("host").is_empty() {
|
||||
t["headers"] = json!({"Host":get("host")});
|
||||
}
|
||||
Some(t)
|
||||
}
|
||||
"grpc" => return Err("Unsupported VLESS transport: grpc".into()),
|
||||
"xhttp" => return Err("Unsupported VLESS transport: xhttp".into()),
|
||||
"httpupgrade" => return Err("Unsupported VLESS transport: httpupgrade".into()),
|
||||
_ => return Err("Unsupported VLESS transport".into()),
|
||||
};
|
||||
if !get("ed").is_empty() {
|
||||
let ed = get("ed")
|
||||
.parse::<u32>()
|
||||
.map_err(|_| "Invalid WebSocket early data")?;
|
||||
if let Some(ref mut t) = transport {
|
||||
t["max_early_data"] = json!(ed);
|
||||
t["early_data_header_name"] = json!(if get("eh").is_empty() {
|
||||
"Sec-WebSocket-Protocol"
|
||||
} else {
|
||||
get("eh")
|
||||
});
|
||||
} else {
|
||||
return Err("Early data requires WebSocket transport".into());
|
||||
}
|
||||
}
|
||||
let flow = get("flow").to_owned();
|
||||
if !matches!(flow.as_str(), "" | "xtls-rprx-vision") {
|
||||
return Err("Unsupported VLESS flow".into());
|
||||
}
|
||||
let security = get("security");
|
||||
if !flow.is_empty() && (!matches!(security, "tls" | "reality") || transport.is_some()) {
|
||||
return Err("Vision flow requires TCP with TLS or REALITY".into());
|
||||
}
|
||||
let sni = alias("sni", "serverName")?;
|
||||
let pk = alias("pbk", "publicKey")?;
|
||||
let sid = alias("sid", "shortId")?;
|
||||
let fingerprint = alias("fp", "fingerprint")?;
|
||||
let tls = match security {
|
||||
"" | "none" => {
|
||||
if !pk.is_empty() {
|
||||
return Err("REALITY key requires security=reality".into());
|
||||
}
|
||||
None
|
||||
}
|
||||
"tls" | "reality" => {
|
||||
let mut t =
|
||||
json!({"enabled":true, "server_name": if sni.is_empty() { &server } else { &sni }});
|
||||
if !fingerprint.is_empty() || security == "reality" {
|
||||
let fp = if fingerprint.is_empty() {
|
||||
"chrome"
|
||||
} else {
|
||||
&fingerprint
|
||||
};
|
||||
if !matches!(
|
||||
fp,
|
||||
"chrome"
|
||||
| "firefox"
|
||||
| "edge"
|
||||
| "safari"
|
||||
| "ios"
|
||||
| "android"
|
||||
| "random"
|
||||
| "randomized"
|
||||
| "360"
|
||||
| "qq"
|
||||
) {
|
||||
return Err("Unsupported TLS fingerprint".into());
|
||||
}
|
||||
t["utls"] = json!({"enabled":true,"fingerprint":fp});
|
||||
}
|
||||
if !get("alpn").is_empty() {
|
||||
t["alpn"] = json!(get("alpn").split(',').collect::<Vec<_>>());
|
||||
}
|
||||
if security == "reality" {
|
||||
if sni.is_empty() {
|
||||
return Err("REALITY server name is missing".into());
|
||||
}
|
||||
if URL_SAFE_NO_PAD.decode(&pk).map(|v| v.len()).ok() != Some(32) {
|
||||
return Err("Invalid or missing REALITY public key".into());
|
||||
}
|
||||
if sid.len() > 16
|
||||
|| sid.len() % 2 != 0
|
||||
|| !sid.bytes().all(|b| b.is_ascii_hexdigit())
|
||||
{
|
||||
return Err("Invalid REALITY short ID".into());
|
||||
}
|
||||
if transport.is_some() {
|
||||
return Err("REALITY requires TCP transport".into());
|
||||
}
|
||||
t["reality"] = json!({"enabled":true,"public_key":pk,"short_id":sid});
|
||||
}
|
||||
Some(t)
|
||||
}
|
||||
_ => return Err("Unsupported VLESS security".into()),
|
||||
};
|
||||
let mut secrets = vec![
|
||||
input.to_owned(),
|
||||
u.username().to_owned(),
|
||||
id.to_string(),
|
||||
pk,
|
||||
sid,
|
||||
];
|
||||
// Also redact transport paths and unknown query values: subscription links may contain tokens.
|
||||
secrets.extend(q.values().filter(|s| s.len() > 3).cloned());
|
||||
secrets.retain(|s| !s.is_empty());
|
||||
secrets.sort_by_key(|s| std::cmp::Reverse(s.len()));
|
||||
secrets.dedup();
|
||||
Ok(Vless {
|
||||
server,
|
||||
port,
|
||||
uuid: id.to_string(),
|
||||
tls,
|
||||
flow,
|
||||
transport,
|
||||
secrets,
|
||||
})
|
||||
}
|
||||
|
||||
impl Vless {
|
||||
pub fn outbound(&self) -> Value {
|
||||
let mut value = json!({"type":"vless", "tag":"proxy", "server":self.server, "server_port":self.port, "uuid":self.uuid, "packet_encoding":"xudp", "connect_timeout":"10s"});
|
||||
if let Some(tls) = &self.tls {
|
||||
value["tls"] = tls.clone();
|
||||
}
|
||||
if let Some(transport) = &self.transport {
|
||||
value["transport"] = transport.clone();
|
||||
}
|
||||
if !self.flow.is_empty() {
|
||||
value["flow"] = json!(self.flow);
|
||||
}
|
||||
value
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
const BASE: &str = "vless://b831381d-6324-4d53-ad4f-8cda48b30811@example.com:443";
|
||||
#[test]
|
||||
fn plain() {
|
||||
let v = parse(BASE).unwrap();
|
||||
assert_eq!(v.server, "example.com");
|
||||
assert_eq!(v.port, 443);
|
||||
assert!(v.tls.is_none());
|
||||
}
|
||||
#[test]
|
||||
fn tls_and_encoding() {
|
||||
let v = parse(&format!(
|
||||
"{BASE}?security=tls&sni=cdn.example.com&alpn=h2%2Chttp%2F1.1"
|
||||
))
|
||||
.unwrap();
|
||||
assert_eq!(v.tls.unwrap()["alpn"], json!(["h2", "http/1.1"]));
|
||||
}
|
||||
#[test]
|
||||
fn reality() {
|
||||
let v = parse(&format!("{BASE}?security=reality&pbk=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA&sid=0123&sni=cdn.example.com&fp=chrome&flow=xtls-rprx-vision")).unwrap();
|
||||
assert_eq!(v.tls.unwrap()["reality"]["short_id"], "0123");
|
||||
}
|
||||
#[test]
|
||||
fn ipv6() {
|
||||
let v = parse("vless://b831381d-6324-4d53-ad4f-8cda48b30811@[2001:db8::1]:443").unwrap();
|
||||
assert_eq!(v.server, "2001:db8::1");
|
||||
}
|
||||
#[test]
|
||||
fn websocket() {
|
||||
let v = parse(&format!(
|
||||
"{BASE}?type=ws&security=tls&path=%2Fsocket%3Ftoken%3Dtest&host=cdn.example.com"
|
||||
))
|
||||
.unwrap();
|
||||
assert_eq!(v.transport.unwrap()["path"], "/socket?token=test");
|
||||
}
|
||||
#[test]
|
||||
fn malformed() {
|
||||
for s in [
|
||||
"",
|
||||
"https://example.com",
|
||||
"vless://host:443",
|
||||
"vless://@host:443",
|
||||
"vless://bad@host:443",
|
||||
"vless://b831381d-6324-4d53-ad4f-8cda48b30811@host:65536",
|
||||
"vless://b831381d-6324-4d53-ad4f-8cda48b30811@host:0",
|
||||
] {
|
||||
assert!(parse(s).is_err());
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn unsupported_and_duplicates() {
|
||||
for q in [
|
||||
"type=grpc",
|
||||
"type=xhttp",
|
||||
"security=unknown",
|
||||
"security=tls&security=none",
|
||||
"flow=xtls-rprx-vision",
|
||||
"headerType=http",
|
||||
"security=reality&pbk=bad",
|
||||
"security=tls&allowInsecure=1",
|
||||
] {
|
||||
assert!(parse(&format!("{BASE}?{q}")).is_err(), "{q}");
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn unknown_is_ignored() {
|
||||
assert!(parse(&format!("{BASE}?futureFeature=hello")).is_ok());
|
||||
}
|
||||
#[test]
|
||||
fn no_secrets_in_errors() {
|
||||
let err = parse(&format!("{BASE}?type=MY_SECRET")).err().unwrap();
|
||||
assert!(!err.contains("MY_SECRET"));
|
||||
assert!(!err.contains("b831381d"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "MiniVLESS",
|
||||
"version": "0.1.0",
|
||||
"identifier": "dev.minivless.desktop",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
"devUrl": "http://127.0.0.1:1420",
|
||||
"beforeBuildCommand": "npm run build",
|
||||
"frontendDist": "../dist"
|
||||
},
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"label": "main",
|
||||
"title": "MiniVLESS",
|
||||
"width": 420,
|
||||
"height": 600,
|
||||
"minWidth": 380,
|
||||
"minHeight": 540,
|
||||
"resizable": true,
|
||||
"decorations": false,
|
||||
"theme": "Dark",
|
||||
"backgroundColor": "#1b1e22"
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src ipc: http://ipc.localhost; object-src 'none'; base-uri 'none'; frame-ancestors 'none'",
|
||||
"capabilities": [
|
||||
{
|
||||
"identifier": "main-window",
|
||||
"windows": [
|
||||
"main"
|
||||
],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"core:window:allow-start-dragging",
|
||||
"core:window:allow-close"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": [
|
||||
"deb"
|
||||
],
|
||||
"category": "Utility",
|
||||
"shortDescription": "Personal VLESS client",
|
||||
"resources": {
|
||||
"../binaries/sing-box": "binaries/sing-box",
|
||||
"../binaries/LICENSE.sing-box": "binaries/LICENSE.sing-box"
|
||||
},
|
||||
"icon": [
|
||||
"icons/icon.png"
|
||||
]
|
||||
}
|
||||
}
|
||||
+570
@@ -0,0 +1,570 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { invoke, isTauri } from "@tauri-apps/api/core";
|
||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||
import {
|
||||
ApplicationInfo,
|
||||
emptyPreferences,
|
||||
Preferences,
|
||||
Status,
|
||||
} from "./types";
|
||||
|
||||
const initialStatus: Status = {
|
||||
phase: "Disconnected",
|
||||
error: null,
|
||||
split_tunneling: true,
|
||||
applications: [],
|
||||
};
|
||||
const message = (e: unknown) =>
|
||||
typeof e === "string" ? e : "Something went wrong. Please try again.";
|
||||
const basename = (p: string) => p.split("/").pop() || p;
|
||||
function Icon({ kind }: { kind: "close" | "eye" | "refresh" | "plus" }) {
|
||||
return (
|
||||
<svg
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.7"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{kind === "close" ? (
|
||||
<path d="m6 6 12 12M18 6 6 18" />
|
||||
) : kind === "plus" ? (
|
||||
<path d="M12 5v14M5 12h14" />
|
||||
) : kind === "refresh" ? (
|
||||
<>
|
||||
<path d="M20 7v5h-5M4 17v-5h5" />
|
||||
<path d="M6 7a7 7 0 0 1 12-1l2 3M4 15l2 3a7 7 0 0 0 12-1" />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7S2 12 2 12Z" />
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
</>
|
||||
)}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const [prefs, setPrefs] = useState<Preferences>(emptyPreferences);
|
||||
const [status, setStatus] = useState<Status>(initialStatus);
|
||||
const [apps, setApps] = useState<ApplicationInfo[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const [shown, setShown] = useState(false);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [logs, setLogs] = useState<string[]>([]);
|
||||
const [logsOpen, setLogsOpen] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const saveQueue = useRef<Promise<unknown>>(Promise.resolve());
|
||||
const currentPrefs = useRef(prefs);
|
||||
const desktop = isTauri();
|
||||
const busy =
|
||||
submitting ||
|
||||
status.phase === "Connecting" ||
|
||||
status.phase === "Disconnecting";
|
||||
const connected = status.phase === "Connected";
|
||||
const locked = busy || connected;
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
if (!isTauri()) return;
|
||||
setRefreshing(true);
|
||||
try {
|
||||
setApps(await invoke<ApplicationInfo[]>("list_applications"));
|
||||
} catch (e) {
|
||||
setError(message(e));
|
||||
} finally {
|
||||
setRefreshing(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!desktop) return;
|
||||
let live = true;
|
||||
void invoke<Preferences>("load_preferences")
|
||||
.then((p) => {
|
||||
if (live) {
|
||||
currentPrefs.current = p;
|
||||
setPrefs(p);
|
||||
setLoaded(true);
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
if (live) setError(message(e));
|
||||
});
|
||||
void refresh();
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}, [desktop, refresh]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!desktop) return;
|
||||
let live = true;
|
||||
let timer: ReturnType<typeof setTimeout>;
|
||||
const poll = async () => {
|
||||
try {
|
||||
const next = await invoke<Status>("get_status");
|
||||
if (live) setStatus(next);
|
||||
if (logsOpen || next.phase === "Error") {
|
||||
const data = await invoke<string[]>("get_logs");
|
||||
if (live) setLogs(data);
|
||||
}
|
||||
} catch {
|
||||
if (live) setError("Cannot communicate with the application backend.");
|
||||
}
|
||||
if (live) timer = setTimeout(poll, 400);
|
||||
};
|
||||
void poll();
|
||||
return () => {
|
||||
live = false;
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [desktop, logsOpen]);
|
||||
|
||||
const update = (next: Preferences) => {
|
||||
currentPrefs.current = next;
|
||||
setPrefs(next);
|
||||
if (!loaded) return;
|
||||
// Serialize writes so an old URL cannot overwrite a newer selection.
|
||||
saveQueue.current = saveQueue.current
|
||||
.catch(() => undefined)
|
||||
.then(() => invoke("save_preferences", { preferences: next }))
|
||||
.catch((e) => {
|
||||
setError(message(e));
|
||||
throw new Error("Settings could not be saved");
|
||||
});
|
||||
// The visible error above reports failures; prevent an unhandled promise rejection.
|
||||
void saveQueue.current.catch(() => undefined);
|
||||
};
|
||||
|
||||
const visible = [...apps];
|
||||
for (const path of new Set([
|
||||
...prefs.applications,
|
||||
...prefs.custom_applications,
|
||||
])) {
|
||||
if (!visible.some((a) => a.executable === path))
|
||||
visible.push({
|
||||
name: basename(path),
|
||||
executable: path,
|
||||
running: false,
|
||||
pid: null,
|
||||
});
|
||||
}
|
||||
visible.sort((a, b) => a.name.localeCompare(b.name));
|
||||
const toggle = (path: string) =>
|
||||
update({
|
||||
...prefs,
|
||||
applications: prefs.applications.includes(path)
|
||||
? prefs.applications.filter((p) => p !== path)
|
||||
: [...prefs.applications, path],
|
||||
});
|
||||
|
||||
const connect = async () => {
|
||||
setError(null);
|
||||
setShown(false);
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await saveQueue.current;
|
||||
await invoke("connect", { request: currentPrefs.current });
|
||||
setStatus(await invoke<Status>("get_status"));
|
||||
} catch (e) {
|
||||
setError(message(e));
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
const disconnect = async () => {
|
||||
setError(null);
|
||||
try {
|
||||
await invoke("disconnect");
|
||||
setStatus(await invoke<Status>("get_status"));
|
||||
} catch (e) {
|
||||
setError(message(e));
|
||||
}
|
||||
};
|
||||
const close = async () => {
|
||||
if (!desktop) return;
|
||||
try {
|
||||
await saveQueue.current;
|
||||
await getCurrentWindow().close();
|
||||
} catch {
|
||||
setError(
|
||||
"Settings could not be saved. Retry after checking disk space and permissions.",
|
||||
);
|
||||
}
|
||||
};
|
||||
const add = (app: ApplicationInfo) => {
|
||||
setApps((old) => [
|
||||
...old.filter((a) => a.executable !== app.executable),
|
||||
app,
|
||||
]);
|
||||
update({
|
||||
...prefs,
|
||||
custom_applications: [
|
||||
...new Set([...prefs.custom_applications, app.executable]),
|
||||
],
|
||||
applications: [...new Set([...prefs.applications, app.executable])],
|
||||
});
|
||||
setAdding(false);
|
||||
};
|
||||
const selectedNames = status.applications.map(
|
||||
(path) =>
|
||||
visible.find((a) => a.executable === path)?.name || basename(path),
|
||||
);
|
||||
const shownError = error || status.error;
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
<header
|
||||
className="titlebar"
|
||||
onMouseDown={(e) => {
|
||||
if (
|
||||
desktop &&
|
||||
e.button === 0 &&
|
||||
!(e.target as HTMLElement).closest("button")
|
||||
)
|
||||
void getCurrentWindow().startDragging();
|
||||
}}
|
||||
>
|
||||
<span>MiniVLESS</span>
|
||||
<button
|
||||
className="icon-button close"
|
||||
aria-label="Close MiniVLESS"
|
||||
onClick={() => void close()}
|
||||
>
|
||||
<Icon kind="close" />
|
||||
</button>
|
||||
</header>
|
||||
<main>
|
||||
{!desktop && (
|
||||
<div className="notice">
|
||||
Interface preview. Open the desktop app to connect.
|
||||
</div>
|
||||
)}
|
||||
{connected ? (
|
||||
<section className="connected-view" aria-live="polite">
|
||||
<div className="connected-heading">
|
||||
<span className="dot green" />
|
||||
<h1>Connected</h1>
|
||||
</div>
|
||||
<p className="connected-apps">
|
||||
{status.split_tunneling
|
||||
? selectedNames.join(" · ")
|
||||
: "Whole system"}
|
||||
</p>
|
||||
<p className="muted">
|
||||
{status.split_tunneling
|
||||
? "Only selected applications use VPN"
|
||||
: "VPN is enabled for all applications"}
|
||||
</p>
|
||||
</section>
|
||||
) : (
|
||||
<div className="form">
|
||||
<div className="status" role="status">
|
||||
<span
|
||||
className={`dot ${busy ? "pulsing" : status.phase === "Error" ? "red" : ""}`}
|
||||
/>
|
||||
{submitting ? "Connecting" : status.phase}
|
||||
</div>
|
||||
<label className="field-label" htmlFor="vless-url">
|
||||
VLESS URL
|
||||
</label>
|
||||
<div className="url-field">
|
||||
<input
|
||||
id="vless-url"
|
||||
type={shown ? "text" : "password"}
|
||||
value={prefs.vless_url}
|
||||
onChange={(e) =>
|
||||
update({ ...prefs, vless_url: e.target.value })
|
||||
}
|
||||
onBlur={() => setShown(false)}
|
||||
placeholder="vless://…"
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
spellCheck={false}
|
||||
maxLength={16384}
|
||||
disabled={locked || (desktop && !loaded)}
|
||||
aria-describedby="url-hint"
|
||||
/>
|
||||
<button
|
||||
className="icon-button"
|
||||
aria-label={shown ? "Hide VLESS URL" : "Show VLESS URL"}
|
||||
aria-pressed={shown}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => setShown(!shown)}
|
||||
disabled={locked}
|
||||
>
|
||||
<Icon kind="eye" />
|
||||
</button>
|
||||
</div>
|
||||
<span id="url-hint" className="sr-only">
|
||||
The link is saved privately on this computer.
|
||||
</span>
|
||||
<div className="mode-row">
|
||||
<div>
|
||||
<label id="tunneling-label" htmlFor="tunneling">
|
||||
Tunneling
|
||||
</label>
|
||||
<p id="mode-hint">
|
||||
{prefs.split_tunneling
|
||||
? "Only selected applications use VPN"
|
||||
: "Whole system uses VPN"}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
id="tunneling"
|
||||
role="switch"
|
||||
aria-checked={prefs.split_tunneling}
|
||||
aria-labelledby="tunneling-label"
|
||||
aria-describedby="mode-hint"
|
||||
className={`switch ${prefs.split_tunneling ? "on" : ""}`}
|
||||
disabled={locked || (desktop && !loaded)}
|
||||
onClick={() =>
|
||||
update({ ...prefs, split_tunneling: !prefs.split_tunneling })
|
||||
}
|
||||
>
|
||||
<span />
|
||||
</button>
|
||||
</div>
|
||||
<section
|
||||
className={`applications ${!prefs.split_tunneling ? "inactive" : ""}`}
|
||||
aria-label="Tunnel applications"
|
||||
>
|
||||
<div className="section-heading">
|
||||
<h2>Applications</h2>
|
||||
<button
|
||||
className="text-button"
|
||||
disabled={
|
||||
locked || refreshing || !prefs.split_tunneling || !desktop
|
||||
}
|
||||
onClick={() => void refresh()}
|
||||
>
|
||||
<span className={refreshing ? "spinning" : ""}>
|
||||
<Icon kind="refresh" />
|
||||
</span>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
<div className="app-list">
|
||||
{visible.length ? (
|
||||
visible.map((app) => (
|
||||
<label className="app-row" key={app.executable}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={prefs.applications.includes(app.executable)}
|
||||
disabled={
|
||||
locked ||
|
||||
!prefs.split_tunneling ||
|
||||
(desktop && !loaded)
|
||||
}
|
||||
onChange={() => toggle(app.executable)}
|
||||
/>
|
||||
<span className="app-description">
|
||||
<span className="app-name">
|
||||
{app.name}
|
||||
{!app.running && (
|
||||
<span className="not-running">Saved</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="app-path" title={app.executable}>
|
||||
{app.executable}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
))
|
||||
) : (
|
||||
<div className="empty-list">
|
||||
{refreshing
|
||||
? "Looking for applications…"
|
||||
: "No running applications found."}
|
||||
<small>Add an application below.</small>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
className="secondary add-button"
|
||||
disabled={
|
||||
locked || !prefs.split_tunneling || !desktop || !loaded
|
||||
}
|
||||
onClick={() => setAdding(true)}
|
||||
>
|
||||
<Icon kind="plus" />
|
||||
Add application
|
||||
</button>
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
{shownError && (
|
||||
<div className="error" role="alert">
|
||||
{shownError}
|
||||
</div>
|
||||
)}
|
||||
{(logs.length > 0 || status.phase === "Error" || connected) && (
|
||||
<details
|
||||
className="logs"
|
||||
open={logsOpen}
|
||||
onToggle={(e) => setLogsOpen(e.currentTarget.open)}
|
||||
>
|
||||
<summary>Connection logs</summary>
|
||||
<pre>{logs.length ? logs.join("\n") : "No logs yet."}</pre>
|
||||
</details>
|
||||
)}
|
||||
<footer>
|
||||
{connected ||
|
||||
status.phase === "Connecting" ||
|
||||
status.phase === "Disconnecting" ? (
|
||||
<button
|
||||
className="secondary main-button"
|
||||
disabled={status.phase === "Disconnecting"}
|
||||
onClick={() => void disconnect()}
|
||||
>
|
||||
{status.phase === "Connecting"
|
||||
? "Cancel"
|
||||
: status.phase === "Disconnecting"
|
||||
? "Disconnecting…"
|
||||
: "Disconnect"}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
className="primary main-button"
|
||||
disabled={
|
||||
!desktop ||
|
||||
!loaded ||
|
||||
submitting ||
|
||||
!prefs.vless_url.trim() ||
|
||||
(prefs.split_tunneling && !prefs.applications.length)
|
||||
}
|
||||
onClick={() => void connect()}
|
||||
>
|
||||
{submitting ? "Connecting…" : "Connect"}
|
||||
</button>
|
||||
)}
|
||||
</footer>
|
||||
</main>
|
||||
{adding && (
|
||||
<AddApplication onClose={() => setAdding(false)} onAdd={add} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AddApplication({
|
||||
onClose,
|
||||
onAdd,
|
||||
}: {
|
||||
onClose: () => void;
|
||||
onAdd: (app: ApplicationInfo) => void;
|
||||
}) {
|
||||
const [installed, setInstalled] = useState<ApplicationInfo[]>([]);
|
||||
const [query, setQuery] = useState("");
|
||||
const [path, setPath] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [working, setWorking] = useState(false);
|
||||
const dialog = useRef<HTMLDialogElement>(null);
|
||||
useEffect(() => {
|
||||
dialog.current?.showModal();
|
||||
dialog.current?.querySelector("input")?.focus();
|
||||
let live = true;
|
||||
void invoke<ApplicationInfo[]>("list_installed_applications")
|
||||
.then((a) => {
|
||||
if (live) setInstalled(a);
|
||||
})
|
||||
.catch((e) => {
|
||||
if (live) setError(message(e));
|
||||
});
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}, []);
|
||||
const add = async (candidate: string) => {
|
||||
setWorking(true);
|
||||
setError(null);
|
||||
try {
|
||||
onAdd(
|
||||
await invoke<ApplicationInfo>("add_application", { path: candidate }),
|
||||
);
|
||||
} catch (e) {
|
||||
setError(message(e));
|
||||
setWorking(false);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<dialog ref={dialog} onCancel={onClose} aria-labelledby="add-title">
|
||||
<div className="dialog-heading">
|
||||
<h2 id="add-title">Add application</h2>
|
||||
<button
|
||||
className="icon-button"
|
||||
onClick={onClose}
|
||||
aria-label="Close dialog"
|
||||
>
|
||||
<Icon kind="close" />
|
||||
</button>
|
||||
</div>
|
||||
<label className="sr-only" htmlFor="app-search">
|
||||
Search installed applications
|
||||
</label>
|
||||
<input
|
||||
id="app-search"
|
||||
className="dialog-input"
|
||||
autoFocus
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Search installed applications…"
|
||||
/>
|
||||
<div className="installed-list">
|
||||
{installed
|
||||
.filter((a) => a.name.toLowerCase().includes(query.toLowerCase()))
|
||||
.map((a) => (
|
||||
<button
|
||||
key={a.executable}
|
||||
disabled={working}
|
||||
onClick={() => void add(a.executable)}
|
||||
>
|
||||
<span>{a.name}</span>
|
||||
<small>{a.executable}</small>
|
||||
<Icon kind="plus" />
|
||||
</button>
|
||||
))}
|
||||
{!installed.length && (
|
||||
<p className="muted">You can also enter an executable path below.</p>
|
||||
)}
|
||||
</div>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
void add(path);
|
||||
}}
|
||||
>
|
||||
<label htmlFor="app-path" className="field-label">
|
||||
Or enter executable path
|
||||
</label>
|
||||
<input
|
||||
id="app-path"
|
||||
className="dialog-input"
|
||||
value={path}
|
||||
onChange={(e) => setPath(e.target.value)}
|
||||
placeholder="/opt/application/bin/application"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
/>
|
||||
{error && (
|
||||
<p className="error" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
<button
|
||||
className="primary main-button"
|
||||
disabled={working || !path.startsWith("/")}
|
||||
>
|
||||
{working ? "Adding…" : "Add application"}
|
||||
</button>
|
||||
</form>
|
||||
</dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import React from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import App from "./App";
|
||||
import "./styles.css";
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
);
|
||||
+530
@@ -0,0 +1,530 @@
|
||||
:root {
|
||||
font-family: Inter, "Noto Sans", system-ui, sans-serif;
|
||||
color: #eef0f4;
|
||||
background: #1b1e22;
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
color-scheme: dark;
|
||||
font-size: 14px;
|
||||
}
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
min-width: 360px;
|
||||
}
|
||||
button,
|
||||
input {
|
||||
font: inherit;
|
||||
}
|
||||
button {
|
||||
cursor: pointer;
|
||||
}
|
||||
button:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.42;
|
||||
}
|
||||
button:focus-visible,
|
||||
input:focus-visible,
|
||||
summary:focus-visible {
|
||||
outline: 2px solid #69aaff;
|
||||
outline-offset: 3px;
|
||||
}
|
||||
button {
|
||||
transition:
|
||||
background 0.12s,
|
||||
border-color 0.12s;
|
||||
}
|
||||
button svg {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.app {
|
||||
height: 100dvh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.titlebar {
|
||||
height: 42px;
|
||||
min-height: 42px;
|
||||
padding-left: 18px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background: #23262b;
|
||||
border-bottom: 1px solid #33373d;
|
||||
user-select: none;
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.icon-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: #b4bbc6;
|
||||
border-radius: 5px;
|
||||
}
|
||||
.icon-button:hover {
|
||||
background: #30353d;
|
||||
color: #fff;
|
||||
}
|
||||
.close {
|
||||
margin-right: 7px;
|
||||
}
|
||||
.close:hover {
|
||||
background: #803d48;
|
||||
}
|
||||
main {
|
||||
padding: 20px 22px 18px;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: auto;
|
||||
}
|
||||
.form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
.status {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
margin-bottom: 18px;
|
||||
font-size: 14px;
|
||||
}
|
||||
.dot {
|
||||
display: inline-block;
|
||||
background: #aab1bc;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.dot.green {
|
||||
background: #22c779;
|
||||
}
|
||||
.dot.red {
|
||||
background: #f07a83;
|
||||
}
|
||||
.field-label {
|
||||
display: block;
|
||||
color: #b5bdc9;
|
||||
font-size: 12px;
|
||||
margin-bottom: 7px;
|
||||
}
|
||||
.url-field {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border: 1px solid #41464f;
|
||||
border-radius: 6px;
|
||||
height: 38px;
|
||||
min-height: 38px;
|
||||
padding-right: 3px;
|
||||
}
|
||||
.url-field:focus-within {
|
||||
border-color: #559bfd;
|
||||
}
|
||||
.url-field input {
|
||||
padding: 0 11px;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
background: none;
|
||||
border: none;
|
||||
color: #eef0f4;
|
||||
outline: none;
|
||||
font-size: 14px;
|
||||
}
|
||||
.url-field input::placeholder {
|
||||
color: #717987;
|
||||
}
|
||||
.mode-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-top: 16px;
|
||||
padding: 13px 0;
|
||||
border-top: 1px solid #34383e;
|
||||
border-bottom: 1px solid #34383e;
|
||||
gap: 10px;
|
||||
}
|
||||
.mode-row label {
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
}
|
||||
.mode-row p {
|
||||
color: #aeb6c2;
|
||||
font-size: 11px;
|
||||
line-height: 1.4;
|
||||
margin: 3px 0 0;
|
||||
}
|
||||
.switch {
|
||||
width: 38px;
|
||||
height: 22px;
|
||||
min-width: 38px;
|
||||
border: 1px solid #646b76;
|
||||
border-radius: 20px;
|
||||
padding: 2px;
|
||||
background: #424852;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.switch span {
|
||||
background: white;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
display: block;
|
||||
transition: transform 0.15s;
|
||||
}
|
||||
.switch.on {
|
||||
background: #2285f7;
|
||||
border-color: #2285f7;
|
||||
}
|
||||
.switch.on span {
|
||||
transform: translateX(16px);
|
||||
}
|
||||
.applications {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 125px;
|
||||
}
|
||||
.section-heading {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin: 12px 0 8px;
|
||||
}
|
||||
h2 {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
margin: 0;
|
||||
}
|
||||
.text-button {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
border: 0;
|
||||
background: none;
|
||||
padding: 0;
|
||||
color: #b5bdc9;
|
||||
font-size: 12px;
|
||||
}
|
||||
.text-button > span {
|
||||
display: flex;
|
||||
}
|
||||
.text-button:hover:not(:disabled) {
|
||||
color: white;
|
||||
}
|
||||
.app-list {
|
||||
border: 1px solid #343940;
|
||||
border-radius: 6px;
|
||||
overflow-y: auto;
|
||||
min-height: 76px;
|
||||
max-height: 252px;
|
||||
flex: 1;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #49515e transparent;
|
||||
}
|
||||
.app-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
min-height: 51px;
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.app-row + .app-row {
|
||||
border-top: 1px solid #343940;
|
||||
}
|
||||
.app-row:hover {
|
||||
background: #24282e;
|
||||
}
|
||||
.app-row input {
|
||||
width: 17px;
|
||||
height: 17px;
|
||||
flex-shrink: 0;
|
||||
margin: 0;
|
||||
accent-color: #268aff;
|
||||
cursor: inherit;
|
||||
}
|
||||
.app-description {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
flex: 1;
|
||||
}
|
||||
.app-name {
|
||||
font-size: 13px;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
.app-path {
|
||||
font-size: 10px;
|
||||
color: #a7b0bd;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
}
|
||||
.not-running {
|
||||
font-size: 9px;
|
||||
color: #8a929e;
|
||||
font-weight: 400;
|
||||
}
|
||||
.secondary {
|
||||
background: #24282d;
|
||||
border: 1px solid #555d68;
|
||||
color: #eef0f4;
|
||||
border-radius: 6px;
|
||||
}
|
||||
.secondary:hover:not(:disabled) {
|
||||
background: #30363e;
|
||||
border-color: #737d8a;
|
||||
}
|
||||
.add-button {
|
||||
min-height: 33px;
|
||||
margin-top: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.inactive .app-list {
|
||||
opacity: 0.45;
|
||||
}
|
||||
.inactive .app-row {
|
||||
cursor: default;
|
||||
}
|
||||
.empty-list {
|
||||
padding: 25px 12px;
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
color: #a8b2bf;
|
||||
}
|
||||
.empty-list small {
|
||||
display: block;
|
||||
margin-top: 5px;
|
||||
color: #858e9c;
|
||||
}
|
||||
.primary {
|
||||
background: #2585f5;
|
||||
border: 1px solid #2585f5;
|
||||
color: white;
|
||||
border-radius: 6px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.primary:hover:not(:disabled) {
|
||||
background: #3896ff;
|
||||
}
|
||||
.main-button {
|
||||
width: 100%;
|
||||
height: 40px;
|
||||
min-height: 40px;
|
||||
font-size: 14px;
|
||||
}
|
||||
footer {
|
||||
margin-top: 14px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.connected-view {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
padding: 15px 0;
|
||||
min-height: 170px;
|
||||
}
|
||||
.connected-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
.connected-heading .dot {
|
||||
height: 15px;
|
||||
width: 15px;
|
||||
}
|
||||
h1 {
|
||||
font-size: 25px;
|
||||
letter-spacing: -0.5px;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
}
|
||||
.connected-apps {
|
||||
color: #b9c1cd;
|
||||
line-height: 1.6;
|
||||
font-size: 16px;
|
||||
margin: 10px 0 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.muted {
|
||||
color: #99a3b1;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.connected-view .muted {
|
||||
margin-top: 12px;
|
||||
}
|
||||
.error {
|
||||
padding: 10px 11px;
|
||||
border: 1px solid #75434b;
|
||||
background: #36252b;
|
||||
color: #f6afb7;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
border-radius: 6px;
|
||||
margin-top: 12px;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
flex-shrink: 0;
|
||||
max-height: 170px;
|
||||
overflow: auto;
|
||||
}
|
||||
.logs {
|
||||
font-size: 11px;
|
||||
color: #9ba6b5;
|
||||
margin-top: 10px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.logs summary {
|
||||
cursor: pointer;
|
||||
width: fit-content;
|
||||
}
|
||||
.logs pre {
|
||||
font:
|
||||
10px/1.6 ui-monospace,
|
||||
monospace;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
max-height: 130px;
|
||||
overflow: auto;
|
||||
padding: 9px;
|
||||
background: #15171b;
|
||||
border: 1px solid #343940;
|
||||
border-radius: 4px;
|
||||
margin: 7px 0 0;
|
||||
color: #b1bbc8;
|
||||
}
|
||||
.notice {
|
||||
border: 1px solid #495566;
|
||||
padding: 9px;
|
||||
font-size: 11px;
|
||||
color: #b8c8dc;
|
||||
border-radius: 5px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
dialog {
|
||||
background: #1d2126;
|
||||
color: #eef0f4;
|
||||
border: 1px solid #4a515c;
|
||||
border-radius: 9px;
|
||||
width: calc(100% - 34px);
|
||||
max-width: 390px;
|
||||
max-height: 90dvh;
|
||||
padding: 17px;
|
||||
box-shadow: 0 18px 60px #0008;
|
||||
}
|
||||
dialog::backdrop {
|
||||
background: #070a10b3;
|
||||
}
|
||||
.dialog-heading {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 13px;
|
||||
}
|
||||
.dialog-heading h2 {
|
||||
font-size: 16px;
|
||||
}
|
||||
.dialog-input {
|
||||
width: 100%;
|
||||
height: 36px;
|
||||
background: #191c20;
|
||||
border: 1px solid #454d58;
|
||||
border-radius: 5px;
|
||||
color: #eef0f4;
|
||||
padding: 0 9px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.installed-list {
|
||||
max-height: 210px;
|
||||
overflow: auto;
|
||||
margin: 10px 0 18px;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
.installed-list button {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
padding: 10px 28px 10px 5px;
|
||||
text-align: left;
|
||||
background: none;
|
||||
color: #eef0f4;
|
||||
border: 0;
|
||||
border-bottom: 1px solid #343940;
|
||||
font-size: 12px;
|
||||
}
|
||||
.installed-list button:hover {
|
||||
background: #292f37;
|
||||
}
|
||||
.installed-list button small {
|
||||
color: #9ba6b5;
|
||||
font-size: 10px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.installed-list button svg {
|
||||
position: absolute;
|
||||
right: 2px;
|
||||
top: 15px;
|
||||
}
|
||||
dialog form .main-button {
|
||||
margin-top: 14px;
|
||||
}
|
||||
.pulsing {
|
||||
animation: pulse 1.2s ease-in-out infinite;
|
||||
background: #529ffc;
|
||||
}
|
||||
.spinning {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
@keyframes pulse {
|
||||
50% {
|
||||
opacity: 0.35;
|
||||
}
|
||||
}
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
* {
|
||||
animation: none !important;
|
||||
transition: none !important;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
export interface ApplicationInfo {
|
||||
name: string;
|
||||
executable: string;
|
||||
running: boolean;
|
||||
pid: number | null;
|
||||
}
|
||||
export interface Preferences {
|
||||
vless_url: string;
|
||||
applications: string[];
|
||||
custom_applications: string[];
|
||||
split_tunneling: boolean;
|
||||
}
|
||||
export type Phase =
|
||||
"Disconnected" | "Connecting" | "Connected" | "Disconnecting" | "Error";
|
||||
export interface Status {
|
||||
phase: Phase;
|
||||
error: string | null;
|
||||
split_tunneling: boolean;
|
||||
applications: string[];
|
||||
}
|
||||
export const emptyPreferences: Preferences = {
|
||||
vless_url: "",
|
||||
applications: [],
|
||||
custom_applications: [],
|
||||
split_tunneling: true,
|
||||
};
|
||||
@@ -0,0 +1,189 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Real TCP/UDP/VLESS/TUN and lifecycle checks. Run only inside an isolated user+network namespace.
|
||||
|
||||
cargo build --manifest-path src-tauri/Cargo.toml --no-default-features --example test_driver
|
||||
unshare --user --map-root-user --net python3 tests/netns_integration.py
|
||||
|
||||
No Internet, real credentials, host routes, sudo, or persistent capabilities required.
|
||||
"""
|
||||
import http.server
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import select
|
||||
import shutil
|
||||
import signal
|
||||
import socket
|
||||
import ssl
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
CORE = ROOT / "binaries/sing-box"
|
||||
DRIVER = ROOT / "src-tauri/target/debug/examples/test_driver"
|
||||
SERVER_IP = "192.0.2.2"
|
||||
|
||||
def run(*args):
|
||||
return subprocess.check_output(args, text=True, stderr=subprocess.STDOUT).strip()
|
||||
|
||||
def link_names():
|
||||
return {link["ifname"] for link in json.loads(run("ip", "-j", "link", "show"))}
|
||||
|
||||
def wait_for(predicate, seconds=8):
|
||||
until = time.monotonic() + seconds
|
||||
while time.monotonic() < until:
|
||||
if predicate():
|
||||
return
|
||||
time.sleep(.05)
|
||||
raise AssertionError("Timed out waiting for cleanup/readiness")
|
||||
|
||||
def serve(directory):
|
||||
directory = Path(directory)
|
||||
run("ip", "link", "set", "lo", "up")
|
||||
wait_for(lambda: "mv-server" in link_names())
|
||||
run("ip", "addr", "add", "192.0.2.2/24", "dev", "mv-server")
|
||||
run("ip", "-6", "addr", "add", "fd00:1::2/64", "dev", "mv-server", "nodad")
|
||||
run("ip", "link", "set", "mv-server", "up")
|
||||
run("ip", "link", "add", "remote", "type", "dummy")
|
||||
run("ip", "addr", "add", "203.0.113.2/32", "dev", "remote")
|
||||
run("ip", "addr", "add", "1.1.1.1/32", "dev", "remote")
|
||||
run("ip", "-6", "addr", "add", "2001:db8:2::2/128", "dev", "remote", "nodad")
|
||||
run("ip", "link", "set", "remote", "up")
|
||||
class Handler(http.server.BaseHTTPRequestHandler):
|
||||
def do_GET(self):
|
||||
data = self.client_address[0].encode()
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Length", str(len(data)))
|
||||
self.end_headers()
|
||||
self.wfile.write(data)
|
||||
def log_message(self, *args):
|
||||
pass
|
||||
class IPv6Server(http.server.ThreadingHTTPServer):
|
||||
address_family = socket.AF_INET6
|
||||
for server in [http.server.ThreadingHTTPServer(("0.0.0.0", 8080), Handler), IPv6Server(("::", 8082), Handler)]:
|
||||
threading.Thread(target=server.serve_forever, daemon=True).start()
|
||||
class DoH(http.server.BaseHTTPRequestHandler):
|
||||
def do_POST(self):
|
||||
query = self.rfile.read(int(self.headers['Content-Length']))
|
||||
proxy = self.client_address[0] != '192.0.2.1'
|
||||
# Different answers prove which outbound the DNS exchange used.
|
||||
answer = query[:2] + b'\x81\x80\x00\x01\x00\x01\x00\x00\x00\x00' + query[12:] + b'\xc0\x0c\x00\x01\x00\x01\x00\x00\x00\x00\x00\x04' + bytes([203,0,113,10 if proxy else 20])
|
||||
self.send_response(200)
|
||||
self.send_header('Content-Type','application/dns-message')
|
||||
self.send_header('Content-Length',str(len(answer)))
|
||||
self.end_headers()
|
||||
self.wfile.write(answer)
|
||||
def log_message(self, *args): pass
|
||||
doh = http.server.ThreadingHTTPServer(('1.1.1.1',443),DoH)
|
||||
tls = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
|
||||
tls.load_cert_chain(directory/'test-ca.pem',directory/'test-key.pem')
|
||||
doh.socket = tls.wrap_socket(doh.socket,server_side=True)
|
||||
threading.Thread(target=doh.serve_forever,daemon=True).start()
|
||||
def udp(family, address):
|
||||
s = socket.socket(family, socket.SOCK_DGRAM)
|
||||
s.bind(address)
|
||||
while True:
|
||||
_, peer = s.recvfrom(1024)
|
||||
s.sendto(peer[0].encode(), peer)
|
||||
threading.Thread(target=udp, args=(socket.AF_INET,("0.0.0.0",8081)), daemon=True).start()
|
||||
threading.Thread(target=udp, args=(socket.AF_INET6,("::",8083)), daemon=True).start()
|
||||
cfg = {"log":{"level":"info"},"inbounds":[{"type":"vless","listen":"::","listen_port":2443,"users":[{"uuid":"b831381d-6324-4d53-ad4f-8cda48b30811"}]}],"outbounds":[{"type":"direct","tag":"direct"}],"route":{"final":"direct"}}
|
||||
config = directory / "server.json"
|
||||
config.write_text(json.dumps(cfg))
|
||||
with (directory / "server.log").open("w") as log:
|
||||
core = subprocess.Popen([str(CORE),"run","--disable-color","-c",str(config)],stdout=log,stderr=log)
|
||||
try:
|
||||
wait_for(lambda: "sing-box started" in (directory/"server.log").read_text())
|
||||
(directory/"server-ready").touch()
|
||||
while True:
|
||||
time.sleep(1)
|
||||
finally:
|
||||
core.terminate()
|
||||
core.wait(timeout=5)
|
||||
|
||||
def main():
|
||||
if os.geteuid() != 0 or len(Path("/proc/self/uid_map").read_text().split()) != 3 or Path("/proc/self/uid_map").read_text().split()[2] != "1":
|
||||
raise SystemExit("Run in an isolated unshare --user --map-root-user --net namespace, not as host root")
|
||||
if link_names() != {"lo"}:
|
||||
raise SystemExit("Refusing to change a nonempty network namespace")
|
||||
with tempfile.TemporaryDirectory(prefix="minivless-test-") as tmp:
|
||||
d = Path(tmp)
|
||||
run('openssl','req','-x509','-newkey','rsa:2048','-nodes','-days','1','-subj','/CN=cloudflare-dns.com','-addext','subjectAltName=DNS:cloudflare-dns.com','-keyout',str(d/'test-key.pem'),'-out',str(d/'test-ca.pem'))
|
||||
server = subprocess.Popen(["unshare","--net",sys.executable,__file__,"--server",tmp],start_new_session=True)
|
||||
controllers = []
|
||||
try:
|
||||
run("ip","link","set","lo","up")
|
||||
run("ip","link","add","mv-client","type","veth","peer","name","mv-server")
|
||||
wait_for(lambda: os.readlink(f"/proc/{server.pid}/ns/net") != os.readlink("/proc/self/ns/net"))
|
||||
run("ip","link","set","mv-server","netns",str(server.pid))
|
||||
run("ip","addr","add","192.0.2.1/24","dev","mv-client")
|
||||
run("ip","-6","addr","add","fd00:1::1/64","dev","mv-client","nodad")
|
||||
run("ip","link","set","mv-client","up")
|
||||
run("ip","route","add","default","via",SERVER_IP)
|
||||
run("ip","-6","route","add","default","via","fd00:1::2")
|
||||
wait_for(lambda:(d/"server-ready").exists())
|
||||
clients = []
|
||||
for name in ["selected-client","direct-client"]:
|
||||
p = d/name
|
||||
shutil.copyfile(DRIVER,p)
|
||||
p.chmod(0o755)
|
||||
clients.append(p)
|
||||
baseline4 = run("ip","rule","show")
|
||||
baseline6 = run("ip","-6","rule","show")
|
||||
baseline_nft = run("nft","list","tables")
|
||||
def start(mode):
|
||||
prefs = d/f"config-{mode}"
|
||||
prefs.mkdir(exist_ok=True,mode=0o700)
|
||||
log = (d/f"controller-{mode}.log").open("w+")
|
||||
p = subprocess.Popen([str(DRIVER),"--connect",str(CORE),str(prefs),str(clients[0]),mode],stdin=subprocess.PIPE,stdout=subprocess.PIPE,stderr=log,text=True,env={**os.environ,'SSL_CERT_FILE':str(d/'test-ca.pem')})
|
||||
controllers.append(p)
|
||||
if not select.select([p.stdout],[],[],22)[0] or p.stdout.readline().strip()!="READY":
|
||||
log.seek(0)
|
||||
raise AssertionError("Tunnel did not start:\n"+log.read())
|
||||
return p,prefs
|
||||
def clean():
|
||||
return "minivless0" not in link_names() and run("ip","rule","show")==baseline4 and run("ip","-6","rule","show")==baseline6 and run("nft","list","tables")==baseline_nft
|
||||
def check(mode):
|
||||
for index,client in enumerate(clients):
|
||||
proxy = mode=="full" or index==0
|
||||
for flag,addr,expected_proxy,expected_direct in [
|
||||
("--fetch","203.0.113.2:8080","203.0.113.2","192.0.2.1"),
|
||||
("--udp","203.0.113.2:8081","203.0.113.2","192.0.2.1"),
|
||||
("--fetch","[2001:db8:2::2]:8082","2001:db8:2::2","fd00:1::1"),
|
||||
("--udp","[2001:db8:2::2]:8083","2001:db8:2::2","fd00:1::1"),
|
||||
]:
|
||||
peer=run(str(client),flag,addr)
|
||||
expected=expected_proxy if proxy else expected_direct
|
||||
assert peer==expected,(mode,client.name,flag,addr,peer,expected)
|
||||
print(f"PASS {mode} {client.name} {flag} {addr}: {'VLESS' if proxy else 'DIRECT'}",flush=True)
|
||||
answer=run(str(client),'--dns','192.0.2.2:53')
|
||||
assert answer == ('203.0.113.10' if proxy else '203.0.113.20'), (mode, client.name, 'DNS', answer)
|
||||
print(f"PASS {mode} {client.name} DNS over HTTPS: {'VLESS' if proxy else 'DIRECT'}",flush=True)
|
||||
p,prefs=start("split")
|
||||
check("split")
|
||||
p.stdin.close();p.wait(timeout=10)
|
||||
assert p.returncode==0
|
||||
wait_for(clean)
|
||||
assert not list(prefs.glob("runtime-*"))
|
||||
print("PASS graceful shutdown restores IPv4/IPv6 routes and nftables",flush=True)
|
||||
p,prefs=start("full")
|
||||
check("full")
|
||||
p.kill();p.wait(timeout=5)
|
||||
wait_for(clean)
|
||||
wait_for(lambda:not list(prefs.glob("runtime-*")))
|
||||
print("PASS GUI SIGKILL: guardian stops core and cleans routes/config",flush=True)
|
||||
finally:
|
||||
for p in controllers:
|
||||
if p.poll() is None:
|
||||
p.stdin.close()
|
||||
try:p.wait(timeout=8)
|
||||
except subprocess.TimeoutExpired:p.kill();p.wait()
|
||||
os.killpg(server.pid,signal.SIGTERM)
|
||||
server.wait(timeout=5)
|
||||
|
||||
if __name__=="__main__":
|
||||
if len(sys.argv)>1 and sys.argv[1]=="--server":serve(sys.argv[2])
|
||||
else:main()
|
||||
@@ -0,0 +1,21 @@
|
||||
<!doctype html><html lang="en"><head><meta charset="utf-8"/><meta name="viewport" content="width=device-width, initial-scale=1"/><title>MiniVLESS — UI test fixture</title></head><body><div id="root"></div><script type="module">
|
||||
// Development-only IPC fixture. Not referenced by index.html or included in production.
|
||||
import { mockIPC, mockWindows } from '@tauri-apps/api/mocks';
|
||||
let prefs = {vless_url:'vless://test-fixture',applications:['/usr/bin/telegram-desktop','/opt/discord/Discord'],custom_applications:[],split_tunneling:true};
|
||||
let status = {phase:'Disconnected',error:null,split_tunneling:true,applications:[]};
|
||||
const apps=[{name:'Telegram',executable:'/usr/bin/telegram-desktop',running:true,pid:1},{name:'Discord',executable:'/opt/discord/Discord',running:true,pid:2},{name:'Firefox',executable:'/usr/lib/firefox/firefox',running:true,pid:3},{name:'Steam',executable:'/usr/bin/steam',running:true,pid:4}];
|
||||
mockIPC((cmd,args)=>{
|
||||
if(cmd==='load_preferences') return prefs;
|
||||
if(cmd==='save_preferences'){prefs=args.preferences;return;}
|
||||
if(cmd==='list_applications') return apps;
|
||||
if(cmd==='list_installed_applications') return [...apps,{name:'Chromium',executable:'/usr/lib/chromium/chromium',running:false,pid:null}];
|
||||
if(cmd==='add_application') return {name:args.path.split('/').pop(),executable:args.path,running:false,pid:null};
|
||||
if(cmd==='get_status') return status;
|
||||
if(cmd==='get_logs') return ['Tunnel started.','Credentials are redacted.'];
|
||||
if(cmd==='connect'){status={...status,phase:'Connecting',split_tunneling:args.request.split_tunneling,applications:args.request.applications};setTimeout(()=>{status={...status,phase:args.request.vless_url==='invalid'?'Error':'Connected',error:args.request.vless_url==='invalid'?'Invalid VLESS URL':null};},700);return;}
|
||||
if(cmd==='disconnect'){status={...status,phase:'Disconnected',error:null};return;}
|
||||
});
|
||||
mockWindows('main');
|
||||
window.isTauri = true;
|
||||
await import('/src/main.tsx');
|
||||
</script></body></html>
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "Bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true
|
||||
},
|
||||
"include": ["src", "vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
clearScreen: false,
|
||||
server: { port: 1420, strictPort: true, host: "127.0.0.1" },
|
||||
build: { target: "es2022" },
|
||||
});
|
||||
Reference in New Issue
Block a user