feat(proxy): route Android WebView and native traffic through a user-supplied HTTP/SOCKS5 proxy via an authed loopback relay
This commit is contained in:
parent
f41ea049cc
commit
717928154b
33 changed files with 2905 additions and 11 deletions
|
|
@ -1,2 +1,3 @@
|
||||||
npx tsc -p tsconfig.json --noEmit
|
npx tsc -p tsconfig.json --noEmit
|
||||||
|
node scripts/check-no-tls-weakening.mjs
|
||||||
npx lint-staged
|
npx lint-staged
|
||||||
|
|
|
||||||
|
|
@ -92,6 +92,16 @@ dependencies {
|
||||||
// (mtalk.google.com:5228) is blocked. Library self-registers its scheduler
|
// (mtalk.google.com:5228) is blocked. Library self-registers its scheduler
|
||||||
// in the merged manifest; we declare no permission for it.
|
// in the merged manifest; we declare no permission for it.
|
||||||
implementation "androidx.work:work-runtime:2.10.0"
|
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"
|
testImplementation "junit:junit:$junitVersion"
|
||||||
androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"
|
androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"
|
||||||
androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion"
|
androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion"
|
||||||
|
|
|
||||||
|
|
@ -151,7 +151,7 @@ final class AvatarLoader {
|
||||||
final String capturedToken = token;
|
final String capturedToken = token;
|
||||||
EXECUTOR.execute(() -> {
|
EXECUTOR.execute(() -> {
|
||||||
try {
|
try {
|
||||||
Bitmap bmp = fetchAndDecode(capturedMxc, capturedHomeserver, capturedToken);
|
Bitmap bmp = fetchAndDecode(ctx, capturedMxc, capturedHomeserver, capturedToken);
|
||||||
if (bmp != null) AvatarBitmapCache.put(capturedMxc, bmp);
|
if (bmp != null) AvatarBitmapCache.put(capturedMxc, bmp);
|
||||||
} catch (Throwable t) {
|
} catch (Throwable t) {
|
||||||
Log.w(TAG, "fetch threw mxc=" + capturedMxc, 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
|
* Bitmap. Returns null on any non-2xx, decode failure, or oversized
|
||||||
* payload (see {@link #MAX_DECODED_BYTES}).
|
* 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 {
|
throws IOException {
|
||||||
Parsed parsed = parseMxc(mxc);
|
Parsed parsed = parseMxc(mxc);
|
||||||
if (parsed == null) {
|
if (parsed == null) {
|
||||||
|
|
@ -224,7 +224,8 @@ final class AvatarLoader {
|
||||||
.append("&height=").append(AVATAR_SIZE_PX)
|
.append("&height=").append(AVATAR_SIZE_PX)
|
||||||
.append("&method=crop");
|
.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 {
|
try {
|
||||||
conn.setRequestMethod("GET");
|
conn.setRequestMethod("GET");
|
||||||
conn.setRequestProperty("Authorization", "Bearer " + token);
|
conn.setRequestProperty("Authorization", "Bearer " + token);
|
||||||
|
|
|
||||||
|
|
@ -159,7 +159,7 @@ public class CallDeclineReceiver extends BroadcastReceiver {
|
||||||
final String txnId = UUID.randomUUID().toString();
|
final String txnId = UUID.randomUUID().toString();
|
||||||
EXECUTOR.execute(() -> {
|
EXECUTOR.execute(() -> {
|
||||||
try {
|
try {
|
||||||
int status = sendDecline(baseUrl, accessToken, roomId, notifEventId, txnId);
|
int status = sendDecline(context, baseUrl, accessToken, roomId, notifEventId, txnId);
|
||||||
if (status >= 200 && status < 300) {
|
if (status >= 200 && status < 300) {
|
||||||
prefs.edit().remove(PENDING_DECLINES_PREFIX + notifEventId).apply();
|
prefs.edit().remove(PENDING_DECLINES_PREFIX + notifEventId).apply();
|
||||||
Log.d(TAG, "decline PUT ok status=" + status + " room=" + roomId);
|
Log.d(TAG, "decline PUT ok status=" + status + " room=" + roomId);
|
||||||
|
|
@ -175,6 +175,7 @@ public class CallDeclineReceiver extends BroadcastReceiver {
|
||||||
}
|
}
|
||||||
|
|
||||||
private int sendDecline(
|
private int sendDecline(
|
||||||
|
Context context,
|
||||||
String baseUrl,
|
String baseUrl,
|
||||||
String accessToken,
|
String accessToken,
|
||||||
String roomId,
|
String roomId,
|
||||||
|
|
@ -196,7 +197,9 @@ public class CallDeclineReceiver extends BroadcastReceiver {
|
||||||
|
|
||||||
HttpURLConnection conn = null;
|
HttpURLConnection conn = null;
|
||||||
try {
|
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.setRequestMethod("PUT");
|
||||||
conn.setConnectTimeout(CONNECT_TIMEOUT_MS);
|
conn.setConnectTimeout(CONNECT_TIMEOUT_MS);
|
||||||
conn.setReadTimeout(READ_TIMEOUT_MS);
|
conn.setReadTimeout(READ_TIMEOUT_MS);
|
||||||
|
|
|
||||||
493
android/app/src/main/java/chat/vojo/app/LocalProxyRelay.java
Normal file
493
android/app/src/main/java/chat/vojo/app/LocalProxyRelay.java
Normal file
|
|
@ -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<Socket> 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -71,6 +71,7 @@ public class MainActivity extends BridgeActivity {
|
||||||
registerPlugin(LaunchSplashPlugin.class);
|
registerPlugin(LaunchSplashPlugin.class);
|
||||||
registerPlugin(ShareTargetPlugin.class);
|
registerPlugin(ShareTargetPlugin.class);
|
||||||
registerPlugin(PollingPlugin.class);
|
registerPlugin(PollingPlugin.class);
|
||||||
|
registerPlugin(ProxyPlugin.class);
|
||||||
|
|
||||||
// AndroidX SplashScreen must be installed before super.onCreate().
|
// AndroidX SplashScreen must be installed before super.onCreate().
|
||||||
// Keep it until the web splash confirms its first visible frame is
|
// Keep it until the web splash confirms its first visible frame is
|
||||||
|
|
|
||||||
|
|
@ -92,7 +92,7 @@ public class MarkAsReadReceiver extends BroadcastReceiver {
|
||||||
final PendingResult pendingResult = goAsync();
|
final PendingResult pendingResult = goAsync();
|
||||||
EXECUTOR.execute(() -> {
|
EXECUTOR.execute(() -> {
|
||||||
try {
|
try {
|
||||||
int status = sendReceipt(homeserver, token, roomId, eventId);
|
int status = sendReceipt(appContext, homeserver, token, roomId, eventId);
|
||||||
if (status >= 200 && status < 300) {
|
if (status >= 200 && status < 300) {
|
||||||
if (BuildConfig.DEBUG) {
|
if (BuildConfig.DEBUG) {
|
||||||
Log.d(TAG, "receipt ok status=" + status + " room=" + roomId);
|
Log.d(TAG, "receipt ok status=" + status + " room=" + roomId);
|
||||||
|
|
@ -109,6 +109,7 @@ public class MarkAsReadReceiver extends BroadcastReceiver {
|
||||||
}
|
}
|
||||||
|
|
||||||
private int sendReceipt(
|
private int sendReceipt(
|
||||||
|
Context context,
|
||||||
String baseUrl,
|
String baseUrl,
|
||||||
String accessToken,
|
String accessToken,
|
||||||
String roomId,
|
String roomId,
|
||||||
|
|
@ -120,7 +121,8 @@ public class MarkAsReadReceiver extends BroadcastReceiver {
|
||||||
+ "/receipt/m.read/"
|
+ "/receipt/m.read/"
|
||||||
+ URLEncoder.encode(eventId, "UTF-8");
|
+ 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 {
|
try {
|
||||||
conn.setRequestMethod("POST");
|
conn.setRequestMethod("POST");
|
||||||
conn.setRequestProperty("Authorization", "Bearer " + accessToken);
|
conn.setRequestProperty("Authorization", "Bearer " + accessToken);
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
176
android/app/src/main/java/chat/vojo/app/ProxyConfigStore.java
Normal file
176
android/app/src/main/java/chat/vojo/app/ProxyConfigStore.java
Normal file
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
375
android/app/src/main/java/chat/vojo/app/ProxyManager.java
Normal file
375
android/app/src/main/java/chat/vojo/app/ProxyManager.java
Normal file
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
173
android/app/src/main/java/chat/vojo/app/ProxyPlugin.java
Normal file
173
android/app/src/main/java/chat/vojo/app/ProxyPlugin.java
Normal file
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
130
android/app/src/main/java/chat/vojo/app/ProxyRouting.java
Normal file
130
android/app/src/main/java/chat/vojo/app/ProxyRouting.java
Normal file
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -130,7 +130,7 @@ public class ReplyReceiver extends BroadcastReceiver {
|
||||||
final String txnId = "vojo-reply-" + UUID.randomUUID();
|
final String txnId = "vojo-reply-" + UUID.randomUUID();
|
||||||
EXECUTOR.execute(() -> {
|
EXECUTOR.execute(() -> {
|
||||||
try {
|
try {
|
||||||
int status = sendReply(homeserver, token, roomId, txnId, text);
|
int status = sendReply(appContext, homeserver, token, roomId, txnId, text);
|
||||||
if (status >= 200 && status < 300) {
|
if (status >= 200 && status < 300) {
|
||||||
if (BuildConfig.DEBUG) {
|
if (BuildConfig.DEBUG) {
|
||||||
Log.d(TAG, "reply ok status=" + status + " room=" + roomId);
|
Log.d(TAG, "reply ok status=" + status + " room=" + roomId);
|
||||||
|
|
@ -149,6 +149,7 @@ public class ReplyReceiver extends BroadcastReceiver {
|
||||||
}
|
}
|
||||||
|
|
||||||
private int sendReply(
|
private int sendReply(
|
||||||
|
Context context,
|
||||||
String baseUrl,
|
String baseUrl,
|
||||||
String accessToken,
|
String accessToken,
|
||||||
String roomId,
|
String roomId,
|
||||||
|
|
@ -173,7 +174,8 @@ public class ReplyReceiver extends BroadcastReceiver {
|
||||||
}
|
}
|
||||||
byte[] payload = body.toString().getBytes("UTF-8");
|
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 {
|
try {
|
||||||
conn.setRequestMethod("PUT");
|
conn.setRequestMethod("PUT");
|
||||||
conn.setRequestProperty("Authorization", "Bearer " + accessToken);
|
conn.setRequestProperty("Authorization", "Bearer " + accessToken);
|
||||||
|
|
|
||||||
|
|
@ -377,6 +377,14 @@ public class VojoPollWorker extends Worker {
|
||||||
.remove(KEY_ACCESS_TOKEN)
|
.remove(KEY_ACCESS_TOKEN)
|
||||||
.apply();
|
.apply();
|
||||||
return Result.success();
|
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) {
|
} catch (ForbiddenException e) {
|
||||||
// 403 from Synapse is usually rate-limit or a transient server
|
// 403 from Synapse is usually rate-limit or a transient server
|
||||||
// policy reject, not a dead token. Don't clear credentials —
|
// 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"));
|
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 {
|
try {
|
||||||
conn.setRequestMethod("GET");
|
conn.setRequestMethod("GET");
|
||||||
conn.setRequestProperty("Authorization", "Bearer " + token);
|
conn.setRequestProperty("Authorization", "Bearer " + token);
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@
|
||||||
"check:prettier": "prettier --check .",
|
"check:prettier": "prettier --check .",
|
||||||
"fix:prettier": "prettier --write .",
|
"fix:prettier": "prettier --write .",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
|
"check:tls-guard": "node scripts/check-no-tls-weakening.mjs",
|
||||||
"gen:push-strings": "node scripts/gen-push-strings.mjs",
|
"gen:push-strings": "node scripts/gen-push-strings.mjs",
|
||||||
"android:sync": "npx cap sync android",
|
"android:sync": "npx cap sync android",
|
||||||
"android:open": "npx cap open android",
|
"android:open": "npx cap open android",
|
||||||
|
|
|
||||||
|
|
@ -113,6 +113,48 @@
|
||||||
"menu_devices": "Devices",
|
"menu_devices": "Devices",
|
||||||
"menu_emojis_stickers": "Emojis & Stickers",
|
"menu_emojis_stickers": "Emojis & Stickers",
|
||||||
"menu_about": "About",
|
"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",
|
"drag_to_close": "Drag down to close",
|
||||||
"close": "Close",
|
"close": "Close",
|
||||||
"logout": "Logout",
|
"logout": "Logout",
|
||||||
|
|
|
||||||
|
|
@ -113,6 +113,48 @@
|
||||||
"menu_devices": "Устройства",
|
"menu_devices": "Устройства",
|
||||||
"menu_emojis_stickers": "Эмодзи и стикеры",
|
"menu_emojis_stickers": "Эмодзи и стикеры",
|
||||||
"menu_about": "О приложении",
|
"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": "Потянуть вниз чтобы закрыть",
|
"drag_to_close": "Потянуть вниз чтобы закрыть",
|
||||||
"close": "Закрыть",
|
"close": "Закрыть",
|
||||||
"logout": "Выйти",
|
"logout": "Выйти",
|
||||||
|
|
|
||||||
64
scripts/check-no-tls-weakening.mjs
Normal file
64
scripts/check-no-tls-weakening.mjs
Normal file
|
|
@ -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');
|
||||||
|
|
@ -24,6 +24,7 @@ import { Notifications } from './notifications';
|
||||||
import { Devices } from './devices';
|
import { Devices } from './devices';
|
||||||
import { EmojisStickers } from './emojis-stickers';
|
import { EmojisStickers } from './emojis-stickers';
|
||||||
import { About } from './about';
|
import { About } from './about';
|
||||||
|
import { Network } from './network';
|
||||||
import { UseStateProvider } from '../../components/UseStateProvider';
|
import { UseStateProvider } from '../../components/UseStateProvider';
|
||||||
import { stopPropagation } from '../../utils/keyboard';
|
import { stopPropagation } from '../../utils/keyboard';
|
||||||
import { LogoutDialog } from '../../components/LogoutDialog';
|
import { LogoutDialog } from '../../components/LogoutDialog';
|
||||||
|
|
@ -32,6 +33,7 @@ export enum SettingsPages {
|
||||||
GeneralPage,
|
GeneralPage,
|
||||||
AccountPage,
|
AccountPage,
|
||||||
NotificationPage,
|
NotificationPage,
|
||||||
|
NetworkPage,
|
||||||
DevicesPage,
|
DevicesPage,
|
||||||
EmojisStickersPage,
|
EmojisStickersPage,
|
||||||
AboutPage,
|
AboutPage,
|
||||||
|
|
@ -52,6 +54,7 @@ export const SETTINGS_PAGE_PARAM = {
|
||||||
general: SettingsPages.GeneralPage,
|
general: SettingsPages.GeneralPage,
|
||||||
account: SettingsPages.AccountPage,
|
account: SettingsPages.AccountPage,
|
||||||
notifications: SettingsPages.NotificationPage,
|
notifications: SettingsPages.NotificationPage,
|
||||||
|
network: SettingsPages.NetworkPage,
|
||||||
devices: SettingsPages.DevicesPage,
|
devices: SettingsPages.DevicesPage,
|
||||||
emojis: SettingsPages.EmojisStickersPage,
|
emojis: SettingsPages.EmojisStickersPage,
|
||||||
about: SettingsPages.AboutPage,
|
about: SettingsPages.AboutPage,
|
||||||
|
|
@ -78,6 +81,7 @@ const SETTINGS_MENU_ITEMS: SettingsMenuItem[] = [
|
||||||
nameKey: 'Settings.menu_notifications',
|
nameKey: 'Settings.menu_notifications',
|
||||||
icon: Icons.Bell,
|
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.DevicesPage, nameKey: 'Settings.menu_devices', icon: Icons.Monitor },
|
||||||
{
|
{
|
||||||
page: SettingsPages.EmojisStickersPage,
|
page: SettingsPages.EmojisStickersPage,
|
||||||
|
|
@ -285,6 +289,9 @@ export function Settings({ initialPage, requestClose }: SettingsProps) {
|
||||||
{activePage === SettingsPages.NotificationPage && (
|
{activePage === SettingsPages.NotificationPage && (
|
||||||
<Notifications requestClose={handlePageRequestClose} />
|
<Notifications requestClose={handlePageRequestClose} />
|
||||||
)}
|
)}
|
||||||
|
{activePage === SettingsPages.NetworkPage && (
|
||||||
|
<Network requestClose={handlePageRequestClose} />
|
||||||
|
)}
|
||||||
{activePage === SettingsPages.DevicesPage && (
|
{activePage === SettingsPages.DevicesPage && (
|
||||||
<Devices requestClose={handlePageRequestClose} />
|
<Devices requestClose={handlePageRequestClose} />
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
19
src/app/features/settings/network/Network.tsx
Normal file
19
src/app/features/settings/network/Network.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<SettingsPage title={t('Settings.network.title')} requestClose={requestClose}>
|
||||||
|
<ProxyForm homeserverBaseUrl={mx.baseUrl} />
|
||||||
|
</SettingsPage>
|
||||||
|
);
|
||||||
|
}
|
||||||
527
src/app/features/settings/network/ProxyForm.tsx
Normal file
527
src/app/features/settings/network/ProxyForm.tsx
Normal file
|
|
@ -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<ProxyType>('socks5');
|
||||||
|
const [typeMenu, setTypeMenu] = useState<RectCords>();
|
||||||
|
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<string>();
|
||||||
|
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<boolean> => {
|
||||||
|
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<HTMLFormElement> = 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<HTMLInputElement>) =>
|
||||||
|
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 (
|
||||||
|
<Box direction="Column" gap="400">
|
||||||
|
{/* Hero: icon + title + live status + the big toggle. */}
|
||||||
|
<div className={css.HeroCard}>
|
||||||
|
<div className={css.HeroIcon}>
|
||||||
|
<Icon src={Icons.Server} size="300" />
|
||||||
|
</div>
|
||||||
|
<div className={css.HeroText}>
|
||||||
|
<Text size="T400" truncate>
|
||||||
|
{t('Settings.network.enabled_label')}
|
||||||
|
</Text>
|
||||||
|
<span className={css.StatusLine}>
|
||||||
|
<span className={`${css.StatusDot} ${css.statusDotColor[displayStatus]}`} />
|
||||||
|
<Text size="T200" priority="300" truncate>
|
||||||
|
{statusLabel}
|
||||||
|
</Text>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{busy && <Spinner size="100" variant="Secondary" />}
|
||||||
|
<Switch
|
||||||
|
variant="Primary"
|
||||||
|
value={state.enabled}
|
||||||
|
onChange={handleToggle}
|
||||||
|
disabled={!state.supported || busy}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!state.supported ? (
|
||||||
|
<div className={css.NoticeCard}>
|
||||||
|
<span className={css.InfoIcon}>
|
||||||
|
<Icon size="300" src={Icons.Info} />
|
||||||
|
</span>
|
||||||
|
<Text size="T200">
|
||||||
|
{isAndroidPlatform()
|
||||||
|
? t('Settings.network.unsupported')
|
||||||
|
: t('Settings.network.unavailable_platform')}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{state.enabled && (
|
||||||
|
<Box direction="Column" gap="200">
|
||||||
|
{!compact && (
|
||||||
|
<Text className={SectionLabel}>{t('Settings.network.section_status')}</Text>
|
||||||
|
)}
|
||||||
|
<div className={css.Panel}>
|
||||||
|
<div className={css.FieldRow}>
|
||||||
|
<span className={css.FieldLabel}>{t('Settings.network.ping_label')}</span>
|
||||||
|
<span className={css.MetaValue}>
|
||||||
|
{state.latencyMs != null ? fmtPing(state.latencyMs) : '—'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className={css.FieldRow}>
|
||||||
|
<span className={css.FieldLabel}>{t('Settings.network.uptime_label')}</span>
|
||||||
|
<span className={css.MetaValue}>
|
||||||
|
{state.enabledSince ? fmtUptime(state.enabledSince) : '—'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className={css.FieldRow}>
|
||||||
|
<span className={css.FieldLabel}>{t('Settings.network.checked_label')}</span>
|
||||||
|
<span className={css.MetaValue}>
|
||||||
|
{state.checkedAt ? fmtChecked(state.checkedAt) : '—'}
|
||||||
|
{homeserverBaseUrl && (
|
||||||
|
<IconButton
|
||||||
|
type="button"
|
||||||
|
size="300"
|
||||||
|
radii="300"
|
||||||
|
variant="SurfaceVariant"
|
||||||
|
onClick={handleRecheck}
|
||||||
|
disabled={busy}
|
||||||
|
aria-label={t('Settings.network.recheck')}
|
||||||
|
>
|
||||||
|
<Icon size="100" src={Icons.Reload} />
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className={css.FieldRow}>
|
||||||
|
<span className={css.FieldLabel}>{t('Settings.network.scope_label')}</span>
|
||||||
|
<span className={css.MetaValue}>
|
||||||
|
{state.scopeHost ?? t('Settings.network.scope_all')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{state.nativeBlockedAt ? (
|
||||||
|
<span className={css.WarnLine}>
|
||||||
|
<Icon size="100" src={Icons.Warning} style={{ flexShrink: 0 }} />
|
||||||
|
<Text size="T200">{t('Settings.network.native_blocked')}</Text>
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Server section: type + address + optional auth, one grouped panel. */}
|
||||||
|
<form onSubmit={handleSave}>
|
||||||
|
<Box direction="Column" gap="200">
|
||||||
|
{!compact && (
|
||||||
|
<Text className={SectionLabel}>{t('Settings.network.section_proxy')}</Text>
|
||||||
|
)}
|
||||||
|
<div className={css.Panel}>
|
||||||
|
<div className={css.FieldRow}>
|
||||||
|
<span className={css.FieldLabel}>{t('Settings.network.type_label')}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={css.SelectValue}
|
||||||
|
onClick={(e) => setTypeMenu(e.currentTarget.getBoundingClientRect())}
|
||||||
|
aria-haspopup="menu"
|
||||||
|
>
|
||||||
|
{typeLabel(type)}
|
||||||
|
<span className={css.SelectChevron}>
|
||||||
|
<Icon size="100" src={Icons.ChevronBottom} />
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<PopOut
|
||||||
|
anchor={typeMenu}
|
||||||
|
offset={5}
|
||||||
|
position="Bottom"
|
||||||
|
align="End"
|
||||||
|
content={
|
||||||
|
<FocusTrap
|
||||||
|
focusTrapOptions={{
|
||||||
|
initialFocus: false,
|
||||||
|
onDeactivate: () => setTypeMenu(undefined),
|
||||||
|
clickOutsideDeactivates: true,
|
||||||
|
escapeDeactivates: stopPropagation,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Menu>
|
||||||
|
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
|
||||||
|
{TYPE_OPTIONS.map((opt) => (
|
||||||
|
<MenuItem
|
||||||
|
key={opt}
|
||||||
|
size="300"
|
||||||
|
variant="Surface"
|
||||||
|
radii="300"
|
||||||
|
aria-selected={type === opt}
|
||||||
|
onClick={() => {
|
||||||
|
setType(opt);
|
||||||
|
setTypeMenu(undefined);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box grow="Yes" alignItems="Center" gap="200">
|
||||||
|
<Text style={{ flexGrow: 1 }} size="T300">
|
||||||
|
{typeLabel(opt)}
|
||||||
|
</Text>
|
||||||
|
{type === opt && <Icon size="100" src={Icons.Check} />}
|
||||||
|
</Box>
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
</Menu>
|
||||||
|
</FocusTrap>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={css.FieldRow}>
|
||||||
|
<label className={css.FieldLabel} htmlFor={`${fid}-host`}>
|
||||||
|
{t('Settings.network.host_label')}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id={`${fid}-host`}
|
||||||
|
className={css.FieldInput}
|
||||||
|
value={host}
|
||||||
|
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setHost(e.target.value)}
|
||||||
|
placeholder={t('Settings.network.host_placeholder')}
|
||||||
|
autoComplete="off"
|
||||||
|
autoCapitalize="off"
|
||||||
|
autoCorrect="off"
|
||||||
|
spellCheck={false}
|
||||||
|
inputMode="url"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={css.FieldRow}>
|
||||||
|
<label className={css.FieldLabel} htmlFor={`${fid}-port`}>
|
||||||
|
{t('Settings.network.port_label')}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id={`${fid}-port`}
|
||||||
|
className={css.FieldInput}
|
||||||
|
value={port}
|
||||||
|
onChange={onPortChange}
|
||||||
|
placeholder={t('Settings.network.port_placeholder')}
|
||||||
|
inputMode="numeric"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={css.FieldRow}>
|
||||||
|
<label className={css.FieldLabel} htmlFor={`${fid}-user`}>
|
||||||
|
{t('Settings.network.username_label_short')}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id={`${fid}-user`}
|
||||||
|
className={css.FieldInput}
|
||||||
|
value={username}
|
||||||
|
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||||
|
setUsername(e.target.value)
|
||||||
|
}
|
||||||
|
placeholder={t('Settings.network.optional')}
|
||||||
|
autoComplete="off"
|
||||||
|
autoCapitalize="off"
|
||||||
|
autoCorrect="off"
|
||||||
|
spellCheck={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={css.FieldRow}>
|
||||||
|
<label className={css.FieldLabel} htmlFor={`${fid}-pass`}>
|
||||||
|
{t('Settings.network.password_label_short')}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id={`${fid}-pass`}
|
||||||
|
className={css.FieldInput}
|
||||||
|
type={showPassword ? 'text' : 'password'}
|
||||||
|
value={password}
|
||||||
|
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setPassword(e.target.value);
|
||||||
|
setPasswordTouched(true);
|
||||||
|
}}
|
||||||
|
placeholder={
|
||||||
|
state.hasAuth
|
||||||
|
? t('Settings.network.password_placeholder_set')
|
||||||
|
: t('Settings.network.optional')
|
||||||
|
}
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={css.EyeButton}
|
||||||
|
onClick={() => setShowPassword((v) => !v)}
|
||||||
|
aria-label={t('Settings.network.toggle_password')}
|
||||||
|
>
|
||||||
|
<Icon size="100" src={showPassword ? Icons.Eye : Icons.EyeBlind} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Text size="T200" style={{ color: color.Critical.Main }}>
|
||||||
|
{error}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!state.reverseBypassSupported && (
|
||||||
|
<Text size="T200" style={{ color: color.Warning.Main }}>
|
||||||
|
{t('Settings.network.reverse_bypass_unsupported')}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="Primary"
|
||||||
|
size="500"
|
||||||
|
radii="400"
|
||||||
|
fill="Solid"
|
||||||
|
disabled={busy || !isDirty}
|
||||||
|
>
|
||||||
|
<Text size="B400">{t('Settings.network.save')}</Text>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{state.configured && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="Critical"
|
||||||
|
fill="None"
|
||||||
|
size="400"
|
||||||
|
radii="400"
|
||||||
|
onClick={handleRemove}
|
||||||
|
disabled={busy}
|
||||||
|
before={<Icon size="100" src={Icons.Delete} />}
|
||||||
|
>
|
||||||
|
<Text size="B400">{t('Settings.network.remove')}</Text>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</form>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
2
src/app/features/settings/network/index.ts
Normal file
2
src/app/features/settings/network/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
export * from './Network';
|
||||||
|
export * from './ProxyForm';
|
||||||
183
src/app/features/settings/network/network.css.ts
Normal file
183
src/app/features/settings/network/network.css.ts
Normal file
|
|
@ -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),
|
||||||
|
});
|
||||||
171
src/app/hooks/useProxy.ts
Normal file
171
src/app/hooks/useProxy.ts
Normal file
|
|
@ -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<void>;
|
||||||
|
// Persist creds (does not enable). Returns the refreshed state.
|
||||||
|
saveConfig: (input: SaveProxyInput) => Promise<ProxyState>;
|
||||||
|
// Apply / remove the override, scoping to `homeserverBaseUrl` when given.
|
||||||
|
setEnabled: (
|
||||||
|
enabled: boolean,
|
||||||
|
homeserverBaseUrl?: string,
|
||||||
|
routeAll?: boolean
|
||||||
|
) => Promise<ProxyState>;
|
||||||
|
// Wipe creds + disable + tear down the relay.
|
||||||
|
clearConfig: () => Promise<ProxyState>;
|
||||||
|
// Re-run the reachability + latency probe against the homeserver (the
|
||||||
|
// "Check"/refresh action in the status card).
|
||||||
|
probe: (homeserverBaseUrl: string) => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
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 };
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import React, { Ref } from 'react';
|
import React, { Ref } from 'react';
|
||||||
import * as css from './styles.css';
|
import * as css from './styles.css';
|
||||||
|
import { ProxySettingsEntry } from './ProxySettingsEntry';
|
||||||
|
|
||||||
interface AuthFooterProps {
|
interface AuthFooterProps {
|
||||||
footerRef?: Ref<HTMLElement>;
|
footerRef?: Ref<HTMLElement>;
|
||||||
|
|
@ -9,6 +10,7 @@ export function AuthFooter({ footerRef }: AuthFooterProps) {
|
||||||
return (
|
return (
|
||||||
<footer className={css.AuthFooter} role="contentinfo" ref={footerRef}>
|
<footer className={css.AuthFooter} role="contentinfo" ref={footerRef}>
|
||||||
<span>Powered by Matrix</span>
|
<span>Powered by Matrix</span>
|
||||||
|
<ProxySettingsEntry />
|
||||||
</footer>
|
</footer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
88
src/app/pages/auth/ProxySettingsEntry.tsx
Normal file
88
src/app/pages/auth/ProxySettingsEntry.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<>
|
||||||
|
<button type="button" className={css.AuthProxyEntry} onClick={() => setOpen(true)}>
|
||||||
|
{t('Settings.network.pre_login_entry')}
|
||||||
|
</button>
|
||||||
|
<Overlay open={open} backdrop={<OverlayBackdrop />}>
|
||||||
|
<OverlayCenter>
|
||||||
|
<FocusTrap
|
||||||
|
focusTrapOptions={{
|
||||||
|
initialFocus: false,
|
||||||
|
onDeactivate: () => setOpen(false),
|
||||||
|
clickOutsideDeactivates: true,
|
||||||
|
escapeDeactivates: stopPropagation,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
direction="Column"
|
||||||
|
style={{
|
||||||
|
maxWidth: '28rem',
|
||||||
|
width: '100%',
|
||||||
|
maxHeight: '85vh',
|
||||||
|
backgroundColor: color.SurfaceVariant.Container,
|
||||||
|
borderRadius: config.radii.R400,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Header
|
||||||
|
size="500"
|
||||||
|
style={{ paddingLeft: config.space.S400, paddingRight: config.space.S200 }}
|
||||||
|
>
|
||||||
|
<Box grow="Yes">
|
||||||
|
<Text size="H4">{t('Settings.network.pre_login_title')}</Text>
|
||||||
|
</Box>
|
||||||
|
<IconButton size="300" radii="300" onClick={() => setOpen(false)}>
|
||||||
|
<Icon src={Icons.Cross} />
|
||||||
|
</IconButton>
|
||||||
|
</Header>
|
||||||
|
<Box
|
||||||
|
grow="Yes"
|
||||||
|
direction="Column"
|
||||||
|
style={{ padding: config.space.S400, overflowY: 'auto' }}
|
||||||
|
>
|
||||||
|
<ProxyForm compact />
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</FocusTrap>
|
||||||
|
</OverlayCenter>
|
||||||
|
</Overlay>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -277,8 +277,10 @@ export const AuthBottomLink = style({
|
||||||
/* ── Footer ── */
|
/* ── Footer ── */
|
||||||
export const AuthFooter = style({
|
export const AuthFooter = style({
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
|
gap: '6px',
|
||||||
width: '100%',
|
width: '100%',
|
||||||
padding: '20px 24px 10px',
|
padding: '20px 24px 10px',
|
||||||
fontSize: '14px',
|
fontSize: '14px',
|
||||||
|
|
@ -286,6 +288,24 @@ export const AuthFooter = style({
|
||||||
letterSpacing: '0.02em',
|
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 ── */
|
/* ── Theme overrides for folds components inside auth card ── */
|
||||||
/* Extract CSS custom property name from folds color token reference */
|
/* Extract CSS custom property name from folds color token reference */
|
||||||
/* e.g. "var(--oq6d070)" → "--oq6d070" — tied to folds API, not hashed names */
|
/* e.g. "var(--oq6d070)" → "--oq6d070" — tied to folds API, not hashed names */
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,8 @@ import { AutoDiscovery } from './AutoDiscovery';
|
||||||
import { AuthSplashScreen } from '../auth/AuthSplashScreen';
|
import { AuthSplashScreen } from '../auth/AuthSplashScreen';
|
||||||
import { clearSessionBridge, writeSessionBridge } from '../../utils/sessionBridge';
|
import { clearSessionBridge, writeSessionBridge } from '../../utils/sessionBridge';
|
||||||
import { polling } from '../../plugins/polling';
|
import { polling } from '../../plugins/polling';
|
||||||
|
import { proxy } from '../../plugins/proxy';
|
||||||
|
import { useProxy } from '../../hooks/useProxy';
|
||||||
|
|
||||||
function ClientRootLoading() {
|
function ClientRootLoading() {
|
||||||
return <AuthSplashScreen />;
|
return <AuthSplashScreen />;
|
||||||
|
|
@ -30,6 +32,9 @@ const useLogoutListener = (mx?: MatrixClient) => {
|
||||||
// access_token lingers in shared_prefs and CallDeclineReceiver spends
|
// access_token lingers in shared_prefs and CallDeclineReceiver spends
|
||||||
// the next login cycle posting 401s until writeSessionBridge overwrites.
|
// the next login cycle posting 401s until writeSessionBridge overwrites.
|
||||||
await clearSessionBridge();
|
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
|
// Same applies to the WorkManager polling fallback: it has its own
|
||||||
// SharedPreferences-stored access_token that the React lifecycle
|
// SharedPreferences-stored access_token that the React lifecycle
|
||||||
// cleanup wouldn't get a chance to clear before window.location.reload.
|
// cleanup wouldn't get a chance to clear before window.location.reload.
|
||||||
|
|
@ -77,6 +82,11 @@ export function ClientRoot({ children }: ClientRootProps) {
|
||||||
);
|
);
|
||||||
|
|
||||||
useLogoutListener(mx);
|
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
|
// Mark the user as Online during foreground and Unavailable while the
|
||||||
// tab is hidden / app is backgrounded, so background long-poll /sync
|
// tab is hidden / app is backgrounded, so background long-poll /sync
|
||||||
// calls stop fraudulently bumping last_active_ago on the homeserver.
|
// calls stop fraudulently bumping last_active_ago on the homeserver.
|
||||||
|
|
@ -105,6 +115,39 @@ export function ClientRoot({ children }: ClientRootProps) {
|
||||||
writeSessionBridge(mx);
|
writeSessionBridge(mx);
|
||||||
}, [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
|
// 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
|
// 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
|
// per `sync.js`). Only acts when the SDK is genuinely paused on a failed
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import { getMxIdLocalPart, mxcUrlToHttp } from '../../../utils/matrix';
|
||||||
import { nameInitials } from '../../../utils/common';
|
import { nameInitials } from '../../../utils/common';
|
||||||
import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize';
|
import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize';
|
||||||
import { useOpenSettingsSheet } from '../../../state/hooks/settingsSheet';
|
import { useOpenSettingsSheet } from '../../../state/hooks/settingsSheet';
|
||||||
|
import { useProxy } from '../../../hooks/useProxy';
|
||||||
import { SETTINGS_SHEET_DRAG_ORIGIN_ATTR } from '../../../features/settings/MobileSettingsHorseshoe';
|
import { SETTINGS_SHEET_DRAG_ORIGIN_ATTR } from '../../../features/settings/MobileSettingsHorseshoe';
|
||||||
import { SETTINGS_FROM_IN_APP_STATE } from '../../../features/settings/SettingsScreen';
|
import { SETTINGS_FROM_IN_APP_STATE } from '../../../features/settings/SettingsScreen';
|
||||||
import { getSettingsPath } from '../../pathUtils';
|
import { getSettingsPath } from '../../pathUtils';
|
||||||
|
|
@ -27,6 +28,9 @@ export function DirectSelfRow() {
|
||||||
const openSheet = useOpenSettingsSheet();
|
const openSheet = useOpenSettingsSheet();
|
||||||
const userId = mx.getSafeUserId();
|
const userId = mx.getSafeUserId();
|
||||||
const profile = useUserProfile(userId);
|
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
|
// Mobile: open the atom-driven bottom-up sheet so the DM list stays
|
||||||
// visible above with a void gap (the )|( horseshoe). The
|
// visible above with a void gap (the )|( horseshoe). The
|
||||||
|
|
@ -120,6 +124,14 @@ export function DirectSelfRow() {
|
||||||
{t('Direct.self_row_preview')}
|
{t('Direct.self_row_preview')}
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
|
{proxyState.enabled && (
|
||||||
|
<Icon
|
||||||
|
src={Icons.Shield}
|
||||||
|
size="100"
|
||||||
|
filled
|
||||||
|
style={{ color: color.Success.Main, flexShrink: 0 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<Icon
|
<Icon
|
||||||
src={Icons.Setting}
|
src={Icons.Setting}
|
||||||
size="100"
|
size="100"
|
||||||
|
|
|
||||||
163
src/app/plugins/proxy.ts
Normal file
163
src/app/plugins/proxy.ts
Normal file
|
|
@ -0,0 +1,163 @@
|
||||||
|
// Bridge to the native ProxyPlugin (see
|
||||||
|
// android/app/src/main/java/chat/vojo/app/ProxyPlugin.java).
|
||||||
|
//
|
||||||
|
// "Bring your own proxy" on Android: the user enters their own HTTP /
|
||||||
|
// SOCKS5 proxy (host/port + optional user/pass) and Vojo routes its own
|
||||||
|
// WebView traffic through it via androidx.webkit ProxyController, so the
|
||||||
|
// homeserver is reachable on networks that block it. No VpnService, no
|
||||||
|
// Vojo-operated servers.
|
||||||
|
//
|
||||||
|
// Secrets (the proxy password) transit JS only at entry — they are written
|
||||||
|
// straight into a native Tink-encrypted store and NEVER read back, never
|
||||||
|
// persisted in localStorage / settingsAtom / any JS state. getState
|
||||||
|
// deliberately omits the password.
|
||||||
|
//
|
||||||
|
// Web / Electron have no analogue here (web cannot client-proxy at all; the
|
||||||
|
// Chromium net stack owns proxy selection). Every call is a no-op off
|
||||||
|
// Android — mirrors plugins/polling.ts.
|
||||||
|
|
||||||
|
import { registerPlugin, type PluginListenerHandle } from '@capacitor/core';
|
||||||
|
import { isAndroidPlatform } from '../utils/capacitor';
|
||||||
|
|
||||||
|
export type ProxyType = 'http' | 'socks5';
|
||||||
|
|
||||||
|
// Live status of the override. 'off' = no override applied; 'connecting' =
|
||||||
|
// applied but not yet confirmed reachable; 'ok' = a homeserver request went
|
||||||
|
// through; 'error' = the WebView reported a proxy/connection error on the
|
||||||
|
// homeserver origin (dead / wrong proxy — see ProxyManager.onWebViewError).
|
||||||
|
export type ProxyStatus = 'off' | 'connecting' | 'ok' | 'error';
|
||||||
|
|
||||||
|
// Non-secret view of the stored config. The password is intentionally
|
||||||
|
// absent — the native side never hands it back.
|
||||||
|
export type ProxyConfigView = {
|
||||||
|
configured: boolean;
|
||||||
|
type?: ProxyType;
|
||||||
|
host?: string;
|
||||||
|
port?: number;
|
||||||
|
hasAuth?: boolean;
|
||||||
|
username?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ProxyState = ProxyConfigView & {
|
||||||
|
enabled: boolean;
|
||||||
|
status: ProxyStatus;
|
||||||
|
// WebViewFeature.isFeatureSupported(PROXY_OVERRIDE) — false on ancient
|
||||||
|
// WebView APKs (< 72). When false the feature is unavailable and the UI
|
||||||
|
// must say so instead of silently doing nothing.
|
||||||
|
supported: boolean;
|
||||||
|
// PROXY_OVERRIDE_REVERSE_BYPASS — when false we cannot host-scope the
|
||||||
|
// override and fall back to routing all WebView traffic through the proxy.
|
||||||
|
reverseBypassSupported: boolean;
|
||||||
|
// Epoch-ms when the override was applied (0/undefined when off) — uptime base.
|
||||||
|
enabledSince?: number;
|
||||||
|
// The homeserver host the override is scoped to (undefined = route-all).
|
||||||
|
scopeHost?: string;
|
||||||
|
// Whether traffic rides the loopback relay (authed proxy) vs direct.
|
||||||
|
relayed?: boolean;
|
||||||
|
// Client-measured reachability ping (NOT from native): set by the JS probe
|
||||||
|
// that fetches `/_matrix/client/versions` through the proxy.
|
||||||
|
latencyMs?: number;
|
||||||
|
// Epoch-ms of the last reachability probe.
|
||||||
|
checkedAt?: number;
|
||||||
|
// Epoch-ms of the most recent background native send that was refused because
|
||||||
|
// the relay couldn't start (fail-closed). 0/undefined = none. Lets the UI
|
||||||
|
// warn that background traffic was blocked (proxy_support.md §15 #3, F17).
|
||||||
|
nativeBlockedAt?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ProxyStatusEvent = { status: ProxyStatus; detail?: string };
|
||||||
|
|
||||||
|
interface ProxyPluginIface {
|
||||||
|
// Persist creds (Tink). Does NOT enable the override. Password is consumed
|
||||||
|
// here and never returned.
|
||||||
|
setConfig(opts: {
|
||||||
|
type: ProxyType;
|
||||||
|
host: string;
|
||||||
|
port: number;
|
||||||
|
username?: string;
|
||||||
|
password?: string;
|
||||||
|
}): Promise<void>;
|
||||||
|
// Wipe creds + disable the override + tear down the relay.
|
||||||
|
clearConfig(): Promise<void>;
|
||||||
|
// 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<ProxyState>;
|
||||||
|
// Full state for hydrating the UI.
|
||||||
|
getState(): Promise<ProxyState>;
|
||||||
|
// Re-apply the stored (enabled) override at app boot, before login.
|
||||||
|
applyAtBoot(opts: { homeserverHost?: string }): Promise<ProxyState>;
|
||||||
|
addListener(
|
||||||
|
eventName: 'proxyStatus',
|
||||||
|
listenerFunc: (event: ProxyStatusEvent) => void
|
||||||
|
): Promise<PluginListenerHandle>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<ProxyPluginIface>('Proxy', {
|
||||||
|
web: noopPlugin,
|
||||||
|
});
|
||||||
|
|
||||||
|
const guard = async <T>(fn: () => Promise<T>, fallback: T): Promise<T> => {
|
||||||
|
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<void> => {
|
||||||
|
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<PluginListenerHandle> => {
|
||||||
|
if (!isAndroidPlatform()) {
|
||||||
|
return Promise.resolve({ remove: async () => undefined });
|
||||||
|
}
|
||||||
|
return plugin.addListener('proxyStatus', listener);
|
||||||
|
},
|
||||||
|
};
|
||||||
14
src/app/state/proxy.ts
Normal file
14
src/app/state/proxy.ts
Normal file
|
|
@ -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<ProxyState>({
|
||||||
|
configured: false,
|
||||||
|
enabled: false,
|
||||||
|
status: 'off',
|
||||||
|
supported: false,
|
||||||
|
reverseBypassSupported: false,
|
||||||
|
});
|
||||||
|
|
@ -7,6 +7,7 @@ import { clearPusherIds, loadPusherIds, setPushEnabled, unregisterPusher } from
|
||||||
import { isNativePlatform } from '../app/utils/capacitor';
|
import { isNativePlatform } from '../app/utils/capacitor';
|
||||||
import { clearSessionBridge } from '../app/utils/sessionBridge';
|
import { clearSessionBridge } from '../app/utils/sessionBridge';
|
||||||
import { polling } from '../app/plugins/polling';
|
import { polling } from '../app/plugins/polling';
|
||||||
|
import { proxy } from '../app/plugins/proxy';
|
||||||
|
|
||||||
type Session = {
|
type Session = {
|
||||||
baseUrl: string;
|
baseUrl: string;
|
||||||
|
|
@ -112,6 +113,12 @@ export const logoutClient = async (mx: MatrixClient) => {
|
||||||
await polling.cancel();
|
await polling.cancel();
|
||||||
await polling.clearSession();
|
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
|
// Wipe the native session bridge so a re-login with a different user
|
||||||
// can't resurrect the old access_token via CallDeclineReceiver.
|
// can't resurrect the old access_token via CallDeclineReceiver.
|
||||||
await clearSessionBridge();
|
await clearSessionBridge();
|
||||||
|
|
@ -166,6 +173,9 @@ export const clearLoginData = async () => {
|
||||||
// will time out the session naturally.
|
// will time out the session naturally.
|
||||||
export const clearLocalSessionAndReload = async () => {
|
export const clearLocalSessionAndReload = async () => {
|
||||||
await clearSessionBridge();
|
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
|
// Same reasoning as the normal logoutClient: kill the WorkManager
|
||||||
// polling fallback before the reload so it can't fire one more time
|
// polling fallback before the reload so it can't fire one more time
|
||||||
// with the old access_token and surface notifications from the
|
// with the old access_token and surface notifications from the
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ import { pushSessionToSW } from './sw-session';
|
||||||
import { getFallbackSession } from './app/state/sessions';
|
import { getFallbackSession } from './app/state/sessions';
|
||||||
import { setupExternalLinkHandler } from './app/utils/capacitor';
|
import { setupExternalLinkHandler } from './app/utils/capacitor';
|
||||||
import { setupExternalLinkHandler as setupElectronExternalLinkHandler } from './app/utils/electron';
|
import { setupExternalLinkHandler as setupElectronExternalLinkHandler } from './app/utils/electron';
|
||||||
|
import { proxy } from './app/plugins/proxy';
|
||||||
|
|
||||||
document.body.classList.add(configClass, varsClass);
|
document.body.classList.add(configClass, varsClass);
|
||||||
setupExternalLinkHandler();
|
setupExternalLinkHandler();
|
||||||
|
|
@ -100,4 +101,26 @@ const mountApp = () => {
|
||||||
root.render(<App />);
|
root.render(<App />);
|
||||||
};
|
};
|
||||||
|
|
||||||
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<void> => {
|
||||||
|
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<void>((resolve) => {
|
||||||
|
setTimeout(resolve, 2500);
|
||||||
|
}),
|
||||||
|
]).catch(() => undefined);
|
||||||
|
};
|
||||||
|
|
||||||
|
applyProxyAtBoot().finally(mountApp);
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue