com.harryaskham.weartailnet · Kotlin · Go · Wear OS

Wear Tailnet

A Wear OS app that joins a Tailscale network as its own node, with Tailscale's tsnet library built into the app rather than a VPN. Machines on the tailnet can reach services on the watch, and other watch apps can reach the tailnet through it, while the rest of the watch's traffic is untouched.

One app on the tailnet

A tailnet node inside one watch app Wear Tailnet's structure. The app embeds Tailscale's tsnet library, built for Android with gomobile, so the watch joins the tailnet as its own node without a VPN. Only this app is on the tailnet. Inbound mappings let a tailnet peer reach a service listening on the watch's loopback address. Outbound mappings and the broker let other watch apps reach tailnet peers through one-shot local ports. Routes and app access are edited from a phone's browser during a temporary setup session. tailnet peera laptop, a server Wear Tailnettsnet nodeno VpnService watch serviceon 127.0.0.1 watch appsNOD Term's SSH … phone browsersetup session, 10 min ON THE WATCH inbound mapping broker tunnel
No VpnService. Other watch apps get no automatic routing; they use a mapping or the broker.

The Go side is a small bridge around tsnet, compiled with gomobile into an Android library for 32- and 64-bit ARM watches and the x86_64 emulator. It supports two kinds of mapping: a tailnet port that forwards to a service on the watch's loopback address, and a loopback port on the watch that forwards to a tailnet peer. Proxy targets other than loopback are rejected.

The first start prints a Tailscale login link and QR code to open on any device with a browser. A one-off pre-approved auth key also works; it goes straight to the bridge and is never stored.

Configured from a phone

Editing routes on a watch is slow, so Configure from phone shows a QR code for a temporary page served by the watch over the tailnet. The phone's browser edits routes and app access there, Save to watch stores them, and the session ends after ten minutes or when you end it.

Tunnels for other watch apps

One brokered connection How a watch app reaches a tailnet peer through the broker. The app, which holds a signature permission or has been allowed explicitly, asks for a tunnel to a peer's name and port with a short purpose and a timeout of one to thirty seconds. The broker checks that the package belongs to the caller, opens a listener on a random local port and returns it with a random bearer. The app connects and writes the bearer on its own line before any protocol bytes; the broker dials the peer over the tailnet and joins the two. The listener accepts one connection and closes, even if authentication fails. openTunnel(peer, port, purpose, 1–30 s)1 check package and caller 127.0.0.1:port + bearer2 connect · bearer + newline3 dial over the tailnet4 bytes flow; the listener has closed watch appNOD Term's SSHwatch appNOD Term's SSH brokerin Wear Tailnetbrokerin Wear Tailnet tailnet peertailnet peer

Apps signed with the same key bind the broker with a signature permission; other installed apps only if you allow them all explicitly. Each tunnel is a local port that accepts one connection, guarded by a random bearer that is never logged. NOD Term's watch app opens its SSH connections this way. A watch-face complication and a Tile show whether the node is online, waiting for login or offline, and how many tunnels are open.

What has been tested

  • Built and run on a Wear OS 5 emulator (API 34, x86_64): the native library loads, and the node correctly reports that it needs login, with a real login link.
  • The bridge library is about 44 MB for three ABIs; the release APK about 24 MB.
  • The service survived 30 seconds with the screen off at about 57 MB of memory while waiting for login.

Not yet shown: an emulator cannot prove authenticated reachability over LTE, behaviour under a watch maker's power saving, carrier NAT or heat, so those wait for a real LTE watch.

The service starts when you start it. Android 15 does not let it start itself after a reboot or a force-stop, and the Tile opens the app rather than trying to.