tcp-serial-relay
Version:
Modular TCP to Serial relay service with comprehensive logging, monitoring and web dashboard
333 lines (283 loc) • 9.23 kB
JavaScript
// src/dashboard/routes/config.js
const fs = require('fs');
const path = require('path');
class ConfigRoutes {
constructor() {
this.configPath = process.env.CONFIG_PATH || '/etc/tcp-serial-relay/relay-config.json';
this.backupDir = path.join(path.dirname(this.configPath), 'backups');
}
// GET /api/config
async getConfig(req, res) {
try {
const config = this.loadConfig();
const metadata = this.getConfigMetadata();
res.json({
config,
metadata,
path: this.configPath
});
} catch (error) {
res.status(500).json({
error: 'Failed to load configuration',
message: error.message
});
}
}
// PUT /api/config
async updateConfig(req, res) {
try {
const newConfig = req.body;
// Validate configuration
const validation = this.validateConfig(newConfig);
if (!validation.valid) {
return res.status(400).json({
error: 'Configuration validation failed',
errors: validation.errors
});
}
// Backup current config
await this.backupConfig();
// Save new config
this.saveConfig(newConfig);
res.json({
success: true,
message: 'Configuration updated successfully'
});
} catch (error) {
res.status(500).json({
error: 'Failed to update configuration',
message: error.message
});
}
}
// GET /api/config/validate
async validateConfigEndpoint(req, res) {
try {
const config = req.body || this.loadConfig();
const validation = this.validateConfig(config);
res.json(validation);
} catch (error) {
res.status(500).json({
error: 'Failed to validate configuration',
message: error.message
});
}
}
// GET /api/config/backups
async getBackups(req, res) {
try {
const backups = this.listBackups();
res.json(backups);
} catch (error) {
res.status(500).json({
error: 'Failed to list backups',
message: error.message
});
}
}
// POST /api/config/restore/:backupId
async restoreBackup(req, res) {
try {
const { backupId } = req.params;
const backupFile = path.join(this.backupDir, backupId);
if (!fs.existsSync(backupFile)) {
return res.status(404).json({
error: 'Backup file not found'
});
}
// Validate backup before restoring
const backupConfig = JSON.parse(fs.readFileSync(backupFile, 'utf8'));
const validation = this.validateConfig(backupConfig);
if (!validation.valid) {
return res.status(400).json({
error: 'Backup file contains invalid configuration',
errors: validation.errors
});
}
// Create backup of current config before restoring
await this.backupConfig();
// Restore from backup
fs.copyFileSync(backupFile, this.configPath);
res.json({
success: true,
message: 'Configuration restored from backup'
});
} catch (error) {
res.status(500).json({
error: 'Failed to restore backup',
message: error.message
});
}
}
// GET /api/config/defaults
async getDefaults(req, res) {
try {
const defaultConfig = require('../../config/default-config');
res.json(defaultConfig);
} catch (error) {
res.status(500).json({
error: 'Failed to load default configuration',
message: error.message
});
}
}
loadConfig() {
try {
if (!fs.existsSync(this.configPath)) {
// Return default config if file doesn't exist
return require('../../config/default-config');
}
const configData = fs.readFileSync(this.configPath, 'utf8');
return JSON.parse(configData);
} catch (error) {
throw new Error(`Failed to load configuration: ${error.message}`);
}
}
saveConfig(config) {
try {
// Ensure config directory exists
const configDir = path.dirname(this.configPath);
if (!fs.existsSync(configDir)) {
fs.mkdirSync(configDir, { recursive: true });
}
// Write config file
fs.writeFileSync(this.configPath, JSON.stringify(config, null, 2), 'utf8');
} catch (error) {
throw new Error(`Failed to save configuration: ${error.message}`);
}
}
getConfigMetadata() {
try {
if (!fs.existsSync(this.configPath)) {
return {
exists: false,
lastModified: null,
size: 0
};
}
const stats = fs.statSync(this.configPath);
return {
exists: true,
lastModified: stats.mtime.toISOString(),
size: stats.size,
path: this.configPath
};
} catch (error) {
return {
exists: false,
error: error.message
};
}
}
validateConfig(config) {
const errors = [];
// Required fields validation
const requiredFields = [
{ field: 'tcpIp', type: 'string', message: 'TCP IP address is required' },
{ field: 'tcpPort', type: 'number', message: 'TCP port is required' },
{ field: 'serialPath', type: 'string', message: 'Serial path is required' },
{ field: 'serialBaud', type: 'number', message: 'Serial baud rate is required' }
];
for (const req of requiredFields) {
if (!config.hasOwnProperty(req.field)) {
errors.push(`Missing required field: ${req.field}`);
} else if (req.type === 'number' && !Number.isInteger(config[req.field])) {
errors.push(`${req.field} must be a number`);
} else if (req.type === 'string' && typeof config[req.field] !== 'string') {
errors.push(`${req.field} must be a string`);
}
}
// Specific validations
if (config.tcpPort && (config.tcpPort < 1 || config.tcpPort > 65535)) {
errors.push('TCP port must be between 1 and 65535');
}
if (config.serialBaud && config.serialBaud < 1) {
errors.push('Serial baud rate must be positive');
}
if (config.serialParity && !['none', 'even', 'odd', 'mark', 'space'].includes(config.serialParity)) {
errors.push('Serial parity must be one of: none, even, odd, mark, space');
}
if (config.serialDataBits && ![5, 6, 7, 8].includes(config.serialDataBits)) {
errors.push('Serial data bits must be 5, 6, 7, or 8');
}
if (config.serialStopBits && ![1, 1.5, 2].includes(config.serialStopBits)) {
errors.push('Serial stop bits must be 1, 1.5, or 2');
}
if (config.maxRetries && config.maxRetries < 0) {
errors.push('Max retries must be non-negative');
}
if (config.retryDelay && config.retryDelay < 100) {
errors.push('Retry delay must be at least 100ms');
}
if (config.connectionTimeout && config.connectionTimeout < 1000) {
errors.push('Connection timeout must be at least 1000ms');
}
if (config.relayTimeout && config.relayTimeout < 1000) {
errors.push('Relay timeout must be at least 1000ms');
}
return {
valid: errors.length === 0,
errors
};
}
async backupConfig() {
try {
if (!fs.existsSync(this.configPath)) {
return null; // No config to backup
}
// Ensure backup directory exists
if (!fs.existsSync(this.backupDir)) {
fs.mkdirSync(this.backupDir, { recursive: true });
}
// Create backup filename with timestamp
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const backupFile = path.join(this.backupDir, `config-backup-${timestamp}.json`);
// Copy current config to backup
fs.copyFileSync(this.configPath, backupFile);
// Clean up old backups (keep last 10)
this.cleanupOldBackups();
return backupFile;
} catch (error) {
throw new Error(`Failed to backup configuration: ${error.message}`);
}
}
listBackups() {
try {
if (!fs.existsSync(this.backupDir)) {
return [];
}
const backupFiles = fs.readdirSync(this.backupDir)
.filter(file => file.startsWith('config-backup-') && file.endsWith('.json'))
.map(file => {
const filePath = path.join(this.backupDir, file);
const stats = fs.statSync(filePath);
return {
id: file,
name: file,
created: stats.mtime.toISOString(),
size: stats.size
};
})
.sort((a, b) => new Date(b.created) - new Date(a.created));
return backupFiles;
} catch (error) {
throw new Error(`Failed to list backups: ${error.message}`);
}
}
cleanupOldBackups(keepCount = 10) {
try {
const backups = this.listBackups();
if (backups.length > keepCount) {
const toDelete = backups.slice(keepCount);
for (const backup of toDelete) {
const backupPath = path.join(this.backupDir, backup.id);
fs.unlinkSync(backupPath);
}
}
} catch (error) {
// Non-critical error, log but don't throw
console.warn('Failed to cleanup old backups:', error.message);
}
}
}
module.exports = ConfigRoutes;