Skip to content
Open
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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,7 @@ Activating lockdown instantly engages the **Kernel SYN Shield**, activates **Ult
- Configures role hierarchies stored in `${INSTALL_DIR}/admins.conf`:
- **`superadmin`**: Full access to all 21 administrative commands, including destructive engine restarts (`/mp_restart`), emergency lockdowns (`/mp_lockdown`), bot removals (`/mp_remove`), and self-updates (`/mp_update`).
- **`reseller`**: Delegated commercial management rights restricted to voucher redemption (`/redeem`), voucher batch generation (`/mp_voucher create <cnt> <qta> <dys>`), and voucher inventory auditing (`/mp_voucher list`). Destructive engine commands are automatically blocked with security violation logging.
- **Any other value** is treated as a misconfiguration and refused rather than granted access, so a typo in `admins.conf` fails closed.
- **Decoupled Self-Service Status Portal (`mtproxymax portal [enable|disable|port|generate|serve|status]`):** Lightweight, zero-dependency static web dashboard designed for client self-service and transparent uptime reporting.
- Generates an ultra-responsive, modern dark-mode glassmorphism HTML page (`index.html`) stored in `${INSTALL_DIR}/portal/`.
- During periodic engine sweeps (`sweep()`), MTProxyMax automatically exports real-time system metrics (`status.json`) and anonymized user leaderboard statistics (`users.json`).
Expand Down
39 changes: 38 additions & 1 deletion mtproxymax.sh
Original file line number Diff line number Diff line change
Expand Up @@ -11554,6 +11554,19 @@ _check_tg_role() {
echo "${r:-none}"
}

# Record a security event in the shared audit log. The bot daemon is
# self-contained and never sources the manager, so it cannot call the manager's
# audit_log() and writes the same line format itself.
_tg_security_log() {
local cid="$1" action="$2"
local _log="${INSTALL_DIR}/audit.log"
mkdir -p "$INSTALL_DIR" 2>/dev/null || true
printf '%s UTC | telegram:%s | SECURITY: denied %q\n' \
"$(date -u '+%Y-%m-%d %H:%M:%S')" "$cid" "$action" >> "$_log" 2>/dev/null || true
chmod 600 "$_log" 2>/dev/null || true
return 0
}

_process_cmd() {
local update_id="$1" chat_id="$2" text="$3"
echo "$((update_id + 1))" > "$OFFSET_FILE"
Expand Down Expand Up @@ -11640,7 +11653,31 @@ _process_cmd() {
return
fi

# Superadmin & Reseller administrative commands
# The control plane is an allowlist. A reseller is limited to vouchers (see
# README: Role-Based Access Control). Any other role value is a
# misconfiguration — admins.conf is a plain file an operator can hand-edit —
# and is refused rather than silently granted administrative rights.
case "$role" in
superadmin)
;;
reseller)
case "$text" in
/mp_voucher|/mp_voucher@*|/mp_voucher\ *|/mp_voucher@*\ *) ;;
*)
tg_send_to "$chat_id" "⛔ Permission denied: the reseller role is limited to voucher commands."
_tg_security_log "$chat_id" "$text"
return
;;
esac
;;
*)
tg_send_to "$chat_id" "⛔ Permission denied: this account has an unrecognised role."
_tg_security_log "$chat_id" "unrecognised role '${role}': ${text}"
return
;;
esac

# Administrative commands (only superadmins reach this point)
case "$text" in
/mp_voucher\ *|/mp_voucher@*\ *)
local sub=$(echo "$text" | awk '{print $2}')
Expand Down
172 changes: 172 additions & 0 deletions tests/test_telegram_reseller_rbac.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
#!/bin/bash
# Regression tests for reseller RBAC enforcement in the Telegram bot.
#
# The README restricts a `reseller` to voucher redemption and voucher
# create/list, but the dispatcher only blocked `role == none` plus four
# superadmin-only commands, so a reseller could drive nearly the whole admin
# control plane. These tests pin the documented contract.
set -o pipefail

