diff --git a/src/renderer/src/components/mobile/NetworkInterfacePicker.tsx b/src/renderer/src/components/mobile/NetworkInterfacePicker.tsx index a668ccbc5..af45e066a 100644 --- a/src/renderer/src/components/mobile/NetworkInterfacePicker.tsx +++ b/src/renderer/src/components/mobile/NetworkInterfacePicker.tsx @@ -6,10 +6,11 @@ import type { MobileNetworkInterface } from '../settings/mobile-network-interfac // Why: MobileHero (mobile pairing screen) and MobileNetworkInterfaceSection // (Settings → Mobile) both need the same network selector. This wraps the -// generic AddressPicker with the mobile grammar (IPv4 / Tailscale *.ts.net) -// and copy. Discovered interfaces come from the OS; "Add custom address…" -// opens a dialog for an address the OS didn't surface — the only way to pair -// across networks. +// generic AddressPicker with the mobile grammar (IPv4, any RFC 1123 +// hostname — including Tailscale *.ts.net and DDNS domains — optionally +// with :port) and copy. Discovered interfaces come from the OS; "Add custom +// address…" opens a dialog for an address the OS didn't surface — the only +// way to pair across networks. export type NetworkInterfacePickerProps = { networkInterfaces: readonly MobileNetworkInterface[] @@ -81,11 +82,11 @@ export function NetworkInterfacePicker({ inputLabel: translate('auto.components.mobile.CustomNetworkAddressDialog.label', 'Address'), placeholder: translate( 'auto.components.mobile.CustomNetworkAddressDialog.placeholder', - 'my-mac.ts.net or 192.168.1.50' + 'my-mac.ts.net, home.example.com, or 192.168.1.50' ), hint: translate( 'auto.components.mobile.CustomNetworkAddressDialog.hint', - 'Enter an IP address or a Tailscale hostname (ends in .ts.net).' + 'Enter an IP address or a hostname — a Tailscale MagicDNS name, a DDNS domain, or a LAN hostname — optionally with :port.' ), cancel: translate('auto.components.mobile.CustomNetworkAddressDialog.cancel', 'Cancel'), confirm: translate('auto.components.mobile.CustomNetworkAddressDialog.use', 'Use address') diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 5611d8d73..32395a562 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -10216,8 +10216,8 @@ "title": "Custom network address", "description": "Advertise an address your phone can reach when it is not on the same Wi-Fi — for example a Tailscale hostname or a static IP.", "label": "Address", - "placeholder": "my-mac.ts.net or 192.168.1.50", - "hint": "Enter an IP address or a Tailscale hostname (ends in .ts.net).", + "placeholder": "my-mac.ts.net, home.example.com, or 192.168.1.50", + "hint": "Enter an IP address or a hostname — a Tailscale MagicDNS name, a DDNS domain, or a LAN hostname — optionally with :port.", "cancel": "Cancel", "use": "Use address" }, diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index ebca92c50..ea305d3ab 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -10216,8 +10216,8 @@ "title": "Dirección de red personalizada", "description": "Anuncia una dirección que tu teléfono pueda alcanzar cuando no esté en la misma red Wi-Fi: por ejemplo, un nombre de host de Tailscale o una IP estática.", "label": "Dirección", - "placeholder": "my-mac.ts.net o 192.168.1.50", - "hint": "Introduce una dirección IP o un nombre de host de Tailscale (termina en .ts.net).", + "placeholder": "my-mac.ts.net, home.example.com o 192.168.1.50", + "hint": "Introduce una dirección IP o un nombre de host (un nombre MagicDNS de Tailscale, un dominio DDNS o un nombre de host de LAN), opcionalmente con :puerto.", "cancel": "Cancelar", "use": "Usar dirección" }, diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index c02e6f709..fc9795ca8 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -10216,8 +10216,8 @@ "title": "カスタムネットワークアドレス", "description": "同じ Wi-Fi に接続していないときにスマートフォンから到達できるアドレスを指定します。たとえば Tailscale のホスト名や静的 IP です。", "label": "アドレス", - "placeholder": "my-mac.ts.net または 192.168.1.50", - "hint": "IP アドレスまたは Tailscale のホスト名(.ts.net で終わる)を入力してください。", + "placeholder": "my-mac.ts.net、home.example.com、または 192.168.1.50", + "hint": "IP アドレスまたはホスト名(Tailscale の MagicDNS 名、DDNS ドメイン、LAN のホスト名など)を入力してください。必要に応じて :ポート を付けられます。", "cancel": "キャンセル", "use": "アドレスを使用" }, diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index ce2b4283f..5ab1eade7 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -10216,8 +10216,8 @@ "title": "사용자 지정 네트워크 주소", "description": "같은 Wi-Fi에 있지 않을 때 휴대폰이 연결할 수 있는 주소를 알립니다. 예: Tailscale 호스트 이름 또는 고정 IP.", "label": "주소", - "placeholder": "my-mac.ts.net 또는 192.168.1.50", - "hint": "IP 주소 또는 Tailscale 호스트 이름(.ts.net으로 끝남)을 입력하세요.", + "placeholder": "my-mac.ts.net, home.example.com 또는 192.168.1.50", + "hint": "IP 주소 또는 호스트 이름(Tailscale MagicDNS 이름, DDNS 도메인 또는 LAN 호스트 이름)을 입력하세요. 필요하면 :포트를 붙일 수 있습니다.", "cancel": "취소", "use": "주소 사용" }, diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 719e1e985..5f9fff503 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -10216,8 +10216,8 @@ "title": "自定义网络地址", "description": "指定当手机不在同一 Wi-Fi 时可以访问的地址,例如 Tailscale 主机名或静态 IP。", "label": "地址", - "placeholder": "my-mac.ts.net 或 192.168.1.50", - "hint": "请输入 IP 地址或 Tailscale 主机名(以 .ts.net 结尾)。", + "placeholder": "my-mac.ts.net、home.example.com 或 192.168.1.50", + "hint": "请输入 IP 地址或主机名(Tailscale MagicDNS 名称、DDNS 域名或 LAN 主机名),可选择附加 :端口。", "cancel": "取消", "use": "使用地址" }, diff --git a/src/shared/network/manual-address.test.ts b/src/shared/network/manual-address.test.ts index 3d1b48b43..fe6c1950f 100644 --- a/src/shared/network/manual-address.test.ts +++ b/src/shared/network/manual-address.test.ts @@ -19,11 +19,18 @@ describe('parseManualNetworkAddress', () => { expect(parseManualNetworkAddress('255.255.255.255').ok).toBe(true) }) + it('accepts an IPv4 address with a port suffix', () => { + expect(parseManualNetworkAddress('192.168.1.24:8080')).toEqual({ + ok: true, + address: '192.168.1.24:8080' + }) + }) + it('rejects malformed IPv4', () => { for (const bad of ['', ' ', '1.2.3', '1.2.3.4.5', '256.0.0.1']) { expect(parseManualNetworkAddress(bad)).toEqual({ ok: false, - error: 'Enter an IPv4 address or Tailscale MagicDNS hostname' + error: 'Enter an IPv4 address or hostname, optionally with a :port suffix' }) } }) @@ -31,7 +38,7 @@ describe('parseManualNetworkAddress', () => { it('rejects leading zeros in octets', () => { expect(parseManualNetworkAddress('01.02.03.04')).toEqual({ ok: false, - error: 'Enter an IPv4 address or Tailscale MagicDNS hostname' + error: 'Enter an IPv4 address or hostname, optionally with a :port suffix' }) expect(parseManualNetworkAddress('0.0.0.0').ok).toBe(true) }) @@ -57,11 +64,107 @@ describe('parseManualNetworkAddress', () => { expect(parseManualNetworkAddress('MY-MAC.TS.NET').ok).toBe(true) }) - it('rejects non-Tailscale hostnames', () => { - for (const bad of ['my-mac', 'my-mac.ts.com', '-foo.ts.net', 'my-mac.com']) { + it('rejects a MagicDNS-shaped hostname with a malformed label', () => { + // Leading/trailing hyphens are invalid in any RFC 1123 label, + // regardless of the `.ts.net` suffix. + expect(parseManualNetworkAddress('-foo.ts.net').ok).toBe(false) + }) + }) + + describe('arbitrary hostnames (DDNS / self-hosted domains)', () => { + it('accepts a bare single-label hostname', () => { + expect(parseManualNetworkAddress('my-nas')).toEqual({ + ok: true, + address: 'my-nas' + }) + }) + + it('accepts a multi-label domain, e.g. a DDNS hostname', () => { + expect(parseManualNetworkAddress('home.example.com')).toEqual({ + ok: true, + address: 'home.example.com' + }) + expect(parseManualNetworkAddress('my-house.ddns.net').ok).toBe(true) + }) + + it('accepts a hostname with a port suffix', () => { + expect(parseManualNetworkAddress('home.example.com:8443')).toEqual({ + ok: true, + address: 'home.example.com:8443' + }) + }) + + it('is case-insensitive', () => { + expect(parseManualNetworkAddress('HOME.EXAMPLE.COM').ok).toBe(true) + }) + + it('rejects labels with leading or trailing hyphens', () => { + for (const bad of ['-foo.example.com', 'foo-.example.com', 'foo..com']) { expect(parseManualNetworkAddress(bad).ok).toBe(false) } }) + + it('rejects hostnames with characters outside the RFC 1123 grammar', () => { + for (const bad of ['my_host.example.com', 'my host.example.com', 'example.com/path']) { + expect(parseManualNetworkAddress(bad).ok).toBe(false) + } + }) + + it('rejects a host whose last label is numeric as an ambiguous IPv4', () => { + // The WHATWG URL parser downstream treats "ends in a number" as an IPv4 + // signal and would reinterpret or fail to resolve these (`123` -> + // `0.0.0.123`; `foo.123` -> no-op fallback), so accepting one here would + // validate an address the main process dials differently. + const bad = [ + '123', + '123:8080', + '1.2.3', + '1.2.3.4.5', + '256.0.0.1', + '999.999.999.999', + 'foo.123', + 'foo.123:8080', + 'foo.0x1' + ] + for (const value of bad) { + expect(parseManualNetworkAddress(value).ok).toBe(false) + } + }) + + it('still accepts a hostname whose last label merely contains digits', () => { + // Only a fully-numeric (or hex) final label is ambiguous; a label like + // `com` or `nas1` is a normal hostname. + expect(parseManualNetworkAddress('host2.example.com').ok).toBe(true) + expect(parseManualNetworkAddress('my-nas1').ok).toBe(true) + }) + }) + + describe('port suffix', () => { + it('accepts boundary port values', () => { + expect(parseManualNetworkAddress('example.com:1').ok).toBe(true) + expect(parseManualNetworkAddress('example.com:65535').ok).toBe(true) + }) + + it('rejects out-of-range ports', () => { + expect(parseManualNetworkAddress('example.com:0').ok).toBe(false) + expect(parseManualNetworkAddress('example.com:65536').ok).toBe(false) + expect(parseManualNetworkAddress('example.com:99999').ok).toBe(false) + }) + + it('rejects a non-numeric or empty port', () => { + expect(parseManualNetworkAddress('example.com:abc').ok).toBe(false) + expect(parseManualNetworkAddress('example.com:').ok).toBe(false) + }) + + it('rejects a leading-zero (non-canonical, unbounded-length) port', () => { + expect(parseManualNetworkAddress('example.com:0080').ok).toBe(false) + expect(parseManualNetworkAddress(`example.com:${'0'.repeat(1000)}8080`).ok).toBe(false) + }) + + it('rejects addresses with more than one colon (e.g. IPv6-shaped input)', () => { + expect(parseManualNetworkAddress('example.com:80:90').ok).toBe(false) + expect(parseManualNetworkAddress('::1').ok).toBe(false) + }) }) describe('length and whitespace', () => { @@ -77,5 +180,9 @@ describe('parseManualNetworkAddress', () => { address: '192.168.1.24' }) }) + + it('rejects whitespace inside the address', () => { + expect(parseManualNetworkAddress('my host.example.com').ok).toBe(false) + }) }) }) diff --git a/src/shared/network/manual-address.ts b/src/shared/network/manual-address.ts index 9f6a8421f..81a8a28a2 100644 --- a/src/shared/network/manual-address.ts +++ b/src/shared/network/manual-address.ts @@ -1,36 +1,83 @@ // Why: pure shared helper so the same validation runs in renderer // today and in any future CLI/main-process caller without duplicating -// the IPv4 + Tailscale MagicDNS grammar. +// the IPv4 + hostname + optional-port grammar. const IPV4_OCTET = '(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])' const IPV4 = `(?:${IPV4_OCTET}\\.){3}${IPV4_OCTET}` -// MagicDNS hostname: lowercase letters/digits/hyphens, dot-separated, ending in .ts.net. -// Labels may not start or end with a hyphen; max 63 chars per label (DNS limit). -const MAGICDNS_LABEL = '[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?' -const MAGICDNS = `(?:${MAGICDNS_LABEL}\\.)+ts\\.net` +const IPV4_REGEX = new RegExp(`^${IPV4}$`) + +// RFC 1123 hostname label: letters/digits/hyphens, 1-63 chars, may not +// start or end with a hyphen. This covers plain LAN hostnames, DDNS domains +// (e.g. `home.example.com`), and Tailscale MagicDNS names (`*.ts.net`) as a +// special case of the same grammar — no separate MagicDNS-only pattern needed. +const HOSTNAME_LABEL = '[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?' +const HOSTNAME = `(?:${HOSTNAME_LABEL}\\.)*${HOSTNAME_LABEL}` +const HOSTNAME_REGEX = new RegExp(`^${HOSTNAME}$`, 'i') const HOSTNAME_MAX_LENGTH = 253 -const ERROR_MESSAGE = 'Enter an IPv4 address or Tailscale MagicDNS hostname' +const MIN_PORT = 1 +const MAX_PORT = 65535 +const ERROR_MESSAGE = 'Enter an IPv4 address or hostname, optionally with a :port suffix' export type ParseManualAddressResult = { ok: true; address: string } | { ok: false; error: string } export function parseManualNetworkAddress(input: string): ParseManualAddressResult { const trimmed = input.trim() - if (trimmed === '' || trimmed.length > HOSTNAME_MAX_LENGTH) { + if (trimmed === '') { return { ok: false, error: ERROR_MESSAGE } } if (/\s/.test(trimmed)) { return { ok: false, error: ERROR_MESSAGE } } - const ipv4Regex = new RegExp(`^${IPV4}$`) - if (ipv4Regex.test(trimmed)) { - return { ok: true, address: trimmed } + const { host, port } = splitHostPort(trimmed) + if (host === '' || host.length > HOSTNAME_MAX_LENGTH) { + return { ok: false, error: ERROR_MESSAGE } + } + if (port !== null && !isValidPort(port)) { + return { ok: false, error: ERROR_MESSAGE } } - const magicRegex = new RegExp(`^(?:${MAGICDNS})$`, 'i') - if (magicRegex.test(trimmed)) { + if (IPV4_REGEX.test(host)) { + return { ok: true, address: trimmed } + } + // Why: reject a host whose last label is numeric — a bare `123`, a dotted + // `256.0.0.1`, or a hostname with a numeric final label (`foo.123`, `foo.0x1`). + // The WHATWG URL host parser that `resolvePairingEndpoint` feeds this into + // treats "ends in a number" as an IPv4 signal and tries to parse the whole + // host as IPv4. A real IPv4 was already accepted above, so anything reaching + // here would fail that parse and the main process would silently dial a + // different host — so treat these as mistyped IPs, not hostnames. + const lastLabel = host.split('.').at(-1) ?? '' + if (/^[0-9]+$/.test(lastLabel) || /^0x[0-9a-f]*$/i.test(lastLabel)) { + return { ok: false, error: ERROR_MESSAGE } + } + if (HOSTNAME_REGEX.test(host)) { return { ok: true, address: trimmed } } return { ok: false, error: ERROR_MESSAGE } } + +// Why: mirrors `parsePairingAddressOverride` in src/main/runtime/runtime-rpc.ts +// so the UI only accepts what the main process's pairing endpoint resolution +// can already handle. IPv6 stays out of scope (same as that function), so a +// second colon is left in `host` and fails the grammar checks below instead +// of being misparsed as a port. +function splitHostPort(value: string): { host: string; port: string | null } { + const firstColon = value.indexOf(':') + if (firstColon === -1 || value.includes(':', firstColon + 1)) { + return { host: value, port: null } + } + return { host: value.slice(0, firstColon), port: value.slice(firstColon + 1) } +} + +function isValidPort(port: string): boolean { + // Reject leading zeros: a canonical port has none, and allowing them lets an + // arbitrarily long zero-padded string (`00…08080`) slip past the range check + // and inflate the returned address past the hostname length cap. + if (!/^[1-9][0-9]*$/.test(port)) { + return false + } + const value = Number(port) + return value >= MIN_PORT && value <= MAX_PORT +}