test enhancements
This commit is contained in:
+304
-116
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user