diff --git a/.claude/HISTORY.md b/.claude/HISTORY.md index da98445e..d5a8c05e 100644 --- a/.claude/HISTORY.md +++ b/.claude/HISTORY.md @@ -849,7 +849,29 @@ _Last updated: 2026-02-06_ - Updated `streamlit/api.js` with 4 new API methods - Updated ACL permissions in `luci-app-streamlit.json` -57. **SecuBox Vhost Manager (2026-02-06)** +57. **Fabricator Embedder & Service Profile Watchdog (2026-02-06)** + - **Fabricator Embedder Tab**: Added 7th tab "🪟 Embedder" for creating unified portal pages. + - Embeds Streamlit apps, MetaBlogizer sites, and custom URLs in single page + - Three layouts: Grid (iframe grid), Tabs (tab-switching), Sidebar (navigation panel) + - Auto-fetches available services from JSON endpoints + - Deploys HTML portal to /www + - **Service Profile Snapshot** (`/usr/sbin/secubox-profile-snapshot`): + - `snapshot`: Captures current enabled/running services to UCI config + - `check`: Returns JSON status comparing current vs expected + - `watchdog`: Attempts to restart failed services + - `list`: Displays profile with current status + - Monitors: Core services (5), LXC containers (3), Streamlit apps (11), MetaBlogizer sites (14) + - **Heartbeat Status** (`/usr/sbin/secubox-heartbeat-status`): + - Returns JSON health score (0-100) with level (healthy/warning/critical) + - Resource metrics: CPU load, memory %, disk % + - Service counts: up/down + - Exported to `/tmp/secubox/heartbeat.json` and `/www/heartbeat.json` + - **Cron Integration**: + - Watchdog runs every 5 minutes to auto-restart failed services + - Heartbeat updates every minute for LED/dashboard status + - **Fabricator Emancipation**: Published at https://fabric.gk2.secubox.in + +58. **SecuBox Vhost Manager (2026-02-06)** - Created `secubox-vhost` CLI for subdomain management in secubox-core: - Manages external (`*.gk2.secubox.in`) and local (`*.gk2.sb.local`) domains - Commands: init, set-domain, list, enable, disable, add, sync, landing, dnsmasq diff --git a/streamlit-apps/fabricator/README.md b/streamlit-apps/fabricator/README.md new file mode 100644 index 00000000..3bafdbba --- /dev/null +++ b/streamlit-apps/fabricator/README.md @@ -0,0 +1,44 @@ +# SecuBox Fabricator + +Widget & Component Constructor for SecuBox platform. + +## Features + +7 tabs for building SecuBox components: + +1. **📊 Collectors** - Stats collector builder (shell scripts with cron) +2. **🚀 Apps** - Streamlit app deployer +3. **📝 Blogs** - MetaBlogizer site management +4. **🌐 Statics** - Static HTML page generator +5. **🔌 Services** - Service exposure (Emancipate) +6. **🧩 Widgets** - HTML widget designer +7. **🪟 Embedder** - Portal page builder (embeds apps/services/blogs) + +## Deployment + +```bash +# Copy to router +scp app.py root@192.168.255.1:/srv/streamlit/apps/fabricator/ + +# Register instance +uci set streamlit.fabricator=instance +uci set streamlit.fabricator.name=fabricator +uci set streamlit.fabricator.app=fabricator +uci set streamlit.fabricator.port=8520 +uci set streamlit.fabricator.enabled=1 +uci commit streamlit + +# Restart +/etc/init.d/streamlit restart +``` + +## Emancipation + +```bash +streamlitctl emancipate fabricator fabric.gk2.secubox.in +``` + +## Access + +- Local: http://192.168.255.1:8520 +- External: https://fabric.gk2.secubox.in diff --git a/streamlit-apps/fabricator/app.py b/streamlit-apps/fabricator/app.py new file mode 100644 index 00000000..f0a9ee8e --- /dev/null +++ b/streamlit-apps/fabricator/app.py @@ -0,0 +1,640 @@ +#!/usr/bin/env python3 +""" +SecuBox Fabricator - Widget & Component Constructor +Multi-tab Streamlit app for building SecuBox components +""" + +import streamlit as st +import json +import subprocess +import os +from datetime import datetime + +st.set_page_config( + page_title="SecuBox Fabricator", + page_icon="🔧", + layout="wide", + initial_sidebar_state="collapsed" +) + +# Custom CSS +st.markdown(""" + +""", unsafe_allow_html=True) + +def run_cmd(cmd, timeout=30): + """Run shell command and return output""" + try: + result = subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=timeout) + return result.stdout.strip(), result.returncode == 0 + except subprocess.TimeoutExpired: + return "Command timed out", False + except Exception as e: + return str(e), False + +def ssh_cmd(cmd, timeout=30): + """Run command on router via SSH""" + return run_cmd(f'ssh -o ConnectTimeout=5 root@192.168.255.1 "{cmd}"', timeout) + +# Main title +st.markdown('

