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 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.yml
has covereddeploy/as well since M4, so a change todeploy/healthcheck.py
could 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 to
allow_anonymous=Truewhen it finds no~/.ssh/authorized_keys— and the
image has none, so the fallback is the only path it takes.docker compose up
therefore published an SSH server accepting any username with no credential on
0.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=True
when they find no~/.ssh/authorized_keys, printed "Fine on localhost; never
do this on a real network", and then calledrun(port=...)— where
ServerConfig.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 in
docs/source/guide/logging.rst, which still listedsession.endedas
session_id, reason, durationandsession.rejectedwithout its conditional
username— 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 is
Session 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 use
uv 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 an
OpenAuthburied inside aChainAuth, since a chain waives authentication
whenever any member does.auth.py, the authentication guide, andSECURITY.md
had 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 through
getattrwith 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 an
index(...) == 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.endedcarries
usernameandpeer_ipas well, andsession.rejectedcarriesusername
only 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 emitted
session.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 raised
TypeErrorinto_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_lostcalled
app.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 emitsESC[?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— which
wijjit_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.