-
Notifications
You must be signed in to change notification settings - Fork 620
android: harden phase-3.2 datapath validation #776
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
cad7915
efde5b2
a82c3d9
fe770e0
7b289d2
49e78bb
8fc9be4
f980a2e
3336809
50b9a42
36022ba
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -47,3 +47,6 @@ libtailscale-sources.jar | |
| .DS_Store | ||
|
|
||
| tailscale.version | ||
| .opencode/ | ||
| .root/ | ||
| .envrc | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,52 @@ | ||
| # AGENTS.md | ||
|
|
||
| ## Project Overview | ||
|
|
||
| This repository contains the open source Tailscale Android client. Tailscale is a private WireGuard® network made easy. The Android client provides seamless VPN connectivity to Tailscale networks on Android devices. | ||
|
|
||
| ## Documentation Index | ||
|
|
||
| The following Chinese documentation is available in the `docs/` directory: | ||
|
|
||
| - [docs/01-项目指南.md](docs/01-项目指南.md) - Project overview, quick start, and usage instructions | ||
| - [docs/02-开发指南.md](docs/02-开发指南.md) - Adding new features and development workflow | ||
| - [docs/03-技术指南.md](docs/03-技术指南.md) - Architecture design, core components, and tech stack | ||
| - [docs/04-更新日志.md](docs/04-更新日志.md) - Version updates and bug fixes | ||
|
|
||
| ## Common Commands | ||
|
|
||
| - `make apk` - Build the debug APK | ||
| - `make install` - Install the APK to a connected device | ||
| - `make androidsdk` - Install necessary Android SDK components | ||
| - `make docker-shell` - Start a Docker-based development shell | ||
| - `make tag_release` - Bump Android version code, update version name, and tag commit | ||
|
|
||
| ## Architecture Highlights | ||
|
|
||
| - Mixed Go and Android/Kotlin development | ||
| - Go code compiled to JNI library for core Tailscale functionality | ||
| - Standard Android project structure with Gradle build system | ||
| - Support for multiple build environments: Android Studio, Docker, Nix | ||
|
|
||
| ## Documentation Maintenance Rules | ||
|
|
||
| - **docs/ directory**: All documentation in the `docs/` directory must be written and maintained in Chinese. | ||
| - **PROGRESS.md**: The `PROGRESS.md` file must be written and maintained in Chinese. | ||
| - **AGENTS.md**: This file (AGENTS.md) must be written and maintained in English. | ||
|
|
||
| ### PROGRESS.md Rules | ||
|
|
||
| `PROGRESS.md` is a sparse, append-only log for high-signal lessons learned, not a routine work log. | ||
|
|
||
| - Only append entries after important bug fixes or significant changes. | ||
| - Never record project initialization, scaffolding generation, documentation-only updates, formatting-only changes, routine configuration tweaks, or other low-signal work. | ||
| - Each entry must be concise and include: problem, solution, prevention, and commitID. | ||
| - The purpose is to help future AI agents and developers avoid repeating the same mistakes. | ||
|
|
||
| ## Development Workflow Rules | ||
|
|
||
| - **Version Bump**: After modifying any code, the Android version code must be incremented by 1 in `android/build.gradle`. | ||
| - **Build Verification**: After modifying any feature or implementation code, `make apk` must be run and complete successfully before the change can be considered successful. | ||
| - **Device Validation**: After `make apk` succeeds for a code change, the updated APK must be installed onto a real Android device and the full end-to-end device test flow must pass before the change can be considered successful. | ||
| - **Validation Executor**: `make apk`, APK installation, and real-device test execution must be delegated to the `execution_runner` subagent instead of the main agent to keep build and device-log noise out of the main context. | ||
| - **Validation Model**: The `execution_runner` subagent used for build and device validation must use `gpt-5.4-mini` to minimize token usage. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,32 @@ | ||
| # 经验教训记录 | ||
|
|
||
| > 每次遇到问题或完成重要改动后在此记录,必须附上 git commitID。 | ||
| > 仅记录重要 bug 修复或重大变更;不要记录初始化、脚手架生成、纯文档补全等噪音内容。 | ||
|
|
||
| --- | ||
|
|
||
| ## 记录模板 | ||
|
|
||
| ## [YYYY-MM-DD] 问题标题 | ||
| - **问题**: 描述问题现象、影响范围和触发原因 | ||
| - **解决**: 描述修复方式和关键改动 | ||
| - **避免**: 描述以后如何避免再次出现 | ||
| - **commitID**: `待填写:实际 commit hash` | ||
|
|
||
| ## [2026-04-05] Android 侧 SOCKS MVP 自动化验证闭环 | ||
| - **问题**: 初版 Android 侧 SOCKS5 MVP 虽然已能通过 adb 触发测试,但存在测试入口暴露面过大、多个场景共用同一 WorkManager unique work 导致结果互相覆盖、以及脚本无法通过退出码做机器判定的问题,影响自动化联调的稳定性与安全边界。 | ||
| - **解决**: 新增 `AdbTcpHttpTestContract` 与 `AdbTcpHttpTestWorker`,通过 `IPNReceiver` 提供 debug-only 的 `RUN_NETWORK_TEST` 入口,按 `requestId` 隔离 unique work,限制 `timeoutMs <= 10_000`,补齐 `tsocks-test-build/install/trigger/logs/pass-fail/run-all.sh` 脚本链路,追加中文开发说明,并完成 `DIRECT`、`TAILSCALE_NORMAL`、`TAILNET_SOCKS` 三类路径的真机 adb 验证。 | ||
| - **避免**: 后续新增 adb/debug harness 时,应同步设计入口收口、并发隔离、稳定日志字段与非 0 退出码,先把“可自动判定”和“不会误暴露到 release”作为基础约束,而不是事后补救。 | ||
| - **commitID**: `fe770e031305534946c1ebc1f7516db66b5dadbc` | ||
|
|
||
| ## [2026-04-07] phase-3.1a 最小规则化 TUN 内 TCP 分流原型 | ||
| - **问题**: phase-3 的真实数据面虽然已经能接管单个 `104.18.26.120:80` 出站 TCP flow,但规则匹配、`/32` route 注入和 gVisor proof-stack 拦截分别散落在 `tsocks.go`、`net.go`、`step0_tun.go`,导致“逻辑 allowlist”与“真实接管目标”割裂,无法稳定扩到多公网目标,也容易把 baseline 环境未就绪误判成回归。 | ||
| - **解决**: 新增集中式 `tsocks_rules.go`,用最小 `IP:port` / `IP:*` 规则表统一驱动 route 选择、`TAILNET_SOCKS` 的 `/32` 注入和 step0 多目标拦截;补充 `hostHeader` 与 `previewOnly` 调试字段,扩展 `phase3-public-http-a/b`、`phase3-public-no-match`、`phase3-wrong-port-entered-tun`、`phase3-recursion-guard` 场景,并让日志稳定输出 `matchedRule`、`selectedRoute`、`injectedRoute`、`offloadDecision`、`recursionGuard` 等机判字段;同时在 `run-all` 中为 phase-1 baseline 增加就绪探测,避免把联调服务未准备好误报成代码失败。 | ||
| - **避免**: 后续继续演进 tun 边界实验时,必须始终保持“规则源唯一、route 注入派生、数据面日志可机判”这三件事同步推进;同时要把 `/32` 注入只能精确到 IP 的语义边界写清楚,不要把 phase-3.1a 描述成真正的系统级 `IP:port` 透明分流。 | ||
| - **commitID**: `待填写:实际 commit hash` | ||
|
|
||
| ## [2026-04-12] phase-3.2 数据面可验证、可压测、可诊断工程原型 | ||
| - **问题**: phase-3.1a 虽然功能可用,但缺少稳定 `flow_id`、并发压测、TCP 生命周期观测、资源回收观测和可重复 baseline 测试服务,导致“能跑通”与“能验证/能诊断”之间仍有明显断层。 | ||
| - **解决**: 为 datapath 引入稳定 `flow_id`、`terminator_attach`/`socks_connect`/`relay_start`/`relay_end`/`conn_close` 等统一日志事件,补齐 `SYN/SYN-ACK/ACK/FIN/RST` 生命周期观测与 `activeRelays`/`goroutines`/`openFDs` 资源快照;新增动态 baseline 环境解析、host 侧 HTTP/TCP 测试服务、自启动与健康检查脚本,并补充 `phase32` 并发/错端口/lifecycle 验证脚本,完成真机 `PHASE32_PASS` 验证。 | ||
| - **避免**: 后续继续演进 datapath 时,任何“规则或 relay 行为改动”都必须同步维护三件事:稳定 flow 关联字段、可重复 baseline 环境、以及并发与 lifecycle 的自动机判脚本;不要再依赖单流人工观察来判断稳定性。 | ||
| - **commitID**: `待填写:实际 commit hash` |
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -90,6 +90,13 @@ | |||||
| </intent-filter> | ||||||
| </activity> | ||||||
|
|
||||||
| <activity | ||||||
| android:name=".DatapathTestActivity" | ||||||
| android:excludeFromRecents="true" | ||||||
| android:exported="true" | ||||||
|
||||||
| android:exported="true" | |
| android:exported="false" |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,39 @@ | ||
| // Copyright (c) Tailscale Inc & AUTHORS | ||
| // SPDX-License-Identifier: BSD-3-Clause | ||
| package com.tailscale.ipn | ||
|
|
||
| object AdbTcpHttpTestContract { | ||
| const val ACTION_RUN_TEST = "com.tailscale.ipn.RUN_NETWORK_TEST" | ||
| const val WORK_RUN_TEST = "ipn-run-network-test" | ||
|
|
||
| const val EXTRA_SCENARIO = "scenario" | ||
| const val EXTRA_REQUEST_ID = "requestId" | ||
| const val EXTRA_HOST = "host" | ||
| const val EXTRA_PORT = "port" | ||
| const val EXTRA_PROTOCOL = "protocol" | ||
| const val EXTRA_PATH = "path" | ||
| const val EXTRA_PAYLOAD = "payload" | ||
| const val EXTRA_HOST_HEADER = "hostHeader" | ||
| const val EXTRA_TIMEOUT_MS = "timeoutMs" | ||
| const val EXTRA_SOCKS_ENABLED = "socksEnabled" | ||
| const val EXTRA_PREVIEW_ONLY = "previewOnly" | ||
| const val EXTRA_URL = "url" | ||
|
|
||
| const val TAG_TEST = "TSOCKS_TEST" | ||
| const val TAG_ROUTE = "TSOCKS_ROUTE" | ||
| const val TAG_SOCKS = "TSOCKS_SOCKS" | ||
| const val TAG_DATAPATH = "TSOCKS_DATAPATH" | ||
|
|
||
| const val DEFAULT_PROTOCOL = "tcp" | ||
| const val DEFAULT_PATH = "/" | ||
| const val DEFAULT_TIMEOUT_MS = 5_000L | ||
| const val DEFAULT_SOCKS_ENABLED = true | ||
|
|
||
| const val LAN_HOST = "192.168.31.101" | ||
| const val TAILNET_LAB_HOST = "100.109.193.113" | ||
| const val TAILNET_DOMAIN_HOST = "wide-ts-wu" | ||
| const val SOCKS_SERVER_HOST = "100.78.63.77" | ||
| const val SOCKS_SERVER_PORT = 1080 | ||
| const val PUBLIC_ALLOWLIST_HOST = "example.com" | ||
| const val PUBLIC_ALLOWLIST_PORT = 80 | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,95 @@ | ||
| // Copyright (c) Tailscale Inc & AUTHORS | ||
| // SPDX-License-Identifier: BSD-3-Clause | ||
| package com.tailscale.ipn | ||
|
|
||
| import android.content.Context | ||
| import androidx.work.CoroutineWorker | ||
| import androidx.work.Data | ||
| import androidx.work.WorkerParameters | ||
| import com.tailscale.ipn.util.TSLog | ||
| import kotlinx.serialization.SerialName | ||
| import kotlinx.serialization.Serializable | ||
| import kotlinx.serialization.encodeToString | ||
| import kotlinx.serialization.json.Json | ||
| import org.json.JSONObject | ||
|
|
||
| class AdbTcpHttpTestWorker(appContext: Context, workerParams: WorkerParameters) : | ||
| CoroutineWorker(appContext, workerParams) { | ||
|
|
||
| override suspend fun doWork(): Result { | ||
| val request = ProbeRequest.from(inputData) | ||
|
|
||
| return runCatching { | ||
| val response = App.get().getLibtailscaleApp().runTsocksProbe(Json.encodeToString(request)) | ||
| val json = JSONObject(response) | ||
| Result.success( | ||
| Data.Builder() | ||
| .putString("route", json.optString("route", "UNKNOWN")) | ||
| .putString("matchedRule", json.optString("matchedRule", "unknown")) | ||
| .putInt("bytesSent", json.optInt("bytesSent", 0)) | ||
| .putInt("bytesReceived", json.optInt("bytesReceived", 0)) | ||
| .putString("detail", json.optString("detail", "")) | ||
| .build()) | ||
| } | ||
| .getOrElse { error -> | ||
| TSLog.e( | ||
| AdbTcpHttpTestContract.TAG_TEST, | ||
| "event=TEST_FAIL requestId=${request.requestId} scenario=${request.scenario} route=UNKNOWN reason=${sanitize(error.message ?: error.javaClass.simpleName)}") | ||
| Result.failure( | ||
| Data.Builder().putString("reason", error.message ?: error.javaClass.simpleName).build()) | ||
| } | ||
| } | ||
|
|
||
| @Serializable | ||
| private data class ProbeRequest( | ||
| @SerialName("scenario") val scenario: String, | ||
| @SerialName("requestId") val requestId: String, | ||
| @SerialName("host") val host: String, | ||
| @SerialName("port") val port: Int, | ||
| @SerialName("protocol") val protocol: String, | ||
| @SerialName("path") val path: String, | ||
| @SerialName("payload") val payload: String, | ||
| @SerialName("hostHeader") val hostHeader: String, | ||
| @SerialName("timeoutMs") val timeoutMs: Int, | ||
| @SerialName("socksEnabled") val socksEnabled: Boolean, | ||
| @SerialName("previewOnly") val previewOnly: Boolean, | ||
| ) { | ||
| companion object { | ||
| fun from(data: Data): ProbeRequest { | ||
| return ProbeRequest( | ||
| scenario = data.getString(AdbTcpHttpTestContract.EXTRA_SCENARIO)?.trim().orEmpty().ifEmpty { "unspecified" }, | ||
| requestId = | ||
| data.getString(AdbTcpHttpTestContract.EXTRA_REQUEST_ID)?.trim().orEmpty().ifEmpty { | ||
| "req-${System.currentTimeMillis()}" | ||
| }, | ||
| host = data.getString(AdbTcpHttpTestContract.EXTRA_HOST)?.trim().orEmpty(), | ||
| port = data.getInt(AdbTcpHttpTestContract.EXTRA_PORT, -1), | ||
| protocol = | ||
| data.getString(AdbTcpHttpTestContract.EXTRA_PROTOCOL) | ||
| ?.trim() | ||
| ?.lowercase() | ||
| .orEmpty() | ||
| .ifEmpty { AdbTcpHttpTestContract.DEFAULT_PROTOCOL }, | ||
| path = data.getString(AdbTcpHttpTestContract.EXTRA_PATH)?.trim().orEmpty(), | ||
| payload = data.getString(AdbTcpHttpTestContract.EXTRA_PAYLOAD).orEmpty(), | ||
| hostHeader = data.getString(AdbTcpHttpTestContract.EXTRA_HOST_HEADER)?.trim().orEmpty(), | ||
| timeoutMs = | ||
| data.getLong( | ||
| AdbTcpHttpTestContract.EXTRA_TIMEOUT_MS, | ||
| AdbTcpHttpTestContract.DEFAULT_TIMEOUT_MS) | ||
| .coerceIn(1L, 10_000L) | ||
| .toInt(), | ||
| socksEnabled = | ||
| data.getBoolean( | ||
| AdbTcpHttpTestContract.EXTRA_SOCKS_ENABLED, | ||
| AdbTcpHttpTestContract.DEFAULT_SOCKS_ENABLED), | ||
| previewOnly = data.getBoolean(AdbTcpHttpTestContract.EXTRA_PREVIEW_ONLY, false), | ||
| ) | ||
| } | ||
| } | ||
| } | ||
|
|
||
| private fun sanitize(value: String): String { | ||
| return value.replace(Regex("\\s+"), "_").replace(Regex("[^a-zA-Z0-9_./:=-]"), "-") | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,114 @@ | ||
| // Copyright (c) Tailscale Inc & AUTHORS | ||
| // SPDX-License-Identifier: BSD-3-Clause | ||
| package com.tailscale.ipn | ||
|
|
||
| import android.app.Activity | ||
| import android.os.Bundle | ||
| import com.tailscale.ipn.util.TSLog | ||
| import java.net.Socket | ||
| import java.net.URI | ||
| import java.nio.charset.StandardCharsets | ||
| import kotlinx.coroutines.CoroutineScope | ||
| import kotlinx.coroutines.Dispatchers | ||
| import kotlinx.coroutines.SupervisorJob | ||
| import kotlinx.coroutines.cancel | ||
| import kotlinx.coroutines.launch | ||
| import kotlinx.coroutines.withContext | ||
|
|
||
| class DatapathTestActivity : Activity() { | ||
| private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main) | ||
|
|
||
| override fun onCreate(savedInstanceState: Bundle?) { | ||
| super.onCreate(savedInstanceState) | ||
| if (!BuildConfig.DEBUG) { | ||
| finish() | ||
| return | ||
| } | ||
|
|
||
| val scenario = intent.getStringExtra(AdbTcpHttpTestContract.EXTRA_SCENARIO)?.trim().orEmpty() | ||
| val requestId = | ||
| intent.getStringExtra(AdbTcpHttpTestContract.EXTRA_REQUEST_ID)?.trim().orEmpty().ifEmpty { | ||
| "req-${System.currentTimeMillis()}" | ||
| } | ||
| val url = intent.getStringExtra(AdbTcpHttpTestContract.EXTRA_URL)?.trim().orEmpty() | ||
| val timeoutMs = | ||
| intent.getLongExtra( | ||
| AdbTcpHttpTestContract.EXTRA_TIMEOUT_MS, AdbTcpHttpTestContract.DEFAULT_TIMEOUT_MS) | ||
| .coerceIn(1L, 10_000L) | ||
| .toInt() | ||
|
|
||
| scope.launch { | ||
| if (url.isEmpty()) { | ||
| TSLog.e( | ||
| AdbTcpHttpTestContract.TAG_TEST, | ||
| "event=TEST_FAIL requestId=$requestId scenario=$scenario route=DATAPATH reason=missing_url") | ||
| finish() | ||
| return@launch | ||
| } | ||
|
|
||
| TSLog.d( | ||
| AdbTcpHttpTestContract.TAG_TEST, | ||
| "event=request_start requestId=$requestId scenario=$scenario protocol=http url=${sanitize(url)} flow=datapath-client") | ||
|
|
||
| val result = | ||
| withContext(Dispatchers.IO) { | ||
| runCatching { | ||
| val uri = URI(url) | ||
| val host = uri.host ?: throw IllegalArgumentException("missing_host") | ||
| val port = if (uri.port == -1) 80 else uri.port | ||
| val path = if (uri.rawPath.isNullOrBlank()) "/" else uri.rawPath | ||
| val socket = Socket() | ||
| socket.connect(java.net.InetSocketAddress(host, port), timeoutMs) | ||
| socket.soTimeout = timeoutMs | ||
| val request = | ||
| buildString { | ||
| append("GET ") | ||
| append(path) | ||
| append(" HTTP/1.1\r\n") | ||
| append("Host: ") | ||
| append(host) | ||
| append("\r\nConnection: close\r\n") | ||
| append("User-Agent: tailscale-android-tsocks-datapath-test\r\n\r\n") | ||
| } | ||
| .toByteArray(StandardCharsets.UTF_8) | ||
| socket.getOutputStream().write(request) | ||
| socket.getOutputStream().flush() | ||
| val response = socket.getInputStream().readBytes() | ||
| socket.close() | ||
| val statusLine = response.toString(StandardCharsets.UTF_8).lineSequence().firstOrNull()?.trim().orEmpty() | ||
| val status = statusLine.split(' ').getOrNull(1)?.toIntOrNull() ?: 0 | ||
| val bodyBytes = response | ||
| Triple(status in 200..399, status, bodyBytes.size) | ||
| } | ||
| } | ||
|
|
||
| result.fold( | ||
| onSuccess = { (success, status, bodySize) -> | ||
| if (success) { | ||
| TSLog.d( | ||
| AdbTcpHttpTestContract.TAG_TEST, | ||
| "event=TEST_PASS requestId=$requestId scenario=$scenario route=DATAPATH protocol=http bytesSent=0 bytesReceived=$bodySize detail=http_status_$status") | ||
| } else { | ||
| TSLog.e( | ||
| AdbTcpHttpTestContract.TAG_TEST, | ||
| "event=TEST_FAIL requestId=$requestId scenario=$scenario route=DATAPATH reason=http_status_$status") | ||
| } | ||
| }, | ||
| onFailure = { error -> | ||
| TSLog.e( | ||
| AdbTcpHttpTestContract.TAG_TEST, | ||
| "event=TEST_FAIL requestId=$requestId scenario=$scenario route=DATAPATH reason=${sanitize(error.message ?: error.javaClass.simpleName)}") | ||
| }) | ||
| finish() | ||
| } | ||
| } | ||
|
|
||
| override fun onDestroy() { | ||
| super.onDestroy() | ||
| scope.cancel() | ||
| } | ||
|
|
||
| private fun sanitize(value: String): String { | ||
| return value.replace(Regex("\\s+"), "_").replace(Regex("[^a-zA-Z0-9_./:=-]"), "-") | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
AGENTS.md states that after modifying any code,
android/build.gradleversionCode must be incremented by 1. This change jumps from 468 to 503, which conflicts with that rule. Either adjust the documented rule (if larger bumps are allowed) or update versionCode to match the stated +1 policy.