Compare commits

8 Commits
Author SHA1 Message Date
DenozordecandClaude Opus 5.5 e9a239e419 android: 2.14.6 - reroute long-dead direct hosts through the tunnel
Android / pre-check (push) Failing after 7s
Android / android (push) Skipped
CIFuzz / pre-check (push) Failing after 5s
CIFuzz / fuzzing (address) (push) Skipped
CIFuzz / fuzzing (undefined) (push) Skipped
CodeQL / pre-check (push) Failing after 4s
CodeQL / analyze (python) (push) Skipped
CodeQL / analyze (ruby) (push) Skipped
CodeQL / analyze-cpp (push) Skipped
FreeBSD / pre-check (push) Failing after 3s
FreeBSD / freebsd (14.4) (push) Skipped
FreeBSD / freebsd (15.0) (push) Skipped
Linux / pre-check (push) Failing after 3s
Linux / latest (apidoc) (push) Skipped
Linux / latest (clang, no, all) (push) Skipped
Linux / latest (clang, no, default) (push) Skipped
Linux / latest (clang, no, printf-builtin) (push) Skipped
Linux / latest (clang, no-dbg) (push) Skipped
Linux / latest (clang, no-testable-ke) (push) Skipped
Linux / latest (clang, yes, all) (push) Skipped
Linux / latest (clang, yes, default) (push) Skipped
Linux / latest (clang, yes, fuzzing) (push) Skipped
Linux / latest (coverage) (push) Skipped
Linux / latest (dist) (push) Skipped
Linux / latest (gcc, no, all) (push) Skipped
Linux / latest (gcc, no, default) (push) Skipped
Linux / latest (gcc, no, printf-builtin) (push) Skipped
Linux / latest (gcc, yes, all) (push) Skipped
Linux / latest (gcc, yes, default) (push) Skipped
Linux / latest (nm) (push) Skipped
Linux / latest (no-dbg) (push) Skipped
Linux / latest (no-testable-ke) (push) Skipped
Linux / latest (yes, ld) (push) Skipped
Linux / crypto (ubuntu-22.04, openssl-sys) (push) Skipped
Linux / crypto (ubuntu-latest, botan) (push) Skipped
Linux / crypto (ubuntu-latest, openssl-3) (push) Skipped
Linux / crypto (ubuntu-latest, openssl-4) (push) Skipped
Linux / crypto (ubuntu-latest, openssl-awslc) (push) Skipped
Linux / crypto (ubuntu-latest, openssl-sys) (push) Skipped
Linux / crypto (ubuntu-latest, wolfssl) (push) Skipped
Linux / older (clang, ubuntu-22.04, all) (push) Skipped
Linux / older (gcc, ubuntu-22.04, all) (push) Skipped
Linux / older (gcc, ubuntu-22.04, nm) (push) Skipped
Linux / alpine (push) Skipped
macOS / pre-check (push) Failing after 3s
macOS / macos (macos-14) (push) Skipped
macOS / macos (macos-latest) (push) Skipped
SonarCloud / pre-check (push) Failing after 4s
SonarCloud / sonarcloud (push) Skipped
TKM / pre-check (push) Failing after 3s
TKM / tkm (push) Skipped
Windows / pre-check (push) Failing after 3s
Windows / cross-compile (win32) (push) Skipped
Windows / cross-compile (win64) (push) Skipped
Windows / native (i686, mingw32, win32) (push) Skipped
Windows / native (x86_64, mingw64, win64) (push) Skipped
Direct destinations that stay unreachable past the cooldown are now
routed through the VPN tunnel until the network changes. The Home
availability card marks such hosts "via tunnel" and offers a one-tap
"Via VPN" action for the rest.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 23:28:03 +07:00
Denozordec bc256ad369 android: 2.14.2 - fix network label showing cellular while on Wi-Fi
underlyingNetworkLabel() returned the first non-VPN network from
allNetworks, whose order is unspecified - Android keeps the cellular
network agent alive in parallel with Wi-Fi, so on Wi-Fi the details
sheet could show "Сотовая сеть".  Wi-Fi now wins when present (same
precedence as NetworkMonitor's trust decision); cellular is only
reported when no Wi-Fi network exists.
2026-10-05 01:56:46 +07:00
Denozordec 7185aa4ba5 android: 2.14.1 - traffic split, exit IP, dead-host visibility (UX phase 1)
- ConnectionDetailsSheet: session traffic split "via VPN / direct (RU)"
  from the native engine counters (userspace mode only; system-routes
  mode shows no split - the engine never sees that traffic), public exit
  IP + country via cloudflare cdn-cgi/trace through the tunnel, current
  underlying network (Wi-Fi + SSID / cellular)
- ConnectionCard: one-glance "N% direct" caption when the RU split moves
  traffic directly
- dead direct hosts surfaced in UI: native snapshot of the negative-cache
  table via new get_dead_targets (mutex-guarded, sorted by failures) +
  JNI getDeadDirectTargets; section on the Routing tab and a badge in the
  Home availability card naming dst, failure count, remaining cooldown
  and the owning app (from the 2.13.15 owner-attribution cache)
- HomeViewModel: split/exit/deadTargets as separate hot flows; exit
  refreshed with the probe round (60s cache), dead targets every round
2026-10-05 00:56:47 +07:00
Denozordec de5b093d62 android: keep jniLibs compressed after the minSdk 23 bump
minSdk 23 flipped AGP's useLegacyPackaging default to false (uncompressed
.so, extractNativeLibs=false): the APK grew 12 -> 23 MB.  Updates are
downloaded over cellular, so pin useLegacyPackaging = true; APK is back
to ~12 MB (2.14.0 re-published with the same versionCode, sha updated in
the manifest before any device fetched it).
2026-10-05 00:41:51 +07:00
Denozordec 2c69cbe775 android: 2.14.0 - minSdk 23 + adaptive navigation (Stage 3)
- minSdkVersion 21 -> 23 (device is Android 17, no other users); jni
  Application.mk: APP_PLATFORM android-23 + -DHAVE_SIGWAITINFO (the NDK
  sysroot declares sigwaitinfo since API 23, conflicting with strongSwan's
  fallback prototype; the system implementation is used now)
- replace the bottom NavigationBar with NavigationSuiteScaffold (bottom bar
  on phones, navigation rail in landscape/on tablets); item colors keep the
  brand palette via NavigationSuiteItemColors(bar/rail/drawer)
- HomeScreen applies statusBarsPadding itself (the adaptive scaffold no
  longer pads the content area)
- predictive back was already enabled (android:enableOnBackInvokedCallback)
2026-10-05 00:39:09 +07:00
Denozordec 285d14e996 android: 2.13.15 - exponential direct-dst cooldown, double-start guard, flap-aware auto-connect
Log-driven fixes from the 10-04 diag/charon analysis:
- direct_tcp: negative-cache window now escalates 45s x2 up to 5 min
  (Telegram was opening ~3000 connects/h to each of two dead hosts,
  waking the radio for every leaked 45s probe); skip the unconditional
  fresh-socket retry on ECONNREFUSED (a refusal is deterministic on the
  network the socket is bound to; roam-time sockets are already aborted
  by reprotect)
- new dead-destination diag line (dst, fail count, cooldown) via JNI
  callback when the window escalates or hits every 100th failure
- VpnStateService: double-start guard - a second connect() for the same
  profile while a healthy attempt is in flight is ignored (four starters
  raced through here within 20 s after the 2.13.14 self-update)
- NetworkMonitor/AutoConnectManager: flap detection (>=2 kind changes in
  5 min stretches the auto-connect delay to 10 s; a Wi-Fi flap produced
  a 7-second-lived session and a wasted IKE handshake)
- hardcode diag TAGs obfuscated by R8 ("a: update: up to date" ->
  "UpdateManager: ..."), same for UpdateInstaller/VpnShortcutManager
2026-10-04 23:04:01 +07:00
Denozordec 3c73eb5a27 android: 2.13.14 - fix owner attribution protocol, log update events, ESP replay window context 2026-10-04 01:15:25 +07:00
Denozordec 95d88709f9 android: 2.13.11-2.13.13 release snapshot (battery backoff, direct-flow cooldown, owner attribution, rule-set editor) 2026-10-04 01:07:04 +07:00
39 changed files with 3732 additions and 795 deletions

No files matched your search

