├── .nvmrc ├── run └── logs │ └── .gitkeep ├── .husky ├── pre-push ├── pre-commit ├── prepare-commit-msg └── scripts │ └── no-commit-to-main.mjs ├── docker ├── .env ├── traefik │ ├── Dockerfile │ ├── dynamic.yml │ └── traefik.yml ├── docker-compose.dev.yml ├── Dockerfile └── docker-compose.yml ├── tests ├── __init__.py ├── cli │ ├── __init__.py │ ├── test_web.py │ ├── test_ssh.py │ └── test_db.py ├── ssh │ ├── __init__.py │ ├── context │ │ ├── __init__.py │ │ └── test_scripting.py │ └── test_start_server.py ├── web │ ├── __init__.py │ ├── test_asgi.py │ ├── test_lifespan.py │ ├── test_start_server.py │ └── test_auth.py ├── mocks │ ├── __init__.py │ └── config.py ├── test_configuration.py ├── test_logger.py ├── test_resources.py ├── test_locks.py └── test_events.py ├── xthulu ├── __init__.py ├── ssh │ ├── console │ │ ├── internal │ │ │ ├── __init__.py │ │ │ ├── file_wrapper.py │ │ │ ├── input.py │ │ │ └── driver.py │ │ ├── __init__.py │ │ ├── choice.py │ │ ├── app.py │ │ ├── banner_app.py │ │ └── art.py │ ├── structs.py │ ├── context │ │ ├── lock_manager.py │ │ ├── handle_events.py │ │ ├── logger_adapter.py │ │ └── scripting.py │ ├── codecs │ │ └── __init__.py │ ├── exceptions.py │ ├── __init__.py │ ├── server.py │ ├── proxy_protocol.py │ └── process_factory.py ├── __main__.py ├── models │ ├── __init__.py │ └── user.py ├── web │ ├── asgi.py │ ├── auth.py │ └── __init__.py ├── cli │ ├── _util.py │ ├── web.py │ ├── __init__.py │ ├── __main__.py │ ├── ssh.py │ └── db.py ├── events │ ├── structs.py │ └── __init__.py ├── configuration │ ├── default.py │ └── __init__.py ├── logger.py ├── resources.py └── locks.py ├── .prettierignore ├── userland ├── web │ ├── schema │ │ ├── __init__.py │ │ └── chat.py │ ├── routes │ │ ├── __init__.py │ │ ├── logout.py │ │ └── chat.py │ ├── __init__.py │ └── static │ │ ├── logout │ │ ├── script.mts │ │ └── index.html │ │ ├── index.html │ │ ├── vite.config.ts │ │ └── chat │ │ ├── index.html │ │ ├── styles.css │ │ └── script.ts ├── scripts │ ├── __init__.py │ ├── main.tcss │ ├── oneliners.tcss │ ├── logoff.py │ ├── nua.tcss │ ├── messages │ │ ├── __init__.py │ │ ├── styles.tcss │ │ ├── editor_screen.py │ │ ├── save_modal.py │ │ ├── view_screen.py │ │ └── filter_modal.py │ ├── lock_example.py │ ├── nua.py │ ├── main.py │ ├── oneliners.py │ ├── top.py │ └── chat.py ├── __init__.py ├── __main__.py ├── artwork │ ├── login.ans │ ├── main.ans │ ├── nua.ans │ ├── logoff.ans │ ├── sysinfo.ans │ ├── oneliners.ans │ └── messages.ans ├── cli │ ├── __init__.py │ ├── __main__.py │ └── db.py └── models │ ├── __init__.py │ ├── message │ ├── tag.py │ ├── message_tags.py │ ├── __init__.py │ └── api.py │ └── oneliner.py ├── .github ├── FUNDING.yml ├── workflows │ ├── retry.yml │ ├── release.yml │ ├── codeql-analysis.yml │ ├── docker-build.yml │ ├── commitlint.yml │ ├── checks.yml │ └── tests.yml └── pull_request_template.md ├── requirements ├── dev.in ├── requirements.in ├── dev.txt └── requirements.txt ├── bin ├── xt ├── xtu ├── update ├── build-web └── setup ├── etc └── fail2ban │ ├── filter.d │ ├── xthulu_ssh.conf │ └── xthulu_www.conf │ └── jail.d │ └── xthulu.conf ├── .gitignore ├── .editorconfig ├── .vscode ├── extensions.json └── settings.json ├── .eslintrc.json ├── data ├── config.example.toml └── config.schema.json ├── tsconfig.json ├── pyproject.toml ├── .releaserc.json ├── LICENSE.md ├── .commitlintrc.cjs ├── package.json ├── CHECKLIST.md ├── README.md └── CONTRIBUTING.md /.nvmrc: -------------------------------------------------------------------------------- 1 | lts/jod 2 | -------------------------------------------------------------------------------- /run/logs/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.husky/pre-push: -------------------------------------------------------------------------------- 1 | pytest --mypy --cov . 2 | -------------------------------------------------------------------------------- /docker/.env: -------------------------------------------------------------------------------- 1 | COMPOSE_PROJECT_NAME=xthulu 2 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | """xthulu unit tests""" 2 | -------------------------------------------------------------------------------- /tests/cli/__init__.py: -------------------------------------------------------------------------------- 1 | """CLI tests module""" 2 | -------------------------------------------------------------------------------- /tests/ssh/__init__.py: -------------------------------------------------------------------------------- 1 | """SSH server tests""" 2 | -------------------------------------------------------------------------------- /xthulu/__init__.py: -------------------------------------------------------------------------------- 1 | """xthulu community server""" 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | CHANGELOG.md 2 | package-lock.json 3 | -------------------------------------------------------------------------------- /tests/ssh/context/__init__.py: -------------------------------------------------------------------------------- 1 | """SSH context tests""" 2 | -------------------------------------------------------------------------------- /tests/web/__init__.py: -------------------------------------------------------------------------------- 1 | """Web server test module""" 2 | -------------------------------------------------------------------------------- /userland/web/schema/__init__.py: -------------------------------------------------------------------------------- 1 | """Userland schema""" 2 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: haliphax 2 | ko_fi: haliphax 3 | -------------------------------------------------------------------------------- /docker/traefik/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM traefik:2.9 2 | COPY *.yml / 3 | -------------------------------------------------------------------------------- /userland/scripts/__init__.py: -------------------------------------------------------------------------------- 1 | """Default userland scripts""" 2 | -------------------------------------------------------------------------------- /userland/__init__.py: -------------------------------------------------------------------------------- 1 | """xthulu community server default userland""" 2 | -------------------------------------------------------------------------------- /xthulu/ssh/console/internal/__init__.py: -------------------------------------------------------------------------------- 1 | """Console engine internals""" 2 | -------------------------------------------------------------------------------- /tests/mocks/__init__.py: -------------------------------------------------------------------------------- 1 | """Reusable mocks and patch helpers for test cases""" 2 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | node .husky/scripts/no-commit-to-main.mjs 2 | node_modules/.bin/nano-staged 3 | -------------------------------------------------------------------------------- /userland/__main__.py: -------------------------------------------------------------------------------- 1 | """Main entry point""" 2 | 3 | from .cli import __main__ # noqa: F401 4 | -------------------------------------------------------------------------------- /xthulu/__main__.py: -------------------------------------------------------------------------------- 1 | """Main entry point""" 2 | 3 | from .cli import __main__ # noqa: F401 4 | -------------------------------------------------------------------------------- /userland/artwork/login.ans: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/haliphax/xthulu/HEAD/userland/artwork/login.ans -------------------------------------------------------------------------------- /userland/artwork/main.ans: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/haliphax/xthulu/HEAD/userland/artwork/main.ans -------------------------------------------------------------------------------- /userland/artwork/nua.ans: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/haliphax/xthulu/HEAD/userland/artwork/nua.ans -------------------------------------------------------------------------------- /userland/artwork/logoff.ans: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/haliphax/xthulu/HEAD/userland/artwork/logoff.ans -------------------------------------------------------------------------------- /userland/artwork/sysinfo.ans: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/haliphax/xthulu/HEAD/userland/artwork/sysinfo.ans -------------------------------------------------------------------------------- /xthulu/models/__init__.py: -------------------------------------------------------------------------------- 1 | """Database ORM models""" 2 | 3 | from .user import User 4 | 5 | __all__ = ("User",) 6 | -------------------------------------------------------------------------------- /userland/web/routes/__init__.py: -------------------------------------------------------------------------------- 1 | """Routes""" 2 | 3 | from . import chat, logout 4 | 5 | __all__ = ("chat", "logout") 6 | -------------------------------------------------------------------------------- /requirements/dev.in: -------------------------------------------------------------------------------- 1 | -c requirements.txt 2 | pip-tools 3 | pytest 4 | pytest-asyncio 5 | pytest-cov 6 | pytest-mypy 7 | ruff 8 | types-aiofiles 9 | -------------------------------------------------------------------------------- /xthulu/web/asgi.py: -------------------------------------------------------------------------------- 1 | """ASGI entrypoint""" 2 | 3 | # local 4 | from . import create_app 5 | 6 | app = create_app() 7 | """ASGI web application""" 8 | -------------------------------------------------------------------------------- /bin/xt: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # cli container shortcut 3 | 4 | set -eo pipefail 5 | cd "$(dirname "${BASH_SOURCE[0]}")/../docker" 6 | docker compose run --rm cli $* 7 | -------------------------------------------------------------------------------- /bin/xtu: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # userland container shortcut 3 | 4 | set -eo pipefail 5 | cd "$(dirname "${BASH_SOURCE[0]}")/../docker" 6 | docker compose run --rm user $* 7 | -------------------------------------------------------------------------------- /etc/fail2ban/filter.d/xthulu_ssh.conf: -------------------------------------------------------------------------------- 1 | # Filter configuration for xthulu_ssh 2 | 3 | [Definition] 4 | failregex = WARNING \ \w+@:\d+ rejected 5 | ignoreregex = 6 | -------------------------------------------------------------------------------- /etc/fail2ban/filter.d/xthulu_www.conf: -------------------------------------------------------------------------------- 1 | # Filter configuration for xthulu_www 2 | 3 | [Definition] 4 | failregex = :\d+ - "[^"]+" 401 Unauthorized$ 5 | ignoreregex = "GET /api/logout/ HTTP/1\.1" 6 | -------------------------------------------------------------------------------- /docker/traefik/dynamic.yml: -------------------------------------------------------------------------------- 1 | # traefik file-provided dynamic configuration 2 | 3 | http: 4 | middlewares: 5 | no-server-header: 6 | headers: 7 | customResponseHeaders: 8 | Server: "" 9 | -------------------------------------------------------------------------------- /.husky/prepare-commit-msg: -------------------------------------------------------------------------------- 1 | # prompt for gitmoji 2 | exec < /dev/tty 3 | cat .git/COMMIT_EDITMSG | npx commitlint -q || { 4 | npx --package=gitmoji-cli -- gitmoji --hook $1 $2; 5 | cat .git/COMMIT_EDITMSG | npx commitlint 6 | } 7 | -------------------------------------------------------------------------------- /userland/scripts/main.tcss: -------------------------------------------------------------------------------- 1 | Button { 2 | height: 5; 3 | width: 100%; 4 | } 5 | 6 | VerticalScroll { 7 | width: 100%; 8 | } 9 | 10 | #buttons { 11 | layout: grid; 12 | grid-gutter: 1 2; 13 | grid-size: 3; 14 | max-width: 80; 15 | } 16 | -------------------------------------------------------------------------------- /userland/web/__init__.py: -------------------------------------------------------------------------------- 1 | """Default userland web module""" 2 | 3 | # stdlib 4 | from importlib import import_module 5 | 6 | # 3rd party 7 | from fastapi import APIRouter 8 | 9 | api = APIRouter() 10 | import_module(".routes", __name__) 11 | -------------------------------------------------------------------------------- /requirements/requirements.in: -------------------------------------------------------------------------------- 1 | aiofiles 2 | asyncpg 3 | asyncssh 4 | bcrypt 5 | click 6 | fastapi 7 | hiredis 8 | redis 9 | rich 10 | sqlalchemy[asyncio] 11 | sqlmodel 12 | sse-starlette 13 | textual 14 | toml 15 | uvicorn[standard] 16 | wrapt 17 | -------------------------------------------------------------------------------- /xthulu/cli/_util.py: -------------------------------------------------------------------------------- 1 | # stdlib 2 | from asyncio import AbstractEventLoop, get_event_loop, new_event_loop 3 | 4 | 5 | def loop() -> AbstractEventLoop: 6 | try: 7 | return get_event_loop() 8 | except RuntimeError: 9 | return new_event_loop() 10 | -------------------------------------------------------------------------------- /userland/cli/__init__.py: -------------------------------------------------------------------------------- 1 | """Command line module""" 2 | 3 | # 3rd party 4 | from click import group 5 | 6 | # local 7 | from . import db 8 | 9 | 10 | @group() 11 | def cli(): 12 | """xthulu community server userland command line utility""" 13 | 14 | 15 | cli.add_command(db.cli) 16 | -------------------------------------------------------------------------------- /bin/update: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # update xthulu repo, rebuild docker images and static web resources 3 | 4 | set -eo pipefail 5 | cd "$(dirname "${BASH_SOURCE[0]}")/.." 6 | git pull 7 | cd bin 8 | ./build-web 9 | cd ../docker 10 | docker compose build base-image 11 | docker compose up -d 12 | -------------------------------------------------------------------------------- /bin/build-web: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # build static web assets 3 | 4 | set -eo pipefail 5 | cd "$(dirname "${BASH_SOURCE[0]}")" 6 | node_ver="$(cat ../.nvmrc | sed -e 's/\n//' | sed -e 's/\//-/')" 7 | docker run -it --rm -e HOME=/tmp -u "$UID:$GID" -v "$(pwd)/..:/app" -w /app \ 8 | "node:$node_ver" npm run build 9 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__/ 2 | *.pyc 3 | **/*.override.yml 4 | /.coverage 5 | /.mypy_cache/ 6 | /.pytest_cache/ 7 | /.python-version 8 | /*.egg-info/ 9 | /build/ 10 | /data/*.toml 11 | /data/ssh_host_key* 12 | /db/ 13 | /html/ 14 | /run/logs/*.log* 15 | coverage.xml 16 | node_modules/ 17 | 18 | # exclusions 19 | !/data/config.example.toml 20 | -------------------------------------------------------------------------------- /userland/models/__init__.py: -------------------------------------------------------------------------------- 1 | """Default userland models""" 2 | 3 | from .message import Message 4 | from .message.message_tags import MessageTags 5 | from .message.tag import MessageTag 6 | from .oneliner import Oneliner 7 | 8 | __all__ = ( 9 | "Message", 10 | "MessageTag", 11 | "MessageTags", 12 | "Oneliner", 13 | ) 14 | -------------------------------------------------------------------------------- /xthulu/cli/web.py: -------------------------------------------------------------------------------- 1 | """Web server CLI""" 2 | 3 | # 3rd party 4 | from click import group 5 | 6 | # local 7 | from ..web import start_server 8 | 9 | 10 | @group("web") 11 | def cli(): 12 | """Web server commands""" 13 | 14 | 15 | @cli.command() 16 | def start(): 17 | """Start web server process.""" 18 | 19 | start_server() 20 | -------------------------------------------------------------------------------- /tests/web/test_asgi.py: -------------------------------------------------------------------------------- 1 | """ASGI entry point tests""" 2 | 3 | # 3rd party 4 | from fastapi import FastAPI 5 | 6 | # local 7 | from xthulu.web.asgi import app # noqa: F401 8 | 9 | 10 | def test_asgi_has_app(): 11 | """The ASGI module should produce an 'app' FastAPI object.""" 12 | 13 | # assert 14 | assert isinstance(app, FastAPI) 15 | -------------------------------------------------------------------------------- /xthulu/cli/__init__.py: -------------------------------------------------------------------------------- 1 | """Command line module""" 2 | 3 | # 3rd party 4 | from click import group 5 | 6 | # local 7 | from . import db, ssh, web 8 | 9 | 10 | @group() 11 | def cli(): 12 | """xthulu community server command line utility""" 13 | 14 | 15 | cli.add_command(db.cli) 16 | cli.add_command(ssh.cli) 17 | cli.add_command(web.cli) 18 | -------------------------------------------------------------------------------- /xthulu/events/structs.py: -------------------------------------------------------------------------------- 1 | """Event structs""" 2 | 3 | # stdlib 4 | from dataclasses import dataclass 5 | from typing import Any 6 | 7 | 8 | @dataclass 9 | class EventData: 10 | """An event and its accompanying data""" 11 | 12 | name: str 13 | """The event namespace""" 14 | 15 | data: Any 16 | """The event's data payload""" 17 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | indent_size = 2 7 | indent_style = tab 8 | insert_final_newline = true 9 | max_line_length = 80 10 | tab_width = 2 11 | trim_trailing_whitespace = true 12 | 13 | [*.{md,py,yaml,yml}] 14 | indent_style = space 15 | 16 | [*.py] 17 | indent_size = 4 18 | tab_width = 4 19 | -------------------------------------------------------------------------------- /userland/web/static/logout/script.mts: -------------------------------------------------------------------------------- 1 | await fetch("/api/logout/").then((r) => { 2 | if (r.status !== 401) { 3 | document.getElementById("status")!.innerHTML = 4 | `There was an error. Status code: ${r.status}`; 5 | return; 6 | } 7 | 8 | document.getElementById("status")!.innerHTML = 9 | "You have been logged out successfully."; 10 | }); 11 | -------------------------------------------------------------------------------- /userland/scripts/oneliners.tcss: -------------------------------------------------------------------------------- 1 | $accent: ansi_red; 2 | 3 | Label { 4 | width: 100%; 5 | } 6 | 7 | ListView { 8 | width: 100%; 9 | } 10 | 11 | ListItem { 12 | background: $primary-background; 13 | } 14 | 15 | ListItem.even { 16 | background: $secondary-background; 17 | } 18 | 19 | ListView:focus ListItem.-highlight { 20 | background: $accent; 21 | } 22 | -------------------------------------------------------------------------------- /userland/scripts/logoff.py: -------------------------------------------------------------------------------- 1 | """Log-off script""" 2 | 3 | # stdlib 4 | from os import path 5 | 6 | # api 7 | from xthulu.ssh.context import SSHContext 8 | from xthulu.ssh.console.art import scroll_art 9 | 10 | 11 | async def main(cx: SSHContext) -> None: 12 | await scroll_art( 13 | cx, path.join("userland", "artwork", "logoff.ans"), "amiga" 14 | ) 15 | -------------------------------------------------------------------------------- /userland/scripts/nua.tcss: -------------------------------------------------------------------------------- 1 | * { 2 | overflow: hidden; 3 | } 4 | 5 | Button { 6 | height: 3; 7 | width: 100%; 8 | } 9 | 10 | Center { 11 | height: 3; 12 | margin: 0; 13 | padding: 0; 14 | padding-top: -1; 15 | } 16 | 17 | #buttons_wrapper { 18 | layout: grid; 19 | grid-size: 3; 20 | grid-gutter: 1; 21 | margin: 0; 22 | padding: 0; 23 | width: 65; 24 | } 25 | -------------------------------------------------------------------------------- /.husky/scripts/no-commit-to-main.mjs: -------------------------------------------------------------------------------- 1 | import child_process from "node:child_process"; 2 | 3 | if (process.env.ALLOW_MAIN) process.exit(0); 4 | 5 | const branch = child_process 6 | .execSync("git branch --show-current") 7 | .toString() 8 | .trim(); 9 | 10 | if (branch === "main") { 11 | process.stderr.write("❌ Do not commit to main branch\n"); 12 | process.exit(1); 13 | } 14 | -------------------------------------------------------------------------------- /xthulu/cli/__main__.py: -------------------------------------------------------------------------------- 1 | """Command line entrypoint""" 2 | 3 | # stdlib 4 | from asyncio import new_event_loop, set_event_loop_policy 5 | 6 | # 3rd party 7 | from uvloop import EventLoopPolicy 8 | 9 | # local 10 | from . import cli 11 | 12 | 13 | set_event_loop_policy(EventLoopPolicy()) 14 | loop = new_event_loop() 15 | 16 | try: 17 | cli() 18 | finally: 19 | loop.close() 20 | -------------------------------------------------------------------------------- /userland/cli/__main__.py: -------------------------------------------------------------------------------- 1 | """Userland command line entry point""" 2 | 3 | # stdlib 4 | from asyncio import new_event_loop, set_event_loop_policy 5 | 6 | # 3rd party 7 | from uvloop import EventLoopPolicy 8 | 9 | # local 10 | from . import cli 11 | 12 | 13 | set_event_loop_policy(EventLoopPolicy()) 14 | loop = new_event_loop() 15 | 16 | try: 17 | cli() 18 | finally: 19 | loop.close() 20 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "charliermarsh.ruff", 4 | "editorconfig.editorconfig", 5 | "esbenp.prettier-vscode", 6 | "ms-python.isort", 7 | "ms-python.python", 8 | "pamaron.pytest-runner", 9 | "ryanluker.vscode-coverage-gutters", 10 | "seatonjiang.gitmoji-vscode", 11 | "tamasfe.even-better-toml", 12 | "textualize.textual-syntax-highlighter", 13 | "vue.volar", 14 | "wholroyd.jinja" 15 | ] 16 | } 17 | -------------------------------------------------------------------------------- /xthulu/ssh/structs.py: -------------------------------------------------------------------------------- 1 | """SSH server structs""" 2 | 3 | # stdlib 4 | from dataclasses import dataclass 5 | from typing import Any, Sequence 6 | 7 | 8 | @dataclass 9 | class Script: 10 | """A userland Python script""" 11 | 12 | name: str 13 | """The script name, used for lookup""" 14 | 15 | args: Sequence[Any] 16 | """The script's `*args` list""" 17 | 18 | kwargs: dict[str, Any] 19 | """The scripts `**kwargs` dict""" 20 | -------------------------------------------------------------------------------- /docker/docker-compose.dev.yml: -------------------------------------------------------------------------------- 1 | x-live-source: &live-source 2 | volumes: 3 | - ../xthulu:/app/xthulu:ro 4 | 5 | services: 6 | cli: *live-source 7 | user: *live-source 8 | ssh: *live-source 9 | web: *live-source 10 | 11 | web-static: 12 | volumes: 13 | # parent volume cannot be read-only or subvolumes will not mount 14 | - ../xthulu/web/static:/usr/share/nginx/html 15 | - ../userland/web/static:/usr/share/nginx/html/user:ro 16 | -------------------------------------------------------------------------------- /userland/web/static/logout/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | xthulu logout 6 | 7 | 8 | 9 |

xthulu

10 |

Logout

11 |

Logging you out...

12 |

13 | Home 14 |

15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /userland/web/static/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | xthulu static web server 6 | 7 | 8 | 9 |

xthulu

10 |

Pages

