mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-10-01 11:42:07 +03:00
fix(hosts): advertise Hosts in every WireGuard, AmneziaWG and TUIC config
Invariant: an inbound's enabled Hosts are the endpoints every client config for it advertises, whichever surface renders that config. WireGuard and AmneziaWG broke it. Their raw generators ignored the externalProxy entries Hosts are injected as and always emitted resolveInboundAddress, so the raw subscription, the sub page .conf, the clients links API and "export all links" gave out the panel address while the JSON and Clash formats of the same inbound used the Host. advertisedEndpoints now states the fan-out once for mtproto, wireguard and amneziawg. The browser-built configs had the same gap. The Clients page WireGuard and AmneziaWG config blocks and QR panels, and its TUIC Clash config, used the panel hostname next to server links that already used Hosts; the Inbounds page peer configs, QR and export ignored them too. withMtprotoHostEndpoints becomes withHostEndpoints over a shared hostEndpointsFor mirror of the backend, the tunnel fan-outs render one config per Host, and the clients page waits for the hosts list the way the inbounds page does, so an empty list means "no hosts" rather than "not loaded yet".
This commit is contained in:
@@ -56,6 +56,24 @@ func (s *SubService) inboundDefaultEndpoint(inbound *model.Inbound) ShareEndpoin
|
||||
}
|
||||
}
|
||||
|
||||
// advertisedEndpoints is every endpoint a stream-less link (mtproto, wireguard,
|
||||
// amneziawg) must fan out over: the externalProxy/Host entries, else the default.
|
||||
func (s *SubService) advertisedEndpoints(inbound *model.Inbound) []ShareEndpoint {
|
||||
stream := unmarshalStreamSettings(inbound.StreamSettings)
|
||||
if externalProxies, ok := stream["externalProxy"].([]any); ok {
|
||||
endpoints := make([]ShareEndpoint, 0, len(externalProxies))
|
||||
for _, raw := range externalProxies {
|
||||
if ep, ok := raw.(map[string]any); ok {
|
||||
endpoints = append(endpoints, externalProxyToEndpoint(ep))
|
||||
}
|
||||
}
|
||||
if len(endpoints) > 0 {
|
||||
return endpoints
|
||||
}
|
||||
}
|
||||
return []ShareEndpoint{s.inboundDefaultEndpoint(inbound)}
|
||||
}
|
||||
|
||||
// applyEndpointTLSParams applies an endpoint's TLS overrides onto a URL-param
|
||||
// map. External-proxy endpoints delegate to the unchanged helper; host/default
|
||||
// endpoints carry no override yet (Phase 4).
|
||||
|
||||
+17
-19
@@ -936,7 +936,6 @@ func (s *SubService) genWireguardLink(inbound *model.Inbound, email string) stri
|
||||
}
|
||||
client := &resolved
|
||||
|
||||
link := fmt.Sprintf("wireguard://%s@%s", encodeUserinfo(client.PrivateKey), joinHostPort(s.resolveInboundAddress(inbound), inbound.Port))
|
||||
params := make(map[string]string)
|
||||
if secretKey != "" {
|
||||
if pub, err := wgutil.PublicKeyFromPrivate(secretKey); err == nil {
|
||||
@@ -958,7 +957,13 @@ func (s *SubService) genWireguardLink(inbound *model.Inbound, email string) stri
|
||||
if ka := client.KeepAliveSeconds(); ka > 0 {
|
||||
params["keepalive"] = strconv.Itoa(ka)
|
||||
}
|
||||
return buildLinkWithParams(link, params, s.genRemark(inbound, email, "", ""))
|
||||
endpoints := s.advertisedEndpoints(inbound)
|
||||
links := make([]string, 0, len(endpoints))
|
||||
for _, e := range endpoints {
|
||||
link := fmt.Sprintf("wireguard://%s@%s", encodeUserinfo(client.PrivateKey), joinHostPort(e.Address, e.Port))
|
||||
links = append(links, buildLinkWithParams(link, params, s.endpointRemark(inbound, email, e.ep, "")))
|
||||
}
|
||||
return strings.Join(links, "\n")
|
||||
}
|
||||
|
||||
// amneziaWGHeaderOrDefault mirrors the frontend's amneziaWGHLine: AmneziaWG's
|
||||
@@ -1084,11 +1089,16 @@ func (s *SubService) genAmneziaWGLink(inbound *model.Inbound, email string) stri
|
||||
}
|
||||
client := &resolved
|
||||
|
||||
text := amneziaWGConfigText(server, client, s.resolveInboundAddress(inbound), inbound.Port, s.genRemark(inbound, email, "", ""))
|
||||
if text == "" {
|
||||
return ""
|
||||
endpoints := s.advertisedEndpoints(inbound)
|
||||
links := make([]string, 0, len(endpoints))
|
||||
for _, e := range endpoints {
|
||||
text := amneziaWGConfigText(server, client, e.Address, e.Port, s.endpointRemark(inbound, email, e.ep, ""))
|
||||
if text == "" {
|
||||
continue
|
||||
}
|
||||
links = append(links, "vpn://"+base64.RawURLEncoding.EncodeToString([]byte(text)))
|
||||
}
|
||||
return "vpn://" + base64.RawURLEncoding.EncodeToString([]byte(text))
|
||||
return strings.Join(links, "\n")
|
||||
}
|
||||
|
||||
// genMtprotoLink builds one Telegram link per advertised endpoint with the client's FakeTLS secret.
|
||||
@@ -1101,19 +1111,7 @@ func (s *SubService) genMtprotoLink(inbound *model.Inbound, email string) string
|
||||
if !ok || resolved.Secret == "" {
|
||||
return ""
|
||||
}
|
||||
endpoints := []ShareEndpoint{s.inboundDefaultEndpoint(inbound)}
|
||||
stream := unmarshalStreamSettings(inbound.StreamSettings)
|
||||
if externalProxies, ok := stream["externalProxy"].([]any); ok && len(externalProxies) > 0 {
|
||||
overrides := make([]ShareEndpoint, 0, len(externalProxies))
|
||||
for _, raw := range externalProxies {
|
||||
if ep, ok := raw.(map[string]any); ok {
|
||||
overrides = append(overrides, externalProxyToEndpoint(ep))
|
||||
}
|
||||
}
|
||||
if len(overrides) > 0 {
|
||||
endpoints = overrides
|
||||
}
|
||||
}
|
||||
endpoints := s.advertisedEndpoints(inbound)
|
||||
links := make([]string, 0, len(endpoints))
|
||||
for _, endpoint := range endpoints {
|
||||
links = append(links, buildLinkWithParams("tg://proxy", map[string]string{
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
package sub
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
)
|
||||
|
||||
func seedTunnelSubInbound(t *testing.T, protocol model.Protocol, tag, subID, email, settings string, port int) *model.Inbound {
|
||||
t.Helper()
|
||||
db := database.GetDB()
|
||||
ib := &model.Inbound{
|
||||
UserId: 1, Tag: tag, Enable: true, Listen: "203.0.113.5", Port: port,
|
||||
Protocol: protocol, Remark: tag, Settings: settings,
|
||||
}
|
||||
if err := db.Create(ib).Error; err != nil {
|
||||
t.Fatalf("create %s: %v", tag, err)
|
||||
}
|
||||
rec := &model.ClientRecord{Email: email, SubID: subID, Enable: true}
|
||||
if err := db.Create(rec).Error; err != nil {
|
||||
t.Fatalf("create client: %v", err)
|
||||
}
|
||||
if err := db.Create(&model.ClientInbound{ClientId: rec.Id, InboundId: ib.Id}).Error; err != nil {
|
||||
t.Fatalf("link client: %v", err)
|
||||
}
|
||||
return ib
|
||||
}
|
||||
|
||||
// A Host on a WireGuard inbound must replace the advertised endpoint; the raw
|
||||
// generator used to ignore it and always emit the panel's own address.
|
||||
func TestGetSubs_WireGuardAdvertisesHostEndpoints(t *testing.T) {
|
||||
initSubDB(t)
|
||||
serverPriv, _ := mustWireguardKeypair(t)
|
||||
clientPriv, _ := mustWireguardKeypair(t)
|
||||
const email, subID = "alice@wg", "sub-wg-hosts"
|
||||
settings := fmt.Sprintf(`{"secretKey":%q,"clients":[{"email":%q,"privateKey":%q,"allowedIPs":["10.0.0.2/32"],"enable":true}]}`,
|
||||
serverPriv, email, clientPriv)
|
||||
ib := seedTunnelSubInbound(t, model.WireGuard, "wg-in", subID, email, settings, 51820)
|
||||
seedHost(t, &model.Host{InboundId: ib.Id, SortOrder: 2, Remark: "CDN-B", Address: "wg2.example.com"})
|
||||
seedHost(t, &model.Host{InboundId: ib.Id, SortOrder: 1, Remark: "CDN-A", Address: "wg.example.com", Port: 443})
|
||||
|
||||
links, _, _, _, err := NewSubService("").GetSubs(subID, "sub.example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("GetSubs: %v", err)
|
||||
}
|
||||
parts := splitLinkLines(strings.Join(links, "\n"))
|
||||
want := []struct{ host, remark string }{
|
||||
{"wg.example.com:443", "wg-in-CDN-A-" + email},
|
||||
{"wg2.example.com:51820", "wg-in-CDN-B-" + email},
|
||||
}
|
||||
if len(parts) != len(want) {
|
||||
t.Fatalf("links = %d, want %d: %v", len(parts), len(want), parts)
|
||||
}
|
||||
for i, w := range want {
|
||||
u := parseWireguardSubLink(t, parts[i])
|
||||
if u.Host != w.host || u.Fragment != w.remark {
|
||||
t.Fatalf("link %d = %s#%s, want %s#%s", i, u.Host, u.Fragment, w.host, w.remark)
|
||||
}
|
||||
if u.User.Username() != clientPriv {
|
||||
t.Fatalf("link %d private key = %q, want the client's", i, u.User.Username())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The AmneziaWG vpn:// payload carries the endpoint inside its .conf text, so a
|
||||
// Host must reach the Endpoint line and the remark comment, not only the URL.
|
||||
func TestGetSubs_AmneziaWGAdvertisesHostEndpoint(t *testing.T) {
|
||||
initSubDB(t)
|
||||
serverPriv, serverPub := mustWireguardKeypair(t)
|
||||
clientPriv, _ := mustWireguardKeypair(t)
|
||||
const email, subID = "alice@awg", "sub-awg-hosts"
|
||||
settings := fmt.Sprintf(`{"server":{"privateKey":%q,"publicKey":%q,"mtu":1420},"clients":[{"email":%q,"privateKey":%q,"allowedIPs":["10.8.0.2/32"],"enable":true}]}`,
|
||||
serverPriv, serverPub, email, clientPriv)
|
||||
ib := seedTunnelSubInbound(t, model.AmneziaWG, "awg-in", subID, email, settings, 51821)
|
||||
seedHost(t, &model.Host{InboundId: ib.Id, SortOrder: 1, Remark: "CDN", Address: "awg.example.com", Port: 8443})
|
||||
|
||||
links, _, _, _, err := NewSubService("").GetSubs(subID, "sub.example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("GetSubs: %v", err)
|
||||
}
|
||||
parts := splitLinkLines(strings.Join(links, "\n"))
|
||||
if len(parts) != 1 {
|
||||
t.Fatalf("links = %d, want 1: %v", len(parts), parts)
|
||||
}
|
||||
conf := decodeAmneziaWGSubLink(t, parts[0])
|
||||
for _, line := range []string{"Endpoint = awg.example.com:8443", "# awg-in-CDN-" + email, "PrivateKey = " + clientPriv} {
|
||||
if !strings.Contains(conf, line) {
|
||||
t.Fatalf("config missing %q\n%s", line, conf)
|
||||
}
|
||||
}
|
||||
if strings.Contains(conf, "203.0.113.5") {
|
||||
t.Fatalf("config still advertises the inbound address\n%s", conf)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user