diff --git a/mtproxymax.sh b/mtproxymax.sh index 90b8c6b..44cf4d6 100644 --- a/mtproxymax.sh +++ b/mtproxymax.sh @@ -11995,13 +11995,23 @@ PYQ # not a URL the API can fetch itself. _tg_send_photo_file() { local chat="$1" file="$2" caption="${3:-}" + local parse_mode="${4:-}" label="${TELEGRAM_SERVER_LABEL:-MTProxyMax}" + local -a format=() + if [ "$parse_mode" = "Markdown" ]; then + label=$(_esc "$label") + label="${label//[/\\[}" + format=(--form-string "parse_mode=Markdown") + fi [ -s "$file" ] || return 1 - curl -s --max-time 30 -X POST \ + local response + response=$(curl -s --max-time 30 -X POST \ -K <(printf 'url = "https://api.telegram.org/bot%s/sendPhoto"\n' "$TELEGRAM_BOT_TOKEN") \ -F "chat_id=${chat}" \ -F "photo=@${file}" \ - --form-string "caption=[$(_esc "${TELEGRAM_SERVER_LABEL:-MTProxyMax}")] ${caption}" \ - --form-string "parse_mode=Markdown" >/dev/null 2>&1 + --form-string "caption=[${label}] ${caption}" \ + "${format[@]}" 2>/dev/null) || return 1 + # HTTP 400/403 responses do not make curl fail. Check the Bot API result. + [[ "$(printf '%s' "$response" | tr -d '[:space:]')" == *'"ok":true'* ]] } # "https://t.me/proxy?...", the one form of the link that is also a credential. @@ -12011,19 +12021,24 @@ _proxy_link() { _send_qr() { local chat="$1" ip="$2" port="$3" secret="$4" caption="$5" + local parse_mode="${6:-}" local link png link=$(_proxy_link "$ip" "$port" "$secret") png=$(mktemp "${TMPDIR:-/tmp}/mtpx-qr.XXXXXX") || png="" if [ -n "$png" ] && _qr_png "$link" "$png"; then - _tg_send_photo_file "$chat" "$png" "$caption" - rm -f "$png" 2>/dev/null - return 0 + if _tg_send_photo_file "$chat" "$png" "$caption" "$parse_mode"; then + rm -f "$png" 2>/dev/null + return 0 + fi fi rm -f "$png" 2>/dev/null - # No renderer here. Send the tappable link rather than reaching for a remote - # one: the operator gets something usable, and the key stays on this host. - tg_send_to "$chat" "🔗 [Connect](${link})\n\n_Install \`qrencode\` (or \`python3-qrcode\`) for a scannable QR._" - return 1 + # No renderer, or the upload failed. Keep a usable connection link without + # handing the credential to a third-party QR service. + # Plain text also works when the URL or server label contains Markdown. + local response + response=$(_tg_post_method sendMessage "chat_id=${chat}" \ + "text=🔗 Connect: ${link}") || return 1 + [[ "$(printf '%s' "$response" | tr -d '[:space:]')" == *'"ok":true'* ]] } # Send QR code image for a proxy secret (no text URL — avoids Telegram bot bans) @@ -12035,7 +12050,7 @@ send_proxy_qr() { send_proxy_qr_to() { local target_cid="$1" ip="$2" port="$3" secret="$4" caption="${5:-Scan in Telegram to connect}" - _send_qr "$target_cid" "$ip" "$port" "$secret" "$caption" + _send_qr "$target_cid" "$ip" "$port" "$secret" "$caption" "${6:-}" } # Escape Markdown special chars in labels for Telegram @@ -13519,6 +13534,7 @@ m=public m:h=public u:l=admin u:s=admin +u:k=admin t=admin t:w=admin t:u=admin @@ -14757,8 +14773,15 @@ _cb_send_link() { else _fs="dd${_secret}" fi - _CB_TOAST="Link sent" - send_proxy_qr_to "$_CB_CHAT" "$_ip" "${PROXY_PORT}" "$_fs" + local _caption + _caption="🔗 [Connect]($(_proxy_link "$_ip" "${PROXY_PORT}" "$_fs"))" + if send_proxy_qr_to "$_CB_CHAT" "$_ip" "${PROXY_PORT}" "$_fs" "$_caption" Markdown; then + _CB_TOAST="Link sent" + else + _CB_TOAST="Could not send the link. Please retry." + _CB_ALERT="true" + return 1 + fi } # The standard button bar attached to ordinary command replies. Inline buttons diff --git a/tests/test_telegram_callback_dispatch.sh b/tests/test_telegram_callback_dispatch.sh index 81d00d9..29f601f 100644 --- a/tests/test_telegram_callback_dispatch.sh +++ b/tests/test_telegram_callback_dispatch.sh @@ -103,6 +103,9 @@ load_tg_settings() { :; } is_running() { return 1; } log_warn() { :; } get_cached_ip() { echo "203.0.113.9"; } +domain_to_hex() { echo "636c6f7564666c6172652e636f6d"; } +_proxy_link() { printf 'https://t.me/proxy?server=%s&port=%s&secret=%s' "$1" "$2" "$3"; } +send_proxy_qr_to() { printf '%s|%s|%s|%s|%s\n' "$1" "$2" "$3" "$4" "$5" >> "$SENDS"; } _tg_metrics_raw() { printf ''; } cat > "$INSTALL_DIR/mtproxymax" <<'EOS' @@ -125,6 +128,25 @@ reset_audit() { : > "$AUDIT_LOG"; } echo "Telegram callback dispatcher tests" +# Exercise the button through the real capability gate, not just its helper. +PROXY_PORT=443 +PROXY_DOMAIN="cloudflare.com" +MASKING_ENABLED=true +for _role in superadmin; do + run_cb "$_role" 111 "u:k:alice" + assert_contains "$_role Links button sends a QR" "111|203.0.113.9|443|" "$(cat "$SENDS")" + assert_contains "$_role Links button includes the connection URL" "https://t.me/proxy?server=203.0.113.9&port=443&secret=ee" "$(cat "$SENDS")" + assert_contains "$_role Links button confirms delivery" "Link sent" "$(cat "$ANSWERS")" +done +run_cb reseller 333 "u:k:alice" +assert_eq "reseller cannot retrieve another user's link" "" "$(cat "$SENDS")" +assert_contains "reseller link request is denied" "Permission denied" "$(cat "$ANSWERS")" +run_cb admin 444 "u:k:alice" +assert_eq "unknown admin role cannot retrieve a link" "" "$(cat "$SENDS")" +assert_contains "unknown admin role is denied" "Permission denied" "$(cat "$ANSWERS")" +run_cb none 999 "u:k:alice" +assert_eq "unauthenticated user cannot retrieve a link" "" "$(cat "$SENDS")" + # ── Every path answers the callback exactly once ───────────────────────────── # Without this the client spins forever, so it must hold on denial, error and # stale-menu paths too — not just the happy path. @@ -249,7 +271,7 @@ cap_leaks() { # CLI verb (secret setlimit / extend / quota-reset / template apply), # and none of them is reachable by a reseller, whose only surface # stays the voucher commands. - u:m|u:t|e|e:q|e:c|e:i|e:x|e:r|e:n|e:a|e:t|e:z|\ + u:k|u:m|u:t|e|e:q|e:c|e:i|e:x|e:r|e:n|e:a|e:t|e:z|\ c:setq|c:setc|c:seti|c:setx|c:setr|c:tpl) [ "$cap" = "admin" ] || { printf '%s=%s\n' "$key" "$cap"; } ;; # The server console and the template section: read-mostly views diff --git a/tests/test_telegram_qr_and_limits.sh b/tests/test_telegram_qr_and_limits.sh index a53c22b..1ee4485 100644 --- a/tests/test_telegram_qr_and_limits.sh +++ b/tests/test_telegram_qr_and_limits.sh @@ -62,7 +62,7 @@ FNS="$TEST_TMPDIR/daemon-fns.sh" for _fn in _tg_security_log _esc _cb_label_ok _cb_enc _cb_dec _process_cmd \ _tg_pending_file _tg_pending_set _tg_pending_take _tg_pending_clear \ _tg_pending_try _tg_pending_run _tg_new_secret_link \ - _qr_png _proxy_link _tg_send_photo_file _send_qr \ + _qr_png _proxy_link _tg_post_method _tg_send_photo_file _send_qr \ send_proxy_qr send_proxy_qr_to; do awk "/^${_fn}\\(\\)/,/^}\$/" "$DAEMON" >> "$FNS" done @@ -94,13 +94,20 @@ _tg_metrics_raw() { printf ''; } # separately: the point of the config file is that argv holds no secret. curl() { printf '%s\n' "$*" >> "$CURLS" - local _f + local _f _cfg="" while [ $# -gt 0 ]; do case "$1" in - -K) _f="$2"; [ -r "$_f" ] && cat "$_f" >> "$CURLCFG" ;; + -K) _f="$2"; if [ -r "$_f" ]; then _cfg=$(cat "$_f"); printf '%s\n' "$_cfg" >> "$CURLCFG"; fi ;; esac shift done + if [[ "$_cfg" == *sendPhoto* ]] && [ -n "${CURL_PHOTO_RESPONSE:-}" ]; then + printf '%s' "$CURL_PHOTO_RESPONSE" + elif [ -n "${CURL_RESPONSE:-}" ]; then + printf '%s' "$CURL_RESPONSE" + else + printf '{"ok":true}' + fi return 0 } @@ -186,13 +193,53 @@ assert_contains "the upload goes to the chat that asked for it" "chat_id=777" "$ # The bot token must travel in the curl config, never in argv where `ps` shows it. assert_not_contains "the bot token is not on the command line" "111:AAA" "$_curl_args" +: > "$CURLS"; : > "$CURLCFG" +_CB_CHAT="777"; _CB_TOAST=""; _CB_ALERT="false" +_cb_send_link alice +assert_eq "Links succeeds when the QR is accepted" 0 "$?" +assert_contains "Links uploads a QR" "sendPhoto" "$(cat "$CURLCFG")" +assert_contains "the QR caption contains the complete FakeTLS URL" \ + "https://t.me/proxy?server=203.0.113.9&port=443&secret=ee$(printf 'a%.0s' $(seq 1 32))0a0b0c0d" "$(cat "$CURLS")" +assert_contains "the QR caption uses a compact Connect link" "🔗 [Connect](https://t.me/proxy?" "$(cat "$CURLS")" +assert_contains "the compact caption enables Markdown" "parse_mode=Markdown" "$(cat "$CURLS")" + +TELEGRAM_SERVER_LABEL='Test_Box[EU]' +: > "$CURLS" +_cb_send_link alice +assert_contains "server label is escaped in a formatted caption" 'Test\_Box\[EU]' "$(cat "$CURLS")" +TELEGRAM_SERVER_LABEL="TestBox" + +# Telegram can reject an upload while curl itself exits successfully. +CURL_RESPONSE='{"ok":false,"error_code":400,"description":"Bad Request"}' +_tg_send_photo_file "777" "$SECRETS_FILE" "scan me" +assert_eq "an API rejection fails the photo upload" 1 "$?" +: > "$CURLS"; : > "$CURLCFG" +send_proxy_qr_to "777" "203.0.113.9" "443" "eeaaaa0a0b0c0d" "scan me" +assert_eq "both delivery attempts failing is reported" 1 "$?" +assert_contains "a rejected photo falls back to a text link" "sendMessage" "$(cat "$CURLCFG")" +assert_contains "fallback contains the complete connection secret" "secret=eeaaaa0a0b0c0d" "$(cat "$CURLS")" +unset CURL_RESPONSE + +CURL_PHOTO_RESPONSE='{"ok":false,"error_code":400}' +_CB_CHAT="777"; _CB_TOAST=""; _CB_ALERT="false" +_cb_send_link alice +assert_eq "the user Links action succeeds via text after photo rejection" 0 "$?" +assert_eq "successful fallback is acknowledged" "Link sent" "$_CB_TOAST" +CURL_RESPONSE='{"ok":false,"error_code":403}' +_CB_TOAST=""; _CB_ALERT="false" +_cb_send_link alice +assert_eq "the user Links action reports failed delivery" 1 "$?" +assert_eq "delivery failure opens a visible alert" true "$_CB_ALERT" +assert_not_contains "delivery failure does not claim success" "Link sent" "$_CB_TOAST" +unset CURL_RESPONSE CURL_PHOTO_RESPONSE + # With no renderer at all, the link is still delivered — as the tappable link, # never by falling back to the third party. rm -f "$TEST_TMPDIR/bin/qrencode" : > "$SENDS"; : > "$CURLS"; : > "$CURLCFG" send_proxy_qr_to "777" "203.0.113.9" "443" "eeaaaa0a0b0c0d" "scan me" assert_not_contains "no renderer still means no third party" "qrserver" "$(cat "$CURLS")$(cat "$CURLCFG")" -assert_contains "the fallback still delivers a usable link" "t.me/proxy?server=203.0.113.9" "$(cat "$SENDS")" +assert_contains "the fallback still delivers a usable link" "t.me/proxy?server=203.0.113.9" "$(cat "$CURLS")" printf '\n%d tests, %d failures\n' "$TESTS_RUN" "$TESTS_FAILED" [ "$TESTS_FAILED" -eq 0 ]