tailnet-byok

Build & use构建与使用

Two build modes. One needs JDK 17 and an Android SDK; the other additionally needs Go, gomobile and the NDK, and gives you the embedded node.两种构建模式。一种只需要 JDK 17 和 Android SDK;另一种还需要 Go、gomobile 和 NDK, 并且会给你内嵌节点。

The honest summary诚实的总结

A fresh clone builds and runs without Go. It will tell you the embedded provider is not compiled in and offer the system-network fallback. If the whole point for you is the auth-key field, you need the second mode — and about two gigabytes of toolchain, which one script will fetch.刚克隆下来的代码不需要 Go 就能构建并运行。它会告诉你内嵌提供方没有编译进去, 并提供系统网络作为回退方案。如果你要的正是那个认证密钥输入框,就需要第二种模式 —— 以及大约 2 GB 的工具链,一个脚本就能把它拉下来。

Fast path快速路径#

git clone https://github.com/zero6689/tailnet-byok.git
cd tailnet-byok

node scripts/fetch-toolchain.mjs gradle sdk   # Gradle + Android SDK, if you lack them
node scripts/make-wrapper.mjs                 # generates gradlew / gradlew.bat

./gradlew assembleDebug                        # macOS / Linux
.\gradlew.bat assembleDebug                    # Windows

That produces an APK in app/build/outputs/apk/debug/. It runs, and the embedded provider will be reported as unavailable — with the reason shown in the UI rather than as a crash.这样会在 app/build/outputs/apk/debug/ 下生成一个 APK。它能跑,只是内嵌 提供方会被报为不可用 —— 原因会显示在 UI 上,而不是直接崩溃。

Full build, with the embedded node完整构建:带内嵌节点#

node scripts/fetch-toolchain.mjs go ndk   # Go + Android NDK
node scripts/build-bridge.mjs             # gomobile bind -> app/libs/tailnet.aar

./gradlew assembleDebug -PwithTsnet=true

scripts/build-bridge.mjs --check reports the toolchain it can see without building anything, which is the fastest way to find out what is missing.scripts/build-bridge.mjs --check 会报告它能看到的工具链,而不实际构建任何东西, 这是最快查出缺什么的方法。

Why the AAR is not committed为什么 AAR 不提交进仓库#

Because there is no prebuilt one to commit. Neither tailscale/libtailscale (which is a C library, and produces no Android artifact at all) nor tailscale/tailscale-android (whose GitHub releases ship only APKs) publishes one on Maven Central or JitPack. The AAR has to be built from source, which is what the script does.因为根本没有现成的可以提交。无论是 tailscale/libtailscale(它是一个 C 库,完全不产出 Android 构件),还是 tailscale/tailscale-android (它的 GitHub release 只发布 APK),都没有在 Maven Central 或 JitPack 上发布过 AAR。 AAR 只能从源码构建,脚本做的就是这件事。

Why not just reuse Tailscale's AAR?为什么不直接复用 Tailscale 的 AAR?#

Two reasons, and the second is the important one.有两个原因,第二个才是关键。

  1. It does not expose what this app needs. The binding has no typed Dial and no GetIP; all control flows through a local HTTP API over JNI.它没有暴露这个 App 需要的东西。绑定里没有带类型的 Dial,也没有 GetIP;所有控制都通过 JNI 走一个本地 HTTP API。
  2. Using it commits you to the system VPN slot. Its Kotlin side implements IPNService (a VpnService) and an AppContext; the Go side calls back into them. You would be building the official app's architecture — device-wide routing, split-tunnel policy, a foreground service — to open one socket.用它就意味着你要占用系统 VPN 槽位。它的 Kotlin 侧实现了 IPNService(一个 VpnService)和 AppContext; Go 侧会回调它们。为了打开一个套接字,你就得照搬官方 App 的架构 —— 设备级路由、拆分隧道策略、前台服务。

So this project binds tsnet directly, with a deliberately tiny surface: every exported Go function takes and returns only String, Int, Bool or error. Structured data crosses as JSON. That avoids both the interface-binding problem (a Go net.Conn has no Java representation) and the version sensitivity of multi-value returns.所以这个项目直接绑定 tsnet,接口面刻意做得很小: 每个导出的 Go 函数只接收和返回 String、Int、Bool 或 error。结构化数据以 JSON 传递。这样既避开了接口绑定的问题(Go 的 net.Conn 没有对应的 Java 表示),也避开了多返回值对版本敏感的问题。

