Multi-User Server Mode
How alpha-branch OpenSnell authenticates multiple users on one server without changing the Snell wire format.
Alpha-branch OpenSnell can run one server with multiple independent
user PSKs through the components/snell library API. This is intended
for panels and embedders that need per-user keys, accounting, quota
checks, or key rotation while keeping full Snell wire compatibility.
This is currently a library-level server feature. The standalone
cmd/snell-server INI loader still exposes the normal single
psk = ... configuration and does not parse a [users] section.
What changes on the wire
Nothing. Snell's TCP stream and v5 QUIC envelope do not carry a
username, user ID, or key hint. A client keeps configuring only its own
psk, exactly as it does in single-user mode.
The server identifies the user by asking a simpler question:
Which registered PSK can decrypt the first encrypted Snell frame?
That keeps the feature compatible with existing clients and the official wire format, but it also means every user must have a unique PSK. If two users share the same PSK, the first matching entry in the server's user snapshot wins and traffic cannot be attributed reliably.
Public API
Multi-user mode is enabled by setting ServerConfig.UserStore.
PSK is for the original single-user fast path; when UserStore is
non-nil, the server uses the user store and ignores PSK.
store := snell.NewStaticUserStore([]snell.User{
{Name: "panel-user-1", PSK: []byte("user-1-random-psk")},
{Name: "panel-user-2", PSK: []byte("user-2-random-psk")},
})
srv, err := snell.NewServer(snell.ServerConfig{
Listen: "0.0.0.0:2333",
UserStore: store,
ObfsMode: "off",
UDP: true,
QUIC: true,
OnAuthorize: func(ctx context.Context, info snell.ConnContext, dialer snell.ContextDialer) (net.Conn, error) {
if quotaExceeded(info.User) {
return nil, errors.New("quota exceeded")
}
return dialer.DialContext(ctx, "tcp", info.Target)
},
}, logger)User.Name is an operator-side label, usually a panel UUID or account
ID. It never travels on the wire. User.PSK is the raw UTF-8 secret
that the client configures as psk = ....
UserStore is deliberately small:
type UserStore interface {
ListUsers() []User
}ListUsers is called on every new authentication path, so production
implementations should return an immutable snapshot without holding a
lock across authentication. StaticUserStore is included for exactly
that pattern: SetUsers atomically swaps the whole user list, making
it suitable for a panel pull loop.
TCP authentication flow
For a new TCP connection, the server does this after the optional
http / tls obfs wrapper has completed:
- Read the first 16-byte Snell salt.
- Read the first 23-byte AEAD-sealed frame header.
- Try to derive AES-GCM with each registered user's PSK and open that 23-byte header with nonce 0.
- Accept a user only when AEAD open succeeds and the plaintext frame
marker is
0x04. - Reuse the already-derived read AEAD and replay the consumed 23-byte header back into the normal v4 reader, so the nonce counter advances exactly as it would in single-user mode.
- Use the matched user's PSK for the server response direction, which has its own fresh salt.
The implementation path is:
handleConn()
-> multiUserAuth.authenticate()
reads salt + first encrypted header
LRU hit? try cached user's PSK first
LRU miss? scan current UserStore snapshot
-> ServerStreamConnPrefilled()
installs pre-derived read AEAD
feeds the consumed header through MultiReader
-> handleRequest()
parses CONNECT / UDP-over-TCP normallyWhen the request is TCP CONNECT or CONNECT reuse, OnAuthorize receives
ConnContext.User, RemoteAddr, Target, and a dialer that already
inherits the server's egress-interface, ipv6, dns, and TFO
settings. Returning a wrapped net.Conn lets an embedder meter traffic
or apply rate limits; returning (nil, error) rejects the request and
sends a Snell error response to the client.
UDP-over-TCP is authenticated by the same stream-level PSK match, but the current hook surface does not pass per-user policy into the UDP relay path.
QUIC envelope flow
The v5 QUIC path uses the same user store. A new flow's first UDP packet carries a Snell encrypted envelope:
salt(16B) || encrypted frame header(23B) || padding || encrypted payloadIn multi-user mode, decodeQUICEnvelope trial-decrypts that 23-byte
header against the user store, using the same source-IP LRU as the TCP
path. Once a PSK matches, the payload is decrypted, the upstream
host:port is parsed, and the remaining packets in the flow are
forwarded as raw QUIC exactly as before.
The matched user is logged on the snell quic flow line. There is no
OnAuthorize callback for QUIC yet, so embedders that need strict
per-user quota enforcement should treat TCP CONNECT as the currently
enforceable policy point.
Cache and failure behavior
Cold-path authentication is O(N) in the number of users because Snell has no user hint on the wire. To keep the steady state cheap, the authenticator keeps a 4096-entry client-IP to user-index cache:
- On a hit, the server tries the cached user's PSK first. A correct hit costs one Argon2id derivation and one AES-GCM open, matching the single-user fast path.
- On a stale hit, for example after key rotation or panel reshuffling, the server falls back to scanning the current snapshot.
- Up to 16 users are scanned serially; larger snapshots fan out across
GOMAXPROCSworkers. - Empty stores fail closed.
- Failed authentication sleeps for 50 ms before returning, reducing timing signal between "no user matched" and fast disconnect paths.
The cache stores user indexes, not PSKs or user names. An incorrect or evicted cache entry only costs an extra scan; it never authorizes the wrong user because the frame still has to decrypt under that user's PSK.
Rotation pattern
For panels, the intended update model is snapshot replacement:
- Pull the current active users from the panel.
- Build a fresh
[]snell.Userwith one unique random PSK per user. - Call
StaticUserStore.SetUsers(next)from the background pull loop. - Keep old and new PSKs in the snapshot during a grace window if you need zero-downtime rotation, then remove the old entry.
Any authentication attempt that already loaded the previous snapshot continues with that immutable slice. New attempts see the new snapshot.
Alpha Branch
Tracks main with experimental, non-Surge-standard extensions on top. Use main if you want interop purity; use alpha if you specifically want multi-user server mode, TUN, fake-IP DNS, tcp-brutal, or related bypass and probe controls.
TCP Brutal Congestion Control
A per-connection, rate-pinned kernel congestion control algorithm. Useful on high-loss long-fat paths where cubic/bbr collapse — and a sharp footgun on snell, which has no mux.