Precheck deploy networking before container create
Release rolling / release (push) Successful in 7m47s

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.
This commit is contained in:
Raven Scott
2026-07-16 14:39:19 -04:00
parent 5b1acdf324
commit 254310b495
5 changed files with 542 additions and 53 deletions
+21
View File
@@ -8282,6 +8282,27 @@ function attachFormHandler(form) {
return false;
}
// Async networking precheck before deploy (empty host ports, peer conflicts)
try {
const { precheckDeployNetworking, formatNetworkingPrecheckMessage } =
await import('./client/deployNetworkPrecheck.js');
const netCheck = await precheckDeployNetworking(formData);
if (!netCheck.ok) {
showAlert(
'danger',
formatNetworkingPrecheckMessage(netCheck) ||
'Networking configuration is invalid.'
);
return false;
}
if (netCheck.warnings?.length) {
showAlert('warning', netCheck.warnings.join(' '), { toast: true, tray: false });
}
formData._networkingPrechecked = true;
} catch (netErr) {
console.warn('[deploy] networking precheck failed', netErr);
}
const containerName = formData.containerName;
// Live job drawer handles progress — no top toast / tray spam
+286
View File
@@ -0,0 +1,286 @@
/**
* 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,
}
+21
View File
@@ -8,6 +8,10 @@ import {
showAlert,
} from './uiUtils.js'
import { presentError } from '../client/errors.js'
import {
precheckDeployNetworking,
formatNetworkingPrecheckMessage,
} from '../client/deployNetworkPrecheck.js'
let portSeq = 0
let volSeq = 0
@@ -423,6 +427,23 @@ export async function submitAddContainer() {
)
}
// Networking precheck before any RPC (empty host ports, conflicts, etc.)
const netCheck = await precheckDeployNetworking(payload)
if (!netCheck.ok) {
throw new Error(
formatNetworkingPrecheckMessage(netCheck) ||
'Networking configuration is invalid.'
)
}
if (netCheck.warnings?.length) {
try {
showAlert('warning', netCheck.warnings.join(' '), { toast: true, tray: false })
} catch {
// ignore
}
}
payload._networkingPrechecked = true
// Reuse template deploy replace helper when available
if (typeof window.deployDockerContainer === 'function') {
// Prefer shared path for name-conflict handling + job tray
+104 -53
View File
@@ -13,6 +13,11 @@ import {
resolveTemplateForDeploy,
buildStackDeployPayload,
} from '../client/templateResolve.js';
import {
precheckDeployNetworking,
validateNetworkingSync,
formatNetworkingPrecheckMessage,
} from '../client/deployNetworkPrecheck.js';
// DOM Elements - Lazy loaded (initialized when modal opens)
let templateList = null;
@@ -221,6 +226,27 @@ function setupFormSubmitListener() {
return;
}
// Async networking precheck (host-port conflicts on peer) before closing UI
try {
const netCheck = await precheckDeployNetworking(formData);
if (!netCheck.ok) {
showAlert(
'danger',
formatNetworkingPrecheckMessage(netCheck) ||
'Networking configuration is invalid.'
);
return;
}
if (netCheck.warnings?.length) {
showAlert('warning', netCheck.warnings.join(' '), { toast: true, tray: false });
}
// Mark so deployDockerContainer can skip a second remote conflict scan
formData._networkingPrechecked = true;
} catch (netErr) {
console.warn('[deploy] networking precheck failed', netErr);
// Continue — deployDockerContainer will re-run checks
}
// Safely get container name with fallback
const containerName = (formData && formData.containerName) ? String(formData.containerName) : 'container';
@@ -453,28 +479,7 @@ function validatePortMapping(portId) {
let isValid = true;
// Validate host port (optional)
if (hostInput && hostInput.value) {
const hostPort = parseInt(hostInput.value, 10);
if (isNaN(hostPort) || hostPort < 1 || hostPort > 65535) {
if (hostError) {
hostError.textContent = 'Port must be between 1 and 65535';
hostError.style.display = 'block';
hostInput.classList.add('is-invalid');
}
isValid = false;
} else {
if (hostError) {
hostError.style.display = 'none';
hostInput.classList.remove('is-invalid');
}
}
} else if (hostError) {
hostError.style.display = 'none';
if (hostInput) hostInput.classList.remove('is-invalid');
}
// Validate container port (required)
// Container port required
if (containerInput) {
const containerPort = parseInt(containerInput.value, 10);
if (!containerInput.value || isNaN(containerPort) || containerPort < 1 || containerPort > 65535) {
@@ -492,9 +497,53 @@ function validatePortMapping(portId) {
}
}
// Check for duplicate host ports
if (hostInput && hostInput.value) {
const hostPort = hostInput.value;
const hasContainer =
containerInput?.value &&
!isNaN(parseInt(containerInput.value, 10)) &&
parseInt(containerInput.value, 10) >= 1;
const hostVal = hostInput?.value?.trim() || '';
// Host port required when container port is set (prevents empty host-side publish)
if (hostInput) {
if (hasContainer && !hostVal) {
if (hostError) {
hostError.textContent = 'Host port required when container port is set';
hostError.style.display = 'block';
hostInput.classList.add('is-invalid');
}
isValid = false;
} else if (hostVal) {
const hostPort = parseInt(hostVal, 10);
if (isNaN(hostPort) || hostPort < 1 || hostPort > 65535) {
if (hostError) {
hostError.textContent = 'Port must be between 1 and 65535';
hostError.style.display = 'block';
hostInput.classList.add('is-invalid');
}
isValid = false;
} else if (hostPort < 1024) {
if (hostError) {
hostError.textContent = 'Privileged port (<1024) — may need elevated host permissions';
hostError.style.display = 'block';
hostInput.classList.remove('is-invalid');
hostInput.classList.add('is-warning');
}
// warning only — still valid for submit path with warning banner
} else {
if (hostError) {
hostError.style.display = 'none';
hostInput.classList.remove('is-invalid', 'is-warning');
}
}
} else if (hostError) {
hostError.style.display = 'none';
hostInput.classList.remove('is-invalid', 'is-warning');
}
}
// Check for duplicate host ports in this form
if (hostInput && hostVal) {
const hostPort = hostVal;
const allHostInputs = document.querySelectorAll('.port-host-input');
let duplicateCount = 0;
allHostInputs.forEach(input => {
@@ -505,7 +554,7 @@ function validatePortMapping(portId) {
if (duplicateCount > 0) {
if (hostError) {
hostError.textContent = 'This host port is already in use';
hostError.textContent = 'This host port is already used in another mapping';
hostError.style.display = 'block';
hostInput.classList.add('is-invalid');
}
@@ -2589,33 +2638,11 @@ function validateFormData(data) {
}
}
// Validate ports
if (data.ports && Array.isArray(data.ports)) {
data.ports.forEach((port, idx) => {
const portStr = String(port).trim();
// Support both "host:container/protocol" and "container/protocol" formats
const portPattern1 = /^(\d+):(\d+)\/(tcp|udp)$/; // host:container/protocol
const portPattern2 = /^(\d+)\/(tcp|udp)$/; // container/protocol
if (!portPattern1.test(portStr) && !portPattern2.test(portStr)) {
errors.push(`Port ${idx + 1} has invalid format. Use "host:container/protocol" or "container/protocol".`);
} else {
// Validate port numbers are in valid range
const match = portStr.match(portPattern1) || portStr.match(portPattern2);
if (match) {
const hostPort = match[1] ? parseInt(match[1], 10) : null;
const containerPort = parseInt(match[2] || match[1], 10);
if (hostPort && (hostPort < 1 || hostPort > 65535)) {
errors.push(`Port ${idx + 1}: Host port must be between 1 and 65535.`);
}
if (containerPort < 1 || containerPort > 65535) {
errors.push(`Port ${idx + 1}: Container port must be between 1 and 65535.`);
}
}
}
});
}
// Networking / ports (empty host side, ranges, in-form duplicates)
const net = validateNetworkingSync(data);
errors.push(...net.errors);
// Surface warnings as soft errors only when there are no hard errors yet —
// warnings are re-checked async with peer port conflicts before deploy.
// Validate volumes
if (data.volumes && Array.isArray(data.volumes)) {
@@ -2862,6 +2889,30 @@ async function deployDockerContainer(payload) {
);
}
// Pre-deploy networking: empty host ports, conflicts, privileged ports, network mode
// (may already be done by form submit; re-run unless marked prechecked this turn)
if (!payload._networkingPrechecked) {
const netCheck = await precheckDeployNetworking(payload);
if (!netCheck.ok) {
const msg =
formatNetworkingPrecheckMessage(netCheck) ||
'Networking configuration is invalid.';
throw new Error(msg);
}
if (netCheck.warnings?.length) {
console.warn('[deploy] networking warnings:', netCheck.warnings.join(' | '));
try {
showAlert('warning', netCheck.warnings.join(' '), { toast: true, tray: false });
} catch {
// ignore
}
}
}
// Never send internal flag to the server
if (payload && typeof payload === 'object') {
delete payload._networkingPrechecked;
}
// Offer replace when name collides (style)
payload = await confirmReplaceExistingContainer(payload)
+110
View File
@@ -0,0 +1,110 @@
import test from 'brittle'
import {
parsePortMapping,
validateNetworkingSync,
precheckDeployNetworking,
formatNetworkingPrecheckMessage,
} from '../client/deployNetworkPrecheck.js'
test('parsePortMapping host:container/proto', (t) => {
const p = parsePortMapping('8080:80/tcp', 0)
t.ok(p)
t.is(p.hostPort, 8080)
t.is(p.containerPort, 80)
t.is(p.protocol, 'tcp')
t.is(p.incomplete, false)
})
test('parsePortMapping container-only is incomplete', (t) => {
const p = parsePortMapping('80/tcp', 1)
t.ok(p)
t.is(p.hostPort, null)
t.is(p.containerPort, 80)
t.is(p.incomplete, true)
})
test('parsePortMapping empty host with colon is incomplete', (t) => {
// Unusual but guard against blank host field serialization
const p = parsePortMapping(':443/tcp', 0)
t.ok(p)
t.is(p.containerPort, 443)
t.is(p.incomplete, true)
})
test('validateNetworkingSync rejects empty host ports', (t) => {
const r = validateNetworkingSync({
networkMode: 'bridge',
ports: ['80/tcp', '443/udp'],
})
t.not(r.ok === true && r.errors.length === 0)
t.ok(r.errors.length >= 2)
t.ok(r.errors.some((e) => /host port is empty/i.test(e)))
})
test('validateNetworkingSync accepts full mappings', (t) => {
const r = validateNetworkingSync({
networkMode: 'bridge',
ports: ['8080:80/tcp', '8443:443/tcp'],
})
t.is(r.errors.length, 0)
t.is(r.parsed.length, 2)
})
test('validateNetworkingSync detects duplicate host ports', (t) => {
const r = validateNetworkingSync({
ports: ['8080:80/tcp', '8080:81/tcp'],
})
t.ok(r.errors.some((e) => /more than once/i.test(e)))
})
test('validateNetworkingSync warns on privileged ports', (t) => {
const r = validateNetworkingSync({
ports: ['80:80/tcp'],
})
t.is(r.errors.length, 0)
t.ok(r.warnings.some((e) => /privileged/i.test(e)))
})
test('validateNetworkingSync host mode warns about ignored mappings', (t) => {
const r = validateNetworkingSync({
networkMode: 'host',
ports: ['8080:80/tcp'],
})
t.is(r.errors.length, 0)
t.ok(r.warnings.some((e) => /host/i.test(e) && /ignored/i.test(e)))
})
test('validateNetworkingSync container mode requires peer container', (t) => {
const r = validateNetworkingSync({
networkMode: 'container',
customNetwork: '',
ports: [],
})
t.ok(r.errors.some((e) => /peer container/i.test(e)))
})
test('precheckDeployNetworking flags used host ports', async (t) => {
const r = await precheckDeployNetworking(
{ networkMode: 'bridge', ports: ['8080:80/tcp'] },
{ usedPorts: [8080, 9000] }
)
t.not(r.ok)
t.ok(r.errors.some((e) => /already published/i.test(e)))
})
test('precheckDeployNetworking ok when free', async (t) => {
const r = await precheckDeployNetworking(
{ networkMode: 'bridge', ports: ['8080:80/tcp'] },
{ usedPorts: [9000] }
)
t.ok(r.ok)
})
test('formatNetworkingPrecheckMessage joins errors', (t) => {
const msg = formatNetworkingPrecheckMessage({
errors: ['a', 'b'],
warnings: ['c'],
})
t.ok(msg.includes('a'))
t.ok(msg.includes('b'))
})