+16 -7
View File
@@ -31,14 +31,23 @@ def keystoreKeyPassword = envOr('KEY_PASSWORD', '')
android {
namespace = 'org.strongswan.android'
/* minSdk 23 flipped AGP's default to uncompressed .so (extractNativeLibs=false):
* the APK grew 12 -> 23 MB. Updates are downloaded over cellular, so keep
* the libraries compressed as before. */
packaging {
jniLibs {
useLegacyPackaging = true
}
}
defaultConfig {
applicationId "one.shx.strongswan.ext"
compileSdk = 36
minSdkVersion 21
minSdkVersion 23
targetSdkVersion 36
versionCode 147
versionName "2.13.10"
versionCode 159
versionName "2.14.6"
/* Base URL of the update storage, trailing slash included */
buildConfigField "String", "UPDATE_BASE_URL", "\"${updateBaseUrl}\""
@@ -146,15 +155,15 @@ dependencies {
implementation 'androidx.work:work-runtime-ktx:2.10.1'
implementation 'com.google.android.material:material:1.13.0'
/* BOM 2025.12.01 (compose 1.10) требует minSdk 23 — приложение поддерживает API 21.
2025.09.01 = первый BOM с material3 1.4.0 (Expressive) на compose 1.9 (minSdk 21). */
/* BOM 2025.12.01 (compose 1.10, Expressive-тема) — отдельная задача после
стабилизации; 2025.09.01 = material3 1.4.0 на compose 1.9. */
def composeBom = platform('androidx.compose:compose-bom:2025.09.01')
implementation composeBom
implementation 'androidx.compose.ui:ui'
implementation 'androidx.compose.material3:material3'
implementation 'androidx.compose.material3:material3-window-size-class'
/* adaptive-navigation-suite требует minSdk 23 (adaptive:1.2.0) — подключить на этапе 3
вместе с решением о minSdk; сейчас приложение поддерживает API 21 */
/* adaptive-navigation-suite (NavigationSuiteScaffold): BOM 1.4.0, требует minSdk 23 */
implementation 'androidx.compose.material3:material3-adaptive-navigation-suite'
implementation 'androidx.compose.material:material-icons-extended'
implementation 'androidx.activity:activity-compose:1.11.0'
implementation 'androidx.navigation:navigation-compose:2.9.4'
@@ -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;
}
@@ -539,6 +565,10 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe
mIsDisconnecting = true;
SimpleFetcher.disable();
deinitializeCharon();
/* the native engine (and its pushed set) is gone with charon:
* reset the flag so the next disabled push can early-return
* again until a fresh session pushes at CHILD_SA_UP */
sUserspacePushActive = false;
Log.i(TAG, "charon stopped");
DiagLog.i(TAG, "charon stopped");
mCurrentProfile = null;
@@ -1034,15 +1064,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 +1108,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 +1175,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 +1199,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 +1211,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 +1222,9 @@ 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]
+ (s.length > 20 ? ", fallbackHits=" + s[20] : "")
+ (s.length > 21 ? ", queueDrops=" + s[21] : ""));
}
private void scheduleRouteDiagnostics()
@@ -1344,6 +1408,84 @@ 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);
/* getConnectionOwnerUid expects an IP protocol (TCP or UDP only),
* not an address family - AF_INET here throws on every call */
int protocol = android.system.OsConstants.IPPROTO_TCP;
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;
rememberDeadOwner(dst, owner);
DiagLog.i(TAG, "direct tcp: dst=" + dst + " owner=" + owner);
}
catch (Exception e)
{
DiagLog.i(TAG, "direct tcp: owner lookup failed for dst=" + dst + ": " + e);
}
}
/**
* Called via JNI when a direct destination's failure cooldown escalates
* (the window grew) or crosses a failure milestone: surfaces the dead
* hosts that apps keep hammering directly, with the current penalty.
*
* @param dst preformatted "ip:port" of the destination
* @param fails failed connect cycles recorded for this destination
* @param cooldownS current cooldown window in seconds
*/
public void logDeadDestination(String dst, int fails, int cooldownS)
{
DiagLog.i(TAG, "direct tcp: dst=" + dst + " dead: " + fails
+ " failed connects, cooldown " + cooldownS + "s");
}
/**
* Called via JNI when a dead direct destination is rerouted through the
* tunnel: its failure cooldown escalated past the fallback bar, so the
* engine stops refusing the app's connects with RSTs and hands the host
* to the IPsec path instead (until the network changes or the failure
* state expires).
*
* @param dst preformatted "ip:port" of the destination
*/
public void logFallbackDestination(String dst)
{
DiagLog.i(TAG, "direct tcp: dst=" + dst + " dead: rerouting via tunnel");
}
/**
* Initialization of charon, provided by libandroidbridge.so
*
@@ -1384,12 +1526,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
@@ -1397,6 +1542,39 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe
*/
public static native long[] getRoutingStats();
/**
* Direct destinations currently refused by the failure cooldown (dead
* hosts that apps keep hammering), provided by libandroidbridge.so.
* Each entry is "ip:port|fails|window_ms|left_ms", most failures first.
*/
public static native String[] getDeadDirectTargets();
/** Last known owning app per dead destination (from owner attribution). */
private static final java.util.Map<String, String> sDeadOwners =
new java.util.LinkedHashMap<>();
/** Remember the app that kept hammering a now-dead destination (UI badge). */
private static void rememberDeadOwner(String dst, String owner)
{
synchronized (sDeadOwners)
{
sDeadOwners.put(dst, owner);
while (sDeadOwners.size() > 32)
{
sDeadOwners.remove(sDeadOwners.keySet().iterator().next());
}
}
}
/** The app last seen hammering the given dead destination, if known. */
public static String getDeadTargetOwner(String dst)
{
synchronized (sDeadOwners)
{
return sDeadOwners.get(dst);
}
}
/**
* Adapter for VpnService.Builder which is used to access it safely via JNI.
* There is a corresponding C object to access it from native code.
@@ -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)
@@ -88,6 +88,11 @@ public class VpnStateService extends Service
private boolean mRetryFrozen;
/* timestamp of the last network-change-triggered immediate retry */
private long mLastNetworkRetry;
/* double-start guard: profile and issue time of the last connect() that
* got through, cleared by disconnect() */
private static final long CONNECT_DEDUPE_MS = 10000;
private String mLastConnectUuid;
private long mLastConnectIssued;
private static final String TAG = VpnStateService.class.getSimpleName();
@@ -330,9 +335,12 @@ public class VpnStateService extends Service
*/
public void disconnect(boolean autoManaged)
{
/* reset any potential retry timer and error state */
/* reset any potential retry timer and error state, and re-arm the
* double-start guard (a connect after a deliberate disconnect is a
* fresh request and must not be deduped) */
resetRetryTimer();
setError(ErrorState.NO_ERROR);
mLastConnectIssued = 0;
if (autoManaged)
{ /* the auto-connect feature parked the tunnel on a trusted network;
@@ -400,6 +408,26 @@ public class VpnStateService extends Service
/* pass the previous password along */
profileInfo.putString(VpnProfileDataSource.KEY_PASSWORD, mProfile.getPassword());
}
String uuid = profileInfo.getString(VpnProfileDataSource.KEY_UUID);
if (uuid == null && mProfile != null)
{
uuid = mProfile.getUUID().toString();
}
/* double-start guard: while a healthy connect attempt for the same
* profile is in flight (or was just issued and the service has not
* reached startConnection() yet) a second start intent only churns
* the daemon - after an app self-update up to four starters (update
* receiver, process-start auto-connect, watchdog, UI) raced through
* here within 20 seconds. Deliberate retries during an error backoff
* (mError set) and requests for another profile always pass. */
if (uuid != null && uuid.equals(mLastConnectUuid) && mError == ErrorState.NO_ERROR
&& (mState == State.CONNECTING
|| ((mState == State.DISABLED || mState == State.DISCONNECTING)
&& SystemClock.uptimeMillis() - mLastConnectIssued < CONNECT_DEDUPE_MS)))
{
DiagLog.i(TAG, "connect: same profile already connecting, ignored");
return;
}
if (fromScratch)
{
/* reset if this is a manual retry or a new connection */
@@ -432,13 +460,10 @@ public class VpnStateService extends Service
/* remember that a connection is expected so the watchdog can restore
* it if this process dies (Always-on VPN is restarted by the system
* and bypasses this path anyway) */
String uuid = profileInfo.getString(VpnProfileDataSource.KEY_UUID);
if (uuid == null && mProfile != null)
{
uuid = mProfile.getUUID().toString();
}
if (uuid != null)
{
mLastConnectUuid = uuid;
mLastConnectIssued = SystemClock.uptimeMillis();
SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context);
if (!uuid.equals(prefs.getString(Constants.PREF_WATCHDOG_EXPECTED_PROFILE, null)))
{
@@ -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,21 @@ 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 - or a network kind that keeps
* bouncing - must not bounce the tunnel through full reconnects (one
* such flap produced a 7-second-lived session and a wasted IKE
* handshake). Require a longer stable-untrusted window before
* starting (the runnable re-evaluates and quietly gives up if a
* trusted Wi-Fi returned in between). */
val delay = if (SystemClock.elapsedRealtime() - lastParkAt < RECENT_PARK_WINDOW_MS || monitor.isFlapping())
{
FLAP_CONNECT_DELAY_MS
}
else
{
CONNECT_DELAY_MS
}
handler.postDelayed({
pendingEvaluate.set(false)
val svc = service
@@ -296,7 +315,7 @@ class AutoConnectManager private constructor(
startVpn(svc, uuid)
}
}
}, CONNECT_DELAY_MS)
}, delay)
}
private fun startVpn(svc: VpnStateService, uuid: String)
@@ -394,6 +413,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
@@ -23,6 +23,7 @@ import android.net.wifi.WifiInfo
import android.os.Build
import android.os.Handler
import android.os.Looper
import android.os.SystemClock
import android.util.Log
import org.strongswan.android.logic.DiagLog
import java.util.concurrent.ConcurrentHashMap
@@ -51,6 +52,8 @@ class NetworkMonitor(
private var registered = false
private var lastReported: AutoConnectManager.NetworkKind? = null
private var ssidRetries = 0
/* rolling log of network-kind change times, for flap detection (main thread only) */
private val kindChangeTimes = ArrayDeque<Long>()
private val events = object
{
@@ -74,12 +77,12 @@ class NetworkMonitor(
{
networks.remove(network)
ssids.remove(network)
scheduleEvaluate()
}
else
else if (remember(network, capabilities))
{
remember(network, capabilities)
scheduleEvaluate()
}
scheduleEvaluate()
}
}
@@ -134,6 +137,7 @@ class NetworkMonitor(
ssids.clear()
lastReported = null
ssidRetries = 0
kindChangeTimes.clear()
}
fun forceEvaluate()
@@ -190,12 +194,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 }
@@ -278,10 +309,42 @@ class NetworkMonitor(
Log.i(TAG, "Network kind $lastReported -> $kind")
DiagLog.i(TAG, "network kind $lastReported -> $kind")
lastReported = kind
recordKindChange(SystemClock.elapsedRealtime())
onNetworkChanged(kind)
}
}
/**
* Remember a kind change for [isFlapping], dropping entries that fell out
* of the flap window (plus a hard cap so a pathological callback storm
* cannot grow the log unbounded).
*/
private fun recordKindChange(now: Long)
{
kindChangeTimes.addLast(now)
while (kindChangeTimes.size > KIND_CHANGE_LOG_MAX ||
(kindChangeTimes.isNotEmpty() && now - kindChangeTimes.first() > FLAP_WINDOW_MS))
{
kindChangeTimes.removeFirst()
}
}
/**
* Whether the network kind is bouncing: at least [FLAP_MIN_CHANGES] kind
* changes (including the latest one) within [FLAP_WINDOW_MS]. Callers
* stretch out auto-connect delays while the underlying network settles
* instead of bouncing the tunnel through short-lived sessions.
*/
fun isFlapping(): Boolean
{
val now = SystemClock.elapsedRealtime()
while (kindChangeTimes.isNotEmpty() && now - kindChangeTimes.first() > FLAP_WINDOW_MS)
{
kindChangeTimes.removeFirst()
}
return kindChangeTimes.size >= FLAP_MIN_CHANGES
}
private val evaluateRunnable = Runnable { evaluateNow() }
companion object
@@ -290,5 +353,8 @@ class NetworkMonitor(
private const val DEBOUNCE_MS = 1500L
private const val SSID_RETRY_MS = 400L
private const val SSID_RETRY_MAX = 8
private const val FLAP_WINDOW_MS = 5 * 60_000L
private const val FLAP_MIN_CHANGES = 2
private const val KIND_CHANGE_LOG_MAX = 16
}
}
@@ -22,7 +22,9 @@ import kotlinx.coroutines.withContext
import java.io.IOException
import java.net.InetSocketAddress
import java.net.Socket
import java.net.URL
import java.util.concurrent.TimeUnit
import javax.net.ssl.HttpsURLConnection
/** What a probe measures. External targets go through the tunnel, SERVER goes direct. */
enum class ProbeTarget
@@ -42,6 +44,12 @@ data class ProbeResult(
val direct: Boolean = target == ProbeTarget.SERVER,
)
/** Public exit (as seen by the internet on the other end of the tunnel). */
data class ExitInfo(
val ip: String,
val country: String?,
)
/**
* TCP-connect "ping" for external targets and ICMP ping for the VPN server.
*
@@ -143,6 +151,53 @@ object ConnectivityProbe
return match.groupValues[1].toDoubleOrNull()?.toLong()
}
/**
* Resolve the public exit IP (and country code) as seen from the other
* end of the tunnel, via Cloudflare's cdn-cgi/trace. The plain
* connection rides the VPN (the app's default network while connected),
* so the answer reflects the tunnel egress. Returns null on any failure.
*/
suspend fun traceExit(timeoutMs: Int): ExitInfo? = withContext(Dispatchers.IO)
{
try
{
val connection = URL("https://www.cloudflare.com/cdn-cgi/trace").openConnection()
as HttpsURLConnection
try
{
connection.connectTimeout = timeoutMs
connection.readTimeout = timeoutMs
connection.instanceFollowRedirects = false
val text = connection.inputStream.bufferedReader().use { it.readText() }
parseTraceExit(text)
}
finally
{
connection.disconnect()
}
}
catch (_: Exception)
{
null
}
}
/** Extracts ip= / loc= from a cdn-cgi/trace body. Visible for tests. */
fun parseTraceExit(text: String): ExitInfo?
{
var ip: String? = null
var loc: String? = null
text.lineSequence().forEach { line ->
val value = line.substringAfter('=', "").trim()
when
{
line.startsWith("ip=") -> ip = value.takeIf { it.isNotEmpty() }
line.startsWith("loc=") -> loc = value.takeIf { it.isNotEmpty() }
}
}
return ip?.let { ExitInfo(ip = it, country = loc) }
}
private fun waitForOrTimeout(process: Process, timeoutMs: Long): Boolean
{
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O)
@@ -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.
@@ -45,7 +45,8 @@ class VpnShortcutManager private constructor(private val context: Context)
{
companion object
{
private val TAG = VpnShortcutManager::class.java.simpleName
/* hardcoded: the runtime class name is obfuscated by R8 in release builds */
private const val TAG = "VpnShortcutManager"
/** slots always occupied by the connect/disconnect/toggle shortcuts */
private const val RESERVED_SLOTS = 3
@@ -31,7 +31,8 @@ import java.io.File
*/
object UpdateInstaller
{
private val TAG = UpdateInstaller::class.java.simpleName
/* hardcoded: the runtime class name is obfuscated by R8 in release builds */
private const val TAG = "UpdateInstaller"
fun install(context: Context, apk: File)
{
@@ -47,6 +47,7 @@ import kotlinx.coroutines.launch
import org.json.JSONObject
import org.strongswan.android.BuildConfig
import org.strongswan.android.R
import org.strongswan.android.logic.DiagLog
import org.strongswan.android.logic.StrongSwanApplication
import org.strongswan.android.logic.VpnStateService
import org.strongswan.android.ui.MainActivity
@@ -102,7 +103,9 @@ class UpdateManager private constructor(private val context: Context)
{
companion object
{
private val TAG = UpdateManager::class.java.simpleName
/* hardcoded: the runtime class name is obfuscated by R8 in release
* builds, which turned the diag tag into "a:" */
private const val TAG = "UpdateManager"
private const val UPDATE_DIR = "update"
private const val APK_NAME = "update.apk"
@@ -174,11 +177,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)
{
@@ -231,11 +238,14 @@ class UpdateManager private constructor(private val context: Context)
mPrefs.edit().putLong(Constants.PREF_LAST_UPDATE_CHECK, System.currentTimeMillis()).apply()
if (info.versionCode > currentVersionCode())
{
DiagLog.i(TAG, "update: available ${info.versionName} (${info.versionCode}), " +
"installed ${currentVersionName()} (${currentVersionCode()})")
mState.value = UpdateState.Available(info)
maybeDownloadSilently(info)
}
else
{
DiagLog.i(TAG, "update: up to date (${currentVersionName()} ${currentVersionCode()})")
mState.value = UpdateState.UpToDate
}
}
@@ -250,29 +260,43 @@ class UpdateManager private constructor(private val context: Context)
*/
private fun maybeDownloadSilently(info: UpdateInfo)
{
if (!isSilentUpdatesEnabled() || isNetworkMetered() || mDownloadJob?.isActive == true)
if (!isSilentUpdatesEnabled())
{
DiagLog.i(TAG, "update: silent download disabled, waiting for manual install")
return
}
if (isNetworkMetered())
{
DiagLog.i(TAG, "update: silent download deferred (network metered)")
return
}
if (mDownloadJob?.isActive == true)
{
return
}
mDownloadJob = mScope.launch {
try
{
DiagLog.i(TAG, "update: downloading ${info.versionName} (${info.size} B)")
val apk = downloadApk(info)
verifySignature(apk)
if (isVpnDown() && canInstallSilently())
{
Log.i(TAG, "Applying the downloaded update silently")
DiagLog.i(TAG, "update: installing ${info.versionName} silently")
UpdateInstaller.install(context, apk)
mState.value = UpdateState.Idle
}
else
{
DiagLog.i(TAG, "update: ${info.versionName} downloaded, waiting for the user")
notifyUpdateReady(info)
}
}
catch (e: Exception)
{
Log.e(TAG, "Silent update download failed", e)
DiagLog.i(TAG, "update: silent download failed: ${e.javaClass.simpleName}: ${e.message}")
/* the manual download from the settings screen still works */
}
}
@@ -351,20 +375,24 @@ class UpdateManager private constructor(private val context: Context)
mDownloadJob = mScope.launch {
try
{
DiagLog.i(TAG, "update: downloading ${info.versionName} (manual)")
val apk = downloadApk(info)
verifySignature(apk)
DiagLog.i(TAG, "update: installing ${info.versionName} (manual)")
UpdateInstaller.install(context, apk)
mState.value = UpdateState.Idle
}
catch (e: UpdateException)
{
Log.e(TAG, "Update failed: ${e.kind}", e)
DiagLog.i(TAG, "update: install failed (${e.kind}): ${e.message}")
mState.value = UpdateState.Failed(e.kind, e.message)
cleanup()
}
catch (e: Exception)
{
Log.e(TAG, "Update failed", e)
DiagLog.i(TAG, "update: install failed: ${e.javaClass.simpleName}: ${e.message}")
mState.value = UpdateState.Failed(UpdateError.OTHER, e.message)
cleanup()
}
@@ -392,6 +420,7 @@ class UpdateManager private constructor(private val context: Context)
if (code != HttpURLConnection.HTTP_OK)
{
conn.disconnect()
DiagLog.i(TAG, "update: check failed: HTTP $code")
mState.value = UpdateState.Failed(UpdateError.NETWORK, "HTTP $code")
return null
}
@@ -410,6 +439,7 @@ class UpdateManager private constructor(private val context: Context)
catch (e: Exception)
{
Log.e(TAG, "Failed to fetch the update manifest", e)
DiagLog.i(TAG, "update: check failed: ${e.javaClass.simpleName}: ${e.message}")
mState.value = UpdateState.Failed(UpdateError.NETWORK, e.message)
return null
}
@@ -527,6 +557,18 @@ class UpdateManager private constructor(private val context: Context)
}
}
private fun currentVersionName(): String
{
return try
{
getPackageInfo()?.versionName ?: "?"
}
catch (e: Exception)
{
"?"
}
}
private fun getPackageInfo(): PackageInfo?
{
val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) PackageManager.GET_SIGNING_CERTIFICATES
@@ -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()
}
}
}
}
}
}
@@ -44,6 +44,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.itemsIndexed
@@ -51,18 +52,23 @@ import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Add
import androidx.compose.material.icons.rounded.AltRoute
import androidx.compose.material.icons.rounded.ArrowDownward
import androidx.compose.material.icons.rounded.ArrowUpward
import androidx.compose.material.icons.rounded.ChevronRight
import androidx.compose.material.icons.rounded.Delete
import androidx.compose.material.icons.rounded.Dns
import androidx.compose.material.icons.rounded.ExpandLess
import androidx.compose.material.icons.rounded.GppMaybe
import androidx.compose.material.icons.rounded.Language
import androidx.compose.material.icons.rounded.PlayArrow
import androidx.compose.material.icons.rounded.Public
import androidx.compose.material.icons.rounded.Refresh
import androidx.compose.material.icons.rounded.Schedule
import androidx.compose.material.icons.rounded.Share
import androidx.compose.material.icons.rounded.SignalCellularAlt
import androidx.compose.material.icons.rounded.Edit
import androidx.compose.material.icons.rounded.Wifi
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
@@ -136,6 +142,8 @@ fun HomeScreen(
val probes by viewModel.probes.collectAsStateWithLifecycle()
val probing by viewModel.probing.collectAsStateWithLifecycle()
val whiteList by viewModel.whiteList.collectAsStateWithLifecycle()
val exit by viewModel.exit.collectAsStateWithLifecycle()
val deadTargets by viewModel.deadTargets.collectAsStateWithLifecycle()
/* rolling RTT history per target, rendered as sparklines in the availability card */
val probeHistory = remember { mutableStateMapOf<ProbeTarget, SnapshotStateList<ProbeResult?>>() }
LaunchedEffect(probes)
@@ -191,7 +199,9 @@ fun HomeScreen(
StatusGlowBackground(color = statusColor, active = connected || connecting)
LazyColumn(
state = listState,
modifier = Modifier.fillMaxSize(),
/* the adaptive navigation scaffold no longer pads the content area,
* so the status headline offsets itself below the status bar */
modifier = Modifier.fillMaxSize().statusBarsPadding(),
horizontalAlignment = Alignment.CenterHorizontally,
)
{
@@ -256,6 +266,9 @@ fun HomeScreen(
probing = probing,
whiteList = whiteList,
history = probeHistory,
deadTargets = deadTargets,
onOpenRouting = { viewModel.setTab(ShxTab.ROUTING) },
onForceTunnel = viewModel::forceTunnelDeadTarget,
onRefresh = { viewModel.refreshProbes() },
)
}
@@ -338,6 +351,7 @@ fun HomeScreen(
traffic = traffic,
durationSec = durationSec,
probes = probes,
exit = exit,
onDismiss = { viewModel.openDetails(false) },
onDisconnect = {
viewModel.openDetails(false)
@@ -573,18 +587,31 @@ private fun ConnectionCard(
enter = expandVertically() + fadeIn(),
exit = shrinkVertically() + fadeOut(),
) {
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly,
verticalAlignment = Alignment.CenterVertically,
) {
TrafficStat(down = true, total = traffic.bytesIn, rate = traffic.rateIn)
Box(
modifier = Modifier
.size(1.dp, 32.dp)
.background(MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.6f)),
)
TrafficStat(down = false, total = traffic.bytesOut, rate = traffic.rateOut)
Column {
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly,
verticalAlignment = Alignment.CenterVertically,
) {
TrafficStat(down = true, total = traffic.bytesIn, rate = traffic.rateIn)
Box(
modifier = Modifier
.size(1.dp, 32.dp)
.background(MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.6f)),
)
TrafficStat(down = false, total = traffic.bytesOut, rate = traffic.rateOut)
}
/* one-glance confirmation that the RU split is working */
traffic.split?.takeIf { it.directBytes > 0 }?.let { split ->
Text(
text = stringResource(R.string.traffic_direct_share_fmt, split.directShare),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
.align(Alignment.CenterHorizontally)
.padding(top = 6.dp),
)
}
}
}
when (vpnState)
@@ -617,6 +644,7 @@ private fun ConnectionDetailsSheet(
traffic: TrafficUi,
durationSec: Long,
probes: Map<ProbeTarget, ProbeResult>,
exit: ExitUi?,
onDismiss: () -> Unit,
onDisconnect: () -> Unit,
)
@@ -658,6 +686,36 @@ private fun ConnectionDetailsSheet(
label = stringResource(R.string.traffic_up),
value = "${formatBytes(traffic.bytesOut)} · ${formatRate(traffic.rateOut)}",
)
/* RU-split session breakdown; null in the system-routes mode where
* the engine never sees the direct traffic */
traffic.split?.let { split ->
DetailRow(
icon = Icons.Rounded.AltRoute,
label = stringResource(R.string.traffic_split_tunnel),
value = "${formatBytes(split.tunnelBytes)} · ${formatRate(split.tunnelRate)}",
)
DetailRow(
icon = Icons.Rounded.AltRoute,
label = stringResource(R.string.traffic_split_direct),
value = "${formatBytes(split.directBytes)} · ${formatRate(split.directRate)}",
)
}
exit?.let { e ->
e.ip?.let { ip ->
DetailRow(
icon = Icons.Rounded.Public,
label = stringResource(R.string.exit_ip_label),
value = e.country?.let { "$ip · $it" } ?: ip,
)
}
e.network?.let { network ->
DetailRow(
icon = if (e.ssid != null) Icons.Rounded.Wifi else Icons.Rounded.SignalCellularAlt,
label = stringResource(R.string.network_label),
value = e.ssid?.let { "$network · $it" } ?: network,
)
}
}
if (probes.isNotEmpty())
{
probes.sortedForDisplay().forEach { result ->
@@ -795,6 +853,9 @@ private fun AvailabilityCard(
probing: Boolean,
whiteList: WhiteListState,
history: Map<ProbeTarget, List<ProbeResult?>>,
deadTargets: List<DeadTargetUi>,
onOpenRouting: () -> Unit,
onForceTunnel: (String) -> Unit,
onRefresh: () -> Unit,
)
{
@@ -863,6 +924,49 @@ private fun AvailabilityCard(
}
}
}
if (deadTargets.isNotEmpty())
{
Spacer(Modifier.height(8.dp))
ShxDivider()
Spacer(Modifier.height(8.dp))
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
modifier = Modifier
.fillMaxWidth()
.combinedClickable(onClick = onOpenRouting)
.padding(vertical = 4.dp),
) {
Box(
modifier = Modifier
.size(8.dp)
.background(MaterialTheme.colorScheme.error, CircleShape),
)
Text(
text = stringResource(R.string.dead_targets_title),
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f),
)
Icon(
Icons.Rounded.ChevronRight,
contentDescription = stringResource(R.string.dead_targets_open),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
deadTargets.take(3).forEach { target ->
DeadTargetRow(target = target, onForceTunnel = onForceTunnel)
}
if (deadTargets.size > 3)
{
Text(
text = stringResource(R.string.dead_targets_more_fmt, deadTargets.size - 3),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 18.dp, top = 4.dp),
)
}
}
if (whiteList.config.enabled)
{
Spacer(Modifier.height(8.dp))
@@ -890,10 +994,60 @@ private fun AvailabilityCard(
}
}
/** A dead direct destination: address, the app hammering it, refusal stats,
* and a one-tap action to force the host through the tunnel. */
@Composable
private fun DirectProbeRow(label: String, ok: Boolean?, rttMs: Long?)
private fun DeadTargetRow(target: DeadTargetUi, onForceTunnel: (String) -> Unit)
{
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
modifier = Modifier
.fillMaxWidth()
.padding(start = 18.dp, top = 6.dp, bottom = 2.dp),
) {
Column(Modifier.weight(1f)) {
Text(
text = target.dst,
style = MaterialTheme.typography.bodyMedium,
fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace,
)
Text(
text = listOfNotNull(
target.owner,
stringResource(R.string.dead_targets_fails_fmt, target.fails),
stringResource(R.string.dead_targets_cooldown_fmt, formatDuration(target.leftMs / 1000)),
).joinToString(" · "),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (target.viaTunnel)
{
Text(
text = stringResource(R.string.dead_targets_via_tunnel),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary,
)
}
else
{
TextButton(
onClick = { onForceTunnel(target.dst) },
contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 8.dp),
) {
Text(
text = stringResource(R.string.dead_targets_force),
style = MaterialTheme.typography.labelMedium,
)
}
}
}
}
@Composable
private fun DirectProbeRow(label: String, ok: Boolean?, rttMs: Long?)
{ Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
@@ -23,6 +23,8 @@ import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.ServiceConnection
import android.net.ConnectivityManager
import android.net.NetworkCapabilities
import android.os.IBinder
import android.os.SystemClock
import android.widget.Toast
@@ -50,6 +52,7 @@ import org.strongswan.android.logic.VpnProfileExporter
import org.strongswan.android.logic.VpnStateService
import org.strongswan.android.logic.VpnStateService.ErrorState
import org.strongswan.android.logic.VpnStateService.State
import org.strongswan.android.logic.routing.RoutingManager
import org.strongswan.android.logic.connectivity.ConnectivityProbe
import org.strongswan.android.logic.connectivity.HealthCheckManager
import org.strongswan.android.logic.connectivity.ProbeResult
@@ -57,6 +60,7 @@ import org.strongswan.android.logic.connectivity.ProbeTarget
import org.strongswan.android.logic.connectivity.ServerCheckMode
import org.strongswan.android.logic.connectivity.WhiteListManager
import org.strongswan.android.logic.connectivity.WhiteListState
import org.strongswan.android.logic.autoconnect.SsidReader
import org.strongswan.android.ui.VpnProfileControlActivity
import org.strongswan.android.utils.Constants
@@ -66,6 +70,38 @@ data class TrafficUi(
val rateIn: Long = 0,
val rateOut: Long = 0,
val durationSec: Long = 0,
/** Session split between the tunnel and direct RU traffic; null when the userspace engine is not active. */
val split: SplitTrafficUi? = null,
)
data class SplitTrafficUi(
val tunnelBytes: Long = 0,
val directBytes: Long = 0,
val tunnelRate: Long = 0,
val directRate: Long = 0,
)
{
val directShare: Int
get() = if (tunnelBytes + directBytes <= 0) 0 else ((directBytes * 100) / (tunnelBytes + directBytes)).toInt()
}
/** Public exit (ip/country as seen past the tunnel) and the underlying network. */
data class ExitUi(
val ip: String? = null,
val country: String? = null,
val network: String? = null,
val ssid: String? = null,
)
/** A direct destination currently refused by the engine's failure cooldown. */
data class DeadTargetUi(
val dst: String,
val fails: Long,
val windowMs: Long,
val leftMs: Long,
val owner: String? = null,
/** the engine already reroutes this host through the tunnel */
val viaTunnel: Boolean = false,
)
enum class ShxTab
@@ -107,6 +143,15 @@ class HomeViewModel(app: Application) : AndroidViewModel(app), VpnStateService.V
private val _probing = MutableStateFlow(false)
val probing: StateFlow<Boolean> = _probing
/* public exit (ip/country) and the underlying network; refreshed with the
* probe round, cached for EXIT_REFRESH_MS */
private val _exit = MutableStateFlow<ExitUi?>(null)
val exit: StateFlow<ExitUi?> = _exit
/* direct destinations currently refused by the engine's cooldown */
private val _deadTargets = MutableStateFlow<List<DeadTargetUi>>(emptyList())
val deadTargets: StateFlow<List<DeadTargetUi>> = _deadTargets
private var service: VpnStateService? = null
/* written from the main thread (pullState) and the poll coroutine (Default) */
@@ -114,6 +159,11 @@ class HomeViewModel(app: Application) : AndroidViewModel(app), VpnStateService.V
@Volatile private var lastOut = 0L
@Volatile private var lastAt = 0L
@Volatile private var connectedAt = 0L
@Volatile private var lastExitAt = 0L
@Volatile private var splitAvailable = false
@Volatile private var lastTunnelBytes = 0L
@Volatile private var lastDirectBytes = 0L
@Volatile private var lastSplitAt = 0L
private val connection = object : ServiceConnection
{
@@ -180,7 +230,11 @@ class HomeViewModel(app: Application) : AndroidViewModel(app), VpnStateService.V
fun refreshProbes()
{
WhiteListManager.getInstance(getApplication()).checkNow()
viewModelScope.launch { probeNow() }
viewModelScope.launch {
probeNow()
refreshExit(force = true)
refreshDeadTargets()
}
}
private suspend fun probeNow()
@@ -202,6 +256,8 @@ class HomeViewModel(app: Application) : AndroidViewModel(app), VpnStateService.V
}
addAll(healthConfig.targets)
}
refreshDeadTargets()
viewModelScope.launch { refreshExit(force = false) }
if (targets.isEmpty())
{
return
@@ -367,12 +423,19 @@ class HomeViewModel(app: Application) : AndroidViewModel(app), VpnStateService.V
lastIn = 0
lastOut = 0
lastAt = 0
splitAvailable = CharonVpnService.isUserspaceRoutingEnabled(getApplication())
lastTunnelBytes = 0
lastDirectBytes = 0
lastSplitAt = 0
}
if (state != State.CONNECTED)
{
connectedAt = 0
_traffic.value = TrafficUi()
_probes.value = emptyMap()
_exit.value = null
_deadTargets.value = emptyList()
lastExitAt = 0
}
/* the retry countdown notifies every second; skip the update when nothing
* actually changed so the Home screen doesn't recompose for no reason */
@@ -422,13 +485,166 @@ class HomeViewModel(app: Application) : AndroidViewModel(app), VpnStateService.V
rateIn = rateIn,
rateOut = rateOut,
durationSec = (now - startedAt) / 1000,
split = if (splitAvailable) splitTraffic(now) else null,
)
}
/**
* Session split between the tunnel and direct RU traffic from the native
* engine counters (s[1] tunnel bytes, s[3] direct bytes). Only available
* in the userspace routing mode - with system routes the engine never
* sees the direct traffic.
*/
private suspend fun splitTraffic(now: Long): SplitTrafficUi
{
val stats = try
{
withContext(Dispatchers.Default) { CharonVpnService.getRoutingStats() }
}
catch (_: Throwable)
{
null
}
if (stats == null || stats.size < 4)
{
return SplitTrafficUi(tunnelBytes = lastTunnelBytes, directBytes = lastDirectBytes)
}
val tunnel = stats[1].coerceAtLeast(0)
val direct = stats[3].coerceAtLeast(0)
val dt = if (lastSplitAt == 0L) 0.0 else ((now - lastSplitAt) / 1000.0).coerceAtLeast(0.2)
val tunnelRate = if (lastSplitAt == 0L) 0 else ((tunnel - lastTunnelBytes) / dt).toLong().coerceAtLeast(0)
val directRate = if (lastSplitAt == 0L) 0 else ((direct - lastDirectBytes) / dt).toLong().coerceAtLeast(0)
lastTunnelBytes = tunnel
lastDirectBytes = direct
lastSplitAt = now
return SplitTrafficUi(tunnelBytes = tunnel, directBytes = direct, tunnelRate = tunnelRate, directRate = directRate)
}
/**
* Public exit ip/country via Cloudflare trace through the tunnel, plus
* the current underlying network label. Keeps the last known answer on
* failure (the sheet simply omits the rows before the first success).
*/
private suspend fun refreshExit(force: Boolean)
{
val now = SystemClock.elapsedRealtime()
if (!force && now - lastExitAt < EXIT_REFRESH_MS)
{
return
}
if (_ui.value.vpnState != State.CONNECTED)
{
return
}
val info = ConnectivityProbe.traceExit(EXIT_TIMEOUT_MS) ?: return
lastExitAt = now
val ctx = getApplication<Application>()
val (network, ssid) = underlyingNetworkLabel(ctx)
_exit.value = ExitUi(ip = info.ip, country = info.country, network = network, ssid = ssid)
}
private fun underlyingNetworkLabel(ctx: Context): Pair<String?, String?>
{
val cm = ctx.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
?: return null to null
/* Android keeps the cellular network agent alive in parallel with
* Wi-Fi (allNetworks order is unspecified), so Wi-Fi wins if present -
* the same precedence NetworkMonitor uses for the trust decision */
var wifiCaps: NetworkCapabilities? = null
var cellular = false
for (network in cm.allNetworks)
{
val caps = try
{
cm.getNetworkCapabilities(network)
}
catch (_: Exception)
{
null
} ?: continue
if (caps.hasTransport(NetworkCapabilities.TRANSPORT_VPN))
{
continue
}
if (caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI))
{
wifiCaps = caps
}
else if (caps.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR))
{
cellular = true
}
}
if (wifiCaps != null)
{
return ctx.getString(R.string.network_wifi) to SsidReader.from(ctx, wifiCaps)
}
if (cellular)
{
return ctx.getString(R.string.network_cellular) to null
}
return null to null
}
/**
* Adds a /32 (or /128) force-tunnel prefix for a dead "ip:port" target,
* so the host permanently leaves the direct set. Applied on the fly,
* no reconnect needed.
*/
fun forceTunnelDeadTarget(dst: String)
{
val ip = dst.substringBeforeLast(':').removeSurrounding("[", "]")
val bits = if (ip.contains(':')) 128 else 32
/* on the main dispatcher like every other RoutingManager call:
* its config mutations are read-modify-write and not thread-safe
* across dispatchers */
viewModelScope.launch {
RoutingManager.getInstance(getApplication()).addForcePrefix("$ip/$bits")
}
}
/** Snapshot of the direct destinations the engine currently refuses. */
private suspend fun refreshDeadTargets()
{
if (_ui.value.vpnState != State.CONNECTED)
{
return
}
val raw = try
{
withContext(Dispatchers.Default) { CharonVpnService.getDeadDirectTargets() }
}
catch (_: Throwable)
{
null
} ?: return
_deadTargets.value = parseDeadTargets(raw)
}
companion object
{
private const val TRAFFIC_POLL_MS = 1000L
private const val PROBE_TICK_MS = 1000L
private const val PROBE_INTERVAL_MS = 10_000L
private const val EXIT_REFRESH_MS = 60_000L
private const val EXIT_TIMEOUT_MS = 5000
/** Parses "ip:port|fails|window_ms|left_ms|via_tunnel" entries of the native snapshot. */
fun parseDeadTargets(raw: Array<String>?): List<DeadTargetUi> =
raw?.mapNotNull { entry ->
val parts = entry.split('|')
if (parts.size < 4)
{
return@mapNotNull null
}
DeadTargetUi(
dst = parts[0],
fails = parts[1].toLongOrNull() ?: 0,
windowMs = parts[2].toLongOrNull() ?: 0,
leftMs = parts[3].toLongOrNull() ?: 0,
owner = CharonVpnService.getDeadTargetOwner(parts[0]),
viaTunnel = parts.getOrNull(4) == "1",
)
}?.sortedByDescending { it.fails } ?: emptyList()
}
}
@@ -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))
@@ -24,10 +24,8 @@ 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.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons
@@ -43,13 +41,15 @@ import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Checkbox
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.NavigationBarItemDefaults
import androidx.compose.material3.Scaffold
import androidx.compose.material3.NavigationDrawerItemDefaults
import androidx.compose.material3.NavigationRailItemDefaults
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteItemColors
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteScope
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteScaffold
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@@ -57,6 +57,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.compositeOver
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
@@ -107,17 +108,18 @@ fun ShxApp(viewModel: HomeViewModel = viewModel())
/* the home list keeps its scroll state across tab switches */
val homeListState = rememberLazyListState()
Scaffold(
bottomBar = {
ShxBottomBar(selected = state.tab, onSelect = viewModel::setTab)
/* adaptive navigation: bottom bar on phones, navigation rail in landscape
* and on tablets (Stage 3 of ui-ux-plan). Item colors are built here in a
* composable context - the items lambda itself does not compose. */
val navItemColors = shxNavigationItemColors()
NavigationSuiteScaffold(
navigationSuiteItems = {
ShxNavigationSuiteItems(selected = state.tab, onSelect = viewModel::setTab, colors = navItemColors)
},
containerColor = MaterialTheme.colorScheme.background,
) { padding ->
) {
Surface(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.consumeWindowInsets(padding),
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background,
) {
AnimatedContent(
@@ -206,32 +208,62 @@ private fun ShareProfileDialog(profile: VpnProfile, onDismiss: () -> Unit, onCon
)
}
/** Item colors for the adaptive navigation suite, keeping the brand palette. */
@Composable
private fun ShxBottomBar(selected: ShxTab, onSelect: (ShxTab) -> Unit)
private fun shxNavigationItemColors(): NavigationSuiteItemColors
{
val bg = MaterialTheme.colorScheme.surfaceContainer
val indicator = MaterialTheme.colorScheme.primary.copy(alpha = 0.32f).compositeOver(bg)
NavigationBar(containerColor = bg, tonalElevation = 0.dp) {
ShxTab.entries.forEach { tab ->
NavigationBarItem(
selected = tab == selected,
onClick = { onSelect(tab) },
icon = {
Icon(
imageVector = tab.icon(selected = tab == selected),
contentDescription = null,
)
},
label = { Text(stringResource(tab.label())) },
colors = NavigationBarItemDefaults.colors(
selectedIconColor = MaterialTheme.colorScheme.primary,
unselectedIconColor = MaterialTheme.colorScheme.onSurfaceVariant,
selectedTextColor = MaterialTheme.colorScheme.primary,
unselectedTextColor = MaterialTheme.colorScheme.onSurfaceVariant,
indicatorColor = indicator,
),
)
}
return NavigationSuiteItemColors(
navigationBarItemColors = NavigationBarItemDefaults.colors(
selectedIconColor = MaterialTheme.colorScheme.primary,
unselectedIconColor = MaterialTheme.colorScheme.onSurfaceVariant,
selectedTextColor = MaterialTheme.colorScheme.primary,
unselectedTextColor = MaterialTheme.colorScheme.onSurfaceVariant,
indicatorColor = indicator,
),
navigationRailItemColors = NavigationRailItemDefaults.colors(
selectedIconColor = MaterialTheme.colorScheme.primary,
unselectedIconColor = MaterialTheme.colorScheme.onSurfaceVariant,
selectedTextColor = MaterialTheme.colorScheme.primary,
unselectedTextColor = MaterialTheme.colorScheme.onSurfaceVariant,
indicatorColor = indicator,
),
navigationDrawerItemColors = NavigationDrawerItemDefaults.colors(
selectedContainerColor = indicator,
unselectedContainerColor = Color.Transparent,
selectedIconColor = MaterialTheme.colorScheme.primary,
unselectedIconColor = MaterialTheme.colorScheme.onSurfaceVariant,
selectedTextColor = MaterialTheme.colorScheme.primary,
unselectedTextColor = MaterialTheme.colorScheme.onSurfaceVariant,
),
)
}
/**
* Registers the app tabs as navigation suite items. The scope's item() is a
* plain registration function - only the icon/label lambdas compose - so
* colors are built outside (in a composable context) and passed in.
*/
private fun NavigationSuiteScope.ShxNavigationSuiteItems(
selected: ShxTab,
onSelect: (ShxTab) -> Unit,
colors: NavigationSuiteItemColors,
)
{
ShxTab.entries.forEach { tab ->
item(
selected = tab == selected,
onClick = { onSelect(tab) },
icon = {
Icon(
imageVector = tab.icon(selected = tab == selected),
contentDescription = null,
)
},
label = { Text(stringResource(tab.label())) },
colors = colors,
)
}
}
@@ -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
@@ -1,3 +1,6 @@
APP_PLATFORM := android-21
APP_PLATFORM := android-23
APP_SUPPORT_FLEXIBLE_PAGE_SIZES := true
APP_ABI := armeabi-v7a arm64-v8a x86_64
# minSdk 23: the NDK sysroot declares sigwaitinfo() (introduced in API 23),
# so use the system implementation instead of strongSwan's fallback prototype
APP_CFLAGS += -DHAVE_SIGWAITINFO
@@ -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,137 @@ 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();
}
/**
* Tell Java a direct destination is dead and how long it is being refused
* for (diagnostics; the engine calls this when the cooldown window escalates
* or a failure milestone is crossed). Best effort.
*/
void charonservice_log_dead_destination(const char *dst, uint32_t fails,
uint32_t cooldown_s)
{
private_charonservice_t *this = (private_charonservice_t*)charonservice;
JNIEnv *env;
jmethodID method_id;
jstring jdst;
if (!this || !dst)
{
return;
}
androidjni_attach_thread(&env);
jdst = (*env)->NewStringUTF(env, dst);
method_id = (*env)->GetMethodID(env, android_charonvpnservice_class,
"logDeadDestination",
"(Ljava/lang/String;II)V");
if (!jdst || !method_id)
{
goto failed;
}
(*env)->CallVoidMethod(env, this->vpn_service, method_id,
jdst, (jint)fails, (jint)cooldown_s);
failed:
androidjni_exception_occurred(env);
androidjni_detach_thread();
}
/**
* Tell Java a dead direct destination was rerouted through the tunnel (its
* cooldown escalated past the fallback bar). Best effort; at most once per
* insertion.
*/
void charonservice_log_fallback_destination(const char *dst)
{
private_charonservice_t *this = (private_charonservice_t*)charonservice;
JNIEnv *env;
jmethodID method_id;
jstring jdst;
if (!this || !dst)
{
return;
}
androidjni_attach_thread(&env);
jdst = (*env)->NewStringUTF(env, dst);
method_id = (*env)->GetMethodID(env, android_charonvpnservice_class,
"logFallbackDestination",
"(Ljava/lang/String;)V");
if (!jdst || !method_id)
{
goto failed;
}
(*env)->CallVoidMethod(env, this->vpn_service, method_id, 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,23 +1231,35 @@ 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();
* in mode A none of its code runs at all. It may also only be CREATED
* while the charon libraries are alive: the engine thread registers in
* the strongSwan threading table that library_deinit() frees on every
* disconnect, so a settings toggle pushed from the UI while parked is
* a use-after-free crash - the set is pushed again on the next connect.
* charon_mutex closes the remaining race against a concurrent
* disconnect destroying the engine under the same mutex */
direct_engine_t *engine;
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;
pthread_mutex_lock(&charon_mutex);
engine = (enabled && charon_running) ?
direct_engine_ensure() : direct_engine_get();
if (!engine)
{
pthread_mutex_unlock(&charon_mutex);
return;
}
@@ -1124,6 +1269,7 @@ JNI_METHOD(CharonVpnService, setUserspaceRoutingData, void,
{ /* malformed input, keep the previous set */
free(v4f);
free(v4t);
pthread_mutex_unlock(&charon_mutex);
return;
}
v6f = bytes_to_v6(env, jv6from, &n6);
@@ -1135,14 +1281,43 @@ JNI_METHOD(CharonVpnService, setUserspaceRoutingData, void,
free(v4t);
free(v6f);
free(v6t);
pthread_mutex_unlock(&charon_mutex);
return;
}
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);
pthread_mutex_unlock(&charon_mutex);
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);
pthread_mutex_unlock(&charon_mutex);
return;
}
ft4 = longs_to_v4(env, jft4, &nf4);
ft6 = bytes_to_v6(env, jft6, &nf6);
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);
pthread_mutex_unlock(&charon_mutex);
}
/**
@@ -1152,7 +1327,7 @@ JNI_METHOD(CharonVpnService, getRoutingStats, jlongArray)
{
direct_engine_t *engine = direct_engine_get();
direct_stats_t s = {};
jlong v[19];
jlong v[22];
jlongArray result;
memset(&s, 0, sizeof(s));
@@ -1179,9 +1354,54 @@ 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;
v[20] = (jlong)s.fallback_hits;
v[21] = (jlong)s.queue_drops;
result = (*env)->NewLongArray(env, 19);
(*env)->SetLongArrayRegion(env, result, 0, 19, v);
result = (*env)->NewLongArray(env, 22);
(*env)->SetLongArrayRegion(env, result, 0, 22, v);
return result;
}
/**
* Snapshot of direct destinations currently refused by the failure cooldown
* (dead hosts that apps keep hammering). Each entry is formatted as
* "ip:port|fails|window_ms|left_ms|via_tunnel", most failures first.
*/
JNI_METHOD(CharonVpnService, getDeadDirectTargets, jobjectArray)
{
direct_engine_t *engine = direct_engine_get();
direct_dead_target_t t[DIRECT_DEAD_MAX];
jobjectArray result;
jclass cls;
size_t i, n = 0;
/* stack snapshot: zero it so no field can ever leak garbage */
memset(t, 0, sizeof(t));
if (engine)
{
n = engine->get_dead_targets(engine, t, DIRECT_DEAD_MAX);
}
cls = (*env)->FindClass(env, "java/lang/String");
if (!cls)
{
androidjni_exception_occurred(env);
return NULL;
}
result = (*env)->NewObjectArray(env, n, cls, NULL);
for (i = 0; i < n; i++)
{
char buf[160];
jstring str;
snprintf(buf, sizeof(buf), "%s|%u|%u|%u|%u",
t[i].dst, t[i].fails, t[i].cooldown_ms, t[i].cooldown_left_ms,
t[i].via_tunnel ? 1 : 0);
str = (*env)->NewStringUTF(env, buf);
(*env)->SetObjectArrayElement(env, result, i, str);
(*env)->DeleteLocalRef(env, str);
}
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,28 @@ 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);
/**
* Log (via Java) a direct destination whose failure cooldown escalated (the
* window grew) or crossed a failure milestone: surfaces the dead hosts apps
* keep hammering. Best effort.
*/
void charonservice_log_dead_destination(const char *dst, uint32_t fails,
uint32_t cooldown_s);
/**
* Log (via Java) that a dead direct destination was rerouted through the
* tunnel (its cooldown escalated past the fallback bar). Best effort; the
* engine calls this at most once per insertion.
*/
void charonservice_log_fallback_destination(const char *dst);
#endif /** CHARONSERVICE_H_ @}*/
@@ -34,21 +34,54 @@ extern void lwip_assert_diag(const char *msg);
#include <unistd.h>
/**
* Poll set capacity; beyond that flows are simply not polled for one round
* (the wakeup pipe keeps the loop spinning at 250ms worst case)
* Poll set capacity. The set always fits: TCP relays are capped at
* DIRECT_MAX_TCP_RELAYS, UDP flows at FLOW_SLOTS (256), plus the wakeup
* pipe - a flow beyond the caps is refused at admission instead of being
* silently never polled.
*/
#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)
*/
#define IP4_FRAG_MASK 0x3fff
/**
* Tunnel fallback for dead direct hosts: after the failure cooldown of a
* destination escalated (direct_tcp), its whole address is rerouted through
* the tunnel here - reactive only, entries appear from existing connect
* failures and vanish with the failure state. Guards: FALLBACK_TTL_MS
* without a new failure, any network change, any TUN unbind.
*/
#define FALLBACK_SLOTS 16
#define FALLBACK_TTL_MS (10 * 60 * 1000)
typedef struct {
bool used;
bool v6;
uint8_t addr[16];
uint64_t last_fail;
} fallback_entry_t;
typedef struct private_direct_engine_t private_direct_engine_t;
/**
@@ -75,6 +108,12 @@ struct private_direct_engine_t {
ru_set_t *set;
bool enabled;
/**
* Dead direct hosts rerouted through the tunnel (see FALLBACK_* above),
* guarded by set_lock like the classification set
*/
fallback_entry_t fallback[FALLBACK_SLOTS];
/**
* TUN binding, guarded by bind_lock
*/
@@ -146,6 +185,134 @@ static bool reprotect_pending;
static int parse_protocol(chunk_t enc);
/**
* Monotonic clock in milliseconds (matches sys_now() in direct_tcp.c);
* defined below the engine thread helpers, declared here for the fallback
* table
*/
static uint64_t monotonic_ms(void);
/**
* Is the address currently rerouted through the tunnel? Caller holds
* set_lock (read); linear over a tiny fixed array, only reached for
* destinations the RU set already classified as direct.
*/
static bool fallback_addr(private_direct_engine_t *this, bool v6, const void *addr)
{
size_t i, len = v6 ? 16 : 4;
for (i = 0; i < FALLBACK_SLOTS; i++)
{
fallback_entry_t *e = &this->fallback[i];
if (e->used && e->v6 == v6 && memcmp(e->addr, addr, len) == 0)
{
return TRUE;
}
}
return FALSE;
}
METHOD(direct_engine_t, mark_fallback, bool,
private_direct_engine_t *this, bool v6, const void *addr)
{
uint64_t now = monotonic_ms();
size_t i, len = v6 ? 16 : 4;
fallback_entry_t *slot;
this->set_lock->write_lock(this->set_lock);
for (i = 0; i < FALLBACK_SLOTS; i++)
{
fallback_entry_t *e = &this->fallback[i];
if (e->used && e->v6 == v6 && memcmp(e->addr, addr, len) == 0)
{
e->last_fail = now;
this->set_lock->unlock(this->set_lock);
return FALSE;
}
}
/* first free slot, else recycle the least recently failed one */
slot = &this->fallback[0];
for (i = 0; i < FALLBACK_SLOTS; i++)
{
if (!this->fallback[i].used)
{
slot = &this->fallback[i];
break;
}
if (this->fallback[i].last_fail < slot->last_fail)
{
slot = &this->fallback[i];
}
}
memset(slot, 0, sizeof(*slot));
slot->used = TRUE;
slot->v6 = v6;
memcpy(slot->addr, addr, len);
slot->last_fail = now;
this->set_lock->unlock(this->set_lock);
return TRUE;
}
/**
* Drop all fallback entries (network change, TUN unbind: the direct path
* may work again). Skips the lock entirely in the common empty case.
*/
static void fallback_clear_all(private_direct_engine_t *this)
{
size_t i;
for (i = 0; i < FALLBACK_SLOTS; i++)
{
if (this->fallback[i].used)
{
break;
}
}
if (i == FALLBACK_SLOTS)
{
return;
}
this->set_lock->write_lock(this->set_lock);
memset(this->fallback, 0, sizeof(this->fallback));
this->set_lock->unlock(this->set_lock);
}
/**
* Expire entries whose failure state went quiet (piggybacks on the engine
* loop's existing sweep cadence - no extra wakeups)
*/
static void fallback_sweep(private_direct_engine_t *this, uint64_t now)
{
size_t i;
bool any = FALSE;
for (i = 0; i < FALLBACK_SLOTS; i++)
{
if (this->fallback[i].used)
{
any = TRUE;
break;
}
}
if (!any)
{
return;
}
this->set_lock->write_lock(this->set_lock);
for (i = 0; i < FALLBACK_SLOTS; i++)
{
fallback_entry_t *e = &this->fallback[i];
if (e->used && now - e->last_fail >= FALLBACK_TTL_MS)
{ /* no new failures for the whole TTL: let the direct path retry */
e->used = FALSE;
}
}
this->set_lock->unlock(this->set_lock);
}
void direct_engine_request_reprotect(void)
{
__atomic_store_n(&reprotect_pending, TRUE, __ATOMIC_RELEASE);
@@ -170,45 +337,74 @@ METHOD(direct_engine_t, handle, bool,
this->set_lock->read_lock(this->set_lock);
if (this->enabled && this->set)
{
if (addr.len == 4)
{ /* network order (big-endian) bytes as one numeric value - the
* same representation the Java side sorts its ranges by; a plain
* memcpy would reverse the byte order on little-endian ARM and
* break the binary search completely */
uint32_t a = ((uint32_t)addr.ptr[0] << 24) |
((uint32_t)addr.ptr[1] << 16) |
((uint32_t)addr.ptr[2] << 8) |
(uint32_t)addr.ptr[3];
if (addr.len == 4)
{ /* network order (big-endian) bytes as one numeric value - the
* same representation the Java side sorts its ranges by; a plain
* memcpy would reverse the byte order on little-endian ARM and
* break the binary search completely */
uint32_t a = ((uint32_t)addr.ptr[0] << 24) |
((uint32_t)addr.ptr[1] << 16) |
((uint32_t)addr.ptr[2] << 8) |
(uint32_t)addr.ptr[3];
if (!this->set->force_tunnel_v4(this->set, a) &&
this->set->contains_v4(this->set, a))
{
direct = TRUE;
if (!this->set->force_tunnel_v4(this->set, a) &&
this->set->contains_v4(this->set, a))
{
if (fallback_addr(this, FALSE, addr.ptr))
{ /* dead direct host rerouted by its failure cooldown */
DIRECT_STATS_ADD(this->stats.fallback_hits, 1);
}
else
{
direct = TRUE;
}
}
}
}
else if (addr.len == 16)
{ /* memcmp order == network order for v6, no conversion needed */
const uint8_t *a = (uint8_t*)addr.ptr;
else if (addr.len == 16)
{ /* memcmp order == network order for v6, no conversion needed */
const uint8_t *a = (uint8_t*)addr.ptr;
if (!this->set->force_tunnel_v6(this->set, a) &&
this->set->contains_v6(this->set, a))
{
direct = TRUE;
if (!this->set->force_tunnel_v6(this->set, a) &&
this->set->contains_v6(this->set, a))
{
if (fallback_addr(this, TRUE, addr.ptr))
{ /* dead direct host rerouted by its failure cooldown */
DIRECT_STATS_ADD(this->stats.fallback_hits, 1);
}
else
{
direct = TRUE;
}
}
}
}
}
this->set_lock->unlock(this->set_lock);
/* TCP and UDP leave directly through the engine; only ICMP etc. cannot
* (no raw sockets for apps) and stay in the tunnel. The engine loop
* wakes lwIP (tcp_active) on the first TCP packet, before that the
* stack stays completely dormant. */
* (no raw sockets for apps) and stay in the tunnel. Count why the
* direct path refused them, so the drops line in route diag is
* meaningful (the packets themselves are counted as tunnel below).
* The engine loop wakes lwIP (tcp_active) on the first TCP packet,
* before that the stack stays completely dormant. */
if (direct)
{
int proto = parse_protocol(enc);
if (proto != IPPROTO_UDP && proto != IPPROTO_TCP)
{
switch (proto)
{
case -2:
DIRECT_STATS_ADD(this->stats.drop_frag, 1);
break;
case -1:
DIRECT_STATS_ADD(this->stats.drop_malformed, 1);
break;
default:
/* ICMP and other protocols to RU destinations */
DIRECT_STATS_ADD(this->stats.drop_icmp, 1);
break;
}
direct = FALSE;
}
}
@@ -225,7 +421,7 @@ METHOD(direct_engine_t, handle, bool,
this->q_lock->lock(this->q_lock);
if (this->queue_full)
{ /* overflow: drop rather than block the tunnel reader */
DIRECT_STATS_ADD(this->stats.drop_malformed, 1);
DIRECT_STATS_ADD(this->stats.queue_drops, 1);
this->q_lock->unlock(this->q_lock);
packet->destroy(packet);
return TRUE;
@@ -375,6 +571,9 @@ 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),
.fallback_hits = __atomic_load_n(&this->stats.fallback_hits, __ATOMIC_RELAXED),
.queue_drops = __atomic_load_n(&this->stats.queue_drops, __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),
@@ -388,6 +587,28 @@ METHOD(direct_engine_t, get_stats, void,
this->set_lock->unlock(this->set_lock);
}
METHOD(direct_engine_t, get_dead_targets, size_t,
private_direct_engine_t *this, direct_dead_target_t *out, size_t cap)
{
size_t i, n = 0;
if (this->tcp)
{
n = this->tcp->get_dead_targets(this->tcp, out, cap);
}
if (n)
{ /* annotate the entries whose address the engine currently reroutes
* through the tunnel (cooldown escalated past the fallback bar) */
this->set_lock->read_lock(this->set_lock);
for (i = 0; i < n; i++)
{
out[i].via_tunnel = fallback_addr(this, out[i].v6, out[i].addr);
}
this->set_lock->unlock(this->set_lock);
}
return n;
}
/**
* Parse the transport protocol of a packet, rejecting fragments
*/
@@ -520,19 +741,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 +773,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,11 +795,16 @@ 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)
{
this->tcp->reprotect(this->tcp);
}
/* the network changed: whatever was dead for direct connects
* may work again - drop the tunnel fallbacks with the rest of
* the failure state (tcp->reprotect clears the neg table) */
fallback_clear_all(this);
}
this->bind_lock->lock(this->bind_lock);
@@ -592,6 +833,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);
@@ -609,6 +851,7 @@ static void *engine_thread(private_direct_engine_t *this)
{
this->tcp->reset(this->tcp);
}
fallback_clear_all(this);
this->unbound_flushed = TRUE;
continue;
}
@@ -621,9 +864,30 @@ 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);
fallback_sweep(this, now);
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;
@@ -664,7 +928,9 @@ direct_engine_t *direct_engine_create(void)
.wake_engine = _wake_engine,
.update_set = _update_set,
.set_enabled = _set_enabled,
.mark_fallback = _mark_fallback,
.get_stats = _get_stats,
.get_dead_targets = _get_dead_targets,
.destroy = _destroy,
},
.set_lock = rwlock_create(RWLOCK_TYPE_DEFAULT),
@@ -37,6 +37,35 @@
typedef struct direct_engine_t direct_engine_t;
/**
* A direct destination currently refused by the failure cooldown (a dead
* host that apps keep hammering). For UI surfacing.
*/
#define DIRECT_DEAD_MAX 8
/**
* Cap on simultaneously relayed direct TCP flows. Every live relay, every
* UDP flow (FLOW_SLOTS = 256) and the wakeup pipe must fit into the
* engine's poll set (MAX_POLL_FDS = 1024); beyond the cap new connections
* are refused with a fast RST (counted as relay errors) instead of being
* admitted and then never polled.
*/
#define DIRECT_MAX_TCP_RELAYS 767
typedef struct {
/* long enough for a full-form IPv6 address plus ":port" and NUL
* (format_dest writes "ip:port"; 48 truncated the worst v6 case) */
char dst[64];
uint32_t fails;
uint32_t cooldown_ms;
uint32_t cooldown_left_ms;
/** destination address, filled by direct_tcp (v4: first 4 bytes) */
bool v6;
uint8_t addr[16];
/** the engine currently reroutes this address through the tunnel
* (its cooldown escalated); filled by the engine */
bool via_tunnel;
} direct_dead_target_t;
/**
* Counters snapshot for the "route diag:" block (all fields relaxed
* atomics, updated from the processor and engine threads).
@@ -50,6 +79,9 @@ typedef struct direct_stats_t {
uint64_t drop_frag;
uint64_t drop_malformed;
uint64_t relay_errors;
uint64_t cooldown_hits;
uint64_t fallback_hits;
uint64_t queue_drops;
uint64_t udp_flows, tcp_flows;
uint64_t tcp_accepts;
size_t set_v4, set_v6, set_ft_v4, set_ft_v6;
@@ -97,6 +129,16 @@ struct direct_engine_t {
*/
void (*set_enabled)(direct_engine_t *this, bool enabled);
/**
* Reroute one address through the tunnel: called by direct_tcp when a
* destination's failure cooldown escalated (a dead direct host), so its
* packets stop burning in the direct RST loop. Address-only - all ports
* of the host are affected. Entries expire with the failure state and
* are dropped on network changes. Returns TRUE if the address was newly
* added (the caller logs the transition once).
*/
bool (*mark_fallback)(direct_engine_t *this, bool v6, const void *addr);
/**
* Wake the engine thread without changing any state (roam re-protect).
*/
@@ -107,6 +149,14 @@ struct direct_engine_t {
*/
void (*get_stats)(direct_engine_t *this, direct_stats_t *stats);
/**
* Snapshot the destinations currently refused by the direct-flow failure
* cooldown (dead hosts apps keep hammering), most failures first.
* Thread-safe (may be called from the JNI thread).
*/
size_t (*get_dead_targets)(direct_engine_t *this,
direct_dead_target_t *out, size_t cap);
void (*destroy)(direct_engine_t *this);
};
@@ -48,6 +48,7 @@
#include <arpa/inet.h>
#include <errno.h>
#include <netinet/in.h>
#include <pthread.h>
#include <stdio.h>
#include <string.h>
#include <sys/socket.h>
@@ -58,8 +59,69 @@
*/
#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), so an app
* hammering a dead host cannot burn sockets, JNI protect() roundtrips and
* log lines. The refusal window starts at NEG_COOLDOWN_MS and doubles per
* further failed cycle up to NEG_COOLDOWN_MAX_MS - a chronically dead host
* then costs one probe per window instead of a constant 45 s cadence (one
* app was observed opening ~3000 connects/hour to each of two dead hosts,
* waking the radio for every leaked probe). 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_COOLDOWN_MAX_MS (5 * 60 * 1000)
#define NEG_EXPIRY_MS (10 * 60 * 1000)
/**
* Once the cooldown window grew to this size the host is considered dead
* for direct connects: its whole address is rerouted through the tunnel
* (direct_engine fallback) until the failure state expires or the network
* changes, so apps stuck on the RST loop get a working path instead.
*/
#define NEG_FALLBACK_MS (3 * 60 * 1000)
/**
* Report the dead destination at every NEG_REPORT_FAILS-th failure even
* after the window has maxed out, so long-running outages stay visible
*/
#define NEG_REPORT_FAILS 100
/**
* neg_record_fail() flags
*/
#define NEG_ARMED (1<<0) /* cooldown (re)armed right now */
#define NEG_ESCALATED (1<<1) /* window grew or milestone crossed */
/**
* 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;
uint32_t cooldown_ms;
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 +138,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 +176,13 @@ 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,
* except for get_dead_targets() which locks neg_lock from the JNI thread
*/
neg_entry_t neg[NEG_SLOTS];
pthread_mutex_t neg_lock;
};
/**
@@ -290,6 +361,232 @@ 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;
bool active = FALSE;
pthread_mutex_lock(&this->neg_lock);
for (i = 0; i < NEG_SLOTS; i++)
{
if (neg_match(&this->neg[i], ss))
{
if (now < this->neg[i].cooldown_until)
{
active = TRUE;
}
break;
}
}
pthread_mutex_unlock(&this->neg_lock);
return active;
}
/**
* 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;
bool should = TRUE;
pthread_mutex_lock(&this->neg_lock);
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;
should = TRUE;
}
else
{
should = FALSE;
}
break;
}
}
pthread_mutex_unlock(&this->neg_lock);
return should;
}
/**
* Record a failed connect cycle. Returns NEG_ARMED when the destination
* (re)entered its cooldown right now (the caller may log/attribute the owner
* once here), plus NEG_ESCALATED when the window grew or a failure milestone
* was crossed (worth a dead-destination diag line). fails/window report the
* armed state for that line and may be NULL.
*/
static int neg_record_fail(private_direct_tcp_t *this, const struct sockaddr_storage *ss,
uint64_t now, uint32_t *fails, uint32_t *window)
{
neg_entry_t *entry = NULL, *oldest = &this->neg[0];
size_t i;
int flags = 0;
pthread_mutex_lock(&this->neg_lock);
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->cooldown_ms = 0;
}
entry->fails++;
entry->last_fail = now;
if (entry->fails >= NEG_FAILS && now >= entry->cooldown_until)
{
uint32_t prev = entry->cooldown_ms;
entry->cooldown_ms = entry->cooldown_ms ?
min(entry->cooldown_ms * 2, NEG_COOLDOWN_MAX_MS) : NEG_COOLDOWN_MS;
entry->cooldown_until = now + entry->cooldown_ms;
if (fails)
{
*fails = entry->fails;
}
if (window)
{
*window = entry->cooldown_ms;
}
if (entry->cooldown_ms > prev || entry->fails % NEG_REPORT_FAILS == 0)
{
flags = NEG_ARMED | NEG_ESCALATED;
}
else
{
flags = NEG_ARMED;
}
}
pthread_mutex_unlock(&this->neg_lock);
return flags;
}
/**
* The destination just (re)entered its cooldown with a window that has
* grown past NEG_FALLBACK_MS: dead for direct connects. Reroute its whole
* address through the tunnel; log the transition once per insertion.
* window is only nonzero when the caller's neg_record_fail armed just now.
*/
static void neg_escalate_fallback(private_direct_tcp_t *this,
const struct sockaddr_storage *ss,
const char *dest, uint32_t window)
{
bool added;
if (window < NEG_FALLBACK_MS)
{
return;
}
if (ss->ss_family == AF_INET6)
{
added = this->engine->mark_fallback(this->engine, TRUE,
&((const struct sockaddr_in6*)ss)->sin6_addr);
}
else
{
added = this->engine->mark_fallback(this->engine, FALSE,
&((const struct sockaddr_in*)ss)->sin_addr);
}
if (added)
{
charonservice_log_fallback_destination(dest);
}
}
/**
* 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;
pthread_mutex_lock(&this->neg_lock);
for (i = 0; i < NEG_SLOTS; i++)
{
if (neg_match(&this->neg[i], ss))
{
this->neg[i].used = FALSE;
}
}
pthread_mutex_unlock(&this->neg_lock);
}
/**
* Drop the whole failure table (network change, engine reset)
*/
static void neg_clear_all(private_direct_tcp_t *this)
{
pthread_mutex_lock(&this->neg_lock);
memset(this->neg, 0, sizeof(this->neg));
pthread_mutex_unlock(&this->neg_lock);
}
/**
* Open a protected, network-pinned non-blocking socket and start connecting
* it to the given destination. Returns -1 on failure.
@@ -304,7 +601,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,13 +621,20 @@ 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;
if (!newpcb)
{ /* lwIP reports accept-time allocation failures this way
* (TCP_EVENT_ACCEPT with a NULL pcb) */
return ERR_ABRT;
}
DIRECT_STATS_ADD(this->stats->tcp_accepts, 1);
memset(&ss, 0, sizeof(ss));
@@ -349,12 +654,60 @@ 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;
}
if (this->relays->get_count(this->relays) >= DIRECT_MAX_TCP_RELAYS)
{ /* poll-set capacity reached: fail fast instead of admitting a
* flow that would never be polled (its data would stall) */
DIRECT_STATS_ADD(this->stats->relay_errors, 1);
tcp_abort(newpcb);
return ERR_ABRT;
}
fd = relay_connect_socket(&ss, ss_len);
if (fd < 0)
{
uint32_t fails = 0, window = 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, &fails, &window) & NEG_ARMED)
{
charonservice_log_connection_owner((struct sockaddr*)&org,
(struct sockaddr*)&ss, dest);
neg_escalate_fallback(this, &ss, dest, window);
}
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 +716,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)
{
@@ -465,6 +820,10 @@ static lwip_err_t netif_output(struct netif *netif, struct pbuf *p)
{
return ERR_BUF;
}
/* stack -> kernel direction (SYN-ACKs, data, FINs, retransmissions):
* counted here so tcp= reads symmetric with the TUN -> stack side */
DIRECT_STATS_ADD(this->stats->tcp_pkts, 1);
DIRECT_STATS_ADD(this->stats->tcp_bytes, len);
ip_packet_t *packet = ip_packet_create(chunk_clone(chunk_create(buf, len)));
if (packet)
{
@@ -621,6 +980,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 +1051,89 @@ 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
{
uint32_t fails = 0, window = 0;
int flags;
format_dest(&relay->dest, dest, sizeof(dest));
do_log = neg_should_log(this, &relay->dest, now);
if (!relay->retried && err != ECONNREFUSED)
{ /* 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). Skipped for ECONNREFUSED -
* the peer actively refused on the network this socket is
* bound to, so a second socket there hits the same
* refusal, and a mid-roam socket is already aborted by
* reprotect() before its error can surface here. */
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);
flags = neg_record_fail(this, &relay->dest, now, &fails, &window);
if (flags & NEG_ARMED)
{ /* 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);
neg_escalate_fallback(this, &relay->dest, dest, window);
}
if (flags & NEG_ESCALATED)
{ /* the cooldown window just grew (or a milestone was
* crossed): make the dead host visible in diag.log */
charonservice_log_dead_destination(dest, fails,
(window + 999) / 1000);
}
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 +1193,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();
}
@@ -801,6 +1208,7 @@ METHOD(direct_tcp_t, destroy, void,
reset(this);
netif_remove(&this->netif);
this->relays->destroy(this->relays);
pthread_mutex_destroy(&this->neg_lock);
free(this);
}
@@ -813,6 +1221,62 @@ u32_t sys_now(void)
return (u32_t)(time_monotonic(NULL) * 1000);
}
METHOD(direct_tcp_t, get_dead_targets, size_t,
private_direct_tcp_t *this, direct_dead_target_t *out, size_t cap)
{
neg_entry_t *sorted[NEG_SLOTS];
size_t i, j, n = 0;
uint64_t now = now_ms();
if (!cap)
{
return 0;
}
pthread_mutex_lock(&this->neg_lock);
for (i = 0; i < NEG_SLOTS; i++)
{
neg_entry_t *e = &this->neg[i];
if (!e->used || e->fails < NEG_FAILS)
{
continue;
}
/* insertion sort by failures, most failing first */
for (j = n; j > 0 && sorted[j-1]->fails < e->fails; j--)
{
sorted[j] = sorted[j-1];
}
sorted[j] = e;
n++;
}
if (n > cap)
{
n = cap;
}
for (i = 0; i < n; i++)
{
neg_entry_t *e = sorted[i];
format_dest(&e->dest, out[i].dst, sizeof(out[i].dst));
out[i].fails = e->fails;
out[i].cooldown_ms = e->cooldown_ms;
out[i].cooldown_left_ms = e->cooldown_until > now ?
(uint32_t)(e->cooldown_until - now) : 0;
/* raw address for the engine's via_tunnel annotation */
out[i].v6 = e->dest.ss_family == AF_INET6;
if (out[i].v6)
{
memcpy(out[i].addr, &((struct sockaddr_in6*)&e->dest)->sin6_addr, 16);
}
else
{
memcpy(out[i].addr, &((struct sockaddr_in*)&e->dest)->sin_addr, 4);
}
}
pthread_mutex_unlock(&this->neg_lock);
return n;
}
direct_tcp_t *direct_tcp_create(uint16_t mtu, direct_engine_t *engine,
direct_stats_t *stats)
{
@@ -832,12 +1296,14 @@ direct_tcp_t *direct_tcp_create(uint16_t mtu, direct_engine_t *engine,
.process_poll = _process_poll,
.reset = _reset,
.destroy = _destroy,
.get_dead_targets = _get_dead_targets,
},
.engine = engine,
.stats = stats,
.mtu = mtu,
.relays = linked_list_create(),
);
pthread_mutex_init(&this->neg_lock, NULL);
lwip_init();
@@ -63,6 +63,14 @@ struct direct_tcp_t {
*/
size_t (*flow_count)(direct_tcp_t *this);
/**
* Snapshot the destinations currently refused by the negative cache,
* most failures first. Thread-safe (called from the JNI thread while
* the engine thread owns the table otherwise).
*/
size_t (*get_dead_targets)(direct_tcp_t *this,
direct_dead_target_t *out, size_t cap);
/**
* Add this module's relay sockets to the poll set, returns how many.
*/
@@ -215,6 +215,12 @@ METHOD(direct_udp_t, handle_packet, void,
{
DIRECT_STATS_ADD(this->stats->relay_errors, 1);
}
else
{ /* kernel -> remote direction, symmetric with the response
* counter in flow_process (which used to be the only one) */
DIRECT_STATS_ADD(this->stats->udp_pkts, 1);
DIRECT_STATS_ADD(this->stats->udp_bytes, data.len);
}
packet->destroy(packet);
}
@@ -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>
@@ -447,7 +467,24 @@
<string name="duration_label">Длительность</string>
<string name="traffic_stat_down">Загружено %1$s, %2$s</string>
<string name="traffic_stat_up">Отправлено %1$s, %2$s</string>
<string name="traffic_split_tunnel">Через VPN</string>
<string name="traffic_split_direct">Напрямую (RU)</string>
<string name="traffic_direct_share_fmt">%1$d %% напрямую</string>
<string name="exit_ip_label">Выходной IP</string>
<string name="network_label">Сеть</string>
<string name="network_wifi">Wi-Fi</string>
<string name="network_cellular">Сотовая сеть</string>
<string name="dead_targets_title">Недоступные напрямую</string>
<string name="dead_targets_summary">Приложения продолжают стучаться к этим адресам, но те не отвечают — движок отклоняет соединения на время паузы. Давно умершие хосты автоматически переводятся в туннель VPN до смены сети. Проверьте серверы или уберите их из настроек использующего их приложения.</string>
<string name="dead_targets_fails_fmt">отказов: %1$d</string>
<string name="dead_targets_cooldown_fmt">пауза %1$s</string>
<string name="dead_targets_more_fmt">ещё %1$d</string>
<string name="dead_targets_open">Открыть маршрутизацию</string>
<string name="dead_targets_via_tunnel">через туннель</string>
<string name="dead_targets_force">Через VPN</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>
@@ -452,7 +472,24 @@
<string name="duration_label">Duration</string>
<string name="traffic_stat_down">Downloaded %1$s, %2$s</string>
<string name="traffic_stat_up">Uploaded %1$s, %2$s</string>
<string name="traffic_split_tunnel">Via VPN</string>
<string name="traffic_split_direct">Direct (RU)</string>
<string name="traffic_direct_share_fmt">%1$d%% direct</string>
<string name="exit_ip_label">Exit IP</string>
<string name="network_label">Network</string>
<string name="network_wifi">Wi-Fi</string>
<string name="network_cellular">Mobile data</string>
<string name="dead_targets_title">Unreachable direct hosts</string>
<string name="dead_targets_summary">Apps keep connecting to these hosts, but they never answer — the engine refuses them for a cooldown window. Long-dead hosts are automatically rerouted through the VPN tunnel until the network changes. Check the servers or remove them from the app that uses them.</string>
<string name="dead_targets_fails_fmt">%1$d failed connects</string>
<string name="dead_targets_cooldown_fmt">cooldown %1$s</string>
<string name="dead_targets_more_fmt">+%1$d more</string>
<string name="dead_targets_open">Open routing</string>
<string name="dead_targets_via_tunnel">via tunnel</string>
<string name="dead_targets_force">Via VPN</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>
+3 -2
View File
@@ -255,8 +255,9 @@ METHOD(esp_packet_t, decrypt, status_t,
if (!esp_context->verify_seqno(esp_context, seq))
{
DBG1(DBG_ESP, "ESP sequence number verification failed:\n "
"src %H, dst %H, SPI %.8x [seq %u]",
get_source(this), get_destination(this), spi, seq);
"src %H, dst %H, SPI %.8x [seq %u], window high %u",
get_source(this), get_destination(this), spi, seq,
esp_context->get_seqno(esp_context));
return VERIFY_ERROR;
}
DBG3(DBG_ESP, "ESP decryption:\n SPI %.8x [seq %u]\n IV %B\n "