if [ "${BASH_VERSINFO[0]:-0}" -lt 4 ]; then
echo "SKIP: bash 4+ required (got ${BASH_VERSION:-unknown})" >&2
exit 0
fi

TEST_TMPDIR=$(mktemp -d)
INSTALL_DIR="$TEST_TMPDIR/install"
mkdir -p "$INSTALL_DIR/relay_stats"
OFFSET_FILE="$INSTALL_DIR/relay_stats/tg_offset"
ADMINS_FILE="$INSTALL_DIR/admins.conf"
AUDIT_LOG="$INSTALL_DIR/audit.log"

MTPROXYMAX_SOURCE_ONLY=true source "$(dirname "${BASH_SOURCE[0]}")/../mtproxymax.sh"
set +e
trap 'rm -rf "$TEST_TMPDIR"' EXIT

TESTS_RUN=0
TESTS_FAILED=0

assert_contains() {
local name="$1" needle="$2" haystack="$3"
TESTS_RUN=$((TESTS_RUN + 1))
if printf '%s' "$haystack" | grep -qF -- "$needle"; then
printf ' PASS %s\n' "$name"
else
TESTS_FAILED=$((TESTS_FAILED + 1))
printf ' FAIL %s (missing %q in %q)\n' "$name" "$needle" "$haystack"
fi
}

assert_not_contains() {
local name="$1" needle="$2" haystack="$3"
TESTS_RUN=$((TESTS_RUN + 1))
if printf '%s' "$haystack" | grep -qF -- "$needle"; then
TESTS_FAILED=$((TESTS_FAILED + 1))
printf ' FAIL %s (unexpected %q in %q)\n' "$name" "$needle" "$haystack"
else
printf ' PASS %s\n' "$name"
fi
}

assert_eq() {
local name="$1" want="$2" got="$3"
TESTS_RUN=$((TESTS_RUN + 1))
if [ "$got" = "$want" ]; then
printf ' PASS %s\n' "$name"
else
TESTS_FAILED=$((TESTS_FAILED + 1))
printf ' FAIL %s (got=%q want=%q)\n' "$name" "$got" "$want"
fi
}

# ── Stubs ────────────────────────────────────────────────────────────────────
REPLIES="$TEST_TMPDIR/replies.log"
ROLE_TO_RETURN="reseller"

_check_tg_role() { echo "$ROLE_TO_RETURN"; }
tg_send() { printf 'admin|%s\n' "$*" >> "$REPLIES"; }
tg_send_to() { printf 'to:%s|%s\n' "$1" "$2" >> "$REPLIES"; }
load_tg_settings() { :; }
is_running() { return 1; }
log_warn() { :; }

# Stand-in for the manager binary the voucher handler shells out to.
cat > "$INSTALL_DIR/mtproxymax" <<'EOS'
#!/bin/bash
case "$1 $2" in
# Three lines so the create path (which does `tail -n +3`) still yields one.
"voucher list") printf 'HEADER\nSEPARATOR\nMTP-AAAA-BBBB\n' ;;
"voucher create"|"voucher redeem") : ;;
*) : ;;
esac
EOS
chmod +x "$INSTALL_DIR/mtproxymax"

# Exercise the exact dispatcher shipped in the generated bot daemon.
telegram_generate_service_script
DAEMON="$INSTALL_DIR/mtproxymax-telegram.sh"
awk '/^_tg_security_log\(\)/,/^}$/' "$DAEMON" > "$TEST_TMPDIR/daemon-fns.sh"
awk '/^_process_cmd\(\)/,/^}$/' "$DAEMON" >> "$TEST_TMPDIR/daemon-fns.sh"
assert_eq "daemon helper extraction is valid bash" 0 \
"$(bash -n "$TEST_TMPDIR/daemon-fns.sh" 2>/dev/null; echo $?)"
source "$TEST_TMPDIR/daemon-fns.sh"

# run <role> <chat_id> <text> -> replies in $REPLIES
run() {
: > "$REPLIES"
ROLE_TO_RETURN="$1"
_process_cmd 1 "$2" "$3" 2>/dev/null
}
audit_now() { cat "$AUDIT_LOG" 2>/dev/null || printf ''; }
reset_audit() { : > "$AUDIT_LOG"; }

