OpenSnell
Alpha Branch

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:

  1. Read the first 16-byte Snell salt.
  2. Read the first 23-byte AEAD-sealed frame header.
  3. Try to derive AES-GCM with each registered user's PSK and open that 23-byte header with nonce 0.
  4. Accept a user only when AEAD open succeeds and the plaintext frame marker is 0x04.
  5. 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.
  6. 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 normally

When 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 payload

In 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 GOMAXPROCS workers.
  • 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:

  1. Pull the current active users from the panel.
  2. Build a fresh []snell.User with one unique random PSK per user.
  3. Call StaticUserStore.SetUsers(next) from the background pull loop.
  4. 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.

On this page