Add Typed SSH Service Controls

Provide admin-only SSH status plus generation-safe start, stop, and
single-session disconnect operations through the bounded dispatcher.
Include
Settings UI coverage, lifecycle safeguards, and host-side regression
tests.
This commit is contained in:
2026-09-13 16:07:04 +02:00
parent 7ccc8799e9
commit 737bd29f9e
25 changed files with 1049 additions and 47 deletions
+2 -1
View File
@@ -13,7 +13,7 @@ const deferred = () => { let resolve; const promise = new Promise(r => { resolve
const tick = async () => { for (let i = 0; i < 6; ++i) await new Promise(r => setImmediate(r)); };
function browser({onlyLoader = false, withLoader = false, role = 'user', username = '<img>'} = {}) {
const nodes = {}, events = {}, calls = [], redirects = [], timers = new Map(), sockets = [], terminals = [];
const queues = {'/api/session': [], '/api/status': [], '/api/ws-ticket': [], '/api/admin/ws-ticket': [], '/api/logout': [], '/api/settings/serial': [], '/api/settings/serial-operation': [], '/api/settings/accounts': [], '/api/settings/account-operation': [], '/api/settings/accounts/generate-password': [], '/api/settings/accounts/keys': [], '/api/settings/network': [], '/api/settings/network-operation': [], '/api/settings/display': [], '/api/settings/display-operation': [], '/api/settings/broker': [], '/api/settings/broker-operation': []};
const queues = {'/api/session': [], '/api/status': [], '/api/ws-ticket': [], '/api/admin/ws-ticket': [], '/api/logout': [], '/api/settings/serial': [], '/api/settings/serial-operation': [], '/api/settings/accounts': [], '/api/settings/account-operation': [], '/api/settings/accounts/generate-password': [], '/api/settings/accounts/keys': [], '/api/settings/network': [], '/api/settings/network-operation': [], '/api/settings/display': [], '/api/settings/display-operation': [], '/api/settings/broker': [], '/api/settings/broker-operation': [], '/api/settings/ssh': [], '/api/settings/ssh-operation': []};
const fits = [];
let serial = 0, now = Date.now();
class Clock extends Date { static now() { return now; } }
@@ -1371,5 +1371,6 @@ async function test(name, fn) { await fn(); ++passed; console.log('PASS JS:', na
await require('./network.cjs')({test, browser, adminBrowser, tick, json, session, failure, deferred, token, html});
await require('./display.cjs')({test, browser, adminBrowser, tick, json, session, failure, deferred, token, html});
await require('./broker.cjs')({test, browser, adminBrowser, tick, json, session, failure, deferred, token, html});
await require('./ssh.cjs')({test, browser, adminBrowser, tick, json, session, failure, deferred, token, html});
console.log(`PASS ${passed} browser behavior groups (production C-rendered JS)`);
})().catch(error => { console.error(error); process.exitCode = 1; });
+5 -5
View File
@@ -56,10 +56,10 @@ def check_layout(html):
assert 'hidden' in ids['quick-header']['attrs']
assert ids['network-password']['parent'] is ids['network-password-label']
assert ids['network-password-mode']['parent'] is ids['network-password-mode-label']
for ident in ('settings-values', 'accounts-list', 'account-keys-list', 'network-summary', 'display-values', 'broker-values'):
for ident in ('settings-values', 'accounts-list', 'account-keys-list', 'network-summary', 'display-values', 'broker-values', 'ssh-values'):
assert ids[ident]['tag'] == 'dl'
assert 'settings-values' in classes(ids[ident])
for ident in ('serial-settings-content', 'account-settings', 'network-settings', 'display-settings', 'broker-settings'):
for ident in ('serial-settings-content', 'account-settings', 'network-settings', 'display-settings', 'broker-settings', 'ssh-settings'):
nodes = list(descendants(ids[ident]))
assert not any(n['tag'] == 'pre' for n in nodes)
assert all('connection-detail' in classes(n) for n in nodes if n['tag'] == 'p')
@@ -70,9 +70,9 @@ def check_layout(html):
ancestor(n, 'settings-edit')
except AssertionError:
ancestor(n, 'serial-edit')
for ident in ('refresh-settings', 'refresh-accounts', 'network-refresh', 'display-refresh', 'broker-refresh'):
for ident in ('refresh-settings', 'refresh-accounts', 'network-refresh', 'display-refresh', 'broker-refresh', 'ssh-refresh'):
assert ids[ident]['text'] == 'Refresh'
for ident in ('serial-result', 'account-result', 'network-result', 'display-result', 'broker-result'):
for ident in ('serial-result', 'account-result', 'network-result', 'display-result', 'broker-result', 'ssh-result'):
assert ids[ident]['text'] == 'Check Operation Result'
for ident in ('network-boot', 'network-enabled', 'account-password-saved'):
assert 'settings-check' in classes(ids[ident]['parent'])
@@ -101,7 +101,7 @@ def check_layout(html):
):
assert rule in css, rule
assert '.settings-edit textarea{font:inherit;width:100%;min-width:0;' in css
print('PASS HTML layout: parsed structure, shared styles, labels, wrapping, checkbox sizing and action order across all five settings views')
print('PASS HTML layout: parsed structure, shared styles, labels, wrapping, checkbox sizing and action order across all six settings views')
def check_browser_layout(html, tmp, executable):
+68
View File
@@ -0,0 +1,68 @@
'use strict';
const assert = require('node:assert/strict');
module.exports = async ({test, browser, adminBrowser, tick, json, session, failure, deferred, html}) => {
const path='/api/settings/ssh', op=path+'-operation';
const row=(id=9, extra={})=>({id,state:2,route:1,name_hex:'3c696d673e',closing:false,...extra});
const fixture=(extra={})=>({generation:7,running:true,transitioning:false,sessions:[row(),row(10,{route:2})],...extra});
const reply=(state='pending',id=42,status=200,action='disconnect')=>new Response(JSON.stringify({id,action,state}),{status});
const n=(b,id)=>b.nodes['ssh-'+id], posts=b=>b.calls.filter(c=>c.url===op&&c.method==='POST');
async function open(v=fixture()) {const b=await adminBrowser();b.click('select-settings');await tick();b.queues[path].push(json(v));b.click('settings-ssh');await tick();return b;}
function select(b,id=9){n(b,'target').value=String(id);n(b,'target').change();}
async function refresh(b,v=fixture()){b.queues[path].push(json(v));b.click('ssh-refresh');await tick();}
async function submit(b,action='disconnect'){if(action==='disconnect')select(b);b.window.confirm=()=>true;b.queues[op].push(reply('pending',42,202,action));b.click('ssh-'+action);await tick();}
await test('SSH admin-only view, safe rows, no navigation/selection mutation and both terminal isolation',async()=>{
const u=browser();u.start();await tick();u.click('settings-ssh');await tick();assert.equal(u.calls.filter(c=>c.url===path).length,0);
const b=await open();assert.equal(b.nodes['ssh-settings'].hidden,false);
assert.equal(n(b,'values').children[0].textContent,'9 / Serial / <img>');select(b);assert.equal(posts(b).length,0);
for(let i=0;i<2;++i){b.sockets[i].emit('message',{data:Uint8Array.of(0,255,i).buffer});assert.deepEqual(b.terminals[i].writes.at(-1),[0,255,i]);b.terminals[i].input('blocked');assert.equal(b.sockets[i].sent.length,0);}
assert.match(html,/Stop closes all SSH sessions/);assert.match(html,/HTTPS login, browser terminals, Wi-Fi, USB and UART0 are not stopped/);
b.click('settings-broker');await tick();assert.equal(b.nodes['ssh-settings'].hidden,true);assert.equal(posts(b).length,0);
});
await test('SSH exact confirmed start stop targeted disconnect requests and manual bounded result flow',async()=>{
for(const action of ['start','stop','disconnect']){
const b=await open(fixture({running:action!=='start'}));if(action==='disconnect')select(b);
let confirmation='';b.window.confirm=text=>{confirmation=text;return false;};b.click('ssh-'+action);await tick();assert.equal(posts(b).length,0);
assert.match(confirmation,/Settings and host identity are unchanged/);assert.match(confirmation,action==='stop'?/ALL SSH sessions/:action==='start'?/port 22/:/only SSH session 9/);
await submit(b,action);assert.deepEqual(JSON.parse(posts(b)[0].body),{action,generation:7,target:action==='disconnect'?9:0});assert.equal(posts(b)[0].headers['X-CSRF-Token'],'a'.repeat(64));
b.click('ssh-'+action);await tick();assert.equal(posts(b).length,1);assert.match(n(b,'operation-detail').textContent,/Check Operation Result/);
b.queues[op].push(reply('ok',42,200,action));b.click('ssh-result');await tick();assert.match(n(b,'operation-detail').textContent,/execution time/);assert.ok(n(b,'start').disabled&&n(b,'stop').disabled&&n(b,'disconnect').disabled);
await refresh(b);assert.equal(posts(b).length,1);assert.equal(n(b,'target').value,'');
}
});
await test('SSH refresh never rebases explicit identity or resurrects stale and absent selections',async()=>{
for(const v of [fixture({generation:8}),fixture({sessions:[row(13)]}),fixture({sessions:[row(9,{closing:true})]}),fixture({sessions:[row(9,{name_hex:'61'})]}),fixture({transitioning:true})]){
const b=await open();select(b);await refresh(b,v);assert.ok(n(b,'disconnect').disabled);await refresh(b);assert.ok(n(b,'disconnect').disabled);assert.equal(n(b,'target').value,'');
b.click('ssh-disconnect');await tick();assert.equal(posts(b).length,0);select(b);assert.equal(n(b,'disconnect').disabled,false);
}
const b=await open();select(b);await refresh(b,fixture({sessions:[row(10,{route:2}),row()]}));assert.equal(n(b,'target').value,'9');assert.equal(n(b,'disconnect').disabled,false);
});
await test('SSH invalid/unavailable/transition/exhausted snapshots fail closed without affecting terminals',async()=>{
const invalid=[{},fixture({generation:0}),fixture({generation:4294967296}),fixture({running:1}),fixture({sessions:[row(),row()]}),fixture({sessions:[row(9,{name_hex:'zz'})]}),fixture({sessions:[row(9,{route:3})]}),fixture({sessions:[row(9,{id:0})]})];
for(const v of invalid){const b=await open(v);assert.ok(n(b,'disconnect').disabled&&n(b,'stop').disabled&&n(b,'start').disabled);assert.match(n(b,'detail').textContent,/unavailable/);assert.equal(posts(b).length,0);}
for(const extra of [{transitioning:true},{generation:4294967295}]){const b=await open(fixture(extra));assert.ok(n(b,'stop').disabled&&n(b,'start').disabled);}
const b=await open();select(b);b.queues[path].push(failure(503));b.click('ssh-refresh');await tick();assert.ok(n(b,'disconnect').disabled);assert.equal(b.sockets.length,2);
});
await test('SSH single-flight whole-read deadline and navigation fence late snapshots without replay',async()=>{
for(const stage of ['session','snapshot']){
const b=await open(), d=deferred();select(b);b.queues[stage==='session'?'/api/session':path].push(d.promise);b.click('ssh-refresh');await tick();b.click('ssh-stop');b.click('ssh-refresh');await tick();assert.equal(posts(b).length,0);
b.fire(15000);await tick();assert.match(n(b,'detail').textContent,/timed out/);await refresh(b);d.resolve(stage==='session'?session({role:'admin'}):json(fixture({generation:99})));await tick();select(b);await submit(b);assert.equal(JSON.parse(posts(b)[0].body).generation,7);
}
const b=await open(),d=deferred();b.queues[path].push(d.promise);b.click('ssh-refresh');await tick();b.click('settings-serial');await tick();d.resolve(json(fixture()));await tick();assert.equal(b.nodes['ssh-settings'].hidden,true);assert.equal(n(b,'values').children.length,0);assert.equal(posts(b).length,0);
});
await test('SSH captures confirmation before delayed auth and handles conflict failed cancelled without replay',async()=>{
for(const state of ['conflict','failed','cancelled']){
const b=await open(),d=deferred();select(b);b.window.confirm=()=>true;b.queues['/api/session'].push(d.promise);b.queues[op].push(reply('pending',42,202));b.click('ssh-disconnect');await tick();assert.equal(posts(b).length,0);d.resolve(session({role:'admin'}));await tick();assert.equal(JSON.parse(posts(b)[0].body).generation,7);
b.queues[op].push(reply(state));b.click('ssh-result');await tick();assert.match(n(b,'operation-detail').textContent,state==='conflict'?/No action admitted/:state==='failed'?/may still finish/:/Rejected before execution/);assert.equal(posts(b).length,1);
}
});
await test('SSH lost acknowledgement/result replacement and timeout retain uncertainty across navigation',async()=>{
const b=await open();select(b);b.window.confirm=()=>true;b.queues[op].push(()=>{throw Error('lost');});b.click('ssh-disconnect');await tick();assert.ok(n(b,'disconnect').disabled);b.click('select-serial');b.queues[path].push(json(fixture()));b.click('select-settings');await tick();assert.equal(posts(b).length,1);
b.queues[op].push(reply('ok',41));b.click('ssh-result');await tick();assert.match(n(b,'operation-detail').textContent,/Acknowledgement was lost/);
b.queues[op].push(reply('ok',43));b.click('ssh-result');await tick();assert.match(n(b,'operation-detail').textContent,/Previous result replaced/);
await refresh(b);const d=deferred();select(b);b.queues[op].push(d.promise);b.click('ssh-disconnect');await tick();b.fire(15000);await tick();assert.match(n(b,'operation-detail').textContent,/timed out/);assert.ok(n(b,'stop').disabled);d.resolve(reply('pending',44,202));await tick();assert.match(n(b,'operation-detail').textContent,/timed out/);assert.equal(posts(b).length,2);
});
await test('SSH revoked session and pagehide cancel UI work without affecting newer context via late401',async()=>{
const b=await open();select(b);b.queues['/api/session'].push(failure(401));b.click('ssh-disconnect');await tick();assert.equal(posts(b).length,0);
const c=await open(),d=deferred();c.queues[path].push(d.promise);c.click('ssh-refresh');await tick();c.emit('pagehide');d.resolve(failure(401));await tick();assert.equal(posts(c).length,0);assert.equal(n(c,'values').children.length,0);
});
};