You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

436 lines
12 KiB

/**
* DX Spider Telnet Proxy Service
*
* A microservice that maintains a persistent telnet connection to DX Spider,
* accumulates spots, and serves them via HTTP API.
*
* Designed to run on Railway as a standalone service.
*/
const net = require('net');
const express = require('express');
const cors = require('cors');
const app = express();
app.use(cors());
app.use(express.json());
// Configuration
const CONFIG = {
// DX Spider nodes to try (in order)
nodes: [
{ host: 'dxspider.co.uk', port: 7300, name: 'DX Spider UK' },
{ host: 'w6kk.no-ip.org', port: 7300, name: 'W6KK' },
{ host: 'dxc.nc7j.com', port: 7373, name: 'NC7J' },
{ host: 'dx.k3lr.com', port: 7300, name: 'K3LR' }
],
callsign: process.env.CALLSIGN || 'OPENHAMCLOCK',
spotRetentionMs: 30 * 60 * 1000, // 30 minutes
reconnectDelayMs: 5000, // 5 seconds between reconnect attempts
maxReconnectAttempts: 5,
cleanupIntervalMs: 60000, // 1 minute
keepAliveIntervalMs: 120000 // 2 minutes - send keepalive
};
// State
let spots = [];
let client = null;
let connected = false;
let currentNode = null;
let currentNodeIndex = 0;
let reconnectAttempts = 0;
let lastSpotTime = null;
let totalSpotsReceived = 0;
let connectionStartTime = null;
let buffer = '';
let reconnectTimer = null;
let keepAliveTimer = null;
// Logging helper
const log = (level, message, data = null) => {
const timestamp = new Date().toISOString();
const logLine = `[${timestamp}] [${level}] ${message}`;
if (data) {
console.log(logLine, typeof data === 'object' ? JSON.stringify(data) : data);
} else {
console.log(logLine);
}
};
// Parse a DX spot line from telnet
// Format: DX de SPOTTER: FREQ DXCALL comment time
const parseSpotLine = (line) => {
try {
// Match: DX de W3ABC: 14025.0 JA1XYZ CW 599 1234Z
const match = line.match(/^DX de\s+([A-Z0-9/]+):\s+(\d+\.?\d*)\s+([A-Z0-9/]+)\s+(.*)$/i);
if (!match) return null;
const spotter = match[1].toUpperCase();
const freqKhz = parseFloat(match[2]);
const dxCall = match[3].toUpperCase();
let comment = match[4].trim();
// Extract time from end of comment (format: 1234Z or 1234z)
let time = '';
const timeMatch = comment.match(/(\d{4})[Zz]\s*$/);
if (timeMatch) {
time = timeMatch[1].substring(0, 2) + ':' + timeMatch[1].substring(2, 4) + 'z';
comment = comment.replace(/\d{4}[Zz]\s*$/, '').trim();
} else {
// Use current UTC time
const now = new Date();
time = String(now.getUTCHours()).padStart(2, '0') + ':' +
String(now.getUTCMinutes()).padStart(2, '0') + 'z';
}
// Detect mode from comment
let mode = null;
const upperComment = comment.toUpperCase();
if (upperComment.includes('FT8')) mode = 'FT8';
else if (upperComment.includes('FT4')) mode = 'FT4';
else if (upperComment.includes('CW')) mode = 'CW';
else if (upperComment.includes('SSB') || upperComment.includes('USB') || upperComment.includes('LSB')) mode = 'SSB';
else if (upperComment.includes('RTTY')) mode = 'RTTY';
else if (upperComment.includes('PSK')) mode = 'PSK';
else if (upperComment.includes('FM')) mode = 'FM';
else if (upperComment.includes('AM')) mode = 'AM';
return {
spotter,
freq: (freqKhz / 1000).toFixed(3), // Convert kHz to MHz string
freqKhz,
call: dxCall,
comment,
time,
mode,
timestamp: Date.now(),
source: 'DX Spider'
};
} catch (err) {
log('ERROR', 'Failed to parse spot line', { line, error: err.message });
return null;
}
};
// Add a spot to the accumulator
const addSpot = (spot) => {
if (!spot) return;
// Check for duplicate (same call + freq within 2 minutes)
const isDuplicate = spots.some(existing =>
existing.call === spot.call &&
existing.freq === spot.freq &&
(spot.timestamp - existing.timestamp) < 120000
);
if (!isDuplicate) {
spots.unshift(spot); // Add to beginning (newest first)
totalSpotsReceived++;
lastSpotTime = new Date();
log('SPOT', `${spot.call} on ${spot.freq} MHz by ${spot.spotter}`);
}
};
// Clean up old spots
const cleanupSpots = () => {
const cutoff = Date.now() - CONFIG.spotRetentionMs;
const before = spots.length;
spots = spots.filter(s => s.timestamp > cutoff);
const removed = before - spots.length;
if (removed > 0) {
log('CLEANUP', `Removed ${removed} expired spots, ${spots.length} remaining`);
}
};
// Connect to DX Spider
const connect = () => {
if (client) {
try {
client.destroy();
} catch (e) {}
client = null;
}
if (reconnectTimer) {
clearTimeout(reconnectTimer);
reconnectTimer = null;
}
const node = CONFIG.nodes[currentNodeIndex];
currentNode = node;
log('CONNECT', `Attempting connection to ${node.name} (${node.host}:${node.port})`);
client = new net.Socket();
client.setTimeout(30000);
client.connect(node.port, node.host, () => {
connected = true;
reconnectAttempts = 0;
connectionStartTime = new Date();
buffer = '';
log('CONNECT', `Connected to ${node.name}`);
// Send login after short delay
setTimeout(() => {
if (client && connected) {
client.write(CONFIG.callsign + '\r\n');
log('AUTH', `Sent callsign: ${CONFIG.callsign}`);
}
}, 1000);
// Start keepalive
startKeepAlive();
});
client.on('data', (data) => {
buffer += data.toString();
// Process complete lines
const lines = buffer.split('\n');
buffer = lines.pop() || ''; // Keep incomplete line in buffer
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;
// Check if it's a DX spot
if (trimmed.startsWith('DX de ')) {
const spot = parseSpotLine(trimmed);
if (spot) {
addSpot(spot);
}
}
}
});
client.on('timeout', () => {
log('TIMEOUT', 'Connection timed out');
handleDisconnect();
});
client.on('error', (err) => {
log('ERROR', `Connection error: ${err.message}`);
handleDisconnect();
});
client.on('close', () => {
log('CLOSE', 'Connection closed');
handleDisconnect();
});
};
// Start keepalive timer
const startKeepAlive = () => {
if (keepAliveTimer) {
clearInterval(keepAliveTimer);
}
keepAliveTimer = setInterval(() => {
if (client && connected) {
try {
// Send a harmless command to keep connection alive
client.write('\r\n');
log('KEEPALIVE', 'Sent keepalive');
} catch (e) {
log('ERROR', 'Keepalive failed', e.message);
}
}
}, CONFIG.keepAliveIntervalMs);
};
// Handle disconnection and reconnection
const handleDisconnect = () => {
connected = false;
if (keepAliveTimer) {
clearInterval(keepAliveTimer);
keepAliveTimer = null;
}
reconnectAttempts++;
if (reconnectAttempts >= CONFIG.maxReconnectAttempts) {
// Try next node
currentNodeIndex = (currentNodeIndex + 1) % CONFIG.nodes.length;
reconnectAttempts = 0;
log('FAILOVER', `Switching to node: ${CONFIG.nodes[currentNodeIndex].name}`);
}
log('RECONNECT', `Attempting reconnect in ${CONFIG.reconnectDelayMs}ms (attempt ${reconnectAttempts + 1})`);
reconnectTimer = setTimeout(() => {
connect();
}, CONFIG.reconnectDelayMs);
};
// ============================================
// HTTP API ENDPOINTS
// ============================================
// Health check
app.get('/health', (req, res) => {
res.json({
status: 'ok',
connected,
currentNode: currentNode?.name || 'none',
spotsInMemory: spots.length,
totalSpotsReceived,
lastSpotTime: lastSpotTime?.toISOString() || null,
connectionUptime: connectionStartTime ?
Math.floor((Date.now() - connectionStartTime.getTime()) / 1000) + 's' : null,
uptime: process.uptime() + 's'
});
});
// Get spots
app.get('/api/spots', (req, res) => {
const limit = Math.min(parseInt(req.query.limit) || 50, 200);
const since = parseInt(req.query.since) || 0; // Timestamp filter
let filteredSpots = spots;
// Filter by timestamp if provided
if (since > 0) {
filteredSpots = spots.filter(s => s.timestamp > since);
}
res.json({
spots: filteredSpots.slice(0, limit),
total: filteredSpots.length,
connected,
source: currentNode?.name || 'disconnected',
timestamp: Date.now()
});
});
// Get spots in simple format (for compatibility with existing DX cluster endpoint)
app.get('/api/dxcluster/spots', (req, res) => {
const limit = Math.min(parseInt(req.query.limit) || 25, 100);
const formattedSpots = spots.slice(0, limit).map(s => ({
spotter: s.spotter,
freq: s.freq,
call: s.call,
comment: s.comment,
time: s.time,
mode: s.mode,
source: 'DX Spider Proxy'
}));
res.json(formattedSpots);
});
// Stats endpoint
app.get('/api/stats', (req, res) => {
// Calculate spots per band
const bandCounts = {};
spots.forEach(s => {
const freq = s.freqKhz;
let band = 'other';
if (freq >= 1800 && freq <= 2000) band = '160m';
else if (freq >= 3500 && freq <= 4000) band = '80m';
else if (freq >= 7000 && freq <= 7300) band = '40m';
else if (freq >= 10100 && freq <= 10150) band = '30m';
else if (freq >= 14000 && freq <= 14350) band = '20m';
else if (freq >= 18068 && freq <= 18168) band = '17m';
else if (freq >= 21000 && freq <= 21450) band = '15m';
else if (freq >= 24890 && freq <= 24990) band = '12m';
else if (freq >= 28000 && freq <= 29700) band = '10m';
else if (freq >= 50000 && freq <= 54000) band = '6m';
bandCounts[band] = (bandCounts[band] || 0) + 1;
});
// Calculate spots per mode
const modeCounts = {};
spots.forEach(s => {
const mode = s.mode || 'unknown';
modeCounts[mode] = (modeCounts[mode] || 0) + 1;
});
res.json({
connected,
currentNode: currentNode?.name || 'none',
totalSpots: spots.length,
totalReceived: totalSpotsReceived,
lastSpotTime: lastSpotTime?.toISOString() || null,
retentionMinutes: CONFIG.spotRetentionMs / 60000,
bandCounts,
modeCounts
});
});
// Force reconnect
app.post('/api/reconnect', (req, res) => {
log('API', 'Force reconnect requested');
handleDisconnect();
res.json({ status: 'reconnecting' });
});
// Switch node
app.post('/api/switch-node', (req, res) => {
const { index } = req.body;
if (typeof index === 'number' && index >= 0 && index < CONFIG.nodes.length) {
currentNodeIndex = index;
reconnectAttempts = 0;
log('API', `Switching to node index ${index}: ${CONFIG.nodes[index].name}`);
handleDisconnect();
res.json({ status: 'switching', node: CONFIG.nodes[index].name });
} else {
res.status(400).json({ error: 'Invalid node index', availableNodes: CONFIG.nodes.map(n => n.name) });
}
});
// List available nodes
app.get('/api/nodes', (req, res) => {
res.json({
nodes: CONFIG.nodes.map((n, i) => ({
index: i,
name: n.name,
host: n.host,
port: n.port,
active: i === currentNodeIndex
})),
currentIndex: currentNodeIndex
});
});
// ============================================
// STARTUP
// ============================================
const PORT = process.env.PORT || 3001;
// Start cleanup interval
setInterval(cleanupSpots, CONFIG.cleanupIntervalMs);
// Start server
app.listen(PORT, () => {
log('START', `DX Spider Proxy listening on port ${PORT}`);
log('CONFIG', `Callsign: ${CONFIG.callsign}`);
log('CONFIG', `Spot retention: ${CONFIG.spotRetentionMs / 60000} minutes`);
log('CONFIG', `Available nodes: ${CONFIG.nodes.map(n => n.name).join(', ')}`);
// Connect to DX Spider
connect();
});
// Handle graceful shutdown
process.on('SIGTERM', () => {
log('SHUTDOWN', 'Received SIGTERM, shutting down...');
if (client) {
client.destroy();
}
process.exit(0);
});
process.on('SIGINT', () => {
log('SHUTDOWN', 'Received SIGINT, shutting down...');
if (client) {
client.destroy();
}
process.exit(0);
});

Powered by TurnKey Linux.