diff --git a/.env.example b/.env.example index 5e52ecb..7189ae7 100644 --- a/.env.example +++ b/.env.example @@ -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 # ============================================ diff --git a/README.md b/README.md index fb38c79..0ee2921 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/app/config.py b/app/config.py index 1f6ef8b..0b0350d 100644 --- a/app/config.py +++ b/app/config.py @@ -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')) diff --git a/app/demo_guard.py b/app/demo_guard.py new file mode 100644 index 0000000..a973a7b --- /dev/null +++ b/app/demo_guard.py @@ -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" + ) diff --git a/app/main.py b/app/main.py index 4889f03..be09783 100644 --- a/app/main.py +++ b/app/main.py @@ -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") diff --git a/app/routes/api.py b/app/routes/api.py index a97c52a..3ce0c0d 100644 --- a/app/routes/api.py +++ b/app/routes/api.py @@ -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: diff --git a/app/static/css/style.css b/app/static/css/style.css index 9917f36..002e210 100644 --- a/app/static/css/style.css +++ b/app/static/css/style.css @@ -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; +} diff --git a/app/static/js/app.js b/app/static/js/app.js index e223e14..9902388 100644 --- a/app/static/js/app.js +++ b/app/static/js/app.js @@ -2284,7 +2284,7 @@ async function loadDeviceInfo() { html += `