WiFi screen: reach the root helper through a socket instead of sudo; adapter dropdown

The kiosk app can end up with the no-new-privileges flag, after which sudo
refuses to run ("The no new privileges flag is set"). The helper is now
served over a root-owned, kiosk-group-only unix socket (systemd socket
activation); sudo stays as a fallback. With several WiFi adapters the screen
shows a dropdown and lists all of them; status/scan/connect take an iface.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013wi3ZQveYdjfcrEsyazq32
This commit is contained in:
Claude
2026-11-06 16:00:10 +00:00
parent 0fe631b4ed
commit 07e7d2866f
9 changed files with 271 additions and 24 deletions
+65 -7
View File
@@ -1,5 +1,6 @@
const {app,BrowserWindow,BrowserView,globalShortcut,ipcMain,dialog,session}=require('electron');
const {exec,spawn}=require('child_process');
const net=require('net');
const fs=require('fs');
const path=require('path');
const os=require('os');
@@ -1215,7 +1216,56 @@ const WIFI_HELPER='/usr/local/bin/kiosk-wifi-helper';
const WIFI_IDLE_TIMEOUT=5*60000; // closes after 5 minutes untouched
let wifiWindow=null,wifiUnlocked=false,wifiIdleTimer=null;
function runWifiHelper(action,input){
const WIFI_SOCKET='/run/kiosk-wifi-helper.sock';
// Parse the helper's one JSON reply line (the last line of its output).
function parseWifiReply(out){
const line=out.trim().split('\n').pop();
return JSON.parse(line);
}
// Preferred route: a root-owned socket (kiosk-wifi-helper.socket). Unlike
// sudo it keeps working when this app has the "no new privileges" flag set.
// Resolves null when the socket isn't usable (not installed yet, or this
// user isn't allowed), so the caller falls back to sudo.
function runWifiHelperSocket(action,input){
return new Promise(resolve=>{
let out='',done=false;
const finish=result=>{if(!done){done=true;clearTimeout(killer);resolve(result);}};
const sock=net.connect({path:WIFI_SOCKET,allowHalfOpen:true});
const killer=setTimeout(()=>{sock.destroy();finish({ok:false,error:'The WiFi helper timed out'});},120000);
sock.on('connect',()=>{
// Request = action line, then the JSON body; half-close so the
// helper sees end of input.
sock.end(action+'\n'+(input?JSON.stringify(input):''));
});
sock.on('data',d=>{out+=d;});
sock.on('error',e=>{
if(out){
// A reset after the reply arrived - use the reply.
try{finish(parseWifiReply(out));return;}catch(_){}
}
if(!out&&['ENOENT','ECONNREFUSED','EACCES','EPERM'].includes(e.code)){
console.log('[WIFI] Helper socket unavailable ('+e.code+') - falling back to sudo');
finish(null);
}else{
finish({ok:false,error:'Could not reach the WiFi helper: '+e.message});
}
});
sock.on('close',()=>{
if(done)return;
try{
finish(parseWifiReply(out));
}catch(e){
console.error('[WIFI] helper socket output:',out);
finish({ok:false,error:'The WiFi helper gave no usable answer'});
}
});
});
}
// Fallback: the helper through passwordless sudo (/etc/sudoers.d/kiosk-wifi).
function runWifiHelperSudo(action,input){
return new Promise(resolve=>{
let out='',err='',done=false;
const finish=result=>{if(!done){done=true;resolve(result);}};
@@ -1232,12 +1282,11 @@ function runWifiHelper(action,input){
child.on('error',e=>{clearTimeout(killer);finish({ok:false,error:'Could not run the WiFi helper: '+e.message});});
child.on('close',()=>{
clearTimeout(killer);
const line=out.trim().split('\n').pop();
try{
finish(JSON.parse(line));
finish(parseWifiReply(out));
}catch(e){
// Not our JSON - typically sudo refusing ("a password is required")
// on a kiosk set up before this feature: Upgrade installs the rule.
// Not our JSON - typically sudo refusing ("a password is required",
// or the "no new privileges" flag) - Upgrade installs the socket.
console.error('[WIFI] helper output:',out,err);
finish({ok:false,error:(err.trim()||'The WiFi helper is not installed')+' - run Advanced -> Upgrade from the admin menu'});
}
@@ -1247,6 +1296,12 @@ function runWifiHelper(action,input){
});
}
async function runWifiHelper(action,input){
const viaSocket=await runWifiHelperSocket(action,input);
if(viaSocket!==null)return viaSocket;
return runWifiHelperSudo(action,input);
}
function resetWifiIdleTimer(){
if(wifiIdleTimer)clearTimeout(wifiIdleTimer);
wifiIdleTimer=setTimeout(()=>{
@@ -1327,7 +1382,10 @@ ipcMain.handle('wifi-request',async(event,req)=>{
return result;
}
if(!wifiUnlocked)return{ok:false,error:'Enter the PIN first'};
if(action==='status'||action==='scan')return runWifiHelper(action);
if(action==='ifaces')return runWifiHelper('ifaces');
if(action==='status'||action==='scan'){
return runWifiHelper(action,req.iface?{iface:String(req.iface)}:null);
}
if(action==='region-status')return runWifiHelper('region-status');
if(action==='region-set'){
const input={};
@@ -1337,7 +1395,7 @@ ipcMain.handle('wifi-request',async(event,req)=>{
}
if(action==='connect'){
console.log('[WIFI] Connecting to',JSON.stringify(String(req.ssid||'')));
const result=await runWifiHelper('connect',{ssid:String(req.ssid||''),password:String(req.password||'')});
const result=await runWifiHelper('connect',{ssid:String(req.ssid||''),password:String(req.password||''),iface:req.iface?String(req.iface):''});
console.log('[WIFI] Connect result:',result.ok?('ok '+result.ip):result.error);
return result;
}
+49 -8
View File
@@ -95,7 +95,11 @@
</div>
<div id="wifi-step" class="hidden">
<div id="status">Checking…</div>
<div id="adapter-row" class="hidden">
<label for="adapter" style="margin-top: 0">WiFi adapter</label>
<select id="adapter"></select>
</div>
<div id="status" style="margin-top: 12px">Checking…</div>
<div class="row" style="margin-top: 14px">
<label style="margin: 0; flex: 1">Networks nearby</label>
@@ -139,6 +143,7 @@
const {ipcRenderer} = require('electron');
const $ = id => document.getElementById(id);
let busy = false;
let adapter = ''; // the adapter picked in the dropdown ('' = let the helper choose)
function request(action, extra) {
return ipcRenderer.invoke('wifi-request', Object.assign({action}, extra || {}))
@@ -163,16 +168,47 @@
return '▂';
}
function adapterLabel(a) {
const kind = a.kind === 'usb' ? 'USB' : 'built-in';
return `${a.name} (${kind}) - ` + (a.connected ? `connected to "${a.ssid}"` : 'not connected');
}
// Fill the dropdown from the helper's adapter list. Shown only when
// there is more than one adapter; the choice sticks across refreshes.
function fillAdapters(r) {
const row = $('adapter-row');
const sel = $('adapter');
if (!r || !r.ok || !r.adapters || r.adapters.length === 0) { row.classList.add('hidden'); return r; }
const names = r.adapters.map(a => a.name);
if (!names.includes(adapter)) adapter = r.selected || names[0];
sel.textContent = '';
for (const a of r.adapters) {
const o = document.createElement('option');
o.value = a.name;
o.textContent = adapterLabel(a);
if (a.name === adapter) o.selected = true;
sel.appendChild(o);
}
row.classList.toggle('hidden', r.adapters.length < 2);
return r;
}
async function refreshStatus() {
const s = await request('status');
const ad = fillAdapters(await request('ifaces'));
const s = await request('status', adapter ? {iface: adapter} : {});
const box = $('status');
if (!s.ok) { box.textContent = s.error || 'Status unavailable'; return; }
if (!s.iface) { box.textContent = 'No WiFi adapter found'; return; }
box.textContent = '';
const line = (text) => { const d = document.createElement('div'); d.textContent = text; box.appendChild(d); };
line(s.connected ? `Connected to "${s.ssid}" - ${s.ip}` : 'Not connected');
if (ad && ad.ok && ad.adapters && ad.adapters.length > 1) {
// Every adapter at a glance, the selected one marked.
for (const a of ad.adapters) line((a.name === s.iface ? '▶ ' : ' ') + adapterLabel(a) + (a.ip ? ` - ${a.ip}` : ''));
} else {
line(s.connected ? `Connected to "${s.ssid}" - ${s.ip}` : 'Not connected');
line(`Adapter: ${s.iface}`);
}
if (s.configured && s.configured !== s.ssid) line(`Saved network: "${s.configured}"`);
line(`Adapter: ${s.iface}`);
if (!$('ssid').value && (s.ssid || s.configured)) $('ssid').value = s.ssid || s.configured;
}
@@ -181,7 +217,7 @@
setBusy(true);
message('Scanning…', 'busy');
const list = $('networks');
const r = await request('scan');
const r = await request('scan', adapter ? {iface: adapter} : {});
list.textContent = '';
if (!r.ok) {
message(r.error, 'error');
@@ -217,7 +253,7 @@
if (!ssid) { message('Pick a network or type its name', 'error'); return; }
setBusy(true);
message(`Connecting to "${ssid}" (up to 30 seconds)…`, 'busy');
const r = await request('connect', {ssid, password});
const r = await request('connect', {ssid, password, iface: adapter});
if (r.ok) {
message(`✓ Connected to "${r.ssid}" - ${r.ip}`, 'ok');
$('password').value = '';
@@ -287,14 +323,19 @@
message('');
$('pin-step').classList.add('hidden');
$('wifi-step').classList.remove('hidden');
refreshStatus();
refreshStatus().then(() => scan());
loadRegion();
scan();
}
$('pin-ok').addEventListener('click', unlock);
$('pin').addEventListener('keydown', e => { if (e.key === 'Enter') unlock(); });
$('scan').addEventListener('click', scan);
$('adapter').addEventListener('change', () => {
adapter = $('adapter').value;
$('networks').innerHTML = '<li class="empty">Scanning…</li>';
refreshStatus();
scan();
});
$('region-save').addEventListener('click', saveRegion);
$('region-from-locale').addEventListener('click', countryFromLocale);
$('connect').addEventListener('click', connect);