Files
op-packages/luci-app-tailscale-community/htdocs/luci-static/resources/view/tailscale.js
T

700 lines
28 KiB
JavaScript

'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 callSetupFirewall = rpc.declare({ object: 'tailscale', method: 'setup_firewall' });
const callGetLogs = rpc.declare({ object: 'tailscale', method: 'get_logs' });
let map;
const tailscaleSettingsConf = [
[form.Flag, 'service_enabled', _('Enable Tailscale Service'), _('Enable or disable the Tailscale service. When disabled, the service will be stopped and the process will be killed.'), { rmempty: false }],
[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.ListValue, 'dns_mode', _('DNS Mode'), _('Controls how Tailscale DNS is handled.')+'<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, 'disable_fw_config', _('Disable Firewall Configuration'), _('Disable Tailscale netfilter auto-configuration (--netfilter-mode=off).'), { rmempty: false }],
[form.Flag, 'enable_relay', _('Enable Peer Relay'), _('Enable this device as a Peer Relay server. Requires a public IP and an UDP port open on the router.'), { 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(), { status: 'stopped', peers: {} }).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)))
]);
return statusTable;
}
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 renderPeerRows(status, query) {
if (!status || !status.hasOwnProperty('status')) {
return [E('tr', { 'class': 'cbi-section-table-row' }, [
E('td', { 'class': 'cbi-value-field', 'colspan': 8 }, E('em', {}, _('Collecting data ...')))
])];
}
if (status.status != 'running') {
return [E('tr', { 'class': 'cbi-section-table-row' }, [
E('td', { 'class': 'cbi-value-field', 'colspan': 8 }, E('em', {}, _('Tailscale status error')))
])];
}
if (Object.keys(regionCodeMap).length === 0) {
initializeRegionMap();
}
const peers = status.peers;
if (!peers || Object.keys(peers).length === 0) {
return [E('tr', { 'class': 'cbi-section-table-row' }, [
E('td', { 'class': 'cbi-value-field', 'colspan': 8 }, E('p', {}, _('No peer devices found.')))
])];
}
const q = (query || '').toLowerCase().trim();
const td_style = 'padding-right: 20px;';
const rows = [];
for (const id in peers) {
const peer = peers[id];
const hostname = peer.hostname || '';
const ip = peer.ip || '';
const ostype = peer.ostype || '';
if (q && !hostname.toLowerCase().includes(q) &&
!ip.toLowerCase().includes(q) &&
!ostype.toLowerCase().includes(q)) {
continue;
}
rows.push(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', {}, hostname + (peer.exit_node_option ? ' (ExNode)' : ''))),
E('td', { 'class': 'cbi-value-field', 'style': td_style }, ip || 'N/A'),
E('td', { 'class': 'cbi-value-field', 'style': td_style }, 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))
]));
}
if (rows.length === 0) {
return [E('tr', { 'class': 'cbi-section-table-row' }, [
E('td', { 'class': 'cbi-value-field', 'colspan': 8 }, E('p', {}, _('No peer devices found.')))
])];
}
return rows;
}
function renderDevices(status) {
const thead = E('thead', {}, [
E('tr', {
'class': 'tr cbi-section-table-titles',
'style': 'position: sticky; top: 0; z-index: 10; background-color: var(--background-color, #fff);'
}, peerTableHeaders.map(function(header) {
let th_style = 'padding-right: 20px; text-align: left;';
if (header.style) {
th_style += header.style;
}
return E('th', { 'class': 'th cbi-table-cell', 'style': th_style }, header.text);
}))
]);
const tbody = E('tbody', { 'id': 'tailscale_devices_tbody' }, renderPeerRows(status, ''));
const table = E('table', { 'class': 'table cbi-table' }, [
thead,
tbody
]);
const tableContainer = E('div', {
'id': 'tailscale_devices_table_container',
'style': 'max-height: 480px; overflow-y: auto; overflow-x: auto;'
}, [table]);
const searchInput = E('input', {
'id': 'tailscale_devices_filter',
'type': 'text',
'class': 'cbi-input-text',
'placeholder': _('Filter peers by name, IP, or OS...'),
'style': 'width: 100%; box-sizing: border-box;'
});
searchInput.addEventListener('input', function() {
const targetBody = document.getElementById('tailscale_devices_tbody');
if (targetBody && lastDevicesStatus) {
targetBody.replaceChildren(...renderPeerRows(lastDevicesStatus, this.value));
}
});
const filterContainer = E('div', {
'style': 'display: flex; align-items: center; gap: 0.5em; margin-bottom: 0.5em;'
}, [
E('label', {
'for': 'tailscale_devices_filter',
'style': 'white-space: nowrap; font-weight: bold;'
}, _('Filter:')),
E('div', { 'style': 'flex: 1;' }, [searchInput])
]);
return E('div', { 'style': 'width: 100%;' }, [
filterContainer,
tableContainer
]);
}
return view.extend({
load() {
return Promise.all([
L.resolveDefault(callGetStatus(), { status: 'stopped', 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', '1');
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', 'dns_mode', 'disabled');
uci.set('tailscale', 'settings', 'disable_fw_config', '0');
uci.set('tailscale', 'settings', 'daemon_reduce_memory', '0');
uci.set('tailscale', 'settings', 'daemon_mtu', '');
return uci.save();
}
}).then(function() {
// 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');
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);
}
lastDevicesStatus = res;
const devicesTbody = document.getElementById("tailscale_devices_tbody");
if (devicesTbody) {
const filterInput = document.getElementById("tailscale_devices_filter");
const currentQuery = filterInput ? filterInput.value : '';
devicesTbody.replaceChildren(...renderPeerRows(res, currentQuery));
}
// 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', null);
s.dynamic = true;
// Create the "General Settings" tab and apply tailscaleSettingsConf
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. 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. 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() {
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');
}).then(function() {
return new Promise(function(resolve) {
window.setTimeout(resolve, 3000);
});
});
};
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);
};
s.tab('devices', _('Devices List'));
const devicesSection = s.taboption('devices', form.DummyValue, '_devices');
devicesSection.render = function () {
lastDevicesStatus = status;
return E('div', { 'id': 'tailscale_devices_display', 'class': 'cbi-value' }, renderDevices(status));
};
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'))));
}
});
};
// 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 saves UCI changes then applies them via the
// standard OpenWrt apply mechanism, which triggers /etc/init.d/tailscale-settings
// and provides automatic rollback protection if the device becomes unreachable.
handleSaveApply(ev, mode) {
return map.save().then(function () {
return ui.changes.apply(mode == '0');
});
},
handleSave: null,
handleReset: null
});