Merge commit '4a2581bb1' into claude/shx-vpn-privacy-stability-a90292

This commit is contained in:
Denozordec committed 2026-11-08 23:34:05 +07:00
commit 3953c39a4e
12 files changed
+301 -2

No files matched your search

+7
View File
@@ -0,0 +1,7 @@
---
name: docs
description: Looks up API references and library documentation. Read-only.
model: claude-haiku-5-5
tools: Read, Grep, Glob, WebFetch, WebSearch
---
Find the relevant API reference and return signatures and short quotes with sources. Never modify anything.
+7
View File
@@ -0,0 +1,7 @@
---
name: grep
description: Finds call sites and usages of a symbol. Read-only.
model: claude-haiku-5-5
tools: Read, Grep, Glob
---
List every call site of the given symbol as file:line with a short context. Never modify anything.
+7
View File
@@ -0,0 +1,7 @@
---
name: search
description: Repo mapping. Finds files, modules and directory structure. Read-only.
model: claude-haiku-5-5
tools: Read, Glob, Grep
---
Map the repository: locate relevant files, modules and entry points. Return paths and one-line summaries. Never modify anything.
+3
View File
@@ -0,0 +1,3 @@
{
"model": "claude-sonnet-5-5"
}
+4
View File
@@ -0,0 +1,4 @@
@AGENTS.md
# Advisor rule
Ping the advisor (Opus 5.5) before locking any plan, as soon as any error repeats a second time, and before the final task handoff.
+2 -2
View File
@@ -45,8 +45,8 @@ android {
minSdk = 24 minSdk = 24
targetSdk = 36 targetSdk = 36
versionCode 169 versionCode 170
versionName "2.19.1" versionName "2.19.2"
/* Base URL of the update storage, trailing slash included */ /* Base URL of the update storage, trailing slash included */
buildConfigField "String", "UPDATE_BASE_URL", "\"${updateBaseUrl}\"" buildConfigField "String", "UPDATE_BASE_URL", "\"${updateBaseUrl}\""
@@ -92,6 +92,7 @@ import java.util.List;
import java.util.Locale; import java.util.Locale;
import java.util.Set; import java.util.Set;
import java.util.SortedSet; import java.util.SortedSet;
import java.util.TimeZone;
import java.util.TreeSet; import java.util.TreeSet;
import java.util.concurrent.ExecutorService; import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors; import java.util.concurrent.Executors;
@@ -171,6 +172,12 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe
private volatile boolean mProfileUpdated; private volatile boolean mProfileUpdated;
private volatile boolean mTerminate; private volatile boolean mTerminate;
private volatile boolean mIsDisconnecting; private volatile boolean mIsDisconnecting;
/* why-did-the-SA-go-down diagnostics (plan 8.1); no effect on behaviour */
private final DailyCounter mSaDropCounter = new DailyCounter();
private volatile long mSaUpSince;
private volatile long mLastNetworkChange;
private volatile boolean mRetransmitTimeout;
private volatile boolean mReauth;
private volatile boolean mShowNotification; private volatile boolean mShowNotification;
/* start of the current CONNECTED session for the notification chronometer */ /* start of the current CONNECTED session for the notification chronometer */
private volatile long mConnectedSince; private volatile long mConnectedSince;
@@ -219,6 +226,8 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe
static final int STATE_UNREACHABLE_ERROR = 6; static final int STATE_UNREACHABLE_ERROR = 6;
static final int STATE_CERTIFICATE_UNAVAILABLE = 7; static final int STATE_CERTIFICATE_UNAVAILABLE = 7;
static final int STATE_GENERIC_ERROR = 8; static final int STATE_GENERIC_ERROR = 8;
static final int STATE_RETRANSMIT_TIMEOUT = 9;
static final int STATE_REAUTH = 10;
@Override @Override
public int onStartCommand(Intent intent, int flags, int startId) public int onStartCommand(Intent intent, int flags, int startId)
@@ -1026,12 +1035,16 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe
switch (status) switch (status)
{ {
case STATE_CHILD_SA_DOWN: case STATE_CHILD_SA_DOWN:
logSaDown();
if (!mIsDisconnecting) if (!mIsDisconnecting)
{ {
setState(State.CONNECTING); setState(State.CONNECTING);
} }
break; break;
case STATE_CHILD_SA_UP: case STATE_CHILD_SA_UP:
mSaUpSince = SystemClock.elapsedRealtime();
mRetransmitTimeout = false;
mReauth = false;
setState(State.CONNECTED); setState(State.CONNECTED);
scheduleDnsDiagnostics(); scheduleDnsDiagnostics();
scheduleRouteDiagnostics(); scheduleRouteDiagnostics();
@@ -1060,12 +1073,49 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe
case STATE_GENERIC_ERROR: case STATE_GENERIC_ERROR:
setErrorDisconnect(ErrorState.GENERIC_ERROR); setErrorDisconnect(ErrorState.GENERIC_ERROR);
break; break;
case STATE_RETRANSMIT_TIMEOUT:
mRetransmitTimeout = true;
break;
case STATE_REAUTH:
mReauth = true;
break;
default: default:
Log.e(TAG, "Unknown status code received"); Log.e(TAG, "Unknown status code received");
break; break;
} }
} }
/**
* Writes why an established SA went down to the diagnostic log (plan 8.1).
* Does not touch the connection state.
*/
private void logSaDown()
{
long up = mSaUpSince;
boolean timeout = mRetransmitTimeout;
boolean reauth = mReauth;
mRetransmitTimeout = false;
mReauth = false;
if (up == 0)
{ /* no SA was up: a failed first connect is logged elsewhere */
return;
}
mSaUpSince = 0;
long now = SystemClock.elapsedRealtime();
long netAgo = mLastNetworkChange == 0 ? -1 : now - mLastNetworkChange;
SaClose why = SaClose.classify(mIsDisconnecting, reauth, timeout, netAgo);
String count = "";
if (why.getDrop())
{
long wall = System.currentTimeMillis();
count = ", drop #" + mSaDropCounter.next(wall, TimeZone.getDefault().getOffset(wall)) +
" today (resets with the process)";
}
DiagLog.i(TAG, "SA down: " + why.getLabel() + ", was up " + ((now - up) / 1000) + "s, network change " +
(netAgo < 0 ? "none" : (netAgo / 1000) + "s ago") + count +
(why == SaClose.OTHER ? " (no retransmit-timeout alert; MOBIKE path probing gives up without one)" : ""));
}
/** Delay before the DNS chain diagnostics run, so validation settles first. */ /** Delay before the DNS chain diagnostics run, so validation settles first. */
private static final long DNS_DIAG_DELAY_MS = 10_000; private static final long DNS_DIAG_DELAY_MS = 10_000;
private static final String DNS_DIAG_PROBE = "one.one.one.one"; private static final String DNS_DIAG_PROBE = "one.one.one.one";
@@ -1879,6 +1929,7 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe
return; return;
} }
mApplied = current; mApplied = current;
mLastNetworkChange = SystemClock.elapsedRealtime();
} }
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M)
{ {
@@ -0,0 +1,68 @@
/*
* 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.logic
/** Why an established IKE SA went down; for the diagnostic log only (plan 8.1). */
enum class SaClose(val label: String, val drop: Boolean)
{
USER("user", false),
REAUTH("reauth", false),
RETRANSMIT_AFTER_NETWORK_CHANGE("retransmit-timeout after network change", true),
RETRANSMIT_TIMEOUT("retransmit-timeout", true),
NETWORK_CHANGE("network change", true),
OTHER("other (server close, rekey failure, MOBIKE probing)", true);
companion object
{
/** a network change this close before the SA went down is blamed first */
const val NETWORK_WINDOW_MS = 20_000L
/** sinceNetworkChangeMs is -1 when there was none */
@JvmStatic
fun classify(disconnecting: Boolean, reauth: Boolean, retransmitTimeout: Boolean,
sinceNetworkChangeMs: Long): SaClose
{
val network = sinceNetworkChangeMs in 0..NETWORK_WINDOW_MS
return when
{
disconnecting -> USER
reauth -> REAUTH
retransmitTimeout && network -> RETRANSMIT_AFTER_NETWORK_CHANGE
retransmitTimeout -> RETRANSMIT_TIMEOUT
network -> NETWORK_CHANGE
else -> OTHER
}
}
}
}
/** Counts events per local calendar day; resets when the process restarts. */
class DailyCounter
{
private var day = Long.MIN_VALUE
private var count = 0
@Synchronized
fun next(nowMs: Long, utcOffsetMs: Long): Int
{
val today = Math.floorDiv(nowMs + utcOffsetMs, 86_400_000L)
if (today != day)
{
day = today
count = 0
}
return ++count
}
}
@@ -591,6 +591,10 @@ METHOD(listener_t, ike_reestablish_pre, bool,
{ {
if (this->ike_sa == old) if (this->ike_sa == old)
{ {
if (old->has_condition(old, COND_REAUTHENTICATING))
{ /* planned reauthentication, not a drop */
charonservice->update_status(charonservice, CHARONSERVICE_REAUTH);
}
/* enable DNS proxy so hosts are properly resolved while the TUN device /* enable DNS proxy so hosts are properly resolved while the TUN device
* is still active */ * is still active */
this->lock->write_lock(this->lock); this->lock->write_lock(this->lock);
@@ -674,6 +678,12 @@ METHOD(listener_t, alert, bool,
{ {
switch (alert) switch (alert)
{ {
case ALERT_RETRANSMIT_SEND_TIMEOUT:
/* fires before the reestablish that reports CHILD_STATE_DOWN;
* break, not FALSE: returning FALSE unregisters the listener */
charonservice->update_status(charonservice,
CHARONSERVICE_RETRANSMIT_TIMEOUT);
break;
case ALERT_PEER_ADDR_FAILED: case ALERT_PEER_ADDR_FAILED:
charonservice->update_status(charonservice, charonservice->update_status(charonservice,
CHARONSERVICE_LOOKUP_ERROR); CHARONSERVICE_LOOKUP_ERROR);
@@ -63,6 +63,9 @@ enum android_vpn_state_t {
CHARONSERVICE_UNREACHABLE_ERROR, CHARONSERVICE_UNREACHABLE_ERROR,
CHARONSERVICE_CERTIFICATE_UNAVAILABLE, CHARONSERVICE_CERTIFICATE_UNAVAILABLE,
CHARONSERVICE_GENERIC_ERROR, CHARONSERVICE_GENERIC_ERROR,
/* diagnostics only, no state change: the next CHILD_STATE_DOWN is caused by it */
CHARONSERVICE_RETRANSMIT_TIMEOUT,
CHARONSERVICE_REAUTH,
}; };
/** /**
@@ -0,0 +1,92 @@
/*
* 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.logic
import org.assertj.core.api.Assertions.assertThat
import org.junit.Test
class SaCloseReasonTest
{
private fun classify(disconnecting: Boolean = false, reauth: Boolean = false,
timeout: Boolean = false, netAgo: Long = -1) =
SaClose.classify(disconnecting, reauth, timeout, netAgo)
@Test
fun userStopWinsOverEverything()
{
assertThat(classify(disconnecting = true, reauth = true, timeout = true, netAgo = 0)).isEqualTo(SaClose.USER)
assertThat(SaClose.USER.drop).isFalse()
}
@Test
fun reauthIsNotADrop()
{
assertThat(classify(reauth = true, timeout = true, netAgo = 1000)).isEqualTo(SaClose.REAUTH)
assertThat(SaClose.REAUTH.drop).isFalse()
}
@Test
fun retransmitTimeoutWithAndWithoutNetworkChange()
{
assertThat(classify(timeout = true, netAgo = 5000)).isEqualTo(SaClose.RETRANSMIT_AFTER_NETWORK_CHANGE)
assertThat(classify(timeout = true)).isEqualTo(SaClose.RETRANSMIT_TIMEOUT)
assertThat(classify(timeout = true, netAgo = 21_000)).isEqualTo(SaClose.RETRANSMIT_TIMEOUT)
}
@Test
fun networkWindowBoundary()
{
assertThat(classify(netAgo = 0)).isEqualTo(SaClose.NETWORK_CHANGE)
assertThat(classify(netAgo = SaClose.NETWORK_WINDOW_MS)).isEqualTo(SaClose.NETWORK_CHANGE)
assertThat(classify(netAgo = SaClose.NETWORK_WINDOW_MS + 1)).isEqualTo(SaClose.OTHER)
}
@Test
fun noSignalsIsOther()
{
assertThat(classify()).isEqualTo(SaClose.OTHER)
assertThat(SaClose.OTHER.drop).isTrue()
}
@Test
fun counterCountsWithinADayAndResetsAtLocalMidnight()
{
val c = DailyCounter()
val day = 86_400_000L
assertThat(c.next(1000, 0)).isEqualTo(1)
assertThat(c.next(2000, 0)).isEqualTo(2)
assertThat(c.next(day - 1, 0)).isEqualTo(3)
assertThat(c.next(day, 0)).isEqualTo(1)
}
@Test
fun counterUsesTheLocalOffset()
{
val c = DailyCounter()
val hour = 3_600_000L
/* 23:30 UTC: past midnight at UTC+3 (21:30 and 23:30 UTC are one local day, the next hour is not),
* still the same local day an hour later at UTC-5 */
val t = 24 * hour - hour / 2
assertThat(c.next(t - 2 * hour, 3 * hour)).isEqualTo(1)
assertThat(c.next(t, 3 * hour)).isEqualTo(2)
assertThat(c.next(t + hour, 3 * hour)).isEqualTo(3)
val d = DailyCounter()
assertThat(d.next(t, -5 * hour)).isEqualTo(1)
assertThat(d.next(t + hour, -5 * hour)).isEqualTo(2)
val e = DailyCounter()
assertThat(e.next(t, 0)).isEqualTo(1)
assertThat(e.next(t + hour, 0)).isEqualTo(1)
}
}
+47
View File
@@ -180,3 +180,50 @@
Проверено на эмуляторе `shx36` (профиль на 192.0.2.1, туннель не поднимается, проверялась логика): `am crash` → watchdog поднимает сервис (~80 с, окно неточного Проверено на эмуляторе `shx36` (профиль на 192.0.2.1, туннель не поднимается, проверялась логика): `am crash` → watchdog поднимает сервис (~80 с, окно неточного
будильника); сценарий «Wi-Fi → включить» срабатывает по событию сети при выключенных правилах сетей (~10 с); будильник сценария по времени будит убитый процесс, будильника); сценарий «Wi-Fi → включить» срабатывает по событию сети при выключенных правилах сетей (~10 с); будильник сценария по времени будит убитый процесс,
`ScenarioReceiver` ждёт сервис и возвращает прежнее состояние VPN. Без реального сервера не проверены keepalive/rekey, NAT-таймауты и энергопотребление. `ScenarioReceiver` ждёт сервис и возвращает прежнее состояние VPN. Без реального сервера не проверены keepalive/rekey, NAT-таймауты и энергопотребление.
## 8. Версия 2.20: стабильность соединения и расход энергии (разбор 2.19.1)
Основа — чтение `NetworkManager`, `UnderlyingNetworkTracker`, `VpnStateService`, `android_net.c`, `charonservice.c`, `android_service.c`
и исходников charon. `CharonVpnService` и `direct_engine.c` целиком не разбирались: пункты ниже — гипотезы, пока их не подтвердит лог с телефона.
Условие раздела 5 (туннель не падает в фоне) действует для каждого пункта.
### 8.1. Шаг 0 — наблюдаемость (поведение не меняется)
- В `DiagLog` писать причину закрытия IKE SA: DPD/ретрансмиты, смена сети, закрыто сервером, действие пользователя; считать реконнекты в сутки.
Новых строк интерфейса нет. Без этих данных 8.2–8.4 решаются вслепую.
- Замеры на телефоне делает владелец: ночь с погашенным экраном на Wi-Fi и на мобильной сети; до и после — `dumpsys batterystats --reset` /
`dumpsys batterystats one.shx.strongswan.ext`, `dumpsys alarm`, `dumpsys deviceidle force-idle`.
### 8.2. Окно ретрансмитов (стабильность и энергия)
- Сейчас `retransmit_tries=3`, `timeout=2,0`, `base=1,4` (`charonservice.c:59-61`): первая отправка и 3 повтора, после ожидания третьего
повтора (`task_manager_v2.c:403`) IKE SA закрывается. Итого 2 + 2,8 + 3,9 + 5,5 ≈ 14 с без ответа; 5 повторов дали бы ≈ 33 с.
- Это относится к обычным запросам (DPD, rekey). У MOBIKE-проверки пути свой бюджет (`ROUTABILITY_CHECK_TRIES`), он от этих настроек не зависит.
- После закрытия SA charon поднимает его заново сам: `dpd_action` и `close_action` = `ACTION_START`, `keyingtries = 6` (`android_service.c:885-896`).
Java-backoff (5 с × 2ⁿ, до 120 с) включается, только когда попытки исчерпаны. Цена короткого провала связи — полный IKE_SA_INIT + AUTH
(радио, CPU, туннель недоступен на время переустановки). Как Java реагирует на `CHILD_STATE_DOWN` при этом, не разбиралось.
- Настройки читаются при создании IKE SA: «больше попыток только на мобильной сети» при роуминге не включится — либо выбор по транспорту
при подключении, либо одно значение для всех.
- Решать по логу 8.1: много падений по DPD на коротких провалах — поднять; мало — не трогать. Цена: дольше обнаружение реально мёртвого пути.
### 8.3. Привязка сокета IKE после смены сети (гипотеза, только по логу)
- Состояние `connected` из `NetworkManager` в нативе нужно одному: `android_net.c` ставит повторную проверку через 1 с после `ENETUNREACH`.
Roam-событие поднимается при любом изменении, поэтому версия «Wi-Fi без интернета считается связью и IKE уходит в пустоту» из кода не следует.
- Остаётся вопрос, на какую сеть привязан сокет IKE после roam (`bypass_socket` → `protect`) и совпадает ли она с `UnderlyingNetworkTracker`,
который требует `VALIDATED`. Риск: в сетях с белыми списками (`WhiteListManager`) проверка системы может не проходить при доступном сервере.
`NetworkManager` и условия сети не менять без лога с реальным случаем; приёмка по разделу 5.
### 8.4. Keepalive по транспорту (энергия, слоты квоты Doze) — пересматривает решение «45 с»
- 45 с ≈ 80 будильников/ч при квоте 72/ч (7/ч без точных будильников). На Wi-Fi 90–120 с вдвое сократили бы число будильников.
- `keep_alive` читается при создании IKE SA (`ike_sa.c:3283`): после смены Wi-Fi ↔ LTE значение не обновится без переподключения.
- Риск: домашние роутеры с UDP-таймаутом 30 с. Только после замера, как необязательная настройка профиля; умолчание не менять.
### 8.5. Нативный роутер и логи (после Perfetto)
- `direct_engine.c`: при погашенном экране растянуть холостой интервал с 4 до 10–30 с (пробуждение через `wake`-fd), не вызывать
`check_timers` вхолостую. Только при включённом режиме «сайты РФ напрямую».
- `DiagLog`: при погашенном экране не писать повторяющиеся строки (watchdog, смена сети); `flush` оставить.
### 8.6. Порядок и граница
1. Релиз 1: шаг 0 (8.1), поведение не меняется. Владелец проверяет сборку из S3 и присылает `diag.log` и цифры.
2. Релизы 2–3: 8.2 и 8.3 — только если лог это оправдывает; каждый отдельно, с приёмкой раздела 5.
3. 8.4 — после замера на Wi-Fi и мобильной сети. 8.5 — после Perfetto.
- Не трогать: keepalive 45 с на мобильной сети, foreground-сервис, будильники `Scheduler`/`WatchdogReceiver`, DPD,
`NetworkManager`/`UnderlyingNetworkTracker` без приёмки.