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>
This commit is contained in:
MarekWoandClaude Opus 5 committed 2026-08-21 18:20:53 +02:00
1 parent b5e5c69faf
commit be0929a3ae
20 files changed
+1399 -22

No files matched your search

+27
View File
@@ -112,6 +112,33 @@ FLASK_DEBUG=false
# yourself out. Leave unset (0.0.0.0) to keep HTTP available as before.
# MC_BIND_ADDRESS=127.0.0.1
# ============================================
# Demo mode (optional) - see docs/demo-mode.md
# ============================================
# For an instance you hand to people you do not know: everything that changes
# the radio, deletes data, or reconfigures this server is refused, and the
# controls for it are greyed out. Reading and sending messages still work, so
# the instance is still worth visiting. Off by default.
# MC_DEMO=true
# How YOU keep full access. Two independent ways in - set either, or both.
#
# 1) By network. Comma-separated CIDRs whose visitors are never restricted.
# Empty by default, and think before filling it in: if the app is published
# through a tunnel (cloudflared, ngrok) or a reverse proxy, every visitor
# arrives from that one address, and listing the network it sits in would
# unlock the instance for everyone at once. mc-webui refuses to match on
# address alone when it sees a proxy header and MC_TRUST_PROXY is off - but
# the safe setup is still to leave this empty unless the port is reached
# directly from your LAN.
# MC_DEMO_TRUSTED_NETS=192.168.0.0/16,10.0.0.0/8
#
# 2) By code. Typed once under Settings, then remembered in a cookie for 30
# days. Works from any network, which is what you want if the app is behind
# a tunnel. Make it long - it is the only thing between a visitor and your
# radio, and it is not rate-limited beyond a one-second delay per attempt.
# MC_DEMO_UNLOCK_CODE=choose-something-long-and-unguessable
# ============================================
# System Configuration
# ============================================
+8
View File
@@ -462,6 +462,7 @@ sudo ~/mc-webui/scripts/updater/install.sh --uninstall
| [User Guide](docs/user-guide.md) | Complete feature documentation |
| [Android App](docs/android-app.md) | Installing the Android companion app and connecting it to your instance |
| [HTTPS Setup](docs/https-setup.md) | Optional encrypted access via Nginx Proxy Manager (Let's Encrypt, self-signed, IP address) |
| [Demo Mode](docs/demo-mode.md) | Share an instance publicly with the device settings, the console and your data locked |
| [Architecture](docs/architecture.md) | Technical details, API reference |
| [Troubleshooting](docs/troubleshooting.md) | Common issues and solutions |
| [Docker Installation](docs/docker-install.md) | How to install Docker on Debian/Ubuntu |
@@ -518,6 +519,13 @@ sudo ~/mc-webui/scripts/updater/install.sh --uninstall
**Important**: This application is designed for **trusted local networks only** and has **no authentication**. Do not expose it to the internet without implementing proper security measures.
If you want to share an instance anyway — a public demo, a group of testers — turn on
[demo mode](docs/demo-mode.md) (`MC_DEMO=true`). Everything that reconfigures the radio,
deletes data or reaches the server is refused, while reading and chatting keep working;
you keep full access from your own network or with an unlock code. It restricts what
visitors can *change*, not what they can *see*, so pair it with a login (NPM's Access
Lists, see [HTTPS Setup](docs/https-setup.md)) if the content itself is private.
---
## Contributing
+20
View File
@@ -50,6 +50,26 @@ class Config:
# talking to the port directly can forge those headers.
MC_TRUST_PROXY = os.getenv('MC_TRUST_PROXY', 'false').lower() == 'true'
# Demo mode: the instance is shared with people who should be able to look
# around and chat, but not reconfigure the radio, wipe data or reach the
# console. Everything that writes is refused unless the caller is unlocked
# (see app/demo_guard.py). Off by default — a private install is unaffected.
MC_DEMO = os.getenv('MC_DEMO', 'false').lower() == 'true'
# Networks that get full access without typing anything, as comma-separated
# CIDRs (e.g. "192.168.0.0/16,10.0.0.0/8"). Empty by default, and DELIBERATELY
# so: when the app is published through a tunnel or a proxy, every visitor
# arrives from that one address, and a network listed here would unlock the
# instance for all of them. demo_guard refuses to match on address alone when
# a forwarded header is present and MC_TRUST_PROXY is off, but the safe setup
# is still to leave this empty unless the port is reached directly.
MC_DEMO_TRUSTED_NETS = os.getenv('MC_DEMO_TRUSTED_NETS', '')
# Shared secret that unlocks the UI from anywhere — typed once in Settings,
# remembered in a signed cookie. Empty = no unlock code, only the networks
# above (or nothing at all) can write.
MC_DEMO_UNLOCK_CODE = os.getenv('MC_DEMO_UNLOCK_CODE', '')
# Flask server configuration
FLASK_HOST = os.getenv('FLASK_HOST', '0.0.0.0')
FLASK_PORT = int(os.getenv('FLASK_PORT', '5000'))
+289
View File
@@ -0,0 +1,289 @@
"""
Demo mode — read-only guard for a publicly shared instance.
mc-webui has no authentication: whoever reaches the port can rename the radio,
rewrite its frequency, wipe the address book or type into the console. That is
fine on a home LAN and not fine once the instance is handed to strangers, which
is what MC_DEMO=true is for.
The rules, in the order they are applied:
1. MC_DEMO off → nothing changes, every request passes. Existing installs are
untouched by this module.
2. Caller unlocked → every request passes. A caller is unlocked by coming from
a network in MC_DEMO_TRUSTED_NETS, or by holding the cookie handed out for
MC_DEMO_UNLOCK_CODE.
3. Otherwise → writes are refused (default deny, see WRITE_ALLOWLIST for the
handful of things a visitor may still do), a few reads that hand out secrets
are refused too (READ_DENYLIST), and the console accepts only the commands
in CONSOLE_READONLY.
Default deny is the point. New endpoints are locked the day they are written
rather than the day someone remembers to add them to a list.
"""
import hmac
import ipaddress
import logging
import re
from hashlib import sha256
from typing import List, Optional
from app.config import config
logger = logging.getLogger(__name__)
# Cookie carrying the unlock proof. HttpOnly — the UI learns whether it is
# unlocked from /api/status, never by reading this back.
UNLOCK_COOKIE = 'mc_demo_unlock'
UNLOCK_COOKIE_MAX_AGE = 30 * 24 * 3600 # 30 days
# Domain separation, so the token cannot be replayed as some other HMAC.
_UNLOCK_MESSAGE = b'mc-webui-demo-unlock-v1'
# Error payload for a refused call. The frontend greys these controls out, so
# seeing this means either a stale page or a direct API call.
DEMO_ERROR = 'demo_locked'
# ================================================================
# What stays reachable while locked
# ================================================================
# Writes a visitor may still perform. Anything not listed here is refused.
# Chatting is the whole point of the demo, so sending stays open; every form
# of configuration does not.
WRITE_ALLOWLIST = [
re.compile(r'^/api/messages$'),
re.compile(r'^/api/messages/\d+/resend$'),
re.compile(r'^/api/dm/messages$'),
re.compile(r'^/api/read_status/mark_read$'),
re.compile(r'^/api/read_status/mark_all_read$'),
# The unlock endpoints themselves, or there is no way back in.
re.compile(r'^/api/demo/unlock$'),
re.compile(r'^/api/demo/lock$'),
]
# Reads refused while locked. These are GETs, so the write guard never sees
# them, and each one hands out something a visitor has no business holding:
# stored repeater admin passwords, a copy of the whole database, captured
# message text, or the server log.
READ_DENYLIST = [
re.compile(r'^/api/repeaters/[^/]+/password$'),
re.compile(r'^/api/backup/list$'),
re.compile(r'^/api/backup/download$'),
re.compile(r'^/api/diagnostics/captures/[^/]+/download$'),
re.compile(r'^/api/logs$'),
]
# Console commands that only read. Everything else — every `set`, every form of
# transmit, every delete — is refused. `get_channel` is absent on purpose: it
# prints a channel's shared key. `clock` is absent because the same branch also
# sets the device clock.
CONSOLE_READONLY = frozenset({
'infos', 'status', 'stats', 'bat', 'ver', 'help',
'contacts', 'contacts_all', 'contact_info', 'pending_contacts',
'channels', 'path', 'get',
})
# ================================================================
# Trusted networks
# ================================================================
def _parse_nets(raw: str) -> List[ipaddress._BaseNetwork]:
"""Parse the comma-separated CIDR list, skipping (and logging) bad entries."""
nets = []
for chunk in raw.split(','):
chunk = chunk.strip()
if not chunk:
continue
try:
nets.append(ipaddress.ip_network(chunk, strict=False))
except ValueError:
logger.warning("MC_DEMO_TRUSTED_NETS: ignoring unparseable entry %r", chunk)
return nets
# Parsed once per distinct value of the setting rather than once per import, so
# the parse stays off the request path without freezing the config at import
# time — which is what lets the tests drive it.
_nets_cache: tuple = (None, []) # (raw value it was parsed from, parsed nets)
def trusted_nets() -> List[ipaddress._BaseNetwork]:
"""The configured trusted networks, parsed."""
global _nets_cache
raw = config.MC_DEMO_TRUSTED_NETS or ''
if _nets_cache[0] != raw:
_nets_cache = (raw, _parse_nets(raw))
return _nets_cache[1]
def _came_through_a_proxy(req) -> bool:
"""
True when the request carries a hop header, i.e. the peer address belongs to
a proxy or tunnel rather than to the client.
"""
return any(
h in req.headers
for h in ('X-Forwarded-For', 'X-Real-IP', 'Forwarded', 'CF-Connecting-IP')
)
def ip_is_trusted(req) -> bool:
"""
True when the caller's address falls inside MC_DEMO_TRUSTED_NETS.
The subtlety worth keeping: when the instance is published through a tunnel
(cloudflared, ngrok, a VPN box) every visitor arrives from that tunnel's
address. If the tunnel runs on the LAN, that address sits inside the very
network the operator listed as trusted — and matching on it would unlock the
instance for the whole internet at once. So an address is only accepted when
it is genuinely the client's: either no hop header is present, or
MC_TRUST_PROXY is on and ProxyFix has already replaced remote_addr with the
real client address.
"""
nets = trusted_nets()
if not nets:
return False
if not config.MC_TRUST_PROXY and _came_through_a_proxy(req):
return False
addr = req.remote_addr
if not addr:
return False
try:
ip = ipaddress.ip_address(addr.split('%')[0]) # strip any zone id
except ValueError:
return False
return any(ip in net for net in nets)
# ================================================================
# Unlock code
# ================================================================
def unlock_token() -> str:
"""The cookie value proving knowledge of MC_DEMO_UNLOCK_CODE."""
return hmac.new(
config.MC_DEMO_UNLOCK_CODE.encode('utf-8'), _UNLOCK_MESSAGE, sha256
).hexdigest()
def code_is_valid(code: Optional[str]) -> bool:
"""Constant-time check of a code typed by the user."""
if not config.MC_DEMO_UNLOCK_CODE or not code:
return False
return hmac.compare_digest(code, config.MC_DEMO_UNLOCK_CODE)
def cookie_is_valid(req) -> bool:
"""Constant-time check of the unlock cookie."""
if not config.MC_DEMO_UNLOCK_CODE:
return False
presented = req.cookies.get(UNLOCK_COOKIE)
if not presented:
return False
return hmac.compare_digest(presented, unlock_token())
# ================================================================
# The decision
# ================================================================
def is_demo() -> bool:
"""True when this instance runs in demo mode at all."""
return bool(config.MC_DEMO)
def is_unlocked(req) -> bool:
"""
True when the caller may do anything — because demo mode is off, or because
they proved they are the operator.
"""
if not config.MC_DEMO:
return True
return ip_is_trusted(req) or cookie_is_valid(req)
def _matches(path: str, patterns) -> bool:
return any(p.match(path) for p in patterns)
def request_is_blocked(req) -> bool:
"""
True when this request must be refused. Call it from a before_request hook.
"""
if is_unlocked(req):
return False
path = req.path.rstrip('/') or '/'
if req.method in ('GET', 'HEAD', 'OPTIONS'):
return _matches(path, READ_DENYLIST)
return not _matches(path, WRITE_ALLOWLIST)
def console_command_is_blocked(command: str) -> bool:
"""
True when a console command must be refused. `command` is the raw line; only
its first word decides, which is the same thing the router dispatches on.
"""
if not config.MC_DEMO:
return False
head = (command or '').strip().split()
if not head:
return False
return head[0].lower() not in CONSOLE_READONLY
# ================================================================
# Startup reporting
# ================================================================
def log_startup_state() -> None:
"""
Say plainly what the guard will do, once, at boot. A demo instance that
quietly failed to lock looks exactly like one that locked correctly, so the
log is the only place the difference shows up before a tester finds it.
"""
if not config.MC_DEMO:
return
logger.info("Demo mode ON — writes are refused unless the caller is unlocked")
nets = trusted_nets()
if nets:
logger.info(
"Demo mode: trusted networks %s",
', '.join(str(n) for n in nets),
)
if not config.MC_TRUST_PROXY:
logger.warning(
"Demo mode: MC_DEMO_TRUSTED_NETS is set but MC_TRUST_PROXY is off. "
"Requests arriving with a proxy/tunnel header will NOT be trusted by "
"address, because that address would be the proxy's and not the "
"visitor's. Set MC_TRUST_PROXY=true only if the app is reached "
"exclusively through your proxy."
)
else:
logger.info("Demo mode: no trusted networks configured")
if config.MC_DEMO_UNLOCK_CODE:
if len(config.MC_DEMO_UNLOCK_CODE) < 8:
logger.warning(
"Demo mode: MC_DEMO_UNLOCK_CODE is shorter than 8 characters — "
"it is the only thing standing between a visitor and the radio"
)
else:
logger.info("Demo mode: unlock code is set")
else:
logger.info("Demo mode: no unlock code set")
if not nets and not config.MC_DEMO_UNLOCK_CODE:
logger.warning(
"Demo mode: neither MC_DEMO_TRUSTED_NETS nor MC_DEMO_UNLOCK_CODE is "
"set — this instance is locked for everyone, including you"
)
+32
View File
@@ -18,6 +18,7 @@ from flask_socketio import SocketIO, emit
from werkzeug.middleware.proxy_fix import ProxyFix
from app import i18n
from app.config import config, runtime_config
from app import demo_guard
from app.database import Database
from app.device_manager import DeviceManager, parse_meshcore_uri
from app.diagnostics import DiagnosticsManager
@@ -238,6 +239,8 @@ def create_app():
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_prefix=1)
logger.info("Trusting X-Forwarded-* headers from one proxy hop (MC_TRUST_PROXY=true)")
demo_guard.log_startup_state()
# Inject version, branch, transport type, and UI language into all templates.
# This is the single injection point for i18n — it covers every render_template()
# in the app, including the six standalone pages loaded as fullscreen iframes.
@@ -249,6 +252,13 @@ def create_app():
'version': VERSION_STRING,
'git_branch': GIT_BRANCH,
'transport_type': config.transport_type,
# Demo mode reaches the templates here rather than through MC_CONFIG,
# because the fullscreen iframe pages (console, repeaters, contacts,
# dm, path-analyzer) never define MC_CONFIG and never load app.js —
# and the console is precisely one of the things being locked.
'demo_mode': demo_guard.is_demo(),
'demo_unlocked': demo_guard.is_unlocked(flask_request),
'demo_has_unlock_code': bool(config.MC_DEMO_UNLOCK_CODE),
'lang': lang,
'i18n_catalog_url': i18n.catalog_url(lang),
'available_languages': i18n.available_languages(),
@@ -436,6 +446,22 @@ def handle_send_command(data):
emit('command_response', {'success': False, 'error': 'Empty command'})
return
# The console reaches `set name`, `set radio`, `reboot`, `remove_channel` and
# every other thing demo mode exists to prevent, and it arrives over a socket
# rather than through the /api blueprint — so the HTTP guard never sees it.
# Read-only commands still run, so the console remains worth demonstrating.
if demo_guard.console_command_is_blocked(command) and not demo_guard.is_unlocked(flask_request):
logger.info(f"Demo mode: refused console command: {command}")
emit('command_response', {
'success': False,
'command': command,
'error': (
'This instance runs in demo mode — only read-only commands are '
'available. Allowed: ' + ', '.join(sorted(demo_guard.CONSOLE_READONLY))
),
})
return
logger.info(f"Console command received: {command}")
def execute_and_respond():
@@ -477,6 +503,12 @@ def handle_send_command(data):
@socketio.on('connect', namespace='/logs')
def handle_logs_connect():
"""Handle log viewer WebSocket connection."""
# The server log carries device internals, addresses and message text. On a
# public demo it is refused at the handshake, which also stops the stream the
# HTTP guard on /api/logs cannot see.
if not demo_guard.is_unlocked(flask_request):
logger.info("Demo mode: refused log viewer connection")
return False
logger.debug("Log viewer WebSocket client connected")
+69
View File
@@ -24,6 +24,7 @@ from app.config import config, runtime_config
from app.device_manager import decode_path_len
from app.archiver import manager as archive_manager
from app.contacts_cache import get_all_names, get_all_contacts
from app import demo_guard
logger = logging.getLogger(__name__)
@@ -39,6 +40,67 @@ def _get_dm():
api_bp = Blueprint('api', __name__, url_prefix='/api')
@api_bp.before_request
def _demo_guard():
"""
Refuse anything that writes when this instance runs as a public demo and the
caller has not unlocked it. Sits in front of every /api route, so an endpoint
added later is locked by default rather than by remembering to lock it —
including the several that have no button in the UI and are only reachable
with curl. See app/demo_guard.py for what stays open.
"""
if demo_guard.request_is_blocked(request):
logger.info(
"Demo mode: refused %s %s from %s", request.method, request.path, request.remote_addr
)
return jsonify({
'success': False,
'error': demo_guard.DEMO_ERROR,
'message': 'This instance runs in demo mode — the action is disabled.',
}), 403
@api_bp.route('/demo/unlock', methods=['POST'])
def demo_unlock():
"""
Trade the unlock code for the cookie that lifts the demo restrictions.
Deliberately quiet about why a code failed, and deliberately slow to answer
— the code is the only thing protecting the radio from anyone who can reach
the page, so a wrong guess should cost something.
"""
data = request.get_json(silent=True) or {}
code = str(data.get('code', ''))
if not demo_guard.is_demo():
return jsonify({'success': False, 'error': 'Demo mode is not enabled'}), 400
if not demo_guard.code_is_valid(code):
time.sleep(1.0)
logger.warning("Demo mode: failed unlock attempt from %s", request.remote_addr)
return jsonify({'success': False, 'error': 'invalid_code'}), 403
logger.info("Demo mode: unlocked from %s", request.remote_addr)
response = jsonify({'success': True, 'unlocked': True})
response.set_cookie(
demo_guard.UNLOCK_COOKIE,
demo_guard.unlock_token(),
max_age=demo_guard.UNLOCK_COOKIE_MAX_AGE,
httponly=True,
samesite='Lax',
secure=request.is_secure,
)
return response
@api_bp.route('/demo/lock', methods=['POST'])
def demo_lock():
"""Drop the unlock cookie — used by the "lock again" button in Settings."""
response = jsonify({'success': True, 'unlocked': False})
response.delete_cookie(demo_guard.UNLOCK_COOKIE, samesite='Lax')
return response
# Simple cache for get_channels() to reduce USB/meshcli calls
# Channels don't change frequently, so caching for 30s is safe
_channels_cache = None
@@ -936,6 +998,13 @@ def get_status():
except Exception:
pass
# Demo mode, so the UI knows whether to grey the controls out and whether
# to offer the unlock box. Both flags, not one: a locked and an unlocked
# visitor need different UI, and a normal install needs neither.
status_data['demo_mode'] = demo_guard.is_demo()
status_data['demo_unlocked'] = demo_guard.is_unlocked(request)
status_data['demo_has_unlock_code'] = bool(config.MC_DEMO_UNLOCK_CODE)
return jsonify(status_data), 200
except Exception as e:
+62
View File
@@ -2643,3 +2643,65 @@ emoji-picker {
.share-preview-body .share-card {
margin: 0;
}
/* ============================================================
Demo mode (MC_DEMO)
------------------------------------------------------------
Locked controls stay visible and stay laid out — the instance is shared to
show the interface off, so a control that vanished would just look like a
feature mc-webui does not have. Dimmed and struck out of reach instead.
============================================================ */
.demo-locked {
opacity: 0.5;
cursor: not-allowed !important;
filter: grayscale(1);
}
/* Bootstrap paints its own disabled state on form controls; keep ours from
fighting it, and keep the pointer honest over the whole locked block. */
.demo-locked-region {
cursor: not-allowed;
}
.demo-locked-region .form-check-input,
.demo-locked-region .form-control,
.demo-locked-region .form-select {
background-color: var(--bg-surface-alt);
}
/* The notice that explains the greying out. Sits at the top of a locked pane. */
.demo-notice {
display: flex;
align-items: flex-start;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
margin-bottom: 0.75rem;
border: 1px solid var(--border-color);
border-left: 3px solid #ffc107;
border-radius: 0.375rem;
background: var(--bg-surface);
color: var(--text-secondary);
font-size: 0.8125rem;
line-height: 1.35;
}
.demo-notice i {
color: #ffc107;
margin-top: 0.1rem;
}
/* "Demo" marker in the navbar, so it is obvious which instance this is. */
.demo-badge {
font-size: 0.65rem;
letter-spacing: 0.05em;
text-transform: uppercase;
vertical-align: middle;
}
/* Operator unlock box at the foot of the Settings modal. */
.demo-unlock-box {
border-top: 1px solid var(--border-color);
margin-top: 1rem;
padding-top: 0.75rem;
}
+1 -1
View File
@@ -2284,7 +2284,7 @@ async function loadDeviceInfo() {
html += `
<div class="d-flex align-items-center justify-content-between gap-2 mt-3 pt-3 border-top">
<div class="small text-muted">${tHtml('device.reboot.hint')}</div>
<button type="button" class="btn btn-sm btn-outline-danger flex-shrink-0" id="deviceRebootBtn" onclick="rebootDevice()">
<button type="button" class="btn btn-sm btn-outline-danger flex-shrink-0" id="deviceRebootBtn" data-demo-lock onclick="rebootDevice()">
<i class="bi bi-arrow-clockwise"></i> ${tHtml('device.reboot.btn')}
</button>
</div>
+260
View File
@@ -0,0 +1,260 @@
/**
* 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();
}
})();
+12
View File
@@ -10,3 +10,15 @@
<script src="{{ url_for('static', filename='js/i18n-runtime.js') }}"></script>
<script src="{{ url_for('static', filename='js/datetime-utils.js') }}"></script>
<script src="{{ url_for('static', filename='js/clipboard-utils.js') }}"></script>
{# Demo mode. Injected here rather than through MC_CONFIG because only index.html
and dm.html define that object, and the console — the page most in need of
locking — is not one of them. Must be set before demo-lock.js runs. #}
<script>
window.MC_DEMO = {
enabled: {{ 'true' if demo_mode else 'false' }},
unlocked: {{ 'true' if demo_unlocked else 'false' }},
hasUnlockCode: {{ 'true' if demo_has_unlock_code else 'false' }}
};
</script>
<script src="{{ url_for('static', filename='js/demo-lock.js') }}"></script>
+51 -18
View File
@@ -101,12 +101,12 @@
<div class="d-flex align-items-center justify-content-between">
<span id="versionDisplay">
<i class="bi bi-tag"></i> <span id="releaseText" class="fw-semibold">{{ release }}</span>
<span class="badge bg-secondary ms-1" id="branchBadge">{{ git_branch }}</span>
<span class="badge bg-secondary ms-1" id="branchBadge">{{ git_branch }}</span>{% if demo_mode %}<span class="badge bg-warning text-dark ms-1 demo-badge" id="demoBadge">{{ t('demo.badge') }}</span>{% endif %}
<!-- Exact build: what pins a deployment down, and what the
update poller compares - keep it as its own element -->
<span id="versionText" class="d-block opacity-75" title="{{ t('menu.exact_build_title') }}">{{ version }}</span>
</span>
<button id="checkUpdateBtn" class="btn btn-sm btn-outline-secondary py-0 px-1" title="{{ t('menu.check_updates_title') }}">
<button id="checkUpdateBtn" data-demo-lock class="btn btn-sm btn-outline-secondary py-0 px-1" title="{{ t('menu.check_updates_title') }}">
<i class="bi bi-arrow-repeat" id="checkUpdateIcon"></i>
</button>
</div>
@@ -181,14 +181,14 @@
<div class="list-group-item py-2 mt-2">
<small class="text-muted fw-bold text-uppercase">{{ t('menu.section.network') }}</small>
</div>
<button class="list-group-item list-group-item-action d-flex align-items-center gap-3" id="advertBtn" title="{{ t('menu.advert_title') }}">
<button class="list-group-item list-group-item-action d-flex align-items-center gap-3" id="advertBtn" data-demo-lock title="{{ t('menu.advert_title') }}">
<i class="bi bi-megaphone" style="font-size: 1.5rem;"></i>
<div>
<span>{{ t('menu.advert') }}</span>
<small class="d-block text-muted">{{ t('menu.advert_desc') }}</small>
</div>
</button>
<button class="list-group-item list-group-item-action d-flex align-items-center gap-3 text-warning" id="floodadvBtn" title="{{ t('menu.flood_advert_title') }}">
<button class="list-group-item list-group-item-action d-flex align-items-center gap-3 text-warning" id="floodadvBtn" data-demo-lock title="{{ t('menu.flood_advert_title') }}">
<i class="bi bi-broadcast" style="font-size: 1.5rem;"></i>
<div>
<span>{{ t('menu.flood_advert') }}</span>
@@ -238,7 +238,7 @@
<i class="bi bi-cpu" style="font-size: 1.5rem;"></i>
<span>{{ t('menu.device_info') }}</span>
</button>
<button id="logsBtn" class="list-group-item list-group-item-action d-flex align-items-center gap-3" data-bs-toggle="modal" data-bs-target="#logsModal" data-bs-dismiss="offcanvas">
<button id="logsBtn" data-demo-lock="hide" class="list-group-item list-group-item-action d-flex align-items-center gap-3" data-bs-toggle="modal" data-bs-target="#logsModal" data-bs-dismiss="offcanvas">
<i class="bi bi-journal-text" style="font-size: 1.5rem;"></i>
<div>
<span>{{ t('logs.title') }}</span>
@@ -465,6 +465,12 @@
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
{% if demo_mode and not demo_unlocked %}
<div class="demo-notice" id="demoSettingsNotice">
<i class="bi bi-shield-lock"></i>
<span>{{ t_html('demo.settings_notice') }}</span>
</div>
{% endif %}
<ul class="nav nav-tabs mb-3" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" data-bs-toggle="tab" data-bs-target="#tabSettingsDevice" type="button">{{ t('device.title') }}</button>
@@ -516,7 +522,7 @@
<div class="tab-content">
<!-- Public Info sub-tab -->
<div class="tab-pane fade show active" id="tabDevicePublicInfo">
<form id="devicePublicInfoForm">
<form id="devicePublicInfoForm" data-demo-lock>
<table class="table table-sm table-borderless mb-3 align-middle">
<tbody>
<tr>
@@ -573,7 +579,7 @@
</div>
<!-- Radio Settings sub-tab -->
<div class="tab-pane fade" id="tabDeviceRadio">
<form id="deviceRadioForm">
<form id="deviceRadioForm" data-demo-lock>
<table class="table table-sm table-borderless mb-3 align-middle">
<tbody>
<tr>
@@ -629,7 +635,7 @@
</div>
</div>
</div>
<div class="tab-pane fade" id="tabSettingsMessages">
<div class="tab-pane fade" id="tabSettingsMessages" data-demo-lock>
<div id="settingsMessagesContent">
<form id="dmRetrySettingsForm">
<p class="text-muted small mb-3">{{ t('settings.msg.intro') }}</p>
@@ -682,7 +688,7 @@
</form>
</div>
</div>
<div class="tab-pane fade" id="tabSettingsChat">
<div class="tab-pane fade" id="tabSettingsChat" data-demo-lock>
<form id="chatSettingsForm">
<h6 class="text-muted mb-2">{{ t('settings.chat.quote') }}</h6>
<table class="table table-sm table-borderless mb-3 align-middle">
@@ -718,7 +724,7 @@
</div>
</form>
</div>
<div class="tab-pane fade" id="tabSettingsInterface">
<div class="tab-pane fade" id="tabSettingsInterface" data-demo-lock>
<h6 class="text-muted mb-2">{{ t('settings.iface.layout') }}</h6>
<p class="text-muted small mb-2">{{ t('settings.iface.layout_desc') }}</p>
<table class="table table-sm table-borderless mb-3 align-middle">
@@ -774,7 +780,7 @@
</div>
<div class="tab-pane fade" id="tabSettingsAppearance">
<h6 class="text-muted mb-3">{{ t('settings.appear.language') }}</h6>
<div class="input-group input-group-sm mb-2">
<div class="input-group input-group-sm mb-2" data-demo-lock>
<select class="form-select form-select-sm" id="settLanguage">
{% for code, name in available_languages.items() %}
<option value="{{ code }}"{% if code == lang %} selected{% endif %}>{{ name }}</option>
@@ -999,7 +1005,7 @@
</table>
</div>
</div>
<div class="tab-pane fade" id="tabSettingsContacts">
<div class="tab-pane fade" id="tabSettingsContacts" data-demo-lock>
<table class="table table-sm table-borderless mb-0 align-middle">
<tbody>
<tr>
@@ -1038,7 +1044,7 @@
</tbody>
</table>
</div>
<div class="tab-pane fade" id="tabSettingsRegions">
<div class="tab-pane fade" id="tabSettingsRegions" data-demo-lock>
<div class="alert alert-info py-2 small mb-3 mt-2">
<i class="bi bi-info-circle"></i> {{ t_html('settings.regions.intro') }}
</div>
@@ -1063,7 +1069,7 @@
{{ t_html('settings.regions.tip') }}
</div>
</div>
<div class="tab-pane fade" id="tabSettingsAnalyzer">
<div class="tab-pane fade" id="tabSettingsAnalyzer" data-demo-lock>
<div class="alert alert-info py-2 small mb-3 mt-2">
<i class="bi bi-info-circle"></i> {{ t_html('settings.analyzer.intro') }}
</div>
@@ -1080,7 +1086,7 @@
{{ t('settings.analyzer.tip') }}
</div>
</div>
<div class="tab-pane fade" id="tabSettingsObserver">
<div class="tab-pane fade" id="tabSettingsObserver" data-demo-lock>
<div class="alert alert-info py-2 small mb-3 mt-2">
<i class="bi bi-info-circle"></i> {{ t_html('settings.observer.intro') }}
</div>
@@ -1130,7 +1136,7 @@
</div>
</button>
</div>
<div class="tab-pane fade" id="tabSettingsDiagnostics">
<div class="tab-pane fade" id="tabSettingsDiagnostics" data-demo-lock>
<div class="alert alert-info py-2 small mb-3 mt-2">
<i class="bi bi-info-circle"></i> {{ t_html('settings.diag.intro') }}
</div>
@@ -1204,6 +1210,33 @@
</button>
</div>
</div>
{% if demo_mode and demo_has_unlock_code %}
<div class="demo-unlock-box" data-demo-lock-exempt>
{% if demo_unlocked %}
<div class="d-flex align-items-center justify-content-between gap-2">
<div class="small text-success">
<i class="bi bi-shield-check"></i> {{ t('demo.unlocked_state') }}
</div>
<button type="button" class="btn btn-outline-secondary btn-sm" id="demoLockBtn">
{{ t('demo.lock_btn') }}
</button>
</div>
{% else %}
<label class="form-label small mb-1" for="demoUnlockInput">
<i class="bi bi-key"></i> {{ t('demo.unlock_label') }}
</label>
<div class="input-group input-group-sm">
<input type="password" class="form-control form-control-sm" id="demoUnlockInput"
autocomplete="off" placeholder="{{ t('demo.unlock_ph') }}">
<button class="btn btn-primary" type="button" id="demoUnlockBtn">
{{ t('demo.unlock_btn') }}
</button>
</div>
<div class="form-text small" id="demoUnlockHint">{{ t('demo.unlock_hint') }}</div>
{% endif %}
</div>
{% endif %}
</div>
<div class="modal-footer py-2">
<button type="button" class="btn btn-secondary btn-sm" data-bs-dismiss="modal">{{ t('common.close') }}</button>
@@ -1448,7 +1481,7 @@
</div>
<div class="modal-body">
<div class="d-flex justify-content-between align-items-center mb-3">
<button class="btn btn-primary" id="createBackupBtn" onclick="createBackup()">
<button class="btn btn-primary" id="createBackupBtn" data-demo-lock onclick="createBackup()">
<i class="bi bi-plus-circle"></i> {{ t('backup.create') }}
</button>
<span id="backupAutoStatus" class="text-muted small"></span>
@@ -1464,7 +1497,7 @@
{{ t_html('backup.optimize_hint') }}
</p>
<div class="d-flex justify-content-between align-items-center">
<button class="btn btn-outline-primary btn-sm" id="vacuumDbBtn" onclick="optimizeDatabase()">
<button class="btn btn-outline-primary btn-sm" id="vacuumDbBtn" data-demo-lock onclick="optimizeDatabase()">
<i class="bi bi-arrows-collapse"></i> {{ t('backup.optimize_now') }}
</button>
<span id="vacuumDbStatus" class="text-muted small">{{ t('backup.current_size') }}</span>
+7
View File
@@ -325,6 +325,13 @@
</button>
</div>
{% if demo_mode and not demo_unlocked %}
<div class="demo-notice mx-3 mb-0 mt-2" id="demoConsoleNotice">
<i class="bi bi-shield-lock"></i>
<span>{{ t_html('demo.console_notice') }}</span>
</div>
{% endif %}
<!-- Input Area -->
<div class="console-input-area">
<form id="consoleForm" class="d-flex gap-2">
+3 -3
View File
@@ -137,10 +137,10 @@
<button class="fab fab-settings" data-bs-toggle="modal" data-bs-target="#settingsModal" title="{{ t('common.settings') }}">
<i class="bi bi-gear-fill"></i>
</button>
<button class="fab fab-advert d-none" id="fab-advert" title="{{ t('menu.advert') }}">
<button class="fab fab-advert d-none" id="fab-advert" data-demo-lock="hide" title="{{ t('menu.advert') }}">
<i class="bi bi-megaphone"></i>
</button>
<button class="fab fab-floodadvert d-none" id="fab-floodadvert" title="{{ t('menu.flood_advert') }}">
<button class="fab fab-floodadvert d-none" id="fab-floodadvert" data-demo-lock="hide" title="{{ t('menu.flood_advert') }}">
<i class="bi bi-broadcast"></i>
</button>
<button class="fab fab-map d-none" id="fab-map" title="{{ t('menu.map') }}">
@@ -158,7 +158,7 @@
<button class="fab fab-deviceinfo d-none" id="fab-deviceinfo" data-bs-toggle="modal" data-bs-target="#deviceInfoModal" title="{{ t('menu.device_info') }}">
<i class="bi bi-cpu"></i>
</button>
<button class="fab fab-syslog d-none" id="fab-syslog" data-bs-toggle="modal" data-bs-target="#logsModal" title="{{ t('logs.title') }}">
<button class="fab fab-syslog d-none" id="fab-syslog" data-demo-lock="hide" data-bs-toggle="modal" data-bs-target="#logsModal" title="{{ t('logs.title') }}">
<i class="bi bi-journal-text"></i>
</button>
</div>
+12
View File
@@ -406,6 +406,18 @@
"coord.confirm": "Confirm",
"coord.hint": "Click on the map to select coordinates",
"coord.title": "Pick Coordinates",
"demo.badge": "Demo",
"demo.blocked_toast": "This instance runs in demo mode — the action is disabled.",
"demo.console_notice": "Demo mode: the console answers read-only commands such as <code>infos</code>, <code>stats</code>, <code>contacts</code> and <code>get</code>. Commands that change the device are refused.",
"demo.lock_btn": "Lock again",
"demo.locked_title": "Disabled in demo mode",
"demo.settings_notice": "This is a public demo instance, so settings that change the radio, the stored data or this server are read-only. Everything else — reading, sending messages, themes — works normally.",
"demo.unlock_bad": "That code was not accepted.",
"demo.unlock_btn": "Unlock",
"demo.unlock_hint": "If you administer this instance, enter the unlock code to restore full access on this device.",
"demo.unlock_label": "Operator access",
"demo.unlock_ph": "Unlock code",
"demo.unlocked_state": "Unlocked — you have full access on this device.",
"device.cmd.exec_failed": "Failed to execute {command}",
"device.cmd.failed": "Command failed: {error}",
"device.cmd.sent": "{command} sent successfully",
+12
View File
@@ -426,6 +426,18 @@
"coord.confirm": "Zatwierdź",
"coord.hint": "Kliknij na mapie, aby wybrać współrzędne",
"coord.title": "Wybierz współrzędne",
"demo.badge": "Demo",
"demo.blocked_toast": "Ta instancja działa w trybie demo — akcja jest niedostępna.",
"demo.console_notice": "Tryb demo: konsola odpowiada na polecenia tylko do odczytu, takie jak <code>infos</code>, <code>stats</code>, <code>contacts</code> i <code>get</code>. Polecenia zmieniające urządzenie są odrzucane.",
"demo.lock_btn": "Zablokuj ponownie",
"demo.locked_title": "Wyłączone w trybie demo",
"demo.settings_notice": "To jest publiczna instancja demonstracyjna, więc ustawienia zmieniające radio, zapisane dane lub ten serwer są tylko do odczytu. Cała reszta — przeglądanie, wysyłanie wiadomości, motywy — działa normalnie.",
"demo.unlock_bad": "Ten kod nie został zaakceptowany.",
"demo.unlock_btn": "Odblokuj",
"demo.unlock_hint": "Jeśli administrujesz tą instancją, podaj kod odblokowujący, aby przywrócić pełny dostęp na tym urządzeniu.",
"demo.unlock_label": "Dostęp operatora",
"demo.unlock_ph": "Kod odblokowujący",
"demo.unlocked_state": "Odblokowane — masz pełny dostęp na tym urządzeniu.",
"device.cmd.exec_failed": "Nie udało się wykonać: {command}",
"device.cmd.failed": "Polecenie nie powiodło się: {error}",
"device.cmd.sent": "Wysłano: {command}",
+3
View File
@@ -35,6 +35,9 @@ services:
- FLASK_PORT=${FLASK_PORT:-5000}
- FLASK_DEBUG=${FLASK_DEBUG:-false}
- MC_TRUST_PROXY=${MC_TRUST_PROXY:-false}
- MC_DEMO=${MC_DEMO:-false}
- MC_DEMO_TRUSTED_NETS=${MC_DEMO_TRUSTED_NETS:-}
- MC_DEMO_UNLOCK_CODE=${MC_DEMO_UNLOCK_CODE:-}
- TZ=${TZ:-UTC}
env_file:
- path: .env
+13
View File
@@ -122,6 +122,16 @@ The `/repeaters` (list) and `/repeaters/manage` (per-repeater tools) panels are
- **Role gating** — the firmware silently drops text CLI from non-admin logins (which would surface as a timeout), so the CLI/Settings/Actions endpoints reject guest sessions with 403 up front instead
- **Actions whitelist** — `advert.zerohop`, `advert` (flood), `clock sync`, `reboot`. `reboot` never replies (the firmware restarts immediately without building one), so a clean send followed by silence is reported as success. Text `erase` is firmware-gated to the USB serial console (`sender_timestamp == 0`), hence no erase in the UI
### Demo mode (MC_DEMO)
`app/demo_guard.py` turns a publicly shared instance read-only. User-facing guide: [demo-mode.md](demo-mode.md).
- **Default deny, not a blacklist** — an `@api_bp.before_request` hook refuses every non-GET on `/api/*` unless the path is on `WRITE_ALLOWLIST` (sending, resending, read markers, and the two unlock endpoints). An endpoint added later is therefore locked without anyone touching this module, which matters because six mutating endpoints already had no frontend caller and would never have made a hand-written blacklist. A short `READ_DENYLIST` covers the GETs that hand out secrets (repeater passwords, backup list/download, capture download, `/api/logs`) since the write guard never sees them
- **Two bypasses of the HTTP layer, both closed separately** — the console is a Socket.IO event (`send_command` on `/console`), not a route, so it is guarded in `handle_send_command` against `CONSOLE_READONLY`; the log viewer is guarded at the `/logs` connect handshake by returning `False`
- **Unlock is stateless** — the cookie holds `HMAC(MC_DEMO_UNLOCK_CODE, "mc-webui-demo-unlock-v1")`, compared with `hmac.compare_digest`. No session, and deliberately not Flask's `session`, whose `SECRET_KEY` is a hardcoded public constant. Changing the code invalidates every issued cookie
- **Trusted networks refuse to match through a proxy** — when a request carries `X-Forwarded-For`/`X-Real-IP`/`Forwarded`/`CF-Connecting-IP` and `MC_TRUST_PROXY` is off, `remote_addr` belongs to the proxy or tunnel rather than the visitor, so it is not matched against `MC_DEMO_TRUSTED_NETS`. Without this, publishing through a LAN-hosted tunnel while trusting the LAN would unlock the instance for every visitor at once. `log_startup_state()` reports the resulting configuration at boot
- **Frontend is advisory** — `demo-lock.js` (loaded from `_head_i18n.html`, so it reaches all eight entry points including the six iframes) disables controls inside `[data-demo-lock]`, removes `[data-demo-lock="hide"]`, re-applies through a `MutationObserver` for JS-rendered buttons, and turns any `403 demo_locked` response into a toast. The flags reach templates through the `inject_globals` context processor and JS through `/api/status`
### Path Analyzer
The `/path-analyzer` panel (standalone iframe page, `path-analyzer.js`) is a read-only analysis view over data the app already collects — no new tables, no background work:
@@ -152,6 +162,7 @@ mc-webui/
│ ├── device_manager.py # Core logic for meshcore communication
│ ├── observer.py # Observer: MQTT packet-capture publishing
│ ├── diagnostics.py # Diagnostic capture (support bundle + upload)
│ ├── demo_guard.py # Demo mode: read-only guard for a public instance
│ ├── contacts_cache.py # Persistent contacts cache (DB-backed)
│ ├── read_status.py # Server-side read status manager (DB-backed)
│ ├── version.py # Git-based version management
@@ -430,6 +441,8 @@ These are top-level routes (not under `/api/`), consumed by Docker's healthcheck
| GET | `/api/updater/status` | Get updater service status |
| POST | `/api/updater/trigger` | Trigger remote update |
| GET | `/api/advertisements` | Get recent advertisements |
| POST | `/api/demo/unlock` | Demo mode: exchange `{code}` for the unlock cookie (403 on a wrong code, after a 1 s delay) |
| POST | `/api/demo/lock` | Demo mode: drop the unlock cookie |
| GET | `/api/console/history` | Get console command history |
| POST | `/api/console/history` | Save console command |
| DELETE | `/api/console/history` | Clear console history |
+174
View File
@@ -0,0 +1,174 @@
# Demo Mode
mc-webui has no login. On a home network that is the right trade: everyone who can reach
the port is someone you already trust. It stops being the right trade the moment you hand
the address to people you have never met — to show the project off, to collect feedback,
or because Google Play wants twelve testers who use the app for a fortnight.
Demo mode is for exactly that instance. Set one variable and everything that reconfigures
the radio, deletes data or reaches this server is refused, while reading and chatting keep
working. Visitors see the whole interface, greyed out where it would bite.
Everything here is opt-in. Skip this document and nothing about your installation changes.
---
## What it stops
The guard is **default-deny**: every write is refused unless it is on a short list of
things a visitor may still do. That is the important property — an endpoint added to
mc-webui next month is locked the day it is written, not the day someone remembers to
lock it.
**Refused while locked:**
| Area | Examples |
|---|---|
| Device configuration | Name, coordinates, advert location policy, path hash mode |
| Radio | Frequency, bandwidth, spreading factor, coding rate, TX power |
| Device actions | Reboot, advert, flood advert |
| The console | Every command that changes anything — `set`, `reboot`, `remove_channel`, `msg`, `login`, … |
| Channels | Create, join, delete, change region scope |
| Contacts | Delete, bulk cleanup, push to device, block, ignore, protect, path edits |
| Repeaters | Login, settings, CLI, reboot, **power off**, stored passwords |
| This server | Update trigger (rebuilds the containers), backups, database vacuum, retention settings |
| External publishing | Observer/MQTT settings and brokers, diagnostics capture and upload |
| Shared preferences | UI language, chat and interface settings, DM retry policy, analyzer and region registries |
| Secrets | Repeater passwords, backup downloads, diagnostic captures, the server log |
**Still works:**
- Reading everything — messages, channels, contacts, the map, the path analyzer
- **Sending messages**, in channels and as DMs, and resending
- Marking messages read
- Theme, layout, FAB placement and notification permission — these live in the browser,
so a visitor changing them affects nobody else
- The console, for read-only commands: `infos`, `status`, `stats`, `bat`, `ver`, `help`,
`contacts`, `contacts_all`, `contact_info`, `pending_contacts`, `channels`, `path`, `get`
Two of those deserve a note. **Sending is allowed on purpose** — a demo nobody can type
into is not much of a demo — but it does mean visitors transmit from your node, under
your node's name. If that is not acceptable, say so and it can be locked too. And
`get_channel` is deliberately *not* in the read-only console list, because it prints a
channel's shared key.
Language is locked because mc-webui stores it server-side: one visitor switching to Polish
switches it for everyone. Theme is not, because that one is per-browser.
---
## Turning it on
Edit `.env` in your mc-webui directory:
```bash
MC_DEMO=true
```
Then rebuild as usual:
```bash
docker compose up -d --build
```
The log says what it decided, once, at startup:
```
Demo mode ON — writes are refused unless the caller is unlocked
Demo mode: no trusted networks configured
Demo mode: unlock code is set
```
Read those three lines after every deploy. A demo instance that quietly failed to lock
looks exactly like one that locked correctly — until a tester finds the difference.
---
## Keeping full access for yourself
With only `MC_DEMO=true`, the instance is locked for everybody, including you. The startup
log warns about it. There are two ways back in, and they work independently — set either,
or both.
### By unlock code — works from anywhere
```bash
MC_DEMO_UNLOCK_CODE=choose-something-long-and-unguessable
```
Open **Settings**, and at the bottom of the panel there is an *Operator access* box. Type
the code once and the browser is remembered for 30 days, on any network. There is a *Lock
again* button in the same place when you want to see the instance as a visitor does.
The cookie stores an HMAC of the code, never the code itself, and it is `HttpOnly`. A
wrong guess costs a one-second delay and is logged with the caller's address. Make the
code long anyway: it is the only thing between a stranger and your radio, and there is no
lockout beyond that delay.
This is the option to use when the instance is published through a tunnel or a proxy —
which is most of the time.
### By network — works with no typing
```bash
MC_DEMO_TRUSTED_NETS=192.168.0.0/16,10.0.0.0/8
```
Comma-separated CIDRs. Anyone whose address falls inside one of them is never restricted,
so from your own LAN the app simply behaves as it always has. IPv4 and IPv6 both work, and
an unparseable entry is skipped with a warning rather than throwing the rest away.
> ### ⚠️ The trap worth reading twice
>
> **If mc-webui is published through a tunnel (cloudflared, ngrok, a VPN box) or a reverse
> proxy, every visitor arrives from that one address.** If the tunnel runs on your LAN —
> and it usually does — that address sits inside the very network you just listed as
> trusted. Taken at face value, a trusted-network list would unlock the instance for the
> entire internet at once.
>
> mc-webui refuses to fall into this. When a request carries a proxy header
> (`X-Forwarded-For`, `X-Real-IP`, `Forwarded`, `CF-Connecting-IP`) and `MC_TRUST_PROXY`
> is off, the address is treated as the proxy's rather than the visitor's, and is **not**
> matched against the trusted list. The startup log warns when your configuration is in
> this shape.
>
> So: use `MC_DEMO_TRUSTED_NETS` when the port is reached **directly** from your LAN. When
> the app is behind a tunnel or a proxy, either set `MC_TRUST_PROXY=true` — only correct
> if the app is reached *exclusively* through that proxy, see
> [HTTPS Setup](https-setup.md) — or skip trusted networks entirely and use the unlock
> code, which does not care how the request arrived.
---
## What a visitor sees
Locked controls are **greyed out, not hidden**. The instance is shared to show mc-webui
off, and a feature nobody can see is a feature nobody is impressed by — so the Settings
panel still lists every tab, the radio fields still show the real values, and the console
still works for the commands that only read.
- A yellow **DEMO** badge sits next to the version in the menu, so it is obvious at a
glance which instance a screenshot or a bug report came from
- A short notice at the top of Settings, and another above the console input, explains
why things are grey
- Clicking a locked control produces a toast rather than a silent no-op
- Advert, flood advert and the system log are removed from the menu and the quick-access
buttons entirely — a greyed-out floating button reads as broken rather than deliberate
---
## Notes
**The frontend is not the guard.** Greying out is a courtesy; the refusal happens on the
server, in front of every `/api` route, on the console socket and on the log socket. A
stale page, a crafted request or a direct `curl` all get the same 403.
**Demo mode is not authentication.** It stops a visitor from changing things. It does not
stop them from reading your messages and contacts, because that is the point of the demo.
If you need the instance genuinely private, put a login in front of it — NPM's *Access
Lists*, described in [HTTPS Setup](https-setup.md), do that in a few clicks, and the two
compose perfectly well.
**Changing the code invalidates old cookies.** The cookie is derived from
`MC_DEMO_UNLOCK_CODE`, so editing it in `.env` and rebuilding logs every unlocked browser
back out — which is also how you revoke access you regret.
+6
View File
@@ -10,6 +10,12 @@ For deep technical notes, see [architecture.md](architecture.md). For the full g
## Unreleased
### Features
- **An instance you can hand to strangers: demo mode.** mc-webui has never had a login, which is the right trade on a home network and the wrong one the moment you share the address — to show the project off, to collect feedback, or because a store wants a dozen testers who use the app for a fortnight. What happens next is predictable: visitors assume the node is theirs and rename it, and the console is right there for anyone curious enough to type into it. Putting `MC_DEMO=true` in `.env` turns the instance read-only for everybody except you. Everything that reconfigures the radio is refused — the name, the coordinates, the frequency and the rest of the radio settings, reboot, advert and flood advert — along with everything that deletes data, everything that reaches other people's repeaters, the update trigger that rebuilds the containers, the settings that publish packets to an external broker, and the handful of things that hand out secrets such as stored repeater passwords, database backups and the server log. The console stays open for the commands that only read, so it is still worth demonstrating, and refuses the rest with a list of what it will accept. What does keep working is the part that makes a demo worth visiting: reading everything, sending messages in channels and as DMs, and changing the theme and layout, which live in your browser and affect nobody else. The refusal happens on the server, in front of every endpoint and on the console's own connection, so a stale page or a crafted request gets the same answer as a click. In the interface the locked controls are greyed out rather than hidden — the point is to show mc-webui off, and a feature nobody can see impresses nobody — with a yellow **DEMO** badge next to the version, a short explanation at the top of Settings and above the console, and a message rather than a silent shrug when something is clicked. Full guide in [demo-mode.md](demo-mode.md).
- **Two ways to keep full access to your own demo.** Locking the instance is only half of it; you still need to administer the thing. `MC_DEMO_TRUSTED_NETS` takes a list of networks — `192.168.0.0/16`, say — whose visitors are never restricted, so from your own LAN the app behaves exactly as it always has, with nothing to type. `MC_DEMO_UNLOCK_CODE` sets a code you enter once under **Settings → Operator access**; the browser is then remembered for thirty days from any network, which is what you want when the instance is published through a tunnel and every visitor looks like it is coming from the same place. A *Lock again* button sits in the same spot for when you want to see the instance the way a visitor does. The cookie holds a derived token rather than the code itself, so changing the code in `.env` logs every unlocked browser out — which is also how you take access back. One warning worth repeating from the guide: if mc-webui is published through a tunnel or a reverse proxy, every visitor arrives from that one address, and if the tunnel runs on your LAN then listing your LAN as trusted would unlock the instance for the whole internet. mc-webui declines to make that mistake — an address that arrives with a proxy header is not matched against the trusted list unless `MC_TRUST_PROXY` is on — and says so in the log at startup, but the safe habit is to use the unlock code whenever the app is not reached directly.
---
## 2.11.0 — 2026-08-20
+338
View File
@@ -0,0 +1,338 @@
"""
Unit tests for demo mode (MC_DEMO) — the read-only guard for a public instance.
What is worth pinning down here is not that the guard says no, but *when* it
says yes: the whole feature is one boolean away from either locking the owner
out of their own radio or leaving it open to everyone. So the tests cover the
three ways in (off, trusted network, unlock code), the default-deny behaviour
that makes a forgotten endpoint safe, and the tunnel case — where every visitor
shares the tunnel's address and matching on it would unlock the instance for
the entire internet at once.
Run: python -m pytest tests/test_demo_guard.py -v
"""
import pytest
from app import demo_guard
from app.config import config
class FakeRequest:
"""The four attributes demo_guard actually reads off a request."""
def __init__(self, method='GET', path='/api/status', remote_addr='203.0.113.9',
headers=None, cookies=None):
self.method = method
self.path = path
self.remote_addr = remote_addr
self.headers = headers or {}
self.cookies = cookies or {}
@pytest.fixture(autouse=True)
def demo_settings():
"""
Every test starts from a locked demo with no way in, and restores the real
config afterwards — these are class attributes on a singleton, so leaking
them would corrupt every test that runs later.
"""
saved = (
config.MC_DEMO,
config.MC_DEMO_TRUSTED_NETS,
config.MC_DEMO_UNLOCK_CODE,
config.MC_TRUST_PROXY,
)
config.MC_DEMO = True
config.MC_DEMO_TRUSTED_NETS = ''
config.MC_DEMO_UNLOCK_CODE = ''
config.MC_TRUST_PROXY = False
yield config
(config.MC_DEMO, config.MC_DEMO_TRUSTED_NETS,
config.MC_DEMO_UNLOCK_CODE, config.MC_TRUST_PROXY) = saved
# ================================================================
# Demo mode off — the guard must be completely inert
# ================================================================
class TestDisabled:
def test_nothing_is_blocked_when_demo_is_off(self, demo_settings):
demo_settings.MC_DEMO = False
req = FakeRequest(method='POST', path='/api/device/config')
assert demo_guard.request_is_blocked(req) is False
assert demo_guard.is_unlocked(req) is True
assert demo_guard.is_demo() is False
def test_console_is_open_when_demo_is_off(self, demo_settings):
demo_settings.MC_DEMO = False
assert demo_guard.console_command_is_blocked('set name Whatever') is False
# ================================================================
# Default deny — the property that makes a forgotten endpoint safe
# ================================================================
class TestWriteGuard:
@pytest.mark.parametrize('path', [
'/api/device/config', # the rename testers keep doing
'/api/device/reboot',
'/api/updater/trigger', # rebuilds the containers
'/api/channels',
'/api/contacts/cleanup',
'/api/db/vacuum',
'/api/repeaters/abc123/action',
'/api/observer/settings',
'/api/diagnostics/start',
'/api/retention-settings', # no button in the UI, still reachable
'/api/sync',
'/api/an/endpoint/invented/tomorrow',
])
def test_writes_are_refused(self, path):
assert demo_guard.request_is_blocked(FakeRequest(method='POST', path=path)) is True
@pytest.mark.parametrize('method', ['POST', 'PUT', 'DELETE', 'PATCH'])
def test_every_write_verb_is_covered(self, method):
req = FakeRequest(method=method, path='/api/channels/3')
assert demo_guard.request_is_blocked(req) is True
@pytest.mark.parametrize('path', [
'/api/messages',
'/api/messages/42/resend',
'/api/dm/messages',
'/api/read_status/mark_read',
'/api/read_status/mark_all_read',
'/api/demo/unlock',
'/api/demo/lock',
])
def test_chatting_and_unlocking_stay_open(self, path):
"""A demo nobody can send a message on is not a demo."""
assert demo_guard.request_is_blocked(FakeRequest(method='POST', path=path)) is False
def test_allowlist_is_anchored(self):
"""
The patterns must match whole paths. Without the anchors,
/api/messages/../device/config style prefixes would slip through.
"""
req = FakeRequest(method='POST', path='/api/messages/bulk-delete')
assert demo_guard.request_is_blocked(req) is True
def test_trailing_slash_does_not_bypass(self):
assert demo_guard.request_is_blocked(
FakeRequest(method='POST', path='/api/device/config/')) is True
class TestReadGuard:
@pytest.mark.parametrize('path', [
'/api/status',
'/api/messages',
'/api/contacts',
'/api/channels',
])
def test_ordinary_reads_pass(self, path):
assert demo_guard.request_is_blocked(FakeRequest(method='GET', path=path)) is False
@pytest.mark.parametrize('path', [
'/api/repeaters/abc123/password', # hands out a stored password
'/api/backup/list',
'/api/backup/download', # the entire database
'/api/diagnostics/captures/cap1/download', # captured message text
'/api/logs',
])
def test_secret_bearing_reads_are_refused(self, path):
assert demo_guard.request_is_blocked(FakeRequest(method='GET', path=path)) is True
def test_head_is_treated_as_a_read(self):
assert demo_guard.request_is_blocked(
FakeRequest(method='HEAD', path='/api/backup/download')) is True
# ================================================================
# Trusted networks — including the tunnel trap
# ================================================================
class TestTrustedNetworks:
def test_address_inside_a_trusted_net_is_unlocked(self, demo_settings):
demo_settings.MC_DEMO_TRUSTED_NETS = '192.168.0.0/16'
req = FakeRequest(method='POST', path='/api/device/config',
remote_addr='192.168.131.42')
assert demo_guard.is_unlocked(req) is True
assert demo_guard.request_is_blocked(req) is False
def test_address_outside_is_still_locked(self, demo_settings):
demo_settings.MC_DEMO_TRUSTED_NETS = '192.168.0.0/16'
req = FakeRequest(method='POST', path='/api/device/config',
remote_addr='203.0.113.9')
assert demo_guard.is_unlocked(req) is False
def test_several_nets_and_junk_entries(self, demo_settings):
"""A typo in one entry must not throw away the others."""
demo_settings.MC_DEMO_TRUSTED_NETS = '10.0.0.0/8, not-a-network, 172.16.0.0/12'
assert demo_guard.ip_is_trusted(FakeRequest(remote_addr='10.1.2.3')) is True
assert demo_guard.ip_is_trusted(FakeRequest(remote_addr='172.20.0.5')) is True
assert demo_guard.ip_is_trusted(FakeRequest(remote_addr='8.8.8.8')) is False
def test_ipv6(self, demo_settings):
demo_settings.MC_DEMO_TRUSTED_NETS = 'fd00::/8'
assert demo_guard.ip_is_trusted(FakeRequest(remote_addr='fd00::1')) is True
def test_empty_list_trusts_nobody(self, demo_settings):
demo_settings.MC_DEMO_TRUSTED_NETS = ''
assert demo_guard.ip_is_trusted(FakeRequest(remote_addr='192.168.1.1')) is False
# --- the trap ---
def test_forwarded_request_is_not_trusted_by_address(self, demo_settings):
"""
Published through a tunnel, every visitor arrives from the tunnel's own
address. If the tunnel runs on the LAN, that address is inside the
trusted net — and trusting it would unlock the instance for the whole
internet. Without MC_TRUST_PROXY the address is therefore not the
client's, and must not be matched.
"""
demo_settings.MC_DEMO_TRUSTED_NETS = '192.168.0.0/16'
demo_settings.MC_TRUST_PROXY = False
req = FakeRequest(method='POST', path='/api/device/config',
remote_addr='192.168.131.5', # the tunnel container
headers={'X-Forwarded-For': '203.0.113.9'})
assert demo_guard.ip_is_trusted(req) is False
assert demo_guard.request_is_blocked(req) is True
@pytest.mark.parametrize('header', [
'X-Forwarded-For', 'X-Real-IP', 'Forwarded', 'CF-Connecting-IP',
])
def test_every_hop_header_disarms_address_matching(self, demo_settings, header):
demo_settings.MC_DEMO_TRUSTED_NETS = '192.168.0.0/16'
req = FakeRequest(remote_addr='192.168.1.5', headers={header: 'anything'})
assert demo_guard.ip_is_trusted(req) is False
def test_with_trust_proxy_on_the_resolved_address_counts(self, demo_settings):
"""
With MC_TRUST_PROXY on, ProxyFix has already replaced remote_addr with the
real client address, so matching it is correct again.
"""
demo_settings.MC_DEMO_TRUSTED_NETS = '192.168.0.0/16'
demo_settings.MC_TRUST_PROXY = True
trusted = FakeRequest(remote_addr='192.168.131.42',
headers={'X-Forwarded-For': '192.168.131.42'})
stranger = FakeRequest(remote_addr='203.0.113.9',
headers={'X-Forwarded-For': '203.0.113.9'})
assert demo_guard.ip_is_trusted(trusted) is True
assert demo_guard.ip_is_trusted(stranger) is False
# ================================================================
# Unlock code
# ================================================================
class TestUnlockCode:
def test_correct_code_accepted_wrong_rejected(self, demo_settings):
demo_settings.MC_DEMO_UNLOCK_CODE = 'correct horse battery'
assert demo_guard.code_is_valid('correct horse battery') is True
assert demo_guard.code_is_valid('correct horse batteryy') is False
assert demo_guard.code_is_valid('') is False
assert demo_guard.code_is_valid(None) is False
def test_no_code_configured_means_no_code_works(self, demo_settings):
"""An empty MC_DEMO_UNLOCK_CODE must not be satisfiable by an empty guess."""
demo_settings.MC_DEMO_UNLOCK_CODE = ''
assert demo_guard.code_is_valid('') is False
assert demo_guard.code_is_valid('anything') is False
assert demo_guard.cookie_is_valid(
FakeRequest(cookies={demo_guard.UNLOCK_COOKIE: ''})) is False
def test_cookie_unlocks(self, demo_settings):
demo_settings.MC_DEMO_UNLOCK_CODE = 'letmein-please'
req = FakeRequest(method='POST', path='/api/device/config',
cookies={demo_guard.UNLOCK_COOKIE: demo_guard.unlock_token()})
assert demo_guard.is_unlocked(req) is True
assert demo_guard.request_is_blocked(req) is False
def test_forged_cookie_does_not(self, demo_settings):
demo_settings.MC_DEMO_UNLOCK_CODE = 'letmein-please'
for value in ('', 'x' * 64, demo_guard.unlock_token()[:-1] + '0'):
req = FakeRequest(cookies={demo_guard.UNLOCK_COOKIE: value})
assert demo_guard.cookie_is_valid(req) is False
def test_token_is_not_the_code(self, demo_settings):
"""The cookie must never carry the code itself — it is sent on every request."""
demo_settings.MC_DEMO_UNLOCK_CODE = 'letmein-please'
assert 'letmein-please' not in demo_guard.unlock_token()
def test_changing_the_code_invalidates_old_cookies(self, demo_settings):
demo_settings.MC_DEMO_UNLOCK_CODE = 'first-code'
old = demo_guard.unlock_token()
demo_settings.MC_DEMO_UNLOCK_CODE = 'second-code'
assert demo_guard.cookie_is_valid(
FakeRequest(cookies={demo_guard.UNLOCK_COOKIE: old})) is False
# ================================================================
# Console
# ================================================================
class TestConsole:
@pytest.mark.parametrize('command', [
'set name Tester', # the rename, via the back door
'set radio 869.525,250,11,5',
'reboot',
'remove_channel 2',
'set_channel 1 name key',
'remove_contact Someone',
'floodadv',
'advert',
'msg Someone hello',
'cmd Repeater reboot',
'login Repeater hunter2',
'time 1700000000',
'get_channel 0', # prints a channel's shared key
'clock sync',
'flush_pending',
'reset_path Someone',
])
def test_mutating_commands_are_refused(self, command):
assert demo_guard.console_command_is_blocked(command) is True
@pytest.mark.parametrize('command', [
'infos', 'status', 'stats', 'bat', 'ver', 'help',
'contacts', 'contacts_all', 'channels',
'get name', 'get radio', 'path Someone', 'contact_info Someone',
])
def test_readonly_commands_still_run(self, command):
"""The console stays worth showing off; it just cannot change anything."""
assert demo_guard.console_command_is_blocked(command) is False
def test_case_and_padding_do_not_bypass(self):
for command in (' SET name Tester', 'SeT name Tester', '\tREBOOT '):
assert demo_guard.console_command_is_blocked(command) is True
def test_empty_command_is_not_blocked(self):
"""The handler answers 'Empty command' on its own; the guard stays out of it."""
assert demo_guard.console_command_is_blocked('') is False
assert demo_guard.console_command_is_blocked(' ') is False
def test_readonly_set_is_a_subset_of_the_real_router(self):
"""
Guard against a typo silently allowing nothing: every command named in
CONSOLE_READONLY must be one the console router actually dispatches, or
it is dead weight that reads as permissive and behaves as unknown.
"""
import io
import re
source = io.open('app/main.py', encoding='utf-8').read()
dispatched = set(re.findall(r"cmd == '([a-z_]+)'", source))
for group in re.findall(r"cmd in \(([^)]*)\)", source):
dispatched.update(re.findall(r"'([a-z_]+)'", group))
missing = demo_guard.CONSOLE_READONLY - dispatched
assert not missing, (
f"CONSOLE_READONLY names commands the router has no branch for: {missing}"
)