summaryrefslogtreecommitdiff
path: root/src
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
parent40cf3276cdc74bf6f1795990ed600891a637d1fe (diff)
Implement Linux proxy controller, subscription state, and CLI/TUI
Diffstat (limited to 'src')
-rw-r--r--src/authentication.rs52
-rw-r--r--src/bus.rs357
-rw-r--r--src/controller.rs478
-rw-r--r--src/daemon.rs74
-rw-r--r--src/diagnostics.rs172
-rw-r--r--src/editor.rs26
-rw-r--r--src/installation.rs152
-rw-r--r--src/lib.rs16
-rw-r--r--src/linux/firewall.rs234
-rw-r--r--src/linux/mod.rs119
-rw-r--r--src/linux/routes.rs186
-rw-r--r--src/locking.rs35
-rw-r--r--src/main.rs244
-rw-r--r--src/model.rs147
-rw-r--r--src/runtime.rs34
-rw-r--r--src/storage.rs264
-rw-r--r--src/subscription.rs305
-rw-r--r--src/tui.rs565
-rw-r--r--src/xray.rs206
19 files changed, 3664 insertions, 2 deletions
diff --git a/src/authentication.rs b/src/authentication.rs
new file mode 100644
index 0000000..5312792
--- /dev/null
+++ b/src/authentication.rs
@@ -0,0 +1,52 @@
+use anyhow::{Context, Result};
+use std::{
+ io::Read,
+ os::fd::AsRawFd,
+ process::{Child, Command, Stdio},
+ time::Duration,
+};
+
+pub struct TerminalAgent(Option<Child>);
+impl TerminalAgent {
+ pub fn start(interactive: bool) -> Result<Self> {
+ if !interactive || unsafe { libc::geteuid() } == 0 {
+ return Ok(Self(None));
+ }
+ let (mut reader, writer) = std::os::unix::net::UnixStream::pair()?;
+ reader.set_read_timeout(Some(Duration::from_secs(5)))?;
+ let fd = writer.as_raw_fd();
+ // SAFETY: fd belongs to writer and remains open until after spawning the agent.
+ unsafe {
+ libc::fcntl(fd, libc::F_SETFD, 0);
+ }
+ let mut child = Command::new("/usr/bin/pkttyagent")
+ .args([
+ "--process",
+ &std::process::id().to_string(),
+ "--notify-fd",
+ &fd.to_string(),
+ "--fallback",
+ ])
+ .stdin(Stdio::inherit())
+ .stdout(Stdio::inherit())
+ .stderr(Stdio::inherit())
+ .spawn()
+ .context("Could not start terminal polkit agent; install polkit")?;
+ drop(writer);
+ let mut buffer = [0u8; 1];
+ if let Err(error) = reader.read(&mut buffer) {
+ let _ = child.kill();
+ let _ = child.wait();
+ return Err(error).context("Terminal authentication agent did not become ready");
+ }
+ Ok(Self(Some(child)))
+ }
+}
+impl Drop for TerminalAgent {
+ fn drop(&mut self) {
+ if let Some(child) = &mut self.0 {
+ let _ = child.kill();
+ let _ = child.wait();
+ }
+ }
+}
diff --git a/src/bus.rs b/src/bus.rs
new file mode 100644
index 0000000..fd26f1d
--- /dev/null
+++ b/src/bus.rs
@@ -0,0 +1,357 @@
+use crate::controller::{Caller, Controller, Operation};
+use anyhow::{Result, anyhow};
+use std::{collections::HashMap, sync::Arc};
+use tokio::sync::Mutex;
+use zbus::{Connection, message::Header, zvariant::Value};
+
+pub const NAME: &str = "org.roentgen.Controller1";
+pub const PATH: &str = "/org/roentgen/Controller1";
+pub const INTERFACE: &str = "org.roentgen.Controller1";
+pub type SharedController = Arc<Mutex<Controller>>;
+
+pub struct Service {
+ pub controller: SharedController,
+ pub cancellation: Arc<tokio::sync::Notify>,
+}
+
+async fn authorize(
+ connection: &Connection,
+ header: &Header<'_>,
+ interactive: bool,
+) -> zbus::fdo::Result<Caller> {
+ let sender = header
+ .sender()
+ .ok_or_else(|| zbus::fdo::Error::AccessDenied("Missing bus sender".into()))?;
+ let bus = zbus::fdo::DBusProxy::new(connection).await?;
+ let uid = bus.get_connection_unix_user(sender.clone().into()).await?;
+ if uid == 0 {
+ return Ok(Caller::authorized());
+ }
+ let authority = zbus::Proxy::new(
+ connection,
+ "org.freedesktop.PolicyKit1",
+ "/org/freedesktop/PolicyKit1/Authority",
+ "org.freedesktop.PolicyKit1.Authority",
+ )
+ .await
+ .map_err(|_| zbus::fdo::Error::AccessDenied("Polkit is unavailable".into()))?;
+ let mut subject = HashMap::new();
+ subject.insert("name", Value::from(sender.as_str()));
+ let result: (bool, bool, HashMap<String, String>) = authority
+ .call(
+ "CheckAuthorization",
+ &(
+ ("system-bus-name", subject),
+ "org.roentgen.control",
+ HashMap::<String, String>::new(),
+ u32::from(interactive),
+ "",
+ ),
+ )
+ .await
+ .map_err(|_| zbus::fdo::Error::AccessDenied("Authorization denied or cancelled".into()))?;
+ if !result.0 {
+ return Err(zbus::fdo::Error::AccessDenied(
+ "Administrator authorization required; no state changed".into(),
+ ));
+ }
+ Ok(Caller::authorized())
+}
+
+fn failure(error: anyhow::Error) -> zbus::fdo::Error {
+ zbus::fdo::Error::Failed(error.to_string())
+}
+fn json<T: serde::Serialize>(value: T) -> zbus::fdo::Result<String> {
+ serde_json::to_string(&value)
+ .map_err(|_| zbus::fdo::Error::Failed("Could not encode response".into()))
+}
+
+impl Service {
+ async fn change(
+ &self,
+ operation: Operation,
+ interactive: bool,
+ connection: &Connection,
+ header: &Header<'_>,
+ ) -> zbus::fdo::Result<()> {
+ let caller = authorize(connection, header, interactive).await?;
+ self.controller
+ .lock()
+ .await
+ .execute(&caller, operation)
+ .await
+ .map_err(failure)
+ }
+}
+
+#[zbus::interface(name = "org.roentgen.Controller1")]
+impl Service {
+ async fn status(&self) -> zbus::fdo::Result<String> {
+ json(self.controller.lock().await.status().map_err(failure)?)
+ }
+ async fn subscriptions(&self) -> zbus::fdo::Result<String> {
+ json(self.controller.lock().await.subscriptions())
+ }
+ async fn configurations(&self, subscription: &str) -> zbus::fdo::Result<String> {
+ json(
+ self.controller
+ .lock()
+ .await
+ .configurations((!subscription.is_empty()).then_some(subscription))
+ .map_err(failure)?,
+ )
+ }
+ async fn logs(&self) -> zbus::fdo::Result<String> {
+ json(self.controller.lock().await.logs())
+ }
+ async fn diagnostic(
+ &self,
+ id: &str,
+ speed: bool,
+ interactive: bool,
+ #[zbus(connection)] connection: &Connection,
+ #[zbus(header)] header: Header<'_>,
+ ) -> zbus::fdo::Result<String> {
+ let caller = authorize(connection, &header, interactive).await?;
+ let mut controller = self.controller.lock().await;
+ let cancellation = self.cancellation.notified();
+ tokio::pin!(cancellation);
+ cancellation.as_mut().enable();
+ tokio::select! {
+ result = controller.diagnostic(&caller, (!id.is_empty()).then_some(id), speed) => json(result.map_err(failure)?),
+ _ = cancellation => Err(zbus::fdo::Error::Failed("Diagnostic cancelled; proxy intent is unchanged".into())),
+ }
+ }
+ async fn cancel_diagnostic(
+ &self,
+ interactive: bool,
+ #[zbus(connection)] connection: &Connection,
+ #[zbus(header)] header: Header<'_>,
+ ) -> zbus::fdo::Result<()> {
+ let _caller = authorize(connection, &header, interactive).await?;
+ self.cancellation.notify_waiters();
+ Ok(())
+ }
+ async fn settings(&self) -> zbus::fdo::Result<String> {
+ json(self.controller.lock().await.settings())
+ }
+ async fn add_subscription(
+ &self,
+ name: &str,
+ url: &str,
+ interactive: bool,
+ #[zbus(connection)] connection: &Connection,
+ #[zbus(header)] header: Header<'_>,
+ ) -> zbus::fdo::Result<()> {
+ self.change(
+ Operation::AddSubscription {
+ name: name.into(),
+ url: url.into(),
+ },
+ interactive,
+ connection,
+ &header,
+ )
+ .await
+ }
+ async fn edit_subscription(
+ &self,
+ name: &str,
+ url: &str,
+ interactive: bool,
+ #[zbus(connection)] connection: &Connection,
+ #[zbus(header)] header: Header<'_>,
+ ) -> zbus::fdo::Result<()> {
+ self.change(
+ Operation::EditSubscription {
+ name: name.into(),
+ url: url.into(),
+ },
+ interactive,
+ connection,
+ &header,
+ )
+ .await
+ }
+ async fn delete_subscription(
+ &self,
+ name: &str,
+ interactive: bool,
+ #[zbus(connection)] connection: &Connection,
+ #[zbus(header)] header: Header<'_>,
+ ) -> zbus::fdo::Result<()> {
+ self.change(
+ Operation::DeleteSubscription { name: name.into() },
+ interactive,
+ connection,
+ &header,
+ )
+ .await
+ }
+ async fn refresh(
+ &self,
+ name: &str,
+ interactive: bool,
+ #[zbus(connection)] connection: &Connection,
+ #[zbus(header)] header: Header<'_>,
+ ) -> zbus::fdo::Result<()> {
+ self.change(
+ Operation::Refresh {
+ name: (!name.is_empty()).then(|| name.into()),
+ },
+ interactive,
+ connection,
+ &header,
+ )
+ .await
+ }
+ async fn select(
+ &self,
+ id: &str,
+ interactive: bool,
+ #[zbus(connection)] connection: &Connection,
+ #[zbus(header)] header: Header<'_>,
+ ) -> zbus::fdo::Result<()> {
+ self.change(
+ Operation::Select { id: id.into() },
+ interactive,
+ connection,
+ &header,
+ )
+ .await
+ }
+ async fn unset(
+ &self,
+ interactive: bool,
+ #[zbus(connection)] connection: &Connection,
+ #[zbus(header)] header: Header<'_>,
+ ) -> zbus::fdo::Result<()> {
+ self.change(Operation::Unset, interactive, connection, &header)
+ .await
+ }
+ async fn enable(
+ &self,
+ interactive: bool,
+ #[zbus(connection)] connection: &Connection,
+ #[zbus(header)] header: Header<'_>,
+ ) -> zbus::fdo::Result<()> {
+ self.change(Operation::Enable, interactive, connection, &header)
+ .await
+ }
+ async fn disable(
+ &self,
+ interactive: bool,
+ #[zbus(connection)] connection: &Connection,
+ #[zbus(header)] header: Header<'_>,
+ ) -> zbus::fdo::Result<()> {
+ self.change(Operation::Disable, interactive, connection, &header)
+ .await
+ }
+ async fn save_settings(
+ &self,
+ value: &str,
+ interactive: bool,
+ #[zbus(connection)] connection: &Connection,
+ #[zbus(header)] header: Header<'_>,
+ ) -> zbus::fdo::Result<()> {
+ let settings = serde_json::from_str(value)
+ .map_err(|_| zbus::fdo::Error::InvalidArgs("Invalid settings".into()))?;
+ self.change(
+ Operation::SaveSettings { settings },
+ interactive,
+ connection,
+ &header,
+ )
+ .await
+ }
+}
+
+pub struct Client {
+ connection: Connection,
+}
+impl Client {
+ pub async fn system() -> Result<Self> {
+ Ok(Self {
+ connection: Connection::system()
+ .await
+ .map_err(|_| anyhow!("Cannot connect to the system bus"))?,
+ })
+ }
+ pub fn from_connection(connection: Connection) -> Self {
+ Self { connection }
+ }
+ async fn proxy(&self) -> Result<zbus::Proxy<'_>> {
+ zbus::Proxy::new(&self.connection, NAME, PATH, INTERFACE)
+ .await
+ .map_err(|_| anyhow!("Cannot contact Roentgen; install and start roentgen.service"))
+ }
+ pub async fn read<T: serde::de::DeserializeOwned>(&self, method: &str) -> Result<T> {
+ let value: String = self.proxy().await?.call(method, &()).await?;
+ Ok(serde_json::from_str(&value)?)
+ }
+ pub async fn configurations(
+ &self,
+ name: Option<&str>,
+ ) -> Result<Vec<crate::model::ConfigurationSummary>> {
+ let value: String = self
+ .proxy()
+ .await?
+ .call("Configurations", &(name.unwrap_or(""),))
+ .await?;
+ Ok(serde_json::from_str(&value)?)
+ }
+ pub async fn diagnostic(
+ &self,
+ id: Option<&str>,
+ speed: bool,
+ interactive: bool,
+ ) -> Result<String> {
+ let proxy = self.proxy().await?;
+ let arguments = (id.unwrap_or(""), speed, interactive);
+ tokio::select! {
+ result = proxy.call("Diagnostic", &arguments) => Ok(result?),
+ _ = tokio::signal::ctrl_c() => {
+ let _: () = proxy.call("CancelDiagnostic", &(interactive,)).await?;
+ anyhow::bail!("Diagnostic cancelled; proxy intent is unchanged")
+ }
+ }
+ }
+ pub async fn change(&self, operation: Operation, interactive: bool) -> Result<()> {
+ let proxy = self.proxy().await?;
+ match operation {
+ Operation::AddSubscription { name, url } => {
+ proxy
+ .call("AddSubscription", &(name, url, interactive))
+ .await?
+ }
+ Operation::EditSubscription { name, url } => {
+ proxy
+ .call("EditSubscription", &(name, url, interactive))
+ .await?
+ }
+ Operation::DeleteSubscription { name } => {
+ proxy
+ .call("DeleteSubscription", &(name, interactive))
+ .await?
+ }
+ Operation::Refresh { name } => {
+ proxy
+ .call("Refresh", &(name.unwrap_or_default(), interactive))
+ .await?
+ }
+ Operation::Select { id } => proxy.call("Select", &(id, interactive)).await?,
+ Operation::Unset => proxy.call("Unset", &(interactive,)).await?,
+ Operation::Enable => proxy.call("Enable", &(interactive,)).await?,
+ Operation::Disable => proxy.call("Disable", &(interactive,)).await?,
+ Operation::SaveSettings { settings } => {
+ proxy
+ .call(
+ "SaveSettings",
+ &(serde_json::to_string(&settings)?, interactive),
+ )
+ .await?
+ }
+ }
+ Ok(())
+ }
+}
diff --git a/src/controller.rs b/src/controller.rs
new file mode 100644
index 0000000..1384ad8
--- /dev/null
+++ b/src/controller.rs
@@ -0,0 +1,478 @@
+use crate::{
+ model::*,
+ runtime::Runtime,
+ storage::Storage,
+ subscription::{SubscriptionSource, import, subscription_url},
+};
+use anyhow::{Result, anyhow, ensure};
+use serde::{Deserialize, Serialize};
+use std::path::Path;
+use uuid::Uuid;
+
+/// Constructed by the transport after resolving the real bus sender and checking polkit.
+pub struct Caller {
+ authorized: bool,
+}
+impl Caller {
+ pub fn root() -> Self {
+ Self { authorized: true }
+ }
+ pub fn denied() -> Self {
+ Self { authorized: false }
+ }
+ pub(crate) fn authorized() -> Self {
+ Self { authorized: true }
+ }
+}
+
+#[derive(Clone, Serialize, Deserialize)]
+pub enum Operation {
+ AddSubscription { name: String, url: String },
+ EditSubscription { name: String, url: String },
+ DeleteSubscription { name: String },
+ Refresh { name: Option<String> },
+ Select { id: String },
+ Unset,
+ Enable,
+ Disable,
+ SaveSettings { settings: Settings },
+}
+
+pub struct Controller {
+ storage: Storage,
+ runtime: Box<dyn Runtime>,
+ source: Box<dyn SubscriptionSource>,
+ last_error: Option<String>,
+ observation: Option<crate::diagnostics::Observation>,
+ logs: std::collections::VecDeque<String>,
+ next_check: Option<tokio::time::Instant>,
+ measurements: Vec<crate::diagnostics::Observation>,
+}
+
+impl Controller {
+ pub fn open(
+ root: &Path,
+ runtime: Box<dyn Runtime>,
+ source: Box<dyn SubscriptionSource>,
+ ) -> Result<Self> {
+ Self::open_paths(&root.join("etc"), &root.join("data"), runtime, source)
+ }
+
+ pub fn open_paths(
+ config: &Path,
+ data: &Path,
+ runtime: Box<dyn Runtime>,
+ source: Box<dyn SubscriptionSource>,
+ ) -> Result<Self> {
+ Ok(Self {
+ storage: Storage::open(config, data)?,
+ runtime,
+ source,
+ last_error: None,
+ observation: None,
+ logs: std::collections::VecDeque::new(),
+ next_check: Some(tokio::time::Instant::now()),
+ measurements: vec![],
+ })
+ }
+
+ pub fn status(&self) -> Result<Status> {
+ let (requested_enabled, selected_configuration, generation) = self.storage.state()?;
+ Ok(Status {
+ version: 1,
+ requested_enabled,
+ selected_configuration,
+ generation,
+ routing: self.runtime.routing().into(),
+ health: self
+ .observation
+ .as_ref()
+ .filter(|o| o.generation == generation)
+ .map(|o| {
+ if o.success {
+ "reachable"
+ } else {
+ "check-failed"
+ }
+ })
+ .unwrap_or("unknown")
+ .into(),
+ last_error: self.last_error.clone(),
+ })
+ }
+
+ pub fn subscriptions(&self) -> Vec<SubscriptionSummary> {
+ self.storage
+ .definitions
+ .subscriptions
+ .iter()
+ .map(|s| SubscriptionSummary {
+ id: s.id.clone(),
+ name: s.name.clone(),
+ })
+ .collect()
+ }
+
+ pub fn settings(&self) -> Settings {
+ self.storage.definitions.settings.clone()
+ }
+
+ pub fn configurations(&self, name: Option<&str>) -> Result<Vec<ConfigurationSummary>> {
+ let (_, selected, _) = self.storage.state()?;
+ if let Some(name) = name {
+ self.subscription(name)?;
+ }
+ let mut rows = vec![];
+ for subscription in &self.storage.definitions.subscriptions {
+ if name.is_some_and(|name| name != subscription.name) {
+ continue;
+ }
+ for entry in self.storage.entries(&subscription.id)? {
+ let ping = self
+ .measurements
+ .iter()
+ .find(|o| o.configuration_id == entry.id && o.kind == "ping" && o.success);
+ let speed = self
+ .measurements
+ .iter()
+ .find(|o| o.configuration_id == entry.id && o.kind == "speed" && o.success);
+ rows.push(ConfigurationSummary {
+ ping_ms: ping.map(|o| o.elapsed_ms),
+ speed_bytes_per_second: speed.and_then(|o| o.bytes_per_second),
+ selected: Some(&entry.id) == selected.as_ref(),
+ id: entry.id,
+ subscription_id: subscription.id.clone(),
+ name: entry.name,
+ host: entry.connection.as_ref().map(|c| c.host.clone()),
+ port: entry.connection.as_ref().map(|c| c.port),
+ status: entry.status,
+ explanation: entry.explanation,
+ });
+ }
+ }
+ Ok(rows)
+ }
+
+ pub async fn restore(&mut self) -> Result<()> {
+ if self.storage.state()?.0 {
+ self.runtime.arm().await?;
+ self.reconcile().await?;
+ } else {
+ self.runtime.disable().await?;
+ }
+ Ok(())
+ }
+
+ pub async fn execute(&mut self, caller: &Caller, operation: Operation) -> Result<()> {
+ ensure!(
+ caller.authorized,
+ "Administrator authorization required; no state changed"
+ );
+ self.last_error = None;
+ self.record("Authorized controller operation".into());
+ match operation {
+ Operation::AddSubscription { name, url } => {
+ validate_name(&name)?;
+ subscription_url(&url)?;
+ ensure!(
+ !self
+ .storage
+ .definitions
+ .subscriptions
+ .iter()
+ .any(|s| s.name == name),
+ "A subscription with this name already exists"
+ );
+ let entries = import(&self.source.fetch(&url).await?)?;
+ let subscription = Subscription {
+ id: Uuid::new_v4().to_string(),
+ name,
+ url,
+ };
+ // Orphan snapshots after a failed TOML write are removed on next startup.
+ self.storage.replace_entries(&subscription.id, &entries)?;
+ let mut definitions = self.storage.definitions.clone();
+ definitions.subscriptions.push(subscription);
+ self.storage.save_definitions(&definitions)?;
+ self.storage.definitions = definitions;
+ }
+ Operation::EditSubscription { name, url } => {
+ subscription_url(&url)?;
+ let id = self.subscription(&name)?.id.clone();
+ let mut definitions = self.storage.definitions.clone();
+ definitions
+ .subscriptions
+ .iter_mut()
+ .find(|s| s.id == id)
+ .unwrap()
+ .url = url;
+ self.storage.save_definitions(&definitions)?;
+ self.storage.definitions = definitions;
+ }
+ Operation::DeleteSubscription { name } => {
+ let id = self.subscription(&name)?.id.clone();
+ let mut definitions = self.storage.definitions.clone();
+ definitions.subscriptions.retain(|s| s.id != id);
+ self.storage.save_definitions(&definitions)?;
+ self.storage.definitions = definitions;
+ self.storage.delete_entries(&id)?;
+ self.reconcile().await?;
+ }
+ Operation::Refresh { name } => {
+ let subscriptions = if let Some(name) = name {
+ vec![self.subscription(&name)?.clone()]
+ } else {
+ self.storage.definitions.subscriptions.clone()
+ };
+ let mut failures = 0;
+ for subscription in subscriptions {
+ let result = self.refresh(&subscription).await;
+ if let Err(error) = result {
+ failures += 1;
+ self.last_error = Some(error.to_string());
+ }
+ }
+ ensure!(
+ failures == 0,
+ "{} subscription update(s) failed; {}",
+ failures,
+ self.last_error.as_deref().unwrap_or("retry update")
+ );
+ }
+ Operation::Select { id } => {
+ self.usable_entry(&id)?;
+ self.storage.select(Some(&id))?;
+ self.reconcile().await?;
+ }
+ Operation::Unset => {
+ self.storage.select(None)?;
+ self.reconcile().await?;
+ }
+ Operation::Enable => {
+ let (_, selected, _) = self.storage.state()?;
+ let id = selected
+ .ok_or_else(|| anyhow!("Select a supported configuration before enabling"))?;
+ self.usable_entry(&id)?;
+ self.runtime.arm().await?;
+ self.storage.set_intent(true)?;
+ self.reconcile().await?;
+ }
+ Operation::Disable => {
+ self.storage.set_intent(false)?;
+ self.runtime.disable().await?;
+ }
+ Operation::SaveSettings { settings } => {
+ settings.validate()?;
+ let mut definitions = self.storage.definitions.clone();
+ definitions.settings = settings;
+ self.storage.save_definitions(&definitions)?;
+ self.storage.definitions = definitions;
+ }
+ }
+ Ok(())
+ }
+
+ fn record(&mut self, message: String) {
+ self.logs.push_back(message);
+ if self.logs.len() > 200 {
+ self.logs.pop_front();
+ }
+ }
+ pub fn report_runtime_error(&mut self, error: &anyhow::Error) {
+ self.last_error = Some(error.to_string());
+ self.record(error.to_string());
+ }
+ pub fn logs(&self) -> Vec<String> {
+ self.logs.iter().cloned().collect()
+ }
+
+ pub async fn diagnostic(
+ &mut self,
+ caller: &Caller,
+ id: Option<&str>,
+ speed: bool,
+ ) -> Result<crate::diagnostics::Observation> {
+ ensure!(
+ caller.authorized,
+ "Administrator authorization required; no state changed"
+ );
+ let (_, selected, generation) = self.storage.state()?;
+ let id = id
+ .or(selected.as_deref())
+ .ok_or_else(|| anyhow!("Select a configuration or supply its ID before testing"))?;
+ let entry = self.usable_entry(id)?;
+ let settings = self.settings();
+ let probe = crate::xray::Probe::start(
+ entry.connection.as_ref().unwrap(),
+ if speed {
+ settings.maximum_duration_seconds
+ } else {
+ 5
+ },
+ )
+ .await?;
+ let result =
+ crate::diagnostics::measure(&settings, &probe.proxy, id.into(), generation, speed)
+ .await;
+ probe.stop().await;
+ match result {
+ Ok(observation) => {
+ if !speed && Some(id) == selected.as_deref() {
+ let wait = observation.retry_after_seconds.unwrap_or(30).max(30);
+ self.next_check = tokio::time::Instant::now()
+ .checked_add(std::time::Duration::from_secs(wait));
+ self.observation = Some(observation.clone());
+ }
+ self.measurements
+ .retain(|o| o.configuration_id != id || o.kind != observation.kind);
+ self.measurements.push(observation.clone());
+ self.record(format!(
+ "{} observation: {}",
+ if speed { "Speed" } else { "Ping" },
+ observation.stop_reason
+ ));
+ Ok(observation)
+ }
+ Err(error) => {
+ if !speed && Some(id) == selected.as_deref() {
+ self.observation = Some(crate::diagnostics::Observation {
+ configuration_id: id.into(),
+ generation,
+ kind: "ping".into(),
+ success: false,
+ elapsed_ms: 0,
+ bytes: 0,
+ bytes_per_second: None,
+ stop_reason: error.to_string(),
+ retry_after_seconds: None,
+ });
+ }
+ self.record(error.to_string());
+ Err(error)
+ }
+ }
+ }
+
+ pub async fn scheduled_check(&mut self) -> Result<()> {
+ let (enabled, selected, generation) = self.storage.state()?;
+ if enabled {
+ let entry = selected
+ .as_deref()
+ .map(|id| self.usable_entry(id))
+ .transpose()?;
+ self.runtime
+ .maintain(
+ entry.as_ref().and_then(|e| e.connection.as_ref()),
+ generation,
+ )
+ .await?;
+ }
+ if selected.is_some()
+ && self
+ .next_check
+ .is_some_and(|deadline| tokio::time::Instant::now() >= deadline)
+ {
+ self.next_check =
+ Some(tokio::time::Instant::now() + std::time::Duration::from_secs(30));
+ self.diagnostic(&Caller::root(), None, false).await?;
+ }
+ Ok(())
+ }
+
+ async fn refresh(&mut self, subscription: &Subscription) -> Result<()> {
+ let mut entries = import(&self.source.fetch(&subscription.url).await?)?;
+ let old = self.storage.entries(&subscription.id)?;
+ // Match normalized content, then a unique endpoint/authentication tuple, never labels or positions.
+ let mut matched_old = std::collections::HashSet::new();
+ let mut matched_new = std::collections::HashSet::new();
+ for exact in [true, false] {
+ for i in 0..entries.len() {
+ if matched_new.contains(&i) {
+ continue;
+ }
+ let Some(connection) = entries[i].connection.as_ref() else {
+ continue;
+ };
+ let matches = |candidate: &Trojan| {
+ if exact {
+ candidate == connection
+ } else {
+ candidate.identity() == connection.identity()
+ }
+ };
+ let candidates: Vec<_> = old
+ .iter()
+ .enumerate()
+ .filter(|(j, old)| {
+ !matched_old.contains(j) && old.connection.as_ref().is_some_and(matches)
+ })
+ .collect();
+ let new_count = entries
+ .iter()
+ .enumerate()
+ .filter(|(j, e)| {
+ !matched_new.contains(j) && e.connection.as_ref().is_some_and(matches)
+ })
+ .count();
+ if candidates.len() == 1 && new_count == 1 {
+ entries[i].id = candidates[0].1.id.clone();
+ matched_old.insert(candidates[0].0);
+ matched_new.insert(i);
+ }
+ }
+ }
+ let affected: std::collections::HashSet<_> = old.iter().map(|e| e.id.as_str()).collect();
+ self.measurements
+ .retain(|o| !affected.contains(o.configuration_id.as_str()));
+ self.storage.replace_entries(&subscription.id, &entries)?;
+ self.reconcile().await
+ }
+
+ fn subscription(&self, name: &str) -> Result<&Subscription> {
+ self.storage
+ .definitions
+ .subscriptions
+ .iter()
+ .find(|s| s.name == name)
+ .ok_or_else(|| anyhow!("Subscription not found; run subscription list"))
+ }
+
+ pub fn usable_entry(&self, id: &str) -> Result<Entry> {
+ let entry = self
+ .storage
+ .find_entry(id)?
+ .ok_or_else(|| anyhow!("Configuration not found; run configuration list"))?;
+ ensure!(
+ entry.connection.is_some(),
+ "Configuration is invalid or unsupported; inspect its explanation"
+ );
+ Ok(entry)
+ }
+
+ async fn reconcile(&mut self) -> Result<()> {
+ let (enabled, selected, generation) = self.storage.state()?;
+ if enabled {
+ let entry = selected.map(|id| self.usable_entry(&id)).transpose()?;
+ if let Err(error) = self
+ .runtime
+ .apply(
+ entry.as_ref().and_then(|e| e.connection.as_ref()),
+ generation,
+ )
+ .await
+ {
+ self.last_error = Some(error.to_string());
+ return Err(error);
+ }
+ }
+ Ok(())
+ }
+}
+
+fn validate_name(name: &str) -> Result<()> {
+ ensure!(
+ !name.trim().is_empty() && name.len() <= 128 && !name.chars().any(char::is_control),
+ "Subscription name must be 1-128 bytes without control characters"
+ );
+ Ok(())
+}
diff --git a/src/daemon.rs b/src/daemon.rs
new file mode 100644
index 0000000..f8588ca
--- /dev/null
+++ b/src/daemon.rs
@@ -0,0 +1,74 @@
+use crate::{
+ bus::{NAME, PATH, Service},
+ controller::Controller,
+ runtime::Runtime,
+ subscription::HttpSource,
+};
+use anyhow::{Result, ensure};
+use std::{path::Path, sync::Arc, time::Duration};
+use tokio::sync::Mutex;
+
+pub fn runtime() -> Result<Box<dyn Runtime>> {
+ #[cfg(feature = "linux-runtime")]
+ {
+ Ok(Box::new(crate::linux::LinuxRuntime::default()))
+ }
+ #[cfg(not(feature = "linux-runtime"))]
+ {
+ anyhow::bail!("This build has no Linux runtime; rebuild with --features linux-runtime")
+ }
+}
+
+pub async fn run() -> Result<()> {
+ ensure!(
+ unsafe { libc::geteuid() } == 0,
+ "The controller must run as a root-owned system service"
+ );
+ let _lock = crate::locking::controller_lock()?;
+ let mut controller = Controller::open_paths(
+ Path::new("/etc/roentgen"),
+ Path::new("/var/lib/roentgen"),
+ runtime()?,
+ Box::new(HttpSource),
+ )?;
+ if let Err(error) = controller.restore().await {
+ controller.report_runtime_error(&error);
+ }
+ let controller = Arc::new(Mutex::new(controller));
+ let _connection = zbus::connection::Builder::system()?
+ .name(NAME)?
+ .serve_at(
+ PATH,
+ Service {
+ controller: controller.clone(),
+ cancellation: Arc::new(tokio::sync::Notify::new()),
+ },
+ )?
+ .build()
+ .await?;
+ let mut interval = tokio::time::interval(Duration::from_secs(5));
+ interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
+ loop {
+ tokio::select! {
+ _ = tokio::signal::ctrl_c() => break,
+ _ = interval.tick() => {
+ let mut controller = controller.lock().await;
+ if let Err(error) = controller.scheduled_check().await { controller.report_runtime_error(&error); tracing::warn!("{error}"); }
+ }
+ }
+ }
+ // Exiting the controller never releases enabled protection or stops the core.
+ Ok(())
+}
+
+pub async fn guard() -> Result<()> {
+ ensure!(unsafe { libc::geteuid() } == 0, "The guard requires root");
+ #[cfg(feature = "linux-runtime")]
+ {
+ crate::linux::restore_guard()
+ }
+ #[cfg(not(feature = "linux-runtime"))]
+ {
+ anyhow::bail!("Rebuild with --features linux-runtime")
+ }
+}
diff --git a/src/diagnostics.rs b/src/diagnostics.rs
new file mode 100644
index 0000000..a2052ec
--- /dev/null
+++ b/src/diagnostics.rs
@@ -0,0 +1,172 @@
+use crate::model::Settings;
+use anyhow::{Result, anyhow, ensure};
+use futures_util::TryStreamExt;
+use serde::{Deserialize, Serialize};
+use std::time::{Duration, Instant};
+use tokio::io::AsyncReadExt;
+
+#[derive(Clone, Debug, Serialize, Deserialize)]
+pub struct Observation {
+ pub configuration_id: String,
+ pub generation: u64,
+ pub kind: String,
+ pub success: bool,
+ pub elapsed_ms: u64,
+ pub bytes: u64,
+ pub bytes_per_second: Option<f64>,
+ pub stop_reason: String,
+ pub retry_after_seconds: Option<u64>,
+}
+
+/// The caller supplies an authenticated, service-owned SOCKS endpoint. This client
+/// never inherits environment proxies or creates a direct route to the target.
+pub async fn measure(
+ settings: &Settings,
+ proxy: &str,
+ id: String,
+ generation: u64,
+ speed: bool,
+) -> Result<Observation> {
+ settings.validate()?;
+ let started = Instant::now();
+ let duration = if speed {
+ settings.maximum_duration_seconds
+ } else {
+ 5
+ };
+ let deadline = tokio::time::Instant::now() + Duration::from_secs(duration);
+ let cap = if speed {
+ settings.maximum_download_mib * 1_048_576
+ } else {
+ 4096
+ };
+ let proxy =
+ reqwest::Proxy::all(proxy).map_err(|_| anyhow!("Invalid protected probe endpoint"))?;
+ let client = reqwest::Client::builder()
+ .no_proxy()
+ .proxy(proxy)
+ .redirect(reqwest::redirect::Policy::none())
+ .timeout(Duration::from_secs(duration))
+ .user_agent(concat!("Roentgen/", env!("CARGO_PKG_VERSION")))
+ .build()
+ .map_err(|_| anyhow!("Could not initialize diagnostic TLS client"))?;
+ let url = if speed {
+ &settings.speed_url
+ } else {
+ &settings.ping_url
+ };
+ let response = tokio::time::timeout_at(deadline, client.get(url).send())
+ .await
+ .map_err(|_| anyhow!("Diagnostic request reached its deadline"))?
+ .map_err(|_| anyhow!("Proxied HTTPS request failed"))?;
+ if response.status().as_u16() == 429 || response.status().as_u16() == 503 {
+ let retry = response
+ .headers()
+ .get("Retry-After")
+ .and_then(|v| v.to_str().ok())
+ .and_then(|v| {
+ v.parse::<u64>().ok().or_else(|| {
+ httpdate::parse_http_date(v)
+ .ok()
+ .and_then(|date| date.duration_since(std::time::SystemTime::now()).ok())
+ .map(|d| d.as_secs().saturating_add(1))
+ })
+ })
+ .unwrap_or(30)
+ .max(30);
+ return Ok(Observation {
+ configuration_id: id,
+ generation,
+ kind: if speed { "speed" } else { "ping" }.into(),
+ success: false,
+ elapsed_ms: started.elapsed().as_millis() as u64,
+ bytes: 0,
+ bytes_per_second: None,
+ stop_reason: "provider-rate-limit".into(),
+ retry_after_seconds: Some(retry),
+ });
+ }
+ ensure!(
+ response.status().as_u16() == 200 || !speed && response.status().as_u16() == 204,
+ "Diagnostic endpoint returned an unexpected HTTP status"
+ );
+ let content_type = response
+ .headers()
+ .get("Content-Type")
+ .and_then(|v| v.to_str().ok())
+ .unwrap_or("")
+ .to_ascii_lowercase();
+ ensure!(
+ !content_type.contains("html"),
+ "Diagnostic endpoint returned HTML"
+ );
+ let status = response.status().as_u16();
+ let stream = response.bytes_stream().map_err(std::io::Error::other);
+ let mut reader = tokio_util::io::StreamReader::new(stream);
+ let mut buffer = [0u8; 16384];
+ let mut prefix = Vec::new();
+ let mut bytes = 0;
+ let reason;
+ loop {
+ if speed && bytes == cap {
+ reason = "byte-limit";
+ break;
+ }
+ let remaining = if speed {
+ (cap - bytes).min(buffer.len() as u64) as usize
+ } else {
+ buffer.len().min((cap + 1 - bytes) as usize)
+ };
+ let chunk =
+ match tokio::time::timeout_at(deadline, reader.read(&mut buffer[..remaining])).await {
+ Err(_) if speed && bytes > 0 => {
+ reason = "duration-limit";
+ break;
+ }
+ Err(_) => return Err(anyhow!("Diagnostic response timed out")),
+ Ok(Err(_)) => {
+ return Err(anyhow!(
+ "Diagnostic response failed before clean completion"
+ ));
+ }
+ Ok(Ok(value)) => value,
+ };
+ if chunk == 0 {
+ reason = "complete";
+ break;
+ }
+ if prefix.len() < 512 {
+ prefix.extend_from_slice(&buffer[..chunk.min(512 - prefix.len())]);
+ }
+ bytes += chunk as u64;
+ if !speed {
+ ensure!(bytes <= cap, "Ping body exceeds 4 KiB");
+ }
+ }
+ let prefix = String::from_utf8_lossy(&prefix)
+ .trim_start()
+ .to_ascii_lowercase();
+ ensure!(
+ !prefix.starts_with("<!doctype html") && !prefix.starts_with("<html"),
+ "Diagnostic endpoint returned an HTML document"
+ );
+ ensure!(!speed || bytes > 0, "Speed response contained no data");
+ if !speed && settings.ping_url == Settings::default().ping_url {
+ ensure!(
+ bytes == 0 && status == 200,
+ "Default Ping must complete with HTTP 200 and an empty body"
+ );
+ }
+ let elapsed = started.elapsed();
+ Ok(Observation {
+ configuration_id: id,
+ generation,
+ kind: if speed { "speed" } else { "ping" }.into(),
+ success: true,
+ elapsed_ms: elapsed.as_millis() as u64,
+ bytes,
+ bytes_per_second: speed.then(|| bytes as f64 / elapsed.as_secs_f64().max(0.000_001)),
+ stop_reason: reason.into(),
+ retry_after_seconds: None,
+ })
+}
diff --git a/src/editor.rs b/src/editor.rs
new file mode 100644
index 0000000..3207fb9
--- /dev/null
+++ b/src/editor.rs
@@ -0,0 +1,26 @@
+use anyhow::{Context, Result, ensure};
+use std::{io::Write, process::Command};
+
+pub fn open(lines: &[String]) -> Result<()> {
+ let editor = std::env::var("EDITOR").unwrap_or_else(|_| "vi".into());
+ let args = shell_words::split(&editor).context("Invalid quoting in EDITOR")?;
+ ensure!(
+ !args.is_empty(),
+ "EDITOR is empty; configure an editor command"
+ );
+ let mut snapshot = tempfile::Builder::new()
+ .prefix("roentgen-logs-")
+ .suffix(".txt")
+ .tempfile()?;
+ for line in lines {
+ writeln!(snapshot, "{}", crate::model::display_text(line))?;
+ }
+ snapshot.flush()?;
+ let status = Command::new(&args[0])
+ .args(&args[1..])
+ .arg(snapshot.path())
+ .status()
+ .context("Could not launch EDITOR")?;
+ ensure!(status.success(), "Editor exited unsuccessfully");
+ Ok(())
+}
diff --git a/src/installation.rs b/src/installation.rs
new file mode 100644
index 0000000..bc86db6
--- /dev/null
+++ b/src/installation.rs
@@ -0,0 +1,152 @@
+use crate::{
+ controller::Controller,
+ storage::{atomic_private_write, private_directory},
+ subscription::HttpSource,
+ xray::systemctl,
+};
+use anyhow::{Result, ensure};
+use std::{fs, path::Path, process::Stdio};
+use tokio::process::Command;
+
+const SERVICE: &str = include_str!("../assets/roentgen.service");
+const GUARD: &str = include_str!("../assets/roentgen-guard.service");
+const XRAY: &str = include_str!("../assets/roentgen-xray.service");
+const BUS: &str = include_str!("../assets/org.roentgen.Controller1.conf");
+const POLKIT: &str = include_str!("../assets/org.roentgen.policy");
+
+fn root() -> Result<()> {
+ ensure!(
+ unsafe { libc::geteuid() } == 0,
+ "Run this explicit installation/recovery command as root"
+ );
+ Ok(())
+}
+
+pub async fn install() -> Result<()> {
+ root()?;
+ let _ = crate::daemon::runtime()?;
+ crate::xray::preflight().await?;
+ private_directory(Path::new("/etc/roentgen"))?;
+ private_directory(Path::new("/var/lib/roentgen"))?;
+ if crate::xray::identity().is_err() {
+ let status = Command::new("/usr/sbin/useradd")
+ .args([
+ "--system",
+ "--user-group",
+ "--no-create-home",
+ "--shell",
+ "/sbin/nologin",
+ "roentgen-xray",
+ ])
+ .stdin(Stdio::null())
+ .status()
+ .await?;
+ ensure!(
+ status.success(),
+ "Could not create the dedicated Xray account"
+ );
+ }
+ let _ = systemctl(&["stop", "roentgen.service"]).await;
+ let _lock = crate::locking::controller_lock()?;
+ let mut controller = Controller::open_paths(
+ Path::new("/etc/roentgen"),
+ Path::new("/var/lib/roentgen"),
+ crate::daemon::runtime()?,
+ Box::new(HttpSource),
+ )?;
+ // Do not overwrite the saved guard or enabled intent on reinstallation.
+ #[cfg(feature = "linux-runtime")]
+ if !Path::new("/etc/roentgen/guard.toml").exists() {
+ crate::linux::write_guard(controller.status()?.requested_enabled)?;
+ }
+ let current = std::env::current_exe()?;
+ let target = Path::new("/usr/local/bin/roentgen");
+ if current != target {
+ atomic_private_write(target, &fs::read(current)?)?;
+ use std::os::unix::fs::PermissionsExt;
+ fs::set_permissions(target, fs::Permissions::from_mode(0o755))?;
+ }
+ for (path, content) in assets() {
+ if let Some(parent) = Path::new(path).parent() {
+ fs::create_dir_all(parent)?;
+ }
+ fs::write(path, content)?;
+ }
+ systemctl(&["daemon-reload"]).await?;
+ // Ask dbus to reload policy without restarting the system bus.
+ let connection = zbus::Connection::system().await?;
+ zbus::fdo::DBusProxy::new(&connection)
+ .await?
+ .reload_config()
+ .await?;
+ systemctl(&["enable", "roentgen-guard.service", "roentgen.service"]).await?;
+ systemctl(&["start", "roentgen-guard.service"]).await?;
+ controller.restore().await?;
+ drop(controller);
+ drop(_lock);
+ systemctl(&["restart", "roentgen.service"]).await?;
+ Ok(())
+}
+
+fn assets() -> [(&'static str, &'static str); 8] {
+ [
+ ("/etc/systemd/system/roentgen.service", SERVICE),
+ ("/etc/systemd/system/roentgen-guard.service", GUARD),
+ ("/etc/systemd/system/roentgen-xray.service", XRAY),
+ ("/etc/dbus-1/system.d/org.roentgen.Controller1.conf", BUS),
+ ("/usr/share/polkit-1/actions/org.roentgen.policy", POLKIT),
+ (
+ "/etc/systemd/system/NetworkManager.service.d/50-roentgen-guard.conf",
+ "[Unit]\nRequires=roentgen-guard.service\nAfter=roentgen-guard.service\n",
+ ),
+ (
+ "/etc/systemd/system/systemd-networkd.service.d/50-roentgen-guard.conf",
+ "[Unit]\nRequires=roentgen-guard.service\nAfter=roentgen-guard.service\n",
+ ),
+ (
+ "/etc/systemd/system/network.service.d/50-roentgen-guard.conf",
+ "[Unit]\nRequires=roentgen-guard.service\nAfter=roentgen-guard.service\n",
+ ),
+ ]
+}
+
+pub async fn recover() -> Result<()> {
+ root()?;
+ // Serialize recovery against ordinary controller transactions by stopping it first.
+ let _ = systemctl(&["stop", "roentgen.service"]).await;
+ let _lock = crate::locking::controller_lock()?;
+ crate::storage::persist_recovery_disabled(Path::new("/var/lib/roentgen"))?;
+ let mut runtime = crate::daemon::runtime()?;
+ runtime.disable().await
+}
+
+pub async fn uninstall(purge: bool) -> Result<()> {
+ root()?;
+ recover().await?;
+ systemctl(&[
+ "disable",
+ "--now",
+ "roentgen.service",
+ "roentgen-guard.service",
+ ])
+ .await?;
+ #[cfg(feature = "linux-runtime")]
+ crate::linux::firewall::remove()?;
+ for (path, _) in assets() {
+ if Path::new(path).exists() {
+ fs::remove_file(path)?;
+ }
+ }
+ systemctl(&["daemon-reload"]).await?;
+ if Path::new("/run/roentgen").exists() {
+ fs::remove_dir_all("/run/roentgen")?;
+ }
+ if purge {
+ fs::remove_dir_all("/etc/roentgen")?;
+ fs::remove_dir_all("/var/lib/roentgen")?;
+ }
+ if Path::new("/usr/local/bin/roentgen").exists() {
+ fs::remove_file("/usr/local/bin/roentgen")?;
+ }
+ Ok(())
+}
diff --git a/src/lib.rs b/src/lib.rs
new file mode 100644
index 0000000..cdd9d34
--- /dev/null
+++ b/src/lib.rs
@@ -0,0 +1,16 @@
+pub mod authentication;
+pub mod bus;
+pub mod controller;
+pub mod daemon;
+pub mod diagnostics;
+pub mod editor;
+pub mod installation;
+#[cfg(feature = "linux-runtime")]
+pub mod linux;
+mod locking;
+pub mod model;
+pub mod runtime;
+mod storage;
+pub mod subscription;
+pub mod tui;
+pub mod xray;
diff --git a/src/linux/firewall.rs b/src/linux/firewall.rs
new file mode 100644
index 0000000..79731b7
--- /dev/null
+++ b/src/linux/firewall.rs
@@ -0,0 +1,234 @@
+use anyhow::{Context, Result, ensure};
+use nftnl::{Batch, Chain, ChainType, Hook, MsgType, Policy, ProtoFamily, Rule, Table, nft_expr};
+use std::{
+ collections::HashSet,
+ ffi::CString,
+ io,
+ net::{Ipv4Addr, Ipv6Addr},
+ os::fd::AsRawFd,
+};
+
+pub const CAPTURE_MARK: u32 = 0x0200_0000;
+const INCOMING_MARK: u32 = 0x0100_0000;
+const TABLE: &std::ffi::CStr = c"roentgen";
+
+fn socket() -> io::Result<mnl::Socket> {
+ let socket = mnl::Socket::new(mnl::Bus::Netfilter)?;
+ let timeout = libc::timeval {
+ tv_sec: 5,
+ tv_usec: 0,
+ };
+ // SAFETY: the live socket fd and timeval pointer/length match SO_RCVTIMEO.
+ let result = unsafe {
+ libc::setsockopt(
+ socket.as_raw_fd(),
+ libc::SOL_SOCKET,
+ libc::SO_RCVTIMEO,
+ (&timeout as *const libc::timeval).cast(),
+ std::mem::size_of_val(&timeout) as _,
+ )
+ };
+ if result != 0 {
+ return Err(io::Error::last_os_error());
+ }
+ Ok(socket)
+}
+
+fn tables() -> Result<HashSet<CString>> {
+ let socket = socket()?;
+ socket.send(&nftnl::table::get_tables_nlmsg(1))?;
+ let mut tables = HashSet::new();
+ let mut buffer = vec![0u8; nftnl::nft_nlmsg_maxsize() as usize];
+ loop {
+ for message in socket.recv(&mut buffer)? {
+ if matches!(
+ mnl::cb_run2(
+ message?,
+ 1,
+ socket.portid(),
+ nftnl::table::get_tables_cb,
+ &mut tables
+ )?,
+ mnl::CbResult::Stop
+ ) {
+ return Ok(tables);
+ }
+ }
+ }
+}
+
+fn send(batch: nftnl::FinalizedBatch) -> Result<()> {
+ let socket = socket()?;
+ socket.send_all(&batch)?;
+ let mut buffer = vec![0u8; nftnl::nft_nlmsg_maxsize() as usize];
+ let mut sequences = batch.sequence_numbers();
+ while !sequences.is_empty() {
+ for message in socket.recv(&mut buffer)? {
+ let sequence = sequences
+ .next()
+ .context("Unexpected firewall acknowledgement")?;
+ mnl::cb_run(message?, sequence, socket.portid())?;
+ }
+ }
+ Ok(())
+}
+
+pub fn preflight() -> Result<()> {
+ let tables = tables()?;
+ ensure!(
+ tables.iter().all(|name| name.as_c_str() == TABLE),
+ "Other nftables tables exist; this firewall arrangement is not supported. Roentgen did not alter them"
+ );
+ Ok(())
+}
+
+/// Replacing the table is one kernel transaction. Connection marks survive table replacement.
+pub fn apply(enabled: bool, xray_uid: u32) -> Result<()> {
+ let existing = tables()?;
+ let table = Table::new(TABLE, ProtoFamily::Inet);
+ let mut batch = Batch::new();
+ if existing.contains(TABLE) {
+ batch.add(&table, MsgType::Del);
+ }
+ batch.add(&table, MsgType::Add);
+ let mut incoming = Chain::new(c"incoming", &table);
+ incoming.set_type(ChainType::Filter);
+ incoming.set_hook(Hook::In, -150);
+ incoming.set_policy(Policy::Accept);
+ batch.add(&incoming, MsgType::Add);
+ let mut classify = Rule::new(&incoming);
+ classify.add_expr(&nft_expr!(meta iifname));
+ classify.add_expr(&nft_expr!(cmp != &b"lo\0"[..]));
+ classify.add_expr(&nft_expr!(ct state));
+ classify.add_expr(&nft_expr!(bitwise mask 8u32, xor 0u32));
+ classify.add_expr(&nft_expr!(cmp == 8u32));
+ classify.add_expr(&nft_expr!(ct mark));
+ classify.add_expr(&nft_expr!(bitwise mask !INCOMING_MARK, xor INCOMING_MARK));
+ classify.add_expr(&nft_expr!(ct mark set));
+ batch.add(&classify, MsgType::Add);
+ if enabled {
+ let mut routing = Chain::new(c"route", &table);
+ routing.set_type(ChainType::Route);
+ routing.set_hook(Hook::Out, -150);
+ routing.set_policy(Policy::Accept);
+ batch.add(&routing, MsgType::Add);
+ exceptions(&mut batch, &routing, xray_uid);
+ let mut mark = Rule::new(&routing);
+ mark.add_expr(&nft_expr!(meta mark));
+ mark.add_expr(&nft_expr!(bitwise mask !CAPTURE_MARK, xor CAPTURE_MARK));
+ mark.add_expr(&nft_expr!(meta mark set));
+ batch.add(&mark, MsgType::Add);
+ let mut guard = Chain::new(c"guard", &table);
+ guard.set_type(ChainType::Filter);
+ guard.set_hook(Hook::Out, 100);
+ guard.set_policy(Policy::Drop);
+ batch.add(&guard, MsgType::Add);
+ exceptions(&mut batch, &guard, xray_uid);
+ let mut tun = Rule::new(&guard);
+ tun.add_expr(&nft_expr!(meta oifname));
+ tun.add_expr(&nft_expr!(cmp == &b"roentgen0\0"[..]));
+ tun.add_expr(&nft_expr!(verdict accept));
+ batch.add(&tun, MsgType::Add);
+ }
+ send(batch.finalize()).context("Could not apply Roentgen firewall transaction")
+}
+
+fn exceptions(batch: &mut Batch, chain: &Chain<'_>, xray_uid: u32) {
+ let mut loopback = Rule::new(chain);
+ loopback.add_expr(&nft_expr!(meta oifname));
+ loopback.add_expr(&nft_expr!(cmp == &b"lo\0"[..]));
+ loopback.add_expr(&nft_expr!(verdict accept));
+ batch.add(&loopback, MsgType::Add);
+ let mut core = Rule::new(chain);
+ core.add_expr(&nft_expr!(meta skuid));
+ core.add_expr(&nft_expr!(cmp == xray_uid));
+ core.add_expr(&nft_expr!(verdict accept));
+ batch.add(&core, MsgType::Add);
+ let mut replies = Rule::new(chain);
+ replies.add_expr(&nft_expr!(ct mark));
+ replies.add_expr(&nft_expr!(bitwise mask INCOMING_MARK, xor 0u32));
+ replies.add_expr(&nft_expr!(cmp == INCOMING_MARK));
+ replies.add_expr(&nft_expr!(verdict accept));
+ batch.add(&replies, MsgType::Add);
+ for (network, mask) in [
+ (Ipv4Addr::new(10, 0, 0, 0), Ipv4Addr::new(255, 0, 0, 0)),
+ (Ipv4Addr::new(172, 16, 0, 0), Ipv4Addr::new(255, 240, 0, 0)),
+ (Ipv4Addr::new(192, 168, 0, 0), Ipv4Addr::new(255, 255, 0, 0)),
+ ] {
+ let mut rule = Rule::new(chain);
+ rule.add_expr(&nft_expr!(meta nfproto));
+ rule.add_expr(&nft_expr!(cmp == libc::NFPROTO_IPV4 as u8));
+ rule.add_expr(&nft_expr!(payload ipv4 daddr));
+ rule.add_expr(&nft_expr!(bitwise mask mask, xor 0u32));
+ rule.add_expr(&nft_expr!(cmp == network));
+ rule.add_expr(&nft_expr!(verdict accept));
+ batch.add(&rule, MsgType::Add);
+ }
+ for (network, mask) in [(
+ Ipv6Addr::new(0xfc00, 0, 0, 0, 0, 0, 0, 0),
+ Ipv6Addr::new(0xfe00, 0, 0, 0, 0, 0, 0, 0),
+ )] {
+ let mut rule = Rule::new(chain);
+ rule.add_expr(&nft_expr!(meta nfproto));
+ rule.add_expr(&nft_expr!(cmp == libc::NFPROTO_IPV6 as u8));
+ rule.add_expr(&nft_expr!(payload ipv6 daddr));
+ rule.add_expr(&nft_expr!(bitwise mask mask, xor Ipv6Addr::UNSPECIFIED));
+ rule.add_expr(&nft_expr!(cmp == network));
+ rule.add_expr(&nft_expr!(verdict accept));
+ batch.add(&rule, MsgType::Add);
+ }
+ // DHCPv4 broadcast is link control, not a general broadcast exception.
+ let mut dhcp = Rule::new(chain);
+ dhcp.add_expr(&nft_expr!(meta nfproto));
+ dhcp.add_expr(&nft_expr!(cmp == libc::NFPROTO_IPV4 as u8));
+ dhcp.add_expr(&nft_expr!(payload ipv4 daddr));
+ dhcp.add_expr(&nft_expr!(cmp == Ipv4Addr::BROADCAST));
+ dhcp.add_expr(&nft_expr!(meta l4proto));
+ dhcp.add_expr(&nft_expr!(cmp == libc::IPPROTO_UDP as u8));
+ dhcp.add_expr(&nft_expr!(payload udp sport));
+ dhcp.add_expr(&nft_expr!(cmp == 68u16.to_be()));
+ dhcp.add_expr(&nft_expr!(payload udp dport));
+ dhcp.add_expr(&nft_expr!(cmp == 67u16.to_be()));
+ dhcp.add_expr(&nft_expr!(verdict accept));
+ batch.add(&dhcp, MsgType::Add);
+ let mut dhcp6 = Rule::new(chain);
+ dhcp6.add_expr(&nft_expr!(meta nfproto));
+ dhcp6.add_expr(&nft_expr!(cmp == libc::NFPROTO_IPV6 as u8));
+ dhcp6.add_expr(&nft_expr!(payload ipv6 daddr));
+ dhcp6.add_expr(&nft_expr!(
+ cmp == Ipv6Addr::new(0xff02, 0, 0, 0, 0, 0, 1, 2)
+ ));
+ dhcp6.add_expr(&nft_expr!(meta l4proto));
+ dhcp6.add_expr(&nft_expr!(cmp == libc::IPPROTO_UDP as u8));
+ dhcp6.add_expr(&nft_expr!(payload udp sport));
+ dhcp6.add_expr(&nft_expr!(cmp == 546u16.to_be()));
+ dhcp6.add_expr(&nft_expr!(payload udp dport));
+ dhcp6.add_expr(&nft_expr!(cmp == 547u16.to_be()));
+ dhcp6.add_expr(&nft_expr!(verdict accept));
+ batch.add(&dhcp6, MsgType::Add);
+ // Limit multicast ICMPv6 to router/neighbor discovery with hop limit 255.
+ for kind in [133u8, 134, 135, 136] {
+ let mut rule = Rule::new(chain);
+ rule.add_expr(&nft_expr!(meta nfproto));
+ rule.add_expr(&nft_expr!(cmp == libc::NFPROTO_IPV6 as u8));
+ rule.add_expr(&nft_expr!(meta l4proto));
+ rule.add_expr(&nft_expr!(cmp == libc::IPPROTO_ICMPV6 as u8));
+ rule.add_expr(&nft_expr!(payload ipv6 hoplimit));
+ rule.add_expr(&nft_expr!(cmp == 255u8));
+ rule.add_expr(&nftnl::expr::Payload::Transport(
+ nftnl::expr::TransportHeaderField::Icmpv6(nftnl::expr::Icmpv6HeaderField::Type),
+ ));
+ rule.add_expr(&nft_expr!(cmp == kind));
+ rule.add_expr(&nft_expr!(verdict accept));
+ batch.add(&rule, MsgType::Add);
+ }
+}
+
+pub fn remove() -> Result<()> {
+ if tables()?.contains(TABLE) {
+ let mut batch = Batch::new();
+ batch.add(&Table::new(TABLE, ProtoFamily::Inet), MsgType::Del);
+ send(batch.finalize())?;
+ }
+ Ok(())
+}
diff --git a/src/linux/mod.rs b/src/linux/mod.rs
new file mode 100644
index 0000000..68bb3a8
--- /dev/null
+++ b/src/linux/mod.rs
@@ -0,0 +1,119 @@
+pub mod firewall;
+pub mod routes;
+
+use crate::{model::Trojan, runtime::Runtime, xray};
+use anyhow::Result;
+use async_trait::async_trait;
+use std::{path::Path, time::Duration};
+
+pub struct LinuxRuntime {
+ routing: String,
+ applied: Option<Trojan>,
+}
+impl Default for LinuxRuntime {
+ fn default() -> Self {
+ Self {
+ routing: "unknown".into(),
+ applied: None,
+ }
+ }
+}
+
+#[async_trait]
+impl Runtime for LinuxRuntime {
+ async fn arm(&mut self) -> Result<()> {
+ xray::preflight().await?;
+ firewall::preflight()?;
+ routes::preflight().await?;
+ let (uid, _) = xray::identity()?;
+ firewall::apply(true, uid)?;
+ // Persist the boot guard input before recording enabled intent in SQLite.
+ write_guard(true)?;
+ self.routing = "blocked".into();
+ Ok(())
+ }
+ async fn apply(&mut self, connection: Option<&Trojan>, _: u64) -> Result<()> {
+ if self.applied.as_ref() == connection {
+ if let Some(index) = routes::tun_index().await? {
+ routes::capture(index).await?;
+ self.routing = "tun".into();
+ return Ok(());
+ }
+ }
+ self.routing = "blocked".into();
+ self.applied = None;
+ xray::systemctl(&["stop", "roentgen-xray.service"]).await?;
+ routes::remove().await?;
+ if let Some(connection) = connection {
+ xray::write_config(
+ Path::new("/run/roentgen/xray.json"),
+ &xray::configuration(connection, true, None),
+ )?;
+ xray::systemctl(&["start", "roentgen-xray.service"]).await?;
+ for _ in 0..100 {
+ if let Some(index) = routes::tun_index().await? {
+ routes::capture(index).await?;
+ self.routing = "tun".into();
+ self.applied = Some(connection.clone());
+ return Ok(());
+ }
+ tokio::time::sleep(Duration::from_millis(50)).await;
+ }
+ anyhow::bail!("Xray did not create roentgen0; traffic remains blocked");
+ }
+ Ok(())
+ }
+ async fn maintain(&mut self, connection: Option<&Trojan>, generation: u64) -> Result<()> {
+ match routes::tun_index().await? {
+ Some(index) if connection.is_some() => {
+ routes::capture(index).await?;
+ self.routing = "tun".into();
+ Ok(())
+ }
+ _ => self.apply(connection, generation).await,
+ }
+ }
+ async fn disable(&mut self) -> Result<()> {
+ xray::systemctl(&["stop", "roentgen-xray.service"]).await?;
+ routes::remove().await?;
+ self.applied = None;
+ write_guard(false)?;
+ firewall::apply(false, xray::identity()?.0)?;
+ self.routing = "direct".into();
+ Ok(())
+ }
+ fn routing(&self) -> &str {
+ &self.routing
+ }
+}
+
+pub fn write_guard(enabled: bool) -> Result<()> {
+ crate::storage::atomic_private_write(
+ Path::new("/etc/roentgen/guard.toml"),
+ format!("version = 1\nenabled = {enabled}\n").as_bytes(),
+ )
+}
+
+pub fn restore_guard() -> Result<()> {
+ #[derive(serde::Deserialize)]
+ #[serde(deny_unknown_fields)]
+ struct Guard {
+ version: u32,
+ enabled: bool,
+ }
+ let uid = xray::identity()?.0;
+ let input = std::fs::read_to_string("/etc/roentgen/guard.toml");
+ let value = input
+ .ok()
+ .and_then(|input| toml::from_str::<Guard>(&input).ok())
+ .filter(|value| value.version == 1);
+ match value {
+ Some(value) => firewall::apply(value.enabled, uid),
+ None => {
+ firewall::apply(true, uid)?;
+ anyhow::bail!(
+ "Corrupt or missing guard state; protection was armed. Use explicit recover --disable"
+ );
+ }
+ }
+}
diff --git a/src/linux/routes.rs b/src/linux/routes.rs
new file mode 100644
index 0000000..07083bb
--- /dev/null
+++ b/src/linux/routes.rs
@@ -0,0 +1,186 @@
+use super::firewall::CAPTURE_MARK;
+use anyhow::{Result, ensure};
+use futures_util::TryStreamExt;
+use rtnetlink::{
+ Handle, IpVersion, RouteMessageBuilder,
+ packet_route::{
+ route::{RouteAttribute, RouteProtocol},
+ rule::{RuleAction, RuleAttribute},
+ },
+};
+use std::net::{Ipv4Addr, Ipv6Addr};
+
+const TABLE_ID: u32 = 20260;
+const PRIORITY: u32 = 10260;
+const PROTOCOL: u8 = 186;
+
+fn connection() -> Result<Handle> {
+ let (connection, handle, _) = rtnetlink::new_connection()?;
+ tokio::spawn(connection);
+ Ok(handle)
+}
+
+pub async fn preflight() -> Result<()> {
+ let handle = connection()?;
+ for version in [IpVersion::V4, IpVersion::V6] {
+ let mut rules = handle.rule().get(version).execute();
+ while let Some(rule) = rules.try_next().await? {
+ let priority = rule
+ .attributes
+ .iter()
+ .find_map(|a| {
+ if let RuleAttribute::Priority(v) = a {
+ Some(*v)
+ } else {
+ None
+ }
+ })
+ .unwrap_or(0);
+ ensure!(
+ matches!(priority, 0 | 32766 | 32767) || owned_rule(&rule),
+ "Unsupported policy-routing rule at priority {}; remove conflicting VPN or policy routing before enabling",
+ priority
+ );
+ }
+ }
+ let mut routes = handle
+ .route()
+ .get(rtnetlink::RouteMessageBuilder::<std::net::IpAddr>::new().build())
+ .execute();
+ while let Some(route) = routes.try_next().await? {
+ if table(&route) == TABLE_ID {
+ ensure!(
+ route.header.protocol == RouteProtocol::Other(PROTOCOL),
+ "Roentgen route table is already in use"
+ );
+ }
+ }
+ Ok(())
+}
+
+fn table(route: &rtnetlink::packet_route::route::RouteMessage) -> u32 {
+ route
+ .attributes
+ .iter()
+ .find_map(|a| {
+ if let RouteAttribute::Table(v) = a {
+ Some(*v)
+ } else {
+ None
+ }
+ })
+ .unwrap_or(u32::from(route.header.table))
+}
+fn owned_rule(rule: &rtnetlink::packet_route::rule::RuleMessage) -> bool {
+ rule.attributes.contains(&RuleAttribute::Priority(PRIORITY))
+ && rule
+ .attributes
+ .contains(&RuleAttribute::FwMark(CAPTURE_MARK))
+ && rule
+ .attributes
+ .contains(&RuleAttribute::FwMask(CAPTURE_MARK))
+ && rule.attributes.contains(&RuleAttribute::Table(TABLE_ID))
+ && rule.header.action == RuleAction::ToTable
+}
+
+pub async fn capture(interface: u32) -> Result<()> {
+ let handle = connection()?;
+ for route in [
+ RouteMessageBuilder::<Ipv4Addr>::new()
+ .table_id(TABLE_ID)
+ .output_interface(interface)
+ .protocol(RouteProtocol::Other(PROTOCOL))
+ .build(),
+ RouteMessageBuilder::<Ipv6Addr>::new()
+ .table_id(TABLE_ID)
+ .output_interface(interface)
+ .protocol(RouteProtocol::Other(PROTOCOL))
+ .build(),
+ ] {
+ handle.route().add(route).replace().execute().await?;
+ }
+ for version in [IpVersion::V4, IpVersion::V6] {
+ let existing: Vec<_> = handle
+ .rule()
+ .get(version.clone())
+ .execute()
+ .try_collect()
+ .await?;
+ if existing.iter().any(owned_rule) {
+ continue;
+ }
+ match version {
+ IpVersion::V4 => {
+ let mut request = handle
+ .rule()
+ .add()
+ .v4()
+ .priority(PRIORITY)
+ .table_id(TABLE_ID)
+ .fw_mark(CAPTURE_MARK)
+ .action(RuleAction::ToTable);
+ request
+ .message_mut()
+ .attributes
+ .push(RuleAttribute::FwMask(CAPTURE_MARK));
+ request.execute().await?;
+ }
+ IpVersion::V6 => {
+ let mut request = handle
+ .rule()
+ .add()
+ .v6()
+ .priority(PRIORITY)
+ .table_id(TABLE_ID)
+ .fw_mark(CAPTURE_MARK)
+ .action(RuleAction::ToTable);
+ request
+ .message_mut()
+ .attributes
+ .push(RuleAttribute::FwMask(CAPTURE_MARK));
+ request.execute().await?;
+ }
+ }
+ }
+ Ok(())
+}
+
+pub async fn remove() -> Result<()> {
+ let handle = connection()?;
+ for version in [IpVersion::V4, IpVersion::V6] {
+ let rules: Vec<_> = handle
+ .rule()
+ .get(version.clone())
+ .execute()
+ .try_collect()
+ .await?;
+ for rule in rules.into_iter().filter(owned_rule) {
+ handle.rule().del(rule).execute().await?;
+ }
+ }
+ let routes: Vec<_> = handle
+ .route()
+ .get(RouteMessageBuilder::<std::net::IpAddr>::new().build())
+ .execute()
+ .try_collect()
+ .await?;
+ for route in routes
+ .into_iter()
+ .filter(|r| table(r) == TABLE_ID && r.header.protocol == RouteProtocol::Other(PROTOCOL))
+ {
+ handle.route().del(route).execute().await?;
+ }
+ Ok(())
+}
+
+pub async fn tun_index() -> Result<Option<u32>> {
+ let handle = connection()?;
+ Ok(handle
+ .link()
+ .get()
+ .match_name("roentgen0")
+ .execute()
+ .try_next()
+ .await?
+ .map(|link| link.header.index))
+}
diff --git a/src/locking.rs b/src/locking.rs
new file mode 100644
index 0000000..1c39471
--- /dev/null
+++ b/src/locking.rs
@@ -0,0 +1,35 @@
+use anyhow::{Context, Result, ensure};
+use std::{
+ fs::{File, OpenOptions},
+ os::fd::AsRawFd,
+ os::unix::fs::{MetadataExt, OpenOptionsExt},
+ path::Path,
+};
+
+pub fn controller_lock() -> Result<File> {
+ let directory = Path::new("/run/roentgen");
+ std::fs::create_dir_all(directory)?;
+ let metadata = std::fs::symlink_metadata(directory)?;
+ ensure!(
+ metadata.is_dir()
+ && metadata.uid() == unsafe { libc::geteuid() }
+ && metadata.mode() & 0o022 == 0,
+ "Runtime directory must be service-owned and not writable by other users"
+ );
+ let file = OpenOptions::new()
+ .create(true)
+ .truncate(false)
+ .read(true)
+ .write(true)
+ .mode(0o600)
+ .custom_flags(libc::O_NOFOLLOW)
+ .open(directory.join("controller.lock"))
+ .context("Open controller lock")?;
+ // SAFETY: file owns this live fd; flock does not retain a pointer.
+ let result = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) };
+ ensure!(
+ result == 0,
+ "Another controller is running; stop it before explicit recovery"
+ );
+ Ok(file)
+}
diff --git a/src/main.rs b/src/main.rs
index e7a11a9..7ad2d81 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1,3 +1,243 @@
-fn main() {
- println!("Hello, world!");
+use anyhow::{Result, ensure};
+use clap::{Parser, Subcommand};
+use roentgen::{
+ bus::Client,
+ controller::Operation,
+ model::{Status, SubscriptionSummary},
+};
+use std::io::{self, BufRead, IsTerminal, Read, Write};
+
+#[derive(Parser)]
+#[command(
+ version,
+ about = "Control local-machine proxying through the Roentgen service"
+)]
+struct Cli {
+ #[command(subcommand)]
+ command: Option<Command>,
+}
+
+#[derive(Subcommand)]
+enum Command {
+ /// Open the terminal interface.
+ Tui,
+ /// Read sanitized requested state, routing, and proxy health.
+ Status {
+ #[arg(long)]
+ json: bool,
+ },
+ #[command(subcommand)]
+ Subscription(SubscriptionCommand),
+ #[command(subcommand)]
+ Configuration(ConfigurationCommand),
+ Enable,
+ Disable,
+ /// Measure HTTPS delay through a configuration without enabling host proxying.
+ Ping {
+ id: Option<String>,
+ },
+ /// Run a bounded manual download through a configuration.
+ Speed {
+ id: Option<String>,
+ },
+ Logs {
+ #[arg(long, required = true)]
+ editor: bool,
+ },
+ /// Install systemwide service assets. Requires root.
+ Install,
+ Uninstall {
+ #[arg(long)]
+ purge: bool,
+ },
+ /// Explicitly persist disabled intent and release only owned networking.
+ Recover {
+ #[arg(long, required = true)]
+ disable: bool,
+ },
+ #[command(hide = true)]
+ Daemon,
+ #[command(hide = true)]
+ Guard,
+}
+
+#[derive(Subcommand)]
+enum SubscriptionCommand {
+ /// Read the secret URL from a prompt or one stdin line.
+ Add {
+ #[arg(long)]
+ name: Option<String>,
+ },
+ List,
+ Edit {
+ name: String,
+ },
+ Delete {
+ name: String,
+ },
+ Update {
+ #[arg(required_unless_present = "all", conflicts_with = "all")]
+ name: Option<String>,
+ #[arg(long)]
+ all: bool,
+ },
+}
+#[derive(Subcommand)]
+enum ConfigurationCommand {
+ List {
+ #[arg(long)]
+ subscription: Option<String>,
+ },
+ Select {
+ id: String,
+ },
+ Unset,
+}
+
+#[tokio::main]
+async fn main() {
+ tracing_subscriber::fmt()
+ .with_writer(std::io::stderr)
+ .with_ansi(false)
+ .init();
+ if let Err(error) = run(Cli::parse()).await {
+ eprintln!("roentgen: {error}");
+ std::process::exit(1);
+ }
+}
+
+async fn run(cli: Cli) -> Result<()> {
+ let command = cli.command.unwrap_or(Command::Tui);
+ match command {
+ Command::Install => return roentgen::installation::install().await,
+ Command::Uninstall { purge } => return roentgen::installation::uninstall(purge).await,
+ Command::Recover { .. } => return roentgen::installation::recover().await,
+ Command::Daemon => return roentgen::daemon::run().await,
+ Command::Guard => return roentgen::daemon::guard().await,
+ _ => {}
+ }
+ let client = Client::system().await?;
+ let interactive = io::stdin().is_terminal() && io::stderr().is_terminal();
+ let mutation = matches!(
+ command,
+ Command::Enable
+ | Command::Disable
+ | Command::Ping { .. }
+ | Command::Speed { .. }
+ | Command::Subscription(
+ SubscriptionCommand::Add { .. }
+ | SubscriptionCommand::Edit { .. }
+ | SubscriptionCommand::Delete { .. }
+ | SubscriptionCommand::Update { .. }
+ )
+ | Command::Configuration(
+ ConfigurationCommand::Select { .. } | ConfigurationCommand::Unset
+ )
+ );
+ let _agent = roentgen::authentication::TerminalAgent::start(interactive && mutation)?;
+ match command {
+ Command::Tui => roentgen::tui::run(client).await?,
+ Command::Status { json } => {
+ let status: Status = client.read("Status").await?;
+ if json {
+ println!("{}", serde_json::to_string(&status)?);
+ } else {
+ println!(
+ "Requested: {}\nSelected: {}\nRouting: {}\nHealth: {}",
+ if status.requested_enabled {
+ "enabled"
+ } else {
+ "disabled"
+ },
+ status.selected_configuration.as_deref().unwrap_or("unset"),
+ status.routing,
+ status.health
+ );
+ if let Some(error) = status.last_error {
+ println!("Error: {error}");
+ }
+ }
+ }
+ Command::Subscription(SubscriptionCommand::List) => {
+ for subscription in client
+ .read::<Vec<SubscriptionSummary>>("Subscriptions")
+ .await?
+ {
+ println!("{}\t{}", subscription.id, subscription.name);
+ }
+ }
+ Command::Subscription(SubscriptionCommand::Add { name }) => {
+ let name = name.unwrap_or_else(|| {
+ format!("Subscription-{}", &uuid::Uuid::new_v4().to_string()[..8])
+ });
+ let url = secret_line("Subscription URL: ")?;
+ client
+ .change(Operation::AddSubscription { name, url }, interactive)
+ .await?;
+ }
+ Command::Subscription(SubscriptionCommand::Edit { name }) => {
+ let url = secret_line("New subscription URL: ")?;
+ client
+ .change(Operation::EditSubscription { name, url }, interactive)
+ .await?;
+ }
+ Command::Subscription(SubscriptionCommand::Delete { name }) => {
+ client
+ .change(Operation::DeleteSubscription { name }, interactive)
+ .await?
+ }
+ Command::Subscription(SubscriptionCommand::Update { name, .. }) => {
+ client
+ .change(Operation::Refresh { name }, interactive)
+ .await?
+ }
+ Command::Configuration(ConfigurationCommand::List { subscription }) => {
+ for entry in client.configurations(subscription.as_deref()).await? {
+ println!(
+ "{}\t{}\t{}\t{}\t{}",
+ if entry.selected { "*" } else { " " },
+ entry.id,
+ entry.name,
+ entry.status,
+ entry.explanation.as_deref().unwrap_or("")
+ );
+ }
+ }
+ Command::Configuration(ConfigurationCommand::Select { id }) => {
+ client.change(Operation::Select { id }, interactive).await?
+ }
+ Command::Configuration(ConfigurationCommand::Unset) => {
+ client.change(Operation::Unset, interactive).await?
+ }
+ Command::Enable => client.change(Operation::Enable, interactive).await?,
+ Command::Disable => client.change(Operation::Disable, interactive).await?,
+ Command::Ping { id } => println!(
+ "{}",
+ client.diagnostic(id.as_deref(), false, interactive).await?
+ ),
+ Command::Speed { id } => println!(
+ "{}",
+ client.diagnostic(id.as_deref(), true, interactive).await?
+ ),
+ Command::Logs { .. } => roentgen::editor::open(&client.read::<Vec<String>>("Logs").await?)?,
+ _ => unreachable!(),
+ }
+ Ok(())
+}
+
+fn secret_line(prompt: &str) -> Result<String> {
+ if io::stdin().is_terminal() {
+ eprint!("{prompt}");
+ io::stderr().flush()?;
+ }
+ let mut line = String::new();
+ let count = io::stdin()
+ .lock()
+ .take(16 * 1024 + 1)
+ .read_line(&mut line)?;
+ ensure!(
+ count > 0 && count <= 16 * 1024,
+ "Provide one subscription URL on stdin, up to 16 KiB"
+ );
+ Ok(line.trim_end_matches(['\r', '\n']).to_owned())
}
diff --git a/src/model.rs b/src/model.rs
new file mode 100644
index 0000000..8966956
--- /dev/null
+++ b/src/model.rs
@@ -0,0 +1,147 @@
+use serde::{Deserialize, Serialize};
+
+#[derive(Clone, Serialize, Deserialize, PartialEq, Eq)]
+pub struct Trojan {
+ pub host: String,
+ pub port: u16,
+ pub password: String,
+ pub server_name: String,
+ pub alpn: Vec<String>,
+ pub fingerprint: Option<String>,
+}
+
+impl Trojan {
+ pub fn identity(&self) -> (&str, u16, &str) {
+ (&self.host, self.port, &self.password)
+ }
+}
+
+#[derive(Clone, Serialize, Deserialize)]
+pub struct Entry {
+ pub id: String,
+ pub name: String,
+ pub status: String,
+ pub explanation: Option<String>,
+ pub connection: Option<Trojan>,
+}
+
+#[derive(Clone, Debug, Serialize, Deserialize)]
+pub struct ConfigurationSummary {
+ pub id: String,
+ pub subscription_id: String,
+ pub name: String,
+ pub host: Option<String>,
+ pub port: Option<u16>,
+ pub status: String,
+ pub explanation: Option<String>,
+ pub selected: bool,
+ pub ping_ms: Option<u64>,
+ pub speed_bytes_per_second: Option<f64>,
+}
+
+#[derive(Clone, Serialize, Deserialize)]
+pub struct Subscription {
+ pub id: String,
+ pub name: String,
+ pub url: String,
+}
+
+#[derive(Clone, Debug, Serialize, Deserialize)]
+pub struct SubscriptionSummary {
+ pub id: String,
+ pub name: String,
+}
+
+#[derive(Clone, Debug, Serialize, Deserialize)]
+#[serde(default, deny_unknown_fields)]
+pub struct Settings {
+ pub ping_url: String,
+ pub speed_url: String,
+ pub maximum_download_mib: u64,
+ pub maximum_duration_seconds: u64,
+}
+
+impl Default for Settings {
+ fn default() -> Self {
+ Self {
+ ping_url: "https://speed.cloudflare.com/__down?bytes=0".into(),
+ speed_url: "https://speed.cloudflare.com/__down?bytes=10485760".into(),
+ maximum_download_mib: 10,
+ maximum_duration_seconds: 10,
+ }
+ }
+}
+
+impl Settings {
+ pub fn validate(&self) -> anyhow::Result<()> {
+ for value in [&self.ping_url, &self.speed_url] {
+ let url = url::Url::parse(value)
+ .map_err(|_| anyhow::anyhow!("Use an absolute HTTPS diagnostic URL"))?;
+ anyhow::ensure!(
+ url.scheme() == "https"
+ && url.host_str().is_some()
+ && url.username().is_empty()
+ && url.password().is_none()
+ && url.fragment().is_none(),
+ "Diagnostic URLs require HTTPS without credentials or fragments"
+ );
+ }
+ anyhow::ensure!(
+ self.maximum_download_mib > 0
+ && self.maximum_download_mib.checked_mul(1_048_576).is_some(),
+ "Download limit must be a positive representable MiB count"
+ );
+ anyhow::ensure!(
+ self.maximum_duration_seconds > 0,
+ "Duration limit must be positive"
+ );
+ anyhow::ensure!(
+ std::time::Instant::now()
+ .checked_add(std::time::Duration::from_secs(
+ self.maximum_duration_seconds
+ ))
+ .is_some(),
+ "Duration exceeds the supported clock range"
+ );
+ Ok(())
+ }
+}
+
+#[derive(Clone, Serialize, Deserialize)]
+#[serde(deny_unknown_fields)]
+pub struct Definitions {
+ pub version: u32,
+ #[serde(default)]
+ pub settings: Settings,
+ #[serde(default)]
+ pub subscriptions: Vec<Subscription>,
+}
+
+impl Default for Definitions {
+ fn default() -> Self {
+ Self {
+ version: 1,
+ settings: Settings::default(),
+ subscriptions: vec![],
+ }
+ }
+}
+
+#[derive(Clone, Debug, Serialize, Deserialize)]
+pub struct Status {
+ pub version: u32,
+ pub requested_enabled: bool,
+ pub selected_configuration: Option<String>,
+ pub generation: u64,
+ pub routing: String,
+ pub health: String,
+ pub last_error: Option<String>,
+}
+
+pub fn display_text(value: &str) -> String {
+ value
+ .chars()
+ .filter(|c| !c.is_control())
+ .take(256)
+ .collect()
+}
diff --git a/src/runtime.rs b/src/runtime.rs
new file mode 100644
index 0000000..96ffd0d
--- /dev/null
+++ b/src/runtime.rs
@@ -0,0 +1,34 @@
+use crate::model::Trojan;
+use anyhow::{Result, bail};
+use async_trait::async_trait;
+
+#[async_trait]
+pub trait Runtime: Send {
+ /// Protection must be installed before durable enabled intent changes.
+ async fn arm(&mut self) -> Result<()>;
+ async fn apply(&mut self, connection: Option<&Trojan>, generation: u64) -> Result<()>;
+ async fn maintain(&mut self, connection: Option<&Trojan>, generation: u64) -> Result<()> {
+ self.apply(connection, generation).await
+ }
+ /// Called only after disabled intent has been durably recorded.
+ async fn disable(&mut self) -> Result<()>;
+ fn routing(&self) -> &str;
+}
+
+/// Allows managing saved subscriptions without claiming that host capture is available.
+pub struct OfflineRuntime;
+#[async_trait]
+impl Runtime for OfflineRuntime {
+ async fn arm(&mut self) -> Result<()> {
+ bail!("Host networking is unavailable; install and start the Roentgen service")
+ }
+ async fn apply(&mut self, _: Option<&Trojan>, _: u64) -> Result<()> {
+ bail!("Host networking is unavailable")
+ }
+ async fn disable(&mut self) -> Result<()> {
+ Ok(())
+ }
+ fn routing(&self) -> &str {
+ "unavailable"
+ }
+}
diff --git a/src/storage.rs b/src/storage.rs
new file mode 100644
index 0000000..cb7fcd9
--- /dev/null
+++ b/src/storage.rs
@@ -0,0 +1,264 @@
+use crate::model::{Definitions, Entry};
+use anyhow::{Context, Result, ensure};
+use rusqlite::{Connection, OptionalExtension, params};
+use std::{
+ fs,
+ io::Write,
+ os::unix::fs::{MetadataExt, PermissionsExt},
+ path::{Path, PathBuf},
+};
+
+pub struct Storage {
+ db: Connection,
+ pub definitions: Definitions,
+ config: PathBuf,
+}
+
+pub fn private_directory(path: &Path) -> Result<()> {
+ fs::create_dir_all(path).context("Create Roentgen directory")?;
+ let metadata = fs::symlink_metadata(path)?;
+ ensure!(
+ metadata.is_dir() && !metadata.file_type().is_symlink(),
+ "Roentgen directory must not be a symlink"
+ );
+ ensure!(
+ metadata.uid() == unsafe { libc::geteuid() },
+ "Roentgen directory must belong to the service user"
+ );
+ fs::set_permissions(path, fs::Permissions::from_mode(0o700))?;
+ Ok(())
+}
+
+pub fn atomic_private_write(path: &Path, data: &[u8]) -> Result<()> {
+ let parent = path.parent().context("Missing parent directory")?;
+ let mut file = tempfile::NamedTempFile::new_in(parent)?;
+ file.as_file()
+ .set_permissions(fs::Permissions::from_mode(0o600))?;
+ file.write_all(data)?;
+ file.as_file().sync_all()?;
+ file.persist(path).map_err(|e| e.error)?;
+ fs::File::open(parent)?.sync_all()?;
+ Ok(())
+}
+
+impl Storage {
+ pub fn open(config_dir: &Path, data_dir: &Path) -> Result<Self> {
+ private_directory(config_dir)?;
+ private_directory(data_dir)?;
+ let config = config_dir.join("config.toml");
+ let definitions: Definitions = if config.exists() {
+ ensure!(
+ fs::symlink_metadata(&config)?.is_file(),
+ "Editable definitions must be a regular file"
+ );
+ toml::from_str(&fs::read_to_string(&config)?)
+ .context("Invalid Roentgen TOML; repair it before starting")?
+ } else {
+ Definitions::default()
+ };
+ ensure!(definitions.version == 1, "Unsupported TOML version");
+ definitions.settings.validate()?;
+ let mut ids = std::collections::HashSet::new();
+ let mut names = std::collections::HashSet::new();
+ for subscription in &definitions.subscriptions {
+ ensure!(
+ !subscription.id.is_empty()
+ && ids.insert(subscription.id.clone())
+ && !subscription.name.is_empty()
+ && names.insert(subscription.name.clone()),
+ "Subscription IDs and names must be unique and nonempty"
+ );
+ crate::subscription::subscription_url(&subscription.url)?;
+ }
+ let path = data_dir.join("state.sqlite3");
+ if path.exists() {
+ ensure!(
+ fs::symlink_metadata(&path)?.is_file(),
+ "State database must be a regular file"
+ );
+ }
+ let db = Connection::open(&path)?;
+ fs::set_permissions(&path, fs::Permissions::from_mode(0o600))?;
+ db.busy_timeout(std::time::Duration::from_secs(5))?;
+ db.execute_batch("PRAGMA synchronous=FULL; PRAGMA foreign_keys=ON;")?;
+ let version: u32 = db.query_row("PRAGMA user_version", [], |r| r.get(0))?;
+ ensure!(version <= 1, "Unsupported database version");
+ if version == 0 {
+ db.execute_batch("BEGIN IMMEDIATE;
+ CREATE TABLE entries (subscription_id TEXT NOT NULL, ordinal INTEGER NOT NULL, id TEXT PRIMARY KEY, data TEXT NOT NULL);
+ CREATE TABLE state (singleton INTEGER PRIMARY KEY CHECK(singleton=1), enabled INTEGER NOT NULL CHECK(enabled IN (0,1)), selected TEXT, generation INTEGER NOT NULL);
+ INSERT INTO state VALUES (1,0,NULL,0);
+ PRAGMA user_version=1; COMMIT;")?;
+ }
+ let store = Self {
+ db,
+ definitions,
+ config,
+ };
+ // Removed TOML definitions never authorize retained snapshots or a stale selection.
+ let known: std::collections::HashSet<_> = store
+ .definitions
+ .subscriptions
+ .iter()
+ .map(|s| s.id.as_str())
+ .collect();
+ for subscription_id in store
+ .db
+ .prepare("SELECT DISTINCT subscription_id FROM entries")?
+ .query_map([], |r| r.get::<_, String>(0))?
+ .collect::<rusqlite::Result<Vec<_>>>()?
+ {
+ if !known.contains(subscription_id.as_str()) {
+ store.delete_entries(&subscription_id)?;
+ }
+ }
+ if !store.config.exists() {
+ store.save_definitions(&store.definitions)?;
+ }
+ Ok(store)
+ }
+
+ pub fn save_definitions(&self, definitions: &Definitions) -> Result<()> {
+ atomic_private_write(
+ &self.config,
+ toml::to_string_pretty(definitions)?.as_bytes(),
+ )
+ }
+
+ pub fn state(&self) -> Result<(bool, Option<String>, u64)> {
+ Ok(self.db.query_row(
+ "SELECT enabled,selected,generation FROM state WHERE singleton=1",
+ [],
+ |r| Ok((r.get(0)?, r.get(1)?, r.get::<_, i64>(2)? as u64)),
+ )?)
+ }
+
+ pub fn set_intent(&self, enabled: bool) -> Result<()> {
+ self.db.execute(
+ "UPDATE state SET enabled=?1,generation=generation+1 WHERE singleton=1",
+ [enabled],
+ )?;
+ Ok(())
+ }
+
+ pub fn select(&self, id: Option<&str>) -> Result<()> {
+ self.db.execute(
+ "UPDATE state SET selected=?1,generation=generation+1 WHERE singleton=1",
+ [id],
+ )?;
+ Ok(())
+ }
+
+ pub fn entries(&self, subscription_id: &str) -> Result<Vec<Entry>> {
+ let mut query = self
+ .db
+ .prepare("SELECT data FROM entries WHERE subscription_id=?1 ORDER BY ordinal")?;
+ let values = query
+ .query_map([subscription_id], |r| r.get::<_, String>(0))?
+ .collect::<rusqlite::Result<Vec<_>>>()?;
+ values
+ .iter()
+ .map(|v| {
+ serde_json::from_str(v)
+ .context("Corrupt saved configuration; explicit recovery is required")
+ })
+ .collect()
+ }
+
+ pub fn find_entry(&self, id: &str) -> Result<Option<Entry>> {
+ self.db
+ .query_row("SELECT data FROM entries WHERE id=?1", [id], |r| {
+ r.get::<_, String>(0)
+ })
+ .optional()?
+ .map(|v| serde_json::from_str(&v).context("Corrupt saved configuration"))
+ .transpose()
+ }
+
+ pub fn replace_entries(&mut self, subscription_id: &str, entries: &[Entry]) -> Result<()> {
+ let transaction = self.db.transaction()?;
+ let selected: Option<String> =
+ transaction.query_row("SELECT selected FROM state WHERE singleton=1", [], |r| {
+ r.get(0)
+ })?;
+ let owns_selection = match selected.as_ref() {
+ Some(id) => contains_id(&transaction, subscription_id, id)?,
+ None => false,
+ };
+ transaction.execute(
+ "DELETE FROM entries WHERE subscription_id=?1",
+ [subscription_id],
+ )?;
+ for (ordinal, entry) in entries.iter().enumerate() {
+ transaction.execute(
+ "INSERT INTO entries VALUES (?1,?2,?3,?4)",
+ params![
+ subscription_id,
+ ordinal as i64,
+ entry.id,
+ serde_json::to_string(entry)?
+ ],
+ )?;
+ }
+ if owns_selection
+ && !entries
+ .iter()
+ .any(|e| Some(&e.id) == selected.as_ref() && e.connection.is_some())
+ {
+ transaction.execute("UPDATE state SET selected=NULL WHERE singleton=1", [])?;
+ }
+ transaction.execute(
+ "UPDATE state SET generation=generation+1 WHERE singleton=1",
+ [],
+ )?;
+ transaction.commit()?;
+ Ok(())
+ }
+
+ pub fn delete_entries(&self, subscription_id: &str) -> Result<()> {
+ self.db.execute_batch("BEGIN IMMEDIATE")?;
+ let result = (|| -> Result<()> {
+ self.db.execute("UPDATE state SET selected=NULL,generation=generation+1 WHERE singleton=1 AND selected IN (SELECT id FROM entries WHERE subscription_id=?1)", [subscription_id])?;
+ self.db.execute(
+ "DELETE FROM entries WHERE subscription_id=?1",
+ [subscription_id],
+ )?;
+ self.db.execute_batch("COMMIT")?;
+ Ok(())
+ })();
+ if result.is_err() {
+ let _ = self.db.execute_batch("ROLLBACK");
+ }
+ result
+ }
+}
+
+fn contains_id(db: &Connection, subscription_id: &str, id: &str) -> Result<bool> {
+ Ok(db.query_row(
+ "SELECT EXISTS(SELECT 1 FROM entries WHERE subscription_id=?1 AND id=?2)",
+ params![subscription_id, id],
+ |r| r.get(0),
+ )?)
+}
+
+/// Recovery deliberately needs no valid editable definitions or imported entries.
+pub fn persist_recovery_disabled(data_dir: &Path) -> Result<()> {
+ private_directory(data_dir)?;
+ let path = data_dir.join("state.sqlite3");
+ if path.exists() {
+ ensure!(
+ fs::symlink_metadata(&path)?.is_file(),
+ "Recovery database must be a regular file"
+ );
+ }
+ let db = Connection::open(&path)?;
+ fs::set_permissions(&path, fs::Permissions::from_mode(0o600))?;
+ db.busy_timeout(std::time::Duration::from_secs(5))?;
+ db.execute_batch("PRAGMA synchronous=FULL; BEGIN EXCLUSIVE;")?;
+ db.execute(
+ "UPDATE state SET enabled=0,generation=generation+1 WHERE singleton=1",
+ [],
+ )?;
+ db.execute_batch("COMMIT")?;
+ Ok(())
+}
diff --git a/src/subscription.rs b/src/subscription.rs
new file mode 100644
index 0000000..df5b318
--- /dev/null
+++ b/src/subscription.rs
@@ -0,0 +1,305 @@
+use crate::model::{Entry, Trojan, display_text};
+use anyhow::{Result, anyhow, bail, ensure};
+use async_trait::async_trait;
+use base64::{Engine, engine::general_purpose};
+use percent_encoding::percent_decode_str;
+use std::time::Duration;
+use url::Url;
+use uuid::Uuid;
+
+pub const MAX_SUBSCRIPTION_BYTES: usize = 4 * 1024 * 1024;
+
+#[async_trait]
+pub trait SubscriptionSource: Send {
+ async fn fetch(&self, url: &str) -> Result<Vec<u8>>;
+}
+
+pub struct HttpSource;
+
+pub fn subscription_url(value: &str) -> Result<Url> {
+ let url = Url::parse(value).map_err(|_| anyhow!("Invalid subscription URL"))?;
+ ensure!(
+ matches!(url.scheme(), "http" | "https")
+ && url.host_str().is_some()
+ && url.username().is_empty()
+ && url.password().is_none()
+ && url.fragment().is_none(),
+ "Use an HTTP or HTTPS subscription URL without userinfo or a fragment"
+ );
+ Ok(url)
+}
+
+#[async_trait]
+impl SubscriptionSource for HttpSource {
+ async fn fetch(&self, value: &str) -> Result<Vec<u8>> {
+ let mut url = subscription_url(value)?;
+ let client = reqwest::Client::builder()
+ .no_proxy()
+ .redirect(reqwest::redirect::Policy::none())
+ .timeout(Duration::from_secs(30))
+ .connect_timeout(Duration::from_secs(10))
+ .user_agent(concat!("Roentgen/", env!("CARGO_PKG_VERSION")))
+ .build()
+ .map_err(|_| anyhow!("Could not initialize subscription TLS client"))?;
+ let deadline = tokio::time::Instant::now() + Duration::from_secs(30);
+ for redirects in 0..=5 {
+ let mut response = tokio::time::timeout_at(
+ deadline,
+ client
+ .get(url.clone())
+ .header("Accept", "text/plain")
+ .send(),
+ )
+ .await
+ .map_err(|_| anyhow!("Subscription request timed out"))?
+ .map_err(|_| anyhow!("Subscription request failed; check connectivity and TLS"))?;
+ if response.status().is_redirection() {
+ ensure!(
+ matches!(response.status().as_u16(), 301 | 302 | 303 | 307 | 308),
+ "Unexpected subscription redirect response"
+ );
+ ensure!(redirects < 5, "Subscription exceeded five redirects");
+ let location = response
+ .headers()
+ .get(reqwest::header::LOCATION)
+ .and_then(|v| v.to_str().ok())
+ .ok_or_else(|| anyhow!("Subscription redirect has no valid location"))?;
+ let next = url
+ .join(location)
+ .map_err(|_| anyhow!("Invalid subscription redirect"))?;
+ subscription_url(next.as_str())?;
+ ensure!(
+ !(url.scheme() == "https" && next.scheme() != "https"),
+ "Refusing HTTPS-to-HTTP subscription redirect"
+ );
+ url = next;
+ continue;
+ }
+ ensure!(
+ response.status() == reqwest::StatusCode::OK,
+ "Subscription returned HTTP {}",
+ response.status().as_u16()
+ );
+ if let Some(kind) = response
+ .headers()
+ .get(reqwest::header::CONTENT_TYPE)
+ .and_then(|h| h.to_str().ok())
+ {
+ ensure!(
+ !["html", "json", "yaml"]
+ .iter()
+ .any(|v| kind.to_ascii_lowercase().contains(v)),
+ "Subscription returned an unsupported document format; use a plain or Base64 link list"
+ );
+ }
+ let mut body = Vec::new();
+ while let Some(chunk) = tokio::time::timeout_at(deadline, response.chunk())
+ .await
+ .map_err(|_| anyhow!("Subscription body timed out"))?
+ .map_err(|_| anyhow!("Subscription body download failed"))?
+ {
+ ensure!(
+ body.len() + chunk.len() <= MAX_SUBSCRIPTION_BYTES,
+ "Subscription exceeds the 4 MiB limit"
+ );
+ body.extend_from_slice(&chunk);
+ }
+ return Ok(body);
+ }
+ unreachable!()
+ }
+}
+
+pub fn import(bytes: &[u8]) -> Result<Vec<Entry>> {
+ ensure!(
+ bytes.len() <= MAX_SUBSCRIPTION_BYTES,
+ "Subscription exceeds the 4 MiB limit"
+ );
+ let text = std::str::from_utf8(bytes)
+ .map_err(|_| anyhow!("Subscription is not UTF-8"))?
+ .trim_start_matches('\u{feff}')
+ .trim();
+ if text.is_empty() {
+ return Ok(vec![]);
+ }
+ let decoded;
+ let plain =
+ if text.contains("://") || text.contains('\n') && text.lines().any(|l| l.contains("://")) {
+ text
+ } else {
+ let compact: String = text.chars().filter(|c| !c.is_whitespace()).collect();
+ let mut result = None;
+ for engine in [
+ general_purpose::STANDARD,
+ general_purpose::STANDARD_NO_PAD,
+ general_purpose::URL_SAFE,
+ general_purpose::URL_SAFE_NO_PAD,
+ ] {
+ if let Ok(value) = engine.decode(&compact) {
+ result = Some(value);
+ break;
+ }
+ }
+ decoded = result
+ .ok_or_else(|| anyhow!("Subscription is neither a link list nor valid Base64"))?;
+ ensure!(
+ decoded.len() <= MAX_SUBSCRIPTION_BYTES,
+ "Decoded subscription exceeds the limit"
+ );
+ std::str::from_utf8(&decoded)
+ .map_err(|_| anyhow!("Decoded subscription is not UTF-8"))?
+ .trim()
+ };
+ let lower = plain.to_ascii_lowercase();
+ ensure!(
+ !plain.starts_with(['<', '{', '['])
+ && !lower.starts_with("proxies:")
+ && !lower.starts_with("proxy-providers:"),
+ "Unsupported subscription envelope; use a plain or Base64 link list"
+ );
+ // A readable empty envelope is accepted, but arbitrary prose is not an entry list.
+ ensure!(
+ plain.is_empty() || plain.lines().any(|line| line.contains("://")),
+ "Subscription does not contain a link list"
+ );
+ Ok(plain
+ .lines()
+ .map(str::trim)
+ .filter(|s| !s.is_empty())
+ .map(parse_entry)
+ .collect())
+}
+
+fn decode(value: &str) -> Result<String> {
+ let bytes = value.as_bytes();
+ for (index, byte) in bytes.iter().enumerate() {
+ if *byte == b'%' {
+ ensure!(
+ bytes.get(index + 1).is_some_and(u8::is_ascii_hexdigit)
+ && bytes.get(index + 2).is_some_and(u8::is_ascii_hexdigit),
+ "Invalid percent encoding"
+ );
+ }
+ }
+ percent_decode_str(value)
+ .decode_utf8()
+ .map(|v| v.into_owned())
+ .map_err(|_| anyhow!("Invalid UTF-8 in URI component"))
+}
+
+fn parse_entry(raw: &str) -> Entry {
+ let mut entry = Entry {
+ id: Uuid::new_v4().to_string(),
+ name: "Invalid entry".into(),
+ status: "invalid".into(),
+ explanation: None,
+ connection: None,
+ };
+ let result = (|| -> Result<Trojan> {
+ ensure!(
+ !raw.chars().any(char::is_control),
+ "Control characters in connection link"
+ );
+ let url = Url::parse(raw).map_err(|_| anyhow!("Malformed connection link"))?;
+ if let Some(label) = url.fragment() {
+ entry.name = display_text(&decode(label)?);
+ }
+ if url.scheme() != "trojan" {
+ entry.status = "unsupported".into();
+ bail!("Only Trojan TCP + TLS connections are supported");
+ }
+ ensure!(
+ url.password().is_none(),
+ "Trojan credentials must be a percent-encoded password"
+ );
+ let password = decode(url.username())?;
+ ensure!(!password.is_empty(), "Missing Trojan password");
+ let host = match url
+ .host()
+ .ok_or_else(|| anyhow!("Missing connection host"))?
+ {
+ url::Host::Domain(v) => v.to_ascii_lowercase(),
+ url::Host::Ipv4(v) => v.to_string(),
+ url::Host::Ipv6(v) => v.to_string(),
+ };
+ let port = url
+ .port()
+ .ok_or_else(|| anyhow!("Missing connection port"))?;
+ ensure!(port != 0, "Connection port must be positive");
+ if entry.name == "Invalid entry" || entry.name.is_empty() {
+ entry.name = display_text(&host);
+ }
+ let mut server_name = host.clone();
+ let mut alpn = vec![];
+ let mut fingerprint = None;
+ let mut seen = std::collections::HashSet::new();
+ for (key, value) in url.query_pairs() {
+ ensure!(seen.insert(key.to_string()), "Duplicate connection option");
+ match key.as_ref() {
+ "type" if matches!(value.as_ref(), "tcp" | "raw") => {}
+ "security" if value == "tls" => {}
+ "sni" | "peer" => {
+ ensure!(
+ !value.is_empty() && !value.chars().any(char::is_control),
+ "Invalid TLS server name"
+ );
+ server_name = value.into_owned();
+ }
+ "alpn" => {
+ alpn = value.split(',').map(str::to_owned).collect();
+ ensure!(
+ alpn.iter().all(|v| !v.is_empty()
+ && v.len() <= 255
+ && !v.chars().any(char::is_control)),
+ "Invalid TLS ALPN list"
+ );
+ }
+ "fp" => {
+ ensure!(
+ matches!(
+ value.as_ref(),
+ "chrome"
+ | "firefox"
+ | "safari"
+ | "ios"
+ | "android"
+ | "edge"
+ | "360"
+ | "qq"
+ | "random"
+ | "randomized"
+ ),
+ "Unsupported TLS fingerprint"
+ );
+ fingerprint = Some(value.into_owned());
+ }
+ "allowInsecure" if value == "0" || value == "false" => {}
+ "headerType" if value == "none" => {}
+ _ => {
+ entry.status = "unsupported".into();
+ bail!("Unsupported transport, TLS, or connection option");
+ }
+ }
+ }
+ ensure!(
+ !(seen.contains("sni") && seen.contains("peer")),
+ "Specify only one TLS server name option"
+ );
+ Ok(Trojan {
+ host,
+ port,
+ password,
+ server_name,
+ alpn,
+ fingerprint,
+ })
+ })();
+ match result {
+ Ok(connection) => {
+ entry.status = "supported".into();
+ entry.connection = Some(connection);
+ }
+ Err(error) => entry.explanation = Some(error.to_string()),
+ }
+ entry
+}
diff --git a/src/tui.rs b/src/tui.rs
new file mode 100644
index 0000000..9cb69a7
--- /dev/null
+++ b/src/tui.rs
@@ -0,0 +1,565 @@
+use crate::{
+ bus::Client,
+ controller::Operation,
+ model::{ConfigurationSummary, Settings, Status, SubscriptionSummary},
+};
+use anyhow::{Result, ensure};
+use crossterm::{
+ event::{self, Event, KeyCode, KeyModifiers},
+ execute,
+ terminal::{self, EnterAlternateScreen, LeaveAlternateScreen},
+};
+use ratatui::{
+ Terminal,
+ backend::CrosstermBackend,
+ layout::{Constraint, Layout},
+ style::{Color, Modifier, Style},
+ widgets::{Block, Borders, Cell, Paragraph, Row, Table, TableState, Tabs},
+};
+use std::{
+ io::{self, IsTerminal},
+ time::Duration,
+};
+
+type Screen = Terminal<CrosstermBackend<io::Stdout>>;
+struct Restore;
+impl Drop for Restore {
+ fn drop(&mut self) {
+ let _ = terminal::disable_raw_mode();
+ let _ = execute!(io::stdout(), LeaveAlternateScreen);
+ }
+}
+
+struct Ui {
+ tab: usize,
+ row: usize,
+ viewed: Option<String>,
+ filter: String,
+ editing_filter: bool,
+ chooser: bool,
+ chooser_query: String,
+ chooser_row: usize,
+ console: bool,
+ details: bool,
+ help: bool,
+ first_g: bool,
+ status: Status,
+ subscriptions: Vec<SubscriptionSummary>,
+ entries: Vec<ConfigurationSummary>,
+ settings: Settings,
+ message: String,
+ logs: Vec<String>,
+}
+
+impl Ui {
+ async fn refresh(&mut self, client: &Client) -> Result<()> {
+ self.status = client.read("Status").await?;
+ self.subscriptions = client.read("Subscriptions").await?;
+ if !self
+ .subscriptions
+ .iter()
+ .any(|s| Some(&s.name) == self.viewed.as_ref())
+ {
+ self.viewed = self.subscriptions.first().map(|s| s.name.clone());
+ }
+ self.entries = client.configurations(self.viewed.as_deref()).await?;
+ self.settings = client.read("Settings").await?;
+ self.logs = client.read("Logs").await?;
+ self.row = self.row.min(self.visible().len().saturating_sub(1));
+ Ok(())
+ }
+ fn visible(&self) -> Vec<&ConfigurationSummary> {
+ let query = self.filter.to_lowercase();
+ self.entries
+ .iter()
+ .filter(|e| {
+ e.name.to_lowercase().contains(&query)
+ || e.host
+ .as_deref()
+ .unwrap_or("")
+ .to_lowercase()
+ .contains(&query)
+ })
+ .collect()
+ }
+ fn choices(&self) -> Vec<&SubscriptionSummary> {
+ self.subscriptions
+ .iter()
+ .filter(|s| fuzzy(&self.chooser_query, &s.name))
+ .collect()
+ }
+ fn selected_row(&self) -> Option<&ConfigurationSummary> {
+ self.visible().get(self.row).copied()
+ }
+}
+
+pub async fn run(client: Client) -> Result<()> {
+ ensure!(
+ io::stdin().is_terminal() && io::stdout().is_terminal(),
+ "The TUI needs a terminal; use status --json for scripts"
+ );
+ let status = client.read("Status").await?;
+ let mut ui = Ui {
+ tab: 0,
+ row: 0,
+ viewed: None,
+ filter: String::new(),
+ editing_filter: false,
+ chooser: false,
+ chooser_query: String::new(),
+ chooser_row: 0,
+ console: false,
+ details: false,
+ help: false,
+ first_g: false,
+ status,
+ subscriptions: vec![],
+ entries: vec![],
+ settings: Settings::default(),
+ message: String::new(),
+ logs: vec![],
+ };
+ ui.refresh(&client).await?;
+ terminal::enable_raw_mode()?;
+ execute!(io::stdout(), EnterAlternateScreen)?;
+ let _restore = Restore;
+ let mut screen = Terminal::new(CrosstermBackend::new(io::stdout()))?;
+ let mut last_refresh = std::time::Instant::now();
+ loop {
+ draw(&mut screen, &ui)?;
+ if !event::poll(Duration::from_millis(200))? {
+ if last_refresh.elapsed() >= Duration::from_secs(2) {
+ if let Err(error) = ui.refresh(&client).await {
+ ui.message = error.to_string();
+ }
+ last_refresh = std::time::Instant::now();
+ }
+ continue;
+ }
+ let Event::Key(key) = event::read()? else {
+ continue;
+ };
+ if key.kind == event::KeyEventKind::Release {
+ continue;
+ }
+ if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) {
+ break;
+ }
+ if ui.chooser {
+ match key.code {
+ KeyCode::Esc => ui.chooser = false,
+ KeyCode::Enter => {
+ if let Some(name) = ui.choices().get(ui.chooser_row).map(|s| s.name.clone()) {
+ ui.viewed = Some(name);
+ ui.filter.clear();
+ ui.row = 0;
+ }
+ ui.chooser = false;
+ ui.refresh(&client).await?;
+ }
+ KeyCode::Down => {
+ ui.chooser_row = (ui.chooser_row + 1).min(ui.choices().len().saturating_sub(1))
+ }
+ KeyCode::Up => ui.chooser_row = ui.chooser_row.saturating_sub(1),
+ KeyCode::Char('n' | 'j') if key.modifiers.contains(KeyModifiers::CONTROL) => {
+ ui.chooser_row = (ui.chooser_row + 1).min(ui.choices().len().saturating_sub(1))
+ }
+ KeyCode::Char('p' | 'k') if key.modifiers.contains(KeyModifiers::CONTROL) => {
+ ui.chooser_row = ui.chooser_row.saturating_sub(1)
+ }
+ KeyCode::Backspace => {
+ ui.chooser_query.pop();
+ ui.chooser_row = 0;
+ }
+ KeyCode::Char(c) => {
+ ui.chooser_query.push(c);
+ ui.chooser_row = 0;
+ }
+ _ => {}
+ }
+ continue;
+ }
+ if ui.editing_filter {
+ match key.code {
+ KeyCode::Esc => {
+ ui.filter.clear();
+ ui.editing_filter = false;
+ }
+ KeyCode::Enter => ui.editing_filter = false,
+ KeyCode::Backspace => {
+ ui.filter.pop();
+ }
+ KeyCode::Char(c) => ui.filter.push(c),
+ _ => {}
+ }
+ ui.row = 0;
+ continue;
+ }
+ if ui.details || ui.help {
+ if matches!(key.code, KeyCode::Esc | KeyCode::Enter | KeyCode::Char('q')) {
+ ui.details = false;
+ ui.help = false;
+ }
+ continue;
+ }
+ let mut operation = None;
+ match key.code {
+ KeyCode::Char('q') => break,
+ KeyCode::Esc => {
+ ui.tab = 0;
+ ui.filter.clear();
+ }
+ KeyCode::Char('h') | KeyCode::Left => {
+ ui.tab = (ui.tab + 2) % 3;
+ ui.row = 0;
+ }
+ KeyCode::Char('l') | KeyCode::Right => {
+ ui.tab = (ui.tab + 1) % 3;
+ ui.row = 0;
+ }
+ KeyCode::Char('j') | KeyCode::Down => ui.row = ui.row.saturating_add(1),
+ KeyCode::Char('k') | KeyCode::Up => ui.row = ui.row.saturating_sub(1),
+ KeyCode::Char('g') if ui.first_g => {
+ ui.row = 0;
+ ui.first_g = false;
+ }
+ KeyCode::Char('g') => {
+ ui.first_g = true;
+ continue;
+ }
+ KeyCode::Char('G') => {
+ ui.row = if ui.tab == 1 {
+ ui.subscriptions.len()
+ } else {
+ ui.visible().len()
+ }
+ .saturating_sub(1)
+ }
+ KeyCode::Char('/') if ui.tab == 0 => {
+ ui.editing_filter = true;
+ }
+ KeyCode::Char('s') => {
+ ui.chooser = true;
+ ui.chooser_query.clear();
+ ui.chooser_row = 0;
+ }
+ KeyCode::Char('J' | 'K') => {
+ if !ui.subscriptions.is_empty() {
+ let index = ui
+ .subscriptions
+ .iter()
+ .position(|s| Some(&s.name) == ui.viewed.as_ref())
+ .unwrap_or(0);
+ let next = if key.code == KeyCode::Char('J') {
+ (index + 1) % ui.subscriptions.len()
+ } else {
+ (index + ui.subscriptions.len() - 1) % ui.subscriptions.len()
+ };
+ ui.viewed = Some(ui.subscriptions[next].name.clone());
+ ui.filter.clear();
+ ui.row = 0;
+ ui.refresh(&client).await?;
+ }
+ }
+ KeyCode::Char('c') => ui.console = !ui.console,
+ KeyCode::Char('?') => ui.help = true,
+ KeyCode::Char('d') if ui.tab == 0 => ui.details = true,
+ KeyCode::Char(' ') => {
+ operation = Some(if ui.status.requested_enabled {
+ Operation::Disable
+ } else {
+ Operation::Enable
+ })
+ }
+ KeyCode::Char('x') if ui.tab == 0 => operation = Some(Operation::Unset),
+ KeyCode::Char('u') => {
+ operation = Some(Operation::Refresh {
+ name: if ui.tab == 1 {
+ ui.subscriptions.get(ui.row).map(|s| s.name.clone())
+ } else {
+ ui.viewed.clone()
+ },
+ })
+ }
+ KeyCode::Enter if ui.tab == 0 => {
+ if let Some(entry) = ui.selected_row() {
+ operation = Some(Operation::Select {
+ id: entry.id.clone(),
+ });
+ }
+ }
+ KeyCode::Enter if ui.tab == 1 => {
+ if let Some(s) = ui.subscriptions.get(ui.row) {
+ ui.viewed = Some(s.name.clone());
+ ui.filter.clear();
+ ui.row = 0;
+ ui.tab = 0;
+ ui.refresh(&client).await?;
+ }
+ }
+ KeyCode::Char('a') if ui.tab == 1 => {
+ match form(
+ &mut screen,
+ "Add subscription",
+ &["Name", "URL"],
+ &[String::new(), String::new()],
+ )? {
+ Some(values) => {
+ operation = Some(Operation::AddSubscription {
+ name: values[0].clone(),
+ url: values[1].clone(),
+ })
+ }
+ None => {}
+ }
+ }
+ KeyCode::Char('e') if ui.tab == 1 => {
+ if let Some(s) = ui.subscriptions.get(ui.row) {
+ if let Some(values) = form(
+ &mut screen,
+ "Edit subscription URL",
+ &["URL"],
+ &[String::new()],
+ )? {
+ operation = Some(Operation::EditSubscription {
+ name: s.name.clone(),
+ url: values[0].clone(),
+ });
+ }
+ }
+ }
+ KeyCode::Char('x') if ui.tab == 1 => {
+ if let Some(s) = ui.subscriptions.get(ui.row) {
+ if let Some(values) = form(
+ &mut screen,
+ "Type delete to remove this subscription",
+ &["Confirmation"],
+ &[String::new()],
+ )? {
+ if values[0] == "delete" {
+ operation = Some(Operation::DeleteSubscription {
+ name: s.name.clone(),
+ });
+ }
+ }
+ }
+ }
+ KeyCode::Char('e') | KeyCode::Enter if ui.tab == 2 => {
+ let current = [
+ &ui.settings.ping_url,
+ &ui.settings.speed_url,
+ &ui.settings.maximum_download_mib.to_string(),
+ &ui.settings.maximum_duration_seconds.to_string(),
+ ]
+ .map(|v| v.to_string());
+ if let Some(values) = form(
+ &mut screen,
+ "Settings",
+ &[
+ "HTTPS Ping URL",
+ "Speed URL",
+ "Maximum MiB",
+ "Maximum seconds",
+ ],
+ &current,
+ )? {
+ let parsed = (values[2].parse::<u64>(), values[3].parse::<u64>());
+ match parsed {
+ (Ok(bytes), Ok(seconds)) => {
+ operation = Some(Operation::SaveSettings {
+ settings: Settings {
+ ping_url: values[0].clone(),
+ speed_url: values[1].clone(),
+ maximum_download_mib: bytes,
+ maximum_duration_seconds: seconds,
+ },
+ })
+ }
+ _ => ui.message = "Limits must be positive integers".into(),
+ }
+ }
+ }
+ KeyCode::Char('e') => {
+ suspend(&mut screen)?;
+ let result = crate::editor::open(&ui.logs);
+ resume(&mut screen)?;
+ if let Err(error) = result {
+ ui.message = error.to_string();
+ }
+ }
+ KeyCode::Char('t' | 'b') if ui.tab == 0 => {
+ if let Some(entry) = ui.selected_row() {
+ let id = entry.id.clone();
+ suspend(&mut screen)?;
+ let result = async {
+ let _agent = crate::authentication::TerminalAgent::start(true)?;
+ client
+ .diagnostic(Some(&id), key.code == KeyCode::Char('b'), true)
+ .await
+ }
+ .await;
+ resume(&mut screen)?;
+ ui.message = match result {
+ Ok(value) => value,
+ Err(error) => error.to_string(),
+ };
+ }
+ }
+ _ => {}
+ }
+ ui.first_g = false;
+ if let Some(operation) = operation {
+ suspend(&mut screen)?;
+ let result = async {
+ let _agent = crate::authentication::TerminalAgent::start(true)?;
+ client.change(operation, true).await
+ }
+ .await;
+ resume(&mut screen)?;
+ ui.message = match result {
+ Ok(()) => "Applied".into(),
+ Err(error) => error.to_string(),
+ };
+ ui.refresh(&client).await?;
+ }
+ ui.row = ui.row.min(
+ if ui.tab == 1 {
+ ui.subscriptions.len()
+ } else {
+ ui.visible().len()
+ }
+ .saturating_sub(1),
+ );
+ }
+ Ok(())
+}
+
+fn suspend(screen: &mut Screen) -> Result<()> {
+ terminal::disable_raw_mode()?;
+ execute!(screen.backend_mut(), LeaveAlternateScreen)?;
+ Ok(())
+}
+fn resume(screen: &mut Screen) -> Result<()> {
+ terminal::enable_raw_mode()?;
+ execute!(screen.backend_mut(), EnterAlternateScreen)?;
+ screen.clear()?;
+ Ok(())
+}
+
+fn form(
+ screen: &mut Screen,
+ title: &str,
+ labels: &[&str],
+ initial: &[String],
+) -> Result<Option<Vec<String>>> {
+ let mut values = initial.to_vec();
+ let mut field = 0;
+ let mut discarding = false;
+ loop {
+ screen.draw(|frame| {
+ let text = labels
+ .iter()
+ .enumerate()
+ .map(|(i, label)| {
+ let value = if *label == "URL" {
+ "*".repeat(values[i].chars().count())
+ } else {
+ values[i].clone()
+ };
+ format!("{} {label}: {value}", if field == i { ">" } else { " " })
+ })
+ .collect::<Vec<_>>()
+ .join("\n\n");
+ let help = if discarding {
+ "Discard edits? y confirms, any other key resumes"
+ } else {
+ "Tab moves fields. Enter saves from the last field. Esc cancels."
+ };
+ frame.render_widget(
+ Paragraph::new(format!("{text}\n\n{help}"))
+ .block(Block::default().borders(Borders::ALL).title(title)),
+ frame.area(),
+ );
+ })?;
+ let Event::Key(key) = event::read()? else {
+ continue;
+ };
+ if discarding {
+ if key.code == KeyCode::Char('y') {
+ return Ok(None);
+ }
+ discarding = false;
+ continue;
+ }
+ match key.code {
+ KeyCode::Esc => {
+ if values == initial {
+ return Ok(None);
+ }
+ discarding = true;
+ }
+ KeyCode::Tab => field = (field + 1) % values.len(),
+ KeyCode::BackTab => field = (field + values.len() - 1) % values.len(),
+ KeyCode::Enter if field + 1 == values.len() => return Ok(Some(values)),
+ KeyCode::Enter => field += 1,
+ KeyCode::Backspace => {
+ values[field].pop();
+ }
+ KeyCode::Char(c) if !key.modifiers.contains(KeyModifiers::CONTROL) => {
+ if values[field].len() < 16384 {
+ values[field].push(c);
+ }
+ }
+ _ => {}
+ }
+ }
+}
+
+fn draw(screen: &mut Screen, ui: &Ui) -> Result<()> {
+ screen.draw(|frame| {
+ let layout = Layout::vertical([Constraint::Length(1), Constraint::Length(2), Constraint::Min(4), Constraint::Length(if ui.console { 6 } else { 0 }), Constraint::Length(2)]).split(frame.area());
+ frame.render_widget(Tabs::new(["Connections", "Subscriptions", "Settings"]).select(ui.tab).highlight_style(Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)), layout[0]);
+ let actions = match ui.tab {
+ 0 => format!("{} [s] Update [u] Details [d] Ping [t] Speed [b] {}", ui.viewed.as_deref().unwrap_or("No subscription"), if ui.filter.is_empty() && !ui.editing_filter { String::new() } else { format!("/ {}", ui.filter) }),
+ 1 => "Add [a] Edit [e] Update [u] Delete [x] Open [Enter]".into(),
+ _ => "Edit settings [e]".into(),
+ };
+ frame.render_widget(Paragraph::new(actions), layout[1]);
+ if ui.help {
+ frame.render_widget(Paragraph::new("h/l: screens j/k: rows gg/G: first/last\nEnter: select/open Space: enable/disable x: unset/delete\ns: subscription chooser J/K: cycle subscriptions /: filter\nd: details t: Ping b: Speed c: console e: logs/editor\n?: help Esc: back/clear q: quit\nSelection and viewed subscription are separate.\nClosing this interface leaves the service running."), layout[2]);
+ } else if ui.details {
+ let text = ui.selected_row().map(|e| format!("{}\nID: {}\nHost: {}\nPort: {}\nStatus: {}\n{}", e.name, e.id, e.host.as_deref().unwrap_or("unavailable"), e.port.map(|p| p.to_string()).unwrap_or_default(), e.status, e.explanation.as_deref().unwrap_or("Trojan / TCP / TLS"))).unwrap_or_else(|| "No configuration highlighted".into());
+ frame.render_widget(Paragraph::new(text), layout[2]);
+ } else if ui.chooser {
+ let choices = ui.choices();
+ let rows = choices.iter().map(|s| Row::new([s.name.as_str()]));
+ let mut state = TableState::default().with_selected(Some(ui.chooser_row));
+ frame.render_stateful_widget(Table::new(rows, [Constraint::Percentage(100)]).block(Block::default().borders(Borders::ALL).title(format!("Choose subscription / {}", ui.chooser_query))).row_highlight_style(Style::default().bg(Color::DarkGray)), layout[2], &mut state);
+ } else if ui.tab == 0 {
+ let rows = ui.visible().into_iter().map(|entry| Row::new([
+ Cell::from(if entry.selected { "*" } else { " " }),
+ Cell::from(format!("{}{}", if entry.status == "supported" { String::new() } else { format!("[{}] ", entry.status) }, entry.name)),
+ Cell::from(entry.host.clone().unwrap_or_default()), Cell::from(entry.ping_ms.map(|v| format!("{v} ms")).unwrap_or_else(|| "?".into())), Cell::from(entry.speed_bytes_per_second.map(|v| format!("{:.1} MiB/s", v / 1048576.0)).unwrap_or_else(|| "?".into())),
+ ]));
+ let mut state = TableState::default().with_selected(Some(ui.row));
+ frame.render_stateful_widget(Table::new(rows, [Constraint::Length(1), Constraint::Percentage(40), Constraint::Percentage(35), Constraint::Length(9), Constraint::Length(9)]).header(Row::new(["", "Name", "Host", "Delay", "Speed"])).row_highlight_style(Style::default().bg(Color::DarkGray)), layout[2], &mut state);
+ } else if ui.tab == 1 {
+ let mut state = TableState::default().with_selected(Some(ui.row));
+ frame.render_stateful_widget(Table::new(ui.subscriptions.iter().map(|s| Row::new([s.name.as_str()])), [Constraint::Percentage(100)]).row_highlight_style(Style::default().bg(Color::DarkGray)), layout[2], &mut state);
+ } else {
+ frame.render_widget(Paragraph::new(format!("HTTPS Ping URL: {}\nSpeed URL: {}\nMaximum download MiB: {}\nMaximum duration seconds: {}", ui.settings.ping_url, ui.settings.speed_url, ui.settings.maximum_download_mib, ui.settings.maximum_duration_seconds)), layout[2]);
+ }
+ if ui.console { frame.render_widget(Paragraph::new(ui.logs.iter().rev().take(5).cloned().collect::<Vec<_>>().join("\n")).block(Block::default().borders(Borders::TOP)), layout[3]); }
+ frame.render_widget(Paragraph::new(format!("{} | Routing: {} | Health: {} | Selected: {}\n{}", if ui.status.requested_enabled { "Enabled" } else { "Disabled" }, ui.status.routing, ui.status.health, ui.status.selected_configuration.as_deref().unwrap_or("unset"), ui.message)), layout[4]);
+ })?;
+ Ok(())
+}
+
+fn fuzzy(query: &str, value: &str) -> bool {
+ let value = value.to_lowercase();
+ let mut chars = value.chars();
+ query
+ .to_lowercase()
+ .chars()
+ .all(|wanted| chars.by_ref().any(|c| c == wanted))
+}
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.
+ }
+}