refactor: focused service files, leaf subpackages, and an internal/ layout (#5167)

* refactor(service): split client.go into focused files

client.go had grown to 4455 lines mixing ~10 responsibilities. Split it
verbatim into cohesive same-package files (no behavior change):

  client.go            foundation: ClientService, ClientWithAttachments,
                       ClientCreatePayload, ErrClientNotInInbound, sqlInChunk
  client_locks.go      inbound mutation locks, delete tombstones, compactOrphans
  client_lookup.go     read-only lookups (GetByID, List, EffectiveFlow, ...)
  client_link.go       inbound association sync (SyncInbound, DetachInbound, ...)
  client_crud.go       single-client CRUD + validation + protocol defaults
  client_inbound_apply.go  low-level inbound-settings mutators + by-email setters
  client_bulk.go       bulk attach/detach/adjust/delete/create + DelDepleted
  client_traffic.go    traffic-reset paths
  client_groups.go     client group management
  client_paging.go     paged listing, filtering, sorting, summary

Every declaration moved unchanged (verified: identical func/type/const/var
signature set before vs after). Imports redistributed per file via goimports.
go build ./..., go vet, and go test ./web/service/... all pass.

* refactor(service): split inbound.go into focused files

inbound.go was 4100 lines. Split it verbatim into cohesive same-package
files (no behavior change):

  inbound.go             core inbound CRUD + InboundService (keeps pkg doc)
  inbound_protocol.go    protocol / stream capability helpers
  inbound_node.go        node/runtime/remote coordination + online tracking
  inbound_traffic.go     traffic accounting, reset, client stats
  inbound_client_ips.go  per-client IP tracking
  inbound_clients.go     client lookups within inbounds + copy-clients
  inbound_disable.go     auto-disable invalid inbounds/clients
  inbound_migration.go   DB migrations
  inbound_sublink.go     subscription link providers
  inbound_util.go        generic slice/string helpers

Identical func/type/const/var signature set before vs after; package doc
comment preserved on inbound.go. Imports redistributed via goimports.
Build, vet, and go test ./web/service/... all pass.

* refactor(service): split tgbot.go into focused files

tgbot.go was 3738 lines dominated by a 1246-line answerCallback. Split it
verbatim into cohesive same-package files (no behavior change):

  tgbot.go           lifecycle, bot setup, caches, small utils
  tgbot_router.go    incoming update / command / callback dispatch
  tgbot_send.go      outbound messaging primitives
  tgbot_client.go    client views, actions, subscription links
  tgbot_inbound.go   inbound listing / pickers
  tgbot_report.go    server usage, exhausted, online, backups, notifications

Identical func/type/const/var signature set before vs after. Imports
redistributed via goimports. Build, vet, and go test ./web/service/... pass.

* refactor(client): dedupe single-field by-email setters

ResetClientIpLimitByEmail, ResetClientExpiryTimeByEmail, and
ResetClientTrafficLimitByEmail shared an identical ~50-line body that
resolves the inbound by email, confirms the client exists, rewrites a
single-client settings payload, and delegates to UpdateInboundClient.

Extract that into applyClientFieldByEmail(inboundSvc, email, mutate) and
reduce each setter to a 3-line wrapper. Behavior is unchanged: same checks
and error strings, same single-client payload contract, same totalGB guard.

SetClientTelegramUserID (resolves by traffic id, different error text) and
ToggleClientEnableByEmail/SetClientEnableByEmail (different return shape and
a pre-read of the old state) intentionally keep their own bodies.

* refactor(service): extract panel/ subpackage

Move the panel-administration leaf services out of the flat service
package into web/service/panel/ (package panel):

  user.go         UserService (auth / 2FA / LDAP)
  panel.go        PanelService (restart / self-update) + version helpers
  panel_other.go  non-unix RestartPanel
  panel_unix.go   unix RestartPanel
  api_token.go    ApiTokenService
  websocket.go    WebSocketService
  panel_test.go   version/shellQuote unit tests

These are leaves: they depend on core (SettingService, Release) but no
core file references them, so the extraction creates no import cycle.
Core references are now qualified (service.SettingService, service.Release);
callers in main.go, web/web.go, and web/controller/* updated to panel.*.
Build, vet, and go test ./web/... pass.

* refactor(service): extract integration/ subpackage

Move the external-provider integration leaves into web/service/integration/
(package integration):

  warp.go        WarpService (Cloudflare WARP)
  nord.go        NordService (NordVPN)
  custom_geo.go  CustomGeoService (custom geo asset management)
  *_test.go      custom_geo / panel-proxy tests

These depend on core (SettingService, ServerService, XraySettingService) but
no core file references them. xray_setting.go stays in core because it calls
the unexported SettingService.saveSetting. The shared isBlockedIP SSRF helper
(used by core url_safety.go and by custom_geo) now has a small copy in each
package rather than being exported. Core references qualified; callers in
web/web.go, web/job/*, and web/controller/* updated to integration.*.
Build, vet, and go test ./web/... pass.

* refactor(service): extract tgbot/ subpackage

Move the Telegram bot (6 files + test) into web/service/tgbot/ (package
tgbot). It is a leaf: it embeds five core services (Inbound/Client/Setting/
Server/Xray) and the core never references it, so no import cycle.

To support the package boundary without changing behavior:
  - core exposes XrayProcess() *xray.Process so tgbot keeps calling the
    exact same running-process methods it used via the package-level `p`;
  - three core methods tgbot calls are exported: ClientService.checkIs-
    EnabledByEmail -> CheckIsEnabledByEmail, InboundService.getAllEmails ->
    GetAllEmails (callers updated in-package);
  - tgbot's embedded-field types and the few core type refs (Status,
    ClientCreatePayload, SanitizePublicHTTPURL) are now service-qualified.

Callers in main.go, web/web.go, web/job/*, and web/controller/* updated to
tgbot.*. Build, vet, and go test ./web/... pass.

* refactor(service): extract outbound/ subpackage

OutboundService (outbound.go) imports only neutral packages (config,
database, model, xray) and its production code is referenced by no core or
sibling service file — only by web/controller/xray_setting.go and
web/job/xray_traffic_job.go. Move it to web/service/outbound/ (package
outbound); no core qualification needed inside. Callers updated to outbound.*.

The one coupling was a tiny pure test helper, outboundsContainTag, used by
both outbound.go and the core outbound_subscription_test.go; it now has a
small copy in that test file rather than being shared across the boundary.
Build, vet, and go test ./web/... pass.

* refactor(util): move wireguard into its own subpackage

util/wireguard.go was the lone file of the root `util` package (24 lines,
one exported func GenerateWireguardKeypair), while every other util concern
lives in a focused subpackage (util/common, util/crypto, util/netsafe, ...).
Move it to util/wireguard/ (package wireguard) for consistency; its only
importer, web/service/integration/warp.go, is updated. The root `util`
package no longer exists.

* refactor(sub): drop redundant sub prefix from filenames

Inside package sub the subXxx.go prefix just repeats the package name
(like client_*.go did inside service). Rename for consistency; content and
type names are unchanged:

  subController.go    -> controller.go
  subService.go       -> service.go
  subClashService.go  -> clash_service.go
  subJsonService.go   -> json_service.go
  (+ matching _test.go files)

* refactor(controller): rename xui.go -> spa.go

XUIController serves the panel's single-page-app shell; spa.go names that
role plainly (the other controller files are domain-named). File rename only
— the type stays XUIController. api_docs_test.go keys route base paths by
filename, so its "xui.go" case is updated to "spa.go".

* refactor: move backend packages under internal/

Adopt the idiomatic Go application layout: the backend packages now live
under internal/ (a boundary the toolchain enforces), signalling private
implementation instead of a library-style flat root. No runtime behavior
changes — only import paths and a few build/config paths move.

Moved: config, database, logger, mtproto, sub, util, web, xray -> internal/.
main.go stays at the repo root and tools/openapigen stays under tools/ (both
still import internal/* because the internal rule keys off the module root).
The module path github.com/mhsanaei/3x-ui/v3 is unchanged; 149 .go files had
their import prefix rewritten to .../internal/<pkg>.

Couplings the Go compiler can't see, updated to the new layout:
  - frontend i18n imports of web/translation (react.ts, setup.components.ts)
  - vite outDir + eslint/tsconfig ignore globs -> internal/web/dist
  - Dockerfile COPY paths for web/dist and web/translation
  - locale.go os.DirFS("web") disk fallback -> "internal/web"
  - .gitignore and ci.yml go:embed stub for internal/web/dist
  - api_docs_test.go repo-root relative walk (one level deeper)
  - tools/openapigen filesystem package paths; ApiTokenView repointed to the
    web/service/panel subpackage and codegen regenerated (clears a stale
    type the ci.yml codegen check was failing on)

Verified: go build/vet/test (all packages), and frontend typecheck, lint,
vitest (478 tests), and production build into internal/web/dist.

* fix(config): keep test runs from writing logs into the source tree

GetLogFolder() returns a CWD-relative "./log" on Windows. Under `go test`
the working directory is each package's own folder, so InitLogger (called by
tests in web/job, web/service, xray, web/websocket) created stray log/
directories scattered through the source tree (e.g. internal/web/job/log/).

Redirect to a shared temp folder when testing.Testing() reports a test run.
Production behavior is unchanged: Windows still uses ./log next to the binary
and Linux /var/log/x-ui. The log files were always gitignored (*.log) and
never committed; this just stops the noise at the source.

* docs: move subscription-template guide out of root into docs/

sub_templates/ was a top-level folder holding only a README and no actual
templates (3x-ui ships none by design), referenced nowhere and unlinked from
any doc — it read like an empty placeholder cluttering the repo root.

Move the guide to docs/custom-subscription-templates.md (a proper docs home),
reword its intro to read as documentation rather than a folder note, link it
from the Features list in README.md, and drop the empty sub_templates/ folder.

* fix: update stale web/ path references after the internal/ move

The internal/ migration rewrote Go import paths but left some references to
the old top-level layout in docs, comments, and a few runtime disk paths.

Functional (dev-mode only): the disk-serving fallbacks that read the Vite
build from disk when running from source still pointed at web/dist/, which
moved to internal/web/dist/ — so `os.DirFS`/`os.Stat`/`os.ReadFile` in
internal/web/web.go and internal/sub/{sub,controller}.go are corrected.
Production was unaffected (it serves the embedded FS; verified by the Docker
build), but `go run` with a live frontend build silently fell back to embed.

Docs/comments: frontend/README.md, CONTRIBUTING.md, the claude-issue-bot and
release workflows, the openapigen -root help text, and assorted Go comments
now reference internal/web, internal/database, internal/sub, internal/xray,
etc. Package-name mentions (the "web" package), root paths (main.go,
frontend/, install scripts, /etc/x-ui), routes (/panel/api/xray), and the
historical "web/assets no longer exists" note were intentionally left as-is.

* refactor(web): remove the legacy /xui -> /panel redirect middleware

RedirectMiddleware existed only for backward compatibility with the old
`/xui` URL scheme (301-redirecting /xui and /xui/API to /panel and
/panel/api). That cutover was long ago, so drop the middleware, its
registration in initRouter, and the now-inaccurate "URL redirection"
mention in the middleware package doc. Old /xui URLs now 404 like any other
unknown path. HTTPS auto-redirect and auth redirects are unrelated and stay.

* build: fix .dockerignore for internal/ layout and exclude runtime dir

- web/dist -> internal/web/dist: the embedded frontend moved under internal/,
  so the stale exclude no longer matched and the locally-built dist could be
  sent to the build context (the frontend stage rebuilds it fresh anyway).
- exclude x-ui/: the local runtime directory (SQLite db, geo .dat files, xray
  binaries, certs — ~150MB) was being shipped into the build context for no
  reason. Verified the pattern excludes only the directory and still keeps
  x-ui.sh, which the Dockerfile copies to /usr/bin/x-ui.
This commit is contained in:
Sanaei
2026-06-10 15:19:22 +02:00
committed by GitHub
parent 26c549a95a
commit 41645255f1
254 changed files with 13074 additions and 12836 deletions
+432
View File
@@ -0,0 +1,432 @@
package mtproto
import (
"bufio"
"encoding/json"
"fmt"
"net"
"net/http"
"os"
"strconv"
"strings"
"sync"
"time"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/logger"
)
// Instance is the desired runtime configuration of one mtproto inbound.
type Instance struct {
Id int
Tag string
Listen string
Port int
Secret string
// Optional mtg tuning; each is omitted from the generated TOML when
// zero-valued so mtg falls back to its own defaults.
Debug bool
ProxyProtocolListener bool
PreferIP string
FrontingIP string
FrontingPort int
FrontingProxyProtocol bool
}
func (inst Instance) bindTo() string {
listen := inst.Listen
if listen == "" {
listen = "0.0.0.0"
}
return fmt.Sprintf("%s:%d", listen, inst.Port)
}
// fingerprint changes whenever any value that ends up in the generated TOML
// changes, so ensureLocked restarts mtg when the operator edits a setting.
func (inst Instance) fingerprint() string {
return strings.Join([]string{
inst.bindTo(),
inst.Secret,
strconv.FormatBool(inst.Debug),
strconv.FormatBool(inst.ProxyProtocolListener),
inst.PreferIP,
inst.FrontingIP,
strconv.Itoa(inst.FrontingPort),
strconv.FormatBool(inst.FrontingProxyProtocol),
}, "|")
}
// Traffic is a per-inbound traffic delta scraped from an mtg metrics endpoint.
type Traffic struct {
Tag string
Up int64
Down int64
}
type managed struct {
proc *Process
tag string
fingerprint string
metricsPort int
lastUp int64
lastDown int64
haveLast bool
}
// Manager owns the set of running mtg processes keyed by inbound id.
type Manager struct {
mu sync.Mutex
procs map[int]*managed
// swept records that the one-time startup cleanup of orphaned mtg
// processes (survivors of a previous x-ui run) has already run.
swept bool
}
var (
managerOnce sync.Once
manager *Manager
)
// GetManager returns the process-wide mtg manager singleton.
func GetManager() *Manager {
managerOnce.Do(func() {
manager = &Manager{procs: map[int]*managed{}}
})
return manager
}
// InstanceFromInbound derives a desired Instance from an mtproto inbound,
// healing the FakeTLS secret so it always matches the configured domain.
// Returns false when the inbound is not a usable mtproto inbound.
func InstanceFromInbound(ib *model.Inbound) (Instance, bool) {
if ib == nil || ib.Protocol != model.MTProto {
return Instance{}, false
}
settings := ib.Settings
if healed, ok := model.HealMtprotoSecret(settings); ok {
settings = healed
}
var parsed struct {
Secret string `json:"secret"`
Debug bool `json:"debug"`
ProxyProtocolListener bool `json:"proxyProtocolListener"`
PreferIP string `json:"preferIp"`
DomainFronting struct {
IP string `json:"ip"`
Port int `json:"port"`
ProxyProtocol bool `json:"proxyProtocol"`
} `json:"domainFronting"`
}
if err := json.Unmarshal([]byte(settings), &parsed); err != nil {
return Instance{}, false
}
if parsed.Secret == "" {
return Instance{}, false
}
return Instance{
Id: ib.Id,
Tag: ib.Tag,
Listen: ib.Listen,
Port: ib.Port,
Secret: parsed.Secret,
Debug: parsed.Debug,
ProxyProtocolListener: parsed.ProxyProtocolListener,
PreferIP: parsed.PreferIP,
FrontingIP: parsed.DomainFronting.IP,
FrontingPort: parsed.DomainFronting.Port,
FrontingProxyProtocol: parsed.DomainFronting.ProxyProtocol,
}, true
}
// Ensure starts the mtg process for an instance, or restarts it when its
// configuration changed. A no-op when the desired process is already running.
func (m *Manager) Ensure(inst Instance) error {
m.mu.Lock()
defer m.mu.Unlock()
m.sweepOrphansLocked()
return m.ensureLocked(inst)
}
// sweepOrphansLocked kills mtg processes left running by a previous x-ui run,
// exactly once per process lifetime and before any of our own mtg are started.
// Because x-ui owns every mtg process, anything alive at this point is an orphan
// that would otherwise keep holding an inbound port with a stale secret.
func (m *Manager) sweepOrphansLocked() {
if m.swept {
return
}
m.swept = true
if n := killStrayMtgProcesses(GetBinaryPath()); n > 0 {
logger.Warningf("mtproto: terminated %d orphaned mtg process(es) from a previous run", n)
}
}
func (m *Manager) ensureLocked(inst Instance) error {
fp := inst.fingerprint()
if cur, ok := m.procs[inst.Id]; ok {
if cur.fingerprint == fp && cur.proc.IsRunning() {
cur.tag = inst.Tag
return nil
}
cur.proc.Stop()
delete(m.procs, inst.Id)
}
metricsPort, err := freeLocalPort()
if err != nil {
return err
}
cfgPath := configPathForID(inst.Id)
if err := writeConfig(cfgPath, inst, metricsPort); err != nil {
return err
}
proc := newProcess(cfgPath, fmt.Sprintf("inbound %d", inst.Id))
if err := proc.Start(); err != nil {
return err
}
m.procs[inst.Id] = &managed{
proc: proc,
tag: inst.Tag,
fingerprint: fp,
metricsPort: metricsPort,
}
logger.Infof("mtproto: started mtg for inbound %d on %s", inst.Id, inst.bindTo())
return nil
}
// Remove stops and forgets the mtg process for an inbound id.
func (m *Manager) Remove(id int) {
m.mu.Lock()
defer m.mu.Unlock()
if cur, ok := m.procs[id]; ok {
cur.proc.Stop()
delete(m.procs, id)
_ = os.Remove(configPathForID(id))
logger.Infof("mtproto: stopped mtg for inbound %d", id)
}
}
// Reconcile drives the running set toward the desired instances: it stops
// processes that are no longer wanted and (re)starts the rest. Used at boot
// and periodically to recover from crashes.
func (m *Manager) Reconcile(desired []Instance) {
m.mu.Lock()
defer m.mu.Unlock()
m.sweepOrphansLocked()
want := make(map[int]struct{}, len(desired))
for _, inst := range desired {
want[inst.Id] = struct{}{}
}
for id, cur := range m.procs {
if _, ok := want[id]; !ok {
cur.proc.Stop()
delete(m.procs, id)
_ = os.Remove(configPathForID(id))
}
}
for _, inst := range desired {
if err := m.ensureLocked(inst); err != nil {
logger.Warningf("mtproto: reconcile failed for inbound %d: %v", inst.Id, err)
}
}
}
// StopAll stops every managed mtg process. Called on panel shutdown.
func (m *Manager) StopAll() {
m.mu.Lock()
defer m.mu.Unlock()
for id, cur := range m.procs {
_ = cur.proc.Stop()
_ = os.Remove(configPathForID(id))
delete(m.procs, id)
}
}
// CollectTraffic scrapes each running mtg metrics endpoint and returns the
// per-inbound byte deltas since the previous scrape.
func (m *Manager) CollectTraffic() []Traffic {
// Snapshot the state we need under the lock, then release before doing
// network I/O so that Ensure/Reconcile/Remove are not blocked.
type snap struct {
id int
metricsPort int
tag string
haveLast bool
lastUp int64
lastDown int64
}
m.mu.Lock()
snaps := make([]snap, 0, len(m.procs))
for id, cur := range m.procs {
if cur.proc == nil || !cur.proc.IsRunning() {
continue
}
snaps = append(snaps, snap{
id: id,
metricsPort: cur.metricsPort,
tag: cur.tag,
haveLast: cur.haveLast,
lastUp: cur.lastUp,
lastDown: cur.lastDown,
})
}
m.mu.Unlock()
out := make([]Traffic, 0, len(snaps))
for _, s := range snaps {
up, down, ok := scrapeTraffic(s.metricsPort)
if !ok {
continue
}
var du, dd int64
if s.haveLast {
du = up - s.lastUp
dd = down - s.lastDown
if du < 0 {
du = 0
}
if dd < 0 {
dd = 0
}
}
// Re-acquire lock to persist the new baseline, but only if the entry
// still exists (it may have been removed during the scrape).
m.mu.Lock()
if cur, ok := m.procs[s.id]; ok {
cur.lastUp = up
cur.lastDown = down
cur.haveLast = true
}
m.mu.Unlock()
if s.haveLast && (du > 0 || dd > 0) {
out = append(out, Traffic{Tag: s.tag, Up: du, Down: dd})
}
}
return out
}
func freeLocalPort() (int, error) {
l, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
return 0, err
}
defer l.Close()
return l.Addr().(*net.TCPAddr).Port, nil
}
// renderConfig builds the mtg TOML for an instance. Top-level keys must precede
// any [section] header in TOML, so the layout is: required keys, then the
// optional scalar tuning, then [domain-fronting], and finally [stats.prometheus]
// — which x-ui always emits and scrapes for traffic (see scrapeTraffic).
func renderConfig(inst Instance, metricsPort int) string {
var b strings.Builder
fmt.Fprintf(&b, "secret = %q\n", inst.Secret)
fmt.Fprintf(&b, "bind-to = %q\n", inst.bindTo())
if inst.Debug {
b.WriteString("debug = true\n")
}
if inst.ProxyProtocolListener {
b.WriteString("proxy-protocol-listener = true\n")
}
if inst.PreferIP != "" {
fmt.Fprintf(&b, "prefer-ip = %q\n", inst.PreferIP)
}
if inst.FrontingIP != "" || inst.FrontingPort > 0 || inst.FrontingProxyProtocol {
b.WriteString("\n[domain-fronting]\n")
if inst.FrontingIP != "" {
fmt.Fprintf(&b, "ip = %q\n", inst.FrontingIP)
}
if inst.FrontingPort > 0 {
fmt.Fprintf(&b, "port = %d\n", inst.FrontingPort)
}
if inst.FrontingProxyProtocol {
b.WriteString("proxy-protocol = true\n")
}
}
fmt.Fprintf(&b, "\n[stats.prometheus]\nenabled = true\nbind-to = \"127.0.0.1:%d\"\nhttp-path = \"/metrics\"\nmetric-prefix = \"mtg\"\n", metricsPort)
return b.String()
}
func writeConfig(path string, inst Instance, metricsPort int) error {
if err := os.MkdirAll(configDir(), 0o750); err != nil {
return err
}
return os.WriteFile(path, []byte(renderConfig(inst, metricsPort)), 0o640)
}
// scrapeTraffic reads the mtg Prometheus metrics endpoint and sums byte
// counters by direction. mtg exposes a traffic counter labelled with a
// direction; "to_telegram" is treated as upload and "to_client" as download.
// Best-effort: an unreachable endpoint or unrecognised format yields ok=false.
func scrapeTraffic(port int) (up int64, down int64, ok bool) {
client := http.Client{Timeout: 3 * time.Second}
resp, err := client.Get(fmt.Sprintf("http://127.0.0.1:%d/metrics", port))
if err != nil {
return 0, 0, false
}
defer resp.Body.Close()
scanner := bufio.NewScanner(resp.Body)
scanner.Buffer(make([]byte, 64*1024), 1024*1024)
found := false
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" || line[0] == '#' || !strings.Contains(line, "traffic") {
continue
}
name, labels, value, perr := parseMetricLine(line)
if perr != nil || !strings.HasPrefix(name, "mtg") {
continue
}
switch labels["direction"] {
case "to_telegram", "egress", "up":
up += int64(value)
case "to_client", "ingress", "down":
down += int64(value)
default:
down += int64(value)
}
found = true
}
if err := scanner.Err(); err != nil {
logger.Debug("mtproto: metrics scan error:", err)
}
return up, down, found
}
func parseMetricLine(line string) (name string, labels map[string]string, value float64, err error) {
labels = map[string]string{}
rest := line
if brace := strings.IndexByte(line, '{'); brace >= 0 {
name = line[:brace]
end := strings.IndexByte(line, '}')
if end < brace {
return "", nil, 0, fmt.Errorf("malformed metric line")
}
for _, kv := range strings.Split(line[brace+1:end], ",") {
eq := strings.IndexByte(kv, '=')
if eq < 0 {
continue
}
labels[strings.TrimSpace(kv[:eq])] = strings.Trim(strings.TrimSpace(kv[eq+1:]), `"`)
}
rest = strings.TrimSpace(line[end+1:])
} else {
fields := strings.Fields(line)
if len(fields) < 2 {
return "", nil, 0, fmt.Errorf("malformed metric line")
}
name = fields[0]
rest = fields[1]
}
valFields := strings.Fields(rest)
if len(valFields) == 0 {
return "", nil, 0, fmt.Errorf("missing metric value")
}
value, err = strconv.ParseFloat(valFields[0], 64)
if err != nil {
return "", nil, 0, err
}
return name, labels, value, nil
}
+127
View File
@@ -0,0 +1,127 @@
package mtproto
import (
"strings"
"testing"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
)
func TestParseMetricLine(t *testing.T) {
name, labels, val, err := parseMetricLine(`mtg_traffic{direction="to_client"} 12345`)
if err != nil {
t.Fatal(err)
}
if name != "mtg_traffic" {
t.Fatalf("name=%q", name)
}
if labels["direction"] != "to_client" {
t.Fatalf("labels=%v", labels)
}
if val != 12345 {
t.Fatalf("val=%v", val)
}
name2, _, val2, err2 := parseMetricLine(`mtg_concurrency 7`)
if err2 != nil {
t.Fatal(err2)
}
if name2 != "mtg_concurrency" || val2 != 7 {
t.Fatalf("got %q %v", name2, val2)
}
}
func TestInstanceFromInbound(t *testing.T) {
ib := &model.Inbound{
Id: 3,
Tag: "inbound-3",
Listen: "0.0.0.0",
Port: 8443,
Protocol: model.MTProto,
Settings: `{"fakeTlsDomain":"example.com","secret":"",` +
`"debug":true,"proxyProtocolListener":true,"preferIp":"prefer-ipv4",` +
`"domainFronting":{"ip":"127.0.0.1","port":9443,"proxyProtocol":true}}`,
}
inst, ok := InstanceFromInbound(ib)
if !ok {
t.Fatal("expected a usable instance")
}
if inst.Secret == "" {
t.Fatal("secret should be healed to a non-empty value")
}
if inst.Port != 8443 || inst.Id != 3 {
t.Fatalf("bad instance %+v", inst)
}
if !inst.Debug || !inst.ProxyProtocolListener || inst.PreferIP != "prefer-ipv4" {
t.Fatalf("scalar options not parsed: %+v", inst)
}
if inst.FrontingIP != "127.0.0.1" || inst.FrontingPort != 9443 || !inst.FrontingProxyProtocol {
t.Fatalf("domain-fronting not parsed: %+v", inst)
}
if _, ok := InstanceFromInbound(&model.Inbound{Protocol: model.VLESS}); ok {
t.Fatal("non-mtproto inbound should not produce an instance")
}
}
func TestRenderConfig(t *testing.T) {
// A bare instance emits only the required keys and the prometheus block,
// with no optional keys and no [domain-fronting] section.
bare := renderConfig(Instance{Secret: "ee00", Listen: "0.0.0.0", Port: 8443}, 5000)
for _, unwanted := range []string{"debug", "proxy-protocol-listener", "prefer-ip", "[domain-fronting]"} {
if strings.Contains(bare, unwanted) {
t.Fatalf("bare config should not contain %q:\n%s", unwanted, bare)
}
}
if !strings.Contains(bare, `bind-to = "0.0.0.0:8443"`) {
t.Fatalf("missing bind-to:\n%s", bare)
}
if !strings.Contains(bare, "[stats.prometheus]") || !strings.Contains(bare, "127.0.0.1:5000") {
t.Fatalf("prometheus block must always be present:\n%s", bare)
}
// A fully configured instance emits every option and the fronting section.
full := renderConfig(Instance{
Secret: "ee11", Listen: "0.0.0.0", Port: 443,
Debug: true, ProxyProtocolListener: true, PreferIP: "only-ipv6",
FrontingIP: "127.0.0.1", FrontingPort: 9443, FrontingProxyProtocol: true,
}, 6000)
for _, want := range []string{
"debug = true\n",
"proxy-protocol-listener = true\n",
`prefer-ip = "only-ipv6"`,
"[domain-fronting]",
`ip = "127.0.0.1"`,
"port = 9443",
"proxy-protocol = true\n",
} {
if !strings.Contains(full, want) {
t.Fatalf("full config missing %q:\n%s", want, full)
}
}
// TOML requires top-level keys before any [section] header.
if strings.Index(full, "prefer-ip") > strings.Index(full, "[domain-fronting]") {
t.Fatalf("top-level keys must precede the [domain-fronting] section:\n%s", full)
}
if strings.LastIndex(full, "[domain-fronting]") > strings.Index(full, "[stats.prometheus]") {
t.Fatalf("[domain-fronting] must precede [stats.prometheus]:\n%s", full)
}
}
func TestFingerprintReactsToOptions(t *testing.T) {
base := Instance{Secret: "ee", Listen: "0.0.0.0", Port: 443}
for name, mutate := range map[string]func(*Instance){
"debug": func(i *Instance) { i.Debug = true },
"listener": func(i *Instance) { i.ProxyProtocolListener = true },
"preferIp": func(i *Instance) { i.PreferIP = "only-ipv4" },
"frontingIP": func(i *Instance) { i.FrontingIP = "127.0.0.1" },
"frontingPort": func(i *Instance) { i.FrontingPort = 9443 },
"frontingProxy": func(i *Instance) { i.FrontingProxyProtocol = true },
} {
changed := base
mutate(&changed)
if base.fingerprint() == changed.fingerprint() {
t.Fatalf("fingerprint must change when %s changes", name)
}
}
}
+79
View File
@@ -0,0 +1,79 @@
//go:build linux
package mtproto
import (
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"syscall"
)
// killStrayMtgProcesses terminates orphaned mtg sidecars left over from a
// previous x-ui run and returns how many were killed.
//
// x-ui starts one mtg process per mtproto inbound outside its own lifecycle, and
// on Linux a child is not guaranteed to die with the panel (there is no
// kill-on-exit, unlike the Windows job object). A survivor keeps holding the
// inbound port with a now-stale secret, so new clients are silently
// domain-fronted to the FakeTLS domain instead of proxied to Telegram. x-ui is
// the sole owner of mtg, so any process matching our binary name at startup is
// an orphan and is safe to kill before we start our own.
//
// binaryPath is the configured mtg path (e.g. "bin/mtg-linux-amd64"); matching
// is done on the executable's base name so it is independent of the bin folder
// and still works after an update has deleted the binary (the running process's
// /proc/<pid>/exe then reads as "<path> (deleted)", so argv[0] is used too).
func killStrayMtgProcesses(binaryPath string) int {
base := filepath.Base(binaryPath)
if base == "" || base == "." || base == string(filepath.Separator) {
return 0
}
self := os.Getpid()
entries, err := os.ReadDir("/proc")
if err != nil {
return 0
}
killed := 0
for _, e := range entries {
pid, err := strconv.Atoi(e.Name())
if err != nil || pid == self {
continue
}
if procExeBase(pid) != base && cmdlineArgv0Base(pid) != base {
continue
}
if err := syscall.Kill(pid, syscall.SIGKILL); err == nil {
killed++
}
}
return killed
}
// procExeBase returns the base name of /proc/<pid>/exe, or "" if unreadable.
func procExeBase(pid int) string {
exe, err := os.Readlink(fmt.Sprintf("/proc/%d/exe", pid))
if err != nil {
return ""
}
return filepath.Base(exe)
}
// cmdlineArgv0Base returns the base name of argv[0] from /proc/<pid>/cmdline,
// the reliable fallback when the binary has been replaced or exe is unreadable.
func cmdlineArgv0Base(pid int) string {
data, err := os.ReadFile(fmt.Sprintf("/proc/%d/cmdline", pid))
if err != nil || len(data) == 0 {
return ""
}
argv0 := data
if i := strings.IndexByte(string(data), 0); i >= 0 {
argv0 = data[:i]
}
if len(argv0) == 0 {
return ""
}
return filepath.Base(string(argv0))
}
+9
View File
@@ -0,0 +1,9 @@
//go:build !linux
package mtproto
// killStrayMtgProcesses is a no-op off Linux. On Windows the kill-on-exit job
// object already terminates mtg together with the panel (see
// attachChildLifetime), so orphans do not arise there; other platforms are not
// a supported deployment target for the mtg sidecar.
func killStrayMtgProcesses(_ string) int { return 0 }
+235
View File
@@ -0,0 +1,235 @@
// Package mtproto manages mtg (github.com/9seconds/mtg) sidecar processes that
// serve MTProto FakeTLS proxies. Xray-core has no mtproto protocol, so mtproto
// inbounds are run as standalone mtg processes — one process per inbound —
// entirely outside the Xray config and lifecycle.
package mtproto
import (
"errors"
"fmt"
"os"
"os/exec"
"runtime"
"strings"
"sync"
"sync/atomic"
"syscall"
"time"
"github.com/mhsanaei/3x-ui/v3/internal/config"
"github.com/mhsanaei/3x-ui/v3/internal/logger"
)
// GetBinaryName returns the mtg binary filename for the current OS and arch,
// matching the naming scheme used for the Xray binary. On Windows the ".exe"
// extension is appended so a natural "mtg-windows-amd64.exe" is found.
func GetBinaryName() string {
name := fmt.Sprintf("mtg-%s-%s", runtime.GOOS, runtime.GOARCH)
if runtime.GOOS == "windows" {
name += ".exe"
}
return name
}
// GetBinaryPath returns the full path to the mtg binary, alongside the Xray binary.
func GetBinaryPath() string {
return config.GetBinFolderPath() + "/" + GetBinaryName()
}
func configDir() string {
return config.GetBinFolderPath() + "/mtproto"
}
func configPathForID(id int) string {
return fmt.Sprintf("%s/mtg-%d.toml", configDir(), id)
}
var (
gracefulStopTimeout = 5 * time.Second
forceStopTimeout = 2 * time.Second
)
// procLogWriter consumes the mtg child process's stdout/stderr. It splits the
// stream into lines, forwards each one to the x-ui log — so mtg's own messages,
// including why it cannot reach Telegram, become visible in the panel log viewer
// and journald — and remembers the most recent line for GetResult.
type procLogWriter struct {
mu sync.Mutex
label string
buf string
lastLine string
}
func (w *procLogWriter) Write(p []byte) (int, error) {
w.mu.Lock()
defer w.mu.Unlock()
w.buf += string(p)
for {
i := strings.IndexByte(w.buf, '\n')
if i < 0 {
break
}
line := w.buf[:i]
w.buf = w.buf[i+1:]
w.emitLocked(line)
}
return len(p), nil
}
// Flush emits any buffered partial line; called once the process exits so a
// final un-terminated error line is not lost.
func (w *procLogWriter) Flush() {
w.mu.Lock()
defer w.mu.Unlock()
if w.buf != "" {
line := w.buf
w.buf = ""
w.emitLocked(line)
}
}
func (w *procLogWriter) emitLocked(line string) {
trimmed := strings.TrimSpace(strings.TrimRight(line, "\r"))
if trimmed == "" {
return
}
w.lastLine = trimmed
logger.Infof("mtproto: mtg %s | %s", w.label, trimmed)
}
func (w *procLogWriter) LastLine() string {
w.mu.Lock()
defer w.mu.Unlock()
return w.lastLine
}
// Process wraps a single mtg process invocation for one mtproto inbound.
type Process struct {
cmd *exec.Cmd
done chan struct{}
configPath string
logWriter *procLogWriter
exitErr error
intentionalStop atomic.Bool
}
func newProcess(configPath, label string) *Process {
return &Process{
configPath: configPath,
logWriter: &procLogWriter{label: label},
}
}
// IsRunning reports whether the mtg process is currently running.
func (p *Process) IsRunning() bool {
if p.cmd == nil || p.cmd.Process == nil {
return false
}
if p.done != nil {
select {
case <-p.done:
return false
default:
}
}
if p.cmd.ProcessState == nil {
return true
}
return false
}
// GetResult returns the last log line or the exit error from the mtg process.
func (p *Process) GetResult() string {
if line := p.logWriter.LastLine(); line != "" {
return line
}
if p.exitErr != nil {
return p.exitErr.Error()
}
return ""
}
// Start launches the mtg process against its generated config file.
func (p *Process) Start() error {
if p.IsRunning() {
return errors.New("mtg is already running")
}
cmd := exec.Command(GetBinaryPath(), "run", p.configPath)
cmd.Stdout = p.logWriter
cmd.Stderr = p.logWriter
p.cmd = cmd
p.done = make(chan struct{})
p.exitErr = nil
p.intentionalStop.Store(false)
if err := cmd.Start(); err != nil {
close(p.done)
p.cmd = nil
return err
}
attachChildLifetime(cmd)
go p.wait(cmd)
return nil
}
func (p *Process) wait(cmd *exec.Cmd) {
defer close(p.done)
err := cmd.Wait()
p.logWriter.Flush()
if err == nil || p.intentionalStop.Load() {
return
}
if runtime.GOOS == "windows" {
if strings.Contains(strings.ToLower(err.Error()), "exit status 1") {
p.exitErr = err
return
}
}
logger.Errorf("mtproto: mtg process exited: %v", err)
p.exitErr = err
}
// Stop terminates the running mtg process gracefully, falling back to a kill.
func (p *Process) Stop() error {
if !p.IsRunning() {
return errors.New("mtg is not running")
}
p.intentionalStop.Store(true)
if runtime.GOOS == "windows" {
if err := p.cmd.Process.Kill(); err != nil && !errors.Is(err, os.ErrProcessDone) {
return err
}
return p.waitForExit(forceStopTimeout)
}
if err := p.cmd.Process.Signal(syscall.SIGTERM); err != nil {
if errors.Is(err, os.ErrProcessDone) {
return p.waitForExit(forceStopTimeout)
}
return err
}
if err := p.waitForExit(gracefulStopTimeout); err == nil {
return nil
}
logger.Warning("mtproto: mtg did not stop after SIGTERM, killing process")
if err := p.cmd.Process.Kill(); err != nil && !errors.Is(err, os.ErrProcessDone) {
return err
}
return p.waitForExit(forceStopTimeout)
}
func (p *Process) waitForExit(timeout time.Duration) error {
if p.done == nil {
return nil
}
timer := time.NewTimer(timeout)
defer timer.Stop()
select {
case <-p.done:
return nil
case <-timer.C:
return fmt.Errorf("timed out waiting for mtg process to stop after %s", timeout)
}
}
+7
View File
@@ -0,0 +1,7 @@
//go:build !windows
package mtproto
import "os/exec"
func attachChildLifetime(_ *exec.Cmd) {}
+66
View File
@@ -0,0 +1,66 @@
//go:build windows
package mtproto
import (
"os/exec"
"sync"
"unsafe"
"github.com/mhsanaei/3x-ui/v3/internal/logger"
"golang.org/x/sys/windows"
)
var (
killOnExitJobOnce sync.Once
killOnExitJob windows.Handle
killOnExitJobErr error
)
func ensureKillOnExitJob() (windows.Handle, error) {
killOnExitJobOnce.Do(func() {
h, err := windows.CreateJobObject(nil, nil)
if err != nil {
killOnExitJobErr = err
return
}
info := windows.JOBOBJECT_EXTENDED_LIMIT_INFORMATION{
BasicLimitInformation: windows.JOBOBJECT_BASIC_LIMIT_INFORMATION{
LimitFlags: windows.JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE,
},
}
_, err = windows.SetInformationJobObject(
h,
windows.JobObjectExtendedLimitInformation,
uintptr(unsafe.Pointer(&info)),
uint32(unsafe.Sizeof(info)),
)
if err != nil {
windows.CloseHandle(h)
killOnExitJobErr = err
return
}
killOnExitJob = h
})
return killOnExitJob, killOnExitJobErr
}
func attachChildLifetime(cmd *exec.Cmd) {
if cmd == nil || cmd.Process == nil {
return
}
job, err := ensureKillOnExitJob()
if err != nil {
logger.Warningf("mtproto: kill-on-exit job unavailable: %v", err)
return
}
h, err := windows.OpenProcess(windows.PROCESS_SET_QUOTA|windows.PROCESS_TERMINATE, false, uint32(cmd.Process.Pid))
if err != nil {
logger.Warningf("mtproto: OpenProcess for job attach failed: %v", err)
return
}
defer windows.CloseHandle(h)
if err := windows.AssignProcessToJobObject(job, h); err != nil {
logger.Warningf("mtproto: AssignProcessToJobObject failed: %v", err)
}
}