🗽 Sync 2026-09-29 04:49:38

This commit is contained in:
github-actions[bot] committed 2026-09-29 04:49:38 +08:00
1 parent 1ba92ebff1
commit cec9f6bff2
87 files changed
+25281

No files matched your search

+12
View File
@@ -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 <moebest@outlook.jp>
LUCI_DEPENDS:=+tailscale +ip +luci-base
LUCI_PKGARCH:=all
include $(TOPDIR)/feeds/luci/luci.mk
# call BuildPackage - OpenWrt buildroot signature
@@ -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('<br>')[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('<br>')[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.')
+'<br>'+_('It automatically creates the tailscale interface, sets up firewall zones for LAN <-> Tailscale forwarding,')
+'<br>'+_('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.')
+'<br>'+_('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'))
+'<br>'+_('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.')
+'<br>'+_('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.')
+'<br>'+_('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 =
'<h2>' + _('Tailscale Login') + '</h2>' +
'<p>' + _('Requesting Tailscale login URL... Please wait.') + '</p>' +
'<p>' + _('This can take up to 30 seconds.') + '</p>';
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 =
'<h2>' + _('Error') + '</h2>' +
'<p>' + _('Failed to get login URL. You may close this tab.') + '</p>';
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?')
+'<br>'+_('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
});
@@ -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 ""
+1
View File
@@ -0,0 +1 @@
zh_Hans
@@ -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修复)功能,以确保连接稳定。"
@@ -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鉗制確保連線穩定。"
@@ -0,0 +1,10 @@
{
"admin/services/tailscale": {
"title": "Tailscale",
"order": 90,
"action": {
"type": "view",
"path": "tailscale"
}
}
}
@@ -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" ]
}
}
}
@@ -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('<br>', 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 };
+30
View File
@@ -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 <moebest@outlook.jp>
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
@@ -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).') + '<br>' + _('Disabled: system DNS only.') + '<br>' + _('MagicDNS: Tailscale overrides resolv.conf.') + '<br>' + _('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 =
'<h2>' + _('Tailscale Login') + '</h2>' +
'<p>' + _('Requesting Tailscale login URL... Please wait.') + '</p>' +
'<p>' + _('This can take up to 30 seconds.') + '</p>';
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 =
'<h2>' + _('Error') + '</h2>' +
'<p>' + _('Failed to get login URL. You may close this tab.') + '</p>';
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?') + '<br>' + _('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('<br>')[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('<br>')[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');
});
}
});
@@ -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 ""
+1
View File
@@ -0,0 +1 @@
zh_Hans
@@ -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 "前"
@@ -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 "前"
@@ -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
+457
View File
@@ -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
}
@@ -0,0 +1,13 @@
{
"admin/vpn/tailscale": {
"title": "Tailscale",
"order": 90,
"action": {
"type": "view",
"path": "tailscale"
},
"depends": {
"acl": [ "luci-app-tailscale" ]
}
}
}
@@ -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" ]
}
}
}
@@ -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('<br>', 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 };
+2
View File
@@ -0,0 +1,2 @@
.DS_Store
*/.DS_Store
@@ -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 `<tmp>`; 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: <tmp>/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: <tmp>/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`.
@@ -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`
@@ -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`
@@ -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: <message> }` 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`.
@@ -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.
+674
View File
@@ -0,0 +1,674 @@
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
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.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
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 <https://www.gnu.org/licenses/>.
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:
<program> Copyright (C) <year> <name of author>
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
<https://www.gnu.org/licenses/>.
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
<https://www.gnu.org/licenses/why-not-lgpl.html>.
+117
View File
@@ -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
+160
View File
@@ -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
<img width="573" alt="Basic" src="https://github.com/user-attachments/assets/bfca389a-bcec-42de-b5dd-b9588fd5db23" />
<img width="577" alt="Advanced" src="https://github.com/user-attachments/assets/d60ce19e-b3f3-43a7-98fc-7df6e2231898" />
<img width="575" alt="Extra" src="https://github.com/user-attachments/assets/6de5eaa7-6c18-48b8-a44a-0eaa311b0b79" />
File diff suppressed because it is too large. Load diff
@@ -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.
@@ -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"
```
@@ -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 单独重启和路由状态变更。
@@ -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.
@@ -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 <peer>
```
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.
@@ -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.
@@ -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 缓存并执行同等级别验证。
@@ -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` 时保持兼容。
- 验证当前设备行显示“当前设备”且没有可点击的探测按钮。
- 运行现有对端页面测试和完整项目测试,确认筛选、分页、探测及打包检查无回归。
@@ -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 处理。
@@ -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
});
@@ -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
});
@@ -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
});
@@ -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 = '<em><span style="color:%s"><strong>%s %s</strong></span></em>';
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 = '<span style="color:%s">%s</span>';
let renderHTML;
if (loginStatus === "NeedsLogin") {
renderHTML = String.format('<a href="%s" target="_blank">%s</a>', authURL, _('Need to log in'));
} else if (loginStatus === "Running") {
renderHTML = String.format('<a href="%s" target="_blank">%s</a>', 'https://login.tailscale.com/admin/machines', displayName);
renderHTML += String.format('<br><a style="color:green" id="logout_button">%s</a>', _('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. <code>10.0.0.0/24</code>.'));
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. <code>--exit-node=10.0.0.1</code>. <br> %s for enabling settings upon the initiation of Tailscale.'),
'<a href="https://tailscale.com/kb/1241/tailscale-up" target="_blank">' + _('Available flags') + '</a>'
)
);
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.'), '<a href="https://github.com/juanfont/headscale" target="_blank">headscale</a>');
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;
});
}
});
@@ -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. <code>10.0.0.0/24</code>."
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. <code>--exit-node=10.0.0.1</"
"code>. <br> %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 ""
+1
View File
@@ -0,0 +1 @@
zh_Hans
+670
View File
@@ -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. <code>10.0.0.0/24</code>."
msgstr "将本地物理网段发布到 Tailscale,例如:<code>10.0.0.0/24</code>。"
#: 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. <code>--exit-node=10.0.0.1</"
"code>. <br> %s for enabling settings upon the initiation of Tailscale."
msgstr "额外参数的列表。格式:--flags=value,例如 <code>--exit-node=10.0.0.1</code>。<br> 在 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 策略路由状态。"
+670
View File
@@ -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. <code>10.0.0.0/24</code>."
msgstr "將本地物理網段發布到 Tailscale,例如:<code>10.0.0.0/24</code>。"
#: 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. <code>--exit-node=10.0.0.1</"
"code>. <br> %s for enabling settings upon the initiation of Tailscale."
msgstr "額外參數的列表。格式:--flags=value,例如 <code>--exit-node=10.0.0.1</code>。<br> 在 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 策略路由狀態。"
@@ -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 ''
@@ -0,0 +1,2 @@
config openclash 'settings'
option enabled '1'
@@ -0,0 +1,3 @@
config settings 'settings'
# Prefer Tailscale table 52 before mwan3 fwmark policy rules.
option enabled '0'
@@ -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'
+313
View File
@@ -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"
}
@@ -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
}
@@ -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
}
+86
View File
@@ -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
@@ -0,0 +1 @@
/etc/tailscale/luci-secrets.json
+230
View File
@@ -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
@@ -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
+523
View File
@@ -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:-<none>}"
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
+286
View File
@@ -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 <peer>... | --run <interval> <failure-log-interval> <peer>..." >&2
exit 2
;;
esac
File diff suppressed because it is too large. Load diff
+89
View File
@@ -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 <peer>" ""
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
+429
View File
@@ -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
+555
View File
@@ -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
@@ -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"
}
}
}
@@ -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" ]
}
}
}
+40
View File
@@ -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');
@@ -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
}
@@ -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);
});
@@ -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'
@@ -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);
});
+29
View File
@@ -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');
@@ -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);
});
@@ -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"
@@ -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"
@@ -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:-<empty>}"
[ "$(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:-<empty>}"
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:-<empty>}"
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:-<empty>}"
[ ! -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"
+214
View File
@@ -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'
@@ -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"
File diff suppressed because it is too large. Load diff
@@ -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" <<EOF
#!/bin/sh
if [ "\$*" = "-f $LEGACY_TAILSCALE_HOTPLUG" ]; then
exit 1
fi
exec /bin/rm "\$@"
EOF
chmod +x "$TMP_DIR/fail-bin/rm"
cp "$TMP_DIR/40-tailscale.before" "$LEGACY_TAILSCALE_HOTPLUG"
PREINST_PATH="$TMP_DIR/fail-bin:$PATH" run_preinst
[ -e "$LEGACY_TAILSCALE_HOTPLUG" ] || fail "remove-failure fixture must leave the legacy hook path in place"
[ ! -x "$LEGACY_TAILSCALE_HOTPLUG" ] || fail "pre-install hook must make the legacy starter non-executable when removal fails"
chmod 755 "$LEGACY_TAILSCALE_HOTPLUG"
rm -f "$LEGACY_TAILSCALE_HOTPLUG"
# Model package extraction replacing non-conffile files before the new package's
# standard UCI-defaults phase runs.
cat >"$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"
@@ -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"
@@ -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 <peer>"' "$missing_arg_output"
echo "tailscale_peer_probe tests passed"
@@ -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'
+279
View File
@@ -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'
+254
View File
@@ -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"
+220
View File
@@ -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'