From 717928154bf94641f3d1f7e6f37023315d70aac4 Mon Sep 17 00:00:00 2001 From: heaven Date: Mon, 8 Jun 2026 02:18:44 +0300 Subject: [PATCH] feat(proxy): route Android WebView and native traffic through a user-supplied HTTP/SOCKS5 proxy via an authed loopback relay --- .husky/pre-commit | 1 + android/app/build.gradle | 10 + .../main/java/chat/vojo/app/AvatarLoader.java | 7 +- .../chat/vojo/app/CallDeclineReceiver.java | 7 +- .../java/chat/vojo/app/LocalProxyRelay.java | 493 ++++++++++++++++ .../main/java/chat/vojo/app/MainActivity.java | 1 + .../chat/vojo/app/MarkAsReadReceiver.java | 6 +- .../vojo/app/ProxyBridgeWebViewClient.java | 83 +++ .../java/chat/vojo/app/ProxyConfigStore.java | 176 ++++++ .../main/java/chat/vojo/app/ProxyManager.java | 375 +++++++++++++ .../main/java/chat/vojo/app/ProxyPlugin.java | 173 ++++++ .../main/java/chat/vojo/app/ProxyRouting.java | 130 +++++ .../java/chat/vojo/app/ReplyReceiver.java | 6 +- .../java/chat/vojo/app/VojoPollWorker.java | 13 +- package.json | 1 + public/locales/en.json | 42 ++ public/locales/ru.json | 42 ++ scripts/check-no-tls-weakening.mjs | 64 +++ src/app/features/settings/Settings.tsx | 7 + src/app/features/settings/network/Network.tsx | 19 + .../features/settings/network/ProxyForm.tsx | 527 ++++++++++++++++++ src/app/features/settings/network/index.ts | 2 + .../features/settings/network/network.css.ts | 183 ++++++ src/app/hooks/useProxy.ts | 171 ++++++ src/app/pages/auth/AuthFooter.tsx | 2 + src/app/pages/auth/ProxySettingsEntry.tsx | 88 +++ src/app/pages/auth/styles.css.ts | 20 + src/app/pages/client/ClientRoot.tsx | 43 ++ src/app/pages/client/direct/DirectSelfRow.tsx | 12 + src/app/plugins/proxy.ts | 163 ++++++ src/app/state/proxy.ts | 14 + src/client/initMatrix.ts | 10 + src/index.tsx | 25 +- 33 files changed, 2905 insertions(+), 11 deletions(-) create mode 100644 android/app/src/main/java/chat/vojo/app/LocalProxyRelay.java create mode 100644 android/app/src/main/java/chat/vojo/app/ProxyBridgeWebViewClient.java create mode 100644 android/app/src/main/java/chat/vojo/app/ProxyConfigStore.java create mode 100644 android/app/src/main/java/chat/vojo/app/ProxyManager.java create mode 100644 android/app/src/main/java/chat/vojo/app/ProxyPlugin.java create mode 100644 android/app/src/main/java/chat/vojo/app/ProxyRouting.java create mode 100644 scripts/check-no-tls-weakening.mjs create mode 100644 src/app/features/settings/network/Network.tsx create mode 100644 src/app/features/settings/network/ProxyForm.tsx create mode 100644 src/app/features/settings/network/index.ts create mode 100644 src/app/features/settings/network/network.css.ts create mode 100644 src/app/hooks/useProxy.ts create mode 100644 src/app/pages/auth/ProxySettingsEntry.tsx create mode 100644 src/app/plugins/proxy.ts create mode 100644 src/app/state/proxy.ts diff --git a/.husky/pre-commit b/.husky/pre-commit index e55b74d6..69c54a01 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1,2 +1,3 @@ npx tsc -p tsconfig.json --noEmit +node scripts/check-no-tls-weakening.mjs npx lint-staged diff --git a/android/app/build.gradle b/android/app/build.gradle index 989cbaa0..46ab402d 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -92,6 +92,16 @@ dependencies { // (mtalk.google.com:5228) is blocked. Library self-registers its scheduler // in the merged manifest; we declare no permission for it. implementation "androidx.work:work-runtime:2.10.0" + // androidx.webkit ProxyController.setProxyOverride — the single chokepoint + // for routing WebView (matrix-js-sdk fetch + media Service Worker) traffic + // through a user-supplied proxy. 1.14.0 carries PROXY_OVERRIDE + + // PROXY_OVERRIDE_REVERSE_BYPASS; every call is runtime-gated on + // WebViewFeature.isFeatureSupported. See docs/plans/proxy_android_impl.md. + implementation "androidx.webkit:webkit:$androidxWebkitVersion" + // Tink AEAD (AES-256-GCM, KEK in AndroidKeyStore) — the ONLY store for the + // proxy credentials (proxy_support.md §15 #4). Not the deprecated + // androidx.security EncryptedSharedPreferences. + implementation "com.google.crypto.tink:tink-android:1.15.0" testImplementation "junit:junit:$junitVersion" androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion" androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion" diff --git a/android/app/src/main/java/chat/vojo/app/AvatarLoader.java b/android/app/src/main/java/chat/vojo/app/AvatarLoader.java index ca3c57c2..91a93884 100644 --- a/android/app/src/main/java/chat/vojo/app/AvatarLoader.java +++ b/android/app/src/main/java/chat/vojo/app/AvatarLoader.java @@ -151,7 +151,7 @@ final class AvatarLoader { final String capturedToken = token; EXECUTOR.execute(() -> { try { - Bitmap bmp = fetchAndDecode(capturedMxc, capturedHomeserver, capturedToken); + Bitmap bmp = fetchAndDecode(ctx, capturedMxc, capturedHomeserver, capturedToken); if (bmp != null) AvatarBitmapCache.put(capturedMxc, bmp); } catch (Throwable t) { Log.w(TAG, "fetch threw mxc=" + capturedMxc, t); @@ -200,7 +200,7 @@ final class AvatarLoader { * Bitmap. Returns null on any non-2xx, decode failure, or oversized * payload (see {@link #MAX_DECODED_BYTES}). */ - private static Bitmap fetchAndDecode(String mxc, String homeserver, String token) + private static Bitmap fetchAndDecode(Context ctx, String mxc, String homeserver, String token) throws IOException { Parsed parsed = parseMxc(mxc); if (parsed == null) { @@ -224,7 +224,8 @@ final class AvatarLoader { .append("&height=").append(AVATAR_SIZE_PX) .append("&method=crop"); - HttpURLConnection conn = (HttpURLConnection) new URL(url.toString()).openConnection(); + // Route through the loopback relay when a proxy is enabled (§15 #3). + HttpURLConnection conn = ProxyRouting.openConnection(ctx, new URL(url.toString())); try { conn.setRequestMethod("GET"); conn.setRequestProperty("Authorization", "Bearer " + token); diff --git a/android/app/src/main/java/chat/vojo/app/CallDeclineReceiver.java b/android/app/src/main/java/chat/vojo/app/CallDeclineReceiver.java index a3f23891..88d870c7 100644 --- a/android/app/src/main/java/chat/vojo/app/CallDeclineReceiver.java +++ b/android/app/src/main/java/chat/vojo/app/CallDeclineReceiver.java @@ -159,7 +159,7 @@ public class CallDeclineReceiver extends BroadcastReceiver { final String txnId = UUID.randomUUID().toString(); EXECUTOR.execute(() -> { try { - int status = sendDecline(baseUrl, accessToken, roomId, notifEventId, txnId); + int status = sendDecline(context, baseUrl, accessToken, roomId, notifEventId, txnId); if (status >= 200 && status < 300) { prefs.edit().remove(PENDING_DECLINES_PREFIX + notifEventId).apply(); Log.d(TAG, "decline PUT ok status=" + status + " room=" + roomId); @@ -175,6 +175,7 @@ public class CallDeclineReceiver extends BroadcastReceiver { } private int sendDecline( + Context context, String baseUrl, String accessToken, String roomId, @@ -196,7 +197,9 @@ public class CallDeclineReceiver extends BroadcastReceiver { HttpURLConnection conn = null; try { - conn = (HttpURLConnection) new URL(url).openConnection(); + // Route through the loopback relay when a proxy is enabled (closes + // the §15 #3 native-path leak); identical to before when off. + conn = ProxyRouting.openConnection(context, new URL(url)); conn.setRequestMethod("PUT"); conn.setConnectTimeout(CONNECT_TIMEOUT_MS); conn.setReadTimeout(READ_TIMEOUT_MS); diff --git a/android/app/src/main/java/chat/vojo/app/LocalProxyRelay.java b/android/app/src/main/java/chat/vojo/app/LocalProxyRelay.java new file mode 100644 index 00000000..f853c46c --- /dev/null +++ b/android/app/src/main/java/chat/vojo/app/LocalProxyRelay.java @@ -0,0 +1,493 @@ +package chat.vojo.app; + +import android.util.Log; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.net.ServerSocket; +import java.net.Socket; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicBoolean; + +import chat.vojo.app.ProxyConfigStore.ProxyCreds; + +/** + * Authenticated HTTP-CONNECT relay on 127.0.0.1:<ephemeral> that exists + * ONLY while the proxy is enabled (proxy_support.md §15 #1/#2/#3). + * + * Why it exists: Chromium's WebView proxy can't present SOCKS5 user/pass auth, + * and the five native HttpURLConnection paths bypass the WebView proxy + * entirely. Both are pointed at this loopback relay; the relay holds the + * upstream credentials and dials the user's real proxy (HTTP-CONNECT or SOCKS5, + * with or without auth). + * + * Inbound is authenticated with a high-entropy per-session token via standard + * proxy Basic auth (407 challenge) — answered by the WebView's + * onReceivedHttpAuthRequest and, for native paths, a loopback-scoped + * Authenticator. Without the token any co-resident app could use the loopback + * listener as an open relay (the §15 #1 HIGH threat). Peer-UID filtering is + * NOT relied on (unreliable on modern Android). + * + * Pure L4 forwarder after CONNECT: no TLS inspection, no CA — the WebView's + * TLS to the homeserver is end-to-end through the tunnel. + */ +final class LocalProxyRelay { + + private static final String TAG = "LocalProxyRelay"; + private static final String AUTH_USER = "vojo"; + // The Basic realm sent in our 407. The WebView's onReceivedHttpAuthRequest + // is matched on THIS (not the host) so the proxy-auth answer works whatever + // host Chromium reports for the relay's 407 (proxy_android_impl.md §1). + private static final String AUTH_REALM = "vojo"; + private static final int ACCEPT_BACKLOG = 32; + private static final int UPSTREAM_CONNECT_TIMEOUT_MS = 15000; + private static final int HANDSHAKE_TIMEOUT_MS = 15000; + private static final int MAX_HEADER_BYTES = 16 * 1024; + + private final ProxyCreds creds; + private final String token; + private final byte[] expectedAuthBytes; + + private final AtomicBoolean running = new AtomicBoolean(false); + // Every established client+upstream socket, so disable()/logout can + // force-close in-flight tunnels (e.g. the /sync long-poll) instead of + // letting them ride the user's proxy until the far end FINs. + private final java.util.Set live = + java.util.Collections.synchronizedSet(new java.util.HashSet<>()); + private ServerSocket serverSocket; + private Thread acceptThread; + private ExecutorService workers; + private int port = -1; + + LocalProxyRelay(ProxyCreds creds, String token) { + this.creds = creds; + this.token = token; + this.expectedAuthBytes = ("Basic " + base64(AUTH_USER + ":" + token)) + .getBytes(StandardCharsets.US_ASCII); + } + + int getPort() { + return port; + } + + String getToken() { + return token; + } + + static String getAuthUser() { + return AUTH_USER; + } + + static String getAuthRealm() { + return AUTH_REALM; + } + + boolean isRunning() { + return running.get(); + } + + /** Bind the loopback listener and start accepting. Idempotent-ish: throws + * if already started. Must be called off the main thread. */ + synchronized void start() throws IOException { + if (running.get()) return; + serverSocket = new ServerSocket(0, ACCEPT_BACKLOG, InetAddress.getByName("127.0.0.1")); + port = serverSocket.getLocalPort(); + workers = Executors.newCachedThreadPool(); + running.set(true); + acceptThread = new Thread(this::acceptLoop, "vojo-proxy-relay"); + acceptThread.setDaemon(true); + acceptThread.start(); + Log.i(TAG, "relay up on 127.0.0.1:" + port); + } + + synchronized void stop() { + if (!running.getAndSet(false)) return; + try { + if (serverSocket != null) serverSocket.close(); + } catch (IOException ignored) { + // closing the listener is best-effort + } + if (workers != null) workers.shutdownNow(); + if (acceptThread != null) acceptThread.interrupt(); + // Force-close every in-flight tunnel so no traffic keeps riding the + // user's upstream after the proxy is turned off / the user logs out. + Socket[] snapshot; + synchronized (live) { + snapshot = live.toArray(new Socket[0]); + } + for (Socket s : snapshot) { + closeQuietly(s); + } + live.clear(); + serverSocket = null; + port = -1; + Log.i(TAG, "relay stopped"); + } + + private void acceptLoop() { + while (running.get()) { + final Socket client; + // Capture the listener locally so a concurrent stop() that nulls + // serverSocket can't NPE us between the running check and accept(). + final ServerSocket ss = serverSocket; + if (ss == null) break; + try { + client = ss.accept(); + } catch (IOException e) { + if (running.get()) Log.w(TAG, "accept failed"); + break; + } + try { + workers.execute(() -> handle(client)); + } catch (RuntimeException rejected) { + closeQuietly(client); + } + } + } + + private void handle(Socket client) { + Socket upstream = null; + try { + client.setSoTimeout(HANDSHAKE_TIMEOUT_MS); + track(client); + InputStream cin = client.getInputStream(); + OutputStream cout = client.getOutputStream(); + + String[] headerLines = readHeaders(cin); + if (headerLines == null) { + closeQuietly(client); + return; + } + + // Authenticate FIRST so an unauthenticated probe gets only a 407 — + // never a fingerprintable 405/400 that confirms what's listening. + if (!authOk(headerLines)) { + // 407 challenge — the client (WebView / native Authenticator) + // retries with the token. + cout.write(("HTTP/1.1 407 Proxy Authentication Required\r\n" + + "Proxy-Authenticate: Basic realm=\"vojo\"\r\n" + + "Content-Length: 0\r\n" + + "Connection: close\r\n\r\n").getBytes(StandardCharsets.US_ASCII)); + cout.flush(); + closeQuietly(client); + return; + } + + String requestLine = headerLines.length > 0 ? headerLines[0] : ""; + String[] parts = requestLine.split(" "); + if (parts.length < 2 || !"CONNECT".equalsIgnoreCase(parts[0])) { + writeStatus(cout, "405 Method Not Allowed"); + closeQuietly(client); + return; + } + + String[] hostPort = splitHostPort(parts[1]); + if (hostPort == null) { + writeStatus(cout, "400 Bad Request"); + closeQuietly(client); + return; + } + String targetHost = hostPort[0]; + int targetPort; + try { + targetPort = Integer.parseInt(hostPort[1]); + } catch (NumberFormatException nfe) { + writeStatus(cout, "400 Bad Request"); + closeQuietly(client); + return; + } + + upstream = dialUpstream(targetHost, targetPort); + if (upstream == null) { + writeStatus(cout, "502 Bad Gateway"); + closeQuietly(client); + return; + } + track(upstream); + + cout.write("HTTP/1.1 200 Connection Established\r\n\r\n" + .getBytes(StandardCharsets.US_ASCII)); + cout.flush(); + + // Tunnel established — pure byte pipe. Drop the handshake timeout so + // a long-poll /sync isn't torn down mid-stream. + client.setSoTimeout(0); + upstream.setSoTimeout(0); + pipe(client, upstream); + } catch (IOException e) { + closeQuietly(client); + closeQuietly(upstream); + } + } + + // ── Upstream dialers ──────────────────────────────────────────────────── + + private Socket dialUpstream(String targetHost, int targetPort) { + try { + if ("http".equalsIgnoreCase(creds.type)) { + return dialHttpUpstream(targetHost, targetPort); + } + return dialSocks5Upstream(targetHost, targetPort); + } catch (IOException e) { + Log.w(TAG, "upstream dial failed"); + return null; + } + } + + private Socket dialHttpUpstream(String targetHost, int targetPort) throws IOException { + Socket s = new Socket(); + s.connect(new InetSocketAddress(creds.host, creds.port), UPSTREAM_CONNECT_TIMEOUT_MS); + s.setSoTimeout(HANDSHAKE_TIMEOUT_MS); + StringBuilder req = new StringBuilder(); + req.append("CONNECT ").append(targetHost).append(':').append(targetPort) + .append(" HTTP/1.1\r\n"); + req.append("Host: ").append(targetHost).append(':').append(targetPort).append("\r\n"); + if (creds.hasAuth()) { + req.append("Proxy-Authorization: Basic ") + .append(base64(creds.username + ":" + (creds.password == null ? "" : creds.password))) + .append("\r\n"); + } + req.append("\r\n"); + s.getOutputStream().write(req.toString().getBytes(StandardCharsets.US_ASCII)); + s.getOutputStream().flush(); + + String[] respLines = readHeaders(s.getInputStream()); + if (respLines == null || respLines.length == 0) { + closeQuietly(s); + return null; + } + // Expect "HTTP/1.x 200 ..." — parse the numeric status token, not a + // substring (a reason phrase could otherwise contain "200"). + String[] statusParts = respLines[0].split(" "); + if (statusParts.length < 2 || !"200".equals(statusParts[1])) { + closeQuietly(s); + return null; + } + return s; + } + + private Socket dialSocks5Upstream(String targetHost, int targetPort) throws IOException { + Socket s = new Socket(); + s.connect(new InetSocketAddress(creds.host, creds.port), UPSTREAM_CONNECT_TIMEOUT_MS); + s.setSoTimeout(HANDSHAKE_TIMEOUT_MS); + InputStream in = s.getInputStream(); + OutputStream out = s.getOutputStream(); + + boolean auth = creds.hasAuth(); + // Greeting: when we have creds advertise ONLY user/pass (0x02) so a + // hostile/misconfigured upstream can't downgrade us to no-auth and have + // the creds silently dropped. Otherwise advertise only no-auth. + if (auth) { + out.write(new byte[] {0x05, 0x01, 0x02}); + } else { + out.write(new byte[] {0x05, 0x01, 0x00}); + } + out.flush(); + byte[] methodSel = readN(in, 2); + if (methodSel == null || methodSel[0] != 0x05) { + closeQuietly(s); + return null; + } + int method = methodSel[1] & 0xff; + if (auth) { + // We advertised ONLY user/pass — reject any downgrade. + if (method != 0x02) { closeQuietly(s); return null; } + // RFC 1929 user/pass sub-negotiation. + byte[] u = creds.username.getBytes(StandardCharsets.UTF_8); + byte[] p = (creds.password == null ? "" : creds.password).getBytes(StandardCharsets.UTF_8); + if (u.length > 255 || p.length > 255) { closeQuietly(s); return null; } + ByteArrayOutputStream a = new ByteArrayOutputStream(); + a.write(0x01); + a.write(u.length); + a.write(u, 0, u.length); + a.write(p.length); + a.write(p, 0, p.length); + out.write(a.toByteArray()); + out.flush(); + byte[] authResp = readN(in, 2); + if (authResp == null || authResp[1] != 0x00) { + closeQuietly(s); + return null; + } + } else if (method != 0x00) { + // We advertised only no-auth; anything else is unacceptable. + closeQuietly(s); + return null; + } + + // CONNECT request with the target as a DOMAINNAME so the upstream does + // remote DNS (no on-device DNS leak). + byte[] host = targetHost.getBytes(StandardCharsets.UTF_8); + if (host.length > 255) { closeQuietly(s); return null; } + ByteArrayOutputStream req = new ByteArrayOutputStream(); + req.write(0x05); // version + req.write(0x01); // CONNECT + req.write(0x00); // reserved + req.write(0x03); // ATYP = domain + req.write(host.length); + req.write(host, 0, host.length); + req.write((targetPort >> 8) & 0xff); + req.write(targetPort & 0xff); + out.write(req.toByteArray()); + out.flush(); + + // Reply: VER REP RSV ATYP BND.ADDR BND.PORT — read the fixed prefix + // then the variable address. + byte[] head = readN(in, 4); + if (head == null || head[0] != 0x05 || head[1] != 0x00) { + closeQuietly(s); + return null; + } + int atyp = head[3] & 0xff; + int addrLen; + if (atyp == 0x01) { + addrLen = 4; + } else if (atyp == 0x04) { + addrLen = 16; + } else if (atyp == 0x03) { + byte[] l = readN(in, 1); + if (l == null) { closeQuietly(s); return null; } + addrLen = l[0] & 0xff; + } else { + closeQuietly(s); + return null; + } + if (readN(in, addrLen + 2) == null) { // address + 2-byte port + closeQuietly(s); + return null; + } + return s; + } + + // ── Helpers ───────────────────────────────────────────────────────────── + + private boolean authOk(String[] headerLines) { + for (String line : headerLines) { + int colon = line.indexOf(':'); + if (colon <= 0) continue; + String name = line.substring(0, colon).trim(); + if (!"Proxy-Authorization".equalsIgnoreCase(name)) continue; + String value = line.substring(colon + 1).trim(); + byte[] got = value.getBytes(StandardCharsets.US_ASCII); + // Constant-time compare to avoid leaking the token via timing. + return MessageDigest.isEqual(got, expectedAuthBytes); + } + return false; + } + + // Read CRLF-delimited header lines until the blank line. Returns the lines + // (request/status line first), or null on malformed / oversized input. Reads + // byte-by-byte so it never consumes tunnel body past the header terminator. + private static String[] readHeaders(InputStream in) throws IOException { + ByteArrayOutputStream buf = new ByteArrayOutputStream(); + int prev = -1; + int total = 0; + while (true) { + int b = in.read(); + if (b == -1) { + if (buf.size() == 0) return null; + break; + } + total++; + if (total > MAX_HEADER_BYTES) return null; + buf.write(b); + if (prev == '\r' && b == '\n') { + int size = buf.size(); + byte[] arr = buf.toByteArray(); + // Header section ends with CRLFCRLF. + if (size >= 4 && arr[size - 3] == '\n' && arr[size - 4] == '\r') { + break; + } + } + prev = b; + } + String text = new String(buf.toByteArray(), StandardCharsets.US_ASCII); + String[] rawLines = text.split("\r\n"); + return rawLines; + } + + private static String[] splitHostPort(String authority) { + int idx = authority.lastIndexOf(':'); + if (idx <= 0 || idx == authority.length() - 1) return null; + String host = authority.substring(0, idx); + // Strip IPv6 brackets if present. + if (host.startsWith("[") && host.endsWith("]")) { + host = host.substring(1, host.length() - 1); + } + return new String[] {host, authority.substring(idx + 1)}; + } + + private static byte[] readN(InputStream in, int n) throws IOException { + byte[] out = new byte[n]; + int off = 0; + while (off < n) { + int r = in.read(out, off, n - off); + if (r == -1) return null; + off += r; + } + return out; + } + + private static void writeStatus(OutputStream out, String status) { + try { + out.write(("HTTP/1.1 " + status + "\r\nContent-Length: 0\r\n" + + "Connection: close\r\n\r\n").getBytes(StandardCharsets.US_ASCII)); + out.flush(); + } catch (IOException ignored) { + // best-effort error reply + } + } + + private void pipe(Socket a, Socket b) { + Thread t1 = new Thread(() -> copy(a, b), "vojo-relay-a2b"); + Thread t2 = new Thread(() -> copy(b, a), "vojo-relay-b2a"); + t1.setDaemon(true); + t2.setDaemon(true); + t1.start(); + t2.start(); + } + + private void copy(Socket from, Socket to) { + byte[] buf = new byte[16 * 1024]; + try { + InputStream in = from.getInputStream(); + OutputStream out = to.getOutputStream(); + int n; + while ((n = in.read(buf)) != -1) { + out.write(buf, 0, n); + out.flush(); + } + } catch (IOException ignored) { + // peer closed / reset — fall through to tear both sides down + } finally { + closeQuietly(from); + closeQuietly(to); + } + } + + private static String base64(String s) { + return android.util.Base64.encodeToString( + s.getBytes(StandardCharsets.UTF_8), android.util.Base64.NO_WRAP); + } + + private void track(Socket s) { + if (s != null) live.add(s); + } + + private void closeQuietly(Socket s) { + if (s == null) return; + live.remove(s); + try { + s.close(); + } catch (IOException ignored) { + // best-effort close + } + } +} diff --git a/android/app/src/main/java/chat/vojo/app/MainActivity.java b/android/app/src/main/java/chat/vojo/app/MainActivity.java index 8e808e08..ad031d5e 100644 --- a/android/app/src/main/java/chat/vojo/app/MainActivity.java +++ b/android/app/src/main/java/chat/vojo/app/MainActivity.java @@ -71,6 +71,7 @@ public class MainActivity extends BridgeActivity { registerPlugin(LaunchSplashPlugin.class); registerPlugin(ShareTargetPlugin.class); registerPlugin(PollingPlugin.class); + registerPlugin(ProxyPlugin.class); // AndroidX SplashScreen must be installed before super.onCreate(). // Keep it until the web splash confirms its first visible frame is diff --git a/android/app/src/main/java/chat/vojo/app/MarkAsReadReceiver.java b/android/app/src/main/java/chat/vojo/app/MarkAsReadReceiver.java index e46b0e85..c6d7a1e0 100644 --- a/android/app/src/main/java/chat/vojo/app/MarkAsReadReceiver.java +++ b/android/app/src/main/java/chat/vojo/app/MarkAsReadReceiver.java @@ -92,7 +92,7 @@ public class MarkAsReadReceiver extends BroadcastReceiver { final PendingResult pendingResult = goAsync(); EXECUTOR.execute(() -> { try { - int status = sendReceipt(homeserver, token, roomId, eventId); + int status = sendReceipt(appContext, homeserver, token, roomId, eventId); if (status >= 200 && status < 300) { if (BuildConfig.DEBUG) { Log.d(TAG, "receipt ok status=" + status + " room=" + roomId); @@ -109,6 +109,7 @@ public class MarkAsReadReceiver extends BroadcastReceiver { } private int sendReceipt( + Context context, String baseUrl, String accessToken, String roomId, @@ -120,7 +121,8 @@ public class MarkAsReadReceiver extends BroadcastReceiver { + "/receipt/m.read/" + URLEncoder.encode(eventId, "UTF-8"); - HttpURLConnection conn = (HttpURLConnection) new URL(url).openConnection(); + // Route through the loopback relay when a proxy is enabled (§15 #3). + HttpURLConnection conn = ProxyRouting.openConnection(context, new URL(url)); try { conn.setRequestMethod("POST"); conn.setRequestProperty("Authorization", "Bearer " + accessToken); diff --git a/android/app/src/main/java/chat/vojo/app/ProxyBridgeWebViewClient.java b/android/app/src/main/java/chat/vojo/app/ProxyBridgeWebViewClient.java new file mode 100644 index 00000000..831d9eaf --- /dev/null +++ b/android/app/src/main/java/chat/vojo/app/ProxyBridgeWebViewClient.java @@ -0,0 +1,83 @@ +package chat.vojo.app; + +import android.content.Context; +import android.webkit.HttpAuthHandler; +import android.webkit.WebResourceError; +import android.webkit.WebResourceRequest; +import android.webkit.WebView; +import android.webkit.WebViewClient; + +import com.getcapacitor.Bridge; +import com.getcapacitor.BridgeWebViewClient; + +/** + * Extends Capacitor's {@link BridgeWebViewClient} (preserving the local-server + * shouldInterceptRequest / launchIntent / error-page wiring via super) and adds + * exactly two proxy hooks (proxy_android_impl.md §1, §3 F5): + * + * 1. onReceivedHttpAuthRequest — answer the proxy/relay 407 with the stored + * creds / relay token. This is the (officially unsupported) Chromium + * mechanism for proxy auth, and is the Phase-0 linchpin: it must be + * validated on a real device. We only ever answer when the 407 comes from + * OUR active proxy/relay endpoint; everything else defers to super (cancel). + * + * 2. onReceivedError — surface a genuinely dead/wrong proxy as a status, but + * ONLY for connection-level error codes (CONNECT / PROXY_AUTHENTICATION / + * HOST_LOOKUP). Per-resource media failures (avatars cancelled on scroll, + * slow-image timeouts) must NOT flag the proxy as down — that flapped the + * status to "unavailable" while the proxy was healthy. HTTP error responses + * (incl. 5xx) are deliberately NOT treated as proxy failures: a status reply + * proves the request reached the server through the proxy. The JS /versions + * probe is the authoritative reachability oracle. + * + * We deliberately do NOT override onReceivedSslError — TLS validation stays at + * the system default (no weakening, proxy_support.md §15). + */ +public class ProxyBridgeWebViewClient extends BridgeWebViewClient { + + private final Context appContext; + + public ProxyBridgeWebViewClient(Bridge bridge) { + super(bridge); + this.appContext = bridge.getActivity().getApplicationContext(); + } + + @Override + public void onReceivedHttpAuthRequest( + WebView view, HttpAuthHandler handler, String host, String realm) { + String[] auth = ProxyManager.get(appContext).getProxyAuth(host, realm); + if (auth != null) { + handler.proceed(auth[0], auth[1]); + return; + } + super.onReceivedHttpAuthRequest(view, handler, host, realm); + } + + @Override + public void onReceivedError(WebView view, WebResourceRequest request, WebResourceError error) { + super.onReceivedError(view, request, error); + // Only flag the proxy as down for unambiguous connection-level failures. + // The WebView fires onReceivedError for every failed subresource, and the + // only homeserver-origin loads it makes are MEDIA (avatars/images), which + // fail routinely and benignly (cancelled on scroll, transient resets, + // slow-image timeouts) even when the proxy is perfectly healthy. Flagging + // any of those flapped the status to "unavailable" until a manual + // recheck. A hung/slow single resource (TIMEOUT/IO) is left to the + // authoritative JS /versions probe rather than latched here. + if (error == null) return; + int code = error.getErrorCode(); + if (code == WebViewClient.ERROR_CONNECT + || code == WebViewClient.ERROR_PROXY_AUTHENTICATION + || code == WebViewClient.ERROR_HOST_LOOKUP) { + notifyProxyError(request); + } + } + + private void notifyProxyError(WebResourceRequest request) { + if (request == null || request.getUrl() == null) return; + String host = request.getUrl().getHost(); + // ProxyManager filters to the active (homeserver) host; external origins + // failing is not a proxy problem. + ProxyManager.get(appContext).onWebViewError(host); + } +} diff --git a/android/app/src/main/java/chat/vojo/app/ProxyConfigStore.java b/android/app/src/main/java/chat/vojo/app/ProxyConfigStore.java new file mode 100644 index 00000000..09289cc1 --- /dev/null +++ b/android/app/src/main/java/chat/vojo/app/ProxyConfigStore.java @@ -0,0 +1,176 @@ +package chat.vojo.app; + +import android.content.Context; +import android.content.SharedPreferences; +import android.util.Base64; +import android.util.Log; + +import com.google.crypto.tink.Aead; +import com.google.crypto.tink.KeyTemplates; +import com.google.crypto.tink.KeysetHandle; +import com.google.crypto.tink.aead.AeadConfig; +import com.google.crypto.tink.integration.android.AndroidKeysetManager; + +import org.json.JSONException; +import org.json.JSONObject; + +/** + * The ONLY store for the user's proxy credentials (proxy_support.md §15 #4). + * + * The host/port/username/password blob is encrypted with a Tink AEAD primitive + * (AES-256-GCM) whose key-encryption key lives in the AndroidKeyStore — so the + * plaintext never touches disk, localStorage, settingsAtom, or any JS state. + * We deliberately do NOT use the deprecated + * androidx.security EncryptedSharedPreferences. + * + * allowBackup=false (AndroidManifest) already excludes this prefs file from + * Auto Backup / adb backup. Nothing here is ever logged. + * + * On logout the blob is wiped ({@link #wipe()}). + */ +final class ProxyConfigStore { + + private static final String TAG = "ProxyConfigStore"; + + // Dedicated prefs file — NOT the vojo_poll_state used by the push/polling + // path (proxy_android_impl.md §3: do not reuse the plaintext pattern). + static final String PREFS = "vojo_proxy_store"; + private static final String KEYSET_NAME = "vojo_proxy_keyset"; + private static final String MASTER_KEY_URI = "android-keystore://vojo_proxy_kek"; + private static final byte[] AAD = "vojo-proxy-v1".getBytes(); + + private static final String KEY_BLOB = "creds_blob"; + private static final String KEY_ENABLED = "enabled"; + // Epoch-ms of the most recent background native send that was refused + // because the relay couldn't start (fail-closed). Non-secret, persisted so + // it survives the process death that's typical for a Worker/receiver wake — + // the UI reads it to warn that background traffic was blocked (F17). + private static final String KEY_NATIVE_BLOCKED_AT = "native_blocked_at"; + + /** Plain value object for the decrypted config. Never logged. */ + static final class ProxyCreds { + final String type; // "http" | "socks5" + final String host; + final int port; + final String username; // nullable / empty = no auth + final String password; // nullable / empty + + ProxyCreds(String type, String host, int port, String username, String password) { + this.type = type; + this.host = host; + this.port = port; + this.username = username; + this.password = password; + } + + boolean hasAuth() { + return username != null && !username.isEmpty(); + } + } + + private final Context appContext; + private volatile Aead aead; + + ProxyConfigStore(Context context) { + this.appContext = context.getApplicationContext(); + } + + private SharedPreferences prefs() { + return appContext.getSharedPreferences(PREFS, Context.MODE_PRIVATE); + } + + // Lazily build the AEAD primitive (Keystore I/O). Synchronized so a + // concurrent FCM/Worker/plugin first-touch can't race the keyset creation + // (a documented Tink corruption hazard on some devices). + private synchronized Aead aead() { + if (aead != null) return aead; + try { + AeadConfig.register(); + KeysetHandle handle = new AndroidKeysetManager.Builder() + .withSharedPref(appContext, KEYSET_NAME, PREFS) + .withKeyTemplate(KeyTemplates.get("AES256_GCM")) + .withMasterKeyUri(MASTER_KEY_URI) + .build() + .getKeysetHandle(); + aead = handle.getPrimitive(Aead.class); + } catch (Throwable t) { + // Don't log the cause's message verbatim — be conservative around + // anything crypto. The store degrades to "unconfigured". + Log.w(TAG, "aead init failed"); + aead = null; + } + return aead; + } + + boolean isConfigured() { + return prefs().contains(KEY_BLOB); + } + + boolean isEnabled() { + return prefs().getBoolean(KEY_ENABLED, false); + } + + void setEnabled(boolean enabled) { + prefs().edit().putBoolean(KEY_ENABLED, enabled).apply(); + } + + long getNativeBlockedAt() { + return prefs().getLong(KEY_NATIVE_BLOCKED_AT, 0); + } + + void setNativeBlockedAt(long epochMs) { + prefs().edit().putLong(KEY_NATIVE_BLOCKED_AT, epochMs).apply(); + } + + /** Encrypt + persist the credentials. Returns false on any crypto failure. */ + boolean save(ProxyCreds creds) { + Aead a = aead(); + if (a == null) return false; + try { + JSONObject o = new JSONObject(); + o.put("type", creds.type); + o.put("host", creds.host); + o.put("port", creds.port); + if (creds.username != null) o.put("username", creds.username); + if (creds.password != null) o.put("password", creds.password); + byte[] cipher = a.encrypt(o.toString().getBytes("UTF-8"), AAD); + prefs().edit() + .putString(KEY_BLOB, Base64.encodeToString(cipher, Base64.NO_WRAP)) + .apply(); + return true; + } catch (Throwable t) { + Log.w(TAG, "save failed"); + return false; + } + } + + /** Decrypt the stored credentials, or null if absent / undecryptable. */ + ProxyCreds load() { + String b64 = prefs().getString(KEY_BLOB, null); + if (b64 == null) return null; + Aead a = aead(); + if (a == null) return null; + try { + byte[] plain = a.decrypt(Base64.decode(b64, Base64.NO_WRAP), AAD); + JSONObject o = new JSONObject(new String(plain, "UTF-8")); + return new ProxyCreds( + o.optString("type", "socks5"), + o.optString("host", ""), + o.optInt("port", 0), + o.has("username") ? o.optString("username", null) : null, + o.has("password") ? o.optString("password", null) : null + ); + } catch (JSONException | RuntimeException e) { + Log.w(TAG, "load parse failed"); + return null; + } catch (Throwable t) { + Log.w(TAG, "load decrypt failed"); + return null; + } + } + + /** Wipe the credentials + enabled flag (logout). */ + void wipe() { + prefs().edit().remove(KEY_BLOB).remove(KEY_ENABLED).remove(KEY_NATIVE_BLOCKED_AT).apply(); + } +} diff --git a/android/app/src/main/java/chat/vojo/app/ProxyManager.java b/android/app/src/main/java/chat/vojo/app/ProxyManager.java new file mode 100644 index 00000000..843238da --- /dev/null +++ b/android/app/src/main/java/chat/vojo/app/ProxyManager.java @@ -0,0 +1,375 @@ +package chat.vojo.app; + +import android.content.Context; +import android.util.Log; + +import androidx.webkit.ProxyConfig; +import androidx.webkit.ProxyController; +import androidx.webkit.WebViewFeature; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; + +import chat.vojo.app.ProxyConfigStore.ProxyCreds; + +/** + * Process-global owner of the WebView proxy override + the loopback relay + * (proxy_android_impl.md §3). Singleton because ProxyController is itself + * process-global and catches ALL WebView traffic — so the reverse-bypass MUST + * be scoped to exactly the homeserver host or external URL-preview / widget + * origins would leak through the user's proxy. + * + * Lifecycle (single-session app, no account switch): + * - applyAtBoot() re-applies a stored+enabled override before login. + * - apply() / disable() are the runtime toggle. + * - On logout the plugin calls disable() + ProxyConfigStore.wipe(). + * + * NEVER calls removeImplicitRules() (keeps localhost/link-local DIRECT — the + * §15 SSRF-to-localhost guard) and never weakens TLS. + */ +final class ProxyManager { + + private static final String TAG = "ProxyManager"; + private static final long LISTENER_TIMEOUT_MS = 3000; + + interface StatusListener { + void onStatus(String status, String detail); + } + + private static volatile ProxyManager instance; + + static ProxyManager get(Context context) { + ProxyManager local = instance; + if (local == null) { + synchronized (ProxyManager.class) { + local = instance; + if (local == null) { + local = new ProxyManager(context.getApplicationContext()); + instance = local; + } + } + } + return local; + } + + private final Context appContext; + private final ProxyConfigStore store; + private final ExecutorService callbackExec = Executors.newSingleThreadExecutor(); + + private volatile boolean enabled = false; + private volatile String status = "off"; + private volatile long appliedAtMs = 0; // when the override was applied (uptime base) + private volatile String activeHost; // homeserver host the override is scoped to (null = route-all) + private volatile LocalProxyRelay relay; + private volatile StatusListener statusListener; + + private ProxyManager(Context context) { + this.appContext = context; + this.store = new ProxyConfigStore(context); + } + + ProxyConfigStore store() { + return store; + } + + void setStatusListener(StatusListener listener) { + this.statusListener = listener; + } + + static boolean isOverrideSupported() { + return WebViewFeature.isFeatureSupported(WebViewFeature.PROXY_OVERRIDE); + } + + static boolean isReverseBypassSupported() { + return WebViewFeature.isFeatureSupported(WebViewFeature.PROXY_OVERRIDE_REVERSE_BYPASS); + } + + boolean isEnabled() { + return enabled; + } + + String getStatus() { + return status; + } + + /** Epoch-ms when the current override was applied (0 if disabled) — uptime base. */ + long getEnabledSince() { + return enabled ? appliedAtMs : 0; + } + + /** The homeserver host the override is scoped to, or null when routing all. */ + String getScopeHost() { + return activeHost; + } + + /** True when traffic goes via the loopback relay (authed proxy) vs direct. */ + boolean isRelayed() { + LocalProxyRelay r = relay; + return r != null && r.isRunning(); + } + + // ── Apply / disable ───────────────────────────────────────────────────── + + /** Apply the override for `creds`, scoped to `host` (null/routeAll = all + * traffic). Must run off the main thread. Returns true on success. */ + synchronized boolean apply(ProxyCreds creds, String host, boolean routeAll) { + if (creds == null) return false; + if (!isOverrideSupported()) { + Log.w(TAG, "PROXY_OVERRIDE unsupported on this WebView"); + return false; + } + + // Always (re)start a relay bound to fresh creds: the WebView uses it for + // the SOCKS5-with-auth case, and the native HttpURLConnection paths + // always route through it (proxy_support.md §15 #3). Restart so a config + // edit takes a fresh token + upstream. + teardownRelay(); + String relayTarget = startRelay(creds); + + // Route the WebView through the loopback relay whenever the upstream + // needs auth — SOCKS5-auth (Chromium can't present it) AND HTTP-auth, so + // the REAL upstream credential never transits the WebView's + // onReceivedHttpAuthRequest callback; the WebView only ever + // authenticates to the loopback relay with its per-session token, and + // the relay holds the upstream creds. No-auth proxies go direct. + String webViewTarget; + boolean socks5 = "socks5".equalsIgnoreCase(creds.type); + if (creds.hasAuth()) { + if (relayTarget == null) { + Log.w(TAG, "relay required for authed proxy but failed to start"); + // teardownRelay() above already killed any previous relay, so a + // prior override now points at a dead port. Reset to a clean + // disabled state (clear the override + flags) rather than leave a + // half-dead proxy the UI would still report as ON (F12). + disable(); + return false; + } + webViewTarget = relayTarget; + } else if (socks5) { + webViewTarget = "socks://" + creds.host + ":" + creds.port; + } else { + webViewTarget = "http://" + creds.host + ":" + creds.port; + } + + ProxyConfig.Builder builder = new ProxyConfig.Builder().addProxyRule(webViewTarget); + boolean scoped = !routeAll && host != null && !host.isEmpty() && isReverseBypassSupported(); + if (scoped) { + // Invert the bypass list into an allow-list: ONLY the homeserver host + // rides the proxy; everything else stays DIRECT. Exact host (a + // wildcard may not match the bare apex — proxy_support.md §15). + builder.setReverseBypassEnabled(true); + builder.addBypassRule(host); + } + // NEVER removeImplicitRules(): keeps 127.0.0.1 / link-local DIRECT so the + // loopback relay hop doesn't loop and XSS can't SSRF localhost. + + final CountDownLatch latch = new CountDownLatch(1); + try { + ProxyController.getInstance().setProxyOverride(builder.build(), callbackExec, latch::countDown); + if (!latch.await(LISTENER_TIMEOUT_MS, TimeUnit.MILLISECONDS)) { + // The request was accepted but Chromium's apply-callback didn't + // confirm in time. Proceed (it usually lands shortly after) but + // log it; the JS reachability probe is the authoritative + // ok/error signal, so we don't claim a false 'ok' here. + Log.w(TAG, "setProxyOverride listener timed out"); + } + } catch (Throwable t) { + Log.w(TAG, "setProxyOverride failed"); + // The previous relay is already torn down and the override may be + // stale/partial — reset to a clean disabled state rather than leave + // a dead-but-"enabled" proxy with in-memory enabled=true (F12). + disable(); + return false; + } + + enabled = true; + appliedAtMs = System.currentTimeMillis(); + activeHost = scoped ? host : null; + // The relay just (re)started, so any earlier "native send blocked" + // condition is resolved — clear the flag (F17). + clearNativeBlocked(); + setStatus("connecting", null); + Log.i(TAG, "override applied (scoped=" + scoped + ")"); + return true; + } + + /** Remove the override + tear down the relay. Must run off the main thread. */ + synchronized void disable() { + if (isOverrideSupported()) { + final CountDownLatch latch = new CountDownLatch(1); + try { + ProxyController.getInstance().clearProxyOverride(callbackExec, latch::countDown); + latch.await(LISTENER_TIMEOUT_MS, TimeUnit.MILLISECONDS); + } catch (Throwable t) { + Log.w(TAG, "clearProxyOverride failed"); + } + } + teardownRelay(); + enabled = false; + appliedAtMs = 0; + activeHost = null; + // Proxy is off → native paths go direct by design; no "blocked" meaning. + clearNativeBlocked(); + setStatus("off", null); + } + + /** Re-apply a stored+enabled override at boot. `host` = the resolved + * homeserver host (null pre-login → route-all). Must run off-main. */ + synchronized void applyAtBoot(String host) { + if (!store.isEnabled() || !store.isConfigured()) return; + ProxyCreds creds = store.load(); + if (creds == null) return; + boolean routeAll = host == null || host.isEmpty(); + boolean ok = apply(creds, host, routeAll); + // Persist the real outcome so the in-memory state, the persisted flag + // the native paths read (isProxyActiveForNative), and the UI can never + // disagree (F7). A boot-time apply failure persists disabled — the + // logged-in narrow effect re-applies and re-enables if it then succeeds. + store.setEnabled(ok); + } + + // ── Status (dead-proxy detection bridge) ──────────────────────────────── + + void onWebViewError(String failingHost) { + if (!enabled) return; + // Only flag the homeserver origin as an error; external origins failing + // is not a proxy problem. Pre-login (route-all, activeHost==null) relies + // on the JS reachability probe / discovery UI instead. + if (activeHost != null && failingHost != null && activeHost.equalsIgnoreCase(failingHost)) { + setStatus("error", failingHost); + } + } + + private void setStatus(String next, String detail) { + status = next; + StatusListener l = statusListener; + if (l != null) { + try { + l.onStatus(next, detail); + } catch (Throwable ignored) { + // listener teardown race — drop + } + } + } + + // ── WebView proxy auth (onReceivedHttpAuthRequest answer) ─────────────── + + /** Returns {user, pass} for the relay's 407, else null. We match on the + * relay's Basic realm ("vojo") OR a loopback host — matching the realm is + * robust to whatever host Chromium reports for the relay's 407 (the F2 + * linchpin uncertainty), and is safe: the token only works against our + * 127.0.0.1 listener, so it's useless even if a site somehow saw it. + * + * The real upstream credential is NEVER returned here — authed upstreams + * are reached through the relay, so the WebView only ever authenticates to + * the loopback relay with its per-session token. */ + String[] getProxyAuth(String host, String realm) { + if (!enabled) return null; + LocalProxyRelay r = relay; + if (r == null || !r.isRunning()) return null; + boolean realmMatch = realm != null && LocalProxyRelay.getAuthRealm().equals(realm); + boolean loopback = host != null && isLoopback(normalizeHost(host)); + if (realmMatch || loopback) { + return new String[] {LocalProxyRelay.getAuthUser(), r.getToken()}; + } + return null; + } + + // ── Native-path routing (Phase 2) ─────────────────────────────────────── + + /** Reads the persisted flag (works in a cold Worker/receiver process where + * apply() never ran). */ + boolean isProxyActiveForNative() { + return store.isEnabled() && store.isConfigured(); + } + + /** Ensure a relay is running for the native paths, starting it from the + * stored config if this process hasn't applied an override. Returns the + * port, or -1 on failure. */ + synchronized int ensureRelayForNative() { + LocalProxyRelay r = relay; + if (r != null && r.isRunning()) return r.getPort(); + ProxyCreds creds = store.load(); + if (creds == null) return -1; + startRelay(creds); + return relay != null && relay.isRunning() ? relay.getPort() : -1; + } + + /** A background native send was refused because the relay couldn't start + * (fail-closed). Records the moment so the UI can warn (F17). */ + void onNativeBlocked() { + store.setNativeBlockedAt(System.currentTimeMillis()); + } + + /** A native send went through the relay — clear any stale blocked flag. */ + void onNativeServed() { + clearNativeBlocked(); + } + + /** Epoch-ms of the most recent fail-closed native block (0 = none). */ + long getNativeBlockedAt() { + return store.getNativeBlockedAt(); + } + + private void clearNativeBlocked() { + if (store.getNativeBlockedAt() != 0) store.setNativeBlockedAt(0); + } + + String getRelayToken() { + LocalProxyRelay r = relay; + return r != null ? r.getToken() : null; + } + + int getRelayPort() { + LocalProxyRelay r = relay; + return r != null && r.isRunning() ? r.getPort() : -1; + } + + // ── Relay helpers ─────────────────────────────────────────────────────── + + // Returns the WebView CONNECT-proxy target for the relay, or null on failure. + private String startRelay(ProxyCreds creds) { + try { + LocalProxyRelay r = new LocalProxyRelay(creds, newToken()); + r.start(); + relay = r; + return "http://127.0.0.1:" + r.getPort(); + } catch (Throwable t) { + Log.w(TAG, "relay start failed"); + relay = null; + return null; + } + } + + private void teardownRelay() { + LocalProxyRelay r = relay; + if (r != null) { + r.stop(); + relay = null; + } + ProxyRouting.clearRelay(); + } + + private static String newToken() { + byte[] b = new byte[24]; + new java.security.SecureRandom().nextBytes(b); + return android.util.Base64.encodeToString(b, android.util.Base64.NO_WRAP | android.util.Base64.URL_SAFE); + } + + private static String normalizeHost(String host) { + String h = host; + int slash = h.indexOf('/'); + if (slash >= 0) h = h.substring(0, slash); + // strip :port + int colon = h.lastIndexOf(':'); + if (colon > 0 && h.indexOf(':') == colon) h = h.substring(0, colon); + if (h.startsWith("[") && h.endsWith("]")) h = h.substring(1, h.length() - 1); + return h; + } + + private static boolean isLoopback(String h) { + return "127.0.0.1".equals(h) || "localhost".equalsIgnoreCase(h) || "::1".equals(h); + } +} diff --git a/android/app/src/main/java/chat/vojo/app/ProxyPlugin.java b/android/app/src/main/java/chat/vojo/app/ProxyPlugin.java new file mode 100644 index 00000000..d88dfc59 --- /dev/null +++ b/android/app/src/main/java/chat/vojo/app/ProxyPlugin.java @@ -0,0 +1,173 @@ +package chat.vojo.app; + +import android.util.Log; + +import com.getcapacitor.Bridge; +import com.getcapacitor.JSObject; +import com.getcapacitor.Plugin; +import com.getcapacitor.PluginCall; +import com.getcapacitor.PluginMethod; +import com.getcapacitor.annotation.CapacitorPlugin; + +import chat.vojo.app.ProxyConfigStore.ProxyCreds; + +/** + * JS ↔ Android bridge for the "bring your own proxy" feature + * (proxy_android_impl.md §3). Mirrors PollingPlugin's shape. + * + * The password transits JS exactly once at entry ({@link #setConfig}) and is + * written straight into the Tink-encrypted store; it is NEVER returned to JS + * (getConfig / getState omit it) and never persisted in localStorage / JS state. + * + * Capacitor dispatches plugin methods off the main thread, so the blocking + * socket / setProxyOverride work in apply()/disable() is safe here. + */ +@CapacitorPlugin(name = "Proxy") +public class ProxyPlugin extends Plugin { + + private static final String TAG = "ProxyPlugin"; + + @Override + public void load() { + ProxyManager pm = ProxyManager.get(getContext()); + pm.setStatusListener((status, detail) -> { + JSObject o = new JSObject(); + o.put("status", status); + if (detail != null) o.put("detail", detail); + notifyListeners("proxyStatus", o); + }); + + // Attach our WebViewClient subclass so onReceivedHttpAuthRequest can + // answer the proxy 407 and onReceivedError can surface a dead proxy. + // Must touch the WebView on the UI thread. + final Bridge bridge = getBridge(); + try { + getActivity().runOnUiThread(() -> { + try { + // bridge.setWebViewClient (not webView.setWebViewClient) so + // Capacitor tracks it as THE client and won't silently + // overwrite our proxy-auth hook later. + bridge.setWebViewClient(new ProxyBridgeWebViewClient(bridge)); + } catch (Throwable t) { + Log.w(TAG, "failed to attach proxy WebViewClient"); + } + }); + } catch (Throwable t) { + Log.w(TAG, "runOnUiThread unavailable"); + } + } + + @PluginMethod + public void setConfig(PluginCall call) { + String type = call.getString("type"); + String host = call.getString("host"); + Integer port = call.getInt("port"); + if (!"http".equals(type) && !"socks5".equals(type)) { + call.reject("invalid_type"); + return; + } + if (host == null || host.isEmpty() || host.contains("://") || host.contains(" ")) { + call.reject("invalid_host"); + return; + } + if (port == null || port < 1 || port > 65535) { + call.reject("invalid_port"); + return; + } + + ProxyManager pm = ProxyManager.get(getContext()); + String username = call.getString("username"); + boolean hasUser = username != null && !username.isEmpty(); + String password = call.getString("password"); + + String finalUsername = hasUser ? username : null; + String finalPassword = null; + if (hasUser) { + if (password != null && !password.isEmpty()) { + finalPassword = password; + } else { + // No new password supplied — keep the existing one (lets the + // user edit host/port without re-typing the secret), but ONLY + // when the username is unchanged: reusing the old password for a + // NEW username would silently send user-B the password of + // user-A (F8). A changed username with no password = no-auth. + ProxyCreds existing = pm.store().load(); + finalPassword = (existing != null + && existing.password != null + && username.equals(existing.username)) ? existing.password : ""; + } + } + + boolean ok = pm.store().save(new ProxyCreds(type, host, port, finalUsername, finalPassword)); + if (!ok) { + call.reject("store_failed"); + return; + } + call.resolve(); + } + + @PluginMethod + public void clearConfig(PluginCall call) { + ProxyManager pm = ProxyManager.get(getContext()); + pm.disable(); + pm.store().wipe(); + call.resolve(); + } + + @PluginMethod + public void setEnabled(PluginCall call) { + boolean enabled = Boolean.TRUE.equals(call.getBoolean("enabled", false)); + String host = call.getString("homeserverHost"); + boolean routeAll = Boolean.TRUE.equals(call.getBoolean("routeAll", false)); + + ProxyManager pm = ProxyManager.get(getContext()); + if (enabled) { + ProxyCreds creds = pm.store().load(); + if (creds != null) { + boolean effectiveRouteAll = routeAll || host == null || host.isEmpty(); + boolean ok = pm.apply(creds, host, effectiveRouteAll); + pm.store().setEnabled(ok); + } + } else { + pm.disable(); + pm.store().setEnabled(false); + } + call.resolve(buildState(pm)); + } + + @PluginMethod + public void getState(PluginCall call) { + call.resolve(buildState(ProxyManager.get(getContext()))); + } + + @PluginMethod + public void applyAtBoot(PluginCall call) { + ProxyManager pm = ProxyManager.get(getContext()); + pm.applyAtBoot(call.getString("homeserverHost")); + call.resolve(buildState(pm)); + } + + private JSObject buildState(ProxyManager pm) { + JSObject o = new JSObject(); + o.put("enabled", pm.isEnabled()); + o.put("status", pm.getStatus()); + o.put("supported", ProxyManager.isOverrideSupported()); + o.put("reverseBypassSupported", ProxyManager.isReverseBypassSupported()); + o.put("enabledSince", pm.getEnabledSince()); + o.put("relayed", pm.isRelayed()); + o.put("nativeBlockedAt", pm.getNativeBlockedAt()); + if (pm.getScopeHost() != null) o.put("scopeHost", pm.getScopeHost()); + ProxyCreds c = pm.store().load(); + if (c == null) { + o.put("configured", false); + } else { + o.put("configured", true); + o.put("type", c.type); + o.put("host", c.host); + o.put("port", c.port); + o.put("hasAuth", c.hasAuth()); + if (c.username != null) o.put("username", c.username); + } + return o; + } +} diff --git a/android/app/src/main/java/chat/vojo/app/ProxyRouting.java b/android/app/src/main/java/chat/vojo/app/ProxyRouting.java new file mode 100644 index 00000000..223fe9b0 --- /dev/null +++ b/android/app/src/main/java/chat/vojo/app/ProxyRouting.java @@ -0,0 +1,130 @@ +package chat.vojo.app; + +import android.content.Context; +import android.util.Log; + +import java.io.IOException; +import java.net.Authenticator; +import java.net.HttpURLConnection; +import java.net.InetSocketAddress; +import java.net.PasswordAuthentication; +import java.net.Proxy; +import java.net.URL; +import java.util.concurrent.atomic.AtomicBoolean; + +/** + * Routes the five native HttpURLConnection paths (VojoPollWorker, AvatarLoader, + * CallDeclineReceiver, ReplyReceiver, MarkAsReadReceiver) through the local + * relay when a proxy is enabled, closing the §15 #3 native-path leak. These run + * outside the WebView so ProxyController never touches them. + * + * When the proxy is OFF (the default for ~all users) this is byte-for-byte the + * old behaviour — {@code url.openConnection()} — so the common path carries + * zero risk from the proxy feature. + * + * When the proxy is ON but the relay can't be started (e.g. AndroidKeyStore not + * yet usable before the first unlock after a reboot), the request is REFUSED + * (fail-closed), NOT sent direct: a direct send would leak the user's real IP + + * the homeserver SNI to the censor on the exact network the proxy exists to + * evade. The callers already degrade gracefully — the poll worker skips the + * cycle, ReplyReceiver re-posts a send-error, the avatar falls back to initials. + * + * Auth to the relay: a process-wide {@link Authenticator} that returns the + * relay's loopback token ONLY for a PROXY request to 127.0.0.1:<relayPort>, + * and null for anything else. NOTE: this is a deliberate, scoped use of + * Authenticator.setDefault — proxy_android_impl.md §3 flags the un-scoped + * pattern as unsafe because it can leak the *upstream* creds across unrelated + * connections. Here the secret handed out is the loopback relay token (not the + * upstream credential — those live only inside the relay), it is scoped to the + * exact loopback host+port+PROXY requestor, and Vojo has no other native + * HttpURLConnection users. HttpURLConnection.setAuthenticator (the per-call + * setter) does not exist on Android, hence the default-Authenticator route. + * + * Validated on-device: NO (no device available this session) — see the summary. + */ +final class ProxyRouting { + + private static final String TAG = "ProxyRouting"; + private static final AtomicBoolean AUTH_INSTALLED = new AtomicBoolean(false); + + private static volatile int relayPort = -1; + private static volatile String relayUser = "vojo"; + private static volatile String relayToken; + + private ProxyRouting() {} + + /** + * Open a connection for `url`, routed through the relay when the proxy is + * enabled. If the proxy is enabled but the relay can't be started, throws + * {@link RelayUnavailableException} (fail-closed) rather than leaking the + * request direct. When the proxy is off, a plain direct connection. + */ + static HttpURLConnection openConnection(Context ctx, URL url) throws IOException { + ProxyManager pm = ProxyManager.get(ctx); + if (!pm.isProxyActiveForNative()) { + return (HttpURLConnection) url.openConnection(); + } + int port = pm.ensureRelayForNative(); + String token = pm.getRelayToken(); + if (port < 0 || token == null) { + // FAIL-CLOSED: refuse the send instead of going direct (would leak + // IP/SNI on the censored network). Record it so the UI can warn. + pm.onNativeBlocked(); + Log.w(TAG, "relay unavailable; native request blocked (fail-closed)"); + throw new RelayUnavailableException(); + } + relayPort = port; + relayToken = token; + relayUser = LocalProxyRelay.getAuthUser(); + installAuthenticator(); + // Relay is up and serving — clear any stale "background blocked" flag. + pm.onNativeServed(); + Proxy proxy = new Proxy(Proxy.Type.HTTP, new InetSocketAddress("127.0.0.1", port)); + return (HttpURLConnection) url.openConnection(proxy); + } + + /** Forget the relay endpoint + token when the relay is torn down, so the + * scoped Authenticator stops handing anything out (defense-in-depth). */ + static void clearRelay() { + relayPort = -1; + relayToken = null; + } + + /** Thrown when the proxy is enabled but the loopback relay can't be started, + * so a native request is refused rather than sent in the clear (F17). */ + static final class RelayUnavailableException extends IOException { + RelayUnavailableException() { + super("proxy relay unavailable"); + } + } + + private static void installAuthenticator() { + if (!AUTH_INSTALLED.compareAndSet(false, true)) return; + Authenticator.setDefault(new Authenticator() { + @Override + protected PasswordAuthentication getPasswordAuthentication() { + // The real security boundary is requestor-type + the ephemeral + // relay port + a live token: the token only works against our + // own 127.0.0.1 listener. Accept any loopback *name* Android may + // report for the relay endpoint (a literal IP InetSocketAddress + // can surface as "localhost" once resolved), so an authed native + // request never silently fails the host check and falls back to + // a direct, un-proxied connection (F9). + if (getRequestorType() == RequestorType.PROXY + && isLoopbackHost(getRequestingHost()) + && getRequestingPort() == relayPort + && relayToken != null) { + return new PasswordAuthentication(relayUser, relayToken.toCharArray()); + } + // Not our loopback relay — never hand out credentials. + return null; + } + }); + } + + private static boolean isLoopbackHost(String h) { + return "127.0.0.1".equals(h) + || "::1".equals(h) + || "localhost".equalsIgnoreCase(h); + } +} diff --git a/android/app/src/main/java/chat/vojo/app/ReplyReceiver.java b/android/app/src/main/java/chat/vojo/app/ReplyReceiver.java index ecac6b2e..32e4fd85 100644 --- a/android/app/src/main/java/chat/vojo/app/ReplyReceiver.java +++ b/android/app/src/main/java/chat/vojo/app/ReplyReceiver.java @@ -130,7 +130,7 @@ public class ReplyReceiver extends BroadcastReceiver { final String txnId = "vojo-reply-" + UUID.randomUUID(); EXECUTOR.execute(() -> { try { - int status = sendReply(homeserver, token, roomId, txnId, text); + int status = sendReply(appContext, homeserver, token, roomId, txnId, text); if (status >= 200 && status < 300) { if (BuildConfig.DEBUG) { Log.d(TAG, "reply ok status=" + status + " room=" + roomId); @@ -149,6 +149,7 @@ public class ReplyReceiver extends BroadcastReceiver { } private int sendReply( + Context context, String baseUrl, String accessToken, String roomId, @@ -173,7 +174,8 @@ public class ReplyReceiver extends BroadcastReceiver { } byte[] payload = body.toString().getBytes("UTF-8"); - HttpURLConnection conn = (HttpURLConnection) new URL(url).openConnection(); + // Route through the loopback relay when a proxy is enabled (§15 #3). + HttpURLConnection conn = ProxyRouting.openConnection(context, new URL(url)); try { conn.setRequestMethod("PUT"); conn.setRequestProperty("Authorization", "Bearer " + accessToken); diff --git a/android/app/src/main/java/chat/vojo/app/VojoPollWorker.java b/android/app/src/main/java/chat/vojo/app/VojoPollWorker.java index e550660a..f01db381 100644 --- a/android/app/src/main/java/chat/vojo/app/VojoPollWorker.java +++ b/android/app/src/main/java/chat/vojo/app/VojoPollWorker.java @@ -377,6 +377,14 @@ public class VojoPollWorker extends Worker { .remove(KEY_ACCESS_TOKEN) .apply(); return Result.success(); + } catch (ProxyRouting.RelayUnavailableException e) { + // Proxy enabled but the relay couldn't start (e.g. Keystore not yet + // usable pre-unlock after a reboot). Fail-closed: skip this cycle + // rather than poll direct and leak IP/SNI. Result.success() (not + // retry) so we wait for the next scheduled fire instead of an + // accelerated retry-storm while the device is still locked. + Log.w(TAG, "poll: proxy relay unavailable — skipping cycle (fail-closed)"); + return Result.success(); } catch (ForbiddenException e) { // 403 from Synapse is usually rate-limit or a transient server // policy reject, not a dead token. Don't clear credentials — @@ -491,7 +499,10 @@ public class VojoPollWorker extends Worker { url.append("&from=").append(java.net.URLEncoder.encode(fromCursor, "UTF-8")); } - HttpURLConnection conn = (HttpURLConnection) new URL(url.toString()).openConnection(); + // Route through the loopback relay when a proxy is enabled — the + // /notifications poll is exactly the path a censored intranet user + // needs proxied (§15 #3). Identical to before when the proxy is off. + HttpURLConnection conn = ProxyRouting.openConnection(getApplicationContext(), new URL(url.toString())); try { conn.setRequestMethod("GET"); conn.setRequestProperty("Authorization", "Bearer " + token); diff --git a/package.json b/package.json index 98efb92a..77b0a441 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "check:prettier": "prettier --check .", "fix:prettier": "prettier --write .", "typecheck": "tsc --noEmit", + "check:tls-guard": "node scripts/check-no-tls-weakening.mjs", "gen:push-strings": "node scripts/gen-push-strings.mjs", "android:sync": "npx cap sync android", "android:open": "npx cap open android", diff --git a/public/locales/en.json b/public/locales/en.json index f1b18f4c..9515c380 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -113,6 +113,48 @@ "menu_devices": "Devices", "menu_emojis_stickers": "Emojis & Stickers", "menu_about": "About", + "network": { + "menu": "Connection", + "title": "Connection", + "section_proxy": "Proxy server", + "type_label": "Type", + "type_socks5": "SOCKS5", + "type_http": "HTTP", + "section_status": "Status", + "ping_label": "Ping", + "uptime_label": "Connected", + "checked_label": "Checked", + "scope_label": "Through proxy", + "scope_all": "All traffic", + "recheck": "Check now", + "host_label": "Host", + "host_placeholder": "proxy.example.com", + "port_label": "Port", + "port_placeholder": "1080", + "username_label_short": "Login", + "password_label_short": "Password", + "optional": "—", + "password_placeholder_set": "••••••••", + "toggle_password": "Show password", + "save": "Save", + "enabled_label": "Use proxy", + "remove": "Remove proxy", + "status_off": "Off", + "status_connecting": "Connecting…", + "status_ok": "Connected", + "status_error": "Can't reach the proxy", + "status_pending": "On — will verify at sign-in", + "native_blocked": "A background message couldn't go through the proxy and was held back. It will retry once the proxy is reachable.", + "unsupported": "Your system WebView is too old to route traffic through a proxy.", + "unavailable_platform": "A proxy is available only in the Vojo app for Android.", + "reverse_bypass_unsupported": "Your WebView can't limit the proxy to the homeserver, so all app traffic goes through it while it's on.", + "error_host_required": "Enter the proxy host.", + "error_host_scheme": "Enter only the host — no http:// or https://.", + "error_port_invalid": "Enter a port between 1 and 65535.", + "error_save_failed": "Couldn't save the proxy settings. Try again.", + "pre_login_entry": "Connection / proxy settings", + "pre_login_title": "Connection" + }, "drag_to_close": "Drag down to close", "close": "Close", "logout": "Logout", diff --git a/public/locales/ru.json b/public/locales/ru.json index fb83ce93..42a3d815 100644 --- a/public/locales/ru.json +++ b/public/locales/ru.json @@ -113,6 +113,48 @@ "menu_devices": "Устройства", "menu_emojis_stickers": "Эмодзи и стикеры", "menu_about": "О приложении", + "network": { + "menu": "Соединение", + "title": "Соединение", + "section_proxy": "Сервер прокси", + "type_label": "Тип", + "type_socks5": "SOCKS5", + "type_http": "HTTP", + "section_status": "Состояние", + "ping_label": "Пинг", + "uptime_label": "Подключён", + "checked_label": "Проверка", + "scope_label": "Через прокси", + "scope_all": "Весь трафик", + "recheck": "Проверить", + "host_label": "Хост", + "host_placeholder": "proxy.example.com", + "port_label": "Порт", + "port_placeholder": "1080", + "username_label_short": "Логин", + "password_label_short": "Пароль", + "optional": "—", + "password_placeholder_set": "••••••••", + "toggle_password": "Показать пароль", + "save": "Сохранить", + "enabled_label": "Использовать прокси", + "remove": "Удалить прокси", + "status_off": "Выключен", + "status_connecting": "Подключение…", + "status_ok": "Подключено", + "status_error": "Прокси недоступен", + "status_pending": "Включён — проверим при входе", + "native_blocked": "Фоновое сообщение не удалось отправить через прокси, оно отложено. Повтор произойдёт, когда прокси станет доступен.", + "unsupported": "Системный WebView слишком старый, чтобы направлять трафик через прокси.", + "unavailable_platform": "Прокси доступен только в приложении Vojo для Android.", + "reverse_bypass_unsupported": "Ваш WebView не может ограничить прокси только домашним сервером, поэтому пока прокси включён, через него идёт весь трафик приложения.", + "error_host_required": "Укажите хост прокси.", + "error_host_scheme": "Укажите только хост — без http:// или https://.", + "error_port_invalid": "Укажите порт от 1 до 65535.", + "error_save_failed": "Не удалось сохранить настройки прокси. Попробуйте ещё раз.", + "pre_login_entry": "Соединение / прокси", + "pre_login_title": "Соединение" + }, "drag_to_close": "Потянуть вниз чтобы закрыть", "close": "Закрыть", "logout": "Выйти", diff --git a/scripts/check-no-tls-weakening.mjs b/scripts/check-no-tls-weakening.mjs new file mode 100644 index 00000000..7b0700f8 --- /dev/null +++ b/scripts/check-no-tls-weakening.mjs @@ -0,0 +1,64 @@ +#!/usr/bin/env node +/* eslint-disable no-console, no-restricted-syntax */ +// This is a standalone Node CLI guard (not app code): console output is its +// whole purpose, and directory-walk loops are clearer than array gymnastics. +// +// Binding guard for proxy_support.md §15 #6: the native tree must never weaken +// TLS. The bring-your-own-proxy relay is a pure L4 forwarder — the WebView's +// TLS to the homeserver stays end-to-end validated against the system store, +// even through an untrusted upstream proxy. This guard fails the build if a +// future change introduces an SSL-error bypass, a custom TrustManager / +// HostnameVerifier, or cleartext traffic. Wired into .husky/pre-commit. + +import { readdirSync, readFileSync, statSync } from 'node:fs'; +import { join } from 'node:path'; + +const ROOT = 'android/app/src/main'; + +const FORBIDDEN = [ + { + re: /onReceivedSslError/, + msg: 'onReceivedSslError override (would let a proxy MITM WebView TLS)', + }, + { + re: /X509TrustManager|X509ExtendedTrustManager|implements\s+TrustManager/, + msg: 'custom TrustManager (TLS trust bypass)', + }, + { re: /HostnameVerifier|ALLOW_ALL_HOSTNAME_VERIFIER/, msg: 'custom/permissive HostnameVerifier' }, + { re: /usesCleartextTraffic\s*=\s*"true"/, msg: 'usesCleartextTraffic="true"' }, + { re: /setHostnameVerifier|setDefaultHostnameVerifier/, msg: 'setHostnameVerifier' }, +]; + +function walk(dir) { + let out = []; + for (const name of readdirSync(dir)) { + const p = join(dir, name); + const s = statSync(p); + if (s.isDirectory()) out = out.concat(walk(p)); + else if (/\.(java|kt|xml)$/.test(name)) out.push(p); + } + return out; +} + +const violations = []; +for (const file of walk(ROOT)) { + const lines = readFileSync(file, 'utf8').split('\n'); + lines.forEach((line, i) => { + const trimmed = line.trimStart(); + // Skip comments (our own code documents that it does NOT do these things). + if (trimmed.startsWith('//') || trimmed.startsWith('*') || trimmed.startsWith('/*')) return; + for (const { re, msg } of FORBIDDEN) { + if (re.test(line)) violations.push(` ${file}:${i + 1} ${msg}\n ${line.trim()}`); + } + }); +} + +if (violations.length) { + console.error('\n✗ TLS-weakening guard failed (proxy_support.md §15 #6):\n'); + console.error(violations.join('\n')); + console.error( + '\nThe proxy is a pure L4 forwarder; WebView TLS must stay end-to-end validated.\n' + ); + process.exit(1); +} +console.log('✓ TLS-weakening guard: clean'); diff --git a/src/app/features/settings/Settings.tsx b/src/app/features/settings/Settings.tsx index 374e5e15..62c7328b 100644 --- a/src/app/features/settings/Settings.tsx +++ b/src/app/features/settings/Settings.tsx @@ -24,6 +24,7 @@ import { Notifications } from './notifications'; import { Devices } from './devices'; import { EmojisStickers } from './emojis-stickers'; import { About } from './about'; +import { Network } from './network'; import { UseStateProvider } from '../../components/UseStateProvider'; import { stopPropagation } from '../../utils/keyboard'; import { LogoutDialog } from '../../components/LogoutDialog'; @@ -32,6 +33,7 @@ export enum SettingsPages { GeneralPage, AccountPage, NotificationPage, + NetworkPage, DevicesPage, EmojisStickersPage, AboutPage, @@ -52,6 +54,7 @@ export const SETTINGS_PAGE_PARAM = { general: SettingsPages.GeneralPage, account: SettingsPages.AccountPage, notifications: SettingsPages.NotificationPage, + network: SettingsPages.NetworkPage, devices: SettingsPages.DevicesPage, emojis: SettingsPages.EmojisStickersPage, about: SettingsPages.AboutPage, @@ -78,6 +81,7 @@ const SETTINGS_MENU_ITEMS: SettingsMenuItem[] = [ nameKey: 'Settings.menu_notifications', icon: Icons.Bell, }, + { page: SettingsPages.NetworkPage, nameKey: 'Settings.network.menu', icon: Icons.Server }, { page: SettingsPages.DevicesPage, nameKey: 'Settings.menu_devices', icon: Icons.Monitor }, { page: SettingsPages.EmojisStickersPage, @@ -285,6 +289,9 @@ export function Settings({ initialPage, requestClose }: SettingsProps) { {activePage === SettingsPages.NotificationPage && ( )} + {activePage === SettingsPages.NetworkPage && ( + + )} {activePage === SettingsPages.DevicesPage && ( )} diff --git a/src/app/features/settings/network/Network.tsx b/src/app/features/settings/network/Network.tsx new file mode 100644 index 00000000..e855e53a --- /dev/null +++ b/src/app/features/settings/network/Network.tsx @@ -0,0 +1,19 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { useMatrixClient } from '../../../hooks/useMatrixClient'; +import { SettingsPage } from '../SettingsPage'; +import { ProxyForm } from './ProxyForm'; + +type NetworkProps = { + requestClose: () => void; +}; + +export function Network({ requestClose }: NetworkProps) { + const { t } = useTranslation(); + const mx = useMatrixClient(); + return ( + + + + ); +} diff --git a/src/app/features/settings/network/ProxyForm.tsx b/src/app/features/settings/network/ProxyForm.tsx new file mode 100644 index 00000000..738429e6 --- /dev/null +++ b/src/app/features/settings/network/ProxyForm.tsx @@ -0,0 +1,527 @@ +import React, { FormEventHandler, useEffect, useId, useRef, useState } from 'react'; +import { + Box, + Button, + Icon, + IconButton, + Icons, + Menu, + MenuItem, + PopOut, + RectCords, + Spinner, + Switch, + Text, + color, + config, +} from 'folds'; +import FocusTrap from 'focus-trap-react'; +import { useTranslation } from 'react-i18next'; +import { SectionLabel } from '../styles.css'; +import { useProxy } from '../../../hooks/useProxy'; +import { isAndroidPlatform } from '../../../utils/capacitor'; +import { stopPropagation } from '../../../utils/keyboard'; +import type { ProxyStatus, ProxyType } from '../../../plugins/proxy'; +import * as css from './network.css'; + +const TYPE_OPTIONS: ProxyType[] = ['socks5', 'http']; + +// A bare host is `letters/digits/dots/hyphens/colon-for-ipv6` with no scheme. +// We explicitly forbid `http://` / `https://` prefixes: the WebView proxy-auth +// mechanism only works for a plaintext-HTTP proxy inbound, an HTTPS proxy +// hard-fails ERR_PROXY_AUTH_UNSUPPORTED (proxy_support.md §15 #2). So the form +// accepts only a bare host and a scheme derived from the type selector. +const SCHEME_RE = /:\/\//; + +type ProxyFormProps = { + // The live homeserver base URL (`https://matrix.vojo.chat`). Used to scope + // the override to that host (reverse-bypass). Omit pre-login when the + // homeserver isn't resolved yet — the override then routes all traffic. + homeserverBaseUrl?: string; + // Compact = the pre-login overlay (which has its own header), so the section + // label is dropped. + compact?: boolean; +}; + +export function ProxyForm({ homeserverBaseUrl, compact }: ProxyFormProps) { + const { t, i18n } = useTranslation(); + const { state, saveConfig, setEnabled, clearConfig, probe } = useProxy(); + const fid = useId(); + const locale = i18n.language || 'en'; + + // Tick once a second while enabled so the uptime / "checked …" rows stay live. + const [now, setNow] = useState(() => Date.now()); + useEffect(() => { + if (!state.enabled) return undefined; + const id = window.setInterval(() => setNow(Date.now()), 1000); + return () => window.clearInterval(id); + }, [state.enabled]); + + const [type, setType] = useState('socks5'); + const [typeMenu, setTypeMenu] = useState(); + const [host, setHost] = useState(''); + const [port, setPort] = useState(''); + const [username, setUsername] = useState(''); + const [password, setPassword] = useState(''); + const [passwordTouched, setPasswordTouched] = useState(false); + const [showPassword, setShowPassword] = useState(false); + const [error, setError] = useState(); + const [busy, setBusy] = useState(false); + + // Prefill the non-secret fields once the native state has hydrated. The + // password is never read back — it stays blank with an "unchanged" hint. + const initializedRef = useRef(false); + useEffect(() => { + if (initializedRef.current) return; + if (!state.configured) return; + initializedRef.current = true; + if (state.type) setType(state.type); + if (state.host) setHost(state.host); + if (typeof state.port === 'number') setPort(String(state.port)); + if (state.username) setUsername(state.username); + }, [state]); + + const validate = (): { host: string; port: number } | undefined => { + const trimmedHost = host.trim(); + if (!trimmedHost) { + setError(t('Settings.network.error_host_required')); + return undefined; + } + if (SCHEME_RE.test(trimmedHost) || /\s/.test(trimmedHost)) { + setError(t('Settings.network.error_host_scheme')); + return undefined; + } + const portNum = Number.parseInt(port, 10); + if (!Number.isInteger(portNum) || portNum < 1 || portNum > 65535) { + setError(t('Settings.network.error_port_invalid')); + return undefined; + } + setError(undefined); + return { host: trimmedHost, port: portNum }; + }; + + const persist = async (): Promise => { + const valid = validate(); + if (!valid) return false; + try { + await saveConfig({ + type, + host: valid.host, + port: valid.port, + username: username.trim() || undefined, + // Omit the password when the user didn't touch it: the native store + // keeps the existing encrypted value. An empty username clears auth. + password: passwordTouched ? password : undefined, + }); + } catch { + // The native store rejected (e.g. Keystore failure). Surface it and KEEP + // the typed password — clearing it here (the success path below) would + // silently lose the secret the user just entered (F11). + setError(t('Settings.network.error_save_failed')); + return false; + } + setPasswordTouched(false); + setPassword(''); + return true; + }; + + const handleSave: FormEventHandler = async (evt) => { + evt.preventDefault(); + if (busy) return; + setBusy(true); + try { + const ok = await persist(); + // Re-apply immediately if the proxy is already on, so edits take effect + // without a manual off/on cycle. + if (ok && state.enabled) { + await setEnabled(true, homeserverBaseUrl); + } + } finally { + setBusy(false); + } + }; + + const handleToggle = async (next: boolean) => { + if (busy) return; + setBusy(true); + try { + if (next) { + if (!(await persist())) return; + await setEnabled(true, homeserverBaseUrl); + } else { + await setEnabled(false, homeserverBaseUrl); + } + } finally { + setBusy(false); + } + }; + + const handleRemove = async () => { + if (busy) return; + setBusy(true); + try { + await clearConfig(); + initializedRef.current = false; + setHost(''); + setPort(''); + setUsername(''); + setPassword(''); + setPasswordTouched(false); + setError(undefined); + } finally { + setBusy(false); + } + }; + + // Pre-login (no homeserverBaseUrl) we can't probe reachability, so a native + // 'connecting' would hang forever. Present it as a calm neutral "pending" + // (on, will verify at sign-in) instead of a perpetual amber "Connecting…" + // (F2/F18). Once logged in, the boot probe gives a real ok/error. + const canProbe = Boolean(homeserverBaseUrl); + const displayStatus: ProxyStatus | 'pending' = + state.enabled && !canProbe && state.status === 'connecting' ? 'pending' : state.status; + const statusLabel = ((): string => { + switch (displayStatus) { + case 'ok': + return t('Settings.network.status_ok'); + case 'error': + return t('Settings.network.status_error'); + case 'connecting': + return t('Settings.network.status_connecting'); + case 'pending': + return t('Settings.network.status_pending'); + default: + return t('Settings.network.status_off'); + } + })(); + + const onPortChange = (e: React.ChangeEvent) => + setPort(e.target.value.replace(/[^\d]/g, '').slice(0, 5)); + + const typeLabel = (v: ProxyType): string => + v === 'socks5' ? t('Settings.network.type_socks5') : t('Settings.network.type_http'); + + // Locale-aware metadata formatters (no per-unit i18n keys needed — Intl + // renders "123 ms" / "123 мс", "5 min" / "5 мин", "2 minutes ago" / …). + const unit = (value: number, u: string): string => + new Intl.NumberFormat(locale, { style: 'unit', unit: u, unitDisplay: 'short' }).format(value); + const fmtPing = (ms: number): string => unit(ms, 'millisecond'); + const fmtUptime = (sinceMs: number): string => { + const secs = Math.max(0, Math.floor((now - sinceMs) / 1000)); + if (secs < 60) return unit(secs, 'second'); + const mins = Math.floor(secs / 60); + if (mins < 60) return unit(mins, 'minute'); + return `${unit(Math.floor(mins / 60), 'hour')} ${unit(mins % 60, 'minute')}`; + }; + const fmtChecked = (atMs: number): string => { + const deltaS = Math.round((atMs - now) / 1000); + const rtf = new Intl.RelativeTimeFormat(locale, { numeric: 'auto' }); + return Math.abs(deltaS) < 60 + ? rtf.format(deltaS, 'second') + : rtf.format(Math.round(deltaS / 60), 'minute'); + }; + + const handleRecheck = async () => { + if (!homeserverBaseUrl || busy) return; + setBusy(true); + try { + await probe(homeserverBaseUrl); + } finally { + setBusy(false); + } + }; + + // "Has anything actually changed since what's saved?" — drives the Save + // button's enabled state so it isn't perpetually lit with nothing to persist. + // The password counts as a change only once the user types into it + // (passwordTouched), since the saved secret never round-trips to JS. + const savedPort = typeof state.port === 'number' ? String(state.port) : ''; + const isDirty = + passwordTouched || + type !== (state.type ?? 'socks5') || + host.trim() !== (state.host ?? '') || + port !== savedPort || + username.trim() !== (state.username ?? ''); + + return ( + + {/* Hero: icon + title + live status + the big toggle. */} +
+
+ +
+
+ + {t('Settings.network.enabled_label')} + + + + + {statusLabel} + + +
+ {busy && } + +
+ + {!state.supported ? ( +
+ + + + + {isAndroidPlatform() + ? t('Settings.network.unsupported') + : t('Settings.network.unavailable_platform')} + +
+ ) : ( + <> + {state.enabled && ( + + {!compact && ( + {t('Settings.network.section_status')} + )} +
+
+ {t('Settings.network.ping_label')} + + {state.latencyMs != null ? fmtPing(state.latencyMs) : '—'} + +
+
+ {t('Settings.network.uptime_label')} + + {state.enabledSince ? fmtUptime(state.enabledSince) : '—'} + +
+
+ {t('Settings.network.checked_label')} + + {state.checkedAt ? fmtChecked(state.checkedAt) : '—'} + {homeserverBaseUrl && ( + + + + )} + +
+
+ {t('Settings.network.scope_label')} + + {state.scopeHost ?? t('Settings.network.scope_all')} + +
+
+ {state.nativeBlockedAt ? ( + + + {t('Settings.network.native_blocked')} + + ) : null} +
+ )} + + {/* Server section: type + address + optional auth, one grouped panel. */} +
+ + {!compact && ( + {t('Settings.network.section_proxy')} + )} +
+
+ {t('Settings.network.type_label')} + + setTypeMenu(undefined), + clickOutsideDeactivates: true, + escapeDeactivates: stopPropagation, + }} + > + + + {TYPE_OPTIONS.map((opt) => ( + { + setType(opt); + setTypeMenu(undefined); + }} + > + + + {typeLabel(opt)} + + {type === opt && } + + + ))} + + + + } + /> +
+ +
+ + ) => setHost(e.target.value)} + placeholder={t('Settings.network.host_placeholder')} + autoComplete="off" + autoCapitalize="off" + autoCorrect="off" + spellCheck={false} + inputMode="url" + /> +
+ +
+ + +
+ +
+ + ) => + setUsername(e.target.value) + } + placeholder={t('Settings.network.optional')} + autoComplete="off" + autoCapitalize="off" + autoCorrect="off" + spellCheck={false} + /> +
+ +
+ + ) => { + setPassword(e.target.value); + setPasswordTouched(true); + }} + placeholder={ + state.hasAuth + ? t('Settings.network.password_placeholder_set') + : t('Settings.network.optional') + } + autoComplete="off" + /> + +
+
+ + {error && ( + + {error} + + )} + + {!state.reverseBypassSupported && ( + + {t('Settings.network.reverse_bypass_unsupported')} + + )} + + + + {state.configured && ( + + )} +
+
+ + )} +
+ ); +} diff --git a/src/app/features/settings/network/index.ts b/src/app/features/settings/network/index.ts new file mode 100644 index 00000000..49914920 --- /dev/null +++ b/src/app/features/settings/network/index.ts @@ -0,0 +1,2 @@ +export * from './Network'; +export * from './ProxyForm'; diff --git a/src/app/features/settings/network/network.css.ts b/src/app/features/settings/network/network.css.ts new file mode 100644 index 00000000..331cd785 --- /dev/null +++ b/src/app/features/settings/network/network.css.ts @@ -0,0 +1,183 @@ +import { style, styleVariants } from '@vanilla-extract/css'; +import { color, config, toRem } from 'folds'; + +// Hero status card — leading tinted icon, title + live status, big toggle. +export const HeroCard = style({ + display: 'flex', + alignItems: 'center', + gap: config.space.S300, + backgroundColor: color.SurfaceVariant.Container, + borderRadius: toRem(16), + padding: config.space.S400, +}); + +export const HeroIcon = style({ + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + width: toRem(40), + height: toRem(40), + borderRadius: toRem(12), + flexShrink: 0, + backgroundColor: color.Primary.Container, + color: color.Primary.OnContainer, +}); + +export const HeroText = style({ + display: 'flex', + flexDirection: 'column', + gap: toRem(2), + flexGrow: 1, + minWidth: 0, +}); + +export const StatusLine = style({ + display: 'flex', + alignItems: 'center', + gap: toRem(6), +}); + +export const StatusDot = style({ + width: toRem(8), + height: toRem(8), + borderRadius: '50%', + flexShrink: 0, +}); + +export const statusDotColor = styleVariants({ + off: { backgroundColor: color.SurfaceVariant.OnContainer, opacity: 0.4 }, + connecting: { backgroundColor: color.Warning.Main }, + ok: { backgroundColor: color.Success.Main }, + error: { backgroundColor: color.Critical.Main }, + // Pre-login: enabled but unverifiable (no homeserver to probe yet) — a calm + // neutral dot, not the amber "connecting" that would never resolve. + pending: { backgroundColor: color.Primary.Main }, +}); + +// A subtle inline warning (e.g. background native traffic was blocked). +export const WarnLine = style({ + display: 'flex', + gap: toRem(6), + alignItems: 'flex-start', + color: color.Warning.Main, +}); + +// One softly-rounded inset panel; rows parted by hairlines (iOS grouped list). +export const Panel = style({ + display: 'flex', + flexDirection: 'column', + backgroundColor: color.SurfaceVariant.Container, + borderRadius: toRem(16), + overflow: 'hidden', +}); + +// A field row: muted label on the left, borderless input filling the rest. +export const FieldRow = style({ + display: 'flex', + alignItems: 'center', + gap: config.space.S300, + minHeight: toRem(48), + paddingLeft: config.space.S400, + paddingRight: config.space.S400, + paddingTop: config.space.S200, + paddingBottom: config.space.S200, + selectors: { + '&:not(:last-child)': { + borderBottom: `1px solid ${color.SurfaceVariant.ContainerLine}`, + }, + }, +}); + +export const FieldLabel = style({ + width: toRem(96), + flexShrink: 0, + whiteSpace: 'nowrap', + fontSize: toRem(15), + color: color.SurfaceVariant.OnContainer, +}); + +// Read-only metadata value on the right of a status row. +export const MetaValue = style({ + marginLeft: 'auto', + display: 'flex', + alignItems: 'center', + gap: toRem(6), + minWidth: 0, + fontSize: toRem(15), + color: color.SurfaceVariant.OnContainer, + fontVariantNumeric: 'tabular-nums', +}); + +// Borderless input that reads as part of the row (no box of its own). +export const FieldInput = style({ + flexGrow: 1, + minWidth: 0, + border: 'none', + outline: 'none', + background: 'transparent', + color: color.SurfaceVariant.OnContainer, + fontSize: toRem(15), + fontFamily: 'inherit', + padding: 0, + textAlign: 'right', + '::placeholder': { + color: color.SurfaceVariant.OnContainer, + opacity: 0.35, + }, +}); + +export const EyeButton = style({ + flexShrink: 0, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + border: 'none', + background: 'transparent', + color: color.SurfaceVariant.OnContainer, + opacity: 0.6, + cursor: 'pointer', + padding: toRem(2), + selectors: { '&:hover': { opacity: 1 } }, +}); + +// A tappable "value" on the right of a grouped row (the Type selector) — the +// current value + a chevron, borderless so it reads as part of the row, same +// as the iOS settings "Theme ›" pattern. +export const SelectValue = style({ + display: 'flex', + alignItems: 'center', + gap: toRem(4), + marginLeft: 'auto', + border: 'none', + outline: 'none', + background: 'transparent', + color: color.SurfaceVariant.OnContainer, + fontSize: toRem(15), + fontWeight: config.fontWeight.W500, + fontFamily: 'inherit', + cursor: 'pointer', + padding: 0, +}); + +export const SelectChevron = style({ + display: 'flex', + opacity: 0.5, +}); + +// A calm "feature unavailable here" notice (web / desktop / ancient WebView). +export const NoticeCard = style({ + display: 'flex', + gap: config.space.S300, + alignItems: 'flex-start', + padding: config.space.S400, + borderRadius: toRem(16), + backgroundColor: color.SurfaceVariant.Container, + color: color.SurfaceVariant.OnContainer, +}); + +export const InfoIcon = style({ + flexShrink: 0, + color: color.SurfaceVariant.OnContainer, + opacity: 0.7, + marginTop: toRem(1), +}); diff --git a/src/app/hooks/useProxy.ts b/src/app/hooks/useProxy.ts new file mode 100644 index 00000000..e5a4b604 --- /dev/null +++ b/src/app/hooks/useProxy.ts @@ -0,0 +1,171 @@ +import { useAtom } from 'jotai'; +import { useCallback, useEffect } from 'react'; +import { proxy, type ProxyState, type ProxyType } from '../plugins/proxy'; +import { proxyStateAtom } from '../state/proxy'; + +// Derive the bare apex host the override should be scoped to from a homeserver +// base URL (`https://matrix.vojo.chat` → `matrix.vojo.chat`). Returns +// undefined for an empty / unparseable URL so the caller falls back to +// route-all (pre-login discovery). Kept here so both the settings page and the +// pre-login entry scope identically. +export const hostFromBaseUrl = (baseUrl?: string): string | undefined => { + if (!baseUrl) return undefined; + try { + return new URL(baseUrl).hostname || undefined; + } catch { + return undefined; + } +}; + +export type SaveProxyInput = { + type: ProxyType; + host: string; + port: number; + username?: string; + password?: string; +}; + +export type UseProxyResult = { + state: ProxyState; + refresh: () => Promise; + // Persist creds (does not enable). Returns the refreshed state. + saveConfig: (input: SaveProxyInput) => Promise; + // Apply / remove the override, scoping to `homeserverBaseUrl` when given. + setEnabled: ( + enabled: boolean, + homeserverBaseUrl?: string, + routeAll?: boolean + ) => Promise; + // Wipe creds + disable + tear down the relay. + clearConfig: () => Promise; + // Re-run the reachability + latency probe against the homeserver (the + // "Check"/refresh action in the status card). + probe: (homeserverBaseUrl: string) => Promise; +}; + +export function useProxy(): UseProxyResult { + const [state, setState] = useAtom(proxyStateAtom); + + const refresh = useCallback(async () => { + const next = await proxy.getState(); + setState((prev) => { + // Reachability ('ok'/'error') is owned by the JS probe — native can only + // ever report 'off' / 'connecting' / a latched WebView 'error'. On a + // getState hydrate, keep the probe's verdict while the proxy is enabled so + // a STALE native field can't regress it: neither native 'connecting' + // dropping a confirmed 'ok' (F3), nor a latched native 'error' re-sticking + // after a recheck already healed it to 'ok'. Only 'off' (disabled) wins, + // since that's authoritative. Live failures still arrive via the listener. + const keepProbeVerdict = next.enabled && (prev.status === 'ok' || prev.status === 'error'); + return { + ...next, + // latencyMs / checkedAt are client-only probe results — getState + // (native) never reports them. Preserve across a refresh (e.g. another + // consumer like DirectSelfRow mounting) instead of wiping the ping the + // user just measured (F3); drop only when disabled. + latencyMs: next.enabled ? prev.latencyMs : undefined, + checkedAt: next.enabled ? prev.checkedAt : undefined, + status: keepProbeVerdict ? prev.status : next.status, + }; + }); + }, [setState]); + + // Hydrate once on mount, then keep `status` live via the native listener + // (dead-proxy detection fires a 'proxyStatus' event from the WebView error + // callbacks — see ProxyManager.onWebViewError). + useEffect(() => { + let active = true; + refresh(); + const handlePromise = proxy.addListener((event) => { + if (!active) return; + setState((prev) => { + // Native re-emits 'connecting' on every (re)apply; once the JS probe + // has a verdict, a late 'connecting' event must not regress it (F4). + // 'error' (dead-proxy detected) and 'off' (disabled) are authoritative + // and always win. + if (event.status === 'connecting' && (prev.status === 'ok' || prev.status === 'error')) { + return prev; + } + return { ...prev, status: event.status }; + }); + }); + return () => { + active = false; + handlePromise.then((h) => h.remove()).catch(() => undefined); + }; + }, [refresh, setState]); + + const saveConfig = useCallback( + async (input: SaveProxyInput) => { + await proxy.setConfig(input); + const next = await proxy.getState(); + setState(next); + return next; + }, + [setState] + ); + + // Confirm reachability through the (now-applied) proxy AND measure latency by + // fetching `/_matrix/client/versions` (a tiny unauthenticated CS-API endpoint) + // through the WebView, which rides the override. This is the authoritative + // "the proxy actually reaches the homeserver" + "ping" signal (F5 dead-proxy + // detection) — the native 'connecting'/'error' callbacks never flip to 'ok'. + const probe = useCallback( + async (homeserverBaseUrl: string) => { + setState((prev) => ({ ...prev, status: 'connecting' })); + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), 8000); + const t0 = performance.now(); + try { + const res = await fetch(`${homeserverBaseUrl.replace(/\/$/, '')}/_matrix/client/versions`, { + signal: controller.signal, + cache: 'no-store', + }); + const latencyMs = Math.round(performance.now() - t0); + setState((prev) => ({ + ...prev, + status: res.ok ? 'ok' : 'error', + latencyMs: res.ok ? latencyMs : undefined, + checkedAt: Date.now(), + })); + } catch { + setState((prev) => ({ + ...prev, + status: 'error', + latencyMs: undefined, + checkedAt: Date.now(), + })); + } finally { + clearTimeout(timer); + } + }, + [setState] + ); + + const setEnabled = useCallback( + async (enabled: boolean, homeserverBaseUrl?: string, routeAll?: boolean) => { + const next = await proxy.setEnabled({ + enabled, + homeserverHost: hostFromBaseUrl(homeserverBaseUrl), + routeAll, + }); + setState(next); + // Skipped pre-login (no base URL → route-all); there the login flow is + // the real signal. + if (enabled && next.enabled && homeserverBaseUrl) { + await probe(homeserverBaseUrl); + } + return next; + }, + [setState, probe] + ); + + const clearConfig = useCallback(async () => { + await proxy.clearConfig(); + const next = await proxy.getState(); + setState(next); + return next; + }, [setState]); + + return { state, refresh, saveConfig, setEnabled, clearConfig, probe }; +} diff --git a/src/app/pages/auth/AuthFooter.tsx b/src/app/pages/auth/AuthFooter.tsx index dafa31c8..d1b4f0ac 100644 --- a/src/app/pages/auth/AuthFooter.tsx +++ b/src/app/pages/auth/AuthFooter.tsx @@ -1,5 +1,6 @@ import React, { Ref } from 'react'; import * as css from './styles.css'; +import { ProxySettingsEntry } from './ProxySettingsEntry'; interface AuthFooterProps { footerRef?: Ref; @@ -9,6 +10,7 @@ export function AuthFooter({ footerRef }: AuthFooterProps) { return (
Powered by Matrix +
); } diff --git a/src/app/pages/auth/ProxySettingsEntry.tsx b/src/app/pages/auth/ProxySettingsEntry.tsx new file mode 100644 index 00000000..7df89458 --- /dev/null +++ b/src/app/pages/auth/ProxySettingsEntry.tsx @@ -0,0 +1,88 @@ +import React, { useState } from 'react'; +import { + Box, + Header, + Icon, + IconButton, + Icons, + Overlay, + OverlayBackdrop, + OverlayCenter, + Text, + color, + config, +} from 'folds'; +import FocusTrap from 'focus-trap-react'; +import { useTranslation } from 'react-i18next'; +import { isAndroidPlatform } from '../../utils/capacitor'; +import { stopPropagation } from '../../utils/keyboard'; +import { ProxyForm } from '../../features/settings/network'; +import * as css from './styles.css'; + +/** + * Pre-login proxy entry (proxy_android_impl.md §2, F1). Because creds are + * wiped on logout, a blocked re-login must be able to configure a proxy + * BEFORE authenticating — this is the headline censorship case. Rendered as + * a self-contained overlay so it never touches the bistable auth-layout CSS + * (bugs.md). Android-only; renders nothing elsewhere. + * + * No homeserver base URL is known yet pre-login, so the override routes all + * traffic during discovery; the authed boot path narrows it to the resolved + * homeserver host once logged in (applyAtBoot in index boot). + */ +export function ProxySettingsEntry() { + const { t } = useTranslation(); + const [open, setOpen] = useState(false); + + if (!isAndroidPlatform()) return null; + + return ( + <> + + }> + + setOpen(false), + clickOutsideDeactivates: true, + escapeDeactivates: stopPropagation, + }} + > + +
+ + {t('Settings.network.pre_login_title')} + + setOpen(false)}> + + +
+ + + +
+
+
+
+ + ); +} diff --git a/src/app/pages/auth/styles.css.ts b/src/app/pages/auth/styles.css.ts index b537b264..860e4c51 100644 --- a/src/app/pages/auth/styles.css.ts +++ b/src/app/pages/auth/styles.css.ts @@ -277,8 +277,10 @@ export const AuthBottomLink = style({ /* ── Footer ── */ export const AuthFooter = style({ display: 'flex', + flexDirection: 'column', alignItems: 'center', justifyContent: 'center', + gap: '6px', width: '100%', padding: '20px 24px 10px', fontSize: '14px', @@ -286,6 +288,24 @@ export const AuthFooter = style({ letterSpacing: '0.02em', }); +/* Pre-login proxy entry — a subtle text button under "Powered by Matrix". + Android-only (gated in ProxySettingsEntry); never affects the bistable + layout since AuthLayout measures footer.offsetHeight dynamically. */ +export const AuthProxyEntry = style({ + appearance: 'none', + border: 'none', + background: 'none', + cursor: 'pointer', + fontSize: '13px', + color: 'rgba(232, 228, 223, 0.55)', + textDecoration: 'underline', + textUnderlineOffset: '2px', + padding: '2px 6px', + selectors: { + '&:hover': { color: '#ffffff' }, + }, +}); + /* ── Theme overrides for folds components inside auth card ── */ /* Extract CSS custom property name from folds color token reference */ /* e.g. "var(--oq6d070)" → "--oq6d070" — tied to folds API, not hashed names */ diff --git a/src/app/pages/client/ClientRoot.tsx b/src/app/pages/client/ClientRoot.tsx index 9b106796..d4e449e7 100644 --- a/src/app/pages/client/ClientRoot.tsx +++ b/src/app/pages/client/ClientRoot.tsx @@ -18,6 +18,8 @@ import { AutoDiscovery } from './AutoDiscovery'; import { AuthSplashScreen } from '../auth/AuthSplashScreen'; import { clearSessionBridge, writeSessionBridge } from '../../utils/sessionBridge'; import { polling } from '../../plugins/polling'; +import { proxy } from '../../plugins/proxy'; +import { useProxy } from '../../hooks/useProxy'; function ClientRootLoading() { return ; @@ -30,6 +32,9 @@ const useLogoutListener = (mx?: MatrixClient) => { // access_token lingers in shared_prefs and CallDeclineReceiver spends // the next login cycle posting 401s until writeSessionBridge overwrites. await clearSessionBridge(); + // Wipe the Tink-encrypted proxy creds + clear the override + tear down + // the relay on a server-driven logout too (proxy_support.md §15, locked). + await proxy.clearConfig(); // Same applies to the WorkManager polling fallback: it has its own // SharedPreferences-stored access_token that the React lifecycle // cleanup wouldn't get a chance to clear before window.location.reload. @@ -77,6 +82,11 @@ export function ClientRoot({ children }: ClientRootProps) { ); useLogoutListener(mx); + // The proxy override + status atom (Android-only; no-op on web). Used by the + // post-login narrow effect below to scope a pre-login route-all override AND + // to run the reachability probe so the status stops reading "Connecting…" + // once we have a homeserver to measure against (F1/F18). + const { setEnabled: setProxyEnabled, probe: proxyProbe } = useProxy(); // Mark the user as Online during foreground and Unavailable while the // tab is hidden / app is backgrounded, so background long-poll /sync // calls stop fraudulently bumping last_active_ago on the homeserver. @@ -105,6 +115,39 @@ export function ClientRoot({ children }: ClientRootProps) { writeSessionBridge(mx); }, [mx]); + // If an Android proxy was configured pre-login (route-all during discovery), + // narrow it to the now-resolved homeserver host so external URL-preview / + // widget origins stop riding the user's proxy (proxy_android_impl.md §2). + // Idempotent + no-op when the proxy is off or on web. + useEffect(() => { + if (!mx) return; + (async () => { + try { + const state = await proxy.getState(); + if (!state.enabled) return; + const host = new URL(mx.baseUrl).hostname; + if (state.scopeHost === host) { + // Already scoped to this exact host — the common logged-in boot path, + // where applyAtBoot ran with the same host. Don't re-apply: apply() + // tears down and restarts the relay, dropping the /sync tunnel that + // just started and rotating the relay token on every boot (F6). But + // DO probe — applyAtBoot was native-only, so the status is still + // 'connecting'; this flips it to ok/error + measures the ping (F1). + await proxyProbe(mx.baseUrl); + return; + } + // Coming from a pre-login route-all override (scopeHost null or another + // host) — narrow it to the resolved homeserver host so external + // URL-preview / widget origins stop riding the user's proxy. setEnabled + // (the hook) also probes once applied, so the status becomes honest the + // moment a homeserver exists to measure against (F18). + await setProxyEnabled(true, mx.baseUrl); + } catch { + // ignore — the boot-time apply already covers the logged-in path. + } + })(); + }, [mx, setProxyEnabled, proxyProbe]); + // When the OS reports the network is back, prod the sync loop instead of // waiting for matrix-js-sdk's internal keep-alive jitter (5–10s backoff // per `sync.js`). Only acts when the SDK is genuinely paused on a failed diff --git a/src/app/pages/client/direct/DirectSelfRow.tsx b/src/app/pages/client/direct/DirectSelfRow.tsx index 89e32b80..05ca6f96 100644 --- a/src/app/pages/client/direct/DirectSelfRow.tsx +++ b/src/app/pages/client/direct/DirectSelfRow.tsx @@ -11,6 +11,7 @@ import { getMxIdLocalPart, mxcUrlToHttp } from '../../../utils/matrix'; import { nameInitials } from '../../../utils/common'; import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize'; import { useOpenSettingsSheet } from '../../../state/hooks/settingsSheet'; +import { useProxy } from '../../../hooks/useProxy'; import { SETTINGS_SHEET_DRAG_ORIGIN_ATTR } from '../../../features/settings/MobileSettingsHorseshoe'; import { SETTINGS_FROM_IN_APP_STATE } from '../../../features/settings/SettingsScreen'; import { getSettingsPath } from '../../pathUtils'; @@ -27,6 +28,9 @@ export function DirectSelfRow() { const openSheet = useOpenSettingsSheet(); const userId = mx.getSafeUserId(); const profile = useUserProfile(userId); + // Live "proxy is on" indicator on the Settings & profile row (Android only; + // off on web). Hydrates + subscribes the proxy atom from the main screen. + const { state: proxyState } = useProxy(); // Mobile: open the atom-driven bottom-up sheet so the DM list stays // visible above with a void gap (the )|( horseshoe). The @@ -120,6 +124,14 @@ export function DirectSelfRow() { {t('Direct.self_row_preview')} + {proxyState.enabled && ( + + )} ; + // Wipe creds + disable the override + tear down the relay. + clearConfig(): Promise; + // Apply or remove the override. `homeserverHost` scopes the reverse-bypass + // (only that host is proxied); omit it pre-login to route everything during + // discovery. `routeAll` forces full coverage even when a host is known. + setEnabled(opts: { + enabled: boolean; + homeserverHost?: string; + routeAll?: boolean; + }): Promise; + // Full state for hydrating the UI. + getState(): Promise; + // Re-apply the stored (enabled) override at app boot, before login. + applyAtBoot(opts: { homeserverHost?: string }): Promise; + addListener( + eventName: 'proxyStatus', + listenerFunc: (event: ProxyStatusEvent) => void + ): Promise; +} + +const DEFAULT_STATE: ProxyState = { + configured: false, + enabled: false, + status: 'off', + supported: false, + reverseBypassSupported: false, +}; + +const noopPlugin: ProxyPluginIface = { + setConfig: async () => undefined, + clearConfig: async () => undefined, + setEnabled: async () => DEFAULT_STATE, + getState: async () => DEFAULT_STATE, + applyAtBoot: async () => DEFAULT_STATE, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + addListener: async (_e, _f) => ({ remove: async () => undefined }), +}; + +const plugin = registerPlugin('Proxy', { + web: noopPlugin, +}); + +const guard = async (fn: () => Promise, fallback: T): Promise => { + if (!isAndroidPlatform()) return fallback; + try { + return await fn(); + } catch (err) { + // Old APK installed before the plugin shipped, or a transient bridge + // error. Swallow — the proxy is an opt-in connectivity aid, not a hard + // boot dependency. The UI shows the fallback state (off / unsupported). + // eslint-disable-next-line no-console + console.warn('[proxy] native call failed:', err); + return fallback; + } +}; + +export const proxy = { + // setConfig is the ONE call that must NOT swallow failures: it persists the + // user's creds, and a silent reject would let the form report success, wipe + // the typed password, and leave nothing stored (F11). Let it reject so the + // form can show an error and keep the password. No-op (resolve) off Android. + setConfig: (opts: { + type: ProxyType; + host: string; + port: number; + username?: string; + password?: string; + }): Promise => { + if (!isAndroidPlatform()) return Promise.resolve(); + return plugin.setConfig(opts); + }, + clearConfig: () => guard(() => plugin.clearConfig(), undefined), + setEnabled: (opts: { enabled: boolean; homeserverHost?: string; routeAll?: boolean }) => + guard(() => plugin.setEnabled(opts), DEFAULT_STATE), + getState: () => guard(() => plugin.getState(), DEFAULT_STATE), + applyAtBoot: (homeserverHost?: string) => + guard(() => plugin.applyAtBoot({ homeserverHost }), DEFAULT_STATE), + addListener: (listener: (event: ProxyStatusEvent) => void): Promise => { + if (!isAndroidPlatform()) { + return Promise.resolve({ remove: async () => undefined }); + } + return plugin.addListener('proxyStatus', listener); + }, +}; diff --git a/src/app/state/proxy.ts b/src/app/state/proxy.ts new file mode 100644 index 00000000..246718c4 --- /dev/null +++ b/src/app/state/proxy.ts @@ -0,0 +1,14 @@ +import { atom } from 'jotai'; +import type { ProxyState } from '../plugins/proxy'; + +// Non-secret UI mirror of the native proxy state. The password is NEVER +// stored here (or anywhere in JS / localStorage) — only the native +// Tink-encrypted store holds it. This atom is in-memory only and is +// re-hydrated from the native plugin on mount (see hooks/useProxy.ts). +export const proxyStateAtom = atom({ + configured: false, + enabled: false, + status: 'off', + supported: false, + reverseBypassSupported: false, +}); diff --git a/src/client/initMatrix.ts b/src/client/initMatrix.ts index a620f41b..8c7db9dd 100644 --- a/src/client/initMatrix.ts +++ b/src/client/initMatrix.ts @@ -7,6 +7,7 @@ import { clearPusherIds, loadPusherIds, setPushEnabled, unregisterPusher } from import { isNativePlatform } from '../app/utils/capacitor'; import { clearSessionBridge } from '../app/utils/sessionBridge'; import { polling } from '../app/plugins/polling'; +import { proxy } from '../app/plugins/proxy'; type Session = { baseUrl: string; @@ -112,6 +113,12 @@ export const logoutClient = async (mx: MatrixClient) => { await polling.cancel(); await polling.clearSession(); + // Wipe the Tink-encrypted proxy creds + clear the WebView override + tear + // down the relay (proxy_support.md §15: creds are wiped on logout, locked). + // A blocked re-login re-enters the proxy on the auth screen (pre-login + // entry, F1). No-op on web. + await proxy.clearConfig(); + // Wipe the native session bridge so a re-login with a different user // can't resurrect the old access_token via CallDeclineReceiver. await clearSessionBridge(); @@ -166,6 +173,9 @@ export const clearLoginData = async () => { // will time out the session naturally. export const clearLocalSessionAndReload = async () => { await clearSessionBridge(); + // Wipe the Tink-encrypted proxy creds + clear the override + tear down the + // relay on this local-only logout too (proxy_support.md §15, locked). + await proxy.clearConfig(); // Same reasoning as the normal logoutClient: kill the WorkManager // polling fallback before the reload so it can't fire one more time // with the old access_token and surface notifications from the diff --git a/src/index.tsx b/src/index.tsx index 2953c149..6df25753 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -21,6 +21,7 @@ import { pushSessionToSW } from './sw-session'; import { getFallbackSession } from './app/state/sessions'; import { setupExternalLinkHandler } from './app/utils/capacitor'; import { setupExternalLinkHandler as setupElectronExternalLinkHandler } from './app/utils/electron'; +import { proxy } from './app/plugins/proxy'; document.body.classList.add(configClass, varsClass); setupExternalLinkHandler(); @@ -100,4 +101,26 @@ const mountApp = () => { root.render(); }; -mountApp(); +// Apply a stored (enabled) Android proxy override BEFORE React boots, so the +// very first homeserver fetch (auto-discovery / login / sync) already rides +// the proxy on a blocked network (proxy_android_impl.md §2, F1). On a +// logged-in boot we narrow the override to the homeserver host immediately; +// pre-login it routes everything until the host is resolved. No-op on web / +// Electron, and time-bounded so a misbehaving bridge can never hang boot. +const applyProxyAtBoot = async (): Promise => { + let host: string | undefined; + try { + const session = getFallbackSession(); + host = session?.baseUrl ? new URL(session.baseUrl).hostname : undefined; + } catch { + host = undefined; + } + await Promise.race([ + proxy.applyAtBoot(host), + new Promise((resolve) => { + setTimeout(resolve, 2500); + }), + ]).catch(() => undefined); +}; + +applyProxyAtBoot().finally(mountApp);