mirror of
https://github.com/CyberMind-FR/secubox-deb.git
synced 2026-07-29 09:14:33 +00:00
* docs(plan): #331 streamlit idle-timeout + wake-on-demand (ref #331) * feat(streamlit): add idle-tracking helpers to streamlitctl (ref #331) Add the foundation for per-app idle timeout / wake-on-demand: - IDLE_STATE_DIR constant at /var/lib/secubox/streamlit/idle - _idle_config: read [idle] settings from /etc/secubox/streamlit.toml with safe defaults - _app_running: check whether an app is listening on its port INSIDE the LXC (existing inline ss check is host-side and unreliable) - _app_active_conns: count ESTABLISHED connections to an app's port - _app_last_active / _app_touch_active: per-app last-activity epoch tracked via state-file mtime Wire cmd_app_start to touch the state file on every successful start so a freshly-started app does not get immediately reaped by the upcoming idle-check. All helpers degrade gracefully when the LXC is down (lxc_running guard) and the helpers themselves do not change any existing behaviour — they are purely additive. * feat(streamlit): streamlitctl app idle-check — stop idle apps past timeout (ref #331) Add cmd_app_idle_check that iterates apps under $APPS_PATH and stops those that have had zero ESTABLISHED connections for longer than the configured timeout_minutes (default 30, from [idle] in streamlit.toml). Apps with active connections get their state file touched; apps with no state file yet are touched too (grace period). Master switch via [idle] enabled in /etc/secubox/streamlit.toml. Wire idle-check + wake into the `app` subcommand dispatcher; wake's function (cmd_app_wake) lands in the next commit (dead branch until then). Usage line updated to include both new subcommands. * feat(streamlit): streamlitctl app wake — lazy start + poll for port (ref #331) Adds cmd_app_wake <name> [wait_seconds] (default 30s): - returns 0 if already running OR if the app comes up within wait_seconds - returns 1 on start failure or poll timeout - returns 2 on misuse (missing name, unknown app) Uses _app_running for readiness probing and _app_touch_active to refresh the idle marker on a successful wake. Polls every 1s for portability. The case-block dispatcher (idle-check / wake) was already wired in Task 2. * feat(streamlit): systemd idle-sweep timer (every 5 min) (ref #331) Ship secubox-streamlit-idle.service (oneshot, runs `streamlitctl app idle-check`) and secubox-streamlit-idle.timer (OnBootSec=5min, OnUnitActiveSec=5min) so idle Streamlit apps are auto-suspended without a separate cron. Wire the units through debian/secubox-streamlit.install (the package uses a hand-rolled override_dh_auto_install, so dh_installsystemd's auto-detection by package.suffix name doesn't pick them up — explicit .install is needed). Enable/disable the timer in postinst/prerm alongside the main service. * feat(streamlit): POST /apps/{name}/wake — lazy start API endpoint (ref #331) * feat(streamlit): default [idle] config block (timeout 30 min) (ref #331) * chore(streamlit): bump to v1.2.2 with idle-timeout + wake summary (closes #331) --------- Co-authored-by: CyberMind-FR <gandalf@Gk2.net>
956 lines
33 KiB
Python
956 lines
33 KiB
Python
"""secubox-streamlit — Streamlit Platform API (Three-Fold Architecture)"""
|
||
import os
|
||
import json
|
||
import subprocess
|
||
import shutil
|
||
import zipfile
|
||
import asyncio
|
||
import threading
|
||
import time
|
||
from pathlib import Path
|
||
from typing import Optional, List, Dict, Any
|
||
from fastapi import FastAPI, APIRouter, Depends, HTTPException, UploadFile, File, BackgroundTasks
|
||
from pydantic import BaseModel
|
||
from secubox_core.auth import router as auth_router, require_jwt
|
||
from secubox_core.config import get_config
|
||
from secubox_core.logger import get_logger
|
||
|
||
app = FastAPI(title="secubox-streamlit", version="1.0.0", root_path="/api/v1/streamlit")
|
||
|
||
# ════════════════════════<E29590><E29590>════════════════════════<E29590><E29590><EFBFBD>════════════════
|
||
# DOUBLE PRE-CACHE BUFFERING (instant + details)
|
||
# ═══════════════════════════════<E29590><E29590>═════════════════════<E29590><E29590><EFBFBD>════════════
|
||
|
||
class DoubleBufferCache:
|
||
"""Thread-safe double-buffer cache for instant and detailed stats.
|
||
|
||
- instant: Quick stats for dashboard cards, refreshed every 5s
|
||
- details: Full detailed data, refreshed every 30s
|
||
"""
|
||
|
||
def __init__(self):
|
||
self._instant: Dict[str, Any] = {}
|
||
self._details: Dict[str, Any] = {}
|
||
self._instant_ts: float = 0
|
||
self._details_ts: float = 0
|
||
self._lock = threading.Lock()
|
||
self.INSTANT_TTL = 5 # seconds
|
||
self.DETAILS_TTL = 30 # seconds
|
||
|
||
def get_instant(self) -> Dict[str, Any]:
|
||
"""Get instant cache (quick stats)."""
|
||
with self._lock:
|
||
return self._instant.copy()
|
||
|
||
def get_details(self) -> Dict[str, Any]:
|
||
"""Get details cache (full data)."""
|
||
with self._lock:
|
||
return self._details.copy()
|
||
|
||
def set_instant(self, data: Dict[str, Any]):
|
||
"""Update instant cache."""
|
||
with self._lock:
|
||
self._instant = data
|
||
self._instant_ts = time.time()
|
||
|
||
def set_details(self, data: Dict[str, Any]):
|
||
"""Update details cache."""
|
||
with self._lock:
|
||
self._details = data
|
||
self._details_ts = time.time()
|
||
|
||
def instant_stale(self) -> bool:
|
||
"""Check if instant cache is stale."""
|
||
return time.time() - self._instant_ts > self.INSTANT_TTL
|
||
|
||
def details_stale(self) -> bool:
|
||
"""Check if details cache is stale."""
|
||
return time.time() - self._details_ts > self.DETAILS_TTL
|
||
|
||
def age(self) -> Dict[str, float]:
|
||
"""Get cache ages."""
|
||
now = time.time()
|
||
return {
|
||
"instant_age": round(now - self._instant_ts, 1) if self._instant_ts else None,
|
||
"details_age": round(now - self._details_ts, 1) if self._details_ts else None,
|
||
}
|
||
|
||
|
||
_cache = DoubleBufferCache()
|
||
_cache_task: Optional[asyncio.Task] = None
|
||
|
||
# ══════════════════════════════════════════════════════════════════
|
||
# Health Check Endpoint (public, no auth)
|
||
# ══════════════════════════════════════════════════════════════════
|
||
|
||
@app.get("/health")
|
||
async def health_check():
|
||
"""Public health check endpoint for sidebar status."""
|
||
return {"status": "ok", "module": "deb"}
|
||
|
||
app.include_router(auth_router, prefix="/auth")
|
||
router = APIRouter()
|
||
log = get_logger("streamlit")
|
||
|
||
APPS_DIR = "/srv/streamlit/apps"
|
||
LXC_NAME = "streamlit"
|
||
CTL = "/usr/sbin/streamlitctl"
|
||
|
||
|
||
def _cfg():
|
||
cfg = get_config("streamlit")
|
||
power_cfg = cfg.get("power", {}) if cfg else {}
|
||
return {
|
||
"apps_dir": cfg.get("apps_dir", APPS_DIR) if cfg else APPS_DIR,
|
||
"default_port": cfg.get("default_port", 8501) if cfg else 8501,
|
||
"use_lxc": cfg.get("use_lxc", True) if cfg else True,
|
||
"auto_pause": power_cfg.get("auto_pause", False),
|
||
"auto_pause_minutes": power_cfg.get("auto_pause_minutes", 30),
|
||
"presence_events": power_cfg.get("presence_events", True),
|
||
"metoblizer_log": power_cfg.get("metoblizer_log", False),
|
||
"metoblizer_endpoint": power_cfg.get("metoblizer_endpoint", "http://localhost:9300/api/v1/metoblizer/ingest"),
|
||
}
|
||
|
||
|
||
def _run_ctl(*args, timeout: int = 30) -> dict:
|
||
"""Run streamlitctl and return parsed JSON or error.
|
||
|
||
Uses sudo -n for non-interactive sudo (requires sudoers config).
|
||
"""
|
||
try:
|
||
result = subprocess.run(
|
||
["sudo", "-n", CTL, *args],
|
||
capture_output=True, text=True, timeout=timeout
|
||
)
|
||
if result.stdout.strip().startswith("{"):
|
||
return json.loads(result.stdout)
|
||
return {"output": result.stdout, "error": result.stderr, "code": result.returncode}
|
||
except subprocess.TimeoutExpired:
|
||
return {"error": "timeout", "success": False}
|
||
except Exception as e:
|
||
return {"error": str(e), "success": False}
|
||
|
||
|
||
def _lxc_running() -> bool:
|
||
"""Check if LXC container is running.
|
||
|
||
Uses sudo -n for non-interactive sudo (requires sudoers config).
|
||
"""
|
||
result = subprocess.run(
|
||
["sudo", "-n", "lxc-info", "-n", LXC_NAME, "-s"],
|
||
capture_output=True, text=True
|
||
)
|
||
return "RUNNING" in result.stdout
|
||
|
||
|
||
def _lxc_exists() -> bool:
|
||
"""Check if LXC container exists."""
|
||
return Path(f"/var/lib/lxc/{LXC_NAME}").exists()
|
||
|
||
|
||
APPS_PATH = "/srv/streamlit/apps"
|
||
|
||
|
||
def _read_deploy_metadata(app_name: str) -> dict:
|
||
"""Read .deploy.json for an app; return empty dict if absent or invalid."""
|
||
path = Path(APPS_PATH) / app_name / ".deploy.json"
|
||
try:
|
||
with path.open() as f:
|
||
return json.load(f)
|
||
except (FileNotFoundError, json.JSONDecodeError, PermissionError, OSError):
|
||
return {}
|
||
|
||
|
||
def _git_describe_tag(app_name: str) -> str | None:
|
||
"""Fallback: read the current tag via `git describe --tags --exact-match`."""
|
||
app_dir = Path(APPS_PATH) / app_name
|
||
if not (app_dir / ".git").exists():
|
||
return None
|
||
try:
|
||
out = subprocess.run(
|
||
["git", "-C", str(app_dir), "describe", "--tags", "--exact-match"],
|
||
capture_output=True, text=True, timeout=5,
|
||
)
|
||
if out.returncode == 0:
|
||
return out.stdout.strip() or None
|
||
except (FileNotFoundError, subprocess.TimeoutExpired):
|
||
pass
|
||
return None
|
||
|
||
|
||
def _get_apps() -> List[dict]:
|
||
"""Get list of apps from streamlitctl, enriched with current_tag + deployed_at."""
|
||
result = _run_ctl("app", "list")
|
||
apps = result.get("apps", [])
|
||
for app in apps:
|
||
name = app.get("name")
|
||
if not name:
|
||
continue
|
||
meta = _read_deploy_metadata(name)
|
||
app["current_tag"] = meta.get("tag") or _git_describe_tag(name)
|
||
app["deployed_at"] = meta.get("deployed_at")
|
||
return apps
|
||
|
||
|
||
def _get_instances() -> List[dict]:
|
||
"""Get list of instances from streamlitctl."""
|
||
result = _run_ctl("instance", "list")
|
||
return result.get("instances", [])
|
||
|
||
|
||
def _refresh_instant_cache():
|
||
"""Refresh instant cache with quick stats."""
|
||
cfg = _cfg()
|
||
container_status = "not_configured"
|
||
if cfg["use_lxc"]:
|
||
if _lxc_exists():
|
||
container_status = "running" if _lxc_running() else "stopped"
|
||
else:
|
||
container_status = "not_installed"
|
||
|
||
_cache.set_instant({
|
||
"container_status": container_status,
|
||
"lxc_exists": _lxc_exists(),
|
||
"lxc_running": _lxc_running() if cfg["use_lxc"] else False,
|
||
"use_lxc": cfg["use_lxc"],
|
||
"default_port": cfg["default_port"],
|
||
"ts": time.time(),
|
||
})
|
||
|
||
|
||
def _refresh_details_cache():
|
||
"""Refresh details cache with full data."""
|
||
apps = _get_apps()
|
||
instances = _get_instances()
|
||
cfg = _cfg()
|
||
|
||
container_status = "not_configured"
|
||
if cfg["use_lxc"]:
|
||
if _lxc_exists():
|
||
container_status = "running" if _lxc_running() else "stopped"
|
||
else:
|
||
container_status = "not_installed"
|
||
|
||
_cache.set_details({
|
||
"apps": apps,
|
||
"instances": instances,
|
||
"app_count": len(apps),
|
||
"running_apps": sum(1 for a in apps if a.get("running")),
|
||
"instance_count": len(instances),
|
||
"running_instances": sum(1 for i in instances if i.get("running")),
|
||
"container_status": container_status,
|
||
"use_lxc": cfg["use_lxc"],
|
||
"default_port": cfg["default_port"],
|
||
"ts": time.time(),
|
||
})
|
||
|
||
|
||
async def _cache_refresh_loop():
|
||
"""Background task to refresh caches."""
|
||
while True:
|
||
try:
|
||
if _cache.instant_stale():
|
||
_refresh_instant_cache()
|
||
if _cache.details_stale():
|
||
_refresh_details_cache()
|
||
except Exception as e:
|
||
log.warning("cache refresh error: %s", e)
|
||
await asyncio.sleep(2)
|
||
|
||
|
||
@app.on_event("startup")
|
||
async def startup_cache():
|
||
"""Start cache refresh loop on startup."""
|
||
global _cache_task
|
||
_refresh_instant_cache()
|
||
_refresh_details_cache()
|
||
_cache_task = asyncio.create_task(_cache_refresh_loop())
|
||
|
||
|
||
@app.on_event("shutdown")
|
||
async def shutdown_cache():
|
||
"""Stop cache refresh loop on shutdown."""
|
||
global _cache_task
|
||
if _cache_task:
|
||
_cache_task.cancel()
|
||
|
||
|
||
# ═══════════════════════════════════════════════════════════════════════
|
||
# THREE-FOLD ARCHITECTURE
|
||
# ═══════════════════════════════════════════════════════════════════════
|
||
|
||
# ── COMPONENTS ─────────────────────────────────────────────────────────
|
||
# What makes up the system
|
||
|
||
@router.get("/components")
|
||
async def components():
|
||
"""List system components (public)."""
|
||
cfg = _cfg()
|
||
lxc_installed = _lxc_exists()
|
||
|
||
return {
|
||
"components": [
|
||
{
|
||
"name": "Streamlit LXC Container",
|
||
"type": "container",
|
||
"description": "Alpine-based LXC container running Streamlit",
|
||
"installed": lxc_installed,
|
||
"config_path": "/etc/secubox/streamlit.toml"
|
||
},
|
||
{
|
||
"name": "Application Directory",
|
||
"type": "storage",
|
||
"description": "Directory containing deployed apps",
|
||
"path": cfg["apps_dir"],
|
||
"exists": Path(cfg["apps_dir"]).exists()
|
||
},
|
||
{
|
||
"name": "streamlitctl",
|
||
"type": "cli",
|
||
"description": "Control script for container and app management",
|
||
"path": CTL,
|
||
"installed": Path(CTL).exists()
|
||
}
|
||
],
|
||
"use_lxc": cfg["use_lxc"]
|
||
}
|
||
|
||
|
||
# ── STATUS ─────────────────────────────────────────────────────────────
|
||
# Health and runtime state
|
||
|
||
@router.get("/instant")
|
||
async def instant():
|
||
"""Get instant stats from pre-cache (public, fast)."""
|
||
data = _cache.get_instant()
|
||
data["cache_age"] = _cache.age()
|
||
return data
|
||
|
||
|
||
@router.get("/details")
|
||
async def details():
|
||
"""Get detailed stats from pre-cache (public)."""
|
||
data = _cache.get_details()
|
||
data["cache_age"] = _cache.age()
|
||
return data
|
||
|
||
|
||
@router.get("/status")
|
||
async def status():
|
||
"""Get platform status from cache (public)."""
|
||
# Use cached details for fast response
|
||
cached = _cache.get_details()
|
||
if cached and cached.get("app_count") is not None:
|
||
return {
|
||
"app_count": cached.get("app_count", 0),
|
||
"running_apps": cached.get("running_apps", 0),
|
||
"instance_count": cached.get("instance_count", 0),
|
||
"running_instances": cached.get("running_instances", 0),
|
||
"container_status": cached.get("container_status", "unknown"),
|
||
"use_lxc": cached.get("use_lxc", True),
|
||
"default_port": cached.get("default_port", 8501),
|
||
"cached": True,
|
||
"cache_age": _cache.age(),
|
||
}
|
||
|
||
# Fallback to direct fetch if cache not ready
|
||
apps = _get_apps()
|
||
instances = _get_instances()
|
||
cfg = _cfg()
|
||
|
||
container_status = "not_configured"
|
||
if cfg["use_lxc"]:
|
||
if _lxc_exists():
|
||
container_status = "running" if _lxc_running() else "stopped"
|
||
else:
|
||
container_status = "not_installed"
|
||
|
||
running_apps = sum(1 for a in apps if a.get("running"))
|
||
running_instances = sum(1 for i in instances if i.get("running"))
|
||
|
||
return {
|
||
"app_count": len(apps),
|
||
"running_apps": running_apps,
|
||
"instance_count": len(instances),
|
||
"running_instances": running_instances,
|
||
"container_status": container_status,
|
||
"use_lxc": cfg["use_lxc"],
|
||
"default_port": cfg["default_port"],
|
||
"cached": False,
|
||
}
|
||
|
||
|
||
@router.get("/health")
|
||
async def health():
|
||
"""Health check endpoint (public)."""
|
||
checks = {
|
||
"api": "ok",
|
||
"streamlitctl": "ok" if Path(CTL).exists() else "missing",
|
||
"apps_dir": "ok" if Path(_cfg()["apps_dir"]).exists() else "missing"
|
||
}
|
||
|
||
if _cfg()["use_lxc"]:
|
||
if _lxc_exists():
|
||
checks["container"] = "running" if _lxc_running() else "stopped"
|
||
else:
|
||
checks["container"] = "not_installed"
|
||
|
||
overall = "healthy" if all(v in ["ok", "running"] for v in checks.values()) else "degraded"
|
||
|
||
return {
|
||
"status": overall,
|
||
"module": "streamlit",
|
||
"checks": checks
|
||
}
|
||
|
||
|
||
# ── ACCESS ─────────────────────────────────────────────────────────────
|
||
# How to connect to services
|
||
|
||
@router.get("/access")
|
||
async def access():
|
||
"""Get access information for running apps (public)."""
|
||
apps = _get_apps()
|
||
instances = _get_instances()
|
||
cfg = _cfg()
|
||
|
||
access_points = []
|
||
|
||
# Add running apps
|
||
for app in apps:
|
||
if app.get("running") and app.get("port"):
|
||
access_points.append({
|
||
"name": app["name"],
|
||
"type": "app",
|
||
"port": app["port"],
|
||
"url": f"http://{{{{hostname}}}}:{app['port']}",
|
||
"status": "running"
|
||
})
|
||
|
||
# Add running instances
|
||
for inst in instances:
|
||
if inst.get("running") and inst.get("port"):
|
||
access_points.append({
|
||
"name": inst.get("id", inst.get("name", "unknown")),
|
||
"type": "instance",
|
||
"app": inst.get("app"),
|
||
"port": inst["port"],
|
||
"url": f"http://{{{{hostname}}}}:{inst['port']}",
|
||
"domain": inst.get("domain"),
|
||
"status": "running"
|
||
})
|
||
|
||
return {
|
||
"access": access_points,
|
||
"default_port": cfg["default_port"]
|
||
}
|
||
|
||
|
||
# ═══════════════════════════════════════════════════════════════════════
|
||
# APPS
|
||
# ═══════════════════════════════════════════════════════════════════════
|
||
|
||
@router.get("/apps")
|
||
async def list_apps():
|
||
"""List all apps (public)."""
|
||
return {"apps": _get_apps()}
|
||
|
||
|
||
@router.get("/app/{name}")
|
||
async def get_app(name: str, user=Depends(require_jwt)):
|
||
"""Get app details."""
|
||
apps = _get_apps()
|
||
for a in apps:
|
||
if a.get("name") == name:
|
||
# Add extra details
|
||
app_dir = Path(_cfg()["apps_dir"]) / name
|
||
a["path"] = str(app_dir)
|
||
a["has_requirements"] = (app_dir / "requirements.txt").exists()
|
||
return a
|
||
raise HTTPException(404, f"App not found: {name}")
|
||
|
||
|
||
@router.post("/deploy")
|
||
async def deploy(
|
||
file: UploadFile = File(...),
|
||
name: str = "",
|
||
port: int = 0,
|
||
user=Depends(require_jwt),
|
||
):
|
||
"""Deploy a Streamlit app from ZIP."""
|
||
if not name:
|
||
if not file.filename:
|
||
raise HTTPException(400, "Filename required")
|
||
name = file.filename.replace(".zip", "").replace(" ", "_").lower()
|
||
|
||
# Save uploaded file
|
||
tmp_path = f"/tmp/streamlit_upload_{name}.zip"
|
||
content = await file.read()
|
||
Path(tmp_path).write_bytes(content)
|
||
|
||
# Deploy via streamlitctl
|
||
result = _run_ctl("app", "deploy", tmp_path, name, timeout=120)
|
||
|
||
# Cleanup
|
||
Path(tmp_path).unlink(missing_ok=True)
|
||
|
||
if result.get("success"):
|
||
log.info("Deployed app: %s", name)
|
||
return result
|
||
else:
|
||
log.error("Deploy failed: %s", result.get("error", "unknown"))
|
||
raise HTTPException(500, result.get("error", "Deploy failed"))
|
||
|
||
|
||
@router.post("/app/{name}/start")
|
||
async def start_app(name: str, port: int = 0, user=Depends(require_jwt)):
|
||
"""Start an app."""
|
||
cfg = _cfg()
|
||
port = port or cfg["default_port"]
|
||
|
||
result = _run_ctl("app", "start", name, str(port))
|
||
|
||
if "error" not in result or result.get("code", 0) == 0:
|
||
log.info("Started app: %s on port %d", name, port)
|
||
return {"success": True, "name": name, "port": port}
|
||
else:
|
||
raise HTTPException(500, result.get("error", "Failed to start"))
|
||
|
||
|
||
@router.post("/app/{name}/stop")
|
||
async def stop_app(name: str, user=Depends(require_jwt)):
|
||
"""Stop an app."""
|
||
result = _run_ctl("app", "stop", name)
|
||
log.info("Stopped app: %s", name)
|
||
return {"success": True, "name": name}
|
||
|
||
|
||
@router.delete("/app/{name}")
|
||
async def delete_app(name: str, user=Depends(require_jwt)):
|
||
"""Delete an app."""
|
||
result = _run_ctl("app", "remove", name)
|
||
log.info("Removed app: %s", name)
|
||
return {"success": True, "name": name}
|
||
|
||
|
||
@router.get("/app/{name}/logs")
|
||
async def get_logs(name: str, lines: int = 100, user=Depends(require_jwt)):
|
||
"""Get app logs."""
|
||
result = _run_ctl("app", "logs", name, str(lines))
|
||
return {"logs": result.get("output", "").splitlines()}
|
||
|
||
|
||
# ── WAKE ───────────────────────────────────────────────────────────────
|
||
# Lazy restart an idle-stopped streamlit app (ref #331)
|
||
|
||
class WakeResult(BaseModel):
|
||
name: str
|
||
status: str # "running" | "started"
|
||
duration_ms: int
|
||
|
||
|
||
@router.post("/apps/{name}/wake", response_model=WakeResult)
|
||
async def wake_app(name: str, user=Depends(require_jwt)) -> WakeResult:
|
||
"""Wake an idle-stopped streamlit app.
|
||
|
||
Blocks until the port comes up or 30 s elapse. Idempotent — returns
|
||
immediately with status="running" if the app is already up.
|
||
|
||
Status codes:
|
||
- 200: app is up (status="running" or "started")
|
||
- 404: app does not exist on disk
|
||
- 502: streamlitctl binary missing
|
||
- 504: wake failed or timed out
|
||
"""
|
||
if not Path(CTL).exists():
|
||
raise HTTPException(502, "streamlitctl missing")
|
||
app_dir = Path(APPS_PATH) / name
|
||
if not app_dir.is_dir():
|
||
raise HTTPException(404, f"app not found: {name}")
|
||
|
||
start = time.monotonic()
|
||
try:
|
||
result = subprocess.run(
|
||
["sudo", "-n", CTL, "app", "wake", name, "30"],
|
||
capture_output=True, timeout=35, check=False,
|
||
)
|
||
except subprocess.TimeoutExpired:
|
||
raise HTTPException(504, "wake exceeded 35s wall-clock")
|
||
|
||
duration_ms = int((time.monotonic() - start) * 1000)
|
||
if result.returncode == 0:
|
||
status = "running" if b"already running" in result.stderr else "started"
|
||
log.info("wake: %s status=%s duration_ms=%d", name, status, duration_ms)
|
||
return WakeResult(name=name, status=status, duration_ms=duration_ms)
|
||
|
||
stderr_snippet = result.stderr.decode(errors="replace")[:200]
|
||
log.warning("wake: %s failed rc=%d stderr=%s", name, result.returncode, stderr_snippet)
|
||
raise HTTPException(504, f"wake failed: {stderr_snippet}")
|
||
|
||
|
||
# ═══════════════════════════════════════════════════════════════════════
|
||
# INSTANCES
|
||
# ═══════════════════════════════════════════════════════════════════════
|
||
|
||
@router.get("/instances")
|
||
async def list_instances(user=Depends(require_jwt)):
|
||
"""List all instances."""
|
||
return {"instances": _get_instances()}
|
||
|
||
|
||
@router.post("/instance/{id}/start")
|
||
async def start_instance(id: str, user=Depends(require_jwt)):
|
||
"""Start an instance."""
|
||
result = _run_ctl("instance", "start", id)
|
||
return {"success": True, "id": id}
|
||
|
||
|
||
@router.post("/instance/{id}/stop")
|
||
async def stop_instance(id: str, user=Depends(require_jwt)):
|
||
"""Stop an instance."""
|
||
result = _run_ctl("instance", "stop", id)
|
||
return {"success": True, "id": id}
|
||
|
||
|
||
# ═══════════════════════════════════════════════════════════════════════
|
||
# CONTAINER CONTROL
|
||
# ═══════════════════════════════════════════════════════════════════════
|
||
|
||
@router.get("/container/status")
|
||
async def container_status(user=Depends(require_jwt)):
|
||
"""Get LXC container status."""
|
||
if not _cfg()["use_lxc"]:
|
||
return {"enabled": False}
|
||
|
||
return {
|
||
"enabled": True,
|
||
"exists": _lxc_exists(),
|
||
"running": _lxc_running()
|
||
}
|
||
|
||
|
||
@router.post("/container/install")
|
||
async def container_install(background_tasks: BackgroundTasks, user=Depends(require_jwt)):
|
||
"""Install LXC container (background)."""
|
||
def do_install():
|
||
subprocess.run([CTL, "install"], timeout=600)
|
||
|
||
background_tasks.add_task(do_install)
|
||
return {"success": True, "message": "Installation started in background"}
|
||
|
||
|
||
@router.post("/container/start")
|
||
async def container_start(user=Depends(require_jwt)):
|
||
"""Start LXC container."""
|
||
result = _run_ctl("start", timeout=60)
|
||
return {"success": "error" not in result}
|
||
|
||
|
||
@router.post("/container/stop")
|
||
async def container_stop(user=Depends(require_jwt)):
|
||
"""Stop LXC container."""
|
||
result = _run_ctl("stop", timeout=60)
|
||
return {"success": "error" not in result}
|
||
|
||
|
||
# ═══════════════════════════════════════════════════════════════════════
|
||
# POWER MANAGEMENT (Sleep/Wake)
|
||
# ═══════════════════════════════════════════════════════════════════════
|
||
|
||
import time
|
||
from datetime import datetime
|
||
|
||
POWER_STATE_FILE = Path("/var/lib/secubox/streamlit-power.json")
|
||
_last_activity: float = time.time()
|
||
|
||
def _load_power_state() -> dict:
|
||
"""Load power state from file."""
|
||
if POWER_STATE_FILE.exists():
|
||
try:
|
||
return json.loads(POWER_STATE_FILE.read_text())
|
||
except:
|
||
pass
|
||
return {"state": "running", "paused_at": None, "last_activity": time.time()}
|
||
|
||
def _save_power_state(state: dict):
|
||
"""Save power state to file."""
|
||
POWER_STATE_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||
POWER_STATE_FILE.write_text(json.dumps(state, indent=2))
|
||
|
||
def _update_activity():
|
||
"""Update last activity timestamp."""
|
||
global _last_activity
|
||
_last_activity = time.time()
|
||
state = _load_power_state()
|
||
state["last_activity"] = _last_activity
|
||
_save_power_state(state)
|
||
|
||
def _get_idle_seconds() -> float:
|
||
"""Get seconds since last activity."""
|
||
state = _load_power_state()
|
||
last = state.get("last_activity", time.time())
|
||
return time.time() - last
|
||
|
||
def _emit_presence_event(event: str, details: Optional[Dict[str, Any]] = None):
|
||
"""Emit presence event for banner injection and metoblizer logging.
|
||
|
||
Events: 'wake', 'sleep', 'activity'
|
||
"""
|
||
cfg = _cfg()
|
||
event_data = {
|
||
"timestamp": datetime.now().isoformat(),
|
||
"module": "streamlit",
|
||
"event": event,
|
||
"details": details if details is not None else {},
|
||
}
|
||
|
||
# Log presence event for banner system
|
||
if cfg.get("presence_events"):
|
||
presence_file = Path("/var/lib/secubox/presence/streamlit.json")
|
||
presence_file.parent.mkdir(parents=True, exist_ok=True)
|
||
presence_file.write_text(json.dumps(event_data, indent=2))
|
||
log.info("Presence event: %s", event)
|
||
|
||
# Send to metoblizer if configured
|
||
if cfg.get("metoblizer_log"):
|
||
try:
|
||
import urllib.request
|
||
|
||
endpoint = cfg.get("metoblizer_endpoint")
|
||
if endpoint:
|
||
req = urllib.request.Request(
|
||
endpoint,
|
||
data=json.dumps(event_data).encode("utf-8"),
|
||
headers={"Content-Type": "application/json"},
|
||
method="POST"
|
||
)
|
||
urllib.request.urlopen(req, timeout=5)
|
||
log.debug("Metoblizer log sent: %s", event)
|
||
except Exception as e:
|
||
log.warning("Metoblizer log failed: %s", e)
|
||
|
||
|
||
def _load_streamlit_config() -> dict:
|
||
"""Load streamlit config from TOML file."""
|
||
config_path = Path("/etc/secubox/streamlit.toml")
|
||
if config_path.exists():
|
||
try:
|
||
import tomllib
|
||
with open(config_path, "rb") as f:
|
||
return tomllib.load(f)
|
||
except Exception as e:
|
||
log.warning("Failed to load config: %s", e)
|
||
return {}
|
||
|
||
|
||
def _start_autostart_instances():
|
||
"""Start all instances with autostart=true."""
|
||
cfg = _load_streamlit_config()
|
||
if not cfg:
|
||
return []
|
||
|
||
started = []
|
||
instances = cfg.get("instances", {})
|
||
for name, inst in instances.items():
|
||
if inst.get("autostart") and inst.get("enabled"):
|
||
port = inst.get("port", 0)
|
||
app = inst.get("app", name)
|
||
if port:
|
||
result = _run_ctl("instance", "start", name, str(port), timeout=30)
|
||
if result.get("success") or "running" in str(result.get("output", "")).lower():
|
||
started.append({"name": name, "app": app, "port": port})
|
||
log.info("Autostarted instance: %s on port %s", name, port)
|
||
return started
|
||
|
||
@router.get("/power/status")
|
||
async def power_status():
|
||
"""Get power state and idle time (public endpoint for dashboard)."""
|
||
state = _load_power_state()
|
||
running = _lxc_running()
|
||
idle_secs = _get_idle_seconds()
|
||
|
||
# Determine effective state
|
||
if state.get("state") == "paused" and not running:
|
||
effective_state = "paused"
|
||
elif running:
|
||
effective_state = "running"
|
||
else:
|
||
effective_state = "stopped"
|
||
|
||
return {
|
||
"state": effective_state,
|
||
"container_running": running,
|
||
"idle_seconds": int(idle_secs),
|
||
"idle_formatted": _format_duration(int(idle_secs)),
|
||
"paused_at": state.get("paused_at"),
|
||
"auto_pause_enabled": _cfg().get("auto_pause", False),
|
||
"auto_pause_minutes": _cfg().get("auto_pause_minutes", 30)
|
||
}
|
||
|
||
def _format_duration(seconds: int) -> str:
|
||
"""Format seconds to human readable."""
|
||
if seconds < 60:
|
||
return f"{seconds}s"
|
||
elif seconds < 3600:
|
||
return f"{seconds // 60}m {seconds % 60}s"
|
||
else:
|
||
hours = seconds // 3600
|
||
mins = (seconds % 3600) // 60
|
||
return f"{hours}h {mins}m"
|
||
|
||
@router.post("/power/pause")
|
||
async def power_pause(user=Depends(require_jwt)):
|
||
"""Pause (sleep) the Streamlit container to save resources.
|
||
|
||
Container is stopped but state is preserved. Use /power/wake to resume.
|
||
"""
|
||
if not _lxc_running():
|
||
return {"success": True, "message": "Already paused/stopped"}
|
||
|
||
# Stop the container
|
||
result = _run_ctl("stop", timeout=60)
|
||
if "error" in result:
|
||
raise HTTPException(500, f"Failed to pause: {result.get('error')}")
|
||
|
||
# Save paused state
|
||
state = _load_power_state()
|
||
state["state"] = "paused"
|
||
state["paused_at"] = datetime.now().isoformat()
|
||
_save_power_state(state)
|
||
|
||
# Emit presence event for banner injection
|
||
_emit_presence_event("sleep", {"idle_seconds": int(_get_idle_seconds())})
|
||
|
||
log.info("Streamlit container paused (sleep mode)")
|
||
return {"success": True, "state": "paused"}
|
||
|
||
@router.post("/power/wake")
|
||
async def power_wake(user=Depends(require_jwt)):
|
||
"""Wake (resume) the Streamlit container from sleep.
|
||
|
||
Restarts the container and all previously running apps.
|
||
"""
|
||
if _lxc_running():
|
||
_update_activity()
|
||
return {"success": True, "message": "Already running"}
|
||
|
||
# Start the container
|
||
result = _run_ctl("start", timeout=60)
|
||
if "error" in result:
|
||
raise HTTPException(500, f"Failed to wake: {result.get('error')}")
|
||
|
||
# Update state
|
||
state = _load_power_state()
|
||
paused_duration = None
|
||
if state.get("paused_at"):
|
||
try:
|
||
paused_at = datetime.fromisoformat(state["paused_at"])
|
||
paused_duration = int((datetime.now() - paused_at).total_seconds())
|
||
except:
|
||
pass
|
||
state["state"] = "running"
|
||
state["paused_at"] = None
|
||
_update_activity()
|
||
_save_power_state(state)
|
||
|
||
# Emit presence event for banner injection
|
||
_emit_presence_event("wake", {"paused_duration": paused_duration})
|
||
|
||
# Start autostart instances
|
||
autostarted = _start_autostart_instances()
|
||
|
||
log.info("Streamlit container woken from sleep, autostarted %d instances", len(autostarted))
|
||
return {"success": True, "state": "running", "autostarted": autostarted}
|
||
|
||
@router.post("/power/toggle")
|
||
async def power_toggle(user=Depends(require_jwt)):
|
||
"""Toggle between pause and running states."""
|
||
if _lxc_running():
|
||
return await power_pause(user)
|
||
else:
|
||
return await power_wake(user)
|
||
|
||
|
||
@router.get("/autostart")
|
||
async def list_autostart():
|
||
"""List instances configured for autostart (public)."""
|
||
cfg = _load_streamlit_config()
|
||
if not cfg:
|
||
return {"autostart": [], "vhost_suffix": "gk2.secubox.in"}
|
||
|
||
autostart_list = []
|
||
instances = cfg.get("instances", {})
|
||
for name, inst in instances.items():
|
||
if inst.get("autostart") and inst.get("enabled"):
|
||
autostart_list.append({
|
||
"name": name,
|
||
"app": inst.get("app", name),
|
||
"port": inst.get("port"),
|
||
"domain": inst.get("domain", f"{name}.gk2.secubox.in"),
|
||
"emancipated": inst.get("emancipated", False),
|
||
})
|
||
|
||
return {
|
||
"autostart": autostart_list,
|
||
"count": len(autostart_list),
|
||
"vhost_suffix": "gk2.secubox.in",
|
||
}
|
||
|
||
|
||
@router.post("/autostart/run")
|
||
async def run_autostart(user=Depends(require_jwt)):
|
||
"""Manually trigger autostart for all configured instances."""
|
||
if not _lxc_running():
|
||
raise HTTPException(400, "Container not running. Wake it first.")
|
||
|
||
started = _start_autostart_instances()
|
||
return {
|
||
"success": True,
|
||
"started": started,
|
||
"count": len(started),
|
||
}
|
||
|
||
|
||
# ═══════════════════════════════════════════════════════════════════════
|
||
# MIGRATION
|
||
# ═══════════════════════════════════════════════════════════════════════
|
||
|
||
class MigrateRequest(BaseModel):
|
||
source: str = "192.168.255.1"
|
||
|
||
|
||
@router.post("/migrate")
|
||
async def migrate(req: MigrateRequest, background_tasks: BackgroundTasks, user=Depends(require_jwt)):
|
||
"""Migrate apps from OpenWrt SecuBox."""
|
||
def do_migrate():
|
||
subprocess.run([CTL, "migrate", req.source], timeout=600)
|
||
|
||
background_tasks.add_task(do_migrate)
|
||
log.info("Migration started from %s", req.source)
|
||
return {"success": True, "message": f"Migration from {req.source} started"}
|
||
|
||
|
||
# ═══════════════════════════════════════════════════════════════════════
|
||
# GITEA INTEGRATION
|
||
# ═══════════════════════════════════════════════════════════════════════
|
||
|
||
@router.post("/gitea/push/{name}")
|
||
async def gitea_push(name: str, user=Depends(require_jwt)):
|
||
"""Push app to Gitea repository."""
|
||
result = _run_ctl("gitea", "push", name, timeout=60)
|
||
return {"success": "error" not in result, "name": name}
|
||
|
||
|
||
class GiteaCloneRequest(BaseModel):
|
||
repo: str
|
||
|
||
|
||
@router.post("/gitea/clone/{name}")
|
||
async def gitea_clone(name: str, req: GiteaCloneRequest, user=Depends(require_jwt)):
|
||
"""Clone app from Gitea repository."""
|
||
result = _run_ctl("gitea", "clone", name, req.repo, timeout=120)
|
||
return {"success": "error" not in result, "name": name}
|
||
|
||
|
||
app.include_router(router)
|