test enhancements

This commit is contained in:
Raven Scott
2025-11-24 09:06:29 -05:00
parent 46d359f18a
commit 074d8df3cc
15 changed files with 2354 additions and 165 deletions
+304 -116
View File
@@ -8,6 +8,11 @@ import os from "os";
import fs from 'fs';
import dotenv from 'dotenv';
import { spawn } from 'child_process';
import * as validation from './utils/validation.js';
import rateLimiter from './utils/rateLimiter.js';
import * as containerConfig from './utils/containerConfig.js';
import { createErrorResponse, sanitizeErrorMessage } from '../utils/errorHandler.js';
import logger from './utils/logger.js';
// Load environment variables from .env file
dotenv.config();
@@ -40,7 +45,7 @@ if (!keyHex) {
// Convert the keyHex to a Buffer
const topic = Buffer.from(keyHex, 'hex');
console.log(`[INFO] Server started with topic: ${topic.toString('hex')}`);
logger.info(`Server started with topic: ${topic.toString('hex')}`);
// Start listening or further implementation logic here
// Join the swarm with the generated topic
@@ -48,7 +53,7 @@ swarm.join(topic, { server: true, client: false });
// Handle incoming peer connections
swarm.on('connection', (peer) => {
console.log('[INFO] Peer connected');
logger.info('Peer connected', { peerId: peer.remotePublicKey?.toString('hex')?.substring(0, 12) });
connectedPeers.add(peer);
peer.on('data', async (data) => {
@@ -57,6 +62,17 @@ swarm.on('connection', (peer) => {
if (!(parsedData.command === 'stats' && Object.keys(parsedData.args).length === 0)) {
console.log(`[DEBUG] Received data from peer: ${JSON.stringify(parsedData)}`);
}
// Rate limiting check
if (!rateLimiter.isAllowed(peer, parsedData.command)) {
console.warn(`[WARN] Rate limit exceeded for peer: ${parsedData.command}`);
peer.write(JSON.stringify({
error: 'Rate limit exceeded. Please wait before making more requests.',
code: 'RATE_LIMIT_EXCEEDED'
}));
return;
}
let response;
switch (parsedData.command) {
@@ -104,9 +120,26 @@ swarm.on('connection', (peer) => {
console.log(`[INFO] Handling 'dockerCommand' with data: ${parsedData.data}`);
try {
const command = parsedData.data.split(' '); // Split the command into executable and args
// Validate command input
const commandStr = validation.sanitizeString(parsedData.data, 500);
if (!commandStr || !commandStr.startsWith('docker ')) {
throw new Error('Invalid command format');
}
// Additional server-side validation
const dangerousPatterns = ['exec', 'run', 'rm -f', 'prune', 'system prune'];
if (dangerousPatterns.some(pattern => commandStr.includes(pattern))) {
throw new Error('Command not allowed for security reasons');
}
const command = commandStr.split(' '); // Split the command into executable and args
const executable = command[0];
const args = command.slice(1);
// Ensure only docker executable
if (executable !== 'docker') {
throw new Error('Only docker commands are allowed');
}
const childProcess = spawn(executable, args);
@@ -263,23 +296,23 @@ swarm.on('connection', (peer) => {
break;
case 'deployContainer':
console.log('[INFO] Handling "deployContainer" command');
logger.info('Handling deployContainer command');
const args = parsedData.args;
try {
// Validate and sanitize container name
if (!args.containerName || typeof args.containerName !== 'string') {
throw new Error('Invalid or missing container name.');
}
if (!/^[a-zA-Z0-9-_]+$/.test(args.containerName)) {
throw new Error('Container name must be alphanumeric and may include dashes or underscores.');
const containerName = validation.sanitizeString(args.containerName, 63);
if (!containerName || !validation.isValidContainerName(containerName)) {
throw new Error('Invalid or missing container name. Must be alphanumeric with dashes/underscores, 1-63 characters.');
}
args.containerName = containerName;
// Validate and sanitize image
if (!args.image || typeof args.image !== 'string') {
throw new Error('Invalid or missing Docker image.');
const image = validation.sanitizeString(args.image, 255);
if (!image || !validation.isValidImageName(image)) {
throw new Error('Invalid or missing Docker image name.');
}
args.image = image;
// Check if container name already exists
const existingContainers = await docker.listContainers({ all: true });
@@ -288,7 +321,7 @@ swarm.on('connection', (peer) => {
throw new Error(`Container name '${args.containerName}' already exists.`);
}
console.log(`[INFO] Pulling Docker image "${args.image}"`);
logger.info(`Pulling Docker image: ${args.image}`);
// Pull the Docker image
const pullStream = await docker.pull(args.image);
@@ -296,7 +329,7 @@ swarm.on('connection', (peer) => {
docker.modem.followProgress(pullStream, (err) => (err ? reject(err) : resolve()));
});
console.log(`[INFO] Image "${args.image}" pulled successfully`);
logger.info(`Image pulled successfully: ${args.image}`);
// Build container configuration
const containerConfig = {
@@ -319,17 +352,39 @@ swarm.on('connection', (peer) => {
if (args.env && Array.isArray(args.env)) {
containerConfig.Env = args.env
.filter(e => e.name && e.value !== undefined)
.map(e => `${e.name}=${e.value}`);
.map(e => {
const name = validation.sanitizeEnvVarName(e.name);
const value = validation.sanitizeEnvVarValue(e.value);
return name && value !== null ? `${name}=${value}` : null;
})
.filter(e => e !== null);
}
// Labels
if (args.labels && typeof args.labels === 'object') {
containerConfig.Labels = args.labels;
containerConfig.Labels = {};
for (const [key, value] of Object.entries(args.labels)) {
const sanitizedKey = validation.sanitizeLabelKey(key);
const sanitizedValue = validation.sanitizeLabelValue(value);
if (sanitizedKey && sanitizedValue !== null) {
containerConfig.Labels[sanitizedKey] = sanitizedValue;
}
}
}
// Hostname and domainname
if (args.hostname) containerConfig.Hostname = args.hostname;
if (args.domainname) containerConfig.Domainname = args.domainname;
if (args.hostname) {
const hostname = validation.sanitizeString(args.hostname, 253);
if (validation.isValidHostname(hostname)) {
containerConfig.Hostname = hostname;
}
}
if (args.domainname) {
const domainname = validation.sanitizeString(args.domainname, 253);
if (validation.isValidHostname(domainname)) {
containerConfig.Domainname = domainname;
}
}
// User
if (args.user) containerConfig.User = args.user;
@@ -368,21 +423,26 @@ swarm.on('connection', (peer) => {
if (args.ports && Array.isArray(args.ports)) {
hostConfig.PortBindings = {};
args.ports.forEach((portStr) => {
// Support both "host:container/protocol" and "container/protocol" formats
if (portStr.includes(':')) {
const [hostPort, rest] = portStr.split(':');
const [containerPort, protocol] = rest.split('/');
hostConfig.PortBindings[`${containerPort}/${protocol || 'tcp'}`] = [{ HostPort: hostPort }];
} else {
const [containerPort, protocol] = portStr.split('/');
hostConfig.PortBindings[`${containerPort}/${protocol || 'tcp'}`] = [{ HostPort: containerPort }];
const sanitizedPort = validation.sanitizeString(portStr, 50);
if (validation.isValidPortMapping(sanitizedPort)) {
// Support both "host:container/protocol" and "container/protocol" formats
if (sanitizedPort.includes(':')) {
const [hostPort, rest] = sanitizedPort.split(':');
const [containerPort, protocol] = rest.split('/');
hostConfig.PortBindings[`${containerPort}/${protocol || 'tcp'}`] = [{ HostPort: hostPort }];
} else {
const [containerPort, protocol] = sanitizedPort.split('/');
hostConfig.PortBindings[`${containerPort}/${protocol || 'tcp'}`] = [{ HostPort: containerPort }];
}
}
});
}
// Volumes
if (args.volumes && Array.isArray(args.volumes)) {
hostConfig.Binds = args.volumes.filter(v => v && v.includes(':'));
hostConfig.Binds = args.volumes
.map(v => validation.sanitizeString(v, 500))
.filter(v => v && validation.isValidVolumeMount(v));
}
// Tmpfs
@@ -430,7 +490,9 @@ swarm.on('connection', (peer) => {
// DNS
if (args.dns && Array.isArray(args.dns)) {
hostConfig.Dns = args.dns;
hostConfig.Dns = args.dns
.map(dns => validation.sanitizeString(dns, 50))
.filter(dns => validation.isValidDnsServer(dns));
}
// Extra hosts
@@ -514,8 +576,8 @@ swarm.on('connection', (peer) => {
containerConfig.HostConfig = hostConfig;
// Create the container
console.log('[INFO] Creating the container...');
const container = await docker.createContainer(containerConfig);
logger.info('Creating container', { name: args.containerName });
const container = await docker.createContainer(config);
// Connect to custom network if specified
if (args.customNetwork && args.networkMode !== 'container' && args.networkMode !== 'host' && args.networkMode !== 'none') {
@@ -529,10 +591,10 @@ swarm.on('connection', (peer) => {
}
// Start the container
console.log('[INFO] Starting the container...');
logger.info('Starting container', { name: args.containerName });
await container.start();
console.log(`[INFO] Container "${args.containerName}" deployed successfully`);
logger.info('Container deployed successfully', { name: args.containerName, image: args.image });
// Respond with success message
peer.write(
@@ -554,12 +616,10 @@ swarm.on('connection', (peer) => {
}
}
} catch (err) {
console.error(`[ERROR] Failed to deploy container: ${err.message}`);
peer.write(
JSON.stringify({
error: `Failed to deploy container: ${err.message}`,
})
);
logger.error('Failed to deploy container', { error: err.message, containerName: args?.containerName });
const errorResponse = createErrorResponse(err);
errorResponse.error = sanitizeErrorMessage(errorResponse.error);
peer.write(JSON.stringify(errorResponse));
}
break;
@@ -590,18 +650,27 @@ swarm.on('connection', (peer) => {
peer.write(JSON.stringify(response));
}
} catch (err) {
console.error(`[ERROR] Failed to handle data from peer: ${err.message}`);
peer.write(JSON.stringify({ error: err.message }));
logger.error('Failed to handle data from peer', { error: err.message, command: parsedData?.command });
// Sanitize error messages to prevent information leakage
const errorMessage = err.message.includes('ENOENT') || err.message.includes('EACCES')
? 'Operation failed. Please check permissions and try again.'
: err.message.length > 200
? 'An error occurred. Please try again.'
: err.message;
peer.write(JSON.stringify({
error: errorMessage,
code: err.code || 'UNKNOWN_ERROR'
}));
}
});
peer.on('error', (err) => {
console.error(`[ERROR] Peer connection error: ${err.message}`);
logger.error('Peer connection error', { error: err.message });
cleanupPeer(peer);
});
peer.on('close', () => {
console.log('[INFO] Peer disconnected');
logger.info('Peer disconnected');
connectedPeers.delete(peer);
cleanupPeer(peer)
@@ -724,70 +793,90 @@ async function duplicateContainer(name, image, hostname, netmode, cpu, memory, c
// Stream Docker events to all peers
let dockerEventStream = null;
docker.getEvents({}, (err, stream) => {
if (err) {
console.error(`[ERROR] Failed to get Docker events: ${err.message}`);
return;
}
dockerEventStream = stream; // Store reference for cleanup
stream.on('data', async (chunk) => {
try {
const event = JSON.parse(chunk.toString());
if (event.status === "undefined") return
console.log(`[INFO] Docker event received: ${event.status} - ${event.id}`);
// Get updated container list and broadcast it to all connected peers
const containers = await docker.listContainers({ all: true });
const update = { type: 'containers', data: containers };
for (const peer of connectedPeers) {
try {
peer.write(JSON.stringify(update));
} catch (peerErr) {
console.error(`[ERROR] Failed to send update to peer: ${peerErr.message}`);
async function initializeDockerEventStream() {
try {
const stream = await new Promise((resolve, reject) => {
docker.getEvents({}, (err, stream) => {
if (err) {
reject(err);
} else {
resolve(stream);
}
}
} catch (err) {
console.error(`[ERROR] Failed to process Docker event: ${err.message}`);
}
});
});
});
stream.on('error', (err) => {
console.error(`[ERROR] Docker event stream error: ${err.message}`);
});
dockerEventStream = stream; // Store reference for cleanup
stream.on('end', () => {
console.log('[INFO] Docker event stream ended');
dockerEventStream = null;
});
});
stream.on('data', async (chunk) => {
try {
const event = JSON.parse(chunk.toString());
if (event.status === "undefined") return;
logger.info('Docker event received', { status: event.status, id: event.id });
// Collect and stream container stats
docker.listContainers({ all: true }, async (err, containers) => {
if (err) {
console.error(`[ERROR] Failed to list containers for stats: ${err.message}`);
return;
}
// Get updated container list and broadcast it to all connected peers
const containers = await docker.listContainers({ all: true });
const update = { type: 'containers', data: containers };
// Iterate over all containers
containers.forEach((containerInfo) => {
const container = docker.getContainer(containerInfo.Id);
// Use the same logic as listContainers to pre-inspect and extract the IP address
container.inspect((inspectErr, details) => {
let ipAddress = 'No IP Assigned'; // Default fallback
if (!inspectErr && details.NetworkSettings && details.NetworkSettings.Networks) {
const networks = Object.values(details.NetworkSettings.Networks);
if (networks.length > 0 && networks[0].IPAddress) {
ipAddress = networks[0].IPAddress; // Use the first network's IP
for (const peer of connectedPeers) {
try {
peer.write(JSON.stringify(update));
} catch (peerErr) {
logger.error('Failed to send update to peer', { error: peerErr.message });
}
}
} catch (err) {
logger.error('Failed to process Docker event', { error: err.message });
}
});
});
});
stream.on('error', (err) => {
logger.error('Docker event stream error', { error: err.message });
});
stream.on('end', () => {
logger.info('Docker event stream ended');
dockerEventStream = null;
});
} catch (err) {
logger.error('Failed to get Docker events', { error: err.message });
}
}
// Initialize Docker event stream
initializeDockerEventStream();
// Collect and stream container stats (async/await version)
async function initializeContainerStatsCollection() {
try {
const containers = await docker.listContainers({ all: true });
// Iterate over all containers
for (const containerInfo of containers) {
const container = docker.getContainer(containerInfo.Id);
try {
// Use the same logic as listContainers to pre-inspect and extract the IP address
const details = await container.inspect();
let ipAddress = 'No IP Assigned'; // Default fallback
if (details.NetworkSettings && details.NetworkSettings.Networks) {
const networks = Object.values(details.NetworkSettings.Networks);
if (networks.length > 0 && networks[0].IPAddress) {
ipAddress = networks[0].IPAddress; // Use the first network's IP
}
}
} catch (inspectErr) {
logger.debug('Failed to inspect container for IP', { containerId: containerInfo.Id, error: inspectErr.message });
}
}
} catch (err) {
logger.error('Failed to list containers for stats', { error: err.message });
}
}
// Initialize container stats collection
initializeContainerStatsCollection();
// Function to calculate CPU usage percentage
function calculateCPUPercent(stats) {
@@ -1004,40 +1093,139 @@ async function initializeContainerStats(containerInfo) {
return statsData;
}
// Stats cache with TTL
const statsCache = new Map();
const STATS_CACHE_TTL = 1000; // 1 second cache TTL
const STATS_BROADCAST_INTERVAL = 2000; // 2 seconds broadcast interval
// Track container activity for adaptive polling
const containerActivity = new Map(); // containerId -> lastActivity timestamp
/**
* Determine if container is active based on CPU/memory usage
* @param {Object} statsData - Container stats data
* @returns {boolean} - True if container is considered active
*/
function isContainerActive(statsData) {
const cpuThreshold = 1.0; // 1% CPU threshold
const memoryThreshold = 1024 * 1024; // 1MB memory threshold
return statsData.cpu > cpuThreshold || statsData.memory > memoryThreshold;
}
/**
* Update container activity tracking
* @param {string} containerId - Container ID
* @param {Object} statsData - Container stats data
*/
function updateContainerActivity(containerId, statsData) {
if (isContainerActive(statsData)) {
containerActivity.set(containerId, Date.now());
}
}
/**
* Check if stats are cached and still valid
* @param {string} containerId - Container ID
* @returns {Object|null} - Cached stats or null
*/
function getCachedStats(containerId) {
const cached = statsCache.get(containerId);
if (cached && (Date.now() - cached.timestamp) < STATS_CACHE_TTL) {
return cached.data;
}
return null;
}
/**
* Cache stats data
* @param {string} containerId - Container ID
* @param {Object} statsData - Stats data to cache
*/
function cacheStats(containerId, statsData) {
statsCache.set(containerId, {
data: { ...statsData },
timestamp: Date.now()
});
}
async function handleStatsBroadcast() {
const containerStats = {};
let lastBroadcast = Date.now();
// Periodically update stats and broadcast
// Increased interval to 2000ms (2 seconds) for better performance
setInterval(async () => {
try {
await collectContainerStats(containerStats);
// Create clean stats objects without stream references for serialization
const aggregatedStats = Object.values(containerStats).map(statsData => ({
id: statsData.id,
name: statsData.name,
cpu: statsData.cpu,
memory: statsData.memory,
ip: statsData.ip
}));
const now = Date.now();
const timeSinceLastBroadcast = now - lastBroadcast;
// Only broadcast if there are stats to send
if (aggregatedStats.length > 0) {
const response = { type: 'allStats', data: aggregatedStats };
for (const peer of connectedPeers) {
try {
peer.write(JSON.stringify(response));
} catch (err) {
console.error(`[ERROR] Failed to send stats to peer: ${err.message}`);
// Only broadcast if enough time has passed
if (timeSinceLastBroadcast >= STATS_BROADCAST_INTERVAL) {
// Create clean stats objects without stream references for serialization
const aggregatedStats = [];
for (const [containerId, statsData] of Object.entries(containerStats)) {
// Check cache first
const cached = getCachedStats(containerId);
if (cached && !isContainerActive(statsData)) {
// Use cached data for inactive containers
aggregatedStats.push(cached);
continue;
}
// Update activity tracking
updateContainerActivity(containerId, statsData);
// Create stats object
const statsObj = {
id: statsData.id,
name: statsData.name,
cpu: statsData.cpu,
memory: statsData.memory,
ip: statsData.ip
};
// Cache the stats
cacheStats(containerId, statsObj);
aggregatedStats.push(statsObj);
}
// Only broadcast if there are stats to send and peers connected
if (aggregatedStats.length > 0 && connectedPeers.size > 0) {
const response = { type: 'allStats', data: aggregatedStats };
for (const peer of connectedPeers) {
try {
peer.write(JSON.stringify(response));
} catch (err) {
console.error(`[ERROR] Failed to send stats to peer: ${err.message}`);
}
}
lastBroadcast = now;
}
}
// Clean up old cache entries
for (const [containerId, cached] of statsCache.entries()) {
if ((now - cached.timestamp) > STATS_CACHE_TTL * 10) {
statsCache.delete(containerId);
}
}
// Clean up old activity tracking
const activityTimeout = 60000; // 1 minute
for (const [containerId, lastActivity] of containerActivity.entries()) {
if ((now - lastActivity) > activityTimeout) {
containerActivity.delete(containerId);
}
}
} catch (err) {
console.error(`[ERROR] Failed to collect/broadcast stats: ${err.message}`);
}
}, 2000); // Send stats every 2 seconds (reduced frequency for better performance)
}, 1000); // Check every second, but broadcast based on interval
}
// Start the stats broadcast
+297
View File
@@ -0,0 +1,297 @@
/**
* Container configuration builder utilities
* Extracted from deployContainer to improve modularity
*/
import * as validation from './validation.js';
/**
* Build basic container configuration
* @param {Object} args - Deployment arguments
* @returns {Object} - Container configuration
*/
export function buildBasicConfig(args) {
const containerConfig = {
name: args.containerName,
Image: args.image,
};
// Basic settings
if (args.command) {
containerConfig.Cmd = args.command.split(' ');
}
if (args.entrypoint) {
containerConfig.Entrypoint = args.entrypoint.split(' ');
}
if (args.workingDir) {
containerConfig.WorkingDir = args.workingDir;
}
return containerConfig;
}
/**
* Build environment variables configuration
* @param {Array} envVars - Environment variables array
* @returns {Array} - Formatted environment variables
*/
export function buildEnvConfig(envVars) {
if (!envVars || !Array.isArray(envVars)) {
return [];
}
return envVars
.filter(e => e.name && e.value !== undefined)
.map(e => {
const name = validation.sanitizeEnvVarName(e.name);
const value = validation.sanitizeEnvVarValue(e.value);
return name && value !== null ? `${name}=${value}` : null;
})
.filter(e => e !== null);
}
/**
* Build labels configuration
* @param {Object} labels - Labels object
* @returns {Object} - Sanitized labels
*/
export function buildLabelsConfig(labels) {
if (!labels || typeof labels !== 'object') {
return {};
}
const sanitizedLabels = {};
for (const [key, value] of Object.entries(labels)) {
const sanitizedKey = validation.sanitizeLabelKey(key);
const sanitizedValue = validation.sanitizeLabelValue(value);
if (sanitizedKey && sanitizedValue !== null) {
sanitizedLabels[sanitizedKey] = sanitizedValue;
}
}
return sanitizedLabels;
}
/**
* Build networking configuration
* @param {Object} args - Deployment arguments
* @returns {Object} - HostConfig networking settings
*/
export function buildNetworkingConfig(args) {
const hostConfig = {
NetworkMode: args.networkMode || 'bridge',
};
// Port bindings
if (args.ports && Array.isArray(args.ports)) {
hostConfig.PortBindings = {};
args.ports.forEach((portStr) => {
const sanitizedPort = validation.sanitizeString(portStr, 50);
if (validation.isValidPortMapping(sanitizedPort)) {
if (sanitizedPort.includes(':')) {
const [hostPort, rest] = sanitizedPort.split(':');
const [containerPort, protocol] = rest.split('/');
hostConfig.PortBindings[`${containerPort}/${protocol || 'tcp'}`] = [{ HostPort: hostPort }];
} else {
const [containerPort, protocol] = sanitizedPort.split('/');
hostConfig.PortBindings[`${containerPort}/${protocol || 'tcp'}`] = [{ HostPort: containerPort }];
}
}
});
}
// DNS
if (args.dns && Array.isArray(args.dns)) {
hostConfig.Dns = args.dns
.map(dns => validation.sanitizeString(dns, 50))
.filter(dns => validation.isValidDnsServer(dns));
}
// Extra hosts
if (args.extraHosts && Array.isArray(args.extraHosts)) {
hostConfig.ExtraHosts = args.extraHosts
.map(host => validation.sanitizeString(host, 200))
.filter(host => host.includes(':'));
}
return hostConfig;
}
/**
* Build volumes configuration
* @param {Object} args - Deployment arguments
* @returns {Object} - HostConfig volumes settings
*/
export function buildVolumesConfig(args) {
const hostConfig = {};
// Volumes
if (args.volumes && Array.isArray(args.volumes)) {
hostConfig.Binds = args.volumes
.map(v => validation.sanitizeString(v, 500))
.filter(v => v && validation.isValidVolumeMount(v));
}
// Tmpfs
if (args.tmpfs && Array.isArray(args.tmpfs)) {
hostConfig.Tmpfs = {};
args.tmpfs.forEach(tmpfsStr => {
const sanitized = validation.sanitizeString(tmpfsStr, 100);
const [path, ...opts] = sanitized.split(':');
if (path) {
hostConfig.Tmpfs[path] = opts.join(':') || '';
}
});
}
return hostConfig;
}
/**
* Build resources configuration
* @param {Object} args - Deployment arguments
* @returns {Object} - HostConfig resources settings
*/
export function buildResourcesConfig(args) {
const hostConfig = {};
// CPU limits
if (args.cpuLimit) {
hostConfig.NanoCpus = args.cpuLimit * 1000000000; // Convert to nanoseconds
}
if (args.cpuReservation) {
hostConfig.CpuQuota = args.cpuReservation * 1000000000;
}
if (args.cpuShares) {
hostConfig.CpuShares = args.cpuShares;
}
// Memory limits
if (args.memoryLimit) {
hostConfig.Memory = args.memoryLimit * 1024 * 1024; // Convert MB to bytes
}
if (args.memoryReservation) {
hostConfig.MemoryReservation = args.memoryReservation * 1024 * 1024;
}
if (args.memorySwap !== undefined && args.memorySwap !== null) {
hostConfig.MemorySwap = args.memorySwap === -1 ? -1 : args.memorySwap * 1024 * 1024;
}
// Devices
if (args.devices && Array.isArray(args.devices)) {
hostConfig.Devices = args.devices
.map(deviceStr => validation.sanitizeString(deviceStr, 200))
.map(deviceStr => {
const parts = deviceStr.split(':');
return {
PathOnHost: parts[0],
PathInContainer: parts[1] || parts[0],
CgroupPermissions: parts[2] || 'rwm'
};
});
}
return hostConfig;
}
/**
* Build security configuration
* @param {Object} args - Deployment arguments
* @returns {Object} - HostConfig security settings
*/
export function buildSecurityConfig(args) {
const hostConfig = {};
// Privileged
if (args.privileged === true) {
hostConfig.Privileged = true;
}
// Capabilities
if (args.capabilities && Array.isArray(args.capabilities)) {
hostConfig.CapAdd = args.capabilities
.map(cap => validation.sanitizeString(cap, 50).toUpperCase())
.filter(cap => /^[A-Z_]+$/.test(cap));
}
// Security options
if (args.securityOpts && Array.isArray(args.securityOpts)) {
hostConfig.SecurityOpt = args.securityOpts
.map(opt => validation.sanitizeString(opt, 200));
}
return hostConfig;
}
/**
* Build runtime configuration
* @param {Object} args - Deployment arguments
* @returns {Object} - HostConfig runtime settings
*/
export function buildRuntimeConfig(args) {
const hostConfig = {};
// Restart policy
if (args.restartPolicy) {
hostConfig.RestartPolicy = {
Name: args.restartPolicy,
MaximumRetryCount: args.restartMaxRetries || 0
};
}
// Auto remove
if (args.autoRemove === true) {
hostConfig.AutoRemove = true;
}
// Init process
if (args.init === true) {
hostConfig.Init = true;
}
// Sysctls
if (args.sysctls && typeof args.sysctls === 'object') {
hostConfig.Sysctls = {};
for (const [key, value] of Object.entries(args.sysctls)) {
const sanitizedKey = validation.sanitizeString(key, 100);
const sanitizedValue = validation.sanitizeString(String(value), 100);
if (sanitizedKey && sanitizedValue) {
hostConfig.Sysctls[sanitizedKey] = sanitizedValue;
}
}
}
// Ulimits
if (args.ulimits && Array.isArray(args.ulimits)) {
hostConfig.Ulimits = args.ulimits
.map(ulimit => {
if (typeof ulimit === 'object' && ulimit.Name) {
return {
Name: validation.sanitizeString(ulimit.Name, 50),
Soft: validation.validateNumber(ulimit.Soft, 0, Infinity),
Hard: validation.validateNumber(ulimit.Hard, 0, Infinity)
};
}
return null;
})
.filter(ulimit => ulimit !== null);
}
// OOM kill disable
if (args.oomKillDisable === true) {
hostConfig.OomKillDisable = true;
}
// PIDs limit
if (args.pidsLimit !== undefined && args.pidsLimit !== null) {
hostConfig.PidsLimit = args.pidsLimit === -1 ? 0 : args.pidsLimit;
}
// Shared memory size
if (args.shmSize) {
hostConfig.ShmSize = args.shmSize * 1024 * 1024; // Convert MB to bytes
}
return hostConfig;
}
+158
View File
@@ -0,0 +1,158 @@
/**
* Structured logging utility with levels and rotation
*/
import fs from 'fs';
import path from 'path';
const LOG_LEVELS = {
ERROR: 0,
WARN: 1,
INFO: 2,
DEBUG: 3,
};
const LOG_LEVEL_NAMES = ['ERROR', 'WARN', 'INFO', 'DEBUG'];
class Logger {
constructor(options = {}) {
this.level = options.level || (process.env.NODE_ENV === 'production' ? LOG_LEVELS.INFO : LOG_LEVELS.DEBUG);
this.enableFileLogging = options.enableFileLogging || false;
this.logDir = options.logDir || './logs';
this.maxFileSize = options.maxFileSize || 10 * 1024 * 1024; // 10MB
this.maxFiles = options.maxFiles || 5;
if (this.enableFileLogging) {
this.ensureLogDirectory();
this.currentLogFile = this.getLogFileName();
}
}
ensureLogDirectory() {
if (!fs.existsSync(this.logDir)) {
fs.mkdirSync(this.logDir, { recursive: true });
}
}
getLogFileName() {
const date = new Date().toISOString().split('T')[0];
return path.join(this.logDir, `peardock-${date}.log`);
}
rotateLogFile() {
if (!this.enableFileLogging) return;
try {
const stats = fs.statSync(this.currentLogFile);
if (stats.size > this.maxFileSize) {
// Rotate: move current to archive
const archiveName = this.currentLogFile.replace('.log', `-${Date.now()}.log`);
fs.renameSync(this.currentLogFile, archiveName);
// Clean up old files
this.cleanupOldLogs();
// Create new log file
this.currentLogFile = this.getLogFileName();
}
} catch (err) {
// File doesn't exist yet, that's okay
}
}
cleanupOldLogs() {
try {
const files = fs.readdirSync(this.logDir)
.filter(f => f.startsWith('peardock-') && f.endsWith('.log'))
.map(f => ({
name: f,
path: path.join(this.logDir, f),
time: fs.statSync(path.join(this.logDir, f)).mtime.getTime()
}))
.sort((a, b) => b.time - a.time);
// Keep only the most recent maxFiles
if (files.length > this.maxFiles) {
files.slice(this.maxFiles).forEach(file => {
try {
fs.unlinkSync(file.path);
} catch (err) {
console.error(`Failed to delete old log file: ${file.name}`, err);
}
});
}
} catch (err) {
console.error('Failed to cleanup old logs:', err);
}
}
formatMessage(level, message, meta = {}) {
const timestamp = new Date().toISOString();
const metaStr = Object.keys(meta).length > 0 ? ` ${JSON.stringify(meta)}` : '';
return `[${timestamp}] [${LOG_LEVEL_NAMES[level]}] ${message}${metaStr}`;
}
writeToFile(message) {
if (!this.enableFileLogging) return;
try {
this.rotateLogFile();
fs.appendFileSync(this.currentLogFile, message + '\n');
} catch (err) {
console.error('Failed to write to log file:', err);
}
}
log(level, message, meta = {}) {
if (level > this.level) return;
const formatted = this.formatMessage(level, message, meta);
// Console output
switch (level) {
case LOG_LEVELS.ERROR:
console.error(formatted);
break;
case LOG_LEVELS.WARN:
console.warn(formatted);
break;
case LOG_LEVELS.INFO:
console.log(formatted);
break;
case LOG_LEVELS.DEBUG:
console.log(formatted);
break;
}
// File output
this.writeToFile(formatted);
}
error(message, meta) {
this.log(LOG_LEVELS.ERROR, message, meta);
}
warn(message, meta) {
this.log(LOG_LEVELS.WARN, message, meta);
}
info(message, meta) {
this.log(LOG_LEVELS.INFO, message, meta);
}
debug(message, meta) {
this.log(LOG_LEVELS.DEBUG, message, meta);
}
}
// Create singleton instance
const logger = new Logger({
enableFileLogging: process.env.ENABLE_FILE_LOGGING === 'true',
level: process.env.LOG_LEVEL === 'ERROR' ? LOG_LEVELS.ERROR :
process.env.LOG_LEVEL === 'WARN' ? LOG_LEVELS.WARN :
process.env.LOG_LEVEL === 'INFO' ? LOG_LEVELS.INFO :
LOG_LEVELS.DEBUG,
});
export default logger;
+136
View File
@@ -0,0 +1,136 @@
/**
* Rate limiting utility to prevent abuse
*/
class RateLimiter {
constructor() {
// Map of peer ID to request timestamps
this.requests = new Map();
// Configuration
this.config = {
maxRequests: 100, // Max requests per window
windowMs: 60000, // 1 minute window
commandLimits: {
deployContainer: { max: 5, windowMs: 60000 }, // 5 deployments per minute
dockerCommand: { max: 30, windowMs: 10000 }, // 30 commands per 10 seconds
startContainer: { max: 20, windowMs: 60000 }, // 20 starts per minute
stopContainer: { max: 20, windowMs: 60000 }, // 20 stops per minute
}
};
}
/**
* Get peer identifier
* @param {Object} peer - Peer object
* @returns {string} - Peer identifier
*/
getPeerId(peer) {
return peer.remotePublicKey?.toString('hex') || 'unknown';
}
/**
* Check if request is within rate limit
* @param {Object} peer - Peer object
* @param {string} command - Command name
* @returns {boolean} - True if allowed
*/
isAllowed(peer, command) {
const peerId = this.getPeerId(peer);
const now = Date.now();
// Clean up old entries
this.cleanup(now);
// Initialize peer entry if needed
if (!this.requests.has(peerId)) {
this.requests.set(peerId, {
general: [],
commands: {}
});
}
const peerData = this.requests.get(peerId);
// Check general rate limit
const generalWindow = now - this.config.windowMs;
peerData.general = peerData.general.filter(timestamp => timestamp > generalWindow);
if (peerData.general.length >= this.config.maxRequests) {
return false;
}
// Check command-specific rate limit
if (this.config.commandLimits[command]) {
const limit = this.config.commandLimits[command];
const commandWindow = now - limit.windowMs;
if (!peerData.commands[command]) {
peerData.commands[command] = [];
}
peerData.commands[command] = peerData.commands[command].filter(
timestamp => timestamp > commandWindow
);
if (peerData.commands[command].length >= limit.max) {
return false;
}
// Record command request
peerData.commands[command].push(now);
}
// Record general request
peerData.general.push(now);
return true;
}
/**
* Clean up old entries
* @param {number} now - Current timestamp
*/
cleanup(now) {
const maxAge = Math.max(
this.config.windowMs,
...Object.values(this.config.commandLimits).map(l => l.windowMs)
);
for (const [peerId, peerData] of this.requests.entries()) {
// Clean general requests
peerData.general = peerData.general.filter(timestamp => timestamp > now - maxAge);
// Clean command requests
for (const [command, timestamps] of Object.entries(peerData.commands)) {
const limit = this.config.commandLimits[command];
if (limit) {
peerData.commands[command] = timestamps.filter(
timestamp => timestamp > now - limit.windowMs
);
}
}
// Remove peer if no active requests
if (peerData.general.length === 0 &&
Object.values(peerData.commands).every(arr => arr.length === 0)) {
this.requests.delete(peerId);
}
}
}
/**
* Reset rate limit for a peer (useful for testing or manual override)
* @param {Object} peer - Peer object
*/
reset(peer) {
const peerId = this.getPeerId(peer);
this.requests.delete(peerId);
}
}
// Singleton instance
const rateLimiter = new RateLimiter();
export default rateLimiter;
+236
View File
@@ -0,0 +1,236 @@
/**
* Input validation and sanitization utilities for server-side security
*/
/**
* Validates Docker image name against Docker naming conventions
* @param {string} image - Docker image name
* @returns {boolean} - True if valid
*/
function isValidImageName(image) {
if (!image || typeof image !== 'string') return false;
// Docker image name pattern: [registry/][namespace/]name[:tag]
// Allowed characters: lowercase letters, numbers, dots, hyphens, underscores, slashes, colons
const imagePattern = /^([a-z0-9._-]+\/)*[a-z0-9._-]+(:[a-zA-Z0-9._-]+)?$/;
// Max length check
if (image.length > 255) return false;
return imagePattern.test(image);
}
/**
* Validates container name against Docker naming conventions
* @param {string} name - Container name
* @returns {boolean} - True if valid
*/
function isValidContainerName(name) {
if (!name || typeof name !== 'string') return false;
// Container names: alphanumeric, dashes, underscores, dots
// Must start and end with alphanumeric
const namePattern = /^[a-zA-Z0-9]([a-zA-Z0-9._-]*[a-zA-Z0-9])?$/;
// Length constraints (1-63 characters for hostname compatibility)
if (name.length < 1 || name.length > 63) return false;
return namePattern.test(name);
}
/**
* Sanitizes environment variable name
* @param {string} name - Environment variable name
* @returns {string|null} - Sanitized name or null if invalid
*/
function sanitizeEnvVarName(name) {
if (!name || typeof name !== 'string') return null;
// Environment variable names: letters, numbers, underscores
// Must start with letter or underscore
const sanitized = name.trim();
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(sanitized)) return null;
if (sanitized.length > 100) return null; // Reasonable limit
return sanitized;
}
/**
* Sanitizes environment variable value
* @param {string} value - Environment variable value
* @returns {string} - Sanitized value
*/
function sanitizeEnvVarValue(value) {
if (value === null || value === undefined) return '';
if (typeof value !== 'string') return String(value);
// Remove null bytes and control characters (except newline, tab)
return value.replace(/[\x00-\x08\x0B-\x0C\x0E-\x1F\x7F]/g, '').trim();
}
/**
* Validates port mapping format
* @param {string} portMapping - Port mapping string (e.g., "8080:80/tcp")
* @returns {boolean} - True if valid
*/
function isValidPortMapping(portMapping) {
if (!portMapping || typeof portMapping !== 'string') return false;
// Format: [hostPort:]containerPort[/protocol]
const portPattern = /^(\d+)?:?\d+\/(tcp|udp)$/;
if (!portPattern.test(portMapping)) return false;
const parts = portMapping.split(':');
if (parts.length === 2) {
const [hostPort, rest] = parts;
const port = parseInt(hostPort, 10);
if (port < 1 || port > 65535) return false;
}
const containerPort = parseInt(parts[parts.length - 1].split('/')[0], 10);
return containerPort >= 1 && containerPort <= 65535;
}
/**
* Validates volume mount format
* @param {string} volume - Volume mount string (e.g., "/host:/container:ro")
* @returns {boolean} - True if valid
*/
function isValidVolumeMount(volume) {
if (!volume || typeof volume !== 'string') return false;
if (!volume.includes(':')) return false;
const parts = volume.split(':');
if (parts.length < 2 || parts.length > 3) return false;
// Check for path traversal attempts
if (parts.some(part => part.includes('..'))) return false;
// Basic path validation
const pathPattern = /^(\/[^\/]+)*\/?$/;
return parts.slice(0, 2).every(part => pathPattern.test(part) || part.startsWith('/'));
}
/**
* Sanitizes label key
* @param {string} key - Label key
* @returns {string|null} - Sanitized key or null if invalid
*/
function sanitizeLabelKey(key) {
if (!key || typeof key !== 'string') return null;
// Docker label keys: alphanumeric, dots, hyphens, underscores
const sanitized = key.trim();
if (!/^[a-zA-Z0-9._-]+$/.test(sanitized)) return null;
if (sanitized.length > 250) return null;
return sanitized;
}
/**
* Sanitizes label value
* @param {string} value - Label value
* @returns {string} - Sanitized value
*/
function sanitizeLabelValue(value) {
if (value === null || value === undefined) return '';
if (typeof value !== 'string') return String(value);
// Remove null bytes
return value.replace(/\x00/g, '').trim();
}
/**
* Validates hostname
* @param {string} hostname - Hostname string
* @returns {boolean} - True if valid
*/
function isValidHostname(hostname) {
if (!hostname || typeof hostname !== 'string') return false;
// Hostname: alphanumeric, dots, hyphens
// Max 253 characters total, each label max 63
const hostnamePattern = /^([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)*[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$/;
if (hostname.length > 253) return false;
return hostnamePattern.test(hostname);
}
/**
* Validates DNS server IP address
* @param {string} dns - DNS server IP
* @returns {boolean} - True if valid
*/
function isValidDnsServer(dns) {
if (!dns || typeof dns !== 'string') return false;
// IPv4 pattern
const ipv4Pattern = /^(\d{1,3}\.){3}\d{1,3}$/;
if (ipv4Pattern.test(dns)) {
const parts = dns.split('.');
return parts.every(part => {
const num = parseInt(part, 10);
return num >= 0 && num <= 255;
});
}
// IPv6 pattern (simplified)
const ipv6Pattern = /^([0-9a-fA-F]{0,4}:){2,7}[0-9a-fA-F]{0,4}$/;
return ipv6Pattern.test(dns);
}
/**
* Sanitizes string input by removing dangerous characters
* @param {string} input - Input string
* @param {number} maxLength - Maximum length
* @returns {string} - Sanitized string
*/
function sanitizeString(input, maxLength = 1000) {
if (input === null || input === undefined) return '';
if (typeof input !== 'string') return String(input);
// Remove null bytes and control characters
let sanitized = input.replace(/[\x00-\x1F\x7F]/g, '').trim();
// Enforce max length
if (sanitized.length > maxLength) {
sanitized = sanitized.substring(0, maxLength);
}
return sanitized;
}
/**
* Validates numeric input within range
* @param {any} value - Input value
* @param {number} min - Minimum value
* @param {number} max - Maximum value
* @returns {number|null} - Validated number or null
*/
function validateNumber(value, min = -Infinity, max = Infinity) {
if (value === null || value === undefined || value === '') return null;
const num = typeof value === 'number' ? value : parseFloat(value);
if (isNaN(num)) return null;
if (num < min || num > max) return null;
return num;
}
export {
isValidImageName,
isValidContainerName,
sanitizeEnvVarName,
sanitizeEnvVarValue,
isValidPortMapping,
isValidVolumeMount,
sanitizeLabelKey,
sanitizeLabelValue,
isValidHostname,
isValidDnsServer,
sanitizeString,
validateNumber
};