forked from snxraven/peardock
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.
287 lines
8.8 KiB
JavaScript
287 lines
8.8 KiB
JavaScript
/**
|
||
* 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 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<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,
|
||
}
|