Release MiniVLESS 0.1.0 for Linux
This commit is contained in:
Generated
+4580
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,30 @@
|
||||
[package]
|
||||
name = "minivless"
|
||||
version = "0.1.0"
|
||||
description = "A small personal VLESS client for Linux"
|
||||
edition = "2021"
|
||||
license = "MIT"
|
||||
rust-version = "1.85"
|
||||
|
||||
[features]
|
||||
default = ["desktop"]
|
||||
desktop = ["dep:tauri", "dep:tauri-build"]
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", optional = true , features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2", optional = true , features = [] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
url = "2"
|
||||
uuid = "1"
|
||||
base64 = "0.22"
|
||||
libc = "0.2"
|
||||
tempfile = "3"
|
||||
signal-hook = "0.3"
|
||||
|
||||
[profile.release]
|
||||
strip = true
|
||||
lto = "thin"
|
||||
codegen-units = 1
|
||||
@@ -0,0 +1,4 @@
|
||||
fn main() {
|
||||
#[cfg(feature = "desktop")]
|
||||
tauri_build::build();
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
//! Local test server credentials only. Never accepts a real subscription on argv.
|
||||
use minivless::{
|
||||
config, guardian,
|
||||
storage::Preferences,
|
||||
tunnel::{AppState, Phase},
|
||||
vless,
|
||||
};
|
||||
use std::{
|
||||
io::{Read, Write},
|
||||
net::{SocketAddr, TcpStream, UdpSocket},
|
||||
path::{Path, PathBuf},
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
fn main() {
|
||||
let a: Vec<String> = std::env::args().collect();
|
||||
if a.get(1).map(String::as_str) == Some("--core-guardian") {
|
||||
std::process::exit(guardian::run(Path::new(&a[2]), Path::new(&a[3])));
|
||||
}
|
||||
if a.get(1).map(String::as_str) == Some("--fetch") {
|
||||
let mut s = TcpStream::connect_timeout(
|
||||
&a[2].parse::<SocketAddr>().unwrap(),
|
||||
Duration::from_secs(4),
|
||||
)
|
||||
.unwrap();
|
||||
s.set_read_timeout(Some(Duration::from_secs(4))).unwrap();
|
||||
s.write_all(b"GET / HTTP/1.0\r\nHost: test\r\n\r\n")
|
||||
.unwrap();
|
||||
let mut text = String::new();
|
||||
s.read_to_string(&mut text).unwrap();
|
||||
println!("{}", text.split("\r\n\r\n").nth(1).unwrap());
|
||||
return;
|
||||
}
|
||||
if a.get(1).map(String::as_str) == Some("--dns") {
|
||||
let socket = UdpSocket::bind("0.0.0.0:0").unwrap();
|
||||
socket
|
||||
.set_read_timeout(Some(Duration::from_secs(5)))
|
||||
.unwrap();
|
||||
let query = b"\x12\x34\x01\x00\x00\x01\x00\x00\x00\x00\x00\x00\x04test\x07example\x00\x00\x01\x00\x01";
|
||||
socket
|
||||
.send_to(query, a.get(2).map(String::as_str).unwrap_or("8.8.8.8:53"))
|
||||
.unwrap();
|
||||
let mut reply = [0; 512];
|
||||
let (n, _) = socket.recv_from(&mut reply).unwrap();
|
||||
assert!(n >= 4);
|
||||
println!(
|
||||
"{}.{}.{}.{}",
|
||||
reply[n - 4],
|
||||
reply[n - 3],
|
||||
reply[n - 2],
|
||||
reply[n - 1]
|
||||
);
|
||||
return;
|
||||
}
|
||||
if a.get(1).map(String::as_str) == Some("--udp") {
|
||||
let addr = a[2].parse::<SocketAddr>().unwrap();
|
||||
let socket = UdpSocket::bind(if addr.is_ipv6() {
|
||||
"[::]:0"
|
||||
} else {
|
||||
"0.0.0.0:0"
|
||||
})
|
||||
.unwrap();
|
||||
socket
|
||||
.set_read_timeout(Some(Duration::from_secs(4)))
|
||||
.unwrap();
|
||||
socket.send_to(b"peer", addr).unwrap();
|
||||
let mut b = [0; 512];
|
||||
let (n, _) = socket.recv_from(&mut b).unwrap();
|
||||
println!("{}", String::from_utf8_lossy(&b[..n]));
|
||||
return;
|
||||
}
|
||||
if a.get(1).map(String::as_str) == Some("--emit-configs") {
|
||||
let dir = PathBuf::from(&a[2]);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
for (name,q) in [("plain",""),("tls","?security=tls&sni=example.com"),("reality","?security=reality&sni=example.com&pbk=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA&sid=0123&fp=chrome&flow=xtls-rprx-vision"),("ws","?type=ws&security=tls&path=%2Fsocket&host=example.com")] {
|
||||
let v=vless::parse(&format!("vless://b831381d-6324-4d53-ad4f-8cda48b30811@192.0.2.2:2443{q}")).unwrap();
|
||||
for split in [true,false] {let c=config::generate(&v,&["/usr/bin/curl".into()],split,1000);std::fs::write(dir.join(format!("{name}-{split}.json")),serde_json::to_vec_pretty(&c).unwrap()).unwrap();}
|
||||
}
|
||||
return;
|
||||
}
|
||||
assert_eq!(a.get(1).map(String::as_str), Some("--connect"));
|
||||
let state = AppState::new(
|
||||
a[3].clone().into(),
|
||||
Some(a[2].clone().into()),
|
||||
std::env::current_exe().unwrap(),
|
||||
);
|
||||
let request = Preferences {
|
||||
// An ignored query value must not interfere with internal version parsing
|
||||
// even though that value is redacted from user-visible core logs.
|
||||
vless_url: "vless://b831381d-6324-4d53-ad4f-8cda48b30811@192.0.2.2:2443?remark=1.14".into(),
|
||||
applications: vec![a[4].clone()],
|
||||
split_tunneling: a[5] == "split",
|
||||
..Default::default()
|
||||
};
|
||||
state.connect(request).unwrap();
|
||||
let until = Instant::now() + Duration::from_secs(20);
|
||||
while Instant::now() < until {
|
||||
let status = state.status();
|
||||
if status.phase == Phase::Connected {
|
||||
println!("READY");
|
||||
std::io::stdout().flush().unwrap();
|
||||
break;
|
||||
}
|
||||
if status.phase == Phase::Error {
|
||||
eprintln!(
|
||||
"{}\n{}",
|
||||
status.error.unwrap_or_default(),
|
||||
state.logs().join("\n")
|
||||
);
|
||||
std::process::exit(1);
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
}
|
||||
if state.status().phase != Phase::Connected {
|
||||
state.shutdown();
|
||||
panic!("startup timeout");
|
||||
}
|
||||
let mut b = [0; 1];
|
||||
let _ = std::io::stdin().read(&mut b);
|
||||
state.shutdown();
|
||||
println!("STOPPED");
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 971 B |
@@ -0,0 +1,84 @@
|
||||
use crate::vless::Vless;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
/// sing-box 1.14.x. No legacy DNS servers or removed outbound actions.
|
||||
pub fn generate(vless: &Vless, selected: &[String], split: bool, uid: u32) -> Value {
|
||||
let mut inbound = json!({
|
||||
"type":"tun", "tag":"tun-in", "interface_name":"minivless0",
|
||||
"address":["172.31.255.1/30", "fdfe:dcba:ffff::1/126"],
|
||||
"mtu":1500, "auto_route":true, "auto_redirect":true, "strict_route":true,
|
||||
"stack":"system", "iproute2_table_index":2090, "iproute2_rule_index":10900,
|
||||
"auto_redirect_input_mark":"0x2091", "auto_redirect_output_mark":"0x2092",
|
||||
"auto_redirect_reset_mark":"0x2093", "auto_redirect_nfqueue":109,
|
||||
"dns_mode": "hijack"
|
||||
});
|
||||
if split {
|
||||
inbound["include_uid"] = json!([uid]);
|
||||
}
|
||||
let mut route_rules = vec![json!({"port":53, "action":"hijack-dns"})];
|
||||
if split {
|
||||
route_rules.push(json!({"process_path":selected, "action":"route", "outbound":"proxy"}));
|
||||
}
|
||||
let dns_rules = if split {
|
||||
vec![json!({"process_path":selected, "action":"route", "server":"remote-dns"})]
|
||||
} else {
|
||||
vec![]
|
||||
};
|
||||
json!({
|
||||
"log":{"level":"info", "timestamp":false, "disabled":false},
|
||||
"dns":{
|
||||
"servers":[
|
||||
{"type":"https", "tag":"direct-dns", "server":"1.1.1.1", "tls":{"server_name":"cloudflare-dns.com"}},
|
||||
{"type":"https", "tag":"remote-dns", "server":"1.1.1.1", "tls":{"server_name":"cloudflare-dns.com"}, "detour":"proxy"}
|
||||
],
|
||||
"rules":dns_rules,
|
||||
"final":if split {"direct-dns"} else {"remote-dns"},
|
||||
"independent_cache":true
|
||||
},
|
||||
"inbounds":[inbound],
|
||||
"outbounds":[vless.outbound(), {"type":"direct", "tag":"direct"}],
|
||||
"route":{
|
||||
"rules":route_rules,
|
||||
"final":if split {"direct"} else {"proxy"},
|
||||
"auto_detect_interface":true,
|
||||
"default_domain_resolver":"direct-dns"
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
fn sample() -> Vless {
|
||||
crate::vless::parse("vless://b831381d-6324-4d53-ad4f-8cda48b30811@127.0.0.1:443").unwrap()
|
||||
}
|
||||
#[test]
|
||||
fn split_has_explicit_direct_fallback() {
|
||||
let c = generate(&sample(), &["/opt/discord/Discord".into()], true, 1000);
|
||||
assert_eq!(c["route"]["final"], "direct");
|
||||
assert_eq!(
|
||||
c["route"]["rules"][1]["process_path"],
|
||||
json!(["/opt/discord/Discord"])
|
||||
);
|
||||
assert_eq!(c["route"]["rules"][1]["outbound"], "proxy");
|
||||
assert_eq!(c["inbounds"][0]["include_uid"], json!([1000]));
|
||||
assert_eq!(c["dns"]["final"], "direct-dns");
|
||||
assert_eq!(c["dns"]["rules"][0]["server"], "remote-dns");
|
||||
}
|
||||
#[test]
|
||||
fn full_system_includes_other_users() {
|
||||
let c = generate(&sample(), &[], false, 1000);
|
||||
assert_eq!(c["route"]["final"], "proxy");
|
||||
assert!(c["inbounds"][0].get("include_uid").is_none());
|
||||
assert_eq!(c["dns"]["final"], "remote-dns");
|
||||
assert_eq!(c["inbounds"][0]["dns_mode"], "hijack");
|
||||
}
|
||||
#[test]
|
||||
fn avoids_recursion_and_handles_ipv6() {
|
||||
let c = generate(&sample(), &[], false, 1000);
|
||||
assert_eq!(c["route"]["auto_detect_interface"], true);
|
||||
assert_eq!(c["route"]["default_domain_resolver"], "direct-dns");
|
||||
assert_eq!(c["inbounds"][0]["address"].as_array().unwrap().len(), 2);
|
||||
assert_eq!(c["route"]["rules"][0]["action"], "hijack-dns");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
//! A pipe-lifetime guardian is necessary: exec of a capability-bearing binary clears
|
||||
//! Linux PR_SET_PDEATHSIG. Closing the GUI's pipe (even after SIGKILL) stops the core.
|
||||
use std::{
|
||||
io::Read,
|
||||
path::Path,
|
||||
process::{Child, Command, Stdio},
|
||||
sync::{
|
||||
atomic::{AtomicBool, Ordering},
|
||||
mpsc, Arc,
|
||||
},
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
pub fn terminate(child: &mut Child) {
|
||||
if matches!(child.try_wait(), Ok(Some(_))) {
|
||||
return;
|
||||
}
|
||||
// Child remains unreaped until wait below, so its PID cannot be reused here.
|
||||
unsafe {
|
||||
libc::kill(child.id() as i32, libc::SIGTERM);
|
||||
}
|
||||
let deadline = Instant::now() + Duration::from_secs(3);
|
||||
while Instant::now() < deadline {
|
||||
if matches!(child.try_wait(), Ok(Some(_))) {
|
||||
return;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(30));
|
||||
}
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
}
|
||||
|
||||
pub fn run(binary: &Path, config: &Path) -> i32 {
|
||||
let stopping = Arc::new(AtomicBool::new(false));
|
||||
for signal in [libc::SIGTERM, libc::SIGINT, libc::SIGHUP] {
|
||||
if signal_hook::flag::register(signal, stopping.clone()).is_err() {
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
// The separate lock is also held by the guardian during GUI-crash cleanup.
|
||||
let lock_dir = config.parent().and_then(Path::parent);
|
||||
let _lock = match lock_dir.and_then(|dir| crate::tunnel::core_lock(dir).ok()) {
|
||||
Some(lock) => lock,
|
||||
None => {
|
||||
eprintln!("Another tunnel is still running or stopping");
|
||||
return 1;
|
||||
}
|
||||
};
|
||||
let (tx, rx) = mpsc::channel();
|
||||
std::thread::spawn(move || {
|
||||
let mut byte = [0; 1];
|
||||
let _ = std::io::stdin().read(&mut byte);
|
||||
let _ = tx.send(());
|
||||
});
|
||||
let mut child = match Command::new(binary)
|
||||
.args(["run", "--disable-color", "-c"])
|
||||
// Pinned sing-tun uses PATH to find resolvectl. DNS interception is native
|
||||
// nftables; disable external helpers so it never changes resolved settings
|
||||
// or asks Polkit for additional privileges. All core I/O is in-process.
|
||||
.env("PATH", "")
|
||||
.arg(config)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::inherit())
|
||||
.stderr(Stdio::inherit())
|
||||
.spawn()
|
||||
{
|
||||
Ok(child) => child,
|
||||
Err(_) => {
|
||||
eprintln!("Cannot start sing-box");
|
||||
cleanup(config);
|
||||
return 1;
|
||||
}
|
||||
};
|
||||
let code = loop {
|
||||
if stopping.load(Ordering::Relaxed) || rx.try_recv().is_ok() {
|
||||
terminate(&mut child);
|
||||
break 0;
|
||||
}
|
||||
match child.try_wait() {
|
||||
Ok(Some(s)) => break s.code().unwrap_or(1),
|
||||
Err(_) => {
|
||||
terminate(&mut child);
|
||||
break 1;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(30));
|
||||
};
|
||||
cleanup(config);
|
||||
code
|
||||
}
|
||||
|
||||
pub fn cleanup(config: &Path) {
|
||||
// Only files created by our runtime writer are eligible for deletion.
|
||||
if config.file_name().is_some_and(|s| s == "config.json") {
|
||||
if let Some(dir) = config.parent().filter(|p| {
|
||||
p.file_name()
|
||||
.is_some_and(|n| n.to_string_lossy().starts_with("runtime-"))
|
||||
}) {
|
||||
let _ = std::fs::remove_file(config);
|
||||
let _ = std::fs::remove_dir(dir);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
#![cfg(target_os = "linux")]
|
||||
pub mod config;
|
||||
pub mod guardian;
|
||||
pub mod processes;
|
||||
pub mod storage;
|
||||
pub mod tunnel;
|
||||
pub mod vless;
|
||||
@@ -0,0 +1,154 @@
|
||||
use minivless::{
|
||||
guardian,
|
||||
processes::{self, ApplicationInfo},
|
||||
storage::{self, Preferences},
|
||||
tunnel::{self, AppState, Status},
|
||||
};
|
||||
use std::{path::PathBuf, sync::Arc};
|
||||
|
||||
#[cfg(feature = "desktop")]
|
||||
mod desktop {
|
||||
use super::*;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use tauri::{Manager, State};
|
||||
#[tauri::command]
|
||||
async fn list_applications() -> Result<Vec<ApplicationInfo>, String> {
|
||||
tauri::async_runtime::spawn_blocking(processes::list_running)
|
||||
.await
|
||||
.map_err(|_| "Cannot list applications".into())
|
||||
}
|
||||
#[tauri::command]
|
||||
async fn list_installed_applications() -> Result<Vec<ApplicationInfo>, String> {
|
||||
tauri::async_runtime::spawn_blocking(processes::list_installed)
|
||||
.await
|
||||
.map_err(|_| "Cannot list installed applications".into())
|
||||
}
|
||||
#[tauri::command]
|
||||
async fn add_application(path: String) -> Result<ApplicationInfo, String> {
|
||||
tauri::async_runtime::spawn_blocking(move || processes::describe(&path))
|
||||
.await
|
||||
.map_err(|_| "Cannot add application")?
|
||||
}
|
||||
#[tauri::command]
|
||||
fn connect(state: State<'_, Arc<AppState>>, request: Preferences) -> Result<(), String> {
|
||||
state.inner().connect(request)
|
||||
}
|
||||
#[tauri::command]
|
||||
fn disconnect(state: State<'_, Arc<AppState>>) {
|
||||
state.disconnect()
|
||||
}
|
||||
#[tauri::command]
|
||||
fn get_status(state: State<'_, Arc<AppState>>) -> Status {
|
||||
state.status()
|
||||
}
|
||||
#[tauri::command]
|
||||
fn get_logs(state: State<'_, Arc<AppState>>) -> Vec<String> {
|
||||
state.logs()
|
||||
}
|
||||
#[tauri::command]
|
||||
fn load_preferences(state: State<'_, Arc<AppState>>) -> Result<Preferences, String> {
|
||||
storage::load(&state.directory)
|
||||
}
|
||||
#[tauri::command]
|
||||
async fn save_preferences(
|
||||
state: State<'_, Arc<AppState>>,
|
||||
preferences: Preferences,
|
||||
) -> Result<(), String> {
|
||||
let state = state.inner().clone();
|
||||
tauri::async_runtime::spawn_blocking(move || state.save(&preferences))
|
||||
.await
|
||||
.map_err(|_| "Cannot save settings")?
|
||||
}
|
||||
pub fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||
if unsafe { libc::geteuid() } == 0 {
|
||||
return Err(
|
||||
"Run MiniVLESS as your normal user. Only sing-box needs capabilities.".into(),
|
||||
);
|
||||
}
|
||||
let directory = storage::config_dir()?;
|
||||
let _lock = storage::lock(&directory)?;
|
||||
let exiting = Arc::new(AtomicBool::new(false));
|
||||
let closed = exiting.clone();
|
||||
let app = tauri::Builder::default()
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
list_applications,
|
||||
list_installed_applications,
|
||||
add_application,
|
||||
connect,
|
||||
disconnect,
|
||||
get_status,
|
||||
get_logs,
|
||||
load_preferences,
|
||||
save_preferences
|
||||
])
|
||||
.setup(move |app| {
|
||||
let resources = app.path().resource_dir().ok();
|
||||
let state = AppState::new(
|
||||
directory.clone(),
|
||||
tunnel::find_core(resources.as_deref()),
|
||||
std::env::current_exe()?,
|
||||
);
|
||||
app.manage(state.clone());
|
||||
let signals = Arc::new(AtomicBool::new(false));
|
||||
for sig in [libc::SIGINT, libc::SIGTERM, libc::SIGHUP] {
|
||||
signal_hook::flag::register(sig, signals.clone())?;
|
||||
}
|
||||
let handle = app.handle().clone();
|
||||
std::thread::spawn(move || {
|
||||
while !exiting.load(Ordering::Relaxed) {
|
||||
if signals.load(Ordering::Relaxed) {
|
||||
exiting.store(true, Ordering::Relaxed);
|
||||
state.shutdown();
|
||||
handle.exit(0);
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(100));
|
||||
}
|
||||
});
|
||||
Ok(())
|
||||
})
|
||||
.on_window_event(move |window, event| {
|
||||
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
|
||||
api.prevent_close();
|
||||
if !closed.swap(true, Ordering::SeqCst) {
|
||||
let state = window.state::<Arc<AppState>>().inner().clone();
|
||||
let app = window.app_handle().clone();
|
||||
std::thread::spawn(move || {
|
||||
state.shutdown();
|
||||
app.exit(0);
|
||||
});
|
||||
}
|
||||
}
|
||||
})
|
||||
.build(tauri::generate_context!())?;
|
||||
app.run(|app, event| {
|
||||
if matches!(event, tauri::RunEvent::Exit) {
|
||||
app.state::<Arc<AppState>>().shutdown();
|
||||
}
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let args = std::env::args_os().collect::<Vec<_>>();
|
||||
if args.get(1).is_some_and(|v| v == "--core-guardian") {
|
||||
if args.len() != 4 {
|
||||
std::process::exit(2);
|
||||
}
|
||||
std::process::exit(guardian::run(
|
||||
&PathBuf::from(&args[2]),
|
||||
&PathBuf::from(&args[3]),
|
||||
));
|
||||
}
|
||||
#[cfg(feature = "desktop")]
|
||||
if let Err(error) = desktop::run() {
|
||||
eprintln!("MiniVLESS: {error}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
#[cfg(not(feature = "desktop"))]
|
||||
{
|
||||
eprintln!("Build with the desktop feature to open MiniVLESS.");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
use serde::Serialize;
|
||||
use std::{
|
||||
collections::{BTreeMap, HashMap},
|
||||
fs,
|
||||
io::Read,
|
||||
os::unix::fs::{MetadataExt, PermissionsExt},
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
#[derive(Clone, Serialize)]
|
||||
pub struct ApplicationInfo {
|
||||
pub name: String,
|
||||
pub executable: String,
|
||||
pub running: bool,
|
||||
pub pid: Option<u32>,
|
||||
}
|
||||
|
||||
pub fn normalize(path: &str) -> Result<String, String> {
|
||||
if path.len() > 4096 || !Path::new(path).is_absolute() || path.chars().any(char::is_control) {
|
||||
return Err("Choose an absolute executable path".into());
|
||||
}
|
||||
let p = fs::canonicalize(path).map_err(|_| "Application executable was not found")?;
|
||||
let m = fs::metadata(&p).map_err(|_| "Cannot read application executable")?;
|
||||
if !m.is_file() || m.permissions().mode() & 0o111 == 0 {
|
||||
return Err("Choose an executable file".into());
|
||||
}
|
||||
let mut magic = [0; 4];
|
||||
fs::File::open(&p)
|
||||
.and_then(|mut f| f.read_exact(&mut magic))
|
||||
.map_err(|_| "Cannot read application executable")?;
|
||||
if magic != *b"\x7fELF" {
|
||||
return Err("Choose the real application binary, not a launcher script".into());
|
||||
}
|
||||
let s = p.to_str().ok_or("Application path must be valid UTF-8")?;
|
||||
if s.chars().any(char::is_control) {
|
||||
return Err("Invalid executable path".into());
|
||||
}
|
||||
Ok(s.into())
|
||||
}
|
||||
|
||||
fn desktop_dirs() -> Vec<PathBuf> {
|
||||
let mut dirs = std::env::var_os("XDG_DATA_DIRS")
|
||||
.map(|v| std::env::split_paths(&v).collect::<Vec<_>>())
|
||||
.unwrap_or_else(|| vec!["/usr/local/share".into(), "/usr/share".into()]);
|
||||
if let Some(home) = std::env::var_os("HOME") {
|
||||
dirs.push(PathBuf::from(home).join(".local/share"));
|
||||
}
|
||||
if let Some(home) = std::env::var_os("XDG_DATA_HOME") {
|
||||
if Path::new(&home).is_absolute() {
|
||||
dirs.push(home.into());
|
||||
}
|
||||
}
|
||||
dirs.into_iter().map(|p| p.join("applications")).collect()
|
||||
}
|
||||
|
||||
// Tokenize Desktop Entry Exec without ever executing it. Field codes are never evaluated.
|
||||
fn exec_token(exec: &str) -> Option<String> {
|
||||
let mut out = String::new();
|
||||
let mut quoted = false;
|
||||
let mut escaped = false;
|
||||
for c in exec.trim().chars() {
|
||||
if escaped {
|
||||
out.push(c);
|
||||
escaped = false;
|
||||
} else if c == '\\' {
|
||||
escaped = true;
|
||||
} else if c == '"' {
|
||||
quoted = !quoted;
|
||||
} else if c.is_whitespace() && !quoted {
|
||||
break;
|
||||
} else {
|
||||
out.push(c);
|
||||
}
|
||||
}
|
||||
if out.is_empty() || quoted || escaped {
|
||||
None
|
||||
} else {
|
||||
Some(out)
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_program(token: &str) -> Option<String> {
|
||||
if token.starts_with('/') {
|
||||
return normalize(token).ok();
|
||||
}
|
||||
std::env::var_os("PATH").and_then(|paths| {
|
||||
std::env::split_paths(&paths)
|
||||
.filter(|p| p.is_absolute())
|
||||
.find_map(|dir| normalize(dir.join(token).to_str()?).ok())
|
||||
})
|
||||
}
|
||||
|
||||
struct DesktopApp {
|
||||
name: String,
|
||||
path: Option<String>,
|
||||
keys: Vec<String>,
|
||||
}
|
||||
|
||||
fn catalog() -> Vec<DesktopApp> {
|
||||
let mut result = vec![];
|
||||
for dir in desktop_dirs() {
|
||||
let Ok(entries) = fs::read_dir(dir) else {
|
||||
continue;
|
||||
};
|
||||
for entry in entries.flatten().take(4096) {
|
||||
let path = entry.path();
|
||||
if path.extension().and_then(|s| s.to_str()) != Some("desktop") {
|
||||
continue;
|
||||
}
|
||||
if entry.metadata().map(|m| m.len() > 128_000).unwrap_or(true) {
|
||||
continue;
|
||||
}
|
||||
let Ok(text) = fs::read_to_string(&path) else {
|
||||
continue;
|
||||
};
|
||||
let mut fields = HashMap::new();
|
||||
let mut main = false;
|
||||
for line in text.lines() {
|
||||
if line.starts_with('[') {
|
||||
main = line == "[Desktop Entry]";
|
||||
continue;
|
||||
}
|
||||
if main {
|
||||
if let Some((k, v)) = line.split_once('=') {
|
||||
fields.insert(k, v);
|
||||
}
|
||||
}
|
||||
}
|
||||
if fields.get("Type") != Some(&"Application")
|
||||
|| fields.get("Hidden") == Some(&"true")
|
||||
|| fields.get("NoDisplay") == Some(&"true")
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let Some(name) = fields
|
||||
.get("Name")
|
||||
.filter(|s| s.len() <= 128 && !s.chars().any(char::is_control))
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let Some(token) = fields.get("Exec").and_then(|s| exec_token(s)) else {
|
||||
continue;
|
||||
};
|
||||
let mut keys = vec![];
|
||||
let mut is_launcher = true;
|
||||
if let Some(stem) = Path::new(&token).file_name().and_then(|s| s.to_str()) {
|
||||
// Generic launchers must never group unrelated apps into a single routing rule.
|
||||
if ![
|
||||
"env", "sh", "bash", "flatpak", "snap", "python", "python3", "node",
|
||||
"electron", "java",
|
||||
]
|
||||
.contains(&stem)
|
||||
{
|
||||
keys.push(stem.to_lowercase());
|
||||
is_launcher = false;
|
||||
}
|
||||
}
|
||||
if let Some(wm) = fields.get("StartupWMClass") {
|
||||
keys.push(wm.to_lowercase());
|
||||
}
|
||||
if keys.is_empty() {
|
||||
continue;
|
||||
}
|
||||
result.push(DesktopApp {
|
||||
name: (*name).into(),
|
||||
path: if is_launcher {
|
||||
None
|
||||
} else {
|
||||
resolve_program(&token)
|
||||
},
|
||||
keys,
|
||||
});
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
pub fn list_installed() -> Vec<ApplicationInfo> {
|
||||
let mut by_path = BTreeMap::new();
|
||||
for app in catalog() {
|
||||
if let Some(path) = app.path {
|
||||
by_path.entry(path.clone()).or_insert(ApplicationInfo {
|
||||
name: app.name,
|
||||
executable: path,
|
||||
running: false,
|
||||
pid: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
sorted(by_path.into_values().collect())
|
||||
}
|
||||
|
||||
pub fn list_running() -> Vec<ApplicationInfo> {
|
||||
let apps = catalog();
|
||||
let uid = unsafe { libc::geteuid() };
|
||||
let mut by_path = BTreeMap::new();
|
||||
let Ok(entries) = fs::read_dir("/proc") else {
|
||||
return vec![];
|
||||
};
|
||||
for e in entries.flatten() {
|
||||
let Some(pid) = e.file_name().to_str().and_then(|s| s.parse::<u32>().ok()) else {
|
||||
continue;
|
||||
};
|
||||
if e.metadata().map(|m| m.uid() != uid).unwrap_or(true) {
|
||||
continue;
|
||||
}
|
||||
let Ok(exe) = fs::read_link(e.path().join("exe")) else {
|
||||
continue;
|
||||
};
|
||||
let Some(executable) = exe.to_str() else {
|
||||
continue;
|
||||
};
|
||||
if executable.ends_with(" (deleted)") {
|
||||
continue;
|
||||
}
|
||||
let basename = exe
|
||||
.file_name()
|
||||
.unwrap_or_default()
|
||||
.to_string_lossy()
|
||||
.to_lowercase();
|
||||
let app = apps
|
||||
.iter()
|
||||
.find(|a| a.path.as_deref() == Some(executable))
|
||||
.or_else(|| apps.iter().find(|a| a.keys.contains(&basename)));
|
||||
let Some(app) = app else { continue };
|
||||
by_path
|
||||
.entry(executable.to_owned())
|
||||
.or_insert(ApplicationInfo {
|
||||
name: app.name.clone(),
|
||||
executable: executable.into(),
|
||||
running: true,
|
||||
pid: Some(pid),
|
||||
});
|
||||
}
|
||||
sorted(by_path.into_values().collect())
|
||||
}
|
||||
|
||||
fn sorted(mut apps: Vec<ApplicationInfo>) -> Vec<ApplicationInfo> {
|
||||
apps.sort_by_cached_key(|a| a.name.to_lowercase());
|
||||
apps
|
||||
}
|
||||
|
||||
pub fn describe(path: &str) -> Result<ApplicationInfo, String> {
|
||||
let path = normalize(path)?;
|
||||
if let Some(app) = list_running()
|
||||
.into_iter()
|
||||
.chain(list_installed())
|
||||
.find(|a| a.executable == path)
|
||||
{
|
||||
return Ok(app);
|
||||
}
|
||||
let name = Path::new(&path)
|
||||
.file_name()
|
||||
.unwrap_or_default()
|
||||
.to_string_lossy()
|
||||
.into_owned();
|
||||
Ok(ApplicationInfo {
|
||||
name,
|
||||
executable: path,
|
||||
running: false,
|
||||
pid: None,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn quoted_desktop_exec() {
|
||||
assert_eq!(
|
||||
exec_token("\"/opt/My App/app\" %U"),
|
||||
Some("/opt/My App/app".into())
|
||||
);
|
||||
assert!(exec_token("\"unclosed").is_none());
|
||||
}
|
||||
#[test]
|
||||
fn path_validation() {
|
||||
assert!(normalize("relative").is_err());
|
||||
assert!(normalize("/etc/passwd").is_err());
|
||||
assert!(normalize("/tmp/x\n").is_err());
|
||||
assert!(normalize("/usr/bin/true").is_ok());
|
||||
}
|
||||
#[test]
|
||||
fn scripts_are_not_routing_identities() {
|
||||
let d = tempfile::tempdir().unwrap();
|
||||
let p = d.path().join("launcher");
|
||||
fs::write(&p, "#!/bin/sh\nexec true\n").unwrap();
|
||||
fs::set_permissions(&p, fs::Permissions::from_mode(0o755)).unwrap();
|
||||
assert!(normalize(p.to_str().unwrap()).is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::{
|
||||
fs::{self, File, OpenOptions},
|
||||
io::{Read, Write},
|
||||
os::{
|
||||
fd::AsRawFd,
|
||||
unix::fs::{DirBuilderExt, MetadataExt, OpenOptionsExt, PermissionsExt},
|
||||
},
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct Preferences {
|
||||
pub vless_url: String,
|
||||
pub applications: Vec<String>,
|
||||
pub custom_applications: Vec<String>,
|
||||
pub split_tunneling: bool,
|
||||
}
|
||||
impl Default for Preferences {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
vless_url: String::new(),
|
||||
applications: vec![],
|
||||
custom_applications: vec![],
|
||||
split_tunneling: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Preferences {
|
||||
pub fn validate_size(&self) -> Result<(), String> {
|
||||
if self.vless_url.len() > 16_384
|
||||
|| self.applications.len() > 256
|
||||
|| self.custom_applications.len() > 256
|
||||
|| self
|
||||
.applications
|
||||
.iter()
|
||||
.chain(&self.custom_applications)
|
||||
.any(|s| s.len() > 4096 || !s.starts_with('/') || s.chars().any(char::is_control))
|
||||
{
|
||||
return Err("Invalid saved settings".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn config_dir() -> Result<PathBuf, String> {
|
||||
let base = std::env::var_os("XDG_CONFIG_HOME")
|
||||
.map(PathBuf::from)
|
||||
.filter(|p| p.is_absolute())
|
||||
.or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".config")))
|
||||
.ok_or("Cannot locate the configuration directory")?;
|
||||
let path = base.join("minivless");
|
||||
ensure_private_dir(&path)?;
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
pub fn ensure_private_dir(path: &Path) -> Result<(), String> {
|
||||
fs::DirBuilder::new()
|
||||
.recursive(true)
|
||||
.mode(0o700)
|
||||
.create(path)
|
||||
.map_err(|_| "Cannot create the private configuration directory")?;
|
||||
let m = fs::symlink_metadata(path).map_err(|_| "Cannot read the configuration directory")?;
|
||||
if !m.is_dir() || m.uid() != unsafe { libc::geteuid() } {
|
||||
return Err("Unsafe configuration directory".into());
|
||||
}
|
||||
fs::set_permissions(path, fs::Permissions::from_mode(0o700))
|
||||
.map_err(|_| "Cannot secure the configuration directory".into())
|
||||
}
|
||||
|
||||
pub fn load(dir: &Path) -> Result<Preferences, String> {
|
||||
let file = match OpenOptions::new()
|
||||
.read(true)
|
||||
.custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC)
|
||||
.open(dir.join("settings.json"))
|
||||
{
|
||||
Ok(f) => f,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Preferences::default()),
|
||||
Err(_) => return Err("Cannot read saved settings".into()),
|
||||
};
|
||||
let m = file.metadata().map_err(|_| "Cannot read saved settings")?;
|
||||
if !m.is_file() || m.uid() != unsafe { libc::geteuid() } || m.len() > 1_048_576 {
|
||||
return Err("Unsafe saved settings file".into());
|
||||
}
|
||||
file.set_permissions(fs::Permissions::from_mode(0o600))
|
||||
.map_err(|_| "Cannot secure saved settings")?;
|
||||
let mut data = String::new();
|
||||
file.take(1_048_577)
|
||||
.read_to_string(&mut data)
|
||||
.map_err(|_| "Cannot read saved settings")?;
|
||||
let p: Preferences = serde_json::from_str(&data)
|
||||
.map_err(|_| "Saved settings are damaged. Restore or remove settings.json.")?;
|
||||
p.validate_size()?;
|
||||
Ok(p)
|
||||
}
|
||||
|
||||
pub fn save(dir: &Path, settings: &Preferences) -> Result<(), String> {
|
||||
settings.validate_size()?;
|
||||
let mut tmp = tempfile::NamedTempFile::new_in(dir).map_err(|_| "Cannot save settings")?;
|
||||
tmp.as_file()
|
||||
.set_permissions(fs::Permissions::from_mode(0o600))
|
||||
.map_err(|_| "Cannot secure settings")?;
|
||||
serde_json::to_writer(&mut tmp, settings).map_err(|_| "Cannot save settings")?;
|
||||
tmp.flush()
|
||||
.and_then(|_| tmp.as_file().sync_all())
|
||||
.map_err(|_| "Cannot save settings")?;
|
||||
tmp.persist(dir.join("settings.json"))
|
||||
.map_err(|_| "Cannot replace saved settings")?;
|
||||
File::open(dir)
|
||||
.and_then(|f| f.sync_all())
|
||||
.map_err(|_| "Cannot sync saved settings")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Keep the file open for the process lifetime. The lock survives a stale lock file.
|
||||
pub fn lock(dir: &Path) -> Result<File, String> {
|
||||
let file = OpenOptions::new()
|
||||
.read(true)
|
||||
.write(true)
|
||||
.create(true)
|
||||
.truncate(false)
|
||||
.mode(0o600)
|
||||
.custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC)
|
||||
.open(dir.join("instance.lock"))
|
||||
.map_err(|_| "Cannot acquire application lock")?;
|
||||
if unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) } != 0 {
|
||||
return Err("MiniVLESS is already running".into());
|
||||
}
|
||||
Ok(file)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn private_roundtrip_and_lock() {
|
||||
let d = tempfile::tempdir().unwrap();
|
||||
let p = Preferences {
|
||||
vless_url: "test-secret".into(),
|
||||
split_tunneling: false,
|
||||
applications: vec!["/opt/discord/Discord".into()],
|
||||
custom_applications: vec!["/opt/discord/Discord".into()],
|
||||
};
|
||||
save(d.path(), &p).unwrap();
|
||||
assert_eq!(load(d.path()).unwrap().vless_url, "test-secret");
|
||||
assert!(!load(d.path()).unwrap().split_tunneling);
|
||||
assert_eq!(load(d.path()).unwrap().applications, p.applications);
|
||||
assert_eq!(
|
||||
load(d.path()).unwrap().custom_applications,
|
||||
p.custom_applications
|
||||
);
|
||||
assert_eq!(
|
||||
fs::metadata(d.path().join("settings.json")).unwrap().mode() & 0o777,
|
||||
0o600
|
||||
);
|
||||
let first = lock(d.path()).unwrap();
|
||||
assert!(lock(d.path()).is_err());
|
||||
drop(first);
|
||||
assert!(lock(d.path()).is_ok());
|
||||
}
|
||||
#[test]
|
||||
fn never_follows_settings_symlink() {
|
||||
let d = tempfile::tempdir().unwrap();
|
||||
std::os::unix::fs::symlink("/etc/passwd", d.path().join("settings.json")).unwrap();
|
||||
assert!(load(d.path()).is_err());
|
||||
}
|
||||
#[test]
|
||||
fn corrupt_settings_are_not_silently_overwritten() {
|
||||
let d = tempfile::tempdir().unwrap();
|
||||
fs::write(d.path().join("settings.json"), "{bad").unwrap();
|
||||
assert!(load(d.path()).is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,566 @@
|
||||
use crate::{
|
||||
config, guardian, processes,
|
||||
storage::{self, Preferences},
|
||||
vless,
|
||||
};
|
||||
use serde::Serialize;
|
||||
use std::{
|
||||
collections::VecDeque,
|
||||
fs::{self, File, OpenOptions},
|
||||
io::{Read, Write},
|
||||
os::{fd::AsRawFd, unix::fs::OpenOptionsExt},
|
||||
path::{Path, PathBuf},
|
||||
process::{Command, Stdio},
|
||||
sync::{
|
||||
mpsc::{self, Receiver, SyncSender},
|
||||
Arc, Mutex,
|
||||
},
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Serialize)]
|
||||
pub enum Phase {
|
||||
Disconnected,
|
||||
Connecting,
|
||||
Connected,
|
||||
Disconnecting,
|
||||
Error,
|
||||
}
|
||||
#[derive(Clone, Serialize)]
|
||||
pub struct Status {
|
||||
pub phase: Phase,
|
||||
pub error: Option<String>,
|
||||
pub split_tunneling: bool,
|
||||
pub applications: Vec<String>,
|
||||
}
|
||||
struct Inner {
|
||||
status: Status,
|
||||
logs: VecDeque<String>,
|
||||
stop: Option<mpsc::Sender<()>>,
|
||||
}
|
||||
pub struct AppState {
|
||||
inner: Mutex<Inner>,
|
||||
pub directory: PathBuf,
|
||||
pub core: Option<PathBuf>,
|
||||
pub guardian: PathBuf,
|
||||
saving: Mutex<()>,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
pub fn new(directory: PathBuf, core: Option<PathBuf>, guardian: PathBuf) -> Arc<Self> {
|
||||
Arc::new(Self {
|
||||
directory,
|
||||
core,
|
||||
guardian,
|
||||
saving: Mutex::new(()),
|
||||
inner: Mutex::new(Inner {
|
||||
status: Status {
|
||||
phase: Phase::Disconnected,
|
||||
error: None,
|
||||
split_tunneling: true,
|
||||
applications: vec![],
|
||||
},
|
||||
logs: VecDeque::new(),
|
||||
stop: None,
|
||||
}),
|
||||
})
|
||||
}
|
||||
pub fn status(&self) -> Status {
|
||||
self.inner
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.status
|
||||
.clone()
|
||||
}
|
||||
pub fn logs(&self) -> Vec<String> {
|
||||
self.inner
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.logs
|
||||
.iter()
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
fn log(&self, line: String) {
|
||||
let mut i = self.inner.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if i.logs.len() >= 300 {
|
||||
i.logs.pop_front();
|
||||
}
|
||||
i.logs.push_back(line);
|
||||
}
|
||||
pub fn save(&self, prefs: &Preferences) -> Result<(), String> {
|
||||
let _guard = self.saving.lock().map_err(|_| "Cannot save settings")?;
|
||||
storage::save(&self.directory, prefs)
|
||||
}
|
||||
pub fn connect(self: &Arc<Self>, request: Preferences) -> Result<(), String> {
|
||||
let (tx, rx) = mpsc::channel();
|
||||
{
|
||||
let mut i = self
|
||||
.inner
|
||||
.lock()
|
||||
.map_err(|_| "Application state is unavailable")?;
|
||||
if i.stop.is_some() {
|
||||
return Err("A connection is already running or stopping".into());
|
||||
}
|
||||
i.logs.clear();
|
||||
i.stop = Some(tx);
|
||||
i.status = Status {
|
||||
phase: Phase::Connecting,
|
||||
error: None,
|
||||
split_tunneling: request.split_tunneling,
|
||||
applications: request.applications.clone(),
|
||||
};
|
||||
}
|
||||
let state = self.clone();
|
||||
std::thread::spawn(move || {
|
||||
let outcome = state.run(request, &rx);
|
||||
let mut i = state.inner.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let cancelled = i.status.phase == Phase::Disconnecting;
|
||||
i.stop = None;
|
||||
if cancelled || outcome.is_ok() {
|
||||
i.status.phase = Phase::Disconnected;
|
||||
i.status.error = None;
|
||||
} else {
|
||||
i.status.phase = Phase::Error;
|
||||
i.status.error = outcome.err();
|
||||
}
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
pub fn disconnect(&self) {
|
||||
let mut i = self.inner.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if let Some(tx) = &i.stop {
|
||||
let _ = tx.send(());
|
||||
i.status.phase = Phase::Disconnecting;
|
||||
} else {
|
||||
i.status.phase = Phase::Disconnected;
|
||||
i.status.error = None;
|
||||
}
|
||||
}
|
||||
pub fn shutdown(&self) {
|
||||
self.disconnect();
|
||||
let deadline = Instant::now() + Duration::from_secs(15);
|
||||
while Instant::now() < deadline {
|
||||
if self
|
||||
.inner
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.stop
|
||||
.is_none()
|
||||
{
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(30));
|
||||
}
|
||||
}
|
||||
|
||||
fn run(&self, mut request: Preferences, stop: &Receiver<()>) -> Result<(), String> {
|
||||
request.validate_size()?;
|
||||
let parsed = vless::parse(&request.vless_url)?;
|
||||
if request.split_tunneling {
|
||||
if request.applications.is_empty() {
|
||||
return Err("Select at least one application".into());
|
||||
}
|
||||
request.applications = request
|
||||
.applications
|
||||
.iter()
|
||||
.map(|p| processes::normalize(p))
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
request.applications.sort();
|
||||
request.applications.dedup();
|
||||
}
|
||||
self.save(&request)?;
|
||||
let binary = self.core.as_ref().ok_or(
|
||||
"sing-box was not found. Run scripts/fetch-sing-box.py and install its capabilities.",
|
||||
)?;
|
||||
let redactor = Redactor(parsed.secrets.clone());
|
||||
self.log("Checking sing-box and TUN permissions…".into());
|
||||
// This command receives no config. Do not redact its internal result with
|
||||
// link secrets: a valid two-digit REALITY short ID such as "14" would
|
||||
// otherwise corrupt "1.14.0" and falsely reject a supported core.
|
||||
let version = run_check(binary, &["version"], None, &Redactor(vec![]), stop)
|
||||
.map_err(|_| "Cannot read sing-box version")?;
|
||||
if !version.contains("sing-box version 1.14.") {
|
||||
return Err("Use sing-box 1.14.x with this version of MiniVLESS".into());
|
||||
}
|
||||
if !Path::new("/dev/net/tun").exists() {
|
||||
return Err("Linux TUN is unavailable (/dev/net/tun is missing)".into());
|
||||
}
|
||||
if !has_capabilities(binary) {
|
||||
return Err(format!("TUN permission is missing.\nRun in a terminal:\nsudo setcap cap_net_admin,cap_net_raw+ep {}",shell_quote(binary)));
|
||||
}
|
||||
if Path::new("/sys/class/net/minivless0").exists() {
|
||||
return Err(
|
||||
"The minivless0 tunnel already exists. Stop the other tunnel first.".into(),
|
||||
);
|
||||
}
|
||||
let cfg = config::generate(
|
||||
&parsed,
|
||||
&request.applications,
|
||||
request.split_tunneling,
|
||||
unsafe { libc::geteuid() },
|
||||
);
|
||||
let runtime = tempfile::Builder::new()
|
||||
.prefix("runtime-")
|
||||
.tempdir_in(&self.directory)
|
||||
.map_err(|_| "Cannot create runtime directory")?;
|
||||
let config_path = runtime.path().join("config.json");
|
||||
let mut file = OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.mode(0o600)
|
||||
.open(&config_path)
|
||||
.map_err(|_| "Cannot write runtime configuration")?;
|
||||
serde_json::to_writer(&mut file, &cfg).map_err(|_| "Cannot write runtime configuration")?;
|
||||
file.flush()
|
||||
.and_then(|_| file.sync_all())
|
||||
.map_err(|_| "Cannot save runtime configuration")?;
|
||||
let check = run_check(
|
||||
binary,
|
||||
&["check", "--disable-color", "-c"],
|
||||
Some(&config_path),
|
||||
&redactor,
|
||||
stop,
|
||||
);
|
||||
if let Err(detail) = check {
|
||||
self.log(detail);
|
||||
return Err("sing-box configuration check failed. See logs.".into());
|
||||
}
|
||||
if stop.try_recv().is_ok() {
|
||||
return Ok(());
|
||||
}
|
||||
self.log(
|
||||
if request.split_tunneling {
|
||||
"Starting VPN for selected applications…"
|
||||
} else {
|
||||
"Starting VPN for the whole system…"
|
||||
}
|
||||
.into(),
|
||||
);
|
||||
let mut child = Command::new(&self.guardian)
|
||||
.arg("--core-guardian")
|
||||
.arg(binary)
|
||||
.arg(&config_path)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.map_err(|_| "Cannot start the tunnel supervisor")?;
|
||||
let _runtime_path = runtime.keep(); // Guardian owns cleanup, including after GUI SIGKILL.
|
||||
let mut control = child.stdin.take();
|
||||
let (tx, logs) = mpsc::sync_channel(128);
|
||||
pump(child.stdout.take().unwrap(), tx.clone());
|
||||
pump(child.stderr.take().unwrap(), tx);
|
||||
let started = Instant::now();
|
||||
let mut ready = false;
|
||||
let mut error_hint = None;
|
||||
let result = loop {
|
||||
if stop.try_recv().is_ok() {
|
||||
drop(control.take());
|
||||
wait_guardian(&mut child);
|
||||
self.log("Tunnel stopped.".into());
|
||||
break Ok(());
|
||||
}
|
||||
if let Ok(line) = logs.recv_timeout(Duration::from_millis(40)) {
|
||||
if line.contains("sing-box started") {
|
||||
ready = true;
|
||||
let mut i = self.inner.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if i.status.phase == Phase::Connecting {
|
||||
i.status.phase = Phase::Connected;
|
||||
}
|
||||
}
|
||||
if line.contains("operation not permitted") || line.contains("permission denied") {
|
||||
error_hint = Some(
|
||||
"TUN permission was denied. Check capabilities and Linux security policy.",
|
||||
);
|
||||
}
|
||||
if line.contains("nfqueue")
|
||||
&& (line.contains("not supported") || line.contains("no such"))
|
||||
{
|
||||
error_hint = Some(
|
||||
"Linux NFQUEUE support is unavailable. Load nfnetlink_queue and retry.",
|
||||
);
|
||||
}
|
||||
self.log(redactor.clean(&line));
|
||||
}
|
||||
match child.try_wait() {
|
||||
Ok(Some(status)) => {
|
||||
for line in logs.try_iter() {
|
||||
self.log(redactor.clean(&line));
|
||||
}
|
||||
self.log(format!("Tunnel process exited ({status})."));
|
||||
break Err(error_hint
|
||||
.unwrap_or(if ready {
|
||||
"The tunnel stopped unexpectedly. See logs."
|
||||
} else {
|
||||
"Connection failed. See logs."
|
||||
})
|
||||
.into());
|
||||
}
|
||||
Err(_) => {
|
||||
drop(control.take());
|
||||
wait_guardian(&mut child);
|
||||
break Err("Cannot read tunnel status".into());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
if !ready && started.elapsed() > Duration::from_secs(12) {
|
||||
drop(control.take());
|
||||
wait_guardian(&mut child);
|
||||
break Err("Tunnel startup timed out. See logs.".into());
|
||||
}
|
||||
};
|
||||
drop(control);
|
||||
guardian::cleanup(&config_path);
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
fn wait_guardian(child: &mut std::process::Child) {
|
||||
let until = Instant::now() + Duration::from_secs(5);
|
||||
while Instant::now() < until {
|
||||
if matches!(child.try_wait(), Ok(Some(_))) {
|
||||
return;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(30));
|
||||
}
|
||||
// Signal its cooperative handler; never SIGKILL a live guardian before its core.
|
||||
unsafe {
|
||||
libc::kill(child.id() as i32, libc::SIGTERM);
|
||||
}
|
||||
let _ = child.wait();
|
||||
}
|
||||
|
||||
fn run_check(
|
||||
binary: &Path,
|
||||
args: &[&str],
|
||||
config: Option<&Path>,
|
||||
redactor: &Redactor,
|
||||
stop: &Receiver<()>,
|
||||
) -> Result<String, String> {
|
||||
let mut cmd = Command::new(binary);
|
||||
cmd.args(args);
|
||||
if let Some(c) = config {
|
||||
cmd.arg(c);
|
||||
}
|
||||
let mut child = cmd
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.map_err(|_| "Cannot execute sing-box")?;
|
||||
let (tx, rx) = mpsc::sync_channel(128);
|
||||
pump(child.stdout.take().unwrap(), tx.clone());
|
||||
pump(child.stderr.take().unwrap(), tx);
|
||||
let until = Instant::now() + Duration::from_secs(5);
|
||||
let mut lines = VecDeque::new();
|
||||
loop {
|
||||
if let Ok(line) = rx.recv_timeout(Duration::from_millis(20)) {
|
||||
if lines.len() >= 20 {
|
||||
lines.pop_front();
|
||||
}
|
||||
lines.push_back(redactor.clean(&line));
|
||||
}
|
||||
match child.try_wait() {
|
||||
Ok(Some(status)) => {
|
||||
// Readers can still be scheduled just after waitpid reports exit.
|
||||
// Wait briefly for EOF so short `version` output cannot be lost.
|
||||
while let Ok(line) = rx.recv_timeout(Duration::from_millis(100)) {
|
||||
if lines.len() >= 20 {
|
||||
lines.pop_front();
|
||||
}
|
||||
lines.push_back(redactor.clean(&line));
|
||||
}
|
||||
let text = lines.into_iter().collect::<Vec<_>>().join("\n");
|
||||
return if status.success() {
|
||||
Ok(text)
|
||||
} else {
|
||||
Err(text)
|
||||
};
|
||||
}
|
||||
Err(_) => {
|
||||
guardian::terminate(&mut child);
|
||||
return Err("Cannot check sing-box".into());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
if Instant::now() > until || stop.try_recv().is_ok() {
|
||||
guardian::terminate(&mut child);
|
||||
return Err("sing-box check cancelled or timed out".into());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Redactor(pub Vec<String>);
|
||||
impl Redactor {
|
||||
pub fn clean(&self, text: &str) -> String {
|
||||
if text.contains("vless://") {
|
||||
return "[Credential-bearing log line omitted]".into();
|
||||
}
|
||||
let mut line = text.to_owned();
|
||||
for secret in &self.0 {
|
||||
if !secret.is_empty() {
|
||||
line = line.replace(secret, "[redacted]");
|
||||
}
|
||||
}
|
||||
line.chars()
|
||||
.filter(|c| !c.is_control() || *c == '\t')
|
||||
.take(4096)
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// Drop whole overlong lines, rather than truncating half of a credential before redaction.
|
||||
fn pump(mut reader: impl Read + Send + 'static, tx: SyncSender<String>) {
|
||||
std::thread::spawn(move || {
|
||||
let mut chunk = [0; 1024];
|
||||
let mut line = Vec::new();
|
||||
let mut overflow = false;
|
||||
loop {
|
||||
let n = match reader.read(&mut chunk) {
|
||||
Ok(0) | Err(_) => break,
|
||||
Ok(n) => n,
|
||||
};
|
||||
for &byte in &chunk[..n] {
|
||||
if byte == b'\n' {
|
||||
let text = if overflow {
|
||||
"[Overlong log line omitted]".into()
|
||||
} else {
|
||||
String::from_utf8_lossy(&line).into_owned()
|
||||
};
|
||||
if tx.send(text).is_err() {
|
||||
return;
|
||||
}
|
||||
line.clear();
|
||||
overflow = false;
|
||||
} else if line.len() < 65_536 {
|
||||
line.push(byte);
|
||||
} else {
|
||||
overflow = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if !line.is_empty() {
|
||||
let _ = tx.send(if overflow {
|
||||
"[Overlong log line omitted]".into()
|
||||
} else {
|
||||
String::from_utf8_lossy(&line).into_owned()
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
pub fn core_lock(dir: &Path) -> Result<File, String> {
|
||||
let f = OpenOptions::new()
|
||||
.read(true)
|
||||
.write(true)
|
||||
.create(true)
|
||||
.truncate(false)
|
||||
.mode(0o600)
|
||||
.custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC)
|
||||
.open(dir.join("tunnel.lock"))
|
||||
.map_err(|_| "Cannot acquire tunnel lock")?;
|
||||
if unsafe { libc::flock(f.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) } != 0 {
|
||||
return Err("Another tunnel is still stopping".into());
|
||||
}
|
||||
Ok(f)
|
||||
}
|
||||
|
||||
pub fn find_core(resource_dir: Option<&Path>) -> Option<PathBuf> {
|
||||
if let Some(p) = std::env::var_os("MINIVLESS_SING_BOX") {
|
||||
return fs::canonicalize(p).ok();
|
||||
}
|
||||
let mut candidates = vec![PathBuf::from("/usr/local/lib/minivless/sing-box")];
|
||||
if let Some(p) = resource_dir {
|
||||
candidates.push(p.join("binaries/sing-box"));
|
||||
}
|
||||
#[cfg(debug_assertions)]
|
||||
candidates.push(Path::new(env!("CARGO_MANIFEST_DIR")).join("../binaries/sing-box"));
|
||||
if let Ok(exe) = std::env::current_exe() {
|
||||
if let Some(dir) = exe.parent() {
|
||||
candidates.push(dir.join("binaries/sing-box"));
|
||||
}
|
||||
}
|
||||
candidates.extend([
|
||||
PathBuf::from("/usr/bin/sing-box"),
|
||||
PathBuf::from("/usr/local/bin/sing-box"),
|
||||
]);
|
||||
candidates
|
||||
.into_iter()
|
||||
.find_map(|p| fs::canonicalize(p).ok().filter(|p| p.is_file()))
|
||||
}
|
||||
|
||||
fn has_capabilities(binary: &Path) -> bool {
|
||||
// Enables isolated user/network namespace tests without modifying file capabilities.
|
||||
let effective = fs::read_to_string("/proc/self/status")
|
||||
.ok()
|
||||
.and_then(|s| {
|
||||
s.lines().find_map(|l| {
|
||||
l.strip_prefix("CapEff:\t")
|
||||
.and_then(|s| u64::from_str_radix(s.trim(), 16).ok())
|
||||
})
|
||||
})
|
||||
.unwrap_or(0);
|
||||
let wanted = (1 << 12) | (1 << 13);
|
||||
if effective & wanted == wanted {
|
||||
return true;
|
||||
}
|
||||
let Some(path) = binary.to_str().and_then(|s| std::ffi::CString::new(s).ok()) else {
|
||||
return false;
|
||||
};
|
||||
let mut value = [0u8; 24];
|
||||
let size = unsafe {
|
||||
libc::getxattr(
|
||||
path.as_ptr(),
|
||||
c"security.capability".as_ptr(),
|
||||
value.as_mut_ptr().cast(),
|
||||
value.len(),
|
||||
)
|
||||
};
|
||||
if size < 12 {
|
||||
return false;
|
||||
}
|
||||
let magic = u32::from_le_bytes(value[0..4].try_into().unwrap());
|
||||
let permitted = u32::from_le_bytes(value[4..8].try_into().unwrap());
|
||||
magic & 1 != 0 && permitted & wanted as u32 == wanted as u32
|
||||
}
|
||||
fn shell_quote(path: &Path) -> String {
|
||||
format!("'{}'", path.to_string_lossy().replace('\'', "'\\''"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn credentials_are_redacted() {
|
||||
let r = Redactor(vec!["secret-uuid".into(), "secret-key".into()]);
|
||||
assert_eq!(
|
||||
r.clean("uuid=secret-uuid key=secret-key"),
|
||||
"uuid=[redacted] key=[redacted]"
|
||||
);
|
||||
assert!(!r.clean("bad vless://secret").contains("vless://"));
|
||||
}
|
||||
#[test]
|
||||
fn log_bounds() {
|
||||
let d = tempfile::tempdir().unwrap();
|
||||
let s = AppState::new(d.path().into(), None, "/bin/false".into());
|
||||
for _ in 0..400 {
|
||||
s.log("line".into());
|
||||
}
|
||||
assert_eq!(s.logs().len(), 300);
|
||||
}
|
||||
#[test]
|
||||
fn same_instance_rejects_parallel_connect() {
|
||||
let d = tempfile::tempdir().unwrap();
|
||||
let s = AppState::new(d.path().into(), None, "/bin/false".into());
|
||||
let (tx, _) = mpsc::channel();
|
||||
s.inner.lock().unwrap().stop = Some(tx);
|
||||
assert!(s.connect(Preferences::default()).is_err());
|
||||
}
|
||||
#[test]
|
||||
fn overlong_lines_never_emit_partial_secrets() {
|
||||
let (tx, rx) = mpsc::sync_channel(128);
|
||||
pump(std::io::Cursor::new(vec![b'x'; 70_000]), tx);
|
||||
assert_eq!(rx.recv().unwrap(), "[Overlong log line omitted]");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
|
||||
use serde_json::{json, Value};
|
||||
use std::collections::HashMap;
|
||||
use url::{Host, Url};
|
||||
|
||||
// Deliberately no Debug: these values contain credentials.
|
||||
pub struct Vless {
|
||||
pub server: String,
|
||||
pub port: u16,
|
||||
pub uuid: String,
|
||||
pub tls: Option<Value>,
|
||||
pub flow: String,
|
||||
pub transport: Option<Value>,
|
||||
pub secrets: Vec<String>,
|
||||
}
|
||||
|
||||
pub fn parse(input: &str) -> Result<Vless, String> {
|
||||
let input = input.trim();
|
||||
if input.len() > 16_384 || input.chars().any(char::is_control) {
|
||||
return Err("Invalid VLESS URL".into());
|
||||
}
|
||||
let u = Url::parse(input).map_err(|_| "Invalid VLESS URL")?;
|
||||
if u.scheme() != "vless" || u.password().is_some() || !matches!(u.path(), "" | "/") {
|
||||
return Err("Invalid VLESS URL".into());
|
||||
}
|
||||
let id = uuid::Uuid::parse_str(u.username()).map_err(|_| "Invalid or missing VLESS UUID")?;
|
||||
let server = match u.host().ok_or("Missing server address")? {
|
||||
Host::Domain(s) => s.to_owned(),
|
||||
Host::Ipv4(ip) => ip.to_string(),
|
||||
Host::Ipv6(ip) => ip.to_string(),
|
||||
};
|
||||
let port = u
|
||||
.port()
|
||||
.filter(|p| *p != 0)
|
||||
.ok_or("Invalid or missing server port")?;
|
||||
let mut q = HashMap::new();
|
||||
for (k, v) in u.query_pairs() {
|
||||
if v.chars().any(char::is_control) {
|
||||
return Err("Invalid VLESS parameter".into());
|
||||
}
|
||||
if q.insert(k.into_owned(), v.into_owned()).is_some() {
|
||||
return Err("Duplicate VLESS parameter".into());
|
||||
}
|
||||
}
|
||||
let get = |k: &str| q.get(k).map(String::as_str).unwrap_or("");
|
||||
let alias = |a: &str, b: &str| -> Result<String, String> {
|
||||
if !get(a).is_empty() && !get(b).is_empty() && get(a) != get(b) {
|
||||
return Err("Conflicting VLESS parameters".into());
|
||||
}
|
||||
Ok(if get(a).is_empty() { get(b) } else { get(a) }.to_owned())
|
||||
};
|
||||
if !matches!(get("encryption"), "" | "none") {
|
||||
return Err("Unsupported VLESS encryption".into());
|
||||
}
|
||||
if !matches!(get("headerType"), "" | "none") {
|
||||
return Err("Unsupported TCP header type".into());
|
||||
}
|
||||
if matches!(get("allowInsecure"), "1" | "true") || matches!(get("insecure"), "1" | "true") {
|
||||
return Err("Insecure TLS is not supported".into());
|
||||
}
|
||||
let transport_kind = get("type");
|
||||
let mut transport = match transport_kind {
|
||||
"" | "tcp" | "raw" => None,
|
||||
"ws" => {
|
||||
let path = if get("path").is_empty() {
|
||||
"/"
|
||||
} else {
|
||||
get("path")
|
||||
};
|
||||
if !path.starts_with('/') {
|
||||
return Err("Invalid WebSocket path".into());
|
||||
}
|
||||
let mut t = json!({"type":"ws", "path":path});
|
||||
if !get("host").is_empty() {
|
||||
t["headers"] = json!({"Host":get("host")});
|
||||
}
|
||||
Some(t)
|
||||
}
|
||||
"grpc" => return Err("Unsupported VLESS transport: grpc".into()),
|
||||
"xhttp" => return Err("Unsupported VLESS transport: xhttp".into()),
|
||||
"httpupgrade" => return Err("Unsupported VLESS transport: httpupgrade".into()),
|
||||
_ => return Err("Unsupported VLESS transport".into()),
|
||||
};
|
||||
if !get("ed").is_empty() {
|
||||
let ed = get("ed")
|
||||
.parse::<u32>()
|
||||
.map_err(|_| "Invalid WebSocket early data")?;
|
||||
if let Some(ref mut t) = transport {
|
||||
t["max_early_data"] = json!(ed);
|
||||
t["early_data_header_name"] = json!(if get("eh").is_empty() {
|
||||
"Sec-WebSocket-Protocol"
|
||||
} else {
|
||||
get("eh")
|
||||
});
|
||||
} else {
|
||||
return Err("Early data requires WebSocket transport".into());
|
||||
}
|
||||
}
|
||||
let flow = get("flow").to_owned();
|
||||
if !matches!(flow.as_str(), "" | "xtls-rprx-vision") {
|
||||
return Err("Unsupported VLESS flow".into());
|
||||
}
|
||||
let security = get("security");
|
||||
if !flow.is_empty() && (!matches!(security, "tls" | "reality") || transport.is_some()) {
|
||||
return Err("Vision flow requires TCP with TLS or REALITY".into());
|
||||
}
|
||||
let sni = alias("sni", "serverName")?;
|
||||
let pk = alias("pbk", "publicKey")?;
|
||||
let sid = alias("sid", "shortId")?;
|
||||
let fingerprint = alias("fp", "fingerprint")?;
|
||||
let tls = match security {
|
||||
"" | "none" => {
|
||||
if !pk.is_empty() {
|
||||
return Err("REALITY key requires security=reality".into());
|
||||
}
|
||||
None
|
||||
}
|
||||
"tls" | "reality" => {
|
||||
let mut t =
|
||||
json!({"enabled":true, "server_name": if sni.is_empty() { &server } else { &sni }});
|
||||
if !fingerprint.is_empty() || security == "reality" {
|
||||
let fp = if fingerprint.is_empty() {
|
||||
"chrome"
|
||||
} else {
|
||||
&fingerprint
|
||||
};
|
||||
if !matches!(
|
||||
fp,
|
||||
"chrome"
|
||||
| "firefox"
|
||||
| "edge"
|
||||
| "safari"
|
||||
| "ios"
|
||||
| "android"
|
||||
| "random"
|
||||
| "randomized"
|
||||
| "360"
|
||||
| "qq"
|
||||
) {
|
||||
return Err("Unsupported TLS fingerprint".into());
|
||||
}
|
||||
t["utls"] = json!({"enabled":true,"fingerprint":fp});
|
||||
}
|
||||
if !get("alpn").is_empty() {
|
||||
t["alpn"] = json!(get("alpn").split(',').collect::<Vec<_>>());
|
||||
}
|
||||
if security == "reality" {
|
||||
if sni.is_empty() {
|
||||
return Err("REALITY server name is missing".into());
|
||||
}
|
||||
if URL_SAFE_NO_PAD.decode(&pk).map(|v| v.len()).ok() != Some(32) {
|
||||
return Err("Invalid or missing REALITY public key".into());
|
||||
}
|
||||
if sid.len() > 16
|
||||
|| sid.len() % 2 != 0
|
||||
|| !sid.bytes().all(|b| b.is_ascii_hexdigit())
|
||||
{
|
||||
return Err("Invalid REALITY short ID".into());
|
||||
}
|
||||
if transport.is_some() {
|
||||
return Err("REALITY requires TCP transport".into());
|
||||
}
|
||||
t["reality"] = json!({"enabled":true,"public_key":pk,"short_id":sid});
|
||||
}
|
||||
Some(t)
|
||||
}
|
||||
_ => return Err("Unsupported VLESS security".into()),
|
||||
};
|
||||
let mut secrets = vec![
|
||||
input.to_owned(),
|
||||
u.username().to_owned(),
|
||||
id.to_string(),
|
||||
pk,
|
||||
sid,
|
||||
];
|
||||
// Also redact transport paths and unknown query values: subscription links may contain tokens.
|
||||
secrets.extend(q.values().filter(|s| s.len() > 3).cloned());
|
||||
secrets.retain(|s| !s.is_empty());
|
||||
secrets.sort_by_key(|s| std::cmp::Reverse(s.len()));
|
||||
secrets.dedup();
|
||||
Ok(Vless {
|
||||
server,
|
||||
port,
|
||||
uuid: id.to_string(),
|
||||
tls,
|
||||
flow,
|
||||
transport,
|
||||
secrets,
|
||||
})
|
||||
}
|
||||
|
||||
impl Vless {
|
||||
pub fn outbound(&self) -> Value {
|
||||
let mut value = json!({"type":"vless", "tag":"proxy", "server":self.server, "server_port":self.port, "uuid":self.uuid, "packet_encoding":"xudp", "connect_timeout":"10s"});
|
||||
if let Some(tls) = &self.tls {
|
||||
value["tls"] = tls.clone();
|
||||
}
|
||||
if let Some(transport) = &self.transport {
|
||||
value["transport"] = transport.clone();
|
||||
}
|
||||
if !self.flow.is_empty() {
|
||||
value["flow"] = json!(self.flow);
|
||||
}
|
||||
value
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
const BASE: &str = "vless://b831381d-6324-4d53-ad4f-8cda48b30811@example.com:443";
|
||||
#[test]
|
||||
fn plain() {
|
||||
let v = parse(BASE).unwrap();
|
||||
assert_eq!(v.server, "example.com");
|
||||
assert_eq!(v.port, 443);
|
||||
assert!(v.tls.is_none());
|
||||
}
|
||||
#[test]
|
||||
fn tls_and_encoding() {
|
||||
let v = parse(&format!(
|
||||
"{BASE}?security=tls&sni=cdn.example.com&alpn=h2%2Chttp%2F1.1"
|
||||
))
|
||||
.unwrap();
|
||||
assert_eq!(v.tls.unwrap()["alpn"], json!(["h2", "http/1.1"]));
|
||||
}
|
||||
#[test]
|
||||
fn reality() {
|
||||
let v = parse(&format!("{BASE}?security=reality&pbk=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA&sid=0123&sni=cdn.example.com&fp=chrome&flow=xtls-rprx-vision")).unwrap();
|
||||
assert_eq!(v.tls.unwrap()["reality"]["short_id"], "0123");
|
||||
}
|
||||
#[test]
|
||||
fn ipv6() {
|
||||
let v = parse("vless://b831381d-6324-4d53-ad4f-8cda48b30811@[2001:db8::1]:443").unwrap();
|
||||
assert_eq!(v.server, "2001:db8::1");
|
||||
}
|
||||
#[test]
|
||||
fn websocket() {
|
||||
let v = parse(&format!(
|
||||
"{BASE}?type=ws&security=tls&path=%2Fsocket%3Ftoken%3Dtest&host=cdn.example.com"
|
||||
))
|
||||
.unwrap();
|
||||
assert_eq!(v.transport.unwrap()["path"], "/socket?token=test");
|
||||
}
|
||||
#[test]
|
||||
fn malformed() {
|
||||
for s in [
|
||||
"",
|
||||
"https://example.com",
|
||||
"vless://host:443",
|
||||
"vless://@host:443",
|
||||
"vless://bad@host:443",
|
||||
"vless://b831381d-6324-4d53-ad4f-8cda48b30811@host:65536",
|
||||
"vless://b831381d-6324-4d53-ad4f-8cda48b30811@host:0",
|
||||
] {
|
||||
assert!(parse(s).is_err());
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn unsupported_and_duplicates() {
|
||||
for q in [
|
||||
"type=grpc",
|
||||
"type=xhttp",
|
||||
"security=unknown",
|
||||
"security=tls&security=none",
|
||||
"flow=xtls-rprx-vision",
|
||||
"headerType=http",
|
||||
"security=reality&pbk=bad",
|
||||
"security=tls&allowInsecure=1",
|
||||
] {
|
||||
assert!(parse(&format!("{BASE}?{q}")).is_err(), "{q}");
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn unknown_is_ignored() {
|
||||
assert!(parse(&format!("{BASE}?futureFeature=hello")).is_ok());
|
||||
}
|
||||
#[test]
|
||||
fn no_secrets_in_errors() {
|
||||
let err = parse(&format!("{BASE}?type=MY_SECRET")).err().unwrap();
|
||||
assert!(!err.contains("MY_SECRET"));
|
||||
assert!(!err.contains("b831381d"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "MiniVLESS",
|
||||
"version": "0.1.0",
|
||||
"identifier": "dev.minivless.desktop",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
"devUrl": "http://127.0.0.1:1420",
|
||||
"beforeBuildCommand": "npm run build",
|
||||
"frontendDist": "../dist"
|
||||
},
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"label": "main",
|
||||
"title": "MiniVLESS",
|
||||
"width": 420,
|
||||
"height": 600,
|
||||
"minWidth": 380,
|
||||
"minHeight": 540,
|
||||
"resizable": true,
|
||||
"decorations": false,
|
||||
"theme": "Dark",
|
||||
"backgroundColor": "#1b1e22"
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src ipc: http://ipc.localhost; object-src 'none'; base-uri 'none'; frame-ancestors 'none'",
|
||||
"capabilities": [
|
||||
{
|
||||
"identifier": "main-window",
|
||||
"windows": [
|
||||
"main"
|
||||
],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"core:window:allow-start-dragging",
|
||||
"core:window:allow-close"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": [
|
||||
"deb"
|
||||
],
|
||||
"category": "Utility",
|
||||
"shortDescription": "Personal VLESS client",
|
||||
"resources": {
|
||||
"../binaries/sing-box": "binaries/sing-box",
|
||||
"../binaries/LICENSE.sing-box": "binaries/LICENSE.sing-box"
|
||||
},
|
||||
"icon": [
|
||||
"icons/icon.png"
|
||||
]
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user