diff --git a/.env.example b/.env.example
index 18fa832a..cd2d8b8e 100644
--- a/.env.example
+++ b/.env.example
@@ -53,9 +53,14 @@ HOST=localhost
# API_WRITE_KEY=your-secret-key-here
# CORS allowed origins (comma-separated)
-# If not set, defaults to reflecting the request origin (backward-compatible).
-# Set this for cloud deployments to restrict which websites can call your API.
-# CORS_ORIGINS=https://yourdomain.com,http://localhost:3000
+# By default, only localhost and openhamclock.com/app origins are allowed.
+# Add your custom domain if you host OHC behind your own URL.
+# CORS_ORIGINS=https://yourdomain.com
+
+# Trust proxy headers (X-Forwarded-For) for rate limiting IP detection.
+# Auto-detected: enabled on Railway, disabled on Pi/local installs.
+# Set to 'true' if behind nginx/Caddy/etc., 'false' to force off.
+# TRUST_PROXY=true
# ===========================================
# AUTO UPDATE (GIT)
@@ -229,18 +234,19 @@ VITE_AMBIENT_API_KEY=your_api_key_here
# ===========================================
# ROTATOR CONTROL
# ===========================================
-
-ROTATOR_PROVIDER=pstrotator_udp
-PSTROTATOR_HOST=192.168.1.43
-PSTROTATOR_UDP_PORT=12000
-ROTATOR_STALE_MS=5000
+# Uncomment and configure ONLY if you have a PSTRotator-compatible rotator.
+# Leaving these active on a fresh install sends UDP traffic to 192.168.1.43.
+# ROTATOR_PROVIDER=pstrotator_udp
+# PSTROTATOR_HOST=192.168.1.43
+# PSTROTATOR_UDP_PORT=12000
+# ROTATOR_STALE_MS=5000
# Your app will use the proxy path:
-VITE_PSTROTATOR_BASE_URL=/pstrotator
+# VITE_PSTROTATOR_BASE_URL=/pstrotator
# Optional: HTTP endpoint for PstRotatorAz web interface (for proxy)
# Set this to the machine running PstRotatorAz (default shown below)
-VITE_PSTROTATOR_TARGET=http://192.168.1.43:50004
+# VITE_PSTROTATOR_TARGET=http://192.168.1.43:50004
# ===========================================
# N3FJP QSO RETENTION
diff --git a/AddOns/APRS-Newsfeed/aprs_newsfeed.user.js b/AddOns/APRS-Newsfeed/aprs_newsfeed.user.js
index 3c9a84a5..63144c82 100644
--- a/AddOns/APRS-Newsfeed/aprs_newsfeed.user.js
+++ b/AddOns/APRS-Newsfeed/aprs_newsfeed.user.js
@@ -159,6 +159,9 @@
let apiKey = localStorage.getItem(STORAGE_API_KEY) || '';
let lastUpdateTs = parseInt(localStorage.getItem('ohc_aprs_last_update')) || 0;
+ // Escape HTML to prevent XSS when interpolating into innerHTML
+ const esc = (s) => String(s).replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"').replace(/'/g,''');
+
function getCallsign() {
try {
const config = JSON.parse(localStorage.getItem('openhamclock_config'));
@@ -386,7 +389,7 @@
`;
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 23ffab5a..0cb67e23 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,46 @@ All notable changes to OpenHamClock will be documented in this file.
> **📅 Schedule Change:** Starting with v15.5.10, OpenHamClock moves to a weekly release cycle. Updates will ship on **Tuesday nights (EST)** — one release per week for better testing and stability.
+## [15.6.5] - 2026-03-09
+
+### Security
+
+- **CORS lockdown**: Replaced wildcard `origin: true` with explicit origin allowlist (localhost, openhamclock.com/app). Prevents malicious websites from accessing the API via the user's browser. Custom origins configurable via `CORS_ORIGINS` env var.
+- **SSRF elimination**: Custom DX cluster hosts are now DNS-resolved to IPv4, validated against private/reserved ranges, and the connection uses the validated IP (not hostname) to prevent DNS rebinding. IPv6 fallback removed to eliminate representation bypass attacks.
+- **Rotator & QRZ auth**: `/api/rotator/turn`, `/api/rotator/stop`, `/api/qrz/configure`, `/api/qrz/remove` now require `API_WRITE_KEY` authentication.
+- **Trust proxy auto-detect**: `trust proxy` enabled only on Railway (auto-detected), disabled on Pi/local installs to prevent rate-limit bypass via spoofed `X-Forwarded-For` headers. Override with `TRUST_PROXY` env var.
+- **SSE connection limiter**: Per-IP cap on concurrent SSE streams (default 10, configurable via `MAX_SSE_PER_IP`) to prevent resource exhaustion.
+- **Telnet command injection**: Control characters stripped from DX cluster login callsigns.
+- **DOM XSS fixes**: `sanitizeColor()` for N3FJP logged QSO line colors; `esc()` helper for APRS Newsfeed userscript.
+- **ReDoS fix**: Replaced `/\d+$/` regex with `substring()` for IP anonymization.
+- **URL encoding**: `encodeURIComponent()` applied to callsign parameters in localhost fetch calls.
+- **RBN callsign validation**: Input sanitized and length-checked on `/api/rbn/location/:callsign`.
+- **Health endpoint**: Session details (partial IPs, user agents) gated behind `API_WRITE_KEY` auth.
+- **Dockerfile**: Application now runs as non-root user (`nodejs`, UID 1001).
+- **Startup warning**: Server prints visible warning when `API_WRITE_KEY` is not set.
+- **Rig-bridge CORS**: Restricted to explicit origin allowlist (was wildcard `*`).
+- **Rig-bridge localhost binding**: HTTP server binds to `127.0.0.1` by default (was `0.0.0.0`).
+- **Rig-bridge serial port validation**: Paths validated against OS-specific patterns (COM*, /dev/tty*, /dev/cu.*).
+- **Rig-bridge relay SSRF**: Relay URL validated to reject private/reserved addresses.
+
+### Added
+
+- **LMSAL solar image fallback**: Three-source failover for solar imagery: SDO direct → LMSAL Sun Today (Lockheed Martin) → Helioviewer API. Independent of NASA Goddard infrastructure.
+- **Lightning unit preferences**: Proximity panel distances respect km/miles setting from allUnits.
+- **DXCC entity selector**: Browse/search DXCC entities to set DX target in Modern and Dockable layouts.
+- **DX News text scale**: Adjustable font size (0.7x–2.0x) with A-/A+ buttons. Persists in localStorage.
+- **Layout lock border panel**: Lock/unlock toggle in dedicated FlexLayout border tab (Dockable layout).
+- **Rig-bridge multicast**: WSJT-X relay supports UDP multicast for multi-app packet sharing.
+- **Rig-bridge simulated radio**: Mock plugin for testing without hardware (`radio.type = "mock"`).
+- **DX cluster TCP keepalive**: Persistent telnet sessions use OS-level keepalive and auto-reconnect after 5 min silence.
+- **DX cluster SSID**: Callsign SSID (-56) appended automatically when not provided.
+
+### Fixed
+
+- **Rotator enabled by default**: `.env.example` had `ROTATOR_PROVIDER=pstrotator_udp` uncommented, causing fresh installs to send UDP to a hardcoded IP. All rotator lines now commented out.
+- **Pi setup (armhf)**: NodeSource dropped 32-bit ARM support for Node 20+. Setup script now downloads armv7l binaries directly from nodejs.org with retry support.
+- **Pi setup (electron)**: `npm install --ignore-scripts` prevents electron-winstaller postinstall failures on ARM. `ELECTRON_SKIP_BINARY_DOWNLOAD=1` skips useless Electron download. `npm prune --omit=dev` frees ~500MB after build.
+
## [15.5.10] - 2026-02-20
### Fixed
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index a12d03e4..060bf3e1 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -10,16 +10,14 @@ Thank you for helping build OpenHamClock! Whether you're fixing a bug, adding a
# 1. Fork and clone
git clone https://github.com/YOUR_USERNAME/openhamclock.git
cd openhamclock
+npm ci
git checkout Staging
-# 2. Install dependencies
-npm install
-
-# 3. Start the backend (Terminal 1)
+# 2. Start the backend (Terminal 1)
node server.js
# → Server running on http://localhost:3001
-# 4. Start the frontend dev server (Terminal 2)
+# 3. Start the frontend dev server (Terminal 2)
npm run dev
# → App running on http://localhost:3000 (proxies API to :3001)
```
@@ -113,7 +111,7 @@ docs/update-readme
We use **Prettier** to enforce consistent formatting across the codebase. This eliminates quote style, indentation, and whitespace noise from PRs so code review can focus on logic.
-**It happens automatically:** If you run `npm install`, a git pre-commit hook (via Husky + lint-staged) will auto-format any staged files before each commit. You don't need to think about it.
+**It happens automatically:** After you run `npm ci`, a git pre-commit hook (via Husky + lint-staged) will auto-format any staged files before each commit. You don't need to think about it.
**Manual commands:**
@@ -227,6 +225,7 @@ This repository uses shared formatting and dependency lock conventions so contri
```bash
npm ci
+git checkout Staging
npm run dev
```
diff --git a/Dockerfile b/Dockerfile
index 0843a34d..322126c2 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -36,6 +36,10 @@ ENV NODE_ENV=production
ENV PORT=3000
ENV NODE_OPTIONS="--max-old-space-size=2048 --expose-gc"
+# Create non-root user for running the application
+RUN addgroup -g 1001 -S nodejs && \
+ adduser -S nodejs -u 1001 -G nodejs
+
WORKDIR /app
# Create /data directory for persistent stats (Railway volume mount point)
@@ -66,6 +70,12 @@ COPY --from=builder /app/public ./public
# Create local data directory as fallback
RUN mkdir -p /app/data
+# Set ownership so non-root user can write to data directories and .git (auto-update)
+RUN chown -R nodejs:nodejs /app /data
+
+# Run as non-root user
+USER nodejs
+
# Expose ports (3000 = web, 2237 = WSJT-X UDP, 12060 = N1MM/DXLog)
EXPOSE 3000
EXPOSE 2237/udp
diff --git a/README.md b/README.md
index a44b4e13..0ce978b9 100644
--- a/README.md
+++ b/README.md
@@ -21,7 +21,7 @@ OpenHamClock brings DX cluster spots, space weather, propagation predictions, PO
```bash
git clone https://github.com/accius/openhamclock.git
cd openhamclock
-npm install
+npm ci
npm start
```
@@ -498,6 +498,14 @@ Live decoded FT8, FT4, JT65, JT9, and WSPR messages from WSJT-X, JTDX, or any co
2. In WSJT-X: set the UDP Server address to your OpenHamClock machine's IP (e.g., `192.168.1.100`) and port `2237`.
3. Make sure UDP port 2237 is not blocked by a firewall.
+**Network setup (WSJT-X using Multicast):**
+
+While the above configuration works just fine in a majority of cases, if you are running more than one multicast listener on a host (e.g. OpenHamClock and something like GridTracker2), then OpenHamClock needs to configure itself properly as a multicast listener.
+
+Uncomment the `WSJTX_MULTICAST_ADDRESS` line in `.env`, and make sure that the multicast address there matches what you have set in WSJT-X. e.g. `224.0.0.1`
+
+You will need to restart OpenHamCLock after this change.
+
**Cloud setup (OpenHamClock on a remote server):**
WSJT-X sends data over UDP, which only works on a local network. For cloud deployments (like Railway or openhamclock.com), you need the WSJT-X Relay Agent to bridge the gap. See the [WSJT-X Relay Agent](#wsjt-x-relay-agent) section below.
@@ -773,11 +781,12 @@ All configuration is done through the `.env` file. On first run, this file is au
### WSJT-X Integration
-| Variable | Default | Description |
-| ----------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
-| `WSJTX_ENABLED` | `true` | Enable the WSJT-X UDP listener on the server. |
-| `WSJTX_UDP_PORT` | `2237` | UDP port for receiving WSJT-X decoded messages. Must match the port configured in WSJT-X Settings → Reporting → UDP Server. |
-| `WSJTX_RELAY_KEY` | _(none)_ | Shared secret key for the WSJT-X relay agent. Required only for cloud deployments where WSJT-X can't reach the server directly over UDP. Pick any strong random string. |
+| Variable | Default | Description |
+| ------------------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `WSJTX_ENABLED` | `true` | Enable the WSJT-X UDP listener on the server. |
+| `WSJTX_MULTICAST_ADDRESS` | _(none)_ | Multicast address to listen for messages |
+| `WSJTX_UDP_PORT` | `2237` | UDP port for receiving WSJT-X decoded messages. Must match the port configured in WSJT-X Settings → Reporting → UDP Server. |
+| `WSJTX_RELAY_KEY` | _(none)_ | Shared secret key for the WSJT-X relay agent. Required only for cloud deployments where WSJT-X can't reach the server directly over UDP. Pick any strong random string. |
### DX Cluster
@@ -1266,11 +1275,15 @@ OpenHamClock is built by the ham radio community. We have 28+ contributors and g
```bash
git clone https://github.com/accius/openhamclock.git
-cd openhamclock && npm install
+cd openhamclock
+git checkout Staging
+npm ci
node server.js # Terminal 1 — Backend on :3001
npm run dev # Terminal 2 — Frontend on :3000
```
+Open pull requests against `Staging`, not `main`.
+
**Read first:**
- **[docs/ARCHITECTURE.md](docs/ARCHITECTURE.md)** — Full codebase map and key patterns
diff --git a/package.json b/package.json
index 0f865cc9..856dabe3 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "openhamclock",
- "version": "15.6.4",
+ "version": "15.6.5",
"description": "Amateur Radio Dashboard - A modern web-based HamClock alternative",
"main": "electron/main.js",
"scripts": {
diff --git a/rig-bridge/README.md b/rig-bridge/README.md
index db7967c5..f64f3a9c 100644
--- a/rig-bridge/README.md
+++ b/rig-bridge/README.md
@@ -18,6 +18,16 @@ Built on a **plugin architecture** — each radio integration is a standalone mo
Also works with **Elecraft** radios (K3, K4, KX3, KX2) using the Kenwood plugin.
+### SDR Radios via TCI (WebSocket)
+
+TCI (Transceiver Control Interface) is a WebSocket-based protocol used by modern SDR applications. Unlike serial CAT, TCI **pushes** frequency, mode, and PTT changes in real-time — no polling, no serial port conflicts.
+
+| Application | Radios | Default TCI Port |
+| ------------- | --------------------- | ---------------- |
+| **Thetis** | Hermes Lite 2, ANAN | 40001 |
+| **ExpertSDR** | SunSDR2 | 40001 |
+| **SmartSDR** | Flex (via TCI bridge) | varies |
+
### Via Control Software (Legacy)
| Software | Protocol | Default Port |
@@ -25,6 +35,14 @@ Also works with **Elecraft** radios (K3, K4, KX3, KX2) using the Kenwood plugin.
| **flrig** | XML-RPC | 12345 |
| **rigctld** | TCP | 4532 |
+### For Testing (No Hardware Required)
+
+| Type | Description |
+| ------------------- | -------------------------------------------------------------------- |
+| **Simulated Radio** | Fake radio that drifts through several bands — no serial port needed |
+
+Enable by setting `radio.type = "mock"` in `rig-bridge-config.json` or selecting **Simulated Radio** in the setup UI.
+
---
## Quick Start
@@ -75,6 +93,124 @@ node rig-bridge.js --debug # Enable raw hex/ASCII CAT traffic logging
1. Connect USB cable from radio to computer
2. In Rig Bridge: Select **Kenwood**, pick COM port, baud **9600**, stop bits **1**
+### SDR Radios via TCI
+
+#### 1. Enable TCI in your SDR application
+
+**Thetis (HL2 / ANAN):** Setup → CAT Control → check **Enable TCI Server** (default port 40001)
+
+**ExpertSDR:** Settings → TCI → Enable (default port 40001)
+
+#### 2. Configure rig-bridge
+
+Edit `rig-bridge-config.json`:
+
+```json
+{
+ "radio": { "type": "tci" },
+ "tci": {
+ "host": "localhost",
+ "port": 40001,
+ "trx": 0,
+ "vfo": 0
+ }
+}
+```
+
+| Field | Description | Default |
+| ------ | -------------------------------- | ----------- |
+| `host` | Host running the SDR application | `localhost` |
+| `port` | TCI WebSocket port | `40001` |
+| `trx` | Transceiver index (0 = primary) | `0` |
+| `vfo` | VFO index (0 = VFO-A, 1 = VFO-B) | `0` |
+
+#### 3. Run rig-bridge
+
+```bash
+node rig-bridge.js
+```
+
+You should see:
+
+```
+[TCI] Connecting to ws://localhost:40001...
+[TCI] ✅ Connected to ws://localhost:40001
+[TCI] Device: Thetis
+[TCI] Server ready
+```
+
+The bridge auto-reconnects every 5 s if the connection drops — just restart your SDR app and it will reconnect automatically.
+
+---
+
+## WSJT-X Relay
+
+The WSJT-X Relay is an **integration plugin** (not a radio plugin) that listens for WSJT-X UDP packets on the local machine and forwards decoded messages to an OpenHamClock server in real-time. This lets OpenHamClock display your FT8/FT4 decodes as DX spots without any manual intervention.
+
+### Setup
+
+Edit `rig-bridge-config.json`:
+
+```json
+{
+ "wsjtxRelay": {
+ "enabled": true,
+ "url": "https://openhamclock.com",
+ "key": "your-relay-key",
+ "session": "your-session-id",
+ "udpPort": 2237,
+ "batchInterval": 2000,
+ "verbose": false,
+ "multicast": false,
+ "multicastGroup": "224.0.0.1",
+ "multicastInterface": ""
+ }
+}
+```
+
+| Field | Description | Default |
+| -------------------- | ------------------------------------------------------- | -------------------------- |
+| `enabled` | Activate the relay on startup | `false` |
+| `url` | OpenHamClock server URL | `https://openhamclock.com` |
+| `key` | Relay authentication key (from your OHC account) | — |
+| `session` | Browser session ID for per-user isolation | — |
+| `udpPort` | UDP port WSJT-X is sending to | `2237` |
+| `batchInterval` | How often decoded messages are sent (ms) | `2000` |
+| `verbose` | Log every decoded message to the console | `false` |
+| `multicast` | Join a UDP multicast group to receive WSJT-X packets | `false` |
+| `multicastGroup` | Multicast group IP address to join | `224.0.0.1` |
+| `multicastInterface` | Local NIC IP for multi-homed systems; `""` = OS default | `""` |
+
+### In WSJT-X
+
+Make sure WSJT-X is configured to send UDP packets to `localhost` on the same port as `udpPort` (default `2237`):
+**File → Settings → Reporting → UDP Server → `127.0.0.1:2237`**
+
+The relay runs alongside your radio plugin — you can use direct USB or TCI at the same time.
+
+### Multicast Mode
+
+By default the relay uses **unicast** — WSJT-X sends packets directly to `127.0.0.1` and only this process receives them.
+
+If you want multiple applications on the same machine or LAN to receive WSJT-X packets simultaneously, enable multicast:
+
+1. In WSJT-X: **File → Settings → Reporting → UDP Server** — set the address to `224.0.0.1`
+2. In `rig-bridge-config.json` (or via the setup UI at `http://localhost:5555`):
+
+```json
+{
+ "wsjtxRelay": {
+ "multicast": true,
+ "multicastGroup": "224.0.0.1",
+ "multicastInterface": ""
+ }
+}
+```
+
+Leave `multicastInterface` blank unless you have multiple network adapters and need to specify which one to use (enter its local IP, e.g. `"192.168.1.100"`).
+
+> `224.0.0.1` is the WSJT-X conventional multicast group. It is link-local — packets are not routed across subnet boundaries.
+
---
## OpenHamClock Setup
@@ -109,15 +245,19 @@ Executables are output to the `dist/` folder.
## Troubleshooting
-| Problem | Solution |
-| ---------------------- | ------------------------------------------------------------------------- |
-| No COM ports found | Install USB driver (Silicon Labs CP210x for Yaesu, FTDI for some Kenwood) |
-| Port opens but no data | Check baud rate matches radio's CAT Rate setting |
-| Icom not responding | Verify CI-V address matches your radio model |
-| CORS errors in browser | The bridge allows all origins by default |
-| Port already in use | Close flrig/rigctld if running — you don't need them anymore |
-| PTT not responsive | Enable **Hardware Flow (RTS/CTS)** (especially for FT-991A/FT-710) |
-| macOS Comms Failure | The bridge automatically applies a `stty` fix for CP210x drivers. |
+| Problem | Solution |
+| ------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| No COM ports found | Install USB driver (Silicon Labs CP210x for Yaesu, FTDI for some Kenwood) |
+| Port opens but no data | Check baud rate matches radio's CAT Rate setting |
+| Icom not responding | Verify CI-V address matches your radio model |
+| CORS errors in browser | The bridge allows all origins by default |
+| Port already in use | Close flrig/rigctld if running — you don't need them anymore |
+| PTT not responsive | Enable **Hardware Flow (RTS/CTS)** (especially for FT-991A/FT-710) |
+| macOS Comms Failure | The bridge automatically applies a `stty` fix for CP210x drivers. |
+| TCI: Connection refused | Enable TCI in your SDR app (Thetis → Setup → CAT Control → Enable TCI Server) |
+| TCI: No frequency updates | Check `trx` / `vfo` index in config match the active transceiver in your SDR app |
+| TCI: Remote SDR | Set `tci.host` to the IP of the machine running the SDR application |
+| Multicast: no packets | Verify `multicastGroup` matches what WSJT-X sends to; check OS firewall allows multicast UDP; set `multicastInterface` to the correct NIC IP if multi-homed |
---
@@ -154,12 +294,15 @@ rig-bridge/
│
└── plugins/
├── usb/
- │ ├── index.js # USB serial lifecycle (open, reconnect, poll)
- │ ├── protocol-yaesu.js # Yaesu CAT ASCII protocol
- │ ├── protocol-kenwood.js# Kenwood ASCII protocol
- │ └── protocol-icom.js # Icom CI-V binary protocol
+ │ ├── index.js # USB serial lifecycle (open, reconnect, poll)
+ │ ├── protocol-yaesu.js # Yaesu CAT ASCII protocol
+ │ ├── protocol-kenwood.js # Kenwood ASCII protocol
+ │ └── protocol-icom.js # Icom CI-V binary protocol
+ ├── tci.js # TCI/SDR WebSocket plugin (Thetis, ExpertSDR, etc.)
├── rigctld.js # rigctld TCP plugin
- └── flrig.js # flrig XML-RPC plugin
+ ├── flrig.js # flrig XML-RPC plugin
+ ├── mock.js # Simulated radio for testing (no hardware needed)
+ └── wsjtx-relay.js # WSJT-X UDP listener → OpenHamClock relay
```
---
@@ -172,7 +315,7 @@ Each plugin exports an object with the following shape:
module.exports = {
id: 'my-plugin', // Unique identifier (matches config.radio.type)
name: 'My Plugin', // Human-readable name
- category: 'rig', // 'rig' | 'rotator' | 'logger' | 'other'
+ category: 'rig', // 'rig' | 'integration' | 'rotator' | 'logger' | 'other'
configKey: 'radio', // Which config section this plugin reads
create(config, { updateState, state }) {
@@ -205,6 +348,7 @@ module.exports = {
**Categories:**
- `rig` — radio control; the bridge dispatches `/freq`, `/mode`, `/ptt` to the active rig plugin
+- `integration` — background service plugins (e.g. WSJT-X relay); started via `registry.connectIntegrations()`
- `rotator`, `logger`, `other` — use `registerRoutes(app)` to expose their own endpoints
To register a plugin at startup, call `registry.register(descriptor)` in `rig-bridge.js` before `registry.connectActive()`.
diff --git a/rig-bridge/core/config.js b/rig-bridge/core/config.js
index 9861b9e4..306f1d97 100644
--- a/rig-bridge/core/config.js
+++ b/rig-bridge/core/config.js
@@ -12,10 +12,12 @@ const CONFIG_PATH = path.join(CONFIG_DIR, 'rig-bridge-config.json');
const DEFAULT_CONFIG = {
port: 5555,
+ bindAddress: '127.0.0.1', // Bind to localhost only; set to '0.0.0.0' for LAN access
+ corsOrigins: '', // Extra allowed CORS origins (comma-separated); OHC origins always allowed
debug: false, // Centralized verbose CAT logging flag
logging: true, // Enable/disable console log capture & broadcast to UI
radio: {
- type: 'none', // none | yaesu | kenwood | icom | flrig | rigctld
+ type: 'none', // none | yaesu | kenwood | icom | flrig | rigctld | tci
serialPort: '', // COM3, /dev/ttyUSB0, etc.
baudRate: 38400,
dataBits: 8,
@@ -33,6 +35,12 @@ const DEFAULT_CONFIG = {
flrigHost: '127.0.0.1',
flrigPort: 12345,
},
+ tci: {
+ host: 'localhost',
+ port: 40001,
+ trx: 0, // transceiver index (0 = primary)
+ vfo: 0, // VFO index (0 = A, 1 = B)
+ },
wsjtxRelay: {
enabled: false,
url: '', // OpenHamClock server URL (e.g. https://openhamclock.com)
@@ -41,6 +49,9 @@ const DEFAULT_CONFIG = {
udpPort: 2237, // UDP port to listen on for WSJT-X packets
batchInterval: 2000, // Batch send interval in ms
verbose: false, // Log all decoded messages
+ multicast: false, // Join a multicast group instead of unicast
+ multicastGroup: '224.0.0.1', // WSJT-X conventional multicast group
+ multicastInterface: '', // Local NIC IP for multi-homed systems; '' = let OS choose
},
};
@@ -63,6 +74,7 @@ function loadConfig() {
...DEFAULT_CONFIG,
...raw,
radio: { ...DEFAULT_CONFIG.radio, ...(raw.radio || {}) },
+ tci: { ...DEFAULT_CONFIG.tci, ...(raw.tci || {}) },
wsjtxRelay: { ...DEFAULT_CONFIG.wsjtxRelay, ...(raw.wsjtxRelay || {}) },
// Coerce logging to boolean in case the stored value is a string
logging: raw.logging !== undefined ? !!raw.logging : DEFAULT_CONFIG.logging,
@@ -87,6 +99,7 @@ function applyCliArgs() {
const args = process.argv.slice(2);
for (let i = 0; i < args.length; i++) {
if (args[i] === '--port') config.port = parseInt(args[++i]);
+ if (args[i] === '--bind') config.bindAddress = args[++i];
if (args[i] === '--debug') config.debug = true;
}
}
diff --git a/rig-bridge/core/plugin-registry.js b/rig-bridge/core/plugin-registry.js
index a8d052ab..ba77aead 100644
--- a/rig-bridge/core/plugin-registry.js
+++ b/rig-bridge/core/plugin-registry.js
@@ -47,7 +47,7 @@ class PluginRegistry {
}
// Single-export rig plugins
- for (const file of ['rigctld', 'flrig', 'mock']) {
+ for (const file of ['rigctld', 'flrig', 'mock', 'tci']) {
try {
const p = require(`../plugins/${file}`);
this._descriptors.set(p.id, p);
diff --git a/rig-bridge/core/server.js b/rig-bridge/core/server.js
index 2eb85bc7..ddb8eb1b 100644
--- a/rig-bridge/core/server.js
+++ b/rig-bridge/core/server.js
@@ -244,6 +244,8 @@ function buildSetupHtml(version) {
}
.icom-addr { display: none; }
.icom-addr.show { display: block; }
+ .tci-opts { display: none; }
+ .tci-opts.show { display: block; }
.ohc-instructions {
background: #0f1923;
border: 1px dashed #2a3040;
@@ -389,6 +391,9 @@ function buildSetupHtml(version) {
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Enable TCI in your SDR app: Thetis → Setup → CAT Control → Enable TCI Server (port 40001)
+
+
@@ -524,6 +554,28 @@ function buildSetupHtml(version) {
+
+
+ Enable Multicast
+
+
+ Join a UDP multicast group so multiple apps can receive WSJT-X packets simultaneously.
+ In WSJT-X set UDP Server to 224.0.0.1 instead of 127.0.0.1.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Status: —
@@ -592,7 +644,11 @@ function buildSetupHtml(version) {
document.getElementById('wsjtxSession').value = w.session || '';
document.getElementById('wsjtxPort').value = w.udpPort || 2237;
document.getElementById('wsjtxInterval').value = w.batchInterval || 2000;
+ document.getElementById('wsjtxMulticast').checked = !!w.multicast;
+ document.getElementById('wsjtxMulticastGroup').value = w.multicastGroup || '224.0.0.1';
+ document.getElementById('wsjtxMulticastInterface').value = w.multicastInterface || '';
toggleWsjtxOpts();
+ toggleWsjtxMulticastOpts();
}
function toggleWsjtxOpts() {
@@ -600,6 +656,11 @@ function buildSetupHtml(version) {
document.getElementById('wsjtxOpts').style.display = enabled ? 'block' : 'none';
}
+ function toggleWsjtxMulticastOpts() {
+ const on = document.getElementById('wsjtxMulticast').checked;
+ document.getElementById('wsjtxMulticastOpts').style.display = on ? 'block' : 'none';
+ }
+
async function saveIntegrations() {
const wsjtxRelay = {
enabled: document.getElementById('wsjtxEnabled').checked,
@@ -608,6 +669,9 @@ function buildSetupHtml(version) {
session: document.getElementById('wsjtxSession').value.trim(),
udpPort: parseInt(document.getElementById('wsjtxPort').value) || 2237,
batchInterval: parseInt(document.getElementById('wsjtxInterval').value) || 2000,
+ multicast: document.getElementById('wsjtxMulticast').checked,
+ multicastGroup: document.getElementById('wsjtxMulticastGroup').value.trim() || '224.0.0.1',
+ multicastInterface: document.getElementById('wsjtxMulticastInterface').value.trim(),
};
try {
const res = await fetch('/api/config', {
@@ -684,6 +748,11 @@ function buildSetupHtml(version) {
r.type === 'rigctld' ? (r.rigctldHost || '127.0.0.1') : (r.flrigHost || '127.0.0.1');
document.getElementById('legacyPort').value =
r.type === 'rigctld' ? (r.rigctldPort || 4532) : (r.flrigPort || 12345);
+ const tci = cfg.tci || {};
+ document.getElementById('tciHost').value = tci.host || 'localhost';
+ document.getElementById('tciPort').value = tci.port || 40001;
+ document.getElementById('tciTrx').value = tci.trx ?? 0;
+ document.getElementById('tciVfo').value = tci.vfo ?? 0;
onTypeChange(true); // Don't overwrite loaded values with model defaults
}
@@ -691,10 +760,12 @@ function buildSetupHtml(version) {
const type = document.getElementById('radioType').value;
const isDirect = ['yaesu', 'kenwood', 'icom'].includes(type);
const isLegacy = ['flrig', 'rigctld'].includes(type);
+ const isTci = type === 'tci';
document.getElementById('serialOpts').className = 'serial-opts' + (isDirect ? ' show' : '');
document.getElementById('legacyOpts').className = 'legacy-opts' + (isLegacy ? ' show' : '');
document.getElementById('icomAddr').className = 'icom-addr' + (type === 'icom' ? ' show' : '');
+ document.getElementById('tciOpts').className = 'tci-opts' + (isTci ? ' show' : '');
if (!skipDefaults) {
if (type === 'yaesu') {
@@ -787,11 +858,23 @@ function buildSetupHtml(version) {
radio.flrigPort = parseInt(document.getElementById('legacyPort').value);
}
+ const tci = {
+ host: document.getElementById('tciHost').value.trim() || 'localhost',
+ port: parseInt(document.getElementById('tciPort').value) || 40001,
+ trx: Math.max(0, parseInt(document.getElementById('tciTrx').value) || 0),
+ vfo: Math.max(0, parseInt(document.getElementById('tciVfo').value) || 0),
+ };
+
+ if (type === 'tci') {
+ if (!tci.host) return showToast('TCI host cannot be empty', 'error');
+ if (tci.port < 1 || tci.port > 65535) return showToast('TCI port must be 1–65535', 'error');
+ }
+
try {
const res = await fetch('/api/config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ radio }),
+ body: JSON.stringify({ radio, tci }),
});
const data = await res.json();
if (data.success) {
@@ -989,7 +1072,37 @@ function buildSetupHtml(version) {
function createServer(registry, version) {
const app = express();
- app.use(cors());
+
+ // SECURITY: Restrict CORS to OpenHamClock origins instead of wildcard.
+ // Wildcard CORS allows any website the user visits to silently call localhost:5555
+ // endpoints (including PTT) via the browser's fetch API.
+ const allowedOrigins = (config.corsOrigins || '')
+ .split(',')
+ .map((s) => s.trim())
+ .filter(Boolean);
+
+ // Always allow the local setup UI and common OHC origins
+ const defaultOrigins = [
+ `http://localhost:${config.port}`,
+ `http://127.0.0.1:${config.port}`,
+ 'http://localhost:3000',
+ 'http://127.0.0.1:3000',
+ 'https://openhamclock.com',
+ 'https://www.openhamclock.com',
+ ];
+ const origins = [...new Set([...defaultOrigins, ...allowedOrigins])];
+
+ app.use(
+ cors({
+ origin: (requestOrigin, callback) => {
+ // Allow requests with no origin (curl, Postman, server-to-server)
+ if (!requestOrigin) return callback(null, true);
+ if (origins.includes(requestOrigin)) return callback(null, true);
+ callback(null, false);
+ },
+ methods: ['GET', 'POST'],
+ }),
+ );
app.use(express.json());
// Allow plugins to register their own routes
@@ -1044,6 +1157,19 @@ function createServer(registry, version) {
});
// ─── API: Get/Set config ───
+
+ // Validate serial port paths to prevent arbitrary file access
+ const isValidSerialPort = (p) => {
+ if (!p || typeof p !== 'string') return false;
+ // Windows: COM1-COM256
+ if (/^COM\d{1,3}$/i.test(p)) return true;
+ // Linux: /dev/ttyUSB*, /dev/ttyACM*, /dev/ttyS*, /dev/ttyAMA*
+ if (/^\/dev\/tty(USB|ACM|S|AMA)\d+$/.test(p)) return true;
+ // macOS: /dev/cu.* or /dev/tty.*
+ if (/^\/dev\/(cu|tty)\.[A-Za-z0-9._-]+$/.test(p)) return true;
+ return false;
+ };
+
app.get('/api/config', (req, res) => {
res.json(config);
});
@@ -1052,6 +1178,10 @@ function createServer(registry, version) {
const newConfig = req.body;
if (newConfig.port) config.port = newConfig.port;
if (newConfig.radio) {
+ // Validate serial port path if provided
+ if (newConfig.radio.serialPort && !isValidSerialPort(newConfig.radio.serialPort)) {
+ return res.status(400).json({ success: false, error: 'Invalid serial port path' });
+ }
config.radio = { ...config.radio, ...newConfig.radio };
}
if (typeof newConfig.logging === 'boolean') {
@@ -1060,6 +1190,9 @@ function createServer(registry, version) {
if (newConfig.wsjtxRelay) {
config.wsjtxRelay = { ...config.wsjtxRelay, ...newConfig.wsjtxRelay };
}
+ if (newConfig.tci) {
+ config.tci = { ...config.tci, ...newConfig.tci };
+ }
// macOS: tty.* (dial-in) blocks open() — silently upgrade to cu.* (call-out)
if (process.platform === 'darwin' && config.radio.serialPort?.startsWith('/dev/tty.')) {
config.radio.serialPort = config.radio.serialPort.replace('/dev/tty.', '/dev/cu.');
@@ -1082,6 +1215,9 @@ function createServer(registry, version) {
// ─── API: Test serial port connection ───
app.post('/api/test', async (req, res) => {
const testPort = req.body.serialPort || config.radio.serialPort;
+ if (!isValidSerialPort(testPort)) {
+ return res.json({ success: false, error: 'Invalid serial port path' });
+ }
const testBaud = req.body.baudRate || config.radio.baudRate;
const testStopBits = req.body.stopBits || config.radio.stopBits || 1;
const testRtscts = req.body.rtscts !== undefined ? !!req.body.rtscts : !!config.radio.rtscts;
@@ -1183,7 +1319,13 @@ function createServer(registry, version) {
function startServer(port, registry, version) {
const app = createServer(registry, version);
- const server = app.listen(port, '0.0.0.0', () => {
+
+ // SECURITY: Bind to localhost by default. Set bindAddress to '0.0.0.0' in
+ // rig-bridge-config.json only if you need LAN access (e.g. bridge on a Pi,
+ // browser on a desktop).
+ const bindAddress = config.bindAddress || '127.0.0.1';
+
+ const server = app.listen(port, bindAddress, () => {
const versionLabel = `v${version}`.padEnd(8);
console.log('');
console.log(' ╔══════════════════════════════════════════════╗');
@@ -1191,6 +1333,9 @@ function startServer(port, registry, version) {
console.log(' ╠══════════════════════════════════════════════╣');
console.log(` ║ Setup UI: http://localhost:${port} ║`);
console.log(` ║ Radio: ${(config.radio.type || 'none').padEnd(30)}║`);
+ if (bindAddress !== '127.0.0.1') {
+ console.log(` ║ ⚠ Bound to ${bindAddress.padEnd(33)}║`);
+ }
console.log(' ╚══════════════════════════════════════════════╝');
console.log('');
});
diff --git a/rig-bridge/package-lock.json b/rig-bridge/package-lock.json
index 9c1d495f..d8028f8a 100644
--- a/rig-bridge/package-lock.json
+++ b/rig-bridge/package-lock.json
@@ -1,16 +1,17 @@
{
"name": "openhamclock-rig-bridge",
- "version": "1.1.0",
+ "version": "1.2.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openhamclock-rig-bridge",
- "version": "1.1.0",
+ "version": "1.2.0",
"dependencies": {
"cors": "^2.8.5",
"express": "^4.18.2",
"serialport": "^12.0.0",
+ "ws": "^8.14.2",
"xmlrpc": "^1.3.2"
},
"bin": {
@@ -2746,6 +2747,27 @@
"dev": true,
"license": "ISC"
},
+ "node_modules/ws": {
+ "version": "8.19.0",
+ "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
+ "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10.0.0"
+ },
+ "peerDependencies": {
+ "bufferutil": "^4.0.1",
+ "utf-8-validate": ">=5.0.2"
+ },
+ "peerDependenciesMeta": {
+ "bufferutil": {
+ "optional": true
+ },
+ "utf-8-validate": {
+ "optional": true
+ }
+ }
+ },
"node_modules/xmlbuilder": {
"version": "8.2.2",
"resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-8.2.2.tgz",
diff --git a/rig-bridge/package.json b/rig-bridge/package.json
index 007f6a96..dabfc28a 100644
--- a/rig-bridge/package.json
+++ b/rig-bridge/package.json
@@ -1,6 +1,6 @@
{
"name": "openhamclock-rig-bridge",
- "version": "1.1.0",
+ "version": "1.2.0",
"description": "Universal rig control bridge for OpenHamClock — plugin architecture supporting USB, flrig, rigctld, and more",
"main": "rig-bridge.js",
"bin": "rig-bridge.js",
@@ -24,6 +24,7 @@
"cors": "^2.8.5",
"express": "^4.18.2",
"serialport": "^12.0.0",
+ "ws": "^8.14.2",
"xmlrpc": "^1.3.2"
},
"devDependencies": {
diff --git a/rig-bridge/plugins/tci.js b/rig-bridge/plugins/tci.js
new file mode 100644
index 00000000..5bdde6c1
--- /dev/null
+++ b/rig-bridge/plugins/tci.js
@@ -0,0 +1,298 @@
+'use strict';
+/**
+ * plugins/tci.js — TCI (Transceiver Control Interface) WebSocket plugin
+ *
+ * Connects to an SDR application's TCI server via WebSocket and provides
+ * real-time rig control without polling. TCI pushes frequency, mode, and
+ * PTT changes as they happen.
+ *
+ * Supported applications:
+ * Thetis — Hermes Lite 2, ANAN (default port 40001)
+ * ExpertSDR — SunSDR2 (default port 40001)
+ * SmartSDR — Flex (via TCI bridge)
+ *
+ * Config key: config.tci { host, port, trx, vfo }
+ *
+ * TCI reference: https://github.com/ExpertSDR3/TCI
+ */
+
+// TCI mode name → OpenHamClock mode name
+const TCI_MODES = {
+ am: 'AM',
+ sam: 'SAM',
+ dsb: 'DSB',
+ lsb: 'LSB',
+ usb: 'USB',
+ cw: 'CW',
+ nfm: 'FM',
+ wfm: 'WFM',
+ digl: 'DATA-LSB',
+ digu: 'DATA-USB',
+ spec: 'SPEC',
+ drm: 'DRM',
+};
+
+// OpenHamClock mode name → TCI mode name
+const TCI_MODES_REV = {};
+for (const [tci, ohc] of Object.entries(TCI_MODES)) {
+ TCI_MODES_REV[ohc] = tci;
+}
+
+module.exports = {
+ id: 'tci',
+ name: 'TCI/SDR (WebSocket)',
+ category: 'rig',
+ configKey: 'tci',
+
+ create(config, { updateState, state }) {
+ // Resolve WebSocket implementation: prefer 'ws' npm package (works inside
+ // pkg snapshots), fall back to Node 21+ built-in WebSocket.
+ let WS;
+ let wsSource;
+ try {
+ WS = require('ws');
+ wsSource = 'ws npm';
+ } catch {
+ console.warn('[TCI] ws npm package not found (run: npm install) — falling back to native WebSocket');
+ if (typeof globalThis.WebSocket !== 'undefined') {
+ WS = globalThis.WebSocket;
+ wsSource = 'native (Node built-in)';
+ } else {
+ console.error('[TCI] WebSocket library not available. Run: npm install ws');
+ WS = null;
+ }
+ }
+ if (WS) console.log(`[TCI] WebSocket implementation: ${wsSource}`);
+
+ const tciCfg = config.tci || {};
+ const trx = tciCfg.trx ?? 0;
+ const vfo = tciCfg.vfo ?? 0;
+ const host = tciCfg.host || 'localhost';
+ const port = tciCfg.port || 40001;
+ const url = `ws://${host}:${port}`;
+
+ let ws = null;
+ let reconnectTimer = null;
+ let wasExplicitlyDisconnected = false;
+ let msgBuffer = ''; // TCI messages end with ';', may arrive chunked
+
+ function parseMessage(msg) {
+ // Accumulate into buffer; split on ';' delimiter
+ msgBuffer += msg;
+ const parts = msgBuffer.split(';');
+ // Last element is either empty (complete) or a partial message
+ msgBuffer = parts.pop();
+
+ for (const raw of parts) {
+ const trimmed = raw.trim();
+ if (!trimmed) continue;
+
+ // Format: "name:arg1,arg2,..." or just "name"
+ const colonIdx = trimmed.indexOf(':');
+ const name = colonIdx >= 0 ? trimmed.slice(0, colonIdx).toLowerCase() : trimmed.toLowerCase();
+ const argStr = colonIdx >= 0 ? trimmed.slice(colonIdx + 1) : '';
+ const args = argStr ? argStr.split(',') : [];
+
+ switch (name) {
+ case 'vfo': {
+ // vfo:rx,sub_vfo,freq_hz
+ const rxIdx = parseInt(args[0]);
+ const vfoIdx = parseInt(args[1]);
+ if (rxIdx === trx && vfoIdx === vfo) {
+ const freq = parseInt(args[2]);
+ if (freq > 0 && state.freq !== freq) {
+ console.log(`[TCI] freq → ${(freq / 1e6).toFixed(6)} MHz`);
+ updateState('freq', freq);
+ }
+ }
+ break;
+ }
+ case 'modulation': {
+ // modulation:rx,mode_name
+ const rxIdx = parseInt(args[0]);
+ if (rxIdx === trx) {
+ const modeName = (args[1] || '').toLowerCase();
+ const ohcMode = TCI_MODES[modeName] || modeName.toUpperCase();
+ if (state.mode !== ohcMode) {
+ console.log(`[TCI] mode → ${ohcMode}`);
+ updateState('mode', ohcMode);
+ }
+ }
+ break;
+ }
+ case 'trx': {
+ // trx:rx,true|false — transmit state
+ const rxIdx = parseInt(args[0]);
+ if (rxIdx === trx) {
+ const ptt = args[1] === 'true';
+ if (state.ptt !== ptt) {
+ console.log(`[TCI] PTT → ${ptt ? 'TX' : 'RX'}`);
+ updateState('ptt', ptt);
+ }
+ }
+ break;
+ }
+ case 'rx_filter_band': {
+ // rx_filter_band:rx,low_hz,high_hz
+ const rxIdx = parseInt(args[0]);
+ if (rxIdx === trx) {
+ const lo = parseInt(args[1]);
+ const hi = parseInt(args[2]);
+ const width = hi - lo;
+ if (width > 0 && state.width !== width) updateState('width', width);
+ }
+ break;
+ }
+ case 'protocol':
+ console.log(`[TCI] Server protocol: ${argStr}`);
+ break;
+ case 'device':
+ console.log(`[TCI] Device: ${argStr}`);
+ break;
+ case 'receive_only':
+ if (args[0] === 'true') {
+ console.log('[TCI] ⚠️ Radio is in receive-only mode (PTT disabled server-side)');
+ }
+ break;
+ case 'ready':
+ console.log('[TCI] Server ready');
+ break;
+ // Silently ignore high-volume / irrelevant TCI messages
+ case 'iq_samplerate':
+ case 'audio_samplerate':
+ case 'iq_start':
+ case 'iq_stop':
+ case 'audio_start':
+ case 'audio_stop':
+ case 'spot':
+ case 'drive':
+ case 'sql_enable':
+ case 'mute':
+ case 'rx_enable':
+ case 'sensors_enable':
+ case 'cw_macros_speed':
+ case 'volume':
+ case 'rx_smeter':
+ break;
+ default:
+ // Uncomment for debugging unknown TCI messages:
+ // console.log(`[TCI] Unhandled: ${trimmed}`);
+ break;
+ }
+ }
+ }
+
+ function send(data) {
+ if (!ws || ws.readyState !== 1 /* OPEN */) return false;
+ try {
+ ws.send(data);
+ return true;
+ } catch (e) {
+ console.error(`[TCI] Send error: ${e.message}`);
+ return false;
+ }
+ }
+
+ function scheduleReconnect() {
+ if (reconnectTimer || wasExplicitlyDisconnected) return;
+ reconnectTimer = setTimeout(() => {
+ reconnectTimer = null;
+ connect(); // eslint-disable-line no-use-before-define
+ }, 5000);
+ }
+
+ function connect() {
+ if (ws || wasExplicitlyDisconnected) return;
+ if (!WS) return;
+
+ console.log(`[TCI] Connecting to ${url}...`);
+ try {
+ // perMessageDeflate disabled for compatibility with non-standard TCI servers
+ // (e.g. Thetis) that may not handle WebSocket extension negotiation correctly.
+ ws = new WS(url, wsSource === 'ws npm' ? { perMessageDeflate: false } : undefined);
+ } catch (e) {
+ console.error(`[TCI] Connection failed: ${e.message}`);
+ scheduleReconnect();
+ return;
+ }
+
+ // Use addEventListener — works on both 'ws' npm lib AND Node 21+ native
+ // WebSocket. (.on() is ws-library-only and crashes with native WebSocket.)
+ ws.addEventListener('open', () => {
+ console.log(`[TCI] ✅ Connected to ${url}`);
+ msgBuffer = '';
+ updateState('connected', true);
+ // Initiate TCI session — server will send device info, then state dump
+ ws.send('start;');
+ });
+
+ ws.addEventListener('message', (evt) => {
+ // ws lib: evt is the data directly (string or Buffer)
+ // native WebSocket: evt is a MessageEvent with .data property
+ const raw = evt.data !== undefined ? evt.data : evt;
+ const msg = typeof raw === 'string' ? raw : raw.toString('utf8');
+ parseMessage(msg);
+ });
+
+ ws.addEventListener('error', (evt) => {
+ // 'error' fires before 'close' — just log; reconnect happens on 'close'
+ const err = evt.error || evt;
+ const msg = (err && err.message) || '';
+ if (err && err.code === 'ECONNREFUSED') {
+ console.error('[TCI] Connection refused — is the SDR app running with TCI enabled?');
+ } else if (msg.toLowerCase().includes('sec-websocket-accept') || msg.toLowerCase().includes('incorrect hash')) {
+ console.error('[TCI] WebSocket handshake rejected by server (invalid Sec-WebSocket-Accept).');
+ console.error(
+ '[TCI] Possible causes: TCI not enabled in SDR app, incompatible SDR version, or ws npm package not installed (run: npm install).',
+ );
+ console.error(`[TCI] Active WebSocket implementation: ${wsSource}`);
+ } else {
+ console.error(`[TCI] Error: ${msg || 'connection error'}`);
+ }
+ });
+
+ ws.addEventListener('close', () => {
+ console.log('[TCI] Disconnected');
+ ws = null;
+ updateState('connected', false);
+ scheduleReconnect();
+ });
+ }
+
+ function disconnect() {
+ wasExplicitlyDisconnected = true;
+ if (reconnectTimer) {
+ clearTimeout(reconnectTimer);
+ reconnectTimer = null;
+ }
+ if (ws) {
+ try {
+ ws.send('stop;');
+ ws.close();
+ } catch (e) {}
+ ws = null;
+ }
+ msgBuffer = '';
+ updateState('connected', false);
+ console.log('[TCI] Disconnected');
+ }
+
+ function setFreq(hz) {
+ console.log(`[TCI] SET FREQ: ${(hz / 1e6).toFixed(6)} MHz`);
+ send(`VFO:${trx},${vfo},${hz};`);
+ }
+
+ function setMode(mode) {
+ console.log(`[TCI] SET MODE: ${mode}`);
+ const tciMode = TCI_MODES_REV[mode] || TCI_MODES_REV[mode.toUpperCase()] || mode.toLowerCase();
+ send(`MODULATION:${trx},${tciMode};`);
+ }
+
+ function setPTT(on) {
+ console.log(`[TCI] SET PTT: ${on ? 'TX' : 'RX'}`);
+ send(`TRX:${trx},${on};`);
+ }
+
+ return { connect, disconnect, setFreq, setMode, setPTT };
+ },
+};
diff --git a/rig-bridge/plugins/wsjtx-relay.js b/rig-bridge/plugins/wsjtx-relay.js
index af813604..47482895 100644
--- a/rig-bridge/plugins/wsjtx-relay.js
+++ b/rig-bridge/plugins/wsjtx-relay.js
@@ -10,9 +10,12 @@
* url string OpenHamClock server URL (e.g. https://openhamclock.com)
* key string Relay authentication key
* session string Browser session ID for per-user isolation
- * udpPort number UDP port to listen on (default: 2237)
- * batchInterval number Batch send interval in ms (default: 2000)
- * verbose boolean Log all decoded messages (default: false)
+ * udpPort number UDP port to listen on (default: 2237)
+ * batchInterval number Batch send interval in ms (default: 2000)
+ * verbose boolean Log all decoded messages (default: false)
+ * multicast boolean Join a multicast group (default: false)
+ * multicastGroup string Multicast group IP (default: '224.0.0.1')
+ * multicastInterface string Local NIC IP for multi-homed systems; '' = OS default
*/
const dgram = require('dgram');
@@ -249,6 +252,10 @@ const descriptor = {
const serverUrl = (cfg.url || '').replace(/\/$/, '');
const relayEndpoint = `${serverUrl}/api/wsjtx/relay`;
+ const mcEnabled = !!cfg.multicast;
+ const mcGroup = cfg.multicastGroup || '224.0.0.1';
+ const mcInterface = cfg.multicastInterface || undefined; // undefined → OS picks NIC
+
let socket = null;
let batchTimer = null;
let heartbeatInterval = null;
@@ -394,6 +401,26 @@ const descriptor = {
return;
}
+ // SECURITY: Validate relay URL to prevent SSRF via config API.
+ // The relay should only POST to legitimate OpenHamClock servers, not internal services.
+ try {
+ const parsed = new URL(cfg.url);
+ if (!['http:', 'https:'].includes(parsed.protocol)) {
+ console.error(`[WsjtxRelay] Blocked: only http/https URLs allowed (got ${parsed.protocol})`);
+ return;
+ }
+ const host = parsed.hostname.toLowerCase();
+ const blockedHosts =
+ /^(localhost|127\.|10\.|172\.(1[6-9]|2\d|3[01])\.|192\.168\.|169\.254\.|0\.|::1|fe80:|fc00:|fd00:)/;
+ if (blockedHosts.test(host)) {
+ console.error(`[WsjtxRelay] Blocked: relay URL must not point to a private/internal address (${host})`);
+ return;
+ }
+ } catch (e) {
+ console.error(`[WsjtxRelay] Invalid relay URL: ${e.message}`);
+ return;
+ }
+
const udpPort = cfg.udpPort || 2237;
socket = dgram.createSocket('udp4');
@@ -426,6 +453,19 @@ const descriptor = {
console.log(`[WsjtxRelay] Listening for WSJT-X on UDP ${addr.address}:${addr.port}`);
console.log(`[WsjtxRelay] Relaying to ${serverUrl}`);
+ if (mcEnabled) {
+ try {
+ socket.addMembership(mcGroup, mcInterface);
+ const ifaceLabel = mcInterface || '0.0.0.0 (OS default)';
+ console.log(`[WsjtxRelay] Joined multicast group ${mcGroup} on interface ${ifaceLabel}`);
+ } catch (err) {
+ console.error(`[WsjtxRelay] Failed to join multicast group ${mcGroup}: ${err.message}`);
+ console.error(
+ `[WsjtxRelay] Falling back to unicast — check that ${mcGroup} is a valid multicast address and your OS supports multicast on this interface`,
+ );
+ }
+ }
+
scheduleBatch();
// Initial health check then heartbeat
@@ -469,6 +509,15 @@ const descriptor = {
healthInterval = null;
}
if (socket) {
+ if (mcEnabled) {
+ try {
+ socket.dropMembership(mcGroup, mcInterface);
+ console.log(`[WsjtxRelay] Left multicast group ${mcGroup}`);
+ } catch (err) {
+ // Socket may already be closing or membership was never joined — safe to ignore
+ console.error(`[WsjtxRelay] dropMembership failed (non-fatal): ${err.message}`);
+ }
+ }
try {
socket.close();
} catch (e) {}
@@ -488,6 +537,8 @@ const descriptor = {
consecutiveErrors,
udpPort: cfg.udpPort || 2237,
serverUrl,
+ multicast: mcEnabled,
+ multicastGroup: mcEnabled ? mcGroup : null,
};
}
diff --git a/rig-bridge/rig-bridge-config.example.json b/rig-bridge/rig-bridge-config.example.json
index 14882186..8e52467b 100644
--- a/rig-bridge/rig-bridge-config.example.json
+++ b/rig-bridge/rig-bridge-config.example.json
@@ -1,5 +1,7 @@
{
"port": 5555,
+ "bindAddress": "127.0.0.1",
+ "corsOrigins": "",
"logging": true,
"radio": {
"type": "none",
@@ -26,6 +28,9 @@
"session": "",
"udpPort": 2237,
"batchInterval": 2000,
- "verbose": false
+ "verbose": false,
+ "multicast": false,
+ "multicastGroup": "224.0.0.1",
+ "multicastInterface": ""
}
}
diff --git a/rig-bridge/rig-bridge.js b/rig-bridge/rig-bridge.js
index 585d13d4..1aaa3828 100644
--- a/rig-bridge/rig-bridge.js
+++ b/rig-bridge/rig-bridge.js
@@ -1,6 +1,6 @@
#!/usr/bin/env node
/**
- * OpenHamClock Rig Bridge v1.1.0
+ * OpenHamClock Rig Bridge v1.2.0
*
* Universal bridge connecting radios and other ham radio services to OpenHamClock.
* Uses a plugin architecture — each integration is a standalone module.
@@ -19,7 +19,7 @@
'use strict';
-const VERSION = '1.1.0';
+const VERSION = '1.2.0';
const { config, loadConfig, applyCliArgs } = require('./core/config');
const { updateState, state } = require('./core/state');
@@ -46,6 +46,7 @@ Usage:
Options:
--port HTTP port for setup UI (default: 5555)
+ --bind Bind address (default: 127.0.0.1, use 0.0.0.0 for LAN)
--debug Enable verbose CAT protocol logging
--version, -v Print version and exit
--help, -h Show this help message
@@ -53,6 +54,7 @@ Options:
Examples:
node rig-bridge.js
node rig-bridge.js --port 8080 --debug
+ node rig-bridge.js --bind 0.0.0.0 # Allow LAN access
`);
process.exit(0);
}
diff --git a/scripts/setup-pi.sh b/scripts/setup-pi.sh
index 2b298e4f..6f81b25d 100755
--- a/scripts/setup-pi.sh
+++ b/scripts/setup-pi.sh
@@ -189,7 +189,7 @@ update_system() {
# Install Node.js
install_nodejs() {
echo -e "${BLUE}>>> Installing Node.js ${NODE_VERSION}...${NC}"
-
+
# Check if Node.js is already installed
if command -v node &> /dev/null; then
CURRENT_VERSION=$(node -v | cut -d'v' -f2 | cut -d'.' -f1)
@@ -198,14 +198,55 @@ install_nodejs() {
return
fi
fi
-
- # Install Node.js via NodeSource
- curl -fsSL https://deb.nodesource.com/setup_${NODE_VERSION}.x | sudo -E bash - || {
- echo -e "${RED}✗ NodeSource setup failed. Check your Debian version and internet connection.${NC}"
- exit 1
- }
- sudo apt-get install -y nodejs
-
+
+ ARCH=$(dpkg --print-architecture 2>/dev/null || uname -m)
+
+ if [ "$ARCH" = "armhf" ]; then
+ # NodeSource dropped 32-bit ARM (armhf) support from Node.js 20 onwards.
+ # The official nodejs.org project still publishes armv7l tarballs, so we
+ # download and install those directly instead.
+ echo -e "${YELLOW}⚠ 32-bit ARM (armhf) detected — NodeSource does not support this architecture.${NC}"
+ echo -e "${BLUE} Downloading official Node.js ${NODE_VERSION} armv7l binary from nodejs.org...${NC}"
+
+ NODE_DIST_BASE="https://nodejs.org/dist/latest-v${NODE_VERSION}.x"
+ NODE_TARBALL=$(curl -fsSL "$NODE_DIST_BASE/" \
+ | grep -o "node-v[0-9.]*-linux-armv7l\.tar\.gz" \
+ | head -1)
+
+ if [ -z "$NODE_TARBALL" ]; then
+ echo -e "${RED}✗ Could not locate a Node.js ${NODE_VERSION} armv7l release on nodejs.org.${NC}"
+ exit 1
+ fi
+
+ # Download to a temp file with retry support.
+ # Piping curl directly into tar gives no retry opportunity on a
+ # dropped connection; saving to disk first lets curl resume/retry
+ # and keeps extraction separate so errors are easier to diagnose.
+ echo -e "${BLUE} Installing $NODE_TARBALL ...${NC}"
+ NODE_TMPFILE=$(mktemp /tmp/nodejs-armv7l-XXXXXX.tar.gz)
+ curl -fsSL \
+ --retry 3 --retry-delay 5 --retry-connrefused \
+ "$NODE_DIST_BASE/$NODE_TARBALL" \
+ -o "$NODE_TMPFILE" || {
+ rm -f "$NODE_TMPFILE"
+ echo -e "${RED}✗ Failed to download Node.js armv7l binary (tried 3 times).${NC}"
+ exit 1
+ }
+ sudo tar -xz -C /usr/local --strip-components=1 -f "$NODE_TMPFILE" || {
+ rm -f "$NODE_TMPFILE"
+ echo -e "${RED}✗ Failed to extract Node.js armv7l binary.${NC}"
+ exit 1
+ }
+ rm -f "$NODE_TMPFILE"
+ else
+ # amd64 and arm64 are supported by NodeSource.
+ curl -fsSL https://deb.nodesource.com/setup_${NODE_VERSION}.x | sudo -E bash - || {
+ echo -e "${RED}✗ NodeSource setup failed. Check your Debian version and internet connection.${NC}"
+ exit 1
+ }
+ sudo apt-get install -y nodejs
+ fi
+
echo -e "${GREEN}✓ Node.js $(node -v) installed${NC}"
}
@@ -249,15 +290,24 @@ setup_repository() {
# Prevent file permission changes from blocking future updates
git config core.fileMode false 2>/dev/null
- # Install npm dependencies
- npm install --include=dev
-
+ # Install npm dependencies.
+ # --ignore-scripts skips lifecycle hooks (postinstall, prepare, etc.) that are
+ # irrelevant or harmful on ARM Linux — most notably electron-winstaller's
+ # postinstall, which tries to copy vendor/7z-arm.exe and fails on a Pi because
+ # that Windows-only file is not shipped for Linux targets.
+ # Husky git-hooks (prepare) are also skipped, which is fine on a production Pi.
+ ELECTRON_SKIP_BINARY_DOWNLOAD=1 npm install --include=dev --ignore-scripts
+
# Download vendor assets (fonts, Leaflet) for self-hosting — no external CDN requests
echo -e "${BLUE}>>> Downloading vendor assets for privacy...${NC}"
bash scripts/vendor-download.sh || echo -e "${YELLOW}⚠ Vendor download failed — will fall back to CDN${NC}"
-
+
# Build frontend for production
npm run build
+
+ # Remove dev dependencies (electron, electron-builder, etc.) after the build.
+ # This frees ~500 MB of node_modules that are not needed at runtime on the Pi.
+ npm prune --omit=dev
# Make update script executable
chmod +x scripts/update.sh 2>/dev/null || true
diff --git a/server.js b/server.js
index cde999a3..065da79d 100644
--- a/server.js
+++ b/server.js
@@ -27,6 +27,7 @@ const net = require('net');
const dgram = require('dgram');
const fs = require('fs');
const { execFile, spawn } = require('child_process');
+const dns = require('dns');
const mqttLib = require('mqtt');
const { initCtyData, getCtyData, lookupCall } = require('./src/server/ctydat.js');
@@ -93,10 +94,15 @@ const app = express();
const PORT = Number(process.env.PORT) || 3000;
const HOST = process.env.HOST || '0.0.0.0';
-// Trust first proxy (Railway, Docker, nginx, etc.) so rate limiting
-// uses X-Forwarded-For (real client IP) instead of the proxy's IP.
-// Without this, ALL users behind a reverse proxy share one rate limit bucket.
-app.set('trust proxy', 1);
+// Trust proxy setting — controls whether X-Forwarded-For headers are trusted.
+// Railway/Docker/nginx deployments need this for correct client IP detection.
+// Pi/local installs should NOT trust proxy headers since clients can forge them,
+// bypassing rate limiting by sending a different X-Forwarded-For with each request.
+// Default: trust proxy if running on Railway (PORT env is always set), otherwise don't.
+const TRUST_PROXY = process.env.TRUST_PROXY !== undefined
+ ? (process.env.TRUST_PROXY === 'true' || process.env.TRUST_PROXY === '1' ? 1 : false)
+ : (process.env.RAILWAY_ENVIRONMENT || process.env.RAILWAY_PROJECT_ID) ? 1 : false;
+app.set('trust proxy', TRUST_PROXY);
// Security: API key for write operations (set in .env to protect POST endpoints)
// If not set, write endpoints are open (backward-compatible for local installs)
@@ -407,11 +413,40 @@ app.use((req, res, next) => {
next();
});
-// CORS — restrict to same origin by default; allow override via env
-const CORS_ORIGINS = process.env.CORS_ORIGINS ? process.env.CORS_ORIGINS.split(',').map((s) => s.trim()) : true; // true = reflect request origin (same as before for local installs)
+// CORS — explicit origin allowlist to prevent malicious websites from accessing the API.
+// origin: true (the old default) reflects any requesting origin, which allows any website
+// the user visits to silently read their callsign, coordinates, QSO data, and (without
+// API_WRITE_KEY) write settings, move their rotator, or restart the server.
+const CORS_ORIGINS = process.env.CORS_ORIGINS
+ ? process.env.CORS_ORIGINS.split(',').map((s) => s.trim())
+ : null; // null = no extra origins, only defaults below
+
+const defaultOrigins = [
+ `http://localhost:${PORT}`,
+ `http://127.0.0.1:${PORT}`,
+ 'http://localhost:3000',
+ 'http://127.0.0.1:3000',
+ 'http://localhost:5173', // Vite dev server
+ 'http://127.0.0.1:5173',
+ 'https://openhamclock.com',
+ 'https://www.openhamclock.com',
+ 'https://openhamclock.app',
+ 'https://www.openhamclock.app',
+];
+const allowedOrigins = new Set([...defaultOrigins, ...(CORS_ORIGINS || [])]);
+
app.use(
cors({
- origin: CORS_ORIGINS,
+ origin: (requestOrigin, callback) => {
+ // Allow requests with no Origin header (curl, Postman, server-to-server, same-origin)
+ if (!requestOrigin) return callback(null, true);
+ if (allowedOrigins.has(requestOrigin)) return callback(null, true);
+ // Don't set CORS headers for unknown origins — the browser's same-origin policy
+ // will block cross-origin reads. Using callback(null, false) instead of throwing
+ // an Error avoids breaking same-origin static asset requests on Railway/staging
+ // where the deployment URL isn't in the allowlist.
+ callback(null, false);
+ },
methods: ['GET', 'POST'],
maxAge: 86400,
}),
@@ -865,7 +900,7 @@ app.get('/api/rotator/status', (req, res) => {
});
});
-app.post('/api/rotator/turn', async (req, res) => {
+app.post('/api/rotator/turn', writeLimiter, requireWriteAuth, async (req, res) => {
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
try {
const { azimuth } = req.body || {};
@@ -886,7 +921,7 @@ app.post('/api/rotator/turn', async (req, res) => {
}
});
-app.post('/api/rotator/stop', async (req, res) => {
+app.post('/api/rotator/stop', writeLimiter, requireWriteAuth, async (req, res) => {
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
try {
const result = await stopRotator();
@@ -1407,7 +1442,7 @@ const sessionTracker = {
duration: now - session.firstSeen,
durationFormatted: formatDuration(now - session.firstSeen),
requests: session.requests,
- ip: ip.replace(/\d+$/, 'x'), // Anonymize last octet
+ ip: ip.includes('.') ? ip.substring(0, ip.lastIndexOf('.') + 1) + 'x' : ip, // Anonymize last octet
});
}
activeList.sort((a, b) => b.duration - a.duration);
@@ -1446,8 +1481,9 @@ app.use((req, res, next) => {
rolloverVisitorStats();
// Track concurrent sessions for ALL requests (not just countable routes)
- const sessionIp =
- req.headers['x-forwarded-for']?.split(',')[0]?.trim() || req.ip || req.connection?.remoteAddress || 'unknown';
+ // Use req.ip which respects the trust proxy setting, not manual x-forwarded-for parsing
+ // which is trivially spoofable on installs without a reverse proxy.
+ const sessionIp = req.ip || req.connection?.remoteAddress || 'unknown';
if (req.path !== '/api/health' && !req.path.startsWith('/assets/')) {
sessionTracker.touch(sessionIp, req.headers['user-agent']);
}
@@ -1455,8 +1491,7 @@ app.use((req, res, next) => {
// Only count meaningful "visits" — initial page load or config fetch
const countableRoutes = ['/', '/index.html', '/api/config'];
if (countableRoutes.includes(req.path)) {
- const ip =
- req.headers['x-forwarded-for']?.split(',')[0]?.trim() || req.ip || req.connection?.remoteAddress || 'unknown';
+ const ip = req.ip || req.connection?.remoteAddress || 'unknown';
// Track today's visitors
const isNewToday = !todayIPSet.has(ip);
@@ -1477,7 +1512,7 @@ app.use((req, res, next) => {
visitorStats.allTimeVisitors++;
queueGeoIPLookup(ip);
logInfo(
- `[Stats] New visitor (#${visitorStats.uniqueIPsToday.length} today, #${visitorStats.allTimeVisitors} all-time) from ${ip.replace(/\d+$/, 'x')}`,
+ `[Stats] New visitor (#${visitorStats.uniqueIPsToday.length} today, #${visitorStats.allTimeVisitors} all-time) from ${ip.includes('.') ? ip.substring(0, ip.lastIndexOf('.') + 1) + 'x' : ip}`,
);
} else if (isNewToday) {
// Existing all-time visitor but new today — queue GeoIP in case cache was lost
@@ -2034,7 +2069,7 @@ app.get('/api/solar-indices', async (req, res) => {
});
// NASA SDO Solar Image Proxy — caches SDO/AIA images so clients don't hit NASA directly.
-// Multi-source: tries SDO direct first (works for self-hosters), then Helioviewer API (works from cloud).
+// Multi-source failover: SDO direct → LMSAL Sun Today (Lockheed) → Helioviewer API.
const sdoImageCache = new Map(); // key: imageType → { buffer, contentType, timestamp }
const SDO_CACHE_TTL = 15 * 60 * 1000; // 15 minutes
const SDO_STALE_SERVE = 6 * 60 * 60 * 1000; // Serve stale up to 6 hours
@@ -2099,6 +2134,31 @@ const fetchFromHelioviewer = async (type, timeoutMs = 20000) => {
}
};
+// Helper: fetch from LMSAL Sun Today (Lockheed Martin Solar & Astrophysics Lab)
+// Independent of Goddard infrastructure — useful when sdo.gsfc.nasa.gov is down.
+// URL pattern: t{type}.jpg = 256x256 thumbnail (AIA channels only, no HMI)
+const LMSAL_TYPES = new Set(['0193', '0304', '0171', '0094']);
+const fetchFromLMSAL = async (type, timeoutMs = 15000) => {
+ if (!LMSAL_TYPES.has(type)) throw new Error(`LMSAL does not serve ${type}`);
+ const url = `https://sdowww.lmsal.com/sdomedia/SunInTime/mostrecent/t${type}.jpg`;
+ const controller = new AbortController();
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
+ try {
+ const res = await fetch(url, {
+ headers: { 'User-Agent': `OpenHamClock/${APP_VERSION}` },
+ signal: controller.signal,
+ });
+ clearTimeout(timer);
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
+ const buffer = Buffer.from(await res.arrayBuffer());
+ if (buffer.length < 500) throw new Error(`Response too small (${buffer.length} bytes)`);
+ return { buffer, contentType: res.headers.get('content-type') || 'image/jpeg', source: 'LMSAL' };
+ } catch (e) {
+ clearTimeout(timer);
+ throw e;
+ }
+};
+
app.get('/api/solar/image/:type', async (req, res) => {
const type = req.params.type;
if (!SDO_VALID_TYPES.has(type)) {
@@ -2130,9 +2190,10 @@ app.get('/api/solar/image/:type', async (req, res) => {
return res.status(503).json({ error: 'SDO temporarily unavailable' });
}
- // Try sources in order: SDO direct → Helioviewer
+ // Try sources in order: SDO direct → LMSAL Sun Today → Helioviewer
const sources = [
{ name: 'SDO', fn: () => fetchFromSDO(type) },
+ { name: 'LMSAL', fn: () => fetchFromLMSAL(type) },
{ name: 'Helioviewer', fn: () => fetchFromHelioviewer(type) },
];
@@ -2891,8 +2952,13 @@ const DXSPIDER_NODES = [
const DXSPIDER_SSID = '-56'; // OpenHamClock SSID
function getDxClusterLoginCallsign(preferredCallsign = null) {
- const candidate = (preferredCallsign || CONFIG.dxClusterCallsign || '').trim();
+ // Strip control characters to prevent telnet command injection via query params
+ const candidate = (preferredCallsign || CONFIG.dxClusterCallsign || '').replace(/[\x00-\x1F\x7F]/g, '').trim();
if (candidate && candidate.toUpperCase() !== 'N0CALL') {
+ // Append default SSID if caller didn't include one
+ if (!candidate.includes('-')) {
+ return `${candidate.toUpperCase()}${DXSPIDER_SSID}`;
+ }
return candidate.toUpperCase();
}
@@ -2994,6 +3060,7 @@ const CUSTOM_DX_RETENTION_MS = 30 * 60 * 1000;
const CUSTOM_DX_MAX_SPOTS = 500;
const CUSTOM_DX_RECONNECT_DELAY_MS = 10000;
const CUSTOM_DX_KEEPALIVE_MS = 30000;
+const CUSTOM_DX_STALE_MS = 5 * 60 * 1000; // Force reconnect after 5 min with no data
const CUSTOM_DX_IDLE_TIMEOUT = 15 * 60 * 1000; // Reap sessions idle for 15 minutes
const customDxSessions = new Map();
@@ -3092,11 +3159,13 @@ function connectCustomSession(session) {
session.loginSent = false;
session.commandSent = false;
client.setTimeout(0);
+ client.setKeepAlive(true, 60000); // OS-level TCP keepalive probes every 60s
client.connect(session.node.port, session.node.host, () => {
session.connected = true;
session.connecting = false;
session.lastConnectedAt = Date.now();
+ session.lastDataAt = Date.now();
logDebug(
`[DX Cluster] DX Spider: connected to ${session.node.host}:${session.node.port} as ${session.loginCallsign}`,
);
@@ -3111,6 +3180,15 @@ function connectCustomSession(session) {
session.keepAliveTimer = setInterval(() => {
if (session.client && session.connected) {
+ // Force reconnect if no data received for CUSTOM_DX_STALE_MS
+ const silentMs = Date.now() - (session.lastDataAt || 0);
+ if (silentMs > CUSTOM_DX_STALE_MS) {
+ logWarn(
+ `[DX Cluster] No data from ${session.node.host} in ${Math.round(silentMs / 60000)} min — forcing reconnect`,
+ );
+ handleCustomSessionDisconnect(session);
+ return;
+ }
try {
session.client.write('\r\n');
} catch (e) {}
@@ -3119,6 +3197,7 @@ function connectCustomSession(session) {
});
client.on('data', (data) => {
+ session.lastDataAt = Date.now();
session.buffer += data.toString();
// Login prompt detection
@@ -3171,7 +3250,10 @@ function connectCustomSession(session) {
}
});
- client.on('timeout', () => {});
+ client.on('timeout', () => {
+ logWarn(`[DX Cluster] Socket timeout for ${session.node.host} — reconnecting`);
+ handleCustomSessionDisconnect(session);
+ });
client.on('error', (err) => {
if (
@@ -3211,6 +3293,7 @@ function getOrCreateCustomSession(node, userCallsign = null) {
reconnectTimer: null,
keepAliveTimer: null,
lastConnectedAt: 0,
+ lastDataAt: 0,
lastUsedAt: Date.now(),
cleanupTimer: null,
};
@@ -3592,6 +3675,64 @@ function parseSpotHHMMzToTimestamp(timeStr, fallbackTs = Date.now()) {
return ts;
}
+/**
+ * SSRF protection: resolve hostname to IP and reject private/reserved addresses.
+ * Returns the resolved IP so callers can connect to the IP directly, preventing
+ * DNS rebinding (TOCTOU) attacks where the record changes between validation and connect.
+ */
+function isPrivateIP(ip) {
+ // Normalize IPv4-mapped IPv6 (e.g. ::ffff:127.0.0.1 → 127.0.0.1)
+ const normalized = ip.replace(/^::ffff:/i, '');
+
+ // IPv4 private/reserved ranges
+ const parts = normalized.split('.').map(Number);
+ if (parts.length === 4 && parts.every((n) => n >= 0 && n <= 255)) {
+ if (parts[0] === 127) return true; // loopback
+ if (parts[0] === 10) return true; // 10.0.0.0/8
+ if (parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) return true; // 172.16.0.0/12
+ if (parts[0] === 192 && parts[1] === 168) return true; // 192.168.0.0/16
+ if (parts[0] === 169 && parts[1] === 254) return true; // link-local
+ if (parts[0] === 0) return true; // 0.0.0.0/8
+ if (parts[0] >= 224) return true; // multicast + reserved
+ }
+ // IPv6 private/reserved
+ const lower = normalized.toLowerCase();
+ if (lower === '::1' || lower === '::' ||
+ lower.startsWith('fe80:') || lower.startsWith('fc00:') ||
+ lower.startsWith('fd00:') || lower.startsWith('ff00:') ||
+ lower.startsWith('::ffff:')) {
+ // Catch any remaining IPv4-mapped forms that weren't normalized above
+ return true;
+ }
+ return false;
+}
+
+async function validateCustomHost(host) {
+ // Reject obvious localhost strings before DNS
+ if (/^localhost$/i.test(host)) return { ok: false, reason: 'localhost not allowed' };
+
+ // Resolve hostname to IPv4 addresses ONLY.
+ // We intentionally do not fall back to resolve6 because IPv6 has many equivalent
+ // representations for private addresses (e.g. ::ffff:7f00:1 = 127.0.0.1 in hex form)
+ // that bypass string-based checks. DX cluster telnet servers are IPv4.
+ let addresses;
+ try {
+ addresses = await dns.promises.resolve4(host);
+ } catch {
+ return { ok: false, reason: 'Host could not be resolved (IPv4 required for custom DX clusters)' };
+ }
+
+ // Check every resolved address — block if any resolve to private/reserved
+ for (const addr of addresses) {
+ if (isPrivateIP(addr)) {
+ return { ok: false, reason: 'Host resolves to a private/reserved address' };
+ }
+ }
+ // Return the first resolved IP so callers connect to the validated IP, not the hostname.
+ // This prevents DNS rebinding (TOCTOU) where the record changes between validation and connect.
+ return { ok: true, resolvedIP: addresses[0] };
+}
+
app.get('/api/dxcluster/paths', async (req, res) => {
// Parse query parameters for custom cluster settings
const source = req.query.source || 'auto';
@@ -3601,28 +3742,15 @@ app.get('/api/dxcluster/paths', async (req, res) => {
const userCallsign = (req.query.callsign || CONFIG.dxClusterCallsign || '').trim();
// SECURITY: Validate custom host to prevent SSRF (internal network scanning)
+ // Resolves DNS and returns the validated IP. We connect to the IP, not the hostname,
+ // to prevent DNS rebinding (TOCTOU) where the record changes between validation and connect.
+ let resolvedHost = customHost;
if (source === 'custom' && customHost) {
- // Block private/reserved IP ranges and localhost
- const blockedPatterns =
- /^(localhost|127\.|10\.|172\.(1[6-9]|2\d|3[01])\.|192\.168\.|169\.254\.|0\.|0:|\[::1\]|::1|fe80:|fc00:|fd00:|ff00:)/i;
- if (blockedPatterns.test(customHost)) {
- return res.status(400).json({ error: 'Custom host cannot be a private/reserved address' });
- }
- // Block numeric-only hosts (raw IPs) that could be encoded to bypass above
- // Only allow hostnames that look like legitimate DX Spider nodes
- if (/^\d+\.\d+\.\d+\.\d+$/.test(customHost)) {
- const octets = customHost.split('.').map(Number);
- if (
- octets[0] === 10 ||
- octets[0] === 127 ||
- octets[0] === 0 ||
- (octets[0] === 172 && octets[1] >= 16 && octets[1] <= 31) ||
- (octets[0] === 192 && octets[1] === 168) ||
- (octets[0] === 169 && octets[1] === 254)
- ) {
- return res.status(400).json({ error: 'Custom host cannot be a private/reserved address' });
- }
+ const hostCheck = await validateCustomHost(customHost);
+ if (!hostCheck.ok) {
+ return res.status(400).json({ error: `Custom host rejected: ${hostCheck.reason}` });
}
+ resolvedHost = hostCheck.resolvedIP; // Connect to the validated IP, not the hostname
// Restrict port range to common DX Spider/telnet ports
if (customPort < 1024 || customPort > 49151) {
return res.status(400).json({ error: 'Port must be between 1024 and 49151' });
@@ -3632,7 +3760,7 @@ app.get('/api/dxcluster/paths', async (req, res) => {
// Generate cache key based on source profile so custom/proxy/auto don't mix.
const cacheKey =
source === 'custom'
- ? `custom-${customHost}-${customPort}-${getDxClusterLoginCallsign(userCallsign)}`
+ ? `custom-${resolvedHost}-${customPort}-${getDxClusterLoginCallsign(userCallsign)}`
: `source-${source}`;
const pathsCache = getDxPathsCache(cacheKey);
@@ -3652,11 +3780,11 @@ app.get('/api/dxcluster/paths', async (req, res) => {
let usedSource = 'none';
// Handle custom telnet source (persistent connection, no reconnect-per-poll)
- if (source === 'custom' && customHost) {
+ if (source === 'custom' && resolvedHost) {
logDebug(
- `[DX Paths] Using custom telnet session: ${customHost}:${customPort} as ${getDxClusterLoginCallsign(userCallsign)}`,
+ `[DX Paths] Using custom telnet session: ${resolvedHost}:${customPort} as ${getDxClusterLoginCallsign(userCallsign)}`,
);
- const customNode = { host: customHost, port: customPort };
+ const customNode = { host: resolvedHost, port: customPort };
const session = getOrCreateCustomSession(customNode, userCallsign);
// Take the most recent spots from persistent session buffer.
const customSpots = (session.spots || []).slice(0, 100).map((s) => ({
@@ -4446,7 +4574,7 @@ app.get('/api/qrz/status', (req, res) => {
});
// POST /api/qrz/configure — save QRZ credentials (from Settings UI)
-app.post('/api/qrz/configure', writeLimiter, async (req, res) => {
+app.post('/api/qrz/configure', writeLimiter, requireWriteAuth, async (req, res) => {
const { username, password } = req.body || {};
if (!username || !password) {
@@ -4510,7 +4638,7 @@ app.post('/api/qrz/configure', writeLimiter, async (req, res) => {
});
// POST /api/qrz/remove — remove saved QRZ credentials
-app.post('/api/qrz/remove', writeLimiter, (req, res) => {
+app.post('/api/qrz/remove', writeLimiter, requireWriteAuth, (req, res) => {
qrzSession.username = CONFIG._qrzUsername || '';
qrzSession.password = CONFIG._qrzPassword || '';
qrzSession.key = null;
@@ -6561,7 +6689,28 @@ pskMqtt.cleanupInterval = setInterval(
// SSE endpoint — clients connect here for real-time spots
// ?type=grid subscribes by grid square instead of callsign
+
+// Per-IP connection limiter for SSE streams to prevent resource exhaustion.
+// Once an SSE connection is established it persists indefinitely, so the normal
+// request-rate limiter doesn't help. This caps concurrent open streams per IP.
+const MAX_SSE_PER_IP = parseInt(process.env.MAX_SSE_PER_IP || '10', 10);
+const sseConnectionsByIP = new Map();
+
app.get('/api/pskreporter/stream/:identifier', (req, res) => {
+ // Use req.ip which respects the trust proxy setting, consistent with express-rate-limit.
+ // Manual x-forwarded-for parsing is trivially spoofable on installs without a reverse proxy.
+ const ip = req.ip || req.connection?.remoteAddress || 'unknown';
+ const current = sseConnectionsByIP.get(ip) || 0;
+ if (current >= MAX_SSE_PER_IP) {
+ return res.status(429).json({ error: 'Too many open SSE connections from this IP' });
+ }
+ sseConnectionsByIP.set(ip, current + 1);
+ req.on('close', () => {
+ const count = sseConnectionsByIP.get(ip) || 1;
+ if (count <= 1) sseConnectionsByIP.delete(ip);
+ else sseConnectionsByIP.set(ip, count - 1);
+ });
+
const identifier = req.params.identifier.toUpperCase();
const type = (req.query.type || 'call').toLowerCase();
@@ -6928,7 +7077,7 @@ async function enrichSpotWithLocation(spot) {
// Lookup location (don't block on failures)
try {
- const response = await fetch(`http://localhost:${PORT}/api/callsign/${skimmerCall}`);
+ const response = await fetch(`http://localhost:${PORT}/api/callsign/${encodeURIComponent(skimmerCall)}`);
if (response.ok) {
const locationData = await response.json();
@@ -7072,7 +7221,10 @@ app.get('/api/rbn/spots', async (req, res) => {
// Endpoint to lookup skimmer location (cached permanently)
app.get('/api/rbn/location/:callsign', async (req, res) => {
- const callsign = req.params.callsign.toUpperCase();
+ const callsign = req.params.callsign.toUpperCase().replace(/[^\w\-\/]/g, '');
+ if (!callsign || callsign.length > 15) {
+ return res.status(400).json({ error: 'Invalid callsign' });
+ }
// Check cache first
if (callsignLocationCache.has(callsign)) {
@@ -7081,7 +7233,7 @@ app.get('/api/rbn/location/:callsign', async (req, res) => {
try {
// Look up via HamQTH
- const response = await fetch(`http://localhost:${PORT}/api/callsign/${callsign}`);
+ const response = await fetch(`http://localhost:${PORT}/api/callsign/${encodeURIComponent(callsign)}`);
if (response.ok) {
const locationData = await response.json();
const grid = latLonToGrid(locationData.lat, locationData.lon);
@@ -10451,7 +10603,11 @@ app.get('/api/health', (req, res) => {
lastSaved: visitorStats.lastSaved,
}
: { enabled: !!STATS_FILE },
- sessions: sessionTracker.getStats(),
+ // SECURITY: Session details include partially anonymized IPs — only expose to authenticated requests.
+ // Unauthenticated requests get aggregate counts only.
+ sessions: isAuthed
+ ? sessionTracker.getStats()
+ : { concurrent: sessionTracker.activeSessions.size, peakConcurrent: sessionTracker.peakConcurrent },
visitors: {
today: {
date: visitorStats.today,
@@ -12868,6 +13024,11 @@ app.listen(PORT, '0.0.0.0', () => {
if (AUTO_UPDATE_ENABLED) {
console.log(` 🔄 Auto-update enabled every ${AUTO_UPDATE_INTERVAL_MINUTES || 60} minutes`);
}
+ if (!API_WRITE_KEY) {
+ console.log('');
+ console.log(' ⚠️ API_WRITE_KEY is not set — write endpoints (settings, update, rotator, QRZ) are unprotected.');
+ console.log(' Set API_WRITE_KEY in .env to secure POST endpoints.');
+ }
console.log(' 🖥️ Open your browser to start using OpenHamClock');
console.log('');
if (CONFIG.callsign !== 'N0CALL') {
diff --git a/src/App.jsx b/src/App.jsx
index 694a77d2..e44b7ec7 100644
--- a/src/App.jsx
+++ b/src/App.jsx
@@ -169,6 +169,12 @@ const App = () => {
return;
}
+ if (e.key === '/') {
+ toggleDeDxMarkers();
+ e.preventDefault();
+ return;
+ }
+
const layerId = layerShortcuts[e.key.toLowerCase()];
if (layerId && window.hamclockLayerControls) {
const isEnabled = window.hamclockLayerControls.layers?.find((l) => l.id === layerId)?.enabled ?? false;
diff --git a/src/DockableApp.jsx b/src/DockableApp.jsx
index 0bcffc82..b131bd46 100644
--- a/src/DockableApp.jsx
+++ b/src/DockableApp.jsx
@@ -37,6 +37,7 @@ import { DockableLayoutProvider } from './contexts';
import { useRig } from './contexts/RigContext.jsx';
import { calculateBearing, calculateDistance, formatDistance } from './utils/geo.js';
import { DXGridInput } from './components/DXGridInput.jsx';
+import DXCCSelect from './components/DXCCSelect.jsx';
import './styles/flexlayout-openhamclock.css';
import useMapLayers from './hooks/app/useMapLayers';
import useRotator from './hooks/useRotator';
@@ -175,6 +176,7 @@ export const DockableApp = ({
});
}, []);
const [showDXLocalTime, setShowDXLocalTime] = useState(false);
+ const [showDxccSelect, setShowDxccSelect] = useState(false);
// ── Tabset auto-rotation (persistent per tabset) ──
const [tabsetRotation, setTabsetRotation] = useState(() => {
@@ -415,6 +417,7 @@ export const DockableApp = ({
'on-air': { name: 'On Air', icon: '🔴' },
'id-timer': { name: 'ID Timer', icon: '📢' },
keybindings: { name: 'Keyboard Shortcuts', icon: '⌨️' },
+ 'lock-layout': { name: 'Lock Layout', icon: '🔒' },
};
}, [isLocalInstall]);
@@ -495,12 +498,40 @@ export const DockableApp = ({
- `;
+ const panelWrapper = L.DomUtil.create('div', 'panel-wrapper');
+ const div = L.DomUtil.create('div', 'lightning-proximity', panelWrapper);
+
+ // Unfortunately, to fit both km and miles in the header we need to override the font size
+ div.innerHTML = `
📍 Nearby Strikes(30km/18.6miles)
No recent strikes
`;
// Prevent map interaction
L.DomEvent.disableClickPropagation(div);
L.DomEvent.disableScrollPropagation(div);
- return div;
+ return panelWrapper;
},
});
@@ -737,9 +723,8 @@ export function useLayer({ enabled = false, opacity = 0.9, map = null, lowMemory
// Default to CENTER of screen (not corner!)
container.style.position = 'fixed';
- container.style.top = '50%';
- container.style.left = '50%';
- container.style.transform = 'translate(-50%, -50%)';
+ container.style.top = '45%'; // NOTE: using 45% instead of 50% with transform: translateX/Y due to dragging issues
+ container.style.left = '45%';
container.style.right = 'auto';
container.style.bottom = 'auto';
container.style.zIndex = '1001'; // Ensure it's on top
@@ -780,7 +765,7 @@ export function useLayer({ enabled = false, opacity = 0.9, map = null, lowMemory
positionLoaded = true;
console.log('[Lightning] Proximity: Converted pixel to percentage:', { topPercent, leftPercent });
} else {
- console.log('[Lightning] Proximity: Saved pixel position off-screen, using center');
+ console.log('[Lightning] Proximity: Saved pixel position off-screen, using default');
localStorage.removeItem('lightning-proximity-position');
}
}
@@ -790,7 +775,7 @@ export function useLayer({ enabled = false, opacity = 0.9, map = null, lowMemory
}
// Make draggable - pass flag to skip position loading since we already did it
- makeDraggable(container, 'lightning-proximity-position', positionLoaded);
+ makeDraggable(container, 'lightning-proximity-position', { skipPositionLoad: positionLoaded, snap: 5 });
addMinimizeToggle(container, 'lightning-proximity-position', {
contentClassName: 'lightning-panel-content',
buttonClassName: 'lightning-minimize-btn',
@@ -851,15 +836,15 @@ export function useLayer({ enabled = false, opacity = 0.9, map = null, lowMemory
const distance = calculateDistance(stationLat, stationLon, strike.lat, strike.lon, 'km');
return { ...strike, distance };
})
- .filter((strike) => strike.distance <= PROXIMITY_RADIUS_KM)
- .sort((a, b) => a.distance - b.distance); // Sort by distance (closest first)
+ .filter((strike) => strike.distance.km <= PROXIMITY_RADIUS_KM)
+ .sort((a, b) => a.distance.km - b.distance.km); // Sort by distance (closest first)
let contentHTML = '';
if (nearbyStrikes.length === 0) {
contentHTML = `
-