diff --git a/gotelegram-bot/bot.py b/gotelegram-bot/bot.py
index 408b64f..8c4ade7 100644
--- a/gotelegram-bot/bot.py
+++ b/gotelegram-bot/bot.py
@@ -240,6 +240,24 @@ async def sh(*args, timeout: int = 60) -> Tuple[int, str, str]:
return (-1, "", str(e))
+# Per-host mutex preventing concurrent install.sh --action invocations. Two
+# admins hitting "change template" at the same second could race each other
+# and corrupt /var/www/gotelegram-site. One global lock is fine — these are
+# rare operations and should serialize cleanly.
+_BOT_ACTION_LOCK = asyncio.Lock()
+
+# Allowed template-id shape: catalog ids are [a-zA-Z0-9_-], never longer than 64.
+# This is a defense-in-depth check before we hand the value to subprocess.
+_TPL_ID_RE = re.compile(r"^[A-Za-z0-9_-]{1,64}$")
+
+# Allowed Lite mask domain shape — simple DNS hostname, up to 253 chars total.
+# Each label 1–63 chars, labels separated by dots, alphanumerics + hyphens.
+_DOMAIN_RE = re.compile(
+ r"^(?=.{1,253}$)(?:(?!-)[A-Za-z0-9-]{1,63}(? Dict:
"""Invoke install.sh --action=X --json and parse the JSON result.
@@ -324,12 +342,23 @@ def save_json(path: str, data: Dict) -> bool:
return False
-async def safe_edit_message(query, text: str, reply_markup=None, parse_mode=None) -> bool:
- """Safely edit message, handling cases where message was deleted or not modified."""
+async def safe_edit_message(
+ query,
+ text: str,
+ reply_markup=None,
+ parse_mode=None,
+ disable_web_page_preview: Optional[bool] = None,
+) -> bool:
+ """Safely edit message, handling cases where message was deleted or not modified.
+
+ `disable_web_page_preview` is forwarded to edit_message_text when set; omitting
+ it keeps Telegram's default (enabled).
+ """
+ kwargs = {"reply_markup": reply_markup, "parse_mode": parse_mode}
+ if disable_web_page_preview is not None:
+ kwargs["disable_web_page_preview"] = disable_web_page_preview
try:
- await query.edit_message_text(
- text, reply_markup=reply_markup, parse_mode=parse_mode
- )
+ await query.edit_message_text(text, **kwargs)
return True
except BadRequest as e:
err_msg = str(e).lower()
@@ -621,11 +650,13 @@ async def get_status_text(user_id: Optional[int] = None) -> str:
config = load_json(GOTELEGRAM_CONFIG)
if config:
lines.append(f"{_t(user_id, 'status_mode')}: {html.escape(str(config.get('mode', 'unknown')))}")
- if "template" in config:
- lines.append(f"{_t(user_id, 'status_template')}: {html.escape(str(config['template']))}")
- if "domain" in config:
+ # install.sh/save_gotelegram_config uses "template_id" (not "template")
+ tpl = config.get("template_id") or config.get("template")
+ if tpl:
+ lines.append(f"{_t(user_id, 'status_template')}: {html.escape(str(tpl))}")
+ if config.get("domain"):
lines.append(f"{_t(user_id, 'status_domain')}: {html.escape(str(config['domain']))}")
- if "port" in config:
+ if config.get("port"):
lines.append(f"{_t(user_id, 'status_port')}: {html.escape(str(config['port']))}")
# Telemt config (v3: [server] port = ..., [censorship] tls_domain = ...)
@@ -878,6 +909,13 @@ async def cb_lite_domain(update: Update, context: ContextTypes.DEFAULT_TYPE) ->
await query.answer("Invalid domain selection")
return
+ # Defense-in-depth: LITE_DOMAINS is trusted, but validate the shape anyway
+ # in case someone extends the list with garbage later.
+ if not _DOMAIN_RE.match(domain):
+ logger.warning(f"cb_lite_domain: rejecting malformed domain {domain!r}")
+ await query.answer("Invalid domain")
+ return
+
await query.answer()
config = load_json(GOTELEGRAM_CONFIG) or {}
@@ -898,6 +936,18 @@ async def cb_lite_domain(update: Update, context: ContextTypes.DEFAULT_TYPE) ->
return
# Lite active — switch fake-TLS mask domain in place
+ if _BOT_ACTION_LOCK.locked():
+ await safe_edit_message(
+ query,
+ "⏳ Другая операция уже выполняется\n\n"
+ "Дождитесь завершения предыдущей смены шаблона/домена и повторите.",
+ reply_markup=InlineKeyboardMarkup(
+ [[InlineKeyboardButton(_t(_uid(update), "btn_back"), callback_data="menu_main")]]
+ ),
+ parse_mode="HTML",
+ )
+ return
+
progress_text = (
"⏳ Меняю маскировочный домен...\n\n"
f"Новый домен: {html.escape(domain)}\n\n"
@@ -905,7 +955,8 @@ async def cb_lite_domain(update: Update, context: ContextTypes.DEFAULT_TYPE) ->
)
await safe_edit_message(query, progress_text, parse_mode="HTML")
- result = await run_bot_action("change-lite-domain", timeout=30, domain=domain)
+ async with _BOT_ACTION_LOCK:
+ result = await run_bot_action("change-lite-domain", timeout=30, domain=domain)
if result.get("status") == "success":
text = (
@@ -1219,6 +1270,21 @@ async def cb_pro_confirm(update: Update, context: ContextTypes.DEFAULT_TYPE) ->
await query.answer()
+ # Defense-in-depth: even though subprocess.exec uses list args (no shell),
+ # we still enforce the catalog id shape before handing it to install.sh.
+ if not _TPL_ID_RE.match(tpl_id):
+ logger.warning(f"cb_pro_confirm: rejecting malformed tpl_id {tpl_id!r}")
+ await safe_edit_message(
+ query,
+ "❌ Некорректный идентификатор шаблона\n\n"
+ "Выбран неподдерживаемый шаблон. Вернитесь в меню и попробуйте снова.",
+ reply_markup=InlineKeyboardMarkup(
+ [[InlineKeyboardButton(_t(_uid(update), "btn_back"), callback_data="menu_main")]]
+ ),
+ parse_mode="HTML",
+ )
+ return
+
# Read current config to decide: in-place change-template vs fresh install
config = load_json(GOTELEGRAM_CONFIG) or {}
current_mode = config.get("mode", "")
@@ -1240,6 +1306,18 @@ async def cb_pro_confirm(update: Update, context: ContextTypes.DEFAULT_TYPE) ->
return
# Pro mode is active — perform change-template in place
+ if _BOT_ACTION_LOCK.locked():
+ await safe_edit_message(
+ query,
+ "⏳ Другая операция уже выполняется\n\n"
+ "Дождитесь завершения предыдущей смены шаблона/домена и повторите.",
+ reply_markup=InlineKeyboardMarkup(
+ [[InlineKeyboardButton(_t(_uid(update), "btn_back"), callback_data="menu_main")]]
+ ),
+ parse_mode="HTML",
+ )
+ return
+
progress_text = (
"⏳ Меняю шаблон сайта...\n\n"
f"Шаблон: {html.escape(tpl_id)}\n\n"
@@ -1248,8 +1326,10 @@ async def cb_pro_confirm(update: Update, context: ContextTypes.DEFAULT_TYPE) ->
)
await safe_edit_message(query, progress_text, parse_mode="HTML")
- # Template download + git clone can be slow — generous timeout
- result = await run_bot_action("change-template", timeout=180, template=tpl_id)
+ # Template download + git clone can be slow — generous timeout.
+ # Mutex serializes with any concurrent change-lite-domain/change-template.
+ async with _BOT_ACTION_LOCK:
+ result = await run_bot_action("change-template", timeout=180, template=tpl_id)
if result.get("status") == "success":
domain = result.get("domain", config.get("domain", ""))
@@ -2209,9 +2289,10 @@ async def handle_text_message(update: Update, context: ContextTypes.DEFAULT_TYPE
if not ok:
await update.message.reply_text(_t(user_id, info), parse_mode="HTML")
return
- # Success — record in GoTelegram config
+ # Success — record in GoTelegram config. Use "template_id" (canonical
+ # field name written by install.sh/save_gotelegram_config).
config = load_json(GOTELEGRAM_CONFIG) or {}
- config["template"] = tpl_id
+ config["template_id"] = tpl_id
config["template_source"] = url
save_json(GOTELEGRAM_CONFIG, config)
await update.message.reply_text(
diff --git a/install.sh b/install.sh
index a9c5d10..fa1c0cf 100755
--- a/install.sh
+++ b/install.sh
@@ -1168,17 +1168,20 @@ bot_emit_json() {
printf '{"status":"%s","message":"%s"%s}\n' "$status" "$msg_esc" "$extra"
}
-# Update a single key in config.json without rewriting the whole file
+# Update a single key in config.json without rewriting the whole file.
+# Uses `date -Iseconds` rather than jq's `now | todate` — the latter requires
+# jq 1.6+ which is not available on Debian 10 or older CentOS.
bot_update_config_field() {
local key="$1"
local value="$2"
if [ ! -f "$GOTELEGRAM_CONFIG" ]; then
return 1
fi
- local tmp
+ local tmp now
tmp=$(mktemp) || return 1
- if jq --arg k "$key" --arg v "$value" \
- '.[$k] = $v | .updated_at = (now | todate)' \
+ now=$(date -Iseconds 2>/dev/null || date +%Y-%m-%dT%H:%M:%S%z)
+ if jq --arg k "$key" --arg v "$value" --arg t "$now" \
+ '.[$k] = $v | .updated_at = $t' \
"$GOTELEGRAM_CONFIG" > "$tmp" 2>/dev/null; then
mv "$tmp" "$GOTELEGRAM_CONFIG"
chmod 600 "$GOTELEGRAM_CONFIG"