Skip to content

feat(mtproto): add MTProto proxy backend via telemt - #61

Closed
T3ST3ST3R0N wants to merge 16 commits into
PasarGuard:devfrom
T3ST3ST3R0N:feat/mtproto-telemt
Closed

T3ST3ST3R0N wants to merge 16 commits into
PasarGuard:devfrom
T3ST3ST3R0N:feat/mtproto-telemt

Conversation

@T3ST3ST3R0N

@T3ST3ST3R0N T3ST3ST3R0N commented Jun 29, 2026 •

Copy link
Copy Markdown
Collaborator

What this adds

A new MTPROTO backend type that runs the telemt MTProto proxy (an external Rust binary) as a node managed subprocess. It is mutually exclusive with xray and wireguard, following the existing one backend at a time model.

Changes

  • proto: BackendType.MTPROTO, a Mtproto proxy message (secret, user_ad_tag, max_tcp_conns, expiration_rfc3339, data_quota_bytes, max_unique_ips), and Proxy.mtproto
  • backend/mtproto: parses the panel JSON config and renders telemt TOML, manages the telemt subprocess (run, reload, version), uses telemt's REST control API for user create/patch/delete, scrapes the Prometheus endpoint for per user traffic, tracks per user up/down deltas with reset and counter restart handling, and injects a sentinel user because telemt refuses to start with an empty [access.users]
  • controller: MTPROTO start case
  • config: TELEMT_EXECUTABLE_PATH
  • build: pinned make install_telemt target (telemt 3.4.19) and Dockerfile.mtproto
  • tests: unit tests plus an env gated integration test (MTPROTO_TELEMT_BIN)

Verification

Builds and vets clean, unit tests pass. The integration test was run against a real telemt 3.4.19 and confirms process start, version, user create/patch/delete/get, online IP list, system stats, and the empty users sentinel path.

Per user traffic and online count stats come from telemt's Prometheus endpoint, which only binds once telemt's data plane is up (it needs Telegram connectivity). Those paths were verified at the unit level and kept best effort in the integration test, so they should be confirmed once more on a host with Telegram egress.

Panel side (separate change, not in this PR)

The panel needs to send a JSON telemt config, set the backend type to MTPROTO, tag MTProto users with the configured inbound_tag, populate proxies.mtproto.secret (and optional limits), and build the proxy links.

Notes

telemt ships releases roughly weekly. The version is pinned in the Makefile and Dockerfile so it can be bumped deliberately.

Summary by CodeRabbit

  • New Features
    • Added MTProto backend support with TeleMT process lifecycle management, user synchronization (including optional sentinel behavior), and metrics/online statistics.
    • Extended the service API to support the MTProto backend type and MTProto proxy settings.
    • Added Docker and build tooling to run TeleMT via a pinned static binary.
  • Chores
    • Documented and wired TELEMT_EXECUTABLE_PATH (default: /usr/local/bin/telemt) for selecting the TeleMT binary.
  • Tests
    • Added MTProto unit tests and environment-gated integration tests for lifecycle, user sync, and metrics parsing.

@coderabbitai

coderabbitai Bot commented Jun 29, 2026 •

Copy link
Copy Markdown

Review Change Stack

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 97b39b09-7dec-4f37-b3a8-e981fa4a8a45

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Walkthrough

Adds an MTProto backend around the external telemt binary. The change set covers schema and config wiring, telemt process lifecycle management, API and metrics clients, traffic tracking, TOML rendering, controller integration, and supporting build and test updates.

Changes

MTProto Backend

Layer / File(s) Summary
Schema, config, and telemt install setup
common/service.proto, config/config.go, .env.example, Makefile, Dockerfile.mtproto
BackendType gains MTPROTO = 2; Mtproto and Proxy.mtproto are added; TelemtExecutablePath is loaded from TELEMT_EXECUTABLE_PATH with a default path, .env.example documents the setting, and telemt-specific build/install variables plus a multi-stage Docker build are added.
MTProto config parsing and TOML rendering
backend/mtproto/config.go, backend/mtproto/toml.go
Parses and validates MTProto JSON config input, extracts inbound_tag, renders managed API/access sections, filters common users into runtime MTProto users, and serializes the resulting map to TOML.
telemt process, API, metrics, and traffic accounting
backend/mtproto/process.go, backend/mtproto/process_unix.go, backend/mtproto/process_windows.go, backend/mtproto/client.go, backend/mtproto/metrics.go, backend/mtproto/tracker.go
Implements telemt process lifecycle management, the HTTP JSON API client, Prometheus scraping, and in-memory traffic accounting with reset and restart handling.
MTProto backend controller and runtime sync
backend/mtproto/mtproto.go, controller/controller.go
Constructs the MTProto backend, manages the telemt process and desired-user state, applies user sync and restart flows, and serves backend stats and online-state queries.
Unit and integration tests
backend/mtproto/mtproto_test.go, backend/mtproto/integration_test.go
Covers config parsing, user selection, rendering, traffic tracking, metrics parsing, latency output, backend lifecycle, and empty-user startup behavior.

Sequence Diagram(s)

sequenceDiagram
    participant Controller
    participant MTProto
    participant processManager
    participant apiClient
    participant metricsScraper

    Controller->>MTProto: New(ctx, cfg, mtConfig, users)
    MTProto->>processManager: newProcessManager(executable, config, pid)
    MTProto->>processManager: Start()
    MTProto->>apiClient: Health() via waitForHealth()
    MTProto-->>Controller: *MTProto

    Controller->>MTProto: SyncUser(ctx, user)
    MTProto->>apiClient: upsertUser / deleteUser

    Controller->>MTProto: SyncUsers(ctx, users)
    MTProto->>processManager: Reload() or Restart()
    MTProto->>apiClient: waitForRuntimeUsers()

    Controller->>MTProto: GetStats(ctx, request)
    MTProto->>metricsScraper: Scrape(ctx)
    metricsScraper-->>MTProto: per-user metrics
    MTProto->>MTProto: trafficTracker.Sync() and stat aggregation
Loading

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Poem

