tailnet-byok

An Android client for your own DSH server, reached over Tailscale.连你自己那台 DSH 服务器的 Android 客户端(走 Tailscale)。

A long job runs on your desktop, you have to leave, and you would rather keep the session in your hand than sit and watch it. That is the one thing this app does: it is the client. Install Tailscale on both ends — the computer running DSH and the phone or tablet — and sign both into the same tailnet. Your DSH then answers at an address inside that tailnet; paste the address, sign in, and the app talks to that one service. The two are used together: Tailscale is the network, this app is the client, and neither replaces the other. No account with us, no server of ours, no shared relay — and this project provides no tailnet, no node, no server and no relay.电脑上有个长任务在跑,而你要出门 —— 与其坐在那儿盯着,不如把会话拿在手上。 这个 App 只做一件事:当那个客户端。两端都要装 Tailscale —— 跑 DSH 的电脑,和手机或平板 —— 并登录同一个 tailnet。你的 DSH 就在这个 tailnet 里有一个地址:粘贴地址、登录,App 只与那一个服务通信。 两者是搭配使用的:Tailscale 负责网络,这个 App 负责当客户端,谁也替代不了谁。 不需要在我们这里注册账号,没有我们的服务器,没有共享中继 —— 本项目不提供 tailnet、不提供节点、 不提供服务器、也不做中继。

You need Tailscale on both ends两端都需要 Tailscale

Computer running DSH: tailscale.com/download. Phone / tablet: the official Tailscale for Android app (com.tailscale.ipn, the maintainer runs 1.103.90) — take the Play Store build, it is where new versions land first; the APK itself only comes from Tailscale's package server, and it does not update itself. Sign both ends into the same tailnet. Android 8 or later — what Tailscale itself requires — and verified working on Android 12 (a HUAWEI AGS5-W00 tablet) and Android 14 (an HONOR ALT-AN00 phone); old devices carry old WebViews, and the DSH interface is where that shows.跑 DSH 的电脑:tailscale.com/download。 手机 / 平板:官方 Tailscale for Android (包名 com.tailscale.ipn,维护者用的是 1.103.90)—— 优先用 Play 商店版,新版本先到那里; 要 APK 文件只有官方的包服务器,而且那个 APK 不会自动更新。 两端登录同一个 tailnet。Android 8 以上(Tailscale 自身的要求), 并在 Android 12(HUAWEI AGS5-W00 平板)与 Android 14(HONOR ALT-AN00 手机)上实测可正常运行; 设备越老 WebView 越老,DSH 界面就是最容易看出问题的地方。

If Play and tailscale.com will not load — mainland networks, typically — take the file itself from Tailscale's official package server: tailscale-android-universal-1.102.4.apk (universal, 100.6 MB). It is the stable track and it does not update itself; the page it comes from is pkgs.tailscale.com/stable/#android.如果 Play 和 tailscale.com 都打不开 (国内网络常见)—— 直接拿文件:Tailscale 官方包服务器 tailscale-android-universal-1.102.4.apk (通用包,100.6 MB)。它是 stable 通道且不会自动更新;它来自的页面是 pkgs.tailscale.com/stable/#android。

That page is English only, so here is the same section in Chinese — the file, its requirements, its checksum, and which build to take.那一页只有英文,所以这里有一份同内容的中文说明 —— 文件、要求、校验值、该装哪一个,都在里面。

What "BYOK" means here这里的“BYOK”是什么意思

The key is a Tailscale auth key, not an LLM API key. It is stored encrypted by the Android Keystore, it is never written to disk in plain text, it is never logged, and it leaves the device only to reach the control plane you configure.这里的密钥是 Tailscale 认证密钥,不是 LLM API key。它由 Android Keystore 加密保存,绝不会以明文写入磁盘,绝不会进日志,只有当它前往你配置的控制面时才会离开设备。

Why this exists为什么会有这个项目#

