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 0000000..466064d Binary files /dev/null and b/WebPlus_IBM_VGA_8x16.woff differ 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