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

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

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

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

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

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

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

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

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

168 lines
6.3 KiB
Python

"""
Configuration module - loads settings from environment variables
"""
import os
import logging
from pathlib import Path
from typing import Optional
logger = logging.getLogger(__name__)
class Config:
"""Application configuration from environment variables"""
# MeshCore device configuration
MC_SERIAL_PORT = os.getenv('MC_SERIAL_PORT', '/dev/ttyUSB0')
MC_DEVICE_NAME = os.getenv('MC_DEVICE_NAME', 'MeshCore')
MC_CONFIG_DIR = os.getenv('MC_CONFIG_DIR', '/root/.config/meshcore')
# MC_BRIDGE_URL removed in v2 (direct device communication)
# Archive configuration (v1 — archives move to SQLite in v2)
MC_ARCHIVE_DIR = os.getenv('MC_ARCHIVE_DIR', '/root/.archive/meshcore')
MC_ARCHIVE_ENABLED = os.getenv('MC_ARCHIVE_ENABLED', 'true').lower() == 'true'
MC_ARCHIVE_RETENTION_DAYS = int(os.getenv('MC_ARCHIVE_RETENTION_DAYS', '7'))
# v2: Database
MC_DB_PATH = os.getenv('MC_DB_PATH', '') # empty = auto: {MC_CONFIG_DIR}/mc_{pubkey_prefix}.db
# v2: TCP connection (alternative to serial, e.g. meshcore-proxy)
MC_TCP_HOST = os.getenv('MC_TCP_HOST', '') # empty = use serial
MC_TCP_PORT = int(os.getenv('MC_TCP_PORT', '5555'))
# v2: BLE connection (alternative to serial/TCP, for BLE companion devices)
# Device must be paired and trusted on host before starting (see docs/meshcore_bluetooth_pairing.md)
MC_BLE_ADDRESS = os.getenv('MC_BLE_ADDRESS', '') # BLE MAC address or device name filter
# v2: Backup
MC_BACKUP_ENABLED = os.getenv('MC_BACKUP_ENABLED', 'true').lower() == 'true'
MC_BACKUP_HOUR = int(os.getenv('MC_BACKUP_HOUR', '2'))
MC_BACKUP_RETENTION_DAYS = int(os.getenv('MC_BACKUP_RETENTION_DAYS', '7'))
# v2: Connection
MC_AUTO_RECONNECT = os.getenv('MC_AUTO_RECONNECT', 'true').lower() == 'true'
MC_LOG_LEVEL = os.getenv('MC_LOG_LEVEL', 'INFO')
# Reverse proxy: trust X-Forwarded-* headers (see docs/https-setup.md).
# Only enable when mc-webui is reachable exclusively through a proxy — a client
# 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'))
FLASK_DEBUG = os.getenv('FLASK_DEBUG', 'false').lower() == 'true'
# Derived paths
@property
def msgs_file_path(self) -> Path:
"""Get the full path to the .msgs file"""
return Path(self.MC_CONFIG_DIR) / f"{self.MC_DEVICE_NAME}.msgs"
@property
def archive_dir_path(self) -> Path:
"""Get the full path to archive directory"""
return Path(self.MC_ARCHIVE_DIR)
@property
def db_path(self) -> Path:
"""Get SQLite database path"""
if self.MC_DB_PATH:
return Path(self.MC_DB_PATH)
return Path(self.MC_CONFIG_DIR) / 'mc-webui.db'
@property
def use_ble(self) -> bool:
"""True if BLE transport should be used (highest priority)"""
return bool(self.MC_BLE_ADDRESS)
@property
def use_tcp(self) -> bool:
"""True if TCP transport should be used instead of serial"""
return bool(self.MC_TCP_HOST)
@property
def transport_type(self) -> str:
"""Return active transport type: 'ble', 'tcp', or 'serial'"""
if self.use_ble:
return 'ble'
if self.use_tcp:
return 'tcp'
return 'serial'
def __repr__(self):
if self.use_ble:
transport = f"ble={self.MC_BLE_ADDRESS}"
elif self.use_tcp:
transport = f"tcp={self.MC_TCP_HOST}:{self.MC_TCP_PORT}"
else:
transport = f"serial={self.MC_SERIAL_PORT}"
return (
f"Config(device={self.MC_DEVICE_NAME}, "
f"{transport}, "
f"config_dir={self.MC_CONFIG_DIR})"
)
# Global config instance
config = Config()
class RuntimeConfig:
"""
Runtime configuration that can be updated after startup.
Used for values that are detected/fetched at runtime, like
device name from bridge auto-detection.
"""
_device_name: Optional[str] = None
_device_name_source: str = "config"
@classmethod
def set_device_name(cls, name: str, source: str = "detected"):
"""Set the runtime device name"""
cls._device_name = name
cls._device_name_source = source
logger.info(f"Runtime device name set: {name} (source: {source})")
@classmethod
def get_device_name(cls) -> str:
"""Get device name - prefers runtime value, falls back to config"""
return cls._device_name or config.MC_DEVICE_NAME
@classmethod
def get_device_name_source(cls) -> str:
"""Get the source of the device name (detected/config/fallback)"""
return cls._device_name_source
@classmethod
def get_msgs_file_path(cls) -> Path:
"""Get the full path to the .msgs file using runtime device name"""
return Path(config.MC_CONFIG_DIR) / f"{cls.get_device_name()}.msgs"
# Global runtime config instance
runtime_config = RuntimeConfig()