diff options
| author | Andrew Guschin <guschin@altlinux.org> | 2026-10-30 03:10:41 +0400 |
|---|---|---|
| committer | Andrew Guschin <guschin@altlinux.org> | 2026-10-30 03:10:41 +0400 |
| commit | d608ffeeda1a479c9dc0e5f6ad269ba07a510d7f (patch) | |
| tree | 3824ec9da5ca0adac913cc47e3e20054c3dc843a /src/xray.rs | |
| parent | 40cf3276cdc74bf6f1795990ed600891a637d1fe (diff) | |
Implement Linux proxy controller, subscription state, and CLI/TUI
Diffstat (limited to 'src/xray.rs')
| -rw-r--r-- | src/xray.rs | 206 |
1 files changed, 206 insertions, 0 deletions
diff --git a/src/xray.rs b/src/xray.rs new file mode 100644 index 0000000..5ac8b62 --- /dev/null +++ b/src/xray.rs @@ -0,0 +1,206 @@ +use crate::model::Trojan; +use anyhow::{Context, Result, ensure}; +use serde_json::{Value, json}; +use std::{ + fs, + os::unix::fs::{MetadataExt, PermissionsExt}, + path::Path, + process::Stdio, + time::Duration, +}; +use tokio::process::Command; + +pub const RUNTIME_DIR: &str = "/run/roentgen"; + +pub fn identity() -> Result<(u32, u32)> { + let mut buffer = vec![0u8; 65536]; + let mut record = std::mem::MaybeUninit::<libc::passwd>::uninit(); + let mut result = std::ptr::null_mut(); + // SAFETY: the output record and buffer are writable and remain alive while + // the reentrant account lookup fills them. The returned IDs are copied. + let error = unsafe { + libc::getpwnam_r( + c"roentgen-xray".as_ptr(), + record.as_mut_ptr(), + buffer.as_mut_ptr().cast(), + buffer.len(), + &mut result, + ) + }; + ensure!( + error == 0 && !result.is_null(), + "Missing roentgen-xray service account; run roentgen install" + ); + let record = unsafe { record.assume_init() }; + ensure!( + record.pw_uid != 0 && record.pw_gid != 0, + "Xray must use a dedicated non-root account and group" + ); + Ok((record.pw_uid, record.pw_gid)) +} + +pub async fn preflight() -> Result<()> { + let metadata = + fs::metadata("/usr/bin/xray").context("Install ALT's current xray package first")?; + ensure!( + metadata.uid() == 0 && metadata.permissions().mode() & 0o022 == 0, + "System Xray must be root-owned and not writable by other users" + ); + let output = tokio::time::timeout( + Duration::from_secs(5), + Command::new("/usr/bin/xray").arg("version").output(), + ) + .await??; + ensure!( + output.status.success(), + "Could not read system Xray version" + ); + let version = String::from_utf8_lossy(&output.stdout); + let version = version.split_whitespace().nth(1).unwrap_or(""); + // Native TUN routing behavior is pinned to the researched release family. + ensure!( + matches!(version, "26.10.29"), + "Unsupported Xray release; this build requires validated Xray 26.10.29 capabilities" + ); + ensure!( + Path::new("/dev/net/tun").exists(), + "Linux TUN is unavailable" + ); + Ok(()) +} + +pub fn configuration(connection: &Trojan, tun: bool, probe: Option<(u16, &str)>) -> Value { + let mut inbounds = vec![]; + if tun { + inbounds.push(json!({"tag":"host-tun","protocol":"tun","settings":{"name":"roentgen0","MTU":1500,"gateway":["198.18.0.1/30","fdfe:726f:656e::1/126"],"autoSystemRoutingTable":[],"autoOutboundsInterface":"","autoSystemDnsToGateway":false}})); + } + if let Some((port, password)) = probe { + inbounds.push(json!({"tag":"probe","listen":"127.0.0.1","port":port,"protocol":"socks","settings":{"auth":"password","accounts":[{"user":"roentgen","pass":password}],"udp":false}})); + } + let mut tls = json!({"serverName":connection.server_name,"allowInsecure":false}); + if !connection.alpn.is_empty() { + tls["alpn"] = json!(connection.alpn); + } + if let Some(fingerprint) = &connection.fingerprint { + tls["fingerprint"] = json!(fingerprint); + } + json!({"log":{"loglevel":"none"},"inbounds":inbounds,"outbounds":[{"tag":"proxy","protocol":"trojan","settings":{"servers":[{"address":connection.host,"port":connection.port,"password":connection.password}]},"streamSettings":{"network":"tcp","security":"tls","tlsSettings":tls}}]}) +} + +pub fn write_config(path: &Path, value: &Value) -> Result<()> { + let (_, gid) = identity()?; + fs::create_dir_all(RUNTIME_DIR)?; + let directory = Path::new(RUNTIME_DIR); + fs::set_permissions(directory, fs::Permissions::from_mode(0o750))?; + std::os::unix::fs::chown(directory, Some(0), Some(gid))?; + crate::storage::atomic_private_write(path, &serde_json::to_vec(value)?)?; + std::os::unix::fs::chown(path, Some(0), Some(gid))?; + fs::set_permissions(path, fs::Permissions::from_mode(0o640))?; + Ok(()) +} + +pub async fn systemctl(args: &[&str]) -> Result<()> { + let status = tokio::time::timeout( + Duration::from_secs(30), + Command::new("/usr/bin/systemctl") + .args(args) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status(), + ) + .await??; + ensure!( + status.success(), + "Service operation failed; inspect systemctl status for Roentgen" + ); + Ok(()) +} + +pub struct Probe { + unit: String, + path: std::path::PathBuf, + pub proxy: String, + active: bool, +} +impl Probe { + pub async fn start(connection: &Trojan, duration: u64) -> Result<Self> { + preflight().await?; + let listener = std::net::TcpListener::bind("127.0.0.1:0")?; + let port = listener.local_addr()?.port(); + let token = uuid::Uuid::new_v4().simple().to_string(); + let unit = format!("roentgen-probe-{token}"); + let path = Path::new(RUNTIME_DIR).join(format!("{unit}.json")); + write_config( + &path, + &configuration(connection, false, Some((port, &token))), + )?; + drop(listener); + let runtime_limit = format!("RuntimeMaxSec={}", duration.saturating_add(10)); + let result = Command::new("/usr/bin/systemd-run") + .args([ + "--quiet", + "--collect", + "--unit", + &unit, + "--uid=roentgen-xray", + "--property=NoNewPrivileges=yes", + "--property=PrivateTmp=yes", + "--property=ProtectSystem=strict", + "--property=ProtectHome=yes", + "--property", + &runtime_limit, + "/usr/bin/xray", + "run", + "-config", + ]) + .arg(&path) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .await; + let probe = Self { + unit, + path, + proxy: format!("socks5h://roentgen:{token}@127.0.0.1:{port}"), + active: true, + }; + if !result?.success() { + probe.stop().await; + anyhow::bail!("Could not launch protected Xray probe"); + } + for _ in 0..50 { + if tokio::net::TcpStream::connect(("127.0.0.1", port)) + .await + .is_ok() + { + return Ok(probe); + } + tokio::time::sleep(Duration::from_millis(20)).await; + } + probe.stop().await; + anyhow::bail!("Protected Xray probe did not start") + } + pub async fn stop(mut self) { + self.active = false; + let _ = systemctl(&["stop", &self.unit]).await; + let _ = fs::remove_file(&self.path); + } +} + +impl Drop for Probe { + fn drop(&mut self) { + if !self.active { + return; + } + let unit = self.unit.clone(); + let path = self.path.clone(); + if let Ok(runtime) = tokio::runtime::Handle::try_current() { + runtime.spawn(async move { + let _ = systemctl(&["stop", &unit]).await; + let _ = fs::remove_file(path); + }); + } + // systemd RuntimeMaxSec also bounds the process after controller death. + } +} |