8 Commits

Author SHA1 Message Date
Jack Kingsman
8b73bef30b More styling 2026-03-23 18:42:09 -07:00
Jack Kingsman
4b583fe337 Rephrasing and add env vars to docker compose 2026-03-23 18:36:55 -07:00
Jack Kingsman
e6e7267eb1 Fix mobile modal 2026-03-23 18:33:37 -07:00
Jack Kingsman
36eeeae64d Protect against uncheck race condition 2026-03-23 18:27:42 -07:00
Jack Kingsman
7c988ae3d0 Initial bot safety warning pass 2026-03-23 15:16:04 -07:00
Jack Kingsman
1a0c4833d5 Enrich the error text for notification blockage and mention http/s issues 2026-03-23 09:12:17 -07:00
Jack Kingsman
84c500d018 Add clearer warning on frontend fetching invalid backend 2026-03-22 23:32:52 -07:00
Jack Kingsman
1960a16fb0 Add note about CORS + Basic auth 2026-03-22 23:28:33 -07:00
21 changed files with 591 additions and 44 deletions

View File

@@ -192,7 +192,7 @@ $env:MESHCORE_SERIAL_PORT="COM8" # or your COM port
uv run uvicorn app.main:app --host 0.0.0.0 --port 8000
```
If you enable Basic Auth, protect the app with HTTPS. HTTP Basic credentials are not safe on plain HTTP.
If you enable Basic Auth, protect the app with HTTPS. HTTP Basic credentials are not safe on plain HTTP. Also note that the app's permissive CORS policy is a deliberate trusted-network tradeoff, so cross-origin browser JavaScript is not a reliable way to use that Basic Auth gate.
## Where To Go Next

View File

@@ -82,6 +82,21 @@ class FanoutManager:
def __init__(self) -> None:
self._modules: dict[str, tuple[FanoutModule, dict]] = {} # id -> (module, scope)
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:
"""Read enabled fanout_configs and instantiate modules."""
@@ -99,13 +114,14 @@ class FanoutManager:
config_blob = cfg["config"]
scope = cfg["scope"]
# Skip bot modules when bots are disabled server-wide
if config_type == "bot":
from app.config import settings as server_settings
if server_settings.disable_bots:
logger.info("Skipping bot module %s (bots disabled by server config)", config_id)
return
# Skip bot modules when bots are disabled server-wide or until restart.
if config_type == "bot" and self.bots_disabled_effective():
logger.info(
"Skipping bot module %s (bots disabled: %s)",
config_id,
self.get_bots_disabled_source(),
)
return
cls = _MODULE_TYPES.get(config_type)
if cls is None:
@@ -240,6 +256,26 @@ class FanoutManager:
}
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
fanout_manager = FanoutManager()

View File

@@ -139,6 +139,18 @@ def register_frontend_static_routes(app: FastAPI, frontend_dir: Path) -> bool:
@app.get("/{path:path}")
async def serve_frontend(path: str):
"""Serve frontend files, falling back to index.html for SPA routing."""
if path == "api" or path.startswith("api/"):
return JSONResponse(
status_code=404,
content={
"detail": (
"API endpoint not found. If you are seeing this in response to a "
"frontend request, you may be running a newer frontend with an older "
"backend or vice versa. A full update is suggested."
)
},
)
file_path = (frontend_dir / path).resolve()
try:
file_path.relative_to(frontend_dir)

View File

@@ -9,8 +9,8 @@ import string
from fastapi import APIRouter, HTTPException
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.manager import fanout_manager
from app.repository.fanout import FanoutConfigRepository
logger = logging.getLogger(__name__)
@@ -325,6 +325,15 @@ def _enforce_scope(config_type: str, scope: dict) -> dict:
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("")
async def list_fanout_configs() -> list[dict]:
"""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))}",
)
if body.type == "bot" and server_settings.disable_bots:
raise HTTPException(status_code=403, detail="Bot system disabled by server configuration")
if body.type == "bot":
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)
scope = _enforce_scope(body.type, body.scope)
@@ -356,8 +367,6 @@ async def create_fanout_config(body: FanoutConfigCreate) -> dict:
# Start the module if enabled
if cfg["enabled"]:
from app.fanout.manager import fanout_manager
await fanout_manager.reload_config(cfg["id"])
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:
raise HTTPException(status_code=404, detail="Fanout config not found")
if existing["type"] == "bot" and server_settings.disable_bots:
raise HTTPException(status_code=403, detail="Bot system disabled by server configuration")
if existing["type"] == "bot":
disabled_detail = _bot_system_disabled_detail()
if disabled_detail:
raise HTTPException(status_code=403, detail=disabled_detail)
kwargs = {}
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")
# Reload the module to pick up changes
from app.fanout.manager import fanout_manager
await fanout_manager.reload_config(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")
# Stop the module first
from app.fanout.manager import fanout_manager
await fanout_manager.remove_config(config_id)
await FanoutConfigRepository.delete(config_id)
logger.info("Deleted fanout config %s", config_id)
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
from typing import Any
from typing import Any, Literal
from fastapi import APIRouter
from pydantic import BaseModel
@@ -37,6 +37,8 @@ class HealthResponse(BaseModel):
oldest_undecrypted_timestamp: int | None
fanout_statuses: dict[str, dict[str, str]] = {}
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:
@@ -46,6 +48,11 @@ def _clean_optional_str(value: object) -> str | 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:
"""Build the health status payload used by REST endpoint and WebSocket broadcasts."""
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_statuses: dict[str, Any] = {}
bots_disabled_source = "env" if _read_optional_bool_setting("disable_bots") else None
try:
from app.fanout.manager import fanout_manager
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:
pass
@@ -118,7 +129,9 @@ async def build_health_data(radio_connected: bool, connection_info: str | None)
"database_size_mb": db_size_mb,
"oldest_undecrypted_timestamp": oldest_ts,
"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

@@ -18,15 +18,18 @@ services:
environment:
MESHCORE_DATABASE_PATH: data/meshcore.db
# Radio connection -- optional if you map just a single serial device above, as the app will autodetect
# Serial (USB)
# MESHCORE_SERIAL_PORT: /dev/ttyUSB0
# MESHCORE_SERIAL_BAUDRATE: 115200
# TCP
# MESHCORE_TCP_HOST: 192.168.1.100
# MESHCORE_TCP_PORT: 4000
# Security
# MESHCORE_DISABLE_BOTS: "true"
# MESHCORE_BASIC_AUTH_USERNAME: changeme
# MESHCORE_BASIC_AUTH_PASSWORD: changeme
# Logging
# MESHCORE_LOG_LEVEL: INFO
restart: unless-stopped

View File

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

View File

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

View File

@@ -611,7 +611,8 @@ export function CrackerPanel({
pick up messages it couldn't crack, attempting them at one longer length.
<strong> Try word pairs</strong> will also try every combination of two dictionary words
concatenated together (e.g. "hello" + "world" = "#helloworld") after the single-word
dictionary pass; this can substantially increase search time.
dictionary pass; this can substantially increase search time and also result in
false-positives.
<strong> Decrypt historical</strong> will run an async job on any room name it finds to see
if any historically captured packets will decrypt with that key.
<strong> Turbo mode</strong> will push your GPU to the max (target dispatch time of 10s) and

View File

@@ -0,0 +1,181 @@
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]);
useEffect(() => {
if (health?.bots_disabled !== true) {
setBotsDisabledLocally(false);
}
}, [health?.bots_disabled, health?.bots_disabled_source]);
if (!shouldWarn) {
return null;
}
return (
<Dialog open>
<DialogContent
hideCloseButton
className="top-3 w-[calc(100vw-1rem)] max-w-[42rem] translate-y-0 gap-5 overflow-y-auto px-4 py-5 max-h-[calc(100vh-1.5rem)] sm:top-[50%] sm:w-full sm:max-h-[min(90vh,48rem)] sm:translate-y-[-50%] sm:px-6"
onEscapeKeyDown={(event) => event.preventDefault()}
onInteractOutside={(event) => event.preventDefault()}
>
<DialogHeader className="space-y-0 text-left">
<div className="flex items-center gap-3">
<div className="inline-flex h-11 w-11 shrink-0 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 className="leading-tight">
Unprotected bot execution is enabled
</DialogTitle>
</div>
</DialogHeader>
<hr className="border-border" />
<div className="space-y-3 break-words text-sm leading-6 text-muted-foreground">
<DialogDescription>
Bots are enabled, and app-wide Basic Auth is not configured.
</DialogDescription>
<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 className="font-semibold text-foreground">
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, run the server with environment variables to either disable bots
with{' '}
<code className="break-all rounded bg-muted px-1 py-0.5 text-foreground">
MESHCORE_DISABLE_BOTS=true
</code>{' '}
or enable the built-in login with{' '}
<code className="break-all rounded bg-muted px-1 py-0.5 text-foreground">
MESHCORE_BASIC_AUTH_USERNAME
</code>{' '}
/{' '}
<code className="break-all 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>
<div className="space-y-2">
<Button
type="button"
className="h-auto w-full whitespace-normal py-3 text-center"
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>
</div>
<div className="space-y-3 rounded-md border border-input bg-muted/20 p-4">
<label className="flex items-start gap-3">
<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>
<Button
type="button"
className="h-auto w-full whitespace-normal py-3 text-center"
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 && (
<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
cannot be created or modified.
{health.bots_disabled_source === 'until_restart'
? '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>
)}

View File

@@ -29,10 +29,16 @@ const DialogOverlay = React.forwardRef<
));
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
interface DialogContentProps extends React.ComponentPropsWithoutRef<
typeof DialogPrimitive.Content
> {
hideCloseButton?: boolean;
}
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
DialogContentProps
>(({ className, children, hideCloseButton = false, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
@@ -44,10 +50,12 @@ const DialogContent = React.forwardRef<
{...props}
>
{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">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
{!hideCloseButton && (
<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">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
));

View File

@@ -110,6 +110,8 @@ export function useBrowserNotifications() {
const toggleConversationNotifications = useCallback(
async (type: 'channel' | 'contact', id: string, label: string) => {
const blockedDescription =
'Allow notifications in your browser settings, then try again. Some browsers may refuse notifications on non-HTTPS or self-signed HTTPS origins. Check your browser documentation for how to trust an insecure origin and the associated risks before doing so.';
const conversationKey = getConversationNotificationKey(type, id);
if (enabledByConversation[conversationKey]) {
setEnabledByConversation((prev) => {
@@ -131,7 +133,7 @@ export function useBrowserNotifications() {
if (permission === 'denied') {
toast.error('Browser notifications blocked', {
description: 'Allow notifications in your browser settings, then try again.',
description: blockedDescription,
});
return;
}
@@ -159,9 +161,7 @@ export function useBrowserNotifications() {
toast.error('Browser notifications not enabled', {
description:
nextPermission === 'denied'
? 'Permission was denied by the browser.'
: 'Permission request was dismissed.',
nextPermission === 'denied' ? blockedDescription : 'Permission request was dismissed.',
});
},
[enabledByConversation, permission]

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 () => {
renderSection({ health: { ...baseHealth, bots_disabled: true } });
await waitFor(() => {

View File

@@ -0,0 +1,119 @@
import { render, screen, waitFor } 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();
});
it('shows the warning again after temporary bot disable disappears on a later health update', async () => {
const user = userEvent.setup();
const { rerender } = render(<SecurityWarningModal health={baseHealth} />);
await user.click(screen.getByRole('button', { name: 'Disable Bots Until Server Restart' }));
expect(screen.queryByText('Unprotected bot execution is enabled')).not.toBeInTheDocument();
rerender(
<SecurityWarningModal
health={{ ...baseHealth, bots_disabled: true, bots_disabled_source: 'until_restart' }}
/>
);
expect(screen.queryByText('Unprotected bot execution is enabled')).not.toBeInTheDocument();
rerender(<SecurityWarningModal health={baseHealth} />);
await waitFor(() => {
expect(screen.getByText('Unprotected bot execution is enabled')).toBeInTheDocument();
});
});
});

View File

@@ -148,4 +148,25 @@ describe('useBrowserNotifications', () => {
expect(focusSpy).toHaveBeenCalledTimes(1);
expect(notificationInstance.close).toHaveBeenCalledTimes(1);
});
it('shows the browser guidance toast when notifications are blocked', async () => {
Object.assign(window.Notification, {
permission: 'denied',
});
const { result } = renderHook(() => useBrowserNotifications());
await act(async () => {
await result.current.toggleConversationNotifications(
'channel',
incomingChannelMessage.conversation_key,
'#flightless'
);
});
expect(mocks.toast.error).toHaveBeenCalledWith('Browser notifications blocked', {
description:
'Allow notifications in your browser settings, then try again. Some browsers may refuse notifications on non-HTTPS or self-signed HTTPS origins. Check your browser documentation for how to trust an insecure origin and the associated risks before doing so.',
});
});
});

View File

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

View File

@@ -23,6 +23,9 @@ export interface HealthStatus {
radio_connected: boolean;
radio_initializing: boolean;
connection_info: string | null;
bots_disabled?: boolean;
bots_disabled_source?: 'env' | 'until_restart' | null;
basic_auth_enabled?: boolean;
}
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:
- POST /api/fanout with type=bot returns 403
- Health endpoint includes bots_disabled=True
"""
from unittest.mock import MagicMock, patch
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from fastapi import HTTPException
from app.config import Settings
from app.repository.fanout import FanoutConfigRepository
from app.routers.fanout import FanoutConfigCreate, create_fanout_config
from app.routers.health import build_health_data
@@ -33,7 +34,9 @@ class TestDisableBotsFanoutEndpoint:
@pytest.mark.asyncio
async def test_bot_create_returns_403_when_disabled(self, test_db):
"""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:
await create_fanout_config(
FanoutConfigCreate(
@@ -50,7 +53,9 @@ class TestDisableBotsFanoutEndpoint:
@pytest.mark.asyncio
async def test_mqtt_create_allowed_when_bots_disabled(self, test_db):
"""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
result = await create_fanout_config(
FanoutConfigCreate(
@@ -62,13 +67,68 @@ class TestDisableBotsFanoutEndpoint:
)
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:
"""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.settings",
MagicMock(disable_bots=True, basic_auth_enabled=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")
@@ -76,8 +136,39 @@ class TestDisableBotsHealthEndpoint:
@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.settings",
MagicMock(disable_bots=False, basic_auth_enabled=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
@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
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:
await update_fanout_config(
cfg["id"],
@@ -617,7 +619,9 @@ class TestDisableBotsPatchGuard:
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):
result = await update_fanout_config(
cfg["id"],

View File

@@ -86,6 +86,16 @@ def test_valid_dist_serves_static_and_spa_fallback(tmp_path):
assert "index page" in missing_response.text
assert missing_response.headers["cache-control"] == INDEX_CACHE_CONTROL
missing_api_response = client.get("/api/not-a-real-endpoint")
assert missing_api_response.status_code == 404
assert missing_api_response.json() == {
"detail": (
"API endpoint not found. If you are seeing this in response to a frontend "
"request, you may be running a newer frontend with an older backend or vice "
"versa. A full update is suggested."
)
}
asset_response = client.get("/assets/app.js")
assert asset_response.status_code == 200
assert "console.log('ok');" in asset_response.text