mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-09-10 20:27:15 +00:00
5a63d5d468
* fix(mtproto): use hosts for public share links Generate MTProto subscription, client, copy, QR, and export links from managed Hosts so reverse-proxied public ports are advertised correctly. Migrate the redundant legacy custom share address into a Host and keep old imports compatible. Closes #5126. * fix(mtproto): keep host share links lossless and consistent Address review on the MTProto hosts share-link change. The migration no longer drops a legacy custom share address: an unrelated (or disabled) Host stopped suppressing it, so only a Host already advertising the same address does. An imported address now clears the same validation the strict normalizer applies to every other protocol before it becomes a Host. Panel and subscription agree on the endpoint a Host advertises: a portless host string inherits the inbound port rather than the group's, and a port-only host inherits the inbound address instead of emitting server=%3A8443. LinksForClient prefers host endpoints for every protocol, the way getSubs and inboundLinks already do, so the client-links API no longer ignores managed hosts. * fix(mtproto): migrate legacy share address past unusable hosts The seeder skipped the conversion whenever any Host already carried the address, including one that is disabled or excludes the raw sub type. hostEndpoints drops those, so nothing advertised the address afterwards and the marker committed with no way back. The duplicate check now mirrors that same predicate. UpdateInbound cleared a legacy MTProto shareAddr without the Host conversion AddInbound runs, so re-applying an inbound definition through the API dropped the public address silently. Both paths share one capture helper now. Refresh the generated clients API reference for the summary reworded in the previous commit. * fix(inbounds): wait for the hosts list before building mtproto links The page destructured only `hosts` from useHostsQuery, and that list reads empty both while /panel/api/hosts/list is in flight and after it fails. withMtprotoHostEndpoints then returns the inbound untouched, so Copy, QR and Export advertise the internal listen port — the endpoint this branch exists to replace. It is worse than not fixing it: the seeder has already moved a legacy custom share address into a Host, so the fallback is the panel's own hostname instead of the operator's address, and the Go generators reading the same rows from the DB stay correct, so the two disagree for one inbound. Fold the query into the page's existing readiness gate, the same way useInbounds and HostsPage already consume that hook, so an empty list means "no hosts" rather than "not loaded yet". The error branch fires only when nothing is cached, so a refetch failing on window focus does not blank a page whose host rows are still perfectly usable. --------- Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
179 lines
5.8 KiB
Go
179 lines
5.8 KiB
Go
package sub
|
|
|
|
import (
|
|
"net/url"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
|
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
|
)
|
|
|
|
const mtprotoTestSecret = "ee8196fe6ed8b637d001f91d6952cfcdf07777772e636c6f7564666c6172652e636f6d"
|
|
|
|
func TestGenMtprotoLinkFields(t *testing.T) {
|
|
inbound := &model.Inbound{
|
|
Listen: "203.0.113.7",
|
|
Port: 8443,
|
|
Protocol: model.MTProto,
|
|
Remark: "mt-sub",
|
|
Settings: `{"fakeTlsDomain":"www.cloudflare.com","clients":[{"email":"user","enable":true,"secret":"` + mtprotoTestSecret + `"}]}`,
|
|
}
|
|
|
|
s := &SubService{}
|
|
link := s.genMtprotoLink(inbound, "user")
|
|
|
|
u, err := url.Parse(link)
|
|
if err != nil {
|
|
t.Fatalf("link does not parse: %v\n got: %s", err, link)
|
|
}
|
|
if u.Scheme != "tg" || u.Host != "proxy" {
|
|
t.Fatalf("link = %q, want a tg://proxy deep link", link)
|
|
}
|
|
q := u.Query()
|
|
if q.Get("server") != "203.0.113.7" {
|
|
t.Fatalf("server = %q, want 203.0.113.7", q.Get("server"))
|
|
}
|
|
if q.Get("port") != "8443" {
|
|
t.Fatalf("port = %q, want 8443", q.Get("port"))
|
|
}
|
|
if q.Get("secret") != mtprotoTestSecret {
|
|
t.Fatalf("secret = %q, want the client's FakeTLS secret", q.Get("secret"))
|
|
}
|
|
if u.Fragment != "" {
|
|
t.Fatalf("link carries a #%s fragment; tg://proxy links must have no remark fragment", u.Fragment)
|
|
}
|
|
}
|
|
|
|
func TestGenMtprotoLinkWrongProtocol(t *testing.T) {
|
|
s := &SubService{}
|
|
vless := &model.Inbound{Protocol: model.VLESS, Settings: `{"clients":[{"email":"user"}]}`}
|
|
if got := s.genMtprotoLink(vless, "user"); got != "" {
|
|
t.Fatalf("wrong protocol should yield empty link, got %q", got)
|
|
}
|
|
}
|
|
|
|
func TestGenMtprotoLinkNoSecret(t *testing.T) {
|
|
s := &SubService{}
|
|
inbound := &model.Inbound{
|
|
Protocol: model.MTProto,
|
|
Port: 8443,
|
|
Settings: `{"fakeTlsDomain":"www.cloudflare.com","clients":[{"email":"user"}]}`,
|
|
}
|
|
if got := s.genMtprotoLink(inbound, "user"); got != "" {
|
|
t.Fatalf("client without secret should yield empty link, got %q", got)
|
|
}
|
|
}
|
|
|
|
func TestGetSubsMtprotoUsesHostEndpoint(t *testing.T) {
|
|
initSubDB(t)
|
|
db := database.GetDB()
|
|
|
|
inbound := &model.Inbound{
|
|
Listen: "127.0.0.1",
|
|
Port: 4060,
|
|
Protocol: model.MTProto,
|
|
Enable: true,
|
|
Tag: "mt-public-port",
|
|
Settings: `{"clients":[{"email":"u@mt","enable":true,"subId":"sub-public-port","secret":"` + mtprotoTestSecret + `"}]}`,
|
|
}
|
|
if err := db.Create(inbound).Error; err != nil {
|
|
t.Fatalf("create inbound: %v", err)
|
|
}
|
|
if err := db.Create(&model.Host{
|
|
InboundId: inbound.Id,
|
|
Remark: "public",
|
|
Address: "proxy.example.com",
|
|
Port: 443,
|
|
Security: "same",
|
|
}).Error; err != nil {
|
|
t.Fatalf("create host: %v", err)
|
|
}
|
|
client := &model.ClientRecord{Email: "u@mt", SubID: "sub-public-port", Enable: true, Secret: mtprotoTestSecret}
|
|
if err := db.Create(client).Error; err != nil {
|
|
t.Fatalf("create client: %v", err)
|
|
}
|
|
if err := db.Create(&model.ClientInbound{ClientId: client.Id, InboundId: inbound.Id}).Error; err != nil {
|
|
t.Fatalf("attach client: %v", err)
|
|
}
|
|
|
|
links, _, _, _, err := NewSubService("").GetSubs(client.SubID, "sub.example.com")
|
|
if err != nil {
|
|
t.Fatalf("GetSubs: %v", err)
|
|
}
|
|
if len(links) != 1 {
|
|
t.Fatalf("links = %d, want 1: %v", len(links), links)
|
|
}
|
|
u, err := url.Parse(links[0])
|
|
if err != nil {
|
|
t.Fatalf("parse link: %v", err)
|
|
}
|
|
if got := u.Query().Get("server"); got != "proxy.example.com" {
|
|
t.Fatalf("server = %q, want proxy.example.com", got)
|
|
}
|
|
if got := u.Query().Get("port"); got != "443" {
|
|
t.Fatalf("port = %q, want public host port 443", got)
|
|
}
|
|
clientLinks := NewLinkProvider().LinksForClient("sub.example.com", inbound, client.Email)
|
|
if len(clientLinks) != 1 {
|
|
t.Fatalf("client links = %d, want 1: %v", len(clientLinks), clientLinks)
|
|
}
|
|
clientURL, err := url.Parse(clientLinks[0])
|
|
if err != nil {
|
|
t.Fatalf("parse client link: %v", err)
|
|
}
|
|
if got := clientURL.Query().Get("server"); got != "proxy.example.com" {
|
|
t.Fatalf("client link server = %q, want proxy.example.com", got)
|
|
}
|
|
if got := clientURL.Query().Get("port"); got != "443" {
|
|
t.Fatalf("client link port = %q, want 443", got)
|
|
}
|
|
}
|
|
|
|
// Regression: an mtproto inbound must resolve for a subscription id the same way
|
|
// every other client-bearing protocol does. It was previously dropped from the
|
|
// getInboundsBySubId protocol allowlist, so multi-client MTProto subscriptions
|
|
// (and the public sub page) emitted no tg://proxy link at all.
|
|
func TestGetInboundsBySubIdIncludesMtproto(t *testing.T) {
|
|
initSubDB(t)
|
|
db := database.GetDB()
|
|
|
|
in := &model.Inbound{
|
|
Port: 8443,
|
|
Protocol: model.MTProto,
|
|
Enable: true,
|
|
Tag: "mt-sub",
|
|
Settings: `{"fakeTlsDomain":"www.cloudflare.com","clients":[{"email":"u@mt","enable":true,"subId":"submt","secret":"` + mtprotoTestSecret + `"}]}`,
|
|
}
|
|
if err := db.Create(in).Error; err != nil {
|
|
t.Fatalf("create inbound: %v", err)
|
|
}
|
|
rec := &model.ClientRecord{Email: "u@mt", SubID: "submt", Enable: true, Secret: mtprotoTestSecret}
|
|
if err := db.Create(rec).Error; err != nil {
|
|
t.Fatalf("create client: %v", err)
|
|
}
|
|
if err := db.Create(&model.ClientInbound{ClientId: rec.Id, InboundId: in.Id}).Error; err != nil {
|
|
t.Fatalf("create link: %v", err)
|
|
}
|
|
|
|
s := &SubService{}
|
|
inbounds, err := s.getInboundsBySubId("submt")
|
|
if err != nil {
|
|
t.Fatalf("getInboundsBySubId: %v", err)
|
|
}
|
|
if len(inbounds) != 1 || inbounds[0].Id != in.Id {
|
|
t.Fatalf("mtproto inbound not returned for subId: %+v", inbounds)
|
|
}
|
|
|
|
links, emails, _, _, err := s.GetSubs("submt", "sub.example.com")
|
|
if err != nil {
|
|
t.Fatalf("GetSubs: %v", err)
|
|
}
|
|
if len(links) != 1 || len(emails) != 1 || emails[0] != "u@mt" {
|
|
t.Fatalf("subscription did not emit the mtproto client: links=%v emails=%v", links, emails)
|
|
}
|
|
if !strings.HasPrefix(links[0], "tg://proxy") || !strings.Contains(links[0], "secret="+mtprotoTestSecret) {
|
|
t.Fatalf("subscription link is not a tg://proxy carrying the client secret: %q", links[0])
|
|
}
|
|
}
|