Initial bot safety warning pass

This commit is contained in:
Jack Kingsman
2026-03-23 15:16:04 -07:00
parent 1a0c4833d5
commit 7c988ae3d0
14 changed files with 499 additions and 36 deletions

View File

@@ -82,6 +82,21 @@ class FanoutManager:
def __init__(self) -> None: def __init__(self) -> None:
self._modules: dict[str, tuple[FanoutModule, dict]] = {} # id -> (module, scope) self._modules: dict[str, tuple[FanoutModule, dict]] = {} # id -> (module, scope)
self._restart_locks: dict[str, asyncio.Lock] = {} self._restart_locks: dict[str, asyncio.Lock] = {}
self._bots_disabled_until_restart = False
def get_bots_disabled_source(self) -> str | None:
"""Return why bot modules are unavailable, if at all."""
from app.config import settings as server_settings
if server_settings.disable_bots:
return "env"
if self._bots_disabled_until_restart:
return "until_restart"
return None
def bots_disabled_effective(self) -> bool:
"""Return True when bot modules should be treated as unavailable."""
return self.get_bots_disabled_source() is not None
async def load_from_db(self) -> None: async def load_from_db(self) -> None:
"""Read enabled fanout_configs and instantiate modules.""" """Read enabled fanout_configs and instantiate modules."""
@@ -99,13 +114,14 @@ class FanoutManager:
config_blob = cfg["config"] config_blob = cfg["config"]
scope = cfg["scope"] scope = cfg["scope"]
# Skip bot modules when bots are disabled server-wide # Skip bot modules when bots are disabled server-wide or until restart.
if config_type == "bot": if config_type == "bot" and self.bots_disabled_effective():
from app.config import settings as server_settings logger.info(
"Skipping bot module %s (bots disabled: %s)",
if server_settings.disable_bots: config_id,
logger.info("Skipping bot module %s (bots disabled by server config)", config_id) self.get_bots_disabled_source(),
return )
return
cls = _MODULE_TYPES.get(config_type) cls = _MODULE_TYPES.get(config_type)
if cls is None: if cls is None:
@@ -240,6 +256,26 @@ class FanoutManager:
} }
return result return result
async def disable_bots_until_restart(self) -> str:
"""Stop active bot modules and prevent them from starting again until restart."""
source = self.get_bots_disabled_source()
if source == "env":
return source
self._bots_disabled_until_restart = True
from app.repository.fanout import _configs_cache
bot_ids = [
config_id
for config_id in list(self._modules)
if _configs_cache.get(config_id, {}).get("type") == "bot"
]
for config_id in bot_ids:
await self.remove_config(config_id)
return "until_restart"
# Module-level singleton # Module-level singleton
fanout_manager = FanoutManager() fanout_manager = FanoutManager()

View File

