From 7528e4121fb0e69e7b23a7b2b1df81fecba9603f Mon Sep 17 00:00:00 2001
From: Jack Kingsman
Date: Mon, 20 Apr 2026 19:31:18 -0700
Subject: [PATCH] Add config export
---
app/config.py | 1 +
app/routers/radio.py | 24 ++
frontend/src/api.ts | 1 +
.../settings/SettingsRadioSection.tsx | 245 +++++++++++++++++-
tests/test_radio_router.py | 33 +++
5 files changed, 302 insertions(+), 2 deletions(-)
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 ── */}
+
);
}
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):