ucode держит по одному открытому дескриптору на каждый require()-модуль всё время жизни интерпретатора и не даёт ни закрыть их, ни пометить close-on-exec, поэтому каждый фоновый процесс наследует весь набор. На роутере watchdog несёт около 90 таких дескрипторов и передаёт их детям: logread, sh и nfqws2 найдены держащими дескрипторы на собственных .uc-файлах Tachyon, часть уже удалена обновлением пакета — старый инод прибит к диску процессом, которому он не нужен. Против этого код писал литерал 1000<&- в 22 местах 13 модулей. Он не закрывал ничего: дескриптор 1000 никогда не открывается, а все настоящие лежат в 6..89. Хуже того, этого литерала требовал tests/service_start_trap.sh — именно поэтому он и разошёлся по кодовой базе. Проверка пинила видимость защиты и продолжала бы проходить вечно при живой утечке. Замена оказалась не такой прямой, как выглядит. Счёт от 3 до потолка не годится: в dash закрытие дескриптора 10 и выше фатально даже для номера, который никогда не открывался — dash перемещает туда собственную служебную бухгалтерию, и перенаправление убивает шелл целиком, а 2>/dev/null прячет сообщение, но не смерть. Перечисление /proc/self/fd трогает только реально открытое, но сами высокие дескрипторы никуда не деваются: набор модулей любого размера уводит их за 10. Поэтому пролог сначала спрашивает шелл в подоболочке, которая поглощает смерть, и опускает собственный потолок, если ответ отрицательный. busybox ash, на котором работает OpenWrt, отвечает утвердительно и закрывает весь набор. Три хелпера в core/common.uc задают форму фонового запуска — раньше каждое место собирало перенаправления вручную, и они разошлись. Форма с пидом ставит exec внутрь подоболочки, чтобы $! называл сам демон, а не обёртку, которая тут же завершается; ведущие присваивания VAR=x при этом выносятся перед exec, иначе шелл ищет программу с именем VAR=x. Для конвейеров, которые exec заменить не может, есть отдельный хелпер. initd.uc и state.uc лежат на пути раннего старта и несут собственные копии, как уже несут shell_quote и command_from_args. Попутно вскрылись два настоящих дефекта. В лестнице эскалации watchdog строка читалась как restart & rm -f lock &, то есть лок снимался параллельно с рестартом, который он охраняет, и вторая эскалация могла начаться посреди первой. В пакетном прогреве Zero-RTT знак & стоял внутри строки, поэтому перенаправления после него цеплялись к пустой команде в родительском шелле, а не к фоновому циклу. Ещё четыре места запускали фон вообще мимо механизма; одно из них аудит пропустил, его нашёл новый тест. tests/background_spawn_fds.sh считает дескрипторы у ребёнка при обоих способах запуска: структурная проверка такое показать не может, строку надо выполнить. На роутере старый способ даёт ребёнку 7 дескрипторов, новый — 3.
156 lines
8.1 KiB
Bash
156 lines
8.1 KiB
Bash
#!/usr/bin/env bash
|
|
set -eo pipefail
|
|
|
|
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|
TACHYON_BIN="$ROOT_DIR/tachyon/files/usr/bin/tachyon"
|
|
TACHYON_INIT="$ROOT_DIR/tachyon/files/etc/init.d/tachyon"
|
|
CLI_UC="$TACHYON_BIN"
|
|
LIFECYCLE_UC="$ROOT_DIR/tachyon/files/usr/lib/service/lifecycle.uc"
|
|
INITD_UC="$ROOT_DIR/tachyon/files/usr/lib/service/initd.uc"
|
|
STATE_UC="$ROOT_DIR/tachyon/files/usr/lib/service/state.uc"
|
|
UI_UC="$ROOT_DIR/tachyon/files/usr/lib/service/ui.uc"
|
|
UPDATES_UC="$ROOT_DIR/tachyon/files/usr/lib/components/updates.uc"
|
|
SUBSCRIPTION_CACHE_UC="$ROOT_DIR/tachyon/files/usr/lib/subscription/cache.uc"
|
|
NFQUEUE_RUNTIME_UC="$ROOT_DIR/tachyon/files/usr/lib/providers/nfqueue/runtime.uc"
|
|
BYEDPI_RUNTIME_UC="$ROOT_DIR/tachyon/files/usr/lib/providers/byedpi/runtime.uc"
|
|
PRIORITY_UC="$ROOT_DIR/tachyon/files/usr/lib/singbox/priority.uc"
|
|
|
|
fail() {
|
|
printf 'FAIL: %s\n' "$1" >&2
|
|
exit 1
|
|
}
|
|
|
|
require_pattern() {
|
|
local pattern="$1"
|
|
local label="$2"
|
|
|
|
grep -Fq "$pattern" "$LIFECYCLE_UC" || fail "$label"
|
|
}
|
|
|
|
reject_pattern() {
|
|
local pattern="$1"
|
|
local label="$2"
|
|
|
|
! grep -Fq "$pattern" "$TACHYON_BIN" || fail "$label"
|
|
}
|
|
|
|
require_file_pattern() {
|
|
local file="$1"
|
|
local pattern="$2"
|
|
local label="$3"
|
|
|
|
grep -Fq "$pattern" "$file" || fail "$label"
|
|
}
|
|
|
|
[ -r "$TACHYON_BIN" ] || fail "tachyon binary source is missing"
|
|
[ -r "$TACHYON_BIN" ] || fail "tachyon entrypoint is missing"
|
|
[ -r "$LIFECYCLE_UC" ] || fail "service/lifecycle.uc is missing"
|
|
|
|
grep -Fq '#!/usr/bin/ucode' "$TACHYON_BIN" ||
|
|
fail "tachyon entrypoint must be a direct ucode executable"
|
|
grep -Fq 'service/lifecycle.uc' "$CLI_UC" ||
|
|
fail "service/cli.uc must dispatch startup through service/lifecycle.uc"
|
|
reject_pattern "trap " \
|
|
"tachyon shell entrypoint must not own startup fail-safe traps"
|
|
reject_pattern "clear_startup_failsafe_trap" \
|
|
"tachyon shell entrypoint must not keep old trap helper"
|
|
require_pattern 'module_background(UPDATES_UC, [ "list-update" ])' \
|
|
"startup list_update background job must be owned by service/lifecycle.uc"
|
|
require_pattern 'module_background(DIAGNOSTICS_UC, [ "get-system-info" ])' \
|
|
"startup system-info background job must be owned by service/lifecycle.uc"
|
|
require_pattern 'startup_config_fingerprint = external_config_fingerprint();' \
|
|
"startup must snapshot runtime-relevant config after validation"
|
|
require_pattern 'mark_pending_reload_if_config_changed(startup_config_fingerprint, "config_changed_during_start")' \
|
|
"startup must queue reload when config changes while service is starting"
|
|
awk '
|
|
/function restart_runtime_for_reload\(\)/ { in_restart = 1 }
|
|
in_restart && /status = start_impl\(\);/ { saw_start_impl = 1 }
|
|
in_restart && /dnsmasq_configure\(true\)/ { duplicated_dns = 1 }
|
|
in_restart && /write-current-reload-state-clean/ { duplicated_state = 1 }
|
|
in_restart && /DNS_FAILOVER_UC.*start-runtime/ { duplicated_failover = 1 }
|
|
in_restart && /^}/ { done = 1; exit }
|
|
END { exit done && saw_start_impl && !duplicated_dns && !duplicated_state && !duplicated_failover ? 0 : 1 }
|
|
' "$LIFECYCLE_UC" ||
|
|
fail "reload fallback restart must reuse the complete start_impl path without duplicating finalization"
|
|
require_pattern 'function abort_reload(status, runtime_changed)' \
|
|
"reload failures must share one cleanup decision owner"
|
|
require_pattern 'return abort_reload(status, true);' \
|
|
"reload failures after runtime mutation must clean the partial runtime"
|
|
cleanup_function_line="$(awk '/function cleanup_failed_runtime\(\)/ { print NR; exit }' "$LIFECYCLE_UC")"
|
|
abort_function_line="$(awk '/function abort_reload\(status, runtime_changed\)/ { print NR; exit }' "$LIFECYCLE_UC")"
|
|
[ -n "$cleanup_function_line" ] && [ -n "$abort_function_line" ] &&
|
|
[ "$cleanup_function_line" -lt "$abort_function_line" ] ||
|
|
fail "cleanup_failed_runtime must be declared before abort_reload for ucode runtime calls"
|
|
require_pattern 'pending_reload_log_context(reason)' \
|
|
"startup queued reload must be visible in logs"
|
|
require_pattern 'return "current reload";' \
|
|
"reload-time queued reload must be distinguishable in logs"
|
|
# Background spawns must route through the shared helpers, which close the
|
|
# descriptors the child would otherwise inherit — the procd lock among them.
|
|
#
|
|
# These assertions used to require a literal `1000>&-` in each of these files,
|
|
# which is why that string spread to 22 sites in 13 modules. It closed nothing:
|
|
# no descriptor 1000 is ever opened, and every real one falls in 6..89. The test
|
|
# pinned the appearance of the fix and would have gone on passing forever with
|
|
# the leak intact. What matters is that each site delegates to a helper that
|
|
# closes what is actually open; tests/background_spawn_fds.sh proves the helper
|
|
# does that at runtime.
|
|
require_file_pattern "$LIFECYCLE_UC" 'common.background_command(module_command(module_path, args))' \
|
|
"service/lifecycle.uc background modules must close inherited descriptors via the shared helper"
|
|
require_file_pattern "$INITD_UC" 'background_command(shell_quote(init_script) + " reload pending")' \
|
|
"service/initd.uc pending reload worker must close inherited descriptors"
|
|
require_file_pattern "$STATE_UC" 'background_command(shell_quote(init_script) + " reload pending")' \
|
|
"service/state.uc pending reload worker must close inherited descriptors"
|
|
for background_owner in "$UI_UC" "$UPDATES_UC" "$SUBSCRIPTION_CACHE_UC" \
|
|
"$NFQUEUE_RUNTIME_UC" "$BYEDPI_RUNTIME_UC" "$PRIORITY_UC"; do
|
|
require_file_pattern "$background_owner" 'common.background_command_with_pid(' \
|
|
"$(basename "$background_owner") workers must spawn through background_command_with_pid"
|
|
done
|
|
|
|
# initd.uc and state.uc sit on the early-boot path and carry their own copies of
|
|
# the helper rather than pulling in core.common, so their prologue is checked
|
|
# separately. Both copies and the shared one must ask the shell what it can close
|
|
# before closing anything — see core/common.uc for why a fixed range is fatal.
|
|
for prologue_owner in "$ROOT_DIR/tachyon/files/usr/lib/core/common.uc" "$INITD_UC" "$STATE_UC"; do
|
|
require_file_pattern "$prologue_owner" 'eval \"exec 10<&-\"' \
|
|
"$(basename "$prologue_owner") descriptor prologue must probe the shell before closing high descriptors"
|
|
done
|
|
require_file_pattern "$TACHYON_INIT" 'TACHYON_LAST_START_STATUS="$?"' \
|
|
"init.d start_service must preserve backend start status for rc.common"
|
|
require_file_pattern "$TACHYON_INIT" 'service_started()' \
|
|
"init.d must return preserved start status through rc.common service_started hook"
|
|
awk '
|
|
/function cleanup_failed_runtime\(\)/ { in_cleanup = 1 }
|
|
in_cleanup && /stop_main\(\);/ { saw_stop = 1 }
|
|
in_cleanup && /dnsmasq_restore_fail_safe\(\);/ && saw_stop { saw_dns = 1 }
|
|
in_cleanup && /mark_runtime_stopped_clean\(\);/ && saw_dns { ok = 1; exit }
|
|
in_cleanup && /^}/ { exit }
|
|
END { exit ok ? 0 : 1 }
|
|
' "$LIFECYCLE_UC" ||
|
|
fail "failed runtime cleanup must stop partial runtime, roll back DNS, and mark clean shutdown"
|
|
grep -Fq 'cleanup_failed_runtime();' "$LIFECYCLE_UC" ||
|
|
fail "service failure paths must use failed runtime cleanup"
|
|
require_pattern "tproxy-marking-rule4-present" \
|
|
"service stop must use direct ucode IPv4 tproxy marking rule check"
|
|
require_pattern "tproxy-marking-rule6-present" \
|
|
"service stop must use direct ucode IPv6 tproxy marking rule check"
|
|
require_pattern "tproxy-route4-present" \
|
|
"service stop must use direct ucode IPv4 tproxy route check"
|
|
require_pattern "tproxy-route6-present" \
|
|
"service stop must use direct ucode IPv6 tproxy route check"
|
|
grep -Fq 'require("core.uci")' "$LIFECYCLE_UC" ||
|
|
fail "service/lifecycle.uc must use core.uci for lifecycle UCI state"
|
|
if grep -n -E 'uci -q|require\("uci"\)\.cursor|function uci_|uci_set\(' "$LIFECYCLE_UC" >/dev/null 2>&1; then
|
|
fail "service/lifecycle.uc must not own direct UCI CLI/cursor helpers"
|
|
fi
|
|
reject_pattern "tproxy_marking_rule_present" \
|
|
"service stop must not call the removed shell tproxy marking helper"
|
|
reject_pattern "tproxy_route_present" \
|
|
"service stop must not call the removed shell tproxy route helper"
|
|
reject_pattern "has_tproxy_marking_rule" \
|
|
"service stop must not call the removed shell tproxy marking helper"
|
|
reject_pattern "has_tproxy_route" \
|
|
"service stop must not call the removed shell tproxy route helper"
|
|
|
|
printf 'service start trap checks passed\n'
|