#!/bin/bash set -euo pipefail source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" run_node_test <<'JS' const tailscale = requireFromRoot('shell/plugins/panels/tailscale/Model.js') assertDeepEqual( tailscale.filterIPv4(['100.64.0.1', 'fd7a:115c:a1e0::1', '192.168.1.2']), ['100.64.0.1'], 'tailscale keeps only Tailscale IPv4 addresses' ) assertDeepEqual( tailscale.filterIPv6(['100.64.0.1', 'fd7a:115c:a1e0::1', 'fe80::1']), ['fd7a:115c:a1e0::1'], 'tailscale keeps only Tailscale IPv6 addresses' ) assertEqual(tailscale.cleanDnsName('work.tailnet.ts.net.'), 'work.tailnet.ts.net', 'tailscale strips trailing DNS dot') assertEqual(tailscale.displayHostName('localhost', 'work.tailnet.ts.net.'), 'work', 'tailscale falls back from localhost to short DNS name') const status = tailscale.parseStatus(JSON.stringify({ BackendState: 'Running', AuthURL: '', TailscaleIPs: ['100.74.97.73', 'fd7a:115c:a1e0::ff32:6149'], Self: { HostName: 'dhh-fd', DNSName: 'dhh-fd.tail32f559.ts.net.', TailscaleIPs: ['100.74.97.73'] }, Peer: { onlineB: { HostName: 'zed', DNSName: 'zed.tail32f559.ts.net.', TailscaleIPs: ['100.1.1.2'], Online: true, OS: 'linux', ExitNodeOption: true, ExitNode: true }, offline: { HostName: 'offline', DNSName: 'offline.tail32f559.ts.net.', TailscaleIPs: ['100.1.1.3'], Online: false, OS: 'linux' }, offlineExit: { HostName: 'mbu-ser9', DNSName: 'mbu-ser9.tail32f559.ts.net.', TailscaleIPs: ['100.125.28.77', 'fd7a:115c:a1e0::1037:1c4d'], Online: false, OS: 'linux', ExitNodeOption: true, ExitNode: false }, onlineA: { HostName: 'alpha', DNSName: 'alpha.tail32f559.ts.net.', TailscaleIPs: ['100.1.1.1', 'fd7a:115c:a1e0::1901:334b'], Online: true, OS: 'macos' }, mullvadExit: { HostName: 'al-tia-wg-003', DNSName: 'al-tia-wg-003.mullvad.ts.net.', TailscaleIPs: ['100.95.87.11'], Online: true, OS: 'linux', ExitNodeOption: true, ExitNode: false } } })) assert(status.ok && status.running, 'tailscale parses running status') assertEqual(status.selfIp, '100.74.97.73', 'tailscale parses self IP') assertDeepEqual(status.peers.map(peer => peer.HostName), ['alpha', 'zed'], 'tailscale filters offline and Mullvad peers and sorts online peers') assertDeepEqual(status.peers[0].TailscaleIPv6, ['fd7a:115c:a1e0::1901:334b'], 'tailscale preserves peer IPv6 addresses for copy menu') assert(status.peers[1].ExitNodeOption && status.peers[1].ExitNode, 'tailscale preserves exit node flags') assertDeepEqual(status.exitNodes.map(peer => peer.HostName), ['zed'], 'tailscale lists only online tailnet exit nodes') assert(tailscale.isMullvadPeer({ HostName: 'al-tia-wg-003', DNSName: 'al-tia-wg-003.mullvad.ts.net.' }), 'tailscale detects Mullvad status peers') const mullvadNodes = tailscale.parseExitNodeList(` IP HOSTNAME COUNTRY CITY STATUS 100.65.216.13 au-adl-wg-301.mullvad.ts.net Australia Any - 100.65.216.13 au-adl-wg-301.mullvad.ts.net Australia Adelaide - 100.70.240.117 au-bne-wg-301.mullvad.ts.net Australia Brisbane - 100.66.11.119 dk-cph-wg-001.mullvad.ts.net Denmark Copenhagen - 100.101.10.10 us-chi-wg-001.mullvad.ts.net United States Chicago - 100.102.10.10 us-nyc-wg-001.mullvad.ts.net United States New York - 100.1.2.3 office.tailnet.ts.net Denmark Office - # To use an exit node, use tailscale set --exit-node= `) assertDeepEqual( mullvadNodes.map(node => node.DisplayName), ['Adelaide, Australia', 'Brisbane, Australia', 'Copenhagen, Denmark', 'Chicago, United States', 'New York, United States'], 'tailscale parses Mullvad exit nodes and skips duplicate country rows' ) assertEqual(mullvadNodes[2].DNSName, 'dk-cph-wg-001.mullvad.ts.net', 'tailscale preserves Mullvad hostname as exit node target') assertDeepEqual(mullvadNodes[2].TailscaleIPs, ['100.66.11.119'], 'tailscale preserves Mullvad exit node IP') assert(mullvadNodes.every(node => node.Mullvad === true && node.ExitNodeOption === true), 'tailscale marks Mullvad rows as exit nodes') const mullvadRegions = tailscale.mullvadRegionOptions(mullvadNodes) assertDeepEqual( mullvadRegions.map(node => node.DisplayName), ['Adelaide, Australia', 'Brisbane, Australia', 'Copenhagen, Denmark', 'Chicago, United States', 'New York, United States'], 'tailscale groups Mullvad exit nodes by unique city region' ) assertDeepEqual( mullvadRegions.filter(node => node.Country === 'United States').map(node => node.City), ['Chicago', 'New York'], 'tailscale keeps multiple Mullvad cities within a country' ) assertEqual(mullvadRegions[0].DNSName, 'au-adl-wg-301.mullvad.ts.net', 'tailscale uses a concrete city endpoint for grouped regions') assertEqual(mullvadRegions[2].DNSName, 'dk-cph-wg-001.mullvad.ts.net', 'tailscale preserves first available city endpoint') const stopped = tailscale.parseStatus(JSON.stringify({ BackendState: 'Stopped', Peer: { online: { HostName: 'alpha', DNSName: 'alpha.tail32f559.ts.net.', TailscaleIPs: ['100.1.1.1'], Online: true, OS: 'macos' } } })) assert(stopped.ok && !stopped.running, 'tailscale parses stopped status') const accounts = tailscale.parseAccounts(JSON.stringify([ { id: 'db1b', nickname: 'Home', tailnet: 'dhh.github', account: 'dhh@github', selected: true }, { id: '1785', nickname: 'Work', tailnet: '37signals.com', account: 'david@37signals.com', selected: false } ])) assertEqual(accounts.accounts.length, 2, 'tailscale parses multiple connections') assertEqual(accounts.selectedAccountId, 'db1b', 'tailscale records selected connection id') assertEqual(accounts.selectedAccountLabel, 'Home', 'tailscale labels connections by nickname') assertDeepEqual( accounts.accounts.map(account => account.nickname), ['Home', 'Work'], 'tailscale preserves connection nicknames' ) assertEqual( tailscale.accountLabel({ nickname: '', tailnet: 'tailnet.example', account: 'user@example', id: 'abcd' }), 'tailnet.example', 'tailscale labels connections by tailnet when nickname is missing' ) assertDeepEqual(tailscale.parseStatus('{'), { ok: false, unavailable: true, message: 'Status error', error: 'Failed to parse tailscale status' }, 'tailscale reports invalid status JSON') assertDeepEqual(tailscale.parseAccounts('{'), { accounts: [], selectedAccountId: '', selectedAccountLabel: '' }, 'tailscale handles invalid account JSON') JS