Compare commits

..

7 Commits

Author SHA1 Message Date
Jack Kingsman 7528e4121f Add config export 2026-04-20 19:55:25 -07:00
Jack Kingsman 291bd85c78 Better env var/config knob exposure 2026-04-20 16:43:43 -07:00
Jack Kingsman 4bc87b4a0f Add debug radio details to radio pane 2026-04-20 16:10:24 -07:00
Jack Kingsman 6d0434d59e Add more intense logging on errors 2026-04-20 16:10:24 -07:00
Jack Kingsman f22184c166 Update README.md to be more clear about core purpose 2026-04-19 23:40:23 -07:00
Jack Kingsman d10de8abf7 CI/CD improvements for codeql 2026-04-19 23:33:45 -07:00
Jack Kingsman 5f78294cd1 Longer linger for web push mobile error 2026-04-19 23:04:36 -07:00
18 changed files with 567 additions and 13 deletions
+10
View File
@@ -0,0 +1,10 @@
name: "RemoteTerm CodeQL config"
# Exclude rules that flag intentional design decisions:
# - AES-ECB is required by the MeshCore radio protocol wire format
# - Repeater/room passwords are not meaningfully sensitive secrets
query-filters:
- exclude:
id: py/weak-cryptographic-algorithm
- exclude:
id: js/clear-text-storage-of-sensitive-data
+3
View File
@@ -4,6 +4,9 @@ on:
push:
pull_request:
permissions:
contents: read
jobs:
backend-checks:
runs-on: ubuntu-latest
+35
View File
@@ -0,0 +1,35 @@
name: CodeQL
on:
push:
branches: [main]
pull_request:
branches: [main]
schedule:
- cron: "0 6 * * 1"
permissions:
contents: read
security-events: write
jobs:
analyze:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
language: [javascript-typescript, python]
steps:
- uses: actions/checkout@v4
- name: Initialize CodeQL
uses: github/codeql-action/init@v3
with:
languages: ${{ matrix.language }}
config-file: .github/codeql/codeql-config.yml
- name: Autobuild
uses: github/codeql-action/autobuild@v3
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v3
+3
View File
@@ -25,6 +25,9 @@ concurrency:
group: publish-aur
cancel-in-progress: false
permissions:
contents: read
jobs:
publish-aur:
runs-on: ubuntu-latest
+5 -3
View File
@@ -1,6 +1,8 @@
# RemoteTerm for MeshCore
Backend server + browser interface for MeshCore mesh radio networks. Connect your radio over Serial, TCP, or BLE, and then you can:
Backend server + browser interface for MeshCore mesh radio networks, providing a rich, web-based power-user management and messaging system through a companion radio.
Connect your radio over Serial, TCP, or BLE, and then you can:
* Send and receive DMs and channel messages
* Cache all received packets, decrypting as you gain keys
@@ -8,8 +10,8 @@ Backend server + browser interface for MeshCore mesh radio networks. Connect you
* Monitor unlimited contacts and channels (radio limits don't apply -- packets are decrypted server-side)
* Access your radio remotely over your network or VPN
* Search for hashtag channel names for channels you don't have keys for yet
* Forward packets to MQTT, LetsMesh, MeshRank, SQS, Apprise, etc.
* Use the more recent 1.14 firmwares which support multibyte pathing
* Forward packets, messages, and automatic repeater telemetry to MQTT, Home Assistant, LetsMesh, MeshRank, SQS, Apprise, etc.
* Use the more recent 1.14+ firmwares which support multibyte pathing
* Visualize the mesh as a map or node set, view repeater stats, and more!
For advanced setup and troubleshooting see [README_ADVANCED.md](README_ADVANCED.md). If you plan to contribute, read [CONTRIBUTING.md](CONTRIBUTING.md).
+1
View File
@@ -26,6 +26,7 @@ class Settings(BaseSettings):
default=False,
validation_alias="__CLOWNTOWN_DO_CLOCK_WRAPAROUND",
)
enable_local_private_key_export: bool = False
load_with_autoevict: bool = False
skip_post_connect_sync: bool = False
basic_auth_username: str = ""
+19
View File
@@ -180,6 +180,25 @@ async def radio_disconnected_handler(request: Request, exc: RadioDisconnectedErr
return JSONResponse(status_code=503, content={"detail": "Radio not connected"})
@app.middleware("http")
async def log_server_errors(request: Request, call_next):
"""Capture 5xx errors and unhandled exceptions into the log ring buffer.
Starlette writes unhandled-exception tracebacks to stderr, bypassing
Python logging, so they never reach the debug dump. This middleware
catches them and logs via ``logger.exception()`` so the full traceback
is preserved in the ring buffer for the ``GET /api/debug`` snapshot.
"""
try:
response = await call_next(request)
except Exception:
logger.exception("Unhandled exception on %s %s", request.method, request.url.path)
raise
if response.status_code >= 500:
logger.error("HTTP %d on %s %s", response.status_code, request.method, request.url.path)
return response
# API routes - all prefixed with /api for production compatibility
app.include_router(health.router, prefix="/api")
app.include_router(debug.router, prefix="/api")
+34 -5
View File
@@ -64,7 +64,6 @@ class DebugRuntimeInfo(BaseModel):
path_hash_mode_supported: bool
channel_slot_reuse_enabled: bool
channel_send_cache_capacity: int
remediation_flags: dict[str, bool]
class DebugContactAudit(BaseModel):
@@ -110,6 +109,21 @@ class DebugHealthSummary(BaseModel):
basic_auth_enabled: bool = False
class DebugEnvironment(BaseModel):
connection_type: str
serial_port: str
serial_baudrate: int
tcp_host: str
tcp_port: int
ble_address: str
log_level: str
database_path: str
disable_bots: bool
enable_message_poll_fallback: bool
force_channel_slot_reconfigure: bool
load_with_autoevict: bool
class DebugAppSettings(BaseModel):
max_radio_contacts: int
auto_decrypt_dm_on_advert: bool
@@ -123,6 +137,7 @@ class DebugSnapshotResponse(BaseModel):
captured_at: str
system: DebugSystemInfo
application: DebugApplicationInfo
environment: DebugEnvironment
health: DebugHealthSummary
settings: DebugAppSettings
runtime: DebugRuntimeInfo
@@ -203,6 +218,23 @@ def _coerce_live_max_channels(device_info: dict[str, Any] | None) -> int | None:
return None
def _build_environment() -> DebugEnvironment:
return DebugEnvironment(
connection_type=settings.connection_type,
serial_port=settings.serial_port,
serial_baudrate=settings.serial_baudrate,
tcp_host=settings.tcp_host,
tcp_port=settings.tcp_port,
ble_address=settings.ble_address,
log_level=settings.log_level,
database_path=settings.database_path,
disable_bots=settings.disable_bots,
enable_message_poll_fallback=settings.enable_message_poll_fallback,
force_channel_slot_reconfigure=settings.force_channel_slot_reconfigure,
load_with_autoevict=settings.load_with_autoevict,
)
def _build_debug_app_settings(app_settings: AppSettings) -> DebugAppSettings:
return DebugAppSettings(
max_radio_contacts=app_settings.max_radio_contacts,
@@ -393,6 +425,7 @@ async def debug_support_snapshot() -> DebugSnapshotResponse:
captured_at=datetime.now(UTC).isoformat(),
system=_build_system_info(),
application=_build_application_info(),
environment=_build_environment(),
health=_build_debug_health_summary(health_data, radio_state=radio_state),
settings=_build_debug_app_settings(app_settings),
runtime=DebugRuntimeInfo(
@@ -404,10 +437,6 @@ async def debug_support_snapshot() -> DebugSnapshotResponse:
path_hash_mode_supported=radio_runtime.path_hash_mode_supported,
channel_slot_reuse_enabled=radio_runtime.channel_slot_reuse_enabled(),
channel_send_cache_capacity=radio_runtime.get_channel_send_cache_capacity(),
remediation_flags={
"enable_message_poll_fallback": settings.enable_message_poll_fallback,
"force_channel_slot_reconfigure": settings.force_channel_slot_reconfigure,
},
),
database=DebugDatabaseInfo(
total_dms=message_totals["total_dms"],
+4
View File
@@ -40,6 +40,8 @@ class RadioStatsSnapshot(BaseModel):
# Core stats
battery_mv: int | None = None
uptime_secs: int | None = None
queue_len: int | None = None
errors: int | None = None
# Radio stats
noise_floor: int | None = None
last_rssi: int | None = None
@@ -155,6 +157,8 @@ async def build_health_data(radio_connected: bool, connection_info: str | None)
"timestamp": raw_stats.get("timestamp"),
"battery_mv": raw_stats.get("battery_mv"),
"uptime_secs": raw_stats.get("uptime_secs"),
"queue_len": raw_stats.get("queue_len"),
"errors": raw_stats.get("errors"),
"noise_floor": raw_stats.get("noise_floor"),
"last_rssi": raw_stats.get("last_rssi"),
"last_snr": raw_stats.get("last_snr"),
+24
View File
@@ -385,6 +385,30 @@ async def update_radio_config(update: RadioConfigUpdate) -> RadioConfigResponse:
return await get_radio_config()
@router.get("/private-key")
async def get_private_key() -> dict:
"""Return the in-memory private key (exported from radio on startup).
Gated behind MESHCORE_ENABLE_LOCAL_PRIVATE_KEY_EXPORT=true.
"""
from app.config import settings
from app.keystore import get_private_key as ks_get
if not settings.enable_local_private_key_export:
raise HTTPException(
status_code=403,
detail="Private key export is disabled (set MESHCORE_ENABLE_LOCAL_PRIVATE_KEY_EXPORT=true)",
)
key = ks_get()
if key is None:
raise HTTPException(
status_code=404,
detail="Private key not available (not exported from radio)",
)
return {"private_key": key.hex()}
@router.put("/private-key")
async def set_private_key(update: PrivateKeyUpdate) -> dict:
"""Set the radio's private key. This is write-only."""
+6
View File
@@ -258,6 +258,12 @@ async def send_channel_message_with_effective_scope(
)
raise HTTPException(status_code=504, detail=NO_RADIO_RESPONSE_AFTER_SEND_DETAIL)
if send_result.type == EventType.ERROR:
logger.error(
"Radio returned error during %s for channel %s: %s",
action_label,
channel.name,
send_result.payload,
)
radio_manager.invalidate_cached_channel_slot(channel_key)
else:
radio_manager.note_channel_slot_used(channel_key)
+1
View File
@@ -96,6 +96,7 @@ export const api = {
method: 'PATCH',
body: JSON.stringify(config),
}),
getPrivateKey: () => fetchJson<{ private_key: string }>('/radio/private-key'),
setPrivateKey: (privateKey: string) =>
fetchJson<{ status: string }>('/radio/private-key', {
method: 'PUT',
@@ -1,11 +1,20 @@
import { useState, useEffect, useMemo } from 'react';
import { MapPinned } from 'lucide-react';
import { useState, useEffect, useMemo, useRef } from 'react';
import { ChevronDown, Download, MapPinned, Upload } from 'lucide-react';
import { Input } from '../ui/input';
import { Label } from '../ui/label';
import { Button } from '../ui/button';
import { Separator } from '../ui/separator';
import { toast } from '../ui/sonner';
import { Checkbox } from '../ui/checkbox';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '../ui/dialog';
import { api } from '../../api';
import { RADIO_PRESETS } from '../../utils/radioPresets';
import { stripRegionScopePrefix } from '../../utils/regionScope';
import type {
@@ -17,8 +26,116 @@ import type {
RadioConfigUpdate,
RadioDiscoveryResponse,
RadioDiscoveryTarget,
RadioStatsSnapshot,
} from '../../types';
function formatUptime(secs: number): string {
const days = Math.floor(secs / 86400);
const hours = Math.floor((secs % 86400) / 3600);
const minutes = Math.floor((secs % 3600) / 60);
if (days > 0) return `${days}d ${hours}h ${minutes}m`;
if (hours > 0) return `${hours}h ${minutes}m`;
return `${minutes}m`;
}
function formatAirtime(secs: number): string {
if (secs < 60) return `${secs}s`;
const hours = Math.floor(secs / 3600);
const minutes = Math.floor((secs % 3600) / 60);
if (hours > 0) return `${hours}h ${minutes}m`;
return `${minutes}m`;
}
function StatRow({ label, value, warn }: { label: string; value: string; warn?: boolean }) {
return (
<div className="flex items-center justify-between gap-2 py-0.5">
<span className="text-xs text-muted-foreground">{label}</span>
<span
className={`text-xs font-mono tabular-nums ${warn ? 'text-warning font-semibold' : ''}`}
>
{value}
</span>
</div>
);
}
function RadioDetailsCollapsible({ stats }: { stats: RadioStatsSnapshot }) {
const age = stats.timestamp ? Math.max(0, Math.floor(Date.now() / 1000) - stats.timestamp) : null;
const packets = {
recv: stats.packets_recv,
sent: stats.packets_sent,
flood_tx: stats.flood_tx,
direct_tx: stats.direct_tx,
flood_rx: stats.flood_rx,
direct_rx: stats.direct_rx,
};
return (
<details className="group">
<summary className="text-sm font-medium text-foreground cursor-pointer select-none flex items-center gap-1">
<ChevronDown className="h-3 w-3 transition-transform group-open:rotate-0 -rotate-90" />
Radio Details
</summary>
<div className="mt-2 space-y-2 rounded-md border border-input bg-muted/20 p-3">
{age !== null && (
<p className="text-[0.625rem] uppercase tracking-wider text-muted-foreground font-medium">
Updated {age < 5 ? 'just now' : `${age}s ago`}
</p>
)}
{/* Core */}
{stats.uptime_secs != null && (
<StatRow label="Uptime" value={formatUptime(stats.uptime_secs)} />
)}
{stats.battery_mv != null && stats.battery_mv > 0 && (
<StatRow label="Battery" value={`${(stats.battery_mv / 1000).toFixed(2)}V`} />
)}
{stats.queue_len != null && (
<StatRow
label="TX Queue"
value={`${stats.queue_len} / 16`}
warn={stats.queue_len >= 14}
/>
)}
{stats.errors != null && (
<StatRow label="Errors" value={String(stats.errors)} warn={stats.errors > 0} />
)}
{/* RF */}
{stats.noise_floor != null && (
<StatRow label="Noise Floor" value={`${stats.noise_floor} dBm`} />
)}
{stats.last_rssi != null && <StatRow label="Last RSSI" value={`${stats.last_rssi} dBm`} />}
{stats.last_snr != null && <StatRow label="Last SNR" value={`${stats.last_snr} dB`} />}
{/* Airtime */}
{(stats.tx_air_secs != null || stats.rx_air_secs != null) && (
<>
{stats.tx_air_secs != null && (
<StatRow label="TX Airtime" value={formatAirtime(stats.tx_air_secs)} />
)}
{stats.rx_air_secs != null && (
<StatRow label="RX Airtime" value={formatAirtime(stats.rx_air_secs)} />
)}
</>
)}
{/* Packets */}
{packets.recv != null && <StatRow label="Packets Received" value={String(packets.recv)} />}
{packets.sent != null && <StatRow label="Packets Sent" value={String(packets.sent)} />}
{packets.flood_tx != null && <StatRow label="Flood TX" value={String(packets.flood_tx)} />}
{packets.flood_rx != null && <StatRow label="Flood RX" value={String(packets.flood_rx)} />}
{packets.direct_tx != null && (
<StatRow label="Direct TX" value={String(packets.direct_tx)} />
)}
{packets.direct_rx != null && (
<StatRow label="Direct RX" value={String(packets.direct_rx)} />
)}
</div>
</details>
);
}
export function SettingsRadioSection({
config,
health,
@@ -320,6 +437,169 @@ export function SettingsRadioSection({
}
};
const importInputRef = useRef<HTMLInputElement>(null);
const [keyImportDialogOpen, setKeyImportDialogOpen] = useState(false);
const pendingImportRef = useRef<Record<string, unknown> | null>(null);
const buildConfigProfile = () => ({
version: 1,
exported_at: new Date().toISOString(),
name: config.name,
lat: config.lat,
lon: config.lon,
tx_power: config.tx_power,
radio: { ...config.radio },
path_hash_mode: config.path_hash_mode,
advert_location_source: config.advert_location_source ?? 'current',
multi_acks_enabled: config.multi_acks_enabled ?? false,
});
const downloadJson = (profile: object, suffix: string) => {
const blob = new Blob([JSON.stringify(profile, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
const safeName = (config.name || 'radio').replace(/[^a-zA-Z0-9_-]/g, '_');
const timestamp = new Date()
.toLocaleString(undefined, {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
})
.replace(/[/:, ]+/g, '-');
a.download = `${safeName}-${suffix}-${timestamp}.json`;
a.click();
URL.revokeObjectURL(url);
};
const handleExportConfig = async () => {
const profile = buildConfigProfile();
try {
const { private_key } = await api.getPrivateKey();
downloadJson({ ...profile, private_key }, 'config');
toast.success('Export generated with private key');
} catch {
downloadJson(profile, 'config');
toast.info('Export generated without private key', {
description: 'See README_ADVANCED.md for private key export enable',
});
}
};
const validateImportData = (
data: unknown
): data is {
name: string;
radio: { freq: number; bw: number; sf: number; cr: number };
[k: string]: unknown;
} =>
typeof data === 'object' &&
data !== null &&
'name' in data &&
typeof (data as Record<string, unknown>).name === 'string' &&
'radio' in data &&
typeof (data as Record<string, unknown>).radio === 'object' &&
(data as Record<string, unknown>).radio !== null &&
typeof (data as Record<string, Record<string, unknown>>).radio.freq === 'number' &&
typeof (data as Record<string, Record<string, unknown>>).radio.bw === 'number' &&
typeof (data as Record<string, Record<string, unknown>>).radio.sf === 'number' &&
typeof (data as Record<string, Record<string, unknown>>).radio.cr === 'number';
const populateFormFromImport = (data: Record<string, unknown>) => {
const radio = data.radio as { freq: number; bw: number; sf: number; cr: number };
setName(data.name as string);
if (typeof data.lat === 'number') setLat(String(data.lat));
if (typeof data.lon === 'number') setLon(String(data.lon));
if (typeof data.tx_power === 'number') setTxPower(String(data.tx_power));
setFreq(String(radio.freq));
setBw(String(radio.bw));
setSf(String(radio.sf));
setCr(String(radio.cr));
if (typeof data.path_hash_mode === 'number') setPathHashMode(String(data.path_hash_mode));
if (data.advert_location_source === 'off' || data.advert_location_source === 'current')
setAdvertLocationSource(data.advert_location_source);
if (typeof data.multi_acks_enabled === 'boolean') setMultiAcksEnabled(data.multi_acks_enabled);
};
const buildUpdateFromImport = (data: Record<string, unknown>): RadioConfigUpdate => {
const radio = data.radio as { freq: number; bw: number; sf: number; cr: number };
const update: RadioConfigUpdate = {
name: data.name as string,
lat: typeof data.lat === 'number' ? data.lat : config.lat,
lon: typeof data.lon === 'number' ? data.lon : config.lon,
tx_power: typeof data.tx_power === 'number' ? (data.tx_power as number) : config.tx_power,
radio,
};
if (data.advert_location_source === 'off' || data.advert_location_source === 'current')
update.advert_location_source = data.advert_location_source;
if (typeof data.multi_acks_enabled === 'boolean')
update.multi_acks_enabled = data.multi_acks_enabled;
if (config.path_hash_mode_supported && typeof data.path_hash_mode === 'number')
update.path_hash_mode = data.path_hash_mode as number;
return update;
};
const applyImport = async (data: Record<string, unknown>) => {
populateFormFromImport(data);
const update = buildUpdateFromImport(data);
setBusy(true);
setRebooting(true);
try {
if (typeof data.private_key === 'string' && data.private_key) {
await onSetPrivateKey(data.private_key);
toast.success('Config + private key imported, saving & rebooting...');
} else {
toast.success('Config imported, saving & rebooting...');
}
await onSave(update);
await onReboot();
if (!pageMode) onClose();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to import config');
} finally {
setRebooting(false);
setBusy(false);
}
};
const handleImportConfig = async (file: File) => {
try {
const text = await file.text();
const data = JSON.parse(text);
if (!validateImportData(data)) {
toast.error('Invalid config file', {
description: 'File must contain name and radio parameters (freq, bw, sf, cr)',
});
return;
}
if (typeof data.private_key === 'string' && data.private_key) {
// Private key present — show warning dialog before applying
pendingImportRef.current = data;
setKeyImportDialogOpen(true);
} else {
await applyImport(data);
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to import config');
} finally {
if (importInputRef.current) importInputRef.current.value = '';
}
};
const handleConfirmKeyImport = async () => {
setKeyImportDialogOpen(false);
const data = pendingImportRef.current;
pendingImportRef.current = null;
if (data) await applyImport(data);
};
const radioState =
health?.radio_state ?? (health?.radio_initializing ? 'initializing' : 'disconnected');
const connectionActionLabel =
@@ -414,6 +694,9 @@ export function SettingsRadioSection({
</span>
</div>
{deviceInfoLabel && <p className="text-sm text-muted-foreground">{deviceInfoLabel}</p>}
{health?.radio_stats && <RadioDetailsCollapsible stats={health.radio_stats} />}
<Button
type="button"
variant="outline"
@@ -678,6 +961,37 @@ export function SettingsRadioSection({
Some settings may require a reboot to take effect on some radios.
</p>
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={handleExportConfig} className="flex-1">
<Download className="mr-1.5 h-4 w-4" aria-hidden="true" />
Export Config
</Button>
<Button
variant="outline"
size="sm"
onClick={() => importInputRef.current?.click()}
disabled={busy || rebooting}
className="flex-1"
>
<Upload className="mr-1.5 h-4 w-4" aria-hidden="true" />
Import &amp; Reboot
</Button>
<input
ref={importInputRef}
type="file"
accept=".json"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) handleImportConfig(file);
}}
/>
</div>
<p className="text-[0.8125rem] text-muted-foreground">
Export saves the current server config to a JSON file. Import loads a config file, applies
it, and reboots the radio.
</p>
<Separator />
{/* ── Messaging ── */}
@@ -907,6 +1221,44 @@ export function SettingsRadioSection({
)}
</div>
</div>
{/* ── Private Key Import Warning ── */}
<Dialog
open={keyImportDialogOpen}
onOpenChange={(open) => {
setKeyImportDialogOpen(open);
if (!open) pendingImportRef.current = null;
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Import includes Private Key</DialogTitle>
<DialogDescription>
This config file contains a private key. Importing it will change your radio&apos;s
identity &mdash; your radio will have a new public key and other nodes will see it as
a different device. This cannot be undone without the original key.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
onClick={() => {
setKeyImportDialogOpen(false);
pendingImportRef.current = null;
}}
>
Cancel
</Button>
<Button
onClick={handleConfirmKeyImport}
className="border-destructive/50 text-destructive hover:bg-destructive/10"
variant="outline"
>
Import Config &amp; Key
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
@@ -224,6 +224,7 @@ export function usePushSubscription(): PushSubscriptionState {
console.error('Push subscribe failed:', err);
toast.error('Failed to enable push notifications', {
description: err instanceof Error ? err.message : 'Check that notifications are allowed',
duration: 8_000,
});
return null;
} finally {
@@ -171,9 +171,12 @@ describe('usePushSubscription', () => {
});
expect(result.current.loading).toBe(false);
expect(mocks.toast.error).toHaveBeenCalledWith('Failed to enable push notifications', {
description: expect.stringContaining('trusted TLS certificate for service workers'),
});
expect(mocks.toast.error).toHaveBeenCalledWith(
'Failed to enable push notifications',
expect.objectContaining({
description: expect.stringContaining('trusted TLS certificate for service workers'),
})
);
}, 5_000);
it('recreates a stale browser subscription when the server VAPID key changed', async () => {
+2
View File
@@ -66,6 +66,8 @@ export interface RadioStatsSnapshot {
timestamp: number | null;
battery_mv: number | null;
uptime_secs: number | null;
queue_len: number | null;
errors: number | null;
noise_floor: number | null;
last_rssi: number | null;
last_snr: number | null;
+26
View File
@@ -203,6 +203,30 @@ class TestHealthEndpoint:
class TestDebugEndpoint:
"""Test the debug support snapshot endpoint."""
def test_build_environment_exposes_env_settings(self):
"""_build_environment should expose env config without secrets."""
from app.config import Settings
from app.routers.debug import _build_environment
with patch(
"app.routers.debug.settings",
Settings(
serial_port="/dev/ttyUSB0",
serial_baudrate=115200,
log_level="DEBUG",
database_path="data/test.db",
),
):
env = _build_environment()
assert env.connection_type == "serial"
assert env.serial_port == "/dev/ttyUSB0"
assert env.log_level == "DEBUG"
assert env.database_path == "data/test.db"
assert not hasattr(env, "ble_pin")
assert not hasattr(env, "basic_auth_password")
assert not hasattr(env, "basic_auth_username")
def test_support_snapshot_sanitizes_radio_probe_location_fields(self):
"""Debug radio probe should redact advertised lat/lon from self_info."""
from app.routers.debug import _sanitize_radio_probe_self_info
@@ -300,6 +324,8 @@ class TestDebugEndpoint:
assert "multi_acks_enabled" not in payload["radio_probe"]
assert "max_channels" not in payload["runtime"]
assert "path_hash_mode" not in payload["runtime"]
assert "environment" in payload
assert payload["environment"]["connection_type"] in ("serial", "tcp", "ble")
assert payload["runtime"]["channels_with_incoming_messages"] == 0
assert payload["database"]["total_dms"] == 0
assert payload["database"]["total_channel_messages"] == 0
+33
View File
@@ -20,6 +20,7 @@ from app.routers.radio import (
RadioSettings,
disconnect_radio,
discover_mesh,
get_private_key,
get_radio_config,
reboot_radio,
reconnect_radio,
@@ -283,6 +284,38 @@ class TestUpdateRadioConfig:
mc.commands.send_appstart.assert_not_awaited()
class TestPrivateKeyExport:
@pytest.mark.asyncio
async def test_returns_403_when_export_disabled(self):
with patch("app.config.settings") as mock_settings:
mock_settings.enable_local_private_key_export = False
with pytest.raises(HTTPException) as exc:
await get_private_key()
assert exc.value.status_code == 403
@pytest.mark.asyncio
async def test_returns_404_when_no_key_available(self):
with (
patch("app.config.settings") as mock_settings,
patch("app.keystore.get_private_key", return_value=None),
):
mock_settings.enable_local_private_key_export = True
with pytest.raises(HTTPException) as exc:
await get_private_key()
assert exc.value.status_code == 404
@pytest.mark.asyncio
async def test_returns_key_hex_when_enabled_and_available(self):
key_bytes = bytes.fromhex("ab" * 64)
with (
patch("app.config.settings") as mock_settings,
patch("app.keystore.get_private_key", return_value=key_bytes),
):
mock_settings.enable_local_private_key_export = True
result = await get_private_key()
assert result == {"private_key": "ab" * 64}
class TestPrivateKeyImport:
@pytest.mark.asyncio
async def test_rejects_invalid_hex(self):