Toolchain versions工具链版本#

Component组件Version版本Needed for用途
JDK17everything全部
Gradle9.6.0the wrapperwrapper
Android SDKplatform 37.0, build-tools 37.0.0the appApp
Goresolved from go.dev (1.23+ required)从 go.dev 解析(要求 1.23+)the bridge桥接
Android NDKr26d (26.3.11579264)the bridge桥接
gomobile / gobindsame x/mobile revision与 x/mobile 同一 revisionthe bridge桥接
gomobile and gobind must matchgomobile 与 gobind 必须版本一致

Install them from the same golang.org/x/mobile revision. Different revisions produce bindings that describe an ABI the runtime library does not implement, and the failure appears at the first call rather than at build time — which is a genuinely miserable thing to debug. build-bridge.mjs installs both from one --gomobile-version, and prints the resolved versions so you can pin them.请从同一个 golang.org/x/mobile revision 安装它们。revision 不同,生成的绑定 所描述的 ABI 就不是运行库实现的那一个,而失败会在第一次调用时才出现,而不是在构建时 —— 这调试起来非常难受。build-bridge.mjs 会用同一个 --gomobile-version 安装这两者,并打印出解析后的版本,方便你固定下来。

SDK level overrides覆盖 SDK 版本#

The default is compileSdk 37 / targetSdk 37, which is what a Play submission needs. API 37 is a minor platform release, so the SDK package is platforms;android-37.0. If your local SDK is older, override both:默认是 compileSdk 37 / targetSdk 37,这是提交到 Google Play 所需要的。 API 37 是一个 minor 平台版本,所以 SDK 包名是 platforms;android-37.0。如果你本地的 SDK 更旧,就把这两个都覆盖掉:

./gradlew assembleDebug -PcompileSdk=35 -PtargetSdk=35

Note that AndroidX core 1.19 requires compileSdk 37, so this override is only useful if you also lower the dependency versions in gradle/libs.versions.toml.注意 AndroidX core 1.19 要求 compileSdk 37,所以只有在你也把 gradle/libs.versions.toml 里的依赖版本调低时,这个覆盖才有用。

Using it使用它#

1. Create a pre-auth key1. 创建预认证密钥#

In your tailnet's admin console: Settings → Keys → Generate auth key.在你 tailnet 的管理控制台里:Settings → Keys → Generate auth key。

The app cannot enforce any of that for you. It is the difference between a key that is a credential and a key that is a capability.这些都没法由 App 替你来保证。这正是「一把密钥只是凭据」与「一把密钥是一种能力」 之间的区别。

2. Enter the target host2. 填写目标主机#

Accepted forms, in order of how likely they are to just work:可接受的写法,按「一填就能用」的可能性从高到低排列:

Input输入Verdict结论Notes说明
100.101.102.103:3080 best最佳 No DNS in the path. If this fails, the network is wrong, not the resolver.链路上没有 DNS。如果这样也失败,那是网络的问题,不是解析器的问题。
host.tailnet.ts.net:3080 good良好 MagicDNS FQDN. Resolved by the node's own network stack.MagicDNS 全限定域名,由节点自己的网络栈解析。
100.101.102.103 warns有警告 Port defaults to 3080, and the app says so.端口默认为 3080,App 会明确告诉你这一点。
myhost warns有警告 A bare short name needs the tailnet search domain. An embedded node is not guaranteed to have it. Use the FQDN or the IP if this fails.裸短名需要 tailnet 的搜索域,而内嵌节点不保证有它。如果失败, 请改用 FQDN 或 IP。
http://100.101.102.103:3080 refused拒绝 Drop the scheme; there is a separate selector for it.去掉 scheme,它有单独的选择项。
93.184.216.34 refused拒绝 A public address is not reachable through a tailnet. This is a security boundary, not a UX preference.公网地址无法通过 tailnet 访问。这是安全边界,不是交互偏好。
127.0.0.1 refused拒绝 That is the phone, not your server.那是手机自己,不是你的服务器。

3. Test3. 测试#

