feat(jitsi): Add Jitsi Meet video conferencing module

fix(live-usb): Improve UEFI boot compatibility

Jitsi module:
- LXC-based Jitsi Meet server
- Prosody + Jicofo + JVB stack
- Authentication support
- Let's Encrypt SSL
- Recording with Jibri

Live USB UEFI fixes:
- Add fallback to pre-built signed EFI
- Copy grubx64.efi for compatibility
- Copy grub.cfg to EFI/BOOT
- Add grub-efi-amd64-signed and shim-signed to CI

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
CyberMind-FR 2026-03-28 10:11:10 +01:00
parent d123ad49e8
commit bb5c30efbd
11 changed files with 1372 additions and 4 deletions

View File

@ -70,8 +70,8 @@ jobs:
sudo apt-get update -qq
sudo apt-get install -y -qq \
debootstrap parted dosfstools e2fsprogs \
squashfs-tools grub-efi-amd64-bin grub-pc-bin \
xorriso mtools rsync curl wget
squashfs-tools grub-efi-amd64-bin grub-efi-amd64-signed grub-pc-bin \
shim-signed xorriso mtools rsync curl wget
- name: Build Live USB image
run: |

View File

@ -544,12 +544,28 @@ cp "${LIVE_DIR}/live/initrd.img" "${MNT}/esp/live/"
cp "${LIVE_DIR}/live/filesystem.squashfs" "${MNT}/live/live/"
# Install GRUB EFI bootloader
grub-mkimage -o "${MNT}/esp/EFI/BOOT/BOOTX64.EFI" \
# Try grub-mkimage first for custom build
if grub-mkimage -o "${MNT}/esp/EFI/BOOT/BOOTX64.EFI" \
-O x86_64-efi \
-p /boot/grub \
part_gpt part_msdos fat ext2 normal linux boot configfile loopback \
chain efifwsetup efi_gop efi_uga ls search search_label search_fs_uuid \
search_fs_file gfxterm gfxterm_background gfxterm_menu test all_video loadenv exfat
search_fs_file gfxterm gfxterm_background gfxterm_menu test all_video loadenv exfat 2>/dev/null; then
ok "GRUB EFI image built successfully"
else
# Fallback: copy pre-built signed EFI from package
warn "grub-mkimage failed, using pre-built EFI"
if [[ -f /usr/lib/grub/x86_64-efi-signed/grubx64.efi.signed ]]; then
cp /usr/lib/grub/x86_64-efi-signed/grubx64.efi.signed "${MNT}/esp/EFI/BOOT/BOOTX64.EFI"
elif [[ -f /usr/lib/grub/x86_64-efi/grub.efi ]]; then
cp /usr/lib/grub/x86_64-efi/grub.efi "${MNT}/esp/EFI/BOOT/BOOTX64.EFI"
else
err "No GRUB EFI binary found!"
fi
fi
# Also copy as grubx64.efi for some UEFI implementations
cp "${MNT}/esp/EFI/BOOT/BOOTX64.EFI" "${MNT}/esp/EFI/BOOT/grubx64.efi" 2>/dev/null || true
# Copy GRUB modules
if [[ -d /usr/lib/grub/x86_64-efi ]]; then
@ -557,6 +573,9 @@ if [[ -d /usr/lib/grub/x86_64-efi ]]; then
cp -r /usr/lib/grub/x86_64-efi/*.mod "${MNT}/esp/boot/grub/x86_64-efi/" 2>/dev/null || true
fi
# Copy grub.cfg to ESP as well (some UEFI look here)
cp "${MNT}/esp/boot/grub/grub.cfg" "${MNT}/esp/EFI/BOOT/grub.cfg" 2>/dev/null || true
# Setup persistence partition
if [[ $INCLUDE_PERSISTENCE -eq 1 ]]; then
mkdir -p "${MNT}/persistence"

View File

@ -0,0 +1,656 @@
"""
SecuBox Jitsi API
LXC-based Jitsi Meet video conferencing management
"""
from fastapi import FastAPI, Depends, HTTPException, Query
from pydantic import BaseModel, Field
from typing import Optional, List, Dict, Any
from pathlib import Path
import subprocess
import json
import os
# Core imports
try:
from secubox_core.auth import require_jwt
from secubox_core.logger import get_logger
except ImportError:
def require_jwt():
return {"sub": "admin"}
class Logger:
def info(self, msg): print(f"INFO: {msg}")
def error(self, msg): print(f"ERROR: {msg}")
def warning(self, msg): print(f"WARN: {msg}")
def get_logger(name): return Logger()
app = FastAPI(
title="secubox-jitsi",
root_path="/api/v1/jitsi",
)
log = get_logger("jitsi")
# ══════════════════════════════════════════════════════════════════
# Configuration
# ══════════════════════════════════════════════════════════════════
LXC_NAME = "secubox-jitsi"
CONFIG_DIR = Path("/var/lib/secubox/jitsi")
DATA_DIR = Path("/var/lib/secubox/jitsi/data")
JITSI_WEB_PORT = 443
JITSI_XMPP_PORT = 5222
JVB_PORT = 10000
# ══════════════════════════════════════════════════════════════════
# LXC Helper Functions
# ══════════════════════════════════════════════════════════════════
def lxc_exists() -> bool:
"""Check if LXC container exists."""
result = subprocess.run(
["lxc-info", "-n", LXC_NAME],
capture_output=True, text=True
)
return result.returncode == 0
def lxc_running() -> bool:
"""Check if LXC container is running."""
try:
result = subprocess.run(
["lxc-info", "-n", LXC_NAME, "-s"],
capture_output=True, text=True, timeout=10
)
return "RUNNING" in result.stdout
except Exception:
return False
def lxc_get_ip() -> Optional[str]:
"""Get LXC container IP address."""
try:
result = subprocess.run(
["lxc-info", "-n", LXC_NAME, "-iH"],
capture_output=True, text=True, timeout=10
)
if result.returncode == 0 and result.stdout.strip():
return result.stdout.strip().split("\n")[0]
except Exception:
pass
return None
def lxc_exec(cmd: List[str], timeout: int = 60) -> subprocess.CompletedProcess:
"""Execute command inside LXC container."""
return subprocess.run(
["lxc-attach", "-n", LXC_NAME, "--"] + cmd,
capture_output=True, text=True, timeout=timeout
)
def lxc_start() -> bool:
"""Start the LXC container."""
result = subprocess.run(
["lxc-start", "-n", LXC_NAME],
capture_output=True, text=True
)
return result.returncode == 0
def lxc_stop() -> bool:
"""Stop the LXC container."""
result = subprocess.run(
["lxc-stop", "-n", LXC_NAME],
capture_output=True, text=True
)
return result.returncode == 0
# ══════════════════════════════════════════════════════════════════
# Request/Response Models
# ══════════════════════════════════════════════════════════════════
class RoomCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=64)
password: Optional[str] = None
max_participants: int = Field(default=50, ge=2, le=500)
class ConfigUpdate(BaseModel):
domain: Optional[str] = None
enable_authentication: Optional[bool] = None
enable_lobby: Optional[bool] = None
enable_recording: Optional[bool] = None
max_participants: Optional[int] = None
welcome_message: Optional[str] = None
# ══════════════════════════════════════════════════════════════════
# Status Endpoints
# ══════════════════════════════════════════════════════════════════
@app.get("/health")
async def health():
return {"status": "ok", "module": "jitsi"}
@app.get("/status")
async def status(user=Depends(require_jwt)):
"""Get Jitsi Meet status."""
container_exists = lxc_exists()
running = lxc_running() if container_exists else False
container_ip = lxc_get_ip() if running else None
services = {
"prosody": False,
"jicofo": False,
"jitsi-videobridge": False,
"nginx": False,
}
version = None
domain = None
active_conferences = 0
active_participants = 0
if running:
# Check service status
for service in services.keys():
result = lxc_exec(["systemctl", "is-active", service])
services[service] = result.returncode == 0
# Get domain from config
result = lxc_exec(["cat", "/etc/jitsi/meet/config.js"])
if result.returncode == 0:
for line in result.stdout.splitlines():
if "domain:" in line:
domain = line.split("'")[1] if "'" in line else None
break
# Get JVB stats
if container_ip:
try:
result = lxc_exec(["curl", "-s", "http://localhost:8080/colibri/stats"])
if result.returncode == 0:
stats = json.loads(result.stdout)
active_conferences = stats.get("conferences", 0)
active_participants = stats.get("participants", 0)
version = stats.get("version")
except Exception:
pass
# Get disk usage
disk_usage = None
if DATA_DIR.exists():
try:
result = subprocess.run(
["du", "-sh", str(DATA_DIR)],
capture_output=True, text=True
)
if result.returncode == 0:
disk_usage = result.stdout.split()[0]
except Exception:
pass
return {
"container_exists": container_exists,
"running": running,
"container_ip": container_ip,
"services": services,
"version": version,
"domain": domain,
"active_conferences": active_conferences,
"active_participants": active_participants,
"disk_usage": disk_usage,
}
# ══════════════════════════════════════════════════════════════════
# Lifecycle Endpoints
# ══════════════════════════════════════════════════════════════════
@app.post("/install")
async def install(
domain: str = Query(..., description="Jitsi domain (e.g., meet.example.com)"),
user=Depends(require_jwt)
):
"""Install Jitsi Meet in LXC container."""
if lxc_exists():
raise HTTPException(400, "Container already exists")
log.info(f"Installing Jitsi Meet for {domain}")
# Create data directories
DATA_DIR.mkdir(parents=True, exist_ok=True)
# Create LXC container
result = subprocess.run([
"lxc-create", "-n", LXC_NAME,
"-t", "download",
"--",
"-d", "debian",
"-r", "bookworm",
"-a", "amd64"
], capture_output=True, text=True, timeout=600)
if result.returncode != 0:
raise HTTPException(500, f"Failed to create container: {result.stderr}")
# Configure container
lxc_config = f"/var/lib/lxc/{LXC_NAME}/config"
with open(lxc_config, "a") as f:
f.write(f"\n# SecuBox Jitsi config\n")
f.write(f"lxc.mount.entry = {DATA_DIR} data none bind,create=dir 0 0\n")
f.write("lxc.start.auto = 1\n")
# Start container
if not lxc_start():
raise HTTPException(500, "Failed to start container")
# Wait for network
import time
for _ in range(30):
if lxc_get_ip():
break
time.sleep(1)
# Install Jitsi
install_script = f'''#!/bin/bash
set -e
export DEBIAN_FRONTEND=noninteractive
# Set hostname
echo "{domain}" > /etc/hostname
hostname {domain}
# Add hosts entry
echo "127.0.0.1 {domain}" >> /etc/hosts
# Update and install prerequisites
apt-get update
apt-get install -y gnupg2 apt-transport-https curl
# Add Jitsi repository
curl -fsSL https://download.jitsi.org/jitsi-key.gpg.key | gpg --dearmor -o /usr/share/keyrings/jitsi-keyring.gpg
echo "deb [signed-by=/usr/share/keyrings/jitsi-keyring.gpg] https://download.jitsi.org stable/" > /etc/apt/sources.list.d/jitsi-stable.list
apt-get update
# Pre-configure Jitsi
echo "jitsi-videobridge2 jitsi-videobridge/jvb-hostname string {domain}" | debconf-set-selections
echo "jitsi-meet-web-config jitsi-meet/cert-choice select Generate a new self-signed certificate" | debconf-set-selections
# Install Jitsi Meet
apt-get install -y jitsi-meet
# Enable services
systemctl enable prosody jicofo jitsi-videobridge2 nginx
systemctl start prosody jicofo jitsi-videobridge2 nginx
echo "Jitsi Meet installed successfully"
'''
result = lxc_exec(["bash", "-c", install_script], timeout=900)
if result.returncode != 0:
log.error(f"Install failed: {result.stderr}")
raise HTTPException(500, f"Installation failed: {result.stderr[:500]}")
log.info("Jitsi Meet installed successfully")
return {"success": True, "message": f"Jitsi installed for {domain}"}
@app.post("/start")
async def start(user=Depends(require_jwt)):
"""Start Jitsi container."""
if not lxc_exists():
raise HTTPException(400, "Container not installed")
if lxc_running():
return {"success": True, "message": "Already running"}
if lxc_start():
# Start all Jitsi services
for service in ["prosody", "jicofo", "jitsi-videobridge2", "nginx"]:
lxc_exec(["systemctl", "start", service])
return {"success": True}
raise HTTPException(500, "Failed to start container")
@app.post("/stop")
async def stop(user=Depends(require_jwt)):
"""Stop Jitsi container."""
if not lxc_running():
return {"success": True, "message": "Already stopped"}
# Stop services gracefully
for service in ["nginx", "jitsi-videobridge2", "jicofo", "prosody"]:
lxc_exec(["systemctl", "stop", service])
if lxc_stop():
return {"success": True}
raise HTTPException(500, "Failed to stop container")
@app.post("/restart")
async def restart(user=Depends(require_jwt)):
"""Restart Jitsi services."""
if not lxc_running():
raise HTTPException(400, "Container not running")
for service in ["prosody", "jicofo", "jitsi-videobridge2", "nginx"]:
lxc_exec(["systemctl", "restart", service])
return {"success": True}
@app.delete("/uninstall")
async def uninstall(user=Depends(require_jwt)):
"""Remove Jitsi container (keeps data)."""
if lxc_running():
lxc_stop()
if lxc_exists():
result = subprocess.run(
["lxc-destroy", "-n", LXC_NAME],
capture_output=True, text=True
)
if result.returncode != 0:
raise HTTPException(500, f"Failed to destroy: {result.stderr}")
return {"success": True, "message": "Container removed, data preserved"}
# ══════════════════════════════════════════════════════════════════
# Conference Management
# ══════════════════════════════════════════════════════════════════
@app.get("/conferences")
async def list_conferences(user=Depends(require_jwt)):
"""List active conferences."""
if not lxc_running():
raise HTTPException(400, "Container not running")
container_ip = lxc_get_ip()
if not container_ip:
return {"conferences": []}
try:
result = lxc_exec(["curl", "-s", "http://localhost:8080/colibri/stats"])
if result.returncode == 0:
stats = json.loads(result.stdout)
return {
"conferences": stats.get("conferences", 0),
"participants": stats.get("participants", 0),
"largest_conference": stats.get("largest_conference", 0),
"total_conferences_created": stats.get("total_conferences_created", 0),
}
except Exception:
pass
return {"conferences": [], "error": "Failed to get stats"}
@app.post("/rooms")
async def create_room(req: RoomCreate, user=Depends(require_jwt)):
"""Create a meeting room with optional password."""
if not lxc_running():
raise HTTPException(400, "Container not running")
container_ip = lxc_get_ip()
if not container_ip:
raise HTTPException(500, "Cannot get container IP")
# Generate room URL
room_name = req.name.replace(" ", "_").lower()
room_url = f"https://{container_ip}/{room_name}"
# Password can only be set when joining the room
return {
"success": True,
"room_name": room_name,
"url": room_url,
"password_required": req.password is not None,
}
# ══════════════════════════════════════════════════════════════════
# Configuration
# ══════════════════════════════════════════════════════════════════
@app.get("/config")
async def get_config(user=Depends(require_jwt)):
"""Get Jitsi configuration."""
if not lxc_running():
return {"error": "Container not running"}
config = {
"domain": None,
"enable_authentication": False,
"enable_lobby": False,
"enable_recording": False,
"max_participants": 100,
"welcome_message": "",
}
# Read interface_config.js
result = lxc_exec(["cat", "/etc/jitsi/meet/config.js"])
if result.returncode == 0:
for line in result.stdout.splitlines():
line = line.strip()
if "domain:" in line:
config["domain"] = line.split("'")[1] if "'" in line else None
elif "enableLobby:" in line:
config["enable_lobby"] = "true" in line.lower()
elif "fileRecordingsEnabled:" in line:
config["enable_recording"] = "true" in line.lower()
return config
@app.post("/config")
async def update_config(req: ConfigUpdate, user=Depends(require_jwt)):
"""Update Jitsi configuration."""
if not lxc_running():
raise HTTPException(400, "Container not running")
# Update interface_config.js for UI settings
if req.welcome_message is not None:
result = lxc_exec([
"sed", "-i",
f"s/MOBILE_APP_PROMO: .*/MOBILE_APP_PROMO: false,/",
"/usr/share/jitsi-meet/interface_config.js"
])
# Restart services to apply
for service in ["prosody", "jicofo", "jitsi-videobridge2"]:
lxc_exec(["systemctl", "restart", service])
return {"success": True, "message": "Configuration updated"}
# ══════════════════════════════════════════════════════════════════
# Authentication
# ══════════════════════════════════════════════════════════════════
@app.post("/auth/enable")
async def enable_authentication(user=Depends(require_jwt)):
"""Enable Prosody authentication for moderators."""
if not lxc_running():
raise HTTPException(400, "Container not running")
# Get domain
result = lxc_exec(["hostname"])
domain = result.stdout.strip() if result.returncode == 0 else "localhost"
auth_script = f'''#!/bin/bash
# Enable authentication in Prosody
sed -i 's/authentication = "anonymous"/authentication = "internal_hashed"/' /etc/prosody/conf.avail/{domain}.cfg.lua
# Add guest virtual host
cat >> /etc/prosody/conf.avail/{domain}.cfg.lua << EOF
VirtualHost "guest.{domain}"
authentication = "anonymous"
modules_enabled = {{
"turncredentials";
}}
c2s_require_encryption = false
EOF
# Update Jitsi config
sed -i "s/anonymousdomain: .*/anonymousdomain: 'guest.{domain}',/" /etc/jitsi/meet/{domain}-config.js
# Restart services
systemctl restart prosody jicofo
'''
result = lxc_exec(["bash", "-c", auth_script])
return {"success": result.returncode == 0, "domain": domain}
@app.post("/auth/users")
async def create_auth_user(
username: str = Query(...),
password: str = Query(..., min_length=8),
user=Depends(require_jwt)
):
"""Create a moderator user."""
if not lxc_running():
raise HTTPException(400, "Container not running")
result = lxc_exec(["hostname"])
domain = result.stdout.strip() if result.returncode == 0 else "localhost"
result = lxc_exec([
"prosodyctl", "register",
username, domain, password
])
return {
"success": result.returncode == 0,
"username": f"{username}@{domain}"
}
# ══════════════════════════════════════════════════════════════════
# Recording (Jibri)
# ══════════════════════════════════════════════════════════════════
@app.get("/recording")
async def recording_status(user=Depends(require_jwt)):
"""Get recording (Jibri) status."""
if not lxc_running():
raise HTTPException(400, "Container not running")
result = lxc_exec(["systemctl", "is-active", "jibri"])
jibri_installed = result.returncode == 0 or "inactive" in result.stdout
return {
"installed": jibri_installed,
"running": result.returncode == 0,
"message": "Jibri provides recording and streaming capabilities"
}
@app.post("/recording/install")
async def install_jibri(user=Depends(require_jwt)):
"""Install Jibri for recording support."""
if not lxc_running():
raise HTTPException(400, "Container not running")
# Jibri installation is complex and requires X11, Chrome, ffmpeg
install_script = '''#!/bin/bash
set -e
apt-get update
apt-get install -y jibri
systemctl enable jibri
systemctl start jibri
'''
result = lxc_exec(["bash", "-c", install_script], timeout=600)
return {
"success": result.returncode == 0,
"message": "Jibri installed" if result.returncode == 0 else result.stderr[:200]
}
# ══════════════════════════════════════════════════════════════════
# Logs
# ══════════════════════════════════════════════════════════════════
@app.get("/logs")
async def get_logs(
service: str = Query("jicofo", enum=["prosody", "jicofo", "jitsi-videobridge2", "nginx"]),
lines: int = Query(100, ge=10, le=1000),
user=Depends(require_jwt)
):
"""Get Jitsi service logs."""
if not lxc_running():
raise HTTPException(400, "Container not running")
result = lxc_exec(["journalctl", "-u", service, "-n", str(lines), "--no-pager"])
return {"logs": result.stdout.splitlines() if result.returncode == 0 else []}
# ══════════════════════════════════════════════════════════════════
# Stats
# ══════════════════════════════════════════════════════════════════
@app.get("/stats")
async def get_stats(user=Depends(require_jwt)):
"""Get detailed JVB statistics."""
if not lxc_running():
raise HTTPException(400, "Container not running")
try:
result = lxc_exec(["curl", "-s", "http://localhost:8080/colibri/stats"])
if result.returncode == 0:
return json.loads(result.stdout)
except Exception:
pass
return {"error": "Failed to get stats"}
# ══════════════════════════════════════════════════════════════════
# SSL Certificates
# ══════════════════════════════════════════════════════════════════
@app.post("/ssl/letsencrypt")
async def setup_letsencrypt(
email: str = Query(...),
user=Depends(require_jwt)
):
"""Setup Let's Encrypt SSL certificate."""
if not lxc_running():
raise HTTPException(400, "Container not running")
result = lxc_exec(["hostname"])
domain = result.stdout.strip() if result.returncode == 0 else "localhost"
ssl_script = f'''#!/bin/bash
set -e
apt-get install -y certbot python3-certbot-nginx
certbot --nginx -d {domain} --non-interactive --agree-tos -m {email}
systemctl restart nginx
'''
result = lxc_exec(["bash", "-c", ssl_script], timeout=300)
return {
"success": result.returncode == 0,
"domain": domain,
"message": "SSL certificate installed" if result.returncode == 0 else result.stderr[:200]
}

