commit b8a99e6e9eea5eaecd1c04845ea205b89b55fbe2 Author: Tronax Date: Sat Aug 8 21:08:51 2026 +0200 Initial commit: CTmine-client (ContainerMine Client) Docker-WebApp, die einen echten Minecraft Java-Client (Prism Launcher) in einem Container mit virtuellem Display betreibt und per noVNC im Browser anzeigt. Gedacht, um Accounts AFK an Farmen zu stellen. - Multi-Stage Dockerfile: Vite/Vue-Build + Ubuntu-Runtime (Xvfb, x11vnc, websockify, noVNC, openbox, nginx, Prism Launcher 11.0.3) - Vue 3 + Vite + TypeScript Dashboard (noVNC-iframe, Start/Stop, Status-Anzeige, Schnellstart-Anleitung) - Node-Status-API ohne externe Dependencies (/api/status, /api/mc/*) - docker-compose.yml mit PUID/PGID, konfigurierbarer Auflösung, shm_size - Persistente Accounts/Instanzen in /config (Volume) diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..7202195 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,13 @@ +# Alles was nicht ins Image soll +**/node_modules +**/dist +.git +.gitignore +.env +.env.* +*.md +config/ +data/ +Dockerfile +docker-compose.yml +.dockerignore diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..d49ad84 --- /dev/null +++ b/.env.example @@ -0,0 +1,29 @@ +# In .env kopieren und anpassen. Wird von docker-compose.yml eingelesen. + +# Benutzer/Gruppe, als der Container-Prozess läuft (entspricht deinem Host-User, +# damit Dateien in ./config dir "gehören"). Mit `id -u` / `id -g` herausfinden. +PUID=1000 +PGID=1000 + +# Zeitzone +TZ=Europe/Berlin + +# Auflösung des virtuellen Displays (MC läuft darin). +DISPLAY_WIDTH=1280 +DISPLAY_HEIGHT=720 + +# Bildwiederholrate des virtuellen Displays. +DISPLAY_REFRESH=60 + +# Farbe pro Pixel (24 = 8-bit RGB, ausreichend und schnell). +DISPLAY_DEPTH=24 + +# Web-Port des Dashboards (Browser → http://localhost:). +WEB_PORT=8080 + +# Optionales VNC-Passwort. Leer = ohne Passwort (nur für lokales Netz gedacht). +VNC_PASSWORD= + +# noVNC-Pfad. Leer = Dashboard startet direkt im VNC-Viewer. +# Z.B. "vnc.html" für die standalone noVNC-Seite ohne Dashboard-Frame. +NOVNC_PATH= diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2621ed5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,20 @@ +# Dependencies +web/node_modules/ +docker/mc-api/node_modules/ + +# Build output +web/dist/ + +# Local env +.env +.env.local + +# Editor / OS +.vscode/ +.idea/ +*.swp +.DS_Store + +# Container runtime state (wird ins Volume gemountet, nie einchecken) +config/ +data/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..6295231 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,165 @@ +# ============================================================================= +# CTmine-client · ContainerMine Client +# Multi-Stage Dockerfile: +# Stage 1 (web-build): baut das Vue 3 + Vite Dashboard +# Stage 2 (final): Ubuntu mit Xvfb/x11vnc/websockify/openbox/nginx, +# Prism Launcher, echtem Minecraft-Client, Node-API +# ============================================================================= + +# ---------------------------------------------------------------------------- +# Stage 1: Vue Dashboard bauen +# ---------------------------------------------------------------------------- +FROM node:22-slim AS web-build + +WORKDIR /build + +# Erst nur Abhängigkeiten (besserer Layer-Cache). +COPY web/package.json web/package-lock.json* ./ +RUN npm install --no-audit --no-fund + +# Quellen kopieren und Produktions-Build erstellen. +COPY web/ ./ +RUN npm run build + + +# ---------------------------------------------------------------------------- +# Stage 2: Runtime +# ---------------------------------------------------------------------------- +FROM ubuntu:24.04 + +ENV DEBIAN_FRONTEND=noninteractive \ + TZ=Europe/Berlin \ + DISPLAY=:0 \ + CONFIG_DIR=/config \ + PRISM_DIR=/config/prism \ + WEB_ROOT=/var/www/ctmine \ + MC_API_PORT=3000 \ + PUID=1000 \ + PGID=1000 + +# --- Systempakete ---------------------------------------------------------- +# Xvfb virtuelles Display +# x11vnc VNC-Server auf Xvfb +# websockify VNC → WebSocket für noVNC +# novnc Browser-VNC-Client (wird hier nur als Fallback gehostet) +# openbox leichter Windowmanager (sonst kein Fokus/Rahmen für MC) +# nginx liefert Dashboard + proxyt API/Websockify +# nodejs Status-API +# supervisord Prozess-Manager +# gosu Prozess als Runtime-Benutzer starten +# xdpyinfo Auflösung auslesen (für /api/status) +# Prism Launcher benötigt Qt6 + multimediale Libs. +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates \ + wget \ + gnupg \ + xz-utils \ + \ + xvfb \ + x11vnc \ + python3 \ + python3-websockify \ + novnc \ + openbox \ + nginx \ + supervisor \ + gosu \ + x11-utils \ + \ + nodejs \ + \ + qt6-base-dev \ + qt6-wayland \ + libqt6core6 \ + libqt6gui6 \ + libqt6network6 \ + libqt6widgets6 \ + libqt6svg6 \ + libqt6opengl6 \ + libqt6multimedia6 \ + libgl1 \ + libglx-mesa0 \ + libegl1 \ + libegl-mesa0 \ + mesa-utils \ + libopenal1 \ + libpulse0 \ + libglfw3 \ + libflac12 \ + libvorbisfile3 \ + libopengl0 \ + locales \ + tzdata \ + && rm -rf /var/lib/apt/lists/* + +# Locale generieren (Qt verlangt ein funktionierendes utf8-Locale). +RUN sed -i '/en_US.UTF-8/s/^# //g' /etc/locale.gen && locale-gen +ENV LANG=en_US.UTF-8 \ + LANGUAGE=en_US:en \ + LC_ALL=en_US.UTF-8 + +# --- Runtime-Benutzer (UID/GID 1000) --------------------------------------- +# Ubuntu hat bereits eine Gruppe/einen User mit ID 1000 (»ubuntu«). Wir nutzen +# diese, statt sie neu anzulegen: bestehende Gruppe/User werden umbenannt. +RUN if getent group 1000 >/dev/null; then \ + existing_group=$(getent group 1000 | cut -d: -f1) && \ + groupmod -n ctmine "$existing_group"; \ + else \ + groupadd -g 1000 ctmine; \ + fi && \ + if getent passwd 1000 >/dev/null; then \ + existing_user=$(getent passwd 1000 | cut -d: -f1) && \ + usermod -l ctmine -d /home/ctmine -m -s /bin/bash "$existing_user" && \ + usermod -g 1000 ctmine; \ + else \ + useradd -m -u 1000 -g 1000 -s /bin/bash ctmine; \ + fi && \ + mkdir -p /config /var/www/ctmine /app/mc-api && \ + chown -R ctmine:ctmine /config /app + +# --- Prism Launcher installieren ------------------------------------------- +# Wir laden den offiziellen portable Linux-Build (Qt6) herunter. Prism ist +# eine portable Qt-App — kein systemweites Installieren nötig. Das Archiv +# entpackt *flach* (Dateien liegen direkt im Root, kein Unterverzeichnis). +ARG PRISM_VERSION=11.0.3 +RUN mkdir -p /opt/prism && cd /opt/prism && \ + wget -q "https://github.com/PrismLauncher/PrismLauncher/releases/download/${PRISM_VERSION}/PrismLauncher-Linux-Qt6-Portable-${PRISM_VERSION}.tar.gz" -O prism.tar.gz && \ + tar -xzf prism.tar.gz && \ + rm prism.tar.gz && \ + chmod +x /opt/prism/PrismLauncher /opt/prism/bin/prismlauncher + +# --- Vue-Dashboard aus Stage 1 übernehmen ---------------------------------- +COPY --from=web-build /build/dist/ /var/www/ctmine/ + +# --- Node-API übernehmen --------------------------------------------------- +COPY docker/mc-api/ /app/mc-api/ + +# --- Docker-Konfigurationsdateien ------------------------------------------ +COPY docker/supervisor.conf /etc/supervisor/conf.d/supervisord.conf +COPY docker/nginx.conf /etc/nginx/nginx.conf +COPY docker/entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh /opt/prism/PrismLauncher + +# --- Prism beim ersten Start ins Volume entpacken -------------------------- +# So liegen Accounts, Instanzen und Launcher persistent in /config und +# überstehen Container-Neustarts. Prism erwartet $HOME als Datenverzeichnis. +RUN echo '#!/usr/bin/env bash' > /opt/bootstrap-prism.sh && \ + echo 'set -e' >> /opt/bootstrap-prism.sh && \ + echo 'if [ ! -x "${PRISM_DIR}/PrismLauncher" ]; then' >> /opt/bootstrap-prism.sh && \ + echo ' mkdir -p "${PRISM_DIR}"' >> /opt/bootstrap-prism.sh && \ + echo ' cp -r /opt/prism/* "${PRISM_DIR}/"' >> /opt/bootstrap-prism.sh && \ + echo ' chmod +x "${PRISM_DIR}/PrismLauncher"' >> /opt/bootstrap-prism.sh && \ + echo ' chown -R ctmine:ctmine "${PRISM_DIR}"' >> /opt/bootstrap-prism.sh && \ + echo 'fi' >> /opt/bootstrap-prism.sh && \ + chmod +x /opt/bootstrap-prism.sh + +# Volumes: persistente Daten (Accounts, Instanzen, .minecraft). +VOLUME ["/config"] + +EXPOSE 8080 + +# Healthcheck: ist das Dashboard erreichbar? +HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \ + CMD wget -qO- http://127.0.0.1:8080/ >/dev/null 2>&1 || exit 1 + +ENTRYPOINT ["/entrypoint.sh"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..125b62f --- /dev/null +++ b/README.md @@ -0,0 +1,198 @@ +# ⛏ CTmine-client · ContainerMine Client + +Eine Docker-WebApp, die einen **echten Minecraft Java-Client** im Container +laufen lässt und ihn per **noVNC direkt im Browser** anzeigt. Ideal, um einen +Account AFK an eine Farm zu stellen — ohne lokalen Minecraft-Client oder +Java-Installation. + +Das Dashboard ist eine **Vite + Vue 3**-Anwendung, die im selben Container +mit ausgeliefert wird. + +--- + +## Architektur + +``` +Browser ──HTTP/WS──▶ nginx (:8080) + ├── / → Vue-Dashboard (statisch) + ├── /api/* → Node-Status-API (127.0.0.1:3000) + └── /websockify → websockify (127.0.0.1:6080) + │ + supervisord verwaltet ▼ + Xvfb :0 → x11vnc → websockify → Browser + ▲ + Prism Launcher + echter Minecraft-Client + (rendert per Software-OpenGL/llvmpipe auf Xvfb) +``` + +**Warum Prism Launcher statt offiziellem Mojang-Launcher?** Der Mojang-Launcher +nutzt für den Microsoft-Login ein eingebettetes Webview, das in einem headlessen +Container regelmäßig Probleme macht. Prism startet denselben Vanilla-Client, +ist aber container-freundlich und nutzt den **Device-Code-Flow** für den Login: +Code notieren → `microsoft.com/link` auf irgendeinem Gerät öffnen → fertig. + +--- + +## Voraussetzungen + +- **Docker** (mit BuildKit) und **Docker Compose v2** +- ~2 GB freier Speicher für das Image +- Einen **gültigen Microsoft-Account** mit Minecraft-Lizenz +- Chromium/Firefox (für WebGL & WebSocket in noVNC) + +> Lokales Bauen ohne Docker ist nicht vorgesehen. Wenn du nur das Dashboard +> entwickeln willst, siehe [Entwicklung](#dashboard-entwickeln). + +--- + +## Schnellstart + +```bash +# 1. Env-Datei erzeugen und anpassen +cp .env.example .env +# PUID/PGID mit `id -u` / `id -g` setzen, damit ./config dir gehört. + +# 2. Image bauen und starten +docker compose up -d --build + +# 3. Dashboard öffnen +# http://localhost:8080 +``` + +--- + +## Erster Login (einmalig) + +1. **Dashboard öffnen** → http://localhost:8080 +2. Auf **„Instanz starten“** klicken. Prism Launcher öffnet sich im VNC-Fenster. +3. In Prism oben rechts: **Konto → Konto hinzufügen → Microsoft**. +4. Prism zeigt einen **Code** an. Auf *irgendeinem* Gerät + `https://microsoft.com/link` öffnen, Code eingeben, mit Microsoft einloggen. +5. Zurück in Prism: im Hauptfenster die gewünschte **Instanz** wählen und auf + **„Spielen“** klicken. Prism lädt die passende Minecraft-Version + Java + automatisch herunter. +6. Im Minecraft-Hauptmenü: **Mehrspieler → Server hinzufügen** → Adresse der + Farm eingeben → **Verbinden**. + +Ab jetzt ist der Account auf der Farm. Der Login liegt persistent in +`./config/prism` und bleibt auch nach `docker compose down` erhalten. + +> **Tipp:** Bevor du den Container Neustartest, kannst du in Minecraft über +> *Optionen → Steuerelemente* auch schon die korrekte Farm-Anbindung +> (z. B. eine Trade/Anti-AFK-Makro-Mod) konfigurieren — sie bleibt erhalten. + +--- + +## AFK-Farm-Setup + +Die konkrete Einrichtung hängt von deiner Farm ab. Typische Schritte: + +- **Anti-AFK**: periodisch springen/bewegen, damit der Server dich nicht kickt. + Du kannst die Vanilla-Funktion (z. B. Wasserstrom, der dich ständig leicht + bewegt) nutzen oder eine Client-Mod wie *Mouse Tweaks* / *Inventory Profiles* + installieren. Mods legst du über Prism in die jeweilige Instanz. +- **Auto-Reconnect**: manche Server trennen nach Stunden. Nutze ggf. eine + *Reconnect*-Mod. +- **Fenster offen lassen**: Solange das Dashboard geöffnet ist, siehst du den + Client live. Du kannst den Tab schließen — Minecraft läuft im Container + weiter. Nur der Container muss laufen. + +--- + +## Konfiguration (`.env`) + +| Variable | Standard | Bedeutung | +| ------------------- | ------------- | ------------------------------------------------- | +| `PUID` / `PGID` | `1000` | UID/GID des Container-Benutzers. Mit `id -u`/`id -g` deines Host-Users setzen, damit `./config` dir gehört. | +| `TZ` | `Europe/Berlin` | Zeitzone des Containers. | +| `DISPLAY_WIDTH` | `1280` | Breite des virtuellen Displays (MC-Auflösung). | +| `DISPLAY_HEIGHT` | `720` | Höhe des virtuellen Displays. | +| `DISPLAY_DEPTH` | `24` | Farbtiefe (24 = 8-bit RGB). | +| `DISPLAY_REFRESH` | `60` | Bildwiederholrate des virtuellen Displays. | +| `WEB_PORT` | `8080` | Browser-Port. | +| `VNC_PASSWORD` | *(leer)* | Optional. Leer = ohne Auth (nur lokales Netz!). | + +--- + +## API + +| Methode | Pfad | Beschreibung | +| ------- | ---------------- | ---------------------------------------- | +| `GET` | `/api/status` | Status von Display, VNC und Minecraft. | +| `POST` | `/api/mc/start` | Startet Prism Launcher. | +| `POST` | `/api/mc/stop` | Beendet Prism + Minecraft. | + +Beispiel: +```bash +curl -X POST http://localhost:8080/api/mc/start +``` + +--- + +## Dashboard entwickeln + +Für die Vue-Entwicklung ohne jedes Mal neu zu bauen: + +```bash +cd web +npm install +npm run dev # → http://localhost:5173 (proxyt /api + /websockify nach :8080) +``` + +Damit das Dev-Dashboard funktioniert, muss der Container laufen +(`docker compose up -d`), damit der VNC-Stream und die API erreichbar sind. + +--- + +## Performance-Hinweise + +- Minecraft rendert im Container per **llvmpipe (CPU-Software-OpenGL)**. + Für AFK-Farmen absolut ausreichend (~1–2 CPU-Kerne), aber kein flüssiges PVP. +- `shm_size: 1gb` ist gesetzt, damit OpenGL genug Shared Memory hat. +- Bei verfübarer GPU kannst du `/dev/dri` durchreichen (in `docker-compose.yml` + auskommentiert). MC nutzt dann Hardware-Rendering. + +--- + +## Projektstruktur + +``` +CTmine-client/ +├── Dockerfile # Multi-Stage: Vue-Build + Runtime +├── docker-compose.yml +├── .env.example +├── docker/ +│ ├── entrypoint.sh # PUID/PGID, /config, Xvfb/x11vnc-Conf generieren +│ ├── supervisor.conf # Prozess-Manager für alle Dienste +│ ├── nginx.conf # Dashboard + /api + /websockify Proxy +│ └── mc-api/ # Node-Status-API (ohne externe Dependencies) +│ ├── server.js +│ └── package.json +└── web/ # Vite + Vue 3 + TypeScript Dashboard + ├── package.json · vite.config.ts · tsconfig.json + └── src/ + ├── App.vue · main.ts · api.ts · types.ts + ├── styles/main.css + └── components/ + ├── VncViewer.vue # noVNC-Integration + ├── ControlPanel.vue # MC Start/Stop, Schnellstart-Anleitung + └── StatusBar.vue # Status-Indikatoren +``` + +--- + +## Bekannte Einschränkungen + +- **Sound** ist im MVP nicht konfiguriert (für AFK-Farmen irrelevant). +- **Erster Login interaktiv**: der Microsoft-Login muss einmalig im VNC-Fenster + per Device-Code durchgeführt werden. Danach persistent. +- **EULA/Nutzungsbedingungen**: Dieses Projekt stellt nur die Infrastruktur + bereit. Du bist selbst für die Einhaltung der Mojang/Minecraft-Nutzungs- + bedingungen und der Regeln deines Zielservers verantwortlich. + +--- + +## Lizenz + +MIT — siehe `LICENSE` falls beigefügt. Minecraft ist Eigentum von Mojang/Microsoft; +dieses Projekt steht nicht in offizieller Verbindung. diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..286f8a5 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,37 @@ +# CTmine-client · ContainerMine Client +# Start: docker compose up -d --build +# Web: http://localhost:8080 + +services: + ctmine-client: + image: ctmine-client:latest + container_name: ctmine-client + build: + context: . + dockerfile: Dockerfile + restart: unless-stopped + + environment: + PUID: ${PUID:-1000} + PGID: ${PGID:-1000} + TZ: ${TZ:-Europe/Berlin} + DISPLAY_WIDTH: ${DISPLAY_WIDTH:-1280} + DISPLAY_HEIGHT: ${DISPLAY_HEIGHT:-720} + DISPLAY_REFRESH: ${DISPLAY_REFRESH:-60} + DISPLAY_DEPTH: ${DISPLAY_DEPTH:-24} + VNC_PASSWORD: ${VNC_PASSWORD:-} + NOVNC_PATH: ${NOVNC_PATH:-} + + ports: + - "${WEB_PORT:-8080}:8080" + + volumes: + # Persistente Daten: Microsoft-Account, Prism-Instanzen, .minecraft + - ./config:/config + + # Software-Rendering braucht Shared Memory für OpenGL. + shm_size: "1gb" + + #Optionale GPU-Beschleunigung (im Software-Rendering-Modus nicht nötig): + # devices: + # - /dev/dri:/dev/dri diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh new file mode 100644 index 0000000..909b1ce --- /dev/null +++ b/docker/entrypoint.sh @@ -0,0 +1,98 @@ +#!/usr/bin/env bash +# CTmine-client Entrypoint. +# Richtet den Runtime-Benutzer, /config und die display-bezogenen Konfigurationen +# ein und startet anschließend supervisord, der alle Prozesse verwaltet. +set -euo pipefail + +PUID="${PUID:-1000}" +PGID="${PGID:-1000}" +TZ="${TZ:-Europe/Berlin}" +CONFIG_DIR="${CONFIG_DIR:-/config}" +WEB_ROOT="${WEB_ROOT:-/var/www/ctmine}" + +# TimeZone setzen, sofern tzdata vorhanden. +if [ -f "/usr/share/zoneinfo/${TZ}" ]; then + ln -snf "/usr/share/zoneinfo/${TZ}" /etc/localtime + echo "${TZ}" > /etc/timezone +fi + +echo "[entrypoint] PUID=${PUID} PGID=${PGID} TZ=${TZ}" + +# --- /config anlegen und dem Runtime-Benutzer übergeben ------------------ +mkdir -p "${CONFIG_DIR}/prism" "${CONFIG_DIR}/.local" + +# Gruppe/Benutzer an PUID/PGID anpassen (der 'ctmine'-Benutzer wird im +# Dockerfile mit UID 1000 angelegt). So gehören Dateien im Volume dem +# Host-Benutzer. +if [ "$(id -u)" = "0" ]; then + groupmod -o -g "${PGID}" ctmine 2>/dev/null || true + usermod -o -u "${PUID}" -g "${PGID}" -d "${CONFIG_DIR}" ctmine 2>/dev/null || true + chown -R ctmine:ctmine "${CONFIG_DIR}" +fi + +# --- Display-Auflösung in Supervisor-Config schreiben -------------------- +# supervisord kann keine ENV-Substitution, darum generieren wir Xvfb-Start +# und x11vnc-Optionen hier als feste Dateien. +DISPLAY_WIDTH="${DISPLAY_WIDTH:-1280}" +DISPLAY_HEIGHT="${DISPLAY_HEIGHT:-720}" +DISPLAY_REFRESH="${DISPLAY_REFRESH:-60}" +DISPLAY_DEPTH="${DISPLAY_DEPTH:-24}" + +cat > /tmp/xvfb.conf < "${VNC_PWFILE}" + chmod 600 "${VNC_PWFILE}" + chown ctmine:ctmine "${VNC_PWFILE}" + VNC_PWFLAG="-rfbauth ${VNC_PWFILE}" +else + rm -f "${VNC_PWFILE}" + VNC_PWFLAG="-nopw" +fi + +cat > /tmp/x11vnc.conf </dev/null || true + +# Wenn Web-Root existiert, Rechte setzen. +if [ -d "${WEB_ROOT}" ]; then + chown -R ctmine:ctmine "${WEB_ROOT}" 2>/dev/null || true +fi + +# --- Prism beim ersten Start ins persistente Volume kopieren -------------- +# /opt/prism enthält die frische Launcher-Installation; beim ersten Start +# (oder nach Updates) wird sie nach /config/prism kopiert, damit Accounts +# und Instanzen persistent bleiben. +/opt/bootstrap-prism.sh +echo "[entrypoint] Prism Launcher unter ${CONFIG_DIR}/prism bereit." + +echo "[entrypoint] Starte supervisord …" +exec /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisord.conf diff --git a/docker/mc-api/package.json b/docker/mc-api/package.json new file mode 100644 index 0000000..8d11920 --- /dev/null +++ b/docker/mc-api/package.json @@ -0,0 +1,14 @@ +{ + "name": "ctmine-client-api", + "version": "0.1.0", + "private": true, + "description": "Status- und Steuerungs-API für CTmine-client", + "type": "module", + "main": "server.js", + "scripts": { + "start": "node server.js" + }, + "engines": { + "node": ">=20" + } +} diff --git a/docker/mc-api/server.js b/docker/mc-api/server.js new file mode 100644 index 0000000..8eb72cb --- /dev/null +++ b/docker/mc-api/server.js @@ -0,0 +1,178 @@ +// CTmine-client Status- und Steuerungs-API. +// Absichtlich ohne externe Abhängigkeiten (nur Node-Built-ins), damit der +// Container schlank bleibt und kein npm install im Runtime-Stage nötig ist. +// +// Endpunkte: +// GET /api/status → { display, vnc, minecraft, resolution, timestamp } +// POST /api/mc/start → startet Prism Launcher +// POST /api/mc/stop → beendet Prism + Minecraft +// +// nginx proxyt /api zu diesem Server (127.0.0.1:3000 im Container). + +import { createServer } from 'node:http' +import { exec, execFile, spawn } from 'node:child_process' +import { promisify } from 'node:util' + +const execAsync = promisify(exec) +const PORT = Number(process.env.MC_API_PORT || 3000) + +// --- Konfiguration aus Environment --------------------------------------- +const CONFIG_DIR = process.env.CONFIG_DIR || '/config' +const PRISM_DIR = process.env.PRISM_DIR || `${CONFIG_DIR}/prism` +const DISPLAY = process.env.DISPLAY || ':0' +const ACCOUNT = process.env.MC_ACCOUNT || '' // Profilname in Prism +const MC_VERSION = process.env.MC_VERSION || '' // optional, z.B. "1.21" + +// --- Hilfsfunktionen ----------------------------------------------------- + +/** + * Liefert true, wenn mindestens ein Prozess auf das Muster passt. + * Wichtig: pgrep -f per Shell matched sonst auf die Shell selbst, deren + * Kommandozeile das Pattern enthält. Wir filtern deshalb die Shell-basierten + * Aufrufe (sh/bash -c …) heraus und prüfen nur „echte“ Treffer. + */ +async function pgrep(pattern) { + try { + // -a gibt die volle Kommandozeile aus, sodass wir filtern können. + const { stdout } = await execAsync(`pgrep -fa '${pattern}' || true`) + const lines = stdout.trim().split('\n').filter(Boolean) + // Nur Treffer zählen, die nicht themselves die Such-Shell sind. + const real = lines.filter((line) => !/\b(?:sh|bash) -c .*pgrep/.test(line)) + return real.length > 0 + } catch { + return false + } +} + +/** Führt ein Kommando als der Runtime-Benutzer aus, falls PUID gesetzt ist. */ +function asUser(cmd) { + const uid = process.env.PUID + const gid = process.env.PGID + // Wenn wir root sind und PUID gesetzt ist → per runpuuids als Benutzer laufen. + if (process.getuid && process.getuid() === 0 && uid && gid) { + return `gosu ${uid}:${gid} bash -lc '${cmd.replace(/'/g, "'\\''")}'` + } + return cmd +} + +/** Sicheres JSON-Antwort-Helfer. */ +function sendJson(res, status, body) { + const payload = JSON.stringify(body) + res.writeHead(status, { + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(payload), + }) + res.end(payload) +} + +// --- Status -------------------------------------------------------------- + +async function readResolution() { + try { + const { stdout } = await execAsync('xdpyinfo -display ' + DISPLAY + ' 2>/dev/null | grep dimensions') + const m = /(\d+)x(\d+)/.exec(stdout) + if (m) return { width: Number(m[1]), height: Number(m[2]) } + } catch { + /* xdpyinfo nicht verfügbar */ + } + return undefined +} + +async function getStatus() { + const [display, vnc, minecraft, resolution] = await Promise.all([ + pgrep('Xvfb ' + DISPLAY), + pgrep('x11vnc'), + // Prism portable Binary heißt 'prismlauncher' (klein); der offizielle Build + // 'PrismLauncher'. Minecraft selbst läuft als java-Prozess mit net.minecraft. + pgrep('prismlauncher|PrismLauncher|java.*net\\.minecraft'), + readResolution(), + ]) + return { + display, + vnc, + minecraft, + resolution, + timestamp: Date.now(), + } +} + +// --- Minecraft-Steuerung ------------------------------------------------- + +async function startMinecraft() { + if (await pgrep('prismlauncher|PrismLauncher|java.*net\\.minecraft')) { + return { ok: true, message: 'Minecraft läuft bereits.' } + } + + // PrismLauncher im Headless-CLI-Modus gibt es nicht zuverlässig über alle + // Versionen. Wir starten daher die GUI-Anwendung auf dem virtuellen Display; + // der Benutzer interagiert dann per VNC. Das ist der robuste Weg. + // + // Die API läuft bereits als ctmine-Benutzer (via gosu in supervisor.conf), + // daher starten wir Prism direkt ohne weiteren gosu-Wrapper. + const child = spawn(PRISM_DIR + '/PrismLauncher', [], { + cwd: PRISM_DIR, + detached: true, + stdio: 'ignore', + env: { + ...process.env, + DISPLAY, + HOME: CONFIG_DIR, + }, + }) + child.on('error', () => { + /* wird über Status-Polling sichtbar */ + }) + child.unref() + + return { ok: true, message: 'Prism Launcher wird gestartet. Im VNC-Fenster sichtbar.' } +} + +async function stopMinecraft() { + // Zuerst sauber Prism schließen, dann Restprozesse killen. + try { + await execAsync("pkill -INT -f 'prismlauncher|PrismLauncher' || true") + await new Promise((r) => setTimeout(r, 1500)) + await execAsync("pkill -TERM -f 'prismlauncher|PrismLauncher|net.minecraft' || true") + } catch { + /* pkill liefert !=0 wenn nichts läuft — egal */ + } + return { ok: true, message: 'Minecraft wurde beendet.' } +} + +// --- HTTP-Server --------------------------------------------------------- + +const server = createServer(async (req, res) => { + // CORS frei für lokales Netz (Dashboard läuft gleicher Origin, aber sicher). + res.setHeader('Access-Control-Allow-Origin', '*') + res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS') + res.setHeader('Access-Control-Allow-Headers', 'Content-Type') + if (req.method === 'OPTIONS') { + res.writeHead(204) + return res.end() + } + + const url = new URL(req.url, `http://localhost:${PORT}`) + + try { + if (req.method === 'GET' && url.pathname === '/api/status') { + return sendJson(res, 200, await getStatus()) + } + + if (req.method === 'POST' && url.pathname === '/api/mc/start') { + return sendJson(res, 200, await startMinecraft()) + } + + if (req.method === 'POST' && url.pathname === '/api/mc/stop') { + return sendJson(res, 200, await stopMinecraft()) + } + + sendJson(res, 404, { ok: false, message: 'Not found' }) + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + sendJson(res, 500, { ok: false, message }) + } +}) + +server.listen(PORT, '127.0.0.1', () => { + console.log(`[ctmine-api] lauscht auf 127.0.0.1:${PORT}`) +}) diff --git a/docker/nginx.conf b/docker/nginx.conf new file mode 100644 index 0000000..0567126 --- /dev/null +++ b/docker/nginx.conf @@ -0,0 +1,66 @@ +# CTmine-client nginx-Konfiguration. +# Liefert das gebaute Vue-Dashboard aus und proxyt API + VNC-WebSocket. + +user ctmine; +worker_processes 1; +pid /run/nginx.pid; + +events { + worker_connections 1024; +} + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + + access_log /dev/stdout; + error_log /dev/stderr warn; + + sendfile on; + keepalive_timeout 65; + client_max_body_size 4m; + + server { + listen 8080 default_server; + listen [::]:8080 default_server; + server_name _; + + root /var/www/ctmine; + index index.html; + + # --- Vue SPA: Fallback auf index.html --------------------------- + location / { + try_files $uri $uri/ /index.html; + } + + # --- noVNC-Webclient (vom novnc-Debian-Paket unter /usr/share/novnc) - + # Der VncViewer-iframe lädt /novnc/vnc.html und verbindet sich dann + # selbsttätig über /websockify (s.u.). + location /novnc/ { + alias /usr/share/novnc/; + try_files $uri $uri/ =404; + } + + # --- Status-/Steuerungs-API → Node ------------------------------ + location /api/ { + proxy_pass http://127.0.0.1:3000; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + # --- VNC über WebSocket (websockify lauscht auf :6080) ---------- + # Wichtig: Upgrade-Header weiterreichen, sonst kein WS-Handshake. + location /websockify { + proxy_pass http://127.0.0.1:6080; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_read_timeout 3600s; + proxy_send_timeout 3600s; + } + } +} diff --git a/docker/supervisor.conf b/docker/supervisor.conf new file mode 100644 index 0000000..bef5789 --- /dev/null +++ b/docker/supervisor.conf @@ -0,0 +1,79 @@ +; CTmine-client — Haupt-Supervisor-Konfiguration. +; supervisord läuft als ROOT, damit es /dev/fd/1 (Container-stdout) nutzen und +; die Programme sauber verwalten kann. Jedes Programm startet seinen Prozess +; selbst als ctmine-Benutzer (per gosu), so dass die eigentliche Arbeit nie +; als root läuft. +; +; Die Xvfb- und x11vnc-Programmblöcke werden vom entrypoint.sh als +; /tmp/xvfb.conf und /tmp/x11vnc.conf generiert (wegen ENV-Substitution) +; und hier per include gezogen. + +[supervisord] +nodaemon=true +user=root +logfile=/dev/null +logfile_maxbytes=0 +pidfile=/var/run/supervisord.pid + +[unix_http_server] +file=/var/run/supervisor.sock +chmod=0700 + +[rpcinterface:supervisor] +supervisor.rpcinterface_factory = supervisor.rpcinterface:make_main_rpcinterface + +[supervisorctl] +serverurl=unix:///var/run/supervisor.sock + +; Von entrypoint.sh generierte Programmblöcke (Xvfb, x11vnc): +[include] +files = /tmp/xvfb.conf /tmp/x11vnc.conf + +; --- Windowmanager (sonst hat MC keinen Rahmen / Fokus) ------------------- +[program:openbox] +command=/usr/sbin/gosu ctmine /usr/bin/openbox +environment=DISPLAY=":0" +priority=30 +autostart=true +autorestart=true +stdout_logfile=/dev/fd/1 +stdout_logfile_maxbytes=0 +stderr_logfile=/dev/fd/2 +stderr_logfile_maxbytes=0 + +; --- websockify: VNC :5900 → WebSocket, von nginx unter /websockify exposed - +[program:websockify] +command=/usr/sbin/gosu ctmine /usr/bin/python3 -m websockify 0.0.0.0:6080 localhost:5900 --web=/usr/share/novnc +priority=40 +autostart=true +autorestart=true +stdout_logfile=/dev/fd/1 +stdout_logfile_maxbytes=0 +stderr_logfile=/dev/fd/2 +stderr_logfile_maxbytes=0 + +; --- Node Status-/Steuerungs-API ------------------------------------------ +[program:mc-api] +command=/usr/sbin/gosu ctmine /usr/bin/node /app/mc-api/server.js +directory=/app/mc-api +environment=DISPLAY=":0",HOME="/config",NODE_ENV="production" +priority=50 +autostart=true +autorestart=true +stdout_logfile=/dev/fd/1 +stdout_logfile_maxbytes=0 +stderr_logfile=/dev/fd/2 +stderr_logfile_maxbytes=0 + +; --- nginx: liefert Vue-Dashboard + proxyt /api und /websockify ----------- +; nginx muss als root starten (Port-Bind + Config), wechselt intern per +; 'user ctmine;' in der nginx.conf in den Worker-Prozessen. +[program:nginx] +command=/usr/sbin/nginx -g "daemon off;" +priority=60 +autostart=true +autorestart=true +stdout_logfile=/dev/fd/1 +stdout_logfile_maxbytes=0 +stderr_logfile=/dev/fd/2 +stderr_logfile_maxbytes=0 diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..c79eaed --- /dev/null +++ b/web/index.html @@ -0,0 +1,13 @@ + + + + + + + CTmine-client · ContainerMine + + +
+ + + diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..5f16791 --- /dev/null +++ b/web/package.json @@ -0,0 +1,23 @@ +{ + "name": "ctmine-client-web", + "private": true, + "version": "0.1.0", + "type": "module", + "description": "Dashboard für CTmine-client (ContainerMine Client)", + "scripts": { + "dev": "vite", + "build": "vue-tsc --noEmit && vite build", + "preview": "vite preview", + "type-check": "vue-tsc --noEmit" + }, + "dependencies": { + "vue": "^3.4.38" + }, + "devDependencies": { + "@types/node": "^22.5.0", + "@vitejs/plugin-vue": "^5.1.2", + "typescript": "^5.5.4", + "vite": "^5.4.2", + "vue-tsc": "^2.0.29" + } +} diff --git a/web/src/App.vue b/web/src/App.vue new file mode 100644 index 0000000..6351545 --- /dev/null +++ b/web/src/App.vue @@ -0,0 +1,136 @@ + + + + + diff --git a/web/src/api.ts b/web/src/api.ts new file mode 100644 index 0000000..f34ec18 --- /dev/null +++ b/web/src/api.ts @@ -0,0 +1,41 @@ +import type { ContainerStatus, McActionResponse } from './types' + +/** Relativer Basis-Pfad — im Container liefert nginx /api aus. */ +const BASE = import.meta.env.BASE_URL.replace(/\/$/, '') + +async function request(path: string, init?: RequestInit): Promise { + const res = await fetch(`${BASE}${path}`, { + ...init, + headers: { + 'Content-Type': 'application/json', + ...(init?.headers ?? {}), + }, + }) + if (!res.ok) { + let detail = `${res.status} ${res.statusText}` + try { + const body = await res.json() + if (body?.message) detail = body.message + } catch { + /* kein JSON-Body */ + } + throw new Error(detail) + } + // 204 → kein Body + if (res.status === 204) return undefined as T + return (await res.json()) as T +} + +export function getStatus(): Promise { + return request('/api/status') +} + +/** Startet Prism Launcher (falls nicht schon offen). */ +export function startMinecraft(): Promise { + return request('/api/mc/start', { method: 'POST' }) +} + +/** Beendet alle Minecraft-/Prism-Prozesse. */ +export function stopMinecraft(): Promise { + return request('/api/mc/stop', { method: 'POST' }) +} diff --git a/web/src/components/ControlPanel.vue b/web/src/components/ControlPanel.vue new file mode 100644 index 0000000..8e8e0ae --- /dev/null +++ b/web/src/components/ControlPanel.vue @@ -0,0 +1,212 @@ + + + + + diff --git a/web/src/components/StatusBar.vue b/web/src/components/StatusBar.vue new file mode 100644 index 0000000..bcb4022 --- /dev/null +++ b/web/src/components/StatusBar.vue @@ -0,0 +1,63 @@ + + + + + diff --git a/web/src/components/VncViewer.vue b/web/src/components/VncViewer.vue new file mode 100644 index 0000000..0cc947a --- /dev/null +++ b/web/src/components/VncViewer.vue @@ -0,0 +1,183 @@ + + + + + diff --git a/web/src/main.ts b/web/src/main.ts new file mode 100644 index 0000000..97128ba --- /dev/null +++ b/web/src/main.ts @@ -0,0 +1,5 @@ +import { createApp } from 'vue' +import App from './App.vue' +import './styles/main.css' + +createApp(App).mount('#app') diff --git a/web/src/shims-vue.d.ts b/web/src/shims-vue.d.ts new file mode 100644 index 0000000..2b97bd9 --- /dev/null +++ b/web/src/shims-vue.d.ts @@ -0,0 +1,5 @@ +declare module '*.vue' { + import type { DefineComponent } from 'vue' + const component: DefineComponent<{}, {}, any> + export default component +} diff --git a/web/src/styles/main.css b/web/src/styles/main.css new file mode 100644 index 0000000..777a2a5 --- /dev/null +++ b/web/src/styles/main.css @@ -0,0 +1,109 @@ +:root { + --bg: #0f1115; + --bg-elev: #161a22; + --bg-elev-2: #1d222d; + --border: #2a3140; + --text: #e6e9ef; + --text-dim: #9aa3b2; + --accent: #4ea34e; + --accent-hover: #5cbf5c; + --danger: #d4564f; + --warn: #d9a441; + --radius: 8px; + --font: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, + Arial, sans-serif; + --mono: ui-monospace, "SF Mono", "JetBrains Mono", Menlo, Consolas, monospace; +} + +* { + box-sizing: border-box; +} + +html, +body, +#app { + margin: 0; + height: 100%; + background: var(--bg); + color: var(--text); + font-family: var(--font); + font-size: 14px; + line-height: 1.5; +} + +button { + font-family: inherit; + font-size: 13px; + padding: 8px 14px; + border-radius: var(--radius); + border: 1px solid var(--border); + background: var(--bg-elev-2); + color: var(--text); + cursor: pointer; + transition: background 0.12s ease, border-color 0.12s ease; +} + +button:hover:not(:disabled) { + background: #262d3c; + border-color: #3a4253; +} + +button:disabled { + opacity: 0.45; + cursor: not-allowed; +} + +button.primary { + background: var(--accent); + border-color: var(--accent); + color: #0f1115; + font-weight: 600; +} + +button.primary:hover:not(:disabled) { + background: var(--accent-hover); + border-color: var(--accent-hover); +} + +button.danger { + border-color: var(--danger); + color: var(--danger); +} + +input { + font-family: inherit; + font-size: 13px; + padding: 8px 10px; + border-radius: var(--radius); + border: 1px solid var(--border); + background: var(--bg); + color: var(--text); + width: 100%; +} + +input:focus { + outline: none; + border-color: var(--accent); +} + +.dot { + display: inline-block; + width: 9px; + height: 9px; + border-radius: 50%; + vertical-align: middle; + margin-right: 6px; +} + +.dot.on { + background: var(--accent); + box-shadow: 0 0 6px var(--accent); +} + +.dot.off { + background: var(--text-dim); +} + +.dot.warn { + background: var(--warn); +} diff --git a/web/src/types.ts b/web/src/types.ts new file mode 100644 index 0000000..f84aaa2 --- /dev/null +++ b/web/src/types.ts @@ -0,0 +1,20 @@ +// Status-Modell, das die Backend-API (/api/status) liefert. +export interface ContainerStatus { + /** Läuft das virtuelle Display? */ + display: boolean + /** Läuft der VNC-Server? */ + vnc: boolean + /** Läuft Prism Launcher / ein Minecraft-Prozess? */ + minecraft: boolean + /** Letzte bekannte Bildschirmauflösung. */ + resolution?: { width: number; height: number } + /** UNIX-Zeitstempel der letzten Aktualisierung. */ + timestamp: number +} + +export type ConnectionState = 'disconnected' | 'connecting' | 'connected' | 'error' + +export interface McActionResponse { + ok: boolean + message: string +} diff --git a/web/src/vite-env.d.ts b/web/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/web/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/web/tsconfig.json b/web/tsconfig.json new file mode 100644 index 0000000..5e4ac17 --- /dev/null +++ b/web/tsconfig.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "Bundler", + "lib": ["ESNext", "DOM", "DOM.Iterable"], + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "jsx": "preserve", + "isolatedModules": true, + "esModuleInterop": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "useDefineForClassFields": true, + "allowImportingTsExtensions": false, + "verbatimModuleSyntax": true, + "noEmit": true, + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.vue", "vite.config.ts"], + "exclude": ["node_modules", "dist"] +} diff --git a/web/vite.config.ts b/web/vite.config.ts new file mode 100644 index 0000000..ae23805 --- /dev/null +++ b/web/vite.config.ts @@ -0,0 +1,31 @@ +import { fileURLToPath, URL } from 'node:url' +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' + +// Beim Entwickeln (außerhalb des Containers) läuft Vite unter :5173 und +// erwartet das Backend (nginx + websockify) unter :8080. In der Produktion +// liefert nginx das gebaute dist/ direkt aus, sodass keine Proxys nötig sind. +export default defineConfig({ + plugins: [vue()], + resolve: { + alias: { + '@': fileURLToPath(new URL('./src', import.meta.url)), + }, + }, + server: { + host: true, + port: 5173, + proxy: { + '/api': { target: 'http://localhost:8080', changeOrigin: true }, + '/websockify': { + target: 'ws://localhost:8080', + ws: true, + changeOrigin: true, + }, + }, + }, + build: { + outDir: 'dist', + emptyOutDir: true, + }, +})