android: 2.13.11-2.13.13 release snapshot (battery backoff, direct-flow cooldown, owner attribution, rule-set editor)

This commit is contained in:
Denozordec committed 2026-10-04 01:07:04 +07:00
1 parent 56f5a00417
commit 95d88709f9
28 files changed
+2283 -706

No files matched your search

+2 -2
View File
@@ -37,8 +37,8 @@ android {
minSdkVersion 21
targetSdkVersion 36
versionCode 147
versionName "2.13.10"
versionCode 150
versionName "2.13.13"
/* Base URL of the update storage, trailing slash included */
buildConfigField "String", "UPDATE_BASE_URL", "\"${updateBaseUrl}\""
@@ -127,6 +127,10 @@
android:name=".ui.ConnectivityCheckActivity"
android:label="@string/health_check_title">
</activity>
<activity
android:name=".ui.RouteCheckActivity"
android:label="@string/route_check_title">
</activity>
<activity
android:name=".ui.RemediationInstructionsActivity"
android:label="@string/remediation_instructions_title" >
@@ -77,6 +77,7 @@ import java.io.IOException;
import java.net.Inet4Address;
import java.net.Inet6Address;
import java.net.InetAddress;
import java.net.InetSocketAddress;
import java.net.UnknownHostException;
import java.nio.ByteBuffer;
import java.nio.channels.ClosedByInterruptException;
@@ -127,12 +128,22 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe
private static volatile List<InetAddress> sPrivateDnsServers = Collections.emptyList();
private static volatile String sPrivateDnsHost;
private static volatile long sPrivateDnsResolvedAt;
/* last-known-good resolve of the private DNS host: a transient resolve
* failure must not silently drop the DoT addresses from the force-tunnel
* set (they are stable for a given hostname) */
private static volatile List<InetAddress> sPrivateDnsLastGood;
private static volatile String sPrivateDnsLastGoodHost;
private static volatile long sPrivateDnsLastGoodAt;
private static final long PRIVATE_DNS_LAST_GOOD_TTL_MS = 24 * 60 * 60 * 1000;
private static volatile List<InetAddress> sTunnelDnsServers = Collections.emptyList();
/* whether the native engine currently holds an enabled classification
* set (skips pointless disable-pushes) */
private static volatile boolean sUserspacePushActive;
/** Number of user-defined force-tunnel prefixes in the last push (diagnostics). */
private static volatile int sUserForcePrefixCount;
/** Snapshot of the geo exclusion set currently applied to the TUN (route check UI). */
public static volatile List<IPRange> sAppliedGeoExclusionsV4 = Collections.emptyList();
public static volatile List<IPRange> sAppliedGeoExclusionsV6 = Collections.emptyList();
@@ -315,8 +326,23 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe
{
if (result.isEmpty())
{
DiagLog.i(TAG, "private DNS resolve timed out for " + host);
DiagLog.i(TAG, "private DNS resolve failed for " + host);
/* a transient failure must not empty the force-tunnel
* set: fall back to the last known good addresses */
long now = SystemClock.elapsedRealtime();
List<InetAddress> lastGood = sPrivateDnsLastGood;
if (lastGood != null && host.equals(sPrivateDnsLastGoodHost)
&& now - sPrivateDnsLastGoodAt < PRIVATE_DNS_LAST_GOOD_TTL_MS)
{
DiagLog.i(TAG, "private DNS: using cached addresses for " + host
+ " (age " + ((now - sPrivateDnsLastGoodAt) / 1000) + "s)");
return new ArrayList<>(lastGood);
}
return result;
}
sPrivateDnsLastGood = new ArrayList<>(result);
sPrivateDnsLastGoodHost = host;
sPrivateDnsLastGoodAt = SystemClock.elapsedRealtime();
DiagLog.i(TAG, "private DNS " + host + " -> " + result);
return result;
}
@@ -1034,15 +1060,35 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe
List<IPRange> v4 = enabled ? list.getExcludedV4() : Collections.emptyList();
List<IPRange> v6 = enabled ? list.getExcludedV6() : Collections.emptyList();
List<InetAddress> force = new ArrayList<>();
List<IPRange> force = new ArrayList<>();
if (enabled)
{
/* DoT servers of strict private DNS and the tunnel's own
* resolvers must never go direct, even when they are in RU
* space (One UI/strict private DNS, see 2.12.12) */
force.addAll(sPrivateDnsServers);
force.addAll(sTunnelDnsServers);
for (InetAddress addr : sPrivateDnsServers)
{
force.add(new IPRange(addr, addr));
}
for (InetAddress addr : sTunnelDnsServers)
{
force.add(new IPRange(addr, addr));
}
/* user-defined prefixes that must always go through the
* tunnel (e.g. a self-hosted proxy on a RU address) */
for (String cidr : org.strongswan.android.logic.routing.RoutingManager.getInstance(context).forceTunnelCidrs())
{
try
{
force.add(new IPRange(cidr));
}
catch (Exception ignored)
{ /* stored prefixes are validated; skip anything stale */
}
}
}
sUserForcePrefixCount = enabled
? force.size() - sPrivateDnsServers.size() - sTunnelDnsServers.size() : 0;
long[] v4From = new long[v4.size()];
long[] v4To = new long[v4.size()];
@@ -1058,26 +1104,32 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe
System.arraycopy(v6.get(i).getFrom().getAddress(), 0, v6From, i * 16, 16);
System.arraycopy(v6.get(i).getTo().getAddress(), 0, v6To, i * 16, 16);
}
ArrayList<Long> ftV4 = new ArrayList<>();
ByteArrayOutputStream ftV6 = new ByteArrayOutputStream();
for (InetAddress addr : force)
ArrayList<Long> forceV4FromList = new ArrayList<>();
ArrayList<Long> forceV4ToList = new ArrayList<>();
ByteArrayOutputStream forceV6From = new ByteArrayOutputStream();
ByteArrayOutputStream forceV6To = new ByteArrayOutputStream();
for (IPRange range : force)
{
if (addr instanceof Inet4Address)
if (range.getFrom() instanceof Inet4Address)
{
ftV4.add(addressToLong(addr));
forceV4FromList.add(addressToLong(range.getFrom()));
forceV4ToList.add(addressToLong(range.getTo()));
}
else if (addr instanceof Inet6Address)
else if (range.getFrom() instanceof Inet6Address)
{
ftV6.write(addr.getAddress(), 0, 16);
forceV6From.write(range.getFrom().getAddress(), 0, 16);
forceV6To.write(range.getTo().getAddress(), 0, 16);
}
}
long[] forceV4 = new long[ftV4.size()];
for (int i = 0; i < ftV4.size(); i++)
long[] forceV4From = new long[forceV4FromList.size()];
long[] forceV4To = new long[forceV4ToList.size()];
for (int i = 0; i < forceV4FromList.size(); i++)
{
forceV4[i] = ftV4.get(i);
forceV4From[i] = forceV4FromList.get(i);
forceV4To[i] = forceV4ToList.get(i);
}
setUserspaceRoutingData(enabled, v4From, v4To, v6From, v6To,
forceV4, ftV6.toByteArray());
forceV4From, forceV4To, forceV6From.toByteArray(), forceV6To.toByteArray());
sUserspacePushActive = enabled;
}
catch (Throwable e)
@@ -1119,9 +1171,15 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe
DiagLog.i(TAG, "route diag failed: " + e);
}
});
/* periodic while the connection is up */
mHandler.removeCallbacks(this);
mHandler.postDelayed(this, ROUTE_DIAG_INTERVAL_MS);
/* periodic re-runs only in extended diagnostics mode: the snapshot
* right after connecting covers field debugging, while a 5-minute
* cadence burns file I/O and battery for nothing in steady state */
SharedPreferences pref = PreferenceManager.getDefaultSharedPreferences(CharonVpnService.this);
if (pref.getBoolean(Constants.PREF_EXTENDED_DIAG, false))
{
mHandler.removeCallbacks(this);
mHandler.postDelayed(this, ROUTE_DIAG_INTERVAL_MS);
}
}
};
@@ -1137,7 +1195,7 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe
DiagLog.i(TAG, "route diag: rusplit=" + rusplit + ", mode=" + mode
+ ", applied=" + (rusplit ? mode + (isUserspaceRoutingEnabled(this) ? " (active)" : "")
+ ", ranges=" + sAppliedGeoExclusionsV4.size() + "v4/" + sAppliedGeoExclusionsV6.size() + "v6"
+ ", forceTunnel=" + (sPrivateDnsServers.size() + sTunnelDnsServers.size())
+ ", forceTunnel=" + (sPrivateDnsServers.size() + sTunnelDnsServers.size() + sUserForcePrefixCount)
: "none"));
long[] s = null;
@@ -1149,7 +1207,7 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe
{
DiagLog.i(TAG, "route diag: native engine unavailable (" + e.getClass().getSimpleName() + ")");
}
if (s == null || s.length < 19)
if (s == null || s.length < 20)
{
return;
}
@@ -1160,7 +1218,7 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe
DiagLog.i(TAG, "route diag: udp=" + s[4] + " pkts (" + s[12] + " flows), tcp=" + s[6] + " pkts ("
+ s[13] + " flows, " + s[14] + " accepts)");
DiagLog.i(TAG, "route diag: drops icmp/frag/malformed=" + s[8] + "/" + s[9] + "/" + s[10]
+ ", relayErrors=" + s[11]);
+ ", relayErrors=" + s[11] + ", cooldownHits=" + s[19]);
}
private void scheduleRouteDiagnostics()
@@ -1344,6 +1402,53 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe
return true;
}
/**
* Called via JNI when a direct-relay destination enters its failure
* cooldown: names the app that owns the connection being refused, so a
* retry storm against a dead server is attributable in diag.log. Best
* effort - on any failure nothing is logged.
*
* @param localAddr app-side source address of its socket
* @param localPort app-side source port
* @param remoteAddr destination address
* @param remotePort destination port
* @param dst preformatted "ip:port" for the log line
*/
public void logConnectionOwner(byte[] localAddr, int localPort,
byte[] remoteAddr, int remotePort, String dst)
{
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q)
{
return;
}
try
{
ConnectivityManager manager = (ConnectivityManager)getSystemService(Context.CONNECTIVITY_SERVICE);
if (manager == null)
{
return;
}
InetAddress localInet = InetAddress.getByAddress(localAddr);
InetAddress remoteInet = InetAddress.getByAddress(remoteAddr);
InetSocketAddress local = new InetSocketAddress(localInet, localPort);
InetSocketAddress remote = new InetSocketAddress(remoteInet, remotePort);
int protocol = localInet instanceof Inet6Address
? android.system.OsConstants.AF_INET6 : android.system.OsConstants.AF_INET;
int uid = manager.getConnectionOwnerUid(protocol, local, remote);
if (uid < 0)
{ /* the socket is already gone - nothing to attribute */
return;
}
String[] packages = getPackageManager().getPackagesForUid(uid);
String owner = packages != null && packages.length > 0 ? packages[0] : "uid " + uid;
DiagLog.i(TAG, "direct tcp: dst=" + dst + " owner=" + owner);
}
catch (Exception e)
{
DiagLog.i(TAG, "direct tcp: owner lookup failed for dst=" + dst + ": " + e);
}
}
/**
* Initialization of charon, provided by libandroidbridge.so
*
@@ -1384,12 +1489,15 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe
* @param v4To IPv4 range ends
* @param v6From IPv6 range starts (16 bytes per entry, concatenated)
* @param v6To IPv6 range ends
* @param forceV4 force-tunnel IPv4 addresses (DoT / tunnel DNS)
* @param forceV6 force-tunnel IPv6 addresses
* @param forceV4From force-tunnel IPv4 range starts (DoT / tunnel DNS / user prefixes)
* @param forceV4To force-tunnel IPv4 range ends
* @param forceV6From force-tunnel IPv6 range starts
* @param forceV6To force-tunnel IPv6 range ends
*/
public static native void setUserspaceRoutingData(boolean enabled, long[] v4From, long[] v4To,
byte[] v6From, byte[] v6To,
long[] forceV4, byte[] forceV6);
long[] forceV4From, long[] forceV4To,
byte[] forceV6From, byte[] forceV6To);
/**
* Snapshot of the userspace routing engine counters, provided by
@@ -28,9 +28,11 @@ import java.util.Locale
* watchdog decisions, retry scheduling, network changes, auto-connect and
* foreground-service start failures.
*
* Every line is flushed and closed on write so events survive a process kill
* at any point. The file rotates at [MAX_SIZE] (one previous generation is
* kept). Never throws - logging must not be able to break the caller.
* The writer is kept open across writes but flushed on every line, so
* events survive a process kill at any point while avoiding a per-line
* open/stat/close cycle. The file rotates at [MAX_SIZE] (one previous
* generation is kept), tracked by a written-byte counter instead of a stat
* per line. Never throws - logging must not be able to break the caller.
*/
object DiagLog
{
@@ -41,6 +43,9 @@ object DiagLog
@Volatile
private var dir: File? = null
private var writer: FileWriter? = null
private var written = 0L
private val format = SimpleDateFormat("MM-dd HH:mm:ss.SSS", Locale.US)
/** Must be called once at process start (from the Application class). */
@@ -57,17 +62,24 @@ object DiagLog
val base = dir ?: return
try
{
val line = "${format.format(Date())} $tag: $message\n"
synchronized(this)
{
val file = File(base, FILE_NAME)
if (file.length() > MAX_SIZE)
if (written > MAX_SIZE)
{
File(base, OLD_FILE_NAME).delete()
file.renameTo(File(base, OLD_FILE_NAME))
rotate(base)
}
FileWriter(file, true).use { writer ->
writer.write("${format.format(Date())} $tag: $message\n")
var w = writer
if (w == null)
{
val file = File(base, FILE_NAME)
written = file.length()
w = FileWriter(file, true)
writer = w
}
w.write(line)
w.flush()
written += line.length + 1
}
}
catch (_: Exception)
@@ -75,6 +87,30 @@ object DiagLog
}
}
/** Close the writer and move diag.log to diag.log.1; reopened lazily. */
private fun rotate(base: File)
{
writer?.let {
try
{
it.close()
}
catch (_: Exception)
{
}
}
writer = null
try
{
File(base, OLD_FILE_NAME).delete()
File(base, FILE_NAME).renameTo(File(base, OLD_FILE_NAME))
}
catch (_: Exception)
{
}
written = 0
}
/** The log file (may not exist yet). */
@JvmStatic
fun file(context: Context): File = File(context.getFilesDir(), FILE_NAME)
@@ -44,11 +44,14 @@ import org.strongswan.android.utils.Constants;
* never wakes the device; it fires only while the device is awake anyway
* (Doze defers it until then). A second WAKEUP safety-net alarm bounds the
* recovery time while the device sleeps (e.g. the process was evicted on a
* trusted Wi-Fi overnight). The checks themselves are a couple of memory
* and preference reads - no network probes, no wake locks, no periodic work
* while the connection is healthy. A crash is therefore repaired within
* seconds of the next time the device is used, and within minutes even if
* it never is.
* trusted Wi-Fi overnight). Once a state proves steady - the tunnel parked
* on a trusted network, or a healthy established connection - both alarms
* back off (5 min maintenance, 30 min/1 h safety net), because in those
* states a live process recovers via its own callbacks within seconds and
* the alarms only guard a dead one. The checks themselves are a couple of
* memory and preference reads - no network probes, no wake locks, no
* periodic work while the connection is healthy. A crash is therefore
* repaired within seconds of the next time the device is used.
*
* Trusted-networks auto sessions: while parked on a trusted Wi-Fi the VPN is
* expected to be DOWN (the auto session is "expected" in the sense that the
@@ -67,6 +70,31 @@ public class WatchdogReceiver extends BroadcastReceiver
/* wakeup safety net; allow-while-idle alarms are throttled to a 9-minute
* minimum interval in Doze, so 10 minutes is effectively exact enough */
private static final long WATCHDOG_WAKEUP_INTERVAL_MS = 600000;
/* after this many consecutive checks that find the tunnel parked on a
* trusted network, stretch the maintenance interval: while parked there
* is nothing to do (auto-connect owns the return via its callbacks) and
* the 60 s cadence only burns battery and floods diag.log overnight.
* The wakeup safety net is stretched to 30 minutes as well: a live
* process reacts to network changes within seconds, so it only guards
* the rare "process died, then the user left the trusted Wi-Fi" corner.
* Static on purpose: a parked tunnel implies a live process, so the
* counter survives; a dead process starts fresh with the short interval. */
private static final int PARKED_BACKOFF_AFTER = 5;
private static final long WATCHDOG_PARKED_INTERVAL_MS = 5 * 60000;
private static final long WATCHDOG_PARKED_WAKEUP_INTERVAL_MS = 30 * 60000;
/* steady state of an established connection: charon's own DPD/rekey and
* the in-process retry machinery own recovery, so the alarms only guard
* a dead process - repaired within seconds of the device being used.
* The wakeup alarm in Doze is stretched to an hour (from 10 minutes),
* cutting the overnight CPU wakeups while the tunnel is healthy. */
private static final int CONNECTED_BACKOFF_AFTER = 5;
private static final long WATCHDOG_CONNECTED_INTERVAL_MS = 5 * 60000;
private static final long WATCHDOG_CONNECTED_WAKEUP_INTERVAL_MS = 60 * 60000;
private static volatile int sParkedChecks;
private static volatile int sConnectedChecks;
/** cadence announced in diag.log last ("parked"/"connected"/null), so a
* steady state is logged once on entry instead of on every fire */
private static volatile String sLastCadence;
/** What onReceive decided to do. */
public enum Action
@@ -135,7 +163,7 @@ public class WatchdogReceiver extends BroadcastReceiver
* schedule() re-arms the alarms when that changes */
return;
}
schedule(context);
reschedule(context);
if (WhiteListManager.getInstance(context).shouldBlockReconnect())
{ /* the ISP whitelist blocks the server; the in-process retry
* machinery parks itself in that case, so should we */
@@ -149,9 +177,42 @@ public class WatchdogReceiver extends BroadcastReceiver
boolean autoProfileConfigured = AutoConnectManager.getInstance(context).getCurrentConfig().getProfileUuid() != null;
Action action = decideAction(enabled, uuid != null, autoExpected, false, serviceTracking,
processAlive, wifiPresent, autoProfileConfigured);
DiagLog.i(TAG, "check: uuid=" + (uuid != null) + " autoExpected=" + autoExpected +
" processAlive=" + processAlive + " tracking=" + serviceTracking +
" wifi=" + wifiPresent + " -> " + action);
boolean connected = serviceTracking && service.getState() == VpnStateService.State.CONNECTED;
boolean parked = uuid == null && autoExpected && processAlive && !serviceTracking && action == Action.NONE;
if (parked)
{
sParkedChecks++;
sConnectedChecks = 0;
}
else if (connected && action == Action.NONE)
{
sConnectedChecks++;
sParkedChecks = 0;
}
else
{ /* any other outcome is interesting - go back to the fast cadence */
sParkedChecks = 0;
sConnectedChecks = 0;
}
boolean parkedSteady = parked && sParkedChecks >= PARKED_BACKOFF_AFTER;
boolean connectedSteady = connected && sConnectedChecks >= CONNECTED_BACKOFF_AFTER;
String cadence = parkedSteady ? "parked" : connectedSteady ? "connected" : null;
if (cadence != null)
{ /* steady state: log the transition once, then stay silent - a
* per-fire line only floods diag.log and burns flash I/O */
if (!cadence.equals(sLastCadence))
{
DiagLog.i(TAG, "steady state (" + cadence + "), backing off checks to " +
(parkedSteady ? "5m/30m" : "5m/60m"));
}
}
else
{
DiagLog.i(TAG, "check: uuid=" + (uuid != null) + " autoExpected=" + autoExpected +
" processAlive=" + processAlive + " tracking=" + serviceTracking +
" wifi=" + wifiPresent + " -> " + action);
}
sLastCadence = cadence;
switch (action)
{
case NONE:
@@ -243,12 +304,27 @@ public class WatchdogReceiver extends BroadcastReceiver
}
/**
* Arm the next maintenance check. A one-shot non-wakeup alarm (fires only
* Arm the next maintenance check after a state transition (connect,
* park, settings toggle). Resets the steady-state counters so the
* checks return to the fast cadence until the new state proves itself
* stable again, then delegates to reschedule().
*/
public static void schedule(Context context)
{
sParkedChecks = 0;
sConnectedChecks = 0;
sLastCadence = null;
reschedule(context);
}
/**
* Arm the next maintenance check with the cadence the steady-state
* counters currently ask for. A one-shot non-wakeup alarm (fires only
* while the device is awake) plus a one-shot wakeup safety net that
* bounds the recovery time while the device sleeps. No-op when the
* feature is disabled or no connection is expected.
*/
public static void schedule(Context context)
private static void reschedule(Context context)
{
context = context.getApplicationContext();
SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context);
@@ -263,8 +339,14 @@ public class WatchdogReceiver extends BroadcastReceiver
{
return;
}
arm(am, pendingIntent(context, REQUEST_CODE), SystemClock.elapsedRealtime() + WATCHDOG_INTERVAL_MS, false);
arm(am, pendingIntent(context, WAKEUP_REQUEST_CODE), SystemClock.elapsedRealtime() + WATCHDOG_WAKEUP_INTERVAL_MS, true);
boolean parkedBackoff = sParkedChecks >= PARKED_BACKOFF_AFTER;
boolean connectedBackoff = sConnectedChecks >= CONNECTED_BACKOFF_AFTER;
long interval = parkedBackoff ? WATCHDOG_PARKED_INTERVAL_MS
: connectedBackoff ? WATCHDOG_CONNECTED_INTERVAL_MS : WATCHDOG_INTERVAL_MS;
long wakeupInterval = parkedBackoff ? WATCHDOG_PARKED_WAKEUP_INTERVAL_MS
: connectedBackoff ? WATCHDOG_CONNECTED_WAKEUP_INTERVAL_MS : WATCHDOG_WAKEUP_INTERVAL_MS;
arm(am, pendingIntent(context, REQUEST_CODE), SystemClock.elapsedRealtime() + interval, false);
arm(am, pendingIntent(context, WAKEUP_REQUEST_CODE), SystemClock.elapsedRealtime() + wakeupInterval, true);
}
/**
@@ -24,6 +24,7 @@ import android.os.Bundle
import android.os.Handler
import android.os.IBinder
import android.os.Looper
import android.os.SystemClock
import android.util.Log
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
@@ -84,6 +85,8 @@ class AutoConnectManager private constructor(
private var currentNetwork: NetworkKind? = null
/* marks the session this manager started itself */
private val autoSession = AtomicBoolean(false)
/* elapsedRealtime of the last park caused by a trusted Wi-Fi (flap guard) */
private var lastParkAt = 0L
private val monitor = NetworkMonitor(context, { config.trustedSsids }, ::onNetworkChanged)
private val lifecycleBound = AtomicBoolean(false)
@@ -261,6 +264,7 @@ class AutoConnectManager private constructor(
Log.i(TAG, "Trusted network detected, disconnecting")
DiagLog.i(TAG, "trusted network detected, parking the tunnel")
autoSession.set(false)
lastParkAt = SystemClock.elapsedRealtime()
/* mark the auto session as expected so the watchdog can restore
* it if the process dies while parked and the device later
* moves to cellular (auto-connect dies with the process) */
@@ -281,6 +285,19 @@ class AutoConnectManager private constructor(
{
return
}
/* Wi-Fi at the edge of range flaps with a ~10-15 s period: a park
* followed by a quick "untrusted" flip must not bounce the tunnel
* through full reconnects. Right after a park require a longer
* stable-cellular window before starting (the runnable re-evaluates
* and quietly gives up if the trusted Wi-Fi returned in between). */
val delay = if (SystemClock.elapsedRealtime() - lastParkAt < RECENT_PARK_WINDOW_MS)
{
FLAP_CONNECT_DELAY_MS
}
else
{
CONNECT_DELAY_MS
}
handler.postDelayed({
pendingEvaluate.set(false)
val svc = service
@@ -296,7 +313,7 @@ class AutoConnectManager private constructor(
startVpn(svc, uuid)
}
}
}, CONNECT_DELAY_MS)
}, delay)
}
private fun startVpn(svc: VpnStateService, uuid: String)
@@ -394,6 +411,10 @@ class AutoConnectManager private constructor(
private const val PREFS_NAME = "one.shx.strongswan.ext.AUTO_CONNECT"
private const val KEY_CONFIG = "config"
private const val CONNECT_DELAY_MS = 3000L
/* anti-flap: a start right after a trusted-Wi-Fi park waits longer
* (the network must stay cellular through the whole window) */
private const val FLAP_CONNECT_DELAY_MS = 10_000L
private const val RECENT_PARK_WINDOW_MS = 120_000L
/**
* Pure decision function, unit-testable. Trusted Wi-Fi parks only
@@ -74,12 +74,12 @@ class NetworkMonitor(
{
networks.remove(network)
ssids.remove(network)
scheduleEvaluate()
}
else
else if (remember(network, capabilities))
{
remember(network, capabilities)
scheduleEvaluate()
}
scheduleEvaluate()
}
}
@@ -190,12 +190,39 @@ class NetworkMonitor(
return null
}
private fun remember(network: Network, caps: NetworkCapabilities)
/**
* Store the latest state of a network. Returns whether anything the
* trust decision depends on changed: capability callbacks fire on every
* signal-strength/validation tick, and re-evaluating on those only burns
* battery - the debounced runnable wakes the main thread and rescans all
* networks for no result. Only a transport change or a new SSID counts.
*/
private fun remember(network: Network, caps: NetworkCapabilities): Boolean
{
val previous = networks[network]
val oldSsid = ssids[network]
val ssid = SsidReader.fromWifiInfo(caps.transportInfo as? WifiInfo)
networks[network] = caps
SsidReader.fromWifiInfo(caps.transportInfo as? WifiInfo)?.let { ssids[network] = it }
if (ssid != null)
{
ssids[network] = ssid
}
if (previous == null)
{
return true
}
if (!sameTransports(previous, caps))
{
return true
}
return ssid != null && ssid != oldSsid
}
private fun sameTransports(a: NetworkCapabilities, b: NetworkCapabilities): Boolean =
a.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) == b.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) &&
a.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) == b.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) &&
a.hasTransport(NetworkCapabilities.TRANSPORT_VPN) == b.hasTransport(NetworkCapabilities.TRANSPORT_VPN)
private fun ssidFor(network: Network, caps: NetworkCapabilities): String?
{
ssids[network]?.let { return it }
@@ -50,9 +50,18 @@ data class RuleSet(
val keywords: Set<String> = emptySet(),
)
/** A network prefix that must always go through the tunnel (userspace mode). */
data class ForceTunnelPrefix(
val id: String,
/** Validated CIDR or single address, e.g. "130.49.213.176/32". */
val prefix: String,
val enabled: Boolean = true,
)
data class RoutingConfig(
val enabled: Boolean = true,
val ruleSets: List<RuleSet> = emptyList(),
val forceTunnelPrefixes: List<ForceTunnelPrefix> = emptyList(),
)
data class RoutingState(
@@ -161,9 +170,12 @@ class RoutingManager private constructor(private val context: Context)
updateConfig { config -> config.copy(ruleSets = config.ruleSets + ruleSet) }
}
/** A rename never affects routing, so it must not schedule a reconnect. */
fun updateRuleSetName(id: String, name: String)
{
updateConfig { config -> config.copy(ruleSets = config.ruleSets.map { if (it.id == id) it.copy(name = name) else it }) }
config = config.copy(ruleSets = config.ruleSets.map { if (it.id == id) it.copy(name = name) else it })
saveConfig(config)
publish()
}
fun setRuleSetEnabled(id: String, enabled: Boolean)
@@ -224,6 +236,79 @@ class RoutingManager private constructor(private val context: Context)
}
}
/* --------------------- force-tunnel prefixes --------------------- */
/**
* Adds a force-tunnel prefix (CIDR or bare address). Returns null on an
* invalid prefix, otherwise the new entry's id.
*/
fun addForcePrefix(cidr: String): String?
{
val normalized = normalizePrefix(cidr) ?: return null
updateConfigNoReconnect { config ->
config.copy(forceTunnelPrefixes = config.forceTunnelPrefixes + ForceTunnelPrefix(
id = UUID.randomUUID().toString(), prefix = normalized))
}
return config.forceTunnelPrefixes.lastOrNull { it.prefix == normalized }?.id
}
/** Replaces the prefix of an existing entry; false if the new one is invalid. */
fun updateForcePrefix(id: String, cidr: String): Boolean
{
val normalized = normalizePrefix(cidr) ?: return false
updateConfigNoReconnect { config ->
config.copy(forceTunnelPrefixes = config.forceTunnelPrefixes.map {
if (it.id == id) it.copy(prefix = normalized) else it })
}
return true
}
fun deleteForcePrefix(id: String)
{
updateConfigNoReconnect { config ->
config.copy(forceTunnelPrefixes = config.forceTunnelPrefixes.filter { it.id != id })
}
}
fun setForcePrefixEnabled(id: String, enabled: Boolean)
{
updateConfigNoReconnect { config ->
config.copy(forceTunnelPrefixes = config.forceTunnelPrefixes.map {
if (it.id == id) it.copy(enabled = enabled) else it })
}
}
/**
* Enabled force-tunnel prefixes as validated CIDR strings (userspace
* routing feeds them into the native force set).
*/
fun forceTunnelCidrs(): List<String> =
config.forceTunnelPrefixes.filter { it.enabled }.map { it.prefix }
/**
* Like [updateConfig], but force prefixes apply on the fly through the
* atomic set swap of the userspace router - no reconnect needed.
*/
private fun updateConfigNoReconnect(transform: (RoutingConfig) -> RoutingConfig)
{
config = transform(config)
saveConfig(config)
publish()
pushUserspaceRouting()
}
private fun pushUserspaceRouting()
{
try
{
org.strongswan.android.logic.CharonVpnService.pushUserspaceRoutingData(context)
}
catch (e: Throwable)
{
Log.w(TAG, "Failed to push userspace routing data", e)
}
}
/* ------------------------- connect-time merge ------------------------- */
/**
@@ -315,6 +400,17 @@ class RoutingManager private constructor(private val context: Context)
}
}
} ?: emptyList(),
forceTunnelPrefixes = obj.optJSONArray("forceTunnelPrefixes")?.let { arr ->
(0 until arr.length()).mapNotNull { i ->
arr.optJSONObject(i)?.let { o ->
ForceTunnelPrefix(
id = o.optString("id"),
prefix = o.optString("prefix"),
enabled = o.optBoolean("enabled", true),
)
}
}
} ?: emptyList(),
)
}
catch (_: JSONException)
@@ -340,6 +436,16 @@ class RoutingManager private constructor(private val context: Context)
sets.put(o)
}
obj.put("ruleSets", sets)
val forcePrefixes = JSONArray()
for (entry in config.forceTunnelPrefixes)
{
val o = JSONObject()
o.put("id", entry.id)
o.put("prefix", entry.prefix)
o.put("enabled", entry.enabled)
forcePrefixes.put(o)
}
obj.put("forceTunnelPrefixes", forcePrefixes)
prefs.edit().putString(KEY_CONFIG, obj.toString()).apply()
}
@@ -353,6 +459,16 @@ class RoutingManager private constructor(private val context: Context)
private const val KEY_CONFIG = "config"
private const val RECONNECT_DEBOUNCE_MS = 2000L
/** Validates and canonicalizes a CIDR/address; null if invalid. */
fun normalizePrefix(cidr: String): String? = try
{
org.strongswan.android.utils.IPRange(cidr.trim()).toString()
}
catch (_: Exception)
{
null
}
/**
* All packages matched by the enabled rule sets: explicit packages,
* prefixes and keywords matched against the installed packages.
@@ -174,11 +174,15 @@ class UpdateManager private constructor(private val context: Context)
{
cleanup()
context.bindService(Intent(context, VpnStateService::class.java), mConnection, Context.BIND_AUTO_CREATE)
val request = PeriodicWorkRequestBuilder<UpdateCheckWorker>(15, TimeUnit.MINUTES)
.setConstraints(Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build())
val request = PeriodicWorkRequestBuilder<UpdateCheckWorker>(12, TimeUnit.HOURS)
.setConstraints(Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
/* background checks only run while charging - zero battery impact */
.setRequiresCharging(true)
.build())
.build()
WorkManager.getInstance(context).enqueueUniquePeriodicWork(
UpdateCheckWorker.WORK_NAME, ExistingPeriodicWorkPolicy.KEEP, request)
UpdateCheckWorker.WORK_NAME, ExistingPeriodicWorkPolicy.UPDATE, request)
mScope.launch {
while (true)
{
@@ -0,0 +1,47 @@
/*
* Copyright (C) 2026 SHX VPN
*
* This program is free software; you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the
* Free Software Foundation; either version 2 of the License, or (at your
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
*
* This program is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
* for more details.
*/
package org.strongswan.android.ui
import android.os.Bundle
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.padding
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import org.strongswan.android.R
import org.strongswan.android.ui.compose.RouteCheckScreen
import org.strongswan.android.ui.compose.SecondaryScaffold
import org.strongswan.android.ui.compose.theme.ShxTheme
/** Standalone route diagnostics screen, opened from the Routing tab. */
class RouteCheckActivity : AppCompatActivity()
{
override fun onCreate(savedInstanceState: Bundle?)
{
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
ShxTheme {
SecondaryScaffold(title = stringResource(R.string.route_check_title), onBack = { finish() }) { padding ->
Box(Modifier.padding(padding)) {
RouteCheckScreen()
}
}
}
}
}
}
@@ -0,0 +1,178 @@
/*
* Copyright (C) 2026 SHX VPN
*
* This program is free software; you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the
* Free Software Foundation; either version 2 of the License, or (at your
* option) any later version.
*/
package org.strongswan.android.ui.compose
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
import androidx.compose.material.icons.rounded.Search
import androidx.compose.material3.Checkbox
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import org.strongswan.android.R
import org.strongswan.android.logic.routing.RuleSet
import org.strongswan.android.logic.routing.RoutingPresets
/**
* Searchable list of the built-in presets shown inside the rule set editor
* sheet. Selection toggles immediately; "Done"/back returns to the editor.
*/
@Composable
fun PresetPickerContent(
ruleSet: RuleSet,
onToggle: (RoutingPresets.Preset) -> Unit,
onBack: () -> Unit,
)
{
var query by remember { mutableStateOf("") }
val filtered = remember(query)
{
val q = query.trim()
if (q.isEmpty())
{
RoutingPresets.RF_BUNDLE
}
else
{
RoutingPresets.RF_BUNDLE.filter { it.name.contains(q, ignoreCase = true) }
}
}
Column(Modifier.fillMaxWidth())
{
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(start = 12.dp, end = 12.dp, bottom = 4.dp),
)
{
IconButton(onClick = onBack)
{
Icon(
Icons.AutoMirrored.Rounded.ArrowBack,
contentDescription = stringResource(R.string.action_back),
)
}
Text(
text = stringResource(R.string.routing_presets_label),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.weight(1f),
)
TextButton(onClick = onBack)
{
Text(stringResource(R.string.action_done))
}
}
OutlinedTextField(
value = query,
onValueChange = { query = it },
label = { Text(stringResource(R.string.routing_presets_search)) },
singleLine = true,
leadingIcon = {
Icon(Icons.Rounded.Search, contentDescription = null)
},
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp),
shape = MaterialTheme.shapes.medium,
)
LazyColumn(
modifier = Modifier
.fillMaxWidth()
.heightIn(max = 460.dp)
.padding(top = 4.dp),
)
{
items(filtered, key = { it.name })
{ preset ->
val selected = ruleSet.prefixes.containsAll(preset.prefixes) &&
ruleSet.keywords.containsAll(preset.keywords)
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.clickable { onToggle(preset) }
.padding(horizontal = 24.dp, vertical = 6.dp),
)
{
Column(
Modifier
.weight(1f)
.padding(vertical = 6.dp),
)
{
Text(preset.name, style = MaterialTheme.typography.bodyLarge)
Text(
preset.patternSummary(),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Checkbox(checked = selected, onCheckedChange = { onToggle(preset) })
}
}
if (filtered.isEmpty())
{
item {
Text(
text = stringResource(R.string.routing_presets_none),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp, vertical = 16.dp),
)
}
}
}
}
}
/** "3 prefixes · 2 keywords" summary, skipping empty groups. */
@Composable
private fun RoutingPresets.Preset.patternSummary(): String
{
val parts = listOf(
if (prefixes.isEmpty()) null else stringResource(R.string.routing_preset_prefixes, prefixes.size),
if (keywords.isEmpty()) null else stringResource(R.string.routing_preset_keywords, keywords.size),
).filterNotNull()
return if (parts.isEmpty())
{
stringResource(R.string.routing_preset_no_patterns)
}
else
{
parts.joinToString(separator = " · ")
}
}
@@ -0,0 +1,160 @@
/*
* Copyright (C) 2026 SHX VPN
*
* This program is free software; you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the
* Free Software Foundation; either version 2 of the License, or (at your
* option) any later version.
*/
package org.strongswan.android.ui.compose
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch
import org.strongswan.android.R
import org.strongswan.android.logic.CharonVpnService
import org.strongswan.android.logic.routing.RouteCheck
import org.strongswan.android.ui.compose.theme.ScreenPadding
/** Diagnostic: how a domain resolves and which route its addresses take. */
@Composable
fun RouteCheckScreen()
{
val context = LocalContext.current
val scope = rememberCoroutineScope()
var domain by remember { mutableStateOf("") }
var checking by remember { mutableStateOf(false) }
var report by remember { mutableStateOf<RouteCheck.Report?>(null) }
Column(
Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(horizontal = ScreenPadding, vertical = 12.dp),
)
{
Text(
text = stringResource(R.string.route_check_hint),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
OutlinedTextField(
value = domain,
onValueChange = { domain = it },
label = { Text(stringResource(R.string.route_check_field)) },
singleLine = true,
modifier = Modifier
.fillMaxWidth()
.padding(top = 10.dp),
shape = MaterialTheme.shapes.medium,
)
Button(
onClick = {
checking = true
scope.launch {
report = RouteCheck.check(
context,
domain.trim(),
CharonVpnService.sAppliedGeoExclusionsV4,
CharonVpnService.sAppliedGeoExclusionsV6,
CharonVpnService.sDnsPunchIps,
)
checking = false
}
},
enabled = domain.isNotBlank() && !checking,
shape = MaterialTheme.shapes.medium,
modifier = Modifier.padding(top = 10.dp),
)
{
Text(stringResource(if (checking) R.string.route_check_checking else R.string.route_check_button))
}
report?.let { rep -> RouteCheckResult(rep) }
}
}
@Composable
private fun RouteCheckResult(report: RouteCheck.Report)
{
Column(modifier = Modifier.padding(top = 12.dp))
{
if (!report.vpnNetworkFound)
{
Text(
text = stringResource(R.string.route_check_no_vpn),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
)
}
RouteCheckRow(stringResource(R.string.route_check_via_vpn_dns), report.vpnDnsIps)
RouteCheckRow(stringResource(R.string.route_check_via_direct_dns), report.directDnsIps)
Text(
text = stringResource(R.string.route_check_routes),
style = MaterialTheme.typography.titleSmall,
modifier = Modifier.padding(top = 10.dp),
)
for (route in report.routes)
{
val label = stringResource(
when (route.kind)
{
RouteCheck.RouteKind.TUNNEL -> R.string.route_verdict_tunnel
RouteCheck.RouteKind.DIRECT_RU -> R.string.route_verdict_direct
RouteCheck.RouteKind.TUNNEL_DNS -> R.string.route_verdict_dns
},
)
Text(
text = "${route.ip} — $label",
style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace,
modifier = Modifier.padding(top = 2.dp),
)
}
if (report.routes.isEmpty())
{
Text(
text = stringResource(R.string.route_check_resolve_failed),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
)
}
}
Spacer(Modifier.height(8.dp))
}
@Composable
private fun RouteCheckRow(label: String, ips: List<String>)
{
Text(
text = label,
style = MaterialTheme.typography.titleSmall,
modifier = Modifier.padding(top = 8.dp),
)
Text(
text = ips.joinToString(", ").ifEmpty { stringResource(R.string.route_check_none) },
style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace,
)
}
@@ -0,0 +1,358 @@
/*
* Copyright (C) 2026 SHX VPN
*
* This program is free software; you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the
* Free Software Foundation; either version 2 of the License, or (at your
* option) any later version.
*/
package org.strongswan.android.ui.compose
import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.togetherWith
import androidx.compose.animation.core.tween
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowRight
import androidx.compose.material.icons.rounded.Add
import androidx.compose.material.icons.rounded.Apps
import androidx.compose.material.icons.rounded.Category
import androidx.compose.material.icons.rounded.Close
import androidx.compose.material.icons.rounded.Delete
import androidx.compose.material3.AssistChip
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.unit.dp
import org.strongswan.android.R
import org.strongswan.android.logic.routing.RuleSet
import org.strongswan.android.logic.routing.RoutingPresets
import org.strongswan.android.ui.compose.components.ShxIconBadge
/**
* Editor of one rule set as a modal bottom sheet. The preset picker is a
* second view inside the same sheet (back returns to the editor) so that two
* sheets never stack on top of each other.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun RuleSetEditorSheet(
ruleSet: RuleSet,
requestNameFocus: Boolean,
onDismiss: () -> Unit,
onRename: (String) -> Unit,
onPickApps: () -> Unit,
onTogglePreset: (RoutingPresets.Preset) -> Unit,
onAddPrefix: (String) -> Unit,
onRemovePrefix: (String) -> Unit,
onAddKeyword: (String) -> Unit,
onRemoveKeyword: (String) -> Unit,
onDelete: () -> Unit,
)
{
var showPresets by remember { mutableStateOf(false) }
BackHandler(enabled = showPresets) { showPresets = false }
ModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
containerColor = MaterialTheme.colorScheme.surfaceContainerLow,
)
{
AnimatedContent(
targetState = showPresets,
transitionSpec = { fadeIn(tween(180)) togetherWith fadeOut(tween(120)) },
label = "rule-set-editor",
)
{ presetsVisible ->
if (presetsVisible)
{
PresetPickerContent(
ruleSet = ruleSet,
onToggle = onTogglePreset,
onBack = { showPresets = false },
)
}
else
{
EditorContent(
ruleSet = ruleSet,
requestNameFocus = requestNameFocus,
onDismiss = onDismiss,
onRename = onRename,
onPickApps = onPickApps,
onOpenPresets = { showPresets = true },
onAddPrefix = onAddPrefix,
onRemovePrefix = onRemovePrefix,
onAddKeyword = onAddKeyword,
onRemoveKeyword = onRemoveKeyword,
onDelete = onDelete,
)
}
}
}
}
@Composable
private fun EditorContent(
ruleSet: RuleSet,
requestNameFocus: Boolean,
onDismiss: () -> Unit,
onRename: (String) -> Unit,
onPickApps: () -> Unit,
onOpenPresets: () -> Unit,
onAddPrefix: (String) -> Unit,
onRemovePrefix: (String) -> Unit,
onAddKeyword: (String) -> Unit,
onRemoveKeyword: (String) -> Unit,
onDelete: () -> Unit,
)
{
val nameFocusRequester = remember { FocusRequester() }
LaunchedEffect(ruleSet.id, requestNameFocus)
{
if (requestNameFocus)
{
nameFocusRequester.requestFocus()
}
}
Column(
Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState())
.padding(bottom = 24.dp),
)
{
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(start = 24.dp, end = 12.dp, bottom = 8.dp),
)
{
Text(
text = stringResource(R.string.routing_editor_title),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.weight(1f),
)
TextButton(onClick = onDismiss)
{
Text(stringResource(R.string.action_done))
}
}
OutlinedTextField(
value = ruleSet.name,
onValueChange = onRename,
label = { Text(stringResource(R.string.routing_rule_name)) },
singleLine = true,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp)
.focusRequester(nameFocusRequester),
shape = MaterialTheme.shapes.medium,
)
EditorRow(
icon = Icons.Rounded.Apps,
title = stringResource(R.string.routing_pick_apps),
subtitle = stringResource(R.string.routing_apps_count, ruleSet.packages.size),
onClick = onPickApps,
)
EditorRow(
icon = Icons.Rounded.Category,
title = stringResource(R.string.routing_presets_selected, countSelectedPresets(ruleSet)),
subtitle = stringResource(R.string.routing_presets_hint),
onClick = onOpenPresets,
)
ChipGroup(
label = stringResource(R.string.routing_prefixes_label),
values = ruleSet.prefixes.sorted(),
text = { it },
onRemove = onRemovePrefix,
addLabel = stringResource(R.string.routing_add_prefix),
onAdd = onAddPrefix,
)
ChipGroup(
label = stringResource(R.string.routing_keywords_label),
values = ruleSet.keywords.sorted(),
text = { "*$it*" },
onRemove = onRemoveKeyword,
addLabel = stringResource(R.string.routing_add_keyword),
onAdd = onAddKeyword,
)
Button(
onClick = onDelete,
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.errorContainer,
contentColor = MaterialTheme.colorScheme.onErrorContainer,
),
shape = MaterialTheme.shapes.medium,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp, vertical = 12.dp),
)
{
Icon(
Icons.Rounded.Delete,
contentDescription = null,
modifier = Modifier.size(18.dp),
)
Spacer(Modifier.width(8.dp))
Text(stringResource(R.string.routing_delete))
}
}
}
/** Labeled chip group with trailing add field, shared by prefixes and keywords. */
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun ChipGroup(
label: String,
values: List<String>,
text: (String) -> String,
onRemove: (String) -> Unit,
addLabel: String,
onAdd: (String) -> Unit,
)
{
Text(
text = label,
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 24.dp, top = 16.dp),
)
FlowRow(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp, vertical = 4.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
)
{
values.forEach { value ->
AssistChip(
onClick = { onRemove(value) },
label = { Text(text(value), maxLines = 1) },
trailingIcon = {
Icon(Icons.Rounded.Close, contentDescription = null, modifier = Modifier.size(16.dp))
},
)
}
}
AddChipField(label = addLabel, onAdd = onAdd)
}
/** Editor row: badge, title/subtitle, chevron. */
@Composable
private fun EditorRow(icon: ImageVector, title: String, subtitle: String, onClick: () -> Unit)
{
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(horizontal = 24.dp, vertical = 14.dp),
)
{
ShxIconBadge(icon)
Spacer(Modifier.width(16.dp))
Column(Modifier.weight(1f))
{
Text(title, style = MaterialTheme.typography.bodyLarge)
Text(
subtitle,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Icon(
Icons.AutoMirrored.Rounded.KeyboardArrowRight,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
/** Compact input with an add action; commits on the IME "done" or the trailing button. */
@Composable
private fun AddChipField(
label: String,
onAdd: (String) -> Unit,
)
{
var value by remember { mutableStateOf("") }
val commit = {
if (value.isNotBlank())
{
onAdd(value.trim())
value = ""
}
}
OutlinedTextField(
value = value,
onValueChange = { value = it },
label = { Text(label) },
singleLine = true,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
keyboardActions = KeyboardActions(onDone = { commit() }),
trailingIcon = {
IconButton(onClick = { commit() }, enabled = value.isNotBlank())
{
Icon(Icons.Rounded.Add, contentDescription = null)
}
},
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp, vertical = 4.dp),
shape = MaterialTheme.shapes.medium,
)
}
/** A preset counts as selected when all of its patterns are in the set. */
private fun countSelectedPresets(ruleSet: RuleSet): Int =
RoutingPresets.RF_BUNDLE.count { preset ->
ruleSet.prefixes.containsAll(preset.prefixes) && ruleSet.keywords.containsAll(preset.keywords)
}
@@ -29,6 +29,7 @@ import androidx.compose.material.icons.rounded.Autorenew
import androidx.compose.material.icons.rounded.BatteryChargingFull
import androidx.compose.material.icons.rounded.Bedtime
import androidx.compose.material.icons.rounded.Bolt
import androidx.compose.material.icons.rounded.BugReport
import androidx.compose.material.icons.rounded.Cached
import androidx.compose.material.icons.rounded.CloudDownload
import androidx.compose.material.icons.rounded.CloudSync
@@ -389,6 +390,20 @@ fun SettingsScreen(profiles: List<VpnProfile>, showTitle: Boolean = true)
context.startActivity(Intent(context, LogActivity::class.java))
},
)
ShxSectionDivider()
var extendedDiag by remember {
mutableStateOf(prefs.getBoolean(Constants.PREF_EXTENDED_DIAG, false))
}
ShxSettingToggleRow(
icon = Icons.Rounded.BugReport,
title = stringResource(R.string.extended_diag_title),
subtitle = stringResource(R.string.extended_diag_summary),
checked = extendedDiag,
onCheckedChange = {
extendedDiag = it
prefs.edit().putBoolean(Constants.PREF_EXTENDED_DIAG, it).apply()
},
)
}
ShxSection(title = stringResource(R.string.settings_section_updates))
@@ -91,10 +91,8 @@ public final class Constants
/**
* Minimum interval between automatic update checks.
* TODO: revert to 12 * 60 * 60 * 1000L (12h) once the silent update flow
* has been verified on a real device
*/
public static final long UPDATE_CHECK_INTERVAL_MS = 10 * 60 * 1000L;
public static final long UPDATE_CHECK_INTERVAL_MS = 12 * 60 * 60 * 1000L;
/**
* Preference key to store the timestamp of the last update check
@@ -135,6 +133,14 @@ public final class Constants
*/
public static final String PREF_WATCHDOG_AUTO_EXPECTED = "pref_watchdog_auto_expected";
/**
* Preference key to store whether extended diagnostics are enabled:
* periodic route-diag snapshots while connected and other verbose
* logging that costs battery and flash I/O. Off by default - the
* one-shot snapshot right after connecting is always written.
*/
public static final String PREF_EXTENDED_DIAG = "pref_extended_diag";
/**
* Preference key to store whether IPv6 should be blackholed when the
* tunnel provides no IPv6 configuration, instead of letting it bypass
@@ -914,10 +914,12 @@ static job_requeue_t initiate(private_android_service_t *this)
}
else
{ /* create ESP proposals with and without DH groups, let responder decide
* if PFS is used */
* if PFS is used. modp2048 is kept in the AEAD list so server-initiated
* rekeys offering it match instead of failing with NO_PROP, which
* triggers a full IKE re-establishment every rekey */
child_cfg->add_proposal(child_cfg, proposal_create_from_string(PROTO_ESP,
"aes256gcm16-aes128gcm16-chacha20poly1305-"
"curve25519-ecp384-ecp521-modp3072-modp4096-ecp256-modp8192"));
"curve25519-ecp384-ecp521-modp3072-modp4096-ecp256-modp8192-modp2048"));
child_cfg->add_proposal(child_cfg, proposal_create_from_string(PROTO_ESP,
"aes256-aes192-aes128-sha384-sha256-sha512-sha1-"
"curve25519-ecp384-ecp521-modp3072-modp4096-ecp256-modp2048-"
@@ -25,8 +25,10 @@
#include <dlfcn.h>
#include <errno.h>
#include <fcntl.h>
#include <netinet/in.h>
#include <pthread.h>
#include <stdio.h>
#include <sys/socket.h>
#include <unistd.h>
#include "charonservice.h"
@@ -254,7 +256,7 @@ static bool bypass_single_socket(private_charonservice_t *this, int fd,
}
if (!(*env)->CallBooleanMethod(env, this->vpn_service, method_id, fd))
{
DBG2(DBG_KNL, "VpnService.protect() failed");
DBG1(DBG_KNL, "VpnService.protect() failed");
goto failed;
}
androidjni_detach_thread();
@@ -326,6 +328,71 @@ bool charonservice_protect_direct_socket(int fd)
return bypass_single_socket(this, fd, TRUE);
}
/**
* Ask Java which app owns the connection the direct relay just refused
* (diagnostics: names the app hammering a dead destination). local is the
* app-side endpoint of its socket, remote the destination. Best effort;
* the engine calls this at most once per destination cooldown.
*/
void charonservice_log_connection_owner(const struct sockaddr *local,
const struct sockaddr *remote,
const char *dst)
{
private_charonservice_t *this = (private_charonservice_t*)charonservice;
JNIEnv *env;
jmethodID method_id;
jbyteArray jlocal, jremote;
jstring jdst;
const void *laddr, *raddr;
size_t alen;
int lport, rport;
if (!this || !local || !remote || !dst)
{
return;
}
if (local->sa_family == AF_INET6)
{
const struct sockaddr_in6 *l6 = (const struct sockaddr_in6*)local;
const struct sockaddr_in6 *r6 = (const struct sockaddr_in6*)remote;
alen = 16;
laddr = &l6->sin6_addr;
raddr = &r6->sin6_addr;
lport = ntohs(l6->sin6_port);
rport = ntohs(r6->sin6_port);
}
else
{
const struct sockaddr_in *l4 = (const struct sockaddr_in*)local;
const struct sockaddr_in *r4 = (const struct sockaddr_in*)remote;
alen = 4;
laddr = &l4->sin_addr;
raddr = &r4->sin_addr;
lport = ntohs(l4->sin_port);
rport = ntohs(r4->sin_port);
}
androidjni_attach_thread(&env);
jlocal = (*env)->NewByteArray(env, alen);
jremote = (*env)->NewByteArray(env, alen);
jdst = (*env)->NewStringUTF(env, dst);
method_id = (*env)->GetMethodID(env, android_charonvpnservice_class,
"logConnectionOwner", "([BI[BILjava/lang/String;)V");
if (!jlocal || !jremote || !jdst || !method_id)
{
goto failed;
}
(*env)->SetByteArrayRegion(env, jlocal, 0, alen, (const jbyte*)laddr);
(*env)->SetByteArrayRegion(env, jremote, 0, alen, (const jbyte*)raddr);
(*env)->CallVoidMethod(env, this->vpn_service, method_id,
jlocal, (jint)lport, jremote, (jint)rport, jdst);
failed:
androidjni_exception_occurred(env);
androidjni_detach_thread();
}
/**
* Converts the given Java array of byte arrays (byte[][]) to a linked list
* of chunk_t objects.
@@ -1098,19 +1165,21 @@ static uint8_t *bytes_to_v6(JNIEnv *env, jbyteArray jarr, size_t *count)
/**
* Push the userspace routing classification data (mode B of the RU split)
* to the engine: the merged sorted RU ranges and the force-tunnel list.
* The set is swapped atomically, no reconnect is required.
* to the engine: the merged sorted RU ranges and the force-tunnel ranges
* (DoT/tunnel DNS servers and user-defined prefixes). The set is swapped
* atomically, no reconnect is required.
*/
JNI_METHOD(CharonVpnService, setUserspaceRoutingData, void,
jboolean enabled, jlongArray jv4from, jlongArray jv4to,
jbyteArray jv6from, jbyteArray jv6to, jlongArray jft4, jbyteArray jft6)
jbyteArray jv6from, jbyteArray jv6to,
jlongArray jft4from, jlongArray jft4to, jbyteArray jft6from, jbyteArray jft6to)
{
/* the engine exists only while the userspace routing mode is enabled;
* in mode A none of its code runs at all */
direct_engine_t *engine = enabled ? direct_engine_ensure() : direct_engine_get();
ru_set_t *set;
uint32_t *v4f = NULL, *v4t = NULL, *ft4 = NULL;
uint8_t *v6f = NULL, *v6t = NULL, *ft6 = NULL;
uint32_t *v4f = NULL, *v4t = NULL, *ft4f = NULL, *ft4t = NULL;
uint8_t *v6f = NULL, *v6t = NULL, *ft6f = NULL, *ft6t = NULL;
size_t n4 = 0, n6 = 0, nf4 = 0, nf6 = 0, n4b = 0;
if (!engine)
@@ -1137,10 +1206,35 @@ JNI_METHOD(CharonVpnService, setUserspaceRoutingData, void,
free(v6t);
return;
}
ft4 = longs_to_v4(env, jft4, &nf4);
ft6 = bytes_to_v6(env, jft6, &nf6);
ft4f = longs_to_v4(env, jft4from, &nf4);
ft4t = longs_to_v4(env, jft4to, &n4b);
if (n4b != nf4)
{
free(v4f);
free(v4t);
free(v6f);
free(v6t);
free(ft4f);
free(ft4t);
return;
}
ft6f = bytes_to_v6(env, jft6from, &nf6);
n4b = 0;
ft6t = bytes_to_v6(env, jft6to, &n4b);
if (n4b != nf6)
{
free(v4f);
free(v4t);
free(v6f);
free(v6t);
free(ft4f);
free(ft4t);
free(ft6f);
free(ft6t);
return;
}
set = ru_set_create(v4f, v4t, n4, v6f, v6t, n6, ft4, nf4, ft6, nf6);
set = ru_set_create(v4f, v4t, n4, v6f, v6t, n6, ft4f, ft4t, nf4, ft6f, ft6t, nf6);
engine->update_set(engine, set);
engine->set_enabled(engine, enabled);
}
@@ -1152,7 +1246,7 @@ JNI_METHOD(CharonVpnService, getRoutingStats, jlongArray)
{
direct_engine_t *engine = direct_engine_get();
direct_stats_t s = {};
jlong v[19];
jlong v[20];
jlongArray result;
memset(&s, 0, sizeof(s));
@@ -1179,9 +1273,10 @@ JNI_METHOD(CharonVpnService, getRoutingStats, jlongArray)
v[16] = (jlong)s.set_v6;
v[17] = (jlong)s.set_ft_v4;
v[18] = (jlong)(s.enabled ? 1 : 0);
v[19] = (jlong)s.cooldown_hits;
result = (*env)->NewLongArray(env, 19);
(*env)->SetLongArrayRegion(env, result, 0, 19, v);
result = (*env)->NewLongArray(env, 20);
(*env)->SetLongArrayRegion(env, result, 0, 20, v);
return result;
}
@@ -40,6 +40,7 @@
#include <library.h>
#include <collections/linked_list.h>
#include <sys/socket.h>
typedef enum android_vpn_state_t android_vpn_state_t;
typedef enum android_imc_state_t android_imc_state_t;
@@ -180,4 +181,13 @@ bool charonservice_protect_socket(int fd);
*/
bool charonservice_protect_direct_socket(int fd);
/**
* Log (via Java) which app owns the connection identified by the two
* endpoints; used by the direct relay when a destination enters its failure
* cooldown. Best effort.
*/
void charonservice_log_connection_owner(const struct sockaddr *local,
const struct sockaddr *remote,
const char *dst);
#endif /** CHARONSERVICE_H_ @}*/
@@ -35,14 +35,28 @@ extern void lwip_assert_diag(const char *msg);
/**
* Poll set capacity; beyond that flows are simply not polled for one round
* (the wakeup pipe keeps the loop spinning at 250ms worst case)
* (the wakeup pipe keeps the loop spinning at the poll timeout worst case)
*/
#define MAX_POLL_FDS 1024
/**
* Poll timeout: upper bound for lwIP timer latency
* Poll timeout: upper bound for lwIP timer latency while traffic flows.
* Empty rounds double it up to the idle cap - ENGINE_IDLE_TCP_MS while TCP
* relays exist (their connect timeout and failure-cooldown bookkeeping are
* evaluated per round), ENGINE_IDLE_POLL_MS otherwise - so an idle engine
* stops waking the CPU four times a second. Any event (queued packet,
* socket, wakeup pipe) restores the active timeout instantly, so latency
* under load is unchanged.
*/
#define ENGINE_POLL_MS 250
#define ENGINE_IDLE_POLL_MS 4000
#define ENGINE_IDLE_TCP_MS 1000
/**
* Idle-flow sweep interval (UDP flow expiry), measured on the monotonic
* clock so stretching the poll timeout does not stretch the sweep
*/
#define ENGINE_SWEEP_MS 4000
/**
* Idle-fragment of the IPv4 fragment flags word (MF flag or offset)
@@ -375,6 +389,7 @@ METHOD(direct_engine_t, get_stats, void,
.drop_frag = __atomic_load_n(&this->stats.drop_frag, __ATOMIC_RELAXED),
.drop_malformed = __atomic_load_n(&this->stats.drop_malformed, __ATOMIC_RELAXED),
.relay_errors = __atomic_load_n(&this->stats.relay_errors, __ATOMIC_RELAXED),
.cooldown_hits = __atomic_load_n(&this->stats.cooldown_hits, __ATOMIC_RELAXED),
.udp_flows = __atomic_load_n(&this->stats.udp_flows, __ATOMIC_RELAXED),
.tcp_flows = __atomic_load_n(&this->stats.tcp_flows, __ATOMIC_RELAXED),
.tcp_accepts = __atomic_load_n(&this->stats.tcp_accepts, __ATOMIC_RELAXED),
@@ -520,19 +535,30 @@ static void drain_queue_nobind(private_direct_engine_t *this)
}
}
/**
* Monotonic clock in milliseconds (matches sys_now() in direct_tcp.c)
*/
static uint64_t monotonic_ms(void)
{
return (uint64_t)(time_monotonic(NULL) * 1000);
}
/**
* Engine thread: drives all direct flows. The queue is drained straight
* into dispatch - no intermediate list, no allocations in this loop.
* The poll timeout is adaptive: idle rounds stretch it (battery), any
* event or queued packet restores the active cadence (latency).
*/
static void *engine_thread(private_direct_engine_t *this)
{
struct pollfd fds[MAX_POLL_FDS];
uint32_t sweeps = 0;
uint32_t poll_ms = ENGINE_POLL_MS;
uint64_t last_sweep = monotonic_ms();
while (this->running)
{
size_t n = 1, udp_n, tcp_n;
bool bound;
bool bound, active = FALSE;
fds[0].fd = this->wake[0];
fds[0].events = POLLIN;
@@ -541,16 +567,20 @@ static void *engine_thread(private_direct_engine_t *this)
n += udp_n;
tcp_n = 0;
if (this->tcp_active)
{ /* dormant until a TCP flow exists (currently never: handle()
* tunnels TCP; see the lwIP note in dispatch_packet) */
{ /* dormant until a TCP flow exists (see the lwIP note in
* dispatch_packet for why TCP is usually tunneled instead) */
tcp_n = this->tcp->fill_poll(this->tcp, fds + n, MAX_POLL_FDS - n);
n += tcp_n;
}
poll(fds, n, ENGINE_POLL_MS);
if (poll(fds, n, poll_ms) != 0)
{ /* an event arrived (or poll failed) - active round */
active = TRUE;
}
if (fds[0].revents & POLLIN)
{
active = TRUE;
drain_wake(this);
}
@@ -559,6 +589,7 @@ static void *engine_thread(private_direct_engine_t *this)
* roam event; the flow lists belong to this thread) */
if (__atomic_exchange_n(&reprotect_pending, FALSE, __ATOMIC_ACQ_REL))
{
active = TRUE;
this->udp->reprotect(this->udp);
if (this->tcp_active)
{
@@ -592,6 +623,7 @@ static void *engine_thread(private_direct_engine_t *this)
this->queue_full = FALSE;
this->q_lock->unlock(this->q_lock);
active = TRUE;
if (bound)
{
dispatch_packet(this, packet);
@@ -621,9 +653,29 @@ static void *engine_thread(private_direct_engine_t *this)
this->tcp->check_timers(this->tcp);
}
if (++sweeps % 16 == 0)
{ /* roughly every four seconds */
this->udp->sweep(this->udp);
{ /* adapt the cadence after the work of this round is known */
uint64_t now = monotonic_ms();
if (now - last_sweep >= ENGINE_SWEEP_MS)
{ /* wall-clock based, so the adaptive timeout does not
* change the flow-expiry cadence */
this->udp->sweep(this->udp);
last_sweep = now;
}
if (active)
{
poll_ms = ENGINE_POLL_MS;
}
else
{
uint32_t cap = this->tcp_active ? ENGINE_IDLE_TCP_MS : ENGINE_IDLE_POLL_MS;
poll_ms *= 2;
if (poll_ms > cap)
{
poll_ms = cap;
}
}
}
}
return NULL;
@@ -50,6 +50,7 @@ typedef struct direct_stats_t {
uint64_t drop_frag;
uint64_t drop_malformed;
uint64_t relay_errors;
uint64_t cooldown_hits;
uint64_t udp_flows, tcp_flows;
uint64_t tcp_accepts;
size_t set_v4, set_v6, set_ft_v4, set_ft_v6;
@@ -58,8 +58,43 @@
*/
#define MAX_BUFFERED (256 * 1024)
/**
* Give up on a direct connect after this many milliseconds (a destination
* that silently drops SYNs would otherwise hang the app's connection
* forever: lwIP already completed the handshake towards the kernel)
*/
#define CONNECT_TIMEOUT_MS 10000
/**
* Negative cache: after NEG_FAILS consecutive failed connect cycles a
* destination is refused outright (RST without a socket) for
* NEG_COOLDOWN_MS, so an app hammering a dead host cannot burn sockets,
* JNI protect() roundtrips and log lines. Cleared on success, on a network
* change (reprotect) and after NEG_EXPIRY_MS without failures.
*/
#define NEG_FAILS 3
#define NEG_COOLDOWN_MS 45000
#define NEG_EXPIRY_MS (10 * 60 * 1000)
/**
* Minimum spacing between failure log lines for the same destination
*/
#define NEG_LOG_MS 5000
#define NEG_SLOTS 32
typedef struct private_direct_tcp_t private_direct_tcp_t;
/**
* One entry of the per-destination failure table (negative cache)
*/
typedef struct {
bool used;
struct sockaddr_storage dest;
uint32_t fails;
uint64_t last_fail, cooldown_until, last_log;
} neg_entry_t;
static lwip_err_t on_accept(void *arg, struct tcp_pcb *newpcb, lwip_err_t err);
static lwip_err_t on_recv(void *arg, struct tcp_pcb *pcb, struct pbuf *p, lwip_err_t err);
static void on_err(void *arg, lwip_err_t err);
@@ -76,8 +111,10 @@ typedef struct {
bool dead;
bool remote_eof;
bool retried;
uint64_t connect_started;
struct sockaddr_storage dest;
socklen_t dest_len;
struct sockaddr_storage origin;
uint8_t *to_sock;
size_t to_sock_len, to_sock_cap;
uint8_t *to_pcb;
@@ -112,6 +149,11 @@ struct private_direct_tcp_t {
* Live relays; only touched on the engine thread
*/
linked_list_t *relays;
/**
* Per-destination failure table (negative cache); engine thread only
*/
neg_entry_t neg[NEG_SLOTS];
};
/**
@@ -290,6 +332,161 @@ static void format_dest(const struct sockaddr_storage *ss, char *buf, size_t len
snprintf(buf, len, "%s:%u", host, ntohs(port));
}
/**
* Monotonic milliseconds
*/
static uint64_t now_ms(void)
{
timeval_t tv;
time_monotonic(&tv);
return (uint64_t)tv.tv_sec * 1000 + tv.tv_usec / 1000;
}
/**
* Same destination (address and port)?
*/
static bool neg_match(const neg_entry_t *e, const struct sockaddr_storage *ss)
{
if (!e->used || e->dest.ss_family != ss->ss_family)
{
return FALSE;
}
if (ss->ss_family == AF_INET6)
{
return memcmp(&((struct sockaddr_in6*)&e->dest)->sin6_addr,
&((struct sockaddr_in6*)ss)->sin6_addr, 16) == 0 &&
((struct sockaddr_in6*)&e->dest)->sin6_port ==
((struct sockaddr_in6*)ss)->sin6_port;
}
return memcmp(&((struct sockaddr_in*)&e->dest)->sin_addr,
&((struct sockaddr_in*)ss)->sin_addr, 4) == 0 &&
((struct sockaddr_in*)&e->dest)->sin_port ==
((struct sockaddr_in*)ss)->sin_port;
}
/**
* Whether the destination is currently refused outright (negative cache hit)
*/
static bool neg_active(private_direct_tcp_t *this, const struct sockaddr_storage *ss,
uint64_t now)
{
size_t i;
for (i = 0; i < NEG_SLOTS; i++)
{
if (neg_match(&this->neg[i], ss))
{
if (now < this->neg[i].cooldown_until)
{
return TRUE;
}
return FALSE;
}
}
return FALSE;
}
/**
* Log gating: at most one line per destination per NEG_LOG_MS
*/
static bool neg_should_log(private_direct_tcp_t *this, const struct sockaddr_storage *ss,
uint64_t now)
{
size_t i;
for (i = 0; i < NEG_SLOTS; i++)
{
if (neg_match(&this->neg[i], ss))
{
if (now - this->neg[i].last_log >= NEG_LOG_MS)
{
this->neg[i].last_log = now;
return TRUE;
}
return FALSE;
}
}
return TRUE;
}
/**
* Record a failed connect cycle. Returns TRUE when the destination just
* entered its cooldown (the caller may log/attribute the owner once here).
*/
static bool neg_record_fail(private_direct_tcp_t *this, const struct sockaddr_storage *ss,
uint64_t now)
{
neg_entry_t *entry = NULL, *oldest = &this->neg[0];
size_t i;
for (i = 0; i < NEG_SLOTS; i++)
{
if (this->neg[i].used)
{
if (neg_match(&this->neg[i], ss))
{
entry = &this->neg[i];
break;
}
if (this->neg[i].last_fail < oldest->last_fail)
{
oldest = &this->neg[i];
}
}
else if (!entry)
{
entry = &this->neg[i];
}
}
if (!entry)
{ /* full: recycle the least recently failed slot */
entry = oldest;
}
if (!entry->used || !neg_match(entry, ss))
{
memset(entry, 0, sizeof(*entry));
entry->used = TRUE;
memcpy(&entry->dest, ss, sizeof(*ss));
}
else if (now - entry->last_fail > NEG_EXPIRY_MS)
{ /* silence long enough: start over */
entry->fails = 0;
}
entry->fails++;
entry->last_fail = now;
if (entry->fails >= NEG_FAILS && now >= entry->cooldown_until)
{
entry->cooldown_until = now + NEG_COOLDOWN_MS;
return TRUE;
}
return FALSE;
}
/**
* Forget failures for a destination after a successful connect
*/
static void neg_clear(private_direct_tcp_t *this, const struct sockaddr_storage *ss)
{
size_t i;
for (i = 0; i < NEG_SLOTS; i++)
{
if (neg_match(&this->neg[i], ss))
{
this->neg[i].used = FALSE;
}
}
}
/**
* Drop the whole failure table (network change, engine reset)
*/
static void neg_clear_all(private_direct_tcp_t *this)
{
memset(this->neg, 0, sizeof(this->neg));
}
/**
* Open a protected, network-pinned non-blocking socket and start connecting
* it to the given destination. Returns -1 on failure.
@@ -304,7 +501,8 @@ static int relay_connect_socket(const struct sockaddr_storage *ss, socklen_t len
return -1;
}
if (!charonservice_protect_direct_socket(fd))
{
{ /* name the cause in the caller's failure log */
errno = EACCES;
close(fd);
return -1;
}
@@ -323,11 +521,12 @@ static lwip_err_t on_accept(void *arg, struct tcp_pcb *newpcb, lwip_err_t err)
{
private_direct_tcp_t *this = arg;
tcp_relay_t *relay;
struct sockaddr_storage ss;
struct sockaddr_storage ss, org;
struct sockaddr_in *sin = (struct sockaddr_in*)&ss;
struct sockaddr_in6 *sin6 = (struct sockaddr_in6*)&ss;
socklen_t ss_len;
char dest[INET6_ADDRSTRLEN + 8];
uint64_t now = now_ms();
int fd;
DIRECT_STATS_ADD(this->stats->tcp_accepts, 1);
@@ -349,12 +548,49 @@ static lwip_err_t on_accept(void *arg, struct tcp_pcb *newpcb, lwip_err_t err)
}
format_dest(&ss, dest, sizeof(dest));
/* the kernel side peer: the app's socket endpoint (source address and
* port), needed to attribute the flow to an owning app */
memset(&org, 0, sizeof(org));
if (IP_IS_V6_VAL(newpcb->remote_ip))
{
struct sockaddr_in6 *o6 = (struct sockaddr_in6*)&org;
o6->sin6_family = AF_INET6;
memcpy(&o6->sin6_addr, &ip_2_ip6(&newpcb->remote_ip)->addr, 16);
o6->sin6_port = htons(newpcb->remote_port);
}
else
{
struct sockaddr_in *o4 = (struct sockaddr_in*)&org;
o4->sin_family = AF_INET;
memcpy(&o4->sin_addr, &ip_2_ip4(&newpcb->remote_ip)->addr, 4);
o4->sin_port = htons(newpcb->remote_port);
}
if (neg_active(this, &ss, now))
{ /* this destination recently failed repeatedly - refuse without
* burning a socket and a protect() roundtrip; the app keeps
* getting its fast RST */
DIRECT_STATS_ADD(this->stats->cooldown_hits, 1);
tcp_abort(newpcb);
return ERR_ABRT;
}
fd = relay_connect_socket(&ss, ss_len);
if (fd < 0)
{
DIRECT_STATS_ADD(this->stats->relay_errors, 1);
DBG1(DBG_NET, "direct tcp: accept socket setup failed dst=%s: %s",
dest, strerror(errno));
if (neg_record_fail(this, &ss, now))
{
charonservice_log_connection_owner((struct sockaddr*)&org,
(struct sockaddr*)&ss, dest);
}
if (neg_should_log(this, &ss, now))
{
DBG1(DBG_NET, "direct tcp: accept socket setup failed dst=%s: %s",
dest, strerror(errno));
}
tcp_abort(newpcb);
return ERR_ABRT;
}
@@ -363,9 +599,11 @@ static lwip_err_t on_accept(void *arg, struct tcp_pcb *newpcb, lwip_err_t err)
.m = this,
.pcb = newpcb,
.fd = fd,
.connect_started = now,
.dest_len = ss_len,
);
memcpy(&relay->dest, &ss, ss_len);
memcpy(&relay->origin, &org, sizeof(org));
/* keep our own segments within the TUN MTU regardless of TCP_MSS */
if (newpcb->mss > this->mtu - 72)
{
@@ -621,6 +859,9 @@ METHOD(direct_tcp_t, reprotect, void,
tcp_relay_t *relay;
enumerator_t *enumerator;
/* the network changed: whatever failed before may work now */
neg_clear_all(this);
enumerator = this->relays->create_enumerator(this->relays);
while (enumerator->enumerate(enumerator, &relay))
{
@@ -689,45 +930,74 @@ METHOD(direct_tcp_t, process_poll, void,
relay_destroy(this, relay, TRUE);
continue;
}
if (!relay->connected && (re & (POLLOUT | POLLERR | POLLHUP)))
if (!relay->connected)
{
int err = 0;
socklen_t len = sizeof(err);
char dest[INET6_ADDRSTRLEN + 8];
uint64_t now = now_ms();
bool timed_out = now - relay->connect_started > CONNECT_TIMEOUT_MS;
getsockopt(relay->fd, SOL_SOCKET, SO_ERROR, &err, &len);
if (err == 0)
if (re & (POLLOUT | POLLERR | POLLHUP) || timed_out)
{
relay->connected = TRUE;
}
else
{
format_dest(&relay->dest, dest, sizeof(dest));
if (!relay->retried)
{ /* one retry on a fresh socket: the first attempt may have
* been pinned to a network that vanished mid-roam (its
* EHOSTUNREACH can take over a minute to surface, leaving
* the connection hanging) */
int nfd;
int err = 0;
socklen_t len = sizeof(err);
char dest[INET6_ADDRSTRLEN + 8];
bool do_log;
DBG1(DBG_NET, "direct tcp: connect failed dst=%s: %s, retrying on a fresh socket",
dest, strerror(err));
relay->retried = TRUE;
close(relay->fd);
nfd = relay_connect_socket(&relay->dest, relay->dest_len);
if (nfd >= 0)
getsockopt(relay->fd, SOL_SOCKET, SO_ERROR, &err, &len);
if (timed_out && err == 0)
{ /* the peer never answered the SYN: the kernel reports no
* error on a connect that is merely stuck */
err = ETIMEDOUT;
}
if (err == 0)
{
relay->connected = TRUE;
neg_clear(this, &relay->dest);
}
else
{
format_dest(&relay->dest, dest, sizeof(dest));
do_log = neg_should_log(this, &relay->dest, now);
if (!relay->retried)
{ /* one retry on a fresh socket: the first attempt may have
* been pinned to a network that vanished mid-roam (its
* EHOSTUNREACH can take over a minute to surface, leaving
* the connection hanging) */
int nfd;
if (do_log)
{
DBG1(DBG_NET, "direct tcp: connect failed dst=%s: %s, retrying on a fresh socket",
dest, strerror(err));
}
relay->retried = TRUE;
close(relay->fd);
nfd = relay_connect_socket(&relay->dest, relay->dest_len);
if (nfd >= 0)
{
relay->fd = nfd;
relay->connect_started = now;
continue;
}
err = errno;
}
DIRECT_STATS_ADD(this->stats->relay_errors, 1);
if (neg_record_fail(this, &relay->dest, now))
{ /* destination just entered its cooldown: name the app
* that owns the connection while its socket still
* exists (relay_destroy aborts the pcb below) */
charonservice_log_connection_owner(
(struct sockaddr*)&relay->origin,
(struct sockaddr*)&relay->dest, dest);
}
if (do_log)
{
relay->fd = nfd;
continue;
DBG1(DBG_NET, "direct tcp: connect failed dst=%s: %s",
dest, strerror(err));
}
err = errno;
this->relays->remove_at(this->relays, enumerator);
relay_destroy(this, relay, TRUE);
continue;
}
DIRECT_STATS_ADD(this->stats->relay_errors, 1);
DBG1(DBG_NET, "direct tcp: connect failed dst=%s: %s",
dest, strerror(err));
this->relays->remove_at(this->relays, enumerator);
relay_destroy(this, relay, TRUE);
continue;
}
}
if (re & (POLLIN | POLLHUP))
@@ -787,6 +1057,7 @@ METHOD(direct_tcp_t, reset, void,
private_direct_tcp_t *this)
{
DIRECT_STATS_SET(this->stats->tcp_flows, 0);
neg_clear_all(this);
this->relays->destroy_function(this->relays, relay_free);
this->relays = linked_list_create();
}
@@ -36,27 +36,13 @@ struct private_ru_set_t {
uint8_t *v6_from, *v6_to;
/**
* Sorted force-tunnel addresses (network byte order)
* Sorted force-tunnel ranges (network byte order): DoT/tunnel DNS
* servers (single-address ranges) plus the user's prefixes
*/
uint32_t *ft_v4;
uint8_t *ft_v6;
uint32_t *ft_v4_from, *ft_v4_to;
uint8_t *ft_v6_from, *ft_v6_to;
};
/**
* Compare two 16-byte keys (network order is big-endian => memcmp is numeric)
*/
static int cmp_v6(const void *a, const void *b)
{
return memcmp(a, b, 16);
}
static int cmp_v4(const void *a, const void *b)
{
uint32_t x = *(uint32_t*)a, y = *(uint32_t*)b;
return x < y ? -1 : (x > y ? 1 : 0);
}
/**
* Sort parallel from/to range arrays by the from key (insertion sort on an
* index permutation keeps the pairs together without a struct copy)
@@ -214,32 +200,17 @@ METHOD(ru_set_t, contains_v6, bool,
METHOD(ru_set_t, force_tunnel_v4, bool,
private_ru_set_t *this, uint32_t addr)
{
size_t lo = 0, hi = this->public.ft_v4_count;
ssize_t i = find_v4(this->ft_v4_from, this->public.ft_v4_count, addr);
while (lo < hi)
{
size_t mid = lo + (hi - lo) / 2;
if (this->ft_v4[mid] == addr)
{
return TRUE;
}
if (this->ft_v4[mid] < addr)
{
lo = mid + 1;
}
else
{
hi = mid;
}
}
return FALSE;
return i >= 0 && addr <= this->ft_v4_to[i];
}
METHOD(ru_set_t, force_tunnel_v6, bool,
private_ru_set_t *this, const uint8_t addr[16])
{
return bsearch(addr, this->ft_v6, this->public.ft_v6_count, 16, cmp_v6) != NULL;
ssize_t i = find_v6(this->ft_v6_from, this->public.ft_v6_count, addr);
return i >= 0 && memcmp(addr, this->ft_v6_to + i * 16, 16) <= 0;
}
METHOD(ru_set_t, destroy, void,
@@ -249,15 +220,17 @@ METHOD(ru_set_t, destroy, void,
free(this->v4_to);
free(this->v6_from);
free(this->v6_to);
free(this->ft_v4);
free(this->ft_v6);
free(this->ft_v4_from);
free(this->ft_v4_to);
free(this->ft_v6_from);
free(this->ft_v6_to);
free(this);
}
ru_set_t *ru_set_create(uint32_t *v4_from, uint32_t *v4_to, size_t v4_count,
uint8_t *v6_from, uint8_t *v6_to, size_t v6_count,
uint32_t *ft_v4, size_t ft_v4_count,
uint8_t *ft_v6, size_t ft_v6_count)
uint32_t *ft_v4_from, uint32_t *ft_v4_to, size_t ft_v4_count,
uint8_t *ft_v6_from, uint8_t *ft_v6_to, size_t ft_v6_count)
{
private_ru_set_t *this;
@@ -277,8 +250,10 @@ ru_set_t *ru_set_create(uint32_t *v4_from, uint32_t *v4_to, size_t v4_count,
.v4_to = v4_to,
.v6_from = v6_from,
.v6_to = v6_to,
.ft_v4 = ft_v4,
.ft_v6 = ft_v6,
.ft_v4_from = ft_v4_from,
.ft_v4_to = ft_v4_to,
.ft_v6_from = ft_v6_from,
.ft_v6_to = ft_v6_to,
);
/* the lookups are binary searches in numeric (network) order; do not
@@ -290,7 +265,7 @@ ru_set_t *ru_set_create(uint32_t *v4_from, uint32_t *v4_to, size_t v4_count,
}
if (ft_v4_count > 1)
{
qsort(this->ft_v4, ft_v4_count, sizeof(uint32_t), cmp_v4);
qsort_ranges_v4(this->ft_v4_from, this->ft_v4_to, ft_v4_count);
}
if (v6_count > 1)
{
@@ -298,7 +273,7 @@ ru_set_t *ru_set_create(uint32_t *v4_from, uint32_t *v4_to, size_t v4_count,
}
if (ft_v6_count > 1)
{
qsort(this->ft_v6, ft_v6_count, 16, cmp_v6);
qsort_ranges_v6(this->ft_v6_from, this->ft_v6_to, ft_v6_count);
}
return &this->public;
}
@@ -63,11 +63,13 @@ struct ru_set_t {
/**
* Create a set, taking ownership of the given arrays (network byte order).
* The force-tunnel arrays are sorted by the constructor.
* The range arrays are sorted by the constructor; the force-tunnel ranges
* cover the DoT/tunnel DNS servers (single-address ranges) and any
* user-defined prefixes.
*/
ru_set_t *ru_set_create(uint32_t *v4_from, uint32_t *v4_to, size_t v4_count,
uint8_t *v6_from, uint8_t *v6_to, size_t v6_count,
uint32_t *ft_v4, size_t ft_v4_count,
uint8_t *ft_v6, size_t ft_v6_count);
uint32_t *ft_v4_from, uint32_t *ft_v4_to, size_t ft_v4_count,
uint8_t *ft_v6_from, uint8_t *ft_v6_to, size_t ft_v6_count);
#endif /** RU_SET_H_ @} */
@@ -41,6 +41,8 @@
<string name="profile_actions">Действия с профилем</string>
<string name="action_clear_search">Очистить поиск</string>
<string name="action_undo">Отменить</string>
<string name="action_done">Готово</string>
<string name="action_back">Назад</string>
<string name="profile_deleted_snackbar">Профиль удалён</string>
<string name="action_more_options">Ещё</string>
<string name="back">Назад</string>
@@ -206,20 +208,40 @@
<string name="whitelist_domestic_direct">Яндекс (напрямую)</string>
<string name="nav_routing">Маршрут</string>
<string name="routing_title">Маршрутизация</string>
<string name="routing_enabled_title">Применять правила маршрутизации</string>
<string name="routing_enabled_summary">Выбранные приложения ходят в интернет напрямую, мимо туннеля</string>
<string name="routing_section_ru">RU-сети напрямую</string>
<string name="routing_rf_quick_title">Исключить приложения из РФ</string>
<string name="routing_rf_quick_summary">Все приложения ru.*, Яндекс, VK, Госуслуги, банки, магазины, связь, транспорт и другие сервисы РФ</string>
<string name="routing_force_section">Всегда через VPN</string>
<string name="routing_force_summary">Адреса и подсети, которые всегда идут через туннель, даже если попадают в RU-диапазоны (режим userspace)</string>
<string name="routing_force_add">Добавить адрес</string>
<string name="routing_force_dialog_title">Адрес или подсеть</string>
<string name="routing_force_hint">например 130.49.213.176 или 10.8.0.0/24</string>
<string name="routing_force_invalid">Некорректный адрес или подсеть</string>
<string name="routing_force_save">Сохранить</string>
<string name="routing_force_delete">Удалить</string>
<string name="routing_rules_section">Наборы правил</string>
<string name="routing_add_rule">Добавить набор</string>
<string name="routing_rule_name">Название набора</string>
<string name="routing_rule_subtitle">Приложений: %1$d · Префиксов: %2$d · Слов: %3$d</string>
<string name="routing_pick_apps">Выбрать приложения</string>
<string name="routing_apps_count">Выбрано: %1$d</string>
<string name="routing_editor_title">Набор правил</string>
<string name="routing_prefixes_label">Префиксы пакетов</string>
<string name="routing_keywords_label">Ключевые слова</string>
<string name="routing_details_more">Подробнее</string>
<string name="routing_details_less">Скрыть</string>
<string name="routing_presets_label">Пресеты</string>
<string name="routing_presets_selected">Пресеты · выбрано %1$d</string>
<string name="routing_presets_hint">Готовые группы сервисов</string>
<string name="routing_presets_search">Поиск пресетов</string>
<string name="routing_presets_none">Ничего не найдено</string>
<string name="routing_preset_prefixes">%1$d префиксов</string>
<string name="routing_preset_keywords">%1$d слов</string>
<string name="routing_preset_no_patterns">нет паттернов</string>
<string name="routing_empty">Наборов пока нет. Добавьте свой или используйте быстрое действие выше.</string>
<string name="routing_delete">Удалить набор</string>
<string name="routing_delete_snackbar">Набор удалён</string>
<string name="routing_add_prefix">Свой префикс IP</string>
<string name="routing_add_prefix">Свой префикс пакета</string>
<string name="routing_add_keyword">Своё ключевое слово</string>
<string name="routing_hero_on">Маршрутизация включена</string>
<string name="routing_hero_off">Маршрутизация выключена</string>
@@ -229,19 +251,17 @@
<string name="routing_geoip_ipv6">IPv6 · %1$d</string>
<string name="routing_geoip_updated">Обновлено %1$s</string>
<string name="routing_geoip_never">Не обновлялся</string>
<string name="rusplit_title">RU IP-список (бета)</string>
<string name="rusplit_toggle_title">RU-сети напрямую (geoip)</string>
<string name="rusplit_toggle_summary">Трафик к российским IP-адресам идёт мимо VPN-туннеля, напрямую</string>
<string name="rusplit_update">Обновить список</string>
<string name="rusplit_updating">Обновление…</string>
<string name="rusplit_updated">Список обновлён: %1$d диапазонов</string>
<string name="rusplit_update_failed">Не удалось обновить, используется сохранённая версия</string>
<string name="rusplit_status_never">Ещё не обновлялся. Нажмите «Обновить список».</string>
<string name="rusplit_status">Обновлён %1$s · IPv4: %2$d · IPv6: %3$d</string>
<string name="rusplit_source">Источник: github.com/MetaCubeX/meta-rules-dat</string>
<string name="rusplit_beta_note">Список статичный, обновляется с GitHub раз в сутки. Работа по IP: сервисы за иностранным CDN могут не попасть в список. Изменения применяются при переподключении.</string>
<string name="rusplit_mode_system">Маршруты ОС</string>
<string name="rusplit_mode_userspace">Userspace-роутинг (бета)</string>
<string name="rusplit_mode_userspace_short">Userspace (бета)</string>
<string name="rusplit_mode_system_desc">Комплемент RU-списка устанавливается системными маршрутами с округлением под бюджет маршрутов. Проверенная схема, но список грубеет, а для обновления нужно переподключение.</string>
<string name="rusplit_mode_userspace_desc">Весь трафик входит в VPN, решение «напрямую/в туннель» принимается по пакету по точному списку, обновления применяются на лету. Напрямую идут TCP и UDP. Ping по RU-адресам напрямую не работает, прочий ICMP отбрасывается.</string>
<string name="route_check_title">Проверка маршрута</string>
@@ -449,5 +469,7 @@
<string name="traffic_stat_up">Отправлено %1$s, %2$s</string>
<string name="profile_advanced_hint">Идентификаторы, DNS, MTU, split-туннель</string>
<string name="import_profile_title">Импорт профиля</string>
<string name="extended_diag_title">Расширенная диагностика</string>
<string name="extended_diag_summary">Периодические снимки диагностики маршрутизации во время подключения (каждые 5 минут); увеличивает расход батареи</string>
</resources>
@@ -186,10 +186,16 @@
<string name="whitelist_domestic_direct">Яндекс (напряму)</string>
<string name="nav_routing">Маршрут</string>
<string name="routing_title">Маршрутизація</string>
<string name="routing_enabled_title">Застосовувати правила маршрутизації</string>
<string name="routing_enabled_summary">Вибрані застосунки ходять в інтернет напряму, повз тунель</string>
<string name="routing_rf_quick_title">Виключити застосунки з РФ</string>
<string name="routing_rf_quick_summary">Усі застосунки ru.*, Яндекс, VK, Дії, банки, магазини, зв\'язок, транспорт та інші сервіси РФ</string>
<string name="routing_force_section">Завжди через VPN</string>
<string name="routing_force_summary">Адреси та підмережі, які завжди йдуть через тунель, навіть якщо потрапляють у RU-діапазони (режим userspace)</string>
<string name="routing_force_add">Додати адресу</string>
<string name="routing_force_dialog_title">Адреса чи підмережа</string>
<string name="routing_force_hint">наприклад 130.49.213.176 або 10.8.0.0/24</string>
<string name="routing_force_invalid">Некоректна адреса чи підмережа</string>
<string name="routing_force_save">Зберегти</string>
<string name="routing_force_delete">Видалити</string>
<string name="routing_rules_section">Набори правил</string>
<string name="routing_add_rule">Додати набір</string>
<string name="routing_rule_name">Назва набору</string>
@@ -294,5 +300,7 @@
<string name="tile_default">Toggle VPN</string>
<string name="tile_connect">Connect VPN</string>
<string name="tile_disconnect">Disconnect VPN</string>
<string name="extended_diag_title">Розширена діагностика</string>
<string name="extended_diag_summary">Періодичні знімки діагностики маршрутизації під час з\'єднання (кожні 5 хвилин); збільшує витрату батареї</string>
</resources>
@@ -185,10 +185,16 @@
<string name="whitelist_domestic_direct">Yandex(直连)</string>
<string name="nav_routing">路由</string>
<string name="routing_title">路由</string>
<string name="routing_enabled_title">应用路由规则</string>
<string name="routing_enabled_summary">所选应用将绕过 VPN 隧道直接联网</string>
<string name="routing_rf_quick_title">排除俄罗斯应用</string>
<string name="routing_rf_quick_summary">全部 ru.* 应用、Yandex、VK、Gosuslugi、主要银行、零售、电信、交通及其他俄罗斯服务</string>
<string name="routing_force_section">始终通过 VPN</string>
<string name="routing_force_summary">始终经隧道路由的地址和子网,即使位于 RU 网段内(用户态模式)</string>
<string name="routing_force_add">添加地址</string>
<string name="routing_force_dialog_title">地址或子网</string>
<string name="routing_force_hint">例如 130.49.213.176 或 10.8.0.0/24</string>
<string name="routing_force_invalid">地址或子网无效</string>
<string name="routing_force_save">保存</string>
<string name="routing_force_delete">删除</string>
<string name="routing_rules_section">规则集</string>
<string name="routing_add_rule">添加规则集</string>
<string name="routing_rule_name">规则集名称</string>
@@ -293,5 +299,7 @@
<string name="tile_default">хИЗцНвVPN</string>
<string name="tile_connect">ш┐ЮцОеVPN</string>
<string name="tile_disconnect">цЦнх╝АVPN</string>
<string name="extended_diag_title">扩展诊断</string>
<string name="extended_diag_summary">连接期间每 5 分钟记录一次路由诊断快照;会增加耗电</string>
</resources>
@@ -45,6 +45,8 @@
<string name="profile_actions">Profile actions</string>
<string name="action_clear_search">Clear search</string>
<string name="action_undo">Undo</string>
<string name="action_done">Done</string>
<string name="action_back">Back</string>
<string name="profile_deleted_snackbar">Profile deleted</string>
<string name="action_more_options">More options</string>
<string name="back">Back</string>
@@ -210,20 +212,40 @@
<string name="whitelist_domestic_direct">Yandex (direct)</string>
<string name="nav_routing">Routing</string>
<string name="routing_title">Routing</string>
<string name="routing_enabled_title">Apply routing rules</string>
<string name="routing_enabled_summary">Selected applications bypass the VPN tunnel and use the connection directly</string>
<string name="routing_section_ru">RU networks direct</string>
<string name="routing_rf_quick_title">Exclude apps from RF companies</string>
<string name="routing_rf_quick_summary">All ru.* apps, Yandex, VK, Gosuslugi, major banks, retail, telecom, transport and other Russian services</string>
<string name="routing_force_section">Always via VPN</string>
<string name="routing_force_summary">Addresses and subnets that are always routed through the tunnel, even when they fall into RU ranges (userspace mode)</string>
<string name="routing_force_add">Add address</string>
<string name="routing_force_dialog_title">Address or subnet</string>
<string name="routing_force_hint">e.g. 130.49.213.176 or 10.8.0.0/24</string>
<string name="routing_force_invalid">Invalid address or subnet</string>
<string name="routing_force_save">Save</string>
<string name="routing_force_delete">Delete</string>
<string name="routing_rules_section">Rule sets</string>
<string name="routing_add_rule">Add rule set</string>
<string name="routing_rule_name">Rule set name</string>
<string name="routing_rule_subtitle">Apps: %1$d · Prefixes: %2$d · Keywords: %3$d</string>
<string name="routing_pick_apps">Choose applications</string>
<string name="routing_apps_count">Selected: %1$d</string>
<string name="routing_editor_title">Rule set</string>
<string name="routing_prefixes_label">Package prefixes</string>
<string name="routing_keywords_label">Keywords</string>
<string name="routing_details_more">Details</string>
<string name="routing_details_less">Hide details</string>
<string name="routing_presets_label">Presets</string>
<string name="routing_presets_selected">Presets · %1$d selected</string>
<string name="routing_presets_hint">Ready-made groups of services</string>
<string name="routing_presets_search">Search presets</string>
<string name="routing_presets_none">Nothing found</string>
<string name="routing_preset_prefixes">%1$d prefixes</string>
<string name="routing_preset_keywords">%1$d keywords</string>
<string name="routing_preset_no_patterns">no patterns</string>
<string name="routing_empty">No rule sets yet. Add one or use the quick action above.</string>
<string name="routing_delete">Delete rule set</string>
<string name="routing_delete_snackbar">Rule set deleted</string>
<string name="routing_add_prefix">Add IP prefix</string>
<string name="routing_add_prefix">Add package prefix</string>
<string name="routing_add_keyword">Add keyword</string>
<string name="routing_hero_on">Routing on</string>
<string name="routing_hero_off">Routing off</string>
@@ -233,19 +255,17 @@
<string name="routing_geoip_ipv6">IPv6 · %1$d</string>
<string name="routing_geoip_updated">Updated %1$s</string>
<string name="routing_geoip_never">Never updated</string>
<string name="rusplit_title">RU IP list (beta)</string>
<string name="rusplit_toggle_title">Route RU networks directly</string>
<string name="rusplit_toggle_summary">Traffic to Russian IP addresses bypasses the VPN tunnel and uses the connection directly (geoip:ru)</string>
<string name="rusplit_update">Update list</string>
<string name="rusplit_updating">Updating…</string>
<string name="rusplit_updated">List updated: %1$d ranges</string>
<string name="rusplit_update_failed">Could not update, using the saved version</string>
<string name="rusplit_status_never">Never updated. Tap "Update list" to download it.</string>
<string name="rusplit_status">Updated %1$s · IPv4: %2$d · IPv6: %3$d</string>
<string name="rusplit_source">Source: github.com/MetaCubeX/meta-rules-dat</string>
<string name="rusplit_beta_note">The list is static and refreshed from GitHub daily. It works by IP: services behind foreign CDNs may not be in the list. Changes apply on reconnect.</string>
<string name="rusplit_mode_system">OS routes</string>
<string name="rusplit_mode_userspace">Userspace routing (beta)</string>
<string name="rusplit_mode_userspace_short">Userspace (beta)</string>
<string name="rusplit_mode_system_desc">The complement of the RU list is installed as system routes, rounded up to fit the route budget. Proven scheme, but the list is coarsened and a reconnect is needed to apply updates.</string>
<string name="rusplit_mode_userspace_desc">All traffic enters the VPN and the direct/tunnel decision is made per packet from the exact list. List updates apply on the fly. TCP and UDP go direct. Ping to RU addresses does not work directly in this mode; other ICMP is dropped.</string>
<string name="route_check_title">Route check</string>
@@ -454,5 +474,7 @@
<string name="traffic_stat_up">Uploaded %1$s, %2$s</string>
<string name="profile_advanced_hint">Identifiers, DNS, MTU, split tunneling</string>
<string name="import_profile_title">Import profile</string>
<string name="extended_diag_title">Extended diagnostics</string>
<string name="extended_diag_summary">Periodic routing diagnostics snapshots while connected (every 5 min); increases battery use</string>
</resources>