Skip to content

Commit 0f58efe

Browse files
author
Ryan Vogel
committed
fix: advertise MagicDNS hosts for Tailscale pairing
Prefer .ts.net names and allow Tailscale CGNAT HTTP on iOS so mobile pairing avoids ATS-blocked raw tailnet IPs.
1 parent 4abb464 commit 0f58efe

3 files changed

Lines changed: 75 additions & 2 deletions

File tree

packages/mobile-voice/app.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,9 @@
2424
"NSAppTransportSecurity": {
2525
"NSAllowsLocalNetworking": true,
2626
"NSExceptionDomains": {
27+
"100.64.0.0/10": {
28+
"NSExceptionAllowsInsecureHTTPLoads": true
29+
},
2730
"ts.net": {
2831
"NSIncludesSubdomains": true,
2932
"NSExceptionAllowsInsecureHTTPLoads": true

packages/opencode/src/cli/cmd/serve.ts

Lines changed: 49 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { spawnSync } from "node:child_process"
12
import { createHash, randomBytes } from "node:crypto"
23
import os from "node:os"
34
import { Server } from "../../server/server"
@@ -21,6 +22,13 @@ type PairPayload = {
2122
hosts: string[]
2223
}
2324

25+
type TailscaleStatus = {
26+
Self?: {
27+
DNSName?: unknown
28+
TailscaleIPs?: unknown
29+
}
30+
}
31+
2432
function ipTier(address: string): number {
2533
const parts = address.split(".")
2634
if (parts.length !== 4) return 4
@@ -107,6 +115,38 @@ function secretHash(input: string) {
107115
return `${createHash("sha256").update(input).digest("hex").slice(0, 12)}...`
108116
}
109117

118+
export function autoTailscaleAdvertiseHost(hostname: string, status: unknown): string | undefined {
119+
const self = (status as TailscaleStatus | undefined)?.Self
120+
if (!self) return
121+
122+
const dnsName = typeof self.DNSName === "string" ? self.DNSName.replace(/\.+$/, "") : ""
123+
if (!dnsName || !dnsName.toLowerCase().endsWith(".ts.net")) return
124+
125+
if (hostname === "0.0.0.0" || hostname === "::" || hostname === dnsName) {
126+
return dnsName
127+
}
128+
129+
const tailscaleIPs = Array.isArray(self.TailscaleIPs)
130+
? self.TailscaleIPs.filter((item): item is string => typeof item === "string" && item.length > 0)
131+
: []
132+
if (tailscaleIPs.includes(hostname)) {
133+
return dnsName
134+
}
135+
}
136+
137+
function readTailscaleAdvertiseHost(hostname: string) {
138+
try {
139+
const result = spawnSync("tailscale", ["status", "--json"], {
140+
encoding: "utf8",
141+
stdio: ["ignore", "pipe", "ignore"],
142+
})
143+
if (result.status !== 0 || result.error || !result.stdout.trim()) return
144+
return autoTailscaleAdvertiseHost(hostname, JSON.parse(result.stdout))
145+
} catch {
146+
return
147+
}
148+
}
149+
110150
async function printPairQR(pair: PairPayload) {
111151
const link = pairLink(pair)
112152
const qrConfig = {
@@ -171,7 +211,14 @@ export const ServeCommand = cmd({
171211
.split(",")
172212
.map((item) => item.trim())
173213
.filter(Boolean)
174-
const advertiseHosts = [...new Set([...advertiseHostsFromArg, ...advertiseHostsFromEnv])]
214+
const tailscaleAdvertiseHost = readTailscaleAdvertiseHost(opts.hostname)
215+
const advertiseHosts = [
216+
...new Set([
217+
...advertiseHostsFromArg,
218+
...advertiseHostsFromEnv,
219+
...(tailscaleAdvertiseHost ? [tailscaleAdvertiseHost] : []),
220+
]),
221+
]
175222

176223
const input = (args["relay-secret"] ?? process.env.OPENCODE_EXPERIMENTAL_PUSH_RELAY_SECRET ?? "").trim()
177224
const relaySecret = input || randomBytes(18).toString("base64url")
@@ -180,7 +227,7 @@ export const ServeCommand = cmd({
180227
if (connectQR) {
181228
const pairHosts = hosts(opts.hostname, opts.port > 0 ? opts.port : 4096, advertiseHosts, false)
182229
if (!pairHosts.length) {
183-
console.log("connect qr mode requires at least one valid --advertise-host value")
230+
console.log("connect qr mode requires at least one valid advertised host")
184231
return
185232
}
186233

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { describe, expect, test } from "bun:test"
2+
import { autoTailscaleAdvertiseHost } from "../../src/cli/cmd/serve"
3+
4+
describe("autoTailscaleAdvertiseHost", () => {
5+
const status = {
6+
Self: {
7+
DNSName: "exos.husky-tilapia.ts.net.",
8+
TailscaleIPs: ["100.76.251.88", "fd7a:115c:a1e0::435:fb58"],
9+
},
10+
}
11+
12+
test("advertises the MagicDNS hostname for all-interface listeners", () => {
13+
expect(autoTailscaleAdvertiseHost("0.0.0.0", status)).toBe("exos.husky-tilapia.ts.net")
14+
})
15+
16+
test("advertises the MagicDNS hostname for Tailscale-bound listeners", () => {
17+
expect(autoTailscaleAdvertiseHost("100.76.251.88", status)).toBe("exos.husky-tilapia.ts.net")
18+
})
19+
20+
test("skips the MagicDNS hostname for unrelated listeners", () => {
21+
expect(autoTailscaleAdvertiseHost("192.168.1.20", status)).toBeUndefined()
22+
})
23+
})

0 commit comments

Comments
 (0)