فهرست منبع

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.
Ryan Vogel 2 هفته پیش
والد
کامیت
0f58efe030
3فایلهای تغییر یافته به همراه75 افزوده شده و 2 حذف شده
  1. 3 0
      packages/mobile-voice/app.json
  2. 49 2
      packages/opencode/src/cli/cmd/serve.ts
  3. 23 0
      packages/opencode/test/cli/serve.test.ts

+ 3 - 0
packages/mobile-voice/app.json

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

+ 49 - 2
packages/opencode/src/cli/cmd/serve.ts

@@ -1,3 +1,4 @@
+import { spawnSync } from "node:child_process"
 import { createHash, randomBytes } from "node:crypto"
 import os from "node:os"
 import { Server } from "../../server/server"
@@ -21,6 +22,13 @@ type PairPayload = {
   hosts: string[]
 }
 
+type TailscaleStatus = {
+  Self?: {
+    DNSName?: unknown
+    TailscaleIPs?: unknown
+  }
+}
+
 function ipTier(address: string): number {
   const parts = address.split(".")
   if (parts.length !== 4) return 4
@@ -107,6 +115,38 @@ function secretHash(input: string) {
   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) {
   const link = pairLink(pair)
   const qrConfig = {
@@ -171,7 +211,14 @@ export const ServeCommand = cmd({
       .split(",")
       .map((item) => item.trim())
       .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 relaySecret = input || randomBytes(18).toString("base64url")
@@ -180,7 +227,7 @@ export const ServeCommand = cmd({
     if (connectQR) {
       const pairHosts = hosts(opts.hostname, opts.port > 0 ? opts.port : 4096, advertiseHosts, false)
       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
       }
 

+ 23 - 0
packages/opencode/test/cli/serve.test.ts

@@ -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()
+  })
+})