tailnet-byok

Architecture架构

Four layers, one seam that matters, and a short list of popular libraries that are deliberately absent.四个分层、一道真正重要的接缝,以及一份简短的清单:那些被有意不用的流行库。

The shape整体形状#

ui/setup Compose screen + view model │ domain/ConnectionTester the six-step test domain/TailnetAddressPolicy what may be dialled, and why not │ net/ConnectivityProvider ← the seam ╱ ╲ net/SystemNetworkProvider net/tsnet/TsnetConnectivityProvider (plain sockets) (embedded node, gomobile bridge) │ tailnet/tailnet.go (Go, tsnet)

The one seam that matters is ConnectivityProvider. The UI never branches on which implementation is active; it reads a status flow and calls start/probe/fetch. That is what makes the embedded node genuinely optional — the default build simply has no implementation registered for it, and the UI says so instead of crashing — and what would make a third provider (a different mesh, a relay, a mock for tests) a local change.真正重要的那一道接缝是 ConnectivityProvider。UI 从不对当前生效的是哪个实现做分支 判断;它读一条状态流,并调用 start/probe/fetch。正因如此, 内嵌节点才是真正可选的 —— 默认构建干脆没有为它注册任何实现,UI 会如实说明而不是崩溃 —— 也正因如此,再加第三个 provider(另一种网状网络、一个中继、一个测试用的 mock)只会是一处局部改动。

Layer分层Package包Responsibility职责
UIUI ui/setup, ui/theme Compose screen, one UiState, one view model. No business rules.Compose 页面、一个 UiState、一个 view model。不含业务规则。
Domain领域 domain What may be dialled, and the ordered test. Pure Kotlin: no Android types, so it is testable on the JVM without Robolectric.什么可以被连接,以及那套有序的测试。纯 Kotlin:不含 Android 类型,所以不用 Robolectric 也能在 JVM 上测试。
Network网络 net, net/tsnet Two ways to reach the target, behind one interface.两种到达目标的方式,藏在同一个接口后面。
Data数据 data/config, data/crypto Persistence, and the Keystore-backed vault.持久化,以及由 Keystore 支撑的保险库。
Native原生 tailnet/ (Go) The embedded node, and the only code that talks to the control plane.内嵌节点,也是唯一与控制面通信的代码。

Why the HTTP happens in Go为什么 HTTP 发生在 Go 侧#

This is the least obvious decision in the codebase, so it is worth stating.这是整个代码库里最不显然的一个决定,所以值得说清楚。

The socket has to be created by the tailnet's own network stack — that is what makes the traffic go through the tunnel. But a Go net.Conn has no Java representation, so no JNI binding can hand it back. The options were:套接字必须由 tailnet 自己的网络栈创建 —— 正是这一点让流量走隧道。但 Go 的 net.Conn 没有 Java 表示形式,所以任何 JNI 绑定都无法把它交回来。可选方案有:

  1. run a SOCKS5 proxy on loopback and have OkHttp dial through it;在回环上跑一个 SOCKS5 代理,让 OkHttp 通过它发起连接;
  2. give the Go side a request-shaped API and return the response bytes.给 Go 侧一个「请求形状」的 API,把响应字节返回回来。

Option 2 was chosen. It removes a listening socket from the process, removes a credential for that proxy, and keeps the whole path in one place. The cost is that HTTP lives in two implementations rather than one — which is a real cost, and the reason ConnectivityProvider.fetch() takes a request object rather than exposing a client.选了方案 2。它让进程里少了一个监听套接字,少了这个代理所需的凭据,并把整条路径收在一处。 代价是 HTTP 存在于两套实现而不是一套 —— 这是真实的代价,也是 ConnectivityProvider.fetch() 接收一个请求对象、而不是暴露一个 client 的原因。

The binding rules绑定规则#

tailnet/tailnet.go follows one rule: every exported function takes and returns only String, Int, Bool or error. Structured data crosses as JSON; byte payloads are base64 inside that JSON.tailnet/tailnet.go 只遵守一条规则:每个导出函数只接受并只返回 String、Int、Bool 或 error。 结构化数据以 JSON 过界;字节载荷是那份 JSON 里的 base64。