The button runs six checks and stops at the first hard failure. Read the first ✗ — that is the thing to fix. Everything below it is marked skipped rather than failed, because a skipped step tells you nothing.这个按钮会跑六项检查,遇到第一个硬失败就停。先看第一个 ✗ —— 那才是要修的东西。它下面的每一步都会被标成「跳过」而不是「失败」, 因为被跳过的步骤说明不了任何问题。

Using headscale instead of Tailscale用 headscale 代替 Tailscale#

Fill in the Control server field with your headscale base URL, for example https://headscale.example.com, and create the pre-auth key with headscale rather than Tailscale:把 Control server 字段填成你的 headscale 基础 URL,例如 https://headscale.example.com,并用 headscale 而不是 Tailscale 来创建 预认证密钥:

headscale preauthkeys create --user <USER_ID>
headscale preauthkeys create --tags tag:phone --expiration 1h

Leaving the field empty means Tailscale's hosted control plane. Note that passing that hosted URL explicitly is not the same as leaving it empty — tsnet treats a non-empty value as a custom server. The app normalises this for you.把该字段留空表示使用 Tailscale 托管的控制面。注意,显式填入那个托管 URL 与留空并不等价 —— tsnet 会把非空值当成自定义服务器。App 会替你把这层差异 抹平。

Trap陷阱

tsnet ignores the auth key you supply when usable node state already exists on disk, unless TSNET_FORCE_LOGIN=1 is set. This produces the single most confusing symptom in the whole project: "I pasted a new key and nothing changed."tsnet 在磁盘上已存在可用节点状态时会忽略你提供的认证密钥,除非 设置了 TSNET_FORCE_LOGIN=1。这会产生整个项目里最让人困惑的一个现象: 「我粘贴了新密钥,结果什么都没变。」

The app handles it: Forget key and reset node clears both the stored ciphertext and the node's state directory, and the Go bridge exposes an explicit forceLogin parameter.App 处理了这一点:Forget key and reset node 会同时清掉已保存的密文和 节点的状态目录,Go 桥接也暴露了一个显式的 forceLogin 参数。

Troubleshooting故障排查#

Symptom现象Cause and fix原因与修法
"not compiled into this build" You built without the bridge. Run scripts/build-bridge.mjs, then rebuild with -PwithTsnet=true.你在构建时没有带上桥接。先运行 scripts/build-bridge.mjs, 再用 -PwithTsnet=true 重新构建。
"no native library for this device ABI" The AAR is present but lacks your ABI — usually an x86_64 emulator with an arm64-only build. Rebind without trimming ABIs.AAR 在,但缺少你的 ABI —— 通常是 x86_64 模拟器配上了只含 arm64 的构建。 重新绑定,不要裁剪 ABI。
Node starts, TCP times out节点启动成功,TCP 超时 Usually the port, or a service that is not listening. "Connection refused" means the host answered; "timed out" means it did not.通常是端口问题,或者服务没有在监听。「Connection refused」说明主机有应答; 「timed out」说明它没应答。
MagicDNS name does not resolveMagicDNS 名称解析不了 Use the 100.x address. Bare short names are the least reliable form; even full FQDNs depend on the control plane having published the suffix.请改用 100.x 地址。裸短名是最不可靠的形式; 即使是完整的 FQDN,也取决于控制面是否发布了那个后缀。
"the encryption key was invalidated" The device's lock screen or credentials changed, which destroys Keystore keys. Re-enter the auth key. This is expected behaviour, not a bug.设备的锁屏方式或凭据变了,这会导致 Keystore 密钥被销毁。重新输入认证密钥即可。 这是预期行为,不是 bug。
Auth key rejected认证密钥被拒绝 Check the key's tags against your ACLs, and that it has not expired. A key from Tailscale will not work against headscale, or the reverse.检查这把密钥的标签与你的 ACL 是否匹配,以及它是否已过期。来自 Tailscale 的密钥 不能用于 headscale,反过来也一样。
Diagnostics诊断

The Diagnostics button shows the provider, whether the Keystore key is hardware-backed, the node's state and address, the target with the host partially masked, and the tailnet node's own recent log lines — all redacted before display.Diagnostics 按钮会显示:当前使用的提供方、Keystore 密钥是否由硬件背书、 节点的状态与地址、把主机名部分打码后的目标,以及 tailnet 节点自己最近的日志行 —— 全部在显示前完成脱敏。