Skip to content

Releases: thomas-villani/wijjit-ssh

Release list

v0.1.0

Choose a tag to compare

@github-actions github-actions released this 03 Aug 15:48

First release. wijjit-ssh serves Wijjit
TUI apps over SSH — you write a factory that builds an app per connection, and
clients ssh straight into it. The work below is the history from the original
prototype to a deployable server, by milestone (see
SPEC.md).

Publishing was blocked on wijjit itself reaching PyPI, since pyproject.toml
resolved it from a sibling checkout. wijjit 0.1.0 is now published, so
[tool.uv.sources] is gone and wijjit>=0.1.0 resolves from the real index —
the precondition release.yml refuses to build without.

Known gap, by design: there is no backpressure handling yet. A client that
stops reading buffers frames in asyncssh without bound. It is the headline item
in SPEC.md's M5 and is documented in the README, the docs, and SECURITY.md.

Added

  • Async byte-parser input path (M1). KeyDecoder, a resumable, side-effect-free
    bytes -> Key | MouseEvent state machine, and ChannelInputSource, which feeds it
    from the SSH channel on the event loop. Handles split escape sequences, UTF-8 runes
    split across packets, CSI and SS3 keys with modifiers, SGR and legacy X10 mouse,
    bracketed paste, and the lone-ESC ambiguity. Replaces the prototype's per-session
    reader thread and prompt_toolkit pipe.

  • Binary channel (M1). The server channel is opened with encoding=None, so the
    decoder sees exactly the bytes the client sent.

  • Pluggable authentication (M2). AuthPolicy with AuthorizedKeys,
    PasswordAuth, ChainAuth, and the development-only OpenAuth; every asyncssh
    auth callback is forwarded to the policy. Construction is fail-closed:
    WijjitSSH raises without a policy unless allow_anonymous=True is passed.
    check_password provides a constant-time comparison. Credentials are never logged.

  • Host keys (M3). ensure_host_key generates and persists an ed25519 key on first
    run (written 0600 from creation via O_CREAT | O_EXCL, so there is no window where
    the server's identity is world-readable, and so two processes starting together
    cannot race); load_host_keys loads keys managed out of band; resolve_host_keys
    normalises paths, PathLike, and live SSHKey objects. Resolution is eager at
    construction, so a bad path fails where the server is configured.

  • Resource limits (M3). SessionRegistry enforcing max_sessions (post-auth, at
    session_requested), max_per_ip connections and a connect_rate token bucket
    (both pre-auth), plus login_timeout, idle_timeout, session_timeout, and
    keepalives. On by default, because a limit that is opt-in is not a limit in any
    deployment where nobody thought about it. Refused clients get an explanatory
    message rather than a bare protocol error.

  • ServerConfig (M3). One validated dataclass holding every knob, with unknown
    keyword overrides raising TypeError rather than being silently dropped.

  • Graceful shutdown (M3). stop() closes the listener, drains live sessions with a
    real grace period so each app's teardown restores the client's terminal, then closes
    the connections underneath them. Idempotent, lock-guarded, and safe on a server that
    never started. run() wires it to SIGINT/SIGTERM; start()/run_async()
    deliberately install no process-global handlers so the server can be embedded.

  • Per-session logging and metrics (M3). A wijjit_ssh logger tree with a
    NullHandler at import, SessionLog binding session id / username / peer IP into
    every line, and an on_event hook for connection.*, auth.*, and session.*.
    A hook that raises is logged and swallowed rather than taking a session down.

  • Non-PTY refusal. A session that never requests a PTY is declined with a friendly
    message; this server only serves interactive TUIs.

  • PEP 561 marker (M4). py.typed ships in the wheel. The tree was already
    mypy --strict clean and fully annotated, but without the marker every downstream
    type checker silently treated it as untyped.

  • Documentation site (M4). A Sphinx site under docs/ — quickstart, guides for
    authentication, host keys, limits, shutdown, logging, and the terminal input path,
    and an autodoc API reference over all eight modules — built with warnings as errors
    and published to GitHub Pages.

  • Two examples about serving many sessions at once (M4).
    examples/dashboard_ssh.py is a live server dashboard — CPU and memory gauges, a
    history chart, the heaviest processes, and a table of everyone connected to the
    server drawing it — fed by a single sampler task that starts on the first viewer
    and stops after the last, and that does its psutil work in asyncio.to_thread
    because every session shares one event loop. examples/chat_ssh.py is a multi-user
    chat room with no user accounts at all, since SSH authenticated everyone before the
    app existed. Both demonstrate the two things that only come up over SSH: pushing to
    a session from outside its own task with app.refresh() (latency
    REFRESH_INTERVAL / 2, or the loop's 0.5s fallback), and using on_event's
    session.ended to unsubscribe — the only signal that covers a dropped connection as
    well as a polite quit. Written up under docs/source/examples/. psutil is declared
    in a new PEP 735 examples group, so uv sync for the test suite does not build it.

  • Deployment artifacts (M4, spec §12). deploy/ ships a systemd unit, a
    Dockerfile, a compose file, and healthcheck.py, with a guide page describing
    them — written as files that have been run rather than snippets that were typed.
    The healthcheck is the part with a real argument behind it: a TCP probe passes
    against a wedged event loop, because the kernel completes the handshake without
    the application ever being scheduled, so it instead completes the SSH key
    exchange and treats being refused at authentication as the success condition.
    That proves the loop is running, the host key loads, and the auth policy is
    reachable. The unit and the compose file both set a stop timeout well above
    shutdown_grace, since a supervisor that kills mid-drain undoes the entire
    point of the drain. The guide page carries the production security checklist.

  • Release pipeline (M4). .github/workflows/release.yml publishes on a v*
    tag via PyPI Trusted Publishing — OIDC, so there is no API token to store or
    leak — then opens a GitHub release with the changelog section as its notes. It
    refuses to build unless the tag matches __version__, the changelog has a
    matching section, py.typed is in the wheel, twine check --strict passes,
    and [tool.uv.sources] is gone: while that section exists, wijjit>=0.1.0
    has never once been resolved from the real index by anything, here or in CI,
    and a version number on PyPI cannot be reused after that is discovered.
    RELEASING.md has the procedure and the one-time trusted-publisher setup.

  • Contributor documentation (M4). CONTRIBUTING.md (setup, the exact checks
    CI runs, style, the commit conventions the log already follows, and what is
    deliberately out of scope), SECURITY.md (private reporting, what is in scope,
    and which known gaps are documented limitations rather than findings), issue
    and PR templates, and a dependabot.yml that groups the tooling bumps.
    CHANGELOG.md and CONTRIBUTING.md are pages on the docs site now, included
    rather than copied — which is what conf.py's myst_parser had been enabled
    for since the site was built, and never used.

  • Smoke tests for examples/ (tests/test_examples.py, 10 tests). Nothing
    else in the tree imports the examples, so nothing else noticed when one broke
    — twice now: the Greet button below, and the bind address in "Fixed". Each
    example is loaded by path into its own module with Path.home() and the
    working directory redirected into tmp_path, so a result does not depend on
    whether the developer running it happens to have SSH keys. Two layers: what
    build_server() decides to expose (the anonymous fallback binds loopback, the
    authenticated path binds every interface, WIJJIT_SSH_HOST overrides both,
    dashboard_ssh.py refuses to start at all), and what each puts on a real
    client's screen over a real socket — hello_ssh.py's frame and its button,
    and a chat join pushed into an already-open window. Deliberately coarse:
    nothing here asserts on chart layout or border spacing. dev now includes the
    examples dependency group, since a plain uv sync has to be able to import
    every example; --group examples still means "what the dashboard wants".

Fixed

  • connect_rate never limited a rate. SessionRegistry.connection_closed
    discarded a peer's token bucket once its last connection went away, on the
    reasoning that the dict would otherwise grow one entry per distinct peer
    forever. But the attack connect_rate exists to stop is connect, get refused
    at auth, disconnect, repeat — and that peer holds zero connections at every
    moment connection_closed runs. Every attempt therefore found no bucket, built
    a fresh one, and spent a full burst: with connect_rate=1.0, connect_burst=3,
    50 serial connections were admitted in zero elapsed time. What it actually
    enforced was a second concurrency limit, duplicating max_per_ip. A bucket now
    outlives its connections and is only forgotten once it has refilled, at which
    point it is indistinguishable from the fresh one that would replace it; a
    flood from many addresses at once is bounded by an amortized sweep of the
    refilled ones. SECURITY.md lists resource exhaustion that defeats
    connect_rate as in scope, so this was a documented guarantee the code did
    not keep.
  • The documented check commands were not the ones CI runs. README.md,
    CONTRIBUTING.md, RELEASING.md, and the install...
Read more