View File

@ -0,0 +1,10 @@
secubox-jitsi (1.0.0-1) bookworm; urgency=medium
* Initial release
* LXC-based Jitsi Meet video conferencing
* Prosody + Jicofo + JVB stack
* Authentication support
* Let's Encrypt SSL integration
* Recording support via Jibri
-- Gandalf / CyberMind <gk@cybermind.fr> Fri, 28 Mar 2026 09:30:00 +0100

View File

@ -0,0 +1,27 @@
Source: secubox-jitsi
Section: net
Priority: optional
Maintainer: Gandalf / CyberMind <gk@cybermind.fr>
Build-Depends: debhelper-compat (= 13)
Standards-Version: 4.6.2
Homepage: https://cybermind.fr/secubox
Package: secubox-jitsi
Architecture: all
Depends: ${misc:Depends},
secubox-core (>= 1.0),
python3-uvicorn,
lxc,
lxc-templates
Recommends: nginx
Description: SecuBox Jitsi Meet - Video Conferencing
LXC-based Jitsi Meet video conferencing for SecuBox.
Provides secure, scalable video meetings with screen sharing.
.
Features:
- Full Jitsi Meet in LXC container
- Prosody XMPP server for signaling
- Jitsi Videobridge (JVB) for media
- Optional authentication
- Let's Encrypt SSL support
- Recording with Jibri (optional)

