diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/authentication.rs | 52 | ||||
| -rw-r--r-- | src/bus.rs | 357 | ||||
| -rw-r--r-- | src/controller.rs | 478 | ||||
| -rw-r--r-- | src/daemon.rs | 74 | ||||
| -rw-r--r-- | src/diagnostics.rs | 172 | ||||
| -rw-r--r-- | src/editor.rs | 26 | ||||
| -rw-r--r-- | src/installation.rs | 152 | ||||
| -rw-r--r-- | src/lib.rs | 16 | ||||
| -rw-r--r-- | src/linux/firewall.rs | 234 | ||||
| -rw-r--r-- | src/linux/mod.rs | 119 | ||||
| -rw-r--r-- | src/linux/routes.rs | 186 | ||||
| -rw-r--r-- | src/locking.rs | 35 | ||||
| -rw-r--r-- | src/main.rs | 244 | ||||
| -rw-r--r-- | src/model.rs | 147 | ||||
| -rw-r--r-- | src/runtime.rs | 34 | ||||
| -rw-r--r-- | src/storage.rs | 264 | ||||
| -rw-r--r-- | src/subscription.rs | 305 | ||||
| -rw-r--r-- | src/tui.rs | 565 | ||||
| -rw-r--r-- | src/xray.rs | 206 |
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", + ], + ¤t, + )? { + 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. + } +} |