echo "Telegram reseller RBAC tests"

# ── A reseller is limited to vouchers ────────────────────────────────────────
reset_audit
run reseller 333 "/mp_status"
assert_contains "reseller is denied /mp_status" "Permission denied" "$(cat "$REPLIES")"
assert_contains "denial is logged" "SECURITY" "$(audit_now)"
assert_contains "log records the denied command" "/mp_status" "$(audit_now)"
assert_contains "log records the offending chat" "333" "$(audit_now)"
assert_contains "denial goes to the sender, not the admin chat" "to:333|" "$(cat "$REPLIES")"
assert_not_contains "denial is not sent to the admin chat" "admin|" "$(cat "$REPLIES")"

for _cmd in /mp_restart /mp_lockdown /mp_update /mp_remove /mp_add /mp_broadcast \
/mp_secrets /mp_link /mp_setlimit /mp_help /mp_traffic /reply; do
reset_audit
run reseller 333 "$_cmd"
assert_contains "reseller is denied $_cmd" "Permission denied" "$(cat "$REPLIES")"
assert_contains "denial of $_cmd is logged" "SECURITY" "$(audit_now)"
done

# ── ...but vouchers and public commands still work ───────────────────────────
for _cmd in "/mp_voucher list" "/mp_voucher create 5 10G 30"; do
reset_audit
run reseller 333 "$_cmd"
assert_not_contains "reseller is allowed $_cmd" "Permission denied" "$(cat "$REPLIES")"
assert_eq "allowed $_cmd is not logged as a violation" "" "$(audit_now)"
assert_contains "allowed $_cmd reaches the voucher engine" "MTP-AAAA-BBBB" "$(cat "$REPLIES")"
done

reset_audit
run reseller 333 "/start"
assert_not_contains "reseller keeps the public /start" "Permission denied" "$(cat "$REPLIES")"
assert_contains "reseller gets the self-service welcome" "Welcome to MTProxyMax" "$(cat "$REPLIES")"

# ── Superadmins are unaffected ───────────────────────────────────────────────
reset_audit
run superadmin 111 "/mp_status"
assert_not_contains "superadmin is not denied /mp_status" "Permission denied" "$(cat "$REPLIES")"
assert_eq "superadmin action is not logged as a violation" "" "$(audit_now)"

# ── Unauthenticated users still get nothing ──────────────────────────────────
reset_audit
run none 999 "/mp_status"
assert_eq "unauthenticated user gets no admin reply" "" "$(cat "$REPLIES")"
assert_eq "unauthenticated user is not logged as a violation" "" "$(audit_now)"

# ── Unrecognised roles fail closed ───────────────────────────────────────────
# _check_tg_role returns whatever admins.conf holds, and admins.conf is a plain
# file an operator can hand-edit. Anything that is not exactly 'superadmin' or
# 'reseller' must be refused rather than granted the admin control plane.
for _role in operator administrator root SUPERADMIN superadmin2; do
reset_audit
run "$_role" 444 "/mp_status"
assert_contains "role '$_role' is denied the control plane" "Permission denied" "$(cat "$REPLIES")"
assert_contains "role '$_role' denial is logged" "SECURITY" "$(audit_now)"
assert_contains "role '$_role' denial names the role" "$_role" "$(audit_now)"
done

reset_audit
run operator 444 "/mp_voucher list"
assert_contains "unrecognised role cannot reach the voucher engine" "Permission denied" "$(cat "$REPLIES")"

reset_audit
run operator 444 "/start"
assert_not_contains "unrecognised role still gets public commands" "Permission denied" "$(cat "$REPLIES")"
assert_contains "unrecognised role gets the self-service welcome" "Welcome to MTProxyMax" "$(cat "$REPLIES")"
assert_eq "a public command is not logged as a violation" "" "$(audit_now)"

printf '\n%d tests, %d failures\n' "$TESTS_RUN" "$TESTS_FAILED"
[ "$TESTS_FAILED" -eq 0 ]