Release MiniVLESS 0.1.0 for Linux

This commit is contained in:
Emil
2026-09-10 18:57:05 +03:00
commit ff7c4e02a7
31 changed files with 10213 additions and 0 deletions
+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"
]
}
}