View File

@ -0,0 +1,19 @@
#!/bin/bash
set -e
case "$1" in
configure)
# Create data directories
install -d -o secubox -g secubox -m 750 /var/lib/secubox/jitsi
install -d -o secubox -g secubox -m 750 /var/lib/secubox/jitsi/data
# Enable and start service
systemctl daemon-reload
systemctl enable secubox-jitsi.service
systemctl start secubox-jitsi.service || true
# Reload nginx if installed
systemctl reload nginx 2>/dev/null || true
;;
esac
#DEBHELPER#

View File

@ -0,0 +1,16 @@
#!/usr/bin/make -f
%:
dh $@
override_dh_auto_install:
install -d $(CURDIR)/debian/secubox-jitsi/usr/lib/secubox/jitsi/api
install -m 644 api/main.py $(CURDIR)/debian/secubox-jitsi/usr/lib/secubox/jitsi/api/
touch $(CURDIR)/debian/secubox-jitsi/usr/lib/secubox/jitsi/api/__init__.py
install -d $(CURDIR)/debian/secubox-jitsi/usr/share/secubox/www/jitsi
install -m 644 www/jitsi/index.html $(CURDIR)/debian/secubox-jitsi/usr/share/secubox/www/jitsi/
install -d $(CURDIR)/debian/secubox-jitsi/etc/nginx/secubox.d
install -m 644 nginx/jitsi.conf $(CURDIR)/debian/secubox-jitsi/etc/nginx/secubox.d/
install -d $(CURDIR)/debian/secubox-jitsi/usr/share/secubox/menu.d
install -m 644 menu.d/809-jitsi.json $(CURDIR)/debian/secubox-jitsi/usr/share/secubox/menu.d/
install -d $(CURDIR)/debian/secubox-jitsi/usr/lib/systemd/system
install -m 644 debian/secubox-jitsi.service $(CURDIR)/debian/secubox-jitsi/usr/lib/systemd/system/