That is not stylistic. gomobile cannot express a Go interface or a net.Conn, and multi-value returns bind differently across gomobile revisions. Keeping the surface primitive means the generated Java is stable, reviewable, and independent of the binding generator's mood.这不是风格问题。gomobile 无法表达 Go 的 interface 或 net.Conn,而多返回值在不同 gomobile 修订版之间绑定的方式不一样。把接口面保持为原始类型,意味着生成的 Java 是稳定的、 可审阅的,也不受绑定生成器心情的影响。

Why one provider is loaded reflectively为什么有一个 provider 用反射加载#

TsnetConnectivityProvider compiles against classes that only exist after gomobile bind. The default build — a fresh clone, and CI without a Go toolchain — has no such classes. A direct reference from shared code would break the build for everyone who is not shipping the native bridge.TsnetConnectivityProvider 编译时依赖的类只在 gomobile bind 之后才存在。 默认构建 —— 刚 clone 下来的仓库,以及没有 Go 工具链的 CI —— 没有这些类。从共享代码里直接引用, 会让所有不发布原生桥接层的人的构建都挂掉。

ProviderRegistry therefore resolves the installer with Class.forName and a single install method, only when BuildConfig.TSNET_ENABLED is true. A separate Gradle module per provider does not remove the problem — the consumer still has to name the class — it only moves the failure.所以 ProviderRegistry 用 Class.forName 加一个 install 方法来解析安装器,而且只在 BuildConfig.TSNET_ENABLED 为真时这么做。给每个 provider 单独一个 Gradle 模块并不能消除这个问题 —— 使用方仍然要写出类名 —— 它只是把失败点挪了个地方。

Consequence后果

R8 cannot see the reference, so app/proguard-rules.pro keeps **.net.tsnet.** explicitly. Without that rule the failure is release-only and presents as "the embedded node feature silently disappeared" — the worst kind of bug to chase. If you add a provider, add its keep rule too.R8 看不到这个引用,所以 app/proguard-rules.pro 显式保留 **.net.tsnet.**。没有这条规则,故障只在 release 出现,表现是「内嵌节点功能悄悄消失了」 —— 最难追的那类 bug。如果你要新增 provider,也别忘了给它加 keep 规则。

What is deliberately not there有意不放在那里的东西#

No Hilt不用 Hilt

Four objects, one scope. A framework would add an annotation processor, a plugin, a generated component, and a second way to construct every object — to remove four lines of wiring in AppContainer.kt that a reader can follow at a glance.四个对象,一个作用域。引入框架会带来一个注解处理器、一个插件、一个生成的组件,以及构造每个 对象的第二种方式 —— 只为了省掉 AppContainer.kt 里四行接线代码,而那四行读者一眼 就能看懂。

This is a judgement, not a principle. Add a background component or a second graph and the trade flips; the wiring is in one file precisely so the swap is one file.这是判断,不是原则。加一个后台组件或第二张依赖图,这笔交易就会反过来;接线集中在一个文件里, 正是为了让替换也只需改一个文件。

No Retrofit不用 Retrofit

The app makes one kind of request to exactly one host, and it has to go through the provider seam anyway. Retrofit's value is managing a large typed API surface; there is no such surface here.这个 App 只对唯一一个主机发一种请求,而且无论如何都要经过 provider 那道接缝。Retrofit 的价值 在于管理庞大的强类型 API 表面;这里没有这样的表面。

No Room不用 Room

Eleven scalar preferences in DataStore. A relational store would be ceremony around a key-value workload.DataStore 里存放十一个标量偏好设置。关系型存储在这样一个键值型负载周围只是繁文缛节。

No navigation library不用导航库

One screen. Adding a back stack to a single destination is a graph with one node.只有一个页面。给单一目的地加一个回退栈,就是一张只有一个节点的图。

No Timber不用 Timber

One fewer dependency to audit in the code path that handles the credential. SafeLog is forty lines and does the redaction Timber would not.在处理凭据的那条代码路径上,少一个要审计的依赖。SafeLog 只有四十行, 而且做了 Timber 不会做的脱敏。

