summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorAndrew Guschin <guschin@altlinux.org>2026-10-30 03:32:00 +0400
committerAndrew Guschin <guschin@altlinux.org>2026-10-30 03:32:00 +0400
commitfc3c1d51b1be9db6ba6ad286584fbc34bb305f38 (patch)
treed23628e0534f0a4646c111c2ef6dd1501f18ae17 /src
parentd608ffeeda1a479c9dc0e5f6ad269ba07a510d7f (diff)
Fix protected activation ordering and diagnostic review findings
Diffstat (limited to 'src')
-rw-r--r--src/controller.rs97
-rw-r--r--src/diagnostics.rs8
-rw-r--r--src/linux/firewall.rs18
-rw-r--r--src/linux/mod.rs53
-rw-r--r--src/linux/routes.rs3
-rw-r--r--src/main.rs3
-rw-r--r--src/model.rs19
-rw-r--r--src/runtime.rs7
-rw-r--r--src/storage.rs65
-rw-r--r--src/subscription.rs10
-rw-r--r--src/tui.rs9
-rw-r--r--src/xray.rs39
12 files changed, 266 insertions, 65 deletions
diff --git a/src/controller.rs b/src/controller.rs
index 1384ad8..4686118 100644
--- a/src/controller.rs
+++ b/src/controller.rs
@@ -47,6 +47,7 @@ pub struct Controller {
logs: std::collections::VecDeque<String>,
next_check: Option<tokio::time::Instant>,
measurements: Vec<crate::diagnostics::Observation>,
+ cooldowns: std::collections::HashMap<String, Option<tokio::time::Instant>>,
}
impl Controller {
@@ -73,6 +74,7 @@ impl Controller {
logs: std::collections::VecDeque::new(),
next_check: Some(tokio::time::Instant::now()),
measurements: vec![],
+ cooldowns: std::collections::HashMap::new(),
})
}
@@ -145,7 +147,7 @@ impl Controller {
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,
+ status: entry.status.as_str().into(),
explanation: entry.explanation,
});
}
@@ -154,9 +156,13 @@ impl Controller {
}
pub async fn restore(&mut self) -> Result<()> {
- if self.storage.state()?.0 {
- self.runtime.arm().await?;
- self.reconcile().await?;
+ let (enabled, selected, _) = self.storage.state()?;
+ if enabled {
+ self.runtime.block().await?;
+ if selected.is_some() {
+ self.runtime.arm().await?;
+ self.reconcile().await?;
+ }
} else {
self.runtime.disable().await?;
}
@@ -211,6 +217,16 @@ impl Controller {
}
Operation::DeleteSubscription { name } => {
let id = self.subscription(&name)?.id.clone();
+ let (enabled, selected, _) = self.storage.state()?;
+ if enabled
+ && self
+ .storage
+ .entries(&id)?
+ .iter()
+ .any(|entry| Some(&entry.id) == selected.as_ref())
+ {
+ self.runtime.block().await?;
+ }
let mut definitions = self.storage.definitions.clone();
definitions.subscriptions.retain(|s| s.id != id);
self.storage.save_definitions(&definitions)?;
@@ -241,10 +257,16 @@ impl Controller {
}
Operation::Select { id } => {
self.usable_entry(&id)?;
+ if self.storage.state()?.0 {
+ self.runtime.block().await?;
+ }
self.storage.select(Some(&id))?;
self.reconcile().await?;
}
Operation::Unset => {
+ if self.storage.state()?.0 {
+ self.runtime.block().await?;
+ }
self.storage.select(None)?;
self.reconcile().await?;
}
@@ -302,21 +324,48 @@ impl Controller {
.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
- },
+ let url = if speed {
+ &settings.speed_url
+ } else {
+ &settings.ping_url
+ };
+ let provider = url::Url::parse(url)?.origin().ascii_serialization();
+ if let Some(deadline) = self.cooldowns.get(&provider) {
+ ensure!(
+ deadline.is_some_and(|deadline| tokio::time::Instant::now() >= deadline),
+ "The diagnostic provider requested a pause; retry after its Retry-After delay"
+ );
+ }
+ let probe = self
+ .runtime
+ .probe(
+ entry.connection.as_ref().unwrap(),
+ if speed {
+ settings.maximum_duration_seconds
+ } else {
+ 5
+ },
+ )
+ .await?;
+ let result = crate::diagnostics::measure(
+ &settings,
+ &probe.proxy,
+ &probe.certificate,
+ id.into(),
+ generation,
+ speed,
)
- .await?;
- let result =
- crate::diagnostics::measure(&settings, &probe.proxy, id.into(), generation, speed)
- .await;
+ .await;
probe.stop().await;
match result {
Ok(observation) => {
+ if let Some(wait) = observation.retry_after_seconds {
+ self.cooldowns.insert(
+ provider,
+ tokio::time::Instant::now()
+ .checked_add(std::time::Duration::from_secs(wait)),
+ );
+ }
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()
@@ -421,6 +470,19 @@ impl Controller {
}
}
}
+ let (enabled, selected, _) = self.storage.state()?;
+ if enabled {
+ if let Some(id) = selected {
+ if let Some(previous) = old.iter().find(|entry| entry.id == id) {
+ let replacement = entries.iter().find(|entry| entry.id == id);
+ if replacement.and_then(|entry| entry.connection.as_ref())
+ != previous.connection.as_ref()
+ {
+ self.runtime.block().await?;
+ }
+ }
+ }
+ }
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()));
@@ -443,7 +505,7 @@ impl Controller {
.find_entry(id)?
.ok_or_else(|| anyhow!("Configuration not found; run configuration list"))?;
ensure!(
- entry.connection.is_some(),
+ entry.status == EntryStatus::Supported && entry.connection.is_some(),
"Configuration is invalid or unsupported; inspect its explanation"
);
Ok(entry)
@@ -452,6 +514,9 @@ impl Controller {
async fn reconcile(&mut self) -> Result<()> {
let (enabled, selected, generation) = self.storage.state()?;
if enabled {
+ if selected.is_none() {
+ return self.runtime.block().await;
+ }
let entry = selected.map(|id| self.usable_entry(&id)).transpose()?;
if let Err(error) = self
.runtime
diff --git a/src/diagnostics.rs b/src/diagnostics.rs
index a2052ec..432a801 100644
--- a/src/diagnostics.rs
+++ b/src/diagnostics.rs
@@ -18,11 +18,12 @@ pub struct Observation {
pub retry_after_seconds: Option<u64>,
}
-/// The caller supplies an authenticated, service-owned SOCKS endpoint. This client
+/// The caller supplies an authenticated, service-owned HTTPS proxy endpoint. This client
/// never inherits environment proxies or creates a direct route to the target.
pub async fn measure(
settings: &Settings,
proxy: &str,
+ certificate: &str,
id: String,
generation: u64,
speed: bool,
@@ -42,11 +43,14 @@ pub async fn measure(
};
let proxy =
reqwest::Proxy::all(proxy).map_err(|_| anyhow!("Invalid protected probe endpoint"))?;
+ let certificate = reqwest::Certificate::from_pem(certificate.as_bytes())
+ .map_err(|_| anyhow!("Invalid private probe certificate"))?;
let client = reqwest::Client::builder()
.no_proxy()
.proxy(proxy)
.redirect(reqwest::redirect::Policy::none())
- .timeout(Duration::from_secs(duration))
+ .tls_certs_merge([certificate])
+ .retry(reqwest::retry::never())
.user_agent(concat!("Roentgen/", env!("CARGO_PKG_VERSION")))
.build()
.map_err(|_| anyhow!("Could not initialize diagnostic TLS client"))?;
diff --git a/src/linux/firewall.rs b/src/linux/firewall.rs
index 79731b7..8a0dd43 100644
--- a/src/linux/firewall.rs
+++ b/src/linux/firewall.rs
@@ -84,6 +84,12 @@ pub fn preflight() -> Result<()> {
/// Replacing the table is one kernel transaction. Connection marks survive table replacement.
pub fn apply(enabled: bool, xray_uid: u32) -> Result<()> {
+ apply_policy(enabled, xray_uid, false)
+}
+pub fn forward(xray_uid: u32) -> Result<()> {
+ apply_policy(true, xray_uid, true)
+}
+fn apply_policy(enabled: bool, xray_uid: u32, allow_tun: bool) -> Result<()> {
let existing = tables()?;
let table = Table::new(TABLE, ProtoFamily::Inet);
let mut batch = Batch::new();
@@ -124,11 +130,13 @@ pub fn apply(enabled: bool, xray_uid: u32) -> Result<()> {
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);
+ if allow_tun {
+ 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")
}
diff --git a/src/linux/mod.rs b/src/linux/mod.rs
index 68bb3a8..fd9c471 100644
--- a/src/linux/mod.rs
+++ b/src/linux/mod.rs
@@ -9,12 +9,14 @@ use std::{path::Path, time::Duration};
pub struct LinuxRuntime {
routing: String,
applied: Option<Trojan>,
+ armed: bool,
}
impl Default for LinuxRuntime {
fn default() -> Self {
Self {
routing: "unknown".into(),
applied: None,
+ armed: false,
}
}
}
@@ -25,17 +27,39 @@ impl Runtime for LinuxRuntime {
xray::preflight().await?;
firewall::preflight()?;
routes::preflight().await?;
+ anyhow::ensure!(
+ self.applied.is_some() || routes::tun_index().await?.is_none(),
+ "roentgen0 already exists outside the active owned configuration; resolve the interface conflict before enabling"
+ );
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();
+ self.armed = true;
+ Ok(())
+ }
+ async fn block(&mut self) -> Result<()> {
+ firewall::apply(true, xray::identity()?.0)?;
+ write_guard(true)?;
+ self.routing = "blocked".into();
+ self.applied = None;
+ xray::systemctl(&["stop", "roentgen-xray.service"]).await?;
+ routes::remove().await?;
Ok(())
}
async fn apply(&mut self, connection: Option<&Trojan>, _: u64) -> Result<()> {
- if self.applied.as_ref() == connection {
+ if connection.is_none() {
+ return self.block().await;
+ }
+ anyhow::ensure!(
+ self.armed,
+ "Activation requires successful compatibility checks and an armed guard"
+ );
+ if connection.is_some() && self.applied.as_ref() == connection {
if let Some(index) = routes::tun_index().await? {
routes::capture(index).await?;
+ firewall::forward(xray::identity()?.0)?;
self.routing = "tun".into();
return Ok(());
}
@@ -45,14 +69,22 @@ impl Runtime for LinuxRuntime {
xray::systemctl(&["stop", "roentgen-xray.service"]).await?;
routes::remove().await?;
if let Some(connection) = connection {
+ anyhow::ensure!(
+ routes::tun_index().await?.is_none(),
+ "roentgen0 remained after stopping the owned Xray unit; refusing to take over another interface"
+ );
+ xray::preflight().await?;
+ firewall::preflight()?;
+ routes::preflight().await?;
xray::write_config(
Path::new("/run/roentgen/xray.json"),
- &xray::configuration(connection, true, None),
+ &xray::configuration(connection, true),
)?;
xray::systemctl(&["start", "roentgen-xray.service"]).await?;
for _ in 0..100 {
if let Some(index) = routes::tun_index().await? {
routes::capture(index).await?;
+ firewall::forward(xray::identity()?.0)?;
self.routing = "tun".into();
self.applied = Some(connection.clone());
return Ok(());
@@ -64,14 +96,16 @@ impl Runtime for LinuxRuntime {
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,
+ if connection.is_none() {
+ return self.block().await;
+ }
+ if !self.armed {
+ self.arm().await?;
}
+ self.apply(connection, generation).await
+ }
+ async fn probe(&mut self, connection: &Trojan, duration: u64) -> Result<xray::Probe> {
+ xray::Probe::start(connection, duration).await
}
async fn disable(&mut self) -> Result<()> {
xray::systemctl(&["stop", "roentgen-xray.service"]).await?;
@@ -80,6 +114,7 @@ impl Runtime for LinuxRuntime {
write_guard(false)?;
firewall::apply(false, xray::identity()?.0)?;
self.routing = "direct".into();
+ self.armed = false;
Ok(())
}
fn routing(&self) -> &str {
diff --git a/src/linux/routes.rs b/src/linux/routes.rs
index 07083bb..4212183 100644
--- a/src/linux/routes.rs
+++ b/src/linux/routes.rs
@@ -84,6 +84,9 @@ fn owned_rule(rule: &rtnetlink::packet_route::rule::RuleMessage) -> bool {
}
pub async fn capture(interface: u32) -> Result<()> {
+ // Only the product-owned TUN changes this setting. Loose reverse-path
+ // validation accepts proxied replies whose ordinary reverse route is uplink.
+ std::fs::write("/proc/sys/net/ipv4/conf/roentgen0/rp_filter", "2")?;
let handle = connection()?;
for route in [
RouteMessageBuilder::<Ipv4Addr>::new()
diff --git a/src/main.rs b/src/main.rs
index 7ad2d81..f4c5373 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -59,6 +59,8 @@ enum Command {
Daemon,
#[command(hide = true)]
Guard,
+ #[command(hide = true)]
+ XrayPreflight,
}
#[derive(Subcommand)]
@@ -114,6 +116,7 @@ async fn run(cli: Cli) -> Result<()> {
Command::Recover { .. } => return roentgen::installation::recover().await,
Command::Daemon => return roentgen::daemon::run().await,
Command::Guard => return roentgen::daemon::guard().await,
+ Command::XrayPreflight => return roentgen::xray::preflight().await,
_ => {}
}
let client = Client::system().await?;
diff --git a/src/model.rs b/src/model.rs
index 8966956..8229207 100644
--- a/src/model.rs
+++ b/src/model.rs
@@ -16,11 +16,28 @@ impl Trojan {
}
}
+#[derive(Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
+#[serde(rename_all = "lowercase")]
+pub enum EntryStatus {
+ Supported,
+ Unsupported,
+ Invalid,
+}
+impl EntryStatus {
+ pub fn as_str(self) -> &'static str {
+ match self {
+ Self::Supported => "supported",
+ Self::Unsupported => "unsupported",
+ Self::Invalid => "invalid",
+ }
+ }
+}
+
#[derive(Clone, Serialize, Deserialize)]
pub struct Entry {
pub id: String,
pub name: String,
- pub status: String,
+ pub status: EntryStatus,
pub explanation: Option<String>,
pub connection: Option<Trojan>,
}
diff --git a/src/runtime.rs b/src/runtime.rs
index 96ffd0d..2d5552d 100644
--- a/src/runtime.rs
+++ b/src/runtime.rs
@@ -7,9 +7,16 @@ 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 block(&mut self) -> Result<()> {
+ self.arm().await?;
+ self.apply(None, 0).await
+ }
async fn maintain(&mut self, connection: Option<&Trojan>, generation: u64) -> Result<()> {
self.apply(connection, generation).await
}
+ async fn probe(&mut self, _: &Trojan, _: u64) -> Result<crate::xray::Probe> {
+ bail!("Protected diagnostic probes are unavailable in this runtime")
+ }
/// Called only after disabled intent has been durably recorded.
async fn disable(&mut self) -> Result<()>;
fn routing(&self) -> &str;
diff --git a/src/storage.rs b/src/storage.rs
index cb7fcd9..93cb98c 100644
--- a/src/storage.rs
+++ b/src/storage.rs
@@ -1,4 +1,4 @@
-use crate::model::{Definitions, Entry};
+use crate::model::{Definitions, Entry, EntryStatus};
use anyhow::{Context, Result, ensure};
use rusqlite::{Connection, OptionalExtension, params};
use std::{
@@ -70,7 +70,30 @@ impl Storage {
);
crate::subscription::subscription_url(&subscription.url)?;
}
+ #[derive(serde::Deserialize)]
+ #[serde(deny_unknown_fields)]
+ struct GuardInput {
+ version: u32,
+ enabled: bool,
+ }
+ let guard_path = config_dir.join("guard.toml");
+ let guard_enabled = if guard_path.exists() {
+ let guard: GuardInput = toml::from_str(&fs::read_to_string(&guard_path)?)
+ .context("Corrupt guard state; use explicit recover --disable")?;
+ ensure!(
+ guard.version == 1,
+ "Unsupported guard state; use explicit recover --disable"
+ );
+ guard.enabled
+ } else {
+ false
+ };
let path = data_dir.join("state.sqlite3");
+ let database_existed = path.exists();
+ ensure!(
+ database_existed || !guard_enabled,
+ "Durable state is missing while enabled protection is requested; use explicit recover --disable"
+ );
if path.exists() {
ensure!(
fs::symlink_metadata(&path)?.is_file(),
@@ -84,6 +107,10 @@ impl Storage {
let version: u32 = db.query_row("PRAGMA user_version", [], |r| r.get(0))?;
ensure!(version <= 1, "Unsupported database version");
if version == 0 {
+ ensure!(
+ !database_existed,
+ "Existing database has no state schema; use explicit recover --disable"
+ );
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);
@@ -156,13 +183,7 @@ impl Storage {
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()
+ values.iter().map(|v| decode_entry(v)).collect()
}
pub fn find_entry(&self, id: &str) -> Result<Option<Entry>> {
@@ -171,7 +192,7 @@ impl Storage {
r.get::<_, String>(0)
})
.optional()?
- .map(|v| serde_json::from_str(&v).context("Corrupt saved configuration"))
+ .map(|v| decode_entry(&v))
.transpose()
}
@@ -254,7 +275,17 @@ pub fn persist_recovery_disabled(data_dir: &Path) -> Result<()> {
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_batch("PRAGMA synchronous=FULL;")?;
+ let version: u32 = db.query_row("PRAGMA user_version", [], |row| row.get(0))?;
+ ensure!(
+ version <= 1,
+ "Unknown database schema; repair it before explicit recovery"
+ );
+ db.execute_batch("BEGIN EXCLUSIVE;
+ CREATE TABLE IF NOT EXISTS entries (subscription_id TEXT NOT NULL, ordinal INTEGER NOT NULL, id TEXT PRIMARY KEY, data TEXT NOT NULL);
+ CREATE TABLE IF NOT EXISTS state (singleton INTEGER PRIMARY KEY CHECK(singleton=1), enabled INTEGER NOT NULL CHECK(enabled IN (0,1)), selected TEXT, generation INTEGER NOT NULL);
+ INSERT OR IGNORE INTO state VALUES (1,0,NULL,0);
+ PRAGMA user_version=1;")?;
db.execute(
"UPDATE state SET enabled=0,generation=generation+1 WHERE singleton=1",
[],
@@ -262,3 +293,17 @@ pub fn persist_recovery_disabled(data_dir: &Path) -> Result<()> {
db.execute_batch("COMMIT")?;
Ok(())
}
+
+fn decode_entry(value: &str) -> Result<Entry> {
+ let entry: Entry = serde_json::from_str(value).context("Corrupt saved configuration")?;
+ ensure!(
+ entry.status == EntryStatus::Supported
+ && entry.connection.is_some()
+ && entry.explanation.is_none()
+ || entry.status != EntryStatus::Supported
+ && entry.connection.is_none()
+ && entry.explanation.is_some(),
+ "Saved entry has contradictory validation state; explicit recovery is required"
+ );
+ Ok(entry)
+}
diff --git a/src/subscription.rs b/src/subscription.rs
index df5b318..8abcb1c 100644
--- a/src/subscription.rs
+++ b/src/subscription.rs
@@ -1,4 +1,4 @@
-use crate::model::{Entry, Trojan, display_text};
+use crate::model::{Entry, EntryStatus, Trojan, display_text};
use anyhow::{Result, anyhow, bail, ensure};
use async_trait::async_trait;
use base64::{Engine, engine::general_purpose};
@@ -191,7 +191,7 @@ fn parse_entry(raw: &str) -> Entry {
let mut entry = Entry {
id: Uuid::new_v4().to_string(),
name: "Invalid entry".into(),
- status: "invalid".into(),
+ status: EntryStatus::Invalid,
explanation: None,
connection: None,
};
@@ -205,7 +205,7 @@ fn parse_entry(raw: &str) -> Entry {
entry.name = display_text(&decode(label)?);
}
if url.scheme() != "trojan" {
- entry.status = "unsupported".into();
+ entry.status = EntryStatus::Unsupported;
bail!("Only Trojan TCP + TLS connections are supported");
}
ensure!(
@@ -276,7 +276,7 @@ fn parse_entry(raw: &str) -> Entry {
"allowInsecure" if value == "0" || value == "false" => {}
"headerType" if value == "none" => {}
_ => {
- entry.status = "unsupported".into();
+ entry.status = EntryStatus::Unsupported;
bail!("Unsupported transport, TLS, or connection option");
}
}
@@ -296,7 +296,7 @@ fn parse_entry(raw: &str) -> Entry {
})();
match result {
Ok(connection) => {
- entry.status = "supported".into();
+ entry.status = EntryStatus::Supported;
entry.connection = Some(connection);
}
Err(error) => entry.explanation = Some(error.to_string()),
diff --git a/src/tui.rs b/src/tui.rs
index 9cb69a7..4f8f489 100644
--- a/src/tui.rs
+++ b/src/tui.rs
@@ -65,7 +65,14 @@ impl Ui {
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));
+ self.row = self.row.min(
+ if self.tab == 1 {
+ self.subscriptions.len()
+ } else {
+ self.visible().len()
+ }
+ .saturating_sub(1),
+ );
Ok(())
}
fn visible(&self) -> Vec<&ConfigurationSummary> {
diff --git a/src/xray.rs b/src/xray.rs
index 5ac8b62..c57147c 100644
--- a/src/xray.rs
+++ b/src/xray.rs
@@ -69,14 +69,11 @@ pub async fn preflight() -> Result<()> {
Ok(())
}
-pub fn configuration(connection: &Trojan, tun: bool, probe: Option<(u16, &str)>) -> Value {
+pub fn configuration(connection: &Trojan, tun: bool) -> 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);
@@ -121,6 +118,7 @@ pub struct Probe {
unit: String,
path: std::path::PathBuf,
pub proxy: String,
+ pub certificate: String,
active: bool,
}
impl Probe {
@@ -131,18 +129,33 @@ impl Probe {
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))),
- )?;
+ let certified = rcgen::generate_simple_self_signed(vec!["127.0.0.1".into()])?;
+ let certificate = certified.cert.pem();
+ let key = certified.key_pair.serialize_pem();
+ let mut configuration = configuration(connection, false);
+ configuration["inbounds"] = json!([{
+ "tag":"probe", "listen":"127.0.0.1", "port":port, "protocol":"http",
+ "settings":{"accounts":[{"user":"roentgen","pass":token}]},
+ "streamSettings":{"network":"tcp","security":"tls","tlsSettings":{"certificates":[{
+ "certificate":certificate.lines().collect::<Vec<_>>(), "key":key.lines().collect::<Vec<_>>()
+ }]}}
+ }]);
+ write_config(&path, &configuration)?;
drop(listener);
let runtime_limit = format!("RuntimeMaxSec={}", duration.saturating_add(10));
+ let probe = Self {
+ unit,
+ path,
+ proxy: format!("https://roentgen:{token}@127.0.0.1:{port}"),
+ certificate,
+ active: true,
+ };
let result = Command::new("/usr/bin/systemd-run")
.args([
"--quiet",
"--collect",
"--unit",
- &unit,
+ &probe.unit,
"--uid=roentgen-xray",
"--property=NoNewPrivileges=yes",
"--property=PrivateTmp=yes",
@@ -154,17 +167,11 @@ impl Probe {
"run",
"-config",
])
- .arg(&path)
+ .arg(&probe.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");