A long job is running on my desktop — a build, a render, an agent session. I have to leave. What I do not want is to be tied to the chair until it finishes, and what I do not want to lose is the session itself: not a status mail, not a log tail, but the same DSH conversation, still able to take the next instruction from a phone on the way out the door.电脑上有个长任务在跑 —— 编译、渲染,或者一个 agent 会话,而我要出门了。 我不想被按在椅子上等它跑完,更不想丢掉会话本身:不是收一封状态邮件、不是 tail 一段日志, 而是同一个 DSH 会话,在出门路上还能用手机接着下一条指令。

That is the whole point of this project: your DSH server keeps running on your own machine, your own tailnet puts the two on the same private network, and this app is the client that reaches it. Leave the desk, keep the session — phone or tablet, same server, same session.这就是本项目存在的全部理由:你的 DSH 服务器继续跑在你自己那台机器上,你自己的 tailnet 把两边放进同一个私有网络,而这个 App 就是连过去的那一端。离开工位,会话还在手上 —— 手机或平板、同一台服务器、同一个会话。

What that looks like in practice:具体到手上是什么样子:

How it connects, and why it asks for no VPN permission它怎么连,以及为什么不申请 VPN 权限#

The everyday setup is two apps: the official Tailscale app signs the device into your tailnet, and tailnet-byok is only the DSH client on top of it. The app opens one connection, to one destination, and nothing else — no auth key, no VPN slot of its own.日常就是两个 App:官方 Tailscale 应用把设备登录进你的 tailnet, tailnet-byok 只是叠在它上面的 DSH 客户端。这个 App 只打开一条连接、只连一个目的地,别的什么都不做 —— 不需要认证密钥,也不占自己的 VPN 槽位。

The other two ways to get a phone onto a tailnet in order to reach one service are both unsatisfying — which is why this build also carries an optional third.另外两条「把手机弄进 tailnet,只为访问一个服务」的路都不太令人满意 —— 所以这个构建还带了一个可选的第三条。

Have the official app do the provisioning让官方应用完成入网

It works, and for most people it is the right answer — the official app is also what this project's own everyday mode runs on. But it takes the device-wide VPN slot, changes DNS for every app on the phone, and — decisively — no other app can hand it an auth key. There is no API, intent or content provider for that.它确实能用,对大多数人来说也是正确答案 —— 而且本项目的日常模式本身就跑在官方应用之上。 但它占用了设备级的 VPN 槽位,会改动手机上 每个 App 的 DNS,而且最关键的是:没有别的 App 能把手里的认证密钥交给它。 没有为此提供 API、intent 或 content provider。

So if your goal is "an app that provisions a device onto my tailnet from inside the app", this route cannot do it. Not "is inconvenient" — cannot.所以如果你的目标是「一个能在自己的 App 里把设备配置进我的 tailnet 的 App」, 这条路走不通。不是「不方便」,而是走不通。

Ship your own VPN service自己实现一个 VPN 服务

That means BIND_VPN_SERVICE, a foreground service, split-tunnel policy and a permanent notification — a great deal of surface, and a great many ways to get it wrong, in order to open one socket to one host.这意味着 BIND_VPN_SERVICE、前台服务、拆分隧道策略和一条常驻通知 —— 为了打开一个到单个主机的套接字,要引入一大片攻击面,还要面对大量出错的可能。

Most of that surface exists to serve the case where you want to route everything. This app does not.这一大片东西,大多是为了服务「想路由所有流量」的场景。这个 App 不做那件事。

tailnet-byok therefore also carries a third option, as a mode in the build rather than a service: it embeds a userspace Tailscale node with tsnet and dials the target through it. The device's other traffic is untouched, and the app itself asks for no VPN permission — on the system-network path the tunnel belongs to the official app instead. The app opens one connection, to one destination, and nothing else.tailnet-byok 所以还带了第三条路,而且它是 构建里的一个模式、不是一项服务:它用 tsnet 内嵌了一个用户态的 Tailscale 节点,并通过它拨号到目标。设备上的其它流量完全不受影响,App 自身也不申请 VPN 权限 —— 走系统网络模式时,隧道由官方应用提供。 这个 App 只打开一条连接、只连一个目的地,别的什么都不做。