@@ -9,8 +9,8 @@ import string
from fastapi import APIRouter, HTTPException from fastapi import APIRouter, HTTPException
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from app.config import settings as server_settings
from app.fanout.bot_exec import _analyze_bot_signature from app.fanout.bot_exec import _analyze_bot_signature
from app.fanout.manager import fanout_manager
from app.repository.fanout import FanoutConfigRepository from app.repository.fanout import FanoutConfigRepository
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -325,6 +325,15 @@ def _enforce_scope(config_type: str, scope: dict) -> dict:
return {"messages": messages, "raw_packets": raw_packets} return {"messages": messages, "raw_packets": raw_packets}
def _bot_system_disabled_detail() -> str | None:
source = fanout_manager.get_bots_disabled_source()
if source == "env":
return "Bot system disabled by server configuration (MESHCORE_DISABLE_BOTS)"
if source == "until_restart":
return "Bot system disabled until the server restarts"
return None
@router.get("") @router.get("")
async def list_fanout_configs() -> list[dict]: async def list_fanout_configs() -> list[dict]:
"""List all fanout configs.""" """List all fanout configs."""
@@ -340,8 +349,10 @@ async def create_fanout_config(body: FanoutConfigCreate) -> dict:
detail=f"Invalid type '{body.type}'. Must be one of: {', '.join(sorted(_VALID_TYPES))}", detail=f"Invalid type '{body.type}'. Must be one of: {', '.join(sorted(_VALID_TYPES))}",
) )
if body.type == "bot" and server_settings.disable_bots: if body.type == "bot":
raise HTTPException(status_code=403, detail="Bot system disabled by server configuration") disabled_detail = _bot_system_disabled_detail()
if disabled_detail:
raise HTTPException(status_code=403, detail=disabled_detail)
normalized_config = _validate_and_normalize_config(body.type, body.config) normalized_config = _validate_and_normalize_config(body.type, body.config)
scope = _enforce_scope(body.type, body.scope) scope = _enforce_scope(body.type, body.scope)
@@ -356,8 +367,6 @@ async def create_fanout_config(body: FanoutConfigCreate) -> dict:
# Start the module if enabled # Start the module if enabled
if cfg["enabled"]: if cfg["enabled"]:
from app.fanout.manager import fanout_manager
await fanout_manager.reload_config(cfg["id"]) await fanout_manager.reload_config(cfg["id"])
logger.info("Created fanout config %s (type=%s, name=%s)", cfg["id"], body.type, body.name) logger.info("Created fanout config %s (type=%s, name=%s)", cfg["id"], body.type, body.name)
@@ -371,8 +380,10 @@ async def update_fanout_config(config_id: str, body: FanoutConfigUpdate) -> dict
if existing is None: if existing is None:
raise HTTPException(status_code=404, detail="Fanout config not found") raise HTTPException(status_code=404, detail="Fanout config not found")
if existing["type"] == "bot" and server_settings.disable_bots: if existing["type"] == "bot":
raise HTTPException(status_code=403, detail="Bot system disabled by server configuration") disabled_detail = _bot_system_disabled_detail()
if disabled_detail:
raise HTTPException(status_code=403, detail=disabled_detail)
kwargs = {} kwargs = {}
if body.name is not None: if body.name is not None:
@@ -390,8 +401,6 @@ async def update_fanout_config(config_id: str, body: FanoutConfigUpdate) -> dict
raise HTTPException(status_code=404, detail="Fanout config not found") raise HTTPException(status_code=404, detail="Fanout config not found")
# Reload the module to pick up changes # Reload the module to pick up changes
from app.fanout.manager import fanout_manager
await fanout_manager.reload_config(config_id) await fanout_manager.reload_config(config_id)
logger.info("Updated fanout config %s", config_id) logger.info("Updated fanout config %s", config_id)
@@ -406,10 +415,24 @@ async def delete_fanout_config(config_id: str) -> dict:
raise HTTPException(status_code=404, detail="Fanout config not found") raise HTTPException(status_code=404, detail="Fanout config not found")
# Stop the module first # Stop the module first
from app.fanout.manager import fanout_manager
await fanout_manager.remove_config(config_id) await fanout_manager.remove_config(config_id)
await FanoutConfigRepository.delete(config_id) await FanoutConfigRepository.delete(config_id)
logger.info("Deleted fanout config %s", config_id) logger.info("Deleted fanout config %s", config_id)
return {"deleted": True} return {"deleted": True}
@router.post("/bots/disable-until-restart")
async def disable_bots_until_restart() -> dict:
"""Stop active bot modules and prevent them from running again until restart."""
source = await fanout_manager.disable_bots_until_restart()
from app.services.radio_runtime import radio_runtime as radio_manager
from app.websocket import broadcast_health
broadcast_health(radio_manager.is_connected, radio_manager.connection_info)
return {
"status": "ok",
"bots_disabled": True,
"bots_disabled_source": source,
}

View File

@@ -1,5 +1,5 @@
import os import os
from typing import Any from typing import Any, Literal
from fastapi import APIRouter from fastapi import APIRouter
from pydantic import BaseModel from pydantic import BaseModel
@@ -37,6 +37,8 @@ class HealthResponse(BaseModel):
oldest_undecrypted_timestamp: int | None oldest_undecrypted_timestamp: int | None
fanout_statuses: dict[str, dict[str, str]] = {} fanout_statuses: dict[str, dict[str, str]] = {}
bots_disabled: bool = False bots_disabled: bool = False
bots_disabled_source: Literal["env", "until_restart"] | None = None
basic_auth_enabled: bool = False
def _clean_optional_str(value: object) -> str | None: def _clean_optional_str(value: object) -> str | None:
@@ -46,6 +48,11 @@ def _clean_optional_str(value: object) -> str | None:
return cleaned or None return cleaned or None
def _read_optional_bool_setting(name: str) -> bool:
value = getattr(settings, name, False)
return value if isinstance(value, bool) else False
async def build_health_data(radio_connected: bool, connection_info: str | None) -> dict: async def build_health_data(radio_connected: bool, connection_info: str | None) -> dict:
"""Build the health status payload used by REST endpoint and WebSocket broadcasts.""" """Build the health status payload used by REST endpoint and WebSocket broadcasts."""
app_build_info = get_app_build_info() app_build_info = get_app_build_info()
@@ -64,10 +71,14 @@ async def build_health_data(radio_connected: bool, connection_info: str | None)
# Fanout module statuses # Fanout module statuses
fanout_statuses: dict[str, Any] = {} fanout_statuses: dict[str, Any] = {}
bots_disabled_source = "env" if _read_optional_bool_setting("disable_bots") else None
try: try:
from app.fanout.manager import fanout_manager from app.fanout.manager import fanout_manager
fanout_statuses = fanout_manager.get_statuses() fanout_statuses = fanout_manager.get_statuses()
manager_bots_disabled_source = fanout_manager.get_bots_disabled_source()
if manager_bots_disabled_source is not None:
bots_disabled_source = manager_bots_disabled_source
except Exception: except Exception:
pass pass
@@ -118,7 +129,9 @@ async def build_health_data(radio_connected: bool, connection_info: str | None)
"database_size_mb": db_size_mb, "database_size_mb": db_size_mb,
"oldest_undecrypted_timestamp": oldest_ts, "oldest_undecrypted_timestamp": oldest_ts,
"fanout_statuses": fanout_statuses, "fanout_statuses": fanout_statuses,
"bots_disabled": settings.disable_bots, "bots_disabled": bots_disabled_source is not None,
"bots_disabled_source": bots_disabled_source,
"basic_auth_enabled": _read_optional_bool_setting("basic_auth_enabled"),
} }

View File

