Compare commits

...

14 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
Jack Kingsman 6b81dd3082 Updating changelog + build for 3.12.1 2026-04-19 21:18:26 -07:00
Jack Kingsman cc2b16e53f Test fix 2026-04-19 21:14:38 -07:00
Jack Kingsman 330007e120 Be smarter about web push not being available on snakeoil certs for mobile 2026-04-19 21:10:17 -07:00
Jack Kingsman f5a2a21f11 Fix e2e tests 2026-04-19 20:45:11 -07:00
Jack Kingsman a3e62885d4 Merge pull request #206 from jkingsman/dependabot/uv/uv-2c6491f7af
Bump the uv group across 1 directory with 2 updates
2026-04-19 19:36:12 -07:00
Jack Kingsman dbdd722c48 Merge pull request #207 from jkingsman/channel-mute
Add channel mute
2026-04-19 19:35:52 -07:00
dependabot[bot] b8683e57d8 Bump the uv group across 1 directory with 2 updates
Bumps the uv group with 2 updates in the / directory: [pytest](https://github.com/pytest-dev/pytest) and [requests](https://github.com/psf/requests).


Updates `pytest` from 9.0.2 to 9.0.3
- [Release notes](https://github.com/pytest-dev/pytest/releases)
- [Changelog](https://github.com/pytest-dev/pytest/blob/main/CHANGELOG.rst)
- [Commits](https://github.com/pytest-dev/pytest/compare/9.0.2...9.0.3)

Updates `requests` from 2.32.5 to 2.33.0
- [Release notes](https://github.com/psf/requests/releases)
- [Changelog](https://github.com/psf/requests/blob/main/HISTORY.md)
- [Commits](https://github.com/psf/requests/compare/v2.32.5...v2.33.0)

---
updated-dependencies:
- dependency-name: pytest
  dependency-version: 9.0.3
  dependency-type: direct:development
  dependency-group: uv
- dependency-name: requests
  dependency-version: 2.33.0
  dependency-type: indirect
  dependency-group: uv
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-20 01:44:21 +00:00
24 changed files with 662 additions and 30 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
+8
View File
@@ -1,3 +1,11 @@
## [3.12.1] - 2026-04-19
* Feature: Auto-evict/circular-buffer contact load mode (solves potential T-Beam issues)
* Feature: Channel mute
* Misc: HA Documentation improvements
* Misc: Bump deps & update tests
* Misc: Improve warnings around web push in untrusted contexts
## [3.12.0] - 2026-04-17
* Feature: Web Push -- get your mesh notifications on a locked phone or when your browser is closed!
+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 -1
View File
@@ -1,7 +1,7 @@
{
"name": "remoteterm-meshcore-frontend",
"private": true,
"version": "3.12.0",
"version": "3.12.1",
"type": "module",
"scripts": {
"dev": "vite",
+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 ── */}
@@ -733,9 +1047,9 @@ export function SettingsRadioSection({
placeholder="MyRegion"
/>
<p className="text-[0.8125rem] text-muted-foreground">
Tag outgoing flood messages with a region name (e.g. MyRegion). Repeaters configured for
that region can forward the traffic, while repeaters configured to deny other regions may
drop it. Leave empty to disable.
Tag outgoing messages with a region name (e.g. MyRegion). Repeaters configured for that
region can forward the traffic, while repeaters configured to deny other regions may drop
it. Leave empty to disable.
</p>
</div>
@@ -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>
);
}
+41 -4
View File
@@ -37,6 +37,33 @@ function urlBase64ToUint8Array(base64String: string): Uint8Array {
return arr;
}
/** Race a promise against a timeout; rejects with a descriptive error on expiry. */
function withTimeout<T>(promise: Promise<T>, ms: number, label: string): Promise<T> {
return new Promise((resolve, reject) => {
const timer = setTimeout(
() =>
reject(
new Error(
`${label} timed out — the service worker may have failed to install. ` +
'Mobile browsers require a trusted TLS certificate for service workers, ' +
'even if the page itself loads with a self-signed cert.'
)
),
ms
);
promise.then(
(v) => {
clearTimeout(timer);
resolve(v);
},
(e) => {
clearTimeout(timer);
reject(e);
}
);
});
}
function uint8ArraysEqual(a: Uint8Array | null, b: Uint8Array): boolean {
if (!a || a.length !== b.length) return false;
for (let i = 0; i < a.length; i++) {
@@ -109,8 +136,9 @@ export function usePushSubscription(): PushSubscriptionState {
const subsPromise = api.getPushSubscriptions().catch(() => [] as PushSubscriptionInfo[]);
// Check if THIS browser has an active push subscription and match it
// to a backend record.
navigator.serviceWorker.ready
// to a backend record. Use a timeout so we don't hang forever when the
// service worker failed to install (e.g. mobile + self-signed cert).
withTimeout(navigator.serviceWorker.ready, 1_000, 'Service worker activation')
.then((reg) => reg.pushManager.getSubscription())
.then(async (sub) => {
const existing = await subsPromise;
@@ -129,7 +157,11 @@ export function usePushSubscription(): PushSubscriptionState {
const refreshSubscriptions = useCallback(async () => {
try {
const subs = await api.getPushSubscriptions();
const reg = await navigator.serviceWorker.ready;
const reg = await withTimeout(
navigator.serviceWorker.ready,
10_000,
'Service worker activation'
);
const sub = await reg.pushManager.getSubscription();
reconcileCurrentSubscription(subs, sub?.endpoint ?? null);
return subs;
@@ -155,7 +187,11 @@ export function usePushSubscription(): PushSubscriptionState {
vapidKeyRef.current = resp.public_key;
const vapidKeyBytes = urlBase64ToUint8Array(resp.public_key);
const reg = await navigator.serviceWorker.ready;
const reg = await withTimeout(
navigator.serviceWorker.ready,
3_000,
'Service worker activation'
);
let pushSub = await reg.pushManager.getSubscription();
const existingKeyBytes = getApplicationServerKeyBytes(pushSub?.options?.applicationServerKey);
const requiresRecreate =
@@ -188,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 {
+3 -1
View File
@@ -24,5 +24,7 @@ createRoot(document.getElementById('root')!).render(
// Register service worker for Web Push (requires secure context)
if ('serviceWorker' in navigator && window.isSecureContext) {
navigator.serviceWorker.register('./sw.js').catch(() => {});
navigator.serviceWorker.register('./sw.js').catch((err) => {
console.warn('Service worker registration failed:', err);
});
}
@@ -150,6 +150,35 @@ describe('usePushSubscription', () => {
expect(result.current.allSubscriptions).toEqual([]);
});
it('times out and shows a toast when service worker never activates', async () => {
// Replace serviceWorker.ready with a promise that never resolves
Object.defineProperty(navigator, 'serviceWorker', {
configurable: true,
value: {
ready: new Promise(() => {}),
},
});
const { result } = renderHook(() => usePushSubscription());
await waitFor(() => {
expect(result.current.isSupported).toBe(true);
});
// subscribe() will hang on serviceWorker.ready, then the 1s timeout fires
await act(async () => {
await result.current.subscribe();
});
expect(result.current.loading).toBe(false);
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 () => {
const oldSubscription = activeSubscription;
mocks.api.getPushSubscriptions
+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;
+2 -2
View File
@@ -1,6 +1,6 @@
[project]
name = "remoteterm-meshcore"
version = "3.12.0"
version = "3.12.1"
description = "RemoteTerm - Web interface for MeshCore radio mesh networks"
readme = "README.md"
requires-python = ">=3.11"
@@ -61,7 +61,7 @@ reportMissingTypeStubs = false
dev = [
"httpx>=0.28.1",
"pip-licenses>=5.0.0",
"pytest>=9.0.2",
"pytest>=9.0.3",
"pytest-asyncio>=1.3.0",
"pytest-xdist>=3.0",
"ruff>=0.8.0",
+7 -1
View File
@@ -52,6 +52,12 @@ test.describe('Favorites persistence', () => {
return channels.some((c) => c.key === channelKey && c.favorite);
})
.toBe(false);
await expect(page.getByText('Favorites')).not.toBeVisible();
// The test channel should no longer appear under the Favorites header —
// but the Favorites section itself may remain if radio-synced contacts are favorited.
const channelsSectionHeader = page.getByText('Channels');
await expect(channelsSectionHeader).toBeVisible();
// Verify the channel now appears in the non-favorites Channels section
const channelEntry = page.getByText(channelName, { exact: true }).first();
await expect(channelEntry).toBeVisible();
});
});
+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):
Generated
+8 -8
View File
@@ -1399,7 +1399,7 @@ wheels = [
[[package]]
name = "pytest"
version = "9.0.2"
version = "9.0.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
@@ -1408,9 +1408,9 @@ dependencies = [
{ name = "pluggy" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901 }
sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801 },
{ url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249 },
]
[[package]]
@@ -1533,7 +1533,7 @@ wheels = [
[[package]]
name = "remoteterm-meshcore"
version = "3.12.0"
version = "3.12.1"
source = { virtual = "." }
dependencies = [
{ name = "aiomqtt" },
@@ -1582,7 +1582,7 @@ dev = [
{ name = "httpx", specifier = ">=0.28.1" },
{ name = "pip-licenses", specifier = ">=5.0.0" },
{ name = "pyright", specifier = ">=1.1.390" },
{ name = "pytest", specifier = ">=9.0.2" },
{ name = "pytest", specifier = ">=9.0.3" },
{ name = "pytest-asyncio", specifier = ">=1.3.0" },
{ name = "pytest-xdist", specifier = ">=3.0" },
{ name = "ruff", specifier = ">=0.8.0" },
@@ -1590,7 +1590,7 @@ dev = [
[[package]]
name = "requests"
version = "2.32.5"
version = "2.33.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
@@ -1598,9 +1598,9 @@ dependencies = [
{ name = "idna" },
{ name = "urllib3" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517 }
sdist = { url = "https://files.pythonhosted.org/packages/34/64/8860370b167a9721e8956ae116825caff829224fbca0ca6e7bf8ddef8430/requests-2.33.0.tar.gz", hash = "sha256:c7ebc5e8b0f21837386ad0e1c8fe8b829fa5f544d8df3b2253bff14ef29d7652", size = 134232 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738 },
{ url = "https://files.pythonhosted.org/packages/56/5d/c814546c2333ceea4ba42262d8c4d55763003e767fa169adc693bd524478/requests-2.33.0-py3-none-any.whl", hash = "sha256:3324635456fa185245e24865e810cecec7b4caf933d7eb133dcde67d48cee69b", size = 65017 },
]
[[package]]