That third option is optional: the same build also runs on the device's existing network, which is the mode used day to day and the one that needs no auth key at all. The embedded mode instead needs a Tailscale auth key from the tailnet it joins.这「第三条路」是可选的: 同一个安装包也能直接走设备现有网络,那才是日常使用的模式,而且完全不需要认证密钥。 内嵌节点模式改用一把来自它所加入的 tailnet 的 Tailscale 认证密钥。

For the record, on the same shape elsewhere: the closest is Haven — tsnet bound through gomobile into a Kotlin app, userspace netstack, no VpnService consent — applied there to a general tunnel and here to the single-destination case. Haven is AGPL-3.0, so it is worth reading as evidence that this shape works, and it is not a source of code for an MIT project. Two designs that look like the same shape are not: tailvisor runs macOS and Linux guest VMs on Apple Silicon (Swift, -buildmode=c-archive) and contains no Android code at all, and netbirdio/android-client does take the device-wide VPN slot — its :tool module declares a VPNService and holds BIND_VPN_SERVICE — exactly the trade this project declines to make.顺带把同类项目说清楚:最接近的是 Haven —— 把 tsnet 经 gomobile 绑进 Kotlin App, 用户态 netstack,不要 VpnService 授权;它做的是通用隧道,本项目做的是「单一目的地」这一种。 Haven 是 AGPL-3.0,所以它值得作为「这个形态确实成立」的证据来读,但它不是 MIT 项目的代码来源。 另有两个看着形状相同、其实不同:tailvisor 是在 Apple Silicon 上跑 macOS 和 Linux 客户机 VM(Swift、-buildmode=c-archive),完全没有 Android 代码; netbirdio/android-client 确实占用设备级 VPN 槽位 —— 它的 :tool 模块声明了 VPNService 并持有 BIND_VPN_SERVICE —— 正是本项目选择不去做的那笔交易。

What it does它能做什么#

Feature能力How实现方式
Configuration配置 Auth key, target host (MagicDNS name or 100.x address), port, scheme, path, node hostname, and an optional self-hosted control server for headscale.认证密钥、目标主机(MagicDNS 名称或 100.x 地址)、端口、 scheme、path、节点主机名,以及可选的、供 headscale 使用的自建控制服务器。
Secure storage安全存储 AES-256-GCM under a key that lives in the Android Keystore. On a device with a TEE or StrongBox, that key never exists in the app's address space at all.AES-256-GCM 加密,密钥存放在 Android Keystore 中。在带 TEE 或 StrongBox 的设备上, 这把密钥根本不会出现在 App 的地址空间里。
Connection test连接测试 Six named steps, each with a duration, stopping at the first hard failure. It tells you which part broke, because the fixes are different.六个具名步骤,每步都有耗时,遇到第一个硬失败就停下。它会告诉你哪一步 坏了,因为各自的修法并不相同。
Direct connection直连 Traffic reaches the target over the tailnet, from inside the app, through the tailnet's own network stack.流量在 App 内部经由 tailnet 自己的网络栈到达目标。

A sample run一次示例运行#

✓ Validate target address校验目标地址 target looks like a tailnet address目标看起来是 tailnet 地址 ✓ Select connection method选择连接方式 using Embedded tailnet node使用内嵌 tailnet 节点 ✓ Check stored credential检查已保存的凭据 auth key present (fp:3f9a1c04)认证密钥已存在(fp:3f9a1c04) ✓ Bring up tailnet node拉起 tailnet 节点 node up at 100.101.102.103节点已上线,地址 100.101.102.103 1840 ms ✓ Open TCP connection打开 TCP 连接 connected in 142 ms (resolved to 100.101.102.103)已连接,耗时 142 ms(解析到 100.101.102.103) 142 ms ✓ Send HTTP request发送 HTTP 请求 HTTP 200 187 ms Connected已连接

