mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-08-13 06:40:59 +00:00
feat(wireguard): multi-client support
WireGuard inbounds now manage per-client peers using xray-core's native WireGuard users (AddUser/RemoveUser). Each client lives in settings.clients (canonical, like every other protocol) and is projected to peers[] only when emitting the xray config, at level 0 so the dispatcher's per-user traffic/online counters work with no extra plumbing. Backend: internal/util/wireguard gains KeyToHex (base64 to hex for the gRPC path), PublicKeyFromPrivate and GenerateWireguardPSK; xray/api.go builds a wireguard account in AddUser with hex keys (RemoveUser already worked); client CRUD generates a keypair and allocates a unique tunnel address per client and never rotates keys on edit; an idempotent migration converts legacy settings.peers into managed clients; WireGuard is included in the raw subscription. Frontend: WireGuard in the add-client modal with keys on the credential tab, client schema, per-client QR/link/.conf, inbound form reduced to server settings; i18n added across 13 locales. Fix: guard the settings[clients] assertion in add/update so a legacy WireGuard inbound stored without a clients key no longer panics.
This commit is contained in:
+44
-1
@@ -21,6 +21,7 @@ import (
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/util/common"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/util/random"
|
||||
wgutil "github.com/mhsanaei/3x-ui/v3/internal/util/wireguard"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/xray"
|
||||
)
|
||||
@@ -367,7 +368,7 @@ func (s *SubService) getInboundsBySubId(subId string) ([]*model.Inbound, error)
|
||||
JOIN client_inbounds ON client_inbounds.inbound_id = inbounds.id
|
||||
JOIN clients ON clients.id = client_inbounds.client_id
|
||||
WHERE
|
||||
inbounds.protocol in ('vmess','vless','trojan','shadowsocks','hysteria')
|
||||
inbounds.protocol in ('vmess','vless','trojan','shadowsocks','hysteria','wireguard')
|
||||
AND clients.sub_id = ? AND inbounds.enable = ?
|
||||
)`, subId, true).Order("sub_sort_index ASC").Order("id ASC").Find(&inbounds).Error
|
||||
if err != nil {
|
||||
@@ -501,10 +502,52 @@ func (s *SubService) GetLink(inbound *model.Inbound, email string) string {
|
||||
return s.genHysteriaLink(inbound, email)
|
||||
case "mtproto":
|
||||
return s.genMtprotoLink(inbound, email)
|
||||
case "wireguard":
|
||||
return s.genWireguardLink(inbound, email)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// genWireguardLink builds a per-client wireguard:// share link mirroring the
|
||||
// frontend genWireguardLink: the client's private key is the userinfo, the
|
||||
// server public key (derived from the inbound secretKey) and the client's
|
||||
// tunnel address ride in the query. Returns "" when the client has no key.
|
||||
func (s *SubService) genWireguardLink(inbound *model.Inbound, email string) string {
|
||||
if inbound.Protocol != model.WireGuard {
|
||||
return ""
|
||||
}
|
||||
settings := map[string]any{}
|
||||
_ = json.Unmarshal([]byte(inbound.Settings), &settings)
|
||||
secretKey, _ := settings["secretKey"].(string)
|
||||
|
||||
clients, _ := s.inboundService.GetClients(inbound)
|
||||
var client *model.Client
|
||||
for i := range clients {
|
||||
if clients[i].Email == email {
|
||||
client = &clients[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if client == nil || client.PrivateKey == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
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 {
|
||||
params["publickey"] = pub
|
||||
}
|
||||
}
|
||||
if len(client.AllowedIPs) > 0 && client.AllowedIPs[0] != "" {
|
||||
params["address"] = client.AllowedIPs[0]
|
||||
}
|
||||
if mtu, ok := settings["mtu"].(float64); ok && mtu > 0 {
|
||||
params["mtu"] = strconv.Itoa(int(mtu))
|
||||
}
|
||||
return buildLinkWithParams(link, params, s.genRemark(inbound, email, "", ""))
|
||||
}
|
||||
|
||||
// genMtprotoLink builds a Telegram proxy deep link for an mtproto inbound:
|
||||
func (s *SubService) genMtprotoLink(inbound *model.Inbound, _ string) string {
|
||||
if inbound.Protocol != model.MTProto {
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
package sub
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
wgutil "github.com/mhsanaei/3x-ui/v3/internal/util/wireguard"
|
||||
)
|
||||
|
||||
func TestGenWireguardLinkFields(t *testing.T) {
|
||||
serverPriv, serverPub, err := wgutil.GenerateWireguardKeypair()
|
||||
if err != nil {
|
||||
t.Fatalf("keypair: %v", err)
|
||||
}
|
||||
clientPriv, _, err := wgutil.GenerateWireguardKeypair()
|
||||
if err != nil {
|
||||
t.Fatalf("client keypair: %v", err)
|
||||
}
|
||||
|
||||
inbound := &model.Inbound{
|
||||
Listen: "203.0.113.7",
|
||||
Port: 51820,
|
||||
Protocol: model.WireGuard,
|
||||
Remark: "wg-sub",
|
||||
Settings: `{"secretKey":"` + serverPriv + `","mtu":1420,"clients":[{"email":"user","privateKey":"` + clientPriv + `","allowedIPs":["10.0.0.2/32"],"keepAlive":25}]}`,
|
||||
}
|
||||
|
||||
s := &SubService{}
|
||||
link := s.genWireguardLink(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 != "wireguard" {
|
||||
t.Fatalf("scheme = %q, want wireguard", u.Scheme)
|
||||
}
|
||||
if u.Host != "203.0.113.7:51820" {
|
||||
t.Fatalf("host = %q, want 203.0.113.7:51820", u.Host)
|
||||
}
|
||||
if u.User.Username() != clientPriv {
|
||||
t.Fatalf("userinfo = %q, want client private key %q", u.User.Username(), clientPriv)
|
||||
}
|
||||
q := u.Query()
|
||||
if q.Get("publickey") != serverPub {
|
||||
t.Fatalf("publickey = %q, want server public key %q", q.Get("publickey"), serverPub)
|
||||
}
|
||||
if q.Get("address") != "10.0.0.2/32" {
|
||||
t.Fatalf("address = %q, want 10.0.0.2/32", q.Get("address"))
|
||||
}
|
||||
if q.Get("mtu") != "1420" {
|
||||
t.Fatalf("mtu = %q, want 1420", q.Get("mtu"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenWireguardLinkWrongProtocol(t *testing.T) {
|
||||
s := &SubService{}
|
||||
vless := &model.Inbound{Protocol: model.VLESS, Settings: `{"clients":[{"email":"user"}]}`}
|
||||
if got := s.genWireguardLink(vless, "user"); got != "" {
|
||||
t.Fatalf("wrong protocol should yield empty link, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenWireguardLinkNoKey(t *testing.T) {
|
||||
s := &SubService{}
|
||||
inbound := &model.Inbound{
|
||||
Protocol: model.WireGuard,
|
||||
Port: 51820,
|
||||
Settings: `{"secretKey":"x","clients":[{"email":"user"}]}`,
|
||||
}
|
||||
if got := s.genWireguardLink(inbound, "user"); got != "" {
|
||||
t.Fatalf("client without private key should yield empty link, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetInboundsBySubIdIncludesWireguard(t *testing.T) {
|
||||
initSubDB(t)
|
||||
db := database.GetDB()
|
||||
|
||||
in := &model.Inbound{Port: 51820, Protocol: model.WireGuard, Enable: true, Tag: "wg-sub", Settings: `{"secretKey":"x","clients":[]}`}
|
||||
if err := db.Create(in).Error; err != nil {
|
||||
t.Fatalf("create inbound: %v", err)
|
||||
}
|
||||
rec := &model.ClientRecord{Email: "u@wg", SubID: "subwg", 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: in.Id}).Error; err != nil {
|
||||
t.Fatalf("create link: %v", err)
|
||||
}
|
||||
|
||||
s := &SubService{}
|
||||
inbounds, err := s.getInboundsBySubId("subwg")
|
||||
if err != nil {
|
||||
t.Fatalf("getInboundsBySubId: %v", err)
|
||||
}
|
||||
if len(inbounds) != 1 || inbounds[0].Id != in.Id {
|
||||
t.Fatalf("wireguard inbound not returned for subId: %+v", inbounds)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user