http endpoint

This commit is contained in:
randogoth 2025-10-26 16:57:22 +02:00
parent 9babf723fe
commit 43b9d9f47a
6 changed files with 487 additions and 6 deletions

View file

@ -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"]

View file

@ -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

BIN
WebPlus_IBM_VGA_8x16.woff Normal file

Binary file not shown.

View file

@ -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

421
scripts/dos-httpd Normal file
View file

@ -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 = """<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>SvarBox Web Console</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/xterm@5.3.0/css/xterm.css" />
<style>
:root, body { background: #1e1e1e; color: #f0f0f0; height: 100%; }
body {
margin: 0;
display: flex;
flex-direction: column;
font-family: system-ui, sans-serif;
}
header {
padding: 0.6rem 1rem;
background: #111;
font-size: 0.95rem;
letter-spacing: 0.04em;
text-transform: uppercase;
}
#terminal {
flex: 1 1 auto;
min-height: 0;
}
#terminal .xterm-viewport {
background-color: #000;
}
</style>
</head>
<body>
<header>SvarBox DOS Console</header>
<div id="terminal"></div>
<script type="module">
import { Terminal } from "https://cdn.jsdelivr.net/npm/xterm@5.3.0/+esm";
import { FitAddon } from "https://cdn.jsdelivr.net/npm/xterm-addon-fit@0.8.0/+esm";
const term = new Terminal({
cursorBlink: true,
convertEol: true,
theme: {
background: "#000000",
foreground: "#f5f5f5"
},
});
const fitAddon = new FitAddon();
term.loadAddon(fitAddon);
term.open(document.getElementById("terminal"));
function fitLater() {
requestAnimationFrame(() => fitAddon.fit());
}
fitLater();
window.addEventListener("resize", fitLater);
const wsProtocol = window.location.protocol === "https:" ? "wss" : "ws";
const socket = new WebSocket(`${wsProtocol}://${window.location.host}/ws`);
const textDecoder = new TextDecoder("utf-8", { fatal: false });
function decodeBase64ToString(b64) {
const binary = atob(b64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i += 1) {
bytes[i] = binary.charCodeAt(i);
}
return textDecoder.decode(bytes);
}
socket.addEventListener("open", () => {
fitLater();
term.focus();
});
socket.addEventListener("message", (event) => {
try {
const payload = JSON.parse(event.data);
if (payload.type === "output") {
term.write(decodeBase64ToString(payload.data));
} else if (payload.type === "exit") {
term.write("\\r\\n[dos-shell exited]\\r\\n");
}
} catch (err) {
console.error("failed to process message", err);
}
});
socket.addEventListener("close", (event) => {
term.write(`\\r\\n[connection closed: ${event.code}]\\r\\n`);
});
socket.addEventListener("error", (event) => {
console.error("websocket error", event);
term.write("\\r\\n[websocket error]\\r\\n");
});
term.onData((data) => {
socket.send(JSON.stringify({ type: "input", data }));
});
term.onResize(({ cols, rows }) => {
socket.send(JSON.stringify({ type: "resize", cols, rows }));
});
</script>
</body>
</html>
"""
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()

View file

@ -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