🔧 SecuBox Fabricator

', unsafe_allow_html=True) + +# Tab navigation +tabs = st.tabs(["📊 Collectors", "🚀 Apps", "📝 Blogs", "🌐 Statics", "🔌 Services", "🧩 Widgets", "🪟 Embedder"]) + +# ============== TAB 1: COLLECTORS ============== +with tabs[0]: + st.subheader("Stats Collector Builder") + st.markdown("Create custom stats collection scripts") + + col1, col2 = st.columns([1, 1]) + + with col1: + collector_name = st.text_input("Collector Name", value="my-collector", key="coll_name") + collector_type = st.selectbox("Template", ["Custom", "CrowdSec", "mitmproxy", "Firewall", "Network"]) + data_source = st.text_input("Data Source", value="/var/log/myservice.log", key="coll_src") + output_path = st.text_input("Output JSON", value=f"/tmp/secubox/{collector_name}.json", key="coll_out") + cron_schedule = st.selectbox("Update Frequency", ["*/5 * * * *", "*/1 * * * *", "*/15 * * * *", "0 * * * *"]) + + # Template-based script generation + if collector_type == "Custom": + script_template = f'''#!/bin/sh +# {collector_name} - Custom Stats Collector +OUTPUT="{output_path}" +SRC="{data_source}" + +# Parse your data source here +count=$(wc -l < "$SRC" 2>/dev/null | tr -cd '0-9') +[ -z "$count" ] && count=0 + +cat > "$OUTPUT" << EOF +{{ + "count": $count, + "last_update": "$(date -Iseconds)" +}} +EOF +''' + elif collector_type == "CrowdSec": + script_template = f'''#!/bin/sh +# {collector_name} - CrowdSec Stats +OUTPUT="{output_path}" +bans=$(cscli decisions list -o json 2>/dev/null | jsonfilter -e "@[*]" 2>/dev/null | wc -l) +[ -z "$bans" ] && bans=0 +cat > "$OUTPUT" << EOF +{{ "bans": $bans, "last_update": "$(date -Iseconds)" }} +EOF +''' + elif collector_type == "mitmproxy": + script_template = f'''#!/bin/sh +# {collector_name} - mitmproxy WAF Stats +OUTPUT="{output_path}" +threats=$(wc -l < /srv/mitmproxy/threats.log 2>/dev/null | tr -cd '0-9') +[ -z "$threats" ] && threats=0 +cat > "$OUTPUT" << EOF +{{ "threats": $threats, "last_update": "$(date -Iseconds)" }} +EOF +''' + elif collector_type == "Firewall": + script_template = f'''#!/bin/sh +# {collector_name} - Firewall Stats +OUTPUT="{output_path}" +dropped=$(nft list chain inet fw4 input_wan 2>/dev/null | grep -oE 'packets [0-9]+' | awk '{{sum+=$2}}END{{print sum+0}}') +cat > "$OUTPUT" << EOF +{{ "dropped": ${{dropped:-0}}, "last_update": "$(date -Iseconds)" }} +EOF +''' + else: + script_template = f'''#!/bin/sh +# {collector_name} - Network Stats +OUTPUT="{output_path}" +conns=$(wc -l < /proc/net/nf_conntrack 2>/dev/null | tr -cd '0-9') +cat > "$OUTPUT" << EOF +{{ "connections": ${{conns:-0}}, "last_update": "$(date -Iseconds)" }} +EOF +''' + + with col2: + st.markdown("**Generated Script:**") + script_code = st.text_area("Script", value=script_template, height=300, key="coll_script") + + if st.button("🚀 Deploy Collector", key="deploy_coll"): + script_path = f"/usr/sbin/{collector_name}.sh" + # Save script + with open(f"/tmp/{collector_name}.sh", "w") as f: + f.write(script_code) + os.system(f"scp /tmp/{collector_name}.sh root@192.168.255.1:{script_path}") + os.system(f"ssh root@192.168.255.1 'chmod +x {script_path}'") + # Add cron + cron_entry = f"{cron_schedule} {script_path} >/dev/null 2>&1" + os.system(f'ssh root@192.168.255.1 "grep -q \\"{collector_name}\\" /etc/crontabs/root || echo \\"{cron_entry}\\" >> /etc/crontabs/root"') + st.success(f"Deployed {collector_name} to {script_path}") + +# ============== TAB 2: APPS ============== +with tabs[1]: + st.subheader("Streamlit App Deployer") + + col1, col2 = st.columns([1, 1]) + + with col1: + st.markdown("**Create New App**") + app_name = st.text_input("App Name", value="myapp", key="app_name") + app_port = st.number_input("Port", min_value=8500, max_value=9999, value=8520, key="app_port") + + app_template = st.selectbox("Template", ["Basic", "Dashboard", "Form", "Data Viewer"]) + + templates = { + "Basic": '''import streamlit as st +st.set_page_config(page_title="{name}", page_icon="🚀") +st.title("{name}") +st.write("Welcome to {name}!") +''', + "Dashboard": '''import streamlit as st +import json +st.set_page_config(page_title="{name}", page_icon="📊", layout="wide") +st.title("📊 {name} Dashboard") +col1, col2, col3 = st.columns(3) +with col1: st.metric("Metric 1", "100") +with col2: st.metric("Metric 2", "200") +with col3: st.metric("Metric 3", "300") +''', + "Form": '''import streamlit as st +st.set_page_config(page_title="{name}", page_icon="📝") +st.title("📝 {name}") +with st.form("main_form"): + name = st.text_input("Name") + email = st.text_input("Email") + if st.form_submit_button("Submit"): + st.success(f"Submitted: {{name}}, {{email}}") +''', + "Data Viewer": '''import streamlit as st +import json +st.set_page_config(page_title="{name}", page_icon="📈", layout="wide") +st.title("📈 {name}") +data_path = st.text_input("JSON Path", "/tmp/secubox/health-status.json") +if st.button("Load Data"): + try: + with open(data_path) as f: + st.json(json.load(f)) + except Exception as e: + st.error(str(e)) +''' + } + + app_code = templates[app_template].format(name=app_name) + + with col2: + st.markdown("**App Code:**") + final_code = st.text_area("Code", value=app_code, height=300, key="app_code") + + if st.button("🚀 Deploy App", key="deploy_app"): + app_path = f"/srv/streamlit/apps/{app_name}" + with open(f"/tmp/{app_name}_app.py", "w") as f: + f.write(final_code) + os.system(f"ssh root@192.168.255.1 'mkdir -p {app_path}'") + os.system(f"scp /tmp/{app_name}_app.py root@192.168.255.1:{app_path}/app.py") + # Register in UCI + os.system(f'ssh root@192.168.255.1 "uci set streamlit.{app_name}=instance && uci set streamlit.{app_name}.name={app_name} && uci set streamlit.{app_name}.app={app_name}/app.py && uci set streamlit.{app_name}.port={app_port} && uci set streamlit.{app_name}.enabled=1 && uci commit streamlit"') + st.success(f"Deployed {app_name} on port {app_port}") + + st.markdown("---") + st.markdown("**Running Instances:**") + output, _ = ssh_cmd("streamlitctl list 2>/dev/null | head -20") + st.code(output or "No instances found") + +# ============== TAB 3: BLOGS ============== +with tabs[2]: + st.subheader("MetaBlogizer Sites") + + col1, col2 = st.columns([1, 1]) + + with col1: + st.markdown("**Create Blog Site**") + blog_name = st.text_input("Site Name", value="myblog", key="blog_name") + blog_domain = st.text_input("Domain", value="myblog.example.com", key="blog_domain") + blog_port = st.number_input("Port", min_value=8900, max_value=9999, value=8920, key="blog_port") + blog_theme = st.selectbox("Theme", ["paper", "ananke", "book", "even", "stack"]) + + if st.button("🚀 Create Blog", key="create_blog"): + cmd = f'metablogizerctl create {blog_name} {blog_domain} {blog_port} {blog_theme} 2>&1' + output, success = ssh_cmd(cmd, timeout=60) + if success: + st.success(f"Created blog: {blog_name}") + else: + st.error(output) + + with col2: + st.markdown("**Existing Sites:**") + sites_output, _ = ssh_cmd("metablogizerctl list 2>/dev/null") + st.code(sites_output or "No sites found") + + st.markdown("**Quick Actions:**") + site_select = st.text_input("Site to manage", key="blog_manage") + action_col1, action_col2, action_col3 = st.columns(3) + with action_col1: + if st.button("Build", key="blog_build"): + output, _ = ssh_cmd(f"metablogizerctl build {site_select}") + st.info(output) + with action_col2: + if st.button("Serve", key="blog_serve"): + output, _ = ssh_cmd(f"metablogizerctl serve {site_select}") + st.info(output) + with action_col3: + if st.button("Emancipate", key="blog_emancipate"): + output, _ = ssh_cmd(f"metablogizerctl emancipate {site_select}") + st.info(output) + +# ============== TAB 4: STATICS ============== +with tabs[3]: + st.subheader("Static Site Generator") + + page_name = st.text_input("Page Name", value="mypage", key="static_name") + page_title = st.text_input("Title", value="My Page", key="static_title") + + page_template = st.selectbox("Template", ["Landing", "Status", "Dashboard", "Portal"]) + + templates = { + "Landing": ''' +{title} + +

