Files
mc-webui/app/config.py
T
MarekWoandClaude Opus 5 05ec5fa13e feat(https): optional HTTPS front end via Nginx Proxy Manager
Adds an opt-in HTTPS layer without touching anything for existing installs.

The proxy is a service in docker-compose.yml behind the "https" Compose
profile, activated with COMPOSE_PROFILES=https in .env. That keeps the whole
setup inside files git owns while the switch lives in a file it ignores --
important because scripts/update.sh runs `git pull`, so any user edit to a
tracked compose file would break the next update with a merge conflict.
mcupdate needs no change: compose reads COMPOSE_PROFILES from .env itself.

Both services share the project's default network, so the proxy forwards to
http://mc-webui:5000 internally; MC_BIND_ADDRESS lets the plain-HTTP port be
restricted to loopback once HTTPS works, and MC_TRUST_PROXY turns on ProxyFix
so the app sees the real client address and scheme.

Also fixes copying over plain HTTP. navigator.clipboard only exists in a
secure context, and 9 call sites across 5 entry points used it with no
fallback, so copy buttons silently did nothing on a LAN address. They now
share clipboard-utils.js, loaded via _head_i18n.html (the one include every
entry point already has), which falls back to execCommand.

Verified locally against the real stack: self-signed cert uploaded to NPM,
proxy host created, app served over HTTPS with socket.io upgrading to a real
wss:// WebSocket (transport "websocket"), and the clipboard fallback checked
end-to-end by pasting back what it copied over http://.

Two findings from that run are documented in docs/https-setup.md:
NPM 2.15 dropped the default admin@example.com account for a setup wizard,
and its default 443 server now refuses connections without SNI -- which is
every connection made to a bare IP address. docker/npm-default-site.conf is
the opt-in way around that, mounted writable (a :ro mount aborts s6 init and
leaves nginx not listening at all).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 17:58:03 +02:00

148 lines
5.0 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'
# 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()