mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-06 08:58:05 +00:00
4e5ba17710
* refactor(backend): sanitize user input before logging to close CRLF injection
Adds a small sanitizeForLog helper that strips CR, LF, tab, and ASCII
control characters (0x00-0x1F, 0x7F) from a value before it is embedded
in a console.log/warn/error/debug call. Wraps every call site where a
user-controlled value (req.params, req.body, req.query, or a value
derived from them) flows into a log message.
Closes the bulk of the open CodeQL alerts in this family:
- 96 js/log-injection
- 28 js/tainted-format-string
The helper is in backend/src/utils/safeLog.ts. Routes still pre-validate
input at the request boundary; this is the second line of defense and
gives static analyzers a sanitizer they can trace through. JSON
responses, Docker filter labels, and other non-log call sites are
intentionally left unwrapped.
* refactor(backend): printf-style format strings for tainted-log call sites
CodeQL's js/tainted-format-string rule flags template literals in the first
arg of console.X when any interpolated value is user-controlled, regardless
of whether each value is sanitized inline. The canonical mitigation is to
use a static format string and pass values as positional args.
Converts the 28 flagged template literals to printf-style ("%s") format
strings, with sanitizeForLog applied to each positional arg. Also fills in
the log-injection wraps on 9 sites where a user-controlled value was
missed in the first sweep (agents, fleet, gitSources, imageUpdates,
GitSourceService).
No behavior change at runtime. Node's util.format substitutes %s tokens
identically to template-literal interpolation.
* fix(backend): wrap nodeId/snapshotId in fleet restore debug log
CodeQL flagged the unwrapped numeric args even though they cannot
contain control chars in practice. Apply the sanitizer for taint-flow
recognition.
1288 lines
47 KiB
TypeScript
1288 lines
47 KiB
TypeScript
import Docker from 'dockerode';
|
|
import WebSocket from 'ws';
|
|
import { exec } from 'child_process';
|
|
import { promisify } from 'util';
|
|
import path from 'path';
|
|
import fs from 'fs/promises';
|
|
import * as yaml from 'yaml';
|
|
|
|
import { NodeRegistry } from './NodeRegistry';
|
|
import { CacheService } from './CacheService';
|
|
import { isPathWithinBase } from '../utils/validation';
|
|
import { isDebugEnabled } from '../utils/debug';
|
|
import { sanitizeForLog } from '../utils/safeLog';
|
|
|
|
const execAsync = promisify(exec);
|
|
const COMPOSE_DIR = process.env.COMPOSE_DIR || '/app/compose';
|
|
|
|
/** Canonical compose file name variants, checked in priority order. */
|
|
const COMPOSE_FILE_NAMES = ['compose.yaml', 'compose.yml', 'docker-compose.yaml', 'docker-compose.yml'] as const;
|
|
|
|
/** Cached mapping from compose `name:` field to stack directory name. TTL-based to avoid re-parsing YAML on every poll. */
|
|
const PROJECT_NAME_CACHE_TTL_MS = 60_000;
|
|
const PROJECT_NAME_CACHE_KEY = 'project-name-map';
|
|
|
|
/** Common web-UI private ports, checked in priority order when detecting the main app port. */
|
|
const WEB_UI_PORTS = [32400, 8989, 7878, 9696, 5055, 8080, 80, 443, 3000, 9000];
|
|
/** Ports that should never be treated as the main app port. */
|
|
const IGNORE_PORTS = [1900, 53, 22];
|
|
|
|
export interface BulkStackInfo {
|
|
status: 'running' | 'exited' | 'unknown';
|
|
mainPort?: number;
|
|
/** Unix seconds of the oldest running container (approximates stack uptime). */
|
|
runningSince?: number;
|
|
}
|
|
|
|
export interface ClassifiedImage {
|
|
Id: string;
|
|
RepoTags: string[];
|
|
Size: number;
|
|
Containers: number;
|
|
managedBy: string | null;
|
|
managedStatus: 'managed' | 'unmanaged' | 'unused';
|
|
}
|
|
|
|
export interface PortInUseInfo {
|
|
stack: string | null;
|
|
container: string;
|
|
}
|
|
|
|
export interface ClassifiedVolume {
|
|
Name: string;
|
|
Driver: string;
|
|
Mountpoint: string;
|
|
Size: number;
|
|
CreatedAt: string | null;
|
|
managedBy: string | null;
|
|
managedStatus: 'managed' | 'unmanaged';
|
|
}
|
|
|
|
export interface ClassifiedNetwork {
|
|
Id: string;
|
|
Name: string;
|
|
Driver: string;
|
|
Scope: string;
|
|
managedBy: string | null;
|
|
managedStatus: 'managed' | 'unmanaged' | 'system';
|
|
}
|
|
|
|
export interface TopologyContainer {
|
|
id: string;
|
|
name: string;
|
|
ip: string;
|
|
state: string;
|
|
image: string;
|
|
stack: string | null;
|
|
}
|
|
|
|
export interface TopologyNetwork {
|
|
Id: string;
|
|
Name: string;
|
|
Driver: string;
|
|
Scope: string;
|
|
managedBy: string | null;
|
|
managedStatus: 'managed' | 'unmanaged' | 'system';
|
|
containers: TopologyContainer[];
|
|
}
|
|
|
|
export type NetworkDriver = 'bridge' | 'overlay' | 'macvlan' | 'host' | 'none';
|
|
|
|
export interface CreateNetworkOptions {
|
|
Name: string;
|
|
Driver?: NetworkDriver;
|
|
IPAM?: { Config: Array<{ Subnet?: string; Gateway?: string }> };
|
|
Labels?: Record<string, string>;
|
|
Internal?: boolean;
|
|
Attachable?: boolean;
|
|
}
|
|
|
|
class DockerController {
|
|
private static readonly SYSTEM_NETWORKS = new Set(['bridge', 'host', 'none']);
|
|
private docker: Docker;
|
|
private nodeId: number;
|
|
|
|
private constructor(nodeId: number) {
|
|
this.nodeId = nodeId;
|
|
this.docker = NodeRegistry.getInstance().getDocker(nodeId);
|
|
}
|
|
|
|
public static getInstance(nodeId?: number): DockerController {
|
|
const id = nodeId ?? NodeRegistry.getInstance().getDefaultNodeId();
|
|
return new DockerController(id);
|
|
}
|
|
|
|
public getDocker(): Docker {
|
|
return this.docker;
|
|
}
|
|
|
|
private validateApiData<T>(data: any): T {
|
|
// If the daemon port points to a web server (like Sencho UI), Dockerode receives HTML
|
|
if (typeof data === 'string') {
|
|
throw new Error("Invalid response from Docker API. Did you provide a web port instead of the Docker daemon port?");
|
|
}
|
|
return data as T;
|
|
}
|
|
|
|
public async getDiskUsage() {
|
|
const df = await this.docker.df();
|
|
|
|
const reclaimableContainers = (items: any[]) => {
|
|
if (!items || !Array.isArray(items)) return { bytes: 0, count: 0 };
|
|
const reclaimable = items.filter(i => i.State !== 'running');
|
|
const bytes = reclaimable.reduce((acc, item) => {
|
|
let size = item.SizeRw || item.SizeRootFs || 0;
|
|
if (item.UsageData && typeof item.UsageData.Size === 'number') {
|
|
size = item.UsageData.Size;
|
|
}
|
|
return acc + size;
|
|
}, 0);
|
|
return { bytes, count: reclaimable.length };
|
|
};
|
|
|
|
const reclaimableImages = (items: any[]) => {
|
|
if (!items || !Array.isArray(items)) return { bytes: 0, count: 0 };
|
|
const reclaimable = items.filter(i => i.Containers === 0);
|
|
const bytes = reclaimable.reduce((acc, item) => {
|
|
let size = item.VirtualSize || item.Size || item.SharedSize || 0;
|
|
if (item.UsageData && typeof item.UsageData.Size === 'number') {
|
|
size = item.UsageData.Size;
|
|
}
|
|
return acc + size;
|
|
}, 0);
|
|
return { bytes, count: reclaimable.length };
|
|
};
|
|
|
|
const reclaimableVolumes = (items: any[]) => {
|
|
if (!items || !Array.isArray(items)) return { bytes: 0, count: 0 };
|
|
const reclaimable = items.filter(i => i.UsageData?.RefCount === 0);
|
|
const bytes = reclaimable.reduce((acc, item) => {
|
|
const size = item.UsageData?.Size || 0;
|
|
return acc + size;
|
|
}, 0);
|
|
return { bytes, count: reclaimable.length };
|
|
};
|
|
|
|
const images = df.Images ? reclaimableImages(df.Images) : { bytes: 0, count: 0 };
|
|
const containers = df.Containers ? reclaimableContainers(df.Containers) : { bytes: 0, count: 0 };
|
|
const volumes = df.Volumes ? reclaimableVolumes(df.Volumes) : { bytes: 0, count: 0 };
|
|
|
|
return {
|
|
reclaimableImages: images.bytes,
|
|
reclaimableContainers: containers.bytes,
|
|
reclaimableVolumes: volumes.bytes,
|
|
reclaimableImageCount: images.count,
|
|
reclaimableContainerCount: containers.count,
|
|
reclaimableVolumeCount: volumes.count,
|
|
};
|
|
}
|
|
|
|
public async pruneSystem(target: 'containers' | 'images' | 'networks' | 'volumes', labelFilter?: string) {
|
|
let spaceReclaimed = 0;
|
|
if (target === 'containers') {
|
|
const filters: Record<string, string[]> = {};
|
|
if (labelFilter) filters.label = [labelFilter];
|
|
const r = await this.docker.pruneContainers({ filters });
|
|
spaceReclaimed = r.SpaceReclaimed || 0;
|
|
} else if (target === 'images') {
|
|
// Remove all unused images, not just dangling ones
|
|
const filters: Record<string, string[] | Record<string, boolean>> = { dangling: { 'false': true } };
|
|
if (labelFilter) filters.label = [labelFilter];
|
|
const r = await this.docker.pruneImages({ filters });
|
|
spaceReclaimed = r.SpaceReclaimed || 0;
|
|
} else if (target === 'networks') {
|
|
const filters: Record<string, string[]> = {};
|
|
if (labelFilter) filters.label = [labelFilter];
|
|
const r = await this.docker.pruneNetworks({ filters });
|
|
spaceReclaimed = (r as { SpaceReclaimed?: number }).SpaceReclaimed || 0;
|
|
} else if (target === 'volumes') {
|
|
const filters: Record<string, string[]> = { all: ['true'] };
|
|
if (labelFilter) filters.label = [labelFilter];
|
|
const r = await this.docker.pruneVolumes({ filters });
|
|
spaceReclaimed = r.SpaceReclaimed || 0;
|
|
}
|
|
|
|
return {
|
|
success: true,
|
|
reclaimedBytes: spaceReclaimed
|
|
};
|
|
}
|
|
|
|
public async getImages() {
|
|
const data = await this.docker.listImages({ all: false });
|
|
return this.validateApiData<any[]>(data);
|
|
}
|
|
|
|
public async getVolumes() {
|
|
const data = await this.docker.listVolumes();
|
|
const validated = this.validateApiData<any>(data);
|
|
return validated.Volumes || [];
|
|
}
|
|
|
|
public async getNetworks() {
|
|
const data = await this.docker.listNetworks();
|
|
return this.validateApiData<any[]>(data);
|
|
}
|
|
|
|
public async getClassifiedResources(knownStackNames: string[]): Promise<{
|
|
images: ClassifiedImage[];
|
|
volumes: ClassifiedVolume[];
|
|
networks: ClassifiedNetwork[];
|
|
}> {
|
|
const debug = isDebugEnabled();
|
|
const t0 = debug ? Date.now() : 0;
|
|
const knownSet = new Set(knownStackNames);
|
|
|
|
const [rawImages, rawVolumeData, rawNetworks, allContainers, projectToStack] = await Promise.all([
|
|
this.docker.listImages({ all: false }),
|
|
this.docker.listVolumes(),
|
|
this.docker.listNetworks(),
|
|
this.docker.listContainers({ all: true }),
|
|
DockerController.resolveProjectNameMap(knownStackNames),
|
|
]);
|
|
|
|
const rawVolumes: any[] = (this.validateApiData<any>(rawVolumeData)).Volumes || [];
|
|
|
|
// Build fallback lookup structures for container-to-stack resolution
|
|
const absDirToStack = DockerController.buildAbsDirMap(knownStackNames);
|
|
const resolvedBase = path.resolve(COMPOSE_DIR);
|
|
|
|
// Build imageId → stack mapping using the full fallback resolution chain
|
|
const imageToStack = new Map<string, string>();
|
|
for (const c of allContainers as any[]) {
|
|
if (!c.ImageID) continue;
|
|
const stack = DockerController.resolveContainerStack(
|
|
c.Labels, projectToStack, knownSet, absDirToStack, resolvedBase,
|
|
);
|
|
if (stack) imageToStack.set(c.ImageID, stack);
|
|
}
|
|
|
|
const images: ClassifiedImage[] = this.validateApiData<any[]>(rawImages).map((img: any) => {
|
|
const stack = imageToStack.get(img.Id) ?? null;
|
|
const managedStatus: ClassifiedImage['managedStatus'] =
|
|
img.Containers === 0 ? 'unused' :
|
|
stack ? 'managed' : 'unmanaged';
|
|
return {
|
|
Id: img.Id,
|
|
RepoTags: img.RepoTags ?? [],
|
|
Size: img.Size ?? 0,
|
|
Containers: img.Containers ?? 0,
|
|
managedBy: stack,
|
|
managedStatus,
|
|
};
|
|
});
|
|
|
|
const volumes: ClassifiedVolume[] = rawVolumes.map((vol: any) => {
|
|
const stack = DockerController.resolveProjectLabel(vol.Labels?.['com.docker.compose.project'], knownSet, projectToStack);
|
|
const managedStatus: ClassifiedVolume['managedStatus'] = stack ? 'managed' : 'unmanaged';
|
|
return {
|
|
Name: vol.Name,
|
|
Driver: vol.Driver,
|
|
Mountpoint: vol.Mountpoint,
|
|
Size: vol.UsageData?.Size ?? 0,
|
|
CreatedAt: vol.CreatedAt ?? null,
|
|
managedBy: stack,
|
|
managedStatus,
|
|
};
|
|
});
|
|
|
|
const networks: ClassifiedNetwork[] = this.validateApiData<any[]>(rawNetworks).map((net: any) => {
|
|
if (DockerController.SYSTEM_NETWORKS.has(net.Name)) {
|
|
return { Id: net.Id, Name: net.Name, Driver: net.Driver, Scope: net.Scope, managedBy: null, managedStatus: 'system' as const };
|
|
}
|
|
const stack = DockerController.resolveProjectLabel(net.Labels?.['com.docker.compose.project'], knownSet, projectToStack);
|
|
const managedStatus: ClassifiedNetwork['managedStatus'] = stack ? 'managed' : 'unmanaged';
|
|
return {
|
|
Id: net.Id,
|
|
Name: net.Name,
|
|
Driver: net.Driver,
|
|
Scope: net.Scope,
|
|
managedBy: stack,
|
|
managedStatus,
|
|
};
|
|
});
|
|
|
|
if (debug) console.debug('[Resources:debug] Classification completed', {
|
|
ms: Date.now() - t0, images: images.length, volumes: volumes.length, networks: networks.length,
|
|
});
|
|
|
|
return { images, volumes, networks };
|
|
}
|
|
|
|
public async pruneManagedOnly(
|
|
target: 'images' | 'volumes' | 'networks',
|
|
knownStackNames: string[]
|
|
): Promise<{ success: boolean; reclaimedBytes: number }> {
|
|
const knownSet = new Set(knownStackNames);
|
|
const projectToStack = await DockerController.resolveProjectNameMap(knownStackNames);
|
|
let reclaimedBytes = 0;
|
|
|
|
if (target === 'volumes') {
|
|
const rawVolumeData = await this.docker.listVolumes();
|
|
const rawVolumes: any[] = (this.validateApiData<any>(rawVolumeData)).Volumes || [];
|
|
const prunable = rawVolumes.filter((v: any) => {
|
|
return !!DockerController.resolveProjectLabel(v.Labels?.['com.docker.compose.project'], knownSet, projectToStack)
|
|
&& (v.UsageData?.RefCount ?? 1) === 0;
|
|
});
|
|
for (const vol of prunable) {
|
|
try {
|
|
await this.docker.getVolume(vol.Name).remove({ force: true });
|
|
reclaimedBytes += vol.UsageData?.Size ?? 0;
|
|
} catch (e) {
|
|
console.error(`[pruneManagedOnly] Failed to remove volume ${vol.Name}:`, e);
|
|
}
|
|
}
|
|
} else if (target === 'networks') {
|
|
const rawNetworks = await this.docker.listNetworks();
|
|
const prunable = (rawNetworks as any[]).filter((n: any) => {
|
|
return !!DockerController.resolveProjectLabel(n.Labels?.['com.docker.compose.project'], knownSet, projectToStack);
|
|
});
|
|
for (const net of prunable) {
|
|
try {
|
|
await this.docker.getNetwork(net.Id).remove({ force: true });
|
|
} catch (e) {
|
|
console.error(`[pruneManagedOnly] Failed to remove network ${net.Name}:`, e);
|
|
}
|
|
}
|
|
} else if (target === 'images') {
|
|
const allContainers = await this.docker.listContainers({ all: true });
|
|
const resolvedBase = path.resolve(COMPOSE_DIR);
|
|
const absDirToStack = DockerController.buildAbsDirMap(knownStackNames);
|
|
const unmanagedImageIds = new Set<string>();
|
|
for (const c of allContainers as any[]) {
|
|
const stack = DockerController.resolveContainerStack(
|
|
c.Labels, projectToStack, knownSet, absDirToStack, resolvedBase,
|
|
);
|
|
if (!stack) unmanagedImageIds.add(c.ImageID);
|
|
}
|
|
const rawImages = await this.docker.listImages({ all: false });
|
|
const prunable = (rawImages as any[]).filter((img: any) =>
|
|
img.Containers === 0 && !unmanagedImageIds.has(img.Id)
|
|
);
|
|
for (const img of prunable) {
|
|
try {
|
|
await this.docker.getImage(img.Id).remove({ force: true });
|
|
reclaimedBytes += img.Size ?? 0;
|
|
} catch (e) {
|
|
console.error(`[pruneManagedOnly] Failed to remove image ${img.Id}:`, e);
|
|
}
|
|
}
|
|
}
|
|
|
|
return { success: true, reclaimedBytes };
|
|
}
|
|
|
|
public async getDiskUsageClassified(knownStackNames: string[]): Promise<{
|
|
reclaimableImages: number;
|
|
reclaimableContainers: number;
|
|
reclaimableVolumes: number;
|
|
reclaimableImageCount: number;
|
|
reclaimableContainerCount: number;
|
|
reclaimableVolumeCount: number;
|
|
managedImageBytes: number;
|
|
unmanagedImageBytes: number;
|
|
managedVolumeBytes: number;
|
|
unmanagedVolumeBytes: number;
|
|
}> {
|
|
const [base, classified] = await Promise.all([
|
|
this.getDiskUsage(),
|
|
this.getClassifiedResources(knownStackNames),
|
|
]);
|
|
|
|
const managedImageBytes = classified.images
|
|
.filter(i => i.managedStatus === 'managed')
|
|
.reduce((acc, i) => acc + i.Size, 0);
|
|
const unmanagedImageBytes = classified.images
|
|
.filter(i => i.managedStatus === 'unmanaged')
|
|
.reduce((acc, i) => acc + i.Size, 0);
|
|
|
|
// Volume disk usage: query raw volumes for UsageData (not available in classified resources)
|
|
const rawVolumeData = await this.docker.listVolumes();
|
|
const rawVolumes: any[] = (this.validateApiData<any>(rawVolumeData)).Volumes || [];
|
|
const projectToStack = await DockerController.resolveProjectNameMap(knownStackNames);
|
|
const knownSet = new Set(knownStackNames);
|
|
|
|
const isVolumeManaged = (v: any): boolean =>
|
|
!!DockerController.resolveProjectLabel(v.Labels?.['com.docker.compose.project'], knownSet, projectToStack);
|
|
|
|
const managedVolumeBytes = rawVolumes
|
|
.filter(isVolumeManaged)
|
|
.reduce((acc: number, v: any) => acc + (v.UsageData?.Size ?? 0), 0);
|
|
const unmanagedVolumeBytes = rawVolumes
|
|
.filter((v: any) => !isVolumeManaged(v))
|
|
.reduce((acc: number, v: any) => acc + (v.UsageData?.Size ?? 0), 0);
|
|
|
|
return { ...base, managedImageBytes, unmanagedImageBytes, managedVolumeBytes, unmanagedVolumeBytes };
|
|
}
|
|
|
|
public async removeImage(id: string) {
|
|
const image = this.docker.getImage(id);
|
|
await image.remove({ force: true });
|
|
}
|
|
|
|
public async removeVolume(name: string) {
|
|
const volume = this.docker.getVolume(name);
|
|
await volume.remove({ force: true });
|
|
}
|
|
|
|
public async removeNetwork(id: string) {
|
|
const network = this.docker.getNetwork(id);
|
|
await network.remove({ force: true });
|
|
}
|
|
|
|
public async inspectNetwork(id: string) {
|
|
const network = this.docker.getNetwork(id);
|
|
return await network.inspect();
|
|
}
|
|
|
|
public async createNetwork(options: CreateNetworkOptions) {
|
|
if (!options.Name || !/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(options.Name)) {
|
|
throw new Error('Invalid network name. Use alphanumeric characters, hyphens, underscores, and dots.');
|
|
}
|
|
return await this.docker.createNetwork(options);
|
|
}
|
|
|
|
public async getRunningContainers() {
|
|
const containers = await this.docker.listContainers({ all: false });
|
|
return this.validateApiData<any[]>(containers);
|
|
}
|
|
|
|
public async getAllContainers() {
|
|
const containers = await this.docker.listContainers({ all: true });
|
|
return this.validateApiData<any[]>(containers);
|
|
}
|
|
|
|
/**
|
|
* Builds topology data with 2 Docker API calls instead of N+1.
|
|
* Fetches all networks + all containers in parallel, then maps
|
|
* container-to-network relationships in memory using NetworkSettings.
|
|
*/
|
|
public async getTopologyData(
|
|
knownStackNames: string[],
|
|
includeSystem: boolean,
|
|
): Promise<TopologyNetwork[]> {
|
|
const debug = isDebugEnabled();
|
|
const t0 = debug ? Date.now() : 0;
|
|
const knownSet = new Set(knownStackNames);
|
|
|
|
const [rawNetworks, rawContainers, projectToStack] = await Promise.all([
|
|
this.docker.listNetworks(),
|
|
this.docker.listContainers({ all: true }),
|
|
DockerController.resolveProjectNameMap(knownStackNames),
|
|
]);
|
|
|
|
const absDirToStack = DockerController.buildAbsDirMap(knownStackNames);
|
|
const resolvedBase = path.resolve(COMPOSE_DIR);
|
|
|
|
const networks = this.validateApiData<any[]>(rawNetworks);
|
|
const containers = this.validateApiData<any[]>(rawContainers);
|
|
|
|
// Build network map, optionally filtering system networks
|
|
const networkMap = new Map<string, TopologyNetwork>();
|
|
for (const net of networks) {
|
|
const isSystem = DockerController.SYSTEM_NETWORKS.has(net.Name);
|
|
if (isSystem && !includeSystem) continue;
|
|
|
|
const stack = isSystem
|
|
? null
|
|
: DockerController.resolveProjectLabel(
|
|
net.Labels?.['com.docker.compose.project'],
|
|
knownSet,
|
|
projectToStack,
|
|
);
|
|
const managedStatus: TopologyNetwork['managedStatus'] = isSystem
|
|
? 'system'
|
|
: stack ? 'managed' : 'unmanaged';
|
|
|
|
networkMap.set(net.Id, {
|
|
Id: net.Id,
|
|
Name: net.Name,
|
|
Driver: net.Driver ?? 'bridge',
|
|
Scope: net.Scope ?? 'local',
|
|
managedBy: stack,
|
|
managedStatus,
|
|
containers: [],
|
|
});
|
|
}
|
|
|
|
// Map containers to their networks via NetworkSettings.
|
|
// Stack resolution is deferred until a network match is found to avoid
|
|
// wasted work for containers not attached to any tracked network.
|
|
for (const c of containers) {
|
|
const netSettings: Record<string, { NetworkID?: string; IPAddress?: string }> =
|
|
c.NetworkSettings?.Networks ?? {};
|
|
|
|
let containerStack: string | null | undefined;
|
|
let stackResolved = false;
|
|
|
|
for (const [, netInfo] of Object.entries(netSettings)) {
|
|
const netId = netInfo.NetworkID;
|
|
if (!netId) continue;
|
|
const topology = networkMap.get(netId);
|
|
if (!topology) continue;
|
|
|
|
if (!stackResolved) {
|
|
containerStack = DockerController.resolveContainerStack(
|
|
c.Labels, projectToStack, knownSet, absDirToStack, resolvedBase,
|
|
);
|
|
stackResolved = true;
|
|
}
|
|
|
|
topology.containers.push({
|
|
id: c.Id,
|
|
name: (c.Names?.[0] ?? '').replace(/^\//, '') || (c.Id ?? '').substring(0, 12),
|
|
ip: netInfo.IPAddress ?? '',
|
|
state: c.State ?? 'unknown',
|
|
image: c.Image ?? '',
|
|
stack: containerStack ?? null,
|
|
});
|
|
}
|
|
}
|
|
|
|
const result = Array.from(networkMap.values());
|
|
|
|
if (debug) {
|
|
const totalContainers = result.reduce((sum, n) => sum + n.containers.length, 0);
|
|
console.debug('[Resources:debug] Topology built', {
|
|
ms: Date.now() - t0,
|
|
networks: result.length,
|
|
containers: totalContainers,
|
|
systemFiltered: !includeSystem,
|
|
stacksKnown: knownStackNames.length,
|
|
});
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
/** Resolves a Docker Compose project label to a known Sencho stack name, or null. */
|
|
private static resolveProjectLabel(
|
|
project: string | undefined,
|
|
knownSet: Set<string>,
|
|
projectToStack: Record<string, string>,
|
|
): string | null {
|
|
if (!project) return null;
|
|
if (knownSet.has(project)) return project;
|
|
if (projectToStack[project]) return projectToStack[project];
|
|
return null;
|
|
}
|
|
|
|
/** Builds a map from absolute stack directory paths to stack names. */
|
|
private static buildAbsDirMap(stackNames: string[]): Record<string, string> {
|
|
const map: Record<string, string> = {};
|
|
for (const stackDir of stackNames) {
|
|
map[path.join(COMPOSE_DIR, stackDir)] = stackDir;
|
|
}
|
|
return map;
|
|
}
|
|
|
|
/**
|
|
* Resolves which Sencho stack a container belongs to using a multi-fallback strategy.
|
|
* Handles containers whose labels predate Sencho's reorganization of compose files into subdirectories.
|
|
*/
|
|
private static resolveContainerStack(
|
|
containerLabels: Record<string, string> | undefined,
|
|
projectToStack: Record<string, string>,
|
|
knownStackSet: Set<string>,
|
|
absDirToStack: Record<string, string>,
|
|
resolvedBase: string,
|
|
): string | null {
|
|
if (!containerLabels) return null;
|
|
|
|
// Primary: match by project name (handles name: overrides and standard directory-based names)
|
|
const project = containerLabels['com.docker.compose.project'];
|
|
if (project && projectToStack[project]) return projectToStack[project];
|
|
|
|
// Fallback 1: match by working_dir
|
|
const workingDir = containerLabels['com.docker.compose.project.working_dir'];
|
|
if (workingDir) {
|
|
const match = absDirToStack[workingDir] ?? absDirToStack[path.resolve(workingDir)];
|
|
if (match) return match;
|
|
}
|
|
|
|
// Fallback 2: match by service name
|
|
const serviceName = containerLabels['com.docker.compose.service'];
|
|
if (serviceName && knownStackSet.has(serviceName)) return serviceName;
|
|
|
|
// Fallback 3: extract stack from config_files path
|
|
const configFiles = containerLabels['com.docker.compose.project.config_files'];
|
|
if (configFiles) {
|
|
const firstFile = configFiles.split(',')[0].trim();
|
|
const resolvedFile = path.resolve(firstFile);
|
|
if (isPathWithinBase(resolvedFile, resolvedBase)) {
|
|
const relative = resolvedFile.slice(resolvedBase.length + 1);
|
|
const firstSegment = relative.split(path.sep)[0].replace(/\.(ya?ml)$/, '');
|
|
if (knownStackSet.has(firstSegment)) return firstSegment;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Builds (or returns cached) mapping from Docker project name to Sencho stack directory name.
|
|
* Compose files with a top-level `name:` field override the default project name.
|
|
*/
|
|
private static async resolveProjectNameMap(stackNames: string[]): Promise<Record<string, string>> {
|
|
return CacheService.getInstance().getOrFetch(
|
|
PROJECT_NAME_CACHE_KEY,
|
|
PROJECT_NAME_CACHE_TTL_MS,
|
|
async () => {
|
|
const map: Record<string, string> = {};
|
|
|
|
await Promise.all(stackNames.map(async (stackDir) => {
|
|
map[stackDir] = stackDir;
|
|
|
|
for (const fileName of COMPOSE_FILE_NAMES) {
|
|
const filePath = path.join(COMPOSE_DIR, stackDir, fileName);
|
|
try {
|
|
const content = await fs.readFile(filePath, 'utf-8');
|
|
const parsed = yaml.parse(content);
|
|
if (parsed?.name && typeof parsed.name === 'string') {
|
|
map[parsed.name] = stackDir;
|
|
}
|
|
break;
|
|
} catch (err: unknown) {
|
|
const code = (err as NodeJS.ErrnoException)?.code;
|
|
if (code !== 'ENOENT' && code !== 'ENOTDIR') {
|
|
console.error('[DockerController] Failed to read %s:', sanitizeForLog(filePath), sanitizeForLog((err as Error)?.message ?? String(err)));
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}));
|
|
|
|
return map;
|
|
},
|
|
);
|
|
}
|
|
|
|
public async getBulkStackStatuses(stackNames: string[]): Promise<Record<string, BulkStackInfo>> {
|
|
// Run Docker API call and project name resolution in parallel
|
|
const [allContainers, projectToStack] = await Promise.all([
|
|
this.docker.listContainers({ all: true }),
|
|
DockerController.resolveProjectNameMap(stackNames),
|
|
]);
|
|
|
|
const absDirToStack = DockerController.buildAbsDirMap(stackNames);
|
|
const knownStackSet = new Set(stackNames);
|
|
const resolvedBase = path.resolve(COMPOSE_DIR);
|
|
|
|
const result: Record<string, BulkStackInfo> = {};
|
|
for (const name of stackNames) {
|
|
result[name] = { status: 'unknown' };
|
|
}
|
|
|
|
for (const container of allContainers as any[]) {
|
|
const stackDir = DockerController.resolveContainerStack(
|
|
container.Labels, projectToStack, knownStackSet, absDirToStack, resolvedBase,
|
|
);
|
|
|
|
if (!stackDir || !result[stackDir]) continue;
|
|
|
|
if (container.State === 'running') {
|
|
result[stackDir].status = 'running';
|
|
|
|
// Track the oldest running container's creation time as a proxy for
|
|
// stack uptime. Docker's listContainers payload exposes Created (unix
|
|
// seconds) but not StartedAt; for compose stacks the gap is small
|
|
// enough to treat as uptime without paying for a per-container inspect.
|
|
const created = typeof container.Created === 'number' ? container.Created : undefined;
|
|
if (created !== undefined) {
|
|
const existing = result[stackDir].runningSince;
|
|
if (existing === undefined || created < existing) {
|
|
result[stackDir].runningSince = created;
|
|
}
|
|
}
|
|
|
|
// Detect main web port (first running container with a matchable port wins)
|
|
if (result[stackDir].mainPort === undefined && Array.isArray(container.Ports) && container.Ports.length > 0) {
|
|
const ports = container.Ports as { PrivatePort?: number; PublicPort?: number }[];
|
|
let match = ports.find(p => p.PrivatePort && WEB_UI_PORTS.includes(p.PrivatePort));
|
|
if (!match) match = ports.find(p => p.PublicPort && WEB_UI_PORTS.includes(p.PublicPort));
|
|
if (!match) match = ports.find(p =>
|
|
(!p.PrivatePort || !IGNORE_PORTS.includes(p.PrivatePort)) &&
|
|
(!p.PublicPort || !IGNORE_PORTS.includes(p.PublicPort))
|
|
);
|
|
const chosen = match || ports[0];
|
|
if (chosen?.PublicPort) {
|
|
result[stackDir].mainPort = chosen.PublicPort;
|
|
}
|
|
}
|
|
} else if (result[stackDir].status !== 'running') {
|
|
result[stackDir].status = 'exited';
|
|
}
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Returns a map of host ports currently bound by running containers,
|
|
* with ownership info (Sencho-managed stack name or external).
|
|
*/
|
|
public async getPortsInUse(knownStackNames: string[]): Promise<Record<number, PortInUseInfo>> {
|
|
const [allContainers, projectToStack] = await Promise.all([
|
|
this.docker.listContainers({ all: false }),
|
|
DockerController.resolveProjectNameMap(knownStackNames),
|
|
]);
|
|
|
|
const absDirToStack = DockerController.buildAbsDirMap(knownStackNames);
|
|
const knownStackSet = new Set(knownStackNames);
|
|
const resolvedBase = path.resolve(COMPOSE_DIR);
|
|
|
|
const result: Record<number, PortInUseInfo> = {};
|
|
|
|
for (const container of allContainers as Array<{ Names?: string[]; Labels?: Record<string, string>; Ports?: Array<{ PublicPort?: number }> }>) {
|
|
const stackDir = DockerController.resolveContainerStack(
|
|
container.Labels, projectToStack, knownStackSet, absDirToStack, resolvedBase,
|
|
);
|
|
|
|
const containerName = (container.Names?.[0] || '').replace(/^\//, '');
|
|
|
|
if (!Array.isArray(container.Ports)) continue;
|
|
|
|
for (const port of container.Ports) {
|
|
if (!port.PublicPort || port.PublicPort <= 0) continue;
|
|
// First container to claim a port wins (avoids overwrites)
|
|
if (result[port.PublicPort]) continue;
|
|
result[port.PublicPort] = { stack: stackDir, container: containerName };
|
|
}
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
public async getContainersByStack(stackName: string) {
|
|
const stackDir = path.join(COMPOSE_DIR, stackName);
|
|
|
|
try {
|
|
const { stdout, stderr } = await execAsync('docker compose ps --format json -a', {
|
|
cwd: stackDir,
|
|
env: {
|
|
...process.env,
|
|
PATH: process.env.PATH || '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin'
|
|
}
|
|
});
|
|
|
|
// Robust JSON parsing - handle both JSON array and newline-separated JSON objects
|
|
// Docker Compose v2 may return either format depending on version
|
|
interface ComposeContainer {
|
|
ID?: string;
|
|
Name?: string;
|
|
Service?: string;
|
|
State?: string;
|
|
Status?: string;
|
|
Publishers?: { URL?: string, TargetPort?: number, PublishedPort?: number }[];
|
|
}
|
|
|
|
let containers: ComposeContainer[] = [];
|
|
|
|
// Only parse if stdout has content
|
|
if (stdout && stdout.trim() !== '') {
|
|
try {
|
|
// Try parsing as a standard JSON array
|
|
const parsed = JSON.parse(stdout);
|
|
containers = Array.isArray(parsed) ? parsed : [parsed];
|
|
} catch (parseError) {
|
|
// Fallback: parse newline-separated JSON objects, filtering out empty lines
|
|
try {
|
|
const lines = stdout.trim().split('\n').filter(line => line.trim() !== '');
|
|
containers = lines.map(line => JSON.parse(line) as ComposeContainer);
|
|
} catch (innerError) {
|
|
// Log parsing failure with stderr for debugging
|
|
console.error('Docker Compose JSON Parse Error for %s:', sanitizeForLog(stackName), sanitizeForLog(stderr || (parseError as Error).message));
|
|
// Don't return empty - trigger smart fallback below
|
|
}
|
|
}
|
|
}
|
|
|
|
// If containers found via docker compose ps, return them
|
|
if (containers.length > 0) {
|
|
// Map to frontend's expected interface
|
|
// Note: docker compose ps returns Name (singular), but frontend expects Names (array)
|
|
// Dockerode returns Names with leading slash, so we add it for compatibility
|
|
const mapped = containers.map((c) => {
|
|
let Ports: { PrivatePort: number, PublicPort: number }[] = [];
|
|
if (c.Publishers && Array.isArray(c.Publishers)) {
|
|
Ports = c.Publishers
|
|
.filter(p => typeof p.PublishedPort === 'number' && p.PublishedPort > 0)
|
|
.map(p => ({ PrivatePort: (p.TargetPort || 0) as number, PublicPort: p.PublishedPort as number }));
|
|
}
|
|
return {
|
|
Id: c.ID || '',
|
|
Names: ['/' + (c.Name || '')], // Add leading slash to match Dockerode format
|
|
Service: c.Service || '',
|
|
State: c.State || 'unknown',
|
|
Status: c.Status || '',
|
|
Ports
|
|
};
|
|
});
|
|
return await this.enrichContainers(mapped);
|
|
}
|
|
|
|
// SMART FALLBACK: Trigger when docker compose ps returns empty
|
|
// This handles legacy containers with incorrect project labels
|
|
return await this.enrichContainers(await this.smartFallback(stackName, stackDir));
|
|
|
|
} catch (error) {
|
|
// If command fails (e.g., stack not deployed, invalid YAML, missing env_file)
|
|
const execError = error as { stderr?: string; message?: string };
|
|
console.error('Docker Compose Error for %s:', sanitizeForLog(stackName), sanitizeForLog(execError.stderr || execError.message || 'unknown'));
|
|
|
|
// Try smart fallback even on error
|
|
return await this.enrichContainers(await this.smartFallback(stackName, stackDir));
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Inspect each container to attach healthcheck status, image tag, and image digest.
|
|
* Each mapper catches its own errors, so Promise.all never rejects (allSettled adds ceremony with no behavior change).
|
|
*/
|
|
private async enrichContainers<T extends { Id?: string }>(list: T[]): Promise<Array<T & { healthStatus: 'healthy' | 'unhealthy' | 'starting' | 'none'; Image?: string; ImageID?: string }>> {
|
|
return Promise.all(list.map(async (c) => {
|
|
const base = { ...c, healthStatus: 'none' as const };
|
|
if (!c.Id) return base;
|
|
try {
|
|
const info = await this.docker.getContainer(c.Id).inspect();
|
|
const health = info.State?.Health?.Status;
|
|
const healthStatus: 'healthy' | 'unhealthy' | 'starting' | 'none' =
|
|
health === 'healthy' || health === 'unhealthy' || health === 'starting' ? health : 'none';
|
|
return { ...c, healthStatus, Image: info.Config?.Image, ImageID: info.Image };
|
|
} catch {
|
|
return base;
|
|
}
|
|
}));
|
|
}
|
|
|
|
/**
|
|
* Smart Fallback: Find legacy containers by parsing compose YAML definitions.
|
|
* This handles containers that were deployed with incorrect project labels
|
|
* that cause `docker compose ps` to ignore them.
|
|
*/
|
|
private async smartFallback(stackName: string, stackDir: string): Promise<any[]> {
|
|
try {
|
|
// 1. Flexible Compose File Discovery
|
|
// Try multiple valid compose file names
|
|
const composeFileNames = COMPOSE_FILE_NAMES;
|
|
let yamlContent: string | null = null;
|
|
|
|
for (const fileName of composeFileNames) {
|
|
try {
|
|
yamlContent = await fs.readFile(path.join(stackDir, fileName), 'utf-8');
|
|
break; // Successfully read a file, stop trying
|
|
} catch {
|
|
// File doesn't exist, try next
|
|
continue;
|
|
}
|
|
}
|
|
|
|
if (!yamlContent) {
|
|
// No compose file found
|
|
return [];
|
|
}
|
|
|
|
const parsedYaml = yaml.parse(yamlContent);
|
|
|
|
if (!parsedYaml || !parsedYaml.services) return [];
|
|
|
|
// 2. Extract expected container names with legacy prefix support
|
|
const expectedNames: string[] = [];
|
|
for (const [serviceName, serviceConfig] of Object.entries(parsedYaml.services)) {
|
|
const config = serviceConfig as any;
|
|
if (config.container_name) {
|
|
expectedNames.push(config.container_name);
|
|
} else {
|
|
// Standard v2 naming
|
|
expectedNames.push(serviceName);
|
|
expectedNames.push(`${stackName}-${serviceName}-1`);
|
|
// Legacy project prefix catch - accounts for orphan containers
|
|
expectedNames.push(`compose-${serviceName}-1`);
|
|
expectedNames.push(`compose_${serviceName}_1`);
|
|
}
|
|
}
|
|
|
|
// 3. Query the raw Docker daemon
|
|
const allContainers = await this.docker.listContainers({ all: true });
|
|
|
|
// 4. Match containers by name
|
|
const fallbackContainers = allContainers.filter(container => {
|
|
// container.Names usually looks like ['/plex']
|
|
return container.Names.some(name => {
|
|
const strippedName = name.replace(/^\//, '');
|
|
return expectedNames.includes(strippedName);
|
|
});
|
|
});
|
|
|
|
// 5. Map to the frontend interface
|
|
return fallbackContainers.map(c => {
|
|
let Ports: { PrivatePort: number, PublicPort: number }[] = [];
|
|
if (c.Ports && Array.isArray(c.Ports)) {
|
|
Ports = c.Ports
|
|
.filter((p: any) => typeof p.PublicPort === 'number' && p.PublicPort > 0)
|
|
.map((p: any) => ({ PrivatePort: (p.PrivatePort || 0) as number, PublicPort: p.PublicPort as number }));
|
|
}
|
|
return {
|
|
Id: c.Id,
|
|
Names: c.Names,
|
|
State: c.State,
|
|
Status: c.Status,
|
|
Ports
|
|
};
|
|
});
|
|
} catch (fallbackError) {
|
|
console.error('Smart Fallback failed for %s:', sanitizeForLog(stackName), sanitizeForLog((fallbackError as Error)?.message ?? String(fallbackError)));
|
|
return [];
|
|
}
|
|
}
|
|
|
|
public async streamContainerLogs(containerId: string, req: any, res: any): Promise<void> {
|
|
const container = this.docker.getContainer(containerId);
|
|
|
|
// 1. Set SSE Headers
|
|
res.setHeader('Content-Type', 'text/event-stream');
|
|
res.setHeader('Cache-Control', 'no-cache');
|
|
res.setHeader('Connection', 'keep-alive');
|
|
res.setHeader('X-Accel-Buffering', 'no');
|
|
res.flushHeaders();
|
|
|
|
try {
|
|
const logStream = await container.logs({
|
|
follow: true,
|
|
stdout: true,
|
|
stderr: true,
|
|
tail: 100 // Send the last 100 lines immediately for context
|
|
});
|
|
|
|
// 2. Process and forward the stream
|
|
logStream.on('data', (chunk: Buffer) => {
|
|
// Docker multiplexes stdout/stderr with an 8-byte header if TTY is false.
|
|
let data = chunk;
|
|
if (chunk.length > 8 && (chunk[0] === 1 || chunk[0] === 2)) {
|
|
data = chunk.slice(8);
|
|
}
|
|
|
|
const text = data.toString('utf-8');
|
|
const lines = text.split('\n');
|
|
|
|
lines.forEach(line => {
|
|
if (line.trim()) {
|
|
res.write(`data: ${JSON.stringify(line)}\n\n`);
|
|
}
|
|
});
|
|
});
|
|
|
|
// 3. Cleanup on disconnect
|
|
req.on('close', () => {
|
|
(logStream as any).destroy();
|
|
});
|
|
|
|
} catch (error: any) {
|
|
res.write(`data: ${JSON.stringify('[Sencho] Error fetching logs: ' + error.message)}\n\n`);
|
|
res.end();
|
|
}
|
|
}
|
|
|
|
// State-safe: silently ignores 304 "already started" errors
|
|
public async startContainer(containerId: string) {
|
|
try {
|
|
const container = this.docker.getContainer(containerId);
|
|
await container.start();
|
|
} catch (error: any) {
|
|
if (error?.statusCode === 304) {
|
|
// Container already running - not an error
|
|
return;
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
// State-safe: silently ignores 304 "already stopped" errors
|
|
public async stopContainer(containerId: string) {
|
|
try {
|
|
const container = this.docker.getContainer(containerId);
|
|
await container.stop();
|
|
} catch (error: any) {
|
|
if (error?.statusCode === 304) {
|
|
// Container already stopped - not an error
|
|
return;
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
public async restartContainer(containerId: string) {
|
|
const container = this.docker.getContainer(containerId);
|
|
await container.restart();
|
|
}
|
|
|
|
public async getOrphanContainers(knownStackNames: string[]) {
|
|
// 1. Fetch all containers (running and stopped)
|
|
const allContainers = await this.docker.listContainers({ all: true });
|
|
|
|
// 2. Filter and categorize orphans
|
|
const orphans: Record<string, any[]> = {};
|
|
|
|
allContainers.forEach((container) => {
|
|
// Look for the docker compose project label
|
|
const projectName = container.Labels?.['com.docker.compose.project'];
|
|
|
|
// If it has a project label, but the project is NOT in our known list...
|
|
if (projectName && !knownStackNames.includes(projectName)) {
|
|
if (!orphans[projectName]) {
|
|
orphans[projectName] = [];
|
|
}
|
|
orphans[projectName].push({
|
|
Id: container.Id,
|
|
Names: container.Names,
|
|
State: container.State,
|
|
Status: container.Status,
|
|
Image: container.Image
|
|
});
|
|
}
|
|
});
|
|
|
|
return orphans;
|
|
}
|
|
|
|
public async removeContainers(containerIds: string[]) {
|
|
if (isDebugEnabled()) console.debug('[Resources:debug] removeContainers', { count: containerIds.length });
|
|
const results = [];
|
|
for (const id of containerIds) {
|
|
try {
|
|
const container = this.docker.getContainer(id);
|
|
await container.remove({ force: true });
|
|
results.push({ id, success: true });
|
|
} catch (error: any) {
|
|
console.error('Failed to remove container %s:', sanitizeForLog(id), sanitizeForLog(error.message));
|
|
results.push({ id, success: false, error: error.message });
|
|
}
|
|
}
|
|
return results;
|
|
}
|
|
|
|
public async streamStats(containerId: string, ws: WebSocket) {
|
|
const container = this.docker.getContainer(containerId);
|
|
const stats = await container.stats({ stream: true });
|
|
|
|
stats.on('data', (chunk: Buffer) => {
|
|
if (ws.readyState === WebSocket.OPEN) {
|
|
ws.send(chunk.toString());
|
|
}
|
|
});
|
|
|
|
stats.on('error', (err: Error) => {
|
|
if (ws.readyState === WebSocket.OPEN) {
|
|
ws.send(JSON.stringify({ error: err.message }));
|
|
}
|
|
});
|
|
|
|
stats.on('end', () => {
|
|
if (ws.readyState === WebSocket.OPEN) {
|
|
ws.send(JSON.stringify({ end: true }));
|
|
}
|
|
});
|
|
|
|
// Destroy the Docker stats stream when the WebSocket closes to prevent
|
|
// orphaned streams polling the daemon after client disconnect.
|
|
ws.on('close', () => {
|
|
try { (stats as any).destroy(); } catch (e) {
|
|
// Stream already ended before client disconnected
|
|
console.warn('[DockerController] Stats stream already ended on WS close:', (e as Error).message);
|
|
}
|
|
});
|
|
}
|
|
|
|
public async getContainerStatsStream(containerId: string): Promise<string> {
|
|
const container = this.docker.getContainer(containerId);
|
|
const stats = await container.stats({ stream: false });
|
|
return typeof stats === 'string' ? stats : JSON.stringify(stats);
|
|
}
|
|
|
|
/** Return the cumulative restart count for a container via inspect(). */
|
|
public async getContainerRestartCount(containerId: string): Promise<number> {
|
|
const container = this.docker.getContainer(containerId);
|
|
const info = await container.inspect();
|
|
return info.RestartCount ?? 0;
|
|
}
|
|
|
|
/**
|
|
* Exec into a container with full session isolation.
|
|
* All state (exec instance, stream) lives in this closure - no singleton traps.
|
|
* The WebSocket message handler is registered here to handle input, resize, and cleanup.
|
|
*/
|
|
public async execContainer(containerId: string, ws: WebSocket) {
|
|
try {
|
|
// Input validation
|
|
if (!containerId || typeof containerId !== 'string') {
|
|
console.warn('[Exec] Empty or invalid containerId');
|
|
if (ws.readyState === WebSocket.OPEN) {
|
|
ws.send('\r\n\x1b[31mError: No container ID provided\x1b[0m\r\n');
|
|
ws.close();
|
|
}
|
|
return;
|
|
}
|
|
|
|
const container = this.docker.getContainer(containerId);
|
|
|
|
// Verify the container is running before attempting exec
|
|
const info = await container.inspect();
|
|
if (!info.State?.Running) {
|
|
console.warn('[Exec] Container not running:', containerId);
|
|
if (ws.readyState === WebSocket.OPEN) {
|
|
ws.send('\r\n\x1b[31mError: Container is not running\x1b[0m\r\n');
|
|
ws.close();
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Try bash first, fall back to sh.
|
|
// Both exec creation AND start must be inside the try/catch because
|
|
// some runtimes reject unknown binaries at start(), not at creation.
|
|
const execOpts = { AttachStdin: true, AttachStdout: true, AttachStderr: true, Tty: true } as const;
|
|
let dockerExec: Docker.Exec;
|
|
let stream: import('stream').Duplex;
|
|
let shellType = '/bin/bash';
|
|
try {
|
|
dockerExec = await container.exec({ ...execOpts, Cmd: ['/bin/bash'] });
|
|
stream = await dockerExec.start({ hijack: true, stdin: true });
|
|
} catch {
|
|
shellType = '/bin/sh';
|
|
dockerExec = await container.exec({ ...execOpts, Cmd: ['/bin/sh'] });
|
|
stream = await dockerExec.start({ hijack: true, stdin: true });
|
|
}
|
|
|
|
if (isDebugEnabled()) console.debug('[Exec:diag] Creating exec', { containerId, shell: shellType });
|
|
console.log('[Exec] Shell session started', { containerId, shell: shellType });
|
|
|
|
// --- Downstream: container output → client ---
|
|
stream.on('data', (chunk: Buffer) => {
|
|
if (ws.readyState === WebSocket.OPEN) {
|
|
ws.send(chunk.toString());
|
|
}
|
|
});
|
|
|
|
stream.on('error', (err: Error) => {
|
|
console.error('[Exec] Stream error:', err.message, { containerId });
|
|
});
|
|
|
|
stream.on('end', () => {
|
|
console.log('[Exec] Shell session ended', { containerId, reason: 'stream-end' });
|
|
if (ws.readyState === WebSocket.OPEN) {
|
|
ws.close();
|
|
}
|
|
});
|
|
|
|
// --- Upstream: client messages → container ---
|
|
ws.on('message', (raw: WebSocket.Data) => {
|
|
try {
|
|
const msg = JSON.parse(raw.toString());
|
|
|
|
switch (msg.type) {
|
|
case 'input':
|
|
if (msg.data) {
|
|
stream.write(msg.data);
|
|
}
|
|
break;
|
|
|
|
case 'resize':
|
|
if (msg.rows && msg.cols) {
|
|
if (isDebugEnabled()) console.debug('[Exec:diag] Terminal resize', { containerId, rows: msg.rows, cols: msg.cols });
|
|
dockerExec.resize({ h: msg.rows, w: msg.cols }).catch((e: Error) => {
|
|
// Exec may have ended before resize completes
|
|
console.warn('[Exec] Resize failed (exec may have ended):', e.message);
|
|
});
|
|
}
|
|
break;
|
|
|
|
case 'ping':
|
|
// Keep-alive, no-op
|
|
break;
|
|
}
|
|
} catch (e) {
|
|
// Non-JSON or malformed WebSocket message
|
|
console.warn('[Exec] Ignoring malformed WS message:', (e as Error).message);
|
|
}
|
|
});
|
|
|
|
// --- Cleanup: prevent zombie processes ---
|
|
ws.on('close', () => {
|
|
console.log('[Exec] Shell session ended', { containerId, reason: 'ws-close' });
|
|
try {
|
|
stream.destroy();
|
|
} catch (e) {
|
|
// Stream already destroyed before WS close
|
|
console.warn('[Exec] Stream already destroyed on WS close:', (e as Error).message);
|
|
}
|
|
});
|
|
|
|
} catch (error) {
|
|
const err = error as Error;
|
|
console.error('[Exec] Failed to start shell:', err.message, { containerId });
|
|
if (ws.readyState === WebSocket.OPEN) {
|
|
ws.send(`\r\n\x1b[31mFailed to start shell: ${err.message}\x1b[0m\r\n`);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
export const globalDockerNetwork = { rxSec: 0, txSec: 0 };
|
|
let lastNetSum = { rx: 0, tx: 0, timestamp: Date.now() };
|
|
let isUpdatingNetwork = false;
|
|
|
|
export const updateGlobalDockerNetwork = async () => {
|
|
if (isUpdatingNetwork) return; // Prevent overlapping calls
|
|
isUpdatingNetwork = true;
|
|
try {
|
|
const nodeId = NodeRegistry.getInstance().getDefaultNodeId();
|
|
const dockerController = DockerController.getInstance(nodeId);
|
|
const containers = await dockerController.getRunningContainers();
|
|
|
|
const statsResults = await Promise.allSettled(
|
|
containers.map(c => dockerController.getContainerStatsStream(c.Id))
|
|
);
|
|
|
|
let currentRxSum = 0;
|
|
let currentTxSum = 0;
|
|
|
|
for (const result of statsResults) {
|
|
if (result.status === 'fulfilled') {
|
|
try {
|
|
const stats = typeof result.value === 'string' ? JSON.parse(result.value) : result.value;
|
|
if (stats.networks) {
|
|
for (const [_, net] of Object.entries(stats.networks) as any) {
|
|
currentRxSum += net.rx_bytes || 0;
|
|
currentTxSum += net.tx_bytes || 0;
|
|
}
|
|
}
|
|
} catch (e) {
|
|
// ignore parsing errors
|
|
}
|
|
}
|
|
}
|
|
|
|
const now = Date.now();
|
|
const timeDiffSeconds = (now - lastNetSum.timestamp) / 1000;
|
|
|
|
if (timeDiffSeconds > 0) {
|
|
const rxDelta = currentRxSum >= lastNetSum.rx ? currentRxSum - lastNetSum.rx : 0;
|
|
const txDelta = currentTxSum >= lastNetSum.tx ? currentTxSum - lastNetSum.tx : 0;
|
|
|
|
globalDockerNetwork.rxSec = rxDelta / timeDiffSeconds;
|
|
globalDockerNetwork.txSec = txDelta / timeDiffSeconds;
|
|
}
|
|
|
|
lastNetSum = { rx: currentRxSum, tx: currentTxSum, timestamp: now };
|
|
} catch {
|
|
// Silently skip when Docker is unreachable (e.g. no local engine).
|
|
// Network stats will remain at their last known values.
|
|
} finally {
|
|
isUpdatingNetwork = false;
|
|
}
|
|
};
|
|
|
|
// Poll network stats every 5s (reduced from 3s to lower Docker daemon pressure)
|
|
setInterval(updateGlobalDockerNetwork, 5000);
|
|
|
|
export default DockerController;
|