Note what is not in that output: the auth key. The credential step reports a non-reversible fingerprint (fp:3f9a1c04) — enough to tell two keys apart in a log, useless for anything else.注意那段输出里没有什么:认证密钥。凭据那一步报的是一个不可逆的指纹 (fp:3f9a1c04)—— 在日志里足以区分两把不同的密钥,除此之外没有别的用处。

And when it fails, it says which part:而失败的时候,它会说明是哪一部分:

✗ Validate target address校验目标地址 a public address is not reachable through a tailnet. Use the 100.x address or the MagicDNS name.公网地址无法通过 tailnet 访问。 请改用 100.x 地址或 MagicDNS 名称。

First run, step by step第一次使用,按顺序来#

  1. Tailscale, on both ends. Install the official Tailscale app on the phone or tablet and on the computer that runs DSH, and sign both into the same tailnet. The first card on the setup screen links to it — and to Tailscale's own package server for the APK, because Google Play and tailscale.com do not load on every network.两端都装 Tailscale。手机或平板和跑 DSH 的电脑都要装官方 Tailscale App,并登录同一个 tailnet。设置页 第一张卡片就有链接 —— 也有 Tailscale 官方包服务器的 APK 直链,因为 Google Play 和 tailscale.com 不是哪儿都能打开。
  2. Bring a configuration in. On the computer, the provisioning page turns a target into a dshbyok://setup link and a QR code. Scan it, paste it, or type the host by hand; the app shows what a link would change and waits for a tap before applying anything.把配置装进来。在电脑上, 配置页会把目标地址变成一条 dshbyok://setup 链接和一张二维码。 扫它、粘它,或者手输主机地址都行;链接会先给你看清楚要改什么,等你点确认才生效。
  3. Pick a connection method. Embedded tailnet node dials from inside the app with your own Tailscale auth key ("BYOK"); System network rides the tunnel the official app already established and needs no key at all. Either works — pick what suits you.选连接方式。内嵌 tailnet 节点在 App 内部用你自己的 Tailscale 认证密钥拨号(这就是 BYOK);系统网络直接用官方应用已经建好的隧道,完全不需要密钥。 两种都能用,按自己的喜好选。
  4. Only the embedded node needs a key. Generate one in your tailnet's admin console (Settings → Keys), single-use and tagged where you can; it is stored encrypted by the Android Keystore and never logged.只有内嵌节点需要密钥。在你 tailnet 的管理控制台(Settings → Keys)生成一把,尽量用一次性的、带 tag 的;它由 Android Keystore 加密保存,绝不进日志。
  5. Run the connection test. Six named steps, each with a duration, stopping at the first hard failure — a failure tells you which part broke, because the fixes differ.跑一次连接测试。六个具名步骤、每步都有耗时,遇到第一个 硬失败就停下 —— 失败会告诉你是哪一步坏了,因为各自的修法不一样。
  6. Then open the DSH UI. Once the test passes, Open the DSH UI shows your server's own interface inside the app, through a loopback-only proxy.然后打开 DSH 网页界面。测试通过后,点打开 DSH 界面,就能在 App 里 用你自己服务器的那套界面 —— 走的是只监听本机的内部代理。
  7. Updates are manual on purpose. Check for updates runs when you tap it, and installs only bytes whose SHA-256 and declared package name both match. There is no timer and no background check.更新是手动的,这是故意的。点检查更新 才会去查,而且只安装 SHA-256 与包名都对得上的字节。没有定时器,没有后台检查。
  8. Diagnostics sit at the bottom of the same screen. Provider, node state, redacted target, the last update result and the node's recent log lines — short enough to copy and paste into a bug report.诊断就在同一页最下面。连接方式、节点状态、 打码后的目标、最近一次更新结果,以及节点自己的近期日志 —— 短到可以直接复制粘贴进问题反馈。

Security posture, in one table一张表看安全姿态#

