mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-08-28 22:17:13 +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:
+102
-29
@@ -18,6 +18,7 @@ import (
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/config"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/util/common"
|
||||
wgutil "github.com/mhsanaei/3x-ui/v3/internal/util/wireguard"
|
||||
|
||||
"github.com/xtls/xray-core/app/proxyman/command"
|
||||
routerService "github.com/xtls/xray-core/app/router/command"
|
||||
@@ -32,6 +33,7 @@ import (
|
||||
"github.com/xtls/xray-core/proxy/trojan"
|
||||
"github.com/xtls/xray-core/proxy/vless"
|
||||
"github.com/xtls/xray-core/proxy/vmess"
|
||||
wireguard "github.com/xtls/xray-core/proxy/wireguard"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
@@ -408,40 +410,62 @@ func ensureXrayAssetLocation() {
|
||||
}
|
||||
}
|
||||
|
||||
// AddUser adds a user to an inbound in the Xray core using the specified protocol and user data.
|
||||
func (x *XrayAPI) AddUser(Protocol string, inboundTag string, user map[string]any) error {
|
||||
userEmail, err := getRequiredUserString(user, "email")
|
||||
if err != nil {
|
||||
return err
|
||||
// collectStringSlice normalizes a JSON-decoded value into a slice of non-empty
|
||||
// strings, accepting both []string (typed maps) and []any (json.Unmarshal output).
|
||||
func collectStringSlice(value any) []string {
|
||||
switch v := value.(type) {
|
||||
case []string:
|
||||
out := make([]string, 0, len(v))
|
||||
for _, s := range v {
|
||||
if s != "" {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
return out
|
||||
case []any:
|
||||
out := make([]string, 0, len(v))
|
||||
for _, e := range v {
|
||||
if s, ok := e.(string); ok && s != "" {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
return out
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
var account *serial.TypedMessage
|
||||
switch Protocol {
|
||||
// buildUserAccount constructs the typed xray account for a user of the given
|
||||
// protocol. It returns (nil, nil) for protocols that cannot be altered live so
|
||||
// callers skip the AlterInbound call. WireGuard keys must be converted to the
|
||||
// hex form xray's wireguard proxy expects (its ParseKey uses hex.DecodeString),
|
||||
// unlike the file-config path which accepts base64 and converts internally.
|
||||
func buildUserAccount(protocolName string, user map[string]any) (*serial.TypedMessage, error) {
|
||||
switch protocolName {
|
||||
case "vmess":
|
||||
userID, err := getRequiredUserString(user, "id")
|
||||
if err != nil {
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
|
||||
account = serial.ToTypedMessage(&vmess.Account{
|
||||
return serial.ToTypedMessage(&vmess.Account{
|
||||
Id: userID,
|
||||
})
|
||||
}), nil
|
||||
case "vless":
|
||||
userID, err := getRequiredUserString(user, "id")
|
||||
if err != nil {
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
|
||||
userFlow, err := getOptionalUserString(user, "flow")
|
||||
if err != nil {
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
|
||||
vlessAccount := &vless.Account{
|
||||
Id: userID,
|
||||
Flow: userFlow,
|
||||
}
|
||||
// Add testseed if provided
|
||||
if testseedVal, ok := user["testseed"]; ok {
|
||||
if testseedArr, ok := testseedVal.([]any); ok && len(testseedArr) >= 4 {
|
||||
testseed := make([]uint32, len(testseedArr))
|
||||
@@ -455,7 +479,6 @@ func (x *XrayAPI) AddUser(Protocol string, inboundTag string, user map[string]an
|
||||
vlessAccount.Testseed = testseedArr
|
||||
}
|
||||
}
|
||||
// Add testpre if provided (for outbound, but can be in user for compatibility)
|
||||
if testpreVal, ok := user["testpre"]; ok {
|
||||
if testpre, ok := testpreVal.(float64); ok && testpre > 0 {
|
||||
vlessAccount.Testpre = uint32(testpre)
|
||||
@@ -463,25 +486,25 @@ func (x *XrayAPI) AddUser(Protocol string, inboundTag string, user map[string]an
|
||||
vlessAccount.Testpre = testpre
|
||||
}
|
||||
}
|
||||
account = serial.ToTypedMessage(vlessAccount)
|
||||
return serial.ToTypedMessage(vlessAccount), nil
|
||||
case "trojan":
|
||||
password, err := getRequiredUserString(user, "password")
|
||||
if err != nil {
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
|
||||
account = serial.ToTypedMessage(&trojan.Account{
|
||||
return serial.ToTypedMessage(&trojan.Account{
|
||||
Password: password,
|
||||
})
|
||||
}), nil
|
||||
case "shadowsocks":
|
||||
cipher, err := getOptionalUserString(user, "cipher")
|
||||
if err != nil {
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
|
||||
password, err := getRequiredUserString(user, "password")
|
||||
if err != nil {
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var ssCipherType shadowsocks.CipherType
|
||||
@@ -497,25 +520,75 @@ func (x *XrayAPI) AddUser(Protocol string, inboundTag string, user map[string]an
|
||||
}
|
||||
|
||||
if ssCipherType != shadowsocks.CipherType_NONE {
|
||||
account = serial.ToTypedMessage(&shadowsocks.Account{
|
||||
return serial.ToTypedMessage(&shadowsocks.Account{
|
||||
Password: password,
|
||||
CipherType: ssCipherType,
|
||||
})
|
||||
} else {
|
||||
account = serial.ToTypedMessage(&shadowsocks_2022.Account{
|
||||
Key: password,
|
||||
})
|
||||
}), nil
|
||||
}
|
||||
return serial.ToTypedMessage(&shadowsocks_2022.Account{
|
||||
Key: password,
|
||||
}), nil
|
||||
case "hysteria":
|
||||
auth, err := getRequiredUserString(user, "auth")
|
||||
if err != nil {
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
|
||||
account = serial.ToTypedMessage(&hysteriaAccount.Account{
|
||||
return serial.ToTypedMessage(&hysteriaAccount.Account{
|
||||
Auth: auth,
|
||||
})
|
||||
}), nil
|
||||
case "wireguard":
|
||||
pubB64, err := getRequiredUserString(user, "publicKey")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pubHex, err := wgutil.KeyToHex(pubB64)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("wireguard publicKey: %w", err)
|
||||
}
|
||||
|
||||
pskB64, err := getOptionalUserString(user, "preSharedKey")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pskHex, err := wgutil.KeyToHex(pskB64)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("wireguard preSharedKey: %w", err)
|
||||
}
|
||||
|
||||
allowed := collectStringSlice(user["allowedIPs"])
|
||||
if len(allowed) == 0 {
|
||||
return nil, common.NewError("wireguard: allowedIPs required")
|
||||
}
|
||||
|
||||
keepAlive, err := getOptionalUserString(user, "keepAlive")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return serial.ToTypedMessage(&wireguard.PeerConfig{
|
||||
PublicKey: pubHex,
|
||||
PreSharedKey: pskHex,
|
||||
AllowedIps: allowed,
|
||||
KeepAlive: keepAlive,
|
||||
}), nil
|
||||
default:
|
||||
return nil, nil
|
||||
}
|
||||
}
|
||||
|
||||
// AddUser adds a user to an inbound in the Xray core using the specified protocol and user data.
|
||||
func (x *XrayAPI) AddUser(Protocol string, inboundTag string, user map[string]any) error {
|
||||
userEmail, err := getRequiredUserString(user, "email")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
account, err := buildUserAccount(Protocol, user)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if account == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
package xray
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"testing"
|
||||
|
||||
wireguard "github.com/xtls/xray-core/proxy/wireguard"
|
||||
"google.golang.org/protobuf/proto"
|
||||
)
|
||||
|
||||
func b64Key(seed byte) string {
|
||||
raw := make([]byte, 32)
|
||||
for i := range raw {
|
||||
raw[i] = seed + byte(i)
|
||||
}
|
||||
return base64.StdEncoding.EncodeToString(raw)
|
||||
}
|
||||
|
||||
func decodeWgAccount(t *testing.T, user map[string]any) *wireguard.PeerConfig {
|
||||
t.Helper()
|
||||
tm, err := buildUserAccount("wireguard", user)
|
||||
if err != nil {
|
||||
t.Fatalf("buildUserAccount: %v", err)
|
||||
}
|
||||
if tm == nil {
|
||||
t.Fatal("buildUserAccount returned nil account for wireguard")
|
||||
}
|
||||
var pc wireguard.PeerConfig
|
||||
if err := proto.Unmarshal(tm.Value, &pc); err != nil {
|
||||
t.Fatalf("unmarshal PeerConfig: %v", err)
|
||||
}
|
||||
return &pc
|
||||
}
|
||||
|
||||
func assertHexKey(t *testing.T, label, value string) {
|
||||
t.Helper()
|
||||
if len(value) != 64 {
|
||||
t.Fatalf("%s = %q, want 64-char hex", label, value)
|
||||
}
|
||||
if raw, err := hex.DecodeString(value); err != nil || len(raw) != 32 {
|
||||
t.Fatalf("%s is not a 32-byte hex key: err=%v len=%d", label, err, len(raw))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildUserAccountWireGuardHexConversion(t *testing.T) {
|
||||
pub := b64Key(1)
|
||||
psk := b64Key(100)
|
||||
user := map[string]any{
|
||||
"email": "alice@example.test",
|
||||
"publicKey": pub,
|
||||
"preSharedKey": psk,
|
||||
"allowedIPs": []any{"10.0.0.2/32", "fd00::2/128"},
|
||||
"keepAlive": "25",
|
||||
}
|
||||
|
||||
pc := decodeWgAccount(t, user)
|
||||
assertHexKey(t, "PublicKey", pc.PublicKey)
|
||||
assertHexKey(t, "PreSharedKey", pc.PreSharedKey)
|
||||
|
||||
wantPubHex, _ := hex.DecodeString(pc.PublicKey)
|
||||
gotPub, _ := base64.StdEncoding.DecodeString(pub)
|
||||
if string(wantPubHex) != string(gotPub) {
|
||||
t.Fatal("PublicKey hex does not match the base64 input bytes")
|
||||
}
|
||||
|
||||
if len(pc.AllowedIps) != 2 || pc.AllowedIps[0] != "10.0.0.2/32" || pc.AllowedIps[1] != "fd00::2/128" {
|
||||
t.Fatalf("AllowedIps = %v, want [10.0.0.2/32 fd00::2/128]", pc.AllowedIps)
|
||||
}
|
||||
if pc.KeepAlive != "25" {
|
||||
t.Fatalf("KeepAlive = %q, want %q", pc.KeepAlive, "25")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildUserAccountWireGuardNoPSK(t *testing.T) {
|
||||
user := map[string]any{
|
||||
"email": "bob@example.test",
|
||||
"publicKey": b64Key(2),
|
||||
"allowedIPs": []string{"10.0.0.3/32"},
|
||||
}
|
||||
pc := decodeWgAccount(t, user)
|
||||
if pc.PreSharedKey != "" {
|
||||
t.Fatalf("PreSharedKey = %q, want empty", pc.PreSharedKey)
|
||||
}
|
||||
if pc.KeepAlive != "" {
|
||||
t.Fatalf("KeepAlive = %q, want empty", pc.KeepAlive)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildUserAccountWireGuardMissingPublicKey(t *testing.T) {
|
||||
user := map[string]any{
|
||||
"email": "c@example.test",
|
||||
"allowedIPs": []any{"10.0.0.4/32"},
|
||||
}
|
||||
if _, err := buildUserAccount("wireguard", user); err == nil {
|
||||
t.Fatal("expected error for missing publicKey")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildUserAccountWireGuardMissingAllowedIPs(t *testing.T) {
|
||||
user := map[string]any{
|
||||
"email": "d@example.test",
|
||||
"publicKey": b64Key(3),
|
||||
}
|
||||
if _, err := buildUserAccount("wireguard", user); err == nil {
|
||||
t.Fatal("expected error for missing allowedIPs")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildUserAccountWireGuardBadKey(t *testing.T) {
|
||||
user := map[string]any{
|
||||
"email": "e@example.test",
|
||||
"publicKey": "not-a-valid-key",
|
||||
"allowedIPs": []any{"10.0.0.5/32"},
|
||||
}
|
||||
if _, err := buildUserAccount("wireguard", user); err == nil {
|
||||
t.Fatal("expected error for invalid publicKey")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildUserAccountUnknownProtocolReturnsNil(t *testing.T) {
|
||||
tm, err := buildUserAccount("mtproto", map[string]any{"email": "x@example.test"})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if tm != nil {
|
||||
t.Fatal("expected nil account for unsupported protocol")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user