From ef02b489666b2562fbdd4d99c156829ee7904312 Mon Sep 17 00:00:00 2001 From: librelad Date: Wed, 19 Aug 2026 05:36:46 +0100 Subject: [PATCH] Refuse instancing an app that pins a fixed host port MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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:` 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 --- scripts/instance/instance_create.sh | 44 +++++++++++++++++++ .../source/files/arrays/function_manifest.sh | 3 ++ 2 files changed, 47 insertions(+) diff --git a/scripts/instance/instance_create.sh b/scripts/instance/instance_create.sh index 47533f0..471dce3 100644 --- a/scripts/instance/instance_create.sh +++ b/scripts/instance/instance_create.sh @@ -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:' 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_ 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 diff --git a/scripts/source/files/arrays/function_manifest.sh b/scripts/source/files/arrays/function_manifest.sh index 373182f..d08376d 100644 --- a/scripts/source/files/arrays/function_manifest.sh +++ b/scripts/source/files/arrays/function_manifest.sh @@ -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 "$@"; }