11 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /userland/web/static/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { resolve } from "path"; 2 | import { defineConfig } from "vite"; 3 | 4 | export default defineConfig({ 5 | build: { 6 | emptyOutDir: true, 7 | outDir: "../../../html", 8 | rollupOptions: { 9 | input: { 10 | chat: resolve(__dirname, "chat/index.html"), 11 | index: resolve(__dirname, "index.html"), 12 | logout: resolve(__dirname, "logout/index.html"), 13 | }, 14 | }, 15 | target: "esnext", 16 | }, 17 | }); 18 | -------------------------------------------------------------------------------- /userland/web/routes/logout.py: -------------------------------------------------------------------------------- 1 | """Web chat""" 2 | 3 | # stdlib 4 | from typing import Annotated 5 | 6 | # 3rd party 7 | from fastapi import Depends, HTTPException 8 | 9 | # api 10 | from xthulu.models.user import User 11 | from xthulu.web.auth import login_user 12 | 13 | # local 14 | from .. import api 15 | 16 | 17 | @api.get("/logout/") 18 | def chat(user: Annotated[User, Depends(login_user)]): 19 | """End the user session.""" 20 | 21 | raise HTTPException(status_code=401) 22 | -------------------------------------------------------------------------------- /xthulu/ssh/context/lock_manager.py: -------------------------------------------------------------------------------- 1 | """Context specific lock management""" 2 | 3 | # local 4 | from ...locks import get, release 5 | 6 | 7 | class _LockManager: 8 | """Internal class for managing user locks""" 9 | 10 | def __init__(self, sid: str, name: str): 11 | self.sid = sid 12 | self.name = name 13 | 14 | def __enter__(self, *args, **kwargs): 15 | return get(self.sid, self.name) 16 | 17 | def __exit__(self, *args, **kwargs): 18 | return release(self.sid, self.name) 19 | -------------------------------------------------------------------------------- /userland/scripts/messages/__init__.py: -------------------------------------------------------------------------------- 1 | """Messages script""" 2 | 3 | # stdlib 4 | from os import path 5 | 6 | # api 7 | from xthulu.ssh.context import SSHContext 8 | 9 | # local 10 | from .app import MessagesApp 11 | 12 | 13 | async def main(cx: SSHContext) -> None: 14 | cx.console.set_window_title("messages") 15 | await MessagesApp( 16 | cx, 17 | art_path=path.join("userland", "artwork", "messages.ans"), 18 | art_encoding="amiga", 19 | alt="79 Columns // Messages", 20 | ).run_async() 21 | -------------------------------------------------------------------------------- /docker/traefik/traefik.yml: -------------------------------------------------------------------------------- 1 | # traefik service configuration 2 | 3 | api: 4 | insecure: true 5 | 6 | entryPoints: 7 | http: 8 | address: :80 9 | http: 10 | redirections: 11 | entryPoint: 12 | to: https 13 | scheme: https 14 | 15 | https: 16 | address: :443 17 | 18 | ssh: 19 | address: :22 20 | 21 | providers: 22 | docker: 23 | exposedByDefault: false 24 | network: xthulu_proxy 25 | watch: true 26 | 27 | file: 28 | filename: /dynamic.yml 29 | watch: true 30 | -------------------------------------------------------------------------------- /tests/web/test_lifespan.py: -------------------------------------------------------------------------------- 1 | """Lifespan tests""" 2 | 3 | # stdlib 4 | from unittest.mock import Mock, patch 5 | 6 | # 3rd party 7 | import pytest 8 | 9 | # local 10 | from xthulu.web import lifespan 11 | 12 | 13 | @pytest.mark.asyncio 14 | @patch("xthulu.web.getLogger") 15 | async def test_lifespan(mock_get_logger: Mock): 16 | """Lifespan method should add a logging handler.""" 17 | 18 | # act 19 | async with lifespan(Mock()): 20 | pass 21 | 22 | # assert 23 | mock_get_logger.return_value.addHandler.assert_called_once() 24 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "es2021": true, 5 | "node": true 6 | }, 7 | "extends": [ 8 | "eslint:recommended", 9 | "plugin:@typescript-eslint/recommended", 10 | "plugin:prettier/recommended" 11 | ], 12 | "ignorePatterns": ["html/"], 13 | "parser": "@typescript-eslint/parser", 14 | "parserOptions": { 15 | "ecmaVersion": "latest", 16 | "sourceType": "module" 17 | }, 18 | "plugins": ["@typescript-eslint", "prettier"], 19 | "rules": { 20 | "indent": [ 21 | "error", 22 | "tab", 23 | { 24 | "ignoredNodes": ["PropertyDefinition"], 25 | "offsetTernaryExpressions": true 26 | } 27 | ] 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /.github/workflows/retry.yml: -------------------------------------------------------------------------------- 1 | name: Retry 2 | 3 | on: 4 | workflow_dispatch: 5 | inputs: 6 | run_id: 7 | description: ID of the workflow run to retry 8 | required: true 9 | 10 | jobs: 11 | rerun: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: retry ${{ inputs.run_id }} 15 | env: 16 | GH_REPO: ${{ github.repository }} 17 | GH_TOKEN: ${{ github.token }} 18 | GH_DEBUG: api 19 | run: | 20 | # wait for teardown to finish 21 | gh run watch ${{ inputs.run_id }} &>/dev/null 22 | 23 | # rerun failed jobs 24 | gh run rerun ${{ inputs.run_id }} --failed 25 | -------------------------------------------------------------------------------- /etc/fail2ban/jail.d/xthulu.conf: -------------------------------------------------------------------------------- 1 | # Jail configuration for xthulu 2 | 3 | [xthulu_ssh] 4 | # remove the "chain=FORWARD" parameter if not using docker 5 | action = iptables[chain=FORWARD, port=22] 6 | backend = auto 7 | enabled = true 8 | filter = xthulu_ssh 9 | # set to your log file path 10 | logpath = /home/bbs/xthulu/run/logs/xthulu.log 11 | maxretry = 3 12 | port = 22 13 | 14 | [xthulu_www] 15 | # remove the "chain=FORWARD" parameter if not using docker 16 | action = iptables[chain=FORWARD, port=443] 17 | backend = auto 18 | enabled = true 19 | filter = xthulu_www 20 | # set to your log file path 21 | logpath = /home/bbs/xthulu/run/logs/www.log 22 | maxretry = 4 23 | port = 443 24 | -------------------------------------------------------------------------------- /userland/scripts/lock_example.py: -------------------------------------------------------------------------------- 1 | """Lock example""" 2 | 3 | # api 4 | from xthulu.ssh.context import SSHContext 5 | 6 | 7 | async def main(cx: SSHContext) -> None: 8 | cx.console.clear() 9 | cx.console.set_window_title("locks example") 10 | cx.echo("\n[bright_white on yellow underline] Shared locks demo [/]\n\n") 11 | 12 | with cx.lock("testing") as l: 13 | if l: 14 | await cx.inkey(":lock: Lock acquired; press any key to release") 15 | cx.echo(":fire: Lock released!\n") 16 | await cx.inkey(timeout=1) 17 | 18 | return 19 | 20 | cx.echo(":cross_mark: Failed to acquire lock\n") 21 | await cx.inkey(timeout=2) 22 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | 6 | 7 | # Summary 8 | 9 | 12 | 13 | # Details 14 | 15 | 18 | -------------------------------------------------------------------------------- /userland/web/static/chat/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | xthulu node chat 6 | 7 | 8 | 9 | 10 |
    11 |
    12 | 20 | 21 |
    22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /xthulu/ssh/codecs/__init__.py: -------------------------------------------------------------------------------- 1 | """Encodings module""" 2 | 3 | # stdlib 4 | from codecs import decode, register 5 | 6 | # local 7 | from . import amiga, cp437 8 | 9 | 10 | def register_encodings(): 11 | """Register encodings to be used by the system.""" 12 | 13 | _encodings = { 14 | "amiga": amiga.getregentry(), 15 | "cp437": cp437.getregentry(), 16 | } 17 | 18 | def _search_function(encoding: str): 19 | if encoding not in _encodings: 20 | return None 21 | 22 | return _encodings[encoding] 23 | 24 | register(_search_function) 25 | 26 | for c in ( 27 | "amiga", 28 | "cp437", 29 | ): 30 | decode(bytes((27,)), c) 31 | -------------------------------------------------------------------------------- /data/config.example.toml: -------------------------------------------------------------------------------- 1 | #:schema ./config.schema.json 2 | [cache] 3 | db = 0 4 | host = "cache" 5 | port = 6379 6 | 7 | [db] 8 | bind = "postgresql+asyncpg://xthulu:xthulu@db:5432/xthulu" 9 | 10 | [debug] 11 | term = false 12 | 13 | [logging] 14 | level = "INFO" 15 | 16 | [ssh] 17 | host = "0.0.0.0" 18 | host_keys = ["data/ssh_host_key"] 19 | port = 8022 20 | proxy_protocol = true 21 | 22 | [ssh.auth] 23 | bad_usernames = ["admin", "administrator", "root", "system", "god"] 24 | no_password = ["guest"] 25 | 26 | [ssh.session] 27 | timeout = 120 28 | 29 | [ssh.userland] 30 | paths = ["userland/scripts"] 31 | top = ["top"] 32 | 33 | [web] 34 | host = "0.0.0.0" 35 | port = 5000 36 | 37 | [web.userland] 38 | modules = ["userland.web"] 39 | -------------------------------------------------------------------------------- /userland/web/schema/chat.py: -------------------------------------------------------------------------------- 1 | """Chat schema""" 2 | 3 | # stdlib 4 | from typing import Any 5 | 6 | # 3rd party 7 | from pydantic import BaseModel 8 | 9 | 10 | class ChatPost(BaseModel): 11 | """Posted chat message""" 12 | 13 | message: str 14 | """The message body""" 15 | 16 | token: str 17 | """The client's CSRF token""" 18 | 19 | def __init__(self, **data: Any): 20 | "" # empty docstring 21 | super(ChatPost, self).__init__(**data) 22 | 23 | 24 | class ChatToken(BaseModel): 25 | """CSRF token""" 26 | 27 | token: str 28 | """The token value""" 29 | 30 | def __init__(self, **data: Any): 31 | "" # empty docstring 32 | super(ChatToken, self).__init__(**data) 33 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "[python]": { 3 | "editor.defaultFormatter": "charliermarsh.ruff" 4 | }, 5 | "[toml]": { 6 | "editor.defaultFormatter": "tamasfe.even-better-toml" 7 | }, 8 | "files.exclude": { 9 | "*.egg-info/": true, 10 | "*.pyc": true, 11 | ".ruff_cache/": true, 12 | "node_modules/": true, 13 | "package-lock.json": true 14 | }, 15 | "python.analysis.importFormat": "relative", 16 | "python.analysis.indexing": true, 17 | "python.analysis.typeCheckingMode": "standard", 18 | "python.testing.pytestArgs": ["tests"], 19 | "python.testing.pytestEnabled": true, 20 | "python.testing.unittestEnabled": false, 21 | "ruff.importStrategy": "fromEnvironment", 22 | "typescript.tsdk": "node_modules/typescript/lib" 23 | } 24 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowImportingTsExtensions": true, 4 | "experimentalDecorators": true, 5 | "isolatedModules": true, 6 | "jsx": "preserve", 7 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 8 | "module": "ESNext", 9 | "moduleResolution": "bundler", 10 | "noEmit": true, 11 | "noFallthroughCasesInSwitch": true, 12 | "noUnusedLocals": true, 13 | "noUnusedParameters": true, 14 | "resolveJsonModule": true, 15 | "resolvePackageJsonExports": false, 16 | "skipLibCheck": true, 17 | "strict": true, 18 | "target": "ES2020", 19 | "useDefineForClassFields": true 20 | }, 21 | "include": [ 22 | "userland/web/**/*.d.ts", 23 | "userland/web/**/*.mts", 24 | "userland/web/**/*.ts" 25 | ] 26 | } 27 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | workflow_dispatch: 5 | 6 | jobs: 7 | release: 8 | runs-on: ubuntu-latest 9 | 10 | steps: 11 | - name: Check out 12 | uses: actions/checkout@v4 13 | with: 14 | fetch-depth: 0 15 | token: ${{ secrets.RELEASE_TOKEN }} 16 | 17 | - name: Set up Node 18 | uses: actions/setup-node@v4 19 | with: 20 | cache: npm 21 | node-version-file: .nvmrc 22 | 23 | - name: Install dependencies 24 | run: npm ci --ignore-scripts 25 | 26 | - name: Release 27 | env: 28 | GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN }} 29 | run: npx semantic-release -r "$(git remote get-url origin)" 30 | -------------------------------------------------------------------------------- /userland/models/message/tag.py: -------------------------------------------------------------------------------- 1 | """Message tag model""" 2 | 3 | # stdlib 4 | from datetime import datetime 5 | from typing import Any 6 | 7 | # 3rd party 8 | from sqlmodel import Field, SQLModel 9 | 10 | 11 | class MessageTag(SQLModel, table=True): 12 | """Message tag model""" 13 | 14 | name: str | None = Field(max_length=32, primary_key=True, default=None) 15 | """The tag's name""" 16 | 17 | created: datetime = Field(default_factory=datetime.now) 18 | """When the tag was created""" 19 | 20 | __tablename__ = "message_tag" # type: ignore 21 | 22 | def __init__(self, **data: Any): 23 | super(MessageTag, self).__init__(**data) 24 | 25 | def __repr__(self): # pragma: no cover 26 | return f"MessageTag({self.name})" 27 | -------------------------------------------------------------------------------- /userland/web/static/chat/styles.css: -------------------------------------------------------------------------------- 1 | * { 2 | font-family: Consolas, "Courier New", Courier, monospace; 3 | } 4 | 5 | body { 6 | display: flex; 7 | flex-direction: column; 8 | margin: 0; 9 | max-height: 100vh; 10 | min-height: 100vh; 11 | overflow: hidden; 12 | padding: 0; 13 | } 14 | 15 | input { 16 | font-size: 1.5rem; 17 | line-height: 1.2; 18 | width: 100%; 19 | } 20 | 21 | ul { 22 | flex-grow: 1; 23 | } 24 | 25 | .hide { 26 | display: none; 27 | } 28 | 29 | .notify { 30 | display: inline-block; 31 | padding: 0 0.1em 0 0.1em; 32 | } 33 | 34 | .user { 35 | background-color: #00f; 36 | color: #fff; 37 | } 38 | 39 | .system { 40 | background-color: #f00; 41 | color: #fff; 42 | } 43 | 44 | .system + .message { 45 | font-style: italic; 46 | } 47 | -------------------------------------------------------------------------------- /userland/models/message/message_tags.py: -------------------------------------------------------------------------------- 1 | """Message tag relationship model""" 2 | 3 | # stdlib 4 | from typing import Any 5 | 6 | # 3rd party 7 | from sqlmodel import Field, SQLModel 8 | 9 | # api 10 | from xthulu.resources import Resources 11 | 12 | db = Resources().db 13 | 14 | 15 | class MessageTags(SQLModel, table=True): 16 | """Message tag model""" 17 | 18 | message_id: int = Field(foreign_key="message.id", primary_key=True) 19 | """The tagged message ID""" 20 | 21 | tag_name: str = Field(foreign_key="message_tag.name", primary_key=True) 22 | """The name of the tag""" 23 | 24 | __tablename__ = "message_x_message_tag" # type: ignore 25 | 26 | def __init__(self, **data: Any): 27 | super(MessageTags, self).__init__(**data) 28 | -------------------------------------------------------------------------------- /xthulu/ssh/exceptions.py: -------------------------------------------------------------------------------- 1 | """SSH server exceptions""" 2 | 3 | # local 4 | from .structs import Script 5 | 6 | 7 | class Goto(Exception): 8 | """Thrown to change the script without returning""" 9 | 10 | def __init__(self, script: str, *args, **kwargs): 11 | """ 12 | Args: 13 | script: The script to run. 14 | args: The positional arguments to pass. 15 | kwargs: The keyword arguments to pass. 16 | """ 17 | 18 | self.value = Script(script, args, kwargs) 19 | 20 | 21 | class ProcessClosing(Exception): 22 | """Thrown when the `asyncssh.SSHServerProcess` is closing""" 23 | 24 | 25 | class ProcessForciblyClosed(Exception): 26 | """Thrown when the process is being forcibly closed by the server""" 27 | -------------------------------------------------------------------------------- /tests/test_configuration.py: -------------------------------------------------------------------------------- 1 | """Configuration tests""" 2 | 3 | # local 4 | from xthulu.configuration import deep_update 5 | 6 | 7 | def test_deep_update(): 8 | """ 9 | The `deep_update` function should appropriately modify the target dict. 10 | """ 11 | 12 | # arrange 13 | target = { 14 | "root": 1, 15 | "other_root": 0, 16 | "nested": {"value": 1, "other_value": 0}, 17 | "other_nested": {"value": 0}, 18 | } 19 | updates = {"root": 2, "nested": {"value": 2}} 20 | expected = { 21 | "root": 2, 22 | "other_root": 0, 23 | "nested": {"value": 2, "other_value": 0}, 24 | "other_nested": {"value": 0}, 25 | } 26 | 27 | # act 28 | result = deep_update(target, updates) 29 | 30 | # assert 31 | assert result == expected 32 | -------------------------------------------------------------------------------- /xthulu/ssh/context/handle_events.py: -------------------------------------------------------------------------------- 1 | """Handle common `xthulu.ssh.context.SSHContext` events""" 2 | 3 | # local 4 | from ...events.structs import EventData 5 | from ...ssh.context import SSHContext 6 | 7 | 8 | def handle_events(cx: SSHContext) -> tuple[list[EventData], bool]: 9 | """ 10 | Handle common events. Will notify the userland script whether the screen 11 | is considered "dirty" based on the events retrieved. 12 | 13 | Args: 14 | cx: The context to inspect for events. 15 | 16 | Returns: 17 | The events (or an empty list) and whether or not the screen is "dirty". 18 | """ 19 | 20 | events = [] 21 | events += cx.events.get("resize") 22 | dirty = False 23 | 24 | for event in events: 25 | if event.name == "resize": 26 | dirty = True 27 | 28 | return events, dirty 29 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # set up the system for the first time 3 | 4 | set -eo pipefail 5 | cd "$(dirname "${BASH_SOURCE[0]}")/../data" 6 | 7 | if [[ -f "config.toml" ]]; then 8 | echo "config.toml already exists; skipping" 9 | else 10 | echo "creating config.toml from config.example.toml" 11 | cp config.example.toml config.toml 12 | fi 13 | 14 | if [[ -f "ssh_host_key" ]]; then 15 | echo "ssh_host_key already exists; skipping" 16 | else 17 | echo "generating ssh_host_key" 18 | ssh-keygen -f ssh_host_key -t rsa -b 4096 -N "" 19 | fi 20 | 21 | cd ../docker 22 | echo "building base image" 23 | docker compose build base-image 24 | echo "pulling service images" 25 | docker compose pull --ignore-buildable 26 | cd ../bin 27 | echo "initializing database" 28 | ./xt db create --seed 29 | ./xtu db create --seed 30 | echo "building static web site" 31 | ./build-web 32 | -------------------------------------------------------------------------------- /xthulu/ssh/context/logger_adapter.py: -------------------------------------------------------------------------------- 1 | """Context specific LoggerAdapter implementation""" 2 | 3 | # type checking 4 | from typing import Any, Mapping, MutableMapping 5 | 6 | # stdlib 7 | from logging import LoggerAdapter 8 | 9 | 10 | class ContextLoggerAdapter(LoggerAdapter): 11 | """LoggerAdapter for prepending log messages with connection info""" 12 | 13 | whoami: str | None 14 | 15 | def __init__( 16 | self, logger: Any, extra: Mapping[str, object] | None = None 17 | ) -> None: 18 | super(ContextLoggerAdapter, self).__init__(logger, extra) 19 | 20 | if extra and "whoami" in extra.keys(): 21 | self.whoami = str(extra["whoami"]) 22 | 23 | def process( 24 | self, msg: Any, kwargs: MutableMapping[str, Any] 25 | ) -> tuple[Any, MutableMapping[str, Any]]: 26 | return f"{self.whoami} {msg}", kwargs 27 | -------------------------------------------------------------------------------- /xthulu/ssh/console/internal/file_wrapper.py: -------------------------------------------------------------------------------- 1 | """File wrapper""" 2 | 3 | # stdlib 4 | from typing import Any, IO 5 | 6 | # 3rd party 7 | from asyncssh import SSHWriter 8 | 9 | 10 | class FileWrapper(IO[str]): 11 | """Duck-typed wrapper for providing a file-like object to rich/Textual""" 12 | 13 | _encoding: str 14 | _wrapped: SSHWriter[Any] 15 | 16 | def __init__(self, wrapped: SSHWriter[Any], encoding: str): 17 | self._encoding = encoding 18 | self._wrapped = wrapped 19 | super(FileWrapper, self).__init__() 20 | 21 | def write(self, string: str) -> int: 22 | try: 23 | self._wrapped.write( 24 | string.replace("\n", "\r\n").encode(self._encoding) 25 | ) 26 | except BrokenPipeError: 27 | # process is likely closing 28 | return 0 29 | 30 | return len(string) 31 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools", "setuptools-scm"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name = "xthulu" 7 | requires-python = ">=3.12" 8 | version = "0.11.0" 9 | dynamic = ["dependencies", "optional-dependencies"] 10 | 11 | [tool.coverage.run] 12 | omit = ["userland/**", "tests/**", "**/__main__.py"] 13 | 14 | [tool.coverage.report] 15 | fail_under = 50 16 | 17 | [tool.pytest.ini_options] 18 | addopts = "--ignore=userland" 19 | filterwarnings = ["ignore"] 20 | 21 | [tool.ruff] 22 | lint.ignore = [ 23 | # ambiguous variable name 24 | "E741", 25 | ] 26 | line-length = 80 27 | target-version = "py312" 28 | 29 | [tool.setuptools] 30 | packages = ["xthulu"] 31 | 32 | [tool.setuptools.dynamic] 33 | dependencies = { file = "requirements/requirements.txt" } 34 | 35 | [tool.setuptools.dynamic.optional-dependencies] 36 | dev = { file = "requirements/dev.txt" } 37 | -------------------------------------------------------------------------------- /xthulu/ssh/context/scripting.py: -------------------------------------------------------------------------------- 1 | """Scripting utilities""" 2 | 3 | # stdlib 4 | from importlib.machinery import ModuleSpec, PathFinder 5 | from types import ModuleType 6 | 7 | # local 8 | from ...configuration import get_config 9 | 10 | 11 | def load_userland_module(name: str) -> ModuleType | None: 12 | """Load module from userland scripts""" 13 | 14 | pathfinder = PathFinder() 15 | paths = get_config("ssh.userland.paths") 16 | split: list[str] = name.split(".") 17 | found: ModuleSpec | None = None 18 | mod: ModuleType | None = None 19 | 20 | for seg in split: 21 | if mod is not None: 22 | found = pathfinder.find_spec(seg, list(mod.__path__)) 23 | else: 24 | found = pathfinder.find_spec(seg, paths) 25 | 26 | if found is not None and found.loader is not None: 27 | mod = found.loader.load_module(found.name) 28 | 29 | return mod 30 | -------------------------------------------------------------------------------- /userland/artwork/oneliners.ans: -------------------------------------------------------------------------------- 1 | :%###########################%:| |:%#########################################%: 2 |  ______ ______ _____ | | ___ ______ _____ _________ _____ 3 | ___/|__/ \_< ___/___| |._\ _)__/\_< ___/___\___\< __/___ 4 | \_. | ._ / _/ /| || | ._ / _/ / _// \__ / 5 |  \ \| | |/ /| V / | || | |/ /| V /| \ _/ V / 6 |  \_______l___<____\|________\ | ||____|___<____\|________\|___\___\|________\ 7 | | |_____________________________________________ 8 | :%########################hX!%:|_______________________________________________ 9 | 10 | -------------------------------------------------------------------------------- /.releaserc.json: -------------------------------------------------------------------------------- 1 | { 2 | "branches": ["main"], 3 | "failComment": false, 4 | "plugins": [ 5 | "semantic-release-gitmoji", 6 | ["@semantic-release/changelog", { "changelogFile": "CHANGELOG.md" }], 7 | [ 8 | "semantic-release-replace-plugin", 9 | { 10 | "replacements": [ 11 | { 12 | "countMatches": true, 13 | "files": ["pyproject.toml"], 14 | "from": "version = \"\\d+\\.\\d+\\.\\d+\"", 15 | "results": [ 16 | { 17 | "file": "pyproject.toml", 18 | "hasChanged": true, 19 | "numMatches": 1, 20 | "numReplacements": 1 21 | } 22 | ], 23 | "to": "version = \"${nextRelease.version}\"" 24 | } 25 | ] 26 | } 27 | ], 28 | [ 29 | "@semantic-release/git", 30 | { 31 | "assets": ["CHANGELOG.md", "pyproject.toml"], 32 | "message": "🔖 release ${nextRelease.version}" 33 | } 34 | ], 35 | "@semantic-release/github" 36 | ], 37 | "successComment": false 38 | } 39 | -------------------------------------------------------------------------------- /docker/Dockerfile: -------------------------------------------------------------------------------- 1 | # syntax = docker/dockerfile-upstream:1-labs 2 | 3 | FROM python:3.12-alpine 4 | WORKDIR /app 5 | STOPSIGNAL SIGTERM 6 | ENV ENV=/app/.profile 7 | RUN \ 8 | --mount=type=cache,target=/var/cache/apk,sharing=locked \ 9 | --mount=type=cache,target=/app/.cache/pip,uid=1000,gid=1000,sharing=locked \ 10 | <<-EOF 11 | apk add -U cargo gcc g++ libffi-dev musl-dev openssl openssl-dev 12 | addgroup --gid 1000 xthulu 13 | adduser --disabled-password --home /app --uid 1000 --ingroup xthulu xthulu 14 | chown -R xthulu:xthulu /app 15 | pip install -U 'pip<25.3' setuptools 16 | EOF 17 | 18 | COPY --chown=xthulu:xthulu ./pyproject.toml /app/pyproject.toml 19 | COPY --chown=xthulu:xthulu ./requirements /app/requirements 20 | COPY --chown=xthulu:xthulu ./xthulu /app/xthulu 21 | USER xthulu 22 | RUN --mount=type=cache,target=/app/.cache/pip,uid=1000,gid=1000,sharing=locked \ 23 | pip install --no-warn-script-location -Ue . 24 | ENTRYPOINT ["/usr/local/bin/python3", "-m", "xthulu"] 25 | -------------------------------------------------------------------------------- /tests/test_logger.py: -------------------------------------------------------------------------------- 1 | """Logger tests""" 2 | 3 | # stdlib 4 | from unittest.mock import Mock, patch 5 | 6 | # local 7 | from xthulu.logger import namer, rotator 8 | 9 | 10 | def test_namer(): 11 | """Namer should append '.gz' to filenames.""" 12 | 13 | # arrange 14 | original = "test" 15 | expected = "test.gz" 16 | 17 | # act 18 | result = namer(original) 19 | 20 | # assert 21 | assert result == expected 22 | 23 | 24 | @patch("xthulu.logger.copyfileobj") 25 | @patch("xthulu.logger.remove") 26 | @patch("xthulu.logger.gzip.open") 27 | @patch("xthulu.logger.open") 28 | def test_rotator(mock_open: Mock, mock_gzip: Mock, mock_remove: Mock, *_): 29 | """Rotator should write gzipped files and remove originals.""" 30 | 31 | # act 32 | rotator("file", "file.gz") 33 | 34 | # assert 35 | mock_open.assert_called_once_with("file", "rb") 36 | mock_gzip.assert_called_once_with("file.gz", "wb") 37 | mock_remove.assert_called_once_with("file") 38 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | name: "CodeQL" 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | 7 | pull_request: 8 | branches: [main] 9 | 10 | schedule: 11 | - cron: "33 2 * * 2" 12 | 13 | concurrency: 14 | cancel-in-progress: true 15 | group: | 16 | ${{ github.workflow }}-${{ github.event.pull_request.id || github.branch }} 17 | 18 | jobs: 19 | analyze: 20 | name: Analyze 21 | runs-on: ubuntu-latest 22 | permissions: 23 | actions: read 24 | contents: read 25 | security-events: write 26 | strategy: 27 | fail-fast: false 28 | matrix: 29 | language: ["python"] 30 | steps: 31 | - name: Checkout repository 32 | uses: actions/checkout@v4 33 | 34 | - name: Initialize CodeQL 35 | uses: github/codeql-action/init@v3 36 | with: 37 | languages: ${{ matrix.language }} 38 | 39 | - name: Autobuild 40 | uses: github/codeql-action/autobuild@v3 41 | 42 | - name: Perform CodeQL Analysis 43 | uses: github/codeql-action/analyze@v3 44 | -------------------------------------------------------------------------------- /userland/scripts/messages/styles.tcss: -------------------------------------------------------------------------------- 1 | $highlight: ansi_magenta; 2 | 3 | Footer, Footer:ansi { 4 | &, 5 | .footer-key--key, 6 | .footer-key--description { 7 | background: #007 100%; 8 | color: #077; 9 | } 10 | 11 | .footer-key--key { 12 | color: #0ff; 13 | } 14 | 15 | FooterKey:hover, 16 | FooterKey:hover .footer-key--key { 17 | background: #077 100%; 18 | color: #fff; 19 | } 20 | } 21 | 22 | ListView { 23 | width: 100%; 24 | } 25 | 26 | ListItem { 27 | background: $primary-background; 28 | layout: horizontal; 29 | } 30 | 31 | ListItem.even { 32 | background: $secondary-background; 33 | } 34 | 35 | ListView ListItem.-highlight { 36 | background: $highlight 100%; 37 | } 38 | 39 | ListView:focus ListItem.-highlight { 40 | background: $highlight 100%; 41 | } 42 | 43 | ListItem Label.message_id, ListItem Label.message_title { 44 | margin-right: 1; 45 | } 46 | 47 | ListItem Label.message_id { 48 | width: 8; 49 | } 50 | 51 | ListItem Label.message_title { 52 | width: 75%; 53 | } 54 | 55 | ListItem Label.message_author { 56 | align: right middle; 57 | } 58 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright © 2023 haliphax 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | -------------------------------------------------------------------------------- /userland/scripts/messages/editor_screen.py: -------------------------------------------------------------------------------- 1 | """Message compose/reply screen""" 2 | 3 | # 3rd party 4 | from textual import events 5 | from textual.screen import ModalScreen 6 | from textual.widgets import TextArea 7 | 8 | # local 9 | from userland.models.message import Message 10 | from .save_modal import SaveModal 11 | 12 | 13 | class EditorScreen(ModalScreen): 14 | """Message compose/reply screen""" 15 | 16 | BINDINGS = [("escape", "", "")] 17 | _content: str 18 | reply_to: Message | None 19 | 20 | def __init__( 21 | self, *args, content="", reply_to: Message | None = None, **kwargs 22 | ): 23 | self._content = content 24 | self.reply_to = reply_to 25 | super(EditorScreen, self).__init__(*args, **kwargs) 26 | 27 | def compose(self): 28 | yield TextArea(text=self._content, show_line_numbers=True) 29 | 30 | async def key_escape(self, key: events.Key) -> None: 31 | if isinstance(self.app.screen_stack[-1], SaveModal): 32 | return 33 | 34 | key.stop() 35 | await self.app.push_screen(SaveModal(reply_to=self.reply_to)) 36 | -------------------------------------------------------------------------------- /userland/models/oneliner.py: -------------------------------------------------------------------------------- 1 | """Oneliner model""" 2 | 3 | # stdlib 4 | from datetime import datetime 5 | from typing import Any, ClassVar 6 | 7 | # 3rd party 8 | from sqlmodel import Field, Relationship, SQLModel 9 | 10 | # api 11 | from xthulu.models.user import User 12 | 13 | 14 | class Oneliner(SQLModel, table=True): 15 | """Oneliner model""" 16 | 17 | MAX_LENGTH: ClassVar = 120 18 | """Maximum length of oneliner messages""" 19 | 20 | id: int | None = Field(primary_key=True, default=None) 21 | """Unique ID""" 22 | 23 | user_id: int | None = Field(foreign_key="user.id", default=None) 24 | """User ID of the author""" 25 | 26 | user: User | None = Relationship() 27 | """Author of the oneliner""" 28 | 29 | message: str = Field(max_length=MAX_LENGTH) 30 | """The oneliner message""" 31 | 32 | timestamp: datetime = Field(default_factory=datetime.now) 33 | """When the oneliner was posted""" 34 | 35 | def __init__(self, **data: Any): 36 | super(Oneliner, self).__init__(**data) 37 | 38 | def __repr__(self): # pragma: no cover 39 | return f"Oneliner(#{self.id})" 40 | -------------------------------------------------------------------------------- /tests/cli/test_web.py: -------------------------------------------------------------------------------- 1 | """Web CLI tests""" 2 | 3 | # stdlib 4 | from unittest.mock import Mock, patch 5 | 6 | # 3rd party 7 | from click.testing import CliRunner 8 | import pytest 9 | 10 | # local 11 | from xthulu.cli import cli 12 | from xthulu.cli.web import cli as web_cli 13 | 14 | 15 | def test_cli_includes_group(): 16 | """The CLI module should include the 'web' command group.""" 17 | 18 | # act 19 | command = cli.get_command(Mock(), "web") 20 | 21 | # assert 22 | assert command is not None 23 | 24 | 25 | @pytest.mark.parametrize("command_name", ["start"]) 26 | def test_cli_includes_commands(command_name: str): 27 | """The command group should include the specified command.""" 28 | 29 | # act 30 | commands = web_cli.list_commands(Mock()) 31 | 32 | # assert 33 | assert command_name in commands 34 | 35 | 36 | @patch("xthulu.cli.web.start_server") 37 | def test_start(mock_start: Mock): 38 | """The 'web start' command should call the `start_server` function.""" 39 | 40 | # act 41 | CliRunner().invoke(cli, ["web", "start"], catch_exceptions=False) 42 | 43 | # assert 44 | mock_start.assert_called_once() 45 | -------------------------------------------------------------------------------- /tests/ssh/context/test_scripting.py: -------------------------------------------------------------------------------- 1 | """Scripting tests""" 2 | 3 | # stdlib 4 | from unittest.mock import Mock, patch 5 | 6 | # 3rd party 7 | import pytest 8 | 9 | # local 10 | from xthulu.ssh.context.scripting import load_userland_module 11 | 12 | 13 | @pytest.mark.parametrize( 14 | ["path", "found"], [["root.good", True], ["root.bad", False]] 15 | ) 16 | @patch("xthulu.ssh.context.scripting.PathFinder") 17 | @patch("xthulu.ssh.context.scripting.get_config", return_value=["/test"]) 18 | def test_load_userland_module( 19 | mock_config: Mock, mock_pathfinder: Mock, path: str, found: bool 20 | ): 21 | """The `load_userland_module` method should return the module if found.""" 22 | 23 | # arrange 24 | mock_module = Mock(__path__="") 25 | mock_found = Mock() 26 | mock_found.loader.load_module.return_value = mock_module 27 | mock_pathfinder.return_value.find_spec.side_effect = [ 28 | mock_found if found else None, 29 | mock_found if found else None, 30 | ] 31 | 32 | # act 33 | result = load_userland_module(path) 34 | 35 | # assert 36 | if found: 37 | assert result == mock_module 38 | else: 39 | assert result is None 40 | -------------------------------------------------------------------------------- /tests/mocks/config.py: -------------------------------------------------------------------------------- 1 | """Reusable mock configuration dicts""" 2 | 3 | # type checking 4 | from typing import Any 5 | 6 | # local 7 | from xthulu.configuration import get_config 8 | 9 | test_ssh_config = { 10 | "host": "1.2.3.4", 11 | "host_keys": "/test", 12 | "port": 9999, 13 | } 14 | """Default SSH configuration for testing""" 15 | 16 | test_web_config = { 17 | "host": "1.2.3.4", 18 | "port": 9999, 19 | "userland": {"modules": ["testland.web"]}, 20 | } 21 | """Default web server configuration for testing""" 22 | 23 | test_config = { 24 | "cache": {"host": "test", "port": 9999}, 25 | "db": {"bind": "test"}, 26 | "ssh": test_ssh_config, 27 | "web": test_web_config, 28 | } 29 | """Default overall configuration for testing""" 30 | 31 | 32 | def patch_get_config(config: dict[str, Any]): 33 | """ 34 | Used for patching the `get_config` method. 35 | 36 | Args: 37 | config: The configuration dictionary to use in place of the default. 38 | """ 39 | 40 | def wrapped( 41 | path: str, 42 | default: Any = None, 43 | config: dict[str, Any] | None = config, 44 | ): 45 | return get_config(path, default, config) 46 | 47 | return wrapped 48 | -------------------------------------------------------------------------------- /.commitlintrc.cjs: -------------------------------------------------------------------------------- 1 | // npx gitmoji -l 2 | const gitmojis = [ 3 | "⏪️", 4 | "♻️", 5 | "♿️", 6 | "⚗️", 7 | "⚡️", 8 | "⚰️", 9 | "✅", 10 | "✏️", 11 | "✨", 12 | "➕", 13 | "➖", 14 | "⬆️", 15 | "⬇️", 16 | "🌐", 17 | "🌱", 18 | "🍱", 19 | "🍻", 20 | "🎉", 21 | "🎨", 22 | "🏗️", 23 | "🏷️", 24 | "🐛", 25 | "👔", 26 | "👥", 27 | "👷", 28 | "👽️", 29 | "💄", 30 | "💚", 31 | "💡", 32 | "💥", 33 | "💩", 34 | "💫", 35 | "💬", 36 | "💸", 37 | "📄", 38 | "📈", 39 | "📌", 40 | "📝", 41 | "📦️", 42 | "📱", 43 | "📸", 44 | "🔀", 45 | "🔇", 46 | "🔊", 47 | "🔍️", 48 | "🔐", 49 | "🔒️", 50 | "🔖", 51 | "🔥", 52 | "🔧", 53 | "🔨", 54 | "🗃️", 55 | "🗑️", 56 | "🙈", 57 | "🚀", 58 | "🚑️", 59 | "🚚", 60 | "🚧", 61 | "🚨", 62 | "🚩", 63 | "🚸", 64 | "🛂", 65 | "🤡", 66 | "🥅", 67 | "🥚", 68 | "🦺", 69 | "🧐", 70 | "🧑‍💻", 71 | "🧪", 72 | "🧱", 73 | "🧵", 74 | "🩹", 75 | "🩺", 76 | ]; 77 | 78 | module.exports = { 79 | extends: ["@commitlint/config-conventional"], 80 | parserPreset: { 81 | parserOpts: { 82 | headerPattern: /^([^ ]+) (.*)$/, 83 | headerCorrespondence: ["type", "subject"], 84 | }, 85 | }, 86 | rules: { 87 | "type-enum": [2, "always", gitmojis], 88 | "type-case": [0, "always", "lower-case"], 89 | "subject-full-stop": [2, "never", "."], 90 | }, 91 | }; 92 | -------------------------------------------------------------------------------- /xthulu/cli/ssh.py: -------------------------------------------------------------------------------- 1 | """SSH server CLI""" 2 | 3 | # stdlib 4 | from signal import SIGTERM 5 | import sys 6 | 7 | # 3rd party 8 | from asyncssh import Error as AsyncSSHError, SSHAcceptor 9 | from click import group 10 | 11 | # local 12 | from ._util import loop 13 | from ..locks import _Locks, expire 14 | from ..logger import log 15 | from ..ssh import start_server 16 | 17 | 18 | @group("ssh") 19 | def cli(): 20 | """SSH server commands""" 21 | 22 | 23 | @cli.command() 24 | def start(): 25 | """Start SSH server process""" 26 | 27 | _loop = loop() 28 | server: SSHAcceptor # type: ignore 29 | 30 | def shutdown(): 31 | log.info("Shutting down") 32 | log.debug("Closing SSH listener") 33 | server.close() 34 | log.debug("Stopping event loop") 35 | _loop.stop() 36 | log.debug("Expiring locks") 37 | 38 | for owner in _Locks.locks.copy().keys(): 39 | log.debug(f"Expiring locks for {owner}") 40 | expire(owner) 41 | 42 | _loop.add_signal_handler(SIGTERM, shutdown) 43 | 44 | try: 45 | server = _loop.run_until_complete(start_server()) 46 | except (OSError, AsyncSSHError) as exc: # pragma: no cover 47 | sys.exit(f"Error: {exc}") 48 | 49 | try: 50 | _loop.run_forever() 51 | except KeyboardInterrupt: 52 | pass 53 | -------------------------------------------------------------------------------- /xthulu/configuration/default.py: -------------------------------------------------------------------------------- 1 | """ 2 | Default configuration 3 | 4 | This default configuration is used if no configuration file is found. If a 5 | configuration file is loaded, this configuration is used as a base and the 6 | loaded configuration is layered over the top of it. 7 | """ 8 | 9 | default_config = { 10 | "cache": { 11 | "db": 0, 12 | "host": "cache", 13 | "port": 6379, 14 | }, 15 | "db": { 16 | "bind": "postgresql+asyncpg://xthulu:xthulu@db:5432/xthulu", 17 | }, 18 | "logging": { 19 | "level": "INFO", 20 | }, 21 | "ssh": { 22 | "auth": { 23 | "bad_usernames": [ 24 | "admin", 25 | "administrator", 26 | "root", 27 | "system", 28 | "god", 29 | ], 30 | "no_password": ["guest"], 31 | }, 32 | "host": "0.0.0.0", 33 | "host_keys": ["data/ssh_host_key"], 34 | "port": 8022, 35 | "proxy_protocol": True, 36 | "userland": { 37 | "paths": ["userland/scripts"], 38 | "top": ["top"], 39 | }, 40 | }, 41 | "web": { 42 | "host": "0.0.0.0", 43 | "port": 5000, 44 | "userland": { 45 | "modules": ["userland.web"], 46 | }, 47 | "proxy": True, 48 | }, 49 | } 50 | """Default configuration""" 51 | -------------------------------------------------------------------------------- /xthulu/ssh/__init__.py: -------------------------------------------------------------------------------- 1 | """SSH server module""" 2 | 3 | # type checking 4 | from typing import Any 5 | 6 | # stdlib 7 | from logging import DEBUG 8 | from tracemalloc import start 9 | 10 | # 3rd party 11 | from asyncssh import SSHAcceptor, listen 12 | 13 | # local 14 | from ..configuration import get_config 15 | from ..logger import log 16 | from .codecs import register_encodings 17 | from .process_factory import handle_client 18 | from .proxy_protocol import ProxyProtocolListener 19 | from .server import SSHServer 20 | 21 | 22 | async def start_server() -> SSHAcceptor: 23 | """Start the SSH server.""" 24 | 25 | register_encodings() 26 | ssh_config: dict[str, Any] = get_config("ssh") 27 | host: str = ssh_config["host"] 28 | port = int(ssh_config["port"]) 29 | log.info(f"SSH listening on {host}:{port}") 30 | 31 | kwargs = { 32 | "host": host, 33 | "port": port, 34 | "server_factory": SSHServer, 35 | "server_host_keys": ssh_config["host_keys"], 36 | "process_factory": handle_client, 37 | "encoding": None, 38 | } 39 | 40 | use_proxy: bool = get_config("ssh.proxy_protocol", False) 41 | 42 | if use_proxy: 43 | log.info("Using PROXY protocol v1 listener") 44 | kwargs["tunnel"] = ProxyProtocolListener() 45 | 46 | if log.getEffectiveLevel() == DEBUG: 47 | start() 48 | 49 | return await listen(**kwargs) 50 | -------------------------------------------------------------------------------- /xthulu/logger.py: -------------------------------------------------------------------------------- 1 | """Logger setup""" 2 | 3 | # stdlib 4 | import gzip 5 | from logging import Formatter, getLogger, StreamHandler 6 | from logging.handlers import TimedRotatingFileHandler 7 | from os import path, remove 8 | from shutil import copyfileobj 9 | from sys import stdout 10 | 11 | # local 12 | from .configuration import get_config 13 | 14 | 15 | def namer(name: str): 16 | """Name rotated files with *.gz extension.""" 17 | 18 | return name + ".gz" 19 | 20 | 21 | def rotator(source: str, dest: str): 22 | """Gzip files during rotation.""" 23 | 24 | with open(source, "rb") as f_in: 25 | with gzip.open(dest, "wb") as f_out: 26 | copyfileobj(f_in, f_out) # type: ignore 27 | 28 | remove(source) 29 | 30 | 31 | log = getLogger(__name__) 32 | """Root logger instance""" 33 | 34 | formatter = Formatter( 35 | "{asctime} {levelname:<7} <{module}.{funcName}> {message}", style="{" 36 | ) 37 | """LogRecord formatter""" 38 | 39 | fileHandler = TimedRotatingFileHandler( 40 | path.join("run", "logs", "xthulu.log"), when="d" 41 | ) 42 | """Rotating file handler""" 43 | 44 | fileHandler.rotator = rotator 45 | fileHandler.namer = namer 46 | 47 | streamHandler = StreamHandler(stdout) 48 | """Console stream handler""" 49 | 50 | fileHandler.setFormatter(formatter) 51 | streamHandler.setFormatter(formatter) 52 | log.addHandler(fileHandler) 53 | log.addHandler(streamHandler) 54 | log.setLevel(str(get_config("logging.level", "INFO")).upper()) 55 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "devDependencies": { 3 | "@commitlint/config-conventional": "^19.8.1", 4 | "@semantic-release/changelog": "^6.0.3", 5 | "@semantic-release/git": "^10.0.1", 6 | "@semantic-release/github": "^10.0.2", 7 | "@types/node": "^20.11.30", 8 | "@typescript-eslint/eslint-plugin": "^7.4.0", 9 | "@typescript-eslint/parser": "^7.4.0", 10 | "commitlint": "^19.8.1", 11 | "eslint": "^8.57.0", 12 | "eslint-config-prettier": "^9.1.0", 13 | "eslint-plugin-prettier": "^5.1.3", 14 | "gitmoji-cli": "^9.7.0", 15 | "husky": "^9.1.7", 16 | "nano-staged": "^0.8.0", 17 | "prettier": "^3.2.5", 18 | "prettier-plugin-organize-imports": "^3.2.4", 19 | "prettier-plugin-toml": "^2.0.1", 20 | "semantic-release": "^22.0.5", 21 | "semantic-release-gitmoji": "^1.6.5", 22 | "semantic-release-replace-plugin": "^1.2.7", 23 | "typescript": "^5.4.3", 24 | "vite": "^7.1.11" 25 | }, 26 | "engines": { 27 | "node": "^22" 28 | }, 29 | "nano-staged": { 30 | "*": [ 31 | "prettier -luw" 32 | ], 33 | "*.{cj,ct,j,mj,mt,t}s": [ 34 | "eslint --fix" 35 | ], 36 | "*.py": [ 37 | "ruff format", 38 | "ruff check --fix" 39 | ] 40 | }, 41 | "overrides": { 42 | "npm": "^10.9.3", 43 | "tmp": "^0.2.4" 44 | }, 45 | "prettier": { 46 | "plugins": [ 47 | "prettier-plugin-organize-imports", 48 | "prettier-plugin-toml" 49 | ] 50 | }, 51 | "scripts": { 52 | "build": "npm ci && npm run build:local", 53 | "build:local": "vite build userland/web/static", 54 | "prepare": "husky || true" 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /xthulu/configuration/__init__.py: -------------------------------------------------------------------------------- 1 | """Configuration utilities""" 2 | 3 | # type checking 4 | from typing import Any, Mapping 5 | 6 | 7 | def deep_update(source: dict, overrides: Mapping): 8 | """ 9 | Update a nested dictionary in place. 10 | """ 11 | 12 | for key, value in overrides.items(): 13 | if isinstance(value, Mapping) and value: 14 | returned = deep_update(source.get(key, {}), value) 15 | source[key] = returned 16 | else: 17 | source[key] = overrides[key] 18 | 19 | return source 20 | 21 | 22 | def get_config( 23 | path: str, default: Any = None, config: dict[str, Any] | None = None 24 | ) -> Any: 25 | """ 26 | Get value from configuration path safely. 27 | 28 | Args: 29 | path: The configuration path to traverse for a value. 30 | default: The default value if the path does not exist. 31 | config: The configuration dictionary to scan. If this is not provided, \ 32 | the system configuration will be loaded by default. 33 | 34 | Returns: 35 | The configuration value or the provided default if the path does not \ 36 | exist. 37 | """ 38 | 39 | store = config 40 | 41 | if store is None: 42 | from ..resources import Resources 43 | 44 | store = Resources().config 45 | 46 | steps = path.split(".") 47 | 48 | for step in steps: 49 | store = store.get(step) 50 | 51 | if store is None: 52 | return default 53 | 54 | return store 55 | -------------------------------------------------------------------------------- /requirements/dev.txt: -------------------------------------------------------------------------------- 1 | # 2 | # This file is autogenerated by pip-compile with Python 3.12 3 | # by the following command: 4 | # 5 | # pip-compile dev.in 6 | # 7 | build==1.3.0 8 | # via pip-tools 9 | click==8.3.0 10 | # via 11 | # -c requirements.txt 12 | # pip-tools 13 | coverage[toml]==7.10.4 14 | # via pytest-cov 15 | filelock==3.19.1 16 | # via pytest-mypy 17 | iniconfig==2.1.0 18 | # via pytest 19 | mypy==1.17.1 20 | # via pytest-mypy 21 | mypy-extensions==1.1.0 22 | # via mypy 23 | packaging==25.0 24 | # via 25 | # build 26 | # pytest 27 | pathspec==0.12.1 28 | # via mypy 29 | pip-tools==7.5.0 30 | # via -r dev.in 31 | pluggy==1.6.0 32 | # via 33 | # pytest 34 | # pytest-cov 35 | pygments==2.19.2 36 | # via 37 | # -c requirements.txt 38 | # pytest 39 | pyproject-hooks==1.2.0 40 | # via 41 | # build 42 | # pip-tools 43 | pytest==8.4.1 44 | # via 45 | # -r dev.in 46 | # pytest-asyncio 47 | # pytest-cov 48 | # pytest-mypy 49 | pytest-asyncio==1.1.0 50 | # via -r dev.in 51 | pytest-cov==6.2.1 52 | # via -r dev.in 53 | pytest-mypy==1.0.1 54 | # via -r dev.in 55 | ruff==0.12.9 56 | # via -r dev.in 57 | types-aiofiles==24.1.0.20250809 58 | # via -r dev.in 59 | typing-extensions==4.15.0 60 | # via 61 | # -c requirements.txt 62 | # mypy 63 | wheel==0.45.1 64 | # via pip-tools 65 | 66 | # The following packages are considered to be unsafe in a requirements file: 67 | # pip 68 | # setuptools 69 | -------------------------------------------------------------------------------- /.github/workflows/docker-build.yml: -------------------------------------------------------------------------------- 1 | name: Docker build 2 | 3 | on: 4 | pull_request: 5 | branches: [main] 6 | 7 | push: 8 | branches: [main] 9 | 10 | workflow_dispatch: 11 | 12 | concurrency: 13 | cancel-in-progress: true 14 | group: | 15 | ${{ github.workflow }}-${{ github.event.pull_request.id || github.branch }} 16 | 17 | jobs: 18 | changes: 19 | name: Change detection 20 | runs-on: ubuntu-latest 21 | outputs: 22 | src: ${{ steps.changes.outputs.src }} 23 | steps: 24 | - name: Check out 25 | uses: actions/checkout@v4 26 | 27 | - name: Detect changed files 28 | id: changes 29 | uses: dorny/paths-filter@v3 30 | with: 31 | filters: | 32 | src: 33 | - "docker/**" 34 | - pyproject.toml 35 | - "requirements/**" 36 | - "xthulu/**" 37 | 38 | validate: 39 | name: Validate 40 | needs: changes 41 | if: needs.changes.outputs.src == 'true' || github.ref == 'refs/heads/main' 42 | runs-on: ubuntu-latest 43 | steps: 44 | - name: Check out 45 | uses: actions/checkout@v4 46 | 47 | - name: Validate docker compose configuration 48 | run: docker compose -f docker/docker-compose.yml config 49 | 50 | build: 51 | name: Build 52 | needs: changes 53 | if: needs.changes.outputs.src == 'true' || github.ref == 'refs/heads/main' 54 | runs-on: ubuntu-latest 55 | steps: 56 | - name: Check out 57 | uses: actions/checkout@v4 58 | 59 | - name: Build docker image 60 | run: docker build -t xthulu -f docker/Dockerfile . 61 | -------------------------------------------------------------------------------- /tests/web/test_start_server.py: -------------------------------------------------------------------------------- 1 | """Web server tests""" 2 | 3 | # stdlib 4 | from unittest.mock import Mock, patch 5 | 6 | # 3rd party 7 | import pytest 8 | 9 | # local 10 | from tests.mocks.config import patch_get_config, test_config, test_web_config 11 | from xthulu.web import create_app, start_server 12 | 13 | 14 | @pytest.fixture(autouse=True) 15 | def mock_config(): 16 | with patch("xthulu.web.get_config", patch_get_config(test_config)) as p: 17 | yield p 18 | 19 | 20 | @pytest.fixture(autouse=True) 21 | def mock_import_module(): 22 | with patch("xthulu.web.import_module") as p: 23 | yield p 24 | 25 | 26 | @patch("xthulu.web.run") 27 | def test_uses_config(mock_run: Mock): 28 | """Server should bind listener using loaded configuration.""" 29 | 30 | # act 31 | start_server() 32 | 33 | # assert 34 | mock_run.assert_called_once_with( 35 | "xthulu.web.asgi:app", 36 | forwarded_allow_ips="*", 37 | host=test_web_config["host"], 38 | lifespan="on", 39 | port=test_web_config["port"], 40 | proxy_headers=True, 41 | ) 42 | 43 | 44 | @patch("xthulu.web.FastAPI.include_router") 45 | def test_includes_router(mock_include_router: Mock): 46 | """Server should include the API router.""" 47 | 48 | # act 49 | create_app() 50 | 51 | # assert 52 | mock_include_router.assert_called() 53 | 54 | 55 | def test_imports_userland_modules(mock_import_module: Mock): 56 | """Server should import userland modules.""" 57 | 58 | # act 59 | create_app() 60 | 61 | # assert 62 | for mod in test_web_config["userland"]["modules"]: # type: ignore 63 | mock_import_module.assert_called_with(mod) 64 | -------------------------------------------------------------------------------- /xthulu/web/auth.py: -------------------------------------------------------------------------------- 1 | """Web authentication""" 2 | 3 | # stdlib 4 | from secrets import compare_digest 5 | from typing import Annotated 6 | 7 | # 3rd party 8 | from fastapi import Depends, HTTPException, status 9 | from fastapi.security import HTTPBasic, HTTPBasicCredentials 10 | from sqlmodel import func, select 11 | 12 | # local 13 | from ..configuration import get_config 14 | from ..models.user import User 15 | from ..resources import db_session 16 | 17 | auth = HTTPBasic() 18 | 19 | 20 | async def login_user( 21 | credentials: Annotated[HTTPBasicCredentials, Depends(auth)], 22 | ) -> User: 23 | """ 24 | HTTP Basic authentication routine. Use as a dependency in route arguments to 25 | require authentication. 26 | 27 | Returns: 28 | A `xthulu.models.user.User` model object for the authenticated user. 29 | """ 30 | 31 | async with db_session() as db: 32 | user = ( 33 | await db.exec( 34 | select(User).where( 35 | func.lower(User.name) == credentials.username.lower() 36 | ) 37 | ) 38 | ).one_or_none() 39 | 40 | if not user: 41 | raise HTTPException( 42 | status_code=status.HTTP_401_UNAUTHORIZED, 43 | headers={"WWW-Authenticate": "Basic"}, 44 | ) 45 | 46 | no_password = set(get_config("ssh.auth.no_password", [])) 47 | 48 | if user.name in no_password: 49 | return user 50 | 51 | expected, _ = User.hash_password(credentials.password, user.salt) 52 | assert user.password 53 | 54 | if not compare_digest(expected, user.password): 55 | raise HTTPException( 56 | status_code=status.HTTP_401_UNAUTHORIZED, 57 | headers={"WWW-Authenticate": "Basic"}, 58 | ) 59 | 60 | return user 61 | -------------------------------------------------------------------------------- /xthulu/ssh/console/internal/input.py: -------------------------------------------------------------------------------- 1 | """Input utilities""" 2 | 3 | # stdlib 4 | from asyncio import QueueEmpty, sleep, wait_for 5 | 6 | # local 7 | from ...context import SSHContext 8 | 9 | 10 | async def wait_for_key( 11 | context: SSHContext, text="", spinner="dots", timeout=0.0 12 | ) -> bytes | None: 13 | """ 14 | Wait for (and return) a keypress. 15 | 16 | Args: 17 | context: The current `xthulu.ssh.context.SSHContext`. 18 | text: The prompt text, if any. 19 | spinner: The prompt spinner (if `text` is specified). 20 | timeout: The length of time (in seconds) to wait for input. A value of \ 21 | `0` will wait forever. 22 | 23 | Returns: 24 | The byte sequence of the key that was pressed, if any. 25 | """ 26 | 27 | async def _wait(): 28 | seq = [] 29 | 30 | while True: 31 | try: 32 | key = context.input.get_nowait() 33 | seq.append(key) 34 | 35 | # wait for next char in ESC sequence (arrow keys, etc.) 36 | if key == b"\x1b": 37 | try: 38 | key = await wait_for(context.input.get(), 0.2) 39 | seq.append(key) 40 | except TimeoutError: 41 | pass 42 | 43 | break 44 | 45 | except QueueEmpty: 46 | await sleep(0.01) 47 | 48 | return b"".join(seq) 49 | 50 | try: 51 | if text == "": 52 | if timeout > 0.0: 53 | return await wait_for(_wait(), timeout) 54 | 55 | return await _wait() 56 | 57 | with context.console.status(text, spinner=spinner): 58 | if timeout > 0.0: 59 | return await wait_for(_wait(), timeout) 60 | 61 | return await _wait() 62 | 63 | except TimeoutError: 64 | pass 65 | 66 | return None 67 | -------------------------------------------------------------------------------- /xthulu/ssh/console/__init__.py: -------------------------------------------------------------------------------- 1 | """An SSH-integrated Console for use with rich/Textual""" 2 | 3 | # stdlib 4 | from typing import Any, Literal, Mapping 5 | 6 | # 3rd party 7 | from asyncssh import SSHWriter 8 | from rich.console import Console 9 | 10 | # local 11 | from .internal.file_wrapper import FileWrapper 12 | 13 | 14 | class XthuluConsole(Console): 15 | """ 16 | Wrapper around rich's Console for integrating with 17 | `xthulu.ssh.context.SSHContext` queues 18 | """ 19 | 20 | _encoding: str 21 | 22 | def __init__( 23 | self, 24 | *, 25 | encoding: str, 26 | height: int | None = None, 27 | ssh_writer: SSHWriter[Any], 28 | width: int | None = None, 29 | _environ: Mapping[str, str] | None = None, 30 | **kwargs, 31 | ): 32 | self.encoding = encoding 33 | color_term = _environ.get("COLORTERM") if _environ else None 34 | term_type = (_environ.get("TERM") if _environ else None) or "" 35 | color_system: ( 36 | Literal["auto", "standard", "256", "truecolor", "windows"] | None 37 | ) = ( 38 | "truecolor" 39 | if color_term == "truecolor" or term_type.find("truecolor") >= 0 40 | else "256" 41 | if term_type.find("256") >= 0 42 | else "standard" 43 | ) 44 | super(XthuluConsole, self).__init__( 45 | **kwargs, 46 | color_system=color_system, 47 | file=FileWrapper(ssh_writer, encoding), # type: ignore 48 | force_interactive=True, 49 | force_terminal=True, 50 | highlight=False, 51 | width=width, 52 | height=height, 53 | _environ=_environ, 54 | ) 55 | 56 | @property 57 | def encoding(self): 58 | return self._encoding 59 | 60 | @encoding.setter 61 | def encoding(self, val: str): 62 | self._encoding = val 63 | -------------------------------------------------------------------------------- /.github/workflows/commitlint.yml: -------------------------------------------------------------------------------- 1 | name: CommitLint 2 | 3 | on: 4 | pull_request: 5 | branches: [main] 6 | types: [opened, edited, reopened, synchronize] 7 | 8 | concurrency: 9 | cancel-in-progress: true 10 | group: ${{ github.workflow }}-${{ github.branch }} 11 | 12 | jobs: 13 | commitlint: 14 | name: CommitLint 15 | runs-on: ubuntu-latest 16 | steps: 17 | - name: Check out 18 | uses: actions/checkout@v4 19 | 20 | - name: Set up Node 21 | uses: actions/setup-node@v4 22 | with: 23 | node-version-file: .nvmrc 24 | cache: npm 25 | 26 | - name: Install dependencies 27 | run: npm ci 28 | 29 | - name: Lint pull request title 30 | id: lint 31 | env: 32 | PR_TITLE: ${{ github.event.pull_request.title }} 33 | run: echo "$PR_TITLE" | npx commitlint 34 | 35 | - name: Find comment 36 | id: find 37 | if: always() 38 | uses: peter-evans/find-comment@v3 39 | with: 40 | issue-number: ${{ github.event.pull_request.number }} 41 | body-includes: 42 | 43 | - name: Failure comment 44 | if: failure() 45 | uses: peter-evans/create-or-update-comment@v4 46 | with: 47 | comment-id: ${{ steps.find.outputs.comment-id }} 48 | issue-number: ${{ github.event.pull_request.number }} 49 | edit-mode: replace 50 | body: | 51 | ❌ Your pull request title does not follow the conventional commit standard enforced by the project. It must begin with one of the emoji from the [gitmoji](https://gitmoji.dev) set appropriate for the change(s) being made. 52 | 53 | - name: Delete comment 54 | if: steps.find.outputs.comment-id != 0 55 | uses: detomarco/delete-comments@1.1.0 56 | with: 57 | comment-id: ${{ steps.find.outputs.comment-id }} 58 | -------------------------------------------------------------------------------- /xthulu/ssh/console/choice.py: -------------------------------------------------------------------------------- 1 | """Choice script helper function""" 2 | 3 | # stdlib 4 | from typing import Sequence 5 | 6 | # 3rd party 7 | from rich.control import Control 8 | 9 | # local 10 | from ..context import SSHContext 11 | 12 | 13 | async def choice( 14 | cx: SSHContext, 15 | prompt: str, 16 | options: Sequence[str], 17 | color: str = "black on green", 18 | ) -> str: 19 | """ 20 | Choose from a short list of provided options. 21 | 22 | Args: 23 | cx: The current SSH context 24 | prompt: The prompt text to show before the options 25 | options: A sequence of options, ideally with unique first characters 26 | color: The color to use for the highlighted option 27 | 28 | Returns: 29 | The selected option's value 30 | """ 31 | 32 | def _prompt(): 33 | cx.console.control(Control.move_to_column(0)) 34 | cx.echo(prompt) 35 | 36 | for i, option in enumerate(options): 37 | if opt == i: 38 | cx.echo(f"[{color}] {option} [/]") 39 | else: 40 | cx.echo(f" {option} ") 41 | 42 | cx.console.control(Control.show_cursor(False)) 43 | opt = 0 44 | how_many = len(options) 45 | 46 | while True: 47 | _prompt() 48 | 49 | k = await cx.inkey() 50 | assert k 51 | 52 | if k == b"\x1b[D": 53 | opt = (opt - 1) if opt > 0 else (how_many - 1) 54 | elif k in (b"\x1b[C", b"\t"): 55 | opt = (opt + 1) if opt < (how_many - 1) else 0 56 | elif k == b"\r": 57 | break 58 | else: 59 | decoded = k.decode() 60 | 61 | for i, key in enumerate([name[0].lower() for name in options]): 62 | if key == decoded: 63 | opt = i 64 | break 65 | else: 66 | continue 67 | 68 | _prompt() 69 | break 70 | 71 | cx.console.control(Control.show_cursor(True)) 72 | 73 | return options[opt] 74 | -------------------------------------------------------------------------------- /userland/scripts/nua.py: -------------------------------------------------------------------------------- 1 | """New user application script""" 2 | 3 | # stdlib 4 | from os import path 5 | 6 | # 3rd party 7 | from textual import events 8 | from textual.app import ComposeResult 9 | from textual.containers import Center, Horizontal 10 | from textual.widgets import Button 11 | 12 | # api 13 | from xthulu.ssh.console.banner_app import BannerApp 14 | from xthulu.ssh.context import SSHContext 15 | 16 | 17 | class NuaApp(BannerApp[str]): 18 | """New user application""" 19 | 20 | BANNER_PADDING = 2 21 | CSS_PATH = path.join(path.dirname(__file__), "nua.tcss") 22 | 23 | def compose(self) -> ComposeResult: 24 | for widget in super(NuaApp, self).compose(): 25 | yield widget 26 | 27 | with Center(): 28 | with Horizontal(id="buttons_wrapper"): 29 | yield Button( 30 | "Continue as guest", 31 | flat=True, 32 | variant="success", 33 | id="guest", 34 | ) 35 | yield Button( 36 | "Create an account", 37 | flat=True, 38 | variant="primary", 39 | id="create", 40 | ) 41 | yield Button( 42 | "Log off", 43 | flat=True, 44 | variant="error", 45 | id="logoff", 46 | ) 47 | 48 | async def on_button_pressed(self, event: Button.Pressed) -> None: 49 | self.exit(result=event.button.id) 50 | 51 | async def on_key(self, event: events.Key) -> None: 52 | if event.key != "escape": 53 | return 54 | 55 | self.exit(result="logoff") 56 | 57 | 58 | async def main(cx: SSHContext) -> str | None: 59 | cx.console.set_window_title("new user application") 60 | return await NuaApp( 61 | cx, 62 | art_path=path.join("userland", "artwork", "nua.ans"), 63 | art_encoding="amiga", 64 | alt="79 Columns // New user application", 65 | ).run_async() 66 | -------------------------------------------------------------------------------- /userland/models/message/__init__.py: -------------------------------------------------------------------------------- 1 | """Message model""" 2 | 3 | # stdlib 4 | from datetime import datetime 5 | from typing import Any, ClassVar, Optional 6 | 7 | # 3rd party 8 | from sqlmodel import Field, Relationship, SQLModel 9 | 10 | # api 11 | from xthulu.models.user import User 12 | 13 | 14 | class Message(SQLModel, table=True): 15 | """Message model""" 16 | 17 | MAX_TITLE_LENGTH: ClassVar[int] = 120 18 | 19 | id: int | None = Field(primary_key=True, default=None) 20 | """Unique ID""" 21 | 22 | parent_id: int | None = Field(foreign_key="message.id", default=None) 23 | """Parent message ID (if any)""" 24 | 25 | parent: Optional["Message"] = Relationship( 26 | back_populates="children", 27 | sa_relationship_kwargs={"remote_side": "Message.id"}, 28 | ) 29 | """Parent message (if any)""" 30 | 31 | children: list["Message"] = Relationship(back_populates="parent") 32 | """Child messages""" 33 | 34 | author_id: int | None = Field(foreign_key="user.id", default=None) 35 | """Author ID of the message""" 36 | 37 | author: User | None = Relationship( 38 | sa_relationship_kwargs={ 39 | "primaryjoin": "Message.author_id == User.id", 40 | } 41 | ) 42 | """Author of the message""" 43 | 44 | recipient_id: int | None = Field(foreign_key="user.id", default=None) 45 | """Recipient ID of the message (`None` for public messages)""" 46 | 47 | recipient: User | None = Relationship( 48 | sa_relationship_kwargs={ 49 | "primaryjoin": "Message.recipient_id == User.id", 50 | } 51 | ) 52 | """Recipient of the message""" 53 | 54 | created: datetime = Field(default_factory=datetime.now) 55 | """Creation time""" 56 | 57 | title: str = Field(max_length=MAX_TITLE_LENGTH) 58 | """Title of the message""" 59 | 60 | content: str = Field() 61 | """The message's content""" 62 | 63 | def __init__(self, **data: Any): 64 | super(Message, self).__init__(**data) 65 | 66 | def __repr__(self): # pragma: no cover 67 | return f"Message(#{self.id})" 68 | -------------------------------------------------------------------------------- /userland/web/static/chat/script.ts: -------------------------------------------------------------------------------- 1 | const ul: HTMLUListElement = document.getElementsByTagName("ul")[0]; 2 | const es = new EventSource("/api/chat/"); 3 | 4 | interface ChatMessage { 5 | user: string | null; 6 | message: string; 7 | } 8 | 9 | interface ChatToken { 10 | token: string; 11 | } 12 | 13 | let token: string; 14 | 15 | // handle new EventSource message 16 | es.addEventListener("message", (ev) => { 17 | if (!ev.data) return; 18 | 19 | const data: ChatMessage | ChatToken = JSON.parse(ev.data); 20 | 21 | // refresh CSRF token with new value 22 | if (Object.prototype.hasOwnProperty.call(data, "token")) { 23 | token = (data as ChatToken).token; 24 | return; 25 | } 26 | 27 | const message = data as ChatMessage; 28 | 29 | // append new chat message to list 30 | const li: HTMLLIElement = document.createElement("li"); 31 | 32 | li.innerHTML = /*html*/ ` 33 | 34 | <${message.user ?? "*"}> 35 | 36 | 37 | `; 38 | (li.querySelector(".message") as HTMLSpanElement)!.innerText = 39 | message.message; 40 | 41 | ul.appendChild(li); 42 | }); 43 | 44 | const f: HTMLFormElement = document.getElementsByTagName("form")[0]; 45 | const inp: HTMLInputElement = document.getElementsByTagName("input")[0]; 46 | 47 | // handle chat message submission 48 | f.addEventListener("submit", async (ev: SubmitEvent) => { 49 | ev.preventDefault(); 50 | ev.stopPropagation(); 51 | 52 | const message = inp.value.trim(); 53 | inp.value = ""; 54 | 55 | // squash empty messages 56 | if (message.length === 0) return; 57 | 58 | // post to server 59 | await fetch("/api/chat/", { 60 | body: JSON.stringify({ message, token }), 61 | headers: { "Content-Type": "application/json" }, 62 | method: "POST", 63 | }).then(async (r) => { 64 | // display JSON errors and text errors 65 | if (r.status != 200) { 66 | try { 67 | alert(await r.json().then((v) => `Error: ${v.detail}`)); 68 | } catch (ex) { 69 | alert(await r.text()); 70 | } 71 | 72 | return; 73 | } 74 | }); 75 | 76 | return false; 77 | }); 78 | -------------------------------------------------------------------------------- /xthulu/models/user.py: -------------------------------------------------------------------------------- 1 | """User model and helper functions""" 2 | 3 | # stdlib 4 | from datetime import datetime 5 | from typing import Any, ClassVar 6 | 7 | # 3rd party 8 | import bcrypt 9 | from sqlmodel import Field, func, Index, SQLModel 10 | 11 | 12 | class User(SQLModel, table=True): 13 | """User model""" 14 | 15 | MAX_NAME_LENGTH: ClassVar[int] = 24 16 | """Maximum allowed user name length""" 17 | 18 | id: int | None = Field(primary_key=True, default=None) 19 | """Unique ID""" 20 | 21 | name: str = Field(max_length=MAX_NAME_LENGTH, unique=True) 22 | """User name""" 23 | 24 | email: str = Field(max_length=64, unique=True) 25 | """Email address""" 26 | 27 | password: bytes | None = Field(max_length=64, default=None) 28 | """Encrypted password""" 29 | 30 | salt: bytes | None = Field(max_length=32, default=None) 31 | """Password salt""" 32 | 33 | created: datetime = Field(default_factory=datetime.now) 34 | """Creation time""" 35 | 36 | last: datetime = Field(default_factory=datetime.now) 37 | """Last login""" 38 | 39 | __table_args__ = ( 40 | Index("idx_user_name_lower", func.lower("name")), 41 | Index("idx_user_email_lower", func.lower("email")), 42 | ) 43 | 44 | def __init__(self, **data: Any): 45 | super(User, self).__init__(**data) 46 | 47 | def __repr__(self): # pragma: no cover 48 | return f"User({self.name}#{self.id})" 49 | 50 | @staticmethod 51 | def hash_password( 52 | pwd: str, salt: bytes | None = None 53 | ) -> tuple[bytes, bytes]: 54 | """ 55 | Generate a hash for the given password and salt. If no salt is 56 | provided, one will be generated. 57 | 58 | Args: 59 | pwd: The plain-text password to encrypt. 60 | salt: The salt to use, if any. 61 | 62 | Returns: 63 | The encrypted password and salt as a tuple. 64 | """ 65 | 66 | if salt is None: 67 | salt = bcrypt.gensalt() 68 | 69 | password = bcrypt.hashpw(pwd.encode("utf-8"), salt) 70 | 71 | return password, salt 72 | -------------------------------------------------------------------------------- /userland/scripts/messages/save_modal.py: -------------------------------------------------------------------------------- 1 | """Save confirmation screen""" 2 | 3 | # 3rd party 4 | from textual.containers import Horizontal, Vertical 5 | from textual.screen import ModalScreen 6 | from textual.widgets import Button, Label 7 | 8 | # local 9 | from userland.models import Message 10 | from .details_modal import DetailsModal 11 | 12 | 13 | class SaveModal(ModalScreen): 14 | """Save confirmation screen""" 15 | 16 | BINDINGS = [("escape", "app.pop_screen", "")] 17 | 18 | CSS = """ 19 | SaveModal { 20 | align: center middle; 21 | background: rgba(0, 0, 0, 0.5); 22 | } 23 | 24 | Button { 25 | margin: 1; 26 | width: 33%; 27 | } 28 | 29 | #save { 30 | margin-left: 0; 31 | margin-top: 1; 32 | } 33 | 34 | #wrapper { 35 | background: $primary-background; 36 | height: 7; 37 | padding: 1; 38 | width: 60; 39 | } 40 | """ 41 | 42 | reply_to: Message | None 43 | 44 | def __init__(self, *args, reply_to: Message | None = None, **kwargs): 45 | super(SaveModal, self).__init__(*args, **kwargs) 46 | self.reply_to = reply_to 47 | 48 | def compose(self): 49 | with Vertical(id="wrapper"): 50 | yield Label("Do you want to save your message?") 51 | 52 | with Horizontal(): 53 | yield Button("Save", variant="success", id="save") 54 | yield Button("Continue", variant="primary", id="continue") 55 | yield Button("Discard", variant="error", id="discard") 56 | 57 | async def on_button_pressed(self, event: Button.Pressed) -> None: 58 | assert event.button.id 59 | 60 | if event.button.id == "continue": 61 | self.app.pop_screen() # pop this modal 62 | return 63 | 64 | if event.button.id == "save": 65 | self.app.pop_screen() 66 | await self.app.push_screen(DetailsModal(reply_to=self.reply_to)) 67 | return 68 | 69 | self.app.pop_screen() # pop this modal 70 | self.app.pop_screen() # pop the editor 71 | -------------------------------------------------------------------------------- /xthulu/web/__init__.py: -------------------------------------------------------------------------------- 1 | """Web server module""" 2 | 3 | # stdlib 4 | from contextlib import asynccontextmanager 5 | from importlib import import_module 6 | from logging import getLogger 7 | from logging.handlers import TimedRotatingFileHandler 8 | from os import path 9 | 10 | # 3rd party 11 | from fastapi import APIRouter, FastAPI 12 | from uvicorn import run 13 | from uvicorn.logging import AccessFormatter 14 | 15 | # local 16 | from ..configuration import get_config 17 | from ..logger import log, namer, rotator 18 | 19 | 20 | api = APIRouter() 21 | """Main router""" 22 | 23 | 24 | @asynccontextmanager 25 | async def lifespan(app: FastAPI): 26 | """FastAPI lifespan; handles startup and shutdown.""" 27 | 28 | access_log = getLogger("uvicorn.access") 29 | fileHandler = TimedRotatingFileHandler( 30 | path.join("run", "logs", "www.log"), when="d" 31 | ) 32 | fileHandler.rotator = rotator 33 | fileHandler.namer = namer 34 | fileHandler.setFormatter( 35 | AccessFormatter( 36 | '%(asctime)s %(client_addr)s - "%(request_line)s" %(status_code)s', 37 | use_colors=False, 38 | ) 39 | ) 40 | access_log.addHandler(fileHandler) 41 | yield 42 | 43 | 44 | def create_app(): 45 | """Create and configure the ASGI application.""" 46 | 47 | app = FastAPI(lifespan=lifespan) 48 | 49 | for mod in list(get_config("web.userland.modules", [])): 50 | m = import_module(mod) 51 | mod_api = getattr(m, "api") 52 | 53 | if not mod_api: # pragma: no cover 54 | log.warning(f"No APIRouter found in userland web module {mod}") 55 | continue 56 | 57 | app.include_router(mod_api, prefix="/api") 58 | 59 | app.include_router(api, prefix="/api") 60 | 61 | return app 62 | 63 | 64 | def start_server(): 65 | """Run ASGI web application in a uvicorn server.""" 66 | 67 | proxy = get_config("web.proxy", True) 68 | 69 | run( 70 | "xthulu.web.asgi:app", 71 | forwarded_allow_ips="*" if proxy else None, 72 | host=get_config("web.host"), 73 | lifespan="on", 74 | port=int(get_config("web.port")), 75 | proxy_headers=proxy, 76 | ) 77 | -------------------------------------------------------------------------------- /xthulu/ssh/console/app.py: -------------------------------------------------------------------------------- 1 | """Textual application wrapper""" 2 | 3 | # stdlib 4 | from asyncio import sleep 5 | 6 | # 3rd party 7 | from rich.segment import Segments 8 | from textual import events 9 | from textual.app import App, ReturnType 10 | from textual.geometry import Size 11 | 12 | # local 13 | from ...logger import log 14 | from ..context import SSHContext 15 | 16 | 17 | class _ErrorConsoleProxy: 18 | def print(self, what, **kwargs): 19 | if isinstance(what, Segments): 20 | log.error("".join([s.text for s in what.segments])) 21 | return 22 | 23 | log.error(what) 24 | 25 | 26 | class XthuluApp(App[ReturnType]): 27 | """SSH wrapper for Textual apps""" 28 | 29 | ENABLE_COMMAND_PALETTE = False 30 | """Command palette is disabled by default""" 31 | 32 | context: SSHContext 33 | """The current SSH context""" 34 | 35 | def __init__(self, context: SSHContext, ansi_color=True, **kwargs): 36 | "" # empty docstring 37 | # avoid cyclic import 38 | from .internal.driver import SSHDriver 39 | 40 | super(XthuluApp, self).__init__( 41 | driver_class=SSHDriver, ansi_color=ansi_color, **kwargs 42 | ) 43 | self.context = context 44 | self.console = context.console 45 | self.error_console = _ErrorConsoleProxy() # type: ignore 46 | self.run_worker(self._watch_for_resize, exclusive=True) 47 | 48 | async def _watch_for_resize(self): 49 | # avoid cyclic import 50 | from .internal.driver import SSHDriver 51 | 52 | while True: 53 | ev = self.context.events.get("resize") 54 | 55 | if not ev: 56 | await sleep(0.5) 57 | continue 58 | 59 | new_size = Size(*ev[-1].data) 60 | d: SSHDriver = self._driver # type: ignore 61 | d.process_message(events.Resize(new_size, new_size)) 62 | 63 | def exit(self, **kwargs) -> None: # type: ignore 64 | "" # empty docstring 65 | # avoid cyclic import 66 | from .internal.driver import SSHDriver 67 | 68 | super(XthuluApp, self).exit(**kwargs) 69 | d: SSHDriver = self._driver # type: ignore 70 | d._disable_bracketed_paste() 71 | d._disable_mouse_support() 72 | d.exit_event.set() 73 | -------------------------------------------------------------------------------- /CHECKLIST.md: -------------------------------------------------------------------------------- 1 | # Feature check list 2 | 3 | ## Terminal server 4 | 5 | - [x] SSH server ([AsyncSSH][]) 6 | - [x] Password authentication 7 | - [x] Guest (no-auth) users 8 | - [ ] Key authentication 9 | - [x] PROXY v1 support 10 | - [ ] SFTP subsystem 11 | - [x] Composite userland script stack 12 | - [x] Goto 13 | - [x] Gosub 14 | - [x] Exception handling 15 | - [x] Terminal library ([rich][]) 16 | - [x] Adapt for SSH session usage 17 | - [ ] UI components ([textual][]) 18 | - [x] Adapt for SSH session usage 19 | - [ ] File browser 20 | - [ ] Message interface 21 | - [x] List messages 22 | - [x] Post messages 23 | - [x] Reply to messages 24 | - [x] Tag system 25 | - [x] Filter by tag(s) 26 | - [ ] Search messages 27 | - [ ] Private messages 28 | - [ ] Door games 29 | - [x] Subprocess redirect for terminal apps 30 | - [ ] Dropfile generators 31 | - [ ] `DOOR.SYS` 32 | - [ ] `DORINFOx.DEF` 33 | 34 | ## Miscellaneous 35 | 36 | - [x] Container proxy ([Traefik][]) 37 | - [x] HTTP server ([uvicorn][]) 38 | - [x] Basic authentication 39 | - [x] Web framework ([FastAPI][]) 40 | - [x] Composite userland 41 | - [x] Static files 42 | - [ ] IPC 43 | - [x] Session events queue 44 | - [x] Methods for manipulating queue (querying specific events, etc.) 45 | - [ ] Can target other sessions and send them events (gosub/goto, chat 46 | requests, IM, etc.) 47 | - [ ] Server events queue (IPC coordination, etc.) 48 | - [x] Locks (IPC semaphore) 49 | - [x] Global IPC (CLI, web, etc.) via Redis PubSub 50 | - [ ] Data layer 51 | - [x] PostgreSQL for data 52 | - [x] Asynchronous ORM ([SQLModel][]) 53 | - [x] User model 54 | - [x] Message bases 55 | - [ ] Simple JSONB table for mixed use 56 | - [ ] Permissions 57 | - [ ] User groups 58 | - [ ] ACLs system 59 | 60 | [asyncssh]: https://asyncssh.readthedocs.io/en/latest/ 61 | [fastapi]: https://fastapi.tiangolo.com 62 | [rich]: https://rich.readthedocs.io/en/latest/ 63 | [sqlmodel]: https://sqlmodel.tiangolo.com/ 64 | [textual]: https://github.com/Textualize/textual 65 | [traefik]: https://traefik.io/traefik 66 | [uvicorn]: https://www.uvicorn.org 67 | -------------------------------------------------------------------------------- /xthulu/resources.py: -------------------------------------------------------------------------------- 1 | """Shared resource singleton""" 2 | 3 | # stdlib 4 | from contextlib import asynccontextmanager 5 | from logging import getLogger 6 | from typing import Any 7 | from os import environ 8 | from os.path import exists, join 9 | 10 | # 3rd party 11 | from redis import Redis 12 | from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine 13 | from sqlmodel.ext.asyncio.session import AsyncSession 14 | from toml import load # type: ignore 15 | 16 | # local 17 | from .configuration import deep_update, get_config 18 | from .configuration.default import default_config 19 | 20 | log = getLogger(__name__) 21 | 22 | 23 | class Resources: 24 | """Shared system resources""" 25 | 26 | cache: Redis 27 | """Redis connection""" 28 | 29 | config: dict[str, Any] 30 | """System configuration""" 31 | 32 | config_file: str 33 | """Configuration file path""" 34 | 35 | db: AsyncEngine 36 | """Database engine""" 37 | 38 | def __new__(cls): 39 | if hasattr(cls, "_singleton"): 40 | return cls._singleton 41 | 42 | singleton = super(Resources, cls).__new__(cls) 43 | singleton._load_config() 44 | singleton.cache = Redis( 45 | host=singleton._config("cache.host"), 46 | port=int(singleton._config("cache.port")), 47 | db=int(singleton._config("cache.db")), 48 | ) 49 | singleton.db = create_async_engine( 50 | singleton._config("db.bind"), future=True 51 | ) 52 | cls._singleton = singleton 53 | 54 | return cls._singleton 55 | 56 | def _config(self, path: str, default: Any = None): 57 | return get_config(path, default, self.config) 58 | 59 | def _load_config(self): 60 | self.config = default_config.copy() 61 | self.config_file = environ.get( 62 | "XTHULU_CONFIG", join("data", "config.toml") 63 | ) 64 | 65 | if exists(self.config_file): 66 | deep_update(self.config, load(self.config_file)) 67 | log.info(f"Loaded configuration file: {self.config_file}") 68 | else: 69 | log.warning(f"Configuration file not found: {self.config_file}") 70 | 71 | 72 | @asynccontextmanager 73 | async def db_session(): 74 | """Get a `sqlmodel.ext.asyncio.session.AsyncSession` object.""" 75 | 76 | async with AsyncSession(Resources().db) as session: 77 | yield session 78 | -------------------------------------------------------------------------------- /tests/ssh/test_start_server.py: -------------------------------------------------------------------------------- 1 | """SSH server startup tests""" 2 | 3 | # stdlib 4 | from logging import DEBUG, Logger 5 | from typing import Any 6 | from unittest.mock import AsyncMock, Mock, patch 7 | 8 | # 3rd party 9 | import pytest 10 | 11 | # local 12 | from tests.mocks.config import patch_get_config, test_config, test_ssh_config 13 | from xthulu.ssh import SSHServer, start_server 14 | from xthulu.ssh.process_factory import handle_client 15 | 16 | 17 | @pytest.fixture(autouse=True) 18 | def mock_config(): 19 | with patch("xthulu.ssh.get_config", patch_get_config(test_config)) as p: 20 | yield p 21 | 22 | 23 | @pytest.fixture(autouse=True) 24 | def mock_listen(): 25 | with patch("xthulu.ssh.listen", AsyncMock()) as p: 26 | yield p 27 | 28 | 29 | @pytest.mark.asyncio 30 | async def test_server_args(mock_listen: Mock): 31 | """Server should bind SSH server to values from configuration.""" 32 | 33 | # act 34 | await start_server() 35 | 36 | # assert 37 | ssh_config: dict[str, Any] = test_config["ssh"] # type: ignore 38 | mock_listen.assert_awaited_once_with( 39 | **{ 40 | "host": ssh_config["host"], 41 | "port": int(ssh_config["port"]), 42 | "server_factory": SSHServer, 43 | "server_host_keys": ssh_config["host_keys"], 44 | "process_factory": handle_client, 45 | "encoding": None, 46 | } 47 | ) 48 | 49 | 50 | @pytest.mark.asyncio 51 | @patch( 52 | "xthulu.ssh.get_config", 53 | patch_get_config( 54 | {**test_config, "ssh": {**test_ssh_config, "proxy_protocol": True}} 55 | ), 56 | ) 57 | @patch("xthulu.ssh.ProxyProtocolListener") 58 | async def test_proxy_procotol(mock_listener: Mock, mock_listen: Mock): 59 | """Server should use a PROXY tunnel if configured to do so.""" 60 | 61 | # act 62 | await start_server() 63 | 64 | # assert 65 | mock_listener.assert_called_once() 66 | mock_listen.assert_awaited_once() 67 | assert "tunnel" in mock_listen.call_args[1] 68 | 69 | 70 | @pytest.mark.asyncio 71 | @patch("xthulu.ssh.start") 72 | async def test_trace_malloc_start(mock_start: Mock): 73 | """Server should call tracemalloc.start if debugging is enabled.""" 74 | 75 | # arrange 76 | with patch.object(Logger, "getEffectiveLevel", return_value=DEBUG): 77 | # act 78 | await start_server() 79 | 80 | # assert 81 | mock_start.assert_called_once() 82 | -------------------------------------------------------------------------------- /tests/cli/test_ssh.py: -------------------------------------------------------------------------------- 1 | """SSH CLI tests""" 2 | 3 | # stdlib 4 | from unittest.mock import AsyncMock, Mock, patch 5 | 6 | # 3rd party 7 | from click.testing import CliRunner 8 | import pytest 9 | 10 | # local 11 | from xthulu import locks 12 | from xthulu.cli import cli 13 | from xthulu.cli.ssh import cli as ssh_cli 14 | 15 | 16 | @pytest.fixture(autouse=True) 17 | def mock_resources(): 18 | with patch("xthulu.locks.Resources") as p: 19 | yield p 20 | 21 | 22 | @pytest.fixture(autouse=True) 23 | def clear_locks(): 24 | locks._Locks.locks.clear() 25 | yield 26 | locks._Locks.locks.clear() 27 | 28 | 29 | @pytest.fixture(autouse=True) 30 | def mock_loop(): 31 | with patch("xthulu.cli.ssh.loop") as p: 32 | yield p.return_value 33 | 34 | 35 | def test_cli_includes_group(): 36 | """The CLI module should include the 'ssh' command group.""" 37 | 38 | # act 39 | command = cli.get_command(Mock(), "ssh") 40 | 41 | # assert 42 | assert command is not None 43 | 44 | 45 | @pytest.mark.parametrize("command_name", ["start"]) 46 | def test_cli_includes_commands(command_name: str): 47 | """The command group should include the specified command.""" 48 | 49 | # act 50 | commands = ssh_cli.list_commands(Mock()) 51 | 52 | # assert 53 | assert command_name in commands 54 | 55 | 56 | @patch("xthulu.cli.ssh.start_server") 57 | def test_start(mock_start: AsyncMock, *, mock_loop: Mock): 58 | """The 'ssh start' command should start an SSH server in the event loop.""" 59 | 60 | # arrange 61 | mock_loop.run_until_complete = Mock(return_value=mock_start) 62 | mock_loop.run_forever = Mock(side_effect=KeyboardInterrupt()) 63 | 64 | # act 65 | CliRunner().invoke(cli, ["ssh", "start"], catch_exceptions=False) 66 | 67 | # assert 68 | mock_start.assert_called_once() 69 | mock_loop.run_until_complete.assert_called_once() 70 | 71 | 72 | @patch("xthulu.cli.ssh.start_server") 73 | def test_shutdown(mock_start: AsyncMock, *, mock_loop: Mock): 74 | """The 'ssh start' command should run lifespan function on shutdown.""" 75 | 76 | # arrange 77 | locks._Locks.locks = {"test_name": {"test_lock": Mock()}} 78 | CliRunner().invoke(cli, ["ssh", "start"], catch_exceptions=False) 79 | shutdown = mock_loop.add_signal_handler.call_args_list[0].args[1] 80 | 81 | # act 82 | shutdown() 83 | 84 | # assert 85 | mock_loop.stop.assert_called_once() 86 | assert "test_name" not in locks._Locks.locks 87 | -------------------------------------------------------------------------------- /userland/cli/db.py: -------------------------------------------------------------------------------- 1 | """Command line database module""" 2 | 3 | # 3rd party 4 | from click import echo, group, option 5 | from sqlmodel import SQLModel 6 | 7 | # api 8 | from xthulu.cli._util import loop 9 | from xthulu.resources import db_session, Resources 10 | 11 | _loop = loop() 12 | 13 | 14 | @group("db") 15 | def cli(): 16 | """Database commands""" 17 | 18 | 19 | @cli.command() 20 | @option( 21 | "-s", 22 | "--seed", 23 | "seed_data", 24 | default=False, 25 | flag_value=True, 26 | help="Seed the database with default data.", 27 | ) 28 | def create(seed_data=False): 29 | """Create database tables.""" 30 | 31 | async def f(): 32 | from .. import models # noqa: F401 33 | from xthulu import models as server_models # noqa: F401 34 | 35 | async with Resources().db.begin() as conn: 36 | await conn.run_sync(SQLModel.metadata.create_all) 37 | 38 | _loop.run_until_complete(f()) 39 | 40 | if seed_data: 41 | _seed() 42 | 43 | 44 | def _seed(): 45 | from ..models import Message, MessageTag, MessageTags 46 | 47 | async def f(): 48 | echo("Posting initial messages") 49 | 50 | async with db_session() as db: 51 | tags = ( 52 | MessageTag(name="demo"), 53 | MessageTag(name="introduction"), 54 | ) 55 | 56 | for tag in tags: 57 | db.add(tag) 58 | await db.commit() 59 | 60 | for i in range(100): 61 | message = Message( 62 | author_id=1, 63 | title=f"Hello, world! #{i}", 64 | content=( 65 | "# Hello\n\nHello, world! ✌️\n\n" 66 | "## Demo\n\nThis is a demonstration message.\n\n" 67 | ) 68 | * 20, 69 | ) 70 | db.add(message) 71 | await db.commit() 72 | 73 | for tag in tags: 74 | await db.refresh(message) 75 | await db.refresh(tag) 76 | db.add( 77 | MessageTags( 78 | message_id=message.id, # type: ignore 79 | tag_name=tag.name, # type: ignore 80 | ) 81 | ) 82 | await db.commit() 83 | 84 | _loop.run_until_complete(f()) 85 | 86 | 87 | @cli.command() 88 | def seed(): 89 | """Initialize database with seed data.""" 90 | 91 | _seed() 92 | -------------------------------------------------------------------------------- /userland/scripts/main.py: -------------------------------------------------------------------------------- 1 | """Main menu script""" 2 | 3 | # stdlib 4 | from os import path 5 | 6 | # 3rd party 7 | from textual.app import ComposeResult 8 | from textual.containers import Center, VerticalScroll 9 | from textual.widgets import Button 10 | 11 | # api 12 | from xthulu.ssh.console.banner_app import BannerApp 13 | from xthulu.ssh.context import SSHContext 14 | 15 | 16 | class MenuApp(BannerApp[str]): 17 | """Main menu""" 18 | 19 | _last: str | None = None 20 | 21 | BANNER_PADDING = 9 22 | BINDINGS = [("escape", "quit", "Log off")] 23 | CSS_PATH = path.join(path.dirname(__file__), "main.tcss") 24 | 25 | def __init__(self, context: SSHContext, last: str | None = None, **kwargs): 26 | "" # empty docstring 27 | super(MenuApp, self).__init__(context, **kwargs) 28 | self._last = last 29 | 30 | def compose(self) -> ComposeResult: 31 | # disable alternate buffer for main menu 32 | self.context.proc.stdout.write(b"\x1b[?1049l") 33 | 34 | for widget in super(MenuApp, self).compose(): 35 | yield widget 36 | 37 | with VerticalScroll(): 38 | with Center(): 39 | with Center(id="buttons"): 40 | yield Button("Messages", id="messages") 41 | yield Button("Node chat", id="chat") 42 | yield Button("Oneliners", id="oneliners") 43 | yield Button("Lock example", id="lock_example") 44 | yield Button("Log off", id="goto_logoff", variant="error") 45 | 46 | async def on_button_pressed(self, event: Button.Pressed) -> None: 47 | assert event.button.id 48 | return self.exit(result=event.button.id) 49 | 50 | async def action_quit(self) -> None: 51 | "" # empty docstring 52 | self.context.console.clear() 53 | self.context.goto("logoff") 54 | 55 | async def on_ready(self) -> None: 56 | if self._last: 57 | btn = self.get_widget_by_id(self._last) 58 | btn.focus() 59 | 60 | 61 | async def main(cx: SSHContext) -> None: 62 | result: str | None = None 63 | 64 | while True: 65 | cx.console.set_window_title("main menu") 66 | result = await MenuApp( 67 | cx, 68 | result, 69 | art_path=path.join("userland", "artwork", "main.ans"), 70 | art_encoding="amiga", 71 | alt="79 Columns // Main menu", 72 | ).run_async() 73 | 74 | if not result: 75 | result = "goto_logoff" 76 | 77 | if result.startswith("goto_"): 78 | break 79 | 80 | await cx.gosub(result) 81 | 82 | cx.console.clear() 83 | cx.goto(result[5:]) 84 | -------------------------------------------------------------------------------- /xthulu/ssh/console/banner_app.py: -------------------------------------------------------------------------------- 1 | """Textual application wrapper with display banner""" 2 | 3 | # stdlib 4 | from math import floor 5 | 6 | # 3rd party 7 | from rich.text import Text 8 | from textual import events 9 | from textual.app import ComposeResult, ReturnType 10 | from textual.widgets import Static 11 | 12 | # local 13 | from ..context import SSHContext 14 | from .app import XthuluApp 15 | from .art import load_art 16 | 17 | 18 | class BannerApp(XthuluApp[ReturnType]): 19 | """Textual app with banner display""" 20 | 21 | BANNER_PADDING = 10 22 | """Required space left over to display banner art""" 23 | 24 | _alt: str 25 | """Alternate text if banner won't fit""" 26 | 27 | art_encoding: str 28 | """Encoding of the artwork file""" 29 | 30 | art_path: str 31 | """Path to the artwork file""" 32 | 33 | artwork: list[str] 34 | """Lines from loaded banner artwork""" 35 | 36 | banner: Static 37 | """Banner widget""" 38 | 39 | def __init__( 40 | self, 41 | context: SSHContext, 42 | art_path: str, 43 | art_encoding: str, 44 | alt: str, 45 | **kwargs, 46 | ): 47 | "" # empty docstring 48 | self.art_encoding = art_encoding 49 | self.art_path = art_path 50 | self.artwork = [] 51 | self._alt = f"{alt}\n" 52 | super(BannerApp, self).__init__(context=context, **kwargs) 53 | 54 | def compose(self) -> ComposeResult: 55 | "" # empty docstring 56 | self.banner = Static(id="banner", markup=False) 57 | yield self.banner 58 | 59 | def _check_size(self, width: int, height: int) -> None: 60 | # assumes art is 80 columns wide; improve this 61 | lines = len(self.artwork) 62 | pad_left = floor(width / 2 - 40) 63 | self.banner.styles.margin = (0, pad_left) 64 | self.banner.styles.width = 80 65 | self.banner.styles.height = lines 66 | self.context.log.info(self.BANNER_PADDING) 67 | 68 | if height < lines + self.BANNER_PADDING or width < 80: 69 | self.banner.styles.height = len(self._alt.splitlines()) 70 | self.banner.update(self._alt) 71 | elif lines > 0: 72 | self.banner.styles.height = lines 73 | text = Text.from_ansi( 74 | "".join(self.artwork), overflow="ignore", end="" 75 | ) 76 | self.banner.update(text) 77 | 78 | async def on_mount(self) -> None: 79 | self.artwork = await load_art(self.art_path, self.art_encoding) 80 | self._check_size(self.console.width, self.console.height) 81 | 82 | def on_resize(self, event: events.Resize) -> None: 83 | self._check_size(event.size.width, event.size.height) 84 | -------------------------------------------------------------------------------- /xthulu/ssh/console/internal/driver.py: -------------------------------------------------------------------------------- 1 | """Console driver""" 2 | 3 | # stdlib 4 | from asyncio import QueueEmpty 5 | from codecs import getincrementaldecoder 6 | from time import sleep 7 | 8 | # 3rd party 9 | from textual import events 10 | from textual._parser import ParseError 11 | from textual._xterm_parser import XTermParser 12 | from textual.drivers.linux_driver import LinuxDriver 13 | 14 | # local 15 | from ...context import SSHContext 16 | from ..app import XthuluApp 17 | 18 | 19 | class SSHDriver(LinuxDriver): 20 | """ 21 | Textual console driver integrated with `xthulu.ssh.context.SSHContext` 22 | queues 23 | """ 24 | 25 | context: SSHContext 26 | """The current SSH context""" 27 | 28 | def __init__(self, app: XthuluApp, **kwargs) -> None: 29 | self.context = app.context 30 | 31 | if "size" in kwargs: 32 | del kwargs["size"] 33 | 34 | super(SSHDriver, self).__init__( 35 | app, 36 | size=self._get_terminal_size(), 37 | **kwargs, 38 | ) 39 | 40 | def _get_terminal_size(self) -> tuple[int, int]: 41 | return (self.context.console.width, self.context.console.height) 42 | 43 | def flush(self) -> None: 44 | pass 45 | 46 | def run_input_thread(self) -> None: 47 | """Wait for input and dispatch events.""" 48 | 49 | parser = XTermParser(self._debug) 50 | feed = parser.feed 51 | tick = parser.tick 52 | utf8_decoder = getincrementaldecoder("utf-8")().decode 53 | decode = utf8_decoder 54 | 55 | while not self.exit_event.is_set(): 56 | try: 57 | unicode_data = decode(self.context.input.get_nowait()) 58 | 59 | for event in feed(unicode_data): 60 | if isinstance(event, events.CursorPosition): 61 | self.cursor_origin = (event.x, event.y) 62 | else: 63 | self.process_message(event) 64 | except QueueEmpty: 65 | sleep(0.01) 66 | except ParseError: 67 | break 68 | 69 | for event in tick(): 70 | if isinstance(event, events.CursorPosition): 71 | self.cursor_origin = (event.x, event.y) 72 | else: 73 | self.process_message(event) 74 | 75 | try: 76 | for event in feed(""): 77 | pass 78 | except ParseError: 79 | pass 80 | 81 | def write(self, data: str) -> None: 82 | try: 83 | self.context.proc.stdout.write(data.encode(self.context.encoding)) 84 | except BrokenPipeError: 85 | # process is likely closing 86 | pass 87 | -------------------------------------------------------------------------------- /tests/test_resources.py: -------------------------------------------------------------------------------- 1 | """Resources tests""" 2 | 3 | # type checking 4 | from typing import Type 5 | 6 | # stdlib 7 | from unittest.mock import Mock, patch 8 | 9 | # 3rd party 10 | import pytest 11 | from redis import Redis 12 | from sqlalchemy.ext.asyncio.engine import AsyncEngine 13 | 14 | # local 15 | from xthulu.configuration.default import default_config 16 | from xthulu.resources import db_session, Resources 17 | 18 | 19 | @pytest.fixture(autouse=True) 20 | def reset_singleton(): 21 | if hasattr(Resources, "_singleton"): 22 | del Resources._singleton 23 | 24 | yield 25 | 26 | if hasattr(Resources, "_singleton"): 27 | del Resources._singleton 28 | 29 | 30 | @pytest.fixture(autouse=True) 31 | def mock_exists(): 32 | with patch("xthulu.resources.exists") as p: 33 | p.return_value = True 34 | yield p 35 | 36 | 37 | @patch("xthulu.resources.load") 38 | def test_config_file_loaded(mock_load: Mock): 39 | """Constructor should load the configuration file.""" 40 | 41 | # act 42 | Resources() 43 | 44 | # assert 45 | mock_load.assert_called_once_with("data/config.toml") 46 | 47 | 48 | @patch("xthulu.resources.exists", return_value=False) 49 | def test_config_fall_back(*_): 50 | """Constructor should use default config if file isn't found.""" 51 | 52 | # act 53 | res = Resources() 54 | 55 | # assert 56 | assert res.config == default_config 57 | 58 | 59 | @patch("xthulu.resources.environ", Mock(get=lambda *_: "test")) 60 | @patch("xthulu.resources.load") 61 | def test_config_file_from_env_used(mock_load: Mock): 62 | """Constructor should use the filename from environ if available.""" 63 | 64 | # act 65 | Resources() 66 | 67 | # assert 68 | mock_load.assert_called_once_with("test") 69 | 70 | 71 | @pytest.mark.parametrize( 72 | ["name", "cls"], [["cache", Redis], ["db", AsyncEngine]] 73 | ) 74 | @patch("xthulu.resources.load", lambda *_: {}) 75 | def test_property_assignment(name: str, cls: Type): 76 | """Constructor should assign properties to singleton appropriately.""" 77 | 78 | # act 79 | resources = Resources() 80 | 81 | # assert 82 | assert isinstance(getattr(resources, name), cls) 83 | 84 | 85 | @pytest.mark.asyncio 86 | @patch("xthulu.resources.AsyncSession") 87 | @patch("xthulu.resources.Resources") 88 | async def test_db_session(mock_resources: Mock, mock_session: Mock): 89 | """The `db_session` function should wrap the Resources.db engine.""" 90 | 91 | res = Mock() 92 | mock_resources.return_value = res 93 | 94 | # act 95 | async with db_session(): 96 | pass 97 | 98 | # assert 99 | mock_session.assert_called_once_with(res.db) 100 | -------------------------------------------------------------------------------- /requirements/requirements.txt: -------------------------------------------------------------------------------- 1 | # 2 | # This file is autogenerated by pip-compile with Python 3.12 3 | # by the following command: 4 | # 5 | # pip-compile requirements.in 6 | # 7 | aiofiles==25.1.0 8 | # via -r requirements.in 9 | annotated-doc==0.0.4 10 | # via fastapi 11 | annotated-types==0.7.0 12 | # via pydantic 13 | anyio==4.11.0 14 | # via 15 | # sse-starlette 16 | # starlette 17 | # watchfiles 18 | asyncpg==0.30.0 19 | # via -r requirements.in 20 | asyncssh==2.21.1 21 | # via -r requirements.in 22 | bcrypt==5.0.0 23 | # via -r requirements.in 24 | cffi==2.0.0 25 | # via cryptography 26 | click==8.3.0 27 | # via 28 | # -r requirements.in 29 | # uvicorn 30 | cryptography==46.0.3 31 | # via asyncssh 32 | fastapi==0.121.2 33 | # via -r requirements.in 34 | greenlet==3.2.4 35 | # via sqlalchemy 36 | h11==0.16.0 37 | # via uvicorn 38 | hiredis==3.3.0 39 | # via -r requirements.in 40 | httptools==0.7.1 41 | # via uvicorn 42 | idna==3.11 43 | # via anyio 44 | linkify-it-py==2.0.3 45 | # via markdown-it-py 46 | markdown-it-py[linkify]==4.0.0 47 | # via 48 | # mdit-py-plugins 49 | # rich 50 | # textual 51 | mdit-py-plugins==0.5.0 52 | # via textual 53 | mdurl==0.1.2 54 | # via markdown-it-py 55 | platformdirs==4.5.0 56 | # via textual 57 | pycparser==2.23 58 | # via cffi 59 | pydantic==2.12.4 60 | # via 61 | # fastapi 62 | # sqlmodel 63 | pydantic-core==2.41.5 64 | # via pydantic 65 | pygments==2.19.2 66 | # via 67 | # rich 68 | # textual 69 | python-dotenv==1.2.1 70 | # via uvicorn 71 | pyyaml==6.0.3 72 | # via uvicorn 73 | redis==7.0.1 74 | # via -r requirements.in 75 | rich==14.2.0 76 | # via 77 | # -r requirements.in 78 | # textual 79 | sniffio==1.3.1 80 | # via anyio 81 | sqlalchemy[asyncio]==2.0.44 82 | # via 83 | # -r requirements.in 84 | # sqlmodel 85 | sqlmodel==0.0.27 86 | # via -r requirements.in 87 | sse-starlette==3.0.3 88 | # via -r requirements.in 89 | starlette==0.49.3 90 | # via fastapi 91 | textual==6.6.0 92 | # via -r requirements.in 93 | toml==0.10.2 94 | # via -r requirements.in 95 | typing-extensions==4.15.0 96 | # via 97 | # anyio 98 | # asyncssh 99 | # fastapi 100 | # pydantic 101 | # pydantic-core 102 | # sqlalchemy 103 | # starlette 104 | # textual 105 | # typing-inspection 106 | typing-inspection==0.4.2 107 | # via pydantic 108 | uc-micro-py==1.0.3 109 | # via linkify-it-py 110 | uvicorn[standard]==0.38.0 111 | # via -r requirements.in 112 | uvloop==0.22.1 113 | # via uvicorn 114 | watchfiles==1.1.1 115 | # via uvicorn 116 | websockets==15.0.1 117 | # via uvicorn 118 | wrapt==2.0.1 119 | # via -r requirements.in 120 | -------------------------------------------------------------------------------- /userland/scripts/messages/view_screen.py: -------------------------------------------------------------------------------- 1 | """Message viewer screen""" 2 | 3 | # stdlib 4 | from typing import Sequence 5 | 6 | # 3rd party 7 | from textual.app import ComposeResult 8 | from textual.binding import Binding 9 | from textual.containers import Horizontal, Vertical 10 | from textual.screen import Screen 11 | from textual.widgets import Footer, Label, MarkdownViewer 12 | 13 | # local 14 | from userland.models import Message 15 | 16 | 17 | class ViewScreen(Screen): 18 | """Message viewer screen""" 19 | 20 | BINDINGS = [ 21 | ("escape", "app.pop_screen", "Exit"), 22 | Binding("f", "", show=False), 23 | ] 24 | 25 | CSS = """ 26 | Horizontal Label { 27 | width: 50%; 28 | } 29 | 30 | Label { 31 | margin: 0; 32 | } 33 | 34 | MarkdownViewer { 35 | padding-top: 1; 36 | } 37 | 38 | #header { 39 | background: #007 100%; 40 | color: #fff; 41 | height: 5; 42 | padding-left: 1; 43 | padding-right: 1; 44 | padding-top: 1; 45 | } 46 | 47 | #title { 48 | width: auto; 49 | margin-bottom: 1; 50 | } 51 | """ 52 | 53 | message: Message 54 | tags: Sequence[str] 55 | 56 | def __init__(self, *args, message: Message, tags: Sequence[str], **kwargs): 57 | super(ViewScreen, self).__init__(*args, **kwargs) 58 | self.message = message 59 | self.tags = tags 60 | 61 | def compose(self) -> ComposeResult: 62 | assert self.message.author 63 | 64 | with Vertical(): 65 | with Vertical(id="header"): 66 | with Horizontal(): 67 | yield Label( 68 | "[bold underline ansi_cyan]Author:[/] " 69 | f"{self.message.author.name}" 70 | ) 71 | yield Label( 72 | "[bold underline ansi_cyan]Posted:[/] " 73 | f"{self.message.created.strftime('%H:%M %a %b %d %Y')}" 74 | ) 75 | 76 | with Horizontal(): 77 | yield Label( 78 | "[bold underline ansi_cyan]Recipient:[/] " 79 | f"{self.message.recipient.name if self.message.recipient else ''}" 80 | ) 81 | yield Label( 82 | f"[bold underline ansi_cyan]Tags:[/] " 83 | f"{', '.join(self.tags)}" 84 | ) 85 | 86 | yield Label( 87 | f"[bold underline ansi_cyan]Title:[/] " 88 | f"{self.message.title}", 89 | id="title", 90 | ) 91 | 92 | yield MarkdownViewer( 93 | markdown=self.message.content, 94 | show_table_of_contents=False, 95 | ) 96 | yield Footer() 97 | -------------------------------------------------------------------------------- /userland/scripts/oneliners.py: -------------------------------------------------------------------------------- 1 | """Oneliners script""" 2 | 3 | # stdlib 4 | from os import path 5 | 6 | # 3rd party 7 | from sqlmodel import col, select 8 | from textual.widgets import Input, Label, ListItem, ListView 9 | 10 | # api 11 | from xthulu.resources import db_session 12 | from xthulu.ssh.console.banner_app import BannerApp 13 | from xthulu.ssh.context import SSHContext 14 | 15 | # local 16 | from userland.models import Oneliner 17 | 18 | LIMIT = 200 19 | """Total number of oneliners to load""" 20 | 21 | 22 | class OnelinersApp(BannerApp): 23 | """Oneliners Textual app""" 24 | 25 | AUTO_FOCUS = "Input" 26 | BANNER_PADDING = 15 27 | CSS_PATH = path.join(path.dirname(__file__), "oneliners.tcss") 28 | 29 | def __init__(self, context: SSHContext, **kwargs): 30 | "" # empty docstring 31 | super(OnelinersApp, self).__init__(context, **kwargs) 32 | self.bind("escape", "quit") 33 | 34 | def compose(self): 35 | for widget in super(OnelinersApp, self).compose(): 36 | yield widget 37 | 38 | # oneliners 39 | lv = ListView() 40 | lv.styles.scrollbar_background = "black" 41 | lv.styles.scrollbar_color = "ansi_yellow" 42 | lv.styles.scrollbar_color_active = "white" 43 | lv.styles.scrollbar_color_hover = "ansi_bright_yellow" 44 | yield lv 45 | 46 | # input 47 | yield Input( 48 | max_length=Oneliner.MAX_LENGTH, 49 | placeholder="Enter a oneliner or press ESC", 50 | ) 51 | 52 | async def on_input_submitted(self, event: Input.Submitted) -> None: 53 | val = event.input.value.strip() 54 | 55 | if val != "": 56 | async with db_session() as db: 57 | db.add(Oneliner(message=val, user_id=self.context.user.id)) 58 | await db.commit() 59 | 60 | self.exit() 61 | 62 | async def on_mount(self) -> None: 63 | recent = ( 64 | select(Oneliner.id) 65 | .order_by(col(Oneliner.id).desc()) 66 | .limit(LIMIT) 67 | .alias("recent") 68 | .select() 69 | ) 70 | 71 | async with db_session() as db: 72 | oneliners = ( 73 | await db.exec( 74 | select(Oneliner).where(col(Oneliner.id).in_(recent)) 75 | ) 76 | ).all() 77 | 78 | lv = self.query_one(ListView) 79 | 80 | for idx, o in enumerate(oneliners): 81 | lv.mount( 82 | ListItem(Label(o.message), classes="even" if idx % 2 else "") 83 | ) 84 | 85 | lv.index = len(oneliners) - 1 86 | lv.scroll_end(animate=False) 87 | 88 | 89 | async def main(cx: SSHContext) -> None: 90 | cx.console.set_window_title("oneliners") 91 | await OnelinersApp( 92 | cx, 93 | art_path=path.join("userland", "artwork", "oneliners.ans"), 94 | art_encoding="amiga", 95 | alt="79 Columns // Oneliners", 96 | ).run_async() 97 | -------------------------------------------------------------------------------- /xthulu/cli/db.py: -------------------------------------------------------------------------------- 1 | """Database CLI""" 2 | 3 | # 3rd party 4 | from click import confirm, echo, group, option 5 | from sqlmodel import SQLModel 6 | 7 | # local 8 | from ._util import loop 9 | from ..resources import db_session, Resources 10 | 11 | _loop = loop() 12 | 13 | 14 | @group("db") 15 | def cli(): 16 | """Database commands""" 17 | 18 | 19 | @cli.command() 20 | @option( 21 | "-s", 22 | "--seed", 23 | "seed_data", 24 | default=False, 25 | flag_value=True, 26 | help="Seed the database with default data.", 27 | ) 28 | def create(seed_data=False): 29 | """Create database tables.""" 30 | 31 | from .. import models # noqa: F401 32 | 33 | async def f(): 34 | echo("Creating database and tables") 35 | 36 | async with Resources().db.begin() as conn: 37 | await conn.run_sync(SQLModel.metadata.create_all) 38 | 39 | _loop.run_until_complete(f()) 40 | 41 | if seed_data: 42 | _seed() 43 | 44 | 45 | @cli.command() 46 | @option( 47 | "--yes", 48 | "confirmed", 49 | default=False, 50 | flag_value=True, 51 | help="Skip confirmation.", 52 | ) 53 | def destroy(confirmed=False): 54 | """Drop database tables.""" 55 | 56 | async def f(): 57 | from .. import models # noqa: F401 58 | 59 | try: 60 | from userland import models as user_models # noqa: F401 61 | except ImportError: # pragma: no cover 62 | pass 63 | 64 | echo("Dropping database tables") 65 | 66 | async with Resources().db.begin() as conn: 67 | await conn.run_sync(SQLModel.metadata.drop_all) 68 | 69 | if confirmed or confirm( 70 | "Are you sure you want to destroy the database tables?" 71 | ): 72 | _loop.run_until_complete(f()) 73 | 74 | 75 | def _seed(): 76 | from ..models import User 77 | 78 | async def f(): 79 | echo("Creating guest user") 80 | pwd, salt = User.hash_password("guest") 81 | 82 | async with db_session() as db: 83 | db.add( 84 | User( 85 | name="guest", 86 | email="guest@localhost.localdomain", 87 | password=pwd, 88 | salt=salt, 89 | ) 90 | ) 91 | await db.commit() 92 | 93 | echo("Creating user with password") 94 | pwd, salt = User.hash_password("user") 95 | 96 | async with db_session() as db: 97 | db.add( 98 | User( 99 | name="user", 100 | email="user@localhost.localdomain", 101 | password=pwd, 102 | salt=salt, 103 | ) 104 | ) 105 | await db.commit() 106 | 107 | _loop.run_until_complete(f()) 108 | 109 | 110 | @cli.command() 111 | def seed(): 112 | """Initialize database with starter data.""" 113 | 114 | _seed() 115 | -------------------------------------------------------------------------------- /userland/scripts/top.py: -------------------------------------------------------------------------------- 1 | """Userland entry point""" 2 | 3 | # stdlib 4 | from importlib.metadata import version 5 | from os import path 6 | 7 | # 3rd party 8 | from rich.progress import track 9 | 10 | # api 11 | from xthulu.ssh.console.art import scroll_art 12 | from xthulu.ssh.console.choice import choice 13 | from xthulu.ssh.console.internal.file_wrapper import FileWrapper 14 | from xthulu.ssh.context import SSHContext 15 | 16 | 17 | async def main(cx: SSHContext) -> None: 18 | if cx.encoding == "utf-8": 19 | cx.echo("\x1b%G") 20 | elif cx.env["TERM"] != "ansi": 21 | cx.echo("\x1b%@\x1b(U") 22 | 23 | if cx.encoding != "utf-8": 24 | cx.echo( 25 | "[red]ERROR:[/] Unfortunately, only [bright_white]utf-8[/] " 26 | "encoding is currently supported. Encoding will be forced if " 27 | "you proceed.\n" 28 | ) 29 | 30 | if not await cx.inkey("Press any key to continue", timeout=30): 31 | return 32 | 33 | cx.console._encoding = "utf-8" 34 | f: FileWrapper = cx.console._file # type: ignore 35 | f._encoding = "utf-8" 36 | cx.encoding = "utf-8" 37 | 38 | cx.console.set_window_title(f"{cx.username}@79columns") 39 | await scroll_art(cx, path.join("userland", "artwork", "login.ans"), "cp437") 40 | await cx.inkey("Press any key to continue", "dots8Bit", timeout=5) 41 | 42 | # new user application 43 | if cx.username == "guest": 44 | result = await cx.gosub("nua") 45 | 46 | if result == "create": 47 | cx.echo("Yeah, only that's not ready yet.\n\n") 48 | return 49 | 50 | if not result or result == "logoff": 51 | return 52 | 53 | if await choice(cx, "Skip to main menu? ", ("No", "Yes")) == "Yes": 54 | cx.console.clear() 55 | cx.goto("main") 56 | return 57 | 58 | cx.echo("\n") 59 | cx.console.set_window_title("system information") 60 | await scroll_art( 61 | cx, path.join("userland", "artwork", "sysinfo.ans"), "amiga" 62 | ) 63 | cx.echo( 64 | ":skull: [bold bright_green]x[/][green]thulu[/] ", 65 | f"terminal server [italic]v{version('xthulu')}[/]\n", 66 | "[bright_black]https://github.com/haliphax/xthulu[/]\n\n", 67 | ) 68 | # alpine only? 69 | await cx.redirect(["/bin/ash", "-c", "uname -a; echo -e '\\r'; sleep 0.1"]) 70 | await cx.inkey("Press any key to continue", "arc") 71 | 72 | cx.console.set_window_title("logging in...") 73 | bar_text = "".join( 74 | [ 75 | "[bright_white]Connecting:[/] ", 76 | f"[bright_cyan underline]{cx.user.name}[/]", 77 | f"@[cyan]{cx.ip}[/]", 78 | ] 79 | ) 80 | 81 | waiting = True 82 | 83 | for _ in track( 84 | sequence=range(20), description=bar_text, console=cx.console 85 | ): 86 | if waiting and await cx.inkey(timeout=0.1): 87 | waiting = False 88 | 89 | await cx.inkey(timeout=0.1) # show bar at 100% before switching screens 90 | await cx.gosub("oneliners") 91 | cx.console.clear() 92 | cx.goto("main") 93 | -------------------------------------------------------------------------------- /data/config.schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-04/schema", 3 | "title": "xthulu configuration", 4 | "description": "xthulu community server configuration schema", 5 | "type": "object", 6 | "properties": { 7 | "cache": { 8 | "type": "object", 9 | "properties": { 10 | "db": { 11 | "type": "integer", 12 | "minimum": 0, 13 | "default": 0 14 | }, 15 | "host": { 16 | "type": "string", 17 | "format": "hostname" 18 | }, 19 | "port": { 20 | "type": "integer", 21 | "minimum": 1, 22 | "default": 6379 23 | } 24 | } 25 | }, 26 | "db": { 27 | "type": "object", 28 | "properties": { 29 | "bind": { 30 | "type": "string", 31 | "minLength": 1 32 | } 33 | } 34 | }, 35 | "debug": { 36 | "type": "object", 37 | "properties": { 38 | "enabled": { 39 | "type": "boolean", 40 | "default": false 41 | }, 42 | "term": { 43 | "type": "boolean", 44 | "default": false 45 | } 46 | } 47 | }, 48 | "ssh": { 49 | "type": "object", 50 | "properties": { 51 | "host": { 52 | "type": "string", 53 | "format": "hostname" 54 | }, 55 | "host_keys": { 56 | "type": "array", 57 | "items": { 58 | "type": "string", 59 | "minLength": 1 60 | }, 61 | "minItems": 1 62 | }, 63 | "port": { 64 | "type": "integer", 65 | "minimum": 1 66 | }, 67 | "proxy_protocol": { 68 | "type": "boolean", 69 | "default": true 70 | }, 71 | "auth": { 72 | "type": "object", 73 | "properties": { 74 | "bad_usernames": { 75 | "type": "array", 76 | "items": { 77 | "type": "string" 78 | } 79 | }, 80 | "no_password": { 81 | "type": "array", 82 | "items": { 83 | "type": "string" 84 | } 85 | } 86 | } 87 | }, 88 | "session": { 89 | "type": "object", 90 | "properties": { 91 | "timeout": { 92 | "type": "integer", 93 | "minimum": 0 94 | } 95 | } 96 | }, 97 | "userland": { 98 | "type": "object", 99 | "properties": { 100 | "paths": { 101 | "type": "array", 102 | "items": { 103 | "type": "string" 104 | } 105 | }, 106 | "top": { 107 | "type": "array", 108 | "items": { 109 | "type": "string" 110 | } 111 | } 112 | } 113 | } 114 | } 115 | }, 116 | "web": { 117 | "type": "object", 118 | "properties": { 119 | "host": { 120 | "type": "string", 121 | "format": "hostname" 122 | }, 123 | "port": { 124 | "type": "integer", 125 | "minimum": 1 126 | }, 127 | "proxy": { 128 | "type": "boolean", 129 | "default": true 130 | }, 131 | "userland": { 132 | "type": "object", 133 | "properties": { 134 | "modules": { 135 | "type": "array", 136 | "items": { 137 | "type": "string" 138 | } 139 | } 140 | } 141 | } 142 | } 143 | } 144 | } 145 | } 146 | -------------------------------------------------------------------------------- /userland/scripts/messages/filter_modal.py: -------------------------------------------------------------------------------- 1 | """Filter messages screen""" 2 | 3 | # stdlib 4 | from typing import Sequence 5 | 6 | # 3rd party 7 | from sqlmodel import and_, col, select 8 | from textual.containers import Horizontal, Vertical 9 | from textual.screen import ModalScreen 10 | from textual.widgets import Button, SelectionList 11 | from textual.widgets.selection_list import Selection 12 | 13 | # api 14 | from xthulu.resources import db_session 15 | 16 | # local 17 | from userland.models.message.tag import MessageTag 18 | 19 | 20 | class FilterModal(ModalScreen[list[str]]): 21 | """Filter messages screen""" 22 | 23 | BINDINGS = [("escape", "app.pop_screen", "")] 24 | 25 | CSS = """ 26 | FilterModal { 27 | align: center middle; 28 | background: rgba(0, 0, 0, 0.5); 29 | } 30 | 31 | Button { 32 | margin: 1; 33 | width: 33.3333%; 34 | } 35 | 36 | SelectionList { 37 | height: 10; 38 | } 39 | 40 | #filter { 41 | margin-left: 0; 42 | margin-top: 1; 43 | } 44 | 45 | #wrapper { 46 | background: $primary-background; 47 | height: 16; 48 | padding: 1; 49 | width: 60; 50 | } 51 | """ 52 | 53 | _tags: list[str] 54 | 55 | def __init__(self, *args, tags: list[str] | None = None, **kwargs): 56 | super(FilterModal, self).__init__(*args, **kwargs) 57 | self._tags = tags or [] 58 | 59 | def compose(self): 60 | with Vertical(id="wrapper"): 61 | yield SelectionList(id="tags") 62 | 63 | with Horizontal(): 64 | yield Button("Filter", variant="success", id="filter") 65 | yield Button("Reset", id="reset") 66 | yield Button("Cancel", variant="error", id="cancel") 67 | 68 | def _submit(self) -> None: 69 | tags = self.query_one(SelectionList) 70 | assert tags 71 | self.dismiss(tags.selected) 72 | 73 | async def on_mount(self) -> None: 74 | tags = self.query_one(SelectionList) 75 | assert tags 76 | tags.add_options([Selection(t, t, True) for t in self._tags]) 77 | 78 | async with db_session() as db: 79 | all_tags: Sequence[str] = ( # type: ignore 80 | await db.exec( 81 | select(MessageTag.name).where( 82 | and_( 83 | col(MessageTag.name).is_not(None), 84 | col(MessageTag.name).not_in(self._tags), 85 | ) 86 | ) 87 | ) 88 | ).all() 89 | 90 | tags.add_options([Selection(t, t, False) for t in all_tags]) 91 | 92 | def on_button_pressed(self, event: Button.Pressed) -> None: 93 | if event.button.id == "cancel": 94 | self.app.pop_screen() # pop this modal 95 | return 96 | 97 | if event.button.id == "reset": 98 | self.dismiss([]) 99 | return 100 | 101 | self._submit() 102 | -------------------------------------------------------------------------------- /userland/artwork/messages.ans: -------------------------------------------------------------------------------- 1 |  ________ ______   ______ 2 |   ___//_____/_  _____\|____    /// /  //// 3 |   _\\__. /__ :: \\  ___  \__ ___\\ /_____ ___\\ /_____ :: 4 |  /// \ /|:: _ \ / \ <__|:: \  \\ \\___ /__ \\ \\___ /__ 5 |  \/ |:: |. \ /_________/__/ \ \ |:  /. / \ \ |:  /. 6 |   \_____/  |:: |:: \/\_//\ |::  |/\ |::  | 7 | _________  \_____ ______ :: <______ __l_______|____ ______ _____| 8 |  ::: gRK \ ___ \ ___> ___//____ _____\|____/// 9 | :::   ____\____| /___ \\  __. \ \\  ___  \__ ___\\ /_____ :: 10 | ::: \\\\ ____.  /__ / \  /|:: \ / \ <__|:: \  \\ \\___ /__ 11 | ::: // \ \ |;:/// |;: __//_________/__/ \ \ |:: /. 12 | ::: /\_l_'  \\___ \_l_' \/\_//\ |::  | 13 | ::: <___________/\ _\_____/ /\_______\/\_______\|:'   | 14 |  :::l_________>/ / \______/l_______| 15 | <___________/ 16 | 17 | -------------------------------------------------------------------------------- /tests/web/test_auth.py: -------------------------------------------------------------------------------- 1 | """Authentication tests""" 2 | 3 | # stdlib 4 | from unittest.mock import AsyncMock, Mock, patch 5 | 6 | # 3rd party 7 | from fastapi import HTTPException, status 8 | from fastapi.security import HTTPBasicCredentials 9 | import pytest 10 | 11 | # local 12 | from xthulu.models.user import User 13 | from xthulu.web.auth import login_user 14 | 15 | 16 | @pytest.fixture(autouse=True) 17 | def mock_depends(): 18 | with patch("xthulu.web.auth.Depends") as p: 19 | yield p 20 | 21 | 22 | @pytest.fixture 23 | def mock_session(): 24 | with patch("xthulu.web.auth.db_session") as p: 25 | yield p.return_value.__aenter__.return_value 26 | 27 | 28 | @pytest.mark.asyncio 29 | @patch("xthulu.web.auth.get_config") 30 | async def test_guest_login(mock_config: Mock, mock_session: Mock): 31 | """Login should succeed with no password for special cases.""" 32 | 33 | # arrange 34 | mock_user = Mock() 35 | mock_user.name = "test" 36 | mock_user.password, mock_user.salt = User.hash_password("test") 37 | mock_config.return_value = ["test"] 38 | mock_session.exec = AsyncMock() 39 | mock_session.exec.return_value.one_or_none = Mock(return_value=mock_user) 40 | 41 | # act 42 | result = await login_user( 43 | HTTPBasicCredentials(username="test", password="") 44 | ) 45 | 46 | # assert 47 | assert result == mock_user 48 | 49 | 50 | @pytest.mark.asyncio 51 | async def test_password_login(mock_session: Mock): 52 | """Login should succeed with a known username and password.""" 53 | 54 | # arrange 55 | mock_user = Mock() 56 | mock_user.password, mock_user.salt = User.hash_password("test") 57 | mock_session.exec = AsyncMock() 58 | mock_session.exec.return_value.one_or_none = Mock(return_value=mock_user) 59 | 60 | # act 61 | result = await login_user( 62 | HTTPBasicCredentials(username="test", password="test") 63 | ) 64 | 65 | # assert 66 | assert result == mock_user 67 | 68 | 69 | @pytest.mark.asyncio 70 | async def test_bad_username(mock_session: Mock): 71 | """Login should fail with an unknown username.""" 72 | 73 | # arrange 74 | mock_session.exec = AsyncMock() 75 | mock_session.exec.return_value.one_or_none = Mock(return_value=None) 76 | 77 | # act 78 | try: 79 | await login_user(HTTPBasicCredentials(username="test", password="test")) 80 | 81 | # assert 82 | assert False 83 | except HTTPException as ex: 84 | assert ex.status_code == status.HTTP_401_UNAUTHORIZED 85 | 86 | 87 | @pytest.mark.asyncio 88 | async def test_bad_password(mock_session: Mock): 89 | """Login should fail with an incorrect password.""" 90 | 91 | # arrange 92 | mock_user = Mock() 93 | mock_user.password, mock_user.salt = User.hash_password("test") 94 | mock_session.exec = AsyncMock() 95 | mock_session.exec.return_value.one_or_none = Mock(return_value=mock_user) 96 | 97 | # act 98 | try: 99 | await login_user(HTTPBasicCredentials(username="test", password="bad")) 100 | 101 | # assert 102 | assert False 103 | except HTTPException as ex: 104 | assert ex.status_code == status.HTTP_401_UNAUTHORIZED 105 | -------------------------------------------------------------------------------- /xthulu/locks.py: -------------------------------------------------------------------------------- 1 | """Shared lock semaphore methods""" 2 | 3 | # stdlib 4 | from contextlib import contextmanager 5 | 6 | # 3rd party 7 | from redis.lock import Lock 8 | 9 | # local 10 | from .logger import log 11 | from .resources import Resources 12 | 13 | cache = Resources().cache 14 | 15 | 16 | class _Locks: 17 | """Internal lock storage mechanism""" 18 | 19 | locks: dict[str, dict[str, Lock]] = {} 20 | """Mapping of lock keys to `redis.lock.Lock` objects""" 21 | 22 | 23 | def get(owner: str, name: str) -> bool: 24 | """ 25 | Acquire and hold lock on behalf of user/system. 26 | 27 | Args: 28 | owner: The sid of the owner. 29 | name: The name of the lock. 30 | 31 | Returns: 32 | Whether or not the lock was granted. 33 | """ 34 | 35 | log.debug(f"{owner} acquiring lock {name}") 36 | lock = cache.lock(f"locks.{name}") 37 | 38 | if not lock.acquire(blocking=False): 39 | log.debug(f"{owner} failed to acquire lock {name}") 40 | 41 | return False 42 | 43 | log.debug(f"{owner} acquired lock {name}") 44 | locks = _Locks.locks[owner] if owner in _Locks.locks else {} 45 | locks[name] = lock 46 | _Locks.locks[owner] = locks 47 | 48 | return True 49 | 50 | 51 | def release(owner: str, name: str) -> bool: 52 | """ 53 | Release a lock owned by user/system. 54 | 55 | Args: 56 | owner: The sid of the owner. 57 | name: The name of the lock. 58 | 59 | Returns: 60 | Whether or not the lock was valid to begin with. 61 | """ 62 | 63 | log.debug(f"{owner} releasing lock {name}") 64 | locks = _Locks.locks[owner] if owner in _Locks.locks else {} 65 | 66 | if not locks: 67 | log.debug(f"{owner} failed to release lock {name}; no locks owned") 68 | 69 | return False 70 | 71 | if name not in locks: 72 | log.debug(f"{owner} failed to release lock {name}; not owned") 73 | 74 | return False 75 | 76 | lock = locks[name] 77 | 78 | if not lock.locked(): 79 | log.debug(f"{owner} failed to release lock {name}; not locked") 80 | 81 | return False 82 | 83 | lock.release() 84 | log.debug(f"{owner} released lock {name}") 85 | del locks[name] 86 | 87 | if not locks: 88 | del _Locks.locks[owner] 89 | else: 90 | _Locks.locks[owner] = locks 91 | 92 | return True 93 | 94 | 95 | @contextmanager 96 | def hold(owner: str, name: str): 97 | """ 98 | Session-agnostic lock context manager. 99 | 100 | Args: 101 | owner: The sid of the owner. 102 | name: The name of the lock. 103 | 104 | Returns: 105 | Whether or not the lock was granted. 106 | """ 107 | 108 | try: 109 | yield get(owner, name) 110 | finally: 111 | release(owner, name) 112 | 113 | 114 | def expire(owner: str) -> bool: 115 | """ 116 | Remove all locks owned by user for this connection. 117 | 118 | Args: 119 | owner: The sid of the owner. 120 | 121 | Returns: 122 | If there were any locks to expire. 123 | """ 124 | 125 | log.debug(f"Releasing locks owned by {owner}") 126 | locks = _Locks.locks[owner] if owner in _Locks.locks else {} 127 | 128 | if not locks: 129 | log.debug(f"No locks owned by {owner}") 130 | 131 | return False 132 | 133 | for lock in locks.copy(): 134 | release(owner, lock) 135 | 136 | return True 137 | -------------------------------------------------------------------------------- /xthulu/ssh/console/art.py: -------------------------------------------------------------------------------- 1 | """Artwork display""" 2 | 3 | # stdlib 4 | from re import Match, sub 5 | 6 | # 3rd party 7 | import aiofiles as aiof 8 | from rich.text import Text 9 | 10 | # local 11 | from ..context import SSHContext 12 | 13 | FIND_BOLD_REGEX = r"(\x1b\[(?:\d+;)?)30m" 14 | FIND_CUF_REGEX = r"\x1b\[(\d+)?C" 15 | 16 | 17 | def _replace_bold(match: Match[str]) -> str: 18 | return f"{match.group(1)}90m" 19 | 20 | 21 | def _replace_cuf(match: Match[str]) -> str: 22 | how_many = int(match.group(1) or 1) 23 | 24 | if how_many < 1: 25 | how_many = 1 26 | 27 | return " " * how_many 28 | 29 | 30 | def normalize_ansi(text: str) -> str: 31 | """ 32 | Replace CUF sequences with spaces. 33 | 34 | Args: 35 | text: The text to modify. 36 | 37 | Returns: 38 | Text with CUF sequences replaced by whitespace. 39 | """ 40 | 41 | return sub(FIND_CUF_REGEX, _replace_cuf, text) 42 | 43 | 44 | async def load_art(path: str, encoding="cp437") -> list[str]: 45 | """ 46 | Load normalized, properly-encoded artwork files. 47 | 48 | Args: 49 | path: The path of the file to load. 50 | encoding: The encoding of the file to load. 51 | 52 | Returns: 53 | A list of normalized lines from the target file. 54 | """ 55 | 56 | async with aiof.open(path, encoding=encoding) as f: 57 | artwork = [normalize_ansi(line) for line in await f.readlines()] 58 | 59 | return artwork 60 | 61 | 62 | async def scroll_art( 63 | context: SSHContext, 64 | path: str, 65 | encoding="cp437", 66 | delay=0.1, 67 | bold_as_bright=True, 68 | ) -> bytes | None: 69 | """ 70 | Display ANSI artwork directly to the console. 71 | 72 | Args: 73 | context: The current `xthulu.ssh.context.SSHContext`. 74 | path: The path of the file to display. 75 | encoding: The encoding of the file to display. 76 | delay: The delay (in seconds) between displaying each line. 77 | bold_as_bright: Render "bold" as "bright" (e.g. for classic DOS ANSI). 78 | 79 | Returns: 80 | The byte sequence of a key pressed during display, if any. 81 | """ 82 | 83 | if context.encoding != "utf-8": 84 | encoding = context.encoding 85 | 86 | artwork = await load_art(path, encoding) 87 | 88 | # show entire piece immediately if shorter than terminal 89 | if context.console.height >= len(artwork): 90 | delay = 0.0 91 | 92 | for line in artwork: 93 | if bold_as_bright: 94 | line = sub(FIND_BOLD_REGEX, _replace_bold, line) 95 | 96 | processed = Text.from_ansi(line, overflow="crop", no_wrap=True, end="") 97 | context.console.print( 98 | processed, 99 | emoji=False, 100 | end="\n", 101 | height=1, 102 | highlight=False, 103 | markup=False, 104 | overflow="ignore", 105 | no_wrap=True, 106 | ) 107 | 108 | if delay <= 0: 109 | continue 110 | 111 | key = await context.inkey(timeout=delay) 112 | 113 | if key: 114 | return key 115 | 116 | return None 117 | 118 | 119 | async def show_art(context: SSHContext, path: str, encoding="cp437") -> None: 120 | """ 121 | Display ANSI artwork directly to the console without scrolling. 122 | 123 | Args: 124 | context: The current `xthulu.ssh.context.SSHContext`. 125 | path: The path of the file to display. 126 | encoding: The encoding of the file to display. 127 | """ 128 | 129 | await scroll_art(context, path, encoding, 0.0) 130 | -------------------------------------------------------------------------------- /tests/cli/test_db.py: -------------------------------------------------------------------------------- 1 | """Database CLI tests""" 2 | 3 | # stdlib 4 | from unittest.mock import AsyncMock, Mock, patch 5 | 6 | # 3rd party 7 | from click.testing import CliRunner 8 | import pytest 9 | from sqlmodel import SQLModel 10 | 11 | # local 12 | from xthulu.cli import cli 13 | from xthulu.cli.db import cli as db_cli 14 | 15 | 16 | @pytest.fixture 17 | def mock_conn(): 18 | with patch("xthulu.cli.db.Resources") as p: 19 | conn = AsyncMock() 20 | p.return_value.db.begin.return_value.__aenter__.return_value = conn 21 | yield conn 22 | 23 | 24 | @pytest.fixture 25 | def mock_session(): 26 | with patch("xthulu.cli.db.db_session") as p: 27 | yield p.return_value.__aenter__.return_value 28 | 29 | 30 | def test_cli_includes_group(): 31 | """The CLI module should include the 'db' command group.""" 32 | 33 | # act 34 | command = cli.get_command(Mock(), "db") 35 | 36 | # assert 37 | assert command is not None 38 | 39 | 40 | @pytest.mark.parametrize("command_name", ["create", "destroy", "seed"]) 41 | def test_cli_includes_commands(command_name: str): 42 | """The command group should include the specified command.""" 43 | 44 | # act 45 | commands = db_cli.list_commands(Mock()) 46 | 47 | # assert 48 | assert command_name in commands 49 | 50 | 51 | @pytest.mark.parametrize("seed", [False, True]) 52 | def test_create(seed: bool, mock_conn: Mock, mock_session: Mock): 53 | """The 'db create' command should create all model tables.""" 54 | 55 | # arrange 56 | from xthulu import models # noqa: F401 57 | 58 | args = ["db", "create"] 59 | 60 | if seed: 61 | args.append("-s") 62 | 63 | # act 64 | CliRunner().invoke(cli, args, catch_exceptions=False) 65 | 66 | # assert 67 | mock_conn.run_sync.assert_awaited_once_with(SQLModel.metadata.create_all) 68 | 69 | if seed: 70 | mock_session.commit.assert_awaited() 71 | 72 | 73 | @pytest.mark.parametrize("is_confirmed", [False, True]) 74 | @patch("xthulu.cli.db.confirm") 75 | def test_destroy(mock_confirm: Mock, is_confirmed: bool, mock_conn: Mock): 76 | """The 'db destroy' command should destroy all model tables.""" 77 | 78 | # arrange 79 | from xthulu import models # noqa: F401 80 | 81 | try: 82 | from userland import models as user_models # noqa: F401 83 | except ImportError: 84 | pass 85 | 86 | mock_confirm.return_value = is_confirmed 87 | 88 | # act 89 | CliRunner().invoke(cli, ["db", "destroy"], catch_exceptions=False) 90 | 91 | # assert 92 | if is_confirmed: 93 | mock_conn.run_sync.assert_awaited_once_with(SQLModel.metadata.drop_all) 94 | else: 95 | assert mock_conn.run_sync.call_count == 0 96 | 97 | 98 | @patch("xthulu.cli.db.confirm") 99 | def test_destroy_force(mock_confirm: Mock, mock_conn: Mock): 100 | """The 'db destroy --yes' command should not ask for confirmation.""" 101 | 102 | # arrange 103 | from xthulu import models # noqa: F401 104 | 105 | try: 106 | from userland import models as user_models # noqa: F401 107 | except ImportError: 108 | pass 109 | 110 | # act 111 | CliRunner().invoke(cli, ["db", "destroy", "--yes"], catch_exceptions=False) 112 | 113 | # assert 114 | mock_conn.run_sync.assert_awaited_once_with(SQLModel.metadata.drop_all) 115 | mock_confirm.assert_not_called() 116 | 117 | 118 | def test_seed(mock_session: Mock): 119 | """The 'db seed' command should import example records.""" 120 | 121 | # act 122 | CliRunner().invoke(cli, ["db", "seed"], catch_exceptions=False) 123 | 124 | # assert 125 | mock_session.commit.assert_awaited() 126 | -------------------------------------------------------------------------------- /userland/models/message/api.py: -------------------------------------------------------------------------------- 1 | """Shared userland messages API""" 2 | 3 | # stdlib 4 | from typing import Sequence, Tuple 5 | 6 | # 3rd party 7 | from sqlmodel import col, select 8 | 9 | # api 10 | from xthulu.models import User 11 | from xthulu.resources import db_session 12 | 13 | # local 14 | from . import Message 15 | from .message_tags import MessageTags 16 | 17 | 18 | def get_messages_query(tags: list[str] | None = None): 19 | """ 20 | Query for pulling messages, optionally filtered by tag(s). 21 | 22 | Args: 23 | tags: A list of tags to filter by (if any) 24 | 25 | Returns: 26 | A query object 27 | """ 28 | 29 | query = ( 30 | select( 31 | Message.id, 32 | Message.title, 33 | User.name, 34 | ) 35 | .select_from(Message) 36 | .join(MessageTags) 37 | .where( 38 | MessageTags.message_id == Message.id, 39 | Message.author_id == User.id, 40 | ) 41 | ) 42 | 43 | if tags: 44 | query = query.where(col(MessageTags.tag_name).in_(tags)) 45 | 46 | return query.group_by( 47 | col(Message.id), 48 | col(Message.title), 49 | col(User.name), 50 | ) 51 | 52 | 53 | async def get_latest_messages( 54 | tags: list[str] | None = None, limit=100 55 | ) -> Sequence[Tuple[int, str, str]]: 56 | """ 57 | Get the latest messages (in descending order). 58 | 59 | Args: 60 | tags: A list of tags to filter by, if any 61 | limit: The number of messages to return 62 | 63 | Returns: 64 | A list of (id, title, author) matching the provided criteria 65 | """ 66 | 67 | async with db_session() as db: 68 | return ( 69 | await db.exec( 70 | get_messages_query(tags) 71 | .order_by(col(Message.id).desc()) 72 | .limit(limit) 73 | ) 74 | ).all() # type: ignore 75 | 76 | 77 | async def get_newer_messages( 78 | id: int, tags: list[str] | None = None, limit=100 79 | ) -> Sequence[Tuple[int, str, str]]: 80 | """ 81 | Get messages newer than the provided ID (in ascending order). 82 | 83 | Args: 84 | id: The message ID used as an exclusive lower bound 85 | tags: A list of tags to filter by, if any 86 | limit: The number of messages to return 87 | 88 | Returns: 89 | A list of (id, title, author) matching the provided criteria 90 | """ 91 | 92 | async with db_session() as db: 93 | return ( 94 | await db.exec( 95 | get_messages_query(tags) 96 | .where(Message.id > id) # type: ignore 97 | .order_by(col(Message.id).asc()) 98 | .limit(limit) 99 | ) 100 | ).all() # type: ignore 101 | 102 | 103 | async def get_older_messages( 104 | id: int, tags: list[str] | None = None, limit=100 105 | ) -> Sequence[Tuple[int, str, str]]: 106 | """ 107 | Get messages older than the provided ID (in descending order). 108 | 109 | Args: 110 | id: The message ID used as an exclusive upper bound 111 | tags: A list of tags to filter by, if any 112 | limit: The number of messages to return 113 | 114 | Returns: 115 | A list of (id, title, author) matching the provided criteria 116 | """ 117 | 118 | async with db_session() as db: 119 | return ( 120 | await db.exec( 121 | get_messages_query(tags) 122 | .where(Message.id < id) # type: ignore 123 | .order_by(col(Message.id).desc()) 124 | .limit(limit) 125 | ) 126 | ).all() 127 | -------------------------------------------------------------------------------- /.github/workflows/checks.yml: -------------------------------------------------------------------------------- 1 | name: Checks 2 | 3 | on: 4 | pull_request: 5 | branches: [main] 6 | 7 | push: 8 | branches: [main] 9 | 10 | workflow_dispatch: 11 | 12 | concurrency: 13 | cancel-in-progress: true 14 | group: | 15 | ${{ github.workflow }}-${{ github.event.pull_request.id || github.branch }} 16 | 17 | jobs: 18 | changes: 19 | name: Change detection 20 | runs-on: ubuntu-latest 21 | outputs: 22 | misc: ${{ steps.changes.outputs.misc }} 23 | py: ${{ steps.changes.outputs.py }} 24 | ts: ${{ steps.changes.outputs.ts }} 25 | steps: 26 | - name: Check out 27 | uses: actions/checkout@v4 28 | 29 | - name: Detect changed files 30 | id: changes 31 | uses: dorny/paths-filter@v3 32 | with: 33 | filters: | 34 | misc: 35 | - "**.html" 36 | - "**.json" 37 | - "**.md" 38 | - "**.toml" 39 | - "**.yaml" 40 | - "**.yml" 41 | py: 42 | - "**.py" 43 | - "requirements/**" 44 | - pyproject.toml 45 | ts: 46 | - "**.js" 47 | - "**.mjs" 48 | - "**.mts" 49 | - "**.ts" 50 | 51 | # formatting 52 | 53 | ruff-format: 54 | name: Ruff (formatter) 55 | needs: changes 56 | if: needs.changes.outputs.py == 'true' || github.ref == 'refs/heads/main' 57 | runs-on: ubuntu-latest 58 | steps: 59 | - name: Check out 60 | uses: actions/checkout@v4 61 | 62 | - name: Set up Python 63 | uses: actions/setup-python@v5 64 | with: 65 | python-version: 3.12 66 | 67 | - name: Install dependencies 68 | run: | 69 | python -m pip install -U pip setuptools 70 | pip install -e .[dev] 71 | 72 | - name: Ruff 73 | run: ruff format --diff . 74 | 75 | prettier: 76 | name: Prettier (formatter) 77 | needs: changes 78 | if: | 79 | needs.changes.outputs.misc == 'true' || 80 | needs.changes.outputs.ts == 'true' || 81 | github.ref == 'refs/heads/main' 82 | runs-on: ubuntu-latest 83 | steps: 84 | - name: Check out 85 | uses: actions/checkout@v4 86 | 87 | - name: Set up Node 88 | uses: actions/setup-node@v4 89 | with: 90 | node-version-file: .nvmrc 91 | cache: npm 92 | 93 | - name: Install dependencies 94 | run: npm ci 95 | 96 | - name: Prettier 97 | run: npx prettier -lu . 98 | 99 | # linting 100 | 101 | eslint: 102 | name: ESLint (linter) 103 | needs: changes 104 | if: | 105 | needs.changes.outputs.ts == 'true' || github.ref == 'refs/heads/main' 106 | runs-on: ubuntu-latest 107 | steps: 108 | - name: Check out 109 | uses: actions/checkout@v4 110 | 111 | - name: Set up Node 112 | uses: actions/setup-node@v4 113 | with: 114 | node-version-file: .nvmrc 115 | cache: npm 116 | 117 | - name: Install dependencies 118 | run: npm ci 119 | 120 | - name: ESLint 121 | run: npx eslint . 122 | 123 | ruff-lint: 124 | name: Ruff (linter) 125 | needs: changes 126 | if: needs.changes.outputs.py == 'true' || github.ref == 'refs/heads/main' 127 | runs-on: ubuntu-latest 128 | steps: 129 | - name: Check out 130 | uses: actions/checkout@v4 131 | 132 | - name: Set up Python 133 | uses: actions/setup-python@v5 134 | with: 135 | python-version: 3.12 136 | 137 | - name: Install dependencies 138 | run: | 139 | python -m pip install -U pip setuptools 140 | pip install -e .[dev] 141 | 142 | - name: Ruff 143 | run: ruff check --diff . 144 | -------------------------------------------------------------------------------- /xthulu/events/__init__.py: -------------------------------------------------------------------------------- 1 | """Event queue mechanism""" 2 | 3 | # stdlib 4 | from collections import OrderedDict 5 | from uuid import uuid4 6 | 7 | # local 8 | from .structs import EventData 9 | 10 | 11 | class EventQueue: 12 | """ 13 | Event queue which uses two underlying storage mechanisms for efficiency. 14 | `xthulu.events.structs.EventData` objects are stored in an `OrderedDict` 15 | with generated UUIDs as keys. These UUIDs are referred to in a 16 | chronologically-ordered list stored in a dict where the keys are the event 17 | names. This makes it possible to pull all events by using the list or to 18 | pull only events with a certain name by using the OrderedDict (without 19 | iterating through unrelated events). 20 | """ 21 | 22 | def __init__(self, sid: str): 23 | self._dict: dict[str, list[str]] = {} 24 | self._list: OrderedDict[str, EventData] = OrderedDict() 25 | EventQueues.q[sid] = self 26 | 27 | def add(self, event: EventData): 28 | """ 29 | Add an event to the queue. 30 | 31 | Args: 32 | event: The event to add. 33 | """ 34 | 35 | key = str(uuid4()) 36 | self._list[key] = event 37 | evlist = ( 38 | self._dict[event.name] if event.name in self._dict.keys() else [] 39 | ) 40 | evlist.append(key) 41 | self._dict[event.name] = evlist 42 | 43 | def get(self, name: str | None = None, flush: bool = True): 44 | """ 45 | Get an event or all events from the queue. If `name` is provided, only 46 | events with that name will be returned. By default, events matching the 47 | criteria will be flushed afterward. To preserve them, set `flush` to 48 | `False`. 49 | 50 | Args: 51 | name: The event name to query. If not provided, queries all events. 52 | flush: Whether to delete events from the queue. 53 | 54 | Returns: 55 | A list of events matching the criteria (or an empty list). 56 | """ 57 | 58 | events = ( 59 | list(self._list.values()) 60 | if name is None 61 | else [self._list[key] for key in self._dict.get(name, [])] 62 | ) 63 | 64 | if flush: 65 | self.flush(name) 66 | 67 | return events 68 | 69 | def flush(self, name: str | None = None): 70 | """ 71 | Flush the event queue. If a name is provided, only events with that name 72 | will be flushed. 73 | 74 | Args: 75 | name: The event name to flush from the queue. `None` flushes all. 76 | """ 77 | 78 | if name is None: 79 | self._list.clear() 80 | self._dict.clear() 81 | 82 | return 83 | 84 | keys = self._dict.get(name) 85 | 86 | if not keys: 87 | return 88 | 89 | for key in keys: 90 | del self._list[key] 91 | 92 | self._dict[name].clear() 93 | 94 | 95 | class EventQueues: 96 | """Underlying event queue storage""" 97 | 98 | q: dict[str, EventQueue] = {} 99 | """Queue storage, mapped by session ID (sid)""" 100 | 101 | 102 | async def put_global(event: EventData, exclude: set[str] | None = None): 103 | """ 104 | Put an event in every connected session's event queue. 105 | 106 | Args: 107 | event: The event to replicate. 108 | exclude: A list of sids to exclude from receiving the event. 109 | 110 | Returns: 111 | The number of event queues which were populated with the event. 112 | """ 113 | 114 | count = 0 115 | 116 | for k, q in EventQueues.q.items(): 117 | if exclude and k in exclude: 118 | continue 119 | 120 | q.add(event) 121 | count += 1 122 | 123 | return count 124 | -------------------------------------------------------------------------------- /docker/docker-compose.yml: -------------------------------------------------------------------------------- 1 | # configuration common to all services using the xthulu image 2 | x-xthulu-common: &xthulu-common 3 | build: 4 | pull: false 5 | environment: 6 | COLORTERM: truecolor 7 | TERM: xterm-256color 8 | image: xthulu 9 | tty: true 10 | volumes: 11 | - ../data:/app/data:ro 12 | - ../run:/app/run 13 | - ../userland:/app/userland:ro 14 | 15 | networks: 16 | internal: 17 | proxy: 18 | 19 | services: 20 | # for docker image build dependency 21 | base-image: 22 | build: 23 | context: .. 24 | dockerfile: ./docker/Dockerfile 25 | entrypoint: /bin/true 26 | image: xthulu 27 | networks: 28 | - internal 29 | restart: "no" 30 | scale: 0 31 | 32 | # for command line utils 33 | cli: 34 | entrypoint: /usr/local/bin/python3 -m xthulu 35 | depends_on: 36 | - base-image 37 | - cache 38 | - db 39 | image: xthulu 40 | networks: 41 | - internal 42 | restart: "no" 43 | scale: 0 44 | <<: *xthulu-common 45 | 46 | # for userland command line utils 47 | user: 48 | entrypoint: /usr/local/bin/python3 -m userland 49 | depends_on: 50 | - base-image 51 | - cache 52 | - db 53 | image: xthulu 54 | networks: 55 | - internal 56 | restart: "no" 57 | scale: 0 58 | <<: *xthulu-common 59 | 60 | #--- 61 | 62 | cache: 63 | image: redis:alpine 64 | networks: 65 | - internal 66 | restart: unless-stopped 67 | 68 | db: 69 | environment: 70 | POSTGRES_USER: xthulu 71 | POSTGRES_PASSWORD: xthulu 72 | image: postgres:13-alpine 73 | networks: 74 | - internal 75 | restart: unless-stopped 76 | volumes: 77 | - db-data:/var/lib/postgresql/data 78 | 79 | proxy: 80 | build: 81 | context: ./traefik 82 | dockerfile: Dockerfile 83 | image: xthulu:traefik 84 | networks: 85 | - proxy 86 | ports: 87 | - "22:22" # need quotes or else YAML interprets it as hex :P 88 | - 80:80 89 | - 443:443 90 | - 127.0.0.1:8080:8080 91 | restart: unless-stopped 92 | volumes: 93 | - proxy-acme:/etc/traefik/acme 94 | - /etc/localtime:/etc/localtime:ro 95 | - /var/run/docker.sock:/var/run/docker.sock:ro 96 | 97 | ssh: 98 | command: ssh start 99 | depends_on: 100 | - base-image 101 | - cache 102 | - db 103 | expose: 104 | - 8022 105 | labels: 106 | - traefik.enable=true 107 | - traefik.tcp.routers.ssh.entrypoints=ssh 108 | - traefik.tcp.routers.ssh.rule=HostSNI(`*`) 109 | - traefik.tcp.routers.ssh.service=ssh 110 | - traefik.tcp.services.ssh.loadbalancer.proxyprotocol.version=1 111 | networks: 112 | - internal 113 | - proxy 114 | restart: unless-stopped 115 | <<: *xthulu-common 116 | 117 | web: 118 | command: web start 119 | depends_on: 120 | - base-image 121 | - db 122 | expose: 123 | - 5000 124 | labels: 125 | - traefik.enable=true 126 | - traefik.http.routers.web.entrypoints=https 127 | - traefik.http.routers.web.middlewares=no-server-header@file 128 | - traefik.http.routers.web.rule=PathPrefix(`/api`) 129 | - traefik.http.routers.web.tls=true 130 | networks: 131 | - internal 132 | - proxy 133 | restart: unless-stopped 134 | <<: *xthulu-common 135 | 136 | web-static: 137 | image: nginx:alpine-slim 138 | labels: 139 | - traefik.enable=true 140 | - traefik.http.routers.web-static.entrypoints=https 141 | - traefik.http.routers.web-static.middlewares=no-server-header@file 142 | - traefik.http.routers.web-static.rule=PathPrefix(`/`) 143 | - traefik.http.routers.web-static.tls=true 144 | networks: 145 | - proxy 146 | restart: unless-stopped 147 | volumes: 148 | - ../html:/usr/share/nginx/html 149 | 150 | volumes: 151 | db-data: 152 | proxy-acme: 153 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # xthulu 2 | 3 | xthulu _("ch-THOO-loo")_ Python asyncio community server 4 | 5 | ![Header image](https://github.com/haliphax/xthulu/raw/assets/banner.jpg) 6 | 7 | [![Build](https://img.shields.io/github/actions/workflow/status/haliphax/xthulu/docker-build.yml?label=Build)](https://github.com/haliphax/xthulu/actions/workflows/docker-build.yml) 8 | [![Checks](https://img.shields.io/github/actions/workflow/status/haliphax/xthulu/checks.yml?label=Checks)](https://github.com/haliphax/xthulu/actions/workflows/checks.yml) 9 | [![Tests](https://img.shields.io/github/actions/workflow/status/haliphax/xthulu/tests.yml?label=Tests)](https://haliphax.testspace.com/spaces/318003?utm_campaign=metric&utm_medium=referral&utm_source=badge) 10 | [![Coverage](https://img.shields.io/coverallsCoverage/github/haliphax/xthulu?label=Coverage)](https://coveralls.io/github/haliphax/xthulu) 11 | 12 | While **xthulu** is intended to be a _community_ server with multiple avenues of 13 | interaction (e.g. terminal, browser, REST API), its primary focus is to provide 14 | a modern SSH terminal interface which pays tribute to the [bulletin boards][] of 15 | the 1990s. Rather than leaning entirely into [DOS][]-era nostalgia, modern 16 | character sets (UTF-8) and [terminal capabilities][] are taken advantage of. 17 | 18 | - 📔 [Contributor guide][] 19 | - 📽️ [Demo video][] (animated GIF) 20 | 21 | ## Progress 22 | 23 | - 📊 [Alpha release project board][] 24 | - ✅ [Feature check list][] 25 | 26 | ## Setup 27 | 28 | ```shell 29 | # in the project root 30 | bin/setup 31 | ``` 32 | 33 |
    34 | Manual steps 35 | 36 | --- 37 | 38 | If you want to perform the steps in the setup script manually for some reason, 39 | here they are: 40 | 41 | ### Create a configuration file and generate host keys 42 | 43 | ```shell 44 | # in the data/ directory 45 | cp config.example.toml config.toml 46 | ssh-keygen -f ssh_host_key -t rsa -b 4096 -N "" 47 | ``` 48 | 49 | ### Prepare the docker images 50 | 51 | ```shell 52 | # in the docker/ directory 53 | docker compose build base-image 54 | docker compose pull --ignore-buildable 55 | ``` 56 | 57 | ### Create and seed the database 58 | 59 | > ℹ️ Note the names of the scripts. The `bin/xt` script is the command line 60 | > interface for server tasks, while the `bin/xtu` script is for userland. 61 | 62 | ```shell 63 | # in the project root 64 | bin/xt db create --seed 65 | bin/xtu db create --seed 66 | ``` 67 | 68 | ### Build the static web assets 69 | 70 | ```shell 71 | # in the project root 72 | bin/build-web 73 | ``` 74 | 75 | --- 76 | 77 |
    78 | 79 | ### Start the services 80 | 81 | ```shell 82 | # in the docker/ directory 83 | docker compose up -d 84 | ``` 85 | 86 | ## Connect 87 | 88 | ### Connect to the terminal server 89 | 90 | There is a `guest` account which demonstrates the ability for some accounts to 91 | bypass authentication. 92 | 93 | ```shell 94 | ssh guest@localhost 95 | ``` 96 | 97 | There is a `user` account with a password for testing password authentication. 98 | 99 | ```shell 100 | ssh user@localhost # password is also "user" 101 | ``` 102 | 103 | ### Connect to the web server 104 | 105 | For the time being, the web server only demonstrates simple interoperability 106 | between the REST API and static pages. It is available at https://localhost. 107 | There is a demo application that can be used for chatting with other users 108 | connected via both the web and the SSH server. 109 | 110 | > ⚠️ [Traefik][] will be using an untrusted certificate, and you will likely be 111 | > presented with a warning. 112 | 113 | The same credentials may be used here; for the `guest` account, any password (or 114 | a blank password) will work. 115 | 116 | [alpha release project board]: https://github.com/users/haliphax/projects/1 117 | [blessed]: https://blessed.readthedocs.io/en/latest/intro.html 118 | [bulletin boards]: https://archive.org/details/BBS.The.Documentary 119 | [contributor guide]: ./CONTRIBUTING.md 120 | [demo video]: https://github.com/haliphax/xthulu/raw/assets/demo.gif 121 | [dos]: https://en.wikipedia.org/wiki/MS-DOS 122 | [feature check list]: ./CHECKLIST.md 123 | [terminal capabilities]: https://en.wikipedia.org/wiki/Terminal_capabilities 124 | [traefik]: https://traefik.io/traefik 125 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributor guide 2 | 3 | > ⚠️ Contribution at this point is not recommended, but isn't necessarily 4 | > unwelcome. Please [open an issue][] with the `enhancement` label with your 5 | > proposed changes before beginning any work in earnest. 6 | 7 | ## Virtual environment 8 | 9 | It is all but _required_ that you use a Python virtual environment for 10 | development. These instructions will assume that you are using [pyenv][], and 11 | have already installed and configured it on your system. 12 | 13 | ### Create the environment 14 | 15 | ```shell 16 | pyenv install 3.12 # if 3.12 isn't already installed 17 | pyenv virtualenv 3.12 xthulu 18 | pyenv activate xthulu 19 | ``` 20 | 21 | ### Install dependencies 22 | 23 | In addition to the standard dependencies for the project, a set of 24 | developer-focused dependencies are included. Some of them are located in the 25 | `dev` optional dependencies bundle from the project's Python package, but others 26 | come from the [node.js][] ecosystem. You should use a node version manager such 27 | as [nvm][] in order to select the appropriate runtime version. 28 | 29 | ```shell 30 | pip install -e .[dev] 31 | nvm install 32 | nvm use 33 | npm ci 34 | ``` 35 | 36 | ## Development tools 37 | 38 | ### husky 39 | 40 | This project makes use of the [husky][] git hooks system. The following 41 | applications are used to lint source code and check formatting: 42 | 43 | - [ESLint][] - TypeScript linter/formatter 44 | - [Prettier][] - Miscellaneous formatter 45 | - [Ruff][] - Python linter/formatter 46 | 47 | ### gitmoji 48 | 49 | For conventional commit messages, this project has adopted the [gitmoji][] 50 | standard. The `prepare-commit-msg` hook for crafting appropriately-categorized 51 | commit messages is handled by _husky_. 52 | 53 | ### docker compose 54 | 55 | In order to avoid the need to rebuild the service containers' base image each 56 | time you make changes to the source code, you can create an override 57 | configuration for the `docker compose` stack. This configuration will mount the 58 | live source code directory into the running containers so that restarting them 59 | should be sufficient to pick up any changes. 60 | 61 | > ℹ️ Userland scripts do not require a restart; a new session will import a 62 | > fresh copy of the file(s). Changes to static web resources (HTML, CSS, 63 | > Javascript, images) should be reflected immediately upon reloading the 64 | > browser. 65 | 66 | Copy the provided override configuration and adjust as necessary: 67 | 68 | ```shell 69 | # in the docker/ directory 70 | cp docker-compose.dev.yml docker-compose.override.yml 71 | ``` 72 | 73 | ## Testing 74 | 75 | ### mypy 76 | 77 | Type safety is enforced by the [mypy][] package. You may run it individually, 78 | or you may use the example command in the [pytest section](#pytest) to include 79 | type checking as part of test execution. 80 | 81 | ```shell 82 | mypy xthulu 83 | ``` 84 | 85 | ### pytest 86 | 87 | The project's chosen testing framework is [pytest][]. 88 | 89 | ```shell 90 | # run tests 91 | pytest 92 | 93 | # run tests, check types, and generate coverage report 94 | pytest --mypy --cov . 95 | ``` 96 | 97 | Asynchronous test cases _are_ supported via the `pytest-asyncio` plugin, but you 98 | must decorate your `async` test functions with `@pytest.mark.asyncio` for them 99 | to run successfully: 100 | 101 | ```python 102 | from asyncio import sleep 103 | import pytest 104 | 105 | @pytest.mark.asyncio 106 | async def test_sleep(): 107 | await sleep(1) 108 | ``` 109 | 110 | ### coverage 111 | 112 | The [coverage][] package is used to calculate test coverage after unit tests 113 | have been run. You may view the cached report at any time: 114 | 115 | ```shell 116 | coverage report 117 | ``` 118 | 119 | [coverage]: https://coverage.readthedocs.io/en/latest 120 | [eslint]: https://eslint.org 121 | [gitmoji]: https://gitmoji.dev 122 | [husky]: https://typicode.github.io/husky 123 | [mypy]: https://www.mypy-lang.org 124 | [node.js]: https://nodejs.org 125 | [nvm]: https://github.com/nvm-sh/nvm 126 | [open an issue]: https://github.com/haliphax/xthulu/issues/new?labels=enhancement&title=Proposal:%20 127 | [prettier]: https://prettier.io 128 | [pyenv]: https://github.com/pyenv/pyenv 129 | [pytest]: https://pytest.org 130 | [ruff]: https://beta.ruff.rs/docs 131 | -------------------------------------------------------------------------------- /userland/web/routes/chat.py: -------------------------------------------------------------------------------- 1 | """Web chat""" 2 | 3 | # stdlib 4 | from asyncio import sleep 5 | import base64 6 | from datetime import datetime 7 | from math import floor 8 | from typing import Annotated 9 | from uuid import uuid4 10 | 11 | # 3rd party 12 | from fastapi import Depends, HTTPException, Request, status 13 | from sse_starlette.sse import EventSourceResponse 14 | 15 | # api 16 | from xthulu.models.user import User 17 | from xthulu.resources import Resources 18 | from xthulu.web.auth import login_user 19 | 20 | # local 21 | from ...scripts.chat import ChatMessage, MAX_LENGTH 22 | from ..schema.chat import ChatPost, ChatToken 23 | from .. import api 24 | 25 | TOKEN_EXPIRY = 30 26 | """Number of seconds for CSRF token expiration""" 27 | 28 | REFRESH_THRESHOLD = floor(TOKEN_EXPIRY * 0.8) 29 | """Number of seconds for CSRF token refresh""" 30 | 31 | redis = Resources().cache 32 | 33 | 34 | def _refresh_token(username: str) -> str: 35 | """ 36 | Refresh a CSRF token. The token is persisted to redis cache. 37 | 38 | Args: 39 | username: The username to generate a token for. 40 | 41 | Returns: 42 | The CSRF token that was generated. 43 | """ 44 | 45 | token = ( 46 | base64.encodebytes(bytearray.fromhex(str(uuid4()).replace("-", ""))) 47 | .decode("utf-8") 48 | .rstrip() 49 | ) 50 | 51 | if not redis.setex(f"{username}.chat_csrf", TOKEN_EXPIRY, token): 52 | raise Exception(f"Error caching CSRF token for {username}") 53 | 54 | return token 55 | 56 | 57 | @api.get("/chat/") 58 | def chat( 59 | user: Annotated[User, Depends(login_user)], request: Request 60 | ) -> EventSourceResponse: 61 | """Server-sent events for chat EventSource.""" 62 | 63 | async def generate(): 64 | pubsub = redis.pubsub() 65 | pubsub.subscribe("chat") 66 | redis.publish( 67 | "chat", 68 | ChatMessage( 69 | user=None, message=f"{user.name} has joined" 70 | ).model_dump_json(), 71 | ) 72 | token = _refresh_token(user.name) 73 | then = datetime.utcnow() 74 | yield ChatToken(token=token).model_dump_json() 75 | 76 | try: 77 | while not await request.is_disconnected(): 78 | now = datetime.utcnow() 79 | 80 | if (now - then).total_seconds() > REFRESH_THRESHOLD: 81 | token = _refresh_token(user.name) 82 | then = now 83 | yield ChatToken(token=token).model_dump_json() 84 | 85 | message = pubsub.get_message(True) 86 | data: bytes 87 | 88 | if not message: 89 | await sleep(0.1) 90 | continue 91 | 92 | data = message["data"] 93 | yield data.decode("utf-8") 94 | finally: 95 | redis.delete(f"{user.name}.chat_csrf") 96 | redis.publish( 97 | "chat", 98 | ChatMessage( 99 | user=None, message=f"{user.name} has left" 100 | ).model_dump_json(), 101 | ) 102 | pubsub.close() 103 | 104 | return EventSourceResponse(generate()) 105 | 106 | 107 | # TODO need rate-limiting decorator/Depends 108 | @api.post("/chat/") 109 | async def post_chat( 110 | message: ChatPost, 111 | user: Annotated[User, Depends(login_user)], 112 | ) -> None: 113 | """ 114 | Post a chat message. 115 | 116 | Args: 117 | message: The message object being posted. 118 | """ 119 | 120 | token_bytes: bytes = redis.get(f"{user.name}.chat_csrf") # type: ignore 121 | token = token_bytes.decode("utf-8") 122 | 123 | if token is None or token != message.token: 124 | raise HTTPException( 125 | status_code=status.HTTP_403_FORBIDDEN, 126 | detail="Missing or invalid CSRF token", 127 | ) 128 | 129 | if len(message.message) > MAX_LENGTH: 130 | raise HTTPException( 131 | status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, 132 | detail=f"Too long; message must be <= {MAX_LENGTH}", 133 | ) 134 | 135 | redis.publish( 136 | "chat", 137 | ChatMessage(user=user.name, message=message.message).model_dump_json(), 138 | ) 139 | -------------------------------------------------------------------------------- /xthulu/ssh/server.py: -------------------------------------------------------------------------------- 1 | """SSH server implementation""" 2 | 3 | # stdlib 4 | from logging import DEBUG 5 | from secrets import compare_digest 6 | 7 | # 3rd party 8 | from asyncssh import SSHServer as AsyncSSHServer, SSHServerConnection 9 | from sqlmodel import func, select 10 | 11 | # local 12 | from .. import locks 13 | from ..configuration import get_config 14 | from ..events import EventQueues 15 | from ..logger import log 16 | from ..models import User 17 | from ..resources import db_session 18 | 19 | 20 | class SSHServer(AsyncSSHServer): 21 | """xthulu SSH Server""" 22 | 23 | _username: str | None = None 24 | _peername: list[str] 25 | 26 | _debug_enabled: bool 27 | _no_password: list[str] 28 | _no_entry: list[str] 29 | 30 | def __init__(self, *args, **kwargs): 31 | super(SSHServer, self).__init__(*args, **kwargs) 32 | self._debug_enabled = log.getEffectiveLevel() == DEBUG 33 | self._no_entry = get_config("ssh.auth.bad_usernames", []) 34 | self._no_password = get_config("ssh.auth.no_password", []) 35 | 36 | @property 37 | def whoami(self): 38 | """The peer name in the format username@host""" 39 | 40 | return f"{self._username}@{self._sid}" 41 | 42 | def connection_made(self, conn: SSHServerConnection): 43 | """ 44 | Connection opened. 45 | 46 | Args: 47 | conn: The connection object. 48 | """ 49 | 50 | self._peername = conn.get_extra_info("peername") 51 | self._sid = "{}:{}".format(*self._peername) 52 | log.info(f"{self._sid} connecting") 53 | 54 | def connection_lost(self, exc: Exception | None): 55 | """ 56 | Connection lost. 57 | 58 | Args: 59 | exc: The exception that caused the connection loss, if any. 60 | """ 61 | 62 | if self._sid in EventQueues.q.keys(): 63 | del EventQueues.q[self._sid] 64 | 65 | locks.expire(self._sid) 66 | 67 | if exc: 68 | log.exception("Subprocess error", exc_info=exc) 69 | 70 | log.info(f"{self.whoami} disconnected") 71 | 72 | def begin_auth(self, username: str) -> bool: 73 | """ 74 | Check for auth bypass. 75 | 76 | Args: 77 | username: The username to check. 78 | 79 | Returns: 80 | Whether authentication is necessary. 81 | """ 82 | 83 | self._username = username 84 | auth_required = True 85 | 86 | if username in self._no_password: 87 | log.info(f"{self.whoami} connected (no password)") 88 | auth_required = False 89 | else: 90 | log.info(f"{self.whoami} authenticating") 91 | 92 | return auth_required 93 | 94 | def password_auth_supported(self) -> bool: 95 | """ 96 | Support password authentication. 97 | 98 | Returns: 99 | True, as this server supports password authentication. 100 | """ 101 | 102 | return True 103 | 104 | async def validate_password(self, username: str, password: str) -> bool: 105 | """ 106 | Validate provided password. 107 | 108 | Args: 109 | username: The username to validate. 110 | password: The password to validate. 111 | 112 | Returns: 113 | Whether the authentication is valid. 114 | """ 115 | 116 | lowered = username.lower() 117 | 118 | if lowered in self._no_entry: 119 | log.warning(f"{self.whoami} rejected") 120 | 121 | return False 122 | 123 | async with db_session() as db: 124 | u = ( 125 | await db.exec( 126 | select(User).where(func.lower(User.name) == lowered) 127 | ) 128 | ).one() 129 | 130 | if u is None: 131 | log.warning(f"{self.whoami} no such user") 132 | 133 | return False 134 | 135 | expected, _ = User.hash_password(password, u.salt) 136 | assert u.password 137 | 138 | if not compare_digest(expected, u.password): 139 | log.warning(f"{self.whoami} failed authentication (password)") 140 | 141 | return False 142 | 143 | log.info(f"{self.whoami} authenticated (password)") 144 | 145 | return True 146 | -------------------------------------------------------------------------------- /xthulu/ssh/proxy_protocol.py: -------------------------------------------------------------------------------- 1 | """ 2 | PROXY protocol v1 support 3 | 4 | Credit to Ron Frederick for the majority of this code. 5 | """ 6 | 7 | # type checking 8 | from typing import Callable 9 | 10 | # stdlib 11 | from asyncio import get_event_loop, Transport 12 | 13 | # 3rd party 14 | from asyncssh import SSHServerConnection, SSHServerSession 15 | 16 | 17 | class ProxyProtocolSession: 18 | """Session implementing the PROXY protocol v1""" 19 | 20 | def __init__(self, conn_factory: Callable): 21 | self._conn_factory = conn_factory 22 | self._conn: SSHServerConnection | None = None 23 | self._peername: tuple[str, int] | None = None 24 | self._transport: Transport | None = None 25 | self._inpbuf: list[bytes] = [] 26 | 27 | def connection_made(self, transport: Transport): 28 | self._transport = transport 29 | 30 | connection_made.__doc__ = SSHServerSession.connection_made.__doc__ 31 | 32 | def connection_lost(self, exc: Exception): 33 | if self._conn: 34 | self._conn.connection_lost(exc) 35 | 36 | self.close() 37 | 38 | connection_lost.__doc__ = SSHServerSession.connection_lost.__doc__ 39 | 40 | def get_extra_info(self, name: str, default=None): 41 | """Return proxied peername; fallback to transport for other values.""" 42 | 43 | assert self._transport is not None 44 | 45 | if name == "peername": 46 | return self._peername 47 | else: 48 | return self._transport.get_extra_info(name, default) 49 | 50 | def data_received(self, data: bytes): 51 | """Look for PROXY headers during connection establishment.""" 52 | 53 | if self._conn: 54 | self._conn.data_received(data) 55 | else: 56 | idx = data.find(b"\r\n") 57 | 58 | if idx >= 0: 59 | self._inpbuf.append(data[:idx]) 60 | data = data[idx + 2 :] 61 | 62 | conn_info = b"".join(self._inpbuf).split() 63 | self._inpbuf.clear() 64 | 65 | self._peername = ( 66 | conn_info[2].decode("ascii"), 67 | int(conn_info[4]), 68 | ) 69 | self._conn = self._conn_factory("", 0) 70 | self._conn.connection_made(self) # type: ignore 71 | 72 | if data: 73 | self._conn.data_received(data) # type: ignore 74 | else: 75 | self._inpbuf.append(data) 76 | 77 | def eof_received(self): 78 | if self._conn: 79 | self._conn.eof_received() 80 | 81 | eof_received.__doc__ = SSHServerSession.eof_received.__doc__ 82 | 83 | def write(self, data: bytes): 84 | if self._transport: 85 | self._transport.write(data) 86 | 87 | write.__doc__ = Transport.write.__doc__ 88 | 89 | def is_closing(self): 90 | return self._transport.is_closing() if self._transport else None 91 | 92 | is_closing.__doc__ = Transport.is_closing.__doc__ 93 | 94 | def abort(self): 95 | self.close() 96 | 97 | abort.__doc__ = Transport.abort.__doc__ 98 | 99 | def close(self): 100 | return self._transport.close() if self._transport else None 101 | 102 | close.__doc__ = Transport.close.__doc__ 103 | 104 | 105 | class ProxyProtocolListener: 106 | """Tunnel listener which passes connections to a PROXY protocol session""" 107 | 108 | async def create_server( 109 | self, conn_factory: Callable, listen_host: str, listen_port: int 110 | ): 111 | """ 112 | Create the server. 113 | 114 | Args: 115 | conn_factory: A callable which will be called and provided with \ 116 | the connection information when a new connection is tunneled. 117 | listen_host: The hostname to bind. 118 | listen_port: The port number to bind. 119 | 120 | Returns: 121 | An asyncio server for tunneling SSH connections. 122 | """ 123 | 124 | def tunnel_factory(): 125 | return ProxyProtocolSession(conn_factory) 126 | 127 | return await get_event_loop().create_server( 128 | tunnel_factory, # type: ignore 129 | listen_host, 130 | listen_port, # type: ignore 131 | ) 132 | -------------------------------------------------------------------------------- /tests/test_locks.py: -------------------------------------------------------------------------------- 1 | # stdlib 2 | from typing import Any 3 | from unittest.mock import patch, Mock 4 | 5 | # 3rd party 6 | import pytest 7 | 8 | # local 9 | from xthulu import locks 10 | from xthulu.locks import _Locks 11 | 12 | 13 | @pytest.fixture(autouse=True) 14 | def mock_cache(): 15 | with patch("xthulu.locks.cache") as p: 16 | yield p 17 | 18 | 19 | @pytest.fixture(autouse=True) 20 | def clear_locks(): 21 | _Locks.locks.clear() 22 | yield 23 | _Locks.locks.clear() 24 | 25 | 26 | def test_get_lock(): 27 | """Acquiring a new lock should populate the Locks singleton.""" 28 | 29 | # act 30 | success = locks.get("test_name", "test_lock") 31 | 32 | # assert 33 | assert success 34 | assert "test_name" in _Locks.locks 35 | 36 | 37 | def test_get_lock_fails_if_exists(mock_cache: Mock): 38 | """Attempting to acquire an existing lock should fail.""" 39 | 40 | # arrange 41 | mock_cache.lock.return_value = Mock() 42 | mock_cache.lock.return_value.acquire = Mock(return_value=False) 43 | 44 | # act 45 | success = locks.get("test_name", "test_lock") 46 | 47 | # assert 48 | assert success is False 49 | assert "test_name" not in _Locks.locks 50 | 51 | 52 | def test_hold_lock(): 53 | """The `hold` context manager should acquire a lock successfully.""" 54 | 55 | # act 56 | with locks.hold("test_name", "test_lock") as l: 57 | # assert 58 | assert l 59 | assert "test_name" in _Locks.locks 60 | 61 | 62 | def test_hold_lock_fails_if_exists(mock_cache: Mock): 63 | """Attempting to hold an existing lock should fail.""" 64 | 65 | # arrange 66 | mock_cache.lock.return_value = Mock() 67 | mock_cache.lock.return_value.acquire = Mock(return_value=False) 68 | 69 | # act 70 | with locks.hold("test_name", "test_lock") as l: 71 | # assert 72 | assert l is False 73 | assert "test_name" not in _Locks.locks 74 | 75 | 76 | def test_release_lock(): 77 | """Releasing a lock should remove it from the singleton.""" 78 | 79 | # arrange 80 | locks_: dict[str, Any] = {"test_lock": Mock()} # type: ignore 81 | _Locks.locks["test_name"] = locks_ 82 | 83 | # act 84 | success = locks.release("test_name", "test_lock") 85 | 86 | # assert 87 | assert success 88 | assert "test_lock" not in locks_ 89 | 90 | 91 | def test_release_lock_removes_parent_when_empty(): 92 | """Releasing a user's only lock should remove the parent object.""" 93 | 94 | # arrange 95 | _Locks.locks["test_name"] = {"test_lock": Mock()} # type: ignore 96 | 97 | # act 98 | success = locks.release("test_name", "test_lock") 99 | 100 | # assert 101 | assert success 102 | assert "test_name" not in _Locks.locks 103 | 104 | 105 | def test_release_lock_fails_if_not_present(): 106 | """Attempting to release a lock not in the dict should fail.""" 107 | 108 | # arrange 109 | _Locks.locks["test_name"] = {"test_lock1": Mock()} # type: ignore 110 | 111 | # act 112 | success = locks.release("test_name", "test_lock2") 113 | 114 | # assert 115 | assert not success 116 | 117 | 118 | def test_release_lock_fails_if_not_locked(): 119 | """Attempting to release a lock that is not locked should fail.""" 120 | 121 | # arrange 122 | mock_lock = Mock() 123 | mock_lock.locked.return_value = False 124 | _Locks.locks["test_name"] = {"test_lock": mock_lock} # type: ignore 125 | 126 | # act 127 | success = locks.release("test_name", "test_lock") 128 | 129 | # assert 130 | assert not success 131 | 132 | 133 | def test_expire_locks(): 134 | """Expiring a user's locks should remove the parent object.""" 135 | 136 | # arrange 137 | _Locks.locks["test_name"] = { # type: ignore 138 | "test_lock_1": Mock(), 139 | "test_lock_2": Mock(), 140 | } 141 | 142 | # act 143 | success = locks.expire("test_name") 144 | 145 | # assert 146 | assert success 147 | assert "test_name" not in _Locks.locks 148 | 149 | 150 | def test_expire_exits_early_if_no_locks(): 151 | """Expiring a user's locks should exit early if they have none.""" 152 | 153 | # arrange 154 | _Locks.locks["test_name"] = dict() 155 | 156 | # act 157 | success = locks.expire("test_name") 158 | 159 | # assert 160 | assert not success 161 | -------------------------------------------------------------------------------- /userland/scripts/chat.py: -------------------------------------------------------------------------------- 1 | """Node chat script""" 2 | 3 | # stdlib 4 | from asyncio import Event 5 | from collections import deque 6 | import json 7 | from typing import Any 8 | 9 | # 3rd party 10 | from pydantic import BaseModel 11 | from redis import Redis 12 | from redis.client import PubSub 13 | from rich.markup import escape 14 | from textual.binding import Binding 15 | from textual.containers import VerticalScroll 16 | from textual.widgets import Input, Static 17 | 18 | # api 19 | from xthulu.resources import Resources 20 | from xthulu.ssh.console.app import XthuluApp 21 | from xthulu.ssh.context import SSHContext 22 | 23 | LIMIT = 1000 24 | """Total number of messages to keep in backlog""" 25 | 26 | MAX_LENGTH = 256 27 | """Maximum length of individual messages""" 28 | 29 | 30 | class ChatMessage(BaseModel): 31 | """Posted chat message""" 32 | 33 | message: str 34 | """Message text""" 35 | 36 | user: str | None 37 | """Message author (or `None` if system)""" 38 | 39 | def __init__(self, **data: Any): 40 | "" # empty docstring 41 | super(ChatMessage, self).__init__(**data) 42 | 43 | 44 | class ChatApp(XthuluApp): 45 | """Node chat Textual app""" 46 | 47 | AUTO_FOCUS = "Input" 48 | BINDINGS = [Binding("escape", "quit", show=False)] 49 | 50 | redis: Redis 51 | """Redis connection""" 52 | 53 | pubsub: PubSub 54 | """Redis PubSub connection""" 55 | 56 | _chatlog: deque[ChatMessage] 57 | _exit_event: Event 58 | 59 | def __init__(self, context: SSHContext, **kwargs): 60 | "" # empty docstring 61 | super(ChatApp, self).__init__(context, **kwargs) 62 | self.redis = Resources().cache 63 | self.pubsub = self.redis.pubsub() 64 | self.pubsub.subscribe(**{"chat": self.on_chat}) 65 | self._chatlog = deque(maxlen=LIMIT) 66 | self._exit_event = Event() 67 | 68 | def _listen(self) -> None: 69 | self.redis.publish( 70 | "chat", 71 | ChatMessage( 72 | user=None, message=f"{self.context.username} has joined" 73 | ).model_dump_json(), 74 | ) 75 | 76 | while not self._exit_event.is_set(): 77 | self.pubsub.get_message(True, 0.01) 78 | 79 | def compose(self): 80 | yield VerticalScroll(Static(id="log")) 81 | yield Input( 82 | placeholder="Enter a message or press ESC", 83 | max_length=MAX_LENGTH, 84 | ) 85 | 86 | def on_chat(self, message: dict[str, str]) -> None: 87 | def format_message(msg: ChatMessage): 88 | if msg.user: 89 | return ( 90 | f"\n[bright_white on blue]<{msg.user}>[/] " 91 | f"{escape(msg.message)}" 92 | ) 93 | 94 | return ( 95 | "\n[bright_white on red]<*>[/] " 96 | f"[italic][white]{msg.message}[/][/]" 97 | ) 98 | 99 | msg = ChatMessage(**json.loads(message["data"])) 100 | self._chatlog.append(msg) 101 | l: Static = self.get_widget_by_id("log") # type: ignore 102 | l.update( 103 | self.console.render_str( 104 | "".join([format_message(m) for m in self._chatlog]) 105 | ) 106 | ) 107 | vs = self.query_one(VerticalScroll) 108 | vs.scroll_end(animate=False) 109 | input = self.query_one(Input) 110 | input.value = "" 111 | 112 | def exit(self, **kwargs) -> None: 113 | msg = ChatMessage( 114 | user=None, message=f"{self.context.username} has left" 115 | ) 116 | self.redis.publish("chat", msg.model_dump_json()) 117 | self._exit_event.set() 118 | self.workers.cancel_all() 119 | super(ChatApp, self).exit() 120 | 121 | async def on_ready(self) -> None: 122 | self.run_worker(self._listen, exclusive=True, thread=True) 123 | 124 | def on_input_submitted(self, event: Input.Submitted) -> None: 125 | val = event.input.value.strip() 126 | 127 | if val == "": 128 | return 129 | 130 | self.redis.publish( 131 | "chat", 132 | ChatMessage( 133 | user=self.context.username, message=val 134 | ).model_dump_json(), 135 | ) 136 | 137 | 138 | async def main(cx: SSHContext) -> None: 139 | cx.console.set_window_title("chat") 140 | await ChatApp(cx).run_async() 141 | -------------------------------------------------------------------------------- /xthulu/ssh/process_factory.py: -------------------------------------------------------------------------------- 1 | """SSH server process factory""" 2 | 3 | # stdlib 4 | from asyncio import gather, IncompleteReadError, wait_for 5 | from datetime import datetime 6 | 7 | # 3rd party 8 | from asyncssh import SSHServerProcess, TerminalSizeChanged 9 | 10 | # local 11 | from ..configuration import get_config 12 | from ..events.structs import EventData 13 | from ..resources import db_session 14 | from .console import XthuluConsole 15 | from .context import SSHContext 16 | from .exceptions import Goto, ProcessClosing, ProcessForciblyClosed 17 | from .structs import Script 18 | 19 | 20 | async def handle_client(proc: SSHServerProcess) -> None: 21 | """ 22 | Factory method for handling client connections. 23 | 24 | Args: 25 | proc: The server process responsible for the client. 26 | """ 27 | 28 | cx = await SSHContext._create(proc) 29 | 30 | if proc.subsystem: 31 | cx.log.error(f"Requested unimplemented subsystem: {proc.subsystem}") 32 | proc.channel.close() 33 | proc.close() 34 | 35 | return 36 | 37 | termtype = proc.get_terminal_type() 38 | 39 | if termtype is None: 40 | proc.channel.close() 41 | proc.close() 42 | 43 | return 44 | 45 | if "LANG" not in proc.env or "UTF-8" not in proc.env["LANG"]: 46 | cx.encoding = "cp437" 47 | 48 | if "TERM" not in cx.env: 49 | cx.env["TERM"] = termtype 50 | 51 | w, h, _, _ = proc.get_terminal_size() 52 | cx.env["COLUMNS"] = str(w) 53 | cx.env["LINES"] = str(h) 54 | cx.user.last = datetime.now() 55 | 56 | async with db_session() as db: 57 | db.add(cx.user) 58 | await db.commit() 59 | await db.refresh(cx.user) 60 | 61 | async def input_loop(): 62 | timeout = int(get_config("ssh.session.timeout", 120)) 63 | 64 | while not proc.is_closing(): 65 | try: 66 | if timeout > 0: 67 | r = await wait_for(proc.stdin.read(1024), timeout) 68 | else: 69 | r = await proc.stdin.read(1024) 70 | 71 | await cx.input.put(r) 72 | 73 | except IncompleteReadError: 74 | # process is likely closing 75 | break 76 | 77 | except ProcessForciblyClosed: 78 | break 79 | 80 | except TimeoutError: 81 | cx.log.warning("Timed out") 82 | cx.echo("\n\n[bright_white on red] TIMED OUT [/]\n\n") 83 | break 84 | 85 | except TerminalSizeChanged as sz: 86 | cx.env["COLUMNS"] = str(sz.width) 87 | cx.env["LINES"] = str(sz.height) 88 | cx.console.width = sz.width 89 | cx.console.height = sz.height 90 | cx.events.add(EventData("resize", (sz.width, sz.height))) 91 | 92 | # disable capture of mouse events 93 | cx.echo("\x1b[?1000l\x1b[?1003l\x1b[?1015l\x1b[?1006l") 94 | # show cursor 95 | cx.echo("\x1b[?25h") 96 | 97 | if proc.channel: 98 | proc.channel.close() 99 | 100 | proc.close() 101 | 102 | async def main_process(): 103 | """Userland script stack; main process.""" 104 | 105 | cx.console = XthuluConsole( 106 | encoding=cx.encoding, 107 | height=h, 108 | ssh_writer=proc.stdout, 109 | width=w, 110 | _environ=cx.env, 111 | ) 112 | # prep script stack with top scripts; 113 | # since we're treating it as a stack and not a queue, add them 114 | # reversed so they are executed in the order they were defined 115 | top_names: list[str] = get_config("ssh.userland.top", ("top",)) # type: ignore 116 | cx.stack = [Script(s, (), {}) for s in reversed(top_names)] 117 | 118 | # main script engine loop 119 | while len(cx.stack): 120 | current = cx.stack.pop() 121 | 122 | try: 123 | await cx.runscript(current) 124 | except Goto as goto_script: 125 | cx.stack = [goto_script.value] 126 | except ProcessClosing: 127 | cx.stack = [] 128 | 129 | if proc.channel: 130 | cx.console.set_window_title("") 131 | proc.channel.close() 132 | 133 | proc.close() 134 | 135 | cx.log.info("Starting terminal session") 136 | 137 | try: 138 | await gather(input_loop(), main_process()) 139 | except Exception: 140 | cx.log.exception("Exception in handler process") 141 | finally: 142 | if proc.channel: 143 | proc.channel.close() 144 | 145 | proc.close() 146 | -------------------------------------------------------------------------------- /tests/test_events.py: -------------------------------------------------------------------------------- 1 | """EventQueue tests""" 2 | 3 | # 3rd party 4 | import pytest 5 | 6 | # local 7 | from xthulu.events import EventData, EventQueue, EventQueues, put_global 8 | 9 | 10 | @pytest.fixture(autouse=True) 11 | def clear_queues(): 12 | EventQueues.q.clear() 13 | yield 14 | EventQueues.q.clear() 15 | 16 | 17 | def test_new_eventqueue_gets_added(): 18 | """Spinning up a new EventQueue should append to the singleton.""" 19 | 20 | # act 21 | q1 = EventQueue("1") 22 | 23 | # assert 24 | assert "1" in EventQueues.q 25 | assert EventQueues.q["1"] == q1 26 | 27 | 28 | @pytest.mark.asyncio 29 | async def test_put_global_count(): 30 | """put_global should return an accurate count of deliveries.""" 31 | 32 | # arrange 33 | EventQueue("1") 34 | EventQueue("2") 35 | 36 | # act 37 | count = await put_global(EventData("test", "test")) 38 | 39 | # assert 40 | assert count == 2 41 | 42 | 43 | @pytest.mark.asyncio 44 | async def test_put_global_exclude(): 45 | """put_global should exclude specified EventQueue sids.""" 46 | 47 | # arrange 48 | q1 = EventQueue("1") 49 | test_data = EventData("test", "test") 50 | 51 | # act 52 | await put_global(test_data, exclude={"1"}) 53 | q1_events = q1.get("test") 54 | 55 | # assert 56 | assert len(q1_events) == 0 57 | 58 | 59 | @pytest.mark.asyncio 60 | async def test_put_global_exclude_count(): 61 | """ 62 | put_global should return an accurate count of deliveries when sids are 63 | excluded. 64 | """ 65 | 66 | # arrange 67 | EventQueue("1") 68 | EventQueue("2") 69 | 70 | # act 71 | count = await put_global(EventData("test", "test"), exclude={"1"}) 72 | 73 | # assert 74 | assert count == 1 75 | 76 | 77 | @pytest.mark.asyncio 78 | async def test_put_global_is_global(): 79 | """put_global should add an event to all EventQueues.""" 80 | 81 | # arrange 82 | q1 = EventQueue("1") 83 | q2 = EventQueue("2") 84 | 85 | # act 86 | await put_global(EventData("test", "test")) 87 | q1_events = q1.get("test") 88 | q2_events = q2.get("test") 89 | 90 | # assert 91 | assert len(q1_events) == len(q2_events) 92 | assert len(q1_events) == 1 93 | 94 | 95 | @pytest.mark.asyncio 96 | async def test_put_global_passes_data(): 97 | """put_global should pass the EventData correctly.""" 98 | 99 | # arrange 100 | q1 = EventQueue("1") 101 | test_data = EventData("test", "test") 102 | 103 | # act 104 | await put_global(test_data) 105 | q1_events = q1.get("test") 106 | ev = q1_events[0] 107 | 108 | # assert 109 | assert ev == test_data 110 | 111 | 112 | def test_add_pushes_eventdata(): 113 | """add should push EventData into the queue.""" 114 | 115 | # arrange 116 | q1 = EventQueue("1") 117 | test_data = EventData("test", "test") 118 | 119 | # act 120 | q1.add(test_data) 121 | q1_events = q1.get() 122 | 123 | # assert 124 | assert q1_events[0] == test_data 125 | 126 | 127 | def test_flush_all(): 128 | """flush should clear the EventQueue if no event name is given.""" 129 | 130 | # arrange 131 | q1 = EventQueue("1") 132 | q1.add(EventData("test", "test")) 133 | 134 | # act 135 | q1.flush() 136 | q1_events = q1.get("test") 137 | 138 | # assert 139 | assert len(q1_events) == 0 140 | 141 | 142 | def test_flush_name(): 143 | """flush should clear only specified items if name is given.""" 144 | 145 | # arrange 146 | q1 = EventQueue("1") 147 | test_data = EventData("test", "test") 148 | q1.add(EventData("nope", "nope")) 149 | q1.add(test_data) 150 | 151 | # act 152 | q1.flush("nope") 153 | q1_events = q1.get() 154 | 155 | # assert 156 | assert len(q1_events) == 1 157 | assert q1_events[0] == test_data 158 | 159 | 160 | def test_get_all_is_chronological(): 161 | """ 162 | get should return data in chronological order if no event name is given. 163 | """ 164 | 165 | # arrange 166 | q1 = EventQueue("1") 167 | test_data = [ 168 | EventData("one", "one"), 169 | EventData("two", "two"), 170 | EventData("three", "three"), 171 | ] 172 | 173 | for event in test_data: 174 | q1.add(event) 175 | 176 | # act 177 | q1_events = q1.get() 178 | 179 | # assert 180 | assert q1_events == test_data 181 | 182 | 183 | def test_get_by_name_is_chronological(): 184 | """ 185 | get should return data in chronological order if event name is given. 186 | """ 187 | 188 | # arrange 189 | q1 = EventQueue("1") 190 | test_data_one = [ 191 | EventData("one", "one"), 192 | EventData("one", "two"), 193 | EventData("one", "three"), 194 | ] 195 | test_data_two = [ 196 | EventData("two", "one"), 197 | EventData("two", "two"), 198 | EventData("two", "three"), 199 | ] 200 | test_data = test_data_one + test_data_two 201 | 202 | for event in test_data: 203 | q1.add(event) 204 | 205 | # act 206 | q1_events = q1.get("one") 207 | 208 | # assert 209 | assert q1_events == test_data_one 210 | 211 | 212 | def test_get_by_name_returns_eventdata(): 213 | """get should return specific EventData from the queue.""" 214 | 215 | # arrange 216 | q1 = EventQueue("1") 217 | test_data = EventData("test", "test") 218 | q1.add(EventData("nope", "nope")) 219 | q1.add(test_data) 220 | 221 | # act 222 | q1_events = q1.get("test") 223 | 224 | # assert 225 | assert len(q1_events) == 1 226 | assert q1_events[0] == test_data 227 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | pull_request: 5 | branches: [main] 6 | 7 | push: 8 | branches: [main] 9 | 10 | workflow_dispatch: 11 | 12 | concurrency: 13 | cancel-in-progress: true 14 | group: | 15 | ${{ github.workflow }}-${{ github.head_ref || github.ref_name }} 16 | 17 | jobs: 18 | changes: 19 | name: Change detection 20 | runs-on: ubuntu-latest 21 | outputs: 22 | src: ${{ steps.changes.outputs.src }} 23 | steps: 24 | - name: Check out 25 | uses: actions/checkout@v4 26 | 27 | - name: Detect changed files 28 | id: changes 29 | uses: dorny/paths-filter@v3 30 | with: 31 | filters: | 32 | src: 33 | - .editorconfig 34 | - .prettier-ignore 35 | - pyproject.toml 36 | - "requirements/**" 37 | - "tests/**" 38 | - "xthulu/**" 39 | 40 | tests: 41 | name: Unit tests 42 | needs: changes 43 | if: needs.changes.outputs.src == 'true' || github.ref == 'refs/heads/main' 44 | runs-on: ubuntu-latest 45 | steps: 46 | - name: Check out 47 | uses: actions/checkout@v4 48 | 49 | - name: Set up Python 50 | uses: actions/setup-python@v5 51 | with: 52 | python-version: 3.12 53 | 54 | - name: Install dependencies 55 | run: | 56 | pip install -U pip setuptools 57 | pip install -e .[dev] 58 | 59 | - uses: testspace-com/setup-testspace@v1 60 | with: 61 | domain: ${{ github.repository_owner }} 62 | 63 | - name: Unit tests 64 | run: pytest --mypy --cov . --junit-xml junit.xml 65 | 66 | - name: Coverage report 67 | run: | 68 | total=$(coverage report --format=total) 69 | cat >> $GITHUB_STEP_SUMMARY <> $GITHUB_STEP_SUMMARY 73 | coverage xml 74 | 75 | - name: Upload coverage report or add comment 76 | id: coveralls 77 | uses: coverallsapp/github-action@v2 78 | with: 79 | file: .coverage 80 | 81 | - name: Push result to Testspace server 82 | if: always() && contains(github.event_name, 'push') && github.ref_name == 'main' 83 | run: testspace ./junit.xml ./coverage.xml 84 | 85 | - name: Resolve Coveralls failure 86 | if: always() && steps.coveralls.outcome == 'failure' 87 | run: exit 1 88 | 89 | # Clean up retry comment (if any) on success or eventual failure 90 | finish: 91 | name: Finish 92 | needs: tests 93 | if: contains(github.event_name, 'pull') && (success() || fromJSON(github.run_attempt) >= 3) 94 | runs-on: ubuntu-latest 95 | steps: 96 | - name: Find retry comment 97 | if: contains(github.event_name, 'pull') 98 | id: find 99 | uses: peter-evans/find-comment@v3 100 | with: 101 | issue-number: ${{ github.event.pull_request.number }} 102 | body-includes: 103 | 104 | - name: Delete comment 105 | id: delete 106 | if: fromJSON(github.run_attempt) < 3 && steps.find.outputs.comment-id != 0 107 | uses: detomarco/delete-comments@1.1.0 108 | with: 109 | comment-id: ${{ steps.find.outputs.comment-id }} 110 | 111 | - name: Post failure comment 112 | if: fromJSON(github.run_attempt) >= 3 113 | uses: peter-evans/create-or-update-comment@v4 114 | with: 115 | comment-id: ${{ steps.find.outputs.comment-id }} 116 | issue-number: ${{ github.event.pull_request.number }} 117 | edit-mode: append 118 | body: | 119 | ❌ `Attempt #${{ github.run_attempt }}` The Coveralls report upload has failed. Retry limit has been reached. 120 | 121 | # Re-run in case of 503 from coveralls.io 122 | retry: 123 | name: Retry 124 | needs: tests 125 | if: failure() && fromJSON(github.run_attempt) < 3 126 | runs-on: ubuntu-latest 127 | steps: 128 | - name: Find comment 129 | if: contains(github.event_name, 'pull') 130 | id: find 131 | uses: peter-evans/find-comment@v3 132 | with: 133 | issue-number: ${{ github.event.pull_request.number }} 134 | body-includes: 135 | 136 | - name: Delete existing comment if first run 137 | id: delete 138 | if: fromJSON(github.run_attempt) == 1 && steps.find.outputs.comment-id != 0 139 | uses: detomarco/delete-comments@1.1.0 140 | with: 141 | comment-id: ${{ steps.find.outputs.comment-id }} 142 | 143 | - name: Prep retry comment id 144 | id: comment-id 145 | run: | 146 | if [[ "${{ steps.delete.outcome }}" == "success" ]]; then 147 | echo "id=0" >> $GITHUB_OUTPUT 148 | else 149 | echo "id=${{ steps.find.outputs.comment-id }}" >> $GITHUB_OUTPUT 150 | fi 151 | 152 | - name: Post retry comment 153 | if: contains(github.event_name, 'pull') 154 | uses: peter-evans/create-or-update-comment@v4 155 | with: 156 | comment-id: ${{ steps.comment-id.outputs.id }} 157 | issue-number: ${{ github.event.pull_request.number }} 158 | edit-mode: append 159 | body: | 160 | ♻️ `Attempt #${{ github.run_attempt }}` The Coveralls report upload has failed. The workflow is being retried. 161 | 162 | - env: 163 | GH_REPO: ${{ github.repository }} 164 | GH_TOKEN: ${{ github.token }} 165 | GH_DEBUG: api 166 | run: gh workflow run retry.yml -F run_id=${{ github.run_id }} 167 | --------------------------------------------------------------------------------