Files
MarekWoandClaude Opus 5 be0929a3ae feat(demo): read-only demo mode for a publicly shared instance
mc-webui has no login, which is fine on a LAN and not fine once the
address is handed to testers: they assume the node is theirs and rename
it, and the console is right there for anyone curious enough to type
into it.

MC_DEMO=true refuses everything that reconfigures the radio, destroys
data, reaches other people's repeaters, rebuilds the containers,
publishes to an external broker, or hands out a secret. Reading and
sending messages keep working, so the instance is still worth visiting.

The guard is default-deny: an api_bp.before_request hook refuses every
non-GET unless it is on a short allowlist, so an endpoint added later is
locked without touching this module. That mattered — six mutating
endpoints already had no frontend caller and would never have made a
hand-written blacklist. A small read denylist covers the GETs that leak
(repeater passwords, backup download, capture download, /api/logs),
which the write guard never sees.

Two paths bypass the HTTP layer and are closed separately: the console
is a Socket.IO event, not a route, so it is checked against a read-only
command set; the log viewer is refused at the connect handshake.

Access is kept two ways. MC_DEMO_TRUSTED_NETS unlocks by CIDR, and
MC_DEMO_UNLOCK_CODE unlocks by a code typed once in Settings and held in
an HMAC cookie for 30 days — stateless, and deliberately not Flask's
session, whose SECRET_KEY is a hardcoded public constant.

The trap the network list walks into: published through a tunnel, every
visitor arrives from the tunnel's address, and if the tunnel runs on the
LAN then trusting the LAN unlocks the instance for the whole internet.
An address arriving with a proxy header is therefore not matched unless
MC_TRUST_PROXY is on, and startup logs report the resulting shape.

Frontend is advisory only: demo-lock.js loads from _head_i18n.html so it
reaches all eight entry points including the six iframes, greys out
[data-demo-lock] rather than hiding it (the instance exists to show the
app off), re-applies via MutationObserver for JS-rendered buttons, and
turns any 403 demo_locked into a toast.

Verified on the local container: the rename, reboot, updater, vacuum,
cleanup and the leaky GETs all 403; sending, read markers and ordinary
reads pass; `set name` is refused over the console socket while `infos`
still answers; unlock/lock round-trips. 86 new unit tests, en/pl at 100%.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 18:20:53 +02:00

261 lines
9.5 KiB
JavaScript

