summaryrefslogtreecommitdiff
path: root/src/xray.rs
diff options
context:
space:
mode:
authorAndrew Guschin <guschin@altlinux.org>2026-10-30 03:10:41 +0400
committerAndrew Guschin <guschin@altlinux.org>2026-10-30 03:10:41 +0400
commitd608ffeeda1a479c9dc0e5f6ad269ba07a510d7f (patch)
tree3824ec9da5ca0adac913cc47e3e20054c3dc843a /src/xray.rs
parent40cf3276cdc74bf6f1795990ed600891a637d1fe (diff)
Implement Linux proxy controller, subscription state, and CLI/TUI
Diffstat (limited to 'src/xray.rs')
-rw-r--r--src/xray.rs206
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.
+ }
+}