mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-10-31 11:45:13 +08:00
🗽 Sync 2026-09-29 08:05:54
This commit is contained in:
1 parent
768fff33e3
commit
1f91dc7297
12 files changed
+4282
No files matched your search
@@ -0,0 +1,695 @@
|
||||
'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 renderDevices(status) {
|
||||
if (!status || !status.hasOwnProperty('status')) {
|
||||
return E('em', {}, _('Collecting data ...'));
|
||||
}
|
||||
|
||||
if (status.status != 'running') {
|
||||
return 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('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') }
|
||||
];
|
||||
|
||||
const existingFilter = document.getElementById('tailscale_devices_filter');
|
||||
const currentQuery = existingFilter ? existingFilter.value : '';
|
||||
|
||||
const searchInput = E('input', {
|
||||
'id': 'tailscale_devices_filter',
|
||||
'class': 'cbi-input-text',
|
||||
'type': 'text',
|
||||
'placeholder': _('Filter by hostname, IP, OS...'),
|
||||
'style': 'margin-bottom: 8px; width: 100%; max-width: 320px;'
|
||||
});
|
||||
if (currentQuery) {
|
||||
searchInput.value = currentQuery;
|
||||
}
|
||||
|
||||
const rows = Object.entries(peers).map(([peerid, peer]) => {
|
||||
const td_style = 'padding-right: 20px;';
|
||||
const hostname = peer.hostname || '';
|
||||
const ip = peer.ip || '';
|
||||
const ostype = peer.ostype || '';
|
||||
const searchText = `${hostname} ${ip} ${ostype}`.toLowerCase();
|
||||
|
||||
const tr = E('tr', {
|
||||
'class': 'cbi-rowstyle-1',
|
||||
'data-search-text': searchText
|
||||
}, [
|
||||
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 (currentQuery && !searchText.includes(currentQuery.toLowerCase().trim())) {
|
||||
tr.style.display = 'none';
|
||||
}
|
||||
|
||||
return tr;
|
||||
});
|
||||
|
||||
searchInput.addEventListener('input', function(ev) {
|
||||
const q = (ev.target.value || '').toLowerCase().trim();
|
||||
for (const tr of rows) {
|
||||
const text = tr.getAttribute('data-search-text') || '';
|
||||
tr.style.display = (!q || text.includes(q)) ? '' : 'none';
|
||||
}
|
||||
});
|
||||
|
||||
if (existingFilter && document.activeElement === existingFilter) {
|
||||
requestAnimationFrame(() => {
|
||||
searchInput.focus();
|
||||
const valLen = searchInput.value.length;
|
||||
searchInput.setSelectionRange(valLen, valLen);
|
||||
});
|
||||
}
|
||||
|
||||
const existingContainer = document.getElementById('tailscale_devices_table_container');
|
||||
const scrollPos = existingContainer ? existingContainer.scrollTop : 0;
|
||||
|
||||
const table = E('table', { 'class': 'cbi-table' }, [
|
||||
E('tr', { 'class': 'cbi-table-header' }, peerTableHeaders.map(header => {
|
||||
let th_style = 'padding-right: 20px; text-align: left; position: sticky; top: 0; background: inherit; z-index: 1;';
|
||||
if (header.style) {
|
||||
th_style += header.style;
|
||||
}
|
||||
return E('th', { 'class': 'cbi-table-cell', 'style': th_style }, header.text);
|
||||
})),
|
||||
...rows
|
||||
]);
|
||||
|
||||
const tableContainer = E('div', {
|
||||
'id': 'tailscale_devices_table_container',
|
||||
'style': 'max-height: 480px; overflow-y: auto; overflow-x: auto; border: 1px solid #ddd; border-radius: 3px;'
|
||||
}, [table]);
|
||||
|
||||
if (scrollPos > 0) {
|
||||
requestAnimationFrame(() => {
|
||||
tableContainer.scrollTop = scrollPos;
|
||||
});
|
||||
}
|
||||
|
||||
return E('div', { 'style': 'width: 100%;' }, [
|
||||
searchInput,
|
||||
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);
|
||||
}
|
||||
|
||||
const devicesView = document.getElementById("tailscale_devices_display");
|
||||
if (devicesView) {
|
||||
devicesView.replaceChildren(renderDevices(res));
|
||||
}
|
||||
|
||||
// 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 () {
|
||||
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
|
||||
});
|
||||
Reference in new issue
Block a user