/**
* Demo mode — the visible half of the guard.
*
* The server already refuses these actions (app/demo_guard.py); this file exists
* so a visitor sees *why* a control does nothing instead of clicking it and
* getting an error. Greying out rather than hiding is deliberate: the instance
* is shared to show mc-webui off, and a feature nobody can see is a feature
* nobody is impressed by.
*
* Loaded from _head_i18n.html, so it runs on all eight entry points including
* the six fullscreen iframes — the console among them, which is the one page
* where hiding the controls would matter most.
*
* Marking things up:
* data-demo-lock disable every control inside (or the element
* itself, when it is a control)
* data-demo-lock="hide" remove it from the page instead
* data-demo-lock-exempt leave this subtree alone
*
* Anything rendered later by JS is caught by a MutationObserver, because most of
* the dangerous buttons in this app (channel delete, region delete, repeater
* actions) are written into a list long after DOMContentLoaded.
*/
(function () {
'use strict';
const CFG = window.MC_DEMO || {};
const LOCK_CLASS = 'demo-locked';
const CONTROLS = 'input, select, textarea, button, a.btn, [contenteditable="true"]';
/** Locked = demo is on and this visitor has not proved they are the operator. */
function isLocked() {
return !!CFG.enabled && !CFG.unlocked;
}
function tr(key, fallback) {
const value = typeof window.t === 'function' ? window.t(key) : key;
// A missing key resolves to the key itself; prefer readable English to that.
return value === key ? fallback : value;
}
// ============================================================
// Applying the lock
// ============================================================
function lockControl(el) {
if (el.dataset.demoLocked === '1') return;
el.dataset.demoLocked = '1';
if ('disabled' in el) {
el.disabled = true;
}
el.classList.add(LOCK_CLASS);
el.setAttribute('aria-disabled', 'true');
// Keep the original tooltip recoverable, in case the page is unlocked
// later without a reload.
if (el.title) el.dataset.demoTitle = el.title;
el.title = tr('demo.locked_title', 'Disabled in demo mode');
// A disabled <a> still navigates, and a disabled element still fires
// click handlers bound to an ancestor — so refuse the event outright.
if (!el.dataset.demoBound) {
el.dataset.demoBound = '1';
el.addEventListener('click', swallow, true);
}
}
function swallow(event) {
event.preventDefault();
event.stopImmediatePropagation();
notifyBlocked();
}
function applyTo(container) {
if (container.dataset.demoLock === 'hide') {
container.remove();
return;
}
if (container.matches(CONTROLS)) {
lockControl(container);
}
container.querySelectorAll(CONTROLS).forEach(function (el) {
if (el.closest('[data-demo-lock-exempt]')) return;
lockControl(el);
});
container.classList.add('demo-locked-region');
}
function apply(root) {
(root || document).querySelectorAll('[data-demo-lock]').forEach(applyTo);
}
// ============================================================
// Telling the visitor why
// ============================================================
let lastNotice = 0;
function notifyBlocked() {
// Several handlers can fire for one click; one message per second is plenty.
const now = Date.now();
if (now - lastNotice < 1000) return;
lastNotice = now;
const message = tr('demo.blocked_toast',
'This instance runs in demo mode — the action is disabled.');
if (typeof window.showNotification === 'function') {
window.showNotification(message, 'warning');
} else if (typeof window.showToast === 'function') {
window.showToast(message, 'warning');
} else {
console.info('[demo] ' + message);
}
}
// ============================================================
// The server is the real guard — report what it refuses
// ============================================================
/**
* A stale page, an un-marked control or a direct call can still reach a
* locked endpoint. The server answers 403 demo_locked; turn that into the
* same message rather than whatever "failed" text the caller would show.
*/
function interceptFetch() {
const original = window.fetch;
if (!original || original.__demoWrapped) return;
const wrapped = function (input, init) {
return original.call(this, input, init).then(function (response) {
if (response.status === 403) {
// Peek without consuming: the caller still needs the body.
response.clone().json().then(function (body) {
if (body && body.error === 'demo_locked') notifyBlocked();
}).catch(function () { /* not JSON — not ours */ });
}
return response;
});
};
wrapped.__demoWrapped = true;
window.fetch = wrapped;
}
// ============================================================
// Unlocking
// ============================================================
/** Trade the code for the cookie. Resolves true when the page should reload. */
window.demoUnlock = function (code) {
return fetch('/api/demo/unlock', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code: code }),
}).then(function (r) { return r.json().catch(function () { return {}; }); })
.then(function (data) { return !!(data && data.success); });
};
/** Drop the cookie and go back to being a visitor. */
window.demoLock = function () {
return fetch('/api/demo/lock', { method: 'POST' })
.then(function () { return true; })
.catch(function () { return false; });
};
window.demoIsLocked = isLocked;
// ============================================================
// The unlock box in Settings
// ============================================================
/**
* Wired whenever demo mode is on at all — not only while locked — because
* the "lock again" button exists precisely in the unlocked state, which the
* lock pass below returns early from.
*/
function wireUnlockBox() {
const input = document.getElementById('demoUnlockInput');
const unlockBtn = document.getElementById('demoUnlockBtn');
const lockBtn = document.getElementById('demoLockBtn');
const hint = document.getElementById('demoUnlockHint');
if (unlockBtn && input) {
const submit = function () {
const code = input.value.trim();
if (!code) return;
unlockBtn.disabled = true;
window.demoUnlock(code).then(function (ok) {
if (ok) {
// Every locked control on every open page was rendered
// for a visitor; a reload is the honest way to redraw.
window.location.reload();
return;
}
unlockBtn.disabled = false;
input.value = '';
input.classList.add('is-invalid');
if (hint) {
hint.textContent = tr('demo.unlock_bad', 'That code was not accepted.');
hint.classList.add('text-danger');
}
});
};
unlockBtn.addEventListener('click', submit);
input.addEventListener('keydown', function (event) {
if (event.key === 'Enter') {
event.preventDefault();
submit();
}
});
}
if (lockBtn) {
lockBtn.addEventListener('click', function () {
lockBtn.disabled = true;
window.demoLock().then(function () { window.location.reload(); });
});
}
}
// ============================================================
// Start
// ============================================================
function start() {
if (!CFG.enabled) return;
wireUnlockBox();
if (!isLocked()) return;
document.documentElement.classList.add('demo-mode-locked');
apply(document);
// Dangerous controls are routinely rendered into a list after load.
const observer = new MutationObserver(function (mutations) {
for (const mutation of mutations) {
for (const node of mutation.addedNodes) {
if (node.nodeType !== 1) continue;
if (node.matches && node.matches('[data-demo-lock]')) applyTo(node);
if (node.querySelectorAll) apply(node);
}
}
});
observer.observe(document.documentElement, { childList: true, subtree: true });
interceptFetch();
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', start);
} else {
start();
}
})();