Skip to content

feat(wireguard): implement proxy.UserManager for dynamic peer management#6360

Open
bitwiresys wants to merge 12 commits into
XTLS:mainfrom
bitwiresys:feat/wireguard-usermanager
Open

feat(wireguard): implement proxy.UserManager for dynamic peer management#6360
bitwiresys wants to merge 12 commits into
XTLS:mainfrom
bitwiresys:feat/wireguard-usermanager

Conversation

@bitwiresys

Copy link
Copy Markdown

Problem

The WireGuard inbound only supports static peers defined in the JSON config. Every user change requires a full process restart, which breaks the standard xray API-driven user lifecycle used by panels and management tools (AddUserOperation / RemoveUserOperation via the gRPC management API).

Solution

This PR adds full proxy.UserManager support to the WireGuard inbound Server, enabling peers to be added and removed at runtime without restarting xray.

New file: proxy/wireguard/account.go

  • MemoryAccount — runtime peer credentials (public key, pre-shared key, allowed IPs). Implements protocol.Account (Equals / ToProto).
  • PeerConfig.AsAccount() — lets API callers use "@type": "xray.proxy.wireguard.PeerConfig" in AddUserOperation requests, consistent with how every other inbound protocol works.
  • buildPeerIPC / buildRemovePeerIPC — produce WireGuard IPC strings for device.Device.IpcSet.
  • parseFirstAddr — extracts a netip.Addr from a CIDR or plain address (used for the IP→user reverse lookup map).

Changes to proxy/wireguard/server.go

New fields on Server:

peers     sync.Map    // email (or pubkey) → *protocol.MemoryUser
peersByIP sync.Map    // netip.Addr → *protocol.MemoryUser
peerCount atomic.Int64

NewServer seeds peers / peersByIP from the static peer list so that GetUser / GetUsers work immediately for peers configured at startup.

HandleConnection tags each session's Inbound.User with the matching *protocol.MemoryUser when the tunnel source IP is in peersByIP. This makes the peer identity visible in access logs and enables per-user traffic stats through the existing stats.Manager.

UserManager methods:

Method Behaviour
AddUser Calls device.IpcSet to install the peer, then records it in the maps. Falls back to the public key when Email is empty.
RemoveUser Removes from maps, then calls IpcSet with remove=true.
GetUser Lock-free map lookup by email.
GetUsers Snapshot of all tracked peers.
GetUsersCount Atomic counter, no map scan.

New test files

  • account_test.go — covers MemoryAccount, PeerConfig.AsAccount, IPC helpers, and parseFirstAddr.
  • server_test.go — full coverage of all UserManager methods including error paths: duplicate add, IPC failure, wrong account type, peer not found. Uses a mockIpc injected via the unexported ipcOverride field so no live WireGuard device is required.
$ go test ./proxy/wireguard/... -v
--- PASS: TestPeerConfigAsAccount
--- PASS: TestMemoryAccountEquals
--- PASS: TestMemoryAccountToProto
--- PASS: TestBuildPeerIPC
--- PASS: TestBuildRemovePeerIPC
--- PASS: TestParseFirstAddr
--- PASS: TestStaticPeersSeededAtStartup
--- PASS: TestAddUser
--- PASS: TestAddUserFallsBackToPubKeyWhenEmailEmpty
--- PASS: TestAddUserDuplicate
--- PASS: TestAddUserWrongAccountType
--- PASS: TestAddUserIpcError
--- PASS: TestRemoveUser
--- PASS: TestRemoveUserNotFound
--- PASS: TestGetUserNotFound
--- PASS: TestGetUsers
--- PASS: TestGetUsersCount
--- PASS: TestPeersByIPPopulatedAndCleanedUp
PASS

Compatibility

The change is purely additive. No existing config field, API, or runtime behaviour is removed or altered. The static-peer path in Start() is untouched. Existing configs that do not use the management API continue to work exactly as before.

Example API usage

// Add a peer at runtime (xray management API)
{
  "op": "AddUserOperation",
  "user": {
    "email": "alice@example.com",
    "level": 0,
    "account": {
      "@type": "xray.proxy.wireguard.PeerConfig",
      "publicKey": "BASE64_PUBLIC_KEY",
      "allowedIps": ["10.0.0.2/32"]
    }
  }
}
// Remove a peer at runtime
{
  "op": "RemoveUserOperation",
  "email": "alice@example.com"
}

bitwiresys added 2 commits June 22, 2026 19:27
Add AddUser / RemoveUser / GetUser / GetUsers / GetUsersCount to the
WireGuard inbound Server so that peers can be inserted and removed at
runtime through the xray management API without restarting the process.

Problem
-------
The WireGuard inbound previously only supported static peers defined in
the JSON config. Every user change required a full process restart, which
breaks the standard xray API-driven user lifecycle (used by panels and
management tools that call AddUserOperation / RemoveUserOperation).

Solution
--------
* proxy/wireguard/account.go (new)
  - MemoryAccount: runtime peer credentials (public key, pre-shared key,
    allowed IPs). Implements protocol.Account (Equals / ToProto).
  - PeerConfig.AsAccount(): lets API callers use "@type":
    "xray.proxy.wireguard.PeerConfig" in AddUserOperation requests.
  - buildPeerIPC / buildRemovePeerIPC: produce the WireGuard IPC strings
    needed by device.Device.IpcSet to add and remove individual peers.
  - parseFirstAddr: extracts a netip.Addr from a CIDR or plain address
    (used to key the IP→user reverse lookup map).

* proxy/wireguard/server.go
  - Server gains three new fields:
      peers     sync.Map    // email (or pubkey) → *protocol.MemoryUser
      peersByIP sync.Map    // netip.Addr → *protocol.MemoryUser
      peerCount atomic.Int64
  - NewServer seeds peers/peersByIP from the static peer list so that
    GetUser / GetUsers work immediately without an explicit AddUser call
    for peers that were configured at startup.
  - HandleConnection tags each session's Inbound.User with the matching
    *protocol.MemoryUser when the tunnel source IP is in peersByIP. This
    makes the peer identity visible in access logs and enables per-user
    traffic stats through the existing stats.Manager infrastructure.
  - AddUser: calls device.IpcSet to install the peer into the running
    WireGuard device, then records the peer in the in-memory maps.
    Falls back to the public key as the email when Email is empty.
    Returns an error when the email is already registered.
  - RemoveUser: removes the peer from the maps, then calls IpcSet with
    "remove=true" to uninstall it from the device.
  - GetUser / GetUsers / GetUsersCount: read-only map lookups.
  - ipcOverride (unexported): test hook that substitutes a wgIpcSetter
    mock for the concrete *device.Device, keeping unit tests free of
    kernel-level WireGuard setup.

* proxy/wireguard/account_test.go (new)
  Tests for MemoryAccount, PeerConfig.AsAccount, buildPeerIPC,
  buildRemovePeerIPC, and parseFirstAddr.

* proxy/wireguard/server_test.go (new)
  Full coverage of AddUser / RemoveUser / GetUser / GetUsers /
  GetUsersCount including error paths (duplicate add, IPC error,
  wrong account type, peer not found).
  All 18 tests pass with go test ./proxy/wireguard/...

Compatibility
-------------
The change is additive: no existing API, config field, or behaviour is
removed or altered. The static-peer path in Start() is unchanged. Panels
that do not call the management API continue to work exactly as before.
Fix check-format CI failure: gofumpt requires multi-field anonymous
structs to use the expanded form (one field per line), aligned comments
must not have extra spaces beyond a single space, and function call
arguments with trailing variadic elements must have the closing paren on
its own line.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@LjhAUMEM

Copy link
Copy Markdown
Collaborator

UAPI 的使用方式看起来不错,但整体实现还是太丑陋了,另外这两个文件的作用是?
image

These belong to our private deployment pipeline, not to this PR.
@bitwiresys

Copy link
Copy Markdown
Author

Hi @LjhAUMEM, thanks for the review!

These two files (.gitlab-ci.yml and Dockerfile.ci) were our internal deployment pipeline — accidentally ended up in this PR. They have nothing to do with the Xray-core codebase. Removed them now.

Also, what specifically feels ugly about the implementation? Happy to clean it up.

@LjhAUMEM

LjhAUMEM commented Jun 23, 2026

Copy link
Copy Markdown
Collaborator

MemoryAccount 的 Equals ToProto AsAccount 不写 infra 应该是用不上 哦不对,路由里只能传 user 的 interface,那先留着吧

另外把 wgIpcSetter interface 删了,直接使用 dev,还有 type Server struct 里的注释删了,两个 test 文件也删了

其余的晚点再看

@bitwiresys

…evice directly

- Remove the wgIpcSetter interface and the test-only ipcOverride field;
  AddUser/RemoveUser now use the concrete *device.Device via s.device().
- Drop the explanatory comments inside the Server struct.
- Remove the two WireGuard UserManager test files.
@bitwiresys

Copy link
Copy Markdown
Author

Thanks for the review! Done in d737888:

  • Removed the wgIpcSetter interface — AddUser/RemoveUser now use the concrete *device.Device directly via s.device().
  • Dropped the comments inside the Server struct.
  • Removed the two WireGuard test files.

Left MemoryAccount's Equals/ToProto/AsAccount as-is since, as you noted, routing can only carry the user interface.

@Fangliding

Copy link
Copy Markdown
Member

test里把最重要的测试部分用interface替换成了mock 说要删interface又把整个test删了 看这种东西感觉像和机器说话不是和人交流。。。

@LjhAUMEM

Copy link
Copy Markdown
Collaborator

test里把最重要的测试部分用interface替换成了mock 说要删interface又把整个test删了

我就是让直接删的 test 文件没错啊,一个 account 要啥 test

感觉差不多该到我接手的部分了,等今晚有空再说,对话效率有点低

@Fangliding 路由那个 inbound 的 protocol.MemoryUser Account 不是必须的吧,只有 email 也可以路由吧

@Fangliding

Copy link
Copy Markdown
Member

路由只看email但是getinbounduser会解引用看 而且这个pr看起来也没法用adu命令

@bitwiresys

Copy link
Copy Markdown
Author

@LjhAUMEM all three are in d737888 — dropped the wgIpcSetter interface (AddUser/RemoveUser now use *device.Device directly via s.device()), removed the comments inside the Server struct, and deleted the two test files.

@Fangliding fair point on the adu path and GetInboundUser dereferencing the Account — I'll dig into making the api add-user flow work for the WG inbound. If you'd rather take that part yourself, happy to leave it to you.

@LjhAUMEM

Copy link
Copy Markdown
Collaborator

AllowedIps 类型是 cidr,看了下 peersByIP 还不能这么写,真要按 ip 来匹配用户的话那几个用户就有几个 matcher,有点麻烦了

而且这个pr看起来也没法用adu命令

如果是用 grpc api 的话那 hysteria 应该也不行,hy 也只给 inbound 实现了 proxy.UserManager

image

@bitwiresys

Copy link
Copy Markdown
Author

On peersByIP — you're right, it's keyed off parseFirstAddr(cidr), so it only holds when a peer's AllowedIPs is a /32; a subnet wouldn't match, and doing it properly means a per-peer range matcher, which is exactly the mess you're describing. The map is only used to tag the session with the user in HandleConnection for per-user stats — AddUser/RemoveUser/GetUser are all email-keyed and don't touch it. Simplest fix is to drop the IP→user map and keep email-only tracking. Happy to strip it, or leave it for your pass if you'd rather rework the matching.

On adu — agreed, extractInboundUsers has no wireguard branch, and as you noted hysteria is in the same spot since it also only implements proxy.UserManager on the inbound, so it isn't specific to this PR.

@LjhAUMEM

Copy link
Copy Markdown
Collaborator

@bitwiresys 你先不用修改,先提前问下,我可以直接 push 到你的 branch 吗

@LjhAUMEM

Copy link
Copy Markdown
Collaborator

如果真要区分用户那也只会分配一个 v4/32 + v6/128,只匹配一个 v4 + v6 倒也还能写

@bitwiresys

Copy link
Copy Markdown
Author

Yeah, go for it — "Allow edits from maintainers" is on, so you can push straight to feat/wireguard-usermanager. If you run into a permissions wall, ping me and I'll add you as a collaborator on the fork. I'll hold off on any changes in the meantime.

@Fangliding

Copy link
Copy Markdown
Member

为什么执意要给wireguard加个这玩意 #6314 (comment)
最开始只是为了滥用cf warp怎么弄成现在这样

@LjhAUMEM

Copy link
Copy Markdown
Collaborator

为什么执意要给wireguard加个这玩意 #6314 (comment) 最开始只是为了滥用cf warp怎么弄成现在这样

原来这还有个 issue

不过我是随意,反正入站也重构了,然后好像也没有不支持用户api的入站,那就对齐一下吧,刚好也能猜测一下 cf warp 的管理原理

