feat(mtproto): adopt dolonet/mtg-multi and make MTProto inbounds multi-client

Replace the upstream 9seconds/mtg sidecar with the dolonet/mtg-multi fork so a single MTProto inbound can serve many per-user secrets. Each panel client is now one named FakeTLS secret in the fork's [secrets] section: clients are first-class (attach/detach, limits, expiry, per-client tg:// links) exactly like every other protocol, mirroring the WireGuard multi-client model. Per-client traffic and online status come from the fork's /stats JSON API (its Prometheus output has no per-user label), fed into the existing email-keyed client_traffics accumulator; an optional throttle caps concurrent connections. A one-time seeder converts each legacy single-secret inbound into a one-client inbound.

The fork ships only linux/darwin amd64/arm64 binaries but is pure Go, so provisioning builds it from source for every supported platform (release.yml, DockerInit.sh) while keeping the panel-expected mtg-<os>-<arch> filename and the 'run' verb, so process.go is untouched. Also fixes a pre-existing update.sh gap that never renamed the mtg binary for armv6/armv7 updates.
This commit is contained in:
MHSanaei
2026-07-06 16:04:32 +02:00
parent 5e9606aa4d
commit d97bd8643e
54 changed files with 1160 additions and 453 deletions
+54 -88
View File
@@ -1,99 +1,65 @@
package mtproto
import (
"io"
"net/http"
"net/http/httptest"
"net/url"
"strconv"
"testing"
)
// TestParseMetricLineBraceBoundary pins the contract of the brace-position
// guard in parseMetricLine (manager.go:425 -> `if end < brace`).
//
// Once a '{' is found at index `brace`, the matching '}' must appear AFTER it.
// A '}' that precedes the '{', or a '{' with no closing '}' at all
// (strings.IndexByte returns -1, which is < brace), is a malformed line and
// must yield an error rather than slicing past the brace.
func TestParseMetricLineBraceBoundary(t *testing.T) {
t.Run("closing brace before opening brace is malformed", func(t *testing.T) {
// '}' at index 8 comes before '{' at index 16: end < brace must hold,
// so this is rejected. Mutating `<` to `>`/`>=` would accept it.
_, _, _, err := parseMetricLine(`mtg_x_a}_b{direction="x"} 5`)
if err == nil {
t.Fatal("expected error for '}' appearing before '{'")
}
})
t.Run("opening brace with no closing brace is malformed", func(t *testing.T) {
// No '}' at all -> end == -1, which is < brace. Must error.
// If the guard were dropped/inverted the code would slice line[brace+1:-1]
// and panic; asserting a clean error keeps that contract.
_, _, _, err := parseMetricLine(`mtg_traffic{direction="x" 5`)
if err == nil {
t.Fatal("expected error for '{' without a closing '}'")
}
})
t.Run("well-formed braces are accepted", func(t *testing.T) {
// '{' at index 11, '}' at index 25: end > brace, so the guard must NOT
// fire and parsing must succeed. Guards against a mutant that always errors.
name, labels, val, err := parseMetricLine(`mtg_traffic{direction="up"} 42`)
if err != nil {
t.Fatalf("well-formed line should parse: %v", err)
}
if name != "mtg_traffic" {
t.Fatalf("name=%q", name)
}
if labels["direction"] != "up" {
t.Fatalf("labels=%v", labels)
}
if val != 42 {
t.Fatalf("val=%v", val)
}
})
// serverPort extracts the loopback port a httptest server bound to, so
// scrapeStats can rebuild the same http://127.0.0.1:<port>/stats URL.
func serverPort(t *testing.T, srv *httptest.Server) int {
t.Helper()
u, err := url.Parse(srv.URL)
if err != nil {
t.Fatalf("parse url: %v", err)
}
port, err := strconv.Atoi(u.Port())
if err != nil {
t.Fatalf("parse port: %v", err)
}
return port
}
// TestParseMetricLineLabelEqualsBoundary pins the contract of the '=' guard in
// the per-label loop (manager.go:430 -> `if eq < 0`).
//
// - eq < 0 (no '=' in the segment): the segment is skipped, no label added.
// - eq == 0 (segment begins with '='): the key is empty but the pair is STILL
// parsed, producing labels[""] = value. The boundary is `< 0`, not `<= 0`.
func TestParseMetricLineLabelEqualsBoundary(t *testing.T) {
t.Run("label segment without '=' is skipped, not fatal", func(t *testing.T) {
// "novalue" has no '=' (eq == -1) and must be skipped. A real key=val
// segment in the same line must still be parsed. Mutating `< 0` to `> 0`
// would take kv[:eq] with eq=-1 and panic; mutating away the skip would
// also corrupt parsing.
name, labels, val, err := parseMetricLine(`mtg_traffic{novalue,direction="down"} 9`)
if err != nil {
t.Fatalf("line with a value-less label should still parse: %v", err)
func TestScrapeStats(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/stats" {
http.NotFound(w, r)
return
}
if name != "mtg_traffic" {
t.Fatalf("name=%q", name)
}
if _, present := labels["novalue"]; present {
t.Fatalf("value-less segment must not create a label: %v", labels)
}
if labels["direction"] != "down" {
t.Fatalf("real label must still be parsed: %v", labels)
}
if val != 9 {
t.Fatalf("val=%v", val)
}
})
_, _ = io.WriteString(w, `{"started_at":"2026-01-01T00:00:00Z","total_connections":2,`+
`"users":{`+
`"alice":{"connections":2,"bytes_in":100,"bytes_out":200,"last_seen":"2026-01-01T00:01:00Z"},`+
`"bob":{"connections":0,"bytes_in":5,"bytes_out":7,"last_seen":null}}}`)
}))
defer srv.Close()
t.Run("label segment beginning with '=' is parsed as empty key", func(t *testing.T) {
// "=onlyvalue": eq == 0. Since the guard is `< 0`, this is NOT skipped:
// it yields labels[""] = "onlyvalue". A mutant changing `< 0` to `<= 0`
// would skip it, losing the empty-key entry.
_, labels, _, err := parseMetricLine(`mtg_traffic{=onlyvalue} 1`)
if err != nil {
t.Fatalf("segment with empty key should still parse: %v", err)
}
v, present := labels[""]
if !present {
t.Fatalf("eq==0 segment must produce an empty-key label: %v", labels)
}
if v != "onlyvalue" {
t.Fatalf("empty-key label value=%q", v)
}
})
users, ok := scrapeStats(serverPort(t, srv))
if !ok {
t.Fatal("scrapeStats should succeed against a valid /stats endpoint")
}
if len(users) != 2 {
t.Fatalf("expected 2 users, got %d: %+v", len(users), users)
}
if users["alice"].BytesIn != 100 || users["alice"].BytesOut != 200 || users["alice"].Connections != 2 {
t.Fatalf("alice stats parsed wrong: %+v", users["alice"])
}
if users["bob"].Connections != 0 || users["bob"].BytesIn != 5 {
t.Fatalf("bob stats parsed wrong: %+v", users["bob"])
}
}
func TestScrapeStatsUnreachable(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.NotFound(w, r)
}))
port := serverPort(t, srv)
srv.Close()
if _, ok := scrapeStats(port); ok {
t.Fatal("scrapeStats must report ok=false when the endpoint is unreachable")
}
}