Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 36 additions & 13 deletions mtproxymax.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
24 changes: 23 additions & 1 deletion tests/test_telegram_callback_dispatch.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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.
Expand Down Expand Up @@ -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
Expand Down
55 changes: 51 additions & 4 deletions tests/test_telegram_qr_and_limits.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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 ]
Loading