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.
pull/19545/head
Ryan Vogel 2026-04-03 13:02:58 +00:00
parent 4abb464345
commit 0f58efe030
3 changed files with 75 additions and 2 deletions

View File

@ -24,6 +24,9 @@
"NSAppTransportSecurity": { "NSAppTransportSecurity": {
"NSAllowsLocalNetworking": true, "NSAllowsLocalNetworking": true,
"NSExceptionDomains": { "NSExceptionDomains": {
"100.64.0.0/10": {
"NSExceptionAllowsInsecureHTTPLoads": true
},
"ts.net": { "ts.net": {
"NSIncludesSubdomains": true, "NSIncludesSubdomains": true,
"NSExceptionAllowsInsecureHTTPLoads": true "NSExceptionAllowsInsecureHTTPLoads": true

View File

@ -1,3 +1,4 @@
import { spawnSync } from "node:child_process"
import { createHash, randomBytes } from "node:crypto" import { createHash, randomBytes } from "node:crypto"
import os from "node:os" import os from "node:os"
import { Server } from "../../server/server" import { Server } from "../../server/server"
@ -21,6 +22,13 @@ type PairPayload = {
hosts: string[] hosts: string[]
} }
type TailscaleStatus = {
Self?: {
DNSName?: unknown
TailscaleIPs?: unknown
}
}
function ipTier(address: string): number { function ipTier(address: string): number {
const parts = address.split(".") const parts = address.split(".")
if (parts.length !== 4) return 4 if (parts.length !== 4) return 4
@ -107,6 +115,38 @@ function secretHash(input: string) {
return `${createHash("sha256").update(input).digest("hex").slice(0, 12)}...` return `${createHash("sha256").update(input).digest("hex").slice(0, 12)}...`
} }
export function autoTailscaleAdvertiseHost(hostname: string, status: unknown): string | undefined {
const self = (status as TailscaleStatus | undefined)?.Self
if (!self) return
const dnsName = typeof self.DNSName === "string" ? self.DNSName.replace(/\.+$/, "") : ""
if (!dnsName || !dnsName.toLowerCase().endsWith(".ts.net")) return
if (hostname === "0.0.0.0" || hostname === "::" || hostname === dnsName) {
return dnsName
}
const tailscaleIPs = Array.isArray(self.TailscaleIPs)
? self.TailscaleIPs.filter((item): item is string => typeof item === "string" && item.length > 0)
: []
if (tailscaleIPs.includes(hostname)) {
return dnsName
}
}
function readTailscaleAdvertiseHost(hostname: string) {
try {
const result = spawnSync("tailscale", ["status", "--json"], {
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
})
if (result.status !== 0 || result.error || !result.stdout.trim()) return
return autoTailscaleAdvertiseHost(hostname, JSON.parse(result.stdout))
} catch {
return
}
}
async function printPairQR(pair: PairPayload) { async function printPairQR(pair: PairPayload) {
const link = pairLink(pair) const link = pairLink(pair)
const qrConfig = { const qrConfig = {
@ -171,7 +211,14 @@ export const ServeCommand = cmd({
.split(",") .split(",")
.map((item) => item.trim()) .map((item) => item.trim())
.filter(Boolean) .filter(Boolean)
const advertiseHosts = [...new Set([...advertiseHostsFromArg, ...advertiseHostsFromEnv])] const tailscaleAdvertiseHost = readTailscaleAdvertiseHost(opts.hostname)
const advertiseHosts = [
...new Set([
...advertiseHostsFromArg,
...advertiseHostsFromEnv,
...(tailscaleAdvertiseHost ? [tailscaleAdvertiseHost] : []),
]),
]
const input = (args["relay-secret"] ?? process.env.OPENCODE_EXPERIMENTAL_PUSH_RELAY_SECRET ?? "").trim() const input = (args["relay-secret"] ?? process.env.OPENCODE_EXPERIMENTAL_PUSH_RELAY_SECRET ?? "").trim()
const relaySecret = input || randomBytes(18).toString("base64url") const relaySecret = input || randomBytes(18).toString("base64url")
@ -180,7 +227,7 @@ export const ServeCommand = cmd({
if (connectQR) { if (connectQR) {
const pairHosts = hosts(opts.hostname, opts.port > 0 ? opts.port : 4096, advertiseHosts, false) const pairHosts = hosts(opts.hostname, opts.port > 0 ? opts.port : 4096, advertiseHosts, false)
if (!pairHosts.length) { if (!pairHosts.length) {
console.log("connect qr mode requires at least one valid --advertise-host value") console.log("connect qr mode requires at least one valid advertised host")
return return
} }

View File

@ -0,0 +1,23 @@
import { describe, expect, test } from "bun:test"
import { autoTailscaleAdvertiseHost } from "../../src/cli/cmd/serve"
describe("autoTailscaleAdvertiseHost", () => {
const status = {
Self: {
DNSName: "exos.husky-tilapia.ts.net.",
TailscaleIPs: ["100.76.251.88", "fd7a:115c:a1e0::435:fb58"],
},
}
test("advertises the MagicDNS hostname for all-interface listeners", () => {
expect(autoTailscaleAdvertiseHost("0.0.0.0", status)).toBe("exos.husky-tilapia.ts.net")
})
test("advertises the MagicDNS hostname for Tailscale-bound listeners", () => {
expect(autoTailscaleAdvertiseHost("100.76.251.88", status)).toBe("exos.husky-tilapia.ts.net")
})
test("skips the MagicDNS hostname for unrelated listeners", () => {
expect(autoTailscaleAdvertiseHost("192.168.1.20", status)).toBeUndefined()
})
})