android: the diagnostic log records why an established IKE SA went down (reauth, retransmit timeout, network change, user, other) with a per-day drop counter; plan section 8 added
Native signals for retransmit timeout and reauth are diagnostics only and change no state. docs/energy-plan.md section 8 is the 2.20 plan for connection stability and energy use. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
bd1ad8390a
commit
ada48f0ca1
6 files changed
+271
No files matched your search
+51
@@ -92,6 +92,7 @@ import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Set;
|
||||
import java.util.SortedSet;
|
||||
import java.util.TimeZone;
|
||||
import java.util.TreeSet;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
@@ -171,6 +172,12 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe
|
||||
private volatile boolean mProfileUpdated;
|
||||
private volatile boolean mTerminate;
|
||||
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;
|
||||
/* start of the current CONNECTED session for the notification chronometer */
|
||||
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_CERTIFICATE_UNAVAILABLE = 7;
|
||||
static final int STATE_GENERIC_ERROR = 8;
|
||||
static final int STATE_RETRANSMIT_TIMEOUT = 9;
|
||||
static final int STATE_REAUTH = 10;
|
||||
|
||||
@Override
|
||||
public int onStartCommand(Intent intent, int flags, int startId)
|
||||
@@ -1025,12 +1034,16 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe
|
||||
switch (status)
|
||||
{
|
||||
case STATE_CHILD_SA_DOWN:
|
||||
logSaDown();
|
||||
if (!mIsDisconnecting)
|
||||
{
|
||||
setState(State.CONNECTING);
|
||||
}
|
||||
break;
|
||||
case STATE_CHILD_SA_UP:
|
||||
mSaUpSince = SystemClock.elapsedRealtime();
|
||||
mRetransmitTimeout = false;
|
||||
mReauth = false;
|
||||
setState(State.CONNECTED);
|
||||
scheduleDnsDiagnostics();
|
||||
scheduleRouteDiagnostics();
|
||||
@@ -1059,12 +1072,49 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe
|
||||
case STATE_GENERIC_ERROR:
|
||||
setErrorDisconnect(ErrorState.GENERIC_ERROR);
|
||||
break;
|
||||
case STATE_RETRANSMIT_TIMEOUT:
|
||||
mRetransmitTimeout = true;
|
||||
break;
|
||||
case STATE_REAUTH:
|
||||
mReauth = true;
|
||||
break;
|
||||
default:
|
||||
Log.e(TAG, "Unknown status code received");
|
||||
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. */
|
||||
private static final long DNS_DIAG_DELAY_MS = 10_000;
|
||||
private static final String DNS_DIAG_PROBE = "one.one.one.one";
|
||||
@@ -1878,6 +1928,7 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe
|
||||
return;
|
||||
}
|
||||
mApplied = current;
|
||||
mLastNetworkChange = SystemClock.elapsedRealtime();
|
||||
}
|
||||
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 (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
|
||||
* is still active */
|
||||
this->lock->write_lock(this->lock);
|
||||
@@ -674,6 +678,12 @@ METHOD(listener_t, alert, bool,
|
||||
{
|
||||
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:
|
||||
charonservice->update_status(charonservice,
|
||||
CHARONSERVICE_LOOKUP_ERROR);
|
||||
|
||||
@@ -63,6 +63,9 @@ enum android_vpn_state_t {
|
||||
CHARONSERVICE_UNREACHABLE_ERROR,
|
||||
CHARONSERVICE_CERTIFICATE_UNAVAILABLE,
|
||||
CHARONSERVICE_GENERIC_ERROR,
|
||||
/* diagnostics only, no state change: the next CHILD_STATE_DOWN is caused by it */
|
||||
CHARONSERVICE_RETRANSMIT_TIMEOUT,
|
||||
CHARONSERVICE_REAUTH,
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
+92
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -180,3 +180,50 @@
|
||||
Проверено на эмуляторе `shx36` (профиль на 192.0.2.1, туннель не поднимается, проверялась логика): `am crash` → watchdog поднимает сервис (~80 с, окно неточного
|
||||
будильника); сценарий «Wi-Fi → включить» срабатывает по событию сети при выключенных правилах сетей (~10 с); будильник сценария по времени будит убитый процесс,
|
||||
`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` без приёмки.
|
||||
Reference in new issue
Block a user