wijjit_ssh.server

A “Flask for SSH apps” server: expose a Wijjit app over SSH.

WijjitSSH wraps asyncssh so that each incoming SSH connection gets its own Wijjit application, driven through a RemoteTerminalBackend. You supply a factory - the SSH analogue of a Flask view - that builds an app per connection:

>>> from wijjit import Wijjit, render_template_string
>>> from wijjit_ssh import WijjitSSH
>>> from wijjit_ssh.auth import AuthorizedKeys
>>>
>>> def make_app(session):
...     app = Wijjit(backend=session.backend)
...     @app.view("main", default=True)
...     def main():
...         return render_template_string(
...             "{% frame %}{% text %}Hi {{ who }}!{% endtext %}{% endframe %}",
...             who=session.username,
...         )
...     return app
>>>
>>> WijjitSSH(
...     make_app,
...     host_keys=[ensure_host_key("ssh_host_key")],
...     auth=AuthorizedKeys("~/.ssh/authorized_keys"),
... ).run(port=8022)

Then ssh -p 8022 you@localhost drops the client straight into the TUI.

Authentication is fail-closed: constructing WijjitSSH without an auth policy raises unless allow_anonymous=True is passed explicitly. See wijjit_ssh.auth.

Resources are bounded by default: concurrent sessions, connections per IP, idle time, and login time all have limits without being asked for. See ServerConfig to tune them and wijjit_ssh.limits for how they are enforced.

Not yet hardened

  • No backpressure handling: a client that stops reading buffers frames in asyncssh without bound (M5).

  • Blocking sync handlers stall that session’s frames; give each app an executor (EXECUTOR) for CPU-bound work.

class wijjit_ssh.server.SSHSession(username, term_type, columns, lines, backend, conn, session_id='', peer_ip='')[source]

Bases: object

Context passed to the per-connection app factory.

Variables:
  • username (str) – Username the client authenticated as.

  • term_type (str) – Client’s TERM (e.g. "xterm-256color").

  • columns (int) – Negotiated terminal width.

  • lines (int) – Negotiated terminal height.

  • backend (RemoteTerminalBackend) – The transport for this connection. Pass it to the app: Wijjit(backend=session.backend) - this is what routes the app’s I/O to the SSH channel instead of the server’s console.

  • conn (asyncssh.SSHServerConnection) – The underlying connection, for advanced use.

  • session_id (str) – Short correlation id, matching the one in this session’s log lines. Worth surfacing in an app’s own logs or error messages: it is what ties a user’s report back to the server-side record.

  • peer_ip (str) – Client address, e.g. for per-user rate limiting inside the app.

Parameters:
class wijjit_ssh.server.WijjitSSH(app_factory, config=None, **overrides)[source]

Bases: object

Serve a Wijjit app over SSH, one app instance per connection.

Parameters:
  • app_factory (Callable[[SSHSession], Wijjit]) – Builds the app for each connection (the SSH analogue of a Flask view).

  • config (ServerConfig, optional) – Every knob the server takes; see ServerConfig. Defaults are used when omitted.

  • **overrides

    Any ServerConfig field, as a keyword. Applied on top of config, so the common case needs no config object at all:

    WijjitSSH(make_app, host_keys=[key], auth=policy, max_sessions=10)
    

    Unknown names raise TypeError rather than being ignored - a typo’d max_session=1 that silently does nothing would leave a server the operator believes is bounded and which is not.

Variables:

config (ServerConfig) – The resolved configuration, after overrides and validation.

Raises:
  • ValueError – If the server would run unauthenticated and allow_anonymous is not True - whether that is because no auth policy was given, or because the one given waives authentication (OpenAuth, or a ChainAuth containing one). Serving an unauthenticated SSH server is a decision that has to be typed out, not one you inherit by forgetting an argument - so the default fails closed rather than silently accepting every client on the internet. Also raised for an out-of-range config value, or an unreadable host key.

  • TypeError – If an override is not a config field.

Examples

>>> from wijjit_ssh import AuthorizedKeys, ensure_host_key, WijjitSSH
>>> WijjitSSH(
...     make_app,
...     host_keys=[ensure_host_key("ssh_host_key")],
...     auth=AuthorizedKeys("~/.ssh/authorized_keys"),
... ).run()

Or build the config up front, e.g. from a file or argparse:

>>> config = ServerConfig(port=2222, max_sessions=10)
>>> WijjitSSH(make_app, config, host_keys=[key], auth=policy).run()
property active_sessions: int

How many sessions are live right now.

async start(host=None, port=None)[source]

Bind the listener and start accepting connections.

Returns as soon as the server is listening, so callers can drive it (tests bind port 0 and read the assigned port off the acceptor). Use run_async() to start and then serve forever.

Does not configure logging or install signal handlers: this entry point may be one coroutine inside a larger application, which owns both. Use run() when the server owns the process.

Parameters:
  • host (str, optional) – Bind address, overriding config.host.

  • port (int, optional) – Bind port, overriding config.port. Pass 0 to let the OS choose.

Returns:

The listening server; call close() on it to stop accepting.

Return type:

asyncssh.SSHAcceptor

Raises:

ValueError – If no host keys were configured. asyncssh would refuse every connection with an opaque handshake failure, so say it plainly here.

async stop(*, grace=None)[source]

Stop accepting, drain live sessions, and close the listener.

Idempotent and safe to call concurrently: a second caller awaits the first rather than racing it. Safe to call on a server that never started.

The order is deliberate. Accepting stops first, so the drain is not chasing a moving target. Then sessions are asked to end and given grace to do it, because a session that ends cleanly runs the app’s teardown and restores the client’s terminal, while one that is cancelled leaves a real person in the alternate screen buffer. Only then does the listener close.

Parameters:

grace (float, optional) – Seconds to allow for a clean exit, overriding config.shutdown_grace.

Return type:

None

Examples

>>> server = WijjitSSH(make_app, host_keys=[key], auth=policy)
>>> await server.start()
>>> await server.stop()
async run_async(host=None, port=None)[source]

Start the SSH server and serve until stop() is called.

Like start(), this configures no logging and installs no signal handlers - it may be embedded in a host application that owns both. A host that wants signal handling should install its own and call stop(), or use run().

Parameters:
  • host (str, optional) – Bind address, overriding config.host.

  • port (int, optional) – Bind port, overriding config.port.

Return type:

None

run(host=None, port=None)[source]

Serve until interrupted, draining cleanly. Blocking; owns the process.

The entry point for “this process is the server”, as opposed to run_async(), which may be one coroutine inside a larger application. That ownership is the whole distinction: this is the only method that configures logging or installs signal handlers, because a library coroutine has no business doing either to somebody else’s process. (It is the same reasoning that makes the backend set owns_terminal = False.)

On SIGINT/SIGTERM the server stops accepting, gives live sessions config.shutdown_grace to exit cleanly - which is what restores each client’s terminal - and then exits.

Parameters:
  • host (str, optional) – Bind address, overriding config.host.

  • port (int, optional) – Bind port, overriding config.port.

Return type:

None

Notes

Signal handling on Windows is best-effort: SIGTERM is never delivered there (TerminateProcess does not run handlers), so only Ctrl+C drains. The deployment targets in the README are systemd and Docker, both POSIX.