Add bot disable flow

This commit is contained in:
Jack Kingsman
2026-03-03 15:57:37 -08:00
parent e8538c55ea
commit eb78285b8f
13 changed files with 1226 additions and 66 deletions

View File

@@ -97,7 +97,7 @@ The following are **deliberate design choices**, not bugs. They are documented i
1. **No CORS restrictions**: The backend allows all origins (`allow_origins=["*"]`). This lets users access their radio from any device/origin on their network without configuration hassle.
2. **No authentication or authorization**: There is no login, no API keys, no session management. The app is designed for trusted networks (home LAN, VPN). The README warns users not to expose it to untrusted networks.
3. **Arbitrary bot code execution**: The bot system (`app/bot.py`) executes user-provided Python via `exec()` with full `__builtins__`. This is intentional — bots are a power-user feature for automation. The README explicitly warns that anyone on the network can execute arbitrary code through this.
3. **Arbitrary bot code execution**: The bot system (`app/bot.py`) executes user-provided Python via `exec()` with full `__builtins__`. This is intentional — bots are a power-user feature for automation. The README explicitly warns that anyone on the network can execute arbitrary code through this. Operators can set `MESHCORE_DISABLE_BOTS=true` to completely disable the bot system at startup — this skips all bot execution, returns 403 on bot settings updates, and shows a disabled message in the frontend.
## Intentional Packet Handling Decision
@@ -421,6 +421,7 @@ mc.subscribe(EventType.ACK, handler)
| `MESHCORE_SERIAL_BAUDRATE` | `115200` | Serial baud rate |
| `MESHCORE_LOG_LEVEL` | `INFO` | Logging level (`DEBUG`/`INFO`/`WARNING`/`ERROR`) |
| `MESHCORE_DATABASE_PATH` | `data/meshcore.db` | SQLite database location |
| `MESHCORE_DISABLE_BOTS` | `false` | Disable bot system entirely (blocks execution and config) |
**Note:** Runtime app settings are stored in the database (`app_settings` table), not environment variables. These include `max_radio_contacts`, `auto_decrypt_dm_on_advert`, `sidebar_sort_order`, `advert_interval`, `last_advert_time`, `favorites`, `last_message_times`, `bots`, all MQTT configuration (`mqtt_broker_host`, `mqtt_broker_port`, `mqtt_username`, `mqtt_password`, `mqtt_use_tls`, `mqtt_tls_insecure`, `mqtt_topic_prefix`, `mqtt_publish_messages`, `mqtt_publish_raw_packets`), and community MQTT configuration (`community_mqtt_enabled`, `community_mqtt_iata`, `community_mqtt_broker_host`, `community_mqtt_broker_port`, `community_mqtt_email`). They are configured via `GET/PATCH /api/settings` (and related settings endpoints).

File diff suppressed because it is too large Load Diff

View File