No analytics, no crash reporter不接统计,也不接崩溃上报

Not a cost decision. A crash reporter is a network destination that sees your configuration, and this app's whole claim is that it has no such destination.这不是成本考量。崩溃上报是一个能看到你配置的网络目标,而这个 App 的全部主张就是它没有 这样的目标。

State and lifetime状态与生命周期#

Three rules, each with a reason:三条规则,每条都有理由:

Repository layout仓库结构#

app/ src/main/kotlin/…/ the app: ui, domain, net, data, di src/main/res/ theme, icons, network security config, backup rules src/tsnet/kotlin/…/ the embedded provider — compiled only with -PwithTsnet=true src/test/kotlin/…/ unit tests (JVM, no device) libs/ where gomobile bind drops tailnet.aar (git-ignored) tailnet/ the Go module: tsnet wrapped for gomobile scripts/ fetch-toolchain · make-wrapper · build-bridge site/ the docs site published to GitHub Pages docs/ the same material as Markdown, for GitHub browsing .github/workflows/ ci · tailnet-bridge · release · docs-pages · sync-personal-site

Why src/tsnet is a separate source set为什么 src/tsnet 是单独的 source set

app/build.gradle.kts adds src/tsnet/kotlin to the main source set only when -PwithTsnet=true and app/libs/tailnet.aar exists. If the property is set and the AAR is missing, the build fails immediately with the command to produce it — rather than with the ten thousand lines of "unresolved reference: mobile" that a naive configuration produces.app/build.gradle.kts 只在 -PwithTsnet=true 且 app/libs/tailnet.aar 存在时,才把 src/tsnet/kotlin 加入主 source set。 如果设置了该属性却缺少这个 AAR,构建会立刻失败,并给出生成它的命令 —— 而不是像天真配置那样 产出上万行「unresolved reference: mobile」。

Known gaps已知缺口

Listed so that they are not discovered by you:列在这里,是为了不让你自己去发现它们:

Gap缺口Impact影响
No instrumented tests没有 instrumentation 测试 Anything needing a real device — Keystore behaviour, the native bridge — is verified by hand, not by CI.任何需要真机的东西 —— Keystore 行为、原生桥接层 —— 都是手工验证,不是 CI 验证。
UI strings are not extractedUI 字符串没有抽取出来 The app cannot be translated without moving copy out of the Compose sources into resources. This is a deliberate trade: each warning currently sits next to the condition that triggers it.不把文案从 Compose 源码搬进资源文件,就无法翻译这个 App。这是有意的取舍: 目前每条警告就挨着触发它的那个条件。
Per-ABI AAR size is unmeasured各 ABI 的 AAR 体积没有测量 An embedded tsnet node adds a Go runtime. It is not small, and abiFilters is not configured.一个内嵌的 tsnet 节点会带上 Go 运行时。它不小,而 abiFilters 没有配置。
No reproducible-build guarantee没有可复现构建的保证 Release APKs are signed in CI but not verified bit-for-bit reproducible.release APK 在 CI 里签名,但没有逐比特验证其可复现性。
The bridge is compiled, not audited桥接层只是被编译,没有被审计 tsnet is a large dependency. This project neither vendors nor reviews it.tsnet 是个大依赖。本项目既不把它 vendor 进来,也不审阅它。

Extending it扩展它

A third provider, in four steps:加第三个 provider,分四步:

  1. Add a value to ProviderId.给 ProviderId 加一个值。
  2. Implement ConnectivityProvider.实现 ConnectivityProvider。
  3. Register it in ProviderRegistry.initialise().在 ProviderRegistry.initialise() 里注册它。
  4. If it needs a heavy dependency, put it in its own source set and add a Class.forName hook plus a ProGuard keep rule — the pattern tsnet uses.如果它需要一个重量级依赖,就把它放进自己的 source set,并加一个 Class.forName 钩子 以及一条 ProGuard keep 规则 —— 即 tsnet 用的那套模式。

No UI change is required. The provider selector is driven by ProviderId.entries and the registry's availability list.不需要改 UI。provider 选择器由 ProviderId.entries 和注册表的可用性列表驱动。