Requirement要求How it is met如何满足
No hardcoded private configuration没有硬编码的私有配置 No credential exists in the source, the build, or BuildConfig. The only generated constant is a non-secret control-plane URL.源码、构建产物和 BuildConfig 里都不存在任何凭据。 唯一生成的常量是一个非机密的控制面 URL。
Key never on disk in plain text密钥绝不以明文落盘 AES-256-GCM via the Android Keystore, setRandomizedEncryptionRequired, setUnlockedDeviceRequired on API 28+. Node state lives in noBackupFilesDir.通过 Android Keystore 使用 AES-256-GCM,在 API 28+ 上启用 setRandomizedEncryptionRequired、setUnlockedDeviceRequired。 节点状态存放在 noBackupFilesDir。
Key never in a log密钥绝不进日志 One logging entry point, scrubbed; release builds strip v/d/i at the bytecode level; CI fails the build on a bare android.util.Log call.只有一个日志入口,并且会做脱敏;release 构建在字节码层面剔除 v/d/i; CI 一旦发现裸的 android.util.Log 调用就让构建失败。
No accidental egress不会意外外联 Anything outside 100.64.0.0/10, fd7a:115c:a1e0::/48 and named hosts is refused before a dial — in the network layer, not just in the UI.任何不在 100.64.0.0/10、fd7a:115c:a1e0::/48 和具名主机范围内的目标,都会在拨号之前被拒绝 —— 在网络层拒绝,而不只是在 UI 上。
Nothing leaves the device什么都不离开设备 allowBackup="false", backup rules exclude everything, no analytics, no crash reporter, no server of ours.allowBackup="false",备份规则排除一切,没有统计,没有崩溃上报, 也没有我们的服务器。
Minimum permissions最小权限 Six, and one of them only while you are watching a task: INTERNET, ACCESS_NETWORK_STATE, POST_NOTIFICATIONS, REQUEST_INSTALL_PACKAGES (the updater asks the system installer; it cannot install silently), CAMERA — asked for by the QR scanner screen alone, and optional, because a code can be read from a picture instead — and FOREGROUND_SERVICE, so a task you started can be noticed finishing while the app is off screen. No BIND_VPN_SERVICE, no QUERY_ALL_PACKAGES, no location.六个,其中一个是「只在你在等任务跑完时」才用到的:INTERNET、 ACCESS_NETWORK_STATE、POST_NOTIFICATIONS、 REQUEST_INSTALL_PACKAGES(更新时交给系统安装器,无法静默安装)、 CAMERA —— 只由二维码扫描页申请,而且是可选的:不改用相机也能用图片识别 —— 以及 FOREGROUND_SERVICE:让你启动的任务在 App 退到后台后跑完也能被发现。没有 BIND_VPN_SERVICE,没有 QUERY_ALL_PACKAGES,不读位置。
Read this before trusting it with a key在把密钥交给它之前,请先读这一节

The honest limits are documented rather than glossed over — including the fact that the plaintext key exists in the heap for a short window, because Kotlin strings cannot be zeroed. See Security.诚实的局限是被写进文档的,而不是被含糊带过 —— 包括明文密钥会在堆上短暂存在这一 事实,因为 Kotlin 字符串无法被清零。见安全。

Status状态#

Version 0.4.6, commit d3ef6b4, docs built 2026-09-26 14:02 UTC.版本 0.4.6,提交 d3ef6b4,文档构建于 2026-09-26 14:02 UTC。

Working已可用 secure storage, address policy, the six-step test, the system-network provider, the full Go bridge source, and CI for all of it.安全存储、地址策略、六步测试、系统网络提供方、完整的 Go 桥接源码, 以及覆盖这一切的 CI。

Not done未完成 the bridge is written but has not been built on every platform; no instrumented tests; UI strings are not extracted for translation; the embedded provider's per-ABI footprint is unmeasured.桥接代码已经写好,但还没有在每个平台上构建过;没有 instrumented 测试; UI 字符串尚未抽出做翻译;内嵌提供方按 ABI 的体积还没有测量。