fix(v2.4.3): flock serialization for bot_action_dispatch

Iteration 3 discovered a race condition: parallel CLI invocations of
change-lite-domain occasionally produced 'no secret in config' because
one process read config.json while another was mid-rewrite (jq -> tmp -> mv).
asyncio.Lock in bot.py only protects bot->bot races; CLI-level races and
bot+CLI mixed races were still possible.

- install.sh: bot_action_dispatch wraps dispatch in flock(1) on
  /var/lock/gotelegram-bot-action.lock, 30s timeout, EX_TEMPFAIL on timeout
- common.sh: flock added to critical deps; apt_pkg_for_cmd/dnf_pkg_for_cmd
  map flock -> util-linux
- common.sh: check_deps_present includes flock
- version bumped to 2.4.3 (common.sh + bot.py)
This commit is contained in:
anten-ka
2026-04-10 13:35:22 +03:00
parent 724eeb92d9
commit e9af6e969f
3 changed files with 48 additions and 6 deletions

View File

@@ -100,7 +100,7 @@ logger = logging.getLogger(__name__)
# CONFIGURATION # CONFIGURATION
# ============================================================================ # ============================================================================
GOTELEGRAM_VERSION = "2.4.2" GOTELEGRAM_VERSION = "2.4.3"
GOTELEGRAM_CONFIG = "/opt/gotelegram/config.json" GOTELEGRAM_CONFIG = "/opt/gotelegram/config.json"
TELEMT_CONFIG = "/etc/telemt/config.toml" TELEMT_CONFIG = "/etc/telemt/config.toml"
TELEMT_SERVICE = "telemt" TELEMT_SERVICE = "telemt"

View File

@@ -1323,8 +1323,45 @@ bot_action_change_lite_domain() {
return 0 return 0
} }
# Main dispatcher — called from main() when --action=X is present # Main dispatcher — called from main() when --action=X is present.
# Uses a file lock (flock) so concurrent CLI invocations (from multiple bot
# users, or from bot + manual CLI) serialize cleanly. Without this, two
# parallel `change-lite-domain` calls raced on the jq-rewrite of config.json
# and one process would see a truncated file ("no secret in config").
bot_action_dispatch() { bot_action_dispatch() {
local lock_file="/var/lock/gotelegram-bot-action.lock"
# Make sure /var/lock exists (it does on Debian/Ubuntu; be defensive for minimal images)
[ -d /var/lock ] || mkdir -p /var/lock 2>/dev/null || true
if command -v flock >/dev/null 2>&1; then
# Wait up to 30 seconds for the lock — bot actions are fast (<5s
# typical), so 30s is plenty for legitimate serialization but short
# enough to surface a stuck process.
(
flock -w 30 9 || {
# If we time out, emit JSON error for the bot parent.
local json_out=0 a
for a in "$@"; do
[ "$a" = "--json" ] && json_out=1
done
if [ "$json_out" = "1" ]; then
bot_emit_json "error" "another action in progress (lock timeout)" "code=lock_timeout"
fi
exit 75 # EX_TEMPFAIL
}
_bot_action_dispatch_locked "$@"
) 9>"$lock_file"
return $?
else
# No flock installed — run unlocked with a warning. ensure_deps/check_deps
# normally ensures util-linux is present, so this branch is defensive.
log_warning "flock not available — bot actions not serialized"
_bot_action_dispatch_locked "$@"
return $?
fi
}
_bot_action_dispatch_locked() {
local action="" tpl_id="" domain="" json_out=0 arg local action="" tpl_id="" domain="" json_out=0 arg
for arg in "$@"; do for arg in "$@"; do
case "$arg" in case "$arg" in

13
lib/common.sh Executable file → Normal file
View File

@@ -3,7 +3,7 @@
# Colors, logging, spinner, system helpers, v1 compat, i18n-aware # Colors, logging, spinner, system helpers, v1 compat, i18n-aware
# ── Version ─────────────────────────────────────────────────────────────────── # ── Version ───────────────────────────────────────────────────────────────────
GOTELEGRAM_VERSION="2.4.2" GOTELEGRAM_VERSION="2.4.3"
GOTELEGRAM_NAME="GoTelegram" GOTELEGRAM_NAME="GoTelegram"
# ── Пути ────────────────────────────────────────────────────────────────────── # ── Пути ──────────────────────────────────────────────────────────────────────
@@ -277,6 +277,7 @@ apt_pkg_for_cmd() {
host) echo "dnsutils" ;; host) echo "dnsutils" ;;
ss) echo "iproute2" ;; ss) echo "iproute2" ;;
netstat) echo "net-tools" ;; netstat) echo "net-tools" ;;
flock) echo "util-linux" ;;
*) echo "$1" ;; # команда == имя пакета *) echo "$1" ;; # команда == имя пакета
esac esac
} }
@@ -287,13 +288,17 @@ dnf_pkg_for_cmd() {
xxd) echo "vim-common" ;; xxd) echo "vim-common" ;;
ss) echo "iproute" ;; ss) echo "iproute" ;;
netstat) echo "net-tools" ;; netstat) echo "net-tools" ;;
flock) echo "util-linux" ;;
*) echo "$1" ;; *) echo "$1" ;;
esac esac
} }
ensure_deps() { ensure_deps() {
# Критические зависимости — без них скрипт не работает # Критические зависимости — без них скрипт не работает.
local critical=(curl jq openssl git xxd tar dig) # flock используется bot_action_dispatch для сериализации параллельных
# вызовов (иначе гонка на config.json при одновременных change-template /
# change-lite-domain из бота).
local critical=(curl jq openssl git xxd tar dig flock)
# Желательные — есть fallback, устанавливать всё равно, но не падать если не смогли # Желательные — есть fallback, устанавливать всё равно, но не падать если не смогли
local optional=(qrencode bc) local optional=(qrencode bc)
@@ -386,7 +391,7 @@ ensure_deps() {
# main() чтобы не дёргать apt-get update при каждом запуске меню. # main() чтобы не дёргать apt-get update при каждом запуске меню.
check_deps_present() { check_deps_present() {
local cmd local cmd
for cmd in curl jq openssl git xxd tar dig; do for cmd in curl jq openssl git xxd tar dig flock; do
command -v "$cmd" &>/dev/null || return 1 command -v "$cmd" &>/dev/null || return 1
done done
return 0 return 0