🐇 I hop through tunnels, bright and neat,
Telemt sings with busy feet.
TOML twirls, the stats run round,
New MTProto burrows in the ground.
When users come or users part,
This bunny grins with a coder’s heart.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 3.39% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: adding a new MTProto proxy backend powered by telemt.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 10

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@backend/mtproto/config.go`:
- Around line 62-67: The trailing JSON check in NewConfig is too permissive
because decoder.Decode(&extra) leaves extra nil for a trailing null, so multiple
JSON documents can slip through. Update the logic around the decoder.Decode
handling in config.go to explicitly detect any successful second decode,
including null, and reject it as a second JSON value rather than relying on
extra != nil. Keep the existing error path and use the NewConfig trailing-decode
flow to ensure only exactly one JSON object is accepted.

In `@backend/mtproto/integration_test.go`:
- Around line 91-93: The GetUser assertion in the integration test only checks
for any error, so it can pass on the wrong failure mode. Update the test around
mt.apiClient.GetUser to assert the returned error is apiStatusError.NotFound()
specifically, using the existing NotFound helper/matcher already used in this
package. Keep the check focused on the removed-user case rather than treating
any non-nil error as success.

In `@backend/mtproto/mtproto_test.go`:
- Around line 35-38: The mtproto_test.go failure check for NewConfig can panic
because it dereferences cfg inside the t.Fatalf path when cfg is nil. Update the
test around NewConfig and the custom inbound tag assertion so it first handles
err (and nil cfg) before referencing cfg.InboundTag, keeping the failure message
in the mtproto_test.go test case informative without risking a nil-pointer
panic.

In `@backend/mtproto/mtproto.go`:
- Around line 103-105: The startup failure path in MTProto construction
currently calls process shutdown directly and bypasses the cleanup logic in
MTProto.Shutdown(), which leaves log resources uncleared when New returns nil.
Update the failure handling in New to invoke mt.Shutdown() instead of
mt.process.Shutdown(), so the full teardown path runs, including CloseLogs() and
any other cleanup owned by MTProto.
- Around line 511-516: The removal convergence check in the mtproto transition
logic is only comparing `previousDesired` against `desired`, so the sentinel
account can be missed when moving from zero desired users to real users. Update
the `expectedAbsent`/convergence logic in `mtproto.go` to explicitly account for
the sentinel runtime user in addition to `previousDesired`, and make sure the
check in this flow rejects success until the sentinel is actually removed.
- Around line 625-634: The rendered telemt config is being written with
world-readable permissions, which exposes the API bearer token and MTProto
secrets. Update writeFileAtomic so the temporary file written by os.WriteFile
uses private permissions instead of 0644, and ensure the surrounding write path
in mtproto.go preserves those restricted permissions when the file is renamed.
Focus on the writeFileAtomic helper and its temp file creation.
- Around line 585-602: The sentinel user creation in newSentinelUser currently
falls back to predictable Username and Secret values when rand.Read fails, so
change it to fail closed by returning an error instead of constructing a
runtimeUser with deterministic bytes. Update New to handle the new error return
from newSentinelUser and propagate it to the caller rather than continuing
startup with known credentials. Make sure any call sites or tests that reference
newSentinelUser or New are adjusted to expect the error path.

In `@backend/mtproto/process.go`:
- Around line 135-136: The log channel is being closed while the stdout/stderr
scanner goroutines may still be sending, which can panic during normal shutdown.
Update the shutdown flow in process.go around process.Start/captureLogs and the
CloseLogs path so p.captureLogs(stdout) and p.captureLogs(stderr) finish before
p.logs is closed, or keep p.logs open for the full manager lifetime. Use the
existing captureLogs and CloseLogs symbols to locate the fix and ensure
process.Shutdown/MTProto.Shutdown cannot race with pending log sends.
- Around line 66-82: `detectVersion()` and `Reload()` currently wait on
`CombinedOutput()` without any deadline, so a stuck `telemt` process can block
startup or reload indefinitely. Update the command creation path in
`processManager` to use a context-aware command (for example via
`exec.CommandContext`) for both the version check and reload execution, and
apply a short timeout appropriate for these operations. Make sure the timeout is
enforced in both `detectVersion` and `Reload`, and return a distinct
timeout-related error so callers can distinguish it from generic command
failures.

In `@Dockerfile.mtproto`:
- Around line 21-31: The runtime stage in the Dockerfile currently leaves the
container running as root, so update the final stage to create and switch to a
dedicated non-root user before ENTRYPOINT. Keep the existing COPY steps for main
and telemt, then add ownership/permission setup as needed and switch user in the
runtime image so the container no longer executes as root; reference the final
stage around FROM alpine:latest, COPY, and ENTRYPOINT.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 48cc9f9f-74c5-4b6a-8547-e4a25311cfbe

📥 Commits

Reviewing files that changed from the base of the PR and between e0031a2 and caf7481.

⛔ Files ignored due to path filters (2)
  • common/service.pb.go is excluded by !**/*.pb.go
  • common/service_grpc.pb.go is excluded by !**/*.pb.go
📒 Files selected for processing (17)
  • .env.example
  • Dockerfile.mtproto
  • Makefile
  • backend/mtproto/client.go
  • backend/mtproto/config.go
  • backend/mtproto/integration_test.go
  • backend/mtproto/metrics.go
  • backend/mtproto/mtproto.go
  • backend/mtproto/mtproto_test.go
  • backend/mtproto/process.go
  • backend/mtproto/process_unix.go
  • backend/mtproto/process_windows.go
  • backend/mtproto/toml.go
  • backend/mtproto/tracker.go
  • common/service.proto
  • config/config.go
  • controller/controller.go

Comment thread backend/mtproto/config.go Resolved
Comment thread backend/mtproto/integration_test.go Resolved
Comment thread backend/mtproto/mtproto_test.go Resolved
Comment thread backend/mtproto/mtproto.go Resolved
Comment thread backend/mtproto/mtproto.go Resolved
Comment thread backend/mtproto/mtproto.go Resolved
Comment thread backend/mtproto/mtproto.go Resolved
Comment thread backend/mtproto/process.go Resolved
Comment thread backend/mtproto/process.go Resolved
Comment thread Dockerfile.mtproto
Comment on lines +21 to +31
FROM alpine:latest

LABEL org.opencontainers.image.source="https://github.com/PasarGuard/node"

RUN apk update && apk add --no-cache ca-certificates

WORKDIR /app
COPY --from=builder /src/main /app/main
COPY --from=builder /usr/local/bin/telemt /usr/local/bin/telemt

ENTRYPOINT ["./main"]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Drop root in the runtime stage.

The final image never switches away from root, so a compromise in either main or telemt gets full root privileges inside the container. Add a dedicated runtime user here; if you need privileged ports, grant only CAP_NET_BIND_SERVICE at deploy time instead of keeping the whole container root.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Dockerfile.mtproto` around lines 21 - 31, The runtime stage in the Dockerfile
currently leaves the container running as root, so update the final stage to
create and switch to a dedicated non-root user before ENTRYPOINT. Keep the
existing COPY steps for main and telemt, then add ownership/permission setup as
needed and switch user in the runtime image so the container no longer executes
as root; reference the final stage around FROM alpine:latest, COPY, and
ENTRYPOINT.

Source: Linters/SAST tools

