Releases: thomas-villani/wijjit-ssh
Release list
v0.1.0
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 | MouseEventstate machine, andChannelInputSource, 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).
AuthPolicywithAuthorizedKeys,
PasswordAuth,ChainAuth, and the development-onlyOpenAuth; every asyncssh
auth callback is forwarded to the policy. Construction is fail-closed:
WijjitSSHraises without a policy unlessallow_anonymous=Trueis passed.
check_passwordprovides a constant-time comparison. Credentials are never logged. -
Host keys (M3).
ensure_host_keygenerates and persists an ed25519 key on first
run (written0600from creation viaO_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_keysloads keys managed out of band;resolve_host_keys
normalises paths,PathLike, and liveSSHKeyobjects. Resolution is eager at
construction, so a bad path fails where the server is configured. -
Resource limits (M3).
SessionRegistryenforcingmax_sessions(post-auth, at
session_requested),max_per_ipconnections and aconnect_ratetoken bucket
(both pre-auth), pluslogin_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 raisingTypeErrorrather 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_sshlogger tree with a
NullHandlerat import,SessionLogbinding session id / username / peer IP into
every line, and anon_eventhook forconnection.*,auth.*, andsession.*.
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.typedships in the wheel. The tree was already
mypy --strictclean 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.pyis 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 itspsutilwork inasyncio.to_thread
because every session shares one event loop.examples/chat_ssh.pyis 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 withapp.refresh()(latency
REFRESH_INTERVAL / 2, or the loop's 0.5s fallback), and usingon_event's
session.endedto unsubscribe — the only signal that covers a dropped connection as
well as a polite quit. Written up underdocs/source/examples/.psutilis declared
in a new PEP 735examplesgroup, souv syncfor the test suite does not build it. -
Deployment artifacts (M4, spec §12).
deploy/ships a systemd unit, a
Dockerfile, a compose file, andhealthcheck.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.ymlpublishes on av*
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.typedis in the wheel,twine check --strictpasses,
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.mdhas 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 adependabot.ymlthat groups the tooling bumps.
CHANGELOG.mdandCONTRIBUTING.mdare pages on the docs site now, included
rather than copied — which is whatconf.py'smyst_parserhad 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 withPath.home()and the
working directory redirected intotmp_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_HOSToverrides both,
dashboard_ssh.pyrefuses 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.devnow includes the
examplesdependency group, since a plainuv synchas to be able to import
every example;--group examplesstill means "what the dashboard wants".
Fixed
connect_ratenever 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 attackconnect_rateexists to stop is connect, get refused
at auth, disconnect, repeat — and that peer holds zero connections at every
momentconnection_closedruns. Every attempt therefore found no bucket, built
a fresh one, and spent a full burst: withconnect_rate=1.0, connect_burst=3,
50 serial connections were admitted in zero elapsed time. What it actually
enforced was a second concurrency limit, duplicatingmax_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.mdlists resource exhaustion that defeats
connect_rateas 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...