diff --git a/internal/sub/external_config.go b/internal/sub/external_config.go index 165d5a083..05131747b 100644 --- a/internal/sub/external_config.go +++ b/internal/sub/external_config.go @@ -36,6 +36,9 @@ type expandedLink struct { // getClientExternalLinksBySubId returns active rows with owner state attached. // Consumers keep inactive owners as metadata but omit their link values. func (s *SubService) getClientExternalLinksBySubId(subId string) ([]externalLinkEntry, error) { + if strings.TrimSpace(subId) == "" { + return nil, nil + } db := database.GetDB() var recs []model.ClientRecord if err := db.Where("sub_id = ?", subId).Find(&recs).Error; err != nil { diff --git a/internal/sub/inactive_external_sub_test.go b/internal/sub/inactive_external_sub_test.go index c6afb1040..072f04348 100644 --- a/internal/sub/inactive_external_sub_test.go +++ b/internal/sub/inactive_external_sub_test.go @@ -113,3 +113,25 @@ func TestInactiveExternalOnlySubRemainsKnownWithoutExposingLinks(t *testing.T) { }) } } + +// Clients stored without a subId share no subscription; a blank key must not +// select all of them. The routes never pass one, in-process callers can. +func TestBlankSubIdResolvesToNothing(t *testing.T) { + for _, subId := range []string{"", " "} { + seedSubDB(t) + seedSubInbound(t, subId, "one", 4431, 1, `{"network":"tcp","security":"none"}`) + seedSubInbound(t, subId, "two", 4432, 2, `{"network":"tcp","security":"none"}`) + seedInactiveExternalOnlySub(t, subId, "ext@e", true, time.Now().Add(time.Hour).UnixMilli()) + + links, emails, _, _, err := NewSubService("").GetSubs(subId, "req.example.com") + if err != nil || len(links) != 0 || len(emails) != 0 { + t.Errorf("GetSubs(%q) = %v, %v, %v; want nothing", subId, links, emails, err) + } + if out, _, err := NewSubJsonService("", "", "", "", NewSubService("")).GetJson(subId, "req.example.com", false); err != nil || out != "" { + t.Errorf("GetJson(%q) = %q, %v; want nothing", subId, out, err) + } + if out, _, err := NewSubClashService(false, "", false, NewSubService("")).GetClash(subId, "req.example.com"); err != nil || out != "" { + t.Errorf("GetClash(%q) = %q, %v; want nothing", subId, out, err) + } + } +} diff --git a/internal/sub/service.go b/internal/sub/service.go index c1ed7bb93..5f9c8cd09 100644 --- a/internal/sub/service.go +++ b/internal/sub/service.go @@ -737,8 +737,12 @@ func subscriptionExpiryFromClient(nowMs, expiryTime int64) int64 { return 0 } -// getInboundsBySubId loads the subscriber's inbounds; hosts are read per request, never cached. +// getInboundsBySubId loads the subscriber's inbounds, hosts read per request; a blank subId +// would match every client stored without one, so it names no subscription. func (s *SubService) getInboundsBySubId(subId string) ([]*model.Inbound, error) { + if strings.TrimSpace(subId) == "" { + return nil, nil + } inbounds, err := s.loadInboundsBySubId(subId) if err != nil { return nil, err diff --git a/internal/web/service/tgbot/tgbot_client.go b/internal/web/service/tgbot/tgbot_client.go index 2edb1c874..5e9868781 100644 --- a/internal/web/service/tgbot/tgbot_client.go +++ b/internal/web/service/tgbot/tgbot_client.go @@ -232,6 +232,11 @@ func (t *Tgbot) clientSubLinks(email, subURL string) ([]string, error) { if err != nil || client == nil { return nil, errors.New("client not found") } + // A client stored without a subId has no subscription to look up; build + // its own links by email, as the panel's client page does. + if strings.TrimSpace(client.SubID) == "" { + return t.inboundService.GetAllClientLinks(u.Hostname(), email) + } return t.inboundService.GetSubLinks(u.Hostname(), client.SubID) } diff --git a/internal/web/service/tgbot/tgbot_individual_links_test.go b/internal/web/service/tgbot/tgbot_individual_links_test.go index b0246a3b2..c8acdbbb9 100644 --- a/internal/web/service/tgbot/tgbot_individual_links_test.go +++ b/internal/web/service/tgbot/tgbot_individual_links_test.go @@ -87,3 +87,38 @@ func TestIndividualLinksDoNotNeedAResolvableHost(t *testing.T) { t.Fatalf("bot sent %q, want the client's vless link", got) } } + +// A client stored without a subId has no subscription; the blank value must not +// be looked up as one, or it selects every other client stored without a subId. +func TestIndividualLinksOfBlankSubIdClientExposeNoOtherClient(t *testing.T) { + sent := recordingBotServer(t) + dbtest.InitDB(t, filepath.Join(t.TempDir(), "x-ui.db")) + service.RegisterSubLinkProvider(sub.NewLinkProvider()) + + const ownerUUID = "11111111-2222-4333-8444-555555555555" + const otherUUID = "99999999-2222-4333-8444-555555555555" + inboundSvc := service.InboundService{} + _, _, err := inboundSvc.AddInbound(&model.Inbound{ + UserId: 1, Tag: "in-443", Enable: true, Listen: "203.0.113.5", Port: 443, + Protocol: model.VLESS, Remark: "in", StreamSettings: `{"network":"tcp","security":"none"}`, + Settings: `{"clients":[{"id":"` + ownerUUID + `","email":"` + ownerMail + `","tgId":4242,"enable":true},` + + `{"id":"` + otherUUID + `","email":"other@x","enable":true}],"decryption":"none"}`, + }) + if err != nil { + t.Fatalf("AddInbound: %v", err) + } + + origRunning := isRunning + t.Cleanup(func() { isRunning = origRunning }) + isRunning = true + + tapClientLinks(t, &Tgbot{}, ownerTgID, "client_individual_links "+ownerMail) + + got := strings.Join(sent(), "\n") + if !strings.Contains(got, "vless://"+ownerUUID+"@203.0.113.5:443") { + t.Errorf("bot sent %q, want the client's own vless link", got) + } + if strings.Contains(got, otherUUID) { + t.Errorf("bot sent another client's credentials to %s: %q", ownerMail, got) + } +}