wijjit-ssh
Flask for SSH apps. Serve Wijjit TUI
applications over SSH: Wijjit draws the UI, asyncssh handles the transport and
PTY, and every connection gets its own live app instance.
Status: early, but no longer a prototype. The core transport seam it builds on (
wijjit.terminal.backend.TerminalBackend) is in Wijjit proper; this package is the reference backend + server glue. The input path is production-shaped (async byte decoder, no threads), auth is pluggable and fail-closed, and resources are bounded by default. See "Not yet hardened" below for what's left.
pip install wijjit-ssh # or: uv add wijjit-ssh
from wijjit import Wijjit, render_template_string
from wijjit_ssh import WijjitSSH, SSHSession, AuthorizedKeys, ensure_host_key
def make_app(session: SSHSession) -> Wijjit:
app = Wijjit(backend=session.backend) # <- routes I/O to the channel
@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")], # generated on first run
auth=AuthorizedKeys("~/.ssh/authorized_keys"),
).run(port=8022)
uv run python examples/hello_ssh.py # serve on :8022 (makes a host key if needed)
ssh -p 8022 you@localhost # connect from anywhere
How it works
Wijjit's event loop talks to "the terminal" through a TerminalBackend - a
small seam covering four things: frame output, key/mouse input, terminal size,
and whether the app owns the process terminal. Locally that's
LocalTerminalBackend (stdout / stdin / shutil / signals on).
wijjit_ssh.RemoteTerminalBackend implements the same seam against an SSH
channel:
| Concern | Local backend | Remote (SSH) backend |
|---|---|---|
| Frame output | sys.stdout |
chan.write(...) |
| Input | real stdin via prompt_toolkit | raw channel bytes, decoded on the event loop (no thread, no prompt_toolkit) |
| Size | shutil.get_terminal_size() |
negotiated PTY size, refreshed on resize, published per-task |
| Terminal ownership | owns_terminal=True (signals/atexit/suspend/raw mode) |
owns_terminal=False (none of that) |
Because Wijjit's render context and the terminal-size override are contextvar-based, N concurrent sessions of different sizes coexist in one process without stepping on each other - each runs as its own asyncio task.
Authentication
Auth is fail-closed: WijjitSSH raises unless you pass an auth policy
that actually authenticates, or explicitly pass allow_anonymous=True. The gate
is on the outcome rather than on the argument, so auth=OpenAuth() needs
allow_anonymous=True too — naming the open policy is not a way around it.
Serving an unauthenticated SSH server should be something you typed, not
something you inherited by forgetting an argument.
from wijjit_ssh.auth import AuthorizedKeys, PasswordAuth, ChainAuth, check_password
# Public keys - the recommended setup. One file for everyone...
auth = AuthorizedKeys("~/.ssh/authorized_keys")
# ...or one per user.
auth = AuthorizedKeys({"alice": "keys/alice.pub", "bob": "keys/bob.pub"})
# Passwords, checked by your callback (async is fine - hit your DB here).
async def check(username, password):
expected = await lookup(username)
return expected is not None and check_password(password, expected)
auth = PasswordAuth(check)
# Either credential gets you in.
auth = ChainAuth(AuthorizedKeys("~/.ssh/authorized_keys"), PasswordAuth(check))
WijjitSSH(make_app, host_keys=[ensure_host_key("ssh_host_key")], auth=auth).run()
The authenticated username is handed to your factory as session.username, so
apps can personalise and authorise per user.
Use check_password (constant-time) rather than == for plaintext secrets, or
a real password hash for anything stored at rest. Credentials are never logged.
Host keys
A host key is your server's identity: clients pin it on first connect and refuse to talk to you if it changes.
from wijjit_ssh import ensure_host_key, load_host_keys
# Development, or a container with a mounted volume: generated on first run,
# reused forever after.
host_keys = [ensure_host_key("ssh_host_key")]
# Production: manage it out of band and fail loudly if it's missing.
# ssh-keygen -t ed25519 -f /var/lib/myapp/host_key -N ''
host_keys = load_host_keys(["/var/lib/myapp/host_key"])
# Rotation: serve both until clients have seen the new one.
host_keys = load_host_keys(["host_key_new", "host_key_old"])
Keys are loaded when the server is constructed, so a bad path fails there rather
than at listen time, and each fingerprint is logged at startup. ensure_host_key
writes 0600 from creation (POSIX; on Windows the file inherits directory ACLs)
and logs at WARNING when it generates - if you see that on every restart, your
"persistent" volume isn't.
Limits
Bounded by default, because a limit that's opt-in isn't a limit in any
deployment where nobody thought about it. Every value below is a
ServerConfig field, settable as a keyword:
WijjitSSH(
make_app,
host_keys=host_keys,
auth=auth,
max_sessions=100, # concurrent sessions, server-wide
max_per_ip=10, # concurrent connections from one address
connect_rate=0.0, # connections/sec/IP; 0 (default) disables
connect_burst=20, # ...and how many at once before that bites
login_timeout=30.0, # seconds to authenticate
idle_timeout=600.0, # seconds of silence before disconnect; None disables
session_timeout=None, # hard cap on duration regardless of activity
keepalive_interval=30.0, # reap peers whose TCP died without a FIN
shutdown_grace=5.0, # seconds sessions get to exit cleanly on stop()
banner="Authorized users only.\n",
on_event=my_metrics_hook,
).run()
Two things worth knowing:
max_per_ipcounts connections,max_sessionscounts sessions. Per-IP limits are enforced before authentication - the whole point is to not spend a key exchange on an abusive peer, and at that moment no session exists yet. Sessions per IP are bounded transitively.- Refusals explain themselves. A client turned away hears "This server is at capacity" or "Too many connections from your address", not a bare protocol error.
Shutdown
stop() stops accepting, asks live sessions to end, gives them
shutdown_grace to do it, then closes connections and the listener. It's
idempotent and safe to call concurrently.
server = WijjitSSH(make_app, host_keys=host_keys, auth=auth)
await server.start()
...
await server.stop() # drains; returns when everything is down
run() does this for you on SIGINT/SIGTERM. The grace period is not politeness:
a session that exits cleanly runs the app's teardown, which leaves the alternate
screen buffer and restores the user's terminal. One that gets cancelled doesn't,
and leaves a real person with a wedged terminal.
run() owns the process, so it is also the only entry point that installs signal
handlers or configures logging. start()/run_async() touch neither, so you can
embed the server in a larger asyncio application and keep control of both.
Signal handling on Windows is best-effort: SIGTERM is never delivered there, so only Ctrl+C drains.
Logging and metrics
Logs go to the wijjit_ssh logger tree, silent until configured, and never
propagate credentials. Each session gets a short id, bound with the username and
peer address into every line it emits:
2026-07-16 11:04:22 INFO wijjit_ssh.session: [3f9a1c04 ada@10.0.0.7] Session started (term=xterm, 120x40)
run() configures stderr logging unless you already set up your own handler (on
either wijjit_ssh or the root logger). Otherwise call
wijjit_ssh.configure_logging(...) yourself.
For metrics, pass on_event= - called with connection.opened|closed|rejected,
auth.ok|failed, and session.started|rejected|ended (with duration), so you
can wire up Prometheus without this package depending on a metrics library. A
hook that raises is logged and swallowed; it can't take a session down.
Deployment
Reference artifacts live in deploy/
— a sandboxed systemd unit, a non-root Dockerfile, a compose file, and a
healthcheck. All four are files you can run, not snippets to adapt.
sudo install -m 0644 deploy/wijjit-ssh.service /etc/systemd/system/
docker compose -f deploy/compose.yaml up --build
python deploy/healthcheck.py --port 8022 --verbose
Three things go wrong far more often than anything else:
- The host key is not persistent. Every returning user then gets
REMOTE HOST IDENTIFICATION HAS CHANGED, which trains them to ignore the one warning that protects them. Mount a volume, useStateDirectory, and watch forensure_host_key's WARNING on restart — it is telling you the storage isn't. - The supervisor's stop timeout is shorter than
shutdown_grace. Then it sendsSIGKILLmid-drain, no session runs its teardown, and every connected user keeps a wedged terminal.TimeoutStopSecandstop_grace_periodmust both exceed it. - The healthcheck is a TCP connect. A wedged event loop still answers a TCP
handshake — the kernel completes it without the application — so the probe
reports healthy while nobody can log in.
deploy/healthcheck.pyinstead completes the SSH key exchange and treats being refused at authentication as the success condition.
Full write-up, including the production security checklist: https://thomas-villani.github.io/wijjit-ssh/guide/deployment.html
Examples
Three runnable programs in examples/, in the order worth reading:
| Example | What it is for |
|---|---|
hello_ssh.py |
The smallest thing that works: one factory, one view, a text field and a counter. |
dashboard_ssh.py |
A live server dashboard - gauges, history, top processes, and a table of everyone connected to the server drawing it. One shared sampler feeding N windows. |
chat_ssh.py |
A multi-user chat room with no user accounts, because SSH already authenticated everyone. N writers feeding N windows. |
uv run python examples/hello_ssh.py # :8022
uv run python examples/chat_ssh.py # :8023
uv sync --group examples # dashboard needs psutil
uv run --group examples python examples/dashboard_ssh.py # :8022
The first two fall back to no authentication when they find no
~/.ssh/authorized_keys, so on that path they bind loopback only —
WIJJIT_SSH_HOST widens it if you mean to. The dashboard refuses to start
instead, because it shows the machine's process table and every connected user's
address. That contrast is the point: allow_anonymous is a decision about what
the app exposes, not a default to inherit.
The first is about the transport; the other two are about the thing the transport
makes possible - N live apps in one process, sharing state. Both are built the
same way: a hub at module scope that every view reads directly, and
app.refresh() to tell the other sessions' apps to redraw, since each is parked
in its own task waiting for a keypress that may never come. The push latency is
REFRESH_INTERVAL / 2, or 0.5s if you leave it unset.
The non-obvious half is unsubscribing. There is no teardown hook on the factory,
and app.running races the server (the factory runs before the app's task
starts). Use on_event= instead: session.ended fires on every way out - quit,
idle timeout, dropped TCP, stop() - and carries the session_id the factory
registered under. Test it by closing a client's terminal rather than pressing
Ctrl+Q; that is the path a hand-rolled subscriber list gets wrong.
Full write-ups: https://thomas-villani.github.io/wijjit-ssh/examples/
Done
- Async byte-decoded input. Raw channel bytes are decoded into Wijjit
key/mouse events on the event loop - no thread and no prompt_toolkit pipe per
session. Handles split escape sequences, split UTF-8 runes, SGR + legacy
mouse, bracketed paste, and the lone-ESC ambiguity. (
wijjit_ssh.input) - Binary channel (
encoding=None): the decoder sees exactly what the client sent. - Pluggable, fail-closed auth: public key, password, keyboard-interactive,
chained. (
wijjit_ssh.auth) - Per-session isolation. N concurrent sessions, each with its own app, state, and terminal size, in one process and one event loop.
- Host keys that generate on first run or load for production, with
rotation. (
wijjit_ssh.keys) - Resource limits on sessions, per-IP connections, connect rate, idle time,
and session duration - on by default. (
wijjit_ssh.limits) - Graceful shutdown: drains sessions so clients get their terminals back,
on
stop()or a signal. - Per-session logging and a metrics hook. (
wijjit_ssh.logging) - No shell, exec, sftp, or port forwarding. A session only ever runs a Wijjit app; there is no code path to anything else. That's a feature.
Not yet hardened
- No backpressure handling. A client that stops reading buffers frames in asyncssh without bound.
- Blocking sync handlers stall that session's frames - give CPU-bound apps an executor.
- Wide chars / emoji are treated as single-width (a Wijjit limitation, not an SSH one).
Documentation
Full docs at https://thomas-villani.github.io/wijjit-ssh/ — quickstart,
guides for each of the subjects above, write-ups of the examples, and an API
reference over all eight modules. See SPEC.md for the full plan and
remaining milestones.
Development
git clone https://github.com/thomas-villani/wijjit-ssh.git
cd wijjit-ssh
uv sync # wijjit comes from PyPI
uv run pytest -q # 351 passed, 4 skipped
uv run ruff check src/ tests/ examples/ deploy/
uv run black --check src/ tests/ examples/ deploy/
uv run mypy src/ deploy/
uv run pytest --cov=src/wijjit_ssh --cov-report=term-missing -q
Those are exactly the commands CI runs, so a clean local run means a green build. The docs are a separate dependency group, so a test run doesn't pay for Sphinx:
uv sync --group docs
uv run sphinx-build -b html -W --keep-going docs/source docs/build/html
-W is what CI uses; the build is warning-clean, and keeping it that way is the
point of the flag.
psutil is its own examples group, so uv run --group examples python examples/dashboard_ssh.py installs what that one example wants. dev includes
that group rather than duplicating it, because tests/test_examples.py imports
every example and so a plain uv sync has to bring it too.
The four skips are all POSIX-only - three 0600 host-key mode-bit assertions and
the end-to-end SIGTERM drain - so on Linux and macOS the suite reports 355 passed.
CI covers Python 3.11-3.13 on Linux, macOS, and Windows.
Working against an unreleased Wijjit
wijjit is an ordinary PyPI dependency as of 0.1.0. To develop the two libraries
in tandem again, point at a local checkout without committing the change —
release.yml refuses to build while [tool.uv.sources] is in pyproject.toml,
because a path source means the PyPI pin has never actually been resolved:
uv sync && uv pip install -e ../wijjit # leaves pyproject.toml alone
CONTRIBUTING.md
has the rest: style rules, what the tests are expected to look like, the commit
conventions, and what is deliberately out of scope.
RELEASING.md
covers cutting a version.
Security
This is an SSH server: it terminates untrusted connections and authenticates
them. Report vulnerabilities privately, through
GitHub Security Advisories
rather than a public issue.
SECURITY.md
says what is in scope, what is a known and documented gap rather than a finding,
and where the trust boundary sits.
The load-bearing guarantee is negative: no shell, no exec, no SFTP, no port
forwarding. Those asyncssh handlers are never implemented, so a session has no
code path to anything but your Wijjit app.
License
MIT — see LICENSE.
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file wijjit_ssh-0.1.0.tar.gz.
File metadata
- Download URL: wijjit_ssh-0.1.0.tar.gz
- Upload date:
- Size: 166.8 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
233898660bacb91091877745465d14fdfa15d4fd9abdc2215403d4c361d3115a
|
|
| MD5 |
9be2bd4175fc655a79f769dc64694c6d
|
|
| BLAKE2b-256 |
f525aa040d94c732187b2951057737a93e01c8772419fa4366b9342026e54a62
|
Provenance
The following attestation bundles were made for wijjit_ssh-0.1.0.tar.gz:
Publisher:
release.yml on thomas-villani/wijjit-ssh
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
wijjit_ssh-0.1.0.tar.gz -
Subject digest:
233898660bacb91091877745465d14fdfa15d4fd9abdc2215403d4c361d3115a - Sigstore transparency entry: 2335548134
- Sigstore integration time:
-
Permalink:
thomas-villani/wijjit-ssh@7b281c0571aef470b7fa19e48b74ad122ccba16c -
Branch / Tag:
refs/tags/v0.1.0 - Owner: https://github.com/thomas-villani
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@7b281c0571aef470b7fa19e48b74ad122ccba16c -
Trigger Event:
push
-
Statement type:
File details
Details for the file wijjit_ssh-0.1.0-py3-none-any.whl.
File metadata
- Download URL: wijjit_ssh-0.1.0-py3-none-any.whl
- Upload date:
- Size: 61.8 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
4fc235ed090d1e46edaf84440f32d1c4f64727d38d4c8e53a0456a14475a5fd5
|
|
| MD5 |
2d3f96274da1c2c84c8819b13d87ea61
|
|
| BLAKE2b-256 |
628a6241d4edce54376d4be4cc991ef218561986711e2bfff8170eb56c76c661
|
Provenance
The following attestation bundles were made for wijjit_ssh-0.1.0-py3-none-any.whl:
Publisher:
release.yml on thomas-villani/wijjit-ssh
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
wijjit_ssh-0.1.0-py3-none-any.whl -
Subject digest:
4fc235ed090d1e46edaf84440f32d1c4f64727d38d4c8e53a0456a14475a5fd5 - Sigstore transparency entry: 2335548201
- Sigstore integration time:
-
Permalink:
thomas-villani/wijjit-ssh@7b281c0571aef470b7fa19e48b74ad122ccba16c -
Branch / Tag:
refs/tags/v0.1.0 - Owner: https://github.com/thomas-villani
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@7b281c0571aef470b7fa19e48b74ad122ccba16c -
Trigger Event:
push
-
Statement type: