From 04908fc414c61644d50d1a2baff71ce39dbe7d74 Mon Sep 17 00:00:00 2001 From: CyberMind-FR Date: Tue, 27 Jan 2026 06:37:19 +0100 Subject: [PATCH] feat(multi): CrowdSec LAPI port fix, Streamlit/HexoJS multi-instance CrowdSec: - Change LAPI default port from 8080 to 8180 (avoid Docker conflict) - Update bouncer config, init script, and RPCD dashboard - Fix port detection hex value (1FF4 for 8180) Streamlit: - Complete rewrite with folder-based app structure - Multi-instance support (multiple apps on different ports) - Gitea integration (clone, pull, setup commands) - Auto-install requirements.txt with hash-based caching HexoJS: - Multi-instance support with folder structure - Multiple blog instances on different ports HAProxy: - Auto-generate fallback backends (luci, apps, default_luci) - Add --server letsencrypt to ACME commands Co-Authored-By: Claude Opus 4.5 --- .../luci-app-crowdsec-dashboard/Makefile | 2 +- .../usr/libexec/rpcd/luci.crowdsec-dashboard | 10 +- .../secubox-app-cs-firewall-bouncer/Makefile | 2 +- .../files/crowdsec-bouncer.defaults | 2 +- .../files/crowdsec-firewall-bouncer.initd | 2 +- .../files/crowdsec.config | 2 +- package/secubox/secubox-app-haproxy/Makefile | 2 +- .../files/usr/sbin/haproxyctl | 54 +- .../usr/share/haproxy/templates/default.cfg | 11 +- package/secubox/secubox-app-hexojs/Makefile | 2 +- .../secubox-app-hexojs/files/usr/sbin/hexoctl | 1113 ++++++++-------- .../secubox/secubox-app-streamlit/Makefile | 21 +- .../files/usr/sbin/streamlitctl | 1173 ++++++++++------- 13 files changed, 1384 insertions(+), 1012 deletions(-) diff --git a/package/secubox/luci-app-crowdsec-dashboard/Makefile b/package/secubox/luci-app-crowdsec-dashboard/Makefile index a55cce8d..5965a3ee 100644 --- a/package/secubox/luci-app-crowdsec-dashboard/Makefile +++ b/package/secubox/luci-app-crowdsec-dashboard/Makefile @@ -9,7 +9,7 @@ include $(TOPDIR)/rules.mk PKG_NAME:=luci-app-crowdsec-dashboard PKG_VERSION:=0.7.0 -PKG_RELEASE:=28 +PKG_RELEASE:=29 PKG_ARCH:=all PKG_LICENSE:=Apache-2.0 diff --git a/package/secubox/luci-app-crowdsec-dashboard/root/usr/libexec/rpcd/luci.crowdsec-dashboard b/package/secubox/luci-app-crowdsec-dashboard/root/usr/libexec/rpcd/luci.crowdsec-dashboard index d49e018a..6218762e 100755 --- a/package/secubox/luci-app-crowdsec-dashboard/root/usr/libexec/rpcd/luci.crowdsec-dashboard +++ b/package/secubox/luci-app-crowdsec-dashboard/root/usr/libexec/rpcd/luci.crowdsec-dashboard @@ -181,14 +181,14 @@ get_status() { elif ! grep -q "password:" "$creds_file" 2>/dev/null; then lapi_reason="credentials incomplete" else - # Check if LAPI port is listening (8080 hex = 1F90) + # Check if LAPI port is listening (8180 hex = 1FF4) local port_up=0 - if grep -qi ":1F90 " /proc/net/tcp 2>/dev/null; then + if grep -qi ":1FF4 " /proc/net/tcp 2>/dev/null; then port_up=1 fi if [ "$port_up" = "0" ]; then - lapi_reason="port 8080 not listening" + lapi_reason="port 8180 not listening" else # Try actual LAPI status check if run_cscli lapi status >/dev/null 2>&1; then @@ -746,7 +746,7 @@ get_firewall_bouncer_config() { val=$(uci -q get crowdsec.bouncer.ipv6 || echo "1") json_add_string "ipv6" "$val" - val=$(uci -q get crowdsec.bouncer.api_url || echo "http://127.0.0.1:8080/") + val=$(uci -q get crowdsec.bouncer.api_url || echo "http://127.0.0.1:8180/") json_add_string "api_url" "$val" val=$(uci -q get crowdsec.bouncer.update_frequency || echo "10s") @@ -1822,7 +1822,7 @@ get_health_check() { # LAPI status local lapi_status="unavailable" - local lapi_url="http://127.0.0.1:8080" + local lapi_url="http://127.0.0.1:8180" if [ -x "$CSCLI" ]; then if run_with_timeout 5 "$CSCLI" lapi status >/dev/null 2>&1; then lapi_status="available" diff --git a/package/secubox/secubox-app-cs-firewall-bouncer/Makefile b/package/secubox/secubox-app-cs-firewall-bouncer/Makefile index 4acd95e0..2ee05f44 100644 --- a/package/secubox/secubox-app-cs-firewall-bouncer/Makefile +++ b/package/secubox/secubox-app-cs-firewall-bouncer/Makefile @@ -10,7 +10,7 @@ include $(TOPDIR)/rules.mk PKG_NAME:=secubox-app-cs-firewall-bouncer PKG_VERSION:=0.0.31 -PKG_RELEASE:=3 +PKG_RELEASE:=4 # Source from upstream CrowdSec # Note: v0.0.31 is the last version compatible with Go 1.23 (OpenWrt 24.10 SDK) diff --git a/package/secubox/secubox-app-cs-firewall-bouncer/files/crowdsec-bouncer.defaults b/package/secubox/secubox-app-cs-firewall-bouncer/files/crowdsec-bouncer.defaults index c42f4a2c..ea435acd 100644 --- a/package/secubox/secubox-app-cs-firewall-bouncer/files/crowdsec-bouncer.defaults +++ b/package/secubox/secubox-app-cs-firewall-bouncer/files/crowdsec-bouncer.defaults @@ -88,7 +88,7 @@ merge_config() { uci set crowdsec.bouncer.enabled='0' uci set crowdsec.bouncer.ipv4='1' uci set crowdsec.bouncer.ipv6='1' - uci set crowdsec.bouncer.api_url='http://127.0.0.1:8080/' + uci set crowdsec.bouncer.api_url='http://127.0.0.1:8180/' uci set crowdsec.bouncer.update_frequency='10s' uci set crowdsec.bouncer.deny_action='drop' uci set crowdsec.bouncer.deny_log='1' diff --git a/package/secubox/secubox-app-cs-firewall-bouncer/files/crowdsec-firewall-bouncer.initd b/package/secubox/secubox-app-cs-firewall-bouncer/files/crowdsec-firewall-bouncer.initd index d93a017b..73e5b02f 100644 --- a/package/secubox/secubox-app-cs-firewall-bouncer/files/crowdsec-firewall-bouncer.initd +++ b/package/secubox/secubox-app-cs-firewall-bouncer/files/crowdsec-firewall-bouncer.initd @@ -50,7 +50,7 @@ init_yaml() { config_get hook_priority $section priority "4" config_get update_frequency $section update_frequency '10s' config_get log_level $section log_level 'info' - config_get api_url $section api_url "http://127.0.0.1:8080" + config_get api_url $section api_url "http://127.0.0.1:8180" config_get api_key $section api_key "API_KEY" config_get_bool ipv6 $section ipv6 '1' config_get deny_action $section deny_action "drop" diff --git a/package/secubox/secubox-app-cs-firewall-bouncer/files/crowdsec.config b/package/secubox/secubox-app-cs-firewall-bouncer/files/crowdsec.config index 156761e9..eddc2a5f 100644 --- a/package/secubox/secubox-app-cs-firewall-bouncer/files/crowdsec.config +++ b/package/secubox/secubox-app-cs-firewall-bouncer/files/crowdsec.config @@ -8,7 +8,7 @@ config bouncer option enabled '0' option ipv4 '1' option ipv6 '1' - option api_url 'http://127.0.0.1:8080/' + option api_url 'http://127.0.0.1:8180/' option api_key '' option update_frequency '10s' option priority '4' diff --git a/package/secubox/secubox-app-haproxy/Makefile b/package/secubox/secubox-app-haproxy/Makefile index d59a6202..c8dd94a2 100644 --- a/package/secubox/secubox-app-haproxy/Makefile +++ b/package/secubox/secubox-app-haproxy/Makefile @@ -6,7 +6,7 @@ include $(TOPDIR)/rules.mk PKG_NAME:=secubox-app-haproxy PKG_VERSION:=1.0.0 -PKG_RELEASE:=19 +PKG_RELEASE:=21 PKG_MAINTAINER:=CyberMind PKG_LICENSE:=MIT diff --git a/package/secubox/secubox-app-haproxy/files/usr/sbin/haproxyctl b/package/secubox/secubox-app-haproxy/files/usr/sbin/haproxyctl index 14d702f5..9ae5d1dc 100644 --- a/package/secubox/secubox-app-haproxy/files/usr/sbin/haproxyctl +++ b/package/secubox/secubox-app-haproxy/files/usr/sbin/haproxyctl @@ -19,6 +19,7 @@ CONFIG_PATH="$DATA_PATH/config" # Logging log_info() { echo "[INFO] $*"; logger -t haproxy "$*"; } +log_warn() { echo "[WARN] $*" >&2; logger -t haproxy -p warning "$*"; } log_error() { echo "[ERROR] $*" >&2; logger -t haproxy -p err "$*"; } log_debug() { [ "$DEBUG" = "1" ] && echo "[DEBUG] $*"; } @@ -44,6 +45,7 @@ load_config() { memory_limit="$(uci_get main.memory_limit)" || memory_limit="256M" maxconn="$(uci_get main.maxconn)" || maxconn="4096" log_level="$(uci_get main.log_level)" || log_level="warning" + default_backend="$(uci_get main.default_backend)" || default_backend="default_luci" CERTS_PATH="$data_path/certs" CONFIG_PATH="$data_path/config" @@ -288,11 +290,12 @@ frontend stats frontend http-in bind *:80 mode http - default_backend fallback + default_backend default_luci -backend fallback +backend default_luci mode http - server local 127.0.0.1:8080 check + balance roundrobin + server luci 192.168.255.1:8081 check CFGEOF fi @@ -415,7 +418,7 @@ EOF # Add vhost ACLs for HTTP config_foreach _add_vhost_acl vhost "http" - echo " default_backend fallback" + echo " default_backend $default_backend" echo "" # HTTPS Frontend (if certificates exist) @@ -432,7 +435,7 @@ EOF # Add vhost ACLs for HTTPS config_foreach _add_vhost_acl vhost "https" - echo " default_backend fallback" + echo " default_backend $default_backend" echo "" fi } @@ -481,18 +484,43 @@ _add_vhost_acl() { _generate_backends() { config_load haproxy + # Track which backends are generated + _generated_backends="" + # Generate each backend from UCI config_foreach _generate_backend backend - # Only add default fallback if no "fallback" backend exists in UCI - if ! uci -q get haproxy.fallback >/dev/null 2>&1; then + # Collect all backends referenced by vhosts + _referenced_backends="" + _collect_vhost_backend() { + local section="$1" + local enabled backend + config_get enabled "$section" enabled "0" + [ "$enabled" = "1" ] || return + config_get backend "$section" backend + [ -n "$backend" ] && _referenced_backends="$_referenced_backends $backend" + } + config_foreach _collect_vhost_backend vhost + + # Add default_backend to referenced list + _referenced_backends="$_referenced_backends $default_backend" + + # Generate fallback backends for any referenced but not generated + # These common backends route to uhttpd on the host + for backend_name in default_luci luci apps; do + # Check if this backend is referenced + echo "$_referenced_backends" | grep -qw "$backend_name" || continue + # Check if already generated + echo "$_generated_backends" | grep -qw "$backend_name" && continue + # Generate fallback cat << EOF -backend fallback +backend $backend_name mode http - http-request deny deny_status 503 + balance roundrobin + server $backend_name 192.168.255.1:8081 check EOF - fi + done } _generate_backend() { @@ -507,6 +535,9 @@ _generate_backend() { config_get balance "$section" balance "roundrobin" config_get health_check "$section" health_check "" + # Track generated backend + _generated_backends="$_generated_backends $name" + echo "" echo "backend $name" echo " mode $mode" @@ -759,7 +790,7 @@ cmd_cert_add() { # Register account if needed if [ ! -f "$LE_WORKING_DIR/account.conf" ]; then log_info "Registering ACME account..." - "$ACME_SH" --register-account -m "$email" $staging_flag --home "$LE_WORKING_DIR" || true + "$ACME_SH" --register-account -m "$email" --server letsencrypt $staging_flag --home "$LE_WORKING_DIR" || true fi # Check if HAProxy is using the port @@ -775,6 +806,7 @@ cmd_cert_add() { log_info "Issuing certificate (standalone mode on port $http_port)..." local acme_result=0 "$ACME_SH" --issue -d "$domain" \ + --server letsencrypt \ --standalone --httpport "$http_port" \ --keylength "$key_type" \ $staging_flag \ diff --git a/package/secubox/secubox-app-haproxy/files/usr/share/haproxy/templates/default.cfg b/package/secubox/secubox-app-haproxy/files/usr/share/haproxy/templates/default.cfg index 080d22e3..531d1738 100644 --- a/package/secubox/secubox-app-haproxy/files/usr/share/haproxy/templates/default.cfg +++ b/package/secubox/secubox-app-haproxy/files/usr/share/haproxy/templates/default.cfg @@ -45,7 +45,7 @@ frontend http-in # Default: redirect to HTTPS http-request redirect scheme https code 301 unless is_acme - default_backend fallback + default_backend default_luci # HTTPS frontend - SSL termination frontend https-in @@ -62,14 +62,15 @@ frontend https-in http-request set-header X-Real-IP %[src] http-request set-header X-Forwarded-For %[src] - default_backend fallback + default_backend default_luci # ACME challenge backend backend acme mode http server acme 127.0.0.1:8080 check -# Fallback backend -backend fallback +# Default LuCI backend - routes to uhttpd +backend default_luci mode http - http-request deny deny_status 503 + balance roundrobin + server luci 192.168.255.1:8081 check diff --git a/package/secubox/secubox-app-hexojs/Makefile b/package/secubox/secubox-app-hexojs/Makefile index debe0305..fd2cd59c 100644 --- a/package/secubox/secubox-app-hexojs/Makefile +++ b/package/secubox/secubox-app-hexojs/Makefile @@ -8,7 +8,7 @@ include $(TOPDIR)/rules.mk PKG_NAME:=secubox-app-hexojs PKG_VERSION:=1.0.0 -PKG_RELEASE:=6 +PKG_RELEASE:=8 PKG_ARCH:=all PKG_MAINTAINER:=CyberMind Studio diff --git a/package/secubox/secubox-app-hexojs/files/usr/sbin/hexoctl b/package/secubox/secubox-app-hexojs/files/usr/sbin/hexoctl index 7e4efb6d..add47bbb 100644 --- a/package/secubox/secubox-app-hexojs/files/usr/sbin/hexoctl +++ b/package/secubox/secubox-app-hexojs/files/usr/sbin/hexoctl @@ -3,6 +3,7 @@ # Copyright (C) 2025 CyberMind.fr # # Manages Hexo static site generator in LXC container +# Supports multiple instances on different ports CONFIG="hexojs" LXC_NAME="hexojs" @@ -15,6 +16,7 @@ SHARE_PATH="/usr/share/hexojs" # Logging log_info() { echo "[INFO] $*"; logger -t hexojs "$*"; } +log_warn() { echo "[WARN] $*" >&2; logger -t hexojs -p warning "$*"; } log_error() { echo "[ERROR] $*" >&2; logger -t hexojs -p err "$*"; } log_debug() { [ "$DEBUG" = "1" ] && echo "[DEBUG] $*"; } @@ -34,46 +36,89 @@ 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 main configuration load_config() { - http_port="$(uci_get main.http_port)" || http_port="4000" data_path="$(uci_get main.data_path)" || data_path="/srv/hexojs" - active_site="$(uci_get main.active_site)" || active_site="default" memory_limit="$(uci_get main.memory_limit)" || memory_limit="512M" - # Site config - site_title="$(uci_get ${active_site}.title)" || site_title="My Blog" - site_subtitle="$(uci_get ${active_site}.subtitle)" || site_subtitle="" - site_author="$(uci_get ${active_site}.author)" || site_author="Admin" - site_language="$(uci_get ${active_site}.language)" || site_language="en" - site_theme="$(uci_get ${active_site}.theme)" || site_theme="cybermind" - site_url="$(uci_get ${active_site}.url)" || site_url="http://localhost:$http_port" - site_root="$(uci_get ${active_site}.root)" || site_root="/" - site_per_page="$(uci_get ${active_site}.per_page)" || site_per_page="10" + # Legacy support: active_site for default instance + active_site="$(uci_get main.active_site)" || active_site="default" + http_port="$(uci_get main.http_port)" || http_port="4000" - # Deploy config - deploy_type="$(uci_get deploy.type)" || deploy_type="git" - deploy_repo="$(uci_get deploy.repo)" || deploy_repo="" - deploy_branch="$(uci_get deploy.branch)" || deploy_branch="gh-pages" - - # Gitea config + # Gitea config (shared) 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="" gitea_content_repo="$(uci_get gitea.content_repo)" || gitea_content_repo="blog-content" gitea_content_branch="$(uci_get gitea.content_branch)" || gitea_content_branch="main" - gitea_auto_sync="$(uci_get gitea.auto_sync)" || gitea_auto_sync="0" ensure_dir "$data_path" - ensure_dir "$data_path/sites" - ensure_dir "$data_path/media" + ensure_dir "$data_path/instances" + ensure_dir "$data_path/themes" +} + +# Load instance configuration +load_instance_config() { + local instance="$1" + [ -z "$instance" ] && instance="default" + + current_instance="$instance" + + # Check if instance section exists + local instance_type=$(uci_get "${instance}") + if [ "$instance_type" != "instance" ]; then + # Legacy: check if it's old-style site section or doesn't exist + if [ -z "$instance_type" ] && [ "$instance" = "default" ]; then + # Use legacy main config for default + instance_port="$http_port" + instance_title="$(uci_get default.title)" || instance_title="My Blog" + instance_theme="$(uci_get default.theme)" || instance_theme="cybermind" + instance_enabled="1" + else + instance_port="" + instance_title="" + instance_theme="" + instance_enabled="0" + return 1 + fi + else + instance_port="$(uci_get ${instance}.port)" || instance_port="4000" + instance_title="$(uci_get ${instance}.title)" || instance_title="My Blog" + instance_theme="$(uci_get ${instance}.theme)" || instance_theme="cybermind" + instance_enabled="$(uci_get ${instance}.enabled)" || instance_enabled="0" + fi + + instance_path="$data_path/instances/$instance" + instance_site="$instance_path/site" + + return 0 +} + +# Get list of all enabled instances +get_enabled_instances() { + local instances="" + + # Check for instance sections in UCI + for section in $(uci show hexojs 2>/dev/null | grep '=instance$' | cut -d'.' -f2 | cut -d'=' -f1); do + local enabled=$(uci_get "${section}.enabled") + [ "$enabled" = "1" ] && instances="$instances $section" + done + + # If no instances defined, check for legacy default + if [ -z "$instances" ]; then + if [ -d "$data_path/site" ] || [ -d "$data_path/instances/default/site" ]; then + instances="default" + fi + fi + + echo "$instances" } # Usage usage() { cat < [options] @@ -83,48 +128,55 @@ Container Commands: update Update Hexo and dependencies status Show service status -Site Management: - site create Create new Hexo site - site list List all sites - site delete Delete a site - site switch Switch active site +Instance Management: + instance list List all instances + instance create Create new instance + instance delete Delete an instance + instance start Start instance server + instance stop Stop instance server + instance status Show instance status + +Site Management (operates on current/specified instance): + site create [instance] Create Hexo site for instance + site delete [instance] Delete site for instance Content Commands: - new post "Title" Create new blog post - new page "Title" Create new page - new draft "Title" Create new draft - publish draft Publish a draft - list posts List all posts - list drafts List all drafts + new post "Title" [instance] Create new blog post + new page "Title" [instance] Create new page + new draft "Title" [instance] Create new draft + list posts [instance] List all posts + list drafts [instance] List all drafts Build Commands: - serve Start preview server (port $http_port) - build (generate) Generate static files - clean Clean generated files - deploy Deploy to configured git target - publish Copy static files to /www/blog/ + serve [instance] Start preview server + build [instance] Generate static files + clean [instance] Clean generated files + deploy [instance] Deploy to configured target + publish [instance] Copy static files to /www/ Service Commands: - service-run Run in foreground (for init) - service-stop Stop service + service-run Run all instances (for init) + service-stop Stop all instances Gitea Integration: - gitea setup Configure git credentials in container - gitea clone Clone content repo from Gitea - gitea sync Pull latest content from Gitea - gitea status Show Gitea sync status + gitea setup [instance] Configure git credentials + gitea clone [instance] Clone content repo + gitea sync [instance] Pull latest content Utility: shell Open shell in container - logs View container logs + logs [instance] View logs exec Execute command in container +Examples: + hexoctl instance create myblog # Create new instance + hexoctl instance start myblog # Start on configured port + hexoctl site create myblog # Initialize Hexo site + hexoctl new post "Hello" myblog # Create post in myblog + Configuration: /etc/config/hexojs -Data directory: - /srv/hexojs - EOF } @@ -146,7 +198,6 @@ lxc_create_rootfs() { ensure_dir "$rootfs" - # Use Alpine mini rootfs local alpine_version="3.21" case "$arch" in x86_64) alpine_arch="x86_64" ;; @@ -178,42 +229,6 @@ lxc_create_rootfs() { # Create Hexo directory ensure_dir "$rootfs/opt/hexojs" - # Create startup script - cat > "$rootfs/opt/start-hexo.sh" << 'STARTUP' -#!/bin/sh -set -e - -# Install Node.js and Hexo on first run -if [ ! -f /opt/.installed ]; then - echo "Installing Node.js and Hexo..." - apk update - apk add --no-cache nodejs npm git openssh-client - - # Install Hexo CLI globally - npm install -g hexo-cli - - touch /opt/.installed -fi - -# Start Hexo server if site exists -cd /opt/hexojs -if [ -d "site" ] && [ -f "site/package.json" ]; then - cd site - - # Install dependencies if needed - [ -d "node_modules" ] || npm install - - # Run server - echo "Starting Hexo server on port ${HEXO_PORT:-4000}..." - exec npx hexo server -p "${HEXO_PORT:-4000}" -else - echo "No site found. Create one with: hexoctl site create default" - # Keep container running - exec sleep infinity -fi -STARTUP - chmod +x "$rootfs/opt/start-hexo.sh" - log_info "Rootfs created successfully" return 0 } @@ -234,7 +249,7 @@ lxc_create_config() { esac cat > "$LXC_CONFIG" << EOF -# Hexo CMS LXC Configuration +# Hexo CMS LXC Configuration (Multi-Instance) lxc.uts.name = $LXC_NAME lxc.rootfs.path = dir:$LXC_ROOTFS lxc.arch = $(uname -m) @@ -247,7 +262,6 @@ lxc.mount.auto = proc:mixed sys:ro cgroup:mixed lxc.mount.entry = $data_path opt/hexojs none bind,create=dir 0 0 # Environment -lxc.environment = HEXO_PORT=$http_port lxc.environment = NODE_ENV=production # Security @@ -256,13 +270,127 @@ 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-hexo.sh +# Init command - multi-instance manager +lxc.init.cmd = /opt/start-hexo-multi.sh EOF log_info "LXC config created" } +# Create multi-instance startup script +create_startup_script() { + load_config + + local start_script="$LXC_ROOTFS/opt/start-hexo-multi.sh" + cat > "$start_script" << 'STARTEOF' +#!/bin/sh +export PATH=/usr/local/bin:/usr/bin:/bin:$PATH +export HOME=/root +export NODE_ENV=production + +HEXO_BASE="/opt/hexojs" +INSTANCES_DIR="$HEXO_BASE/instances" +PIDS_DIR="/var/run/hexo" +LOG_DIR="/var/log/hexo" + +mkdir -p "$PIDS_DIR" "$LOG_DIR" + +# Install dependencies on first run +if [ ! -f /opt/.installed ]; then + echo "Installing Node.js and Hexo..." + apk update + apk add --no-cache nodejs npm git openssh-client + npm install -g hexo-cli + touch /opt/.installed +fi + +# Function to start a single instance +start_instance() { + local name="$1" + local port="$2" + local site_dir="$INSTANCES_DIR/$name/site" + + [ -d "$site_dir" ] || return 1 + [ -f "$site_dir/package.json" ] || return 1 + + echo "Starting instance '$name' on port $port..." + + cd "$site_dir" + [ -d "node_modules" ] || npm install + + # Start hexo server in background + nohup npx hexo server -p "$port" -i 0.0.0.0 > "$LOG_DIR/$name.log" 2>&1 & + echo $! > "$PIDS_DIR/$name.pid" + + echo "Instance '$name' started (PID: $!)" +} + +# Function to stop an instance +stop_instance() { + local name="$1" + local pidfile="$PIDS_DIR/$name.pid" + + if [ -f "$pidfile" ]; then + local pid=$(cat "$pidfile") + if kill -0 "$pid" 2>/dev/null; then + kill "$pid" + echo "Stopped instance '$name' (PID: $pid)" + fi + rm -f "$pidfile" + fi +} + +# Read instances config from file +INSTANCES_CONF="$HEXO_BASE/instances.conf" + +# Main loop - keep container running +if [ -f "$INSTANCES_CONF" ]; then + echo "Loading instances from config..." + while IFS=: read -r name port; do + [ -n "$name" ] && [ -n "$port" ] && start_instance "$name" "$port" + done < "$INSTANCES_CONF" +fi + +# Legacy: check for old-style single site +if [ -d "$HEXO_BASE/site" ] && [ ! -L "$HEXO_BASE/site" ]; then + echo "Starting legacy site on port ${HEXO_PORT:-4000}..." + cd "$HEXO_BASE/site" + [ -d "node_modules" ] || npm install + nohup npx hexo server -p "${HEXO_PORT:-4000}" -i 0.0.0.0 > "$LOG_DIR/default.log" 2>&1 & + echo $! > "$PIDS_DIR/default.pid" +fi + +# Keep container running +echo "Hexo multi-instance manager running. 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 + +# Wait forever +exec tail -f /dev/null +STARTEOF + chmod +x "$start_script" +} + +# Generate instances.conf for container +generate_instances_conf() { + load_config + + local conf_file="$data_path/instances.conf" + > "$conf_file" + + for instance in $(get_enabled_instances); do + load_instance_config "$instance" || continue + [ "$instance_enabled" = "1" ] || continue + [ -d "$instance_site" ] || continue + echo "${instance}:${instance_port}" >> "$conf_file" + done + + log_debug "Generated instances.conf with $(wc -l < "$conf_file") instances" +} + # Container control lxc_running() { lxc-info -n "$LXC_NAME" -s 2>/dev/null | grep -q "RUNNING" @@ -289,26 +417,12 @@ lxc_run() { return 1 fi - # Regenerate config in case settings changed + # Regenerate config lxc_create_config + create_startup_script + generate_instances_conf - # Ensure start script exists in container - local start_script="$LXC_ROOTFS/opt/start-hexo.sh" - cat > "$start_script" << 'STARTEOF' -#!/bin/sh -export PATH=/usr/local/bin:/usr/bin:/bin:$PATH -export HOME=/root -export NODE_ENV=production -HEXO_PORT="${HEXO_PORT:-4000}" -SITE_DIR="/opt/hexojs/site" -cd "$SITE_DIR" 2>/dev/null || exec tail -f /dev/null -[ -d "node_modules" ] || npm install -[ -d "$SITE_DIR/public" ] || hexo generate -exec hexo server -p "$HEXO_PORT" -i 0.0.0.0 -STARTEOF - chmod +x "$start_script" - - log_info "Starting Hexo container on port $http_port..." + log_info "Starting Hexo container..." exec lxc-start -n "$LXC_NAME" -F -f "$LXC_CONFIG" } @@ -320,6 +434,202 @@ lxc_exec() { lxc-attach -n "$LXC_NAME" -- env PATH=/usr/local/bin:/usr/bin:/bin "$@" } +# Instance management commands +cmd_instance_list() { + load_config + + echo "Hexo Instances:" + echo "---------------" + + local found=0 + for section in $(uci show hexojs 2>/dev/null | grep '=instance$' | cut -d'.' -f2 | cut -d'=' -f1); do + found=1 + load_instance_config "$section" + local status="disabled" + [ "$instance_enabled" = "1" ] && status="enabled" + + local site_status="no site" + [ -d "$instance_site" ] && site_status="site ready" + + local running="" + if lxc_running && [ -f "$LXC_ROOTFS/var/run/hexo/${section}.pid" ]; then + running=" [RUNNING]" + fi + + printf " %-15s port:%-5s %s (%s)%s\n" "$section" "$instance_port" "[$status]" "$site_status" "$running" + done + + # Check for legacy default + if [ "$found" = "0" ] && [ -d "$data_path/site" ]; then + echo " default port:$http_port [legacy] (site ready)" + fi + + [ "$found" = "0" ] && [ ! -d "$data_path/site" ] && echo " (no instances)" +} + +cmd_instance_create() { + require_root + load_config + + local name="$1" + [ -z "$name" ] && { log_error "Instance name required"; return 1; } + + # Validate name + echo "$name" | grep -qE '^[a-z][a-z0-9_]*$' || { + log_error "Invalid instance name. Use lowercase letters, numbers, underscore. Start with letter." + return 1 + } + + # Check if exists + local existing=$(uci_get "$name") + [ -n "$existing" ] && { log_error "Instance '$name' already exists"; return 1; } + + # Find next available port + local port=4000 + while uci show hexojs 2>/dev/null | grep -q "port='$port'"; do + port=$((port + 1)) + done + + # Create UCI config + uci set hexojs.${name}=instance + uci set hexojs.${name}.enabled='1' + uci set hexojs.${name}.port="$port" + uci set hexojs.${name}.title="$name Blog" + uci set hexojs.${name}.theme='cybermind' + uci commit hexojs + + # Create directory + ensure_dir "$data_path/instances/$name" + + log_info "Instance '$name' created on port $port" + log_info "Next: hexoctl site create $name" +} + +cmd_instance_delete() { + require_root + load_config + + local name="$1" + [ -z "$name" ] && { log_error "Instance name required"; return 1; } + + # Stop instance first + cmd_instance_stop "$name" 2>/dev/null + + # Remove UCI config + uci delete hexojs.${name} 2>/dev/null + uci commit hexojs + + # Optionally remove data (ask user) + local instance_dir="$data_path/instances/$name" + if [ -d "$instance_dir" ]; then + log_warn "Data directory exists: $instance_dir" + log_info "Remove manually if needed: rm -rf $instance_dir" + fi + + log_info "Instance '$name' deleted" +} + +cmd_instance_start() { + require_root + load_config + + local name="$1" + [ -z "$name" ] && { log_error "Instance name required"; return 1; } + + load_instance_config "$name" || { log_error "Instance '$name' not found"; return 1; } + + if ! lxc_running; then + log_error "Container not running. Start with: /etc/init.d/hexojs start" + return 1 + fi + + if [ ! -d "$instance_site" ]; then + log_error "No site for instance '$name'. Create with: hexoctl site create $name" + return 1 + fi + + log_info "Starting instance '$name' on port $instance_port..." + + lxc_exec sh -c " + cd /opt/hexojs/instances/$name/site || exit 1 + [ -d node_modules ] || npm install + + # Kill existing if running + [ -f /var/run/hexo/$name.pid ] && kill \$(cat /var/run/hexo/$name.pid) 2>/dev/null + + mkdir -p /var/run/hexo /var/log/hexo + nohup npx hexo server -p $instance_port -i 0.0.0.0 > /var/log/hexo/$name.log 2>&1 & + echo \$! > /var/run/hexo/$name.pid + echo \"Started on port $instance_port (PID: \$!)\" + " + + # Update instances.conf + generate_instances_conf +} + +cmd_instance_stop() { + require_root + load_config + + local name="$1" + [ -z "$name" ] && { log_error "Instance name required"; return 1; } + + if ! lxc_running; then + return 0 + fi + + log_info "Stopping instance '$name'..." + + lxc_exec sh -c " + if [ -f /var/run/hexo/$name.pid ]; then + kill \$(cat /var/run/hexo/$name.pid) 2>/dev/null + rm -f /var/run/hexo/$name.pid + echo 'Stopped' + else + echo 'Not running' + fi + " +} + +cmd_instance_status() { + load_config + + local name="$1" + [ -z "$name" ] && { log_error "Instance name required"; return 1; } + + load_instance_config "$name" || { log_error "Instance '$name' not found"; return 1; } + + local running="false" + local pid="" + + if lxc_running; then + pid=$(lxc_exec cat /var/run/hexo/$name.pid 2>/dev/null) + if [ -n "$pid" ] && lxc_exec kill -0 "$pid" 2>/dev/null; then + running="true" + fi + fi + + local site_exists="false" + [ -d "$instance_site" ] && site_exists="true" + + cat << EOF +Instance: $name +-------------- +Enabled: $([ "$instance_enabled" = "1" ] && echo "yes" || echo "no") +Running: $([ "$running" = "true" ] && echo "yes (PID: $pid)" || echo "no") +Port: $instance_port +Title: $instance_title +Theme: $instance_theme +Site: $([ "$site_exists" = "true" ] && echo "ready" || echo "not created") +Path: $instance_path + +EOF + + if [ "$running" = "true" ]; then + echo "URL: http://$(uci -q get network.lan.ipaddr || echo 'localhost'):$instance_port" + fi +} + # Commands cmd_install() { require_root @@ -329,12 +639,12 @@ cmd_install() { lxc_check_prereqs || exit 1 - # Create container if ! lxc_exists; then lxc_create_rootfs || exit 1 fi lxc_create_config || exit 1 + create_startup_script # Copy theme if [ -d "$SHARE_PATH/themes/cybermind" ]; then @@ -352,11 +662,9 @@ cmd_install() { log_info "Installation complete!" log_info "" log_info "Next steps:" - log_info " 1. Create a site: hexoctl site create default" - log_info " 2. Enable service: uci set hexojs.main.enabled=1 && uci commit hexojs" + log_info " 1. Create instance: hexoctl instance create myblog" + log_info " 2. Create site: hexoctl site create myblog" log_info " 3. Start service: /etc/init.d/hexojs start" - log_info "" - log_info "Web preview: http://:$http_port" } cmd_uninstall() { @@ -364,20 +672,16 @@ cmd_uninstall() { log_info "Uninstalling Hexo CMS..." - # Stop service /etc/init.d/hexojs stop 2>/dev/null || true /etc/init.d/hexojs disable 2>/dev/null || true lxc_stop - # Remove container (keep data) 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 "Hexo CMS uninstalled" log_info "Data preserved in: $(uci_get main.data_path)" } @@ -393,7 +697,16 @@ cmd_update() { return 1 fi - lxc_exec sh -c 'npm update -g hexo-cli && cd /opt/hexojs/site && npm update' + lxc_exec sh -c 'npm update -g hexo-cli' + + # Update each instance + for instance in $(get_enabled_instances); do + load_instance_config "$instance" || continue + if [ -d "$instance_site" ]; then + log_info "Updating instance '$instance'..." + lxc_exec sh -c "cd /opt/hexojs/instances/$instance/site && npm update" + fi + done log_info "Update complete!" } @@ -403,233 +716,140 @@ cmd_status() { local enabled="$(uci_get main.enabled)" local running="false" - local site_exists="false" - local post_count=0 - local draft_count=0 - - if lxc_running; then - running="true" - fi - - if [ -d "$data_path/site/source/_posts" ]; then - site_exists="true" - post_count=$(ls -1 "$data_path/site/source/_posts/"*.md 2>/dev/null | wc -l) - draft_count=$(ls -1 "$data_path/site/source/_drafts/"*.md 2>/dev/null | wc -l) - fi - - cat << EOF -{ - "enabled": $([ "$enabled" = "1" ] && echo "true" || echo "false"), - "running": $running, - "site_exists": $site_exists, - "active_site": "$active_site", - "http_port": $http_port, - "data_path": "$data_path", - "post_count": $post_count, - "draft_count": $draft_count, - "memory_limit": "$memory_limit", - "container": "$LXC_NAME", - "site": { - "title": "$site_title", - "author": "$site_author", - "theme": "$site_theme", - "language": "$site_language" - } -} -EOF -} - -cmd_status_text() { - load_config - - local enabled="$(uci_get main.enabled)" - local running="false" - - if lxc_running; then - running="true" - fi + lxc_running && running="true" cat << EOF Hexo CMS 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 Site: $active_site -Title: $site_title -Author: $site_author -Theme: $site_theme - Container: $LXC_NAME -Rootfs: $LXC_ROOTFS -Config: $LXC_CONFIG +Instances: EOF - if [ "$running" = "true" ]; then - echo "Preview: http://$(uci -q get network.lan.ipaddr || echo "localhost"):$http_port" - fi + for instance in $(get_enabled_instances); do + load_instance_config "$instance" || continue + local status="stopped" + if [ "$running" = "true" ]; then + local pid=$(lxc_exec cat /var/run/hexo/$instance.pid 2>/dev/null) + [ -n "$pid" ] && status="running:$instance_port" + fi + printf " %-15s %s\n" "$instance" "[$status]" + done } -# Site management +# Site management (instance-aware) cmd_site_create() { require_root load_config - local name="${1:-default}" - local site_dir="$data_path/site" + local instance="${1:-default}" + load_instance_config "$instance" || { + # Auto-create instance if it doesn't exist + log_info "Creating instance '$instance'..." + cmd_instance_create "$instance" + load_instance_config "$instance" + } - log_info "Creating Hexo site: $name" + log_info "Creating Hexo site for instance: $instance" - if [ -d "$site_dir" ]; then - log_error "Site already exists at $site_dir" - log_info "Delete it first with: hexoctl site delete $name" + if [ -d "$instance_site" ]; then + log_error "Site already exists at $instance_site" return 1 fi + ensure_dir "$instance_path" + # Start container if not running local was_stopped=0 if ! lxc_running; then was_stopped=1 lxc_create_config + create_startup_script lxc-start -n "$LXC_NAME" -d -f "$LXC_CONFIG" sleep 5 fi # Create site in container - lxc_exec sh -c "cd /opt/hexojs && hexo init site" || { + lxc_exec sh -c "cd /opt/hexojs/instances/$instance && hexo init site" || { log_error "Failed to initialize site" return 1 } # Install dependencies - lxc_exec sh -c "cd /opt/hexojs/site && npm install" || { + lxc_exec sh -c "cd /opt/hexojs/instances/$instance/site && npm install" || { log_error "Failed to install dependencies" return 1 } # Install deploy plugin - lxc_exec sh -c "cd /opt/hexojs/site && npm install hexo-deployer-git --save" || true + lxc_exec sh -c "cd /opt/hexojs/instances/$instance/site && npm install hexo-deployer-git --save" || true # Install theme if [ -d "$data_path/themes/cybermind" ]; then log_info "Installing CyberMind theme..." - cp -r "$data_path/themes/cybermind" "$site_dir/themes/" - - # Update _config.yml to use cybermind theme - if [ -f "$site_dir/_config.yml" ]; then - sed -i 's/^theme:.*/theme: cybermind/' "$site_dir/_config.yml" - fi + cp -r "$data_path/themes/cybermind" "$instance_site/themes/" + sed -i 's/^theme:.*/theme: cybermind/' "$instance_site/_config.yml" fi # Copy scaffolds if [ -d "$data_path/scaffolds" ]; then - cp -r "$data_path/scaffolds/"* "$site_dir/scaffolds/" 2>/dev/null || true + cp -r "$data_path/scaffolds/"* "$instance_site/scaffolds/" 2>/dev/null || true fi - # Update site config - cmd_update_site_config + # Update config + if [ -f "$instance_site/_config.yml" ]; then + sed -i "s/^title:.*/title: $instance_title/" "$instance_site/_config.yml" + sed -i "s|^url:.*|url: http://localhost:$instance_port|" "$instance_site/_config.yml" + fi - # Create apps directory for CyberMind theme - ensure_dir "$site_dir/source/apps" - ensure_dir "$site_dir/source/portfolio" - ensure_dir "$site_dir/source/services" - - # Stop if we started it if [ "$was_stopped" = "1" ]; then lxc_stop fi - log_info "Site created successfully!" - log_info "" - log_info "Start preview server with: /etc/init.d/hexojs start" -} - -cmd_update_site_config() { - load_config - local config_file="$data_path/site/_config.yml" - - if [ ! -f "$config_file" ]; then - return 1 - fi - - # Update basic settings - sed -i "s/^title:.*/title: $site_title/" "$config_file" - sed -i "s/^subtitle:.*/subtitle: $site_subtitle/" "$config_file" - sed -i "s/^author:.*/author: $site_author/" "$config_file" - sed -i "s/^language:.*/language: $site_language/" "$config_file" - sed -i "s|^url:.*|url: $site_url|" "$config_file" - sed -i "s|^root:.*|root: $site_root|" "$config_file" - sed -i "s/^per_page:.*/per_page: $site_per_page/" "$config_file" - sed -i "s/^theme:.*/theme: $site_theme/" "$config_file" - - # Add deploy config if repo is set - if [ -n "$deploy_repo" ]; then - # Remove existing deploy section - sed -i '/^deploy:/,/^[^ ]/{ /^deploy:/d; /^ /d; }' "$config_file" - - # Add new deploy section - cat >> "$config_file" << EOF - -deploy: - type: $deploy_type - repo: $deploy_repo - branch: $deploy_branch -EOF - fi -} - -cmd_site_list() { - load_config - - echo "Sites:" - if [ -d "$data_path/site" ]; then - local active="*" - echo " $active default ($([ -f "$data_path/site/_config.yml" ] && echo "active" || echo "empty"))" - else - echo " (no sites)" - fi + log_info "Site created for instance '$instance'!" + log_info "Start with: hexoctl instance start $instance" } cmd_site_delete() { require_root load_config - local name="${1:-default}" - local site_dir="$data_path/site" + local instance="${1:-default}" + load_instance_config "$instance" || { log_error "Instance not found"; return 1; } - log_info "Deleting site: $name" - - if [ ! -d "$site_dir" ]; then - log_error "Site does not exist" + if [ ! -d "$instance_site" ]; then + log_error "No site exists for instance '$instance'" return 1 fi - rm -rf "$site_dir" - log_info "Site deleted" + cmd_instance_stop "$instance" 2>/dev/null + + rm -rf "$instance_site" + log_info "Site deleted for instance '$instance'" } -# Content commands +# Content commands (instance-aware) cmd_new_post() { require_root load_config local title="$1" - if [ -z "$title" ]; then - log_error "Title required" - echo "Usage: hexoctl new post \"My Title\"" - return 1 - fi + local instance="${2:-default}" + + [ -z "$title" ] && { log_error "Title required"; return 1; } + + load_instance_config "$instance" || { log_error "Instance not found"; return 1; } if ! lxc_running; then log_error "Container not running" return 1 fi - lxc_exec sh -c "cd /opt/hexojs/site && hexo new post \"$title\"" + lxc_exec sh -c "cd /opt/hexojs/instances/$instance/site && hexo new post \"$title\"" } cmd_new_page() { @@ -637,17 +857,18 @@ cmd_new_page() { load_config local title="$1" - if [ -z "$title" ]; then - log_error "Title required" - return 1 - fi + local instance="${2:-default}" + + [ -z "$title" ] && { log_error "Title required"; return 1; } + + load_instance_config "$instance" || { log_error "Instance not found"; return 1; } if ! lxc_running; then log_error "Container not running" return 1 fi - lxc_exec sh -c "cd /opt/hexojs/site && hexo new page \"$title\"" + lxc_exec sh -c "cd /opt/hexojs/instances/$instance/site && hexo new page \"$title\"" } cmd_new_draft() { @@ -655,72 +876,40 @@ cmd_new_draft() { load_config local title="$1" - if [ -z "$title" ]; then - log_error "Title required" - return 1 - fi + local instance="${2:-default}" + + [ -z "$title" ] && { log_error "Title required"; return 1; } + + load_instance_config "$instance" || { log_error "Instance not found"; return 1; } if ! lxc_running; then log_error "Container not running" return 1 fi - lxc_exec sh -c "cd /opt/hexojs/site && hexo new draft \"$title\"" -} - -cmd_publish_draft() { - require_root - load_config - - local slug="$1" - if [ -z "$slug" ]; then - log_error "Slug required" - echo "Usage: hexoctl publish draft " - return 1 - fi - - if ! lxc_running; then - log_error "Container not running" - return 1 - fi - - lxc_exec sh -c "cd /opt/hexojs/site && hexo publish \"$slug\"" + lxc_exec sh -c "cd /opt/hexojs/instances/$instance/site && hexo new draft \"$title\"" } cmd_list_posts() { load_config - local posts_dir="$data_path/site/source/_posts" - if [ ! -d "$posts_dir" ]; then - echo "[]" - return - fi + local instance="${1:-default}" + load_instance_config "$instance" || { log_error "Instance not found"; return 1; } + + local posts_dir="$instance_site/source/_posts" + [ -d "$posts_dir" ] || { echo "[]"; return; } echo "[" local first=1 for f in "$posts_dir"/*.md; do [ -f "$f" ] || continue - local filename=$(basename "$f") local slug="${filename%.md}" local title=$(grep -m1 "^title:" "$f" | sed 's/^title:[[:space:]]*//' | tr -d '"' | tr -d "'") - local date=$(grep -m1 "^date:" "$f" | sed 's/^date:[[:space:]]*//') - local categories=$(grep -m1 "^categories:" "$f" | sed 's/^categories:[[:space:]]*//' | tr -d '[]') - local tags=$(grep -m1 "^tags:" "$f" | sed 's/^tags:[[:space:]]*//' | tr -d '[]') [ "$first" = "1" ] || echo "," first=0 - - cat << EOF - { - "slug": "$slug", - "title": "$title", - "date": "$date", - "categories": "$categories", - "tags": "$tags", - "path": "$f" - } -EOF + echo " {\"slug\": \"$slug\", \"title\": \"$title\"}" done echo "]" } @@ -728,154 +917,121 @@ EOF cmd_list_drafts() { load_config - local drafts_dir="$data_path/site/source/_drafts" - if [ ! -d "$drafts_dir" ]; then - echo "[]" - return - fi + local instance="${1:-default}" + load_instance_config "$instance" || { log_error "Instance not found"; return 1; } + + local drafts_dir="$instance_site/source/_drafts" + [ -d "$drafts_dir" ] || { echo "[]"; return; } echo "[" local first=1 for f in "$drafts_dir"/*.md; do [ -f "$f" ] || continue - local filename=$(basename "$f") local slug="${filename%.md}" local title=$(grep -m1 "^title:" "$f" | sed 's/^title:[[:space:]]*//' | tr -d '"' | tr -d "'") [ "$first" = "1" ] || echo "," first=0 - - echo " {\"slug\": \"$slug\", \"title\": \"$title\", \"path\": \"$f\"}" + echo " {\"slug\": \"$slug\", \"title\": \"$title\"}" done echo "]" } -# Build commands +# Build commands (instance-aware) cmd_serve() { require_root load_config + local instance="${1:-default}" + load_instance_config "$instance" || { log_error "Instance not found"; return 1; } + if ! lxc_running; then - log_error "Container not running. Start with: /etc/init.d/hexojs start" + log_error "Container not running" return 1 fi - log_info "Starting preview server on port $http_port..." - lxc_exec sh -c "cd /opt/hexojs/site && hexo server -p $http_port -i 0.0.0.0" + log_info "Starting preview server for '$instance' on port $instance_port..." + lxc_exec sh -c "cd /opt/hexojs/instances/$instance/site && hexo server -p $instance_port -i 0.0.0.0" } cmd_build() { require_root load_config + local instance="${1:-default}" + load_instance_config "$instance" || { log_error "Instance not found"; return 1; } + if ! lxc_running; then log_error "Container not running" return 1 fi - log_info "Generating static files..." - lxc_exec sh -c "cd /opt/hexojs/site && hexo generate" - log_info "Build complete! Files in: $data_path/site/public/" + log_info "Generating static files for '$instance'..." + lxc_exec sh -c "cd /opt/hexojs/instances/$instance/site && hexo generate" + log_info "Build complete!" } cmd_clean() { require_root load_config - if ! lxc_running; then - log_error "Container not running" - return 1 - fi - - log_info "Cleaning generated files..." - lxc_exec sh -c "cd /opt/hexojs/site && hexo clean" - log_info "Clean complete!" -} - -cmd_deploy() { - require_root - load_config - - if [ -z "$deploy_repo" ]; then - log_error "Deploy repository not configured" - log_info "Set with: uci set hexojs.deploy.repo='git@github.com:user/repo.git'" - return 1 - fi + local instance="${1:-default}" + load_instance_config "$instance" || { log_error "Instance not found"; return 1; } if ! lxc_running; then log_error "Container not running" return 1 fi - log_info "Deploying to $deploy_repo..." - lxc_exec sh -c "cd /opt/hexojs/site && hexo deploy" - log_info "Deploy complete!" + log_info "Cleaning generated files for '$instance'..." + lxc_exec sh -c "cd /opt/hexojs/instances/$instance/site && hexo clean" } cmd_publish() { require_root load_config - local public_dir="$data_path/site/public" - local portal_path="/www" - local config_file="$data_path/site/_config.yml" + local instance="${1:-default}" + load_instance_config "$instance" || { log_error "Instance not found"; return 1; } - # Allow custom portal path from config - local custom_path=$(uci_get portal.path) - [ -n "$custom_path" ] && portal_path="$custom_path" - - # Calculate web root from portal path (strip /www prefix) - local web_root="${portal_path#/www}" - [ -z "$web_root" ] && web_root="/" - # Ensure trailing slash - [ "${web_root%/}" = "$web_root" ] && web_root="$web_root/" + local public_dir="$instance_site/public" + local portal_path="$(uci_get ${instance}.publish_path)" || portal_path="/www/${instance}" if ! lxc_running; then log_error "Container not running" return 1 fi - if [ ! -f "$config_file" ]; then - log_error "No Hexo config found at $config_file" - return 1 - fi + # Calculate web root + local web_root="${portal_path#/www}" + [ -z "$web_root" ] && web_root="/" + [ "${web_root%/}" = "$web_root" ] && web_root="$web_root/" - log_info "Setting Hexo root to: $web_root" + log_info "Setting root to: $web_root" - # Update root in _config.yml (use sed to replace existing root line) - if grep -q "^root:" "$config_file"; then - sed -i "s|^root:.*|root: $web_root|" "$config_file" - else - # Add root config if not present - echo "root: $web_root" >> "$config_file" - fi + # Update config + sed -i "s|^root:.*|root: $web_root|" "$instance_site/_config.yml" - log_info "Regenerating static files for $web_root..." - lxc_exec sh -c "cd /opt/hexojs/site && hexo clean && hexo generate" + log_info "Regenerating..." + lxc_exec sh -c "cd /opt/hexojs/instances/$instance/site && hexo clean && hexo generate" - if [ ! -d "$public_dir" ]; then - log_error "Build failed - no public directory" - return 1 - fi + [ -d "$public_dir" ] || { log_error "Build failed"; return 1; } log_info "Publishing to $portal_path..." - - # Create portal directory ensure_dir "$portal_path" - - # Sync files rsync -av --delete "$public_dir/" "$portal_path/" - log_info "Published $(find "$portal_path" -type f | wc -l) files to $portal_path" - log_info "Access at: http://$(uci -q get network.lan.ipaddr || echo 'router')$web_root" + log_info "Published $(find "$portal_path" -type f | wc -l) files" } cmd_logs() { load_config + local instance="${1:-default}" + if lxc_running; then - lxc_exec sh -c "cat /var/log/hexo.log 2>/dev/null || echo 'No logs yet'" + lxc_exec cat /var/log/hexo/$instance.log 2>/dev/null || echo "No logs for '$instance'" else echo "Container not running" fi @@ -910,14 +1066,15 @@ cmd_service_stop() { lxc_stop } -# Gitea integration commands +# Gitea integration (instance-aware) cmd_gitea_setup() { require_root load_config + local instance="${1:-default}" + if [ -z "$gitea_token" ]; then log_error "Gitea token not configured" - log_info "Set with: uci set hexojs.gitea.token='your-token' && uci commit hexojs" return 1 fi @@ -926,25 +1083,22 @@ cmd_gitea_setup() { return 1 fi - log_info "Configuring git credentials for Gitea..." + log_info "Configuring git credentials..." - # Extract host from URL local gitea_host=$(echo "$gitea_url" | sed 's|^https\?://||' | sed 's|/.*||') - # Configure git credential helper in container lxc_exec sh -c " + export PATH=/usr/local/bin:\$PATH git config --global user.name '$gitea_user' git config --global user.email '${gitea_user}@localhost' git config --global credential.helper store - # Store credentials - mkdir -p ~/.git-credentials + 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 - git config --global credential.helper 'store --file ~/.git-credentials' " log_info "Git credentials configured" @@ -954,14 +1108,11 @@ cmd_gitea_clone() { require_root load_config + local instance="${1:-default}" + load_instance_config "$instance" || { log_error "Instance not found"; return 1; } + if [ "$gitea_enabled" != "1" ]; then log_error "Gitea integration not enabled" - log_info "Enable with: uci set hexojs.gitea.enabled=1 && uci commit hexojs" - return 1 - fi - - if [ -z "$gitea_token" ]; then - log_error "Gitea token not configured" return 1 fi @@ -970,17 +1121,14 @@ cmd_gitea_clone() { return 1 fi - local content_path="$data_path/content" - local site_source="$data_path/site/source" + local content_path="$instance_path/content" - # Clone content repo if [ -d "$content_path/.git" ]; then - log_info "Content repo already cloned, pulling latest..." + log_info "Content repo already cloned, pulling..." cd "$content_path" && git pull else - log_info "Cloning content repo from Gitea..." + log_info "Cloning content repo..." - # Build clone URL with token local gitea_host=$(echo "$gitea_url" | sed 's|^https\?://||' | sed 's|/.*||') local clone_url="http://${gitea_user}:${gitea_token}@${gitea_host}/${gitea_user}/${gitea_content_repo}.git" @@ -988,114 +1136,39 @@ cmd_gitea_clone() { rm -rf "$content_path" git clone -b "$gitea_content_branch" "$clone_url" "$content_path" || { - log_error "Failed to clone content repo" + log_error "Failed to clone" return 1 } fi - # Sync to hexo source - cmd_gitea_sync_files + # Check if content is a full hexo site + if [ -f "$content_path/package.json" ] && [ -d "$content_path/source" ]; then + log_info "Content is a complete Hexo site, linking..." + lxc_exec sh -c " + rm -rf /opt/hexojs/instances/$instance/site + ln -sf /opt/hexojs/instances/$instance/content /opt/hexojs/instances/$instance/site + cd /opt/hexojs/instances/$instance/site && npm install + " + fi - log_info "Content cloned successfully" + log_info "Content cloned for instance '$instance'" } cmd_gitea_sync() { require_root load_config - local content_path="$data_path/content" + local instance="${1:-default}" + load_instance_config "$instance" || { log_error "Instance not found"; return 1; } - if [ ! -d "$content_path/.git" ]; then - log_error "Content repo not cloned. Run: hexoctl gitea clone" - return 1 - fi + local content_path="$instance_path/content" - log_info "Pulling latest content from Gitea..." + [ -d "$content_path/.git" ] || { log_error "Content not cloned"; return 1; } - cd "$content_path" && git pull || { - log_error "Git pull failed" - return 1 - } + log_info "Pulling latest content..." + cd "$content_path" && git pull - cmd_gitea_sync_files - - log_info "Content synced" -} - -cmd_gitea_sync_files() { - load_config - - local content_path="$data_path/content" - local site_source="$data_path/site/source" - - if [ ! -d "$site_source" ]; then - log_error "Hexo site not created. Run: hexoctl site create default" - return 1 - fi - - log_info "Syncing content files to Hexo source..." - - # Sync _posts - if [ -d "$content_path/_posts" ]; then - ensure_dir "$site_source/_posts" - cp -r "$content_path/_posts/"* "$site_source/_posts/" 2>/dev/null || true - log_info "Synced _posts" - fi - - # Sync _drafts - if [ -d "$content_path/_drafts" ]; then - ensure_dir "$site_source/_drafts" - cp -r "$content_path/_drafts/"* "$site_source/_drafts/" 2>/dev/null || true - log_info "Synced _drafts" - fi - - # Sync images - if [ -d "$content_path/images" ]; then - ensure_dir "$site_source/images" - cp -r "$content_path/images/"* "$site_source/images/" 2>/dev/null || true - log_info "Synced images" - fi - - # Sync pages (about, etc) - for page in about portfolio services; do - if [ -d "$content_path/$page" ]; then - ensure_dir "$site_source/$page" - cp -r "$content_path/$page/"* "$site_source/$page/" 2>/dev/null || true - fi - done -} - -cmd_gitea_status() { - load_config - - local content_path="$data_path/content" - local has_repo="false" - local last_commit="" - local remote_url="" - local branch="" - - if [ -d "$content_path/.git" ]; then - has_repo="true" - cd "$content_path" - last_commit=$(git log -1 --format="%h %s" 2>/dev/null || echo "unknown") - remote_url=$(git remote get-url origin 2>/dev/null | sed "s|${gitea_token}|***|g" || echo "none") - branch=$(git branch --show-current 2>/dev/null || echo "unknown") - fi - - cat << EOF -{ - "gitea_enabled": $([ "$gitea_enabled" = "1" ] && echo "true" || echo "false"), - "gitea_url": "$gitea_url", - "gitea_user": "$gitea_user", - "content_repo": "$gitea_content_repo", - "content_branch": "$gitea_content_branch", - "has_local_repo": $has_repo, - "local_branch": "$branch", - "last_commit": "$last_commit", - "remote_url": "$remote_url", - "auto_sync": $([ "$gitea_auto_sync" = "1" ] && echo "true" || echo "false") -} -EOF + log_info "Content synced for '$instance'" } # Main @@ -1103,22 +1176,28 @@ case "${1:-}" in install) shift; cmd_install "$@" ;; uninstall) shift; cmd_uninstall "$@" ;; update) shift; cmd_update "$@" ;; - status) + status) shift; cmd_status "$@" ;; + + instance) shift - if [ "$1" = "--json" ] || [ "$1" = "-j" ]; then - cmd_status - else - cmd_status_text - fi + case "${1:-}" in + list) shift; cmd_instance_list "$@" ;; + create) shift; cmd_instance_create "$@" ;; + delete) shift; cmd_instance_delete "$@" ;; + start) shift; cmd_instance_start "$@" ;; + stop) shift; cmd_instance_stop "$@" ;; + status) shift; cmd_instance_status "$@" ;; + *) echo "Usage: hexoctl instance {list|create|delete|start|stop|status} [name]" ;; + esac ;; site) shift case "${1:-}" in create) shift; cmd_site_create "$@" ;; - list) shift; cmd_site_list "$@" ;; delete) shift; cmd_site_delete "$@" ;; - *) echo "Usage: hexoctl site {create|list|delete} [name]" ;; + list) shift; cmd_instance_list "$@" ;; + *) echo "Usage: hexoctl site {create|delete|list} [instance]" ;; esac ;; @@ -1128,30 +1207,23 @@ case "${1:-}" in post) shift; cmd_new_post "$@" ;; page) shift; cmd_new_page "$@" ;; draft) shift; cmd_new_draft "$@" ;; - *) echo "Usage: hexoctl new {post|page|draft} \"Title\"" ;; + *) echo "Usage: hexoctl new {post|page|draft} \"Title\" [instance]" ;; esac ;; list) shift case "${1:-}" in - posts) cmd_list_posts ;; - drafts) cmd_list_drafts ;; - *) echo "Usage: hexoctl list {posts|drafts}" ;; + posts) shift; cmd_list_posts "$@" ;; + drafts) shift; cmd_list_drafts "$@" ;; + *) echo "Usage: hexoctl list {posts|drafts} [instance]" ;; esac ;; serve) shift; cmd_serve "$@" ;; build|generate) shift; cmd_build "$@" ;; clean) shift; cmd_clean "$@" ;; - deploy) shift; cmd_deploy "$@" ;; - publish) - shift - case "${1:-}" in - draft) shift; cmd_publish_draft "$@" ;; - *) cmd_publish "$@" ;; - esac - ;; + publish) shift; cmd_publish "$@" ;; logs) shift; cmd_logs "$@" ;; shell) shift; cmd_shell "$@" ;; @@ -1166,8 +1238,7 @@ case "${1:-}" in setup) shift; cmd_gitea_setup "$@" ;; clone) shift; cmd_gitea_clone "$@" ;; sync) shift; cmd_gitea_sync "$@" ;; - status) shift; cmd_gitea_status "$@" ;; - *) echo "Usage: hexoctl gitea {setup|clone|sync|status}" ;; + *) echo "Usage: hexoctl gitea {setup|clone|sync} [instance]" ;; esac ;; diff --git a/package/secubox/secubox-app-streamlit/Makefile b/package/secubox/secubox-app-streamlit/Makefile index 21ec9b63..18f29a2b 100644 --- a/package/secubox/secubox-app-streamlit/Makefile +++ b/package/secubox/secubox-app-streamlit/Makefile @@ -8,7 +8,7 @@ include $(TOPDIR)/rules.mk PKG_NAME:=secubox-app-streamlit PKG_VERSION:=1.0.0 -PKG_RELEASE:=4 +PKG_RELEASE:=5 PKG_ARCH:=all PKG_MAINTAINER:=CyberMind Studio @@ -22,22 +22,25 @@ define Package/secubox-app-streamlit PKGARCH:=all SUBMENU:=SecuBox Apps TITLE:=SecuBox Streamlit Platform - DEPENDS:=+uci +libuci +jsonfilter +wget-ssl +tar +lxc +lxc-common + DEPENDS:=+uci +libuci +jsonfilter +wget-ssl +tar +lxc +lxc-common +git endef define Package/secubox-app-streamlit/description Streamlit App Platform - Self-hosted Python data app platform Features: -- Run Streamlit apps in LXC container +- Folder-based app structure (app.py, requirements.txt, .streamlit/) - Multi-instance support (multiple apps on different ports) -- Python 3.12 with Streamlit 1.53.x -- Auto-install requirements.txt dependencies +- Gitea integration for app deployment and updates +- Python 3.12 with Streamlit in LXC container +- Auto-install requirements.txt with hash-based caching - HAProxy publish wizard for vhost routing - Web dashboard integration -- Configurable port and memory limits -Runs in LXC container with Alpine Linux. +App folder structure: + /srv/streamlit/apps// + app.py, requirements.txt, .streamlit/ + Configure in /etc/config/streamlit. endef @@ -74,7 +77,9 @@ define Package/secubox-app-streamlit/postinst echo "" echo "Web interface: http://:8501" echo "" - echo "Deploy your apps with: streamlitctl app add /path/to/app.py" + echo "Create apps: streamlitctl app create myapp" + echo "Add instance: streamlitctl instance add myapp 8502" + echo "Gitea clone: streamlitctl gitea clone myapp user/repo" echo "" } exit 0 diff --git a/package/secubox/secubox-app-streamlit/files/usr/sbin/streamlitctl b/package/secubox/secubox-app-streamlit/files/usr/sbin/streamlitctl index 4845740a..80d48b66 100644 --- a/package/secubox/secubox-app-streamlit/files/usr/sbin/streamlitctl +++ b/package/secubox/secubox-app-streamlit/files/usr/sbin/streamlitctl @@ -3,6 +3,10 @@ # 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" @@ -11,11 +15,11 @@ LXC_NAME="streamlit" 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_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] $*"; } @@ -28,6 +32,7 @@ require_root() { } 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"; } @@ -48,6 +53,12 @@ load_config() { 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" @@ -57,49 +68,54 @@ load_config() { # 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 +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 list List deployed apps - app add Deploy new app - app remove Remove app +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 list List running instances - instance add Add instance config - instance remove Remove instance config - instance enable Enable instance - instance disable Disable instance +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 - service-run Start service (used by init) - service-stop Stop service (used by init) +Gitea Integration: + gitea setup Configure git credentials + gitea clone Clone app from Gitea repo + gitea pull Pull latest from Gitea -Configuration: - /etc/config/streamlit +Service Commands: + service-run Start all instances (for init) + service-stop Stop all instances -Data directory: - /srv/streamlit +Utility: + shell Open shell in container + exec Execute command in container -Multi-Instance Mode: - Add instances in /etc/config/streamlit: - config instance 'myapp' - option app 'myapp.py' - option port '8502' - option enabled '1' +App Folder Structure: + /srv/streamlit/apps// + app.py Main Streamlit app (or .py) + requirements.txt Python dependencies + .streamlit/ Streamlit config (optional) + ... Other files -Requirements: - Place requirements.txt in /srv/streamlit/apps/ - - .requirements.txt - - _requirements.txt - - requirements.txt (global) +Examples: + streamlitctl app create myapp + streamlitctl instance add myapp 8502 + streamlitctl gitea clone myapp myuser/myapp-repo EOF } @@ -122,7 +138,6 @@ lxc_create_rootfs() { ensure_dir "$rootfs" - # Use Alpine mini rootfs local alpine_version="3.21" case "$arch" in x86_64) alpine_arch="x86_64" ;; @@ -147,19 +162,35 @@ lxc_create_rootfs() { } 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' + 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 procps + apk add --no-cache python3 py3-pip git procps echo "Installing Streamlit..." pip3 install --break-system-packages streamlit 2>/dev/null || \ @@ -170,9 +201,9 @@ if [ ! -f /opt/.installed ]; then fi # Create default hello app if missing -mkdir -p /srv/apps -if [ ! -f "/srv/apps/hello.py" ]; then - cat > /srv/apps/hello.py << 'HELLO' +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 ⚡") @@ -181,112 +212,168 @@ col1, col2, col3 = st.columns(3) with col1: st.metric("Status", "ONLINE", delta="Active") with col2: - st.metric("Apps", "1", delta="+1") + st.metric("Apps", "Ready", delta="+1") with col3: st.metric("Platform", "SecuBox") -st.info("Deploy your Streamlit apps via LuCI dashboard") +st.info("Deploy your Streamlit apps via LuCI dashboard or CLI") 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 +# 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 start a single Streamlit instance -start_instance() { - local app_file="$1" - local port="$2" - local app_name=$(basename "$app_file" .py) +# Function to install requirements for an app +install_requirements() { + local app_dir="$1" + local app_name=$(basename "$app_dir") + local req_file="$app_dir/requirements.txt" - if [ ! -f "/srv/apps/$app_file" ]; then - echo "App not found: $app_file" + 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..." + 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" + + if [ ! -d "$app_dir" ]; then + echo "App folder not found: $app_dir" return 1 fi - install_requirements "$app_name" + local app_file=$(find_app_file "$app_dir") + if [ -z "$app_file" ]; then + echo "No Python app file found in $app_dir" + return 1 + fi - echo "Starting instance: $app_name on port $port" - cd /srv/apps - streamlit run "$app_file" \ + # Install requirements + install_requirements "$app_dir" + + echo "Starting instance: $app_name on port $port (file: $(basename $app_file))" + + # Change to app directory so relative imports work + cd "$app_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}" & - echo $! > "/tmp/streamlit_${app_name}.pid" + --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: $!)" } -# 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 +# 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 - # Single instance mode (backward compatible) - ACTIVE_APP="${STREAMLIT_APP:-hello.py}" - PORT="${STREAMLIT_PORT:-8501}" - start_instance "$ACTIVE_APP" "$PORT" + # 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 and monitor processes -echo "Streamlit instances started. Monitoring..." +# 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 - # 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 + chmod +x "$start_script" } -# 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 "" +# Generate instances.conf from UCI +generate_instances_conf() { + load_config - 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 -} + 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 + } -build_instances_string() { - _instances_result="" config_load "$CONFIG" - config_foreach _build_instance_entry instance - echo "$_instances_result" + 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 @@ -295,7 +382,6 @@ lxc_create_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)) ;; @@ -304,24 +390,8 @@ lxc_create_config() { *) 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 +# Streamlit Platform LXC Configuration (Multi-Instance) lxc.uts.name = $LXC_NAME lxc.rootfs.path = dir:$LXC_ROOTFS lxc.arch = $(uname -m) @@ -337,19 +407,6 @@ 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 @@ -390,152 +447,519 @@ lxc_run() { return 1 fi - # Regenerate config in case settings changed lxc_create_config + create_startup_script + generate_instances_conf log_info "Starting Streamlit container..." exec lxc-start -n "$LXC_NAME" -F -f "$LXC_CONFIG" } -# App management +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 "{" - echo ' "apps": [' + echo "Streamlit Apps:" + echo "---------------" - 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}') + for app_dir in "$APPS_PATH"/*/; do + [ -d "$app_dir" ] || continue + local name=$(basename "$app_dir") - # Check if this is the active app - local is_active="false" - if [ "$name" = "$active_app" ] || [ "${name}.py" = "$active_app" ]; then - is_active="true" - fi + # 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) - [ $first -eq 0 ] && echo "," - first=0 - printf ' {"name": "%s", "path": "%s", "size": "%s", "modified": "%s", "active": %s}' \ - "$name" "$app" "$size" "$mtime" "$is_active" + # Check for requirements + local has_req="no" + [ -f "$app_dir/requirements.txt" ] && 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 - echo "" - echo " ]," - echo " \"active_app\": \"$active_app\"," - echo " \"apps_path\": \"$APPS_PATH\"" - echo "}" + # 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_add() { - local name="$1" - local path="$2" - - if [ -z "$name" ] || [ -z "$path" ]; then - log_error "Usage: streamlitctl app add " - return 1 - fi - +cmd_app_create() { + require_root load_config - if [ ! -f "$path" ]; then - log_error "Source file not found: $path" - return 1 - fi + local name="$1" + [ -z "$name" ] && { log_error "App name required"; return 1; } - # 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" + # 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}.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"'"}' + log_info "App '$name' created at $app_dir" + 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_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 - +cmd_app_delete() { + require_root load_config - local app_file="$APPS_PATH/${name}.py" - if [ -f "$app_file" ]; then - rm -f "$app_file" + 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 || true + uci -q delete "${CONFIG}.${name}" 2>/dev/null 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"'"}' + log_info "App '$name' deleted" } -cmd_app_run() { - local name="$1" - - if [ -z "$name" ]; then - log_error "Usage: streamlitctl app run " - return 1 - fi - +cmd_app_deploy() { + require_root load_config - local app_file="$APPS_PATH/${name}.py" - if [ ! -f "$app_file" ]; then - log_error "App not found: $name" + 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 - uci_set main.active_app "$name" + # Register in UCI + uci set "${CONFIG}.${name}=app" + uci set "${CONFIG}.${name}.name=$name" + uci set "${CONFIG}.${name}.enabled=1" + uci commit "$CONFIG" - # 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"'"}' + log_info "App '$name' deployed to $app_dir" } -# Commands +# 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" +} + +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" +} + +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 " + cd /srv/apps/$app 2>/dev/null || exit 1 + + # Find main file + APP_FILE='' + 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) + [ -z \"\$APP_FILE\" ] && { echo 'No Python file found'; exit 1; } + + # Install requirements + [ -f requirements.txt ] && pip3 install --break-system-packages -r requirements.txt 2>/dev/null + + # 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" +} + +# Main commands cmd_install() { require_root load_config @@ -544,28 +968,25 @@ cmd_install() { lxc_check_prereqs || exit 1 - # Create container 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" - if [ -f "$DEFAULT_APP" ] && [ ! -f "$APPS_PATH/hello.py" ]; then - cp "$DEFAULT_APP" "$APPS_PATH/hello.py" - log_info "Default hello app installed" - fi + ensure_dir "$APPS_PATH/hello" - # 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" + 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() { @@ -573,13 +994,11 @@ cmd_uninstall() { 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" @@ -596,16 +1015,14 @@ cmd_update() { load_config if ! lxc_exists; then - log_error "Container not installed. Run: streamlitctl install" + log_error "Container not installed" return 1 fi - log_info "Updating Streamlit in container..." + log_info "Updating Streamlit..." - # 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 @@ -618,103 +1035,49 @@ cmd_status() { 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 + lxc_running && running="true" 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 + echo "Apps:" + cmd_app_list | tail -n +3 + + echo "" + echo "Instances:" + cmd_instance_list | tail -n +3 } cmd_logs() { load_config - local lines="${1:-100}" + local app="${1:-}" - 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" + if [ -n "$app" ]; then + local log_file="$data_path/logs/${app}.log" + if [ -f "$log_file" ]; then + tail -100 "$log_file" else - echo "No logs yet" + # Try inside container + lxc_exec cat /var/log/streamlit/${app}.log 2>/dev/null || echo "No logs for '$app'" 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 + # 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 "" - echo "=== $logfile ===" - tail -n 50 "$logfile" - fi - done + done + fi } cmd_shell() { @@ -741,150 +1104,50 @@ cmd_service_stop() { 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 - ;; + status) shift; cmd_status "$@" ;; 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 ;; + 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 "$@" ;; - enable) shift; cmd_instance_enable "$@" ;; - disable) shift; cmd_instance_disable "$@" ;; - *) echo "Usage: streamlitctl instance {list|add|remove|enable|disable}"; exit 1 ;; + 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 "$@" ;; + *) echo "Usage: streamlitctl gitea {setup|clone|pull}"; exit 1 ;; + esac + ;; + service-run) shift; cmd_service_run "$@" ;; service-stop) shift; cmd_service_stop "$@" ;; + *) usage ;; esac