@T3ST3ST3R0N
T3ST3ST3R0N force-pushed the feat/mtproto-telemt branch from caf7481 to fc734c2 Compare June 29, 2026 15:32

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@backend/mtproto/client.go`:
- Around line 72-79: `telemtPatchUserRequest` is omitting zero/empty fields, so
`UpdateUsers()` cannot clear existing telemt settings during a PATCH. Update the
request model and patch flow in `UpdateUsers()` to preserve explicit clears for
`UserAdTag`, `ExpirationRFC3339`, and the limit/quota fields, either by
switching those fields to pointers/nullable values or by adding a separate
clear-path so omitted values are not re-sent as old state.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 0b8b481a-cd91-45bf-96ea-cc5e38d17b49

📥 Commits

Reviewing files that changed from the base of the PR and between caf7481 and fc734c2.

⛔ Files ignored due to path filters (2)
  • common/service.pb.go is excluded by !**/*.pb.go
  • common/service_grpc.pb.go is excluded by !**/*.pb.go
📒 Files selected for processing (17)
  • .env.example
  • Dockerfile.mtproto
  • Makefile
  • backend/mtproto/client.go
  • backend/mtproto/config.go
  • backend/mtproto/integration_test.go
  • backend/mtproto/metrics.go
  • backend/mtproto/mtproto.go
  • backend/mtproto/mtproto_test.go
  • backend/mtproto/process.go
  • backend/mtproto/process_unix.go
  • backend/mtproto/process_windows.go
  • backend/mtproto/toml.go
  • backend/mtproto/tracker.go
  • common/service.proto
  • config/config.go
  • controller/controller.go
✅ Files skipped from review due to trivial changes (1)
  • .env.example
🚧 Files skipped from review as they are similar to previous changes (8)
  • config/config.go
  • backend/mtproto/process_windows.go
  • backend/mtproto/integration_test.go
  • common/service.proto
  • backend/mtproto/process_unix.go
  • controller/controller.go
  • backend/mtproto/tracker.go
  • Makefile

Comment thread backend/mtproto/client.go
Comment on lines +72 to +79
type telemtPatchUserRequest struct {
Secret string `json:"secret,omitempty"`
UserAdTag string `json:"user_ad_tag,omitempty"`
MaxTCPConns *int `json:"max_tcp_conns,omitempty"`
ExpirationRFC3339 string `json:"expiration_rfc3339,omitempty"`
DataQuotaBytes *uint64 `json:"data_quota_bytes,omitempty"`
MaxUniqueIPs *int `json:"max_unique_ips,omitempty"`
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== client.go around telemtPatchUserRequest ==\n'
sed -n '60,110p' backend/mtproto/client.go

printf '\n== buildPatchUserRequest ==\n'
sed -n '160,210p' backend/mtproto/client.go

printf '\n== mtproto.go around upsertUser ==\n'
sed -n '1,220p' backend/mtproto/mtproto.go

printf '\n== search for PatchUser / telemtPatchUserRequest usage ==\n'
rg -n "PatchUser|telemtPatchUserRequest|buildPatchUserRequest|UpdateUsers|upsertUser" backend/mtproto

Repository: PasarGuard/node

Length of output: 9240


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== runtimeUser definition and transforms ==\n'
rg -n "type runtimeUser|func .*runtimeUser|optionalPositive|ExpirationRFC3339|UserAdTag|MaxTCPConns|MaxUniqueIPs|DataQuotaBytes" backend/mtproto/client.go backend/mtproto/mtproto.go backend/mtproto/*.go

printf '\n== buildNextDesiredLocked / applyFullDesiredLocked / upsertUser ==\n'
sed -n '300,430p' backend/mtproto/mtproto.go

printf '\n== any clear/reset/delete calls in apiClient ==\n'
rg -n "CreateUser|PatchUser|DeleteUser|Reset|Clear|UpdateUser" backend/mtproto/*.go

Repository: PasarGuard/node

Length of output: 16123


🏁 Script executed:

#!/bin/bash
set -euo pipefail

sed -n '209,285p' backend/mtproto/config.go

Repository: PasarGuard/node

Length of output: 2549


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== search for telemt API docs / handlers ==\n'
rg -n "PatchUser|patch user|user_ad_tag|max_tcp_conns|expiration_rfc3339|data_quota_bytes|max_unique_ips|omitempty" -g '!**/*_test.go' .

printf '\n== list likely doc files ==\n'
git ls-files | rg '(^|/)(README|docs|openapi|swagger|api|telemt|mtproto).*'

Repository: PasarGuard/node

Length of output: 17128


PATCH drops explicit clears for runtime limits
telemtPatchUserRequest omits zero/empty values, so a partial update can’t clear an existing user_ad_tag, expiration_rfc3339, or limit/quota field on an already-created user. UpdateUsers() will PATCH the old telemt values back into place instead of removing them. Fix by sending explicit zero/empty values on patch, or by using pointer fields / a separate clear path.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/mtproto/client.go` around lines 72 - 79, `telemtPatchUserRequest` is
omitting zero/empty fields, so `UpdateUsers()` cannot clear existing telemt
settings during a PATCH. Update the request model and patch flow in
`UpdateUsers()` to preserve explicit clears for `UserAdTag`,
`ExpirationRFC3339`, and the limit/quota fields, either by switching those
fields to pointers/nullable values or by adding a separate clear-path so omitted
values are not re-sent as old state.

@T3ST3ST3R0N
T3ST3ST3R0N force-pushed the feat/mtproto-telemt branch from 39f65cc to 746cd2f Compare June 29, 2026 16:05
@T3ST3ST3R0N