View File

@ -0,0 +1,22 @@
[Unit]
Description=SecuBox Jitsi API
After=network.target secubox-core.service
Requires=secubox-core.service
[Service]
Type=simple
User=root
Group=root
WorkingDirectory=/usr/lib/secubox/jitsi
ExecStart=/usr/bin/uvicorn api.main:app --uds /run/secubox/jitsi.sock --log-level warning
UMask=0117
Restart=on-failure
RestartSec=5
PrivateTmp=true
NoNewPrivileges=false
ProtectSystem=strict
ReadWritePaths=/run/secubox /var/lib/secubox /var/lib/lxc
[Install]
WantedBy=multi-user.target

View File

@ -0,0 +1,9 @@
{
"id": "jitsi",
"name": "Jitsi Meet",
"icon": "📹",
"path": "/jitsi/",
"category": "apps",
"order": 809,
"description": "Video conferencing and meetings"
}

View File

@ -0,0 +1,16 @@
# SecuBox Jitsi API proxy
location /api/v1/jitsi/ {
proxy_pass http://unix:/run/secubox/jitsi.sock;
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;
proxy_read_timeout 300s;
}
# SecuBox Jitsi Web UI
location /jitsi/ {
alias /usr/share/secubox/www/jitsi/;
try_files $uri $uri/ /jitsi/index.html;
}

