scp81: UI framing options, permissive TLS, drop the Apache-header mimicry (v2.2.14)
Listener Options block (applied at Start, persisted in localStorage, Reset to defaults): HTTP framing - chunked body, chunk size (0 = one TLS record), keep-alive, Connection header, compact headers, Next-URI (unchecked = omit); script framing - indefinite/definite Command Scripting template, CR tag, targeted app; link events (now parsed on the common start path, so every mode honors them). scp81OptionsFromForm() is unit-tested. TLS is automatic: 'auto' (min 1.0, max 1.2 + :@SECLEVEL=0) is the new default, all six PSK suites are offered and OpenSSL negotiates the highest; the negotiated version/cipher is logged (tls-handshake) and reported as version_seen/cipher_seen in /api/scp81/status, and a handshake failing for a TLS/cipher reason logs tls-handshake-failed (post-handshake record errors stay tls-error). tls_version/cipher/keylog/answer_delay stay as API-only pins. Dropped the Apache-style header mimicry completely: no Date/Server/ X-Powered-By, no Content-Length-before-Content-Type ordering, no Content-Type on 204 - the minimal response set is X-Admin-Protocol (+ X-Admin-Next-URI / Targeted-Application), Content-Type on 200s, and Transfer-Encoding or Content-Length per the chunked flag. Docs, help (EN/RU), READMEs and the AGENTS notes updated; SW cache otaman-v180.
This commit is contained in:
@@ -0,0 +1,161 @@
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const html = fs.readFileSync(path.join(__dirname, '..', 'index.html'), 'utf8');
|
||||
|
||||
function extractFunc(src, name) {
|
||||
const re = new RegExp('function\\s+' + name + '\\s*\\([^)]*\\)\\s*\\{');
|
||||
const m = re.exec(src);
|
||||
if (!m) throw new Error('function ' + name + ' not found');
|
||||
let i = m.index + m[0].length - 1;
|
||||
let depth = 0;
|
||||
for (; i < src.length; i++) {
|
||||
if (src[i] === '{') depth++;
|
||||
else if (src[i] === '}') {
|
||||
depth--;
|
||||
if (depth === 0) break;
|
||||
}
|
||||
}
|
||||
return src.slice(m.index, i + 1);
|
||||
}
|
||||
|
||||
let code = html.match(/const SCP81_OPT_DEFAULTS = \{[\s\S]*?\n\};/)[0].replace('const ', 'var ') + '\n';
|
||||
for (const fn of ['scp81OptionsLoad', 'scp81OptionsFromForm', 'scp81OptionsPersist', 'scp81OptionsReset']) {
|
||||
code += extractFunc(html, fn) + '\n';
|
||||
}
|
||||
eval(code);
|
||||
|
||||
function setup(opts) {
|
||||
const spec = Object.assign({
|
||||
'opt-chunked': true,
|
||||
'opt-chunk-size': 0,
|
||||
'opt-keep-alive': true,
|
||||
'opt-conn-header': 'none',
|
||||
'opt-compact': false,
|
||||
'opt-next-uri': true,
|
||||
'opt-next-uri-value': '/api/scp81?req=%d',
|
||||
'opt-link-events': true,
|
||||
'opt-script-template': 'indefinite',
|
||||
'opt-cr-tag': false,
|
||||
'opt-targeted-app': ''
|
||||
}, opts || {});
|
||||
const els = {};
|
||||
for (const [id, v] of Object.entries(spec)) {
|
||||
els[id] = typeof v === 'boolean' ? { checked: v, value: '' } : { checked: true, value: String(v) };
|
||||
}
|
||||
globalThis.document = { getElementById: id => els[id] || null };
|
||||
return els;
|
||||
}
|
||||
|
||||
function fakeStorage(initial) {
|
||||
const store = Object.assign({}, initial || {});
|
||||
globalThis.localStorage = {
|
||||
getItem: k => (k in store ? store[k] : null),
|
||||
setItem: (k, v) => { store[k] = String(v); },
|
||||
removeItem: k => { delete store[k]; },
|
||||
_store: store,
|
||||
};
|
||||
return store;
|
||||
}
|
||||
|
||||
test('scp81OptionsFromForm maps the reference defaults', () => {
|
||||
setup();
|
||||
assert.deepStrictEqual(scp81OptionsFromForm(), {
|
||||
chunked: true,
|
||||
chunk_size: 0,
|
||||
keep_alive: true,
|
||||
conn_header: 'none',
|
||||
compact_headers: false,
|
||||
next_uri: '/api/scp81?req=%d',
|
||||
link_events: true,
|
||||
script_template: 'indefinite',
|
||||
cr_tag: false,
|
||||
targeted_app: ''
|
||||
});
|
||||
});
|
||||
|
||||
test('scp81OptionsFromForm reflects a changed setup', () => {
|
||||
setup({
|
||||
'opt-chunked': false,
|
||||
'opt-chunk-size': '100',
|
||||
'opt-keep-alive': false,
|
||||
'opt-conn-header': 'close',
|
||||
'opt-compact': true,
|
||||
'opt-next-uri-value': '/adminserver?apdu_id=%d',
|
||||
'opt-link-events': false,
|
||||
'opt-script-template': 'definite',
|
||||
'opt-cr-tag': true,
|
||||
'opt-targeted-app': ' //aid/A000000151000000 '
|
||||
});
|
||||
assert.deepStrictEqual(scp81OptionsFromForm(), {
|
||||
chunked: false,
|
||||
chunk_size: 100,
|
||||
keep_alive: false,
|
||||
conn_header: 'close',
|
||||
compact_headers: true,
|
||||
next_uri: '/adminserver?apdu_id=%d',
|
||||
link_events: false,
|
||||
script_template: 'definite',
|
||||
cr_tag: true,
|
||||
targeted_app: '//aid/A000000151000000'
|
||||
});
|
||||
});
|
||||
|
||||
test('unchecked Next-URI omits the header; empty text falls back to the template', () => {
|
||||
setup({ 'opt-next-uri': false });
|
||||
assert.strictEqual(scp81OptionsFromForm().next_uri, '');
|
||||
setup({ 'opt-next-uri': true, 'opt-next-uri-value': ' ' });
|
||||
assert.strictEqual(scp81OptionsFromForm().next_uri, '/api/scp81?req=%d');
|
||||
});
|
||||
|
||||
test('load/persist/reset round-trip through localStorage', () => {
|
||||
const store = fakeStorage();
|
||||
setup({
|
||||
'opt-chunked': false,
|
||||
'opt-chunk-size': 100,
|
||||
'opt-keep-alive': false,
|
||||
'opt-conn-header': 'close',
|
||||
'opt-compact': true,
|
||||
'opt-next-uri': false,
|
||||
'opt-link-events': false,
|
||||
'opt-script-template': 'definite',
|
||||
'opt-cr-tag': true,
|
||||
'opt-targeted-app': '//aid/A000000151000000'
|
||||
});
|
||||
scp81OptionsPersist();
|
||||
assert.ok(store['otaman_scp81_opts'].includes('"chunkSize":100'));
|
||||
// a reload restores the saved setup
|
||||
setup({});
|
||||
scp81OptionsLoad();
|
||||
assert.strictEqual(globalThis.document.getElementById('opt-chunked').checked, false);
|
||||
assert.strictEqual(globalThis.document.getElementById('opt-chunk-size').value, 100);
|
||||
assert.strictEqual(globalThis.document.getElementById('opt-conn-header').value, 'close');
|
||||
assert.strictEqual(globalThis.document.getElementById('opt-next-uri').checked, false);
|
||||
assert.strictEqual(globalThis.document.getElementById('opt-script-template').value, 'definite');
|
||||
assert.strictEqual(globalThis.document.getElementById('opt-cr-tag').checked, true);
|
||||
assert.strictEqual(globalThis.document.getElementById('opt-targeted-app').value, '//aid/A000000151000000');
|
||||
// reset clears the saved entry and restores the defaults
|
||||
scp81OptionsReset();
|
||||
assert.ok(!('otaman_scp81_opts' in store));
|
||||
scp81OptionsLoad();
|
||||
assert.strictEqual(globalThis.document.getElementById('opt-chunked').checked, true);
|
||||
assert.strictEqual(globalThis.document.getElementById('opt-next-uri').checked, true);
|
||||
assert.strictEqual(globalThis.document.getElementById('opt-conn-header').value, 'none');
|
||||
});
|
||||
|
||||
test('the Listener UI wires the framing options into Start', () => {
|
||||
for (const id of ['scp81-opts-http', 'scp81-opts-script', 'opt-chunked',
|
||||
'opt-chunk-size', 'opt-keep-alive', 'opt-conn-header', 'opt-compact',
|
||||
'opt-next-uri', 'opt-next-uri-value', 'opt-script-template', 'opt-cr-tag',
|
||||
'opt-targeted-app', 'opt-link-events']) {
|
||||
assert.ok(html.includes('id="' + id + '"'), id);
|
||||
}
|
||||
assert.ok(html.includes('body.link_events = opts.link_events;'));
|
||||
assert.ok(html.includes('body.chunk_size = opts.chunk_size;'));
|
||||
assert.ok(html.includes('body.next_uri = opts.next_uri;'));
|
||||
assert.ok(html.includes('body.script_template = opts.script_template;'));
|
||||
assert.ok(html.includes("httpOpts.classList.toggle('hidden', mode !== 'tls')"));
|
||||
assert.ok(html.includes("scriptOpts.classList.toggle('hidden', mode !== 'tls')"));
|
||||
});
|
||||
Reference in New Issue
Block a user