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": '''
+
+''',
+ "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("
+