From 43b9d9f47af6bf9a5921a5f2e3237d9fe05e3592 Mon Sep 17 00:00:00 2001 From: randogoth Date: Sun, 26 Oct 2025 16:57:22 +0200 Subject: [PATCH] http endpoint --- Dockerfile | 7 +- README.md | 10 +- WebPlus_IBM_VGA_8x16.woff | Bin 0 -> 22584 bytes compose.yml | 3 + scripts/dos-httpd | 421 ++++++++++++++++++++++++++++++++++++++ scripts/start-services.sh | 52 +++++ 6 files changed, 487 insertions(+), 6 deletions(-) create mode 100644 WebPlus_IBM_VGA_8x16.woff create mode 100644 scripts/dos-httpd diff --git a/Dockerfile b/Dockerfile index e540f26..0da7b46 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,13 +3,14 @@ FROM ubuntu:22.04 ENV DEBIAN_FRONTEND=noninteractive # Install base packages and enable the dosemu2 PPA -RUN apt-get update && apt-get install -y --no-install-recommends software-properties-common gnupg openssh-server busybox-static sudo ca-certificates curl mtools unzip file xauth acl && add-apt-repository -y ppa:dosemu2/ppa && apt-get install -y --no-install-recommends dosemu2 && rm -rf /var/lib/apt/lists/* +RUN apt-get update && apt-get install -y --no-install-recommends software-properties-common gnupg openssh-server busybox-static sudo ca-certificates curl mtools unzip file xauth acl python3 python3-aiohttp && add-apt-repository -y ppa:dosemu2/ppa && apt-get install -y --no-install-recommends dosemu2 && rm -rf /var/lib/apt/lists/* # Provide DOS wrapper, SvarDOS bootstrapper, and service supervisor COPY scripts/dos-shell /usr/local/bin/dos-shell +COPY scripts/dos-httpd /usr/local/bin/dos-httpd COPY scripts/prepare-svardos.sh /usr/local/bin/prepare-svardos COPY scripts/start-services.sh /usr/local/bin/start-dos-services -RUN chmod +x /usr/local/bin/dos-shell /usr/local/bin/start-dos-services /usr/local/bin/prepare-svardos && echo "/usr/local/bin/dos-shell" >> /etc/shells +RUN chmod +x /usr/local/bin/dos-shell /usr/local/bin/dos-httpd /usr/local/bin/start-dos-services /usr/local/bin/prepare-svardos && echo "/usr/local/bin/dos-shell" >> /etc/shells # Create sshd runtime directory RUN mkdir -p /var/run/sshd @@ -31,5 +32,5 @@ COPY config/dos_allowed /etc/dos_allowed # Configure sshd to force command for dosuser COPY config/sshd_config /etc/ssh/sshd_config -EXPOSE 22 23 +EXPOSE 22 23 8080 CMD ["/usr/local/bin/start-dos-services"] diff --git a/README.md b/README.md index 67cfaab..0d90568 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,7 @@ Default access: - **Password:** `dosuser` - **SSH:** `ssh -X dosuser@localhost -p 2222` - **Telnet (optional):** `telnet localhost 2323` (disabled if `ENABLE_TELNET=0`) +- **Web console:** `http://localhost:8080` (disable with `ENABLE_HTTP_CONSOLE=0`) Use `exit` from the DOS shell to terminate the session; the container keeps running for the next login. @@ -42,7 +43,7 @@ All persistent user data inside the guest lives under `/home/dosuser/.dosemu`, w ## How the Container Boots 1. **`prepare-svardos`** runs during build, downloading the latest SvarDOS ZIP (override with `SVARDOS_IMG_URL`) and staging it under `/opt/svardos/base`. -2. **`start-dos-services`** starts BusyBox `telnetd` (if enabled) and then `sshd`. +2. **`start-dos-services`** starts the optional web console, BusyBox `telnetd` (if enabled), and then `sshd`. 3. Whenever `dosuser` logs in, **`dos-shell`**: - Detects terminal mode (X11 window, terminal, or dumb) and composes the corresponding `dosemu` flags. - Ensures a private C: drive under `/home/dosuser/.dosemu/drive_c`, copying SvarDOS files if the sentinel `.svardos_installed` is missing or if you asked for a reinstall. @@ -124,7 +125,9 @@ You can override which binary is used for the Linux side by setting `DOS_LINUX_S | `DOS_CONTAINER_NAME` | `svarbox` | Name of the running container. | | `DOS_SSH_PORT` | `2222` | Host port forwarded to container port 22. | | `DOS_TELNET_PORT` | `2323` | Host port forwarded to container port 23. | +| `DOS_HTTP_PORT` | `8080` | Host port forwarded to the web console. | | `ENABLE_TELNET` | `1` | Toggle BusyBox telnetd. | +| `ENABLE_HTTP_CONSOLE` | `1` | Launch the built-in web terminal. | | `TELNET_PORT` | `23` | Port inside the container where telnetd listens. | | `TELNET_LOGIN` | `/bin/login` | Login command invoked by telnetd. | | `DOS_ALLOW_MODE` | `all` | Passed straight through to `dos-shell`. | @@ -135,6 +138,7 @@ Example `.env` snippet: ``` DOS_SSH_PORT=2022 ENABLE_TELNET=0 +ENABLE_HTTP_CONSOLE=0 DOS_ALLOW_MODE=list SVARDOS_IMG_URL=https://example.com/custom-svardos.zip ``` @@ -167,7 +171,7 @@ You can launch the container directly: ```sh docker build -t svarbox . docker run -d --name svarbox \ - -p 2222:22 -p 2323:23 \ + -p 2222:22 -p 2323:23 -p 8080:8080 \ -v "$(pwd)/allowed_repo:/opt/allowed_repo" \ -v "$(pwd)/config/dos_allowed:/etc/dos_allowed:ro" \ -v "$(pwd)/dos_env:/etc/dos_env:ro" \ @@ -177,7 +181,7 @@ docker run -d --name svarbox \ To override behaviour, append `-e` flags: ```sh -docker run … -e ENABLE_TELNET=0 -e DOS_ALLOW_MODE=list -e DOS_AUDIO_MODE=force … +docker run … -e ENABLE_TELNET=0 -e ENABLE_HTTP_CONSOLE=0 -e DOS_ALLOW_MODE=list -e DOS_AUDIO_MODE=force … ``` ## Troubleshooting diff --git a/WebPlus_IBM_VGA_8x16.woff b/WebPlus_IBM_VGA_8x16.woff new file mode 100644 index 0000000000000000000000000000000000000000..466064deaedfbb94b9ca281f66b7ad2194b80bf6 GIT binary patch literal 22584 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 literal 0 HcmV?d00001 diff --git a/compose.yml b/compose.yml index f4565f7..ecf4169 100644 --- a/compose.yml +++ b/compose.yml @@ -9,11 +9,14 @@ services: ports: - "${DOS_SSH_PORT:-2222}:22" - "${DOS_TELNET_PORT:-2323}:23" + - "${DOS_HTTP_PORT:-8080}:${DOS_HTTP_PORT:-8080}" environment: DOS_ALLOW_MODE: ${DOS_ALLOW_MODE:-all} ENABLE_TELNET: ${ENABLE_TELNET:-1} TELNET_PORT: ${TELNET_PORT:-23} TELNET_LOGIN: /bin/login + ENABLE_HTTP_CONSOLE: ${ENABLE_HTTP_CONSOLE:-1} + DOS_HTTP_PORT: ${DOS_HTTP_PORT:-8080} volumes: - ./allowed_repo:/opt/allowed_repo:z - ./config/dos_allowed:/etc/dos_allowed:ro,z diff --git a/scripts/dos-httpd b/scripts/dos-httpd new file mode 100644 index 0000000..b4c3c8c --- /dev/null +++ b/scripts/dos-httpd @@ -0,0 +1,421 @@ +#!/usr/bin/env python3 +""" +Serve a lightweight web console backed by /usr/local/bin/dos-shell. + +The server exposes: + - GET / → Single-page app with an xterm.js terminal + - GET /ws → WebSocket endpoint that proxies a PTY attached to dos-shell + - GET /healthz → Basic readiness probe +""" + +import argparse +import asyncio +import base64 +import errno +import fcntl +import json +import logging +import os +import pty +import pwd +import signal +import struct +import termios +from typing import Dict, Optional, Tuple + +from aiohttp import web, WSMsgType + + +LOG = logging.getLogger("dos_httpd") + +HTML_PAGE = """ + + + + SvarBox Web Console + + + + + +
SvarBox DOS Console
+
+ + + + +""" + + +def set_winsize(fd: int, rows: int, cols: int) -> None: + packed = struct.pack("HHHH", rows, cols, 0, 0) + fcntl.ioctl(fd, termios.TIOCSWINSZ, packed) + + +def spawn_shell(username: str, shell_path: str, env: Optional[Dict[str, str]] = None) -> Tuple[int, int]: + try: + pw_record = pwd.getpwnam(username) + except KeyError as exc: + raise RuntimeError(f"Unable to resolve user '{username}'") from exc + + base_env = os.environ.copy() + if env: + base_env.update(env) + + base_env["TERM"] = base_env.get("TERM", "xterm-256color") + base_env["HOME"] = pw_record.pw_dir + base_env["LOGNAME"] = username + base_env["USER"] = username + base_env["SHELL"] = shell_path + base_env.setdefault("PWD", pw_record.pw_dir) + + pid, master_fd = pty.fork() + if pid == 0: + try: + os.chdir(pw_record.pw_dir) + except FileNotFoundError: + os.chdir("/") + + try: + os.setsid() + except OSError: + pass + + try: + os.umask(0o22) + except Exception: + pass + + if os.getuid() == 0: + try: + os.initgroups(username, pw_record.pw_gid) + except PermissionError: + pass + os.setgid(pw_record.pw_gid) + os.setuid(pw_record.pw_uid) + + os.environ.clear() + os.environ.update(base_env) + + try: + os.execvpe(shell_path, [shell_path], os.environ) + except Exception as exc: + LOG.error("execvpe failed: %%s", exc, exc_info=True) + os._exit(1) # noqa: SLF001 + + os.set_blocking(master_fd, False) + return pid, master_fd + + +class ShellSession: + def __init__(self, username: str, shell_path: str, term_rows: int, term_cols: int): + self.username = username + self.shell_path = shell_path + self.term_rows = term_rows + self.term_cols = term_cols + + self.pid: Optional[int] = None + self.fd: Optional[int] = None + self.loop = asyncio.get_running_loop() + self.ws: Optional[web.WebSocketResponse] = None + self._reader_active = False + self._closed = asyncio.Event() + + async def start(self, ws: web.WebSocketResponse) -> None: + self.ws = ws + self.pid, self.fd = spawn_shell(self.username, self.shell_path) + set_winsize(self.fd, self.term_rows, self.term_cols) + self.loop.add_reader(self.fd, self._on_pty_ready) + self._reader_active = True + LOG.debug("Spawned dos-shell pid=%s fd=%s", self.pid, self.fd) + + def _on_pty_ready(self) -> None: + if self.fd is None or self.ws is None or self.ws.closed: + return + try: + data = os.read(self.fd, 4096) + except OSError as exc: + if exc.errno in (errno.EIO, errno.EBADF): + data = b"" + else: + LOG.warning("read error from PTY: exc=%s", exc) + return + + if data: + payload = base64.b64encode(data).decode("ascii") + asyncio.create_task(self._send({"type": "output", "data": payload})) + return + + LOG.debug("PTY produced zero bytes; closing websocket") + asyncio.create_task(self._send({"type": "exit"})) + asyncio.create_task(self.ws.close()) + + async def _send(self, message: Dict[str, object]) -> None: + if self.ws is None or self.ws.closed: + return + try: + await self.ws.send_str(json.dumps(message)) + except ConnectionResetError: + LOG.debug("websocket reset during send") + except RuntimeError as exc: + LOG.debug("unable to send on websocket: %s", exc) + + async def write(self, data: bytes) -> None: + if self.fd is None: + return + try: + await asyncio.to_thread(os.write, self.fd, data) + except OSError as exc: + LOG.debug("write failed: %s", exc) + + def resize(self, rows: int, cols: int) -> None: + if self.fd is None: + return + rows = max(rows, 1) + cols = max(cols, 1) + try: + set_winsize(self.fd, rows, cols) + except OSError as exc: + LOG.debug("winsize update failed: %s", exc) + + async def close(self) -> None: + if self._closed.is_set(): + return + + self._closed.set() + if self._reader_active and self.fd is not None: + try: + self.loop.remove_reader(self.fd) + except Exception: + pass + self._reader_active = False + + if self.fd is not None: + try: + os.close(self.fd) + except OSError: + pass + self.fd = None + + if self.pid: + for sig in (signal.SIGHUP, signal.SIGTERM, signal.SIGKILL): + try: + os.kill(self.pid, sig) + except ProcessLookupError: + break + await asyncio.sleep(0.1) + waited_pid, _ = await asyncio.to_thread(os.waitpid, self.pid, os.WNOHANG) + if waited_pid == self.pid: + break + try: + await asyncio.to_thread(os.waitpid, self.pid, 0) + except ChildProcessError: + pass + self.pid = None + + +async def index_handler(_request: web.Request) -> web.StreamResponse: + return web.Response(text=HTML_PAGE, content_type="text/html", headers={"Cache-Control": "no-store"}) + + +async def health_handler(_request: web.Request) -> web.Response: + return web.Response(text="ok\n", content_type="text/plain") + + +async def websocket_handler(request: web.Request) -> web.WebSocketResponse: + ws = web.WebSocketResponse(heartbeat=30) + await ws.prepare(request) + + cfg = request.app["config"] + session = ShellSession(cfg.user, cfg.shell_path, cfg.default_rows, cfg.default_cols) + await session.start(ws) + + try: + async for msg in ws: + if msg.type == WSMsgType.TEXT: + try: + payload = json.loads(msg.data) + except json.JSONDecodeError: + continue + + if payload.get("type") == "input": + await session.write(payload.get("data", "").encode("utf-8", "ignore")) + elif payload.get("type") == "resize": + rows = int(payload.get("rows", cfg.default_rows)) + cols = int(payload.get("cols", cfg.default_cols)) + session.resize(rows, cols) + elif msg.type == WSMsgType.ERROR: + LOG.warning("websocket error: %s", ws.exception()) + break + finally: + await session.close() + + return ws + + +class AppConfig: + def __init__(self, user: str, shell_path: str, host: str, port: int, rows: int, cols: int): + self.user = user + self.shell_path = shell_path + self.host = host + self.port = port + self.default_rows = rows + self.default_cols = cols + + +def build_argument_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description="SvarBox web console") + parser.add_argument("--host", default=os.environ.get("DOS_HTTP_HOST", "0.0.0.0"), help="Address to bind") + parser.add_argument("--port", type=int, default=int(os.environ.get("DOS_HTTP_PORT", "8080")), help="Port to bind") + parser.add_argument( + "--shell", + default=os.environ.get("DOS_HTTP_SHELL", "/usr/local/bin/dos-shell"), + help="Path to the DOS shell launcher", + ) + parser.add_argument( + "--user", + default=os.environ.get("DOS_HTTP_USER", "dosuser"), + help="System user the PTY session should run as", + ) + parser.add_argument("--rows", type=int, default=int(os.environ.get("DOS_HTTP_ROWS", "25")), help="Default rows") + parser.add_argument("--cols", type=int, default=int(os.environ.get("DOS_HTTP_COLS", "80")), help="Default columns") + parser.add_argument( + "--log-level", + default=os.environ.get("DOS_HTTP_LOG_LEVEL", "INFO"), + choices=["CRITICAL", "ERROR", "WARNING", "INFO", "DEBUG"], + help="Log verbosity", + ) + return parser + + +def configure_logging(level: str) -> None: + logging.basicConfig(level=getattr(logging, level.upper(), logging.INFO), format="%(asctime)s %(levelname)s %(message)s") + + +async def create_app(cfg: AppConfig) -> web.Application: + app = web.Application() + app["config"] = cfg + app.add_routes( + [ + web.get("/", index_handler), + web.get("/healthz", health_handler), + web.get("/ws", websocket_handler), + ] + ) + return app + + +def main() -> None: + parser = build_argument_parser() + args = parser.parse_args() + configure_logging(args.log_level) + + cfg = AppConfig( + user=args.user, + shell_path=args.shell, + host=args.host, + port=args.port, + rows=args.rows, + cols=args.cols, + ) + + app = asyncio.run(create_app(cfg)) + + web.run_app(app, host=cfg.host, port=cfg.port, print=None, access_log=None) + + +if __name__ == "__main__": + main() diff --git a/scripts/start-services.sh b/scripts/start-services.sh index 27e8132..0fe1624 100755 --- a/scripts/start-services.sh +++ b/scripts/start-services.sh @@ -5,6 +5,9 @@ TELNET_PORT="${TELNET_PORT:-23}" TELNET_LOGIN="${TELNET_LOGIN:-/bin/login}" TELNETD_BIN="${TELNETD_BIN:-/bin/busybox}" ENABLE_TELNET="${ENABLE_TELNET:-1}" +ENABLE_HTTP_CONSOLE="${ENABLE_HTTP_CONSOLE:-1}" +DOS_HTTP_PORT="${DOS_HTTP_PORT:-8080}" +DOS_HTTPD_BIN="${DOS_HTTPD_BIN:-/usr/local/bin/dos-httpd}" ensure_dosuser_home() { local dos_entry dos_home dos_uid dos_gid owner_uid owner_gid @@ -78,6 +81,55 @@ ensure_dosuser_home() { ensure_dosuser_home +start_http_console() { + if [ "${ENABLE_HTTP_CONSOLE}" != "1" ]; then + return + fi + + if [ ! -x "${DOS_HTTPD_BIN}" ]; then + echo "start-dos-services: HTTP console disabled; missing ${DOS_HTTPD_BIN}" >&2 + return + fi + + local args=() + if [ -n "${DOS_HTTP_HOST:-}" ]; then + args+=(--host "${DOS_HTTP_HOST}") + fi + if [ -n "${DOS_HTTP_PORT:-}" ]; then + args+=(--port "${DOS_HTTP_PORT}") + fi + if [ -n "${DOS_HTTP_USER:-}" ]; then + args+=(--user "${DOS_HTTP_USER}") + fi + if [ -n "${DOS_HTTP_SHELL:-}" ]; then + args+=(--shell "${DOS_HTTP_SHELL}") + fi + if [ -n "${DOS_HTTP_ROWS:-}" ]; then + args+=(--rows "${DOS_HTTP_ROWS}") + fi + if [ -n "${DOS_HTTP_COLS:-}" ]; then + args+=(--cols "${DOS_HTTP_COLS}") + fi + if [ -n "${DOS_HTTP_LOG_LEVEL:-}" ]; then + args+=(--log-level "${DOS_HTTP_LOG_LEVEL}") + fi + + set +e + "${DOS_HTTPD_BIN}" "${args[@]}" & + local status=$? + local pid=$! + set -e + + if [ "${status}" -ne 0 ]; then + echo "start-dos-services: warning: HTTP console failed to launch (exit ${status})" >&2 + return + fi + + echo "start-dos-services: HTTP console listening on ${DOS_HTTP_HOST:-0.0.0.0}:${DOS_HTTP_PORT} (pid ${pid})" +} + +start_http_console + if [ "$ENABLE_TELNET" = "1" ]; then if [ ! -x "$TELNETD_BIN" ]; then echo "Telnet disabled: telnetd binary not found at $TELNETD_BIN" >&2