Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added public/aprs-symbols-24-0.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/aprs-symbols-24-1.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/aprs-symbols-24-2.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
954 changes: 495 additions & 459 deletions rig-bridge/README.md

Large diffs are not rendered by default.

12 changes: 10 additions & 2 deletions rig-bridge/core/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ function resolveConfigPath() {
const { dir: CONFIG_DIR, path: CONFIG_PATH } = resolveConfigPath();

// Increment when DEFAULT_CONFIG structure changes (new keys, renamed keys, etc.)
const CONFIG_VERSION = 7;
const CONFIG_VERSION = 8;

const DEFAULT_CONFIG = {
configVersion: CONFIG_VERSION,
Expand Down Expand Up @@ -103,6 +103,7 @@ const DEFAULT_CONFIG = {
},
wsjtxRelay: {
enabled: false,
relayToServer: false, // false = SSE-only (local/LAN); true = also POST decodes to OHC server (cloud relay)
url: '', // OpenHamClock server URL (e.g. https://openhamclock.com)
key: '', // Relay authentication key
session: '', // Browser session ID for per-user isolation
Expand Down Expand Up @@ -184,6 +185,11 @@ const DEFAULT_CONFIG = {
port: 8080,
},
},
// TLS/HTTPS — enables HTTPS to avoid mixed-content errors when OHC is served over HTTPS
tls: {
enabled: false, // false = plain HTTP (backward-compatible default)
certGenerated: false, // tracks whether a cert has been generated at least once
},
};

let config = JSON.parse(JSON.stringify(DEFAULT_CONFIG));
Expand Down Expand Up @@ -223,6 +229,7 @@ function loadConfig() {
...(raw.winlink || {}),
pat: { ...DEFAULT_CONFIG.winlink.pat, ...((raw.winlink || {}).pat || {}) },
},
tls: { ...DEFAULT_CONFIG.tls, ...(raw.tls || {}) },
// Coerce logging to boolean in case the stored value is a string
logging: raw.logging !== undefined ? !!raw.logging : DEFAULT_CONFIG.logging,
});
Expand All @@ -244,6 +251,7 @@ function loadConfig() {
'rotator',
'cloudRelay',
'winlink',
'tls',
]) {
if (DEFAULT_CONFIG[section] && raw[section]) {
for (const key of Object.keys(DEFAULT_CONFIG[section])) {
Expand Down Expand Up @@ -296,4 +304,4 @@ function applyCliArgs() {
}
}

module.exports = { config, loadConfig, saveConfig, applyCliArgs, CONFIG_PATH };
module.exports = { config, loadConfig, saveConfig, applyCliArgs, CONFIG_PATH, CONFIG_DIR };
490 changes: 438 additions & 52 deletions rig-bridge/core/server.js

Large diffs are not rendered by default.

22 changes: 22 additions & 0 deletions rig-bridge/core/state.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,21 @@
* state.js — Shared rig state store and SSE broadcast
*/

// Ring-buffer of recent plugin decodes (FT8/FT4/MSHV/JTDX/JS8Call).
// Sent to browsers on SSE connect so they see recent data immediately
// without waiting for the next decode cycle.
const DECODE_RING_MAX = 100;
const decodeRingBuffer = [];

function addToDecodeRingBuffer(decode) {
decodeRingBuffer.push(decode);
if (decodeRingBuffer.length > DECODE_RING_MAX) decodeRingBuffer.shift();
}

function getDecodeRingBuffer() {
return decodeRingBuffer.slice();
}

const state = {
connected: false,
freq: 0,
Expand Down Expand Up @@ -50,12 +65,19 @@ function removeSseClient(id) {
sseClients = sseClients.filter((c) => c.id !== id);
}

function getSseClientCount() {
return sseClients.length;
}

module.exports = {
state,
broadcast,
updateState,
addSseClient,
removeSseClient,
getSseClientCount,
onStateChange,
removeStateChangeListener,
addToDecodeRingBuffer,
getDecodeRingBuffer,
};
164 changes: 164 additions & 0 deletions rig-bridge/core/tls.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
'use strict';
/**
* tls.js — Self-signed certificate generation and management
*
* Generates a self-signed RSA-2048 certificate for rig-bridge's HTTPS server.
* Certificates are stored in ~/.config/openhamclock/certs/ (or the platform
* equivalent) so they survive rig-bridge updates.
*
* This module has no dependencies on config.js — it computes the cert directory
* independently using the same platform logic — so there is no circular import.
*/

const fs = require('fs');
const path = require('path');
const os = require('os');
const forge = require('node-forge');

// ── Cert storage path ────────────────────────────────────────────────────────
// Mirrors config.js's externalDir logic but appends /certs
function resolveCertDir() {
if (process.platform === 'win32') {
return path.join(process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'), 'openhamclock', 'certs');
}
return path.join(os.homedir(), '.config', 'openhamclock', 'certs');
}

const CERT_DIR = resolveCertDir();
const KEY_PATH = path.join(CERT_DIR, 'rig-bridge.key');
const CERT_PATH = path.join(CERT_DIR, 'rig-bridge.crt');

// ── Certificate generation ───────────────────────────────────────────────────

/**
* Generate a new RSA-2048 self-signed certificate.
* @returns {Promise<{ privateKeyPem: string, certPem: string }>}
*/
function generateCert() {
return new Promise((resolve, reject) => {
forge.pki.rsa.generateKeyPair({ bits: 2048, workers: -1 }, (err, keyPair) => {
if (err) return reject(err);

const cert = forge.pki.createCertificate();
cert.publicKey = keyPair.publicKey;
// Random 16-byte serial — avoids OS trust-store caching bugs when a cert
// is regenerated (macOS Keychain and some browsers cache by issuer+serial).
cert.serialNumber = forge.util.bytesToHex(forge.random.getBytesSync(16));

cert.validity.notBefore = new Date();
cert.validity.notAfter = new Date();
cert.validity.notAfter.setFullYear(cert.validity.notBefore.getFullYear() + 10);

const attrs = [{ name: 'commonName', value: 'localhost' }];
cert.setSubject(attrs);
cert.setIssuer(attrs); // self-signed

cert.setExtensions([
{ name: 'basicConstraints', cA: false },
{
name: 'keyUsage',
digitalSignature: true,
keyEncipherment: true,
},
{
name: 'extKeyUsage',
serverAuth: true,
},
{
name: 'subjectAltName',
altNames: [
{ type: 2, value: 'localhost' }, // DNS
{ type: 7, ip: '127.0.0.1' }, // IP
],
},
]);

cert.sign(keyPair.privateKey, forge.md.sha256.create());

resolve({
privateKeyPem: forge.pki.privateKeyToPem(keyPair.privateKey),
certPem: forge.pki.certificateToPem(cert),
});
});
});
}

// ── Public API ───────────────────────────────────────────────────────────────

/**
* Ensure certificate and key files exist on disk.
* Generates them if missing or if forceRegen is true.
*
* @param {boolean} [forceRegen=false]
* @returns {Promise<{ keyPath: string, certPath: string, generated: boolean }>}
*/
async function ensureCerts(forceRegen = false) {
const exists = fs.existsSync(KEY_PATH) && fs.existsSync(CERT_PATH);

if (exists && !forceRegen) {
return { keyPath: KEY_PATH, certPath: CERT_PATH, generated: false };
}

console.log('[TLS] Generating self-signed certificate (RSA-2048, 10-year validity)…');

const { privateKeyPem, certPem } = await generateCert();

if (!fs.existsSync(CERT_DIR)) {
fs.mkdirSync(CERT_DIR, { recursive: true });
}

fs.writeFileSync(KEY_PATH, privateKeyPem, { mode: 0o600 });
fs.writeFileSync(CERT_PATH, certPem, { mode: 0o644 });

console.log(`[TLS] Certificate written to ${CERT_DIR}`);
return { keyPath: KEY_PATH, certPath: CERT_PATH, generated: true };
}

/**
* Load key and cert buffers from disk.
* @returns {{ key: Buffer, cert: Buffer }}
* @throws if files do not exist
*/
function loadCreds() {
return {
key: fs.readFileSync(KEY_PATH),
cert: fs.readFileSync(CERT_PATH),
};
}

/**
* Parse the on-disk certificate and return human-readable metadata.
* Returns { exists: false } if no certificate file is present.
*
* @returns {{ exists: boolean, fingerprint?: string, subject?: string, notBefore?: string, notAfter?: string, daysLeft?: number }}
*/
function getCertInfo() {
if (!fs.existsSync(CERT_PATH)) {
return { exists: false };
}

try {
const pem = fs.readFileSync(CERT_PATH, 'utf8');
const cert = forge.pki.certificateFromPem(pem);

// SHA-1 fingerprint formatted as colon-separated hex pairs (matches browser/OS display)
const der = forge.asn1.toDer(forge.pki.certificateToAsn1(cert)).getBytes();
const md = forge.md.sha1.create();
md.update(der);
const raw = md.digest().toHex();
const fingerprint = raw.match(/.{2}/g).join(':').toUpperCase();

const notBefore = cert.validity.notBefore.toISOString();
const notAfter = cert.validity.notAfter.toISOString();
const daysLeft = Math.floor((cert.validity.notAfter - Date.now()) / 86400000);

const cnField = cert.subject.getField('CN');
const subject = cnField ? cnField.value : 'localhost';

return { exists: true, fingerprint, subject, notBefore, notAfter, daysLeft };
} catch (e) {
return { exists: true, fingerprint: null, error: e.message };
}
}

module.exports = { ensureCerts, loadCreds, getCertInfo, CERT_DIR, KEY_PATH, CERT_PATH };
Loading