Copy link
Copy Markdown
Collaborator Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jun 29, 2026 •

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@T3ST3ST3R0N
T3ST3ST3R0N changed the base branch from main to dev June 29, 2026 16:14

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@backend/mtproto/mtproto.go`:
- Line 645: The deferred temp-file cleanup in mtproto.go should explicitly
ignore the removal error to satisfy errcheck. Update the deferred
os.Remove(tempPath) cleanup in the affected mtproto logic to use a best-effort
wrapper that discards the returned error intentionally, keeping the cleanup
behavior while making the ignore explicit.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: f48cad09-0c52-4681-96c2-a3a30c5cd85b

📥 Commits

Reviewing files that changed from the base of the PR and between fc734c2 and 746cd2f.

📒 Files selected for processing (5)
  • backend/mtproto/config.go
  • backend/mtproto/integration_test.go
  • backend/mtproto/mtproto.go
  • backend/mtproto/mtproto_test.go
  • backend/mtproto/process.go
🚧 Files skipped from review as they are similar to previous changes (4)
  • backend/mtproto/integration_test.go
  • backend/mtproto/mtproto_test.go
  • backend/mtproto/config.go
  • backend/mtproto/process.go

Comment thread backend/mtproto/mtproto.go Resolved
@T3ST3ST3R0N

Copy link
Copy Markdown
Collaborator Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jun 29, 2026 •

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Adds BackendType.MTPROTO, a Mtproto proxy message (secret plus optional ad_tag, max_tcp_conns, expiration_rfc3339, data_quota_bytes, max_unique_ips), and Proxy.mtproto. Regenerated pb.go.
Runs the telemt binary as a node-managed subprocess (run/reload/version), renders the panel JSON config to telemt TOML, manages users over telemt's REST API, scrapes Prometheus metrics for per-user up/down traffic, and uses a sentinel user to satisfy telemt's non-empty access.users requirement. Includes unit and env-gated integration tests.
Adds the MTPROTO case to StartBackend and the TELEMT_EXECUTABLE_PATH config option.
Pinned telemt 3.4.19 install target with sha256 verification, an MTProto Docker image, and env documentation.
Wait for the stdout/stderr capture goroutines (WaitGroup + sync.Once) before closing the log channel, so a late non-blocking send cannot hit a closed channel during shutdown.
Use exec.CommandContext with a 30s deadline for the version probe and live reload, returning a distinct timeout error, so a stuck telemt cannot block startup or reload indefinitely.
The rendered telemt config contains the API bearer token and per-user secrets; create the directory 0700 and the file 0600 via CreateTemp instead of world-readable 0644.
newSentinelUser now returns an error instead of falling back to predictable credentials, and New propagates it. On startup-health failure New calls mt.Shutdown() (which closes the log channel) instead of only stopping the process.
When transitioning from zero desired users to real users the sentinel is dropped from the rendered config but is absent from previousDesired, so waitForRuntimeUsers now requires it to be gone before reporting convergence.
NewConfig now rejects a second JSON document, including a bare null (which the previous extra != nil check let through).
The removed-user lookup now asserts an apiStatusError NotFound rather than any error, and the custom-inbound-tag assertion no longer dereferences a possibly-nil config in the failure path.
Wrap the deferred os.Remove of the config temp file so the discarded error is intentional (satisfies errcheck).
Remove data_quota_bytes and expiration_rfc3339 from the Mtproto proxy message and from config rendering and the telemt API client. Quota and expiry are enforced by the panel (usage reporting plus user sync), the same as for xray and wireguard, so telemt must not enforce them. Real-time connection limits (max_tcp_conns, max_unique_ips) and user_ad_tag remain, since only telemt can enforce those live. The node still forbids the panel from setting user_data_quota/user_expirations in the config template.
@T3ST3ST3R0N
T3ST3ST3R0N force-pushed the feat/mtproto-telemt branch from 5916be2 to 098d828 Compare June 29, 2026 18:24
x0sina and others added 3 commits August 16, 2026 18:09
Keep MTPROTO backend support while picking up routing API and other dev changes. Regenerated common/service.pb.go from the merged proto so both BackendType.MTPROTO and the routing messages coexist.

Co-authored-by: Cursor <cursoragent@cursor.com>
Bump the pinned binary, send JSON null on PATCH so ad-tag and limits can be cleared, return empty stats when Prometheus is not bound yet, and disable the sentinel user so it cannot authenticate.

Co-authored-by: Cursor <cursoragent@cursor.com>
@T3ST3ST3R0N

Copy link
Copy Markdown
Collaborator Author

Superseded by #120, which ports this onto dev and includes the review fixes.

Free-Guy-IR added a commit to Free-Guy-IR/node that referenced this pull request Oct 30, 2026
…es r2-C found

scripts/upstream_drift_test.sh goes from 9 passed / 16 failed to 24
passed / 0 failed.

- D1 (HIGH, fail-open): the merge check piped `git show --cc` into
  `grep -q` under pipefail. grep exited on the first line, git died of
  SIGPIPE, and a merge with a large combined diff was skipped as having
  no changes of its own. The real upstream merge fbb0110 on pr-61
  (a 38,960-byte combined diff) was missed in 5 of 5 runs. Both diffs are
  now read in full, and a git failure stops the run. fbb0110 is now
  found in 5 of 5 runs.
- D3: a merge that drops one side's change has an empty --cc diff but a
  non-empty --remerge-diff, so both are checked. The script refuses to
  run on a git without --remerge-diff (2.36+).
- D2: a pending waiver now needs exactly one until:YYYY-MM-DD. The date
  must be a real calendar date, at most 30 days after today; a missing,
  second, spaced, unpadded or impossible date is a parse error (exit 2).
  An issue: reference is optional and must be issue:<owner>/<repo>#<n>;
  it no longer replaces the date, so no waiver is permanent.
- D4: patch headers are read from the captured patch text instead of
  piping git show into an early-exiting awk. The go.mod and Dockerfile
  ARG readers no longer exit early either.
- D5: an Upstream line in a patch header counts only when the Makefile
  applies patches/xray-*.patch and the Dockerfile at the checked commit
  runs git apply on that file. It counts only for the xray component;
  node, sing-box, sing-quic and mtg are ported by commit trailers only.
- D6: an ours-merge that an upstream ref also contains was invisible to
  the fork-entry check. Every merge in the target history that no
  upstream tag contains, and whose recorded result differs from git's
  automatic merge (--remerge-diff), must now be allowlisted as
  allowed-merge.
- D7: advisory paging used &page=N, which both endpoints ignore, so 100
  or more advisories looped until the job timed out. It now follows the
  rel="next" Link header of the last response. Only links under the API
  URL are accepted, and it stops with an error after
  DRIFT_MAX_API_PAGES pages (default 50).

Ledger entries the new checks need:
- two allowed-merge entries, both pre-fork-point merges whose only
  rewrite is an ordinary conflict resolution: bd8ceea (Merge branch
  'dev', backend/xray/api/stats.go) and 61932a4 (Merge branch 'main' into
  dev, controller/rest and controller/rpc stats.go);
- a rejected waiver for fbb0110, the PR PasarGuard#61 telemt MTProto merge on
  pr-61, which collides with the fork's MTProto enum like the other PR
  PasarGuard#61 commits.
Free-Guy-IR added a commit to Free-Guy-IR/node that referenced this pull request Oct 30, 2026
…NPORTED

A rejected or not-applicable waiver for work that lives only on a
pull-request or debug branch now carries a branch:<ref> token. The node
commit and node merge checks grant WAIVED only while every upstream ref
that contains the commit is one of the named branches. Once the commit
is reachable from any other ref, a release tag or a mainline branch
after a merge, the item turns UNPORTED with a message naming the refs
that now reach it.

The waiver parser rejects a malformed branch: token, and it refuses the
whole file when a waiver says the upstream work is unmerged ("not
merged", "never merged", "revisit if upstream merges") without a
branch: scope, so the pattern cannot recur unscoped.

The sixteen PR PasarGuard#61 waivers gain branch:pr-61 and the two debug-branch
waivers gain branch:debug.
T3ST3ST3R0N added a commit that referenced this pull request Nov 4, 2026
* feat(proto): add MTPROTO backend type and Masque and Mtproto proxy messages

Add BackendType MTPROTO = 2, the Masque message (matching the released
Python bridge 0.9.3) and the Mtproto user message, exposed on Proxy as
masque = 7 and mtproto = 8. Regenerate the Go code.

* feat(mtproto): add telemt MTProto backend

Port node PR #61 (feat/mtproto-telemt, head 4eb26fb) onto dev: the
backend/mtproto package that drives telemt as a child process, the
MTPROTO case in Controller.StartBackend, TELEMT_EXECUTABLE_PATH
(default /usr/local/bin/telemt) in config and .env.example, and the
install_telemt Makefile target (TELEMT_VERSION still 3.4.25).

Ported PR commits: 8542f27, 90c50a1, 419732c, 9f9eac2, a87168d,
77c62fd, 630df12, 0eae051, ef43229, cd183a4, a66a322, fa79e3b,
098d828, 2c4fce8, 4eb26fb. The proto part of 8542f27 landed in the
previous commit. Dockerfile.mtproto (9f9eac2, 4eb26fb) is not ported.

Adapt the backend to dev's Backend interface: the process manager now
keeps its output in a logstream.Buffer, the capture goroutines Publish
into it, MTProto.SubscribeLogs returns a Subscribe channel, and
Shutdown closes the buffer. This replaces Logs(), CloseLogs and the log
WaitGroup, so a late log line can no longer be sent on a closed
channel. Add a test that runs a stand-in process and checks that its
stdout and stderr reach a subscriber and that shutdown closes the
subscription cleanly.

Co-authored-by: x0sina <bulletsina77@gmail.com>

* fix(mtproto): harden rendered telemt config and skip invalid users

Config:
- Reject panel JSON that sets node-managed or host-reaching telemt keys:
  include, web, server.api, server.admin_api, server.metrics_port,
  server.metrics_listen, server.metrics_whitelist,
  access.user_rate_limits and access.user_source_deny (next to the
  existing restricted per-user access maps).
- Validate object sections and ports (1-65535, integers) and add
  Config.ListenPorts (server.port, default 443, plus listener ports).
- Render forces general.disable_colors, general.telemetry.user_enabled
  and server.api.read_only=false, and fails when a listen port equals
  the node's telemt API or metrics port.

TOML:
- Render strings and keys as TOML basic strings (\uXXXX for control
  characters) instead of strconv.Quote, whose \a, \v and \x escapes
  are not TOML.
- Drop null object fields; a null array element stays an error.
- Always quote a key named include: telemt expands any
  `include = "..."` line as a file include before parsing.

Users:
- ActiveUsersFromCommon logs and skips invalid users and returns their
  emails, so one bad user no longer fails Start or a full sync. Partial
  updates treat an invalid user as not included. SyncUser still
  returns the validation error for its single user.

Tests use github.com/pelletier/go-toml v1.9.5 (already in go.sum) as an
independent TOML decoder; it is now a direct requirement.

* fix(mtproto): force stderr logging and reject web listeners

- Render forces logging.destination = "stderr": the node reads telemt's
  stderr for its log stream and startup errors, and a panel config with
  destination "file" or "syslog" would leave both empty. logging must
  be an object when present.
- NewConfig rejects a server.listeners[].transport other than
  "mtproxy". telemt WEB listeners need [[web.vhosts]], which the node
  rejects, so they would only make telemt exit at startup.

* fix(mtproto): supervise telemt 3.5.14 safely across start, crash and shutdown

Process manager (process.go):
- Refuse telemt older than 3.5.14 (parsed from `telemt X.Y.Z`).
- Run `telemt run <config>` with no --pid-file, so a stale pid file can
  no longer block startup; reload sends SIGHUP to the child (Windows
  returns an error and the caller restarts).
- Started() checks a waitDone channel and never reads ProcessState
  (data race under -race). Stop marks the run as stopping; any other exit
  is logged and published as "telemt exited unexpectedly: <err>", with an
  OOM hint on SIGKILL.
- Read stdout and stderr to EOF before Wait so a crash's last lines are
  kept; overlong lines are cut instead of stalling the reader.
- Keep the last 50 lines since Start for startup errors (StartupTail).
- Kill an orphaned telemt whose command line runs exactly this config
  path before starting a new one.
- Add Exited(), Generation(), Close(); Start after Close fails.

Lifecycle (lifecycle.go):
- New picks the API and metrics ports again while they clash with each
  other or with the config's listen ports, creates <generated>/telemt
  with mode 0700 and resolves symlinks (telemt 3.5.8+ rejects a
  symlinked config path).
- Wait up to 90 s for /v1/health (telemt binds its API only after the
  Telegram DC check), then fail with telemt's recent output instead of a
  bare exit status. /v1/health/ready gets a 30 s budget and only logs a
  warning.
- A supervisor restarts telemt after an unexpected exit with backoff
  from 1 s doubling to 60 s, re-rendering the desired users.
- syncMu serializes every mutating call, the supervisor and Shutdown.
  Shutdown sets closed, stops telemt, closes the log buffer and deletes
  config.toml; later syncs return errClosed instead of starting a new
  telemt and crashing the node on a closed log channel.

Also bump TELEMT_VERSION to 3.5.14 and give the integration tests a
150 s context. Unit tests drive a fake telemt (the test binary re-run as
a helper process) for slow APIs, crashes, orphans and startup failures.

* fix(mtproto): detach telemt startup from request deadlines

- New starts telemt on context.WithoutCancel(ctx) bounded by
  telemtStartupTimeout, so a telemt that binds its API after the panel's
  10 s Start deadline still comes up and the panel's retry attaches to
  it. Once spawned, startLocked waits for health on a detached ctx too;
  only the startup timeout and Shutdown (errClosed) end that wait.
- The /v1/health/ready probe moved to a background watcher bound to the
  backend lifetime and to that telemt's exit: it warns after 30 s and
  logs when telemt becomes ready later. Start and every restart return
  right after health instead of holding syncMu and the panel deadline.
- A sync whose ctx is already done (it queued on syncMu past its
  deadline) returns ctx.Err() before touching the config; reloadLocked
  never restarts telemt when the caller gave up, and startLocked spawns
  nothing for a done ctx or after Shutdown.
- The supervisor and readiness watchers share one WaitGroup that
  Shutdown waits on.

* fix(mtproto): apply user changes without per-user reads and confirm reloads

User changes are now decided from the confirmed desired state, never with
a GET per user. Unchanged users are skipped; up to 32 changes go through
telemt's user API (PATCH falls back to POST on 404, POST to PATCH on 409
user_exists, DELETE of a missing user is done). Larger batches, changes to
or from zero users, a failed partial apply and every full sync render the
config once and reload it through POST /v1/system/reload (instant,
rollback), polling the reload status for the outcome and retrying while
another reload is in progress.

desired changes only after telemt confirmed the new users. A rolled_back,
failed or refused reload re-renders the previous users to disk and returns
telemt's error. A reload whose caller gave up leaves telemt running and
forces the next sync to apply all users. Only a reload request that cannot
reach telemt restarts it.

The online IP list uses one GET /v1/stats/users/active-ips call. The SIGHUP
reload and waitForRuntimeUsers are removed, and DELETE responses (a JSON
string) decode again instead of failing into the full-render fallback.

* fix(mtproto): confirm hot reloads with a marker user instead of the reload API

telemt's API reload swaps the runtime generation and, in instant mode,
stops every session of the old one. A hot reload (SIGHUP, or telemt's
config file watcher) updates the users live and cancels only changed or
removed ones, so full applies go back to it.

Every render now carries one disabled marker user with a fresh random
name (pgm_<16 hex>). After writing the config the node sends SIGHUP (on
Windows it relies on telemt's file watcher) and polls GET /v1/users until
the marker is in telemt's running config, for up to 30 s, detached from
the caller and ended by Shutdown. A config telemt did not load by then is
rejected: the previous users are rendered again with a new marker and the
error carries telemt's last output. A caller that gives up leaves telemt
running and forces the next sync to apply all users; only an API that
gets no answer restarts telemt.

The marker replaces the zero-user sentinel and keeps telemt's user list
non-empty, so the last real user is deleted, and the first one created,
through the user API. The reload API client code is removed.

* fix(mtproto): settle hot reloads independently of callers and clear stale overrides

telemt stages an admission override for every user API write and clears
it only when a published config carries the same value. A full render that
changed such a user again before telemt published the API's own write left
the user refused while the node reported success. The node now records
users written through the API since the last confirmed render or telemt
start, and before a render writes again each one whose rendered state
differs, so the render's publish clears the override. A write that fails
stays unknown and keeps the state unconfirmed.

A full apply now runs to telemt's verdict in the background under syncMu,
which a context-aware lock lets callers wait for only until their own
deadline. A caller that gives up gets its ctx error at once; the apply
still confirms, or restores the previous users, and logs the outcome. A
rejection goes to telemt's log stream, which the panel shows. A 5xx
"failed to load config" from GET /v1/users is an immediate rejection
with telemt's message.

telemt is restarted only after 3 polls in a row get no answer or when it
exited, not on one failed request. Reload code moves to reload.go and the
per-user API writes to userapi.go.

* fix(mtproto): account traffic per stat family without double counting

- Give users, outbound and inbound stats their own baseline per user, so
  the panel's 10 s user poll and 30 s outbound poll no longer consume each
  other's traffic.
- Track every user telemt reports, desired or not. A user missing from a
  snapshot keeps its counters, so a removed and re-added user is not billed
  again and its last interval is still reported. Absent users are dropped
  after 25 h (telemt forgets idle stats after 24 h), keeping the outbound
  and inbound traffic still pending.
- Count from zero after a telemt restart (process generation) or a counter
  decrease, carrying the pending delta of every family.
- Serialize refreshes with refreshMu and share a snapshot younger than 1 s,
  so concurrent polls never apply an older scrape after a newer one. A
  scrape that overlaps a telemt restart is discarded.
- Read telemt 3.5.14's metric names, filter by name before parsing labels,
  and switch to GET /v1/users totals (reported as downlink) while
  telemt_telemetry_user_series_suppressed is set; a source switch reseeds
  without double counting.
- Report distinct client IPs as the online count, drop marker users from
  every result, and log failed refreshes at most once a minute.
- Move the stats endpoints into stats.go.

* fix(mtproto): carry exact traffic across a stats source switch

The reseed on a switch between per-user metrics and API totals dropped
the bytes telemt counted between the last sample of one source and the
first of the other, in every family. Both sources count the same telemt
stats entry (total_octets is octets_from_client + octets_to_client), so
within one telemt process the increment is known exactly: credit it as
downlink. A total below the last sample means telemt dropped the entry
in between, which counts from zero like a restart.

The suppression trigger also covers more than 4096 configured users;
say so in the comments and the switch log line.

* build(mtproto): ship pinned telemt 3.5.14 in the node image

- Dockerfile: the builder runs make install_telemt after install_xray, and
  the final stage carries /usr/local/bin/telemt plus the telemt license
  files in /usr/share/licenses/telemt/.
- Makefile: install_telemt checks the tarball against our own SHA256 per
  arch (x86_64, aarch64) instead of the release's .sha256 file, refuses
  "latest", and needs TELEMT_SHA256 for any version other than the pinned
  one. TELEMT_INSTALL_DIR selects the target dir; sudo is used only when
  that dir is not writable. --version runs only for a host-arch binary.
- CI: install telemt and point MTPROTO_TELEMT_BIN at it, so the gated
  MTProto integration tests run.
- third_party/telemt: telemt 3.5.14 LICENSE and the canonical English
  license text, copied verbatim, with a note on the unmodified binary.
- README: MTProto backend, Xray and MTProto on one host, third-party note.

* fix(mtproto): read telemt 3.5.14's answer to a user delete

telemt 3.5.14 answers DELETE /v1/users/{name} with an object,
{"username": ..., "in_runtime": ...} (status 202 while the running config
still has the user), not with the username as a JSON string. The node
failed to decode every successful delete, logged the batch as failed and
fell back to a full render and hot reload for each user removal.

The fake telemt API now answers deletes like telemt 3.5.14, and a client
test pins the answer. Found by the live tests against telemt 3.5.14.

* fix(mtproto): reject a render telemt cannot read without waiting 30 s

telemt reads the config file and its includes before it parses them, and
answers GET /v1/users with 500 "failed to read config graph: ..." when that
read fails, for example on a rendered file above its 8 MiB limit per
config file. The node only took "failed to load config" (a parse error) as
a rejection, so a read failure kept the sync waiting the whole
markerConfirmTimeout under the sync lock before the restore.

Both answers now reject the render at once with telemt's message. Found by
the live tests against telemt 3.5.14.

* fix(mtproto): do not SIGHUP telemt before it handles the signal

telemt installs its SIGHUP handler in its config watcher, which waits for
the runtime to activate after the API is up, reads the whole config and
sets up its file watches first. A SIGHUP in that window ends telemt with
the default action. The node assumed the opposite order, so a sync right
after a start (or a restore after a quick rejection) could kill telemt:
the reload then found the API gone, restarted telemt and dropped every
session.

For telemtSignalGrace (5 s) after telemt's API first answers, the node now
sends no SIGHUP. The watcher loads most renders of that time by itself,
but misses one written while it sets up its watches, so a reload whose
marker telemt has not loaded when the grace ends gets its SIGHUP then. The
fake telemt can now play the watcher, including that blind spot, and a
late SIGHUP handler. Found by the live tests against telemt 3.5.14, where
nine telemts started at once made the window wide.

* test(mtproto): cover telemt 3.5.14 lifecycle, reload and removal end to end

The integration tests (run when MTPROTO_TELEMT_BIN names a telemt binary)
now check what telemt does, not only what its config file says. A fake
MTProto client performs the obfuscated2 handshake with a user's secret,
and telemt's per-user connection gauge tells whether it admitted the
client. telemt's dc_overrides point the client's DC at a local listener,
so sessions stay open without Telegram.

Covered live: start, version 3.5.14, health and stats, then Shutdown
removing config.toml and leaving no telemt process; user create, secret
change and delete through the user API; full syncs down to the marker
alone and back on the same process, with the watcher and with SIGHUP;
unchanged users keeping their sessions across a hot reload while removed
users lose theirs; API deletes cutting sessions, including the last real
user; a start without users; a secret written through the API and changed
by a render before telemt published it; a symlinked generated path; and a
render above telemt's 8 MiB limit, rejected at once with telemt's reason
while the previous users and their sessions stay.

The tests run in parallel, each with its own telemt.

* refactor(mtproto): keep mtproto.go to the backend struct and drop dead code

mtproto.go now holds the MTProto struct and its one-line accessors. The
helpers it collected move next to their callers: Restart and the API token
to lifecycle.go, the marker user to reload.go, the atomic file write and
user map clone to sync.go. markerUsername, read only by tests, moves to the
tests, and the unused config field goes.

Also removes the never-built tomlLiteral type, the always-nil error of
classifyTOMLSection, and decoded telemt API fields the node never reads.
No behavior change.

* chore(mtproto): fix the Windows test build and document telemt limits

- Build integration_test.go only off Windows: it uses waitFor from the
  Unix-only fake telemt helpers, so GOOS=windows go vet failed.
- Move the TestUsersAPIUnreachableRestartsTelemt doc comment back above
  its test.
- README: Start can take up to 90 s and the panel's retry attaches; above
  4096 users telemt stops per-user series and the node bills API totals
  as downlink; telemt refuses configs above 8 MiB (about 150k to 190k
  users).

* fix(mtproto): send the first SIGHUP once telemt handles it

telemt's config watcher installs its SIGHUP handler at the end of its own
setup, which runs concurrently with the API coming up and can finish after
the API first answers; a SIGHUP before that ends telemt, and once installed
the handler stays for the process lifetime. On live telemt 3.5.14 the
handler appeared about 3 ms after the API first answered, but a slow STUN
probe, TLS front or large config can stretch that past any fixed grace.

On Linux the node now sends SIGHUP only once bit 0 of the SigCgt mask in
/proc/<pid>/status is set, checked on each marker poll and before the
restore render's signal. The 5 s grace after the first API answer stays as
the fallback on other platforms or when /proc cannot be read.

The fake telemt now models the mask too: with a signal delay it resets
SIGHUP to its default action until signal.Notify, since the Go runtime
otherwise catches SIGHUP from the start. The grace test forces the
fallback, a new Linux test leaves only the mask to let the signal through,
and the live tests wait for the handler instead of the clock.

* fix(mtproto): restart telemt whose API hangs during a reload

A telemt whose API accepts connections but never answers was diagnosed as
a rejected reload: each users poll ran into the 10 s client timeout, so
the 30 s marker budget ran out before three unanswered polls, the node
restored the previous render and reported a rejection, and nothing
restarted telemt.

Each marker poll now gets its own timeout, a quarter of the budget
(7.5 s), so three hung polls in a row reach the existing restart branch
at about 22.5 s. A shorter timeout would misread a large node: telemt
reads its whole config for each poll, and listing 150k users took about
2.5 s on live telemt 3.5.14.

* test(mtproto): cover Shutdown during a full apply in the marker wait

A full apply blocked waiting for telemt to load its render must not hold
Shutdown for the 30 s marker budget. The test starts a sync against a fake
telemt that never loads the render, waits until the marker wait polls
GET /v1/users (the fake now logs those requests), and calls Shutdown:
Shutdown returns promptly, the apply ends with errClosed, needsFullApply
is set on the closed backend, desired keeps the confirmed users and
telemt is not restarted.

* test(controller): cover a slow MTProto Start outliving its caller

Dev now runs every core start under a context that keeps the request's
values but not its cancellation (#116), and a same-config Start is a
heartbeat only when the running backend passes GetSysStats (#117).
The panel relies on both for MTProto: telemt may need up to 90 s to
bind its API, the panel gives up on Start after 10 s, and its retry
must attach to the telemt that came up instead of restarting it.

Run the controller against a fake telemt (the test binary re-executed
through a wrapper script, so the node starts and reaps a real child)
that binds its API after a delay: a Start whose caller already went
away ends with telemt running, telemt keeps running after the start
context ends, and a retry that waits behind the start attaches to the
same backend.

* fix(mtproto): let Disconnect abort a telemt start in progress

New detached the telemt start from its context (WithoutCancel plus its
own 90 s bound) so a slow start would outlive the panel's 10 s Start
request. Dev's controller now does that for every backend (#116): it
runs StartOrAttach under a context without the request's cancellation
and cancels it only from Disconnect, which process shutdown calls
without LockControl, so it does not wait for a start in progress.

The second detachment in New and startLocked ignored that abort. A
Disconnect during a telemt start that never binds its API (Telegram
unreachable) blocked on the controller mutex for the whole 90 s
telemtStartupTimeout, and the telemt being started kept running until
then.

New and startLocked now follow their context; waitForHealth still
bounds the wait by telemtStartupTimeout and reports Shutdown as
errClosed. Every other start already passes a context the caller
cannot cancel: Restart and the supervisor the backend lifetime, a full
apply its detached apply context, the restart after a hung reload a
WithoutCancel context. The panel sees no change: the Start outlives
its request through the controller, as the controller tests show.

* test(controller): restart telemt when an MTProto Start changes the config

Dev's StartOrAttach now offers a changed config to a running backend
that implements backend.LiveConfigApplier before it restarts it (#105).
Only xray does: telemt has no live path for its core config, so a
Start with another MTProto config must still stop the old telemt and
start a new one on the new config. Cover that against the fake telemt,
and fail loudly if MTProto ever gains the interface without a review
of what a live apply means for telemt.

* test: pin the node answers the panel relies on for MTProto

The panel reads three answers of the node as fixed:
- "invalid backend type" from a node without the MTProto backend, which
  it turns into an upgrade message;
- "the users are still being applied" and "context deadline exceeded"
  from a sync that is still applying, which it retries later;
- a retry or repair Start that attaches to a running telemt, which since
  #117 needs GetSysStats to answer within the 3 s heartbeat budget.

Assert the exact texts, check that the unknown backend type error stays
unwrapped under the detached start (also when the request is gone), and
check that GetSysStats answers while telemt is frozen (SIGSTOP, so its
API cannot answer) and syncMu is held: it stays a local read of the
process, so a stuck API or a full apply never turns a heartbeat into a
restart.

* fix(mtproto): mark sync errors of a busy or restarting telemt as retry-later

Bridge 0.9.4 delivers user deltas through SyncUsersChunked and treats
an INTERNAL "failed to update users" answer (REST: 500) as a rejection
of the users it sent: it splits the batch in halves, sends again and
holds each user it isolates with a backoff of 2 s up to 60 s (an add
every 300 s after 10 minutes). MTProto answered that way for failures
that have nothing to do with the users: the backend shut down while a
request held it, telemt that does not start for a full apply, a reload
whose restart failed, a caller that gave up while it queued behind an
apply. A busy or restarting telemt therefore multiplied the requests
and delayed every user of the batch.

Add backend.RetryLaterError, a marker that keeps the error and its
text, and wrap every MTProto sync failure in it except a render telemt
read and refused (errReloadRejected): the users sent may have caused
that one, so splitting the batch can find them. An invalid user synced
alone stays a plain error too. The texts the panel matches ("the users
are still being applied", "context deadline exceeded") are unchanged.
Xray and WireGuard never return the marker.

* fix(controller): answer retry-later sync errors as unavailable

A sync error a backend marked retry-later (only MTProto does: telemt is
busy with an earlier apply, restarting or shut down) now reaches the
client as gRPC Unavailable or HTTP 503 instead of INTERNAL (SyncUser,
SyncUsersChunked), Unknown (SyncUsers) or 500 (REST), with the same
text. Bridge 0.9.4 retries such an answer as a whole batch with its
1 s to 30 s backoff, the same path a timed-out sync already takes,
instead of splitting the batch and holding each user it isolates.
The texts the panel matches ("the users are still being applied",
"context deadline exceeded") stay in the answer, so the panel's manual
sync keeps reporting a still-applying MTProto node as retry-later.

Every other error keeps its code, so Xray and WireGuard answers do not
change. Tests: the mapping in both services, and the rpc and rest
handlers end to end against an MTProto backend on the fake telemt whose
full apply is still waiting for telemt when the caller's deadline
passes (the fake now catches SIGHUP like telemt).

* refactor(mtproto): render the telemt config with go-toml/v2

Replace the hand-written TOML encoder with github.com/pelletier/go-toml/v2,
as asked in review. The test-only go-toml v1 dependency goes away.

- JSON numbers from the panel config are converted to int64 or float64
  first: the encoder writes a json.Number as a string. An integer outside
  the TOML 64-bit range is an error naming the field.
- JSON nulls are still dropped from tables; a null array element is an
  error that names its path.
- telemt expands any line "include = <path>" as a file include before it
  parses TOML, in every table. The old encoder quoted such keys; go-toml
  writes them bare, so the rendered text is now checked with telemt's own
  line matcher and refused, and the username "include" is skipped like
  any other invalid user.
- Test helpers and the fake telemts decode the config with go-toml
  instead of parsing double-quoted lines by hand.

* refactor(mtproto): use omitzero for the telemt user requests

The create request now sends the limits as plain uint32 with omitzero
instead of *int with omitempty, so the pointer and the int range
conversion go away; a zero limit is still left out. The patch request
keeps pointers without omitzero on the clearable fields: JSON Merge Patch
needs nil sent as null to clear an override.

* fix(mtproto): reject include keys at any depth of the panel config

A key named include in any table becomes a telemt file include, and the
render already refuses it; NewConfig now rejects it too, naming the path,
so the panel gets the error up front instead of a failed Start.

Also from review:
- the TOML conversion refuses container types it does not walk, since
  they could hide a json.Number that would render as a string
- a golden test pins one full render, so a go-toml upgrade that changes
  the output shows up
- table test for checkNoIncludeLines against telemt's matcher, signed
  zero, and the request JSON without a secret
- parseConfigUsers fails loudly when a config does not decode

* feat(mtproto): accept telemt WEB listeners and vhosts behind a private front

Accept the web section and listeners with transport "web" on a loopback or
private address and their own port. Vhosts may only use an http_upstream decoy
on a loopback or private IP literal, must not define profiles, and may carry a
user_profile template that the node keeps per vhost and strips from the config.
web.debug and web.limits.max_profiles stay node managed.

* feat(mtproto): render one telemt WEB profile per user

* fix(mtproto): apply user changes by render when WEB profiles are in use

* fix(mtproto): render web.limits.max_profiles within telemt's memory envelope

telemt 3.5.14 reserves 64 bytes of its WEB memory envelope per
max_profiles entry, and its default envelope (1280 MiB) has only about
4.1 MiB left after its other default reservations. With 262144 profiles
(16 MiB) every WEB config failed at start with "web.limits memory
reservations must fit memory_envelope_bytes within 4 GiB". Render 65536
(4 MiB), which telemt accepts with its default limits.

A render with more profiles than that (users times vhosts) is rejected
by telemt and the node restores the previous users, as for any rejected
render.

* test(mtproto): cover WEB decoy, profiles and empty start against telemt 3.5.14

startLiveTelemtWithConfig lets a live test pass its own panel config;
startLiveTelemt keeps today's config through it.

TestIntegrationWebDecoyAndProfiles runs a native and a WEB listener on
127.0.0.1 with one vhost and a local decoy: a plain request for the
vhost reaches the decoy, GET /v1/config lists a profile per user and
follows a full sync, and a native session stays open across that sync.

TestIntegrationWebStartWithoutUsers starts with no real users: telemt
accepts the disabled marker user's profile, so no fallback is needed.

* test(mtproto): check WEB profiles in telemt's running config

GET /v1/config reads telemt's config file on disk, so it showed only
that the node rendered the profiles. The WEB tests now also send the
bridge request Telegram Desktop sends for a dd link (capability
HMAC-SHA256 over the context and host, keyed by the client secret) and
check telemt's answer: the bridge page for a user with a running
profile, the decoy once a sync removed it, and telemt's private 404 for
the disabled marker, whose profile telemt runs but serves no bridge to.
The /v1/config check stays, named as a check of the rendered file.

* docs(mtproto): explain WEB mode and an example HTTPS front

* docs(mtproto): add tls_domain to the WEB example config

* fix(mtproto): match telemt's decoy origin rule and keep decoys off telemt's own ports

A WEB decoy upstream must now be a bare http origin (no https, path,
query, fragment, credentials or port 0), as telemt's parse_origin
requires, so the node refuses it before telemt fails at Start. Render
also refuses a loopback decoy on the node's telemt API or metrics port,
which would publish that endpoint on the WEB domain.

Also fix the max_profiles rejection reason and the UpdateUsers comment,
and say in the README that web_trusted_proxy_cidrs is required and
where telemt's per-profile limits stop.

* fix(mtproto): check WEB profile totals and base paths before telemt does

telemt counts WEB profiles across all vhosts and refuses a config above
web.limits.max_profiles, and it refuses a vhost base_path that is not empty
or slash-separated [A-Za-z0-9][A-Za-z0-9_-]* segments of at most 128 ASCII
bytes. Render now refuses more than 65536 profiles before writing the file,
naming the user and vhost counts, and NewConfig applies telemt's base_path
rule. The README notes that `http2 on;` needs nginx 1.25.1 or newer.

---------

Co-authored-by: x0sina <bulletsina77@gmail.com>
Free-Guy-IR added a commit to Free-Guy-IR/node that referenced this pull request Nov 10, 2026
The owner ordered the Telegram MTProto proxy removed from the fork; its
users lose the proxy from their configs. Production was already cleared
before this change (hosts 140/142/141, node 69, core 10).

backend/mtproto and controller/mtproto_shutdown_stream_race_test.go go
entirely, 52 files and 9740 lines, middleproxy among them: no instance of
core 10 ever carried an ad_tag, so the per-user middleproxy path was dead
code in production.

BackendType MTPROTO = 4 and Proxy mtproto = 9 are reserved by number and
by name instead of renumbered, so nothing reuses them and a node still
running the previous image stays wire-compatible during the rollout.
SING_BOX = 2, Proxy.hysteria2 = 7 and open_vpn = 8 do not move.

The controller tests that used MTProto as the only backend needing no
external binary keep their assertions: the multi-backend and lifecycle
tests move to OPEN_VPN, the two Start interleaving tests build their core
through the c.newBackend seam already used in this package, and the REST
multi-backend tests move to SING_BOX. The race step now passes
SINGBOX_SMOKE_BINARY to ./controller/..., which the job already builds, so
TestREST_AddRemoveSingBoxBackend and TestREST_StatsStillWorkWithTwoBackends
run there instead of skipping as the sing-box one silently did.

Nothing imports mtg any more, so go mod tidy drops the require, the
Free-Guy-IR/mtg replace and five transitive dependencies, and the drift
check loses its mtg component and the pin it read from go.mod. The upstream
waivers for PR PasarGuard#61 and PasarGuard#120 keep refusing them, now because upstream's
MTPROTO = 2 and mtproto = 8 are the fork's SING_BOX = 2 and open_vpn = 8,
not because the fork runs a competing backend.

Rebuild guide: work/r11/MED/mtproto.med.

NodeVersion goes 0.6.22 to 0.6.23, one patch step from the last value the
fork used, because BaseInfoResponse.supported_backends loses "mtproto" and a
panel reads that list to decide what a node can run. The three upstream-waiver
rows that cite the constant are updated with it.

The controller race step points SINGBOX_EXECUTABLE_PATH, not
SINGBOX_SMOKE_BINARY, at the sing-box the job builds: requireSingBoxBinary
only decided whether to skip, while the backend forks the path
config.SingBoxExecutablePath holds, which no CI step installs. With only
SINGBOX_SMOKE_BINARY set, TestREST_AddRemoveSingBoxBackend and
TestREST_StatsStillWorkWithTwoBackends stop skipping and fail on
"fork/exec /usr/local/bin/sing-box: no such file or directory"; both pass
against the pinned v1.13.21 build with SINGBOX_EXECUTABLE_PATH set. The
helper now reads that same path, and the three race-step mutations of
scripts/test_workflow_policy.sh, which still matched the single-line step,
are rewritten for the split step and joined by two more, for the backend
race run and for the sing-box path.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants