Skip to content

fix(core): disconnect the live session of a user who was just removed - #198

Merged
shenaba merged 3 commits into
mainfrom
fix/session-revocation
Sep 16, 2026
Merged

shenaba merged 3 commits into
mainfrom
fix/session-revocation

Conversation

@shenaba

@shenaba shenaba commented Sep 16, 2026

Copy link
Copy Markdown
Owner

Closes #175.

The hole

Protocols that authenticate once per session keep serving a client after its
user is gone from the inbound. Swapping the user table only decides who may
start a new session; ConnTracker closes the routed connections but not the
session carrying them, so the client opens another stream on the one it already
has and is served. Every multiplex session has the same shape — one
authenticated carrier connection serves every stream opened after it.

Measured on the test server against main, a client DepleteJob had just
disabled for running out of quota was served 11 out of 11 retries on anytls,
hysteria2 and vless+mux alike.

Two kinds of session

anytls and the sing-mux carrier arrive as a net.Conn that lives exactly as
long as the session does. core/usersession tracks those by source address and
closes them outright — the key is one TCP connection's address, it cannot move,
and Untrack runs when the session ends. That is the whole story for anytls,
vless, vmess and trojan
, and it disturbs nobody else on the inbound.

The QUIC protocols give this layer no handle on the session, and no usable
name for one either:

  • sing-quic keeps its session list unexported, and the ctx it hands the
    handler per stream is the Service's own (serverSession{ctx: s.ctx}),
    shared by every session on the listener;
  • all three services set quic-go's DisablePathManager, which rewrites a
    connection's remote address the moment a decryptable packet arrives from a new
    one, with no path validation — one NAT rebind and the address is different;
  • that address is an ephemeral UDP port, recycled to somebody else once the
    session ends, and nothing tells this layer that it has been;
  • and there is no moment at which a QUIC session can be declared gone.
    quic-go keeps one alive with a 10s PING whether or not a single byte is
    routed, so an idle session and a dead one are indistinguishable from here.

Two earlier revisions of this PR tried to do better and could not. Muting the
source address is defeated by the second and third points. Ageing a per-session
entry out after ten idle minutes — which is what the first review round of this
PR produced — is defeated by the fourth: a client idle past the window was
dropped from the registry, no restart was asked for, and its still-authenticated
session went on being served. That is #175 verbatim. Both versions looked fine
because a probe that reconnects every second is never idle and never moves.

What this does

For QUIC the registry does not track sessions at all. It records something
weaker and completely reliable: which users have been seen on this inbound.
That set is keyed by user rather than by address, so a client whose address
moves is still one entry; and it is bounded by the client count rather than the
session count, which is what lets it carry no expiry at all. A set with no
expiry is the only kind that cannot be wrong about a session it cannot see.

CloseUsers cuts every session it holds a transport for and reports the rest as
Unclosable. The QUIC inbounds turn that into ErrRestartRequired, and
InboundService.UpdateInboundsUsers rebuilds the inbound — the same
RemoveInbound/AddInbound path protocols with no in-place update already
take. That destroys every QUIC session on the listener, the removed user's
included.

Nothing in core/usersession refuses a connection any more, in any mode.

What it costs

On a QUIC inbound, disabling a user who has been connected disconnects
everyone on that inbound once.
They reconnect on their own.

Being wrong here is one-sided on purpose: a user who connected and then left for
good is still in the seen set, so removing them costs one restart that bought
nothing. The other direction — deciding a session is gone when it is not — is
issue #175, so the cost is paid on that side.

It is not paid otherwise: adding a user, rotating a UUID or password, or
removing a user who never connected all still update in place. DepleteClients
disables every expired client in one transaction and calls
UpdateInboundsUsers once with the union of their inbounds, so a deplete round
costs at most one restart per inbound, not one per client.

One line per copy

Unchanged, and scripts/check-protocol-copies.sh still reports 8/8 ok against
sing-box v1.14.1 with the same expect_diff counts: everything here lives in
users.go (exempt) or in the header comment above the package clause (which
the script strips). Six copies carry the single line

inbound.router = withUserSessions(inbound.router)

and anytls costs three, because it holds its session in NewConnection, which
the router never sees.

Verification

The hole is measured; this implementation is not yet. The 11/11 figure above
was taken against main and stands as evidence that the bug is real. What has
been verified for the code in this PR is everything below the network:

  • the full PR gate — check-protocol-copies.sh (8/8), go vet, go build and
    go test ./... with the CI tag set, all green;
  • core/usersession clean under -race, including a stress run driving every
    exported method from eight goroutines at once, which also asserts that the
    seen set grows per user and not per address;
  • mutation testing, 16/16. Every behaviour this package claims was backed
    out one at a time and the matching test confirmed to go red — including
    "expire an idle user out of the seen set", which is the defect described
    above, and "key the seen set by address instead of user".

Mutation testing is also what makes the review trail below worth trusting
rather than just worth reading: the ten-minute-idle defect was found by a code
review that ran after a 12/12 mutation score, because the suite at that point
had the wrong behaviour written into it as an assertion
(TestSweptSourceIsNotReportedUnclosable). That test is gone, replaced by
TestIdleUserIsNeverForgotten.

Not covered by anything automatic: the one line in each QUIC users.go that
turns Unclosable into ErrRestartRequired. Constructing an *Inbound needs
the whole of NewInbound (TLS config, listener), which is too much to stand up
in a unit test. Result.RestartRequired itself is table-tested, and the seven
call sites were checked by hand — the three QUIC protocols call it, the other
four deliberately do not and say why.

Still to do before merge: a run on the test server — deplete a connected
client on a hysteria2 inbound and confirm both halves, that it is actually cut
off and that the inbound comes back. Worth doing with an idle client too,
since that is the case both earlier revisions got wrong.

Known edges

  • shadowsocks is not covered. It has the same multiplex hole but still runs
    sing-box's own inbound; forking it belongs with the live-traffic work.
  • KickUserSessions exists and is tested, but has no caller until the
    panel-side disconnect lands. On a QUIC inbound it can only report
    Unclosable — whether disconnecting one user is worth restarting the inbound
    everyone else is on is a decision for that caller, not this layer.
  • RemoveInbound then a failing AddInbound leaves the inbound gone from the
    core while the transaction rolls back
    , and checkCoreJob will not notice
    because the core itself is still up. That is pre-existing behaviour for every
    protocol without an in-place update, but QUIC deplete now takes this path
    routinely, so the exposure is larger than it was. Not addressed here.
  • The real fix for QUIC is upstream: Close sessions of removed users on UpdateUsers SagerNet/sing-quic#19 and
    fix: close sessions of removed users on UpdateUsers anytls/sing-anytls#4 are still open. A per-session handle would turn the
    restart back into a targeted close.

Protocols that authenticate once per session keep serving a client after its
user is gone from the inbound. Swapping the user table only decides who may
start a *new* session, and ConnTracker closes the routed connections but not
the session carrying them -- so the client opens another stream on the one it
already has and is served. That is issue #175: a client DepleteJob disabled for
running out of quota kept running, and the same hole covers every multiplex
session, where one authenticated carrier connection serves every stream after
it.

core/usersession records, per inbound, which user the session at a source
address authenticated as. Removing a user closes the sessions there is a closer
to reach -- anytls, and the sing-mux carrier behind vless, vmess and trojan --
and mutes the ones there is not, which is all the QUIC protocols offer: their
session lives inside sing-quic with no handle out. A muted source has nothing
routed for it any more, so the traffic stops either way, and the block ages out
after ten minutes rather than becoming a lockout.

This sits at the inbound layer rather than in ConnTracker because a
tracker-level gate only sees a connection after routing: it misses the ones the
router answers itself, and refusing there still costs one real dial to the
destination first. The two layers stay separate -- IP limits keep their gate in
ConnTracker, whose ban state has to outlive a core restart.

The hook is a router wrapper, not a field plus a block in every handler, so
each copy under core/protocol carries a single added line:

    inbound.router = withUserSessions(inbound.router)

Everything that line reaches lives in users.go, which the copy check skips, so
the expected diffs grow by one each. anytls costs three instead: it holds its
session in NewConnection, which the router never sees, so that call is
redirected through users.go as well.

Cutting hangs off each protocol's UpdateUsers, which leaves the service layer
untouched and still covers all three paths that reach it -- DepleteJob, a panel
save, and a node push.

Two details are easy to undo by accident:

  - Idle entries are only dropped when they have no closer. A tracked session's
    lastSeen moves only when it opens another connection, so one carrying a
    single long-lived stream looks idle while it is perfectly alive; sweeping it
    would discard the only handle on it and the next removal would find nothing
    to cut. Tracked entries are cleaned up by their own deferred Untrack.

  - The user and the closer are recorded under one lock. Split apart, a removal
    landing in between sees a user with no closer, files the session as
    unclosable and mutes it -- and a mux carrier is never gated, so that mute
    does nothing at all.

Verified on the test server, three protocol tunnels driven by a probe that
reconnects every second, with the client expired and DepleteJob disabling it.
Attempts made strictly after the disable:

                    anytls      hysteria2   vless+mux
  1.8.2, no change  11 served   11 served   11 served
  with this change   0 served    0 served    0 served

The control was built from main at the same version and sing-box, so the only
variable is this change.
The backstop was measured from when the block was written, so a mute expired ten
minutes later whatever the client was doing. But a client that keeps retrying is
a session that is still alive -- and for QUIC, still authenticated, because the
streams it opens afterwards never consult the user table again. A client
DepleteJob had disabled therefore got its traffic back after ten minutes, on the
very session the mute existed to stop. Both windows now run from the last
refused attempt, so quiet is what lifts a block, not the clock.

The sweep was a second road to the same place. Allowed only ever touched the
block, never the entry, so a source being refused once a second looked idle and
the sweep dropped its entry -- taking the block with it. Allowed now keeps the
entry alive for as long as it is refusing it.

A block also no longer changes kind behind the caller's back. A kick is about a
user who is still enabled and lifts after thirty seconds of quiet; a removal is
not, and must not be downgraded to that by kicking the same name, nor cleared by
an unrelated save that happens to list the user in keep.

`block.at` has no readers left and is gone.

Found by re-reviewing the previous commit. The test meant to cover the backstop
aged the block rather than the last attempt -- exactly the distinction that was
wrong -- so it passed either way; it now ages the attempt, and a companion test
pins the case it was missing.

Verified on the test server across thirteen minutes of retries at one per
second, spanning the ten-minute mark that used to end the mute:

  anytls  0 served / 695 refused
  hy2     0 served / 698 refused
  vless   0 served / 698 refused

The hysteria2 inbound logged 718 arrivals in that window, so the session was
alive throughout and being refused -- not quietly dead, which would have proved
nothing.
…e cut

Replaces the source-address mute of the two commits before this one, which
could not be made correct.

anytls and the sing-mux carrier arrive as a net.Conn that lives exactly as long
as the session does, so those are tracked by address and closed outright. That
part stands.

The QUIC protocols give this layer no handle on the session and no usable name
for one either. sing-quic keeps its session list unexported and the per-stream
ctx it hands the handler is the Service's own, shared by every session. All
three services set quic-go's DisablePathManager, which rewrites a connection's
remote address on the first decryptable packet from a new one with no path
validation, so the address moves under a NAT rebind; and it is an ephemeral UDP
port that is recycled to somebody else afterwards. There is also no moment at
which a QUIC session can be declared gone, because quic-go keeps it alive with
a 10s PING whether or not a single byte is routed -- which is what defeated
both the mute's quiet window and, in review, an attempt to age a per-session
entry out after ten idle minutes. Either one silently let a removed user keep
an idle session.

So for QUIC the registry no longer tracks sessions. It records which users have
been seen on the inbound: a set keyed by user rather than address, bounded by
the client count rather than the session count, and therefore needing no expiry
at all. CloseUsers reports those users as Unclosable, the QUIC inbounds turn
that into ErrRestartRequired, and InboundService.UpdateInboundsUsers rebuilds
the inbound -- which destroys every QUIC session on it, the removed user's
included.

Being wrong is one-sided on purpose: a user who connected and then left for
good still costs one restart that bought nothing, while the other direction is
issue #175 itself. The cost is only paid when a removed user is actually
connected, so adding a user or rotating a credential still updates in place.

Closes #175.
@shenaba shenaba changed the title fix(core): cut the sessions of a user who was just removed fix(core): disconnect the live session of a user who was just removed Sep 16, 2026
@shenaba
shenaba merged commit e60a136 into main Sep 16, 2026
2 checks passed
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.

TUIC & hysteria2 connection stays alive for a while after traffic quota is exhausted (VLESS closes immediately)

1 participant