{title}

Welcome to {title}

''', + "Status": ''' +{title} + +

{title}

+
Loading...
+''', + "Dashboard": ''' +{title} + +

{title}

+
+''', + "Portal": ''' +{title} + +

{title}

+''' + } + + html_code = templates[page_template].format(title=page_title) + final_html = st.text_area("HTML Code", value=html_code, height=300, key="static_html") + + if st.button("🚀 Deploy Page", key="deploy_static"): + with open(f"/tmp/{page_name}.html", "w") as f: + f.write(final_html) + os.system(f"scp /tmp/{page_name}.html root@192.168.255.1:/www/{page_name}.html") + st.success(f"Deployed to /www/{page_name}.html") + +# ============== TAB 5: SERVICES ============== +with tabs[4]: + st.subheader("Service Exposure (Emancipate)") + + col1, col2 = st.columns([1, 1]) + + with col1: + st.markdown("**Local Services Scan**") + if st.button("🔍 Scan Services", key="scan_svc"): + output, _ = ssh_cmd("netstat -tln | grep LISTEN | awk '{print $4}' | sort -u | head -20") + st.code(output or "No services found") + + st.markdown("**Expose Service**") + svc_port = st.number_input("Port", min_value=1, max_value=65535, value=8080, key="svc_port") + svc_domain = st.text_input("Domain", value="myservice.example.com", key="svc_domain") + svc_backend = st.text_input("Backend Name", value="myservice", key="svc_backend") + + expose_options = st.multiselect("Exposure Channels", ["HAProxy/SSL", "Tor", "Mesh"]) + + with col2: + if st.button("🔌 Emancipate Service", key="emancipate_svc"): + # Create HAProxy backend and vhost + if "HAProxy/SSL" in expose_options: + cmds = [ + f'uci set haproxy.{svc_backend}=backend', + f'uci set haproxy.{svc_backend}.name="{svc_backend}"', + f'uci set haproxy.{svc_backend}.mode="http"', + f'uci set haproxy.{svc_backend}.balance="roundrobin"', + f'uci set haproxy.{svc_backend}.enabled="1"', + f'uci set haproxy.{svc_backend}.server="srv 192.168.255.1:{svc_port} check"', + f'uci set haproxy.{svc_domain.replace(".", "_")}=vhost', + f'uci set haproxy.{svc_domain.replace(".", "_")}.domain="{svc_domain}"', + f'uci set haproxy.{svc_domain.replace(".", "_")}.backend="{svc_backend}"', + f'uci set haproxy.{svc_domain.replace(".", "_")}.ssl="1"', + f'uci set haproxy.{svc_domain.replace(".", "_")}.https_redirect="1"', + f'uci set haproxy.{svc_domain.replace(".", "_")}.enabled="1"', + 'uci commit haproxy', + 'haproxyctl generate && haproxyctl reload' + ] + for cmd in cmds: + ssh_cmd(cmd) + st.success(f"HAProxy vhost created for {svc_domain}") + + if "Tor" in expose_options: + output, _ = ssh_cmd(f"torctl add {svc_backend} {svc_port}") + st.info(f"Tor: {output}") + + if "Mesh" in expose_options: + output, _ = ssh_cmd(f"vortexctl mesh publish {svc_backend} {svc_domain}") + st.info(f"Mesh: {output}") + +# ============== TAB 6: WIDGETS ============== +with tabs[5]: + st.subheader("Widget Designer") + + widget_name = st.text_input("Widget Name", value="mywidget", key="widget_name") + widget_type = st.selectbox("Type", ["Metric", "Chart", "Status", "List"]) + data_source = st.text_input("Data JSON Path", value="/secubox-status.json", key="widget_data") + data_field = st.text_input("Data Field", value="resources.cpu_load", key="widget_field") + + widget_templates = { + "Metric": '''
+
--
+
{name}
+
+ +''', + "Status": '''
+ {name} +
+ +''', + "Chart": '''
+ +
+ + +''', + "List": '''
+ +
+ +''' + } + + widget_code = widget_templates[widget_type].format( + name=widget_name, + source=data_source, + field=data_field + ) + + st.markdown("**Preview:**") + st.markdown(f'
{widget_code.split("', unsafe_allow_html=True) + + st.markdown("**Generated Code:**") + st.code(widget_code, language="html") + + if st.button("📋 Copy to Clipboard", key="copy_widget"): + st.info("Code ready - copy from the code block above") + +# ============== TAB 7: EMBEDDER ============== +with tabs[6]: + st.subheader("Service Embedder Portal") + st.markdown("Create unified portal pages embedding multiple services") + + col1, col2 = st.columns([1, 2]) + + with col1: + portal_name = st.text_input("Portal Name", value="myportal", key="embed_name") + portal_title = st.text_input("Portal Title", value="My SecuBox Portal", key="embed_title") + layout = st.selectbox("Layout", ["Grid", "Tabs", "Sidebar"], key="embed_layout") + cols_num = st.slider("Grid Columns", 1, 4, 2, key="embed_cols") + + st.markdown("---") + st.markdown("**Available Sources:**") + + # Fetch available services + apps_json, _ = ssh_cmd("cat /www/streamlit-instances.json 2>/dev/null") + blogs_json, _ = ssh_cmd("cat /www/metablogizer-sites.json 2>/dev/null") + + try: + apps = json.loads(apps_json) if apps_json else [] + blogs = json.loads(blogs_json).get("sites", []) if blogs_json else [] + except: + apps, blogs = [], [] + + # Streamlit apps selection + app_options = [f"{a['name']} (:{a['port']})" for a in apps if a.get('running')] + selected_apps = st.multiselect("Streamlit Apps", app_options, key="embed_apps") + + # MetaBlogizer sites selection + blog_options = [f"{b['name']} ({b['domain']})" for b in blogs] + selected_blogs = st.multiselect("Blog Sites", blog_options, key="embed_blogs") + + # Custom URLs + custom_urls = st.text_area("Custom URLs (one per line)", placeholder="https://glances.maegia.tv\nhttps://grafana.local:3000", key="embed_custom") + + with col2: + # Build embed items + embed_items = [] + + for app_str in selected_apps: + name = app_str.split(" (")[0] + app = next((a for a in apps if a['name'] == name), None) + if app: + embed_items.append({ + "name": app['name'], + "url": f"http://192.168.255.1:{app['port']}", + "type": "streamlit" + }) + + for blog_str in selected_blogs: + name = blog_str.split(" (")[0] + blog = next((b for b in blogs if b['name'] == name), None) + if blog: + embed_items.append({ + "name": blog['name'], + "url": f"https://{blog['domain']}", + "type": "blog" + }) + + for url in (custom_urls or "").strip().split("\n"): + if url.strip(): + name = url.strip().split("//")[-1].split("/")[0].split(":")[0] + embed_items.append({ + "name": name, + "url": url.strip(), + "type": "custom" + }) + + # Generate portal HTML based on layout + if layout == "Grid": + items_html = "\n".join([ + f'
{item["name"]}
' + f'
' + for item in embed_items + ]) + portal_html = f''' + + + +{portal_title} + + +

