Resource limits

Bounded by default, because a limit that is opt-in is not a limit in any deployment where nobody thought about it. Without limits, any peer can open sessions until the process runs out of memory, and a forgotten ssh window holds a session slot forever.

Every value below is a ServerConfig field and can be passed to WijjitSSH as a keyword:

WijjitSSH(
    make_app,
    host_keys=host_keys,
    auth=auth,
    max_sessions=100,        # concurrent sessions, server-wide
    max_per_ip=10,           # concurrent connections from one address
    connect_rate=0.0,        # connections/sec/IP; 0 (the default) disables
    connect_burst=20,        # ...and how many at once before that bites
    login_timeout=30.0,      # seconds to authenticate
    idle_timeout=600.0,      # seconds of silence before disconnect
    session_timeout=None,    # hard cap on duration regardless of activity
    keepalive_interval=30.0, # reap peers whose TCP died without a FIN
    shutdown_grace=5.0,      # seconds sessions get to exit cleanly on stop()
).run()

What each one is for

Setting

Default

What it actually bounds

max_sessions

100

Live Wijjit apps in the process. Really a memory bound - size it against what your app costs, not against a round number.

max_per_ip

10

Concurrent connections from one address, so a single peer cannot consume the whole session pool.

connect_rate / connect_burst

0.0 / 20

Sustained connections per second per IP, as a token bucket. Off by default.

login_timeout

30.0

How long an unauthenticated peer can hold resources. Tightens asyncssh’s own 120 seconds.

idle_timeout

600.0

Silence before a session is closed. This is what reaps the forgotten ssh window. None disables.

session_timeout

None

Hard cap on duration regardless of activity. Off by default because unlike idle_timeout it interrupts someone who is actively working.

keepalive_interval / keepalive_count_max

30.0 / 3

Reclaims peers whose TCP connection died without a FIN - a closed laptop lid, a NAT timeout. A dead peer is gone in about 90 seconds.

The one deliberately-off default is connect_rate. Rate limiting a service you have not measured is how you throttle your own health check, so it is opt-in.

Two chokepoints, not one

``max_per_ip`` counts connections; ``max_sessions`` counts sessions. That is not an inconsistency, it is a consequence of where each one can be enforced:

  • Per-IP limits and the rate limit are pre-authentication, checked when the TCP connection arrives. The entire point is to not spend a key exchange on an abusive peer, and at that moment no session exists yet.

  • max_sessions is inherently post-authentication. A session only exists once a channel is opened, which requires a successful userauth.

Sessions per IP are bounded transitively, since every session lives inside a connection.

Refusals explain themselves

A client turned away hears why, on its terminal, rather than getting a bare protocol error:

This server is at capacity (100 sessions). Please try again shortly.

Too many connections from your address (limit 10). Close one and try again.

Too many connection attempts. Please wait and try again.

Refusals are also logged and emitted to the on_event hook as connection.rejected and session.rejected, each carrying a reason, so “we are turning people away” is something your metrics can show you rather than something users have to report. See Logging and metrics.

Tuning for an exposed deployment

The defaults are meant to be invisible to a legitimate user and finite to an abusive one. A server on a public address wants something tighter:

WijjitSSH(
    make_app,
    host_keys=load_host_keys(["/var/lib/myapp/host_key"]),
    auth=AuthorizedKeys("/etc/myapp/authorized_keys"),
    port=22,
    max_sessions=25,
    max_per_ip=2,
    connect_rate=1.0,        # 1/sec sustained, 20 at once
    login_timeout=15.0,
    idle_timeout=120.0,
).run()

Out-of-range values raise ValueError at construction, and an unknown name raises TypeError rather than being ignored - a typo’d max_session=1 that silently does nothing leaves an operator believing a server is bounded when it is not.

What is not bounded yet

Warning

There is no backpressure handling. A client that stops reading - suspends its terminal, or has its network stall - causes frames to buffer inside asyncssh without bound. max_sessions limits how many such clients can exist, but not how much memory each one can accumulate. This is the largest open item before this package is production-ready on a hostile network.

Two smaller ones:

  • A blocking synchronous handler stalls that session’s frames, and with them the event loop. Give CPU-bound work an executor.

  • Wide characters and emoji are treated as single-width. That is a Wijjit limitation rather than an SSH one.

How it is enforced

wijjit_ssh.limits is pure bookkeeping and policy: no sockets, no asyncssh imports, and an injectable clock. Sessions are reached only through the ManagedSession protocol, which is what lets the real assertions run as fast unit tests with a fake clock and leaves the over-SSH tests to prove only that the wiring is connected.

There is no locking anywhere in that module. It is correct because asyncio is single-threaded and none of its methods await: each runs to completion before another callback can observe the state. try_admit() is one call rather than a check followed by a register for exactly this reason - it makes the atomicity structural rather than a comment a later refactor can invalidate.