Skip to content
This repository was archived by the owner on Apr 5, 2026. It is now read-only.

Commit dbd8935

Browse files
committed
feat: add per-secret connection limits (#66)
Cap concurrent connections per secret to prevent a leaked secret from consuming all proxy resources. When the limit is reached, new TLS connections are proxied to the configured domain (indistinguishable from a normal website), and obfuscated2 connections are silently dropped. Existing connections and other secrets are unaffected. - CLI: -S secret:label:1000 (limit is optional, backward-compatible) - Docker: SECRET_LIMIT_N env vars - Stats: secret_<label>_limit, secret_<label>_rejected - Prometheus: mtproxy_secret_connection_limit, mtproxy_secret_connections_rejected_total - Multi-worker: limit divided across workers for approximate enforcement Closes #66
1 parent fc50472 commit dbd8935

11 files changed

Lines changed: 376 additions & 16 deletions

.github/workflows/test.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,9 @@ jobs:
3737
- name: Run Multi-Secret Tests
3838
run: make test-multi-secret
3939

40+
- name: Run Secret Limit Tests
41+
run: make test-secret-limit
42+
4043
test-arm64:
4144
runs-on: ubuntu-24.04-arm
4245
steps:

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,11 @@
22

33
All notable changes to this project will be documented in this file.
44

5+
## [3.4.0] - 2026-03-27
6+
7+
### Added
8+
- **Per-secret connection limits** — cap concurrent connections per secret to prevent a leaked or widely-shared secret from consuming all proxy resources. Syntax: `-S secret:label:1000`. Fake-TLS connections are proxied to the domain on rejection (indistinguishable from a normal website); obfuscated2 connections are silently dropped. Docker: `SECRET_LIMIT_N` env vars. New stats: `secret_<label>_limit`, `secret_<label>_rejected`, and Prometheus equivalents ([#66](https://github.com/GetPageSpeed/MTProxy/issues/66))
9+
510
## [3.3.1] - 2026-03-27
611

712
### Fixed

Makefile

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ DEPDIRS := ${DEP} $(addprefix ${DEP}/,${PROJECTS})
5959
ALLDIRS := ${DEPDIRS} ${OBJDIRS}
6060

6161

62-
.PHONY: all clean lint tests test test-tls test-multi-secret test-ip-acl docker-image-amd64 docker-run-help-amd64 docker-image-arm64 docker-run-help-arm64 fuzz fuzz-run
62+
.PHONY: all clean lint tests test test-tls test-multi-secret test-secret-limit test-ip-acl docker-image-amd64 docker-run-help-amd64 docker-image-arm64 docker-run-help-arm64 fuzz fuzz-run
6363

6464
EXELIST := ${EXE}/mtproto-proxy
6565

@@ -198,6 +198,16 @@ test-multi-secret:
198198
(echo "FAIL: No connection links found in proxy logs"; exit 1)
199199
docker compose -f tests/docker-compose.multi-secret-test.yml down
200200

201+
test-secret-limit:
202+
@export MTPROXY_SECRET_1=$$(head -c 16 /dev/urandom | xxd -ps) && \
203+
export MTPROXY_SECRET_2=$$(head -c 16 /dev/urandom | xxd -ps) && \
204+
echo "Using secrets: $$MTPROXY_SECRET_1 (unlimited), $$MTPROXY_SECRET_2 (limit=5)" && \
205+
timeout 300s docker compose -f tests/docker-compose.secret-limit-test.yml up --build --exit-code-from tester || \
206+
(echo "Secret limit test timed out or failed"; \
207+
docker compose -f tests/docker-compose.secret-limit-test.yml logs mtproxy; \
208+
docker compose -f tests/docker-compose.secret-limit-test.yml down; exit 1)
209+
docker compose -f tests/docker-compose.secret-limit-test.yml down
210+
201211
test-ip-acl:
202212
@if [ -z "$$MTPROXY_SECRET" ]; then \
203213
export MTPROXY_SECRET=$$(head -c 16 /dev/urandom | xxd -ps); \

README.md

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -162,7 +162,7 @@ head -c 16 /dev/urandom | xxd -ps
162162
- `nobody` is the username. `mtproto-proxy` calls `setuid()` to drop privilegies.
163163
- `443` is the port, used by clients to connect to the proxy.
164164
- `8888` is the local port for statistics (requires `--http-stats`). Like `curl http://localhost:8888/stats`. Stats are accessible from private networks (loopback, 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16) but not from public IPs.
165-
- `<secret>` is the secret generated at step 3. Also you can set multiple secrets: `-S <secret1> -S <secret2>`. Each secret can have an optional label: `-S <secret>:family -S <secret>:friends`. Labels appear in logs and stats instead of raw secrets, making it easy to identify which secret a connection used.
165+
- `<secret>` is the secret generated at step 3. Also you can set multiple secrets: `-S <secret1> -S <secret2>`. Each secret can have an optional label: `-S <secret>:family -S <secret>:friends`. Labels appear in logs and stats instead of raw secrets, making it easy to identify which secret a connection used. You can also set a per-secret connection limit: `-S <secret>:family:1000` (see [Per-Secret Connection Limits](#per-secret-connection-limits)).
166166
- `--aes-pwd proxy-secret` points to the `proxy-secret` file downloaded at step 1, which contains the encryption key used for MTProto key exchange with Telegram DCs.
167167
- `proxy-secret` and `proxy-multi.conf` are obtained at steps 1 and 2.
168168
- `1` is the number of workers. You can increase the number of workers, if you have a powerful server.
@@ -487,6 +487,7 @@ docker run -d \
487487
- If both `SECRET` and `SECRET_N` are set, all are combined
488488
- Maximum 16 secrets (binary limit)
489489
- `SECRET_LABEL_1`, `SECRET_LABEL_2`, ...: Optional labels for numbered secrets (e.g. `SECRET_LABEL_1=family`). See [Secret Labels](#secret-labels)
490+
- `SECRET_LIMIT_1`, `SECRET_LIMIT_2`, ...: Optional per-secret connection limits (e.g. `SECRET_LIMIT_1=1000`). See [Per-Secret Connection Limits](#per-secret-connection-limits)
490491
- `PORT`: Port for client connections (default: 443)
491492
- `STATS_PORT`: Port for statistics endpoint (default: 8888)
492493
- `WORKERS`: Number of worker processes (default: 1)
@@ -578,6 +579,47 @@ If no label is given, secrets are auto-labeled `secret_0`, `secret_1`, etc.
578579

579580
Label rules: max 32 characters, alphanumeric plus `_` and `-` only.
580581

582+
#### Per-Secret Connection Limits
583+
584+
Prevent a leaked or widely-shared secret from consuming all proxy resources by setting
585+
a maximum number of concurrent connections per secret:
586+
587+
```bash
588+
# CLI: append :LIMIT after the label
589+
./mtproto-proxy ... -S cafe...90ab:family:1000 -S dead...90ef:public:200
590+
591+
# Without a label, use an empty label field
592+
./mtproto-proxy ... -S cafe...90ab::500
593+
594+
# Docker: numbered env vars
595+
SECRET_1=cafe1234567890abcdef1234567890ab
596+
SECRET_LABEL_1=family
597+
SECRET_LIMIT_1=1000
598+
SECRET_2=dead1234567890abcdef1234567890ef
599+
SECRET_LABEL_2=public
600+
SECRET_LIMIT_2=200
601+
602+
# Docker: inline (comma-separated)
603+
SECRET=cafe...90ab:family:1000,dead...90ef:public:200
604+
```
605+
606+
When the limit is reached, new connections using that secret are rejected:
607+
- **Fake-TLS (EE mode)**: rejected during the TLS handshake — the client is proxied to the
608+
configured domain, so it sees a normal website (indistinguishable from a non-proxy server).
609+
- **Obfuscated2 (DD mode)**: the connection is silently dropped.
610+
611+
Existing connections are not affected. Other secrets continue operating normally.
612+
613+
**Multi-worker note**: with `-M N` workers, each worker enforces `limit / N` independently.
614+
For single-worker mode (`-M 0` or `-M 1`), the limit is exact.
615+
616+
Limits appear in stats and Prometheus metrics:
617+
- **Stats** (`/stats`): `secret_family_limit 1000`, `secret_family_rejected 42`
618+
- **Prometheus** (`/metrics`): `mtproxy_secret_connection_limit{secret="family"} 1000`,
619+
`mtproxy_secret_connections_rejected_total{secret="family"} 42`
620+
621+
Secrets without a limit are unlimited (the default, backward-compatible behavior).
622+
581623
And reference it in your `docker-compose.yml`:
582624
```yaml
583625
services:

mtproto/mtproto-proxy.c

Lines changed: 46 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,7 @@ struct ext_connection_ref {
197197

198198
long long ext_connections, ext_connections_created;
199199
long long per_secret_connections[16], per_secret_connections_created[16];
200+
long long per_secret_connections_rejected[16];
200201

201202
struct ext_connection_ref OutExtConnections[EXT_CONN_TABLE_SIZE];
202203
struct ext_connection *InExtConnectionHash[EXT_CONN_HASH_SIZE];
@@ -414,6 +415,7 @@ struct worker_stats {
414415

415416
long long per_secret_connections[16];
416417
long long per_secret_connections_created[16];
418+
long long per_secret_connections_rejected[16];
417419
};
418420

419421
struct worker_stats *WStats, SumStats;
@@ -473,6 +475,7 @@ static void update_local_stats_copy (struct worker_stats *S) {
473475
{ int _i; for (_i = 0; _i < 16; _i++) {
474476
UPD (per_secret_connections[_i]);
475477
UPD (per_secret_connections_created[_i]);
478+
UPD (per_secret_connections_rejected[_i]);
476479
}}
477480
#undef UPD
478481
__sync_synchronize();
@@ -553,6 +556,7 @@ static inline void add_stats (struct worker_stats *W) {
553556
{ int _i; for (_i = 0; _i < 16; _i++) {
554557
UPD (per_secret_connections[_i]);
555558
UPD (per_secret_connections_created[_i]);
559+
UPD (per_secret_connections_rejected[_i]);
556560
}}
557561
#undef UPD
558562
}
@@ -753,9 +757,15 @@ void mtfront_prepare_stats (stats_buffer_t *sb) {
753757
for (_i = 0; _i < _sc; _i++) {
754758
sb_printf (sb,
755759
"secret_%s_connections\t%lld\n"
756-
"secret_%s_connections_created\t%lld\n",
760+
"secret_%s_connections_created\t%lld\n"
761+
"secret_%s_rejected\t%lld\n",
757762
tcp_rpcs_get_ext_secret_label (_i), S(per_secret_connections[_i]),
758-
tcp_rpcs_get_ext_secret_label (_i), S(per_secret_connections_created[_i]));
763+
tcp_rpcs_get_ext_secret_label (_i), S(per_secret_connections_created[_i]),
764+
tcp_rpcs_get_ext_secret_label (_i), S(per_secret_connections_rejected[_i]));
765+
int _lim = tcp_rpcs_get_ext_secret_limit (_i);
766+
if (_lim > 0) {
767+
sb_printf (sb, "secret_%s_limit\t%d\n", tcp_rpcs_get_ext_secret_label (_i), _lim);
768+
}
759769
}
760770
}
761771
#undef S
@@ -928,6 +938,20 @@ void mtfront_prepare_prometheus_stats (stats_buffer_t *sb) {
928938
sb_printf (sb, "mtproxy_secret_connections_created_total{secret=\"%s\"} %lld\n",
929939
tcp_rpcs_get_ext_secret_label (_i), S(per_secret_connections_created[_i]));
930940
}
941+
sb_printf (sb,
942+
"# HELP mtproxy_secret_connection_limit Configured connection limit per secret (0=unlimited).\n"
943+
"# TYPE mtproxy_secret_connection_limit gauge\n");
944+
for (_i = 0; _i < _sc; _i++) {
945+
sb_printf (sb, "mtproxy_secret_connection_limit{secret=\"%s\"} %d\n",
946+
tcp_rpcs_get_ext_secret_label (_i), tcp_rpcs_get_ext_secret_limit (_i));
947+
}
948+
sb_printf (sb,
949+
"# HELP mtproxy_secret_connections_rejected_total Connections rejected due to per-secret limit.\n"
950+
"# TYPE mtproxy_secret_connections_rejected_total counter\n");
951+
for (_i = 0; _i < _sc; _i++) {
952+
sb_printf (sb, "mtproxy_secret_connections_rejected_total{secret=\"%s\"} %lld\n",
953+
tcp_rpcs_get_ext_secret_label (_i), S(per_secret_connections_rejected[_i]));
954+
}
931955
}
932956
}
933957

@@ -2447,12 +2471,30 @@ int f_parse_option (int val) {
24472471
{
24482472
char *label = NULL;
24492473
int hex_len;
2474+
int conn_limit = 0;
24502475

24512476
if (val == 'S') {
24522477
char *colon = strchr (optarg, ':');
24532478
if (colon) {
24542479
hex_len = colon - optarg;
24552480
label = colon + 1;
2481+
2482+
/* Look for optional :LIMIT after label */
2483+
char *colon2 = strchr (label, ':');
2484+
if (colon2) {
2485+
*colon2 = '\0';
2486+
char *limit_str = colon2 + 1;
2487+
if (*limit_str) {
2488+
char *endp;
2489+
long lv = strtol (limit_str, &endp, 10);
2490+
if (*endp || lv < 1) {
2491+
kprintf ("Invalid connection limit '%s' (must be a positive integer)\n", limit_str);
2492+
usage ();
2493+
}
2494+
conn_limit = (int)lv;
2495+
}
2496+
}
2497+
24562498
if (strlen (label) == 0) {
24572499
label = NULL;
24582500
} else if (strlen (label) > EXT_SECRET_LABEL_MAX) {
@@ -2499,7 +2541,7 @@ int f_parse_option (int val) {
24992541
}
25002542
}
25012543
if (val == 'S') {
2502-
tcp_rpcs_set_ext_secret (secret, label);
2544+
tcp_rpcs_set_ext_secret (secret, label, conn_limit);
25032545
secret_count++;
25042546
} else {
25052547
memcpy (proxy_tag, secret, sizeof (proxy_tag));
@@ -2527,7 +2569,7 @@ int f_parse_option (int val) {
25272569

25282570
void mtfront_prepare_parse_options (void) {
25292571
parse_option ("http-stats", no_argument, 0, 2000, "allow http server to answer on stats queries");
2530-
parse_option ("mtproto-secret", required_argument, 0, 'S', "16-byte secret in hex, optionally followed by :LABEL (e.g. -S abcdef01234567890abcdef012345678:myapp)");
2572+
parse_option ("mtproto-secret", required_argument, 0, 'S', "16-byte secret in hex, optionally :LABEL:LIMIT (e.g. -S abcdef01234567890abcdef012345678:myapp:1000)");
25312573
parse_option ("proxy-tag", required_argument, 0, 'P', "16-byte proxy tag in hex mode to be passed along with all forwarded queries");
25322574
parse_option ("domain", required_argument, 0, 'D', "adds allowed domain or host:port for TLS-transport mode, disables other transports; can be specified more than once");
25332575
parse_option ("max-special-connections", required_argument, 0, 'C', "sets maximal number of accepted client connections per worker");

net/net-tcp-rpc-ext-server.c

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -176,8 +176,10 @@ int tcp_proxy_pass_write_packet (connection_job_t C, struct raw_message *raw) {
176176
*/
177177

178178
extern int direct_mode;
179+
extern int workers;
179180
extern long long direct_dc_connections_created, direct_dc_connections_active;
180181
extern long long per_secret_connections[16], per_secret_connections_created[16];
182+
extern long long per_secret_connections_rejected[16];
181183

182184
static int tcp_direct_client_parse_execute (connection_job_t C);
183185
static int tcp_direct_dc_parse_execute (connection_job_t C);
@@ -466,8 +468,9 @@ static unsigned char ext_secret[16][16];
466468
static int ext_secret_cnt = 0;
467469
static int ext_rand_pad_only = 0;
468470
static char ext_secret_label[16][EXT_SECRET_LABEL_MAX + 1];
471+
static int ext_secret_limit[16]; /* 0 = unlimited */
469472

470-
void tcp_rpcs_set_ext_secret (unsigned char secret[16], const char *label) {
473+
void tcp_rpcs_set_ext_secret (unsigned char secret[16], const char *label, int limit) {
471474
assert (ext_secret_cnt < 16);
472475
int idx = ext_secret_cnt++;
473476
memcpy (ext_secret[idx], secret, 16);
@@ -476,18 +479,36 @@ void tcp_rpcs_set_ext_secret (unsigned char secret[16], const char *label) {
476479
} else {
477480
snprintf (ext_secret_label[idx], sizeof (ext_secret_label[idx]), "secret_%d", idx);
478481
}
479-
vkprintf (0, "Added secret #%d label=[%s]\n", idx, ext_secret_label[idx]);
482+
ext_secret_limit[idx] = limit;
483+
if (limit > 0) {
484+
vkprintf (0, "Added secret #%d label=[%s] limit=%d\n", idx, ext_secret_label[idx], limit);
485+
} else {
486+
vkprintf (0, "Added secret #%d label=[%s] (unlimited)\n", idx, ext_secret_label[idx]);
487+
}
480488
}
481489

482490
const char *tcp_rpcs_get_ext_secret_label (int index) {
483491
assert (index >= 0 && index < ext_secret_cnt);
484492
return ext_secret_label[index];
485493
}
486494

495+
int tcp_rpcs_get_ext_secret_limit (int index) {
496+
assert (index >= 0 && index < ext_secret_cnt);
497+
return ext_secret_limit[index];
498+
}
499+
487500
int tcp_rpcs_get_ext_secret_count (void) {
488501
return ext_secret_cnt;
489502
}
490503

504+
static int secret_over_limit (int secret_id) {
505+
int limit = ext_secret_limit[secret_id];
506+
if (limit <= 0) { return 0; }
507+
int eff = workers > 1 ? limit / workers : limit;
508+
if (eff < 1) { eff = 1; }
509+
return per_secret_connections[secret_id] >= eff;
510+
}
511+
491512
void tcp_rpcs_set_ext_rand_pad_only(int set) {
492513
ext_rand_pad_only = set;
493514
}
@@ -1457,6 +1478,12 @@ int tcp_rpcs_compact_parse_execute (connection_job_t C) {
14571478
D->extra_int2 = secret_id + 1;
14581479
vkprintf (1, "TLS handshake matched secret [%s] from %s:%d\n", ext_secret_label[secret_id], show_remote_ip (C), c->remote_port);
14591480

1481+
if (secret_over_limit (secret_id)) {
1482+
per_secret_connections_rejected[secret_id]++;
1483+
vkprintf (1, "TLS connection rejected: secret [%s] at limit %d from %s:%d\n", ext_secret_label[secret_id], ext_secret_limit[secret_id], show_remote_ip (C), c->remote_port);
1484+
RETURN_TLS_ERROR(info);
1485+
}
1486+
14601487
unsigned char cipher_suite_id;
14611488
if (tls_parse_client_hello_ciphers (client_hello, read_len, &cipher_suite_id) < 0) {
14621489
vkprintf (1, "Can't find supported cipher suite\n");
@@ -1626,6 +1653,17 @@ int tcp_rpcs_compact_parse_execute (connection_job_t C) {
16261653
}
16271654

16281655
if (ok) {
1656+
/* Check per-secret connection limit (non-TLS; TLS checked during handshake) */
1657+
if (!(c->flags & C_IS_TLS)) {
1658+
int _sid = D->extra_int2;
1659+
if (_sid > 0 && _sid <= 16 && secret_over_limit (_sid - 1)) {
1660+
per_secret_connections_rejected[_sid - 1]++;
1661+
vkprintf (1, "connection rejected: secret [%s] at limit %d from %s:%d\n", ext_secret_label[_sid - 1], ext_secret_limit[_sid - 1], show_remote_ip (C), c->remote_port);
1662+
fail_connection (C, -1);
1663+
return 0;
1664+
}
1665+
}
1666+
16291667
/* Activate DRS for TLS connections */
16301668
if (c->flags & C_IS_TLS) {
16311669
static int drs_types_checked;

net/net-tcp-rpc-ext-server.h

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,9 +31,10 @@ int tcp_rpcs_compact_parse_execute (connection_job_t c);
3131

3232
#define EXT_SECRET_LABEL_MAX 32
3333

34-
void tcp_rpcs_set_ext_secret(unsigned char secret[16], const char *label);
34+
void tcp_rpcs_set_ext_secret(unsigned char secret[16], const char *label, int limit);
3535
void tcp_rpcs_set_ext_rand_pad_only(int set);
3636
const char *tcp_rpcs_get_ext_secret_label(int index);
37+
int tcp_rpcs_get_ext_secret_limit(int index);
3738
int tcp_rpcs_get_ext_secret_count(void);
3839

3940
void tcp_rpc_add_proxy_domain (const char *domain);

start.sh

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -65,11 +65,16 @@ while [ "$_i" -le 16 ]; do
6565
if [ -n "$_val" ]; then
6666
eval "_lbl=\${SECRET_LABEL_${_i}:-}"
6767
_lbl=$(printf '%s' "$_lbl" | tr -d '[:space:]')
68-
if [ -n "$_lbl" ]; then
69-
set -- "$@" "${_val}:${_lbl}"
70-
else
71-
set -- "$@" "$_val"
68+
eval "_lim=\${SECRET_LIMIT_${_i}:-}"
69+
_lim=$(printf '%s' "$_lim" | tr -d '[:space:]')
70+
_suffix=""
71+
if [ -n "$_lbl" ] || [ -n "$_lim" ]; then
72+
_suffix=":${_lbl}"
73+
fi
74+
if [ -n "$_lim" ]; then
75+
_suffix="${_suffix}:${_lim}"
7276
fi
77+
set -- "$@" "${_val}${_suffix}"
7378
fi
7479
_i=$((_i + 1))
7580
done
@@ -164,9 +169,9 @@ echo ""
164169
echo "===== Connection Links ====="
165170
_host="${EXTERNAL_IP:-<YOUR_SERVER_IP>}"
166171
for _s in "$@"; do
167-
# Strip :LABEL suffix for URLs (labels are for the proxy, not for clients)
172+
# Strip :LABEL:LIMIT suffix for URLs (labels/limits are for the proxy, not for clients)
168173
_secret_hex=$(printf '%s' "$_s" | cut -d: -f1)
169-
_label=$(printf '%s' "$_s" | grep -o ':.*' | cut -c2- || true)
174+
_label=$(printf '%s' "$_s" | cut -d: -f2 -s)
170175
if [ -n "$EE_DOMAIN" ]; then
171176
_domain_only=$(printf '%s' "$EE_DOMAIN" | cut -d: -f1)
172177
_domain_hex=$(printf '%s' "$_domain_only" | od -An -tx1 | tr -d ' \n')

tests/Dockerfile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,6 @@ WORKDIR /app
33
COPY requirements.txt .
44
RUN pip install -r requirements.txt
55
ENV PYTHONUNBUFFERED=1
6-
COPY test_proxy.py test_tls_e2e.py test_multi_secret_tls.py test_ip_acl.py test_direct_e2e.py ./
6+
COPY test_proxy.py test_tls_e2e.py test_multi_secret_tls.py test_ip_acl.py test_direct_e2e.py test_secret_limit.py ./
77
CMD ["python", "test_proxy.py"]
88

0 commit comments

Comments
 (0)