{portal_title}

+
+{items_html} +
+''' + + elif layout == "Tabs": + tabs_html = "\n".join([f'' for item in embed_items]) + frames_html = "\n".join([f'' for item in embed_items]) + portal_html = f''' + + +{portal_title} + + +
+

{portal_title}

+
{tabs_html}
+
+{frames_html} + +''' + + else: # Sidebar + first_url = embed_items[0]['url'] if embed_items else 'about:blank' + nav_html = "\n".join([f'{item["name"]}' for item in embed_items]) + portal_html = f''' + + +{portal_title} + + + +
+ +
+ +''' + + st.markdown("**Preview:**") + if embed_items: + st.markdown(f"Embedding {len(embed_items)} services: {', '.join(i['name'] for i in embed_items)}") + st.code(portal_html[:2000] + ("..." if len(portal_html) > 2000 else ""), language="html") + else: + st.info("Select services to embed") + + if st.button("🚀 Deploy Portal", key="deploy_portal"): + if embed_items: + with open(f"/tmp/{portal_name}.html", "w") as f: + f.write(portal_html) + os.system(f"scp /tmp/{portal_name}.html root@192.168.255.1:/www/{portal_name}.html") + st.success(f"Deployed portal to /www/{portal_name}.html") + st.markdown(f"**Access:** http://192.168.255.1/{portal_name}.html") + else: + st.warning("Please select at least one service to embed") + +# Footer +st.markdown("---") +st.markdown(f'
SecuBox Fabricator • {datetime.now().strftime("%H:%M:%S")}
', unsafe_allow_html=True)