feat(eye-remote): Add multi-mode display system v1.9.0

Eye Remote Interactive UI enhancements:
- TTY mode: Serial terminal display from /dev/ttyGS0
- Flash mode: Progress bar with speed/ETA for USB transfers
- Auth mode: QR code generation for backup authentication
- Mode detection via /etc/secubox/gadget-mode

Hub service VM compatibility fix:
- Changed from Unix socket to TCP port 8001
- Updated nginx configs for TCP proxy
- Fixes 502 errors in VirtualBox VMs

Also includes:
- FAQ/Troubleshooting wiki page with GitHub issue links
- Kiosk launcher --no-sandbox fix for VMs
- Profile Generator GUI mockup

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
CyberMind-FR 2026-04-29 10:24:02 +02:00
parent 33304e7b6f
commit c4b7ac0f7f
9 changed files with 1717 additions and 16 deletions

View File

@ -3,6 +3,36 @@
---
## 2026-04-29
### Session 73 — Eye Remote Interactive v1.9.0
**Feature:** Multi-mode USB gadget display system for Eye Remote
**Files Modified:**
- `remote-ui/round/fb_dashboard.py` — Added mode detection, TTY terminal, flash progress, auth QR
- `packages/secubox-hub/debian/secubox-hub.service` — Changed to TCP binding (port 8001)
- `packages/secubox-hub/nginx/hub.conf` — Changed to TCP proxy
- `common/nginx/modules.d/hub.conf` — Changed to TCP proxy
**New Classes:**
- `SerialTerminal` — Read serial console output for TTY mode
- `FlashProgress` — Track USB mass storage transfer progress
- `AuthState` — QR code generation for backup authentication
**New Functions:**
- `get_gadget_mode()` — Read current USB gadget mode from /etc/secubox/gadget-mode
- `draw_terminal()` — Render serial terminal output on round display
- `draw_flash_progress()` — Render flash transfer progress bar
- `draw_auth_mode()` — Render QR code authentication screen
**Fixes:**
- Hub service changed from Unix socket to TCP (VM compatibility)
- FAQ and wiki updated with troubleshooting for common issues
- Kiosk launcher fixed for VM sandbox issues (--no-sandbox flag)
---
## 2026-04-28
### Session 72 — v2.1.1 Release: Build and API Fixes

View File

@ -1,5 +1,49 @@
# WIP — Work In Progress
*Mis à jour : 2026-04-28 (Session 71)*
*Mis à jour : 2026-04-29 (Session 73)*
---
## ✅ Complété (Session 73) — Eye Remote Interactive v1.9.0
### Mode-Aware Display System ✅
**Feature:** Multi-mode USB gadget display system for Eye Remote
**Files Modified:**
- `remote-ui/round/fb_dashboard.py` — Added mode detection, TTY terminal, flash progress, auth QR
- `packages/secubox-hub/debian/secubox-hub.service` — Changed to TCP binding (port 8001)
- `packages/secubox-hub/nginx/hub.conf` — Changed to TCP proxy
- `common/nginx/modules.d/hub.conf` — Changed to TCP proxy
**New Features:**
| Mode | Display | Function |
|------|---------|----------|
| TTY | Serial terminal | Real-time U-Boot/console output from /dev/ttyGS0 |
| FLASH | Progress bar | Transfer progress with speed/ETA for eMMC flashing |
| AUTH | QR code | Backup authentication code for FIDO2 security |
| NORMAL | Dashboard | Standard metrics display |
| DEBUG | Dashboard | Network + storage + serial combined |
**TTY Mode Features:**
- SerialTerminal class reading from /dev/ttyGS0 at 115200 baud
- Real-time line buffering with ASCII filtering
- Monospace font rendering adapted to round display
- Automatic mode switching via /etc/secubox/gadget-mode
**Flash Mode Features:**
- FlashProgress class tracking /var/lib/secubox-flash.img transfers
- Progress bar with percentage, speed (MB/s), and ETA
- Status: WAITING/TRANSFERRING/COMPLETE indicators
**Auth Mode Features:**
- AuthState class with QR code generation
- Device ID based on /etc/machine-id
- Backup authentication URL format: secubox-auth://device_id/challenge
- State machine: idle → pending → approved/denied
**Hub Service Fix:**
- Changed from Unix socket to TCP port 8001 for VM compatibility
- Issue documented in FAQ-Troubleshooting.md and GitHub #34
---

View File

@ -1,6 +1,7 @@
# /etc/nginx/secubox.d/hub.conf
# Installed by secubox-hub package
# Using TCP port for VM compatibility (Unix socket has issues in some VMs)
location /api/v1/hub/ {
proxy_pass http://unix:/run/secubox/hub.sock:/;
proxy_pass http://127.0.0.1:8001/;
include /etc/nginx/snippets/secubox-proxy.conf;
}

View File

