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
+
+
+
+
+
+
+
+
+
+
+
+"""
+
+
+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