View File

@ -0,0 +1,574 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Jitsi Meet - SecuBox</title>
<link rel="stylesheet" href="/shared/crt-light.css">
<link rel="stylesheet" href="/shared/sidebar-light.css">
<style>
:root {
--jitsi-blue: #246fe5;
--jitsi-light: #5090e8;
--jitsi-dark: #1a5cc4;
}
body.dark {
--jitsi-blue: #5090e8;
--jitsi-light: #7aabe8;
--jitsi-dark: #246fe5;
}
.accent { color: var(--jitsi-blue); }
.accent-bg { background-color: var(--jitsi-blue); }
.header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem;
border-bottom: 1px solid var(--border);
margin-bottom: 1rem;
}
.header h1 { margin: 0; font-size: 1.5rem; }
.status-badge {
padding: 0.25rem 0.75rem;
border-radius: 1rem;
font-size: 0.875rem;
}
.status-badge.running { background: var(--success-bg); color: var(--success); }
.status-badge.stopped { background: var(--error-bg); color: var(--error); }
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 1rem;
margin-bottom: 1.5rem;
}
.stat-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 0.5rem;
padding: 1rem;
text-align: center;
}
.stat-value {
font-size: 2rem;
font-weight: bold;
color: var(--jitsi-blue);
}
.stat-label {
font-size: 0.75rem;
color: var(--text-secondary);
text-transform: uppercase;
}
.services-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 0.5rem;
margin-bottom: 1rem;
}
.service-badge {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 0.25rem;
font-size: 0.875rem;
}
.service-status {
width: 8px;
height: 8px;
border-radius: 50%;
}
.service-status.active { background: var(--success); }
.service-status.inactive { background: var(--error); }
.action-bar {
display: flex;
gap: 0.5rem;
margin-bottom: 1rem;
flex-wrap: wrap;
}
.btn {
padding: 0.5rem 1rem;
border: 1px solid var(--border);
border-radius: 0.25rem;
background: var(--surface);
color: var(--text);
cursor: pointer;
font-size: 0.875rem;
}
.btn:hover { background: var(--hover); }
.btn.primary { background: var(--jitsi-blue); color: white; border-color: var(--jitsi-blue); }
.btn.primary:hover { background: var(--jitsi-dark); }
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
.tabs {
display: flex;
gap: 0.5rem;
border-bottom: 1px solid var(--border);
margin-bottom: 1rem;
}
.tab {
padding: 0.75rem 1rem;
border: none;
background: none;
color: var(--text-secondary);
cursor: pointer;
border-bottom: 2px solid transparent;
}
.tab:hover { color: var(--text); }
.tab.active { color: var(--jitsi-blue); border-bottom-color: var(--jitsi-blue); }
.tab-content { display: none; }
.tab-content.active { display: block; }
.log-viewer {
background: var(--bg-dark, #1a1a2e);
color: var(--text-light, #e0e0e0);
font-family: monospace;
font-size: 0.75rem;
padding: 1rem;
border-radius: 0.5rem;
height: 400px;
overflow-y: auto;
white-space: pre-wrap;
}
.settings-form {
display: grid;
gap: 1rem;
max-width: 500px;
}
.form-group label {
display: block;
margin-bottom: 0.25rem;
font-weight: 500;
}
.form-group input, .form-group select {
width: 100%;
padding: 0.5rem;
border: 1px solid var(--border);
border-radius: 0.25rem;
background: var(--surface);
color: var(--text);
}
.quick-join {
max-width: 400px;
margin: 1rem auto;
padding: 1.5rem;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 0.5rem;
text-align: center;
}
.quick-join input {
width: 100%;
padding: 0.75rem;
font-size: 1rem;
border: 1px solid var(--border);
border-radius: 0.25rem;
margin-bottom: 1rem;
}
.open-app-btn {
position: fixed;
bottom: 1rem;
right: 1rem;
padding: 1rem 1.5rem;
background: var(--jitsi-blue);
color: white;
border: none;
border-radius: 2rem;
cursor: pointer;
font-size: 1rem;
box-shadow: 0 4px 12px rgba(36, 111, 229, 0.4);
}
.open-app-btn:hover { background: var(--jitsi-dark); }
.hidden { display: none !important; }
.install-form {
max-width: 400px;
margin: 2rem auto;
padding: 2rem;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 0.5rem;
}
.install-form h2 { margin-top: 0; text-align: center; }
</style>
</head>
<body class="crt-light">
<nav class="sidebar" id="sidebar"></nav>
<main class="main-content">
<div class="container">
<div class="header">
<h1><span class="accent">Jitsi</span> Meet</h1>
<span id="status-badge" class="status-badge stopped">Stopped</span>
</div>
<div id="install-section" class="install-form hidden">
<h2>Install Jitsi Meet</h2>
<form onsubmit="installJitsi(event)">
<div class="form-group">
<label>Domain</label>
<input type="text" id="install-domain" placeholder="meet.example.com" required>
<small style="color: var(--text-secondary);">Must resolve to this server</small>
</div>
<button type="submit" class="btn primary" style="width: 100%; margin-top: 1rem;">Install Jitsi</button>
</form>
</div>
<div id="main-section" class="hidden">
<div class="stats-grid">
<div class="stat-card">
<div class="stat-value" id="active-conferences">0</div>
<div class="stat-label">Active Meetings</div>
</div>
<div class="stat-card">
<div class="stat-value" id="active-participants">0</div>
<div class="stat-label">Participants</div>
</div>
<div class="stat-card">
<div class="stat-value" id="largest-conference">0</div>
<div class="stat-label">Largest Meeting</div>
</div>
<div class="stat-card">
<div class="stat-value" id="total-conferences">0</div>
<div class="stat-label">Total Created</div>
</div>
</div>
<div class="services-grid" id="services-grid"></div>
<div class="action-bar">
<button class="btn primary" id="btn-start" onclick="startService()">Start</button>
<button class="btn" id="btn-stop" onclick="stopService()" disabled>Stop</button>
<button class="btn" id="btn-restart" onclick="restartService()" disabled>Restart</button>
</div>
<div class="quick-join">
<h3 style="margin-top: 0;">Quick Join</h3>
<input type="text" id="room-name" placeholder="Enter room name...">
<button class="btn primary" onclick="joinRoom()" style="width: 100%;">Join Meeting</button>
</div>
<div class="tabs">
<button class="tab active" data-tab="overview">Overview</button>
<button class="tab" data-tab="auth">Authentication</button>
<button class="tab" data-tab="logs">Logs</button>
<button class="tab" data-tab="settings">Settings</button>
</div>
<div id="tab-overview" class="tab-content active">
<div class="info-grid" style="display: grid; gap: 1rem; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));">
<div class="info-card" style="background: var(--surface); border: 1px solid var(--border); border-radius: 0.5rem; padding: 1rem;">
<div style="font-size: 0.75rem; color: var(--text-secondary); text-transform: uppercase;">Domain</div>
<div style="font-size: 1.25rem; font-weight: 500; margin-top: 0.25rem;" id="jitsi-domain">-</div>
</div>
<div class="info-card" style="background: var(--surface); border: 1px solid var(--border); border-radius: 0.5rem; padding: 1rem;">
<div style="font-size: 0.75rem; color: var(--text-secondary); text-transform: uppercase;">Container IP</div>
<div style="font-size: 1.25rem; font-weight: 500; margin-top: 0.25rem;" id="container-ip">-</div>
</div>
<div class="info-card" style="background: var(--surface); border: 1px solid var(--border); border-radius: 0.5rem; padding: 1rem;">
<div style="font-size: 0.75rem; color: var(--text-secondary); text-transform: uppercase;">Disk Usage</div>
<div style="font-size: 1.25rem; font-weight: 500; margin-top: 0.25rem;" id="disk-usage">-</div>
</div>
</div>
</div>
<div id="tab-auth" class="tab-content">
<p style="margin-bottom: 1rem;">Enable authentication to require login for meeting moderators.</p>
<div class="action-bar">
<button class="btn primary" onclick="enableAuth()">Enable Authentication</button>
</div>
<h4>Create Moderator User</h4>
<form class="settings-form" onsubmit="createUser(event)">
<div class="form-group">
<label>Username</label>
<input type="text" id="auth-username" required>
</div>
<div class="form-group">
<label>Password</label>
<input type="password" id="auth-password" minlength="8" required>
</div>
<button type="submit" class="btn primary">Create User</button>
</form>
</div>
<div id="tab-logs" class="tab-content">
<div class="action-bar">
<select id="log-service">
<option value="jicofo">Jicofo</option>
<option value="prosody">Prosody</option>
<option value="jitsi-videobridge2">JVB</option>
<option value="nginx">Nginx</option>
</select>
<button class="btn" onclick="loadLogs()">Refresh</button>
</div>
<div id="log-viewer" class="log-viewer">Select a service and click Refresh</div>
</div>
<div id="tab-settings" class="tab-content">
<h4>SSL Certificate</h4>
<form class="settings-form" onsubmit="setupSSL(event)">
<div class="form-group">
<label>Email (for Let's Encrypt)</label>
<input type="email" id="ssl-email" required>
</div>
<button type="submit" class="btn primary">Setup Let's Encrypt</button>
</form>
</div>
</div>
</div>
<button id="open-app-btn" class="open-app-btn hidden" onclick="openJitsi()">
Open Jitsi Meet
</button>
</main>
<script>
const API_BASE = '/api/v1/jitsi';
let containerIP = null;
async function apiCall(endpoint, options = {}) {
const token = localStorage.getItem('sbx_token');
const response = await fetch(API_BASE + endpoint, {
...options,
headers: {
'Authorization': 'Bearer ' + token,
'Content-Type': 'application/json',
...options.headers
}
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.json();
}
async function loadStatus() {
try {
const data = await apiCall('/status');
const badge = document.getElementById('status-badge');
const btnStart = document.getElementById('btn-start');
const btnStop = document.getElementById('btn-stop');
const btnRestart = document.getElementById('btn-restart');
const openBtn = document.getElementById('open-app-btn');
if (!data.container_exists) {
document.getElementById('install-section').classList.remove('hidden');
document.getElementById('main-section').classList.add('hidden');
badge.textContent = 'Not Installed';
badge.className = 'status-badge stopped';
return;
}
document.getElementById('install-section').classList.add('hidden');
document.getElementById('main-section').classList.remove('hidden');
if (data.running) {
badge.textContent = 'Running';
badge.className = 'status-badge running';
btnStart.disabled = true;
btnStop.disabled = false;
btnRestart.disabled = false;
containerIP = data.container_ip;
openBtn.classList.remove('hidden');
// Update stats
document.getElementById('active-conferences').textContent = data.active_conferences || 0;
document.getElementById('active-participants').textContent = data.active_participants || 0;
} else {
badge.textContent = 'Stopped';
badge.className = 'status-badge stopped';
btnStart.disabled = false;
btnStop.disabled = true;
btnRestart.disabled = true;
openBtn.classList.add('hidden');
}
document.getElementById('jitsi-domain').textContent = data.domain || '-';
document.getElementById('container-ip').textContent = data.container_ip || '-';
document.getElementById('disk-usage').textContent = data.disk_usage || '-';
// Services
const servicesGrid = document.getElementById('services-grid');
if (data.services) {
servicesGrid.innerHTML = Object.entries(data.services).map(([name, active]) => `
<div class="service-badge">
<span class="service-status ${active ? 'active' : 'inactive'}"></span>
${name}
</div>
`).join('');
}
// Load conference stats
if (data.running) loadConferences();
} catch (e) {
console.error('Status load failed:', e);
}
}
async function loadConferences() {
try {
const data = await apiCall('/conferences');
document.getElementById('active-conferences').textContent = data.conferences || 0;
document.getElementById('active-participants').textContent = data.participants || 0;
document.getElementById('largest-conference').textContent = data.largest_conference || 0;
document.getElementById('total-conferences').textContent = data.total_conferences_created || 0;
} catch (e) {
console.error('Conferences load failed:', e);
}
}
async function installJitsi(e) {
e.preventDefault();
const domain = document.getElementById('install-domain').value;
if (!domain) return;
const btn = e.target.querySelector('button');
btn.disabled = true;
btn.textContent = 'Installing... (this takes several minutes)';
try {
await apiCall(`/install?domain=${encodeURIComponent(domain)}`, { method: 'POST' });
await loadStatus();
} catch (err) {
alert('Installation failed: ' + err.message);
} finally {
btn.disabled = false;
btn.textContent = 'Install Jitsi';
}
}
async function startService() {
document.getElementById('btn-start').disabled = true;
try {
await apiCall('/start', { method: 'POST' });
await loadStatus();
} catch (e) {
alert('Failed: ' + e.message);
}
}
async function stopService() {
document.getElementById('btn-stop').disabled = true;
try {
await apiCall('/stop', { method: 'POST' });
await loadStatus();
} catch (e) {
alert('Failed: ' + e.message);
}
}
async function restartService() {
document.getElementById('btn-restart').disabled = true;
try {
await apiCall('/restart', { method: 'POST' });
await loadStatus();
} catch (e) {
alert('Failed: ' + e.message);
}
}
function joinRoom() {
const roomName = document.getElementById('room-name').value.trim();
if (!roomName) {
alert('Enter a room name');
return;
}
if (containerIP) {
window.open(`https://${containerIP}/${roomName}`, '_blank');
}
}
async function enableAuth() {
if (!confirm('Enable authentication? This will require users to log in as moderators.')) return;
try {
await apiCall('/auth/enable', { method: 'POST' });
alert('Authentication enabled');
} catch (e) {
alert('Failed: ' + e.message);
}
}
async function createUser(e) {
e.preventDefault();
const username = document.getElementById('auth-username').value;
const password = document.getElementById('auth-password').value;
try {
const result = await apiCall(`/auth/users?username=${encodeURIComponent(username)}&password=${encodeURIComponent(password)}`, { method: 'POST' });
alert(`User created: ${result.username}`);
e.target.reset();
} catch (err) {
alert('Failed: ' + err.message);
}
}
async function loadLogs() {
const service = document.getElementById('log-service').value;
try {
const data = await apiCall(`/logs?service=${service}&lines=200`);
document.getElementById('log-viewer').textContent = data.logs?.join('\n') || 'No logs';
} catch (e) {
document.getElementById('log-viewer').textContent = 'Error: ' + e.message;
}
}
async function setupSSL(e) {
e.preventDefault();
const email = document.getElementById('ssl-email').value;
try {
await apiCall(`/ssl/letsencrypt?email=${encodeURIComponent(email)}`, { method: 'POST' });
alert('SSL certificate installed');
} catch (err) {
alert('Failed: ' + err.message);
}
}
function openJitsi() {
if (containerIP) window.open(`https://${containerIP}/`, '_blank');
}
document.querySelectorAll('.tab').forEach(tab => {
tab.addEventListener('click', () => {
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
tab.classList.add('active');
document.getElementById('tab-' + tab.dataset.tab).classList.add('active');
if (tab.dataset.tab === 'logs') loadLogs();
});
});
if (window.matchMedia?.('(prefers-color-scheme: dark)').matches) {
document.body.classList.add('dark');
}
loadStatus();
setInterval(loadStatus, 30000);
</script>
<script src="/shared/sidebar.js"></script>
</body>
</html>