#!/bin/sh # SecuBox Streamlit Platform Controller # Copyright (C) 2025 CyberMind.fr # # Manages Streamlit in LXC container # Supports multi-instance with folder-based apps and Gitea integration # Source OpenWrt UCI functions . /lib/functions.sh CONFIG="streamlit" LXC_NAME="streamlit" # Paths LXC_PATH="/srv/lxc" LXC_ROOTFS="$LXC_PATH/$LXC_NAME/rootfs" LXC_CONFIG="$LXC_PATH/$LXC_NAME/config" DEFAULT_APP="/usr/share/streamlit/hello.py" # Logging log_info() { echo "[INFO] $*"; logger -t streamlit "$*"; } log_warn() { echo "[WARN] $*" >&2; logger -t streamlit -p warning "$*"; } 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; } has_git() { command -v git >/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" # Gitea config gitea_enabled="$(uci_get gitea.enabled)" || gitea_enabled="0" gitea_url="$(uci_get gitea.url)" || gitea_url="http://192.168.255.1:3000" gitea_user="$(uci_get gitea.user)" || gitea_user="admin" gitea_token="$(uci_get gitea.token)" || gitea_token="" ensure_dir "$data_path" ensure_dir "$data_path/apps" ensure_dir "$data_path/logs" APPS_PATH="$data_path/apps" } # Usage usage() { cat < [options] Container Commands: install Download and setup LXC container uninstall Remove container (preserves apps) update Update Streamlit in container status Show service status logs [app] Show logs App Management (folder-based): app list List all apps app create Create new app folder app delete Delete app folder app deploy Deploy app from path/archive Instance Management: instance list List configured instances instance add Add instance for app on port instance remove Remove instance instance start Start single instance instance stop Stop single instance Gitea Integration: gitea setup Configure git credentials gitea clone Clone app from Gitea repo gitea pull Pull latest from Gitea gitea push Create Gitea repo and push app content gitea init-all Initialize Gitea repos for all existing apps Exposure: emancipate [domain] KISS ULTIME MODE - Full exposure workflow: 1. DNS A record (Gandi/OVH) 2. Vortex DNS mesh publication 3. HAProxy vhost with SSL 4. ACME certificate 5. Zero-downtime reload Service Commands: service-run Start all instances (for init) service-stop Stop all instances Utility: shell Open shell in container exec Execute command in container App Folder Structure: /srv/streamlit/apps// app.py Main Streamlit app (or .py) requirements.txt Python dependencies .streamlit/ Streamlit config (optional) ... Other files Examples: # Create local app streamlitctl app create myapp streamlitctl instance add myapp 8502 /etc/init.d/streamlit restart # Deploy from Gitea (complete workflow) uci set streamlit.gitea.enabled=1 uci set streamlit.gitea.url='http://192.168.255.1:3000' uci set streamlit.gitea.user='admin' uci set streamlit.gitea.token='your-token' uci commit streamlit streamlitctl gitea setup streamlitctl gitea clone yijing CyberMood/yijing-oracle streamlitctl instance add yijing 8505 /etc/init.d/streamlit restart # Access at: http://:8505 # Update app from Gitea streamlitctl gitea pull yijing /etc/init.d/streamlit restart 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" 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" cp /etc/resolv.conf "$rootfs/etc/resolv.conf" 2>/dev/null || \ echo "nameserver 1.1.1.1" > "$rootfs/etc/resolv.conf" log_info "Rootfs created successfully" return 0 } # Create startup script for multi-instance folder-based apps create_startup_script() { load_config local start_script="$LXC_ROOTFS/opt/start-streamlit.sh" cat > "$start_script" << 'STARTUP' #!/bin/sh export PATH=/usr/local/bin:/usr/bin:/bin:$PATH export HOME=/root APPS_BASE="/srv/apps" PIDS_DIR="/var/run/streamlit" LOG_DIR="/var/log/streamlit" INSTANCES_CONF="/srv/apps/instances.conf" mkdir -p "$PIDS_DIR" "$LOG_DIR" # 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 git procps poppler-utils 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 if [ ! -d "$APPS_BASE/hello" ]; then mkdir -p "$APPS_BASE/hello" cat > "$APPS_BASE/hello/app.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", "Ready", delta="+1") with col3: st.metric("Platform", "SecuBox") st.info("Deploy your Streamlit apps via LuCI dashboard or CLI") HELLO fi # Function to find main app file in folder find_app_file() { local app_dir="$1" local app_name=$(basename "$app_dir") # Priority: app.py > main.py > .py > first .py file for candidate in "$app_dir/app.py" "$app_dir/main.py" "$app_dir/${app_name}.py"; do [ -f "$candidate" ] && { echo "$candidate"; return 0; } done # Fallback to first .py file local first_py=$(ls -1 "$app_dir"/*.py 2>/dev/null | head -1) [ -n "$first_py" ] && { echo "$first_py"; return 0; } return 1 } # Function to install requirements for an app install_requirements() { local app_dir="$1" local app_name=$(basename "$app_dir") # Find requirements file: requirements.txt first, then any requirements*.txt local req_file="" if [ -f "$app_dir/requirements.txt" ]; then req_file="$app_dir/requirements.txt" else req_file=$(ls -1 "$app_dir"/requirements*.txt 2>/dev/null | head -1) fi if [ -n "$req_file" ] && [ -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 from $(basename $req_file) for $app_name..." pip3 install --break-system-packages -r "$req_file" 2>/dev/null || \ pip3 install -r "$req_file" 2>/dev/null || true touch "$REQ_MARKER" fi fi } # Function to start a single instance start_instance() { local app_name="$1" local port="$2" local app_dir="$APPS_BASE/$app_name" local app_file="" local work_dir="" if [ -d "$app_dir" ]; then # Folder-based app (ZIP upload, Gitea clone, or created via CLI) app_file=$(find_app_file "$app_dir") if [ -z "$app_file" ]; then echo "No Python app file found in $app_dir" return 1 fi install_requirements "$app_dir" work_dir="$app_dir" elif [ -f "$APPS_BASE/${app_name}.py" ]; then # Top-level single .py file (direct upload) app_file="$APPS_BASE/${app_name}.py" work_dir="$APPS_BASE" else echo "App not found: $app_name (no folder or .py file)" return 1 fi echo "Starting instance: $app_name on port $port (file: $(basename $app_file))" # Change to app/work directory so relative imports work cd "$work_dir" nohup streamlit run "$(basename $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}" \ > "$LOG_DIR/${app_name}.log" 2>&1 & echo $! > "$PIDS_DIR/${app_name}.pid" echo "Instance '$app_name' started (PID: $!)" } # Function to stop an instance stop_instance() { local app_name="$1" local pidfile="$PIDS_DIR/${app_name}.pid" if [ -f "$pidfile" ]; then local pid=$(cat "$pidfile") if kill -0 "$pid" 2>/dev/null; then kill "$pid" echo "Stopped instance '$app_name' (PID: $pid)" fi rm -f "$pidfile" fi } # Read instances from config file (format: appname:port) if [ -f "$INSTANCES_CONF" ]; then echo "Loading instances from config..." while IFS=: read -r app_name port; do [ -n "$app_name" ] && [ -n "$port" ] && start_instance "$app_name" "$port" done < "$INSTANCES_CONF" else # Fallback: single instance mode if [ -n "$STREAMLIT_APP" ] && [ -n "$STREAMLIT_PORT" ]; then start_instance "$STREAMLIT_APP" "$STREAMLIT_PORT" else # Default: start hello on 8501 start_instance "hello" "8501" fi fi # Keep container running echo "" echo "Streamlit multi-instance manager running." echo "Instances:" ls -1 "$PIDS_DIR"/*.pid 2>/dev/null | while read f; do name=$(basename "$f" .pid) pid=$(cat "$f") echo " - $name (PID: $pid)" done # Monitor loop while true; do sleep 30 if ! pgrep -f "streamlit" >/dev/null; then echo "No streamlit processes running, exiting..." exit 1 fi done STARTUP chmod +x "$start_script" } # Generate instances.conf from UCI generate_instances_conf() { load_config local conf_file="$data_path/apps/instances.conf" > "$conf_file" # Iterate over instance sections _add_instance_to_conf() { 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 echo "${inst_app}:${inst_port}" >> "$conf_file" fi } config_load "$CONFIG" config_foreach _add_instance_to_conf instance # If no instances, add default if [ ! -s "$conf_file" ]; then echo "hello:8501" > "$conf_file" fi log_debug "Generated instances.conf: $(cat $conf_file | tr '\n' ' ')" } # Create LXC config lxc_create_config() { load_config ensure_dir "$(dirname "$LXC_CONFIG")" 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 cat > "$LXC_CONFIG" << EOF # Streamlit Platform LXC Configuration (Multi-Instance) 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 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 lxc.mount.entry = /tmp/secubox tmp/secubox none bind,create=dir 0 0 # Environment lxc.environment = STREAMLIT_THEME_BASE=$theme_base lxc.environment = STREAMLIT_THEME_PRIMARY=$theme_primary # Terminal lxc.tty.max = 0 lxc.pty.max = 256 # Standard character devices lxc.cgroup2.devices.allow = c 1:* rwm lxc.cgroup2.devices.allow = c 5:* rwm lxc.cgroup2.devices.allow = c 136:* rwm # Security lxc.cap.drop = sys_admin sys_module mac_admin mac_override sys_time sys_rawio # Resource limits lxc.cgroup2.memory.max = $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 lxc_create_config create_startup_script generate_instances_conf log_info "Starting Streamlit container..." exec lxc-start -n "$LXC_NAME" -F -f "$LXC_CONFIG" } lxc_exec() { if ! lxc_running; then log_error "Container not running. Start with: /etc/init.d/streamlit start" return 1 fi lxc-attach -n "$LXC_NAME" -- env PATH=/usr/local/bin:/usr/bin:/bin "$@" } # App management commands cmd_app_list() { load_config echo "Streamlit Apps:" echo "---------------" if [ -d "$APPS_PATH" ]; then for app_dir in "$APPS_PATH"/*/; do [ -d "$app_dir" ] || continue local name=$(basename "$app_dir") # Find main file local main_file="" for candidate in "$app_dir/app.py" "$app_dir/main.py" "$app_dir/${name}.py"; do [ -f "$candidate" ] && { main_file=$(basename "$candidate"); break; } done [ -z "$main_file" ] && main_file=$(ls -1 "$app_dir"/*.py 2>/dev/null | head -1 | xargs basename 2>/dev/null) # Check for requirements (requirements.txt or any requirements*.txt) local has_req="no" [ -f "$app_dir/requirements.txt" ] && has_req="yes" [ "$has_req" = "no" ] && ls -1 "$app_dir"/requirements*.txt >/dev/null 2>&1 && has_req="yes" # Check for git repo local has_git="no" [ -d "$app_dir/.git" ] && has_git="yes" printf " %-20s main:%-15s req:%-3s git:%-3s\n" "$name" "${main_file:-none}" "$has_req" "$has_git" done fi # Also list legacy single-file apps for app_file in "$APPS_PATH"/*.py; do [ -f "$app_file" ] || continue local name=$(basename "$app_file" .py) # Skip if there's a folder with same name [ -d "$APPS_PATH/$name" ] && continue printf " %-20s (legacy single-file)\n" "$name" done } cmd_app_create() { require_root load_config local name="$1" [ -z "$name" ] && { log_error "App name required"; return 1; } # Validate name echo "$name" | grep -qE '^[a-z][a-z0-9_-]*$' || { log_error "Invalid app name. Use lowercase, numbers, underscore, hyphen." return 1 } local app_dir="$APPS_PATH/$name" if [ -d "$app_dir" ]; then log_error "App '$name' already exists" return 1 fi log_info "Creating app folder: $name" ensure_dir "$app_dir" ensure_dir "$app_dir/.streamlit" # Create template app.py cat > "$app_dir/app.py" << 'APPTEMPLATE' import streamlit as st st.set_page_config( page_title="My Streamlit App", page_icon="🚀", layout="wide" ) st.title("🚀 My Streamlit App") st.write("Edit this file to build your app!") # Example widgets name = st.text_input("Enter your name:") if name: st.success(f"Hello, {name}!") APPTEMPLATE # Create empty requirements.txt cat > "$app_dir/requirements.txt" << 'REQTEMPLATE' # Add your Python dependencies here, one per line # Example: # pandas>=2.0.0 # numpy # plotly REQTEMPLATE # Create .streamlit/config.toml cat > "$app_dir/.streamlit/config.toml" << 'CONFIGTEMPLATE' [theme] base = "dark" primaryColor = "#0ff" [server] headless = true CONFIGTEMPLATE # Register in UCI uci set "${CONFIG}.${name}=app" uci set "${CONFIG}.${name}.name=$name" uci set "${CONFIG}.${name}.enabled=1" uci commit "$CONFIG" log_info "App '$name' created at $app_dir" # Auto-push to Gitea if enabled if [ "$gitea_enabled" = "1" ] && [ -n "$gitea_token" ]; then log_info "Auto-pushing to Gitea..." cmd_gitea_push "$name" fi log_info "Next steps:" log_info " 1. Edit $app_dir/app.py" log_info " 2. Add dependencies to $app_dir/requirements.txt" log_info " 3. Start with: streamlitctl instance add $name " } cmd_app_delete() { require_root load_config local name="$1" [ -z "$name" ] && { log_error "App name required"; return 1; } if [ "$name" = "hello" ]; then log_error "Cannot delete the default hello app" return 1 fi local app_dir="$APPS_PATH/$name" # Stop any running instance cmd_instance_stop "$name" 2>/dev/null # Remove directory if [ -d "$app_dir" ]; then rm -rf "$app_dir" log_info "App folder removed: $app_dir" fi # Remove legacy file if [ -f "$APPS_PATH/${name}.py" ]; then rm -f "$APPS_PATH/${name}.py" fi # Remove from UCI uci -q delete "${CONFIG}.${name}" 2>/dev/null uci commit "$CONFIG" log_info "App '$name' deleted" # Regenerate GK2 Hub landing page if generator exists [ -x /usr/bin/gk2hub-generate ] && /usr/bin/gk2hub-generate >/dev/null 2>&1 & } cmd_app_deploy() { require_root load_config local name="$1" local source="$2" [ -z "$name" ] || [ -z "$source" ] && { log_error "Usage: streamlitctl app deploy " return 1 } local app_dir="$APPS_PATH/$name" ensure_dir "$app_dir" if [ -d "$source" ]; then # Copy directory contents log_info "Deploying from directory: $source" cp -r "$source"/* "$app_dir/" elif [ -f "$source" ]; then case "$source" in *.tar.gz|*.tgz) log_info "Extracting archive: $source" tar -xzf "$source" -C "$app_dir" ;; *.zip) log_info "Extracting zip: $source" unzip -q "$source" -d "$app_dir" ;; *.py) log_info "Deploying single file: $source" cp "$source" "$app_dir/app.py" ;; *) log_error "Unsupported file type" return 1 ;; esac else log_error "Source not found: $source" return 1 fi # Register in UCI uci set "${CONFIG}.${name}=app" uci set "${CONFIG}.${name}.name=$name" uci set "${CONFIG}.${name}.enabled=1" uci commit "$CONFIG" log_info "App '$name' deployed to $app_dir" # Auto-push to Gitea if enabled if [ "$gitea_enabled" = "1" ] && [ -n "$gitea_token" ]; then log_info "Auto-pushing to Gitea..." cmd_gitea_push "$name" fi # Auto-package for P2P distribution if [ -x /usr/sbin/secubox-content-pkg ]; then log_info "Packaging app for P2P distribution..." /usr/sbin/secubox-content-pkg streamlit "$name" 2>/dev/null && \ log_info "App packaged for mesh distribution" fi } # Instance management cmd_instance_list() { load_config echo "Streamlit Instances:" echo "--------------------" local found=0 _print_instance() { 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" [ -z "$app" ] || [ -z "$port" ] && return found=1 local status="disabled" [ "$enabled" = "1" ] && status="enabled" local running="" if lxc_running && [ -f "$LXC_ROOTFS/var/run/streamlit/${app}.pid" ]; then running=" [RUNNING]" fi printf " %-15s app:%-15s port:%-5s [%s]%s\n" "$section" "$app" "$port" "$status" "$running" } config_load "$CONFIG" config_foreach _print_instance instance [ "$found" = "0" ] && echo " (no instances configured)" } cmd_instance_add() { require_root load_config local app="$1" local port="$2" [ -z "$app" ] || [ -z "$port" ] && { log_error "Usage: streamlitctl instance add " return 1 } # Validate port echo "$port" | grep -qE '^[0-9]+$' || { log_error "Port must be a number" return 1 } # Check app exists if [ ! -d "$APPS_PATH/$app" ] && [ ! -f "$APPS_PATH/${app}.py" ]; then log_error "App not found: $app" log_info "Create with: streamlitctl app create $app" return 1 fi # Use app name as instance name local instance_name="$app" uci set "${CONFIG}.${instance_name}=instance" uci set "${CONFIG}.${instance_name}.name=$instance_name" uci set "${CONFIG}.${instance_name}.app=$app" uci set "${CONFIG}.${instance_name}.port=$port" uci set "${CONFIG}.${instance_name}.enabled=1" uci commit "$CONFIG" log_info "Instance added: $app on port $port" log_info "Restart service to apply: /etc/init.d/streamlit restart" # Regenerate GK2 Hub landing page if generator exists [ -x /usr/bin/gk2hub-generate ] && /usr/bin/gk2hub-generate >/dev/null 2>&1 & } cmd_instance_remove() { require_root load_config local name="$1" [ -z "$name" ] && { log_error "Instance name required"; return 1; } cmd_instance_stop "$name" 2>/dev/null uci -q delete "${CONFIG}.${name}" 2>/dev/null || { log_error "Instance not found: $name" return 1 } uci commit "$CONFIG" # Regenerate instances.conf generate_instances_conf log_info "Instance '$name' removed" # Regenerate GK2 Hub landing page if generator exists [ -x /usr/bin/gk2hub-generate ] && /usr/bin/gk2hub-generate >/dev/null 2>&1 & } cmd_instance_start() { require_root load_config local name="$1" [ -z "$name" ] && { log_error "Instance name required"; return 1; } # Get instance config local app=$(uci_get "${name}.app") local port=$(uci_get "${name}.port") [ -z "$app" ] || [ -z "$port" ] && { log_error "Instance '$name' not found or incomplete config" return 1 } if ! lxc_running; then log_error "Container not running. Start with: /etc/init.d/streamlit start" return 1 fi log_info "Starting instance '$name' (app: $app, port: $port)..." lxc_exec sh -c " WORK_DIR='' APP_FILE='' if [ -d /srv/apps/$app ]; then WORK_DIR='/srv/apps/$app' cd \"\$WORK_DIR\" # Find main file in folder for f in app.py main.py ${app}.py; do [ -f \"\$f\" ] && { APP_FILE=\"\$f\"; break; } done [ -z \"\$APP_FILE\" ] && APP_FILE=\$(ls -1 *.py 2>/dev/null | head -1) # Install requirements (requirements.txt or any requirements*.txt) REQ_FILE='' [ -f requirements.txt ] && REQ_FILE='requirements.txt' [ -z \"\$REQ_FILE\" ] && REQ_FILE=\$(ls -1 requirements*.txt 2>/dev/null | head -1) [ -n \"\$REQ_FILE\" ] && pip3 install --break-system-packages -r \"\$REQ_FILE\" 2>/dev/null elif [ -f /srv/apps/${app}.py ]; then WORK_DIR='/srv/apps' APP_FILE='${app}.py' cd \"\$WORK_DIR\" fi [ -z \"\$APP_FILE\" ] && { echo 'No Python file found for $app'; exit 1; } # Kill existing [ -f /var/run/streamlit/${app}.pid ] && kill \$(cat /var/run/streamlit/${app}.pid) 2>/dev/null mkdir -p /var/run/streamlit /var/log/streamlit nohup streamlit run \"\$APP_FILE\" \ --server.address=0.0.0.0 \ --server.port=$port \ --server.headless=true \ > /var/log/streamlit/${app}.log 2>&1 & echo \$! > /var/run/streamlit/${app}.pid echo \"Started \$APP_FILE on port $port (PID: \$!)\" " } cmd_instance_stop() { require_root load_config local name="$1" [ -z "$name" ] && { log_error "Instance name required"; return 1; } local app=$(uci_get "${name}.app") [ -z "$app" ] && app="$name" if ! lxc_running; then return 0 fi log_info "Stopping instance '$name'..." lxc_exec sh -c " if [ -f /var/run/streamlit/${app}.pid ]; then kill \$(cat /var/run/streamlit/${app}.pid) 2>/dev/null rm -f /var/run/streamlit/${app}.pid echo 'Stopped' else echo 'Not running' fi " } # Gitea integration cmd_gitea_setup() { require_root load_config if [ -z "$gitea_token" ]; then log_error "Gitea token not configured" log_info "Set with: uci set streamlit.gitea.token='your-token' && uci commit streamlit" return 1 fi if ! lxc_running; then log_error "Container not running" return 1 fi log_info "Configuring git credentials..." local gitea_host=$(echo "$gitea_url" | sed 's|^https\?://||' | sed 's|/.*||') lxc_exec sh -c " git config --global user.name '$gitea_user' git config --global user.email '${gitea_user}@localhost' git config --global credential.helper store rm -rf ~/.git-credentials cat > ~/.git-credentials << CRED https://${gitea_user}:${gitea_token}@${gitea_host} http://${gitea_user}:${gitea_token}@${gitea_host} CRED chmod 600 ~/.git-credentials " log_info "Git credentials configured" } cmd_gitea_clone() { require_root load_config local name="$1" local repo="$2" [ -z "$name" ] || [ -z "$repo" ] && { log_error "Usage: streamlitctl gitea clone " log_info " repo can be: username/reponame or full URL" return 1 } if [ "$gitea_enabled" != "1" ]; then log_error "Gitea integration not enabled" log_info "Enable with: uci set streamlit.gitea.enabled=1 && uci commit streamlit" return 1 fi if [ -z "$gitea_token" ]; then log_error "Gitea token not configured" return 1 fi local app_dir="$APPS_PATH/$name" # Build clone URL local clone_url if echo "$repo" | grep -q '^http'; then clone_url="$repo" else local gitea_host=$(echo "$gitea_url" | sed 's|^https\?://||' | sed 's|/.*||') clone_url="http://${gitea_user}:${gitea_token}@${gitea_host}/${repo}.git" fi if [ -d "$app_dir/.git" ]; then log_info "Repository already cloned, pulling latest..." cd "$app_dir" && git pull else log_info "Cloning $repo to $name..." ensure_dir "$(dirname "$app_dir")" rm -rf "$app_dir" git clone "$clone_url" "$app_dir" || { log_error "Failed to clone repository" return 1 } fi # Register in UCI uci set "${CONFIG}.${name}=app" uci set "${CONFIG}.${name}.name=$name" uci set "${CONFIG}.${name}.repo=$repo" uci set "${CONFIG}.${name}.enabled=1" uci commit "$CONFIG" log_info "App '$name' cloned from Gitea" log_info "Add instance with: streamlitctl instance add $name " } cmd_gitea_pull() { require_root load_config local name="$1" [ -z "$name" ] && { log_error "App name required"; return 1; } local app_dir="$APPS_PATH/$name" if [ ! -d "$app_dir/.git" ]; then log_error "App '$name' is not a git repository" return 1 fi log_info "Pulling latest for '$name'..." cd "$app_dir" && git pull log_info "Update complete" } # Create Gitea repo via API and push local content cmd_gitea_push() { require_root load_config local name="$1" [ -z "$name" ] && { log_error "App name required"; return 1; } if [ "$gitea_enabled" != "1" ]; then log_error "Gitea integration not enabled" log_info "Enable with: uci set streamlit.gitea.enabled=1 && uci commit streamlit" return 1 fi if [ -z "$gitea_token" ]; then log_error "Gitea token not configured" return 1 fi local app_dir="$APPS_PATH/$name" if [ ! -d "$app_dir" ]; then log_error "App '$name' not found at $app_dir" return 1 fi local gitea_host=$(echo "$gitea_url" | sed 's|^https\?://||' | sed 's|/.*||') local gitea_proto=$(echo "$gitea_url" | grep -q '^https' && echo "https" || echo "http") local repo_name="streamlit-$name" log_info "Creating Gitea repository: $repo_name" # Check if repo exists, create if not local repo_check=$(curl -s -o /dev/null -w "%{http_code}" \ -H "Authorization: token $gitea_token" \ "${gitea_url}/api/v1/repos/${gitea_user}/${repo_name}" 2>/dev/null) if [ "$repo_check" != "200" ]; then log_info "Repository doesn't exist, creating..." local create_result=$(curl -s -X POST \ -H "Authorization: token $gitea_token" \ -H "Content-Type: application/json" \ -d "{\"name\":\"${repo_name}\",\"description\":\"Streamlit app: ${name}\",\"private\":true,\"auto_init\":false}" \ "${gitea_url}/api/v1/user/repos" 2>/dev/null) if ! echo "$create_result" | grep -q "\"name\":"; then log_error "Failed to create repository" log_error "Response: $create_result" return 1 fi log_info "Repository created: ${gitea_user}/${repo_name}" else log_info "Repository exists: ${gitea_user}/${repo_name}" fi # Initialize git in app directory if needed cd "$app_dir" if [ ! -d ".git" ]; then log_info "Initializing git repository..." git init git config user.name "$gitea_user" git config user.email "${gitea_user}@localhost" fi # Set remote local remote_url="${gitea_proto}://${gitea_user}:${gitea_token}@${gitea_host}/${gitea_user}/${repo_name}.git" git remote remove origin 2>/dev/null git remote add origin "$remote_url" # Add, commit and push log_info "Adding files and committing..." git add -A git commit -m "Auto-push from SecuBox Streamlit at $(date -Iseconds)" 2>/dev/null || \ log_info "No changes to commit" log_info "Pushing to Gitea..." git push -u origin HEAD:main --force 2>&1 || { # Try master branch as fallback git push -u origin HEAD:master --force 2>&1 || { log_error "Failed to push to Gitea" return 1 } } # Save repo reference in UCI uci set "${CONFIG}.${name}.repo=${gitea_user}/${repo_name}" uci set "${CONFIG}.${name}.gitea_synced=$(date -Iseconds)" uci commit "$CONFIG" log_info "Push complete: ${gitea_url}/${gitea_user}/${repo_name}" } # Initialize Gitea for all existing apps cmd_gitea_init_all() { require_root load_config if [ "$gitea_enabled" != "1" ]; then log_error "Gitea integration not enabled" log_info "Enable with: uci set streamlit.gitea.enabled=1 && uci commit streamlit" return 1 fi if [ -z "$gitea_token" ]; then log_error "Gitea token not configured" return 1 fi log_info "Initializing Gitea repositories for all apps..." echo "" local success=0 local failed=0 # Process all app directories if [ -d "$APPS_PATH" ]; then for app_dir in "$APPS_PATH"/*/; do [ -d "$app_dir" ] || continue local name=$(basename "$app_dir") # Skip if already has a repo configured local existing_repo=$(uci -q get ${CONFIG}.${name}.repo) if [ -n "$existing_repo" ]; then log_info "[$name] Already linked to $existing_repo, syncing..." cmd_gitea_push "$name" && success=$((success + 1)) || failed=$((failed + 1)) else log_info "[$name] Creating Gitea repository..." cmd_gitea_push "$name" && success=$((success + 1)) || failed=$((failed + 1)) fi echo "" done fi echo "========================================" echo "Gitea initialization complete" echo " Success: $success" echo " Failed: $failed" echo "========================================" } # =========================================== # KISS ULTIME MODE - Emancipate # =========================================== _emancipate_dns() { local name="$1" local domain="$2" local default_zone=$(uci -q get dns-provider.main.zone) local provider=$(uci -q get dns-provider.main.provider) local vortex_wildcard=$(uci -q get vortex-dns.master.wildcard_domain) # Check if dnsctl is available if ! command -v dnsctl >/dev/null 2>&1; then log_warn "[DNS] dnsctl not found, skipping external DNS" return 1 fi # Get public IP local public_ip=$(curl -s --connect-timeout 5 https://ipv4.icanhazip.com 2>/dev/null | tr -d '\n') [ -z "$public_ip" ] && { log_warn "[DNS] Cannot detect public IP, skipping DNS"; return 1; } # Detect zone from domain suffix (try known zones) local zone="" local subdomain="" for z in "secubox.in" "maegia.tv" "cybermind.fr"; do if echo "$domain" | grep -q "\.${z}$"; then zone="$z" subdomain=$(echo "$domain" | sed "s/\.${z}$//") break elif [ "$domain" = "$z" ]; then zone="$z" subdomain="@" break fi done # Fallback to default zone if no match if [ -z "$zone" ]; then zone="$default_zone" subdomain=$(echo "$domain" | sed "s/\.${zone}$//") fi [ -z "$zone" ] && { log_warn "[DNS] No zone detected, skipping external DNS"; return 1; } log_info "[DNS] Registering $subdomain.$zone -> $public_ip via $provider" # Register on the published domain's zone dnsctl -z "$zone" add A "$subdomain" "$public_ip" 3600 # Also register on vortex node subdomain (e.g., myapp.gk2.secubox.in) if [ -n "$vortex_wildcard" ]; then local vortex_zone=$(echo "$vortex_wildcard" | sed 's/^[^.]*\.//') local vortex_node=$(echo "$vortex_wildcard" | cut -d. -f1) local vortex_subdomain="${name}.${vortex_node}" log_info "[DNS] Registering $vortex_subdomain.$vortex_zone -> $public_ip (vortex node)" dnsctl -z "$vortex_zone" add A "$vortex_subdomain" "$public_ip" 3600 fi log_info "[DNS] Verify with: dnsctl verify $domain" } _emancipate_vortex() { local name="$1" local domain="$2" # Check if vortexctl is available if ! command -v vortexctl >/dev/null 2>&1; then log_info "[VORTEX] vortexctl not found, skipping mesh publication" return 0 fi # Check if vortex-dns is enabled local vortex_enabled=$(uci -q get vortex-dns.main.enabled) if [ "$vortex_enabled" = "1" ]; then log_info "[VORTEX] Publishing $name as $domain to mesh" vortexctl mesh publish "$name" "$domain" 2>/dev/null else log_info "[VORTEX] Vortex DNS disabled, skipping mesh publication" fi } _emancipate_haproxy() { local name="$1" local domain="$2" local port="$3" log_info "[HAPROXY] Creating vhost for $domain -> port $port" # Create backend local backend_name="streamlit_${name}" uci set haproxy.${backend_name}=backend uci set haproxy.${backend_name}.name="$backend_name" uci set haproxy.${backend_name}.mode="http" uci set haproxy.${backend_name}.balance="roundrobin" uci set haproxy.${backend_name}.enabled="1" # Create server local server_name="${backend_name}_srv" uci set haproxy.${server_name}=server uci set haproxy.${server_name}.backend="$backend_name" uci set haproxy.${server_name}.name="streamlit" uci set haproxy.${server_name}.address="192.168.255.1" uci set haproxy.${server_name}.port="$port" uci set haproxy.${server_name}.weight="100" uci set haproxy.${server_name}.check="1" uci set haproxy.${server_name}.enabled="1" # Create vhost with SSL - Route through mitmproxy_inspector for WAF protection local vhost_name=$(echo "$domain" | tr '.-' '_') uci set haproxy.${vhost_name}=vhost uci set haproxy.${vhost_name}.domain="$domain" uci set haproxy.${vhost_name}.backend="mitmproxy_inspector" uci set haproxy.${vhost_name}.ssl="1" uci set haproxy.${vhost_name}.ssl_redirect="1" uci set haproxy.${vhost_name}.acme="1" uci set haproxy.${vhost_name}.enabled="1" uci commit haproxy # Register route in centralized registry (route sync happens in _emancipate_mitmproxy) # Note: Route registration moved to _emancipate_mitmproxy() for centralized management # Generate HAProxy config if command -v haproxyctl >/dev/null 2>&1; then haproxyctl generate 2>/dev/null fi } _emancipate_ssl() { local domain="$1" log_info "[SSL] Requesting certificate for $domain" # Check if haproxyctl is available if ! command -v haproxyctl >/dev/null 2>&1; then log_warn "[SSL] haproxyctl not found, skipping SSL" return 1 fi # haproxyctl cert add handles ACME webroot mode haproxyctl cert add "$domain" 2>&1 | while read line; do echo " $line" done if [ -f "/srv/haproxy/certs/$domain.pem" ]; then log_info "[SSL] Certificate obtained successfully" else log_warn "[SSL] Certificate request may still be pending" log_warn "[SSL] Check with: haproxyctl cert verify $domain" fi } _emancipate_mitmproxy() { local domain="$1" local port="$2" local routes_file="/srv/mitmproxy-in/haproxy-routes.json" log_info "[MITMPROXY] Adding route: $domain -> 192.168.255.1:$port" # Direct JSON update - most reliable method if [ -f "$routes_file" ] && command -v python3 >/dev/null 2>&1; then python3 -c " import json import sys try: with open('$routes_file', 'r') as f: routes = json.load(f) routes['$domain'] = ['192.168.255.1', $port] with open('$routes_file', 'w') as f: json.dump(routes, f, indent=2) print('Route added successfully') except Exception as e: print(f'Error: {e}', file=sys.stderr) sys.exit(1) " 2>&1 && { log_info "[MITMPROXY] Route registered in $routes_file" return 0 } fi # Fallback: Use centralized secubox-route if available if command -v secubox-route >/dev/null 2>&1; then if secubox-route add "$domain" "192.168.255.1" "$port" "streamlit" 2>&1; then log_info "[MITMPROXY] Route registered via secubox-route" return 0 fi fi # Fallback: Sync via mitmproxyctl if command -v mitmproxyctl >/dev/null 2>&1; then log_warn "[MITMPROXY] Direct update failed, trying mitmproxyctl" mitmproxyctl sync-routes >/dev/null 2>&1 && { log_info "[MITMPROXY] Routes synced via mitmproxyctl" return 0 } fi log_error "[MITMPROXY] Failed to register route - manual intervention required" log_error "[MITMPROXY] Add manually: echo '{\"$domain\": [\"192.168.255.1\", $port]}' to $routes_file" return 1 } _emancipate_reload() { log_info "[RELOAD] Applying HAProxy configuration" # Generate fresh config and reload container haproxyctl generate 2>/dev/null log_info "[RELOAD] Reloading HAProxy container..." haproxyctl reload 2>/dev/null # Verify HAProxy container is running if lxc-info -n haproxy -s 2>/dev/null | grep -q RUNNING; then log_info "[RELOAD] HAProxy container reloaded successfully" else log_warn "[RELOAD] HAProxy container may not be running properly" fi # Regenerate GK2 Hub landing page if generator exists [ -x /usr/bin/gk2hub-generate ] && /usr/bin/gk2hub-generate >/dev/null 2>&1 & } _emancipate_acl() { local name="$1" local domain="$2" local port="$3" log_info "[ACL] Creating routing rules for $name" # Create ACL rule for Streamlit WebSocket paths local acl_name="streamlit_${name}_acl" local vhost_name=$(echo "$domain" | tr '.-' '_') local backend_name="streamlit_${name}" # ACL for Streamlit-specific paths (WebSocket + static) uci set haproxy.${acl_name}=acl uci set haproxy.${acl_name}.name="path_streamlit_${name}" uci set haproxy.${acl_name}.criterion="path_beg" uci set haproxy.${acl_name}.pattern="/_stcore /static /component /media /health" uci set haproxy.${acl_name}.backend="$backend_name" uci set haproxy.${acl_name}.vhost="$vhost_name" uci set haproxy.${acl_name}.enabled="1" uci commit haproxy log_info "[ACL] Rule $acl_name created for vhost $vhost_name" } _emancipate_mesh() { local name="$1" local domain="$2" local port="$3" log_info "[MESH] Publishing $name to P2P mesh" # Register in secubox-p2p shared services if command -v ubus >/dev/null 2>&1; then ubus call luci.secubox-p2p share_service "{\"name\":\"streamlit_${name}\",\"port\":$port,\"domain\":\"$domain\",\"type\":\"streamlit\"}" 2>/dev/null && \ log_info "[MESH] Service published to local mesh registry" fi # Sync to peers if P2P is enabled local p2p_enabled=$(uci -q get secubox-p2p.main.enabled) if [ "$p2p_enabled" = "1" ]; then # Get peer list and sync local peers=$(uci -q get secubox-p2p.main.peers 2>/dev/null) for peer in $peers; do local peer_addr=$(uci -q get secubox-p2p.${peer}.address 2>/dev/null) if [ -n "$peer_addr" ]; then log_info "[MESH] Syncing to peer: $peer_addr" # Use dbclient to sync emancipation state dbclient -y -i /root/.ssh/id_dropbear "root@$peer_addr" \ "uci set streamlit.${name}_remote=remote_instance; \ uci set streamlit.${name}_remote.origin_host='$(uci -q get system.@system[0].hostname)'; \ uci set streamlit.${name}_remote.domain='$domain'; \ uci set streamlit.${name}_remote.port='$port'; \ uci commit streamlit" 2>/dev/null && \ log_info "[MESH] Peer $peer_addr updated" || \ log_warn "[MESH] Failed to sync to $peer_addr" fi done fi } cmd_emancipate_all() { require_root load_config echo "" echo "==============================================" echo " BULK EMANCIPATION: All Streamlit Instances" echo "==============================================" echo "" # Get all enabled instances local instances=$(uci show ${CONFIG} 2>/dev/null | grep "=instance$" | cut -d'.' -f2 | cut -d'=' -f1) local count=0 local success=0 local failed=0 for inst in $instances; do local enabled=$(uci -q get ${CONFIG}.${inst}.enabled) local port=$(uci -q get ${CONFIG}.${inst}.port) local app=$(uci -q get ${CONFIG}.${inst}.app) if [ "$enabled" = "1" ] && [ -n "$port" ]; then count=$((count + 1)) echo "[$count] Emancipating: $inst (app: $app, port: $port)" # Check if already emancipated local already=$(uci -q get ${CONFIG}.${inst}.emancipated) if [ "$already" = "1" ]; then local existing_domain=$(uci -q get ${CONFIG}.${inst}.domain) echo " Already emancipated: $existing_domain" success=$((success + 1)) continue fi # Run emancipation if cmd_emancipate "$inst" 2>/dev/null; then success=$((success + 1)) echo " ✓ Success" else failed=$((failed + 1)) echo " ✗ Failed" fi fi done echo "" echo "==============================================" echo " BULK EMANCIPATION COMPLETE" echo "==============================================" echo "" echo " Total: $count instances" echo " Success: $success" echo " Failed: $failed" echo "" # Final reload _emancipate_reload } cmd_emancipate() { require_root load_config local name="$1" local domain="$2" [ -z "$name" ] && { log_error "Instance name required"; usage; return 1; } # Get instance port and app name from UCI local port=$(uci -q get ${CONFIG}.${name}.port) local app=$(uci -q get ${CONFIG}.${name}.app) # If no port, this might be an app name not instance name if [ -z "$port" ]; then # Try to find an instance for this app local found_inst=$(uci show ${CONFIG} 2>/dev/null | grep "\.app='$name'" | head -1 | cut -d'.' -f2) if [ -n "$found_inst" ]; then port=$(uci -q get ${CONFIG}.${found_inst}.port) app="$name" name="$found_inst" fi fi # Check if app folder exists (use app name, not instance name) local app_to_check="${app:-$name}" if [ ! -d "$APPS_PATH/$app_to_check" ] && [ ! -f "$APPS_PATH/${app_to_check}.py" ]; then log_error "App '$app_to_check' not found in $APPS_PATH" log_error "Create first: streamlitctl app create $app_to_check" return 1 fi if [ -z "$port" ]; then log_error "No instance configured for app '$name'" log_error "Add instance first: streamlitctl instance add $name " return 1 fi # Domain is optional - can be auto-generated from vortex wildcard if [ -z "$domain" ]; then local vortex_wildcard=$(uci -q get vortex-dns.master.wildcard_domain) if [ -n "$vortex_wildcard" ]; then local vortex_zone=$(echo "$vortex_wildcard" | sed 's/^[^.]*\.//') local vortex_node=$(echo "$vortex_wildcard" | cut -d. -f1) domain="${name}.${vortex_node}.${vortex_zone}" log_info "Auto-generated domain: $domain" else log_error "Domain required: streamlitctl emancipate $name " return 1 fi fi echo "" echo "==============================================" echo " KISS ULTIME MODE: Emancipating $name" echo "==============================================" echo "" echo " App: $name" echo " Domain: $domain" echo " Port: $port" echo "" # Step 1: DNS Registration (external provider) _emancipate_dns "$name" "$domain" # Step 2: Vortex DNS (mesh registration) _emancipate_vortex "$name" "$domain" # Step 3: HAProxy vhost + backend _emancipate_haproxy "$name" "$domain" "$port" # Step 4: HAProxy ACL rules for routing _emancipate_acl "$name" "$domain" "$port" # Step 5: SSL Certificate _emancipate_ssl "$domain" # Step 6: Mesh P2P distribution to peers _emancipate_mesh "$name" "$domain" "$port" # Step 7: Register mitmproxy route (WAF routing) _emancipate_mitmproxy "$domain" "$port" # Step 8: Reload HAProxy _emancipate_reload # Mark instance as emancipated with WAF enabled uci set ${CONFIG}.${name}.emancipated="1" uci set ${CONFIG}.${name}.emancipated_at="$(date -Iseconds)" uci set ${CONFIG}.${name}.domain="$domain" uci set ${CONFIG}.${name}.waf_enabled="1" uci commit ${CONFIG} echo "" echo "==============================================" echo " EMANCIPATION COMPLETE" echo "==============================================" echo "" echo " Site: https://$domain" echo " Status: Published and SSL-protected" echo " Mesh: $(uci -q get vortex-dns.main.enabled | grep -q 1 && echo 'Published' || echo 'Disabled')" echo "" echo " Verify:" echo " curl -v https://$domain" echo " dnsctl verify $domain" echo " haproxyctl cert verify $domain" echo "" } # Main commands cmd_install() { require_root load_config log_info "Installing Streamlit Platform..." lxc_check_prereqs || exit 1 if ! lxc_exists; then lxc_create_rootfs || exit 1 fi lxc_create_config || exit 1 create_startup_script # Setup default app ensure_dir "$APPS_PATH/hello" uci_set main.enabled '1' /etc/init.d/streamlit enable 2>/dev/null || true log_info "Installation complete!" log_info "" log_info "Next steps:" log_info " 1. Create app: streamlitctl app create myapp" log_info " 2. Add instance: streamlitctl instance add myapp 8501" log_info " 3. Start: /etc/init.d/streamlit start" } cmd_uninstall() { require_root log_info "Uninstalling Streamlit Platform..." /etc/init.d/streamlit stop 2>/dev/null || true /etc/init.d/streamlit disable 2>/dev/null || true lxc_stop 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" return 1 fi log_info "Updating Streamlit..." rm -f "$LXC_ROOTFS/opt/.installed" 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" lxc_running && running="true" cat << EOF Streamlit Platform Status ========================== Enabled: $([ "$enabled" = "1" ] && echo "yes" || echo "no") Running: $([ "$running" = "true" ] && echo "yes" || echo "no") Data Path: $data_path Memory: $memory_limit Container: $LXC_NAME EOF echo "Apps:" cmd_app_list | tail -n +3 echo "" echo "Instances:" cmd_instance_list | tail -n +3 } cmd_logs() { load_config local app="${1:-}" if [ -n "$app" ]; then local log_file="$data_path/logs/${app}.log" if [ -f "$log_file" ]; then tail -100 "$log_file" else # Try inside container lxc_exec cat /var/log/streamlit/${app}.log 2>/dev/null || echo "No logs for '$app'" fi else # Show all logs for log_file in "$data_path/logs"/*.log; do [ -f "$log_file" ] || continue echo "=== $(basename "$log_file") ===" tail -50 "$log_file" echo "" done fi } 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 } # Main case "${1:-}" in install) shift; cmd_install "$@" ;; uninstall) shift; cmd_uninstall "$@" ;; update) shift; cmd_update "$@" ;; status) shift; cmd_status "$@" ;; logs) shift; cmd_logs "$@" ;; shell) shift; cmd_shell "$@" ;; app) shift case "${1:-}" in list) shift; cmd_app_list "$@" ;; create) shift; cmd_app_create "$@" ;; delete) shift; cmd_app_delete "$@" ;; deploy) shift; cmd_app_deploy "$@" ;; *) echo "Usage: streamlitctl app {list|create|delete|deploy}"; exit 1 ;; esac ;; instance) shift case "${1:-}" in list) shift; cmd_instance_list "$@" ;; add) shift; cmd_instance_add "$@" ;; remove) shift; cmd_instance_remove "$@" ;; start) shift; cmd_instance_start "$@" ;; stop) shift; cmd_instance_stop "$@" ;; *) echo "Usage: streamlitctl instance {list|add|remove|start|stop}"; exit 1 ;; esac ;; gitea) shift case "${1:-}" in setup) shift; cmd_gitea_setup "$@" ;; clone) shift; cmd_gitea_clone "$@" ;; pull) shift; cmd_gitea_pull "$@" ;; push) shift; cmd_gitea_push "$@" ;; init-all) shift; cmd_gitea_init_all "$@" ;; *) echo "Usage: streamlitctl gitea {setup|clone|pull|push|init-all}"; exit 1 ;; esac ;; service-run) shift; cmd_service_run "$@" ;; service-stop) shift; cmd_service_stop "$@" ;; emancipate) shift; cmd_emancipate "$@" ;; emancipate-all) cmd_emancipate_all ;; *) usage ;; esac