mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-03-28 17:43:05 +01:00
Initial bot safety warning pass
This commit is contained in:
@@ -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()
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
|
|||||||
@@ -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"),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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'),
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
165
frontend/src/components/SecurityWarningModal.tsx
Normal file
165
frontend/src/components/SecurityWarningModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
));
|
));
|
||||||
|
|||||||
@@ -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(() => {
|
||||||
|
|||||||
98
frontend/src/test/securityWarningModal.test.tsx
Normal file
98
frontend/src/test/securityWarningModal.test.tsx
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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> {
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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"],
|
||||||
|
|||||||
Reference in New Issue
Block a user