Files
peardock/client/deployNetworkPrecheck.js
T
Raven Scott 254310b495 Precheck deploy networking before container create
Validate port mappings for empty host ports, in-form duplicates,
privileged low ports, and peer-side host port conflicts. Runs on
template deploy, deploy view, and add-container before create RPC.
2026-07-16 14:39:19 -04:00

287 lines
8.8 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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<string, number[]>} */
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 165535).`
)
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<number[]>}
*/
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,
}