@Fangliding

Copy link
Copy Markdown
Member

cfwarp是自己写的服务端 不是标准wireguard 它的ipv4 local都能是一样的 只是套了这个协议传l3包而已

@bitwiresys

Copy link
Copy Markdown
Author

The request actually came from #6314 (not from us) — after #6287 reworked the WG inbound and Finalmask landed, a WG inbound that a panel can manage users on becomes useful.

Concrete use case: multi-protocol panels (PasarGuard / Marzban-style) provision and revoke users for every inbound through one xray gRPC path (AddUser/RemoveUser/AlterInbound). WG was the only inbound without a UserManager, so adding or removing a single peer meant restarting the whole core and dropping every live connection. This PR just lets WG peers be added/removed at runtime like the other protocols — that's the whole scope.

You're right that for pure anti-censorship hysteria/xhttp3 are the better UDP options; WG isn't trying to compete there. Its value is native client support (every OS/router ships a WG client) and giving panels one consistent user-management API across all protocols — not censorship resistance.

And agreed the peer/user-api fit isn't perfect (the peersByIP/CIDR point) — fine to keep it minimal, just enough for add/remove/list.

@LjhAUMEM

LjhAUMEM commented Jun 23, 2026

Copy link
Copy Markdown
Collaborator

cfwarp是自己写的服务端 不是标准wireguard 它的ipv4 local都能是一样的 只是套了这个协议传l3包而已

那肯定不是说完全一样,语言都可能不同,而是只用 wg go device 看看能不能模仿出来,不过按你说的 v4 可以一样这点应该就 GG 了,wg go 应该只能做到按 local ip 来区分(只用接口的情况下)

不过 cf warp 好像也没有说不带 reversed 不给连的情况,我自己连都是不带的,只是简单在 wg 里校验了下 local address

@LjhAUMEM

Copy link
Copy Markdown
Collaborator

其实真正区分用户应该用 pub key,不过 gvisor 的三转四应该是拿不到了

@LjhAUMEM

LjhAUMEM commented Jun 23, 2026

Copy link
Copy Markdown
Collaborator

不行,用 localaddress 来区分还是太诡异了,可以用 user manager 来添加/删除 peers,但不统计各个 peer 的流量(当然也无法路由具体 peer),你觉得怎么样 @bitwiresys

算了,当我没说,我看下怎么实现吧

@LjhAUMEM

Copy link
Copy Markdown
Collaborator

为什么执意要给wireguard加个这玩意 #6314 (comment) 最开始只是为了滥用cf warp怎么弄成现在这样

想了想除了对齐还有另一个原因,wg 的 peer 注定只能给一个设备用,单 peer 多设备必定冲突,不像其他入站可以单 user 多用户

@RPRX

RPRX commented Jun 24, 2026

Copy link
Copy Markdown
Member

@LjhAUMEM ready 时说一下,会合并

@LjhAUMEM

Copy link
Copy Markdown
Collaborator

估计这两天

@LjhAUMEM

Copy link
Copy Markdown
Collaborator

@bitwiresys 应该差不多了,你方便测试下吗

@RPRX

RPRX commented Jun 24, 2026

Copy link
Copy Markdown
Member

@LjhAUMEM 三平台全挂

@LjhAUMEM

Copy link
Copy Markdown
Collaborator

@LjhAUMEM 三平台全挂

好了,删了个文件

@bitwiresys

Copy link
Copy Markdown
Author

Tested your branch — looks good on our side.

CI (built 9f82e5a locally, 1:1 with the workflow): go build ./..., go vet, go test ./proxy/wireguard/ ./infra/conf/ and vformat -mode check all pass. The only failing test locally was TestGeodataConfig, which just needs the cached geoip.dat/geosite.dat (the check-assets + geodat-cache steps provide those in CI), so unrelated to the change.

Functional test on a real node: ran a WG inbound with a peer that carries email, connected a client and pushed ~9 MB. Handshake + routing work, and per-user stats now key off the email instead of the public key:

user>>>1.hehe>>>traffic>>>uplink    3534
user>>>1.hehe>>>traffic>>>downlink  9038719

That's exactly what panels need — email = {id}.{username} flows straight into per-user accounting and the online indicator. The GetUserByAddr tunnel-IP tagging does the job for the /32 + /128 case.

Works for us, thanks for the rework.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants