#!/bin/sh # SecuBox Streamlit Platform Controller # Copyright (C) 2025 CyberMind.fr # # Manages Streamlit in LXC container CONFIG="streamlit" LXC_NAME="streamlit" # Paths LXC_PATH="/srv/lxc" LXC_ROOTFS="$LXC_PATH/$LXC_NAME/rootfs" LXC_CONFIG="$LXC_PATH/$LXC_NAME/config" APPS_PATH="/srv/streamlit/apps" DEFAULT_APP="/usr/share/streamlit/hello.py" # Logging log_info() { echo "[INFO] $*"; logger -t streamlit "$*"; } log_error() { echo "[ERROR] $*" >&2; logger -t streamlit -p err "$*"; } log_debug() { [ "$DEBUG" = "1" ] && echo "[DEBUG] $*"; } # Helpers require_root() { [ "$(id -u)" -eq 0 ] || { log_error "This command requires root privileges" exit 1 } } has_lxc() { command -v lxc-start >/dev/null 2>&1; } ensure_dir() { [ -d "$1" ] || mkdir -p "$1"; } uci_get() { uci -q get ${CONFIG}.$1; } uci_set() { uci set ${CONFIG}.$1="$2" && uci commit ${CONFIG}; } # Load configuration load_config() { http_port="$(uci_get main.http_port)" || http_port="8501" http_host="$(uci_get main.http_host)" || http_host="0.0.0.0" data_path="$(uci_get main.data_path)" || data_path="/srv/streamlit" memory_limit="$(uci_get main.memory_limit)" || memory_limit="512M" active_app="$(uci_get main.active_app)" || active_app="hello" # Server settings headless="$(uci_get server.headless)" || headless="true" gather_stats="$(uci_get server.browser_gather_usage_stats)" || gather_stats="false" theme_base="$(uci_get server.theme_base)" || theme_base="dark" theme_primary="$(uci_get server.theme_primary_color)" || theme_primary="#0ff" ensure_dir "$data_path" ensure_dir "$data_path/apps" ensure_dir "$data_path/logs" APPS_PATH="$data_path/apps" } # Usage usage() { cat < [options] Commands: install Download Alpine rootfs and setup LXC container uninstall Remove container (preserves apps) update Update Streamlit package in container status Show service status (JSON format) logs Show container logs shell Open shell in container app list List deployed apps app add Deploy new app app remove Remove app instance list List running instances instance add Add instance config instance remove Remove instance config instance enable Enable instance instance disable Disable instance service-run Start service (used by init) service-stop Stop service (used by init) Configuration: /etc/config/streamlit Data directory: /srv/streamlit Multi-Instance Mode: Add instances in /etc/config/streamlit: config instance 'myapp' option app 'myapp.py' option port '8502' option enabled '1' Requirements: Place requirements.txt in /srv/streamlit/apps/ - .requirements.txt - _requirements.txt - requirements.txt (global) EOF } # Check prerequisites lxc_check_prereqs() { if ! has_lxc; then log_error "LXC not installed. Install with: opkg install lxc lxc-common" return 1 fi return 0 } # Create Python LXC rootfs from Alpine lxc_create_rootfs() { local rootfs="$LXC_ROOTFS" local arch=$(uname -m) log_info "Creating Alpine rootfs for Streamlit..." ensure_dir "$rootfs" # Use Alpine mini rootfs local alpine_version="3.21" case "$arch" in x86_64) alpine_arch="x86_64" ;; aarch64) alpine_arch="aarch64" ;; armv7l) alpine_arch="armv7" ;; *) log_error "Unsupported architecture: $arch"; return 1 ;; esac local alpine_url="https://dl-cdn.alpinelinux.org/alpine/v${alpine_version}/releases/${alpine_arch}/alpine-minirootfs-${alpine_version}.0-${alpine_arch}.tar.gz" local tmpfile="/tmp/alpine-rootfs.tar.gz" log_info "Downloading Alpine ${alpine_version} rootfs..." wget -q -O "$tmpfile" "$alpine_url" || { log_error "Failed to download Alpine rootfs" return 1 } log_info "Extracting rootfs..." tar -xzf "$tmpfile" -C "$rootfs" || { log_error "Failed to extract rootfs" return 1 } rm -f "$tmpfile" # Setup resolv.conf cp /etc/resolv.conf "$rootfs/etc/resolv.conf" 2>/dev/null || \ echo "nameserver 1.1.1.1" > "$rootfs/etc/resolv.conf" # Create startup script - Multi-instance support cat > "$rootfs/opt/start-streamlit.sh" << 'STARTUP' #!/bin/sh # Install Python and Streamlit on first run if [ ! -f /opt/.installed ]; then echo "Installing Python 3.12 and dependencies..." apk update apk add --no-cache python3 py3-pip procps echo "Installing Streamlit..." pip3 install --break-system-packages streamlit 2>/dev/null || \ pip3 install streamlit 2>/dev/null touch /opt/.installed echo "Installation complete!" fi # Create default hello app if missing mkdir -p /srv/apps if [ ! -f "/srv/apps/hello.py" ]; then cat > /srv/apps/hello.py << 'HELLO' import streamlit as st st.set_page_config(page_title="SecuBox Streamlit", page_icon="⚡", layout="wide") st.title("⚡ SECUBOX STREAMLIT ⚡") st.markdown("### Neural Data App Platform") col1, col2, col3 = st.columns(3) with col1: st.metric("Status", "ONLINE", delta="Active") with col2: st.metric("Apps", "1", delta="+1") with col3: st.metric("Platform", "SecuBox") st.info("Deploy your Streamlit apps via LuCI dashboard") HELLO fi # Function to install requirements for an app install_requirements() { local app_name="$1" for req_file in "/srv/apps/${app_name}.requirements.txt" \ "/srv/apps/${app_name}_requirements.txt" \ "/srv/apps/requirements.txt"; do if [ -f "$req_file" ]; then REQ_HASH=$(md5sum "$req_file" 2>/dev/null | cut -d' ' -f1) REQ_MARKER="/opt/.req_${app_name}_${REQ_HASH}" if [ ! -f "$REQ_MARKER" ]; then echo "Installing requirements for $app_name from: $req_file" pip3 install --break-system-packages -r "$req_file" 2>/dev/null || \ pip3 install -r "$req_file" 2>/dev/null || true touch "$REQ_MARKER" fi break fi done } # Function to start a single Streamlit instance start_instance() { local app_file="$1" local port="$2" local app_name=$(basename "$app_file" .py) if [ ! -f "/srv/apps/$app_file" ]; then echo "App not found: $app_file" return 1 fi install_requirements "$app_name" echo "Starting instance: $app_name on port $port" cd /srv/apps streamlit run "$app_file" \ --server.address="0.0.0.0" \ --server.port="$port" \ --server.headless=true \ --browser.gatherUsageStats=false \ --theme.base="${STREAMLIT_THEME_BASE:-dark}" \ --theme.primaryColor="${STREAMLIT_THEME_PRIMARY:-#0ff}" & echo $! > "/tmp/streamlit_${app_name}.pid" } # Parse STREAMLIT_INSTANCES env var (format: "app1.py:8501,app2.py:8502") if [ -n "$STREAMLIT_INSTANCES" ]; then echo "Multi-instance mode: $STREAMLIT_INSTANCES" IFS=',' for instance in $STREAMLIT_INSTANCES; do app_file=$(echo "$instance" | cut -d: -f1) port=$(echo "$instance" | cut -d: -f2) start_instance "$app_file" "$port" done unset IFS else # Single instance mode (backward compatible) ACTIVE_APP="${STREAMLIT_APP:-hello.py}" PORT="${STREAMLIT_PORT:-8501}" start_instance "$ACTIVE_APP" "$PORT" fi # Keep container running and monitor processes echo "Streamlit instances started. Monitoring..." while true; do sleep 30 # Check if any streamlit process is running if ! pgrep -f "streamlit" >/dev/null; then echo "No streamlit processes running, exiting..." exit 1 fi done STARTUP chmod +x "$rootfs/opt/start-streamlit.sh" log_info "Rootfs created successfully" return 0 } # Build instances string from UCI config _instances_result="" _build_instance_entry() { local section="$1" local inst_enabled inst_app inst_port config_get inst_enabled "$section" enabled "0" config_get inst_app "$section" app "" config_get inst_port "$section" port "" if [ "$inst_enabled" = "1" ] && [ -n "$inst_app" ] && [ -n "$inst_port" ]; then [ -n "$_instances_result" ] && _instances_result="${_instances_result}," _instances_result="${_instances_result}${inst_app}:${inst_port}" fi } build_instances_string() { _instances_result="" config_load "$CONFIG" config_foreach _build_instance_entry instance echo "$_instances_result" } # Create LXC config lxc_create_config() { load_config ensure_dir "$(dirname "$LXC_CONFIG")" # Convert memory limit to bytes local mem_bytes case "$memory_limit" in *G|*g) mem_bytes=$((${memory_limit%[Gg]} * 1024 * 1024 * 1024)) ;; *M|*m) mem_bytes=$((${memory_limit%[Mm]} * 1024 * 1024)) ;; *K|*k) mem_bytes=$((${memory_limit%[Kk]} * 1024)) ;; *) mem_bytes="$memory_limit" ;; esac # Build multi-instance string or fallback to single app local instances_str instances_str=$(build_instances_string) # Fallback: if no instances defined, use active_app local app_file="" if [ -z "$instances_str" ]; then if [ -f "$APPS_PATH/${active_app}.py" ]; then app_file="${active_app}.py" elif [ -f "$APPS_PATH/${active_app}" ]; then app_file="${active_app}" else app_file="hello.py" fi fi cat > "$LXC_CONFIG" << EOF # Streamlit Platform LXC Configuration lxc.uts.name = $LXC_NAME lxc.rootfs.path = dir:$LXC_ROOTFS lxc.arch = $(uname -m) # Network: use host network lxc.net.0.type = none # Mount points lxc.mount.auto = proc:mixed sys:ro cgroup:mixed lxc.mount.entry = $APPS_PATH srv/apps none bind,create=dir 0 0 lxc.mount.entry = $data_path/logs srv/logs none bind,create=dir 0 0 # Environment lxc.environment = STREAMLIT_THEME_BASE=$theme_base lxc.environment = STREAMLIT_THEME_PRIMARY=$theme_primary EOF # Add multi-instance or single-instance env vars if [ -n "$instances_str" ]; then echo "lxc.environment = STREAMLIT_INSTANCES=$instances_str" >> "$LXC_CONFIG" else cat >> "$LXC_CONFIG" << EOF lxc.environment = STREAMLIT_APP=$app_file lxc.environment = STREAMLIT_PORT=$http_port EOF fi cat >> "$LXC_CONFIG" << EOF # Security lxc.cap.drop = sys_admin sys_module mac_admin mac_override sys_time sys_rawio # Resource limits lxc.cgroup.memory.limit_in_bytes = $mem_bytes # Init command lxc.init.cmd = /opt/start-streamlit.sh EOF log_info "LXC config created" } # Container control lxc_running() { lxc-info -n "$LXC_NAME" -s 2>/dev/null | grep -q "RUNNING" } lxc_exists() { [ -f "$LXC_CONFIG" ] && [ -d "$LXC_ROOTFS" ] } lxc_stop() { if lxc_running; then log_info "Stopping Streamlit container..." lxc-stop -n "$LXC_NAME" -k 2>/dev/null || true sleep 2 fi } lxc_run() { load_config lxc_stop if ! lxc_exists; then log_error "Container not installed. Run: streamlitctl install" return 1 fi # Regenerate config in case settings changed lxc_create_config log_info "Starting Streamlit container..." exec lxc-start -n "$LXC_NAME" -F -f "$LXC_CONFIG" } # App management cmd_app_list() { load_config echo "{" echo ' "apps": [' local first=1 if [ -d "$APPS_PATH" ]; then for app in "$APPS_PATH"/*.py; do [ -f "$app" ] || continue local name=$(basename "$app" .py) local size=$(ls -la "$app" 2>/dev/null | awk '{print $5}') local mtime=$(ls -la "$app" 2>/dev/null | awk '{print $6, $7, $8}') # Check if this is the active app local is_active="false" if [ "$name" = "$active_app" ] || [ "${name}.py" = "$active_app" ]; then is_active="true" fi [ $first -eq 0 ] && echo "," first=0 printf ' {"name": "%s", "path": "%s", "size": "%s", "modified": "%s", "active": %s}' \ "$name" "$app" "$size" "$mtime" "$is_active" done fi echo "" echo " ]," echo " \"active_app\": \"$active_app\"," echo " \"apps_path\": \"$APPS_PATH\"" echo "}" } cmd_app_add() { local name="$1" local path="$2" if [ -z "$name" ] || [ -z "$path" ]; then log_error "Usage: streamlitctl app add " return 1 fi load_config if [ ! -f "$path" ]; then log_error "Source file not found: $path" return 1 fi # Validate it looks like a Python file if ! echo "$path" | grep -q '\.py$'; then log_error "Source file must be a .py file" return 1 fi ensure_dir "$APPS_PATH" local dest="$APPS_PATH/${name}.py" cp "$path" "$dest" || { log_error "Failed to copy app to $dest" return 1 } # Register in UCI uci set "${CONFIG}.${name}=app" uci set "${CONFIG}.${name}.name=$name" uci set "${CONFIG}.${name}.path=${name}.py" uci set "${CONFIG}.${name}.enabled=1" uci commit "$CONFIG" log_info "App '$name' added successfully" echo '{"success": true, "message": "App added", "name": "'"$name"'"}' } cmd_app_remove() { local name="$1" if [ -z "$name" ]; then log_error "Usage: streamlitctl app remove " return 1 fi if [ "$name" = "hello" ]; then log_error "Cannot remove the default hello app" return 1 fi load_config local app_file="$APPS_PATH/${name}.py" if [ -f "$app_file" ]; then rm -f "$app_file" fi # Remove from UCI uci -q delete "${CONFIG}.${name}" 2>/dev/null || true uci commit "$CONFIG" # If this was the active app, switch to hello if [ "$active_app" = "$name" ]; then uci_set main.active_app "hello" fi log_info "App '$name' removed" echo '{"success": true, "message": "App removed", "name": "'"$name"'"}' } cmd_app_run() { local name="$1" if [ -z "$name" ]; then log_error "Usage: streamlitctl app run " return 1 fi load_config local app_file="$APPS_PATH/${name}.py" if [ ! -f "$app_file" ]; then log_error "App not found: $name" return 1 fi uci_set main.active_app "$name" # Restart if running if lxc_running; then log_info "Switching to app: $name (restarting container)" /etc/init.d/streamlit restart else log_info "Active app set to: $name" fi echo '{"success": true, "message": "Active app changed", "active_app": "'"$name"'"}' } # Commands cmd_install() { require_root load_config log_info "Installing Streamlit Platform..." lxc_check_prereqs || exit 1 # Create container if ! lxc_exists; then lxc_create_rootfs || exit 1 fi lxc_create_config || exit 1 # Setup default app ensure_dir "$APPS_PATH" if [ -f "$DEFAULT_APP" ] && [ ! -f "$APPS_PATH/hello.py" ]; then cp "$DEFAULT_APP" "$APPS_PATH/hello.py" log_info "Default hello app installed" fi # Enable service uci_set main.enabled '1' /etc/init.d/streamlit enable 2>/dev/null || true log_info "Installation complete!" log_info "" log_info "Start with: /etc/init.d/streamlit start" log_info "Web interface: http://:$http_port" } cmd_uninstall() { require_root log_info "Uninstalling Streamlit Platform..." # Stop service /etc/init.d/streamlit stop 2>/dev/null || true /etc/init.d/streamlit disable 2>/dev/null || true lxc_stop # Remove container (keep apps) if [ -d "$LXC_PATH/$LXC_NAME" ]; then rm -rf "$LXC_PATH/$LXC_NAME" log_info "Container removed" fi uci_set main.enabled '0' log_info "Streamlit Platform uninstalled" log_info "Apps preserved in: $(uci_get main.data_path)/apps" } cmd_update() { require_root load_config if ! lxc_exists; then log_error "Container not installed. Run: streamlitctl install" return 1 fi log_info "Updating Streamlit in container..." # Remove installed marker to force reinstall rm -f "$LXC_ROOTFS/opt/.installed" # Restart to trigger update if [ "$(uci_get main.enabled)" = "1" ]; then /etc/init.d/streamlit restart fi log_info "Update will apply on next start" } cmd_status() { load_config local enabled="$(uci_get main.enabled)" local running="false" local installed="false" local uptime="" if lxc_exists; then installed="true" fi if lxc_running; then running="true" uptime=$(lxc-info -n "$LXC_NAME" 2>/dev/null | grep -i "cpu use" | head -1 | awk '{print $3}') fi # Get LAN IP for URL local lan_ip lan_ip=$(uci -q get network.lan.ipaddr || echo "192.168.1.1") # Count apps local app_count=0 if [ -d "$APPS_PATH" ]; then app_count=$(ls -1 "$APPS_PATH"/*.py 2>/dev/null | wc -l) fi cat << EOF { "enabled": $([ "$enabled" = "1" ] && echo "true" || echo "false"), "running": $running, "installed": $installed, "uptime": "$uptime", "http_port": $http_port, "data_path": "$data_path", "memory_limit": "$memory_limit", "active_app": "$active_app", "app_count": $app_count, "web_url": "http://${lan_ip}:${http_port}", "container_name": "$LXC_NAME" } EOF } cmd_status_text() { load_config local enabled="$(uci_get main.enabled)" local running="false" local uptime="" if lxc_running; then running="true" uptime=$(lxc-info -n "$LXC_NAME" 2>/dev/null | grep -i "cpu use" | head -1) fi cat << EOF Streamlit Platform Status ========================== Enabled: $([ "$enabled" = "1" ] && echo "yes" || echo "no") Running: $([ "$running" = "true" ] && echo "yes" || echo "no") HTTP Port: $http_port Data Path: $data_path Memory: $memory_limit Active App: $active_app Container: $LXC_NAME Rootfs: $LXC_ROOTFS Config: $LXC_CONFIG EOF if [ "$running" = "true" ]; then echo "Web interface: http://$(uci -q get network.lan.ipaddr || echo "localhost"):$http_port" fi } cmd_logs() { load_config local lines="${1:-100}" if [ -d "$data_path/logs" ]; then if [ -n "$(ls -A "$data_path/logs" 2>/dev/null)" ]; then tail -n "$lines" "$data_path/logs"/*.log 2>/dev/null || \ cat "$data_path/logs"/*.log 2>/dev/null || \ echo "No logs found" else echo "No logs yet" fi else echo "Log directory not found" fi # Also check install logs for logfile in /var/log/streamlit-install.log /var/log/streamlit-update.log; do if [ -f "$logfile" ]; then echo "" echo "=== $logfile ===" tail -n 50 "$logfile" fi done } cmd_shell() { require_root if ! lxc_running; then log_error "Container not running" exit 1 fi lxc-attach -n "$LXC_NAME" -- /bin/sh } cmd_service_run() { require_root load_config lxc_check_prereqs || exit 1 lxc_run } cmd_service_stop() { require_root lxc_stop } # Instance management _instance_list_first=1 _print_instance_json() { local section="$1" local name app port enabled config_get name "$section" name "$section" config_get app "$section" app "" config_get port "$section" port "" config_get enabled "$section" enabled "0" [ "$_instance_list_first" -eq 0 ] && echo "," _instance_list_first=0 printf ' {"id": "%s", "name": "%s", "app": "%s", "port": "%s", "enabled": %s}' \ "$section" "$name" "$app" "$port" "$([ "$enabled" = "1" ] && echo "true" || echo "false")" } cmd_instance_list() { load_config echo "{" echo ' "instances": [' _instance_list_first=1 config_load "$CONFIG" config_foreach _print_instance_json instance echo "" echo " ]" echo "}" } cmd_instance_add() { local name="$1" local app="$2" local port="$3" if [ -z "$name" ] || [ -z "$app" ] || [ -z "$port" ]; then log_error "Usage: streamlitctl instance add " return 1 fi # Validate port is numeric if ! echo "$port" | grep -qE '^[0-9]+$'; then log_error "Port must be a number" return 1 fi uci set "${CONFIG}.${name}=instance" uci set "${CONFIG}.${name}.name=$name" uci set "${CONFIG}.${name}.app=$app" uci set "${CONFIG}.${name}.port=$port" uci set "${CONFIG}.${name}.enabled=1" uci commit "$CONFIG" log_info "Instance '$name' added (app: $app, port: $port)" echo '{"success": true, "message": "Instance added", "name": "'"$name"'", "port": '"$port"'}' } cmd_instance_remove() { local name="$1" if [ -z "$name" ]; then log_error "Usage: streamlitctl instance remove " return 1 fi uci -q delete "${CONFIG}.${name}" 2>/dev/null || { log_error "Instance not found: $name" return 1 } uci commit "$CONFIG" log_info "Instance '$name' removed" echo '{"success": true, "message": "Instance removed", "name": "'"$name"'"}' } cmd_instance_enable() { local name="$1" if [ -z "$name" ]; then log_error "Usage: streamlitctl instance enable " return 1 fi uci set "${CONFIG}.${name}.enabled=1" && uci commit "$CONFIG" || { log_error "Instance not found: $name" return 1 } log_info "Instance '$name' enabled" echo '{"success": true, "message": "Instance enabled", "name": "'"$name"'"}' } cmd_instance_disable() { local name="$1" if [ -z "$name" ]; then log_error "Usage: streamlitctl instance disable " return 1 fi uci set "${CONFIG}.${name}.enabled=0" && uci commit "$CONFIG" || { log_error "Instance not found: $name" return 1 } log_info "Instance '$name' disabled" echo '{"success": true, "message": "Instance disabled", "name": "'"$name"'"}' } # Main case "${1:-}" in install) shift; cmd_install "$@" ;; uninstall) shift; cmd_uninstall "$@" ;; update) shift; cmd_update "$@" ;; status) shift if [ "${1:-}" = "--json" ] || [ -t 0 ]; then cmd_status "$@" else cmd_status_text "$@" fi ;; logs) shift; cmd_logs "$@" ;; shell) shift; cmd_shell "$@" ;; app) shift case "${1:-}" in list) shift; cmd_app_list "$@" ;; add) shift; cmd_app_add "$@" ;; remove) shift; cmd_app_remove "$@" ;; *) echo "Usage: streamlitctl app {list|add|remove}"; exit 1 ;; esac ;; instance) shift case "${1:-}" in list) shift; cmd_instance_list "$@" ;; add) shift; cmd_instance_add "$@" ;; remove) shift; cmd_instance_remove "$@" ;; enable) shift; cmd_instance_enable "$@" ;; disable) shift; cmd_instance_disable "$@" ;; *) echo "Usage: streamlitctl instance {list|add|remove|enable|disable}"; exit 1 ;; esac ;; service-run) shift; cmd_service_run "$@" ;; service-stop) shift; cmd_service_stop "$@" ;; *) usage ;; esac