/** * Pre-deploy networking checks for container creates (template deploy + add container). * * Catches incomplete port rows (container port set, host empty), invalid ranges, * in-form duplicates, privileged host ports, and peer-side host port conflicts. */ import { manager, Methods } from './manager.js' /** * @typedef {{ * raw: string, * hostPort: number|null, * containerPort: number|null, * protocol: string, * incomplete: boolean, * index: number, * }} ParsedPortMapping */ /** * Parse a port mapping string produced by collectPortMappings / collectPorts. * Formats: "host:container/proto", "container/proto", "host:container", "container" * * @param {string} portStr * @param {number} [index=0] * @returns {ParsedPortMapping|null} */ export function parsePortMapping(portStr, index = 0) { const raw = String(portStr ?? '').trim() if (!raw) return null let hostPort = null let containerPort = null let protocol = 'tcp' if (raw.includes(':')) { const [hostPart, rest] = raw.split(':') const hostTrim = String(hostPart || '').trim() if (hostTrim !== '') { const hp = Number(hostTrim) hostPort = Number.isFinite(hp) ? hp : null } const restTrim = String(rest || '').trim() if (restTrim.includes('/')) { const [cPort, proto] = restTrim.split('/') const cp = Number(String(cPort || '').trim()) containerPort = Number.isFinite(cp) ? cp : null protocol = String(proto || 'tcp').trim().toLowerCase() === 'udp' ? 'udp' : 'tcp' } else { const cp = Number(restTrim) containerPort = Number.isFinite(cp) ? cp : null } } else if (raw.includes('/')) { const [cPort, proto] = raw.split('/') const cp = Number(String(cPort || '').trim()) containerPort = Number.isFinite(cp) ? cp : null protocol = String(proto || 'tcp').trim().toLowerCase() === 'udp' ? 'udp' : 'tcp' } else { const cp = Number(raw) containerPort = Number.isFinite(cp) ? cp : null } const incomplete = containerPort != null && Number.isFinite(containerPort) && containerPort >= 1 && (hostPort == null || !Number.isFinite(hostPort)) return { raw, hostPort: hostPort != null && Number.isFinite(hostPort) ? hostPort : null, containerPort: containerPort != null && Number.isFinite(containerPort) ? containerPort : null, protocol, incomplete: Boolean(incomplete), index, } } /** * @param {number|null|undefined} port * @returns {boolean} */ function isValidPortNumber(port) { return port != null && Number.isFinite(port) && port >= 1 && port <= 65535 } /** * Synchronous networking validation from collected deploy payload fields. * * @param {{ * ports?: string[], * networkMode?: string, * publishAllPorts?: boolean, * customNetwork?: string|null, * }} data * @returns {{ errors: string[], warnings: string[], parsed: ParsedPortMapping[] }} */ export function validateNetworkingSync(data = {}) { /** @type {string[]} */ const errors = [] /** @type {string[]} */ const warnings = [] const networkMode = String(data.networkMode || 'bridge') const publishAll = data.publishAllPorts === true const portsIn = Array.isArray(data.ports) ? data.ports : [] /** @type {ParsedPortMapping[]} */ const parsed = [] portsIn.forEach((p, i) => { const row = parsePortMapping(p, i) if (row) parsed.push(row) }) if (networkMode === 'host') { if (parsed.length > 0) { warnings.push( 'Network mode is "host" — published port mappings are ignored (the container shares the host network namespace).' ) } if (data.customNetwork) { warnings.push( 'Network mode is "host" — "attach to network" is ignored for this mode.' ) } return { errors, warnings, parsed } } if (networkMode === 'none' && parsed.length > 0) { warnings.push( 'Network mode is "none" — published ports will not be reachable from outside the container.' ) } if (networkMode === 'container' && !String(data.customNetwork || '').trim()) { errors.push( 'Network mode is "container" but no peer container name/id was set — How to fix: enter the container to share the network stack with.' ) } /** @type {Map} */ const hostKeys = new Map() parsed.forEach((row) => { const n = row.index + 1 if (row.containerPort == null || !isValidPortNumber(row.containerPort)) { errors.push( `Port mapping ${n}: container port is missing or invalid (must be 1–65535).` ) return } // Explicit publish row with only container side filled (common template pitfall) if (row.incomplete && !publishAll) { errors.push( `Port mapping ${n}: host port is empty while container port ${row.containerPort}/${row.protocol} is set — How to fix: enter a host port to publish, or remove this mapping.` ) } if (row.incomplete && publishAll) { warnings.push( `Port mapping ${n}: host port is empty (Publish all ports is on). Prefer an explicit host port so the published address is predictable.` ) } if (row.hostPort != null) { if (!isValidPortNumber(row.hostPort)) { errors.push(`Port mapping ${n}: host port must be between 1 and 65535.`) } else { if (row.hostPort < 1024) { warnings.push( `Port mapping ${n}: host port ${row.hostPort} is privileged (< 1024). Binding may fail unless the Docker host allows low ports (typically root / CAP_NET_BIND_SERVICE).` ) } const key = `${row.hostPort}/${row.protocol}` const list = hostKeys.get(key) || [] list.push(n) hostKeys.set(key, list) } } // container/protocol only — server currently maps HostPort = containerPort (surprise bind) if ( !row.incomplete && row.hostPort == null && isValidPortNumber(row.containerPort) && !publishAll ) { // Already covered by incomplete for empty host; keep for string forms without host: // "80/tcp" is incomplete by our definition (host null + container set) } }) for (const [key, idxs] of hostKeys) { if (idxs.length > 1) { errors.push( `Host port ${key} is used more than once in this form (mappings ${idxs.join(', ')}) — How to fix: use unique host ports.` ) } } return { errors, warnings, parsed } } /** * Fetch host ports published by containers on the active peer. * @returns {Promise} */ export async function fetchUsedHostPorts() { if (!manager.active?.connected) return [] try { const res = await manager.request(Methods.listUsedHostPorts, {}) const ports = res?.ports || res?.data?.ports || [] return Array.isArray(ports) ? ports.map((p) => Number(p)).filter((n) => Number.isFinite(n)) : [] } catch { return [] } } /** * Full precheck including live host-port conflicts on the connected peer. * * @param {object} payload - deploy form / add-container payload * @param {{ usedPorts?: number[], skipRemote?: boolean }} [opts] * @returns {Promise<{ ok: boolean, errors: string[], warnings: string[], parsed: ParsedPortMapping[] }>} */ export async function precheckDeployNetworking(payload = {}, opts = {}) { const sync = validateNetworkingSync(payload) const errors = [...sync.errors] const warnings = [...sync.warnings] const networkMode = String(payload.networkMode || 'bridge') if (networkMode !== 'host' && !opts.skipRemote) { const used = Array.isArray(opts.usedPorts) && opts.usedPorts.length ? opts.usedPorts : await fetchUsedHostPorts() const usedSet = new Set(used.map((n) => Number(n))) for (const row of sync.parsed) { if (row.hostPort == null || !isValidPortNumber(row.hostPort)) continue if (usedSet.has(row.hostPort)) { errors.push( `Host port ${row.hostPort}/${row.protocol} is already published by another container on this peer — How to fix: pick a free host port or stop the container using ${row.hostPort}.` ) } } } // Custom network attach: empty when mode expects a named network is soft unless container mode // (container mode hard-error already above) return { ok: errors.length === 0, errors, warnings, parsed: sync.parsed, } } /** * Format precheck result for showAlert / job logs. * @param {{ errors?: string[], warnings?: string[] }} result * @returns {string} */ export function formatNetworkingPrecheckMessage(result) { const parts = [] if (result.errors?.length) { parts.push(result.errors.join(' ')) } if (result.warnings?.length && !result.errors?.length) { parts.push(result.warnings.join(' ')) } return parts.join(' ').trim() } export default { parsePortMapping, validateNetworkingSync, fetchUsedHostPorts, precheckDeployNetworking, formatNetworkingPrecheckMessage, }