/**
* luci-app-quickactions
* All-in-one dashboard and system-control surface for OpenWrt / ImmortalWrt.
*
* Bundles 14 tabs: Dashboard, Essential, Tools, Logs, Services, Hotplug,
* Crontab, Guest WiFi, Terminal (ttyd), Task Plan, Network Setup, Command,
* Dependencies, Configuration.
*
* Licensed to the GNU General Public License v3.0.
*/
'use strict';
'require view';
'require ui';
'require uci';
'require rpc';
'require form';
'require fs';
'require poll';
'require dom';
'require uqr';
'require tools.widgets as widgets';
// =====================================================================
// LuCI 26.203 workaround — dom.data() crashes on stale data-idref nodes
//
// dom.content() deletes registry entries for every descendant with a
// data-idref attribute, but leaves the attribute on the node. Any later
// dom.data(node, key, value) on those nodes throws
// "Cannot set properties of undefined (setting '_class')"
// which crashes form.js's CBIMap.renderContents(), the last step of
// form.Map.save(). The writes reach the server first, but the promise
// chain rejects and the UI reports failure.
//
// Reinitialize the registry slot when we detect it's been dropped.
// =====================================================================
(function patchDOMData() {
try {
if (!dom || dom._qaDataPatched) return;
var _orig = dom.data;
dom.data = function(node, key, val) {
if (node && typeof node.getAttribute === 'function') {
var id = node.getAttribute('data-idref');
if (id != null && this.registry && !this.registry[id]) {
// Fires for BOTH the getter and setter paths. dom.content()
// deletes registry entries when nodes are detached but
// leaves the data-idref attribute on the node. The next
// dom.data(node, '_class') then hits
// this.registry[id][key]
// with registry[id] === undefined and throws
// "Cannot read properties of undefined (reading '_class')".
// Seed a fresh empty slot so the getter returns undefined.
this.registry[id] = {};
}
}
return _orig.apply(this, arguments);
};
dom._qaDataPatched = true;
console.log('[qa-fix] dom.data patched (getter+setter, data-idref)');
} catch(e) {
console.warn('[qa-fix] failed to patch dom.data:', e);
}
})();
function getCurrentLanIp() {
var lanIpRaw = uci.get('network', 'lan', 'ipaddr');
var lanIp = Array.isArray(lanIpRaw) ? (lanIpRaw[0] || '192.168.10.1') : (lanIpRaw || '192.168.10.1');
return String(lanIp).split(' ')[0];
}
var GuestWifiViewClass = (function() {
/*
Copyright 2026 Rafał Wabik - IceG - From eko.one.pl forum
Licensed to the GNU General Public License v3.0.
*/
let DEFAULTS = {
ssid: 'Guest-WiFi',
encryption: 'psk2',
password: '',
radio: 'radio0',
isolate: '1',
macaddr: '',
ip: '172.16.0.1',
netmask: '255.255.255.0',
dhcpStart: '100',
dhcpLimit: '150',
dhcpLease: '12h',
fwForwardDest: 'wan',
fwInput: 'REJECT',
fwOutput: 'ACCEPT',
fwForward: 'REJECT',
fwDhcpPorts: '67-68',
fwDnsPort: '53'
};
let OWNER_TAG = 'guest_owner';
function addGuestWifiStyles() {
if (document.getElementById('guest-wifi-styles')) return;
let style = document.createElement('style');
style.id = 'guest-wifi-styles';
style.type = 'text/css';
style.textContent = '\
:root {\
--gw-badge-on-bg: #34c759;\
--gw-badge-off-bg: #7f8c8d;\
--gw-badge-text: #ffffff;\
--gw-badge-shadow: 0 1px 2px rgba(0,0,0,.4), 0 2px 6px rgba(0,0,0,.25);\
--gw-badge-border-on: transparent;\
--gw-badge-border-off: transparent;\
}\
:root[data-darkmode="true"] {\
--gw-badge-on-bg: rgba(46,204,113,0.28);\
--gw-badge-off-bg: rgba(255,255,255,0.12);\
--gw-badge-text: #e5e7eb;\
--gw-badge-shadow: 0 1px 2px rgba(0,0,0,.35), 0 2px 6px rgba(0,0,0,.22);\
--gw-badge-border-on: rgba(46,204,113,0.5);\
--gw-badge-border-off: rgba(255,255,255,0.3);\
}\
.gw-badge {\
display:inline-block;\
padding:4px 10px;\
border-radius:4px;\
color:var(--gw-badge-text);\
font-size:13px;\
font-weight:500;\
white-space:nowrap;\
text-align:center;\
border:1px solid transparent;\
text-shadow:var(--gw-badge-shadow);\
}\
.gw-badge-on { background:var(--gw-badge-on-bg); border-color:var(--gw-badge-border-on); }\
.gw-badge-off { background:var(--gw-badge-off-bg); border-color:var(--gw-badge-border-off); }\
';
document.head.appendChild(style);
// Layout fixups for the narrow iframe context. The outer page
// reserves sidebar space, so the iframe viewport is smaller than
// a full page; these rules keep modals and controls usable.
if (!document.getElementById('guest-wifi-layout-styles')) {
let ls = document.createElement('style');
ls.id = 'guest-wifi-layout-styles';
ls.type = 'text/css';
ls.textContent = '\
.modal.cbi-modal { width: min(95vw, 1200px) !important; max-width: 95vw !important; min-width: 0 !important; }\
.modal.cbi-modal .cbi-value { display: flex !important; flex-wrap: nowrap !important; align-items: flex-start !important; }\
.modal.cbi-modal .cbi-value-title { flex: 0 0 200px !important; }\
.modal.cbi-modal .cbi-value-field { flex: 1 1 auto !important; min-width: 0 !important; }\
.cbi-value-field { min-width: 0 !important; }\
.cbi-value-field select,\
.cbi-value-field input,\
.cbi-value-field .cbi-dropdown { max-width: 100% !important; }\
.btn.qr-code { display: inline-flex; align-items: center; gap: .5em; }\
.btn.qr-code svg { display: block; height: 1.5em; width: auto; }\
html, body { background-color: var(--background-color-default, #141414) !important; background-image: none !important; }\
#view.spinning::before,\
#view.spinning::after,\
body > .spinning,\
body > .loading-overlay,\
body > .spinner { display: none !important; visibility: hidden !important; }\
.cbi-input-password { padding-right: 34px !important; }\
';
document.head.appendChild(ls);
}
}
// Un-cap .container inside the embed page. The embed view is a full
// LuCI page loaded at ?embed=guestwifi, so it has its own theme
// wrapper (.container) that caps width at ~940px. Un-cap here so the
// guest wifi form and its modals use the entire iframe viewport.
(function(){
if (document.getElementById('qa-embed-fullwidth')) return;
var s = document.createElement('style');
s.id = 'qa-embed-fullwidth';
s.type = 'text/css';
s.textContent = '.container,.main,.main-right,.cbi-map,.fs-content,.fs-main{' +
'max-width:none !important;width:100% !important;' +
'padding-left:0 !important;padding-right:0 !important;' +
'margin-left:0 !important;margin-right:0 !important;}' +
'html,body{height:auto !important;min-height:0 !important;}' +
'body>.container,body>#view,body>main{min-height:0 !important;}'
document.head.appendChild(s);
})();
function ssidBadge(section_id, text) {
let val = uci.get('guestwifi', section_id, 'enable');
let enabled = (val == null) ? true : (val === '1');
let cls = 'gw-badge ' + (enabled ? 'gw-badge-on' : 'gw-badge-off');
return E('span', { 'class': cls }, text || '');
}
function radioBadge(section_id, radioName, wifiDevices) {
let val = uci.get('guestwifi', section_id, 'enable');
let enabled = (val == null) ? true : (val === '1');
let label = radioLabel(radioName, wifiDevices);
return E('span', { 'class': 'ifacebadge' }, [
E('img', { 'src': L.resource('icons/wifi%s.svg').format(enabled ? '' : '_disabled') }),
' ',
label,
'\u00A0'
]);
}
let ENCRYPTION_MODES = [
['psk2', 'WPA2-PSK'],
['sae', 'WPA3-SAE'],
['sae-mixed', 'WPA2-PSK/WPA3-SAE ' + _('Mixed Mode')],
['psk-mixed', 'WPA-PSK/WPA2-PSK ' + _('Mixed Mode')],
['psk', 'WPA-PSK'],
['owe', 'OWE (' + _('Enhanced Open') + ')'],
['wep-open', _('WEP Open System')],
['wep-shared', _('WEP Shared Key')],
['none', _('No encryption (open network)')]
];
function encryptionLabel(key) {
let hit = ENCRYPTION_MODES.filter(function(m) { return m[0] === key; })[0];
return hit ? hit[1] : key;
}
let PASSWORD_CHARSETS = {
upper: 'ABCDEFGHIJKLMNOPQRSTUVWXYZ',
lower: 'abcdefghijklmnopqrstuvwxyz',
digits: '1234567890',
special: '!@#$%^&*()-_=+[]{}?'
};
function secureRandomInt(max) {
let cryptoObj = window.crypto || window.msCrypto;
let arr = new Uint32Array(1);
if (cryptoObj && cryptoObj.getRandomValues) {
cryptoObj.getRandomValues(arr);
return arr[0] % max;
}
return Math.floor(Math.random() * max);
}
function shuffleArray(arr) {
for (let i = arr.length - 1; i > 0; i--) {
let j = secureRandomInt(i + 1);
let tmp = arr[i];
arr[i] = arr[j];
arr[j] = tmp;
}
return arr;
}
function generateStrongPassword(length, components) {
let chosenSets = (components || []).map(function(c) { return PASSWORD_CHARSETS[c]; }).filter(Boolean);
if (!chosenSets.length)
chosenSets = [PASSWORD_CHARSETS.lower, PASSWORD_CHARSETS.digits];
length = Math.max(parseInt(length, 10) || 0, chosenSets.length);
length = Math.min(Math.max(length, 8), 63);
let pool = chosenSets.join('');
let result = chosenSets.map(function(set) { return set.charAt(secureRandomInt(set.length)); });
for (let i = result.length; i < length; i++)
result.push(pool.charAt(secureRandomInt(pool.length)));
return shuffleArray(result).join('');
}
let cbiPasswordComponentsValue = form.ListValue.extend({
renderWidget: function(section_id, option_index, cfgvalue) {
let choices = this.transformChoices();
let widget = new ui.Dropdown(
(cfgvalue != null) ? cfgvalue : this.default,
choices,
{
id: this.cbid(section_id),
sort: this.keylist,
optional: true,
multiple: true,
display_items: 4,
dropdown_items: 4,
select_placeholder: this.placeholder,
validate: L.bind(this.validate, this, section_id),
disabled: (this.readonly != null) ? this.readonly : this.map.readonly
}
);
window.__guestPasswordComponentsDropdown = window.__guestPasswordComponentsDropdown || {};
window.__guestPasswordComponentsDropdown[section_id] = widget;
return widget.render();
}
});
function buildSVGQRCode(data, code, options, dummy) {
let opts = Object.assign({
pixelSize: 4,
whiteColor: 'white',
blackColor: 'black',
ecc: 'M'
}, options);
let svg = uqr.renderSVG(data, opts);
if (dummy)
return svg;
code.style.opacity = '';
dom.content(code, Object.assign(E(svg), { style: 'width:100%;height:auto' }));
}
function assignNetIndexes() {
let defs = uci.sections('guestwifi', 'guest');
let used = {};
defs.forEach(function(def) {
let idx = parseInt(def.net_idx, 10);
if (idx > 0)
used[idx] = true;
});
let nextIdx = 1;
function takeNextFreeIdx() {
while (used[nextIdx]) nextIdx++;
used[nextIdx] = true;
return nextIdx;
}
defs.forEach(function(def) {
let idx = parseInt(def.net_idx, 10);
if (!(idx > 0))
uci.set('guestwifi', def['.name'], 'net_idx', String(takeNextFreeIdx()));
});
}
function namesForNetIndex(idx) {
let suf = (idx > 1) ? String(idx) : '';
return {
networkName: 'guest' + (suf ? '_' + suf : ''),
deviceName: 'br-guest' + suf,
wifiName: 'guestwifi' + (suf ? '_' + suf : ''),
dhcpName: 'guest' + (suf ? '_' + suf : '')
};
}
let IP_POOL_FIRST_OCTET = 172;
let IP_POOL_SECOND_FIRST = 16;
let IP_POOL_SECOND_LAST = 31;
function usedInterfaceIps(excludeSid) {
let used = {};
uci.sections('guestwifi', 'guest').forEach(function(s) {
if (s['.name'] === excludeSid) return;
if (s.interface_ip) used[s.interface_ip] = true;
});
uci.sections('network', 'interface').forEach(function(s) {
if (excludeSid != null && s[OWNER_TAG] === excludeSid) return;
if (s.ipaddr) used[s.ipaddr] = true;
});
return used;
}
function randomFreeGuestIp(excludeSid) {
let used = usedInterfaceIps(excludeSid);
let candidates = [];
for (let o2 = IP_POOL_SECOND_FIRST; o2 <= IP_POOL_SECOND_LAST; o2++) {
let ip = IP_POOL_FIRST_OCTET + '.' + o2 + '.0.1';
if (!used[ip])
candidates.push(ip);
}
if (!candidates.length)
return null;
return candidates[Math.floor(Math.random() * candidates.length)];
}
function bandLabelForDevice(dev) {
if (!dev)
return '';
let band = dev.band;
if (band === '2g') return '2.4 GHz';
if (band === '5g') return '5 GHz';
if (band === '6g') return '6 GHz';
let hw = dev.hwmode || '';
if (/^11a/.test(hw) && !/^11ax/.test(hw)) {
return '5 GHz';
}
if (/^11(b|g)/.test(hw))
return '2.4 GHz';
let htmode = dev.htmode || '';
if (/^HE160|HE80|VHT/.test(htmode))
return '5 GHz';
return '';
}
function radioLabel(radioName, wifiDevices) {
let dev = wifiDevices.filter(function(d) { return d['.name'] === radioName; })[0];
let band = bandLabelForDevice(dev);
return band ? (radioName + ' (' + band + ')') : radioName;
}
function cleanupGuestConfig(validSids, ownerSid) {
function shouldRemove(s) {
let owner = s[OWNER_TAG];
if (!owner)
return false;
if (ownerSid != null)
return owner === ownerSid;
return validSids.indexOf(owner) === -1;
}
uci.sections('network', 'interface').forEach(function(s) {
if (shouldRemove(s)) uci.remove('network', s['.name']);
});
uci.sections('network', 'device').forEach(function(s) {
if (shouldRemove(s)) uci.remove('network', s['.name']);
});
uci.sections('wireless', 'wifi-iface').forEach(function(s) {
if (shouldRemove(s)) uci.remove('wireless', s['.name']);
});
uci.sections('dhcp', 'dhcp').forEach(function(s) {
if (shouldRemove(s)) uci.remove('dhcp', s['.name']);
});
uci.sections('firewall', 'zone').forEach(function(s) {
if (shouldRemove(s)) uci.remove('firewall', s['.name']);
});
uci.sections('firewall', 'forwarding').forEach(function(s) {
if (shouldRemove(s)) uci.remove('firewall', s['.name']);
});
uci.sections('firewall', 'rule').forEach(function(s) {
if (shouldRemove(s)) uci.remove('firewall', s['.name']);
});
}
function collectOwnerSids() {
let sids = {};
uci.sections('network', 'interface').forEach(function(s) { if (s[OWNER_TAG]) sids[s[OWNER_TAG]] = true; });
uci.sections('network', 'device').forEach(function(s) { if (s[OWNER_TAG]) sids[s[OWNER_TAG]] = true; });
uci.sections('wireless', 'wifi-iface').forEach(function(s) { if (s[OWNER_TAG]) sids[s[OWNER_TAG]] = true; });
uci.sections('dhcp', 'dhcp').forEach(function(s) { if (s[OWNER_TAG]) sids[s[OWNER_TAG]] = true; });
uci.sections('firewall', 'zone').forEach(function(s) { if (s[OWNER_TAG]) sids[s[OWNER_TAG]] = true; });
uci.sections('firewall', 'forwarding').forEach(function(s) { if (s[OWNER_TAG]) sids[s[OWNER_TAG]] = true; });
uci.sections('firewall', 'rule').forEach(function(s) { if (s[OWNER_TAG]) sids[s[OWNER_TAG]] = true; });
return Object.keys(sids);
}
function importGuestNetwork(sid) {
let iface = uci.sections('network', 'interface').filter(function(s) { return s[OWNER_TAG] === sid; })[0];
let dhcp = uci.sections('dhcp', 'dhcp').filter(function(s) { return s[OWNER_TAG] === sid; })[0];
let wifi = uci.sections('wireless', 'wifi-iface').filter(function(s) { return s[OWNER_TAG] === sid; })[0];
let zone = uci.sections('firewall', 'zone').filter(function(s) { return s[OWNER_TAG] === sid; })[0];
let fwd = uci.sections('firewall', 'forwarding').filter(function(s) { return s[OWNER_TAG] === sid; })[0];
let rules = uci.sections('firewall', 'rule').filter(function(s) { return s[OWNER_TAG] === sid; });
let dhcpRule = rules.filter(function(r) { return r.proto === 'udp'; })[0];
let dnsRule = rules.filter(function(r) { return r.proto === 'tcpudp'; })[0];
uci.add('guestwifi', 'guest', sid);
uci.set('guestwifi', sid, 'enable', (wifi && wifi.disabled === '1') ? '0' : '1');
if (wifi) {
uci.set('guestwifi', sid, 'ssid', wifi.ssid || DEFAULTS.ssid);
uci.set('guestwifi', sid, 'radio', wifi.device || DEFAULTS.radio);
uci.set('guestwifi', sid, 'isolate', wifi.isolate || DEFAULTS.isolate);
if (wifi.macaddr)
uci.set('guestwifi', sid, 'macaddr', wifi.macaddr);
if (!wifi.encryption || wifi.encryption === 'none' || wifi.encryption === 'owe') {
uci.set('guestwifi', sid, 'encryption', wifi.encryption || 'none');
} else if (wifi.encryption === 'wep-open' || wifi.encryption === 'wep-shared') {
uci.set('guestwifi', sid, 'encryption', wifi.encryption);
uci.set('guestwifi', sid, 'password', wifi.key1 || DEFAULTS.password);
} else {
uci.set('guestwifi', sid, 'encryption', wifi.encryption);
uci.set('guestwifi', sid, 'password', wifi.key || DEFAULTS.password);
}
}
if (iface) {
uci.set('guestwifi', sid, 'interface_ip', iface.ipaddr || DEFAULTS.ip);
uci.set('guestwifi', sid, 'netmask', iface.netmask || DEFAULTS.netmask);
}
if (dhcp) {
uci.set('guestwifi', sid, 'dhcp_start', dhcp.start || DEFAULTS.dhcpStart);
uci.set('guestwifi', sid, 'dhcp_limit', dhcp.limit || DEFAULTS.dhcpLimit);
uci.set('guestwifi', sid, 'dhcp_lease', dhcp.leasetime || DEFAULTS.dhcpLease);
}
if (zone) {
uci.set('guestwifi', sid, 'fw_input', zone.input || DEFAULTS.fwInput);
uci.set('guestwifi', sid, 'fw_output', zone.output || DEFAULTS.fwOutput);
uci.set('guestwifi', sid, 'fw_forward', zone.forward || DEFAULTS.fwForward);
}
if (fwd) {
uci.set('guestwifi', sid, 'fw_forward_dest', fwd.dest || DEFAULTS.fwForwardDest);
}
if (dhcpRule) {
uci.set('guestwifi', sid, 'fw_dhcp_ports', dhcpRule.dest_port || DEFAULTS.fwDhcpPorts);
}
if (dnsRule) {
uci.set('guestwifi', sid, 'fw_dns_port', dnsRule.dest_port || DEFAULTS.fwDnsPort);
}
}
function knownGuestSids() {
return uci.sections('guestwifi', 'guest').map(function(s) { return s['.name']; });
}
function resolveDuplicateInterfaceIps() {
let seen = {};
uci.sections('guestwifi', 'guest').forEach(function(def) {
let sid = def['.name'];
let ip = def.interface_ip;
if (!ip) return;
if (seen[ip]) {
let newIp = randomFreeGuestIp(sid);
if (newIp) {
uci.set('guestwifi', sid, 'interface_ip', newIp);
seen[newIp] = true;
}
} else {
seen[ip] = true;
}
});
}
function guessLegacyInterfaceNames() {
return uci.sections('network', 'interface').filter(function(s) {
if (s[OWNER_TAG]) return false;
if (/^cfg[0-9a-f]+$/i.test(s['.name'])) return false;
return /guest/i.test(s['.name']) || /guest/i.test(s.device || '');
}).map(function(s) { return s['.name']; });
}
function zoneMatchesNetwork(zone, ifname) {
let net = zone.network;
if (Array.isArray(net)) return net.indexOf(ifname) !== -1;
return net === ifname;
}
function importLegacyGuestNetwork(ifname) {
let sid = ifname;
uci.set('network', ifname, OWNER_TAG, sid);
let iface = uci.sections('network', 'interface').filter(function(s) { return s['.name'] === ifname; })[0];
let deviceName = iface ? iface.device : null;
if (deviceName) {
let dev = uci.sections('network', 'device').filter(function(d) { return d.name === deviceName; })[0];
if (dev) uci.set('network', dev['.name'], OWNER_TAG, sid);
}
let dhcp = uci.sections('dhcp', 'dhcp').filter(function(d) { return d.interface === ifname; })[0];
if (dhcp) uci.set('dhcp', dhcp['.name'], OWNER_TAG, sid);
let wifi = uci.sections('wireless', 'wifi-iface').filter(function(w) { return w.network === ifname; })[0];
if (wifi) uci.set('wireless', wifi['.name'], OWNER_TAG, sid);
let zone = uci.sections('firewall', 'zone').filter(function(z) { return zoneMatchesNetwork(z, ifname); })[0];
if (zone) {
uci.set('firewall', zone['.name'], OWNER_TAG, sid);
let zoneName = zone.name || zone['.name'];
uci.sections('firewall', 'forwarding').forEach(function(f) {
if (f.src === zoneName) uci.set('firewall', f['.name'], OWNER_TAG, sid);
});
uci.sections('firewall', 'rule').forEach(function(r) {
if (r.src === zoneName) uci.set('firewall', r['.name'], OWNER_TAG, sid);
});
}
importGuestNetwork(sid);
}
function importExistingGuestNetworks() {
collectOwnerSids().forEach(function(sid) {
if (knownGuestSids().indexOf(sid) === -1)
importGuestNetwork(sid);
});
guessLegacyInterfaceNames().forEach(function(ifname) {
if (knownGuestSids().indexOf(ifname) === -1)
importLegacyGuestNetwork(ifname);
});
}
return view.extend({
load: function() {
return Promise.all([
uci.load('network'),
uci.load('wireless'),
uci.load('dhcp'),
uci.load('firewall'),
uci.load('guestwifi')
]).then(function() {
importExistingGuestNetworks();
});
},
buildGuestNetwork: function(sid) {
let get = function(name, def) { return uci.get('guestwifi', sid, name) || def; };
let enabled = get('enable', '1') === '1';
// Persist enable explicitly — form.Flag.default is not written to
// UCI on its own, so without this the toggle renders off even when
// the app treats a missing enable key as enabled.
uci.set('guestwifi', sid, 'enable', enabled ? '1' : '0');
let ssid = get('ssid', DEFAULTS.ssid);
let encryption = get('encryption', DEFAULTS.encryption);
let password = get('password', DEFAULTS.password);
let radio = get('radio', DEFAULTS.radio);
let isolate = get('isolate', DEFAULTS.isolate);
let macaddr = get('macaddr', DEFAULTS.macaddr);
let ip = get('interface_ip', DEFAULTS.ip);
let netmask = get('netmask', DEFAULTS.netmask);
let dhcpStart = get('dhcp_start', DEFAULTS.dhcpStart);
let dhcpLimit = get('dhcp_limit', DEFAULTS.dhcpLimit);
let dhcpLease = get('dhcp_lease', DEFAULTS.dhcpLease);
let fwDest = get('fw_forward_dest', DEFAULTS.fwForwardDest);
let fwInput = get('fw_input', DEFAULTS.fwInput);
let fwOutput = get('fw_output', DEFAULTS.fwOutput);
let fwForward = get('fw_forward', DEFAULTS.fwForward);
let fwDhcpPorts= get('fw_dhcp_ports', DEFAULTS.fwDhcpPorts);
let fwDnsPort = get('fw_dns_port', DEFAULTS.fwDnsPort);
let netIdx = parseInt(get('net_idx', ''), 10);
let names = namesForNetIndex(netIdx > 0 ? netIdx : 1);
let networkName = names.networkName;
let deviceName = names.deviceName;
let wifiName = names.wifiName;
let dhcpName = names.dhcpName;
// Original 4IceG/luci-app-guest-wifi-js form — uci.add() unconditional
uci.add('network', 'interface', networkName);
uci.set('network', networkName, OWNER_TAG, sid);
uci.set('network', networkName, 'device', deviceName);
uci.set('network', networkName, 'proto', 'static');
uci.set('network', networkName, 'ipaddr', ip);
uci.set('network', networkName, 'netmask', netmask);
let devSid = uci.add('network', 'device');
uci.set('network', devSid, OWNER_TAG, sid);
uci.set('network', devSid, 'name', deviceName);
uci.set('network', devSid, 'type', 'bridge');
uci.set('network', devSid, 'bridge_empty', '1');
uci.add('dhcp', 'dhcp', dhcpName);
uci.set('dhcp', dhcpName, OWNER_TAG, sid);
uci.set('dhcp', dhcpName, 'start', dhcpStart);
uci.set('dhcp', dhcpName, 'limit', dhcpLimit);
uci.set('dhcp', dhcpName, 'leasetime', dhcpLease);
uci.set('dhcp', dhcpName, 'interface', networkName);
uci.set('dhcp', dhcpName, 'dhcpv4', 'server');
uci.set('dhcp', dhcpName, 'ignore', enabled ? '0' : '1');
uci.add('wireless', 'wifi-iface', wifiName);
uci.set('wireless', wifiName, OWNER_TAG, sid);
uci.set('wireless', wifiName, 'device', radio);
uci.set('wireless', wifiName, 'mode', 'ap');
uci.set('wireless', wifiName, 'network', networkName);
uci.set('wireless', wifiName, 'ssid', ssid);
uci.set('wireless', wifiName, 'isolate', isolate);
if (macaddr)
uci.set('wireless', wifiName, 'macaddr', macaddr);
uci.set('wireless', wifiName, 'disabled', enabled ? '0' : '1');
if (encryption === 'none' || encryption === 'owe') {
uci.set('wireless', wifiName, 'encryption', encryption);
} else if (encryption === 'wep-open' || encryption === 'wep-shared') {
uci.set('wireless', wifiName, 'encryption', encryption);
uci.set('wireless', wifiName, 'key', '1');
uci.set('wireless', wifiName, 'key1', password);
} else {
uci.set('wireless', wifiName, 'encryption', encryption);
uci.set('wireless', wifiName, 'key', password);
}
let zoneSid = uci.add('firewall', 'zone');
uci.set('firewall', zoneSid, OWNER_TAG, sid);
uci.set('firewall', zoneSid, 'name', networkName);
uci.set('firewall', zoneSid, 'network', [ networkName ]);
uci.set('firewall', zoneSid, 'input', fwInput);
uci.set('firewall', zoneSid, 'output', fwOutput);
uci.set('firewall', zoneSid, 'forward', fwForward);
let fwdSid = uci.add('firewall', 'forwarding');
uci.set('firewall', fwdSid, OWNER_TAG, sid);
uci.set('firewall', fwdSid, 'src', networkName);
uci.set('firewall', fwdSid, 'dest', fwDest);
let dhcpRuleSid = uci.add('firewall', 'rule');
uci.set('firewall', dhcpRuleSid, OWNER_TAG, sid);
uci.set('firewall', dhcpRuleSid, 'src', networkName);
uci.set('firewall', dhcpRuleSid, 'proto', 'udp');
uci.set('firewall', dhcpRuleSid, 'src_port', fwDhcpPorts);
uci.set('firewall', dhcpRuleSid, 'dest_port', fwDhcpPorts);
uci.set('firewall', dhcpRuleSid, 'target', 'ACCEPT');
uci.set('firewall', dhcpRuleSid, 'family', 'ipv4');
let dnsRuleSid = uci.add('firewall', 'rule');
uci.set('firewall', dnsRuleSid, OWNER_TAG, sid);
uci.set('firewall', dnsRuleSid, 'src', networkName);
uci.set('firewall', dnsRuleSid, 'dest_port', fwDnsPort);
uci.set('firewall', dnsRuleSid, 'target', 'ACCEPT');
uci.set('firewall', dnsRuleSid, 'family', 'ipv4');
uci.set('firewall', dnsRuleSid, 'proto', 'tcpudp');
},
applyAll: function(m) {
let self = this;
// form.Map.save() can throw synchronously (from checkDepends ->
// triggerValidation -> getUIElement) if a dependent option's DOM
// node lost its data-idref registry slot. Wrap in a resolved
// promise so the .catch() at the end of this chain actually fires.
return Promise.resolve().then(function() {
return m.save();
}).then(function() {
resolveDuplicateInterfaceIps();
assignNetIndexes();
let defs = uci.sections('guestwifi', 'guest');
let validSids = defs.map(function(s) { return s['.name']; });
cleanupGuestConfig(validSids, null);
defs.forEach(function(def) {
let sid = def['.name'];
cleanupGuestConfig(validSids, sid);
self.buildGuestNetwork(sid);
});
return uci.save();
}).then(function() {
// Auto-apply removed: only Save & Apply triggers it.
}).catch(function(err) {
var msg = (err && err.message) ? err.message : String(err);
if (/No data|code 5|Permission denied|code 6/i.test(msg)) {
return;
}
ui.addNotification(null, E('p', {}, _('Error saving configuration:') + ' ' + msg), 'error');
return Promise.reject(err);
});
},
render: function() {
let self = this;
addGuestWifiStyles();
// Modal sweep: LuCI pre-renders the modal once and toggles
// its display. No DOM insertion event fires on open, so we
// actively sweep for visible modals and force the width via
// inline !important. Runs on click, on interval, and once now.
if (!window._qaWidenModals) {
window._qaWidenModals = function() {
try {
var mods = document.querySelectorAll('.modal, .cbi-modal');
for (var i = 0; i < mods.length; i++) {
var m = mods[i];
var r = m.getBoundingClientRect();
if (r.width < 100 || r.height < 50) continue;
m.style.setProperty('width', 'min(95vw, 2200px)', 'important');
m.style.setProperty('max-width', 'calc(100vw - 32px)', 'important');
m.style.setProperty('min-width', '0', 'important');
}
} catch(e) {}
};
if (!window._qaModalSweepTimer) {
window._qaModalSweepTimer = setInterval(window._qaWidenModals, 400);
}
document.addEventListener('click', function() {
setTimeout(window._qaWidenModals, 30);
setTimeout(window._qaWidenModals, 150);
setTimeout(window._qaWidenModals, 400);
}, true);
}
setTimeout(window._qaWidenModals, 100);
let wifiDevices = uci.sections('wireless', 'wifi-device');
let radios = wifiDevices.map(function(d) { return d['.name']; });
if (!radios.length)
radios = ['radio0', 'radio1', 'radio2'];
let m = new form.Map('guestwifi', _('Guest Wi-Fi'),
_('A user interface for easily creating and management isolated Wi-Fi networks for guests, each with its own access point, DHCP, and firewall rules.'));
self.map = m;
// The map's own page-actions (Save / Save & Apply buttons)
// call map.handleSave / map.handleSaveApply — form.Map's
// defaults — which only run form.Map.save(). They never reach
// applyAll(), so the runtime network/wireless/dhcp/firewall
// sections tagged with guest_owner were never created.
// Route the map's handlers through applyAll() as well.
m.handleSave = function(ev) {
return self.applyAll(m);
};
m.handleSaveApply = function(ev, mode) {
return self.applyAll(m);
};
let s = m.section(form.GridSection, 'guest', _('Guest networks'));
s.anonymous = true;
s.addremove = true;
s.sortable = true;
s.nodescriptions = true;
s.addbtntitle = _('Add new guest network...');
let o = s.option(form.Flag, 'enable', _('Enabled'),
_('Enable guest network.'));
o.rmempty = false;
o.default = '1';
o.editable = true;
// Read side: show ON for a fresh section, else UCI value.
o.cfgvalue = function(section_id) {
let v = uci.get('guestwifi', section_id, 'enable');
return (v == null || v === '') ? '1' : v;
};
// Formvalue: read the checkbox from the DOM directly. LuCI's
// base formvalue() returns null when getUIElement() can't find
// the widget, and base save() then writes '0' (this.remove()).
o.formvalue = function(section_id) {
// The element at this.cbid() is often a wrapper div, not the
// actual . Dig one level.
let cbid = this.cbid(section_id);
let el = document.getElementById(cbid);
let inp = el;
if (inp && inp.tagName !== 'INPUT')
inp = inp.querySelector('input[type="checkbox"]');
if (inp && typeof inp.checked === 'boolean')
return inp.checked ? '1' : '0';
// Fallback: search the whole row for the checkbox
if (el && el.closest) {
let rowCb = el.closest('tr, .cbi-rowstyle-1, .cbi-rowstyle-2, .tr');
if (rowCb) {
let rInput = rowCb.querySelector('input[type="checkbox"]');
if (rInput && typeof rInput.checked === 'boolean')
return rInput.checked ? '1' : '0';
}
}
let v = uci.get('guestwifi', section_id, 'enable');
return (v == null || v === '') ? '1' : v;
};
// Write: bypass Flag.save()'s default-skip and uci.set direct.
o.write = function(section_id, formvalue) {
if (formvalue == null || formvalue === '')
formvalue = '1';
let on = (formvalue === '1' || formvalue === true ||
formvalue === 1 || formvalue === 'on');
uci.set('guestwifi', section_id, 'enable', on ? '1' : '0');
};
o = s.option(form.Value, 'ssid', _('Network name (SSID)'));
o.rmempty = false;
o.default = DEFAULTS.ssid;
o.textvalue = function(section_id) {
let val = this.cfgvalue(section_id) || this.default;
return ssidBadge(section_id, val);
};
o = s.option(form.ListValue, 'radio', _('Radio'),
_('The radio on which the guest network will work.'));
radios.forEach(function(r) { o.value(r, radioLabel(r, wifiDevices)); });
o.default = DEFAULTS.radio;
o.rmempty = false;
o.textvalue = function(section_id) {
let val = this.cfgvalue(section_id) || this.default;
return radioBadge(section_id, val, wifiDevices);
};
o = s.option(form.ListValue, 'encryption', _('Encryption'),
_('WPA2-PSK is recommended for most devices. WPA3-SAE offers the strongest security but requires WPA3-capable clients.'));
ENCRYPTION_MODES.forEach(function(m) { o.value(m[0], m[1]); });
o.default = DEFAULTS.encryption;
o.rmempty = false;
o.textvalue = function(section_id) {
let val = this.cfgvalue(section_id) || this.default;
return encryptionLabel(val);
};
o = s.option(form.Value, 'password', _('Password'), ' ');
o.password = true;
o.depends('encryption', 'psk');
o.depends('encryption', 'psk2');
o.depends('encryption', 'psk-mixed');
o.depends('encryption', 'sae');
o.depends('encryption', 'sae-mixed');
o.depends('encryption', 'wep-open');
o.depends('encryption', 'wep-shared');
o.default = DEFAULTS.password;
o.modalonly = true;
o.validate = function(section_id, value) {
let enc = this.map.lookupOption('encryption', section_id)[0].formvalue(section_id);
let inputEl = document.getElementById(this.cbid(section_id));
let strength = inputEl ? inputEl.parentNode.querySelector('.cbi-value-description') : null;
let strongRegex = new RegExp("^(?=.{8,})(?=.*[A-Z])(?=.*[a-z])(?=.*[0-9])(?=.*\\W).*$", "g"),
mediumRegex = new RegExp("^(?=.{7,})(((?=.*[A-Z])(?=.*[a-z]))|((?=.*[A-Z])(?=.*[0-9]))|((?=.*[a-z])(?=.*[0-9]))).*$", "g"),
enoughRegex = new RegExp("(?=.{6,}).*", "g");
if (strength) {
if (!value || !value.length)
strength.innerHTML = '';
else if (false == enoughRegex.test(value))
strength.innerHTML = '%s: %s'.format(_('Password strength'), _('More Characters'));
else if (strongRegex.test(value))
strength.innerHTML = '%s: %s'.format(_('Password strength'), _('Strong'));
else if (mediumRegex.test(value))
strength.innerHTML = '%s: %s'.format(_('Password strength'), _('Medium'));
else
strength.innerHTML = '%s: %s'.format(_('Password strength'), _('Weak'));
}
if (!value)
return true;
if (enc === 'wep-open' || enc === 'wep-shared') {
let isHex = /^[0-9a-fA-F]+$/.test(value);
let validLength = isHex ? (value.length === 10 || value.length === 26)
: (value.length === 5 || value.length === 13);
if (!validLength)
return _('WEP key must be 5 or 13 ASCII characters, or 10 or 26 hexadecimal digits.');
return true;
}
if (enc !== 'none' && enc !== 'owe' && value.length < 8)
return _('Password must be at least 8 characters long (WPA-PSK/WPA2-PSK/WPA3-SAE).');
return true;
};
let passwordDependsOn = function(opt) {
opt.depends('encryption', 'psk');
opt.depends('encryption', 'psk2');
opt.depends('encryption', 'psk-mixed');
opt.depends('encryption', 'sae');
opt.depends('encryption', 'sae-mixed');
opt.depends('encryption', 'wep-open');
opt.depends('encryption', 'wep-shared');
};
o = s.option(form.Value, 'password_length', _('Password length'),
_('Minimum length is 8 characters, 13 characters recommended.'));
o.datatype = 'range(8,63)';
o.default = '13';
o.rmempty = true;
o.modalonly = true;
passwordDependsOn(o);
o = s.option(cbiPasswordComponentsValue, 'password_components', _('Password composition'),
_('Character types to use when generating a password.'));
o.value('upper', _('Uppercase letters'));
o.value('lower', _('Lowercase letters'));
o.value('digits', _('Digits'));
o.value('special', _('Special characters'));
o.default = ['upper', 'lower', 'digits', 'special'];
o.placeholder = _('Select password components...');
o.rmempty = true;
o.modalonly = true;
passwordDependsOn(o);
o = s.option(form.Button, '_password_generate', _('Generate password'));
o.inputtitle = _('Generate');
o.inputstyle = 'action';
o.modalonly = true;
passwordDependsOn(o);
o.onclick = function(ev, section_id) {
let section = this.section;
let lengthEl = section.getUIElement(section_id, 'password_length');
let componentsEl = section.getUIElement(section_id, 'password_components');
let passwordEl = section.getUIElement(section_id, 'password');
let length = lengthEl ? parseInt(lengthEl.getValue(), 10) : 16;
let components = componentsEl ? componentsEl.getValue() : [];
if (!Array.isArray(components))
components = components ? [components] : [];
let newPassword = generateStrongPassword(length, components);
if (passwordEl) {
passwordEl.setValue(newPassword);
if (typeof passwordEl.triggerValidation === 'function')
passwordEl.triggerValidation();
}
};
o = s.option(form.DummyValue, '_qrops', _('QR Code'),
_('Generates a QR code with the guest network data (SSID, encryption, password) so a client device can scan and connect.'));
o.modalonly = true;
o.createWiFiPassword = function(section_id) {
function pctEncode(str) {
let bytes = new TextEncoder().encode(str);
let out = '';
for (let i = 0; i < bytes.length; i++) {
let b = bytes[i];
let printable = (b >= 0x20 && b <= 0x3A && b !== 0x3B) || (b >= 0x3C && b <= 0x7E);
if (printable)
out += String.fromCharCode(b);
else
out += '%' + b.toString(16).toUpperCase().padStart(2, '0');
}
return out;
}
let wifiSSID = this.section.formvalue(section_id, 'ssid');
let wifiEncr = this.section.formvalue(section_id, 'encryption') || '';
let wifiKey = this.section.formvalue(section_id, 'password');
let trdisable = '';
if (wifiEncr === 'sae') trdisable = 0;
else if (wifiEncr === 'owe') trdisable = 3;
return [
'WIFI:',
(wifiKey) ? 'T:WPA;' : null,
(trdisable !== '') ? 'R:' + trdisable + ';' : null,
'S:' + wifiSSID + ';',
(wifiKey) ? 'P:' + pctEncode(wifiKey) + ';' : null
].filter(Boolean).join('') + ';';
};
o.handleGenerateQR = function(section_id, ev) {
let parent = s.map;
let mapNode = document.querySelector('body.modal-overlay-active > #modal_overlay > .modal.cbi-modal > .cbi-map:not(.hidden)');
let headNode = mapNode.parentNode.querySelector('h4');
let wifiQRGenerator = this.createWiFiPassword.bind(this, section_id);
return Promise.all([
parent.save(null, true)
]).then(function() {
let qrm, qrs, qro;
qrm = new form.JSONMap({ qrcode: {} }, null, _('Scan this QR code with the client device.'));
qrm.parent = parent;
qrs = qrm.section(form.NamedSection, 'qrcode');
function handleQRParamChange(ev, section_id, value) {
let code = this.map.findElement('.qr-code');
let conf = this.map.findElement('.wifi-qr-code-content');
let ecc = this.section.getUIElement(section_id, 'ecc');
if (this.isValid(section_id)) {
conf.firstChild.data = wifiQRGenerator(section_id);
code.style.opacity = '.5';
buildSVGQRCode(conf.firstChild.data, code, { ecc: ecc.getValue() });
}
}
qro = qrs.option(form.ListValue, 'ecc', _('QR Error Correction Code Level'));
qro.value('L', _('Low'));
qro.value('M', _('Medium'));
qro.value('Q', _('Quartile'));
qro.value('H', _('High'));
qro.onchange = handleQRParamChange;
qro = qrs.option(form.DummyValue, 'output');
qro.renderWidget = function() {
let wifi_qr = wifiQRGenerator(section_id);
let ecc = this.section.formvalue(section_id, 'ecc');
return E('div', {
'class': 'qr-code-display',
'style': 'display:flex; flex-wrap:wrap; align-items:center; gap:.5em'
}, [
E('div', { 'class': 'qr-code' }, [
E(buildSVGQRCode(wifi_qr, null, { ecc: ecc || undefined }, true))
]),
E('pre', {
'class': 'wifi-qr-code-content',
'style': 'flex:1; overflow:auto; word-break:break-all;',
'click': function(ev) {
let sel = window.getSelection();
let range = document.createRange();
range.selectNodeContents(ev.currentTarget);
sel.removeAllRanges();
sel.addRange(range);
}
}, [wifi_qr])
]);
};
return qrm.render().then(function(nodes) {
let dStyle = mapNode.style;
mapNode.style.display = 'none';
let bRowStyle = mapNode.nextElementSibling.style;
mapNode.nextElementSibling.style.display = 'none';
headNode.appendChild(E('span', [' » ', _('Generate guest WiFi QR…')]));
mapNode.parentNode.appendChild(E([], [
nodes,
E('div', { 'class': 'right' }, [
E('button', {
'class': 'btn',
'click': function() {
nodes.parentNode.removeChild(nodes.nextSibling);
nodes.parentNode.removeChild(nodes);
mapNode.style = dStyle;
mapNode.nextSibling.style = bRowStyle;
headNode.removeChild(headNode.lastChild);
}
}, [_('Back to settings')])
])
]));
});
});
};
o.cfgvalue = function(section_id) {
return E('button', {
'class': 'btn qr-code',
'style': 'display:inline-flex;align-items:center;gap:.5em',
'click': ui.createHandlerFn(this, 'handleGenerateQR', section_id)
}, [
E(buildSVGQRCode('openwrt.org', null, { pixelSize: 1, ecc: 'L' }, true)),
_('Generate QR…')
]);
};
o = s.option(form.Value, 'interface_ip', _('Interface IP address'));
o.datatype = 'ip4addr';
o.rmempty = false;
o.default = DEFAULTS.ip;
o.cfgvalue = function(section_id) {
let val = uci.get('guestwifi', section_id, 'interface_ip');
if (val) return val;
return randomFreeGuestIp(section_id) || DEFAULTS.ip;
};
o.textvalue = function(section_id) {
let val = this.cfgvalue(section_id) || this.default;
return E('code', {}, val);
};
o.validate = function(section_id, value) {
if (!/^(\d{1,3}\.){3}\d{1,3}$/.test(value))
return true;
let used = usedInterfaceIps(section_id);
if (!used[value])
return true;
let newIp = randomFreeGuestIp(section_id);
if (!newIp) {
return _('The address %s is already in use and no free address is available in the 172.16.0.1 - 172.31.0.1 range.').format(value);
}
let el = this.getUIElement(section_id);
if (el) el.setValue(newIp);
return true;
};
o = s.option(form.Value, 'netmask', _('Netmask'));
o.datatype = 'ip4addr';
o.rmempty = false;
o.default = DEFAULTS.netmask;
o.modalonly = true;
o = s.option(form.Value, 'dhcp_start', _('DHCP range start'),
_('Lowest leased address, as offset from the network address.'));
o.datatype = 'uinteger';
o.rmempty = false;
o.default = DEFAULTS.dhcpStart;
o.modalonly = true;
o = s.option(form.Value, 'dhcp_limit', _('DHCP client limit'),
_('Maximum number of leased addresses.'));
o.datatype = 'uinteger';
o.rmempty = false;
o.default = DEFAULTS.dhcpLimit;
o.modalonly = true;
o = s.option(form.Value, 'dhcp_lease', _('DHCP lease time'),
_('E.g. "1h", "30m", "12h". Minimum is "2m".'));
o.rmempty = false;
o.default = DEFAULTS.dhcpLease;
o = s.option(form.ListValue, 'isolate', _('Client isolation'),
_('Blocks communication between clients on this guest network.'));
o.value('1', _('Yes'));
o.value('0', _('No'));
o.default = DEFAULTS.isolate;
o.rmempty = false;
o.modalonly = true;
o = s.option(form.Value, 'macaddr', _('MAC address'),
_('Override default MAC address - the range of usable addresses might be limited by the driver'));
o.value('', _('driver default'));
o.value('random', _('randomly generated'));
o.datatype = "or('random',macaddr)";
o.default = DEFAULTS.macaddr;
o.rmempty = true;
o.modalonly = true;
o = s.option(widgets.ZoneSelect, 'fw_forward_dest', _('Forwarding destination zone'),
_('Firewall zone guest traffic is forwarded to (usually "wan"). Pick an existing zone from the list or fill out the -- custom -- field to enter one manually.'));
o.rmempty = false;
o.default = DEFAULTS.fwForwardDest;
o.modalonly = true;
o = s.option(form.ListValue, 'fw_input', _('Input'),
_('Traffic from guests to the router itself (e.g. LuCI, SSH).'));
o.value('REJECT', _('reject')); o.value('ACCEPT', _('accept')); o.value('DROP', _('drop'));
o.default = DEFAULTS.fwInput;
o.rmempty = false;
o.modalonly = true;
o = s.option(form.ListValue, 'fw_output', _('Output'),
_('Traffic from the router itself to guests. ACCEPT is recommended so router services (DHCP, DNS) keep working.'));
o.value('REJECT', _('reject')); o.value('ACCEPT', _('accept')); o.value('DROP', _('drop'));
o.default = DEFAULTS.fwOutput;
o.rmempty = false;
o.modalonly = true;
o = s.option(form.ListValue, 'fw_forward', _('Forward'),
_('Traffic passing between guests and other networks/zones (e.g. LAN). Keep this REJECT or DROP to isolate guests from your LAN. ACCEPT would allow guests to reach other networks.'));
o.value('REJECT', _('reject')); o.value('ACCEPT', _('accept')); o.value('DROP', _('drop'));
o.default = DEFAULTS.fwForward;
o.rmempty = false;
o.modalonly = true;
o = s.option(form.Value, 'fw_dhcp_ports', _('DHCP rule ports'),
_('UDP port range for DHCP traffic, e.g. "67-68".'));
o.rmempty = false;
o.default = DEFAULTS.fwDhcpPorts;
o.modalonly = true;
o = s.option(form.Value, 'fw_dns_port', _('DNS rule port'));
o.datatype = 'port';
o.rmempty = false;
o.default = DEFAULTS.fwDnsPort;
o.modalonly = true;
return m.render().then(function(node) {
// LuCI theme often wins specificity over our injected