UserController
UserController is an advanced support node that enforces authenticated-user limits for traffic-serving chains.
Normal users usually should not place it manually. Server-side nodes such as Socks5Server, TrojanServer, and VlessServer create an internal UserController when they run in authenticated database-backed mode. Configure those higher-level nodes first unless you are building a custom tunnel that calls lineAddUser() and understands WaterWall's user-handle lifecycle.
Cached Admission And First Use
New managed lines use AuthenticationClient's valid RAM cache during ordinary outages, subject to cached enabled status, expiry, traffic quota and local connection/IP reservations. No initial table/clock basis, stopping, terminal continuity failure or explicit client credential rejection blocks new admission; only validated authorization clears that rejection. Existing managed lines keep accounting, payload expiry checks, idle sweeps and reservation release. Local allowances can overshoot aggregate quotas across disconnected clients; administrative changes arrive on refresh.
The AuthenticationClient accounting helper captures first nonzero local traffic, even offline or with duration zero, and starts relative expiry immediately. Login, reservation, zero-byte calls and rebasing do not start use. The server merges earliest producer observations; origins, elapsed age, report/ACK state and reservations migrate by durable ID, including same-ID credential changes. ACK/reconnect/unchanged pulls do not restart duration. Explicit duration and absolute-date edits recompute independent components; the earlier enabled deadline governs. Generic copies/JSON exclude this runtime state. Cross-machine time estimates include transit delay and drift; local monotonic suspend semantics are unchanged. State remains RAM-only with no crash recovery or guaranteed final delivery.
What It Does
UserController sits after a node that has already authenticated a user and recorded a user_handle_t on the line. On each admitted authenticated line, it:
- checks whether the user is enabled
- checks expiry state
- reserves a live outbound connection slot
- reserves a source-IP slot when source IP is available
- accounts upstream and downstream payload bytes
- closes active lines when a user is disabled, expired, removed, or over traffic quota
- releases connection/IP slots when the line finishes
It does not parse login protocols and it does not store the authoritative user database. It depends on an existing AuthenticationClient node for user lookup, admission checks, and traffic accounting.
Typical Placement
For normal authenticated server nodes, do not add it manually:
TcpListener -> Socks5Server -> TcpConnector
In authenticated mode, Socks5Server inserts its own internal UserController between itself and the configured outbound node.
Manual placement is for advanced custom chains where a previous node records an authenticated user handle:
CustomAuthNode -> UserController -> TcpConnector
Lines without a valid current user handle are passed through without enforcement.
Some nodes authenticate the user only after the line is already open, and their next side is packet lines (one per worker) rather than per-user lines, so they cannot place a UserController after themselves. A WireGuardDevice doing database-backed peer authentication is the motivating example; it places the UserController on its own side that still carries per-peer lines. Depending on which adapter is the chain head, that per-peer line is initiated from either side of the UserController:
UdpStatelessSocket -> UserController -> WireGuardDevice -> TunDevice (line starts from prev)
TunDevice -> WireGuardDevice -> UserController -> UdpStatelessSocket (line starts from next)
In both layouts the per-peer line passes through unmanaged. WireGuardDevice currently authenticates configured peers and does not attach database users. A custom authenticating integration can record a user with lineAddUser() and use the Programmatic Promotion API; payload classification alone does not add that integration.
UserController is bidirectional: it works whether the line is initiated from its prev side (an upstream Init) or its next side (a downstream Init), records that origin per line, and uses it to account upload/download correctly (see Direction Awareness).
Basic Example
{
"name": "user-controller",
"type": "UserController",
"settings": {
"auth-client-node-name": "auth-client",
"payload-mode": "stream",
"sweep-interval-ms": 1000,
"verbose": false
},
"next": "outbound"
}
auth-client must be an AuthenticationClient node in the same config file. UserController only references that existing node; it does not create one.
Required Fields
| Field | Type | Description |
|---|---|---|
type | string | Must be UserController. |
settings | object | Must be a non-empty object. |
settings.auth-client-node-name | string | Name of an existing AuthenticationClient node. It must not point to UserController itself. |
settings.payload-mode | string | Required for manual instances: stream, datagram, or dest_context->protocol. Internal creators supply an immutable resolver instead. |
Optional Settings
| Field | Default | Description |
|---|---|---|
sweep-interval-ms | 1000 | Per-worker interval for checking active managed lines even when they are idle. Must be >= 1. |
verbose | false | Enables more detailed warning context for rejected or closed managed lines. |
Enforced Limits
UserController reads the user limit state from the local table maintained by AuthenticationClient.
| Limit area | Behavior |
|---|---|
enabled | Disabled users cannot open new managed lines; existing managed lines are closed by payload checks or the sweep timer. |
| Time expiry | Expired users cannot open new managed lines; existing managed lines are closed. |
traffic.up, traffic.down, traffic.total | Actual payload bytes pass to AuthenticationClient, which applies its traffic-multiplier once and updates cumulative charged counters. Over-quota users are rejected or closed. |
connections-out | Maximum simultaneous managed outbound lines for the user. |
ips | Maximum simultaneous distinct source IPs for the user. Reusing an already-counted IP is allowed. |
connections-in and live devices occupancy are not enforced by this node. Stored/exported device limits and
diagnostic device gauges remain available; they do not provide a live device reservation limit. bandwidth.up / .down are enforced locally as described below.
A zero or missing limit means unlimited for that field.
Bandwidth And Payload Framing
limit.bandwidth.u / up and .d / down are actual logical bytes per second. Zero means unlimited;
the full unsigned 64-bit range is supported. One user's upload and download allowances are independent and shared
by every connection, controller and worker using the same AuthenticationClient instance. Other users and other
client instances have independent allowances. This is local enforcement, without coordination between machines.
traffic-multiplier changes charged quota bytes only; it never changes rates, bursts, queue sizes or clocks.
For finite rate R, ordinary burst capacity is max(1, ceil(R / 10)) bytes: approximately 100 ms, with a one-byte
minimum at very low rates. Contending eligible queues receive bounded FIFO turns. A whole datagram can hold its turn
while earning credit; fairness does not promise equal latency. Streams retain excess bytes in FIFO order and may split a delivery. Datagrams remain whole and ordered,
including empty datagrams, which consume no rate or quota. A datagram larger than ordinary burst capacity earns its
complete credit before release, even at 1 byte/s; the instantaneous burst has a one-datagram exception. Idle time before
that head became eligible supplies only ordinary burst credit. Canceling or pausing the head releases its turn and
clamps transferable credit to ordinary capacity. Payloads over 65,535 bytes are refused in datagram mode; smaller
protocol-specific limits still apply.
Retained data, prepared fragments and queue entries share these hard resource limits in both directions combined:
| Scope | Logical bytes | Allocation charge | Buffers | Queued direction tickets |
|---|---|---|---|---|
| One managed line | 1 MiB | 2 MiB | 256 | 2 |
| One user in one AuthenticationClient | 4 MiB | 8 MiB | 1024 | 1024 |
| One AuthenticationClient | 64 MiB | 128 MiB | 16384 | 16384 |
These are resource ceilings, not configurable bandwidth rates. Stream admission refusal closes the affected line and discards retained bytes. Datagram overflow drops only the newly refused datagram; accepted FIFO entries survive. Required timer or continuation refusal closes the affected line in either mode. Dropped or discarded data is never charged. Consumer Pause stops that direction's drain and fair-turn eligibility. Resume arranges progress before notifying the producer. For streams, local shaping pressure and external consumer pressure are independent holds. Datagram backlog alone never sends producer Pause/Resume: UDP producers may drop arrivals while paused, before this node's queue is full. Datagrams continue entering the bounded queue while waiting for rate credit; explicit external consumer Pause/Resume still applies and is propagated to the producer.
First positive measured arrival starts first-use expiry and reporting before queueing, including offline and discounted traffic; zero length alone does not start use. Quota is charged exactly once, with the multiplier, immediately before Payload ownership is handed onward. The final permitted chunk is handed off once even if it exhausts or crosses quota, then the still-live line closes. An already exhausted user receives no further grant. Handoff does not confirm socket delivery.
Configuration refresh preserves same-ID bucket credit, fractional refill, timestamps and queue identity, including password replacement. Rate changes settle old-rate credit first and clamp it to the new capacity; unchanged refresh adds no burst. Finite-to-unlimited changes preserve the queued FIFO. Unlimited-to-finite initializes one ordinary burst. Existing managed lines keep cached enforcement through ordinary authentication-client outages or credential rejection.
Manual instances require an explicit payload-mode, even when current users are unlimited:
| Value | Payload handling |
|---|---|
stream | Bytes may be split across deliveries. |
datagram | Each payload remains one whole datagram. |
dest_context->protocol | Read the destination protocol once during Init: exactly TCP selects stream; exactly UDP selects datagram. |
Destination-context mode rejects the line if the protocol is missing, ambiguous or unsupported. Use it only when the destination protocol is available before Init and describes the payload at this node's position. A transport conversion can leave UDP routing metadata on a line carrying decoded stream bytes, so destination detection is never an automatic fallback for an omitted setting.
Internal creators instead bind usercontrollerSetPayloadModeResolver(controller, owner, resolver) immediately after
construction, before preparation. A configured mode and a resolver cannot both be supplied. The pure owner-worker
metadata query runs once in either Init direction; every mode source is resolved and saved before admission or forwarding.
The saved mode also governs later promotion and cannot change with destination metadata.
SOCKS/Trojan/VLESS resolve initialized TCP versus decoded UDP remote-line roles; HTTP fixes stream mode; WireGuard fixes
datagram mode on its normal transport side in either orientation. Packet lines never enter UserController. This binding
does not add WireGuard database-user authentication; its existing unmanaged transport behavior remains.
Direction Awareness
UserController records, per line, which side the line was initiated from, and uses it to map traffic to the user's upload and download counters correctly. "Upload" is traffic leaving the user toward the far side; "download" is traffic arriving at the user. The user always sits on the side the line started from.
| Line origin | Initiated by | Upstream payload counts as | Downstream payload counts as |
|---|---|---|---|
From prev (upstream Init) | a head/prev adapter (for example UdpStatelessSocket at the front) | upload | download |
From next (downstream Init) | a tail/next adapter (for example UdpStatelessSocket at the back) | download | upload |
The forward row is the common case (Socks5Server, TrojanServer, VlessServer). The reverse row happens when the user-facing adapter is the chain tail, as in TunDevice -> WireGuardDevice -> UserController -> UdpStatelessSocket: there the user's packets arrive on the next side, so downstream payload is the user's upload. Quota enforcement is otherwise identical in both directions.
Runtime Flow
On Init from either side (upstream Init for a line from prev, downstream Init for a line from next):
- line state is initialized, the origin direction is recorded, and the payload mode is resolved and saved; an unresolved mode rejects even an anonymous line
- the current line user handle is read
- anonymous or invalid-handle lines are forwarded without enforcement (and may be promoted later, see below)
- authenticated lines are checked through
AuthenticationClient - accepted lines reserve connection/IP slots and are registered for sweep checks
- rejected lines finish back toward the side that initiated them, without opening the far side
Positive arrivals first record first-use evidence. The shaper previews current eligibility, prepares an exact stream fragment or whole datagram, then atomically grants bandwidth and charges its raw logical length before handoff. A final quota-exhausting candidate is forwarded once before close. Retained or discarded bytes are uncharged; see Bandwidth And Payload Framing.
On Finish, the node releases the reserved connection/IP accounting state, destroys its own line state, and propagates Finish only to the opposite side according to WaterWall's directional finish rules.
Programmatic Promotion API
Some nodes authenticate a user only after the line is already open (see Typical Placement). For them, UserController exposes a promotion function in its public interface.h:
WW_EXPORT user_admission_result_t usercontrollerTunnelTryManageLine(tunnel_t *t, line_t *l);
It runs the same admission as upstream Init — checking every limit and reserving a connection + IP slot — but on demand. A node that wants to use it:
- Keeps a pointer to its
UserControllerinstance (created the same waySocks5Serverbuilds its internalUserController, vianodeUserControllerGet()andcreateHandle). - Authenticates the peer and records the user on the line with
lineAddUser(). - Calls
usercontrollerTunnelTryManageLine(user_controller_instance, line).
Return values:
| Result | Meaning |
|---|---|
kUserAdmissionOk | The line is now managed (admitted), was already managed (idempotent), or carries no user and was left as an unmanaged passthrough. Proceed. |
any other kUserAdmission* | The user was rejected (disabled, expired, over quota, connection limit, or IP limit). The line is left unmanaged and untouched. |
Contract:
- On a non-OK result the function never sends a finish and never destroys line state. The caller owns the rejection and must close or drop the line itself; it must not treat a rejected line as admitted. A non-OK call reserves nothing, so retrying later is safe.
- Call it on the line's owner worker (
lineIsOnCurrentWorker(l)), and only on a normal line that already traversed thisUserControllerinstance. It retains the payload mode saved during Init. Do not call it on packet lines. - Once a line is managed this way, traffic accounting, the idle sweep, and
Finish-time slot release apply exactly as they do for a line admitted atInit; no extra teardown wiring is needed on the caller side.
Example sketch (inside the authenticating node, after a successful handshake on line):
lineAddUser(line, &resolved_handle, username, password);
user_admission_result_t admission = usercontrollerTunnelTryManageLine(ts->user_controller_tunnel, line);
if (admission != kUserAdmissionOk)
{
// limits exceeded (for example IP limit): reject this peer / close the line here.
return;
}
// admitted: continue bringing the peer up.
Sweep Timer
Each worker gets a periodic sweep timer. The sweep checks registered managed lines so disabled, expired, removed, or over-quota users can be disconnected even when no new payload arrives.
The sweep registry holds line references while lines are managed and releases them when the line closes or the worker stops.
Operational Notes
- Manual placement requires a previous node that calls
lineAddUser()with a valid user handle. - Authenticated
Socks5Server,TrojanServer, andVlessServercreate this node internally; do not manually place anotherUserControllerimmediately after those nodes in that mode. UserControllernever writes user configuration such as passwords, enabled state, or limits.- Live connection/IP counters are process-local runtime state held through
AuthenticationClienthelpers. - Charged traffic is added to normal user traffic stats. Set
traffic-multiplieron AuthenticationClient; physical payload length, bandwidth and speed are unchanged. - First measured traffic starts expiry even when a discounted charge rounds to zero. Per-direction fractional charge remainders migrate by durable user ID during refresh and survive reconnects, in process memory only.
- This support node borrows normal stream or datagram lines; it is not a packet tunnel.
Node Metadata
Source-backed metadata:
| Property | Value |
|---|---|
| node flags | kNodeFlagSupportsSplice |
can_have_prev | true |
can_have_next | true |
layer_group | kNodeLayer4 |
layer_group_prev_node | kNodeLayer4 |
layer_group_next_node | kNodeLayer4 |
required_padding_left | 0 bytes |
Splice support
Unmanaged and unlimited immediate payloads pass through opaquely in both directions. Shaped queues preserve whole datagrams and split streams through sbufMoveRangeTo(), including private pipes with ordinary fallback. Prepared output remains budgeted until handoff or discard. Traffic accounting uses logical payload bytes, including resident prefixes, with the existing reversed upload/download mapping for lines started from next. Rejection recycles the still-owned buffer and discards its private-pipe bytes before closing through the existing lifecycle.
Ordinary fallback buffers remain accepted. Splice requires a supported Linux build, misc.splice enabled, no packet nodes, and support from every node in the finalized expanded/merged chain. The capability flag does not bypass these checks. No performance improvement is promised without measurement.