Three routing levels per account: clearnet, service tunnel, strict (#257)

An account's route was a single switch: the daemon's connection to the
service went through Tor or the proxy, or it did not. Everything the window
loaded for itself - linked pictures, avatars, emoji, the link check - went
out directly, so anyone in a Tor-routed channel could post a link to their
own server and learn the reader's address.

Accounts now carry strict_route beside use_tor, reported as routeLevel
(clearnet / service / strict). setAccountRoute sets both and respawns the
account only when its tunnelled-ness changes. netRoute hands the window the
router's SOCKS address so it can fetch strict accounts' media through it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-03 00:13:12 -04:00
co-authored by Claude Opus 5.5
parent 7976452332
commit a3897bab95
9 changed files with 176 additions and 3 deletions
+97 -2
View File
@@ -74,6 +74,11 @@ pub struct IrcAccountConfig {
/// listening at `tor_proxy` (defaults to the standard system Tor port).
#[serde(default)]
pub use_tor: bool,
/// Strict routing: on top of `use_tor`, everything the window loads for
/// this account's conversations goes through the route too - see
/// Account::route_level.
#[serde(default)]
pub strict_route: bool,
#[serde(default)]
pub tor_proxy: Option<String>,
}
@@ -110,6 +115,11 @@ pub struct DiscordAccountConfig {
/// proxy set in the network settings. Off by default.
#[serde(default)]
pub use_tor: bool,
/// Strict routing: on top of `use_tor`, everything the window loads for
/// this account's conversations goes through the route too - see
/// Account::route_level.
#[serde(default)]
pub strict_route: bool,
}
impl DiscordAccountConfig {
@@ -156,6 +166,11 @@ pub struct SneedChatAccountConfig {
/// account does.
#[serde(default)]
pub use_tor: bool,
/// Strict routing: on top of `use_tor`, everything the window loads for
/// this account's conversations goes through the route too - see
/// Account::route_level.
#[serde(default)]
pub strict_route: bool,
#[serde(default)]
pub proxy: Option<String>,
#[serde(default)]
@@ -291,6 +306,11 @@ pub struct MatrixAccountConfig {
/// proxy set in the network settings. Off by default.
#[serde(default)]
pub use_tor: bool,
/// Strict routing: on top of `use_tor`, everything the window loads for
/// this account's conversations goes through the route too - see
/// Account::route_level.
#[serde(default)]
pub strict_route: bool,
}
impl MatrixAccountConfig {
@@ -333,6 +353,11 @@ pub struct KickAccountConfig {
/// proxy set in the network settings. Off by default.
#[serde(default)]
pub use_tor: bool,
/// Strict routing: on top of `use_tor`, everything the window loads for
/// this account's conversations goes through the route too - see
/// Account::route_level.
#[serde(default)]
pub strict_route: bool,
}
impl KickAccountConfig {
@@ -477,6 +502,11 @@ impl AccountStore {
// Signing in again keeps a route that was on: a re-login comes from
// the account's own card, which does not ask, and must not quietly
// send an account that was going through Tor out directly.
// Strict routing survives a re-login too: dropping it silently would
// send this account's media straight out again (#257).
if discord.get(&id).is_some_and(|old| old.strict_route) {
config.strict_route = true;
}
if discord.get(&id).is_some_and(|old| old.use_tor) {
config.use_tor = true;
}
@@ -495,9 +525,14 @@ impl AccountStore {
/// Upserts like add_discord - re-adding the same username is how a user
/// updates a changed password/TOTP secret, not a duplicate mistake.
pub fn add_sneedchat(&self, config: SneedChatAccountConfig) -> Result<SneedChatAccountConfig> {
pub fn add_sneedchat(&self, mut config: SneedChatAccountConfig) -> Result<SneedChatAccountConfig> {
let id = config.account_id();
let mut sneedchat = self.sneedchat.lock().unwrap();
// Strict routing survives signing in again, as long as the form still
// asks for Tor - see add_discord.
if config.use_tor && sneedchat.get(&id).is_some_and(|old| old.strict_route) {
config.strict_route = true;
}
sneedchat.insert(id, config.clone());
self.persist(&self.irc.lock().unwrap(), &self.discord.lock().unwrap(), &sneedchat, &self.matrix.lock().unwrap(), &self.kick.lock().unwrap())?;
Ok(config)
@@ -520,6 +555,11 @@ impl AccountStore {
// Signing in again keeps a route that was on: a re-login comes from
// the account's own card, which does not ask, and must not quietly
// send an account that was going through Tor out directly.
// Strict routing survives a re-login too: dropping it silently would
// send this account's media straight out again (#257).
if matrix.get(&id).is_some_and(|old| old.strict_route) {
config.strict_route = true;
}
if matrix.get(&id).is_some_and(|old| old.use_tor) {
config.use_tor = true;
}
@@ -544,6 +584,11 @@ impl AccountStore {
// Signing in again keeps a route that was on: a re-login comes from
// the account's own card, which does not ask, and must not quietly
// send an account that was going through Tor out directly.
// Strict routing survives a re-login too: dropping it silently would
// send this account's media straight out again (#257).
if kick.get(&id).is_some_and(|old| old.strict_route) {
config.strict_route = true;
}
if kick.get(&id).is_some_and(|old| old.use_tor) {
config.use_tor = true;
}
@@ -632,7 +677,12 @@ impl AccountStore {
/// Whether any account's connections are routed, by account id. False
/// when there is no such account.
pub fn set_routed(&self, account_id: &str, routed: bool) -> Result<bool> {
pub fn set_route_level(&self, account_id: &str, level: &str) -> Result<bool> {
let (routed, strict) = match level {
"strict" => (true, true),
"service" => (true, false),
_ => (false, false),
};
let (irc, discord, sneedchat, matrix, kick) = (
&mut *self.irc.lock().unwrap(),
&mut *self.discord.lock().unwrap(),
@@ -642,18 +692,23 @@ impl AccountStore {
);
let found = if let Some(a) = discord.get_mut(account_id) {
a.use_tor = routed;
a.strict_route = strict;
true
} else if let Some(a) = matrix.get_mut(account_id) {
a.use_tor = routed;
a.strict_route = strict;
true
} else if let Some(a) = kick.get_mut(account_id) {
a.use_tor = routed;
a.strict_route = strict;
true
} else if let Some(a) = sneedchat.get_mut(account_id) {
a.use_tor = routed;
a.strict_route = strict;
true
} else if let Some(a) = irc.get_mut(account_id) {
a.use_tor = routed;
a.strict_route = strict;
true
} else {
false
@@ -664,6 +719,29 @@ impl AccountStore {
Ok(found)
}
/// An account's routing level, if there is such an account.
pub fn route_level_of(&self, account_id: &str) -> Option<String> {
let pick = |use_tor: bool, strict: bool| Some(route_level(use_tor, strict));
if let Some(a) = self.get_irc(account_id) {
return pick(a.use_tor, a.strict_route);
}
if let Some(a) = self.get_discord(account_id) {
return pick(a.use_tor, a.strict_route);
}
if let Some(a) = self.get_sneedchat(account_id) {
return pick(a.use_tor, a.strict_route);
}
if let Some(a) = self.get_matrix(account_id) {
return pick(a.use_tor, a.strict_route);
}
self.get_kick(account_id).and_then(|a| pick(a.use_tor, a.strict_route))
}
/// The old two-way switch: the service tunnel, or none.
pub fn set_routed(&self, account_id: &str, routed: bool) -> Result<bool> {
self.set_route_level(account_id, if routed { "service" } else { "clearnet" })
}
/// Called after a successful login once the `xf_user` cookie reveals the
/// account's numeric id - best-effort, not required for the backend to
@@ -988,6 +1066,18 @@ impl AccountStore {
/// `state` ("connected"/"connecting"/"disconnected") comes from the
/// runtime's live connection tracking, not from AccountStore, which only
/// knows the persisted config - see runtime.rs.
/// The three positions an account's routing can be in, as the window shows
/// them: none, the service tunnel (the connection and the media the daemon
/// fetches for it), or strict (that, and everything the window loads).
pub fn route_level(use_tor: bool, strict: bool) -> String {
match (use_tor, strict) {
(true, true) => "strict",
(true, false) => "service",
_ => "clearnet",
}
.to_string()
}
pub fn irc_account_to_json(a: &IrcAccountConfig, state: &str) -> Account {
let id = a.account_id();
Account {
@@ -1017,6 +1107,7 @@ pub fn irc_account_to_json(a: &IrcAccountConfig, state: &str) -> Account {
tor_mode: None,
tor_proxy: a.tor_proxy.clone(),
use_tor: a.use_tor,
route_level: route_level(a.use_tor, a.strict_route),
has_key_backup: false,
rtc_focus_url: None,
sliding_sync: false,
@@ -1053,6 +1144,7 @@ pub fn discord_account_to_json(a: &DiscordAccountConfig, state: &str) -> Account
tor_mode: None,
tor_proxy: None,
use_tor: a.use_tor,
route_level: route_level(a.use_tor, a.strict_route),
has_key_backup: false,
rtc_focus_url: None,
sliding_sync: false,
@@ -1088,6 +1180,7 @@ pub fn sneedchat_account_to_json(a: &SneedChatAccountConfig, state: &str) -> Acc
tor_mode: Some(a.tor_mode.clone()),
tor_proxy: a.proxy.clone(),
use_tor: a.use_tor,
route_level: route_level(a.use_tor, a.strict_route),
has_key_backup: false,
rtc_focus_url: None,
sliding_sync: false,
@@ -1132,6 +1225,7 @@ pub fn kick_account_to_json(a: &KickAccountConfig, state: &str) -> Account {
tor_mode: None,
tor_proxy: None,
use_tor: a.use_tor,
route_level: route_level(a.use_tor, a.strict_route),
has_key_backup: false,
rtc_focus_url: None,
sliding_sync: false,
@@ -1171,6 +1265,7 @@ pub fn matrix_account_to_json(a: &MatrixAccountConfig, state: &str, has_key_back
tor_mode: None,
tor_proxy: None,
use_tor: a.use_tor,
route_level: route_level(a.use_tor, a.strict_route),
has_key_backup,
rtc_focus_url: a.rtc_focus_url.clone(),
sliding_sync: a.prefer_sliding_sync,
+1 -1
View File
@@ -250,7 +250,7 @@ pub(super) async fn finish_login(
.map(|hash| format!("https://cdn.discordapp.com/avatars/{user_id}/{hash}.png"));
let use_tor = crate::net::route::router().wanted(&crate::net::route::pending_key("discord"));
let config = DiscordAccountConfig { user_id, username, display_name: None, token, avatar_url, use_tor };
let config = DiscordAccountConfig { user_id, username, display_name: None, token, avatar_url, use_tor, strict_route: false };
if let Some(expected) = reauth_account_id {
if config.account_id() != expected {
bail!("that is a different Discord account - re-authenticating {expected} needs the same account");
+15
View File
@@ -523,6 +523,9 @@ pub(super) async fn try_device_login(state: &AppState, login_id: &str, homeserve
let config = MatrixAccountConfig {
use_tor: crate::net::route::router().wanted(&crate::net::route::pending_key("matrix")),
// Never strict at sign-in; the account store keeps an existing
// account's strict routing across a re-login.
strict_route: false,
homeserver_url: homeserver_url.to_string(),
user_id,
// Nothing to keep, and the same real difference the SSO path notes:
@@ -608,6 +611,9 @@ pub(super) async fn try_sso_login(state: &AppState, login_id: &str, homeserver_u
let config = MatrixAccountConfig {
use_tor: crate::net::route::router().wanted(&crate::net::route::pending_key("matrix")),
// Never strict at sign-in; the account store keeps an existing
// account's strict routing across a re-login.
strict_route: false,
homeserver_url: homeserver_url.to_string(),
user_id: login.user_id,
// No password to keep, and that is a real difference rather than an
@@ -706,6 +712,9 @@ async fn try_registration(state: &AppState, login_id: &str, homeserver_url: &str
let config = MatrixAccountConfig {
use_tor: crate::net::route::router().wanted(&crate::net::route::pending_key("matrix")),
// Never strict at sign-in; the account store keeps an existing
// account's strict routing across a re-login.
strict_route: false,
homeserver_url: homeserver_url.to_string(),
user_id,
password: password.to_string(),
@@ -765,6 +774,9 @@ pub(super) async fn try_login(state: &AppState, login_id: &str, homeserver_url:
// account id comes from the user id.
let pending = MatrixAccountConfig {
use_tor: crate::net::route::router().wanted(&crate::net::route::pending_key("matrix")),
// Never strict at sign-in; the account store keeps an existing
// account's strict routing across a re-login.
strict_route: false,
homeserver_url: homeserver_url.to_string(),
user_id: username.to_string(),
password: password.to_string(),
@@ -785,6 +797,9 @@ pub(super) async fn try_login(state: &AppState, login_id: &str, homeserver_url:
let config = MatrixAccountConfig {
use_tor: crate::net::route::router().wanted(&crate::net::route::pending_key("matrix")),
// Never strict at sign-in; the account store keeps an existing
// account's strict routing across a re-login.
strict_route: false,
homeserver_url: homeserver_url.to_string(),
user_id: login.user_id,
password: password.to_string(),
+1
View File
@@ -260,6 +260,7 @@ mod tests {
};
let config = crate::accounts::MatrixAccountConfig {
use_tor: false,
strict_route: false,
homeserver_url: homeserver.to_string(),
user_id: login.user_id.clone(),
password: password.to_string(),
+5
View File
@@ -72,6 +72,7 @@ mod live {
let config = MatrixAccountConfig {
use_tor: false,
strict_route: false,
homeserver_url: homeserver.clone(),
user_id: login.user_id.clone(),
password: password.clone(),
@@ -197,6 +198,7 @@ mod live {
let config = MatrixAccountConfig {
use_tor: false,
strict_route: false,
homeserver_url: homeserver.clone(),
user_id: login.user_id.clone(),
password: password.clone(),
@@ -315,6 +317,7 @@ mod live {
let config_a = MatrixAccountConfig {
use_tor: false,
strict_route: false,
homeserver_url: homeserver.clone(),
user_id: login_a.user_id.clone(),
password: password.clone(),
@@ -479,6 +482,7 @@ mod live {
let config = MatrixAccountConfig {
use_tor: false,
strict_route: false,
homeserver_url: homeserver.clone(),
user_id: login.user_id.clone(),
password: password.clone(),
@@ -642,6 +646,7 @@ mod live {
let config = MatrixAccountConfig {
use_tor: false,
strict_route: false,
homeserver_url: homeserver.clone(),
user_id: login.user_id.clone(),
password: password.clone(),
+3
View File
@@ -256,6 +256,9 @@ async fn try_qr_login(state: &AppState, login_id: &str, homeserver_url: &str) ->
let config = MatrixAccountConfig {
use_tor: crate::net::route::router().wanted(&crate::net::route::pending_key("matrix")),
// Never strict at sign-in; the account store keeps an existing
// account's strict routing across a re-login.
strict_route: false,
homeserver_url: homeserver_url.clone(),
user_id: user_id.clone(),
password: String::new(),
+1
View File
@@ -809,6 +809,7 @@ mod tests {
};
let config = MatrixAccountConfig {
use_tor: false,
strict_route: false,
homeserver_url: homeserver.to_string(),
user_id: login.user_id.clone(),
password: password.to_string(),
+3
View File
@@ -159,6 +159,9 @@ pub struct Account {
/// false for every other service.
#[serde(rename = "useTor")]
pub use_tor: bool,
/// "clearnet", "service" or "strict" - see accounts::route_level.
#[serde(rename = "routeLevel")]
pub route_level: String,
/// Matrix-only: whether this account currently has server-side room
/// key backup (a recovery key) set up - see backend/matrix/backup.rs.
/// Always false for every other service.
+50
View File
@@ -561,6 +561,9 @@ pub async fn dispatch(
nickserv_password: None,
display_name: None,
use_tor: p_bool(params, "useTor", false),
// Never strict at sign-in; the account store keeps an existing
// account's strict routing across a re-login.
strict_route: false,
tor_proxy: None,
};
match state.accounts.add_irc(config.clone()) {
@@ -3262,6 +3265,9 @@ pub async fn dispatch(
tor_mode: p_str_opt(params, "torMode").map(String::from).unwrap_or_else(|| "embedded".to_string()),
// The open internet unless asked otherwise.
use_tor: p_bool(params, "useTor", false),
// Never strict at sign-in; the account store keeps an existing
// account's strict routing across a re-login.
strict_route: false,
proxy: p_str_opt(params, "proxy").map(String::from),
rooms: parse_sneedchat_rooms(params).unwrap_or_default(),
rooms_chosen: false,
@@ -3339,6 +3345,9 @@ pub async fn dispatch(
// Chosen on the add form before the window opened; an
// account that already exists keeps its own setting.
use_tor: p_bool(params, "useTor", false),
// Never strict at sign-in; the account store keeps an existing
// account's strict routing across a re-login.
strict_route: false,
proxy: None,
rooms: Vec::new(),
rooms_chosen: false,
@@ -3463,6 +3472,44 @@ pub async fn dispatch(
}
}
// Where a strict account's media goes (#257): the SOCKS address of
// the route - moho's own Tor through its loopback relay, or the proxy
// the user named - whether or not everything is tunnelled. Asked by
// the main process for the session it fetches strict media through;
// an error is answered as such, and the window then loads nothing for
// that account rather than loading it directly.
"netRoute" => match crate::net::route::router().ready(|_| {}).await {
Ok((host, port)) => (Some(serde_json::json!({ "socks": format!("{host}:{port}") })), None),
Err(e) => (None, Some(format!("{e:#}"))),
},
// One account's routing, in three positions: "clearnet", "service"
// (the connection, and the media the daemon fetches for it) or
// "strict" (that, and everything the window loads for it - #257).
// Reconnected only when the connection's own route changes; strict
// on or off is the window's business alone.
"setAccountRoute" => {
let Some(id) = p_str_opt(params, "accountId") else {
return (None, Some("setAccountRoute requires \"accountId\"".to_string()));
};
let level = p_str(params, "level", "clearnet");
if !matches!(level, "clearnet" | "service" | "strict") {
return (None, Some(format!("\"{level}\" is not a routing level")));
}
let was = state.accounts.route_level_of(id);
match state.accounts.set_route_level(id, level) {
Ok(true) => {
let tunnelled = |l: &Option<String>| l.as_deref().is_some_and(|l| l != "clearnet");
if tunnelled(&was) != (level != "clearnet") {
respawn_account(state, id);
}
(Some(ok_node()), None)
}
Ok(false) => (None, Some("no such account".to_string())),
Err(e) => (None, Some(format!("{e:#}"))),
}
}
// One account's switch, on any service. Reconnected at once, so the
// change is the connection rather than a setting waiting for one.
"setAccountRouted" => {
@@ -3692,6 +3739,9 @@ pub async fn dispatch(
// person had closed.
followed_synced: existing.as_ref().is_some_and(|e| e.followed_synced),
use_tor: crate::net::route::router().wanted(&crate::net::route::pending_key("kick")),
// Never strict at sign-in; the account store keeps an existing
// account's strict routing across a re-login.
strict_route: false,
channels: existing.map(|e| e.channels).unwrap_or_default(),
};
match state.accounts.add_kick(config) {