diff --git a/.claude/agents/docs.md b/.claude/agents/docs.md new file mode 100644 index 000000000..5f55f13ef --- /dev/null +++ b/.claude/agents/docs.md @@ -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. diff --git a/.claude/agents/grep.md b/.claude/agents/grep.md new file mode 100644 index 000000000..45b373988 --- /dev/null +++ b/.claude/agents/grep.md @@ -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. diff --git a/.claude/agents/search.md b/.claude/agents/search.md new file mode 100644 index 000000000..3b81cec48 --- /dev/null +++ b/.claude/agents/search.md @@ -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. diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 000000000..6b14b1ea1 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,3 @@ +{ + "model": "claude-sonnet-5-5" +} diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..2e5394264 --- /dev/null +++ b/CLAUDE.md @@ -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. diff --git a/src/frontends/android/app/build.gradle b/src/frontends/android/app/build.gradle index 941af1d2d..6a9f7f815 100644 --- a/src/frontends/android/app/build.gradle +++ b/src/frontends/android/app/build.gradle @@ -45,8 +45,8 @@ android { minSdk = 24 targetSdk = 36 - versionCode 169 - versionName "2.19.1" + versionCode 170 + versionName "2.19.2" /* Base URL of the update storage, trailing slash included */ buildConfigField "String", "UPDATE_BASE_URL", "\"${updateBaseUrl}\"" diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/logic/CharonVpnService.java b/src/frontends/android/app/src/main/java/org/strongswan/android/logic/CharonVpnService.java index babd22b1d..e2e0b3358 100644 --- a/src/frontends/android/app/src/main/java/org/strongswan/android/logic/CharonVpnService.java +++ b/src/frontends/android/app/src/main/java/org/strongswan/android/logic/CharonVpnService.java @@ -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) @@ -1026,12 +1035,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(); @@ -1060,12 +1073,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"; @@ -1879,6 +1929,7 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe return; } mApplied = current; + mLastNetworkChange = SystemClock.elapsedRealtime(); } if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/logic/SaCloseReason.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/logic/SaCloseReason.kt new file mode 100644 index 000000000..eeae75fc5 --- /dev/null +++ b/src/frontends/android/app/src/main/java/org/strongswan/android/logic/SaCloseReason.kt @@ -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 . + * + * 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 + } +} diff --git a/src/frontends/android/app/src/main/jni/libandroidbridge/backend/android_service.c b/src/frontends/android/app/src/main/jni/libandroidbridge/backend/android_service.c index c8fc5296e..4b207b239 100644 --- a/src/frontends/android/app/src/main/jni/libandroidbridge/backend/android_service.c +++ b/src/frontends/android/app/src/main/jni/libandroidbridge/backend/android_service.c @@ -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); diff --git a/src/frontends/android/app/src/main/jni/libandroidbridge/charonservice.h b/src/frontends/android/app/src/main/jni/libandroidbridge/charonservice.h index a0a316ab9..aeaffb16c 100644 --- a/src/frontends/android/app/src/main/jni/libandroidbridge/charonservice.h +++ b/src/frontends/android/app/src/main/jni/libandroidbridge/charonservice.h @@ -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, }; /** diff --git a/src/frontends/android/app/src/test/java/org/strongswan/android/logic/SaCloseReasonTest.kt b/src/frontends/android/app/src/test/java/org/strongswan/android/logic/SaCloseReasonTest.kt new file mode 100644 index 000000000..ab2056d99 --- /dev/null +++ b/src/frontends/android/app/src/test/java/org/strongswan/android/logic/SaCloseReasonTest.kt @@ -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 . + * + * 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) + } +} diff --git a/src/frontends/android/docs/energy-plan.md b/src/frontends/android/docs/energy-plan.md index 84b665d68..34e29be5f 100644 --- a/src/frontends/android/docs/energy-plan.md +++ b/src/frontends/android/docs/energy-plan.md @@ -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` без приёмки.