Proyecto kiradesktop - escritorio virtual CPU/GPU para captura DRM

This commit is contained in:
Joaquin
2026-07-13 17:20:02 +02:00
commit ea5b82412f
6 changed files with 1556 additions and 0 deletions
+125
View File
@@ -0,0 +1,125 @@
# KiraDesktop
Sistema de escritorios virtuales remotos (X11 + XFCE) pensado para **captura de
streams con protección DRM (Widevine L3) reproducidos en Firefox**. Cada
instancia levanta un escritorio Linux headless, expone acceso VNC para
control/depuración y ofrece una API REST que produce un stream de vídeo+audio
en formato MPEG-TS (vía `ffmpeg`) listo para consumir con cualquier
reproductor/pipeline aguas abajo.
La idea general: como el contenido DRM no se puede volcar "en crudo" (el
decodificador protege los frames), se renderiza en una sesión X11 real dentro
de Firefox y se captura la salida de pantalla (X11 grab) + audio (PulseAudio
null-sink), re-codificando con `ffmpeg` a H.264/AAC en MPEG-TS. El resultado es
funcionalmente equivalente a "grabar la pantalla" del escritorio remoto.
## Arquitectura: variante CPU vs variante GPU
### Variante CPU (`cpu/`)
- Contenedores LXC (Proxmox `pct`), clonados: `kiradesktop-cpu-1` ...
`kiradesktop-cpu-12`. Son **12 instancias idénticas** en el cluster, cada
una con **1 escritorio** por contenedor.
- Servidor VNC: `Xtigervnc` (framebuffer virtual, no requiere GPU).
- Captura: `ffmpeg` con `-f x11grab` + encoder software `libx264` (preset
`superfast`, escalado a 1280x720 para aliviar la carga de CPU aunque el VNC
se sirve en 1080p).
- Cada contenedor corre un único proceso (`kiradesktop-cpu.py`) que gestiona
ese único escritorio (`/desktop/0/...`).
- Incluye `kiradesktop.py`, una versión anterior/backup del mismo script. Ver
sección "Diferencias" más abajo.
### Variante GPU (`gpu/`)
- Una VM KVM (`kirastream-gpu1`, Proxmox VM id 211) con GPU NVIDIA pasada por
passthrough/vGPU.
- Servidor VNC: `x11vnc` sobre sesiones `Xorg` reales con el driver NVIDIA
(permite aceleración GLX/VDPAU y decodificación por hardware NVDEC/VA-API
dentro de Firefox: `LIBVA_DRIVER_NAME=nvidia`, `MOZ_X11_EGL=1`).
- Captura: `ffmpeg` con `-f x11grab` + encoder por hardware `h264_nvenc`.
- Un único proceso (`kiradesktop-gpu.py`) gestiona **hasta 6 escritorios en
paralelo** (`/desktop/{0..5}/...`), cada uno con su propio display
(`:1`-`:6`), puerto VNC (`5901`-`5906`) y PulseAudio aislado.
## API REST (puerto 8900 en ambas variantes)
| Método | Ruta | Descripción |
|--------|-----------------------------------------|---------------------------------------|
| GET | `/health` | Estado de escritorio(s) |
| POST | `/desktop/{id}/start` | Arranca el escritorio `id` |
| POST | `/desktop/{id}/stop` | Detiene el escritorio `id` |
| GET | `/desktop/{id}/stream` | Stream MPEG-TS (multi-cliente) |
| GET | `/sync/firefox/export[/{slot}]` | Exporta el perfil de Firefox (.tar.gz)|
| POST | `/sync/firefox/import` | Importa un perfil de Firefox |
En la variante CPU `{id}` es siempre `0` (un solo escritorio por contenedor).
En la variante GPU `{id}` va de `0` a `5` (hasta 6 escritorios por VM).
## Puertos
- **8900/tcp** — API REST (FastAPI + uvicorn, `0.0.0.0:8900`).
- **5901/tcp** (y consecutivos `5902`-`5906` en GPU) — VNC directo al
framebuffer X11 de cada escritorio.
## Despliegue (systemd)
Cada instancia corre como servicio systemd (`kiradesktop.service` en CPU,
`kiradesktop-gpu.service` en GPU) con `Restart=always` y toda la configuración
por variables de entorno — así el mismo `.py` sirve para las 12 instancias CPU
clonadas y para la VM GPU, cambiando solo el entorno:
- `MY_IP` — IP pública/interna que se anuncia en las respuestas de la API
(`vnc_addr`, `stream_url`). **Debe fijarse por instancia** al clonar el
contenedor/VM (es lo único que realmente cambia entre clones CPU).
- `RESOLUTION`, `FRAMERATE`, `VIDEO_BITRATE`, `AUDIO_BITRATE` — parámetros de
codificación.
- `VNC_PORT` / `BASE_VNC_PORT` — puerto(s) VNC.
- `WM_CMD` — gestor de ventanas a lanzar (`startxfce4` por defecto).
- Variante GPU además: `NUM_DESKTOPS` (6), `BASE_DISPLAY`, `NVENC_PRESET`,
`XORG_CONFIG` (ruta al `xorg.conf` con el driver NVIDIA).
Instalación típica en cada nodo:
```bash
cp kiradesktop-cpu.py /opt/kiradesktop-cpu.py # o kiradesktop-gpu.py
cp kiradesktop.service /etc/systemd/system/ # o kiradesktop-gpu.service
systemctl daemon-reload
systemctl enable --now kiradesktop.service # o kiradesktop-gpu.service
```
## Diferencias entre `kiradesktop.py` y `kiradesktop-cpu.py`
`cpu/kiradesktop.py` es una **versión previa/backup** del script CPU actual
(`cpu/kiradesktop-cpu.py`), tal como se encontró en `/opt` del contenedor. Se
incluye igual por trazabilidad histórica, pero **el que está desplegado y en
uso (referenciado por el `.service`) es `kiradesktop-cpu.py`**.
La diferencia real entre ambos archivos es mínima:
- Se eliminó un comentario explicativo sobre el escalado a 720p.
- Se cambió el preset del encoder `libx264` de `ultrafast` a `superfast`
(mejor relación calidad/CPU a cambio de un poco más de carga).
El resto del código (gestión de PulseAudio, Xtigervnc, ffmpeg, API FastAPI,
export/import de perfil de Firefox) es idéntico.
## Nota de seguridad (importante para un despliegue real)
Los servidores VNC de ambas variantes se levantan **sin contraseña**
(`Xtigervnc -SecurityTypes None` en CPU, `x11vnc -nopw` en GPU) y la API REST
no implementa autenticación. Esto asume que el servicio corre en una red
interna/confiable (o detrás de un firewall/VPN/reverse-proxy que añada auth).
**No exponer el puerto 8900 ni los puertos VNC directamente a Internet** sin
añadir una capa de autenticación (por ejemplo, un proxy con Basic Auth o un
túnel VPN) delante de ambos servicios.
No se encontraron contraseñas, tokens ni claves de API hardcodeadas en el
código (se auditó con `grep -iE "password|secret|token|key"` antes de subir
el repo).
## Requisitos
- Python 3 con `fastapi`, `uvicorn`.
- `ffmpeg` (con soporte `libx264` en CPU, o `h264_nvenc`/driver NVIDIA en GPU).
- `pulseaudio`, `dbus-launch`, XFCE (`startxfce4`) o el WM que se configure.
- CPU: `Xtigervnc`. GPU: `Xorg` + driver propietario NVIDIA + `x11vnc`.
+411
View File
@@ -0,0 +1,411 @@
#!/usr/bin/env python3
"""
kiradesktop-cpu.py — Gestor de escritorio virtual CPU para KiraStream
Gestiona 1 escritorio X11 virtual con VNC y streaming MPEG-TS via libx264.
Diseñado para captura de contenido DRM (Widevine L3 en Firefox).
API en puerto 8900:
GET /health → estado del escritorio
GET /desktop/0/stream → stream MPEG-TS
POST /desktop/0/start → arrancar escritorio
POST /desktop/0/stop → parar escritorio
"""
import asyncio
import io
import logging
import os
import shutil
import tarfile
import time
from typing import Optional, Set
import uvicorn
from fastapi import FastAPI, HTTPException, Request
from fastapi.responses import Response, StreamingResponse
RESOLUTION = os.getenv("RESOLUTION", "1920x1080")
FRAMERATE = int(os.getenv("FRAMERATE", "30"))
VIDEO_BITRATE = os.getenv("VIDEO_BITRATE", "4M")
AUDIO_BITRATE = os.getenv("AUDIO_BITRATE", "128k")
XVNC_BIN = os.getenv("XVNC_BIN", "Xtigervnc")
FFMPEG_BIN = os.getenv("FFMPEG_BIN", "ffmpeg")
MY_IP = os.getenv("MY_IP", "10.10.10.220")
VNC_PORT = int(os.getenv("VNC_PORT", "5901"))
X_DISPLAY = os.getenv("X_DISPLAY", ":1")
WM_CMD = os.getenv("WM_CMD", "startxfce4")
logging.basicConfig(level=logging.INFO,
format="%(asctime)s %(levelname)s %(name)s %(message)s")
logger = logging.getLogger("kiradesktop-cpu")
PA_DIR = "/tmp/pa-desktop"
HOME_DIR = "/tmp/home-desktop"
def _double(b: str) -> str:
try:
return f"{int(b[:-1]) * 2}{b[-1]}"
except Exception:
return "8M"
async def _drain(proc: asyncio.subprocess.Process, label: str) -> None:
try:
async for line in proc.stderr:
txt = line.decode(errors="replace").rstrip()
if txt:
logger.debug("[%s] %s", label, txt)
except Exception:
pass
async def _wait_path(path: str, timeout: float = 10.0) -> bool:
deadline = time.time() + timeout
while time.time() < deadline:
if os.path.exists(path):
return True
await asyncio.sleep(0.3)
return False
class DesktopSlot:
def __init__(self):
self.id = 0
self.pa_dir = PA_DIR
self.home_dir = HOME_DIR
self._xvnc : Optional[asyncio.subprocess.Process] = None
self._wm : Optional[asyncio.subprocess.Process] = None
self._pa : Optional[asyncio.subprocess.Process] = None
self._ffmpeg: Optional[asyncio.subprocess.Process] = None
self._tasks : list[asyncio.Task] = []
self._fan : Optional[asyncio.Task] = None
self._clients: Set[asyncio.Queue] = set()
self._lock = asyncio.Lock()
@property
def running(self) -> bool:
return self._xvnc is not None and self._xvnc.returncode is None
@property
def streaming(self) -> bool:
return self._ffmpeg is not None and self._ffmpeg.returncode is None
@property
def stream_url(self) -> str:
return f"http://{MY_IP}:8900/desktop/0/stream"
def _env(self, extra: dict | None = None) -> dict:
e = {**os.environ,
"DISPLAY": X_DISPLAY,
"PULSE_SERVER": f"unix:{self.pa_dir}/native",
"PULSE_RUNTIME_PATH": self.pa_dir,
"HOME": self.home_dir,
"DBUS_SESSION_BUS_ADDRESS": ""}
if extra:
e.update(extra)
return e
def _setup_pulseaudio_config(self) -> None:
"""Create PulseAudio config with null-sink for LXC (no hardware audio)."""
pa_config_dir = f"{self.home_dir}/.config/pulse"
os.makedirs(pa_config_dir, mode=0o700, exist_ok=True)
default_pa = f"{pa_config_dir}/default.pa"
with open(default_pa, "w") as f:
f.write(
".nofail\n"
"load-module module-null-sink sink_name=virtual sink_properties=device.description=Virtual\n"
"set-default-sink virtual\n"
"set-default-source virtual.monitor\n"
"load-module module-native-protocol-unix\n"
)
async def start(self) -> dict:
async with self._lock:
if self.running:
return {"ok": True, "already": True}
os.makedirs(self.pa_dir, mode=0o700, exist_ok=True)
os.makedirs(self.home_dir, mode=0o700, exist_ok=True)
# PulseAudio with null-sink (no ALSA hardware in LXC)
self._setup_pulseaudio_config()
self._pa = await asyncio.create_subprocess_exec(
"pulseaudio", "--start", "--exit-idle-time=-1",
env={**os.environ, "PULSE_RUNTIME_PATH": self.pa_dir, "HOME": self.home_dir},
stdout=asyncio.subprocess.DEVNULL,
stderr=asyncio.subprocess.DEVNULL,
)
await _wait_path(f"{self.pa_dir}/native", timeout=8.0)
# Xtigervnc
self._xvnc = await asyncio.create_subprocess_exec(
XVNC_BIN, X_DISPLAY,
"-rfbport", str(VNC_PORT),
"-SecurityTypes", "None",
"-localhost", "no",
"-geometry", RESOLUTION,
"-depth", "24", "-dpi", "96",
"-AlwaysShared",
stderr=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.DEVNULL,
)
self._tasks.append(asyncio.create_task(_drain(self._xvnc, "xvnc")))
await asyncio.sleep(1.5)
if self._xvnc.returncode is not None:
raise RuntimeError(f"Xtigervnc exited with code {self._xvnc.returncode}")
# Window manager
self._wm = await asyncio.create_subprocess_exec(
"dbus-launch", "--exit-with-session", *WM_CMD.split(),
env=self._env(),
stdout=asyncio.subprocess.DEVNULL,
stderr=asyncio.subprocess.PIPE,
)
self._tasks.append(asyncio.create_task(_drain(self._wm, "wm")))
await asyncio.sleep(3.0)
# ffmpeg captura CPU
await self._start_ffmpeg()
logger.info("Desktop started — display=%s vnc=%d stream=%s",
X_DISPLAY, VNC_PORT, self.stream_url)
return {
"ok": True, "display": X_DISPLAY,
"vnc_port": VNC_PORT, "vnc_addr": f"{MY_IP}:{VNC_PORT}",
"stream_url": self.stream_url,
}
async def _start_ffmpeg(self):
cmd = [
FFMPEG_BIN, "-hide_banner", "-loglevel", "warning",
# Audio PRIMERO: PulseAudio debe estar listo antes de abrir x11grab
"-f", "pulse", "-ac", "2",
"-fragment_size", "7680",
"-thread_queue_size", "512",
"-use_wallclock_as_timestamps", "1",
"-i", "default",
# Video SEGUNDO
"-f", "x11grab", "-draw_mouse", "1",
"-framerate", str(FRAMERATE),
"-video_size", RESOLUTION,
"-thread_queue_size", "512",
"-use_wallclock_as_timestamps", "1",
"-i", f"{X_DISPLAY}+0,0",
"-map", "0:a",
"-map", "1:v",
"-vf", "scale=1280:720",
"-c:v", "libx264", "-preset", "superfast", "-tune", "zerolatency",
"-pix_fmt", "yuv420p",
"-b:v", VIDEO_BITRATE, "-maxrate", VIDEO_BITRATE,
"-bufsize", VIDEO_BITRATE,
"-g", "30", "-keyint_min", "15",
"-c:a", "aac", "-b:a", AUDIO_BITRATE, "-ar", "48000", "-ac", "2",
"-muxdelay", "0", "-muxpreload", "0",
"-f", "mpegts",
"pipe:1",
]
self._ffmpeg = await asyncio.create_subprocess_exec(
*cmd, env=self._env(),
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
self._tasks.append(asyncio.create_task(_drain(self._ffmpeg, "ffmpeg")))
self._fan = asyncio.create_task(self._fan_out())
logger.info("ffmpeg capture started (pid=%d)", self._ffmpeg.pid)
async def _fan_out(self):
try:
while True:
chunk = await self._ffmpeg.stdout.read(65536)
if not chunk:
break
dead: Set[asyncio.Queue] = set()
for q in list(self._clients):
try:
q.put_nowait(chunk)
except asyncio.QueueFull:
dead.add(q)
for q in dead:
self._clients.discard(q)
try:
q.put_nowait(None)
except Exception:
pass
except Exception as e:
logger.warning("fan_out: %s", e)
finally:
for q in list(self._clients):
try:
q.put_nowait(None)
except Exception:
pass
self._clients.clear()
async def stop(self) -> dict:
async with self._lock:
if not self.running:
return {"ok": True, "already_stopped": True}
logger.info("Stopping desktop...")
for proc in (self._ffmpeg, self._wm, self._xvnc):
if proc and proc.returncode is None:
try:
proc.kill()
await asyncio.wait_for(proc.wait(), timeout=3.0)
except Exception:
pass
try:
import subprocess
subprocess.run(["pulseaudio", "--kill"],
env={**os.environ, "PULSE_RUNTIME_PATH": self.pa_dir,
"HOME": self.home_dir},
timeout=3, capture_output=True)
except Exception:
pass
for t in self._tasks:
t.cancel()
if self._fan:
self._fan.cancel()
self._tasks.clear()
self._fan = None
self._xvnc = self._wm = self._pa = self._ffmpeg = None
logger.info("Desktop stopped")
return {"ok": True}
def status(self) -> dict:
return {
"id": 0, "display": X_DISPLAY,
"vnc_port": VNC_PORT, "vnc_addr": f"{MY_IP}:{VNC_PORT}",
"running": self.running, "streaming": self.streaming,
"clients": len(self._clients), "stream_url": self.stream_url,
}
slot = DesktopSlot()
app = FastAPI(title="KiraDesktop CPU Daemon", version="1.0")
@app.get("/health")
async def health():
return {
"node": f"kiradesktop-cpu ({MY_IP})",
"type": "cpu",
"desktops": 1,
"resolution": RESOLUTION,
"desktop_status": [slot.status()],
}
@app.post("/desktop/0/start")
async def start_desktop():
try:
return await slot.start()
except Exception as e:
logger.error("start: %s", e)
raise HTTPException(500, str(e))
@app.post("/desktop/0/stop")
async def stop_desktop():
return await slot.stop()
@app.get("/desktop/0/stream")
async def stream_desktop():
if not slot.streaming:
raise HTTPException(503, "Desktop no está en streaming")
q: asyncio.Queue = asyncio.Queue(maxsize=128)
slot._clients.add(q)
async def generator():
try:
while True:
chunk = await q.get()
if chunk is None:
break
yield chunk
except asyncio.CancelledError:
pass
finally:
slot._clients.discard(q)
return StreamingResponse(generator(), media_type="video/mp2t")
@app.get("/sync/firefox/export")
async def export_firefox():
mozilla_dir = f"{HOME_DIR}/.mozilla"
if not os.path.exists(mozilla_dir):
raise HTTPException(404, "No Firefox profile found")
buf = io.BytesIO()
with tarfile.open(fileobj=buf, mode="w:gz") as tar:
tar.add(mozilla_dir, arcname=".mozilla")
return Response(
content=buf.getvalue(),
media_type="application/octet-stream",
headers={"Content-Disposition": "attachment; filename=firefox-profile.tar.gz"},
)
@app.post("/sync/firefox/import")
async def import_firefox(request: Request):
data = await request.body()
if not data:
raise HTTPException(400, "No data received")
mozilla_dir = f"{HOME_DIR}/.mozilla"
os.makedirs(HOME_DIR, exist_ok=True)
if os.path.exists(mozilla_dir):
shutil.rmtree(mozilla_dir)
with tarfile.open(fileobj=io.BytesIO(data), mode="r:gz") as tar:
tar.extractall(HOME_DIR)
_fix_firefox_profile(mozilla_dir)
return {"ok": True}
def _fix_firefox_profile(mozilla_dir: str) -> None:
ff_dir = f"{mozilla_dir}/firefox"
if not os.path.isdir(ff_dir):
return
# Convert absolute paths → relative in profiles.ini
profiles_ini = f"{ff_dir}/profiles.ini"
if os.path.exists(profiles_ini):
lines = []
in_profile = False
with open(profiles_ini) as f:
for line in f:
s = line.strip()
if s.startswith("[") and s.endswith("]"):
in_profile = s.startswith("[Profile")
if in_profile and s.startswith("IsRelative=0"):
lines.append("IsRelative=1\n")
elif in_profile and s.startswith("Path=") and len(s) > 5 and s[5] == "/":
lines.append(f"Path={os.path.basename(s[5:].rstrip('/'))}\n")
else:
lines.append(line)
with open(profiles_ini, "w") as f:
f.writelines(lines)
# Fix installs.ini absolute paths
installs_ini = f"{ff_dir}/installs.ini"
if os.path.exists(installs_ini):
lines = []
with open(installs_ini) as f:
for line in f:
s = line.strip()
if s.startswith("Default=") and len(s) > 8 and s[8] == "/":
lines.append(f"Default={os.path.basename(s[8:].rstrip('/'))}\n")
else:
lines.append(line)
with open(installs_ini, "w") as f:
f.writelines(lines)
# Remove lock files
import glob
for profile_dir in glob.glob(f"{ff_dir}/*/"):
for name in ("lock", ".parentlock", ".parentlock.lck"):
try:
os.remove(os.path.join(profile_dir, name))
except OSError:
pass
if __name__ == "__main__":
uvicorn.run(app, host="0.0.0.0", port=8900, workers=1)
+412
View File
@@ -0,0 +1,412 @@
#!/usr/bin/env python3
"""
kiradesktop-cpu.py — Gestor de escritorio virtual CPU para KiraStream
Gestiona 1 escritorio X11 virtual con VNC y streaming MPEG-TS via libx264.
Diseñado para captura de contenido DRM (Widevine L3 en Firefox).
API en puerto 8900:
GET /health → estado del escritorio
GET /desktop/0/stream → stream MPEG-TS
POST /desktop/0/start → arrancar escritorio
POST /desktop/0/stop → parar escritorio
"""
import asyncio
import io
import logging
import os
import shutil
import tarfile
import time
from typing import Optional, Set
import uvicorn
from fastapi import FastAPI, HTTPException, Request
from fastapi.responses import Response, StreamingResponse
RESOLUTION = os.getenv("RESOLUTION", "1920x1080")
FRAMERATE = int(os.getenv("FRAMERATE", "30"))
VIDEO_BITRATE = os.getenv("VIDEO_BITRATE", "4M")
AUDIO_BITRATE = os.getenv("AUDIO_BITRATE", "128k")
XVNC_BIN = os.getenv("XVNC_BIN", "Xtigervnc")
FFMPEG_BIN = os.getenv("FFMPEG_BIN", "ffmpeg")
MY_IP = os.getenv("MY_IP", "10.10.10.220")
VNC_PORT = int(os.getenv("VNC_PORT", "5901"))
X_DISPLAY = os.getenv("X_DISPLAY", ":1")
WM_CMD = os.getenv("WM_CMD", "startxfce4")
logging.basicConfig(level=logging.INFO,
format="%(asctime)s %(levelname)s %(name)s %(message)s")
logger = logging.getLogger("kiradesktop-cpu")
PA_DIR = "/tmp/pa-desktop"
HOME_DIR = "/tmp/home-desktop"
def _double(b: str) -> str:
try:
return f"{int(b[:-1]) * 2}{b[-1]}"
except Exception:
return "8M"
async def _drain(proc: asyncio.subprocess.Process, label: str) -> None:
try:
async for line in proc.stderr:
txt = line.decode(errors="replace").rstrip()
if txt:
logger.debug("[%s] %s", label, txt)
except Exception:
pass
async def _wait_path(path: str, timeout: float = 10.0) -> bool:
deadline = time.time() + timeout
while time.time() < deadline:
if os.path.exists(path):
return True
await asyncio.sleep(0.3)
return False
class DesktopSlot:
def __init__(self):
self.id = 0
self.pa_dir = PA_DIR
self.home_dir = HOME_DIR
self._xvnc : Optional[asyncio.subprocess.Process] = None
self._wm : Optional[asyncio.subprocess.Process] = None
self._pa : Optional[asyncio.subprocess.Process] = None
self._ffmpeg: Optional[asyncio.subprocess.Process] = None
self._tasks : list[asyncio.Task] = []
self._fan : Optional[asyncio.Task] = None
self._clients: Set[asyncio.Queue] = set()
self._lock = asyncio.Lock()
@property
def running(self) -> bool:
return self._xvnc is not None and self._xvnc.returncode is None
@property
def streaming(self) -> bool:
return self._ffmpeg is not None and self._ffmpeg.returncode is None
@property
def stream_url(self) -> str:
return f"http://{MY_IP}:8900/desktop/0/stream"
def _env(self, extra: dict | None = None) -> dict:
e = {**os.environ,
"DISPLAY": X_DISPLAY,
"PULSE_SERVER": f"unix:{self.pa_dir}/native",
"PULSE_RUNTIME_PATH": self.pa_dir,
"HOME": self.home_dir,
"DBUS_SESSION_BUS_ADDRESS": ""}
if extra:
e.update(extra)
return e
def _setup_pulseaudio_config(self) -> None:
"""Create PulseAudio config with null-sink for LXC (no hardware audio)."""
pa_config_dir = f"{self.home_dir}/.config/pulse"
os.makedirs(pa_config_dir, mode=0o700, exist_ok=True)
default_pa = f"{pa_config_dir}/default.pa"
with open(default_pa, "w") as f:
f.write(
".nofail\n"
"load-module module-null-sink sink_name=virtual sink_properties=device.description=Virtual\n"
"set-default-sink virtual\n"
"set-default-source virtual.monitor\n"
"load-module module-native-protocol-unix\n"
)
async def start(self) -> dict:
async with self._lock:
if self.running:
return {"ok": True, "already": True}
os.makedirs(self.pa_dir, mode=0o700, exist_ok=True)
os.makedirs(self.home_dir, mode=0o700, exist_ok=True)
# PulseAudio with null-sink (no ALSA hardware in LXC)
self._setup_pulseaudio_config()
self._pa = await asyncio.create_subprocess_exec(
"pulseaudio", "--start", "--exit-idle-time=-1",
env={**os.environ, "PULSE_RUNTIME_PATH": self.pa_dir, "HOME": self.home_dir},
stdout=asyncio.subprocess.DEVNULL,
stderr=asyncio.subprocess.DEVNULL,
)
await _wait_path(f"{self.pa_dir}/native", timeout=8.0)
# Xtigervnc
self._xvnc = await asyncio.create_subprocess_exec(
XVNC_BIN, X_DISPLAY,
"-rfbport", str(VNC_PORT),
"-SecurityTypes", "None",
"-localhost", "no",
"-geometry", RESOLUTION,
"-depth", "24", "-dpi", "96",
"-AlwaysShared",
stderr=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.DEVNULL,
)
self._tasks.append(asyncio.create_task(_drain(self._xvnc, "xvnc")))
await asyncio.sleep(1.5)
if self._xvnc.returncode is not None:
raise RuntimeError(f"Xtigervnc exited with code {self._xvnc.returncode}")
# Window manager
self._wm = await asyncio.create_subprocess_exec(
"dbus-launch", "--exit-with-session", *WM_CMD.split(),
env=self._env(),
stdout=asyncio.subprocess.DEVNULL,
stderr=asyncio.subprocess.PIPE,
)
self._tasks.append(asyncio.create_task(_drain(self._wm, "wm")))
await asyncio.sleep(3.0)
# ffmpeg captura CPU
await self._start_ffmpeg()
logger.info("Desktop started — display=%s vnc=%d stream=%s",
X_DISPLAY, VNC_PORT, self.stream_url)
return {
"ok": True, "display": X_DISPLAY,
"vnc_port": VNC_PORT, "vnc_addr": f"{MY_IP}:{VNC_PORT}",
"stream_url": self.stream_url,
}
async def _start_ffmpeg(self):
cmd = [
FFMPEG_BIN, "-hide_banner", "-loglevel", "warning",
# Audio PRIMERO: PulseAudio debe estar listo antes de abrir x11grab
"-f", "pulse", "-ac", "2",
"-fragment_size", "7680",
"-thread_queue_size", "512",
"-use_wallclock_as_timestamps", "1",
"-i", "default",
# Video SEGUNDO
"-f", "x11grab", "-draw_mouse", "1",
"-framerate", str(FRAMERATE),
"-video_size", RESOLUTION,
"-thread_queue_size", "512",
"-use_wallclock_as_timestamps", "1",
"-i", f"{X_DISPLAY}+0,0",
"-map", "0:a",
"-map", "1:v",
# Escalar a 720p para reducir carga CPU del encoder (VNC sigue en 1080p)
"-vf", "scale=1280:720",
"-c:v", "libx264", "-preset", "ultrafast", "-tune", "zerolatency",
"-pix_fmt", "yuv420p",
"-b:v", VIDEO_BITRATE, "-maxrate", VIDEO_BITRATE,
"-bufsize", VIDEO_BITRATE,
"-g", "30", "-keyint_min", "15",
"-c:a", "aac", "-b:a", AUDIO_BITRATE, "-ar", "48000", "-ac", "2",
"-muxdelay", "0", "-muxpreload", "0",
"-f", "mpegts",
"pipe:1",
]
self._ffmpeg = await asyncio.create_subprocess_exec(
*cmd, env=self._env(),
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
self._tasks.append(asyncio.create_task(_drain(self._ffmpeg, "ffmpeg")))
self._fan = asyncio.create_task(self._fan_out())
logger.info("ffmpeg capture started (pid=%d)", self._ffmpeg.pid)
async def _fan_out(self):
try:
while True:
chunk = await self._ffmpeg.stdout.read(65536)
if not chunk:
break
dead: Set[asyncio.Queue] = set()
for q in list(self._clients):
try:
q.put_nowait(chunk)
except asyncio.QueueFull:
dead.add(q)
for q in dead:
self._clients.discard(q)
try:
q.put_nowait(None)
except Exception:
pass
except Exception as e:
logger.warning("fan_out: %s", e)
finally:
for q in list(self._clients):
try:
q.put_nowait(None)
except Exception:
pass
self._clients.clear()
async def stop(self) -> dict:
async with self._lock:
if not self.running:
return {"ok": True, "already_stopped": True}
logger.info("Stopping desktop...")
for proc in (self._ffmpeg, self._wm, self._xvnc):
if proc and proc.returncode is None:
try:
proc.kill()
await asyncio.wait_for(proc.wait(), timeout=3.0)
except Exception:
pass
try:
import subprocess
subprocess.run(["pulseaudio", "--kill"],
env={**os.environ, "PULSE_RUNTIME_PATH": self.pa_dir,
"HOME": self.home_dir},
timeout=3, capture_output=True)
except Exception:
pass
for t in self._tasks:
t.cancel()
if self._fan:
self._fan.cancel()
self._tasks.clear()
self._fan = None
self._xvnc = self._wm = self._pa = self._ffmpeg = None
logger.info("Desktop stopped")
return {"ok": True}
def status(self) -> dict:
return {
"id": 0, "display": X_DISPLAY,
"vnc_port": VNC_PORT, "vnc_addr": f"{MY_IP}:{VNC_PORT}",
"running": self.running, "streaming": self.streaming,
"clients": len(self._clients), "stream_url": self.stream_url,
}
slot = DesktopSlot()
app = FastAPI(title="KiraDesktop CPU Daemon", version="1.0")
@app.get("/health")
async def health():
return {
"node": f"kiradesktop-cpu ({MY_IP})",
"type": "cpu",
"desktops": 1,
"resolution": RESOLUTION,
"desktop_status": [slot.status()],
}
@app.post("/desktop/0/start")
async def start_desktop():
try:
return await slot.start()
except Exception as e:
logger.error("start: %s", e)
raise HTTPException(500, str(e))
@app.post("/desktop/0/stop")
async def stop_desktop():
return await slot.stop()
@app.get("/desktop/0/stream")
async def stream_desktop():
if not slot.streaming:
raise HTTPException(503, "Desktop no está en streaming")
q: asyncio.Queue = asyncio.Queue(maxsize=128)
slot._clients.add(q)
async def generator():
try:
while True:
chunk = await q.get()
if chunk is None:
break
yield chunk
except asyncio.CancelledError:
pass
finally:
slot._clients.discard(q)
return StreamingResponse(generator(), media_type="video/mp2t")
@app.get("/sync/firefox/export")
async def export_firefox():
mozilla_dir = f"{HOME_DIR}/.mozilla"
if not os.path.exists(mozilla_dir):
raise HTTPException(404, "No Firefox profile found")
buf = io.BytesIO()
with tarfile.open(fileobj=buf, mode="w:gz") as tar:
tar.add(mozilla_dir, arcname=".mozilla")
return Response(
content=buf.getvalue(),
media_type="application/octet-stream",
headers={"Content-Disposition": "attachment; filename=firefox-profile.tar.gz"},
)
@app.post("/sync/firefox/import")
async def import_firefox(request: Request):
data = await request.body()
if not data:
raise HTTPException(400, "No data received")
mozilla_dir = f"{HOME_DIR}/.mozilla"
os.makedirs(HOME_DIR, exist_ok=True)
if os.path.exists(mozilla_dir):
shutil.rmtree(mozilla_dir)
with tarfile.open(fileobj=io.BytesIO(data), mode="r:gz") as tar:
tar.extractall(HOME_DIR)
_fix_firefox_profile(mozilla_dir)
return {"ok": True}
def _fix_firefox_profile(mozilla_dir: str) -> None:
ff_dir = f"{mozilla_dir}/firefox"
if not os.path.isdir(ff_dir):
return
# Convert absolute paths → relative in profiles.ini
profiles_ini = f"{ff_dir}/profiles.ini"
if os.path.exists(profiles_ini):
lines = []
in_profile = False
with open(profiles_ini) as f:
for line in f:
s = line.strip()
if s.startswith("[") and s.endswith("]"):
in_profile = s.startswith("[Profile")
if in_profile and s.startswith("IsRelative=0"):
lines.append("IsRelative=1\n")
elif in_profile and s.startswith("Path=") and len(s) > 5 and s[5] == "/":
lines.append(f"Path={os.path.basename(s[5:].rstrip('/'))}\n")
else:
lines.append(line)
with open(profiles_ini, "w") as f:
f.writelines(lines)
# Fix installs.ini absolute paths
installs_ini = f"{ff_dir}/installs.ini"
if os.path.exists(installs_ini):
lines = []
with open(installs_ini) as f:
for line in f:
s = line.strip()
if s.startswith("Default=") and len(s) > 8 and s[8] == "/":
lines.append(f"Default={os.path.basename(s[8:].rstrip('/'))}\n")
else:
lines.append(line)
with open(installs_ini, "w") as f:
f.writelines(lines)
# Remove lock files
import glob
for profile_dir in glob.glob(f"{ff_dir}/*/"):
for name in ("lock", ".parentlock", ".parentlock.lck"):
try:
os.remove(os.path.join(profile_dir, name))
except OSError:
pass
if __name__ == "__main__":
uvicorn.run(app, host="0.0.0.0", port=8900, workers=1)
+23
View File
@@ -0,0 +1,23 @@
[Unit]
Description=KiraDesktop CPU - Virtual Desktop Streamer
After=network.target
[Service]
Type=simple
WorkingDirectory=/opt
Environment=MY_IP=10.10.10.220
Environment=VNC_PORT=5901
Environment=X_DISPLAY=:1
Environment=RESOLUTION=1920x1080
Environment=FRAMERATE=30
Environment=VIDEO_BITRATE=4M
Environment=AUDIO_BITRATE=128k
Environment=WM_CMD=startxfce4
ExecStart=/usr/bin/python3 /opt/kiradesktop-cpu.py
Restart=always
RestartSec=5
KillSignal=SIGTERM
TimeoutStopSec=15
[Install]
WantedBy=multi-user.target
+560
View File
@@ -0,0 +1,560 @@
#!/usr/bin/env python3
"""
kiradesktop-gpu.py — Gestor de escritorios virtuales GPU para kirastream-gpu1
Gestiona hasta 6 escritorios X11 con Xorg+NVIDIA real, VNC via x11vnc y
streaming MPEG-TS via h264_nvenc. Firefox usa GPU para render y NVDEC (VA-API).
API en puerto 8900:
GET /health → estado de todos los escritorios
GET /desktop/{id}/stream → stream MPEG-TS (soporta múltiples clientes)
POST /desktop/{id}/start → arrancar escritorio N
POST /desktop/{id}/stop → parar escritorio N
"""
import asyncio
import io
import logging
import os
import shutil
import tarfile
import time
from typing import Optional, Set
import uvicorn
from fastapi import FastAPI, HTTPException, Request
from fastapi.responses import Response, StreamingResponse
# ── Configuración ─────────────────────────────────────────────────────────────
NUM_DESKTOPS = int(os.getenv("NUM_DESKTOPS", "6"))
BASE_DISPLAY = int(os.getenv("BASE_DISPLAY", "1")) # :1 – :6
BASE_VNC_PORT = int(os.getenv("BASE_VNC_PORT", "5901")) # 5901 – 5906
RESOLUTION = os.getenv("RESOLUTION", "1920x1080")
FRAMERATE = int(os.getenv("FRAMERATE", "30"))
VIDEO_BITRATE = os.getenv("VIDEO_BITRATE", "8M")
AUDIO_BITRATE = os.getenv("AUDIO_BITRATE", "128k")
NVENC_PRESET = os.getenv("NVENC_PRESET", "p4")
FFMPEG_BIN = os.getenv("FFMPEG_BIN", "ffmpeg")
MY_IP = os.getenv("MY_IP", "10.10.10.239")
WM_CMD = os.getenv("WM_CMD", "startxfce4")
XORG_CONFIG = os.getenv("XORG_CONFIG", "/etc/X11/xorg-virtual.conf")
logging.basicConfig(level=logging.INFO,
format="%(asctime)s %(levelname)s %(name)s %(message)s")
logger = logging.getLogger("kiradesktop-gpu")
# ── Helpers ───────────────────────────────────────────────────────────────────
def _double(b: str) -> str:
try:
return f"{int(b[:-1]) * 2}{b[-1]}"
except Exception:
return "12M"
async def _drain(proc: asyncio.subprocess.Process, label: str) -> None:
try:
async for line in proc.stderr:
txt = line.decode(errors="replace").rstrip()
if txt:
logger.debug("[%s] %s", label, txt)
except Exception:
pass
async def _wait_path(path: str, timeout: float = 10.0) -> bool:
deadline = time.time() + timeout
while time.time() < deadline:
if os.path.exists(path):
return True
await asyncio.sleep(0.3)
return False
async def _wait_display(display: str, timeout: float = 12.0) -> bool:
"""Espera hasta que el display X11 acepte conexiones."""
deadline = time.time() + timeout
while time.time() < deadline:
try:
proc = await asyncio.create_subprocess_exec(
"xdpyinfo", "-display", display,
stdout=asyncio.subprocess.DEVNULL,
stderr=asyncio.subprocess.DEVNULL,
)
await asyncio.wait_for(proc.wait(), timeout=2.0)
if proc.returncode == 0:
return True
except Exception:
pass
await asyncio.sleep(0.5)
return False
# ── DesktopSlot ───────────────────────────────────────────────────────────────
class DesktopSlot:
def __init__(self, id: int):
self.id = id
self.disp_num = BASE_DISPLAY + id
self.vnc_port = BASE_VNC_PORT + id
self.pa_dir = f"/tmp/pa-{self.disp_num}"
self.home_dir = f"/tmp/home-{self.disp_num}"
self._xorg : Optional[asyncio.subprocess.Process] = None
self._x11vnc: Optional[asyncio.subprocess.Process] = None
self._wm : Optional[asyncio.subprocess.Process] = None
self._pa : Optional[asyncio.subprocess.Process] = None
self._ffmpeg: Optional[asyncio.subprocess.Process] = None
self._tasks : list[asyncio.Task] = []
self._fan : Optional[asyncio.Task] = None
self._clients: Set[asyncio.Queue] = set()
self._lock = asyncio.Lock()
# ── Properties ──────────────────────────────────────────────────────────
@property
def display(self) -> str:
return f":{self.disp_num}"
@property
def pulse_socket(self) -> str:
return f"unix:{self.pa_dir}/native"
@property
def running(self) -> bool:
return self._xorg is not None and self._xorg.returncode is None
@property
def streaming(self) -> bool:
return self._ffmpeg is not None and self._ffmpeg.returncode is None
@property
def stream_url(self) -> str:
return f"http://{MY_IP}:8900/desktop/{self.id}/stream"
def _setup_pulseaudio_config(self) -> None:
"""PulseAudio con null-sink virtual (sin dependencia de hardware ALSA)."""
pa_config_dir = f"{self.home_dir}/.config/pulse"
os.makedirs(pa_config_dir, mode=0o700, exist_ok=True)
with open(f"{pa_config_dir}/default.pa", "w") as f:
f.write(
".nofail\n"
"load-module module-null-sink sink_name=virtual "
"sink_properties=device.description=Virtual\n"
"set-default-sink virtual\n"
"set-default-source virtual.monitor\n"
"load-module module-native-protocol-unix\n"
)
def _env(self, extra: dict | None = None) -> dict:
e = {
**os.environ,
"DISPLAY": self.display,
"PULSE_SERVER": self.pulse_socket,
"PULSE_RUNTIME_PATH": self.pa_dir,
"HOME": self.home_dir,
"DBUS_SESSION_BUS_ADDRESS": "",
# Firefox: decode por hardware (NVDEC via VA-API) y render GPU
"LIBVA_DRIVER_NAME": "nvidia",
"MOZ_DISABLE_RDD_SANDBOX": "1",
"MOZ_X11_EGL": "1",
}
if extra:
e.update(extra)
return e
def _setup_firefox_profile(self) -> None:
"""Inyecta user.js con VA-API en todos los perfiles Firefox existentes."""
user_js = (
'user_pref("media.ffmpeg.vaapi.enabled", true);\n'
'user_pref("media.hardware-video-decoding.force-enabled", true);\n'
'user_pref("gfx.webrender.all", true);\n'
'user_pref("media.av1.enabled", false);\n'
)
ff_base = f"{self.home_dir}/.mozilla/firefox"
os.makedirs(ff_base, mode=0o700, exist_ok=True)
# Inyectar en cualquier perfil que ya exista
try:
for entry in os.listdir(ff_base):
pdir = os.path.join(ff_base, entry)
if os.path.isdir(pdir) and entry not in ("Crash Reports", "Pending Pings"):
with open(os.path.join(pdir, "user.js"), "w") as f:
f.write(user_js)
except Exception:
pass
# Crear perfil propio como fallback
profile_dir = f"{ff_base}/kiradesktop"
os.makedirs(profile_dir, mode=0o700, exist_ok=True)
with open(f"{profile_dir}/user.js", "w") as f:
f.write(user_js)
# ── Start ────────────────────────────────────────────────────────────────
async def start(self) -> dict:
async with self._lock:
if self.running:
return {"ok": True, "already": True, "display": self.display}
os.makedirs(self.pa_dir, mode=0o700, exist_ok=True)
os.makedirs(self.home_dir, mode=0o700, exist_ok=True)
# 1. PulseAudio con null-sink virtual
logger.info("[desktop-%d] Starting PulseAudio", self.id)
self._setup_pulseaudio_config()
self._pa = await asyncio.create_subprocess_exec(
"pulseaudio", "--start", "--exit-idle-time=-1",
env={**os.environ,
"PULSE_RUNTIME_PATH": self.pa_dir,
"HOME": self.home_dir},
stdout=asyncio.subprocess.DEVNULL,
stderr=asyncio.subprocess.DEVNULL,
)
await _wait_path(f"{self.pa_dir}/native", timeout=8.0)
# 2. Xorg con driver NVIDIA real (GPU rendering, GLX, VDPAU)
logger.info("[desktop-%d] Starting Xorg on %s (NVIDIA)", self.id, self.display)
log_file = f"/tmp/xorg{self.disp_num}.log"
self._xorg = await asyncio.create_subprocess_exec(
"Xorg", self.display,
"-config", XORG_CONFIG,
"-noreset",
"-logfile", log_file,
"-logverbose", "1",
stderr=asyncio.subprocess.DEVNULL,
stdout=asyncio.subprocess.DEVNULL,
)
# Esperar a que el display esté listo
ready = await _wait_display(self.display, timeout=12.0)
if not ready or self._xorg.returncode is not None:
code = self._xorg.returncode
# Mostrar últimas líneas del log para diagnóstico
try:
with open(log_file) as lf:
tail = lf.read()[-2000:]
except Exception:
tail = "(sin log)"
raise RuntimeError(
f"Xorg no arrancó en {self.display} (exit={code})\n{tail}"
)
# 3. x11vnc — acceso VNC al display Xorg
logger.info("[desktop-%d] Starting x11vnc on port %d", self.id, self.vnc_port)
self._x11vnc = await asyncio.create_subprocess_exec(
"x11vnc",
"-display", self.display,
"-rfbport", str(self.vnc_port),
"-nopw", "-forever", "-shared",
"-quiet", "-noxdamage",
stderr=asyncio.subprocess.DEVNULL,
stdout=asyncio.subprocess.DEVNULL,
)
await asyncio.sleep(0.5)
# 4. Perfil Firefox con VA-API pre-configurado
self._setup_firefox_profile()
# 5. Window manager
logger.info("[desktop-%d] Starting window manager: %s", self.id, WM_CMD)
self._wm = await asyncio.create_subprocess_exec(
"dbus-launch", "--exit-with-session", *WM_CMD.split(),
env=self._env(),
stdout=asyncio.subprocess.DEVNULL,
stderr=asyncio.subprocess.PIPE,
)
self._tasks.append(asyncio.create_task(
_drain(self._wm, f"wm-{self.id}")))
await asyncio.sleep(3.0)
# 6. ffmpeg x11grab → h264_nvenc
await self._start_ffmpeg()
logger.info("[desktop-%d] Ready — display=%s vnc=%d stream=%s",
self.id, self.display, self.vnc_port, self.stream_url)
return {
"ok": True,
"display": self.display,
"vnc_port": self.vnc_port,
"vnc_addr": f"{MY_IP}:{self.vnc_port}",
"stream_url": self.stream_url,
}
async def _start_ffmpeg(self):
cmd = [
FFMPEG_BIN, "-hide_banner", "-loglevel", "warning",
# Audio PRIMERO: PulseAudio debe estar listo antes de abrir x11grab.
# Si x11grab arranca primero, bufferiza ~30 frames mientras espera
# audio, causando que el video salga 1s atrasado respecto al audio.
"-f", "pulse", "-ac", "2",
"-fragment_size", "7680", # chunks de 20ms → listo rápido
"-thread_queue_size", "512",
"-use_wallclock_as_timestamps", "1",
"-i", "default",
# Video SEGUNDO — x11grab arranca cuando audio ya está activo
"-f", "x11grab", "-draw_mouse", "1",
"-framerate", str(FRAMERATE),
"-video_size", RESOLUTION,
"-thread_queue_size", "512",
"-use_wallclock_as_timestamps", "1",
"-i", f"{self.display}+0,0",
"-map", "0:a",
"-map", "1:v",
"-vf", "format=nv12,hwupload_cuda",
"-c:v", "h264_nvenc",
"-preset", NVENC_PRESET,
"-rc", "cbr",
"-profile:v", "high", "-level:v", "4.2",
"-b:v", VIDEO_BITRATE, "-maxrate", VIDEO_BITRATE,
"-bufsize", VIDEO_BITRATE,
"-g", "60", "-keyint_min", "30",
# Audio: AAC
"-c:a", "aac", "-b:a", AUDIO_BITRATE, "-ar", "48000", "-ac", "2",
"-muxdelay", "0", "-muxpreload", "0",
# Output MPEG-TS
"-f", "mpegts",
"pipe:1",
]
self._ffmpeg = await asyncio.create_subprocess_exec(
*cmd,
env=self._env(),
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
self._tasks.append(asyncio.create_task(
_drain(self._ffmpeg, f"ffmpeg-{self.id}")))
self._fan = asyncio.create_task(self._fan_out())
logger.info("[desktop-%d] ffmpeg capture started (pid=%d)", self.id, self._ffmpeg.pid)
async def _fan_out(self):
try:
while True:
chunk = await self._ffmpeg.stdout.read(65536)
if not chunk:
break
dead: Set[asyncio.Queue] = set()
for q in list(self._clients):
try:
q.put_nowait(chunk)
except asyncio.QueueFull:
dead.add(q)
for q in dead:
self._clients.discard(q)
try:
q.put_nowait(None)
except Exception:
pass
except Exception as e:
logger.warning("[desktop-%d] fan_out: %s", self.id, e)
finally:
for q in list(self._clients):
try:
q.put_nowait(None)
except Exception:
pass
self._clients.clear()
# ── Stop ─────────────────────────────────────────────────────────────────
async def stop(self) -> dict:
async with self._lock:
if not self.running:
return {"ok": True, "already_stopped": True}
logger.info("[desktop-%d] Stopping...", self.id)
for proc in (self._ffmpeg, self._wm, self._x11vnc, self._xorg):
if proc and proc.returncode is None:
try:
proc.kill()
await asyncio.wait_for(proc.wait(), timeout=3.0)
except Exception:
pass
try:
import subprocess
subprocess.run(
["pulseaudio", "--kill"],
env={**os.environ,
"PULSE_RUNTIME_PATH": self.pa_dir,
"HOME": self.home_dir},
timeout=3, capture_output=True,
)
except Exception:
pass
for t in self._tasks:
t.cancel()
if self._fan:
self._fan.cancel()
self._tasks.clear()
self._fan = None
self._xorg = self._x11vnc = self._wm = self._pa = self._ffmpeg = None
logger.info("[desktop-%d] Stopped", self.id)
return {"ok": True}
# ── Status ───────────────────────────────────────────────────────────────
def status(self) -> dict:
return {
"id": self.id,
"display": self.display,
"vnc_port": self.vnc_port,
"vnc_addr": f"{MY_IP}:{self.vnc_port}",
"running": self.running,
"streaming": self.streaming,
"clients": len(self._clients),
"stream_url": self.stream_url,
}
# ── Estado global ─────────────────────────────────────────────────────────────
slots = [DesktopSlot(i) for i in range(NUM_DESKTOPS)]
# ── FastAPI ───────────────────────────────────────────────────────────────────
app = FastAPI(title="KiraDesktop GPU Daemon", version="2.0")
@app.get("/health")
async def health():
return {
"node": "kiradesktop-gpu",
"type": "gpu",
"desktops": NUM_DESKTOPS,
"resolution": RESOLUTION,
"desktop_status": [s.status() for s in slots],
}
@app.post("/desktop/{id}/start")
async def start_desktop(id: int):
if id < 0 or id >= NUM_DESKTOPS:
raise HTTPException(404, f"Desktop {id} no existe")
try:
return await slots[id].start()
except Exception as e:
logger.error("start desktop %d: %s", id, e)
raise HTTPException(500, str(e))
@app.post("/desktop/{id}/stop")
async def stop_desktop(id: int):
if id < 0 or id >= NUM_DESKTOPS:
raise HTTPException(404, f"Desktop {id} no existe")
return await slots[id].stop()
@app.get("/desktop/{id}/stream")
async def stream_desktop(id: int):
if id < 0 or id >= NUM_DESKTOPS:
raise HTTPException(404, f"Desktop {id} no existe")
slot = slots[id]
if not slot.streaming:
raise HTTPException(503, "Desktop no está en streaming")
q: asyncio.Queue = asyncio.Queue(maxsize=128)
slot._clients.add(q)
async def generator():
try:
while True:
chunk = await q.get()
if chunk is None:
break
yield chunk
except asyncio.CancelledError:
pass
finally:
slot._clients.discard(q)
return StreamingResponse(generator(), media_type="video/mp2t")
def _export_slot(slot_idx: int | None) -> Response:
if slot_idx is not None:
if slot_idx < 0 or slot_idx >= len(slots):
raise HTTPException(404, f"Slot {slot_idx} no existe")
src = slots[slot_idx]
if not os.path.exists(f"{src.home_dir}/.mozilla"):
raise HTTPException(404, f"Slot {slot_idx} no tiene perfil de Firefox")
else:
src = next((s for s in slots if os.path.exists(f"{s.home_dir}/.mozilla")), None)
if src is None:
raise HTTPException(404, "Ningún slot tiene perfil de Firefox")
buf = io.BytesIO()
with tarfile.open(fileobj=buf, mode="w:gz") as tar:
tar.add(f"{src.home_dir}/.mozilla", arcname=".mozilla")
return Response(
content=buf.getvalue(),
media_type="application/octet-stream",
headers={"Content-Disposition": "attachment; filename=firefox-profile.tar.gz"},
)
@app.get("/sync/firefox/export")
async def export_firefox_gpu():
return _export_slot(None)
@app.get("/sync/firefox/export/{slot_id}")
async def export_firefox_gpu_slot(slot_id: int):
return _export_slot(slot_id)
def _fix_firefox_profile(mozilla_dir: str, home_dir: str) -> None:
ff_dir = f"{mozilla_dir}/firefox"
if not os.path.isdir(ff_dir):
return
# Fix profiles.ini: convert absolute paths → relative, and patch wrong home paths
profiles_ini = f"{ff_dir}/profiles.ini"
if os.path.exists(profiles_ini):
lines = []
in_profile = False
with open(profiles_ini) as f:
for line in f:
s = line.strip()
if s.startswith("[") and s.endswith("]"):
in_profile = s.startswith("[Profile")
if in_profile and s.startswith("IsRelative=0"):
lines.append("IsRelative=1\n")
elif in_profile and s.startswith("Path=") and len(s) > 5 and s[5] == "/":
lines.append(f"Path={os.path.basename(s[5:].rstrip('/'))}\n")
else:
lines.append(line)
with open(profiles_ini, "w") as f:
f.writelines(lines)
installs_ini = f"{ff_dir}/installs.ini"
if os.path.exists(installs_ini):
lines = []
with open(installs_ini) as f:
for line in f:
s = line.strip()
if s.startswith("Default=") and len(s) > 8 and s[8] == "/":
lines.append(f"Default={os.path.basename(s[8:].rstrip('/'))}\n")
else:
lines.append(line)
with open(installs_ini, "w") as f:
f.writelines(lines)
import glob
for profile_dir in glob.glob(f"{ff_dir}/*/"):
for name in ("lock", ".parentlock", ".parentlock.lck"):
try:
os.remove(os.path.join(profile_dir, name))
except OSError:
pass
@app.post("/sync/firefox/import")
async def import_firefox_gpu(request: Request):
data = await request.body()
if not data:
raise HTTPException(400, "No data received")
for s in slots:
mozilla_dir = f"{s.home_dir}/.mozilla"
os.makedirs(s.home_dir, exist_ok=True)
if os.path.exists(mozilla_dir):
shutil.rmtree(mozilla_dir)
with tarfile.open(fileobj=io.BytesIO(data), mode="r:gz") as tar:
tar.extractall(s.home_dir)
_fix_firefox_profile(mozilla_dir, s.home_dir)
return {"ok": True, "slots": len(slots)}
if __name__ == "__main__":
uvicorn.run(app, host="0.0.0.0", port=8900, workers=1)
+25
View File
@@ -0,0 +1,25 @@
[Unit]
Description=KiraDesktop GPU - Virtual Desktop Manager (6 escritorios GPU)
After=network.target kiragpu.service
[Service]
Type=simple
WorkingDirectory=/opt
Environment=NUM_DESKTOPS=6
Environment=BASE_DISPLAY=1
Environment=BASE_VNC_PORT=5901
Environment=RESOLUTION=1920x1080
Environment=FRAMERATE=60
Environment=VIDEO_BITRATE=8M
Environment=AUDIO_BITRATE=128k
Environment=NVENC_PRESET=p4
Environment=MY_IP=10.10.10.239
Environment=WM_CMD=startxfce4
ExecStart=/usr/bin/python3 /opt/kiradesktop-gpu.py
Restart=always
RestartSec=5
KillSignal=SIGTERM
TimeoutStopSec=15
[Install]
WantedBy=multi-user.target