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:
+97
-2
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user