diff --git a/packages/mobile-voice/app.json b/packages/mobile-voice/app.json index 3dcbf0232b..834f5c38bb 100644 --- a/packages/mobile-voice/app.json +++ b/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 diff --git a/packages/opencode/src/cli/cmd/serve.ts b/packages/opencode/src/cli/cmd/serve.ts index 4e5d748063..8c24533381 100644 --- a/packages/opencode/src/cli/cmd/serve.ts +++ b/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 } diff --git a/packages/opencode/test/cli/serve.test.ts b/packages/opencode/test/cli/serve.test.ts new file mode 100644 index 0000000000..a7f6309605 --- /dev/null +++ b/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() + }) +})