@@ -11,7 +11,7 @@ Backend server + browser interface for MeshCore mesh radio networks. Connect you
* Forward packets to MQTT brokers (private: decrypted messages and/or raw packets; community aggregators like LetsMesh.net: raw packets only)
* Visualize the mesh as a map or node set, view repeater stats, and more!
**Warning:** This app has no auth, and is for trusted environments only. _Do not put this on an untrusted network, or open it to the public._ The bots can execute arbitrary Python code which means anyone on your network can, too. If you need access control, consider using a reverse proxy like Nginx, or extending FastAPI; access control and user management are outside the scope of this app.
**Warning:** This app has no auth, and is for trusted environments only. _Do not put this on an untrusted network, or open it to the public._ The bots can execute arbitrary Python code which means anyone on your network can, too. To completely disable the bot system, start the server with `MESHCORE_DISABLE_BOTS=true` — this prevents all bot execution and blocks bot configuration changes via the API. If you need access control, consider using a reverse proxy like Nginx, or extending FastAPI; access control and user management are outside the scope of this app.
![Screenshot of the application's web interface](app_screenshot.png)
@@ -222,6 +222,7 @@ npm run build # build the frontend
| `MESHCORE_BLE_PIN` | | BLE PIN (required when BLE address is set) |
| `MESHCORE_LOG_LEVEL` | INFO | DEBUG, INFO, WARNING, ERROR |
| `MESHCORE_DATABASE_PATH` | data/meshcore.db | SQLite database path |
| `MESHCORE_DISABLE_BOTS` | false | Disable bot system entirely (blocks execution and config) |
Only one transport may be active at a time. If multiple are set, the server will refuse to start.

View File

@@ -19,6 +19,8 @@ from typing import Any
from fastapi import HTTPException
from app.config import settings as server_settings
logger = logging.getLogger(__name__)
# Limit concurrent bot executions to prevent resource exhaustion
@@ -288,6 +290,9 @@ async def run_bot_for_message(
path: Hex-encoded routing path
is_outgoing: Whether this is our own outgoing message
"""
if server_settings.disable_bots:
return
# Early check if any bots are enabled (will re-check after sleep)
from app.repository import AppSettingsRepository

View File

@@ -16,6 +16,7 @@ class Settings(BaseSettings):
ble_pin: str = ""
log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR"] = "INFO"
database_path: str = "data/meshcore.db"
disable_bots: bool = False
@model_validator(mode="after")
def validate_transport_exclusivity(self) -> "Settings":

View File

@@ -18,6 +18,7 @@ class HealthResponse(BaseModel):
oldest_undecrypted_timestamp: int | None
mqtt_status: str | None = None
community_mqtt_status: str | None = None
bots_disabled: bool = False
async def build_health_data(radio_connected: bool, connection_info: str | None) -> dict:
@@ -67,6 +68,7 @@ async def build_health_data(radio_connected: bool, connection_info: str | None)
"oldest_undecrypted_timestamp": oldest_ts,
"mqtt_status": mqtt_status,
"community_mqtt_status": community_mqtt_status,
"bots_disabled": settings.disable_bots,
}

View File

@@ -6,6 +6,7 @@ from typing import Literal
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel, Field
from app.config import settings as server_settings
from app.models import AppSettings, BotConfig
from app.repository import AppSettingsRepository
@@ -181,6 +182,10 @@ async def update_settings(update: AppSettingsUpdate) -> AppSettings:
kwargs["advert_interval"] = interval
if update.bots is not None:
if server_settings.disable_bots:
raise HTTPException(
status_code=403, detail="Bot system disabled by server configuration"
)
validate_all_bots(update.bots)
logger.info("Updating bots (count=%d)", len(update.bots))
kwargs["bots"] = update.bots

View File

@@ -246,6 +246,7 @@ export function SettingsModal(props: SettingsModalProps) {
{isSectionVisible('bot') && appSettings && (
<SettingsBotSection
appSettings={appSettings}
health={health}
isMobileLayout={isMobileLayout}
onSaveAppSettings={onSaveAppSettings}
className={sectionContentClass}

View File

@@ -4,7 +4,7 @@ import { Button } from '../ui/button';
import { Separator } from '../ui/separator';
import { toast } from '../ui/sonner';
import { handleKeyboardActivate } from '../../utils/a11y';
import type { AppSettings, AppSettingsUpdate, BotConfig } from '../../types';
import type { AppSettings, AppSettingsUpdate, BotConfig, HealthStatus } from '../../types';
const BotCodeEditor = lazy(() =>
import('../BotCodeEditor').then((m) => ({ default: m.BotCodeEditor }))
@@ -50,11 +50,13 @@ const DEFAULT_BOT_CODE = `def bot(
export function SettingsBotSection({
appSettings,
health,
isMobileLayout,
onSaveAppSettings,
className,
}: {
appSettings: AppSettings;
health: HealthStatus | null;
isMobileLayout: boolean;
onSaveAppSettings: (update: AppSettingsUpdate) => Promise<void>;
className?: string;
@@ -139,6 +141,14 @@ export function SettingsBotSection({
setBots(bots.map((b) => (b.id === botId ? { ...b, code: DEFAULT_BOT_CODE } : b)));
};
if (health?.bots_disabled) {
return (
<div className={className}>
<p className="text-sm text-muted-foreground">Bot system disabled by server startup flag.</p>
</div>
);
}
return (
<div className={className}>
<div className="p-3 bg-red-500/10 border border-red-500/30 rounded-md">

View File

@@ -40,6 +40,7 @@ const baseHealth: HealthStatus = {
oldest_undecrypted_timestamp: null,
mqtt_status: null,
community_mqtt_status: null,
bots_disabled: false,
};
const baseSettings: AppSettings = {

View File

@@ -31,6 +31,7 @@ export interface HealthStatus {
oldest_undecrypted_timestamp: number | null;
mqtt_status: string | null;
community_mqtt_status: string | null;
bots_disabled: boolean;
}
export interface MaintenanceResult {

View File

@@ -19,84 +19,50 @@ SCRIPT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
echo -e "${YELLOW}=== RemoteTerm Quality Checks ===${NC}"
echo
# --- Phase 1: Lint + Format (backend ∥ frontend) ---
# --- Phase 1: Lint & Format ---
echo -e "${YELLOW}=== Phase 1: Lint & Format ===${NC}"
(
echo -e "${BLUE}[backend lint]${NC} Running ruff check + format..."
cd "$SCRIPT_DIR"
uv run ruff check app/ tests/ --fix
uv run ruff format app/ tests/
echo -e "${GREEN}[backend lint]${NC} Passed!"
) &
PID_BACKEND_LINT=$!
echo -e "${BLUE}[backend lint]${NC} Running ruff check + format..."
cd "$SCRIPT_DIR"
uv run ruff check app/ tests/ --fix
uv run ruff format app/ tests/
echo -e "${GREEN}[backend lint]${NC} Passed!"
(
echo -e "${BLUE}[frontend lint]${NC} Running eslint + prettier..."
cd "$SCRIPT_DIR/frontend"
npm run lint:fix
npm run format
echo -e "${GREEN}[frontend lint]${NC} Passed!"
) &
PID_FRONTEND_LINT=$!
echo -e "${BLUE}[frontend lint]${NC} Running eslint + prettier..."
cd "$SCRIPT_DIR/frontend"
npm run lint:fix
npm run format
echo -e "${GREEN}[frontend lint]${NC} Passed!"
(
echo -e "${BLUE}[licenses]${NC} Regenerating LICENSES.md (always run)..."
cd "$SCRIPT_DIR"
bash scripts/collect_licenses.sh LICENSES.md
echo -e "${GREEN}[licenses]${NC} LICENSES.md updated"
) &
PID_LICENSES=$!
echo -e "${BLUE}[licenses]${NC} Regenerating LICENSES.md (always run)..."
cd "$SCRIPT_DIR"
bash scripts/collect_licenses.sh LICENSES.md
echo -e "${GREEN}[licenses]${NC} LICENSES.md updated"
FAIL=0
wait $PID_BACKEND_LINT || FAIL=1
wait $PID_FRONTEND_LINT || FAIL=1
wait $PID_LICENSES || FAIL=1
if [ $FAIL -ne 0 ]; then
echo -e "${RED}Phase 1 failed — aborting.${NC}"
exit 1
fi
echo -e "${GREEN}=== Phase 1 complete ===${NC}"
echo
# --- Phase 2: Typecheck + Tests + Build (all parallel) ---
# --- Phase 2: Typecheck, Tests & Build ---
echo -e "${YELLOW}=== Phase 2: Typecheck, Tests & Build ===${NC}"
(
echo -e "${BLUE}[pyright]${NC} Running type check..."
cd "$SCRIPT_DIR"
uv run pyright app/
echo -e "${GREEN}[pyright]${NC} Passed!"
) &
PID_PYRIGHT=$!
echo -e "${BLUE}[pyright]${NC} Running type check..."
cd "$SCRIPT_DIR"
uv run pyright app/
echo -e "${GREEN}[pyright]${NC} Passed!"
(
echo -e "${BLUE}[pytest]${NC} Running backend tests..."
cd "$SCRIPT_DIR"
PYTHONPATH=. uv run pytest tests/ -v
echo -e "${GREEN}[pytest]${NC} Passed!"
) &
PID_PYTEST=$!
echo -e "${BLUE}[pytest]${NC} Running backend tests..."
cd "$SCRIPT_DIR"
PYTHONPATH=. uv run pytest tests/ -v
echo -e "${GREEN}[pytest]${NC} Passed!"
(
echo -e "${BLUE}[frontend]${NC} Running tests + build..."
cd "$SCRIPT_DIR/frontend"
npm run test:run
npm run build
echo -e "${GREEN}[frontend]${NC} Passed!"
) &
PID_FRONTEND=$!
echo -e "${BLUE}[frontend]${NC} Running tests + build..."
cd "$SCRIPT_DIR/frontend"
npm run test:run
npm run build
echo -e "${GREEN}[frontend]${NC} Passed!"
FAIL=0
wait $PID_PYRIGHT || FAIL=1
wait $PID_PYTEST || FAIL=1
wait $PID_FRONTEND || FAIL=1
if [ $FAIL -ne 0 ]; then
echo -e "${RED}Phase 2 failed — aborting.${NC}"
exit 1
fi
echo -e "${GREEN}=== Phase 2 complete ===${NC}"
echo

136
tests/test_disable_bots.py Normal file
View File

@@ -0,0 +1,136 @@
"""Tests for the --disable-bots (MESHCORE_DISABLE_BOTS) startup flag.
Verifies that when disable_bots=True:
- run_bot_for_message() exits immediately without any work
- PATCH /api/settings with bots returns 403
- Health endpoint includes bots_disabled=True
"""
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from fastapi import HTTPException
from app.bot import run_bot_for_message
from app.config import Settings
from app.models import BotConfig
from app.routers.health import build_health_data
from app.routers.settings import AppSettingsUpdate, update_settings
class TestDisableBotsConfig:
"""Test the disable_bots configuration field."""
def test_disable_bots_defaults_to_false(self):
s = Settings(serial_port="", tcp_host="", ble_address="")
assert s.disable_bots is False
def test_disable_bots_can_be_set_true(self):
s = Settings(serial_port="", tcp_host="", ble_address="", disable_bots=True)
assert s.disable_bots is True
class TestDisableBotsBotExecution:
"""Test that run_bot_for_message exits immediately when bots are disabled."""
@pytest.mark.asyncio
async def test_returns_immediately_when_disabled(self):
"""No settings load, no semaphore, no bot execution."""
with patch("app.bot.server_settings", MagicMock(disable_bots=True)):
with patch("app.repository.AppSettingsRepository") as mock_repo:
mock_repo.get = AsyncMock()
await run_bot_for_message(
sender_name="Alice",
sender_key="ab" * 32,
message_text="Hello",
is_dm=True,
channel_key=None,
)
# Should never even load settings
mock_repo.get.assert_not_called()
@pytest.mark.asyncio
async def test_runs_normally_when_not_disabled(self):
"""Bots execute normally when disable_bots is False."""
with patch("app.bot.server_settings", MagicMock(disable_bots=False)):
with patch("app.repository.AppSettingsRepository") as mock_repo:
mock_settings = MagicMock()
mock_settings.bots = [
BotConfig(id="1", name="Echo", enabled=True, code="def bot(**k): return 'echo'")
]
mock_repo.get = AsyncMock(return_value=mock_settings)
with (
patch("app.bot.asyncio.sleep", new_callable=AsyncMock),
patch("app.bot.execute_bot_code", return_value="echo") as mock_exec,
patch("app.bot.process_bot_response", new_callable=AsyncMock),
):
await run_bot_for_message(
sender_name="Alice",
sender_key="ab" * 32,
message_text="Hello",
is_dm=True,
channel_key=None,
)
mock_exec.assert_called_once()
class TestDisableBotsSettingsEndpoint:
"""Test that bot settings updates are rejected when bots are disabled."""
@pytest.mark.asyncio
async def test_bot_update_returns_403_when_disabled(self, test_db):
"""PATCH /api/settings with bots field returns 403."""
with patch("app.routers.settings.server_settings", MagicMock(disable_bots=True)):
with pytest.raises(HTTPException) as exc_info:
await update_settings(
AppSettingsUpdate(
bots=[
BotConfig(id="1", name="Bot", enabled=True, code="def bot(**k): pass")
]
)
)
assert exc_info.value.status_code == 403
assert "disabled" in exc_info.value.detail.lower()
@pytest.mark.asyncio
async def test_non_bot_update_allowed_when_disabled(self, test_db):
"""Other settings can still be updated when bots are disabled."""
with patch("app.routers.settings.server_settings", MagicMock(disable_bots=True)):
result = await update_settings(AppSettingsUpdate(max_radio_contacts=50))
assert result.max_radio_contacts == 50
@pytest.mark.asyncio
async def test_bot_update_allowed_when_not_disabled(self, test_db):
"""Bot updates work normally when disable_bots is False."""
with patch("app.routers.settings.server_settings", MagicMock(disable_bots=False)):
result = await update_settings(
AppSettingsUpdate(
bots=[BotConfig(id="1", name="Bot", enabled=False, code="def bot(**k): pass")]
)
)
assert len(result.bots) == 1
class TestDisableBotsHealthEndpoint:
"""Test that bots_disabled is exposed in health data."""
@pytest.mark.asyncio
async def test_health_includes_bots_disabled_true(self, test_db):
with patch("app.routers.health.settings", MagicMock(disable_bots=True, database_path="x")):
with patch("app.routers.health.os.path.getsize", return_value=0):
data = await build_health_data(True, "TCP: 1.2.3.4:4000")
assert data["bots_disabled"] is True
@pytest.mark.asyncio
async def test_health_includes_bots_disabled_false(self, test_db):
with patch("app.routers.health.settings", MagicMock(disable_bots=False, database_path="x")):
with patch("app.routers.health.os.path.getsize", return_value=0):
data = await build_health_data(True, "TCP: 1.2.3.4:4000")
assert data["bots_disabled"] is False