diff --git a/app/config.py b/app/config.py index 8a3d1dc..b6d6032 100644 --- a/app/config.py +++ b/app/config.py @@ -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 = "" diff --git a/app/routers/radio.py b/app/routers/radio.py index f468da0..aad08f7 100644 --- a/app/routers/radio.py +++ b/app/routers/radio.py @@ -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.""" diff --git a/frontend/src/api.ts b/frontend/src/api.ts index e65ee82..30dfbd1 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -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', diff --git a/frontend/src/components/settings/SettingsRadioSection.tsx b/frontend/src/components/settings/SettingsRadioSection.tsx index 09a015a..c3dc9d4 100644 --- a/frontend/src/components/settings/SettingsRadioSection.tsx +++ b/frontend/src/components/settings/SettingsRadioSection.tsx @@ -1,11 +1,20 @@ -import { useState, useEffect, useMemo } from 'react'; -import { ChevronDown, 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 { @@ -428,6 +437,169 @@ export function SettingsRadioSection({ } }; + const importInputRef = useRef(null); + const [keyImportDialogOpen, setKeyImportDialogOpen] = useState(false); + const pendingImportRef = useRef | 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).name === 'string' && + 'radio' in data && + typeof (data as Record).radio === 'object' && + (data as Record).radio !== null && + typeof (data as Record>).radio.freq === 'number' && + typeof (data as Record>).radio.bw === 'number' && + typeof (data as Record>).radio.sf === 'number' && + typeof (data as Record>).radio.cr === 'number'; + + const populateFormFromImport = (data: Record) => { + 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): 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) => { + 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 = @@ -789,6 +961,37 @@ export function SettingsRadioSection({ Some settings may require a reboot to take effect on some radios.

+
+ + + { + const file = e.target.files?.[0]; + if (file) handleImportConfig(file); + }} + /> +
+

+ Export saves the current server config to a JSON file. Import loads a config file, applies + it, and reboots the radio. +

+ {/* ── Messaging ── */} @@ -1018,6 +1221,44 @@ export function SettingsRadioSection({ )} + + {/* ── Private Key Import Warning ── */} + { + setKeyImportDialogOpen(open); + if (!open) pendingImportRef.current = null; + }} + > + + + Import includes Private Key + + This config file contains a private key. Importing it will change your radio's + identity — 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. + + + + + + + + ); } diff --git a/tests/test_radio_router.py b/tests/test_radio_router.py index 92d4135..55c981f 100644 --- a/tests/test_radio_router.py +++ b/tests/test_radio_router.py @@ -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):