@ -432,8 +432,8 @@ create_session() {
log "Real hardware detected - GPU acceleration enabled"
else
# VM - disable GPU to prevent crashes
chromium_gpu_flags="--disable-gpu --disable-gpu-compositing --disable-software-rasterizer"
log "VM detected ($vm_type) - GPU disabled"
chromium_gpu_flags="--disable-gpu --disable-gpu-compositing --disable-software-rasterizer --no-sandbox"
log "VM detected ($vm_type) - GPU disabled, sandbox disabled for VM"
fi
# Write GPU flags for session to read

View File

@ -14,8 +14,9 @@ WorkingDirectory=/usr/lib/secubox/hub
ExecStartPre=+/bin/mkdir -p /run/secubox
ExecStartPre=+/bin/chown secubox:secubox /run/secubox
ExecStartPre=+/bin/chmod 775 /run/secubox
# TCP binding for VM compatibility (Unix socket has issues in some VMs)
ExecStart=/usr/bin/python3 -m uvicorn api.main:app \
--uds /run/secubox/hub.sock \
--host 127.0.0.1 --port 8001 \
--log-level warning
Restart=on-failure
RestartSec=5

View File

@ -1,6 +1,7 @@
# /etc/nginx/secubox.d/hub.conf
# Installed by secubox-hub package
# Using TCP port for VM compatibility (Unix socket has issues in some VMs)
location /api/v1/hub/ {
proxy_pass http://unix:/run/secubox/hub.sock:/;
proxy_pass http://127.0.0.1:8001/;
include /etc/nginx/snippets/secubox-proxy.conf;
}

View File

@ -60,6 +60,25 @@ MODULES = {
# Eye Agent Unix socket
AGENT_SOCKET = '/run/secubox-eye/metrics.sock'
# Gadget mode configuration
MODE_FILE = '/etc/secubox/gadget-mode'
SERIAL_DEV = '/dev/ttyGS0'
SERIAL_BAUD = 115200
# Terminal display settings
TERM_FONT_SIZE = 12
TERM_LINES = 28 # Lines visible in round display
TERM_COLS = 45 # Chars per line (fits 480px with 12px mono font)
# Flash mode settings
FLASH_IMAGE_FILE = '/var/lib/secubox-flash.img'
FLASH_STATS_FILE = '/sys/kernel/config/usb_gadget/secubox/functions/mass_storage.0/lun.0/file'
FLASH_PROGRESS_FILE = '/run/secubox-flash-progress'
# Auth mode settings
AUTH_STATE_FILE = '/run/secubox-auth-state.json'
AUTH_QR_SIZE = 200 # QR code size in pixels
# Module icons directory (relative to script location)
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
ICONS_DIR = os.path.join(SCRIPT_DIR, 'assets', 'icons')
@ -345,6 +364,629 @@ def format_uptime(seconds):
return f'up {hours}h{minutes:02d}'
def get_gadget_mode() -> str:
"""Read current USB gadget mode.
Returns:
Mode string: 'normal', 'flash', 'debug', 'tty', 'auth', or 'normal' as default
"""
try:
if os.path.exists(MODE_FILE):
with open(MODE_FILE, 'r') as f:
mode = f.read().strip().lower()
if mode in ('normal', 'flash', 'debug', 'tty', 'auth'):
return mode
except:
pass
return 'normal'
class SerialTerminal:
"""Read and buffer serial console output for TTY mode display."""
def __init__(self, device=SERIAL_DEV, baud=SERIAL_BAUD, max_lines=100):
self.device = device
self.baud = baud
self.max_lines = max_lines
self.lines: list[str] = []
self.serial = None
self._buffer = ''
def open(self) -> bool:
"""Open serial port for reading."""
try:
import serial
self.serial = serial.Serial(
self.device,
self.baud,
timeout=0.1,
rtscts=False,
dsrdtr=False
)
return True
except ImportError:
print('pyserial not installed')
return False
except Exception as e:
print(f'Serial open error: {e}')
return False
def close(self):
"""Close serial port."""
if self.serial:
try:
self.serial.close()
except:
pass
self.serial = None
def read(self) -> bool:
"""Read available data from serial port.
Returns:
True if new data was read
"""
if not self.serial:
return False
try:
if self.serial.in_waiting > 0:
data = self.serial.read(self.serial.in_waiting)
text = data.decode('utf-8', errors='replace')
self._buffer += text
# Process buffer into lines
while '\n' in self._buffer:
line, self._buffer = self._buffer.split('\n', 1)
# Strip CR and clean control chars (keep basic ASCII)
line = line.rstrip('\r')
line = ''.join(c if 32 <= ord(c) < 127 else ' ' for c in line)
self.lines.append(line)
# Trim old lines
if len(self.lines) > self.max_lines:
self.lines = self.lines[-self.max_lines:]
return True
except Exception as e:
print(f'Serial read error: {e}')
return False
def get_display_lines(self, count=TERM_LINES) -> list[str]:
"""Get last N lines for display.
Args:
count: Number of lines to return
Returns:
List of strings, each truncated to TERM_COLS
"""
display = self.lines[-count:] if self.lines else []
# Pad to fill display
while len(display) < count:
display.insert(0, '')
# Truncate long lines
return [line[:TERM_COLS] for line in display]
def add_simulated_line(self, text: str):
"""Add a line to the buffer (for testing without serial)."""
self.lines.append(text[:TERM_COLS])
if len(self.lines) > self.max_lines:
self.lines = self.lines[-self.max_lines:]
def draw_terminal(term: SerialTerminal, mode: str = 'tty') -> Image.Image:
"""Draw a terminal display for TTY/serial mode.
Args:
term: SerialTerminal instance with buffered output
mode: Current gadget mode (tty, debug, etc.)
Returns:
PIL Image ready for framebuffer
"""
img = Image.new('RGBA', (WIDTH, HEIGHT), BG_COLOR + (255,))
draw = ImageDraw.Draw(img)
cx, cy = WIDTH // 2, HEIGHT // 2
# Load monospace font
try:
font_mono = ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf', TERM_FONT_SIZE)
font_small = ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf', 10)
except:
font_mono = ImageFont.load_default()
font_small = font_mono
# Draw circular mask/border
draw.ellipse([5, 5, WIDTH-5, HEIGHT-5], outline=(40, 40, 50), width=2)
# Header bar
header = f'TTY MODE — {mode.upper()}'
bbox = draw.textbbox((0, 0), header, font=font_small)
tw = bbox[2] - bbox[0]
draw.rectangle([cx-tw//2-10, 8, cx+tw//2+10, 24], fill=(30, 80, 50))
draw.text((cx - tw//2, 10), header, fill=(0, 255, 100), font=font_small)
# Terminal area - calculate visible region (centered circle)
# For round display, we need to shrink text area toward center
term_top = 30
term_left = 25
term_right = WIDTH - 25
line_height = TERM_FONT_SIZE + 2
# Get display lines
lines = term.get_display_lines(TERM_LINES)
# Draw lines with subtle alternating background
y = term_top
for i, line in enumerate(lines):
# Skip lines outside visible circle area
# Simple approximation: reduce width near top/bottom
dist_from_center = abs(y + line_height//2 - cy)
if dist_from_center > 220: # Outside circle
y += line_height
continue
# Calculate available width at this y position
if dist_from_center < 200:
# Use Pythagorean theorem for circle
half_width = int(math.sqrt(220**2 - dist_from_center**2))
else:
half_width = 50
line_x = max(term_left, cx - half_width)
max_chars = (half_width * 2) // (TERM_FONT_SIZE // 2) # Approximate chars
display_line = line[:max_chars]
# Alternate background for readability
if i % 2 == 0 and display_line.strip():
draw.rectangle([line_x, y, cx + half_width, y + line_height], fill=(15, 15, 20))
# Draw text
if display_line.strip():
draw.text((line_x, y), display_line, fill=(0, 255, 100), font=font_mono)
y += line_height
# Footer with status
footer = f'● SERIAL @ {SERIAL_BAUD}'
bbox = draw.textbbox((0, 0), footer, font=font_small)
tw = bbox[2] - bbox[0]
draw.text((cx - tw//2, HEIGHT - 22), footer, fill=(100, 100, 120), font=font_small)
return img
class FlashProgress:
"""Track flash/mass storage transfer progress."""
def __init__(self, image_file=FLASH_IMAGE_FILE):
self.image_file = image_file
self.total_size = 0
self.bytes_read = 0
self.start_time = time.time()
self._last_bytes = 0
self._last_time = time.time()
self.speed_mbps = 0.0
self.active = False
# Get image size
try:
if os.path.exists(image_file):
self.total_size = os.path.getsize(image_file)
print(f'Flash image: {image_file} ({self.total_size / (1024*1024):.1f} MB)')
except:
pass
def update(self) -> bool:
"""Update progress by reading from /proc/diskstats or similar.
Returns:
True if progress changed
"""
# Try to read progress from a status file written by gadget driver
try:
if os.path.exists(FLASH_PROGRESS_FILE):
with open(FLASH_PROGRESS_FILE, 'r') as f:
data = json.load(f)
self.bytes_read = data.get('bytes_read', 0)
self.active = data.get('active', False)
now = time.time()
if now > self._last_time:
elapsed = now - self._last_time
bytes_diff = self.bytes_read - self._last_bytes
if elapsed > 0 and bytes_diff > 0:
self.speed_mbps = (bytes_diff / elapsed) / (1024 * 1024)
self._last_bytes = self.bytes_read
self._last_time = now
return True
except:
pass
# Fallback: check if backing file is being read via /proc
try:
# Use stat to detect access time changes
stat = os.stat(self.image_file)
# This is a rough heuristic - actual bytes read would need eBPF/tracing
self.active = (time.time() - stat.st_atime) < 5
except:
self.active = False
return False
@property
def percent(self) -> float:
"""Get completion percentage."""
if self.total_size <= 0:
return 0.0
return min(100.0, (self.bytes_read / self.total_size) * 100)
@property
def eta_seconds(self) -> int:
"""Estimate time remaining."""
if self.speed_mbps <= 0 or self.total_size <= 0:
return -1
remaining_bytes = self.total_size - self.bytes_read
return int(remaining_bytes / (self.speed_mbps * 1024 * 1024))
def format_size(self, bytes_val: int) -> str:
"""Format bytes as human-readable string."""
if bytes_val >= 1024 * 1024 * 1024:
return f'{bytes_val / (1024*1024*1024):.1f} GB'
elif bytes_val >= 1024 * 1024:
return f'{bytes_val / (1024*1024):.1f} MB'
elif bytes_val >= 1024:
return f'{bytes_val / 1024:.1f} KB'
return f'{bytes_val} B'
def draw_flash_progress(progress: FlashProgress) -> Image.Image:
"""Draw flash mode progress screen.
Args:
progress: FlashProgress instance
Returns:
PIL Image ready for framebuffer
"""
img = Image.new('RGBA', (WIDTH, HEIGHT), BG_COLOR + (255,))
draw = ImageDraw.Draw(img)
cx, cy = WIDTH // 2, HEIGHT // 2
# Load fonts
try:
font_large = ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf', 36)
font_medium = ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf', 16)
font_small = ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf', 12)
except:
font_large = ImageFont.load_default()
font_medium = font_large
font_small = font_large
# Draw circular border
draw.ellipse([10, 10, WIDTH-10, HEIGHT-10], outline=(40, 40, 50), width=2)
# Header
header = 'FLASH MODE'
bbox = draw.textbbox((0, 0), header, font=font_medium)
tw = bbox[2] - bbox[0]
draw.text((cx - tw//2, 25), header, fill=(255, 100, 0), font=font_medium)
# Flash icon (simplified USB icon)
icon = load_module_icon('BOOT', 96)
if icon:
img.paste(icon, (cx - 48, 55), icon)
# Status text
if progress.active:
status = 'TRANSFERRING...'
status_color = (0, 255, 100)
elif progress.percent > 0:
status = 'TRANSFER COMPLETE'
status_color = (0, 200, 255)
else:
status = 'WAITING FOR HOST'
status_color = (255, 200, 0)
bbox = draw.textbbox((0, 0), status, font=font_small)
tw = bbox[2] - bbox[0]
draw.text((cx - tw//2, 160), status, fill=status_color, font=font_small)
# Progress bar (circular arc style)
bar_y = 190
bar_width = 280
bar_height = 20
bar_x = cx - bar_width // 2
# Background
draw.rounded_rectangle(
[bar_x, bar_y, bar_x + bar_width, bar_y + bar_height],
radius=10,
fill=(30, 30, 40)
)
# Progress fill
fill_width = int(bar_width * (progress.percent / 100))
if fill_width > 0:
# Gradient-like effect with orange to yellow
draw.rounded_rectangle(
[bar_x, bar_y, bar_x + fill_width, bar_y + bar_height],
radius=10,
fill=(255, 150, 0)
)
# Percentage text
pct_text = f'{progress.percent:.0f}%'
bbox = draw.textbbox((0, 0), pct_text, font=font_large)
tw = bbox[2] - bbox[0]
draw.text((cx - tw//2, 220), pct_text, fill=(255, 255, 255), font=font_large)
# Size info
size_text = f'{progress.format_size(progress.bytes_read)} / {progress.format_size(progress.total_size)}'
bbox = draw.textbbox((0, 0), size_text, font=font_small)
tw = bbox[2] - bbox[0]
draw.text((cx - tw//2, 270), size_text, fill=TEXT_MUTED, font=font_small)
# Speed and ETA
if progress.speed_mbps > 0:
speed_text = f'{progress.speed_mbps:.1f} MB/s'
bbox = draw.textbbox((0, 0), speed_text, font=font_small)
tw = bbox[2] - bbox[0]
draw.text((cx - tw//2, 290), speed_text, fill=(100, 200, 100), font=font_small)
if progress.eta_seconds > 0:
eta_min = progress.eta_seconds // 60
eta_sec = progress.eta_seconds % 60
eta_text = f'ETA: {eta_min}:{eta_sec:02d}'
bbox = draw.textbbox((0, 0), eta_text, font=font_small)
tw = bbox[2] - bbox[0]
draw.text((cx - tw//2, 310), eta_text, fill=TEXT_MUTED, font=font_small)
# Footer with instructions
footer = 'Boot from USB to flash SecuBox'
bbox = draw.textbbox((0, 0), footer, font=font_small)
tw = bbox[2] - bbox[0]
draw.text((cx - tw//2, HEIGHT - 50), footer, fill=(100, 100, 120), font=font_small)
# CyberMind branding
brand = 'SECUBOX EYE'
bbox = draw.textbbox((0, 0), brand, font=font_small)
tw = bbox[2] - bbox[0]
draw.text((cx - tw//2, HEIGHT - 30), brand, fill=(201, 168, 76), font=font_small)
return img
class AuthState:
"""Manage authentication state for AUTH mode (FIDO2 security key)."""
def __init__(self):
self.device_id = ''
self.challenge = ''
self.qr_url = ''
self.state = 'idle' # idle, pending, approved, denied
self.last_update = 0
self._qr_image = None
# Try to load device ID
try:
import uuid
machine_id_path = '/etc/machine-id'
if os.path.exists(machine_id_path):
with open(machine_id_path, 'r') as f:
self.device_id = f.read().strip()[:12]
else:
self.device_id = uuid.uuid4().hex[:12]
except:
self.device_id = 'eye-remote'
def update(self) -> bool:
"""Update auth state from status file.
Returns:
True if state changed
"""
try:
if os.path.exists(AUTH_STATE_FILE):
with open(AUTH_STATE_FILE, 'r') as f:
data = json.load(f)
old_state = self.state
self.state = data.get('state', 'idle')
self.challenge = data.get('challenge', '')
self.qr_url = data.get('qr_url', '')
self.last_update = data.get('timestamp', 0)
# Regenerate QR if URL changed
if self.qr_url and self.qr_url != getattr(self, '_last_qr_url', ''):
self._generate_qr()
self._last_qr_url = self.qr_url
return self.state != old_state
except:
pass
return False
def _generate_qr(self):
"""Generate QR code image from URL."""
try:
import qrcode
# Create QR code with medium error correction
qr = qrcode.QRCode(
version=1,
error_correction=1, # ERROR_CORRECT_M = 1
box_size=6,
border=2,
)
qr.add_data(self.qr_url)
qr.make(fit=True)
# Create image with PIL
qr_img = qr.make_image(fill_color='white', back_color='black')
# Convert to PIL Image - handle different qrcode versions
try:
# Try PIL image factory (qrcode >= 7.0)
if hasattr(qr_img, '_img'):
pil_img = qr_img._img
elif hasattr(qr_img, 'get_image'):
pil_img = qr_img.get_image()
else:
# Fallback: convert via tobytes/frombytes
pil_img = qr_img
except Exception:
pil_img = qr_img
# Ensure it's an RGBA image at the right size
if hasattr(pil_img, 'convert'):
self._qr_image = pil_img.convert('RGBA').resize(
(AUTH_QR_SIZE, AUTH_QR_SIZE),
Image.Resampling.NEAREST if hasattr(Image, 'Resampling') else Image.NEAREST
)
else:
# Last resort: create blank image
self._qr_image = Image.new('RGBA', (AUTH_QR_SIZE, AUTH_QR_SIZE), (255, 255, 255, 255))
except ImportError:
print('qrcode library not installed')
self._qr_image = None
except Exception as e:
print(f'QR generation error: {e}')
self._qr_image = None
def get_qr_image(self):
"""Get the QR code image.
Returns:
PIL Image or None if not generated
"""
return self._qr_image
def generate_backup_code(self) -> str:
"""Generate a backup authentication URL for QR code.
Uses device ID and a timestamp-based challenge.
"""
import hashlib
ts = int(time.time())
data = f'{self.device_id}:{ts}'
challenge = hashlib.sha256(data.encode()).hexdigest()[:16]
self.challenge = challenge
# Format: secubox-auth://device_id/challenge
self.qr_url = f'secubox-auth://{self.device_id}/{challenge}'
self._generate_qr()
return self.qr_url
def draw_auth_mode(auth: AuthState) -> Image.Image:
"""Draw auth mode screen with QR code.
Args:
auth: AuthState instance
Returns:
PIL Image ready for framebuffer
"""
img = Image.new('RGBA', (WIDTH, HEIGHT), BG_COLOR + (255,))
draw = ImageDraw.Draw(img)
cx, cy = WIDTH // 2, HEIGHT // 2
# Load fonts
try:
font_large = ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf', 24)
font_medium = ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf', 14)
font_small = ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf', 11)
except:
font_large = ImageFont.load_default()
font_medium = font_large
font_small = font_large
# Draw circular border
draw.ellipse([10, 10, WIDTH-10, HEIGHT-10], outline=(40, 40, 50), width=2)
# Header
header = 'AUTH MODE'
bbox = draw.textbbox((0, 0), header, font=font_medium)
tw = bbox[2] - bbox[0]
draw.text((cx - tw//2, 20), header, fill=(192, 78, 36), font=font_medium)
# Auth icon
icon = load_module_icon('AUTH', 48)
if icon:
img.paste(icon, (cx - 24, 45), icon)
# State indicator
state_colors = {
'idle': (100, 100, 120),
'pending': (255, 200, 0),
'approved': (0, 255, 100),
'denied': (255, 50, 50),
}
state_texts = {
'idle': 'READY TO AUTHENTICATE',
'pending': 'TOUCH TO APPROVE',
'approved': 'AUTHENTICATED',
'denied': 'ACCESS DENIED',
}
state_color = state_colors.get(auth.state, state_colors['idle'])
state_text = state_texts.get(auth.state, 'UNKNOWN')
bbox = draw.textbbox((0, 0), state_text, font=font_small)
tw = bbox[2] - bbox[0]
draw.text((cx - tw//2, 100), state_text, fill=state_color, font=font_small)
# QR code (centered, below status)
qr_img = auth.get_qr_image()
if qr_img:
qr_x = cx - AUTH_QR_SIZE // 2
qr_y = 125
img.paste(qr_img, (qr_x, qr_y), qr_img)
# QR label
qr_label = 'BACKUP CODE'
bbox = draw.textbbox((0, 0), qr_label, font=font_small)
tw = bbox[2] - bbox[0]
draw.text((cx - tw//2, qr_y + AUTH_QR_SIZE + 5), qr_label, fill=TEXT_MUTED, font=font_small)
else:
# No QR - show placeholder
draw.rectangle(
[cx - AUTH_QR_SIZE//2, 125, cx + AUTH_QR_SIZE//2, 125 + AUTH_QR_SIZE],
outline=(50, 50, 60),
width=2
)
placeholder = 'QR CODE'
bbox = draw.textbbox((0, 0), placeholder, font=font_medium)
tw = bbox[2] - bbox[0]
th = bbox[3] - bbox[1]
draw.text((cx - tw//2, 125 + AUTH_QR_SIZE//2 - th//2), placeholder, fill=(60, 60, 70), font=font_medium)
# Device ID
device_text = f'Device: {auth.device_id}'
bbox = draw.textbbox((0, 0), device_text, font=font_small)
tw = bbox[2] - bbox[0]
draw.text((cx - tw//2, HEIGHT - 80), device_text, fill=TEXT_MUTED, font=font_small)
# Instructions
if auth.state == 'pending':
instr = 'Touch the display to approve'
else:
instr = 'Connect USB for FIDO2 auth'
bbox = draw.textbbox((0, 0), instr, font=font_small)
tw = bbox[2] - bbox[0]
draw.text((cx - tw//2, HEIGHT - 55), instr, fill=(100, 100, 120), font=font_small)
# Branding
brand = 'SECUBOX EYE'
bbox = draw.textbbox((0, 0), brand, font=font_small)
tw = bbox[2] - bbox[0]
draw.text((cx - tw//2, HEIGHT - 30), brand, fill=(201, 168, 76), font=font_small)
return img
def draw_dashboard(metrics, mode='SIM', host='', device_name=''):
"""Draw the dashboard to an image
@ -581,6 +1223,10 @@ def main():
else:
print(f'WARNING: {FB_DEV} not found!')
# Check initial gadget mode
gadget_mode = get_gadget_mode()
print(f'Gadget mode: {gadget_mode}')
# Try agent first, fall back to simulation
if os.path.exists(AGENT_SOCKET):
print(f'Eye Agent socket found: {AGENT_SOCKET}')
@ -588,27 +1234,123 @@ def main():
print('Agent not running, will use simulation mode')
source = AgentMetricsSource()
terminal = None
flash_progress = None
# Initialize serial terminal for TTY mode
if gadget_mode == 'tty':
terminal = SerialTerminal()
if terminal.open():
print(f'Serial terminal opened: {SERIAL_DEV}')
else:
print('Serial terminal failed to open, using simulation')
# Add some simulated lines for testing
terminal.add_simulated_line('U-Boot 2024.01 (Apr 15 2026)')
terminal.add_simulated_line('')
terminal.add_simulated_line('Marvell ARMADA 3720 ESPRESSOBIN Board')
terminal.add_simulated_line('')
terminal.add_simulated_line('DRAM: 1 GiB')
terminal.add_simulated_line('SF: Detected w25q32dw with page size 256 Bytes')
terminal.add_simulated_line('Net: eth0: mvpp2-0, eth1: mvpp2-1')
terminal.add_simulated_line('')
terminal.add_simulated_line('Hit any key to stop autoboot: 0')
terminal.add_simulated_line('=> ')
# Initialize flash progress for FLASH mode
if gadget_mode == 'flash':
flash_progress = FlashProgress()
print(f'Flash mode initialized, image size: {flash_progress.format_size(flash_progress.total_size)}')
# Initialize auth state for AUTH mode
auth_state = None
if gadget_mode == 'auth':
auth_state = AuthState()
auth_state.generate_backup_code()
print(f'Auth mode initialized, device: {auth_state.device_id}')
# Draw initial frame immediately
print('Drawing initial frame...')
try:
metrics, mode, host, device_name = source.get_metrics()
img = draw_dashboard(metrics, mode, host, device_name)
if gadget_mode == 'tty' and terminal:
img = draw_terminal(terminal, gadget_mode)
elif gadget_mode == 'flash' and flash_progress:
img = draw_flash_progress(flash_progress)
elif gadget_mode == 'auth':
if not auth_state:
auth_state = AuthState()
auth_state.generate_backup_code()
img = draw_auth_mode(auth_state)
else:
metrics, mode, host, device_name = source.get_metrics()
img = draw_dashboard(metrics, mode, host, device_name)
write_to_fb(img)
print(f'Initial frame drawn, mode={mode}')
print(f'Initial frame drawn, gadget_mode={gadget_mode}')
except Exception as e:
print(f'Initial frame error: {e}')
last_gadget_mode = gadget_mode
while True:
try:
metrics, mode, host, device_name = source.get_metrics()
img = draw_dashboard(metrics, mode, host, device_name)
write_to_fb(img)
# Check for mode changes
gadget_mode = get_gadget_mode()
if gadget_mode != last_gadget_mode:
print(f'Mode changed: {last_gadget_mode} -> {gadget_mode}')
last_gadget_mode = gadget_mode
# Handle terminal lifecycle
if gadget_mode == 'tty':
if not terminal:
terminal = SerialTerminal()
if not terminal.serial:
terminal.open()
elif terminal and terminal.serial:
terminal.close()
# Handle flash progress lifecycle
if gadget_mode == 'flash':
if not flash_progress:
flash_progress = FlashProgress()
print(f'Flash mode initialized')
# Handle auth state lifecycle
if gadget_mode == 'auth':
if not auth_state:
auth_state = AuthState()
auth_state.generate_backup_code()
print(f'Auth mode initialized')
# Render based on current mode
if gadget_mode == 'tty' and terminal:
# Read any new serial data
terminal.read()
img = draw_terminal(terminal, gadget_mode)
write_to_fb(img)
time.sleep(0.1) # Faster refresh for serial data
elif gadget_mode == 'flash':
# Flash mode with progress
if not flash_progress:
flash_progress = FlashProgress()
flash_progress.update()
img = draw_flash_progress(flash_progress)
write_to_fb(img)
time.sleep(0.5) # Medium refresh for progress updates
elif gadget_mode == 'auth':
# Auth mode with QR code
if not auth_state:
auth_state = AuthState()
auth_state.generate_backup_code()
auth_state.update()
img = draw_auth_mode(auth_state)
write_to_fb(img)
time.sleep(0.5) # Medium refresh for auth updates
else:
# Standard dashboard mode
metrics, mode, host, device_name = source.get_metrics()
img = draw_dashboard(metrics, mode, host, device_name)
write_to_fb(img)
time.sleep(1)
# Log connection changes
if mode != 'SIM' and host:
pass # Connected to real device
time.sleep(1)
except KeyboardInterrupt:
print('\nExiting...')
break
@ -616,6 +1358,10 @@ def main():
print(f'Error: {e}')
time.sleep(1)
# Cleanup
if terminal:
terminal.close()
if __name__ == '__main__':
main()

View File

@ -0,0 +1,722 @@
#!/usr/bin/env python3
"""
SecuBox Profile Generator GUI Application
============================================
Multiplatform GUI for generating SecuBox appliance profiles.
Can be launched via USB autorun on Windows/Mac/Linux.
CyberMind https://cybermind.fr
Author: Gérald Kerma <gandalf@gk2.net>
"""
import sys
import os
import json
import platform
from pathlib import Path
from dataclasses import dataclass, field
from typing import Optional, Dict, List
from enum import Enum
# Try PyQt6 first, fall back to tkinter
try:
from PyQt6.QtWidgets import (
QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
QLabel, QPushButton, QComboBox, QCheckBox, QGroupBox, QTabWidget,
QListWidget, QListWidgetItem, QProgressBar, QTextEdit, QFileDialog,
QMessageBox, QFrame, QSplitter, QScrollArea, QGridLayout, QSpacerItem,
QSizePolicy, QStackedWidget
)
from PyQt6.QtCore import Qt, QThread, pyqtSignal, QTimer
from PyQt6.QtGui import QFont, QColor, QPalette, QIcon, QPixmap
USE_QT = True
except ImportError:
import tkinter as tk
from tkinter import ttk, messagebox, filedialog
USE_QT = False
# ══════════════════════════════════════════════════════════════════════════════
# Data Models (from profile-generator.md v0.2)
# ══════════════════════════════════════════════════════════════════════════════
class Tier(Enum):
LITE = "lite" # ≤1GB RAM, ≤2 cores
STANDARD = "standard" # 2-4GB RAM, 2-4 cores
PRO = "pro" # 4GB+ RAM, 4+ cores
class ModuleLevel(Enum):
DISABLED = "disabled"
MINIMAL = "minimal"
STANDARD = "standard"
FULL = "full"
@dataclass
class Board:
id: str
name: str
arch: str
min_tier: Tier
ram_mb: int
cores: int
description: str
icon: str = "🖥️"
@dataclass
class Module:
id: str
name: str
color: str # Light 3 palette
zkp_layer: str # L1, L2, L3, or "-"
description: str
@dataclass
class Profile:
tier: Tier
board: Board
stage: str = "dev" # dev / staging / cspn-frozen
modules: Dict[str, ModuleLevel] = field(default_factory=dict)
tweaks: List[str] = field(default_factory=list)
components: List[str] = field(default_factory=list)
# ══════════════════════════════════════════════════════════════════════════════
# Static Data
# ══════════════════════════════════════════════════════════════════════════════
BOARDS = [
Board("rpi-zero-w", "Raspberry Pi Zero W", "arm", Tier.LITE, 512, 1,
"Minimal USB gadget device", "🍓"),
Board("espressobin-v7", "ESPRESSObin v7", "arm64", Tier.LITE, 1024, 2,
"Compact network appliance", ""),
Board("rpi4", "Raspberry Pi 4", "arm64", Tier.STANDARD, 4096, 4,
"Versatile SBC", "🍓"),
Board("vm-x64", "Virtual Machine x64", "amd64", Tier.STANDARD, 2048, 2,
"VirtualBox/QEMU/VMware", "💻"),
Board("mochabin", "MOCHAbin", "arm64", Tier.PRO, 8192, 4,
"High-performance network appliance", ""),
Board("bare-metal", "Bare Metal x64", "amd64", Tier.PRO, 16384, 8,
"Server / Desktop hardware", "🖥️"),
]
MODULES = [
Module("auth", "AUTH", "#C04E24", "L1", "NIZK Hamiltonian, G rotation 24h, PFS"),
Module("wall", "WALL", "#9A6010", "-", "nftables, CrowdSec, rate limiting"),
Module("boot", "BOOT", "#803018", "-", "secure boot, LUKS, dm-verity"),
Module("mind", "MIND", "#3D35A0", "L2", "nDPId, mitmproxy bridge, DPI"),
Module("root", "ROOT", "#0A5840", "-", "base Debian, kernel, systemd"),
Module("mesh", "MESH", "#104A88", "L3", "WireGuard, Tailscale, MirrorNet"),
]
STAGES = [
("dev", "Development", "Unrestricted, all debug enabled"),
("staging", "Staging", "Production-like, logs enabled"),
("cspn-frozen", "CSPN Frozen", "Certified config, no modifications"),
]
COMPONENTS = {
"security": [
("secubox-crowdsec", "CrowdSec IDS/IPS", True),
("secubox-suricata", "Suricata IDS", False),
("secubox-nftables", "nftables Firewall", True),
("secubox-fail2ban", "Fail2ban", False),
],
"network": [
("secubox-wireguard", "WireGuard VPN", True),
("secubox-tailscale", "Tailscale Mesh", False),
("secubox-unbound", "Unbound DNS", True),
("secubox-haproxy", "HAProxy LB", True),
],
"monitoring": [
("secubox-netdata", "Netdata", True),
("secubox-prometheus", "Prometheus", False),
("secubox-grafana", "Grafana", False),
],
"services": [
("secubox-mitmproxy", "mitmproxy WAF", True),
("secubox-nginx", "Nginx", True),
("secubox-squid", "Squid Cache", False),
],
}
# ══════════════════════════════════════════════════════════════════════════════
# SecuBox Color Palette (SPIRITUALCEPT Light 3)
# ══════════════════════════════════════════════════════════════════════════════
COLORS = {
"bg_main": "#f5f0e8", # Warm parchment
"bg_panel": "#ebe5d9", # Panel background
"bg_card": "#ffffff", # Card background
"text_primary": "#2d2a24", # Dark brown text
"text_secondary": "#5c564a", # Muted text
"accent_gold": "#c9a84c", # Hermetic gold
"accent_red": "#c04e24", # AUTH red
"border": "#d4cfc3", # Subtle border
"success": "#0a5840", # ROOT green
"warning": "#9a6010", # WALL amber
"error": "#803018", # BOOT rust
}
# ══════════════════════════════════════════════════════════════════════════════
# PyQt6 GUI Implementation
# ══════════════════════════════════════════════════════════════════════════════
if USE_QT:
class ModuleCard(QFrame):
"""Card widget for a single module with level selector."""
def __init__(self, module: Module, parent=None):
super().__init__(parent)
self.module = module
self.setup_ui()
def setup_ui(self):
self.setFrameStyle(QFrame.Shape.StyledPanel)
self.setStyleSheet(f"""
QFrame {{
background-color: {COLORS['bg_card']};
border: 2px solid {self.module.color};
border-radius: 8px;
padding: 8px;
}}
""")
layout = QVBoxLayout(self)
layout.setSpacing(8)
# Header with module name and ZKP layer
header = QHBoxLayout()
name_label = QLabel(f"<b>{self.module.name}</b>")
name_label.setStyleSheet(f"color: {self.module.color}; font-size: 14px;")
header.addWidget(name_label)
if self.module.zkp_layer != "-":
layer_label = QLabel(f"[{self.module.zkp_layer}]")
layer_label.setStyleSheet(f"color: {COLORS['text_secondary']}; font-size: 12px;")
header.addWidget(layer_label)
header.addStretch()
layout.addLayout(header)
# Description
desc_label = QLabel(self.module.description)
desc_label.setWordWrap(True)
desc_label.setStyleSheet(f"color: {COLORS['text_secondary']}; font-size: 11px;")
layout.addWidget(desc_label)
# Level selector
self.level_combo = QComboBox()
for level in ModuleLevel:
self.level_combo.addItem(level.value.capitalize(), level)
self.level_combo.setCurrentText("Standard")
layout.addWidget(self.level_combo)
def get_level(self) -> ModuleLevel:
return self.level_combo.currentData()
class BoardSelector(QWidget):
"""Board selection widget with filtering by tier."""
board_changed = pyqtSignal(Board)
def __init__(self, parent=None):
super().__init__(parent)
self.setup_ui()
def setup_ui(self):
layout = QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
# Tier filter
filter_layout = QHBoxLayout()
filter_layout.addWidget(QLabel("Tier:"))
self.tier_combo = QComboBox()
self.tier_combo.addItem("All", None)
for tier in Tier:
self.tier_combo.addItem(tier.value.capitalize(), tier)
self.tier_combo.currentIndexChanged.connect(self.filter_boards)
filter_layout.addWidget(self.tier_combo)
filter_layout.addStretch()
layout.addLayout(filter_layout)
# Board list
self.board_list = QListWidget()
self.board_list.setStyleSheet(f"""
QListWidget {{
background-color: {COLORS['bg_card']};
border: 1px solid {COLORS['border']};
border-radius: 4px;
}}
QListWidget::item {{
padding: 8px;
border-bottom: 1px solid {COLORS['border']};
}}
QListWidget::item:selected {{
background-color: {COLORS['accent_gold']};
color: white;
}}
""")
self.board_list.itemClicked.connect(self.on_board_selected)
layout.addWidget(self.board_list)
self.populate_boards()
def populate_boards(self, tier_filter: Optional[Tier] = None):
self.board_list.clear()
for board in BOARDS:
if tier_filter and board.min_tier != tier_filter:
continue
item = QListWidgetItem(f"{board.icon} {board.name}")
item.setData(Qt.ItemDataRole.UserRole, board)
item.setToolTip(f"{board.description}\nArch: {board.arch}\nRAM: {board.ram_mb}MB\nCores: {board.cores}")
self.board_list.addItem(item)
def filter_boards(self):
tier = self.tier_combo.currentData()
self.populate_boards(tier)
def on_board_selected(self, item):
board = item.data(Qt.ItemDataRole.UserRole)
self.board_changed.emit(board)
def get_selected_board(self) -> Optional[Board]:
item = self.board_list.currentItem()
if item:
return item.data(Qt.ItemDataRole.UserRole)
return None
class ComponentsPanel(QWidget):
"""Component selection panel organized by category."""
def __init__(self, parent=None):
super().__init__(parent)
self.checkboxes = {}
self.setup_ui()
def setup_ui(self):
layout = QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
scroll = QScrollArea()
scroll.setWidgetResizable(True)
scroll.setStyleSheet("QScrollArea { border: none; }")
content = QWidget()
content_layout = QVBoxLayout(content)
for category, components in COMPONENTS.items():
group = QGroupBox(category.capitalize())
group.setStyleSheet(f"""
QGroupBox {{
font-weight: bold;
border: 1px solid {COLORS['border']};
border-radius: 4px;
margin-top: 12px;
padding-top: 8px;
}}
QGroupBox::title {{
color: {COLORS['accent_gold']};
}}
""")
group_layout = QVBoxLayout(group)
for comp_id, comp_name, default in components:
cb = QCheckBox(comp_name)
cb.setChecked(default)
cb.setStyleSheet(f"color: {COLORS['text_primary']};")
group_layout.addWidget(cb)
self.checkboxes[comp_id] = cb
content_layout.addWidget(group)
content_layout.addStretch()
scroll.setWidget(content)
layout.addWidget(scroll)
def get_selected_components(self) -> List[str]:
return [comp_id for comp_id, cb in self.checkboxes.items() if cb.isChecked()]
class OutputPanel(QWidget):
"""Panel showing generated manifest and actions."""
def __init__(self, parent=None):
super().__init__(parent)
self.setup_ui()
def setup_ui(self):
layout = QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
# Output format selector
format_layout = QHBoxLayout()
format_layout.addWidget(QLabel("Output Format:"))
self.format_combo = QComboBox()
self.format_combo.addItems(["ISO Live", "VDI (VirtualBox)", "QCOW2", "Raw IMG", "Chroot Tarball"])
format_layout.addWidget(self.format_combo)
format_layout.addStretch()
layout.addLayout(format_layout)
# Manifest preview
preview_label = QLabel("Generated Manifest:")
preview_label.setStyleSheet(f"color: {COLORS['text_secondary']}; font-weight: bold;")
layout.addWidget(preview_label)
self.manifest_preview = QTextEdit()
self.manifest_preview.setReadOnly(True)
self.manifest_preview.setStyleSheet(f"""
QTextEdit {{
background-color: {COLORS['bg_card']};
border: 1px solid {COLORS['border']};
border-radius: 4px;
font-family: 'JetBrains Mono', 'Consolas', monospace;
font-size: 11px;
}}
""")
layout.addWidget(self.manifest_preview)
# Progress bar
self.progress = QProgressBar()
self.progress.setVisible(False)
layout.addWidget(self.progress)
# Action buttons
btn_layout = QHBoxLayout()
self.save_btn = QPushButton("💾 Save Manifest")
self.save_btn.setStyleSheet(self.button_style(COLORS['accent_gold']))
btn_layout.addWidget(self.save_btn)
self.build_btn = QPushButton("🔨 Build Image")
self.build_btn.setStyleSheet(self.button_style(COLORS['success']))
btn_layout.addWidget(self.build_btn)
self.fetch_btn = QPushButton("⬇️ Fetch Pre-built")
self.fetch_btn.setStyleSheet(self.button_style(COLORS['accent_red']))
btn_layout.addWidget(self.fetch_btn)
layout.addLayout(btn_layout)
def button_style(self, color: str) -> str:
return f"""
QPushButton {{
background-color: {color};
color: white;
border: none;
border-radius: 4px;
padding: 10px 20px;
font-weight: bold;
}}
QPushButton:hover {{
opacity: 0.9;
}}
"""
def update_manifest(self, manifest: dict):
import yaml
try:
text = yaml.dump(manifest, default_flow_style=False, allow_unicode=True)
except:
text = json.dumps(manifest, indent=2)
self.manifest_preview.setText(text)
class SecuBoxGenGUI(QMainWindow):
"""Main application window."""
def __init__(self):
super().__init__()
self.current_profile = Profile(tier=Tier.STANDARD, board=BOARDS[3]) # Default: vm-x64
self.setup_ui()
self.update_manifest()
def setup_ui(self):
self.setWindowTitle("SecuBox Profile Generator")
self.setMinimumSize(1200, 800)
self.setStyleSheet(f"background-color: {COLORS['bg_main']};")
# Central widget
central = QWidget()
self.setCentralWidget(central)
main_layout = QHBoxLayout(central)
main_layout.setSpacing(16)
main_layout.setContentsMargins(16, 16, 16, 16)
# Left panel: Board & Stage selection
left_panel = QWidget()
left_panel.setMaximumWidth(300)
left_layout = QVBoxLayout(left_panel)
# Logo/Header
header = QLabel("⬡ SecuBox Generator")
header.setStyleSheet(f"""
font-size: 20px;
font-weight: bold;
color: {COLORS['accent_gold']};
padding: 8px;
""")
left_layout.addWidget(header)
# Board selector
board_group = QGroupBox("Target Board")
board_group.setStyleSheet(self.group_style())
board_layout = QVBoxLayout(board_group)
self.board_selector = BoardSelector()
self.board_selector.board_changed.connect(self.on_board_changed)
board_layout.addWidget(self.board_selector)
left_layout.addWidget(board_group)
# Stage selector
stage_group = QGroupBox("Stage")
stage_group.setStyleSheet(self.group_style())
stage_layout = QVBoxLayout(stage_group)
self.stage_combo = QComboBox()
for stage_id, stage_name, stage_desc in STAGES:
self.stage_combo.addItem(f"{stage_name}", stage_id)
self.stage_combo.currentIndexChanged.connect(self.update_manifest)
stage_layout.addWidget(self.stage_combo)
left_layout.addWidget(stage_group)
left_layout.addStretch()
main_layout.addWidget(left_panel)
# Center panel: Modules
center_panel = QWidget()
center_layout = QVBoxLayout(center_panel)
modules_label = QLabel("Modules Configuration")
modules_label.setStyleSheet(f"font-size: 16px; font-weight: bold; color: {COLORS['text_primary']};")
center_layout.addWidget(modules_label)
modules_grid = QGridLayout()
modules_grid.setSpacing(12)
self.module_cards = {}
for i, module in enumerate(MODULES):
card = ModuleCard(module)
card.level_combo.currentIndexChanged.connect(self.update_manifest)
self.module_cards[module.id] = card
modules_grid.addWidget(card, i // 3, i % 3)
center_layout.addLayout(modules_grid)
# Components panel
components_label = QLabel("Components")
components_label.setStyleSheet(f"font-size: 16px; font-weight: bold; color: {COLORS['text_primary']};")
center_layout.addWidget(components_label)
self.components_panel = ComponentsPanel()
center_layout.addWidget(self.components_panel)
main_layout.addWidget(center_panel, stretch=2)
# Right panel: Output
right_panel = QWidget()
right_panel.setMaximumWidth(400)
right_layout = QVBoxLayout(right_panel)
output_label = QLabel("Output")
output_label.setStyleSheet(f"font-size: 16px; font-weight: bold; color: {COLORS['text_primary']};")
right_layout.addWidget(output_label)
self.output_panel = OutputPanel()
self.output_panel.save_btn.clicked.connect(self.save_manifest)
self.output_panel.build_btn.clicked.connect(self.build_image)
self.output_panel.fetch_btn.clicked.connect(self.fetch_prebuilt)
right_layout.addWidget(self.output_panel)
main_layout.addWidget(right_panel)
def group_style(self) -> str:
return f"""
QGroupBox {{
font-weight: bold;
border: 1px solid {COLORS['border']};
border-radius: 8px;
margin-top: 12px;
padding: 12px;
background-color: {COLORS['bg_panel']};
}}
QGroupBox::title {{
color: {COLORS['accent_gold']};
subcontrol-position: top left;
padding: 0 8px;
}}
"""
def on_board_changed(self, board: Board):
self.current_profile.board = board
self.current_profile.tier = board.min_tier
self.update_manifest()
def update_manifest(self):
board = self.board_selector.get_selected_board()
if not board:
board = BOARDS[3] # Default vm-x64
stage = self.stage_combo.currentData() or "dev"
modules = {}
for mod_id, card in self.module_cards.items():
modules[mod_id] = card.get_level().value
components = self.components_panel.get_selected_components()
manifest = {
"schema_version": 1,
"profile": {
"tier": board.min_tier.value,
"board": board.id,
"stage": stage,
},
"modules": modules,
"components": components,
"tweaks": [],
}
self.output_panel.update_manifest(manifest)
def save_manifest(self):
filepath, _ = QFileDialog.getSaveFileName(
self, "Save Manifest", "manifest.yaml", "YAML Files (*.yaml *.yml)"
)
if filepath:
text = self.output_panel.manifest_preview.toPlainText()
Path(filepath).write_text(text)
QMessageBox.information(self, "Saved", f"Manifest saved to:\n{filepath}")
def build_image(self):
QMessageBox.information(
self, "Build Image",
"This will call:\n\nsecubox-build --manifest manifest.yaml --format iso-live\n\n"
"(Not implemented in mockup)"
)
def fetch_prebuilt(self):
board = self.board_selector.get_selected_board()
if not board:
QMessageBox.warning(self, "No Board", "Please select a target board first.")
return
QMessageBox.information(
self, "Fetch Pre-built",
f"This will call:\n\nsecubox-fetch download --board {board.id}\n\n"
"(Not implemented in mockup)"
)
def run_qt_app():
app = QApplication(sys.argv)
# Set application-wide font
font = QFont("Segoe UI", 10)
app.setFont(font)
window = SecuBoxGenGUI()
window.show()
sys.exit(app.exec())
# ══════════════════════════════════════════════════════════════════════════════
# Tkinter Fallback Implementation (minimal)
# ══════════════════════════════════════════════════════════════════════════════
else:
def run_tk_app():
root = tk.Tk()
root.title("SecuBox Profile Generator")
root.geometry("800x600")
root.configure(bg=COLORS['bg_main'])
# Header
header = tk.Label(
root, text="⬡ SecuBox Profile Generator",
font=("Arial", 18, "bold"),
fg=COLORS['accent_gold'],
bg=COLORS['bg_main']
)
header.pack(pady=20)
# Info label
info = tk.Label(
root,
text="Install PyQt6 for the full GUI experience:\n\npip install PyQt6",
font=("Arial", 12),
fg=COLORS['text_secondary'],
bg=COLORS['bg_main']
)
info.pack(pady=20)
# Board selector
frame = tk.Frame(root, bg=COLORS['bg_panel'])
frame.pack(pady=10, padx=20, fill='x')
tk.Label(frame, text="Target Board:", bg=COLORS['bg_panel']).pack(side='left')
board_var = tk.StringVar(value="vm-x64")
board_combo = ttk.Combobox(frame, textvariable=board_var, values=[b.id for b in BOARDS])
board_combo.pack(side='left', padx=10)
# Generate button
def generate():
board = board_var.get()
messagebox.showinfo("Generate", f"Would generate manifest for: {board}")
btn = tk.Button(
root, text="Generate Manifest",
command=generate,
bg=COLORS['accent_gold'],
fg='white'
)
btn.pack(pady=20)
root.mainloop()
# ══════════════════════════════════════════════════════════════════════════════
# USB Autorun Entry Point
# ══════════════════════════════════════════════════════════════════════════════
def detect_usb_mode() -> bool:
"""Detect if running from USB drive (portable mode)."""
exe_path = Path(sys.executable if getattr(sys, 'frozen', False) else __file__).resolve()
if platform.system() == "Windows":
# Check if on removable drive
import ctypes
drive = str(exe_path)[:2]
return ctypes.windll.kernel32.GetDriveTypeW(drive) == 2 # DRIVE_REMOVABLE
else:
# Check if path contains /media/ or /mnt/ (typical USB mount points)
return any(p in str(exe_path) for p in ['/media/', '/mnt/', '/Volumes/'])
def main():
"""Main entry point."""
# Check for portable/USB mode
portable = detect_usb_mode()
if portable:
print("Running in portable USB mode")
# Could load config from USB drive here
if USE_QT:
run_qt_app()
else:
run_tk_app()
if __name__ == "__main__":
main()

156
wiki/FAQ-Troubleshooting.md Normal file
View File

@ -0,0 +1,156 @@
# SecuBox FAQ & Troubleshooting
Quick solutions to common issues. **For the latest fixes, always check [GitHub Issues](https://github.com/CyberMind-FR/secubox-deb/issues)** - community-reported problems often have solutions there before documentation is updated.
---
## Quick Links
- **GitHub Issues (check here first!)**: https://github.com/CyberMind-FR/secubox-deb/issues
- **Known Bugs Label**: https://github.com/CyberMind-FR/secubox-deb/issues?q=label%3Abug
- **Wiki Home**: [Home](Home.md)
---
## VirtualBox Issues
### Kiosk doesn't start / "No usable sandbox" error
**Symptom**: Chromium fails with sandbox error, kiosk service keeps restarting.
**Solution**: The VirtualBox image includes a fix for this. If using an older image:
```bash
# SSH into VM
ssh -p 2222 root@localhost # password: secubox
# Add --no-sandbox flag
echo '--disable-gpu --disable-gpu-compositing --disable-software-rasterizer --no-sandbox' > /home/secubox-kiosk/.chromium-gpu-flags
chown secubox-kiosk:secubox-kiosk /home/secubox-kiosk/.chromium-gpu-flags
systemctl restart secubox-kiosk
```
**Related Issue**: [#34](https://github.com/CyberMind-FR/secubox-deb/issues/34)
### VM tries PXE boot instead of disk
**Symptom**: VirtualBox attempts network boot.
**Solution**: Disable network boot or ensure disk is first in boot order:
```bash
VBoxManage modifyvm "SecuBox" --boot1 disk --boot2 none --boot3 none --boot4 none
```
### WebUI not accessible via port forward
**Symptom**: https://localhost:9443 doesn't connect.
**Solution**: nginx listens on port 443, not 9443. Fix port forwarding:
```bash
VBoxManage controlvm "SecuBox" natpf1 delete https 2>/dev/null
VBoxManage controlvm "SecuBox" natpf1 "https,tcp,,9443,,443"
```
---
## Authentication Issues
### Login fails with "Invalid credentials"
**Default credentials**:
- **WebUI**: `admin` / `secubox` (NOT root)
- **SSH**: `root` / `secubox`
### Menu/Sidebar fails to load ("Invalid menu data")
**Symptom**: After login, sidebar shows error, pages don't load.
**Cause**: FastAPI/Pydantic compatibility issue with auth module.
**Status**: Known issue, fix in progress. See [#34](https://github.com/CyberMind-FR/secubox-deb/issues/34)
**Workaround**: Use REST API directly or wait for package update.
---
## Network Issues
### No IP address after boot
**Check DHCP**:
```bash
# Inside SecuBox
ip addr show
dhclient -v enp0s3 # or appropriate interface
```
**Check NetworkManager vs systemd-networkd**:
```bash
systemctl status NetworkManager
systemctl status systemd-networkd
```
### Bridged mode shows wrong subnet
**Symptom**: VM gets IP from different network (e.g., 10.x instead of 192.168.x).
**Solution**: Verify bridge adapter in VirtualBox settings matches your host interface.
---
## Service Issues
### coturn.service keeps failing
**Symptom**: Boot shows repeated coturn failures.
**Solution**: Disable if not using TURN/STUN:
```bash
systemctl disable coturn
systemctl mask coturn
```
### secubox-hub socket not created
**Symptom**: API returns 502 Bad Gateway, `/run/secubox/hub.sock` missing.
**Workaround**: Service was switched to TCP binding. If you have an old image:
```bash
# Update service to use TCP
sed -i 's/--uds.*sock/--host 127.0.0.1 --port 8001/' /lib/systemd/system/secubox-hub.service
systemctl daemon-reload
systemctl restart secubox-hub
```
---
## Hardware-Specific Issues
### ESPRESSObin / MOCHAbin
See [Board-Specific-Notes.md](Board-Specific-Notes.md)
### AMD64 / Bare Metal
- Ensure UEFI boot mode (GPT partition table)
- For kiosk: verify X11/DRM drivers are loaded
---
## Getting Help
1. **Check GitHub Issues first**: https://github.com/CyberMind-FR/secubox-deb/issues
2. **Search closed issues** for solutions already found
3. **Create new issue** with:
- SecuBox version (`cat /etc/secubox/version`)
- Board type
- Full error messages
- Steps to reproduce
---
## See Also
- [VirtualBox-Setup.md](VirtualBox-Setup.md)
- [Installation-Guide.md](Installation-Guide.md)
- [API-Reference.md](API-Reference.md)