# NUC Server - Claude Code Instructions ## Server Access The NUC server is accessible via SSH: ```bash ssh nuc ``` **Connection Details:** - Hostname: `192.168.1.3` (local) or `100.113.153.45` (Tailscale) - User: `alezmad` - SSH Key: `~/.ssh/id_ed25519_nuc` ## DNS & Tailscale Setup ### DNS Strategy: Local IP + Tailscale Subnet Routing All `.nuc.lan` domains resolve to the **local LAN IP** (`192.168.1.3`). This allows any device on the LAN to access services without Tailscale. **Remote access (Tailscale)** works via two mechanisms: 1. **Split DNS**: Tailscale forwards `*.nuc.lan` queries to the OpenWrt router (`192.168.1.1`) through the tunnel 2. **Subnet routing**: The NUC advertises `192.168.1.0/24` as a Tailscale subnet route, so `192.168.1.3` is reachable from any Tailscale device remotely ### Configured Domains (OpenWrt Router DNS) | Domain | Resolves To | Service | |--------|-------------|---------| | `nuc.lan` | `192.168.1.3` | NUC Portal | | `nuc.local` | `192.168.1.3` | NUC Portal | | `coolify.nuc.lan` | `192.168.1.3` | Coolify | | `gitea.nuc.lan` | `192.168.1.3` | Gitea | | `outline.nuc.lan` | `192.168.1.3` | Outline Wiki | | `files.nuc.lan` | `192.168.1.3` | FileBrowser | | `mail.nuc.lan` | `192.168.1.3` | Snappymail | | `vault.nuc.lan` | `192.168.1.3` | Vaultwarden | | `homepage.nuc.lan` | `192.168.1.3` | NUC Portal | | `brand.nuc.lan` | `192.168.1.3` | Whyrating Brand | | `templates.nuc.lan` | `192.168.1.3` | Whyrating Templates | | `whyrating.nuc.lan` | `192.168.1.3` | Whyrating Hub | | `whyops.nuc.lan` | `192.168.1.3` | WhyOps (scraping, pipelines, testing) | | `arrio.nuc.lan` | `192.168.1.3` | Arrio (hotel check-in) | ### Traefik Routing (Dynamic Config) Traefik routes domain-based requests to the correct backend. Config location: `/data/coolify/proxy/dynamic/nuc-services.yaml` ```yaml # Routes for port-based services via domain names http: routers: coolify: rule: Host(`coolify.nuc.lan`) service: coolify services: coolify: loadBalancer: servers: - url: http://host.docker.internal:8000 ``` ### Adding a New Domain ```bash # 1. Add DNS entry on router (via NUC jump host) ssh nuc "ssh -i ~/.ssh/id_ed25519_nuc root@192.168.1.1 ' uci add dhcp domain uci set dhcp.@domain[-1].name=\"newservice.nuc.lan\" uci set dhcp.@domain[-1].ip=\"192.168.1.3\" uci commit dhcp /etc/init.d/dnsmasq restart '" # 2. Add Traefik route (if needed for port-based service) # Edit /data/coolify/proxy/dynamic/nuc-services.yaml ``` ### Always-On Tailscale **Keep Tailscale running** - it's designed to be always-on: - When on home network: Uses direct connection (no relay, same performance as local) - When remote: Routes through Tailscale mesh - Minimal resource usage (~0% CPU when idle) ## Service Management ### Coolify (Primary Service Manager) All services are managed through Coolify at `http://coolify.nuc.lan` (or `http://100.113.153.45:8000`) **Prefer using Coolify MCP** (`mcp__coolify__*`) for service management - it's faster and more reliable than SSH commands. ### ⚠️ STRICT RULE: Container Deployment Priority **ALWAYS attempt Coolify first when adding any container/service:** 1. **First:** Try `mcp__coolify__service(action="create", type="", ...)` 2. **If type invalid:** Deploy via docker-compose in Coolify using `docker_compose_raw` 3. **Last resort:** Direct Docker commands via SSH (only if Coolify can't handle it) ```python # Step 1: Try native Coolify service type mcp__coolify__service(action="create", type="servicename", name="ServiceName", server_uuid="qk84w0goo4w48g4ggsoo0oss", project_uuid="a8484ggc88c40w4g4k004ow0", environment_name="production", instant_deploy=True) # Step 2: If "Invalid service type", use docker-compose mcp__coolify__service(action="create", name="ServiceName", server_uuid="qk84w0goo4w48g4ggsoo0oss", project_uuid="a8484ggc88c40w4g4k004ow0", environment_name="production", docker_compose_raw=""" services: myservice: image: organization/image:tag restart: unless-stopped ports: - "8080:8080" volumes: - data:/app/data volumes: data: """, instant_deploy=True) ``` **Why Coolify first:** - Centralized management in one UI - Automatic restarts, health checks - Easy updates and rollbacks - Visible in infrastructure overview - Consistent with existing services ### ⚠️ STRICT RULE: Browser MCP for Manual Configurations **When a task requires manual web UI interaction (OAuth setup, API key generation, admin consoles), ALWAYS use Browser MCP instead of asking the user to do it manually.** **Priority order for browser automation:** 1. `mcp__playwriter-nuc-01__*` - Remote NUC browser (preferred, no local resources) 2. `mcp__chrome-devtools-nuc-01__*` - Chrome DevTools for NUC browser 3. `mcp__playwriter-local__*` - Local browser (fallback, uses local resources) **Browser MCP Naming Convention:** ``` playwriter-- chrome-devtools-- Examples: - playwriter-local → Local machine browser - playwriter-nuc-01 → First NUC browser container - playwriter-nuc-02 → Second NUC browser (future) - playwriter-cloud-01 → Cloud browser instance (future) ``` **Common use cases:** - Generating API keys/tokens (Tailscale, OAuth apps, etc.) - Configuring OAuth/OIDC providers - Admin console settings not available via API - Any "go to website and click" tasks ```python # Example: Navigate and interact mcp__chrome-devtools__navigate_page(type="url", url="https://admin.example.com") mcp__chrome-devtools__take_snapshot() # See current state mcp__chrome-devtools__click(uid="") mcp__chrome-devtools__fill(uid="", value="text") ``` **NEVER say "please go to X and do Y manually" - use browser MCP instead.** ### ⚠️ STRICT RULE: Parallel Subtasks for Multiple Operations **When multiple independent services, configurations, or entities need to be set up, ALWAYS use parallel Task agents instead of sequential operations.** ```python # WRONG - Sequential (slow) # Step 1: Configure Tailscale # Step 2: Configure WireGuard # Step 3: Add to Homepage # CORRECT - Parallel subtasks (fast) Task(subagent_type="general-purpose", prompt="Configure Tailscale auth key...", description="Setup Tailscale") Task(subagent_type="general-purpose", prompt="Install WireGuard on router...", description="Setup WireGuard") Task(subagent_type="general-purpose", prompt="Add services to Homepage...", description="Update Homepage") # All three run simultaneously! ``` **When to parallelize:** - Multiple service deployments - Multiple configuration changes across different systems - Independent API calls or browser automations - Any tasks that don't depend on each other's output **How to parallelize:** - Use multiple `Task` tool calls in a single message - Each task gets its own agent with full context - Results are collected when all complete ### Available MCPs for NUC Management | MCP | Purpose | |-----|---------| | `mcp__coolify__*` | Service management, deployments, env vars | | `mcp__stalwart-mail__*` | Email server management (users, domains, queue) | | `mcp__email-client__*` | Read/send emails via IMAP/SMTP (see below) | | `mcp__nocodb__*` | Database operations, table management | | `mcp__ssh-manager__*` | Direct SSH commands, file transfers | | `mcp__n8n__*` | Workflow automation (if configured) | | `mcp__playwriter__*` | Browser automation fallback (see below) | | `mcp__deepgram__*` | Audio transcription (STT) and text-to-speech (TTS) | ### Stalwart Mail MCP (Quick Guide) **Location:** `~/mcp-servers/stalwart-mail/` Manage the self-hosted Stalwart mail server via natural language. **Available Tools:** | Category | Tools | |----------|-------| | **Users** | `list_users`, `get_user`, `create_user`, `update_user_password`, `delete_user`, `add_email_alias` | | **Domains** | `create_domain`, `generate_dkim` | | **Queue** | `list_queue`, `get_queue_status`, `delete_queued_message`, `retry_queued_message` | | **Monitoring** | `get_metrics`, `get_dmarc_reports`, `get_server_logs` | | **DNS** | `check_dns_records`, `troubleshoot_delivery` | | **Spam** | `train_spam`, `train_ham`, `update_spam_filter` | **Usage Examples:** ``` "List all mail users" "Create user sales with email sales@whyrating.com and password Secret123" "Check the mail queue" "Verify DNS records for whyrating.com" "Show server metrics" "Delete user john" ``` **Direct API Test (if MCP not responding):** ```bash curl -s -u "admin:QfKYjCJdxu" "http://192.168.1.3:8081/api/principal" | jq . ``` **Reconfigure MCP:** ```bash claude mcp remove stalwart-mail claude mcp add stalwart-mail \ -e STALWART_URL=http://192.168.1.3:8081 \ -e STALWART_USER=admin \ -e STALWART_PASS=QfKYjCJdxu \ --scope user \ -- ~/mcp-servers/stalwart-mail/.venv/bin/python ~/mcp-servers/stalwart-mail/server.py ``` **⚠️ SMTP Authentication Requirements:** 1. **Password format:** Must be SHA-512 hashed (not plaintext). When creating users via API: ```python import crypt hashed = crypt.crypt('password', crypt.mksalt(crypt.METHOD_SHA512)) # Use hashed value in 'secrets' field ``` 2. **SMTP login:** Use username only (e.g., `info`), NOT full email (`info@whyrating.com`) 3. **Port 465 (SMTPS):** Supports PLAIN/LOGIN auth with implicit TLS 4. **Port 587 (Submission):** Requires STARTTLS, only OAuth supported without TLS **Send email via Python (from NUC):** ```python import smtplib, ssl from email.mime.text import MIMEText context = ssl.create_default_context() context.check_hostname = False context.verify_mode = ssl.CERT_NONE with smtplib.SMTP_SSL('localhost', 465, context=context) as server: server.login('info', 'whyrating2026') # Username only! server.sendmail('info@whyrating.com', 'recipient@example.com', msg.as_string()) ``` ### Email Client MCP (Read/Send Emails) **Package:** [mcp-email-server](https://github.com/ai-zerolab/mcp-email-server) Read and send emails via IMAP/SMTP directly from Claude. **Configured for:** `info@whyrating.com` on Stalwart **Usage Examples:** ``` "Check my inbox" "Read the latest email" "Send an email to john@example.com with subject Hello" "Search emails from support@" "List email folders" ``` **Reconfigure:** ```bash claude mcp remove email-client claude mcp add email-client \ -e MCP_EMAIL_SERVER_EMAIL_ADDRESS=info@whyrating.com \ -e MCP_EMAIL_SERVER_PASSWORD=whyrating2026 \ -e MCP_EMAIL_SERVER_IMAP_HOST=192.168.1.3 \ -e MCP_EMAIL_SERVER_IMAP_PORT=143 \ -e MCP_EMAIL_SERVER_SMTP_HOST=192.168.1.3 \ -e MCP_EMAIL_SERVER_SMTP_PORT=587 \ -e MCP_EMAIL_SERVER_SMTP_VERIFY_SSL=false \ -e MCP_EMAIL_SERVER_ENABLE_ATTACHMENT_DOWNLOAD=true \ --scope user \ -- uvx mcp-email-server@latest stdio ``` ### Adding Remote MCP Servers (HTTP Transport) **Use `claude mcp add --transport http` for remote MCP endpoints** - this is the recommended method for services with native MCP support. ```bash # Basic syntax claude mcp add --transport http --scope user --header "
" # Example: NocoDB MCP (globally available) claude mcp add --transport http nocodb http://192.168.1.3:8084/mcp/ncnyir1cy6n9bf5p \ --scope user \ --header "xc-mcp-token: qjjAXRxuYzRtEn-cA4lbPFi5km_pojTX" ``` **Scope options:** - `--scope user` - Available across all projects (stored in `~/.claude.json`) - `--scope local` - Current project only (default) - `--scope project` - Shared via `.mcp.json` (committed to repo) **Why CLI over JSON config:** - JSON config with `mcp-remote` often fails to load tools - CLI `--transport http` handles HTTP endpoints natively - No need for `--allow-http` flag or other workarounds **Managing MCP servers:** ```bash claude mcp list # List all configured servers claude mcp get nocodb # Get details for specific server claude mcp remove nocodb # Remove a server /mcp # Check status in Claude Code ``` ### ⚠️ STRICT RULE: MCP Research Protocol **When asked to find/recommend new MCPs**, read the detailed guide at `docs/mcp-research-guide.md` which contains: - MCP directories (MCP.so, Smithery, MCPHub, etc.) - GitHub verification resources - Evaluation criteria checklist - Research workflow - Category quick reference **Quick workflow:** 1. Search directories: `mcp.so`, `smithery.ai`, `mcpservers.org` 2. Verify on GitHub: stars, last commit, issues 3. Evaluate: 50+ stars, <6 months active, 3+ tools, OSS license ### Playwriter as Fallback When SSH, API endpoints, or other MCPs can't accomplish a task (e.g., no API available, UI-only settings), use **Playwriter MCP** to automate browser interactions: ```javascript // Navigate to service UI await page.goto('http://192.168.1.3:8000'); // Get page state console.log(await accessibilitySnapshot({ page })); // Interact with elements await page.locator('aria-ref=e5').click(); ``` **Use cases:** - Configuring services that lack APIs (Coolify UI settings, etc.) - Creating OAuth apps, API keys through web interfaces - Debugging issues by inspecting service dashboards - Any task where clicking through a UI is the only option ### Remote Browser Container (NUC) A dedicated browser container runs on the NUC for AI-controlled browsing without local resources: **Access:** - noVNC Web: `http://192.168.1.3:6081/vnc.html` - Playwriter Relay: `ws://192.168.1.3:19988` - Chrome DevTools: `http://192.168.1.3:9222` **MCP connects remotely via:** ```json { "playwriter-nuc-01": { "_id": "nuc-01", "_host": "192.168.1.3", "args": ["playwriter", "--host", "ws://192.168.1.3:19988", "--token", "nuc-browser-token"] } } ``` **First-time setup:** Access noVNC, install Playwriter extension, click to activate (turns green). **Container location:** `~/playwriter-browser/` on NUC (deployed via docker compose) **Coolify CLI Commands:** ```bash # Access Coolify's Laravel tinker for direct database/service manipulation ssh nuc "docker exec coolify php artisan tinker --execute=\"\"" # Restart a service (example for service ID 9 - Outline) ssh nuc "docker exec coolify php artisan tinker --execute=\" use App\Actions\Service\StartService; use App\Models\Service; \\\$service = Service::find(9); StartService::run(\\\$service); \"" # Update environment variable (encrypted) ssh nuc "docker exec coolify php artisan tinker --execute=\" use App\Models\EnvironmentVariable; \\\$var = EnvironmentVariable::where('key', 'VAR_NAME')->where('resourceable_id', SERVICE_ID)->first(); \\\$var->value = encrypt('new_value'); \\\$var->save(); \"" ``` ### Docker Commands ```bash # List all containers ssh nuc "docker ps -a --format '{{.Names}}\t{{.Status}}'" # View container logs ssh nuc "docker logs 2>&1 | tail -50" # Restart a container ssh nuc "docker restart " # Execute command in container ssh nuc "docker exec " ``` ## Services & Ports **Preferred access via domain names** (works from anywhere via Tailscale): | Service | Domain | Port-based URL | Container | |---------|--------|----------------|-----------| | NUC Portal | `http://nuc.lan` | - | nuc-portal-* | | Coolify | `http://coolify.nuc.lan` | `http://100.113.153.45:8000` | coolify | | Gitea | `http://gitea.nuc.lan` | `http://100.113.153.45:3030` | gitea-* | | Outline | `http://outline.nuc.lan` | `http://100.113.153.45:3080` | outline-* | | FileBrowser | `http://files.nuc.lan` | `http://100.113.153.45:8085` | filebrowser-* | | Snappymail | `http://mail.nuc.lan` | `http://100.113.153.45:8082` | snappymail-* | | Vaultwarden | `http://vault.nuc.lan` | `http://100.113.153.45:8222` | vaultwarden-* | | Homepage | `http://homepage.nuc.lan` | `http://100.113.153.45:3000` | nuc-portal-* | | NocoDB | - | `http://100.113.153.45:8084` | nocodb-* | | n8n | - | `http://100.113.153.45:5678` | n8n-* | | Ntfy | - | `http://100.113.153.45:8333` | ntfy-* | | MinIO Console | - | `http://100.113.153.45:9001` | minio-* | | MinIO API | - | `http://100.113.153.45:9000` | minio-* | | Authentik | - | `http://100.113.153.45:9090` | authentik-* | | Adminer | - | `http://100.113.153.45:8088` | adminer | | Uptime Kuma | - | `http://100.113.153.45:3001` | uptime-kuma | | Kopia | - | `http://100.113.153.45:51515` | kopia | | Dozzle | - | `http://100.113.153.45:9999` | dozzle | | CloudBeaver | - | `http://100.113.153.45:8978` | cloudbeaver-* | | WhyOps | `http://whyops.nuc.lan` | `http://100.113.153.45:3002` | whyrating-dashboard | | Palmr | `https://alezmad-nuc.tail58f5ad.ts.net:8443` | `http://100.113.153.45:3334` | palmr | | Arrio | `http://arrio.nuc.lan` | `http://100.113.153.45:3335` | web-tgksg0s8gocko4csggs0808c | | Deepgram MCP | - | `http://100.113.153.45:8009` | deepgram-mcp | **Note:** Use Tailscale IP (`100.113.153.45`) instead of `192.168.1.3` to avoid subnet conflicts when remote. ## Port Forwarding Some services use port forwarding containers (alpine/socat or nginx) to expose internal Coolify services: ```bash # Create a port forwarder for a Coolify service ssh nuc "docker run -d --name port-fwd- --network -p : alpine/socat tcp-listen:,fork,reuseaddr tcp-connect::" ``` ## Configuration Files **Homepage Config:** - Location: `/opt/homepage/config/` - Services: `/opt/homepage/config/services.yaml` **Coolify Data:** - Location: `/data/coolify/` ## Important Notes 1. **After Coolify redeploy**: Containers may be in "Created" state - manually start with `docker start ` 2. **Environment Variables in Coolify**: Are encrypted with Laravel encryption. Use `encrypt()` when updating. 3. **HSTS Issues**: Some services send HSTS headers. Use nginx proxy with `proxy_hide_header Strict-Transport-Security;` to strip them. 4. **Network Discovery**: Find container's network with `docker inspect --format '{{.NetworkSettings.Networks}}'` ## Troubleshooting ### Coolify MCP vs Direct Docker **Always verify Coolify status with Docker** - Coolify's status can lag behind actual container state: ```bash # Coolify may show "exited" but container is actually running ssh nuc "docker ps -a --format 'table {{.Names}}\t{{.Status}}' | grep " ``` ### Common Issues 1. **Containers stuck in "Created" state**: After Coolify restart/redeploy, containers may not auto-start ```bash ssh nuc "docker start " ``` 2. **Service shows "running:unknown"**: No healthcheck configured. Add one via Coolify service update: ```yaml healthcheck: test: ["CMD", "wget", "-q", "--spider", "http://localhost:"] interval: 30s timeout: 10s retries: 3 start_period: 30s ``` 3. **Service dependencies not starting**: Services with `depends_on: condition: service_healthy` won't start until dependencies are healthy. Check dependency containers first. 4. **Stale database entries in Coolify**: Coolify may have database/service entries with no corresponding container. Safe to delete if container doesn't exist: ```bash # Verify container doesn't exist ssh nuc "docker ps -a | grep " # Then delete via Coolify MCP or UI ``` 5. **Embedded vs Standalone databases**: Services like Outline and Authentik have their own PostgreSQL containers (e.g., `postgres-pccg80...`) bundled in the service compose. These are separate from standalone Coolify databases. 6. **Wrong healthcheck endpoint**: Some services use `/healthz` instead of `/`. Verify with: ```bash ssh nuc "docker exec wget -qO- http://127.0.0.1:/healthz" ``` 7. **localhost resolves to IPv6 on NUC**: Docker containers bind to IPv4 only. Always use `127.0.0.1` instead of `localhost` in Tailscale Funnel/Serve targets, curl commands inside SSH, etc. 8. **Tailscale Funnel port not working externally**: Funnel ONLY supports ports **443, 8443, and 10000**. Other ports will show as "Funnel on" in status but won't route internet traffic. 9. **Creating API keys when no UI available** (e.g., n8n): ```bash # Stop container, insert directly into SQLite, restart ssh nuc "docker run --rm -v :/data keinos/sqlite3 sqlite3 /data/database.sqlite \"\"" ``` ### Coolify MCP Quick Reference ```python # Check infrastructure overview mcp__coolify__get_infrastructure_overview() # Start/stop/restart service mcp__coolify__control(resource="service", action="start|stop|restart", uuid="") # Get service details (including docker_compose) mcp__coolify__get_service(uuid="") # Update service config (e.g., add healthcheck) mcp__coolify__service(action="update", uuid="", docker_compose_raw="") # Delete stale database mcp__coolify__database(action="delete", uuid="", delete_volumes=True) ``` ## OpenWrt Router The network is managed by an OpenWrt router at `192.168.1.1`. ### SSH Access ```bash # Connect to router ssh -i ~/.ssh/id_ed25519_nuc root@192.168.1.1 # Or via NUC as jump host ssh nuc "ssh root@192.168.1.1 ''" ``` **Router Details:** - IP: `192.168.1.1` - User: `root` - SSH Key: `~/.ssh/id_ed25519_nuc` - Firmware: OpenWrt 23.05.0 - Architecture: ARM Cortex-A9 (mvebu/cortexa9) - LuCI Web UI: `http://192.168.1.1` ### OpenWrt MCP Server An MCP server runs on the router for AI integration: - **Location:** `/opt/mcp-server/openwrt-mcp-server` - **Config:** `/opt/mcp-server/config.toml` - **HTTP API:** `http://192.168.1.1:8090` - **API Token:** `openwrt-mcp-secret-2026` - **Init Script:** `/etc/init.d/mcp-server` ```bash # Control MCP server ssh -i ~/.ssh/id_ed25519_nuc root@192.168.1.1 "/etc/init.d/mcp-server start|stop|restart" ``` ### Common Router Tasks **Port Forwarding:** ```bash # List current port forwards ssh -i ~/.ssh/id_ed25519_nuc root@192.168.1.1 "uci show firewall | grep redirect" # Add port forward ssh -i ~/.ssh/id_ed25519_nuc root@192.168.1.1 " uci add firewall redirect uci set firewall.@redirect[-1].name='' uci set firewall.@redirect[-1].src='wan' uci set firewall.@redirect[-1].src_dport='' uci set firewall.@redirect[-1].dest='lan' uci set firewall.@redirect[-1].dest_ip='' uci set firewall.@redirect[-1].dest_port='' uci set firewall.@redirect[-1].proto='tcp udp' uci set firewall.@redirect[-1].target='DNAT' uci commit firewall /etc/init.d/firewall restart " ``` **Firewall Rules:** ```bash # Show firewall zones ssh -i ~/.ssh/id_ed25519_nuc root@192.168.1.1 "uci show firewall | grep zone" # Allow traffic from WAN to specific port ssh -i ~/.ssh/id_ed25519_nuc root@192.168.1.1 " uci add firewall rule uci set firewall.@rule[-1].name='Allow-' uci set firewall.@rule[-1].src='wan' uci set firewall.@rule[-1].dest_port='' uci set firewall.@rule[-1].proto='tcp' uci set firewall.@rule[-1].target='ACCEPT' uci commit firewall /etc/init.d/firewall restart " ``` **DNS/DHCP (dnsmasq):** ```bash # View DNS/DHCP config ssh -i ~/.ssh/id_ed25519_nuc root@192.168.1.1 "uci show dhcp" # Force DNS cache refresh ssh -i ~/.ssh/id_ed25519_nuc root@192.168.1.1 "/etc/init.d/dnsmasq restart" # Add static DHCP lease ssh -i ~/.ssh/id_ed25519_nuc root@192.168.1.1 " uci add dhcp host uci set dhcp.@host[-1].name='' uci set dhcp.@host[-1].mac='' uci set dhcp.@host[-1].ip='' uci commit dhcp /etc/init.d/dnsmasq restart " # Add custom DNS entry ssh -i ~/.ssh/id_ed25519_nuc root@192.168.1.1 " uci add dhcp domain uci set dhcp.@domain[-1].name='' uci set dhcp.@domain[-1].ip='' uci commit dhcp /etc/init.d/dnsmasq restart " ``` **Network Diagnostics:** ```bash # Check WAN status ssh -i ~/.ssh/id_ed25519_nuc root@192.168.1.1 "ifstatus wan" # View connected clients ssh -i ~/.ssh/id_ed25519_nuc root@192.168.1.1 "cat /tmp/dhcp.leases" # Check routing table ssh -i ~/.ssh/id_ed25519_nuc root@192.168.1.1 "ip route" # View system logs ssh -i ~/.ssh/id_ed25519_nuc root@192.168.1.1 "logread | tail -50" ``` **Package Management (opkg):** ```bash # Update package lists ssh -i ~/.ssh/id_ed25519_nuc root@192.168.1.1 "opkg update" # Install package ssh -i ~/.ssh/id_ed25519_nuc root@192.168.1.1 "opkg install " # List installed packages ssh -i ~/.ssh/id_ed25519_nuc root@192.168.1.1 "opkg list-installed" ``` **Browser Automation (Chrome DevTools MCP):** When SSH commands aren't sufficient, use Chrome DevTools MCP to automate LuCI: ```python # Navigate to router UI mcp__chrome-devtools__navigate_page(type="url", url="http://192.168.1.1/cgi-bin/luci/admin/...") # Take snapshot of UI state mcp__chrome-devtools__take_snapshot() # Interact with elements by uid mcp__chrome-devtools__click(uid="") mcp__chrome-devtools__fill(uid="", value="") ``` ## Authentication **Outline OIDC (via Gitea):** - Client ID: `249a3a1d-92d4-47d8-b4a9-81c64e1da6ab` - Auth URL: `http://192.168.1.3:3030/login/oauth/authorize` - Token URL: `http://192.168.1.3:3030/login/oauth/access_token` - Userinfo URL: `http://192.168.1.3:3030/login/oauth/userinfo` ## ⚠️ Critical Credentials & Access ### CloudBeaver (Database Manager) | Property | Value | |----------|-------| | **URL** | `http://192.168.1.3:8978` | | **Admin User** | `cbadmin` | | **Admin Password** | `CloudBeaver2026!` | | **Service UUID** | `joo4g4k0w08k8kcosgsgswc0` | **Pre-configured connections:** 9 databases across 3 folders. Turbostarter DB is now in service `v4gogwwc8wkk4888ksscc4k4` (container: `db-v4gogwwc8wkk4888ksscc4k4`). Connected to 7 Docker networks for direct container-to-container access. ### Vaultwarden (Password Manager) **⚠️ CRITICAL: Vaultwarden REQUIRES HTTPS** - The Web Crypto API needs a secure context for client-side encryption. HTTP access will NOT work (blank page/loading forever). | Property | Value | |----------|-------| | **HTTPS URL** | `https://nuc-tailscale.tail58f5ad.ts.net:8443` | | **HTTP URL** | `http://192.168.1.3:8222` (won't load - HTTPS required) | | **Admin Email** | `admin@nuc.lan` | | **Admin Password** | `NucVault2026!Secure` | **Access via Tailscale Funnel:** ```bash # Vaultwarden is exposed on port 8443 via Tailscale Funnel open "https://nuc-tailscale.tail58f5ad.ts.net:8443" ``` ### Stalwart Mail Server | Property | Value | |----------|-------| | **Admin URL** | `http://192.168.1.3:8081` | | **Username** | `admin` | | **Password** | `QfKYjCJdxu` | | **Webmail (Snappymail)** | `http://192.168.1.3:8082` | | **Service UUID** | `kw00kok0w0s8gcok008gk04k` | | **MCP Server** | `mcp__stalwart-mail__*` (see Quick Guide above) | **Mail Users:** | Email | Username | Password | |-------|----------|----------| | `info@whyrating.com` | `info` | `whyrating2026` | **DNS Records Configured:** SPF, DKIM (Ed25519 + RSA), DMARC, MX for `whyrating.com` **Container Status:** Running ### Gitea Users (for Outline OIDC) | Username | Password | Notes | |----------|----------|-------| | `nedas` | `NedasNUC2026!` | Regular user account | ## Gitea-Coolify Integration (Git Auto-Deploy) Deploy Next.js apps from self-hosted Gitea with auto-deploy on push. Full docs: `docs/gitea-coolify-auto-deploy.md` ### ⚠️ CRITICAL: Gitea Webhook Allowed Hosts **Gitea blocks webhooks to internal hosts by default!** Before setting up webhooks, configure `ALLOWED_HOST_LIST` in Gitea's app.ini: ```bash # Edit Gitea config ssh nuc "docker exec gitea-ho0cwgcwos88cwc48g84c0g8 vi /data/gitea/conf/app.ini" # Add/modify [webhook] section: [webhook] ALLOWED_HOST_LIST = coolify,10.0.1.5,192.168.1.3,localhost,host.docker.internal,external # Restart Gitea ssh nuc "docker restart gitea-ho0cwgcwos88cwc48g84c0g8" ``` **Without this, webhooks fail with:** `webhook can only call allowed HTTP servers` ### Key References | Resource | Value | |----------|-------| | **Deploy Key UUID** | `akssgwowsccgwgoggs4ks8ck` | | **Gitea Container** | `gitea-ho0cwgcwos88cwc48g84c0g8` | | **Webhook Secret** | `9eb07a77964563378c5d66d99006e06ba3da39d232905d4b12554ff91ca39718` | ### ⚠️ CRITICAL: Webhook Secret Must Be Set in BOTH Places **Auto-deploy won't work unless the webhook secret is configured in BOTH Coolify AND Gitea!** **Step 1: Set secret in Coolify** (via tinker or when creating app): ```bash ssh nuc "docker exec coolify php artisan tinker --execute=\" use App\\Models\\Application; \\\$app = Application::where('uuid', '')->first(); \\\$app->manual_webhook_secret_gitea = '9eb07a77964563378c5d66d99006e06ba3da39d232905d4b12554ff91ca39718'; \\\$app->save(); \"" ``` **Step 2: Set secret in Gitea webhook**: 1. Go to repo → Settings → Webhooks → Add/Edit Webhook 2. Target URL: `http://coolify:8080/webhooks/source/gitea/events/manual?uuid=` 3. Secret: `9eb07a77964563378c5d66d99006e06ba3da39d232905d4b12554ff91ca39718` 4. Trigger: Push Events 5. Active: ✓ **Common symptom when secret is missing:** Git pushes succeed but no deployment is triggered. Check: ```bash # Verify Coolify has the secret ssh nuc "docker exec coolify php artisan tinker --execute=\" use App\\Models\\Application; \\\$app = Application::where('uuid', '')->first(); echo 'Secret: ' . (\\\$app->manual_webhook_secret_gitea ? 'SET' : 'MISSING'); \"" ``` ### Local Development (Clone & Push) SSH config is set up for direct git operations: ```bash # Clone a repo git clone gitea:alezmad/nuc-portal.git # Push changes (triggers auto-deploy via webhook) git push origin main ``` **SSH Host Config** (in `~/.ssh/config`): ``` Host gitea HostName 192.168.1.3 Port 22222 User git IdentityFile ~/.ssh/id_ed25519_nuc ``` ### Webhook URL Format (MUST include UUID and use port 8080!) ``` http://coolify:8080/webhooks/source/gitea/events/manual?uuid= ``` **⚠️ Port 8080 is required** - Coolify listens on 8080 internally (not 8000, which is the external mapping). | App | UUID | Webhook URL | |-----|------|-------------| | nuc-portal | `t80w0cw0oooc4g0soswos4so` | `...?uuid=t80w0cw0oooc4g0soswos4so` | | whyrating-hub | `vw4ggc40socwkgwg4osc8wg8` | `...?uuid=vw4ggc40socwkgwg4osc8wg8` | | whyrating-brand | `r80gk0ccgg0okos8cw848kkk` | `...?uuid=r80gk0ccgg0okos8cw848kkk` | | whyrating-templates | `qw80g4sog0kk8cc4wkcs8sgc` | `...?uuid=qw80g4sog0kk8cc4wkcs8sgc` | ### Quick Deploy (Next.js) ```python # 1. Create application with deploy key mcp__coolify__application( action="create_key", name="my-app", project_uuid="a8484ggc88c40w4g4k004ow0", environment_name="production", server_uuid="qk84w0goo4w48g4ggsoo0oss", git_repository="git@gitea-ho0cwgcwos88cwc48g84c0g8:alezmad/.git", git_branch="main", build_pack="nixpacks", ports_exposes="3000", private_key_uuid="akssgwowsccgwgoggs4ks8ck" ) # 2. Set FQDN ssh nuc "docker exec coolify php artisan tinker --execute=\" use App\Models\Application; \\\$app = Application::where('uuid', '')->first(); \\\$app->fqdn = 'http://.nuc.lan'; \\\$app->custom_labels = null; \\\$app->save(); \"" # 3. Set webhook secret ssh nuc "docker exec coolify php artisan tinker --execute=\" use App\Models\Application; \\\$app = Application::where('uuid', '')->first(); \\\$app->manual_webhook_secret_gitea = '9eb07a77964563378c5d66d99006e06ba3da39d232905d4b12554ff91ca39718'; \\\$app->save(); \"" # 4. Create webhook in Gitea (via browser or API): # URL: http://coolify:8080/webhooks/source/gitea/events/manual?uuid= # (Use port 8080, NOT 8000 - 8080 is the internal container port) # Secret: 9eb07a77964563378c5d66d99006e06ba3da39d232905d4b12554ff91ca39718 # Trigger: Push Events # 5. Deploy mcp__coolify__deploy(tag_or_uuid="") ``` ### Deploy Key (add to each new repo) ``` ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGHtsL3jicJTsBekYuwbKjO0EcRadYKhvLSUw/36XF7h coolify-gitea ``` Add via Gitea: Repository → Settings → Deploy Keys → **Enable Write Access** ✓ ### ✅ New Repo Auto-Deploy Checklist When creating a new repo that should auto-deploy: 1. **[ ] Add deploy key to Gitea repo** - Go to: `http://192.168.1.3:3030/alezmad//settings/keys` - Add the deploy key above with **Write Access** enabled 2. **[ ] Create Coolify application** (use `mcp__coolify__application` with `action="create_key"`) 3. **[ ] Set FQDN** via tinker command 4. **[ ] Set webhook secret** via tinker command (use shared secret above) 5. **[ ] Create Gitea webhook** - Go to: `http://192.168.1.3:3030/alezmad//settings/hooks` - Add Webhook → Gitea - **URL:** `http://coolify:8080/webhooks/source/gitea/events/manual?uuid=` - **Secret:** `9eb07a77964563378c5d66d99006e06ba3da39d232905d4b12554ff91ca39718` - **Trigger:** Push Events - **Active:** ✓ 6. **[ ] Test webhook** - Click "Test Delivery" and verify HTTP 200 response 7. **[ ] Initial deploy** - `mcp__coolify__deploy(tag_or_uuid="")` **Common mistakes:** - ❌ Using port 8000 instead of 8080 in webhook URL - ❌ Forgetting `?uuid=` in webhook URL - ❌ Not enabling Write Access on deploy key ### Quick Verification Commands ```bash # Check Gitea has [webhook] section configured ssh nuc "docker exec gitea-ho0cwgcwos88cwc48g84c0g8 cat /data/gitea/conf/app.ini | grep -A2 '\[webhook\]'" # Test Coolify is reachable from Gitea (should return HTML) ssh nuc "docker exec gitea-ho0cwgcwos88cwc48g84c0g8 wget -qO- --timeout=5 http://coolify:8080/ | head -5" # Check Gitea is on coolify network ssh nuc "docker inspect gitea-ho0cwgcwos88cwc48g84c0g8 --format '{{json .NetworkSettings.Networks}}' | jq -r 'keys[]'" ``` ### Why NOT "Gitea Source" Coolify's "Gitea Source" uses GitHub App-style OAuth with JWT - **this doesn't work with Gitea**. Use deploy keys + manual webhooks instead. ### Current Deployed Apps | App | URL | Repository | UUID | |-----|-----|------------|------| | nuc-portal | http://nuc.lan | `alezmad/nuc-portal` | `t80w0cw0oooc4g0soswos4so` | | whyrating-hub | http://whyrating.nuc.lan | `alezmad/whyrating-hub` | `vw4ggc40socwkgwg4osc8wg8` | | whyrating-brand | http://brand.nuc.lan | `alezmad/whyrating-brand` | `r80gk0ccgg0okos8cw848kkk` | | whyrating-templates | http://templates.nuc.lan | `alezmad/whyrating-templates` | `qw80g4sog0kk8cc4wkcs8sgc` | | turbostarter | https://alezmad-nuc.tail58f5ad.ts.net | `alezmad/turbostarter` | `v4gogwwc8wkk4888ksscc4k4` (service) | | arrio | http://arrio.nuc.lan | `alezmad/arrio` | `tgksg0s8gocko4csggs0808c` (service) | ### Turbostarter (Knosia) - Build & Deploy Turbostarter is deployed as a **Coolify Service** (not a standalone app) with full docker-compose infrastructure: web + pgvector + minio. **Architecture:** Tailscale Funnel (HTTPS) → Traefik (HTTP:80) → web container **FQDN (Traefik):** `http://alezmad-nuc.tail58f5ad.ts.net` (HTTP internally — Tailscale handles TLS termination) **Build & Deploy workflow:** ```bash # 1. Build image locally (ARM→AMD cross-compile) cd /Users/agutierrez/Desktop/turbostarter-export docker build --platform linux/amd64 \ --build-arg NEXT_PUBLIC_URL=https://alezmad-nuc.tail58f5ad.ts.net \ -t 192.168.1.3:3030/alezmad/turbostarter:latest . # 2. Push to Gitea registry docker push 192.168.1.3:3030/alezmad/turbostarter:latest # 3. Redeploy via Coolify (stop + start for full container recreation) mcp__coolify__control(resource="service", action="stop", uuid="v4gogwwc8wkk4888ksscc4k4") mcp__coolify__control(resource="service", action="start", uuid="v4gogwwc8wkk4888ksscc4k4") ``` **Containers:** | Container | Image | Purpose | |-----------|-------|---------| | `web-v4gogwwc8wkk4888ksscc4k4` | `localhost:3030/alezmad/turbostarter:latest` | Next.js app | | `db-v4gogwwc8wkk4888ksscc4k4` | `pgvector/pgvector:pg17` | PostgreSQL + pgvector | | `minio-v4gogwwc8wkk4888ksscc4k4` | `minio/minio:latest` | Object storage | | `minio-init-v4gogwwc8wkk4888ksscc4k4` | `minio/mc:latest` | One-time bucket init | **Database access (via SSH tunnel):** ```bash # Get DB container IP first ssh nuc "docker inspect db-v4gogwwc8wkk4888ksscc4k4 --format '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}'" # Then tunnel to IP (not container name) ssh -L 5440::5432 nuc # Connect: postgres://turbostarter:turbostarter@localhost:5440/core ``` **Seeded users:** `me+admin@turbostarter.dev` / `Pa$$w0rd` (admin), `me+user@turbostarter.dev` / `Pa$$w0rd` **Key env vars:** - `BETTER_AUTH_TRUSTED_ORIGINS` — comma-separated list of allowed origins (CSRF protection) - `NEXT_PUBLIC_URL` — build-time arg baked into Next.js static output (must rebuild to change) - `DATABASE_URL` — internal docker network connection to pgvector ### New Site from nuc-portal Template To create a new Next.js site using nuc-portal as a base: ```bash # 1. Copy and clean cp -r /path/to/nuc-portal /path/to/new-site cd /path/to/new-site rm -rf .git .next node_modules # 2. Update package.json (name, description) # 3. Customize src/app/page.tsx # 4. Remove unused components if simplifying # 5. Initialize and push npm install && npm run build # verify it builds git init && git add -A && git commit -m "Initial commit" # Create repo in Gitea first, then: git remote add origin gitea:alezmad/.git git push -u origin main ``` Then follow the "New Repo Auto-Deploy Checklist" above. ## Public Access & Security Architecture **Full architecture details:** `docs/architecture.md` ### Why Tailscale Funnel (Not Cloudflare) Cloudflare shared IPs get blocked by Spanish ISPs during LaLiga matches. Tailscale Funnel: - Uses different IP infrastructure (not blocked) - Handles dynamic ISP IP changes automatically - No ports exposed on router - HTTPS termination included ### Tailscale Funnel (Public Access) | Property | Value | |----------|-------| | **Funnel URL** | `https://nuc-tailscale.tail58f5ad.ts.net` | | **Tailscale IP** | `100.x.x.x` (stable, never changes) | | **Status** | `ssh nuc "tailscale funnel status"` | **⚠️ CRITICAL: Funnel only supports ports 443, 8443, and 10000.** Other ports will appear to work locally (`tailscale funnel status` shows them) but traffic will NOT route from the public internet. Always use one of these three ports. **⚠️ CRITICAL: Always use `127.0.0.1`, NOT `localhost`** in Funnel/Serve targets. On the NUC, `localhost` resolves to IPv6 (`::1`) but Docker containers bind to IPv4 only, causing connection resets. **Start Funnel for a service:** ```bash # Expose port 3000 via Funnel (use 127.0.0.1, NOT localhost!) ssh nuc "printf '\n' | sudo -S tailscale funnel --bg --https=8443 http://127.0.0.1:3000" ``` **Current Funnel allocation:** | Port | Service | Target | |------|---------|--------| | 443 | Traefik (main) | `http://192.168.1.3:80` | | 8443 | Palmr (file sharing) | `http://127.0.0.1:3334` | | 10000 | Palmr MinIO (uploads) | `http://127.0.0.1:9379` | ### Current Domain Routes | Domain | Destination | Method | |--------|-------------|--------| | whyrating.com | `nuc-tailscale.tail58f5ad.ts.net` | Namecheap 301 redirect | ### Adding a New Domain 1. **Check availability** (if needed): ```python mcp__namecheap__namecheap_check_domain_availability(domains=["example.com"]) ``` 2. **Configure redirect at registrar** (Namecheap): - Go to Domain List → Manage → Redirect Domain - Source: `example.com` - Destination: `https://nuc-tailscale.tail58f5ad.ts.net` - Type: Permanent (301) 3. **Start Funnel** on NUC for the target port ### Security Layers ``` Internet → Tailscale Funnel (HTTPS) → CrowdSec → Traefik → Container ↓ Blocks malicious IPs ``` | Layer | Purpose | |-------|---------| | **Tailscale Funnel** | Only entry point, HTTPS termination | | **CrowdSec** | DDoS protection, threat intelligence | | **Traefik** | Domain routing, rate limiting | | **Docker Networks** | Container isolation | ### Tailscale Mesh (Admin Access) Private encrypted access from anywhere: | From | Command | |------|---------| | **Remote SSH** | `ssh nuc-tailscale` | | **Remote Coolify** | `http://nuc-tailscale:8000` | | **Home network** | `ssh nuc` or `http://192.168.1.3:8000` | **NOT exposed to internet:** SSH (22), Coolify (8000), databases, MinIO, Authentik, router admin. ### Dynamic IP Handling ISP can change your public IP anytime. Tailscale handles this automatically: - Tailscale IP (100.x.x.x) stays stable - Funnel URL stays stable - Domain redirects stay stable - Tunnels auto-reconnect in ~10-30 seconds ## Artifacts Folder **Location:** `.artifacts/` This folder stores important information artifacts that should be preserved for future reference. Claude should proactively save artifacts here when relevant information is generated during sessions. ### Naming Convention Files must use datetime-prefixed names: ``` YYYY-MM-DD_HH-MM_.md ``` Examples: - `2026-02-01_13-45_coolify-api-token.md` - `2026-02-01_14-30_n8n-mcp-setup.md` - `2026-02-01_15-00_service-health-report.md` ### When to Save Artifacts **Always save artifacts for:** - API tokens, keys, or credentials generated during sessions - Configuration changes made to services - Troubleshooting steps that resolved issues - Infrastructure changes or deployments - MCP server configurations and setup details - Database schema changes or migrations - Important command outputs that may be needed later - Service health reports or diagnostics ### Artifact Format ```markdown # **Date:** YYYY-MM-DD HH:MM **Context:** <Brief description of what was being done> ## Details <Relevant information, configs, tokens, commands, etc.> ## Related - <Links to services, docs, or other artifacts> ``` ## Publishing JSX/React Artifacts Online Single-file React components (JSX) can be published as standalone web pages via the NUC's artifacts infrastructure. ### How It Works ``` Public Internet → Tailscale Funnel (:443) → Traefik → artifacts-web (nginx) → /opt/artifacts/ ``` - **Funnel URL:** `https://alezmad-nuc.tail58f5ad.ts.net/artifacts/<name>/` - **LAN URL:** `https://artifacts.nuc.lan/<name>/` - **Nginx container:** `artifacts-web` (image: `nginx:alpine`, read-only mount of `/opt/artifacts`) - **Traefik public route:** `Host(alezmad-nuc.tail58f5ad.ts.net) && PathPrefix(/artifacts)` → strips `/artifacts` → `artifacts-web:80` - **Config file:** `/traefik/dynamic/nuc-services-public.yaml` (inside `coolify-proxy` container) ### Quick Publish Steps ```bash # 1. Build self-contained HTML from JSX # - Replace `import { useState, ... } from "react"` with `const { useState, ... } = React;` # - Remove `export default ComponentName;` # - Wrap in HTML with React 18 CDN + Babel standalone # - Add `ReactDOM.createRoot(root).render(<Component />)` at the end # 2. Copy to NUC artifacts directory ssh nuc "echo '7vXHpSTD.' | sudo -S mkdir -p /opt/artifacts/<name>" scp /tmp/build/index.html nuc:~/tmp-artifact.html ssh nuc "echo '7vXHpSTD.' | sudo -S mv ~/tmp-artifact.html /opt/artifacts/<name>/index.html" ssh nuc "echo '7vXHpSTD.' | sudo -S chmod 644 /opt/artifacts/<name>/index.html" # 3. Done! No server restart needed — nginx serves it immediately. ``` ### HTML Template for JSX Files ```html <!DOCTYPE html> <html lang="es"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>TITLE
``` ### Build Script (for large JSX files) ```bash # Automated: strips import/export, wraps in HTML cat > /tmp/build.html << 'HEADER'
FOOTER ``` ### Currently Published | Path | Source | Public URL | |------|--------|------------| | `/opt/artifacts/checkin/` | `arrio/.scratch/checkin_demo_v1.jsx` | `https://alezmad-nuc.tail58f5ad.ts.net/artifacts/checkin/` | ## OpenWrt Interaction Methods (Quick Reference) | Method | When to Use | Example | |--------|-------------|---------| | **SSH** | Direct commands, config changes, package management | `ssh -i ~/.ssh/id_ed25519_nuc root@192.168.1.1 "uci show network"` | | **OpenWrt MCP** | AI-driven automation, status queries | `curl -H "x-api-token: openwrt-mcp-secret-2026" http://192.168.1.1:8090/status` | | **Chrome DevTools MCP** | LuCI UI automation when no CLI/API exists | `mcp__chrome-devtools__navigate_page(url="http://192.168.1.1/...")` | | **LuCI Web UI** | Manual configuration, visual inspection | `http://192.168.1.1` (user: root) | **Priority Order:** SSH > OpenWrt MCP > Chrome DevTools > Manual UI ## Palmr (File Sharing - Dropbox Alternative) Self-hosted file sharing platform (like WeTransfer/Dropbox) for sending and receiving files. ### Access | Property | Value | |----------|-------| | **Public URL** | `https://alezmad-nuc.tail58f5ad.ts.net:8443` | | **Local URL** | `http://192.168.1.3:3334` | | **Login page** | `/login` | | **Container** | `palmr` | | **Image** | `kyantech/palmr:latest` | | **Network** | `coolify` (IP: `10.0.1.5`) | | **Sudo pass** | `7vXHpSTD.` | ### Users | Username | Email | Password | Admin | |----------|-------|----------|-------| | `alezmad` | `agutierrez@mineryreport.com` | *(set via UI)* | Yes | | `michi` | `michi@nuc.lan` | `FlexiCar2025.` | No | ### Architecture ``` External Browser → Funnel :8443 → 127.0.0.1:3334 → container:5487 (Next.js frontend) Funnel :10000 → 127.0.0.1:9379 → container:9379 (MinIO uploads) ↑ via port-fwd-palmr-minio ``` Palmr uses **presigned URLs** for file uploads — the browser uploads directly to MinIO, NOT through the backend. This means MinIO MUST be reachable from the client's browser. ### Key Environment Variables | Variable | Value | Purpose | |----------|-------|---------| | `STORAGE_URL` | `https://alezmad-nuc.tail58f5ad.ts.net:10000` | Public MinIO URL for presigned upload URLs | | `API_BASE_URL` | `http://127.0.0.1:3333` | Internal backend API (Next.js → Fastify) | ### Volume Mounts | Volume | Mount Point | Contents | |--------|-------------|----------| | `5273abd0c...` (anonymous) | `/app/server` | SQLite DB, MinIO data, credentials | | `palmr_uploads` | `/app/uploads` | Uploaded files | ### Container Recreation ```bash ssh nuc "docker stop palmr && docker rm palmr && docker run -d \ --name palmr \ --network coolify \ --restart unless-stopped \ -p 3334:5487 \ -v 5273abd0c536116056362397cdb568d2eab066b8289412dd91ecce58c174df68:/app/server \ -v palmr_uploads:/app/uploads \ -e STORAGE_URL=https://alezmad-nuc.tail58f5ad.ts.net:10000 \ kyantech/palmr:latest" ``` ### Port Forwarder (MinIO) MinIO runs inside the Palmr container on port 9379 (not exposed). A socat forwarder bridges it: ```bash ssh nuc "docker run -d --name port-fwd-palmr-minio --network coolify -p 9379:9379 \ alpine/socat tcp-listen:9379,fork,reuseaddr tcp-connect:palmr:9379" ``` ### Database Access Palmr uses SQLite at `/app/server/prisma/palmr.db`. Passwords are **bcrypt** hashed. ```bash # Copy DB locally for queries ssh nuc "docker cp palmr:/app/server/prisma/palmr.db /tmp/palmr.db" scp nuc:/tmp/palmr.db /tmp/palmr.db sqlite3 /tmp/palmr.db "SELECT username, email, isAdmin FROM users;" # Generate bcrypt hash inside container ssh nuc "docker exec palmr sh -c 'cd /app/palmr-app && node -e \"const bcrypt = require(\\\"bcryptjs\\\"); console.log(bcrypt.hashSync(\\\"PASSWORD\\\", 10));\"'" ``` ### Troubleshooting 1. **Upload stuck on loader**: Check `docker logs palmr` for `STORAGE_URL` errors. MinIO must be reachable from the client browser via the presigned URL. 2. **Blank page externally**: Verify Funnel is on a supported port (443/8443/10000) and target uses `127.0.0.1` not `localhost`. 3. **JWT token invalid after restart**: Expected — users must log in again after container recreation (JWT secret regenerated). 4. **Config limits**: File size and storage limits are in `app_configs` table (values in bytes). Update via SQLite, copy DB back, restart. ## MinIO Object Storage (Mac ↔ NUC File Transfer) ### Architecture MinIO runs on the NUC as S3-compatible object storage. Used for Mac backups, file transfers, and app storage. ``` Mac (mc CLI) → Tailscale → 100.113.153.45:9000 → port-fwd (socat) → minio-xwowg8kswwsocssgocs8ss40:9000 ``` ### Access | Property | Value | |----------|-------| | **API URL** | `http://100.113.153.45:9000` | | **Console URL** | `http://100.113.153.45:9001` | | **Access Key** | `minioadmin` | | **Secret Key** | `minioadmin` | | **Port Forwarder** | `minio-port-fwd` (socat, maps host :9000/:9001 → container) | | **Main Container** | `minio-xwowg8kswwsocssgocs8ss40` | ### Buckets | Bucket | Purpose | |--------|---------| | `mac-backups` | Database dumps, tar archives, important backups | | `mac-downloads` | Files moved from ~/Downloads for archival | | `mac-projects` | Archived project files | | `nuc-portal-previews` | NUC Portal screenshot previews | | `whyrating` | WhyRating app assets | ### Mac CLI (mc) Installed at `/opt/homebrew/bin/mc`, alias `nuc` pre-configured. ```bash # List buckets mc ls nuc/ # Upload file mc cp file.tar nuc/mac-backups/ # Download file mc cp nuc/mac-backups/file.tar ./ # Sync a directory mc mirror ~/path/to/dir nuc/mac-projects/dirname/ # Check bucket size mc du nuc/mac-backups ``` ### nuc-sync CLI Tool Wrapper script at `~/.local/bin/nuc-sync` for daily operations: ```bash nuc-sync status # Check NUC + MinIO connectivity nuc-sync upload file.tar # Upload to mac-backups bucket nuc-sync upload file.sql mac-downloads # Upload to specific bucket nuc-sync push ~/Desktop/project # rsync to NUC (excludes node_modules, .next, .git) nuc-sync pull /opt/backups/mac/x . # rsync from NUC nuc-sync list # List all buckets nuc-sync list mac-backups # List bucket contents ``` ### MinIO MCP Server MCP server for Claude Code integration: `minio-nuc` - **Source:** `~/mcp-servers/minio-mcp-server/` - **GitHub:** `https://github.com/Rafitis/minio-mcp-server` - **Scope:** user (available in all projects) - **Tools:** list buckets, upload/download objects, create/delete buckets ### rsync Backup Directory ```bash # NUC backup path (owned by alezmad) /opt/backups/mac/ ``` ### Troubleshooting 1. **MinIO unreachable (connection refused)**: The port forwarder may be down: ```bash ssh nuc "docker start minio-port-fwd" ``` 2. **mc alias not working**: Reconfigure: ```bash /opt/homebrew/bin/mc alias set nuc http://100.113.153.45:9000 minioadmin minioadmin ``` 3. **Console not loading**: Port 9001 needs the port-fwd container running. Check with: ```bash ssh nuc "docker ps | grep minio-port-fwd" ``` ## Next Steps / Migration Candidates ### Priority 1: Safe to Delete (Duplicates/Old Versions) | Image | Size | Action | Reason | |-------|------|--------|--------| | `google-reviews-scraper-pro-api` (old) | 3.62GB | ❌ DELETE | Old version, newer exists | | `claudefarm-claudefarm` | 3.87GB | ❌ DELETE | Replaced by claudefarm-browser + claudefarm-api | | `postgres:16` | 657MB | ❌ DELETE | Using `16-alpine` (389MB) instead | | `prom/mysqld-exporter:v0.14.0` | 28MB | ❌ DELETE | 3 years old, likely unused | **Savings: ~8.2GB** ### Priority 2: Migrate to NUC (High Value) | Image | Size | Priority | Notes | |-------|------|----------|-------| | `nocodb/nocodb` | 1.24GB | ⭐ HIGH | Airtable alternative - great for self-hosted data | | `grafana/grafana` | 932MB | ⭐ HIGH | Pairs with existing Uptime Kuma for monitoring | | `prom/prometheus` | 479MB | ⭐ HIGH | Metrics backend for Grafana | | `timescale/timescaledb` | 1.45GB | ⭐ HIGH | Time-series data, useful for IoT/metrics | ### Priority 3: Migrate to NUC (Medium Value) | Image | Size | Priority | Notes | |-------|------|----------|-------| | `mysql:8` | 1.07GB | 🔶 MEDIUM | Only if you have MySQL-specific apps | | `minio/minio + minio/mc` | 340MB | 🔶 SKIP | Already running on NUC via Coolify | ### Priority 4: MCP Tools - Evaluate Usage | Image | Size | Recommendation | Notes | |-------|------|----------------|-------| | `mcp/n8n` | 675MB | 🔶 SKIP | n8n already on NUC; this is just MCP wrapper | | `mcp/youtube-transcript` | 321MB | ✅ KEEP LOCAL | Useful for AI workflows | | `mcp/context7` | 423MB | ✅ KEEP LOCAL | Documentation lookup, AI essential | | `mcp/fetch` | ? | ✅ KEEP LOCAL | Web fetching for AI | ### Priority 5: Review Before Deleting | Image | Size | Action | Why Review | |-------|------|--------|------------| | `mysql:8` | 1.07GB | ⚠️ CHECK | May have local databases; verify before delete | | `timescale/timescaledb` | 1.45GB | ⚠️ CHECK | May have local time-series data | ### Recommended Coolify Deployments ```bash # 1. NocoDB (Airtable alternative) mcp__coolify__service(action="create", type="nocodb", name="NocoDB", server_uuid="qk84w0goo4w48g4ggsoo0oss", project_uuid="a8484ggc88c40w4g4k004ow0", environment_name="production", instant_deploy=True) # 2. Prometheus + Grafana stack mcp__coolify__service(action="create", type="grafana", ...) mcp__coolify__service(action="create", type="prometheus", ...) ``` ### Migration Checklist - [ ] Delete old/duplicate images locally - [ ] Deploy NocoDB to NUC - [ ] Deploy Grafana + Prometheus monitoring stack - [ ] Consider TimescaleDB if IoT/metrics needed - [ ] Verify MySQL data before deleting - [ ] Add CloudBeaver to Uptime Kuma monitoring - [ ] Configure OpenWrt MCP MQTT broker (optional) ## OpenClaw (AI Assistant Gateway) Self-hosted AI assistant gateway running on the NUC via Docker Compose. Connects to messaging platforms (WhatsApp, Telegram, Discord, etc.) and routes messages through Claude. ### Access | Property | Value | |----------|-------| | **Control UI (HTTPS)** | `https://alezmad-nuc.tail58f5ad.ts.net:8443` | | **Gateway WS** | `ws://192.168.1.3:18789` | | **Gateway Token** | `3547c3f2b7b4a33eb077cf804bcca446057f81ba1578b2045dbb3aa4e04346ee` | | **Model** | `anthropic/claude-sonnet-4-5-20250929` | | **Repo on NUC** | `~/openclaw/` | | **Config** | `~/.openclaw/openclaw.json` | | **Version** | `2026.2.10` | **⚠️ HTTPS Required:** The Control UI requires a secure context (HTTPS or localhost). Access via Tailscale Serve on port 8443. ### Tailscale Serve (HTTPS access) The gateway is exposed via Tailscale Serve (not Funnel - tailnet only, not public): ```bash # Start HTTPS proxy (requires sudo, must run from NUC terminal) ssh nuc sudo tailscale serve --bg --https=8443 http://localhost:18789 # Password: 7vXHpSTD ``` **⚠️ The `--bg` flag makes it persistent.** Without it, Ctrl+C stops the proxy. ### Docker Compose Management ```bash # Start gateway ssh nuc "cd ~/openclaw && docker compose up -d openclaw-gateway" # Restart gateway ssh nuc "cd ~/openclaw && docker compose restart openclaw-gateway" # View logs ssh nuc "docker logs openclaw-openclaw-gateway-1 2>&1 | tail -30" # Run CLI commands (use docker exec for commands that need gateway connection) ssh nuc "docker exec openclaw-openclaw-gateway-1 node dist/index.js --token 3547c3f2b7b4a33eb077cf804bcca446057f81ba1578b2045dbb3aa4e04346ee --url ws://127.0.0.1:18789" # Run CLI commands that don't need gateway (use docker compose run) ssh nuc "cd ~/openclaw && script -qc 'docker compose run --rm openclaw-cli ' /dev/null" ``` ### Device Pairing When the Control UI shows "pairing required", approve the pending device: ```bash # List pending devices ssh nuc "docker exec openclaw-openclaw-gateway-1 node dist/index.js devices list --token 3547c3f2b7b4a33eb077cf804bcca446057f81ba1578b2045dbb3aa4e04346ee --url ws://127.0.0.1:18789" # Approve a device (use requestId from the list) ssh nuc "docker exec openclaw-openclaw-gateway-1 node dist/index.js devices approve --token 3547c3f2b7b4a33eb077cf804bcca446057f81ba1578b2045dbb3aa4e04346ee --url ws://127.0.0.1:18789" ``` **Dashboard URL with embedded token (auto-authenticates):** ``` https://alezmad-nuc.tail58f5ad.ts.net:8443/#token=3547c3f2b7b4a33eb077cf804bcca446057f81ba1578b2045dbb3aa4e04346ee ``` ### Channel Plugins Channels are plugins that must be enabled before use. 35 available, 4 loaded by default. ```bash # List all plugins ssh nuc "script -qc 'cd ~/openclaw && docker compose run --rm openclaw-cli plugins list' /dev/null" # Enable a channel plugin ssh nuc "script -qc 'cd ~/openclaw && docker compose run --rm openclaw-cli plugins enable ' /dev/null" # Then restart gateway ssh nuc "cd ~/openclaw && docker compose restart openclaw-gateway" ``` **Currently enabled channels:** WhatsApp (linked and active) **Available channel plugins:** | Plugin ID | Channel | |-----------|---------| | `whatsapp` | WhatsApp | | `telegram` | Telegram | | `discord` | Discord | | `slack` | Slack | | `signal` | Signal | | `matrix` | Matrix | | `msteams` | Microsoft Teams | | `googlechat` | Google Chat | | `imessage` | iMessage | | `irc` | IRC | ### Setting Up Channels That Require QR Codes (WhatsApp, etc.) **⚠️ The CLI needs a TTY for QR display.** Cannot run directly via `ssh nuc "command"`. Use `script` to fake a TTY and capture output: ```bash # Step 1: Enable the plugin ssh nuc "script -qc 'cd ~/openclaw && docker compose run --rm openclaw-cli plugins enable whatsapp' /dev/null" # Step 2: Restart gateway ssh nuc "cd ~/openclaw && docker compose restart openclaw-gateway" # Step 3: Run login with TTY capture (captures QR to file) ssh nuc "script -q /tmp/openclaw-qr.txt -c 'cd ~/openclaw && docker compose run --rm openclaw-cli channels login'" # Step 4: If QR needs to be viewed remotely, copy and render as image scp nuc:/tmp/openclaw-qr.txt /tmp/ # Then use Python + Pillow to convert Unicode block chars to PNG ``` **QR to PNG conversion (run locally on Mac):** ```python from PIL import Image import re with open("/tmp/openclaw-qr.txt", "rb") as f: content = f.read().decode("utf-8", errors="replace") lines = content.split("\n") qr_lines = [] for l in lines: clean = re.sub(r"\x1b\[[0-9;]*[a-zA-Z]", "", l).replace(chr(0), "") if any(c in clean for c in ["\u2584", "\u2588", "\u2580"]): qr_lines.append(clean) scale = 10 width = max(len(l) for l in qr_lines) height = len(qr_lines) * 2 img = Image.new("RGB", (width * scale, height * scale), "white") for row_idx, line in enumerate(qr_lines): for col_idx, ch in enumerate(line): top_black = ch in ["\u2588", "\u2580"] bot_black = ch in ["\u2588", "\u2584"] for dy in range(scale): for dx in range(scale): if top_black: img.putpixel((col_idx*scale+dx, row_idx*2*scale+dy), (0,0,0)) if bot_black: img.putpixel((col_idx*scale+dx, (row_idx*2+1)*scale+dy), (0,0,0)) img.save("/tmp/qr.png") ``` **Alternative: User scans directly** — If the user has SSH terminal access, they can run `channels login` interactively and scan the QR from their terminal. ### Anthropic Authentication Uses a Claude Code OAuth token (valid 1 year). Set in both config and docker-compose env: **Config (`~/.openclaw/openclaw.json`):** ```json { "env": { "ANTHROPIC_API_KEY": "sk-ant-oat01-..." } } ``` **Docker env (`~/openclaw/.env`):** ``` ANTHROPIC_API_KEY=sk-ant-oat01-... ``` **To regenerate token (run on Mac where `claude` CLI is installed):** ```bash claude setup-token # Copy the output token and update both config + .env on NUC ``` ### Config Schema (current) ```json { "agents": { "defaults": { "model": { "primary": "anthropic/claude-sonnet-4-5-20250929" } } }, "gateway": { "port": 18789, "mode": "local", "bind": "lan", "auth": { "mode": "token" } } } ``` **⚠️ Config gotchas:** - `agent.model` is **legacy** — use `agents.defaults.model.primary` instead - Run `docker exec openclaw-openclaw-gateway-1 node dist/index.js doctor --fix` to migrate legacy keys - The `gateway.pairing` key does NOT exist — device pairing is managed via the `devices` CLI, not config ### Troubleshooting 1. **"control ui requires HTTPS or localhost"**: Access via `https://alezmad-nuc.tail58f5ad.ts.net:8443` (Tailscale Serve), NOT `http://192.168.1.3:18789` 2. **"pairing required"**: Approve the device via `devices approve` command (see Device Pairing section above) 3. **"unauthorized: gateway token missing"**: Use the dashboard URL with `#token=...` hash to auto-authenticate 4. **CLI commands fail with "gateway closed"**: Use `docker exec` into the running gateway container instead of `docker compose run` (the CLI container can't reach the gateway on its internal Docker IP) 5. **Config "invalid" after edit**: Run `doctor --fix` inside the gateway container to clean up 6. **Channel "unsupported"**: Enable the plugin first with `plugins enable `, then restart gateway 7. **CLAUDE_AI_SESSION_KEY warnings**: Harmless — these are for Claude web session auth which isn't used when using API key