From dd9d1f12367c85c679679d0c8af83efe1145c54f Mon Sep 17 00:00:00 2001 From: CyberMind-FR Date: Wed, 25 Feb 2026 10:46:01 +0100 Subject: [PATCH] feat(streamlit): Gitea auto-push, WAF integration, and rename enhancements - Add auto Gitea push on emancipate and app rename - Route emancipated instances through mitmproxy_inspector (WAF) by default - Add mitmproxy route entries for domains - Enhanced rename_app to actually rename folders/files - Enhanced rename_instance to update HAProxy vhost and mitmproxy routes - Display WAF badge in dashboard for exposed instances Co-Authored-By: Claude Opus 4.5 --- .claude/HISTORY.md | 21 ++ .../resources/view/streamlit/dashboard.js | 12 +- .../root/usr/libexec/rpcd/luci.streamlit | 207 ++++++++++++++++-- 3 files changed, 219 insertions(+), 21 deletions(-) diff --git a/.claude/HISTORY.md b/.claude/HISTORY.md index 5c4696c7..478991b3 100644 --- a/.claude/HISTORY.md +++ b/.claude/HISTORY.md @@ -3571,3 +3571,24 @@ git checkout HEAD -- index.html - `secubox-app-haproxy/files/usr/sbin/haproxyctl`: Added lxc_start_bg, lxc_reload; fixed ACME cert handling - `secubox-app-haproxy/files/usr/sbin/haproxy-sync-certs`: Uses haproxyctl reload instead of init script - **Verified:** 20 consecutive tests all returned HTTP 200 across all sites + +32. **Streamlit Gitea Integration & WAF Enhancements (2026-02-25)** + - **Auto Gitea Push on Emancipate:** + - Added automatic Gitea push when instance is emancipated + - Also pushes on app rename (keeps code in sync with Gitea) + - **WAF (mitmproxy) Integration:** + - Emancipate now routes through `mitmproxy_inspector` backend by default (all traffic WAF-protected) + - Adds mitmproxy route entry for domain → streamlit port + - Restarts mitmproxy to pick up new routes + - Uses `haproxyctl reload` instead of restart for smooth reloads + - **Enhanced Rename Functions:** + - `rename_app()` now actually renames app folder/file (not just display name) + - Updates all instance references when app ID changes + - `rename_instance()` can now change domain, updates HAProxy vhost and mitmproxy routes + - **WAF Status Display:** + - Dashboard shows WAF badge for exposed instances + - `get_exposure_status()` returns `waf_enabled` field + - Blue "WAF" badge displayed next to exposure status + - **Files Modified:** + - `luci-app-streamlit/root/usr/libexec/rpcd/luci.streamlit`: emancipate_instance, rename_app, rename_instance, get_exposure_status + - `luci-app-streamlit/htdocs/luci-static/resources/view/streamlit/dashboard.js`: WAF badge display diff --git a/package/secubox/luci-app-streamlit/htdocs/luci-static/resources/view/streamlit/dashboard.js b/package/secubox/luci-app-streamlit/htdocs/luci-static/resources/view/streamlit/dashboard.js index 65d95300..1572ec2d 100644 --- a/package/secubox/luci-app-streamlit/htdocs/luci-static/resources/view/streamlit/dashboard.js +++ b/package/secubox/luci-app-streamlit/htdocs/luci-static/resources/view/streamlit/dashboard.js @@ -146,6 +146,7 @@ return view.extend({ var isExposed = exp.emancipated; var certValid = exp.cert_valid; var authRequired = exp.auth_required; + var wafEnabled = exp.waf_enabled; // Status indicator var statusBadge; @@ -159,6 +160,15 @@ return view.extend({ statusBadge = E('span', { 'style': 'color:#999' }, _('Local only')); } + // WAF badge (shown when exposed) + var wafBadge = ''; + if (isExposed && wafEnabled) { + wafBadge = E('span', { + 'style': 'display:inline-block; padding:2px 6px; border-radius:4px; font-size:0.85em; background:#d1ecf1; color:#0c5460; margin-left:4px', + 'title': _('Traffic inspected by WAF (mitmproxy)') + }, 'WAF'); + } + // Running indicator var runStatus = inst.enabled ? E('span', { 'style': 'color:#0a0' }, '\u25CF') : @@ -221,7 +231,7 @@ return view.extend({ E('td', {}, [runStatus, ' ', E('strong', {}, inst.id)]), E('td', {}, inst.app || '-'), E('td', {}, ':' + inst.port), - E('td', {}, statusBadge), + E('td', {}, [statusBadge, wafBadge]), E('td', {}, actions) ]); }); diff --git a/package/secubox/luci-app-streamlit/root/usr/libexec/rpcd/luci.streamlit b/package/secubox/luci-app-streamlit/root/usr/libexec/rpcd/luci.streamlit index 1b9e7d4e..c6484334 100755 --- a/package/secubox/luci-app-streamlit/root/usr/libexec/rpcd/luci.streamlit +++ b/package/secubox/luci-app-streamlit/root/usr/libexec/rpcd/luci.streamlit @@ -796,37 +796,98 @@ remove_instance() { json_success "Instance removed: $id" } -# Rename app +# Rename app (updates both display name and file/folder name) rename_app() { read -r input - local id name + local id new_name new_id id=$(echo "$input" | jsonfilter -e '@.id' 2>/dev/null) - name=$(echo "$input" | jsonfilter -e '@.name' 2>/dev/null) + new_name=$(echo "$input" | jsonfilter -e '@.name' 2>/dev/null) + new_id=$(echo "$input" | jsonfilter -e '@.new_id' 2>/dev/null) - if [ -z "$id" ] || [ -z "$name" ]; then + if [ -z "$id" ] || [ -z "$new_name" ]; then json_error "Missing id or name" return fi - # Create UCI section if it doesn't exist yet + # If new_id not provided, sanitize new_name to create it + [ -z "$new_id" ] && new_id=$(echo "$new_name" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/_/g') + + local data_path=$(uci -q get "${CONFIG}.main.data_path") + [ -z "$data_path" ] && data_path="/srv/streamlit" + local apps_path="$data_path/apps" + + # Check if renaming filesystem (folder or file) + local old_path="" + local new_path="" + if [ -d "$apps_path/$id" ]; then + old_path="$apps_path/$id" + new_path="$apps_path/$new_id" + elif [ -f "$apps_path/${id}.py" ]; then + old_path="$apps_path/${id}.py" + new_path="$apps_path/${new_id}.py" + fi + + # Rename filesystem if different + if [ -n "$old_path" ] && [ "$id" != "$new_id" ]; then + if [ -e "$new_path" ]; then + json_error "Destination already exists: $new_id" + return + fi + mv "$old_path" "$new_path" + fi + + # Update display name in UCI local existing existing=$(uci -q get "${CONFIG}.${id}") if [ -z "$existing" ]; then uci set "${CONFIG}.${id}=app" uci set "${CONFIG}.${id}.enabled=1" fi + uci set "${CONFIG}.${id}.name=$new_name" + + # If id changed, update all instance references + if [ "$id" != "$new_id" ]; then + config_load "$CONFIG" + _update_instance_refs() { + local section="$1" + local app + app=$(uci -q get "${CONFIG}.${section}.app") + if [ "$app" = "$id" ] || [ "$app" = "${id}.py" ]; then + # Update to new app reference + if [ -d "$new_path" ]; then + uci set "${CONFIG}.${section}.app=$new_id" + else + uci set "${CONFIG}.${section}.app=${new_id}.py" + fi + fi + } + config_foreach _update_instance_refs instance + fi - uci set "${CONFIG}.${id}.name=$name" uci commit "$CONFIG" - json_success "App renamed" + + # Auto-push to Gitea if configured + local gitea_enabled=$(uci -q get "${CONFIG}.gitea.enabled") + if [ "$gitea_enabled" = "1" ]; then + streamlitctl gitea push "$new_id" >/dev/null 2>&1 & + fi + + json_init_obj + json_add_boolean "success" 1 + json_add_string "message" "App renamed" + json_add_string "old_id" "$id" + json_add_string "new_id" "$new_id" + json_add_string "name" "$new_name" + json_close_obj } -# Rename instance +# Rename instance (updates display name and optionally domain/vhost) rename_instance() { read -r input - local id name + local id name new_domain id=$(echo "$input" | jsonfilter -e '@.id' 2>/dev/null) name=$(echo "$input" | jsonfilter -e '@.name' 2>/dev/null) + new_domain=$(echo "$input" | jsonfilter -e '@.domain' 2>/dev/null) if [ -z "$id" ] || [ -z "$name" ]; then json_error "Missing id or name" @@ -840,9 +901,75 @@ rename_instance() { return fi + # Update display name uci set "${CONFIG}.${id}.name=$name" + + # If new domain provided and instance is emancipated, update vhost + local emancipated=$(uci -q get "${CONFIG}.${id}.emancipated") + local old_domain=$(uci -q get "${CONFIG}.${id}.domain") + local port=$(uci -q get "${CONFIG}.${id}.port") + + if [ "$emancipated" = "1" ] && [ -n "$new_domain" ] && [ "$new_domain" != "$old_domain" ]; then + local old_vhost=$(echo "$old_domain" | sed 's/\./_/g') + local new_vhost=$(echo "$new_domain" | sed 's/\./_/g') + local backend_name="streamlit_${id}" + + # Remove old vhost and cert entries + uci delete "haproxy.${old_vhost}" 2>/dev/null + uci delete "haproxy.cert_${old_vhost}" 2>/dev/null + + # Create new vhost with WAF routing + uci set "haproxy.${new_vhost}=vhost" + uci set "haproxy.${new_vhost}.domain=${new_domain}" + uci set "haproxy.${new_vhost}.backend=mitmproxy_inspector" + uci set "haproxy.${new_vhost}.ssl=1" + uci set "haproxy.${new_vhost}.ssl_redirect=1" + uci set "haproxy.${new_vhost}.acme=1" + uci set "haproxy.${new_vhost}.enabled=1" + + # Create new certificate entry + uci set "haproxy.cert_${new_vhost}=certificate" + uci set "haproxy.cert_${new_vhost}.domain=${new_domain}" + uci set "haproxy.cert_${new_vhost}.type=acme" + uci set "haproxy.cert_${new_vhost}.enabled=1" + + uci commit haproxy + + # Update mitmproxy routes + local routes_file="/srv/mitmproxy/haproxy-routes.json" + if [ -f "$routes_file" ]; then + # Remove old route and add new one + if command -v jq >/dev/null 2>&1; then + jq --arg old "$old_domain" --arg new "$new_domain" --argjson port "$port" \ + 'del(.[$old]) | . + {($new): ["192.168.255.1", $port]}' "$routes_file" > "/tmp/routes_$$.json" && \ + mv "/tmp/routes_$$.json" "$routes_file" + fi + fi + + # Reload HAProxy and mitmproxy + haproxyctl generate >/dev/null 2>&1 + haproxyctl reload >/dev/null 2>&1 + /etc/init.d/mitmproxy restart >/dev/null 2>&1 & + + # Update instance domain + uci set "${CONFIG}.${id}.domain=${new_domain}" + + # Request new certificate + case "$new_domain" in + *.gk2.secubox.in) ;; + *) haproxyctl cert add "$new_domain" >/dev/null 2>&1 & ;; + esac + fi + uci commit "$CONFIG" - json_success "Instance renamed" + + json_init_obj + json_add_boolean "success" 1 + json_add_string "message" "Instance renamed" + json_add_string "id" "$id" + json_add_string "name" "$name" + [ -n "$new_domain" ] && json_add_string "domain" "$new_domain" + json_close_obj } # Enable instance @@ -1618,7 +1745,7 @@ emancipate_instance() { local vhost_section=$(echo "$domain" | sed 's/\./_/g') local backend_name="streamlit_${id}" - # Create backend + # Create backend for direct routing (used by mitmproxy) uci set "haproxy.${backend_name}=backend" uci set "haproxy.${backend_name}.name=${backend_name}" uci set "haproxy.${backend_name}.mode=http" @@ -1635,10 +1762,10 @@ emancipate_instance() { uci set "haproxy.${backend_name}_srv.check=1" uci set "haproxy.${backend_name}_srv.enabled=1" - # Create vhost - NO waf_bypass (all traffic through mitmproxy) + # Create vhost - Route through mitmproxy_inspector for WAF protection uci set "haproxy.${vhost_section}=vhost" uci set "haproxy.${vhost_section}.domain=${domain}" - uci set "haproxy.${vhost_section}.backend=${backend_name}" + uci set "haproxy.${vhost_section}.backend=mitmproxy_inspector" uci set "haproxy.${vhost_section}.ssl=1" uci set "haproxy.${vhost_section}.ssl_redirect=1" uci set "haproxy.${vhost_section}.acme=1" @@ -1652,14 +1779,38 @@ emancipate_instance() { uci commit haproxy - # Sync mitmproxy routes from HAProxy config - if command -v mitmproxyctl >/dev/null 2>&1; then - mitmproxyctl sync-routes >/dev/null 2>&1 + # Add mitmproxy route for this domain -> streamlit backend + local routes_file="/srv/mitmproxy/haproxy-routes.json" + local routes_file_in="/srv/mitmproxy-in/haproxy-routes.json" + if [ -f "$routes_file" ]; then + # Add route entry: "domain": ["192.168.255.1", port] + local tmp_routes="/tmp/routes_$$.json" + if command -v jq >/dev/null 2>&1; then + jq --arg domain "$domain" --argjson port "$port" \ + '. + {($domain): ["192.168.255.1", $port]}' "$routes_file" > "$tmp_routes" 2>/dev/null && \ + mv "$tmp_routes" "$routes_file" + else + # Fallback: append using sed (for OpenWrt without jq) + sed -i "s/}$/,\"${domain}\":[\"192.168.255.1\",${port}]}/" "$routes_file" 2>/dev/null + fi + fi + # Same for inbound mitmproxy + if [ -f "$routes_file_in" ]; then + if command -v jq >/dev/null 2>&1; then + jq --arg domain "$domain" --argjson port "$port" \ + '. + {($domain): ["192.168.255.1", $port]}' "$routes_file_in" > "/tmp/routes_in_$$.json" 2>/dev/null && \ + mv "/tmp/routes_in_$$.json" "$routes_file_in" + else + sed -i "s/}$/,\"${domain}\":[\"192.168.255.1\",${port}]}/" "$routes_file_in" 2>/dev/null + fi fi - # Regenerate and restart HAProxy for clean state + # Restart mitmproxy to pick up routes + /etc/init.d/mitmproxy restart >/dev/null 2>&1 & + + # Regenerate and reload HAProxy haproxyctl generate >/dev/null 2>&1 - /etc/init.d/haproxy restart >/dev/null 2>&1 + haproxyctl reload >/dev/null 2>&1 # Request certificate via ACME (wildcard covers *.gk2.secubox.in) case "$domain" in @@ -1675,14 +1826,22 @@ emancipate_instance() { uci set "${CONFIG}.${id}.emancipated=1" uci set "${CONFIG}.${id}.emancipated_at=$(date -Iseconds)" uci set "${CONFIG}.${id}.domain=${domain}" + uci set "${CONFIG}.${id}.waf_enabled=1" uci commit "$CONFIG" + # Auto-push to Gitea if configured + local gitea_enabled=$(uci -q get "${CONFIG}.gitea.enabled") + if [ "$gitea_enabled" = "1" ]; then + streamlitctl gitea push "$app" >/dev/null 2>&1 & + fi + json_init_obj json_add_boolean "success" 1 - json_add_string "message" "Instance exposed at https://${domain}" + json_add_string "message" "Instance exposed at https://${domain} (WAF protected)" json_add_string "domain" "$domain" json_add_string "url" "https://${domain}" json_add_int "port" "$port" + json_add_boolean "waf_enabled" 1 json_close_obj } @@ -1695,7 +1854,7 @@ get_exposure_status() { _add_exposure_json() { local section="$1" - local app port enabled domain emancipated auth_required + local app port enabled domain emancipated auth_required waf_enabled config_get app "$section" app "" config_get port "$section" port "" @@ -1703,6 +1862,7 @@ get_exposure_status() { config_get domain "$section" domain "" config_get emancipated "$section" emancipated "0" config_get auth_required "$section" auth_required "0" + config_get waf_enabled "$section" waf_enabled "0" [ -z "$app" ] && return @@ -1714,6 +1874,12 @@ get_exposure_status() { cert_valid=1 cert_expires=$(openssl x509 -enddate -noout -in "$cert_file" 2>/dev/null | cut -d= -f2) fi + # Check WAF status from HAProxy vhost config + local vhost_section=$(echo "$domain" | sed 's/\./_/g') + local vhost_backend=$(uci -q get "haproxy.${vhost_section}.backend" 2>/dev/null) + if [ "$vhost_backend" = "mitmproxy_inspector" ]; then + waf_enabled=1 + fi fi json_add_object "" @@ -1726,6 +1892,7 @@ get_exposure_status() { json_add_boolean "auth_required" "$( [ "$auth_required" = "1" ] && echo 1 || echo 0 )" json_add_boolean "cert_valid" "$cert_valid" json_add_string "cert_expires" "$cert_expires" + json_add_boolean "waf_enabled" "$( [ "$waf_enabled" = "1" ] && echo 1 || echo 0 )" json_close_object }