diff --git a/luci-app-tailscale-community/Makefile b/luci-app-tailscale-community/Makefile new file mode 100644 index 000000000..d0ba87cb1 --- /dev/null +++ b/luci-app-tailscale-community/Makefile @@ -0,0 +1,12 @@ +include $(TOPDIR)/rules.mk + +LUCI_TITLE:=LuCI support for Tailscale +LUCI_URL:=https://github.com/tokisaki-galaxy/luci-app-tailscale-community +PKG_DESCRIPTION:=Provides a LuCI Web management interface for Tailscale, allowing viewing status, configuring nodes and daemons. +PKG_MAINTAINER:=Tokisaki-Galaxy +LUCI_DEPENDS:=+tailscale +ip +luci-base +LUCI_PKGARCH:=all + +include $(TOPDIR)/feeds/luci/luci.mk + +# call BuildPackage - OpenWrt buildroot signature diff --git a/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js b/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js new file mode 100644 index 000000000..b7f2d7bdc --- /dev/null +++ b/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js @@ -0,0 +1,590 @@ +'use strict'; +'require view'; +'require form'; +'require rpc'; +'require ui'; +'require uci'; +'require tools.widgets as widgets'; + +const callGetStatus = rpc.declare({ object: 'tailscale', method: 'get_status' }); +const callGetSettings = rpc.declare({ object: 'tailscale', method: 'get_settings' }); +const callSetSettings = rpc.declare({ object: 'tailscale', method: 'set_settings', params: ['form_data'] }); +const callDoLogin = rpc.declare({ object: 'tailscale', method: 'do_login', params: ['form_data'] }); +const callDoLogout = rpc.declare({ object: 'tailscale', method: 'do_logout' }); +const callGetSubroutes = rpc.declare({ object: 'tailscale', method: 'get_subroutes' }); +const callSetupFirewall = rpc.declare({ object: 'tailscale', method: 'setup_firewall' }); +let map; + +const tailscaleSettingsConf = [ + [form.ListValue, 'fw_mode', _('Firewall Mode'), _('Select the firewall backend for Tailscale to use. Requires service restart to take effect.'), {values: ['nftables','iptables'],rmempty: false}], + [form.Flag, 'accept_routes', _('Accept Routes'), _('Allow accepting routes announced by other nodes.'), { rmempty: false }], + [form.Flag, 'advertise_exit_node', _('Advertise Exit Node'), _('Declare this device as an Exit Node.'), { rmempty: false }], + [form.Flag, 'exit_node_allow_lan_access', _('Allow LAN Access'), _('When using the exit node, access to the local LAN is allowed.'), { rmempty: false }], + [form.Flag, 'runwebclient', _('Enable Web Interface'), _('Expose a web interface on port 5252 for managing this node over Tailscale.'), { rmempty: false }], + [form.Flag, 'nosnat', _('Disable SNAT'), _('Disable Source NAT (SNAT) for traffic to advertised routes. Most users should leave this unchecked.'), { rmempty: false }], + [form.Flag, 'shields_up', _('Shields Up'), _('When enabled, blocks all inbound connections from the Tailscale network.'), { rmempty: false }], + [form.Flag, 'ssh', _('Enable Tailscale SSH'), _('Allow connecting to this device through the SSH function of Tailscale.'), { rmempty: false }], + [form.Flag, 'disable_magic_dns', _('Disable MagicDNS'), _('Use system DNS instead of MagicDNS.'), { rmempty: false }] +]; + +const accountConf = []; // dynamic created in render function + +const daemonConf = [ + //[form.Value, 'daemon_mtu', _('Daemon MTU'), _('Set a custom MTU for the Tailscale daemon. Leave blank to use the default value.'), { datatype: 'uinteger', placeholder: '1280' }, { rmempty: false }], + [form.Flag, 'daemon_reduce_memory', _('(Experimental) Reduce Memory Usage'), _('Enabling this option can reduce memory usage, but it may sacrifice some performance (set GOGC=10).'), { rmempty: false }] +]; + +const derpMapUrl = 'https://controlplane.tailscale.com/derpmap/default'; +let regionCodeMap = {}; + +// this function copy from luci-app-frpc. thx +function setParams(o, params) { + if (!params) return; + + for (const [key, val] of Object.entries(params)) { + if (key === 'values') { + [].concat(val).forEach(v => + o.value.apply(o, Array.isArray(v) ? v : [v]) + ); + } else if (key === 'depends') { + const arr = Array.isArray(val) ? val : [val]; + o.deps = arr.map(dep => Object.assign({}, ...o.deps, dep)); + } else { + o[key] = val; + } + } + + if (params.datatype === 'bool') + Object.assign(o, { enabled: 'true', disabled: 'false' }); +} + +// this function copy from luci-app-frpc. thx +function defTabOpts(s, t, opts, params) { + for (let i = 0; i < opts.length; i++) { + const opt = opts[i]; + const o = s.taboption(t, opt[0], opt[1], opt[2], opt[3]); + setParams(o, opt[4]); + setParams(o, params); + } +} + +function getRunningStatus() { + return L.resolveDefault(callGetStatus(), { running: false }).then(function (res) { + return res; + }); +} + +function formatBytes(bytes) { + const bytes_num = parseInt(bytes, 10); + if (isNaN(bytes_num) || bytes_num === 0) return '-'; + const k = 1000; + const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB']; + const i = Math.floor(Math.log(bytes_num) / Math.log(k)); + return parseFloat((bytes_num / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; +} + +function formatLastSeen(d) { + if (!d) return _('N/A'); + if (d === '0001-01-01T00:00:00Z') return _('Now'); + const t = new Date(d); + if (isNaN(t)) return _('Invalid Date'); + const diff = (Date.now() - t) / 1000; + if (diff < 0) return t.toLocaleString(); + if (diff < 60) return _('Just now'); + + const mins = diff / 60, hrs = mins / 60, days = hrs / 24; + const fmt = (n, s, p) => `${Math.floor(n)} ${Math.floor(n) === 1 ? _(s) : _(p)} ${_('ago')}`; + + if (mins < 60) return fmt(mins, 'minute', 'minutes'); + if (hrs < 24) return fmt(hrs, 'hour', 'hours'); + if (days < 30) return fmt(days, 'day', 'days'); + + return t.toISOString().slice(0, 10); +} + +async function initializeRegionMap() { + const cacheKey = 'tailscale_derp_map_cache'; + const ttl = 7 * 24 * 60 * 60 * 1000; // 7 days in milliseconds + + try { + const cachedItem = localStorage.getItem(cacheKey); + if (cachedItem) { + const cached = JSON.parse(cachedItem); + // Check if the cached data is still valid (not expired) + if (Date.now() - cached.timestamp < ttl) { + regionCodeMap = cached.data; + return; + } + } + } catch (e) { + ui.addTimeLimitedNotification(null, [ E('p', _('Error reading cached DERP region map: %s').format(e.message || _('Unknown error'))) ], 7000, 'error'); + } + + // If no valid cache, fetch from the network + try { + const response = await fetch(derpMapUrl); + if (!response.ok) { + return; + } + const data = await response.json(); + const newRegionMap = {}; + for (const regionId in data.Regions) { + const region = data.Regions[regionId]; + const code = (region.RegionCode || '').toLowerCase(); + const name = region.RegionName || region.RegionCode || `Region ${regionId}`; + newRegionMap[code] = name; + } + regionCodeMap = newRegionMap; + + // Save the newly fetched data to the cache + try { + const itemToCache = { + timestamp: Date.now(), + data: regionCodeMap + }; + localStorage.setItem(cacheKey, JSON.stringify(itemToCache)); + } catch (e) { + ui.addTimeLimitedNotification(null, [ E('p', _('Error caching DERP region map: %s').format(e.message || _('Unknown error'))) ], 7000, 'error'); + } + } catch (error) { + ui.addTimeLimitedNotification(null, [ E('p', _('Error fetching DERP region map: %s').format(error.message || _('Unknown error'))) ], 7000, 'error'); + } +} + +function formatConnectionInfo(info) { + if (!info) { return '-'; } + if (typeof info === 'string' && info.length === 3) { + const lowerCaseInfo = info.toLowerCase(); + return regionCodeMap[lowerCaseInfo] || info; + } + return info; +} + +function renderStatus(status) { + // If status object is not yet available, show a loading message. + if (!status || !status.hasOwnProperty('status')) { + return E('em', {}, _('Collecting data ...')); + } + + const notificationId = 'tailscale_health_notification'; + let notificationElement = document.getElementById(notificationId); + if (status.health != '') { + const message = _('Tailscale Health Check: %s').format(status.health); + if (notificationElement) { + notificationElement.textContent = message; + } + else { + let newNotificationContent = E('p', { 'id': notificationId }, message); + ui.addNotification(null, newNotificationContent, 'info'); + } + }else{ + try{ + notificationElement.parentNode.parentNode.remove(); + }catch(e){} + } + + if (Object.keys(regionCodeMap).length === 0) { + initializeRegionMap(); + } + + // --- Part 1: Handle non-running states --- + + // State: Tailscale binary not found. + if (status.status == 'not_installed') { + return E('dl', { 'class': 'cbi-value' }, [ + E('dt', {}, _('Service Status')), + E('dd', {}, E('span', { 'style': 'color:red;' }, E('strong', {}, _('TAILSCALE NOT FOUND')))) + ]); + } + + // State: Logged out, requires user action. + if (status.status == 'logout') { + return E('dl', { 'class': 'cbi-value' }, [ + E('dt', {}, _('Service Status')), + E('dd', {}, [ + E('span', { 'style': 'color:orange;' }, E('strong', {}, _('LOGGED OUT'))), + E('br'), + E('span', {}, _('Please use the login button in the settings below to authenticate.')) + ]) + ]); + } + + // State: Service is installed but not running. + if (status.status != 'running') { + return E('dl', { 'class': 'cbi-value' }, [ + E('dt', {}, _('Service Status')), + E('dd', {}, E('span', { 'style': 'color:red;' }, E('strong', {}, _('NOT RUNNING')))) + ]); + } + + // --- Part 2: Render the full status display for a running service --- + + // A helper array to define the data for the main status table. + const statusData = [ + { label: _('Service Status'), value: E('span', { 'style': 'color:green;' }, E('strong', {}, _('RUNNING'))) }, + { label: _('Version'), value: status.version || 'N/A' }, + { label: _('TUN Mode'), value: status.TUNMode ? _('Enabled') : _('Disabled') }, + { label: _('Tailscale IPv4'), value: status.ipv4 || 'N/A' }, + { label: _('Tailscale IPv6'), value: status.ipv6 || 'N/A' }, + { label: _('Tailnet Name'), value: status.domain_name || 'N/A' } + ]; + + // Build the horizontal status table using the data array. + const statusTable = E('table', { 'style': 'width: 100%; border-spacing: 0 5px;' }, [ + E('tr', {}, statusData.map(item => E('td', { 'style': 'padding-right: 20px;' }, E('strong', {}, item.label)))), + E('tr', {}, statusData.map(item => E('td', { 'style': 'padding-right: 20px;' }, item.value))) + ]); + + // --- Part 3: Render the Peers/Network Devices table --- + + const peers = status.peers; + let peersContent; + + if (!peers || Object.keys(peers).length === 0) { + // Display a message if no peers are found. + peersContent = E('p', {}, _('No peer devices found.')); + } else { + // Define headers for the peers table. + const peerTableHeaders = [ + { text: _('Status'), style: 'width: 80px;' }, + { text: _('Hostname') }, + { text: _('Tailscale IP') }, + { text: _('OS') }, + { text: _('Connection Info') }, + { text: _('RX') }, + { text: _('TX') }, + { text: _('Last Seen') } + ]; + + // Build the peers table. + peersContent = E('table', { 'class': 'cbi-table' }, [ + // Table Header Row + E('tr', { 'class': 'cbi-table-header' }, peerTableHeaders.map(header => { + let th_style = 'padding-right: 20px; text-align: left;'; + if (header.style) { + th_style += header.style; + } + return E('th', { 'class': 'cbi-table-cell', 'style': th_style }, header.text); + })), + + // Table Body Rows (one for each peer) + ...Object.entries(peers).map(([peerid, peer]) => { + const td_style = 'padding-right: 20px;'; + + return E('tr', { 'class': 'cbi-rowstyle-1' }, [ + E('td', { 'class': 'cbi-value-field', 'style': td_style }, + E('span', { + 'style': `color:${peer.exit_node ? 'blue' : (peer.online ? 'green' : 'gray')};`, + 'title': (peer.exit_node ? _('Exit Node') + ' ' : '') + (peer.online ? _('Online') : _('Offline')) + }, peer.online ? '●' : '○') + ), + E('td', { 'class': 'cbi-value-field', 'style': td_style }, E('strong', {}, peer.hostname + (peer.exit_node_option ? ' (ExNode)' : ''))), + E('td', { 'class': 'cbi-value-field', 'style': td_style }, peer.ip || 'N/A'), + E('td', { 'class': 'cbi-value-field', 'style': td_style }, peer.ostype || 'N/A'), + E('td', { 'class': 'cbi-value-field', 'style': td_style }, formatConnectionInfo(peer.linkadress || '-')), + E('td', { 'class': 'cbi-value-field', 'style': td_style }, formatBytes(peer.rx)), + E('td', { 'class': 'cbi-value-field', 'style': td_style }, formatBytes(peer.tx)), + E('td', { 'class': 'cbi-value-field', 'style': td_style }, formatLastSeen(peer.lastseen)) + ]); + }) + ]); + } + + // Combine all parts into a single DocumentFragment. + // Using E() without a tag name creates a fragment, which is perfect for grouping elements. + return E([ + statusTable, + E('div', { 'style': 'margin-top: 25px;' }, [ + E('h4', {}, _('Network Devices')), + peersContent + ]) + ]); +} + +return view.extend({ + load() { + return Promise.all([ + L.resolveDefault(callGetStatus(), { running: '', peers: [] }), + L.resolveDefault(callGetSettings(), { accept_routes: false }), + L.resolveDefault(callGetSubroutes(), { routes: [] }) + ]) + .then(function([status, settings_from_rpc, subroutes]) { + return uci.load('tailscale').then(function() { + if (uci.get('tailscale', 'settings') === null) { + // No existing settings found; initialize UCI with RPC settings + uci.add('tailscale', 'settings', 'settings'); + uci.set('tailscale', 'settings', 'fw_mode', 'nftables'); + uci.set('tailscale', 'settings', 'accept_routes', (settings_from_rpc.accept_routes ? '1' : '0')); + uci.set('tailscale', 'settings', 'advertise_exit_node', ((settings_from_rpc.advertise_exit_node || false) ? '1' : '0')); + uci.set('tailscale', 'settings', 'advertise_routes', (settings_from_rpc.advertise_routes || []).join(', ')); + uci.set('tailscale', 'settings', 'exit_node', settings_from_rpc.exit_node || ''); + uci.set('tailscale', 'settings', 'exit_node_allow_lan_access', ((settings_from_rpc.exit_node_allow_lan_access || false) ? '1' : '0')); + uci.set('tailscale', 'settings', 'ssh', ((settings_from_rpc.ssh || false) ? '1' : '0')); + uci.set('tailscale', 'settings', 'shields_up', ((settings_from_rpc.shields_up || false) ? '1' : '0')); + uci.set('tailscale', 'settings', 'runwebclient', ((settings_from_rpc.runwebclient || false) ? '1' : '0')); + uci.set('tailscale', 'settings', 'nosnat', ((settings_from_rpc.nosnat || false) ? '1' : '0')); + uci.set('tailscale', 'settings', 'disable_magic_dns', ((settings_from_rpc.disable_magic_dns || false) ? '1' : '0')); + + uci.set('tailscale', 'settings', 'daemon_reduce_memory', '0'); + uci.set('tailscale', 'settings', 'daemon_mtu', ''); + return uci.save(); + } + }).then(function() { + return [status, settings_from_rpc, subroutes]; + }); + }); + }, + + render ([status = {}, settings = {}, subroutes_obj]) { + const subroutes = (subroutes_obj && subroutes_obj.routes) ? subroutes_obj.routes : []; + + let s; + map = new form.Map('tailscale', _('Tailscale'), _('Tailscale is a mesh VPN solution that makes it easy to connect your devices securely. This configuration page allows you to manage Tailscale settings on your OpenWrt device.')); + + s = map.section(form.NamedSection, '_status'); + s.anonymous = true; + s.render = function (section_id) { + L.Poll.add( + function () { + return getRunningStatus().then(function (res) { + const view = document.getElementById("service_status_display"); + if (view) { + const content = renderStatus(res); + view.replaceChildren(content); + } + + // login button only available when logged out + const login_btn=document.getElementsByClassName('cbi-button cbi-button-apply')[0]; + if(login_btn) { login_btn.disabled=(res.status != 'logout'); } + }); + }, 10); + + return E('div', { 'id': 'service_status_display', 'class': 'cbi-value' }, + _('Collecting data ...') + ); + } + + // Bind settings to the 'settings' section of uci + s = map.section(form.NamedSection, 'settings', 'settings', _('Settings')); + s.dynamic = true; + + // Create the "General Settings" tab and apply tailscaleSettingsConf + s.tab('general', _('General Settings')); + + defTabOpts(s, 'general', tailscaleSettingsConf, { optional: false }); + + const en = s.taboption('general', form.ListValue, 'exit_node', _('Exit Node'), _('Select an exit node from the list. If enabled, Allow LAN Access is enabled implicitly.')); + en.value('', _('None')); + if (status.peers) { + Object.values(status.peers).forEach(function(peer) { + if (peer.exit_node_option) { + const primaryIp = peer.ip.split('
')[0]; + const label = peer.hostname ? `${peer.hostname} (${primaryIp})` : primaryIp; + en.value(primaryIp, label); + } + }); + } + en.rmempty = true; + en.cfgvalue = function(section_id) { + if (status && status.status === 'running' && status.peers) { + for (const id in status.peers) { + if (status.peers[id].exit_node) { + return status.peers[id].ip.split('
')[0]; + } + } + return ''; + } + return uci.get('tailscale', 'settings', 'exit_node') || ''; + }; + + const o = s.taboption('general', form.DynamicList, 'advertise_routes', _('Advertise Routes'),_('Advertise subnet routes behind this device. Select from the detected subnets below or enter custom routes (comma-separated).')); + if (subroutes.length > 0) { + subroutes.forEach(function(subnet) { + o.value(subnet, subnet); + }); + } + o.rmempty = true; + + const fwBtn = s.taboption('general', form.Button, '_setup_firewall', _('Auto Configure Firewall')); + fwBtn.description = _('Essential configuration for Subnet Routing (Site-to-Site) and Exit Node features.') + +'
'+_('It automatically creates the tailscale interface, sets up firewall zones for LAN <-> Tailscale forwarding,') + +'
'+_('and enables Masquerading and MSS Clamping (MTU fix) to ensure stable connections.'); + fwBtn.inputstyle = 'action'; + fwBtn.onclick = function() { + const btn = this; + btn.disabled = true; + return callSetupFirewall().then(function(res) { + const msg = res?.message || _('Firewall configuration applied.'); + ui.addNotification(null, E('p', {}, msg), 'info'); + }).catch(function(err) { + ui.addNotification(null, E('p', {}, _('Failed to configure firewall: %s').format(err?.message || err || 'Unknown error')), 'error'); + }).finally(function() { + btn.disabled = false; + }); + }; + + const helpTitle = s.taboption('general', form.DummyValue, '_help_title'); + helpTitle.title = _('How to enable Site-to-Site?'); + helpTitle.render = function() { + return E('div', { 'class': 'cbi-value', 'style': 'margin-top: 1em; border-top: 1px font-weight: bold;' }, [ + E('label', { 'class': 'cbi-value-title' }, this.title), + E('div', { 'class': 'cbi-value-field', 'style': 'line-height: 1.6em; font-size: 95%; color: #555;' }, [ + _('1. Select "Accept Routes" (to access remote devices).'), E('br'), + _('2. In "Advertise Routes", select your local subnet (to allow remote devices to access this LAN).'), E('br'), + _('3. Click "Auto Configure Firewall" (to allow traffic forwarding).'), E('br'), + E('strong', { 'style': 'color: #d9534f;' }, _('[Important] Log in to the Tailscale admin console and manually enable "Subnet Routes" for this device.')) + ]) + ]); + }; + + // Create the account settings + s.tab('account', _('Account Settings')); + defTabOpts(s, 'account', accountConf, { optional: false }); + + const loginBtn = s.taboption('account', form.Button, '_login', _('Login'), + _('Click to get a login URL for this device.') + +'
'+_('If the timeout is displayed, you can refresh the page and click Login again.')); + loginBtn.inputstyle = 'apply'; + + const customLoginUrl = s.taboption('account', form.Value, 'custom_login_url', + _('Custom Login Server'), + _('Optional: Specify a custom control server URL (e.g., a Headscale instance, %s).'.format('https://example.com')) + +'
'+_('Leave blank for default Tailscale control plane.') + ); + customLoginUrl.placeholder = ''; + customLoginUrl.rmempty = true; + + const customLoginAuthKey = s.taboption('account', form.Value, 'custom_login_AuthKey', + _('Custom Login Server Auth Key'), + _('Optional: Specify an authentication key for the custom control server. Leave blank if not required.') + +'
'+_('If you are using custom login server but not providing an Auth Key, will redirect to the login page without pre-filling the key.') + ); + customLoginAuthKey.placeholder = ''; + customLoginAuthKey.rmempty = true; + + const logoutBtn = s.taboption('account', form.Button, '_logout', _('Logout'), + _('Click to Log out account on this device.') + +'
'+_('Disconnect from Tailscale and expire current node key.')); + logoutBtn.inputstyle = 'apply'; + logoutBtn.id = 'tailscale_logout_btn'; + + loginBtn.onclick = function() { + const customServerInput = document.getElementById('widget.cbid.tailscale.settings.custom_login_url'); + const customServer = customServerInput ? customServerInput.value : ''; + const customserverAuthInput = document.getElementById('widget.cbid.tailscale.settings.custom_login_AuthKey'); + const customServerAuth = customserverAuthInput ? customserverAuthInput.value : ''; + const loginWindow = window.open('', '_blank'); + if (!loginWindow) { + ui.addTimeLimitedNotification(null, [ E('p', _('Could not open a new tab. Please check if your browser or an extension blocked the pop-up.')) ], 10000, 'error'); + return; + } + // Display a prompt message in the new window + const doc = loginWindow.document; + doc.body.innerHTML = + '

' + _('Tailscale Login') + '

' + + '

' + _('Requesting Tailscale login URL... Please wait.') + '

' + + '

' + _('This can take up to 30 seconds.') + '

'; + + ui.showModal(_('Requesting Login URL...'), E('em', {}, _('Please wait.'))); + const payload = { + loginserver: customServer || '', + loginserver_authkey: customServerAuth || '' + }; + // Show a "loading" modal and execute the asynchronous RPC call + ui.showModal(_('Requesting Login URL...'), E('em', {}, _('Please wait.'))); + return callDoLogin(payload).then(function(res) { + ui.hideModal(); + if (res && res.url) { + // After successfully obtaining the URL, redirect the previously opened tab + loginWindow.location.href = res.url; + } else { + // If it fails, inform the user and they can close the new tab + doc.body.innerHTML = + '

' + _('Error') + '

' + + '

' + _('Failed to get login URL. You may close this tab.') + '

'; + ui.addTimeLimitedNotification(null, [ E('p', _('Failed to get login URL: Invalid response from server.')) ], 7000, 'error'); + } + }).catch(function(err) { + ui.hideModal(); + ui.addTimeLimitedNotification(null, [ E('p', _('Failed to get login URL: %s').format(err.message || _('Unknown error'))) ], 7000, 'error'); + }); + }; + + logoutBtn.onclick = function() { + const confirmationContent = E([ + E('p', {}, _('Are you sure you want to log out?') + +'
'+_('This will disconnect this device from your Tailnet and require you to re-authenticate.')), + + E('div', { 'style': 'text-align: right; margin-top: 1em;' }, [ + E('button', { + 'class': 'cbi-button', + 'click': ui.hideModal + }, _('Cancel')), + ' ', + E('button', { + 'class': 'cbi-button cbi-button-negative', + 'click': function() { + ui.hideModal(); + ui.showModal(_('Logging out...'), E('em', {}, _('Please wait.'))); + + return callDoLogout().then(function(res) { + ui.hideModal(); + ui.addTimeLimitedNotification(null, [ E('p', _('Successfully logged out.')) ], 5000, 'info'); + }).catch(function(err) { + ui.hideModal(); + ui.addTimeLimitedNotification(null, [ E('p', _('Logout failed: %s').format(err.message || _('Unknown error'))) ], 7000, 'error'); + }); + } + }, _('Logout')) + ]) + ]); + ui.showModal(_('Confirm Logout'), confirmationContent); + }; + + // Create the "Daemon Settings" tab and apply daemonConf + //s.tab('daemon', _('Daemon Settings')); + //defTabOpts(s, 'daemon', daemonConf, { optional: false }); + + return map.render(); + }, + + // The handleSaveApply function is executed after clicking "Save & Apply" + handleSaveApply(ev) { + return map.save().then(function () { + const data = map.data.get('tailscale', 'settings'); + + // fix empty value issue + if(!data.advertise_exit_node) data.advertise_exit_node = ''; + if(!data.advertise_routes) data.advertise_routes = ''; + if(!data.exit_node) data.exit_node = ''; + if(!data.custom_login_url) data.custom_login_url = ''; + if(!data.custom_login_AuthKey) data.custom_login_AuthKey = ''; + + ui.showModal(_('Applying changes...'), E('em', {}, _('Please wait.'))); + + return callSetSettings(data).then(function (response) { + if (response.success) { + ui.hideModal(); + setTimeout(function() { + ui.addTimeLimitedNotification(null, [ E('p', _('Tailscale settings applied successfully.')) ], 5000, 'info'); + }, 1000); + try { + L.ui.changes.revert(); + } catch (error) { + ui.addTimeLimitedNotification(null, [ E('p', _('Error saving settings: %s').format(error || _('Unknown error'))) ], 7000, 'error'); + } + } else { + ui.hideModal(); + ui.addTimeLimitedNotification(null, [ E('p', _('Error applying settings: %s').format(response.error || _('Unknown error'))) ], 7000, 'error'); + } + }); + }).catch(function(err) { + ui.hideModal(); + //console.error('Save failed:', err); + ui.addTimeLimitedNotification(null, [ E('p', _('Failed to save settings: %s').format(err.message)) ], 7000, 'error'); + }); + }, + + handleSave: null, + handleReset: null +}); diff --git a/luci-app-tailscale-community/po/templates/community.pot b/luci-app-tailscale-community/po/templates/community.pot new file mode 100644 index 000000000..12882f266 --- /dev/null +++ b/luci-app-tailscale-community/po/templates/community.pot @@ -0,0 +1,503 @@ +msgid "" +msgstr "Content-Type: text/plain; charset=UTF-8" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:34 +msgid "(Experimental) Reduce Memory Usage" +msgstr "" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:432 +msgid "1. Select \"Accept Routes\" (to access remote devices)." +msgstr "" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:433 +msgid "" +"2. In \"Advertise Routes\", select your local subnet (to allow remote " +"devices to access this LAN)." +msgstr "" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:434 +msgid "3. Click \"Auto Configure Firewall\" (to allow traffic forwarding)." +msgstr "" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:20 +msgid "Accept Routes" +msgstr "" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:441 +msgid "Account Settings" +msgstr "" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:21 +msgid "Advertise Exit Node" +msgstr "" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:400 +msgid "Advertise Routes" +msgstr "" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:400 +msgid "" +"Advertise subnet routes behind this device. Select from the detected subnets " +"below or enter custom routes (comma-separated)." +msgstr "" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:22 +msgid "Allow LAN Access" +msgstr "" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:20 +msgid "Allow accepting routes announced by other nodes." +msgstr "" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:26 +msgid "Allow connecting to this device through the SSH function of Tailscale." +msgstr "" + +#: applications/luci-app-tailscale-community/root/usr/share/rpcd/acl.d/luci-app-tailscale-community.json:3 +msgid "Allow user access to tailscale" +msgstr "" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:563 +msgid "Applying changes..." +msgstr "" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:515 +msgid "Are you sure you want to log out?" +msgstr "" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:408 +msgid "Auto Configure Firewall" +msgstr "" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:522 +msgid "Cancel" +msgstr "" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:466 +msgid "Click to Log out account on this device." +msgstr "" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:445 +msgid "Click to get a login URL for this device." +msgstr "" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:166 +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:363 +msgid "Collecting data ..." +msgstr "" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:541 +msgid "Confirm Logout" +msgstr "" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:253 +msgid "Connection Info" +msgstr "" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:478 +msgid "" +"Could not open a new tab. Please check if your browser or an extension " +"blocked the pop-up." +msgstr "" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:450 +msgid "Custom Login Server" +msgstr "" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:458 +msgid "Custom Login Server Auth Key" +msgstr "" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:21 +msgid "Declare this device as an Exit Node." +msgstr "" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:27 +msgid "Disable MagicDNS" +msgstr "" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:24 +msgid "Disable SNAT" +msgstr "" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:24 +msgid "" +"Disable Source NAT (SNAT) for traffic to advertised routes. Most users " +"should leave this unchecked." +msgstr "" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:226 +msgid "Disabled" +msgstr "" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:467 +msgid "Disconnect from Tailscale and expire current node key." +msgstr "" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:26 +msgid "Enable Tailscale SSH" +msgstr "" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:23 +msgid "Enable Web Interface" +msgstr "" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:226 +msgid "Enabled" +msgstr "" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:34 +msgid "" +"Enabling this option can reduce memory usage, but it may sacrifice some " +"performance (set GOGC=10)." +msgstr "" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:503 +msgid "Error" +msgstr "" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:578 +msgid "Error applying settings: %s" +msgstr "" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:147 +msgid "Error caching DERP region map: %s" +msgstr "" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:150 +msgid "Error fetching DERP region map: %s" +msgstr "" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:120 +msgid "Error reading cached DERP region map: %s" +msgstr "" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:574 +msgid "Error saving settings: %s" +msgstr "" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:409 +msgid "" +"Essential configuration for Subnet Routing (Site-to-Site) and Exit Node " +"features." +msgstr "" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:278 +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:376 +msgid "Exit Node" +msgstr "" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:23 +msgid "" +"Expose a web interface on port 5252 for managing this node over Tailscale." +msgstr "" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:420 +msgid "Failed to configure firewall: %s" +msgstr "" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:504 +msgid "Failed to get login URL. You may close this tab." +msgstr "" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:509 +msgid "Failed to get login URL: %s" +msgstr "" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:505 +msgid "Failed to get login URL: Invalid response from server." +msgstr "" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:584 +msgid "Failed to save settings: %s" +msgstr "" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:19 +msgid "Firewall Mode" +msgstr "" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:417 +msgid "Firewall configuration applied." +msgstr "" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:372 +msgid "General Settings" +msgstr "" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:250 +msgid "Hostname" +msgstr "" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:427 +msgid "How to enable Site-to-Site?" +msgstr "" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:446 +msgid "" +"If the timeout is displayed, you can refresh the page and click Login again." +msgstr "" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:460 +msgid "" +"If you are using custom login server but not providing an Auth Key, will " +"redirect to the login page without pre-filling the key." +msgstr "" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:90 +msgid "Invalid Date" +msgstr "" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:410 +msgid "" +"It automatically creates the tailscale interface, sets up firewall zones for " +"LAN <-> Tailscale forwarding," +msgstr "" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:93 +msgid "Just now" +msgstr "" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:205 +msgid "LOGGED OUT" +msgstr "" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:256 +msgid "Last Seen" +msgstr "" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:452 +msgid "Leave blank for default Tailscale control plane." +msgstr "" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:528 +msgid "Logging out..." +msgstr "" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:444 +msgid "Login" +msgstr "" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:465 +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:538 +msgid "Logout" +msgstr "" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:535 +msgid "Logout failed: %s" +msgstr "" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:87 +msgid "N/A" +msgstr "" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:216 +msgid "NOT RUNNING" +msgstr "" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:298 +msgid "Network Devices" +msgstr "" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:245 +msgid "No peer devices found." +msgstr "" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:377 +msgid "None" +msgstr "" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:88 +msgid "Now" +msgstr "" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:252 +msgid "OS" +msgstr "" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:278 +msgid "Offline" +msgstr "" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:278 +msgid "Online" +msgstr "" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:451 +msgid "" +"Optional: Specify a custom control server URL (e.g., a Headscale instance, " +"%s)." +msgstr "" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:459 +msgid "" +"Optional: Specify an authentication key for the custom control server. Leave " +"blank if not required." +msgstr "" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:207 +msgid "Please use the login button in the settings below to authenticate." +msgstr "" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:488 +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:494 +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:528 +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:563 +msgid "Please wait." +msgstr "" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:224 +msgid "RUNNING" +msgstr "" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:254 +msgid "RX" +msgstr "" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:488 +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:494 +msgid "Requesting Login URL..." +msgstr "" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:485 +msgid "Requesting Tailscale login URL... Please wait." +msgstr "" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:376 +msgid "" +"Select an exit node from the list. If enabled, Allow LAN Access is enabled " +"implicitly." +msgstr "" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:19 +msgid "" +"Select the firewall backend for Tailscale to use. Requires service restart " +"to take effect." +msgstr "" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:195 +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:203 +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:215 +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:224 +msgid "Service Status" +msgstr "" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:368 +msgid "Settings" +msgstr "" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:25 +msgid "Shields Up" +msgstr "" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:249 +msgid "Status" +msgstr "" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:532 +msgid "Successfully logged out." +msgstr "" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:196 +msgid "TAILSCALE NOT FOUND" +msgstr "" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:226 +msgid "TUN Mode" +msgstr "" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:255 +msgid "TX" +msgstr "" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:229 +msgid "Tailnet Name" +msgstr "" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:342 +#: applications/luci-app-tailscale-community/root/usr/share/luci/menu.d/luci-app-tailscale-community.json:3 +msgid "Tailscale" +msgstr "" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:172 +msgid "Tailscale Health Check: %s" +msgstr "" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:251 +msgid "Tailscale IP" +msgstr "" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:227 +msgid "Tailscale IPv4" +msgstr "" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:228 +msgid "Tailscale IPv6" +msgstr "" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:484 +msgid "Tailscale Login" +msgstr "" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:342 +msgid "" +"Tailscale is a mesh VPN solution that makes it easy to connect your devices " +"securely. This configuration page allows you to manage Tailscale settings on " +"your OpenWrt device." +msgstr "" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:569 +msgid "Tailscale settings applied successfully." +msgstr "" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:486 +msgid "This can take up to 30 seconds." +msgstr "" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:516 +msgid "" +"This will disconnect this device from your Tailnet and require you to re-" +"authenticate." +msgstr "" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:120 +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:147 +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:150 +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:509 +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:535 +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:574 +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:578 +msgid "Unknown error" +msgstr "" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:27 +msgid "Use system DNS instead of MagicDNS." +msgstr "" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:225 +msgid "Version" +msgstr "" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:25 +msgid "" +"When enabled, blocks all inbound connections from the Tailscale network." +msgstr "" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:22 +msgid "When using the exit node, access to the local LAN is allowed." +msgstr "" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:435 +msgid "" +"[Important] Log in to the Tailscale admin console and manually enable " +"\"Subnet Routes\" for this device." +msgstr "" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:96 +msgid "ago" +msgstr "" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:411 +msgid "" +"and enables Masquerading and MSS Clamping (MTU fix) to ensure stable " +"connections." +msgstr "" diff --git a/luci-app-tailscale-community/po/zh-cn b/luci-app-tailscale-community/po/zh-cn new file mode 120000 index 000000000..8d69574dd --- /dev/null +++ b/luci-app-tailscale-community/po/zh-cn @@ -0,0 +1 @@ +zh_Hans \ No newline at end of file diff --git a/luci-app-tailscale-community/po/zh_Hans/community.po b/luci-app-tailscale-community/po/zh_Hans/community.po new file mode 100644 index 000000000..851cd36c2 --- /dev/null +++ b/luci-app-tailscale-community/po/zh_Hans/community.po @@ -0,0 +1,508 @@ +msgid "" +msgstr "Content-Type: text/plain; charset=UTF-8\n" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:34 +msgid "(Experimental) Reduce Memory Usage" +msgstr "(实验性) 减少内存使用" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:432 +msgid "1. Select \"Accept Routes\" (to access remote devices)." +msgstr "1. 勾选 接受路由 (接受远程设备访问)。" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:433 +msgid "" +"2. In \"Advertise Routes\", select your local subnet (to allow remote " +"devices to access this LAN)." +msgstr "2. 在 通告路由 选择本设备的局域网段 (让远程设备能访问你)。" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:434 +msgid "3. Click \"Auto Configure Firewall\" (to allow traffic forwarding)." +msgstr "3. 点击 自动配置防火墙 (打通防火墙转发)。" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:20 +msgid "Accept Routes" +msgstr "接受路由" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:441 +msgid "Account Settings" +msgstr "账户设置" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:21 +msgid "Advertise Exit Node" +msgstr "通告出口节点" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:400 +msgid "Advertise Routes" +msgstr "通告路由" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:400 +msgid "" +"Advertise subnet routes behind this device. Select from the detected subnets " +"below or enter custom routes (comma-separated)." +msgstr "" +"通告此设备后的子网路由。从下面的子网中选择,或输入自定义路由 (逗号分隔)。" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:22 +msgid "Allow LAN Access" +msgstr "允许局域网访问" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:20 +msgid "Allow accepting routes announced by other nodes." +msgstr "允许接受由其他节点通告的路由。" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:26 +msgid "Allow connecting to this device through the SSH function of Tailscale." +msgstr "允许通过 Tailscale 的 SSH 功能连接到此设备。" + +#: applications/luci-app-tailscale-community/root/usr/share/rpcd/acl.d/luci-app-tailscale-community.json:3 +msgid "Allow user access to tailscale" +msgstr "允许用户访问 Tailscale" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:563 +msgid "Applying changes..." +msgstr "正在应用更改..." + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:515 +msgid "Are you sure you want to log out?" +msgstr "您确定要登出吗?" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:408 +msgid "Auto Configure Firewall" +msgstr "自动配置防火墙" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:522 +msgid "Cancel" +msgstr "取消" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:466 +msgid "Click to Log out account on this device." +msgstr "点击以登出此设备上的账户。" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:445 +msgid "Click to get a login URL for this device." +msgstr "点击获取此设备的登录 URL。" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:166 +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:363 +msgid "Collecting data ..." +msgstr "正在收集数据..." + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:541 +msgid "Confirm Logout" +msgstr "确认登出" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:253 +msgid "Connection Info" +msgstr "连接信息" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:478 +msgid "" +"Could not open a new tab. Please check if your browser or an extension " +"blocked the pop-up." +msgstr "无法打开新标签页。请检查您的浏览器或扩展程序是否阻止了弹出窗口。" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:450 +msgid "Custom Login Server" +msgstr "自定义登录服务器" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:458 +msgid "Custom Login Server Auth Key" +msgstr "自定义登录服务器认证密钥" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:21 +msgid "Declare this device as an Exit Node." +msgstr "将此设备声明为出口节点。" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:27 +msgid "Disable MagicDNS" +msgstr "禁用 MagicDNS" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:24 +msgid "Disable SNAT" +msgstr "禁用 SNAT" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:24 +msgid "" +"Disable Source NAT (SNAT) for traffic to advertised routes. Most users " +"should leave this unchecked." +msgstr "为通告路由的流量禁用源地址转换 (SNAT)。大多数用户应保持此项不勾选。" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:226 +msgid "Disabled" +msgstr "已禁用" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:467 +msgid "Disconnect from Tailscale and expire current node key." +msgstr "从 Tailscale 断开连接并使当前节点密钥过期。" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:26 +msgid "Enable Tailscale SSH" +msgstr "启用 Tailscale SSH" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:23 +msgid "Enable Web Interface" +msgstr "启用 Web 界面" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:226 +msgid "Enabled" +msgstr "已启用" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:34 +msgid "" +"Enabling this option can reduce memory usage, but it may sacrifice some " +"performance (set GOGC=10)." +msgstr "启用此选项可以减少内存使用,但可能会牺牲一些性能 (设置 GOGC=10)。" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:503 +msgid "Error" +msgstr "错误" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:578 +msgid "Error applying settings: %s" +msgstr "应用设置时出错: %s" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:147 +msgid "Error caching DERP region map: %s" +msgstr "缓存 DERP 区域地图时出错: %s" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:150 +msgid "Error fetching DERP region map: %s" +msgstr "获取 DERP 区域地图时出错: %s" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:120 +msgid "Error reading cached DERP region map: %s" +msgstr "读取缓存的 DERP 区域地图时出错: %s" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:574 +msgid "Error saving settings: %s" +msgstr "保存设置时出错: %s" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:409 +msgid "" +"Essential configuration for Subnet Routing (Site-to-Site) and Exit Node " +"features." +msgstr "子网路由(局域网间互通)和出口节点的基本配置。" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:278 +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:376 +msgid "Exit Node" +msgstr "出口节点" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:23 +msgid "" +"Expose a web interface on port 5252 for managing this node over Tailscale." +msgstr "在端口 5252 上暴露一个 Web 界面,用于通过 Tailscale 管理此节点。" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:420 +msgid "Failed to configure firewall: %s" +msgstr "获取防火墙设置失败: %s" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:504 +msgid "Failed to get login URL. You may close this tab." +msgstr "获取登录 URL 失败。您可以关闭此标签页。" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:509 +msgid "Failed to get login URL: %s" +msgstr "获取登录 URL 失败: %s" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:505 +msgid "Failed to get login URL: Invalid response from server." +msgstr "获取登录 URL 失败: 服务器响应无效。" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:584 +msgid "Failed to save settings: %s" +msgstr "保存设置失败: %s" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:19 +msgid "Firewall Mode" +msgstr "防火墙模式" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:417 +msgid "Firewall configuration applied." +msgstr "已应用防火墙配置" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:372 +msgid "General Settings" +msgstr "常规设置" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:250 +msgid "Hostname" +msgstr "主机名" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:427 +msgid "How to enable Site-to-Site?" +msgstr "如何实现站点到站点(即多局域网互通)?" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:446 +msgid "" +"If the timeout is displayed, you can refresh the page and click Login again." +msgstr "如果显示超时,您可以刷新页面并再次点击登录。" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:460 +msgid "" +"If you are using custom login server but not providing an Auth Key, will " +"redirect to the login page without pre-filling the key." +msgstr "" +"如果您使用自定义登录服务器但未提供认证密钥,将重定向到登录页面而不会预先填充" +"密钥。" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:90 +msgid "Invalid Date" +msgstr "无效日期" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:410 +msgid "" +"It automatically creates the tailscale interface, sets up firewall zones for " +"LAN <-> Tailscale forwarding," +msgstr "将自动创建Tailscale接口并设置防火墙区域用于LAN <-> Tailscale转发" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:93 +msgid "Just now" +msgstr "刚才" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:205 +msgid "LOGGED OUT" +msgstr "已登出" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:256 +msgid "Last Seen" +msgstr "上次在线" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:452 +msgid "Leave blank for default Tailscale control plane." +msgstr "留空以使用默认的 Tailscale 控制平面。" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:528 +msgid "Logging out..." +msgstr "正在登出..." + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:444 +msgid "Login" +msgstr "登录" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:465 +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:538 +msgid "Logout" +msgstr "登出" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:535 +msgid "Logout failed: %s" +msgstr "登出失败: %s" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:87 +msgid "N/A" +msgstr "不可用" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:216 +msgid "NOT RUNNING" +msgstr "未运行" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:298 +msgid "Network Devices" +msgstr "网络设备" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:245 +msgid "No peer devices found." +msgstr "未找到节点设备。" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:377 +msgid "None" +msgstr "无" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:88 +msgid "Now" +msgstr "现在" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:252 +msgid "OS" +msgstr "操作系统" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:278 +msgid "Offline" +msgstr "离线" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:278 +msgid "Online" +msgstr "在线" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:451 +msgid "" +"Optional: Specify a custom control server URL (e.g., a Headscale instance, " +"%s)." +msgstr "可选:指定一个自定义控制服务器 URL (例如,一个 Headscale 实例, %s)。" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:459 +msgid "" +"Optional: Specify an authentication key for the custom control server. Leave " +"blank if not required." +msgstr "可选:为自定义控制服务器指定一个认证密钥。如果不需要,请留空。" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:207 +msgid "Please use the login button in the settings below to authenticate." +msgstr "请使用下方设置中的登录按钮进行认证。" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:488 +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:494 +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:528 +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:563 +msgid "Please wait." +msgstr "请稍候。" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:224 +msgid "RUNNING" +msgstr "正在运行" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:254 +msgid "RX" +msgstr "接收" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:488 +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:494 +msgid "Requesting Login URL..." +msgstr "正在请求登录 URL..." + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:485 +msgid "Requesting Tailscale login URL... Please wait." +msgstr "正在请求 Tailscale 登录 URL... 请稍候。" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:376 +msgid "" +"Select an exit node from the list. If enabled, Allow LAN Access is enabled " +"implicitly." +msgstr "列表中选择出口节点。一旦开启,默认局域网之间可以互访" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:19 +msgid "" +"Select the firewall backend for Tailscale to use. Requires service restart " +"to take effect." +msgstr "选择 Tailscale 使用的防火墙后端。需要重启服务才能生效。" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:195 +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:203 +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:215 +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:224 +msgid "Service Status" +msgstr "服务状态" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:368 +msgid "Settings" +msgstr "设置" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:25 +msgid "Shields Up" +msgstr "开启防护 (Shields Up)" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:249 +msgid "Status" +msgstr "状态" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:532 +msgid "Successfully logged out." +msgstr "登出成功。" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:196 +msgid "TAILSCALE NOT FOUND" +msgstr "未找到 TAILSCALE" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:226 +msgid "TUN Mode" +msgstr "TUN 模式" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:255 +msgid "TX" +msgstr "发送" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:229 +msgid "Tailnet Name" +msgstr "Tailnet 名称" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:342 +#: applications/luci-app-tailscale-community/root/usr/share/luci/menu.d/luci-app-tailscale-community.json:3 +msgid "Tailscale" +msgstr "Tailscale" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:172 +msgid "Tailscale Health Check: %s" +msgstr "Tailscale 健康检查: %s" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:251 +msgid "Tailscale IP" +msgstr "Tailscale IP" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:227 +msgid "Tailscale IPv4" +msgstr "Tailscale IPv4" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:228 +msgid "Tailscale IPv6" +msgstr "Tailscale IPv6" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:484 +msgid "Tailscale Login" +msgstr "Tailscale 登录" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:342 +msgid "" +"Tailscale is a mesh VPN solution that makes it easy to connect your devices " +"securely. This configuration page allows you to manage Tailscale settings on " +"your OpenWrt device." +msgstr "" +"Tailscale 是一个网状 VPN 解决方案,可以轻松地安全连接您的设备。此配置页面允许" +"您在 OpenWrt 设备上管理 Tailscale 设置。" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:569 +msgid "Tailscale settings applied successfully." +msgstr "Tailscale 设置已成功应用。" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:486 +msgid "This can take up to 30 seconds." +msgstr "此过程最多可能需要 30 秒。" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:516 +msgid "" +"This will disconnect this device from your Tailnet and require you to re-" +"authenticate." +msgstr "这将使此设备从您的 Tailnet 断开连接,并需要您重新进行身份验证。" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:120 +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:147 +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:150 +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:509 +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:535 +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:574 +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:578 +msgid "Unknown error" +msgstr "未知错误" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:27 +msgid "Use system DNS instead of MagicDNS." +msgstr "使用系统 DNS 而不是 MagicDNS。" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:225 +msgid "Version" +msgstr "版本" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:25 +msgid "" +"When enabled, blocks all inbound connections from the Tailscale network." +msgstr "启用后,将阻止来自 Tailscale 网络的所有入站连接。" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:22 +msgid "When using the exit node, access to the local LAN is allowed." +msgstr "使用出口节点时,允许访问本地局域网。" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:435 +msgid "" +"[Important] Log in to the Tailscale admin console and manually enable " +"\"Subnet Routes\" for this device." +msgstr "【重要】 登录 Tailscale 控制平面,在设备设置中手动授权子网路由。" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:96 +msgid "ago" +msgstr "前" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:411 +msgid "" +"and enables Masquerading and MSS Clamping (MTU fix) to ensure stable " +"connections." +msgstr "并启用伪装和MSS钳制(MTU修复)功能,以确保连接稳定。" diff --git a/luci-app-tailscale-community/po/zh_Hant/community.po b/luci-app-tailscale-community/po/zh_Hant/community.po new file mode 100644 index 000000000..ce864eb7e --- /dev/null +++ b/luci-app-tailscale-community/po/zh_Hant/community.po @@ -0,0 +1,510 @@ +msgid "" +msgstr "Content-Type: text/plain; charset=UTF-8\n" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:34 +msgid "(Experimental) Reduce Memory Usage" +msgstr "(實驗性) 減少記憶體使用" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:432 +msgid "1. Select \"Accept Routes\" (to access remote devices)." +msgstr "1. 勾選 接受路由 (接受遠端裝置訪問)。" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:433 +msgid "" +"2. In \"Advertise Routes\", select your local subnet (to allow remote " +"devices to access this LAN)." +msgstr "2. 在 通告路由 選擇本裝置的區域網段 (讓遠端裝置能訪問你)。" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:434 +msgid "3. Click \"Auto Configure Firewall\" (to allow traffic forwarding)." +msgstr "3. 點選 自動配置防火牆 (打通防火牆轉發)。" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:20 +msgid "Accept Routes" +msgstr "接受路由" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:441 +msgid "Account Settings" +msgstr "賬戶設定" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:21 +msgid "Advertise Exit Node" +msgstr "通告出口節點" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:400 +msgid "Advertise Routes" +msgstr "通告路由" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:400 +msgid "" +"Advertise subnet routes behind this device. Select from the detected subnets " +"below or enter custom routes (comma-separated)." +msgstr "" +"通告此裝置後的子網路由。從下面的子網中選擇,或輸入自定義路由 (逗號分隔)。" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:22 +msgid "Allow LAN Access" +msgstr "允許區域網訪問" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:20 +msgid "Allow accepting routes announced by other nodes." +msgstr "允許接受由其他節點通告的路由。" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:26 +msgid "Allow connecting to this device through the SSH function of Tailscale." +msgstr "允許透過 Tailscale 的 SSH 功能連線到此裝置。" + +#: applications/luci-app-tailscale-community/root/usr/share/rpcd/acl.d/luci-app-tailscale-community.json:3 +msgid "Allow user access to tailscale" +msgstr "允許使用者訪問 Tailscale" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:563 +msgid "Applying changes..." +msgstr "正在應用更改..." + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:515 +msgid "Are you sure you want to log out?" +msgstr "您確定要登出嗎?" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:408 +msgid "Auto Configure Firewall" +msgstr "自動配置防火牆" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:522 +msgid "Cancel" +msgstr "取消" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:466 +msgid "Click to Log out account on this device." +msgstr "點選以登出此裝置上的賬戶。" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:445 +msgid "Click to get a login URL for this device." +msgstr "點選獲取此裝置的登入 URL。" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:166 +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:363 +msgid "Collecting data ..." +msgstr "正在收集資料..." + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:541 +msgid "Confirm Logout" +msgstr "確認登出" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:253 +msgid "Connection Info" +msgstr "連線資訊" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:478 +msgid "" +"Could not open a new tab. Please check if your browser or an extension " +"blocked the pop-up." +msgstr "無法開啟新標籤頁。請檢查您的瀏覽器或擴充套件程式是否阻止了彈出視窗。" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:450 +msgid "Custom Login Server" +msgstr "自定義登入伺服器" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:458 +msgid "Custom Login Server Auth Key" +msgstr "自定義登入伺服器認證金鑰" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:21 +msgid "Declare this device as an Exit Node." +msgstr "將此裝置宣告為出口節點。" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:27 +msgid "Disable MagicDNS" +msgstr "" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:24 +msgid "Disable SNAT" +msgstr "停用 SNAT" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:24 +msgid "" +"Disable Source NAT (SNAT) for traffic to advertised routes. Most users " +"should leave this unchecked." +msgstr "為通告路由的流量停用源地址轉換 (SNAT)。大多數使用者應保持此項不勾選。" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:226 +msgid "Disabled" +msgstr "已停用" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:467 +msgid "Disconnect from Tailscale and expire current node key." +msgstr "從 Tailscale 斷開連線並使當前節點金鑰過期。" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:26 +msgid "Enable Tailscale SSH" +msgstr "啟用 Tailscale SSH" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:23 +msgid "Enable Web Interface" +msgstr "啟用 Web 介面" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:226 +msgid "Enabled" +msgstr "已啟用" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:34 +msgid "" +"Enabling this option can reduce memory usage, but it may sacrifice some " +"performance (set GOGC=10)." +msgstr "啟用此選項可以減少記憶體使用,但可能會犧牲一些效能 (設定 GOGC=10)。" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:503 +msgid "Error" +msgstr "錯誤" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:578 +msgid "Error applying settings: %s" +msgstr "應用設定時出錯: %s" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:147 +msgid "Error caching DERP region map: %s" +msgstr "快取 DERP 區域地圖時出錯: %s" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:150 +msgid "Error fetching DERP region map: %s" +msgstr "獲取 DERP 區域地圖時出錯: %s" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:120 +msgid "Error reading cached DERP region map: %s" +msgstr "讀取快取的 DERP 區域地圖時出錯: %s" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:574 +msgid "Error saving settings: %s" +msgstr "儲存設定時出錯: %s" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:409 +msgid "" +"Essential configuration for Subnet Routing (Site-to-Site) and Exit Node " +"features." +msgstr "子網路由和出口節點的基本配置。" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:278 +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:376 +msgid "Exit Node" +msgstr "出口節點" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:23 +msgid "" +"Expose a web interface on port 5252 for managing this node over Tailscale." +msgstr "在埠 5252 上暴露一個 Web 介面,用於透過 Tailscale 管理此節點。" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:420 +msgid "Failed to configure firewall: %s" +msgstr "獲取防火牆設定失敗: %s" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:504 +msgid "Failed to get login URL. You may close this tab." +msgstr "獲取登入 URL 失敗。您可以關閉此標籤頁。" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:509 +msgid "Failed to get login URL: %s" +msgstr "獲取登入 URL 失敗: %s" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:505 +msgid "Failed to get login URL: Invalid response from server." +msgstr "獲取登入 URL 失敗: 伺服器響應無效。" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:584 +msgid "Failed to save settings: %s" +msgstr "儲存設定失敗: %s" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:19 +msgid "Firewall Mode" +msgstr "防火牆模式" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:417 +msgid "Firewall configuration applied." +msgstr "已應用防火牆配置" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:372 +msgid "General Settings" +msgstr "常規設定" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:250 +msgid "Hostname" +msgstr "主機名" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:427 +msgid "How to enable Site-to-Site?" +msgstr "" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:446 +msgid "" +"If the timeout is displayed, you can refresh the page and click Login again." +msgstr "如果顯示超時,您可以重新整理頁面並再次點選登入。" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:460 +msgid "" +"If you are using custom login server but not providing an Auth Key, will " +"redirect to the login page without pre-filling the key." +msgstr "" +"如果您使用自定義登入伺服器但未提供認證金鑰,將重定向到登入頁面而不會預先填充" +"金鑰。" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:90 +msgid "Invalid Date" +msgstr "無效日期" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:410 +msgid "" +"It automatically creates the tailscale interface, sets up firewall zones for " +"LAN <-> Tailscale forwarding," +msgstr "" +"子網路由和出口節點的基本配置。會自動tailscale介面,設定防火牆區域LAN <-> " +"Tailscale轉發" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:93 +msgid "Just now" +msgstr "剛才" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:205 +msgid "LOGGED OUT" +msgstr "已登出" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:256 +msgid "Last Seen" +msgstr "上次線上" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:452 +msgid "Leave blank for default Tailscale control plane." +msgstr "留空以使用預設的 Tailscale 控制平面。" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:528 +msgid "Logging out..." +msgstr "正在登出..." + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:444 +msgid "Login" +msgstr "登入" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:465 +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:538 +msgid "Logout" +msgstr "登出" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:535 +msgid "Logout failed: %s" +msgstr "登出失敗: %s" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:87 +msgid "N/A" +msgstr "N/A" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:216 +msgid "NOT RUNNING" +msgstr "未執行" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:298 +msgid "Network Devices" +msgstr "網路裝置" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:245 +msgid "No peer devices found." +msgstr "未找到對等裝置。" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:377 +msgid "None" +msgstr "" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:88 +msgid "Now" +msgstr "現在" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:252 +msgid "OS" +msgstr "作業系統" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:278 +msgid "Offline" +msgstr "離線" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:278 +msgid "Online" +msgstr "線上" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:451 +msgid "" +"Optional: Specify a custom control server URL (e.g., a Headscale instance, " +"%s)." +msgstr "" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:459 +msgid "" +"Optional: Specify an authentication key for the custom control server. Leave " +"blank if not required." +msgstr "可選:為自定義控制伺服器指定一個認證金鑰。如果不需要,請留空。" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:207 +msgid "Please use the login button in the settings below to authenticate." +msgstr "請使用下方設定中的登入按鈕進行認證。" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:488 +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:494 +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:528 +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:563 +msgid "Please wait." +msgstr "請稍候。" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:224 +msgid "RUNNING" +msgstr "正在執行" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:254 +msgid "RX" +msgstr "接收" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:488 +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:494 +msgid "Requesting Login URL..." +msgstr "正在請求登入 URL..." + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:485 +msgid "Requesting Tailscale login URL... Please wait." +msgstr "正在請求 Tailscale 登入 URL... 請稍候。" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:376 +msgid "" +"Select an exit node from the list. If enabled, Allow LAN Access is enabled " +"implicitly." +msgstr "" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:19 +msgid "" +"Select the firewall backend for Tailscale to use. Requires service restart " +"to take effect." +msgstr "選擇 Tailscale 使用的防火牆後端。需要重啟服務才能生效。" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:195 +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:203 +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:215 +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:224 +msgid "Service Status" +msgstr "服務狀態" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:368 +msgid "Settings" +msgstr "設定" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:25 +msgid "Shields Up" +msgstr "開啟防護 (Shields Up)" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:249 +msgid "Status" +msgstr "狀態" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:532 +msgid "Successfully logged out." +msgstr "登出成功。" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:196 +msgid "TAILSCALE NOT FOUND" +msgstr "未找到 TAILSCALE" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:226 +msgid "TUN Mode" +msgstr "TUN 模式" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:255 +msgid "TX" +msgstr "傳送" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:229 +msgid "Tailnet Name" +msgstr "Tailnet 名稱" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:342 +#: applications/luci-app-tailscale-community/root/usr/share/luci/menu.d/luci-app-tailscale-community.json:3 +msgid "Tailscale" +msgstr "Tailscale" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:172 +msgid "Tailscale Health Check: %s" +msgstr "Tailscale 健康檢查: %s" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:251 +msgid "Tailscale IP" +msgstr "Tailscale IP" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:227 +msgid "Tailscale IPv4" +msgstr "Tailscale IPv4" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:228 +msgid "Tailscale IPv6" +msgstr "Tailscale IPv6" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:484 +msgid "Tailscale Login" +msgstr "Tailscale 登入" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:342 +msgid "" +"Tailscale is a mesh VPN solution that makes it easy to connect your devices " +"securely. This configuration page allows you to manage Tailscale settings on " +"your OpenWrt device." +msgstr "" +"Tailscale 是一個網狀 VPN 解決方案,可以輕鬆地安全連線您的裝置。此配置頁面允許" +"您在 OpenWrt 裝置上管理 Tailscale 設定。" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:569 +msgid "Tailscale settings applied successfully." +msgstr "Tailscale 設定已成功應用。" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:486 +msgid "This can take up to 30 seconds." +msgstr "此過程最多可能需要 30 秒。" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:516 +msgid "" +"This will disconnect this device from your Tailnet and require you to re-" +"authenticate." +msgstr "這將使此裝置從您的 Tailnet 斷開連線,並需要您重新進行身份驗證。" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:120 +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:147 +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:150 +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:509 +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:535 +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:574 +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:578 +msgid "Unknown error" +msgstr "未知錯誤" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:27 +msgid "Use system DNS instead of MagicDNS." +msgstr "" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:225 +msgid "Version" +msgstr "版本" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:25 +msgid "" +"When enabled, blocks all inbound connections from the Tailscale network." +msgstr "啟用後,將阻止來自 Tailscale 網路的所有入站連線。" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:22 +msgid "When using the exit node, access to the local LAN is allowed." +msgstr "使用出口節點時,允許訪問本地區域網。" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:435 +msgid "" +"[Important] Log in to the Tailscale admin console and manually enable " +"\"Subnet Routes\" for this device." +msgstr "【重要】 登入 Tailscale 控制平面,在裝置設定中手動授權子網路由。" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:96 +msgid "ago" +msgstr "前" + +#: applications/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js:411 +msgid "" +"and enables Masquerading and MSS Clamping (MTU fix) to ensure stable " +"connections." +msgstr "並啟用地址偽裝和 MSS鉗制確保連線穩定。" diff --git a/luci-app-tailscale-community/root/usr/share/luci/menu.d/luci-app-tailscale-community.json b/luci-app-tailscale-community/root/usr/share/luci/menu.d/luci-app-tailscale-community.json new file mode 100644 index 000000000..70c90695c --- /dev/null +++ b/luci-app-tailscale-community/root/usr/share/luci/menu.d/luci-app-tailscale-community.json @@ -0,0 +1,10 @@ +{ + "admin/services/tailscale": { + "title": "Tailscale", + "order": 90, + "action": { + "type": "view", + "path": "tailscale" + } + } +} diff --git a/luci-app-tailscale-community/root/usr/share/rpcd/acl.d/luci-app-tailscale-community.json b/luci-app-tailscale-community/root/usr/share/rpcd/acl.d/luci-app-tailscale-community.json new file mode 100644 index 000000000..f80185ff1 --- /dev/null +++ b/luci-app-tailscale-community/root/usr/share/rpcd/acl.d/luci-app-tailscale-community.json @@ -0,0 +1,26 @@ +{ + "luci-app-tailscale-community": { + "description": "Allow user access to tailscale", + "read": { + "ubus": { + "tailscale": [ + "get_status", + "get_settings", + "get_subroutes" + ] + }, + "uci": [ "tailscale" ] + }, + "write": { + "ubus": { + "tailscale": [ + "do_login", + "do_logout", + "setup_firewall", + "set_settings" + ] + }, + "uci": [ "tailscale" ] + } + } +} diff --git a/luci-app-tailscale-community/root/usr/share/rpcd/ucode/tailscale.uc b/luci-app-tailscale-community/root/usr/share/rpcd/ucode/tailscale.uc new file mode 100644 index 000000000..2a30bdbfb --- /dev/null +++ b/luci-app-tailscale-community/root/usr/share/rpcd/ucode/tailscale.uc @@ -0,0 +1,393 @@ +#!/usr/bin/env ucode + +'use strict'; + +import { access, popen, readfile, writefile, unlink } from 'fs'; +import { cursor } from 'uci'; + +const uci = cursor(); + +function exec(command) { + let stdout_content = ''; + let p = popen(command, 'r'); + sleep(100); + if (p == null) { + return { code: -1, stdout: '', stderr: `Failed to execute: ${command}` }; + } + for (let line = p.read('line'); length(line); line = p.read('line')) { + stdout_content = stdout_content+line; + } + stdout_content = rtrim(stdout_content); + stdout_content = split(stdout_content, '\n'); + + let exit_code = p.close(); + let stderr_content = ''; + if (exit_code != 0) { + stderr_content = stdout_content; + } + return { code: exit_code, stdout: stdout_content, stderr: stderr_content }; +} + +function shell_quote(s) { + if (s == null || s == '') return "''"; + return "'" + replace(s, "'", "'\\''") + "'"; +} + +const methods = {}; + +methods.get_status = { + call: function() { + let data = { + status: '', + version: '', + TUNMode: '', + health: '', + ipv4: "Not running", + ipv6: null, + domain_name: '', + peers: [] + }; + if (access('/usr/sbin/tailscale')==true || access('/usr/bin/tailscale')==true){ }else{ + data.status = 'not_installed'; + return data; + } + + let status_json_output = exec('tailscale status --json'); + let peer_map = {}; + if (status_json_output.code == 0 && length(status_json_output.stdout) > 0) { + try { + let status_data = json(join('',status_json_output.stdout)); + data.version = status_data?.Version || 'Unknown'; + data.health = status_data?.Health || ''; + data.TUNMode = status_data?.TUN || 'true'; + if (status_data?.BackendState == 'Running') { data.status = 'running'; } + if (status_data?.BackendState == 'NeedsLogin') { data.status = 'logout'; } + + data.ipv4 = status_data?.Self?.TailscaleIPs?.[0] || 'No IP assigned'; + data.ipv6 = status_data?.Self?.TailscaleIPs?.[1] || null; + data.domain_name = status_data?.CurrentTailnet?.Name || ''; + + // peers list + for (let p in status_data?.Peer) { + p = status_data.Peer[p]; + peer_map[p.ID] = { + ip: join('
', p?.TailscaleIPs) || '', + hostname: split(p?.DNSName || '','.')[0] || '', + ostype: p?.OS, + online: p?.Online, + linkadress: (!p?.CurAddr) ? p?.Relay : p?.CurAddr, + lastseen: p?.LastSeen, + exit_node: !!p?.ExitNode, + exit_node_option: !!p?.ExitNodeOption, + tx: p?.TxBytes || '', + rx: p?.RxBytes || '' + }; + } + } catch (e) { /* ignore */ } + } + + data.peers = peer_map; + return data; + } +}; + +methods.get_settings = { + call: function() { + let settings = {}; + uci.load('tailscale'); + let state_file_path = uci.get('tailscale', 'settings', 'state_file') || "/etc/tailscale/tailscaled.state"; + if (access(state_file_path)) { + try { + let state_content = readfile(state_file_path); + if (state_content != null) { + let state_data = json(state_content); + let profiles_b64 = state_data?._profiles; + if (!profiles_b64) return settings; + + let profiles_data = json(b64dec(profiles_b64)); + let profiles_key = null; + for (let key in profiles_data) { + profiles_key = key; + break; + } + profiles_key = 'profile-'+profiles_key; + + let status_data = json(b64dec(state_data?.[profiles_key])); + if (status_data != null) { + settings.accept_routes = status_data?.RouteAll || false; + settings.advertise_exit_node = status_data?.AdvertiseExitNode || false; + settings.advertise_routes = status_data?.AdvertiseRoutes || []; + settings.exit_node = status_data?.ExitNodeID || ""; + settings.exit_node_allow_lan_access = status_data?.ExitNodeAllowLANAccess || false; + settings.shields_up = status_data?.ShieldsUp || false; + settings.ssh = status_data?.RunSSH || false; + settings.runwebclient = status_data?.RunWebClient || false; + settings.nosnat = status_data?.NoSNAT || false; + settings.disable_magic_dns = !status_data?.CorpDNS || false; + settings.fw_mode = split(uci.get('tailscale', 'settings', 'fw_mode'),' ')[0] || 'nftables'; + } + } + } catch (e) { /* ignore */ } + } + return settings; + } +}; + +methods.set_settings = { + args: { form_data: {} }, + call: function(request) { + const form_data = request.args.form_data; + if (form_data == null || length(form_data) == 0) { + return { error: 'Missing or invalid form_data parameter. Please provide settings data.' }; + } + let args = ['set']; + + push(args,'--accept-routes=' + (form_data.accept_routes == '1')); + push(args,'--advertise-exit-node=' + ((form_data.advertise_exit_node == '1')&&(form_data.exit_node == ""))); + if (form_data.exit_node == "") push(args,'--exit-node-allow-lan-access=' + (form_data.exit_node_allow_lan_access == '1')); + push(args,'--ssh=' + (form_data.ssh == '1')); + push(args,'--accept-dns=' + (form_data.disable_magic_dns != '1')); + push(args,'--shields-up=' + (form_data.shields_up == '1')); + push(args,'--webclient=' + (form_data.runwebclient == '1')); + push(args,'--snat-subnet-routes=' + (form_data.nosnat != '1')); + push(args,'--advertise-routes ' + (shell_quote(join(',',form_data.advertise_routes)) || '\"\"')); + push(args,'--exit-node=' + (shell_quote(form_data.exit_node) || '\"\"')); + if (form_data.exit_node != "") push(args,' --exit-node-allow-lan-access=true'); + push(args,'--hostname ' + (shell_quote(form_data.hostname) || '\"\"')); + + let cmd_array = 'tailscale '+join(' ', args); + let set_result = exec(cmd_array); + if (set_result.code != 0) { + return { error: 'Failed to apply node settings: ' + set_result.stderr }; + } + + uci.load('tailscale'); + for (let key in form_data) { + uci.set('tailscale', 'settings', key, form_data[key]); + } + uci.save('tailscale'); + uci.commit('tailscale'); + + // process reduce memory https://github.com/GuNanOvO/openwrt-tailscale + // some new versions of Tailscale may not work well with this method + //if (form_data.daemon_mtu != "" || form_data.daemon_reduce_memory != "") { + // popen('/bin/sh -c ". ' + env_script_path + ' && /etc/init.d/tailscale restart" &'); + //} + return { success: true }; + } +}; + +methods.do_login = { + args: { form_data: {} }, + call: function(request) { + const form_data = request.args.form_data; + let loginargs = []; + if (form_data == null || length(form_data) == 0) { + return { error: 'Missing or invalid form_data parameter. Please provide login data.' }; + } + + let status=methods.get_status.call(); + if (status.status != 'logout') { + return { error: 'Tailscale is already logged in and running.' }; + } + + // --- 1. Prepare and Run Login Command (Once) --- + const loginserver = trim(form_data.loginserver) || ''; + const loginserver_authkey = trim(form_data.loginserver_authkey) || ''; + + if (loginserver!='') { + push(loginargs,'--login-server '+shell_quote(loginserver)); + if (loginserver_authkey!='') { + push(loginargs,'--auth-key '+shell_quote(loginserver_authkey)); + } + } + + // Run the command in the background using /bin/sh -c to handle the '&' correctly + let login_cmd = 'tailscale login '+join(' ', loginargs); + popen('/bin/sh -c ' + shell_quote(login_cmd + ' &'), 'r'); + + // --- 2. Loop to Check Status for URL --- + let max_attempts = 15; + let interval = 2000; + + for (let i = 0; i < max_attempts; i++) { + let tresult = exec('tailscale status'); + for (let line in tresult.stdout) { + let trline = trim(line); + if (index(trline, 'http') != -1) { + let parts = split(trline, ' '); + for (let part in parts) { + if (index(part, 'http') != -1) { + return { url: part }; + } + } + } + } + sleep(interval); + } + return { error: 'Could not retrieve login URL from tailscale command after 30 seconds.' }; + } +}; + +methods.do_logout = { + call: function() { + let status=methods.get_status.call(); + if (status.status != 'running') { + return { error: 'Tailscale is not running. Cannot perform logout.' }; + } + + let logout_result = exec('tailscale logout'); + if (logout_result.code != 0) { + return { error: 'Failed to logout: ' + logout_result.stderr }; + } + return { success: true }; + } +}; + +methods.get_subroutes = { + call: function() { + try { + let cmd = 'ip -j route'; + let result = exec(cmd); + let subnets = []; + + if (result.code == 0 && length(result.stdout) > 0) { + let routes_json = json(join('',result.stdout)); + + for (let route in routes_json) { + // We need to filter out local subnets + // 1. 'dst' (target address) is not' default' (default gateway) + // 2. 'scope' is' link' (indicating directly connected network) + // 3. It is an IPv4 address (simple judgment: including'.') + if (route?.dst && route.dst != 'default' && route?.scope == 'link' && index(route.dst,'.') != -1) { + push(subnets,route.dst); + } + } + } + return { routes: subnets }; + } + catch(e) { + return { routes: [] }; + } + } +}; + +methods.setup_firewall = { + call: function() { + try { + uci.load('network'); + uci.load('firewall'); + + let changed_network = false; + let changed_firewall = false; + + // 1. config Network Interface + let net_ts = uci.get('network', 'tailscale'); + if (net_ts == null) { + uci.set('network', 'tailscale', 'interface'); + uci.set('network', 'tailscale', 'proto', 'none'); + uci.set('network', 'tailscale', 'device', 'tailscale0'); + changed_network = true; + } else { + let current_dev = uci.get('network', 'tailscale', 'device'); + if (current_dev != 'tailscale0') { + uci.set('network', 'tailscale', 'device', 'tailscale0'); + changed_network = true; + } + } + + // 2. config Firewall Zone + let fw_all = uci.get_all('firewall'); + let ts_zone_section = null; + let fwd_lan_to_ts = false; + let fwd_ts_to_lan = false; + + for (let sec_key in fw_all) { + let s = fw_all[sec_key]; + if (s['.type'] == 'zone' && s['name'] == 'tailscale') { + ts_zone_section = sec_key; + } + if (s['.type'] == 'forwarding') { + if (s.src == 'lan' && s.dest == 'tailscale') fwd_lan_to_ts = true; + if (s.src == 'tailscale' && s.dest == 'lan') fwd_ts_to_lan = true; + } + } + + if (ts_zone_section == null) { + let zid = uci.add('firewall', 'zone'); + uci.set('firewall', zid, 'name', 'tailscale'); + uci.set('firewall', zid, 'input', 'ACCEPT'); + uci.set('firewall', zid, 'output', 'ACCEPT'); + uci.set('firewall', zid, 'forward', 'ACCEPT'); + uci.set('firewall', zid, 'masq', '1'); + uci.set('firewall', zid, 'mtu_fix', '1'); + uci.set('firewall', zid, 'network', ['tailscale']); + changed_firewall = true; + } else { + let nets = uci.get('firewall', ts_zone_section, 'network'); + let net_list = []; + let has_ts_net = false; + + if (type(nets) == 'array') { + net_list = nets; + } else if (type(nets) == 'string') { + net_list = [nets]; + } + + // check if 'tailscale' is already in the list + for (let n in net_list) { + if (net_list[n] == 'tailscale') { + has_ts_net = true; + break; + } + } + + if (!has_ts_net) { + push(net_list, 'tailscale'); + uci.set('firewall', ts_zone_section, 'network', net_list); + changed_firewall = true; + } + } + + // 3. config Forwarding + if (!fwd_lan_to_ts) { + let fid = uci.add('firewall', 'forwarding'); + uci.set('firewall', fid, 'src', 'lan'); + uci.set('firewall', fid, 'dest', 'tailscale'); + changed_firewall = true; + } + + if (!fwd_ts_to_lan) { + let fid = uci.add('firewall', 'forwarding'); + uci.set('firewall', fid, 'src', 'tailscale'); + uci.set('firewall', fid, 'dest', 'lan'); + changed_firewall = true; + } + + // 4. save + if (changed_network) { + uci.save('network'); + uci.commit('network'); + exec('/etc/init.d/network reload'); + } + + if (changed_firewall) { + uci.save('firewall'); + uci.commit('firewall'); + exec('/etc/init.d/firewall reload'); + } + + return { + success: true, + changed_network: changed_network, + changed_firewall: changed_firewall, + message: (changed_network || changed_firewall) ? 'Tailscale firewall/interface configuration applied.' : 'Tailscale firewall/interface already configured.' + }; + + } catch (e) { + return { error: 'Exception in setup_firewall: ' + e + '\nStack: ' + (e.stacktrace || '') }; + } + } +}; + +return { 'tailscale': methods }; diff --git a/luci-app-tailscale-next/Makefile b/luci-app-tailscale-next/Makefile new file mode 100644 index 000000000..dab84c68f --- /dev/null +++ b/luci-app-tailscale-next/Makefile @@ -0,0 +1,30 @@ +include $(TOPDIR)/rules.mk + +LUCI_TITLE:=LuCI support for Tailscale Next +LUCI_URL:=https://github.com/tokisaki-galaxy/luci-app-tailscale-community +PKG_DESCRIPTION:=Provides a LuCI Web management interface for Tailscale, allowing viewing status, configuring nodes and daemons. +PKG_MAINTAINER:=Tokisaki-Galaxy +LUCI_DEPENDS:=+tailscale-next +ip-full +luci-base +ucode +ucode-mod-fs +ucode-mod-uci +LUCI_PKGARCH:=all + +define Package/luci-app-tailscale-next/postinst +#!/bin/sh +[ -n "$${IPKG_INSTROOT}" ] || { + rm -f /tmp/luci-indexcache.* /tmp/luci-modulecache/* 2>/dev/null + chmod 0755 /etc/init.d/tailscale-settings /etc/hotplug.d/iface/99-tailscale-routes 2>/dev/null || true + /etc/init.d/rpcd restart 2>/dev/null || true +} +exit 0 +endef + +define Package/luci-app-tailscale-next/prerm +#!/bin/sh +[ -n "$${IPKG_INSTROOT}" ] || { + /etc/init.d/tailscale-settings clean_firewall 2>/dev/null || true +} +exit 0 +endef + +include $(TOPDIR)/feeds/luci/luci.mk + +# call BuildPackage - OpenWrt buildroot signature diff --git a/luci-app-tailscale-next/htdocs/luci-static/resources/view/tailscale.js b/luci-app-tailscale-next/htdocs/luci-static/resources/view/tailscale.js new file mode 100644 index 000000000..2277d00b7 --- /dev/null +++ b/luci-app-tailscale-next/htdocs/luci-static/resources/view/tailscale.js @@ -0,0 +1,667 @@ +'use strict'; +'require view'; +'require form'; +'require rpc'; +'require ui'; +'require uci'; +'require tools.widgets as widgets'; + +const callGetStatus = rpc.declare({ object: 'tailscale', method: 'get_status' }); +const callGetSettings = rpc.declare({ object: 'tailscale', method: 'get_settings' }); +const callDoLogin = rpc.declare({ object: 'tailscale', method: 'do_login', params: ['form_data'] }); +const callDoLogout = rpc.declare({ object: 'tailscale', method: 'do_logout' }); +const callGetSubroutes = rpc.declare({ object: 'tailscale', method: 'get_subroutes' }); +const callGetLogs = rpc.declare({ object: 'tailscale', method: 'get_logs' }); +const callReloadSettings = rpc.declare({ object: 'tailscale', method: 'reload_settings' }); +const callRestart = rpc.declare({ object: 'tailscale', method: 'restart' }); +let map; + +const tailscaleSettingsConf = [ + [form.Flag, 'service_enabled', _('Enable Tailscale Service'), _('Enable or disable the Tailscale background service (/etc/init.d/tailscale).'), { rmempty: false }], + [form.Flag, 'runwebclient', _('Enable Web Interface'), _('Expose a local web interface on port 5252 for managing this node over Tailscale (--webclient).'), { rmempty: false }], + [form.Flag, 'tunnel_snat', _('Enable Tunnel Traffic SNAT'), _('Use OpenWrt system firewall to manage NAT for Tailscale tunnel and subnet traffic. Disabling this option disables source address masquerading to preserve real source IPs of remote peers and subnets in the local network.'), { default: '0', rmempty: false }], + [form.Flag, 'accept_routes', _('Accept Routes'), _('Allow accepting subnet routes announced by other nodes (--accept-routes).'), { rmempty: false }], + [form.Flag, 'advertise_exit_node', _('Advertise Exit Node'), _('Declare this device as an exit node, allowing other nodes to route all traffic through it (--advertise-exit-node).'), { rmempty: false }], + [form.Flag, 'exit_node_allow_lan_access', _('Allow LAN Access'), _('When using or advertising the exit node, allow access to the local LAN (--exit-node-allow-lan-access).'), { rmempty: false, depends: { 'advertise_exit_node': '1' } }], + [form.Flag, 'ssh', _('Enable Tailscale SSH'), _('Allow connecting to this device through the native SSH function of Tailscale (--ssh).'), { rmempty: false }], + [form.Flag, 'shields_up', _('Shields Up'), _('When enabled, blocks all inbound connections from the Tailscale network (--shields-up).'), { rmempty: false }], + [form.ListValue, 'dns_mode', _('DNS Mode'), _('Controls how Tailscale DNS is handled (--accept-dns).') + '
' + _('Disabled: system DNS only.') + '
' + _('MagicDNS: Tailscale overrides resolv.conf.') + '
' + _('OpenWrt Forward: MagicDNS via dnsmasq forwarding.(Only support ts.net)'), { values: [['disabled', _('Disabled')], ['magicdns', 'MagicDNS'], ['openwrt_forward', _('OpenWrt Forward')]], rmempty: false }], + [form.Flag, 'enable_relay', _('Enable Peer Relay'), _('Enable this device as a Peer Relay server (--peer-relay). Requires a public IP and an UDP port open on the router.'), { rmempty: false }] +]; + +const daemonConf = [ + [form.Flag, 'daemon_reduce_memory', _('(Experimental) Reduce Memory Usage'), _('Enabling this option can reduce memory usage, but it may sacrifice some performance (set GOGC=10).'), { rmempty: false }] +]; + +// this function copy from luci-app-frpc. thx +function setParams(o, params) { + if (!params) return; + + for (const [key, val] of Object.entries(params)) { + if (key === 'values') { + [].concat(val).forEach(v => + o.value.apply(o, Array.isArray(v) ? v : [v]) + ); + } else if (key === 'depends') { + const arr = Array.isArray(val) ? val : [val]; + o.deps = arr.map(dep => Object.assign({}, ...o.deps, dep)); + } else { + o[key] = val; + } + } + + if (params.datatype === 'bool') + Object.assign(o, { enabled: 'true', disabled: 'false' }); +} + +// this function copy from luci-app-frpc. thx +function defTabOpts(s, t, opts, params) { + for (let i = 0; i < opts.length; i++) { + const opt = opts[i]; + const o = s.taboption(t, opt[0], opt[1], opt[2], opt[3]); + setParams(o, opt[4]); + setParams(o, params); + } +} + +function getRunningStatus() { + return L.resolveDefault(callGetStatus(), { running: false }).then(function (res) { + return res; + }); +} + +function formatBytes(bytes) { + const bytes_num = parseInt(bytes, 10); + if (isNaN(bytes_num) || bytes_num === 0) return '-'; + const k = 1000; + const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB']; + const i = Math.floor(Math.log(bytes_num) / Math.log(k)); + return parseFloat((bytes_num / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; +} + +function formatLastSeen(d) { + if (!d) return _('N/A'); + if (d === '0001-01-01T00:00:00Z') return _('Now'); + const t = new Date(d); + if (isNaN(t)) return _('Invalid Date'); + const diff = (Date.now() - t) / 1000; + if (diff < 0) return t.toLocaleString(); + if (diff < 60) return _('Just now'); + + const mins = diff / 60, hrs = mins / 60, days = hrs / 24; + const fmt = (n, s, p) => `${Math.floor(n)} ${Math.floor(n) === 1 ? _(s) : _(p)} ${_('ago')}`; + + if (mins < 60) return fmt(mins, 'minute', 'minutes'); + if (hrs < 24) return fmt(hrs, 'hour', 'hours'); + if (days < 30) return fmt(days, 'day', 'days'); + + return t.toISOString().slice(0, 10); +} + +const defaultDerpRegions = { + "1": "NYC", "nyc": "NYC", + "2": "SFO", "sfo": "SFO", + "3": "SIN", "sin": "SIN", + "4": "FRA", "fra": "FRA", + "5": "SYD", "syd": "SYD", + "6": "BLR", "blr": "BLR", + "7": "NRT", "nrt": "NRT", + "8": "LHR", "lhr": "LHR", + "9": "DFW", "dfw": "DFW", + "10": "SEA", "sea": "SEA", + "11": "MIA", "mia": "MIA", + "12": "ORD", "ord": "ORD", + "13": "MAD", "mad": "MAD", + "14": "AMS", "ams": "AMS", + "15": "WAW", "waw": "WAW", + "16": "YUL", "yul": "YUL", + "17": "LAX", "lax": "LAX", + "18": "SCL", "scl": "SCL", + "19": "DXB", "dxb": "DXB", + "20": "DEN", "den": "DEN", + "21": "JNB", "jnb": "JNB", + "22": "GRU", "gru": "GRU", + "23": "DUB", "dub": "DUB", + "24": "MAN", "man": "MAN", + "25": "HND", "hnd": "HND", + "26": "YYZ", "yyz": "YYZ" +}; +let regionCodeMap = Object.assign({}, defaultDerpRegions); + +function getRegionName(idOrCode) { + if (!idOrCode) return ''; + return regionCodeMap[idOrCode] || regionCodeMap[String(idOrCode).toLowerCase()] || ''; +} + +function formatConnectionInfo(info) { + if (!info) { return '-'; } + if (typeof info === 'string' && info.length === 3) { + const lowerCaseInfo = info.toLowerCase(); + return regionCodeMap[lowerCaseInfo] || info; + } + return info; +} + +function handleLogin() { + const customServerInput = document.getElementById('widget.cbid.tailscale.settings.custom_login_url'); + const customServer = (customServerInput ? customServerInput.value : '') || uci.get('tailscale', 'settings', 'custom_login_url') || ''; + const customServerAuthInput = document.getElementById('widget.cbid.tailscale.settings.custom_login_AuthKey'); + const customServerAuth = (customServerAuthInput ? customServerAuthInput.value : '') || uci.get('tailscale', 'settings', 'custom_login_AuthKey') || ''; + + const loginWindow = window.open('', '_blank'); + if (!loginWindow) { + ui.addTimeLimitedNotification(null, [ E('p', _('Could not open a new tab. Please check if your browser or an extension blocked the pop-up.')) ], 10000, 'error'); + return; + } + + const doc = loginWindow.document; + doc.body.innerHTML = + '

' + _('Tailscale Login') + '

' + + '

' + _('Requesting Tailscale login URL... Please wait.') + '

' + + '

' + _('This can take up to 30 seconds.') + '

'; + + ui.showModal(_('Requesting Login URL...'), E('em', {}, _('Please wait.'))); + const payload = { + loginserver: customServer, + loginserver_authkey: customServerAuth + }; + + return callDoLogin(payload).then(function(res) { + ui.hideModal(); + if (res && res.url) { + loginWindow.location.href = res.url; + } else { + doc.body.innerHTML = + '

' + _('Error') + '

' + + '

' + _('Failed to get login URL. You may close this tab.') + '

'; + ui.addTimeLimitedNotification(null, [ E('p', _('Failed to get login URL: Invalid response from server.')) ], 7000, 'error'); + } + }).catch(function(err) { + ui.hideModal(); + ui.addTimeLimitedNotification(null, [ E('p', _('Failed to get login URL: %s').format(err.message || _('Unknown error'))) ], 7000, 'error'); + }); +} + +function handleLogout() { + const confirmationContent = E([ + E('p', {}, _('Are you sure you want to log out?') + '
' + _('This will disconnect this device from your Tailnet and require you to re-authenticate.')), + E('div', { 'style': 'text-align: right; margin-top: 1em;' }, [ + E('button', { + 'class': 'cbi-button', + 'click': ui.hideModal + }, _('Cancel')), + ' ', + E('button', { + 'class': 'cbi-button cbi-button-negative', + 'click': function() { + ui.hideModal(); + ui.showModal(_('Logging out...'), E('em', {}, _('Please wait.'))); + + return callDoLogout().then(function(res) { + ui.hideModal(); + ui.addTimeLimitedNotification(null, [ E('p', _('Successfully logged out.')) ], 3000, 'info'); + window.location.reload(); + }).catch(function(err) { + ui.hideModal(); + ui.addTimeLimitedNotification(null, [ E('p', _('Logout failed: %s').format(err.message || _('Unknown error'))) ], 7000, 'error'); + }); + } + }, _('Logout')) + ]) + ]); + ui.showModal(_('Confirm Logout'), confirmationContent); +} + +function handleRestart() { + ui.showModal(_('Restart Service'), [ + E('p', {}, _('Are you sure you want to restart Tailscale service?')), + E('div', { 'class': 'right', 'style': 'margin-top: 15px;' }, [ + E('button', { + 'class': 'cbi-button cbi-button-neutral', + 'click': ui.hideModal + }, _('Cancel')), + ' ', + E('button', { + 'class': 'cbi-button cbi-button-apply', + 'click': function(ev) { + ui.showModal(_('Restarting Tailscale...'), [ + E('p', { 'class': 'spinning' }, _('Please wait while the service is restarting...')) + ]); + return L.resolveDefault(callRestart(), {}).then(function() { + return new Promise(resolve => setTimeout(resolve, 2000)); + }).then(function() { + ui.hideModal(); + window.location.reload(); + }).catch(function(e) { + ui.addNotification(null, E('p', {}, _('Failed to restart: %s').format(e.message || e)), 'error'); + ui.hideModal(); + }); + } + }, _('Restart')) + ]) + ]); +} + +function renderStatus(status) { + // If status object is not yet available, show a loading message. + if (!status || !status.hasOwnProperty('status')) { + return E('em', {}, _('Collecting data ...')); + } + + const customServerUrl = uci.get('tailscale', 'settings', 'custom_login_url'); + let serverDisplayText = _('Official Server (Tailscale SaaS)'); + if (customServerUrl && customServerUrl !== '') { + serverDisplayText = _('Custom Server (%s)').format(customServerUrl.replace(/^https?:\/\//, '')); + } + + let statusBadge; + let actionButtons = []; + + if (status.status === 'not_installed') { + statusBadge = E('span', { 'style': 'color:red;' }, E('strong', {}, _('NOT INSTALLED'))); + } else if (status.status === 'logout') { + statusBadge = E('span', { 'style': 'color:red;' }, E('strong', {}, _('NOT LOGGED IN'))); + actionButtons.push(E('button', { + 'class': 'cbi-button cbi-button-action', + 'style': 'margin-left: 10px; padding: 2px 10px;', + 'click': ui.createHandlerFn(this, handleLogin) + }, _('Login'))); + } else if (status.status === 'running') { + statusBadge = E('span', { 'style': 'color:green;' }, E('strong', {}, _('RUNNING'))); + actionButtons.push(E('button', { + 'class': 'cbi-button cbi-button-apply', + 'style': 'margin-left: 10px; padding: 2px 10px;', + 'click': ui.createHandlerFn(this, handleRestart) + }, _('Restart'))); + actionButtons.push(E('button', { + 'class': 'cbi-button cbi-button-remove', + 'style': 'margin-left: 8px; padding: 2px 10px;', + 'click': ui.createHandlerFn(this, handleLogout) + }, _('Logout'))); + } else { + // stopped / not running + statusBadge = E('span', { 'style': 'color:orange;' }, E('strong', {}, _('NOT RUNNING'))); + } + + const statusData = [ + { label: _('Service Status'), value: E('span', {}, [statusBadge, ...actionButtons]) }, + { label: _('Control Server'), value: serverDisplayText }, + { label: _('Version'), value: status.version || 'N/A' }, + { label: _('TUN Mode'), value: status.TUNMode ? _('Enabled') : _('Disabled') }, + { label: _('Tailscale IPv4'), value: status.ipv4 || '-' }, + { label: _('Tailscale IPv6'), value: status.ipv6 || '-' }, + { label: _('Tailnet Account'), value: status.account || status.domain_name || '-' } + ]; + + const statusTable = E('table', { 'style': 'width: 100%; border-spacing: 0 5px;' }, [ + E('tr', {}, statusData.map(item => E('td', { 'style': 'padding-right: 20px;' }, E('strong', {}, item.label)))), + E('tr', {}, statusData.map(item => E('td', { 'style': 'padding-right: 20px;' }, item.value))) + ]); + + let connSection = null; + if (status.connectivity) { + const conn = status.connectivity; + const badges = [ + { name: 'Varies', val: conn.varies, ok: (conn.varies === 'No') }, + { name: 'IPv4', val: conn.ipv4, ok: (conn.ipv4 === 'Yes') }, + { name: 'IPv6', val: conn.ipv6, ok: (conn.ipv6 === 'Yes') }, + { name: 'UDP', val: conn.udp, ok: (conn.udp === 'Yes') }, + { name: 'UPnP', val: conn.upnp, ok: (conn.upnp === 'Yes') }, + { name: 'PCP', val: conn.pcp, ok: (conn.pcp === 'Yes') }, + { name: 'NAT-PMP', val: conn.pmp, ok: (conn.pmp === 'Yes') }, + { name: 'Hairpinning', val: conn.hairpinning, ok: (conn.hairpinning === 'Yes') } + ]; + + const badgeElements = badges.map(b => { + const color = b.ok ? '#28a745' : '#6c757d'; + return E('span', { + 'style': 'display: inline-block; margin-right: 8px; margin-bottom: 6px; padding: 2px 8px; border-radius: 3px; font-size: 12px; font-weight: bold; background: #e9ecef; color: ' + color + '; border: 1px solid #ced4da;' + }, `${b.name}: ${b.val || 'No'}`); + }); + + let nearestDerpText = '-'; + if (conn.preferred_derp) { + const regionName = getRegionName(conn.preferred_derp) || `DERP-${conn.preferred_derp}`; + nearestDerpText = regionName + (conn.derp_latency ? ` (${conn.derp_latency})` : ''); + } + + let endpointsText = (conn.endpoints && conn.endpoints.length > 0) ? conn.endpoints.join(', ') : '-'; + + connSection = E('div', { 'style': 'margin-top: 12px; padding-top: 10px; border-top: 1px dashed #ccc;' }, [ + E('div', { 'style': 'font-weight: bold; margin-bottom: 8px; font-size: 14px;' }, [ + _('Client Connectivity') + ]), + E('div', { 'style': 'margin-bottom: 8px; font-size: 13px;' }, [ + E('strong', {}, _('Nearest Relay') + ': '), + E('span', { 'style': 'color: #007bff; font-weight: bold;' }, nearestDerpText), + E('span', { 'style': 'margin-left: 20px;' }, [ + E('strong', {}, _('Public Endpoints') + ': '), + E('span', {}, endpointsText) + ]) + ]), + E('div', { 'style': 'margin-top: 4px;' }, badgeElements) + ]); + } + + return E('div', {}, [statusTable, connSection].filter(Boolean)); +} + +function renderLogs(logs_data) { + if (!logs_data || !logs_data.logs || logs_data.logs.length === 0) { + return E('em', {}, _('No tailscale-related logs found.')); + } + + const lines = logs_data.logs.map(function(line) { + return E('div', { 'style': 'white-space: pre; font-family: monospace; font-size: 13px; line-height: 1.5;' }, line); + }); + + return E('div', { + 'style': 'max-height: 500px; overflow-y: auto; background: #f5f5f5; border: 1px solid #ccc; padding: 8px; border-radius: 3px;' + }, lines); +} + +function renderDevices(status) { + if (!status || !status.hasOwnProperty('status')) { + return E('em', {}, _('Collecting data ...')); + } + + if (status.status === 'not_installed') { + return E('em', {}, _('Tailscale is not installed.')); + } + + if (status.status === 'logout') { + return E('em', {}, _('Tailscale is not logged in. Please log in to view devices.')); + } + + if (status.status !== 'running') { + return E('em', {}, _('Tailscale is not running.')); + } + + const peers = status.peers; + if (!peers || Object.keys(peers).length === 0) { + return E('p', {}, _('No peer devices found.')); + } + + const peerTableHeaders = [ + { text: _('Status'), style: 'width: 80px;' }, + { text: _('Hostname') }, + { text: _('Tailscale IP') }, + { text: _('OS') }, + { text: _('Connection Info') }, + { text: _('RX') }, + { text: _('TX') }, + { text: _('Last Seen') } + ]; + + return E('table', { 'class': 'cbi-table' }, [ + E('tr', { 'class': 'cbi-table-header' }, peerTableHeaders.map(header => { + let th_style = 'padding-right: 20px; text-align: left;'; + if (header.style) { + th_style += header.style; + } + return E('th', { 'class': 'cbi-table-cell', 'style': th_style }, header.text); + })), + + ...Object.entries(peers).map(([peerid, peer]) => { + const td_style = 'padding-right: 20px;'; + + return E('tr', { 'class': 'cbi-row', 'id': `tailscale-peer-${peerid}` }, [ + E('td', { 'class': 'cbi-value-field', 'style': td_style }, + E('span', { + 'class': peer.online ? 'badge-status-positive' : 'badge-status-neutral', + 'style': `color: ${peer.online ? 'green' : 'grey'}; font-size: 1.2em;`, + 'title': (peer.exit_node ? _('Exit Node') + ' ' : '') + (peer.online ? _('Online') : _('Offline')) + }, peer.online ? '●' : '○') + ), + E('td', { 'class': 'cbi-value-field', 'style': td_style }, E('strong', {}, peer.hostname + (peer.exit_node_option ? ' (ExNode)' : ''))), + E('td', { 'class': 'cbi-value-field', 'style': td_style }, peer.ip || 'N/A'), + E('td', { 'class': 'cbi-value-field', 'style': td_style }, peer.ostype || 'N/A'), + E('td', { 'class': 'cbi-value-field', 'style': td_style }, formatConnectionInfo(peer.linkadress || '-')), + E('td', { 'class': 'cbi-value-field', 'style': td_style }, formatBytes(peer.rx)), + E('td', { 'class': 'cbi-value-field', 'style': td_style }, formatBytes(peer.tx)), + E('td', { 'class': 'cbi-value-field', 'style': td_style }, formatLastSeen(peer.lastseen)) + ]); + }) + ]); +} + +return view.extend({ + load() { + return Promise.all([ + L.resolveDefault(callGetStatus(), { running: '', peers: [] }), + L.resolveDefault(callGetSettings(), { accept_routes: false }), + L.resolveDefault(callGetSubroutes(), { routes: [] }) + ]) + .then(function([status, settings_from_rpc, subroutes]) { + return uci.load('tailscale').then(function() { + if (uci.get('tailscale', 'settings') === null) { + // No existing settings found; initialize UCI with RPC settings + uci.add('tailscale', 'settings', 'settings'); + uci.set('tailscale', 'settings', 'service_enabled', '0'); + uci.set('tailscale', 'settings', 'accept_routes', (settings_from_rpc.accept_routes ? '1' : '0')); + uci.set('tailscale', 'settings', 'advertise_exit_node', ((settings_from_rpc.advertise_exit_node || false) ? '1' : '0')); + uci.set('tailscale', 'settings', 'advertise_routes', (settings_from_rpc.advertise_routes || []).join(', ')); + uci.set('tailscale', 'settings', 'exit_node', settings_from_rpc.exit_node || ''); + uci.set('tailscale', 'settings', 'exit_node_allow_lan_access', ((settings_from_rpc.exit_node_allow_lan_access || false) ? '1' : '0')); + uci.set('tailscale', 'settings', 'ssh', ((settings_from_rpc.ssh || false) ? '1' : '0')); + uci.set('tailscale', 'settings', 'shields_up', ((settings_from_rpc.shields_up || false) ? '1' : '0')); + uci.set('tailscale', 'settings', 'runwebclient', ((settings_from_rpc.runwebclient || false) ? '1' : '0')); + uci.set('tailscale', 'settings', 'tunnel_snat', (settings_from_rpc.tunnel_snat || '0')); + uci.set('tailscale', 'settings', 'dns_mode', 'disabled'); + + uci.set('tailscale', 'settings', 'daemon_reduce_memory', '0'); + uci.set('tailscale', 'settings', 'daemon_mtu', ''); + return uci.save(); + } + }).then(function() { + var dirty = false; + // Migrate from old disable_magic_dns to dns_mode if needed + if (uci.get('tailscale', 'settings', 'dns_mode') === null) { + var oldMagicDns = uci.get('tailscale', 'settings', 'disable_magic_dns'); + uci.set('tailscale', 'settings', 'dns_mode', oldMagicDns === '1' ? 'disabled' : 'magicdns'); + uci.unset('tailscale', 'settings', 'disable_magic_dns'); + dirty = true; + } + // Migrate and clean up legacy nosnat and outbound_masq options + if (uci.get('tailscale', 'settings', 'tunnel_snat') === null) { + var oldNosnat = uci.get('tailscale', 'settings', 'nosnat'); + var oldMasq = uci.get('tailscale', 'settings', 'outbound_masq'); + if (oldNosnat !== null) { + uci.set('tailscale', 'settings', 'tunnel_snat', oldNosnat === '1' ? '0' : '1'); + } else if (oldMasq !== null) { + uci.set('tailscale', 'settings', 'tunnel_snat', oldMasq === '1' ? '1' : '0'); + } else { + uci.set('tailscale', 'settings', 'tunnel_snat', '0'); + } + dirty = true; + } + if (uci.get('tailscale', 'settings', 'nosnat') !== null) { + uci.unset('tailscale', 'settings', 'nosnat'); + dirty = true; + } + if (uci.get('tailscale', 'settings', 'outbound_masq') !== null) { + uci.unset('tailscale', 'settings', 'outbound_masq'); + dirty = true; + } + if (dirty) { + return uci.save(); + } + }).then(function() { + return [status, settings_from_rpc, subroutes]; + }); + }); + }, + + render ([status = {}, settings = {}, subroutes_obj]) { + const subroutes = (subroutes_obj && subroutes_obj.routes) ? subroutes_obj.routes : []; + + let s; + map = new form.Map('tailscale', _('Tailscale'), _('Tailscale is a mesh VPN solution that makes it easy to connect your devices securely. This configuration page allows you to manage Tailscale settings on your OpenWrt device.')); + + let lastStatus = null; + s = map.section(form.NamedSection, '_status'); + s.anonymous = true; + s.render = function (section_id) { + L.Poll.add( + function () { + return getRunningStatus().then(function (res) { + if (lastStatus === 'logout' && (res.status === 'running' || res.status === 'stopped')) { + L.resolveDefault(callReloadSettings(), {}).then(function() { + window.location.reload(); + }); + } + if (res.status === 'logout' || res.status === 'running' || res.status === 'stopped') { + lastStatus = res.status; + } + + const view = document.getElementById("service_status_display"); + if (view) { + const content = renderStatus(res); + view.replaceChildren(content); + } + + const devicesView = document.getElementById("tailscale_devices_display"); + if (devicesView) { + devicesView.replaceChildren(renderDevices(res)); + } + }); + }, 5); + + return E('div', {}, [ + E('hr', { 'style': 'margin: 5px 0 15px 0; border: 0; border-top: 1px solid #e5e5e5;' }), + E('div', { 'id': 'service_status_display', 'class': 'cbi-value' }, + _('Collecting data ...') + ) + ]); + } + + // Bind settings to the 'settings' section of uci + s = map.section(form.NamedSection, 'settings', 'settings', null); + s.dynamic = true; + + // Tab 1: General Settings + s.tab('general', _('General Settings')); + + defTabOpts(s, 'general', tailscaleSettingsConf, { optional: false }); + + const relayPort = s.taboption('general', form.Value, 'relay_server_port', _('Peer Relay Port'), + _('UDP port for the Peer Relay service (--peer-relay-port). Open this port on your router firewall/NAT.') + ); + relayPort.datatype = 'port'; + relayPort.placeholder = '40000'; + relayPort.rmempty = false; + relayPort.depends('enable_relay', '1'); + + const en = s.taboption('general', form.ListValue, 'exit_node', _('Exit Node'), _('Select an exit node from the list (--exit-node). If enabled, Allow LAN Access is enabled implicitly.')); + en.value('', _('None')); + if (status.peers) { + Object.values(status.peers).forEach(function(peer) { + if (peer.exit_node_option) { + const primaryIp = peer.ip.split('
')[0]; + const label = peer.hostname ? `${peer.hostname} (${primaryIp})` : primaryIp; + en.value(primaryIp, label); + } + }); + } + en.rmempty = true; + en.cfgvalue = function(section_id) { + if (status && status.status === 'running' && status.peers) { + for (const id in status.peers) { + if (status.peers[id].exit_node) { + return status.peers[id].ip.split('
')[0]; + } + } + return ''; + } + return uci.get('tailscale', 'settings', 'exit_node') || ''; + }; + + const o = s.taboption('general', form.DynamicList, 'advertise_routes', _('Advertise Subnet Routes'), + _('Announce subnet routes behind this device (--advertise-routes). Select from the detected subnets below or enter custom routes (comma-separated).') + ); + if (subroutes.length > 0) { + subroutes.forEach(function(subnet) { + o.value(subnet, subnet); + }); + } + o.rmempty = true; + + const customLoginUrl = s.taboption('general', form.Value, 'custom_login_url', + _('Custom Control Server'), + _('Optional: Specify a custom control server URL (e.g., a Headscale instance, --login-server). Leave blank for default Tailscale control plane.') + ); + customLoginUrl.placeholder = ''; + customLoginUrl.rmempty = true; + + const customLoginAuthKey = s.taboption('general', form.Value, 'custom_login_AuthKey', + _('Custom Server Auth Key'), + _('Optional: Specify an authentication key for the custom control server (--auth-key). Leave blank if not required.') + ); + customLoginAuthKey.placeholder = ''; + customLoginAuthKey.rmempty = true; + + defTabOpts(s, 'general', daemonConf, { optional: false }); + + // Tab 2: Devices List + s.tab('devices', _('Devices List')); + + const refreshDevicesBtn = s.taboption('devices', form.Button, '_refresh_devices', _('Refresh')); + refreshDevicesBtn.inputstyle = 'action'; + refreshDevicesBtn.onclick = function() { + const display = document.getElementById('tailscale_devices_display'); + if (display) { + display.replaceChildren(E('em', {}, _('Collecting data ...'))); + } + return getRunningStatus().then(function(res) { + if (display) { + display.replaceChildren(renderDevices(res)); + } + }).catch(function(err) { + if (display) { + display.replaceChildren(E('em', {}, _('Failed to load devices: %s').format(err.message || _('Unknown error')))); + } + }); + }; + + const devicesSection = s.taboption('devices', form.DummyValue, '_devices'); + devicesSection.render = function () { + return E('div', { 'id': 'tailscale_devices_display', 'class': 'cbi-value' }, renderDevices(status)); + }; + + // Tab 3: Logs + s.tab('logs', _('Logs')); + const logsSection = s.taboption('logs', form.DummyValue, '_logs'); + logsSection.render = function () { + const container = E('div', { 'id': 'tailscale_logs_display', 'class': 'cbi-value' }, + _('No tailscale-related logs found.') + ); + return container; + }; + + const refreshLogsBtn = s.taboption('logs', form.Button, '_refresh_logs', _('Refresh')); + refreshLogsBtn.inputstyle = 'action'; + refreshLogsBtn.onclick = function() { + const display = document.getElementById('tailscale_logs_display'); + if (display) { + display.replaceChildren(E('em', {}, _('Collecting data ...'))); + } + return callGetLogs().then(function(res) { + if (display) { + display.replaceChildren(renderLogs(res)); + } + }).catch(function(err) { + if (display) { + display.replaceChildren(E('em', {}, _('Failed to load logs: %s').format(err.message || _('Unknown error')))); + } + }); + }; + + return map.render(); + }, + + handleSaveApply(ev, mode) { + return map.save().then(function () { + return ui.changes.apply(mode == '0'); + }); + } +}); diff --git a/luci-app-tailscale-next/po/templates/tailscale.pot b/luci-app-tailscale-next/po/templates/tailscale.pot new file mode 100644 index 000000000..48ddc2937 --- /dev/null +++ b/luci-app-tailscale-next/po/templates/tailscale.pot @@ -0,0 +1,335 @@ +msgid "" +msgstr "Content-Type: text/plain; charset=UTF-8\n" + +msgid "(Experimental) Reduce Memory Usage" +msgstr "" + +msgid "Accept Routes" +msgstr "" + +msgid "Advertise Exit Node" +msgstr "" + +msgid "Advertise Subnet Routes" +msgstr "" + +msgid "Allow LAN Access" +msgstr "" + +msgid "Allow accepting subnet routes announced by other nodes (--accept-routes)." +msgstr "" + +msgid "Allow connecting to this device through the native SSH function of Tailscale (--ssh)." +msgstr "" + +msgid "Allow user access to tailscale" +msgstr "" + +msgid "Announce subnet routes behind this device (--advertise-routes). Select from the detected subnets below or enter custom routes (comma-separated)." +msgstr "" + +msgid "Are you sure you want to log out?" +msgstr "" + +msgid "Are you sure you want to restart Tailscale service?" +msgstr "" + +msgid "Cancel" +msgstr "" + +msgid "Client Connectivity" +msgstr "" + +msgid "Collecting data ..." +msgstr "" + +msgid "Confirm Logout" +msgstr "" + +msgid "Connection Info" +msgstr "" + +msgid "Control Server" +msgstr "" + +msgid "Controls how Tailscale DNS is handled (--accept-dns)." +msgstr "" + +msgid "Could not open a new tab. Please check if your browser or an extension blocked the pop-up." +msgstr "" + +msgid "Custom Control Server" +msgstr "" + +msgid "Custom Server (%s)" +msgstr "" + +msgid "Custom Server Auth Key" +msgstr "" + +msgid "DNS Mode" +msgstr "" + +msgid "Declare this device as an exit node, allowing other nodes to route all traffic through it (--advertise-exit-node)." +msgstr "" + +msgid "Devices List" +msgstr "" + +msgid "Disabled" +msgstr "" + +msgid "Disabled: system DNS only." +msgstr "" + +msgid "Enable Peer Relay" +msgstr "" + +msgid "Enable Tailscale SSH" +msgstr "" + +msgid "Enable Tailscale Service" +msgstr "" + +msgid "Enable Tunnel Traffic SNAT" +msgstr "" + +msgid "Enable Web Interface" +msgstr "" + +msgid "Enable or disable the Tailscale background service (/etc/init.d/tailscale)." +msgstr "" + +msgid "Enable this device as a Peer Relay server (--peer-relay). Requires a public IP and an UDP port open on the router." +msgstr "" + +msgid "Enabled" +msgstr "" + +msgid "Enabling this option can reduce memory usage, but it may sacrifice some performance (set GOGC=10)." +msgstr "" + +msgid "Error" +msgstr "" + +msgid "Exit Node" +msgstr "" + +msgid "Expose a local web interface on port 5252 for managing this node over Tailscale (--webclient)." +msgstr "" + +msgid "Failed to get login URL. You may close this tab." +msgstr "" + +msgid "Failed to get login URL: %s" +msgstr "" + +msgid "Failed to get login URL: Invalid response from server." +msgstr "" + +msgid "Failed to load devices: %s" +msgstr "" + +msgid "Failed to load logs: %s" +msgstr "" + +msgid "Failed to restart: %s" +msgstr "" + +msgid "General Settings" +msgstr "" + +msgid "Hostname" +msgstr "" + +msgid "Invalid Date" +msgstr "" + +msgid "Just now" +msgstr "" + +msgid "Last Seen" +msgstr "" + +msgid "Logging out..." +msgstr "" + +msgid "Login" +msgstr "" + +msgid "Logout" +msgstr "" + +msgid "Logout failed: %s" +msgstr "" + +msgid "Logs" +msgstr "" + +msgid "MagicDNS: Tailscale overrides resolv.conf." +msgstr "" + +msgid "N/A" +msgstr "" + +msgid "NOT INSTALLED" +msgstr "" + +msgid "NOT LOGGED IN" +msgstr "" + +msgid "NOT RUNNING" +msgstr "" + +msgid "Nearest Relay" +msgstr "" + +msgid "No peer devices found." +msgstr "" + +msgid "No tailscale-related logs found." +msgstr "" + +msgid "None" +msgstr "" + +msgid "Now" +msgstr "" + +msgid "OS" +msgstr "" + +msgid "Official Server (Tailscale SaaS)" +msgstr "" + +msgid "Offline" +msgstr "" + +msgid "Online" +msgstr "" + +msgid "OpenWrt Forward" +msgstr "" + +msgid "OpenWrt Forward: MagicDNS via dnsmasq forwarding.(Only support ts.net)" +msgstr "" + +msgid "Optional: Specify a custom control server URL (e.g., a Headscale instance, --login-server). Leave blank for default Tailscale control plane." +msgstr "" + +msgid "Optional: Specify an authentication key for the custom control server (--auth-key). Leave blank if not required." +msgstr "" + +msgid "Peer Relay Port" +msgstr "" + +msgid "Please wait while the service is restarting..." +msgstr "" + +msgid "Please wait." +msgstr "" + +msgid "Public Endpoints" +msgstr "" + +msgid "RUNNING" +msgstr "" + +msgid "RX" +msgstr "" + +msgid "Refresh" +msgstr "" + +msgid "Requesting Login URL..." +msgstr "" + +msgid "Requesting Tailscale login URL... Please wait." +msgstr "" + +msgid "Restart" +msgstr "" + +msgid "Restart Service" +msgstr "" + +msgid "Restarting Tailscale..." +msgstr "" + +msgid "Select an exit node from the list (--exit-node). If enabled, Allow LAN Access is enabled implicitly." +msgstr "" + +msgid "Service Status" +msgstr "" + +msgid "Shields Up" +msgstr "" + +msgid "Status" +msgstr "" + +msgid "Successfully logged out." +msgstr "" + +msgid "TUN Mode" +msgstr "" + +msgid "TX" +msgstr "" + +msgid "Tailnet Account" +msgstr "" + +msgid "Tailscale" +msgstr "" + +msgid "Tailscale IP" +msgstr "" + +msgid "Tailscale IPv4" +msgstr "" + +msgid "Tailscale IPv6" +msgstr "" + +msgid "Tailscale Login" +msgstr "" + +msgid "Tailscale is a mesh VPN solution that makes it easy to connect your devices securely. This configuration page allows you to manage Tailscale settings on your OpenWrt device." +msgstr "" + +msgid "Tailscale is not installed." +msgstr "" + +msgid "Tailscale is not logged in. Please log in to view devices." +msgstr "" + +msgid "Tailscale is not running." +msgstr "" + +msgid "This can take up to 30 seconds." +msgstr "" + +msgid "This will disconnect this device from your Tailnet and require you to re-authenticate." +msgstr "" + +msgid "UDP port for the Peer Relay service (--peer-relay-port). Open this port on your router firewall/NAT." +msgstr "" + +msgid "Unknown error" +msgstr "" + +msgid "Use OpenWrt system firewall to manage NAT for Tailscale tunnel and subnet traffic. Disabling this option disables source address masquerading to preserve real source IPs of remote peers and subnets in the local network." +msgstr "" + +msgid "Version" +msgstr "" + +msgid "When enabled, blocks all inbound connections from the Tailscale network (--shields-up)." +msgstr "" + +msgid "When using or advertising the exit node, allow access to the local LAN (--exit-node-allow-lan-access)." +msgstr "" + +msgid "ago" +msgstr "" diff --git a/luci-app-tailscale-next/po/zh-cn b/luci-app-tailscale-next/po/zh-cn new file mode 120000 index 000000000..8d69574dd --- /dev/null +++ b/luci-app-tailscale-next/po/zh-cn @@ -0,0 +1 @@ +zh_Hans \ No newline at end of file diff --git a/luci-app-tailscale-next/po/zh_Hans/tailscale.po b/luci-app-tailscale-next/po/zh_Hans/tailscale.po new file mode 100644 index 000000000..41fae5d73 --- /dev/null +++ b/luci-app-tailscale-next/po/zh_Hans/tailscale.po @@ -0,0 +1,335 @@ +msgid "" +msgstr "Content-Type: text/plain; charset=UTF-8\n" + +msgid "(Experimental) Reduce Memory Usage" +msgstr "(实验性)减少内存占用" + +msgid "Accept Routes" +msgstr "接受子网路由" + +msgid "Advertise Exit Node" +msgstr "宣告出口节点" + +msgid "Advertise Subnet Routes" +msgstr "通告子网路由" + +msgid "Allow LAN Access" +msgstr "允许访问局域网" + +msgid "Allow accepting subnet routes announced by other nodes (--accept-routes)." +msgstr "允许接收并应用其他节点通告的子网路由 (--accept-routes)。" + +msgid "Allow connecting to this device through the native SSH function of Tailscale (--ssh)." +msgstr "允许通过 Tailscale 原生 SSH 功能连接到此设备 (--ssh)。" + +msgid "Allow user access to tailscale" +msgstr "允许访问 Tailscale" + +msgid "Announce subnet routes behind this device (--advertise-routes). Select from the detected subnets below or enter custom routes (comma-separated)." +msgstr "向 Tailnet 通告本设备后方的子网路由 (--advertise-routes)。从下方检测到的子网中选择或手动输入自定义路由(逗号分隔)。" + +msgid "Are you sure you want to log out?" +msgstr "确定要退出登录吗?" + +msgid "Are you sure you want to restart Tailscale service?" +msgstr "确定要重启 Tailscale 服务吗?" + +msgid "Cancel" +msgstr "取消" + +msgid "Client Connectivity" +msgstr "客户端连通性" + +msgid "Collecting data ..." +msgstr "正在收集数据..." + +msgid "Confirm Logout" +msgstr "确认退出登录" + +msgid "Connection Info" +msgstr "连接信息" + +msgid "Control Server" +msgstr "控制服务器" + +msgid "Controls how Tailscale DNS is handled (--accept-dns)." +msgstr "控制 Tailscale DNS 的处理方式 (--accept-dns)。" + +msgid "Could not open a new tab. Please check if your browser or an extension blocked the pop-up." +msgstr "无法打开新标签页,请检查浏览器或扩展程序是否拦截了弹出窗口。" + +msgid "Custom Control Server" +msgstr "自定义控制服务器" + +msgid "Custom Server (%s)" +msgstr "自定义服务器 (%s)" + +msgid "Custom Server Auth Key" +msgstr "自定义服务器预认证密钥" + +msgid "DNS Mode" +msgstr "DNS 模式" + +msgid "Declare this device as an exit node, allowing other nodes to route all traffic through it (--advertise-exit-node)." +msgstr "将此设备声明为出口节点,允许其他节点通过它路由所有流量 (--advertise-exit-node)。" + +msgid "Devices List" +msgstr "设备列表" + +msgid "Disabled" +msgstr "已禁用" + +msgid "Disabled: system DNS only." +msgstr "禁用:仅使用系统 DNS。" + +msgid "Enable Peer Relay" +msgstr "启用节点中继" + +msgid "Enable Tailscale SSH" +msgstr "启用 Tailscale SSH" + +msgid "Enable Tailscale Service" +msgstr "启用 Tailscale 服务" + +msgid "Enable Tunnel Traffic SNAT" +msgstr "开启隧道流量 SNAT" + +msgid "Enable Web Interface" +msgstr "启用 Web 管理界面" + +msgid "Enable or disable the Tailscale background service (/etc/init.d/tailscale)." +msgstr "启用或禁用 Tailscale 后台守护进程 (/etc/init.d/tailscale)。" + +msgid "Enable this device as a Peer Relay server (--peer-relay). Requires a public IP and an UDP port open on the router." +msgstr "将此设备作为节点中继服务器 (--peer-relay)。需要公网 IP 并在路由器上开放 UDP 端口。" + +msgid "Enabled" +msgstr "已启用" + +msgid "Enabling this option can reduce memory usage, but it may sacrifice some performance (set GOGC=10)." +msgstr "启用此选项可以减少内存占用,但可能会牺牲部分性能(设置 GOGC=10)。" + +msgid "Error" +msgstr "错误" + +msgid "Exit Node" +msgstr "出口节点" + +msgid "Expose a local web interface on port 5252 for managing this node over Tailscale (--webclient)." +msgstr "在 5252 端口开放本地 Web 管理界面,以便通过 Tailscale 管理此节点 (--webclient)。" + +msgid "Failed to get login URL. You may close this tab." +msgstr "获取登录 URL 失败,您可以关闭此标签页。" + +msgid "Failed to get login URL: %s" +msgstr "获取登录 URL 失败:%s" + +msgid "Failed to get login URL: Invalid response from server." +msgstr "获取登录 URL 失败:服务器响应无效。" + +msgid "Failed to load devices: %s" +msgstr "加载设备列表失败:%s" + +msgid "Failed to load logs: %s" +msgstr "加载日志失败:%s" + +msgid "Failed to restart: %s" +msgstr "重启失败:%s" + +msgid "General Settings" +msgstr "常规设置" + +msgid "Hostname" +msgstr "主机名" + +msgid "Invalid Date" +msgstr "无效日期" + +msgid "Just now" +msgstr "刚刚" + +msgid "Last Seen" +msgstr "最后活跃" + +msgid "Logging out..." +msgstr "正在退出登录..." + +msgid "Login" +msgstr "登录" + +msgid "Logout" +msgstr "退出登录" + +msgid "Logout failed: %s" +msgstr "退出登录失败:%s" + +msgid "Logs" +msgstr "运行日志" + +msgid "MagicDNS: Tailscale overrides resolv.conf." +msgstr "MagicDNS:Tailscale 将根据设置覆写系统 resolv.conf 配置。" + +msgid "N/A" +msgstr "不适用" + +msgid "NOT INSTALLED" +msgstr "未安装" + +msgid "NOT LOGGED IN" +msgstr "未登录" + +msgid "NOT RUNNING" +msgstr "未运行" + +msgid "Nearest Relay" +msgstr "最近中继节点" + +msgid "No peer devices found." +msgstr "未发现对等设备。" + +msgid "No tailscale-related logs found." +msgstr "未找到 Tailscale 相关日志。" + +msgid "None" +msgstr "无" + +msgid "Now" +msgstr "当前在线" + +msgid "OS" +msgstr "操作系统" + +msgid "Official Server (Tailscale SaaS)" +msgstr "官方服务器 (Tailscale SaaS)" + +msgid "Offline" +msgstr "离线" + +msgid "Online" +msgstr "在线" + +msgid "OpenWrt Forward" +msgstr "OpenWrt 转发" + +msgid "OpenWrt Forward: MagicDNS via dnsmasq forwarding.(Only support ts.net)" +msgstr "OpenWrt 转发:通过 dnsmasq 转发 MagicDNS(仅支持 ts.net 域名)。" + +msgid "Optional: Specify a custom control server URL (e.g., a Headscale instance, --login-server). Leave blank for default Tailscale control plane." +msgstr "可选:指定自定义控制服务器地址(例如 Headscale 实例,--login-server)。留空则使用 Tailscale 官方服务器。" + +msgid "Optional: Specify an authentication key for the custom control server (--auth-key). Leave blank if not required." +msgstr "可选:指定自定义控制服务器的预认证密钥(--auth-key)。留空则无需认证密钥。" + +msgid "Peer Relay Port" +msgstr "节点中继端口" + +msgid "Please wait while the service is restarting..." +msgstr "服务正在重启,请稍候..." + +msgid "Please wait." +msgstr "请稍候。" + +msgid "Public Endpoints" +msgstr "公网端点" + +msgid "RUNNING" +msgstr "运行中" + +msgid "RX" +msgstr "接收流量" + +msgid "Refresh" +msgstr "刷新" + +msgid "Requesting Login URL..." +msgstr "正在获取登录链接..." + +msgid "Requesting Tailscale login URL... Please wait." +msgstr "正在请求 Tailscale 登录链接,请稍候..." + +msgid "Restart" +msgstr "重启" + +msgid "Restart Service" +msgstr "重启服务" + +msgid "Restarting Tailscale..." +msgstr "正在重启 Tailscale..." + +msgid "Select an exit node from the list (--exit-node). If enabled, Allow LAN Access is enabled implicitly." +msgstr "从列表中选择出口节点 (--exit-node)。如果启用,将隐式允许访问本地局域网。" + +msgid "Service Status" +msgstr "服务状态" + +msgid "Shields Up" +msgstr "防护模式" + +msgid "Status" +msgstr "状态概览" + +msgid "Successfully logged out." +msgstr "已成功退出登录。" + +msgid "TUN Mode" +msgstr "TUN 模式" + +msgid "TX" +msgstr "发送流量" + +msgid "Tailnet Account" +msgstr "Tailnet 账户" + +msgid "Tailscale" +msgstr "Tailscale" + +msgid "Tailscale IP" +msgstr "Tailscale IP" + +msgid "Tailscale IPv4" +msgstr "Tailscale IPv4" + +msgid "Tailscale IPv6" +msgstr "Tailscale IPv6" + +msgid "Tailscale Login" +msgstr "Tailscale 登录认证" + +msgid "Tailscale is a mesh VPN solution that makes it easy to connect your devices securely. This configuration page allows you to manage Tailscale settings on your OpenWrt device." +msgstr "Tailscale 是一种网状 Mesh VPN 解决方案,可让您安全轻松地连接各类设备。此页面用于管理 OpenWrt 上的 Tailscale 服务配置。" + +msgid "Tailscale is not installed." +msgstr "Tailscale 未安装。" + +msgid "Tailscale is not logged in. Please log in to view devices." +msgstr "Tailscale 未登录。请先登录以查看设备列表。" + +msgid "Tailscale is not running." +msgstr "Tailscale 未运行。" + +msgid "This can take up to 30 seconds." +msgstr "此过程最长可能需要 30 秒。" + +msgid "This will disconnect this device from your Tailnet and require you to re-authenticate." +msgstr "这将断开本设备与 Tailnet 的连接,并且需要重新进行身份验证。" + +msgid "UDP port for the Peer Relay service (--peer-relay-port). Open this port on your router firewall/NAT." +msgstr "节点中继服务的 UDP 端口 (--peer-relay-port)。请确保在路由器防火墙中放行此端口。" + +msgid "Unknown error" +msgstr "未知错误" + +msgid "Use OpenWrt system firewall to manage NAT for Tailscale tunnel and subnet traffic. Disabling this option disables source address masquerading to preserve real source IPs of remote peers and subnets in the local network." +msgstr "由 OpenWrt 系统防火墙统一接管 Tailscale 隧道及对端子网流量的地址转换。禁用此项将禁用源地址伪装,在局域网中保留对端节点和子网的真实源 IP。" + +msgid "Version" +msgstr "版本信息" + +msgid "When enabled, blocks all inbound connections from the Tailscale network (--shields-up)." +msgstr "启用后,阻止所有来自 Tailscale 网络的主动入站连接 (--shields-up)。" + +msgid "When using or advertising the exit node, allow access to the local LAN (--exit-node-allow-lan-access)." +msgstr "在使用或宣告出口节点时,允许访问本地局域网设备 (--exit-node-allow-lan-access)。" + +msgid "ago" +msgstr "前" diff --git a/luci-app-tailscale-next/po/zh_Hant/tailscale.po b/luci-app-tailscale-next/po/zh_Hant/tailscale.po new file mode 100644 index 000000000..3aeeceeb5 --- /dev/null +++ b/luci-app-tailscale-next/po/zh_Hant/tailscale.po @@ -0,0 +1,335 @@ +msgid "" +msgstr "Content-Type: text/plain; charset=UTF-8\n" + +msgid "(Experimental) Reduce Memory Usage" +msgstr "(實驗性)減少記憶體佔用" + +msgid "Accept Routes" +msgstr "接受子網路由" + +msgid "Advertise Exit Node" +msgstr "宣告出口節點" + +msgid "Advertise Subnet Routes" +msgstr "通告子網路由" + +msgid "Allow LAN Access" +msgstr "允許存取區域網路" + +msgid "Allow accepting subnet routes announced by other nodes (--accept-routes)." +msgstr "允許接收並套用其他節點通告的子網路由 (--accept-routes)。" + +msgid "Allow connecting to this device through the native SSH function of Tailscale (--ssh)." +msgstr "允許透過 Tailscale 原生 SSH 功能連線到此裝置 (--ssh)。" + +msgid "Allow user access to tailscale" +msgstr "允許使用者存取 Tailscale" + +msgid "Announce subnet routes behind this device (--advertise-routes). Select from the detected subnets below or enter custom routes (comma-separated)." +msgstr "向 Tailnet 通告本裝置後方的子網路由 (--advertise-routes)。從下方偵測到的子網中選擇或手動輸入自訂路由(逗號分隔)。" + +msgid "Are you sure you want to log out?" +msgstr "確定要登出嗎?" + +msgid "Are you sure you want to restart Tailscale service?" +msgstr "確定要重啟 Tailscale 服務嗎?" + +msgid "Cancel" +msgstr "取消" + +msgid "Client Connectivity" +msgstr "用戶端連通性" + +msgid "Collecting data ..." +msgstr "正在收集資料..." + +msgid "Confirm Logout" +msgstr "確認登出" + +msgid "Connection Info" +msgstr "連線資訊" + +msgid "Control Server" +msgstr "控制伺服器" + +msgid "Controls how Tailscale DNS is handled (--accept-dns)." +msgstr "控制 Tailscale DNS 的處理方式 (--accept-dns)。" + +msgid "Could not open a new tab. Please check if your browser or an extension blocked the pop-up." +msgstr "無法開啟新分頁,請檢查瀏覽器或擴充功能是否封鎖了彈出視窗。" + +msgid "Custom Control Server" +msgstr "自訂控制伺服器" + +msgid "Custom Server (%s)" +msgstr "自訂伺服器 (%s)" + +msgid "Custom Server Auth Key" +msgstr "自訂伺服器預認證密鑰" + +msgid "DNS Mode" +msgstr "DNS 模式" + +msgid "Declare this device as an exit node, allowing other nodes to route all traffic through it (--advertise-exit-node)." +msgstr "將此裝置宣告為出口節點,允許其他節點透過它路由所有流量 (--advertise-exit-node)。" + +msgid "Devices List" +msgstr "裝置列表" + +msgid "Disabled" +msgstr "已停用" + +msgid "Disabled: system DNS only." +msgstr "停用:僅使用系統 DNS。" + +msgid "Enable Peer Relay" +msgstr "啟用節點中繼" + +msgid "Enable Tailscale SSH" +msgstr "啟用 Tailscale SSH" + +msgid "Enable Tailscale Service" +msgstr "啟用 Tailscale 服務" + +msgid "Enable Tunnel Traffic SNAT" +msgstr "開啟隧道流量 SNAT" + +msgid "Enable Web Interface" +msgstr "啟用 Web 管理介面" + +msgid "Enable or disable the Tailscale background service (/etc/init.d/tailscale)." +msgstr "啟用或停用 Tailscale 背景守護程序 (/etc/init.d/tailscale)。" + +msgid "Enable this device as a Peer Relay server (--peer-relay). Requires a public IP and an UDP port open on the router." +msgstr "將此裝置作為節點中繼伺服器 (--peer-relay)。需要公網 IP 並在路由器上開放 UDP 連接埠。" + +msgid "Enabled" +msgstr "已啟用" + +msgid "Enabling this option can reduce memory usage, but it may sacrifice some performance (set GOGC=10)." +msgstr "啟用此選項可以減少記憶體佔用,但可能會犧牲部分效能(設定 GOGC=10)。" + +msgid "Error" +msgstr "錯誤" + +msgid "Exit Node" +msgstr "出口節點" + +msgid "Expose a local web interface on port 5252 for managing this node over Tailscale (--webclient)." +msgstr "在 5252 連接埠開放本地 Web 管理介面,以便透過 Tailscale 管理此節點 (--webclient)。" + +msgid "Failed to get login URL. You may close this tab." +msgstr "取得登入 URL 失敗,您可以關閉此分頁。" + +msgid "Failed to get login URL: %s" +msgstr "取得登入 URL 失敗:%s" + +msgid "Failed to get login URL: Invalid response from server." +msgstr "取得登入 URL 失敗:伺服器回應無效。" + +msgid "Failed to load devices: %s" +msgstr "載入裝置列表失敗:%s" + +msgid "Failed to load logs: %s" +msgstr "載入日誌失敗:%s" + +msgid "Failed to restart: %s" +msgstr "重啟失敗:%s" + +msgid "General Settings" +msgstr "一般設定" + +msgid "Hostname" +msgstr "主機名稱" + +msgid "Invalid Date" +msgstr "無效日期" + +msgid "Just now" +msgstr "剛剛" + +msgid "Last Seen" +msgstr "最後活躍" + +msgid "Logging out..." +msgstr "正在登出..." + +msgid "Login" +msgstr "登入" + +msgid "Logout" +msgstr "登出" + +msgid "Logout failed: %s" +msgstr "登出失敗:%s" + +msgid "Logs" +msgstr "執行日誌" + +msgid "MagicDNS: Tailscale overrides resolv.conf." +msgstr "MagicDNS:Tailscale 將根據設定覆寫系統 resolv.conf 配置。" + +msgid "N/A" +msgstr "不適用" + +msgid "NOT INSTALLED" +msgstr "未安裝" + +msgid "NOT LOGGED IN" +msgstr "未登入" + +msgid "NOT RUNNING" +msgstr "未執行" + +msgid "Nearest Relay" +msgstr "最近中繼節點" + +msgid "No peer devices found." +msgstr "未發現對等裝置。" + +msgid "No tailscale-related logs found." +msgstr "未找到 Tailscale 相關日誌。" + +msgid "None" +msgstr "無" + +msgid "Now" +msgstr "目前在線" + +msgid "OS" +msgstr "作業系統" + +msgid "Official Server (Tailscale SaaS)" +msgstr "官方伺服器 (Tailscale SaaS)" + +msgid "Offline" +msgstr "離線" + +msgid "Online" +msgstr "在線" + +msgid "OpenWrt Forward" +msgstr "OpenWrt 轉發" + +msgid "OpenWrt Forward: MagicDNS via dnsmasq forwarding.(Only support ts.net)" +msgstr "OpenWrt 轉發:透過 dnsmasq 轉發 MagicDNS(僅支援 ts.net 網域名稱)。" + +msgid "Optional: Specify a custom control server URL (e.g., a Headscale instance, --login-server). Leave blank for default Tailscale control plane." +msgstr "可選:指定自訂控制伺服器位址(例如 Headscale 實例,--login-server)。留空則使用 Tailscale 官方伺服器。" + +msgid "Optional: Specify an authentication key for the custom control server (--auth-key). Leave blank if not required." +msgstr "可選:指定自訂控制伺服器的預認證密鑰(--auth-key)。留空則無需認證密鑰。" + +msgid "Peer Relay Port" +msgstr "節點中繼連接埠" + +msgid "Please wait while the service is restarting..." +msgstr "服務正在重啟,請稍候..." + +msgid "Please wait." +msgstr "請稍候。" + +msgid "Public Endpoints" +msgstr "公網端點" + +msgid "RUNNING" +msgstr "執行中" + +msgid "RX" +msgstr "接收流量" + +msgid "Refresh" +msgstr "重新整理" + +msgid "Requesting Login URL..." +msgstr "正在取得登入連結..." + +msgid "Requesting Tailscale login URL... Please wait." +msgstr "正在請求 Tailscale 登入連結,請稍候..." + +msgid "Restart" +msgstr "重啟" + +msgid "Restart Service" +msgstr "重啟服務" + +msgid "Restarting Tailscale..." +msgstr "正在重啟 Tailscale..." + +msgid "Select an exit node from the list (--exit-node). If enabled, Allow LAN Access is enabled implicitly." +msgstr "從列表中選擇出口節點 (--exit-node)。啟用後將隱式開啟允許存取區域網路。" + +msgid "Service Status" +msgstr "服務狀態" + +msgid "Shields Up" +msgstr "防護模式" + +msgid "Status" +msgstr "狀態概覽" + +msgid "Successfully logged out." +msgstr "已成功登出。" + +msgid "TUN Mode" +msgstr "TUN 模式" + +msgid "TX" +msgstr "發送流量" + +msgid "Tailnet Account" +msgstr "Tailnet 帳戶" + +msgid "Tailscale" +msgstr "Tailscale" + +msgid "Tailscale IP" +msgstr "Tailscale IP" + +msgid "Tailscale IPv4" +msgstr "Tailscale IPv4" + +msgid "Tailscale IPv6" +msgstr "Tailscale IPv6" + +msgid "Tailscale Login" +msgstr "Tailscale 登入認證" + +msgid "Tailscale is a mesh VPN solution that makes it easy to connect your devices securely. This configuration page allows you to manage Tailscale settings on your OpenWrt device." +msgstr "Tailscale 是一種網狀 Mesh VPN 解決方案,可讓您安全輕鬆地連接各類裝置。此頁面用於管理 OpenWrt 上的 Tailscale 服務設定。" + +msgid "Tailscale is not installed." +msgstr "Tailscale 未安裝。" + +msgid "Tailscale is not logged in. Please log in to view devices." +msgstr "Tailscale 未登入。請先登入以檢視裝置列表。" + +msgid "Tailscale is not running." +msgstr "Tailscale 未執行。" + +msgid "This can take up to 30 seconds." +msgstr "此過程最長可能需要 30 秒。" + +msgid "This will disconnect this device from your Tailnet and require you to re-authenticate." +msgstr "這將中斷本裝置與 Tailnet 的連線,並且需要重新進行身分驗證。" + +msgid "UDP port for the Peer Relay service (--peer-relay-port). Open this port on your router firewall/NAT." +msgstr "節點中繼服務的 UDP 連接埠 (--peer-relay-port)。請確保在路由器防火牆中放行此連接埠。" + +msgid "Unknown error" +msgstr "未知錯誤" + +msgid "Use OpenWrt system firewall to manage NAT for Tailscale tunnel and subnet traffic. Disabling this option disables source address masquerading to preserve real source IPs of remote peers and subnets in the local network." +msgstr "由 OpenWrt 系統防火牆統一接管 Tailscale 隧道及對端子網流量的位址轉換。停用此項將停用來源位址偽裝,在區域網路中保留對端節點和子網的真實來源 IP。" + +msgid "Version" +msgstr "版本資訊" + +msgid "When enabled, blocks all inbound connections from the Tailscale network (--shields-up)." +msgstr "啟用後,阻止所有來自 Tailscale 網路的主動入站連線 (--shields-up)。" + +msgid "When using or advertising the exit node, allow access to the local LAN (--exit-node-allow-lan-access)." +msgstr "在使用或宣告出口節點時,允許存取本地區域網路裝置 (--exit-node-allow-lan-access)。" + +msgid "ago" +msgstr "前" diff --git a/luci-app-tailscale-next/root/etc/hotplug.d/iface/99-tailscale-routes b/luci-app-tailscale-next/root/etc/hotplug.d/iface/99-tailscale-routes new file mode 100755 index 000000000..769a1cbac --- /dev/null +++ b/luci-app-tailscale-next/root/etc/hotplug.d/iface/99-tailscale-routes @@ -0,0 +1,41 @@ +#!/bin/sh + +# Tailscale table 52 route recovery via tailscaled restart. +# +# Trigger: netifd brings tailscale0 back up (ifup) after an external +# interface rebuild (e.g. network reload, firewall reload). +# +# tailscaled maintains an in-memory route state that diverges from the +# kernel after netifd flushes tailscale0 routes. The only reliable +# fix is a full tailscaled restart, which re-initialises from a clean +# state and re-applies all table 52 routes from the current NetworkMap. +# +# Atomicity: a timestamp lock file (30 s TTL) prevents the recursive +# loop that would otherwise arise because the restart itself causes +# tailscale0 to be torn down and rebuilt, firing another ifup event. + +[ "$DEVICE" = "tailscale0" ] || exit 0 +[ "$ACTION" = "ifup" ] || exit 0 + +# Check if Tailscale service is enabled +service_enabled=$(uci -q get tailscale.settings.service_enabled 2>/dev/null || echo 0) +[ "$service_enabled" = "1" ] || exit 0 + +LOCK=/tmp/tailscale_hotplug.lock + +# Timestamp-based lock: bail out if another restart fired within 30 s. +if [ -f "$LOCK" ]; then + ts=$(cat "$LOCK" 2>/dev/null) + [ -n "$ts" ] && [ $(( $(date +%s) - ts )) -lt 30 ] && exit 0 +fi + +# Skip if tailscaled is not yet ready (normal first ifup during boot). +tailscale status >/dev/null 2>&1 || exit 0 + +# Record lock timestamp only after confirming service readiness +date +%s > "$LOCK" + +logger -t tailscale-hotplug \ + "tailscale0 ifup: restarting tailscaled to restore table 52 routes" + +/etc/init.d/tailscale restart diff --git a/luci-app-tailscale-next/root/etc/init.d/tailscale-settings b/luci-app-tailscale-next/root/etc/init.d/tailscale-settings new file mode 100755 index 000000000..a26672393 --- /dev/null +++ b/luci-app-tailscale-next/root/etc/init.d/tailscale-settings @@ -0,0 +1,457 @@ +#!/bin/sh /etc/rc.common + +USE_PROCD=1 +START=99 +STOP=10 + +NAME=tailscale-settings +EXTRA_COMMANDS="clean_firewall clean_firewall_rules" + +# Compare version strings: return 0 (true) if $1 >= $2 +version_gte() { + awk -v a="${1%%-*}" -v b="${2%%-*}" 'BEGIN { + split(a, av, ".") + split(b, bv, ".") + for (i=1; i<=3; i++) { + ai = int(av[i]+0); bi = int(bv[i]+0) + if (ai > bi) { exit 0 } + if (ai < bi) { exit 1 } + } + exit 0 + }' + return $? +} + +# Add/remove DNS forward entry in dnsmasq for MagicDNS via OpenWrt +setup_tailscale_dns_forward() { + if [ ! -x /usr/sbin/tailscale ] && [ ! -x /usr/bin/tailscale ]; then + return 0 + fi + + local entry="/.ts.net/100.100.100.100" + local count + count=$(uci -q get dhcp.@dnsmasq[0].server 2>/dev/null | tr ' ' '\n' | grep -Fxc "$entry" 2>/dev/null) + + if [ "$count" -gt 1 ]; then + uci -q del_list dhcp.@dnsmasq[0].server="$entry" + uci add_list dhcp.@dnsmasq[0].server="$entry" + uci commit dhcp + /etc/init.d/dnsmasq reload 2>/dev/null || /etc/init.d/dnsmasq restart 2>/dev/null + logger -t "$NAME" "Collapsed duplicate DNS forward entries" + elif [ "$count" -eq 0 ]; then + uci add_list dhcp.@dnsmasq[0].server="$entry" + uci commit dhcp + /etc/init.d/dnsmasq reload 2>/dev/null || /etc/init.d/dnsmasq restart 2>/dev/null + logger -t "$NAME" "Added DNS forward: $entry" + fi +} + +remove_tailscale_dns_forward() { + local entry="/.ts.net/100.100.100.100" + local idx=0 + local removed=0 + while true; do + local val + val=$(uci -q get dhcp.@dnsmasq[0].server."${idx}" 2>/dev/null) + [ -z "$val" ] && break + if [ "$val" = "$entry" ]; then + uci -q del_list dhcp.@dnsmasq[0].server="$entry" + removed=1 + break + fi + idx=$((idx + 1)) + done + + if [ "$removed" = "1" ]; then + uci commit dhcp + /etc/init.d/dnsmasq reload 2>/dev/null || /etc/init.d/dnsmasq restart 2>/dev/null + logger -t "$NAME" "Removed Tailscale DNS forward entry: $entry" + fi +} + +check_zone() { + local cfg="$1" + local name + config_get name "$cfg" name + if [ "$name" = "tailscale" ]; then + FOUND_TS_ZONE="$cfg" + fi +} + +check_forwarding() { + local cfg="$1" + local src dst + config_get src "$cfg" src + config_get dst "$cfg" dest + [ "$src" = "lan" ] && [ "$dst" = "tailscale" ] && FWD_LAN_TS=1 + [ "$src" = "tailscale" ] && [ "$dst" = "lan" ] && FWD_TS_LAN=1 + [ "$src" = "tailscale" ] && [ "$dst" = "wan" ] && FWD_TS_WAN=1 +} + +setup_firewall_rules() { + local changed_net=0 + local changed_fw=0 + + # 1. Interface (set both device and ifname for broad OpenWrt version compatibility) + local dev ifname + dev=$(uci -q get network.tailscale.device) + ifname=$(uci -q get network.tailscale.device) + + if [ -z "$dev" ] && [ -z "$ifname" ]; then + uci -q set network.tailscale=interface + uci -q set network.tailscale.proto='none' + uci -q set network.tailscale.device='tailscale0' + uci -q set network.tailscale.device='tailscale0' + changed_net=1 + else + if [ -z "$dev" ]; then + uci -q set network.tailscale.device='tailscale0' + changed_net=1 + fi + if [ -z "$ifname" ]; then + uci -q set network.tailscale.device='tailscale0' + changed_net=1 + fi + fi + + # 2. Zone + local tunnel_snat target_masq="0" + tunnel_snat=$(uci -q get tailscale.settings.tunnel_snat) + [ "$tunnel_snat" = "1" ] && target_masq="1" + + config_load firewall + FOUND_TS_ZONE="" + config_foreach check_zone zone + + if [ -z "$FOUND_TS_ZONE" ]; then + local zid + zid=$(uci -q add firewall zone) + uci -q set firewall."$zid".name='tailscale' + uci -q set firewall."$zid".input='ACCEPT' + uci -q set firewall."$zid".output='ACCEPT' + uci -q set firewall."$zid".forward='ACCEPT' + uci -q set firewall."$zid".masq="$target_masq" + uci -q set firewall."$zid".mtu_fix='1' + uci -q add_list firewall."$zid".network='tailscale' + changed_fw=1 + else + local cur_masq + cur_masq=$(uci -q get firewall."$FOUND_TS_ZONE".masq) + if [ "$cur_masq" != "$target_masq" ]; then + uci -q set firewall."$FOUND_TS_ZONE".masq="$target_masq" + changed_fw=1 + fi + + local has_net=0 + local net + for net in $(uci -q get firewall."$FOUND_TS_ZONE".network); do + [ "$net" = "tailscale" ] && has_net=1 + done + if [ "$has_net" = "0" ]; then + uci -q add_list firewall."$FOUND_TS_ZONE".network='tailscale' + changed_fw=1 + fi + fi + + # 3. Forwardings + FWD_LAN_TS=0 + FWD_TS_LAN=0 + FWD_TS_WAN=0 + config_foreach check_forwarding forwarding + + if [ "$FWD_LAN_TS" = "0" ]; then + local fid + fid=$(uci -q add firewall forwarding) + uci -q set firewall."$fid".src='lan' + uci -q set firewall."$fid".dest='tailscale' + changed_fw=1 + fi + if [ "$FWD_TS_LAN" = "0" ]; then + local fid=$(uci -q add firewall forwarding) + uci -q set firewall."$fid".src='tailscale' + uci -q set firewall."$fid".dest='lan' + changed_fw=1 + fi + if [ "$FWD_TS_WAN" = "0" ]; then + local fid=$(uci -q add firewall forwarding) + uci -q set firewall."$fid".src='tailscale' + uci -q set firewall."$fid".dest='wan' + changed_fw=1 + fi + + if [ "$changed_net" = "1" ]; then + uci -q commit network + logger -t "$NAME" "Auto configured tailscale network interface (tailscale0)" + fi + + if [ "$changed_fw" = "1" ]; then + uci -q commit firewall + /etc/init.d/firewall reload 2>/dev/null + logger -t "$NAME" "Auto configured tailscale firewall zone and forwarding rules" + fi +} + +clean_firewall_rules() { + local changed_net=0 + local changed_fw=0 + + # 1. Remove forwarding rules referencing tailscale + config_load firewall + local fwds_to_del="" + delete_ts_fwd() { + local cfg="$1" + local src dst + config_get src "$cfg" src + config_get dst "$cfg" dest + if [ "$src" = "tailscale" ] || [ "$dst" = "tailscale" ]; then + fwds_to_del="$fwds_to_del $cfg" + fi + } + config_foreach delete_ts_fwd forwarding + + for fwd in $fwds_to_del; do + uci -q delete firewall."$fwd" + changed_fw=1 + done + + # 2. Remove tailscale zone + local zones_to_del="" + find_ts_zone() { + local cfg="$1" + local name + config_get name "$cfg" name + if [ "$name" = "tailscale" ]; then + zones_to_del="$zones_to_del $cfg" + fi + } + config_foreach find_ts_zone zone + + for z in $zones_to_del; do + uci -q delete firewall."$z" + changed_fw=1 + done + + # 3. Remove network interface + if uci -q get network.tailscale >/dev/null; then + uci -q delete network.tailscale + changed_net=1 + fi + + # 4. Remove DNS forward entry in dnsmasq + remove_tailscale_dns_forward + + if [ "$changed_net" = "1" ]; then + uci -q commit network + logger -t "$NAME" "Cleaned tailscale network interface" + fi + + if [ "$changed_fw" = "1" ]; then + uci -q commit firewall + /etc/init.d/firewall reload 2>/dev/null + logger -t "$NAME" "Cleaned tailscale firewall zone and forwarding rules" + fi +} + +clean_firewall() { + clean_firewall_rules +} + +apply_settings() { + setup_firewall_rules + + local ts_bin + if [ -x /usr/sbin/tailscale ]; then + ts_bin=/usr/sbin/tailscale + elif [ -x /usr/bin/tailscale ]; then + ts_bin=/usr/bin/tailscale + else + logger -t "$NAME" "tailscale binary not found, skipping settings apply" + return 0 + fi + + config_load tailscale + + local accept_routes advertise_exit_node exit_node exit_node_allow_lan_access + local ssh shields_up runwebclient tunnel_snat hostname dns_mode + local enable_relay relay_server_port + + config_get accept_routes settings accept_routes '0' + config_get advertise_exit_node settings advertise_exit_node '0' + config_get exit_node settings exit_node '' + config_get exit_node_allow_lan_access settings exit_node_allow_lan_access '0' + config_get ssh settings ssh '0' + config_get dns_mode settings dns_mode 'disabled' + config_get shields_up settings shields_up '0' + config_get runwebclient settings runwebclient '0' + config_get tunnel_snat settings tunnel_snat '0' + config_get hostname settings hostname '' + config_get enable_relay settings enable_relay '0' + config_get relay_server_port settings relay_server_port '40000' + + # Collect advertise_routes UCI list into comma-separated string + local routes="" + append_route() { + if [ -z "$routes" ]; then + routes="$1" + else + routes="$routes,$1" + fi + } + config_list_foreach settings advertise_routes append_route + + # Build argument list for 'tailscale set' + set -- set + + # --accept-routes + [ "$accept_routes" = "1" ] \ + && set -- "$@" --accept-routes=true \ + || set -- "$@" --accept-routes=false + + # --advertise-exit-node (only when no exit node is selected) + [ "$advertise_exit_node" = "1" ] && [ -z "$exit_node" ] \ + && set -- "$@" --advertise-exit-node=true \ + || set -- "$@" --advertise-exit-node=false + + # --exit-node-allow-lan-access (only when no exit node is selected) + if [ -z "$exit_node" ]; then + [ "$exit_node_allow_lan_access" = "1" ] \ + && set -- "$@" --exit-node-allow-lan-access=true \ + || set -- "$@" --exit-node-allow-lan-access=false + fi + + # --ssh + [ "$ssh" = "1" ] \ + && set -- "$@" --ssh=true \ + || set -- "$@" --ssh=false + + # --accept-dns based on dns_mode + case "$dns_mode" in + magicdns) + set -- "$@" --accept-dns=true + remove_tailscale_dns_forward + ;; + openwrt_forward) + set -- "$@" --accept-dns=false + setup_tailscale_dns_forward + ;; + *) + # disabled (default) + set -- "$@" --accept-dns=false + remove_tailscale_dns_forward + ;; + esac + + # --shields-up + [ "$shields_up" = "1" ] \ + && set -- "$@" --shields-up=true \ + || set -- "$@" --shields-up=false + + # --webclient + [ "$runwebclient" = "1" ] \ + && set -- "$@" --webclient=true \ + || set -- "$@" --webclient=false + + # --snat-subnet-routes (aligned with tunnel_snat) + [ "$tunnel_snat" = "1" ] \ + && set -- "$@" --snat-subnet-routes=true \ + || set -- "$@" --snat-subnet-routes=false + + # --advertise-routes (comma-separated, empty clears routes) + set -- "$@" "--advertise-routes=$routes" + + # --exit-node (empty clears the exit node) + set -- "$@" "--exit-node=$exit_node" + + # when an exit node is configured, always allow LAN access + [ -n "$exit_node" ] && set -- "$@" --exit-node-allow-lan-access=true + + # --hostname (only set if non-empty) + [ -n "$hostname" ] && set -- "$@" "--hostname=$hostname" + + # --relay-server-port (requires tailscale >= 1.90.5) + local ts_ver + ts_ver=$("$ts_bin" version 2>/dev/null | head -n1) + if version_gte "$ts_ver" "1.90.5"; then + if [ "$enable_relay" = "1" ]; then + set -- "$@" "--relay-server-port=${relay_server_port:-40000}" + else + set -- "$@" "--relay-server-port=" + fi + fi + + # Always enforce netfilter-mode=off so OpenWrt manages all firewall rules cleanly + set -- "$@" --netfilter-mode=off + + # Wait for tailscaled daemon to be ready (up to 5 seconds) + local i=0 + local daemon_ready=0 + while [ $i -lt 5 ]; do + if "$ts_bin" status >/dev/null 2>&1; then + daemon_ready=1 + break + fi + if ! "$ts_bin" status 2>&1 | grep -q "failed to connect to local tailscaled"; then + daemon_ready=1 + break + fi + sleep 1 + i=$((i + 1)) + done + + if [ "$daemon_ready" = "0" ]; then + logger -t "$NAME" "Tailscale daemon is not running or socket is unreachable, skipping settings apply" + return 0 + fi + + # Check logged-in status. Skip applying settings if logged out to prevent command error. + local status_out + status_out=$("$ts_bin" status 2>&1) + if echo "$status_out" | grep -qE "Logged out|NeedsLogin|NeedsCard"; then + logger -t "$NAME" "Tailscale is logged out, saving settings locally but skipping immediate apply" + return 0 + fi + + logger -t "$NAME" "Applying settings via tailscale set" + "$ts_bin" "$@" + local ret=$? + if [ $ret -eq 0 ]; then + logger -t "$NAME" "Settings applied successfully" + else + logger -t "$NAME" "Failed to apply settings (exit code: $ret)" + fi + + return 0 +} + +handle_service_state() { + config_load tailscale + local service_enabled + config_get service_enabled settings service_enabled '0' + + if [ "$service_enabled" = "0" ]; then + logger -t "$NAME" "Service is disabled, stopping tailscale and cleaning up" + /etc/init.d/tailscale stop 2>/dev/null + /etc/init.d/tailscale disable 2>/dev/null + clean_firewall_rules + killall -9 tailscaled 2>/dev/null || true + else + logger -t "$NAME" "Service is enabled, ensuring tailscale is running" + /etc/init.d/tailscale enable 2>/dev/null + if ! /usr/sbin/tailscale status >/dev/null 2>&1 && ! /usr/bin/tailscale status >/dev/null 2>&1; then + /etc/init.d/tailscale start 2>/dev/null + fi + apply_settings + fi +} + +start_service() { + handle_service_state +} + +service_triggers() { + procd_add_reload_trigger "tailscale" +} + +reload_service() { + handle_service_state +} diff --git a/luci-app-tailscale-next/root/usr/share/luci/menu.d/luci-app-tailscale.json b/luci-app-tailscale-next/root/usr/share/luci/menu.d/luci-app-tailscale.json new file mode 100644 index 000000000..ed466fa9b --- /dev/null +++ b/luci-app-tailscale-next/root/usr/share/luci/menu.d/luci-app-tailscale.json @@ -0,0 +1,13 @@ +{ + "admin/vpn/tailscale": { + "title": "Tailscale", + "order": 90, + "action": { + "type": "view", + "path": "tailscale" + }, + "depends": { + "acl": [ "luci-app-tailscale" ] + } + } +} diff --git a/luci-app-tailscale-next/root/usr/share/rpcd/acl.d/luci-app-tailscale.json b/luci-app-tailscale-next/root/usr/share/rpcd/acl.d/luci-app-tailscale.json new file mode 100644 index 000000000..c35411134 --- /dev/null +++ b/luci-app-tailscale-next/root/usr/share/rpcd/acl.d/luci-app-tailscale.json @@ -0,0 +1,28 @@ +{ + "luci-app-tailscale": { + "description": "Allow user access to tailscale", + "read": { + "ubus": { + "tailscale": [ + "get_status", + "get_settings", + "get_subroutes", + "get_logs" + ] + }, + "uci": [ "tailscale" ] + }, + "write": { + "ubus": { + "tailscale": [ + "do_login", + "do_logout", + "restart", + "setup_firewall", + "reload_settings" + ] + }, + "uci": [ "tailscale" ] + } + } +} diff --git a/luci-app-tailscale-next/root/usr/share/rpcd/ucode/tailscale.uc b/luci-app-tailscale-next/root/usr/share/rpcd/ucode/tailscale.uc new file mode 100755 index 000000000..616548d1b --- /dev/null +++ b/luci-app-tailscale-next/root/usr/share/rpcd/ucode/tailscale.uc @@ -0,0 +1,578 @@ +#!/usr/bin/env ucode + +'use strict'; + +import { access, popen, readfile, writefile, unlink, stat } from 'fs'; +import { cursor } from 'uci'; + +const uci = cursor(); + +function exec(command) { + let stdout_content = ''; + let p = popen(command, 'r'); + if (p == null) { + return { code: -1, stdout: '', stderr: `Failed to execute: ${command}` }; + } + for (let line = p.read('line'); length(line); line = p.read('line')) { + stdout_content = stdout_content+line; + } + stdout_content = rtrim(stdout_content); + stdout_content = split(stdout_content, '\n'); + + let exit_code = p.close(); + let stderr_content = ''; + if (exit_code != 0) { + stderr_content = stdout_content; + } + return { code: exit_code, stdout: stdout_content, stderr: stderr_content }; +} + +function shell_quote(s) { + if (s == null || s == '') return "''"; + return "'" + replace(s, "'", "'\\''") + "'"; +} + +function get_connectivity_data() { + let ts_cmd = access('/usr/sbin/tailscale') ? '/usr/sbin/tailscale' : '/usr/bin/tailscale'; + if (!access(ts_cmd)) return null; + + const cache_file = '/tmp/tailscale_netcheck.json'; + const lock_file = '/tmp/tailscale_netcheck.lock'; + + let cached_data = null; + if (access(cache_file)) { + try { + let content = readfile(cache_file); + if (content != null && content != '') { + cached_data = json(content); + } + } catch(e) { /* ignore */ } + } + + let cache_st = access(cache_file) ? stat(cache_file) : null; + let now = time(); + if (!cache_st || (now - cache_st.mtime) > 60) { + let lock_st = access(lock_file) ? stat(lock_file) : null; + if (!lock_st || (now - lock_st.mtime) > 30) { + writefile(lock_file, '' + now); + system('(' + ts_cmd + ' netcheck --format=json > /tmp/tailscale_netcheck.tmp 2>/dev/null && mv /tmp/tailscale_netcheck.tmp /tmp/tailscale_netcheck.json; rm -f ' + lock_file + ') >/dev/null 2>&1 &'); + } + } + + return cached_data; +} + +function get_cached_version() { + const cache_file = '/tmp/tailscale_version.cache'; + const ts_bin = access('/usr/sbin/tailscale') ? '/usr/sbin/tailscale' : '/usr/bin/tailscale'; + if (!access(ts_bin)) return 'Unknown'; + + let bin_st = stat(ts_bin); + let cache_st = access(cache_file) ? stat(cache_file) : null; + + if (cache_st && cache_st.size > 0 && bin_st && cache_st.mtime >= bin_st.mtime) { + let cached = trim(readfile(cache_file) || ''); + if (cached != '') return cached; + } + + let ver_res = exec(ts_bin + ' version 2>/dev/null'); + if (ver_res.code == 0 && length(ver_res.stdout) > 0) { + let ver_line = trim(ver_res.stdout[0]); + if (ver_line != '') { + writefile(cache_file, ver_line + '\n'); + return ver_line; + } + } + return 'Unknown'; +} + +const methods = {}; + +methods.get_status = { + call: function() { + let data = { + status: '', + version: '', + TUNMode: '', + health: '', + ipv4: null, + ipv6: null, + domain_name: '', + peers: [], + connectivity: null + }; + + if (!access('/usr/sbin/tailscale') && !access('/usr/bin/tailscale')) { + data.status = 'not_installed'; + return data; + } + + data.version = get_cached_version(); + + let status_out = exec('tailscale status --json 2>/dev/null'); + let peer_map = {}; + + if (status_out.code == 0 && length(status_out.stdout) > 0) { + try { + let status_data = json(join('', status_out.stdout)); + if (status_data?.Version) { + data.version = status_data.Version; + } + data.health = status_data?.Health || ''; + data.TUNMode = (status_data?.TUN == false ? 'false' : 'true'); + if (status_data?.BackendState == 'NeedsLogin' || status_data?.AuthURL || status_data?.BackendState == 'NoState' || (status_data?.HaveNodeKey == false && status_data?.BackendState != 'Running')) { + data.status = 'logout'; + } else if (status_data?.BackendState == 'Running' && status_data?.HaveNodeKey == true) { + data.status = 'running'; + } else if (status_data?.HaveNodeKey == true) { + data.status = 'stopped'; + } else { + data.status = (status_data?.BackendState == 'Stopped') ? 'stopped' : 'logout'; + } + + if (status_data?.Self?.TailscaleIPs) { + data.ipv4 = status_data.Self.TailscaleIPs[0] || null; + data.ipv6 = status_data.Self.TailscaleIPs[1] || null; + } + + let account_str = ''; + if (status_data?.CurrentTailnet?.Name) { + account_str = status_data.CurrentTailnet.Name; + } + if (status_data?.Self?.UserID && status_data?.User) { + let uid = '' + status_data.Self.UserID; + let uinfo = status_data.User[uid]; + if (uinfo) { + let login_name = uinfo?.LoginName || uinfo?.Username || ''; + let display_name = uinfo?.DisplayName || ''; + if (login_name != '' && display_name != '' && login_name != display_name) { + account_str = login_name + ' (' + display_name + ')'; + } else if (login_name != '') { + account_str = login_name; + } else if (display_name != '') { + account_str = display_name; + } + } + } + if (account_str == '' && status_data?.Self?.DNSName) { + account_str = status_data.Self.DNSName; + } + data.account = account_str; + data.domain_name = account_str; + + if (status_data?.Peer) { + for (let id, p in status_data.Peer) { + let ips = ''; + if (p?.TailscaleIPs) { + ips = join('
', p.TailscaleIPs); + } + let hostname = p?.DNSName || ''; + if (hostname != '') { + let parts = split(hostname, '.'); + hostname = parts[0]; + } + + peer_map[id] = { + ip: ips, + hostname: hostname, + ostype: p?.OS, + online: p?.Online || false, + linkadress: (!p?.CurAddr || p?.CurAddr == '') ? p?.Relay : p?.CurAddr, + lastseen: p?.LastSeen, + exit_node: !(!p?.ExitNode), + exit_node_option: !(!p?.ExitNodeOption), + tx: p?.TxBytes || '', + rx: p?.RxBytes || '' + }; + } + } + + if (data.status == 'running') { + let netcheck = get_connectivity_data(); + let derp_lat_str = ''; + let pref_derp = netcheck?.PreferredDERP || status_data?.Self?.Relay || 0; + if (pref_derp != 0 && pref_derp != '' && netcheck?.DERPLatencies) { + let lat = netcheck.DERPLatencies['' + pref_derp]; + if (lat) { + derp_lat_str = sprintf('%d ms', lat * 1000); + } + } + + let ep_list = []; + if (status_data?.Self?.Addrs && length(status_data.Self.Addrs) > 0) { + ep_list = status_data.Self.Addrs; + } else if (status_data?.Self?.Endpoints && length(status_data.Self.Endpoints) > 0) { + ep_list = status_data.Self.Endpoints; + } else if (netcheck) { + if (netcheck.GlobalV4) push(ep_list, netcheck.GlobalV4); + if (netcheck.GlobalV6) push(ep_list, netcheck.GlobalV6); + if (netcheck.GlobalAddrs && length(netcheck.GlobalAddrs) > 0) { + for (let a in netcheck.GlobalAddrs) push(ep_list, a); + } + } + + data.connectivity = { + varies: (netcheck?.MappingVariesByDestIP == true) ? 'Yes' : 'No', + ipv4: (netcheck?.IPv4 == true || (ep_list && length(ep_list) > 0)) ? 'Yes' : 'No', + ipv6: (netcheck?.IPv6 == true) ? 'Yes' : 'No', + udp: (netcheck?.UDP == true || (ep_list && length(ep_list) > 0)) ? 'Yes' : 'No', + upnp: (netcheck?.UPnP == true) ? 'Yes' : 'No', + pcp: (netcheck?.PCP == true) ? 'Yes' : 'No', + pmp: (netcheck?.PMP == true) ? 'Yes' : 'No', + hairpinning: (netcheck?.HairPinning == true) ? 'Yes' : 'No', + preferred_derp: pref_derp, + derp_latency: derp_lat_str, + endpoints: ep_list + }; + } + } catch (e) { /* ignore */ } + } else { + uci.load('tailscale'); + let state_file_path = uci.get('tailscale', 'settings', 'state_file') || "/etc/tailscale/tailscaled.state"; + let has_state = false; + if (access(state_file_path) && stat(state_file_path)?.size > 0) { + has_state = true; + } else if (access('/var/lib/tailscale/tailscaled.state') && stat('/var/lib/tailscale/tailscaled.state')?.size > 0) { + has_state = true; + } + data.status = has_state ? 'stopped' : 'logout'; + } + + data.peers = peer_map; + return data; + } +}; + +methods.get_settings = { + call: function() { + let settings = {}; + uci.load('tailscale'); + let state_file_path = uci.get('tailscale', 'settings', 'state_file') || "/etc/tailscale/tailscaled.state"; + if (access(state_file_path)) { + try { + let state_content = readfile(state_file_path); + if (state_content != null) { + let state_data = json(state_content); + let profiles_b64 = state_data?._profiles; + if (!profiles_b64) return settings; + + let profiles_data = json(b64dec(profiles_b64)); + let profiles_key = null; + for (let key in profiles_data) { + profiles_key = key; + break; + } + profiles_key = 'profile-'+profiles_key; + + let status_data = json(b64dec(state_data?.[profiles_key])); + if (status_data != null) { + settings.accept_routes = status_data?.RouteAll || false; + settings.advertise_exit_node = status_data?.AdvertiseExitNode || false; + settings.advertise_routes = status_data?.AdvertiseRoutes || []; + settings.exit_node = status_data?.ExitNodeID || ""; + settings.exit_node_allow_lan_access = status_data?.ExitNodeAllowLANAccess || false; + settings.shields_up = status_data?.ShieldsUp || false; + settings.ssh = status_data?.RunSSH || false; + settings.runwebclient = status_data?.RunWebClient || false; + settings.tunnel_snat = uci.get('tailscale', 'settings', 'tunnel_snat') || '0'; + settings.dns_mode = uci.get('tailscale', 'settings', 'dns_mode') || 'disabled'; + } + } + } catch (e) { /* ignore */ } + } + return settings; + } +}; + + +methods.do_login = { + args: { form_data: {} }, + call: function(request) { + const form_data = request.args.form_data; + if (form_data == null || length(form_data) == 0) { + return { error: 'Missing or invalid form_data parameter. Please provide login data.' }; + } + + let status = methods.get_status.call(); + if (status.status == 'running') { + return { error: 'Tailscale is already logged in and running.' }; + } + + let status_test = exec('tailscale status >/dev/null 2>&1'); + if (status_test.code != 0) { + exec('/etc/init.d/tailscale start >/dev/null 2>&1'); + for (let i = 0; i < 3; i++) { + sleep(1000); + let res = exec('tailscale status >/dev/null 2>&1'); + if (res.code == 0) break; + } + } + + // --- 1. Prepare and Run Login Command (Once) --- + let loginargs = []; + const loginserver = trim(form_data.loginserver) || ''; + const loginserver_authkey = trim(form_data.loginserver_authkey) || ''; + + if (loginserver != '') { + push(loginargs, '--login-server ' + shell_quote(loginserver)); + if (loginserver_authkey != '') { + push(loginargs, '--auth-key ' + shell_quote(loginserver_authkey)); + } + } + + // Run the command in the background using /bin/sh -c to handle the '&' correctly + let login_cmd = 'tailscale login ' + join(' ', loginargs); + popen('/bin/sh -c ' + shell_quote(login_cmd + ' &'), 'r'); + + // --- 2. Loop to Check Status for URL --- + let max_attempts = 15; + let interval = 2000; + + for (let i = 0; i < max_attempts; i++) { + let tresult = exec('tailscale status 2>&1'); + for (let line in tresult.stdout) { + let trline = trim(line); + if (index(trline, 'http') != -1) { + let parts = split(trline, ' '); + for (let part in parts) { + if (index(part, 'http') != -1) { + return { url: part }; + } + } + } + } + sleep(interval); + } + return { error: 'Could not retrieve login URL from tailscale command after 30 seconds.' }; + } +}; + +methods.do_logout = { + call: function() { + let status = methods.get_status.call(); + if (status.status == 'not_installed') { + return { error: 'Tailscale is not installed.' }; + } + if (status.status == 'logout') { + return { success: true }; + } + + uci.load('tailscale'); + let state_file_path = uci.get('tailscale', 'settings', 'state_file') || "/etc/tailscale/tailscaled.state"; + + if (status.status == 'running') { + exec('tailscale logout 2>/dev/null'); + } else { + exec('/etc/init.d/tailscale start >/dev/null 2>&1'); + for (let i = 0; i < 3; i++) { + sleep(1000); + let res = exec('tailscale logout 2>/dev/null'); + if (res.code == 0) break; + } + } + + exec('/etc/init.d/tailscale stop >/dev/null 2>&1'); + + if (access(state_file_path)) { + unlink(state_file_path); + } + + return { success: true }; + } +}; + +methods.get_subroutes = { + call: function() { + try { + let cmd = 'ip -j route'; + let result = exec(cmd); + let subnets = []; + + if (result.code == 0 && length(result.stdout) > 0) { + let routes_json = json(join('',result.stdout)); + + for (let route in routes_json) { + // We need to filter out local subnets + // 1. 'dst' (target address) is not' default' (default gateway) + // 2. 'scope' is' link' (indicating directly connected network) + // 3. It is an IPv4 address (simple judgment: including'.') + if (route?.dst && route.dst != 'default' && route?.scope == 'link' && index(route.dst,'.') != -1) { + push(subnets,route.dst); + } + } + } + return { routes: subnets }; + } + catch(e) { + return { routes: [] }; + } + } +}; + +methods.setup_firewall = { + call: function() { + try { + uci.load('tailscale'); + + uci.load('network'); + uci.load('firewall'); + + let changed_network = false; + let changed_firewall = false; + + // 1. config Network Interface + let net_ts = uci.get('network', 'tailscale'); + if (net_ts == null) { + uci.set('network', 'tailscale', 'interface'); + uci.set('network', 'tailscale', 'proto', 'none'); + uci.set('network', 'tailscale', 'device', 'tailscale0'); + changed_network = true; + } else { + let current_dev = uci.get('network', 'tailscale', 'device'); + if (current_dev != 'tailscale0') { + uci.set('network', 'tailscale', 'device', 'tailscale0'); + changed_network = true; + } + } + + // 2. config Firewall Zone + let ts_zone_section = null; + let fwd_lan_to_ts = false; + let fwd_ts_to_lan = false; + + uci.foreach('firewall', 'zone', function(s) { + if (s['name'] == 'tailscale') + ts_zone_section = s['.name']; + }); + uci.foreach('firewall', 'forwarding', function(s) { + if (s['src'] == 'lan' && s['dest'] == 'tailscale') fwd_lan_to_ts = true; + if (s['src'] == 'tailscale' && s['dest'] == 'lan') fwd_ts_to_lan = true; + }); + + let tunnel_snat = uci.get('tailscale', 'settings', 'tunnel_snat') || '0'; + let target_masq = (tunnel_snat == '1') ? '1' : '0'; + + if (ts_zone_section == null) { + let zid = uci.add('firewall', 'zone'); + uci.set('firewall', zid, 'name', 'tailscale'); + uci.set('firewall', zid, 'input', 'ACCEPT'); + uci.set('firewall', zid, 'output', 'ACCEPT'); + uci.set('firewall', zid, 'forward', 'ACCEPT'); + uci.set('firewall', zid, 'masq', target_masq); + uci.set('firewall', zid, 'mtu_fix', '1'); + uci.set('firewall', zid, 'network', ['tailscale']); + changed_firewall = true; + } else { + let cur_masq = uci.get('firewall', ts_zone_section, 'masq'); + if (cur_masq != target_masq) { + uci.set('firewall', ts_zone_section, 'masq', target_masq); + changed_firewall = true; + } + let nets = uci.get('firewall', ts_zone_section, 'network'); + let net_list = []; + let has_ts_net = false; + + if (type(nets) == 'array') { + net_list = nets; + } else if (type(nets) == 'string') { + net_list = [nets]; + } + + // check if 'tailscale' is already in the list + for (let n in net_list) { + if (n == 'tailscale') { + has_ts_net = true; + break; + } + } + + if (!has_ts_net) { + push(net_list, 'tailscale'); + uci.set('firewall', ts_zone_section, 'network', net_list); + changed_firewall = true; + } + } + + // 3. config Forwarding + if (!fwd_lan_to_ts) { + let fid = uci.add('firewall', 'forwarding'); + uci.set('firewall', fid, 'src', 'lan'); + uci.set('firewall', fid, 'dest', 'tailscale'); + changed_firewall = true; + } + + if (!fwd_ts_to_lan) { + let fid = uci.add('firewall', 'forwarding'); + uci.set('firewall', fid, 'src', 'tailscale'); + uci.set('firewall', fid, 'dest', 'lan'); + changed_firewall = true; + } + + // Exit node requires WAN <- tailscale forwarding + let fwd_ts_to_wan = false; + uci.foreach('firewall', 'forwarding', function(s) { + if (s['src'] == 'tailscale' && s['dest'] == 'wan') fwd_ts_to_wan = true; + }); + + if (!fwd_ts_to_wan) { + let fid = uci.add('firewall', 'forwarding'); + uci.set('firewall', fid, 'src', 'tailscale'); + uci.set('firewall', fid, 'dest', 'wan'); + changed_firewall = true; + } + + // 4. save + if (changed_network) { + uci.save('network'); + uci.commit('network'); + } + + if (changed_firewall) { + uci.save('firewall'); + uci.commit('firewall'); + exec('/etc/init.d/firewall reload'); + } + + return { + success: true, + changed_network: changed_network, + changed_firewall: changed_firewall, + message: (changed_network || changed_firewall) ? 'Tailscale firewall/interface configuration applied.' : 'Tailscale firewall/interface already configured.' + }; + + } catch (e) { + return { error: 'Exception in setup_firewall: ' + e + '\nStack: ' + (e.stacktrace || '') }; + } + } +}; + +methods.get_logs = { + args: { lines: 200 }, + call: function(request) { + let lines = int(request?.args?.lines) || 200; + let cmd = 'logread -l ' + lines + ' 2>/dev/null | grep -i -E "tailscale" || true'; + let result = exec(cmd); + if (result.code == 0) { + return { logs: result.stdout }; + } + return { logs: [], error: join(' ', result.stderr) }; + } +}; + +methods.reload_settings = { + call: function() { + exec('/etc/init.d/tailscale-settings reload'); + return { success: true }; + } +}; + +methods.restart = { + call: function() { + writefile('/tmp/tailscale_hotplug.lock', '' + time() + '\n'); + exec('/etc/init.d/tailscale restart'); + exec('/etc/init.d/tailscale-settings reload'); + return { success: true }; + } +}; + +return { 'tailscale': methods }; diff --git a/luci-app-tailscale/.gitignore b/luci-app-tailscale/.gitignore new file mode 100644 index 000000000..85df44477 --- /dev/null +++ b/luci-app-tailscale/.gitignore @@ -0,0 +1,2 @@ +.DS_Store +*/.DS_Store diff --git a/luci-app-tailscale/.superpowers/sdd/final-review-fix-report.md b/luci-app-tailscale/.superpowers/sdd/final-review-fix-report.md new file mode 100644 index 000000000..b8f478fed --- /dev/null +++ b/luci-app-tailscale/.superpowers/sdd/final-review-fix-report.md @@ -0,0 +1,210 @@ +# Final Review Fix Report + +## Scope completed + +All six final-review corrections were implemented as one fix wave: + +1. Added an nftables table-generation guard before handle caching, made its deletion the first operation in the atomic reconciliation batch, and covered success, failure, signal, cleanup, and deterministic table-replacement paths. +2. Distinguished a verified absent UCI `enabled` option from UCI section/read failures. +3. Added `nft -j list tables` inventory checks so verified `inet fw4` absence remains non-fatal while inventory, table-read, and malformed-JSON failures remain errors. +4. Reordered cleanup to remove nftables rules before the hook, retained the hook when nftables cleanup failed, and made disabled status report managed residue. +5. Bumped `PKG_VERSION` to `1.2.8`. +6. Made status require the canonical managed hook body and exact structured nftables rule expressions while allowing dynamic counter values. + +A second independent whole-branch review found five additional reconciliation issues, which were fixed before deployment: + +1. Chain reads now require structurally valid nftables JSON with exactly one matching chain object and correctly scoped rules with numeric handles. +2. The command used by the official OpenClash hook now performs full cleanup when the feature is disabled, including removal of the managed hook. +3. A failed or interrupted independent generation-guard deletion remains tracked so the EXIT trap can retry it. +4. Status validates UCI and disabled managed artifacts before treating an absent `inet fw4` table as unsupported. +5. If any target OpenClash chain is absent, reconciliation removes any surviving package-owned partial rule set and waits without installing a subset. + +A final focused review found one remaining apply-time race: a target chain could disappear after the initial complete table snapshot but before its individual chain read. That path now removes package-owned rules from all surviving chains instead of returning with a partial rule set. + +## Strict TDD evidence + +Temporary test paths in two failure messages are normalized as ``; all other output is verbatim. + +### Correction 1: table-generation guard + +- RED command: `sh tests/tailscale_openclash_bypass_test.sh` +- RED result: exit 1, `FAIL: apply must create a named counter before caching owned handles` +- GREEN command: `sh tests/tailscale_openclash_bypass_test.sh` +- GREEN result: exit 0, `tailscale OpenClash bypass tests passed` + +### Correction 2: UCI absence versus failure + +- RED command: `sh tests/tailscale_openclash_bypass_test.sh` +- RED result: exit 1, `FAIL: /uci.log expected 1 exact occurrences of -q show tailscale_openclash.settings, got 0` +- GREEN command: `sh tests/tailscale_openclash_bypass_test.sh` +- GREEN result: exit 0, `tailscale OpenClash bypass tests passed` + +### Correction 3: nftables table inventory + +- RED command: `sh tests/tailscale_openclash_bypass_test.sh` +- RED result: exit 1, `FAIL: /nft.calls expected 1 occurrences of -j list tables, got 0` +- GREEN command: `sh tests/tailscale_openclash_bypass_test.sh` +- GREEN result: exit 0, `tailscale OpenClash bypass tests passed` + +### Correction 4: retryable cleanup and disabled residue + +- RED command: `sh tests/tailscale_openclash_bypass_test.sh` +- RED result: exit 1, `FAIL: false alias 0 did not report disabled` +- GREEN command: `sh tests/tailscale_openclash_bypass_test.sh` +- GREEN result: exit 0, `tailscale OpenClash bypass tests passed` + +### Correction 5: release version + +- RED command: `sh tests/package_release_test.sh` +- RED result: exit 1, `FAIL: Makefile should contain: PKG_VERSION:=1.2.8` +- GREEN command: `sh tests/package_release_test.sh` +- GREEN result: exit 0, `package release tests passed` + +### Correction 6: canonical status validation + +- RED command: `sh -n tests/tailscale_openclash_bypass_test.sh && sh tests/tailscale_openclash_bypass_test.sh` +- RED result: exit 1, `FAIL: status accepted a noncanonical managed hook body` +- GREEN command: `sh -n root/usr/sbin/tailscale_openclash_bypass && sh tests/tailscale_openclash_bypass_test.sh` +- GREEN result: exit 0, `tailscale OpenClash bypass tests passed` + +### Second review: strict chain JSON validation + +- RED command: `sh tests/tailscale_openclash_bypass_test.sh` +- RED result: exit 1, `FAIL: apply accepted structurally invalid chain JSON mode empty` +- GREEN command: `sh tests/tailscale_openclash_bypass_test.sh` +- GREEN result: exit 0, `tailscale OpenClash bypass tests passed` +- Compatibility evidence: a read-only query against the office OpenWrt 24.10.5 / nftables 1.1.1 returned the expected `metainfo`, matching `chain`, and scoped `rule` objects with numeric handles. The fake nft fixture now includes that real top-level structure. + +### Second review: retryable hook cleanup and generation guard + +- Added regression coverage for the actual official hook path (`apply` while disabled), independent guard-deletion failure, and termination during guard deletion. +- GREEN result: `tailscale OpenClash bypass tests passed` + +### Second review: status ordering and partial-rule cleanup + +- Added regression coverage for invalid UCI with no fw4 table, disabled hook residue with no fw4 table, clean disabled state with no fw4 table, and a missing target chain with surviving partial owned rules. +- GREEN result: `tailscale OpenClash bypass tests passed` + +### Final review: apply-time chain disappearance + +- RED command: `sh tests/tailscale_openclash_bypass_test.sh` +- RED result: exit 1, `FAIL: apply did not clean surviving owned rules after a target chain disappeared during inspection` +- GREEN command: `sh -n root/usr/sbin/tailscale_openclash_bypass && sh -n tests/tailscale_openclash_bypass_test.sh && sh tests/tailscale_openclash_bypass_test.sh` +- GREEN result: exit 0, `tailscale OpenClash bypass tests passed` + +### Office preflight: OpenWrt applet compatibility + +- A read-only preflight on the office OpenWrt 24.10.5 image confirmed that neither `stat` nor `od` is installed or provided as a BusyBox applet. +- RED command: `sh tests/tailscale_openclash_bypass_test.sh` +- RED result: exit 1 after fake target-image `stat` and `od` commands returned 127. +- Replaced hook metadata reads with POSIX `ls -ldn | awk` parsing. The parser preserves owner/group, ordinary mode bits, and setuid/setgid/sticky bits. +- Replaced single-byte hexadecimal inspection with `dd | grep` newline detection, using commands present on the target image. +- Added regression coverage that hides `stat` and `od` from the helper and verifies numeric restoration of mode `6750` after atomic replacement. +- GREEN command: `sh tests/tailscale_openclash_bypass_test.sh` +- GREEN result: exit 0, `tailscale OpenClash bypass tests passed` + +### Compatibility re-review: byte-read errors and special modes + +- A fresh independent review found that a POSIX pipeline exposed only the final `grep` status, so an underlying `dd` failure could be mistaken for an ordinary non-newline byte at the optional suffix boundary. +- RED command: `sh tests/tailscale_openclash_bypass_test.sh` +- RED result: exit 1, `FAIL: reconcile-hook must fail when the optional-newline probe cannot be read` +- The byte probe now appends and parses the `dd` status without losing a leading newline to command substitution. It returns three distinct states: newline, non-newline/EOF, and read error. +- Both optional-newline call sites now abort atomic replacement on a read error. +- Added special-mode reconciliation and cleanup coverage for `4640`, `1750`, `1751`, and `7777`, in addition to the existing `6750` check. This covers uppercase and lowercase setuid/setgid/sticky renderings (`S/s/T/t`). +- GREEN command: `sh tests/tailscale_openclash_bypass_test.sh` +- GREEN result: exit 0, `tailscale OpenClash bypass tests passed` + +The complete 11-file shell suite and 4-file JavaScript suite also passed after the compatibility correction GREEN. + +## Final verification + +Focused checks: + +```sh +sh -n root/usr/sbin/tailscale_openclash_bypass && \ +sh -n tests/tailscale_openclash_bypass_test.sh && \ +sh tests/tailscale_openclash_bypass_test.sh +sh -n tests/package_release_test.sh && sh tests/package_release_test.sh +``` + +Result: both passed. + +Complete shell suite: + +```sh +set -eu +count=0 +for test_file in tests/*_test.sh; do + sh "$test_file" + count=$((count + 1)) +done +printf 'shell test files passed: %s\n' "$count" +``` + +Latest result after the target-image compatibility correction: `shell test files passed: 11`. + +Complete JavaScript suite: + +```sh +set -eu +count=0 +for test_file in tests/*_test.js; do + node "$test_file" + count=$((count + 1)) +done +printf 'JavaScript test files passed: %s\n' "$count" +``` + +Latest result after the target-image compatibility correction: `JavaScript test files passed: 4`. + +Static gates: + +```sh +for file in .github/scripts/*.sh; do bash -n "$file"; done +for file in root/etc/init.d/tailscale root/etc/init.d/tailscale-openclash-bypass root/etc/hotplug.d/iface/* root/etc/uci-defaults/* root/usr/libexec/rpcd/* root/usr/sbin/tailscale_* tests/*_test.sh; do sh -n "$file"; done +for file in htdocs/luci-static/resources/view/tailscale/*.js tests/*_test.js; do node --check "$file"; done +for file in root/usr/share/luci/menu.d/*.json root/usr/share/rpcd/acl.d/*.json; do jq -e . "$file" >/dev/null; done +for file in po/templates/*.pot po/*/*.po; do msgfmt --check-format -o /dev/null "$file"; done +git diff --check +``` + +Latest results: 23 shell syntax files, 8 JavaScript syntax files, 2 JSON files, 3 translation files, both the whole-branch and working-tree `git diff --check` checks passed. + +Ownership-isolation searches returned no matches: + +```sh +rg -n '/etc/config/firewall|uci (set|delete|commit) firewall|/etc/init.d/firewall|fw4 (reload|restart)' root/usr/sbin/tailscale_openclash_bypass root/etc/init.d/tailscale-openclash-bypass +rg -n 'tailscale_openclash_bypass|openclash_custom_firewall_rules' root/usr/sbin/tailscale_helper root/etc/init.d/tailscale +rg -n '/etc/init.d/openclash (start|stop|restart|reload|enable|disable)' root/usr/sbin/tailscale_openclash_bypass root/etc/init.d/tailscale-openclash-bypass +``` + +No Docker, MkDocs, router write, service reload/restart, GitHub, or production mutation command was used. Read-only SSH checks queried the office OpenWrt's nftables JSON and available applets for compatibility evidence; `hzsls-openwrt` was not accessed. + +## Files changed + +- `Makefile` +- `root/usr/sbin/tailscale_openclash_bypass` +- `tests/package_release_test.sh` +- `tests/tailscale_openclash_bypass_test.sh` +- `.superpowers/sdd/final-review-fix-report.md` + +The pre-existing modifications to task-1 through task-4 reports were preserved byte-for-byte relative to their initial working-tree diff and excluded from staging. + +## Self-review + +- Reviewed the complete production and test diff against all six required corrections. +- Confirmed the generation-replacement fake preserves unrelated rules that reuse stale handles. +- Confirmed guard cleanup paths cover success, batch failure, signal exit, no-op cleanup, and table replacement. +- Confirmed status rejects noncanonical hooks, altered/overbroad/incomplete/wrong-chain rules, duplicate rules, and unexpected package-owned rules while accepting changing counter values. +- Confirmed cleanup failure leaves the hook retryable and disabled status reports both hook and rule residue. +- Confirmed structurally invalid chain JSON cannot trigger apply or cleanup mutation. +- Confirmed a missing target chain removes surviving partial owned rules and never installs an incomplete set. +- Confirmed a target chain disappearing after the initial snapshot also removes the surviving partial owned rules and reports a residue-free waiting state. +- Confirmed OpenWrt 24.10.5 / nftables 1.1.1 real chain JSON satisfies the strict validator. +- Confirmed only the five files listed above belong to this fix wave. + +## Remaining concerns + +Office deployment and device-level behavior verification remain pending execution of the approved backup/change/verification/rollback plan. No deployment write has started yet. + +The final independent re-review of commit `94eb302` reported: `未发现可操作问题,READY`. diff --git a/luci-app-tailscale/.superpowers/sdd/task-1-report.md b/luci-app-tailscale/.superpowers/sdd/task-1-report.md new file mode 100644 index 000000000..363fb32de --- /dev/null +++ b/luci-app-tailscale/.superpowers/sdd/task-1-report.md @@ -0,0 +1,36 @@ +# Task 1 Report: Tailscale peer observability helper + +## Completed +- Added `tests/tailscale_peer_probe_test.sh` with RED-first coverage for: + - successful direct peer latency output + - successful DERP peer relay + latency + - unknown format handling + - ping failure path + - invalid peer input + - missing argument usage response +- Added `root/usr/sbin/tailscale_peer_probe` implementation. +- Extended `tests/package_release_test.sh` assertions for: + - helper file existence + - ACL references to `tailscale_peer_probe` + - "Peer probe" comment marker in helper + +## Test results +- `sh tests/tailscale_peer_probe_test.sh` -> **pass** +- `sh tests/package_release_test.sh` -> **fail** at `assert_contains "tailscale_peer_probe" root/usr/share/rpcd/acl.d/luci-app-tailscale.json` + +## Commit +- `08259ed` (`Add Tailscale peer probe helper`) + +## Remaining concern +- The package release test failure is expected for Task 1 because ACL/menu registration is not yet implemented in later tasks. + +## Review Findings Fix (Task 1 Follow-up) +- Removed the premature ACL assertion from `tests/package_release_test.sh` so Task 1 static tests no longer validate ACL registration. +- Expanded `json_escape` in `root/usr/sbin/tailscale_peer_probe` to escape control characters in raw output: backslash, double quote, tab, carriage return, and newline. +- Extended `tests/tailscale_peer_probe_test.sh` with a multiline control-peer output case covering escaped `\"`, `\\`, `\\t`, `\\r`, and `\\n`, and an explicit single-line JSON output assertion. + +### Verification commands +- `sh tests/tailscale_peer_probe_test.sh` + - Output: `tailscale_peer_probe tests passed` +- `sh tests/package_release_test.sh` + - Output: `package release tests passed` diff --git a/luci-app-tailscale/.superpowers/sdd/task-2-report.md b/luci-app-tailscale/.superpowers/sdd/task-2-report.md new file mode 100644 index 000000000..56a0781eb --- /dev/null +++ b/luci-app-tailscale/.superpowers/sdd/task-2-report.md @@ -0,0 +1,35 @@ +# Task 2 Report: Menu and ACL + +Status: completed + +Date (UTC): 2026-07-13 18:45:31 UTC + +## Implemented +- Added menu route entry assertions in `tests/package_release_test.sh`: + - `admin/vpn/tailscale/peers` + - `"title": "Peers"` + - `"path": "tailscale/peers"` + - order assertions before interface and log routes. +- Added ACL assertion in `tests/package_release_test.sh` for: + - `"/usr/sbin/tailscale_peer_probe": [ "exec" ]` +- Updated `root/usr/share/luci/menu.d/luci-app-tailscale.json`: + - inserted `admin/vpn/tailscale/peers` with title `Peers`, order `25`, and view path `tailscale/peers`. +- Updated `root/usr/share/rpcd/acl.d/luci-app-tailscale.json`: + - added `"/usr/sbin/tailscale_peer_probe": [ "exec" ]` under `"read" -> "file"`. + +## Verification +- `sh tests/package_release_test.sh` + - before implementation: failed at missing `admin/vpn/tailscale/peers` assertion + - after implementation: `package release tests passed` + +## Follow-up Fix (Task 2.i) +- Added localization entry for menu title `Peers` to `po/templates/tailscale.pot` with references to `root/usr/share/luci/menu.d/luci-app-tailscale.json:30`. +- Added `msgstr "对端列表"` for Simplified Chinese in `po/zh_Hans/tailscale.po`. +- Added `msgstr "對端列表"` for Traditional Chinese in `po/zh_Hant/tailscale.po`. +- Added focused assertions in `tests/package_release_test.sh` for: + - `msgid "Peers"` in `po/templates/tailscale.pot`, `po/zh_Hans/tailscale.po`, `po/zh_Hant/tailscale.po` + - `msgstr "对端列表"` in `po/zh_Hans/tailscale.po` + - `msgstr "對端列表"` in `po/zh_Hant/tailscale.po` +- Ran: + - `sh tests/package_release_test.sh` + - Result: `package release tests passed` diff --git a/luci-app-tailscale/.superpowers/sdd/task-3-report.md b/luci-app-tailscale/.superpowers/sdd/task-3-report.md new file mode 100644 index 000000000..a9ee7542d --- /dev/null +++ b/luci-app-tailscale/.superpowers/sdd/task-3-report.md @@ -0,0 +1,38 @@ +# Task 3 Report: Tailscale Peers LuCI View + +## Summary +- Added the read-only LuCI peers page at `admin/vpn/tailscale/peers`. +- The page loads `tailscale status --json`, lists all peers, supports `all`, `online`, and `advertising subnets` filters, and adds a per-peer `Probe` action that calls `/usr/sbin/tailscale_peer_probe`. +- Probe results render the path label as `Direct`, `DERP`, `Failed`, or `Unknown` with the helper summary. + +## Changed Files +- `htdocs/luci-static/resources/view/tailscale/peers.js` +- `tests/package_release_test.sh` + +## RED / GREEN +- RED: `sh tests/package_release_test.sh` + - Result: `FAIL: missing required package file: htdocs/luci-static/resources/view/tailscale/peers.js` +- GREEN: `node --check htdocs/luci-static/resources/view/tailscale/peers.js` + - Result: exit `0` +- GREEN: `sh tests/package_release_test.sh` + - Result: `package release tests passed` + +## Self-Review +- The page is read-only: no save/apply/reset handlers and no reload actions. +- The helper call is per-peer only; there is no bulk probe control. +- Status parsing reads all peers from `tailscale status --json`, not just subnet advertisers. +- Filter state, probe state, and result rendering are kept local to the view and survive polling refreshes. + +## Concerns +- None. + +## Task 3 Fix (2026-07-14) +- `loadPeerState()` now checks `res.code` after `tailscale status --json` execution; when `res.code !== 0`, it returns `{ ok: false, peers: [], error: }` without parsing status. +- `refreshPeers()` now clears stale rows on failed refresh by setting `state.peers = []` and `state.error = next.error` when `next.ok === false`. +- Behavior remains read-only (no reload/restart/network side effects introduced). + +### Verification +- Command: `node --check htdocs/luci-static/resources/view/tailscale/peers.js` + - Result: syntax check passed (exit 0). +- Command: `sh tests/package_release_test.sh` + - Result: `package release tests passed`. diff --git a/luci-app-tailscale/.superpowers/sdd/task-4-report.md b/luci-app-tailscale/.superpowers/sdd/task-4-report.md new file mode 100644 index 000000000..6f5c77f79 --- /dev/null +++ b/luci-app-tailscale/.superpowers/sdd/task-4-report.md @@ -0,0 +1,37 @@ +# Task 4 Report + +## Summary + +Implemented the Tailscale peers translation set for the peer observability page. +Added new POT and PO entries for the actual strings used by `peers.js`, and extended +`tests/package_release_test.sh` to assert the key peer-page translations. + +## Changed Files + +- `/Users/jearton/projects/litata/luci-app-tailscale/po/templates/tailscale.pot` +- `/Users/jearton/projects/litata/luci-app-tailscale/po/zh_Hans/tailscale.po` +- `/Users/jearton/projects/litata/luci-app-tailscale/po/zh_Hant/tailscale.po` +- `/Users/jearton/projects/litata/luci-app-tailscale/tests/package_release_test.sh` + +## Commands And Results + +- `sh tests/package_release_test.sh` + - First run: failed on missing `Tailscale Peers` POT entry, which confirmed the new assertions were active. + - Final run: passed with `package release tests passed`. +- Duplicate msgid sanity check across the three translation files: + - Passed with `no duplicate msgid entries found`. +- `git diff --check -- po/templates/tailscale.pot po/zh_Hans/tailscale.po po/zh_Hant/tailscale.po tests/package_release_test.sh` + - Passed with no whitespace or patch-format issues. + +## Self-Review + +- Verified that the new peer-page strings are present in the POT and both Chinese PO files. +- Kept existing shared msgids such as `Peers`, `Online`, `Offline`, and `Status` as single catalog entries instead of adding duplicates. +- Confirmed the package release test covers the new peer page strings and still passes. + +## Concerns + +- The worktree already contained unrelated dirty files outside the allowed write scope: + - `/Users/jearton/projects/litata/luci-app-tailscale/root/etc/init.d/tailscale` + - `/Users/jearton/projects/litata/luci-app-tailscale/tests/tailscale_init_adguard_lifecycle_test.sh` +- Those files were intentionally left untouched. diff --git a/luci-app-tailscale/LICENSE b/luci-app-tailscale/LICENSE new file mode 100644 index 000000000..f288702d2 --- /dev/null +++ b/luci-app-tailscale/LICENSE @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. diff --git a/luci-app-tailscale/Makefile b/luci-app-tailscale/Makefile new file mode 100644 index 000000000..f7db7bce8 --- /dev/null +++ b/luci-app-tailscale/Makefile @@ -0,0 +1,117 @@ +# SPDX-License-Identifier: GPL-3.0-only +# +# Copyright (C) 2024 asvow + +include $(TOPDIR)/rules.mk + +LUCI_TITLE:=LuCI for Tailscale +LUCI_DEPENDS:=+tailscale +jshn +curl +jq +flock +LUCI_PKGARCH:=all + +PKG_VERSION:=1.2.17 +PKG_RELEASE:=3 + +# Keep UCI configs as ordinary package data. The pre-install hook snapshots an +# existing configuration before extraction; the UCI-defaults script restores it +# afterwards. Declaring the same files as opkg conffiles conflicts with this +# lifecycle and creates inactive *-opkg files during upgrades. +define Package/luci-app-tailscale/preinst +#!/bin/sh +state_dir=/etc/.luci-app-tailscale-upgrade +state_pending="$${state_dir}.pending" +legacy_hotplug=/etc/hotplug.d/iface/40-tailscale +legacy_backup_dir=/etc/tailscale/luci-app-tailscale-legacy + +[ -n "$${IPKG_INSTROOT}" ] && exit 0 + +disable_legacy_hotplug() { + local backup_file + + [ -e "$${legacy_hotplug}" ] || [ -L "$${legacy_hotplug}" ] || return 0 + backup_file="$${legacy_backup_dir}/40-tailscale.disabled.$$(date +%Y%m%d%H%M%S).$$$$" + if mkdir -p "$${legacy_backup_dir}" 2>/dev/null && cp -p "$${legacy_hotplug}" "$${backup_file}"; then + : + else + rm -f "$${backup_file}" 2>/dev/null || true + fi + + # Disabling the duplicate starter is mandatory even when its backup fails. + # chmod is a last-resort guard for a transient remove failure; package + # extraction will subsequently remove the obsolete package-owned path. + rm -f "$${legacy_hotplug}" || chmod 000 "$${legacy_hotplug}" || return 1 +} + +disable_legacy_hotplug || exit 1 + +[ -f /etc/config/tailscale ] || exit 0 +[ -f "$${state_dir}/.complete" ] && exit 0 + +clear_state_dir() { + rm -f "$${1}/tailscale" "$${1}/tailscale_openclash" \ + "$${1}/tailscale_openclash.absent" "$${1}/tailscale_policy_routing" \ + "$${1}/.complete" + rmdir "$${1}" 2>/dev/null || [ ! -e "$${1}" ] +} + +clear_state_dir "$${state_dir}" || exit 1 +clear_state_dir "$${state_pending}" || exit 1 +umask 077 +mkdir "$${state_pending}" || exit 1 +chmod 700 "$${state_pending}" || exit 1 +cp /etc/config/tailscale "$${state_pending}/tailscale" || exit 1 +if [ -f /etc/config/tailscale_openclash ]; then + cp /etc/config/tailscale_openclash "$${state_pending}/tailscale_openclash" || exit 1 +else + : >"$${state_pending}/tailscale_openclash.absent" || exit 1 +fi +if [ -f /etc/config/tailscale_policy_routing ]; then + cp /etc/config/tailscale_policy_routing "$${state_pending}/tailscale_policy_routing" || exit 1 +fi +: >"$${state_pending}/.complete" || exit 1 +mv "$${state_pending}" "$${state_dir}" || exit 1 +exit 0 +endef + +define Package/luci-app-tailscale/prerm +#!/bin/sh +state_dir=/etc/.luci-app-tailscale-upgrade + +case "$${2:-}" in + remove|[0-9]*) ;; + *) + # default_prerm sources this script for opkg, while OpenWrt 24.10 + # apk runs pre-deinstall directly with the old package version in $1. + case "$${1:-}" in + [0-9]*) ;; + *) exit 0 ;; + esac + ;; +esac + +if [ -z "$${IPKG_INSTROOT}" ] && [ -x /usr/sbin/tailscale_policy_routing ]; then + /usr/sbin/tailscale_policy_routing cleanup >/dev/null 2>&1 || exit 1 +fi +if [ -z "$${IPKG_INSTROOT}" ] && [ -x /etc/init.d/tailscale-policy-routing ]; then + /etc/init.d/tailscale-policy-routing disable >/dev/null 2>&1 || exit 1 +fi +if [ -z "$${IPKG_INSTROOT}" ] && [ -x /usr/sbin/tailscale_openclash_bypass ]; then + /usr/sbin/tailscale_openclash_bypass cleanup >/dev/null 2>&1 || true +fi +if [ -z "$${IPKG_INSTROOT}" ] && [ -x /etc/init.d/tailscale-openclash-bypass ]; then + /etc/init.d/tailscale-openclash-bypass disable >/dev/null 2>&1 || true +fi +if [ -z "$${IPKG_INSTROOT}" ] && [ -x /etc/init.d/tailscale ]; then + /etc/init.d/tailscale stop >/dev/null 2>&1 || exit 1 +fi +for cleanup_dir in "$${state_dir}" "$${state_dir}.pending"; do + rm -f "$${cleanup_dir}/tailscale" "$${cleanup_dir}/tailscale_openclash" \ + "$${cleanup_dir}/tailscale_openclash.absent" "$${cleanup_dir}/tailscale_policy_routing" \ + "$${cleanup_dir}/.complete" + rmdir "$${cleanup_dir}" 2>/dev/null || true +done +exit 0 +endef + +include $(TOPDIR)/feeds/luci/luci.mk + +# call BuildPackage - OpenWrt buildroot signature diff --git a/luci-app-tailscale/README.md b/luci-app-tailscale/README.md new file mode 100644 index 000000000..88822c3bb --- /dev/null +++ b/luci-app-tailscale/README.md @@ -0,0 +1,160 @@ +# luci-app-tailscale + +Tailscale is a zero config VPN for building secure networks. + +[![GitHub release (latest by date)](https://img.shields.io/github/v/release/asvow/luci-app-tailscale?style=flat-square)](https://github.com/asvow/luci-app-tailscale/releases) +[![GitHub stars](https://img.shields.io/github/stars/asvow/luci-app-tailscale?style=flat-square)](https://github.com/asvow/luci-app-tailscale/stargazers) +[![GitHub forks](https://img.shields.io/github/forks/asvow/luci-app-tailscale?style=flat-square)](https://github.com/asvow/luci-app-tailscale/network/members) +[![License](https://img.shields.io/github/license/asvow/luci-app-tailscale?style=flat-square)](LICENSE) +[![GitHub All Releases](https://img.shields.io/github/downloads/asvow/luci-app-tailscale/total?style=flat-square)](https://github.com/asvow/luci-app-tailscale/releases) + +## How to build + +- Only compatible with luci2 version + +- Enter in your openwrt dir + + *1. replace the default startup script and configuration of Tailscale.* + ```shell + sed -i '/\/etc\/init\.d\/tailscale/d;/\/etc\/config\/tailscale/d;' feeds/packages/net/tailscale/Makefile + ``` + + *2. get luci-app-tailscale source & building* + ```shell + git clone https://github.com/asvow/luci-app-tailscale package/luci-app-tailscale + make menuconfig # choose LUCI -> Applications -> luci-app-tailscale + make package/luci-app-tailscale/compile V=s # luci-app-tailscale + ``` + +-------------- + +## How to install prebuilt packages + +- Upload the prebuilt ipk or apk package to the /tmp directory of OpenWrt +- Login OpenWrt terminal (SSH) + +Install the main package, then optionally install either the Simplified Chinese or Traditional Chinese language package. + +### opkg package manager + +```shell +opkg update +opkg install /tmp/luci-app-tailscale_*.ipk + +# Optional Simplified Chinese translation +opkg install /tmp/luci-i18n-tailscale-zh-cn_*.ipk + +# Or optional Traditional Chinese translation +opkg install /tmp/luci-i18n-tailscale-zh-tw_*.ipk +``` + +### apk package manager + +```shell +apk update +apk add --allow-untrusted /tmp/luci-app-tailscale-*.apk + +# Optional Simplified Chinese translation +apk add --allow-untrusted /tmp/luci-i18n-tailscale-zh-cn-*.apk + +# Or optional Traditional Chinese translation +apk add --allow-untrusted /tmp/luci-i18n-tailscale-zh-tw-*.apk +``` +-------------- + +## OpenClash Bypass + +- Supports firewall4/nftables only. +- Enabled by default when the packaged setting is present; it is a no-op when OpenClash is absent. +- Manages one delimited block in `/etc/openclash/custom/openclash_custom_firewall_rules.sh`. +- The managed block invokes `/usr/sbin/tailscale_openclash_bypass apply` through OpenClash's official custom firewall hook. +- Manages exactly four comment-owned rules in OpenClash's nftables chains. +- Does not modify `/etc/config/firewall`, reload firewall4, or start/restart OpenClash. +- WAN direct remains a separate feature that manages only its own firewall UCI rules. +- Status states: `active`, `waiting`, `disabled`, `absent`, `unsupported`, and `error`. +- Cleanup removes only the managed hook block and the four `luci-app-tailscale:` rules. +- Package installation uses the standard LuCI/OpenWrt post-install lifecycle; the packaged init script performs one locked `sync`. + +Rollback: + +```sh +uci set tailscale_openclash.settings.enabled='0' +uci commit tailscale_openclash +/etc/init.d/tailscale-openclash-bypass reload +/usr/sbin/tailscale_openclash_bypass status +``` + +-------------- + +## Auth Key Lifecycle + +- An auth key is used only for first-time enrollment or when a new value is + explicitly entered in LuCI to re-enroll the device. +- After the connection obtains a Tailscale address, the saved auth key is + removed from every protected credential version. The persistent + `tailscaled.state` identity is kept and is used for normal restarts. +- An auth key is retained when enrollment fails so the configuration can be + corrected and retried. +- Auth keys migrated from an older package are ignored when an existing + `tailscaled.state` identity is present, preventing a reboot from attempting + an unintended registration. + +-------------- + +## mwan3 Policy Routing Compatibility + +- Disabled by default; enable it only when mwan3 fwmark rules take precedence + over Tailscale table `52` and LAN clients cannot reach Tailnet routes. +- Creates an app-owned IPv4 `priority 1000 lookup 52` rule before mwan3's + fwmark rules. Destinations absent from table `52` continue to mwan3. +- Refuses to activate if table `52` has a default route, such as while using a + Tailscale exit node, so ordinary internet traffic is not diverted from mwan3. +- Uses only `network` UCI, `ip rule`, and an interface-event self-heal. It does + not modify firewall UCI, reload firewall4, or manage mwan3/OpenClash. +- Validate from a real LAN client or with an equivalent mwan3 fwmark; an + unmarked `ip route get` on the router is not a sufficient test. + +Rollback: + +```sh +uci set tailscale_policy_routing.settings.enabled='0' +uci commit tailscale_policy_routing +/etc/init.d/tailscale-policy-routing reload +/usr/sbin/tailscale_policy_routing status +``` + +-------------- + +## AdGuard DNS Auto Switch + +- Disabled by default. +- Intended for OpenWrt setups where local AdGuard Home owns LAN DNS port 53. +- LAN DHCP should advertise the router LAN IP as DNS, for example `6,192.168.100.1`. +- DNS health is checked through Headscale/Tailscale DNS at `100.100.100.100`. +- Tailscale `accept_dns` is not required; the health check queries `100.100.100.100` directly. +- Healthy profile: default upstreams plus Tailnet conditional upstreams. +- Unhealthy profile: default upstreams only. +- Configuration is written through the AdGuard Home HTTP API, not by editing the YAML path. +- Leaving the password field blank keeps the existing value. + +Rollback: + +```shell +/usr/sbin/tailscale_adguard_dns_switch --apply-profile down +uci set tailscale.settings.adguard_dns_switch_enabled='0' +uci commit tailscale +/etc/init.d/tailscale reload +``` + +-------------- + +## Thanks +- [Carseason/openwrt-tailscale](https://github.com/Carseason/openwrt-tailscale) +- [immortalwrt/luci-app-zerotier](https://github.com/immortalwrt/luci/blob/master/applications/luci-app-zerotier) + +-------------- + +## Screenshot +Basic +Advanced +Extra diff --git a/luci-app-tailscale/docs/superpowers/plans/2026-07-13-tailscale-peer-observability.md b/luci-app-tailscale/docs/superpowers/plans/2026-07-13-tailscale-peer-observability.md new file mode 100644 index 000000000..71ea35111 --- /dev/null +++ b/luci-app-tailscale/docs/superpowers/plans/2026-07-13-tailscale-peer-observability.md @@ -0,0 +1,1154 @@ +# Tailscale Peer Observability Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a read-only LuCI page that lists all Tailscale peers and lets the user manually probe whether each peer is reached directly or through DERP. + +**Architecture:** Add a focused shell helper for safe `tailscale ping` probing and a new LuCI view for rendering peer state from `tailscale status --json`. Keep the existing settings, keepalive, DNS, firewall, and network lifecycle behavior unchanged. + +**Tech Stack:** OpenWrt LuCI JavaScript views, `fs.exec()`, rpcd ACL JSON, POSIX shell helpers, package static shell tests. + +## Global Constraints + +- The feature is diagnostic only; it must not change Tailscale configuration, keepalive configuration, firewall rules, DNS settings, or network interfaces. +- Add menu path `admin/vpn/tailscale/peers` with title `Peers`, Chinese title `对端列表`, after `Interface Info` and before `Logs`. +- Page load must not automatically ping all peers. +- The first version must not include a `Probe all` button. +- Probe helper path must be `/usr/sbin/tailscale_peer_probe`. +- Probe helper accepts exactly one peer argument. +- Probe helper validates peer arguments: length 1 to 253 characters, allowed characters letters, numbers, `_`, `-`, `.`, `:`. +- Probe helper outputs JSON only. +- LuCI probe action must be read-only. +- No network reload, Tailscale restart, or firewall restart is part of this feature. + +--- + +## File Structure + +- Create `root/usr/sbin/tailscale_peer_probe`: safe command wrapper around `/usr/sbin/tailscale ping`, returning normalized JSON. +- Create `tests/tailscale_peer_probe_test.sh`: shell unit tests with a fake `tailscale` binary. +- Create `htdocs/luci-static/resources/view/tailscale/peers.js`: new read-only peer observability page. +- Modify `root/usr/share/luci/menu.d/luci-app-tailscale.json`: add `admin/vpn/tailscale/peers`. +- Modify `root/usr/share/rpcd/acl.d/luci-app-tailscale.json`: allow read-only exec of `/usr/sbin/tailscale_peer_probe`. +- Modify `tests/package_release_test.sh`: assert new files, menu, ACL, strings, and syntax coverage. +- Modify `po/templates/tailscale.pot`, `po/zh_Hans/tailscale.po`, and `po/zh_Hant/tailscale.po`: add visible labels for the new page. + +--- + +### Task 1: Probe Helper + +**Files:** +- Create: `root/usr/sbin/tailscale_peer_probe` +- Create: `tests/tailscale_peer_probe_test.sh` +- Modify: `tests/package_release_test.sh` + +**Interfaces:** +- Consumes: one CLI argument `peer`. +- Produces: JSON object with fields `peer`, `ok`, `path`, `latency_ms`, `relay`, `summary`, `raw`. + +- [ ] **Step 1: Add failing package assertions** + +Add these assertions to `tests/package_release_test.sh` after the existing helper file assertions: + +```sh +assert_file root/usr/sbin/tailscale_peer_probe +assert_contains "tailscale_peer_probe" root/usr/share/rpcd/acl.d/luci-app-tailscale.json +assert_contains "Peer probe" root/usr/sbin/tailscale_peer_probe +``` + +- [ ] **Step 2: Add the failing helper test** + +Create `tests/tailscale_peer_probe_test.sh`: + +```sh +#!/bin/sh + +set -eu + +ROOT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)" +HELPER="$ROOT_DIR/root/usr/sbin/tailscale_peer_probe" +TMP_DIR="${TMPDIR:-/tmp}/tailscale-peer-probe-test.$$" + +cleanup() { + rm -rf "$TMP_DIR" +} +trap cleanup EXIT + +fail() { + echo "FAIL: $*" >&2 + exit 1 +} + +assert_contains() { + needle="$1" + haystack="$2" + case "$haystack" in + *"$needle"*) ;; + *) fail "expected output to contain: $needle +actual: $haystack" ;; + esac +} + +assert_not_contains() { + needle="$1" + haystack="$2" + case "$haystack" in + *"$needle"*) fail "expected output not to contain: $needle +actual: $haystack" ;; + esac +} + +mkdir -p "$TMP_DIR" + +cat >"$TMP_DIR/fake-tailscale" <<'SH' +#!/bin/sh +case "$*" in + "ping --c=1 --timeout=5s direct-peer") + echo "pong from direct-peer (100.64.2.2) via 192.168.188.1:41641 in 12.4ms" + exit 0 + ;; + "ping --c=1 --timeout=5s derp-peer") + echo "pong from derp-peer (100.64.2.1) via DERP(litata) in 41.8ms" + exit 0 + ;; + "ping --c=1 --timeout=5s weird-peer") + echo "pong from weird-peer using new format" + exit 0 + ;; + "ping --c=1 --timeout=5s failed-peer") + echo "timeout waiting for pong from failed-peer" >&2 + exit 1 + ;; + *) + echo "unexpected fake tailscale args: $*" >&2 + exit 99 + ;; +esac +SH +chmod +x "$TMP_DIR/fake-tailscale" + +run_probe() { + TAILSCALE_BIN="$TMP_DIR/fake-tailscale" "$HELPER" "$@" +} + +direct_output="$(run_probe direct-peer)" +assert_contains '"peer":"direct-peer"' "$direct_output" +assert_contains '"ok":true' "$direct_output" +assert_contains '"path":"direct"' "$direct_output" +assert_contains '"latency_ms":12.4' "$direct_output" +assert_contains '"summary":"direct 12.4 ms"' "$direct_output" + +derp_output="$(run_probe derp-peer)" +assert_contains '"peer":"derp-peer"' "$derp_output" +assert_contains '"ok":true' "$derp_output" +assert_contains '"path":"derp"' "$derp_output" +assert_contains '"relay":"litata"' "$derp_output" +assert_contains '"latency_ms":41.8' "$derp_output" +assert_contains '"summary":"DERP litata 41.8 ms"' "$derp_output" + +unknown_output="$(run_probe weird-peer)" +assert_contains '"peer":"weird-peer"' "$unknown_output" +assert_contains '"ok":true' "$unknown_output" +assert_contains '"path":"unknown"' "$unknown_output" +assert_contains '"summary":"unknown path"' "$unknown_output" + +failed_output="$(run_probe failed-peer || true)" +assert_contains '"peer":"failed-peer"' "$failed_output" +assert_contains '"ok":false' "$failed_output" +assert_contains '"path":"failed"' "$failed_output" +assert_contains '"summary":"tailscale ping failed"' "$failed_output" + +invalid_output="$(run_probe 'bad peer;rm -rf /' || true)" +assert_contains '"ok":false' "$invalid_output" +assert_contains '"path":"failed"' "$invalid_output" +assert_contains '"summary":"invalid peer argument"' "$invalid_output" +assert_not_contains 'rm -rf' "$invalid_output" + +missing_arg_output="$("$HELPER" || true)" +assert_contains '"ok":false' "$missing_arg_output" +assert_contains '"summary":"usage: tailscale_peer_probe "' "$missing_arg_output" + +echo "tailscale_peer_probe tests passed" +``` + +- [ ] **Step 3: Run tests to verify RED** + +Run: + +```sh +sh tests/package_release_test.sh +sh tests/tailscale_peer_probe_test.sh +``` + +Expected: + +- `tests/package_release_test.sh` fails because `root/usr/sbin/tailscale_peer_probe` is missing. +- `tests/tailscale_peer_probe_test.sh` fails because the helper is missing or not executable. + +- [ ] **Step 4: Implement `tailscale_peer_probe`** + +Create `root/usr/sbin/tailscale_peer_probe`: + +```sh +#!/bin/sh + +# Peer probe for LuCI Tailscale diagnostics. + +TAILSCALE_BIN="${TAILSCALE_BIN:-/usr/sbin/tailscale}" + +json_escape() { + printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g; s/ /\\t/g' +} + +json_string() { + printf '"%s"' "$(json_escape "$1")" +} + +emit_json() { + peer="$1" + ok="$2" + path="$3" + latency_ms="$4" + relay="$5" + summary="$6" + raw="$7" + + printf '{' + printf '"peer":'; json_string "$peer"; printf ',' + printf '"ok":%s,' "$ok" + printf '"path":'; json_string "$path"; printf ',' + if [ -n "$latency_ms" ]; then + printf '"latency_ms":%s,' "$latency_ms" + else + printf '"latency_ms":null,' + fi + printf '"relay":'; json_string "$relay"; printf ',' + printf '"summary":'; json_string "$summary"; printf ',' + printf '"raw":'; json_string "$raw" + printf '}\n' +} + +usage() { + emit_json "" false failed "" "" "usage: tailscale_peer_probe " "" + exit 2 +} + +[ "$#" -eq 1 ] || usage + +peer="$1" + +case "$peer" in + ""|*[!A-Za-z0-9_.:-]*) + emit_json "" false failed "" "" "invalid peer argument" "" + exit 2 + ;; +esac + +peer_len=${#peer} +if [ "$peer_len" -lt 1 ] || [ "$peer_len" -gt 253 ]; then + emit_json "" false failed "" "" "invalid peer argument" "" + exit 2 +fi + +raw="$("$TAILSCALE_BIN" ping --c=1 --timeout=5s "$peer" 2>&1)" +rc="$?" + +latency_ms="$(printf '%s\n' "$raw" | sed -nE 's/.* in ([0-9]+(\.[0-9]+)?)ms.*/\1/p' | head -n 1)" +relay="$(printf '%s\n' "$raw" | sed -nE 's/.*DERP\(([^)]*)\).*/\1/p' | head -n 1)" + +if [ "$rc" -ne 0 ]; then + emit_json "$peer" false failed "" "" "tailscale ping failed" "$raw" + exit 0 +fi + +if [ -n "$relay" ]; then + if [ -n "$latency_ms" ]; then + summary="DERP $relay $latency_ms ms" + else + summary="DERP $relay" + fi + emit_json "$peer" true derp "$latency_ms" "$relay" "$summary" "$raw" + exit 0 +fi + +case "$raw" in + *pong\ from*) + if [ -n "$latency_ms" ]; then + summary="direct $latency_ms ms" + else + summary="direct" + fi + emit_json "$peer" true direct "$latency_ms" "" "$summary" "$raw" + ;; + *) + emit_json "$peer" true unknown "" "" "unknown path" "$raw" + ;; +esac +``` + +Run: + +```sh +chmod +x root/usr/sbin/tailscale_peer_probe +``` + +- [ ] **Step 5: Run helper tests to verify GREEN** + +Run: + +```sh +sh tests/tailscale_peer_probe_test.sh +sh tests/package_release_test.sh +``` + +Expected: + +- `tailscale_peer_probe tests passed` +- `package release tests passed` after ACL/menu assertions are added in later tasks; at this step it may still fail on menu/ACL checks not yet implemented. + +Commit after Task 1 only if helper tests pass: + +```sh +git add root/usr/sbin/tailscale_peer_probe tests/tailscale_peer_probe_test.sh tests/package_release_test.sh +git commit -m "Add Tailscale peer probe helper" +``` + +--- + +### Task 2: Menu and ACL + +**Files:** +- Modify: `root/usr/share/luci/menu.d/luci-app-tailscale.json` +- Modify: `root/usr/share/rpcd/acl.d/luci-app-tailscale.json` +- Modify: `tests/package_release_test.sh` + +**Interfaces:** +- Consumes: helper `/usr/sbin/tailscale_peer_probe` from Task 1. +- Produces: LuCI route `admin/vpn/tailscale/peers`, ACL exec access for the helper. + +- [ ] **Step 1: Add failing static assertions** + +Add to `tests/package_release_test.sh` near menu and ACL checks: + +```sh +assert_contains '"admin/vpn/tailscale/peers"' root/usr/share/luci/menu.d/luci-app-tailscale.json +assert_contains '"title": "Peers"' root/usr/share/luci/menu.d/luci-app-tailscale.json +assert_contains '"path": "tailscale/peers"' root/usr/share/luci/menu.d/luci-app-tailscale.json +assert_before '"admin/vpn/tailscale/interface"' '"admin/vpn/tailscale/peers"' root/usr/share/luci/menu.d/luci-app-tailscale.json +assert_before '"admin/vpn/tailscale/peers"' '"admin/vpn/tailscale/log"' root/usr/share/luci/menu.d/luci-app-tailscale.json +assert_contains '"/usr/sbin/tailscale_peer_probe": [ "exec" ]' root/usr/share/rpcd/acl.d/luci-app-tailscale.json +``` + +- [ ] **Step 2: Run test to verify RED** + +Run: + +```sh +sh tests/package_release_test.sh +``` + +Expected: FAIL because the menu and ACL entries are not present. + +- [ ] **Step 3: Add menu entry** + +Edit `root/usr/share/luci/menu.d/luci-app-tailscale.json` so the child routes are: + +```json + "admin/vpn/tailscale/interface": { + "title": "Interface Info", + "order": 20, + "action": { + "type": "view", + "path": "tailscale/interface" + } + }, + "admin/vpn/tailscale/peers": { + "title": "Peers", + "order": 25, + "action": { + "type": "view", + "path": "tailscale/peers" + } + }, + "admin/vpn/tailscale/log": { + "title": "Logs", + "order": 30, + "action": { + "type": "view", + "path": "tailscale/log" + } + } +``` + +- [ ] **Step 4: Add ACL entry** + +Edit `root/usr/share/rpcd/acl.d/luci-app-tailscale.json` read file exec list to include: + +```json + "/usr/sbin/tailscale_peer_probe": [ "exec" ], +``` + +Keep it in the read section, not write. + +- [ ] **Step 5: Run static test to verify GREEN** + +Run: + +```sh +sh tests/package_release_test.sh +``` + +Expected: PASS for menu and ACL assertions, unless later page assertions are not yet added. + +Commit: + +```sh +git add root/usr/share/luci/menu.d/luci-app-tailscale.json root/usr/share/rpcd/acl.d/luci-app-tailscale.json tests/package_release_test.sh +git commit -m "Add Tailscale peers menu and ACL" +``` + +--- + +### Task 3: Peers LuCI View + +**Files:** +- Create: `htdocs/luci-static/resources/view/tailscale/peers.js` +- Modify: `tests/package_release_test.sh` + +**Interfaces:** +- Consumes: `tailscale status --json`, `/usr/sbin/tailscale_peer_probe`. +- Produces: a read-only LuCI page with peer table, filters, and per-peer probe action. + +- [ ] **Step 1: Add failing static assertions** + +Add to `tests/package_release_test.sh` after existing view file assertions: + +```sh +assert_file htdocs/luci-static/resources/view/tailscale/peers.js +assert_contains "Tailscale Peers" htdocs/luci-static/resources/view/tailscale/peers.js +assert_contains "filterMode" htdocs/luci-static/resources/view/tailscale/peers.js +assert_contains "Advertising subnets" htdocs/luci-static/resources/view/tailscale/peers.js +assert_contains "tailscale_peer_probe" htdocs/luci-static/resources/view/tailscale/peers.js +assert_contains "Probe" htdocs/luci-static/resources/view/tailscale/peers.js +assert_contains "DERP" htdocs/luci-static/resources/view/tailscale/peers.js +assert_contains "Direct" htdocs/luci-static/resources/view/tailscale/peers.js +assert_not_contains "Probe all" htdocs/luci-static/resources/view/tailscale/peers.js +``` + +- [ ] **Step 2: Run test to verify RED** + +Run: + +```sh +sh tests/package_release_test.sh +``` + +Expected: FAIL because `peers.js` is missing. + +- [ ] **Step 3: Create `peers.js`** + +Create `htdocs/luci-static/resources/view/tailscale/peers.js`: + +```javascript +/* SPDX-License-Identifier: GPL-3.0-only + * + * Copyright (C) 2026 + */ + +'use strict'; +'require dom'; +'require fs'; +'require poll'; +'require ui'; +'require view'; + +function parseStatus(stdout) { + const parsed = JSON.parse(stdout.replace(/("\w+"):\s*(\d+)/g, '$1:"$2"')); + const peers = Object.values(parsed.Peer || {}).map(function(peer) { + const ip = Array.isArray(peer.TailscaleIPs) ? (peer.TailscaleIPs[0] || '') : ''; + const dnsName = (peer.DNSName || '').replace(/\.$/, ''); + const shortDnsName = dnsName.split('.', 1)[0] || ''; + const hostName = peer.HostName || ''; + const routes = Array.isArray(peer.PrimaryRoutes) ? peer.PrimaryRoutes : []; + const name = shortDnsName || hostName || dnsName || ip; + + return { + name: name, + hostName: hostName, + dnsName: dnsName, + ip: ip, + online: !!peer.Online, + lastSeen: peer.LastSeen || '', + exitNode: !!peer.ExitNodeOption, + routes: routes, + hasSubnetRoutes: routes.length > 0 + }; + }).filter(function(peer) { + return !!peer.name; + }); + + peers.sort(function(a, b) { + return a.name.localeCompare(b.name); + }); + + return peers; +} + +function renderBadge(text, className) { + return E('span', { + class: className || 'label', + style: 'display:inline-block;margin-right:4px;padding:1px 6px;border-radius:3px;background:#eef2f7;color:#334155;font-size:12px' + }, text); +} + +function renderProbeResult(result) { + if (!result) + return E('span', { style: 'color:#94a3b8' }, _('Not probed')); + + const path = result.path || 'unknown'; + const color = path === 'direct' ? 'green' : (path === 'derp' ? '#b7791f' : (path === 'failed' ? 'red' : '#64748b')); + const label = path === 'direct' ? _('Direct') : (path === 'derp' ? _('DERP') : (path === 'failed' ? _('Failed') : _('Unknown'))); + const summary = result.summary ? ' - ' + result.summary : ''; + + return E('span', { style: 'color:%s'.format(color) }, label + summary); +} + +return view.extend({ + async load() { + const res = await fs.exec('/usr/sbin/tailscale', ['status', '--json']); + if (res.code !== 0 || !res.stdout || res.stdout.trim() === '') { + ui.addNotification(null, E('p', {}, _('Unable to get Tailscale peer status: %s.').format(res.message || res.stderr || ''))); + return []; + } + + try { + return parseStatus(res.stdout); + } catch (e) { + ui.addNotification(null, E('p', {}, _('Error parsing Tailscale peer status: %s.').format(e.message))); + return []; + } + }, + + filterPeers(peers, filterMode) { + if (filterMode === 'online') + return peers.filter(function(peer) { return peer.online; }); + if (filterMode === 'subnets') + return peers.filter(function(peer) { return peer.hasSubnetRoutes; }); + return peers; + }, + + async probePeer(peerName, button, resultNode) { + button.disabled = true; + dom.content(resultNode, E('span', {}, _('Probing...'))); + + try { + const res = await fs.exec('/usr/sbin/tailscale_peer_probe', [peerName]); + const data = JSON.parse(res.stdout || '{}'); + dom.content(resultNode, renderProbeResult(data)); + } catch (e) { + dom.content(resultNode, renderProbeResult({ + path: 'failed', + summary: String(e.message || e) + })); + } finally { + button.disabled = false; + } + }, + + renderRows(peers, filterMode) { + const filtered = this.filterPeers(peers, filterMode); + + if (!filtered.length) + return E('tr', { class: 'tr' }, E('td', { class: 'td left', colspan: '7' }, _('No peers found'))); + + return filtered.map(function(peer) { + const resultNode = E('span', {}, renderProbeResult(null)); + const button = E('button', { + class: 'btn cbi-button cbi-button-action', + click: ui.createHandlerFn(this, function() { + return this.probePeer(peer.name, button, resultNode); + }) + }, _('Probe')); + + const role = []; + if (peer.exitNode) + role.push(renderBadge(_('Exit node'))); + if (peer.hasSubnetRoutes) + role.push(renderBadge(_('Advertising subnets'))); + + return E('tr', { class: 'tr' }, [ + E('td', { class: 'td left' }, peer.name), + E('td', { class: 'td left' }, peer.ip || '-'), + E('td', { class: 'td left' }, peer.online ? _('Online') : _('Offline')), + E('td', { class: 'td left' }, peer.lastSeen || '-'), + E('td', { class: 'td left' }, role.length ? role : '-'), + E('td', { class: 'td left' }, peer.routes.length ? peer.routes.join(', ') : '-'), + E('td', { class: 'td left' }, [button, E('span', { style: 'margin-left:8px' }, resultNode)]) + ]); + }, this); + }, + + renderContent(peers) { + let filterMode = 'all'; + const rowsBody = E('tbody'); + + const updateRows = L.bind(function() { + dom.content(rowsBody, this.renderRows(peers, filterMode)); + }, this); + + const filterSelect = E('select', { + change: function(ev) { + filterMode = ev.target.value; + updateRows(); + } + }, [ + E('option', { value: 'all' }, _('All')), + E('option', { value: 'online' }, _('Online')), + E('option', { value: 'subnets' }, _('Advertising subnets')) + ]); + + updateRows(); + + return E('div', {}, [ + E('div', { style: 'margin-bottom:10px' }, [ + E('label', { style: 'margin-right:8px' }, _('Filter')), + filterSelect + ]), + E('table', { class: 'table' }, [ + E('thead', {}, E('tr', { class: 'tr table-titles' }, [ + E('th', { class: 'th left' }, _('Name')), + E('th', { class: 'th left' }, _('Tailnet IP')), + E('th', { class: 'th left' }, _('Status')), + E('th', { class: 'th left' }, _('Last Seen')), + E('th', { class: 'th left' }, _('Role')), + E('th', { class: 'th left' }, _('Advertised Subnets')), + E('th', { class: 'th left' }, _('Probe')) + ])), + rowsBody + ]) + ]); + }, + + pollData(container) { + poll.add(async () => { + const data = await this.load(); + dom.content(container, this.renderContent(data)); + }); + }, + + render(data) { + const content = E([], [ + E('h2', { class: 'content' }, _('Tailscale Peers')), + E('div', { class: 'cbi-map-descr' }, _('View all peers and manually probe whether traffic is direct or relayed through DERP.')), + E('div') + ]); + const container = content.lastElementChild; + + dom.content(container, this.renderContent(data)); + this.pollData(container); + + return content; + }, + + handleSaveApply: null, + handleSave: null, + handleReset: null +}); +``` + +- [ ] **Step 4: Run JavaScript syntax check** + +Run: + +```sh +node --check htdocs/luci-static/resources/view/tailscale/peers.js +``` + +Expected: exit 0. + +- [ ] **Step 5: Run package test** + +Run: + +```sh +sh tests/package_release_test.sh +``` + +Expected: PASS for new page assertions after translation assertions are added in Task 4. + +Commit: + +```sh +git add htdocs/luci-static/resources/view/tailscale/peers.js tests/package_release_test.sh +git commit -m "Add Tailscale peers LuCI view" +``` + +--- + +### Task 4: Translations and Package Verification + +**Files:** +- Modify: `po/templates/tailscale.pot` +- Modify: `po/zh_Hans/tailscale.po` +- Modify: `po/zh_Hant/tailscale.po` +- Modify: `tests/package_release_test.sh` + +**Interfaces:** +- Consumes: visible strings from `peers.js`. +- Produces: Chinese labels for the peer observability page. + +- [ ] **Step 1: Add failing translation assertions** + +Add to `tests/package_release_test.sh` near other translation assertions: + +```sh +assert_contains 'msgid "Peers"' po/zh_Hans/tailscale.po +assert_contains 'msgstr "对端列表"' po/zh_Hans/tailscale.po +assert_contains 'msgid "Tailscale Peers"' po/zh_Hans/tailscale.po +assert_contains 'msgstr "Tailscale 对端"' po/zh_Hans/tailscale.po +assert_contains 'msgid "Direct"' po/zh_Hans/tailscale.po +assert_contains 'msgstr "直连"' po/zh_Hans/tailscale.po +assert_contains 'msgid "DERP"' po/zh_Hans/tailscale.po +assert_contains 'msgstr "DERP 中继"' po/zh_Hans/tailscale.po +assert_contains 'msgid "Advertising subnets"' po/zh_Hans/tailscale.po +assert_contains 'msgstr "发布网段"' po/zh_Hans/tailscale.po +``` + +- [ ] **Step 2: Run test to verify RED** + +Run: + +```sh +sh tests/package_release_test.sh +``` + +Expected: FAIL because translation entries are missing. + +- [ ] **Step 3: Add template entries** + +Append these entries to `po/templates/tailscale.pot` if the project does not regenerate POT automatically: + +```po +msgid "Peers" +msgstr "" + +msgid "Tailscale Peers" +msgstr "" + +msgid "View all peers and manually probe whether traffic is direct or relayed through DERP." +msgstr "" + +msgid "Unable to get Tailscale peer status: %s." +msgstr "" + +msgid "Error parsing Tailscale peer status: %s." +msgstr "" + +msgid "No peers found" +msgstr "" + +msgid "Not probed" +msgstr "" + +msgid "Probing..." +msgstr "" + +msgid "Probe" +msgstr "" + +msgid "Direct" +msgstr "" + +msgid "DERP" +msgstr "" + +msgid "Failed" +msgstr "" + +msgid "Unknown" +msgstr "" + +msgid "Filter" +msgstr "" + +msgid "All" +msgstr "" + +msgid "Tailnet IP" +msgstr "" + +msgid "Last Seen" +msgstr "" + +msgid "Role" +msgstr "" + +msgid "Exit node" +msgstr "" + +msgid "Advertising subnets" +msgstr "" + +msgid "Advertised Subnets" +msgstr "" +``` + +- [ ] **Step 4: Add Simplified Chinese entries** + +Append to `po/zh_Hans/tailscale.po`: + +```po +msgid "Peers" +msgstr "对端列表" + +msgid "Tailscale Peers" +msgstr "Tailscale 对端" + +msgid "View all peers and manually probe whether traffic is direct or relayed through DERP." +msgstr "查看全部对端,并手动探测流量是直连还是通过 DERP 中继。" + +msgid "Unable to get Tailscale peer status: %s." +msgstr "无法获取 Tailscale 对端状态:%s。" + +msgid "Error parsing Tailscale peer status: %s." +msgstr "解析 Tailscale 对端状态失败:%s。" + +msgid "No peers found" +msgstr "没有找到对端" + +msgid "Not probed" +msgstr "未探测" + +msgid "Probing..." +msgstr "探测中..." + +msgid "Probe" +msgstr "探测" + +msgid "Direct" +msgstr "直连" + +msgid "DERP" +msgstr "DERP 中继" + +msgid "Failed" +msgstr "失败" + +msgid "Unknown" +msgstr "未知" + +msgid "Filter" +msgstr "筛选" + +msgid "All" +msgstr "全部" + +msgid "Tailnet IP" +msgstr "Tailnet IP" + +msgid "Last Seen" +msgstr "最后在线" + +msgid "Role" +msgstr "角色" + +msgid "Exit node" +msgstr "出口节点" + +msgid "Advertising subnets" +msgstr "发布网段" + +msgid "Advertised Subnets" +msgstr "已发布网段" +``` + +- [ ] **Step 5: Add Traditional Chinese entries** + +Append to `po/zh_Hant/tailscale.po`: + +```po +msgid "Peers" +msgstr "對端列表" + +msgid "Tailscale Peers" +msgstr "Tailscale 對端" + +msgid "View all peers and manually probe whether traffic is direct or relayed through DERP." +msgstr "查看全部對端,並手動探測流量是直連還是透過 DERP 中繼。" + +msgid "Unable to get Tailscale peer status: %s." +msgstr "無法取得 Tailscale 對端狀態:%s。" + +msgid "Error parsing Tailscale peer status: %s." +msgstr "解析 Tailscale 對端狀態失敗:%s。" + +msgid "No peers found" +msgstr "沒有找到對端" + +msgid "Not probed" +msgstr "未探測" + +msgid "Probing..." +msgstr "探測中..." + +msgid "Probe" +msgstr "探測" + +msgid "Direct" +msgstr "直連" + +msgid "DERP" +msgstr "DERP 中繼" + +msgid "Failed" +msgstr "失敗" + +msgid "Unknown" +msgstr "未知" + +msgid "Filter" +msgstr "篩選" + +msgid "All" +msgstr "全部" + +msgid "Tailnet IP" +msgstr "Tailnet IP" + +msgid "Last Seen" +msgstr "最後在線" + +msgid "Role" +msgstr "角色" + +msgid "Exit node" +msgstr "出口節點" + +msgid "Advertising subnets" +msgstr "發布網段" + +msgid "Advertised Subnets" +msgstr "已發布網段" +``` + +- [ ] **Step 6: Run package test** + +Run: + +```sh +sh tests/package_release_test.sh +``` + +Expected: `package release tests passed`. + +Commit: + +```sh +git add po/templates/tailscale.pot po/zh_Hans/tailscale.po po/zh_Hant/tailscale.po tests/package_release_test.sh +git commit -m "Add peer observability translations" +``` + +--- + +### Task 5: Full Verification + +**Files:** +- Test only. + +**Interfaces:** +- Consumes: all files from Tasks 1-4. +- Produces: verified branch ready for PR review. + +- [ ] **Step 1: Run all shell tests** + +Run: + +```sh +sh tests/package_release_test.sh +sh tests/tailscale_peer_probe_test.sh +sh tests/tailscale_adguard_dns_switch_test.sh +sh tests/tailscale_helper_network_cleanup_test.sh +sh tests/tailscale_keepalive_test.sh +``` + +Expected: + +```text +package release tests passed +tailscale_peer_probe tests passed +tailscale_adguard_dns_switch tests passed +tailscale_helper network cleanup tests passed +tailscale_keepalive resolver tests passed +``` + +- [ ] **Step 2: Run JavaScript syntax checks** + +Run: + +```sh +node --check htdocs/luci-static/resources/view/tailscale/setting.js +node --check htdocs/luci-static/resources/view/tailscale/interface.js +node --check htdocs/luci-static/resources/view/tailscale/log.js +node --check htdocs/luci-static/resources/view/tailscale/peers.js +``` + +Expected: all commands exit 0. + +- [ ] **Step 3: Run shell syntax checks** + +Run: + +```sh +find root -type f \( -path '*/init.d/*' -o -path '*/sbin/*' -o -path '*/uci-defaults/*' \) -exec sh -n {} \; +``` + +Expected: exit 0. + +- [ ] **Step 4: Run whitespace check** + +Run: + +```sh +git diff --check +``` + +Expected: exit 0. + +- [ ] **Step 5: Inspect final diff** + +Run: + +```sh +git diff --stat origin/main...HEAD +git diff --name-only origin/main...HEAD +``` + +Expected: changed files include the helper, peer view, menu, ACL, translations, and tests. No unrelated OpenWrt runtime config files should be changed. + +- [ ] **Step 6: Push** + +Run: + +```sh +git push +``` + +Expected: current PR branch updates successfully. + +--- + +### Task 6: Office OpenWrt Hot Deployment Check + +**Files:** +- Remote deployment only after user confirms `ok` for OpenWrt write operations. + +**Interfaces:** +- Consumes: verified files from Tasks 1-5. +- Produces: office OpenWrt can load `Peers` page and probe one peer. + +- [ ] **Step 1: Read infrastructure status before remote operations** + +Run from `/Users/jearton/projects/litata/infra-docs`: + +```sh +sed -n '1,260p' docs/infra/litata-current-infrastructure-status.md +``` + +Expected: read current OpenWrt, Headscale, DNS, and route-sync context. + +- [ ] **Step 2: Confirm write plan with user** + +Ask the user to approve this exact write plan: + +```text +1. Backup current LuCI view/menu/ACL/helper files under /tmp. +2. Upload peers.js, tailscale_peer_probe, menu JSON, and ACL JSON. +3. Do not reload network, do not restart Tailscale, do not restart firewall. +4. Restart rpcd only if LuCI ACL does not pick up the new helper permission. +5. Verify static file over HTTP and run tailscale_peer_probe against one known peer. +6. Rollback by restoring /tmp backups. +``` + +Do not run remote write commands until the user replies `ok`. + +- [ ] **Step 3: Backup remote files** + +After user approval, run: + +```sh +/usr/bin/expect <<'EXPECT' +set timeout 60 +set password {password} +spawn ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null root@192.168.100.1 "set -eu; ts=\$(date +%Y%m%d-%H%M%S); for f in /www/luci-static/resources/view/tailscale/peers.js /usr/share/luci/menu.d/luci-app-tailscale.json /usr/share/rpcd/acl.d/luci-app-tailscale.json /usr/sbin/tailscale_peer_probe; do [ -e \$f ] && cp \$f /tmp/\$(basename \$f).bak-\$ts || true; done; echo BACKUP_TS=\$ts" +expect { + -re "password:" { send "$password\r"; exp_continue } + eof +} +EXPECT +``` + +Expected: prints `BACKUP_TS=...`. + +- [ ] **Step 4: Upload files** + +Run `scp -O` for: + +```text +htdocs/luci-static/resources/view/tailscale/peers.js -> root@192.168.100.1:/www/luci-static/resources/view/tailscale/peers.js +root/usr/sbin/tailscale_peer_probe -> root@192.168.100.1:/usr/sbin/tailscale_peer_probe +root/usr/share/luci/menu.d/luci-app-tailscale.json -> root@192.168.100.1:/usr/share/luci/menu.d/luci-app-tailscale.json +root/usr/share/rpcd/acl.d/luci-app-tailscale.json -> root@192.168.100.1:/usr/share/rpcd/acl.d/luci-app-tailscale.json +``` + +Then run: + +```sh +ssh root@192.168.100.1 "chmod 0755 /usr/sbin/tailscale_peer_probe; chmod 0644 /www/luci-static/resources/view/tailscale/peers.js /usr/share/luci/menu.d/luci-app-tailscale.json /usr/share/rpcd/acl.d/luci-app-tailscale.json" +``` + +- [ ] **Step 5: Verify served static view** + +Run: + +```sh +curl -fsS --max-time 10 http://192.168.100.1/luci-static/resources/view/tailscale/peers.js | rg -n "Tailscale Peers|tailscale_peer_probe|Probe|DERP|Direct" +``` + +Expected: all strings are present. + +- [ ] **Step 6: Verify helper with known peer** + +Run: + +```sh +ssh root@192.168.100.1 "/usr/sbin/tailscale_peer_probe hzsls-openwrt" +``` + +Expected: JSON with `"path":"direct"`, `"path":"derp"`, `"path":"unknown"`, or `"path":"failed"`. The command must output JSON, not shell errors. + +- [ ] **Step 7: Verify LuCI menu visibility** + +Open: + +```text +http://192.168.100.1/cgi-bin/luci/admin/vpn/tailscale/peers +``` + +Expected: page loads and shows the peer table. + +If LuCI returns an ACL or not-found error, restart only rpcd: + +```sh +/etc/init.d/rpcd restart +``` + +Then repeat Step 7. + +--- + +## Self-Review + +- Spec coverage: all spec requirements are mapped to Tasks 1-6. +- Placeholder scan: no unfinished placeholder markers are intentionally left. +- Type consistency: helper JSON fields are consistent across helper, tests, and LuCI view. +- Scope check: no `Probe all`, no keepalive changes, no Headscale API integration, no historical charts. diff --git a/luci-app-tailscale/docs/superpowers/plans/2026-07-17-tailscale-openclash-bypass.md b/luci-app-tailscale/docs/superpowers/plans/2026-07-17-tailscale-openclash-bypass.md new file mode 100644 index 000000000..d12786dbc --- /dev/null +++ b/luci-app-tailscale/docs/superpowers/plans/2026-07-17-tailscale-openclash-bypass.md @@ -0,0 +1,1103 @@ +# Tailscale OpenClash Bypass Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a package-owned, firewall4/nftables-only OpenClash bypass that is applied through OpenClash's official custom firewall hook, while preserving the existing WAN-direct and site-to-site SNAT behavior as independent flows. + +**Architecture:** Keep the existing WAN-direct and Tailscale firewall-zone logic in `tailscale_helper`; the new `/usr/sbin/tailscale_openclash_bypass` helper owns only one marked hook block and four runtime nftables rules. Store the OpenClash toggle in the app-owned `/etc/config/tailscale_openclash` package and give it a dedicated procd reload trigger, because the existing `tailscale` trigger is package-wide and would otherwise restart Tailscale and execute the WAN/firewall path for an OpenClash-only change. The setting remains visible in the existing Tailscale LuCI page, while status is loaded asynchronously through a read-only rpcd method. + +**Tech Stack:** OpenWrt 24.10 firewall4, nftables JSON output, POSIX `ash`, `flock`, `jq`, UCI/procd, LuCI JavaScript, shell and Node.js behavior tests. + +## Approved Final-Review Corrections + +The user approved these corrections on 2026-07-17 after read-only verification +against the office OpenWrt 24.10.5 target. They supersede conflicting details in +Tasks 1-6 below: + +- BusyBox 1.36 `grep` on the target has no `-b`; hook parsing must use exact + whole marker lines and BusyBox-compatible `awk`/POSIX tools while preserving + all bytes outside the managed block. +- The canonical managed body must be validated and repaired. A user line that + merely contains marker text is not a managed marker. +- The procd lifecycle invokes one helper `sync` operation. `sync` re-reads UCI + and reconciles hook plus runtime rules under one lock; it does not call + `reconcile-hook` and `apply` as separate transactions. +- Unsupported or absent OpenClash/firewall4 states are side-effect-free. Missing + OpenClash chains may install the canonical hook and report `waiting`, allowing + the next official OpenClash hook execution to apply rules. +- Remove the custom package `postinst`. Standard LuCI/OpenWrt package lifecycle + must retain LuCI cache invalidation, `rpcd` reload, and normal init handling. +- Normalize UCI booleans with OpenWrt semantics. +- Site-to-site no-SNAT creates/maintains `firewall.tszone.masq='0'` even when no + forwarding access tokens are selected; forwarding sections still depend only + on the access list. +- Cleanup treats an absent fw4 table/chains as success, refreshes state after + chain-read races, restores owner before mode, and removes tracked temporary + files on signals. + +## Global Constraints + +- Preserve every pre-existing working-tree change; do not run `git reset`, `git checkout`, `git restore`, or overwrite files modified by another session. +- Before each task, run `git status --short --branch` and stage only the paths named by that task. +- WAN direct owns only firewall UCI objects and firewall4 reload behavior. +- OpenClash bypass must not read or write firewall UCI, modify `/etc/config/firewall`, reload or restart firewall4, or manage the OpenClash service. +- OpenClash bypass must be invoked by `/etc/openclash/custom/openclash_custom_firewall_rules.sh` after OpenClash creates its chains. +- Support only firewall4/nftables; do not add firewall3 or iptables compatibility. +- Use test-driven development: add a failing focused test, observe the expected failure, implement the smallest change, then rerun focused and full tests. +- Do not connect to or modify any production OpenWrt during this plan. Device deployment and remote verification require a separate explicit `ok`. +- Do not push, merge, tag, publish a release, or start a GitHub Actions package build during this local implementation phase. + +## Current-Tree Audit + +- Plan baseline is branch `codex/fix-release-install-instructions` at `10fa8b6`; do not switch, reset, rebase, or merge this worktree before preserving any newer user changes found at execution time. +- Baseline at plan creation: all existing `tests/*_test.sh` and `tests/*_test.js` pass. +- WAN direct already belongs in this app as an explicit, disabled-by-default option. It reads the current Tailscale listen port, supports multiple firewall source zones, creates idempotent `firewall.ts_wan_direct_*` rules, and removes them when disabled. +- `子网互通` maps to `disable_snat_subnet_routes=1`. The current init script passes `--snat-subnet-routes=false`, and `tailscale_helper` sets the app-owned `firewall.tszone.masq=0`. Turning it off restores `--snat-subnet-routes=true` and `masq=1`. +- The dual SNAT behavior is implemented but lacks a behavioral regression test. Task 1 adds that protection before OpenClash work begins. +- No OpenClash helper, hook lifecycle, dedicated UCI package, rpcd status method, or LuCI control exists yet. + +## File Map + +**Create:** + +- `root/usr/sbin/tailscale_openclash_bypass`: sole owner of the managed hook block and four nftables rules. +- `root/etc/config/tailscale_openclash`: app-owned toggle, isolated from the `tailscale` package reload trigger. +- `root/etc/init.d/tailscale-openclash-bypass`: one-shot reconciler with a reload trigger only for `tailscale_openclash`. +- `tests/tailscale_openclash_bypass_test.sh`: hook, nftables, locking, status, and cleanup behavior. +- `tests/tailscale_openclash_lifecycle_test.sh`: UCI/procd and package lifecycle isolation. +- `tests/setting_openclash_bypass_test.js`: LuCI separate-UCI binding and nonblocking status behavior. + +**Modify:** + +- `tests/tailscale_helper_network_cleanup_test.sh`: prove fw4 `masq` follows the site-to-site setting. +- `tests/tailscale_init_adguard_lifecycle_test.sh`: prove Tailscale receives the matching `--snat-subnet-routes` flag. +- `Makefile`: package install/upgrade/removal hooks and no new OpenClash service dependency. +- `root/usr/libexec/rpcd/luci.tailscale`: read-only OpenClash bypass status method. +- `root/usr/share/rpcd/acl.d/luci-app-tailscale.json`: read access for status and UCI access for `tailscale_openclash`. +- `htdocs/luci-static/resources/view/tailscale/setting.js`: OpenClash tab, toggle, and asynchronous status. +- `po/templates/tailscale.pot`, `po/zh_Hans/tailscale.po`, `po/zh_Hant/tailscale.po`: UI strings. +- `tests/package_release_test.sh`: package inventory and strict flow-isolation assertions. +- `README.md`: supported backend, ownership, status, and rollback documentation. + +--- + +### Task 1: Lock Down Existing WAN and Site-to-Site Contracts + +**Files:** +- Modify: `tests/tailscale_helper_network_cleanup_test.sh:243-425` +- Modify: `tests/tailscale_init_adguard_lifecycle_test.sh:21-356` +- Test: `tests/tailscale_helper_network_cleanup_test.sh` +- Test: `tests/tailscale_init_adguard_lifecycle_test.sh` + +**Interfaces:** +- Consumes: `DISABLE_SNAT_SUBNET_ROUTES`, `configure_tailscale_access_rules()`, and `tailscale_helper()` from the existing implementation. +- Produces: regression coverage proving `disable_snat_subnet_routes` controls both Tailscale `NoSNAT` and app-owned fw4 zone masquerade. + +- [ ] **Step 1: Extend the firewall test harness and write the failing no-SNAT assertions** + +In `run_helper()`, insert this assignment after the existing `access` assignment: + +```sh +disable_snat_subnet_routes="${6:-0}" +``` + +Replace the existing forced value: + +```sh +DISABLE_SNAT_SUBNET_ROUTES=0 +``` + +with: + +```sh +DISABLE_SNAT_SUBNET_ROUTES="$disable_snat_subnet_routes" +``` + +Then add this sequence after the existing access-rule assertions: + +```sh + +: >"$TMP_DIR/uci_changes.log" +: >"$TMP_DIR/firewall.log" +run_helper "" 0 41641 wan "ts_ac_lan lan_ac_ts" 1 +assert_contains "firewall.tszone.masq=0" "$(cat "$TMP_DIR/uci_db")" \ + "site-to-site enable must disable masquerade in the app-owned Tailscale firewall zone" +assert_contains "reload" "$(cat "$TMP_DIR/firewall.log")" \ + "changing site-to-site SNAT must reload firewall4 through the existing firewall flow" + +: >"$TMP_DIR/uci_changes.log" +: >"$TMP_DIR/firewall.log" +run_helper "" 0 41641 wan "ts_ac_lan lan_ac_ts" 1 +[ ! -s "$TMP_DIR/uci_changes.log" ] || fail "unchanged no-SNAT firewall state must be idempotent" +[ ! -s "$TMP_DIR/firewall.log" ] || fail "unchanged no-SNAT firewall state must not reload firewall4" + +run_helper "" 0 41641 wan "ts_ac_lan lan_ac_ts" 0 +assert_contains "firewall.tszone.masq=1" "$(cat "$TMP_DIR/uci_db")" \ + "site-to-site disable must restore masquerade in the app-owned Tailscale firewall zone" +``` + +- [ ] **Step 2: Run the focused firewall test and confirm the harness fails before accepting the new argument** + +Run: + +```sh +sh tests/tailscale_helper_network_cleanup_test.sh +``` + +Expected: FAIL because the original harness forces `DISABLE_SNAT_SUBNET_ROUTES=0`, so `firewall.tszone.masq=0` is absent. + +- [ ] **Step 3: Implement only the sixth-argument harness change and rerun the test** + +Run: + +```sh +sh tests/tailscale_helper_network_cleanup_test.sh +``` + +Expected: `tailscale_helper network cleanup tests passed`. + +- [ ] **Step 4: Add a behavioral init-script capture for both Tailscale SNAT values** + +Add a fake procd command recorder to `tests/tailscale_init_adguard_lifecycle_test.sh`: + +```sh +capture_tailscale_snat_args() { + disable_snat="$1" + PROCD_LOG="$TMP_DIR/procd-snat-$disable_snat.log" + : >"$PROCD_LOG" + export PROCD_LOG + ( + . "$INIT_SCRIPT" + PROGS="$TMP_DIR/fake-secrets" + config_get() { + case "$3" in + port) eval "$1=41641" ;; + access) eval "$1=ts_ac_lan" ;; + *) eval "$1=" ;; + esac + } + config_get_bool() { + case "$3" in + disable_snat_subnet_routes) eval "$1=$disable_snat" ;; + accept_dns) eval "$1=1" ;; + *) eval "$1=0" ;; + esac + } + config_list_foreach() { :; } + procd_open_instance() { :; } + procd_set_param() { printf 'set:%s\n' "$*" >>"$PROCD_LOG"; } + procd_append_param() { printf 'append:%s\n' "$*" >>"$PROCD_LOG"; } + procd_close_instance() { :; } + tailscale_helper settings + ) + cat "$PROCD_LOG" +} + +snat_disabled_args="$(capture_tailscale_snat_args 1)" +printf '%s\n' "$snat_disabled_args" | grep -F -- '--snat-subnet-routes=false' >/dev/null || \ + fail "site-to-site enable must pass --snat-subnet-routes=false" +printf '%s\n' "$snat_disabled_args" | grep -F 'DISABLE_SNAT_SUBNET_ROUTES=1' >/dev/null || \ + fail "site-to-site enable must pass no-SNAT state to the firewall helper" + +snat_enabled_args="$(capture_tailscale_snat_args 0)" +printf '%s\n' "$snat_enabled_args" | grep -F -- '--snat-subnet-routes=true' >/dev/null || \ + fail "site-to-site disable must pass --snat-subnet-routes=true" +printf '%s\n' "$snat_enabled_args" | grep -F 'DISABLE_SNAT_SUBNET_ROUTES=0' >/dev/null || \ + fail "site-to-site disable must pass SNAT state to the firewall helper" +``` + +- [ ] **Step 5: Run both focused tests** + +Run: + +```sh +sh tests/tailscale_helper_network_cleanup_test.sh +sh tests/tailscale_init_adguard_lifecycle_test.sh +``` + +Expected: both scripts print their existing `tests passed` messages. + +- [ ] **Step 6: Commit only the regression tests** + +```sh +git add tests/tailscale_helper_network_cleanup_test.sh tests/tailscale_init_adguard_lifecycle_test.sh +git commit -m "test: lock down Tailscale subnet SNAT behavior" +``` + +### Task 2: Implement Ownership-Safe OpenClash Hook Reconciliation + +**Files:** +- Create: `tests/tailscale_openclash_bypass_test.sh` +- Create: `root/usr/sbin/tailscale_openclash_bypass` + +**Interfaces:** +- Consumes: `/etc/openclash/custom/openclash_custom_firewall_rules.sh`, `flock`, and the app-owned `tailscale_openclash.settings.enabled` value. +- Produces: `tailscale_openclash_bypass reconcile-hook` and `tailscale_openclash_bypass cleanup`, with a uniquely delimited managed block and ownership-scoped rollback. + +- [ ] **Step 1: Create hook fixtures and failing behavior tests** + +Create `tests/tailscale_openclash_bypass_test.sh` with a temporary OpenClash tree and these assertions: + +```sh +#!/bin/sh +set -eu + +ROOT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)" +SCRIPT="$ROOT_DIR/root/usr/sbin/tailscale_openclash_bypass" +TMP_DIR="${TMPDIR:-/tmp}/tailscale-openclash-test.$$" +trap 'rm -rf "$TMP_DIR"' EXIT + +fail() { printf 'FAIL: %s\n' "$*" >&2; exit 1; } +assert_count() { + expected="$1"; needle="$2"; file="$3" + actual="$(grep -cF -- "$needle" "$file" || true)" + [ "$actual" = "$expected" ] || fail "$file expected $expected occurrences of $needle, got $actual" +} + +mkdir -p "$TMP_DIR/openclash/custom" "$TMP_DIR/bin" +touch "$TMP_DIR/openclash-init" +chmod +x "$TMP_DIR/openclash-init" +HOOK="$TMP_DIR/openclash/custom/openclash_custom_firewall_rules.sh" +printf '#!/bin/sh\nprintf user-before\nprintf user-after\n' >"$HOOK" +chmod 750 "$HOOK" +original_owner="$(stat -c '%u:%g' "$HOOK")" +original_hash="$(sha256sum "$HOOK" | awk '{print $1}')" + +run_helper() { + OPENCLASH_INIT="$TMP_DIR/openclash-init" \ + OPENCLASH_HOOK_FILE="$HOOK" \ + LOCK_FILE="$TMP_DIR/lock" \ + UCI_BIN="$TMP_DIR/bin/uci" \ + NFT_BIN="$TMP_DIR/bin/nft" \ + JQ_BIN=jq \ + "$SCRIPT" "$@" +} + +run_helper reconcile-hook +assert_count 1 '# BEGIN luci-app-tailscale 托管:Tailscale 绕过 OpenClash' "$HOOK" +assert_count 1 '# END luci-app-tailscale 托管:Tailscale 绕过 OpenClash' "$HOOK" +grep -F 'printf user-before' "$HOOK" >/dev/null || fail 'hook insertion removed user content before the block' +grep -F 'printf user-after' "$HOOK" >/dev/null || fail 'hook insertion removed user content after the block' +[ "$(stat -c '%a' "$HOOK")" = 750 ] || fail 'hook mode changed' +[ "$(stat -c '%u:%g' "$HOOK")" = "$original_owner" ] || fail 'hook ownership changed' + +first_hash="$(sha256sum "$HOOK" | awk '{print $1}')" +run_helper reconcile-hook +[ "$(sha256sum "$HOOK" | awk '{print $1}')" = "$first_hash" ] || fail 'reconcile-hook is not idempotent' + +cp "$HOOK" "$TMP_DIR/hook-before-malformed" +printf '%s\n' '# BEGIN luci-app-tailscale 托管:Tailscale 绕过 OpenClash' >>"$HOOK" +malformed_hash="$(sha256sum "$HOOK" | awk '{print $1}')" +if run_helper reconcile-hook >/dev/null 2>&1; then + fail 'duplicate managed markers must fail' +fi +[ "$(sha256sum "$HOOK" | awk '{print $1}')" = "$malformed_hash" ] || fail 'malformed hook changed after rejection' + +cp "$TMP_DIR/hook-before-malformed" "$HOOK" +run_helper cleanup +grep -F 'luci-app-tailscale 托管' "$HOOK" >/dev/null && fail 'cleanup left the managed hook block' +grep -F 'printf user-before' "$HOOK" >/dev/null || fail 'cleanup removed user content' +[ "$(sha256sum "$HOOK" | awk '{print $1}')" = "$original_hash" ] || \ + fail 'hook cleanup did not restore every original byte' +``` + +Also test these states in the same script: + +```sh +rm -f "$TMP_DIR/openclash-init" "$HOOK" +run_helper reconcile-hook +[ ! -e "$HOOK" ] || fail 'OpenClash-absent reconciliation created a custom script' + +touch "$TMP_DIR/openclash-init" +chmod +x "$TMP_DIR/openclash-init" +run_helper reconcile-hook +[ -x "$HOOK" ] || fail 'missing OpenClash custom script was not created executable' +sh -n "$HOOK" || fail 'created hook is not valid shell' +``` + +- [ ] **Step 2: Run the new test and confirm the helper is missing** + +Run: + +```sh +sh tests/tailscale_openclash_bypass_test.sh +``` + +Expected: FAIL because `root/usr/sbin/tailscale_openclash_bypass` does not exist. + +- [ ] **Step 3: Implement the hook-only helper commands** + +Create `root/usr/sbin/tailscale_openclash_bypass` with these constants and command contract: + +```sh +#!/bin/sh +set -u + +OPENCLASH_INIT="${OPENCLASH_INIT:-/etc/init.d/openclash}" +OPENCLASH_HOOK_FILE="${OPENCLASH_HOOK_FILE:-/etc/openclash/custom/openclash_custom_firewall_rules.sh}" +LOCK_FILE="${LOCK_FILE:-/var/lock/tailscale-openclash-bypass.lock}" +UCI_BIN="${UCI_BIN:-uci}" +NFT_BIN="${NFT_BIN:-nft}" +JQ_BIN="${JQ_BIN:-jq}" +LOGGER_CMD="${LOGGER_CMD:-logger}" + +BEGIN_MARKER='# BEGIN luci-app-tailscale 托管:Tailscale 绕过 OpenClash' +END_MARKER='# END luci-app-tailscale 托管:Tailscale 绕过 OpenClash' + +managed_block() { + cat <<'EOF' +# BEGIN luci-app-tailscale 托管:Tailscale 绕过 OpenClash +if test -x /usr/sbin/tailscale_openclash_bypass; then + /usr/sbin/tailscale_openclash_bypass apply +fi +# END luci-app-tailscale 托管:Tailscale 绕过 OpenClash +EOF +} +``` + +Implement these exact functions in the same file: + +```sh +openclash_installed() { test -x "$OPENCLASH_INIT"; } +log_error() { "$LOGGER_CMD" -p daemon.err -t tailscale_openclash_bypass "$*"; } + +marker_state() { + file="$1" + begin_count="$(grep -cF -- "$BEGIN_MARKER" "$file" 2>/dev/null || true)" + end_count="$(grep -cF -- "$END_MARKER" "$file" 2>/dev/null || true)" + [ "$begin_count" = 0 ] && [ "$end_count" = 0 ] && { printf 'absent\n'; return 0; } + [ "$begin_count" = 1 ] && [ "$end_count" = 1 ] || return 1 + begin_line="$(grep -nF -- "$BEGIN_MARKER" "$file" | cut -d: -f1)" + end_line="$(grep -nF -- "$END_MARKER" "$file" | cut -d: -f1)" + [ "$begin_line" -lt "$end_line" ] || return 1 + printf 'present\n' +} +``` + +`reconcile_hook()` must preserve bytes outside the managed block. Locate exact +whole-line markers with BusyBox-compatible tools; do not use `grep -b`. The +parser must distinguish marker-like user text, reject duplicate/malformed marker +pairs, and replace a noncanonical managed body. Use byte-preserving prefix/suffix +copies and insert the managed block immediately after the shebang line. Treat any +separator newline introduced for the block as part of the managed byte range so +`cleanup_hook()` restores the original file hash even when the original last line +has no newline. Build a temp file in the hook's directory, run `sh -n`, restore +numeric owner/group before mode, and atomically `mv` it over the hook. When the +file is absent, create a minimal `#!/bin/sh` script with mode `0755`. Any malformed +marker state must return nonzero before a write, and every failure or signal +before `mv` must delete only the tracked temp file. + +`cleanup_hook()` must use the same parser and atomic replacement path, but omit the managed block. It must return zero when OpenClash, the hook, or the managed block is absent. + +Acquire one exclusive lock before dispatching mutating commands: + +```sh +exec 9>"$LOCK_FILE" || exit 1 +flock -x 9 || exit 1 +case "${1:-}" in + reconcile-hook) openclash_installed || exit 0; reconcile_hook ;; + cleanup) cleanup_hook ;; + *) printf 'usage: %s {reconcile-hook|cleanup}\n' "$0" >&2; exit 2 ;; +esac +``` + +- [ ] **Step 4: Run hook tests and shell syntax checks** + +Run: + +```sh +sh -n root/usr/sbin/tailscale_openclash_bypass +sh tests/tailscale_openclash_bypass_test.sh +``` + +Expected: syntax passes and all hook assertions pass; nft assertions are added in Task 3. + +- [ ] **Step 5: Commit the hook reconciler** + +```sh +git add root/usr/sbin/tailscale_openclash_bypass tests/tailscale_openclash_bypass_test.sh +git commit -m "feat: add ownership-safe OpenClash hook" +``` + +### Task 3: Reconcile Four nftables Rules Atomically and Report Status + +**Files:** +- Modify: `tests/tailscale_openclash_bypass_test.sh` +- Modify: `root/usr/sbin/tailscale_openclash_bypass` + +**Interfaces:** +- Consumes: `nft -j -a list chain inet fw4 ` and `nft -f `. +- Produces: `apply`, complete `cleanup`, and JSON `status` with `active`, `waiting`, `disabled`, `absent`, `unsupported`, or `error` state. + +- [ ] **Step 1: Add a stateful fake nft command and failing rule tests** + +The fake must expose all four target chains, record the single `nft -f` batch, and return owned handles from JSON. Add assertions for these exact managed rules: + +```text +insert rule inet fw4 openclash_mangle_output meta mark & 0x00ff0000 == 0x00080000 counter comment "luci-app-tailscale: Tailscale 标记流量绕过 OpenClash(mangle output)" return +insert rule inet fw4 openclash_output meta mark & 0x00ff0000 == 0x00080000 counter comment "luci-app-tailscale: Tailscale 标记流量绕过 OpenClash(output)" return +insert rule inet fw4 openclash_mangle iifname "tailscale0" counter comment "luci-app-tailscale: tailscale0 入站流量绕过 OpenClash(mangle)" return +insert rule inet fw4 openclash iifname "tailscale0" counter comment "luci-app-tailscale: tailscale0 入站流量绕过 OpenClash(filter)" return +``` + +Add test cases that verify: + +```sh +run_helper apply +assert_count 1 'insert rule inet fw4 openclash_mangle_output ' "$NFT_BATCH_LOG" +assert_count 1 'insert rule inet fw4 openclash_output ' "$NFT_BATCH_LOG" +assert_count 1 'insert rule inet fw4 openclash_mangle iifname "tailscale0"' "$NFT_BATCH_LOG" +assert_count 1 'insert rule inet fw4 openclash iifname "tailscale0"' "$NFT_BATCH_LOG" + +run_helper apply +[ "$(grep -c 'insert rule inet fw4' "$NFT_STATE")" = 4 ] || fail 'repeated apply duplicated owned rules' + +MISSING_CHAIN=openclash_output run_helper apply +[ ! -s "$NFT_BATCH_LOG" ] || fail 'missing target chain produced a partial nft transaction' +printf '%s' "$(run_helper status)" | jq -e '.state == "waiting"' >/dev/null || fail 'missing chain did not report waiting' + +run_helper cleanup +grep -F 'luci-app-tailscale:' "$NFT_STATE" >/dev/null && fail 'cleanup left owned nft rules' +grep -F 'user-owned-rule' "$NFT_STATE" >/dev/null || fail 'cleanup removed a user-owned rule' +``` + +- [ ] **Step 2: Run the focused test and confirm `apply` and `status` fail** + +Run: + +```sh +sh tests/tailscale_openclash_bypass_test.sh +``` + +Expected: FAIL at the first `apply` assertion because Task 2 intentionally left nft commands unimplemented. + +- [ ] **Step 3: Implement complete-chain validation and one nft transaction** + +Add an exact rule registry: + +```sh +rule_specs() { + cat <<'EOF' +openclash_mangle_output|luci-app-tailscale: Tailscale 标记流量绕过 OpenClash(mangle output)|meta mark & 0x00ff0000 == 0x00080000 +openclash_output|luci-app-tailscale: Tailscale 标记流量绕过 OpenClash(output)|meta mark & 0x00ff0000 == 0x00080000 +openclash_mangle|luci-app-tailscale: tailscale0 入站流量绕过 OpenClash(mangle)|iifname "tailscale0" +openclash|luci-app-tailscale: tailscale0 入站流量绕过 OpenClash(filter)|iifname "tailscale0" +EOF +} +``` + +For each spec, first run `"$NFT_BIN" -j -a list chain inet fw4 "$chain"`. Do not create a batch until every call succeeds. Extract only handles whose JSON rule comment exactly equals the registered comment: + +```sh +printf '%s' "$chain_json" | "$JQ_BIN" -r --arg comment "$comment" ' + .nftables[] | .rule? | select(.comment == $comment) | .handle +' +``` + +Write all owned-handle deletions and four `insert rule` statements to one temp batch, then execute one `"$NFT_BIN" -f "$batch"`. A failed batch returns nonzero and is logged; nftables transaction semantics prevent partial application. + +`cleanup_rules()` uses the same exact-comment handle lookup and one delete-only batch. Missing chains are successful no-ops during cleanup, and user-owned equivalent rules are never selected. + +- [ ] **Step 4: Implement machine-readable status without mutating state** + +Use `jq -nc` to return one object with these fields: + +```json +{ + "state": "active", + "enabled": true, + "openclash_present": true, + "firewall4_supported": true, + "hook": "managed", + "rules_present": 4, + "message": "OpenClash bypass is active." +} +``` + +Status precedence must be: + +1. OpenClash absent -> `absent`. +2. firewall4 table or nft JSON unsupported -> `unsupported`. +3. configured disabled -> `disabled`. +4. malformed/duplicate markers -> `error`. +5. one or more target chains absent -> `waiting`. +6. hook and all four exact owned comments present -> `active`. +7. every other incomplete state -> `error`. + +Read only `tailscale_openclash.settings.enabled`; never query the `firewall` UCI package. Default enabled is `1` when the option is absent and OpenClash is installed. + +- [ ] **Step 5: Finish cleanup and rerun focused tests** + +`cleanup_all()` must call `cleanup_hook` and `cleanup_rules` under the same lock, preserve the first nonzero result, and never call firewall4 or OpenClash service commands. + +Replace the Task 2 dispatcher with the final command set: + +```sh +case "${1:-}" in + reconcile-hook) + openclash_installed || exit 0 + reconcile_hook + ;; + apply) + openclash_installed || exit 0 + feature_enabled || { cleanup_rules; exit $?; } + apply_rules + ;; + cleanup) + cleanup_all + ;; + status) + print_status + ;; + *) + printf 'usage: %s {reconcile-hook|apply|cleanup|status}\n' "$0" >&2 + exit 2 + ;; +esac +``` + +Run: + +```sh +sh -n root/usr/sbin/tailscale_openclash_bypass +sh tests/tailscale_openclash_bypass_test.sh +``` + +Expected: `tailscale OpenClash bypass tests passed`. + +- [ ] **Step 6: Commit atomic nft reconciliation** + +```sh +git add root/usr/sbin/tailscale_openclash_bypass tests/tailscale_openclash_bypass_test.sh +git commit -m "feat: reconcile OpenClash bypass rules" +``` + +### Task 4: Add an Independent UCI and Package Lifecycle + +**Files:** +- Create: `root/etc/config/tailscale_openclash` +- Create: `root/etc/init.d/tailscale-openclash-bypass` +- Create: `tests/tailscale_openclash_lifecycle_test.sh` +- Modify: `Makefile:7-21` +- Modify: `tests/package_release_test.sh:126-199,500-547` + +**Interfaces:** +- Consumes: helper commands `sync`, `apply`, and `cleanup` from Tasks 2-3. +- Produces: a package-owned UCI toggle whose reload trigger cannot invoke `/etc/init.d/tailscale` or the WAN-direct helper. + +- [ ] **Step 1: Write failing lifecycle and static-isolation tests** + +Create `tests/tailscale_openclash_lifecycle_test.sh` that sources the new init script with a fake helper and verifies: + +```sh +enabled_log="$(run_sync 1)" +[ "$enabled_log" = "sync" ] || fail "enabled OpenClash bypass must reconcile under one helper lock" + +disabled_log="$(run_sync 0)" +[ "$disabled_log" = "cleanup" ] || fail "disabled OpenClash bypass must remove only owned state" + +grep -F 'procd_add_reload_trigger "tailscale_openclash"' "$INIT_SCRIPT" >/dev/null || \ + fail 'OpenClash lifecycle needs its own UCI reload trigger' +grep -F 'procd_add_reload_trigger "tailscale"' "$INIT_SCRIPT" >/dev/null && \ + fail 'OpenClash lifecycle must not subscribe to the core Tailscale UCI package' +grep -E '/etc/init.d/(firewall|openclash)|fw4 (reload|restart)' "$INIT_SCRIPT" >/dev/null && \ + fail 'OpenClash lifecycle must not manage firewall4 or OpenClash services' +``` + +Extend `tests/package_release_test.sh` with these hard boundaries: + +```sh +assert_file root/usr/sbin/tailscale_openclash_bypass +assert_file root/etc/config/tailscale_openclash +assert_file root/etc/init.d/tailscale-openclash-bypass +assert_not_contains "tailscale_openclash_bypass" root/usr/sbin/tailscale_helper +assert_not_contains "openclash_custom_firewall_rules.sh" root/usr/sbin/tailscale_helper +assert_not_contains "/etc/config/firewall" root/usr/sbin/tailscale_openclash_bypass +assert_not_contains "uci commit firewall" root/usr/sbin/tailscale_openclash_bypass +assert_not_contains "/etc/init.d/firewall" root/usr/sbin/tailscale_openclash_bypass +assert_not_contains "/etc/init.d/openclash reload" root/usr/sbin/tailscale_openclash_bypass +assert_not_contains "/etc/init.d/openclash restart" root/usr/sbin/tailscale_openclash_bypass +``` + +- [ ] **Step 2: Run both tests and confirm lifecycle files are missing** + +Run: + +```sh +sh tests/tailscale_openclash_lifecycle_test.sh +sh tests/package_release_test.sh +``` + +Expected: both fail on missing package files. + +- [ ] **Step 3: Add the dedicated UCI package and one-shot init script** + +Create `root/etc/config/tailscale_openclash`: + +```uci +config openclash 'settings' + option enabled '1' +``` + +Create `root/etc/init.d/tailscale-openclash-bypass`: + +```sh +#!/bin/sh /etc/rc.common + +START=99 +USE_PROCD=1 +PROG="${PROG:-/usr/sbin/tailscale_openclash_bypass}" + +service_triggers() { + procd_add_reload_trigger "tailscale_openclash" +} + +sync_instance() { + "$PROG" sync +} + +start_service() { + config_load tailscale_openclash + config_foreach sync_instance openclash +} + +reload_service() { + start_service +} + +stop_service() { + "$PROG" cleanup +} +``` + +This script must contain no reference to `/etc/init.d/tailscale`, `/etc/init.d/firewall`, `fw4 reload`, or OpenClash service actions. + +- [ ] **Step 4: Preserve standard installation and add removal cleanup** + +Do not define `Package/luci-app-tailscale/postinst`; the LuCI default post-install +script must retain cache cleanup and `rpcd` reload, while OpenWrt +`default_postinst` handles packaged init scripts. In `Makefile`, retain the +existing Tailscale stop behavior and add OpenClash cleanup before it: + +```make +define Package/luci-app-tailscale/prerm +#!/bin/sh +if [ -z "$${IPKG_INSTROOT}" ] && [ -x /usr/sbin/tailscale_openclash_bypass ]; then + /usr/sbin/tailscale_openclash_bypass cleanup >/dev/null 2>&1 || true +fi +if [ -z "$${IPKG_INSTROOT}" ] && [ -x /etc/init.d/tailscale ]; then + /etc/init.d/tailscale stop >/dev/null 2>&1 || exit 1 +fi +exit 0 +endef +``` + +The OpenClash cleanup is best-effort and runs while the helper still exists. It does not suppress the existing failure handling for Tailscale's own managed firewall cleanup. + +- [ ] **Step 5: Run focused lifecycle, package, and syntax tests** + +Run: + +```sh +sh -n root/etc/init.d/tailscale-openclash-bypass +sh tests/tailscale_openclash_lifecycle_test.sh +sh tests/package_release_test.sh +``` + +Expected: all pass. + +- [ ] **Step 6: Commit the isolated package lifecycle** + +```sh +git add Makefile root/etc/config/tailscale_openclash root/etc/init.d/tailscale-openclash-bypass tests/tailscale_openclash_lifecycle_test.sh tests/package_release_test.sh +git commit -m "feat: isolate OpenClash bypass lifecycle" +``` + +### Task 5: Add Read-Only Status RPC and LuCI Control + +**Files:** +- Create: `tests/setting_openclash_bypass_test.js` +- Modify: `root/usr/libexec/rpcd/luci.tailscale:6-180` +- Modify: `root/usr/share/rpcd/acl.d/luci-app-tailscale.json:4-24` +- Modify: `htdocs/luci-static/resources/view/tailscale/setting.js:16-62,349-362,470-766,849-852` + +**Interfaces:** +- Consumes: helper `status` JSON and UCI package `tailscale_openclash`. +- Produces: read-only rpcd method `openclash_bypass_status` and a LuCI tab whose setting writes only `tailscale_openclash.settings.enabled`. + +- [ ] **Step 1: Write failing rpcd and LuCI binding tests** + +Extend `tests/tailscale_rpcd_test.sh` to require the new method and exact helper pass-through: + +```sh +printf '%s\n' '{}' | OPENCLASH_BYPASS_BIN="$TMP_DIR/openclash-helper" \ + "$RPCD_SCRIPT" call openclash_bypass_status | \ + jq -e '.state == "active" and .rules_present == 4' >/dev/null || \ + fail 'rpcd must return the helper status object unchanged' +``` + +Create `tests/setting_openclash_bypass_test.js` with static and VM assertions: + +```js +const fs = require('fs'); +const source = fs.readFileSync('htdocs/luci-static/resources/view/tailscale/setting.js', 'utf8'); + +function assert(value, message) { + if (!value) + throw new Error(message); +} + +assert(source.includes("uci.load('tailscale_openclash')"), 'setting view must load the isolated OpenClash UCI package'); +assert(source.includes("method: 'openclash_bypass_status'"), 'setting view must use the read-only status RPC'); +assert(source.includes("uci.set('tailscale_openclash', 'settings', 'enabled'"), 'toggle must write only the isolated UCI package'); +assert(!source.includes("uci.set('tailscale', section_id, 'openclash"), 'toggle must not write the core Tailscale UCI package'); + +const loadBody = source.match(/load\(\)\s*\{([\s\S]*?)\n\t\},\n\n\trender/)[1]; +assert(!loadBody.includes('callOpenclashBypassStatus('), 'OpenClash status must not block initial page rendering'); +assert(source.includes('refreshOpenclashBypassStatus'), 'status must refresh after render'); + +console.log('setting OpenClash bypass tests passed'); +``` + +- [ ] **Step 2: Run tests and confirm the RPC and UI are absent** + +Run: + +```sh +sh tests/tailscale_rpcd_test.sh +node tests/setting_openclash_bypass_test.js +``` + +Expected: both fail on missing `openclash_bypass_status` support. + +- [ ] **Step 3: Implement the read-only rpcd method** + +Add: + +```sh +OPENCLASH_BYPASS_BIN="${OPENCLASH_BYPASS_BIN:-/usr/sbin/tailscale_openclash_bypass}" + +run_openclash_bypass_status() { + "$OPENCLASH_BYPASS_BIN" status +} +``` + +Add `"openclash_bypass_status":{}` to `print_methods()` and dispatch it without parsing caller-controlled command arguments. In the ACL, grant read access to `openclash_bypass_status` and UCI read/write access to `tailscale_openclash`; do not expose helper execution through the generic LuCI file-exec ACL. + +- [ ] **Step 4: Add an asynchronous OpenClash tab to the existing settings page** + +Declare the RPC and status renderer: + +```js +const callOpenclashBypassStatus = rpc.declare({ + object: 'luci.tailscale', + method: 'openclash_bypass_status', + expect: { '': {} }, + reject: true +}); + +function renderOpenclashBypassStatus(status) { + const labels = { + active: _('Enabled and active'), + waiting: _('Enabled; waiting for OpenClash nftables chains'), + disabled: _('Disabled'), + absent: _('OpenClash is not installed'), + unsupported: _('Unsupported: firewall4/nftables is required'), + error: _('Configuration error') + }; + return labels[status && status.state] || _('Unknown status'); +} + +async function refreshOpenclashBypassStatus() { + const node = document.getElementById('openclash_bypass_status'); + if (!node) + return; + try { + const status = await callOpenclashBypassStatus(); + node.textContent = renderOpenclashBypassStatus(status); + } catch (error) { + node.textContent = _('Unable to read OpenClash bypass status.'); + } +} +``` + +Load `tailscale_openclash` alongside the existing UCI loads, but do not call the status RPC from `load()`. Add a dedicated tab: + +```js +s.tab('openclash', _('OpenClash')); + +o = s.taboption('openclash', form.Flag, 'openclash_bypass_enabled', _('Enable OpenClash Bypass'), + _('Bypass OpenClash for Tailscale marked host traffic and traffic entering from tailscale0. This feature does not reload firewall4 or manage the OpenClash service.')); +o.default = o.enabled; +o.rmempty = false; +o.cfgvalue = function() { + return uci.get('tailscale_openclash', 'settings', 'enabled') || '1'; +}; +o.write = function(section_id, value) { + return uci.set('tailscale_openclash', 'settings', 'enabled', value); +}; +o.remove = function() { + return uci.set('tailscale_openclash', 'settings', 'enabled', '0'); +}; + +o = s.taboption('openclash', form.DummyValue, '_openclash_bypass_status', _('Status')); +o.rawhtml = true; +o.cfgvalue = function() { return ''; }; +o.renderWidget = function() { + return E('span', { id: 'openclash_bypass_status' }, _('Checking ...')); +}; +``` + +After `m.render()`, schedule `refreshOpenclashBypassStatus` next to the existing AdGuard refresh: + +```js +window.setTimeout(refreshAdguardPreflightStatus, 0); +window.setTimeout(refreshOpenclashBypassStatus, 0); +``` + +- [ ] **Step 5: Run RPC, LuCI, and syntax tests** + +Run: + +```sh +sh tests/tailscale_rpcd_test.sh +node tests/setting_openclash_bypass_test.js +node --check htdocs/luci-static/resources/view/tailscale/setting.js +sh -n root/usr/libexec/rpcd/luci.tailscale +jq -e . root/usr/share/rpcd/acl.d/luci-app-tailscale.json >/dev/null +``` + +Expected: all commands pass. + +- [ ] **Step 6: Commit the UI and status API** + +```sh +git add htdocs/luci-static/resources/view/tailscale/setting.js root/usr/libexec/rpcd/luci.tailscale root/usr/share/rpcd/acl.d/luci-app-tailscale.json tests/setting_openclash_bypass_test.js tests/tailscale_rpcd_test.sh +git commit -m "feat: add OpenClash bypass controls" +``` + +### Task 6: Complete Translations, Documentation, and Full Local Verification + +**Files:** +- Modify: `po/templates/tailscale.pot` +- Modify: `po/zh_Hans/tailscale.po` +- Modify: `po/zh_Hant/tailscale.po` +- Modify: `README.md:64-89` +- Modify: `tests/package_release_test.sh` + +**Interfaces:** +- Consumes: state names and UI strings from Task 5. +- Produces: complete Simplified/Traditional Chinese UI, operational documentation, and a locally verified source tree ready for review. + +- [ ] **Step 1: Add failing translation inventory assertions** + +Require every new msgid in the template and both translations. At minimum assert these Simplified Chinese values: + +```text +OpenClash -> OpenClash +Enable OpenClash Bypass -> 启用 OpenClash 绕过 +Status -> 状态 +Enabled and active -> 已启用并生效 +Enabled; waiting for OpenClash nftables chains -> 已启用,等待 OpenClash 创建 nftables 链 +Disabled -> 已禁用 +OpenClash is not installed -> 未安装 OpenClash +Unsupported: firewall4/nftables is required -> 不支持:需要 firewall4/nftables +Configuration error -> 配置错误 +Unable to read OpenClash bypass status. -> 无法读取 OpenClash 绕过状态。 +``` + +Run: + +```sh +sh tests/package_release_test.sh +``` + +Expected: FAIL on the first missing translation assertion. + +- [ ] **Step 2: Update the POT and both PO files** + +Add all Task 5 msgids. Use natural Traditional Chinese equivalents in `po/zh_Hant/tailscale.po`, including `啟用 OpenClash 繞過`, `已啟用並生效`, and `未安裝 OpenClash`. Preserve existing translation entries and run format validation. + +- [ ] **Step 3: Document ownership, lifecycle, and rollback separately from WAN direct** + +Add a `## OpenClash Bypass` section to `README.md` containing these facts: + +````markdown +## OpenClash Bypass + +- Supports firewall4/nftables only. +- Enabled by default when the packaged setting is present; it is a no-op when OpenClash is absent. +- Manages one delimited block in `/etc/openclash/custom/openclash_custom_firewall_rules.sh`. +- The managed block invokes `/usr/sbin/tailscale_openclash_bypass apply` through OpenClash's official custom firewall hook. +- Manages exactly four comment-owned rules in OpenClash's nftables chains. +- Does not modify `/etc/config/firewall`, reload firewall4, or start/restart OpenClash. +- WAN direct remains a separate feature that manages only its own firewall UCI rules. + +Rollback: + +```sh +uci set tailscale_openclash.settings.enabled='0' +uci commit tailscale_openclash +/etc/init.d/tailscale-openclash-bypass reload +/usr/sbin/tailscale_openclash_bypass status +``` +```` + +Do not add production hostnames, public IPs, credentials, or device-specific OpenClash content to the package README. + +- [ ] **Step 4: Run the complete existing and new behavior suite** + +Run: + +```sh +set -eu +for test in tests/*_test.sh; do sh "$test"; done +for test in tests/*_test.js; do node "$test"; done +``` + +Expected: every script exits zero and prints its pass message. + +- [ ] **Step 5: Run all local syntax, JSON, translation, and diff checks** + +Run: + +```sh +set -eu +for file in .github/scripts/*.sh; do bash -n "$file"; done +for file in root/etc/init.d/tailscale root/etc/init.d/tailscale-openclash-bypass root/etc/hotplug.d/iface/* root/etc/uci-defaults/* root/usr/libexec/rpcd/* root/usr/sbin/tailscale_* tests/*_test.sh; do sh -n "$file"; done +for file in htdocs/luci-static/resources/view/tailscale/*.js tests/*_test.js; do node --check "$file"; done +for file in root/usr/share/luci/menu.d/*.json root/usr/share/rpcd/acl.d/*.json; do jq -e . "$file" >/dev/null; done +for file in po/templates/*.pot po/*/*.po; do msgfmt --check-format -o /dev/null "$file"; done +git diff --check +``` + +Expected: no output except test pass messages, and exit status zero. + +- [ ] **Step 6: Verify strict isolation from source text** + +Run: + +```sh +! rg -n '/etc/config/firewall|uci (set|delete|commit) firewall|/etc/init.d/firewall|fw4 (reload|restart)' root/usr/sbin/tailscale_openclash_bypass root/etc/init.d/tailscale-openclash-bypass +! rg -n 'tailscale_openclash_bypass|openclash_custom_firewall_rules' root/usr/sbin/tailscale_helper root/etc/init.d/tailscale +! rg -n '/etc/init.d/openclash (start|stop|restart|reload|enable|disable)' root/usr/sbin/tailscale_openclash_bypass root/etc/init.d/tailscale-openclash-bypass +``` + +Expected: all three negated searches exit zero and print nothing. + +- [ ] **Step 7: Commit documentation and translations** + +```sh +git add README.md po/templates/tailscale.pot po/zh_Hans/tailscale.po po/zh_Hant/tailscale.po tests/package_release_test.sh +git commit -m "docs: document OpenClash bypass management" +``` + +### Task 7: Correct Final-Review Findings Against OpenWrt 24.10 + +**Files:** +- Modify: `docs/superpowers/specs/2026-07-16-tailscale-openclash-bypass-design.md` +- Modify: `docs/superpowers/plans/2026-07-17-tailscale-openclash-bypass.md` +- Modify: `root/usr/sbin/tailscale_openclash_bypass` +- Modify: `root/etc/init.d/tailscale-openclash-bypass` +- Modify: `root/usr/sbin/tailscale_helper` +- Modify: `Makefile` +- Modify: `tests/tailscale_openclash_bypass_test.sh` +- Modify: `tests/tailscale_openclash_lifecycle_test.sh` +- Modify: `tests/tailscale_helper_network_cleanup_test.sh` +- Modify: `tests/package_release_test.sh` +- Modify: `README.md` + +**Interfaces:** +- Preserves: WAN-direct ownership of firewall UCI/firewall4 reloads only. +- Preserves: OpenClash ownership of one official custom-hook block and four + comment-owned runtime nftables rules only. +- Produces: one-lock `sync`, BusyBox-compatible hook handling, standard LuCI + installation lifecycle, and empty-access no-SNAT correctness. + +- [x] **Step 1: Add focused failing regression tests** + +Add tests proving: + +1. no production path invokes `grep -b`, and the hook works with a fake grep that + rejects `-b`; +2. marker-like user text is preserved, exact duplicate/malformed markers fail, + and a stale managed body is replaced canonically; +3. `sync` performs one locked decision after re-reading UCI, including concurrent + enable/disable coverage; +4. missing OpenClash or unsupported fw4 produces no hook/rule side effect, while + present fw4 with missing OpenClash chains produces `waiting` with a canonical + hook; +5. cleanup succeeds when the fw4 table or target chains are already absent, and + a disappearing chain is reclassified from refreshed table state; +6. false aliases `0`, `false`, `off`, `no`, and `disabled` disable the feature; +7. site-to-site no-SNAT with an empty access list keeps `tszone.masq=0` and creates + no forwarding sections; disabling no-SNAT with empty access removes the zone; +8. standard LuCI post-install behavior is not replaced by a custom package + `postinst`; +9. replacement restores owner before mode and tracked temporary files are removed + after termination; +10. fake nftables rejects nonexistent handles and the locking test uses real + `flock` behavior. + +Run each focused test before implementation and retain the expected failure +output in the task report. + +- [x] **Step 2: Implement the smallest complete corrections** + +Implement `sync` under one exclusive lock. It re-reads UCI itself and dispatches +the full enabled/disabled state. Keep `apply` for the OpenClash-owned hook, but do +not split procd reconciliation into two helper invocations. Use exact whole-line, +canonical hook parsing without `grep -b`; clean only package-owned state; never +touch firewall UCI or manage OpenClash/firewall4 services. + +Remove the custom package post-install block. Keep best-effort package removal +cleanup. Decouple `tszone` existence from forwarding-token existence when +site-to-site no-SNAT is enabled. + +- [x] **Step 3: Strengthen lifecycle, race, and documentation coverage** + +Document all status states and ownership cleanup. Make the fake nftables harness +fail on stale/nonexistent handles and add a deterministic generation-race case. +Verify unsupported, waiting, disabled, active, and cleanup behavior through the +public command interface. + +- [x] **Step 4: Run focused and full verification** + +Run: + +```sh +set -eu +sh tests/tailscale_openclash_bypass_test.sh +sh tests/tailscale_openclash_lifecycle_test.sh +sh tests/tailscale_helper_network_cleanup_test.sh +sh tests/package_release_test.sh +for test in tests/*_test.sh; do sh "$test"; done +for test in tests/*_test.js; do node "$test"; done +``` + +Then run every syntax, JSON, translation, strict-isolation, and diff check from +Task 6. No router is modified in Task 7. + +- [x] **Step 5: Commit Task 7 as one review-fix commit** + +Stage only Task 7 paths and preserve the pre-existing modified SDD reports. +Record the final commit range and request a fresh whole-branch review against +merge base `7f100d587f473eb73d8736ef9731d644c75b6b2d`. + +## Local Completion Gate + +After Task 7, stop. Report: + +- the exact local commits created; +- the complete test and syntax results; +- `git status --short --branch` without changing or staging unrelated files; +- confirmation that no router, server, GitHub branch, PR, tag, release, or Actions workflow was modified. + +Production deployment remains a separate operation requiring a new explicit `ok`. That later verification must first read current device state, take rollback backups, and then independently verify: + +1. OpenClash hook content outside the managed block is unchanged. +2. Four owned rules are at the heads of the expected chains with no duplicates. +3. OpenClash restart recreates the rules through its custom hook without a firewall4 reload. +4. Disabling removes only the owned block and rules. +5. WAN direct still creates only its firewall UCI rules and cold-start direct connectivity works. +6. Site-to-site enabled shows both Tailscale `NoSNAT=true` and `firewall.tszone.masq='0'`, with routing table 52 intact. diff --git a/luci-app-tailscale/docs/superpowers/plans/2026-07-18-openclash-bypass-ui-copy.md b/luci-app-tailscale/docs/superpowers/plans/2026-07-18-openclash-bypass-ui-copy.md new file mode 100644 index 000000000..71421133a --- /dev/null +++ b/luci-app-tailscale/docs/superpowers/plans/2026-07-18-openclash-bypass-ui-copy.md @@ -0,0 +1,234 @@ +# OpenClash Bypass UI Copy Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace the OpenClash bypass fallback English with complete Simplified and Traditional Chinese copy that explains the concrete network impact of disabling the protection. + +**Architecture:** Keep the existing OpenClash helper, UCI package, RPC method, and four-rule lifecycle unchanged. Update only LuCI msgids, translation catalogs, and tests; then use the Linux/OpenWrt SDK CI artifacts to hot-deploy matching JavaScript and Simplified Chinese LMO to the office router with backup and rollback. + +**Tech Stack:** LuCI JavaScript forms, gettext PO/POT catalogs, POSIX shell tests, Node.js tests, GitHub Actions OpenWrt SDK builds, OpenWrt LuCI LMO catalogs. + +## Global Constraints + +- `OpenClash` remains an untranslated product name. +- The switch must state that disabling it returns Tailscale traffic to OpenClash and removes protection for node connectivity, direct paths, Tailnet DNS, and subnet access. +- Do not change helper behavior, firewall UCI, WAN UDP direct rules, subnet SNAT, Tailscale service state, OpenClash service state, or firewall4 lifecycle. +- Only the office OpenWrt at `192.168.100.1` may be written during deployment verification; `hzsls-openwrt` remains read-only. +- Never source `.env`; read only explicit credential keys. +- Back up the deployed JavaScript and LMO before writing, arm an automatic rollback, and verify page, rules, services, DNS, and logs after deployment. + +--- + +### Task 1: Lock the user-facing contract with failing tests + +**Files:** +- Modify: `tests/setting_openclash_bypass_test.js` +- Modify: `tests/package_release_test.sh` +- Test: `tests/setting_openclash_bypass_test.js` +- Test: `tests/package_release_test.sh` + +**Interfaces:** +- Consumes: existing `_()` gettext msgids in `setting.js` and `assert_po_entry()` in `package_release_test.sh`. +- Produces: exact required English msgids and Simplified/Traditional Chinese translations for Task 2. + +- [ ] **Step 1: Add source-contract assertions to the JavaScript test** + +Require these new msgids and reject the old switch copy: + +```js +assert(source.includes("_('Protect Tailscale Traffic (Bypass OpenClash)')"), 'toggle must explain that it protects Tailscale traffic'); +assert(source.includes("_('Keep Tailscale control connections, direct connections, Tailnet DNS, and subnet traffic outside OpenClash. When disabled, this traffic is handled by OpenClash; node connectivity, direct paths, subnet access, and internal DNS are no longer protected by this feature. Keep this enabled while using OpenClash.')"), 'description must name the protected traffic and disabled impact'); +assert(source.includes("_('Enabled; 4 bypass rules are active')"), 'active status must report the concrete rule state'); +assert(source.includes("_('Disabled; Tailscale traffic is handled by OpenClash')"), 'disabled status must state the traffic impact'); +assert(!source.includes("_('Enable OpenClash Bypass')"), 'old implementation-only toggle copy must be removed'); +``` + +Update runtime expectations from `Enabled and active` to `Enabled; 4 bypass rules are active`. + +- [ ] **Step 2: Replace package translation assertions with the new copy** + +Require exact PO entries for: + +```text +Protect Tailscale Traffic (Bypass OpenClash) +Keep Tailscale control connections, direct connections, Tailnet DNS, and subnet traffic outside OpenClash. When disabled, this traffic is handled by OpenClash; node connectivity, direct paths, subnet access, and internal DNS are no longer protected by this feature. Keep this enabled while using OpenClash. +Enabled; 4 bypass rules are active +Disabled; Tailscale traffic is handled by OpenClash +OpenClash is not installed; bypass is not required +Unsupported; firewall4/nftables is required +``` + +The exact Simplified Chinese switch description must be: + +```text +开启后,Tailscale 控制连接、直连通信、Tailnet DNS 和跨子网流量不会被 OpenClash 接管。关闭后,这些流量将重新经过 OpenClash,节点在线、点对点直连、跨子网访问和 Tailnet DNS 解析不再受本功能保护;如果 OpenClash 规则接管或重定向这些流量,可能导致节点掉线、直连退化为 DERP、跨子网访问或 Tailnet DNS 中断。使用 OpenClash 时必须保持开启。 +``` + +Update `assert_po_entry_mutation_test()` to mutate the new active and disabled translations rather than the removed strings. + +- [ ] **Step 3: Run focused tests and verify RED** + +Run: + +```sh +node tests/setting_openclash_bypass_test.js +sh tests/package_release_test.sh +``` + +Expected: both fail because `setting.js` and PO/POT catalogs still contain the old copy. + +- [ ] **Step 4: Commit the test contract** + +```sh +git add tests/setting_openclash_bypass_test.js tests/package_release_test.sh +git commit -m "test: clarify OpenClash bypass impact copy" +``` + +--- + +### Task 2: Implement the LuCI copy and translations + +**Files:** +- Modify: `htdocs/luci-static/resources/view/tailscale/setting.js` +- Modify: `po/templates/tailscale.pot` +- Modify: `po/zh_Hans/tailscale.po` +- Modify: `po/zh_Hant/tailscale.po` +- Test: `tests/setting_openclash_bypass_test.js` +- Test: `tests/package_release_test.sh` + +**Interfaces:** +- Consumes: exact msgid/translation contract from Task 1. +- Produces: LuCI-rendered switch label, explanation, and status strings with matching gettext catalogs. + +- [ ] **Step 1: Update the status msgids in `renderOpenclashBypassStatus()`** + +Use: + +```js +const labels = { + active: _('Enabled; 4 bypass rules are active'), + waiting: _('Enabled; waiting for OpenClash nftables chains'), + disabled: _('Disabled; Tailscale traffic is handled by OpenClash'), + absent: _('OpenClash is not installed; bypass is not required'), + unsupported: _('Unsupported; firewall4/nftables is required'), + error: _('Configuration error') +}; +``` + +- [ ] **Step 2: Update the form label and description** + +Use: + +```js +o = s.taboption('openclash', form.Flag, 'openclash_bypass_enabled', _('Protect Tailscale Traffic (Bypass OpenClash)'), + _('Keep Tailscale control connections, direct connections, Tailnet DNS, and subnet traffic outside OpenClash. When disabled, this traffic is handled by OpenClash; node connectivity, direct paths, subnet access, and internal DNS are no longer protected by this feature. Keep this enabled while using OpenClash.')); +``` + +- [ ] **Step 3: Replace matching POT and PO entries** + +Update all removed msgids in the template and both language catalogs. Use these Simplified Chinese status strings: + +```text +已开启,4 条绕过规则已生效 +已开启,正在等待 OpenClash 创建 nftables 链 +已关闭,Tailscale 流量将由 OpenClash 处理 +未安装 OpenClash,无需绕过 +当前系统不支持,仅支持 firewall4/nftables +``` + +The exact Traditional Chinese switch description must be: + +```text +開啟後,Tailscale 控制連線、直連通訊、Tailnet DNS 和跨子網流量不會被 OpenClash 接管。關閉後,這些流量將重新經過 OpenClash,節點在線、點對點直連、跨子網存取和 Tailnet DNS 解析不再受本功能保護;如果 OpenClash 規則接管或重新導向這些流量,可能導致節點離線、直連退化為 DERP、跨子網存取或 Tailnet DNS 中斷。使用 OpenClash 時必須保持開啟。 +``` + +Use these Traditional Chinese status strings: + +```text +已開啟,4 條繞過規則已生效 +已開啟,正在等待 OpenClash 建立 nftables 鏈 +已關閉,Tailscale 流量將由 OpenClash 處理 +未安裝 OpenClash,無需繞過 +目前系統不支援,僅支援 firewall4/nftables +``` + +- [ ] **Step 4: Run focused tests and verify GREEN** + +Run: + +```sh +node tests/setting_openclash_bypass_test.js +sh tests/package_release_test.sh +``` + +Expected: both pass. + +- [ ] **Step 5: Run the complete local verification suite** + +Run the same behavior, syntax, JSON, and translation checks as `.github/workflows/release.yml`. Expected: all shell and JavaScript tests pass; syntax, JSON, and `msgfmt --check-format` produce no errors. + +- [ ] **Step 6: Commit the implementation** + +```sh +git add htdocs/luci-static/resources/view/tailscale/setting.js po/templates/tailscale.pot po/zh_Hans/tailscale.po po/zh_Hant/tailscale.po +git commit -m "fix: explain OpenClash bypass impact" +``` + +--- + +### Task 3: Review, CI build, and office hot deployment + +**Files:** +- Read: `.github/workflows/release.yml` +- Deploy from CI artifact: `/www/luci-static/resources/view/tailscale/setting.js` +- Deploy from CI artifact: `/usr/lib/lua/luci/i18n/tailscale.zh-cn.lmo` + +**Interfaces:** +- Consumes: reviewed commits from Tasks 1 and 2 and successful `ipk` CI artifact. +- Produces: a merged source change and an office router page using matching JavaScript and Simplified Chinese LMO. + +- [ ] **Step 1: Request an independent code review** + +Review `origin/main...HEAD` for translation completeness, status accuracy, accidental helper behavior changes, and missing tests. Resolve every actionable finding and rerun the complete suite. + +- [ ] **Step 2: Push and create a PR** + +Push `codex/clarify-openclash-bypass-ui`, create a PR against `main`, and wait for Behavior and syntax tests, Build ipk, and Build apk to finish successfully. + +- [ ] **Step 3: Extract exact deployment files from the successful ipk artifact** + +Download the `luci-app-tailscale-ipk` Actions artifact for the PR head. Extract the main package and `luci-i18n-tailscale-zh-cn` package, then obtain: + +```text +/www/luci-static/resources/view/tailscale/setting.js +/usr/lib/lua/luci/i18n/tailscale.zh-cn.lmo +``` + +Verify the JavaScript contains the new msgids and the LMO contains the new Simplified Chinese strings before connecting to the router. + +- [ ] **Step 4: Back up and arm rollback on the office router** + +Create a timestamped directory under `/root/`, copy the current JavaScript and LMO into it, record SHA-256 hashes, and install a five-minute rollback watchdog. The rollback restores both files and removes LuCI index/module caches. + +- [ ] **Step 5: Deploy the matching JavaScript and LMO** + +Copy both files into place with their existing owner and mode, remove LuCI cache files, and do not restart Tailscale, OpenClash, firewall4, or network. + +- [ ] **Step 6: Verify and disarm rollback** + +Verify: + +```text +The LuCI source and LMO contain the new strings. +The page renders the Chinese switch, explanation, and active status. +openclash_bypass_status reports state=active and rules_present=4. +Tailscale and OpenClash are running. +Tailnet DNS, public DNS, public web access, and cross-subnet reachability work. +No new related errors appear in logread. +``` + +Disarm the watchdog only after every check passes. If any check fails, run rollback and repeat the same read-only verification. + +- [ ] **Step 7: Merge only after CI and office validation** + +Squash-merge the PR after all checks and office validation pass. Do not create the `v1.2.8` tag or upgrade the installed package in this task. diff --git a/luci-app-tailscale/docs/superpowers/plans/2026-07-18-tailscale-current-device-list.md b/luci-app-tailscale/docs/superpowers/plans/2026-07-18-tailscale-current-device-list.md new file mode 100644 index 000000000..b33d7099e --- /dev/null +++ b/luci-app-tailscale/docs/superpowers/plans/2026-07-18-tailscale-current-device-list.md @@ -0,0 +1,188 @@ +# Tailscale Current Device List Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Show the current OpenWrt device in the existing user-grouped Tailscale device list and label its probe cell as “Current device”. + +**Architecture:** Extend `parseStatus()` to normalize the top-level Tailscale `Self` object through the same mapping used for `Peer` entries, adding an `isSelf` marker. Keep grouping, filtering, and pagination unchanged; branch only when rendering the last column so the current device cannot be probed. + +**Tech Stack:** LuCI JavaScript, Tailscale `status --json`, Node.js behavior tests, gettext PO translations, shell package checks. + +## Global Constraints + +- The current device must be grouped by `Self.UserID` under the same user heading as its other devices. +- The current device participates in existing status, subnet, and pagination behavior. +- The probe column displays “Current device” and never invokes the probe helper for `Self`. +- A missing `Self` object remains compatible with the existing peer-only response. +- Existing uncommitted `.superpowers/sdd/task-*-report.md` files must not be modified or committed. + +--- + +### Task 1: Normalize and render the current device + +**Files:** +- Modify: `tests/peers_pagination_test.js` +- Modify: `htdocs/luci-static/resources/view/tailscale/peers.js` + +**Interfaces:** +- Consumes: Tailscale status JSON containing optional top-level `Self`, `Peer`, and `User` objects. +- Produces: `parseStatus(stdout)` entries with `isSelf: boolean`; the current device row renders a non-interactive “Current device” probe cell. + +- [ ] **Step 1: Write the failing normalization test** + +Add a `Self` object to the existing status fixture and assert that it is returned with its user metadata and marker: + +```javascript +Self: { + ID: 'self-node', + HostName: 'hz-office-openwrt', + DNSName: 'hz-office-openwrt.example.ts.net.', + TailscaleIPs: ['100.64.2.15'], + PrimaryRoutes: ['192.168.100.0/24'], + UserID: 2, + Online: true +} +``` + +```javascript +const selfPeer = normalizedPeers.find(item => item.isSelf); +assert(selfPeer && selfPeer.name === 'hz-office-openwrt', 'Self should be normalized into the device list'); +assert(selfPeer.userName === 'Alpha User', 'Self should resolve its owner through UserID'); +assert(selfPeer.routes.join(',') === '192.168.100.0/24', 'Self should preserve its advertised routes'); +``` + +- [ ] **Step 2: Write the failing render test** + +Render a list containing a current-device entry and verify that the serialized tree contains `Current device` but no `Probe` action for that row: + +```javascript +const currentDevice = { + id: 'self-node', + name: 'hz-office-openwrt', + ip: '100.64.2.15', + userKey: '2', + userName: 'Alpha User', + userLoginName: 'alpha@example.test', + online: true, + lastSeen: '-', + exitNode: false, + routes: ['192.168.100.0/24'], + hasSubnetRoutes: true, + probeTarget: '100.64.2.15', + isSelf: true +}; +``` + +- [ ] **Step 3: Run the focused test and verify RED** + +Run: + +```bash +node tests/peers_pagination_test.js +``` + +Expected: FAIL because `parseStatus()` ignores `Self` and the row renderer has no current-device branch. + +- [ ] **Step 4: Implement shared status normalization** + +In `parseStatus()`, introduce a local `appendPeer(peerId, peer, isSelf)` helper containing the existing normalization logic. Call it once for `parsed.Self` and once per `parsed.Peer` entry. Include: + +```javascript +isSelf: !!isSelf +``` + +Use a stable current-device identifier derived from `Self.ID`, falling back to `Self.PublicKey`, `Self.HostName`, and finally `self`. + +- [ ] **Step 5: Render a non-interactive current-device probe cell** + +In the device-row loop, branch before creating the probe button: + +```javascript +var probeCell = peer.isSelf + ? E('span', { style: 'font-weight:600;color:#475569' }, _('Current device')) + : E('div', { + style: 'display:flex;align-items:center;gap:8px;flex-wrap:wrap' + }, [button, resultNode]); +``` + +Guard `probePeer()` with `if (peer.isSelf || !peer.online) return;` as defense in depth. + +- [ ] **Step 6: Run the focused test and verify GREEN** + +Run: + +```bash +node tests/peers_pagination_test.js +``` + +Expected: `peers pagination and behavior tests passed`. + +### Task 2: Add translations and package regression coverage + +**Files:** +- Modify: `po/templates/tailscale.pot` +- Modify: `po/zh_Hans/tailscale.po` +- Modify: `po/zh_Hant/tailscale.po` +- Modify: `tests/package_release_test.sh` + +**Interfaces:** +- Consumes: gettext key `Current device` emitted by `peers.js`. +- Produces: Simplified Chinese `当前设备`, Traditional Chinese `目前設備`, and package-level assertions for the new behavior. + +- [ ] **Step 1: Add failing package assertions** + +Add checks requiring the renderer marker and translations: + +```bash +assert_contains "peer.isSelf" htdocs/luci-static/resources/view/tailscale/peers.js +assert_contains "Current device" htdocs/luci-static/resources/view/tailscale/peers.js +assert_po_entry "Current device" "当前设备" po/zh_Hans/tailscale.po +assert_po_entry "Current device" "目前設備" po/zh_Hant/tailscale.po +``` + +- [ ] **Step 2: Run the package test and verify RED** + +Run: + +```bash +sh tests/package_release_test.sh +``` + +Expected: FAIL because the gettext entries do not exist yet. + +- [ ] **Step 3: Add gettext entries** + +Add `Current device` to the POT template and both PO catalogs: + +```po +msgid "Current device" +msgstr "当前设备" +``` + +```po +msgid "Current device" +msgstr "目前設備" +``` + +- [ ] **Step 4: Run focused and complete verification** + +Run: + +```bash +node tests/peers_pagination_test.js +sh tests/package_release_test.sh +for test_file in tests/*_test.sh; do sh "$test_file"; done +git diff --check +``` + +Expected: all commands exit `0`, the behavior test prints its success message, and no whitespace errors are reported. + +- [ ] **Step 5: Commit only feature files** + +```bash +git add htdocs/luci-static/resources/view/tailscale/peers.js \ + tests/peers_pagination_test.js tests/package_release_test.sh \ + po/templates/tailscale.pot po/zh_Hans/tailscale.po po/zh_Hant/tailscale.po \ + docs/superpowers/plans/2026-07-18-tailscale-current-device-list.md +git commit -m "feat: show current device in peer list" +``` diff --git a/luci-app-tailscale/docs/superpowers/plans/2026-07-23-tailscale-mwan3-policy-routing.md b/luci-app-tailscale/docs/superpowers/plans/2026-07-23-tailscale-mwan3-policy-routing.md new file mode 100644 index 000000000..b672adb50 --- /dev/null +++ b/luci-app-tailscale/docs/superpowers/plans/2026-07-23-tailscale-mwan3-policy-routing.md @@ -0,0 +1,18 @@ +# Tailscale 与 mwan3 策略路由兼容实施计划 + +1. 已为 helper 和 hotplug 增加独立的 shell 行为测试,覆盖创建、幂等、 + 自愈、关闭、冲突、默认路由保护和所有权保护。 +2. 已实现独立配置、init service、hotplug 与 `tailscale_policy_routing` helper; + 禁止 helper 操作 firewall、OpenClash 或 mwan3 服务。 +3. 已接入软件包升级快照/恢复和 Tailscale 生命周期同步,确保应用升级、重启和 + 接口事件后配置不丢失。 +4. 已通过 rpcd 暴露只读状态,在 LuCI 高级设置中增加可选开关、中文状态和 + 从真实 LAN 客户端验证的说明;冲突场景保留所有权、route-only `ifupdate` 会 + 重新评估 exit node 与 mwan3 优先级保护。 +5. 已补充简体/繁体中文翻译、包结构检查、rpcd 与 LuCI 视图测试。 +6. 已运行可由当前本地终端完整执行的 shell/JavaScript 测试和 `git diff --check`。 + OpenClash 信号注入测试会终止当前终端代理,保留给 Linux CI 执行。仅在用户另行 + 明确确认后,才部署到杭州办公室 OpenWrt 验证;不写入 hzsls-openwrt。 +7. 已在独立评审后补强:统一规范化 mwan3 标记掩码的十六进制大小写;软件包卸载 + 不会遗留无 UCI 所有权的精确 `priority 1000 lookup 52` 规则;策略服务以 15 秒 + 间隔静默校验已拥有的运行态规则,覆盖 tailscaled 单独重启和路由状态变更。 diff --git a/luci-app-tailscale/docs/superpowers/specs/2026-07-06-adguard-dns-switch-design.md b/luci-app-tailscale/docs/superpowers/specs/2026-07-06-adguard-dns-switch-design.md new file mode 100644 index 000000000..297e72aa5 --- /dev/null +++ b/luci-app-tailscale/docs/superpowers/specs/2026-07-06-adguard-dns-switch-design.md @@ -0,0 +1,202 @@ +# AdGuard DNS Auto Switch Design + +## Goal + +Add an optional AdGuard DNS auto-switch feature to `luci-app-tailscale`. + +The feature lets OpenWrt LAN clients use Headscale DNS Records while Tailscale is healthy, and automatically fall back to public DNS behavior while Tailscale DNS is unhealthy. Tailscale itself keeps its official behavior; OpenWrt may run with either `accept_dns=1` or `accept_dns=0` because the switch health check queries `100.100.100.100` directly. + +This is intended for setups where OpenWrt runs local AdGuard Home as the LAN DNS entry point. + +## Non-Goals + +- Do not modify Tailscale's native DNS behavior. +- Do not add or maintain per-service DNS records in OpenWrt. +- Do not introduce a separate DNS server. +- Do not require users to know or edit the AdGuard Home config file path. +- Do not depend on dnsmasq layout, except for user-provided upstream entries such as `[/lan/]127.0.0.1:5353`. + +## User Model + +Users maintain DNS records in two authoritative places: + +- Internal overrides in Headscale DNS Records. +- Public records in public DNS, such as AliDNS. + +Each OpenWrt instance only configures a default public upstream list and a small set of Tailnet conditional upstreams. + +## Profiles + +The switch manages AdGuard Home `upstream_dns`. + +### Down Profile + +The down profile is the user-configured default upstream list. Example: + +```text +[/lan/]127.0.0.1:5353 +223.5.5.5 +223.6.6.6 +119.29.29.29 +``` + +This is used when Tailscale DNS is unhealthy. Example outcomes: + +- `sso.litata.com` resolves through public DNS. +- `tailscale.litata.com` resolves through public DNS. +- `deploy.litata.com` resolves through public DNS. +- `code.litata.com` returns public DNS behavior, normally `NXDOMAIN` if no public record exists. + +### Up Profile + +The up profile is: + +```text +default upstreams + tailnet conditional upstreams +``` + +Example: + +```text +[/lan/]127.0.0.1:5353 +[/litata.tailnet/]100.100.100.100 +[/litata.com/]100.100.100.100 +223.5.5.5 +223.6.6.6 +119.29.29.29 +``` + +This is used when Tailscale DNS is healthy. Example outcomes: + +- `sso.litata.com` resolves to the Headscale DNS Record internal IP and avoids public OTP policy. +- `code.litata.com` resolves to the Headscale DNS Record internal IP. +- `deploy.litata.com` falls through Headscale split DNS to public DNS. +- `litata.tailnet` names resolve through MagicDNS. + +## Health Check + +The switch determines health by querying the configured health check domain through the configured Tailnet DNS server. + +Example: + +```sh +nslookup sso.litata.com 100.100.100.100 +``` + +The result is healthy only when at least one returned IP equals one of the configured expected IPs, for example `10.10.6.156`. + +Recommended hysteresis: + +- Switch to up after 2 consecutive successful checks. +- Switch to down after 2 consecutive failed checks. +- Default check interval: 10 seconds. + +## Strong Enablement Checks + +The feature must not start unless all checks pass: + +- AdGuardHome process is running. +- Port 53 is listened by AdGuardHome. +- LAN DHCP explicitly advertises the OpenWrt LAN IP as DNS, for example `6,192.168.100.1`. +- AdGuard API is reachable and writable. +- The Tailnet DNS health check can return the expected IP at least once. + +LuCI must show failing checks and block enabling the feature until they pass. + +## AdGuard Integration + +Use AdGuard Home HTTP API rather than exposing `/etc/AdGuardHome.yaml` in LuCI. + +Default API base URL: + +```text +http://127.0.0.1:3000 +``` + +The implementation should support authenticated AdGuard instances with username and password. The password must be treated like the Tailscale auth key: do not echo it back in LuCI; show a configured placeholder and keep the existing value when the field is empty. + +API operations: + +- Read current DNS config. +- Write `upstream_dns` for the selected profile. +- Clear cache after profile changes when configured. + +If API write fails, keep the last known profile and log the error. + +## Runtime Components + +Add an independent script: + +```text +/usr/sbin/tailscale_adguard_dns_switch +``` + +Responsibilities: + +- Read UCI configuration. +- Run strong preflight checks. +- Poll health state. +- Apply the up or down AdGuard profile. +- Clear AdGuard cache after profile changes. +- Log state changes and rate-limit repeated failures. + +Add a named procd instance under the existing `/etc/init.d/tailscale` service. + +Responsibilities: + +- Start only when the feature is enabled. +- Run the switch script in loop mode. +- Restart on failure via procd respawn. +- Reload when `tailscale` UCI changes. +- Apply the down profile during Tailscale service stop/reload so persisted AdGuard upstreams do not keep pointing at Tailnet DNS after the watcher stops. + +## LuCI Configuration + +Add an "AdGuard DNS Auto Switch" section to the existing Tailscale settings page. + +Fields: + +- Enable AdGuard DNS auto switch. +- Default upstream DNS list. +- Tailnet conditional upstream DNS list. +- Health check domain. +- Expected health check IP list. +- Check interval. +- Success threshold. +- Failure threshold. +- Clear AdGuard cache after switching. +- AdGuard API URL. +- AdGuard username. +- AdGuard password. + +The section should include a read-only status panel for the strong enablement checks. + +## Cache Policy + +Keep AdGuard DNS cache disabled by default for this feature. + +If cache is enabled by the user, the switch should clear AdGuard cache after each profile change. Users that want caching should use a short maximum TTL such as 30 seconds and keep optimistic cache disabled. + +Tailscale DNS TTL is not controlled by this feature. + +## Testing + +Add shell tests for the switch script: + +- Profile generation from default and Tailnet upstream lists. +- Healthy result detection from `nslookup` output. +- Up transition after configured success threshold. +- Down transition after configured failure threshold. +- AdGuard API payload generation. +- Missing/invalid configuration handling. +- Preflight failure behavior. + +Existing `tailscale_keepalive` tests must continue passing. + +Manual verification on OpenWrt should cover: + +- Up profile makes `sso.litata.com` resolve to the internal Headscale DNS Record IP. +- Down profile makes `sso.litata.com` resolve through public DNS. +- `code.litata.com` fails in down state when no public record exists. +- `deploy.litata.com` resolves through public DNS in both states. +- Tailscale can restart and reconnect without self-bootstrap DNS failure. diff --git a/luci-app-tailscale/docs/superpowers/specs/2026-07-13-tailscale-peer-observability-design.md b/luci-app-tailscale/docs/superpowers/specs/2026-07-13-tailscale-peer-observability-design.md new file mode 100644 index 000000000..921a2afae --- /dev/null +++ b/luci-app-tailscale/docs/superpowers/specs/2026-07-13-tailscale-peer-observability-design.md @@ -0,0 +1,209 @@ +# Tailscale Peer Observability Design + +## Goal + +Add a LuCI page for observing all Tailscale peers and manually probing whether traffic to a peer is direct or relayed through DERP. + +This feature is diagnostic only. It does not change Tailscale configuration, keepalive configuration, firewall rules, DNS settings, or network interfaces. + +## User Experience + +Add a top-level Tailscale child page: + +- Menu path: `admin/vpn/tailscale/peers` +- Title: `Peers` +- Chinese title: `对端列表` +- Order: after `Interface Info`, before `Logs` + +The page shows a compact table of every peer from `tailscale status --json`. + +Columns: + +- Name: short DNS name when available, otherwise host name, DNS name, or Tailnet IP. +- Tailnet IP: first `TailscaleIPs` value. +- Status: online or offline. +- Last Seen: `LastSeen` when available. +- Role: badges for exit node option and advertised subnet routes. +- Advertised Subnets: `PrimaryRoutes`, shown as comma-separated CIDRs. +- Probe: a button for one-shot connectivity probing. +- Probe Result: latest result for that row. + +Filters: + +- All +- Online +- Advertising subnets + +Each row has a `Probe` button. Clicking it probes only that peer and updates that row. Page load does not automatically ping all peers. + +Probe result labels: + +- `Direct`: Tailscale ping reached the peer directly. +- `DERP`: Tailscale ping used DERP relay. +- `Failed`: the probe command failed or the peer did not answer. +- `Unknown`: output did not match known direct or DERP patterns. + +The first version does not include a `Probe all` button. That avoids accidental bursts of probes on larger tailnets. + +## Data Flow + +The LuCI view loads status data using: + +```sh +/usr/sbin/tailscale status --json +``` + +The view parses peers in JavaScript and renders the table. It also polls status periodically, matching existing page patterns. + +Manual probes use a helper command: + +```sh +/usr/sbin/tailscale_peer_probe +``` + +The view calls the helper via `fs.exec()`. The helper validates the peer argument, runs `tailscale ping`, parses the output, and prints JSON. + +Example helper output: + +```json +{ + "peer": "hzsls-openwrt", + "ok": true, + "path": "direct", + "latency_ms": 12.4, + "summary": "direct 12.4 ms", + "raw": "pong from hzsls-openwrt ..." +} +``` + +For DERP: + +```json +{ + "peer": "jiaxing-idc-wg", + "ok": true, + "path": "derp", + "latency_ms": 41.8, + "relay": "litata", + "summary": "DERP litata 41.8 ms", + "raw": "pong from jiaxing-idc-wg via DERP(litata) ..." +} +``` + +For failure: + +```json +{ + "peer": "missing-peer", + "ok": false, + "path": "failed", + "summary": "tailscale ping failed", + "raw": "..." +} +``` + +Exact parsing patterns will be covered by fixtures from the installed Tailscale version before implementation. The helper will preserve raw output so unfamiliar formats remain debuggable. + +## Components + +### LuCI view + +Add: + +```text +htdocs/luci-static/resources/view/tailscale/peers.js +``` + +Responsibilities: + +- Fetch `tailscale status --json`. +- Render all peers. +- Apply client-side filters. +- Call `tailscale_peer_probe` for one selected peer. +- Display loading, success, and failure states per row. + +### Probe helper + +Add: + +```text +root/usr/sbin/tailscale_peer_probe +``` + +Responsibilities: + +- Accept exactly one peer argument. +- Validate the argument before running anything. +- Run `/usr/sbin/tailscale ping`. +- Classify direct, DERP, failed, or unknown. +- Output JSON only. + +Argument validation: + +- Length: 1 to 253 characters. +- Allowed characters: letters, numbers, `_`, `-`, `.`, `:`. +- No shell evaluation. The helper passes arguments as command arguments, not interpolated shell fragments. + +### Menu and ACL + +Update: + +```text +root/usr/share/luci/menu.d/luci-app-tailscale.json +root/usr/share/rpcd/acl.d/luci-app-tailscale.json +``` + +ACL grants read-only exec access to: + +```sh +/usr/sbin/tailscale_peer_probe +``` + +The feature remains read-only from LuCI's perspective. + +## Error Handling + +If `tailscale status --json` fails, the page shows a clear error and does not render stale peer rows. + +If no peers exist, the page shows `No peers found`. + +If a probe is already running for a row, that row's probe button is disabled until it completes. + +If helper output is invalid JSON, the row shows `Failed` and includes a short error message. + +If Tailscale is not running, both status loading and probe actions surface that state instead of silently failing. + +## Tests + +Add or update tests: + +- Static package test confirms the new `peers.js`, menu entry, ACL entry, and helper exist. +- Shell tests for `tailscale_peer_probe` cover: + - invalid peer arguments are rejected; + - direct ping output is classified as `direct`; + - DERP ping output is classified as `derp`; + - failed ping output is classified as `failed`; + - unknown successful output is classified as `unknown`. +- JavaScript syntax check includes `peers.js`. +- Shell syntax check includes `tailscale_peer_probe`. + +## Deployment + +Source changes go through the existing PR branch and release package flow. + +For office OpenWrt hot deployment during development: + +1. Backup current LuCI view and helper files under `/tmp`. +2. Upload `peers.js`, menu JSON, ACL JSON, and helper. +3. Reload only affected LuCI/rpcd metadata if needed. +4. Verify by reading the served LuCI static file over HTTP and calling the helper with a known peer. + +No network reload, Tailscale restart, or firewall restart is part of this feature. + +## Non-Goals + +- No automatic probing of all peers on page load. +- No keepalive configuration changes. +- No Headscale API integration. +- No historical latency chart. +- No persistent storage of probe results. diff --git a/luci-app-tailscale/docs/superpowers/specs/2026-07-16-tailscale-openclash-bypass-design.md b/luci-app-tailscale/docs/superpowers/specs/2026-07-16-tailscale-openclash-bypass-design.md new file mode 100644 index 000000000..c0a861624 --- /dev/null +++ b/luci-app-tailscale/docs/superpowers/specs/2026-07-16-tailscale-openclash-bypass-design.md @@ -0,0 +1,359 @@ +# Tailscale OpenClash Bypass Design + +## Status + +Design approved on 2026-07-16; final-review corrections approved on 2026-07-17. + +## Background + +OpenClash can intercept traffic generated by `tailscaled` before Tailscale completes +peer-to-peer endpoint negotiation. The affected traffic falls into two groups: + +- locally generated Tailscale outer UDP and TCP traffic, identified by Tailscale's + `0x80000` packet mark; +- traffic entering the router from `tailscale0` before it is forwarded to a local + network or subnet route. + +The office router currently bypasses OpenClash with four manually maintained +nftables rules in OpenClash's developer custom firewall script. The rules work, +but the installation is device-specific and is not owned by the customized +`luci-app-tailscale` package. + +The package already has a separate WAN-direct feature that manages firewall UCI +configuration. That feature and the OpenClash bypass must remain independent. +The OpenClash bypass must not use a firewall4 reload as a lifecycle mechanism. + +## Goals + +- Automatically install and maintain the four required bypass rules when the + customized package is installed on an OpenWrt device using OpenClash. +- Use OpenClash's supported developer custom firewall script hook so rules are + recreated after OpenClash creates its built-in nftables chains. +- Preserve all user-owned content in the OpenClash custom firewall script. +- Make applying and removing rules idempotent and ownership-safe. +- Provide a LuCI option that is enabled by default when OpenClash is present and + can be explicitly disabled. +- Support only firewall4/nftables. +- Keep the WAN-direct and OpenClash-bypass lifecycles completely independent. + +## Non-Goals + +- Supporting firewall3 or legacy iptables. +- Fixing OpenClash's own firewall-only reload behavior. +- Monitoring or repairing arbitrary firewall4 reloads initiated outside this + feature. +- Restarting, enabling, disabling, or otherwise managing the OpenClash service. +- Replacing OpenClash's native local-network destination bypass logic. +- Dynamically synchronizing Tailscale routing table 52 into OpenClash rules. +- Modifying `/etc/config/firewall` for the OpenClash bypass. + +## Hard Boundary Between Features + +The package contains two separate features with no calls between them. + +### WAN-Direct Flow + +The existing WAN-direct feature owns only its firewall UCI objects and its +firewall4 reload behavior. + +```text +WAN-direct setting changes + -> update firewall UCI objects + -> reload firewall4 + -> verify WAN-direct firewall state +``` + +The WAN-direct flow must not: + +- edit the OpenClash custom firewall script; +- invoke the OpenClash bypass helper; +- inspect OpenClash nftables chains; +- restart or reload OpenClash; +- repair OpenClash after a firewall4 reload. + +### OpenClash-Bypass Flow + +The new OpenClash-bypass feature owns only its hook block and four runtime +nftables rules. + +```text +OpenClash creates its built-in nftables chains + -> OpenClash executes its developer custom firewall script + -> the managed hook invokes the package helper + -> the helper reconciles four bypass rules +``` + +The OpenClash-bypass flow must not: + +- read or write firewall UCI objects; +- edit `/etc/config/firewall`; +- reload or restart firewall4; +- invoke the WAN-direct helper or apply path; +- restart, reload, enable, or disable OpenClash. + +Changing settings in one flow must not execute any action from the other flow. +If both settings are changed in one LuCI submission, each flow executes only its +own independent apply operation. + +## Components + +### UCI Setting and LuCI Control + +Add an OpenClash-bypass option to the Tailscale application's own UCI section. +Its effective default is enabled when OpenClash is installed. When OpenClash is +not installed, the feature silently performs no action and does not create an +OpenClash directory or custom script. + +The LuCI control supports these operational states: + +- enabled and active; +- enabled and waiting for OpenClash chains; +- disabled; +- unsupported because the device is not using firewall4/nftables; +- configuration error requiring user attention. + +The setting is independent from all WAN-direct options. + +### Package-Owned Helper + +Install an executable helper at: + +```text +/usr/sbin/tailscale_openclash_bypass +``` + +The helper exposes these operations: + +- `sync`: re-read the app-owned UCI setting and reconcile the complete enabled or + disabled state under one exclusive lock; +- `reconcile-hook`: install or update the managed hook block without changing + user content; this remains a focused diagnostic/test operation and is not used + by the procd lifecycle; +- `apply`: reconcile the four runtime nftables rules; +- `cleanup`: remove only the managed hook block and managed nftables rules; +- `status`: return a machine-readable operational state for LuCI and tests. + +All mutating operations are idempotent and use an OpenWrt-compatible lock. The +procd service invokes only `sync`, so one UCI decision, hook reconciliation, and +runtime-rule reconciliation cannot be interleaved with a concurrent enable, +disable, OpenClash startup, or LuCI apply. + +### Managed OpenClash Hook + +The helper manages one uniquely delimited block in: + +```text +/etc/openclash/custom/openclash_custom_firewall_rules.sh +``` + +The block contains only a guarded invocation of the package helper: + +```sh +# BEGIN luci-app-tailscale 托管:Tailscale 绕过 OpenClash +if test -x /usr/sbin/tailscale_openclash_bypass; then + /usr/sbin/tailscale_openclash_bypass apply +fi +# END luci-app-tailscale 托管:Tailscale 绕过 OpenClash +``` + +Hook reconciliation must: + +- preserve all bytes outside the managed block; +- preserve the file's mode and ownership; +- identify markers only when an entire line exactly equals the marker, using + BusyBox 1.36-compatible tools; `grep -b` is not available on the target; +- validate the complete managed body and replace a stale managed body with the + canonical block; +- write through a temporary file and atomically replace the original only after + `sh -n` succeeds; +- restore the original immediately if validation or replacement fails; +- refuse to modify a file containing malformed or duplicate managed markers; +- create no file when OpenClash is absent; +- create a minimal executable custom script only when OpenClash is installed but + the documented hook path is missing. + +Temporary hook and nftables files are tracked explicitly and removed by signal +and exit traps. Replacement restores numeric ownership before restoring mode so +`chown` cannot clear special mode bits after `chmod`. + +The marker comments and nftables rule comments are written in Chinese, with a +stable `luci-app-tailscale` ownership prefix for reliable cleanup. + +## Managed nftables Rules + +The helper manages exactly these four rules in table `inet fw4`: + +| Chain | Match | Action | +| --- | --- | --- | +| `openclash_mangle_output` | `meta mark & 0x00ff0000 == 0x00080000` | `return` | +| `openclash_output` | `meta mark & 0x00ff0000 == 0x00080000` | `return` | +| `openclash_mangle` | `iifname "tailscale0"` | `return` | +| `openclash` | `iifname "tailscale0"` | `return` | + +Each rule includes a counter and a unique comment beginning with +`luci-app-tailscale:`. The rules are inserted at the head of their respective +OpenClash chains so later interception rules cannot run first. + +Before applying, the helper verifies that firewall4/nftables and all four target +chains exist. It then reconciles the complete rule set as one operation. It does +not leave a partial rule set when any target chain is missing. + +Rules are identified for update and removal only by their unique ownership +comments. The helper does not remove equivalent rules written by the user or +another package. Repeated application leaves exactly one owned rule in each +target chain. + +An absent `inet fw4` table or an already removed target chain is a successful +cleanup no-op. If a chain disappears between a table snapshot and a chain read, +the helper refreshes table state before classifying the result as waiting, +unsupported, or an actual error. + +## Existing Site-to-Site SNAT Contract + +The existing `子网互通` option remains outside the OpenClash flow. It controls +both Tailscale `--snat-subnet-routes` and the app-owned firewall4 zone's `masq` +option. Enabling it means `--snat-subnet-routes=false` and +`firewall.tszone.masq='0'`. + +The `tailscale` firewall zone exists whenever either forwarding access rules are +selected or site-to-site no-SNAT is enabled. Forwarding sections are still +created only for selected access directions. Therefore no-SNAT remains effective +when the access list is empty, without creating unrelated forwarding rules. + +## Destination Bypass Responsibility + +The helper does not create destination-based rules for Tailnet addresses or +advertised subnet routes. OpenClash's native local-network handling remains +responsible for those destinations. + +In particular, the feature does not enumerate routes from Tailscale routing table +52 and does not add `100.64.0.0/10` or private subnet routes to OpenClash. This +keeps route ownership with Tailscale and destination bypass ownership with +OpenClash. + +## Lifecycle + +### Package Install or Upgrade + +1. Use the standard LuCI package post-install path so cache invalidation and + `rpcd` reload remain intact; do not add a package-specific start/enable hook. +2. OpenWrt's `default_postinst` starts/enables the packaged init script. +3. The one-shot init script invokes `sync`, which re-reads UCI under one lock. +4. If OpenClash is absent, finish without creating a hook or changing rules. +5. If firewall4/nftables is unsupported, report `unsupported` and make no hook or + rule change. +6. When enabled and firewall4 exists, reconcile the managed hook. If all four + OpenClash chains exist, reconcile the rules in one nft transaction. +7. If the chains do not exist, retain the canonical hook and report `waiting`; + do not start or restart OpenClash. Its next successful startup executes the + hook. + +### OpenClash Start or Restart + +OpenClash creates its built-in rules and then executes its developer custom +firewall script. The managed block calls `apply`, which validates the target +chains and reconciles the four rules. No firewall4 reload occurs. + +### Enable Through LuCI + +Enabling follows the same steps as package installation: reconcile the hook and +apply immediately only when all target chains already exist. Missing chains +produce the `waiting` state without managing the OpenClash service. + +### Disable Through LuCI + +Disabling removes the managed hook block and the four owned runtime rules. It +does not remove user rules, reload firewall4, or restart OpenClash. + +### Package Removal + +The package removal hook invokes `cleanup` while the helper is still present. +Cleanup is best-effort and ownership-scoped. An absent OpenClash installation, +absent chains, or an already removed hook is a successful no-op. + +## Failure Handling + +- Missing OpenClash is a silent no-op, not an error. +- Unsupported firewall backend produces an explicit unsupported status and no + changes. +- Missing target chains produce a waiting status and no partial rules. +- Malformed or duplicate managed hook markers produce an error and leave the + custom script unchanged. +- nftables command failures leave a nonzero status and are logged with `logger`. +- The helper never responds to a failure by reloading firewall4 or restarting + OpenClash. +- LuCI displays the helper result without reporting success when hook or rule + reconciliation failed. +- UCI booleans use OpenWrt semantics: `0`, `false`, `off`, `no`, and `disabled` + are false; known true aliases are true; malformed values do not silently enable + the feature. + +OpenClash may fail to rebuild its own chains after a firewall4 reload. That is an +independent OpenClash lifecycle problem. This feature reports missing chains and +waits for a successful OpenClash startup; it does not cross into service repair. + +## Testing + +### Automated Tests + +Add shell and LuCI tests covering: + +- default-enabled detection when OpenClash is installed; +- silent no-op behavior when OpenClash is absent; +- rejection of non-firewall4 backends; +- hook insertion while preserving user content, mode, and ownership; +- hook update and repeated reconciliation without duplication; +- malformed and duplicate marker rejection without file changes; +- hook cleanup without removing surrounding user content; +- exact generation of all four nftables rules; +- insertion at the head of each target chain; +- unique Chinese ownership comments and counters; +- repeated apply leaving one owned rule per chain; +- no partial application when a chain is missing; +- cleanup removing only owned rules; +- package install, upgrade, disable, and removal lifecycle behavior; +- LuCI status and toggle behavior; +- static assertions that the OpenClash helper contains no firewall UCI writes, + firewall reloads, OpenClash service operations, or WAN-direct calls; +- static assertions that the WAN-direct apply path contains no OpenClash bypass + calls or OpenClash service repair. + +Existing WAN-direct tests remain separate and must continue to pass unchanged. + +### Device Verification + +Remote device verification is a separate, explicitly approved operation. It +must verify: + +1. Existing user content in the custom script survives migration. +2. Enabling the feature creates one managed hook block. +3. The four rules are present at the heads of the expected chains. +4. Reapplying does not create duplicates. +5. A normal OpenClash restart recreates all four rules through the official hook. +6. OpenClash proxy traffic remains functional. +7. Tailscale outer traffic bypasses OpenClash and cold peer negotiation reaches a + direct path when the network endpoints permit it. +8. Disabling removes only the package-owned hook and rules. + +Device verification for this flow must not reload firewall4. WAN-direct behavior +is verified independently. + +## Rollback + +The operational rollback is to disable the OpenClash-bypass option, verify that +cleanup removed the owned hook and rules, and then reinstall the previous package +version if a package rollback is required. Cleanup preserves user content. +Because the feature does not write firewall UCI or manage services, rollback +requires no firewall4 or OpenClash restart. + +## Documentation After Implementation + +After package deployment and device verification: + +- replace the office router's manual OpenClash rule procedure with the + package-managed state; +- remove obsolete manual rollback instructions that apply only to the old + hand-maintained script; +- document the LuCI toggle, supported backend, managed hook path, status states, + and ownership-scoped rollback; +- keep the WAN-direct and OpenClash-bypass documentation in separate sections. diff --git a/luci-app-tailscale/docs/superpowers/specs/2026-07-18-openclash-bypass-ui-copy-design.md b/luci-app-tailscale/docs/superpowers/specs/2026-07-18-openclash-bypass-ui-copy-design.md new file mode 100644 index 000000000..55884fe98 --- /dev/null +++ b/luci-app-tailscale/docs/superpowers/specs/2026-07-18-openclash-bypass-ui-copy-design.md @@ -0,0 +1,68 @@ +# OpenClash 绕过开关中文说明设计 + +## 背景与根因 + +办公室 OpenWrt 热部署了包含 OpenClash 绕过页面的新版 JavaScript,但仍安装旧版 `luci-i18n-tailscale-zh-cn`。设备上的 `tailscale.zh-cn.lmo` 不包含新增 msgid,因此 LuCI 将开关、说明和状态回退为英文。 + +这不是浏览器缓存问题。修复必须同时更新源码中的 msgid、简体/繁体翻译和设备上的编译语言目录,不能只改 JavaScript。 + +## 用户意图 + +页面必须直接解释关闭开关的网络影响,而不只描述内部实现。用户应能在保存前明确知道:关闭会让 Tailscale 流量重新进入 OpenClash,并使节点在线、点对点直连、Tailnet DNS 和跨子网访问失去绕过保护。 + +## 页面设计 + +`OpenClash` 作为产品名称保留英文。 + +开关名称: + +> 保护 Tailscale 流量(绕过 OpenClash) + +简体中文说明: + +> 开启后,Tailscale 控制连接、直连通信、Tailnet DNS 和跨子网流量不会被 OpenClash 接管。关闭后,这些流量将重新经过 OpenClash,节点在线、点对点直连、跨子网访问和 Tailnet DNS 解析不再受本功能保护;如果 OpenClash 规则接管或重定向这些流量,可能导致节点掉线、直连退化为 DERP、跨子网访问或 Tailnet DNS 中断。使用 OpenClash 时必须保持开启。 + +繁体中文说明使用相同语义。 + +状态文案: + +- `active`:已开启,4 条绕过规则已生效 +- `waiting`:已开启,正在等待 OpenClash 创建 nftables 链 +- `disabled`:已关闭,Tailscale 流量将由 OpenClash 处理 +- `absent`:未安装 OpenClash,无需绕过 +- `unsupported`:当前系统不支持,仅支持 firewall4/nftables +- `error`:配置错误 + +## 行为边界 + +页面文案只解释既有行为,不改变 helper 的所有权和生命周期: + +- 开启时继续维护 4 条包拥有的 nftables `return` 规则和一个托管 hook。 +- 关闭时继续删除这 4 条规则和托管 hook。 +- 不停止 Tailscale。 +- 不修改 WAN UDP 端口放行。 +- 不修改子网 SNAT。 +- 不 reload firewall4,不启停或重启 OpenClash。 + +## 测试设计 + +先扩展现有测试并确认 RED: + +- `tests/package_release_test.sh` 精确验证简体和繁体中文翻译。 +- `tests/setting_openclash_bypass_test.js` 验证页面使用新的 msgid 和各状态文案。 +- 测试拒绝旧的 `Enable OpenClash Bypass` 及只描述实现、不描述关闭影响的说明。 + +实现后运行完整 shell、JavaScript、语法、JSON 和翻译检查。GitHub Actions 继续负责 Linux/OpenWrt SDK 的 `ipk`、`apk` 构建。 + +## 办公室验证与回滚 + +源码与 CI 通过后,只写办公室 OpenWrt,不修改杭州机房 OpenWrt。 + +部署前备份当前 `setting.js`、`tailscale.zh-cn.lmo` 和相关 LuCI 缓存状态。部署匹配版本的 JavaScript 与编译语言目录后清理 LuCI 缓存,并验证: + +- 开关、说明和状态均显示中文。 +- OpenClash 绕过状态仍为 active,规则数仍为 4。 +- Tailscale、OpenClash、Tailnet DNS、公共 DNS 和跨子网访问正常。 +- 日志没有新增相关错误。 + +任何页面加载错误、语言目录错误或网络回归都会触发回滚:恢复备份的 JavaScript 和 LMO,重新清理 LuCI 缓存并执行同等级别验证。 diff --git a/luci-app-tailscale/docs/superpowers/specs/2026-07-18-tailscale-current-device-list-design.md b/luci-app-tailscale/docs/superpowers/specs/2026-07-18-tailscale-current-device-list-design.md new file mode 100644 index 000000000..d49511af2 --- /dev/null +++ b/luci-app-tailscale/docs/superpowers/specs/2026-07-18-tailscale-current-device-list-design.md @@ -0,0 +1,27 @@ +# Tailscale 当前设备列表设计 + +## 目标 + +在“对端”页面的现有用户/设备两层列表中展示当前 OpenWrt 设备。当前设备应归入其 Tailscale 用户分组,并与其他设备共享状态、地址、角色、已发布网段、筛选和分页逻辑。 + +## 数据处理 + +`tailscale status --json` 将当前设备放在顶层 `Self`,其他设备放在 `Peer`。页面解析状态时应使用同一套规范化逻辑处理两类节点,并为 `Self` 增加 `isSelf: true` 标记。 + +当前设备使用 `Self.UserID` 查找顶层 `User` 中的显示名称和登录名,因此与该用户的其他设备显示在同一分组下。若 `Self` 缺失,则保持现有行为,仅显示 `Peer`。 + +## 页面行为 + +- 当前设备与普通设备使用相同的名称、Tailnet IP、在线状态、最后在线时间、角色和已发布网段列。 +- 当前设备参与“全部”“在线”和“发布子网”筛选。 +- 当前设备参与设备数量统计和现有分页。 +- “探测”列对当前设备固定显示“当前设备”,不渲染探测按钮,也不允许调用探测命令。 +- 普通在线、离线设备的现有探测行为保持不变。 + +## 测试 + +- 验证 `Self` 被规范化并带有 `isSelf` 标记。 +- 验证 `Self` 根据 `UserID` 进入正确的用户分组。 +- 验证缺少 `Self` 时保持兼容。 +- 验证当前设备行显示“当前设备”且没有可点击的探测按钮。 +- 运行现有对端页面测试和完整项目测试,确认筛选、分页、探测及打包检查无回归。 diff --git a/luci-app-tailscale/docs/superpowers/specs/2026-07-23-tailscale-mwan3-policy-routing-design.md b/luci-app-tailscale/docs/superpowers/specs/2026-07-23-tailscale-mwan3-policy-routing-design.md new file mode 100644 index 000000000..559629061 --- /dev/null +++ b/luci-app-tailscale/docs/superpowers/specs/2026-07-23-tailscale-mwan3-policy-routing-design.md @@ -0,0 +1,91 @@ +# Tailscale 与 mwan3 策略路由兼容设计 + +**状态:已实现,待杭州办公室 OpenWrt 部署验证** + +## 背景 + +Tailscale 在 OpenWrt 上维护 IPv4 路由表 `52`,并通常安装优先级为 +`5270` 的 `lookup 52` 规则。启用了 mwan3 的路由器会在更低的优先级安装 +带 fwmark 的策略规则,例如 `2001`、`2002`。LAN 转发流量先被 mwan3 标记后, +会命中这些规则并被发往 WAN,无法到达后续的 Tailscale 路由表。 + +本功能为存在该冲突的路由器提供一个显式、可选的修复:在 mwan3 的 fwmark +规则之前安装 `priority 1000 lookup 52`。当目标不在表 `52` 中时,该 lookup +不产生路由结果,内核会继续评估后续 mwan3 规则;它不接管普通公网流量。 + +## 目标 + +1. 检测 mwan3 的早期 fwmark 规则与 Tailscale 表 `52` 的优先级冲突。 +2. 通过独立 UCI 开关维护应用拥有的 `network` 规则: + `network.ts_mwan3_table52`、IPv4、优先级 `1000`、`lookup 52`。 +3. 在接口 `ifup` / `ifupdate` 后只做幂等的运行态自愈,不重启任何服务。 +4. 在 LuCI 中呈现启用状态、阻断原因和真实 LAN 客户端的验证要求。 +5. 当表 `52` 存在默认路由(例如使用 Tailscale exit node)时拒绝启用, + 防止把普通公网流量绕过 mwan3。 + +## 非目标与边界 + +- 不修改 `/etc/config/firewall`,不重载 firewall4,不操作 nftables。 +- 不管理、重启或重载 mwan3。 +- 不管理 OpenClash 服务或其配置;OpenClash bypass 仍是独立流程。 +- 不直接修改或替换 tailscaled 所有的 `5270 lookup 52` 运行态规则。 + 该规则由 tailscaled 管理,重启或路由同步时会重新生成。 +- 不将本功能用于发布 Tailscale 子网路由,也不替代 Headscale 的路由审批。 + +## 三条独立流程 + +| 流程 | 所有者 | 可修改的资源 | +| --- | --- | --- | +| WAN 直连 | Tailscale LuCI WAN 设置 | firewall UCI / firewall4 | +| OpenClash 绕过 | OpenClash 自定义防火墙 hook | package-owned helper 的 nft 规则 | +| mwan3 优先级兼容 | `tailscale_policy_routing` helper | `network` UCI 与运行态 `ip rule` | + +三条流程不能互相 reload 或管理对方服务。 + +## 配置与所有权 + +- 独立配置包:`/etc/config/tailscale_policy_routing`。 +- 开关:`tailscale_policy_routing.settings.enabled`,默认关闭。 +- 应用管理的网络规则固定命名为 `network.ts_mwan3_table52`,并使用固定的 + 名称、family、priority 与 lookup 值证明所有权。 +- 如果同名 UCI section 或运行态优先级 `1000` 被其他配置占用,helper 失败 + 并记录日志,不接管、不删除第三方规则;冲突后仍保留应用的 UCI 所有权声明, + 以便第三方规则撤销后安全恢复。 +- 比较 mwan3 的 `mmx_mask` 与运行态 `ip rule` 时统一使用小写十六进制,避免 UCI + 与 `iproute2` 输出的大小写差异绕过优先级保护。 +- 关闭开关时,helper 仅删除经所有权确认的 UCI rule 及其对应的精确运行态 + `1000: from all lookup 52` 规则。带 `from`、`to` 或 `fwmark` 条件的表 52 + 规则均视为手工规则并保持不变。 + +## 生命周期 + +1. 保存 LuCI 配置后,策略路由 init service 调用 helper 的 `sync`。 +2. Tailscale 正常启动或重新应用配置完成后,调用 helper 的 `sync`,以便在 + exit-node 状态变化时重新评估默认路由保护。 +3. 接口 `ifup` / `ifupdate` 调用 helper 的 `ensure`;它不写 UCI, + 只在配置已被确认拥有且规则缺失时恢复运行态规则。若表 `52` 此时有默认路由, + 它只删除应用拥有的运行态优先级规则,保留 UCI 声明以便退出节点撤销后恢复。 +4. 若已有 mwan3 fwmark 规则的优先级数值小于 `1000`,helper 不安装规则;若原有 + 运行态规则已存在则只移除运行态规则、保留 UCI 声明,并在 LuCI 显示阻断原因。 +5. 策略服务启动后以 15 秒间隔静默执行 `ensure`,覆盖 `tailscaled` respawn 与通过 + CLI 改变退出节点/路由的场景;它不写 UCI、不重启任何网络或防火墙服务。 +6. 软件包卸载时先以严格模式调用 `cleanup`;若 UCI 所有权已丢失但仍有精确的 + `1000: from all lookup 52` 运行态规则,清理失败并中止卸载,防止卸载后遗留未知 + 路由行为。普通服务 stop 不删除持久化配置。 + +## 运行状态 + +- `active`:持久化和运行态规则均由本应用管理。 +- `waiting`:开关已启用,等待后续接口/Tailscale 生命周期同步。 +- `blocked_default_route`:表 `52` 有默认路由,禁止提升优先级。 +- `blocked_mwan3_priority`:已有 mwan3 fwmark 策略规则优先级数值小于 `1000`,本 + 功能无法保证 table `52` 优先。 +- `blocked_priority_conflict`:优先级 `1000` 已被其他规则占用。 +- `blocked_ownership`:同名 UCI section 或同优先级运行态规则不属于本应用。 +- `disabled`:功能关闭,且不存在本应用残留。 + +## 验证 + +不能只使用 OpenWrt 本机的无 fwmark `ip route get`。验收须从真实 LAN 客户端 +发起,或使用与 mwan3 相同 fwmark 的路由模拟,验证 Tailnet 地址和远端子网 +地址命中表 `52`,普通公网目标仍由 mwan3 处理。 diff --git a/luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/interface.js b/luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/interface.js new file mode 100644 index 000000000..68098c241 --- /dev/null +++ b/luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/interface.js @@ -0,0 +1,115 @@ +/* SPDX-License-Identifier: GPL-3.0-only + * + * Copyright (C) 2022 ImmortalWrt.org + * Copyright (C) 2024 asvow + */ + +'use strict'; +'require dom'; +'require fs'; +'require poll'; +'require ui'; +'require view'; + +return view.extend({ + async load() { + const res = await fs.exec('/sbin/ip', ['-s', '-j', 'ad']); + if (res.code !== 0 || !res.stdout || res.stdout.trim() === '') { + ui.addNotification(null, E('p', {}, _('Unable to get interface info: %s.').format(res.message))); + return []; + } + + try { + const interfaces = JSON.parse(res.stdout); + const tailscaleInterfaces = interfaces.filter(iface => iface.ifname.match(/tailscale[0-9]+/)); + + return tailscaleInterfaces.map(iface => { + const parsedInfo = { + name: iface.ifname + }; + + const addr_info = iface.addr_info || []; + addr_info.forEach(addr => { + if (addr.family === 'inet' && !parsedInfo.ipv4) { + parsedInfo.ipv4 = addr.local; + } else if (addr.family === 'inet6' && !parsedInfo.ipv6) { + parsedInfo.ipv6 = addr.local; + } + }); + + parsedInfo.mtu = iface.mtu; + parsedInfo.rxBytes = '%1024mB'.format(iface.stats64.rx.bytes); + parsedInfo.txBytes = '%1024mB'.format(iface.stats64.tx.bytes); + + return parsedInfo; + }); + } catch (e) { + ui.addNotification(null, E('p', {}, _('Error parsing interface info: %s.').format(e.message))); + return []; + } + }, + + pollData(container) { + poll.add(async () => { + const data = await this.load(); + dom.content(container, this.renderContent(data)); + }); + }, + + renderContent(data) { + if (!Array.isArray(data) || data.length === 0) { + return E('div', {}, _('No interface online.')); + } + const rows = [ + E('th', { class: 'th', colspan: '2' }, _('Network Interface Information')) + ]; + data.forEach(interfaceData => { + rows.push( + E('tr', { class: 'tr' }, [ + E('td', { class: 'td left', width: '25%' }, _('Interface Name')), + E('td', { class: 'td left', width: '25%' }, interfaceData.name) + ]), + E('tr', { class: 'tr' }, [ + E('td', { class: 'td left', width: '25%' }, _('IPv4 Address')), + E('td', { class: 'td left', width: '25%' }, interfaceData.ipv4) + ]), + E('tr', { class: 'tr' }, [ + E('td', { class: 'td left', width: '25%' }, _('IPv6 Address')), + E('td', { class: 'td left', width: '25%' }, interfaceData.ipv6) + ]), + E('tr', { class: 'tr' }, [ + E('td', { class: 'td left', width: '25%' }, _('MTU')), + E('td', { class: 'td left', width: '25%' }, interfaceData.mtu) + ]), + E('tr', { class: 'tr' }, [ + E('td', { class: 'td left', width: '25%' }, _('Total Download')), + E('td', { class: 'td left', width: '25%' }, interfaceData.rxBytes) + ]), + E('tr', { class: 'tr' }, [ + E('td', { class: 'td left', width: '25%' }, _('Total Upload')), + E('td', { class: 'td left', width: '25%' }, interfaceData.txBytes) + ]) + ); + }); + + return E('table', { 'class': 'table' }, rows); + }, + + render(data) { + const content = E([], [ + E('h2', { class: 'content' }, _('Tailscale')), + E('div', { class: 'cbi-map-descr' }, _('Tailscale is a cross-platform and easy to use virtual LAN.')), + E('div') + ]); + const container = content.lastElementChild; + + dom.content(container, this.renderContent(data)); + this.pollData(container); + + return content; + }, + + handleSaveApply: null, + handleSave: null, + handleReset: null +}); diff --git a/luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/log.js b/luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/log.js new file mode 100644 index 000000000..2f8601c9f --- /dev/null +++ b/luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/log.js @@ -0,0 +1,126 @@ +'use strict'; +'require fs'; +'require poll'; +'require ui'; +'require view'; + +function translateLogText(message) { + return (typeof _ === 'function') ? _(message) : message; +} + +function formatLogLine(log) { + const logParts = String(log || '').split(' ').filter(Boolean); + const statusMappings = { + 'daemon.err': { status: 'StdErr', startIndex: 6 }, + 'daemon.notice': { status: 'Info', startIndex: 6 }, + 'user.err': { status: 'StdErr', startIndex: 6 }, + 'user.notice': { status: 'Info', startIndex: 6 } + }; + + if (logParts.length < 6) + return log; + + const formattedTime = logParts.slice(0, 4).join(' '); + const status = logParts[5]; + const mapping = statusMappings[status] || { status: status, startIndex: 6 }; + const message = logParts.slice(mapping.startIndex).join(' '); + + return `${formattedTime} [ ${mapping.status} ] - ${message}`; +} + +function formatLogLines(logdata) { + const text = String(logdata || '').trim(); + + if (!text) + return { value: translateLogText('Log is empty.'), rows: 2 }; + + const loglines = text.split(/\n/).map(formatLogLine).filter(Boolean); + + return { value: loglines.join('\n'), rows: loglines.length + 1 }; +} + +function formatLogError(err) { + const message = String( + (err && (err.stderr || err.message || err.stdout)) || err || translateLogText('Unknown error') + ).trim().replace(/\n+/g, ' '); + + return { + value: translateLogText('Unable to load log data:') + ' ' + message, + rows: 2 + }; +} + +return view.extend({ + async retrieveLog() { + const stat = await Promise.all([ + L.resolveDefault(fs.stat('/sbin/logread'), null), + L.resolveDefault(fs.stat('/usr/sbin/logread'), null) + ]); + const logger = stat[0] ? stat[0].path : stat[1] ? stat[1].path : null; + + if (!logger) + return formatLogError(translateLogText('logread command not found')); + + try { + return formatLogLines(await fs.exec_direct(logger, ['-e', 'tailscale'])); + } catch (err) { + return formatLogError(err); + } + }, + + async pollLog() { + const element = document.getElementById('syslog'); + if (element) { + try { + const log = await this.retrieveLog(); + element.value = log.value; + element.rows = log.rows; + } catch (err) { + ui.addNotification(null, E('p', {}, _('Unable to load log data: ' + err.message))); + } + } + }, + + load() { + poll.add(this.pollLog.bind(this)); + return this.retrieveLog(); + }, + + render(loglines) { + const scrollDownButton = E('button', { + id: 'scrollDownButton', + class: 'cbi-button cbi-button-neutral' + }, _('Scroll to tail', 'scroll to bottom (the tail) of the log file') + ); + scrollDownButton.addEventListener('click', function() { + scrollUpButton.scrollIntoView(); + }); + + const scrollUpButton = E('button', { + id : 'scrollUpButton', + class: 'cbi-button cbi-button-neutral' + }, _('Scroll to head', 'scroll to top (the head) of the log file') + ); + scrollUpButton.addEventListener('click', function() { + scrollDownButton.scrollIntoView(); + }); + + return E([], [ + E('div', { id: 'content_syslog' }, [ + E('div', { style: 'padding-bottom: 20px' }, [scrollDownButton]), + E('textarea', { + id: 'syslog', + style: 'font-size:12px', + readonly: 'readonly', + wrap: 'off', + rows: loglines.rows, + }, [ loglines.value ]), + E('div', { style: 'padding-bottom: 20px' }, [scrollUpButton]) + ]) + ]); + }, + + handleSaveApply: null, + handleSave: null, + handleReset: null +}); diff --git a/luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/peers.js b/luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/peers.js new file mode 100644 index 000000000..f20655343 --- /dev/null +++ b/luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/peers.js @@ -0,0 +1,695 @@ +/* SPDX-License-Identifier: GPL-3.0-only + * + * Copyright (C) 2026 + */ + +'use strict'; +'require dom'; +'require fs'; +'require poll'; +'require view'; + +var PROBE_MAX_ATTEMPTS = 5; +var PROBE_RETRY_DELAY_MS = 350; +var PEER_PAGE_SIZE_DEFAULT = 25; +var PEER_PAGE_SIZE_OPTIONS = [25, 50, 100, 0]; + +function formatLastSeen(value) { + if (!value) + return '-'; + + value = String(value); + if (value.indexOf('0001-01-01') === 0) + return '-'; + + var date = new Date(value); + if (isNaN(date.getTime())) + return value; + + return date.toLocaleString(); +} + +function parseStatus(stdout) { + var losslessJson = String(stdout || '{}').replace(/("UserID"\s*:\s*)(\d+)/g, '$1"$2"'); + var parsed = JSON.parse(losslessJson); + var peerEntries = parsed && parsed.Peer ? parsed.Peer : {}; + var userEntries = parsed && parsed.User ? parsed.User : {}; + var peers = []; + + function appendPeer(peerId, peer, isSelf) { + peer = peer || {}; + var ip = Array.isArray(peer.TailscaleIPs) ? (peer.TailscaleIPs[0] || '') : ''; + var dnsName = String(peer.DNSName || '').replace(/\.$/, ''); + var shortDnsName = dnsName ? dnsName.split('.')[0] : ''; + var hostName = String(peer.HostName || ''); + var displayName = (shortDnsName && hostName && shortDnsName !== hostName) + ? shortDnsName + ' (' + hostName + ')' + : (shortDnsName || hostName || dnsName || ip || peerId); + var routes = Array.isArray(peer.PrimaryRoutes) ? peer.PrimaryRoutes.filter(Boolean) : []; + var userKey = peer.UserID != null ? String(peer.UserID) : ''; + var user = userKey ? (userEntries[userKey] || {}) : {}; + var userDisplayName = String(user.DisplayName || '').trim(); + var userLoginName = String(user.LoginName || '').trim(); + + peers.push({ + id: peerId, + name: displayName, + ip: ip, + userKey: userKey || 'unknown', + userName: userDisplayName || userLoginName || '', + userLoginName: userLoginName, + online: !!peer.Online, + lastSeen: formatLastSeen(peer.LastSeen), + exitNode: !!peer.ExitNodeOption, + routes: routes, + hasSubnetRoutes: routes.length > 0, + probeTarget: ip || hostName || dnsName || shortDnsName, + isSelf: !!isSelf + }); + } + + if (parsed && parsed.Self) { + var self = parsed.Self; + var selfId = self.ID || self.PublicKey || self.HostName || 'self'; + + appendPeer(selfId, self, true); + } + + Object.keys(peerEntries).forEach(function(peerId) { + appendPeer(peerId, peerEntries[peerId], false); + }); + + peers.sort(function(a, b) { + return String(a.name || '').localeCompare(String(b.name || '')); + }); + + return peers; +} + +function buildPeerGroups(peers) { + var groupMap = Object.create(null); + var groups = []; + + peers.forEach(function(peer) { + var userKey = peer.userKey || 'unknown'; + var group = groupMap[userKey]; + + if (!group) { + group = groupMap[userKey] = { + key: userKey, + name: peer.userName || _('Unknown user'), + loginName: peer.userLoginName || '', + peers: [] + }; + groups.push(group); + } + + group.peers.push(peer); + }); + + groups.sort(function(a, b) { + return String(a.name || '').localeCompare(String(b.name || '')); + }); + + groups.forEach(function(group) { + group.peers.sort(function(a, b) { + return String(a.name || '').localeCompare(String(b.name || '')); + }); + }); + + return groups; +} + +function cloneGroupWithPeers(group, peers) { + return { + key: group.key, + name: group.name, + loginName: group.loginName, + peers: peers + }; +} + +function countPagePeers(groups) { + return groups.reduce(function(count, group) { + return count + group.peers.length; + }, 0); +} + +function paginatePeerGroups(groups, pageSize, requestedPageIndex) { + var total = countPagePeers(groups); + var pages = []; + var currentGroups = []; + var currentCount = 0; + + if (!total || pageSize === 0) { + pages = total ? [{ groups: groups, count: total }] : []; + } else { + var pushCurrent = function() { + if (!currentGroups.length) + return; + + pages.push({ + groups: currentGroups, + count: currentCount + }); + currentGroups = []; + currentCount = 0; + }; + + groups.forEach(function(group) { + var peers = group.peers || []; + + /* split oversized groups into dedicated pages; do not mix their tail with other users */ + if (peers.length > pageSize) { + pushCurrent(); + + for (var i = 0; i < peers.length; i += pageSize) { + var chunk = peers.slice(i, i + pageSize); + pages.push({ + groups: [cloneGroupWithPeers(group, chunk)], + count: chunk.length + }); + } + return; + } + + if (currentCount > 0 && currentCount + peers.length > pageSize) + pushCurrent(); + + currentGroups.push(group); + currentCount += peers.length; + }); + + pushCurrent(); + } + + var pageCount = Math.max(pages.length, 1); + var pageIndex = Math.min(Math.max(Number(requestedPageIndex) || 0, 0), pageCount - 1); + var start = 0; + + for (var j = 0; j < pageIndex; j++) + start += pages[j].count; + + return { + groups: pages[pageIndex] ? pages[pageIndex].groups : [], + pageIndex: pageIndex, + pageCount: pageCount, + pageSize: pageSize, + total: total, + start: total ? start + 1 : 0, + end: total ? start + (pages[pageIndex] ? pages[pageIndex].count : 0) : 0 + }; +} + +function parseProbeResult(stdout, errorMessage) { + var fallback = { + ok: false, + path: 'failed', + summary: errorMessage || _('Probe failed'), + raw: '' + }; + + if (!stdout) + return fallback; + + try { + var parsed = JSON.parse(String(stdout)); + return parsed && typeof parsed === 'object' ? parsed : fallback; + } catch (e) { + fallback.summary = errorMessage || String(e.message || e); + return fallback; + } +} + +function stripProbeSummary(path, summary) { + summary = String(summary || '').trim(); + + if (!summary) + return ''; + + if (path === 'direct') + return summary.replace(/^direct\b[\s:-]*/i, ''); + + if (path === 'derp') + return summary.replace(/^DERP\b[\s:-]*/i, ''); + + return summary; +} + +function renderProbeResult(result) { + if (!result) + return E('span', { style: 'color:#94a3b8' }, _('Not probed')); + + var path = result.path || 'unknown'; + var label = path === 'direct' ? _('Direct') + : (path === 'derp' ? _('DERP') + : (path === 'failed' ? _('Failed') : _('Unknown'))); + var color = path === 'direct' ? '#166534' + : (path === 'derp' ? '#a16207' + : (path === 'failed' ? '#b91c1c' : '#475569')); + var summary = stripProbeSummary(path, result.summary); + + return E('span', { style: 'color:%s'.format(color) }, [ + E('strong', {}, label), + summary ? ' - ' + summary : '' + ]); +} + +function appendProbeSummary(result, suffix) { + var next = { + ok: result && result.ok, + path: result && result.path, + latency_ms: result && result.latency_ms, + relay: result && result.relay, + raw: result && result.raw, + summary: result && result.summary ? String(result.summary) : '' + }; + + next.summary = next.summary + ? next.summary + ' - ' + suffix + : suffix; + + return next; +} + +function sleep(ms) { + return new Promise(function(resolve) { + window.setTimeout(resolve, ms); + }); +} + +function renderBadge(text) { + return E('span', { + style: 'display:inline-block;margin:0 4px 4px 0;padding:1px 6px;border-radius:3px;background:#eef2f7;color:#334155;font-size:12px;line-height:1.4' + }, text); +} + +function renderStatusLabel(online) { + return E('span', { + style: 'font-weight:600;color:%s'.format(online ? '#166534' : '#64748b') + }, online ? _('Online') : _('Offline')); +} + +function renderRoleBadges(peer) { + var badges = []; + + if (peer.exitNode) + badges.push(renderBadge(_('Exit node'))); + + if (peer.hasSubnetRoutes) + badges.push(renderBadge(_('Advertising subnets'))); + + return badges.length ? badges : '-'; +} + +function getScrollState() { + if (typeof document === 'undefined' || typeof window === 'undefined') + return null; + + var scrollElement = document.querySelector('.main-right') || document.scrollingElement || document.documentElement; + + return { + scrollElement: scrollElement, + scrollLeft: scrollElement ? scrollElement.scrollLeft || 0 : window.scrollX || 0, + scrollTop: scrollElement ? scrollElement.scrollTop || 0 : window.scrollY || 0, + windowScrollX: window.scrollX || 0, + windowScrollY: window.scrollY || 0 + }; +} + +function restoreScrollState(state) { + if (!state || typeof window === 'undefined') + return; + + var restore = function() { + var scrollElement = state.scrollElement; + + if (scrollElement) { + scrollElement.scrollLeft = state.scrollLeft; + scrollElement.scrollTop = state.scrollTop; + } else if (window.scrollTo) { + window.scrollTo(state.windowScrollX, state.windowScrollY); + } + }; + + restore(); + + if (window.requestAnimationFrame) + window.requestAnimationFrame(restore); + else + setTimeout(restore, 0); +} + +async function loadPeerState() { + try { + var res = await fs.exec('/usr/sbin/tailscale', ['status', '--json']); + var code = Number(res && res.code); + + if (Number.isFinite(code) && code !== 0) { + var statusError = String((res && (res.stderr || res.stdout || res.message)) || _('Unable to load Tailscale peer status')).trim(); + + return { + ok: false, + peers: [], + error: statusError || _('Unable to load Tailscale peer status') + }; + } + + return { + ok: true, + peers: parseStatus(res.stdout), + error: '' + }; + } catch (e) { + return { + ok: false, + peers: [], + error: String((e && (e.message || e.stderr || e.stdout)) || _('Unable to load Tailscale peer status')) + }; + } +} + +return view.extend({ + load: loadPeerState, + + render: function(data) { + var state = { + peers: (data && data.peers) || [], + error: (data && data.error) || '', + probeResults: Object.create(null), + probing: Object.create(null) + }; + var filterMode = 'all'; + var pageIndex = 0; + var pageSize = PEER_PAGE_SIZE_DEFAULT; + var tbody = E('tbody'); + var statusBox = E('div'); + var paginationBox = E('div'); + var filterSelect = E('select', { + id: 'filterMode', + change: function(ev) { + filterMode = ev.target.value; + pageIndex = 0; + renderRows(); + } + }, [ + E('option', { value: 'all' }, _('All')), + E('option', { value: 'online' }, _('Online')), + E('option', { value: 'subnets' }, _('Advertising subnets')) + ]); + var pageSizeSelect = E('select', { + id: 'pageSize', + change: function(ev) { + pageSize = Number(ev.target.value); + pageIndex = 0; + renderRows(); + } + }, PEER_PAGE_SIZE_OPTIONS.map(function(size) { + return E('option', { + value: String(size), + selected: size === pageSize ? 'selected' : null + }, size ? String(size) : _('All peers')); + })); + + function filterPeers(peers) { + if (filterMode === 'online') + return peers.filter(function(peer) { return peer.online; }); + + if (filterMode === 'subnets') + return peers.filter(function(peer) { return peer.hasSubnetRoutes; }); + + return peers; + } + + function renderPagination(page) { + if (!page.total) { + dom.content(paginationBox, ''); + return; + } + + dom.content(paginationBox, E('div', { + class: 'peer-pagination-bar', + style: 'display:flex;align-items:center;justify-content:space-between;gap:12px;margin:10px 0 0 0;flex-wrap:wrap' + }, [ + E('div', { + class: 'peer-pagination-summary', + style: 'display:flex;align-items:center;gap:10px;color:#64748b;flex:1 1 auto;min-width:220px;flex-wrap:wrap' + }, [ + E('span', {}, _('Showing %d-%d of %d peers').format(page.start, page.end, page.total)), + E('span', {}, _('Page %d / %d').format(page.pageIndex + 1, page.pageCount)) + ]), + E('div', { + class: 'peer-pagination-controls', + style: 'display:flex;align-items:center;justify-content:flex-end;gap:10px;margin-left:auto;flex-wrap:wrap' + }, [ + E('label', { for: 'pageSize' }, _('Items per page')), + pageSizeSelect, + E('button', { + type: 'button', + class: 'btn cbi-button', + disabled: page.pageIndex <= 0 ? 'disabled' : null, + click: function(ev) { + if (ev) { + ev.preventDefault(); + ev.stopPropagation(); + } + pageIndex = Math.max(pageIndex - 1, 0); + renderRows(true); + } + }, _('Previous')), + E('button', { + type: 'button', + class: 'btn cbi-button', + disabled: page.pageIndex >= page.pageCount - 1 ? 'disabled' : null, + click: function(ev) { + if (ev) { + ev.preventDefault(); + ev.stopPropagation(); + } + pageIndex = Math.min(pageIndex + 1, page.pageCount - 1); + renderRows(true); + } + }, _('Next')) + ]) + ])); + } + + function renderRows(keepScroll) { + var filtered = filterPeers(state.peers); + var page = paginatePeerGroups(buildPeerGroups(filtered), pageSize, pageIndex); + var rows; + var scrollState = keepScroll ? getScrollState() : null; + + pageIndex = page.pageIndex; + renderPagination(page); + + if (state.error) { + dom.content(statusBox, E('div', { + style: 'margin-bottom:10px;padding:8px 10px;border-left:3px solid #d97706;background:#fff7ed;color:#9a3412' + }, state.error)); + } else { + dom.content(statusBox, ''); + } + + if (!filtered.length) { + rows = [ + E('tr', { class: 'tr' }, + E('td', { + class: 'td left', + colspan: '7' + }, state.peers.length ? _('No peers match the selected filter.') : _('No peers found')) + ) + ]; + } else { + rows = []; + page.groups.forEach(function(group) { + rows.push(E('tr', { class: 'tr peer-group-header' }, + E('td', { + class: 'td left', + colspan: '7', + style: 'background:#f1f5f9;border-left:4px solid #64748b;font-weight:600;padding-top:10px;padding-bottom:10px' + }, E('div', { + style: 'display:flex;align-items:baseline;gap:10px;flex-wrap:wrap' + }, [ + E('span', { + class: 'peer-group-title', + style: 'font-size:16px;font-weight:700;color:#1e293b' + }, group.name), + group.loginName && group.loginName !== group.name + ? E('span', { style: 'font-weight:400;color:#64748b;font-size:13px' }, group.loginName) + : '', + E('span', { style: 'font-weight:400;color:#64748b;font-size:13px' }, String(group.peers.length)) + ])) + )); + + group.peers.forEach(function(peer) { + var probeCell; + + if (peer.isSelf) { + probeCell = E('span', { style: 'font-weight:600;color:#475569' }, _('Current device')); + } else { + var result = state.probeResults[peer.id]; + var probing = !!state.probing[peer.id]; + var button = E('button', { + type: 'button', + class: 'btn cbi-button cbi-button-action', + disabled: probing || !peer.online ? 'disabled' : null, + click: function(ev) { + if (ev) { + ev.preventDefault(); + ev.stopPropagation(); + } + + return probePeer(peer); + } + }, probing ? _('Probing...') : _('Probe')); + var resultNode = !peer.online + ? E('span', { style: 'color:#94a3b8' }, _('Offline peers cannot be probed')) + : (result ? renderProbeResult(result) : (probing + ? E('span', { style: 'color:#64748b' }, _('Probing...')) + : renderProbeResult(result))); + + probeCell = E('div', { + style: 'display:flex;align-items:center;gap:8px;flex-wrap:wrap' + }, [ + button, + resultNode + ]); + } + + rows.push(E('tr', { + class: 'tr', + style: peer.online ? '' : 'opacity:0.62' + }, [ + E('td', { class: 'td left' }, peer.name), + E('td', { class: 'td left' }, peer.ip || '-'), + E('td', { class: 'td left' }, renderStatusLabel(peer.online)), + E('td', { class: 'td left' }, peer.lastSeen || '-'), + E('td', { class: 'td left' }, renderRoleBadges(peer)), + E('td', { class: 'td left' }, peer.routes.length ? peer.routes.join(', ') : '-'), + E('td', { class: 'td left' }, probeCell) + ])); + }); + }); + } + + dom.content(tbody, rows); + + restoreScrollState(scrollState); + } + + async function refreshPeers() { + var next = await loadPeerState(); + if (next.ok) { + state.peers = next.peers; + state.error = ''; + } else { + state.peers = []; + state.error = next.error; + } + renderRows(true); + } + + async function probeOnce(target) { + var res = await fs.exec('/usr/sbin/tailscale_peer_probe', [target]); + + return parseProbeResult( + res && res.stdout, + res && (res.stderr || res.message || '') + ); + } + + async function probePeer(peer) { + var target = peer.probeTarget; + var attempt; + + if (peer.isSelf || !peer.online) + return; + + if (!target) { + state.probeResults[peer.id] = { + ok: false, + path: 'failed', + summary: _('No probe target available'), + raw: '' + }; + renderRows(true); + return; + } + + state.probing[peer.id] = true; + renderRows(true); + + try { + for (attempt = 1; attempt <= PROBE_MAX_ATTEMPTS; attempt++) { + var result = await probeOnce(target); + + if (result.path === 'derp' && attempt < PROBE_MAX_ATTEMPTS) { + state.probeResults[peer.id] = appendProbeSummary( + result, + _('Continuing probe %d/%d').format(attempt, PROBE_MAX_ATTEMPTS) + ); + renderRows(true); + await sleep(PROBE_RETRY_DELAY_MS); + continue; + } + + if (result.path === 'derp') { + state.probeResults[peer.id] = appendProbeSummary( + result, + _('%d probes; direct connection not established').format(PROBE_MAX_ATTEMPTS) + ); + } else { + state.probeResults[peer.id] = result; + } + + renderRows(true); + break; + } + } catch (e) { + state.probeResults[peer.id] = parseProbeResult('', String((e && (e.message || e.stderr || e.stdout)) || _('Probe failed'))); + } finally { + state.probing[peer.id] = false; + renderRows(true); + } + } + + poll.add(async function() { + await refreshPeers(); + }); + + renderRows(); + + return E('div', { class: 'cbi-map' }, [ + E('h2', { class: 'content' }, _('Tailscale Peers')), + E('div', { class: 'cbi-map-descr' }, _('View all peers and manually probe whether traffic is direct or relayed through DERP.')), + statusBox, + E('div', { + style: 'display:flex;align-items:center;gap:8px;margin-bottom:10px;flex-wrap:wrap' + }, [ + E('label', { for: 'filterMode' }, _('Filter')), + filterSelect + ]), + E('table', { + class: 'table', + style: 'table-layout:fixed;width:100%' + }, [ + E('thead', {}, E('tr', { class: 'tr table-titles' }, [ + E('th', { class: 'th left' }, _('Name')), + E('th', { class: 'th left' }, _('Tailnet IP')), + E('th', { class: 'th left' }, _('Status')), + E('th', { class: 'th left' }, _('Last Seen')), + E('th', { class: 'th left' }, _('Role')), + E('th', { class: 'th left' }, _('Advertised Subnets')), + E('th', { class: 'th left' }, _('Probe')) + ])), + tbody + ]), + paginationBox + ]); + }, + + handleSaveApply: null, + handleSave: null, + handleReset: null +}); diff --git a/luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js b/luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js new file mode 100644 index 000000000..513004803 --- /dev/null +++ b/luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js @@ -0,0 +1,982 @@ +/* SPDX-License-Identifier: GPL-3.0-only + * + * Copyright (C) 2024 asvow + */ + +'use strict'; +'require form'; +'require fs'; +'require network'; +'require poll'; +'require rpc'; +'require uci'; +'require ui'; +'require view'; + +const callServiceList = rpc.declare({ + object: 'service', + method: 'list', + params: ['name'], + expect: { '': {} } +}); + +const callAdguardPreflight = rpc.declare({ + object: 'luci.tailscale', + method: 'adguard_preflight', + params: [ + 'candidate', + 'api_url', + 'username', + 'password_set', + 'password', + 'default_upstreams', + 'tailnet_upstreams', + 'health_domain', + 'expected_ips' + ], + expect: { '': {} }, + reject: true +}); + +const callSecretStatus = rpc.declare({ + object: 'luci.tailscale', + method: 'secret_status', + expect: { '': {} }, + reject: true +}); + +const callSetSecrets = rpc.declare({ + object: 'luci.tailscale', + method: 'set_secrets', + params: ['authkey_set', 'authkey', 'adguard_password_set', 'adguard_password', 'api_url', 'username', 'base_ref'], + expect: { '': {} }, + reject: true +}); + +const callOpenclashBypassStatus = rpc.declare({ + object: 'luci.tailscale', + method: 'openclash_bypass_status', + expect: { '': {} }, + reject: true +}); + +const callPolicyRoutingStatus = rpc.declare({ + object: 'luci.tailscale', + method: 'policy_routing_status', + expect: { '': {} }, + reject: true +}); + +const ADGUARD_PREFLIGHT_CHECKS = [ + { key: 'adguard_process', label: _('AdGuard process') }, + { key: 'port_53_adguard', label: _('Port 53 is AdGuard') }, + { key: 'dhcp_advertises_lan_dns', label: _('LAN DHCP advertises this router as DNS') }, + { key: 'adguard_api', label: _('AdGuard API') }, + { key: 'health_check', label: _('Tailnet DNS health check') } +]; + +async function getInterfaceSubnets(interfaces = ['lan', 'wan']) { + const networks = await network.getNetworks(); + return [...new Set( + networks + .filter(ifc => interfaces.includes(ifc.getName())) + .flatMap(ifc => ifc.getIPAddrs()) + .filter(addr => addr.includes('/')) + .map(addr => { + const [ip, cidr] = addr.split('/'); + const ipParts = ip.split('.').map(Number); + const mask = ~((1 << (32 - parseInt(cidr))) - 1); + const subnetParts = ipParts.map((part, i) => (part & (mask >> (24 - i * 8))) & 255); + return `${subnetParts.join('.')}/${cidr}`; + }) + )]; +} + +async function getStatus() { + const status = { + isRunning: false, + backendState: undefined, + authURL: undefined, + displayName: undefined, + onlineExitNodes: [], + peers: [] + }; + const res = await callServiceList('tailscale'); + try { + status.isRunning = res['tailscale']['instances']['instance1']['running']; + } catch (e) { + return status; + } + const tailscaleRes = await fs.exec("/usr/sbin/tailscale", ["status", "--json"]); + const tailscaleStatus = JSON.parse(tailscaleRes.stdout.replace(/("\w+"):\s*(\d+)/g, '$1:"$2"')); + if (!tailscaleStatus.AuthURL && tailscaleStatus.BackendState === "NeedsLogin") { + fs.exec("/usr/sbin/tailscale", ["login"]); + } + status.backendState = tailscaleStatus.BackendState; + status.authURL = tailscaleStatus.AuthURL; + status.displayName = (status.backendState === "Running") ? tailscaleStatus.User[tailscaleStatus.Self.UserID].DisplayName : undefined; + if (tailscaleStatus.Peer) { + status.peers = Object.values(tailscaleStatus.Peer) + .map(peer => { + const ip = Array.isArray(peer.TailscaleIPs) ? (peer.TailscaleIPs[0] || '') : ''; + const dnsName = (peer.DNSName || '').replace(/\.$/, ''); + const shortDnsName = dnsName.split('.', 1)[0] || ''; + const hostName = peer.HostName || ''; + const name = shortDnsName || hostName || dnsName || ip; + const routes = Array.isArray(peer.PrimaryRoutes) ? peer.PrimaryRoutes : []; + const hasSubnetRoutes = routes.length > 0; + const displayName = (shortDnsName && hostName && shortDnsName !== hostName) + ? [shortDnsName, '(' + hostName + ')'].join(' ') + : (shortDnsName || hostName || dnsName || ip); + const onlineLabel = peer.Online ? _('Online') : _('Offline'); + const label = [ + displayName, + ip, + onlineLabel, + routes.join(', ') + ].filter(Boolean).join(' '); + + return { + name: name, + label: label, + displayName: displayName, + ip: ip, + onlineLabel: onlineLabel, + routes: routes, + hasSubnetRoutes: hasSubnetRoutes, + aliases: [hostName, dnsName, shortDnsName, ip].filter(Boolean) + }; + }) + .filter(peer => peer.name); + status.onlineExitNodes = Object.values(tailscaleStatus.Peer) + .flatMap(peer => (peer.ExitNodeOption && peer.Online) ? [peer.HostName] : []); + } + return status; +} + +function renderStatus(isRunning) { + const spanTemp = '%s %s'; + let renderHTML; + if (isRunning) { + renderHTML = String.format(spanTemp, 'green', _('Tailscale'), _('RUNNING')); + } else { + renderHTML = String.format(spanTemp, 'red', _('Tailscale'), _('NOT RUNNING')); + } + + return renderHTML; +} + +function renderLogin(loginStatus, authURL, displayName) { + const spanTemp = '%s'; + let renderHTML; + if (loginStatus === "NeedsLogin") { + renderHTML = String.format('%s', authURL, _('Need to log in')); + } else if (loginStatus === "Running") { + renderHTML = String.format('%s', 'https://login.tailscale.com/admin/machines', displayName); + renderHTML += String.format('
%s', _('Log out and Unbind')); + } else { + renderHTML = String.format(spanTemp, 'orange', _('NOT RUNNING')); + } + + return renderHTML; +} + +function toList(value) { + if (!value) + return []; + + if (Array.isArray(value)) + return value; + + return String(value).trim().split(/\s+/).filter(Boolean); +} + +function renderCheck(value) { + if (value !== 'pass' && value !== 'fail') + return E('span', { style: 'color:#687586' }, _('Checking ...')); + + const ok = value === 'pass'; + return E('span', { style: 'color:' + (ok ? 'green' : 'red') }, ok ? _('Pass') : _('Fail')); +} + +function adguardPreflightCellId(key) { + return 'adguard_preflight_' + key; +} + +function buildAdguardPreflightRequest(values) { + return { + candidate: '1', + api_url: String(values.apiUrl || ''), + username: String(values.username || ''), + password_set: values.keepPassword ? '0' : '1', + password: values.keepPassword ? '' : String(values.password || ''), + default_upstreams: toList(values.defaultUpstreams).join('\n'), + tailnet_upstreams: toList(values.tailnetUpstreams).join('\n'), + health_domain: String(values.healthDomain || ''), + expected_ips: toList(values.expectedIps).join('\n') + }; +} + +function normalizeAdguardApiUrl(value) { + return String(value || '').replace(/\/+$/, ''); +} + +function adguardConfigChanged(candidate, persisted) { + const current = candidate || {}; + const saved = persisted || {}; + return normalizeAdguardApiUrl(current.api_url) !== normalizeAdguardApiUrl(saved.api_url) || + String(current.username || '') !== String(saved.username || '') || + String(current.default_upstreams || '') !== String(saved.default_upstreams || '') || + String(current.tailnet_upstreams || '') !== String(saved.tailnet_upstreams || '') || + String(current.health_domain || '') !== String(saved.health_domain || '') || + String(current.expected_ips || '') !== String(saved.expected_ips || ''); +} + +function adguardBindingChanged(candidate, persisted) { + return normalizeAdguardApiUrl(candidate && candidate.api_url) !== normalizeAdguardApiUrl(persisted && persisted.api_url) || + String(candidate && candidate.username || '') !== String(persisted && persisted.username || ''); +} + +function buildSecretUpdateRequest(values) { + const authkey = String(values.authkey || '').trim(); + const adguardPassword = values.adguardPassword == null ? '' : String(values.adguardPassword); + return { + authkey_set: authkey ? '1' : '0', + authkey: authkey, + adguard_password_set: adguardPassword ? '1' : '0', + adguard_password: adguardPassword, + api_url: String(values.adguardApiUrl || '').trim(), + username: String(values.adguardUsername || '').trim(), + base_ref: String(values.baseRef || '').trim() + }; +} + +function saveMapWithSecrets(map, cb, silent, stageSecrets, afterSave) { + let stagedSecrets; + map.checkDepends(); + + return map.parse() + .then(cb) + .then(stageSecrets) + .then(function(staged) { + stagedSecrets = staged; + return map.data.save(); + }) + .then(map.load.bind(map)) + .then(function() { + return afterSave(stagedSecrets); + }) + .catch(function(e) { + if (!silent) { + ui.showModal(_('Save error'), [ + E('p', {}, [ _('An error occurred while saving the form:') ]), + E('p', {}, [ E('em', { style: 'white-space:pre-wrap' }, [ e.message ]) ]), + E('div', { class: 'right' }, [ + E('button', { class: 'cbi-button', click: ui.hideModal }, [ _('Dismiss') ]) + ]) + ]); + } + return Promise.reject(e); + }) + .then(map.renderContents.bind(map)); +} + +async function fetchAdguardPreflightStatus(request) { + const input = request || { + candidate: '0', + api_url: '', + username: '', + password_set: '0', + password: '', + default_upstreams: '', + tailnet_upstreams: '', + health_domain: '', + expected_ips: '' + }; + + try { + return await callAdguardPreflight( + input.candidate, + input.api_url, + input.username, + input.password_set, + input.password, + input.default_upstreams, + input.tailnet_upstreams, + input.health_domain, + input.expected_ips + ); + } catch (e) { + const error = e && (e.message || e.stderr || e) || 'preflight command failed'; + return { + ready: 'fail', + error: String(error).replace(/\n/g, ' ') + }; + } +} + +async function writeAdguardDnsSwitchEnabled(section_id, value, candidateRequest, persistedRequest) { + const persistedEnabled = persistedRequest && persistedRequest.enabled === '1'; + const shouldPreflight = value === '1' && ( + !persistedEnabled || + candidateRequest.password_set === '1' || + adguardConfigChanged(candidateRequest, persistedRequest) + ); + + if (shouldPreflight) { + if (persistedEnabled && adguardBindingChanged(candidateRequest, persistedRequest) && !String(candidateRequest.password || '')) + throw new Error(_('Re-enter the AdGuard password after changing the API URL or username.')); + const status = await fetchAdguardPreflightStatus(candidateRequest); + if (status.ready !== 'pass') + throw new Error(_('Only enable when all status checks pass.')); + } + + return uci.set('tailscale', section_id, 'adguard_dns_switch_enabled', value); +} + +async function refreshAdguardPreflightStatus() { + const status = await fetchAdguardPreflightStatus(); + + ADGUARD_PREFLIGHT_CHECKS.forEach(function(check) { + const cell = document.getElementById(adguardPreflightCellId(check.key)); + if (!cell) + return; + + cell.innerHTML = ''; + cell.appendChild(renderCheck(status[check.key] || 'fail')); + }); +} + +function renderOpenclashBypassStatus(status) { + const labels = { + active: _('Enabled; 4 bypass rules are active'), + waiting: _('Enabled; waiting for OpenClash nftables chains'), + disabled: _('Disabled; Tailscale traffic is handled by OpenClash'), + absent: _('OpenClash is not installed; bypass is not required'), + unsupported: _('Unsupported; firewall4/nftables is required'), + error: _('Configuration error') + }; + return labels[status && status.state] || _('Unknown status'); +} + +async function refreshOpenclashBypassStatus() { + const node = document.getElementById('openclash_bypass_status'); + if (!node) + return; + try { + const status = await callOpenclashBypassStatus(); + node.textContent = renderOpenclashBypassStatus(status); + } catch (error) { + node.textContent = _('Unable to read OpenClash bypass status.'); + } +} + +function renderPolicyRoutingStatus(status) { + if (status && status.state === 'active' && !status.mwan3_present) + return _('Enabled; mwan3 is not installed'); + if (status && status.state === 'active' && !status.mwan3_earlier_mark_rule) + return _('Enabled; no earlier mwan3-marked rule was detected'); + + const labels = { + active: _('Enabled; Tailscale routes take precedence over mwan3'), + waiting: _('Enabled; waiting for the managed policy rule'), + disabled: _('Disabled; mwan3 routing is unchanged'), + blocked_default_route: _('Blocked; table 52 contains a default route'), + blocked_mwan3_priority: _('Blocked; a mwan3 rule has higher priority than 1000'), + blocked_priority_conflict: _('Blocked; priority 1000 is already in use'), + blocked_ownership: _('Blocked; an existing rule is not managed by this app'), + error: _('Configuration error') + }; + return labels[status && status.state] || _('Unknown status'); +} + +async function refreshPolicyRoutingStatus() { + const node = document.getElementById('policy_routing_status'); + if (!node) + return; + try { + const status = await callPolicyRoutingStatus(); + node.textContent = renderPolicyRoutingStatus(status); + } catch (error) { + node.textContent = _('Unable to read Tailscale policy-routing status.'); + } +} + +function hasFormListValue(option, section_id) { + return toList(option.formvalue(section_id)).map(function(value) { + return String(value || '').trim(); + }).filter(Boolean).length > 0; +} + +function getFirewallZones() { + const zones = uci.sections('firewall', 'zone') + .map(function(zone) { return zone.name; }) + .filter(Boolean); + return zones.length ? zones : ['wan']; +} + +return view.extend({ + load() { + return callSecretStatus().then(function(secretStatus) { + return Promise.all([ + uci.load('tailscale'), + uci.load('firewall'), + getStatus(), + getInterfaceSubnets(), + uci.load('tailscale_openclash'), + uci.load('tailscale_policy_routing') + ]).then(function(data) { + return [ + data[0], + data[1], + data[2], + data[3], + data[4], + secretStatus, + data[5] + ]; + }); + }); + }, + + render(data) { + let m, s, o; + const statusData = data[2]; + const interfaceSubnets = data[3]; + const firewallZones = getFirewallZones(); + const onlineExitNodes = statusData.onlineExitNodes; + const peers = statusData.peers; + const secretStatus = data[5] || {}; + let hasAuthKey = secretStatus.authkey_set === '1'; + const savedKeepalivePeers = toList(uci.get('tailscale', 'settings', 'keepalive_peers')); + let hasAdguardPassword = secretStatus.adguard_password_set === '1'; + let saveInFlight = null; + let adguardPasswordOption, authKeyOption; + let persistedAdguardConfig; + + function readPersistedAdguardConfig() { + const request = buildAdguardPreflightRequest({ + apiUrl: uci.get('tailscale', 'settings', 'adguard_api_url'), + username: uci.get('tailscale', 'settings', 'adguard_username'), + password: '', + keepPassword: true, + defaultUpstreams: uci.get('tailscale', 'settings', 'adguard_default_upstreams'), + tailnetUpstreams: uci.get('tailscale', 'settings', 'adguard_tailnet_upstreams'), + healthDomain: uci.get('tailscale', 'settings', 'adguard_health_domain'), + expectedIps: uci.get('tailscale', 'settings', 'adguard_health_expected_ips') + }); + request.enabled = uci.get('tailscale', 'settings', 'adguard_dns_switch_enabled') === '1' ? '1' : '0'; + return request; + } + + persistedAdguardConfig = readPersistedAdguardConfig(); + + m = new form.Map('tailscale', _('Tailscale'), _('Tailscale is a cross-platform and easy to use virtual LAN.')); + + s = m.section(form.TypedSection); + s.anonymous = true; + s.render = function () { + poll.add(async function() { + const res = await getStatus(); + const service_view = document.getElementById("service_status"); + const login_view = document.getElementById("login_status_div"); + service_view.innerHTML = renderStatus(res.isRunning); + login_view.innerHTML = renderLogin(res.backendState, res.authURL, res.displayName); + const logoutButton = document.getElementById('logout_button'); + if (logoutButton) { + logoutButton.onclick = function() { + if (confirm(_('Are you sure you want to log out and unbind the current device?'))) { + fs.exec("/usr/sbin/tailscale", ["logout"]); + } + } + } + }); + + return E('div', { class: 'cbi-section', id: 'status_bar' }, [ + E('p', { id: 'service_status' }, _('Collecting data ...')) + ]); + } + + s = m.section(form.NamedSection, 'settings', 'config'); + s.tab('basic', _('Basic Settings')); + + o = s.taboption('basic', form.Flag, 'enabled', _('Enable')); + o.default = o.disabled; + o.rmempty = false; + + o = s.taboption('basic', form.DummyValue, 'login_status', _('Login Status')); + o.depends('enabled', '1'); + o.renderWidget = function(section_id, option_id) { + return E('div', { 'id': 'login_status_div' }, _('Collecting data ...')); + }; + + o = s.taboption('basic', form.Value, 'port', _('Port'), _('Set the Tailscale port number.')); + o.datatype = 'port'; + o.default = '41641'; + o.rmempty = false; + + o = s.taboption('basic', form.Flag, 'allow_wan_direct', _('Allow WAN Direct'), _('Allow inbound UDP traffic from WAN to the local Tailscale listen port so remote peers can establish direct connections without first using DERP.')); + o.default = o.disabled; + o.rmempty = false; + + o = s.taboption('basic', form.DynamicList, 'wan_direct_zones', _('WAN Direct Source Zones'), _('Firewall source zones allowed to reach the local Tailscale listen port when WAN direct is enabled.')); + firewallZones.forEach(function(zone) { + o.value(zone, zone); + }); + o.default = 'wan'; + o.depends('allow_wan_direct', '1'); + o.rmempty = false; + + o = s.taboption('basic', form.Value, 'config_path', _('Workdir'), _('The working directory contains config files, audit logs, and runtime info.')); + o.default = '/etc/tailscale'; + o.rmempty = false; + + o = s.taboption('basic', form.ListValue, 'fw_mode', _('Firewall Mode')); + o.value('nftables', 'nftables'); + o.value('iptables', 'iptables'); + o.default = 'nftables'; + o.rmempty = false; + + o = s.taboption('basic', form.Flag, 'log_stdout', _('StdOut Log'), _('Logging program activities.')); + o.default = o.enabled; + o.rmempty = false; + + o = s.taboption('basic', form.Flag, 'log_stderr', _('StdErr Log'), _('Logging program errors and exceptions.')); + o.default = o.enabled; + o.rmempty = false; + + s.tab('advance', _('Advanced Settings')); + + o = s.taboption('advance', form.Flag, 'accept_routes', _('Accept Routes'), _('Accept subnet routes that other nodes advertise.')); + o.default = o.disabled; + o.rmempty = false; + + o = s.taboption('advance', form.Flag, 'mwan3_table52_precedence', _('Prioritize Tailscale Routes Before mwan3'), + _('Give routes already present in Tailscale table 52 priority over mwan3-marked traffic. This fixes LAN clients being sent to a WAN instead of a Tailnet or routed subnet. Public destinations not present in table 52 continue through mwan3. Disabled leaves mwan3 precedence unchanged. Verify from a real LAN client or with an equivalent mwan3 fwmark; the router\'s unmarked ip route get alone is insufficient. The feature remains blocked while table 52 has a default route, such as when using an exit node.')); + o.default = o.disabled; + o.rmempty = false; + o.cfgvalue = function() { + return uci.get('tailscale_policy_routing', 'settings', 'enabled') || '0'; + }; + o.write = function(section_id, value) { + return uci.set('tailscale_policy_routing', 'settings', 'enabled', value); + }; + o.remove = function() { + return uci.set('tailscale_policy_routing', 'settings', 'enabled', '0'); + }; + + o = s.taboption('advance', form.DummyValue, '_policy_routing_status', _('Policy Routing Status')); + o.rawhtml = true; + o.cfgvalue = function() { return ''; }; + o.renderWidget = function() { + return E('span', { id: 'policy_routing_status' }, _('Checking ...')); + }; + + o = s.taboption('advance', form.Value, 'hostname', _('Device Name'), _("Leave blank to use the device's hostname.")); + o.default = ''; + o.rmempty = true; + + o = s.taboption('advance', form.Flag, 'accept_dns', _('Accept DNS'), _('Accept DNS configuration from the Tailscale admin console.')); + o.default = o.enabled; + o.rmempty = false; + + o = s.taboption('advance', form.Flag, 'advertise_exit_node', _('Exit Node'), _('Offer to be an exit node for outbound internet traffic from the Tailscale network.')); + o.default = o.disabled; + o.rmempty = false; + + o = s.taboption('advance', form.ListValue, 'exit_node', _('Online Exit Nodes'), _('Select an online machine name to use as an exit node.')); + if (onlineExitNodes.length > 0) { + o.optional = true; + onlineExitNodes.forEach(function(node) { + o.value(node, node); + }); + } else { + o.value('', _('No Available Exit Nodes')); + o.readonly = true; + } + o.default = ''; + o.depends('advertise_exit_node', '0'); + o.rmempty = true; + + o = s.taboption('advance', form.DynamicList, 'advertise_routes', _('Expose Subnets'), _('Expose physical network routes into Tailscale, e.g. 10.0.0.0/24.')); + if (interfaceSubnets.length > 0) { + interfaceSubnets.forEach(function(subnet) { + o.value(subnet, subnet); + }); + } + o.default = ''; + o.rmempty = true; + + o = s.taboption('advance', form.Flag, 'disable_snat_subnet_routes', _('Site To Site'), _('Use site-to-site layer 3 networking to connect subnets on the Tailscale network.')); + o.default = o.disabled; + o.depends('accept_routes', '1'); + o.rmempty = false; + + o = s.taboption('advance', form.MultiValue, 'access', _('Access Control')); + o.value('ts_ac_lan', _('Tailscale access LAN')); + o.value('ts_ac_wan', _('Tailscale access WAN')); + o.value('lan_ac_ts', _('LAN access Tailscale')); + o.value('wan_ac_ts', _('WAN access Tailscale')); + o.default = "ts_ac_lan ts_ac_wan lan_ac_ts"; + o.rmempty = true; + + s.tab('keepalive', _('Keepalive')); + + o = s.taboption('keepalive', form.Flag, 'keepalive_enabled', _('Peer Keepalive'), _('Periodically send lightweight Tailscale pings to selected peers.')); + o.default = o.disabled; + o.rmempty = false; + + const keepalivePeerChoices = []; + const keepalivePeerChoiceNames = {}; + const keepalivePeerAliases = {}; + const keepalivePeersByName = {}; + peers.forEach(function(peer) { + keepalivePeersByName[peer.name] = peer; + (peer.aliases || []).forEach(function(alias) { + keepalivePeerAliases[alias] = peer.name; + }); + }); + + const selectedKeepalivePeers = {}; + savedKeepalivePeers.forEach(function(peer) { + const matchedName = keepalivePeerAliases[peer]; + if (matchedName && keepalivePeersByName[matchedName] && keepalivePeersByName[matchedName].hasSubnetRoutes) + selectedKeepalivePeers[matchedName] = true; + else + selectedKeepalivePeers[peer] = true; + }); + + peers.forEach(function(peer) { + if (!peer.hasSubnetRoutes) + return; + + keepalivePeerChoiceNames[peer.name] = true; + keepalivePeerChoices.push({ + value: peer.name, + peer: peer + }); + }); + + savedKeepalivePeers.forEach(function(peer) { + const matchedName = keepalivePeerAliases[peer]; + const matchedPeer = matchedName ? keepalivePeersByName[matchedName] : null; + + if (matchedPeer && matchedPeer.hasSubnetRoutes) + return; + + if (keepalivePeerChoiceNames[peer]) + return; + + keepalivePeerChoiceNames[peer] = true; + if (matchedPeer) { + keepalivePeerChoices.push({ + value: peer, + peer: matchedPeer, + warning: _('No subnet routes') + }); + } + else { + keepalivePeerChoices.push({ + value: peer, + peer: { + displayName: peer, + ip: '', + onlineLabel: '', + routes: [] + }, + warning: _('Not found') + }); + } + }); + + const KeepalivePeersValue = form.Value.extend({ + cfgvalue: function() { + return savedKeepalivePeers; + }, + formvalue: function(section_id) { + const node = document.getElementById(this.cbid(section_id)); + const values = node ? Array.from(node.querySelectorAll('input[type="checkbox"]:checked')).map(function(input) { + return input.value; + }) : []; + return values.length ? values : ''; + }, + renderWidget: function(section_id) { + const disabled = (this.readonly != null) ? this.readonly : this.map.readonly; + if (keepalivePeerChoices.length === 0) { + return E('div', { + id: this.cbid(section_id), + class: 'keepalive-peer-list' + }, E('em', _('No Available Peers'))); + } + + return E('div', { + id: this.cbid(section_id), + class: 'keepalive-peer-list', + style: 'display:grid;gap:6px;max-width:680px;width:100%' + }, keepalivePeerChoices.map(function(choice, index) { + const peer = choice.peer; + const meta = [peer.ip, peer.onlineLabel].filter(Boolean).join(' / '); + const checkboxId = '%s.%d'.format(this.cbid(section_id), index); + const routes = (peer.routes || []).length ? '%s: %s'.format(_('Subnets'), peer.routes.join(', ')) : ''; + + return E('label', { + 'for': checkboxId, + class: 'keepalive-peer-row', + style: 'display:grid;grid-template-columns:minmax(320px,1fr) minmax(140px,260px);gap:10px;align-items:center;min-height:42px;padding:5px 10px;border:1px solid #d8dee5;border-radius:4px;box-sizing:border-box;cursor:pointer' + }, [ + E('span', { class: 'keepalive-peer-main', style: 'display:flex;align-items:center;gap:10px;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis' }, [ + E('span', { class: 'keepalive-peer-check', style: 'display:flex;align-items:center;justify-content:center;line-height:0;flex:0 0 24px' }, E('input', { + id: checkboxId, + type: 'checkbox', + value: choice.value, + checked: selectedKeepalivePeers[choice.value] ? 'checked' : null, + disabled: disabled ? 'disabled' : null, + style: 'margin:0' + })), + E('strong', {}, peer.displayName || choice.value), + meta ? E('span', { style: 'color:#687586;font-size:12px;overflow:hidden;text-overflow:ellipsis' }, meta) : '' + ]), + E('span', { style: 'color:#687586;font-size:12px;line-height:1.3;text-align:right;white-space:normal;overflow-wrap:anywhere;max-width:260px' }, [ + routes, + choice.warning ? E('span', { style: routes ? 'color:#b7791f;margin-left:8px' : 'color:#b7791f' }, choice.warning) : '' + ]) + ]); + }, this)); + } + }); + + o = s.taboption('keepalive', KeepalivePeersValue, 'keepalive_peers', _('Keepalive Peers'), _('Only peers advertising subnet routes are shown. Selected peers are periodically pinged to keep cross-subnet paths active.')); + o.rmempty = true; + o.depends('keepalive_enabled', '1'); + + o = s.taboption('keepalive', form.Value, 'keepalive_interval', _('Keepalive Interval'), _('Seconds between keepalive probes.')); + o.datatype = 'uinteger'; + o.default = '20'; + o.depends('keepalive_enabled', '1'); + o.rmempty = false; + + o = s.taboption('keepalive', form.Value, 'keepalive_failure_log_interval', _('Failure Log Interval'), _('Seconds between repeated failure log messages for the same peer.')); + o.datatype = 'uinteger'; + o.default = '300'; + o.depends('keepalive_enabled', '1'); + o.rmempty = false; + + s.tab('adguard_dns', _('AdGuard DNS')); + let adguardApiUrlOption, adguardUsernameOption; + let adguardDefaultUpstreamsOption, adguardTailnetUpstreamsOption, adguardHealthDomainOption, adguardHealthExpectedIpsOption; + + adguardApiUrlOption = s.taboption('adguard_dns', form.Value, 'adguard_api_url', _('AdGuard API URL')); + adguardApiUrlOption.default = 'http://127.0.0.1:3000'; + adguardApiUrlOption.rmempty = false; + + adguardUsernameOption = s.taboption('adguard_dns', form.Value, 'adguard_username', _('AdGuard Username')); + adguardUsernameOption.default = ''; + adguardUsernameOption.rmempty = true; + + adguardPasswordOption = s.taboption('adguard_dns', form.Value, 'adguard_password', _('AdGuard Password')); + adguardPasswordOption.password = true; + adguardPasswordOption.default = ''; + adguardPasswordOption.rmempty = true; + adguardPasswordOption.placeholder = hasAdguardPassword ? _('Configured') : ''; + adguardPasswordOption.description = _('Leave blank to keep the existing AdGuard password; enter a new value to replace it.'); + adguardPasswordOption.cfgvalue = function() { + return ''; + }; + adguardPasswordOption.write = function() {}; + adguardPasswordOption.remove = function() {}; + + o = s.taboption('adguard_dns', form.DummyValue, '_adguard_dns_status', _('Status')); + o.renderWidget = function() { + return E('div', { class: 'table' }, ADGUARD_PREFLIGHT_CHECKS.map(function(check) { + return E('div', { class: 'tr' }, [ + E('div', { class: 'td left' }, check.label), + E('div', { class: 'td', id: adguardPreflightCellId(check.key) }, renderCheck()) + ]); + })); + }; + + o = s.taboption('adguard_dns', form.Flag, 'adguard_dns_switch_enabled', _('Enable AdGuard DNS Auto Switch'), _('Only enable when all status checks pass.')); + o.default = o.disabled; + o.rmempty = false; + o.write = function(section_id, value) { + const password = String(adguardPasswordOption.formvalue(section_id) || ''); + const candidateRequest = buildAdguardPreflightRequest({ + apiUrl: String(adguardApiUrlOption.formvalue(section_id) || '').trim(), + username: String(adguardUsernameOption.formvalue(section_id) || '').trim(), + password: password, + keepPassword: !password && hasAdguardPassword, + defaultUpstreams: adguardDefaultUpstreamsOption.formvalue(section_id), + tailnetUpstreams: adguardTailnetUpstreamsOption.formvalue(section_id), + healthDomain: String(adguardHealthDomainOption.formvalue(section_id) || '').trim(), + expectedIps: adguardHealthExpectedIpsOption.formvalue(section_id) + }); + + return writeAdguardDnsSwitchEnabled(section_id, value, candidateRequest, persistedAdguardConfig); + }; + o.validate = function(section_id, value) { + if (value !== '1') + return true; + + if (!String(adguardHealthDomainOption.formvalue(section_id) || '').trim()) + return _('Health Check Domain is required before enabling AdGuard DNS auto switch.'); + + if (!hasFormListValue(adguardHealthExpectedIpsOption, section_id)) + return _('At least one Expected Internal IP is required before enabling AdGuard DNS auto switch.'); + + if (!hasFormListValue(adguardDefaultUpstreamsOption, section_id)) + return _('At least one Default Upstream is required before enabling AdGuard DNS auto switch.'); + + if (!hasFormListValue(adguardTailnetUpstreamsOption, section_id)) + return _('At least one Tailnet Upstream is required before enabling AdGuard DNS auto switch.'); + + return true; + }; + + adguardDefaultUpstreamsOption = s.taboption('adguard_dns', form.DynamicList, 'adguard_default_upstreams', _('Default Upstreams'), _('Used when Tailnet DNS is unhealthy.')); + adguardDefaultUpstreamsOption.default = ''; + adguardDefaultUpstreamsOption.rmempty = true; + + adguardTailnetUpstreamsOption = s.taboption('adguard_dns', form.DynamicList, 'adguard_tailnet_upstreams', _('Tailnet Upstreams'), _('Added when Tailnet DNS is healthy.')); + adguardTailnetUpstreamsOption.default = ''; + adguardTailnetUpstreamsOption.rmempty = true; + + adguardHealthDomainOption = s.taboption('adguard_dns', form.Value, 'adguard_health_domain', _('Health Check Domain'), _('Resolved through Tailscale DNS 100.100.100.100. The result must match one of the expected internal IPs.')); + adguardHealthDomainOption.rmempty = true; + + adguardHealthExpectedIpsOption = s.taboption('adguard_dns', form.DynamicList, 'adguard_health_expected_ips', _('Expected Internal IPs')); + adguardHealthExpectedIpsOption.default = ''; + adguardHealthExpectedIpsOption.rmempty = true; + + o = s.taboption('adguard_dns', form.Value, 'adguard_check_interval', _('Check Interval')); + o.datatype = 'uinteger'; + o.default = '10'; + o.rmempty = false; + + o = s.taboption('adguard_dns', form.Value, 'adguard_success_threshold', _('Success Threshold')); + o.datatype = 'uinteger'; + o.default = '2'; + o.rmempty = false; + + o = s.taboption('adguard_dns', form.Value, 'adguard_failure_threshold', _('Failure Threshold')); + o.datatype = 'uinteger'; + o.default = '2'; + o.rmempty = false; + + s.tab('openclash', _('OpenClash')); + + o = s.taboption('openclash', form.Flag, 'openclash_bypass_enabled', _('Protect Tailscale Traffic (Bypass OpenClash)'), + _('Keep Tailscale control connections, direct connections, Tailnet DNS, and subnet traffic outside OpenClash. When disabled, this traffic is handled by OpenClash; node connectivity, direct paths, subnet access, and internal DNS are no longer protected by this feature. Keep this enabled while using OpenClash.')); + o.default = o.enabled; + o.rmempty = false; + o.cfgvalue = function() { + return uci.get('tailscale_openclash', 'settings', 'enabled') || '1'; + }; + o.write = function(section_id, value) { + return uci.set('tailscale_openclash', 'settings', 'enabled', value); + }; + o.remove = function() { + return uci.set('tailscale_openclash', 'settings', 'enabled', '0'); + }; + + o = s.taboption('openclash', form.DummyValue, '_openclash_bypass_status', _('Status')); + o.rawhtml = true; + o.cfgvalue = function() { return ''; }; + o.renderWidget = function() { + return E('span', { id: 'openclash_bypass_status' }, _('Checking ...')); + }; + + s.tab('extra', _('Extra Settings')); + + o = s.taboption('extra', form.DynamicList, 'flags', _('Additional Flags'), + String.format( + _('List of extra flags. Format: --flags=value, e.g. --exit-node=10.0.0.1.
%s for enabling settings upon the initiation of Tailscale.'), + '' + _('Available flags') + '' + ) + ); + o.default = ''; + o.rmempty = true; + + s = m.section(form.NamedSection, 'settings', 'config'); + s.title = _('Custom Server Settings'); + s.description = String.format(_('Use %s to deploy a private server.'), 'headscale'); + + o = s.option(form.Value, 'login_server', _('Server Address')); + o.default = ''; + o.rmempty = true; + + authKeyOption = s.option(form.Value, 'authkey', _('Auth Key')); + authKeyOption.password = true; + authKeyOption.default = ''; + authKeyOption.rmempty = true; + authKeyOption.placeholder = hasAuthKey ? _('Configured') : ''; + authKeyOption.description = _('Used once for first-time enrollment or explicit re-enrollment, then removed after a successful connection. Leave blank for normal restarts.'); + authKeyOption.cfgvalue = function() { + return ''; + }; + authKeyOption.write = function() {}; + authKeyOption.remove = function() {}; + + m.save = function(cb, silent) { + if (saveInFlight) + return saveInFlight; + + const map = this; + saveInFlight = saveMapWithSecrets(map, cb, silent, function() { + const request = Object.freeze(buildSecretUpdateRequest({ + authkey: authKeyOption.formvalue('settings'), + adguardPassword: adguardPasswordOption.formvalue('settings'), + adguardApiUrl: adguardApiUrlOption.formvalue('settings'), + adguardUsername: adguardUsernameOption.formvalue('settings'), + baseRef: uci.get('tailscale', 'settings', 'secrets_ref') + })); + + if (request.authkey_set !== '1' && request.adguard_password_set !== '1') + return { request: request, ref: request.base_ref }; + + return callSetSecrets( + request.authkey_set, + request.authkey, + request.adguard_password_set, + request.adguard_password, + request.api_url, + request.username, + request.base_ref + ).then(function(result) { + const ref = String(result && result.ref || ''); + if (!result || result.code !== 0 || !/^[A-Za-z0-9_.-]+$/.test(ref)) + throw new Error(_('Failed to stage protected credentials.')); + uci.set('tailscale', 'settings', 'secrets_ref', ref); + return { request: request, ref: ref }; + }); + }, function(staged) { + const request = staged.request; + const updateStatus = function() { + if (request.authkey_set === '1') { + hasAuthKey = true; + authKeyOption.placeholder = _('Configured'); + } + if (request.adguard_password_set === '1') { + hasAdguardPassword = true; + adguardPasswordOption.placeholder = _('Configured'); + } + persistedAdguardConfig = readPersistedAdguardConfig(); + }; + updateStatus(); + }).then(function(result) { + window.setTimeout(refreshOpenclashBypassStatus, 0); + window.setTimeout(refreshPolicyRoutingStatus, 0); + return result; + }).finally(function() { + saveInFlight = null; + }); + return saveInFlight; + }; + + return Promise.resolve(m.render()).then(function(node) { + window.setTimeout(refreshAdguardPreflightStatus, 0); + window.setTimeout(refreshOpenclashBypassStatus, 0); + window.setTimeout(refreshPolicyRoutingStatus, 0); + return node; + }); + } +}); diff --git a/luci-app-tailscale/po/templates/tailscale.pot b/luci-app-tailscale/po/templates/tailscale.pot new file mode 100644 index 000000000..e567bdb18 --- /dev/null +++ b/luci-app-tailscale/po/templates/tailscale.pot @@ -0,0 +1,675 @@ +msgid "" +msgstr "Content-Type: text/plain; charset=UTF-8" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:186 +msgid "Accept DNS" +msgstr "" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:186 +msgid "Accept DNS configuration from the Tailscale admin console." +msgstr "" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:178 +msgid "Accept Routes" +msgstr "" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:178 +msgid "Accept subnet routes that other nodes advertise." +msgstr "" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:235 +msgid "Access Control" +msgstr "" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:245 +msgid "Additional Flags" +msgstr "" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:176 +msgid "Advanced Settings" +msgstr "" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:128 +msgid "Are you sure you want to log out and unbind the current device?" +msgstr "" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:262 +msgid "Auth Key" +msgstr "" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:267 +msgid "Configured" +msgstr "" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:268 +msgid "Used once for first-time enrollment or explicit re-enrollment, then removed after a successful connection. Leave blank for normal restarts." +msgstr "" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:248 +msgid "Available flags" +msgstr "" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:141 +msgid "Basic Settings" +msgstr "" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:136 +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:150 +msgid "Collecting data ..." +msgstr "" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:255 +msgid "Custom Server Settings" +msgstr "" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/peers.js:529 +msgid "Current device" +msgstr "" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:182 +msgid "Device Name" +msgstr "" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:143 +msgid "Enable" +msgstr "" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/interface.js:47 +msgid "Error parsing interface info: %s." +msgstr "" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:190 +msgid "Exit Node" +msgstr "" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:208 +msgid "Expose Subnets" +msgstr "" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:208 +msgid "" +"Expose physical network routes into Tailscale, e.g. 10.0.0.0/24." +msgstr "" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:243 +msgid "Extra Settings" +msgstr "" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:162 +msgid "Firewall Mode" +msgstr "" + +#: luci-app-tailscale/root/usr/share/luci/menu.d/luci-app-tailscale.json:14 +msgid "Global Settings" +msgstr "" + +#: luci-app-tailscale/root/usr/share/rpcd/acl.d/luci-app-tailscale.json:3 +msgid "Grant access to Tailscale configuration" +msgstr "" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/interface.js:73 +msgid "IPv4 Address" +msgstr "" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/interface.js:77 +msgid "IPv6 Address" +msgstr "" + +#: luci-app-tailscale/root/usr/share/luci/menu.d/luci-app-tailscale.json:22 +msgid "Interface Info" +msgstr "" + +#: luci-app-tailscale/root/usr/share/luci/menu.d/luci-app-tailscale.json:30 +msgid "Peers" +msgstr "" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/peers.js:216 +msgid "Tailscale Peers" +msgstr "" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/peers.js:217 +msgid "View all peers and manually probe whether traffic is direct or relayed through DERP." +msgstr "" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/peers.js:170 +msgid "Unable to load Tailscale peer status" +msgstr "" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/peers.js:151 +msgid "No peers match the selected filter." +msgstr "" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/peers.js:151 +msgid "No peers found" +msgstr "" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/peers.js:191 +msgid "No probe target available" +msgstr "" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/peers.js:55 +msgid "Probe failed" +msgstr "" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/peers.js:74 +msgid "Not probed" +msgstr "" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/peers.js:84 +msgid "Probing..." +msgstr "" + +msgid "Continuing probe %d/%d" +msgstr "" + +msgid "%d probes; direct connection not established" +msgstr "" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/peers.js:85 +msgid "Probe" +msgstr "" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/peers.js:62 +msgid "Direct" +msgstr "" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/peers.js:63 +msgid "DERP" +msgstr "" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/peers.js:64 +msgid "Failed" +msgstr "" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/peers.js:64 +msgid "Unknown" +msgstr "" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/peers.js:133 +msgid "Filter" +msgstr "" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/peers.js:142 +msgid "All" +msgstr "" + +msgid "Items per page" +msgstr "" + +msgid "All peers" +msgstr "" + +msgid "Showing %d-%d of %d peers" +msgstr "" + +msgid "Page %d / %d" +msgstr "" + +msgid "Previous" +msgstr "" + +msgid "Next" +msgstr "" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/peers.js:183 +msgid "Name" +msgstr "" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/peers.js:184 +msgid "Tailnet IP" +msgstr "" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/peers.js:186 +msgid "Last Seen" +msgstr "" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/peers.js:187 +msgid "Role" +msgstr "" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/peers.js:95 +msgid "Exit node" +msgstr "" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/peers.js:98 +msgid "Advertising subnets" +msgstr "" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/peers.js:188 +msgid "Advertised Subnets" +msgstr "" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/interface.js:69 +msgid "Interface Name" +msgstr "" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:238 +msgid "LAN access Tailscale" +msgstr "" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:182 +msgid "Leave blank to use the device's hostname." +msgstr "" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:247 +msgid "" +"List of extra flags. Format: --flags=value, e.g. --exit-node=10.0.0.1.
%s for enabling settings upon the initiation of Tailscale." +msgstr "" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:90 +msgid "Log out and Unbind" +msgstr "" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:168 +msgid "Logging program activities." +msgstr "" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:172 +msgid "Logging program errors and exceptions." +msgstr "" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:147 +msgid "Login Status" +msgstr "" + +#: luci-app-tailscale/root/usr/share/luci/menu.d/luci-app-tailscale.json:30 +msgid "Logs" +msgstr "" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/interface.js:81 +msgid "MTU" +msgstr "" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:77 +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:92 +msgid "NOT RUNNING" +msgstr "" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:87 +msgid "Need to log in" +msgstr "" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/interface.js:64 +msgid "Network Interface Information" +msgstr "" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:201 +msgid "No Available Exit Nodes" +msgstr "" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/interface.js:61 +msgid "No interface online." +msgstr "" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:190 +msgid "" +"Offer to be an exit node for outbound internet traffic from the Tailscale " +"network." +msgstr "" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:194 +msgid "Online Exit Nodes" +msgstr "" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:153 +msgid "Port" +msgstr "" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:75 +msgid "RUNNING" +msgstr "" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/log.js:68 +msgctxt "scroll to top (the head) of the log file" +msgid "Scroll to head" +msgstr "" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/log.js:59 +msgctxt "scroll to bottom (the tail) of the log file" +msgid "Scroll to tail" +msgstr "" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:194 +msgid "Select an online machine name to use as an exit node." +msgstr "" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:258 +msgid "Server Address" +msgstr "" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:153 +msgid "Set the Tailscale port number." +msgstr "" + +msgid "Allow WAN Direct" +msgstr "" + +msgid "Allow inbound UDP traffic from WAN to the local Tailscale listen port so remote peers can establish direct connections without first using DERP." +msgstr "" + +msgid "WAN Direct Source Zones" +msgstr "" + +msgid "Firewall source zones allowed to reach the local Tailscale listen port when WAN direct is enabled." +msgstr "" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:217 +msgid "Site To Site" +msgstr "" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:172 +msgid "StdErr Log" +msgstr "" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:168 +msgid "StdOut Log" +msgstr "" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/interface.js:100 +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:75 +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:77 +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:114 +#: luci-app-tailscale/root/usr/share/luci/menu.d/luci-app-tailscale.json:3 +msgid "Tailscale" +msgstr "" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:236 +msgid "Tailscale access LAN" +msgstr "" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:237 +msgid "Tailscale access WAN" +msgstr "" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/interface.js:101 +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:114 +msgid "Tailscale is a cross-platform and easy to use virtual LAN." +msgstr "" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:158 +msgid "" +"The working directory contains config files, audit logs, and runtime info." +msgstr "" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/interface.js:85 +msgid "Total Download" +msgstr "" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/interface.js:89 +msgid "Total Upload" +msgstr "" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/interface.js:18 +msgid "Unable to get interface info: %s." +msgstr "" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/log.js:45 +msgid "Unable to load log data:" +msgstr "" + +msgid "Log is empty." +msgstr "" + +msgid "Unknown error" +msgstr "" + +msgid "logread command not found" +msgstr "" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:256 +msgid "Use %s to deploy a private server." +msgstr "" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:217 +msgid "" +"Use site-to-site layer 3 networking to connect subnets on the Tailscale " +"network." +msgstr "" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:239 +msgid "WAN access Tailscale" +msgstr "" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:158 +msgid "Workdir" +msgstr "" + +msgid "Online" +msgstr "" + +msgid "Offline" +msgstr "" + +msgid "Not found" +msgstr "" + +msgid "Keepalive" +msgstr "" + +msgid "Peer Keepalive" +msgstr "" + +msgid "Periodically send lightweight Tailscale pings to selected peers." +msgstr "" + +msgid "Keepalive Peers" +msgstr "" + +msgid "Only peers advertising subnet routes are shown. Selected peers are periodically pinged to keep cross-subnet paths active." +msgstr "" + +msgid "No Available Peers" +msgstr "" + +msgid "No subnet routes" +msgstr "" + +msgid "Subnets" +msgstr "" + +msgid "Keepalive Interval" +msgstr "" + +msgid "Seconds between keepalive probes." +msgstr "" + +msgid "Failure Log Interval" +msgstr "" + +msgid "Seconds between repeated failure log messages for the same peer." +msgstr "" + +msgid "AdGuard DNS" +msgstr "" + +msgid "AdGuard API URL" +msgstr "" + +msgid "AdGuard Username" +msgstr "" + +msgid "AdGuard Password" +msgstr "" + +msgid "Leave blank to keep the existing AdGuard password; enter a new value to replace it." +msgstr "" + +msgid "Status" +msgstr "" + +msgid "Checking ..." +msgstr "" + +msgid "Pass" +msgstr "" + +msgid "Fail" +msgstr "" + +msgid "AdGuard process" +msgstr "" + +msgid "Port 53 is AdGuard" +msgstr "" + +msgid "LAN DHCP advertises this router as DNS" +msgstr "" + +msgid "AdGuard API" +msgstr "" + +msgid "Tailnet DNS health check" +msgstr "" + +msgid "Enable AdGuard DNS Auto Switch" +msgstr "" + +msgid "Only enable when all status checks pass." +msgstr "" + +msgid "Re-enter the AdGuard password after changing the API URL or username." +msgstr "" + +msgid "Save error" +msgstr "" + +msgid "An error occurred while saving the form:" +msgstr "" + +msgid "Failed to stage protected credentials." +msgstr "" + +msgid "Health Check Domain is required before enabling AdGuard DNS auto switch." +msgstr "" + +msgid "At least one Expected Internal IP is required before enabling AdGuard DNS auto switch." +msgstr "" + +msgid "At least one Default Upstream is required before enabling AdGuard DNS auto switch." +msgstr "" + +msgid "At least one Tailnet Upstream is required before enabling AdGuard DNS auto switch." +msgstr "" + +msgid "AdGuard DNS auto switch cannot be enabled until every environment status check passes." +msgstr "" + +msgid "Default Upstreams" +msgstr "" + +msgid "Used when Tailnet DNS is unhealthy." +msgstr "" + +msgid "Tailnet Upstreams" +msgstr "" + +msgid "Added when Tailnet DNS is healthy." +msgstr "" + +msgid "Health Check Domain" +msgstr "" + +msgid "Resolved through Tailscale DNS 100.100.100.100. The result must match one of the expected internal IPs." +msgstr "" + +msgid "Expected Internal IPs" +msgstr "" + +msgid "Check Interval" +msgstr "" + +msgid "Success Threshold" +msgstr "" + +msgid "Failure Threshold" +msgstr "" + +msgid "Unknown user" +msgstr "" + +msgid "Offline peers cannot be probed" +msgstr "" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:345 +msgid "Enabled; 4 bypass rules are active" +msgstr "" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:346 +msgid "Enabled; waiting for OpenClash nftables chains" +msgstr "" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:347 +msgid "Disabled; Tailscale traffic is handled by OpenClash" +msgstr "" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:348 +msgid "OpenClash is not installed; bypass is not required" +msgstr "" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:349 +msgid "Unsupported; firewall4/nftables is required" +msgstr "" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:350 +msgid "Configuration error" +msgstr "" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:352 +msgid "Unknown status" +msgstr "" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:363 +msgid "Unable to read OpenClash bypass status." +msgstr "" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:798 +msgid "OpenClash" +msgstr "" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:800 +msgid "Protect Tailscale Traffic (Bypass OpenClash)" +msgstr "" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:801 +msgid "Keep Tailscale control connections, direct connections, Tailnet DNS, and subnet traffic outside OpenClash. When disabled, this traffic is handled by OpenClash; node connectivity, direct paths, subnet access, and internal DNS are no longer protected by this feature. Keep this enabled while using OpenClash." +msgstr "" + +msgid "Prioritize Tailscale Routes Before mwan3" +msgstr "" + +msgid "Give routes already present in Tailscale table 52 priority over mwan3-marked traffic. This fixes LAN clients being sent to a WAN instead of a Tailnet or routed subnet. Public destinations not present in table 52 continue through mwan3. Disabled leaves mwan3 precedence unchanged. Verify from a real LAN client or with an equivalent mwan3 fwmark; the router's unmarked ip route get alone is insufficient. The feature remains blocked while table 52 has a default route, such as when using an exit node." +msgstr "" + +msgid "Policy Routing Status" +msgstr "" + +msgid "Enabled; mwan3 is not installed" +msgstr "" + +msgid "Enabled; no earlier mwan3-marked rule was detected" +msgstr "" + +msgid "Enabled; Tailscale routes take precedence over mwan3" +msgstr "" + +msgid "Enabled; waiting for the managed policy rule" +msgstr "" + +msgid "Disabled; mwan3 routing is unchanged" +msgstr "" + +msgid "Blocked; table 52 contains a default route" +msgstr "" + +msgid "Blocked; a mwan3 rule has higher priority than 1000" +msgstr "" + +msgid "Blocked; priority 1000 is already in use" +msgstr "" + +msgid "Blocked; an existing rule is not managed by this app" +msgstr "" + +msgid "Unable to read Tailscale policy-routing status." +msgstr "" diff --git a/luci-app-tailscale/po/zh-cn b/luci-app-tailscale/po/zh-cn new file mode 120000 index 000000000..8d69574dd --- /dev/null +++ b/luci-app-tailscale/po/zh-cn @@ -0,0 +1 @@ +zh_Hans \ No newline at end of file diff --git a/luci-app-tailscale/po/zh_Hans/tailscale.po b/luci-app-tailscale/po/zh_Hans/tailscale.po new file mode 100644 index 000000000..af7ba6661 --- /dev/null +++ b/luci-app-tailscale/po/zh_Hans/tailscale.po @@ -0,0 +1,670 @@ +msgid "" +msgstr "" +"Content-Type: text/plain; charset=UTF-8\n" +"Last-Translator: Automatically generated\n" +"Language-Team: none\n" +"Language: zh_Hans\n" +"MIME-Version: 1.0\n" +"Content-Transfer-Encoding: 8bit\n" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:186 +msgid "Accept DNS" +msgstr "允许DNS" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:186 +msgid "Accept DNS configuration from the Tailscale admin console." +msgstr "使用 Tailscale 管理控制台的 DNS 配置。" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:178 +msgid "Accept Routes" +msgstr "启用路由" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:178 +msgid "Accept subnet routes that other nodes advertise." +msgstr "接受其他节点广播的网段。" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:235 +msgid "Access Control" +msgstr "访问控制" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:245 +msgid "Additional Flags" +msgstr "参数列表" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:176 +msgid "Advanced Settings" +msgstr "高级设置" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:128 +msgid "Are you sure you want to log out and unbind the current device?" +msgstr "是否注销当前登录并且解绑当前设备?" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:262 +msgid "Auth Key" +msgstr "认证密钥" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:267 +msgid "Configured" +msgstr "已配置" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:268 +msgid "Used once for first-time enrollment or explicit re-enrollment, then removed after a successful connection. Leave blank for normal restarts." +msgstr "仅用于首次注册或明确重新注册,连接成功后自动删除;正常重启请留空。" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:248 +msgid "Available flags" +msgstr "可用参数" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:141 +msgid "Basic Settings" +msgstr "基础设置" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:136 +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:150 +msgid "Collecting data ..." +msgstr "正在收集数据..." + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:255 +msgid "Custom Server Settings" +msgstr "自定义服务器" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/peers.js:529 +msgid "Current device" +msgstr "当前设备" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:182 +msgid "Device Name" +msgstr "设备名称" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:143 +msgid "Enable" +msgstr "启用" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/interface.js:47 +msgid "Error parsing interface info: %s." +msgstr "接口信息解析错误:%s。" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:190 +msgid "Exit Node" +msgstr "出口节点" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:208 +msgid "Expose Subnets" +msgstr "公开网段" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:208 +msgid "" +"Expose physical network routes into Tailscale, e.g. 10.0.0.0/24." +msgstr "将本地物理网段发布到 Tailscale,例如:10.0.0.0/24。" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:243 +msgid "Extra Settings" +msgstr "附加设置" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:162 +msgid "Firewall Mode" +msgstr "防火墙模式" + +#: luci-app-tailscale/root/usr/share/luci/menu.d/luci-app-tailscale.json:14 +msgid "Global Settings" +msgstr "全局设置" + +#: luci-app-tailscale/root/usr/share/rpcd/acl.d/luci-app-tailscale.json:3 +msgid "Grant access to Tailscale configuration" +msgstr "授予访问 Tailscale 配置的权限" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/interface.js:73 +msgid "IPv4 Address" +msgstr "IPv4地址" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/interface.js:77 +msgid "IPv6 Address" +msgstr "IPv6地址" + +#: luci-app-tailscale/root/usr/share/luci/menu.d/luci-app-tailscale.json:22 +msgid "Interface Info" +msgstr "接口信息" + +#: luci-app-tailscale/root/usr/share/luci/menu.d/luci-app-tailscale.json:30 +msgid "Peers" +msgstr "对端列表" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/peers.js:216 +msgid "Tailscale Peers" +msgstr "Tailscale 对端" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/peers.js:217 +msgid "View all peers and manually probe whether traffic is direct or relayed through DERP." +msgstr "查看所有对端,并手动探测流量是直连还是通过 DERP 中继。" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/peers.js:170 +msgid "Unable to load Tailscale peer status" +msgstr "无法加载 Tailscale 对端状态。" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/peers.js:151 +msgid "No peers match the selected filter." +msgstr "没有对端符合所选筛选条件。" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/peers.js:151 +msgid "No peers found" +msgstr "没有找到对端" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/peers.js:191 +msgid "No probe target available" +msgstr "没有可用探测目标。" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/peers.js:55 +msgid "Probe failed" +msgstr "探测失败" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/peers.js:74 +msgid "Not probed" +msgstr "未探测" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/peers.js:84 +msgid "Probing..." +msgstr "探测中..." + +msgid "Continuing probe %d/%d" +msgstr "继续探测 %d/%d" + +msgid "%d probes; direct connection not established" +msgstr "%d 次探测后仍未建立直连" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/peers.js:85 +msgid "Probe" +msgstr "探测" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/peers.js:62 +msgid "Direct" +msgstr "直连" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/peers.js:63 +msgid "DERP" +msgstr "DERP 中继" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/peers.js:64 +msgid "Failed" +msgstr "失败" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/peers.js:64 +msgid "Unknown" +msgstr "未知" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/peers.js:133 +msgid "Filter" +msgstr "筛选" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/peers.js:142 +msgid "All" +msgstr "全部" + +msgid "Items per page" +msgstr "每页数量" + +msgid "All peers" +msgstr "全部对端" + +msgid "Showing %d-%d of %d peers" +msgstr "显示第 %d-%d 台,共 %d 台" + +msgid "Page %d / %d" +msgstr "第 %d / %d 页" + +msgid "Previous" +msgstr "上一页" + +msgid "Next" +msgstr "下一页" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/peers.js:183 +msgid "Name" +msgstr "名称" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/peers.js:184 +msgid "Tailnet IP" +msgstr "Tailnet IP" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/peers.js:186 +msgid "Last Seen" +msgstr "最后在线" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/peers.js:187 +msgid "Role" +msgstr "角色" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/peers.js:95 +msgid "Exit node" +msgstr "出口节点" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/peers.js:98 +msgid "Advertising subnets" +msgstr "发布网段" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/peers.js:188 +msgid "Advertised Subnets" +msgstr "已发布网段" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/interface.js:69 +msgid "Interface Name" +msgstr "接口名称" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:238 +msgid "LAN access Tailscale" +msgstr "本地局域网访问虚拟局域网" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:182 +msgid "Leave blank to use the device's hostname." +msgstr "留空以使用设备的主机名。" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:247 +msgid "" +"List of extra flags. Format: --flags=value, e.g. --exit-node=10.0.0.1.
%s for enabling settings upon the initiation of Tailscale." +msgstr "额外参数的列表。格式:--flags=value,例如 --exit-node=10.0.0.1。
在 Tailscale 启动时的%s。" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:90 +msgid "Log out and Unbind" +msgstr "注销登录并解除绑定" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:168 +msgid "Logging program activities." +msgstr "记录程序运行信息。" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:172 +msgid "Logging program errors and exceptions." +msgstr "记录程序错误和警告信息。" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:147 +msgid "Login Status" +msgstr "已绑定用户" + +#: luci-app-tailscale/root/usr/share/luci/menu.d/luci-app-tailscale.json:30 +msgid "Logs" +msgstr "日志" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/interface.js:81 +msgid "MTU" +msgstr "MTU" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:77 +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:92 +msgid "NOT RUNNING" +msgstr "未运行" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:87 +msgid "Need to log in" +msgstr "未登录" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/interface.js:64 +msgid "Network Interface Information" +msgstr "网络接口信息" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:201 +msgid "No Available Exit Nodes" +msgstr "没有可用的出口节点" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/interface.js:61 +msgid "No interface online." +msgstr "无在线接口。" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:190 +msgid "" +"Offer to be an exit node for outbound internet traffic from the Tailscale " +"network." +msgstr "作为 Tailscale 广域网出口节点。" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:194 +msgid "Online Exit Nodes" +msgstr "可用出口节点" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:153 +msgid "Port" +msgstr "端口" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:75 +msgid "RUNNING" +msgstr "运行中" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/log.js:68 +msgctxt "scroll to top (the head) of the log file" +msgid "Scroll to head" +msgstr "滚动到顶部" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/log.js:59 +msgctxt "scroll to bottom (the tail) of the log file" +msgid "Scroll to tail" +msgstr "滚动到底部" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:194 +msgid "Select an online machine name to use as an exit node." +msgstr "选择一个可用的节点名称作为出口节点使用。" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:258 +msgid "Server Address" +msgstr "服务器地址" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:153 +msgid "Set the Tailscale port number." +msgstr "设置 Tailscale 端口号。" + +msgid "Allow WAN Direct" +msgstr "允许 WAN 直连" + +msgid "Allow inbound UDP traffic from WAN to the local Tailscale listen port so remote peers can establish direct connections without first using DERP." +msgstr "允许 WAN 入站 UDP 流量访问本机 Tailscale 监听端口,使远端节点可冷启动直连,不必先经过 DERP。" + +msgid "WAN Direct Source Zones" +msgstr "WAN 直连来源区域" + +msgid "Firewall source zones allowed to reach the local Tailscale listen port when WAN direct is enabled." +msgstr "允许访问本机 Tailscale 监听端口的防火墙来源区域。多 WAN 环境可填写多个区域。" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:217 +msgid "Site To Site" +msgstr "子网互通" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:172 +msgid "StdErr Log" +msgstr "错误日志" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:168 +msgid "StdOut Log" +msgstr "运行日志" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/interface.js:100 +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:75 +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:77 +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:114 +#: luci-app-tailscale/root/usr/share/luci/menu.d/luci-app-tailscale.json:3 +msgid "Tailscale" +msgstr "Tailscale" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:236 +msgid "Tailscale access LAN" +msgstr "虚拟局域网访问本地局域网" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:237 +msgid "Tailscale access WAN" +msgstr "虚拟局域网访问本地广域网" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/interface.js:101 +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:114 +msgid "Tailscale is a cross-platform and easy to use virtual LAN." +msgstr "Tailscale 是一个跨平台且易于使用的虚拟局域网 VPN。" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:158 +msgid "" +"The working directory contains config files, audit logs, and runtime info." +msgstr "工作目录包含配置文件、审计日志和运行时信息。" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/interface.js:85 +msgid "Total Download" +msgstr "总下载量" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/interface.js:89 +msgid "Total Upload" +msgstr "总上传量" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/interface.js:18 +msgid "Unable to get interface info: %s." +msgstr "无法获取接口信息:%s。" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/log.js:45 +msgid "Unable to load log data:" +msgstr "无法读取日志数据:" + +msgid "Log is empty." +msgstr "日志为空。" + +msgid "Unknown error" +msgstr "未知错误" + +msgid "logread command not found" +msgstr "未找到 logread 命令" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:256 +msgid "Use %s to deploy a private server." +msgstr "使用 %s 部署私有服务器" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:217 +msgid "" +"Use site-to-site layer 3 networking to connect subnets on the Tailscale " +"network." +msgstr "使用站点到站点的三层网络连接 Tailscale 中的子网。" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:239 +msgid "WAN access Tailscale" +msgstr "本地广域网访问虚拟局域网" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:158 +msgid "Workdir" +msgstr "工作目录" + +msgid "Online" +msgstr "在线" + +msgid "Offline" +msgstr "离线" + +msgid "Not found" +msgstr "未找到" + +msgid "Keepalive" +msgstr "保活" + +msgid "Peer Keepalive" +msgstr "启用" + +msgid "Periodically send lightweight Tailscale pings to selected peers." +msgstr "定时向选中的对端发送轻量 Tailscale ping。" + +msgid "Keepalive Peers" +msgstr "保活对端" + +msgid "Only peers advertising subnet routes are shown. Selected peers are periodically pinged to keep cross-subnet paths active." +msgstr "仅显示对端也发布了网段的设备;选中的设备会被定时 Tailscale ping,用于保持跨网段路径活跃。" + +msgid "No Available Peers" +msgstr "没有可用对端" + +msgid "No subnet routes" +msgstr "未发布网段" + +msgid "Subnets" +msgstr "子网" + +msgid "Keepalive Interval" +msgstr "保活间隔" + +msgid "Seconds between keepalive probes." +msgstr "两次保活探测之间的秒数。" + +msgid "Failure Log Interval" +msgstr "失败日志间隔" + +msgid "Seconds between repeated failure log messages for the same peer." +msgstr "同一对端重复失败日志之间的秒数。" + +msgid "AdGuard DNS" +msgstr "AdGuard DNS" + +msgid "AdGuard API URL" +msgstr "AdGuard API 地址" + +msgid "AdGuard Username" +msgstr "AdGuard 用户名" + +msgid "AdGuard Password" +msgstr "AdGuard 密码" + +msgid "Leave blank to keep the existing AdGuard password; enter a new value to replace it." +msgstr "留空则保留现有 AdGuard 密码;填写新值则覆盖。" + +msgid "Status" +msgstr "状态" + +msgid "Checking ..." +msgstr "检查中..." + +msgid "Pass" +msgstr "通过" + +msgid "Fail" +msgstr "失败" + +msgid "AdGuard process" +msgstr "AdGuard 进程" + +msgid "Port 53 is AdGuard" +msgstr "53 端口由 AdGuard 接管" + +msgid "LAN DHCP advertises this router as DNS" +msgstr "LAN DHCP 下发本路由器作为 DNS" + +msgid "AdGuard API" +msgstr "AdGuard API" + +msgid "Tailnet DNS health check" +msgstr "Tailnet DNS 健康检查" + +msgid "Enable AdGuard DNS Auto Switch" +msgstr "启用 AdGuard DNS 自动切换" + +msgid "Only enable when all status checks pass." +msgstr "仅在所有状态检查通过后启用。" + +msgid "Re-enter the AdGuard password after changing the API URL or username." +msgstr "修改 API URL 或用户名后,请重新输入 AdGuard 密码。" + +msgid "Save error" +msgstr "保存出错" + +msgid "An error occurred while saving the form:" +msgstr "保存表单时出错:" + +msgid "Failed to stage protected credentials." +msgstr "暂存受保护凭证失败。" + +msgid "Health Check Domain is required before enabling AdGuard DNS auto switch." +msgstr "启用 AdGuard DNS 自动切换前必须填写健康检查域名。" + +msgid "At least one Expected Internal IP is required before enabling AdGuard DNS auto switch." +msgstr "启用 AdGuard DNS 自动切换前至少需要填写一个预期内网 IP。" + +msgid "At least one Default Upstream is required before enabling AdGuard DNS auto switch." +msgstr "启用 AdGuard DNS 自动切换前至少需要填写一个默认上游。" + +msgid "At least one Tailnet Upstream is required before enabling AdGuard DNS auto switch." +msgstr "启用 AdGuard DNS 自动切换前至少需要填写一个 Tailnet 上游。" + +msgid "AdGuard DNS auto switch cannot be enabled until every environment status check passes." +msgstr "所有环境状态检查通过后才能启用 AdGuard DNS 自动切换。" + +msgid "Default Upstreams" +msgstr "默认上游" + +msgid "Used when Tailnet DNS is unhealthy." +msgstr "Tailnet DNS 不健康时使用。" + +msgid "Tailnet Upstreams" +msgstr "Tailnet 上游" + +msgid "Added when Tailnet DNS is healthy." +msgstr "Tailnet DNS 健康时追加使用。" + +msgid "Health Check Domain" +msgstr "健康检查域名" + +msgid "Resolved through Tailscale DNS 100.100.100.100. The result must match one of the expected internal IPs." +msgstr "通过 Tailscale DNS 100.100.100.100 解析,结果必须匹配任一预期内网 IP。" + +msgid "Expected Internal IPs" +msgstr "预期内网 IP" + +msgid "Check Interval" +msgstr "检查间隔" + +msgid "Success Threshold" +msgstr "成功阈值" + +msgid "Failure Threshold" +msgstr "失败阈值" + +msgid "Unknown user" +msgstr "未知用户" + +msgid "Offline peers cannot be probed" +msgstr "离线设备不可探测" + +msgid "Enabled; 4 bypass rules are active" +msgstr "已开启,4 条绕过规则已生效" + +msgid "Enabled; waiting for OpenClash nftables chains" +msgstr "已开启,正在等待 OpenClash 创建 nftables 链" + +msgid "Disabled; Tailscale traffic is handled by OpenClash" +msgstr "已关闭,Tailscale 流量将由 OpenClash 处理" + +msgid "OpenClash is not installed; bypass is not required" +msgstr "未安装 OpenClash,无需绕过" + +msgid "Unsupported; firewall4/nftables is required" +msgstr "当前系统不支持,仅支持 firewall4/nftables" + +msgid "Configuration error" +msgstr "配置错误" + +msgid "Unknown status" +msgstr "未知状态" + +msgid "Unable to read OpenClash bypass status." +msgstr "无法读取 OpenClash 绕过状态。" + +msgid "OpenClash" +msgstr "OpenClash" + +msgid "Protect Tailscale Traffic (Bypass OpenClash)" +msgstr "保护 Tailscale 流量(绕过 OpenClash)" + +msgid "Keep Tailscale control connections, direct connections, Tailnet DNS, and subnet traffic outside OpenClash. When disabled, this traffic is handled by OpenClash; node connectivity, direct paths, subnet access, and internal DNS are no longer protected by this feature. Keep this enabled while using OpenClash." +msgstr "开启后,Tailscale 控制连接、直连通信、Tailnet DNS 和跨子网流量不会被 OpenClash 接管。关闭后,这些流量将重新经过 OpenClash,节点在线、点对点直连、跨子网访问和 Tailnet DNS 解析不再受本功能保护;如果 OpenClash 规则接管或重定向这些流量,可能导致节点掉线、直连退化为 DERP、跨子网访问或 Tailnet DNS 中断。使用 OpenClash 时必须保持开启。" + +msgid "Prioritize Tailscale Routes Before mwan3" +msgstr "优先让 Tailscale 路由先于 mwan3" + +msgid "Give routes already present in Tailscale table 52 priority over mwan3-marked traffic. This fixes LAN clients being sent to a WAN instead of a Tailnet or routed subnet. Public destinations not present in table 52 continue through mwan3. Disabled leaves mwan3 precedence unchanged. Verify from a real LAN client or with an equivalent mwan3 fwmark; the router's unmarked ip route get alone is insufficient. The feature remains blocked while table 52 has a default route, such as when using an exit node." +msgstr "让已存在于 Tailscale 路由表 52 中的路由优先于带 mwan3 标记的流量。这可以修复 LAN 客户端访问 Tailnet 或已路由子网时被错误发往 WAN 的问题。不在路由表 52 中的公网目标仍由 mwan3 处理。关闭后保持 mwan3 原有优先级。请从真实 LAN 客户端验证,或使用等效的 mwan3 fwmark;仅在路由器上执行未标记的 ip route get 不足以验证。若路由表 52 存在默认路由(例如正在使用出口节点),功能会保持阻止状态。" + +msgid "Policy Routing Status" +msgstr "策略路由状态" + +msgid "Enabled; mwan3 is not installed" +msgstr "已开启,未安装 mwan3" + +msgid "Enabled; no earlier mwan3-marked rule was detected" +msgstr "已开启,未检测到更早的 mwan3 标记规则" + +msgid "Enabled; Tailscale routes take precedence over mwan3" +msgstr "已开启,Tailscale 路由优先于 mwan3" + +msgid "Enabled; waiting for the managed policy rule" +msgstr "已开启,正在等待托管策略路由规则" + +msgid "Disabled; mwan3 routing is unchanged" +msgstr "已关闭,mwan3 路由保持不变" + +msgid "Blocked; table 52 contains a default route" +msgstr "已阻止,路由表 52 包含默认路由" + +msgid "Blocked; a mwan3 rule has higher priority than 1000" +msgstr "已阻止,存在优先级数值小于 1000 的 mwan3 规则" + +msgid "Blocked; priority 1000 is already in use" +msgstr "已阻止,优先级 1000 已被占用" + +msgid "Blocked; an existing rule is not managed by this app" +msgstr "已阻止,现有规则不由本应用管理" + +msgid "Unable to read Tailscale policy-routing status." +msgstr "无法读取 Tailscale 策略路由状态。" diff --git a/luci-app-tailscale/po/zh_Hant/tailscale.po b/luci-app-tailscale/po/zh_Hant/tailscale.po new file mode 100644 index 000000000..5376dfc66 --- /dev/null +++ b/luci-app-tailscale/po/zh_Hant/tailscale.po @@ -0,0 +1,670 @@ +msgid "" +msgstr "" +"Content-Type: text/plain; charset=UTF-8\n" +"Last-Translator: Automatically generated\n" +"Language-Team: none\n" +"Language: zh_Hant\n" +"MIME-Version: 1.0\n" +"Content-Transfer-Encoding: 8bit\n" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:186 +msgid "Accept DNS" +msgstr "允許DNS" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:186 +msgid "Accept DNS configuration from the Tailscale admin console." +msgstr "使用 Tailscale 管理控制台的 DNS 設定。" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:178 +msgid "Accept Routes" +msgstr "啟用路由" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:178 +msgid "Accept subnet routes that other nodes advertise." +msgstr "接受其他節點廣播的網段。" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:235 +msgid "Access Control" +msgstr "訪問控制" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:245 +msgid "Additional Flags" +msgstr "參數列表" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:176 +msgid "Advanced Settings" +msgstr "高級設置" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:128 +msgid "Are you sure you want to log out and unbind the current device?" +msgstr "是否註銷當前登錄並且解綁當前設備?" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:262 +msgid "Auth Key" +msgstr "認證密鑰" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:267 +msgid "Configured" +msgstr "已配置" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:268 +msgid "Used once for first-time enrollment or explicit re-enrollment, then removed after a successful connection. Leave blank for normal restarts." +msgstr "僅用於首次註冊或明確重新註冊,連線成功後自動刪除;正常重新啟動請留空。" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:248 +msgid "Available flags" +msgstr "可用參數" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:141 +msgid "Basic Settings" +msgstr "基礎設置" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:136 +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:150 +msgid "Collecting data ..." +msgstr "正在收集數據..." + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:255 +msgid "Custom Server Settings" +msgstr "自定義伺服器" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/peers.js:529 +msgid "Current device" +msgstr "目前設備" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:182 +msgid "Device Name" +msgstr "設備名稱" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:143 +msgid "Enable" +msgstr "啟用" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/interface.js:47 +msgid "Error parsing interface info: %s." +msgstr "接口信息解析錯誤:%s。" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:190 +msgid "Exit Node" +msgstr "出口節點" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:208 +msgid "Expose Subnets" +msgstr "公開網段" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:208 +msgid "" +"Expose physical network routes into Tailscale, e.g. 10.0.0.0/24." +msgstr "將本地物理網段發布到 Tailscale,例如:10.0.0.0/24。" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:243 +msgid "Extra Settings" +msgstr "附加設置" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:162 +msgid "Firewall Mode" +msgstr "防火牆模式" + +#: luci-app-tailscale/root/usr/share/luci/menu.d/luci-app-tailscale.json:14 +msgid "Global Settings" +msgstr "全局設置" + +#: luci-app-tailscale/root/usr/share/rpcd/acl.d/luci-app-tailscale.json:3 +msgid "Grant access to Tailscale configuration" +msgstr "授予訪問 Tailscale 配置的權限" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/interface.js:73 +msgid "IPv4 Address" +msgstr "IPv4地址" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/interface.js:77 +msgid "IPv6 Address" +msgstr "IPv6地址" + +#: luci-app-tailscale/root/usr/share/luci/menu.d/luci-app-tailscale.json:22 +msgid "Interface Info" +msgstr "接口信息" + +#: luci-app-tailscale/root/usr/share/luci/menu.d/luci-app-tailscale.json:30 +msgid "Peers" +msgstr "對端列表" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/peers.js:216 +msgid "Tailscale Peers" +msgstr "Tailscale 對端" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/peers.js:217 +msgid "View all peers and manually probe whether traffic is direct or relayed through DERP." +msgstr "查看所有對端,並手動探測流量是直連還是透過 DERP 中繼。" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/peers.js:170 +msgid "Unable to load Tailscale peer status" +msgstr "無法載入 Tailscale 對端狀態。" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/peers.js:151 +msgid "No peers match the selected filter." +msgstr "沒有對端符合所選篩選條件。" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/peers.js:151 +msgid "No peers found" +msgstr "沒有找到對端" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/peers.js:191 +msgid "No probe target available" +msgstr "沒有可用探測目標。" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/peers.js:55 +msgid "Probe failed" +msgstr "探測失敗" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/peers.js:74 +msgid "Not probed" +msgstr "未探測" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/peers.js:84 +msgid "Probing..." +msgstr "探測中..." + +msgid "Continuing probe %d/%d" +msgstr "繼續探測 %d/%d" + +msgid "%d probes; direct connection not established" +msgstr "%d 次探測後仍未建立直連" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/peers.js:85 +msgid "Probe" +msgstr "探測" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/peers.js:62 +msgid "Direct" +msgstr "直連" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/peers.js:63 +msgid "DERP" +msgstr "DERP 中繼" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/peers.js:64 +msgid "Failed" +msgstr "失敗" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/peers.js:64 +msgid "Unknown" +msgstr "未知" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/peers.js:133 +msgid "Filter" +msgstr "篩選" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/peers.js:142 +msgid "All" +msgstr "全部" + +msgid "Items per page" +msgstr "每頁數量" + +msgid "All peers" +msgstr "全部對端" + +msgid "Showing %d-%d of %d peers" +msgstr "顯示第 %d-%d 台,共 %d 台" + +msgid "Page %d / %d" +msgstr "第 %d / %d 頁" + +msgid "Previous" +msgstr "上一頁" + +msgid "Next" +msgstr "下一頁" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/peers.js:183 +msgid "Name" +msgstr "名稱" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/peers.js:184 +msgid "Tailnet IP" +msgstr "Tailnet IP" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/peers.js:186 +msgid "Last Seen" +msgstr "最後在線" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/peers.js:187 +msgid "Role" +msgstr "角色" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/peers.js:95 +msgid "Exit node" +msgstr "出口節點" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/peers.js:98 +msgid "Advertising subnets" +msgstr "發布網段" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/peers.js:188 +msgid "Advertised Subnets" +msgstr "已發布網段" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/interface.js:69 +msgid "Interface Name" +msgstr "接口名稱" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:238 +msgid "LAN access Tailscale" +msgstr "本地局域網訪問虛擬局域網" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:182 +msgid "Leave blank to use the device's hostname." +msgstr "留空以使用設備的主機名。" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:247 +msgid "" +"List of extra flags. Format: --flags=value, e.g. --exit-node=10.0.0.1.
%s for enabling settings upon the initiation of Tailscale." +msgstr "額外參數的列表。格式:--flags=value,例如 --exit-node=10.0.0.1。
在 Tailscale 啟動時的%s。" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:90 +msgid "Log out and Unbind" +msgstr "註銷登錄並解除綁定" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:168 +msgid "Logging program activities." +msgstr "記錄程式運行信息。" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:172 +msgid "Logging program errors and exceptions." +msgstr "記錄程式錯誤和警告信息。" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:147 +msgid "Login Status" +msgstr "已綁定用戶" + +#: luci-app-tailscale/root/usr/share/luci/menu.d/luci-app-tailscale.json:30 +msgid "Logs" +msgstr "日誌" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/interface.js:81 +msgid "MTU" +msgstr "MTU" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:77 +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:92 +msgid "NOT RUNNING" +msgstr "未運行" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:87 +msgid "Need to log in" +msgstr "未登錄" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/interface.js:64 +msgid "Network Interface Information" +msgstr "網絡接口信息" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:201 +msgid "No Available Exit Nodes" +msgstr "没有可用的出口節點" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/interface.js:61 +msgid "No interface online." +msgstr "無在線接口。" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:190 +msgid "" +"Offer to be an exit node for outbound internet traffic from the Tailscale " +"network." +msgstr "作為 Tailscale 廣域網出口節點。" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:194 +msgid "Online Exit Nodes" +msgstr "可用出口節點" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:153 +msgid "Port" +msgstr "端口" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:75 +msgid "RUNNING" +msgstr "運行中" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/log.js:68 +msgctxt "scroll to top (the head) of the log file" +msgid "Scroll to head" +msgstr "捲動到頂部" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/log.js:59 +msgctxt "scroll to bottom (the tail) of the log file" +msgid "Scroll to tail" +msgstr "捲動到尾部" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:194 +msgid "Select an online machine name to use as an exit node." +msgstr "選擇一個可用的節點名稱作為出口節點使用。" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:258 +msgid "Server Address" +msgstr "伺服器地址" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:153 +msgid "Set the Tailscale port number." +msgstr "設置 Tailscale 端口號。" + +msgid "Allow WAN Direct" +msgstr "允許 WAN 直連" + +msgid "Allow inbound UDP traffic from WAN to the local Tailscale listen port so remote peers can establish direct connections without first using DERP." +msgstr "允許 WAN 入站 UDP 流量訪問本機 Tailscale 監聽端口,使遠端節點可冷啟動直連,不必先經過 DERP。" + +msgid "WAN Direct Source Zones" +msgstr "WAN 直連來源區域" + +msgid "Firewall source zones allowed to reach the local Tailscale listen port when WAN direct is enabled." +msgstr "允許訪問本機 Tailscale 監聽端口的防火牆來源區域。多 WAN 環境可填寫多個區域。" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:217 +msgid "Site To Site" +msgstr "子網互通" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:172 +msgid "StdErr Log" +msgstr "錯誤日誌" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:168 +msgid "StdOut Log" +msgstr "運行日誌" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/interface.js:100 +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:75 +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:77 +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:114 +#: luci-app-tailscale/root/usr/share/luci/menu.d/luci-app-tailscale.json:3 +msgid "Tailscale" +msgstr "Tailscale" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:236 +msgid "Tailscale access LAN" +msgstr "虛擬局域網訪問本地局域網" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:237 +msgid "Tailscale access WAN" +msgstr "虛擬局域網訪問本地廣域網" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/interface.js:101 +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:114 +msgid "Tailscale is a cross-platform and easy to use virtual LAN." +msgstr "Tailscale 是一個跨平台且易於使用的虛擬局域網 VPN。" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:158 +msgid "" +"The working directory contains config files, audit logs, and runtime info." +msgstr "工作目錄包含配置文件、審計日誌和運行時信息。" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/interface.js:85 +msgid "Total Download" +msgstr "總下載量" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/interface.js:89 +msgid "Total Upload" +msgstr "總上傳量" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/interface.js:18 +msgid "Unable to get interface info: %s." +msgstr "無法獲取接口信息:%s。" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/log.js:45 +msgid "Unable to load log data:" +msgstr "無法載入日誌檔:" + +msgid "Log is empty." +msgstr "日誌為空。" + +msgid "Unknown error" +msgstr "未知錯誤" + +msgid "logread command not found" +msgstr "未找到 logread 命令" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:256 +msgid "Use %s to deploy a private server." +msgstr "使用 %s 部署私有伺服器" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:217 +msgid "" +"Use site-to-site layer 3 networking to connect subnets on the Tailscale " +"network." +msgstr "使用站點到站點的三層網路連線 Tailscale 中的子網。" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:239 +msgid "WAN access Tailscale" +msgstr "本地廣域網訪問虛擬局域網" + +#: luci-app-tailscale/htdocs/luci-static/resources/view/tailscale/setting.js:158 +msgid "Workdir" +msgstr "工作目錄" + +msgid "Online" +msgstr "在線" + +msgid "Offline" +msgstr "離線" + +msgid "Not found" +msgstr "未找到" + +msgid "Keepalive" +msgstr "保活" + +msgid "Peer Keepalive" +msgstr "啟用" + +msgid "Periodically send lightweight Tailscale pings to selected peers." +msgstr "定時向選中的對端發送輕量 Tailscale ping。" + +msgid "Keepalive Peers" +msgstr "保活對端" + +msgid "Only peers advertising subnet routes are shown. Selected peers are periodically pinged to keep cross-subnet paths active." +msgstr "僅顯示對端也發布了網段的裝置;選中的裝置會被定時 Tailscale ping,用於保持跨網段路徑活躍。" + +msgid "No Available Peers" +msgstr "沒有可用對端" + +msgid "No subnet routes" +msgstr "未發布網段" + +msgid "Subnets" +msgstr "子網" + +msgid "Keepalive Interval" +msgstr "保活間隔" + +msgid "Seconds between keepalive probes." +msgstr "兩次保活探測之間的秒數。" + +msgid "Failure Log Interval" +msgstr "失敗日誌間隔" + +msgid "Seconds between repeated failure log messages for the same peer." +msgstr "同一對端重複失敗日誌之間的秒數。" + +msgid "AdGuard DNS" +msgstr "AdGuard DNS" + +msgid "AdGuard API URL" +msgstr "AdGuard API 位址" + +msgid "AdGuard Username" +msgstr "AdGuard 使用者名稱" + +msgid "AdGuard Password" +msgstr "AdGuard 密碼" + +msgid "Leave blank to keep the existing AdGuard password; enter a new value to replace it." +msgstr "留空則保留現有 AdGuard 密碼;填寫新值則覆蓋。" + +msgid "Status" +msgstr "狀態" + +msgid "Checking ..." +msgstr "檢查中..." + +msgid "Pass" +msgstr "通過" + +msgid "Fail" +msgstr "失敗" + +msgid "AdGuard process" +msgstr "AdGuard 行程" + +msgid "Port 53 is AdGuard" +msgstr "53 埠由 AdGuard 接管" + +msgid "LAN DHCP advertises this router as DNS" +msgstr "LAN DHCP 下發本路由器作為 DNS" + +msgid "AdGuard API" +msgstr "AdGuard API" + +msgid "Tailnet DNS health check" +msgstr "Tailnet DNS 健康檢查" + +msgid "Enable AdGuard DNS Auto Switch" +msgstr "啟用 AdGuard DNS 自動切換" + +msgid "Only enable when all status checks pass." +msgstr "僅在所有狀態檢查通過後啟用。" + +msgid "Re-enter the AdGuard password after changing the API URL or username." +msgstr "變更 API URL 或使用者名稱後,請重新輸入 AdGuard 密碼。" + +msgid "Save error" +msgstr "儲存時發生錯誤" + +msgid "An error occurred while saving the form:" +msgstr "儲存表單時發生錯誤:" + +msgid "Failed to stage protected credentials." +msgstr "暫存受保護憑證失敗。" + +msgid "Health Check Domain is required before enabling AdGuard DNS auto switch." +msgstr "啟用 AdGuard DNS 自動切換前必須填寫健康檢查網域。" + +msgid "At least one Expected Internal IP is required before enabling AdGuard DNS auto switch." +msgstr "啟用 AdGuard DNS 自動切換前至少需要填寫一個預期內網 IP。" + +msgid "At least one Default Upstream is required before enabling AdGuard DNS auto switch." +msgstr "啟用 AdGuard DNS 自動切換前至少需要填寫一個預設上游。" + +msgid "At least one Tailnet Upstream is required before enabling AdGuard DNS auto switch." +msgstr "啟用 AdGuard DNS 自動切換前至少需要填寫一個 Tailnet 上游。" + +msgid "AdGuard DNS auto switch cannot be enabled until every environment status check passes." +msgstr "所有環境狀態檢查通過後才能啟用 AdGuard DNS 自動切換。" + +msgid "Default Upstreams" +msgstr "預設上游" + +msgid "Used when Tailnet DNS is unhealthy." +msgstr "Tailnet DNS 不健康時使用。" + +msgid "Tailnet Upstreams" +msgstr "Tailnet 上游" + +msgid "Added when Tailnet DNS is healthy." +msgstr "Tailnet DNS 健康時追加使用。" + +msgid "Health Check Domain" +msgstr "健康檢查網域" + +msgid "Resolved through Tailscale DNS 100.100.100.100. The result must match one of the expected internal IPs." +msgstr "透過 Tailscale DNS 100.100.100.100 解析,結果必須匹配任一預期內網 IP。" + +msgid "Expected Internal IPs" +msgstr "預期內網 IP" + +msgid "Check Interval" +msgstr "檢查間隔" + +msgid "Success Threshold" +msgstr "成功閾值" + +msgid "Failure Threshold" +msgstr "失敗閾值" + +msgid "Unknown user" +msgstr "未知使用者" + +msgid "Offline peers cannot be probed" +msgstr "離線設備不可探測" + +msgid "Enabled; 4 bypass rules are active" +msgstr "已開啟,4 條繞過規則已生效" + +msgid "Enabled; waiting for OpenClash nftables chains" +msgstr "已開啟,正在等待 OpenClash 建立 nftables 鏈" + +msgid "Disabled; Tailscale traffic is handled by OpenClash" +msgstr "已關閉,Tailscale 流量將由 OpenClash 處理" + +msgid "OpenClash is not installed; bypass is not required" +msgstr "未安裝 OpenClash,無需繞過" + +msgid "Unsupported; firewall4/nftables is required" +msgstr "目前系統不支援,僅支援 firewall4/nftables" + +msgid "Configuration error" +msgstr "設定錯誤" + +msgid "Unknown status" +msgstr "未知狀態" + +msgid "Unable to read OpenClash bypass status." +msgstr "無法讀取 OpenClash 繞過狀態。" + +msgid "OpenClash" +msgstr "OpenClash" + +msgid "Protect Tailscale Traffic (Bypass OpenClash)" +msgstr "保護 Tailscale 流量(繞過 OpenClash)" + +msgid "Keep Tailscale control connections, direct connections, Tailnet DNS, and subnet traffic outside OpenClash. When disabled, this traffic is handled by OpenClash; node connectivity, direct paths, subnet access, and internal DNS are no longer protected by this feature. Keep this enabled while using OpenClash." +msgstr "開啟後,Tailscale 控制連線、直連通訊、Tailnet DNS 和跨子網流量不會被 OpenClash 接管。關閉後,這些流量將重新經過 OpenClash,節點在線、點對點直連、跨子網存取和 Tailnet DNS 解析不再受本功能保護;如果 OpenClash 規則接管或重新導向這些流量,可能導致節點離線、直連退化為 DERP、跨子網存取或 Tailnet DNS 中斷。使用 OpenClash 時必須保持開啟。" + +msgid "Prioritize Tailscale Routes Before mwan3" +msgstr "優先讓 Tailscale 路由先於 mwan3" + +msgid "Give routes already present in Tailscale table 52 priority over mwan3-marked traffic. This fixes LAN clients being sent to a WAN instead of a Tailnet or routed subnet. Public destinations not present in table 52 continue through mwan3. Disabled leaves mwan3 precedence unchanged. Verify from a real LAN client or with an equivalent mwan3 fwmark; the router's unmarked ip route get alone is insufficient. The feature remains blocked while table 52 has a default route, such as when using an exit node." +msgstr "讓已存在於 Tailscale 路由表 52 中的路由優先於帶 mwan3 標記的流量。這可修復 LAN 用戶端存取 Tailnet 或已路由子網時被錯誤送往 WAN 的問題。不在路由表 52 中的公網目標仍由 mwan3 處理。關閉後保持 mwan3 原有優先順序。請從真實 LAN 用戶端驗證,或使用等效的 mwan3 fwmark;僅在路由器上執行未標記的 ip route get 不足以驗證。若路由表 52 存在預設路由(例如正在使用出口節點),功能會保持阻止狀態。" + +msgid "Policy Routing Status" +msgstr "策略路由狀態" + +msgid "Enabled; mwan3 is not installed" +msgstr "已開啟,未安裝 mwan3" + +msgid "Enabled; no earlier mwan3-marked rule was detected" +msgstr "已開啟,未偵測到較早的 mwan3 標記規則" + +msgid "Enabled; Tailscale routes take precedence over mwan3" +msgstr "已開啟,Tailscale 路由優先於 mwan3" + +msgid "Enabled; waiting for the managed policy rule" +msgstr "已開啟,正在等待受管理的策略路由規則" + +msgid "Disabled; mwan3 routing is unchanged" +msgstr "已關閉,mwan3 路由保持不變" + +msgid "Blocked; table 52 contains a default route" +msgstr "已阻止,路由表 52 包含預設路由" + +msgid "Blocked; a mwan3 rule has higher priority than 1000" +msgstr "已阻止,存在優先順序數值小於 1000 的 mwan3 規則" + +msgid "Blocked; priority 1000 is already in use" +msgstr "已阻止,優先順序 1000 已被占用" + +msgid "Blocked; an existing rule is not managed by this app" +msgstr "已阻止,現有規則不由本應用程式管理" + +msgid "Unable to read Tailscale policy-routing status." +msgstr "無法讀取 Tailscale 策略路由狀態。" diff --git a/luci-app-tailscale/root/etc/config/tailscale b/luci-app-tailscale/root/etc/config/tailscale new file mode 100644 index 000000000..acb576fbc --- /dev/null +++ b/luci-app-tailscale/root/etc/config/tailscale @@ -0,0 +1,68 @@ +config tailscale 'settings' + # Set whether Tailcale is enabled or not + option enabled '0' + # Set the port to listen on for incoming Tailscale packets (default 41641) + option port '41641' + # Allow WAN inbound UDP traffic to the local Tailscale listen port for first-packet direct connections + #option allow_wan_direct '0' + # Firewall source zones used when WAN direct is enabled + list wan_direct_zones 'wan' + # Path to config file + option config_path '/etc/tailscale' + # Default to using nftables - change below to 'iptables' if still using iptables + option fw_mode 'nftables' + # Enable stdout log + option log_stdout '1' + # Enable stderr log + option log_stderr '1' + # Accept subnet routes that other nodes advertise + #option accept_routes '1' + # Provide a hostname to use for the device instead of the one provided by the OS + #option hostname '' + # Accept DNS configuration from the admin console. (default accept) + option accept_dns '1' + # Offer to be an exit node for outbound internet traffic from the Tailscale network + #option advertise_exit_node '1' + # Provide a Tailscale IP or machine name to use as an exit node + #option exit_node '' + # Expose physical subnet routes to your entire Tailscale network (e.g. 10.0.0.0/24) + #list advertise_routes '' + # Set to '1' to disable subnet route masquerading + #option disable_snat_subnet_routes '1' + # Set firewall zone (ts_ac_lan: Tailscale access LAN, ts_ac_wan: Tailscale access WAN:, lan_ac_ts: LAN access Tailscale, wan_ac_ts: WAN access Tailscale) + # Documentation https://openwrt.org/docs/guide-user/services/vpn/tailscale/start + #list access 'ts_ac_lan' + #list access 'ts_ac_wan' + #list access 'lan_ac_ts' + #list access 'wan_ac_ts' + # Enable periodic Tailscale pings to keep selected peer paths warm + #option keepalive_enabled '0' + # Select peer hostnames from tailscale status --json + #list keepalive_peers '' + # Seconds between keepalive probes + option keepalive_interval '20' + # Seconds between repeated failure log messages for the same peer + option keepalive_failure_log_interval '300' + # Enable automatic AdGuard Home upstream switching based on tailnet DNS health + #option adguard_dns_switch_enabled '0' + # Configure at least one default upstream in LuCI before enabling the switch + #list adguard_default_upstreams '' + # Configure at least one tailnet upstream in LuCI before enabling the switch + #list adguard_tailnet_upstreams '' + # Domain and expected IPs for tailnet DNS health checks through 100.100.100.100; fill both before enabling + #option adguard_health_domain '' + # Add at least one expected IP before enabling the AdGuard DNS switch + #list adguard_health_expected_ips '' + # Seconds between AdGuard DNS health checks + option adguard_check_interval '10' + # Consecutive health check results required before switching profiles + option adguard_success_threshold '2' + option adguard_failure_threshold '2' + # AdGuard Home API endpoint and optional credentials + option adguard_api_url 'http://127.0.0.1:3000' + #option adguard_username '' + # Set extra flags for enabling settings upon the initiation of Tailscale + # Documentation https://tailscale.com/kb/1241/tailscale-up + #list flags '' + # Provide the base URL of a custom server instead of https://controlplane.tailscale.com + #option login_server '' diff --git a/luci-app-tailscale/root/etc/config/tailscale_openclash b/luci-app-tailscale/root/etc/config/tailscale_openclash new file mode 100644 index 000000000..b97df2055 --- /dev/null +++ b/luci-app-tailscale/root/etc/config/tailscale_openclash @@ -0,0 +1,2 @@ +config openclash 'settings' + option enabled '1' diff --git a/luci-app-tailscale/root/etc/config/tailscale_policy_routing b/luci-app-tailscale/root/etc/config/tailscale_policy_routing new file mode 100644 index 000000000..a8bf54b1e --- /dev/null +++ b/luci-app-tailscale/root/etc/config/tailscale_policy_routing @@ -0,0 +1,3 @@ +config settings 'settings' + # Prefer Tailscale table 52 before mwan3 fwmark policy rules. + option enabled '0' diff --git a/luci-app-tailscale/root/etc/hotplug.d/iface/98-tailscale-policy-routing b/luci-app-tailscale/root/etc/hotplug.d/iface/98-tailscale-policy-routing new file mode 100755 index 000000000..c327c4397 --- /dev/null +++ b/luci-app-tailscale/root/etc/hotplug.d/iface/98-tailscale-policy-routing @@ -0,0 +1,14 @@ +#!/bin/sh + +POLICY_HELPER="${POLICY_HELPER:-/usr/sbin/tailscale_policy_routing}" +LOGGER_CMD="${LOGGER_CMD:-logger}" + +case "${ACTION:-}" in +ifup|ifupdate) ;; +*) exit 0 ;; +esac + +[ -x "$POLICY_HELPER" ] || exit 0 + +"$POLICY_HELPER" ensure >/dev/null 2>&1 || \ + "$LOGGER_CMD" -p daemon.err -t tailscale_policy_routing 'failed to restore Tailscale table 52 policy priority' diff --git a/luci-app-tailscale/root/etc/init.d/tailscale b/luci-app-tailscale/root/etc/init.d/tailscale new file mode 100755 index 000000000..6ce86a030 --- /dev/null +++ b/luci-app-tailscale/root/etc/init.d/tailscale @@ -0,0 +1,313 @@ +#!/bin/sh /etc/rc.common + +START=90 + +USE_PROCD=1 + +PROG=/usr/sbin/tailscale_helper +PROGK=/usr/sbin/tailscale_keepalive +PROGA=/usr/sbin/tailscale_adguard_dns_switch +PROGS=/usr/sbin/tailscale_secrets +PROGD=/usr/sbin/tailscaled +CONFIG_PATH=/var/lib/tailscale +ADGUARD_DNS_STATE_DIR="${ADGUARD_DNS_STATE_DIR:-/var/run/tailscale_adguard_dns_switch}" +FIREWALL_PENDING_STATE_FILE="${FIREWALL_PENDING_STATE_FILE:-/var/run/tailscale-firewall-pending}" + +service_triggers() { + procd_add_reload_trigger "tailscale" +} + +reload_in_progress() { + [ "${TAILSCALE_INTERNAL_RELOAD:-0}" = "1" ] +} + +section_enabled() { + config_get_bool enabled "$1" 'enabled' 0 + [ $enabled -gt 0 ] +} + +append_wan_direct_zone() { + wan_direct_zones="${wan_direct_zones:+$wan_direct_zones }$1" +} + +normalize_tailscale_port() { + case "$1" in + ''|*[!0-9]*) printf '%s\n' 41641 ;; + *) + if [ "$1" -ge 1 ] 2>/dev/null && [ "$1" -le 65535 ] 2>/dev/null; then + printf '%s\n' "$1" + else + printf '%s\n' 41641 + fi + ;; + esac +} + +tailscale_helper() { + local cfg="$1" + local state_was_present="${2:-0}" + local accept_routes hostname accept_dns advertise_exit_node exit_node advertise_routes disable_snat_subnet_routes allow_wan_direct wan_direct_zones access flags login_server std_out std_err port + + config_get port $cfg 'port' + port="$(normalize_tailscale_port "$port")" + config_get_bool accept_routes $cfg 'accept_routes' 0 + config_get hostname $cfg 'hostname' + config_get_bool accept_dns $cfg 'accept_dns' 1 + config_get_bool advertise_exit_node $cfg 'advertise_exit_node' 0 + config_get exit_node $cfg 'exit_node' + config_get advertise_routes $cfg 'advertise_routes' + config_get_bool disable_snat_subnet_routes $cfg 'disable_snat_subnet_routes' 0 + config_get_bool allow_wan_direct $cfg 'allow_wan_direct' 0 + wan_direct_zones="" + config_list_foreach "$cfg" 'wan_direct_zones' append_wan_direct_zone + wan_direct_zones="${wan_direct_zones:-wan}" + config_get access $cfg 'access' + config_get flags $cfg 'flags' + config_get login_server $cfg 'login_server' + config_get_bool std_out $cfg 'log_stdout' + config_get_bool std_err $cfg 'log_stderr' + + procd_open_instance + procd_set_param command $PROG --run + + [ "$accept_routes" = "1" ] && procd_append_param command --accept-routes=true + [ -n "$hostname" ] && procd_append_param command --hostname="$hostname" + [ "$accept_dns" = "0" ] && procd_append_param command --accept-dns=false + [ "$advertise_exit_node" = "1" ] && procd_append_param command --advertise-exit-node + [ -n "$exit_node" ] && { + procd_append_param command --exit-node="$exit_node" + procd_append_param command --exit-node-allow-lan-access=true + } + [ -n "$advertise_routes" ] && procd_append_param command --advertise-routes="$(echo $advertise_routes | tr ' ' ',')" + if [ "$disable_snat_subnet_routes" = "1" ]; then + procd_append_param command --snat-subnet-routes=false + else + procd_append_param command --snat-subnet-routes=true + fi + [ -n "$flags" ] && procd_append_param command $flags + [ -n "$login_server" ] && procd_append_param command --login-server="$login_server" + + procd_set_param env \ + ACCEPT_DNS="$accept_dns" \ + EXIT_NODE="$exit_node" \ + ACCESS="$access" \ + DISABLE_SNAT_SUBNET_ROUTES="$disable_snat_subnet_routes" \ + ALLOW_WAN_DIRECT="$allow_wan_direct" \ + WAN_DIRECT_ZONES="$wan_direct_zones" \ + TAILSCALE_PORT="$port" \ + TAILSCALE_STATE_WAS_PRESENT="$state_was_present" \ + TAILSCALE_HELPER_STATE_DIR="$CONFIG_PATH" \ + FIREWALL_PENDING_STATE_FILE="$FIREWALL_PENDING_STATE_FILE" + + procd_set_param stdout "$std_out" + procd_set_param stderr "$std_err" + procd_close_instance +} + +tailscale_keepalive() { + local cfg="$1" + local keepalive_enabled keepalive_peers keepalive_interval keepalive_failure_log_interval std_out std_err + + config_get_bool keepalive_enabled $cfg 'keepalive_enabled' 0 + [ "$keepalive_enabled" = "1" ] || return 0 + + config_get keepalive_peers $cfg 'keepalive_peers' + [ -n "$keepalive_peers" ] || { + logger -p daemon.err -t tailscale "keepalive enabled but no peers configured" + return 0 + } + + config_get keepalive_interval $cfg 'keepalive_interval' 20 + config_get keepalive_failure_log_interval $cfg 'keepalive_failure_log_interval' 300 + config_get_bool std_out $cfg 'log_stdout' + config_get_bool std_err $cfg 'log_stderr' + + procd_open_instance keepalive + procd_set_param command $PROGK --run "$keepalive_interval" "$keepalive_failure_log_interval" + procd_append_param command $keepalive_peers + procd_set_param respawn + procd_set_param stdout "$std_out" + procd_set_param stderr "$std_err" + procd_close_instance +} + +tailscale_adguard_dns() { + local cfg="$1" + local adguard_dns_switch_enabled std_out std_err + local adguard_health_domain adguard_health_expected_ips + local adguard_default_upstreams adguard_tailnet_upstreams + + config_get_bool adguard_dns_switch_enabled "$cfg" 'adguard_dns_switch_enabled' 0 + [ "$adguard_dns_switch_enabled" = "1" ] || return 0 + + config_get adguard_health_domain "$cfg" 'adguard_health_domain' + config_get adguard_health_expected_ips "$cfg" 'adguard_health_expected_ips' + config_get adguard_default_upstreams "$cfg" 'adguard_default_upstreams' + config_get adguard_tailnet_upstreams "$cfg" 'adguard_tailnet_upstreams' + + [ -n "$adguard_health_domain" ] || { + logger -p daemon.err -t tailscale_adguard_dns "adguard_dns_switch_enabled=1 but adguard_health_domain is not configured" + return 0 + } + [ -n "$adguard_health_expected_ips" ] || { + logger -p daemon.err -t tailscale_adguard_dns "adguard_dns_switch_enabled=1 but adguard_health_expected_ips is not configured" + return 0 + } + [ -n "$adguard_default_upstreams" ] || { + logger -p daemon.err -t tailscale_adguard_dns "adguard_dns_switch_enabled=1 but adguard_default_upstreams is not configured" + return 0 + } + [ -n "$adguard_tailnet_upstreams" ] || { + logger -p daemon.err -t tailscale_adguard_dns "adguard_dns_switch_enabled=1 but adguard_tailnet_upstreams is not configured" + return 0 + } + + config_get_bool std_out "$cfg" 'log_stdout' 1 + config_get_bool std_err "$cfg" 'log_stderr' 1 + + procd_open_instance adguard_dns + procd_set_param command "$PROGA" --run + procd_set_param respawn + procd_set_param stdout "$std_out" + procd_set_param stderr "$std_err" + procd_close_instance +} + +tailscale_adguard_dns_apply_down() { + local cfg="$1" + local adguard_dns_switch_enabled adguard_current_profile + + config_get_bool adguard_dns_switch_enabled "$cfg" 'adguard_dns_switch_enabled' 0 + adguard_current_profile="$(cat "$ADGUARD_DNS_STATE_DIR/current_profile" 2>/dev/null || true)" + [ "$adguard_current_profile" != "down" ] || return 0 + [ "$adguard_dns_switch_enabled" = "1" ] || [ "$adguard_current_profile" = "up" ] || return 0 + + "$PROGA" --apply-profile down >/dev/null 2>&1 || { + logger -p daemon.err -t tailscale_adguard_dns "failed to restore AdGuard DNS down profile" + return 1 + } +} + +start_instance() { + local cfg="$1" + local port config_path fw_mode std_out std_err state_file state_was_present allow_wan_direct secrets_ref + + config_get secrets_ref "$cfg" 'secrets_ref' + if [ -n "$secrets_ref" ] && ! "$PROGS" activate "$secrets_ref"; then + logger -p daemon.err -t tailscale "failed to activate protected credential version" + return 1 + fi + + if ! section_enabled "$cfg"; then + TAILSCALE_HELPER_STATE_DIR="$CONFIG_PATH" \ + FIREWALL_PENDING_STATE_FILE="$FIREWALL_PENDING_STATE_FILE" \ + "$PROG" --cleanup-managed-firewall || logger -p daemon.err -t tailscale "failed to remove managed firewall state for disabled service" + echo "disabled in /etc/config/tailscale" + return 1 + fi + + config_get port $cfg 'port' + port="$(normalize_tailscale_port "$port")" + config_get config_path $cfg 'config_path' + config_get fw_mode $cfg 'fw_mode' + config_get_bool std_out $cfg 'log_stdout' + config_get_bool std_err $cfg 'log_stderr' + config_get_bool allow_wan_direct $cfg 'allow_wan_direct' 0 + if [ "$allow_wan_direct" != "1" ]; then + TAILSCALE_HELPER_STATE_DIR="$CONFIG_PATH" \ + FIREWALL_PENDING_STATE_FILE="$FIREWALL_PENDING_STATE_FILE" \ + "$PROG" --cleanup-wan-direct-firewall || return 1 + fi + + [ -d $config_path ] || mkdir -p $config_path + [ -d $CONFIG_PATH ] || mkdir -p $CONFIG_PATH + state_file="$config_path/tailscaled.state" + state_was_present=0 + [ ! -s "$state_file" ] || state_was_present=1 + + "$PROGD" --cleanup + + procd_open_instance + procd_set_param command $PROGD + + procd_append_param command --port "$port" + [ -n "$state_file" ] && procd_append_param command --state "$state_file" + + procd_set_param env TS_DEBUG_FIREWALL_MODE="$fw_mode" + + procd_set_param respawn + procd_set_param stdout "$std_out" + procd_set_param stderr "$std_err" + procd_close_instance + + tailscale_helper "$cfg" "$state_was_present" + tailscale_keepalive "$cfg" + tailscale_adguard_dns "$cfg" +} + +start_service() { + "$PROGS" migrate || { + logger -p daemon.err -t tailscale "failed to migrate protected credentials" + return 1 + } + config_load 'tailscale' + config_foreach start_instance 'tailscale' +} + +stop_instance() { + local cfg="$1" + local helper_cleanup_failed=0 cleanup_action + + if reload_in_progress; then + cleanup_action=--cleanup-exit-node-firewall + else + cleanup_action=--cleanup-managed-firewall + fi + TAILSCALE_HELPER_STATE_DIR="$CONFIG_PATH" \ + FIREWALL_PENDING_STATE_FILE="$FIREWALL_PENDING_STATE_FILE" \ + "$PROG" "$cleanup_action" || helper_cleanup_failed=1 + + # Remove dnsmasq settings + MagicDNSSuffix=$(tailscale status --json | awk -F'"' '/"MagicDNSSuffix"/ {last=$(NF-1)} END {print last}') + if [ -n "$MagicDNSSuffix" ]; then + target_address="/$MagicDNSSuffix/100.100.100.100" + indexes=$(uci show dhcp | sed -n 's/^dhcp\.@dnsmasq\[\([0-9][0-9]*\)\]=dnsmasq$/\1/p') + for index in $indexes; do + option="dhcp.@dnsmasq[$index].address" + uci -q del_list "$option=$target_address" + done + fi + + # Tailscale owns tailscale0, policy rules, and table 52 routes. Do not reload + # network here; netifd must not manage tailscale0. + [ -n "$(uci changes dhcp)" ] && uci commit dhcp && /etc/init.d/dnsmasq reload + + "$PROGD" --cleanup + + # Keep helper recovery state available when firewall cleanup needs a retry. + [ "$helper_cleanup_failed" -ne 0 ] || rm -rf "$CONFIG_PATH" + + [ "$helper_cleanup_failed" -eq 0 ] +} + +stop_service() { + config_load 'tailscale' + config_foreach stop_instance 'tailscale' +} + +service_stopped() { + config_load 'tailscale' + config_foreach tailscale_adguard_dns_apply_down 'tailscale' +} + +reload_service() { + local reload_status=0 + TAILSCALE_INTERNAL_RELOAD=1 + stop || reload_status=1 + TAILSCALE_INTERNAL_RELOAD=0 + if ! start; then + reload_status=1 + stop || reload_status=1 + fi + return "$reload_status" +} diff --git a/luci-app-tailscale/root/etc/init.d/tailscale-openclash-bypass b/luci-app-tailscale/root/etc/init.d/tailscale-openclash-bypass new file mode 100755 index 000000000..5c79019ec --- /dev/null +++ b/luci-app-tailscale/root/etc/init.d/tailscale-openclash-bypass @@ -0,0 +1,25 @@ +#!/bin/sh /etc/rc.common + +START=99 +USE_PROCD=1 +PROG="${PROG:-/usr/sbin/tailscale_openclash_bypass}" + +service_triggers() { + procd_add_reload_trigger "tailscale_openclash" +} + +sync_instance() { + "$PROG" sync +} + +start_service() { + sync_instance +} + +reload_service() { + start_service +} + +stop_service() { + "$PROG" cleanup +} diff --git a/luci-app-tailscale/root/etc/init.d/tailscale-policy-routing b/luci-app-tailscale/root/etc/init.d/tailscale-policy-routing new file mode 100755 index 000000000..642ce613a --- /dev/null +++ b/luci-app-tailscale/root/etc/init.d/tailscale-policy-routing @@ -0,0 +1,30 @@ +#!/bin/sh /etc/rc.common + +START=91 +USE_PROCD=1 + +PROG="${PROG:-/usr/sbin/tailscale_policy_routing}" + +service_triggers() { + procd_add_reload_trigger "tailscale_policy_routing" +} + +start_service() { + "$PROG" sync || true + "$PROG" enabled || return 0 + + procd_open_instance monitor + procd_set_param command "$PROG" monitor + procd_set_param respawn + procd_close_instance +} + +reload_service() { + start_service +} + +stop_service() { + # Persistent cleanup is reserved for an explicit disable or package removal. + # A normal service stop must not erase the enabled UCI declaration. + return 0 +} diff --git a/luci-app-tailscale/root/etc/uci-defaults/40_luci-tailscale b/luci-app-tailscale/root/etc/uci-defaults/40_luci-tailscale new file mode 100755 index 000000000..6daf36e2f --- /dev/null +++ b/luci-app-tailscale/root/etc/uci-defaults/40_luci-tailscale @@ -0,0 +1,86 @@ +#!/bin/sh + +UPGRADE_STATE_DIR="${UPGRADE_STATE_DIR:-/etc/.luci-app-tailscale-upgrade}" +TAILSCALE_SECRETS_BIN="${TAILSCALE_SECRETS_BIN:-/usr/sbin/tailscale_secrets}" +UCI_BIN="${UCI_BIN:-uci}" +OPENCLASH_BYPASS_INIT="${OPENCLASH_BYPASS_INIT:-/etc/init.d/tailscale-openclash-bypass}" +POLICY_ROUTING_INIT="${POLICY_ROUTING_INIT:-/etc/init.d/tailscale-policy-routing}" +LEGACY_ADGUARD_DNS_INIT="${LEGACY_ADGUARD_DNS_INIT:-/etc/init.d/tailscale-adguard-dns}" +LEGACY_ADGUARD_DNS_RC_DIR="${LEGACY_ADGUARD_DNS_RC_DIR:-/etc/rc.d}" +LEGACY_ADGUARD_DNS_BACKUP_DIR="${LEGACY_ADGUARD_DNS_BACKUP_DIR:-/etc/tailscale/luci-app-tailscale-legacy}" +LUCI_INDEX_CACHE="${LUCI_INDEX_CACHE:-/tmp/luci-indexcache}" + +cleanup_upgrade_state() { + rm -f "$UPGRADE_STATE_DIR/tailscale" "$UPGRADE_STATE_DIR/tailscale_openclash" \ + "$UPGRADE_STATE_DIR/tailscale_openclash.absent" "$UPGRADE_STATE_DIR/tailscale_policy_routing" \ + "$UPGRADE_STATE_DIR/.complete" + rmdir "$UPGRADE_STATE_DIR" 2>/dev/null || true +} + +restore_upgrade_config() { + local source_file="$1" target_file="$2" temporary_file + + [ -f "$source_file" ] || return 0 + temporary_file="$target_file.luci-app-tailscale-upgrade.$$" + cp "$source_file" "$temporary_file" || return 1 + mv "$temporary_file" "$target_file" || { + rm -f "$temporary_file" + return 1 + } +} + +cleanup_legacy_adguard_dns_service() { + local backup_dir link + + [ -e "$LEGACY_ADGUARD_DNS_INIT" ] || return 0 + backup_dir="$LEGACY_ADGUARD_DNS_BACKUP_DIR/tailscale-adguard-dns.$(date +%Y%m%d%H%M%S).$$" + mkdir -p "$backup_dir" || return 1 + + for link in "$LEGACY_ADGUARD_DNS_RC_DIR"/*tailscale-adguard-dns; do + [ -e "$link" ] || [ -L "$link" ] || continue + printf '%s\t%s\n' "${link##*/}" "$(readlink "$link" 2>/dev/null || true)" >>"$backup_dir/rc-links" + done + + if [ -x "$LEGACY_ADGUARD_DNS_INIT" ]; then + "$LEGACY_ADGUARD_DNS_INIT" stop || return 1 + "$LEGACY_ADGUARD_DNS_INIT" disable || true + fi + + cp -p "$LEGACY_ADGUARD_DNS_INIT" "$backup_dir/init" || return 1 + for link in "$LEGACY_ADGUARD_DNS_RC_DIR"/*tailscale-adguard-dns; do + [ -e "$link" ] || [ -L "$link" ] || continue + rm -f "$link" || return 1 + done + mv "$LEGACY_ADGUARD_DNS_INIT" "$backup_dir/tailscale-adguard-dns.disabled" || return 1 +} + +if [ -f "$UPGRADE_STATE_DIR/.complete" ]; then + restore_upgrade_config "$UPGRADE_STATE_DIR/tailscale" /etc/config/tailscale || exit 1 + if [ -f "$UPGRADE_STATE_DIR/tailscale_openclash.absent" ]; then + rm -f /etc/config/tailscale_openclash + else + restore_upgrade_config "$UPGRADE_STATE_DIR/tailscale_openclash" /etc/config/tailscale_openclash || exit 1 + fi + restore_upgrade_config "$UPGRADE_STATE_DIR/tailscale_policy_routing" /etc/config/tailscale_policy_routing || exit 1 + cleanup_upgrade_state +elif [ -d "$UPGRADE_STATE_DIR" ]; then + cleanup_upgrade_state +fi + +cleanup_legacy_adguard_dns_service || exit 1 + +"$TAILSCALE_SECRETS_BIN" migrate || exit 1 + +openclash_bypass_enabled="$("$UCI_BIN" -q get tailscale_openclash.settings.enabled 2>/dev/null || true)" +if [ "$openclash_bypass_enabled" = '1' ] && [ -x "$OPENCLASH_BYPASS_INIT" ]; then + "$OPENCLASH_BYPASS_INIT" enable + "$OPENCLASH_BYPASS_INIT" start +fi + +if [ -x "$POLICY_ROUTING_INIT" ]; then + "$POLICY_ROUTING_INIT" enable + "$POLICY_ROUTING_INIT" start +fi + +rm -f "$LUCI_INDEX_CACHE" +exit 0 diff --git a/luci-app-tailscale/root/lib/upgrade/keep.d/luci-app-tailscale b/luci-app-tailscale/root/lib/upgrade/keep.d/luci-app-tailscale new file mode 100644 index 000000000..9d4b0ce88 --- /dev/null +++ b/luci-app-tailscale/root/lib/upgrade/keep.d/luci-app-tailscale @@ -0,0 +1 @@ +/etc/tailscale/luci-secrets.json diff --git a/luci-app-tailscale/root/usr/libexec/rpcd/luci.tailscale b/luci-app-tailscale/root/usr/libexec/rpcd/luci.tailscale new file mode 100755 index 000000000..333be9187 --- /dev/null +++ b/luci-app-tailscale/root/usr/libexec/rpcd/luci.tailscale @@ -0,0 +1,230 @@ +#!/bin/sh + +# Copyright (C) 2026 jearton +# SPDX-License-Identifier: GPL-3.0-only + +PRECHECK_BIN="${PRECHECK_BIN:-/usr/sbin/tailscale_adguard_dns_switch}" +SECRETS_BIN="${SECRETS_BIN:-/usr/sbin/tailscale_secrets}" +OPENCLASH_BYPASS_BIN="${OPENCLASH_BYPASS_BIN:-/usr/sbin/tailscale_openclash_bypass}" +POLICY_ROUTING_BIN="${POLICY_ROUTING_BIN:-/usr/sbin/tailscale_policy_routing}" +JQ_BIN="${JQ_BIN:-jq}" + +print_methods() { + printf '%s\n' '{"secret_status":{},"openclash_bypass_status":{},"policy_routing_status":{},"adguard_preflight":{"candidate":"","api_url":"","username":"","password_set":"","password":"","default_upstreams":"","tailnet_upstreams":"","health_domain":"","expected_ips":""},"set_secrets":{"authkey_set":"","authkey":"","adguard_password_set":"","adguard_password":"","api_url":"","username":"","base_ref":""}}' +} + +fail_json() { + message="$1" + "$JQ_BIN" -nc --arg error "$message" '{code: 2, ready: "fail", error: $error}' + exit 1 +} + +parse_request() { + request="$(cat)" + printf '%s' "$request" | "$JQ_BIN" -e ' + type == "object" and + (.candidate | type == "string") and + (.api_url | type == "string") and + (.username | type == "string") and + (.password_set | type == "string") and + (.password | type == "string") and + (.default_upstreams | type == "string") and + (.tailnet_upstreams | type == "string") and + (.health_domain | type == "string") and + (.expected_ips | type == "string") + ' >/dev/null 2>&1 || fail_json "invalid request" + + candidate="$(printf '%s' "$request" | "$JQ_BIN" -r '.candidate')" + api_url="$(printf '%s' "$request" | "$JQ_BIN" -r '.api_url')" + username="$(printf '%s' "$request" | "$JQ_BIN" -r '.username')" + password_set="$(printf '%s' "$request" | "$JQ_BIN" -r '.password_set')" + password="$(printf '%s' "$request" | "$JQ_BIN" -r '.password')" + default_upstreams="$(printf '%s' "$request" | "$JQ_BIN" -r '.default_upstreams')" + tailnet_upstreams="$(printf '%s' "$request" | "$JQ_BIN" -r '.tailnet_upstreams')" + health_domain="$(printf '%s' "$request" | "$JQ_BIN" -r '.health_domain')" + expected_ips="$(printf '%s' "$request" | "$JQ_BIN" -r '.expected_ips')" + + case "$candidate" in + 0|1) ;; + *) fail_json "invalid candidate mode" ;; + esac + + case "$password_set" in + 0|1) ;; + *) fail_json "invalid password mode" ;; + esac + if [ "$candidate" = "1" ]; then + case "$api_url" in + http://?*|https://?*) ;; + *) fail_json "AdGuard API URL must use HTTP or HTTPS" ;; + esac + fi +} + +run_preflight() { + if [ "$candidate" = "1" ] && [ "$password_set" = "0" ]; then + "$SECRETS_BIN" matches-adguard "$api_url" "$username" >/dev/null 2>&1 || \ + fail_json "saved AdGuard password is not bound to this API URL and username" + fi + + if [ "$candidate" = "1" ]; then + if output="$( + TAILSCALE_ADGUARD_PREFLIGHT_CANDIDATE=1 \ + TAILSCALE_ADGUARD_PREFLIGHT_API_URL="$api_url" \ + TAILSCALE_ADGUARD_PREFLIGHT_USERNAME="$username" \ + TAILSCALE_ADGUARD_PREFLIGHT_PASSWORD_SET="$password_set" \ + TAILSCALE_ADGUARD_PREFLIGHT_PASSWORD="$password" \ + TAILSCALE_ADGUARD_PREFLIGHT_DEFAULT_UPSTREAMS="$default_upstreams" \ + TAILSCALE_ADGUARD_PREFLIGHT_TAILNET_UPSTREAMS="$tailnet_upstreams" \ + TAILSCALE_ADGUARD_PREFLIGHT_HEALTH_DOMAIN="$health_domain" \ + TAILSCALE_ADGUARD_PREFLIGHT_EXPECTED_IPS="$expected_ips" \ + "$PRECHECK_BIN" --preflight + )"; then + code=0 + else + code=$? + fi + else + if output="$("$PRECHECK_BIN" --preflight)"; then + code=0 + else + code=$? + fi + fi + + printf '%s\n' "$output" | "$JQ_BIN" -Rn --argjson code "$code" ' + reduce inputs as $line ({code: $code}; + ($line | index("=")) as $separator | + if $separator == null then . + else . + {($line[0:$separator]): $line[$separator + 1:]} + end + ) + ' +} + +run_secret_status() { + "$SECRETS_BIN" migrate >/dev/null 2>&1 || fail_json "failed to migrate protected credentials" + authkey_set=0 + adguard_password_set=0 + "$SECRETS_BIN" has authkey >/dev/null 2>&1 && authkey_set=1 + "$SECRETS_BIN" has adguard_password >/dev/null 2>&1 && adguard_password_set=1 + "$JQ_BIN" -nc \ + --arg authkey_set "$authkey_set" \ + --arg adguard_password_set "$adguard_password_set" \ + '{authkey_set:$authkey_set,adguard_password_set:$adguard_password_set}' +} + +run_openclash_bypass_status() { + if output="$("$OPENCLASH_BYPASS_BIN" status 2>&1)"; then + : + else + fail_json "failed to read OpenClash bypass status" + fi + + printf '%s\n' "$output" | "$JQ_BIN" -se 'length == 1 and (.[0] | type == "object")' >/dev/null 2>&1 || \ + fail_json "invalid OpenClash bypass status" + printf '%s\n' "$output" +} + +run_policy_routing_status() { + if output="$("$POLICY_ROUTING_BIN" status 2>&1)"; then + : + else + fail_json "failed to read Tailscale policy-routing status" + fi + + printf '%s\n' "$output" | "$JQ_BIN" -se 'length == 1 and (.[0] | type == "object")' >/dev/null 2>&1 || \ + fail_json "invalid Tailscale policy-routing status" + printf '%s\n' "$output" +} + +run_set_secrets() { + request="$(cat)" + printf '%s' "$request" | "$JQ_BIN" -e ' + def valid_ref_or_empty: + type == "string" and + all(explode[]; + (. >= 48 and . <= 57) or + (. >= 65 and . <= 90) or + (. >= 97 and . <= 122) or + . == 45 or . == 46 or . == 95 + ); + type == "object" and + (.authkey_set | type == "string" and (. == "0" or . == "1")) and + (.authkey | type == "string") and + (.adguard_password_set | type == "string" and (. == "0" or . == "1")) and + (.adguard_password | type == "string") and + (.api_url | type == "string") and + (.username | type == "string") and + (.base_ref | valid_ref_or_empty) and + (if .authkey_set == "1" then (.authkey | length > 0) else true end) and + (if .adguard_password_set == "1" then + (.adguard_password | length > 0) + else true end) + ' >/dev/null 2>&1 || fail_json "invalid credential batch request" + + authkey_set="$(printf '%s' "$request" | "$JQ_BIN" -r '.authkey_set')" + adguard_password_set="$(printf '%s' "$request" | "$JQ_BIN" -r '.adguard_password_set')" + base_ref="$(printf '%s' "$request" | "$JQ_BIN" -r '.base_ref')" + case "$base_ref" in + *[!A-Za-z0-9_.-]*) fail_json "invalid credential batch request" ;; + esac + if [ "$adguard_password_set" = "1" ]; then + api_url="$(printf '%s' "$request" | "$JQ_BIN" -r '.api_url')" + case "$api_url" in + http://?*|https://?*) ;; + *) fail_json "invalid credential batch request" ;; + esac + fi + if [ "$authkey_set" = "0" ] && [ "$adguard_password_set" = "0" ]; then + "$JQ_BIN" -nc --arg ref "$base_ref" '{code:0,ref:$ref}' + return 0 + fi + + if ! ref="$(printf '%s' "$request" | "$JQ_BIN" -c ' + { + authkey: (if .authkey_set == "1" then .authkey else null end), + adguard: (if .adguard_password_set == "1" then { + password: .adguard_password, + api_url: .api_url, + username: .username + } else null end) + } + ' | "$SECRETS_BIN" stage-batch "$base_ref")"; then + fail_json "failed to stage protected credentials" + fi + case "$ref" in + ''|*[!A-Za-z0-9_.-]*) fail_json "invalid protected credential reference" ;; + esac + "$JQ_BIN" -nc --arg ref "$ref" '{code:0,ref:$ref}' +} + +case "${1:-}" in + list) + print_methods + ;; + call) + case "${2:-}" in + secret_status) + run_secret_status + ;; + openclash_bypass_status) + run_openclash_bypass_status + ;; + policy_routing_status) + run_policy_routing_status + ;; + adguard_preflight) + parse_request + run_preflight + ;; + set_secrets) + run_set_secrets + ;; + *) exit 1 ;; + esac + ;; + *) + exit 1 + ;; +esac diff --git a/luci-app-tailscale/root/usr/sbin/tailscale_adguard_dns_switch b/luci-app-tailscale/root/usr/sbin/tailscale_adguard_dns_switch new file mode 100755 index 000000000..a942f3971 --- /dev/null +++ b/luci-app-tailscale/root/usr/sbin/tailscale_adguard_dns_switch @@ -0,0 +1,609 @@ +#!/bin/sh + +# Copyright (C) 2026 jearton +# SPDX-License-Identifier: GPL-3.0-only + +UCI_CMD="${UCI_CMD:-uci}" +SECRETS_BIN="${SECRETS_BIN:-/usr/sbin/tailscale_secrets}" +CURL_CMD="${CURL_CMD:-curl}" +FLOCK_CMD="${FLOCK_CMD:-flock}" +JQ_CMD="${JQ_CMD:-jq}" +LOGGER_CMD="${LOGGER_CMD:-logger}" +NSLOOKUP_CMD="${NSLOOKUP_CMD:-nslookup}" +PGREP_CMD="${PGREP_CMD:-pgrep}" +NETSTAT_CMD="${NETSTAT_CMD:-netstat}" +UBUS_CMD="${UBUS_CMD:-ubus}" +SLEEP_CMD="${SLEEP_CMD:-sleep}" +DATE_CMD="${DATE_CMD:-date}" +CURL_CONNECT_TIMEOUT="${CURL_CONNECT_TIMEOUT:-3}" +CURL_MAX_TIME="${CURL_MAX_TIME:-10}" +STATE_DIR="${STATE_DIR:-/var/run/tailscale_adguard_dns_switch}" +TAG="tailscale_adguard_dns" +FAILURE_LOG_INTERVAL=300 + +umask 077 + +log_msg() { + priority="$1" + message="$2" + + "$LOGGER_CMD" -p "$priority" -t "$TAG" "$message" +} + +ensure_state_dir() { + if [ -L "$STATE_DIR" ]; then + echo "refusing symlink state directory: $STATE_DIR" >&2 + return 1 + fi + + if [ -e "$STATE_DIR" ]; then + [ -d "$STATE_DIR" ] || return 1 + owner="$(ls -nd "$STATE_DIR" 2>/dev/null | awk '{print $3}')" + [ -n "$owner" ] && [ "$owner" = "$(id -u)" ] || return 1 + else + mkdir -m 0700 "$STATE_DIR" || return 1 + fi + + chmod 0700 "$STATE_DIR" +} + +state_temp_file() { + prefix="$1" + ensure_state_dir || return 1 + mktemp "$STATE_DIR/$prefix.XXXXXX" +} + +write_state_atomic() { + destination="$1" + temp_file="$(state_temp_file state)" || return 1 + + if ! cat >"$temp_file" || ! mv -f "$temp_file" "$destination"; then + rm -f "$temp_file" + return 1 + fi +} + +state_key() { + printf '%s' "$1" | sed 's/[^A-Za-z0-9_.-]/_/g' +} + +log_rate_limited() { + priority="$1" + key="$(state_key "$2")" + message="$3" + last_file="$STATE_DIR/$key.last_error" + last_seen= + now="$($DATE_CMD +%s 2>/dev/null || true)" + + case "$now" in + ''|*[!0-9]*) now=0 ;; + esac + [ -r "$last_file" ] && last_seen="$(cat "$last_file" 2>/dev/null || true)" + case "$last_seen" in + ''|*[!0-9]*) last_seen= ;; + esac + + if [ -z "$last_seen" ] || [ $((now - last_seen)) -ge "$FAILURE_LOG_INTERVAL" ]; then + ensure_state_dir || return 1 + log_msg "$priority" "$message" + printf '%s\n' "$now" | write_state_atomic "$last_file" + fi +} + +candidate_mode() { + [ "${TAILSCALE_ADGUARD_PREFLIGHT_CANDIDATE:-0}" = "1" ] +} + +normalize_api_url() { + url="${1:-http://127.0.0.1:3000}" + [ -n "$url" ] || url='http://127.0.0.1:3000' + while [ "${url%/}" != "$url" ]; do + url="${url%/}" + done + case "$url" in + http://?*|https://?*) ;; + *) return 1 ;; + esac + printf '%s\n' "$url" +} + +persisted_uci_get() { + "$UCI_CMD" -q get "tailscale.settings.$1" 2>/dev/null || true +} + +protected_adguard_password() { + if candidate_mode; then + url="${TAILSCALE_ADGUARD_PREFLIGHT_API_URL:-}" + user="${TAILSCALE_ADGUARD_PREFLIGHT_USERNAME:-}" + else + url="$(persisted_uci_get adguard_api_url)" + user="$(persisted_uci_get adguard_username)" + fi + url="$(normalize_api_url "$url")" || return 0 + "$SECRETS_BIN" adguard-password-for "$url" "$user" 2>/dev/null || true +} + +uci_get() { + if candidate_mode; then + case "$1" in + adguard_api_url) printf '%s\n' "${TAILSCALE_ADGUARD_PREFLIGHT_API_URL:-}"; return 0 ;; + adguard_username) printf '%s\n' "${TAILSCALE_ADGUARD_PREFLIGHT_USERNAME:-}"; return 0 ;; + adguard_password) + if [ "${TAILSCALE_ADGUARD_PREFLIGHT_PASSWORD_SET:-0}" = "1" ]; then + printf '%s\n' "${TAILSCALE_ADGUARD_PREFLIGHT_PASSWORD:-}" + return 0 + fi + protected_adguard_password + return 0 + ;; + adguard_health_domain) printf '%s\n' "${TAILSCALE_ADGUARD_PREFLIGHT_HEALTH_DOMAIN:-}"; return 0 ;; + esac + fi + if [ "$1" = "adguard_password" ]; then + protected_adguard_password + return 0 + fi + + persisted_uci_get "$1" +} + +uci_get_raw() { + "$UCI_CMD" -q get "$1" 2>/dev/null || true +} + +uci_list() { + option="$1" + if candidate_mode; then + case "$option" in + adguard_default_upstreams) value="${TAILSCALE_ADGUARD_PREFLIGHT_DEFAULT_UPSTREAMS:-}" ;; + adguard_tailnet_upstreams) value="${TAILSCALE_ADGUARD_PREFLIGHT_TAILNET_UPSTREAMS:-}" ;; + adguard_health_expected_ips) value="${TAILSCALE_ADGUARD_PREFLIGHT_EXPECTED_IPS:-}" ;; + *) value=__tailscale_no_candidate_value__ ;; + esac + if [ "$value" != "__tailscale_no_candidate_value__" ]; then + printf '%s\n' "$value" | sed '/^$/d' + return 0 + fi + fi + + "$UCI_CMD" -q get "tailscale.settings.$option" 2>/dev/null | tr ' ' '\n' | sed '/^$/d' +} + +enabled() { + [ "$(uci_get adguard_dns_switch_enabled)" = "1" ] +} + +dedupe_lines() { + awk 'NF && !seen[$0]++' +} + +default_upstreams() { + uci_list adguard_default_upstreams +} + +tailnet_upstreams() { + uci_list adguard_tailnet_upstreams +} + +default_conditionals_without_tailnet_dns() { + default_upstreams | awk 'index($0, "100.100.100.100") == 0 && index($0, "[") > 0' +} + +default_plain_without_tailnet_dns() { + default_upstreams | awk 'index($0, "100.100.100.100") == 0 && index($0, "[") == 0' +} + +profile_lines() { + local profile="$1" + + case "$profile" in + up) + { + default_conditionals_without_tailnet_dns + tailnet_upstreams + default_plain_without_tailnet_dns + default_upstreams | grep -F '100.100.100.100' || true + } | dedupe_lines + ;; + down) + default_upstreams | grep -Fv '100.100.100.100' | dedupe_lines + ;; + *) + echo "unknown profile: $profile" >&2 + return 2 + ;; + esac +} + +expected_ips() { + uci_list adguard_health_expected_ips +} + +health_domain() { + uci_get adguard_health_domain +} + +health_dns() { + printf '%s\n' "100.100.100.100" +} + +output_has_ip() { + output="$1" + expected="$2" + + printf '%s\n' "$output" | awk -v ip="$expected" ' + { + pos = index($0, ip) + if (pos > 0) { + before = pos > 1 ? substr($0, pos - 1, 1) : "" + after = substr($0, pos + length(ip), 1) + if (before !~ /[0-9A-Fa-f:.]/ && after !~ /[0-9A-Fa-f:.]/) + found = 1 + } + } + END { exit found ? 0 : 1 } + ' +} + +check_health() { + domain="$(health_domain)" + dns="$(health_dns)" + + [ -n "$(tailnet_upstreams)" ] || return 1 + [ -n "$domain" ] || return 1 + [ -n "$(expected_ips)" ] || return 1 + + output="$("$NSLOOKUP_CMD" "$domain" "$dns" 2>&1)" || return 1 + for expected in $(expected_ips); do + output_has_ip "$output" "$expected" && return 0 + done + + return 1 +} + +api_url() { + url="$(uci_get adguard_api_url)" + normalize_api_url "$url" +} + +curl_config_escape() { + printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g' +} + +run_curl() ( + user="$(uci_get adguard_username)" + pass="$(uci_get adguard_password)" + + if [ -n "$user" ]; then + auth_file="$(state_temp_file curl-auth)" || exit 1 + cleanup_curl_auth() { + rm -f "$auth_file" + } + trap cleanup_curl_auth 0 1 2 15 + escaped_user="$(curl_config_escape "$user")" || exit 1 + escaped_pass="$(curl_config_escape "$pass")" || exit 1 + printf 'user = "%s:%s"\n' "$escaped_user" "$escaped_pass" >"$auth_file" || exit 1 + chmod 0600 "$auth_file" || exit 1 + "$CURL_CMD" --config "$auth_file" "$@" + else + "$CURL_CMD" "$@" + fi +) + +curl_get() { + endpoint="$1" + base="$(api_url)" || return 1 + run_curl -fsS --connect-timeout "$CURL_CONNECT_TIMEOUT" --max-time "$CURL_MAX_TIME" "$base$endpoint" +} + +curl_post_json() { + endpoint="$1" + body_file="$2" + base="$(api_url)" || return 1 + run_curl -fsS --connect-timeout "$CURL_CONNECT_TIMEOUT" --max-time "$CURL_MAX_TIME" -H "Content-Type: application/json" -X POST --data-binary "@$body_file" "$base$endpoint" +} + +check_adguard_api() ( + api_check_body= + api_check_response= + api_check_upstreams= + cleanup_preflight_files() { + for cleanup_file in "$api_check_body" "$api_check_response" "$api_check_upstreams"; do + [ -z "$cleanup_file" ] || rm -f "$cleanup_file" + done + } + trap cleanup_preflight_files 0 1 2 15 + api_check_body="$(state_temp_file preflight-body)" || exit 1 + api_check_response="$(state_temp_file preflight-response)" || exit 1 + api_check_upstreams="$(state_temp_file preflight-upstreams)" || exit 1 + + curl_get "/control/status" >/dev/null || return 1 + profile_lines down >"$api_check_upstreams" || return 1 + if profile_is_empty "$api_check_upstreams"; then + log_rate_limited daemon.err api_default_upstreams_empty "AdGuard API preflight requires at least one valid default upstream" + return 1 + fi + "$JQ_CMD" -nc --rawfile upstreams "$api_check_upstreams" ' + {upstream_dns: ($upstreams | split("\n") | map(select(length > 0)))} + ' >"$api_check_body" || { + log_rate_limited daemon.err api_preflight_payload "failed to build AdGuard API preflight payload" + return 1 + } + curl_post_json "/control/test_upstream_dns" "$api_check_body" >"$api_check_response" || return 1 + "$JQ_CMD" -e ' + type == "object" and length > 0 and all(.[]; . == "OK") + ' "$api_check_response" >/dev/null || return 1 +) + +json_replace_upstreams() { + dns_info="$1" + upstream_file="$2" + + "$JQ_CMD" -c --rawfile upstreams "$upstream_file" ' + .upstream_dns = ($upstreams | split("\n") | map(select(length > 0))) + ' "$dns_info" +} + +profile_is_empty() { + [ ! -s "$1" ] || ! awk 'NF { found=1 } END { exit found ? 0 : 1 }' "$1" +} + +validate_profile_file() { + local profile="$1" + local upstream_file="$2" + + if profile_is_empty "$upstream_file"; then + log_rate_limited daemon.err "profile_empty_$profile" "generated AdGuard DNS $profile profile is empty" + return 1 + fi + + if [ "$profile" = "up" ]; then + [ -n "$(tailnet_upstreams)" ] || { + log_rate_limited daemon.err profile_tailnet_upstreams_empty "AdGuard DNS up profile requires tailnet upstreams" + return 1 + } + [ -n "$(health_domain)" ] && [ -n "$(expected_ips)" ] || { + log_rate_limited daemon.err profile_health_settings_empty "AdGuard DNS up profile requires health settings" + return 1 + } + fi +} + +apply_profile_locked() ( + profile="$1" + dns_info= + body= + upstreams= + down_snapshot= + cleanup_profile_files() { + for cleanup_file in "$dns_info" "$body" "$upstreams" "$down_snapshot"; do + [ -z "$cleanup_file" ] || rm -f "$cleanup_file" + done + } + trap cleanup_profile_files 0 1 2 15 + dns_info="$(state_temp_file dns-info)" || exit 1 + body="$(state_temp_file dns-config)" || exit 1 + upstreams="$(state_temp_file upstreams)" || exit 1 + + profile_lines "$profile" >"$upstreams" || { + log_rate_limited daemon.err "profile_generate_$profile" "failed to generate AdGuard DNS $profile profile" + return 1 + } + if [ "$profile" = "down" ] && profile_is_empty "$upstreams" && \ + [ "$(cat "$STATE_DIR/current_profile" 2>/dev/null || true)" = "up" ] && \ + [ -s "$STATE_DIR/down_profile" ]; then + cat "$STATE_DIR/down_profile" >"$upstreams" || return 1 + fi + validate_profile_file "$profile" "$upstreams" || return 1 + if [ "$profile" = "up" ]; then + down_snapshot="$(state_temp_file down-profile)" || return 1 + profile_lines down >"$down_snapshot" || return 1 + validate_profile_file down "$down_snapshot" || return 1 + cat "$down_snapshot" | write_state_atomic "$STATE_DIR/down_profile" || return 1 + fi + + curl_get "/control/dns_info" >"$dns_info" || { + log_rate_limited daemon.err profile_read "failed to read AdGuard DNS config" + return 1 + } + + json_replace_upstreams "$dns_info" "$upstreams" >"$body" || { + log_rate_limited daemon.err "profile_build_$profile" "failed to build AdGuard DNS config payload" + return 1 + } + + curl_post_json "/control/dns_config" "$body" >/dev/null || { + log_rate_limited daemon.err "profile_write_$profile" "failed to write AdGuard DNS $profile profile" + return 1 + } + + curl_post_json "/control/cache_clear" /dev/null >/dev/null 2>&1 || \ + log_rate_limited daemon.warn "profile_cache_clear_$profile" "AdGuard cache clear failed after $profile profile switch" + + printf '%s\n' "$profile" | write_state_atomic "$STATE_DIR/current_profile" || return 1 + log_msg daemon.info "applied AdGuard DNS $profile profile" +) + +apply_profile() { + profile="$1" + + case "$profile" in + up|down) ;; + *) + echo "unknown profile: $profile" >&2 + return 2 + ;; + esac + + ensure_state_dir || return 1 + exec 9>"$STATE_DIR/profile.lock" || return 1 + "$FLOCK_CMD" -x 9 || return 1 + apply_profile_locked "$profile" +} + +lan_ip() { + ip="$("$UBUS_CMD" call network.interface.lan status 2>/dev/null | \ + sed -n 's/.*"address":"\([0-9.][0-9.]*\)".*/\1/p' | head -n 1)" + + if [ -n "$ip" ]; then + printf '%s\n' "$ip" + return 0 + fi + + uci_get_raw network.lan.ipaddr +} + +dhcp_options() { + options="$(uci_get_raw dhcp.lan.dhcp_option)" + if [ -n "$options" ]; then + printf '%s\n' "$options" + else + "$UCI_CMD" show dhcp.lan.dhcp_option 2>/dev/null | \ + sed -n "s/^dhcp\\.lan\\.dhcp_option='\\(.*\\)'$/\\1/p" + fi +} + +check_dhcp_option() { + ip="$(lan_ip)" + [ -n "$ip" ] || return 1 + + for option in $(dhcp_options); do + [ "$option" = "6,$ip" ] && return 0 + done + + return 1 +} + +check_port_53_adguard() { + "$NETSTAT_CMD" -lnup 2>/dev/null | \ + grep -E '[:.]53[[:space:]]' | \ + grep -F 'AdGuardHome' >/dev/null +} + +preflight() { + ready=pass + + for item in adguard_process port_53_adguard dhcp_advertises_lan_dns adguard_api health_check; do + case "$item" in + adguard_process) "$PGREP_CMD" -f AdGuardHome >/dev/null 2>&1 ;; + port_53_adguard) check_port_53_adguard ;; + dhcp_advertises_lan_dns) check_dhcp_option ;; + adguard_api) check_adguard_api ;; + health_check) check_health ;; + esac + + if [ "$?" -eq 0 ]; then + printf '%s=pass\n' "$item" + else + printf '%s=fail\n' "$item" + ready=fail + fi + done + + printf 'ready=%s\n' "$ready" + [ "$ready" = "pass" ] +} + +startup_preflight() { + failures= + + for item in adguard_process port_53_adguard dhcp_advertises_lan_dns adguard_api; do + case "$item" in + adguard_process) "$PGREP_CMD" -f AdGuardHome >/dev/null 2>&1 ;; + port_53_adguard) check_port_53_adguard ;; + dhcp_advertises_lan_dns) check_dhcp_option ;; + adguard_api) check_adguard_api ;; + esac + + if [ "$?" -ne 0 ]; then + failures="${failures:+$failures,}$item" + fi + done + + [ -z "$failures" ] || { + printf '%s\n' "$failures" + return 1 + } +} + +valid_positive_number() { + case "$1" in + ''|*[!0-9]*) return 1 ;; + 0) return 1 ;; + *) return 0 ;; + esac +} + +run_loop() { + enabled || { + log_msg daemon.info "AdGuard DNS auto switch disabled" + return 0 + } + + interval="$(uci_get adguard_check_interval)" + success_threshold="$(uci_get adguard_success_threshold)" + failure_threshold="$(uci_get adguard_failure_threshold)" + valid_positive_number "$interval" || interval=10 + valid_positive_number "$success_threshold" || success_threshold=2 + valid_positive_number "$failure_threshold" || failure_threshold=2 + + ensure_state_dir || return 1 + success_count=0 + failure_count=0 + + while :; do + if static_failures="$(startup_preflight)"; then + break + fi + + log_rate_limited daemon.err "static_preflight_$static_failures" "AdGuard DNS static preflight failed: $static_failures" + "$SLEEP_CMD" "$interval" + done + + while :; do + current="$(cat "$STATE_DIR/current_profile" 2>/dev/null || true)" + desired="$current" + + if check_health; then + success_count=$((success_count + 1)) + failure_count=0 + if [ "$success_count" -ge "$success_threshold" ]; then + desired=up + fi + else + failure_count=$((failure_count + 1)) + success_count=0 + if [ "$failure_count" -ge "$failure_threshold" ]; then + desired=down + fi + fi + + if [ -n "$desired" ] && [ "$desired" != "$current" ]; then + apply_profile "$desired" || true + fi + + "$SLEEP_CMD" "$interval" + done +} + +case "${1:-}" in + --profile) + profile_lines "${2:-}" + ;; + --check-health) + check_health + ;; + --preflight) + preflight + ;; + --apply-profile) + apply_profile "${2:-}" + ;; + --run) + run_loop + ;; + *) + echo "usage: $0 --profile up|down | --check-health | --preflight | --apply-profile up|down | --run" >&2 + exit 2 + ;; +esac diff --git a/luci-app-tailscale/root/usr/sbin/tailscale_helper b/luci-app-tailscale/root/usr/sbin/tailscale_helper new file mode 100755 index 000000000..08c9240f0 --- /dev/null +++ b/luci-app-tailscale/root/usr/sbin/tailscale_helper @@ -0,0 +1,523 @@ +#!/bin/sh + +# Copyright (C) 2025 asvow +# SPDX-License-Identifier: GPL-3.0-only + +TAILSCALE_BIN="${TAILSCALE_BIN:-/usr/sbin/tailscale}" +LOGGER_CMD="${LOGGER_CMD:-logger}" +FLOCK_BIN="${FLOCK_BIN:-flock}" +IFCONFIG_BIN="${IFCONFIG_BIN:-ifconfig}" +FIREWALL_INIT="${FIREWALL_INIT:-/etc/init.d/firewall}" +DNSMASQ_INIT="${DNSMASQ_INIT:-/etc/init.d/dnsmasq}" +TAILSCALE_SECRETS_BIN="${TAILSCALE_SECRETS_BIN:-/usr/sbin/tailscale_secrets}" +TAILSCALE_HELPER_STATE_DIR="${TAILSCALE_HELPER_STATE_DIR:-/var/lib/tailscale}" +EXIT_NODE_FIREWALL_STATE_FILE="$TAILSCALE_HELPER_STATE_DIR/exit_node_firewall_state" +FIREWALL_PENDING_STATE_FILE="${FIREWALL_PENDING_STATE_FILE:-/var/run/tailscale-firewall-pending}" +LOCK_FILE="${LOCK_FILE:-/var/lock/tailscale.lock}" +TAILSCALE_PORT="${TAILSCALE_PORT:-41641}" +TAILSCALE_STATE_WAS_PRESENT="${TAILSCALE_STATE_WAS_PRESENT:-0}" +ALLOW_WAN_DIRECT="${ALLOW_WAN_DIRECT:-0}" +WAN_DIRECT_ZONES="${WAN_DIRECT_ZONES:-wan}" +POLICY_ROUTING_HELPER="${POLICY_ROUTING_HELPER:-/usr/sbin/tailscale_policy_routing}" + +log_error() { + "$LOGGER_CMD" -p daemon.err -t tailscale_helper "$(date '+%Y/%m/%d %H:%M:%S') $1" +} + +# Error handling function +revert_exit() { + log_error "$1" + uci revert dhcp && uci revert network && uci revert firewall + exit 1 +} + +find_lan_wan_forwarding() { + uci show firewall | awk -F= ' + $1 ~ /^firewall\.@forwarding\[[0-9]+\]$/ && $2 == "forwarding" { + section=$1 + next + } + $1 ~ /^firewall\.@forwarding\[[0-9]+\]\.src$/ { + value=$2 + gsub(/\047/, "", value) + if (section != "" && value == "lan") { + src[section]=1 + } + next + } + $1 ~ /^firewall\.@forwarding\[[0-9]+\]\.dest$/ { + value=$2 + gsub(/\047/, "", value) + if (section != "" && value == "wan") { + dest[section]=1 + } + } + END { + for (section in src) { + if (dest[section]) { + print section + exit + } + } + } + ' +} + +valid_port() { + case "$1" in + ''|*[!0-9]*) return 1 ;; + *) [ "$1" -ge 1 ] && [ "$1" -le 65535 ] ;; + esac +} + +normalize_uci_id() { + printf '%s' "$1" | sed 's/[^A-Za-z0-9_]/_/g' +} + +firewall_section_has_only_options() { + local section="$1" + local allowed="$2" + local output option + + output="$(uci -q show "firewall.$section" 2>/dev/null)" || return 1 + for option in $(printf '%s\n' "$output" | sed -n "s/^firewall\\.$section\\.\\([^.=]*\\)=.*/\\1/p"); do + case " $allowed " in + *" $option "*) ;; + *) return 1 ;; + esac + done +} + +delete_named_firewall_sections() { + local output section + + output="$(uci -q show firewall 2>/dev/null)" || return 1 + for section in "$@"; do + if printf '%s\n' "$output" | grep -q "^firewall\\.$section="; then + uci -q delete "firewall.$section" || return 1 + fi + done +} + +delete_wan_direct_rules() { + local output section + + output="$(uci -q show firewall 2>/dev/null)" || return 1 + if printf '%s\n' "$output" | grep -q '^firewall\.ts_wan_direct='; then + uci -q delete firewall.ts_wan_direct || return 1 + fi + for section in $(printf '%s\n' "$output" | sed -n 's/^firewall\.\(ts_wan_direct_[^.=]*\)=[^=]*$/\1/p'); do + uci -q delete "firewall.$section" || return 1 + done +} + +wan_direct_rule_matches() { + local section="$1" + local port="$2" + local zone="$3" + + [ "$(uci -q get "firewall.$section" 2>/dev/null)" = "rule" ] && + [ "$(uci -q get "firewall.$section.name" 2>/dev/null)" = "Allow-Tailscale-WAN-$port-$zone" ] && + [ "$(uci -q get "firewall.$section.src" 2>/dev/null)" = "$zone" ] && + [ "$(uci -q get "firewall.$section.proto" 2>/dev/null)" = "udp" ] && + [ "$(uci -q get "firewall.$section.dest_port" 2>/dev/null)" = "$port" ] && + [ "$(uci -q get "firewall.$section.target" 2>/dev/null)" = "ACCEPT" ] && + firewall_section_has_only_options "$section" "name src proto dest_port target" +} + +wan_direct_rules_match() { + local port="$1" + local output zones zone zone_id section index seen current_count + + output="$(uci -q show firewall 2>/dev/null)" || return 1 + printf '%s\n' "$output" | grep -q '^firewall\.ts_wan_direct=' && return 1 + + current_count=0 + for section in $(printf '%s\n' "$output" | sed -n 's/^firewall\.\(ts_wan_direct_[^.=]*\)=[^=]*$/\1/p'); do + current_count=$((current_count + 1)) + done + + if [ "$ALLOW_WAN_DIRECT" != "1" ]; then + [ "$current_count" -eq 0 ] + return $? + fi + + zones="${WAN_DIRECT_ZONES:-wan}" + index=0 + seen=" " + for zone in $zones; do + [ -n "$zone" ] || continue + case "$seen" in + *" $zone "*) continue ;; + esac + seen="${seen}${zone} " + index=$((index + 1)) + zone_id="$(normalize_uci_id "$zone")" + [ -n "$zone_id" ] || zone_id="zone" + section="ts_wan_direct_${index}_${zone_id}" + wan_direct_rule_matches "$section" "$port" "$zone" || return 1 + done + + [ "$index" -gt 0 ] && [ "$current_count" -eq "$index" ] +} + +configure_wan_direct_rule() { + local port zones zone zone_id section index seen + + port="$TAILSCALE_PORT" + valid_port "$port" || port=41641 + wan_direct_rules_match "$port" && return 0 + + delete_wan_direct_rules || return 1 + + [ "$ALLOW_WAN_DIRECT" = "1" ] || return 0 + + zones="${WAN_DIRECT_ZONES:-wan}" + index=0 + seen=" " + for zone in $zones; do + [ -n "$zone" ] || continue + case "$seen" in + *" $zone "*) continue ;; + esac + seen="${seen}${zone} " + index=$((index + 1)) + zone_id="$(normalize_uci_id "$zone")" + [ -n "$zone_id" ] || zone_id="zone" + section="ts_wan_direct_${index}_${zone_id}" + + uci set "firewall.$section=rule" || return 1 + uci set "firewall.$section.name=Allow-Tailscale-WAN-$port-$zone" || return 1 + uci set "firewall.$section.src=$zone" || return 1 + uci set "firewall.$section.proto=udp" || return 1 + uci set "firewall.$section.dest_port=$port" || return 1 + uci set "firewall.$section.target=ACCEPT" || return 1 + done + + [ "$index" -gt 0 ] || return 1 +} + +access_has() { + case " $ACCESS " in + *" $1 "*) return 0 ;; + *) return 1 ;; + esac +} + +tailscale_zone_matches() { + local masq="$1" + + [ "$(uci -q get firewall.tszone 2>/dev/null)" = "zone" ] && + [ "$(uci -q get firewall.tszone.input 2>/dev/null)" = "ACCEPT" ] && + [ "$(uci -q get firewall.tszone.output 2>/dev/null)" = "ACCEPT" ] && + [ "$(uci -q get firewall.tszone.forward 2>/dev/null)" = "ACCEPT" ] && + [ "$(uci -q get firewall.tszone.masq 2>/dev/null)" = "$masq" ] && + [ "$(uci -q get firewall.tszone.mtu_fix 2>/dev/null)" = "1" ] && + [ "$(uci -q get firewall.tszone.name 2>/dev/null)" = "tailscale" ] && + [ "$(uci -q get firewall.tszone.device 2>/dev/null)" = "tailscale0" ] && + firewall_section_has_only_options tszone "input output forward masq mtu_fix name device" +} + +forwarding_rule_matches() { + local section="$1" + local src="$2" + local dest="$3" + + [ "$(uci -q get "firewall.$section" 2>/dev/null)" = "forwarding" ] && + [ "$(uci -q get "firewall.$section.src" 2>/dev/null)" = "$src" ] && + [ "$(uci -q get "firewall.$section.dest" 2>/dev/null)" = "$dest" ] && + firewall_section_has_only_options "$section" "src dest" +} + +tailscale_access_rules_match() { + local masq="$1" + local output section token src dest zone_required=0 + + output="$(uci -q show firewall 2>/dev/null)" || return 1 + [ -n "$ACCESS" ] && zone_required=1 + [ "$DISABLE_SNAT_SUBNET_ROUTES" = "1" ] && zone_required=1 + + if [ "$zone_required" = 0 ]; then + for section in tszone ts_ac_lan ts_ac_wan lan_ac_ts wan_ac_ts; do + printf '%s\n' "$output" | grep -q "^firewall\\.$section=" && return 1 + done + return 0 + fi + + tailscale_zone_matches "$masq" || return 1 + for token in ts_ac_lan ts_ac_wan lan_ac_ts wan_ac_ts; do + case "$token" in + ts_ac_lan) src=tailscale; dest=lan ;; + ts_ac_wan) src=tailscale; dest=wan ;; + lan_ac_ts) src=lan; dest=tailscale ;; + wan_ac_ts) src=wan; dest=tailscale ;; + esac + if access_has "$token"; then + forwarding_rule_matches "$token" "$src" "$dest" || return 1 + elif printf '%s\n' "$output" | grep -q "^firewall\\.$token="; then + return 1 + fi + done +} + +configure_tailscale_access_rules() { + local masq=1 token src dest + + [ "$DISABLE_SNAT_SUBNET_ROUTES" = "1" ] && masq=0 + tailscale_access_rules_match "$masq" && return 0 + + delete_named_firewall_sections tszone ts_ac_lan ts_ac_wan lan_ac_ts wan_ac_ts || return 1 + [ -n "$ACCESS" ] || [ "$DISABLE_SNAT_SUBNET_ROUTES" = "1" ] || return 0 + + uci set firewall.tszone='zone' || return 1 + uci set firewall.tszone.input='ACCEPT' || return 1 + uci set firewall.tszone.output='ACCEPT' || return 1 + uci set firewall.tszone.forward='ACCEPT' || return 1 + uci set "firewall.tszone.masq=$masq" || return 1 + uci set firewall.tszone.mtu_fix='1' || return 1 + uci set firewall.tszone.name='tailscale' || return 1 + uci add_list firewall.tszone.device='tailscale0' || return 1 + + for token in ts_ac_lan ts_ac_wan lan_ac_ts wan_ac_ts; do + access_has "$token" || continue + case "$token" in + ts_ac_lan) src=tailscale; dest=lan ;; + ts_ac_wan) src=tailscale; dest=wan ;; + lan_ac_ts) src=lan; dest=tailscale ;; + wan_ac_ts) src=wan; dest=tailscale ;; + esac + uci set "firewall.$token=forwarding" || return 1 + uci set "firewall.$token.src=$src" || return 1 + uci set "firewall.$token.dest=$dest" || return 1 + done +} + +apply_firewall_changes() { + local changes pending_dir had_pending=0 + + changes="$(uci changes firewall 2>/dev/null)" || return 1 + [ -e "$FIREWALL_PENDING_STATE_FILE" ] && had_pending=1 + if [ -n "$changes" ]; then + pending_dir="${FIREWALL_PENDING_STATE_FILE%/*}" + [ "$pending_dir" = "$FIREWALL_PENDING_STATE_FILE" ] && pending_dir=. + mkdir -p "$pending_dir" || return 1 + : >"$FIREWALL_PENDING_STATE_FILE" || return 1 + if ! uci commit firewall; then + [ "$had_pending" -eq 1 ] || rm -f "$FIREWALL_PENDING_STATE_FILE" + return 1 + fi + fi + + [ -e "$FIREWALL_PENDING_STATE_FILE" ] || return 0 + if "$FIREWALL_INIT" reload || "$FIREWALL_INIT" restart; then + rm -f "$FIREWALL_PENDING_STATE_FILE" || return 1 + return 0 + fi + return 1 +} + +save_exit_node_firewall_state() { + local default_forward lan_wan_section lan_wan_enabled state_tmp + + [ -f "$EXIT_NODE_FIREWALL_STATE_FILE" ] && return 0 + mkdir -p "$TAILSCALE_HELPER_STATE_DIR" || return 1 + state_tmp="${EXIT_NODE_FIREWALL_STATE_FILE}.tmp" + : >"$state_tmp" || return 1 + + if default_forward="$(uci get firewall.@defaults[0].forward 2>/dev/null)"; then + printf 'default_forward_present=1\n' >>"$state_tmp" + printf 'default_forward_value=%s\n' "$default_forward" >>"$state_tmp" + else + printf 'default_forward_present=0\n' >>"$state_tmp" + fi + + lan_wan_section="$(find_lan_wan_forwarding)" + if [ -n "$lan_wan_section" ]; then + printf 'lan_wan_forwarding_present=1\n' >>"$state_tmp" + if lan_wan_enabled="$(uci get "$lan_wan_section.enabled" 2>/dev/null)"; then + printf 'lan_wan_enabled_present=1\n' >>"$state_tmp" + printf 'lan_wan_enabled_value=%s\n' "$lan_wan_enabled" >>"$state_tmp" + else + printf 'lan_wan_enabled_present=0\n' >>"$state_tmp" + fi + else + printf 'lan_wan_forwarding_present=0\n' >>"$state_tmp" + fi + + mv "$state_tmp" "$EXIT_NODE_FIREWALL_STATE_FILE" +} + +restore_exit_node_firewall_state() { + local default_forward_value default_forward_present lan_wan_enabled_present lan_wan_enabled_value + local lan_wan_section current_value + + [ -r "$EXIT_NODE_FIREWALL_STATE_FILE" ] || return 0 + + default_forward_value= + default_forward_present=0 + lan_wan_enabled_present=0 + lan_wan_enabled_value= + lan_wan_forwarding_present=0 + . "$EXIT_NODE_FIREWALL_STATE_FILE" + + if [ "${default_forward_present:-0}" = "1" ]; then + current_value="$(uci get firewall.@defaults[0].forward 2>/dev/null || true)" + if [ "$current_value" != "${default_forward_value:-}" ]; then + uci set "firewall.@defaults[0].forward=${default_forward_value}" || return 1 + fi + elif uci get firewall.@defaults[0].forward >/dev/null 2>&1; then + uci -q delete firewall.@defaults[0].forward || return 1 + fi + + lan_wan_section="$(find_lan_wan_forwarding)" + if [ -n "$lan_wan_section" ] && [ "${lan_wan_forwarding_present:-0}" = "1" ]; then + if [ "${lan_wan_enabled_present:-0}" = "1" ]; then + current_value="$(uci get "$lan_wan_section.enabled" 2>/dev/null || true)" + if [ "$current_value" != "${lan_wan_enabled_value:-}" ]; then + uci set "$lan_wan_section.enabled=${lan_wan_enabled_value}" || return 1 + fi + elif uci get "$lan_wan_section.enabled" >/dev/null 2>&1; then + uci -q delete "$lan_wan_section.enabled" || return 1 + fi + fi + + # Always run the apply path so a previous commit with a pending reload is + # retried even when the restored values already match UCI. + apply_firewall_changes || return 1 + rm -f "$EXIT_NODE_FIREWALL_STATE_FILE" || return 1 +} + +cleanup_managed_firewall() { + restore_exit_node_firewall_state || return 1 + + ALLOW_WAN_DIRECT=0 + configure_wan_direct_rule || return 1 + + ACCESS= + DISABLE_SNAT_SUBNET_ROUTES=0 + configure_tailscale_access_rules || return 1 + + apply_firewall_changes +} + +case "${1:-}" in + --run) + shift + ;; + --cleanup-exit-node-firewall) + restore_exit_node_firewall_state || { + log_error "Failed to restore exit-node firewall state." + exit 1 + } + exit 0 + ;; + --cleanup-wan-direct-firewall) + ALLOW_WAN_DIRECT=0 + configure_wan_direct_rule || { + log_error "Failed to remove Tailscale WAN direct firewall rules." + exit 1 + } + apply_firewall_changes || { + log_error "Failed to apply Tailscale WAN direct firewall cleanup." + exit 1 + } + exit 0 + ;; + --cleanup-managed-firewall) + cleanup_managed_firewall || { + log_error "Failed to remove Tailscale managed firewall state." + exit 1 + } + exit 0 + ;; + *) + log_error "Unsupported tailscale_helper mode: ${1:-}" + exit 2 + ;; +esac + +# Use an auth key only for first enrollment or an explicit replacement request. +# Legacy stored keys are ignored when an existing tailscaled identity is present. +authkey= +if [ "$TAILSCALE_STATE_WAS_PRESENT" != "1" ] || \ + "$TAILSCALE_SECRETS_BIN" authkey-pending >/dev/null 2>&1; then + authkey="$("$TAILSCALE_SECRETS_BIN" get authkey 2>/dev/null || true)" +fi +if [ -n "$authkey" ]; then + "$TAILSCALE_BIN" up --reset --authkey="$authkey" "$@" || revert_exit "tailscale up failed." +else + "$TAILSCALE_BIN" up --reset "$@" || revert_exit "tailscale up failed." +fi +unset authkey + +# Use flock to acquire an exclusive lock +exec 9> "$LOCK_FILE" +"$FLOCK_BIN" -xn 9 || { revert_exit "Failed to acquire lock on $LOCK_FILE"; } +trap 'rm -f "$LOCK_FILE"; exit' INT TERM EXIT + +# Wait for Tailscale IPv4 address +count=0 +while [ -z "$("$IFCONFIG_BIN" | grep 'tailscale' | awk '{print $1}')" ] || [ -z "$("$TAILSCALE_BIN" ip -4)" ]; do + sleep 2 + count=$((count + 1)) + [ "${count}" -ge 5 ] && revert_exit "Failed to get Tailscale IPv4 address after 5 attempts." +done + +# Registration keys are one-time inputs. Purge every stored credential version +# only after the connection has a Tailscale address, so failed enrollment keeps +# the key available for correction and retry. +if "$TAILSCALE_SECRETS_BIN" has authkey >/dev/null 2>&1; then + "$TAILSCALE_SECRETS_BIN" clear-authkey || revert_exit "Failed to clear the used Tailscale auth key." +fi + +# Configure Tailscale MagicDNS +if [ "$ACCEPT_DNS" = "1" ]; then + MagicDNSSuffix=$("$TAILSCALE_BIN" status --json | awk -F'"' '/"MagicDNSSuffix"/ {last=$(NF-1)} END {print last}') + if [ -n "$MagicDNSSuffix" ]; then + target_address="/$MagicDNSSuffix/100.100.100.100" + index=$(uci show dhcp | sed -n 's/^dhcp\.@dnsmasq\[\([0-9][0-9]*\)\]=dnsmasq$/\1/p') + for i in $index; do + if ! uci -q get "dhcp.@dnsmasq[$i].address" 2>/dev/null | tr ' ' '\n' | grep -qxF "$target_address"; then + uci add_list "dhcp.@dnsmasq[$i].address=$target_address" || revert_exit "Failed to add DNS address." + fi + done + fi +fi + +# Remove legacy LuCI network wrappers for tailscale0. tailscaled owns the TUN +# device, policy rules, and table 52 routes; netifd must not manage it. +uci -q delete network.tailscale +uci -q delete network.ts_lan +for route in $(uci show network | grep 'network.ts_subnet[0-9]\+=route' | grep -o 'network.ts_subnet[0-9]\+'); do + uci -q delete "$route" +done + +# Configure exit node firewall rules +if [ -n "$EXIT_NODE" ]; then + save_exit_node_firewall_state || revert_exit "Failed to save exit-node firewall state." + uci set firewall.@defaults[0].forward='REJECT' || revert_exit "Failed to set default forward policy to REJECT." + lan_wan_section="$(find_lan_wan_forwarding)" + [ -n "$lan_wan_section" ] && uci set "$lan_wan_section.enabled=0" || true +else + restore_exit_node_firewall_state || revert_exit "Failed to restore exit-node firewall state." +fi + +configure_wan_direct_rule || revert_exit "Failed to configure Tailscale WAN direct firewall rule." +configure_tailscale_access_rules || revert_exit "Failed to configure Tailscale firewall zone and forwarding rules." + +# Commit configuration changes and reload service +if [ -n "$(uci changes dhcp)" ]; then + uci commit dhcp || revert_exit "Failed to commit Tailscale DNS configuration." + "$DNSMASQ_INIT" reload || revert_exit "Failed to reload dnsmasq." +fi +if [ -n "$(uci changes network)" ]; then + uci commit network || revert_exit "Failed to commit Tailscale network cleanup." +fi +apply_firewall_changes || revert_exit "Failed to commit or apply Tailscale firewall configuration." + +# The policy-routing helper owns only its dedicated network UCI rule and the +# matching runtime rule. Its failure must not disrupt an otherwise healthy node. +if [ -x "$POLICY_ROUTING_HELPER" ] && ! "$POLICY_ROUTING_HELPER" sync; then + log_error "Failed to synchronize Tailscale/mwan3 policy routing precedence." +fi +exit 0 diff --git a/luci-app-tailscale/root/usr/sbin/tailscale_keepalive b/luci-app-tailscale/root/usr/sbin/tailscale_keepalive new file mode 100755 index 000000000..3cefdd5a2 --- /dev/null +++ b/luci-app-tailscale/root/usr/sbin/tailscale_keepalive @@ -0,0 +1,286 @@ +#!/bin/sh + +# Copyright (C) 2026 jearton +# SPDX-License-Identifier: GPL-3.0-only + +TAILSCALE_BIN="${TAILSCALE_BIN:-/usr/sbin/tailscale}" +LOGGER_CMD="${LOGGER_CMD:-logger}" +JSHN_LIB="${JSHN_LIB:-/usr/share/libubox/jshn.sh}" + +log_msg() { + local priority="$1" + local message="$2" + + "$LOGGER_CMD" -p "$priority" -t tailscale_keepalive "$message" +} + +state_key() { + printf '%s' "$1" | sed 's/[^A-Za-z0-9_.-]/_/g' +} + +log_rate_limited_error() { + local key="$1" + local message="$2" + local interval="$3" + local now="$4" + local last_file="$TMP_STATE_DIR/$key.last_error" + local last_seen=0 + + [ -r "$last_file" ] && last_seen="$(cat "$last_file" 2>/dev/null || echo 0)" + if [ $((now - last_seen)) -ge "$interval" ]; then + log_msg daemon.err "$message" + printf '%s\n' "$now" >"$last_file" + fi +} + +read_status() { + if [ -n "${TAILSCALE_STATUS_FILE:-}" ] && [ -r "$TAILSCALE_STATUS_FILE" ]; then + cat "$TAILSCALE_STATUS_FILE" + else + "$TAILSCALE_BIN" status --json + fi +} + +normalize_dns() { + printf '%s' "$1" | sed 's/\.$//' +} + +short_dns_name() { + normalize_dns "$1" | sed 's/\..*$//' +} + +peer_matches() { + local peer="$1" + local host="$2" + local dns="$3" + local matched_ip="$4" + local normalized_dns short_dns + + normalized_dns="$(normalize_dns "$dns")" + short_dns="$(short_dns_name "$dns")" + + [ "$host" = "$peer" ] || \ + [ "$dns" = "$peer" ] || \ + [ "$normalized_dns" = "$peer" ] || \ + [ "$short_dns" = "$peer" ] || \ + [ "$matched_ip" = "$peer" ] +} + +emit_resolved_peer() { + local peer="$1" + local host="$2" + local dns="$3" + local ip="$4" + local matched_ip="$5" + + [ -n "$host" ] || [ -n "$dns" ] || [ -n "$matched_ip" ] || return 1 + peer_matches "$peer" "$host" "$dns" "$matched_ip" || return 1 + + if [ -n "$matched_ip" ]; then + printf 'OK\t%s\n' "$matched_ip" + elif [ -n "$ip" ]; then + printf 'OK\t%s\n' "$ip" + else + printf 'NO_IP\t\n' + fi + return 0 +} + +resolve_peer_with_jshn() { + local peer="$1" + local status keys key host dns ips ip matched_ip value + + [ -r "$JSHN_LIB" ] || return 1 + status="$(read_status)" || return 1 + + # OpenWrt ships jshn.sh in the jshn package; it parses JSON independently of + # whitespace and field order. + . "$JSHN_LIB" || return 1 + + json_cleanup + json_load "$status" || { + json_cleanup + return 1 + } + + json_select Peer || { + json_cleanup + return 1 + } + + json_get_keys keys + for key in $keys; do + json_select "$key" || continue + + host= + dns= + ips= + ip= + matched_ip= + json_get_var host HostName + json_get_var dns DNSName + + if json_select TailscaleIPs; then + json_get_values ips + for value in $ips; do + [ -n "$ip" ] || ip="$value" + [ "$value" = "$peer" ] && matched_ip="$value" + done + json_select .. + fi + + if emit_resolved_peer "$peer" "$host" "$dns" "$ip" "$matched_ip"; then + json_cleanup + return 0 + fi + + json_select .. + done + + json_cleanup + printf 'NOT_FOUND\t\n' + return 0 +} + +resolve_peer_with_python() { + local peer="$1" + local status + + command -v python3 >/dev/null 2>&1 || return 1 + status="$(read_status)" || return 1 + + STATUS_JSON="$status" python3 - "$peer" <<'PY' +import json +import os +import sys + +peer = sys.argv[1] + +try: + status = json.loads(os.environ["STATUS_JSON"]) +except Exception: + sys.exit(1) + + +def norm_dns(value): + return value[:-1] if value.endswith(".") else value + + +def short_dns(value): + return norm_dns(value).split(".", 1)[0] + + +for item in status.get("Peer", {}).values(): + host = item.get("HostName", "") + dns = item.get("DNSName", "") + ips = item.get("TailscaleIPs") or [] + matched_ip = next((value for value in ips if value == peer), "") + + if peer in (host, dns, norm_dns(dns), short_dns(dns)) or matched_ip: + if matched_ip: + print("OK\t%s" % matched_ip) + elif ips: + print("OK\t%s" % ips[0]) + else: + print("NO_IP\t") + sys.exit(0) + +print("NOT_FOUND\t") +PY +} + +resolve_peer_from_status() { + local peer="$1" + + resolve_peer_with_jshn "$peer" && return 0 + resolve_peer_with_python "$peer" && return 0 + + printf 'ERROR\tno JSON parser available\n' +} + +resolve_peers() { + local peer result + + for peer in "$@"; do + result="$(resolve_peer_from_status "$peer")" + printf '%s\t%s\n' "$peer" "$result" + done +} + +run_once() { + local failure_log_interval="$1" + shift + local now peer result status ip peer_key last_success + + now="$(date +%s)" + for peer in "$@"; do + peer_key="$(state_key "$peer")" + result="$(resolve_peer_from_status "$peer")" + status="${result%% *}" + ip="${result#* }" + + case "$status" in + OK) + if "$TAILSCALE_BIN" ping --timeout=5s --c=1 "$ip" >/dev/null 2>&1; then + last_success="$TMP_STATE_DIR/$peer_key.success" + if [ ! -e "$last_success" ]; then + log_msg daemon.info "peer $peer resolved to $ip and is reachable" + fi + : >"$last_success" + rm -f "$TMP_STATE_DIR/$peer_key.last_error" + else + log_rate_limited_error "$peer_key" "peer $peer resolved to $ip but tailscale ping failed" "$failure_log_interval" "$now" + rm -f "$TMP_STATE_DIR/$peer_key.success" + fi + ;; + NO_IP) + log_rate_limited_error "$peer_key" "peer $peer has no Tailscale IP" "$failure_log_interval" "$now" + rm -f "$TMP_STATE_DIR/$peer_key.success" + ;; + ERROR) + log_rate_limited_error "$peer_key" "peer $peer resolution failed: $ip" "$failure_log_interval" "$now" + rm -f "$TMP_STATE_DIR/$peer_key.success" + ;; + *) + log_rate_limited_error "$peer_key" "peer $peer not found in tailscale status" "$failure_log_interval" "$now" + rm -f "$TMP_STATE_DIR/$peer_key.success" + ;; + esac + done +} + +run_loop() { + local interval="$1" + local failure_log_interval="$2" + shift 2 + + [ "$interval" -gt 0 ] 2>/dev/null || interval=20 + [ "$failure_log_interval" -gt 0 ] 2>/dev/null || failure_log_interval=300 + [ "$#" -gt 0 ] || { + log_msg daemon.err "keepalive enabled but no peers configured" + exit 1 + } + + TMP_STATE_DIR="/var/run/tailscale_keepalive" + mkdir -p "$TMP_STATE_DIR" + + while true; do + run_once "$failure_log_interval" "$@" + sleep "$interval" + done +} + +case "$1" in + --resolve-peers) + shift + resolve_peers "$@" + ;; + --run) + shift + run_loop "$@" + ;; + *) + echo "Usage: $0 --resolve-peers ... | --run ..." >&2 + exit 2 + ;; +esac diff --git a/luci-app-tailscale/root/usr/sbin/tailscale_openclash_bypass b/luci-app-tailscale/root/usr/sbin/tailscale_openclash_bypass new file mode 100755 index 000000000..bf8de62a3 --- /dev/null +++ b/luci-app-tailscale/root/usr/sbin/tailscale_openclash_bypass @@ -0,0 +1,1077 @@ +#!/bin/sh +set -u + +OPENCLASH_INIT="${OPENCLASH_INIT:-/etc/init.d/openclash}" +OPENCLASH_HOOK_FILE="${OPENCLASH_HOOK_FILE:-/etc/openclash/custom/openclash_custom_firewall_rules.sh}" +LOCK_FILE="${LOCK_FILE:-/var/lock/tailscale-openclash-bypass.lock}" +UCI_BIN="${UCI_BIN:-uci}" +NFT_BIN="${NFT_BIN:-nft}" +JQ_BIN="${JQ_BIN:-jq}" +LOGGER_CMD="${LOGGER_CMD:-logger}" + +BEGIN_MARKER='# BEGIN luci-app-tailscale 托管:Tailscale 绕过 OpenClash' +END_MARKER='# END luci-app-tailscale 托管:Tailscale 绕过 OpenClash' +HOOK_TEMP_FILE= +NFT_TEMP_FILE= +NFT_CACHE_DIR= +NFT_GUARD_NAME= + +cleanup_tracked_temps() { + [ -z "$HOOK_TEMP_FILE" ] || rm -f "$HOOK_TEMP_FILE" + [ -z "$NFT_TEMP_FILE" ] || rm -f "$NFT_TEMP_FILE" + [ -z "$NFT_CACHE_DIR" ] || rm -rf "$NFT_CACHE_DIR" + [ -z "$NFT_GUARD_NAME" ] || \ + "$NFT_BIN" delete counter inet fw4 "$NFT_GUARD_NAME" >/dev/null 2>&1 || true +} + +trap cleanup_tracked_temps EXIT +trap 'exit 1' HUP INT TERM + +managed_block() { + cat <<'EOF' +# BEGIN luci-app-tailscale 托管:Tailscale 绕过 OpenClash +if test -x /usr/sbin/tailscale_openclash_bypass; then + /usr/sbin/tailscale_openclash_bypass apply +fi +# END luci-app-tailscale 托管:Tailscale 绕过 OpenClash +EOF +} + +openclash_installed() { test -x "$OPENCLASH_INIT"; } +log_error() { "$LOGGER_CMD" -p daemon.err -t tailscale_openclash_bypass "$*"; } + +marker_offsets() { + file="$1" + [ ! -e "$file" ] && { printf 'absent\n'; return 0; } + [ -f "$file" ] || return 1 + LC_ALL=C awk -v begin_marker="$BEGIN_MARKER" -v end_marker="$END_MARKER" ' + BEGIN { + offset = 0 + begin_count = 0 + end_count = 0 + } + { + if ($0 == begin_marker) { + begin_count++ + begin_offset = offset + } + if ($0 == end_marker) { + end_count++ + end_offset = offset + } + offset += length($0) + 1 + } + END { + if (begin_count == 0 && end_count == 0) { + print "absent" + exit 0 + } + if (begin_count != 1 || end_count != 1 || begin_offset >= end_offset) + exit 1 + printf "present|%d|%d\n", begin_offset, end_offset + } + ' "$file" +} + +marker_state() { + offsets="$(marker_offsets "$1")" || return 1 + printf '%s\n' "${offsets%%|*}" +} + +canonical_hook_state() { + offsets="$(marker_offsets "$1")" || return 1 + state="${offsets%%|*}" + [ "$state" = present ] || { + printf '%s\n' "$state" + return 0 + } + rest="${offsets#*|}" + begin_offset="${rest%%|*}" + end_offset="${rest#*|}" + end_length="$(printf '%s' "$END_MARKER" | wc -c | tr -d '[:space:]')" + block_length=$((end_offset + end_length - begin_offset)) + actual_block="$(dd if="$1" bs=1 skip="$begin_offset" count="$block_length" 2>/dev/null)" || return 1 + expected_block="$(managed_block)" || return 1 + if [ "$actual_block" = "$expected_block" ]; then + printf 'managed\n' + else + printf 'stale\n' + fi +} + +file_mode() { + LC_ALL=C ls -ldn "$1" 2>/dev/null | awk ' + NR == 1 { + permissions = substr($1, 2, 9) + if (permissions !~ /^[r-][w-][xSs-][r-][w-][xSs-][r-][w-][xTt-]$/) + exit 1 + + user = (substr(permissions, 1, 1) == "r" ? 4 : 0) + \ + (substr(permissions, 2, 1) == "w" ? 2 : 0) + \ + (substr(permissions, 3, 1) ~ /[xs]/ ? 1 : 0) + group = (substr(permissions, 4, 1) == "r" ? 4 : 0) + \ + (substr(permissions, 5, 1) == "w" ? 2 : 0) + \ + (substr(permissions, 6, 1) ~ /[xs]/ ? 1 : 0) + other = (substr(permissions, 7, 1) == "r" ? 4 : 0) + \ + (substr(permissions, 8, 1) == "w" ? 2 : 0) + \ + (substr(permissions, 9, 1) ~ /[xt]/ ? 1 : 0) + special = (substr(permissions, 3, 1) ~ /[sS]/ ? 4 : 0) + \ + (substr(permissions, 6, 1) ~ /[sS]/ ? 2 : 0) + \ + (substr(permissions, 9, 1) ~ /[tT]/ ? 1 : 0) + + found = 1 + printf "%d%d%d%d\n", special, user, group, other + exit + } + END { if (!found) exit 1 } + ' +} + +file_owner() { + LC_ALL=C ls -ldn "$1" 2>/dev/null | awk ' + NR == 1 { + if ($3 !~ /^[0-9]+$/ || $4 !~ /^[0-9]+$/) + exit 1 + found = 1 + printf "%s:%s\n", $3, $4 + exit + } + END { if (!found) exit 1 } + ' +} + +byte_is_newline() { + newline=' +' + probe="$({ + dd if="$1" bs=1 skip="$2" count=1 2>/dev/null + printf '.%s' "$?" + })" + probe_status="${probe##*.}" + byte="${probe%.*}" + [ "$probe_status" = 0 ] || return 2 + [ "$byte" = "$newline" ] +} + +shebang_size() { + first_line="$(head -n 1 "$1")" + case "$first_line" in + '#!'*) ;; + *) return 1 ;; + esac + + size="$(head -n 1 "$1" | wc -c | tr -d '[:space:]')" + [ "$size" -gt 0 ] || return 1 + byte_is_newline "$1" $((size - 1)) || return 1 + printf '%s\n' "$size" +} + +new_hook_temp_file() { + directory="$(dirname "$OPENCLASH_HOOK_FILE")" + HOOK_TEMP_FILE="$(mktemp "$directory/.tailscale-openclash-hook.XXXXXX")" || return 1 +} + +discard_hook_temp() { + [ -z "$HOOK_TEMP_FILE" ] || rm -f "$HOOK_TEMP_FILE" + HOOK_TEMP_FILE= +} + +finish_replacement() { + mode="$1" + owner="$2" + + if ! sh -n "$HOOK_TEMP_FILE"; then + discard_hook_temp + return 1 + fi + if [ -n "$owner" ] && ! chown "$owner" "$HOOK_TEMP_FILE"; then + discard_hook_temp + return 1 + fi + if ! chmod "$mode" "$HOOK_TEMP_FILE"; then + discard_hook_temp + return 1 + fi + if ! mv -f "$HOOK_TEMP_FILE" "$OPENCLASH_HOOK_FILE"; then + discard_hook_temp + return 1 + fi + HOOK_TEMP_FILE= +} + +reconcile_hook() { + if [ ! -e "$OPENCLASH_HOOK_FILE" ]; then + mkdir -p "$(dirname "$OPENCLASH_HOOK_FILE")" || return 1 + new_hook_temp_file || return 1 + if ! printf '#!/bin/sh\n' >"$HOOK_TEMP_FILE"; then + discard_hook_temp + return 1 + fi + if ! managed_block >>"$HOOK_TEMP_FILE"; then + discard_hook_temp + return 1 + fi + finish_replacement 0755 '' + return $? + fi + + offsets="$(marker_offsets "$OPENCLASH_HOOK_FILE")" || return 1 + state="${offsets%%|*}" + case "$state" in + absent) + prefix_offset="$(shebang_size "$OPENCLASH_HOOK_FILE")" || return 1 + suffix_offset="$prefix_offset" + ;; + present) + rest="${offsets#*|}" + prefix_offset="${rest%%|*}" + end_offset="${rest#*|}" + end_length="$(printf '%s' "$END_MARKER" | wc -c | tr -d '[:space:]')" + suffix_offset=$((end_offset + end_length)) + byte_is_newline "$OPENCLASH_HOOK_FILE" "$suffix_offset" + byte_result=$? + case "$byte_result" in + 0) suffix_offset=$((suffix_offset + 1)) ;; + 1) ;; + *) return 1 ;; + esac + ;; + *) return 1 ;; + esac + + mode="$(file_mode "$OPENCLASH_HOOK_FILE")" || return 1 + owner="$(file_owner "$OPENCLASH_HOOK_FILE")" || return 1 + new_hook_temp_file || return 1 + + if ! dd if="$OPENCLASH_HOOK_FILE" bs=1 count="$prefix_offset" 2>/dev/null >"$HOOK_TEMP_FILE"; then + discard_hook_temp + return 1 + fi + if ! managed_block >>"$HOOK_TEMP_FILE"; then + discard_hook_temp + return 1 + fi + if ! dd if="$OPENCLASH_HOOK_FILE" bs=1 skip="$suffix_offset" 2>/dev/null >>"$HOOK_TEMP_FILE"; then + discard_hook_temp + return 1 + fi + + finish_replacement "$mode" "$owner" +} + +cleanup_hook() { + openclash_installed || return 0 + [ -e "$OPENCLASH_HOOK_FILE" ] || return 0 + + offsets="$(marker_offsets "$OPENCLASH_HOOK_FILE")" || return 1 + state="${offsets%%|*}" + [ "$state" = absent ] && return 0 + + rest="${offsets#*|}" + begin_offset="${rest%%|*}" + end_offset="${rest#*|}" + end_length="$(printf '%s' "$END_MARKER" | wc -c | tr -d '[:space:]')" + suffix_offset=$((end_offset + end_length)) + byte_is_newline "$OPENCLASH_HOOK_FILE" "$suffix_offset" + byte_result=$? + case "$byte_result" in + 0) suffix_offset=$((suffix_offset + 1)) ;; + 1) ;; + *) return 1 ;; + esac + + mode="$(file_mode "$OPENCLASH_HOOK_FILE")" || return 1 + owner="$(file_owner "$OPENCLASH_HOOK_FILE")" || return 1 + new_hook_temp_file || return 1 + + if ! dd if="$OPENCLASH_HOOK_FILE" bs=1 count="$begin_offset" 2>/dev/null >"$HOOK_TEMP_FILE"; then + discard_hook_temp + return 1 + fi + if ! dd if="$OPENCLASH_HOOK_FILE" bs=1 skip="$suffix_offset" 2>/dev/null >>"$HOOK_TEMP_FILE"; then + discard_hook_temp + return 1 + fi + + finish_replacement "$mode" "$owner" +} + +rule_specs() { + cat <<'EOF' +openclash_mangle_output|luci-app-tailscale: Tailscale 标记流量绕过 OpenClash(mangle output)|meta mark & 0x00ff0000 == 0x00080000 +openclash_output|luci-app-tailscale: Tailscale 标记流量绕过 OpenClash(output)|meta mark & 0x00ff0000 == 0x00080000 +openclash_mangle|luci-app-tailscale: tailscale0 入站流量绕过 OpenClash(mangle)|iifname "tailscale0" +openclash|luci-app-tailscale: tailscale0 入站流量绕过 OpenClash(filter)|iifname "tailscale0" +EOF +} + +feature_state() { + if ! section_output="$("$UCI_BIN" -q show tailscale_openclash.settings 2>/dev/null)"; then + printf 'invalid\n' + return 0 + fi + section_count="$(printf '%s\n' "$section_output" | awk ' + $0 == "tailscale_openclash.settings=openclash" { count++ } + END { print count + 0 } + ')" || section_count=0 + [ "$section_count" = 1 ] || { + printf 'invalid\n' + return 0 + } + option_count="$(printf '%s\n' "$section_output" | awk ' + index($0, "tailscale_openclash.settings.enabled=") == 1 { count++ } + END { print count + 0 } + ')" || option_count=2 + case "$option_count" in + 0) normalized=1 ;; + 1) + if ! configured="$("$UCI_BIN" -q get tailscale_openclash.settings.enabled 2>/dev/null)"; then + printf 'invalid\n' + return 0 + fi + normalized="$(printf '%s' "$configured" | tr '[:upper:]' '[:lower:]')" || { + printf 'invalid\n' + return 0 + } + ;; + *) + printf 'invalid\n' + return 0 + ;; + esac + + case "$normalized" in + 1|true|on|yes|enabled) printf 'enabled\n' ;; + 0|false|off|no|disabled) printf 'disabled\n' ;; + *) printf 'invalid\n' ;; + esac +} + +new_nft_temp_file() { + mktemp "${TMPDIR:-/tmp}/tailscale-openclash-nft.XXXXXX" +} + +new_nft_cache_dir() { + mktemp -d "${TMPDIR:-/tmp}/tailscale-openclash-nft-cache.XXXXXX" +} + +discard_nft_temp() { + [ -z "$NFT_TEMP_FILE" ] || rm -f "$NFT_TEMP_FILE" + NFT_TEMP_FILE= +} + +discard_nft_cache() { + [ -z "$NFT_CACHE_DIR" ] || rm -rf "$NFT_CACHE_DIR" + NFT_CACHE_DIR= +} + +create_generation_guard() { + cache_dir="$1" + guard_suffix="${cache_dir##*.}" + case "$guard_suffix" in + ''|*[!A-Za-z0-9_]*) return 1 ;; + esac + NFT_GUARD_NAME="luci_app_tailscale_guard_$$_$guard_suffix" + if ! "$NFT_BIN" add counter inet fw4 "$NFT_GUARD_NAME"; then + NFT_GUARD_NAME= + return 1 + fi +} + +discard_nft_guard() { + guard_name="$NFT_GUARD_NAME" + [ -n "$guard_name" ] || return 0 + if "$NFT_BIN" delete counter inet fw4 "$guard_name" >/dev/null 2>&1; then + [ "$NFT_GUARD_NAME" != "$guard_name" ] || NFT_GUARD_NAME= + return 0 + fi + log_error "Failed to remove tracked nftables generation guard $guard_name; EXIT cleanup will retry." || true + return 1 +} + +append_guard_deletion() { + [ -n "$NFT_GUARD_NAME" ] || return 1 + printf 'delete counter inet fw4 %s\n' "$NFT_GUARD_NAME" +} + +nft_json_supported() { + printf '%s\n' "$1" | "$JQ_BIN" -e '.nftables | type == "array"' >/dev/null 2>&1 +} + +nft_chain_json_supported() { + printf '%s\n' "$1" | "$JQ_BIN" -e --arg chain "$2" ' + (.nftables | type == "array") and + ([.nftables[] | .chain? | + select(.family == "inet" and .table == "fw4" and .name == $chain)] | length == 1) and + all(.nftables[]; + (type == "object") and + if has("metainfo") then + (.metainfo | type == "object") + elif has("chain") then + (.chain | type == "object") and + (.chain.family == "inet") and + (.chain.table == "fw4") and + (.chain.name == $chain) + elif has("rule") then + (.rule | type == "object") and + (.rule.family == "inet") and + (.rule.table == "fw4") and + (.rule.chain == $chain) and + (.rule.handle | type == "number") + else + false + end + ) + ' >/dev/null 2>&1 +} + +nft_table_inventory_supported() { + printf '%s\n' "$1" | "$JQ_BIN" -e ' + (.nftables | type == "array") and + all(.nftables[]; + if has("metainfo") then + (.metainfo | type == "object") + elif has("table") then + (.table | type == "object") and + (.table.family | type == "string") and + (.table.name | type == "string") + else + false + end + ) + ' >/dev/null 2>&1 +} + +fw4_inventory_count() { + printf '%s\n' "$1" | "$JQ_BIN" -r ' + [.nftables[] | .table? | + select(.family == "inet" and .name == "fw4")] | length + ' +} + +table_json_has_fw4() { + printf '%s\n' "$1" | "$JQ_BIN" -e ' + [.nftables[] | .table? | + select(.family == "inet" and .name == "fw4")] | length == 1 + ' >/dev/null 2>&1 +} + +read_fw4_table() { + if ! inventory_json="$("$NFT_BIN" -j list tables 2>/dev/null)"; then + return 1 + fi + nft_table_inventory_supported "$inventory_json" || return 1 + inventory_count="$(fw4_inventory_count "$inventory_json")" || return 1 + case "$inventory_count" in + 0) return 2 ;; + 1) ;; + *) return 1 ;; + esac + if ! snapshot_json="$("$NFT_BIN" -j list table inet fw4 2>/dev/null)"; then + return 3 + fi + nft_json_supported "$snapshot_json" || return 3 + table_json_has_fw4 "$snapshot_json" || return 3 + printf '%s\n' "$snapshot_json" +} + +table_has_chain() { + table_json="$1" + chain="$2" + printf '%s\n' "$table_json" | "$JQ_BIN" -e --arg chain "$chain" ' + .nftables[] | .chain? | + select(.family == "inet" and .table == "fw4" and .name == $chain) + ' >/dev/null 2>&1 +} + +all_target_chains_present() { + table_json="$1" + specs="$(rule_specs)" + while IFS='|' read -r chain comment match; do + table_has_chain "$table_json" "$chain" || return 1 + done <"$handles_file" || return 1 + [ -n "$handles" ] || return 0 + + while IFS= read -r handle || [ -n "$handle" ]; do + case "$handle" in + ''|*[!0-9]*) return 1 ;; + esac + printf '%s\n' "$handle" >>"$handles_file" || return 1 + done </dev/null)"; then + refreshed_state="$(refreshed_chain_state "$chain")" || refreshed_state=unsupported + case "$refreshed_state" in + absent) + if ! discard_nft_guard; then + discard_nft_cache + return 1 + fi + discard_nft_cache + cleanup_rules + return $? + ;; + table-absent) + NFT_GUARD_NAME= + discard_nft_cache + return 0 + ;; + *) + log_error "OpenClash nftables chain $chain could not be read." || true + discard_nft_guard + discard_nft_cache + return 1 + ;; + esac + fi + if ! nft_chain_json_supported "$chain_json" "$chain"; then + log_error "OpenClash nftables JSON is unsupported for chain $chain." || true + discard_nft_guard + discard_nft_cache + return 1 + fi + if ! cache_owned_handles "$cache_dir" "$chain" "$comment" "$chain_json"; then + log_error "Failed to read owned OpenClash nftables rules from chain $chain." || true + discard_nft_guard + discard_nft_cache + return 1 + fi + done <"$NFT_TEMP_FILE"; then + discard_nft_guard + discard_nft_cache + discard_nft_temp + return 1 + fi + if ! append_cached_deletions "$cache_dir" >>"$NFT_TEMP_FILE"; then + discard_nft_guard + discard_nft_cache + discard_nft_temp + return 1 + fi + discard_nft_cache + if ! append_managed_rules >>"$NFT_TEMP_FILE"; then + discard_nft_guard + discard_nft_temp + return 1 + fi + if ! "$NFT_BIN" -f "$NFT_TEMP_FILE"; then + log_error 'OpenClash nftables reconciliation transaction failed.' || true + discard_nft_guard + discard_nft_temp + return 1 + fi + NFT_GUARD_NAME= + discard_nft_temp +} + +cleanup_rules() { + table_json="$(read_fw4_table)" + table_result=$? + case "$table_result" in + 0) ;; + 2) return 0 ;; + *) + log_error 'OpenClash nftables table state could not be read.' || true + return 1 + ;; + esac + cache_dir="$(new_nft_cache_dir)" || return 1 + NFT_CACHE_DIR="$cache_dir" + if ! create_generation_guard "$cache_dir"; then + discard_nft_cache + return 1 + fi + specs="$(rule_specs)" + + while IFS='|' read -r chain comment match; do + : >"$cache_dir/$chain" || { + discard_nft_guard + discard_nft_cache + return 1 + } + table_has_chain "$table_json" "$chain" || continue + if ! chain_json="$("$NFT_BIN" -j -a list chain inet fw4 "$chain" 2>/dev/null)"; then + refreshed_state="$(refreshed_chain_state "$chain")" || refreshed_state=unsupported + case "$refreshed_state" in + absent) continue ;; + table-absent) + discard_nft_guard + discard_nft_cache + return 0 + ;; + *) + log_error "OpenClash nftables chain $chain could not be read." || true + discard_nft_guard + discard_nft_cache + return 1 + ;; + esac + fi + if ! nft_chain_json_supported "$chain_json" "$chain"; then + log_error "OpenClash nftables JSON is unsupported for chain $chain." || true + discard_nft_guard + discard_nft_cache + return 1 + fi + if ! cache_owned_handles "$cache_dir" "$chain" "$comment" "$chain_json"; then + log_error "Failed to read owned OpenClash nftables rules from chain $chain." || true + discard_nft_guard + discard_nft_cache + return 1 + fi + done <"$NFT_TEMP_FILE"; then + discard_nft_guard + discard_nft_cache + discard_nft_temp + return 1 + fi + if ! append_cached_deletions "$cache_dir" >>"$NFT_TEMP_FILE"; then + discard_nft_guard + discard_nft_cache + discard_nft_temp + return 1 + fi + discard_nft_cache + if [ ! -s "$NFT_TEMP_FILE" ]; then + discard_nft_guard + discard_nft_temp + return 0 + fi + if ! "$NFT_BIN" -f "$NFT_TEMP_FILE"; then + log_error 'OpenClash nftables cleanup transaction failed.' || true + discard_nft_guard + discard_nft_temp + return 1 + fi + NFT_GUARD_NAME= + discard_nft_temp +} + +cleanup_all() { + cleanup_rules || return $? + cleanup_hook +} + +sync_all() { + openclash_installed || return 0 + current_feature_state="$(feature_state)" || return 1 + case "$current_feature_state" in + disabled) + cleanup_all + return $? + ;; + invalid) + log_error 'OpenClash bypass enabled setting is invalid.' || true + return 1 + ;; + enabled) ;; + *) return 1 ;; + esac + + sync_table_json="$(read_fw4_table)" + sync_table_result=$? + case "$sync_table_result" in + 0) ;; + 2) return 0 ;; + *) + log_error 'OpenClash nftables table state could not be read.' || true + return 1 + ;; + esac + reconcile_hook || return 1 + apply_rules +} + +print_status_json() { + state="$1" + enabled="$2" + openclash_present="$3" + firewall4_supported="$4" + hook="$5" + rules_present="$6" + message="$7" + "$JQ_BIN" -nc \ + --arg state "$state" \ + --argjson enabled "$enabled" \ + --argjson openclash_present "$openclash_present" \ + --argjson firewall4_supported "$firewall4_supported" \ + --arg hook "$hook" \ + --argjson rules_present "$rules_present" \ + --arg message "$message" \ + '{state: $state, enabled: ($enabled == 1), openclash_present: ($openclash_present == 1), firewall4_supported: ($firewall4_supported == 1), hook: $hook, rules_present: $rules_present, message: $message}' +} + +print_disabled_status() { + disabled_table_json="$1" + disabled_firewall4_supported="${2:-1}" + if ! disabled_hook_state="$(canonical_hook_state "$OPENCLASH_HOOK_FILE")"; then + disabled_hook=error + else + case "$disabled_hook_state" in + managed) disabled_hook=managed ;; + absent) disabled_hook=absent ;; + *) disabled_hook=error ;; + esac + fi + + disabled_rules_present="$(owned_rule_count "$disabled_table_json")" || { + print_status_json error 0 1 "$disabled_firewall4_supported" "$disabled_hook" 0 \ + 'OpenClash bypass rule ownership data could not be read.' + return + } + case "$disabled_rules_present" in + ''|*[!0-9]*) + print_status_json error 0 1 "$disabled_firewall4_supported" "$disabled_hook" 0 \ + 'OpenClash bypass rule ownership data is invalid.' + return + ;; + esac + + if [ "$disabled_hook" = absent ] && [ "$disabled_rules_present" = 0 ]; then + print_status_json disabled 0 1 "$disabled_firewall4_supported" absent 0 'OpenClash bypass is disabled.' + return + fi + print_status_json error 0 1 "$disabled_firewall4_supported" "$disabled_hook" "$disabled_rules_present" \ + 'OpenClash bypass is disabled but managed artifacts remain.' +} + +print_disabled_read_error_status() { + disabled_firewall4_supported="$1" + disabled_message="$2" + if ! disabled_hook_state="$(canonical_hook_state "$OPENCLASH_HOOK_FILE")"; then + disabled_hook=error + else + case "$disabled_hook_state" in + managed) disabled_hook=managed ;; + absent) disabled_hook=absent ;; + *) disabled_hook=error ;; + esac + fi + print_status_json error 0 1 "$disabled_firewall4_supported" "$disabled_hook" 0 "$disabled_message" +} + +print_status() { + if ! openclash_installed; then + print_status_json absent 0 0 0 absent 0 'OpenClash is not installed.' + return + fi + + current_feature_state="$(feature_state)" || current_feature_state=invalid + case "$current_feature_state" in + enabled) enabled=1 ;; + *) enabled=0 ;; + esac + if [ "$current_feature_state" = invalid ]; then + print_status_json error 0 1 0 error 0 'OpenClash bypass enabled setting is invalid.' + return + fi + + table_json="$(read_fw4_table)" + table_result=$? + if [ "$current_feature_state" = disabled ]; then + case "$table_result" in + 0) print_disabled_status "$table_json" 1 ;; + 2) print_disabled_status '{"nftables":[]}' 0 ;; + 1) print_disabled_read_error_status 0 'Unable to read firewall4/nftables state.' ;; + *) print_disabled_read_error_status 1 'Unable to read firewall4/nftables state.' ;; + esac + return + fi + + case "$table_result" in + 0) ;; + 2) + print_status_json unsupported "$enabled" 1 0 unknown 0 'firewall4/nftables support is unavailable.' + return + ;; + 1) + print_status_json error "$enabled" 1 0 unknown 0 'Unable to read firewall4/nftables state.' + return + ;; + *) + print_status_json error "$enabled" 1 1 unknown 0 'Unable to read firewall4/nftables state.' + return + ;; + esac + + if ! hook_state="$(canonical_hook_state "$OPENCLASH_HOOK_FILE")"; then + print_status_json error 1 1 1 error 0 'Managed hook markers are malformed.' + return + fi + case "$hook_state" in + managed) hook=managed ;; + absent) hook=absent ;; + stale) + print_status_json error 1 1 1 error 0 'Managed hook body is not canonical.' + return + ;; + *) print_status_json error 1 1 1 error 0 'Managed hook markers are malformed.'; return ;; + esac + + complete_rules=1 + rules_present="$(owned_rule_count "$table_json")" || { + print_status_json error 1 1 1 "$hook" 0 'OpenClash bypass rule ownership data could not be read.' + return + } + case "$rules_present" in + ''|*[!0-9]*) + print_status_json error 1 1 1 "$hook" 0 'OpenClash bypass rule ownership data is invalid.' + return + ;; + esac + if ! all_target_chains_present "$table_json"; then + print_status_json waiting 1 1 1 "$hook" "$rules_present" 'Waiting for OpenClash nftables chains.' + return + fi + + specs="$(rule_specs)" + while IFS='|' read -r chain comment match; do + if ! chain_json="$("$NFT_BIN" -j -a list chain inet fw4 "$chain" 2>/dev/null)"; then + refreshed_state="$(refreshed_chain_state "$chain")" || refreshed_state=unsupported + case "$refreshed_state" in + absent) + print_status_json waiting 1 1 1 "$hook" "$rules_present" 'Waiting for OpenClash nftables chains.' + return + ;; + table-absent) + print_status_json unsupported 1 1 0 "$hook" "$rules_present" 'firewall4/nftables support is unavailable.' + return + ;; + error|present) + print_status_json error 1 1 1 "$hook" "$rules_present" "OpenClash nftables chain $chain could not be read." + return + ;; + *) + print_status_json error 1 1 1 "$hook" "$rules_present" "OpenClash nftables chain $chain could not be read." + return + ;; + esac + fi + if ! nft_chain_json_supported "$chain_json" "$chain"; then + print_status_json error 1 1 1 "$hook" "$rules_present" "OpenClash nftables JSON is invalid for chain $chain." + return + fi + case "$chain" in + openclash_mangle_output|openclash_output) rule_kind=mark ;; + *) rule_kind=ingress ;; + esac + canonical_count="$(canonical_owned_rule_count "$chain_json" "$chain" "$comment" "$rule_kind")" || { + print_status_json error 1 1 1 "$hook" "$rules_present" 'OpenClash bypass rule structure could not be read.' + return + } + [ "$canonical_count" = 1 ] || complete_rules=0 + done <"$LOCK_FILE" || exit 1 + flock -x 9 || exit 1 + case "$1" in + sync) + sync_all + ;; + reconcile-hook) + openclash_installed || exit 0 + reconcile_hook + ;; + apply) + openclash_installed || exit 0 + current_feature_state="$(feature_state)" || current_feature_state=invalid + case "$current_feature_state" in + enabled) apply_rules ;; + disabled) cleanup_all ;; + *) log_error 'OpenClash bypass enabled setting is invalid.' || true; exit 1 ;; + esac + ;; + cleanup) + cleanup_all + ;; + esac + ;; + *) + printf 'usage: %s {sync|reconcile-hook|apply|cleanup|status}\n' "$0" >&2 + exit 2 + ;; +esac diff --git a/luci-app-tailscale/root/usr/sbin/tailscale_peer_probe b/luci-app-tailscale/root/usr/sbin/tailscale_peer_probe new file mode 100755 index 000000000..b25239b62 --- /dev/null +++ b/luci-app-tailscale/root/usr/sbin/tailscale_peer_probe @@ -0,0 +1,89 @@ +#!/bin/sh + +# Peer probe for LuCI Tailscale diagnostics. + +TAILSCALE_BIN="${TAILSCALE_BIN:-/usr/sbin/tailscale}" +JQ_BIN="${JQ_BIN:-/usr/bin/jq}" + +emit_json() { + peer="$1" + ok="$2" + path="$3" + latency_ms="$4" + relay="$5" + summary="$6" + raw="$7" + latency_json="null" + + [ -n "$latency_ms" ] && latency_json="$latency_ms" + + "$JQ_BIN" -nc \ + --arg peer "$peer" \ + --arg path "$path" \ + --arg relay "$relay" \ + --arg summary "$summary" \ + --arg raw "$raw" \ + --argjson ok "$ok" \ + --argjson latency_ms "$latency_json" \ + '{peer:$peer,ok:$ok,path:$path,latency_ms:$latency_ms,relay:$relay,summary:$summary,raw:$raw}' +} + +usage() { + emit_json "" false failed "" "" "usage: tailscale_peer_probe " "" + exit 2 +} + +[ "$#" -eq 1 ] || usage + +peer="$1" + +case "$peer" in + ""|*[!A-Za-z0-9_.:-]*) + emit_json "" false failed "" "" "invalid peer argument" "" + exit 2 + ;; +esac + +peer_len=${#peer} +if [ "$peer_len" -lt 1 ] || [ "$peer_len" -gt 253 ]; then + emit_json "" false failed "" "" "invalid peer argument" "" + exit 2 +fi + +raw="$("$TAILSCALE_BIN" ping --c=1 --timeout=2s "$peer" 2>&1)" +rc="$?" + +latency_ms="$(printf '%s\n' "$raw" | sed -nE 's/.* in ([0-9]+(\.[0-9]+)?)ms.*/\1/p' | head -n 1)" +relay="$(printf '%s\n' "$raw" | sed -nE 's/.*DERP\(([^)]*)\).*/\1/p' | head -n 1)" +direct_note="$(printf '%s\n' "$raw" | sed -n '/direct connection not established/{p;q;}')" + +if [ -n "$relay" ]; then + if [ -n "$latency_ms" ]; then + summary="DERP $relay $latency_ms ms" + else + summary="DERP $relay" + fi + [ -n "$direct_note" ] && summary="$summary - direct connection not established" + emit_json "$peer" true derp "$latency_ms" "$relay" "$summary" "$raw" + exit 0 +fi + +if [ "$rc" -ne 0 ]; then + emit_json "$peer" false failed "" "" "tailscale ping failed" "$raw" + exit 0 +fi + +case "$raw" in + *pong\ from*) + if [ -n "$latency_ms" ]; then + summary="direct $latency_ms ms" + emit_json "$peer" true direct "$latency_ms" "" "$summary" "$raw" + else + summary="unknown path" + emit_json "$peer" true unknown "" "" "$summary" "$raw" + fi + ;; + *) + emit_json "$peer" true unknown "" "" "unknown path" "$raw" + ;; +esac diff --git a/luci-app-tailscale/root/usr/sbin/tailscale_policy_routing b/luci-app-tailscale/root/usr/sbin/tailscale_policy_routing new file mode 100755 index 000000000..27ac150a9 --- /dev/null +++ b/luci-app-tailscale/root/usr/sbin/tailscale_policy_routing @@ -0,0 +1,429 @@ +#!/bin/sh + +# Copyright (C) 2026 jearton +# SPDX-License-Identifier: GPL-3.0-only + +UCI_BIN="${UCI_BIN:-uci}" +IP_BIN="${IP_BIN:-ip}" +JQ_BIN="${JQ_BIN:-jq}" +LOGGER_CMD="${LOGGER_CMD:-logger}" +FLOCK_BIN="${FLOCK_BIN:-flock}" +MWAN3_INIT="${MWAN3_INIT:-/etc/init.d/mwan3}" +LOCK_FILE="${LOCK_FILE:-/var/lock/tailscale-policy-routing.lock}" +SLEEP_BIN="${SLEEP_BIN:-sleep}" +MONITOR_INTERVAL="${MONITOR_INTERVAL:-15}" + +SETTINGS_PACKAGE='tailscale_policy_routing' +SETTINGS_SECTION='settings' +NETWORK_SECTION='ts_mwan3_table52' +PRIORITY='1000' +TABLE='52' +RULE_NAME='luci-app-tailscale: Tailscale table 52 before mwan3' + +log_error() { + "$LOGGER_CMD" -p daemon.err -t tailscale_policy_routing "$1" +} + +setting_enabled() { + case "$("$UCI_BIN" -q get "$SETTINGS_PACKAGE.$SETTINGS_SECTION.enabled" 2>/dev/null || true)" in + 1) return 0 ;; + 0|'') return 1 ;; + *) return 2 ;; + esac +} + +network_section_exists() { + "$UCI_BIN" -q get "network.$NETWORK_SECTION" >/dev/null 2>&1 +} + +network_section_owned() { + [ "$("$UCI_BIN" -q get "network.$NETWORK_SECTION" 2>/dev/null || true)" = 'rule' ] && \ + [ "$("$UCI_BIN" -q get "network.$NETWORK_SECTION.name" 2>/dev/null || true)" = "$RULE_NAME" ] && \ + [ "$("$UCI_BIN" -q get "network.$NETWORK_SECTION.family" 2>/dev/null || true)" = 'ipv4' ] && \ + [ "$("$UCI_BIN" -q get "network.$NETWORK_SECTION.priority" 2>/dev/null || true)" = "$PRIORITY" ] && \ + [ "$("$UCI_BIN" -q get "network.$NETWORK_SECTION.lookup" 2>/dev/null || true)" = "$TABLE" ] +} + +rule_dump() { + "$IP_BIN" -4 rule show 2>/dev/null +} + +route_dump() { + "$IP_BIN" -4 route show table "$TABLE" 2>/dev/null +} + +priority_lines() { + printf '%s\n' "$1" | sed -n "/^$PRIORITY:[[:space:]]/p" +} + +priority_line_count() { + priority_lines "$1" | sed '/^$/d' | wc -l | tr -d ' ' +} + +is_exact_rule_line() { + # ip-rule has no comments or other ownership metadata. Only the exact rule we + # create can be treated as app-owned; constrained table-52 rules are foreign. + printf '%s\n' "$1" | grep -Fqx "$PRIORITY: from all lookup $TABLE" +} + +runtime_rule_state() { + rules="$1" + lines="$(priority_lines "$rules")" + count="$(priority_line_count "$rules")" + case "$count" in + 0) printf '%s\n' 'absent' ;; + 1) + if is_exact_rule_line "$lines"; then + printf '%s\n' 'exact' + else + printf '%s\n' 'conflict' + fi + ;; + *) printf '%s\n' 'conflict' ;; + esac +} + +table_has_default_route() { + route_dump | grep -Eq '^default[[:space:]]' +} + +tailscale_rule_priority() { + rule_dump | awk -v preferred="$PRIORITY" -v table="$TABLE" ' + $0 ~ ("(lookup|table)[[:space:]]+" table "([[:space:]]|$)") { + priority = $1 + sub(/:$/, "", priority) + if (priority != preferred && (best == "" || priority + 0 < best + 0)) + best = priority + } + END { print best } + ' +} + +mwan3_present() { + [ -x "$MWAN3_INIT" ] && \ + [ "$("$UCI_BIN" -q get mwan3.globals 2>/dev/null || true)" = 'globals' ] +} + +mwan3_mark_mask() { + mask="$("$UCI_BIN" -q get mwan3.globals.mmx_mask 2>/dev/null || true)" + case "$mask" in + 0[xX][0-9A-Fa-f]*) printf '%s\n' "$mask" | tr '[:upper:]' '[:lower:]' ;; + *) printf '%s\n' '0x3f00' ;; + esac +} + +mwan3_earlier_mark_rule() { + rules="$1" + threshold="$2" + mask="$(mwan3_mark_mask)" + mwan3_present || return 1 + printf '%s\n' "$rules" | awk -v threshold="$threshold" -v mask="$mask" ' + /fwmark/ && index(tolower($0), "/" mask) && /(lookup|table)/ { + priority = $1 + sub(/:$/, "", priority) + if (priority + 0 < threshold + 0) { + found = 1 + exit + } + } + END { exit !found } + ' +} + +write_owned_network_rule() { + "$UCI_BIN" set "network.$NETWORK_SECTION=rule" || return 1 + "$UCI_BIN" set "network.$NETWORK_SECTION.name=$RULE_NAME" || return 1 + "$UCI_BIN" set "network.$NETWORK_SECTION.family=ipv4" || return 1 + "$UCI_BIN" set "network.$NETWORK_SECTION.priority=$PRIORITY" || return 1 + "$UCI_BIN" set "network.$NETWORK_SECTION.lookup=$TABLE" || return 1 + "$UCI_BIN" commit network +} + +delete_owned_network_rule() { + network_section_exists || return 3 + network_section_owned || return 2 + "$UCI_BIN" delete "network.$NETWORK_SECTION" || return 1 + "$UCI_BIN" commit network +} + +add_runtime_rule() { + rules="$(rule_dump)" || return 1 + case "$(runtime_rule_state "$rules")" in + absent) "$IP_BIN" -4 rule add priority "$PRIORITY" lookup "$TABLE" ;; + exact) return 0 ;; + conflict) return 2 ;; + esac +} + +delete_runtime_rule() { + rules="$(rule_dump)" || return 1 + case "$(runtime_rule_state "$rules")" in + absent) return 0 ;; + exact) "$IP_BIN" -4 rule del priority "$PRIORITY" lookup "$TABLE" ;; + conflict) return 2 ;; + esac +} + +cleanup_owned_state() { + strict_cleanup="${1:-0}" + rules="$(rule_dump)" || return 1 + if ! network_section_exists; then + # A normal disable does not adopt or remove manually-created exact rules. + # Package removal is stricter: it must not leave an indistinguishable + # priority-1000 rule behind after the helper is gone. + if [ "$strict_cleanup" = 1 ] && [ "$(runtime_rule_state "$rules")" = exact ]; then + log_error 'refusing package cleanup because an unowned priority 1000 table 52 rule remains' + return 2 + fi + return 0 + fi + if ! network_section_owned; then + log_error 'refusing to delete a non-owned network rule section' + return 2 + fi + + delete_runtime_rule + delete_status=$? + case "$delete_status" in + 0) ;; + 2) + log_error 'refusing to delete conflicting priority 1000 policy rules' + return 2 + ;; + *) return "$delete_status" ;; + esac + + # Keep the ownership declaration until the runtime rule is gone. If deletion + # fails, a later sync can retry safely instead of treating the rule as foreign. + delete_owned_network_rule +} + +sync_enabled() { + rules="$(rule_dump)" || return 1 + + if table_has_default_route; then + cleanup_owned_state + cleanup_status=$? + case "$cleanup_status" in + 0) return 0 ;; + 2) + log_error 'table 52 has a default route and an unowned rule prevents safe cleanup' + return 1 + ;; + *) return "$cleanup_status" ;; + esac + fi + + if mwan3_earlier_mark_rule "$rules" "$PRIORITY"; then + if network_section_owned; then + delete_runtime_rule + cleanup_status=$? + case "$cleanup_status" in + 0) ;; + 2) + log_error 'refusing to remove the managed rule because priority 1000 now conflicts' + return 1 + ;; + *) return "$cleanup_status" ;; + esac + fi + log_error 'mwan3 has a fwmark policy rule before priority 1000; table 52 precedence is blocked' + return 0 + fi + + if network_section_exists; then + if ! network_section_owned; then + log_error 'network.ts_mwan3_table52 exists but is not owned by luci-app-tailscale' + return 1 + fi + else + case "$(runtime_rule_state "$rules")" in + absent) write_owned_network_rule || return 1 ;; + exact) + log_error 'priority 1000 table 52 rule already exists without app ownership' + return 1 + ;; + conflict) + log_error 'priority 1000 is occupied by another policy rule' + return 1 + ;; + esac + fi + + add_runtime_rule + add_status=$? + case "$add_status" in + 0) return 0 ;; + 2) + # Keep the declaration so a later sync can safely recover if the foreign + # rule disappears. It also records that the app must not adopt that rule. + log_error 'priority 1000 is occupied by another policy rule' + return 1 + ;; + *) return "$add_status" ;; + esac +} + +sync() { + setting_enabled + enabled_status=$? + case "$enabled_status" in + 0) sync_enabled ;; + 1) cleanup_owned_state ;; + 2) + log_error 'tailscale_policy_routing.settings.enabled is invalid' + return 1 + ;; + esac +} + +ensure() { + setting_enabled + enabled_status=$? + case "$enabled_status" in + 0) ;; + 1) return 0 ;; + 2) + log_error 'tailscale_policy_routing.settings.enabled is invalid' + return 1 + ;; + esac + rules="$(rule_dump)" || return 1 + if table_has_default_route; then + # Keep the persistent declaration for recovery after the exit node is + # removed, but never leave our high-priority runtime rule ahead of a + # default route in table 52. + network_section_owned || return 0 + delete_runtime_rule + return $? + fi + if mwan3_earlier_mark_rule "$rules" "$PRIORITY"; then + network_section_owned || return 0 + delete_runtime_rule + cleanup_status=$? + case "$cleanup_status" in + 0) + log_error 'mwan3 has a fwmark policy rule before priority 1000; table 52 precedence is blocked' + return 0 + ;; + 2) + log_error 'refusing to remove the managed rule because priority 1000 now conflicts' + return 1 + ;; + *) return "$cleanup_status" ;; + esac + fi + network_section_owned || return 0 + add_runtime_rule +} + +bool_json() { + if "$1"; then + printf '%s\n' true + else + printf '%s\n' false + fi +} + +print_status() { + rules="$(rule_dump)" || exit 1 + enabled=false + settings_invalid=false + setting_enabled + enabled_status=$? + case "$enabled_status" in + 0) enabled=true ;; + 2) settings_invalid=true ;; + esac + runtime_state="$(runtime_rule_state "$rules")" + owned=false + network_section_owned && owned=true + section_exists=false + network_section_exists && section_exists=true + default_route=false + table_has_default_route && default_route=true + tailscale_priority="$(tailscale_rule_priority)" + mwan3=false + mwan3_present && mwan3=true + early_mark=false + if mwan3_earlier_mark_rule "$rules" "$PRIORITY"; then + early_mark=true + fi + + if [ "$settings_invalid" = true ]; then + state=error + message='The policy-routing enable setting is invalid.' + elif [ "$enabled" = false ]; then + state=disabled + message='Policy routing precedence is disabled.' + elif [ "$default_route" = true ]; then + state=blocked_default_route + message='Table 52 contains a default route, so precedence is blocked to preserve mwan3 routing.' + elif [ "$early_mark" = true ]; then + state=blocked_mwan3_priority + message='A mwan3 fwmark policy rule precedes priority 1000, so table 52 precedence is blocked.' + elif [ "$section_exists" = true ] && [ "$owned" = false ]; then + state=blocked_ownership + message='The named network rule is not owned by luci-app-tailscale.' + elif [ "$runtime_state" = conflict ]; then + state=blocked_priority_conflict + message='Priority 1000 is occupied by another policy rule.' + elif [ "$runtime_state" = exact ] && [ "$owned" = false ]; then + state=blocked_ownership + message='Priority 1000 lookup 52 exists but is not owned by luci-app-tailscale.' + elif [ "$runtime_state" = exact ] && [ "$owned" = true ]; then + state=active + message='Tailscale table 52 is evaluated before mwan3 policy rules.' + else + state=waiting + message='Waiting to install the managed table 52 policy rule.' + fi + + "$JQ_BIN" -nc \ + --arg state "$state" \ + --arg message "$message" \ + --arg tailscale_priority "$tailscale_priority" \ + --argjson enabled "$enabled" \ + --argjson mwan3_present "$mwan3" \ + --argjson mwan3_earlier_mark_rule "$early_mark" \ + --argjson managed_network_rule "$owned" \ + --argjson runtime_rule_present "$( [ "$runtime_state" = exact ] && printf true || printf false )" \ + --argjson default_route "$default_route" \ + '{state:$state, message:$message, enabled:$enabled, mwan3_present:$mwan3_present, mwan3_earlier_mark_rule:$mwan3_earlier_mark_rule, managed_network_rule:$managed_network_rule, runtime_rule_present:$runtime_rule_present, default_route:$default_route, tailscale_rule_priority:($tailscale_priority | if . == "" then null else tonumber end)}' +} + +monitor() { + while :; do + if setting_enabled; then + # The normal sync and hotplug paths report actionable errors. The monitor + # retries quietly so a persistent external conflict cannot flood syslog. + saved_logger="$LOGGER_CMD" + LOGGER_CMD=: + run_locked ensure || : + LOGGER_CMD="$saved_logger" + fi + "$SLEEP_BIN" "$MONITOR_INTERVAL" || return 0 + done +} + +run_locked() { + lock_dir="${LOCK_FILE%/*}" + [ "$lock_dir" = "$LOCK_FILE" ] && lock_dir=. + mkdir -p "$lock_dir" || return 1 + exec 9>"$LOCK_FILE" + "$FLOCK_BIN" -xn 9 || return 1 + "$@" +} + +case "${1:-}" in +sync) run_locked sync ;; +ensure) run_locked ensure ;; +cleanup) run_locked cleanup_owned_state 1 ;; +enabled) setting_enabled ;; +monitor) monitor ;; +status) print_status ;; +*) + echo "usage: $0 {sync|ensure|cleanup|enabled|monitor|status}" >&2 + exit 2 + ;; +esac diff --git a/luci-app-tailscale/root/usr/sbin/tailscale_secrets b/luci-app-tailscale/root/usr/sbin/tailscale_secrets new file mode 100755 index 000000000..3f8f81816 --- /dev/null +++ b/luci-app-tailscale/root/usr/sbin/tailscale_secrets @@ -0,0 +1,555 @@ +#!/bin/sh + +set -eu + +SECRETS_FILE="${TAILSCALE_SECRETS_FILE:-/etc/tailscale/luci-secrets.json}" +JQ_CMD="${JQ_CMD:-jq}" +UCI_CMD="${UCI_CMD:-uci}" +FLOCK_BIN="${FLOCK_BIN:-flock}" +umask 077 + +fail() { + printf '%s\n' "$*" >&2 + exit 1 +} + +normalize_api_url() { + url="${1:-http://127.0.0.1:3000}" + [ -n "$url" ] || url='http://127.0.0.1:3000' + while [ "${url%/}" != "$url" ]; do + url="${url%/}" + done + case "$url" in + http://?*|https://?*) ;; + *) return 1 ;; + esac + printf '%s\n' "$url" +} + +valid_key() { + case "$1" in + authkey|adguard_password|adguard_api_url|adguard_username) return 0 ;; + *) return 1 ;; + esac +} + +valid_ref() { + case "${1:-}" in + ''|*[!A-Za-z0-9_.-]*) return 1 ;; + *) return 0 ;; + esac +} + +secrets_dir() { + dir="${SECRETS_FILE%/*}" + [ "$dir" != "$SECRETS_FILE" ] || dir='.' + printf '%s\n' "$dir" +} + +ensure_secrets_dir() { + dir="$(secrets_dir)" + [ ! -L "$dir" ] || fail "refusing symlinked secrets directory" + [ -d "$dir" ] || mkdir -p "$dir" + chmod 0700 "$dir" +} + +lock_secrets() { + ensure_secrets_dir + lock_file="$(secrets_dir)/.luci-secrets.lock" + [ ! -L "$lock_file" ] || fail "refusing symlinked secrets lock" + exec 9>"$lock_file" + chmod 0600 "$lock_file" + "$FLOCK_BIN" -x 9 || fail "failed to lock protected credentials" +} + +replace_store() ( + source_file="$1" + dir="$(secrets_dir)" + target_tmp="$(mktemp "$dir/.luci-secrets-store.XXXXXX")" + cleanup_replace() { + rm -f "$target_tmp" + } + trap cleanup_replace 0 1 2 15 + cat "$source_file" >"$target_tmp" + chmod 0600 "$target_tmp" + mv -f "$target_tmp" "$SECRETS_FILE" + target_tmp= +) + +store_is_valid() { + "$JQ_CMD" -e ' + def valid_ref: + type == "string" and + length > 0 and + all(explode[]; + (. >= 48 and . <= 57) or + (. >= 65 and . <= 90) or + (. >= 97 and . <= 122) or + . == 45 or . == 46 or . == 95 + ); + .schema == 2 and + (.active_ref | valid_ref) and + (.versions | type == "object") and + (.versions[.active_ref] | type == "object") and + all(.versions[]; type == "object") + ' "$SECRETS_FILE" >/dev/null 2>&1 +} + +ensure_store() ( + ensure_secrets_dir + [ ! -L "$SECRETS_FILE" ] || fail "refusing symlinked secrets file" + dir="$(secrets_dir)" + + if [ ! -e "$SECRETS_FILE" ]; then + initial_tmp="$(mktemp "$dir/.luci-secrets-initial.XXXXXX")" + cleanup_initial() { + rm -f "$initial_tmp" + } + trap cleanup_initial 0 1 2 15 + "$JQ_CMD" -nc '{schema:2,active_ref:"base",versions:{base:{}}}' >"$initial_tmp" + replace_store "$initial_tmp" + rm -f "$initial_tmp" + trap - 0 1 2 15 + return 0 + fi + + if store_is_valid; then + chmod 0600 "$SECRETS_FILE" + return 0 + fi + + "$JQ_CMD" -e ' + type == "object" and + (has("schema") | not) and + (([keys[]] - ["authkey", "adguard_password", "adguard_api_url", "adguard_username"]) | length == 0) and + all(.[]; type == "string") + ' "$SECRETS_FILE" >/dev/null 2>&1 || fail "invalid protected credential store" + + legacy_tmp="$(mktemp "$dir/.luci-secrets-legacy.XXXXXX")" + cleanup_legacy_store() { + rm -f "$legacy_tmp" + } + trap cleanup_legacy_store 0 1 2 15 + "$JQ_CMD" -c '{schema:2,active_ref:"legacy",versions:{legacy:.}}' "$SECRETS_FILE" >"$legacy_tmp" + replace_store "$legacy_tmp" + rm -f "$legacy_tmp" + trap - 0 1 2 15 +) + +active_ref() { + ensure_store + "$JQ_CMD" -er '.active_ref' "$SECRETS_FILE" +} + +version_exists() { + ref="$1" + valid_ref "$ref" || return 1 + ensure_store + "$JQ_CMD" -e --arg ref "$ref" '.versions[$ref] | type == "object"' "$SECRETS_FILE" >/dev/null 2>&1 +} + +new_ref() { + dir="$(secrets_dir)" + while :; do + ref_marker="$(mktemp "$dir/.luci-secret-ref.XXXXXX")" + ref_suffix="${ref_marker##*.}" + rm -f "$ref_marker" + ref="v$(date +%s 2>/dev/null || printf 0)-$$-$ref_suffix" + if ! version_exists "$ref"; then + printf '%s\n' "$ref" + return 0 + fi + done +} + +stage_update() ( + update_file="$1" + base_ref="${2:-}" + ensure_store + [ -n "$base_ref" ] || base_ref="$(active_ref)" + version_exists "$base_ref" || fail "unknown protected credential base reference" + ref="$(new_ref)" + dir="$(secrets_dir)" + staged_tmp="$(mktemp "$dir/.luci-secrets-stage.XXXXXX")" + cleanup_stage() { + rm -f "$staged_tmp" + } + trap cleanup_stage 0 1 2 15 + "$JQ_CMD" -ce \ + --arg base "$base_ref" \ + --arg ref "$ref" \ + --slurpfile update "$update_file" \ + '.versions[$ref] = (.versions[$base] * $update[0])' \ + "$SECRETS_FILE" >"$staged_tmp" + replace_store "$staged_tmp" + rm -f "$staged_tmp" + trap - 0 1 2 15 + printf '%s\n' "$ref" +) + +activate_ref() ( + ref="$1" + version_exists "$ref" || fail "unknown protected credential reference" + dir="$(secrets_dir)" + activate_tmp="$(mktemp "$dir/.luci-secrets-activate.XXXXXX")" + cleanup_activate() { + rm -f "$activate_tmp" + } + trap cleanup_activate 0 1 2 15 + "$JQ_CMD" -ce --arg ref "$ref" '.active_ref = $ref' "$SECRETS_FILE" >"$activate_tmp" + replace_store "$activate_tmp" + rm -f "$activate_tmp" + trap - 0 1 2 15 +) + +activate_and_persist_ref() { + ref="$1" + old_ref="$(active_ref)" + activate_ref "$ref" + if ! "$UCI_CMD" -q set "tailscale.settings.secrets_ref=$ref" || ! "$UCI_CMD" commit tailscale; then + activate_ref "$old_ref" || true + fail "failed to persist protected credential reference" + fi +} + +get_value() { + key="$1" + valid_key "$key" || fail "invalid secret key" + ensure_store + "$JQ_CMD" -er --arg key "$key" '.versions[.active_ref][$key] | select(type == "string")' "$SECRETS_FILE" +} + +get_required_value() { + value="$(get_value "$1")" || return 1 + [ -n "$value" ] || return 1 + printf '%s\n' "$value" +} + +has_value() { + get_required_value "$1" >/dev/null 2>&1 +} + +authkey_update_file() ( + dir="$(secrets_dir)" + update_file="$(mktemp "$dir/.luci-secrets-update.XXXXXX")" + trap 'rm -f "$update_file"' 0 1 2 15 + "$JQ_CMD" -Rse 'select(length > 0) | {authkey: ., authkey_pending: true}' >"$update_file" || { + rm -f "$update_file" + fail "auth key must not be empty" + } + printf '%s\n' "$update_file" + trap - 0 1 2 15 +) + +adguard_update_file() ( + dir="$(secrets_dir)" + payload_file="$(mktemp "$dir/.luci-secrets-payload.XXXXXX")" + update_file="$(mktemp "$dir/.luci-secrets-update.XXXXXX")" + trap 'rm -f "$payload_file" "$update_file"' 0 1 2 15 + cat >"$payload_file" + "$JQ_CMD" -e ' + type == "object" and + (.password | type == "string" and length > 0) and + (.api_url | type == "string") and + (.username | type == "string") + ' "$payload_file" >/dev/null 2>&1 || fail "invalid AdGuard credential payload" + api_url="$("$JQ_CMD" -er '.api_url' "$payload_file")" || fail "invalid AdGuard credential payload" + [ -n "$api_url" ] || fail "invalid AdGuard credential payload" + normalized_api_url="$(normalize_api_url "$api_url")" || fail "invalid AdGuard credential payload" + "$JQ_CMD" -ce --arg api_url "$normalized_api_url" ' + if ( + type == "object" and + (.password | type == "string" and length > 0) and + (.api_url | type == "string") and + (.username | type == "string") + ) then { + adguard_password: .password, + adguard_api_url: $api_url, + adguard_username: .username + } else error("invalid AdGuard credential payload") end + ' "$payload_file" >"$update_file" || { + rm -f "$update_file" + fail "invalid AdGuard credential payload" + } + rm -f "$payload_file" + printf '%s\n' "$update_file" + trap - 0 1 2 15 +) + +batch_update_file() ( + dir="$(secrets_dir)" + payload_file="$(mktemp "$dir/.luci-secrets-payload.XXXXXX")" + update_file="$(mktemp "$dir/.luci-secrets-update.XXXXXX")" + trap 'rm -f "$payload_file" "$update_file"' 0 1 2 15 + cat >"$payload_file" + "$JQ_CMD" -e ' + type == "object" and + ((.authkey == null) or (.authkey | type == "string" and length > 0)) and + ((.adguard == null) or ( + (.adguard | type == "object") and + (.adguard.password | type == "string" and length > 0) and + (.adguard.api_url | type == "string") and + (.adguard.username | type == "string") + )) and + ((.authkey != null) or (.adguard != null)) + ' "$payload_file" >/dev/null 2>&1 || fail "invalid credential batch payload" + has_adguard="$("$JQ_CMD" -r 'if .adguard == null then "0" else "1" end' "$payload_file")" || \ + fail "invalid credential batch payload" + normalized_api_url= + if [ "$has_adguard" = "1" ]; then + api_url="$("$JQ_CMD" -er '.adguard.api_url' "$payload_file")" || fail "invalid credential batch payload" + [ -n "$api_url" ] || fail "invalid credential batch payload" + normalized_api_url="$(normalize_api_url "$api_url")" || fail "invalid credential batch payload" + fi + "$JQ_CMD" -ce --arg api_url "$normalized_api_url" ' + if ( + type == "object" and + ((.authkey == null) or (.authkey | type == "string" and length > 0)) and + ((.adguard == null) or ( + (.adguard | type == "object") and + (.adguard.password | type == "string" and length > 0) and + (.adguard.api_url | type == "string") and + (.adguard.username | type == "string") + )) and + ((.authkey != null) or (.adguard != null)) + ) then + (if .authkey == null then {} else {authkey: .authkey, authkey_pending: true} end) + + (if .adguard == null then {} else { + adguard_password: .adguard.password, + adguard_api_url: $api_url, + adguard_username: .adguard.username + } end) + else error("invalid credential batch payload") end + ' "$payload_file" >"$update_file" || { + rm -f "$update_file" + fail "invalid credential batch payload" + } + rm -f "$payload_file" + printf '%s\n' "$update_file" + trap - 0 1 2 15 +) + +set_authkey() { + update_file="$(authkey_update_file)" + trap 'rm -f "$update_file"' 0 1 2 15 + ref="$(stage_update "$update_file")" + activate_and_persist_ref "$ref" + rm -f "$update_file" + trap - 0 1 2 15 +} + +stage_authkey() { + base_ref="${1:-}" + update_file="$(authkey_update_file)" + trap 'rm -f "$update_file"' 0 1 2 15 + stage_update "$update_file" "$base_ref" + rm -f "$update_file" + trap - 0 1 2 15 +} + +set_adguard() { + update_file="$(adguard_update_file)" + trap 'rm -f "$update_file"' 0 1 2 15 + ref="$(stage_update "$update_file")" + activate_and_persist_ref "$ref" + rm -f "$update_file" + trap - 0 1 2 15 +} + +set_batch() { + update_file="$(batch_update_file)" + trap 'rm -f "$update_file"' 0 1 2 15 + ref="$(stage_update "$update_file")" + activate_and_persist_ref "$ref" + rm -f "$update_file" + trap - 0 1 2 15 +} + +stage_batch() { + base_ref="${1:-}" + update_file="$(batch_update_file)" + trap 'rm -f "$update_file"' 0 1 2 15 + stage_update "$update_file" "$base_ref" + rm -f "$update_file" + trap - 0 1 2 15 +} + +authkey_pending() { + ensure_store + "$JQ_CMD" -e ' + .versions[.active_ref] | + (.authkey_pending == true) and + (.authkey | type == "string" and length > 0) + ' "$SECRETS_FILE" >/dev/null 2>&1 +} + +clear_authkey() ( + ensure_store + dir="$(secrets_dir)" + clear_tmp="$(mktemp "$dir/.luci-secrets-clear-authkey.XXXXXX")" + cleanup_clear_authkey() { + rm -f "$clear_tmp" + } + trap cleanup_clear_authkey 0 1 2 15 + "$JQ_CMD" -ce \ + '.versions |= with_entries(.value |= del(.authkey, .authkey_pending))' \ + "$SECRETS_FILE" >"$clear_tmp" + replace_store "$clear_tmp" + rm -f "$clear_tmp" + trap - 0 1 2 15 +) + +matches_adguard() { + requested_url="$(normalize_api_url "${1:-}")" + requested_user="${2:-}" + has_value adguard_password || return 1 + bound_url="$(get_value adguard_api_url)" || return 1 + bound_user="$(get_value adguard_username)" || return 1 + [ "$requested_url" = "$(normalize_api_url "$bound_url")" ] && [ "$requested_user" = "$bound_user" ] +} + +adguard_password_for() { + matches_adguard "${1:-}" "${2:-}" || return 1 + get_required_value adguard_password +} + +write_migration_update() ( + authkey="$1" + password="$2" + api_url="$3" + username="$4" + auth_present="$5" + adguard_present="$6" + dir="$(secrets_dir)" + auth_file="$(mktemp "$dir/.luci-migrate-auth.XXXXXX")" + password_file="$(mktemp "$dir/.luci-migrate-password.XXXXXX")" + api_url_file="$(mktemp "$dir/.luci-migrate-url.XXXXXX")" + username_file="$(mktemp "$dir/.luci-migrate-user.XXXXXX")" + update_file="$(mktemp "$dir/.luci-secrets-update.XXXXXX")" + trap 'rm -f "$auth_file" "$password_file" "$api_url_file" "$username_file" "$update_file"' 0 1 2 15 + printf '%s' "$authkey" >"$auth_file" + printf '%s' "$password" >"$password_file" + printf '%s' "$api_url" >"$api_url_file" + printf '%s' "$username" >"$username_file" + "$JQ_CMD" -nc \ + --arg auth_present "$auth_present" \ + --arg adguard_present "$adguard_present" \ + --rawfile authkey "$auth_file" \ + --rawfile password "$password_file" \ + --rawfile api_url "$api_url_file" \ + --rawfile username "$username_file" \ + '(if $auth_present == "1" and ($authkey | length > 0) then {authkey:$authkey} else {} end) + + (if $adguard_present == "1" and ($password | length > 0) then { + adguard_password:$password, + adguard_api_url:$api_url, + adguard_username:$username + } else {} end)' >"$update_file" + rm -f "$auth_file" "$password_file" "$api_url_file" "$username_file" + printf '%s\n' "$update_file" + trap - 0 1 2 15 +) + +migrate_legacy() { + ensure_store + auth_present=0 + adguard_present=0 + legacy_authkey= + legacy_adguard_password= + if legacy_authkey="$("$UCI_CMD" -q get tailscale.settings.authkey 2>/dev/null)"; then + auth_present=1 + fi + if legacy_adguard_password="$("$UCI_CMD" -q get tailscale.settings.adguard_password 2>/dev/null)"; then + adguard_present=1 + fi + legacy_api_url="$("$UCI_CMD" -q get tailscale.settings.adguard_api_url 2>/dev/null || true)" + legacy_username="$("$UCI_CMD" -q get tailscale.settings.adguard_username 2>/dev/null || true)" + legacy_api_url="$(normalize_api_url "$legacy_api_url")" + ref="$(active_ref)" + + if { [ "$auth_present" = "1" ] && [ -n "$legacy_authkey" ]; } || \ + { [ "$adguard_present" = "1" ] && [ -n "$legacy_adguard_password" ]; }; then + update_file="$(write_migration_update \ + "$legacy_authkey" \ + "$legacy_adguard_password" \ + "$legacy_api_url" \ + "$legacy_username" \ + "$auth_present" \ + "$adguard_present")" + trap 'rm -f "$update_file"' 0 1 2 15 + ref="$(stage_update "$update_file" "$ref")" + rm -f "$update_file" + trap - 0 1 2 15 + fi + + configured_ref="$("$UCI_CMD" -q get tailscale.settings.secrets_ref 2>/dev/null || true)" + changed=0 + if [ -z "$configured_ref" ] || [ "$auth_present" = "1" ] || [ "$adguard_present" = "1" ]; then + "$UCI_CMD" -q set "tailscale.settings.secrets_ref=$ref" || fail "failed to stage protected credential reference" + changed=1 + fi + if [ "$auth_present" = "1" ]; then + "$UCI_CMD" -q delete tailscale.settings.authkey || true + changed=1 + fi + if [ "$adguard_present" = "1" ]; then + "$UCI_CMD" -q delete tailscale.settings.adguard_password || true + changed=1 + fi + [ "$changed" = "0" ] || "$UCI_CMD" commit tailscale || fail "failed to commit protected credential migration" + activate_ref "$ref" +} + +command="${1:-}" +case "$command" in + get|has|active-ref|authkey-pending|clear-authkey|set-authkey|stage-authkey|set-adguard|set-batch|stage-batch|activate|matches-adguard|adguard-password-for|migrate) + lock_secrets + ;; + *) + fail "usage: $0 get|has|active-ref|authkey-pending|clear-authkey|set-authkey|stage-authkey|set-adguard|set-batch|stage-batch|activate|matches-adguard|adguard-password-for|migrate" + ;; +esac + +case "$command" in + get) + get_required_value "${2:-}" + ;; + has) + has_value "${2:-}" + ;; + active-ref) + active_ref + ;; + authkey-pending) + authkey_pending + ;; + clear-authkey) + clear_authkey + ;; + set-authkey) + set_authkey + ;; + stage-authkey) + stage_authkey "${2:-}" + ;; + set-adguard) + set_adguard + ;; + set-batch) + set_batch + ;; + stage-batch) + stage_batch "${2:-}" + ;; + activate) + activate_ref "${2:-}" + ;; + matches-adguard) + matches_adguard "${2:-}" "${3:-}" + ;; + adguard-password-for) + adguard_password_for "${2:-}" "${3:-}" + ;; + migrate) + migrate_legacy + ;; +esac diff --git a/luci-app-tailscale/root/usr/share/luci/menu.d/luci-app-tailscale.json b/luci-app-tailscale/root/usr/share/luci/menu.d/luci-app-tailscale.json new file mode 100644 index 000000000..12a0a4ae4 --- /dev/null +++ b/luci-app-tailscale/root/usr/share/luci/menu.d/luci-app-tailscale.json @@ -0,0 +1,45 @@ +{ + "admin/vpn/tailscale": { + "title": "Tailscale", + "order": 90, + "action": { + "type": "firstchild" + }, + "depends": { + "acl": [ "luci-app-tailscale" ], + "uci": { "tailscale": true } + } + }, + "admin/vpn/tailscale/setting": { + "title": "Global Settings", + "order": 10, + "action": { + "type": "view", + "path": "tailscale/setting" + } + }, + "admin/vpn/tailscale/interface": { + "title": "Interface Info", + "order": 20, + "action": { + "type": "view", + "path": "tailscale/interface" + } + }, + "admin/vpn/tailscale/peers": { + "title": "Peers", + "order": 25, + "action": { + "type": "view", + "path": "tailscale/peers" + } + }, + "admin/vpn/tailscale/log": { + "title": "Logs", + "order": 30, + "action": { + "type": "view", + "path": "tailscale/log" + } + } +} diff --git a/luci-app-tailscale/root/usr/share/rpcd/acl.d/luci-app-tailscale.json b/luci-app-tailscale/root/usr/share/rpcd/acl.d/luci-app-tailscale.json new file mode 100644 index 000000000..afbdb7eed --- /dev/null +++ b/luci-app-tailscale/root/usr/share/rpcd/acl.d/luci-app-tailscale.json @@ -0,0 +1,26 @@ +{ + "luci-app-tailscale": { + "description": "Grant access to Tailscale configuration", + "read": { + "file": { + "/sbin/ip -s -j ad": [ "exec" ], + "/sbin/logread -e tailscale": [ "exec" ], + "/usr/sbin/tailscale status --json": [ "exec" ], + "/usr/sbin/tailscale_peer_probe": [ "exec" ], + "/usr/sbin/tailscale login": [ "exec" ], + "/usr/sbin/tailscale logout": [ "exec" ] + }, + "ubus": { + "luci.tailscale": [ "secret_status", "openclash_bypass_status", "policy_routing_status" ], + "service": [ "list" ] + }, + "uci": [ "tailscale", "tailscale_openclash", "tailscale_policy_routing" ] + }, + "write": { + "ubus": { + "luci.tailscale": [ "adguard_preflight", "set_secrets" ] + }, + "uci": [ "tailscale", "tailscale_openclash", "tailscale_policy_routing" ] + } + } +} diff --git a/luci-app-tailscale/tests/log_view_test.js b/luci-app-tailscale/tests/log_view_test.js new file mode 100644 index 000000000..54cfec234 --- /dev/null +++ b/luci-app-tailscale/tests/log_view_test.js @@ -0,0 +1,40 @@ +const fs = require('fs'); +const vm = require('vm'); + +const source = fs.readFileSync('htdocs/luci-static/resources/view/tailscale/log.js', 'utf8'); +const helperSource = source.split('return view.extend')[0] + .split('\n') + .filter(line => !line.startsWith("'require ") && !line.startsWith("'use strict'")) + .join('\n'); + +const context = { + module: { exports: {} }, + console +}; + +vm.runInNewContext(`${helperSource}\nmodule.exports = { formatLogLines, formatLogError };`, context); + +const { formatLogLines, formatLogError } = context.module.exports; + +function assert(condition, message) { + if (!condition) + throw new Error(message); +} + +let result = formatLogLines(''); +assert(result.value === 'Log is empty.', `unexpected empty log value: ${result.value}`); +assert(result.rows === 2, `unexpected empty log rows: ${result.rows}`); + +result = formatLogError(new Error('Failed to find log object: Not found')); +assert( + result.value === 'Unable to load log data: Failed to find log object: Not found', + `unexpected log error value: ${result.value}` +); +assert(result.rows === 2, `unexpected log error rows: ${result.rows}`); + +const sample = 'Thu Jul 16 00:21:29 2026 daemon.notice tailscaled[123]: active\\n'; +result = formatLogLines(sample); +assert(result.value.includes('[ Info ]'), `expected notice to map to Info: ${result.value}`); +assert(result.value.includes('active'), `expected message body: ${result.value}`); + +console.log('log view tests passed'); diff --git a/luci-app-tailscale/tests/package_release_test.sh b/luci-app-tailscale/tests/package_release_test.sh new file mode 100644 index 000000000..5672ecfdd --- /dev/null +++ b/luci-app-tailscale/tests/package_release_test.sh @@ -0,0 +1,789 @@ +#!/bin/sh + +set -eu + +ROOT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)" + +fail() { + echo "FAIL: $*" >&2 + exit 1 +} + +assert_file() { + [ -f "$ROOT_DIR/$1" ] || fail "missing required package file: $1" +} + +assert_contains() { + needle="$1" + file="$2" + grep -F -- "$needle" "$ROOT_DIR/$file" >/dev/null || fail "$file should contain: $needle" +} + +assert_po_entry() { + expected_msgid="$1" + expected_msgstr="$2" + file="$3" + case "$file" in + /*) po_file="$file" ;; + *) po_file="$ROOT_DIR/$file" ;; + esac + + awk -v expected_msgid="$expected_msgid" -v expected_msgstr="$expected_msgstr" ' + function quoted_value(line, value) { + value = line + sub(/^[^"]*"/, "", value) + sub(/"$/, "", value) + return value + } + function finish_entry() { + if (has_msgid && has_msgstr && msgid == expected_msgid && msgstr == expected_msgstr) + matched = 1 + msgid = "" + msgstr = "" + active_field = "" + has_msgid = 0 + has_msgstr = 0 + } + /^[[:space:]]*$/ { + finish_entry() + next + } + /^msgid "/ { + if (has_msgid || has_msgstr) + finish_entry() + msgid = quoted_value($0) + has_msgid = 1 + active_field = "msgid" + next + } + /^msgstr "/ { + msgstr = quoted_value($0) + has_msgstr = 1 + active_field = "msgstr" + next + } + /^"/ { + if (active_field == "msgid") + msgid = msgid quoted_value($0) + else if (active_field == "msgstr") + msgstr = msgstr quoted_value($0) + next + } + { + active_field = "" + } + END { + finish_entry() + exit !matched + } + ' "$po_file" || fail "$file should contain PO entry: msgid $expected_msgid with msgstr $expected_msgstr" +} + +assert_po_entry_mutation_test() { + mutated_po="$(mktemp "$ROOT_DIR/tests/package_release_test.XXXXXX")" + trap 'rm -f "$mutated_po"' 0 HUP INT TERM + awk ' + $0 == "msgstr \"已开启,4 条绕过规则已生效\"" { print "msgstr \"已关闭,Tailscale 流量将由 OpenClash 处理\""; next } + $0 == "msgstr \"已关闭,Tailscale 流量将由 OpenClash 处理\"" { print "msgstr \"已开启,4 条绕过规则已生效\""; next } + { print } + ' "$ROOT_DIR/po/zh_Hans/tailscale.po" > "$mutated_po" + + if (assert_po_entry "Enabled; 4 bypass rules are active" "已开启,4 条绕过规则已生效" "$mutated_po") >/dev/null 2>&1; then + fail "assert_po_entry must reject swapped PO translations" + fi + + rm -f "$mutated_po" +} + +assert_not_contains() { + needle="$1" + file="$2" + if grep -F -- "$needle" "$ROOT_DIR/$file" >/dev/null; then + fail "$file should not contain: $needle" + fi +} + +assert_jq() { + filter="$1" + file="$2" + message="$3" + jq -e "$filter" "$ROOT_DIR/$file" >/dev/null || fail "$message" +} + +line_number() { + needle="$1" + file="$2" + grep -nF -- "$needle" "$ROOT_DIR/$file" | head -n 1 | cut -d: -f1 +} + +assert_before() { + first="$1" + second="$2" + file="$3" + first_line="$(line_number "$first" "$file")" + second_line="$(line_number "$second" "$file")" + [ -n "$first_line" ] || fail "$file should contain: $first" + [ -n "$second_line" ] || fail "$file should contain: $second" + [ "$first_line" -lt "$second_line" ] || fail "$file should place '$first' before '$second'" +} + +assert_peer_pagination_after_table() { + file="$1" + awk ' + /return E\('\''div'\'', \{ class: '\''cbi-map'\'' \}/ { in_return = 1 } + in_return && /E\('\''table'\'', \{/ { table_line = NR } + in_return && /^[[:space:]]*paginationBox[[:space:]]*$/ { pagination_line = NR } + END { + if (table_line && pagination_line && table_line < pagination_line) + exit 0 + exit 1 + } + ' "$ROOT_DIR/$file" || fail "$file should render peer pagination below the peer table" +} + +assert_not_exists() { + [ ! -e "$ROOT_DIR/$1" ] || fail "$1 should not exist" +} + +assert_no_active_config_list() { + option="$1" + file="$2" + if grep -E "^[[:space:]]*list[[:space:]]+$option([[:space:]]|$)" "$ROOT_DIR/$file" >/dev/null; then + fail "$file must not ship an active $option default" + fi +} + +assert_release_permissions() { + file="$1" + awk ' + /^permissions:$/ { + global_permissions = 1 + next + } + global_permissions && /^ contents: / { + global_contents = $2 + if ($2 == "write") { + write_count++ + write_outside_release = 1 + } + global_permissions = 0 + next + } + /^jobs:$/ { + in_jobs = 1 + next + } + in_jobs && /^ [A-Za-z0-9_-]+:$/ { + job = $1 + sub(/:$/, "", job) + job_permissions = 0 + next + } + in_jobs && /^ permissions:$/ { + job_permissions = 1 + next + } + in_jobs && job_permissions && /^ contents: / { + job_contents[job] = $2 + if ($2 == "write") { + write_count++ + if (job != "release") + write_outside_release = 1 + } + job_permissions = 0 + next + } + END { + if (global_contents != "read") + exit 1 + if (job_contents["release"] != "write") + exit 1 + if (job_contents["test"] == "write" || job_contents["build"] == "write") + exit 1 + if (write_count != 1 || write_outside_release) + exit 1 + } + ' "$ROOT_DIR/$file" || fail "$file should grant contents: read globally and contents: write only to the release job" +} + +grep -Eq '^PKG_VERSION:=[0-9]+\.[0-9]+\.[0-9]+$' "$ROOT_DIR/Makefile" || \ + fail 'Makefile should declare a semantic package version' +assert_file .github/workflows/release.yml +assert_release_permissions .github/workflows/release.yml +assert_contains "tags:" .github/workflows/release.yml +assert_contains "v*" .github/workflows/release.yml +assert_contains "pull_request:" .github/workflows/release.yml +assert_contains "branches:" .github/workflows/release.yml +assert_contains "main" .github/workflows/release.yml +assert_contains "test:" .github/workflows/release.yml +assert_contains "for test in tests/*_test.sh" .github/workflows/release.yml +assert_contains "for test in tests/*_test.js" .github/workflows/release.yml +assert_contains "for file in root/etc/init.d/tailscale" .github/workflows/release.yml +assert_contains "for file in htdocs/luci-static/resources/view/tailscale/*.js tests/*_test.js" .github/workflows/release.yml +assert_contains "sh -n" .github/workflows/release.yml +assert_contains "node --check" .github/workflows/release.yml +assert_contains "jq -e" .github/workflows/release.yml +assert_contains "msgfmt --check-format" .github/workflows/release.yml +assert_contains "needs: test" .github/workflows/release.yml +assert_contains "sdk_arch: x86_64-24.10.5@sha256:" .github/workflows/release.yml +assert_contains "sdk_arch: x86_64@sha256:" .github/workflows/release.yml +assert_contains "if: startsWith(github.ref, 'refs/tags/')" .github/workflows/release.yml +assert_contains "luci-app-tailscale_*.ipk" .github/workflows/release.yml +assert_contains "luci-app-tailscale-*.apk" .github/workflows/release.yml + +assert_file htdocs/luci-static/resources/view/tailscale/setting.js +assert_file tests/setting_preflight_test.js +assert_contains "refreshAdguardPreflightStatus" htdocs/luci-static/resources/view/tailscale/setting.js +assert_file root/etc/config/tailscale +assert_no_active_config_list adguard_default_upstreams root/etc/config/tailscale +assert_no_active_config_list adguard_tailnet_upstreams root/etc/config/tailscale +assert_not_contains "litata.com" root/etc/config/tailscale +assert_not_contains "litata.tailnet" root/etc/config/tailscale +assert_file root/etc/init.d/tailscale +assert_file root/usr/sbin/tailscale_helper +assert_file root/usr/sbin/tailscale_keepalive +assert_file root/usr/sbin/tailscale_adguard_dns_switch +assert_file root/usr/sbin/tailscale_openclash_bypass +assert_file root/etc/config/tailscale_openclash +assert_file root/etc/init.d/tailscale-openclash-bypass +assert_file root/etc/config/tailscale_policy_routing +assert_file root/etc/init.d/tailscale-policy-routing +assert_file root/etc/hotplug.d/iface/98-tailscale-policy-routing +assert_file root/usr/sbin/tailscale_policy_routing +assert_file root/usr/sbin/tailscale_peer_probe +assert_file root/usr/sbin/tailscale_secrets +assert_file root/lib/upgrade/keep.d/luci-app-tailscale +assert_file root/usr/libexec/rpcd/luci.tailscale +assert_file root/usr/share/rpcd/acl.d/luci-app-tailscale.json +assert_contains "Peer probe" root/usr/sbin/tailscale_peer_probe +assert_contains "LUCI_DEPENDS:=+tailscale +jshn +curl +jq +flock" Makefile +assert_contains "allow_wan_direct" root/etc/config/tailscale +assert_contains "wan_direct_zones" root/etc/config/tailscale +assert_contains "allow_wan_direct" htdocs/luci-static/resources/view/tailscale/setting.js +assert_contains "wan_direct_zones" htdocs/luci-static/resources/view/tailscale/setting.js +assert_contains "Allow WAN Direct" htdocs/luci-static/resources/view/tailscale/setting.js +assert_contains "WAN Direct Source Zones" htdocs/luci-static/resources/view/tailscale/setting.js +assert_contains "ALLOW_WAN_DIRECT" root/etc/init.d/tailscale +assert_contains "WAN_DIRECT_ZONES" root/etc/init.d/tailscale +assert_contains "TAILSCALE_PORT" root/etc/init.d/tailscale +assert_contains "firewall.ts_wan_direct" root/usr/sbin/tailscale_helper +assert_contains "Allow-Tailscale-WAN-" root/usr/sbin/tailscale_helper +assert_contains "WAN_DIRECT_ZONES" root/usr/sbin/tailscale_helper +assert_contains 'firewall.$section.src=$zone' root/usr/sbin/tailscale_helper +assert_contains 'firewall.$section.proto=udp' root/usr/sbin/tailscale_helper +assert_contains 'firewall.$section.dest_port=$port' root/usr/sbin/tailscale_helper +assert_contains 'firewall.$section.target=ACCEPT' root/usr/sbin/tailscale_helper +assert_not_contains "tailscale_openclash_bypass" root/usr/sbin/tailscale_helper +assert_not_contains "openclash_custom_firewall_rules.sh" root/usr/sbin/tailscale_helper +assert_contains 'POLICY_ROUTING_HELPER' root/usr/sbin/tailscale_helper +assert_contains '"$POLICY_ROUTING_HELPER" sync' root/usr/sbin/tailscale_helper +assert_not_contains '/etc/config/firewall' root/usr/sbin/tailscale_policy_routing +assert_not_contains 'uci commit firewall' root/usr/sbin/tailscale_policy_routing +assert_not_contains '/etc/init.d/firewall' root/usr/sbin/tailscale_policy_routing +assert_not_contains '/etc/init.d/openclash' root/usr/sbin/tailscale_policy_routing +assert_not_contains '"$MWAN3_INIT" reload' root/usr/sbin/tailscale_policy_routing +assert_not_contains '"$MWAN3_INIT" restart' root/usr/sbin/tailscale_policy_routing +assert_not_contains '"$MWAN3_INIT" start' root/usr/sbin/tailscale_policy_routing +assert_not_contains '"$MWAN3_INIT" stop' root/usr/sbin/tailscale_policy_routing +assert_not_contains 'mwan3 restart' root/usr/sbin/tailscale_policy_routing +assert_contains 'network.ts_mwan3_table52' root/usr/sbin/tailscale_policy_routing +assert_contains "PRIORITY='1000'" root/usr/sbin/tailscale_policy_routing +assert_contains "TABLE='52'" root/usr/sbin/tailscale_policy_routing +assert_not_contains "/etc/config/firewall" root/usr/sbin/tailscale_openclash_bypass +assert_not_contains "uci commit firewall" root/usr/sbin/tailscale_openclash_bypass +assert_not_contains "/etc/init.d/firewall" root/usr/sbin/tailscale_openclash_bypass +assert_not_contains "/etc/init.d/openclash reload" root/usr/sbin/tailscale_openclash_bypass +assert_not_contains "/etc/init.d/openclash restart" root/usr/sbin/tailscale_openclash_bypass +assert_contains 'msgid "OpenClash"' po/templates/tailscale.pot +assert_contains 'msgid "Protect Tailscale Traffic (Bypass OpenClash)"' po/templates/tailscale.pot +assert_contains 'msgid "Status"' po/templates/tailscale.pot +assert_contains 'msgid "Checking ..."' po/templates/tailscale.pot +assert_contains 'msgid "Enabled; 4 bypass rules are active"' po/templates/tailscale.pot +assert_contains 'msgid "Enabled; waiting for OpenClash nftables chains"' po/templates/tailscale.pot +assert_contains 'msgid "Disabled; Tailscale traffic is handled by OpenClash"' po/templates/tailscale.pot +assert_contains 'msgid "OpenClash is not installed; bypass is not required"' po/templates/tailscale.pot +assert_contains 'msgid "Unsupported; firewall4/nftables is required"' po/templates/tailscale.pot +assert_contains 'msgid "Configuration error"' po/templates/tailscale.pot +assert_contains 'msgid "Unknown status"' po/templates/tailscale.pot +assert_contains 'msgid "Unable to read OpenClash bypass status."' po/templates/tailscale.pot +assert_contains 'msgid "Keep Tailscale control connections, direct connections, Tailnet DNS, and subnet traffic outside OpenClash. When disabled, this traffic is handled by OpenClash; node connectivity, direct paths, subnet access, and internal DNS are no longer protected by this feature. Keep this enabled while using OpenClash."' po/templates/tailscale.pot +assert_contains 'msgid "Prioritize Tailscale Routes Before mwan3"' po/templates/tailscale.pot +assert_contains 'msgid "Policy Routing Status"' po/templates/tailscale.pot +assert_contains 'msgid "Enabled; mwan3 is not installed"' po/templates/tailscale.pot +assert_contains 'msgid "Enabled; no earlier mwan3-marked rule was detected"' po/templates/tailscale.pot +assert_contains 'msgid "Enabled; Tailscale routes take precedence over mwan3"' po/templates/tailscale.pot +assert_contains 'msgid "Unable to read Tailscale policy-routing status."' po/templates/tailscale.pot +assert_po_entry "OpenClash" "OpenClash" po/zh_Hans/tailscale.po +assert_po_entry "Protect Tailscale Traffic (Bypass OpenClash)" "保护 Tailscale 流量(绕过 OpenClash)" po/zh_Hans/tailscale.po +assert_po_entry "Status" "状态" po/zh_Hans/tailscale.po +assert_po_entry "Checking ..." "检查中..." po/zh_Hans/tailscale.po +assert_po_entry "Enabled; 4 bypass rules are active" "已开启,4 条绕过规则已生效" po/zh_Hans/tailscale.po +assert_po_entry "Enabled; waiting for OpenClash nftables chains" "已开启,正在等待 OpenClash 创建 nftables 链" po/zh_Hans/tailscale.po +assert_po_entry "Disabled; Tailscale traffic is handled by OpenClash" "已关闭,Tailscale 流量将由 OpenClash 处理" po/zh_Hans/tailscale.po +assert_po_entry "OpenClash is not installed; bypass is not required" "未安装 OpenClash,无需绕过" po/zh_Hans/tailscale.po +assert_po_entry "Unsupported; firewall4/nftables is required" "当前系统不支持,仅支持 firewall4/nftables" po/zh_Hans/tailscale.po +assert_po_entry "Configuration error" "配置错误" po/zh_Hans/tailscale.po +assert_po_entry "Unknown status" "未知状态" po/zh_Hans/tailscale.po +assert_po_entry "Unable to read OpenClash bypass status." "无法读取 OpenClash 绕过状态。" po/zh_Hans/tailscale.po +assert_po_entry "Keep Tailscale control connections, direct connections, Tailnet DNS, and subnet traffic outside OpenClash. When disabled, this traffic is handled by OpenClash; node connectivity, direct paths, subnet access, and internal DNS are no longer protected by this feature. Keep this enabled while using OpenClash." "开启后,Tailscale 控制连接、直连通信、Tailnet DNS 和跨子网流量不会被 OpenClash 接管。关闭后,这些流量将重新经过 OpenClash,节点在线、点对点直连、跨子网访问和 Tailnet DNS 解析不再受本功能保护;如果 OpenClash 规则接管或重定向这些流量,可能导致节点掉线、直连退化为 DERP、跨子网访问或 Tailnet DNS 中断。使用 OpenClash 时必须保持开启。" po/zh_Hans/tailscale.po +assert_po_entry "Prioritize Tailscale Routes Before mwan3" "优先让 Tailscale 路由先于 mwan3" po/zh_Hans/tailscale.po +assert_po_entry "Policy Routing Status" "策略路由状态" po/zh_Hans/tailscale.po +assert_po_entry "Enabled; mwan3 is not installed" "已开启,未安装 mwan3" po/zh_Hans/tailscale.po +assert_po_entry "Enabled; no earlier mwan3-marked rule was detected" "已开启,未检测到更早的 mwan3 标记规则" po/zh_Hans/tailscale.po +assert_po_entry "Enabled; Tailscale routes take precedence over mwan3" "已开启,Tailscale 路由优先于 mwan3" po/zh_Hans/tailscale.po +assert_po_entry "Unable to read Tailscale policy-routing status." "无法读取 Tailscale 策略路由状态。" po/zh_Hans/tailscale.po +assert_po_entry "OpenClash" "OpenClash" po/zh_Hant/tailscale.po +assert_po_entry "Protect Tailscale Traffic (Bypass OpenClash)" "保護 Tailscale 流量(繞過 OpenClash)" po/zh_Hant/tailscale.po +assert_po_entry "Status" "狀態" po/zh_Hant/tailscale.po +assert_po_entry "Checking ..." "檢查中..." po/zh_Hant/tailscale.po +assert_po_entry "Enabled; 4 bypass rules are active" "已開啟,4 條繞過規則已生效" po/zh_Hant/tailscale.po +assert_po_entry "Enabled; waiting for OpenClash nftables chains" "已開啟,正在等待 OpenClash 建立 nftables 鏈" po/zh_Hant/tailscale.po +assert_po_entry "Disabled; Tailscale traffic is handled by OpenClash" "已關閉,Tailscale 流量將由 OpenClash 處理" po/zh_Hant/tailscale.po +assert_po_entry "OpenClash is not installed; bypass is not required" "未安裝 OpenClash,無需繞過" po/zh_Hant/tailscale.po +assert_po_entry "Unsupported; firewall4/nftables is required" "目前系統不支援,僅支援 firewall4/nftables" po/zh_Hant/tailscale.po +assert_po_entry "Configuration error" "設定錯誤" po/zh_Hant/tailscale.po +assert_po_entry "Unknown status" "未知狀態" po/zh_Hant/tailscale.po +assert_po_entry "Unable to read OpenClash bypass status." "無法讀取 OpenClash 繞過狀態。" po/zh_Hant/tailscale.po +assert_po_entry "Keep Tailscale control connections, direct connections, Tailnet DNS, and subnet traffic outside OpenClash. When disabled, this traffic is handled by OpenClash; node connectivity, direct paths, subnet access, and internal DNS are no longer protected by this feature. Keep this enabled while using OpenClash." "開啟後,Tailscale 控制連線、直連通訊、Tailnet DNS 和跨子網流量不會被 OpenClash 接管。關閉後,這些流量將重新經過 OpenClash,節點在線、點對點直連、跨子網存取和 Tailnet DNS 解析不再受本功能保護;如果 OpenClash 規則接管或重新導向這些流量,可能導致節點離線、直連退化為 DERP、跨子網存取或 Tailnet DNS 中斷。使用 OpenClash 時必須保持開啟。" po/zh_Hant/tailscale.po +assert_po_entry "Prioritize Tailscale Routes Before mwan3" "優先讓 Tailscale 路由先於 mwan3" po/zh_Hant/tailscale.po +assert_po_entry "Policy Routing Status" "策略路由狀態" po/zh_Hant/tailscale.po +assert_po_entry "Enabled; mwan3 is not installed" "已開啟,未安裝 mwan3" po/zh_Hant/tailscale.po +assert_po_entry "Enabled; no earlier mwan3-marked rule was detected" "已開啟,未偵測到較早的 mwan3 標記規則" po/zh_Hant/tailscale.po +assert_po_entry "Enabled; Tailscale routes take precedence over mwan3" "已開啟,Tailscale 路由優先於 mwan3" po/zh_Hant/tailscale.po +assert_po_entry "Unable to read Tailscale policy-routing status." "無法讀取 Tailscale 策略路由狀態。" po/zh_Hant/tailscale.po +assert_po_entry_mutation_test +assert_contains 'msgid "Allow WAN Direct"' po/templates/tailscale.pot +assert_contains 'msgid "WAN Direct Source Zones"' po/templates/tailscale.pot +assert_contains 'msgid "Allow WAN Direct"' po/zh_Hans/tailscale.po +assert_contains 'msgstr "允许 WAN 直连"' po/zh_Hans/tailscale.po +assert_contains 'msgid "WAN Direct Source Zones"' po/zh_Hans/tailscale.po +assert_contains 'msgstr "WAN 直连来源区域"' po/zh_Hans/tailscale.po +assert_contains 'msgid "Allow WAN Direct"' po/zh_Hant/tailscale.po +assert_contains 'msgstr "允許 WAN 直連"' po/zh_Hant/tailscale.po +assert_contains 'msgid "WAN Direct Source Zones"' po/zh_Hant/tailscale.po +assert_contains 'msgstr "WAN 直連來源區域"' po/zh_Hant/tailscale.po +assert_contains '"$JQ_BIN" -nc' root/usr/sbin/tailscale_peer_probe +assert_contains "--argjson ok" root/usr/sbin/tailscale_peer_probe +assert_contains "ping --c=1 --timeout=2s" root/usr/sbin/tailscale_peer_probe +assert_not_contains "json_escape()" root/usr/sbin/tailscale_peer_probe +assert_file root/etc/uci-defaults/40_luci-tailscale +assert_file tests/tailscale_legacy_adguard_migration_test.sh +assert_contains '"admin/vpn/tailscale/peers"' root/usr/share/luci/menu.d/luci-app-tailscale.json +assert_contains '"title": "Peers"' root/usr/share/luci/menu.d/luci-app-tailscale.json +assert_contains '"path": "tailscale/peers"' root/usr/share/luci/menu.d/luci-app-tailscale.json +assert_before '"admin/vpn/tailscale/interface"' '"admin/vpn/tailscale/peers"' root/usr/share/luci/menu.d/luci-app-tailscale.json +assert_before '"admin/vpn/tailscale/peers"' '"admin/vpn/tailscale/log"' root/usr/share/luci/menu.d/luci-app-tailscale.json +assert_contains 'msgid "Peers"' po/templates/tailscale.pot +assert_contains 'msgid "Peers"' po/zh_Hans/tailscale.po +assert_contains 'msgstr "对端列表"' po/zh_Hans/tailscale.po +assert_contains 'msgid "Peers"' po/zh_Hant/tailscale.po +assert_contains 'msgstr "對端列表"' po/zh_Hant/tailscale.po +assert_contains 'msgid "Tailscale Peers"' po/templates/tailscale.pot +assert_contains 'msgid "Tailscale Peers"' po/zh_Hans/tailscale.po +assert_contains 'msgstr "Tailscale 对端"' po/zh_Hans/tailscale.po +assert_contains 'msgid "Tailscale Peers"' po/zh_Hant/tailscale.po +assert_contains 'msgstr "Tailscale 對端"' po/zh_Hant/tailscale.po +assert_contains 'msgid "View all peers and manually probe whether traffic is direct or relayed through DERP."' po/templates/tailscale.pot +assert_contains 'msgid "View all peers and manually probe whether traffic is direct or relayed through DERP."' po/zh_Hans/tailscale.po +assert_contains 'msgstr "查看所有对端,并手动探测流量是直连还是通过 DERP 中继。"' po/zh_Hans/tailscale.po +assert_contains 'msgid "View all peers and manually probe whether traffic is direct or relayed through DERP."' po/zh_Hant/tailscale.po +assert_contains 'msgstr "查看所有對端,並手動探測流量是直連還是透過 DERP 中繼。"' po/zh_Hant/tailscale.po +assert_contains 'msgid "Unable to load Tailscale peer status"' po/templates/tailscale.pot +assert_contains 'msgid "Unable to load Tailscale peer status"' po/zh_Hans/tailscale.po +assert_contains 'msgstr "无法加载 Tailscale 对端状态。"' po/zh_Hans/tailscale.po +assert_contains 'msgid "Unable to load Tailscale peer status"' po/zh_Hant/tailscale.po +assert_contains 'msgstr "無法載入 Tailscale 對端狀態。"' po/zh_Hant/tailscale.po +assert_contains 'msgid "No peers match the selected filter."' po/templates/tailscale.pot +assert_contains 'msgid "No peers match the selected filter."' po/zh_Hans/tailscale.po +assert_contains 'msgstr "没有对端符合所选筛选条件。"' po/zh_Hans/tailscale.po +assert_contains 'msgid "No peers match the selected filter."' po/zh_Hant/tailscale.po +assert_contains 'msgstr "沒有對端符合所選篩選條件。"' po/zh_Hant/tailscale.po +assert_contains "No peers found" htdocs/luci-static/resources/view/tailscale/peers.js +assert_contains 'msgid "No peers found"' po/templates/tailscale.pot +assert_contains 'msgid "No peers found"' po/zh_Hans/tailscale.po +assert_contains 'msgstr "没有找到对端"' po/zh_Hans/tailscale.po +assert_contains 'msgid "No peers found"' po/zh_Hant/tailscale.po +assert_contains 'msgstr "沒有找到對端"' po/zh_Hant/tailscale.po +assert_contains 'msgid "No probe target available"' po/templates/tailscale.pot +assert_contains 'msgid "No probe target available"' po/zh_Hans/tailscale.po +assert_contains 'msgstr "没有可用探测目标。"' po/zh_Hans/tailscale.po +assert_contains 'msgid "No probe target available"' po/zh_Hant/tailscale.po +assert_contains 'msgstr "沒有可用探測目標。"' po/zh_Hant/tailscale.po +assert_contains 'msgid "Probe failed"' po/templates/tailscale.pot +assert_contains 'msgid "Probe failed"' po/zh_Hans/tailscale.po +assert_contains 'msgstr "探测失败"' po/zh_Hans/tailscale.po +assert_contains 'msgid "Probe failed"' po/zh_Hant/tailscale.po +assert_contains 'msgstr "探測失敗"' po/zh_Hant/tailscale.po +assert_contains 'msgid "Not probed"' po/templates/tailscale.pot +assert_contains 'msgid "Not probed"' po/zh_Hans/tailscale.po +assert_contains 'msgstr "未探测"' po/zh_Hans/tailscale.po +assert_contains 'msgid "Not probed"' po/zh_Hant/tailscale.po +assert_contains 'msgstr "未探測"' po/zh_Hant/tailscale.po +assert_contains 'msgid "Probing..."' po/templates/tailscale.pot +assert_contains 'msgid "Probing..."' po/zh_Hans/tailscale.po +assert_contains 'msgstr "探测中..."' po/zh_Hans/tailscale.po +assert_contains 'msgid "Probing..."' po/zh_Hant/tailscale.po +assert_contains 'msgstr "探測中..."' po/zh_Hant/tailscale.po +assert_contains 'msgid "Probe"' po/templates/tailscale.pot +assert_contains 'msgid "Probe"' po/zh_Hans/tailscale.po +assert_contains 'msgstr "探测"' po/zh_Hans/tailscale.po +assert_contains 'msgid "Probe"' po/zh_Hant/tailscale.po +assert_contains 'msgstr "探測"' po/zh_Hant/tailscale.po +assert_contains 'msgid "Direct"' po/templates/tailscale.pot +assert_contains 'msgid "Direct"' po/zh_Hans/tailscale.po +assert_contains 'msgstr "直连"' po/zh_Hans/tailscale.po +assert_contains 'msgid "Direct"' po/zh_Hant/tailscale.po +assert_contains 'msgstr "直連"' po/zh_Hant/tailscale.po +assert_contains 'msgid "DERP"' po/templates/tailscale.pot +assert_contains 'msgid "DERP"' po/zh_Hans/tailscale.po +assert_contains 'msgstr "DERP 中继"' po/zh_Hans/tailscale.po +assert_contains 'msgid "DERP"' po/zh_Hant/tailscale.po +assert_contains 'msgstr "DERP 中繼"' po/zh_Hant/tailscale.po +assert_contains 'msgid "Failed"' po/templates/tailscale.pot +assert_contains 'msgid "Failed"' po/zh_Hans/tailscale.po +assert_contains 'msgstr "失败"' po/zh_Hans/tailscale.po +assert_contains 'msgid "Failed"' po/zh_Hant/tailscale.po +assert_contains 'msgstr "失敗"' po/zh_Hant/tailscale.po +assert_contains 'msgid "Unknown"' po/templates/tailscale.pot +assert_contains 'msgid "Unknown"' po/zh_Hans/tailscale.po +assert_contains 'msgstr "未知"' po/zh_Hans/tailscale.po +assert_contains 'msgid "Unknown"' po/zh_Hant/tailscale.po +assert_contains 'msgstr "未知"' po/zh_Hant/tailscale.po +assert_contains 'msgid "Filter"' po/templates/tailscale.pot +assert_contains 'msgid "Filter"' po/zh_Hans/tailscale.po +assert_contains 'msgstr "筛选"' po/zh_Hans/tailscale.po +assert_contains 'msgid "Filter"' po/zh_Hant/tailscale.po +assert_contains 'msgstr "篩選"' po/zh_Hant/tailscale.po +assert_contains 'msgid "All"' po/templates/tailscale.pot +assert_contains 'msgid "All"' po/zh_Hans/tailscale.po +assert_contains 'msgstr "全部"' po/zh_Hans/tailscale.po +assert_contains 'msgid "All"' po/zh_Hant/tailscale.po +assert_contains 'msgstr "全部"' po/zh_Hant/tailscale.po +assert_contains 'msgid "Online"' po/templates/tailscale.pot +assert_contains 'msgid "Online"' po/zh_Hans/tailscale.po +assert_contains 'msgstr "在线"' po/zh_Hans/tailscale.po +assert_contains 'msgid "Online"' po/zh_Hant/tailscale.po +assert_contains 'msgstr "在線"' po/zh_Hant/tailscale.po +assert_contains 'msgid "Offline"' po/templates/tailscale.pot +assert_contains 'msgid "Offline"' po/zh_Hans/tailscale.po +assert_contains 'msgstr "离线"' po/zh_Hans/tailscale.po +assert_contains 'msgid "Offline"' po/zh_Hant/tailscale.po +assert_contains 'msgstr "離線"' po/zh_Hant/tailscale.po +assert_contains 'msgid "Name"' po/templates/tailscale.pot +assert_contains 'msgid "Name"' po/zh_Hans/tailscale.po +assert_contains 'msgstr "名称"' po/zh_Hans/tailscale.po +assert_contains 'msgid "Name"' po/zh_Hant/tailscale.po +assert_contains 'msgstr "名稱"' po/zh_Hant/tailscale.po +assert_contains 'msgid "Tailnet IP"' po/templates/tailscale.pot +assert_contains 'msgid "Tailnet IP"' po/zh_Hans/tailscale.po +assert_contains 'msgstr "Tailnet IP"' po/zh_Hans/tailscale.po +assert_contains 'msgid "Tailnet IP"' po/zh_Hant/tailscale.po +assert_contains 'msgstr "Tailnet IP"' po/zh_Hant/tailscale.po +assert_contains 'msgid "Last Seen"' po/templates/tailscale.pot +assert_contains 'msgid "Last Seen"' po/zh_Hans/tailscale.po +assert_contains 'msgstr "最后在线"' po/zh_Hans/tailscale.po +assert_contains 'msgid "Last Seen"' po/zh_Hant/tailscale.po +assert_contains 'msgstr "最後在線"' po/zh_Hant/tailscale.po +assert_contains 'msgid "Role"' po/templates/tailscale.pot +assert_contains 'msgid "Role"' po/zh_Hans/tailscale.po +assert_contains 'msgstr "角色"' po/zh_Hans/tailscale.po +assert_contains 'msgid "Role"' po/zh_Hant/tailscale.po +assert_contains 'msgstr "角色"' po/zh_Hant/tailscale.po +assert_contains 'msgid "Exit node"' po/templates/tailscale.pot +assert_contains 'msgid "Exit node"' po/zh_Hans/tailscale.po +assert_contains 'msgstr "出口节点"' po/zh_Hans/tailscale.po +assert_contains 'msgid "Exit node"' po/zh_Hant/tailscale.po +assert_contains 'msgstr "出口節點"' po/zh_Hant/tailscale.po +assert_contains 'msgid "Advertising subnets"' po/templates/tailscale.pot +assert_contains 'msgid "Advertising subnets"' po/zh_Hans/tailscale.po +assert_contains 'msgstr "发布网段"' po/zh_Hans/tailscale.po +assert_contains 'msgid "Advertising subnets"' po/zh_Hant/tailscale.po +assert_contains 'msgstr "發布網段"' po/zh_Hant/tailscale.po +assert_contains 'msgid "Advertised Subnets"' po/templates/tailscale.pot +assert_contains 'msgid "Advertised Subnets"' po/zh_Hans/tailscale.po +assert_contains 'msgstr "已发布网段"' po/zh_Hans/tailscale.po +assert_contains 'msgid "Advertised Subnets"' po/zh_Hant/tailscale.po +assert_contains 'msgstr "已發布網段"' po/zh_Hant/tailscale.po + +assert_contains "Peer Keepalive" htdocs/luci-static/resources/view/tailscale/setting.js +assert_contains "AdGuard DNS" htdocs/luci-static/resources/view/tailscale/setting.js +assert_before "AdGuard API URL" "_adguard_dns_status" htdocs/luci-static/resources/view/tailscale/setting.js +assert_before "AdGuard Username" "_adguard_dns_status" htdocs/luci-static/resources/view/tailscale/setting.js +assert_before "AdGuard Password" "_adguard_dns_status" htdocs/luci-static/resources/view/tailscale/setting.js +assert_before "_adguard_dns_status" "Enable AdGuard DNS Auto Switch" htdocs/luci-static/resources/view/tailscale/setting.js +assert_contains "ADGUARD_PREFLIGHT_CHECKS" htdocs/luci-static/resources/view/tailscale/setting.js +assert_contains "window.setTimeout(refreshAdguardPreflightStatus" htdocs/luci-static/resources/view/tailscale/setting.js +assert_contains 'msgid "Checking ..."' po/templates/tailscale.pot +assert_po_entry "Checking ..." "检查中..." po/zh_Hans/tailscale.po +assert_po_entry "Checking ..." "檢查中..." po/zh_Hant/tailscale.po +assert_not_contains "AdGuard DNS auto switch cannot be enabled until every environment status check passes." htdocs/luci-static/resources/view/tailscale/setting.js +assert_contains "keepalivePeerAliases" htdocs/luci-static/resources/view/tailscale/setting.js +assert_contains "shortDnsName" htdocs/luci-static/resources/view/tailscale/setting.js +assert_contains "hasSubnetRoutes" htdocs/luci-static/resources/view/tailscale/setting.js +assert_contains "No subnet routes" htdocs/luci-static/resources/view/tailscale/setting.js +assert_contains "s.tab('keepalive'" htdocs/luci-static/resources/view/tailscale/setting.js +assert_contains "form.Value.extend" htdocs/luci-static/resources/view/tailscale/setting.js +assert_contains "KeepalivePeersValue, 'keepalive_peers'" htdocs/luci-static/resources/view/tailscale/setting.js +assert_contains "keepalive-peer-list" htdocs/luci-static/resources/view/tailscale/setting.js +assert_contains "Only peers advertising subnet routes are shown" htdocs/luci-static/resources/view/tailscale/setting.js +assert_contains "keepalive-peer-row" htdocs/luci-static/resources/view/tailscale/setting.js +assert_contains "max-width:680px;width:100%" htdocs/luci-static/resources/view/tailscale/setting.js +assert_contains "grid-template-columns:minmax(320px,1fr) minmax(140px,260px)" htdocs/luci-static/resources/view/tailscale/setting.js +assert_contains "min-height:42px" htdocs/luci-static/resources/view/tailscale/setting.js +assert_contains "keepalive-peer-main" htdocs/luci-static/resources/view/tailscale/setting.js +assert_contains "keepalive-peer-check" htdocs/luci-static/resources/view/tailscale/setting.js +assert_contains "display:flex;align-items:center;gap:10px;min-width:0" htdocs/luci-static/resources/view/tailscale/setting.js +assert_contains "display:flex;align-items:center;justify-content:center;line-height:0;flex:0 0 24px" htdocs/luci-static/resources/view/tailscale/setting.js +assert_contains "max-width:260px" htdocs/luci-static/resources/view/tailscale/setting.js +assert_not_contains "max-width:820px" htdocs/luci-static/resources/view/tailscale/setting.js +assert_not_contains "grid-template-columns:24px minmax(160px,1fr) auto" htdocs/luci-static/resources/view/tailscale/setting.js +assert_not_contains "grid-template-columns:24px minmax(180px,1fr) minmax(140px,260px)" htdocs/luci-static/resources/view/tailscale/setting.js +assert_not_contains "padding:10px 12px" htdocs/luci-static/resources/view/tailscale/setting.js +assert_not_contains "grid-template-columns:24px 1fr" htdocs/luci-static/resources/view/tailscale/setting.js +assert_not_contains "form.AbstractValue, 'keepalive_peers'" htdocs/luci-static/resources/view/tailscale/setting.js +assert_not_contains "form.MultiValue, 'keepalive_peers'" htdocs/luci-static/resources/view/tailscale/setting.js +assert_file htdocs/luci-static/resources/view/tailscale/peers.js +assert_file tests/peers_pagination_test.js +assert_contains "Tailscale Peers" htdocs/luci-static/resources/view/tailscale/peers.js +assert_contains "filterMode" htdocs/luci-static/resources/view/tailscale/peers.js +assert_contains "buildPeerGroups" htdocs/luci-static/resources/view/tailscale/peers.js +assert_contains "paginatePeerGroups" htdocs/luci-static/resources/view/tailscale/peers.js +assert_contains "PEER_PAGE_SIZE_DEFAULT = 25" htdocs/luci-static/resources/view/tailscale/peers.js +assert_contains "PEER_PAGE_SIZE_OPTIONS" htdocs/luci-static/resources/view/tailscale/peers.js +assert_peer_pagination_after_table htdocs/luci-static/resources/view/tailscale/peers.js +assert_contains "peer-pagination-summary" htdocs/luci-static/resources/view/tailscale/peers.js +assert_contains "peer-pagination-controls" htdocs/luci-static/resources/view/tailscale/peers.js +assert_contains "justify-content:flex-end" htdocs/luci-static/resources/view/tailscale/peers.js +assert_contains "margin-left:auto" htdocs/luci-static/resources/view/tailscale/peers.js +assert_contains "split oversized groups into dedicated pages" htdocs/luci-static/resources/view/tailscale/peers.js +assert_contains "pageSize === 0" htdocs/luci-static/resources/view/tailscale/peers.js +assert_contains "pageIndex = 0" htdocs/luci-static/resources/view/tailscale/peers.js +assert_contains "peer.userKey" htdocs/luci-static/resources/view/tailscale/peers.js +assert_contains "peer-group-header" htdocs/luci-static/resources/view/tailscale/peers.js +assert_contains "peer-group-title" htdocs/luci-static/resources/view/tailscale/peers.js +assert_contains "border-left:4px solid #64748b" htdocs/luci-static/resources/view/tailscale/peers.js +assert_contains "font-size:16px" htdocs/luci-static/resources/view/tailscale/peers.js +assert_contains "peer.online ? '' : 'opacity:0.62'" htdocs/luci-static/resources/view/tailscale/peers.js +assert_contains "Offline peers cannot be probed" htdocs/luci-static/resources/view/tailscale/peers.js +assert_contains "type: 'button'" htdocs/luci-static/resources/view/tailscale/peers.js +assert_contains "disabled: probing || !peer.online ? 'disabled' : null" htdocs/luci-static/resources/view/tailscale/peers.js +assert_contains "preventDefault" htdocs/luci-static/resources/view/tailscale/peers.js +assert_contains "stopPropagation" htdocs/luci-static/resources/view/tailscale/peers.js +assert_contains "window.scrollTo" htdocs/luci-static/resources/view/tailscale/peers.js +assert_contains "querySelector('.main-right')" htdocs/luci-static/resources/view/tailscale/peers.js +assert_contains "restoreScrollState" htdocs/luci-static/resources/view/tailscale/peers.js +assert_contains "scrollElement.scrollTop" htdocs/luci-static/resources/view/tailscale/peers.js +assert_contains "renderRows(true)" htdocs/luci-static/resources/view/tailscale/peers.js +assert_not_contains "disabled: probing," htdocs/luci-static/resources/view/tailscale/peers.js +assert_contains "PROBE_MAX_ATTEMPTS = 5" htdocs/luci-static/resources/view/tailscale/peers.js +assert_contains "PROBE_RETRY_DELAY_MS" htdocs/luci-static/resources/view/tailscale/peers.js +assert_contains "Continuing probe %d/%d" htdocs/luci-static/resources/view/tailscale/peers.js +assert_contains "%d probes; direct connection not established" htdocs/luci-static/resources/view/tailscale/peers.js +assert_contains 'msgid "Continuing probe %d/%d"' po/zh_Hans/tailscale.po +assert_contains 'msgstr "继续探测 %d/%d"' po/zh_Hans/tailscale.po +assert_contains 'msgid "%d probes; direct connection not established"' po/zh_Hans/tailscale.po +assert_contains 'msgstr "%d 次探测后仍未建立直连"' po/zh_Hans/tailscale.po +assert_contains 'msgid "Continuing probe %d/%d"' po/zh_Hant/tailscale.po +assert_contains 'msgstr "繼續探測 %d/%d"' po/zh_Hant/tailscale.po +assert_contains 'msgid "%d probes; direct connection not established"' po/zh_Hant/tailscale.po +assert_contains 'msgstr "%d 次探測後仍未建立直連"' po/zh_Hant/tailscale.po +assert_contains 'msgid "Items per page"' po/zh_Hans/tailscale.po +assert_contains 'msgstr "每页数量"' po/zh_Hans/tailscale.po +assert_contains 'msgid "All peers"' po/zh_Hans/tailscale.po +assert_contains 'msgstr "全部对端"' po/zh_Hans/tailscale.po +assert_contains 'msgid "Showing %d-%d of %d peers"' po/zh_Hans/tailscale.po +assert_contains 'msgstr "显示第 %d-%d 台,共 %d 台"' po/zh_Hans/tailscale.po +assert_contains 'msgid "Page %d / %d"' po/zh_Hans/tailscale.po +assert_contains 'msgstr "第 %d / %d 页"' po/zh_Hans/tailscale.po +assert_contains 'msgid "Previous"' po/zh_Hans/tailscale.po +assert_contains 'msgstr "上一页"' po/zh_Hans/tailscale.po +assert_contains 'msgid "Next"' po/zh_Hans/tailscale.po +assert_contains 'msgstr "下一页"' po/zh_Hans/tailscale.po +assert_contains 'msgid "Offline peers cannot be probed"' po/templates/tailscale.pot +assert_contains 'msgid "Offline peers cannot be probed"' po/zh_Hans/tailscale.po +assert_contains 'msgstr "离线设备不可探测"' po/zh_Hans/tailscale.po +assert_contains 'msgid "Offline peers cannot be probed"' po/zh_Hant/tailscale.po +assert_contains 'msgstr "離線設備不可探測"' po/zh_Hant/tailscale.po +assert_contains 'msgid "Unknown user"' po/templates/tailscale.pot +assert_contains 'msgid "Unknown user"' po/zh_Hans/tailscale.po +assert_contains 'msgstr "未知用户"' po/zh_Hans/tailscale.po +assert_contains 'msgid "Unknown user"' po/zh_Hant/tailscale.po +assert_contains 'msgstr "未知使用者"' po/zh_Hant/tailscale.po +assert_contains "peer.isSelf" htdocs/luci-static/resources/view/tailscale/peers.js +assert_contains "Current device" htdocs/luci-static/resources/view/tailscale/peers.js +assert_po_entry "Current device" "当前设备" po/zh_Hans/tailscale.po +assert_po_entry "Current device" "目前設備" po/zh_Hant/tailscale.po +assert_contains "Advertising subnets" htdocs/luci-static/resources/view/tailscale/peers.js +assert_contains "tailscale_peer_probe" htdocs/luci-static/resources/view/tailscale/peers.js +assert_contains "Probe" htdocs/luci-static/resources/view/tailscale/peers.js +assert_contains "DERP" htdocs/luci-static/resources/view/tailscale/peers.js +assert_contains "Direct" htdocs/luci-static/resources/view/tailscale/peers.js +assert_not_contains "Probe all" htdocs/luci-static/resources/view/tailscale/peers.js +assert_file tests/log_view_test.js +assert_contains "formatLogLines" htdocs/luci-static/resources/view/tailscale/log.js +assert_contains "formatLogError" htdocs/luci-static/resources/view/tailscale/log.js +assert_contains "Log is empty." po/zh_Hans/tailscale.po +assert_contains "日志为空。" po/zh_Hans/tailscale.po +assert_contains "logread command not found" po/zh_Hans/tailscale.po +assert_contains "未找到 logread 命令" po/zh_Hans/tailscale.po +assert_contains 'msgid "Keepalive"' po/zh_Hans/tailscale.po +assert_contains 'msgstr "保活"' po/zh_Hans/tailscale.po +assert_contains 'msgid "Peer Keepalive"' po/zh_Hans/tailscale.po +assert_contains 'msgstr "启用"' po/zh_Hans/tailscale.po +assert_contains 'msgid "Peer Keepalive"' po/zh_Hant/tailscale.po +assert_contains 'msgstr "啟用"' po/zh_Hant/tailscale.po +assert_not_contains 'msgstr "对端保活"' po/zh_Hans/tailscale.po +assert_contains 'msgid "Subnets"' po/zh_Hans/tailscale.po +assert_contains 'msgstr "子网"' po/zh_Hans/tailscale.po +assert_contains 'msgid "Only peers advertising subnet routes are shown. Selected peers are periodically pinged to keep cross-subnet paths active."' po/zh_Hans/tailscale.po +assert_contains 'msgstr "仅显示对端也发布了网段的设备;选中的设备会被定时 Tailscale ping,用于保持跨网段路径活跃。"' po/zh_Hans/tailscale.po +assert_not_contains "子网路由" po/zh_Hans/tailscale.po +assert_not_contains "子網路由" po/zh_Hant/tailscale.po +assert_contains 'msgid "AdGuard DNS"' po/zh_Hans/tailscale.po +assert_contains 'msgstr "AdGuard DNS"' po/zh_Hans/tailscale.po +assert_contains 'msgid "AdGuard Username"' po/zh_Hans/tailscale.po +assert_contains 'msgstr "AdGuard 用户名"' po/zh_Hans/tailscale.po +assert_contains "Expected Internal IPs" htdocs/luci-static/resources/view/tailscale/setting.js +assert_contains 'msgid "Expected Internal IPs"' po/zh_Hans/tailscale.po +assert_contains 'msgstr "预期内网 IP"' po/zh_Hans/tailscale.po +assert_contains 'msgstr "啟用 AdGuard DNS 自動切換前至少需要填寫一個預期內網 IP。"' po/zh_Hant/tailscale.po +assert_not_contains "Expected Health IPs" htdocs/luci-static/resources/view/tailscale/setting.js +assert_not_contains "预期健康 IP" po/zh_Hans/tailscale.po +assert_not_contains "預期健康 IP" po/zh_Hant/tailscale.po +assert_contains "100.100.100.100" root/usr/sbin/tailscale_adguard_dns_switch +assert_contains "100.100.100.100" htdocs/luci-static/resources/view/tailscale/setting.js +assert_not_contains "'subnet_routes'" htdocs/luci-static/resources/view/tailscale/setting.js +assert_not_contains "Subnet Routes" htdocs/luci-static/resources/view/tailscale/setting.js +assert_not_contains "No Available Subnet Routes" htdocs/luci-static/resources/view/tailscale/setting.js +assert_not_contains "config_get subnet_routes" root/etc/init.d/tailscale +assert_not_contains 'SUBNET_ROUTES="$subnet_routes"' root/etc/init.d/tailscale +assert_not_contains "list subnet_routes" root/etc/config/tailscale +assert_not_contains 'msgid "Subnet Routes"' po/templates/tailscale.pot +assert_not_contains 'msgid "No Available Subnet Routes"' po/templates/tailscale.pot +assert_not_contains 'msgid "Subnet Routes"' po/zh_Hans/tailscale.po +assert_not_contains 'msgid "No Available Subnet Routes"' po/zh_Hans/tailscale.po +assert_not_contains 'msgid "Subnet Routes"' po/zh_Hant/tailscale.po +assert_not_contains 'msgid "No Available Subnet Routes"' po/zh_Hant/tailscale.po +assert_not_contains "Tailscale Accept DNS" htdocs/luci-static/resources/view/tailscale/setting.js +assert_not_contains "Accept DNS must be enabled before enabling AdGuard DNS auto switch." htdocs/luci-static/resources/view/tailscale/setting.js +assert_not_contains 'msgid "Tailscale Accept DNS"' po/zh_Hans/tailscale.po +assert_not_contains 'msgid "Accept DNS must be enabled before enabling AdGuard DNS auto switch."' po/zh_Hans/tailscale.po +assert_contains "PROGA=/usr/sbin/tailscale_adguard_dns_switch" root/etc/init.d/tailscale +assert_contains 'tailscale_adguard_dns "$cfg"' root/etc/init.d/tailscale +assert_contains "service_stopped()" root/etc/init.d/tailscale +assert_contains "config_foreach tailscale_adguard_dns_apply_down 'tailscale'" root/etc/init.d/tailscale +assert_contains "--apply-profile down" root/etc/init.d/tailscale +assert_not_contains "adguard_health_dns" htdocs/luci-static/resources/view/tailscale/setting.js +assert_not_contains "Health Check DNS Server" htdocs/luci-static/resources/view/tailscale/setting.js +assert_not_contains "adguard_health_dns" root/etc/config/tailscale +assert_not_contains "adguard_clear_cache" htdocs/luci-static/resources/view/tailscale/setting.js +assert_not_contains "Clear AdGuard Cache After Switch" htdocs/luci-static/resources/view/tailscale/setting.js +assert_not_contains "adguard_clear_cache" root/etc/config/tailscale +assert_not_contains 'msgid "Health Check DNS Server"' po/zh_Hans/tailscale.po +assert_not_contains 'msgid "Clear AdGuard Cache After Switch"' po/zh_Hans/tailscale.po +assert_contains "placeholder = hasAdguardPassword ? _('Configured') : ''" htdocs/luci-static/resources/view/tailscale/setting.js +assert_contains "placeholder = hasAuthKey ? _('Configured') : ''" htdocs/luci-static/resources/view/tailscale/setting.js +assert_contains "Leave blank to keep the existing AdGuard password; enter a new value to replace it." htdocs/luci-static/resources/view/tailscale/setting.js +assert_contains "Used once for first-time enrollment or explicit re-enrollment, then removed after a successful connection. Leave blank for normal restarts." htdocs/luci-static/resources/view/tailscale/setting.js +assert_not_contains "placeholder = hasAdguardPassword ? _('Configured; leave blank to keep existing value.') : ''" htdocs/luci-static/resources/view/tailscale/setting.js +assert_not_contains "placeholder = hasAuthKey ? _('Configured; leave blank to keep existing value.') : ''" htdocs/luci-static/resources/view/tailscale/setting.js +assert_contains 'msgid "Configured"' po/zh_Hans/tailscale.po +assert_contains 'msgstr "已配置"' po/zh_Hans/tailscale.po +assert_contains 'msgid "Leave blank to keep the existing AdGuard password; enter a new value to replace it."' po/zh_Hans/tailscale.po +assert_contains 'msgstr "留空则保留现有 AdGuard 密码;填写新值则覆盖。"' po/zh_Hans/tailscale.po +assert_contains 'msgid "Used once for first-time enrollment or explicit re-enrollment, then removed after a successful connection. Leave blank for normal restarts."' po/zh_Hans/tailscale.po +assert_contains 'msgstr "仅用于首次注册或明确重新注册,连接成功后自动删除;正常重启请留空。"' po/zh_Hans/tailscale.po +assert_contains 'msgid "Failed to stage protected credentials."' po/zh_Hans/tailscale.po +assert_contains 'msgstr "暂存受保护凭证失败。"' po/zh_Hans/tailscale.po +assert_contains '"luci.tailscale": [ "adguard_preflight", "set_secrets" ]' root/usr/share/rpcd/acl.d/luci-app-tailscale.json +acl_file=root/usr/share/rpcd/acl.d/luci-app-tailscale.json +assert_jq '.["luci-app-tailscale"].read.uci == ["tailscale", "tailscale_openclash", "tailscale_policy_routing"]' "$acl_file" 'ACL read UCI access must include the isolated policy-routing package' +assert_jq '.["luci-app-tailscale"].write.uci == ["tailscale", "tailscale_openclash", "tailscale_policy_routing"]' "$acl_file" 'ACL write UCI access must include the isolated policy-routing package' +assert_jq '.["luci-app-tailscale"].read.ubus["luci.tailscale"] == ["secret_status", "openclash_bypass_status", "policy_routing_status"]' "$acl_file" 'ACL read ubus access must include the policy-routing status method' +assert_jq '.["luci-app-tailscale"].read.file | has("/usr/sbin/tailscale_openclash_bypass") | not' "$acl_file" 'ACL must not grant file-exec access to the OpenClash helper' +assert_not_contains '"set_secret"' root/usr/share/rpcd/acl.d/luci-app-tailscale.json +assert_not_contains '"luci.tailscale": [ "adguard_preflight" ]' root/usr/share/rpcd/acl.d/luci-app-tailscale.json +assert_not_contains "/usr/sbin/tailscale_adguard_dns_switch --preflight" root/usr/share/rpcd/acl.d/luci-app-tailscale.json +assert_contains '"/usr/sbin/tailscale_peer_probe": [ "exec" ]' root/usr/share/rpcd/acl.d/luci-app-tailscale.json +assert_not_contains "config_get authkey" root/etc/init.d/tailscale +assert_not_contains "get authkey" root/etc/init.d/tailscale +assert_not_contains "--authkey=" root/etc/init.d/tailscale +assert_contains 'procd_set_param command $PROG --run' root/etc/init.d/tailscale +assert_contains 'TAILSCALE_SECRETS_BIN' root/usr/sbin/tailscale_helper +assert_not_contains 'TAILSCALE_INIT' root/usr/sbin/tailscale_helper +assert_contains 'Unsupported tailscale_helper mode' root/usr/sbin/tailscale_helper +assert_contains '"$TAILSCALE_SECRETS_BIN" migrate' root/etc/uci-defaults/40_luci-tailscale +assert_contains 'cleanup_legacy_adguard_dns_service' root/etc/uci-defaults/40_luci-tailscale +assert_contains 'tailscale-adguard-dns.disabled' root/etc/uci-defaults/40_luci-tailscale +assert_before 'cleanup_legacy_adguard_dns_service' '"$TAILSCALE_SECRETS_BIN" migrate' root/etc/uci-defaults/40_luci-tailscale +assert_contains 'disable_legacy_hotplug' Makefile +assert_contains '40-tailscale.disabled' Makefile +assert_contains 'rm -f "$${legacy_hotplug}" || chmod 000 "$${legacy_hotplug}" || return 1' Makefile +assert_before 'disable_legacy_hotplug || exit 1' '[ -f /etc/config/tailscale ] || exit 0' Makefile +assert_not_contains 'ucitrack.@tailscale' root/etc/uci-defaults/40_luci-tailscale +assert_not_contains 'uci -q batch' root/etc/uci-defaults/40_luci-tailscale +assert_contains 'openclash_bypass_enabled="$("$UCI_BIN" -q get tailscale_openclash.settings.enabled' root/etc/uci-defaults/40_luci-tailscale +assert_contains '"$OPENCLASH_BYPASS_INIT" enable' root/etc/uci-defaults/40_luci-tailscale +assert_contains '"$OPENCLASH_BYPASS_INIT" start' root/etc/uci-defaults/40_luci-tailscale +assert_before '"$TAILSCALE_SECRETS_BIN" migrate' '"$OPENCLASH_BYPASS_INIT" enable' root/etc/uci-defaults/40_luci-tailscale +assert_before '"$OPENCLASH_BYPASS_INIT" enable' '"$OPENCLASH_BYPASS_INIT" start' root/etc/uci-defaults/40_luci-tailscale +assert_contains 'POLICY_ROUTING_INIT=' root/etc/uci-defaults/40_luci-tailscale +assert_contains '"$POLICY_ROUTING_INIT" enable' root/etc/uci-defaults/40_luci-tailscale +assert_contains '"$POLICY_ROUTING_INIT" start' root/etc/uci-defaults/40_luci-tailscale +assert_not_contains "option adguard_password" root/etc/config/tailscale +assert_not_contains "option authkey" root/etc/config/tailscale +assert_contains "--cleanup-managed-firewall" root/etc/init.d/tailscale +assert_contains "TAILSCALE_INTERNAL_RELOAD" root/etc/init.d/tailscale +assert_not_contains "RELOAD_MARKER_FILE" root/etc/init.d/tailscale +assert_contains 'activate "$secrets_ref"' root/etc/init.d/tailscale +assert_contains "Package/luci-app-tailscale/prerm" Makefile +assert_contains "Package/luci-app-tailscale/preinst" Makefile +assert_not_contains "Package/luci-app-tailscale/conffiles" Makefile +assert_contains "/etc/config/tailscale" Makefile +assert_contains "/etc/config/tailscale_openclash" Makefile +assert_contains "/etc/config/tailscale_policy_routing" Makefile +assert_not_contains "Package/luci-app-tailscale/postinst" Makefile +assert_contains "state_dir=/etc/.luci-app-tailscale-upgrade" Makefile +assert_contains 'state_pending="$${state_dir}.pending"' Makefile +assert_contains 'clear_state_dir()' Makefile +assert_contains "cp /etc/config/tailscale \"\$\${state_pending}/tailscale\"" Makefile +assert_not_contains "/tmp/luci-app-tailscale-upgrade" Makefile +assert_contains "restore_upgrade_config" root/etc/uci-defaults/40_luci-tailscale +assert_contains "cleanup_upgrade_state()" root/etc/uci-defaults/40_luci-tailscale +assert_contains 'UPGRADE_STATE_DIR="${UPGRADE_STATE_DIR:-/etc/.luci-app-tailscale-upgrade}"' root/etc/uci-defaults/40_luci-tailscale +assert_contains "chmod 700 \"\$\${state_pending}\"" Makefile +assert_before "restore_upgrade_config" '"$TAILSCALE_SECRETS_BIN" migrate' root/etc/uci-defaults/40_luci-tailscale +assert_not_contains "/etc/init.d/tailscale-openclash-bypass enable" Makefile +assert_not_contains "/etc/init.d/tailscale-openclash-bypass start" Makefile +assert_contains '/etc/init.d/tailscale-openclash-bypass disable >/dev/null 2>&1 || true' Makefile +assert_contains '[ -z "$${IPKG_INSTROOT}" ] && [ -x /usr/sbin/tailscale_openclash_bypass ]; then' Makefile +assert_contains "/usr/sbin/tailscale_openclash_bypass cleanup >/dev/null 2>&1 || true" Makefile +assert_contains "/usr/sbin/tailscale_policy_routing cleanup >/dev/null 2>&1 || exit 1" Makefile +assert_contains 'case "$${2:-}" in' Makefile +assert_contains 'remove|[0-9]*) ;;' Makefile +assert_contains 'case "$${1:-}" in' Makefile +assert_contains '"$${cleanup_dir}/tailscale_openclash.absent" "$${cleanup_dir}/tailscale_policy_routing"' Makefile +assert_contains '"$${cleanup_dir}/.complete"' Makefile +assert_not_contains 'APK_SCRIPT' Makefile +assert_before "/usr/sbin/tailscale_openclash_bypass cleanup" "/etc/init.d/tailscale stop" Makefile +assert_before "/etc/init.d/tailscale-openclash-bypass disable" "/etc/init.d/tailscale stop" Makefile +assert_before "/usr/sbin/tailscale_policy_routing cleanup" "/etc/init.d/tailscale stop" Makefile +assert_before "/etc/init.d/tailscale-policy-routing disable" "/etc/init.d/tailscale stop" Makefile +assert_contains "/etc/init.d/tailscale stop" Makefile +assert_not_contains "/etc/init.d/tailscale stop >/dev/null 2>&1 || true" Makefile +assert_not_contains "grep -b" root/usr/sbin/tailscale_openclash_bypass +assert_contains '"$PROG" sync' root/etc/init.d/tailscale-openclash-bypass +assert_not_contains "config_get_bool" root/etc/init.d/tailscale-openclash-bypass +assert_contains 'Status states: `active`, `waiting`, `disabled`, `absent`, `unsupported`, and `error`.' README.md +assert_contains 'Cleanup removes only the managed hook block and the four `luci-app-tailscale:` rules.' README.md + +assert_not_exists root/lib/netifd/proto/tailscale.sh +assert_not_exists htdocs/luci-static/resources/protocol/tailscale.js +assert_not_exists root/etc/init.d/tailscale-adguard-dns +assert_not_exists root/etc/hotplug.d/iface/40-tailscale + +echo "package release tests passed" + +reload_service() { + restart +} diff --git a/luci-app-tailscale/tests/peers_pagination_test.js b/luci-app-tailscale/tests/peers_pagination_test.js new file mode 100644 index 000000000..79ba88785 --- /dev/null +++ b/luci-app-tailscale/tests/peers_pagination_test.js @@ -0,0 +1,286 @@ +const fs = require('fs'); +const vm = require('vm'); + +const source = fs.readFileSync('htdocs/luci-static/resources/view/tailscale/peers.js', 'utf8'); +const helperSource = source.split('return view.extend({')[0]; +const context = { + module: { exports: {} }, + console, + _: value => value +}; + +vm.runInNewContext(`${helperSource}\nmodule.exports = { parseStatus, buildPeerGroups, paginatePeerGroups, parseProbeResult, appendProbeSummary };`, context); + +const { parseStatus, buildPeerGroups, paginatePeerGroups, parseProbeResult, appendProbeSummary } = context.module.exports; + +function peer(id) { + return { id: String(id) }; +} + +function group(name, count) { + return { + key: name, + name, + loginName: '', + peers: Array.from({ length: count }, (_, index) => peer(`${name}-${index}`)) + }; +} + +function assert(condition, message) { + if (!condition) + throw new Error(message); +} + +function groupNames(page) { + return page.groups.map(item => item.name).join(','); +} + +const mixedGroups = [group('user-a', 20), group('user-b', 5), group('user-c', 10)]; +let page = paginatePeerGroups(mixedGroups, 25, 0); +assert(page.pageCount === 2, `expected two pages, got ${page.pageCount}`); +assert(groupNames(page) === 'user-a,user-b', `unexpected first page groups: ${groupNames(page)}`); +assert(page.start === 1 && page.end === 25 && page.total === 35, 'unexpected first page range'); + +page = paginatePeerGroups(mixedGroups, 25, 1); +assert(groupNames(page) === 'user-c', `unexpected second page groups: ${groupNames(page)}`); +assert(page.start === 26 && page.end === 35, 'unexpected second page range'); + +const oversizedGroups = [group('large-user', 40), group('small-user', 5)]; +page = paginatePeerGroups(oversizedGroups, 25, 0); +assert(groupNames(page) === 'large-user', `unexpected oversized first page groups: ${groupNames(page)}`); +assert(page.groups[0].peers.length === 25, `expected first chunk of 25, got ${page.groups[0].peers.length}`); + +page = paginatePeerGroups(oversizedGroups, 25, 1); +assert(groupNames(page) === 'large-user', `unexpected oversized second page groups: ${groupNames(page)}`); +assert(page.groups[0].peers.length === 15, `expected second chunk of 15, got ${page.groups[0].peers.length}`); + +page = paginatePeerGroups(oversizedGroups, 25, 2); +assert(groupNames(page) === 'small-user', `unexpected oversized third page groups: ${groupNames(page)}`); +assert(page.groups[0].peers.length === 5, `expected small group page of 5, got ${page.groups[0].peers.length}`); + +page = paginatePeerGroups(oversizedGroups, 0, 0); +assert(page.pageCount === 1, `expected all mode single page, got ${page.pageCount}`); +assert(groupNames(page) === 'large-user,small-user', `unexpected all mode groups: ${groupNames(page)}`); + +const normalizedPeers = parseStatus(JSON.stringify({ + User: { + '1': { DisplayName: 'Zeta User', LoginName: 'zeta@example.test' }, + '2': { DisplayName: 'Alpha User', LoginName: 'alpha@example.test' } + }, + Self: { + ID: 'self-node', + HostName: 'hz-office-openwrt', + DNSName: 'hz-office-openwrt.example.ts.net.', + TailscaleIPs: ['100.64.2.15'], + PrimaryRoutes: ['192.168.100.0/24'], + UserID: 2, + Online: true + }, + Peer: { + 'node:gateway': { + HostName: 'gateway', + DNSName: 'gateway.example.ts.net.', + TailscaleIPs: ['100.64.0.20'], + PrimaryRoutes: ['10.20.0.0/24', '', null], + UserID: 2, + Online: 1 + }, + 'node:alpha': { + HostName: 'legacy-host', + DNSName: 'alpha.example.ts.net.', + TailscaleIPs: ['100.64.0.10'], + PrimaryRoutes: [], + UserID: '1', + Online: false + }, + 'node:unknown': { + HostName: '', + DNSName: '', + TailscaleIPs: ['100.64.0.30'], + PrimaryRoutes: null, + Online: true + } + } +})); + +assert(normalizedPeers.map(item => item.id).join(',') === 'node:unknown,node:alpha,node:gateway,self-node', 'status peers should normalize names and sort deterministically'); +assert(normalizedPeers.find(item => item.id === 'node:alpha').name === 'alpha (legacy-host)', 'status normalization should combine distinct short DNS and host names'); +assert(normalizedPeers.find(item => item.id === 'node:gateway').probeTarget === '100.64.0.20', 'status normalization should prefer the Tailscale IP as probe target'); +assert(normalizedPeers.find(item => item.id === 'node:gateway').routes.join(',') === '10.20.0.0/24' && normalizedPeers.find(item => item.id === 'node:gateway').hasSubnetRoutes, 'status normalization should remove empty routes and flag advertised subnets'); +assert(normalizedPeers.find(item => item.id === 'node:unknown').userKey === 'unknown', 'missing user IDs should normalize into the unknown group'); +const selfPeer = normalizedPeers.find(item => item.isSelf); +assert(selfPeer && selfPeer.name === 'hz-office-openwrt', 'Self should be normalized into the device list'); +assert(selfPeer.userName === 'Alpha User', 'Self should resolve its owner through UserID'); +assert(selfPeer.routes.join(',') === '192.168.100.0/24', 'Self should preserve its advertised routes'); + +const normalizedGroups = buildPeerGroups(normalizedPeers); +assert(normalizedGroups.map(item => item.name).join(',') === 'Alpha User,Unknown user,Zeta User', 'peer groups should sort by normalized user display name'); +assert(normalizedGroups[0].peers[0].id === 'node:gateway', 'peer grouping should retain the normalized user association'); +const alphaGroup = normalizedGroups.find(item => item.key === '2'); +assert(alphaGroup && alphaGroup.peers.length === 2, 'Self and regular peers with the same UserID should share one user group'); +assert(alphaGroup.peers.some(item => item.isSelf) && alphaGroup.peers.some(item => item.id === 'node:gateway'), 'shared user group should contain both Self and the regular peer'); +const normalizedPage = paginatePeerGroups(normalizedGroups, 1, 1); +assert(normalizedPage.total === normalizedPeers.length, 'Self should be included in the paginated peer count'); +assert(normalizedPage.groups.length === 1 && normalizedPage.groups[0].peers[0].isSelf, 'Self should participate in pagination as a peer entry'); + +const peerOnlyPeers = parseStatus(JSON.stringify({ + User: { + '3': { DisplayName: 'Peer Only User' } + }, + Peer: { + 'node:peer-only': { + HostName: 'peer-only-host', + TailscaleIPs: ['100.64.0.50'], + UserID: 3, + Online: true + } + } +})); +assert(peerOnlyPeers.length === 1 && peerOnlyPeers[0].id === 'node:peer-only', 'status without Self should preserve the peer-only list'); +assert(!peerOnlyPeers[0].isSelf && peerOnlyPeers[0].userName === 'Peer Only User', 'peer-only status should remain compatible with existing user normalization'); + +const largeUserId = '9007199254740993'; +const largeIdPeers = parseStatus(`{"User":{"${largeUserId}":{"DisplayName":"Large ID User"}},"Peer":{"node:large":{"HostName":"large-id-peer","TailscaleIPs":["100.64.0.99"],"UserID":${largeUserId},"Online":true}}}`); +assert(largeIdPeers[0].userKey === largeUserId, 'status parsing must preserve UserID values larger than JavaScript safe integers'); +assert(largeIdPeers[0].userName === 'Large ID User', 'large UserID values must still resolve the correct user metadata'); + +const derpProbe = parseProbeResult('{"ok":true,"path":"derp","relay":"test-relay","summary":"DERP test-relay 24 ms"}', 'ignored'); +assert(derpProbe.path === 'derp' && derpProbe.relay === 'test-relay', 'probe parser should preserve a valid DERP result'); +const progressiveProbe = appendProbeSummary(derpProbe, 'Continuing probe 1/5'); +assert(progressiveProbe.summary === 'DERP test-relay 24 ms - Continuing probe 1/5', 'progressive DERP parsing should append the retry status'); +assert(derpProbe.summary === 'DERP test-relay 24 ms', 'progressive probe summary should not mutate the parsed result'); +const failedProbe = parseProbeResult('{invalid', 'probe output was incomplete'); +assert(failedProbe.path === 'failed' && failedProbe.summary === 'probe output was incomplete', 'invalid progressive probe output should use the supplied failure detail'); + +function createElement(tag, attrs, children) { + if (arguments.length === 2 && (typeof attrs !== 'object' || Array.isArray(attrs))) { + children = attrs; + attrs = {}; + } + + return { + tag, + attrs: attrs || {}, + children: children == null ? [] : (Array.isArray(children) ? children : [children]) + }; +} + +function findRowWithText(node, text) { + if (!node || typeof node !== 'object') + return null; + + if (node.tag === 'tr' && JSON.stringify(node).includes(text)) + return node; + + for (const child of (node.children || []).concat(node.content || [])) { + const row = findRowWithText(child, text); + if (row) + return row; + } + + return null; +} + +async function testRefreshFailureClearsStaleRows() { + let pollCallback; + const viewContext = { + console, + _: value => value, + E: createElement, + dom: { + content: (node, content) => { + node.content = content; + } + }, + fs: { + exec: async () => ({ code: 1, stderr: 'status refresh unavailable' }) + }, + poll: { + add: callback => { + pollCallback = callback; + } + }, + view: { + extend: value => value + } + }; + const viewObject = vm.runInNewContext(` + String.prototype.format = function() { + var args = arguments; + var index = 0; + return String(this).replace(/%[sd]/g, function() { return String(args[index++]); }); + }; + (function() { ${source}\n })(); + `, viewContext); + const stalePeer = { + id: 'stale-peer-id', + name: 'stale-peer-name', + ip: '100.64.0.40', + userKey: '1', + userName: 'Example User', + userLoginName: 'user@example.test', + online: true, + lastSeen: '-', + exitNode: false, + routes: [], + hasSubnetRoutes: false, + probeTarget: '100.64.0.40' + }; + const rendered = viewObject.render({ ok: true, peers: [stalePeer], error: '' }); + assert(JSON.stringify(rendered).includes('stale-peer-name'), 'refresh test should begin with a rendered stale peer'); + assert(typeof pollCallback === 'function', 'peer view should register its refresh callback'); + + await pollCallback(); + const refreshed = JSON.stringify(rendered); + assert(!refreshed.includes('stale-peer-name'), 'failed refresh must clear stale peer rows'); + assert(refreshed.includes('status refresh unavailable'), 'failed refresh should render the current status error'); + assert(refreshed.includes('No peers found'), 'failed refresh should render the empty peer state'); +} + +async function testCurrentDeviceRendersWithoutProbeAction() { + const viewContext = { + console, + _: value => value, + E: createElement, + dom: { content: (node, content) => { node.content = content; } }, + fs: { exec: async () => ({ code: 0, stdout: '{}' }) }, + poll: { add: () => {} }, + view: { extend: value => value } + }; + const viewObject = vm.runInNewContext(` + String.prototype.format = function() { + var args = arguments; + var index = 0; + return String(this).replace(/%[sd]/g, function() { return String(args[index++]); }); + }; + (function() { ${source}\n })(); + `, viewContext); + const currentDevice = { + id: 'self-node', + name: 'hz-office-openwrt', + ip: '100.64.2.15', + userKey: '2', + userName: 'Alpha User', + userLoginName: 'alpha@example.test', + online: true, + lastSeen: '-', + exitNode: false, + routes: ['192.168.100.0/24'], + hasSubnetRoutes: true, + probeTarget: '100.64.2.15', + isSelf: true + }; + const rendered = viewObject.render({ ok: true, peers: [currentDevice], error: '' }); + const serialized = JSON.stringify(rendered); + const currentDeviceRow = findRowWithText(rendered, currentDevice.name); + + assert(serialized.includes('Current device'), 'current device row should show a current-device probe cell'); + assert(currentDeviceRow && !JSON.stringify(currentDeviceRow).includes('Probe'), 'current device row should not render a Probe action'); +} + +Promise.all([testRefreshFailureClearsStaleRows(), testCurrentDeviceRendersWithoutProbeAction()]).then(() => { + console.log('peers pagination and behavior tests passed'); +}).catch(error => { + console.error(error); + process.exit(1); +}); diff --git a/luci-app-tailscale/tests/release_workflow_test.sh b/luci-app-tailscale/tests/release_workflow_test.sh new file mode 100644 index 000000000..183ac22ac --- /dev/null +++ b/luci-app-tailscale/tests/release_workflow_test.sh @@ -0,0 +1,80 @@ +#!/bin/sh + +set -eu + +workflow=.github/workflows/release.yml +readme=README.md +sdk_runner=.github/scripts/build-openwrt-package.sh +sdk_build=.github/scripts/openwrt-sdk-build.sh +build_job="$(sed -n '/^ build:/,/^ release:/p' "$workflow")" +release_body="$(sed -n '/^ body: |/,$p' "$workflow")" + +printf '%s\n' "$build_job" | grep -F 'path: luci-app-tailscale' >/dev/null +printf '%s\n' "$build_job" | grep -F 'if [[ -d logs ]]; then' >/dev/null +grep -F 'name: Verify release tag matches package version' "$workflow" >/dev/null +grep -F 'GITHUB_REF_NAME' "$workflow" >/dev/null +grep -F 'PKG_VERSION:=' "$workflow" >/dev/null +grep -F "luci-i18n-tailscale-zh-cn_*.ipk" "$workflow" >/dev/null +grep -F "luci-i18n-tailscale-zh-tw_*.ipk" "$workflow" >/dev/null +grep -F "luci-i18n-tailscale-zh-cn-*.apk" "$workflow" >/dev/null +grep -F "luci-i18n-tailscale-zh-tw-*.apk" "$workflow" >/dev/null +grep -F 'expected exactly one artifact' "$workflow" >/dev/null +grep -E 'softprops/action-gh-release@[0-9a-f]{40}' "$workflow" >/dev/null +grep -F 'generate_release_notes: true' "$workflow" >/dev/null +if grep -F 'softprops/action-gh-release@v2' "$workflow" >/dev/null; then + printf '%s\n' 'release action must be pinned to an immutable commit' >&2 + exit 1 +fi + +check_install_text() { + label="$1" + text="$2" + if printf '%s\n' "$text" | grep -F -- '--force-overwrite' >/dev/null; then + printf '%s\n' "$label must not recommend force-overwrite for normal installation" >&2 + exit 1 + fi + if printf '%s\n' "$text" | grep -F '/tmp/luci-*-tailscale*' >/dev/null; then + printf '%s\n' "$label must not install every translation package through a broad wildcard" >&2 + exit 1 + fi + printf '%s\n' "$text" | grep -F '/tmp/luci-app-tailscale_' >/dev/null + printf '%s\n' "$text" | grep -F '/tmp/luci-app-tailscale-' >/dev/null + printf '%s\n' "$text" | grep -F '/tmp/luci-i18n-tailscale-zh-cn_' >/dev/null + printf '%s\n' "$text" | grep -F '/tmp/luci-i18n-tailscale-zh-cn-' >/dev/null + printf '%s\n' "$text" | grep -F '/tmp/luci-i18n-tailscale-zh-tw_' >/dev/null + printf '%s\n' "$text" | grep -F '/tmp/luci-i18n-tailscale-zh-tw-' >/dev/null +} +check_install_text "$readme" "$(cat "$readme")" +check_install_text "$workflow release body" "$release_body" +grep -F 'apk add --allow-untrusted' "$readme" >/dev/null +printf '%s\n' "$release_body" | grep -F 'apk add --allow-untrusted' >/dev/null + +grep -F 'V: s' "$workflow" >/dev/null +grep -F 'name: Write build failure summary' "$workflow" >/dev/null +grep -F '>> "$GITHUB_STEP_SUMMARY"' "$workflow" >/dev/null +grep -F 'name: Upload build diagnostics' "$workflow" >/dev/null +grep -F 'if: failure()' "$workflow" >/dev/null +grep -F 'logs/**' "$workflow" >/dev/null +grep -E 'sdk_arch: x86_64-24\.10\.5@sha256:[0-9a-f]{64}' "$workflow" >/dev/null +grep -E 'sdk_arch: x86_64@sha256:[0-9a-f]{64}' "$workflow" >/dev/null +if grep -Eq 'sdk_arch: x86_64(-24\.10\.5)?$' "$workflow"; then + printf '%s\n' 'release SDK images must be pinned by digest' >&2 + exit 1 +fi + +grep -F 'bash luci-app-tailscale/.github/scripts/build-openwrt-package.sh' "$workflow" >/dev/null +grep -F 'docker pull "$SDK_IMAGE"' "$sdk_runner" >/dev/null +grep -F 'docker run --rm' "$sdk_runner" >/dev/null +grep -F -- '--volume "$ARTIFACTS_DIR:/feed"' "$sdk_runner" >/dev/null +if grep -F -- '--volume "$PACKAGE_FEED_DIR:/feed"' "$sdk_runner" >/dev/null; then + printf '%s\n' 'the OpenWrt feed mount must contain the package directory, not point at the package itself' >&2 + exit 1 +fi +grep -F './scripts/feeds update -a' "$sdk_build" >/dev/null +grep -F 'package/$PACKAGE_NAME/compile' "$sdk_build" >/dev/null +if grep -F 'gh-action-sdk@' "$workflow" >/dev/null; then + printf '%s\n' 'the release build must not depend on a composite SDK action with mutable nested actions' >&2 + exit 1 +fi + +printf '%s\n' 'release workflow diagnostics test passed' diff --git a/luci-app-tailscale/tests/setting_openclash_bypass_test.js b/luci-app-tailscale/tests/setting_openclash_bypass_test.js new file mode 100644 index 000000000..c524a30ea --- /dev/null +++ b/luci-app-tailscale/tests/setting_openclash_bypass_test.js @@ -0,0 +1,339 @@ +const fs = require('fs'); +const vm = require('vm'); + +const source = fs.readFileSync('htdocs/luci-static/resources/view/tailscale/setting.js', 'utf8'); + +function assert(value, message) { + if (!value) + throw new Error(message); +} + +assert(source.includes("uci.load('tailscale_openclash')"), 'setting view must load the isolated OpenClash UCI package'); +assert(source.includes("method: 'openclash_bypass_status'"), 'setting view must use the read-only status RPC'); +assert(source.includes("uci.set('tailscale_openclash', 'settings', 'enabled'"), 'toggle must write only the isolated UCI package'); +assert(!source.includes("uci.set('tailscale', section_id, 'openclash"), 'toggle must not write the core Tailscale UCI package'); +assert(source.includes("_('Protect Tailscale Traffic (Bypass OpenClash)')"), 'toggle must explain that it protects Tailscale traffic'); +assert(source.includes("_('Keep Tailscale control connections, direct connections, Tailnet DNS, and subnet traffic outside OpenClash. When disabled, this traffic is handled by OpenClash; node connectivity, direct paths, subnet access, and internal DNS are no longer protected by this feature. Keep this enabled while using OpenClash.')"), 'description must name the protected traffic and disabled impact'); +assert(source.includes("_('Enabled; 4 bypass rules are active')"), 'active status must report the concrete rule state'); +assert(source.includes("_('Disabled; Tailscale traffic is handled by OpenClash')"), 'disabled status must state the traffic impact'); +assert(!source.includes("_('Enable OpenClash Bypass')"), 'old implementation-only toggle copy must be removed'); +assert(!source.includes("_('Bypass OpenClash for Tailscale marked host traffic and traffic entering from tailscale0. This feature does not reload firewall4 or manage the OpenClash service.')"), 'old implementation-only description must be removed'); + +const loadMatch = source.match(/load\(\)\s*\{([\s\S]*?)\n\t\},\n\n\trender/); +assert(loadMatch, 'expected setting view to define a load() method'); +assert(!loadMatch[1].includes('callOpenclashBypassStatus('), 'OpenClash status must not block initial page rendering'); +assert(source.includes('refreshOpenclashBypassStatus'), 'status must refresh after render'); + +function createRuntime(openclashState = 'active') { + const rpcDeclarations = []; + const rpcCalls = []; + const uciLoads = []; + const uciSets = []; + const scheduled = []; + const maps = []; + const lifecycle = []; + const nodes = new Map(); + const statusNodes = []; + const openclashUci = { enabled: '1' }; + const luciString = value => String(value); + luciString.format = (format, ...values) => format.replace(/%s/g, () => values.shift()); + + function textFrom(value) { + if (Array.isArray(value)) + return value.map(textFrom).join(''); + if (value == null) + return ''; + if (typeof value === 'object') + return textFrom(value.children); + return String(value); + } + + function E(tag, attrs, children) { + return { + tag, + attrs: attrs || {}, + children, + textContent: textFrom(children) + }; + } + + function registerNode(value) { + if (Array.isArray(value)) { + value.forEach(registerNode); + return; + } + if (!value || typeof value !== 'object') + return; + + const id = value.attrs && value.attrs.id; + if (id) { + nodes.set(id, value); + if (id === 'openclash_bypass_status') + statusNodes.push(value); + } + registerNode(value.children); + } + + function Option(option) { + this.option = option; + } + + Option.prototype.depends = function() {}; + Option.prototype.value = function() {}; + Option.prototype.formvalue = function() { + return this.map && this.map.formValues[this.option] || ''; + }; + Option.prototype.cbid = function(sectionId) { return sectionId + '.' + this.option; }; + + function Section(map) { + this.map = map; + this.tabs = []; + this.options = []; + } + + Section.prototype.tab = function(tab) { + this.tabs.push(tab); + }; + + Section.prototype.taboption = function(tab, Type, option, title, description) { + const value = new Type(option); + value.map = this.map; + value.tab = tab; + value.title = title; + value.description = description; + this.options.push(value); + return value; + }; + + Section.prototype.option = function(Type, option) { + const value = new Type(option); + value.map = this.map; + this.options.push(value); + return value; + }; + + function FormMap(config) { + this.config = config; + this.sections = []; + this.formValues = {}; + this.data = { + save: async () => lifecycle.push('uci-save'), + load: async () => lifecycle.push('uci-load') + }; + maps.push(this); + } + + FormMap.prototype.section = function() { + const section = new Section(this); + this.sections.push(section); + return section; + }; + + FormMap.prototype.options = function() { + return this.sections.flatMap(section => section.options); + }; + + FormMap.prototype.checkDepends = function() { + lifecycle.push('depends'); + }; + + FormMap.prototype.parse = async function() { + lifecycle.push('parse'); + for (const option of this.options()) { + if (Object.prototype.hasOwnProperty.call(this.formValues, option.option) && typeof option.write === 'function') + await option.write('settings', this.formValues[option.option]); + } + }; + + FormMap.prototype.load = async function() { + lifecycle.push('load'); + }; + + FormMap.prototype.renderContents = async function() { + lifecycle.push('renderContents'); + nodes.clear(); + this.options().forEach(option => { + if (typeof option.renderWidget === 'function') + registerNode(option.renderWidget('settings', option.option)); + }); + return { map: this }; + }; + + FormMap.prototype.render = function() { + lifecycle.push('render'); + return this.renderContents(); + }; + + const form = { + Map: FormMap, + TypedSection: Option, + NamedSection: Option, + Flag: Option, + DummyValue: Option, + Value: Option, + ListValue: Option, + DynamicList: Option, + MultiValue: Option + }; + form.Value.extend = properties => { + function ExtendedOption(option) { + Option.call(this, option); + } + ExtendedOption.prototype = Object.assign(Object.create(Option.prototype), properties); + return ExtendedOption; + }; + + const context = { + module: { exports: {} }, + console, + Promise, + Object, + String: luciString, + Array, + Set, + Error, + _: value => value, + E, + form, + fs: {}, + network: { getNetworks: async () => [] }, + poll: { add: () => {} }, + rpc: { + declare: spec => { + rpcDeclarations.push(spec); + return async (...args) => { + rpcCalls.push({ method: spec.method, args }); + if (spec.method === 'secret_status') + return { authkey_set: '0', adguard_password_set: '0' }; + if (spec.method === 'openclash_bypass_status') + return { state: openclashState }; + return {}; + }; + } + }, + uci: { + load: async config => { + uciLoads.push(config); + }, + get: (config, section, option) => { + if (config === 'tailscale_openclash' && section === 'settings' && option === 'enabled') + return openclashUci.enabled; + return ''; + }, + set: (config, section, option, value) => { + uciSets.push({ config, section, option, value }); + if (config === 'tailscale_openclash' && section === 'settings' && option === 'enabled') + openclashUci.enabled = value; + return Promise.resolve('uci-set'); + }, + sections: () => [] + }, + ui: { showModal: () => {}, hideModal: () => {} }, + document: { + getElementById: id => nodes.get(id) || null + }, + window: { + setTimeout: (callback, delay) => { + scheduled.push({ callback, delay }); + return scheduled.length; + } + } + }; + + const viewObject = vm.runInNewContext(`(function() {\n${source}\n})()`, Object.assign(context, { + view: { extend: value => value } + })); + + return { + viewObject, + rpcDeclarations, + rpcCalls, + uciLoads, + uciSets, + scheduled, + maps, + lifecycle, + statusNodes, + currentStatusNode: () => nodes.get('openclash_bypass_status') + }; +} + +function scheduledOpenclashRefresh(runtime) { + return runtime.scheduled.find(entry => entry.callback.name === 'refreshOpenclashBypassStatus'); +} + +(async () => { + const runtime = createRuntime(); + const openclashStatusDeclaration = runtime.rpcDeclarations.find(spec => spec.method === 'openclash_bypass_status'); + assert(openclashStatusDeclaration, 'view must declare the OpenClash status RPC'); + assert(!openclashStatusDeclaration.params, 'OpenClash status RPC must not accept caller-controlled command arguments'); + + const data = await runtime.viewObject.load(); + assert(runtime.uciLoads.includes('tailscale_openclash'), 'load() must read the isolated OpenClash UCI package'); + assert(!runtime.rpcCalls.some(call => call.method === 'openclash_bypass_status'), 'load() must not call OpenClash status RPC'); + + await runtime.viewObject.render(data); + assert(!runtime.rpcCalls.some(call => call.method === 'openclash_bypass_status'), 'render() must schedule, not await, OpenClash status RPC'); + const initialRefresh = scheduledOpenclashRefresh(runtime); + const initialNode = runtime.currentStatusNode(); + assert(initialRefresh && initialRefresh.delay === 0, 'render() must queue the OpenClash status refresh immediately after rendering'); + assert(initialNode && initialNode.textContent === 'Checking ...', 'render() must create the initial OpenClash status node'); + await initialRefresh.callback(); + assert(initialNode.textContent === 'Enabled; 4 bypass rules are active', 'async status refresh must update the rendered status node'); + + const map = runtime.maps.find(candidate => candidate.config === 'tailscale'); + const openclashOption = map.options().find(option => option.tab === 'openclash' && option.option === 'openclash_bypass_enabled'); + assert(openclashOption, 'render() must create the OpenClash bypass toggle in its own tab'); + assert(openclashOption.title === 'Protect Tailscale Traffic (Bypass OpenClash)', 'toggle must render the protection-focused title'); + assert( + openclashOption.description === 'Keep Tailscale control connections, direct connections, Tailnet DNS, and subnet traffic outside OpenClash. When disabled, this traffic is handled by OpenClash; node connectivity, direct paths, subnet access, and internal DNS are no longer protected by this feature. Keep this enabled while using OpenClash.', + 'toggle must render the complete disabled-impact description' + ); + assert(openclashOption.cfgvalue() === '1', 'toggle must read the isolated enabled value'); + + const expectedStatuses = { + active: 'Enabled; 4 bypass rules are active', + waiting: 'Enabled; waiting for OpenClash nftables chains', + disabled: 'Disabled; Tailscale traffic is handled by OpenClash', + absent: 'OpenClash is not installed; bypass is not required', + unsupported: 'Unsupported; firewall4/nftables is required', + error: 'Configuration error' + }; + for (const [state, expected] of Object.entries(expectedStatuses)) { + const statusRuntime = createRuntime(state); + const statusData = await statusRuntime.viewObject.load(); + await statusRuntime.viewObject.render(statusData); + const refresh = scheduledOpenclashRefresh(statusRuntime); + const node = statusRuntime.currentStatusNode(); + assert(refresh && node, `status ${state} must schedule a rendered refresh`); + await refresh.callback(); + assert(node.textContent === expected, `status ${state} must render as: ${expected}`); + } + + runtime.scheduled.length = 0; + map.formValues.openclash_bypass_enabled = '0'; + await map.save(undefined, true); + assert(runtime.lifecycle.includes('parse') && runtime.lifecycle.includes('uci-save') && runtime.lifecycle.includes('load') && runtime.lifecycle.includes('renderContents'), 'save() must run the form parse, UCI save, reload, and content render lifecycle'); + assert( + runtime.uciSets.length === 1 && runtime.uciSets.every(call => + call.config === 'tailscale_openclash' && + call.section === 'settings' && + call.option === 'enabled' && + call.value === '0' + ), + 'actual map.save() must only write tailscale_openclash.settings.enabled' + ); + + const savedNode = runtime.currentStatusNode(); + const savedRefresh = scheduledOpenclashRefresh(runtime); + assert(savedNode && savedNode !== initialNode && runtime.statusNodes.length === 2, 'save() must replace the status node during renderContents()'); + assert(savedNode.textContent === 'Checking ...', 'the replacement status node must start in checking state'); + assert(savedRefresh && savedRefresh.delay === 0, 'successful save() must queue a status refresh after renderContents()'); + await savedRefresh.callback(); + assert(savedNode.textContent === 'Enabled; 4 bypass rules are active', 'post-save refresh must update the replacement status node'); + + console.log('setting OpenClash bypass tests passed'); +})().catch(error => { + console.error(error); + process.exit(1); +}); diff --git a/luci-app-tailscale/tests/setting_policy_routing_test.js b/luci-app-tailscale/tests/setting_policy_routing_test.js new file mode 100755 index 000000000..495b72a8f --- /dev/null +++ b/luci-app-tailscale/tests/setting_policy_routing_test.js @@ -0,0 +1,29 @@ +#!/usr/bin/env node + +'use strict'; + +const fs = require('fs'); +const path = require('path'); + +const source = fs.readFileSync(path.join(__dirname, '..', 'htdocs/luci-static/resources/view/tailscale/setting.js'), 'utf8'); + +function assert(condition, message) { + if (!condition) + throw new Error(message); +} + +assert(source.includes("uci.load('tailscale_policy_routing')"), 'setting view must load the isolated policy-routing UCI package'); +assert(source.includes("method: 'policy_routing_status'"), 'setting view must use the read-only policy-routing status RPC'); +assert(source.includes("uci.set('tailscale_policy_routing', 'settings', 'enabled'"), 'toggle must write only the isolated policy-routing UCI package'); +assert(!source.includes("uci.set('tailscale', section_id, 'mwan3_table52_precedence'"), 'toggle must not write the core Tailscale UCI package'); +assert(source.includes("_('Prioritize Tailscale Routes Before mwan3')"), 'setting view must expose the mwan3 precedence toggle'); +assert(source.includes("_('Give routes already present in Tailscale table 52 priority over mwan3-marked traffic. This fixes LAN clients being sent to a WAN instead of a Tailnet or routed subnet. Public destinations not present in table 52 continue through mwan3. Disabled leaves mwan3 precedence unchanged. Verify from a real LAN client or with an equivalent mwan3 fwmark; the router\\'s unmarked ip route get alone is insufficient. The feature remains blocked while table 52 has a default route, such as when using an exit node.')"), 'toggle description must explain scope, disabled impact, real-client validation, and the exit-node safety block'); +assert(source.includes('!status.mwan3_present') && source.includes('!status.mwan3_earlier_mark_rule'), 'policy-routing status must surface whether mwan3 and an earlier marked rule were detected'); +assert(source.includes("blocked_mwan3_priority: _('Blocked; a mwan3 rule has higher priority than 1000')"), 'policy-routing status must explain when a mwan3 rule precedes the managed priority'); + +const loadMatch = source.match(/load\(\)\s*\{([\s\S]*?)\n\t\},\n\n\trender/); +assert(loadMatch, 'expected setting view to define a load() method'); +assert(!loadMatch[1].includes('callPolicyRoutingStatus('), 'policy-routing status must not block initial page rendering'); +assert(source.includes('refreshPolicyRoutingStatus'), 'status must refresh after render'); + +console.log('setting policy-routing tests passed'); diff --git a/luci-app-tailscale/tests/setting_preflight_test.js b/luci-app-tailscale/tests/setting_preflight_test.js new file mode 100644 index 000000000..eb8f48853 --- /dev/null +++ b/luci-app-tailscale/tests/setting_preflight_test.js @@ -0,0 +1,306 @@ +const fs = require('fs'); +const vm = require('vm'); + +const source = fs.readFileSync('htdocs/luci-static/resources/view/tailscale/setting.js', 'utf8'); + +function assert(condition, message) { + if (!condition) + throw new Error(message); +} + +const loadMatch = source.match(/load\(\)\s*\{([\s\S]*?)\n\t\},\n\n\trender/); +assert(loadMatch, 'expected setting view to define a load() method'); + +assert( + !loadMatch[1].includes('tailscale_adguard_dns_switch'), + 'setting load() must not block first render on AdGuard DNS preflight' +); + +assert( + source.includes('refreshAdguardPreflightStatus'), + 'setting view should refresh AdGuard DNS preflight status after initial render' +); + +assert(source.includes("method: 'secret_status'"), 'setting view must load credential presence through a dedicated RPC'); +assert(source.includes("method: 'set_secrets'"), 'setting view must atomically write all changed credentials through one RPC'); +assert(source.includes("params: ['authkey_set', 'authkey', 'adguard_password_set', 'adguard_password', 'api_url', 'username', 'base_ref']"), 'credential staging must identify the secret version it extends'); +assert( + /load\(\)\s*\{\s*return callSecretStatus\(\)\.then/.test(source), + 'setting view must finish credential migration before loading the readable UCI configuration' +); +assert(!source.includes("uci.get('tailscale', 'settings', 'adguard_password')"), 'browser must never read the persisted AdGuard password'); +assert(!source.includes("uci.get('tailscale', 'settings', 'authkey')"), 'browser must never read the persisted auth key'); +assert(!source.includes("uci.set('tailscale', section_id, 'adguard_password'"), 'browser must not persist AdGuard passwords in readable UCI'); +assert(!source.includes("uci.set('tailscale', section_id, 'authkey'"), 'browser must not persist auth keys in readable UCI'); + +const helperSource = source.split('return view.extend({')[0]; + +const candidateValues = { + apiUrl: 'http://127.0.0.1:3001', + username: 'candidate-user', + password: 'candidate-password', + keepPassword: false, + defaultUpstreams: ['1.1.1.1', '8.8.8.8'], + tailnetUpstreams: ['[/example.test/]100.100.100.100'], + healthDomain: 'health.example.test', + expectedIps: ['10.23.0.15', '10.23.0.16'] +}; + +async function testEnablementWrite({ persistedEnabled, value, preflightResult, preflightError, candidateRequest, persistedRequest }) { + const calls = []; + const context = { + module: { exports: {} }, + console, + _: value => value, + rpc: { + declare: spec => { + if (spec.object !== 'luci.tailscale') + return function() {}; + return async (...args) => { + calls.push({ type: 'rpc', object: spec.object, method: spec.method, args }); + if (preflightError) + throw preflightError; + return preflightResult || {}; + }; + } + }, + fs: {}, + uci: { + get: (config, section, option) => { + calls.push({ type: 'get', config, section, option }); + return persistedEnabled ? '1' : '0'; + }, + set: (config, section, option, nextValue) => { + calls.push({ type: 'set', config, section, option, value: nextValue }); + return 'set-result'; + } + } + }; + + vm.runInNewContext( + `${helperSource}\nmodule.exports = { buildAdguardPreflightRequest, writeAdguardDnsSwitchEnabled };`, + context + ); + + const builtCandidateRequest = context.module.exports.buildAdguardPreflightRequest( + persistedEnabled + ? { ...candidateValues, password: '', keepPassword: true } + : candidateValues + ); + + let result; + let error; + try { + result = await context.module.exports.writeAdguardDnsSwitchEnabled( + 'settings', + value, + candidateRequest || builtCandidateRequest, + persistedRequest || Object.assign({}, builtCandidateRequest, { enabled: persistedEnabled ? '1' : '0' }) + ); + } catch (e) { + error = e; + } + + return { calls, result, error }; +} + +(async () => { + let outcome = await testEnablementWrite({ + persistedEnabled: false, + value: '1', + preflightResult: { adguard_process: 'pass', health_check: 'fail', ready: 'fail' } + }); + assert(outcome.calls.filter(call => call.type === 'rpc').length === 1, '0 -> 1 must run one fresh preflight RPC'); + const preflightCall = outcome.calls.find(call => call.type === 'rpc'); + assert(preflightCall.object === 'luci.tailscale' && preflightCall.method === 'adguard_preflight', 'preflight must use the dedicated Tailscale RPC method'); + assert(preflightCall.args[0] === '1', 'preflight RPC must opt into candidate configuration'); + assert(preflightCall.args[1] === candidateValues.apiUrl, 'preflight RPC must receive the candidate API URL'); + assert(preflightCall.args[4] === candidateValues.password, 'preflight RPC must receive a newly entered candidate password'); + assert(preflightCall.args[5] === '1.1.1.1\n8.8.8.8', 'preflight RPC must preserve candidate upstream list boundaries'); + assert(preflightCall.args[8] === '10.23.0.15\n10.23.0.16', 'preflight RPC must receive candidate expected IPs'); + assert(outcome.calls.every(call => call.type !== 'set'), 'failed initial enablement must not write the flag'); + assert(outcome.error && /status checks/i.test(outcome.error.message), 'failed initial enablement must reject save with a useful error'); + + outcome = await testEnablementWrite({ + persistedEnabled: false, + value: '1', + preflightResult: { adguard_process: 'pass', health_check: 'pass', ready: 'pass' } + }); + assert(outcome.calls.filter(call => call.type === 'rpc').length === 1, 'passing 0 -> 1 must still use a fresh preflight'); + assert(outcome.calls.some(call => call.type === 'set' && call.value === '1'), 'passing initial enablement must write the flag'); + assert(outcome.result === 'set-result', 'write hook must preserve the Promise-capable UCI write result'); + + outcome = await testEnablementWrite({ + persistedEnabled: true, + value: '1', + preflightError: new Error('transient health failure') + }); + assert(outcome.calls.every(call => call.type !== 'rpc'), 'unchanged already-enabled saves must not rerun or gate on preflight'); + assert(outcome.calls.some(call => call.type === 'set' && call.value === '1'), 'already-enabled saves must remain writable'); + + const persistedRequest = Object.assign({}, vm.runInNewContext( + `${helperSource}\nbuildAdguardPreflightRequest(${JSON.stringify({ ...candidateValues, password: '', keepPassword: true })})`, + { + module: { exports: {} }, + console, + _: value => value, + rpc: { declare: () => function() {} }, + fs: {}, + uci: {} + } + ), { enabled: '1' }); + + const changedEndpointWithoutPassword = Object.assign({}, persistedRequest, { + api_url: 'http://changed.example:3000' + }); + outcome = await testEnablementWrite({ + persistedEnabled: true, + value: '1', + candidateRequest: changedEndpointWithoutPassword, + persistedRequest + }); + assert(outcome.error && /password/i.test(outcome.error.message), 'changing a bound AdGuard endpoint must require the password again'); + assert(outcome.calls.every(call => call.type !== 'rpc' && call.type !== 'set'), 'missing replacement password must fail before preflight or UCI mutation'); + + const changedEndpointWithPassword = Object.assign({}, changedEndpointWithoutPassword, { + password_set: '1', + password: 'replacement-password' + }); + outcome = await testEnablementWrite({ + persistedEnabled: true, + value: '1', + candidateRequest: changedEndpointWithPassword, + persistedRequest, + preflightResult: { ready: 'pass' } + }); + assert(outcome.calls.filter(call => call.type === 'rpc').length === 1, 'changed AdGuard endpoint with a replacement password must rerun preflight'); + assert(outcome.calls.some(call => call.type === 'set' && call.value === '1'), 'passing changed-endpoint preflight must save the enabled flag'); + + const changedHealthDomain = Object.assign({}, persistedRequest, { + health_domain: 'changed-health.example.test' + }); + outcome = await testEnablementWrite({ + persistedEnabled: true, + value: '1', + candidateRequest: changedHealthDomain, + persistedRequest, + preflightResult: { ready: 'pass' } + }); + assert(outcome.calls.filter(call => call.type === 'rpc').length === 1, 'changed AdGuard health configuration must rerun preflight'); + + outcome = await testEnablementWrite({ + persistedEnabled: false, + value: '0', + preflightError: new Error('AdGuard unavailable') + }); + assert(outcome.calls.every(call => call.type !== 'rpc'), 'disabled saves must not run preflight'); + assert(outcome.calls.some(call => call.type === 'set' && call.value === '0'), 'disabled saves must remain writable'); + + const keepPasswordRequest = vm.runInNewContext( + `${helperSource}\nbuildAdguardPreflightRequest(${JSON.stringify({ ...candidateValues, password: '', keepPassword: true })})`, + { + module: { exports: {} }, + console, + _: value => value, + rpc: { declare: () => function() {} }, + fs: {}, + uci: {} + } + ); + assert(keepPasswordRequest.password_set === '0', 'blank password must tell preflight to retain the persisted secret'); + assert(keepPasswordRequest.password === '', 'persisted password must not be copied into the browser RPC request'); + + assert( + source.includes('buildAdguardPreflightRequest({') && source.includes('writeAdguardDnsSwitchEnabled(section_id, value, candidateRequest, persistedAdguardConfig)'), + 'the AdGuard flag write hook must build candidate values from the live form before preflight' + ); + + const atomicContext = { + module: { exports: {} }, + console, + _: value => value, + rpc: { declare: () => function() {} }, + fs: {}, + ui: { showModal: () => {} }, + uci: {}, + E: () => ({}) + }; + vm.runInNewContext( + `${helperSource}\nmodule.exports = { buildSecretUpdateRequest, saveMapWithSecrets };`, + atomicContext + ); + const secretRequest = atomicContext.module.exports.buildSecretUpdateRequest({ + authkey: 'new-auth-key', + adguardPassword: 'new-adguard-password', + adguardApiUrl: 'https://adguard.example:3000', + adguardUsername: 'root' + }); + assert(secretRequest.authkey_set === '1' && secretRequest.adguard_password_set === '1', 'one atomic request must carry both changed credentials'); + const spacedPasswordRequest = atomicContext.module.exports.buildSecretUpdateRequest({ + adguardPassword: ' password with spaces ' + }); + assert(spacedPasswordRequest.adguard_password === ' password with spaces ', 'AdGuard passwords must preserve leading and trailing spaces'); + assert(!source.includes("String(adguardPasswordOption.formvalue(section_id) || '').trim()"), 'live AdGuard password input must not be trimmed before preflight'); + assert(source.includes("uci.set('tailscale', 'settings', 'secrets_ref'"), 'staged credentials must be activated through a UCI-managed version reference'); + assert(source.includes('saveInFlight'), 'concurrent Save and Save & Apply actions must share one in-flight save transaction'); + + const saveCalls = []; + const map = { + checkDepends: () => saveCalls.push('depends'), + parse: async () => saveCalls.push('parse'), + data: { save: async () => saveCalls.push('uci-save') }, + load: async () => saveCalls.push('load'), + renderContents: async () => saveCalls.push('render') + }; + await atomicContext.module.exports.saveMapWithSecrets( + map, + undefined, + true, + async () => { saveCalls.push('stage'); return { ref: 'staged-ref' }; }, + async staged => saveCalls.push(`after:${staged.ref}`) + ); + assert(saveCalls.join(',') === 'depends,parse,stage,uci-save,load,after:staged-ref,render', 'credentials must be staged before the UCI reference is saved and remain inactive until apply'); + + let afterSaveCalled = false; + const failingMap = { + checkDepends: () => {}, + parse: async () => {}, + data: { save: async () => { throw new Error('uci save failed'); } }, + load: async () => {}, + renderContents: async () => {} + }; + try { + await atomicContext.module.exports.saveMapWithSecrets( + failingMap, + undefined, + true, + async () => ({ ref: 'orphaned-but-inactive-ref' }), + async () => { afterSaveCalled = true; } + ); + } catch (e) {} + assert(!afterSaveCalled, 'failed UCI persistence must not report the staged credential reference as saved'); + + let uciSaveCalled = false; + const stageFailingMap = { + checkDepends: () => {}, + parse: async () => {}, + data: { save: async () => { uciSaveCalled = true; } }, + load: async () => {}, + renderContents: async () => {} + }; + try { + await atomicContext.module.exports.saveMapWithSecrets( + stageFailingMap, + undefined, + true, + async () => { throw new Error('secret staging failed'); }, + async () => {} + ); + } catch (e) {} + assert(!uciSaveCalled, 'failed secret staging must not save a UCI reference or any other form changes'); + + console.log('setting preflight tests passed'); +})().catch(error => { + console.error(error); + process.exit(1); +}); diff --git a/luci-app-tailscale/tests/tailscale_adguard_dns_switch_test.sh b/luci-app-tailscale/tests/tailscale_adguard_dns_switch_test.sh new file mode 100755 index 000000000..c19e11eb7 --- /dev/null +++ b/luci-app-tailscale/tests/tailscale_adguard_dns_switch_test.sh @@ -0,0 +1,579 @@ +#!/bin/sh + +set -eu + +ROOT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)" +SCRIPT="$ROOT_DIR/root/usr/sbin/tailscale_adguard_dns_switch" +TMP_DIR="${TMPDIR:-/tmp}/tailscale-adguard-dns-test.$$" + +cleanup() { + rm -rf "$TMP_DIR" +} +trap cleanup EXIT + +fail() { + echo "FAIL: $*" >&2 + exit 1 +} + +assert_eq() { + expected="$1" + actual="$2" + message="$3" + + [ "$expected" = "$actual" ] || fail "$message +expected: $expected +actual: $actual" +} + +assert_contains() { + needle="$1" + haystack="$2" + message="$3" + + printf '%s' "$haystack" | grep -F -- "$needle" >/dev/null || fail "$message +missing: $needle +actual: $haystack" +} + +assert_occurrences() { + expected="$1" + needle="$2" + file="$3" + message="$4" + actual="$(grep -F -c -- "$needle" "$file" || true)" + + [ "$expected" = "$actual" ] || fail "$message +expected occurrences: $expected +actual occurrences: $actual +needle: $needle" +} + +make_fake_uci() { + cat >"$TMP_DIR/uci" <<'SH' +#!/bin/sh +case "$*" in + "-q get tailscale.settings.adguard_dns_switch_enabled") echo "${UCI_SWITCH_ENABLED:-1}" ;; + "-q get tailscale.settings.accept_dns") echo "${UCI_ACCEPT_DNS:-1}" ;; + "-q get tailscale.settings.adguard_api_url") echo "${UCI_API_URL:-http://127.0.0.1:3000}" ;; + "-q get tailscale.settings.adguard_username") echo "${UCI_API_USER:-}" ;; + "-q get tailscale.settings.adguard_password") echo "${UCI_API_PASS:-}" ;; + "-q get tailscale.settings.adguard_health_domain") echo "${UCI_HEALTH_DOMAIN:-service.example.test}" ;; + "-q get tailscale.settings.adguard_check_interval") echo "${UCI_CHECK_INTERVAL:-10}" ;; + "-q get tailscale.settings.adguard_success_threshold") echo "${UCI_SUCCESS_THRESHOLD:-2}" ;; + "-q get tailscale.settings.adguard_failure_threshold") echo "${UCI_FAILURE_THRESHOLD:-2}" ;; + "-q get tailscale.settings.adguard_default_upstreams") + [ "${UCI_EMPTY_DEFAULT_UPSTREAMS:-0}" = "1" ] && exit 1 + echo "[/lan/]127.0.0.1:5353 9.9.9.9 100.100.100.100 149.112.112.112 9.9.9.9" + ;; + "-q get tailscale.settings.adguard_tailnet_upstreams") + echo "[/example.ts.net/]100.100.100.100 [/internal.example/]100.100.100.100" + ;; + "-q get tailscale.settings.adguard_health_expected_ips") echo "10.23.0.15" ;; + "-q get network.lan.ipaddr") echo "${UCI_LAN_IP:-192.168.100.1}" ;; + "-q get dhcp.lan.dhcp_option") echo "${UCI_DHCP_OPTIONS:-6,192.168.100.1}" ;; + "show tailscale.settings.adguard_default_upstreams") + [ "${UCI_EMPTY_DEFAULT_UPSTREAMS:-0}" = "1" ] && exit 0 + echo "tailscale.settings.adguard_default_upstreams='[/lan/]127.0.0.1:5353' '9.9.9.9' '100.100.100.100' '149.112.112.112' '9.9.9.9'" + ;; + "show tailscale.settings.adguard_tailnet_upstreams") + echo "tailscale.settings.adguard_tailnet_upstreams='[/example.ts.net/]100.100.100.100' '[/internal.example/]100.100.100.100'" + ;; + "show tailscale.settings.adguard_health_expected_ips") + echo "tailscale.settings.adguard_health_expected_ips='10.23.0.15'" + ;; + *) exit 1 ;; +esac +SH + chmod +x "$TMP_DIR/uci" +} + +make_fake_commands() { + make_fake_uci + + cat >"$TMP_DIR/secrets" <<'SH' +#!/bin/sh +printf '%s\n' "$*" >>"${SECRETS_LOG:?}" +case "${1:-}" in + adguard-password-for) + requested_url="${2:-}" + requested_user="${3:-}" + bound_url="${SECRET_BOUND_URL:-${UCI_API_URL:-http://127.0.0.1:3000}}" + bound_url="${bound_url%/}" + bound_user="${SECRET_BOUND_USER:-${UCI_API_USER:-}}" + [ "$requested_url" = "$bound_url" ] && [ "$requested_user" = "$bound_user" ] || exit 1 + [ -n "${SECRET_PASSWORD:-}" ] || exit 1 + printf '%s\n' "$SECRET_PASSWORD" + ;; + *) exit 1 ;; +esac +SH + chmod +x "$TMP_DIR/secrets" + + cat >"$TMP_DIR/nslookup" <<'SH' +#!/bin/sh +printf '%s\n' "$*" >>"${NSLOOKUP_LOG:?}" +if [ "${NSLOOKUP_HEALTH:-ok}" = "ok" ]; then + cat <<'OUT' +Server: 100.100.100.100 +Address: 100.100.100.100:53 +Name: service.example.test +Address: 10.23.0.15 +OUT +else + cat <<'OUT' +Server: 100.100.100.100 +Address: 100.100.100.100:53 +** server can't find service.example.test: SERVFAIL +OUT + exit 1 +fi +SH + chmod +x "$TMP_DIR/nslookup" + + cat >"$TMP_DIR/curl" <<'SH' +#!/bin/sh +printf '%s\n' "$*" >>"${CURL_LOG:?}" +read_config=0 +for arg in "$@"; do + if [ "$read_config" = "1" ]; then + cat "$arg" >>"${CURL_AUTH_LOG:?}" + read_config=0 + elif [ "$arg" = "--config" ]; then + read_config=1 + fi +done +case "$*" in + *"/control/status"*) echo '{"running":true,"dns_port":53}' ;; + *"/control/dns_info"*) + [ "${CURL_DNS_INFO_FAIL:-0}" = "1" ] && exit 1 + cat "${DNS_INFO_JSON:?}" + ;; + *"/control/dns_config"*) + body= + while [ "$#" -gt 0 ]; do + if [ "$1" = "--data-binary" ]; then + shift + body="${1#@}" + fi + shift + done + [ "${CURL_DNS_CONFIG_FAIL:-0}" = "1" ] && exit 1 + [ -n "$body" ] && cat "$body" >"${POSTED_JSON:?}" + echo OK + ;; + *"/control/test_upstream_dns"*) + body= + while [ "$#" -gt 0 ]; do + if [ "$1" = "--data-binary" ]; then + shift + body="${1#@}" + fi + shift + done + [ -n "$body" ] && cat "$body" >"${TEST_UPSTREAM_JSON:?}" + [ "${CURL_TEST_UPSTREAM_FAIL:-0}" = "1" ] && exit 1 + if [ -n "${CURL_TEST_UPSTREAM_RESPONSE:-}" ]; then + printf '%s\n' "$CURL_TEST_UPSTREAM_RESPONSE" + else + cat <<'JSON' +{"[/lan/]127.0.0.1:5353":"OK","9.9.9.9":"OK","149.112.112.112":"OK"} +JSON + fi + ;; + *"/control/cache_clear"*) + [ "${CURL_CACHE_CLEAR_FAIL:-0}" = "1" ] && exit 1 + echo OK + ;; + *) echo OK ;; +esac +SH + chmod +x "$TMP_DIR/curl" + + cat >"$TMP_DIR/flock" <<'SH' +#!/bin/sh +printf '%s\n' "$*" >>"${FLOCK_LOG:?}" +[ "${FLOCK_FAIL:-0}" = "0" ] +SH + chmod +x "$TMP_DIR/flock" + + cat >"$TMP_DIR/logger" <<'SH' +#!/bin/sh +printf '%s\n' "$*" >>"${LOGGER_LOG:?}" +SH + chmod +x "$TMP_DIR/logger" + +cat >"$TMP_DIR/sleep" <<'SH' +#!/bin/sh +printf '%s\n' "$*" >>"${SLEEP_LOG:?}" +count_file="${SLEEP_COUNT_FILE:?}" +count="$(cat "$count_file" 2>/dev/null || echo 0)" +count=$((count + 1)) +printf '%s\n' "$count" >"$count_file" +if [ "$count" -ge "${SLEEP_MAX_CALLS:-1}" ]; then + kill -TERM "$PPID" +fi +exit 0 +SH + chmod +x "$TMP_DIR/sleep" + + cat >"$TMP_DIR/pgrep" <<'SH' +#!/bin/sh +[ "${PGREP_ADGUARD:-1}" = "1" ] && exit 0 +exit 1 +SH + chmod +x "$TMP_DIR/pgrep" + + cat >"$TMP_DIR/netstat" <<'SH' +#!/bin/sh +if [ "${NETSTAT_ADGUARD_53:-1}" = "1" ]; then + echo "udp 0 0 0.0.0.0:53 0.0.0.0:* 1234/AdGuardHome" +else + echo "udp 0 0 0.0.0.0:53 0.0.0.0:* 1234/dnsmasq" +fi +SH + chmod +x "$TMP_DIR/netstat" + + cat >"$TMP_DIR/ubus" <<'SH' +#!/bin/sh +cat <<'OUT' +{"ipv4-address":[{"address":"192.168.100.1","mask":24}]} +OUT +SH + chmod +x "$TMP_DIR/ubus" + +cat >"$TMP_DIR/jq" <<'SH' +#!/bin/sh +printf '%s\n' "$*" >>"${JQ_LOG:?}" +[ "${JQ_REPLACE_FAIL:-0}" = "1" ] && printf '%s\n' "$*" | grep -F -- '--rawfile upstreams' >/dev/null && exit 1 +exec "${REAL_JQ:?}" "$@" +SH + chmod +x "$TMP_DIR/jq" + + PATH="$TMP_DIR:$PATH" + export PATH +} + +prepare_api_json() { + CURL_LOG="$TMP_DIR/curl.log" + CURL_AUTH_LOG="$TMP_DIR/curl-auth.log" + FLOCK_LOG="$TMP_DIR/flock.log" + LOGGER_LOG="$TMP_DIR/logger.log" + DNS_INFO_JSON="$TMP_DIR/dns-info.json" + POSTED_JSON="$TMP_DIR/posted.json" + TEST_UPSTREAM_JSON="$TMP_DIR/test-upstream.json" + JQ_LOG="$TMP_DIR/jq.log" + NSLOOKUP_LOG="$TMP_DIR/nslookup-call.log" + SLEEP_LOG="$TMP_DIR/sleep.log" + SLEEP_COUNT_FILE="$TMP_DIR/sleep.count" + SECRETS_LOG="$TMP_DIR/secrets.log" + REAL_JQ="${REAL_JQ:-$(command -v jq)}" + STATE_DIR="$TMP_DIR/state" + export CURL_LOG CURL_AUTH_LOG FLOCK_LOG LOGGER_LOG DNS_INFO_JSON POSTED_JSON TEST_UPSTREAM_JSON JQ_LOG NSLOOKUP_LOG SLEEP_LOG SLEEP_COUNT_FILE SECRETS_LOG REAL_JQ STATE_DIR + : >"$CURL_AUTH_LOG" + : >"$FLOCK_LOG" + : >"$SECRETS_LOG" + printf '0\n' >"$SLEEP_COUNT_FILE" + + cat >"$DNS_INFO_JSON" <<'JSON' +{"upstream_dns":["1.1.1.1"],"bootstrap_dns":["223.5.5.5"],"fallback_dns":["119.29.29.29"],"cache_enabled":false,"cache_size":4194304} +JSON +} + +run_script() { + UCI_CMD="$TMP_DIR/uci" \ + SECRETS_BIN="$TMP_DIR/secrets" \ + CURL_CMD="$TMP_DIR/curl" \ + FLOCK_CMD="$TMP_DIR/flock" \ + LOGGER_CMD="$TMP_DIR/logger" \ + NSLOOKUP_CMD="$TMP_DIR/nslookup" \ + PGREP_CMD="$TMP_DIR/pgrep" \ + NETSTAT_CMD="$TMP_DIR/netstat" \ + UBUS_CMD="$TMP_DIR/ubus" \ + JQ_CMD="$TMP_DIR/jq" \ + SLEEP_CMD="$TMP_DIR/sleep" \ + "$SCRIPT" "$@" +} + +test_persisted_password_is_bound_to_saved_endpoint() { + : >"$CURL_AUTH_LOG" + : >"$SECRETS_LOG" + UCI_API_URL='http://attacker.example:3000' \ + UCI_API_USER='saved-user' \ + UCI_API_PASS='legacy-readable-secret' \ + SECRET_BOUND_URL='http://saved.example:3000' \ + SECRET_BOUND_USER='saved-user' \ + SECRET_PASSWORD='protected-secret' \ + run_script --preflight >/dev/null || true + + if grep -F -- 'legacy-readable-secret' "$CURL_AUTH_LOG" >/dev/null || grep -F -- 'protected-secret' "$CURL_AUTH_LOG" >/dev/null; then + fail "a changed AdGuard endpoint must not receive either legacy or protected saved passwords" + fi + assert_contains 'adguard-password-for http://attacker.example:3000 saved-user' "$(cat "$SECRETS_LOG")" "runtime must verify the endpoint binding before loading the protected password" +} + +test_preflight_uses_candidate_configuration() { + : >"$CURL_LOG" + : >"$CURL_AUTH_LOG" + : >"$NSLOOKUP_LOG" + + out="$( + TAILSCALE_ADGUARD_PREFLIGHT_CANDIDATE=1 \ + TAILSCALE_ADGUARD_PREFLIGHT_API_URL='http://candidate.example:3000' \ + TAILSCALE_ADGUARD_PREFLIGHT_USERNAME='candidate-user' \ + TAILSCALE_ADGUARD_PREFLIGHT_PASSWORD_SET=1 \ + TAILSCALE_ADGUARD_PREFLIGHT_PASSWORD='candidate-secret' \ + TAILSCALE_ADGUARD_PREFLIGHT_DEFAULT_UPSTREAMS="$(printf '1.1.1.1\n8.8.8.8')" \ + TAILSCALE_ADGUARD_PREFLIGHT_TAILNET_UPSTREAMS='[/candidate.example/]100.100.100.100' \ + TAILSCALE_ADGUARD_PREFLIGHT_HEALTH_DOMAIN='candidate-health.example' \ + TAILSCALE_ADGUARD_PREFLIGHT_EXPECTED_IPS='10.23.0.15' \ + run_script --preflight + )" + + assert_contains 'ready=pass' "$out" "candidate configuration should pass preflight" + assert_eq '{"upstream_dns":["1.1.1.1","8.8.8.8"]}' "$(cat "$TEST_UPSTREAM_JSON")" "preflight API payload must use candidate upstreams instead of persisted UCI" + assert_contains 'candidate-health.example 100.100.100.100' "$(cat "$NSLOOKUP_LOG")" "health check must use the candidate domain" + assert_contains 'http://candidate.example:3000/control/status' "$(cat "$CURL_LOG")" "AdGuard API checks must use the candidate URL" + assert_contains 'user = "candidate-user:candidate-secret"' "$(cat "$CURL_AUTH_LOG")" "curl must receive candidate credentials through a protected config" + if grep -F -- 'candidate-secret' "$CURL_LOG" >/dev/null || grep -F -- '-u ' "$CURL_LOG" >/dev/null; then + fail "AdGuard credentials must not appear in curl argv" + fi +} + +test_profile_generation() { + up="$(run_script --profile up)" + down="$(run_script --profile down)" + + assert_eq "[/lan/]127.0.0.1:5353 +[/example.ts.net/]100.100.100.100 +[/internal.example/]100.100.100.100 +9.9.9.9 +149.112.112.112 +100.100.100.100" "$up" "up profile should place tailnet conditionals before public defaults" + assert_eq "[/lan/]127.0.0.1:5353 +9.9.9.9 +149.112.112.112" "$down" "down profile should remove 100.100.100.100 and dedupe defaults" +} + +test_health_check() { + NSLOOKUP_HEALTH=ok run_script --check-health >/dev/null + nslookup_call="$(cat "$TMP_DIR/nslookup-call.log")" + assert_contains "service.example.test 100.100.100.100" "$nslookup_call" "health check must use hard-coded Tailscale DNS" + if NSLOOKUP_HEALTH=fail run_script --check-health >/dev/null 2>&1; then + fail "failed nslookup must make health check fail" + fi +} + +test_preflight_reports_failures() { + out="$(NETSTAT_ADGUARD_53=0 run_script --preflight || true)" + assert_contains "port_53_adguard=fail" "$out" "preflight should report port 53 ownership failure" + assert_contains "ready=fail" "$out" "preflight should not be ready when a required check fails" +} + +test_preflight_requires_api_post_test() { + out="$(CURL_TEST_UPSTREAM_FAIL=1 run_script --preflight || true)" + assert_contains "adguard_api=fail" "$out" "preflight should report AdGuard API test POST failure" + assert_contains "ready=fail" "$out" "preflight should not be ready when AdGuard API test POST fails" + + if CURL_TEST_UPSTREAM_FAIL=1 run_script --preflight >/dev/null 2>&1; then + fail "preflight should fail when AdGuard test_upstream_dns POST fails" + fi + if grep -F '/control/dns_config' "$CURL_LOG" >/dev/null; then + fail "preflight must not POST to /control/dns_config" + fi + assert_contains "/control/test_upstream_dns" "$(cat "$CURL_LOG")" "preflight should POST to test_upstream_dns" + assert_eq '{"upstream_dns":["[/lan/]127.0.0.1:5353","9.9.9.9","149.112.112.112"]}' "$(cat "$TEST_UPSTREAM_JSON")" "preflight API payload should come from configured default upstreams" + assert_contains "--rawfile upstreams" "$(cat "$JQ_LOG")" "preflight API payload should be built from the validated upstream file with jq" +} + +test_preflight_rejects_upstream_error_response() { + out="$(CURL_TEST_UPSTREAM_RESPONSE='{"[/lan/]127.0.0.1:5353":"OK","9.9.9.9":"upstream timeout","149.112.112.112":"OK"}' run_script --preflight || true)" + + assert_contains "adguard_api=fail" "$out" "preflight should fail the AdGuard API check when an upstream test reports an error" + assert_contains "ready=fail" "$out" "preflight should not be ready when an upstream test reports an error" +} + +test_preflight_requires_configured_default_upstream() { + : >"$LOGGER_LOG" + out="$(UCI_EMPTY_DEFAULT_UPSTREAMS=1 run_script --preflight || true)" + assert_contains "adguard_api=fail" "$out" "preflight should fail the API check when no default upstream is configured" + assert_contains "ready=fail" "$out" "preflight should not be ready without a default upstream" + assert_contains "requires at least one valid default upstream" "$(cat "$LOGGER_LOG")" "empty default upstream failure should be logged clearly" +} + +test_preflight_does_not_require_accept_dns() { + out="$(UCI_ACCEPT_DNS=0 run_script --preflight)" + assert_contains "ready=pass" "$out" "preflight should pass when direct Tailscale DNS health checks work with accept_dns disabled" + if printf '%s' "$out" | grep -F 'accept_dns=' >/dev/null; then + fail "accept_dns should not be a blocking AdGuard DNS auto switch preflight check" + fi +} + +test_apply_profile_preserves_dns_info() { + : >"$FLOCK_LOG" + run_script --apply-profile up + + posted="$(cat "$POSTED_JSON")" + assert_contains '"bootstrap_dns":["223.5.5.5"]' "$posted" "API payload should preserve bootstrap_dns" + assert_contains '"fallback_dns":["119.29.29.29"]' "$posted" "API payload should preserve fallback_dns" + assert_contains '"upstream_dns":["[/lan/]127.0.0.1:5353","[/example.ts.net/]100.100.100.100","[/internal.example/]100.100.100.100","9.9.9.9","149.112.112.112","100.100.100.100"]' "$posted" "API payload should replace upstream_dns" + assert_contains "--rawfile upstreams" "$(cat "$JQ_LOG")" "profile application should use jq with raw upstream file input" + assert_contains "--connect-timeout 3" "$(cat "$CURL_LOG")" "curl calls should include a connect timeout" + assert_contains "--max-time 10" "$(cat "$CURL_LOG")" "curl calls should include a max time" + assert_contains "/control/cache_clear" "$(cat "$CURL_LOG")" "profile application should always clear AdGuard cache after switching" + assert_contains "-x 9" "$(cat "$FLOCK_LOG")" "profile application must acquire an exclusive lock" +} + +test_down_profile_uses_saved_snapshot_when_config_is_cleared() { + rm -rf "$STATE_DIR" + mkdir -p "$STATE_DIR" + run_script --apply-profile up + + [ -s "$STATE_DIR/down_profile" ] || fail "switching up must persist a recoverable down profile" + : >"$CURL_LOG" + rm -f "$POSTED_JSON" + if ! UCI_EMPTY_DEFAULT_UPSTREAMS=1 run_script --apply-profile down; then + fail "saved down-profile recovery failed +logger: $(cat "$LOGGER_LOG") +curl: $(cat "$CURL_LOG")" + fi + + assert_contains '"upstream_dns":["[/lan/]127.0.0.1:5353","9.9.9.9","149.112.112.112"]' "$(cat "$POSTED_JSON")" "a saved down profile must restore public DNS after the new UCI defaults are cleared" + assert_eq "down" "$(cat "$STATE_DIR/current_profile")" "successful recovery must record the down profile" +} + +test_apply_profile_fails_when_lock_is_unavailable() { + : >"$CURL_LOG" + if FLOCK_FAIL=1 run_script --apply-profile up >/dev/null 2>&1; then + fail "profile application must fail when the exclusive lock cannot be acquired" + fi + if grep -F '/control/dns_info' "$CURL_LOG" >/dev/null; then + fail "profile application must not touch AdGuard before acquiring the lock" + fi +} + +test_symlink_state_directory_is_rejected() { + evil_dir="$TMP_DIR/evil-state-target" + rm -rf "$STATE_DIR" "$evil_dir" + mkdir -p "$evil_dir" + ln -s "$evil_dir" "$STATE_DIR" + + if run_script --apply-profile up >/dev/null 2>&1; then + fail "a symlink state directory must be rejected" + fi + [ -z "$(find "$evil_dir" -mindepth 1 -print -quit)" ] || fail "a rejected symlink state directory must not receive root-written files" + + rm -f "$STATE_DIR" + mkdir -p "$STATE_DIR" +} + +test_empty_profile_does_not_write_dns_config() { + : >"$CURL_LOG" + rm -f "$POSTED_JSON" + + if UCI_EMPTY_DEFAULT_UPSTREAMS=1 run_script --apply-profile down >/dev/null 2>&1; then + fail "empty down profile should make apply-profile fail" + fi + if [ -e "$POSTED_JSON" ]; then + fail "empty profile must not write dns_config payload" + fi + if grep -F '/control/dns_config' "$CURL_LOG" >/dev/null; then + fail "empty profile must not POST to /control/dns_config" + fi +} + +test_run_loop_applies_down_profile_when_initial_health_fails() { + : >"$CURL_LOG" + : >"$SLEEP_LOG" + rm -f "$POSTED_JSON" + mkdir -p "$STATE_DIR" + printf 'up\n' >"$STATE_DIR/current_profile" + printf '0\n' >"$SLEEP_COUNT_FILE" + + UCI_FAILURE_THRESHOLD=1 NSLOOKUP_HEALTH=fail UCI_CHECK_INTERVAL=1 run_script --run >/dev/null 2>&1 || true + + posted="$(cat "$POSTED_JSON")" + assert_contains '"upstream_dns":["[/lan/]127.0.0.1:5353","9.9.9.9","149.112.112.112"]' "$posted" "run loop should apply down profile when health initially fails" + assert_contains "1" "$(cat "$SLEEP_LOG")" "run loop should continue after applying the down profile" +} + +test_run_loop_runs_static_preflight_once_before_health_polling() { + rm -rf "$STATE_DIR" + mkdir -p "$STATE_DIR" + : >"$CURL_LOG" + : >"$NSLOOKUP_LOG" + : >"$SLEEP_LOG" + printf 'up\n' >"$STATE_DIR/current_profile" + printf '0\n' >"$SLEEP_COUNT_FILE" + + SLEEP_MAX_CALLS=3 UCI_CHECK_INTERVAL=1 run_script --run >/dev/null 2>&1 || true + + assert_occurrences 1 "/control/test_upstream_dns" "$CURL_LOG" "static AdGuard API preflight should run only once after it succeeds" + assert_occurrences 3 "service.example.test 100.100.100.100" "$NSLOOKUP_LOG" "health polling should continue after static preflight succeeds" +} + +test_run_loop_retries_static_preflight_failures_without_exit_churn() { + rm -rf "$STATE_DIR" + mkdir -p "$STATE_DIR" + : >"$LOGGER_LOG" + : >"$SLEEP_LOG" + printf '0\n' >"$SLEEP_COUNT_FILE" + + SLEEP_MAX_CALLS=3 NETSTAT_ADGUARD_53=0 UCI_CHECK_INTERVAL=1 run_script --run >/dev/null 2>&1 || true + + assert_eq "3" "$(cat "$SLEEP_COUNT_FILE")" "static preflight failure should stay in the worker and retry internally" + assert_occurrences 1 "static preflight failed: port_53_adguard" "$LOGGER_LOG" "identical static preflight failures should be rate-limited without hiding the first" + + printf '0\n' >"$SLEEP_COUNT_FILE" + SLEEP_MAX_CALLS=1 PGREP_ADGUARD=0 UCI_CHECK_INTERVAL=1 run_script --run >/dev/null 2>&1 || true + assert_occurrences 1 "static preflight failed: adguard_process" "$LOGGER_LOG" "a different static preflight failure should log immediately" +} + +test_profile_failures_are_rate_limited_by_signature() { + rm -rf "$STATE_DIR" + mkdir -p "$STATE_DIR" + : >"$LOGGER_LOG" + + CURL_DNS_INFO_FAIL=1 run_script --apply-profile up >/dev/null 2>&1 || true + CURL_DNS_INFO_FAIL=1 run_script --apply-profile up >/dev/null 2>&1 || true + assert_occurrences 1 "failed to read AdGuard DNS config" "$LOGGER_LOG" "repeated profile read failures should be rate-limited" + + JQ_REPLACE_FAIL=1 run_script --apply-profile up >/dev/null 2>&1 || true + JQ_REPLACE_FAIL=1 run_script --apply-profile up >/dev/null 2>&1 || true + assert_occurrences 1 "failed to build AdGuard DNS config payload" "$LOGGER_LOG" "repeated profile build failures should be rate-limited" + + CURL_DNS_CONFIG_FAIL=1 run_script --apply-profile up >/dev/null 2>&1 || true + CURL_DNS_CONFIG_FAIL=1 run_script --apply-profile up >/dev/null 2>&1 || true + assert_occurrences 1 "failed to write AdGuard DNS up profile" "$LOGGER_LOG" "repeated profile write failures should be rate-limited" + + CURL_CACHE_CLEAR_FAIL=1 run_script --apply-profile up >/dev/null 2>&1 + CURL_CACHE_CLEAR_FAIL=1 run_script --apply-profile up >/dev/null 2>&1 + assert_occurrences 1 "AdGuard cache clear failed after up profile switch" "$LOGGER_LOG" "repeated cache-clear failures should be rate-limited" +} + +mkdir -p "$TMP_DIR" +REAL_JQ="${REAL_JQ:-$(command -v jq)}" +export REAL_JQ +make_fake_commands +prepare_api_json + +test_profile_generation +test_health_check +test_preflight_reports_failures +test_preflight_requires_api_post_test +test_preflight_rejects_upstream_error_response +test_preflight_requires_configured_default_upstream +test_preflight_does_not_require_accept_dns +test_preflight_uses_candidate_configuration +test_persisted_password_is_bound_to_saved_endpoint +test_apply_profile_preserves_dns_info +test_down_profile_uses_saved_snapshot_when_config_is_cleared +test_apply_profile_fails_when_lock_is_unavailable +test_symlink_state_directory_is_rejected +test_empty_profile_does_not_write_dns_config +test_run_loop_applies_down_profile_when_initial_health_fails +test_run_loop_runs_static_preflight_once_before_health_polling +test_run_loop_retries_static_preflight_failures_without_exit_churn +test_profile_failures_are_rate_limited_by_signature + +echo "tailscale_adguard_dns_switch tests passed" diff --git a/luci-app-tailscale/tests/tailscale_helper_network_cleanup_test.sh b/luci-app-tailscale/tests/tailscale_helper_network_cleanup_test.sh new file mode 100755 index 000000000..6380bb368 --- /dev/null +++ b/luci-app-tailscale/tests/tailscale_helper_network_cleanup_test.sh @@ -0,0 +1,736 @@ +#!/bin/sh + +set -eu + +ROOT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)" +SCRIPT="$ROOT_DIR/root/usr/sbin/tailscale_helper" +TMP_DIR="${TMPDIR:-/tmp}/tailscale-helper-test.$$" + +cleanup() { + rm -rf "$TMP_DIR" +} +trap cleanup EXIT + +fail() { + echo "FAIL: $*" >&2 + exit 1 +} + +assert_contains() { + needle="$1" + haystack="$2" + message="$3" + + printf '%s' "$haystack" | grep -F -- "$needle" >/dev/null || fail "$message +missing: $needle" +} + +assert_not_contains() { + needle="$1" + haystack="$2" + message="$3" + + if printf '%s' "$haystack" | grep -F -- "$needle" >/dev/null; then + fail "$message +unexpected: $needle" + fi +} + +body="$(cat "$SCRIPT")" + +assert_contains "uci -q delete network.tailscale" "$body" "helper should remove the legacy LuCI network interface wrapper" +assert_contains "add_list firewall.tszone.device='tailscale0'" "$body" "firewall zone should bind directly to tailscale0" +assert_contains "apply_firewall_changes" "$body" "helper should apply committed firewall changes through one checked path" + +assert_not_contains "set network.tailscale='interface'" "$body" "helper must not recreate a LuCI network interface for tailscale0" +assert_not_contains "set network.tailscale.proto" "$body" "helper must not configure a netifd protocol for tailscale0" +assert_not_contains "set network.ts_subnet" "$body" "helper must not create static netifd routes for Tailscale subnet routes" +assert_not_contains "add_list firewall.tszone.network='tailscale'" "$body" "firewall zone should not depend on a LuCI network interface" + +mkdir -p "$TMP_DIR/bin" "$TMP_DIR/state" + +cat >"$TMP_DIR/uci_db" <<'EOF' +firewall.@defaults[0].forward=ACCEPT +firewall.@forwarding[0]=forwarding +firewall.@forwarding[0].src=lan +firewall.@forwarding[0].dest=wan +dhcp.@dnsmasq[0]=dnsmasq +dhcp.@dnsmasq[0].address=/example.tailnet/100.100.100.100 /example.tailnet/192.0.2.53 +EOF + +cat >"$TMP_DIR/bin/uci" <<'SH' +#!/bin/sh +set -eu + +db="${UCI_DB:?}" +changes="${UCI_CHANGES_LOG:?}" +quiet=0 + +if [ "${1:-}" = "-q" ]; then + quiet=1 + shift +fi + +cmd="${1:-}" +[ -n "$cmd" ] || exit 1 +shift || true + +get_value() { + key="$1" + awk -F= -v key="$key" '$1 == key { print substr($0, index($0, "=") + 1); found=1; exit } END { if (!found) exit 1 }' "$db" +} + +has_key() { + key="$1" + awk -F= -v key="$key" '$1 == key { found=1; exit } END { exit(found ? 0 : 1) }' "$db" +} + +set_value() { + key="$1" + value="$2" + tmp="$db.tmp" + awk -F= -v key="$key" -v value="$value" ' + BEGIN { replaced=0 } + $1 == key { + print key "=" value + replaced=1 + next + } + { print } + END { + if (!replaced) { + print key "=" value + } + } + ' "$db" >"$tmp" + mv "$tmp" "$db" + printf 'set %s=%s\n' "$key" "$value" >>"$changes" +} + +delete_value() { + key="$1" + tmp="$db.tmp" + awk -F= -v key="$key" '$1 != key && index($1, key ".") != 1 { print }' "$db" >"$tmp" + mv "$tmp" "$db" + printf 'delete %s\n' "$key" >>"$changes" +} + +show_prefix() { + prefix="$1" + awk -F= -v prefix="$prefix" '$1 ~ "^" prefix { print }' "$db" +} + +case "$cmd" in + get) + get_value "$1" + ;; + set) + key="${1%%=*}" + value="${1#*=}" + set_value "$key" "$value" + ;; + add_list) + key="${1%%=*}" + value="${1#*=}" + set_value "$key" "$value" + ;; + delete) + [ "${UCI_FAIL_DELETE_KEY:-}" != "$1" ] || exit 1 + if has_key "$1"; then + delete_value "$1" + else + [ "$quiet" -eq 1 ] || exit 1 + fi + ;; + show) + [ "${UCI_FAIL_SHOW_PACKAGE:-}" != "${1:-}" ] || exit 1 + show_prefix "${1:-}" + ;; + changes) + if [ -s "$changes" ]; then + if [ -n "${1:-}" ]; then + awk -v pkg="$1" '$2 ~ "^" pkg "\\." { print }' "$changes" + else + cat "$changes" + fi + fi + ;; + commit) + [ "${UCI_FAIL_COMMIT_PACKAGE:-}" != "${1:-}" ] || exit 1 + if [ -n "${1:-}" ]; then + tmp="$changes.tmp" + awk -v pkg="$1" '$2 !~ "^" pkg "\\." { print }' "$changes" >"$tmp" + mv "$tmp" "$changes" + else + : >"$changes" + fi + printf 'commit %s\n' "${1:-}" >>"${UCI_COMMIT_LOG:?}" + ;; + revert) + printf 'revert %s\n' "${1:-}" >>"${UCI_REVERT_LOG:?}" + ;; + *) + echo "unsupported fake uci command: $cmd" >&2 + exit 1 + ;; +esac +SH +chmod +x "$TMP_DIR/bin/uci" + +cat >"$TMP_DIR/bin/flock" <<'SH' +#!/bin/sh +exit 0 +SH +chmod +x "$TMP_DIR/bin/flock" + +cat >"$TMP_DIR/bin/ifconfig" <<'SH' +#!/bin/sh +printf 'tailscale0 Link encap:Ethernet\n' +SH +chmod +x "$TMP_DIR/bin/ifconfig" + +cat >"$TMP_DIR/bin/logger" <<'SH' +#!/bin/sh +printf '%s\n' "$*" >>"${LOGGER_LOG:?}" +SH +chmod +x "$TMP_DIR/bin/logger" + +cat >"$TMP_DIR/bin/tailscale" <<'SH' +#!/bin/sh +set -eu + +printf '%s\n' "$*" >>"${TAILSCALE_LOG:?}" + +case "${1:-}" in + up) + [ "${TAILSCALE_UP_FAIL:-0}" != "1" ] || exit 1 + exit 0 + ;; + ip) + [ "${2:-}" = "-4" ] || exit 1 + printf '100.64.0.10\n' + ;; + status) + if [ "${TAILSCALE_EMPTY_MAGIC_DNS:-0}" = "1" ]; then + printf '{}\n' + else + printf '{"MagicDNSSuffix":"example.tailnet"}\n' + fi + ;; + *) + echo "unsupported fake tailscale command: $*" >&2 + exit 1 + ;; +esac +SH +chmod +x "$TMP_DIR/bin/tailscale" + +cat >"$TMP_DIR/firewall-init" <<'SH' +#!/bin/sh +printf '%s\n' "$*" >>"${FIREWALL_LOG:?}" +case "${1:-}" in + reload) [ "${FIREWALL_FAIL_RELOAD:-0}" != "1" ] ;; + restart) [ "${FIREWALL_FAIL_RESTART:-0}" != "1" ] ;; +esac +SH +chmod +x "$TMP_DIR/firewall-init" + +cat >"$TMP_DIR/tailscale-init" <<'SH' +#!/bin/sh +printf '%s\n' "$*" >>"${TAILSCALE_INIT_LOG:?}" +SH +chmod +x "$TMP_DIR/tailscale-init" + +cat >"$TMP_DIR/dnsmasq-init" <<'SH' +#!/bin/sh +printf '%s\n' "$*" >>"${DNSMASQ_LOG:?}" +SH +chmod +x "$TMP_DIR/dnsmasq-init" + +cat >"$TMP_DIR/tailscale-policy-routing" <<'SH' +#!/bin/sh +set -eu +[ "${1:-}" = 'sync' ] || exit 1 +printf '%s\n' "$1" >>"${POLICY_ROUTING_LOG:?}" +SH +chmod +x "$TMP_DIR/tailscale-policy-routing" + +run_helper() { + exit_node="$1" + allow_wan_direct="${2:-0}" + tailscale_port="${3:-41641}" + wan_direct_zones="${4:-wan}" + access="${5:-}" + disable_snat_subnet_routes="${6:-0}" + accept_dns="${7:-0}" + state_was_present="${8:-1}" + authkey_pending="${9:-0}" + has_authkey="${10:-1}" + ACCESS="$access" + ACCEPT_DNS="$accept_dns" + DISABLE_SNAT_SUBNET_ROUTES="$disable_snat_subnet_routes" + ALLOW_WAN_DIRECT="$allow_wan_direct" + TAILSCALE_PORT="$tailscale_port" + WAN_DIRECT_ZONES="$wan_direct_zones" + UCI_DB="$TMP_DIR/uci_db" + UCI_CHANGES_LOG="$TMP_DIR/uci_changes.log" + UCI_COMMIT_LOG="$TMP_DIR/uci_commit.log" + UCI_REVERT_LOG="$TMP_DIR/uci_revert.log" + TAILSCALE_LOG="$TMP_DIR/tailscale.log" + LOGGER_LOG="$TMP_DIR/logger.log" + FIREWALL_LOG="$TMP_DIR/firewall.log" + TAILSCALE_INIT_LOG="$TMP_DIR/tailscale-init.log" + DNSMASQ_LOG="$TMP_DIR/dnsmasq.log" + POLICY_ROUTING_LOG="$TMP_DIR/policy-routing.log" + PATH="$TMP_DIR/bin:$PATH" + TAILSCALE_BIN="$TMP_DIR/bin/tailscale" + IFCONFIG_BIN="$TMP_DIR/bin/ifconfig" + FLOCK_BIN="$TMP_DIR/bin/flock" + LOCK_FILE="$TMP_DIR/tailscale.lock" + LOGGER_CMD="$TMP_DIR/bin/logger" + FIREWALL_INIT="$TMP_DIR/firewall-init" + TAILSCALE_INIT="$TMP_DIR/tailscale-init" + DNSMASQ_INIT="$TMP_DIR/dnsmasq-init" + POLICY_ROUTING_HELPER="$TMP_DIR/tailscale-policy-routing" + TAILSCALE_SECRETS_BIN="$TMP_DIR/tailscale-secrets" + TAILSCALE_HELPER_STATE_DIR="$TMP_DIR/state" + FIREWALL_PENDING_STATE_FILE="$TMP_DIR/firewall-pending" + TAILSCALE_STATE_WAS_PRESENT="$state_was_present" + SECRET_AUTHKEY_PENDING="$authkey_pending" + SECRET_HAS_AUTHKEY="$has_authkey" + SECRETS_LOG="$TMP_DIR/secrets.log" + export ACCESS ACCEPT_DNS DISABLE_SNAT_SUBNET_ROUTES ALLOW_WAN_DIRECT TAILSCALE_PORT WAN_DIRECT_ZONES UCI_DB UCI_CHANGES_LOG UCI_COMMIT_LOG UCI_REVERT_LOG + export TAILSCALE_LOG LOGGER_LOG FIREWALL_LOG TAILSCALE_INIT_LOG DNSMASQ_LOG POLICY_ROUTING_LOG TAILSCALE_EMPTY_MAGIC_DNS PATH + export TAILSCALE_BIN IFCONFIG_BIN FLOCK_BIN LOCK_FILE LOGGER_CMD FIREWALL_INIT TAILSCALE_INIT DNSMASQ_INIT POLICY_ROUTING_HELPER TAILSCALE_SECRETS_BIN TAILSCALE_HELPER_STATE_DIR FIREWALL_PENDING_STATE_FILE + export TAILSCALE_STATE_WAS_PRESENT SECRET_AUTHKEY_PENDING SECRET_HAS_AUTHKEY SECRETS_LOG + export UCI_FAIL_DELETE_KEY UCI_FAIL_SHOW_PACKAGE UCI_FAIL_COMMIT_PACKAGE FIREWALL_FAIL_RELOAD FIREWALL_FAIL_RESTART TAILSCALE_UP_FAIL + EXIT_NODE="$exit_node" export EXIT_NODE + + if ! "$SCRIPT" --run; then + printf 'helper failed for EXIT_NODE=%s\n' "$exit_node" >&2 + [ -s "$LOGGER_LOG" ] && { printf 'logger:\n' >&2; cat "$LOGGER_LOG" >&2; } + [ -s "$TAILSCALE_INIT_LOG" ] && { printf 'tailscale-init:\n' >&2; cat "$TAILSCALE_INIT_LOG" >&2; } + [ -s "$UCI_REVERT_LOG" ] && { printf 'uci-revert:\n' >&2; cat "$UCI_REVERT_LOG" >&2; } + return 1 + fi +} + +cat >"$TMP_DIR/tailscale-secrets" <<'SH' +#!/bin/sh +printf '%s\n' "$*" >>"${SECRETS_LOG:?}" +case "${1:-}" in + get) + [ "${2:-}" = "authkey" ] || exit 1 + printf '%s\n' 'tskey-test-only' + ;; + authkey-pending) + [ "${SECRET_AUTHKEY_PENDING:-0}" = "1" ] + ;; + has) + [ "${2:-}" = "authkey" ] || exit 1 + [ "${SECRET_HAS_AUTHKEY:-1}" = "1" ] + ;; + clear-authkey) + exit 0 + ;; + *) + exit 1 + ;; +esac +SH +chmod +x "$TMP_DIR/tailscale-secrets" + +: >"$TMP_DIR/uci_changes.log" +: >"$TMP_DIR/uci_commit.log" +: >"$TMP_DIR/uci_revert.log" +: >"$TMP_DIR/tailscale.log" +: >"$TMP_DIR/logger.log" +: >"$TMP_DIR/firewall.log" +: >"$TMP_DIR/tailscale-init.log" +: >"$TMP_DIR/dnsmasq.log" +: >"$TMP_DIR/policy-routing.log" +: >"$TMP_DIR/secrets.log" + +: >"$TMP_DIR/tailscale.log" +: >"$TMP_DIR/secrets.log" +run_helper "" 0 41641 wan "" 0 0 1 0 1 +assert_not_contains '--authkey=' "$(cat "$TMP_DIR/tailscale.log")" \ + "an existing node must ignore an unmarked auth key left by an older package" +assert_contains 'clear-authkey' "$(cat "$TMP_DIR/secrets.log")" \ + "a successful existing-node start must purge the stale stored auth key" + +: >"$TMP_DIR/tailscale.log" +: >"$TMP_DIR/secrets.log" +run_helper "" 0 41641 wan "" 0 0 0 0 1 +assert_contains '--authkey=tskey-test-only' "$(cat "$TMP_DIR/tailscale.log")" \ + "a first enrollment without pre-existing state must use the saved auth key" +assert_contains 'clear-authkey' "$(cat "$TMP_DIR/secrets.log")" \ + "a successful first enrollment must purge the auth key" + +: >"$TMP_DIR/tailscale.log" +: >"$TMP_DIR/secrets.log" +run_helper "" 0 41641 wan "" 0 0 1 1 1 +assert_contains '--authkey=tskey-test-only' "$(cat "$TMP_DIR/tailscale.log")" \ + "an explicitly replaced auth key must allow deliberate re-enrollment" +assert_contains 'clear-authkey' "$(cat "$TMP_DIR/secrets.log")" \ + "a successful explicit re-enrollment must purge the auth key" + +run_helper "peer-exit-node" +assert_contains 'sync' "$(cat "$TMP_DIR/policy-routing.log")" "helper should synchronize policy-routing precedence after Tailscale configuration" + +: >"$TMP_DIR/tailscale-init.log" +: >"$TMP_DIR/uci_revert.log" +TAILSCALE_UP_FAIL=1 +export TAILSCALE_UP_FAIL +: >"$TMP_DIR/secrets.log" +if run_helper "" 0 41641 wan "" 0 0 0 0 1 >/dev/null 2>&1; then + fail "helper must fail when tailscale up fails" +fi +unset TAILSCALE_UP_FAIL +[ "$(grep -c '^clear-authkey$' "$TMP_DIR/secrets.log" || true)" = "0" ] || \ + fail "a failed enrollment must retain its auth key for correction and retry" +[ ! -s "$TMP_DIR/tailscale-init.log" ] || fail "a tailscale up failure must not stop the Tailscale service" +assert_contains "dhcp" "$(cat "$TMP_DIR/uci_revert.log")" "helper failure should revert its pending DHCP changes" +assert_contains "network" "$(cat "$TMP_DIR/uci_revert.log")" "helper failure should revert its pending network changes" +assert_contains "firewall" "$(cat "$TMP_DIR/uci_revert.log")" "helper failure should revert its pending firewall changes" + +: >"$TMP_DIR/tailscale.log" +: >"$TMP_DIR/tailscale-init.log" +: >"$TMP_DIR/uci_revert.log" +if "$SCRIPT" --unexpected-mode >/dev/null 2>&1; then + fail "helper must reject an unsupported mode" +fi +[ ! -s "$TMP_DIR/tailscale.log" ] || fail "unsupported helper mode must not call tailscale up" +[ ! -s "$TMP_DIR/tailscale-init.log" ] || fail "unsupported helper mode must not stop the Tailscale service" +[ ! -s "$TMP_DIR/uci_revert.log" ] || fail "unsupported helper mode must not revert unrelated UCI changes" + +enabled_forward="$(awk -F= '$1 == "firewall.@defaults[0].forward" { print $2 }' "$TMP_DIR/uci_db")" +[ "$enabled_forward" = "REJECT" ] || fail "exit node enable should force firewall default forward to REJECT +actual: $enabled_forward" + +enabled_override="$(awk -F= '$1 == "firewall.@forwarding[0].enabled" { print $2 }' "$TMP_DIR/uci_db")" +[ "$enabled_override" = "0" ] || fail "exit node enable should disable the LAN to WAN forwarding rule +actual: ${enabled_override:-missing}" + +[ -f "$TMP_DIR/state/exit_node_firewall_state" ] || fail "exit node enable should persist firewall restore state" + +: >"$TMP_DIR/uci_changes.log" +run_helper "" 0 41641 wan "" 0 1 +if grep -F 'set dhcp.@dnsmasq[0].address=' "$TMP_DIR/uci_changes.log" >/dev/null; then + fail "MagicDNS setup must not duplicate its address when a dnsmasq list has additional user values" +fi + +: >"$TMP_DIR/uci_changes.log" +TAILSCALE_EMPTY_MAGIC_DNS=1 run_helper "" 0 41641 wan "" 0 1 +assert_not_contains 'set dhcp.@dnsmasq[0].address=//100.100.100.100' "$(cat "$TMP_DIR/uci_changes.log")" \ + "MagicDNS setup must not write an invalid address when the suffix is unavailable" + +run_helper "" + +restored_forward="$(awk -F= '$1 == "firewall.@defaults[0].forward" { print $2 }' "$TMP_DIR/uci_db")" +[ "$restored_forward" = "ACCEPT" ] || fail "non-exit-node helper run should restore the previous firewall forward policy +actual: $restored_forward" + +if awk -F= '$1 == "firewall.@forwarding[0].enabled" { found=1 } END { exit(found ? 0 : 1) }' "$TMP_DIR/uci_db"; then + fail "non-exit-node helper run should remove the temporary LAN to WAN enabled override" +fi + +[ ! -f "$TMP_DIR/state/exit_node_firewall_state" ] || fail "helper should clear the exit-node firewall restore state after reconciliation" + +assert_contains "reload" "$(cat "$TMP_DIR/firewall.log")" "helper should reload firewall after restoring exit-node changes" + +: >"$TMP_DIR/uci_changes.log" +: >"$TMP_DIR/firewall.log" +run_helper "" 1 41641 + +assert_contains "firewall.ts_wan_direct_1_wan=rule" "$(cat "$TMP_DIR/uci_db")" "WAN direct enable should create a named firewall rule" +assert_contains "firewall.ts_wan_direct_1_wan.name=Allow-Tailscale-WAN-41641-wan" "$(cat "$TMP_DIR/uci_db")" "WAN direct rule should use the configured Tailscale listen port and zone in the rule name" +assert_contains "firewall.ts_wan_direct_1_wan.src=wan" "$(cat "$TMP_DIR/uci_db")" "WAN direct rule should come from the wan zone" +assert_contains "firewall.ts_wan_direct_1_wan.proto=udp" "$(cat "$TMP_DIR/uci_db")" "WAN direct rule should allow UDP" +assert_contains "firewall.ts_wan_direct_1_wan.dest_port=41641" "$(cat "$TMP_DIR/uci_db")" "WAN direct rule should target the configured listen port" +assert_contains "firewall.ts_wan_direct_1_wan.target=ACCEPT" "$(cat "$TMP_DIR/uci_db")" "WAN direct rule should accept matching packets" +assert_contains "reload" "$(cat "$TMP_DIR/firewall.log")" "WAN direct rule changes should reload firewall" + +: >"$TMP_DIR/uci_changes.log" +: >"$TMP_DIR/firewall.log" +run_helper "" 1 41641 +wan_direct_count="$(awk -F= '$1 ~ /^firewall\.ts_wan_direct[^.]*$/ { count++ } END { print count + 0 }' "$TMP_DIR/uci_db")" +[ "$wan_direct_count" = "1" ] || fail "reapplying WAN direct should keep exactly one named firewall rule +actual: $wan_direct_count" +[ ! -s "$TMP_DIR/uci_changes.log" ] || fail "reapplying an unchanged WAN direct rule should not create UCI changes +actual: $(cat "$TMP_DIR/uci_changes.log")" +[ ! -s "$TMP_DIR/firewall.log" ] || fail "reapplying an unchanged WAN direct rule should not reload firewall +actual: $(cat "$TMP_DIR/firewall.log")" + +: >"$TMP_DIR/uci_changes.log" +: >"$TMP_DIR/firewall.log" +run_helper "" 0 41641 +if grep -F 'firewall.ts_wan_direct' "$TMP_DIR/uci_db" >/dev/null; then + fail "WAN direct disable should remove the named firewall rule" +fi + +: >"$TMP_DIR/uci_changes.log" +: >"$TMP_DIR/firewall.log" +run_helper "" 1 41641 "wan wan2" + +assert_contains "firewall.ts_wan_direct_1_wan=rule" "$(cat "$TMP_DIR/uci_db")" "multi-zone WAN direct should create a rule for the wan zone" +assert_contains "firewall.ts_wan_direct_1_wan.name=Allow-Tailscale-WAN-41641-wan" "$(cat "$TMP_DIR/uci_db")" "multi-zone WAN direct rule should include the wan zone in the rule name" +assert_contains "firewall.ts_wan_direct_1_wan.src=wan" "$(cat "$TMP_DIR/uci_db")" "multi-zone WAN direct rule should target the wan zone" +assert_contains "firewall.ts_wan_direct_2_wan2=rule" "$(cat "$TMP_DIR/uci_db")" "multi-zone WAN direct should create a rule for the wan2 zone" +assert_contains "firewall.ts_wan_direct_2_wan2.name=Allow-Tailscale-WAN-41641-wan2" "$(cat "$TMP_DIR/uci_db")" "multi-zone WAN direct rule should include the wan2 zone in the rule name" +assert_contains "firewall.ts_wan_direct_2_wan2.src=wan2" "$(cat "$TMP_DIR/uci_db")" "multi-zone WAN direct rule should target the wan2 zone" +wan_direct_count="$(awk -F= '$1 ~ /^firewall\.ts_wan_direct[^.]*$/ { count++ } END { print count + 0 }' "$TMP_DIR/uci_db")" +[ "$wan_direct_count" = "2" ] || fail "multi-zone WAN direct should create exactly two named firewall rules +actual: $wan_direct_count" + +: >"$TMP_DIR/uci_changes.log" +: >"$TMP_DIR/firewall.log" +run_helper "" 1 41641 "wan wan2" +wan_direct_count="$(awk -F= '$1 ~ /^firewall\.ts_wan_direct[^.]*$/ { count++ } END { print count + 0 }' "$TMP_DIR/uci_db")" +[ "$wan_direct_count" = "2" ] || fail "reapplying multi-zone WAN direct should keep exactly two named firewall rules +actual: $wan_direct_count" +[ ! -s "$TMP_DIR/uci_changes.log" ] || fail "reapplying unchanged multi-zone WAN direct rules should not create UCI changes +actual: $(cat "$TMP_DIR/uci_changes.log")" +[ ! -s "$TMP_DIR/firewall.log" ] || fail "reapplying unchanged multi-zone WAN direct rules should not reload firewall +actual: $(cat "$TMP_DIR/firewall.log")" + +printf '%s\n' 'firewall.ts_wan_direct_1_wan.enabled=0' >>"$TMP_DIR/uci_db" +: >"$TMP_DIR/uci_changes.log" +: >"$TMP_DIR/firewall.log" +run_helper "" 1 41641 "wan wan2" +if grep -F 'firewall.ts_wan_direct_1_wan.enabled=' "$TMP_DIR/uci_db" >/dev/null; then + fail "WAN direct reconciliation should remove stale managed rule options" +fi +assert_contains "reload" "$(cat "$TMP_DIR/firewall.log")" "removing stale WAN direct options should reload firewall" + +: >"$TMP_DIR/uci_changes.log" +: >"$TMP_DIR/firewall.log" +run_helper "" 0 41641 "wan wan2" +if grep -F 'firewall.ts_wan_direct' "$TMP_DIR/uci_db" >/dev/null; then + fail "WAN direct disable should remove every named multi-zone firewall rule" +fi + +: >"$TMP_DIR/uci_changes.log" +: >"$TMP_DIR/firewall.log" +run_helper "" 0 41641 "wan wan2" +[ ! -s "$TMP_DIR/uci_changes.log" ] || fail "reapplying disabled WAN direct should not create UCI changes +actual: $(cat "$TMP_DIR/uci_changes.log")" +[ ! -s "$TMP_DIR/firewall.log" ] || fail "reapplying disabled WAN direct should not reload firewall +actual: $(cat "$TMP_DIR/firewall.log")" + +: >"$TMP_DIR/uci_changes.log" +: >"$TMP_DIR/firewall.log" +run_helper "" 0 41641 wan "ts_ac_lan lan_ac_ts" +assert_contains "firewall.tszone=zone" "$(cat "$TMP_DIR/uci_db")" "ACCESS should create the Tailscale firewall zone" +assert_contains "firewall.ts_ac_lan=forwarding" "$(cat "$TMP_DIR/uci_db")" "ACCESS should create Tailscale-to-LAN forwarding" +assert_contains "firewall.lan_ac_ts=forwarding" "$(cat "$TMP_DIR/uci_db")" "ACCESS should create LAN-to-Tailscale forwarding" + +: >"$TMP_DIR/uci_changes.log" +: >"$TMP_DIR/firewall.log" +run_helper "" 0 41641 wan "ts_ac_lan lan_ac_ts" +[ ! -s "$TMP_DIR/uci_changes.log" ] || fail "reapplying unchanged ACCESS firewall rules should not create UCI changes +actual: $(cat "$TMP_DIR/uci_changes.log")" +[ ! -s "$TMP_DIR/firewall.log" ] || fail "reapplying unchanged ACCESS firewall rules should not reload firewall +actual: $(cat "$TMP_DIR/firewall.log")" + +: >"$TMP_DIR/uci_changes.log" +: >"$TMP_DIR/firewall.log" +run_helper "" 0 41641 wan "ts_ac_lan lan_ac_ts" 1 +assert_contains "firewall.tszone.masq=0" "$(cat "$TMP_DIR/uci_db")" \ + "site-to-site enable must disable masquerade in the app-owned Tailscale firewall zone" +assert_contains "reload" "$(cat "$TMP_DIR/firewall.log")" \ + "changing site-to-site SNAT must reload firewall4 through the existing firewall flow" + +: >"$TMP_DIR/uci_changes.log" +: >"$TMP_DIR/firewall.log" +run_helper "" 0 41641 wan "ts_ac_lan lan_ac_ts" 1 +[ ! -s "$TMP_DIR/uci_changes.log" ] || fail "unchanged no-SNAT firewall state must be idempotent" +[ ! -s "$TMP_DIR/firewall.log" ] || fail "unchanged no-SNAT firewall state must not reload firewall4" + +: >"$TMP_DIR/uci_changes.log" +: >"$TMP_DIR/firewall.log" +run_helper "" 0 41641 wan "ts_ac_lan lan_ac_ts" 0 +assert_contains "firewall.tszone.masq=1" "$(cat "$TMP_DIR/uci_db")" \ + "site-to-site disable must restore masquerade in the app-owned Tailscale firewall zone" +assert_contains "reload" "$(cat "$TMP_DIR/firewall.log")" \ + "restoring site-to-site SNAT must reload firewall4 through the existing firewall flow" + +: >"$TMP_DIR/uci_changes.log" +: >"$TMP_DIR/firewall.log" +run_helper "" 0 41641 wan "ts_ac_lan lan_ac_ts" 0 +[ ! -s "$TMP_DIR/uci_changes.log" ] || fail "reapplying restored SNAT state must not create UCI changes" +[ ! -s "$TMP_DIR/firewall.log" ] || fail "reapplying restored SNAT state must not reload firewall4" + +: >"$TMP_DIR/uci_changes.log" +: >"$TMP_DIR/firewall.log" +run_helper "" 0 41641 wan "" 1 +empty_access_db="$(cat "$TMP_DIR/uci_db")" +assert_contains "firewall.tszone=zone" "$empty_access_db" \ + "site-to-site no-SNAT must keep the Tailscale zone when ACCESS is empty" +assert_contains "firewall.tszone.masq=0" "$empty_access_db" \ + "site-to-site no-SNAT must disable masquerade when ACCESS is empty" +for forwarding in ts_ac_lan ts_ac_wan lan_ac_ts wan_ac_ts; do + assert_not_contains "firewall.$forwarding=" "$empty_access_db" \ + "empty ACCESS must not create forwarding section $forwarding" +done + +: >"$TMP_DIR/uci_changes.log" +: >"$TMP_DIR/firewall.log" +run_helper "" 0 41641 wan "" 1 +[ ! -s "$TMP_DIR/uci_changes.log" ] || fail "empty-access no-SNAT state must be idempotent" +[ ! -s "$TMP_DIR/firewall.log" ] || fail "empty-access no-SNAT state must not reload firewall4 when unchanged" + +: >"$TMP_DIR/uci_changes.log" +: >"$TMP_DIR/firewall.log" +run_helper "" 0 41641 wan "" 0 +empty_access_disabled_db="$(cat "$TMP_DIR/uci_db")" +assert_not_contains "firewall.tszone=" "$empty_access_disabled_db" \ + "disabling no-SNAT with empty ACCESS must remove the Tailscale zone" +for forwarding in ts_ac_lan ts_ac_wan lan_ac_ts wan_ac_ts; do + assert_not_contains "firewall.$forwarding=" "$empty_access_disabled_db" \ + "disabling no-SNAT with empty ACCESS must not retain forwarding section $forwarding" +done + +run_helper "" 0 41641 wan "ts_ac_lan lan_ac_ts" +printf '%s\n' 'firewall.tszone.enabled=0' >>"$TMP_DIR/uci_db" +: >"$TMP_DIR/uci_changes.log" +: >"$TMP_DIR/firewall.log" +run_helper "" 0 41641 wan "ts_ac_lan lan_ac_ts" +if grep -F 'firewall.tszone.enabled=' "$TMP_DIR/uci_db" >/dev/null; then + fail "ACCESS reconciliation should remove stale managed zone options" +fi + +: >"$TMP_DIR/uci_changes.log" +: >"$TMP_DIR/firewall.log" +run_helper "" 1 41641 +UCI_FAIL_DELETE_KEY=firewall.ts_wan_direct_1_wan +if run_helper "" 0 41641 >/dev/null 2>&1; then + fail "WAN direct cleanup should fail when UCI cannot delete a managed rule" +fi +unset UCI_FAIL_DELETE_KEY +run_helper "" 0 41641 + +UCI_FAIL_SHOW_PACKAGE=firewall +if run_helper "" 1 41641 >/dev/null 2>&1; then + fail "WAN direct reconciliation should fail when UCI cannot enumerate firewall sections" +fi +unset UCI_FAIL_SHOW_PACKAGE +run_helper "" 0 41641 + +: >"$TMP_DIR/uci_changes.log" +: >"$TMP_DIR/firewall.log" +run_helper "" 1 41641 +: >"$TMP_DIR/tailscale.log" +: >"$TMP_DIR/firewall.log" +ALLOW_WAN_DIRECT=0 "$SCRIPT" --cleanup-wan-direct-firewall +if grep -F 'firewall.ts_wan_direct' "$TMP_DIR/uci_db" >/dev/null; then + fail "standalone WAN direct cleanup should remove managed rules" +fi +[ ! -s "$TMP_DIR/tailscale.log" ] || fail "standalone WAN direct cleanup must not call tailscale up +actual: $(cat "$TMP_DIR/tailscale.log")" + +: >"$TMP_DIR/uci_changes.log" +: >"$TMP_DIR/firewall.log" +rm -f "$TMP_DIR/firewall-pending" +FIREWALL_FAIL_RELOAD=1 +FIREWALL_FAIL_RESTART=1 +if run_helper "" 1 41641 >/dev/null 2>&1; then + fail "helper should fail when firewall reload and restart both fail" +fi +[ -f "$TMP_DIR/firewall-pending" ] || fail "failed firewall apply should leave a pending retry marker" +unset FIREWALL_FAIL_RELOAD FIREWALL_FAIL_RESTART + +: >"$TMP_DIR/uci_changes.log" +: >"$TMP_DIR/firewall.log" +run_helper "" 1 41641 +assert_contains "reload" "$(cat "$TMP_DIR/firewall.log")" "a pending firewall apply should retry reload even without new UCI changes" +[ ! -e "$TMP_DIR/firewall-pending" ] || fail "successful firewall retry should clear the pending marker" + +: >"$TMP_DIR/uci_changes.log" +: >"$TMP_DIR/firewall.log" +run_helper "" 0 41641 +: >"$TMP_DIR/firewall-pending" +UCI_FAIL_COMMIT_PACKAGE=firewall +if run_helper "" 1 41641 >/dev/null 2>&1; then + fail "helper should fail when a later firewall commit fails with an existing pending marker" +fi +[ -f "$TMP_DIR/firewall-pending" ] || fail "a pre-existing pending marker must survive a later UCI commit failure" +unset UCI_FAIL_COMMIT_PACKAGE +: >"$TMP_DIR/uci_changes.log" +: >"$TMP_DIR/firewall.log" +run_helper "" 1 41641 +assert_contains "reload" "$(cat "$TMP_DIR/firewall.log")" "a preserved pending marker should retry firewall apply after commit recovers" +[ ! -e "$TMP_DIR/firewall-pending" ] || fail "successful retry should clear the preserved pending marker" + +: >"$TMP_DIR/uci_changes.log" +: >"$TMP_DIR/firewall.log" +run_helper "" 0 41641 +UCI_FAIL_COMMIT_PACKAGE=firewall +if run_helper "" 1 41641 >/dev/null 2>&1; then + fail "helper should fail when the firewall UCI commit fails" +fi +[ ! -e "$TMP_DIR/firewall-pending" ] || fail "failed UCI commit should not leave an apply-only retry marker" +unset UCI_FAIL_COMMIT_PACKAGE + +run_helper "" 0 41641 + +run_helper "peer-exit-node" +[ -f "$TMP_DIR/state/exit_node_firewall_state" ] || fail "exit-node durability test requires persisted restore state" +UCI_FAIL_COMMIT_PACKAGE=firewall +export UCI_FAIL_COMMIT_PACKAGE +if "$SCRIPT" --cleanup-exit-node-firewall >/dev/null 2>&1; then + fail "exit-node cleanup should fail when its firewall commit fails" +fi +[ -f "$TMP_DIR/state/exit_node_firewall_state" ] || fail "exit-node restore state must survive a failed UCI commit" +unset UCI_FAIL_COMMIT_PACKAGE +: >"$TMP_DIR/firewall.log" +"$SCRIPT" --cleanup-exit-node-firewall +[ ! -f "$TMP_DIR/state/exit_node_firewall_state" ] || fail "successful retry should remove exit-node restore state after commit and reload" +assert_contains "reload" "$(cat "$TMP_DIR/firewall.log")" "successful commit retry should reload the firewall" + +run_helper "peer-exit-node" +[ -f "$TMP_DIR/state/exit_node_firewall_state" ] || fail "reload durability test requires persisted restore state" +FIREWALL_FAIL_RELOAD=1 +FIREWALL_FAIL_RESTART=1 +export FIREWALL_FAIL_RELOAD FIREWALL_FAIL_RESTART +if "$SCRIPT" --cleanup-exit-node-firewall >/dev/null 2>&1; then + fail "exit-node cleanup should fail when firewall reload and restart fail" +fi +[ -f "$TMP_DIR/state/exit_node_firewall_state" ] || fail "exit-node restore state must survive failed firewall apply" +[ -f "$TMP_DIR/firewall-pending" ] || fail "failed exit-node firewall apply should preserve the pending retry marker" +unset FIREWALL_FAIL_RELOAD FIREWALL_FAIL_RESTART +: >"$TMP_DIR/uci_changes.log" +: >"$TMP_DIR/firewall.log" +"$SCRIPT" --cleanup-exit-node-firewall +[ ! -f "$TMP_DIR/state/exit_node_firewall_state" ] || fail "pending-only successful retry should remove exit-node restore state" +[ ! -f "$TMP_DIR/firewall-pending" ] || fail "pending-only successful retry should clear the firewall marker" +assert_contains "reload" "$(cat "$TMP_DIR/firewall.log")" "exit-node cleanup should retry an existing pending reload without a new UCI delta" + +run_helper "peer-exit-node" 1 41641 wan "ts_ac_lan ts_ac_wan lan_ac_ts wan_ac_ts" +cat >>"$TMP_DIR/uci_db" <<'EOF' +firewall.user_rule=rule +firewall.user_rule.name=User-owned rule +firewall.user_rule.target=ACCEPT +EOF +: >"$TMP_DIR/uci_changes.log" +: >"$TMP_DIR/firewall.log" +"$SCRIPT" --cleanup-managed-firewall + +managed_cleanup_db="$(cat "$TMP_DIR/uci_db")" +for managed_section in tszone ts_ac_lan ts_ac_wan lan_ac_ts wan_ac_ts ts_wan_direct_1_wan; do + assert_not_contains "firewall.$managed_section=" "$managed_cleanup_db" "managed cleanup should remove package-owned section $managed_section" +done +assert_contains "firewall.user_rule=rule" "$managed_cleanup_db" "managed cleanup must preserve unrelated user firewall sections" +assert_contains "firewall.@defaults[0].forward=ACCEPT" "$managed_cleanup_db" "managed cleanup should restore stale exit-node firewall state" +[ ! -f "$TMP_DIR/state/exit_node_firewall_state" ] || fail "managed cleanup should clear successfully restored exit-node state" +assert_contains "reload" "$(cat "$TMP_DIR/firewall.log")" "managed cleanup should apply firewall changes" + +: >"$TMP_DIR/uci_changes.log" +: >"$TMP_DIR/firewall.log" +"$SCRIPT" --cleanup-managed-firewall +[ ! -s "$TMP_DIR/uci_changes.log" ] || fail "managed firewall cleanup should be idempotent +actual: $(cat "$TMP_DIR/uci_changes.log")" +[ ! -s "$TMP_DIR/firewall.log" ] || fail "idempotent managed firewall cleanup should not reload without changes or a pending marker +actual: $(cat "$TMP_DIR/firewall.log")" + +echo "tailscale_helper network cleanup tests passed" diff --git a/luci-app-tailscale/tests/tailscale_init_adguard_lifecycle_test.sh b/luci-app-tailscale/tests/tailscale_init_adguard_lifecycle_test.sh new file mode 100644 index 000000000..a0d7b0d1e --- /dev/null +++ b/luci-app-tailscale/tests/tailscale_init_adguard_lifecycle_test.sh @@ -0,0 +1,476 @@ +#!/bin/sh + +set -eu + +ROOT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)" +INIT_SCRIPT="$ROOT_DIR/root/etc/init.d/tailscale" +TMP_DIR="${TMPDIR:-/tmp}/tailscale-init-adguard-test.$$" + +cleanup() { + rm -rf "$TMP_DIR" +} +trap cleanup EXIT + +fail() { + echo "FAIL: $*" >&2 + exit 1 +} + +mkdir -p "$TMP_DIR" + +cat >"$TMP_DIR/fake-adguard" <<'SH' +#!/bin/sh +printf '%s\n' "$*" >>"${FAKE_ADGUARD_LOG:?}" +[ "${FAKE_ADGUARD_FAIL:-0}" = "0" ] +SH +chmod +x "$TMP_DIR/fake-adguard" + +cat >"$TMP_DIR/fake-helper" <<'SH' +#!/bin/sh +printf '%s\n' "$*" >>"${FAKE_HELPER_LOG:?}" +[ -z "${FAKE_HELPER_FAIL_ARG:-}" ] || [ "$*" != "$FAKE_HELPER_FAIL_ARG" ] || exit 1 +SH +chmod +x "$TMP_DIR/fake-helper" + +cat >"$TMP_DIR/fake-secrets" <<'SH' +#!/bin/sh +printf '%s\n' "$*" >>"${FAKE_SECRETS_LOG:?}" +SH +chmod +x "$TMP_DIR/fake-secrets" + +run_apply_down() { + switch_enabled="$1" + state_profile="${2:-}" + fail_mode="${3:-0}" + FAKE_ADGUARD_LOG="$TMP_DIR/adguard.log" + ADGUARD_DNS_STATE_DIR="$TMP_DIR/state" + : >"$FAKE_ADGUARD_LOG" + rm -rf "$ADGUARD_DNS_STATE_DIR" + if [ -n "$state_profile" ]; then + mkdir -p "$ADGUARD_DNS_STATE_DIR" + printf '%s\n' "$state_profile" >"$ADGUARD_DNS_STATE_DIR/current_profile" + fi + FAKE_ADGUARD_FAIL="$fail_mode" + export FAKE_ADGUARD_LOG FAKE_ADGUARD_FAIL ADGUARD_DNS_STATE_DIR + + rc=0 + ( + . "$INIT_SCRIPT" + PROGA="$TMP_DIR/fake-adguard" + ADGUARD_DNS_STATE_DIR="$ADGUARD_DNS_STATE_DIR" + + config_get_bool() { + case "$3" in + adguard_dns_switch_enabled) eval "$1=$switch_enabled" ;; + *) eval "$1=${4:-0}" ;; + esac + } + + config_get() { + case "$3" in + adguard_default_upstreams) eval "$1=9.9.9.9" ;; + *) eval "$1=" ;; + esac + } + + tailscale_adguard_dns_apply_down settings + ) || rc=$? + + cat "$FAKE_ADGUARD_LOG" + return "$rc" +} + +run_reload_service() { + start_result="${1:-0}" + RELOAD_LOG="$TMP_DIR/reload.log" + : >"$RELOAD_LOG" + export RELOAD_LOG + + ( + . "$INIT_SCRIPT" + stop() { + printf 'stop:%s\n' "${TAILSCALE_INTERNAL_RELOAD:-0}" >>"$RELOAD_LOG" + } + start() { + printf 'start\n' >>"$RELOAD_LOG" + return "$start_result" + } + reload_service + ) +} + +run_disabled_start() { + FAKE_HELPER_LOG="$TMP_DIR/disabled-helper.log" + FAKE_SECRETS_LOG="$TMP_DIR/disabled-secrets.log" + CONFIG_DIR="$TMP_DIR/disabled-config" + : >"$FAKE_HELPER_LOG" + : >"$FAKE_SECRETS_LOG" + mkdir -p "$CONFIG_DIR" + export FAKE_HELPER_LOG FAKE_SECRETS_LOG CONFIG_DIR + + ( + . "$INIT_SCRIPT" + PROG="$TMP_DIR/fake-helper" + PROGS="$TMP_DIR/fake-secrets" + CONFIG_PATH="$CONFIG_DIR" + + config_get_bool() { + eval "$1=0" + } + config_get() { + case "$3" in + secrets_ref) eval "$1=disabled-secret-ref" ;; + *) eval "$1=" ;; + esac + } + + start_instance settings >/dev/null 2>&1 || true + ) + + cat "$FAKE_HELPER_LOG" +} + +run_stop_instance() { + helper_fail_arg="${1:-}" + reload_mode="${2:-0}" + FAKE_ADGUARD_LOG="$TMP_DIR/stop-adguard.log" + FAKE_HELPER_LOG="$TMP_DIR/helper.log" + ADGUARD_DNS_STATE_DIR="$TMP_DIR/stop-state" + CONFIG_DIR="$TMP_DIR/config" + UCI_CHANGES_OUT="$TMP_DIR/uci-changes.out" + UCI_DEL_LIST_LOG="$TMP_DIR/uci-del-list.log" + UCI_DNSMASQ_ADDRESS_STATE="$TMP_DIR/dnsmasq-addresses" + TAILSCALE_STATUS_OUT="$TMP_DIR/tailscale-status.out" + TAILSCALED_CLEANUP_LOG="$TMP_DIR/tailscaled-cleanup.log" + rm -rf "$ADGUARD_DNS_STATE_DIR" "$CONFIG_DIR" + : >"$FAKE_ADGUARD_LOG" + : >"$FAKE_HELPER_LOG" + : >"$UCI_CHANGES_OUT" + : >"$UCI_DEL_LIST_LOG" + printf '%s\n' '/example.tailnet/100.100.100.100' '/example.tailnet/192.0.2.53' >"$UCI_DNSMASQ_ADDRESS_STATE" + : >"$TAILSCALE_STATUS_OUT" + : >"$TAILSCALED_CLEANUP_LOG" + mkdir -p "$ADGUARD_DNS_STATE_DIR" "$CONFIG_DIR" + printf 'up\n' >"$ADGUARD_DNS_STATE_DIR/current_profile" + printf 'recovery-state\n' >"$CONFIG_DIR/exit_node_firewall_state" + FAKE_HELPER_FAIL_ARG="$helper_fail_arg" + FAKE_ADGUARD_FAIL=0 + export FAKE_ADGUARD_LOG FAKE_ADGUARD_FAIL FAKE_HELPER_LOG FAKE_HELPER_FAIL_ARG ADGUARD_DNS_STATE_DIR CONFIG_DIR UCI_CHANGES_OUT UCI_DEL_LIST_LOG UCI_DNSMASQ_ADDRESS_STATE TAILSCALE_STATUS_OUT TAILSCALED_CLEANUP_LOG + + ( + . "$INIT_SCRIPT" + PROGA="$TMP_DIR/fake-adguard" + PROG="$TMP_DIR/fake-helper" + PROGD="$TMP_DIR/fake-tailscaled" + CONFIG_PATH="$CONFIG_DIR" + ADGUARD_DNS_STATE_DIR="$ADGUARD_DNS_STATE_DIR" + TAILSCALE_INTERNAL_RELOAD="$reload_mode" + + config_get_bool() { + case "$3" in + adguard_dns_switch_enabled) eval "$1=0" ;; + *) eval "$1=${4:-0}" ;; + esac + } + + config_get() { + case "$3" in + adguard_default_upstreams) eval "$1=9.9.9.9" ;; + *) eval "$1=" ;; + esac + } + + tailscale() { + case "$1" in + status) + printf '{"MagicDNSSuffix":"example.tailnet"}\n' >"$TAILSCALE_STATUS_OUT" + printf '{"MagicDNSSuffix":"example.tailnet"}\n' + ;; + *) + return 1 + ;; + esac + } + + uci() { + case "$1" in + show) + if [ "${2:-}" = 'dhcp' ]; then + printf '%s\n' 'dhcp.@dnsmasq[0]=dnsmasq' + fi + ;; + changes) + cat "$UCI_CHANGES_OUT" + ;; + commit) + : + ;; + -q) + shift + case "$1" in + del_list) + printf '%s\n' "${2:-}" >>"$UCI_DEL_LIST_LOG" + [ "${2:-}" = 'dhcp.@dnsmasq[0].address=/example.tailnet/100.100.100.100' ] + grep -vxF '/example.tailnet/100.100.100.100' "$UCI_DNSMASQ_ADDRESS_STATE" >"$UCI_DNSMASQ_ADDRESS_STATE.tmp" + mv "$UCI_DNSMASQ_ADDRESS_STATE.tmp" "$UCI_DNSMASQ_ADDRESS_STATE" + ;; + *) + return 1 + ;; + esac + ;; + *) + return 1 + ;; + esac + } + + rm() { + command rm "$@" + } + + stop_instance settings + ) +} + +run_service_stopped() { + FAKE_ADGUARD_LOG="$TMP_DIR/service-stopped-adguard.log" + ADGUARD_DNS_STATE_DIR="$TMP_DIR/service-stopped-state" + : >"$FAKE_ADGUARD_LOG" + rm -rf "$ADGUARD_DNS_STATE_DIR" + mkdir -p "$ADGUARD_DNS_STATE_DIR" + printf 'up\n' >"$ADGUARD_DNS_STATE_DIR/current_profile" + FAKE_ADGUARD_FAIL=0 + export FAKE_ADGUARD_LOG FAKE_ADGUARD_FAIL ADGUARD_DNS_STATE_DIR + + ( + . "$INIT_SCRIPT" + PROGA="$TMP_DIR/fake-adguard" + ADGUARD_DNS_STATE_DIR="$ADGUARD_DNS_STATE_DIR" + + config_load() { :; } + config_foreach() { + callback="$1" + "$callback" settings + } + config_get_bool() { + case "$3" in + adguard_dns_switch_enabled) eval "$1=0" ;; + *) eval "$1=${4:-0}" ;; + esac + } + config_get() { + case "$3" in + adguard_default_upstreams) eval "$1=9.9.9.9" ;; + *) eval "$1=" ;; + esac + } + + command -v service_stopped >/dev/null 2>&1 || fail "init script must define service_stopped" + service_stopped + ) + + cat "$FAKE_ADGUARD_LOG" +} + +capture_tailscale_snat_args() { + disable_snat="$1" + state_was_present="${2:-1}" + PROCD_LOG="$TMP_DIR/procd-snat-$disable_snat.log" + FAKE_SECRETS_LOG="$TMP_DIR/procd-secrets-$disable_snat.log" + : >"$PROCD_LOG" + : >"$FAKE_SECRETS_LOG" + export PROCD_LOG FAKE_SECRETS_LOG + ( + . "$INIT_SCRIPT" + PROGS="$TMP_DIR/fake-secrets" + config_get() { + case "$3" in + port) eval "$1=41641" ;; + access) eval "$1=ts_ac_lan" ;; + *) eval "$1=" ;; + esac + } + config_get_bool() { + case "$3" in + disable_snat_subnet_routes) eval "$1=$disable_snat" ;; + accept_dns) eval "$1=1" ;; + *) eval "$1=0" ;; + esac + } + config_list_foreach() { :; } + procd_open_instance() { :; } + procd_set_param() { printf 'set:%s\n' "$*" >>"$PROCD_LOG"; } + procd_append_param() { printf 'append:%s\n' "$*" >>"$PROCD_LOG"; } + procd_close_instance() { :; } + tailscale_helper settings "$state_was_present" + ) + [ ! -s "$FAKE_SECRETS_LOG" ] || fail "init must not retrieve the auth key while building the procd command" + cat "$PROCD_LOG" +} + +capture_start_state_flag() { + with_state="$1" + START_CONFIG_DIR="$TMP_DIR/start-config-$with_state" + rm -rf "$START_CONFIG_DIR" + mkdir -p "$START_CONFIG_DIR" + if [ "$with_state" = "1" ]; then + printf '%s\n' 'existing-machine-identity' >"$START_CONFIG_DIR/tailscaled.state" + fi + export START_CONFIG_DIR + + ( + . "$INIT_SCRIPT" + PROG=true + PROGS=true + PROGD=true + CONFIG_PATH="$START_CONFIG_DIR" + + config_get() { + case "$3" in + config_path) eval "$1=\$START_CONFIG_DIR" ;; + port) eval "$1=41641" ;; + *) eval "$1=" ;; + esac + } + config_get_bool() { + case "$3" in + enabled) eval "$1=1" ;; + *) eval "$1=${4:-0}" ;; + esac + } + procd_open_instance() { :; } + procd_set_param() { :; } + procd_append_param() { :; } + procd_close_instance() { :; } + tailscale_helper() { printf '%s\n' "$2"; } + tailscale_keepalive() { :; } + tailscale_adguard_dns() { :; } + + start_instance settings + ) +} + +cat >"$TMP_DIR/fake-tailscaled" <<'SH' +#!/bin/sh +[ "${1:-}" = "--cleanup" ] || exit 1 +printf '%s\n' "$*" >>"${TAILSCALED_CLEANUP_LOG:?}" +SH +chmod +x "$TMP_DIR/fake-tailscaled" + +disabled_output="$(run_apply_down 0)" +[ -z "$disabled_output" ] || fail "disabled AdGuard DNS switch must not apply down profile +actual: $disabled_output" + +enabled_output="$(run_apply_down 1)" +[ "$enabled_output" = "--apply-profile down" ] || fail "enabled AdGuard DNS switch should apply down profile +actual: $enabled_output" + +disabled_with_managed_profile_output="$(run_apply_down 0 up)" +[ "$disabled_with_managed_profile_output" = "--apply-profile down" ] || fail "disabling a managed AdGuard DNS switch should restore the down profile +actual: $disabled_with_managed_profile_output" + +if run_apply_down 0 up 1 >/dev/null; then + fail "failed AdGuard down-profile restoration must propagate through the stop lifecycle" +fi + +run_reload_service 0 +[ "$(cat "$TMP_DIR/reload.log")" = "stop:1 +start" ] || fail "successful reload must use one internal stop followed by start" + +if run_reload_service 1; then + fail "reload must fail when restart fails" +fi +[ "$(cat "$TMP_DIR/reload.log")" = "stop:1 +start +stop:0" ] || fail "failed reload start must execute a final stop that removes persistent firewall state +actual: $(cat "$TMP_DIR/reload.log")" + +disabled_helper_output="$(run_disabled_start)" +[ "$disabled_helper_output" = "--cleanup-managed-firewall" ] || fail "disabled start should invoke complete managed firewall cleanup +actual: ${disabled_helper_output:-}" +[ "$(cat "$TMP_DIR/disabled-secrets.log")" = "activate disabled-secret-ref" ] || fail "every start, including a disabled service, must activate the credential version selected by UCI +actual: $(cat "$TMP_DIR/disabled-secrets.log")" + +run_stop_instance + +[ ! -s "$FAKE_ADGUARD_LOG" ] || fail "stop_instance must not switch AdGuard before procd terminates the watcher +actual: $(cat "$FAKE_ADGUARD_LOG")" + +[ "$(cat "$UCI_DEL_LIST_LOG")" = 'dhcp.@dnsmasq[0].address=/example.tailnet/100.100.100.100' ] || fail "stop_instance must remove only the exact MagicDNS dnsmasq address +actual: $(cat "$UCI_DEL_LIST_LOG")" +[ "$(cat "$UCI_DNSMASQ_ADDRESS_STATE")" = '/example.tailnet/192.0.2.53' ] || fail "stop_instance must preserve a user-defined address with the same MagicDNS suffix +actual: $(cat "$UCI_DNSMASQ_ADDRESS_STATE")" + +service_stopped_output="$(run_service_stopped)" +[ "$service_stopped_output" = "--apply-profile down" ] || fail "service_stopped should apply the down profile after procd terminates the watcher +actual: ${service_stopped_output:-}" + +helper_output="$(cat "$TMP_DIR/helper.log")" +[ "$helper_output" = "--cleanup-managed-firewall" ] || fail "final stop_instance should remove every managed firewall rule +actual: ${helper_output:-}" + +run_stop_instance "" 1 +reload_helper_output="$(cat "$TMP_DIR/helper.log")" +[ "$reload_helper_output" = "--cleanup-exit-node-firewall" ] || fail "reload stop should retain persistent Tailscale zone, forwarding, and WAN-direct rules +actual: ${reload_helper_output:-}" +[ ! -e "$TMP_DIR/reloading" ] || fail "reload state must not be shared through a stale PID marker file" + +if run_stop_instance "--cleanup-managed-firewall"; then + fail "stop_instance should report a managed firewall cleanup failure" +fi +[ -f "$CONFIG_DIR/exit_node_firewall_state" ] || fail "stop_instance must preserve helper recovery state when cleanup fails" + +snat_disabled_args="$(capture_tailscale_snat_args 1)" +printf '%s\n' "$snat_disabled_args" | grep -F 'set:command ' | grep -F -- ' --run' >/dev/null || \ + fail "Tailscale helper procd command must use the explicit --run mode" +if printf '%s\n' "$snat_disabled_args" | grep -F -- '--authkey=' >/dev/null; then + fail "Tailscale auth key must not be included in the procd command" +fi +printf '%s\n' "$snat_disabled_args" | grep -F -- '--snat-subnet-routes=false' >/dev/null || \ + fail "site-to-site enable must pass --snat-subnet-routes=false" +printf '%s\n' "$snat_disabled_args" | grep -F 'DISABLE_SNAT_SUBNET_ROUTES=1' >/dev/null || \ + fail "site-to-site enable must pass no-SNAT state to the firewall helper" +printf '%s\n' "$snat_disabled_args" | grep -F 'TAILSCALE_STATE_WAS_PRESENT=1' >/dev/null || \ + fail "existing state detection must be passed to the helper without exposing the state file" + +fresh_state_args="$(capture_tailscale_snat_args 0 0)" +printf '%s\n' "$fresh_state_args" | grep -F 'TAILSCALE_STATE_WAS_PRESENT=0' >/dev/null || \ + fail "first enrollment must tell the helper that no state existed before daemon startup" + +[ "$(capture_start_state_flag 1)" = "1" ] || \ + fail "start_instance must detect a non-empty identity before starting tailscaled" +[ "$(capture_start_state_flag 0)" = "0" ] || \ + fail "start_instance must preserve first-enrollment intent before tailscaled can create state" + +snat_enabled_args="$(capture_tailscale_snat_args 0)" +printf '%s\n' "$snat_enabled_args" | grep -F -- '--snat-subnet-routes=true' >/dev/null || \ + fail "site-to-site disable must pass --snat-subnet-routes=true" +printf '%s\n' "$snat_enabled_args" | grep -F 'DISABLE_SNAT_SUBNET_ROUTES=0' >/dev/null || \ + fail "site-to-site disable must pass SNAT state to the firewall helper" + +normalized_default="$( + . "$INIT_SCRIPT" + normalize_tailscale_port "" +)" +[ "$normalized_default" = "41641" ] || fail "empty Tailscale port should normalize to 41641" + +normalized_invalid="$( + . "$INIT_SCRIPT" + normalize_tailscale_port "invalid" +)" +[ "$normalized_invalid" = "41641" ] || fail "non-numeric Tailscale port should normalize to 41641" + +normalized_out_of_range="$( + . "$INIT_SCRIPT" + normalize_tailscale_port "70000" +)" +[ "$normalized_out_of_range" = "41641" ] || fail "out-of-range Tailscale port should normalize to 41641" + +normalized_custom="$( + . "$INIT_SCRIPT" + normalize_tailscale_port "42424" +)" +[ "$normalized_custom" = "42424" ] || fail "valid Tailscale port should be preserved" + +echo "tailscale init AdGuard lifecycle tests passed" diff --git a/luci-app-tailscale/tests/tailscale_keepalive_test.sh b/luci-app-tailscale/tests/tailscale_keepalive_test.sh new file mode 100755 index 000000000..c8709a89b --- /dev/null +++ b/luci-app-tailscale/tests/tailscale_keepalive_test.sh @@ -0,0 +1,214 @@ +#!/bin/sh + +set -eu + +ROOT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)" +SCRIPT="$ROOT_DIR/root/usr/sbin/tailscale_keepalive" +INIT_SCRIPT="$ROOT_DIR/root/etc/init.d/tailscale" +TMP_DIR="${TMPDIR:-/tmp}/tailscale-keepalive-test.$$" +STATUS_FILE="$TMP_DIR/status.json" + +cleanup() { + rm -rf "$TMP_DIR" +} +trap cleanup EXIT + +mkdir -p "$TMP_DIR" + +cat >"$TMP_DIR/jshn.sh" <<'SH' +json_cleanup() { + JSHN_LEVEL= + JSHN_PEER= +} + +json_load() { + JSHN_JSON="$1" + printf 'load\n' >>"${JSHN_TRACE:?}" +} + +json_select() { + printf 'select %s\n' "$1" >>"${JSHN_TRACE:?}" + case "$1:$JSHN_LEVEL" in + Peer:) + JSHN_LEVEL=Peer + ;; + TailscaleIPs:peer) + JSHN_LEVEL=ips + ;; + ..:ips) + JSHN_LEVEL=peer + ;; + ..:peer) + JSHN_LEVEL=Peer + JSHN_PEER= + ;; + *:Peer) + JSHN_PEER="$1" + JSHN_LEVEL=peer + ;; + *) + return 1 + ;; + esac +} + +json_get_keys() { + values="$(printf '%s' "$JSHN_JSON" | "${REAL_JQ:?}" -r '.Peer | keys[]')" + eval "$1=\$values" + printf 'get_keys\n' >>"${JSHN_TRACE:?}" +} + +json_get_var() { + value="$(printf '%s' "$JSHN_JSON" | "${REAL_JQ:?}" -r --arg peer "$JSHN_PEER" --arg field "$2" '.Peer[$peer][$field] // ""')" + eval "$1=\$value" + printf 'get_var %s\n' "$2" >>"${JSHN_TRACE:?}" +} + +json_get_values() { + values="$(printf '%s' "$JSHN_JSON" | "${REAL_JQ:?}" -r --arg peer "$JSHN_PEER" '.Peer[$peer].TailscaleIPs[]?')" + eval "$1=\$values" + printf 'get_values\n' >>"${JSHN_TRACE:?}" +} +SH + +cat >"$TMP_DIR/python3" <<'SH' +#!/bin/sh +printf 'python fallback invoked\n' >>"${PYTHON_TRACE:?}" +exit 1 +SH +chmod +x "$TMP_DIR/python3" + +REAL_JQ="${REAL_JQ:-$(command -v jq)}" +JSHN_TRACE="$TMP_DIR/jshn.trace" +PYTHON_TRACE="$TMP_DIR/python.trace" +: >"$JSHN_TRACE" +: >"$PYTHON_TRACE" +export REAL_JQ JSHN_TRACE PYTHON_TRACE + +cat >"$STATUS_FILE" <<'JSON' +{ + "Peer": { + "nodekey:one": { + "HostName": "site-a-openwrt", + "DNSName": "site-a-openwrt.example.tailnet.", + "TailscaleIPs": [ + "100.64.100.2", + "fd7a:115c:a1e0::2" + ] + }, + "nodekey:two": { + "HostName": "site-b-gateway", + "DNSName": "site-b-gateway.example.tailnet.", + "TailscaleIPs": [ + "100.64.100.1" + ] + }, + "nodekey:three": { + "HostName": "no-ip-peer", + "DNSName": "no-ip-peer.example.ts.net.", + "TailscaleIPs": [] + }, + "nodekey:four": { + "HostName": "", + "DNSName": "", + "TailscaleIPs": [ + "100.64.100.44" + ] + } + } +} +JSON + +output="$(PATH="$TMP_DIR:$PATH" JSHN_LIB="$TMP_DIR/jshn.sh" TAILSCALE_STATUS_FILE="$STATUS_FILE" "$SCRIPT" --resolve-peers \ + site-a-openwrt \ + site-b-gateway.example.tailnet \ + 100.64.100.2 \ + 100.64.100.44 \ + missing-peer \ + no-ip-peer)" + +expected="$(printf '%s\t%s\t%s\n%s\t%s\t%s\n%s\t%s\t%s\n%s\t%s\t%s\n%s\t%s\t%s\n%s\t%s\t%s' \ + site-a-openwrt OK 100.64.100.2 \ + site-b-gateway.example.tailnet OK 100.64.100.1 \ + 100.64.100.2 OK 100.64.100.2 \ + 100.64.100.44 OK 100.64.100.44 \ + missing-peer NOT_FOUND '' \ + no-ip-peer NO_IP '')" + +if [ "$output" != "$expected" ]; then + printf 'unexpected resolver output\nexpected:\n%s\nactual:\n%s\n' "$expected" "$output" >&2 + exit 1 +fi + +grep -F 'select Peer' "$JSHN_TRACE" >/dev/null || { + printf 'expected production jshn resolver path to select Peer\n' >&2 + exit 1 +} +grep -F 'get_values' "$JSHN_TRACE" >/dev/null || { + printf 'expected production jshn resolver path to read TailscaleIPs\n' >&2 + exit 1 +} +[ ! -s "$PYTHON_TRACE" ] || { + printf 'jshn resolver unexpectedly fell back to Python:\n' >&2 + cat "$PYTHON_TRACE" >&2 + exit 1 +} +rm -f "$TMP_DIR/python3" + +cat >"$STATUS_FILE" <<'JSON' +{"Peer":{"nodekey:one":{"TailscaleIPs":["100.64.100.9"],"DNSName":"compact-peer.example.tailnet.","HostName":"compact-peer"},"nodekey:two":{"DNSName":"reordered.example.tailnet.","HostName":"reordered-peer","Online":true,"TailscaleIPs":["100.64.100.10"]}}} +JSON + +output="$(TAILSCALE_STATUS_FILE="$STATUS_FILE" "$SCRIPT" --resolve-peers \ + compact-peer \ + reordered.example.tailnet)" + +expected="$(printf '%s\t%s\t%s\n%s\t%s\t%s' \ + compact-peer OK 100.64.100.9 \ + reordered.example.tailnet OK 100.64.100.10)" + +if [ "$output" != "$expected" ]; then + printf 'unexpected compact resolver output\nexpected:\n%s\nactual:\n%s\n' "$expected" "$output" >&2 + exit 1 +fi + +cat >"$TMP_DIR/logger" <<'SH' +#!/bin/sh +printf '%s\n' "$*" >>"$LOGGER_OUTPUT" +SH +chmod +x "$TMP_DIR/logger" + +LOGGER_OUTPUT="$TMP_DIR/logger.out" +PATH="$TMP_DIR:$PATH" +export LOGGER_OUTPUT PATH + +( + config_get_bool() { + eval "$1=1" + } + + config_get() { + case "$3" in + keepalive_peers) eval "$1=" ;; + keepalive_interval) eval "$1=20" ;; + keepalive_failure_log_interval) eval "$1=300" ;; + log_stdout|log_stderr) eval "$1=0" ;; + *) eval "$1=" ;; + esac + } + + procd_open_instance() { :; } + procd_set_param() { :; } + procd_append_param() { :; } + procd_close_instance() { :; } + + . "$INIT_SCRIPT" + tailscale_keepalive cfg +) + +if ! grep -q 'keepalive enabled but no peers configured' "$LOGGER_OUTPUT"; then + printf 'expected init script to log empty keepalive peers\n' >&2 + exit 1 +fi + +printf 'tailscale_keepalive resolver tests passed\n' diff --git a/luci-app-tailscale/tests/tailscale_legacy_adguard_migration_test.sh b/luci-app-tailscale/tests/tailscale_legacy_adguard_migration_test.sh new file mode 100755 index 000000000..a0cb34ace --- /dev/null +++ b/luci-app-tailscale/tests/tailscale_legacy_adguard_migration_test.sh @@ -0,0 +1,85 @@ +#!/bin/sh + +set -eu + +ROOT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)" +SCRIPT="$ROOT_DIR/root/etc/uci-defaults/40_luci-tailscale" +TMP_DIR="${TMPDIR:-/tmp}/tailscale-legacy-adguard-migration-test.$$" + +cleanup() { + rm -rf "$TMP_DIR" +} +trap cleanup EXIT + +fail() { + echo "FAIL: $*" >&2 + exit 1 +} + +mkdir -p "$TMP_DIR/init.d" "$TMP_DIR/rc.d" "$TMP_DIR/bin" + +cat >"$TMP_DIR/init.d/tailscale-adguard-dns" <<'SH' +#!/bin/sh +printf '%s\n' "$*" >>"${LEGACY_SERVICE_LOG:?}" +if [ "${1:-}" = 'disable' ]; then + rm -f "${LEGACY_ADGUARD_DNS_RC_DIR:?}"/*tailscale-adguard-dns +fi +SH +chmod +x "$TMP_DIR/init.d/tailscale-adguard-dns" +ln -s "$TMP_DIR/init.d/tailscale-adguard-dns" "$TMP_DIR/rc.d/S99tailscale-adguard-dns" + +cat >"$TMP_DIR/bin/tailscale-secrets" <<'SH' +#!/bin/sh +printf '%s\n' "$*" >>"${SECRETS_LOG:?}" +[ "${1:-}" = 'migrate' ] +SH +chmod +x "$TMP_DIR/bin/tailscale-secrets" + +cat >"$TMP_DIR/bin/uci" <<'SH' +#!/bin/sh +case "${1:-}" in + -q) exit 1 ;; + *) exit 1 ;; +esac +SH +chmod +x "$TMP_DIR/bin/uci" + +LEGACY_SERVICE_LOG="$TMP_DIR/legacy-service.log" +SECRETS_LOG="$TMP_DIR/secrets.log" +: >"$LEGACY_SERVICE_LOG" +: >"$SECRETS_LOG" +export LEGACY_SERVICE_LOG SECRETS_LOG + +run_migration() { + UPGRADE_STATE_DIR="$TMP_DIR/upgrade" \ + TAILSCALE_SECRETS_BIN="$TMP_DIR/bin/tailscale-secrets" \ + UCI_BIN="$TMP_DIR/bin/uci" \ + OPENCLASH_BYPASS_INIT="$TMP_DIR/init.d/tailscale-openclash-bypass" \ + LEGACY_ADGUARD_DNS_INIT="$TMP_DIR/init.d/tailscale-adguard-dns" \ + LEGACY_ADGUARD_DNS_RC_DIR="$TMP_DIR/rc.d" \ + LEGACY_ADGUARD_DNS_BACKUP_DIR="$TMP_DIR/backups" \ + LUCI_INDEX_CACHE="$TMP_DIR/luci-indexcache" \ + "$SCRIPT" +} + +run_migration + +[ ! -e "$TMP_DIR/init.d/tailscale-adguard-dns" ] || fail "legacy AdGuard DNS init script must be removed from init.d" +[ ! -e "$TMP_DIR/rc.d/S99tailscale-adguard-dns" ] || fail "legacy AdGuard DNS init link must be removed" +expected_legacy_log="$(printf 'stop\ndisable')" +[ "$(cat "$LEGACY_SERVICE_LOG")" = "$expected_legacy_log" ] || fail "legacy AdGuard DNS service must be stopped and disabled before migration" +[ "$(cat "$SECRETS_LOG")" = 'migrate' ] || fail "credential migration must still run after legacy service cleanup" + +backup_dir="$(find "$TMP_DIR/backups" -mindepth 1 -maxdepth 1 -type d | head -n 1)" +[ -n "$backup_dir" ] || fail "legacy AdGuard DNS service must be backed up before removal" +[ -f "$backup_dir/init" ] || fail "legacy AdGuard DNS init script backup is missing" +[ -f "$backup_dir/tailscale-adguard-dns.disabled" ] || fail "disabled legacy AdGuard DNS init script backup is missing" +expected_rc_link="$(printf 'S99tailscale-adguard-dns\t%s' "$TMP_DIR/init.d/tailscale-adguard-dns")" +[ "$(cat "$backup_dir/rc-links")" = "$expected_rc_link" ] || fail "legacy rc link backup is incomplete" + +run_migration +[ "$(cat "$LEGACY_SERVICE_LOG")" = "$expected_legacy_log" ] || fail "legacy service cleanup must be idempotent" +expected_secrets_log="$(printf 'migrate\nmigrate')" +[ "$(cat "$SECRETS_LOG")" = "$expected_secrets_log" ] || fail "credential migration must continue on an idempotent upgrade run" + +echo "tailscale legacy AdGuard migration tests passed" diff --git a/luci-app-tailscale/tests/tailscale_openclash_bypass_test.sh b/luci-app-tailscale/tests/tailscale_openclash_bypass_test.sh new file mode 100644 index 000000000..89b4b5f22 --- /dev/null +++ b/luci-app-tailscale/tests/tailscale_openclash_bypass_test.sh @@ -0,0 +1,1220 @@ +#!/bin/sh +set -eu + +ROOT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)" +SCRIPT="$ROOT_DIR/root/usr/sbin/tailscale_openclash_bypass" +TMP_DIR="${TMPDIR:-/tmp}/tailscale-openclash-test.$$" +REAL_CHOWN="$(command -v chown)" +REAL_CHMOD="$(command -v chmod)" +REAL_DD="$(command -v dd)" +REAL_GREP="$(command -v grep)" +SYNC_ENABLE_PID= +SYNC_DISABLE_PID= + +cleanup() { + [ -z "$SYNC_ENABLE_PID" ] || kill "$SYNC_ENABLE_PID" >/dev/null 2>&1 || true + [ -z "$SYNC_DISABLE_PID" ] || kill "$SYNC_DISABLE_PID" >/dev/null 2>&1 || true + rm -rf "$TMP_DIR" +} +trap cleanup EXIT + +fail() { printf 'FAIL: %s\n' "$*" >&2; exit 1; } +file_mode() { + stat -c '%a' "$1" 2>/dev/null || stat -f '%Lp' "$1" +} +file_owner() { + stat -c '%u:%g' "$1" 2>/dev/null || stat -f '%u:%g' "$1" +} +file_permissions() { + LC_ALL=C ls -ld "$1" | awk '{ print substr($1, 1, 10) }' +} +assert_count() { + expected="$1"; needle="$2"; file="$3" + actual="$(grep -cF -- "$needle" "$file" || true)" + [ "$actual" = "$expected" ] || fail "$file expected $expected occurrences of $needle, got $actual" +} +assert_line_count() { + expected="$1"; line="$2"; file="$3" + actual="$(grep -Fxc -- "$line" "$file" || true)" + [ "$actual" = "$expected" ] || fail "$file expected $expected exact occurrences of $line, got $actual" +} +assert_chown_owner() { + owner="$1" + grep -F -- "$owner " "$CHOWN_LOG" >/dev/null || fail "chown did not receive numeric owner/group $owner" +} +managed_hook_present() { + grep -Fx '# BEGIN luci-app-tailscale 托管:Tailscale 绕过 OpenClash' "$1" >/dev/null 2>&1 +} +assert_owner_before_mode() { + owner_line="$(grep -n '^chown ' "$REPLACEMENT_LOG" | tail -n 1 | cut -d: -f1)" + mode_line="$(grep -n '^chmod ' "$REPLACEMENT_LOG" | tail -n 1 | cut -d: -f1)" + [ -n "$owner_line" ] || fail 'replacement did not restore hook ownership' + [ -n "$mode_line" ] || fail 'replacement did not restore hook mode' + [ "$owner_line" -lt "$mode_line" ] || fail 'replacement must restore owner before mode' +} + +if grep -E 'grep[[:space:]]+-[^[:space:]]*b' "$SCRIPT" >/dev/null; then + fail 'OpenClash helper must not use grep -b on BusyBox 1.36' +fi + +mkdir -p "$TMP_DIR/openclash/custom" "$TMP_DIR/bin" "$TMP_DIR/tmp" +cat >"$TMP_DIR/bin/flock" <<'PL' +#!/usr/bin/perl +use strict; +use warnings; +use Fcntl qw(LOCK_EX); + +@ARGV == 2 && $ARGV[0] eq '-x' && $ARGV[1] =~ /^\d+$/ or die "unsupported flock arguments\n"; +open my $lock_fh, "<&=$ARGV[1]" or die "cannot inherit lock fd: $!\n"; +flock($lock_fh, LOCK_EX) or die "cannot acquire lock: $!\n"; +PL +chmod +x "$TMP_DIR/bin/flock" +cat >"$TMP_DIR/bin/grep" <<'SH' +#!/bin/sh +for arg in "$@"; do + case "$arg" in + --byte-offset|-*b*) + printf 'fake grep rejects byte offsets: %s\n' "$arg" >&2 + exit 64 + ;; + esac +done +exec "${REAL_GREP:?}" "$@" +SH +chmod +x "$TMP_DIR/bin/grep" +cat >"$TMP_DIR/bin/stat" <<'SH' +#!/bin/sh +printf 'stat is unavailable on the target OpenWrt image\n' >&2 +exit 127 +SH +chmod +x "$TMP_DIR/bin/stat" +cat >"$TMP_DIR/bin/od" <<'SH' +#!/bin/sh +printf 'od is unavailable on the target OpenWrt image\n' >&2 +exit 127 +SH +chmod +x "$TMP_DIR/bin/od" +cat >"$TMP_DIR/bin/chown" <<'SH' +#!/bin/sh +printf '%s\n' "$*" >>"${CHOWN_LOG:?}" +printf 'chown %s\n' "$*" >>"${REPLACEMENT_LOG:?}" +exec "${REAL_CHOWN:?}" "$@" +SH +chmod +x "$TMP_DIR/bin/chown" +cat >"$TMP_DIR/bin/chmod" <<'SH' +#!/bin/sh +printf 'chmod %s\n' "$*" >>"${REPLACEMENT_LOG:?}" +exec "${REAL_CHMOD:?}" "$@" +SH +chmod +x "$TMP_DIR/bin/chmod" +cat >"$TMP_DIR/bin/dd" <<'SH' +#!/bin/sh +has_count=0 +has_skip=0 +for arg in "$@"; do + case "$arg" in + count=*) has_count=1 ;; + skip=*) has_skip=1 ;; + esac +done +if [ "${DD_FAIL_PREFIX:-0}" = 1 ] && [ "$has_count" = 1 ] && [ "$has_skip" = 0 ]; then + printf 'failed: %s\n' "$*" >>"${DD_LOG:?}" + exit 1 +fi +if [ "${DD_FAIL_PROBE:-0}" = 1 ] && [ "$has_count" = 1 ] && [ "$has_skip" = 1 ]; then + printf 'probe-failed: %s\n' "$*" >>"${DD_LOG:?}" + exit 1 +fi +if [ "${DD_SIGNAL_PARENT:-0}" = 1 ] && [ "$has_count" = 1 ] && [ "$has_skip" = 0 ]; then + printf 'signalled: %s\n' "$*" >>"${DD_LOG:?}" + kill -TERM "$PPID" + exit 1 +fi +printf 'passed: %s\n' "$*" >>"${DD_LOG:?}" +exec "${REAL_DD:?}" "$@" +SH +chmod +x "$TMP_DIR/bin/dd" +cat >"$TMP_DIR/bin/uci" <<'SH' +#!/bin/sh +set -eu + +printf '%s\n' "$*" >>"${UCI_LOG:?}" +case "$*" in + '-q show tailscale_openclash.settings') + case "${UCI_SECTION_MODE:-present}" in + present) + printf '%s\n' 'tailscale_openclash.settings=openclash' + [ -z "${FEATURE_ENABLED:-}" ] || \ + printf "tailscale_openclash.settings.enabled='%s'\n" "$FEATURE_ENABLED" + ;; + missing) exit 1 ;; + error) exit 74 ;; + malformed) printf '%s\n' 'malformed uci section output' ;; + *) exit 99 ;; + esac + ;; + '-q get tailscale_openclash.settings.enabled') + printf 'value:%s\n' "${FEATURE_ENABLED:-default}" >>"${UCI_LOG:?}" + [ "${UCI_SECTION_MODE:-present}" = present ] || exit 1 + [ "${UCI_OPTION_GET_FAIL:-0}" = 0 ] || exit 75 + if [ -n "${UCI_READY_FILE:-}" ]; then + : >"$UCI_READY_FILE" + while [ ! -e "${UCI_RELEASE_FILE:?}" ]; do sleep 0.05; done + fi + [ -n "${FEATURE_ENABLED:-}" ] || exit 1 + printf '%s\n' "$FEATURE_ENABLED" + ;; + *) + printf 'unsupported fake uci command: %s\n' "$*" >&2 + exit 99 + ;; +esac +SH +chmod +x "$TMP_DIR/bin/uci" +cat >"$TMP_DIR/bin/logger" <<'SH' +#!/bin/sh +printf '%s\n' "$*" >>"${LOGGER_LOG:?}" +SH +chmod +x "$TMP_DIR/bin/logger" +cat >"$TMP_DIR/bin/nft" <<'SH' +#!/bin/sh +set -eu + +state="${NFT_STATE:?}" +guard_state="${NFT_GUARD_STATE:?}" +batch_log="${NFT_BATCH_LOG:?}" +call_log="${NFT_CALL_LOG:?}" +table_state="${NFT_TABLE_STATE:-PRESENT}" + +printf '%s\n' "$*" >>"$call_log" + +print_rule_json() { + handle="$1" + rule="$2" + rest="${rule#insert rule inet fw4 }" + rule_chain="${rest%% *}" + comment="$(printf '%s\n' "$rule" | sed -n 's/.* return comment "\(.*\)"$/\1/p')" + [ -n "$comment" ] || return 0 + json_chain="$rule_chain" + case "$rule" in + *' meta mark & 0x00ff0000 == 0x00080000 counter return comment '*) + match_json='{"match":{"op":"==","left":{"&":[{"meta":{"key":"mark"}},16711680]},"right":524288}}' + ;; + *' iifname "tailscale0" counter return comment '*) + match_json='{"match":{"op":"==","left":{"meta":{"key":"iifname"}},"right":"tailscale0"}}' + ;; + *) + match_json='{"match":{"op":"==","left":{"meta":{"key":"iifname"}},"right":"unrelated0"}}' + ;; + esac + counter_json="{\"counter\":{\"packets\":${NFT_COUNTER_PACKETS:-0},\"bytes\":${NFT_COUNTER_BYTES:-0}}}" + verdict_json='{"return":null}' + handle_json=",\"handle\":$handle" + case "$comment" in + luci-app-tailscale:*) + case "${NFT_RULE_JSON_MODE:-canonical}" in + altered-match) + match_json='{"match":{"op":"==","left":{"meta":{"key":"mark"}},"right":0}}' + ;; + overbroad) match_json= ;; + incomplete) verdict_json= ;; + altered-verdict) verdict_json='{"accept":null}' ;; + wrong-chain) json_chain='wrong_chain' ;; + esac + if [ "${NFT_CHAIN_JSON_TARGET:-}" = "$rule_chain" ]; then + case "${NFT_CHAIN_JSON_MODE:-canonical}" in + wrong-rule-chain) json_chain='wrong_chain' ;; + missing-owned-handle) handle_json= ;; + esac + fi + ;; + esac + if [ -n "$match_json" ] && [ -n "$verdict_json" ]; then + expr_json="[$match_json,$counter_json,$verdict_json]" + elif [ -n "$match_json" ]; then + expr_json="[$match_json,$counter_json]" + else + expr_json="[$counter_json,$verdict_json]" + fi + printf ',{"rule":{"family":"inet","table":"fw4","chain":"%s"%s,"expr":%s,"comment":"%s"}}' \ + "$json_chain" "$handle_json" "$expr_json" "$comment" +} + +print_chain_json() { + chain="$1" + chain_json_mode=canonical + [ "${NFT_CHAIN_JSON_TARGET:-}" != "$chain" ] || chain_json_mode="${NFT_CHAIN_JSON_MODE:-canonical}" + [ "$chain_json_mode" != empty ] || { + printf '%s\n' '{"nftables":[]}' + return + } + json_chain_name="$chain" + [ "$chain_json_mode" != wrong-chain-object ] || json_chain_name=wrong_chain + printf '{"nftables":[{"metainfo":{"version":"1.1.1","release_name":"Commodore Bullmoose #2","json_schema_version":1}},{"chain":{"family":"inet","table":"fw4","name":"%s","handle":1075}}' "$json_chain_name" + while IFS='|' read -r handle rule || [ -n "${handle:-}" ]; do + case "$rule" in + "insert rule inet fw4 $chain "*) ;; + *) continue ;; + esac + print_rule_json "$handle" "$rule" + done <"$state" + printf ']}\n' +} + +target_chain() { + case "$1" in + openclash_mangle_output|openclash_output|openclash_mangle|openclash) return 0 ;; + *) return 1 ;; + esac +} + +delete_rule_from_state() { + state_file="$1" + chain="$2" + handle="$3" + temp_file="$(mktemp "${state_file}.XXXXXX")" + found=0 + while IFS='|' read -r stored_handle stored_rule || [ -n "${stored_handle:-}" ]; do + if [ "$stored_handle" = "$handle" ]; then + case "$stored_rule" in + "insert rule inet fw4 $chain "*) found=1; continue ;; + esac + fi + printf '%s|%s\n' "$stored_handle" "$stored_rule" >>"$temp_file" + done <"$state_file" + if [ "$found" -ne 1 ]; then + printf 'nonexistent nft handle: %s/%s\n' "$chain" "$handle" >&2 + rm -f "$temp_file" + return 1 + fi + mv "$temp_file" "$state_file" +} + +delete_counter_from_state() { + state_file="$1" + counter="$2" + temp_file="$(mktemp "${state_file}.XXXXXX")" + found=0 + while IFS= read -r stored_counter || [ -n "$stored_counter" ]; do + if [ "$stored_counter" = "$counter" ]; then + found=$((found + 1)) + continue + fi + printf '%s\n' "$stored_counter" >>"$temp_file" + done <"$state_file" + if [ "$found" -ne 1 ]; then + printf 'nonexistent nft counter: %s\n' "$counter" >&2 + rm -f "$temp_file" + return 1 + fi + mv "$temp_file" "$state_file" +} + +replace_table_generation() { + batch="$1" + replacement="$(mktemp "${state}.replacement.XXXXXX")" + while IFS= read -r statement || [ -n "$statement" ]; do + case "$statement" in + 'delete rule inet fw4 '*) + rest="${statement#delete rule inet fw4 }" + chain="${rest%% handle *}" + handle="${rest##* handle }" + printf '%s|insert rule inet fw4 %s counter return comment "unrelated-generation-rule-%s"\n' \ + "$handle" "$chain" "$handle" >>"$replacement" + ;; + esac + done <"$batch" + mv "$replacement" "$state" + : >"$guard_state" +} + +apply_batch() { + batch="$1" + next_state="$(mktemp "${state}.next.XXXXXX")" + next_guard_state="$(mktemp "${guard_state}.next.XXXXXX")" + cp "$state" "$next_state" + cp "$guard_state" "$next_guard_state" + operation=0 + while IFS= read -r statement || [ -n "$statement" ]; do + operation=$((operation + 1)) + case "$statement" in + 'delete counter inet fw4 '*) + counter="${statement#delete counter inet fw4 }" + if ! delete_counter_from_state "$next_guard_state" "$counter"; then + rm -f "$next_state" "$next_guard_state" + return 1 + fi + ;; + 'delete rule inet fw4 '*) + rest="${statement#delete rule inet fw4 }" + chain="${rest%% handle *}" + handle="${rest##* handle }" + if ! delete_rule_from_state "$next_state" "$chain" "$handle"; then + rm -f "$next_state" "$next_guard_state" + return 1 + fi + ;; + 'insert rule inet fw4 '*) + next_handle="$(awk -F '|' 'BEGIN { max = 0 } $1 + 0 > max { max = $1 + 0 } END { print max + 1 }' "$next_state")" + printf '%s|%s\n' "$next_handle" "$statement" >>"$next_state" + ;; + *) + printf 'unsupported fake nft statement: %s\n' "$statement" >&2 + rm -f "$next_state" "$next_guard_state" + exit 99 + ;; + esac + if [ "${NFT_BATCH_FAIL_AFTER:-0}" = "$operation" ]; then + rm -f "$next_state" "$next_guard_state" + exit 1 + fi + done <"$batch" + mv "$next_state" "$state" + mv "$next_guard_state" "$guard_state" +} + +print_table_json() { + printf '{"nftables":[{"table":{"family":"inet","name":"fw4"}}' + for chain in openclash_mangle_output openclash_output openclash_mangle openclash; do + [ "${MISSING_CHAIN:-}" = "$chain" ] && continue + if [ -s "${NFT_DISAPPEARED_FILE:?}" ] && grep -Fx "$chain" "$NFT_DISAPPEARED_FILE" >/dev/null; then + continue + fi + printf ',{"chain":{"family":"inet","table":"fw4","name":"%s"}}' "$chain" + done + while IFS='|' read -r handle rule || [ -n "${handle:-}" ]; do + rest="${rule#insert rule inet fw4 }" + chain="${rest%% *}" + [ "${MISSING_CHAIN:-}" = "$chain" ] && continue + if [ -s "${NFT_DISAPPEARED_FILE:?}" ] && grep -Fx "$chain" "$NFT_DISAPPEARED_FILE" >/dev/null; then + continue + fi + print_rule_json "$handle" "$rule" + done <"$state" + printf ']}\n' +} + +print_table_inventory_json() { + case "$table_state" in + TABLE_ABSENT) + printf '%s\n' '{"nftables":[{"table":{"family":"inet","name":"other"}}]}' + ;; + *) + printf '%s\n' '{"nftables":[{"table":{"family":"inet","name":"fw4"}}]}' + ;; + esac +} + +if [ "$#" = 3 ] && [ "$1" = '-j' ] && [ "$2" = 'list' ] && [ "$3" = 'tables' ]; then + case "$table_state" in + INVENTORY_READ_ERROR) exit 73 ;; + INVENTORY_MALFORMED) printf '%s\n' 'not-json'; exit 0 ;; + esac + if [ "${REFRESH_INVENTORY_ERROR:-0}" = 1 ] && \ + [ "$(grep -Fxc -- '-j list tables' "$call_log")" -gt 1 ]; then + exit 73 + fi + print_table_inventory_json + exit 0 +fi + +if [ "$#" = 5 ] && [ "$1" = '-j' ] && [ "$2" = 'list' ] && [ "$3" = 'table' ] && \ + [ "$4" = 'inet' ] && [ "$5" = 'fw4' ]; then + case "$table_state" in + TABLE_ABSENT|TABLE_READ_ERROR|INVENTORY_READ_ERROR|INVENTORY_MALFORMED) exit 72 ;; + TABLE_MALFORMED) printf '%s\n' 'not-json'; exit 0 ;; + esac + if [ "${NFT_JSON_UNSUPPORTED:-0}" = 1 ]; then + printf 'not-json\n' + else + print_table_json + fi + exit 0 +fi + +if [ "$#" = 7 ] && [ "$1" = '-j' ] && [ "$2" = '-a' ] && [ "$3" = 'list' ] && \ + [ "$4" = 'chain' ] && [ "$5" = 'inet' ] && [ "$6" = 'fw4' ]; then + chain="$7" + [ "$table_state" = PRESENT ] || exit 72 + target_chain "$chain" || { + printf 'unsupported fake nft chain: %s\n' "$chain" >&2 + exit 99 + } + [ "${MISSING_CHAIN:-}" = "$chain" ] && exit 1 + if [ "${DISAPPEAR_CHAIN:-}" = "$chain" ] && ! grep -Fx "$chain" "${NFT_DISAPPEARED_FILE:?}" >/dev/null 2>&1; then + printf '%s\n' "$chain" >"$NFT_DISAPPEARED_FILE" + exit 1 + fi + grep -Fx "$chain" "${NFT_DISAPPEARED_FILE:?}" >/dev/null 2>&1 && exit 1 + [ "${LIST_CHAIN_FAIL:-}" = "$chain" ] && exit 1 + if [ "${NFT_JSON_UNSUPPORTED:-0}" = 1 ]; then + printf 'not-json\n' + else + print_chain_json "$chain" + fi + exit 0 +fi + +if [ "$#" = 5 ] && [ "$1" = 'add' ] && [ "$2" = 'counter' ] && \ + [ "$3" = 'inet' ] && [ "$4" = 'fw4' ]; then + [ "$table_state" = PRESENT ] || exit 72 + grep -Fx "$5" "$guard_state" >/dev/null 2>&1 && exit 1 + printf '%s\n' "$5" >>"$guard_state" + exit 0 +fi + +if [ "$#" = 5 ] && [ "$1" = 'delete' ] && [ "$2" = 'counter' ] && \ + [ "$3" = 'inet' ] && [ "$4" = 'fw4' ]; then + [ "$table_state" = PRESENT ] || exit 72 + if [ "${NFT_GUARD_DELETE_FAIL_ONCE:-0}" = 1 ] && \ + [ ! -e "${NFT_GUARD_DELETE_FAIL_ONCE_FILE:?}" ]; then + : >"$NFT_GUARD_DELETE_FAIL_ONCE_FILE" + exit 71 + fi + if [ "${NFT_GUARD_DELETE_SIGNAL_ONCE:-0}" = 1 ] && \ + [ ! -e "${NFT_GUARD_DELETE_SIGNAL_ONCE_FILE:?}" ]; then + : >"$NFT_GUARD_DELETE_SIGNAL_ONCE_FILE" + kill -TERM "$PPID" + exit 1 + fi + delete_counter_from_state "$guard_state" "$5" + exit $? +fi + +if [ "$#" = 2 ] && [ "$1" = '-f' ]; then + cat "$2" >"$batch_log" + [ "${NFT_BATCH_FAIL:-0}" = 1 ] && exit 1 + if [ "${NFT_SIGNAL_PARENT:-0}" = 1 ]; then + kill -TERM "$PPID" + exit 1 + fi + if [ "${NFT_GENERATION_REPLACE:-0}" = 1 ]; then + replace_table_generation "$2" + fi + apply_batch "$2" + exit 0 +fi + +printf 'unsupported fake nft command: %s\n' "$*" >&2 +exit 99 +SH +chmod +x "$TMP_DIR/bin/nft" +touch "$TMP_DIR/openclash-init" +chmod +x "$TMP_DIR/openclash-init" +HOOK="$TMP_DIR/openclash/custom/openclash_custom_firewall_rules.sh" +cat >"$HOOK" <<'EOF' +#!/bin/sh +printf user-before +printf '%s\n' 'user text mentions # BEGIN luci-app-tailscale 托管:Tailscale 绕过 OpenClash without owning it' +printf user-after +EOF +chmod 6750 "$HOOK" +original_owner="$(file_owner "$HOOK")" +original_hash="$(sha256sum "$HOOK" | awk '{print $1}')" +CHOWN_LOG="$TMP_DIR/chown.log" +REPLACEMENT_LOG="$TMP_DIR/replacement.log" +DD_LOG="$TMP_DIR/dd.log" +NFT_STATE="$TMP_DIR/nft.state" +NFT_GUARD_STATE="$TMP_DIR/nft.guards" +NFT_BATCH_LOG="$TMP_DIR/nft.batch" +NFT_CALL_LOG="$TMP_DIR/nft.calls" +NFT_DISAPPEARED_FILE="$TMP_DIR/nft.disappeared" +NFT_GUARD_DELETE_FAIL_ONCE_FILE="$TMP_DIR/nft.guard-delete-fail-once" +NFT_GUARD_DELETE_SIGNAL_ONCE_FILE="$TMP_DIR/nft.guard-delete-signal-once" +UCI_LOG="$TMP_DIR/uci.log" +LOGGER_LOG="$TMP_DIR/logger.log" +: >"$NFT_STATE" +: >"$NFT_GUARD_STATE" +: >"$NFT_BATCH_LOG" +: >"$NFT_CALL_LOG" +: >"$NFT_DISAPPEARED_FILE" +: >"$UCI_LOG" +: >"$LOGGER_LOG" +export CHOWN_LOG REPLACEMENT_LOG DD_LOG REAL_CHOWN REAL_CHMOD REAL_DD REAL_GREP +export NFT_STATE NFT_GUARD_STATE NFT_BATCH_LOG NFT_CALL_LOG NFT_DISAPPEARED_FILE UCI_LOG LOGGER_LOG +export NFT_GUARD_DELETE_FAIL_ONCE_FILE NFT_GUARD_DELETE_SIGNAL_ONCE_FILE + +run_helper() { + OPENCLASH_INIT="$TMP_DIR/openclash-init" \ + OPENCLASH_HOOK_FILE="$HOOK" \ + LOCK_FILE="$TMP_DIR/lock" \ + UCI_BIN="$TMP_DIR/bin/uci" \ + NFT_BIN="$TMP_DIR/bin/nft" \ + JQ_BIN=jq \ + LOGGER_CMD="$TMP_DIR/bin/logger" \ + TMPDIR="$TMP_DIR/tmp" \ + DD_FAIL_PREFIX="${DD_FAIL_PREFIX:-0}" \ + DD_FAIL_PROBE="${DD_FAIL_PROBE:-0}" \ + DD_SIGNAL_PARENT="${DD_SIGNAL_PARENT:-0}" \ + FEATURE_ENABLED="${FEATURE_ENABLED:-}" \ + UCI_SECTION_MODE="${UCI_SECTION_MODE:-present}" \ + UCI_OPTION_GET_FAIL="${UCI_OPTION_GET_FAIL:-0}" \ + NFT_TABLE_STATE="${NFT_TABLE_STATE:-PRESENT}" \ + REFRESH_INVENTORY_ERROR="${REFRESH_INVENTORY_ERROR:-0}" \ + NFT_RULE_JSON_MODE="${NFT_RULE_JSON_MODE:-canonical}" \ + NFT_CHAIN_JSON_MODE="${NFT_CHAIN_JSON_MODE:-canonical}" \ + NFT_CHAIN_JSON_TARGET="${NFT_CHAIN_JSON_TARGET:-}" \ + NFT_GUARD_DELETE_FAIL_ONCE="${NFT_GUARD_DELETE_FAIL_ONCE:-0}" \ + NFT_GUARD_DELETE_SIGNAL_ONCE="${NFT_GUARD_DELETE_SIGNAL_ONCE:-0}" \ + NFT_COUNTER_PACKETS="${NFT_COUNTER_PACKETS:-0}" \ + NFT_COUNTER_BYTES="${NFT_COUNTER_BYTES:-0}" \ + PATH="$TMP_DIR/bin:$PATH" \ + "$SCRIPT" "$@" +} + +failed_reconcile_hash="$(sha256sum "$HOOK" | awk '{print $1}')" +failed_reconcile_mode="$(file_mode "$HOOK")" +failed_reconcile_owner="$(file_owner "$HOOK")" +failed_reconcile_permissions="$(file_permissions "$HOOK")" +: >"$DD_LOG" +if DD_FAIL_PREFIX=1 run_helper reconcile-hook; then + fail 'reconcile-hook must fail when prefix copy fails' +fi +DD_FAIL_PREFIX=0 +grep -F 'failed:' "$DD_LOG" >/dev/null || fail 'reconcile-hook did not execute the failing prefix copy' +[ "$(sha256sum "$HOOK" | awk '{print $1}')" = "$failed_reconcile_hash" ] || \ + fail 'reconcile-hook changed hook after prefix copy failure' +[ "$(file_mode "$HOOK")" = "$failed_reconcile_mode" ] || \ + fail 'reconcile-hook changed hook mode after prefix copy failure' +[ "$(file_owner "$HOOK")" = "$failed_reconcile_owner" ] || \ + fail 'reconcile-hook changed hook ownership after prefix copy failure' +[ "$(file_permissions "$HOOK")" = "$failed_reconcile_permissions" ] || \ + fail 'reconcile-hook changed special permission bits after prefix copy failure' + +run_helper reconcile-hook +assert_line_count 1 '# BEGIN luci-app-tailscale 托管:Tailscale 绕过 OpenClash' "$HOOK" +assert_line_count 1 '# END luci-app-tailscale 托管:Tailscale 绕过 OpenClash' "$HOOK" +grep -F 'printf user-before' "$HOOK" >/dev/null || fail 'hook insertion removed user content before the block' +grep -F 'printf user-after' "$HOOK" >/dev/null || fail 'hook insertion removed user content after the block' +[ "$(file_permissions "$HOOK")" = '-rwsr-s---' ] || \ + fail 'hook mode changed, including special permission bits' +grep -F 'chmod 6750 ' "$REPLACEMENT_LOG" >/dev/null || \ + fail 'replacement did not restore special permission bits numerically' +[ "$(file_owner "$HOOK")" = "$original_owner" ] || fail 'hook ownership changed' + +first_hash="$(sha256sum "$HOOK" | awk '{print $1}')" +run_helper reconcile-hook +[ "$(sha256sum "$HOOK" | awk '{print $1}')" = "$first_hash" ] || fail 'reconcile-hook is not idempotent' + +probe_reconcile_hash="$(sha256sum "$HOOK" | awk '{print $1}')" +probe_reconcile_permissions="$(file_permissions "$HOOK")" +: >"$DD_LOG" +if DD_FAIL_PROBE=1 run_helper reconcile-hook; then + fail 'reconcile-hook must fail when the optional-newline probe cannot be read' +fi +DD_FAIL_PROBE=0 +grep -F 'probe-failed:' "$DD_LOG" >/dev/null || fail 'reconcile-hook did not execute the failing newline probe' +[ "$(sha256sum "$HOOK" | awk '{print $1}')" = "$probe_reconcile_hash" ] || \ + fail 'reconcile-hook changed hook after newline probe failure' +[ "$(file_permissions "$HOOK")" = "$probe_reconcile_permissions" ] || \ + fail 'reconcile-hook changed hook mode after newline probe failure' + +sed 's#^[[:space:]]*/usr/sbin/tailscale_openclash_bypass apply$#\tprintf stale-managed-body#' "$HOOK" >"$TMP_DIR/stale-hook" +chmod 750 "$TMP_DIR/stale-hook" +mv "$TMP_DIR/stale-hook" "$HOOK" +run_helper reconcile-hook +grep -Fx ' /usr/sbin/tailscale_openclash_bypass apply' "$HOOK" >/dev/null || \ + fail 'reconcile-hook did not restore the canonical managed command' +grep -F 'stale-managed-body' "$HOOK" >/dev/null && \ + fail 'reconcile-hook retained a stale managed body' +grep -F 'user text mentions # BEGIN luci-app-tailscale' "$HOOK" >/dev/null || \ + fail 'reconcile-hook removed marker-like user text' + +cp "$HOOK" "$TMP_DIR/hook-before-malformed" +printf '%s\n' '# BEGIN luci-app-tailscale 托管:Tailscale 绕过 OpenClash' >>"$HOOK" +malformed_hash="$(sha256sum "$HOOK" | awk '{print $1}')" +if run_helper reconcile-hook >/dev/null 2>&1; then + fail 'duplicate managed markers must fail' +fi +[ "$(sha256sum "$HOOK" | awk '{print $1}')" = "$malformed_hash" ] || fail 'malformed hook changed after rejection' + +cp "$TMP_DIR/hook-before-malformed" "$HOOK" +probe_cleanup_hash="$(sha256sum "$HOOK" | awk '{print $1}')" +probe_cleanup_permissions="$(file_permissions "$HOOK")" +: >"$DD_LOG" +if DD_FAIL_PROBE=1 run_helper cleanup; then + fail 'cleanup must fail when the optional-newline probe cannot be read' +fi +DD_FAIL_PROBE=0 +grep -F 'probe-failed:' "$DD_LOG" >/dev/null || fail 'cleanup did not execute the failing newline probe' +[ "$(sha256sum "$HOOK" | awk '{print $1}')" = "$probe_cleanup_hash" ] || \ + fail 'cleanup changed hook after newline probe failure' +[ "$(file_permissions "$HOOK")" = "$probe_cleanup_permissions" ] || \ + fail 'cleanup changed hook mode after newline probe failure' + +failed_cleanup_hash="$(sha256sum "$HOOK" | awk '{print $1}')" +failed_cleanup_mode="$(file_mode "$HOOK")" +failed_cleanup_owner="$(file_owner "$HOOK")" +: >"$DD_LOG" +if DD_FAIL_PREFIX=1 run_helper cleanup; then + fail 'cleanup must fail when prefix copy fails' +fi +DD_FAIL_PREFIX=0 +grep -F 'failed:' "$DD_LOG" >/dev/null || fail 'cleanup did not execute the failing prefix copy' +[ "$(sha256sum "$HOOK" | awk '{print $1}')" = "$failed_cleanup_hash" ] || \ + fail 'cleanup changed hook after prefix copy failure' +[ "$(file_mode "$HOOK")" = "$failed_cleanup_mode" ] || \ + fail 'cleanup changed hook mode after prefix copy failure' +[ "$(file_owner "$HOOK")" = "$failed_cleanup_owner" ] || \ + fail 'cleanup changed hook ownership after prefix copy failure' + +: >"$CHOWN_LOG" +: >"$REPLACEMENT_LOG" +run_helper cleanup +assert_chown_owner "$original_owner" +assert_owner_before_mode +managed_hook_present "$HOOK" && fail 'cleanup left the managed hook block' +grep -F 'printf user-before' "$HOOK" >/dev/null || fail 'cleanup removed user content' +[ "$(sha256sum "$HOOK" | awk '{print $1}')" = "$original_hash" ] || \ + fail 'hook cleanup did not restore every original byte' +[ "$(file_mode "$HOOK")" = 750 ] || fail 'cleanup changed hook mode' +[ "$(file_owner "$HOOK")" = "$original_owner" ] || fail 'cleanup changed hook ownership' + +: >"$DD_LOG" +if DD_SIGNAL_PARENT=1 run_helper reconcile-hook >/dev/null 2>&1; then + fail 'reconcile-hook must fail when terminated during replacement' +fi +DD_SIGNAL_PARENT=0 +find "$TMP_DIR/openclash/custom" -name '.tailscale-openclash-hook.*' -print | grep . >/dev/null && \ + fail 'terminated hook replacement left a tracked temporary file' +grep -F 'signalled:' "$DD_LOG" >/dev/null || fail 'termination test did not reach hook replacement' + +printf '#!/bin/sh\nprintf user-without-trailing-newline' >"$HOOK" +chmod 740 "$HOOK" +no_newline_hash="$(sha256sum "$HOOK" | awk '{print $1}')" +run_helper reconcile-hook +run_helper cleanup +[ "$(sha256sum "$HOOK" | awk '{print $1}')" = "$no_newline_hash" ] || \ + fail 'cleanup did not restore a hook without a trailing newline' + +for special_mode in 4640 1750 1751 7777; do + printf '#!/bin/sh\nprintf user-special-mode-%s\n' "$special_mode" >"$HOOK" + chmod "$special_mode" "$HOOK" + special_hash="$(sha256sum "$HOOK" | awk '{print $1}')" + special_permissions="$(file_permissions "$HOOK")" + run_helper reconcile-hook + [ "$(file_permissions "$HOOK")" = "$special_permissions" ] || \ + fail "reconcile-hook changed special permission mode $special_mode" + run_helper cleanup + [ "$(sha256sum "$HOOK" | awk '{print $1}')" = "$special_hash" ] || \ + fail "cleanup did not restore bytes for special permission mode $special_mode" + [ "$(file_permissions "$HOOK")" = "$special_permissions" ] || \ + fail "cleanup changed special permission mode $special_mode" +done + +rm -f "$TMP_DIR/openclash-init" "$HOOK" +run_helper reconcile-hook +[ ! -e "$HOOK" ] || fail 'OpenClash-absent reconciliation created a custom script' + +touch "$TMP_DIR/openclash-init" +chmod +x "$TMP_DIR/openclash-init" +run_helper reconcile-hook +[ -x "$HOOK" ] || fail 'missing OpenClash custom script was not created executable' +sh -n "$HOOK" || fail 'created hook is not valid shell' + +absent_cleanup_hash="$(sha256sum "$HOOK" | awk '{print $1}')" +absent_cleanup_mode="$(file_mode "$HOOK")" +absent_cleanup_owner="$(file_owner "$HOOK")" +rm -f "$TMP_DIR/openclash-init" +: >"$CHOWN_LOG" +run_helper cleanup +[ "$(sha256sum "$HOOK" | awk '{print $1}')" = "$absent_cleanup_hash" ] || \ + fail 'cleanup when OpenClash is absent rewrote the managed hook' +[ "$(file_mode "$HOOK")" = "$absent_cleanup_mode" ] || \ + fail 'cleanup when OpenClash is absent changed hook mode' +[ "$(file_owner "$HOOK")" = "$absent_cleanup_owner" ] || \ + fail 'cleanup when OpenClash is absent changed hook ownership' +[ ! -s "$CHOWN_LOG" ] || fail 'cleanup when OpenClash is absent invoked chown' + +touch "$TMP_DIR/openclash-init" +chmod +x "$TMP_DIR/openclash-init" +: >"$NFT_STATE" +: >"$NFT_BATCH_LOG" +: >"$NFT_CALL_LOG" +: >"$UCI_LOG" + +run_helper apply +assert_count 1 'insert rule inet fw4 openclash_mangle_output ' "$NFT_BATCH_LOG" +assert_count 1 'insert rule inet fw4 openclash_output ' "$NFT_BATCH_LOG" +assert_count 1 'insert rule inet fw4 openclash_mangle iifname "tailscale0"' "$NFT_BATCH_LOG" +assert_count 1 'insert rule inet fw4 openclash iifname "tailscale0"' "$NFT_BATCH_LOG" +assert_count 1 '-j list tables' "$NFT_CALL_LOG" +assert_count 1 '-j list table inet fw4' "$NFT_CALL_LOG" +assert_line_count 1 '-j -a list chain inet fw4 openclash_mangle_output' "$NFT_CALL_LOG" +assert_line_count 1 '-j -a list chain inet fw4 openclash_output' "$NFT_CALL_LOG" +assert_line_count 1 '-j -a list chain inet fw4 openclash_mangle' "$NFT_CALL_LOG" +assert_line_count 1 '-j -a list chain inet fw4 openclash' "$NFT_CALL_LOG" +assert_count 1 '-f ' "$NFT_CALL_LOG" +guard_name="$(sed -n 's/^add counter inet fw4 //p' "$NFT_CALL_LOG")" +[ -n "$guard_name" ] || fail 'apply must create a named counter before caching owned handles' +[ "$(sed -n '1p' "$NFT_BATCH_LOG")" = "delete counter inet fw4 $guard_name" ] || \ + fail 'the guard deletion must be the first statement in the reconciliation batch' +[ ! -s "$NFT_GUARD_STATE" ] || fail 'successful reconciliation leaked its table-generation guard' +guard_add_line="$(grep -nFx "add counter inet fw4 $guard_name" "$NFT_CALL_LOG" | cut -d: -f1)" +first_chain_line="$(grep -nF -- '-j -a list chain inet fw4 ' "$NFT_CALL_LOG" | head -n 1 | cut -d: -f1)" +[ "$guard_add_line" -lt "$first_chain_line" ] || fail 'apply cached rule handles before creating its table-generation guard' +grep -F 'counter return comment "luci-app-tailscale: Tailscale 标记流量绕过 OpenClash(mangle output)"' "$NFT_BATCH_LOG" >/dev/null || \ + fail 'mangle output rule comment is not exact' +grep -F 'counter return comment "luci-app-tailscale: Tailscale 标记流量绕过 OpenClash(output)"' "$NFT_BATCH_LOG" >/dev/null || \ + fail 'output rule comment is not exact' +grep -F 'counter return comment "luci-app-tailscale: tailscale0 入站流量绕过 OpenClash(mangle)"' "$NFT_BATCH_LOG" >/dev/null || \ + fail 'mangle ingress rule comment is not exact' +grep -F 'counter return comment "luci-app-tailscale: tailscale0 入站流量绕过 OpenClash(filter)"' "$NFT_BATCH_LOG" >/dev/null || \ + fail 'filter ingress rule comment is not exact' + +run_helper apply +[ "$(grep -c 'insert rule inet fw4' "$NFT_STATE")" = 4 ] || fail 'repeated apply duplicated owned rules' + +hook_before_status="$(sha256sum "$HOOK" | awk '{print $1}')" +state_before_status="$(sha256sum "$NFT_STATE" | awk '{print $1}')" +: >"$NFT_BATCH_LOG" +printf '%s' "$(run_helper status)" | jq -e ' + .state == "active" and .enabled == true and .openclash_present == true and + .firewall4_supported == true and .hook == "managed" and .rules_present == 4 and + .message == "OpenClash bypass is active." +' >/dev/null || fail 'complete managed state did not report active' +[ ! -s "$NFT_BATCH_LOG" ] || fail 'status mutated nftables state' +[ "$(sha256sum "$HOOK" | awk '{print $1}')" = "$hook_before_status" ] || fail 'status rewrote the hook' +[ "$(sha256sum "$NFT_STATE" | awk '{print $1}')" = "$state_before_status" ] || fail 'status rewrote nftables state' + +sed 's#^[[:space:]]*/usr/sbin/tailscale_openclash_bypass apply$#\tprintf stale-status-hook#' "$HOOK" >"$TMP_DIR/stale-status-hook" +chmod 750 "$TMP_DIR/stale-status-hook" +mv "$TMP_DIR/stale-status-hook" "$HOOK" +printf '%s' "$(run_helper status)" | jq -e '.state == "error" and .hook == "error"' >/dev/null || \ + fail 'status accepted a noncanonical managed hook body' +run_helper reconcile-hook + +for invalid_rule_mode in altered-match overbroad incomplete altered-verdict wrong-chain; do + printf '%s' "$(NFT_RULE_JSON_MODE="$invalid_rule_mode" run_helper status)" | \ + jq -e '.state == "error" and .rules_present == 4' >/dev/null || \ + fail "status accepted noncanonical owned rule JSON mode $invalid_rule_mode" +done +NFT_RULE_JSON_MODE=canonical + +printf '%s' "$(NFT_COUNTER_PACKETS=123456 NFT_COUNTER_BYTES=987654321 run_helper status)" | \ + jq -e '.state == "active" and .rules_present == 4' >/dev/null || \ + fail 'dynamic counter values changed canonical rule validation' +NFT_COUNTER_PACKETS=0 +NFT_COUNTER_BYTES=0 + +cp "$NFT_STATE" "$TMP_DIR/nft-before-duplicate-status" +printf '%s\n' '901|insert rule inet fw4 openclash_mangle_output meta mark & 0x00ff0000 == 0x00080000 counter return comment "luci-app-tailscale: Tailscale 标记流量绕过 OpenClash(mangle output)"' >>"$NFT_STATE" +printf '%s' "$(run_helper status)" | jq -e '.state == "error" and .rules_present == 5' >/dev/null || \ + fail 'status accepted a duplicated canonical owned rule' +mv "$TMP_DIR/nft-before-duplicate-status" "$NFT_STATE" + +cp "$NFT_STATE" "$TMP_DIR/nft-before-unexpected-status" +printf '%s\n' '902|insert rule inet fw4 openclash iifname "tailscale0" counter return comment "luci-app-tailscale: unexpected owned rule"' >>"$NFT_STATE" +printf '%s' "$(run_helper status)" | jq -e '.state == "error" and .rules_present == 5' >/dev/null || \ + fail 'status ignored an unexpected package-owned rule' +mv "$TMP_DIR/nft-before-unexpected-status" "$NFT_STATE" + +for malformed_chain_mode in empty wrong-chain-object wrong-rule-chain missing-owned-handle; do + : >"$NFT_BATCH_LOG" + malformed_chain_state_hash="$(sha256sum "$NFT_STATE" | awk '{print $1}')" + malformed_chain_hook_hash="$(sha256sum "$HOOK" | awk '{print $1}')" + if NFT_CHAIN_JSON_TARGET=openclash_output NFT_CHAIN_JSON_MODE="$malformed_chain_mode" run_helper apply; then + fail "apply accepted structurally invalid chain JSON mode $malformed_chain_mode" + fi + [ ! -s "$NFT_BATCH_LOG" ] || fail "invalid chain JSON mode $malformed_chain_mode submitted an apply batch" + [ "$(sha256sum "$NFT_STATE" | awk '{print $1}')" = "$malformed_chain_state_hash" ] || \ + fail "invalid chain JSON mode $malformed_chain_mode changed nft state during apply" + printf '%s' "$(NFT_CHAIN_JSON_TARGET=openclash_output NFT_CHAIN_JSON_MODE="$malformed_chain_mode" run_helper status)" | \ + jq -e '.state == "error"' >/dev/null || \ + fail "status accepted structurally invalid chain JSON mode $malformed_chain_mode" + if NFT_CHAIN_JSON_TARGET=openclash_output NFT_CHAIN_JSON_MODE="$malformed_chain_mode" run_helper cleanup; then + fail "cleanup accepted structurally invalid chain JSON mode $malformed_chain_mode" + fi + [ ! -s "$NFT_BATCH_LOG" ] || fail "invalid chain JSON mode $malformed_chain_mode submitted a cleanup batch" + [ "$(sha256sum "$NFT_STATE" | awk '{print $1}')" = "$malformed_chain_state_hash" ] || \ + fail "invalid chain JSON mode $malformed_chain_mode changed nft state during cleanup" + [ "$(sha256sum "$HOOK" | awk '{print $1}')" = "$malformed_chain_hook_hash" ] || \ + fail "invalid chain JSON mode $malformed_chain_mode removed the retry hook" +done +NFT_CHAIN_JSON_MODE=canonical +NFT_CHAIN_JSON_TARGET= + +awk '!/insert rule inet fw4 openclash_output /' "$NFT_STATE" >"$TMP_DIR/nft-partial-before-missing-chain" +mv "$TMP_DIR/nft-partial-before-missing-chain" "$NFT_STATE" +: >"$NFT_BATCH_LOG" +: >"$NFT_CALL_LOG" +MISSING_CHAIN=openclash_output run_helper apply +[ -s "$NFT_BATCH_LOG" ] || fail 'missing target chain did not remove partial owned rules' +grep -F 'insert rule inet fw4' "$NFT_BATCH_LOG" >/dev/null && \ + fail 'missing target chain attempted to install a partial owned rule set' +grep -F 'luci-app-tailscale:' "$NFT_STATE" >/dev/null && \ + fail 'missing target chain left partial owned rules in surviving chains' +printf '%s' "$(MISSING_CHAIN=openclash_output run_helper status)" | \ + jq -e '.state == "waiting" and .hook == "managed" and .rules_present == 0' >/dev/null || \ + fail 'missing chain did not report a residue-free waiting state' +MISSING_CHAIN= +run_helper apply +[ "$(grep -c 'comment "luci-app-tailscale:' "$NFT_STATE")" = 4 ] || \ + fail 'apply did not restore the complete rule set after the missing chain returned' + +: >"$NFT_DISAPPEARED_FILE" +: >"$NFT_BATCH_LOG" +: >"$NFT_CALL_LOG" +DISAPPEAR_CHAIN=openclash_output run_helper apply +[ -s "$NFT_BATCH_LOG" ] || \ + fail 'apply did not clean surviving owned rules after a target chain disappeared during inspection' +grep -F 'luci-app-tailscale:' "$NFT_STATE" | grep -Fv 'openclash_output ' >/dev/null && \ + fail 'apply left a partial owned rule set after a target chain disappeared during inspection' +printf '%s' "$(DISAPPEAR_CHAIN=openclash_output run_helper status)" | \ + jq -e '.state == "waiting" and .hook == "managed" and .rules_present == 0' >/dev/null || \ + fail 'apply-time chain disappearance did not report a residue-free waiting state' +DISAPPEAR_CHAIN= +: >"$NFT_DISAPPEARED_FILE" +: >"$NFT_STATE" +run_helper apply +[ "$(grep -c 'comment "luci-app-tailscale:' "$NFT_STATE")" = 4 ] || \ + fail 'apply did not restore the complete rule set after an inspected chain returned' + +: >"$NFT_BATCH_LOG" +: >"$NFT_CALL_LOG" +state_before_list_failure="$(sha256sum "$NFT_STATE" | awk '{print $1}')" +if LIST_CHAIN_FAIL=openclash_output run_helper apply; then + fail 'apply must fail when a table-enumerated chain cannot be listed' +fi +LIST_CHAIN_FAIL= +[ ! -s "$NFT_BATCH_LOG" ] || fail 'list-chain precheck failure wrote an nft batch' +assert_count 0 '-f ' "$NFT_CALL_LOG" +[ "$(sha256sum "$NFT_STATE" | awk '{print $1}')" = "$state_before_list_failure" ] || \ + fail 'list-chain precheck failure changed nft state' +printf '%s' "$(LIST_CHAIN_FAIL=openclash_output run_helper status)" | jq -e '.state == "error"' >/dev/null || \ + fail 'table-enumerated list-chain failure did not report error' +LIST_CHAIN_FAIL= + +: >"$NFT_GUARD_STATE" +: >"$NFT_CALL_LOG" +rm -f "$NFT_GUARD_DELETE_FAIL_ONCE_FILE" +if NFT_GUARD_DELETE_FAIL_ONCE=1 LIST_CHAIN_FAIL=openclash_output run_helper apply; then + fail 'apply must fail when a chain read and the first independent guard deletion fail' +fi +[ ! -s "$NFT_GUARD_STATE" ] || fail 'EXIT cleanup did not retry a failed independent guard deletion' +[ "$(grep -c '^delete counter inet fw4 ' "$NFT_CALL_LOG")" -ge 2 ] || \ + fail 'failed independent guard deletion was not retried by EXIT cleanup' +NFT_GUARD_DELETE_FAIL_ONCE=0 +LIST_CHAIN_FAIL= + +: >"$NFT_GUARD_STATE" +: >"$NFT_CALL_LOG" +rm -f "$NFT_GUARD_DELETE_SIGNAL_ONCE_FILE" +if NFT_GUARD_DELETE_SIGNAL_ONCE=1 LIST_CHAIN_FAIL=openclash_output run_helper apply >/dev/null 2>&1; then + fail 'apply must fail when terminated during independent guard deletion' +fi +[ ! -s "$NFT_GUARD_STATE" ] || fail 'termination during independent guard deletion leaked the tracked guard' +[ "$(grep -c '^delete counter inet fw4 ' "$NFT_CALL_LOG")" -ge 2 ] || \ + fail 'termination during independent guard deletion did not trigger EXIT cleanup' +NFT_GUARD_DELETE_SIGNAL_ONCE=0 +LIST_CHAIN_FAIL= + +: >"$NFT_BATCH_LOG" +state_before_failed_batch="$(sha256sum "$NFT_STATE" | awk '{print $1}')" +if NFT_BATCH_FAIL=1 run_helper apply; then + fail 'apply must fail when the nft batch fails' +fi +NFT_BATCH_FAIL=0 +[ ! -s "$NFT_GUARD_STATE" ] || fail 'failed reconciliation leaked its table-generation guard' +[ -s "$NFT_BATCH_LOG" ] || fail 'failed apply did not submit an nft batch' +[ "$(sha256sum "$NFT_STATE" | awk '{print $1}')" = "$state_before_failed_batch" ] || \ + fail 'failed nft batch changed state' +grep -F 'OpenClash nftables reconciliation transaction failed.' "$LOGGER_LOG" >/dev/null || \ + fail 'failed nft batch was not logged' + +: >"$NFT_BATCH_LOG" +: >"$NFT_CALL_LOG" +state_before_mid_batch_failure="$(sha256sum "$NFT_STATE" | awk '{print $1}')" +if NFT_BATCH_FAIL_AFTER=1 run_helper apply; then + fail 'apply must fail when fake nft fails after the first batch operation' +fi +NFT_BATCH_FAIL_AFTER=0 +[ -s "$NFT_BATCH_LOG" ] || fail 'mid-batch failure did not submit an nft batch' +assert_count 1 '-f ' "$NFT_CALL_LOG" +[ "$(sha256sum "$NFT_STATE" | awk '{print $1}')" = "$state_before_mid_batch_failure" ] || \ + fail 'mid-batch fake nft failure changed the primary state' + +if NFT_SIGNAL_PARENT=1 run_helper apply >/dev/null 2>&1; then + fail 'apply must fail when terminated during nft replacement' +fi +NFT_SIGNAL_PARENT=0 +find "$TMP_DIR/tmp" -name 'tailscale-openclash-nft.*' -print | grep . >/dev/null && \ + fail 'terminated nft reconciliation left a tracked temporary path' +[ ! -s "$NFT_GUARD_STATE" ] || fail 'terminated reconciliation leaked its table-generation guard' + +: >"$NFT_BATCH_LOG" +if NFT_GENERATION_REPLACE=1 run_helper apply >/dev/null 2>&1; then + fail 'apply must fail when inet fw4 is replaced after owned handles are cached' +fi +NFT_GENERATION_REPLACE=0 +[ "$(grep -c 'unrelated-generation-rule-' "$NFT_STATE")" = 4 ] || \ + fail 'table replacement transaction deleted unrelated rules that reused cached handles' +[ ! -s "$NFT_GUARD_STATE" ] || fail 'table replacement failure leaked its stale guard state' +run_helper apply +[ "$(grep -c 'comment "luci-app-tailscale:' "$NFT_STATE")" = 4 ] || \ + fail 'apply did not recover after the deterministic table replacement' +[ "$(grep -c 'unrelated-generation-rule-' "$NFT_STATE")" = 4 ] || \ + fail 'recovery after table replacement removed unrelated rules' + +for false_alias in 0 false off no disabled; do + FEATURE_ENABLED=1 run_helper sync + FEATURE_ENABLED="$false_alias" run_helper sync + grep -F 'luci-app-tailscale:' "$NFT_STATE" >/dev/null && \ + fail "false alias $false_alias left owned nft rules" + managed_hook_present "$HOOK" && \ + fail "false alias $false_alias left the managed hook" + printf '%s' "$(FEATURE_ENABLED="$false_alias" run_helper status)" | \ + jq -e '.state == "disabled" and .enabled == false and .hook == "absent" and .rules_present == 0' >/dev/null || \ + fail "false alias $false_alias did not report disabled" +done + +: >"$UCI_LOG" +FEATURE_ENABLED= UCI_SECTION_MODE=present run_helper sync +assert_line_count 1 '-q show tailscale_openclash.settings' "$UCI_LOG" +assert_line_count 0 '-q get tailscale_openclash.settings.enabled' "$UCI_LOG" +printf '%s' "$(FEATURE_ENABLED= UCI_SECTION_MODE=present run_helper status)" | \ + jq -e '.state == "active" and .enabled == true' >/dev/null || \ + fail 'a verified absent enabled option did not use the enabled default' + +for uci_failure in missing error malformed; do + printf '#!/bin/sh\nprintf user-only\n' >"$HOOK" + : >"$NFT_STATE" + : >"$NFT_BATCH_LOG" + uci_failure_hook_hash="$(sha256sum "$HOOK" | awk '{print $1}')" + uci_failure_state_hash="$(sha256sum "$NFT_STATE" | awk '{print $1}')" + if UCI_SECTION_MODE="$uci_failure" FEATURE_ENABLED=1 run_helper sync; then + fail "UCI section state $uci_failure must make sync fail" + fi + [ "$(sha256sum "$HOOK" | awk '{print $1}')" = "$uci_failure_hook_hash" ] || \ + fail "UCI section state $uci_failure changed the managed hook" + [ "$(sha256sum "$NFT_STATE" | awk '{print $1}')" = "$uci_failure_state_hash" ] || \ + fail "UCI section state $uci_failure changed nft state" + [ ! -s "$NFT_BATCH_LOG" ] || fail "UCI section state $uci_failure submitted an nft batch" + printf '%s' "$(UCI_SECTION_MODE="$uci_failure" FEATURE_ENABLED=1 run_helper status)" | \ + jq -e '.state == "error" and .enabled == false' >/dev/null || \ + fail "UCI section state $uci_failure did not report error" +done +UCI_SECTION_MODE=present + +printf '#!/bin/sh\nprintf user-only\n' >"$HOOK" +: >"$NFT_STATE" +: >"$NFT_BATCH_LOG" +uci_get_failure_hook_hash="$(sha256sum "$HOOK" | awk '{print $1}')" +uci_get_failure_state_hash="$(sha256sum "$NFT_STATE" | awk '{print $1}')" +if UCI_OPTION_GET_FAIL=1 FEATURE_ENABLED=1 run_helper sync; then + fail 'an operational failure reading a present enabled option must make sync fail' +fi +[ "$(sha256sum "$HOOK" | awk '{print $1}')" = "$uci_get_failure_hook_hash" ] || \ + fail 'an enabled-option read failure changed the managed hook' +[ "$(sha256sum "$NFT_STATE" | awk '{print $1}')" = "$uci_get_failure_state_hash" ] || \ + fail 'an enabled-option read failure changed nft state' +[ ! -s "$NFT_BATCH_LOG" ] || fail 'an enabled-option read failure submitted an nft batch' +printf '%s' "$(UCI_OPTION_GET_FAIL=1 FEATURE_ENABLED=1 run_helper status)" | \ + jq -e '.state == "error" and .enabled == false' >/dev/null || \ + fail 'an enabled-option read failure did not report error' +UCI_OPTION_GET_FAIL=0 + +printf '#!/bin/sh\nprintf user-only\n' >"$HOOK" +: >"$NFT_STATE" +: >"$NFT_BATCH_LOG" +: >"$NFT_CALL_LOG" +unsupported_hook_hash="$(sha256sum "$HOOK" | awk '{print $1}')" +unsupported_state_hash="$(sha256sum "$NFT_STATE" | awk '{print $1}')" +NFT_TABLE_STATE=TABLE_ABSENT FEATURE_ENABLED=1 run_helper sync +[ "$(sha256sum "$HOOK" | awk '{print $1}')" = "$unsupported_hook_hash" ] || \ + fail 'unsupported firewall4 sync changed the OpenClash hook' +[ "$(sha256sum "$NFT_STATE" | awk '{print $1}')" = "$unsupported_state_hash" ] || \ + fail 'unsupported firewall4 sync changed nft state' +[ ! -s "$NFT_BATCH_LOG" ] || fail 'unsupported firewall4 sync submitted an nft batch' +assert_line_count 1 '-j list tables' "$NFT_CALL_LOG" +assert_line_count 0 '-j list table inet fw4' "$NFT_CALL_LOG" +printf '%s' "$(NFT_TABLE_STATE=TABLE_ABSENT FEATURE_ENABLED=1 run_helper status)" | jq -e '.state == "unsupported"' >/dev/null || \ + fail 'missing firewall4 table did not report unsupported' + +: >"$NFT_BATCH_LOG" +: >"$NFT_CALL_LOG" +NFT_TABLE_STATE=TABLE_ABSENT FEATURE_ENABLED=1 run_helper apply +[ ! -s "$NFT_BATCH_LOG" ] || fail 'verified absent firewall4 apply submitted an nft batch' +assert_line_count 1 '-j list tables' "$NFT_CALL_LOG" +assert_line_count 0 '-j list table inet fw4' "$NFT_CALL_LOG" + +for table_failure in INVENTORY_READ_ERROR INVENTORY_MALFORMED TABLE_READ_ERROR TABLE_MALFORMED; do + printf '#!/bin/sh\nprintf user-only\n' >"$HOOK" + : >"$NFT_STATE" + for helper_command in sync apply cleanup; do + : >"$NFT_BATCH_LOG" + table_failure_hook_hash="$(sha256sum "$HOOK" | awk '{print $1}')" + table_failure_state_hash="$(sha256sum "$NFT_STATE" | awk '{print $1}')" + if NFT_TABLE_STATE="$table_failure" FEATURE_ENABLED=1 run_helper "$helper_command"; then + fail "$helper_command must fail for nft table state $table_failure" + fi + [ "$(sha256sum "$HOOK" | awk '{print $1}')" = "$table_failure_hook_hash" ] || \ + fail "$helper_command changed the hook for nft table state $table_failure" + [ "$(sha256sum "$NFT_STATE" | awk '{print $1}')" = "$table_failure_state_hash" ] || \ + fail "$helper_command changed nft state for nft table state $table_failure" + [ ! -s "$NFT_BATCH_LOG" ] || \ + fail "$helper_command submitted an nft batch for nft table state $table_failure" + done + printf '%s' "$(NFT_TABLE_STATE="$table_failure" FEATURE_ENABLED=1 run_helper status)" | \ + jq -e '.state == "error"' >/dev/null || \ + fail "nft table state $table_failure did not report error" +done +NFT_TABLE_STATE=PRESENT + +rm -f "$TMP_DIR/openclash-init" +absent_hook_hash="$(sha256sum "$HOOK" | awk '{print $1}')" +FEATURE_ENABLED=1 run_helper sync +[ "$(sha256sum "$HOOK" | awk '{print $1}')" = "$absent_hook_hash" ] || \ + fail 'OpenClash-absent sync changed the hook' +touch "$TMP_DIR/openclash-init" +chmod +x "$TMP_DIR/openclash-init" + +: >"$NFT_BATCH_LOG" +MISSING_CHAIN=openclash_output FEATURE_ENABLED=1 run_helper sync +grep -Fx '# BEGIN luci-app-tailscale 托管:Tailscale 绕过 OpenClash' "$HOOK" >/dev/null || \ + fail 'waiting sync did not install the canonical hook' +grep -Fx ' /usr/sbin/tailscale_openclash_bypass apply' "$HOOK" >/dev/null || \ + fail 'waiting sync did not install the canonical managed command' +[ ! -s "$NFT_BATCH_LOG" ] || fail 'waiting sync submitted a partial nft batch' +printf '%s' "$(MISSING_CHAIN=openclash_output FEATURE_ENABLED=1 run_helper status)" | \ + jq -e '.state == "waiting" and .hook == "managed" and .rules_present == 0' >/dev/null || \ + fail 'waiting sync did not report a canonical managed hook' +MISSING_CHAIN= + +FEATURE_ENABLED=true run_helper sync +printf '%s' "$(FEATURE_ENABLED=true run_helper status)" | jq -e '.state == "active" and .rules_present == 4' >/dev/null || \ + fail 'sync did not produce active state for a true alias' + +FEATURE_ENABLED=0 run_helper sync +: >"$UCI_LOG" +sync_ready="$TMP_DIR/sync-ready" +sync_release="$TMP_DIR/sync-release" +rm -f "$sync_ready" "$sync_release" +UCI_READY_FILE="$sync_ready" UCI_RELEASE_FILE="$sync_release" FEATURE_ENABLED=1 run_helper sync & +SYNC_ENABLE_PID=$! +attempt=0 +while [ ! -e "$sync_ready" ]; do + attempt=$((attempt + 1)) + [ "$attempt" -lt 100 ] || fail 'enabled sync did not reach the locked UCI read' + sleep 0.05 +done +FEATURE_ENABLED=0 run_helper sync & +SYNC_DISABLE_PID=$! +sleep 0.2 +[ "$(grep -c '^-q get tailscale_openclash.settings.enabled$' "$UCI_LOG")" = 1 ] || \ + fail 'concurrent disabled sync passed the real flock before enabled sync completed' +: >"$sync_release" +wait "$SYNC_ENABLE_PID" || fail 'locked enabled sync failed' +SYNC_ENABLE_PID= +wait "$SYNC_DISABLE_PID" || fail 'serialized disabled sync failed' +SYNC_DISABLE_PID= +[ "$(grep -c '^-q get tailscale_openclash.settings.enabled$' "$UCI_LOG")" = 2 ] || \ + fail 'both serialized sync commands must re-read UCI under the lock' +grep -F 'luci-app-tailscale:' "$NFT_STATE" >/dev/null && \ + fail 'serialized disable left owned nft rules' +managed_hook_present "$HOOK" && \ + fail 'serialized disable left the managed hook' +FEATURE_ENABLED= + +: >"$NFT_BATCH_LOG" +: >"$NFT_CALL_LOG" +run_helper apply +: >"$NFT_BATCH_LOG" +: >"$NFT_CALL_LOG" +if LIST_CHAIN_FAIL=openclash_output run_helper cleanup; then + fail 'cleanup must fail when a table-enumerated chain cannot be listed' +fi +LIST_CHAIN_FAIL= +[ ! -s "$NFT_BATCH_LOG" ] || fail 'cleanup list-chain failure wrote an nft batch' +assert_count 0 '-f ' "$NFT_CALL_LOG" +grep -F 'luci-app-tailscale:' "$NFT_STATE" >/dev/null || fail 'list-chain cleanup failure changed owned nft rules' +run_helper reconcile-hook +if NFT_BATCH_FAIL=1 run_helper cleanup; then + fail 'cleanup must fail when the nft delete batch fails' +fi +NFT_BATCH_FAIL=0 +grep -F 'luci-app-tailscale:' "$NFT_STATE" >/dev/null || fail 'failed cleanup batch changed owned nft rules' +managed_hook_present "$HOOK" || fail 'failed nft cleanup must retain the managed hook for retry' +printf '%s' "$(FEATURE_ENABLED=0 run_helper status)" | jq -e ' + .state == "error" and .enabled == false and .hook == "managed" and + .rules_present == 4 +' >/dev/null || fail 'disabled status did not expose retained cleanup artifacts' + +FEATURE_ENABLED=0 run_helper apply +grep -F 'luci-app-tailscale:' "$NFT_STATE" >/dev/null && fail 'cleanup retry left owned nft rules' +managed_hook_present "$HOOK" && fail 'cleanup retry left the managed hook' +printf '%s' "$(FEATURE_ENABLED=0 run_helper status)" | jq -e ' + .state == "disabled" and .enabled == false and .hook == "absent" and + .rules_present == 0 +' >/dev/null || fail 'successful cleanup retry did not report a residue-free disabled state' + +FEATURE_ENABLED=1 run_helper sync +printf '%s\n' '900|insert rule inet fw4 openclash iifname "tailscale0" counter return comment "user-owned-rule"' >>"$NFT_STATE" +run_helper cleanup +grep -F 'luci-app-tailscale:' "$NFT_STATE" >/dev/null && fail 'cleanup left owned nft rules' +grep -F 'user-owned-rule' "$NFT_STATE" >/dev/null || fail 'cleanup removed a user-owned rule' +managed_hook_present "$HOOK" && fail 'cleanup left the managed hook block' + +FEATURE_ENABLED=1 run_helper sync +: >"$NFT_BATCH_LOG" +NFT_TABLE_STATE=TABLE_ABSENT run_helper cleanup +NFT_TABLE_STATE=PRESENT +[ ! -s "$NFT_BATCH_LOG" ] || fail 'cleanup with an absent fw4 table submitted an nft batch' +: >"$NFT_STATE" + +FEATURE_ENABLED=1 run_helper sync +awk '!/insert rule inet fw4 openclash_output /' "$NFT_STATE" >"$TMP_DIR/nft-with-missing-chain" +mv "$TMP_DIR/nft-with-missing-chain" "$NFT_STATE" +MISSING_CHAIN=openclash_output run_helper cleanup +MISSING_CHAIN= +grep -F 'luci-app-tailscale:' "$NFT_STATE" >/dev/null && \ + fail 'cleanup with an already absent target chain left rules from existing chains' + +FEATURE_ENABLED=1 run_helper sync +: >"$NFT_DISAPPEARED_FILE" +: >"$NFT_CALL_LOG" +DISAPPEAR_CHAIN=openclash_output run_helper cleanup +DISAPPEAR_CHAIN= +[ "$(grep -c '^-j list table inet fw4$' "$NFT_CALL_LOG")" -ge 2 ] || \ + fail 'cleanup did not refresh table state after a chain disappeared' +: >"$NFT_STATE" +: >"$NFT_DISAPPEARED_FILE" + +printf '%s' "$(run_helper status)" | jq -e '.state == "error"' >/dev/null || \ + fail 'incomplete managed state did not report error' + +rm -f "$HOOK" +printf '%s' "$(MISSING_CHAIN=openclash_output run_helper status)" | jq -e '.state == "waiting" and .hook == "absent"' >/dev/null || \ + fail 'missing hook and target chain did not report waiting' +MISSING_CHAIN= +mkdir "$HOOK" +printf '%s' "$(run_helper status)" | jq -e '.state == "error" and .hook == "error"' >/dev/null || \ + fail 'hook read failure did not report error' +rmdir "$HOOK" + +printf '%s\n' '# BEGIN luci-app-tailscale 托管:Tailscale 绕过 OpenClash' >>"$HOOK" +printf '%s' "$(run_helper status)" | jq -e '.state == "error"' >/dev/null || \ + fail 'malformed markers did not report error' +printf '%s' "$(NFT_TABLE_STATE=TABLE_ABSENT FEATURE_ENABLED=0 run_helper status)" | \ + jq -e '.state == "error" and .enabled == false and .hook == "error"' >/dev/null || \ + fail 'verified absent firewall4 table hid a malformed disabled hook' +printf '%s' "$(NFT_TABLE_STATE=TABLE_ABSENT UCI_SECTION_MODE=error FEATURE_ENABLED=1 run_helper status)" | \ + jq -e '.state == "error" and .enabled == false' >/dev/null || \ + fail 'verified absent firewall4 table hid an operational UCI failure' +printf '%s' "$(NFT_JSON_UNSUPPORTED=1 FEATURE_ENABLED=0 run_helper status)" | jq -e '.state == "error"' >/dev/null || \ + fail 'malformed nft JSON did not report error' +rm -f "$HOOK" +printf '%s' "$(NFT_TABLE_STATE=TABLE_ABSENT FEATURE_ENABLED=0 run_helper status)" | \ + jq -e '.state == "disabled" and .enabled == false and .firewall4_supported == false and .hook == "absent" and .rules_present == 0' >/dev/null || \ + fail 'disabled state with no fw4 table and no hook did not report cleanly disabled' +run_helper reconcile-hook +printf '%s' "$(NFT_TABLE_STATE=TABLE_ABSENT FEATURE_ENABLED=0 run_helper status)" | \ + jq -e '.state == "error" and .enabled == false and .firewall4_supported == false and .hook == "managed" and .rules_present == 0' >/dev/null || \ + fail 'disabled state with no fw4 table hid a retained managed hook' +NFT_TABLE_STATE=TABLE_ABSENT run_helper cleanup +rm -f "$TMP_DIR/openclash-init" +printf '%s' "$(NFT_TABLE_STATE=TABLE_ABSENT run_helper status)" | jq -e '.state == "absent"' >/dev/null || \ + fail 'OpenClash absence did not take status precedence' +grep -F 'firewall' "$UCI_LOG" >/dev/null && fail 'helper queried the firewall UCI package' + +printf '%s\n' 'tailscale OpenClash bypass tests passed' diff --git a/luci-app-tailscale/tests/tailscale_openclash_install_test.sh b/luci-app-tailscale/tests/tailscale_openclash_install_test.sh new file mode 100644 index 000000000..67450ff29 --- /dev/null +++ b/luci-app-tailscale/tests/tailscale_openclash_install_test.sh @@ -0,0 +1,348 @@ +#!/bin/sh + +set -eu + +ROOT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)" +UCI_DEFAULTS="$ROOT_DIR/root/etc/uci-defaults/40_luci-tailscale" +TMP_DIR="${TMPDIR:-/tmp}/tailscale-openclash-install-test.$$" +CONFIG_DIR="$TMP_DIR/etc/config" +UPGRADE_STATE_DIR="$TMP_DIR/luci-app-tailscale-upgrade" +LEGACY_TAILSCALE_HOTPLUG="$TMP_DIR/40-tailscale" +LEGACY_TAILSCALE_HOTPLUG_BACKUP_DIR="$TMP_DIR/legacy-backup" + +cleanup() { + rm -rf "$TMP_DIR" +} +trap cleanup EXIT + +fail() { + echo "FAIL: $*" >&2 + exit 1 +} + +[ -f "$UCI_DEFAULTS" ] || fail "missing package UCI defaults script" +mkdir -p "$TMP_DIR/bin" "$CONFIG_DIR" + +cat >"$TMP_DIR/tailscale_secrets" <<'SH' +#!/bin/sh +printf 'secrets %s\n' "$*" >>"${LIFECYCLE_LOG:?}" +SH +chmod +x "$TMP_DIR/tailscale_secrets" + +cat >"$TMP_DIR/tailscale-openclash-bypass" <<'SH' +#!/bin/sh +printf 'bypass %s\n' "$*" >>"${LIFECYCLE_LOG:?}" +SH +chmod +x "$TMP_DIR/tailscale-openclash-bypass" + +cat >"$TMP_DIR/tailscale-policy-routing" <<'SH' +#!/bin/sh +printf 'policy %s\n' "$*" >>"${LIFECYCLE_LOG:?}" +SH +chmod +x "$TMP_DIR/tailscale-policy-routing" + +cat >"$TMP_DIR/bin/uci" <<'SH' +#!/bin/sh +case "$*" in + '-q get tailscale.settings.enabled') + printf '%s\n' "${TAILSCALE_ENABLED:-0}" + exit 0 + ;; + '-q get tailscale_openclash.settings.enabled') + printf '%s\n' "${OPENCLASH_BYPASS_ENABLED:-1}" + exit 0 + ;; +esac +printf 'uci %s\n' "$*" >>"${LIFECYCLE_LOG:?}" +cat >/dev/null +SH +chmod +x "$TMP_DIR/bin/uci" + +cat >"$TMP_DIR/tailscale" <<'SH' +#!/bin/sh +printf 'tailscale %s\n' "$*" >>"${LIFECYCLE_LOG:?}" +SH +chmod +x "$TMP_DIR/tailscale" + +run_defaults() { + mode="$1" + : >"$TMP_DIR/lifecycle.log" + if [ "$mode" = "without-bypass" ]; then + rm -f "$TMP_DIR/tailscale-openclash-bypass" + fi + # Run an isolated copy so the package script's production absolute paths use + # the test fixtures without adding test-only hooks to the package itself. + sed \ + -e "s#/usr/sbin/tailscale_secrets#$TMP_DIR/tailscale_secrets#g" \ + -e "s#/etc/init.d/tailscale-openclash-bypass#$TMP_DIR/tailscale-openclash-bypass#g" \ + -e "s#/etc/init.d/tailscale-policy-routing#$TMP_DIR/tailscale-policy-routing#g" \ + -e "s#/etc/config/tailscale#$CONFIG_DIR/tailscale#g" \ + -e "s#/etc/.luci-app-tailscale-upgrade#$UPGRADE_STATE_DIR#g" \ + "$UCI_DEFAULTS" >"$TMP_DIR/uci-defaults" + chmod +x "$TMP_DIR/uci-defaults" + + PATH="$TMP_DIR/bin:$PATH" \ + LIFECYCLE_LOG="$TMP_DIR/lifecycle.log" \ + sh "$TMP_DIR/uci-defaults" + + cat "$TMP_DIR/lifecycle.log" +} + +enabled_log="$(run_defaults enabled)" +expected_enabled='secrets migrate +bypass enable +bypass start +policy enable +policy start' +[ "$enabled_log" = "$expected_enabled" ] || fail "package defaults must migrate credentials, then enable and start managed helpers\nactual:\n$enabled_log" +cat >"$TMP_DIR/tailscale-openclash-bypass" <<'SH' +#!/bin/sh +printf 'bypass %s\n' "$*" >>"${LIFECYCLE_LOG:?}" +SH +chmod +x "$TMP_DIR/tailscale-openclash-bypass" + +absent_log="$(run_defaults without-bypass)" +expected_absent='secrets migrate +policy enable +policy start' +[ "$absent_log" = "$expected_absent" ] || fail "missing bypass init script must not prevent policy-routing initialization\nactual:\n$absent_log" + +cat >"$TMP_DIR/tailscale-openclash-bypass" <<'SH' +#!/bin/sh +printf 'bypass %s\n' "$*" >>"${LIFECYCLE_LOG:?}" +SH +chmod +x "$TMP_DIR/tailscale-openclash-bypass" + +run_preinst() { + awk ' + /^define Package\/luci-app-tailscale\/preinst$/ { in_preinst = 1; next } + in_preinst && /^endef$/ { exit } + in_preinst { print } + ' "$ROOT_DIR/Makefile" \ + | sed \ + -e 's/\$\${/${/g' \ + -e "s#/etc/hotplug.d/iface/40-tailscale#$LEGACY_TAILSCALE_HOTPLUG#g" \ + -e "s#/etc/tailscale/luci-app-tailscale-legacy#$LEGACY_TAILSCALE_HOTPLUG_BACKUP_DIR#g" \ + -e "s#/etc/config/tailscale#$CONFIG_DIR/tailscale#g" \ + -e "s#/etc/.luci-app-tailscale-upgrade#$UPGRADE_STATE_DIR#g" \ + >"$TMP_DIR/preinst" + [ -s "$TMP_DIR/preinst" ] || fail "missing package pre-install configuration snapshot" + chmod +x "$TMP_DIR/preinst" + PATH="${PREINST_PATH:-$PATH}" IPKG_INSTROOT='' sh "$TMP_DIR/preinst" +} + +cat >"$LEGACY_TAILSCALE_HOTPLUG" <<'EOF' +#!/bin/sh +/etc/init.d/tailscale start & +EOF +cp "$LEGACY_TAILSCALE_HOTPLUG" "$TMP_DIR/40-tailscale.before" + +cat >"$CONFIG_DIR/tailscale" <<'EOF' +config tailscale 'settings' + option enabled '1' + option hostname 'upgrade-preserved' +EOF +cat >"$CONFIG_DIR/tailscale_openclash" <<'EOF' +config openclash 'settings' + option enabled '1' +EOF +cat >"$CONFIG_DIR/tailscale_policy_routing" <<'EOF' +config settings 'settings' + option enabled '1' +EOF +cp "$CONFIG_DIR/tailscale" "$TMP_DIR/tailscale-before-upgrade" +cp "$CONFIG_DIR/tailscale_openclash" "$TMP_DIR/tailscale-openclash-before-upgrade" +cp "$CONFIG_DIR/tailscale_policy_routing" "$TMP_DIR/tailscale-policy-routing-before-upgrade" + +run_preinst +[ ! -e "$LEGACY_TAILSCALE_HOTPLUG" ] || fail "pre-install hook must disable the legacy Tailscale interface hotplug starter before extraction" +legacy_hotplug_backup="$(find "$LEGACY_TAILSCALE_HOTPLUG_BACKUP_DIR" -type f -name '40-tailscale.disabled.*' -print)" +[ -n "$legacy_hotplug_backup" ] || fail "pre-install hook must preserve a backup of the disabled legacy Tailscale hotplug starter" +cmp -s "$TMP_DIR/40-tailscale.before" "$legacy_hotplug_backup" || fail "legacy Tailscale hotplug backup must preserve the original script" +[ -f "$UPGRADE_STATE_DIR/tailscale" ] || fail "pre-install hook must snapshot the existing Tailscale UCI config" +[ -f "$UPGRADE_STATE_DIR/tailscale_openclash" ] || fail "pre-install hook must snapshot the existing OpenClash bypass UCI config" +[ -f "$UPGRADE_STATE_DIR/tailscale_policy_routing" ] || fail "pre-install hook must snapshot the existing policy-routing UCI config" +[ -f "$UPGRADE_STATE_DIR/.complete" ] || fail "pre-install hook must only publish a completed configuration snapshot" + +# Backup failure must not leave the duplicate hotplug starter executable. +rm -rf "$LEGACY_TAILSCALE_HOTPLUG_BACKUP_DIR" +printf 'not a directory\n' >"$LEGACY_TAILSCALE_HOTPLUG_BACKUP_DIR" +cp "$TMP_DIR/40-tailscale.before" "$LEGACY_TAILSCALE_HOTPLUG" +run_preinst +[ ! -e "$LEGACY_TAILSCALE_HOTPLUG" ] || fail "pre-install hook must disable the legacy starter even when its backup cannot be created" +rm -f "$LEGACY_TAILSCALE_HOTPLUG_BACKUP_DIR" + +# If removing the obsolete path itself fails transiently, preinst must make the +# script non-executable before package extraction continues. +mkdir -p "$TMP_DIR/fail-bin" "$LEGACY_TAILSCALE_HOTPLUG_BACKUP_DIR" +cat >"$TMP_DIR/fail-bin/rm" <"$CONFIG_DIR/tailscale" <<'EOF' +config tailscale 'settings' + option enabled '0' +EOF +cat >"$CONFIG_DIR/tailscale_openclash" <<'EOF' +config openclash 'settings' + option enabled '0' +EOF +cat >"$CONFIG_DIR/tailscale_policy_routing" <<'EOF' +config settings 'settings' + option enabled '0' +EOF + +# A failed package install can be retried after the new data files have already +# replaced the old config. The second pre-install hook must retain the first +# snapshot instead of replacing it with those defaults. +run_preinst +cmp -s "$TMP_DIR/tailscale-before-upgrade" "$UPGRADE_STATE_DIR/tailscale" || \ + fail "retrying an interrupted upgrade must retain the first Tailscale configuration snapshot" +cmp -s "$TMP_DIR/tailscale-openclash-before-upgrade" "$UPGRADE_STATE_DIR/tailscale_openclash" || \ + fail "retrying an interrupted upgrade must retain the first OpenClash bypass configuration snapshot" +cmp -s "$TMP_DIR/tailscale-policy-routing-before-upgrade" "$UPGRADE_STATE_DIR/tailscale_policy_routing" || \ + fail "retrying an interrupted upgrade must retain the first policy-routing configuration snapshot" + +upgrade_defaults_log="$(run_defaults enabled)" +[ "$upgrade_defaults_log" = "$expected_enabled" ] || fail "upgrade defaults must restore configuration before reconciling the bypass\nactual:\n$upgrade_defaults_log" +cmp -s "$TMP_DIR/tailscale-before-upgrade" "$CONFIG_DIR/tailscale" || \ + fail "upgrade must preserve the existing Tailscale UCI configuration" +cmp -s "$TMP_DIR/tailscale-openclash-before-upgrade" "$CONFIG_DIR/tailscale_openclash" || \ + fail "upgrade must preserve the existing OpenClash bypass UCI configuration" +cmp -s "$TMP_DIR/tailscale-policy-routing-before-upgrade" "$CONFIG_DIR/tailscale_policy_routing" || \ + fail "upgrade must preserve the existing policy-routing UCI configuration" +[ ! -e "$UPGRADE_STATE_DIR/tailscale" ] || fail "successful configuration restore must remove the Tailscale snapshot" +[ ! -e "$UPGRADE_STATE_DIR/tailscale_openclash" ] || fail "successful configuration restore must remove the OpenClash bypass snapshot" +[ ! -e "$UPGRADE_STATE_DIR/tailscale_policy_routing" ] || fail "successful configuration restore must remove the policy-routing snapshot" +[ ! -d "$UPGRADE_STATE_DIR" ] || fail "successful configuration restore must remove the private snapshot directory" + +: >"$TMP_DIR/lifecycle.log" +LIFECYCLE_LOG="$TMP_DIR/lifecycle.log" "$TMP_DIR/tailscale-openclash-bypass" start +LIFECYCLE_LOG="$TMP_DIR/lifecycle.log" "$TMP_DIR/tailscale" start +upgrade_restart_log="$(cat "$TMP_DIR/lifecycle.log")" +expected_restart_log='bypass start +tailscale start' +[ "$upgrade_restart_log" = "$expected_restart_log" ] || fail "standard package restart must bring the bypass and enabled Tailscale service back after upgrade\nactual:\n$upgrade_restart_log" + +disabled_bypass_log="$(OPENCLASH_BYPASS_ENABLED=0 run_defaults enabled)" +[ "$disabled_bypass_log" = "$expected_absent" ] || fail "package lifecycle must not re-enable a bypass the user disabled\nactual:\n$disabled_bypass_log" + +# Model a pre-install interruption after a partial state directory was written. +# The next attempt must discard that partial data and publish a complete snapshot. +mkdir -p "$UPGRADE_STATE_DIR" +printf 'partial configuration\n' >"$UPGRADE_STATE_DIR/tailscale" +run_preinst +cmp -s "$TMP_DIR/tailscale-before-upgrade" "$UPGRADE_STATE_DIR/tailscale" || \ + fail "retrying after an interrupted snapshot must replace partial Tailscale data" +cmp -s "$TMP_DIR/tailscale-openclash-before-upgrade" "$UPGRADE_STATE_DIR/tailscale_openclash" || \ + fail "retrying after an interrupted snapshot must replace partial OpenClash data" +cmp -s "$TMP_DIR/tailscale-policy-routing-before-upgrade" "$UPGRADE_STATE_DIR/tailscale_policy_routing" || \ + fail "retrying after an interrupted snapshot must replace partial policy-routing data" +[ -f "$UPGRADE_STATE_DIR/.complete" ] || fail "retrying after an interrupted snapshot must publish a completion marker" +run_defaults enabled >/dev/null +[ ! -d "$UPGRADE_STATE_DIR" ] || fail "restoring a retried snapshot must remove the private state directory" + +run_prerm() { + mode="$1" + style="${2:-ipk}" + awk ' + /^define Package\/luci-app-tailscale\/prerm$/ { in_prerm = 1; next } + in_prerm && /^endef$/ { exit } + in_prerm { print } + ' "$ROOT_DIR/Makefile" \ + | sed \ + -e 's/\$\${/${/g' \ + -e "s#/usr/sbin/tailscale_openclash_bypass#$TMP_DIR/tailscale_openclash_bypass#g" \ + -e "s#/etc/init.d/tailscale-openclash-bypass#$TMP_DIR/tailscale-openclash-bypass#g" \ + -e "s#/usr/sbin/tailscale_policy_routing#$TMP_DIR/tailscale_policy_routing_helper#g" \ + -e "s#/etc/init.d/tailscale-policy-routing#$TMP_DIR/tailscale-policy-routing#g" \ + -e "s#/etc/init.d/tailscale#$TMP_DIR/tailscale#g" \ + -e "s#/etc/config/tailscale#$CONFIG_DIR/tailscale#g" \ + -e "s#/etc/.luci-app-tailscale-upgrade#$UPGRADE_STATE_DIR#g" \ + >"$TMP_DIR/prerm" + chmod +x "$TMP_DIR/prerm" + + cat >"$TMP_DIR/tailscale_openclash_bypass" <<'SH' +#!/bin/sh +printf 'helper %s\n' "$*" >>"${LIFECYCLE_LOG:?}" +SH + chmod +x "$TMP_DIR/tailscale_openclash_bypass" + + cat >"$TMP_DIR/tailscale-openclash-bypass" <<'SH' +#!/bin/sh +printf 'bypass %s\n' "$*" >>"${LIFECYCLE_LOG:?}" +SH + chmod +x "$TMP_DIR/tailscale-openclash-bypass" + + cat >"$TMP_DIR/tailscale_policy_routing_helper" <<'SH' +#!/bin/sh +printf 'policy-helper %s\n' "$*" >>"${LIFECYCLE_LOG:?}" +[ "${POLICY_CLEANUP_FAIL:-0}" = 1 ] && [ "${1:-}" = cleanup ] && exit 1 +exit 0 +SH + chmod +x "$TMP_DIR/tailscale_policy_routing_helper" + + cat >"$TMP_DIR/tailscale-policy-routing" <<'SH' +#!/bin/sh +printf 'policy %s\n' "$*" >>"${LIFECYCLE_LOG:?}" +SH + chmod +x "$TMP_DIR/tailscale-policy-routing" + + : >"$TMP_DIR/lifecycle.log" + if [ "$style" = 'apk' ]; then + IPKG_INSTROOT='' LIFECYCLE_LOG="$TMP_DIR/lifecycle.log" POLICY_CLEANUP_FAIL="${POLICY_CLEANUP_FAIL:-0}" sh "$TMP_DIR/prerm" "$mode" || return 1 + else + IPKG_INSTROOT='' LIFECYCLE_LOG="$TMP_DIR/lifecycle.log" POLICY_CLEANUP_FAIL="${POLICY_CLEANUP_FAIL:-0}" sh "$TMP_DIR/prerm" "$TMP_DIR/prerm-wrapper" "$mode" || return 1 + fi + cat "$TMP_DIR/lifecycle.log" +} + +upgrade_prerm_log="$(run_prerm upgrade)" +[ -z "$upgrade_prerm_log" ] || fail "package upgrade must preserve helper rules, bypass startup, and Tailscale\nactual:\n$upgrade_prerm_log" +[ ! -d "$UPGRADE_STATE_DIR" ] || fail "package upgrade must not leave removal state or replace the pre-install snapshot" + +mkdir -p "$UPGRADE_STATE_DIR" "$UPGRADE_STATE_DIR.pending" +: >"$UPGRADE_STATE_DIR/tailscale" +: >"$UPGRADE_STATE_DIR/tailscale_openclash" +: >"$UPGRADE_STATE_DIR/tailscale_openclash.absent" +: >"$UPGRADE_STATE_DIR/tailscale_policy_routing" +: >"$UPGRADE_STATE_DIR/.complete" +: >"$UPGRADE_STATE_DIR.pending/tailscale" +: >"$UPGRADE_STATE_DIR.pending/tailscale_openclash" +: >"$UPGRADE_STATE_DIR.pending/tailscale_openclash.absent" +: >"$UPGRADE_STATE_DIR.pending/tailscale_policy_routing" +: >"$UPGRADE_STATE_DIR.pending/.complete" + +prerm_log="$(run_prerm remove)" +expected_prerm='policy-helper cleanup +policy disable +helper cleanup +bypass disable +tailscale stop' +[ "$prerm_log" = "$expected_prerm" ] || fail "package removal must clean helper rules, disable bypass boot startup, then stop Tailscale\nactual:\n$prerm_log" +[ ! -d "$UPGRADE_STATE_DIR" ] || fail "package removal must remove completed upgrade state" +[ ! -d "$UPGRADE_STATE_DIR.pending" ] || fail "package removal must remove pending upgrade state" + +apk_prerm_log="$(run_prerm 1.2.10 apk)" +[ "$apk_prerm_log" = "$expected_prerm" ] || fail "OpenWrt APK pre-deinstall must perform package removal cleanup\nactual:\n$apk_prerm_log" + +if POLICY_CLEANUP_FAIL=1 run_prerm remove >/dev/null; then + fail 'package removal must stop when policy-routing cleanup fails' +fi +[ "$(cat "$TMP_DIR/lifecycle.log")" = 'policy-helper cleanup' ] || \ + fail 'failed policy-routing cleanup must not disable services or stop Tailscale' + +echo "tailscale OpenClash install lifecycle tests passed" diff --git a/luci-app-tailscale/tests/tailscale_openclash_lifecycle_test.sh b/luci-app-tailscale/tests/tailscale_openclash_lifecycle_test.sh new file mode 100755 index 000000000..db7e12d7e --- /dev/null +++ b/luci-app-tailscale/tests/tailscale_openclash_lifecycle_test.sh @@ -0,0 +1,85 @@ +#!/bin/sh + +set -eu + +ROOT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)" +INIT_SCRIPT="$ROOT_DIR/root/etc/init.d/tailscale-openclash-bypass" +UCI_CONFIG="$ROOT_DIR/root/etc/config/tailscale_openclash" +TMP_DIR="${TMPDIR:-/tmp}/tailscale-openclash-lifecycle-test.$$" + +cleanup() { + rm -rf "$TMP_DIR" +} +trap cleanup EXIT + +fail() { + echo "FAIL: $*" >&2 + exit 1 +} + +[ -f "$INIT_SCRIPT" ] || fail "missing OpenClash bypass init script" +[ -f "$UCI_CONFIG" ] || fail "missing OpenClash bypass UCI config" +grep -qx "config openclash 'settings'" "$UCI_CONFIG" || \ + fail 'OpenClash bypass UCI config must use the openclash section type' + +mkdir -p "$TMP_DIR" + +cat >"$TMP_DIR/fake-helper" <<'SH' +#!/bin/sh +printf '%s\n' "$*" >>"${FAKE_HELPER_LOG:?}" +SH +chmod +x "$TMP_DIR/fake-helper" + +run_service() { + service_entrypoint="$1" + script_path="${2:-$INIT_SCRIPT}" + FAKE_HELPER_LOG="$TMP_DIR/helper.log" + : >"$FAKE_HELPER_LOG" + export FAKE_HELPER_LOG + + rc=0 + ( + . "$script_path" + PROG="$TMP_DIR/fake-helper" + + config_load() { + fail 'procd lifecycle must leave UCI reads to the locked helper sync command' + } + + config_foreach() { + fail 'procd lifecycle must not split helper sync by UCI section' + } + + config_get_bool() { + fail 'procd lifecycle must not decide enabled state outside the helper lock' + } + + "$service_entrypoint" + ) || rc=$? + [ "$rc" -eq 0 ] || return "$rc" + + cat "$FAKE_HELPER_LOG" +} + +enabled_start_log="$(run_service start_service)" +[ "$enabled_start_log" = "sync" ] || fail "start must reconcile under one helper lock" + +enabled_reload_log="$(run_service reload_service)" +[ "$enabled_reload_log" = "sync" ] || fail "reload must reconcile under one helper lock" + +stop_log="$(run_service stop_service)" +[ "$stop_log" = "cleanup" ] || fail "stop must remove only owned state" + +[ "$(grep -Fc '"$PROG" sync' "$INIT_SCRIPT")" -eq 1 ] || \ + fail 'procd lifecycle must contain exactly one helper sync invocation' +grep -E 'config_(load|foreach|get_bool)' "$INIT_SCRIPT" >/dev/null && \ + fail 'OpenClash lifecycle must not read UCI outside the helper lock' + +grep -F 'procd_add_reload_trigger "tailscale_openclash"' "$INIT_SCRIPT" >/dev/null || \ + fail 'OpenClash lifecycle needs its own UCI reload trigger' +grep -F 'procd_add_reload_trigger "tailscale"' "$INIT_SCRIPT" >/dev/null && \ + fail 'OpenClash lifecycle must not subscribe to the core Tailscale UCI package' +grep -E '/etc/init.d/(tailscale|firewall|openclash)|fw4 (reload|restart)' "$INIT_SCRIPT" >/dev/null && \ + fail 'OpenClash lifecycle must not manage Tailscale, firewall4, or OpenClash services' + +echo "tailscale OpenClash lifecycle tests passed" diff --git a/luci-app-tailscale/tests/tailscale_peer_probe_test.sh b/luci-app-tailscale/tests/tailscale_peer_probe_test.sh new file mode 100644 index 000000000..be66adafa --- /dev/null +++ b/luci-app-tailscale/tests/tailscale_peer_probe_test.sh @@ -0,0 +1,157 @@ +#!/bin/sh + +set -eu + +ROOT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)" +HELPER="$ROOT_DIR/root/usr/sbin/tailscale_peer_probe" +TMP_DIR="${TMPDIR:-/tmp}/tailscale-peer-probe-test.$$" + +cleanup() { + rm -rf "$TMP_DIR" +} +trap cleanup EXIT + +fail() { + echo "FAIL: $*" >&2 + exit 1 +} + +assert_contains() { + needle="$1" + haystack="$2" + case "$haystack" in + *"${needle}"*) ;; + *) fail "expected output to contain: $needle +actual: $haystack" ;; + esac +} + +assert_not_contains() { + needle="$1" + haystack="$2" + case "$haystack" in + *"${needle}"*) fail "expected output not to contain: $needle +actual: $haystack" ;; + esac +} + +assert_valid_json() { + output="$1" + if command -v node >/dev/null 2>&1; then + printf '%s' "$output" | node -e 'let s = ""; process.stdin.on("data", c => s += c); process.stdin.on("end", () => { JSON.parse(s); });' \ + || fail "expected valid JSON, actual: $output" + fi +} + +mkdir -p "$TMP_DIR" + +cat >"$TMP_DIR/fake-tailscale" <<'SH' +#!/bin/sh + case "$*" in + "ping --c=1 --timeout=2s direct-peer") + echo "pong from direct-peer (100.64.2.2) via 192.168.188.1:41641 in 12.4ms" + exit 0 + ;; + "ping --c=1 --timeout=2s control-peer") + printf 'pong from control-peer (100.64.2.3) via 192.168.188.1:41641 in 15.0ms\n"quoted" line has \\backslash\\ and tab:\t with carriage:\rreturn' + exit 0 + ;; + "ping --c=1 --timeout=2s derp-peer") + echo "pong from derp-peer (100.64.2.1) via DERP(test-relay) in 41.8ms" + exit 0 + ;; + "ping --c=1 --timeout=2s derp-no-direct-peer") + echo "pong from derp-no-direct-peer (100.64.2.6) via DERP(test-relay) in 67ms" + echo "direct connection not established" + exit 1 + ;; + "ping --c=1 --timeout=2s weird-peer") + echo "pong from weird-peer using new format" + exit 0 + ;; + "ping --c=1 --timeout=2s failed-peer") + echo 'ping "100.64.2.6" timed out' >&2 + echo "no reply" >&2 + exit 1 + ;; + *) + echo "unexpected fake tailscale args: $*" >&2 + exit 99 + ;; +esac +SH +chmod +x "$TMP_DIR/fake-tailscale" + +run_probe() { + TAILSCALE_BIN="$TMP_DIR/fake-tailscale" "$HELPER" "$@" +} + +direct_output="$(run_probe direct-peer)" +assert_valid_json "$direct_output" +assert_contains '"peer":"direct-peer"' "$direct_output" +assert_contains '"ok":true' "$direct_output" +assert_contains '"path":"direct"' "$direct_output" +assert_contains '"latency_ms":12.4' "$direct_output" +assert_contains '"summary":"direct 12.4 ms"' "$direct_output" + +derp_output="$(run_probe derp-peer)" +assert_valid_json "$derp_output" +assert_contains '"peer":"derp-peer"' "$derp_output" +assert_contains '"ok":true' "$derp_output" +assert_contains '"path":"derp"' "$derp_output" +assert_contains '"relay":"test-relay"' "$derp_output" +assert_contains '"latency_ms":41.8' "$derp_output" +assert_contains '"summary":"DERP test-relay 41.8 ms"' "$derp_output" + +derp_no_direct_output="$(run_probe derp-no-direct-peer)" +assert_valid_json "$derp_no_direct_output" +assert_contains '"peer":"derp-no-direct-peer"' "$derp_no_direct_output" +assert_contains '"ok":true' "$derp_no_direct_output" +assert_contains '"path":"derp"' "$derp_no_direct_output" +assert_contains '"relay":"test-relay"' "$derp_no_direct_output" +assert_contains '"latency_ms":67' "$derp_no_direct_output" +assert_contains '"summary":"DERP test-relay 67 ms - direct connection not established"' "$derp_no_direct_output" + +unknown_output="$(run_probe weird-peer)" +assert_valid_json "$unknown_output" +assert_contains '"peer":"weird-peer"' "$unknown_output" +assert_contains '"ok":true' "$unknown_output" +assert_contains '"path":"unknown"' "$unknown_output" +assert_contains '"summary":"unknown path"' "$unknown_output" + +control_output="$(run_probe control-peer)" +assert_valid_json "$control_output" +assert_contains '"peer":"control-peer"' "$control_output" +assert_contains '"ok":true' "$control_output" +assert_contains '"path":"direct"' "$control_output" +assert_contains '"latency_ms":15.0' "$control_output" +assert_contains '"summary":"direct 15.0 ms"' "$control_output" +assert_contains '\"' "$control_output" +assert_contains '\\' "$control_output" +assert_contains '\t' "$control_output" +assert_contains '\r' "$control_output" +assert_contains '\n' "$control_output" + +control_output_lines="$(printf '%s\n' "$control_output" | wc -l | tr -d ' ')" +[ "$control_output_lines" -eq 1 ] || fail "expected control peer output to be single-line JSON: $control_output" + +failed_output="$(run_probe failed-peer || true)" +assert_valid_json "$failed_output" +assert_contains '"peer":"failed-peer"' "$failed_output" +assert_contains '"ok":false' "$failed_output" +assert_contains '"path":"failed"' "$failed_output" +assert_contains '"summary":"tailscale ping failed"' "$failed_output" + +invalid_output="$(run_probe 'bad peer;rm -rf /' || true)" +assert_valid_json "$invalid_output" +assert_contains '"ok":false' "$invalid_output" +assert_contains '"path":"failed"' "$invalid_output" +assert_contains '"summary":"invalid peer argument"' "$invalid_output" +assert_not_contains 'rm -rf' "$invalid_output" + +missing_arg_output="$("$HELPER" || true)" +assert_valid_json "$missing_arg_output" +assert_contains '"ok":false' "$missing_arg_output" +assert_contains '"summary":"usage: tailscale_peer_probe "' "$missing_arg_output" + +echo "tailscale_peer_probe tests passed" diff --git a/luci-app-tailscale/tests/tailscale_policy_routing_lifecycle_test.sh b/luci-app-tailscale/tests/tailscale_policy_routing_lifecycle_test.sh new file mode 100755 index 000000000..e396be09c --- /dev/null +++ b/luci-app-tailscale/tests/tailscale_policy_routing_lifecycle_test.sh @@ -0,0 +1,79 @@ +#!/bin/sh + +set -eu + +ROOT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)" +INIT_SCRIPT="$ROOT_DIR/root/etc/init.d/tailscale-policy-routing" +HOTPLUG_SCRIPT="$ROOT_DIR/root/etc/hotplug.d/iface/98-tailscale-policy-routing" +UCI_CONFIG="$ROOT_DIR/root/etc/config/tailscale_policy_routing" +TMP_DIR="${TMPDIR:-/tmp}/tailscale-policy-routing-lifecycle-test.$$" + +cleanup() { rm -rf "$TMP_DIR"; } +trap cleanup EXIT HUP INT TERM + +fail() { + echo "FAIL: $*" >&2 + exit 1 +} + +[ -x "$INIT_SCRIPT" ] || fail "missing policy-routing init script" +[ -x "$HOTPLUG_SCRIPT" ] || fail "missing policy-routing hotplug script" +[ -f "$UCI_CONFIG" ] || fail "missing policy-routing UCI config" +grep -qx "config settings 'settings'" "$UCI_CONFIG" || fail 'policy-routing UCI config must use a settings section' + +mkdir -p "$TMP_DIR" +cat >"$TMP_DIR/fake-helper" <<'SH' +#!/bin/sh +case "${1:-}" in +sync|monitor) printf '%s\n' "$*" >>"${FAKE_HELPER_LOG:?}" ;; +enabled) + printf '%s\n' "$*" >>"${FAKE_HELPER_LOG:?}" + [ "${FAKE_HELPER_ENABLED:-1}" = 1 ] + ;; +*) exit 2 ;; +esac +SH +chmod +x "$TMP_DIR/fake-helper" + +run_service() { + entrypoint="$1" + FAKE_HELPER_LOG="$TMP_DIR/helper.log" + FAKE_PROCD_LOG="$TMP_DIR/procd.log" + : >"$FAKE_HELPER_LOG" + : >"$FAKE_PROCD_LOG" + export FAKE_HELPER_LOG FAKE_PROCD_LOG + ( + procd_open_instance() { printf 'open %s\n' "${1:-default}" >>"$FAKE_PROCD_LOG"; } + procd_set_param() { printf 'set %s\n' "$*" >>"$FAKE_PROCD_LOG"; } + procd_close_instance() { printf '%s\n' 'close' >>"$FAKE_PROCD_LOG"; } + . "$INIT_SCRIPT" + PROG="$TMP_DIR/fake-helper" + "$entrypoint" + ) + cat "$FAKE_HELPER_LOG" "$FAKE_PROCD_LOG" +} + +expected_start="sync +enabled +open monitor +set command $TMP_DIR/fake-helper monitor +set respawn +close" +[ "$(run_service start_service)" = "$expected_start" ] || fail 'start must synchronize and monitor policy routing' +[ "$(run_service reload_service)" = "$expected_start" ] || fail 'reload must recreate the policy-routing monitor' +FAKE_HELPER_ENABLED=0 +export FAKE_HELPER_ENABLED +disabled_start="$(run_service start_service)" +[ "$disabled_start" = "sync +enabled" ] || fail 'disabled policy routing must not create a monitor instance' +unset FAKE_HELPER_ENABLED +[ -z "$(run_service stop_service)" ] || fail 'ordinary stop must preserve persistent policy-routing configuration' + +grep -F 'procd_add_reload_trigger "tailscale_policy_routing"' "$INIT_SCRIPT" >/dev/null || \ + fail 'policy-routing service needs its own UCI reload trigger' +grep -E '/etc/init.d/(tailscale|firewall|mwan3|openclash)|fw4 (reload|restart)|mwan3 restart' "$INIT_SCRIPT" "$HOTPLUG_SCRIPT" >/dev/null && \ + fail 'policy-routing lifecycle must not manage Tailscale, firewall, mwan3, or OpenClash services' +grep -F 'procd_set_param command "$PROG" monitor' "$INIT_SCRIPT" >/dev/null || \ + fail 'policy-routing service must continuously reconcile Tailscale route changes' + +echo 'tailscale policy-routing lifecycle tests passed' diff --git a/luci-app-tailscale/tests/tailscale_policy_routing_test.sh b/luci-app-tailscale/tests/tailscale_policy_routing_test.sh new file mode 100755 index 000000000..ac0f92ab7 --- /dev/null +++ b/luci-app-tailscale/tests/tailscale_policy_routing_test.sh @@ -0,0 +1,279 @@ +#!/bin/sh + +set -eu + +ROOT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)" +HELPER="$ROOT_DIR/root/usr/sbin/tailscale_policy_routing" +HOTPLUG="$ROOT_DIR/root/etc/hotplug.d/iface/98-tailscale-policy-routing" +TMP_DIR="$(mktemp -d)" + +cleanup() { rm -rf "$TMP_DIR"; } +trap cleanup EXIT HUP INT TERM + +fail() { echo "FAIL: $*" >&2; exit 1; } +assert_file() { [ -x "$1" ] || fail "missing executable: $1"; } +assert_contains() { grep -F -- "$1" "$2" >/dev/null || fail "$2 should contain: $1"; } +assert_not_contains() { grep -F -- "$1" "$2" >/dev/null && fail "$2 should not contain: $1" || true; } +assert_line_count() { + expected="$1" + needle="$2" + file="$3" + actual="$(grep -F -- "$needle" "$file" | wc -l | tr -d ' ')" + [ "$actual" = "$expected" ] || fail "$file expected $expected occurrences of: $needle, got $actual" +} + +mkdir -p "$TMP_DIR/bin" "$TMP_DIR/uci" "$TMP_DIR/state" + +cat >"$TMP_DIR/bin/uci" <<'SH' +#!/bin/sh +set -eu +state_dir="${UCI_STATE_DIR:?}" +[ "${1:-}" = '-q' ] && shift +command="${1:?}" +shift +key_file() { printf '%s/%s' "$state_dir" "$(printf '%s' "$1" | tr '/' '_')"; } +case "$command" in + get) + case "${1:?}" in + mwan3.globals) printf '%s' 'globals' ;; + mwan3.globals.mmx_mask) + file="$(key_file "$1")" + if [ -f "$file" ]; then + cat "$file" + else + printf '%s' '0x3f00' + fi + ;; + *) + file="$(key_file "$1")" + [ -f "$file" ] || exit 1 + cat "$file" + ;; + esac + ;; +set) + assignment="${1:?}" + key="${assignment%%=*}" + value="${assignment#*=}" + printf '%s' "$value" >"$(key_file "$key")" + ;; +delete) + file="$(key_file "${1:?}")" + rm -f "$file" "$file".* + ;; +commit) + printf '%s\n' "${1:?}" >>"${UCI_LOG:?}" + ;; +*) + exit 2 + ;; +esac +SH +chmod +x "$TMP_DIR/bin/uci" + +cat >"$TMP_DIR/bin/ip" <<'SH' +#!/bin/sh +set -eu +rules="${IP_RULES_FILE:?}" +routes="${IP_ROUTES_FILE:?}" +log="${IP_LOG:?}" +[ "${1:-}" = '-4' ] || exit 2 +shift +case "$*" in +'rule show') cat "$rules" ;; +'route show table 52') cat "$routes" ;; +'rule add priority 1000 lookup 52') + grep -q '^1000:' "$rules" && exit 2 + printf '%s\n' '1000: from all lookup 52' >>"$rules" + printf '%s\n' 'add priority 1000 lookup 52' >>"$log" + ;; +'rule del priority 1000 lookup 52') + [ "${IP_FAIL_DELETE:-0}" != 1 ] || exit 1 + tmp="$rules.tmp" + grep -v '^1000: from all lookup 52$' "$rules" >"$tmp" || true + mv "$tmp" "$rules" + printf '%s\n' 'del priority 1000 lookup 52' >>"$log" + ;; +*) exit 2 ;; +esac +SH +chmod +x "$TMP_DIR/bin/ip" + +cat >"$TMP_DIR/bin/logger" <<'SH' +#!/bin/sh +printf '%s\n' "$*" >>"${LOGGER_LOG:?}" +SH +chmod +x "$TMP_DIR/bin/logger" + +cat >"$TMP_DIR/bin/flock" <<'SH' +#!/bin/sh +exit 0 +SH +chmod +x "$TMP_DIR/bin/flock" + +cat >"$TMP_DIR/bin/sleep" <<'SH' +#!/bin/sh +exit 1 +SH +chmod +x "$TMP_DIR/bin/sleep" + +write_uci() { printf '%s' "$2" >"$TMP_DIR/uci/$1"; } +read_uci() { cat "$TMP_DIR/uci/$1"; } + +reset_state() { + rm -f "$TMP_DIR/uci"/* "$TMP_DIR/state"/* + : >"$TMP_DIR/uci.log" + : >"$TMP_DIR/ip.log" + : >"$TMP_DIR/logger.log" + printf '%s\n' '0: from all lookup local' '2001: from all fwmark 0x100/0x3f00 lookup 1' '2002: from all fwmark 0x200/0x3f00 lookup 2' '5270: from all lookup 52' '32766: from all lookup main' >"$TMP_DIR/rules" + printf '%s\n' '10.10.6.128/25 dev tailscale0' '100.64.2.0/24 dev tailscale0' >"$TMP_DIR/routes" + write_uci 'tailscale_policy_routing.settings.enabled' '1' +} + +run_helper() { + UCI_BIN="$TMP_DIR/bin/uci" IP_BIN="$TMP_DIR/bin/ip" LOGGER_CMD="$TMP_DIR/bin/logger" FLOCK_BIN="$TMP_DIR/bin/flock" MWAN3_INIT="$TMP_DIR/mwan3-init" LOCK_FILE="$TMP_DIR/state/tailscale-policy-routing.lock" UCI_STATE_DIR="$TMP_DIR/uci" UCI_LOG="$TMP_DIR/uci.log" IP_RULES_FILE="$TMP_DIR/rules" IP_ROUTES_FILE="$TMP_DIR/routes" IP_LOG="$TMP_DIR/ip.log" LOGGER_LOG="$TMP_DIR/logger.log" IP_FAIL_DELETE="${IP_FAIL_DELETE:-0}" "$HELPER" "$@" +} + +run_hotplug() { + ACTION="$1" POLICY_HELPER="$HELPER" UCI_BIN="$TMP_DIR/bin/uci" IP_BIN="$TMP_DIR/bin/ip" LOGGER_CMD="$TMP_DIR/bin/logger" FLOCK_BIN="$TMP_DIR/bin/flock" MWAN3_INIT="$TMP_DIR/mwan3-init" LOCK_FILE="$TMP_DIR/state/tailscale-policy-routing.lock" UCI_STATE_DIR="$TMP_DIR/uci" UCI_LOG="$TMP_DIR/uci.log" IP_RULES_FILE="$TMP_DIR/rules" IP_ROUTES_FILE="$TMP_DIR/routes" IP_LOG="$TMP_DIR/ip.log" LOGGER_LOG="$TMP_DIR/logger.log" "$HOTPLUG" +} + +touch "$TMP_DIR/mwan3-init" +chmod +x "$TMP_DIR/mwan3-init" + +assert_file "$HELPER" +assert_file "$HOTPLUG" + +reset_state +run_helper sync +[ "$(read_uci 'network.ts_mwan3_table52')" = 'rule' ] || fail 'sync must create the owned network rule section' +[ "$(read_uci 'network.ts_mwan3_table52.family')" = 'ipv4' ] || fail 'sync must persist IPv4-only rule family' +[ "$(read_uci 'network.ts_mwan3_table52.priority')" = '1000' ] || fail 'sync must persist priority 1000' +[ "$(read_uci 'network.ts_mwan3_table52.lookup')" = '52' ] || fail 'sync must persist Tailscale table 52 lookup' +assert_contains 'network' "$TMP_DIR/uci.log" +assert_line_count 1 '1000: from all lookup 52' "$TMP_DIR/rules" +assert_line_count 1 'add priority 1000 lookup 52' "$TMP_DIR/ip.log" + +run_helper sync +assert_line_count 1 '1000: from all lookup 52' "$TMP_DIR/rules" +assert_line_count 1 'add priority 1000 lookup 52' "$TMP_DIR/ip.log" + +printf '%s\n' '2001: from all fwmark 0x100/0x3f00 lookup 1' '5270: from all lookup 52' >"$TMP_DIR/rules" +run_hotplug ifup +assert_line_count 1 '1000: from all lookup 52' "$TMP_DIR/rules" +assert_line_count 2 'add priority 1000 lookup 52' "$TMP_DIR/ip.log" + +write_uci 'tailscale_policy_routing.settings.enabled' '0' +run_helper sync +[ ! -e "$TMP_DIR/uci/network.ts_mwan3_table52" ] || fail 'disabled sync must remove the owned UCI network rule' +assert_line_count 0 '1000: from all lookup 52' "$TMP_DIR/rules" +assert_contains 'del priority 1000 lookup 52' "$TMP_DIR/ip.log" +: >"$TMP_DIR/logger.log" +run_hotplug ifup +[ ! -s "$TMP_DIR/logger.log" ] || fail 'disabled hotplug must not report a policy-routing restore failure' + +reset_state +run_helper sync +write_uci 'tailscale_policy_routing.settings.enabled' '0' +if IP_FAIL_DELETE=1 run_helper sync; then fail 'disabled sync must fail when the runtime rule cannot be deleted'; fi +[ -e "$TMP_DIR/uci/network.ts_mwan3_table52" ] || fail 'failed runtime cleanup must retain the owned UCI rule for a safe retry' +assert_line_count 1 '1000: from all lookup 52' "$TMP_DIR/rules" +IP_FAIL_DELETE=0 +run_helper sync +[ ! -e "$TMP_DIR/uci/network.ts_mwan3_table52" ] || fail 'a later successful cleanup must remove the owned UCI rule' +assert_line_count 0 '1000: from all lookup 52' "$TMP_DIR/rules" + +reset_state +printf '%s\n' '1000: from all lookup 52' '2001: from all fwmark 0x100/0x3f00 lookup 1' '5270: from all lookup 52' >"$TMP_DIR/rules" +write_uci 'tailscale_policy_routing.settings.enabled' '0' +run_helper sync +assert_line_count 1 '1000: from all lookup 52' "$TMP_DIR/rules" +[ ! -e "$TMP_DIR/uci/network.ts_mwan3_table52" ] || fail 'disabled sync must not adopt a manually-created exact rule' + +reset_state +run_helper sync +printf '%s\n' '1000: from 10.10.10.0/24 lookup 52' '2001: from all fwmark 0x100/0x3f00 lookup 1' '5270: from all lookup 52' >"$TMP_DIR/rules" +write_uci 'tailscale_policy_routing.settings.enabled' '0' +if run_helper sync; then fail 'disabled sync must refuse to delete a constrained table 52 rule'; fi +[ -e "$TMP_DIR/uci/network.ts_mwan3_table52" ] || fail 'constrained-rule cleanup failure must retain UCI ownership for retry' +assert_line_count 1 '1000: from 10.10.10.0/24 lookup 52' "$TMP_DIR/rules" + +reset_state +printf '%s\n' '1000: from all lookup 1' '2001: from all fwmark 0x100/0x3f00 lookup 1' '5270: from all lookup 52' >"$TMP_DIR/rules" +if run_helper sync; then fail 'sync must reject an occupied priority 1000'; fi +[ ! -e "$TMP_DIR/uci/network.ts_mwan3_table52" ] || fail 'priority conflict must not create an owned UCI rule' + +reset_state +printf '%s\n' '1000: from all lookup 52' '2001: from all fwmark 0x100/0x3f00 lookup 1' '5270: from all lookup 52' >"$TMP_DIR/rules" +if run_helper sync; then fail 'sync must reject an unowned pre-existing table 52 priority rule'; fi +[ ! -e "$TMP_DIR/uci/network.ts_mwan3_table52" ] || fail 'unowned runtime rule must not be adopted' + +reset_state +run_helper sync +printf '%s\n' '1000: from all lookup 1' '2001: from all fwmark 0x100/0x3f00 lookup 1' '5270: from all lookup 52' >"$TMP_DIR/rules" +if run_helper sync; then fail 'sync must reject a priority conflict introduced after enablement'; fi +[ -e "$TMP_DIR/uci/network.ts_mwan3_table52" ] || fail 'priority conflict must retain the app-owned UCI rule for safe recovery' +assert_line_count 1 '1000: from all lookup 1' "$TMP_DIR/rules" +printf '%s\n' '2001: from all fwmark 0x100/0x3f00 lookup 1' '5270: from all lookup 52' >"$TMP_DIR/rules" +run_helper sync +assert_line_count 1 '1000: from all lookup 52' "$TMP_DIR/rules" + +reset_state +printf '%s\n' 'default dev tailscale0' >"$TMP_DIR/routes" +run_helper sync +[ ! -e "$TMP_DIR/uci/network.ts_mwan3_table52" ] || fail 'default route in table 52 must prevent persistent precedence rule creation' +assert_line_count 0 '1000: from all lookup 52' "$TMP_DIR/rules" +status="$(run_helper status)" +printf '%s' "$status" | jq -e '.state == "blocked_default_route" and .enabled == true and .mwan3_present == true and .mwan3_earlier_mark_rule == false' >/dev/null || fail 'status must identify the exit-node default route block without treating later mwan3 rules as earlier than priority 1000' + +reset_state +run_helper sync +printf '%s\n' 'default dev tailscale0' >"$TMP_DIR/routes" +run_hotplug ifupdate +[ -e "$TMP_DIR/uci/network.ts_mwan3_table52" ] || fail 'hotplug default-route protection must retain the owned UCI declaration for recovery' +assert_line_count 0 '1000: from all lookup 52' "$TMP_DIR/rules" # route-only ifupdate must remove the runtime rule +printf '%s\n' '10.10.6.128/25 dev tailscale0' '100.64.2.0/24 dev tailscale0' >"$TMP_DIR/routes" +run_hotplug ifup +assert_line_count 1 '1000: from all lookup 52' "$TMP_DIR/rules" +printf '%s\n' 'default dev tailscale0' >"$TMP_DIR/routes" +run_helper sync +[ ! -e "$TMP_DIR/uci/network.ts_mwan3_table52" ] || fail 'main sync must remove the persistent rule after an exit-node default route appears' +assert_line_count 0 '1000: from all lookup 52' "$TMP_DIR/rules" + +reset_state +run_helper sync +printf '%s\n' '500: from all fwmark 0x100/0x3f00 lookup 1' '1000: from all lookup 52' '2001: from all fwmark 0x200/0x3f00 lookup 2' '5270: from all lookup 52' >"$TMP_DIR/rules" +run_helper ensure +[ -e "$TMP_DIR/uci/network.ts_mwan3_table52" ] || fail 'earlier mwan3 priority must retain UCI ownership for recovery' +assert_line_count 0 '1000: from all lookup 52' "$TMP_DIR/rules" +status="$(run_helper status)" +printf '%s' "$status" | jq -e '.state == "blocked_mwan3_priority" and .mwan3_earlier_mark_rule == true' >/dev/null || fail 'status must block when mwan3 precedes priority 1000' + +reset_state +write_uci 'mwan3.globals.mmx_mask' '0x3F00' +printf '%s\n' '500: from all fwmark 0x100/0x3f00 lookup 1' '5270: from all lookup 52' >"$TMP_DIR/rules" +run_helper sync +[ ! -e "$TMP_DIR/uci/network.ts_mwan3_table52" ] || fail 'uppercase mwan3 mask must still block priority 1000 creation' +assert_line_count 0 '1000: from all lookup 52' "$TMP_DIR/rules" + +reset_state +printf '%s\n' '1000: from all lookup 52' '2001: from all fwmark 0x100/0x3f00 lookup 1' '5270: from all lookup 52' >"$TMP_DIR/rules" +rm -f "$TMP_DIR/uci/network.ts_mwan3_table52" "$TMP_DIR/uci/network.ts_mwan3_table52".* +if run_helper cleanup; then fail 'package cleanup must refuse to leave an unowned exact runtime rule'; fi +assert_line_count 1 '1000: from all lookup 52' "$TMP_DIR/rules" + +reset_state +run_helper sync +printf '%s\n' '2001: from all fwmark 0x100/0x3f00 lookup 1' '2002: from all fwmark 0x200/0x3f00 lookup 2' '5270: from all lookup 52' >"$TMP_DIR/rules" +SLEEP_BIN="$TMP_DIR/bin/sleep" +export SLEEP_BIN +run_helper monitor +unset SLEEP_BIN +assert_line_count 1 '1000: from all lookup 52' "$TMP_DIR/rules" + +assert_not_contains '/etc/init.d/network' "$HELPER" +assert_not_contains 'firewall' "$HELPER" +assert_not_contains 'openclash' "$HELPER" +assert_not_contains 'mwan3 restart' "$HELPER" + +echo 'tailscale policy routing tests passed' diff --git a/luci-app-tailscale/tests/tailscale_rpcd_test.sh b/luci-app-tailscale/tests/tailscale_rpcd_test.sh new file mode 100755 index 000000000..23464f2bc --- /dev/null +++ b/luci-app-tailscale/tests/tailscale_rpcd_test.sh @@ -0,0 +1,254 @@ +#!/bin/sh + +set -eu + +ROOT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)" +RPCD_SCRIPT="$ROOT_DIR/root/usr/libexec/rpcd/luci.tailscale" +TMP_DIR="${TMPDIR:-/tmp}/tailscale-rpcd-test.$$" + +cleanup() { + rm -rf "$TMP_DIR" +} +trap cleanup EXIT + +fail() { + echo "FAIL: $*" >&2 + exit 1 +} + +mkdir -p "$TMP_DIR" + +REAL_JQ="$(command -v jq)" +export REAL_JQ +cat >"$TMP_DIR/jq-no-regex" <<'SH' +#!/bin/sh +filter= +skip=0 +for arg do + if [ "$skip" -gt 0 ]; then + skip=$((skip - 1)) + continue + fi + [ -z "$filter" ] || continue + case "$arg" in + --arg|--argjson|--argfile|--slurpfile|--rawfile) skip=2 ;; + -*) ;; + *) filter="$arg" ;; + esac +done +normalized_filter="$(printf '%s' "$filter" | tr '\n\r\t' ' ')" +if printf '%s\n' "$normalized_filter" | + grep -Eq '(^|[^[:alnum:]_])(test|match|sub|gsub)[[:space:]]*\('; then + printf '%s\n' 'jq regex functions are unavailable on the target OpenWrt build' >&2 + exit 3 +fi +exec "${REAL_JQ:?}" "$@" +SH +chmod +x "$TMP_DIR/jq-no-regex" +export JQ_BIN="$TMP_DIR/jq-no-regex" + +if "$TMP_DIR/jq-no-regex" -n '"x" | test +("x")' >/dev/null 2>&1; then + fail "target jq shim must reject regex functions split across whitespace" +fi + +cat >"$TMP_DIR/secrets" <<'SH' +#!/bin/sh +printf '%s\n' "$*" >>"${SECRETS_ARGV_LOG:?}" +case "${1:-}" in + has) + case "${2:-}" in + authkey|adguard_password) exit 0 ;; + esac + ;; + matches-adguard) + [ "${2:-}" = 'http://saved.example:3000' ] && [ "${3:-}" = 'saved-user' ] + ;; + stage-batch) + cat >"${SECRETS_STDIN_LOG:?}" + printf '%s\n' 'staged-ref' + ;; + migrate) ;; + *) exit 1 ;; +esac +SH +chmod +x "$TMP_DIR/secrets" + +cat >"$TMP_DIR/preflight" <<'SH' +#!/bin/sh +printf '%s\n' "$*" >"${PREFLIGHT_ARGV_LOG:?}" +{ + printf 'candidate=%s\n' "${TAILSCALE_ADGUARD_PREFLIGHT_CANDIDATE:-}" + printf 'api_url=%s\n' "${TAILSCALE_ADGUARD_PREFLIGHT_API_URL:-}" + printf 'username=%s\n' "${TAILSCALE_ADGUARD_PREFLIGHT_USERNAME:-}" + printf 'password_set=%s\n' "${TAILSCALE_ADGUARD_PREFLIGHT_PASSWORD_SET:-}" + printf 'password=%s\n' "${TAILSCALE_ADGUARD_PREFLIGHT_PASSWORD:-}" + printf 'default_upstreams=%s\n' "${TAILSCALE_ADGUARD_PREFLIGHT_DEFAULT_UPSTREAMS:-}" + printf 'tailnet_upstreams=%s\n' "${TAILSCALE_ADGUARD_PREFLIGHT_TAILNET_UPSTREAMS:-}" + printf 'health_domain=%s\n' "${TAILSCALE_ADGUARD_PREFLIGHT_HEALTH_DOMAIN:-}" + printf 'expected_ips=%s\n' "${TAILSCALE_ADGUARD_PREFLIGHT_EXPECTED_IPS:-}" +} >"${PREFLIGHT_ENV_LOG:?}" +printf 'adguard_process=pass\nhealth_check=pass\nready=pass\n' +SH +chmod +x "$TMP_DIR/preflight" + +cat >"$TMP_DIR/openclash-helper" <<'SH' +#!/bin/sh +printf '%s\n' "$*" >"${OPENCLASH_HELPER_ARGV_LOG:?}" +case "${OPENCLASH_HELPER_MODE:-success}" in + success) + printf '%s\n' '{"state":"active","rules_present":4,"detail":"helper status"}' + ;; + fail) + printf '%s\n' 'partial helper output' + printf '%s\n' 'helper failed' >&2 + exit 7 + ;; + invalid) + printf '%s\n' 'not JSON' + ;; + esac +SH +chmod +x "$TMP_DIR/openclash-helper" + +cat >"$TMP_DIR/policy-routing-helper" <<'SH' +#!/bin/sh +printf '%s\n' "$*" >"${POLICY_ROUTING_HELPER_ARGV_LOG:?}" +case "${POLICY_ROUTING_HELPER_MODE:-success}" in + success) + printf '%s\n' '{"state":"active","enabled":true,"mwan3_present":true}' + ;; + fail) + printf '%s\n' 'partial helper output' + printf '%s\n' 'helper failed' >&2 + exit 7 + ;; + invalid) + printf '%s\n' 'not JSON' + ;; +esac +SH +chmod +x "$TMP_DIR/policy-routing-helper" + +PREFLIGHT_ARGV_LOG="$TMP_DIR/argv.log" +PREFLIGHT_ENV_LOG="$TMP_DIR/env.log" +SECRETS_ARGV_LOG="$TMP_DIR/secrets-argv.log" +SECRETS_STDIN_LOG="$TMP_DIR/secrets-stdin.log" +OPENCLASH_HELPER_ARGV_LOG="$TMP_DIR/openclash-helper-argv.log" +POLICY_ROUTING_HELPER_ARGV_LOG="$TMP_DIR/policy-routing-helper-argv.log" +export PREFLIGHT_ARGV_LOG PREFLIGHT_ENV_LOG SECRETS_ARGV_LOG SECRETS_STDIN_LOG OPENCLASH_HELPER_ARGV_LOG POLICY_ROUTING_HELPER_ARGV_LOG +: >"$SECRETS_ARGV_LOG" +: >"$SECRETS_STDIN_LOG" + +list_json="$(PRECHECK_BIN="$TMP_DIR/preflight" SECRETS_BIN="$TMP_DIR/secrets" "$RPCD_SCRIPT" list)" +printf '%s' "$list_json" | jq -e '.adguard_preflight.candidate == "" and .adguard_preflight.password == ""' >/dev/null || \ + fail "rpcd list output must declare the AdGuard preflight string arguments" +printf '%s' "$list_json" | jq -e '.secret_status == {} and (has("set_secret") | not) and .set_secrets.authkey_set == "" and .set_secrets.adguard_password_set == "" and .set_secrets.base_ref == ""' >/dev/null || \ + fail "rpcd list output must declare secret status and write methods" +printf '%s' "$list_json" | jq -e '.openclash_bypass_status == {}' >/dev/null || \ + fail "rpcd list output must declare the read-only OpenClash bypass status method" +printf '%s' "$list_json" | jq -e '.policy_routing_status == {}' >/dev/null || \ + fail "rpcd list output must declare the read-only policy-routing status method" + +openclash_status_response="$(printf '%s\n' '{"ignored":"caller-controlled input"}' | OPENCLASH_BYPASS_BIN="$TMP_DIR/openclash-helper" \ + "$RPCD_SCRIPT" call openclash_bypass_status)" +printf '%s' "$openclash_status_response" | jq -e '.state == "active" and .rules_present == 4 and .detail == "helper status"' >/dev/null || \ + fail "rpcd must return the helper status object unchanged" +[ "$(cat "$OPENCLASH_HELPER_ARGV_LOG")" = "status" ] || \ + fail "rpcd must invoke the OpenClash helper with the fixed status argument only" + +if openclash_failure_response="$(printf '%s\n' '{}' | OPENCLASH_HELPER_MODE=fail OPENCLASH_BYPASS_BIN="$TMP_DIR/openclash-helper" \ + "$RPCD_SCRIPT" call openclash_bypass_status 2>&1)"; then + fail "rpcd must fail when the OpenClash helper exits nonzero" +fi +printf '%s' "$openclash_failure_response" | jq -e '.code == 2 and .ready == "fail" and (.error | type == "string")' >/dev/null || \ + fail "nonzero OpenClash helper exits must return controlled JSON errors" + +if openclash_invalid_response="$(printf '%s\n' '{}' | OPENCLASH_HELPER_MODE=invalid OPENCLASH_BYPASS_BIN="$TMP_DIR/openclash-helper" \ + "$RPCD_SCRIPT" call openclash_bypass_status 2>&1)"; then + fail "rpcd must fail when the OpenClash helper returns invalid JSON" +fi +printf '%s' "$openclash_invalid_response" | jq -e '.code == 2 and .ready == "fail" and (.error | type == "string")' >/dev/null || \ + fail "invalid OpenClash helper JSON must return controlled JSON errors" + +policy_status_response="$(printf '%s\n' '{"ignored":"caller-controlled input"}' | POLICY_ROUTING_BIN="$TMP_DIR/policy-routing-helper" \ + "$RPCD_SCRIPT" call policy_routing_status)" +printf '%s' "$policy_status_response" | jq -e '.state == "active" and .enabled == true and .mwan3_present == true' >/dev/null || \ + fail "rpcd must return the policy-routing helper status object unchanged" +[ "$(cat "$POLICY_ROUTING_HELPER_ARGV_LOG")" = "status" ] || \ + fail "rpcd must invoke the policy-routing helper with the fixed status argument only" + +if policy_failure_response="$(printf '%s\n' '{}' | POLICY_ROUTING_HELPER_MODE=fail POLICY_ROUTING_BIN="$TMP_DIR/policy-routing-helper" \ + "$RPCD_SCRIPT" call policy_routing_status 2>&1)"; then + fail "rpcd must fail when the policy-routing helper exits nonzero" +fi +printf '%s' "$policy_failure_response" | jq -e '.code == 2 and .ready == "fail" and (.error | type == "string")' >/dev/null || \ + fail "nonzero policy-routing helper exits must return controlled JSON errors" + +if policy_invalid_response="$(printf '%s\n' '{}' | POLICY_ROUTING_HELPER_MODE=invalid POLICY_ROUTING_BIN="$TMP_DIR/policy-routing-helper" \ + "$RPCD_SCRIPT" call policy_routing_status 2>&1)"; then + fail "rpcd must fail when the policy-routing helper returns invalid JSON" +fi +printf '%s' "$policy_invalid_response" | jq -e '.code == 2 and .ready == "fail" and (.error | type == "string")' >/dev/null || \ + fail "invalid policy-routing helper JSON must return controlled JSON errors" + +status_response="$(printf '%s\n' '{}' | SECRETS_BIN="$TMP_DIR/secrets" "$RPCD_SCRIPT" call secret_status)" +printf '%s' "$status_response" | jq -e '.authkey_set == "1" and .adguard_password_set == "1"' >/dev/null || \ + fail "secret status must expose booleans without returning secret values" +grep -Fx 'migrate' "$SECRETS_ARGV_LOG" >/dev/null || fail "secret status must migrate readable legacy credentials before reporting status" + +request='{"candidate":"1","api_url":"http://candidate.example:3000","username":"candidate-user","password_set":"1","password":"candidate-secret","default_upstreams":"1.1.1.1\\n8.8.8.8","tailnet_upstreams":"[/candidate.example/]100.100.100.100","health_domain":"candidate-health.example","expected_ips":"10.23.0.15\\n10.23.0.16"}' +response="$(printf '%s\n' "$request" | PRECHECK_BIN="$TMP_DIR/preflight" SECRETS_BIN="$TMP_DIR/secrets" "$RPCD_SCRIPT" call adguard_preflight)" + +printf '%s' "$response" | jq -e '.ready == "pass" and .health_check == "pass" and .code == 0' >/dev/null || \ + fail "rpcd preflight must return the structured checker result" +[ "$(cat "$PREFLIGHT_ARGV_LOG")" = "--preflight" ] || fail "candidate values and credentials must not enter the checker argv" +grep -F 'candidate=1' "$PREFLIGHT_ENV_LOG" >/dev/null || fail "rpcd preflight must enable candidate mode" +grep -F 'api_url=http://candidate.example:3000' "$PREFLIGHT_ENV_LOG" >/dev/null || fail "rpcd preflight must pass the candidate API URL" +grep -F 'password=candidate-secret' "$PREFLIGHT_ENV_LOG" >/dev/null || fail "rpcd preflight must pass the candidate password through the child environment" +grep -F 'default_upstreams=1.1.1.1' "$PREFLIGHT_ENV_LOG" >/dev/null || fail "rpcd preflight must preserve candidate upstreams" + +: >"$PREFLIGHT_ARGV_LOG" +reuse_request='{"candidate":"1","api_url":"http://attacker.example:3000","username":"saved-user","password_set":"0","password":"","default_upstreams":"1.1.1.1","tailnet_upstreams":"[/candidate.example/]100.100.100.100","health_domain":"candidate-health.example","expected_ips":"10.23.0.15"}' +if printf '%s\n' "$reuse_request" | PRECHECK_BIN="$TMP_DIR/preflight" SECRETS_BIN="$TMP_DIR/secrets" "$RPCD_SCRIPT" call adguard_preflight >/dev/null 2>&1; then + fail "preflight must reject saved-password reuse for a different AdGuard endpoint" +fi +[ ! -s "$PREFLIGHT_ARGV_LOG" ] || fail "rejected credential reuse must not invoke the preflight checker" + +: >"$PREFLIGHT_ARGV_LOG" +unsafe_request='{"candidate":"1","api_url":"file:///etc/shadow","username":"candidate-user","password_set":"1","password":"candidate-secret","default_upstreams":"1.1.1.1","tailnet_upstreams":"[/candidate.example/]100.100.100.100","health_domain":"candidate-health.example","expected_ips":"10.23.0.15"}' +if printf '%s\n' "$unsafe_request" | PRECHECK_BIN="$TMP_DIR/preflight" SECRETS_BIN="$TMP_DIR/secrets" "$RPCD_SCRIPT" call adguard_preflight >/dev/null 2>&1; then + fail "preflight must reject non-HTTP AdGuard API URLs" +fi +[ ! -s "$PREFLIGHT_ARGV_LOG" ] || fail "rejected API schemes must not invoke the preflight checker" + +: >"$SECRETS_ARGV_LOG" +: >"$SECRETS_STDIN_LOG" +batch_request='{"authkey_set":"1","authkey":"new-auth-key","adguard_password_set":"1","adguard_password":"new-batch-secret","api_url":"https://batch.example:3000","username":"batch-user","base_ref":"active-ref"}' +batch_response="$(printf '%s\n' "$batch_request" | SECRETS_BIN="$TMP_DIR/secrets" "$RPCD_SCRIPT" call set_secrets)" +[ "$(cat "$SECRETS_ARGV_LOG")" = "stage-batch active-ref" ] || fail "batch credential writes must stage one atomic secret version based on the current UCI reference" +printf '%s' "$(cat "$SECRETS_STDIN_LOG")" | jq -e '.authkey == "new-auth-key" and .adguard.password == "new-batch-secret" and .adguard.api_url == "https://batch.example:3000" and .adguard.username == "batch-user"' >/dev/null || \ + fail "batch credential writes must preserve both credentials and the AdGuard endpoint binding" +printf '%s' "$batch_response" | jq -e '.code == 0 and .ref == "staged-ref"' >/dev/null || \ + fail "batch credential staging must return the UCI version reference without activating it" + +for invalid_ref_request in \ + '{"authkey_set":"1","authkey":"new-auth-key","adguard_password_set":"0","adguard_password":"","api_url":"","username":"","base_ref":"active-ref\n\n"}' \ + '{"authkey_set":"1","authkey":"new-auth-key","adguard_password_set":"0","adguard_password":"","api_url":"","username":"","base_ref":"active-ref\u0000"}' +do + : >"$SECRETS_ARGV_LOG" + if printf '%s\n' "$invalid_ref_request" | SECRETS_BIN="$TMP_DIR/secrets" "$RPCD_SCRIPT" call set_secrets >/dev/null 2>&1; then + fail "credential batch RPC must reject control characters in base_ref" + fi + [ ! -s "$SECRETS_ARGV_LOG" ] || fail "invalid base_ref must not reach the protected credential helper" +done + +legacy_set_request='{"name":"adguard_password","value":"new-secret","api_url":"http://new.example:3000","username":"new-user"}' +if printf '%s\n' "$legacy_set_request" | SECRETS_BIN="$TMP_DIR/secrets" "$RPCD_SCRIPT" call set_secret >/dev/null 2>&1; then + fail "the non-transactional legacy secret write RPC must fail closed" +fi + +printf '%s\n' '{}' | PRECHECK_BIN="$TMP_DIR/preflight" SECRETS_BIN="$TMP_DIR/secrets" "$RPCD_SCRIPT" call unknown >/dev/null 2>&1 && \ + fail "unknown rpcd methods must fail closed" + +echo "tailscale rpcd tests passed" diff --git a/luci-app-tailscale/tests/tailscale_secrets_test.sh b/luci-app-tailscale/tests/tailscale_secrets_test.sh new file mode 100755 index 000000000..dcedb1348 --- /dev/null +++ b/luci-app-tailscale/tests/tailscale_secrets_test.sh @@ -0,0 +1,220 @@ +#!/bin/sh + +set -eu + +ROOT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)" +SCRIPT="$ROOT_DIR/root/usr/sbin/tailscale_secrets" +TMP_DIR="${TMPDIR:-/tmp}/tailscale-secrets-test.$$" +SECRET_FILE="$TMP_DIR/luci-secrets.json" +UCI_LOG="$TMP_DIR/uci.log" +UCI_REF_FILE="$TMP_DIR/uci-secrets-ref" + +cleanup() { + rm -rf "$TMP_DIR" +} +trap cleanup EXIT + +fail() { + printf 'FAIL: %s\n' "$*" >&2 + exit 1 +} + +assert_eq() { + expected="$1" + actual="$2" + message="$3" + [ "$expected" = "$actual" ] || fail "$message +expected: $expected +actual: $actual" +} + +file_mode() { + stat -c '%a' "$1" 2>/dev/null || stat -f '%Lp' "$1" +} + +mkdir -p "$TMP_DIR" +: >"$UCI_LOG" +export UCI_LOG UCI_REF_FILE + +REAL_JQ="$(command -v jq)" +export REAL_JQ +cat >"$TMP_DIR/jq-no-regex" <<'SH' +#!/bin/sh +filter= +skip=0 +for arg do + if [ "$skip" -gt 0 ]; then + skip=$((skip - 1)) + continue + fi + [ -z "$filter" ] || continue + case "$arg" in + --arg|--argjson|--argfile|--slurpfile|--rawfile) skip=2 ;; + -*) ;; + *) filter="$arg" ;; + esac +done +normalized_filter="$(printf '%s' "$filter" | tr '\n\r\t' ' ')" +if printf '%s\n' "$normalized_filter" | + grep -Eq '(^|[^[:alnum:]_])(test|match|sub|gsub)[[:space:]]*\('; then + printf '%s\n' 'jq regex functions are unavailable on the target OpenWrt build' >&2 + exit 3 +fi +exec "${REAL_JQ:?}" "$@" +SH +chmod +x "$TMP_DIR/jq-no-regex" + +if "$TMP_DIR/jq-no-regex" -n '"x" | test ("x")' >/dev/null 2>&1; then + fail "target jq shim must reject regex functions separated from their opening parenthesis by whitespace" +fi + +cat >"$TMP_DIR/uci" <<'SH' +#!/bin/sh +printf '%s\n' "$*" >>"${UCI_LOG:?}" +case "$*" in + "-q get tailscale.settings.authkey") printf '%s\n' "${LEGACY_AUTHKEY:-}" ;; + "-q get tailscale.settings.adguard_password") printf '%s\n' "${LEGACY_ADGUARD_PASSWORD:-}" ;; + "-q get tailscale.settings.adguard_api_url") printf '%s\n' "${LEGACY_ADGUARD_API_URL:-http://127.0.0.1:3000}" ;; + "-q get tailscale.settings.adguard_username") printf '%s\n' "${LEGACY_ADGUARD_USERNAME:-}" ;; + "-q get tailscale.settings.secrets_ref") [ -f "$UCI_REF_FILE" ] && cat "$UCI_REF_FILE" ;; + "-q set tailscale.settings.secrets_ref="*) printf '%s\n' "${3#*=}" >"$UCI_REF_FILE" ;; + "-q delete tailscale.settings.authkey"|"-q delete tailscale.settings.adguard_password"|"commit tailscale") ;; + *) exit 1 ;; +esac +SH +chmod +x "$TMP_DIR/uci" + +if command -v flock >/dev/null 2>&1; then + FLOCK_CMD="$(command -v flock)" +else + cat >"$TMP_DIR/flock" <<'SH' +#!/bin/sh +exit 0 +SH + chmod +x "$TMP_DIR/flock" + FLOCK_CMD="$TMP_DIR/flock" +fi + +run_secrets() { + TAILSCALE_SECRETS_FILE="$SECRET_FILE" \ + UCI_CMD="$TMP_DIR/uci" \ + JQ_CMD="$TMP_DIR/jq-no-regex" \ + FLOCK_BIN="$FLOCK_CMD" \ + "$SCRIPT" "$@" +} + +for encoded_ref in '"base\n\n"' '"base\u0000"'; do + jq -nc --argjson ref "$encoded_ref" '{schema:2,active_ref:$ref,versions:{($ref):{}}}' >"$SECRET_FILE" + if run_secrets active-ref >/dev/null 2>&1; then + fail "protected credential store must reject control characters in active_ref" + fi + rm -f "$SECRET_FILE" +done + +authkey='tskey-auth-special-$HOME-$(not-expanded)-"quoted"-\backslash' +printf '%s' "$authkey" | run_secrets set-authkey +assert_eq "$authkey" "$(run_secrets get authkey)" "auth key must round-trip without shell expansion" +run_secrets authkey-pending || fail "newly saved auth keys must be marked for one-time enrollment" +jq -e '.versions[.active_ref].authkey_pending == true' "$SECRET_FILE" >/dev/null || \ + fail "active auth key version must record pending enrollment intent" +assert_eq 600 "$(file_mode "$SECRET_FILE")" "secret file must be root-only" +if find "$TMP_DIR" -maxdepth 1 -type f -name '.luci-secrets-*' | grep -q .; then + fail "secret updates must not leave temporary credential files behind" +fi + +adguard_password='adguard-password-$HOME-$(not-expanded)-"quoted"-\backslash' +jq -nc \ + --arg password "$adguard_password" \ + --arg api_url 'http://127.0.0.1:3000/' \ + --arg username 'root' \ + '{password:$password,api_url:$api_url,username:$username}' | run_secrets set-adguard + +run_secrets matches-adguard 'http://127.0.0.1:3000' root || fail "saved AdGuard binding must match its normalized endpoint" +assert_eq "$adguard_password" "$(run_secrets adguard-password-for 'http://127.0.0.1:3000/' root)" "matching AdGuard endpoint must receive the saved password" +if find "$TMP_DIR" -maxdepth 1 -type f -name '.luci-secrets-*' | grep -q .; then + fail "AdGuard secret updates must clean temporary credential files" +fi +if run_secrets matches-adguard 'http://attacker.example:3000' root; then + fail "changed AdGuard endpoint must not match the saved credential binding" +fi +if run_secrets adguard-password-for 'http://attacker.example:3000' root >"$TMP_DIR/mismatch.out"; then + fail "changed AdGuard endpoint must not receive the saved password" +fi +[ ! -s "$TMP_DIR/mismatch.out" ] || fail "mismatched AdGuard endpoint leaked the saved password" + +if jq -nc '{password:"secret",api_url:"file:///etc/shadow",username:"root"}' | run_secrets set-adguard >/dev/null 2>&1; then + fail "AdGuard credentials must only bind to HTTP or HTTPS API URLs" +fi + +batch_authkey='batch-auth-key' +batch_password='batch-adguard-password' +jq -nc \ + --arg authkey "$batch_authkey" \ + --arg password "$batch_password" \ + '{authkey:$authkey,adguard:{password:$password,api_url:"https://adguard.example:3000/",username:"batch-user"}}' | + run_secrets set-batch +assert_eq "$batch_authkey" "$(run_secrets get authkey)" "batch updates must atomically preserve the auth key" +run_secrets authkey-pending || fail "batch auth key updates must be marked for one-time enrollment" +assert_eq "$batch_password" "$(run_secrets adguard-password-for 'https://adguard.example:3000' batch-user)" "batch updates must atomically preserve the bound AdGuard password" +[ -f "$TMP_DIR/.luci-secrets.lock" ] || fail "secret access must use a persistent lock file" +assert_eq 600 "$(file_mode "$TMP_DIR/.luci-secrets.lock")" "secret lock file must be root-only" +jq -e '.schema == 2 and (.active_ref | type == "string") and (.versions[.active_ref] | type == "object")' "$SECRET_FILE" >/dev/null || \ + fail "secret storage must use a versioned schema with an explicit active reference" + +active_ref="$(run_secrets active-ref)" +staged_auth_ref="$(printf '%s' 'staged-auth-key' | run_secrets stage-authkey "$active_ref")" +assert_eq "$batch_authkey" "$(run_secrets get authkey)" "staging a credential version must not change the active auth key" +jq -e --arg ref "$staged_auth_ref" '.versions[$ref].authkey_pending == true' "$SECRET_FILE" >/dev/null || \ + fail "staged auth key version must record pending enrollment intent" +staged_combined_ref="$( + jq -nc '{authkey:null,adguard:{password:"staged-adguard-password",api_url:"https://staged.example:3000",username:"staged-user"}}' | + run_secrets stage-batch "$staged_auth_ref" +)" +run_secrets activate "$staged_combined_ref" +assert_eq 'staged-auth-key' "$(run_secrets get authkey)" "a second staged update must inherit the first staged version" +assert_eq 'staged-adguard-password' "$(run_secrets adguard-password-for 'https://staged.example:3000' staged-user)" "activating a staged version must expose its bound AdGuard password" +run_secrets activate "$active_ref" +assert_eq "$batch_authkey" "$(run_secrets get authkey)" "reactivating the previous UCI reference must roll credentials back" + +run_secrets clear-authkey +if run_secrets has authkey; then + fail "successful enrollment cleanup must remove the active auth key" +fi +if run_secrets authkey-pending; then + fail "successful enrollment cleanup must remove pending enrollment intent" +fi +jq -e '[.versions[] | has("authkey") or has("authkey_pending")] | any | not' "$SECRET_FILE" >/dev/null || \ + fail "auth key cleanup must purge every credential version" +assert_eq "$batch_password" "$(run_secrets adguard-password-for 'https://adguard.example:3000' batch-user)" \ + "auth key cleanup must preserve unrelated AdGuard credentials" + +printf '%s\n' '{"authkey":"flat-file-auth"}' >"$SECRET_FILE" +rm -f "$UCI_REF_FILE" +unset LEGACY_AUTHKEY LEGACY_ADGUARD_PASSWORD LEGACY_ADGUARD_API_URL LEGACY_ADGUARD_USERNAME || true +run_secrets migrate +assert_eq 'flat-file-auth' "$(run_secrets get authkey)" "migration must preserve the pre-versioned secret file" +if run_secrets authkey-pending; then + fail "migrated legacy auth keys must not trigger re-enrollment on an existing node" +fi +jq -e '.schema == 2 and (.versions[.active_ref].authkey == "flat-file-auth")' "$SECRET_FILE" >/dev/null || \ + fail "migration must wrap the old flat secret object in a version" + +rm -f "$SECRET_FILE" +rm -f "$UCI_REF_FILE" +export LEGACY_AUTHKEY='legacy-auth-key' +export LEGACY_ADGUARD_PASSWORD='legacy-adguard-password' +export LEGACY_ADGUARD_API_URL='http://legacy.example:3000/' +export LEGACY_ADGUARD_USERNAME='legacy-user' +run_secrets migrate + +assert_eq 'legacy-auth-key' "$(run_secrets get authkey)" "migration must preserve the legacy auth key" +if run_secrets authkey-pending; then + fail "readable legacy auth keys must not be treated as explicit new enrollment requests" +fi +assert_eq 'legacy-adguard-password' "$(run_secrets adguard-password-for 'http://legacy.example:3000' legacy-user)" "migration must bind and preserve the legacy AdGuard password" +grep -F -- '-q delete tailscale.settings.authkey' "$UCI_LOG" >/dev/null || fail "migration must delete the readable legacy auth key" +grep -F -- '-q delete tailscale.settings.adguard_password' "$UCI_LOG" >/dev/null || fail "migration must delete the readable legacy AdGuard password" +grep -F -- '-q set tailscale.settings.secrets_ref=' "$UCI_LOG" >/dev/null || fail "migration must persist the active secret version reference" +grep -F -- 'commit tailscale' "$UCI_LOG" >/dev/null || fail "migration must commit removal of legacy credentials" + +printf '%s\n' 'tailscale secret storage tests passed'