Kick over Tor: start the route before the client, and say why a join fails
Found checking Tor on a fresh Windows install; the same on Linux. - run() built Kick's HTTP client before the chat socket, which is what started Tor. A routed account's client was made before there was a route, pointed at the address that refuses everything, and kept for the session: every channel lookup failed in milliseconds until something forced a reconnect. The route is now made ready first. - Router::client_if no longer caches a client that goes nowhere, so one asked for too early is not what every later caller gets. - A failed Kick join went to report_progress, which only speaks while an account is connecting - a join is asked of a connected one, so every failure vanished and the join page sat there. Join now carries a reply and joinBuffer waits for it (45s), so the reason reaches the window. What it says over Tor today is Kick's own answer: 403 - Kick's API refuses Tor exits. Tor itself bootstraps in seconds on Windows and the daemon idles at 0% CPU, so the saturating-time fix holds under arti 0.47. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
@@ -27,8 +27,10 @@ pub(super) fn pusher_url() -> String {
|
||||
/// because the socket is owned by its own task and these arrive from RPC.
|
||||
#[derive(Debug)]
|
||||
pub enum Command {
|
||||
/// Watch a channel, by whatever the user typed as a handle.
|
||||
Join(String),
|
||||
/// Watch a channel, by whatever the user typed as a handle. With a
|
||||
/// reply when somebody is waiting to hear how it went: a join that
|
||||
/// fails on a connected account has nowhere else to say so.
|
||||
Join(String, Option<tokio::sync::oneshot::Sender<Result<(), String>>>),
|
||||
/// Try a channel Kick refused for the rate limit again, counting the
|
||||
/// attempts so this cannot become a connection that asks forever.
|
||||
Rejoin(String, u8),
|
||||
@@ -161,6 +163,19 @@ pub(super) async fn run_with_retry(state: &AppState, config: &KickAccountConfig,
|
||||
}
|
||||
|
||||
pub(super) async fn run(state: &AppState, config: &KickAccountConfig, account_id: &str) -> Result<()> {
|
||||
// The route first, then the client that uses it. Built the other way
|
||||
// round - the chat socket below being what used to start Tor - a routed
|
||||
// account's client was made before there was anywhere to send it, got
|
||||
// the address that goes nowhere, and kept it for the whole session:
|
||||
// every channel looked up through Kick's API failed at once until
|
||||
// something forced a reconnect.
|
||||
let router = crate::net::route::router();
|
||||
if router.routed(super::api::SHARED_KEY) {
|
||||
router
|
||||
.ready(|msg| state.runtime.report_progress(state, account_id, msg))
|
||||
.await
|
||||
.context("starting Tor or reaching the proxy")?;
|
||||
}
|
||||
let http = api::client()?;
|
||||
|
||||
// Signed in or not is settled first, because everything downstream reads
|
||||
@@ -370,7 +385,7 @@ pub(super) async fn run(state: &AppState, config: &KickAccountConfig, account_id
|
||||
tokio::select! {
|
||||
command = rx.recv() => match command {
|
||||
None => return Ok(()),
|
||||
Some(Command::Join(handle)) => {
|
||||
Some(Command::Join(handle, reply)) => {
|
||||
match prepare(state, &http, config, account_id, &handle).await {
|
||||
Ok(channel) => {
|
||||
// Propagated, not reported: a subscribe that will
|
||||
@@ -380,10 +395,27 @@ pub(super) async fn run(state: &AppState, config: &KickAccountConfig, account_id
|
||||
subscribe(&mut socket, channel.chatroom_id, channel.id).await?;
|
||||
watched.add(&channel);
|
||||
remember(state, account_id, &channel.slug);
|
||||
if let Some(reply) = reply {
|
||||
let _ = reply.send(Ok(()));
|
||||
}
|
||||
}
|
||||
// Said, and not saved: a typo stays a typo rather
|
||||
// than becoming a channel asked for on every connect.
|
||||
Err(e) => state.runtime.report_progress(state, account_id, &format!("{e:#}")),
|
||||
//
|
||||
// Said to whoever asked. report_progress only speaks
|
||||
// while an account is connecting, and a join is asked
|
||||
// of a connected one - so every failed join, a typo or
|
||||
// Kick refusing the request, used to vanish: the join
|
||||
// page sat there and nothing appeared.
|
||||
Err(e) => {
|
||||
tracing::warn!("kick[{account_id}]: could not watch {handle}: {e:#}");
|
||||
match reply {
|
||||
Some(reply) => {
|
||||
let _ = reply.send(Err(format!("{e:#}")));
|
||||
}
|
||||
None => state.runtime.report_progress(state, account_id, &format!("{e:#}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(Command::Rejoin(handle, attempt)) => {
|
||||
|
||||
+21
-1
@@ -267,7 +267,13 @@ impl Router {
|
||||
builder = builder.no_proxy();
|
||||
}
|
||||
let client = builder.build().unwrap_or_else(|_| reqwest::Client::new());
|
||||
self.clients.lock().unwrap().insert(cache_key, client.clone());
|
||||
// Not kept when it goes nowhere. A client asked for before the route
|
||||
// was ready points at the address that refuses everything, which is
|
||||
// right for that moment - but cached, every later caller got it too,
|
||||
// long after Tor was up.
|
||||
if proxy.as_deref() != Some(NOWHERE) {
|
||||
self.clients.lock().unwrap().insert(cache_key, client.clone());
|
||||
}
|
||||
client
|
||||
}
|
||||
|
||||
@@ -475,6 +481,20 @@ mod tests {
|
||||
assert_eq!(r.proxy_for("discord:2"), None, "only the account that asked");
|
||||
}
|
||||
|
||||
/// A client made before the route is ready goes nowhere - and must not
|
||||
/// be what everybody gets afterwards. Cached, it was: a Tor Kick account
|
||||
/// built its client before Tor started and failed every lookup for the
|
||||
/// rest of the session.
|
||||
#[test]
|
||||
fn a_client_that_goes_nowhere_is_not_kept() {
|
||||
let r = router_at("early");
|
||||
r.client_if("svc", true, |b| b);
|
||||
assert!(r.clients.lock().unwrap().is_empty(), "the nowhere client was cached");
|
||||
*r.socks.lock().unwrap() = Some(("127.0.0.1".into(), 9150));
|
||||
r.client_if("svc", true, |b| b);
|
||||
assert_eq!(r.clients.lock().unwrap().len(), 1, "a client with a real route is kept");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tunnelling_everything_routes_every_key_and_a_proxy_is_used_as_given() {
|
||||
let r = router_at("all");
|
||||
|
||||
+12
-3
@@ -790,10 +790,19 @@ pub async fn dispatch(
|
||||
// Remembered by the connection once Kick has said the handle
|
||||
// is a channel, not here: saved first, a typo was asked for
|
||||
// on every connect for good, with no buffer to close it by.
|
||||
if sender.send(backend::kick::Command::Join(slug)).is_err() {
|
||||
let (reply, answer) = tokio::sync::oneshot::channel();
|
||||
if sender.send(backend::kick::Command::Join(slug, Some(reply))).is_err() {
|
||||
return (None, Some("that Kick account is not connected".to_string()));
|
||||
}
|
||||
return (Some(ok_node()), None);
|
||||
// Waited for, so a refusal reaches the person who asked
|
||||
// rather than nobody. Bounded: looking a channel up goes
|
||||
// through Kick's API, over Tor for a routed account.
|
||||
return match tokio::time::timeout(std::time::Duration::from_secs(45), answer).await {
|
||||
Ok(Ok(Ok(()))) => (Some(ok_node()), None),
|
||||
Ok(Ok(Err(e))) => (None, Some(e)),
|
||||
Ok(Err(_)) => (None, Some("the Kick connection dropped before the channel opened".to_string())),
|
||||
Err(_) => (None, Some("Kick took too long to answer for that channel".to_string())),
|
||||
};
|
||||
}
|
||||
match state.runtime.irc_sender(id) {
|
||||
None => (None, Some("join failed (account not connected?)".to_string())),
|
||||
@@ -4232,7 +4241,7 @@ pub async fn dispatch(
|
||||
let connected = match state.runtime.kick_sender(account_id) {
|
||||
Some(sender) => {
|
||||
for slug in &added {
|
||||
let _ = sender.send(backend::kick::Command::Join(slug.clone()));
|
||||
let _ = sender.send(backend::kick::Command::Join(slug.clone(), None));
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user