diff --git a/scripts/dev/lp-port-host-test b/scripts/dev/lp-port-host-test new file mode 100755 index 0000000..c35711e --- /dev/null +++ b/scripts/dev/lp-port-host-test @@ -0,0 +1,71 @@ +#!/bin/bash +# Does port allocation avoid a port the HOST is already listening on? +# +# scripts/dev/lp-port-host-test +# +# It used to avoid only a hardcoded list (8080, 6060) plus whatever an admin had +# written into CFG_RESERVED_PORTS_EXTRA, while the comment above it claimed a +# picked port "can never collide with a host service at compose-up time". On a +# desktop, a first-run restore handed stoat's livekit a UDP port kdeconnectd +# already held; compose failed with "address already in use", four dependent +# containers exited 101, and the app came back half-running under a restore that +# reported success. +# +# The test binds a real socket and asserts the allocator refuses that port. + +REPO="$(cd "$(dirname "$0")/../.." && pwd)" +fail=0 +chk(){ if [[ "$2" == "$3" ]]; then echo " ok $1"; else echo " FAIL $1: got '$2' want '$3'"; fail=1; fi; } + +command -v ss >/dev/null 2>&1 || { echo " SKIP ss not available"; exit 0; } + +# Hold a real port for the duration, so this tests the kernel's answer and not a +# fixture's idea of one. +python3 -c ' +import socket, sys, time +s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) +s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) +s.bind(("127.0.0.1", 0)); s.listen(1) +print(s.getsockname()[1], flush=True) +time.sleep(30) +' > /tmp/lp-port-host-test.$$ & +holder=$! +trap 'kill $holder 2>/dev/null; rm -f /tmp/lp-port-host-test.$$' EXIT +for _ in $(seq 1 50); do [[ -s /tmp/lp-port-host-test.$$ ]] && break; sleep 0.1; done +BOUND=$(cat /tmp/lp-port-host-test.$$) +[[ -n "$BOUND" ]] || { echo " FAIL could not bind a test port"; exit 1; } +echo "--- holding tcp/$BOUND ---" + +source "$REPO/scripts/network/ports/core/port_find_next_available.sh" + +echo "--- the kernel's view includes it ---" +chk "in host set" "$(portHostBoundPorts | grep -cx "$BOUND")" "1" + +echo "--- allocation refuses it ---" +# A one-port range containing only the bound port: with no other candidate, a +# correct allocator returns nothing rather than handing back a port in use. +isNotice(){ :; }; isError(){ :; } +runInstallOp(){ :; } # empty => the port table claims nothing +docker_dir="/tmp"; db_file="lp-port-host-test.db.$$"; : > "$docker_dir/$db_file" +CFG_PORT_RANGE="$BOUND-$BOUND" +CFG_RESERVED_PORTS_EXTRA="" +allocated_port="sentinel" +portFindNextAvailablePort +rm -f "$docker_dir/$db_file" +chk "refused the bound port" "$allocated_port" "" + +echo "--- a free range still allocates ---" +CFG_PORT_RANGE="49000-49010" +: > "$docker_dir/$db_file" +allocated_port="" +portFindNextAvailablePort +rm -f "$docker_dir/$db_file" +if [[ "$allocated_port" =~ ^[0-9]+$ ]] && (( allocated_port >= 49000 && allocated_port <= 49010 )); then + echo " ok allocated $allocated_port" +else + echo " FAIL free range gave '$allocated_port'"; fail=1 +fi + +echo "" +if (( fail )); then echo "FAILED"; exit 1; fi +echo "All port checks passed." diff --git a/scripts/network/ports/core/port_find_next_available.sh b/scripts/network/ports/core/port_find_next_available.sh index 74bc865..c2a0766 100755 --- a/scripts/network/ports/core/port_find_next_available.sh +++ b/scripts/network/ports/core/port_find_next_available.sh @@ -4,8 +4,6 @@ # # Host-bound ports that aren't tracked in the docker port table — extend # CFG_RESERVED_PORTS_EXTRA in your config to add more (comma-separated). -# These are skipped during allocation so a randomly-picked app port can -# never collide with a host service at compose-up time. portIsReservedHostPort() { local p="$1" @@ -18,12 +16,38 @@ portIsReservedHostPort() return 1 } +# Every port the host is ACTUALLY listening on, one per line. +# +# The reserved list above only covers what someone thought to write down, and +# this used to claim that was enough — that a picked port "can never collide +# with a host service at compose-up time". It can. A first-run restore on a +# desktop handed stoat's livekit a UDP port that kdeconnectd already had, and +# compose failed with "address already in use"; four containers that depended +# on it exited 101 and the app came back half-running, while the restore +# reported success. +# +# So ask the kernel instead of a list. Read once per allocation rather than per +# candidate: the loop below runs up to fifty times and the set does not change +# meaningfully in between. If ss is missing the set is empty, which is exactly +# the old behaviour. +portHostBoundPorts() +{ + command -v ss >/dev/null 2>&1 || return 0 + ss -HlntuA tcp,udp 2>/dev/null \ + | awk '{print $5}' | sed 's/.*://' | grep -E '^[0-9]+$' | sort -u +} + portFindNextAvailablePort() { IFS='-' read -ra PORT_RANGE <<< "$CFG_PORT_RANGE" local port_start=${PORT_RANGE[0]} local port_end=${PORT_RANGE[1]} + local -A host_bound=() + local _hp + while IFS= read -r _hp; do [[ -n "$_hp" ]] && host_bound[$_hp]=1; done \ + < <(portHostBoundPorts) + if [[ ! -f "$docker_dir/$db_file" ]]; then isNotice "Database not found: $docker_dir/$db_file" allocated_port="" @@ -37,8 +61,9 @@ portFindNextAvailablePort() # Generate random port in range local random_port=$(( RANDOM % (port_end - port_start + 1) + port_start )) - # Skip host-reserved ports (CrowdSec LAPI, etc.) - if portIsReservedHostPort "$random_port"; then + # Skip host-reserved ports (CrowdSec LAPI, etc.) and anything + # the host is already listening on. + if portIsReservedHostPort "$random_port" || [[ -n "${host_bound[$random_port]:-}" ]]; then ((attempts++)) continue fi @@ -56,7 +81,7 @@ portFindNextAvailablePort() if [[ -z "$available_port" ]]; then isNotice "Random allocation failed, trying sequential..." for ((port=port_start; port<=port_end; port++)); do - if portIsReservedHostPort "$port"; then + if portIsReservedHostPort "$port" || [[ -n "${host_bound[$port]:-}" ]]; then continue fi local check_result=$(runInstallOp sqlite3 "$docker_dir/$db_file" "SELECT 1 FROM network_resources WHERE resource_value LIKE '$port:%' AND resource_type='port' AND status='active' LIMIT 1" 2>/dev/null) diff --git a/scripts/restore/restore_first_run.sh b/scripts/restore/restore_first_run.sh index bf0c603..b44c4dc 100644 --- a/scripts/restore/restore_first_run.sh +++ b/scripts/restore/restore_first_run.sh @@ -58,23 +58,45 @@ restoreFirstRunBulk() # Count what actually landed. A per-app failure must not be reported as a # complete restore — that is how "4 apps restored" read as success when # nine had gone missing. + # + # An app can also come back only half-running: continue-on-error (the + # default) lets a failed compose-up log and carry on, so restoreAppStart + # still returns 0. That is how a restore reported thirteen successes while + # stoat's livekit had lost a port race and four containers that depended on + # it exited 101. checkSuccess appends every such failure to error_report.log, + # so watch that file grow across each app and name the noisy ones. + local _errlog="${logs_dir%/}/error_report.log" + _restoreErrLines() { wc -l < "$_errlog" 2>/dev/null || echo 0; } + local app - local -i ok=0 bad=0 - local -a failed=() + local -i ok=0 bad=0 before=0 after=0 + local -a failed=() noisy=() for app in "${apps_to_restore[@]}"; do + before=$(_restoreErrLines) if restoreAppStart "$app" "latest" "$idx" "$source_host"; then ok=$(( ok + 1 )) + after=$(_restoreErrLines) + (( after > before )) && noisy+=("$app") else bad=$(( bad + 1 )); failed+=("$app") fi done + unset -f _restoreErrLines if (( bad > 0 )); then isError "First-run restore finished with failures — $ok of ${#apps_to_restore[@]} restored" isNotice "Failed: ${failed[*]}" + (( ${#noisy[@]} )) && isNotice "Restored but reported errors: ${noisy[*]}" return 1 fi + if (( ${#noisy[@]} )); then + isSuccessful "First-run restore complete — $ok apps restored" + isNotice "${#noisy[@]} reported errors while starting: ${noisy[*]}" + isNotice "They are restored, but check them: $_errlog" + return 0 + fi + isSuccessful "First-run restore complete — $ok apps restored" return 0 } diff --git a/scripts/source/files/arrays/function_manifest.sh b/scripts/source/files/arrays/function_manifest.sh index 13a7f84..e0c3858 100644 --- a/scripts/source/files/arrays/function_manifest.sh +++ b/scripts/source/files/arrays/function_manifest.sh @@ -825,6 +825,7 @@ declare -gA LP_FN_MAP=( [portGetPublicPorts]="network/ports/core/port_get_public_ports.sh" [portGetServicePorts]="network/ports/core/port_get_service_ports.sh" [portGetServicePortsOnly]="network/ports/core/port_get_service_ports_only.sh" + [portHostBoundPorts]="network/ports/core/port_find_next_available.sh" [portIsReservedHostPort]="network/ports/core/port_find_next_available.sh" [portLookupExisting]="network/ports/allocation/port_allocate.sh" [port_show_all_network_services]="network/display/show_all_network_services.sh" @@ -2063,6 +2064,7 @@ declare -gA LP_FN_ROOT=( [portGetPublicPorts]="scripts" [portGetServicePorts]="scripts" [portGetServicePortsOnly]="scripts" + [portHostBoundPorts]="scripts" [portIsReservedHostPort]="scripts" [portLookupExisting]="scripts" [port_show_all_network_services]="scripts" @@ -3339,6 +3341,7 @@ portFindNextAvailablePort() { unset -f portFindNextAvailablePort; __lpAutoload " portGetPublicPorts() { unset -f portGetPublicPorts; __lpAutoload "${install_scripts_dir}network/ports/core/port_get_public_ports.sh"; portGetPublicPorts "$@"; } portGetServicePorts() { unset -f portGetServicePorts; __lpAutoload "${install_scripts_dir}network/ports/core/port_get_service_ports.sh"; portGetServicePorts "$@"; } portGetServicePortsOnly() { unset -f portGetServicePortsOnly; __lpAutoload "${install_scripts_dir}network/ports/core/port_get_service_ports_only.sh"; portGetServicePortsOnly "$@"; } +portHostBoundPorts() { unset -f portHostBoundPorts; __lpAutoload "${install_scripts_dir}network/ports/core/port_find_next_available.sh"; portHostBoundPorts "$@"; } portIsReservedHostPort() { unset -f portIsReservedHostPort; __lpAutoload "${install_scripts_dir}network/ports/core/port_find_next_available.sh"; portIsReservedHostPort "$@"; } portLookupExisting() { unset -f portLookupExisting; __lpAutoload "${install_scripts_dir}network/ports/allocation/port_allocate.sh"; portLookupExisting "$@"; } port_show_all_network_services() { unset -f port_show_all_network_services; __lpAutoload "${install_scripts_dir}network/display/show_all_network_services.sh"; port_show_all_network_services "$@"; }