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、不提供节点、 不提供服务器、也不做中继。
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.那一页只有英文,所以这里有一份同内容的中文说明 —— 文件、要求、校验值、该装哪一个,都在里面。
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:具体到手上是什么样子:
- Reply in the same session — the conversation on the phone is the one the desktop is running, not a copy, a summary or a read-only view.在同一个会话里回复 —— 手机上的对话就是电脑正在跑的那一个,不是副本、不是摘要,也不是只读视图。
- Answer what DSH asks — its questions and tool approvals arrive in your hand, so a job does not sit stalled until you are back at the desk to tap something.回答 DSH 的提问 —— 它的问题和工具审批会送到你手上,任务不会因为「要等你回到桌前点一下」而干等。
- Watch a long job finish — output streams while you are elsewhere, background tasks included.看着长任务跑完 —— 你人在别处,输出照样流过来,后台任务也在。
- Phone or tablet, one target — both point at the same server and see the same thing.手机或平板,同一个目标 —— 两台指向同一台服务器,看到的是同一份东西。
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一次示例运行#
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:而失败的时候,它会说明是哪一部分:
First run, step by step第一次使用,按顺序来#
- 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.comdo not load on every network.两端都装 Tailscale。手机或平板和跑 DSH 的电脑都要装官方 Tailscale App,并登录同一个 tailnet。设置页 第一张卡片就有链接 —— 也有 Tailscale 官方包服务器的 APK 直链,因为 Google Play 和tailscale.com不是哪儿都能打开。 - Bring a configuration in. On the computer, the
provisioning page turns a target into a
dshbyok://setuplink 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链接和一张二维码。 扫它、粘它,或者手输主机地址都行;链接会先给你看清楚要改什么,等你点确认才生效。 - 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);系统网络直接用官方应用已经建好的隧道,完全不需要密钥。 两种都能用,按自己的喜好选。
- 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 加密保存,绝不进日志。
- 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.跑一次连接测试。六个具名步骤、每步都有耗时,遇到第一个 硬失败就停下 —— 失败会告诉你是哪一步坏了,因为各自的修法不一样。
- 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 里 用你自己服务器的那套界面 —— 走的是只监听本机的内部代理。
- 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 与包名都对得上的字节。没有定时器,没有后台检查。
- 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,不读位置。 |
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 的体积还没有测量。