Changelog
All notable changes to this project are documented here.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
Unreleased
Nothing yet.
0.1.0 - 2026-08-01
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-freebytes -> 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_keysnormalises 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, atsession_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 aNullHandlerat 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 alreadymypy --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_threadbecause 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()(latencyREFRESH_INTERVAL / 2, or the loop’s 0.5s fallback), and usingon_event’ssession.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 aboveshutdown_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.0has 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: whatbuild_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 theexamplesdependency 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_closeddiscarded 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 defeatsconnect_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 installation page all say “these are exactly the commands CI runs, so a clean local run means a green build”, then list ruff, black, and mypy oversrc/,tests/, andexamples/.ci.ymlhas covereddeploy/as well since M4, so a change todeploy/healthcheck.pycould pass everything a contributor was told to run and still redden the build. All four now include it, as doesCONTRIBUTING.md’s style section.The reference container image is unauthenticated, and said so nowhere.
deploy/Dockerfileservesexamples/hello_ssh.py, whose auth falls back toallow_anonymous=Truewhen it finds no~/.ssh/authorized_keys— and the image has none, so the fallback is the only path it takes.docker compose uptherefore published an SSH server accepting any username with no credential on0.0.0.0:8022, underrestart: unless-stopped, from files introduced as “reference artifacts for running awijjit-sshserver in production”. The compose port mapping is127.0.0.1:8022:8022now, and the Dockerfile,deploy/README.md, and the deployment guide each say plainly that the demo app is the unauthenticated part and the hardening around it is what transfers.The unauthenticated examples bound every interface while saying they did not.
hello_ssh.pyandchat_ssh.pyfall back toallow_anonymous=Truewhen they find no~/.ssh/authorized_keys, printed “Fine on localhost; never do this on a real network”, and then calledrun(port=...)— whereServerConfig.hostdefaults to"", meaning0.0.0.0. Running the documented demo on a laptop with noauthorized_keyspublished an open SSH server to whatever network that laptop was on, and the warning implied otherwise. The fallback binds127.0.0.1now;WIJJIT_SSH_HOSToverrides it, anddeploy/Dockerfilesets0.0.0.0because Docker forwards a published port to the container’s address, where a loopback bind is reachable by nobody (the host-side mapping is what keeps that safe, and it is still127.0.0.1). Same class of bug as the compose port mapping above, in the file the compose fix pointed at.The docs site’s
on_eventtable was the under-reported one. The fix above landed inlogging.pyand the README but not indocs/source/guide/logging.rst, which still listedsession.endedassession_id, reason, durationandsession.rejectedwithout its conditionalusername— anddocs/source/examples/index.rstsends readers there for “the full event table”, which is exactly where a hook that subscripts the payload gets written. The page’s sample log lines were invented too: the real record isSession ended after 325.0s: idle_timeout, notSession ended (idle timeout, 5m25s).Dependabot was told to ignore
wijjit. The rule dated from the path source — “there is nothing for dependabot to update and it cannot see the path source anyway” — and survived the move to PyPI, so the one dependency this package is most tightly coupled to was the one it would never propose a bump for. The pin iswijjit>=0.1.0with no upper bound and the seam this package implements lives upstream, which is exactly the caseci.yml’s header names as now being “caught when the pin moves”. The ignore is gone, andwijjitis deliberately outside the grouped tooling PRs so it lands on its own with the full matrix behind it. The neighbouring comment claiming CI does not useuv sync --lockedwas stale for the same reason.deploy/wijjit-ssh.serviceset an environment variable nothing reads.WIJJIT_SSH_HOST_KEYlooked like a library convention;wijjit_sshreads no environment at all. It is for the unit’s ownExecStartapp, and now says so with the one line ofload_host_keysthat consumes it.SPEC.mddescribed a repository that no longer existed. The file tree still markeddeploy/as(TODO, §12)while §13’s own milestone log recorded it[DONE], and the M4 notes still explained CI’s two-checkout arrangement in the present tense — “since wijjit is not on PyPI, each job checks out both repos” — which 0.1.0 undid.SPEC.mdships in the sdist and is linked from the README as the plan of record. A second pass caught the rest: the status line still read “M1, M2 and M3 done” with M4 released two sections below it, the same two-checkout sentence survived in thedocs.ymlnote, the lint description was still the pre-deploy/one, and every test count was stale (“338 tests” against an actual 345, plus six wrong per-file counts in the layout). Counts drift silently because nothing fails when they are wrong, which is whyRELEASING.mdstep 4 now says to check them.Naming
OpenAuthexplicitly bypassed the fail-closed construction check. The gate sat on theauth is Nonebranch, soWijjitSSH(make_app, auth=OpenAuth())built and served an unauthenticated server with only a log warning — noallow_anonymous=Truerequired. Passing no policy raised, so the one spelling that got through was the one that looked more deliberate, and it is the spelling a reader copying from the auth guide would reach for. The check is on the outcome now (auth.auth_required("")), which also catches anOpenAuthburied inside aChainAuth, since a chain waives authentication whenever any member does.auth.py, the authentication guide, andSECURITY.mdhad all documented the behaviour this now implements.A
Wijjitinternal rename would have failed every session. The check that the factory wired the session backend into the app readsapp._backend, a private attribute with no public accessor upstream. Read directly, a rename in a future wijjit would raiseAttributeErrorinside the factory’stry, and every client would be told “Failed to start application”. It goes throughgetattrwith a sentinel now, so the sanity check degrades to silence instead of to an outage. The dependency pin iswijjit>=0.1.0with no upper bound, which is what makes this reachable.The release workflow would have published empty release notes. The awk that lifts this file’s section for the GitHub release used
$0 ~ "^## \\[" ver "\\]". awk parses the string literal before compiling the regex, so\\[arrives as a bare[and the pattern becomes the character class[0.1.0]— it matched nothing, silently. Theverifyjob would not have caught it, because that check usesgrep, where\[behaves. Rewritten as anindex(...) == 1prefix test, which has no escaping question at all, plus a guard that fails the job rather than publishing an empty announcement.The
on_eventtable under-reported what it emits.session.endedcarriesusernameandpeer_ipas well, andsession.rejectedcarriesusernameonly when the refusal came after authentication — so a hook that subscripted the payload wouldKeyErroron a no-pty refusal. Documented, along with the fact thatsession.endedfires for sessions that never emittedsession.started(a no-pty refusal, or an app factory that raised), which any hook pairing the two events has to tolerate.The sdist quietly included three files from
docs/. Hatchling matches[tool.hatch.build.targets.sdist]include patterns gitignore-style, so the bare entryexamplesmatched a directory of that name at any depth and pulled indocs/source/examples/*.rstwhile the rest of the docs stayed out. Every pattern is anchored with a leading/now. The list gaineddeploy/and the docs sources deliberately — as/docs/sourcerather than/docs, so a locally builtdocs/build/cannot reach a release artifact even if someone builds the site beforeuv build.Relative links in
README.mdwould have rendered broken on PyPI. The README is the package’s long description, and PyPI does not resolve relative links the way GitHub does, soLICENSE,SPEC.md, and everyexamples/reference pointed nowhere on the page most people would see first. They are absolute now, as is the one inCHANGELOG.md, which had the same problem for a different reason once the docs site started including it.hello_ssh.py’s Greet button never worked. Action handlers are always called with theActionEvent, and the handler took no parameters, so every press raisedTypeErrorinto_dispatch_action’s catch and the counter stayed at 0. This was the repo’s only example and the README’s headline demo; nothing tests the examples.SPEC.mdwas excluded from the sdist. The[tool.hatch.build.targets.sdist]include list and the README’s link both saidspec.md, which matches nothing on a case-sensitive filesystem.Session teardown ended every session by cancellation.
connection_lostcalledapp.quit()andtask.cancel()in the same tick, butquit()only sets a flag the event loop reads on its next pass, so the cancel always won. Harmless when the peer had already gone, wrong for idle timeout and shutdown, where the channel is still alive and the app’sfinallyis what restores the user’s terminal.The idle-timeout notice landed inside the alternate screen buffer. The message has to be written after the app’s teardown emits
ESC[?1049l, not before, or the diff renderer paints over it.wijjit_sshloggers escaped to stderr. Reusing Wijjit’sget_loggerapplied its"wijjit."prefix only when the name did not already start withwijjit— whichwijjit_ssh.serverdoes. Every logger here landed as a sibling of thewijjittree, inheriting none of its handlers and none of itspropagate = False, so records fell through tologging.lastResortand sprayed across any local TUI’s screen.Pre-auth rejections corrupted the SSH banner. Disconnecting inline from
connection_madeputsMSG_DISCONNECTahead of theSSH-2.0-version string; the rejection is now deferred a tick withloop.call_soon, and reaches the client as a properDisconnectErrorcarrying our text.stop()hung until clients gave up. Draining sessions closes channels, but the SSH connection outlives them and only its owner can close it — and Python 3.12 changedasyncio.Server.wait_closed()to wait for every connection. The server now tracks live connections and disconnects them after the drain.A raising
app_factorydropped the connection silently. It now reports to the client and logs.