mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-11-02 13:23:56 +08:00
⛅ Sync 2026-10-03 02:37:08
This commit is contained in:
1 parent
1b126bfea3
commit
a5bb91e6ed
24 files changed
+5536
-990
No files matched your search
@@ -1,157 +0,0 @@
|
||||
/* SPDX-License-Identifier: GPL-3.0-only
|
||||
*
|
||||
* Copyright (C) 2022 ImmortalWrt.org
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
'require form';
|
||||
'require poll';
|
||||
'require rpc';
|
||||
'require uci';
|
||||
'require view';
|
||||
'require tools.widgets as widgets';
|
||||
|
||||
const callServiceList = rpc.declare({
|
||||
object: 'service',
|
||||
method: 'list',
|
||||
params: ['name'],
|
||||
expect: { '': {} }
|
||||
});
|
||||
|
||||
function getServiceStatus() {
|
||||
return L.resolveDefault(callServiceList('zerotier'), {}).then(function(res) {
|
||||
let isRunning = false;
|
||||
try {
|
||||
isRunning = res['zerotier']['instances']['instance1']['running'];
|
||||
} catch (e) { }
|
||||
return isRunning;
|
||||
});
|
||||
}
|
||||
|
||||
function renderStatus(isRunning) {
|
||||
let spanTemp = '<em><span style="color:%s"><strong>%s %s</strong></span></em>';
|
||||
let renderHTML;
|
||||
if (isRunning)
|
||||
renderHTML = String.format(spanTemp, 'green', _('ZeroTier'), _('RUNNING'));
|
||||
else
|
||||
renderHTML = String.format(spanTemp, 'red', _('ZeroTier'), _('NOT RUNNING'));
|
||||
|
||||
return renderHTML;
|
||||
}
|
||||
|
||||
return view.extend({
|
||||
render: function() {
|
||||
let m, s, o;
|
||||
|
||||
m = new form.Map('zerotier', _('ZeroTier'),
|
||||
_('ZeroTier is an open source, cross-platform and easy to use virtual LAN.'));
|
||||
|
||||
s = m.section(form.TypedSection);
|
||||
s.anonymous = true;
|
||||
s.render = function() {
|
||||
poll.add(function() {
|
||||
return L.resolveDefault(getServiceStatus()).then(function(res) {
|
||||
let view = document.getElementById('service_status');
|
||||
view.innerHTML = renderStatus(res);
|
||||
});
|
||||
});
|
||||
|
||||
return E('div', { class: 'cbi-section', id: 'status_bar' }, [
|
||||
E('p', { id: 'service_status' }, _('Collecting data…'))
|
||||
]);
|
||||
}
|
||||
|
||||
s = m.section(form.NamedSection, 'global', 'zerotier', _('Global configuration'));
|
||||
|
||||
o = s.option(form.Flag, 'enabled', _('Enable'));
|
||||
|
||||
o = s.option(form.Value, 'port', _('Listen port'));
|
||||
o.datatype = 'port';
|
||||
|
||||
o = s.option(form.Value, 'secret', _('Client secret'));
|
||||
o.password = true;
|
||||
|
||||
o = s.option(form.Value, 'local_conf_path', _('Local config path'),
|
||||
_('Path of the optional file local.conf (see <a target="_blank" href="%s">documentation</a>).').format(
|
||||
'https://docs.zerotier.com/config/#local-configuration-options'));
|
||||
o.value('/etc/zerotier.conf');
|
||||
|
||||
o = s.option(form.Value, 'config_path', _('Config path'),
|
||||
_('Persistent configuration directory (to keep other configurations such as controller or moons, etc.).'));
|
||||
o.value('/etc/zerotier');
|
||||
|
||||
o = s.option(form.Flag, 'copy_config_path', _('Copy config path'),
|
||||
_('Copy the contents of the persistent configuration directory to memory instead of linking it, this avoids writing to flash.'));
|
||||
o.depends({'config_path': '', '!reverse': true});
|
||||
|
||||
o = s.option(form.Flag, 'fw_allow_input', _('Allow input traffic'),
|
||||
_('Allow input traffic to the ZeroTier daemon.'));
|
||||
|
||||
o = s.option(form.Button, '_panel', _('ZeroTier Central'),
|
||||
_('Create or manage your ZeroTier network, and auth clients who could access.'));
|
||||
o.inputtitle = _('Open website');
|
||||
o.inputstyle = 'apply';
|
||||
o.onclick = function() {
|
||||
window.open("https://my.zerotier.com/network", '_blank');
|
||||
}
|
||||
|
||||
s = m.section(form.GridSection, 'network', _('Network configuration'));
|
||||
s.addremove = true;
|
||||
s.rowcolors = true;
|
||||
s.sortable = true;
|
||||
s.nodescriptions = true;
|
||||
|
||||
o = s.option(form.Flag, 'enabled', _('Enable'));
|
||||
o.default = o.enabled;
|
||||
o.editable = true;
|
||||
|
||||
o = s.option(form.Value, 'id', _('Network ID'));
|
||||
o.rmempty = false;
|
||||
o.width = '20%';
|
||||
|
||||
o = s.option(form.Flag, 'allow_managed', _('Allow managed IP/route'),
|
||||
_('Allow ZeroTier to set IP addresses and routes (local/private ranges only).'));
|
||||
o.default = o.enabled;
|
||||
o.editable = true;
|
||||
|
||||
o = s.option(form.Flag, 'allow_global', _('Allow global IP/route'),
|
||||
_('Allow ZeroTier to set global/public/not-private range IPs and routes.'));
|
||||
o.editable = true;
|
||||
|
||||
o = s.option(form.Flag, 'allow_default', _('Allow default route'),
|
||||
_('Allow ZeroTier to set the default route on the system.'));
|
||||
o.editable = true;
|
||||
|
||||
o = s.option(form.Flag, 'allow_dns', _('Allow DNS'),
|
||||
_('Allow ZeroTier to set DNS servers.'));
|
||||
o.editable = true;
|
||||
|
||||
o = s.option(form.Flag, 'fw_allow_input', _('Allow input'),
|
||||
_('Allow input traffic from the ZeroTier network.'));
|
||||
o.editable = true;
|
||||
|
||||
o = s.option(form.Flag, 'fw_allow_forward', _('Allow forward'),
|
||||
_('Allow forward traffic from/to the ZeroTier network.'));
|
||||
o.editable = true;
|
||||
|
||||
o = s.option(widgets.DeviceSelect, 'fw_forward_ifaces', _('Forward interfaces'),
|
||||
_('Leave empty for all.'));
|
||||
o.multiple = true;
|
||||
o.noaliases = true;
|
||||
o.depends('fw_allow_forward', '1');
|
||||
o.modalonly = true;
|
||||
|
||||
o = s.option(form.Flag, 'fw_allow_masq', _('Masquerading'),
|
||||
_('Enable network address and port translation (NAT) for outbound traffic for this network.'));
|
||||
o.editable = true;
|
||||
|
||||
o = s.option(widgets.DeviceSelect, 'fw_masq_ifaces', _('Masquerade interfaces'),
|
||||
_('Leave empty for all.'));
|
||||
o.multiple = true;
|
||||
o.noaliases = true;
|
||||
o.depends('fw_allow_masq', '1');
|
||||
o.modalonly = true;
|
||||
|
||||
return m.render();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,320 @@
|
||||
'use strict';
|
||||
'require view';
|
||||
'require form';
|
||||
'require uci';
|
||||
'require ui';
|
||||
'require poll';
|
||||
'require rpc';
|
||||
|
||||
var callLuciZerotierStatus = rpc.declare({
|
||||
object: 'luci-zerotier',
|
||||
method: 'status'
|
||||
});
|
||||
|
||||
var callLuciZerotierReload = rpc.declare({
|
||||
object: 'luci-zerotier',
|
||||
method: 'reload'
|
||||
});
|
||||
|
||||
var callLuciZerotierSync = rpc.declare({
|
||||
object: 'luci-zerotier',
|
||||
method: 'sync_config',
|
||||
params: ['local_conf_path']
|
||||
});
|
||||
|
||||
// Mask all but the first 4 and last 4 chars of a secret for display.
|
||||
// Used for the `secret` field so the value reaching the browser cannot
|
||||
// be directly exfiltrated, while still giving the user a visual cue that
|
||||
// a secret is configured and roughly what it looks like.
|
||||
var maskSecret = function(s) {
|
||||
if (!s) return '';
|
||||
if (s.length <= 8) return '••••••••';
|
||||
return s.substring(0, 4) + '••••••••' + s.substring(s.length - 4);
|
||||
};
|
||||
|
||||
return view.extend({
|
||||
load: function() {
|
||||
return Promise.all([
|
||||
uci.load('zerotier')
|
||||
]);
|
||||
},
|
||||
|
||||
render: function() {
|
||||
var m, s, o;
|
||||
|
||||
m = new form.Map('zerotier', _('ZeroTier'),
|
||||
_('Zerotier is an open source, cross-platform and easy to use virtual LAN'));
|
||||
|
||||
// Global settings section
|
||||
s = m.section(form.NamedSection, 'global', 'zerotier', _('Global Settings'));
|
||||
s.anonymous = true;
|
||||
s.addremove = false;
|
||||
|
||||
s.tab('main', _('General options'));
|
||||
s.tab('more', _('Advanced options'));
|
||||
|
||||
// General options
|
||||
o = s.taboption('main', form.Flag, 'enabled', _('Enable'));
|
||||
o.default = '0';
|
||||
o.rmempty = false;
|
||||
|
||||
o = s.taboption('main', form.Flag, 'nat', _('Auto NAT Clients'));
|
||||
o.description = _('Allow zerotier clients access your LAN network');
|
||||
o.default = '0';
|
||||
o.rmempty = false;
|
||||
|
||||
o = s.taboption('main', form.DummyValue, 'opennewwindow', '');
|
||||
o.render = function() {
|
||||
return E('div', { 'class': 'cbi-value-field' }, [
|
||||
E('input', {
|
||||
'type': 'button',
|
||||
'class': 'cbi-button cbi-button-apply',
|
||||
'value': 'Zerotier.com',
|
||||
'onclick': function() {
|
||||
window.open('https://my.zerotier.com/network', '_blank');
|
||||
}
|
||||
})
|
||||
]);
|
||||
};
|
||||
o.description = _('Create or manage your zerotier network, and auth clients who could access');
|
||||
|
||||
// Advanced options
|
||||
o = s.taboption('more', form.Value, 'port', _('Port'));
|
||||
o.description = _('Port of zerotier service, default 9993');
|
||||
o.placeholder = '9993';
|
||||
o.validate = function(section_id, value) {
|
||||
if (!value) return true;
|
||||
// luci.js form.Value has no `datatype` hook, so it validated
|
||||
// nothing and a bad value reached the daemon as "-p<value>".
|
||||
// 0 is valid and documented upstream as "pick a random port".
|
||||
if (!/^\d+$/.test(value))
|
||||
return _('Port must be a number');
|
||||
if (parseInt(value, 10) > 65535)
|
||||
return _('Port must be between 0 and 65535');
|
||||
return true;
|
||||
};
|
||||
|
||||
o = s.taboption('more', form.TextValue, 'secret', _('Secret'));
|
||||
o.description = _('Secret of zerotier client. Displayed masked for security; paste a new identity.secret to replace it, or clear the field to remove it (a new identity is generated on next start).');
|
||||
o.rows = 3;
|
||||
o.cfgvalue = function(section_id) {
|
||||
var val = uci.get('zerotier', section_id, 'secret');
|
||||
return val ? maskSecret(val) : '';
|
||||
};
|
||||
o.write = function(section_id, formvalue) {
|
||||
var current = uci.get('zerotier', section_id, 'secret');
|
||||
if (current && formvalue === maskSecret(current)) {
|
||||
return;
|
||||
}
|
||||
if (!formvalue) {
|
||||
if (current) {
|
||||
uci.remove('zerotier', section_id, 'secret');
|
||||
}
|
||||
return;
|
||||
}
|
||||
uci.set('zerotier', section_id, 'secret', formvalue);
|
||||
};
|
||||
|
||||
o = s.taboption('more', form.Value, 'local_conf_path', _('Local configuration'));
|
||||
o.description = _('Path to the local.conf. Imported into the configuration folder on save & apply.');
|
||||
o.placeholder = '/etc/zerotier.conf';
|
||||
o.validate = function(section_id, value) {
|
||||
if (!value) return true;
|
||||
// /etc/zerotier.conf is the upstream zerotier package's conventional
|
||||
// single-file location (see the commented option in its stock config)
|
||||
if (!value.match(/^(\/etc\/zerotier\.conf$|\/etc\/zerotier(\/|$)|\/tmp(\/|$))/)) {
|
||||
return _('Path must be within /etc/zerotier, or /tmp');
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
o = s.taboption('more', form.Value, 'config_path', _('Configuration folder'));
|
||||
o.description = _('Persistent configuration folder (for ZT controller mode)');
|
||||
o.placeholder = '/etc/zerotier';
|
||||
o.validate = function(section_id, value) {
|
||||
if (!value) return true;
|
||||
// Persistent locations only: the daemon refuses to start when
|
||||
// config_path is missing, so a volatile /tmp path would leave it
|
||||
// dead after every reboot
|
||||
if (!value.match(/^\/etc\/zerotier(\/|$)/)) {
|
||||
return _('Path must be within /etc/zerotier');
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
o = s.taboption('more', form.Flag, 'copy_config_path', _('Copy configuration folder'));
|
||||
o.description = _('Copy configuration folder to RAM to prevent writing to flash (for ZT controller mode)');
|
||||
|
||||
o = s.taboption('more', form.DummyValue, 'backup_now', '');
|
||||
o.render = function() {
|
||||
return E('div', { 'class': 'cbi-value-field' }, [
|
||||
E('button', {
|
||||
'class': 'cbi-button cbi-button-action',
|
||||
'click': function(ev) {
|
||||
var btn = ev.currentTarget;
|
||||
btn.disabled = true;
|
||||
btn.textContent = _('Backing up...');
|
||||
L.resolveDefault(callLuciZerotierSync(), {}).then(function(res) {
|
||||
if (res && res.code === 0) {
|
||||
ui.addTimeLimitedNotification(null, E('p', _('Runtime state backed up to persistent storage')), 5000, 'info');
|
||||
} else {
|
||||
ui.addTimeLimitedNotification(null, E('p', _('Backup failed') + (res && res.stderr ? ': ' + res.stderr : '')), 10000, 'warning');
|
||||
}
|
||||
}).catch(function() {
|
||||
ui.addTimeLimitedNotification(null, E('p', _('Backup request failed')), 10000, 'warning');
|
||||
}).finally(function() {
|
||||
btn.disabled = false;
|
||||
btn.textContent = _('Backup Now');
|
||||
});
|
||||
}
|
||||
}, [_('Backup Now')])
|
||||
]);
|
||||
};
|
||||
o.description = _('Save the current runtime state to the persistent configuration folder. Recommended after adding a moon with zerotier orbit, and essential for controller nodes (member data lives only in the runtime directory).');
|
||||
|
||||
// Networks section
|
||||
s = m.section(form.GridSection, 'network', _('Networks'));
|
||||
s.anonymous = true;
|
||||
s.addremove = true;
|
||||
s.sortable = true;
|
||||
|
||||
o = s.option(form.Value, 'id', _('Network ID'));
|
||||
o.rmempty = false;
|
||||
o.validate = function(section_id, value) {
|
||||
if (value && !/^[0-9a-fA-F]{16}$/.test(value)) {
|
||||
return _('Network ID must be 16 hex characters (0-9, a-f)');
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
o = s.option(form.Flag, 'allow_managed', _('Allow Managed'));
|
||||
o.default = '1';
|
||||
|
||||
o = s.option(form.Flag, 'allow_global', _('Allow Global'));
|
||||
o.default = '0';
|
||||
|
||||
o = s.option(form.Flag, 'allow_default', _('Allow Default Route'));
|
||||
o.default = '0';
|
||||
|
||||
o = s.option(form.Flag, 'allow_dns', _('Allow DNS'));
|
||||
o.default = '0';
|
||||
|
||||
return m.render().then(function(mapEl) {
|
||||
var infoDiv = E('div', {
|
||||
'id': 'zerotier_info',
|
||||
'style': 'margin: 0 16px; padding: 0; color: inherit; font-size: 16px; line-height: 1.6;'
|
||||
}, [
|
||||
E('span', {'id': 'zerotier_status'}, [
|
||||
E('em', {}, [_('Collecting data...')])
|
||||
]),
|
||||
E('br', {}),
|
||||
E('span', {'id': 'zerotier_identity'}, [
|
||||
E('em', {}, [_('Collecting identity...')])
|
||||
])
|
||||
]);
|
||||
|
||||
var descDiv = mapEl.querySelector('.cbi-map-descr');
|
||||
if (descDiv && descDiv.parentNode) {
|
||||
descDiv.parentNode.insertBefore(infoDiv, descDiv.nextSibling);
|
||||
} else {
|
||||
mapEl.insertBefore(infoDiv, mapEl.firstChild);
|
||||
}
|
||||
|
||||
var updateStatus = function() {
|
||||
return L.resolveDefault(callLuciZerotierStatus(), {}).then(function(res) {
|
||||
var statusEl = document.getElementById('zerotier_status');
|
||||
if (statusEl && res) {
|
||||
var runningClass = res.running ? 'green' : 'red';
|
||||
var runningText = res.running ? _('RUNNING') : _('NOT RUNNING');
|
||||
statusEl.textContent = '';
|
||||
statusEl.appendChild(E('b', {
|
||||
'style': 'color:' + runningClass
|
||||
}, ['ZeroTier ' + runningText]));
|
||||
}
|
||||
updateIdentity(res);
|
||||
}).catch(function(err) {
|
||||
var statusEl = document.getElementById('zerotier_status');
|
||||
if (statusEl) {
|
||||
statusEl.textContent = '';
|
||||
statusEl.appendChild(E('b', {
|
||||
'style': 'color:red'
|
||||
}, ['ZeroTier ' + _('NOT RUNNING')]));
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
var updateIdentity = function(res) {
|
||||
var identityEl = document.getElementById('zerotier_identity');
|
||||
if (!identityEl) return;
|
||||
identityEl.textContent = '';
|
||||
identityEl.appendChild(E('span', {}, [
|
||||
_('Address') + ': '
|
||||
]));
|
||||
if (res && res.identity) {
|
||||
identityEl.appendChild(E('b', {
|
||||
'style': 'font-family: monospace; color: inherit;'
|
||||
}, [res.identity]));
|
||||
} else {
|
||||
identityEl.appendChild(E('b', {
|
||||
'style': 'color: gray;'
|
||||
}, ['-']));
|
||||
}
|
||||
};
|
||||
|
||||
// Service state only changes on start/stop, so 10s is plenty.
|
||||
poll.add(updateStatus, 10);
|
||||
|
||||
updateStatus();
|
||||
|
||||
return mapEl;
|
||||
});
|
||||
},
|
||||
|
||||
handleSaveApply: function(ev, mode) {
|
||||
return this.handleSave(ev).then(function() {
|
||||
// Persist runtime state BEFORE the apply commits: committing the
|
||||
// zerotier config fires procd's reload trigger for the daemon,
|
||||
// whose upstream stop_service wipes /var/lib/zerotier-one. The
|
||||
// ucitrack/procd trigger order is not deterministic, so syncing
|
||||
// here — after staging (handleSave) but before the commit —
|
||||
// is the only ordering-safe point. The pending form value of
|
||||
// local_conf_path is passed along so its convergence uses the
|
||||
// about-to-be-committed value, not the previous one.
|
||||
var lc = uci.get('zerotier', 'global', 'local_conf_path') || '';
|
||||
return L.resolveDefault(callLuciZerotierSync(lc), {});
|
||||
}).then(function() {
|
||||
// Reload the service only AFTER the apply has committed the staged
|
||||
// configuration: Map.save() alone merely writes to the ubus session
|
||||
// delta, so reloading earlier would read the previous config (and the
|
||||
// notification would describe a state that is not in effect yet).
|
||||
return ui.changes.apply(mode == '0');
|
||||
}).then(function() {
|
||||
return L.resolveDefault(callLuciZerotierReload(), {});
|
||||
}).then(function(reloadResult) {
|
||||
if (reloadResult && reloadResult.code === 0) {
|
||||
var natValue = uci.get('zerotier', 'global', 'nat') || '0';
|
||||
var natMsg = natValue === '1' ? _('Auto NAT: Enabled') : _('Auto NAT: Disabled');
|
||||
ui.addTimeLimitedNotification(null, E('p', natMsg), 5000, 'info');
|
||||
} else {
|
||||
var errorMsg = reloadResult && reloadResult.stderr ? reloadResult.stderr : _('Unknown error');
|
||||
ui.addTimeLimitedNotification(null, E('p', _('Service reload failed: ') + errorMsg), 10000, 'warning');
|
||||
}
|
||||
}).catch(function(error) {
|
||||
ui.addTimeLimitedNotification(null, E('p', _('Failed to reload service, please run manually: /etc/init.d/luci-zerotier reload')), 10000, 'warning');
|
||||
});
|
||||
},
|
||||
|
||||
handleSave: function(ev) {
|
||||
var tasks = [];
|
||||
|
||||
document.querySelectorAll('.cbi-map').forEach(function(map) {
|
||||
tasks.push(DOM.callClassMethod(map, 'save'));
|
||||
});
|
||||
|
||||
return Promise.all(tasks);
|
||||
}
|
||||
});
|
||||
|
||||
reload_service() {
|
||||
restart
|
||||
}
|
||||
@@ -0,0 +1,733 @@
|
||||
'use strict';
|
||||
'require view';
|
||||
'require ui';
|
||||
'require poll';
|
||||
'require rpc';
|
||||
|
||||
var callLuciZerotierStatus = rpc.declare({
|
||||
object: 'luci-zerotier',
|
||||
method: 'status'
|
||||
});
|
||||
|
||||
var callLuciZerotierNetworksPeers = rpc.declare({
|
||||
object: 'luci-zerotier',
|
||||
method: 'get_networks_peers'
|
||||
});
|
||||
|
||||
var callLuciZerotierPing = rpc.declare({
|
||||
object: 'luci-zerotier',
|
||||
method: 'ping_networks'
|
||||
});
|
||||
|
||||
var callLuciZerotierListMoons = rpc.declare({
|
||||
object: 'luci-zerotier',
|
||||
method: 'list_moons'
|
||||
});
|
||||
|
||||
var callLuciZerotierOrbitMoon = rpc.declare({
|
||||
object: 'luci-zerotier',
|
||||
method: 'orbit_moon',
|
||||
params: ['moon_id', 'seed']
|
||||
});
|
||||
|
||||
var callLuciZerotierDeorbitMoon = rpc.declare({
|
||||
object: 'luci-zerotier',
|
||||
method: 'deorbit_moon',
|
||||
params: ['moon_id']
|
||||
});
|
||||
|
||||
// Called with no argument it falls back to the committed local_conf_path,
|
||||
// i.e. it does exactly what the "Backup Now" button does.
|
||||
var callLuciZerotierSync = rpc.declare({
|
||||
object: 'luci-zerotier',
|
||||
method: 'sync_config'
|
||||
});
|
||||
|
||||
return view.extend({
|
||||
load: function() {
|
||||
return Promise.resolve();
|
||||
},
|
||||
|
||||
render: function() {
|
||||
var container = E('div', { 'class': 'cbi-map' }, [
|
||||
E('h2', {}, [_('ZeroTier'), ' - ', _('Interface Info')]),
|
||||
E('div', { 'class': 'cbi-section', 'id': 'zt_info_section' }, [
|
||||
E('div', { 'id': 'zt_identity', 'style': 'margin-bottom: 10px;' }),
|
||||
E('div', { 'id': 'zt_actions', 'style': 'margin-bottom: 10px; display: flex; align-items: center; gap: 10px;' }, [
|
||||
E('button', {
|
||||
'class': 'cbi-button cbi-button-action important',
|
||||
'id': 'zt_ping_btn',
|
||||
'click': function() {
|
||||
var btn = document.getElementById('zt_ping_btn');
|
||||
var statusEl = document.getElementById('zt_ping_status');
|
||||
var resultEl = document.getElementById('zt_ping_result');
|
||||
if (btn && !btn.disabled) {
|
||||
btn.disabled = true;
|
||||
btn.textContent = _('Pinging...');
|
||||
if (statusEl) {
|
||||
statusEl.textContent = '';
|
||||
}
|
||||
if (resultEl) {
|
||||
while (resultEl.firstChild) {
|
||||
resultEl.removeChild(resultEl.firstChild);
|
||||
}
|
||||
}
|
||||
L.resolveDefault(callLuciZerotierPing(), {}).then(function(res) {
|
||||
if (statusEl) {
|
||||
if (res && res.result && res.result !== 'no_result') {
|
||||
var lines = res.result.split('|');
|
||||
var okCount = 0;
|
||||
var displayLines = [];
|
||||
for (var i = 0; i < lines.length; i++) {
|
||||
var line = lines[i].trim();
|
||||
if (!line) continue;
|
||||
if (line.indexOf('OK:') === 0) {
|
||||
okCount++;
|
||||
displayLines.push(line.substring(3));
|
||||
}
|
||||
}
|
||||
statusEl.style.color = okCount > 0 ? 'green' : 'orange';
|
||||
statusEl.textContent = _('Online') + ': ' + okCount;
|
||||
|
||||
if (resultEl) {
|
||||
while (resultEl.firstChild) {
|
||||
resultEl.removeChild(resultEl.firstChild);
|
||||
}
|
||||
if (okCount > 0) {
|
||||
var gridEl = E('div', {
|
||||
'style': 'display: flex; flex-wrap: wrap; gap: 4px; margin-top: 5px;'
|
||||
});
|
||||
for (var j = 0; j < displayLines.length; j++) {
|
||||
gridEl.appendChild(E('span', {
|
||||
'style': 'font-family: monospace; font-size: 12px; padding: 2px 6px;'
|
||||
}, [displayLines[j]]));
|
||||
}
|
||||
resultEl.appendChild(gridEl);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (res && res.stderr === 'scan already in progress') {
|
||||
statusEl.style.color = 'orange';
|
||||
statusEl.textContent = _('Scan already in progress, please wait');
|
||||
} else {
|
||||
statusEl.textContent = res && res.stderr ? res.stderr : _('No IPs to ping');
|
||||
}
|
||||
}
|
||||
}
|
||||
if (btn) {
|
||||
btn.disabled = false;
|
||||
btn.textContent = _('Ping All');
|
||||
}
|
||||
}).catch(function(err) {
|
||||
if (statusEl) {
|
||||
statusEl.textContent = _('Error');
|
||||
}
|
||||
if (btn) {
|
||||
btn.disabled = false;
|
||||
btn.textContent = _('Ping All');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [_('Ping All')]),
|
||||
E('span', { 'id': 'zt_ping_status', 'style': 'margin-left: 10px;' }),
|
||||
]),
|
||||
E('div', { 'id': 'zt_ping_result' }),
|
||||
E('h3', {}, _('Networks')),
|
||||
E('div', { 'id': 'zt_networks' }, [
|
||||
E('em', {}, [_('Loading...')])
|
||||
]),
|
||||
E('h3', { 'style': 'margin-top: 16px;' }, _('Peers')),
|
||||
E('div', { 'id': 'zt_peers' }, [
|
||||
E('em', {}, [_('Loading...')])
|
||||
]),
|
||||
E('h3', { 'style': 'margin-top: 16px;' }, _('Moons')),
|
||||
E('div', {
|
||||
'style': 'color: #888; font-size: 12px; margin-bottom: 8px;'
|
||||
}, [_('Orbiting a moon adds its roots to this node\'s root set. ZeroTier keeps using the planetary roots as fallback. Note that upstream ZeroTier no longer recommends private moons and does not support them under its SLA.')]),
|
||||
E('div', { 'id': 'zt_moon_actions', 'style': 'margin-bottom: 10px; display: flex; align-items: center; gap: 10px;' }, [
|
||||
E('button', {
|
||||
'class': 'cbi-button cbi-button-add',
|
||||
'id': 'zt_moon_add_btn',
|
||||
'click': function() {
|
||||
var f = document.getElementById('zt_moon_form');
|
||||
if (f) f.style.display = (f.style.display === 'none' || !f.style.display) ? 'block' : 'none';
|
||||
}
|
||||
}, [_('Add Moon')]),
|
||||
E('span', { 'id': 'zt_moon_status' })
|
||||
]),
|
||||
E('div', { 'id': 'zt_moon_form', 'style': 'display: none; margin-bottom: 10px;' }, [
|
||||
E('div', { 'class': 'cbi-value' }, [
|
||||
E('label', { 'class': 'cbi-value-title' }, [_('Moon ID')]),
|
||||
E('div', { 'class': 'cbi-value-field' }, [
|
||||
E('input', {
|
||||
'type': 'text',
|
||||
'id': 'zt_moon_id',
|
||||
'class': 'cbi-input-text',
|
||||
'placeholder': '123456abcd'
|
||||
})
|
||||
])
|
||||
]),
|
||||
E('div', { 'class': 'cbi-value' }, [
|
||||
E('label', { 'class': 'cbi-value-title' }, [_('Moon Seed')]),
|
||||
E('div', { 'class': 'cbi-value-field' }, [
|
||||
E('input', {
|
||||
'type': 'text',
|
||||
'id': 'zt_moon_seed',
|
||||
'class': 'cbi-input-text',
|
||||
'placeholder': '1a2b3c4d5e'
|
||||
}),
|
||||
E('div', { 'style': 'color: #888; font-size: 11px;' }, [_('The 10-digit address of one of the moon\'s roots. ZeroTier fetches the moon definition from it.')])
|
||||
])
|
||||
]),
|
||||
E('div', { 'style': 'display: flex; gap: 8px;' }, [
|
||||
E('button', {
|
||||
'class': 'cbi-button cbi-button-apply important',
|
||||
'id': 'zt_moon_add_submit',
|
||||
'click': function() {
|
||||
var btn = document.getElementById('zt_moon_add_submit');
|
||||
var statusEl = document.getElementById('zt_moon_status');
|
||||
var idEl = document.getElementById('zt_moon_id');
|
||||
var seedEl = document.getElementById('zt_moon_seed');
|
||||
if (!btn || btn.disabled || !idEl || !seedEl) return;
|
||||
|
||||
var id = (idEl.value || '').trim();
|
||||
var seed = (seedEl.value || '').trim();
|
||||
|
||||
var fail = function(msg) {
|
||||
if (statusEl) {
|
||||
statusEl.style.color = 'red';
|
||||
statusEl.textContent = msg;
|
||||
}
|
||||
};
|
||||
|
||||
if (!/^[0-9a-fA-F]{1,16}$/.test(id.replace(/^0+(?=.)/, '')) || !/[1-9a-fA-F]/.test(id)) {
|
||||
return fail(_('Moon ID must be 10 hexadecimal digits (e.g. 123456abcd)'));
|
||||
}
|
||||
if (!/^[0-9a-fA-F]{10}$/.test(seed) || !/[1-9a-fA-F]/.test(seed)) {
|
||||
return fail(_('Moon Seed must be the 10-digit address of one of the moon\'s roots'));
|
||||
}
|
||||
|
||||
btn.disabled = true;
|
||||
btn.textContent = _('Orbiting...');
|
||||
if (statusEl) {
|
||||
statusEl.style.color = 'orange';
|
||||
statusEl.textContent = _('Registering the orbit...');
|
||||
}
|
||||
|
||||
L.resolveDefault(callLuciZerotierOrbitMoon(id, seed), {}).then(function(res) {
|
||||
if (res && res.code === 0) {
|
||||
if (statusEl) {
|
||||
if (res.pending) {
|
||||
statusEl.style.color = 'orange';
|
||||
statusEl.textContent = _('Orbit registered. The moon definition is still being fetched from the seed - it will appear below once it arrives.');
|
||||
} else {
|
||||
statusEl.style.color = 'green';
|
||||
statusEl.textContent = _('Orbited Moon') + ' ' + (res.moon_id || id);
|
||||
}
|
||||
}
|
||||
idEl.value = '';
|
||||
seedEl.value = '';
|
||||
var f = document.getElementById('zt_moon_form');
|
||||
if (f) f.style.display = 'none';
|
||||
updateMoons();
|
||||
} else {
|
||||
fail((res && res.stderr) ? res.stderr : _('Orbit failed'));
|
||||
}
|
||||
}).catch(function() {
|
||||
fail(_('Orbit request failed'));
|
||||
}).finally(function() {
|
||||
btn.disabled = false;
|
||||
btn.textContent = _('Add');
|
||||
});
|
||||
}
|
||||
}, [_('Add')]),
|
||||
E('button', {
|
||||
'class': 'cbi-button cbi-button-reset',
|
||||
'click': function() {
|
||||
var f = document.getElementById('zt_moon_form');
|
||||
if (f) f.style.display = 'none';
|
||||
}
|
||||
}, [_('Cancel')])
|
||||
])
|
||||
]),
|
||||
E('div', { 'id': 'zt_moons' }, [
|
||||
E('em', {}, [_('Loading...')])
|
||||
])
|
||||
])
|
||||
]);
|
||||
|
||||
var parseNetworks = function(jsonStr) {
|
||||
if (!jsonStr || typeof jsonStr !== 'string') return null;
|
||||
try {
|
||||
var arr = JSON.parse(jsonStr);
|
||||
if (!Array.isArray(arr)) return null;
|
||||
return arr.map(function(net) {
|
||||
return {
|
||||
nwid: net.nwid || net.id || '-',
|
||||
name: net.name || '-',
|
||||
mac: net.mac || '-',
|
||||
status: net.status || '-',
|
||||
type: net.type || '-',
|
||||
dev: net.portDeviceName || '-',
|
||||
ips: (net.assignedAddresses || []).join(' ') || '-'
|
||||
};
|
||||
});
|
||||
} catch(e) {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
var parsePeers = function(jsonStr) {
|
||||
if (!jsonStr || typeof jsonStr !== 'string') return null;
|
||||
try {
|
||||
var arr = JSON.parse(jsonStr);
|
||||
if (!Array.isArray(arr)) return null;
|
||||
return arr.map(function(peer) {
|
||||
var path = '-';
|
||||
var link = '-';
|
||||
if (peer.paths && peer.paths.length > 0) {
|
||||
for (var i = 0; i < peer.paths.length; i++) {
|
||||
if (peer.paths[i].active) {
|
||||
path = peer.paths[i].address || '-';
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (peer.tunneled) {
|
||||
link = 'RELAY';
|
||||
} else if (path !== '-') {
|
||||
link = 'DIRECT';
|
||||
}
|
||||
// ZeroTier reports an unknown version as the literal string
|
||||
// "-1.-1.-1", which is truthy and so survived a `|| '-'`.
|
||||
var ver = peer.version;
|
||||
return {
|
||||
ztaddr: peer.address || '-',
|
||||
version: (ver && !/^-1\./.test(ver)) ? ver : '-',
|
||||
role: peer.role || '-',
|
||||
latency: (peer.latency != null && peer.latency >= 0) ? String(peer.latency) : '-',
|
||||
link: link,
|
||||
path: path
|
||||
};
|
||||
});
|
||||
} catch(e) {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
var getStatusColor = function(status) {
|
||||
if (status && status.toLowerCase().indexOf('ok') >= 0) {
|
||||
return 'green';
|
||||
} else if (status && status.toLowerCase().indexOf('error') >= 0) {
|
||||
return 'red';
|
||||
} else if (status && status.toLowerCase().indexOf('request') >= 0) {
|
||||
return 'orange';
|
||||
}
|
||||
return 'gray';
|
||||
};
|
||||
|
||||
var getRoleColor = function(role) {
|
||||
if (role === 'PLANET') {
|
||||
return '#8B4513';
|
||||
} else if (role === 'MOON') {
|
||||
return '#4169E1';
|
||||
} else if (role === 'LEAF') {
|
||||
return 'green';
|
||||
}
|
||||
return 'gray';
|
||||
};
|
||||
|
||||
var getLinkColor = function(link) {
|
||||
if (link === 'DIRECT') {
|
||||
return 'green';
|
||||
} else if (link === 'RELAY') {
|
||||
return 'orange';
|
||||
}
|
||||
return 'gray';
|
||||
};
|
||||
|
||||
var clearElement = function(el) {
|
||||
while (el.firstChild) {
|
||||
el.removeChild(el.firstChild);
|
||||
}
|
||||
};
|
||||
|
||||
// The daemon's control-plane route is /moon/([0-9a-fA-F]{10}) -- exactly
|
||||
// ten -- while listmoons reports ids zero-padded to 16. Normalize to the
|
||||
// 10-digit form for display and for orbit/deorbit. Mirrors moon_norm_id().
|
||||
var normMoonId = function(id) {
|
||||
var s = (id || '').toLowerCase().replace(/[^0-9a-f]/g, '');
|
||||
s = s.replace(/^0+/, '');
|
||||
if (s === '') s = '0';
|
||||
while (s.length < 10) s = '0' + s;
|
||||
return s;
|
||||
};
|
||||
|
||||
var parseMoons = function(jsonStr) {
|
||||
if (!jsonStr || typeof jsonStr !== 'string') return null;
|
||||
try {
|
||||
var arr = JSON.parse(jsonStr);
|
||||
if (!Array.isArray(arr)) return null;
|
||||
return arr.map(function(m) {
|
||||
var roots = Array.isArray(m.roots) ? m.roots : [];
|
||||
var eps = [];
|
||||
var rootAddrs = [];
|
||||
roots.forEach(function(r) {
|
||||
var ident = (r && r.identity) ? String(r.identity) : '';
|
||||
// wire format is "<10 hex address>:0:<pubkey>"
|
||||
var am = ident.match(/^([0-9a-fA-F]{10}):/);
|
||||
if (am) rootAddrs.push(am[1].toLowerCase());
|
||||
((r && r.stableEndpoints) || []).forEach(function(e) {
|
||||
eps.push(String(e));
|
||||
});
|
||||
});
|
||||
return {
|
||||
id: normMoonId(m.id),
|
||||
roots: roots.length,
|
||||
rootAddresses: rootAddrs,
|
||||
endpoints: eps,
|
||||
waiting: m.waiting === true
|
||||
};
|
||||
});
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
var persistedMoonSet = function(str) {
|
||||
var set = {};
|
||||
(String(str || '').split(/\s+/)).forEach(function(p) {
|
||||
if (!p) return;
|
||||
var s = p.toLowerCase().replace(/^0+/, '');
|
||||
set[s === '' ? '0' : s] = true;
|
||||
});
|
||||
return set;
|
||||
};
|
||||
|
||||
var createMoonTable = function(moons, persisted) {
|
||||
var table = E('table', {
|
||||
class: 'cbi-section-table',
|
||||
style: 'width: 100%; border-collapse: collapse;'
|
||||
});
|
||||
|
||||
var header = E('tr', { class: 'cbi-section-table-titles' });
|
||||
var headers = [_('Moon ID'), _('Roots'), _('Endpoints'), _('Storage'), _('Actions')];
|
||||
for (var h = 0; h < headers.length; h++) {
|
||||
header.appendChild(E('th', { style: 'text-align: left; padding: 4px;' }, [headers[h]]));
|
||||
}
|
||||
table.appendChild(header);
|
||||
|
||||
moons.forEach(function(moon) {
|
||||
var row = E('tr', { class: 'cbi-section-table-row' });
|
||||
|
||||
var idTd = E('td', { style: 'text-align: left; padding: 4px;' });
|
||||
idTd.appendChild(E('code', {}, [moon.id]));
|
||||
if (moon.waiting) {
|
||||
idTd.appendChild(E('div', {
|
||||
style: 'color: orange; font-size: 11px;'
|
||||
}, [_('waiting for the moon definition - this orbit is not persistent yet')]));
|
||||
}
|
||||
row.appendChild(idTd);
|
||||
|
||||
var rootsTd = E('td', { style: 'text-align: left; padding: 4px;' });
|
||||
rootsTd.appendChild(E('span', {}, [String(moon.roots)]));
|
||||
if (moon.rootAddresses.length) {
|
||||
rootsTd.appendChild(E('div', {
|
||||
style: 'font-family: monospace; font-size: 11px; color: #888;'
|
||||
}, [moon.rootAddresses.join(', ')]));
|
||||
}
|
||||
row.appendChild(rootsTd);
|
||||
|
||||
row.appendChild(E('td', {
|
||||
style: 'text-align: left; padding: 4px; font-family: monospace; font-size: 11px;'
|
||||
}, [moon.endpoints.length ? moon.endpoints.join(', ') : '-']));
|
||||
|
||||
var isPersisted = !!persisted[moon.id];
|
||||
var stTd = E('td', { style: 'text-align: left; padding: 4px;' });
|
||||
stTd.appendChild(E('span', {
|
||||
style: 'color: ' + (isPersisted ? 'green' : 'orange')
|
||||
}, [isPersisted ? _('persistent') : _('saving...')]));
|
||||
if (!isPersisted) {
|
||||
stTd.appendChild(E('div', {
|
||||
style: 'font-size: 11px; color: #888;'
|
||||
}, [_('Saving to the persistent folder automatically. Until then the orbit is lost on daemon restart. If it stays in this state, use "Backup Now" under Settings -> Advanced options.')]));
|
||||
}
|
||||
row.appendChild(stTd);
|
||||
|
||||
var actTd = E('td', { style: 'text-align: left; padding: 4px;' });
|
||||
actTd.appendChild(E('button', {
|
||||
'class': 'cbi-button cbi-button-remove',
|
||||
'click': function() {
|
||||
var statusEl = document.getElementById('zt_moon_status');
|
||||
if (statusEl) {
|
||||
statusEl.style.color = 'orange';
|
||||
statusEl.textContent = _('Leaving moon...');
|
||||
}
|
||||
L.resolveDefault(callLuciZerotierDeorbitMoon(moon.id), {}).then(function(res) {
|
||||
if (statusEl) {
|
||||
if (res && res.code === 0 && res.deorbited) {
|
||||
statusEl.style.color = 'green';
|
||||
statusEl.textContent = _('Left Moon') + ' ' + moon.id;
|
||||
} else if (res && res.code === 0) {
|
||||
statusEl.style.color = 'orange';
|
||||
statusEl.textContent = res.stderr || _('Moon was not orbited');
|
||||
} else {
|
||||
statusEl.style.color = 'red';
|
||||
statusEl.textContent = res && res.stderr ? res.stderr : _('Deorbit failed');
|
||||
}
|
||||
}
|
||||
updateMoons();
|
||||
}).catch(function() {
|
||||
if (statusEl) {
|
||||
statusEl.style.color = 'red';
|
||||
statusEl.textContent = _('Deorbit request failed');
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [_('Leave')]));
|
||||
row.appendChild(actTd);
|
||||
|
||||
table.appendChild(row);
|
||||
});
|
||||
|
||||
return table;
|
||||
};
|
||||
|
||||
var moonSyncTries = {};
|
||||
var moonSyncBusy = false;
|
||||
|
||||
// A moon only becomes persistent once its signed world has landed in
|
||||
// moons.d. orbit_moon syncs on the spot when the definition is already
|
||||
// there, but a definition that arrives later (the pending case) is
|
||||
// picked up here instead, so the user never has to hunt for the
|
||||
// "Backup Now" button on the other page.
|
||||
var autoPersistMoons = function() {
|
||||
if (moonSyncBusy) return;
|
||||
moonSyncBusy = true;
|
||||
L.resolveDefault(callLuciZerotierSync(), {}).catch(function() {}).finally(function() {
|
||||
moonSyncBusy = false;
|
||||
});
|
||||
};
|
||||
|
||||
var updateMoons = function() {
|
||||
return L.resolveDefault(callLuciZerotierListMoons(), {}).then(function(res) {
|
||||
var moonsEl = document.getElementById('zt_moons');
|
||||
if (!moonsEl) return;
|
||||
|
||||
clearElement(moonsEl);
|
||||
|
||||
if (!res || res.code !== 0) {
|
||||
moonsEl.appendChild(E('p', { style: 'color: gray' }, [
|
||||
_('Cannot read moons (is the ZeroTier service running?)')
|
||||
]));
|
||||
return;
|
||||
}
|
||||
|
||||
var moons = parseMoons(res.moons);
|
||||
if (moons === null) {
|
||||
moonsEl.appendChild(E('p', { style: 'color: red' }, [_('Error reading moon list')]));
|
||||
return;
|
||||
}
|
||||
|
||||
if (moons.length === 0) {
|
||||
moonsEl.appendChild(E('p', { style: 'color: gray' }, [_('No moons orbited')]));
|
||||
return;
|
||||
}
|
||||
|
||||
var persisted = persistedMoonSet(res.persisted);
|
||||
moonsEl.appendChild(createMoonTable(moons, persisted));
|
||||
|
||||
moons.forEach(function(moon) {
|
||||
if (persisted[moon.id]) {
|
||||
delete moonSyncTries[moon.id];
|
||||
return;
|
||||
}
|
||||
moonSyncTries[moon.id] = (moonSyncTries[moon.id] || 0) + 1;
|
||||
if (moonSyncTries[moon.id] <= 3) autoPersistMoons();
|
||||
});
|
||||
}).catch(function() {
|
||||
var moonsEl = document.getElementById('zt_moons');
|
||||
if (moonsEl) {
|
||||
clearElement(moonsEl);
|
||||
moonsEl.appendChild(E('p', { style: 'color: red' }, [_('Error loading moons')]));
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
var updateInfo = function() {
|
||||
return L.resolveDefault(callLuciZerotierStatus(), {}).then(function(res) {
|
||||
var identityEl = document.getElementById('zt_identity');
|
||||
if (identityEl && res) {
|
||||
clearElement(identityEl);
|
||||
if (res.identity) {
|
||||
identityEl.appendChild(E('b', {}, [_('Address') + ':']));
|
||||
identityEl.appendChild(document.createTextNode(' '));
|
||||
var code = E('code', { style: 'font-size: 14px' }, [res.identity]);
|
||||
identityEl.appendChild(code);
|
||||
} else {
|
||||
identityEl.appendChild(E('b', {}, [_('Address') + ':']));
|
||||
identityEl.appendChild(document.createTextNode(' '));
|
||||
var span = E('span', { style: 'color: gray' }, ['-']);
|
||||
identityEl.appendChild(span);
|
||||
}
|
||||
}
|
||||
}).catch(function(err) {
|
||||
var identityEl = document.getElementById('zt_identity');
|
||||
if (identityEl) {
|
||||
clearElement(identityEl);
|
||||
identityEl.appendChild(E('b', {}, [_('Address') + ':']));
|
||||
identityEl.appendChild(document.createTextNode(' '));
|
||||
identityEl.appendChild(E('span', { style: 'color: gray' }, ['-']));
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
var createNetworkTable = function(networks) {
|
||||
var table = E('table', {
|
||||
class: 'cbi-section-table',
|
||||
style: 'width: 100%; border-collapse: collapse;'
|
||||
});
|
||||
|
||||
var header = E('tr', { class: 'cbi-section-table-titles' });
|
||||
var headers = [_('Network ID'), _('Name'), _('Status'), _('Device'), _('IP Address')];
|
||||
for (var h = 0; h < headers.length; h++) {
|
||||
header.appendChild(E('th', { style: 'text-align: left; padding: 4px;' }, [headers[h]]));
|
||||
}
|
||||
table.appendChild(header);
|
||||
|
||||
for (var i = 0; i < networks.length; i++) {
|
||||
var net = networks[i];
|
||||
var row = E('tr', { class: 'cbi-section-table-row' });
|
||||
|
||||
var nwidTd = E('td', { style: 'text-align: left; padding: 4px;' });
|
||||
nwidTd.appendChild(E('code', {}, [net.nwid || '-']));
|
||||
row.appendChild(nwidTd);
|
||||
|
||||
row.appendChild(E('td', { style: 'text-align: left; padding: 4px;' }, [net.name || '-']));
|
||||
|
||||
var statusTd = E('td', { style: 'text-align: left; padding: 4px;' });
|
||||
statusTd.appendChild(E('span', { style: 'color: ' + getStatusColor(net.status) }, [net.status || '-']));
|
||||
row.appendChild(statusTd);
|
||||
|
||||
row.appendChild(E('td', { style: 'text-align: left; padding: 4px; font-family: monospace;' }, [net.dev || '-']));
|
||||
row.appendChild(E('td', { style: 'text-align: left; padding: 4px; font-family: monospace; font-size: 12px;' }, [net.ips || '-']));
|
||||
|
||||
table.appendChild(row);
|
||||
}
|
||||
|
||||
return table;
|
||||
};
|
||||
|
||||
var createPeerTable = function(peers) {
|
||||
var table = E('table', {
|
||||
class: 'cbi-section-table',
|
||||
style: 'width: 100%; border-collapse: collapse;'
|
||||
});
|
||||
|
||||
var header = E('tr', { class: 'cbi-section-table-titles' });
|
||||
var headers = [_('Address'), _('Version'), _('Role'), _('Latency'), _('Link'), _('Path')];
|
||||
for (var h = 0; h < headers.length; h++) {
|
||||
header.appendChild(E('th', { style: 'text-align: left; padding: 4px;' }, [headers[h]]));
|
||||
}
|
||||
table.appendChild(header);
|
||||
|
||||
for (var i = 0; i < peers.length; i++) {
|
||||
var peer = peers[i];
|
||||
var row = E('tr', { class: 'cbi-section-table-row' });
|
||||
|
||||
var ztaddrTd = E('td', { style: 'text-align: left; padding: 4px;' });
|
||||
ztaddrTd.appendChild(E('code', {}, [peer.ztaddr || '-']));
|
||||
row.appendChild(ztaddrTd);
|
||||
|
||||
row.appendChild(E('td', { style: 'text-align: left; padding: 4px;' }, [peer.version || '-']));
|
||||
|
||||
var roleTd = E('td', { style: 'text-align: left; padding: 4px;' });
|
||||
roleTd.appendChild(E('span', { style: 'color: ' + getRoleColor(peer.role) }, [peer.role || '-']));
|
||||
row.appendChild(roleTd);
|
||||
|
||||
row.appendChild(E('td', { style: 'text-align: left; padding: 4px;' }, [peer.latency || '-']));
|
||||
|
||||
var linkTd = E('td', { style: 'text-align: left; padding: 4px;' });
|
||||
linkTd.appendChild(E('span', { style: 'color: ' + getLinkColor(peer.link) }, [peer.link || '-']));
|
||||
row.appendChild(linkTd);
|
||||
|
||||
row.appendChild(E('td', { style: 'text-align: left; padding: 4px; font-family: monospace; font-size: 11px;' }, [peer.path || '-']));
|
||||
|
||||
table.appendChild(row);
|
||||
}
|
||||
|
||||
return table;
|
||||
};
|
||||
|
||||
var renderNetworks = function(res) {
|
||||
var el = document.getElementById('zt_networks');
|
||||
if (!el) return;
|
||||
clearElement(el);
|
||||
|
||||
if (!res || res.code !== 0) {
|
||||
el.appendChild(E('p', { style: 'color: red' }, [_('Cannot reach the ZeroTier service')]));
|
||||
return;
|
||||
}
|
||||
var networks = parseNetworks(res.networks);
|
||||
if (networks === null) {
|
||||
el.appendChild(E('p', { style: 'color: red' }, [_('Error loading networks')]));
|
||||
return;
|
||||
}
|
||||
if (networks.length === 0) {
|
||||
el.appendChild(E('p', { style: 'color: gray' }, [_('No networks joined')]));
|
||||
return;
|
||||
}
|
||||
el.appendChild(createNetworkTable(networks));
|
||||
};
|
||||
|
||||
var renderPeers = function(res) {
|
||||
var el = document.getElementById('zt_peers');
|
||||
if (!el) return;
|
||||
clearElement(el);
|
||||
|
||||
if (!res || res.code !== 0) {
|
||||
el.appendChild(E('p', { style: 'color: red' }, [_('Cannot reach the ZeroTier service')]));
|
||||
return;
|
||||
}
|
||||
var peers = parsePeers(res.peers);
|
||||
if (peers === null) {
|
||||
el.appendChild(E('p', { style: 'color: red' }, [_('Error loading peers')]));
|
||||
return;
|
||||
}
|
||||
if (peers.length === 0) {
|
||||
el.appendChild(E('p', { style: 'color: gray' }, [_('No peers')]));
|
||||
return;
|
||||
}
|
||||
el.appendChild(createPeerTable(peers));
|
||||
};
|
||||
|
||||
// One RPC feeds both tables: zerotier-cli is the 1.2 MB zerotier-one
|
||||
// binary, so two calls meant loading and linking it twice per cycle.
|
||||
var updateTables = function() {
|
||||
return L.resolveDefault(callLuciZerotierNetworksPeers(), {})
|
||||
.then(function(res) {
|
||||
renderNetworks(res);
|
||||
renderPeers(res);
|
||||
})
|
||||
.catch(function() {
|
||||
renderNetworks(null);
|
||||
renderPeers(null);
|
||||
});
|
||||
};
|
||||
|
||||
updateInfo();
|
||||
updateTables();
|
||||
updateMoons();
|
||||
|
||||
poll.add(updateInfo, 10);
|
||||
poll.add(updateTables, 5);
|
||||
poll.add(updateMoons, 10);
|
||||
|
||||
return container;
|
||||
},
|
||||
|
||||
handleSaveApply: null,
|
||||
handleSave: null,
|
||||
handleReset: null
|
||||
});
|
||||
@@ -1,119 +0,0 @@
|
||||
/* SPDX-License-Identifier: GPL-3.0-only
|
||||
*
|
||||
* Copyright (C) 2022 ImmortalWrt.org
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
'require fs';
|
||||
'require ui';
|
||||
'require view';
|
||||
|
||||
return view.extend({
|
||||
load: function() {
|
||||
return fs.exec('/sbin/ifconfig').then(function(res) {
|
||||
if (res.code !== 0 || !res.stdout || res.stdout.trim() === '') {
|
||||
ui.addNotification(null, E('p', {}, _('Unable to get interface info: %s.').format(res.message)));
|
||||
return '';
|
||||
}
|
||||
|
||||
var interfaces = res.stdout.match(/zt[a-z0-9]+/g);
|
||||
if (!interfaces || interfaces.length === 0)
|
||||
return 'No interface online.';
|
||||
|
||||
var promises = interfaces.map(function(name) {
|
||||
return fs.exec('/sbin/ifconfig', [name]);
|
||||
});
|
||||
|
||||
return Promise.all(promises).then(function(results) {
|
||||
var data = results.map(function(res, index) {
|
||||
if (res.code !== 0 || !res.stdout || res.stdout.trim() === '') {
|
||||
ui.addNotification(null, E('p', {}, _('Unable to get interface %s info: %s.').format(interfaces[index], res.message)));
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
name: interfaces[index],
|
||||
stdout: res.stdout.trim()
|
||||
};
|
||||
}).filter(Boolean);
|
||||
|
||||
return data.map(function(info) {
|
||||
var lines = info.stdout.split('\n');
|
||||
var parsedInfo = {
|
||||
name: info.name
|
||||
};
|
||||
|
||||
lines.forEach(function(line) {
|
||||
if (line.includes('HWaddr')) {
|
||||
parsedInfo.mac = line.split('HWaddr')[1].trim().split(' ')[0];
|
||||
} else if (line.includes('inet addr:')) {
|
||||
parsedInfo.ipv4 = line.split('inet addr:')[1].trim().split(' ')[0];
|
||||
} else if (line.includes('inet6 addr:')) {
|
||||
parsedInfo.ipv6 = line.split('inet6 addr:')[1].trim().split('/')[0];
|
||||
} else if (line.includes('MTU:')) {
|
||||
parsedInfo.mtu = line.split('MTU:')[1].trim().split(' ')[0];
|
||||
} else if (line.includes('RX bytes:')) {
|
||||
var rxMatch = line.match(/RX bytes:\d+ \(([\d.]+\s*[a-zA-Z]+)\)/);
|
||||
if (rxMatch && rxMatch[1]) {
|
||||
parsedInfo.rxBytes = rxMatch[1];
|
||||
}
|
||||
var txMatch = line.match(/TX bytes:\d+ \(([\d.]+\s*[a-zA-Z]+)\)/);
|
||||
if (txMatch && txMatch[1]) {
|
||||
parsedInfo.txBytes = txMatch[1];
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return parsedInfo;
|
||||
});
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
render: function(data) {
|
||||
var title = E('h2', {class: 'content'}, _('ZeroTier'));
|
||||
var desc = E('div', {class: 'cbi-map-descr'}, _('ZeroTier is an open source, cross-platform and easy to use virtual LAN.'));
|
||||
|
||||
if (!Array.isArray(data)) {
|
||||
return E('div', {}, [title, desc, E('div', {}, _('No interface online.'))]);
|
||||
}
|
||||
var rows = data.flatMap(function(interfaceData) {
|
||||
return [
|
||||
E('th', {class: 'th', colspan: '2'}, _('Network Interface Information')),
|
||||
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%'}, _('MAC Address')),
|
||||
E('td', {class: 'td left', width: '25%'}, interfaceData.mac)
|
||||
]),
|
||||
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('div', {}, [title, desc, E('table', { 'class': 'table' }, rows)]);
|
||||
},
|
||||
|
||||
handleSaveApply: null,
|
||||
handleSave: null,
|
||||
handleReset: null
|
||||
});
|
||||
@@ -0,0 +1,971 @@
|
||||
'use strict';
|
||||
'require view';
|
||||
'require ui';
|
||||
'require rpc';
|
||||
'require uci';
|
||||
|
||||
/*
|
||||
* Remote Controller / Moon management.
|
||||
*
|
||||
* The LuCI page lives on the OpenWrt router, but the Controller and Moon root
|
||||
* live on a separate machine with a fixed public IP. Everything here reaches
|
||||
* that machine over an SSH-forwarded loopback port; see zerotier-remote.sh for
|
||||
* why a tunnel is the only option (no TLS on the control plane, and
|
||||
* allowManagementFrom rejects every non-loopback caller).
|
||||
*
|
||||
* Every remote call costs an SSH handshake (~1-3s), so this page deliberately
|
||||
* does NOT poll the way the local Interface Info page does. Diagnostics are
|
||||
* refreshed on demand and after mutations.
|
||||
*/
|
||||
|
||||
var rpcHostList = rpc.declare({ object: 'luci-zerotier', method: 'remote_list' });
|
||||
var rpcHostSet = rpc.declare({
|
||||
object: 'luci-zerotier', method: 'remote_host_set',
|
||||
params: [ 'section', 'name', 'host', 'port', 'user', 'controller_port', 'private_key' ]
|
||||
});
|
||||
var rpcHostDel = rpc.declare({ object: 'luci-zerotier', method: 'remote_host_del', params: [ 'section' ] });
|
||||
var rpcDiagnose = rpc.declare({ object: 'luci-zerotier', method: 'remote_diagnose', params: [ 'section' ] });
|
||||
var rpcCtlGet = rpc.declare({ object: 'luci-zerotier', method: 'remote_ctl_get', params: [ 'section', 'path' ] });
|
||||
var rpcNetSet = rpc.declare({ object: 'luci-zerotier', method: 'remote_network_set', params: [ 'section', 'nwid', 'body' ] });
|
||||
var rpcNetDel = rpc.declare({ object: 'luci-zerotier', method: 'remote_network_del', params: [ 'section', 'nwid' ] });
|
||||
var rpcMemberSet = rpc.declare({ object: 'luci-zerotier', method: 'remote_member_set', params: [ 'section', 'nwid', 'member_id', 'body' ] });
|
||||
var rpcMemberList = rpc.declare({ object: 'luci-zerotier', method: 'remote_member_list', params: [ 'section', 'nwid' ] });
|
||||
var rpcPeerList = rpc.declare({ object: 'luci-zerotier', method: 'remote_peer_list', params: [ 'section' ] });
|
||||
var rpcMoonPlan = rpc.declare({ object: 'luci-zerotier', method: 'remote_moon_plan', params: [ 'section' ] });
|
||||
var rpcMoonApply = rpc.declare({ object: 'luci-zerotier', method: 'remote_moon_apply', params: [ 'section', 'confirm' ] });
|
||||
|
||||
function errText(res, fallback) {
|
||||
if (!res) return fallback;
|
||||
if (res.error) return res.error;
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function yn(v, t, f) {
|
||||
return E('span', { 'style': 'color: ' + (v ? 'green' : 'orange') }, [ v ? t : f ]);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ hosts */
|
||||
|
||||
function hostEditor(section, host, onSaved) {
|
||||
var isNew = !section;
|
||||
var f = section || '';
|
||||
|
||||
var nameI = E('input', { 'type': 'text', 'id': 'zt_r_name', 'value': (host && host.name) || '', 'placeholder': _('My controller') });
|
||||
var hostI = E('input', { 'type': 'text', 'id': 'zt_r_host', 'value': (host && host.host) || '', 'placeholder': '203.0.113.10' });
|
||||
var portI = E('input', { 'type': 'number', 'id': 'zt_r_port', 'value': (host && host.port) || '22', 'min': '1', 'max': '65535' });
|
||||
var userI = E('input', { 'type': 'text', 'id': 'zt_r_user', 'value': (host && host.user) || 'root' });
|
||||
var cportI = E('input', { 'type': 'number', 'id': 'zt_r_cport', 'value': (host && host.controller_port) || '27893', 'min': '1', 'max': '65535' });
|
||||
var keyI = E('textarea', {
|
||||
'id': 'zt_r_key', 'rows': '6', 'style': 'width:100%; font-family:monospace; font-size:12px;',
|
||||
'placeholder': isNew
|
||||
? _('Required: paste the PRIVATE key of a keypair whose public half is in the remote ~/.ssh/authorized_keys')
|
||||
: _('Leave blank to keep the stored key')
|
||||
});
|
||||
|
||||
var msg = E('div', { 'style': 'color:red; margin-top:6px;' });
|
||||
var saveBtn = E('button', { 'class': 'cbi-button cbi-button-action important' }, [ _('Save') ]);
|
||||
var cancelBtn = E('button', { 'class': 'cbi-button', 'click': function() { hostEditor.close(); } }, [ _('Cancel') ]);
|
||||
|
||||
saveBtn.addEventListener('click', function() {
|
||||
msg.textContent = '';
|
||||
saveBtn.disabled = true;
|
||||
var sectName = f || ('zt' + Date.now().toString(36));
|
||||
L.resolveDefault(rpcHostSet(
|
||||
sectName, nameI.value, hostI.value, portI.value,
|
||||
userI.value, cportI.value, keyI.value), {})
|
||||
.then(function(res) {
|
||||
if (res && res.code === 0) {
|
||||
hostEditor.close();
|
||||
onSaved();
|
||||
} else {
|
||||
msg.textContent = errText(res, _('Could not save the host'));
|
||||
saveBtn.disabled = false;
|
||||
}
|
||||
}).catch(function() {
|
||||
msg.textContent = _('Could not save the host');
|
||||
saveBtn.disabled = false;
|
||||
});
|
||||
});
|
||||
|
||||
hostEditor.body = E('div', { 'class': 'cbi-section' }, [
|
||||
E('h3', {}, [ isNew ? _('Add a remote host') : _('Edit remote host') ]),
|
||||
E('div', { 'class': 'cbi-value' }, [ E('label', {}, [_('Label')]), E('div', {}, [ nameI ]) ]),
|
||||
E('div', { 'class': 'cbi-value' }, [ E('label', {}, [_('Public IP or host')]), E('div', {}, [ hostI ]) ]),
|
||||
E('div', { 'class': 'cbi-value' }, [ E('label', {}, [_('SSH port')]), E('div', {}, [ portI ]) ]),
|
||||
E('div', { 'class': 'cbi-value' }, [ E('label', {}, [_('SSH user')]), E('div', {}, [ userI ]) ]),
|
||||
E('div', { 'class': 'cbi-value' }, [ E('label', {}, [_('Controller port')]), E('div', {}, [ cportI, E('div', { 'class': 'cbi-value-description' }, [_('The port the controller API listens on, e.g. 27893. Only reachable through the SSH tunnel.')]) ]) ]),
|
||||
E('div', { 'class': 'cbi-value' }, [ E('label', {}, [_('SSH private key')]), E('div', {}, [ keyI ]) ]),
|
||||
msg,
|
||||
E('div', { 'style': 'margin-top:10px; display:flex; gap:8px;' }, [ saveBtn, cancelBtn ])
|
||||
]);
|
||||
hostEditor.open();
|
||||
}
|
||||
|
||||
hostEditor.open = function() {
|
||||
hostEditor.modal = ui.showModal(_('Remote host'), hostEditor.body);
|
||||
};
|
||||
hostEditor.close = function() {
|
||||
if (hostEditor.modal) { ui.hideModal(); hostEditor.modal = null; }
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------- diagnostics */
|
||||
|
||||
function diagnostics(section, host) {
|
||||
var box = E('div', { 'style': 'margin-top:8px;' });
|
||||
var btn = E('button', { 'class': 'cbi-button cbi-button-action' }, [ _('Run diagnostics') ]);
|
||||
|
||||
function row(k, v) {
|
||||
return E('tr', {}, [ E('td', { 'style': 'width:220px;' }, [ k ]), E('td', {}, [ v ]) ]);
|
||||
}
|
||||
|
||||
function paint(res) {
|
||||
while (box.firstChild) box.removeChild(box.firstChild);
|
||||
if (!res || res.code !== 0) {
|
||||
box.appendChild(E('div', { 'style': 'color:red;' }, [ errText(res, _('Diagnostics failed')) ]));
|
||||
return;
|
||||
}
|
||||
box.appendChild(E('table', { 'class': 'table' }, [
|
||||
E('tr', {}, [ E('th', { 'colspan': '2' }, [_('Remote host report')]) ]),
|
||||
row(_('Operating system'), res.os || '-'),
|
||||
row(_('Passwordless sudo'), yn(res.sudo, _('yes'), _('no — required'))),
|
||||
row(_('ZeroTier installed'), yn(res.zerotier_installed, _('yes'), _('no'))),
|
||||
row(_('ZeroTier version'), res.zerotier_version || '-'),
|
||||
row(_('Service state'), res.zerotier_service || '-'),
|
||||
row(_('Node address'), E('code', {}, [ res.identity || '-' ])),
|
||||
row(_('Controller state present'), res.controller_state ? _('yes') : _('no')),
|
||||
row(_('Controller reachable via tunnel'), yn(res.controller_reachable, _('yes'), _('no'))),
|
||||
row(_('moon.json on host'), res.moon_json_present ? _('yes') : _('no')),
|
||||
row(_('Signed .moon files'), String(res.moon_files || 0))
|
||||
]));
|
||||
}
|
||||
|
||||
btn.addEventListener('click', function() {
|
||||
btn.disabled = true;
|
||||
btn.textContent = _('Running...');
|
||||
while (box.firstChild) box.removeChild(box.firstChild);
|
||||
L.resolveDefault(rpcDiagnose(section), {}).then(function(res) {
|
||||
paint(res);
|
||||
btn.disabled = false;
|
||||
btn.textContent = _('Run diagnostics');
|
||||
}).catch(function() {
|
||||
paint(null);
|
||||
btn.disabled = false;
|
||||
btn.textContent = _('Run diagnostics');
|
||||
});
|
||||
});
|
||||
|
||||
return E('div', {}, [ btn, box ]);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- networks */
|
||||
|
||||
function networksPanel(section) {
|
||||
var box = E('div', { 'style': 'margin-top:8px;' });
|
||||
var refresh = E('button', { 'class': 'cbi-button cbi-button-action' }, [ _('Refresh') ]);
|
||||
var add = E('button', { 'class': 'cbi-button cbi-button-add' }, [ _('Create network') ]);
|
||||
var table = E('div', {});
|
||||
|
||||
/* One row of the pool editor. The pool is {ipRangeStart, ipRangeEnd} and
|
||||
* the route needs a CIDR target, so start/end are validated separately. */
|
||||
function poolRow(pool, onChange) {
|
||||
var s = E('input', { 'type': 'text', 'value': (pool && pool.ipRangeStart) || '', 'placeholder': '192.168.192.1', 'style': 'width:130px;' });
|
||||
var e = E('input', { 'type': 'text', 'value': (pool && pool.ipRangeEnd) || '', 'placeholder': '192.168.192.254', 'style': 'width:130px;' });
|
||||
var rm = E('button', { 'class': 'cbi-button cbi-button-remove' }, [ _('Remove') ]);
|
||||
rm.addEventListener('click', function() { onChange(null, s, e); });
|
||||
return { el: E('tr', {}, [
|
||||
E('td', {}, [ s ]), E('td', {}, [ e ]),
|
||||
E('td', { 'style': 'text-align:right;' }, [ rm ])
|
||||
]), start: s, end: e };
|
||||
}
|
||||
|
||||
function routeRow(rt, onChange) {
|
||||
var t = E('input', { 'type': 'text', 'value': (rt && rt.target) || '', 'placeholder': '192.168.192.0/24', 'style': 'width:150px;' });
|
||||
var v = E('input', { 'type': 'text', 'value': (rt && typeof rt.via === 'string') ? rt.via : '', 'placeholder': _('empty = direct'), 'style': 'width:150px;' });
|
||||
var rm = E('button', { 'class': 'cbi-button cbi-button-remove' }, [ _('Remove') ]);
|
||||
rm.addEventListener('click', function() { onChange(null, t, v); });
|
||||
return { el: E('tr', {}, [
|
||||
E('td', {}, [ t ]), E('td', {}, [ v ]),
|
||||
E('td', { 'style': 'text-align:right;' }, [ rm ])
|
||||
]), target: t, via: v };
|
||||
}
|
||||
|
||||
function isIp(s) { return /^(\d{1,3}\.){3}\d{1,3}$/.test(s) && s.split('.').every(function (o) { return +o >= 0 && +o <= 255; }); }
|
||||
function isCidr(s) {
|
||||
var m = /^([0-9.]+)\/(\d{1,2})$/.exec(s);
|
||||
return !!m && isIp(m[1]) && +m[2] >= 0 && +m[2] <= 32;
|
||||
}
|
||||
|
||||
function netRow(nw) {
|
||||
/* ztncui-style rename: the name is a plain span with an edit glyph;
|
||||
* the whole-form Save below commits the last accepted value via
|
||||
* netName, not a live input. */
|
||||
var netName = nw.name || '';
|
||||
var nameWrap = E('span', { 'style': 'white-space:nowrap;' }, []);
|
||||
var nameBusy = false, nameAfterKey = false;
|
||||
var nameEdit = E('a', {
|
||||
'href': '#', 'title': _('Rename'),
|
||||
'style': 'margin-left:6px; text-decoration:none; cursor:pointer;',
|
||||
'click': function(ev) { ev.preventDefault(); startRename(); }
|
||||
}, [ '✎' ]);
|
||||
function drawNameSpan() {
|
||||
while (nameWrap.firstChild) nameWrap.removeChild(nameWrap.firstChild);
|
||||
nameWrap.appendChild(E('span', {}, [ netName ]));
|
||||
nameWrap.appendChild(nameEdit);
|
||||
}
|
||||
function startRename() {
|
||||
if (nameBusy) return;
|
||||
while (nameWrap.firstChild) nameWrap.removeChild(nameWrap.firstChild);
|
||||
var inp = E('input', { 'type': 'text', 'value': netName, 'style': 'width:150px;' });
|
||||
nameWrap.appendChild(inp);
|
||||
if (inp.focus) inp.focus();
|
||||
function close() { nameBusy = false; drawNameSpan(); }
|
||||
function doCommit() {
|
||||
var v = inp.value;
|
||||
if (v === netName) { close(); return; }
|
||||
nameBusy = true;
|
||||
L.resolveDefault(rpcNetSet(section, nw.nwid, JSON.stringify({ name: v })), {}).then(function(res) {
|
||||
if (res && res.code === 200) netName = v;
|
||||
else ui.addNotification(null, E('span', { 'class': 'alert-message warning' }, [ errText(res, _('Rename failed')) ]), 'warning');
|
||||
close();
|
||||
});
|
||||
}
|
||||
inp.addEventListener('keydown', function(ev) {
|
||||
if (ev.keyCode === 13) { ev.preventDefault(); nameAfterKey = true; doCommit(); }
|
||||
else if (ev.keyCode === 27) { inp.value = netName; close(); }
|
||||
});
|
||||
inp.addEventListener('blur', function() {
|
||||
if (nameAfterKey) { nameAfterKey = false; return; }
|
||||
doCommit();
|
||||
});
|
||||
}
|
||||
drawNameSpan();
|
||||
var bcast = E('input', { 'type': 'checkbox' });
|
||||
bcast.checked = !!nw.enableBroadcast;
|
||||
|
||||
var mtu = E('input', { 'type': 'number', 'value': nw.mtu || 2800, 'min': '1280', 'max': '10000', 'style': 'width:90px;' });
|
||||
var mlimit = E('input', { 'type': 'number', 'value': (nw.multicastLimit == null ? 32 : nw.multicastLimit), 'min': '0', 'style': 'width:80px;' });
|
||||
var priv = E('input', { 'type': 'checkbox' });
|
||||
priv.checked = nw.private !== false;
|
||||
|
||||
/* Scalars are merged by the controller (every field is guarded by a
|
||||
* presence check server-side), but routes and ipAssignmentPools are
|
||||
* arrays replaced wholesale -- so those are read, edited here and
|
||||
* written back whole. Verified against 1.14.2 behaviour, not assumed. */
|
||||
var pools = (nw.ipAssignmentPools || []).slice();
|
||||
var routes = (nw.routes || []).slice();
|
||||
var poolTbl = E('tbody', {});
|
||||
var routeTbl = E('tbody', {});
|
||||
|
||||
/* drawPools re-renders from the model, so the input references handed
|
||||
* back by poolRow are captured here and used to focus the new row.
|
||||
* Walking childNodes by index would silently break if the table
|
||||
* structure ever changed. */
|
||||
var lastPoolInputs = null;
|
||||
function drawPools() {
|
||||
while (poolTbl.firstChild) poolTbl.removeChild(poolTbl.firstChild);
|
||||
lastPoolInputs = null;
|
||||
pools.forEach(function (p, i) {
|
||||
var r = poolRow(p, function () { pools.splice(i, 1); drawPools(); });
|
||||
poolTbl.appendChild(r.el);
|
||||
lastPoolInputs = r;
|
||||
});
|
||||
if (!pools.length) poolTbl.appendChild(E('tr', {}, [ E('td', { 'colspan': '3', 'style': 'color:orange;' }, [ _('No pool — members get no managed address') ]) ]));
|
||||
}
|
||||
function drawRoutes() {
|
||||
while (routeTbl.firstChild) routeTbl.removeChild(routeTbl.firstChild);
|
||||
routes.forEach(function (r, i) {
|
||||
routeTbl.appendChild(routeRow(r, function () { routes.splice(i, 1); drawRoutes(); }).el);
|
||||
});
|
||||
if (!routes.length) routeTbl.appendChild(E('tr', {}, [ E('td', { 'colspan': '3', 'style': 'color:orange;' }, [ _('No route') ]) ]));
|
||||
}
|
||||
|
||||
function addPool() {
|
||||
pools.push({ ipRangeStart: '', ipRangeEnd: '' });
|
||||
drawPools();
|
||||
if (lastPoolInputs && lastPoolInputs.start.focus) lastPoolInputs.start.focus();
|
||||
}
|
||||
function addRoute() { routes.push({ target: '', via: null }); drawRoutes(); }
|
||||
|
||||
drawPools(); drawRoutes();
|
||||
|
||||
/* A pool without a route covering the same subnet leaves members with
|
||||
* no address: the pool is allocated but never installed, and the member
|
||||
* list then shows no IP with no indication why. Reads the live input
|
||||
* values so it reflects unsaved edits. */
|
||||
function poolWarning() {
|
||||
var rows = poolTbl.childNodes, nets = [], i, j;
|
||||
for (i = 0; i < rows.length; i++) {
|
||||
var s = rows[i].childNodes[0] && rows[i].childNodes[0].childNodes[0];
|
||||
if (!s) continue;
|
||||
var sv = (s.value || '').trim();
|
||||
if (!sv || !isIp(sv)) continue;
|
||||
nets.push(sv.split('.').slice(0, 3).join('.') + '.0/24');
|
||||
}
|
||||
if (!nets.length) return null;
|
||||
var rrows = routeTbl.childNodes, targets = [];
|
||||
for (j = 0; j < rrows.length; j++) {
|
||||
var t = rrows[j].childNodes[0] && rrows[j].childNodes[0].childNodes[0];
|
||||
if (t && (t.value || '').trim()) targets.push(t.value.trim());
|
||||
}
|
||||
for (i = 0; i < nets.length; i++) {
|
||||
if (targets.indexOf(nets[i]) === -1) {
|
||||
return _('No route covers ') + nets[i] +
|
||||
_(' — members will not receive an address from this pool.');
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
var save = E('button', { 'class': 'cbi-button cbi-button-apply' }, [ _('Save') ]);
|
||||
save.addEventListener('click', function() {
|
||||
var warn = poolWarning();
|
||||
if (warn && !confirm(warn + '\n\n' + _('Save anyway?'))) return;
|
||||
|
||||
var newPools = [], newRoutes = [], bad = null;
|
||||
poolTbl.childNodes.forEach(function (tr) {
|
||||
var s = tr.childNodes[0] && tr.childNodes[0].childNodes[0];
|
||||
var e = tr.childNodes[1] && tr.childNodes[1].childNodes[0];
|
||||
if (!s || !e) return;
|
||||
var sv = (s.value || '').trim(), ev = (e.value || '').trim();
|
||||
if (!sv && !ev) return;
|
||||
if (!isIp(sv) || !isIp(ev)) { bad = _('Pool bounds must be IPv4 addresses'); return; }
|
||||
newPools.push({ ipRangeStart: sv, ipRangeEnd: ev });
|
||||
});
|
||||
if (bad) { ui.addNotification(null, E('span', { 'class': 'alert-message warning' }, [ bad ]), 'warning'); return; }
|
||||
|
||||
routeTbl.childNodes.forEach(function (tr) {
|
||||
var t = tr.childNodes[0] && tr.childNodes[0].childNodes[0];
|
||||
var v = tr.childNodes[1] && tr.childNodes[1].childNodes[0];
|
||||
if (!t) return;
|
||||
var tv = (t.value || '').trim(), vv = (v && v.value || '').trim();
|
||||
if (!tv) return;
|
||||
if (!isCidr(tv)) { bad = _('Route target must be CIDR, e.g. 192.168.192.0/24'); return; }
|
||||
if (vv && !isIp(vv)) { bad = _('Route via must be an IPv4 address, or empty for a direct route'); return; }
|
||||
newRoutes.push({ target: tv, via: vv || null });
|
||||
});
|
||||
if (bad) { ui.addNotification(null, E('span', { 'class': 'alert-message warning' }, [ bad ]), 'warning'); return; }
|
||||
|
||||
/* v4AssignMode.zt must be enabled for a pool to hand out addresses at
|
||||
* all -- with it false the pool is stored but nothing is allocated,
|
||||
* silently. Verified on 1.14.2: identical pool+routes produce
|
||||
* ipAssignments:[] without it and a real address with it. Sent
|
||||
* unconditionally so a cleared pool also switches assignment off
|
||||
* rather than leaving it dangling.
|
||||
*
|
||||
* The object form is required. The published tutorial shows the
|
||||
* string "zt", but the schema defines an object and the daemon
|
||||
* ignores a wrongly-typed field without complaint. */
|
||||
var body = JSON.stringify({
|
||||
name: netName,
|
||||
enableBroadcast: bcast.checked,
|
||||
private: priv.checked,
|
||||
mtu: parseInt(mtu.value, 10) || 2800,
|
||||
multicastLimit: parseInt(mlimit.value, 10) || 0,
|
||||
ipAssignmentPools: newPools,
|
||||
routes: newRoutes,
|
||||
v4AssignMode: { zt: newPools.length > 0 }
|
||||
});
|
||||
|
||||
save.disabled = true;
|
||||
L.resolveDefault(rpcNetSet(section, nw.nwid, body), {}).then(function(res) {
|
||||
if (res && res.code === 200) load();
|
||||
else ui.addNotification(null, E('span', { 'class': 'alert-message warning' }, [ errText(res, _('Update failed')) ]), 'warning');
|
||||
save.disabled = false;
|
||||
});
|
||||
});
|
||||
|
||||
var del = E('button', {
|
||||
'class': 'cbi-button cbi-button-remove',
|
||||
'click': function() {
|
||||
if (!confirm(_('Delete network ') + nw.nwid + '? Members will lose connectivity.')) return;
|
||||
del.disabled = true;
|
||||
L.resolveDefault(rpcNetDel(section, nw.nwid), {}).then(function(res) {
|
||||
if (res && res.code === 0) load();
|
||||
else ui.addNotification(null, E('span', { 'class': 'alert-message warning' }, [ errText(res, _('Delete failed')) ]), 'warning');
|
||||
del.disabled = false;
|
||||
});
|
||||
}
|
||||
}, [ _('Delete') ]);
|
||||
|
||||
var members = E('div', { 'style': 'margin-top:6px; padding-left:12px;' });
|
||||
var mToggle = E('button', { 'class': 'cbi-button' }, [ _('Members') ]);
|
||||
mToggle.addEventListener('click', function() {
|
||||
if (members.firstChild) { while (members.firstChild) members.removeChild(members.firstChild); return; }
|
||||
loadMembers(nw.nwid, members);
|
||||
});
|
||||
|
||||
var cfg = E('div', { 'style': 'margin-top:8px; padding-left:12px;' }, [
|
||||
E('div', { 'style': 'display:flex; gap:10px; flex-wrap:wrap; align-items:center; margin-bottom:6px;' }, [
|
||||
E('label', {}, [ _('MTU')]), mtu,
|
||||
E('label', {}, [ _('Multicast limit')]), mlimit,
|
||||
E('label', {}, [ _('Private')]), priv
|
||||
]),
|
||||
E('div', { 'style': 'font-weight:bold; margin-top:6px;' }, [ _('IP assignment pools') ]),
|
||||
E('div', { 'style': 'color:#666; font-size:12px;' }, [
|
||||
_('The controller allocates addresses from these ranges and reports them per member.')
|
||||
]),
|
||||
E('table', { 'class': 'table' }, [
|
||||
E('tr', {}, [ E('th', {}, [_('First IP')]), E('th', {}, [_('Last IP')]), E('th', {}) ]),
|
||||
poolTbl
|
||||
]),
|
||||
E('button', { 'class': 'cbi-button cbi-button-add', 'click': addPool }, [ _('Add pool') ]),
|
||||
E('div', { 'style': 'font-weight:bold; margin-top:10px;' }, [ _('Routes') ]),
|
||||
E('div', { 'style': 'color:#666; font-size:12px;' }, [
|
||||
_('A pool needs a route covering the same subnet, otherwise members receive no address.')
|
||||
]),
|
||||
E('table', { 'class': 'table' }, [
|
||||
E('tr', {}, [ E('th', {}, [_('Target')]), E('th', {}, [_('Via')]), E('th', {}) ]),
|
||||
routeTbl
|
||||
]),
|
||||
E('button', { 'class': 'cbi-button cbi-button-add', 'click': addRoute }, [ _('Add route') ])
|
||||
]);
|
||||
var cToggle = E('button', { 'class': 'cbi-button' }, [ _('Configure') ]);
|
||||
cToggle.addEventListener('click', function() {
|
||||
if (cfg.style.display === 'none') { cfg.style.display = 'block'; cToggle.textContent = _('Hide'); }
|
||||
else { cfg.style.display = 'none'; cToggle.textContent = _('Configure'); }
|
||||
});
|
||||
cfg.style.display = 'none';
|
||||
|
||||
/* The editors live outside the network <table> on purpose. Nested
|
||||
* tables share column widths, so a wide pool form stretched the nwid
|
||||
* column until the address broke to one character per line. */
|
||||
return E('div', { 'style': 'margin-bottom:14px;' }, [
|
||||
E('table', {
|
||||
'class': 'table',
|
||||
/* auto layout ignores a width on a lone cell and shrinks the
|
||||
* column to the widest unbreakable content, which is one hex
|
||||
* character. Fixed layout honours the column widths below. */
|
||||
'style': 'table-layout:fixed; width:100%;'
|
||||
}, [ E('tr', {}, [
|
||||
E('td', { 'style': 'width:170px; font-family:monospace; white-space:nowrap;' }, [ nw.nwid ]),
|
||||
E('td', { 'style': 'width:auto;' }, [ nameWrap ]),
|
||||
E('td', { 'style': 'width:90px; text-align:center; white-space:nowrap;' }, [ bcast ]),
|
||||
E('td', { 'style': 'width:280px; text-align:right;' }, [
|
||||
E('div', { 'style': 'display:flex; flex-wrap:wrap; gap:4px; justify-content:flex-end;' },
|
||||
[ save, cToggle, mToggle, del ])
|
||||
])
|
||||
]) ]),
|
||||
E('div', { 'style': 'padding-left:12px;' }, [ cfg, members ])
|
||||
]);
|
||||
}
|
||||
|
||||
function loadMembers(nwid, into) {
|
||||
into.appendChild(E('div', { 'style': 'color:orange;' }, [ _('Loading...') ]));
|
||||
/* remote_member_list collapses what used to be an N+1 chain (one
|
||||
* SSH round-trip per member: 30-90s for a 29-member network) into
|
||||
* one call, and remote_peer_list supplies live online / version /
|
||||
* path state in a second. Two parallel calls are fine; the old
|
||||
* comment's warning was about firing thirty at once. */
|
||||
Promise.all([
|
||||
L.resolveDefault(rpcMemberList(section, nwid), {}),
|
||||
L.resolveDefault(rpcPeerList(section), {})
|
||||
]).then(function(r) {
|
||||
var mres = r[0], pres = r[1];
|
||||
/* remote_ctl_get reports the tunnelled HTTP status (200) while
|
||||
* remote_member_set / the batch list calls report the helper's
|
||||
* own 0-for-success; both conventions live on this page, so
|
||||
* accept either. */
|
||||
function okList(res) {
|
||||
return !!(res && (res.code === 0 || res.code === 200) && Array.isArray(res.body));
|
||||
}
|
||||
var mok = okList(mres), pok = okList(pres);
|
||||
while (into.firstChild) into.removeChild(into.firstChild);
|
||||
if (!mok) {
|
||||
into.appendChild(E('div', { 'style': 'color:red;' }, [ errText(mres, _('Could not list members')) ]));
|
||||
return;
|
||||
}
|
||||
/* A failed peer list must not fabricate an all-OFFLINE table:
|
||||
* render the members with '-' and a muted note instead. */
|
||||
if (!pok)
|
||||
into.appendChild(E('div', { 'style': 'color:#888; font-size:12px; margin-bottom:4px;' }, [ _('Peer status unavailable') ]));
|
||||
var idI = E('input', { 'type': 'text', 'placeholder': _('Node address (10 hex)'), 'style': 'width:180px;' });
|
||||
var authBtn = E('button', { 'class': 'cbi-button cbi-button-add' }, [ _('Authorize') ]);
|
||||
authBtn.addEventListener('click', function() {
|
||||
var mid = idI.value.trim();
|
||||
if (!/^[0-9a-fA-F]{10}$/.test(mid)) {
|
||||
ui.addNotification(null, E('span', { 'class': 'alert-message warning' }, [ _('A node address is exactly 10 hex digits') ]), 'warning');
|
||||
return;
|
||||
}
|
||||
authBtn.disabled = true;
|
||||
var body2 = JSON.stringify({ authorized: true, activeBridge: false, capability: null, id: mid, name: '', nodeId: mid });
|
||||
L.resolveDefault(rpcMemberSet(section, nwid, mid, body2), {}).then(function(r2) {
|
||||
if (r2 && r2.code === 0) { idI.value = ''; loadMembers(nwid, into); }
|
||||
else ui.addNotification(null, E('span', { 'class': 'alert-message warning' }, [ errText(r2, _('Authorize failed')) ]), 'warning');
|
||||
authBtn.disabled = false;
|
||||
});
|
||||
});
|
||||
|
||||
into.appendChild(E('div', { 'style': 'display:flex; gap:6px; align-items:center; margin:6px 0;' }, [ idI, authBtn ]));
|
||||
|
||||
/* The member objects are the cached model: every inline edit commits
|
||||
* against its entry here and the filter re-renders from it, so no
|
||||
* keystroke ever costs an SSH round-trip. */
|
||||
var membersArr = mres.body;
|
||||
if (!membersArr.length) {
|
||||
into.appendChild(E('div', { 'style': 'color:orange;' }, [ _('No members have joined this network yet.') ]));
|
||||
return;
|
||||
}
|
||||
|
||||
/* The controller's own node address is the first 10 hex of the nwid,
|
||||
* which is why e.g. f08c171006 shows as CONTROLLER. This must be
|
||||
* checked before the online test: a node never peers with itself,
|
||||
* so the controller is absent from the peer list and would
|
||||
* otherwise render OFFLINE. */
|
||||
var controllerAddr = nwid.substring(0, 10);
|
||||
var peerMap = {};
|
||||
if (pok) pres.body.forEach(function(p) { if (p && p.address) peerMap[p.address] = p; });
|
||||
|
||||
/* Online = at least one non-expired physical path. An absent peer
|
||||
* and a peer whose paths have all expired are both OFFLINE. */
|
||||
function isOnline(p) {
|
||||
if (!p || !Array.isArray(p.paths)) return false;
|
||||
for (var i = 0; i < p.paths.length; i++)
|
||||
if (p.paths[i] && p.paths[i].expired !== true) return true;
|
||||
return false;
|
||||
}
|
||||
/* PLANET and root peers legitimately report versionMajor -1
|
||||
* ("version":"-1.-1.-1"); a negative or missing major means "no
|
||||
* version", not a version to render. */
|
||||
function peerVersion(p) {
|
||||
if (!p || p.versionMajor == null || p.versionMajor < 0) return null;
|
||||
return 'v' + p.versionMajor + '.' + p.versionMinor + '.' + (p.versionRev || 0);
|
||||
}
|
||||
function pickPath(p) {
|
||||
if (!p || !Array.isArray(p.paths) || !p.paths.length) return null;
|
||||
var i, pth = null;
|
||||
for (i = 0; i < p.paths.length; i++) if (p.paths[i] && p.paths[i].preferred) { pth = p.paths[i]; break; }
|
||||
if (!pth) for (i = 0; i < p.paths.length; i++) if (p.paths[i] && p.paths[i].active) { pth = p.paths[i]; break; }
|
||||
return pth || p.paths[0];
|
||||
}
|
||||
|
||||
var filterI = E('input', { 'type': 'text', 'placeholder': _('Filter by name, address or IP'), 'style': 'width:220px; margin-right:8px;' });
|
||||
var counterHost = E('span', { 'style': 'color:#888; font-size:12px;' }, []);
|
||||
var tbody = E('tbody', {});
|
||||
|
||||
/* Pending / saved / failed on a narrow column: signal with the input
|
||||
* border and clear it shortly after, rather than a status element. */
|
||||
function flashBorder(el, color) {
|
||||
el.style.borderColor = color;
|
||||
setTimeout(function() { el.style.borderColor = ''; }, 1500);
|
||||
}
|
||||
function setCounter(shown) {
|
||||
while (counterHost.firstChild) counterHost.removeChild(counterHost.firstChild);
|
||||
counterHost.appendChild(E('span', {}, [ shown + ' / ' + membersArr.length + ' ' + _('members') ]));
|
||||
}
|
||||
|
||||
/* Inline edits post single-field bodies on purpose: the controller
|
||||
* merges presence-checked scalars, and the ztncui reference UI posts
|
||||
* single fields against this same controller in production use. */
|
||||
function memberRow(m) {
|
||||
var mid = m.id;
|
||||
var rmBusy = false;
|
||||
var rm = E('a', {
|
||||
'href': '#', 'title': _('Remove'),
|
||||
'style': 'cursor:pointer; text-decoration:none;',
|
||||
'click': function(ev) {
|
||||
ev.preventDefault();
|
||||
if (rmBusy) return;
|
||||
if (!confirm(_('Remove member ') + mid + ' from this network?')) return;
|
||||
rmBusy = true;
|
||||
L.resolveDefault(rpcMemberSet(section, nwid, mid, JSON.stringify({
|
||||
name: '', authorized: false, activeBridge: false, ipAssignments: [], noAutoAssignIps: false
|
||||
})), {}).then(function() { loadMembers(nwid, into); });
|
||||
}
|
||||
}, [ '✕' ]);
|
||||
|
||||
var nameI = E('input', { 'type': 'text', 'value': m.name || '', 'style': 'width:100%; box-sizing:border-box;' });
|
||||
var nameBusy = false, nameAfterKey = false;
|
||||
function commitName() {
|
||||
var v = nameI.value;
|
||||
if (v === (m.name || '') || nameBusy) return;
|
||||
nameBusy = true;
|
||||
nameI.style.borderColor = 'orange';
|
||||
L.resolveDefault(rpcMemberSet(section, nwid, mid, JSON.stringify({ name: v })), {}).then(function(r2) {
|
||||
nameBusy = false;
|
||||
if (r2 && r2.code === 0) { m.name = v; flashBorder(nameI, 'green'); }
|
||||
else {
|
||||
flashBorder(nameI, 'red');
|
||||
ui.addNotification(null, E('span', { 'class': 'alert-message warning' }, [ errText(r2, _('Update failed')) ]), 'warning');
|
||||
}
|
||||
});
|
||||
}
|
||||
nameI.addEventListener('blur', function() {
|
||||
if (nameAfterKey) { nameAfterKey = false; return; }
|
||||
commitName();
|
||||
});
|
||||
nameI.addEventListener('keydown', function(ev) {
|
||||
if (ev.keyCode === 13) { ev.preventDefault(); nameAfterKey = true; commitName(); }
|
||||
else if (ev.keyCode === 27) nameI.value = m.name || '';
|
||||
});
|
||||
|
||||
function checkboxTd(field) {
|
||||
var c = E('input', { 'type': 'checkbox' });
|
||||
c.checked = !!m[field];
|
||||
var busy = false;
|
||||
c.addEventListener('change', function() {
|
||||
if (busy) return;
|
||||
busy = true;
|
||||
var b = {};
|
||||
b[field] = c.checked;
|
||||
L.resolveDefault(rpcMemberSet(section, nwid, mid, JSON.stringify(b)), {}).then(function(r2) {
|
||||
busy = false;
|
||||
if (r2 && r2.code === 0) m[field] = c.checked;
|
||||
else {
|
||||
c.checked = !!m[field];
|
||||
ui.addNotification(null, E('span', { 'class': 'alert-message warning' }, [ errText(r2, _('Update failed')) ]), 'warning');
|
||||
}
|
||||
});
|
||||
});
|
||||
return E('td', { 'style': 'width:9%; text-align:center;' }, [ c ]);
|
||||
}
|
||||
|
||||
var ipTd = E('td', { 'style': 'width:13%; font-family:monospace; white-space:nowrap;' }, []);
|
||||
var ipBusy = false, ipEditing = false;
|
||||
function drawIp() {
|
||||
while (ipTd.firstChild) ipTd.removeChild(ipTd.firstChild);
|
||||
var ips = m.ipAssignments || [];
|
||||
var clickable = E('span', { 'style': 'cursor:pointer;' }, ips.length
|
||||
? [ ips.join(', ') ]
|
||||
: [ E('span', { 'style': 'color:orange;' }, [
|
||||
/* Empty ipAssignments with version -1 means the node has
|
||||
* never come up on this network; a positive version with
|
||||
* no IP means the network has no pool. */
|
||||
(m.vMajor != null && m.vMajor < 0) ? _('never connected') : _('no pool assigned')
|
||||
]) ]);
|
||||
ipTd.appendChild(clickable);
|
||||
}
|
||||
function startIpEdit() {
|
||||
if (ipBusy) return;
|
||||
ipEditing = true;
|
||||
while (ipTd.firstChild) ipTd.removeChild(ipTd.firstChild);
|
||||
var inp = E('input', { 'type': 'text', 'value': (m.ipAssignments || []).join(', '), 'style': 'width:100%; box-sizing:border-box; font-family:monospace;' });
|
||||
ipTd.appendChild(inp);
|
||||
if (inp.focus) inp.focus();
|
||||
var afterKey = false;
|
||||
function close() { ipEditing = false; drawIp(); }
|
||||
function doCommit() {
|
||||
var raw = inp.value.trim(), arr = [], i;
|
||||
if (raw !== '') {
|
||||
arr = raw.split(',');
|
||||
for (i = 0; i < arr.length; i++) {
|
||||
arr[i] = arr[i].trim();
|
||||
if (!isIp(arr[i])) {
|
||||
ui.addNotification(null, E('span', { 'class': 'alert-message warning' }, [ _('Managed IPs must be IPv4 addresses, comma-separated') ]), 'warning');
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (arr.join(',') === (m.ipAssignments || []).join(',') || ipBusy) { close(); return; }
|
||||
ipBusy = true;
|
||||
inp.style.borderColor = 'orange';
|
||||
L.resolveDefault(rpcMemberSet(section, nwid, mid, JSON.stringify({ ipAssignments: arr })), {}).then(function(r2) {
|
||||
ipBusy = false;
|
||||
if (r2 && r2.code === 0) { m.ipAssignments = arr; close(); }
|
||||
else {
|
||||
flashBorder(inp, 'red');
|
||||
ui.addNotification(null, E('span', { 'class': 'alert-message warning' }, [ errText(r2, _('Update failed')) ]), 'warning');
|
||||
}
|
||||
});
|
||||
}
|
||||
inp.addEventListener('blur', function() {
|
||||
if (afterKey) { afterKey = false; return; }
|
||||
if (ipEditing) doCommit();
|
||||
});
|
||||
inp.addEventListener('keydown', function(ev) {
|
||||
if (ev.keyCode === 13) { ev.preventDefault(); afterKey = true; doCommit(); }
|
||||
else if (ev.keyCode === 27) { inp.value = (m.ipAssignments || []).join(', '); close(); }
|
||||
});
|
||||
}
|
||||
ipTd.addEventListener('click', function() { if (!ipEditing) startIpEdit(); });
|
||||
drawIp();
|
||||
|
||||
function statusContent() {
|
||||
if (!pok) return E('span', { 'style': 'color:#888;' }, [ '-' ]);
|
||||
if (mid === controllerAddr) return E('span', { 'style': 'color:green; font-weight:bold;' }, [ 'CONTROLLER' ]);
|
||||
var p = peerMap[mid];
|
||||
if (!isOnline(p)) return E('span', { 'style': 'color:red; font-weight:bold;' }, [ 'OFFLINE' ]);
|
||||
var v = peerVersion(p);
|
||||
return E('span', { 'style': 'color:green; font-weight:bold;' }, [ 'ONLINE' + (v ? ' (' + v + ')' : '') ]);
|
||||
}
|
||||
function addrContent() {
|
||||
if (!pok) return '-';
|
||||
var p = peerMap[mid];
|
||||
var pth = pickPath(p);
|
||||
if (!pth || !pth.address) return '-';
|
||||
/* latency 0 means "not measured yet", not "0 ms" -- render (-). */
|
||||
return pth.address + ((p.latency > 0) ? ' (' + p.latency + ' ms)' : ' (-)');
|
||||
}
|
||||
|
||||
return E('tr', {}, [
|
||||
E('td', { 'style': 'width:34px; text-align:center;' }, [ rm ]),
|
||||
E('td', { 'style': 'width:19%;' }, [ nameI ]),
|
||||
E('td', { 'style': 'width:12%; font-family:monospace; white-space:nowrap;' }, [
|
||||
E('code', {}, [ E('a', { 'href': '#', 'click': function(ev) { ev.preventDefault(); } }, [ mid ]) ])
|
||||
]),
|
||||
checkboxTd('authorized'),
|
||||
checkboxTd('activeBridge'),
|
||||
ipTd,
|
||||
E('td', { 'style': 'width:12%;' }, [ statusContent() ]),
|
||||
E('td', { 'style': 'width:26%; font-family:monospace;' }, [ addrContent() ])
|
||||
]);
|
||||
}
|
||||
|
||||
function drawRows() {
|
||||
while (tbody.firstChild) tbody.removeChild(tbody.firstChild);
|
||||
var q = filterI.value ? filterI.value.toLowerCase() : '';
|
||||
var shown = 0;
|
||||
membersArr.forEach(function(m) {
|
||||
var hay = ((m.name || '') + ' ' + m.id + ' ' + (m.ipAssignments || []).join(', ')).toLowerCase();
|
||||
if (q && hay.indexOf(q) === -1) return;
|
||||
shown++;
|
||||
tbody.appendChild(memberRow(m));
|
||||
});
|
||||
if (!shown) tbody.appendChild(E('tr', {}, [ E('td', { 'colspan': '8', 'style': 'color:orange;' }, [ _('No members match.') ]) ]));
|
||||
setCounter(shown);
|
||||
}
|
||||
filterI.addEventListener('input', drawRows);
|
||||
|
||||
/* ztncui column order. Lives inside the members div, never nested in
|
||||
* the network <table>: nested tables share column widths (see the
|
||||
* comment above netRow's return). Header labels may wrap; the fixed
|
||||
* layout plus 34px + 19+12+9+9+13+12+26% keeps the grid stable. */
|
||||
var hdr = E('tr', {}, [
|
||||
E('th', { 'style': 'width:34px;' }, []),
|
||||
E('th', { 'style': 'width:19%;' }, [_('Member name')]),
|
||||
E('th', { 'style': 'width:12%;' }, [_('Member ID')]),
|
||||
E('th', { 'style': 'width:9%; text-align:center;' }, [_('Authorized')]),
|
||||
E('th', { 'style': 'width:9%; text-align:center;' }, [_('Active bridge')]),
|
||||
E('th', { 'style': 'width:13%;' }, [_('IP assignment')]),
|
||||
E('th', { 'style': 'width:12%;' }, [_('Peer status')]),
|
||||
E('th', { 'style': 'width:26%;' }, [_('Peer address / latency')])
|
||||
]);
|
||||
into.appendChild(E('div', { 'style': 'display:flex; gap:8px; align-items:center; margin:6px 0;' }, [ filterI, counterHost ]));
|
||||
/* The scroll wrapper and min-width are load-bearing: table-layout:fixed
|
||||
* pins column widths but does NOT clip or wrap content, so a nowrap cell
|
||||
* wider than its share prints over the next column. Screenshotting the
|
||||
* page at 820px showed "192.168.192.x" on top of "OFFLINE" down the whole
|
||||
* table. 1040px is where the widest nowrap cell (the peer endpoint, 26%
|
||||
* column) stops overflowing; below it the table scrolls instead of
|
||||
* colliding, and above it the min-width is inert. */
|
||||
into.appendChild(E('div', { 'style': 'overflow-x:auto;' }, [
|
||||
E('table', { 'class': 'table', 'style': 'table-layout:fixed; width:100%; min-width:1040px;' }, [ hdr, tbody ])
|
||||
]));
|
||||
drawRows();
|
||||
});
|
||||
}
|
||||
|
||||
function load() {
|
||||
while (table.firstChild) table.removeChild(table.firstChild);
|
||||
table.appendChild(E('div', { 'style': 'color:orange;' }, [ _('Loading...') ]));
|
||||
L.resolveDefault(rpcCtlGet(section, '/controller/network'), {}).then(function(res) {
|
||||
while (table.firstChild) table.removeChild(table.firstChild);
|
||||
if (res && res.code !== 200) {
|
||||
table.appendChild(E('div', { 'style': 'color:red;' }, [ errText(res, _('Could not reach the controller')) ]));
|
||||
return;
|
||||
}
|
||||
var ids = Array.isArray(res.body) ? res.body : [];
|
||||
if (!ids.length) {
|
||||
table.appendChild(E('div', { 'style': 'color:orange;' }, [ _('This controller has no networks yet.') ]));
|
||||
return;
|
||||
}
|
||||
var head = E('tr', {}, [
|
||||
E('th', { 'style': 'width:170px;' }, [_('Network ID')]),
|
||||
E('th', { 'style': 'min-width:160px;' }, [_('Name')]),
|
||||
E('th', { 'style': 'text-align:center; white-space:nowrap;' }, [_('Broadcast')]),
|
||||
E('th', { 'style': 'text-align:right;' }, [_('Actions')])
|
||||
]);
|
||||
/* netRow returns a block element, not a <tr>: the per-network editors
|
||||
* below the row must not be trapped inside the table, or their wide
|
||||
* forms stretch the nwid column (see netRow). So the header goes in
|
||||
* its own table and each network follows as a sibling block. */
|
||||
table.appendChild(E('table', { 'class': 'table' }, [ head ]));
|
||||
ids.forEach(function(nwid) {
|
||||
L.resolveDefault(rpcCtlGet(section, '/controller/network/' + nwid), {}).then(function(d) {
|
||||
if (d && d.code === 200 && d.body) table.appendChild(netRow(d.body));
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
refresh.addEventListener('click', load);
|
||||
add.addEventListener('click', function() {
|
||||
add.disabled = true;
|
||||
L.resolveDefault(rpcNetSet(section, 'new', '{}'), {}).then(function(res) {
|
||||
if (res && res.code === 200) load();
|
||||
else ui.addNotification(null, E('span', { 'class': 'alert-message warning' }, [ errText(res, _('Create failed')) ]), 'warning');
|
||||
add.disabled = false;
|
||||
});
|
||||
});
|
||||
|
||||
load();
|
||||
return E('div', {}, [ E('div', { 'style': 'display:flex; gap:8px; margin-top:8px;' }, [ refresh, add ]), table ]);
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------- moon */
|
||||
|
||||
function moonPanel(section) {
|
||||
var box = E('div', { 'style': 'margin-top:8px;' });
|
||||
var planBtn = E('button', { 'class': 'cbi-button' }, [ _('Show plan') ]);
|
||||
|
||||
function showPlan(res) {
|
||||
while (box.firstChild) box.removeChild(box.firstChild);
|
||||
if (!res || res.code !== 0) {
|
||||
box.appendChild(E('div', { 'style': 'color:red;' }, [ errText(res, _('Could not build a plan')) ]));
|
||||
return;
|
||||
}
|
||||
var go = E('button', { 'class': 'cbi-button cbi-button-action important' }, [ _('Create and sign the moon') ]);
|
||||
go.addEventListener('click', function() {
|
||||
if (!confirm(_('This will create moon.json (holding the signing secret) and a signed .moon on the remote host. Continue?'))) return;
|
||||
go.disabled = true;
|
||||
L.resolveDefault(rpcMoonApply(section, res.confirm), {}).then(function(r2) {
|
||||
go.disabled = false;
|
||||
while (box.firstChild) box.removeChild(box.firstChild);
|
||||
if (!r2 || r2.code !== 0) {
|
||||
box.appendChild(E('div', { 'style': 'color:red;' }, [ errText(r2, _('Moon creation failed')) ]));
|
||||
return;
|
||||
}
|
||||
var href = 'data:application/octet-stream;base64,' + r2.moon_b64;
|
||||
box.appendChild(E('div', { 'style': 'color:green; margin-bottom:6px;' }, [
|
||||
_('Moon created: '), E('code', {}, [ r2.moon_id ]), ' — ',
|
||||
E('a', { 'href': href, 'download': r2.moon_file || (r2.moon_id + '.moon'), 'class': 'cbi-button' }, [ _('Download .moon') ])
|
||||
]));
|
||||
box.appendChild(E('div', { 'style': 'color:orange;' }, [
|
||||
_('Distribute this file to members, then use Interface Info → Moons → Add Moon on each node. The signing secret never left the remote host.')
|
||||
]));
|
||||
});
|
||||
});
|
||||
|
||||
box.appendChild(E('div', { 'style': 'margin:6px 0;' }, [
|
||||
E('div', {}, [ _('The following will run on the remote host:') ]),
|
||||
E('pre', {
|
||||
/* Same reason as the requirements line: a fixed light background
|
||||
* is unreadable under LuCI's dark theme. */
|
||||
'style': 'border:1px solid #666; border-radius:3px; padding:8px; overflow:auto; font-size:12px;'
|
||||
}, [ res.script ])
|
||||
]));
|
||||
box.appendChild(E('div', { 'style': 'color:orange; margin-bottom:6px;' }, [ res.notes ]));
|
||||
box.appendChild(go);
|
||||
}
|
||||
|
||||
planBtn.addEventListener('click', function() {
|
||||
planBtn.disabled = true;
|
||||
planBtn.textContent = _('Building...');
|
||||
L.resolveDefault(rpcMoonPlan(section), {}).then(function(res) {
|
||||
showPlan(res);
|
||||
planBtn.disabled = false;
|
||||
planBtn.textContent = _('Show plan');
|
||||
}).catch(function() {
|
||||
showPlan(null);
|
||||
planBtn.disabled = false;
|
||||
planBtn.textContent = _('Show plan');
|
||||
});
|
||||
});
|
||||
|
||||
return E('div', {}, [
|
||||
planBtn,
|
||||
E('div', { 'style': 'color:orange; margin-top:4px;' }, [
|
||||
_('The remote host becomes the single root of a new moon. Additional roots are not supported yet.')
|
||||
]),
|
||||
box
|
||||
]);
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------------- page */
|
||||
|
||||
return view.extend({
|
||||
load: function() {
|
||||
return Promise.all([ uci.load('zerotier') ]);
|
||||
},
|
||||
|
||||
render: function() {
|
||||
var listBox = E('div', {});
|
||||
|
||||
function reload() {
|
||||
L.resolveDefault(rpcHostList(), {}).then(function(res) {
|
||||
while (listBox.firstChild) listBox.removeChild(listBox.firstChild);
|
||||
var hosts = (res && res.hosts) || [];
|
||||
if (!hosts.length) {
|
||||
listBox.appendChild(E('div', { 'style': 'color:orange;' }, [
|
||||
_('No remote hosts configured yet. Add one to manage a Controller or Moon that runs on a separate server.')
|
||||
]));
|
||||
return;
|
||||
}
|
||||
hosts.forEach(function(h) {
|
||||
var detail = E('div', { 'style': 'display:none; margin:8px 0 16px 0; padding-left:12px; border-left:3px solid #ccc;' });
|
||||
var toggle = E('button', { 'class': 'cbi-button cbi-button-action' }, [ _('Manage') ]);
|
||||
toggle.addEventListener('click', function() {
|
||||
/* A freshly created div has display '', not 'none', so the
|
||||
* open test has to be for 'block' -- testing "!== 'none'"
|
||||
* reads the initial state as already-open and the first
|
||||
* click collapses the panel instead of building it. */
|
||||
var isOpen = detail.style.display === 'block';
|
||||
detail.style.display = isOpen ? 'none' : 'block';
|
||||
toggle.textContent = isOpen ? _('Manage') : _('Hide');
|
||||
if (!isOpen && !detail.firstChild) {
|
||||
detail.appendChild(E('h4', {}, [_('Diagnostics')]));
|
||||
detail.appendChild(diagnostics(h.section, h));
|
||||
detail.appendChild(E('h4', {}, [_('Networks')]));
|
||||
detail.appendChild(networksPanel(h.section));
|
||||
detail.appendChild(E('h4', {}, [_('Moon')]));
|
||||
detail.appendChild(moonPanel(h.section));
|
||||
}
|
||||
});
|
||||
|
||||
var edit = E('button', { 'class': 'cbi-button' }, [ _('Edit') ]);
|
||||
edit.addEventListener('click', function() { hostEditor(h.section, h, reload); });
|
||||
|
||||
var del = E('button', { 'class': 'cbi-button cbi-button-remove' }, [ _('Delete') ]);
|
||||
del.addEventListener('click', function() {
|
||||
if (!confirm(_('Remove ') + h.name + ' from this router? The remote host is not modified.')) return;
|
||||
del.disabled = true;
|
||||
L.resolveDefault(rpcHostDel(h.section), {}).then(function() { reload(); });
|
||||
});
|
||||
|
||||
listBox.appendChild(E('div', { 'class': 'cbi-section' }, [
|
||||
E('div', { 'style': 'display:flex; justify-content:space-between; align-items:center; flex-wrap:wrap; gap:8px;' }, [
|
||||
E('div', {}, [
|
||||
E('strong', {}, [ h.name ]),
|
||||
E('div', { 'style': 'font-family:monospace; font-size:12px; color:#666;' }, [
|
||||
h.user + '@' + h.host + ':' + h.port + _(' (controller port ') + h.controller_port + ')'
|
||||
]),
|
||||
h.key_present
|
||||
? E('div', { 'style': 'font-size:12px; color:green;' }, [_('SSH key installed')])
|
||||
: E('div', { 'style': 'font-size:12px; color:red;' }, [_('No SSH key — add one before use')])
|
||||
]),
|
||||
E('div', { 'style': 'display:flex; gap:6px;' }, [ toggle, edit, del ])
|
||||
]),
|
||||
detail
|
||||
]));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
var addBtn = E('button', { 'class': 'cbi-button cbi-button-add' }, [ _('Add remote host') ]);
|
||||
addBtn.addEventListener('click', function() { hostEditor(null, null, reload); });
|
||||
|
||||
reload();
|
||||
|
||||
return E('div', { 'class': 'cbi-map' }, [
|
||||
E('h2', {}, [ _('ZeroTier'), ' - ', _('Remote Controller') ]),
|
||||
E('div', { 'class': 'cbi-section' }, [
|
||||
E('h3', {}, [_('How remote management works')]),
|
||||
E('div', { 'style': 'margin-bottom:8px;' }, [
|
||||
E('p', {}, [ _('A ZeroTier controller or moon root must live on a server with a fixed public IP, reachable by SSH, with passwordless sudo for that user. The controller API has no TLS and refuses every non-loopback caller, so this page never talks to it directly: it opens an SSH tunnel from this router to the remote loopback interface and sends API calls through it. The controller authtoken is read over SSH on demand and is never stored here.') ])
|
||||
]),
|
||||
E('div', {
|
||||
/* No hardcoded background: LuCI has a dark theme, and a fixed
|
||||
* light panel with default-coloured text left this line almost
|
||||
* unreadable. A muted currentColor border works in both. */
|
||||
'style': 'border-left:3px solid #888; padding:6px 10px; opacity:0.85; font-size:13px;'
|
||||
}, [
|
||||
_('Requirements: a fixed public IP · SSH access · a keypair whose public key is in the remote authorized_keys · passwordless sudo · ZeroTier built with the controller (1.14.2, or a 1.16 nonfree build)')
|
||||
])
|
||||
]),
|
||||
E('div', { 'class': 'cbi-section' }, [
|
||||
E('h3', {}, [_('Configured hosts')]),
|
||||
listBox,
|
||||
E('div', { 'style': 'margin-top:10px;' }, [ addBtn ])
|
||||
])
|
||||
]);
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user