Release MiniVLESS 0.1.0 for Linux

This commit is contained in:
Emil
2026-09-10 18:57:05 +03:00
commit bbb1e533e8
31 changed files with 10213 additions and 0 deletions
+14
View File
@@ -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-*/
+21
View File
@@ -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.
+167
View File
@@ -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](design/approved-dark.png)
## Установка готового релиза
Скачайте `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 соблюдайте его лицензию и предоставляйте соответствующий исходный код.
+17
View File
@@ -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

+2
View File
@@ -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>
+2087
View File
File diff suppressed because it is too large Load Diff
+27
View File
@@ -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"
}
}
+49
View File
@@ -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()
+4580
View File
File diff suppressed because it is too large Load Diff
+30
View File
@@ -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
+4
View File
@@ -0,0 +1,4 @@
fn main() {
#[cfg(feature = "desktop")]
tauri_build::build();
}
+121
View File
@@ -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

+84
View File
@@ -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");
}
}
+104
View File
@@ -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);
}
}
}
+7
View File
@@ -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;
+154
View File
@@ -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);
}
}
+291
View File
@@ -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());
}
}
+174
View File
@@ -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());
}
}
+566
View File
@@ -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]");
}
}
+284
View File
@@ -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"));
}
}
+59
View File
@@ -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
View File
@@ -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>
);
}
+9
View File
@@ -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
View File
@@ -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;
}
}
+26
View File
@@ -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,
};
+189
View File
@@ -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()
+21
View File
@@ -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>
+18
View File
@@ -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"]
}
+8
View File
@@ -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" },
});