mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-03-28 17:43:05 +01:00
Add bot disable flow
This commit is contained in:
@@ -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).
|
||||
|
||||
|
||||
1030
LICENSES.md
1030
LICENSES.md
File diff suppressed because it is too large
Load Diff
@@ -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.
|
||||
|
||||

|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -246,6 +246,7 @@ export function SettingsModal(props: SettingsModalProps) {
|
||||
{isSectionVisible('bot') && appSettings && (
|
||||
<SettingsBotSection
|
||||
appSettings={appSettings}
|
||||
health={health}
|
||||
isMobileLayout={isMobileLayout}
|
||||
onSaveAppSettings={onSaveAppSettings}
|
||||
className={sectionContentClass}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -40,6 +40,7 @@ const baseHealth: HealthStatus = {
|
||||
oldest_undecrypted_timestamp: null,
|
||||
mqtt_status: null,
|
||||
community_mqtt_status: null,
|
||||
bots_disabled: false,
|
||||
};
|
||||
|
||||
const baseSettings: AppSettings = {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
136
tests/test_disable_bots.py
Normal 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
|
||||
Reference in New Issue
Block a user