@@ -343,6 +343,14 @@ export const api = {
fetchJson<{ deleted: boolean }>(`/fanout/${id}`, { fetchJson<{ deleted: boolean }>(`/fanout/${id}`, {
method: 'DELETE', method: 'DELETE',
}), }),
disableBotsUntilRestart: () =>
fetchJson<{
status: string;
bots_disabled: boolean;
bots_disabled_source: 'env' | 'until_restart';
}>('/fanout/bots/disable-until-restart', {
method: 'POST',
}),
// Statistics // Statistics
getStatistics: () => fetchJson<StatisticsResponse>('/statistics'), getStatistics: () => fetchJson<StatisticsResponse>('/statistics'),

View File

@@ -6,6 +6,7 @@ import { ConversationPane } from './ConversationPane';
import { NewMessageModal } from './NewMessageModal'; import { NewMessageModal } from './NewMessageModal';
import { ContactInfoPane } from './ContactInfoPane'; import { ContactInfoPane } from './ContactInfoPane';
import { ChannelInfoPane } from './ChannelInfoPane'; import { ChannelInfoPane } from './ChannelInfoPane';
import { SecurityWarningModal } from './SecurityWarningModal';
import { Toaster } from './ui/sonner'; import { Toaster } from './ui/sonner';
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from './ui/sheet'; import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from './ui/sheet';
import { import {
@@ -289,6 +290,7 @@ export function AppShell({
}} }}
/> />
<SecurityWarningModal health={statusProps.health} />
<ContactInfoPane {...contactInfoPaneProps} /> <ContactInfoPane {...contactInfoPaneProps} />
<ChannelInfoPane {...channelInfoPaneProps} /> <ChannelInfoPane {...channelInfoPaneProps} />
<Toaster position="top-right" /> <Toaster position="top-right" />

View File

@@ -0,0 +1,165 @@
import { useEffect, useState } from 'react';
import { AlertTriangle } from 'lucide-react';
import { api } from '../api';
import type { HealthStatus } from '../types';
import { Button } from './ui/button';
import { Checkbox } from './ui/checkbox';
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from './ui/dialog';
import { toast } from './ui/sonner';
const STORAGE_KEY = 'meshcore_security_warning_acknowledged';
function readAcknowledgedState(): boolean {
if (typeof window === 'undefined') {
return false;
}
try {
return window.localStorage.getItem(STORAGE_KEY) === 'true';
} catch {
return false;
}
}
function writeAcknowledgedState(): void {
if (typeof window === 'undefined') {
return;
}
try {
window.localStorage.setItem(STORAGE_KEY, 'true');
} catch {
// Best effort only; the warning will continue to show if localStorage is unavailable.
}
}
interface SecurityWarningModalProps {
health: HealthStatus | null;
}
export function SecurityWarningModal({ health }: SecurityWarningModalProps) {
const [acknowledged, setAcknowledged] = useState(readAcknowledgedState);
const [confirmedRisk, setConfirmedRisk] = useState(false);
const [disablingBots, setDisablingBots] = useState(false);
const [botsDisabledLocally, setBotsDisabledLocally] = useState(false);
const shouldWarn =
health !== null &&
health.bots_disabled !== true &&
health.basic_auth_enabled !== true &&
!botsDisabledLocally &&
!acknowledged;
useEffect(() => {
if (!shouldWarn) {
setConfirmedRisk(false);
}
}, [shouldWarn]);
if (!shouldWarn) {
return null;
}
return (
<Dialog open>
<DialogContent
hideCloseButton
className="max-w-[min(92vw,42rem)] gap-5"
onEscapeKeyDown={(event) => event.preventDefault()}
onInteractOutside={(event) => event.preventDefault()}
>
<DialogHeader className="space-y-3 text-left">
<div className="inline-flex h-11 w-11 items-center justify-center rounded-full border border-destructive/30 bg-destructive/10 text-destructive">
<AlertTriangle className="h-5 w-5" aria-hidden="true" />
</div>
<DialogTitle>Unprotected bot execution is enabled</DialogTitle>
<DialogDescription>
Bots are enabled, and app-wide Basic Auth is not configured.
</DialogDescription>
</DialogHeader>
<div className="space-y-3 text-sm leading-6 text-muted-foreground">
<p>
Without one of those protections, or another access-control layer in front of
RemoteTerm, anyone on your local network who can reach this app can run Python code on
the computer hosting this instance.
</p>
<p>
This is only safe on protected or isolated networks with appropriate access control. If
your network is untrusted or later compromised, this setup may expose the host system to
arbitrary code execution.
</p>
<p>
To reduce that risk, either disable bots with{' '}
<code className="rounded bg-muted px-1 py-0.5 text-foreground">
MESHCORE_DISABLE_BOTS=true
</code>{' '}
or enable the built-in login with{' '}
<code className="rounded bg-muted px-1 py-0.5 text-foreground">
MESHCORE_BASIC_AUTH_USERNAME
</code>{' '}
and{' '}
<code className="rounded bg-muted px-1 py-0.5 text-foreground">
MESHCORE_BASIC_AUTH_PASSWORD
</code>
. Another external auth or access-control system is also acceptable.
</p>
<p>
If you just want a temporary safety measure while you learn the system, you can use the
button below to disable bots until the server restarts. That is only a temporary guard;
permanent protection through Basic Auth or env-based bot disablement is still
encouraged.
</p>
</div>
<label className="flex items-start gap-3 rounded-md border border-input bg-muted/20 p-4">
<Checkbox
checked={confirmedRisk}
onCheckedChange={(checked) => setConfirmedRisk(checked === true)}
aria-label="Acknowledge bot security risk"
className="mt-0.5"
/>
<span className="text-sm leading-6 text-foreground">
I understand that continuing with my existing security setup may put me at risk on
untrusted networks or if my home network is compromised.
</span>
</label>
<div className="space-y-2">
<Button
type="button"
className="w-full"
disabled={disablingBots}
onClick={async () => {
setDisablingBots(true);
try {
await api.disableBotsUntilRestart();
setBotsDisabledLocally(true);
toast.success('Bots disabled until restart');
} catch (err) {
toast.error('Failed to disable bots', {
description: err instanceof Error ? err.message : undefined,
});
} finally {
setDisablingBots(false);
}
}}
>
{disablingBots ? 'Disabling Bots...' : 'Disable Bots Until Server Restart'}
</Button>
<Button
type="button"
className="w-full"
variant="outline"
disabled={!confirmedRisk || disablingBots}
onClick={() => {
writeAcknowledgedState();
setAcknowledged(true);
}}
>
Do Not Warn Me On This Device Again
</Button>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -1817,8 +1817,9 @@ export function SettingsFanoutSection({
{health?.bots_disabled && ( {health?.bots_disabled && (
<div className="rounded-md border border-destructive/50 bg-destructive/10 px-4 py-3 text-sm text-destructive"> <div className="rounded-md border border-destructive/50 bg-destructive/10 px-4 py-3 text-sm text-destructive">
Bot system is disabled by server configuration (MESHCORE_DISABLE_BOTS). Bot integrations {health.bots_disabled_source === 'until_restart'
cannot be created or modified. ? 'Bot system is disabled until the server restarts. Bot integrations cannot run, be created, or be modified right now.'
: 'Bot system is disabled by server configuration (MESHCORE_DISABLE_BOTS). Bot integrations cannot run, be created, or be modified.'}
</div> </div>
)} )}

View File

@@ -29,10 +29,16 @@ const DialogOverlay = React.forwardRef<
)); ));
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName; DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
interface DialogContentProps extends React.ComponentPropsWithoutRef<
typeof DialogPrimitive.Content
> {
hideCloseButton?: boolean;
}
const DialogContent = React.forwardRef< const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>, React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> DialogContentProps
>(({ className, children, ...props }, ref) => ( >(({ className, children, hideCloseButton = false, ...props }, ref) => (
<DialogPortal> <DialogPortal>
<DialogOverlay /> <DialogOverlay />
<DialogPrimitive.Content <DialogPrimitive.Content
@@ -44,10 +50,12 @@ const DialogContent = React.forwardRef<
{...props} {...props}
> >
{children} {children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground"> {!hideCloseButton && (
<X className="h-4 w-4" /> <DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<span className="sr-only">Close</span> <X className="h-4 w-4" />
</DialogPrimitive.Close> <span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content> </DialogPrimitive.Content>
</DialogPortal> </DialogPortal>
)); ));

View File

@@ -114,6 +114,15 @@ describe('SettingsFanoutSection', () => {
}); });
}); });
it('shows restart-scoped bots disabled messaging when disabled until restart', async () => {
renderSection({
health: { ...baseHealth, bots_disabled: true, bots_disabled_source: 'until_restart' },
});
await waitFor(() => {
expect(screen.getByText(/disabled until the server restarts/i)).toBeInTheDocument();
});
});
it('hides bot option from add integration menu when bots_disabled', async () => { it('hides bot option from add integration menu when bots_disabled', async () => {
renderSection({ health: { ...baseHealth, bots_disabled: true } }); renderSection({ health: { ...baseHealth, bots_disabled: true } });
await waitFor(() => { await waitFor(() => {

View File

@@ -0,0 +1,98 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { beforeEach, describe, expect, it, vi } from 'vitest';
const mocks = vi.hoisted(() => ({
disableBotsUntilRestart: vi.fn(),
toast: {
success: vi.fn(),
error: vi.fn(),
},
}));
vi.mock('../api', () => ({
api: {
disableBotsUntilRestart: mocks.disableBotsUntilRestart,
},
}));
vi.mock('../components/ui/sonner', () => ({
toast: mocks.toast,
}));
import { SecurityWarningModal } from '../components/SecurityWarningModal';
import type { HealthStatus } from '../types';
const baseHealth: HealthStatus = {
status: 'degraded',
radio_connected: false,
radio_initializing: false,
connection_info: null,
database_size_mb: 1.2,
oldest_undecrypted_timestamp: null,
fanout_statuses: {},
bots_disabled: false,
basic_auth_enabled: false,
};
describe('SecurityWarningModal', () => {
beforeEach(() => {
vi.clearAllMocks();
window.localStorage.clear();
mocks.disableBotsUntilRestart.mockResolvedValue({
status: 'ok',
bots_disabled: true,
bots_disabled_source: 'until_restart',
});
});
it('shows the warning when bots are enabled and basic auth is off', () => {
render(<SecurityWarningModal health={baseHealth} />);
expect(screen.getByText('Unprotected bot execution is enabled')).toBeInTheDocument();
expect(
screen.getByRole('button', { name: 'Do Not Warn Me On This Device Again' })
).toBeDisabled();
});
it('does not show when bots are disabled', () => {
render(<SecurityWarningModal health={{ ...baseHealth, bots_disabled: true }} />);
expect(screen.queryByText('Unprotected bot execution is enabled')).not.toBeInTheDocument();
});
it('does not show when basic auth is enabled', () => {
render(<SecurityWarningModal health={{ ...baseHealth, basic_auth_enabled: true }} />);
expect(screen.queryByText('Unprotected bot execution is enabled')).not.toBeInTheDocument();
});
it('persists dismissal only after the checkbox is acknowledged', async () => {
const user = userEvent.setup();
render(<SecurityWarningModal health={baseHealth} />);
const dismissButton = screen.getByRole('button', {
name: 'Do Not Warn Me On This Device Again',
});
await user.click(screen.getByLabelText('Acknowledge bot security risk'));
expect(dismissButton).toBeEnabled();
await user.click(dismissButton);
expect(window.localStorage.getItem('meshcore_security_warning_acknowledged')).toBe('true');
expect(screen.queryByText('Unprotected bot execution is enabled')).not.toBeInTheDocument();
});
it('disables bots until restart from the warning modal', async () => {
const user = userEvent.setup();
render(<SecurityWarningModal health={baseHealth} />);
await user.click(screen.getByRole('button', { name: 'Disable Bots Until Server Restart' }));
expect(mocks.disableBotsUntilRestart).toHaveBeenCalledTimes(1);
expect(mocks.toast.success).toHaveBeenCalledWith('Bots disabled until restart');
expect(screen.queryByText('Unprotected bot execution is enabled')).not.toBeInTheDocument();
});
});

View File

@@ -78,6 +78,8 @@ export interface HealthStatus {
oldest_undecrypted_timestamp: number | null; oldest_undecrypted_timestamp: number | null;
fanout_statuses: Record<string, FanoutStatusEntry>; fanout_statuses: Record<string, FanoutStatusEntry>;
bots_disabled: boolean; bots_disabled: boolean;
bots_disabled_source?: 'env' | 'until_restart' | null;
basic_auth_enabled?: boolean;
} }
export interface FanoutConfig { export interface FanoutConfig {

View File

@@ -23,6 +23,9 @@ export interface HealthStatus {
radio_connected: boolean; radio_connected: boolean;
radio_initializing: boolean; radio_initializing: boolean;
connection_info: string | null; connection_info: string | null;
bots_disabled?: boolean;
bots_disabled_source?: 'env' | 'until_restart' | null;
basic_auth_enabled?: boolean;
} }
export function getHealth(): Promise<HealthStatus> { export function getHealth(): Promise<HealthStatus> {

View File

@@ -1,16 +1,17 @@
"""Tests for the --disable-bots (MESHCORE_DISABLE_BOTS) startup flag. """Tests for bot-disable enforcement.
Verifies that when disable_bots=True: Verifies that when disable_bots=True:
- POST /api/fanout with type=bot returns 403 - POST /api/fanout with type=bot returns 403
- Health endpoint includes bots_disabled=True - Health endpoint includes bots_disabled=True
""" """
from unittest.mock import MagicMock, patch from unittest.mock import AsyncMock, MagicMock, patch
import pytest import pytest
from fastapi import HTTPException from fastapi import HTTPException
from app.config import Settings from app.config import Settings
from app.repository.fanout import FanoutConfigRepository
from app.routers.fanout import FanoutConfigCreate, create_fanout_config from app.routers.fanout import FanoutConfigCreate, create_fanout_config
from app.routers.health import build_health_data from app.routers.health import build_health_data
@@ -33,7 +34,9 @@ class TestDisableBotsFanoutEndpoint:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_bot_create_returns_403_when_disabled(self, test_db): async def test_bot_create_returns_403_when_disabled(self, test_db):
"""POST /api/fanout with type=bot returns 403.""" """POST /api/fanout with type=bot returns 403."""
with patch("app.routers.fanout.server_settings", MagicMock(disable_bots=True)): with patch(
"app.routers.fanout.fanout_manager.get_bots_disabled_source", return_value="env"
):
with pytest.raises(HTTPException) as exc_info: with pytest.raises(HTTPException) as exc_info:
await create_fanout_config( await create_fanout_config(
FanoutConfigCreate( FanoutConfigCreate(
@@ -50,7 +53,9 @@ class TestDisableBotsFanoutEndpoint:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_mqtt_create_allowed_when_bots_disabled(self, test_db): async def test_mqtt_create_allowed_when_bots_disabled(self, test_db):
"""Non-bot fanout configs can still be created when bots are disabled.""" """Non-bot fanout configs can still be created when bots are disabled."""
with patch("app.routers.fanout.server_settings", MagicMock(disable_bots=True)): with patch(
"app.routers.fanout.fanout_manager.get_bots_disabled_source", return_value="env"
):
# Create as disabled so fanout_manager.reload_config is not called # Create as disabled so fanout_manager.reload_config is not called
result = await create_fanout_config( result = await create_fanout_config(
FanoutConfigCreate( FanoutConfigCreate(
@@ -62,13 +67,68 @@ class TestDisableBotsFanoutEndpoint:
) )
assert result["type"] == "mqtt_private" assert result["type"] == "mqtt_private"
@pytest.mark.asyncio
async def test_bot_create_returns_403_when_disabled_until_restart(self, test_db):
with patch(
"app.routers.fanout.fanout_manager.get_bots_disabled_source",
return_value="until_restart",
):
with pytest.raises(HTTPException) as exc_info:
await create_fanout_config(
FanoutConfigCreate(
type="bot",
name="Test Bot",
config={"code": "def bot(**k): pass"},
enabled=False,
)
)
assert exc_info.value.status_code == 403
assert "until the server restarts" in exc_info.value.detail
@pytest.mark.asyncio
async def test_disable_bots_until_restart_endpoint(self, test_db):
from app.routers.fanout import disable_bots_until_restart
await FanoutConfigRepository.create(
config_type="bot",
name="Test Bot",
config={"code": "def bot(**k): pass"},
scope={"messages": "all", "raw_packets": "none"},
enabled=True,
)
with (
patch(
"app.routers.fanout.fanout_manager.disable_bots_until_restart",
new=AsyncMock(return_value="until_restart"),
) as mock_disable,
patch("app.websocket.broadcast_health") as mock_broadcast_health,
patch("app.services.radio_runtime.radio_runtime") as mock_radio_runtime,
):
mock_radio_runtime.is_connected = True
mock_radio_runtime.connection_info = "TCP: 1.2.3.4:4000"
result = await disable_bots_until_restart()
mock_disable.assert_awaited_once()
mock_broadcast_health.assert_called_once_with(True, "TCP: 1.2.3.4:4000")
assert result == {
"status": "ok",
"bots_disabled": True,
"bots_disabled_source": "until_restart",
}
class TestDisableBotsHealthEndpoint: class TestDisableBotsHealthEndpoint:
"""Test that bots_disabled is exposed in health data.""" """Test that bots_disabled is exposed in health data."""
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_health_includes_bots_disabled_true(self, test_db): 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.settings",
MagicMock(disable_bots=True, basic_auth_enabled=False, database_path="x"),
):
with patch("app.routers.health.os.path.getsize", return_value=0): with patch("app.routers.health.os.path.getsize", return_value=0):
data = await build_health_data(True, "TCP: 1.2.3.4:4000") data = await build_health_data(True, "TCP: 1.2.3.4:4000")
@@ -76,8 +136,39 @@ class TestDisableBotsHealthEndpoint:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_health_includes_bots_disabled_false(self, test_db): 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.settings",
MagicMock(disable_bots=False, basic_auth_enabled=False, database_path="x"),
):
with patch("app.routers.health.os.path.getsize", return_value=0): with patch("app.routers.health.os.path.getsize", return_value=0):
data = await build_health_data(True, "TCP: 1.2.3.4:4000") data = await build_health_data(True, "TCP: 1.2.3.4:4000")
assert data["bots_disabled"] is False assert data["bots_disabled"] is False
@pytest.mark.asyncio
async def test_health_includes_basic_auth_enabled(self, test_db):
with patch(
"app.routers.health.settings",
MagicMock(disable_bots=False, basic_auth_enabled=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["basic_auth_enabled"] is True
@pytest.mark.asyncio
async def test_health_includes_runtime_bot_disable_source(self, test_db):
with (
patch(
"app.routers.health.settings",
MagicMock(disable_bots=False, basic_auth_enabled=False, database_path="x"),
),
patch("app.routers.health.os.path.getsize", return_value=0),
patch("app.fanout.manager.fanout_manager") as mock_fm,
):
mock_fm.get_statuses.return_value = {}
mock_fm.get_bots_disabled_source.return_value = "until_restart"
data = await build_health_data(True, "TCP: 1.2.3.4:4000")
assert data["bots_disabled"] is True
assert data["bots_disabled_source"] == "until_restart"

View File

@@ -593,7 +593,9 @@ class TestDisableBotsPatchGuard:
) )
# Now try to update with bots disabled # Now try to update with bots disabled
with patch("app.routers.fanout.server_settings", MagicMock(disable_bots=True)): with patch(
"app.routers.fanout.fanout_manager.get_bots_disabled_source", return_value="env"
):
with pytest.raises(HTTPException) as exc_info: with pytest.raises(HTTPException) as exc_info:
await update_fanout_config( await update_fanout_config(
cfg["id"], cfg["id"],
@@ -617,7 +619,9 @@ class TestDisableBotsPatchGuard:
enabled=False, enabled=False,
) )
with patch("app.routers.fanout.server_settings", MagicMock(disable_bots=True)): with patch(
"app.routers.fanout.fanout_manager.get_bots_disabled_source", return_value="env"
):
with patch("app.fanout.manager.fanout_manager.reload_config", new_callable=AsyncMock): with patch("app.fanout.manager.fanout_manager.reload_config", new_callable=AsyncMock):
result = await update_fanout_config( result = await update_fanout_config(
cfg["id"], cfg["id"],