Refuse instancing an app that pins a fixed host port

Audit of the per-app install hooks for singleton assumptions. The naming work
so far made identities unique, but a second copy still has to bind its own
ports, and `8201:80` is the same 8201 for every instance — the second container
simply fails at compose-up. `random:<internal>` is what makes an app
instanceable, since portAllocate then hands each instance its own host port.

Seven apps are caught: pihole (53 tcp+udp), stalwart (25/465/587/993), unbound
(5335 tcp+udp), traefik (443), searxng (8083), vaultwarden (8201), stoat
(7881). The message distinguishes the two cases, because they need opposite
fixes: an arbitrary pin like vaultwarden's 8201 should just become random,
while a DNS server on 53 or a mail server on 25 is genuinely one-per-host and
should never be instanced.

Runs before anything is cloned — this is a property of the app, not of the
instance. Bookstack is unaffected (all its ports are already random).

The rest of the hook audit found nothing further to fix:

- No hook writes to another app's config or deployed directory. The three that
  reference ${containers_dir}traefik / headscale only test [[ -d ]] to detect
  whether those are installed.
- Only two hooks read a foreign CFG_ namespace, and both are system-wide
  settings (CFG_DOCKER_INSTALL_TYPE, CFG_ENABLE_VIDEO), not another app's.
- No app declares a fixed container IP; all come from IP_TAG allocation.
- Host-level writes are limited to wireguard's sysctl IPv4-forwarding drop-in
  (global and idempotent) and its /etc/wireguard/params conflict probe. Traefik
  writes only under $containers_dir$app_name. Stalwart's /etc/stalwart path is
  inside its container.

Not mechanically checkable, so left as maintainer judgement: gluetun is a
network provider other apps join via network_mode container:gluetun-service,
and it plus wireguard hold NET_ADMIN and /dev/net/tun. Both are one-per-host
for reasons no guard can see.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
librelad 2026-08-19 05:36:46 +01:00
parent e9bbe44601
commit ef02b48966
2 changed files with 47 additions and 0 deletions

View File

@ -136,6 +136,46 @@ _instanceSetLocalOnly() {
done
}
# Refuse an app whose ports are pinned to a fixed host port.
#
# The identity checks below make names unique, but a second instance still has to
# bind its own ports, and `8201:80` is the same 8201 for every copy — the second
# container just fails to start at compose-up. `random:80` is what makes an app
# instanceable: portAllocate hands each instance its own host port from the pool.
#
# This is a property of the app, not of the instance, so it runs before anything
# is cloned. Ports the maintainer marked `disabled` are skipped — they publish
# nothing, so they cannot collide.
_instanceCheckPortsInstanceable() {
local type="$1"
local cfg="${install_containers_dir%/}/$type/$type.config"
[[ -f "$cfg" ]] || return 0
local line val ext access
local -a fixed=()
while IFS= read -r line; do
val="${line#*=}"; val="${val//$'\r'/}"; val="${val#\"}"; val="${val%\"}"
local IFS='|'
local -a f=($val)
unset IFS
ext="${f[2]%%:*}"
access="${f[3]}"
[[ "$access" == "disabled" ]] && continue
[[ "$ext" == "random" ]] && continue
[[ -z "$ext" ]] && continue
fixed+=("${f[1]:-port} -> ${f[2]}")
done < <(grep -E "^CFG_${type^^}_PORT_[0-9]+=" "$cfg" 2>/dev/null)
if [[ ${#fixed[@]} -gt 0 ]]; then
isError "Instance create: '$type' pins ${#fixed[@]} port(s) to a fixed host port, so a second copy cannot start:"
local p
for p in "${fixed[@]}"; do echo " $p"; done
isNotice "Change those to 'random:<internal>' in ${type}.config if the host port is arbitrary. If it is not — a DNS server on 53, a mail server on 25 — the app is genuinely one-per-host and should not be instanced."
return 1
fi
return 0
}
# Every container identity a compose declares: its service keys (from the
# SERVICE_TAG_<n> markers) plus its container_name values, deduped.
#
@ -371,6 +411,10 @@ instanceCreate() {
return 1
fi
# Fail before cloning: a fixed host port is a property of the app, and no
# amount of renaming makes a second copy able to bind it.
_instanceCheckPortsInstanceable "$type" || return 1
local id
id=$(instanceIdPart "$rawname")
if [[ -z "$id" ]]; then

View File

@ -578,6 +578,7 @@ declare -gA LP_FN_MAP=(
[installSwapfile]="install/install_swapfile.sh"
[installUFW]="install/install_ufw.sh"
[installUFWDocker]="install/install_ufwd.sh"
[_instanceCheckPortsInstanceable]="instance/instance_create.sh"
[_instanceComposeIdentities]="instance/instance_create.sh"
[instanceCreate]="instance/instance_create.sh"
[instanceIdPart]="instance/instance_create.sh"
@ -1733,6 +1734,7 @@ declare -gA LP_FN_ROOT=(
[installSwapfile]="scripts"
[installUFW]="scripts"
[installUFWDocker]="scripts"
[_instanceCheckPortsInstanceable]="scripts"
[_instanceComposeIdentities]="scripts"
[instanceCreate]="scripts"
[instanceIdPart]="scripts"
@ -2924,6 +2926,7 @@ installSSLCertificate() { unset -f installSSLCertificate; __lpAutoload "${instal
installSwapfile() { unset -f installSwapfile; __lpAutoload "${install_scripts_dir}install/install_swapfile.sh"; installSwapfile "$@"; }
installUFW() { unset -f installUFW; __lpAutoload "${install_scripts_dir}install/install_ufw.sh"; installUFW "$@"; }
installUFWDocker() { unset -f installUFWDocker; __lpAutoload "${install_scripts_dir}install/install_ufwd.sh"; installUFWDocker "$@"; }
_instanceCheckPortsInstanceable() { unset -f _instanceCheckPortsInstanceable; __lpAutoload "${install_scripts_dir}instance/instance_create.sh"; _instanceCheckPortsInstanceable "$@"; }
_instanceComposeIdentities() { unset -f _instanceComposeIdentities; __lpAutoload "${install_scripts_dir}instance/instance_create.sh"; _instanceComposeIdentities "$@"; }
instanceCreate() { unset -f instanceCreate; __lpAutoload "${install_scripts_dir}instance/instance_create.sh"; instanceCreate "$@"; }
instanceIdPart() { unset -f instanceIdPart; __lpAutoload "${install_scripts_dir}instance/instance_create.sh"; instanceIdPart "$@"; }